1 Kỹ thuật FindUnion 2 1.1 Công viên 2 1.2 Thành phần liên thông 8 1.3 Tính liên thông 13 1.4 Chu trình 14 1.5 Cây khung 17 1.6 Cây khung cực tiểu 20 1.7 Rừng khung 24 1.8 Rừng khung cực tiểu 30 1.9 Mạng 35 1.10 Van nước 39 1.11 Cầu 42 1.12 Giao khung 47 1.13 Đỉnh khớp 47 1.14 Liên thông hóa 52 1. 15 Chia đội 55 1.16 Kiến 59
Trang 1MỤC LỤC
1 Kỹ thuật Find-Union 2
1.1 Công viên 2
1.2 Thành phần liên thông 8
1.3 Tính liên thông 13
1.4 Chu trình 13
1.5 Cây khung 16
1.6 Cây khung cực tiểu 20
1.7 Rừng khung 24
1.8 Rừng khung cực tiểu 30
1.9 Mạng 35
1.10 Van nước 39
1.11 Cầu 42
1.12 Giao khung 47
1.13 Đỉnh khớp 47
1.14 Liên thông hóa 51
1 15 Chia đội 55
1.16 Kiến 59
1
Trang 2Kỹ thuật Find-Union
Kỹ thuật Find-Union (Tìm-Gộp) dùng để quản lí hợp của các tập rời nhau
Ta minh hoạ kỹ thuật này qua bài toán sau:
Input: text file Park.inp
• Dòng đầu tiên: hai số nguyên dương n và m,
• Dòng thứ i trong số m dòng tiếp theo: mỗi dòng hai số nguyên dương a b là mã số của hai bạn được ghép vào cùng nhóm.
Output: Hiển thị
• Dòng đầu tiên: k − số lượng nhóm
• Tiếp đến là k dòng, mỗi dòng liệt kê danh sách một nhóm.
Thí dụ
Park.inp Output Ý nghĩa: Tổng cộng các em được chia thành 3 nhóm Nhóm thứ nhất gồm 3 bạn có số
hiệu là 1, 2 và 5; Nhóm thứ hai 3 bạn: 3, 4 và 7 Nhóm cuối cùng có 2 bạn: 6 và 8
Trang 3Điều lí thú trong kỹ thuật này là ở điểm nhóm trưởng chính là em mang số hiệu nhỏ nhất trong nhóm Lúc đầu, chưa ghép cặp thì mỗi em tạo thành một nhóm riêng Như vậy, lúc đầu ta có n nhóm Vì mỗi nhóm chỉ có duy nhất 1 em nên chính em đó là nhóm trưởng của chính mình Ta mường tượng mỗi bạn j phải bám thắt lưng bạn đứng trước i trong cùng nhóm Theo qui định của cô giáo thì j > i Riêng nhóm trưởng, do có số hiệu nhỏ nhất nên i tự bám thắt lưng mình
Ta sử dụng mảng nguyên một chiều d[1 n] để quản lí các nhóm Ta gán d[j] = i nếu bạn j phải bám vào bạn
i là bạn có số hiệu nhỏ hơn mình: i j nếu I < j Lúc đầu ta khởi trị d[i] = i; i = 1 n: i i, với ý nghĩa: lúc đầu mỗi bạn tạo thành một nhóm 1 người và tự mình làm nhóm trưởng, do đó mỗi bạn tự bám thắt lưng mình Mảng
d được gọi là mảng tham chiếu Như ta sẽ thấy sau này, nhờ d ta có thể nhanh chóng tìm được nhóm trưởng của bất kì em nào
Ta qui ước gọi d là mảng chứa thông tin liên kết giữa các phần tử trong các tập con.
Mỗi khi cô giáo yêu cầu ghép cặp (i, j) thì ta hiểu là: nhóm có em i cần hợp (gộp) với nhóm có em j Để ghép cặp (i, j) ta thực hiện các bước sau đây:
Bước 1 Tìm nhóm trưởng x của nhóm có em i;
Bước 2 Tìm nhóm trưởng y của nhóm có em j;
Bước 3 Quyết định xem ai sẽ là nhóm trưởng của nhóm gộp mới này Dễ hiểu là nhóm trưởng mới sẽ là em
có số hiệu nhỏ nhất trong 2 nhóm trưởng x và y, tức là Min(x,y) Sau đó ta gộp 2 nhóm theo kỹ thuật tham chiếu: Giả sử x < y Ta chỉ việc gán d[y] = x, yx Ý nghĩa của việc này là: cô giáo qui định nhóm trưởng y phải bám thắt lưng nhóm trưởng x Ta suy ra ngay rằng mọi em trong nhóm y đi theo nhóm trưởng y, mà y lại
đi theo x nên mọi em trong nhóm y sẽ đi theo x Nếu x > y ta gán d[x] = y, xy với ý nghĩa tương tự như trên Nếu x = y thì có nghĩa là i và j hiện ở trong cùng một nhóm (vì có cùng nhóm trưởng) nên ta không phải làm gì Bạn quan sát lần lượt các hình dưới đây để phát hiện ra qui luật cập nhật từng cặp học sinh theo yêu cầu của
Trang 4Để cập nhật cặp (5,1) ta để ý rằng nhóm trưởng của 5 là 2, vì d[5] = 2, nhóm trưởng của 1 là 1, vì d[1] = 1.
Vì 1 < 2 nên ta cho nhóm trưởng 2 bám vào nhóm trưởng 1, tức là gán d[2] = 1, 21
d [1] [2] [3] [4] [5] [6] [7] [8]
Trang 5Hàm Init khởi trị mảng d[1 n], d[i] = i với ý nghĩa lúc đầu mỗi em tạo thành một nhóm riêng biệt với nhóm trưởng là chính mình.
Để gộp 2 nhóm: nhóm có em x và nhóm có em y ta lưu ý hai điểm sau đây:
• Rất có thể 2 em x và y hiện đã trong cùng một nhóm Trường hợp này ta không phải làm gì Hàm sẽ cho ra giá trị 0 Ta thấy x và y cùng nhóm khi và chỉ khi x và y có cùng nhóm trưởng, tức là Find(x) = Find(y).
• Nếu 2 em x và y thuộc 2 nhóm khác nhau, Find(x) ≠ Find(y), thì ta thực sự gộp 2 nhóm này và hàm Union cho ra giá trị 1.
int Union(int x, int y) {
Nhớ rằng lúc đầu ta có tổng cộng n nhóm Mỗi khi ta gộp thực sự 2 nhóm, tức là khi Union(x,y) = 1 thì số lượng nhóm sẽ giảm đi 1 Nhờ nhận xét này ta dễ dàng tính được số nhóm tại thời điểm kết thúc.
Kết quả của thuật toán sử dụng kỹ thuật Find-Union không phụ thuộc vào trật tự duyệt các cặp ghép
Độ phức tạp tính toán: Hàm Find thực hiện tối đa n bước tham chiếu x = d[x] Hàm Union gọi hàm Find do
đó có độ phức tạp O(n) Tổng cộng lại, với m cặp ghép ta có độ phức tạp cỡ n.m – tuyến tính theo chiều dài input.
Ghi nhớ: Để vận dụng kỹ thuật Find-Union ta cần:
5
d [1] [2] [3] [4] [5] [6] [7] [8]
Trang 61 Khai báo một mảng nguyên d, khởi trị d[i] = i với i = 1 n;
ifstream f(inf); // mo input file
f >> n >> m; // doc so nguoi n, so cap ghep m
Initd();
soNhom = n; // luc dau co n nhom
for (i = 1; i <= m; ++i){ // duyet cap ghep thu i
f >> x >> y; // doc cap ghep x y
soNhom -= Union(x,y); // gop nhom co x voi nhom chua y
// giam so nhom neu hai nhom khac nhau
}
f.close(); // dong input file
// Giai trinh ket qua
cout << "\n So Nhom: " << soNhom;
for (i = 1; i <= n; ++i) {
if (d[i] == i) { // gap nhom truong cua mot nhom moi
cout << "\n Nhom " << i << " "; // Hien thi nhom truong
// Hien thi nguoi trong nhom i
for (j = i + 1; j <= n; ++j)
if (Find(j) == i) cout << j << " ";
}
}
Trang 7(* park.pas: Ki thuat Find-Union *)
const MN = 2001; nl = #13#10; { xuong dong }
readln(f,n,m); { doc so nguoi n, so cap ghep m }
Initd; // Khoi tri mang d[1 n] = (1 n)
soNhom := n; { luc dau co n nhom }
for i := 1 to m do
begin { duyet cap ghep thu i }
readln(f,x,y); // doc cap ghep x y
{ gop nhom co x voi nhom chua i
giam so nhom neu hai nhom khac nhau }
soNhom := soNhom - Union(x,y);
end;
close(f); { dong input file }
{ Giai trinh ket qua }
writeln(nl, ' So Nhom: ', soNhom);
for i := 1 to n do
7
Trang 8begin
if (d[i] = i) then
begin { gap nhom truong cua mot nhom moi i }
write(nl, ' Nhom ',i, ' '); { Hien thi nhom truong }
{ Hien thi so thanh vien trong nhom i }
Trong các mục tiếp theo sẽ trình bày các ứng dụng kỹ thuật Find-Union vào các bài toán trên đồ thị.
Một đồ thị hữu hạn G = (V, E) bao gồm tập V với n đỉnh mã số từ 1 n và tập cạnh E với các cặp đỉnh (x, y)
nối hai đỉnh x và y Nếu các cạnh (x, y) đều có chiều đi từ đỉnh x đến đỉnh y, xy thì ta có đồ thị có hướng;
ngược lại, khi không qui định hướng đi thì ta thu được đồ thị vô hướng Mỗi cạnh (x, y) của đồ thị vô hướng
cho phép ta đi từ đỉnh x đến đỉnh y, xy, hoặc ngược lại, từ đỉnh y đến đỉnh x, xy giống như qui định trong
giao thông về đường một hoặc hai chiều Trong đồ thị có hướng ta dùng thuật ngữ cung (x, y) thay cho cạnh Trong đơn đồ thị người ta qui định giữa hai đỉnh bất kì x và y có không quá 1 cạnh (cung).
Trong sách này, nếu không ghi chú gì thêm, ta ngầm hiểu các đồ thị được cho là đơn đồ thị hữu hạn.
1.2 Thành phần liên thông
Cho đồ thị vô hướng G = (V, E) với n đỉnh V = {1, 2, …, n} và m cạnh (x,y) nối đỉnh x với đỉnh y Hãy tính
số thành phần liên thông và liệt kê tập đỉnh trong mỗi thành phần của G.
Đây chính là nội dung của bài đi công viên được trình bày qua ngôn ngữ đồ thị Đồ thị vô hướng G được gọi
là liên thông nếu từ một đỉnh bất kì i ta có thể đi theo một số cạnh liền kề nhau của đồ thị để đến một đỉnh j bất
kì Dãy các cạnh liền kề từ đỉnh i đến đỉnh j được gọi là một đường từ i đến j Với đồ thị vô hướng, khi có
đường từ đỉnh i đến đỉnh j thì ta cũng có đường từ đỉnh j đến đỉnh i Nếu G không liên thông thì G được chia
Trang 9thành các mảnh liên thông, còn gọi là thành phần liên thông, gồm một số đỉnh và cạnh của G Hãy tưởng tượng
mỗi đỉnh của đồ thị như là một hạt cườm, cạnh nối hai đỉnh i và j chính là sợi dây nối hai hạt i và j Khi đó, G là
liên thông khi và chỉ khi ta cầm một hạt bất kì nhấc lên thì tất cả các hạt đều được nhấc theo Nếu G không liên
thông thì mỗi lần nhấc một hạt ta được một mảnh lên thông gồm một số hạt được liên kết với nhau Ta đặt riêng mảnh đó ra, rồi nhấc một hạt bất kì trong số các hạt còn lại ta sẽ được mảnh liên thông thứ hai,…
Nhận xét: G là liên thông khi và chỉ khi số mảnh liên thông của G là 1
Đồ thị trong thí dụ dưới đây gồm 3 mảnh liên thông (1, 5, 2), (4, 3, 7) và (8, 6).
Nhận xét: Số thành phần liên thông của G = số nhóm trưởng.
Để tính số thành phần liên thông của G ta thực hiện hai bước sau:
Bước 1 Khởi trị d[i] = i; i = 1 n; gán k = n dùng để đếm số mảnh liên thông.
Bước 2 Đọc dần từng cạnh (x,y) và thực hiện toán tử k = k – Union(x,y);
k sẽ là số thành phần liên thông của G.
Lưu ý rằng hàm Union(x,y) cho giá trị 1 nếu thành phần liên thông chứa đỉnh x được gộp thực sự với thành phần liên thông chứa đỉnh y, ngược lại, Union(x,y) = 0 nếu hai đỉnh x và y hiện đang cùng có mặt trong một thành phần liên thông (do đó không cần thiết gộp hai thành phần này) Như vậy ý nghĩa của giá trị ra của hàm Union(x,y) là giảm số lượng thành phần liên thông của G Dĩ nhiên, sau khi thực hiện hàm Union(x,y) thì hai đỉnh x và y sẽ thuộc cùng một thành phần liên thông
Để cài đặt, ta chỉ cần tách hàm Run trong bài Công viên thành hai hàm Hàm thứ nhất như đã trình bày ở trên:
int SoThanhPhanLienThong(){
int i, x, y, k;
ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n va so canh m
Initd(); // Khoi tri mang tham chieu d
Hàm thứ hai liệt kê các đỉnh thuộc mỗi thành phần liên thông như sau:
Duyệt các đỉnh i trong mảng d, nếu i là nhóm trưởng, tức là Find(i) = i thì duyệt tiếp các đỉnh j = i+1 n
và thỏa điều kiện Find(j) = i , tức là j thuộc thành phần liên thông i thì hiển thị đỉnh j
9
Trang 10// Liet ke cac thanh phan lien thong
// Dem so thanh phan lien thong cua do thi
// vo huong G gom n dinh, m canh
Trang 11Liet ke cac thanh phan lien thong
cua do thi vo huong n dinh, m canh
Trang 12function SoThanhPhanLienThong: integer;
var i,k,x,y: integer;
{ gop nhom co x voi nhom chua i
giam so nhom neu hai nhom khac nhau }
Thanh phan liên thong thuoc dinh 1: 1 2 5
Thanh phan liên thong thuoc dinh 3: 3 4 7
Thanh phan liên thong thuoc dinh 6: 6 8
Trang 13• Số lượng thành phần liên thông trong G = số lượng nhóm trưởng = số lượng các đỉnh i thỏa điều kiện Find(i) = i;
• Hai đinht i và j thuộc cùng một thành phần liên thông khi và chỉ khi Find(i) = Fìd(j);
• Mỗi đỉnh i thuộc một thàn phần liên thông duy nhất.
Trang 14graph.inp Output Ý nghĩa:
Thật vậy, Union(x, y) = 0 chứng tỏ hai đỉnh x và y thuộc cùng một thành phần liên thông, nghĩa là có đường
đi từ y đến x Nay ta thêm cạnh (x, y) tức là ta có thể đi tiếp thêm một bước nữa từ x đến y, hoặc từ y đến x vì
đồ thị là vô hướng Như vậy là đã xuất hiện một chu trình từ x trở về x.
Chú ý rằng thuật toán chỉ cho biết đồ thị có một chu trình nào đó hay không chứ không chỉ ra chu trình cụ thể nào
int ChuTrinh(){
int i, x, y;
ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
// Kiem tra tinh chu trinh
// cua do thi vo huong G gom n dinh, m canh
#include <iostream>
#include <fstream>
#include <string.h>
Trang 15ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
Kiem tra tinh lien thong cua do thi
vo huong G gom n dinh, m canh
Trang 16function ChuTrinh: integer;
var i,x,y,GapChuTrinh: integer;
Cây khung (còn gọi là cây bao trùm) của một đồ thị liên thông n đỉnh, m cạnh là cây gồm n đỉnh với số
cạnh tối thiểu bảo toàn tính liên thông của đồ thị.
Nhận xét:
Cây khung của đồ thị n đỉnh, m cạnh có đúng n đỉnh và n − 1 cạnh
Thuật toán Kruskal:
Trang 17• Bước 1 Khởi trị với cây khung rỗng;
• Bước 2 Duyệt các cạnh: nếu cạnh (u,v) không tạo thành chu trình với cây khung thì nạp (u,v) vào cây
khung
Điều kiện để cạnh (u,v) không tạo thành chu trình với cây khung là Union(u, v) = 0
Ta sử dụng mảng khung với mỗi phàn tử là một bản ghi gồm 2 trường nguyên x và y dùng để chứa các cạnh (x,y) được chọn vào cây khung, trong đó x và y là số hiệu hai đỉnh của cạnh.
struct canh { int x, y; } khung[MN];
void CayKhung(){
int i, j, u, v, n1;
ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
f.close(); // dong input file
// Giai trinh ket qua
cout << "\n Cay Khung gom " << n << " dinh, "
<< j << " canh:";
for (i = 1; i <= j; ++i)
cout << endl << khung[i].x << " " << khung[i].y;
}
Hoạt động của chương trình được minh họa qua thí dụ sau:
graph.inp Output Ý nghĩa:
// ST.cpp: Thuat toan Kruskal
// Spaning Tree: Tim cay khung
// trong do thi lien thong n dinh, m canh
Trang 18ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
f.close(); // dong input file
// Giai trinh ket qua
cout << "\n Cay Khung gom " << n << " dinh, "
ST.PAS: Thuat toan Kruskal
Spaning Tree: Tim cay khung
trong do thi lien thong n dinh, m canh
Trang 19assign(f,inf); reset(f); { mo input file }
readln(f,n,m); { doc so dinh n, so canh m }
close(f); { dong input file }
{ Giai trinh ket qua }
writeln(nl,' Cay Khung gom ', n ,' dinh, ', j, ' canh:');
Trang 20readln;
END.
1.6 Cây khung cực tiểu
Cho đồ thị G vô hướng và liên thông với n đỉnh và m cạnh, cạnh (u,v) có trọng số p(u,v) là một số dương.
Cây khung cực tiểu (còn gọi là cây bao trùm ngắn nhất) của G là cây khung với tổng trọng số của các cạnh
trong khung là nhỏ nhất.
Thuật toán tìm cây khung cực tiểu hoạt động giống thuật toán tìm cây khung với một điểm khác biệt duy
nhất là duyệt các cạnh theo trật tự tăng dần của trọng số
• Bước 1 Sắp các cạnh tăng theo trọng số;
• Bước 2 Khởi trị với cây khung rỗng;
• Bước 3 Duyệt các cạnh theo trật tự đã sắp: nếu cạnh (u,v) không tạo thành chu trình với cây khung thì
nạp (u,v) vào cây khung
Điều kiện để cạnh (u,v) không tạo thành chu trình với cây khung là Union(u,v) = 0
Tùy theo các ứng dụng, ta có thể sử dụng trọng số để biểu thị độ dài đường đi giữa hai đỉnh hoặc chi phí phải trả khi đi từ đỉnh này đến đỉnh kia
Ta sử dụng mảng c với mỗi phần tử là một bản ghi gồm 3 trường nguyên x, y và p dùng để chứa các cạnh, trong đó x và y là số hiệu các đỉnh đầu mút của cạnh và p là trọng số của cạnh (x,y) đó.
Ngoải ra, ta sử dụng mảng một chiều khung dùng để chứa chỉ số của các cạnh được chọn vào cây khung.
struct canh { int x, y, p; } c[MN];
int khung[MN]; // MN la so luong toi da cac canh
void CayKhungMin(){
int i, j, n1;
int t = 0; // tong trong so
ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
// doc cac canh vao mang c
++j; // nap canh i vao cay khung
khung[j] = i; // Danh dau canh da chon vao cay khung
if (j == n1) break;
}
}
// Giai trinh ket qua
cout << "\n Cay Khung cuc tieu gom " << n << " dinh, "
Trang 21graph.inp Output Ý nghĩa:
Chương trình C++
// Devcpp: MST.cpp
// Minimal Spaning Tree: Tim cay khung cuc tieu
// trong do thi lien thong n dinh, m canh co trong so
// Sap cac canh tang theo trong so p
void Sort(int dau, int cuoi) {
21
Trang 22int i = dau, j = cuoi, g = c[(i+j)/2].p; // trong so giua
int t = 0; // tong trong so
ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
// doc cac canh
// Giai trinh ket qua
cout << "\n Cay Khung cuc tieu gom " << n << " dinh, "
Minimal Spaning Tree: Tim cay khung cuc tieu.
trong do thi lien thong n dinh, m canh
-*)
uses crt;
Trang 23const MN = 2001; nl = #13#10; { xuong dong }
c: array[0 MN] of canh; { luu cac canh }
khung: array[0 MN] of integer;
{ luu so hieu canh cua cay khung }
{ Sap cac canh tang theo trong so p }
procedure Sort(dau, cuoi: integer);
var i, j, g: integer; { g – trong so giua }
if (dau < j) then Sort(dau,j);
if (i < cuoi) then Sort(i,cuoi);
assign(f,inf); reset(f); { mo input file }
readln(f,n,m); { doc so dinh n, so canh m }
for i := 1 to m do { doc m canh }
readln(f,c[i].x,c[i].y,c[i].p);
23
Trang 24close(f);
Sort(1,m); { sap tang m canh theo trong so p }
Initd;
n1 := n-1; { so canh cua cay khung }
j := 0; { dem so canh cua cay khung }
{ Giai trinh ket qua }
write(nl,' Cay Khung cuc tieu gom ', n, ' dinh, ');
Trang 25Như vậy, để cho tiện lợi, ta thêm cho dữ liệu mô tả cạnh một trường manh kiểu nguyên dùng để ghi nhận cạnh này thuộc mảnh liên thông nào
struct canh { int x, y, manh; } khung[MN];
Hàm CanhKhung hoạt động như hàm CayKhung trong các bài trước Hàm cho ra số lượng cạnh được chọn vào rừng khung
int CanhKhung(){
int i, j, u, v, n1;
ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
25
Trang 26if (khung[i].manh != khung[i-1].manh) ++soCay;
cout << "\n Canh " << khung[i].x << " " << khung[i].y
<< " thuoc cay khung cua manh " << khung[i].manh;
Thật vậy, giả sử đồ thị có n đỉnh và k mảnh liên thông Gọi ni là số đỉnh trong mảnh liên thông thứ i; i = 1 k,
t là tổng số cạnh trong các cây của rừng khung Để ý rằng n đỉnh được phân hoạch vào k mảnh liên thông, và cây khung của mảnh liên thông thứ i có ni− 1; i = 1 k Ta có
n1+n2+…+nk = n
t = (n1− 1)+(n2− 1)+…+(nk− 1) = (n1+n2+…+nk) − k = n − k
Chương trình C++
// Devcpp: RungKhung.cpp
// Tim cac cay khung cua cac manh lien thong
// trong do thi vo huong n dinh, m canh
Trang 27int Union(int x, int y) {
ifstream f(inf); // mo input file
f >> n >> m; // doc so dinh n, so canh m
void Sort(int dau, int cuoi){
int i = dau, j = cuoi, g = khung[(i+j)/2].manh;
if (khung[i].manh != khung[i-1].manh) ++soCay;
cout << "\n Canh " << khung[i].x << " " << khung[i].y
<< " thuoc cay khung cua manh " << khung[i].manh;
Trang 28Liet ke cac cay khung
trong do thi n dinh, m canh
assign(f,inf); reset(f); { mo input file }
readln(f,n,m); { doc so dinh n, so canh m }
n1 := n-1;
Initd;
j := 0; { dem so canh cua cay khung }
for i := 1 to m do { Duyet m canh }
Trang 29if(dau < j) then Sort(dau,j);
if(i < cuoi) then Sort(i,cuoi);
Sort(1,j); { Sap tang cac cach khung theo so hieu manh }
{ Giai trinh ket qua }
khung[0].manh := 0; soCay := 0;
for i := 1 to j do
begin
if khung[i].manh <> khung[i-1].manh then inc(soCay);
write(nl,' Canh ',khung[i].x,' ', khung[i].y);
write(' thuoc cay khung ',khung[i].manh);
Canh 1 5 thuoc cay khung cua manh 1
Canh 1 2 thuoc cay khung cua manh 1
Canh 3 8 thuoc cay khung cua manh 3
Canh 4 6 thuoc cay khung cua manh 3
Canh 3 4 thuoc cay khung cua manh 3
29