Graph Valid Tree
Bạn có n nút, được đánh số từ 0 đến n-1, và một danh sách edges gồm các cạnh vô hướng. Mỗi cặp [a, b] nối nút a với nút b.
Trả về true nếu các cạnh tạo thành một cây gồm tất cả n nút: mỗi nút có thể đi đến mọi nút khác và không có chu trình. Nếu không, trả về false.
Hàm
- ninteger
- số lượng nút, được đánh số từ 0 đến n-1
- edgesinteger-2d-array
- các cạnh vô hướng, mỗi cạnh là một cặp [a, b]
- Trả vềboolean
- đúng nếu các cạnh nối tất cả n nút thành một cây, sai nếu không
Ràng buộc
2 ≤ n ≤ 1041 ≤ edges.length ≤ 5000edges[i] = [a, b]với0 ≤ a, b ≤ n-1vàa ≠ b- Không có cạnh nào xuất hiện hai lần, dù theo hướng nào.
Ví dụ
- Đầu vào
- n = 5edges = [[0, 1], [0, 2], [0, 3], [1, 4]]
- Đầu ra
- true
- Giải thích
- Nút 0 nối với các nút 1, 2 và 3, còn nút 4 nối với nút 1. Bốn cạnh nối cả năm nút mà không tạo thành vòng lặp, nên đây là một cây.
- Đầu vào
- n = 5edges = [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]]
- Đầu ra
- false
- Giải thích
- Các cạnh
[1, 2],[2, 3]và[1, 3]tạo thành vòng lặp 1, 2, 3, 1. Năm cạnh trên năm nút nhiều hơn một cạnh so với số cạnh tối đa của một cây.
- Đầu vào
- n = 4edges = [[0, 1], [1, 2], [2, 0]]
- Đầu ra
- false
- Giải thích
- Có 3 cạnh, tương ứng với
n-1, nhưng chúng tạo thành vòng lặp 0, 1, 2, 0, còn nút 3 thì không có cạnh nào.
Câu hỏi mở rộng
Khi đồ thị không phải là cây, bạn cần xóa bao nhiêu cạnh và thêm bao nhiêu cạnh để biến nó thành cây? Bạn có thể đếm cả hai trong một lượt duyệt không?
Gợi ý
Mở từng gợi ý một. Mỗi gợi ý tiết lộ thêm một chút.
Hãy vẽ một vài đồ thị nhỏ không phải là cây. Chỉ có hai cách để thất bại: đó là những cách nào?
Đồ thị không phải cây nếu có chu trình hoặc có nút bị tách rời. Cây có
nnút có đúngn-1cạnh, và khi có đúngn-1cạnh, hai lỗi đó luôn xảy ra cùng nhau. Vì vậy, hãy kiểm tra số cạnh, rồi kiểm tra một trong hai điều kiện.Hãy để mỗi nút bắt đầu riêng lẻ. Với mỗi cạnh, hãy kiểm tra xem hai đầu của nó đã thuộc cùng một nhóm chưa. Nếu rồi, cạnh đó khép lại một chu trình; nếu chưa, hãy gộp hai nhóm lại với nhau. Cấu trúc union-find trả lời câu hỏi đó trong thời gian gần như hằng số.
Lời giải
Cây là đồ thị liên thông và không có chu trình. Với đúng n-1 cạnh, hai điều kiện này tương đương: đồ thị liên thông khi và chỉ khi không có chu trình. Vì vậy, trước tiên hãy kiểm tra số cạnh, sau đó tìm kiếm một lần để kiểm tra tính liên thông hoặc dùng union-find để phát hiện cạnh nối hai nút đã được kết nối với nhau.
Tìm đường đi trước mỗi cạnh
Đúng, nhưng không chạy xong với các test lớn nhất
Ý tưởng
Thêm từng cạnh một. Trước khi thêm [u, v], hãy tìm từ u theo các cạnh đã thêm. Nếu có thể đến được v, cạnh mới tạo thành tuyến đường thứ hai giữa hai nút và khép lại một chu trình, như [1, 3] trong ví dụ thứ hai. Nếu không có cạnh nào khép lại một chu trình, đồ thị là một rừng, và lần tìm cuối cùng từ nút 0 sẽ cho biết liệu nó có phải là một cây hay không.
Mỗi lần kiểm tra đều tìm kiếm trên toàn bộ đồ thị đã thêm đến thời điểm đó. Trên một đường đi gồm 5001 nút với các cạnh được thêm lần lượt, các lần tìm duyệt qua 1 + 2 + ... + 5000 nút, khoảng 12.5 triệu.
Thuật toán
- Bắt đầu với một danh sách kề trống cho mỗi nút.
- Với mỗi cạnh
[u, v], tìm kiếm từuqua các cạnh đã thêm. Nếu tìm thấyv, trả vềfalse. - Nếu không, thêm cạnh vào danh sách của cả hai đầu.
- Cuối cùng, trả về kết quả cho biết liệu việc tìm kiếm từ nút 0 có đến được tất cả
nnút hay không.
def reachable(graph, start):
# Every node a search from start can reach, using the edges added so far.
seen = [False] * len(graph)
seen[start] = True
stack = [start]
while stack:
node = stack.pop()
for nxt in graph[node]:
if not seen[nxt]:
seen[nxt] = True
stack.append(nxt)
return seen
def validTree(n, edges):
graph = [[] for _ in range(n)]
for u, v in edges:
# A path from u to v already exists, so this edge closes a cycle.
if reachable(graph, u)[v]:
return False
graph[u].append(v)
graph[v].append(u)
# No cycle anywhere. A tree also needs every node reachable from node 0.
return all(reachable(graph, 0))Đếm số cạnh, rồi tìm kiếm một lần
Ý tưởng
Bắt đầu với n nút riêng biệt rồi lần lượt thêm từng cạnh. Mỗi cạnh hoặc nối hai nhóm, khiến số nhóm giảm đi một, hoặc nằm trong một nhóm và tạo thành một chu trình. Để còn lại một nhóm cần n-1 lần nối. Vì vậy, ít hơn n-1 cạnh thì không thể nối đồ thị, còn nhiều hơn thì phải tạo thành một chu trình: ví dụ thứ hai, có 5 cạnh trên 5 nút, không đạt yêu cầu chỉ xét theo số lượng.
Với đúng n-1 cạnh, đồ thị chỉ kết thúc với một nhóm nếu mọi cạnh đều nối hai nhóm, tức là không có cạnh nào tạo thành chu trình. Vì vậy, khi số lượng đã đúng, chỉ cần kiểm tra tính liên thông: một lần tìm kiếm bắt đầu từ nút 0 phải đến được tất cả n nút. Trong ví dụ thứ ba, lần tìm kiếm chỉ đến được các nút 0, 1 và 2. Với m = n-1, các danh sách và lần tìm kiếm có độ phức tạp O(n).
Thuật toán
- Nếu
edges.lengthkhông bằngn-1, trả vềfalse. - Tạo danh sách kề, lưu mỗi cạnh ở cả hai đầu mút.
- Đẩy nút 0 vào ngăn xếp và đánh dấu là đã thăm.
- Lấy các nút ra khỏi ngăn xếp rồi đẩy mọi nút lân cận chưa được thăm vào, đồng thời đánh dấu và đếm từng nút khi đẩy.
- Trả về kết quả kiểm tra xem số lượng có bằng
nhay không.
def validTree(n, edges):
# A tree on n nodes has exactly n-1 edges.
if len(edges) != n - 1:
return False
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# With n-1 edges, the graph is a tree exactly when it is connected.
seen = [False] * n
seen[0] = True
stack = [0]
reached = 1
while stack:
node = stack.pop()
for nxt in graph[node]:
if not seen[nxt]:
seen[nxt] = True
reached += 1
stack.append(nxt)
return reached == nĐếm các cạnh và cấu trúc hợp nhất
Ý tưởng
Union-find trả lời câu hỏi về chu trình cho từng cạnh mà không cần tìm kiếm. Mỗi nút ban đầu là một nhóm riêng. Với cạnh [u, v], hãy tìm gốc của mỗi đầu mút. Nếu hai gốc khác nhau, cạnh nối hai phần với nhau, vì vậy hãy gộp chúng. Nếu hai gốc giống nhau, u và v đã được nối với nhau, nên cạnh khép kín một chu trình. Với n-1 cạnh đều gộp các nhóm, cuối cùng n nhóm trở thành một nhóm: một cây.
Hai quy tắc giúp các chuỗi cha ngắn hơn. Khi gộp, ta gắn gốc của nhóm nhỏ hơn vào gốc của nhóm lớn hơn, vì vậy với 10^4 nút, không chuỗi nào dài quá 13 bước. Trong quá trình tìm gốc, hãy trỏ mỗi nút đi qua đến nút ông của nó. Kết hợp lại, hai quy tắc này giúp m thao tác có độ phức tạp O(m α(n)), trong đó α, hàm Ackermann nghịch đảo, tối đa là 4 ở đây. Độ phức tạp bộ nhớ là O(n).
Thuật toán
- Nếu
edges.lengthkhông phải làn-1, trả vềfalse. - Đặt
parent[x] = xvà kích thước nhóm là 1 cho mỗi nút. - Với mỗi cạnh
[u, v], lần theo các nút cha từuvàvđến nút gốc của chúng, rồi trỏ mỗi nút bạn đi qua đến nút ông của nó. - Nếu hai nút gốc giống nhau, trả về
false. - Nếu hai nút gốc khác nhau, nối nút gốc của nhóm nhỏ hơn vào nhóm còn lại rồi cộng kích thước của nhóm đó.
- Nếu mọi cạnh đều gộp hai nhóm, trả về
true.
def validTree(n, edges):
# A tree on n nodes has exactly n-1 edges.
if len(edges) != n - 1:
return False
parent = list(range(n)) # every node starts as its own group
size = [1] * n
def find(x):
# Walk up to the root, halving the path on the way.
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for u, v in edges:
a, b = find(u), find(v)
# The two ends are already joined: this edge closes a cycle.
if a == b:
return False
# Hang the smaller group under the larger one.
if size[a] < size[b]:
a, b = b, a
parent[b] = a
size[a] += size[b]
# n-1 merges with no cycle turn the n groups into one.
return True
Lỗi thường gặp và trường hợp biên
- Chỉ kiểm tra chu trình. Ba cạnh riêng biệt trên sáu nút không tạo thành chu trình, nhưng chúng tạo thành ba thành phần.
- Chỉ kiểm tra số cạnh. Ví dụ thứ ba có
n-1cạnh, nhưng vòng lặp 0, 1, 2 sử dụng mất cạnh mà nút 3 cần. - Chỉ lưu mỗi cạnh dưới một đầu mút.
[1, 0]cũng nối nút 0 và nút 1, nên việc tìm kiếm từ nút 0 bỏ sót nút 1. - Kiểm tra chu trình bằng tìm kiếm theo chiều sâu mà không bỏ qua nút cha. Mỗi cạnh đều dẫn thẳng về nút bạn vừa đi qua, nên điều kiện “gặp nút kề đã thăm” luôn đúng với mọi cây.
- Dùng đệ quy trên một đường đi dài. Một đường thẳng gồm 5001 nút sẽ đi sâu đến 5000 lần gọi, vượt quá giới hạn mặc định là 1000 của Python; hãy dùng ngăn xếp tường minh.
Câu hỏi thường gặp3
Độ phức tạp thời gian của Graph Valid Tree là bao nhiêu?
O(n + m) với tìm kiếm theo chiều sâu, trong đó m là số cạnh, và O(n + m α(n)) với cấu trúc union-find. Cả hai đều kiểm tra số cạnh trước, nên đồ thị có số cạnh không đúng sẽ bị loại trước khi tìm kiếm.
Tại sao một cây có n nút lại có n-1 cạnh?
Mở rộng cây từ một nút và thêm các nút khác từng nút một. Mỗi nút mới nối với đúng một cạnh: nếu không có cạnh nào, nút sẽ bị tách rời; nếu có hai cạnh, nó sẽ tạo thành một chu trình. Vì vậy, n nút cần n-1 cạnh.
Làm thế nào để phát hiện chu trình trong đồ thị vô hướng?
Với cấu trúc union-find, một cạnh có hai đầu đã cùng chung một gốc sẽ khép lại một chu trình. Với tìm kiếm theo chiều sâu, một chu trình là một nút kề đã được thăm nhưng không phải nút mà bạn vừa đi từ đó đến, vì vậy bạn cần theo dõi nút cha của từng nút. Cấu trúc union-find không cần nút cha hay danh sách kề.
Bài tập tương tự
Các bài tập dùng cùng ý tưởng. Giải hai hoặc ba bài là bạn sẽ nhớ được mẫu này.
def validTree(n, edges):
# Viết code tại đâyKiểm thử #1
Kiểm thử #2
Kiểm thử #3
Đối số
n = 5 edges = [[0, 1], [0, 2], [0, 3], [1, 4]]
Đầu ra mong đợi
true