الصناديق المقفلة
درس 2 من 11
2الصناديق المقفلة
مسألة تبدو عن مفاتيح وصناديق، وهي في الحقيقة أول بحث لك في رسم بياني: من الصندوق صفر، إلى أين تصل؟
ماذا تشاهد في الرسم: نبدأ بالصندوق 0، ونفتح كل صندوق وجدنا مفتاحه، حتى لا يبقى مفتاح جديد. أي صندوق لم يُفتح يعني أن الجواب False.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
أمامك صناديق مقفلة، وفي كل صندوق مفاتيح لصناديق أخرى. الصندوق الأول مفتوح. هل تستطيع أن تفتحها كلها؟ تفتح الأول وتأخذ مفاتيحه، ثم تفتح بها ما تستطيع وتجمع مفاتيح جديدة، حتى لا يبقى مفتاح جديد. وأي صندوق بقي مقفلًا يعني أن الجواب لا.
الصناديق عقد والمفاتيح أسهم، فالمسألة قابلية وصول reachability في رسم بياني موجّه. تحلّها ببحث بالعمق DFS أو بالعرض BFS مع مجموعة set للمفتوح تمنع الدوران، في زمن O(V + E).
المتطلبات قبل البداية
المجموعة (set)
set تفحص العضوية في زمن ثابت O(1) تقريبًا، بينما list تمشي على القائمة كلها. هذا الفرق هو ما يحوّل حلك من بطيء إلى سريع.
المكدّس (stack)
قائمة بايثون العادية تكفي: append تضيف في النهاية وpop تسحب من النهاية. هذا هو المكدّس بلا أي مكتبة.
while مع شرط متغيّر
لا نعرف مقدمًا كم دورة نحتاج، فالحلقة تستمر «ما دام هناك عمل متبقٍّ». هذه الحالة التي فيها while أفضل من for.
فكرة الرسم البياني
ما تحتاج مادة سابقة في الرسوم البيانية، هذا المشروع هو مقدمتك لها. لكن اعرف أن «عقدة» تعني نقطة و«حرف» (edge) يعني سهم بين نقطتين.
المفاهيم الأساسية
المسألة: عندك n صندوقًا مرقّمة من 0 إلى n-1. الصندوق 0 مفتوح، وكل صندوق فيه مفاتيح لصناديق أخرى. السؤال: هل تستطيع أن تفتحها كلها؟
1إعادة صياغة المسألة: هذا رسم بياني
بدّل الكلمات وتختفي المسألة: كل صندوق هو عقدة، وكل مفتاح هو سهم من الصندوق الذي فيه المفتاح إلى الصندوق الذي يفتحه. والسؤال صار: هل كل العقد قابلة للوصول من العقدة صفر؟
هذه تسمّى مسألة قابلية الوصول (reachability)، وحلّها معروف: ابدأ من نقطة البداية، وتتبّع كل سهم تستطيع عليه، وسجّل كل عقدة وصلتها، ثم قارن عدد ما وصلته بالعدد الكلي.
2لماذا نحتاج المجموعة opened؟
سببان، وكلٌّ منهما يكفي:
- منع الدوران اللانهائي. لو كان الصندوق 0 فيه مفتاح 2 والصندوق 2 فيه مفتاح 0، فبدون تسجيل ما فُتح ستدور بينهما إلى الأبد.
- العدّ. في النهاية نحتاج نعرف كم صندوقًا فتحنا. المجموعة تمنع العدّ المزدوج لو وصلنا نفس الصندوق من طريقين.
3المفاتيح التي بلا صناديق
النص يقول صريحًا: «قد توجد مفاتيح ليس لها صناديق». يعني ممكن يأتيك المفتاح 42 وعندك 5 صناديق فقط. لذلك الشرط 0 <= key < n ليس زينة، بدونه يظهر لك IndexError عند boxes[42].
4عمقًا أولًا أم عرضًا أولًا؟
| DFS (عمقًا أولًا) | BFS (عرضًا أولًا) | |
|---|---|---|
| البنية | stack، pop() من النهاية | deque، popleft() من البداية |
| المسار | يغوص في فرع واحد لآخره ثم يرجع | يفتح كل الصناديق القريبة أولًا |
| النتيجة هنا | نفسها تمامًا | نفسها تمامًا |
| التعقيد | O(n + k) | O(n + k) |
بما أن السؤال «هل نصل للكل؟» ولا يهمنا أقصر طريق، فالاثنان صحيحان. المكدّس أبسط لأنه قائمة عادية بلا استيراد. (k هو عدد المفاتيح كلها، أي مجموع أطوال القوائم الداخلية.)
5الحل الساذج ولماذا نتجنّبه
الحل الأول الذي يخطر في البال: امشِ على كل الصناديق، خذ مفاتيح المفتوح منها، وكرّر هذا حتى ما يتغيّر شيء. هذا يعطي الجواب الصحيح، لكن في أسوأ الحالات تحتاج n مسحة وكل مسحة O(n)، فيصير O(n²). البحث بمكدّس يزور كل صندوق مرة واحدة فقط.
opened بدل مجموعة. key in opened_list داخل حلقة يجعل الحل O(n²) بدون أن تلاحظ، لأن كل فحص عضوية يمشي على القائمة كلها. اختيار البنية ليس تفصيلًا تجميليًا، هو التعقيد نفسه.كيف يعمل؟
ثلاثة أشكال: مسار البحث نفسه، ثم الحالة الفاشلة من الاختبار كرسم بياني، ثم الحل الساذج لكي ترى الفرق بعينك.
شكل 3، مسار البحث: اسحب صندوقًا من المكدّس، خذ منه كل مفتاح جديد وصالح، وأضف صاحبه للمكدّس، حتى يفرغ.
شكل 4، نفس الحالة الثالثة من الاختبار كرسم بياني: الصندوقان 5 و6 (المرسومان بخط متقطع) ما يصل لهما سهم أبدًا، ومفاتيحهما لا تنفعهما لأن المفتاح داخل صندوق مقفل كأنه غير موجود.
شكل 5، الحل الساذج: مسحة كاملة على كل الصناديق في كل دورة، ويمكن تفتح صندوقًا واحدًا فقط في المسحة، فيصير العمل مضاعفًا بلا داعٍ.
الحل خطوة بخطوة
نكتب الحل، نشغّله، ثم نمشي على الحالة الفاشلة خطوة بخطوة. كل مخرج تحت حقيقي.
الخطوة 1، الحل
#!/usr/bin/python3
"""0-lockboxes"""
def canUnlockAll(boxes):
"""Return True if every box can be opened starting from box 0."""
n = len(boxes)
if n == 0:
return True
opened = {0}
stack = [0]
while stack:
box = stack.pop()
for key in boxes[box]:
if 0 <= key < n and key not in opened:
opened.add(key)
stack.append(key)
return len(opened) == nالخطوة 2، التشغيل
$ cat main_0.py
#!/usr/bin/python3
canUnlockAll = __import__('0-lockboxes').canUnlockAll
boxes = [[1], [2], [3], [4], []]
print(canUnlockAll(boxes))
boxes = [[1, 4, 6], [2], [0, 4, 1], [5, 6, 2], [3], [4, 1], [6]]
print(canUnlockAll(boxes))
boxes = [[1, 4], [2], [0, 4, 1], [3], [], [4, 1], [5, 6]]
print(canUnlockAll(boxes))
$ ./main_0.py
True
True
Falseالخطوة 3، الحدود
>>> canUnlockAll([[]]) # one box, already open True >>> canUnlockAll([[1], []]) # box 0 holds the key to box 1 True >>> canUnlockAll([[], [1]]) # box 1 unreachable; its own key is useless False >>> canUnlockAll([[1, 9, 42], []]) # keys with no box: harmlessly ignored True
السطر الأخير مهم: المفاتيح 9 و42 لا توجد لها صناديق، والحل يتجاهلها بهدوء بفضل شرط المدى.
الخطوة 4، امشِ على الحالة الفاشلة
تتبّع الحالة الثالثة، سحبة بسحبة
الحالة: [[1,4],[2],[0,4,1],[3],[],[4,1],[5,6]]، سبعة صناديق.
البداية: opened = {0} وstack = [0].
نسحب 0. مفاتيحه [1, 4]. الاثنان جديدان وصالحان، فنضيفهما.
opened = {0,1,4} · stack = [1,4]
نسحب 4 (آخر المكدّس، هذا هو العمق أولًا). مفاتيحه []، فما فيه شيء نضيفه.
opened = {0,1,4} · stack = [1]
نسحب 1. مفتاحه [2] وهو جديد، فنضيفه.
opened = {0,1,2,4} · stack = [2]
نسحب 2. مفاتيحه [0, 4, 1]: الصندوق 3 جديد فنضيفه، و0 و4 مفتوحان فنتجاهلهما، وهنا بالضبط تمنع المجموعة الدوران.
opened = {0,1,2,3,4} · stack = [3]
نسحب 3. مفتاحه []. المكدّس فرغ.
الحساب: len(opened) = 5 وn = 7، و5 != 7 ← False.
الصندوقان 5 و6 ما وصلناهما أبدًا. لاحظ أن الصندوق 6 فيه مفاتيح 5 و6، لكن هذه المفاتيح نفسها داخل صندوق مقفل، فلا قيمة لها.
stack = [0] بـ deque([0]) وpop() بـ popleft() وتحصل على بحث عرضًا أولًا. النتيجة واحدة، لكن ترتيب الزيارة يختلف، وهذا الترتيب هو ما يهم في مسائل «أقصر مسار» لاحقًا.اعتقاد شائع × الحقيقة
| الاعتقاد | الحقيقة |
|---|---|
| «المسألة عن مفاتيح، فيجب أن أتعامل مع المفاتيح واحدًا واحدًا.» | المفاتيح ليست الهدف، الصناديق المفتوحة هي الهدف. فكّر بما وصلته، لا بما تحمله. |
| «إذا كان المفتاح موجودًا فالصندوق مفتوح.» | المفتاح الموجود داخل صندوق مقفل كأنه غير موجود. القابلية للوصول تمرّ عبر صناديق مفتوحة فقط. |
| «يجب أن أتأكد إن كل مفتاح مستخدم.» | لا. مفاتيح زائدة أو مكرّرة أو بلا صناديق كلها مقبولة. السؤال الوحيد: هل فُتحت كل الصناديق؟ |
«لو كان الصندوق 0 فاضيًا فالجواب False.» | صح فقط إذا كان فيه أكثر من صندوق. [[]] يعطي True، صندوق واحد مفتوح أصلًا. |
«DFS وBFS يعطيان نتائج مختلفة.» | لقابلية الوصول: نفس النتيجة تمامًا. الاختلاف في ترتيب الزيارة فقط، وهذا يهم في مسائل أخرى. |
«أستطيع أن أستخدم boxes وأعدّه في النهاية.» | المدخل لا يخبرك من فُتح. يجب سجل منفصل، وإلا لا تستطيع تفرّق بين «وصلته» و«موجود». |
أين نستخدمه فعليًا؟
حلّ التبعيات
«هل كل الحزم التي أحتاجها قابلة للتركيب بدءًا من هذه الحزمة؟» نفس المسألة بالحرف: الحزمة عقدة والتبعية سهم.
الصلاحيات والأدوار
دور يمنح أدوارًا أخرى، وتلك تمنح غيرها. السؤال «ما الذي يستطيع أن يصل إليه هذا المستخدم فعليًا؟» هو قابلية وصول في رسم بياني.
جمع المهملات (garbage collection)
محرّك اللغة يبدأ من الجذور ويتتبّع المراجع. كل كائن ما وصله البحث هو مهملات. خوارزميتك هذه هي نواة «mark» في mark-and-sweep.
زحف الشبكة
ابدأ من صفحة، اتبع الروابط، لا تزر صفحة مرتين. المجموعة opened هي «الصفحات المزارة» بالضبط.
جرّب بنفسك
حذفت شرط key not in opened فقط، وخلّيت الباقي كما هو. ما أسوأ ما يصير؟
بدون هذا الشرط نعيد إضافة صناديق فُتحت مسبقًا إلى المكدّس. لو كان الصندوق 0 فيه مفتاح 2 والصندوق 2 فيه مفتاح 0، فالمكدّس ما يفرغ أبدًا، وهذا هو السبب الأول لوجود opened قبل أن يكون للعدّ.
بطاقات مراجعة سريعة
opened؟O(1) بدل O(n)، وهذا يخلّي الحل O(n+k) بدل O(n²).key not in opened قبل الإضافة للمكدّس. بدونه الدورات تدور بلا نهاية.0 <= key < n؟boxes[key] لمفتاح كبير يرفع IndexError.len(opened) == n، ولا شيء غير ذلك.O(n + k) حيث n عدد الصناديق وk عدد المفاتيح كلها، كل صندوق يُزار مرة وكل مفتاح يُقرأ مرة.تحقّق من فهمك
1. لماذا [[1], []] يرجع True بينما [[], [1]] يرجع False؟
في الأولى الصندوق 0 (المفتوح) فيه مفتاح 1، فنصل للاثنين. في الثانية الصندوق 0 فاضي فما نتحرّك أبدًا؛ ومفتاح 1 الموجود في الصندوق 1 لا ينفع لأن الصندوق 1 نفسه مقفل. المفتاح يفيدك فقط إذا كان في صندوق وصلته.
2. هل ترتيب المفاتيح داخل الصندوق يؤثّر على النتيجة؟
لا على النتيجة، نعم على ترتيب الزيارة. مجموعة الصناديق القابلة للوصول لا تتغيّر بترتيب الأسهم، لكن تتبّعك اليدوي سيختلف، وهذا يحيّر كثيرين وهم يصحّحون الكود.
3. (اكتشف الخطأ) حلك يرجع True لحالة صحيحة فيها 100 صندوق، ويعمل لكن ببطء شديد. ماذا تفحص أولًا؟
نوع opened. لو كانت قائمة فكل in يمشي على القائمة كلها، فالحل صحيح لكن O(n²). حوّلها إلى set وفقط.
4. أحدهم يستخدم stack.pop(0) بدل stack.pop(). هل الحل يبقى صحيحًا؟
صحيحًا نعم (صار BFS تقريبًا)، لكن pop(0) في قائمة بايثون هي O(n) لأنها تزحزح كل العناصر. للبحث عرضًا أولًا استخدم collections.deque وpopleft().
5. (قرار) المدخل فيه مليون صندوق وعمق متداخل كبير. DFS بالاستدعاء الذاتي ولا بمكدّس صريح؟
بمكدّس صريح. الاستدعاء الذاتي يصل حد العمق في بايثون (حوالي 1000 افتراضيًا) ويرفع RecursionError. المكدّس الصريح محدود بالذاكرة فقط، وهذه قاعدة عامة: كل DFS تكراري يمكن كتابته بمكدّس.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.