Nguồn: TopCoder
Người dịch: T
Disjoint Sets là cấu trúc dữ liệu hữu ích được sử dụng thường xuyên trong các cuộc thi, từ VOI (Vietnam Olympiad Informatics) đến IOI (International Informatics Olympiad). Bài viết này sẽ đưa ra các cách cài đặt Disjoint Sets hiệu quả và nhanh nhất.
Mở đầu, ta hãy xét bài toán sau:
Trong một phòng có người, ta định nghĩa hai người là bạn nếu họ là bạn bè trực tiếp của nhau hoặc gián tiếp là bạn của nhau. Bạn gián tiếp có nghĩa là nếu là bạn với , và là bạn với , thì cũng là bạn với . Một nhóm bạn là một nhóm mà hai người bất kỳ trong nhóm là bạn. Đưa ra danh sách những người là bạn bè trực tiếp với nhau, hãy tìm số nhóm bạn và số người trong mỗi nhóm.
Với và danh sách bạn bè là: , , và . Đây là hình của đồ thị đại diện cho các nhóm bạn. và là bạn, sau đó và là bạn, cuối cùng và là bạn, nhưng là bạn với ; do đó và là bạn bè, v.v.
Cuối cùng ta có nhóm bạn: một nhóm là , và nhóm còn lại là .
Đầu tiên, Disjoint Sets là một cấu trúc dữ liệu quản lý các tập hợp không giao nhau, chứa các tập hợp không giao nhau , , , …, . Hai tập hợp không giao nhau nếu giao của chúng là tập hợp rỗng. Trong cấu trúc dữ liệu Disjoint Sets, mọi tập hợp đều có một đại diện là một phần tử của tập hợp đó. Trong các bài toán, Disjoint Sets có vai trò gộp hai tập hợp rời rạc với nhau.
Hãy xét phương thức hoạt động của Disjoint Sets đối với ví dụ đã cho ở bài toán mở đầu.
Các phần tử sẽ được sắp xếp thành các tập hợp, và đại diện của mỗi tập hợp là giá trị có chỉ số lớn nhất. Lúc đầu có tập hợp: , , , , . Không ai là bạn của bất kỳ ai và mọi người đều là đại diện cho nhóm của chính mình. Bước tiếp theo là và trở thành bạn bè, điều này có nghĩa là nhóm có và nhóm có sẽ trở thành một nhóm. Khi đó ta có các nhóm sau: , trong đó đại diện của nhóm đầu tiên sẽ là . Tiếp theo, và trở thành bạn bè. Các nhóm sẽ là . Cuối cùng và trở thành bạn bè, và các nhóm sẽ là . Đại diện của nhóm đầu tiên sẽ là và đại diện cho nhóm thứ hai là . (Ta sẽ xem xét lý do tại sao ta cần các đại diện ở phần sau). Sau cùng thì ta có tập hợp, tập hợp đầu tiên có bốn phần tử và tập hợp thứ hai có một phần tử, và đây cũng là câu trả lời cho bài toán ví dụ: hai nhóm, trong đó có một nhóm bốn người và một nhóm một người.
Vậy, làm thế nào để kiểm tra xem hai người có trong cùng một nhóm hay không? Đây là lí do mà ta sử dụng các phần tử đại diện. Giả sử ta muốn kiểm tra xem và có thuộc cùng một nhóm hay không, ta kiểm tra hai đại diện của các tập hợp chứa và . Nếu đại diện của tập hợp chứa giống với đại diện của tập hợp chứa thì và cùng thuộc một nhóm. Ở đây, ta có một đại diện là và đại diện còn lại là ; do đó và không ở trong cùng một nhóm bạn.
Ta hãy định nghĩa một số thao tác sau:
create-set(x)
- tạo một tập hợp mới với một phần tử .merge-sets(x, y)
- gộp tập hợp chứa phần tử và tập hợp chứa phần tử (với và nằm trong các tập hợp khác nhau) thành một tập hợp mới. Đồng thời, hai tập hợp ban đầu sẽ bị xóa.find-set(x)
- trả về đại diện hoặc con trỏ đến đại diện của tập hợp chứa phần tử .Lời giải của bài toán ban đầu đã sử dụng các thao tác trên:
Khi đó, để kiểm tra xem 2 người có trong cùng một nhóm hay không, ta kiểm tra xem liệu (find-set(x)
=find-set(y)
) hay không.
Một cách để triển khai Disjoint Sets là biểu diễn từng tập hợp bằng một danh sách liên kết. Danh sách liên kết là một cấu trúc dữ liệu trong đó là một danh sách mà mỗi phần tử đều liên kết với phần tử đứng sau nó trong danh sách. Khi cài đặt Disjoint Sets, mỗi đối tượng (phần tử) sẽ nằm trong danh sách liên kết và chứa hai con trỏ. Một con trỏ chỉ đến phần tử tiếp theo trong tập hợp và một con trỏ khác đến đại diện của tập hợp.
Dưới đây là một hình ảnh minh họa cho bài toán ví dụ sau khi tất cả các hoạt động được thực hiện.
Các mũi tên màu xanh là con trỏ đến đại diện tập hợp và các mũi tên màu đen là con trỏ đến phần tử tiếp theo trong tập hợp.
Độ phức tạp thuật toán
Khi biểu diễn các tập hợp với danh sách liên kết, ta xét từng thao tác:
create-set(x)
: chỉ tạo một danh sách liên kết mới có phần tử duy nhất là , cho độ phức tạp find-set(x)
: chỉ trả về con trỏ đến đại diện của tập hợp chứa phần tử , cho độ phức tạp merge-sets(x, y)
: bằng việc nối danh sách của vào cuối danh sách của ; trong đó đại diện của tập hợp mới là đại diện của tập hợp ban đầu chứa ; đồng thời sau đó ta phải cập nhật con trỏ thành đại diện cho từng phần tử ban đầu trong danh sách của . Điều này cần thời gian tuyến tính là độ dài của danh sách . Trong trường hợp xấu nhất, độ phức tạp sẽ là , với là số lần lặp lại thao tác.Như vậy, độ phức tạp thuật toán trung bình là cho mỗi vòng lặp tập hợp với trung bình số lượng phần tử trong mỗi tập hợp.
Bằng cách sử dụng thuật giải gộp tập có trọng số (weighted-union heuristic), ta có thể hiệu quả hóa thuật toán. Trong trường hợp này, giả sử rằng đại diện của tập hợp đồng thời chứa thông tin về số lượng phần tử trong tập hợp đó. Cách tối ưu hóa thuật toán là luôn nối danh sách ngắn hơn vào danh sách dài hơn, và trong trường hợp bằng nhau, ta sẽ nối tùy ý. Khi đó độ phức tạp của thuật toán là ; trong đó là số hoạt động của ba thao tác đã đề cập, và là số hoạt động create-set
. Tuy nhiên, BFS sẽ giải quyết bài toán trong và bộ nhớ của . Ta có thể thấy rằng ta đã tối ưu hóa bộ nhớ nhưng không tối ưu hóa được thời gian chạy.
Cây là một trong các cấu trúc hữu hiệu nhất dùng để cài đặt Disjoint Sets. Ta sẽ biểu diễn các tập hợp bằng các nút trong cây, với mỗi nút chứa một phần tử và mỗi cây đại diện cho một tập hợp. Mỗi phần tử sẽ chỉ đến gốc của nó và gốc của mỗi cây là đại diện của tập hợp đồng thời là phần tử gốc của chính nó.
Với ví dụ ở bài toán mở đầu, các cây sẽ được mô tả trong từng bước sau
Bước 1: Không ai là bạn của bất kỳ ai
Chúng ta có 5 cây và mỗi cây có một phần tử duy nhất, đó là gốc và là đại diện của cây đó.
Bước 2: 1 và 2 là bạn bè, merge-sets(1, 2)
:
Thao tác được thực hiện là merge-sets(1, 2)
. Bây giờ, chúng ta có 4 cây, một cây chứa 2 phần tử và có gốc là 1. Các cây còn lại chỉ có một phần tử duy nhất.
Bước 3: 5 và 4 là bạn bè, merge-sets(5, 4)
Thao tác được thực hiện là merge-sets(5, 4)
. Bây giờ chúng ta có 3 cây, 2 cây có 2 phần tử và một cây có một phần tử.
Bước 4: 5 và 1 là bạn bè, merge-sets(5, 1)
Thao tác được thực hiện là merge-sets(5, 1)
. Bây giờ chúng ta có 2 cây, một cây có 4 phần tử và cây còn lại chỉ có một phần tử.
Tuy nhiên, ta có thể thấy rằng, thuật toán này không hiệu quả hơn so với danh sách liên kết. Để tăng tốc thuật toán này, ta sử dụng hai thuật giải heuristics là gộp theo thứ hạng (union by rank) và nén đường dẫn (path compression).
Ý tưởng của “gộp theo thứ hạng” là làm cho gốc của cây có ít nút hơn trỏ đến gốc của cây có nhiều nút hơn. Đối với mỗi nút, ta duy trì một thứ hạng gần đúng với logarit của kích thước cây con và cũng là giới hạn trên về chiều cao của nút. Khi
merge-sets(x, y)
được gọi, gốc có thứ hạng nhỏ hơn được thực hiện để trỏ đến gốc có thứ hạng lớn hơn.Ý tưởng của “nén đường dẫn”, được sử dụng cho thao tác
find-set(x)
, là làm cho mỗi nút trên đường dẫn tìm điểm trỏ trực tiếp đến gốc. Điều này sẽ không thay đổi bất kỳ thứ tự cấp bậc nào.
Để cài đặt Disjoint Sets với hai thuật toán trên, ta phải truy vết theo thứ hạng. Với mỗi nút , ta giữ nguyên giá trị rank[x]
(có nghĩa là thứ hạng của ), giá trị này lớn hơn hoặc bằng số cạnh trong đường đi dài nhất giữa nút và một nút con. Khi lệnh create-set(x)
được gọi, rank[x]
ban đầu sẽ bằng 0. Khi một phép toán merge-sets(x, y)
được thực hiện thì gốc của thứ hạng cao hơn sẽ trở thành gốc của gốc của thứ hạng thấp hơn - hoặc, trong trường hợp bằng nhau, ta tùy ý chọn một trong hai gốc làm gốc và tăng thứ hạng của nó.
Độ phức tạp thuật toán
Khi ta chỉ sử dụng "gộp theo thứ hạng" thì thời gian chạy mà ta đã đạt được sẽ chỉ bằng với kỹ thuật "gộp tập có trọng số" khi ta sử dụng danh sách liên kết
Khi ta sử dụng cả “gộp theo thứ hạng” và “nén đường dẫn”, thời gian chạy lâu nhất là , trong đó là nghịch đảo tăng rất chậm của hàm Ackermann và rất nhỏ so với .
Như vậy, ta đã có thuật toán với thời gian chạy và bộ nhớ sử dụng được tối ưu.
Bài toán mở đầu bài viết có thể giải quyết được khi sử dụng Disjoint Sets với độ phức tạp là và với bộ nhớ . Sự khác biệt về thời gian chạy là không lớn nếu bài toán được giải quyết bằng BFS, nhưng ta không cần phải ghi nhớ các đỉnh của đồ thị.
Hãy xét bài toán sau: Trong một căn phòng có người và truy vấn. Mỗi truy vấn có dạng “ ”, nghĩa là là bạn với ; hoặc “ ”, nghĩa là yêu cầu trả lời liệu và có thuộc cùng một nhóm bạn tại thời điểm đó hay không. Trong trường hợp này, lời giải với Disjoint Sets là nhanh nhất, cho độ phức tạp là .
Disjoint Sets là một công cụ hữu ích làm nền tảng để sử dụng các thuật toán khác nhau hoặc thậm chí để giải quyết các vấn đề trong SRM. Nó hiệu quả và sử dụng dung lượng bộ nhớ nhỏ. Nó cũng hữu ích với các ứng dụng như “Tìm cây khung nhỏ nhất của đồ thị”, “Chia tập hợp các nguyên tử thành phân tử hoặc đoạn phân tử”, “Ghi nhãn thành phần được kết nối trong phân tích hình ảnh” và các ứng dụng khác.
Một số bài tập từ cơ bản đến nâng cao đã được tổng hợp trên VNOI:
Hy vọng bạn thấy bài viết này hữu ích!