Redundant Connection
Mạng ban đầu có dạng cây: gồm n nút, được đánh số từ 1 đến n, nối với nhau bằng n-1 cạnh vô hướng và không có chu trình. Sau đó, một cạnh nữa được thêm vào giữa hai nút chưa được nối trực tiếp với nhau, nên danh sách edges hiện có n cạnh và đúng một chu trình.
Hãy trả về một cạnh mà bạn có thể xóa để n-1 cạnh còn lại lại tạo thành một cây. Nếu có nhiều cạnh phù hợp, hãy trả về cạnh xuất hiện cuối cùng trong edges, dưới dạng cặp [a, b] chính xác như được ghi ở đó.
Hàm
- edgesinteger-2d-array
- n cạnh vô hướng, mỗi cạnh là một cặp [a, b] gồm các nút được đánh số từ 1
- Trả vềinteger-array
- cạnh chu trình xuất hiện cuối cùng trong danh sách edges, được viết đúng như trong đó
Ràng buộc
3 ≤ n ≤ 5000, trong đón = edges.lengthedges[i] = [a, b]với1 ≤ a, b ≤ nvàa ≠ b- Không có cạnh nào xuất hiện hai lần, dù theo chiều nào.
- Các cạnh nối tất cả
nnút và tạo thành đúng một chu trình.
Ví dụ
- Đầu vào
- edges = [[1, 2], [1, 3], [2, 3]]
- Đầu ra
- [2, 3]
- Giải thích
- Ba cạnh tạo thành tam giác 1, 2, 3, vì vậy xóa bất kỳ cạnh nào cũng để lại một cây.
[2, 3]đứng cuối danh sách.
- Đầu vào
- edges = [[1, 2], [2, 3], [3, 4], [1, 4], [1, 5]]
- Đầu ra
- [1, 4]
- Giải thích
- Chu kỳ là 1, 2, 3, 4, 1. Cạnh
[1, 5]không nằm trong chu kỳ này: xóa cạnh đó sẽ khiến nút 5 bị tách rời. Trong bốn cạnh của chu kỳ,[1, 4]xuất hiện cuối cùng.
Câu hỏi mở rộng
Nếu các cạnh có hướng và đồ thị ban đầu là một cây có gốc, bạn sẽ xóa cạnh nào khi cạnh thừa khiến một nút có hai nút cha?
Gợi ý
Mở từng gợi ý một. Mỗi gợi ý tiết lộ thêm một chút.
Gỡ bỏ cạnh vẫn phải giữ cho tất cả
nnút được kết nối. Bạn có thể loại bỏ những cạnh nào mà không chia đồ thị thành hai phần?Chỉ các cạnh trên chu trình mới có một đường đi thứ hai vòng quanh chu trình, còn mọi cạnh khác đều là liên kết duy nhất dẫn đến một phần nào đó của đồ thị. Vì vậy, đáp án là cạnh trên chu trình xuất hiện cuối cùng trong danh sách.
Thêm các cạnh theo thứ tự trong danh sách vào
nnhóm riêng biệt. Cạnh đầu tiên có hai đầu mút đã nằm trong cùng một nhóm sẽ khép kín chu trình, và mọi cạnh khác của chu trình đều xuất hiện trước cạnh đó. Cấu trúc union-find tìm cạnh đó chỉ trong một lượt.
Lời giải
Xóa một cạnh sẽ tạo thành cây khi và chỉ khi cạnh đó nằm trên chu trình duy nhất của đồ thị, vì vậy đáp án là cạnh trên chu trình xuất hiện cuối cùng trong danh sách. Cấu trúc hợp nhất và tìm kiếm tìm ra cạnh đó chỉ trong một lượt: thêm các cạnh theo thứ tự, cạnh đầu tiên có hai đầu mút đã nối với nhau chính là đáp án.
Hãy thử lần lượt xóa từng cạnh, bắt đầu từ cạnh cuối cùng.
Đúng, nhưng không chạy xong với các test lớn nhất
Ý tưởng
Xóa một cạnh rồi kiểm tra xem n-1 cạnh còn lại có tạo thành cây hay không. Với n nút và n-1 cạnh, chúng tạo thành cây khi và chỉ khi một lần tìm kiếm bắt đầu từ nút 1 đi đến được tất cả n nút. Thử các cạnh từ cuối danh sách, như vậy lần xóa đầu tiên vượt qua kiểm tra sẽ là cạnh nằm sau cùng. Lưu chỉ số của từng cạnh trong các danh sách kề, rồi khi tìm kiếm, bạn có thể bỏ qua cạnh bị xóa mà không cần dựng lại bất cứ thứ gì.
Mỗi lần thử cần một lượt tìm kiếm đầy đủ O(n), và có thể có n lần thử: tối đa 2.5 × 10^7 bước khi n = 5000.
Thuật toán
- Xây dựng các danh sách kề, lưu chỉ số của cạnh dẫn đến mỗi nút kề.
- Với
skip, duyệt từ chỉ số cuối cùng xuống 0, tìm kiếm từ nút 1 và bỏ qua cạnh có chỉ sốskip. - Đếm số nút mà quá trình tìm kiếm đi đến.
- Nếu quá trình tìm kiếm đi đến tất cả
nnút, trả vềedges[skip].
def findRedundantConnection(edges):
n = len(edges)
# graph[x] holds (neighbour, index of the edge) for every edge at node x.
graph = [[] for _ in range(n + 1)]
for i, (u, v) in enumerate(edges):
graph[u].append((v, i))
graph[v].append((u, i))
# Try leaving out each edge, the last one first.
for skip in range(n - 1, -1, -1):
# Without edge skip, can node 1 still reach all n nodes?
seen = [False] * (n + 1)
seen[1] = True
stack = [1]
reached = 1
while stack:
node = stack.pop()
for nxt, i in graph[node]:
if i != skip and not seen[nxt]:
seen[nxt] = True
reached += 1
stack.append(nxt)
# n-1 edges that connect n nodes form a tree.
if reached == n:
return edges[skip]
return []Bóc các chiếc lá
Ý tưởng
Lá, tức là một nút có một cạnh, không thể nằm trên chu trình vì chu trình đi vào rồi đi ra khỏi mỗi nút. Xóa một lá có thể khiến nút kề với nó trở thành lá, vì vậy hãy tiếp tục bóc các lá cho đến khi không còn lá nào. Phần còn lại chính là chu trình, vì mỗi nút trên chu trình vẫn giữ hai cạnh thuộc chu trình. Trong ví dụ thứ hai, nút 5 bị bóc đi, nút 1 vẫn giữ hai cạnh, còn các nút từ 1 đến 4 vẫn còn.
Mọi cạnh nối hai nút còn lại đều thuộc chu trình, vì một liên kết thứ hai giữa các nút trên chu trình sẽ tạo thành chu trình thứ hai. Vì vậy, hãy duyệt edges từ cuối về đầu và trả về cạnh đầu tiên có cả hai đầu mút đều còn lại.
Thuật toán
- Xây dựng danh sách kề và đếm bậc của từng nút.
- Đưa mọi nút có bậc bằng 1 vào một ngăn xếp.
- Lấy một nút lá ra khỏi ngăn xếp, đặt bậc của nó về 0 và giảm bậc của nút kề còn lại; đưa nút kề đó vào ngăn xếp khi bậc của nó giảm xuống còn 1.
- Khi ngăn xếp trống, các nút có bậc lớn hơn 0 tạo thành chu trình.
- Duyệt
edgestừ cuối lên và trả về cạnh đầu tiên có cả hai đầu mút còn lại.
def findRedundantConnection(edges):
n = len(edges)
graph = [[] for _ in range(n + 1)]
degree = [0] * (n + 1)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
degree[u] += 1
degree[v] += 1
# A node with one edge cannot lie on the cycle. Remove it, and repeat.
stack = [x for x in range(1, n + 1) if degree[x] == 1]
while stack:
leaf = stack.pop()
degree[leaf] = 0
for nxt in graph[leaf]:
if degree[nxt] > 0:
degree[nxt] -= 1
if degree[nxt] == 1:
stack.append(nxt)
# Only the cycle is left. Its edge that comes last in the list is the answer.
for u, v in reversed(edges):
if degree[u] > 0 and degree[v] > 0:
return [u, v]
return []Cấu trúc hợp nhất và tìm kiếm: cạnh đầu tiên khép kín một vòng lặp
Ý tưởng
Thêm các cạnh theo thứ tự trong danh sách và theo dõi những nút nào được kết nối. Trước khi chu trình khép lại, mỗi cạnh nối hai phần chưa có đường đi nào nối chúng. Cạnh đầu tiên có hai đầu mút đã được kết nối sẽ khép lại chu trình. Các cạnh còn lại của chu trình đã được thêm trước đó, vì vậy cạnh khép chu trình là cạnh xuất hiện sau cùng. Trong ví dụ thứ hai, [1, 4] phát hiện các nút 1 và 4 đã được nối với nhau, còn [1, 5] không bao giờ được đọc.
Cấu trúc union-find dùng một mảng, parent; lần theo các con trỏ từ một nút sẽ dẫn đến gốc của phần đó. Gắn phần nhỏ hơn vào dưới phần lớn hơn và trỏ các nút đến nút ông bà trong quá trình find giúp các chuỗi liên kết ngắn hơn. Khi đó, chi phí khấu hao cho mỗi thao tác là O(α(n)), trong đó α, hàm Ackermann ngược, không lớn hơn 4 với mọi đầu vào thực tế.
Thuật toán
- Cho mỗi nút (node) từ 1 đến
ntrỏ đến chính nó,parent[x] = x, và đặt kích thước của nút là 1. - Duyệt
edgestheo thứ tự. Với[u, v], tìm gốc củauvà gốc củav. - Nếu hai gốc trùng nhau,
[u, v]khép kín chu trình: trả về cặp này như đã viết. - Nếu không, nhóm lớn hơn sẽ gộp nhóm nhỏ hơn: gốc của nhóm lớn hơn trở thành cha của gốc nhóm nhỏ hơn, rồi cộng kích thước hai nhóm.
def findRedundantConnection(edges):
n = len(edges)
parent = list(range(n + 1)) # every node starts as its own group
size = [1] * (n + 1)
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)
# u and v are already connected: this edge closes the cycle.
if a == b:
return [u, v]
# Hang the smaller group under the larger one.
if size[a] < size[b]:
a, b = b, a
parent[b] = a
size[a] += size[b]
return []
Lỗi thường gặp và trường hợp biên
- Trả về cạnh cuối cùng trong danh sách. Ở ví dụ thứ hai, đó là
[1, 5], và việc xóa cạnh này khiến nút 5 bị tách rời. - Duyệt từ đầu danh sách sau khi bóc tách. Cách này trả về cạnh sớm nhất trong chu trình,
[1, 2]ở ví dụ thứ hai. - Sắp xếp hoặc đảo ngược cặp. Nếu danh sách chứa
[3, 1], hãy trả về[3, 1], không phải[1, 3]. - Tạo mảng cho các nút từ 0 đến
n-1. Các nút được đánh số từ 1, vì vậy nútncần một mảng cón+1ô. - Dùng đệ quy để đi quanh chu trình. Cách này có thể đi qua cả 5000 nút, gấp năm lần giới hạn đệ quy mặc định của Python, vì vậy hãy dùng ngăn xếp tường minh và vòng lặp trong
find.
Câu hỏi thường gặp3
Độ phức tạp thời gian của Redundant Connection là bao nhiêu?
Union-find giải quyết bài toán chỉ trong một lượt duyệt qua n cạnh: thời gian O(n · α(n)), trong đó α(n) trên thực tế tối đa là 4, và bộ nhớ O(n). Loại bỏ các nút lá cũng có độ phức tạp O(n). Thử loại bỏ từng cạnh rồi tìm kiếm lại từ đầu sẽ tốn O(n²).
Có thể xóa những cạnh nào để còn lại một cây?
Chính xác là các cạnh nằm trên chu trình. Mỗi cạnh đều có một đường đi khác nối hai đầu của nó qua phần còn lại của chu trình, nên khi xóa cạnh đó, đồ thị vẫn liên thông. Mọi cạnh khác đều là liên kết duy nhất giữa hai phần của đồ thị, nên khi xóa cạnh đó, đồ thị bị chia tách.
Liệu có thể giải bài toán Kết nối dư thừa bằng DFS không?
Có. Trước khi thêm mỗi cạnh, hãy tìm kiếm từ một đầu qua các cạnh đã thêm; cạnh đầu tiên có đầu kia đã có thể đến được chính là đáp án. Cách này tốn O(n) cho mỗi cạnh, tổng cộng O(n²), trong khi cấu trúc union-find trả lời cùng câu hỏi trong thời gian gần như hằng số.
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 findRedundantConnection(edges):
# Viết code ở đâyKiểm thử #1
Kiểm thử #2
Đối số
edges = [[1, 2], [1, 3], [2, 3]]
Đầu ra mong đợi
[2, 3]