Disjoint Set Union — Near-O(1) với path compression + union by rank
DSU dùng mảng parent[] đạt amortized O(α(n)) nhờ path compression + union by rank. Từ naive O(n²) lên gần hằng số, ứng dụng Kruskal MST, image segmentation, connectivity.
TL;DR: Disjoint Set Union (DSU) quản lý tập hợp rời rạc với hai operation: find(x) — trả representative (root) của set chứa x; union(x, y) — gộp hai set. Cấu trúc chỉ dùng một mảng parent[], nhưng với hai optimization cộng hưởng — path compression (sau mỗi find, gán thẳng parent tới root) và union by rank (attach cây thấp vào cây cao) — đạt amortized O(α(n)) per operation, α là inverse Ackermann, thực tế nhỏ hơn hoặc bằng 4 với mọi n. Không có optimization nào đạt được giới hạn này đơn lẻ — phải kết hợp cả hai.
Lesson 08 đã dùng DSU như một black box trong Kruskal: find(x) trả root của component chứa x, union(x, y) gộp hai component, và ghi chú complexity "O(α(V)) amortized". Vậy α là gì, và tại sao một mảng số nguyên đơn giản lại đạt hiệu năng gần bằng hằng số?
Câu trả lời nằm ở hai optimization cộng hưởng nhau: path compression (mỗi lần tìm root, gán thẳng parent tới root để lần sau nhanh hơn) và union by rank (khi gộp, luôn attach cây nhỏ vào cây lớn để giữ chiều cao thấp). Riêng lẻ, mỗi optimization cho O(log n). Kết hợp, chúng đạt O(α(n)) amortized — giới hạn tới inverse Ackermann, thực tế nhỏ hơn hoặc bằng 4 với mọi n trong thực tế.
Bài này dạy DSU từ naive O(n²) lên optimized α(n), 2 optimization (path compression + union by rank), pseudocode đầy đủ, ứng dụng trong Kruskal MST, image segmentation, và friend circle problem.
1. Analogy — Cây gia phả tìm tổ tiên chung
Tưởng tượng một tập người, mỗi người có đúng một "parent" — người cha hoặc mẹ. Người ở gốc cây gia phả thì parent là chính mình. Để biết hai người có cùng dòng họ không, ta đi ngược lên parent chain của từng người đến gốc — nếu cùng gốc, cùng dòng họ.
Find tương đương hỏi "tổ tiên xa nhất của người này là ai?" — đi lên từng bước cho đến khi gặp người có parent là chính mình.
Union tương đương "kết hợp hai dòng họ thành một" — đặt gốc của dòng họ này làm con của gốc dòng họ kia.
Path compression tương đương "sau khi tìm ra tổ tiên xa nhất, mọi người trên đường đi đó cập nhật thẳng parent của mình tới tổ tiên đó" — lần sau hỏi lại sẽ trả lời ngay lập tức.
| Cây gia phả | DSU |
|---|---|
| Người | Node (phần tử của set) |
| Parent | parent[x] |
| Gốc dòng họ (parent là chính mình) | Root — representative của set |
| Tìm tổ tiên xa nhất | find(x) |
| Kết hợp hai dòng họ | union(x, y) |
| Hai người cùng dòng họ? | connected(x, y) — kiểm tra cùng root |
| Nhớ đường tắt tới tổ tiên | Path compression |
| Ghép dòng họ nhỏ vào dòng họ lớn | Union by rank/size |
DSU = 1 mảng parent[]. Mọi thứ khác chỉ là cách bạn đọc và ghi mảng đó thông minh hơn.
2. API của DSU
Ba operation cốt lõi:
find(x)— trả root (representative) của set chứa x. Hai phần tử cùng set khi và chỉ khifind(x) = find(y).union(x, y)— gộp set chứa x với set chứa y. Trảtruenếu thực sự merge (hai phần tử chưa cùng set),falsenếu đã cùng set rồi.connected(x, y)— trảtruenếu x và y cùng set. Tương đươngfind(x) = find(y).
-- Ví dụ sử dụng -- DSU trên 5 phần tử: {0, 1, 2, 3, 4}
DSU dsu <- khởi tạo với 5 phần tử
dsu.union(0, 1) -- gộp set chứa 0 và set chứa 1
dsu.union(2, 3) -- gộp set chứa 2 và set chứa 3
dsu.connected(0, 1) -- true -- cùng set
dsu.connected(0, 2) -- false -- khác set
dsu.union(1, 2) -- gộp hai nhóm lại
dsu.connected(0, 3) -- true -- giờ tất cả cùng một set
3. Naive implementation — và tại sao O(n²)
Ý tưởng cơ bản: parent[i] = i có nghĩa là i là root của set của mình. find(x) đi lên parent chain đến khi parent[x] = x. union(x, y) set root của x làm parent của root của y.
DSU_naive_find(parent, x):
while parent[x] != x:
x <- parent[x] -- đi lên parent chain
return x
DSU_naive_union(parent, x, y):
rx <- DSU_naive_find(parent, x)
ry <- DSU_naive_find(parent, y)
if rx = ry: return false -- đã cùng set
parent[rx] <- ry -- attach root của x vào root của y
return true
Time: O(n) worst case mỗi find Space: O(n)
Worst case — chuỗi chain: nếu liên tục union theo chiều nhất định, cây DSU degenerate thành linked list dài n. Ví dụ: union(0,1), union(1,2), union(2,3), ..., union(n-2, n-1) tạo ra chuỗi 0 → 1 → 2 → ... → n-1. Khi đó find(0) cần đi qua n bước.
Tổng n lần union, mỗi lần cần gọi find tốn O(n) worst case → toàn bộ O(n²).
4. Optimization 1 — Union by Rank
Ý tưởng: khi gộp hai cây, attach cây thấp hơn vào cây cao hơn — giữ chiều cao toàn bộ cây ở O(log n). Nếu attach cây cao vào cây thấp, chiều cao tổng tăng thêm 1 không cần thiết.
rank[x] là một ước lượng chiều cao của cây gốc tại x. Khi hai cây có rank bằng nhau được gộp, rank của root mới tăng thêm 1. Khi rank khác nhau, attach root rank thấp vào root rank cao — rank không đổi.
DSU_rank_find(parent, x):
while parent[x] != x:
x <- parent[x]
return x
DSU_rank_union(parent, rank, x, y):
rx <- DSU_rank_find(parent, x)
ry <- DSU_rank_find(parent, y)
if rx = ry: return false
-- Attach cây có rank thấp hơn vào cây có rank cao hơn
if rank[rx] < rank[ry]:
đổi chỗ rx và ry -- đảm bảo rank[rx] >= rank[ry]
parent[ry] <- rx
if rank[rx] = rank[ry]:
rank[rx] <- rank[rx] + 1
return true
Time: O(log n) mỗi find Space: O(n)
Effect: chiều cao cây DSU tối đa log n sau bất kỳ sequence union nào. Do đó find(x) tốn O(log n). Tổng n operation: O(n log n). Cải thiện đáng kể so với O(n²), nhưng vẫn chưa phải tối ưu.
5. Optimization 2 — Path Compression
Ý tưởng: khi find(x) đi lên từ x đến root, mọi node trên đường đó đều biết root là gì. Tại sao không gán thẳng parent[x] = root cho chúng luôn? Lần find tiếp theo trên bất kỳ node nào trong đường đó sẽ tốn O(1).
Variant 1 — Full compression (đệ quy): đi lên root qua đệ quy, trên đường trở về set parent của từng node thẳng tới root.
DSU_compress_find(parent, x):
if parent[x] != x:
parent[x] <- DSU_compress_find(parent, parent[x]) -- compress khi quay về
return parent[x]
Variant 2 — Path halving (iterative): mỗi bước, nhảy lên hai cấp (parent[parent[x]]) thay vì một. Không cần đệ quy. Thực tế cho cùng amortized complexity với full compression.
DSU_halving_find(parent, x):
while parent[x] != x:
parent[x] <- parent[parent[x]] -- path halving -- nhảy qua một cấp
x <- parent[x]
return x
Time: O(log n) amortized (chỉ path compression) Space: O(n)
Effect (chỉ path compression, không union by rank): O(log n) amortized per operation. Cây bị "flatten" dần sau nhiều lần find, nhưng không có gì ngăn cây ban đầu cao tới O(n) trước khi được compress.
6. Combined — Union by Rank + Path Compression
DSU_init(parent, rank, n):
for i <- 0 đến n-1:
parent[i] <- i -- mỗi phần tử là root của chính mình
rank[i] <- 0
-- Full path compression -- đệ quy
DSU_find(parent, x):
if parent[x] != x:
parent[x] <- DSU_find(parent, parent[x])
return parent[x]
-- Union by rank -- attach cây thấp vào cây cao
DSU_union(parent, rank, x, y):
rx <- DSU_find(parent, x)
ry <- DSU_find(parent, y)
if rx = ry: return false
if rank[rx] < rank[ry]:
đổi chỗ rx và ry
parent[ry] <- rx
if rank[rx] = rank[ry]:
rank[rx] <- rank[rx] + 1
return true
DSU_connected(parent, x, y):
return DSU_find(parent, x) = DSU_find(parent, y)
Time: O(α(n)) amortized mỗi operation Space: O(n)
Amortized complexity: O(α(n)) per operation, trong đó α là inverse Ackermann function.
α(n) là hàm tăng cực kỳ chậm:
- α(n) nhỏ hơn hoặc bằng 4 với n nhỏ hơn hoặc bằng 2^65536 (con số lớn hơn số nguyên tử trong vũ trụ).
- Trong mọi input thực tế: α(n) nhỏ hơn hoặc bằng 4.
- Thực tế coi như hằng số.
Đây là tight bound được chứng minh bởi Tarjan (1975) — bài báo "Efficiency of a Good But Not Linear Set Union Algorithm" chứng minh cả upper bound O(α(n)) lẫn lower bound, nghĩa là không thể cải thiện thêm với model này.
7. Trace ví dụ — step-by-step
Bắt đầu với DSU 5 phần tử (0 đến 4). parent = [0,1,2,3,4], rank = [0,0,0,0,0].
graph TD
subgraph "Sau union(1,2) — rank[1]=1"
A1((1)) --> A2((2))
end
subgraph "Sau union(3,4) — rank[3]=1"
B3((3)) --> B4((4))
end
subgraph "Sau union(1,3) — rank[1]=2"
C1((1)) --> C2((2))
C1 --> C3((3))
C3 --> C4((4))
end
subgraph "Sau find(4) — path compression"
D1((1)) --> D2((2))
D1 --> D3((3))
D1 --> D4((4))
endBước 1: union(1, 2)
find(1) = 1,find(2) = 2— khác nhau.rank[1] = rank[2] = 0— attach 2 dưới 1, tăngrank[1]lên 1.parent = [0,1,1,3,4],rank = [0,1,0,0,0].
Bước 2: union(3, 4)
find(3) = 3,find(4) = 4— khác nhau.rank[3] = rank[4] = 0— attach 4 dưới 3, tăngrank[3]lên 1.parent = [0,1,1,3,3],rank = [0,1,0,1,0].
Bước 3: union(1, 3)
find(1) = 1,find(3) = 3— khác nhau.rank[1] = rank[3] = 1— attach 3 dưới 1, tăngrank[1]lên 2.parent = [0,1,1,1,3],rank = [0,2,0,1,0].- Cây: root 1, con trực tiếp là 2 và 3, con của 3 là 4.
Bước 4: find(4)
parent[4] = 3,parent[3] = 1,parent[1] = 1— root là 1.- Path compression:
parent[4] = 1,parent[3] = 1(đã là 1 rồi). parent = [0,1,1,1,1]. Node 4 giờ trỏ thẳng tới root.- Lần sau gọi
find(4): O(1) ngay lập tức.
| Operation | parent[] | Ghi chú |
|---|---|---|
| Init | [0,1,2,3,4] | Mỗi node là root của chính mình |
| union(1,2) | [0,1,1,3,4] | 2 trỏ tới 1. rank[1]=1 |
| union(3,4) | [0,1,1,3,3] | 4 trỏ tới 3. rank[3]=1 |
| union(1,3) | [0,1,1,1,3] | 3 trỏ tới 1. rank[1]=2 |
| find(4) | [0,1,1,1,1] | Path compression: 4 trỏ thẳng tới 1 |
8. Pitfall tổng hợp
Pitfall 1 — Chỉ dùng một trong hai optimization
Chỉ union by rank (không path compression) cho O(log n) per find — tốt, nhưng chưa phải tối ưu.
Chỉ path compression (không union by rank) cho O(log n) amortized per operation (theo phân tích Tarjan) — tốt, nhưng worst-case một lần find đầu tiên trên cây chưa compress vẫn tốn O(n), và không có gì ngăn cây ban đầu cao O(n) trước khi được compress lần đầu.
-- WRONG: chỉ union by rank, không path compression
DSU_find_wrong1(parent, x):
while parent[x] != x:
x <- parent[x] -- O(log n) mỗi lần -- chưa tối ưu
-- WRONG: chỉ path compression, không union by rank
DSU_union_wrong2(parent, x, y):
rx <- DSU_find(parent, x)
ry <- DSU_find(parent, y)
if rx = ry: return false
parent[rx] <- ry -- không kiểm tra rank -- cây vẫn có thể cao trước khi compress
return true
Phải dùng cả hai để đạt O(α(n)). Trên 1 triệu operation: chênh lệch thực tế khoảng 4× so với chỉ dùng một optimization.
Pitfall 2 — Recursive find gây stack overflow
Trên input adversarial (chain dài trước khi compression lần đầu), recursion depth của find bằng chiều cao cây. Với n = 100.000 và cây chưa compress, có thể gây lỗi stack overflow (stack mặc định thường giới hạn vài trăm đến vài nghìn frame).
-- RISKY: độ sâu đệ quy = chiều cao cây, có thể stack overflow
DSU_find_recursive(parent, x):
if parent[x] != x:
parent[x] <- DSU_find_recursive(parent, parent[x])
return parent[x]
-- SAFE: iterative two-pass full compression
DSU_find_safe(parent, x):
root <- x
while parent[root] != root:
root <- parent[root] -- pass 1: tìm root
while parent[x] != root: -- pass 2: compress
next <- parent[x]
parent[x] <- root
x <- next
return root
Time: O(α(n)) amortized Space: O(1) thêm (không stack)
Pitfall 3 — DSU không thread-safe
find vừa đọc vừa ghi parent[] (path compression). Nếu nhiều thread gọi find hoặc union đồng thời, race condition xảy ra:
-- RACE CONDITION: hai thread gọi union đồng thời
-- Thread A: find(x) đọc parent[x] = 3
-- Thread B: find(x) ghi parent[x] = 1 (path compression)
-- Thread A: ghi parent[x] = 5 (giá trị cũ, stale)
-- Kết quả: parent[x] không còn trỏ tới root -- find() trả kết quả sai
DSU với path compression không an toàn để dùng từ nhiều thread mà không có synchronization. Với concurrent use case, dùng lock hoặc thiết kế offline: collect tất cả query trước, chạy DSU single-threaded, trả kết quả.
9. Variants đáng biết
Weighted DSU (track size thay rank): thay rank[] bằng size[] — size[root] là số phần tử trong set đó. union attach set nhỏ hơn vào set lớn hơn. Cho phép query thêm "set này có bao nhiêu phần tử?" trong O(α(n)).
-- Query: số phần tử trong set chứa x
DSU_size(size, parent, x):
return size[DSU_find(parent, x)]
Offline LCA (Lowest Common Ancestor) — Tarjan offline: bài toán tìm LCA cho nhiều cặp node trong cây, query offline. Dùng DSU để track "đã process component nào" trong DFS — cho O((V + Q) × α(V)) tổng với Q query.
DSU with rollback (Undo Union): cho phép "undo" các union theo thứ tự stack. Cần thiết cho persistent DS hoặc time-travel query (ví dụ offline dynamic connectivity). Đánh đổi: không thể dùng path compression (vì compress làm mất thông tin để rollback) — chỉ dùng union by rank, cho O(log n) per operation.
10. Ứng dụng thực tế
Kruskal MST (lesson 08): DSU detect cycle khi thêm edge. Với mỗi edge (u, v) trong danh sách đã sort, kiểm tra dsu.union(u, v) — nếu return false, u và v đã cùng component, thêm edge này sẽ tạo cycle. Hiệu năng DSU trực tiếp ảnh hưởng đến toàn bộ Kruskal: E lần gọi union × O(α(V)) = O(E × α(V)) — thực tế coi như O(E), không bottleneck sort O(E log E).
Image segmentation (computer vision): treat mỗi pixel là vertex, edge giữa hai pixel liền kề có weight là difference màu/độ sáng. Felzenszwalb-Huttenlocher algorithm (2004) dùng DSU: với mỗi edge theo thứ tự tăng dần của weight, kiểm tra xem hai pixel có đủ "khác nhau" để thuộc hai segment khác nhau không — nếu không, union chúng lại. Kết quả: mỗi root của DSU là một segment. DSU cho phép xử lý ảnh 4K (khoảng 8 triệu pixel) real-time.
Network connectivity (offline): social network với hàng triệu user, cần trả lời "A và B có kết nối không?" cho nhiều cặp. Xây DSU một lần trên toàn graph (O(E × α(V))), sau đó mỗi query chỉ tốn O(α(V)) ≈ O(1). Ứng dụng trong Facebook "People You May Know" feature và LinkedIn connection degree.
Compiler — register allocation: trong SSA (Static Single Assignment) form, các biến tạm cùng giá trị cần được assign vào cùng register. Track equivalence class của biến bằng DSU — union(a, b) khi phát hiện a và b phải cùng register. GCC và LLVM dùng union-find internally cho coalescing pass trong register allocator.
11. Deep Dive
Bài báo gốc và proof:
- Tarjan, R.E. (1975) — "Efficiency of a Good But Not Linear Set Union Algorithm", Journal of the ACM: chứng minh tight bound O(α(n)) amortized cho Union by Rank + Path Compression — cả upper lẫn lower bound. Đây là công trình đặt nền tảng lý thuyết cho DSU hiện đại.
- Galler, B.A. & Fischer, M.J. (1964) — "An Improved Equivalence Algorithm", Communications of the ACM: bài báo giới thiệu Union-Find (DSU) ban đầu, chưa có path compression.
Sách tham khảo:
- Introduction to Algorithms (CLRS), Chapter 21 — Data Structures for Disjoint Sets: chứng minh đầy đủ O(m α(n)) cho m operation trên n phần tử, phân tích rank lemma.
Cross-link trong khóa học:
- Module 3, Lesson 08 — Kruskal MST: DSU dùng như black box — bài này giải thích tại sao nó hoạt động.
- Thuật toán Căn bản — Module 1, Lesson 03 — Amortized analysis: background lý thuyết về amortized complexity (potential method), áp dụng trực tiếp để hiểu O(α(n)) proof.
12. Tóm tắt
- DSU dùng 1 mảng
parent[]:parent[x] = xnghĩa là x là root của set mình. - Naive (không optimization):
findO(n) worst case khi cây degenerate thành chain → tổng O(n²). - Union by rank: attach cây thấp vào cây cao → chiều cao tối đa log n →
findO(log n). - Path compression: sau mỗi
find, gánparent[x] = rootcho mọi node trên đường → lần sau O(1). - Kết hợp cả hai: amortized O(α(n)) per operation. α(n) nhỏ hơn hoặc bằng 4 với mọi n thực tế — thực tế coi như hằng số.
- Recursive
findcó thể gây stack overflow trên chain dài — prefer iterative two-pass hoặc path halving. - Không thread-safe: path compression vừa đọc vừa ghi
parent[]— cần synchronization hoặc offline design khi dùng concurrent. - Ứng dụng: Kruskal MST (cycle detection), image segmentation, network connectivity, compiler register allocation.
13. Tự kiểm tra
Q1Naive DSU không dùng optimization nào cho O(n²) trong worst case — trace một ví dụ cụ thể để giải thích tại sao.▸
Xét sequence: union(0,1), union(1,2), union(2,3), ..., union(n-2, n-1) với naive implementation (luôn attach root của x vào root của y).
Sau n-1 lần union: cây DSU là chuỗi 0 → 1 → 2 → 3 → ... → n-1, trong đó n-1 là root. Khi gọi find(0), phải đi lên n bước. Tổng n lần union, mỗi lần find tệ nhất O(n) → tổng O(n²). Với n = 10.000: 100 triệu operations — chậm rõ ràng so với optimized.
Q2Union by rank vs union by size — hai cách này khác nhau như thế nào? Có ảnh hưởng đến complexity không?▸
Union by rank: rank[x] là ước lượng chiều cao cây gốc tại x. Attach cây có rank thấp hơn vào cây có rank cao hơn. Rank chỉ tăng khi hai cây có rank bằng nhau được gộp. Sau path compression, rank không còn là chiều cao chính xác — chỉ là upper bound.
Union by size: size[x] là số phần tử trong cây gốc tại x. Attach cây nhỏ hơn vào cây lớn hơn. Size chính xác hơn rank (luôn đúng), và cho phép query "set này có bao nhiêu phần tử?" O(1). Cả hai đều cho cùng amortized complexity O(α(n)) khi kết hợp path compression — không ảnh hưởng big-O. Union by size thực tế phổ biến hơn vì tích hợp được thêm query.
Q3Path compression recursive vs path halving — trade-off thực tế là gì? Khi nào nên chọn cái nào?▸
Recursive full compression: sau một lần find(x), tất cả node trên đường từ x tới root đều trỏ thẳng tới root. Cây được flatten tối đa. Nhược điểm: stack frame đệ quy sâu bằng chiều cao cây — với cây chưa compress cao n node, recursion depth = n → stack overflow trên input lớn.
Path halving (iterative): mỗi bước nhảy lên 2 cấp — không đệ quy, không stack overflow risk. Không flatten hoàn toàn trong một lần, nhưng amortized complexity bằng nhau (O(α(n))). Thực tế: prefer path halving khi input n lớn (100.000+) hoặc khi không chắc chiều cao ban đầu của cây. Recursive fine cho n nhỏ hoặc khi biết union by rank giữ cây thấp.
Q4Inverse Ackermann α(n) nhỏ hơn hoặc bằng 4 với mọi n thực tế — ý nghĩa thực tiễn của điều này là gì? Nó có thực sự là O(1) không?▸
Về mặt lý thuyết, α(n) không phải O(1) — nó là một hàm tăng dần theo n, chỉ tăng cực kỳ chậm. Nói "O(1)" là không chính xác về mặt học thuật.
Về mặt thực tiễn, α(n) nhỏ hơn hoặc bằng 4 với n nhỏ hơn hoặc bằng 2^65536 — con số vượt xa mọi input thực tế. Nghĩa là: trên bất kỳ máy tính nào, với bất kỳ dataset nào có thể tồn tại, mỗi DSU operation tốn nhiều nhất 4 bước "amortized". Trong code thực tế và trong phỏng vấn, nói "near-O(1)" hoặc "practically constant" là chấp nhận được — quan trọng là hiểu nó không phải O(1) tuyệt đối mà là bound cực kỳ chặt.
Q5DSU không thread-safe — vì sao? Cho ví dụ race condition cụ thể.▸
Path compression trong find(x) vừa đọc vừa ghi parent[] — đây là read-modify-write không atomic. Nếu Thread A và Thread B cùng gọi find trên hai node có chung đường tới root:
-- Thread A: find(5)
-- đọc: parent[5] = 3
-- đọc: parent[3] = 1 -> root = 1
-- ghi: parent[5] = 1 (path compress)
-- Thread B: đồng thời -- find(3)
-- đọc: parent[3] = 1 -> root = 1
-- union(3, 7): ghi parent[1] = 7 (root mới)
-- Bây giờ Thread A ghi parent[5] = 1 (root cũ, stale!)
-- parent[1] = 7 nhưng parent[5] = 1 -- find(5) trả 1, không phải 7 -> SAIKết quả: find(5) trả root lỗi thời. Fix: dùng lock hoặc thiết kế offline (collect queries, chạy single-threaded, trả kết quả).
Q6Kruskal MST dùng DSU để detect cycle — tại sao DSU phù hợp hơn các cách khác (ví dụ BFS/DFS cycle check)?▸
Kruskal xử lý E edge theo thứ tự weight tăng dần. Với mỗi edge (u, v), cần biết "u và v hiện có thuộc cùng connected component chưa?" — nếu có, thêm edge này sẽ tạo cycle.
BFS/DFS cycle check: mỗi lần check phải traverse toàn bộ component hiện tại — O(V + E) per check, tổng O(E × (V + E)) — quá chậm.
DSU: maintain incrementally — mỗi lần union(u, v) check và merge trong O(α(V)) ≈ O(1). Tổng E lần: O(E × α(V)) ≈ O(E). DSU phù hợp vì bài toán chỉ cần biết "cùng component?" và "merge component" — đúng hai operation mà DSU cung cấp với efficiency tối ưu. Không cần biết đường đi cụ thể (không cần BFS/DFS).
Bài tiếp theo: Mini-challenge: maze BFS + path reconstruction
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