Strongly Connected Components
Đồ thị có hướng có n nút, được đánh số từ 0 đến n-1, và một danh sách edges, trong đó [from, to] là một cạnh từ nút from đến nút to.
Thành phần liên thông mạnh là nhóm nút lớn nhất mà từ mỗi nút đều có thể đi đến mọi nút khác bằng cách đi theo các cạnh. Mỗi nút thuộc chính xác một thành phần, còn nút không nằm trên chu trình nào sẽ tự tạo thành một thành phần. Hãy trả về số thành phần liên thông mạnh.
Hàm
- ninteger
- số lượng nút
- edgesinteger-2d-array
- các cạnh có hướng, mỗi cạnh có dạng [from, to]
- Trả vềinteger
- số lượng thành phần liên thông mạnh
Ràng buộc
2 ≤ n ≤ 1041 ≤ edges.length ≤ 5000edges[i] = [from, to]với0 ≤ from, to < nvàfrom ≠ to- Không có cạnh nào xuất hiện hai lần.
Ví dụ
- Đầu vào
- n = 6edges = [[0, 1], [1, 2], [2, 0], [2, 3], [3, 4], [4, 3], [5, 4]]
- Đầu ra
- 3
- Giải thích
- Các nút 0, 1 và 2 tạo thành một vòng lặp nên có thể đến được với nhau. Nút 3 và 4 trỏ đến nhau. Nút 5 đến được nút 4, nhưng không có đường nào quay lại nút 5, nên nút 5 đứng riêng: 3 thành phần.
- Đầu vào
- n = 5edges = [[1, 0], [0, 1], [2, 3]]
- Đầu ra
- 4
- Giải thích
- Nút 0 và 1 trỏ đến nhau và tạo thành một thành phần. Cạnh từ 2 đến 3 chỉ đi theo một chiều, nên 2 và 3 là hai thành phần riêng biệt, còn nút 4 không có cạnh nào: tổng cộng 4 thành phần.
Câu hỏi mở rộng
Bạn cần thêm ít nhất bao nhiêu cạnh để toàn bộ đồ thị trở thành một thành phần liên thông mạnh duy nhất?
Gợi ý
Mở từng gợi ý một. Mỗi gợi ý tiết lộ thêm một chút.
Hai nút chia sẻ cùng một thành phần khi và chỉ khi mỗi nút đều có thể đến được nút kia. Bạn có thể kiểm tra điều đó bằng cách tìm kiếm từ từng nút, nhưng cách này sẽ duyệt đi duyệt lại cùng một cạnh. Một lần tìm kiếm theo chiều sâu có thể cho bạn biết điều gì về nơi một thành phần bắt đầu?
Đánh số các nút theo thứ tự mà tìm kiếm theo chiều sâu lần đầu tiên đến được chúng. Với mỗi nút, hãy theo dõi thêm
low: số nhỏ nhất mà nút đó có thể đến được bằng cách đi xuống cây tìm kiếm rồi đi theo một cạnh quay lại một nút có thành phần vẫn đang mở. Nút cólowbằng số của chính nó là nút đầu tiên trong thành phần đó.Giữ các nút đã duyệt trong một ngăn xếp. Khi một nút hoàn tất với
lowbằng chính số của nó, hãy lấy các nút ra khỏi ngăn xếp cho đến nút đó: những nút ấy tạo thành một thành phần, vì vậy hãy đếm thành phần đó. Một test khiến quá trình tìm kiếm đi sâu đến 5000 nút, vì vậy hãy tự tạo một ngăn xếp gồm các khung (nút, cạnh tiếp theo) thay vì dùng đệ quy.
Lời giải
Thu gọn mỗi thành phần thành một điểm, các thành phần sẽ tạo thành một đồ thị không có chu trình. Thuật toán Kosaraju và Tarjan dùng điều đó để tìm mọi thành phần trong O(n + E), với E là số cạnh.
Tìm kiếm tiến và lùi từ mọi nút
Đúng, nhưng không chạy xong với các test lớn nhất
Ý tưởng
Các nút u và v thuộc cùng một thành phần khi và chỉ khi mỗi nút đều đi tới được nút kia. Vì vậy, với một nút s chưa thuộc thành phần nào, hãy tìm kiếm xuôi theo các cạnh và ngược theo các cạnh đã đảo chiều. Các nút được tìm thấy trong cả hai lượt tìm kiếm tạo thành thành phần của nút đó. Trong ví dụ đầu tiên, tìm kiếm xuôi từ 0 tìm thấy các nút từ 0 đến 4, còn tìm kiếm ngược tìm thấy 0, 1 và 2, nên {0, 1, 2} là một thành phần.
Mỗi thành phần cần hai lượt tìm kiếm trên phần lớn đồ thị. Trên một chuỗi gồm 5000 cạnh đi qua 5001 nút, mỗi nút là một thành phần riêng, và các lượt tìm kiếm sẽ duyệt khoảng 2.5 × 10^7 nút.
Thuật toán
- Xây dựng danh sách các cạnh đi ra và đi vào của mọi nút.
- Lấy nút tiếp theo
schưa thuộc thành phần nào và tăng số lượng thành phần lên 1. - Đánh dấu những nút mà
sđi tới theo các cạnh đi ra, và những nút đi tớistheo các cạnh đi vào. - Mọi nút được đánh dấu trong cả hai lần tìm kiếm đều thuộc thành phần của
s. Khi mọi nút đều đã thuộc một thành phần, trả về số lượng.
def countSCC(n, edges):
out = [[] for _ in range(n)]
rev = [[] for _ in range(n)]
for a, b in edges:
out[a].append(b)
rev[b].append(a)
done = [False] * n
# fwd[v] == s: the search forward from s reached v. bwd[v] == s: v reaches s.
fwd = [-1] * n
bwd = [-1] * n
count = 0
for s in range(n):
if done[s]:
continue
count += 1
# Every node s can reach.
fwd[s] = s
reached = [s]
stack = [s]
while stack:
node = stack.pop()
for nxt in out[node]:
if fwd[nxt] != s:
fwd[nxt] = s
reached.append(nxt)
stack.append(nxt)
# Every node that can reach s.
bwd[s] = s
stack = [s]
while stack:
node = stack.pop()
for prev in rev[node]:
if bwd[prev] != s:
bwd[prev] = s
stack.append(prev)
# The nodes found both ways form the component of s.
for v in reached:
if bwd[v] == s:
done[v] = True
return countThuật toán Kosaraju: hai lượt duyệt
Ý tưởng
Trong đồ thị các thành phần, có thành phần là một điểm hút, không có cạnh nào đi ra, và một lượt tìm kiếm bắt đầu bên trong thành phần đó sẽ tìm thấy chính xác thành phần ấy. Thuật toán Kosaraju sắp xếp các lượt tìm kiếm để mỗi lượt hoạt động như vậy.
Lượt duyệt thứ nhất liệt kê từng nút khi lượt tìm kiếm theo chiều sâu của nút đó kết thúc. Lượt duyệt thứ hai tìm kiếm trên đồ thị đảo, có cùng các thành phần, bắt đầu từ nút kết thúc sau cùng. Nếu một cạnh dẫn từ thành phần B vào C, nút cuối cùng của B kết thúc sau tất cả các nút của C. Vì vậy, khi lượt duyệt thứ hai đến C, mọi thành phần có cạnh đi vào C đều đã được gán, và trên đồ thị đảo, lượt tìm kiếm không thể rời khỏi C.
Trong ví dụ đầu tiên, các nút kết thúc theo thứ tự 4, 3, 2, 1, 0, 5. Lượt duyệt thứ hai lần lượt gom các nút thành {5}, rồi {0, 1, 2}, rồi {3, 4}.
Thuật toán
- Xây dựng danh sách cạnh đi ra và danh sách cạnh đi vào.
- Chạy tìm kiếm theo chiều sâu lặp từ mỗi nút chưa được duyệt, rồi thêm từng nút vào
orderkhi duyệt xong. - Duyệt
ordertừ cuối về đầu. Với mỗi nút chưa thuộc thành phần nào, tăng số đếm thêm một rồi tìm kiếm từ nút đó theo các cạnh đi vào, gán mọi nút chưa được gán mà tìm kiếm gặp. Trả về số đếm.
def countSCC(n, edges):
out = [[] for _ in range(n)]
rev = [[] for _ in range(n)]
for a, b in edges:
out[a].append(b)
rev[b].append(a)
# Pass 1: depth first search on the graph, listing each node when it finishes.
seen = [False] * n
order = []
for start in range(n):
if seen[start]:
continue
seen[start] = True
# Each frame is [node, next edge to try].
stack = [[start, 0]]
while stack:
frame = stack[-1]
node, i = frame
if i < len(out[node]):
frame[1] += 1
nxt = out[node][i]
if not seen[nxt]:
seen[nxt] = True
stack.append([nxt, 0])
else:
stack.pop()
order.append(node)
# Pass 2: on the reversed graph, start from the last node to finish.
# Each search collects exactly one component.
done = [False] * n
count = 0
for start in reversed(order):
if done[start]:
continue
count += 1
done[start] = True
stack = [start]
while stack:
node = stack.pop()
for prev in rev[node]:
if not done[prev]:
done[prev] = True
stack.append(prev)
return countThuật toán Tarjan: một lượt duyệt với các giá trị low
Ý tưởng
Chạy một lần tìm kiếm theo chiều sâu và đánh số từng nút theo thứ tự bạn đi đến nó: index. Giữ một ngăn xếp gồm các nút đã thăm thuộc những thành phần vẫn còn mở, cùng với một giá trị low: chỉ số nhỏ nhất mà một nút có thể đến bằng cách đi xuống cây tìm kiếm rồi đi ngược lại một cạnh đến một nút trong ngăn xếp đó.
Khi quá trình xử lý một nút kết thúc với low nhỏ hơn index, có thứ gì đó bên dưới nó đi ngược lên một nút mở đã được thăm trước đó, nên nó vẫn nằm trong ngăn xếp. Khi hai giá trị bằng nhau, nút đó là nút đầu tiên mà quá trình tìm kiếm đi vào trong thành phần của nó: lấy các nút ra khỏi ngăn xếp cho đến nút đó rồi đếm một thành phần. Cạnh dẫn đến một nút đã lấy ra sẽ đi vào một thành phần đã hoàn tất và không thể đi ngược lại, nên ta bỏ qua cạnh đó.
Trong ví dụ đầu tiên, 3 lấy {4, 3} ra khỏi ngăn xếp, còn 0 lấy {2, 1, 0} ra. Cạnh của nút 5 dẫn đến nút 4 đã được lấy ra, nên chỉ có nút 5 được lấy ra.
Thuật toán
- Với mỗi nút chưa được thăm, gán cho nó chỉ số tiếp theo, đặt
lowthành cùng giá trị đó, đẩy nút vào ngăn xếp nút và đẩy khung (node, first edge) vào ngăn xếp. - Nếu nút trong khung trên cùng còn cạnh chưa xét, hãy đi theo cạnh đó: đích chưa được thăm sẽ nhận một chỉ số và một khung; nếu đích nằm trong ngăn xếp nút, hãy giảm
low[node]xuống chỉ số của nó. - Nếu không, hãy lấy khung ra khỏi ngăn xếp và giảm
lowcủa nút cha xuốnglow[node]. Nếulow[node]bằngindex[node], hãy lấy các nút ra khỏi ngăn xếp nút cho đến nút đó và đếm một thành phần.
def countSCC(n, edges):
out = [[] for _ in range(n)]
for a, b in edges:
out[a].append(b)
index = [-1] * n # discovery time, -1 while unvisited
low = [0] * n # earliest discovery time the node can reach and still return from
on_stack = [False] * n
stack = [] # visited nodes whose component is still open
time = 0
count = 0
for start in range(n):
if index[start] != -1:
continue
index[start] = low[start] = time
time += 1
stack.append(start)
on_stack[start] = True
# The depth first search keeps its own call stack: [node, next edge to try].
calls = [[start, 0]]
while calls:
frame = calls[-1]
node, i = frame
if i < len(out[node]):
frame[1] += 1
nxt = out[node][i]
if index[nxt] == -1:
index[nxt] = low[nxt] = time
time += 1
stack.append(nxt)
on_stack[nxt] = True
calls.append([nxt, 0])
elif on_stack[nxt]:
# An edge back into an open component.
low[node] = min(low[node], index[nxt])
else:
calls.pop()
if calls:
parent = calls[-1][0]
low[parent] = min(low[parent], low[node])
# Nothing below node reaches above it: node closes a component.
if low[node] == index[node]:
count += 1
while True:
w = stack.pop()
on_stack[w] = False
if w == node:
break
return count
Lỗi thường gặp và trường hợp biên
- Bỏ sót các nút (node) không có cạnh (edge). Trong ví dụ thứ hai, nút 4 không xuất hiện trong cạnh nào nhưng vẫn là một thành phần.
- Giảm
lowqua bất kỳ nút nào đã được duyệt, thay vì chỉ qua các nút nằm trong ngăn xếp (stack). Trong ví dụ thứ nhất, cạnh5 → 4khiến 5 không thể kết thúc thành phần của mình. - Xem các cạnh là hai chiều. Cách này đếm các thành phần liên thông: ví dụ thứ nhất sẽ cho kết quả 1 thay vì 3.
- Dùng đệ quy trong Python. Một chu trình gồm 5000 nút tạo ra 5000 lời gọi lồng nhau, gấp năm lần giới hạn mặc định là 1000. Hãy tự quản lý ngăn xếp các khung lời gọi.
- Chạy lượt thứ hai của Kosaraju trên các cạnh ban đầu, hoặc theo thứ tự các nút được duyệt lần đầu. Cả hai cách đều gộp những thành phần chỉ có thể đi đến nhau theo một chiều.
Câu hỏi thường gặp3
Độ phức tạp thời gian của việc tìm các thành phần liên thông mạnh là bao nhiêu?
Độ phức tạp thời gian O(n + E) khi dùng thuật toán Kosaraju hoặc Tarjan, vì mỗi nút và cạnh được xử lý một số lần hằng định. Cả hai đều dùng O(n + E) bộ nhớ cho các danh sách cạnh và ngăn xếp tìm kiếm.
Bạn nên dùng thuật toán Kosaraju hay Tarjan?
Thuật toán Kosaraju gồm hai lượt tìm kiếm đơn giản, nên khó mắc lỗi hơn. Tarjan chỉ cần một lượt tìm kiếm và không cần đồ thị đảo ngược, đổi lại phải theo dõi low.
Thành phần liên thông mạnh khác thành phần liên thông như thế nào?
Các thành phần liên thông không xét hướng của cạnh: hai nút thuộc cùng một thành phần khi có đường đi nối chúng. Các thành phần liên thông mạnh yêu cầu mỗi nút có thể đi đến nút kia. Ví dụ đầu tiên có một thành phần liên thông nhưng có ba thành phần liên thông mạnh.
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 countSCC(n, edges):
# Viết code tại đâyKiểm thử #1
Kiểm thử #2
Đối số
n = 6 edges = [[0, 1], [1, 2], [2, 0], [2, 3], [3, 4], [4, 3], [5, 4]]
Đầu ra mong đợi
3