CHƯƠNG II: CÁC PHƯƠNG PHÁP XÁC ĐỊNH CHA CHUNG GẦN NHẤT CỦA HAI NÚT TRONG CÂY
2.2 Mối quan hệ giữa LCA và RMQ
Bài toán: với 2 nút u, v bất kỳ của cây T, chất vấn LCA(u,v) cho biết cha chung gần nhất của 2 đỉnh u,v trong cây T, tức là cho biết đỉnh xa gốc nhất là cha của cả u, v.
Bài toán RMQ (Range Minimum Query ) : Đầu vào: 1 mảng A với n số.
Chất vấn: Với 2 chỉ số i và j, chất vấn RMQ(i,j) cho biết chỉ số của phần tử nhỏ nhất trong mảng con A[i…j].
Giả sử rằng một thuật toán có thời gian tiền xử lý f(n) và thời gian truy vấn g(n). Các ký hiệu cho sự phức tạp tổng thể cho các thuật toán là
<f(n), g(n)>.
Nút xa nhất từ gốc là tổ tiên của cả u và v trong một số cây bắt nguồn từ T là LCAT (u,v).
Bài toán Range Minimum Query (RMQ) đƣợc phát biểu nhƣ sau:
Cho một mảng A[0,N-1] tìm vị trí của các phần tử có giá trị nhỏ nhất giữa hai chỉ số nhất định.
Hình 2.1 Vị trí của các phần tử trong bài toán RQM
Đối với mỗi cặp chỉ số (i,j) lưu giữ những giá trị của RMQA(i,j) trong một bảng M[0,N-1] [0,N-1]. Độ phức tạp của thuật toán sẽ là
<O(N3),O(1)>. Tuy nhiên, bằng cách sử dụng phương pháp tiếp cận động dễ dàng chúng ta có thể giảm bớt sự phức tạp <O (N2), O (1)>. Các chức năng tiền xử lý sẽ đƣợc viết nhƣ sau:
void process1(int M[MAXN][MAXN], int A[MAXN], int N) {
int i, j;
for (i =0; i < N; i++) M[i][i] = i;
for (i = 0; i < N; i++)
for (j = i + 1; j < N; j++) if (A[M[i][j - 1]] < A[j]) M[i][j] = M[i][j - 1];
else
M[i][j] = j;
}
Tuy nhiên thuật toán này thực hiện khá chậm và sử dụng O (N2) bộ nhớ, vì vậy nó sẽ không làm việc được cho trường hợp lớn.
Nhưng<O(N),O(sqrt (N))> là một giải pháp khắc phục được trường hợp trên.
Một ý tưởng tốt hơn là phân chia các vector trong sqrt(N). Chúng sẽ tiếp tục trong một vector M[0, sqrt (N) -1] vị trí cho các giá trị tối thiểu cho từng phần. M có thể dễ dàng xử lý trước trong O (N). Dưới đây là một ví dụ:
Hình 2.1 Ví dụ về phân chia đoạn trong bài toán RQM
Bây giờ làm thế nào chúng ta có thể tính toán RMQA(i, j). Ý tưởng là để có đƣợc tối thiểu chung từ sqrt (N) phần nằm bên trong các khoảng phần tử cuối và đầu tiên. Để có đƣợc RMQA(2,7) trong ví dụ trên, chúng ta nên so sánh A[2], A[M [1]], A[6] và A[7] và có đƣợc vị trí của các giá trị tối thiểu.
Thật dễ dàng để thấy rằng thuật toán này không làm cho hơn 3*sqrt (N) hoạt động trên truy vấn.
Ưu điểm của phương pháp này là mã hóa nhanh và có thể thay đổi các yếu tố của các mảng giữa các truy vấn.
Một cách tiếp cận tốt hơn là để xử lý trước RMQ cho mảng phụ của chiều dài 2k sử dụng lập trìnhđộng. Ta sẽ giữ một mảng M[0,N-1] [0,logN]
nơi M [i] [j] là chỉ số của các giá trị tối thiểu trong các mảng tiểu bắt đầu từ i có độ dài 2j. Dưới đây là một ví dụ:
Hình 2.2 Ví dụ về bài toán RQM
Đối với máy tính M[i][j], chúng phải tìm kiếm các giá trị tối thiểu trong nửa đầu tiên và thứ hai của khoảng. Rõ ràng rằng các mảnh nhỏ có 2j- 1 chiều dài, vì vậy sự tái phát là:
Hàm sử lý sẽ đƣợc mô tả nhƣ sau:
void process2(int M[MAXN][LOGMAXN], int A[MAXN], int N) {
int i, j;
//initialize M for the intervals with length 1 for (i = 0; i < N; i++)
M[i][0] = i;
//compute values from smaller to bigger intervals for (j = 1; 1 << j <= N; j++)
for (i = 0; i + (1 << j) - 1 < N; i++) if (A[M[i][j - 1]] < A[M[i + (1 << (j - 1))][j - 1]])
M[i][j] = M[i][j - 1];
else
M[i][j] = M[i + (1 << (j - 1))][j - 1];
}
Khi những giá trị này đã đƣợc xử lý, ta có thể sử dụng chúng để tính toán RMQA(i,j). Ý tưởng là để chọn hai khối hoàn toàn bao gồm khoảng [i..j]
và tìm thấy những tối thiểu giữa chúng k = [log (j - i + 1)]. Đối với máy tính RMQA (i, j), Ta có thể sử dụng công thức sau đây:
Vì vậy độ phức tạp của thuật toán là <O(N logN), O(1)>.
Để giải quyết bài toán RMQ ta có thể sử dụng cây phân đoạn. Cây phân đoạn là một cấu trúc dữ liệu có thể đƣợc sử dụng để làm các hoạt động cập nhật/truy vấn trong mảng trong khoảng thời gian thực hiện thuật toán. Ta xác định các cây phân đoạn cho khoảng [i, j] theo cách đệ quy nhƣ sau:
- Nút đầu tiên sẽ tổ chức thông tin cho khoảng [i, j]
- Nếu i <j bên trái và bên phải con sẽ giữ thông tin cho các khoảng [i, (i + j) / 2] và [(i + j) / 2 + 1, j]
Chiều cao của một cây phân đoạn cho mỗi khoảng với các yếu tố N là [logN] + 1. Đây là cách một cây phân đoạn cho khoảng [0, 9] sẽ nhƣ sau:
Hình 2.3: Cấu trúc cây phân đoạn
Cây phân đoạn có cấu trúc giống nhƣ hình trên, vì vậy nếu chúng ta có một số nút x đó không phải là lá con còn lại của x là 2*x và lá con phải 2*x+1.
Để giải quyết bài toán RMQ sử dụng cây phân đoạn ta nên sử dụng một mảng M [1, 2 * 2 [logN] + 1] trong đó M [i] chứa các vị trí có giá trị tối thiểu trong khoảng đƣợc giao đến nút i. Các cây phải đƣợc khởi tạovới hàm sau(b và e là những giới hạn của khoảng thời gian hiện tại):
void initialize(intnode, int b, int e, int M[MAXIND], int A[MAXN], int N)
{
if (b == e)
M[node] = b;
else {
//compute the values in the left and right subtrees initialize(2 * node, b, (b + e) / 2, M, A, N);
initialize(2 * node + 1, (b + e) / 2 + 1, e, M, A, N);
//search for the minimum value in the first and //second half of the interval
if (A[M[2 * node]] <= A[M[2 * node + 1]])
M[node] = M[2 * node];
else
M[node] = M[2 * node + 1];
} }
Các chức năng ở trên trình bày cách cây đƣợc xây dựng. Khi tính toán vị trí tối thiểu cho một số khoảng ta nên nhìn vào giá trị của chúng. Do đó gọi hàm với node = 1, b = 0 và e = N-1.
Bây giờ có thể bắt đầu thực hiện các truy vấn. Nếu muốn tìm vị trí của giá trị tối thiểu ở một số khoảng thời gian [i, j] ta sử dụng hàm sau:
int query(int node, int b, int e, int M[MAXIND], int A[MAXN], int i, int j)
{
int p1, p2;
//if the current interval doesn't intersect //the query interval return -1
if (i > e || j < b) return -1;
//if the current interval is included in //the query interval return M[node]
if (b >= i && e <= j) return M[node];
//compute the minimum position in the //left and right part of the interval
p1 = query(2 * node, b, (b + e) / 2, M, A, i, j);
p2 = query(2 * node + 1, (b + e) / 2 + 1, e, M, A, i, j);
//return the position where the overall //minimum is
if (p1 == -1)
return M[node] = p2;
if (p2 == -1)
return M[node] = p1;
if (A[p1] <= A[p2])
return M[node] = p1;
return M[node] = p2;
}
Gọi hàm này với node = 1, b = 0 và e = N - 1, vì khoảng gán cho nút đầu tiên là [0, N-1].
Ta có thể thấy rằng bất kỳ truy vấn đƣợc thực hiện trong O(log N).
Thuật toán dừng lại chúng tiếp cận hoàn toàn cây, vì vậy đường đi của chúng trong cây đƣợc chia một lần duy nhất.
Sử dụng cây phân đoạn có độ phức tạp của thuật toán là<O (N), O(logN)>. Cây phân đoạn đang đƣợc sử dụng rất phổ biến, không chỉ bởi vì chúng có thể đƣợc sử dụng cho bài toán RMQ. Chúng là một cấu trúc dữ liệu rất linh hoạt, có thể giải quyết ngay cả những phiên bản động của bài toán RMQ, và có nhiều ứng dụng trong các lĩnh vực và phạm vi tìm kiếm.
Bổ đề:
Nếu có 1 thuật toán với độ phức tạp là <f(n),g(n)> cho bài toán LCA thì sẽ có 1 thuật toán với độ phức tạp là <f(n)+n,g(n)+1> cho bài toán RMQ.
Chứng minh:
Giả sử ta có mảng A[1…n] là mảng đầu vào của bài toán RMQ. Ta sẽ xây dựng 1 cây theo cách sau: gốc của cây sẽ tương ứng với phần tử nhỏ nhất của mảng A; sau khi loại bỏ phần tử này đi, mảng A sẽ chia thành 2 phần; con trái và con phải của gốc được xây dựng tương tự từ 2 mảng con bên trái và bên phải mảng ban đầu.
Để xây dựng đƣợc cây này ta mất thời gian là O(N). Gọi Ti là cây tương ứng với mảng A[1…i], để xây dựng cây Ti+1 ta để ý rằng nút i+1 sẽ thuộc đường đi xuất phát từ gốc và luôn đi sang bên phải (gọi là đường Pr).
Vì thế ta sẽ đi theo đường này từ dưới lên trên cho đến khi gặp vị trí thích hợp để chèn nút i+1 vào. Các nút bên dưới sẽ trở thành con trái của nút i+1.
Để ý rằng mỗi lần kiểm tra xem 1 vị trí có thích hợp để chèn nút mới vào không, ta sẽ thêm vào hoặc loại bỏ 1 nút từ đường Pr, và 1 nút chỉ có thể được thêm vào và loại bỏ khỏi đường Pr nhiều nhất 1 lần. Vì thế ta xây dựng cây sẽ mất O(N).
Sau khi xây dựng cây, gọi k = LCA(i,j) với i,j bất kỳ. Nhƣ thế k sẽ là nút đầu tiên chia cắt i,j. Theo cách xây dựng cây thì k chính là phần tử nhỏ nhất của mảng con A[i…j]. Vì thế RMQ(i,j) = LCA(i,j).
Như thế để tính RMQ(i,j) ta mất O(N) ở bước chuẩn bị để xây dựng cây và O(1) để gọi thủ tục LCA(i,j) trên cây. Bổ đề đƣợc chứng minh.
Bổ đề:
Nếu có 1 thuật toán với độ phức tạp là <f(n),g(n)> cho bài toán RMQ thì sẽ có 1 thuật toán với độ phức tạp là <f(n)+n,g(n)+1> cho bài toán LCA.
Chứng minh:
Giả sử ta có 1 cây T là cây đầu vào của bài toán LCA. Dùng thủ tục DFS cho cây này và viết ra các đỉnh của cây mỗi khi đỉnh đó đƣợc đi qua.
Khi đó ta sẽ nhận đƣợc 1 dãy 2*n-1 số bởi vì chúng ta bắt đầu liệt kê từ đỉnh gốc, với mỗi cạnh ta sẽ liệt kê đƣợc 2 đỉnh ( ở lần đi và lần về ). Có n- 1 cạnh nên số đỉnh sẽ là 2*(n-1)+1.
Gọi mảng E[1…2n-1] là mảng ghi danh sách các đỉnh đƣợc thăm bằng cách DFS trên.
Khởi tạo mảng L[1…2n-1], L[i] cho ta biết khoảng cách từ nút E[i]
tới gốc của cây.
Khởi tạo mảng R[1…n], R[i] cho ta biết vị trí xuất hiện của số i trong mảng E. Nếu số i xuất hiện nhiều lần thì ta lấy vị trí nào cũng đƣợc.
Dựa vào nhận xét : LCA của 2 nút u và v chính là nút có khoảng cách tới gốc nhỏ nhất trong số các nút xuất hiện từ lúc ta thăm tới u cho đến khi ta thăm tới v.
Thực hiện thao tác i = RMQ(R[u], R[v]) trên mảng L, khi đó i sẽ là chỉ số của phần tử nhỏ nhất trong khoảng L[R[u]…R[v]], E[i] cho ta kết của của phép chất vấn LCA(u,v).
Nhƣ thế để tính LCA(u,v) ta mất O(n) để khởi tạo các mảng E,L,R.
và mất O(1) để gọi thủ tục RMQ(R[u],R[v]). Bổ đề đƣợc chứng minh.
Nhận xét : bài toán RMQ phát sinh khi giải bài toán LCA chỉ là 1 trường hợp đặc biệt của bài toán RMQ tổng quát bởi các phần tử liên tiếp nhau trong mảng hơn kém nhau đúng 1 đơn vị. ( Do 2 phần tử liên tiếp là khoảng cách tới gốc của 2 nút có quan hệ cha con với nhau ). Bài toán này gọi là ±1RMQ.
Để giải bài toán RMQ ta có thể sử dụng cấu trúc dữ liệu Interval Tree. Khi đó thuật toán sẽ có độ phức tạp là <NlogN,LogN> và sử dụng O(N) bộ nhớ. Một thuật toán khác nhanh hơn với độ phức tạp là
<NlogN,1> sẽ được trình bày dưới đây:
Gọi F[i,j] = RMQ(i,i+2^j-1). Ta có thể khởi tạo mảng F trong thời gian NlogN bằng cách sử dụng QHĐ. Khi đó RMQ(i,j) = F[i,t] nếu A[F[i,t]] < A[F[j-2^t+1,t]] và RMQ(i,j) = F[j-2^t+1,t] nếu ngƣợc lại với t = Trunc(Log(j-i+1)).
Cũng với phương pháp trên và một số cải tiến ta có thể giải bài toán
±1RMQ trong <N,1> nhƣ sau :
Giải sử A là mảng đầu vào của bài toán ±1RMQ. Chia mảng A thành các block liên tiếp với độ dài là logN/2. Định nghĩa mảng A’[1…2*N/LogN] với A’[i] cho ta biết giá trị nhỏ nhất của block thứ i và B[1…2*N/LogN] với B[i] là vị trí mà giá trị A’[i] xuất hiện trong block đó.
Sử dụng thuật toán trên với mảng A’ ta sẽ có thuật toán với độ phức tạp là
<N,1>. Để thực hiện chất vấn RMQ(i,j) trên mảng A, nếu i,j thuộc các block khác nhau thì ta sẽ làm nhƣ sau :
- Tìm Min của đoạn từ i đến phần cuối của block chứa i
- Tìm Min của các block nằm giữa block chứa i và block chứa j - Tìm Min của đoạn từ đầu block chứa j đến j
Thao tác thứ 2 hoàn toàn có thể thực hiện trong <N,1> dựa vào mảng A’ và B. Vì vậy ta chỉ cần quan tâm đến trường hợp i,j nằm trên cùng 1 block.
Để ý 1 block có 2 đặc trƣng : 1 là giá trị của phần tử đầu tiên, 2 là dãy nhị phân ứng với mỗi phần tử của block cho ta biết nó lớn hơn hay nhỏ hơn phần tử trước. Nếu 2 block có cùng đặc trưng 2 thì ta có thể sử dụng kết quả chất vấn của block này để xác định kết quả chất vấn của block kia.
Số đặc trƣng 2 chỉ vào khoảng 2^(logN/2-1) = O(sqrt(N)) là rất nhỏ, vì thế ta có thể xác định trước các đặc trưng 2 cũng như kết qủa của tất cả các câu chất vấn chỉ mất O( sqrt(N)*LogN*LogN). Với N đủ lớn ta có thể coi sqrt(N)*Log(N)*Log(N) < N ( nếu muốn ta có thể sử dụng các block với độ dài là LogN/3, LogN/4 để đạt đƣợc bất đẳng thức này). Để xác định đặc trƣng 2 của các block trong A cũng chỉ mất O(N) khi khởi tạo. Vì thế bài toán ±1RMQ có thể giải quyết trong <N,1> với bộ nhớ là O(N).
Với thuật toán ±1RMQ <N,1> ta có thể đƣa ra đƣợc thuật toán LCA
<N,1> và thuật toán RMQ tổng quát cũng với thời gian <N,1>.
Trong lý thuyết đồ thị và khoa học máy tính, tổ tiên thấp nhất (LCA) của hai nút u và v trong một câylà thấp nhất (tức là sâu nhất) nút đó có cả u và v là con cháu, mà mỗi nút là một hậu duệ của chính nó (vì vậy nếu v có một kết nối trực tiếp từ v thì v là tổ tiên chung thấp nhất).
Ví dụ:
Hình 2.4 Hình cây của thuật toán LCA
- Ở ví dụ trên node 9 và 12 có tổ tiên chung thấp nhất là node 3 Giải quyết bài toán bằng <O (N), O (sqrt (N))>
Chia đầu vào của cây thành những phần kích thước bằng nhau, chứng tỏ là một cách để giải quyết các vấn đề RMQ. Phương pháp này có thể đƣợc điều chỉnh để giải quyết cho các vấn đề của bài toán LCA. Ý tưởng là để phân chia câysqrt(H) thành các bộ phận, là H là chiều cao của cây. Nhƣ vậy, phần đầu tiên sẽ chứa các mức đánh số từ 0 đến sqrt (H) - 1, thứ hai sẽ chứa các mức đánh số từ sqrt (H) 2 * sqrt (H) - 1, và nhƣ vậy.
Đây là cách các cây trong ví dụ này nên đƣợc phân chia:
Hình 2.5 Phân chia đoạn trong bài toán LCA
Ta thấy, đối với mỗi nút, ta thấy rằng tổ tiên nằm trên cấp độ cuối cùng của phần tiếp theo trên. Chúng tôi sẽ xử lý trước các giá trị này trong một mảng P [1, MAXN]. Sau đây ví dụ cho mỗi node i trong phần đầu tiên cho P [i] = 1):
Với các nút nằm ở mức trên là những node đầu tiên ở một số đoạn, P[i] = T[i]. Ta có thể xử lý P đầu tiên sử dụng tìm kiếm theo chiều sâu (T[i]
là cha của node i trong cây, [sqrt (H)] và L [i] là mức độ của nút i):
void dfs(int node, int T[MAXN], int N, int P[MAXN], int L[MAXN], int nr) {
int k;
//if node is situated in the first
//section then P[node] = 1
//if node is situated at the beginning //of some section then P[node] = T[node]
//if none of those two cases occurs, then //P[node] = P[T[node]]
if (L[node] < nr) P[node] = 1;
else
if(!(L[node] % nr)) P[node] = T[node];
else
P[node] = P[T[node]];
for each son k of node
dfs(k, T, N, P, L, nr);
}
Bây giờ, chúng ta có thể dễ dàng thực hiện các truy vấn. Để tìm LCA (x, y),đầu tiên chúng ta sẽ tìm thấy những gì trong phần phát sinh và sau đó thực hiện tính toán.
Dưới đây là đoạn mã dùng để thực hiện:
int LCA(int T[MAXN], int P[MAXN], int L[MAXN], int x, int y)
{
//as long as the node in the next section of //x and y is not one common ancestor
//we get the node situated on the smaller //lever closer
while (P[x] != P[y]) if (L[x] > L[y]) x = P[x];
else
y = P[y];
//now they are in the same section, so we trivially compute the LCA
while (x != y)
if (L[x] > L[y]) x = T[x];
else
y = T[y];
return x;
}
Hàm này làm cho nhiều nhất là 2*sqrt (H) hoạt động. Sử dụng phương pháp này, chúng ta có được độ phức tạp của thuật toán là <O (N), O (sqrt (H))>, trong đó H là chiều cao của cây. Trong trường hợp xấu nhất H = N, do sự phức tạp tổng thể là <O (N), O (sqrt (N))>. Ƣu điểm chính của thuật toán này là mã hóa nhanh (trung bình một lần chia không mất quá 15 phút để thực hiện).
Nếu chúng ta cần một giải pháp nhanh hơn cho vấn đề này, chúng ta có thể sử dụng chương trình động. Đầu tiên, chúng ta hãy tính toán một bảng P[1, N] [1,logN] nơi P [i] [j] là tổ tiên 2j thứ của nói. Để tính toán giá trị này chúng ta có thể sử dụng đệ quy nhƣ sau:
Hàm dùng để thực hiện sẽ nhƣ sau:
void process3(int N, int T[MAXN], int P[MAXN][LOGMAXN]) {
int i, j;
//we initialize every element in P with -1 for (i = 0; i < N; i++)
for (j = 0; 1 << j < N; j++) P[i][j] = -1;
//the first ancestor of every node i is T[i]
for (i = 0; i < N; i++) P[i][0] = T[i];
//bottom up dynamic programing
for (j = 1; 1 << j < N; j++) for (i = 0; i < N; i++) if (P[i][j - 1] != -1)
P[i][j] = P[P[i][j - 1]][j - 1];
}
Để thực hiện O (N logN) thời gian xử lý. Vậy làm thế nào để chúng có thể thực hiện truy vấn. L[i] là mức của node i trong cây. Ta ta phải thấy rằng nếu p và q là trên cùng cấp trong cây, ta có thể tính toán LCA (p, q) sử dụng một tìm kiếm nhị phân. Vì vậy, đối với mỗi khả năng j của 2 (giữa log (L [p]) và 0, thứ tự giảm dần), nếu P [p] [j]! = P [q] [j] thì chúng ta biết rằng LCA (p, q) là trên một cấp độ cao hơn và chúng tôi sẽ tiếp tục tìm kiếm cho LCA (p = P [p] [j], q = P [q] [j]). Cuối cùng, cả hai p và q sẽ có người cha giống nhau, nên trở về T [p]. Nhưng điều gì xảy ra nếu L [p]! = L [q]. Giả sử, không mất tính tổng quát, mà L [p] <L [q]. Chúng có thể sử dụng tìm kiếm nhị phân tương tự cho việc tìm kiếm tổ tiên của p nằm trên cùng cấp với q, và sau đó chúng ta có thể tính toán LCA nhƣ đƣợc mô tả dưới đây. Dưới đây là cách các hàm truy vấn cần thực hiện:
int query(int N, int P[MAXN][LOGMAXN], int T[MAXN], int L[MAXN], int p, int q)
{
int tmp, log, i;
//if p is situated on a higher level than q then we swap them
if (L[p] < L[q])
tmp = p, p = q, q = tmp;
//we compute the value of [log(L[p)]
for (log = 1; 1 << log <= L[p]; log++);
log--;
//we find the ancestor of node p situated on the same level