Strongly Connected Components
לגרף מכוון יש n צמתים, הממוספרים מ-0 עד n-1, ורשימה של edges, שבה [from, to] היא קשת מהצומת from אל הצומת to.
רכיב קשירות חזקה הוא קבוצה גדולה ביותר של צמתים, שבה כל צומת יכול להגיע לכל צומת אחר באמצעות מעבר לאורך קשתות. כל צומת שייך לרכיב אחד בדיוק, וצומת שאינו נמצא באף מעגל יוצר רכיב בפני עצמו. יש להחזיר את מספר רכיבי הקשירות החזקה.
פונקציה
- ninteger
- מספר הצמתים
- edgesinteger-2d-array
- הקשתות המכוונות, כל אחת בצורה [from, to]
- מחזירהinteger
- מספר הרכיבים הקשירים היטב
אילוצים
2 ≤ n ≤ 1041 ≤ edges.length ≤ 5000edges[i] = [from, to]כאשר0 ≤ from, to < nוגםfrom ≠ to- אף קשת אינה מופיעה פעמיים.
דוגמאות
- קלט
- n = 6edges = [[0, 1], [1, 2], [2, 0], [2, 3], [3, 4], [4, 3], [5, 4]]
- פלט
- 3
- הסבר
- הצמתים 0, 1 ו-2 יוצרים לולאה, ולכן הם מגיעים זה לזה. הצמתים 3 ו-4 מצביעים זה על זה. הצומת 5 מגיע לצומת 4, אבל שום דבר לא חוזר לצומת 5, ולכן הוא עומד בפני עצמו: 3 רכיבים.
- קלט
- n = 5edges = [[1, 0], [0, 1], [2, 3]]
- פלט
- 4
- הסבר
- הצמתים 0 ו-1 מצביעים זה על זה ויוצרים רכיב אחד. הקשת מ-2 ל-3 הולכת בכיוון אחד בלבד, לכן 2 ו-3 נפרדים, ולצומת 4 אין קשתות כלל: 4 רכיבים.
שאלת המשך
כמה שפחות קשתות תצטרך להוסיף כדי להפוך את כל הגרף לרכיב קשיר היטב יחיד?
רמזים
פתחו אותם אחד אחד. כל רמז חושף קצת יותר.
שתי צמתים שייכים לאותו רכיב בדיוק כאשר כל אחד מהם יכול להגיע לשני. חיפוש מכל צומת יכול לבדוק זאת, אבל הוא עובר שוב ושוב על אותן קשתות. מה חיפוש יחיד לעומק יכול ללמד אותך על המקום שבו רכיב מתחיל?
מספרו את הצמתים לפי הסדר שבו חיפוש לעומק מגיע אליהם לראשונה. עבור כל צומת עקבו גם אחר
low: המספר הקטן ביותר שאליו הוא יכול להגיע בירידה בעץ החיפוש ולאחר מכן במעבר לאורך קשת אחת בחזרה לצומת שהרכיב שלו עדיין פתוח. צומת שהערך שלlowשלו שווה למספר שלו עצמו הוא הצומת הראשון ברכיב שלו.שמור את הצמתים שבהם ביקרת במחסנית. כשצומת מסתיים עם
lowהשווה למספר שלו עצמו, הוצא צמתים מהמחסנית עד אליו: הצמתים האלה יוצרים רכיב אחד, אז ספור אותו. בדיקה אחת מעמיקה את החיפוש ל־5000 צמתים, לכן שמור מחסנית משלך של מסגרות (צומת, קשת הבאה) במקום להשתמש ברקורסיה.
פתרון
מכווצים כל רכיב לנקודה, והרכיבים יוצרים גרף ללא מעגלים. האלגוריתמים של Kosaraju ושל Tarjan משתמשים בכך כדי למצוא כל רכיב בזמן O(n + E), כאשר E הוא מספר הקשתות.
חיפוש קדימה ואחורה מכל צומת
נכונה, אבל לא מסתיימת בבדיקות הגדולות ביותר
האינטואיציה
הצמתים u ו־v שייכים לאותו רכיב בדיוק כאשר כל אחד מהם מגיע אל האחר. לכן, עבור צומת s שעדיין אינו שייך לרכיב כלשהו, חפשו קדימה לאורך הקשתות ואחורה לאורך הקשתות ההפוכות. הצמתים שנמצאו בשתי החיפושים הם הרכיב שלו. בדוגמה הראשונה, חיפוש קדימה מ־0 מוצא את 0 עד 4, וחיפוש אחורה מוצא את 0, 1 ו־2, ולכן {0, 1, 2} הם רכיב אחד.
כל רכיב דורש שני חיפושים ברובו של הגרף. בשרשרת של 5000 קשתות דרך 5001 צמתים, כל צומת הוא רכיב בפני עצמו, והחיפושים מבקרים בכ־2.5 × 10^7 צמתים.
אלגוריתם
- בנו את רשימות הקשתות היוצאות והנכנסות של כל צומת.
- קחו את הצומת הבא
sשאין לו רכיב, וספרו רכיב חדש. - סמנו את הצמתים שאליהם
sמגיע לאורך קשתות יוצאות, ואת הצמתים שמהם מגיעים אלsלאורך קשתות נכנסות. - כל צומת שסומן בשתי החיפושים מצטרף לרכיב של
s. כשיש לכל הצמתים רכיב, החזירו את המספר.
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 countהאלגוריתם של קוסרג'ו: שתי מעברים
האינטואיציה
בגרף הרכיבים, רכיב כלשהו הוא רכיב sink, שאין ממנו קשת יוצאת, וחיפוש שמתחיל בתוכו מוצא בדיוק את אותו רכיב. האלגוריתם של Kosaraju מסדר את החיפושים כך שכל אחד מהם מתנהג כך.
במעבר הראשון מתווסף כל צומת לרשימה כשחיפוש העומק שלו מסתיים. במעבר השני מתבצע חיפוש בגרף ההפוך, שיש בו אותם רכיבים, החל מהצומת האחרון שסיים. אם קשת מובילה מרכיב B אל C, הצומת האחרון של B מסיים אחרי שכל הצמתים של C סיימו. לכן, כשהמעבר השני מגיע אל C, כל הרכיבים שיש להם קשת המובילה אל C כבר שובצו, ובגרף ההפוך החיפוש אינו יכול לצאת מ-C.
בדוגמה הראשונה הצמתים מסיימים בסדר 4, 3, 2, 1, 0, 5. המעבר השני אוסף את {5}, ואז את {0, 1, 2}, ואז את {3, 4}.
אלגוריתם
- בנו את רשימות הקשתות היוצאות והנכנסות.
- הריצו חיפוש עומק איטרטיבי מכל צומת שטרם בוקר, והוסיפו כל צומת אל
orderכשהחיפוש בו מסתיים. - עברו על
orderמהסוף להתחלה. עבור כל צומת שאינו משויך לרכיב, ספרו רכיב חדש וחפשו ממנו לאורך הקשתות הנכנסות, תוך שיוך כל צומת לא משויך שאליו מגיעים. החזירו את המספר.
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 countהאלגוריתם של Tarjan: מעבר אחד עם ערכי low
האינטואיציה
בצע חיפוש אחד לעומק, ומספר כל צומת לפי סדר ההגעה אליו: זהו ה־index שלו. שמור מחסנית של צמתים שביקרת בהם, שהרכיב שלהם עדיין פתוח, וערך low: האינדקס הקטן ביותר שאליו צומת מגיע בירידה בעץ החיפוש ולאחר מכן בחזרה בקשת אחת לצומת שנמצא במחסנית.
כשחיפוש של צומת מסתיים וערך low שלו קטן מ־index, משהו מתחתיו חוזר לצומת פתוח מוקדם יותר, ולכן הוא נשאר במחסנית. כשהערכים שווים, הצומת הוא הראשון שהחיפוש נכנס אליו ברכיב שלו: הוצא צמתים מהמחסנית עד אליו, וספור רכיב אחד. קשת שמובילה לצומת שכבר הוצא מובילה לרכיב שהושלם, שאינו יכול להגיע בחזרה, ולכן מתעלמים ממנה.
בדוגמה הראשונה, הצומת 3 מוציא את {4, 3} והצומת 0 מוציא את {2, 1, 0}. הקשת של הצומת 5 מובילה אל הצומת 4 שכבר הוצא, ולכן הצומת 5 יוצא לבדו.
אלגוריתם
- עבור כל צומת שלא בוקר, תן לו את האינדקס הבא, הגדר את
lowלאותו ערך, דחוף אותו למחסנית הצמתים ודחוף את המסגרת (node, first edge). - אם נותרה קשת לצומת שבמסגרת העליונה, עבור בעקבותיה: יעד שלא בוקר יקבל אינדקס ומסגרת, ויעד שנמצא במחסנית הצמתים יוריד את
low[node]לאינדקס שלו. - אחרת, הוצא את המסגרת והורד את
lowשל ההורה ל-low[node]. אםlow[node]שווה ל-index[node], הוצא צמתים ממחסנית הצמתים עד לצומת וספור רכיב.
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
מלכודות ומקרי קצה
- שוכחים צמתים שאין להם קשתות. בדוגמה השנייה צומת 4 לא מופיע באף קשת, והוא עדיין רכיב.
- מורידים את
lowדרך כל צומת שביקרנו בו, במקום רק דרך צמתים שנמצאים במחסנית. בדוגמה הראשונה, הקשת5 → 4מונעת מ־5 לסגור את הרכיב שלו. - מתייחסים לקשתות כאילו הן דו־כיווניות. כך סופרים רכיבים קשירים: הדוגמה הראשונה הייתה מניבה 1 במקום 3.
- משתמשים ברקורסיה ב־Python. מחזור שעובר דרך 5000 צמתים יוצר 5000 קריאות מקוננות, פי חמישה ממגבלת ברירת המחדל, שהיא 1000. נהלו מחסנית מסגרות משלכם.
- מריצים את המעבר השני של Kosaraju על הקשתות המקוריות, או לפי הסדר שבו ביקרנו לראשונה בצמתים. כל אחת מהאפשרויות מאחדת רכיבים שמגיעים זה לזה בכיוון אחד בלבד.
שאלות נפוצות3
מהי סיבוכיות הזמן של מציאת רכיבים קשירים היטב?
זמן O(n + E) עם האלגוריתם של Kosaraju או של Tarjan, מכיוון שכל צומת וקשת מטופלים מספר קבוע של פעמים. בשניהם נדרש מקום של O(n + E) עבור רשימות הקשתות ומחסניות החיפוש.
האם להשתמש באלגוריתם של Kosaraju או באלגוריתם של Tarjan?
האלגוריתם של Kosaraju כולל שתי סריקות פשוטות, וקשה יותר לטעות בו. האלגוריתם של Tarjan דורש סריקה אחת ואינו מצריך גרף הפוך, במחיר של ניהול הערך low.
מה ההבדל בין רכיבים קשירים היטב לרכיבים קשירים?
רכיבים קשירים מתעלמים מכיוון הקשתות: שני צמתים שייכים לאותו רכיב כאשר יש מסלול כלשהו שמחבר ביניהם. ברכיבים קשירים היטב, כל צומת צריך להיות מסוגל להגיע לצומת האחר. הדוגמה הראשונה כוללת רכיב קשיר אחד, אך שלושה רכיבים קשירים היטב.
בעיות דומות
בעיות שמשתמשות באותם רעיונות. פתרון של שתיים או שלוש מהן מקבע את הדפוס.
def countSCC(n, edges):
# כתבו כאן קודבדיקה #1
בדיקה #2
ארגומנטים
n = 6 edges = [[0, 1], [1, 2], [2, 0], [2, 3], [3, 4], [4, 3], [5, 4]]
הפלט הצפוי
3