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خوارزمية كوساراجو: مروران
الفكرة
في رسم المكوّنات، يوجد مكوّن هو مصبّ، لا تخرج منه أي حافة، ويعثر البحث الذي يبدأ داخله على ذلك المكوّن وحده. يرتّب خوارزمية كوساراجو عمليات البحث بحيث يتصرّف كلّ منها بهذه الطريقة.
تسرد الجولة الأولى كل عقدة عند انتهاء البحث بالعمق أولًا فيها. تبحث الجولة الثانية في الرسم المعكوس، الذي يحتوي على المكوّنات نفسها، بدءًا من آخر عقدة انتهى البحث فيها. إذا كانت هناك حافة من المكوّن 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خوارزمية تارjan: مرور واحد باستخدام القيم الدنيا
الفكرة
نفّذ بحثًا واحدًا بالعمق أولًا ورقّم كل عقدة بترتيب الوصول إليها: وهذا هو 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) لقوائم الحواف ومكدسات البحث.
هل ينبغي لك استخدام خوارزمية كوساراجو أم خوارزمية تارjan؟
خوارزمية 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