Case Study: LCS trong git diff & Huffman trong gzip
git diff dùng LCS/Myers để tìm minimal diff, gzip/DEFLATE dùng Huffman coding để nén theo tần suất ký tự — hai công cụ hàng ngày ẩn chứa DP cổ điển.
TL;DR: Mỗi lần bạn chạy git diff hay nén file .gz, 2 thuật toán DP chạy ngầm: (A) git diff dùng Myers algorithm — variant của LCS (Longest Common Subsequence) — để tìm dòng chung dài nhất giữa 2 phiên bản file, từ đó tạo minimal edit script hiển thị +/-. (B) gzip (DEFLATE) dùng Huffman coding — cây nhị phân tối ưu theo tần suất ký tự — để gán mã ngắn hơn cho ký tự xuất hiện nhiều, tiết kiệm 40-70% kích thước so với ASCII fixed-width 8-bit.
Module 1 đã xây nền DP: subproblem, memoization, transition. Bài này không dạy thêm kỹ thuật mới — nó cho thấy 2 bài toán production thực tế là DP, giải thích tại sao DP là lựa chọn đúng, và đọc cơ chế đủ sâu để bạn hiểu output hàng ngày (dấu +/- của diff, tỷ lệ nén gzip) xuất phát từ đâu.
Phần A — LCS trong git diff và Myers algorithm
1. Vì sao diff = bài toán LCS
Khi bạn sửa 1 file và chạy git diff, output hiển thị dòng nào bị xoá (-) và dòng nào được thêm (+). Câu hỏi nền tảng: git quyết định dòng nào là "chung" (unchanged) và dòng nào là "thay đổi" như thế nào?
Xét 2 phiên bản file:
-- File A (cũ): -- File B (mới):
line 1: "apple" line 1: "apple"
line 2: "banana" line 2: "cherry"
line 3: "cherry" line 3: "date"
line 4: "date"
Nếu ta tìm dãy dòng chung dài nhất (LCS) giữa A và B, ta được ["apple", "cherry", "date"] — 3 dòng chung. Từ đó:
- Dòng trong A nhưng không trong LCS → bị xoá (dấu
-). - Dòng trong B nhưng không trong LCS → được thêm (dấu
+).
git diff output:
apple
- banana
cherry
date
LCS chính là "bộ khung chung" của 2 file — phần không thay đổi. Phần thay đổi (edit) = tổng dòng ngoài LCS. Minimal diff = minimal edit = LCS dài nhất.
2. LCS là DP cổ điển
Subproblem: lcs[i][j] = độ dài LCS của A[0..i-1] và B[0..j-1].
lcs(A, B):
n <- A.length
m <- B.length
-- Khởi tạo bảng (n+1) x (m+1), base case = 0
dp[0..n][0..m] <- 0
for i from 1 to n:
for j from 1 to m:
if A[i-1] = B[j-1]:
dp[i][j] <- dp[i-1][j-1] + 1 -- dòng khớp, kéo dài LCS
else:
dp[i][j] <- max(dp[i-1][j], dp[i][j-1]) -- bỏ dòng A hoặc dòng B
return dp[n][m]
-- Time: O(n*m) Space: O(n*m)
Transition max(dp[i-1][j], dp[i][j-1]) — bỏ dòng A i hoặc dòng B j, chọn cái nào giữ được LCS dài hơn.
Lưu ý quan trọng: "dòng chung" ở đây nghĩa là nội dung dòng giống hệt nhau, không phải vị trí dòng. Git so sánh nội dung text, không phải số thứ tự dòng.
graph LR
subgraph "Bảng LCS — A=['apple','banana','cherry','date'], B=['apple','cherry','date']"
R0[" | ε | a | c | d"]
R1["ε | 0 | 0 | 0 | 0"]
R2["a | 0 | 1 | 1 | 1"]
R3["b | 0 | 1 | 1 | 1"]
R4["c | 0 | 1 | 2 | 2"]
R5["d | 0 | 1 | 2 | 3"]
enddp[4][3] = 3 — LCS là ["apple","cherry","date"], đúng với 3 dòng unchanged.
3. Myers algorithm — git dùng gì thực tế
Git không dùng LCS DP O(n×m) thuần túy — file thực tế có hàng nghìn dòng, O(n×m) quá chậm. Git dùng Myers diff algorithm (1986) — tìm minimal edit script trên "edit graph":
Edit graph: đỉnh (i, j) = đã xét i dòng của A và j dòng của B
Cạnh ngang (i,j) → (i, j+1): insert dòng B[j] (cost 1)
Cạnh dọc (i,j) → (i+1, j): delete dòng A[i] (cost 1)
Cạnh chéo (i,j) → (i+1,j+1): match (A[i]=B[j]) (cost 0)
Minimal diff = đường đi từ (0,0) đến (n,m) với số cạnh có cost nhỏ nhất.
Myers tìm đường này bằng BFS theo "edit distance" (d = số cạnh cost-1), mở rộng theo diagonal "snake" (chuỗi cạnh chéo liên tiếp). Complexity: O(n + m + D²) với D là edit distance thực tế — thường D rất nhỏ so với n, m → nhanh hơn LCS DP nhiều lần.
graph TD
subgraph "Edit graph (đơn giản 3 dòng)"
N00["(0,0)"] -- "chéo: match apple" --> N11["(1,1)"]
N11 -- "dọc: delete banana" --> N21["(2,1)"]
N21 -- "chéo: match cherry" --> N32["(3,2)"]
N32 -- "chéo: match date" --> N43["(3,3) ✓"]
endPath (0,0) → (1,1) → (2,1) → (3,2) → (3,3): 1 cạnh dọc (delete "banana"), 3 cạnh chéo (match) → D=1, minimal.
4. Tại sao cùng file đôi khi git diff cho kết quả "lạ"
Khi bạn di chuyển một function lớn xuống cuối file, git diff có thể hiển thị nó như "xoá ở chỗ cũ, thêm ở chỗ mới" thay vì "moved". Lý do: LCS/Myers tối ưu theo số dòng thay đổi tối thiểu, không có khái niệm "move". Thuật toán nhìn thấy dòng giống nhau ở vị trí mới và cố gắng "match", nhưng với function dài, nhiều dòng khác xung quanh có thể "chen" vào LCS trước — kết quả diff trông rối.
git diff -W (extended context) và git log -S "pattern" (pickaxe search) là công cụ tốt hơn khi cần trace code movement qua history.
Phần B — Huffman coding trong gzip/DEFLATE
5. Vấn đề: ASCII cố định 8 bit, lãng phí
ASCII dùng 8 bit cho mọi ký tự — từ e (xuất hiện ~12% trong tiếng Anh) đến z (dưới 1%). Không ký tự nào được ưu tiên — cùng 8 bit cho dù xuất hiện 1 lần hay 1 triệu lần.
File text 1 MB = 8 triệu bit. Nếu ta dùng mã ngắn hơn cho ký tự phổ biến và mã dài hơn cho ký tự hiếm — tổng bit giảm xuống.
Đây là ý tưởng của Huffman coding (1952): xây cây nhị phân tối ưu theo tần suất, gán mã độ dài biến đổi (variable-length code).
6. Xây cây Huffman — greedy DP bottom-up
Cho tần suất ký tự trong file:
Ví dụ: "aabbbccccdddddd"
a: 2, b: 3, c: 4, d: 6
Tổng: 15 ký tự
Thuật toán Huffman:
buildHuffman(frequencies):
-- Mỗi ký tự là 1 node lá, đặt vào MinHeap theo tần suất
H <- MinHeap
for each (char, freq) trong frequencies:
H.push(LeafNode(char, freq))
-- Gộp 2 node nhỏ nhất thành node nội cho đến khi còn 1 node (gốc)
while H.size() > 1:
left <- H.pop() -- node tần suất nhỏ nhất
right <- H.pop() -- node tần suất nhỏ thứ hai
merged <- InternalNode(left, right, left.freq + right.freq)
H.push(merged)
return H.pop() -- gốc của cây Huffman
-- Time: O(n log n) Space: O(n) với n là số ký tự phân biệt
Tại sao greedy đúng? Ký tự tần suất nhỏ được gộp trước → nằm sâu hơn trong cây → mã dài hơn. Ký tự tần suất lớn được gộp sau → gần gốc → mã ngắn hơn. Có thể chứng minh bằng exchange argument: bất kỳ swap nào giữa 2 node cũng làm tổng bit tăng lên.
Trace ví dụ:
Bước 1 — MinHeap ban đầu: [a:2, b:3, c:4, d:6]
Bước 2 — Gộp a:2 + b:3 = ab:5
Heap: [c:4, ab:5, d:6]
Bước 3 — Gộp c:4 + ab:5 = cab:9
Heap: [d:6, cab:9]
Bước 4 — Gộp d:6 + cab:9 = root:15
Cây hoàn chỉnh
graph TD
ROOT["root: 15"] -- "0" --> D["d: 6"]
ROOT -- "1" --> CAB["cab: 9"]
CAB -- "0" --> C["c: 4"]
CAB -- "1" --> AB["ab: 5"]
AB -- "0" --> A["a: 2"]
AB -- "1" --> B["b: 3"]7. Đọc mã từ cây Huffman
Đi từ gốc xuống: rẽ trái = bit 0, rẽ phải = bit 1. Mã của mỗi ký tự:
| Ký tự | Tần suất | Mã Huffman | Số bit |
|---|---|---|---|
d | 6 | 0 | 1 |
c | 4 | 10 | 2 |
b | 3 | 111 | 3 |
a | 2 | 110 | 3 |
Tổng bit với Huffman:
d: 6 × 1 = 6 bit
c: 4 × 2 = 8 bit
b: 3 × 3 = 9 bit
a: 2 × 3 = 6 bit
Tổng: 29 bit
So với ASCII 8-bit cố định: 15 × 8 = 120 bit
Tiết kiệm: (120 - 29) / 120 ≈ 76%
Prefix-free property: không mã nào là tiền tố của mã khác (vì mỗi ký tự là node lá, không nằm trên đường đến lá khác). Điều này đảm bảo giải mã không mơ hồ: đọc bit từ trái, mỗi khi match một lá thì đó là ký tự — không cần dấu phân cách.
8. DEFLATE — Huffman trong gzip thực tế
gzip dùng chuẩn DEFLATE (RFC 1951) — kết hợp 2 bước:
Bước 1 — LZ77 (1977): tìm chuỗi lặp lại
Thay vì lưu "abcabc", lưu "abc" + (offset=3, length=3)
→ giảm entropy (loại bỏ redundancy theo vị trí)
Bước 2 — Huffman coding: nén ký tự theo tần suất
Áp lên output của LZ77 (ký tự + backreference token)
→ giảm thêm theo tần suất phân phối
DEFLATE không phải thuần Huffman — nó là Huffman trên output của LZ77. LZ77 xử lý redundancy "theo chiều ngang" (chuỗi lặp lại), Huffman xử lý redundancy "theo chiều dọc" (ký tự phổ biến). Kết hợp 2 bước đạt tỷ lệ nén 40-70% cho file text thông thường.
graph LR
RAW["File gốc<br/>120 KB"] -- "LZ77<br/>loại lặp lại" --> LZ["Sau LZ77<br/>~60 KB"]
LZ -- "Huffman<br/>nén tần suất" --> GZ["File .gz<br/>~36 KB"]Tại sao gzip không chỉ dùng Huffman? Huffman một mình không xử lý được chuỗi lặp lại (ví dụ file log với timestamp pattern). LZ77 phá pattern lặp lại trước, sau đó Huffman nén phân phối ký tự của phần còn lại — hiệu quả cộng hưởng.
9. Ứng dụng thực tế Huffman ngoài gzip
| Ứng dụng | Variant |
|---|---|
| ZIP, zlib | DEFLATE (LZ77 + Huffman) |
| PNG | DEFLATE cho pixel data (sau filter) |
| JPEG | Huffman cho AC/DC coefficient trong DCT |
| MP3 | Huffman cho quantized frequency bands |
| HTTP/2, gRPC | HPACK/QPACK — Huffman cho HTTP headers |
| Mã Morse | Tiền thân thủ công: E=., T=- (ngắn cho phổ biến) |
HTTP/2 đặc biệt thú vị: HPACK (RFC 7541) dùng Huffman table tĩnh được precompute trên tần suất thống kê của HTTP headers qua hàng triệu request thực tế — không cần truyền cây Huffman trong mỗi connection.
10. Bảng so sánh 2 DP
| LCS / Myers diff | Huffman coding | |
|---|---|---|
| Bài toán gốc | Dãy con chung dài nhất | Mã prefix-free chi phí tối thiểu |
| Subproblem | dp[i][j] = LCS của 2 tiền tố | Tần suất subtree tại mỗi node |
| Transition | match/skip từng phần tử | Gộp 2 node nhỏ nhất |
| Complexity | O(n×m) DP, O(n+m+D²) Myers | O(n log n) heap |
| Production | git diff, Unix diff, merge tool | gzip, PNG, JPEG, HTTP/2 headers |
| Đặc điểm | Tối ưu toàn cục (bảng 2D) | Greedy tối ưu (exchange argument) |
Tự kiểm tra
Q1git diff hiển thị dòng '-' và '+'. Tại sao LCS của 2 phiên bản file lại tương đương với minimal diff?▸
LCS là tập hợp các dòng chung giữa 2 phiên bản — dòng không thay đổi. Minimal diff chính là tập dòng phải xoá (có trong file cũ, không trong LCS) cộng tập dòng phải thêm (có trong file mới, không trong LCS).
Khi LCS dài nhất, phần nằm ngoài LCS (dòng thay đổi) nhỏ nhất — đó là minimal edit. Đây là lý do tại sao tìm LCS = tìm minimal diff: 2 bài toán là hai mặt của cùng một quan hệ bổ sung (complement).
Nói cách khác: edit_distance = (n - LCS) + (m - LCS) với n, m là độ dài 2 file. Tối thiểu hóa edit distance tương đương tối đa hóa LCS.
Q2LCS DP có transition: nếu A[i]=B[j] thì dp[i][j] = dp[i-1][j-1] + 1, ngược lại max(dp[i-1][j], dp[i][j-1]). Tại sao không lấy max của cả 3 ô (dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) khi ký tự không khớp?▸
Khi A[i] != B[j], ta có 2 lựa chọn: bỏ qua A[i] (dùng dp[i-1][j]) hoặc bỏ qua B[j] (dùng dp[i][j-1]). Không có lựa chọn thứ 3 "bỏ qua cả 2 cùng lúc" vì đó sẽ là dp[i-1][j-1] — nhưng dp[i-1][j-1] luôn nhỏ hơn hoặc bằng cả dp[i-1][j] và dp[i][j-1].
Lý do: dp[i-1][j] >= dp[i-1][j-1] vì xét thêm ký tự B[j] không thể làm LCS ngắn hơn (ta có thể chọn không dùng nó). Vậy max(dp[i-1][j], dp[i][j-1]) đã bao gồm dp[i-1][j-1] — không cần xét riêng.
Q3Myers algorithm có complexity O(n + m + D²) với D là edit distance. Tại sao nhanh hơn LCS DP O(n×m) đáng kể với file thực tế?▸
Với 2 phiên bản file thực tế (code, config, text), phần lớn dòng không thay đổi — D (số dòng thay đổi) rất nhỏ so với n và m. Ví dụ file 500 dòng, sửa 10 dòng thì D=10, n=m=500.
LCS DP cần xử lý toàn bộ bảng 500 × 500 = 250,000 ô dù chỉ 10 dòng thay đổi. Myers chỉ cần O(500 + 500 + 10² = 1100) — nhanh hơn hơn 200 lần trong trường hợp này.
Myers khai thác insight rằng "edit path ngắn = ít cạnh cost-1 = ít bước BFS". Trong khi DP phải điền mọi ô bảng bất kể D, Myers chỉ mở rộng đến depth D — và D nhỏ với file ít thay đổi là trường hợp thực tế phổ biến.
Q4Huffman coding đảm bảo prefix-free property. Tại sao property này quan trọng cho việc giải mã? Điều gì xảy ra nếu vi phạm?▸
Prefix-free đảm bảo không có mã nào là tiền tố của mã khác. Ví dụ: nếu a = "10" và b = "101", khi đọc bit stream 101... sẽ không biết đó là a rồi một bit nữa hay là b — mơ hồ.
Với prefix-free (mỗi ký tự là lá trong cây), giải mã đơn giản: đọc bit từ gốc, rẽ trái/phải theo bit 0/1, khi đến lá thì emit ký tự và quay về gốc. Không cần dấu phân cách giữa các ký tự — stream bit liên tục giải mã được 1-1. Vi phạm prefix-free → giải mã mơ hồ → không recover được dữ liệu gốc.
Cây Huffman tự nhiên là prefix-free vì mã = đường từ gốc đến lá, không đường nào là tiền tố đường khác (đường đến lá A không thể là đoạn đầu đường đến lá B).
Q5gzip dùng LZ77 trước rồi Huffman sau, thay vì chỉ dùng Huffman. Trường hợp nào Huffman một mình hiệu quả kém, cần LZ77 trước?▸
Huffman kém hiệu quả khi file có nhiều chuỗi lặp lại theo vị trí — ví dụ file log với timestamp pattern [2026-06-16 10:23:45] lặp lại hàng nghìn lần. Huffman chỉ nhìn vào phân phối ký tự đơn lẻ — nó không "nhận ra" chuỗi này lặp lại và không khai thác được redundancy đó.
LZ77 giải quyết bằng backreference: khi thấy chuỗi đã xuất hiện trước đó trong window, thay bằng (offset, length) token. File log 10 MB có thể xuống 500 KB sau LZ77 (95% là chuỗi lặp), rồi Huffman nén thêm phân phối ký tự còn lại.
Ngược lại: file binary ngẫu nhiên (entropy cao, ít lặp lại) thì LZ77 gần như không giúp được — Huffman cũng không nhiều vì phân phối ký tự gần đều. Đó là lý do nén file .zip của file .zip không nhỏ thêm.
Q6HTTP/2 HPACK dùng Huffman table tĩnh precomputed trên thống kê HTTP headers thực tế, thay vì tính cây Huffman mới cho mỗi request. Trade-off của approach này là gì?▸
Ưu điểm của table tĩnh: client và server không cần trao đổi cây Huffman — tiết kiệm overhead truyền cây. Mọi connection đều dùng chung 1 table, giảm latency cho request đầu tiên. Table được tối ưu trên hàng triệu HTTP header thực tế → tốt cho trường hợp trung bình (headers thông thường).
Nhược điểm: nếu ứng dụng của bạn có phân phối header đặc biệt khác trung bình (ví dụ header tùy chỉnh ít gặp), table tĩnh kém tối ưu hơn table động tính riêng. Table tĩnh cũng không thay đổi được khi HTTP traffic pattern thay đổi theo thời gian — phải chờ update RFC.
HPACK chọn table tĩnh vì HTTP headers thường rất uniform (Content-Type, Authorization, Accept xuất hiện ở gần như mọi request) — lợi ích cố định table vượt nhược điểm kém linh hoạt cho hầu hết use case.
Bài tiếp theo: Module 1 — Tổng kết & cheat sheet
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên