الحافة الزائدة واكتشاف الدورات
اكتشف الحافة التي تُنشئ دورة في رسم بياني غير موجّه عبر تطبيق union على كل حافة والتحقق مما إذا كانت عقدتان متصلتين مسبقًا.
الحافة الزائدة واكتشاف الدورات درس مجاني في DSA Interview Prep على CoddyKit. هذا هو الدرس 3 من أصل 4. يمكنك قراءة الدرس كاملاً أدناه مجاناً — ثم تمرن عليه مباشرة في المتصفح باستخدام محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7. هذا الدرس جزء من مسار التعلم في DSA Interview Prep، وتقدمك يتزامن عبر الويب وتطبيق CoddyKit. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
ما هو الاتصال الزائد؟
تمنحكم مسألة Redundant Connection (LeetCode 684) شجرةً تتكون من n عقد وحافة إضافية واحدة، فتشكّل دورة واحدة بالضبط. ومهمتكم هي العثور على الحافة التي تؤدي إزالتها إلى استعادة الشجرة. وإذا وُجدت إجابات متعددة، فأعيدوا الحافة الأخيرة في قائمة الإدخال.
تحتوي الشجرة التي تضم n عقد على n-1 حافة بالضبط، وتكون متصلة ولا تحتوي على دورات. تؤدي إضافة حافة أخرى إلى إنشاء دورة واحدة بالضبط. وتصل الحافة المضافة، أي الحافة الزائدة، بين عقدتين كانتا متصلتين بالفعل في المكوّن نفسه — وهو سيناريو كلاسيكي لاكتشاف الدورات باستخدام DSU.
# Example
# n=5, edges = [[1,2],[1,3],[2,3],[2,4],[3,5]]
# Adding edge [2,3] creates cycle 1-2-3-1
# So [2,3] is the redundant connection
# Key insight: process edges one by one with DSU
# The FIRST edge where both endpoints are already connected is the redundant one
print('Tree property: n nodes, n-1 edges, no cycles')
print('Adding 1 edge: n nodes, n edges, exactly 1 cycle')
print('DSU approach: find the edge that connects already-connected nodes')اكتشاف الدورات باستخدام DSU
يكتشف DSU الدورات بصورة طبيعية: قبل إضافة الحافة (u, v)، تحقّقوا مما إذا كان find(u) == find(v). فإذا كان لهما الجذر نفسه، فهذا يعني أنهما متصلان بالفعل — وإضافة هذه الحافة تنشئ دورة. وهذه هي الحافة الزائدة.
تعمل هذه الطريقة مع الرسوم البيانية غير الموجّهة. وبالنسبة إلى كل حافة، نُجري union بنجاح للمكوّنين أو نكتشف أن طرفيها موجودان بالفعل في المكوّن نفسه، وبذلك نعثر على دورة. التعقيد الزمني هو O(n × alpha(n))، وهو قريب جدًا من O(n).
def find_redundant_connection(edges):
n = len(edges)
parent = list(range(n + 1)) # 1-indexed
rank = [0] * (n + 1)
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False # same component => cycle found
if rank[px] < rank[py]: px, py = py, px
parent[py] = px
if rank[px] == rank[py]: rank[px] += 1
return True
for u, v in edges:
if not union(u, v):
return [u, v] # this edge creates the cycle
edges = [[1,2],[1,3],[2,3],[2,4],[3,5]]
print(find_redundant_connection(edges)) # [2, 3]تتبّع الخوارزمية
دعونا نتتبّع [[1,2],[1,3],[2,3]] خطوةً بخطوة. في البداية، تكون كل عقدة مكوّنًا مستقلًا: {1}، {2}، {3}.
- الحافة [1,2]: find(1)=1، وfind(2)=2، مختلفان — نُجري union لهما. المكوّنات: {1,2}، {3}
- الحافة [1,3]: find(1)=root، وfind(3)=3، مختلفان — نُجري union لهما. المكوّنات: {1,2,3}
- الحافة [2,3]: find(2)=root، وfind(3)=root — الجذر نفسه! اكتُشفت دورة. أعيدوا [2,3].
تعالج الخوارزمية الحواف بالترتيب وتعيد أول حافة تُكمل دورة. وبما أن المسألة تضمن وجود حافة إضافية واحدة فقط، فهذه دائمًا هي الحافة الزائدة الصحيحة.
def find_redundant_trace(edges):
parent = list(range(len(edges) + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for u, v in edges:
pu, pv = find(u), find(v)
print(f'Edge ({u},{v}): find({u})={pu}, find({v})={pv}', end=' => ')
if pu == pv:
print('CYCLE DETECTED!')
return [u, v]
parent[pv] = pu
print('merged')
return []
result = find_redundant_trace([[1,2],[1,3],[2,3]])
print('Redundant edge:', result)اكتشاف الدورات في الرسوم البيانية غير الموجّهة باستخدام DFS
من البدائل عن DSU لاكتشاف الدورات في الرسوم البيانية غير الموجّهة استخدام DFS مع تتبّع الأب. أثناء تنفيذ DFS، إذا وصلنا إلى عقدة سبقَت زيارتها وليست الأب المباشر للعقدة الحالية، فقد وجدنا حافة رجوع — وهذا يدل على وجود دورة.
لكن نهج DFS يتطلب زمنًا قدره O(V + E)، ويعيد ما إذا كانت دورة موجودة، لكنه لا يحدد بسهولة أي حافة بعينها هي الحافة الزائدة. ويُفضّل DSU في المسائل التي تطلب تحديد الحافة الزائدة نفسها، لأنكم تعثرون عليها طبيعيًا عند فشل عملية union.
from collections import defaultdict
def has_cycle_dfs(n, edges):
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
visited = set()
def dfs(node, parent):
visited.add(node)
for nb in graph[node]:
if nb == parent:
continue # skip the edge we came from
if nb in visited:
return True # back edge => cycle
if dfs(nb, node):
return True
return False
for node in range(1, n + 1):
if node not in visited:
if dfs(node, -1):
return True
return False
print(has_cycle_dfs(3, [[1,2],[1,3],[2,3]])) # True
print(has_cycle_dfs(3, [[1,2],[1,3]])) # Falseاكتشاف الدورات في الرسوم البيانية الموجّهة
بالنسبة إلى الرسوم البيانية الموجّهة، لا يعمل اكتشاف الدورات باستخدام DSU مباشرةً لأن للحواف اتجاهًا. وبدلًا من ذلك، استخدموا DFS مع التمييز بثلاثة ألوان: الأبيض للعقد غير المُزارة، والرمادي للعقد الموجودة في مسار DFS الحالي، والأسود للعقد التي اكتملت معالجتها. تشير حافة رجوع إلى عقدة رمادية إلى وجود دورة.
في الرسم البياني غير الموجّه، تعني أي حافة رجوع وجود دورة. أما في الرسم البياني الموجّه، فلا تُعد الحافة العرضية إلى عقدة سوداء دورة — فحواف الرجوع إلى العقد الرمادية وحدها هي التي تدل على ذلك. وهذا الفرق مهم جدًا، ويُختبر في مسائل جدولة المقررات.
def has_cycle_directed(n, edges):
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
# 0=white(unvisited), 1=grey(in stack), 2=black(done)
color = [0] * (n + 1)
def dfs(node):
color[node] = 1 # grey: currently visiting
for nb in graph[node]:
if color[nb] == 1:
return True # back edge to grey node => cycle
if color[nb] == 0:
if dfs(nb):
return True
color[node] = 2 # black: fully processed
return False
for node in range(1, n + 1):
if color[node] == 0:
if dfs(node):
return True
return False
from collections import defaultdict
print(has_cycle_directed(3, [[1,2],[2,3],[3,1]])) # True: 1->2->3->1
print(has_cycle_directed(3, [[1,2],[1,3],[2,3]])) # Falseالاتصال الزائد II: نسخة الرسم البياني الموجّه
توسّع LeetCode 685 المسألة لتشمل الرسوم البيانية الموجّهة التي يكون لكل عقدة فيها أب واحد بالضبط، فتشكّل شجرة متجذّرة مع حافة إضافية. تظهر حالتان: إما أن تكون لعقدة واحدة أبوان، أي أن تكون درجتها الداخلة 2، أو أن توجد دورة من دون أن تكون لأي عقدة أبوّتان.
يفحص الحل أولًا العقد ذات الدرجة الداخلة 2. وإذا عُثر على إحداها، فلا بد أن تكون إحدى حافتيها الواردتين هي الإجابة. بعد ذلك يحدد اكتشاف الدورات باستخدام DSU أيًّا من الحافتين المرشحتين ينبغي إزالتها. ويتعامل هذا النهج ذو المرحلتين مع جميع الحالات بصورة صحيحة.
def find_redundant_directed(edges):
n = len(edges)
parent_map = {} # node -> its parent in the input
candidate1 = candidate2 = None
for u, v in edges:
if v in parent_map: # v already has a parent
candidate1 = [parent_map[v], v] # earlier edge
candidate2 = [u, v] # later edge
else:
parent_map[v] = u
# DSU cycle detection, skipping candidate2 if it exists
dsu = list(range(n + 1))
def find(x):
while dsu[x] != x: dsu[x] = dsu[dsu[x]]; x = dsu[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py: return False
dsu[px] = py; return True
for u, v in edges:
if candidate2 and [u, v] == candidate2: continue # skip candidate2
if not union(u, v): # cycle found without candidate2
return candidate1 if candidate1 else [u, v]
return candidate2 # no cycle when excluding candidate2 => candidate2 is redundant
print(find_redundant_directed([[1,2],[1,3],[2,3]])) # [2,3]
print(find_redundant_directed([[1,2],[2,3],[3,4],[4,1],[1,5]])) # [4,1]التحقق من صحة الرسم البياني بعد إزالة حافة
بعد تحديد الحافة الزائدة، يمكننا التحقق من النتيجة بالتأكد من أن إزالتها تترك شجرة صحيحة: n-1 حافة بالضبط، وجميع العقد متصلة، ولا توجد دورات. وبالنسبة إلى مسألة المقابلة، يضمن DSU ذلك طبيعيًا — فإذا أعدنا الحافة التي فشلت عملية union فيها، فإن إزالتها تترك لدينا الحواف n-1 التي نجحت عمليات union لها بالضبط، وهي تشكّل شجرة ممتدة.
ولهذا الضمان يُعد DSU مناسبًا جدًا لهذه المسألة: فعمليات union الناجحة تبني الشجرة تدريجيًا، وتحدد العملية الفاشلة الحافة الوحيدة التي لا تنتمي إليها.
def verify_tree(n, edges, removed_edge):
parent = list(range(n + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
components = n
for u, v in edges:
if [u, v] == removed_edge:
continue # skip the removed edge
pu, pv = find(u), find(v)
if pu == pv:
print('CYCLE DETECTED after removal! Wrong answer.')
return False
parent[pv] = pu
components -= 1
if components != 1:
print(f'Graph not connected ({components} components). Wrong answer.')
return False
print('Valid tree after removing edge:', removed_edge)
return True
edges = [[1,2],[1,3],[2,3]]
verify_tree(3, edges, [2,3])
verify_tree(3, edges, [1,2]) # wrong removalتحليل التعقيد الزمني وتعقيد المساحة
يعالج الحل المعتمد على DSU كل حافة من الحواف n مرة واحدة بالضبط، وتبلغ تكلفة كل عملية union/find مقدار O(alpha(n)) بمتوسط تراكمي. الزمن الإجمالي: O(n × alpha(n))، وهو عمليًا O(n).
تعقيد المساحة هو O(n) لمصفوفتي الأب والرتبة. وهذا أمثل؛ إذ يجب عليكم على الأقل قراءة الحواف n كلها وتخزين بعض الحالة لكل عقدة. قارنوا ذلك بنهج ساذج ينفّذ DFS بعد كل إضافة لحافة: زمن O(n²) ومساحة O(n + E).
# Summary of complexities
complexity = {
'Naive (DFS after each edge)': {'time': 'O(n^2)', 'space': 'O(n)'},
'DSU (path compression + rank)': {'time': 'O(n * alpha(n))', 'space': 'O(n)'},
'Sorting + DSU (Kruskal style)': {'time': 'O(n log n)', 'space': 'O(n)'},
}
for approach, costs in complexity.items():
print(f'{approach}:')
print(f' Time: {costs["time"]}')
print(f' Space: {costs["space"]}')
print()
print('alpha(n) <= 4 for all practical n, so DSU is effectively O(n).')حالة طرفية: حلقة ذاتية
تنشئ الحافة ذات الحلقة الذاتية [u, u] دورةً فورًا، لأن طرفيها هما العقدة نفسها. وفي DSU، يكون find(u) == find(u) صحيحًا دائمًا، لذلك تفشل عملية union فورًا وتُعاد [u, u] بوصفها الحافة الزائدة.
تضمن معظم قيود المسائل عدم وجود حلقات ذاتية، لكن ينبغي للكود المتين التعامل معها. ويتعامل تنفيذ DSU معها طبيعيًا من دون حالة خاصة — إذ يكتشفها فحص الدورة if find(u) == find(v) قبل محاولة إجراء أي عملية union. احرصوا دائمًا على التحقق باستخدام مدخلات الحالات الطرفية، مثل الحلقات على عقدة واحدة والمدخلات ذات الحجم الأدنى.
def find_redundant_robust(edges):
n = len(edges)
parent = list(range(n + 1))
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
for u, v in edges:
pu, pv = find(u), find(v)
if pu == pv:
return [u, v] # handles self-loops too: u==v => pu==pv always
parent[pv] = pu
return []
# Self-loop test
print(find_redundant_robust([[1,2],[2,2]])) # [2,2] self-loop
# Minimum tree test
print(find_redundant_robust([[1,2],[2,3],[1,3]])) # [1,3]
# Standard test
print(find_redundant_robust([[1,2],[1,3],[2,3],[2,4],[3,5]])) # [2,3]تعميم اكتشاف الدورات عبر الخوارزميات
تكتشف خوارزميات متعددة الدورات، وتناسب كل خوارزمية منها سيناريوهات مختلفة:
- DSU: الرسوم البيانية غير الموجّهة، ووصول الحواف عبر الإنترنت، وO(alpha(n)) لكل حافة — الأفضل لعدّ الحواف الزائدة أو العثور عليها
- DFS مع تتبّع الأب: الرسوم البيانية غير الموجّهة، مع معرفة جميع الحواف مسبقًا، وO(V+E) — الأفضل عندما تحتاجون إلى مسار الدورة
- DFS بثلاثة ألوان: الرسوم البيانية الموجّهة، واكتشاف حواف الرجوع، وO(V+E) — الأفضل لمسائل جدولة المقررات والفرز الطوبولوجي
- الفرز الطوبولوجي (Kahn's): الرسوم البيانية الموجّهة، ويكتشف الدورة من خلال العقد المتبقية ذات الدرجة الداخلة غير الصفرية — الأفضل عندما تحتاجون أيضًا إلى ترتيب
# When to use which cycle-detection method:
# Problem type => preferred algorithm
problems = [
('Redundant Connection (undirected)', 'DSU'),
('Course Schedule (directed)', 'DFS three-color or Kahn topological sort'),
('Detect cycle in undirected graph', 'DFS with parent tracking or DSU'),
('Find cycle members in directed graph', 'DFS three-color + backtrack'),
('Online graph edges with cycle check', 'DSU'),
('Minimum spanning tree validity', 'DSU (Kruskal)'),
]
for problem, solution in problems:
print(f'{problem}\n => {solution}\n')الحل الكامل مع الحالات الطرفية
إليكم حلًا جاهزًا للاستخدام في بيئة الإنتاج لمسألة Redundant Connection، ويتعامل مع جميع الحالات الطرفية: العقد المفهرسة بدءًا من 1، ووجود حافة زائدة واحدة بالضبط، وضمان أن إزالتها تترك شجرة صحيحة. ويستخدم الحل DSU الأمثل مع تقليص المسار إلى النصف والاتحاد بحسب الرتبة.
بعد الإرسال، جرّبوا السؤال التالي: ماذا لو كان الرسم البياني يمكن أن يحتوي على عدة حواف زائدة؟ ستحتاجون إلى تتبّع جميع الحواف التي تُكمل دورة وإعادة الحافة الأخيرة في الإدخال — وستظل الاستراتيجية الجشعة نفسها صالحة لأن DSU يعالج الحواف بالترتيب.
def find_redundant_connection(edges):
n = len(edges)
parent = list(range(n + 1))
rank = [0] * (n + 1)
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path halving
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py:
return False
if rank[px] < rank[py]:
px, py = py, px
parent[py] = px
if rank[px] == rank[py]:
rank[px] += 1
return True
for u, v in edges:
if not union(u, v):
return [u, v]
return [] # should never reach here given valid input
test_cases = [
[[1,2],[1,3],[2,3]],
[[1,2],[2,3],[3,4],[1,4],[1,5]],
[[1,2],[1,3],[2,3],[2,4],[3,5]],
]
for tc in test_cases:
print(find_redundant_connection(tc))تحقق سريع
اختبروا فهمكم لمفاهيم Data Structures & Algorithms — Coding Interview Prep الواردة في هذا الدرس.
مراجعة الدرس
في هذا الدرس تعلمتم أن: الاتصال الزائد هو حافة تصل بين عقدتين متصلتين أصلًا في رسم بياني غير موجّه، وأن DSU يكتشف ذلك بالتحقق من find(u) == find(v) قبل union وإعادة تلك الحافة، وأن الرسوم البيانية الموجّهة تتطلب DFS ذا تلوين ثلاثي أو خوارزمية Kahn's بدلًا من DSU لاكتشاف الدورات. بعد ذلك سنطبّق DSU على مسألة دمج الحسابات، حيث تكون رسائل البريد الإلكتروني هي العقد، وتؤدي الرسائل المشتركة بين الحسابات إلى عمليات union.
الأسئلة الشائعة
هل درس «الحافة الزائدة واكتشاف الدورات» مجاني؟
نعم — نص درس «الحافة الزائدة واكتشاف الدورات» كامل متاح مجاناً هنا على الويب. لتمرينه بشكل تفاعلي (محرر أكواد مدمج ومدرس ذكاء اصطناعي متاح 24/7) وفتح باقي دورة DSA Interview Prep، انتقل إلى CoddyKit PRO. تتضمن دورة DSA Interview Prep 4 دروس في المجموع.
ماذا ستتعلم في «الحافة الزائدة واكتشاف الدورات»؟
اكتشف الحافة التي تُنشئ دورة في رسم بياني غير موجّه عبر تطبيق union على كل حافة والتحقق مما إذا كانت عقدتان متصلتين مسبقًا. تتمرن على DSA Interview Prep مع أكواد عملية تشغلها مباشرة في المتصفح، ومدرس ذكاء اصطناعي متاح 24/7 يجيب على أسئلتك أثناء عملك.
هل أحتاج إلى خبرة سابقة لأبدأ DSA Interview Prep؟
لا تُشترط خبرة سابقة. DSA Interview Prep على CoddyKit منظم للمبتدئين حتى المتقدمين، لذا يمكنك البدء من هنا أو من البداية والتقدم بسرعتك الخاصة. هذا هو الدرس 3 من أصل 4.
كم من الوقت يستغرق درس «الحافة الزائدة واكتشاف الدورات»؟
معظم دروس CoddyKit تستغرق حوالي 5–10 دقائق. كل منها موجز وتفاعلي، لذا تحرز تقدماً مستمراً وتستأنف من حيث توقفت عبر الويب والتطبيق.
هل يمكنني كتابة وتشغيل أكواد في درس DSA Interview Prep هذا؟
نعم. كل درس في DSA Interview Prep يتضمن محرر أكواد مدمج، لذا تكتب وتشغل أكواداً حقيقية مباشرة في متصفحك وتحصل على تعليقات فورية من الذكاء الاصطناعي — بدون إعداد محلي.
جميع الدروس في هذه الدورة
- DSU مع ضغط المسار
- الدمج حسب الرتبة وحدّ دالة Ackermann العكسية
- الحافة الزائدة واكتشاف الدورات
- دمج الحسابات والمكوّنات المتصلة