Bài 24.5. Bài tập cơ bản về LCA

Bài 24.5. Bài tập cơ bản về LCA
Chào mừng các bạn quay trở lại với chuỗi bài viết về Cấu trúc dữ liệu và Giải thuật! Hôm nay, chúng ta sẽ khám phá một khái niệm cực kỳ quan trọng khi làm việc với cấu trúc cây: Cây Tổ Tiên Chung Nhất, hay viết tắt là LCA (Lowest Common Ancestor). Nghe tên có vẻ hơi "huyền bí", nhưng thực ra nó mô tả một mối quan hệ rất tự nhiên trong cây. Hãy cùng tìm hiểu xem LCA là gì, tại sao nó lại hữu ích và làm thế nào để tìm được nó một cách hiệu quả nhé!
LCA là gì? Bài toán LCA
Hãy tưởng tượng một cây gia phả. Tổ tiên chung của hai người là bất kỳ ai mà cả hai đều là hậu duệ. Tổ tiên chung nhất (LCA) của hai người đó chính là người tổ tiên chung ở tầng thấp nhất (gần hai người đó nhất).
Trong thuật ngữ của tin học, với một cây có gốc (rooted tree) và hai nút bất kỳ u
và v
:
- Một nút
w
là tổ tiên củau
nếuw
nằm trên đường đi từ gốc đếnu
(bao gồm cảu
). w
là tổ tiên chung củau
vàv
nếuw
vừa là tổ tiên củau
vừa là tổ tiên củav
.w
là tổ tiên chung nhất (LCA) củau
vàv
nếuw
là tổ tiên chung củau
vàv
và nó có độ sâu lớn nhất (tức là xa gốc nhất) trong tất cả các tổ tiên chung.
Bài toán LCA đơn giản là: Cho một cây có gốc và hai nút u
, v
, tìm LCA của u
và v
.
Tại sao LCA lại quan trọng?
LCA không chỉ là một khái niệm lý thuyết. Nó có rất nhiều ứng dụng thực tế trong tin học:
- Hệ thống tập tin: Tìm thư mục chung gần nhất chứa hai tệp.
- Sinh học: Phân tích cây phát sinh loài (phylogenetic trees) để tìm tổ tiên chung gần nhất của hai loài.
- Mạng máy tính: Định tuyến trong các mạng cây.
- Lập trình thi đấu: LCA là nền tảng cho nhiều bài toán phức tạp hơn trên cây, ví dụ như tính khoảng cách giữa hai nút, hay các bài toán liên quan đến đường đi.
Các phương pháp cơ bản tìm LCA
Có nhiều cách để tìm LCA, từ đơn giản đến phức tạp, với hiệu suất khác nhau. Hôm nay, chúng ta sẽ tập trung vào một phương pháp cơ bản nhưng rất trực quan, dựa trên việc điều chỉnh độ sâu và duyệt đồng thời lên phía gốc.
Phương pháp: Điều chỉnh độ sâu và Duyệt đồng thời
Ý tưởng chính của phương pháp này là đưa hai nút u
và v
về cùng một độ sâu, sau đó cùng lúc di chuyển cả hai lên phía gốc cho đến khi chúng gặp nhau. Nút gặp nhau chính là LCA.
Để làm được điều này, chúng ta cần thông tin về cây:
- Độ sâu (
depth
) của mỗi nút: Khoảng cách từ gốc đến nút đó (gốc có độ sâu 0 hoặc 1 tùy quy ước). - Nút cha (
parent
) trực tiếp của mỗi nút: Ngoại trừ gốc.
Chúng ta có thể tính toán hai thông tin này bằng cách thực hiện một lần duyệt cây, ví dụ như Duyệt theo chiều sâu (DFS), bắt đầu từ gốc.
Các bước thực hiện:
- Tiền xử lý (DFS): Duyệt cây từ gốc để tính toán
depth[i]
vàparent[i]
cho mọi núti
.parent[root]
có thể đặt lànull
hoặc chính nó. - Xử lý truy vấn (tìm LCA của u và v):
- Bước 2a: Điều chỉnh độ sâu. Nếu
depth[u]
khácdepth[v]
, di chuyển nút ở độ sâu lớn hơn lên phía gốc cho đến khi độ sâu của nó bằng với nút kia. Ví dụ, nếudepth[u] > depth[v]
, gánu = parent[u]
cho đến khidepth[u] == depth[v]
. - Bước 2b: Kiểm tra trường hợp đặc biệt. Sau khi điều chỉnh độ sâu, nếu
u == v
, điều đó có nghĩa là một nút ban đầu là tổ tiên của nút kia. Nút này (hiện tại làu
hoặcv
) chính là LCA. - Bước 2c: Duyệt đồng thời. Nếu
u != v
, di chuyển cả hai nútu
vàv
lên phía gốc đồng thời từng bước một (u = parent[u]
,v = parent[v]
) cho đến khiu == v
. Nút mà chúng gặp nhau chính là LCA.
- Bước 2a: Điều chỉnh độ sâu. Nếu
Ví dụ Minh họa
Xét cây có gốc là nút 1 sau:
1 (depth 0)
/ \
2 3 (depth 1)
/ \ \
4 5 6 (depth 2)
/ / \
7 8 9 (depth 3)
Thông tin parent
và depth
sau khi DFS từ gốc 1:
Nút | Parent | Depth |
---|---|---|
1 | - | 0 |
2 | 1 | 1 |
3 | 1 | 1 |
4 | 2 | 2 |
5 | 2 | 2 |
6 | 3 | 2 |
7 | 5 | 3 |
8 | 6 | 3 |
9 | 6 | 3 |
Truy vấn 1: Tìm LCA(7, 9)
u = 7
,v = 9
.depth[7] = 3
,depth[9] = 3
. Độ sâu bằng nhau, không cần điều chỉnh.u != v
. Duyệt đồng thời:u = parent[7] = 5
,v = parent[9] = 6
. (u != v
)u = parent[5] = 2
,v = parent[6] = 3
. (u != v
)u = parent[2] = 1
,v = parent[3] = 1
. (u == v
! Chúng gặp nhau tại nút 1).
- LCA(7, 9) là 1.
Truy vấn 2: Tìm LCA(8, 6)
u = 8
,v = 6
.depth[8] = 3
,depth[6] = 2
.depth[8] > depth[6]
.- Điều chỉnh độ sâu:
u = parent[8] = 6
. Bây giờu = 6
,v = 6
. u == v
. Chúng gặp nhau ngay sau khi điều chỉnh.- LCA(8, 6) là 6. (Điều này đúng vì 6 là tổ tiên của 8).
Truy vấn 3: Tìm LCA(4, 5)
u = 4
,v = 5
.depth[4] = 2
,depth[5] = 2
. Độ sâu bằng nhau.u != v
. Duyệt đồng thời:u = parent[4] = 2
,v = parent[5] = 2
. (u == v
! Chúng gặp nhau tại nút 2).
- LCA(4, 5) là 2.
Phương pháp này khá đơn giản và dễ hiểu phải không?
C++ Code Minh họa
Bây giờ, chúng ta sẽ hiện thực hóa phương pháp này bằng C++. Chúng ta cần một hàm DFS để tiền xử lý và một hàm findLCA
để xử lý các truy vấn.
#include <iostream>
#include <vector>
#include <algorithm> // for swap
using namespace std;
const int MAXN = 1005; // Kích thước tối đa của cây
vector<int> adj[MAXN]; // Danh sách kề biểu diễn cây
int parent[MAXN]; // Mảng lưu nút cha
int depth[MAXN]; // Mảng lưu độ sâu
int n; // Số lượng nút trong cây
// Hàm DFS để tính toán parent và depth
// u: nút hiện tại, p: nút cha của u, d: độ sâu của u
void dfs(int u, int p, int d) {
parent[u] = p;
depth[u] = d;
for (int v : adj[u]) {
if (v != p) { // Đảm bảo không đi ngược lại về nút cha
dfs(v, u, d + 1);
}
}
}
// Hàm tìm LCA của hai nút u và v
int findLCA(int u, int v) {
// Bước 2a: Điều chỉnh độ sâu
// Đưa nút có độ sâu lớn hơn lên bằng với nút kia
while (depth[u] > depth[v]) {
u = parent[u];
}
while (depth[v] > depth[u]) {
v = parent[v];
}
// Bước 2b & 2c: Kiểm tra và duyệt đồng thời
// Nếu đã cùng nút, đó là LCA
if (u == v) {
return u;
}
// Di chuyển đồng thời lên phía gốc cho đến khi gặp nhau
while (parent[u] != parent[v]) {
u = parent[u];
v = parent[v];
}
// Khi parent[u] == parent[v], nút cha chung đó chính là LCA
// (trừ trường hợp u và v là con trực tiếp của LCA)
// Nút cha đó là LCA
return parent[u];
}
int main() {
// Xây dựng cây ví dụ
// Nút 1 là gốc
n = 9;
adj[1].push_back(2); adj[1].push_back(3);
adj[2].push_back(1); adj[2].push_back(4); adj[2].push_back(5);
adj[3].push_back(1); adj[3].push_back(6);
adj[4].push_back(2);
adj[5].push_back(2); adj[5].push_back(7);
adj[6].push_back(3); adj[6].push_back(8); adj[6].push_back(9);
adj[7].push_back(5);
adj[8].push_back(6);
adj[9].push_back(6);
// Bước 1: Tiền xử lý - Chạy DFS từ gốc (nút 1)
// Nút cha của gốc (1) có thể để là 0 hoặc 1 tùy quy ước, ở đây để là 0 tạm
dfs(1, 0, 0); // Bắt đầu từ gốc 1, cha là 0, độ sâu 0
// Kiểm tra kết quả DFS (ví dụ)
// for(int i = 1; i <= n; ++i) {
// cout << "Node " << i << ": parent = " << parent[i] << ", depth = " << depth[i] << endl;
// }
// Thử nghiệm với các truy vấn LCA
cout << "LCA(7, 9) is: " << findLCA(7, 9) << endl; // Expected: 1
cout << "LCA(8, 6) is: " << findLCA(8, 6) << endl; // Expected: 6
cout << "LCA(4, 5) is: " << findLCA(4, 5) << endl; // Expected: 2
cout << "LCA(7, 5) is: " << findLCA(7, 5) << endl; // Expected: 5 (7 là con của 5)
cout << "LCA(4, 6) is: " << findLCA(4, 6) << endl; // Expected: 1
cout << "LCA(1, 9) is: " << findLCA(1, 9) << endl; // Expected: 1 (1 là gốc)
return 0;
}
Giải thích Code C++
Includes và Khai báo:
iostream
,vector
,algorithm
: Các thư viện cần thiết cho nhập xuất, sử dụng vector (danh sách kề) và hàmswap
(mặc dù tôi đã thay đổi logic để không cần swap trực tiếpu, v
mà chỉ điều chỉnh nút sâu hơn).MAXN
: Hằng số định nghĩa kích thước tối đa của cây, giúp khai báo mảng tĩnh.adj[MAXN]
: Mảng các vector để biểu diễn cây dưới dạng danh sách kề.adj[i]
chứa các nút kề với núti
. Vì đây là cây vô hướng khi đọc input, chúng ta thêm cạnh ở cả hai chiều. Tuy nhiên, khi duyệt DFS, chúng ta sẽ coi nó như cây có gốc.parent[MAXN]
: Mảng lưu trữ nút cha trực tiếp của mỗi nút.parent[i]
là cha của núti
.depth[MAXN]
: Mảng lưu trữ độ sâu của mỗi nút.depth[i]
là khoảng cách từ gốc đến núti
.n
: Biến lưu số lượng nút.
Hàm
dfs(int u, int p, int d)
:- Đây là hàm đệ quy để duyệt cây theo chiều sâu, bắt đầu từ
u
. p
là nút cha củau
trong quá trình duyệt hiện tại.d
là độ sâu củau
.parent[u] = p;
: Gán cha của nútu
làp
.depth[u] = d;
: Gán độ sâu của nútu
làd
.- Vòng lặp
for (int v : adj[u])
: Duyệt qua tất cả các nút kềv
vớiu
. if (v != p)
: Kiểm tra để đảm bảov
không phải là nút cha mà từ đó chúng ta vừa đi xuốngu
. Điều này quan trọng để tránh đi ngược lên trong quá trình DFS trên biểu diễn đồ thị vô hướng.dfs(v, u, d + 1);
: Gọi đệ quy cho nút conv
, vớiu
là cha củav
và độ sâu tăng lên 1.
- Đây là hàm đệ quy để duyệt cây theo chiều sâu, bắt đầu từ
Hàm
findLCA(int u, int v)
:- Hàm này nhận hai nút
u
vàv
và trả về LCA của chúng. - Điều chỉnh độ sâu: Hai vòng
while
đầu tiên thực hiện Bước 2a. Nếuu
sâu hơnv
, di chuyểnu
lên bằng cách gánu = parent[u]
. Lặp lại cho đến khidepth[u]
không còn lớn hơndepth[v]
. Sau đó, làm tương tự vớiv
nếuv
sâu hơnu
. Khi kết thúc hai vòng lặp này,u
vàv
sẽ có cùng độ sâu. - Kiểm tra gặp nhau sớm:
if (u == v)
: Nếu sau khi điều chỉnh độ sâu màu
vàv
đã là cùng một nút, điều này chỉ xảy ra khi một nút ban đầu là tổ tiên của nút kia. Nút đó chính là LCA, nên ta trả vều
(hoặcv
). - Duyệt đồng thời: Vòng
while (parent[u] != parent[v])
thực hiện Bước 2c. Chừng nào cha củau
và cha củav
còn khác nhau, ta di chuyển cảu
lẫnv
lên một bước bằng cách gánu = parent[u]
vàv = parent[v]
. - Tìm thấy LCA: Khi vòng lặp kết thúc, điều kiện
parent[u] != parent[v]
không còn đúng, tức làparent[u] == parent[v]
. Nút cha chung này chính là LCA củau
vàv
. Lưu ý rằngu
vàv
lúc này đang đứng ở hai nút con của LCA, và chúng có chung một cha. Nút cha đó là LCA. Ta trả vềparent[u]
(hoặcparent[v]
).
- Hàm này nhận hai nút
Hàm
main()
:- Xây dựng cấu trúc cây bằng cách thêm các cạnh vào danh sách kề
adj
. Lưu ý: vì DFS được gọi từ gốc 1, cây sẽ được coi là có hướng từ 1 đi xuống, mặc dù danh sách kề được thêm hai chiều. - Gọi
dfs(1, 0, 0)
để tiền xử lý. Gốc là 1, cha của gốc quy ước là 0 (một giá trị không tồn tại trong tập nút {1..n}), độ sâu của gốc là 0. - Thực hiện các truy vấn
findLCA
với các cặp nút khác nhau và in kết quả.
- Xây dựng cấu trúc cây bằng cách thêm các cạnh vào danh sách kề
Độ phức tạp
- Tiền xử lý (DFS): Duyệt qua mỗi cạnh và mỗi nút một lần. Độ phức tạp thời gian là O(N + M), trong đó N là số nút và M là số cạnh. Trong cây, M = N-1, nên là O(N). Độ phức tạp không gian là O(N) để lưu trữ danh sách kề, mảng
parent
vàdepth
. - Truy vấn (findLCA): Trong trường hợp xấu nhất (cây suy biến thành danh sách liên kết), để điều chỉnh độ sâu hoặc di chuyển đồng thời, chúng ta có thể phải đi lên tới H bước, trong đó H là chiều cao của cây. Độ phức tạp thời gian cho mỗi truy vấn là O(H). Trong trường hợp xấu nhất, H = N.
Comments