Word Ladder
لديك كلمتان، beginWord وendWord، وقائمة كلمات wordList. يبدأ التسلسل بـbeginWord، وينتهي بـendWord، ويتغير حرف واحد بالضبط من كل كلمة إلى الكلمة التالية. يجب أن تأتي كل كلمة بعد beginWord من wordList، لذا يجب أن تكون endWord ضمنها؛ ولا يلزم أن تكون beginWord ضمنها.
أعِد عدد الكلمات في أقصر تسلسل، مع احتساب الطرفين، أو 0 إذا لم يوجد أي تسلسل.
الدالة
- beginWordstring
- الكلمة الأولى في السُّلَّم
- endWordstring
- الكلمة التي يجب أن يصل إليها السُّلَّم
- wordListstring-array
- الكلمات التي يجب أن تأتي منها كل خطوة لاحقة
- تُرجعinteger
- عدد الكلمات في أقصر تسلسل، أو 0 إذا لم يوجد
القيود
1 ≤ beginWord.length ≤ 10endWordوكل كلمة فيwordListلها الطول نفسه الذي لـbeginWord.1 ≤ wordList.length ≤ 5000- تحتوي جميع الكلمات على أحرف إنجليزية صغيرة فقط.
beginWord != endWord- الكلمات في
wordListمختلفة كلها. قد تكونbeginWordواحدة منها أو لا.
أمثلة
- المدخلات
- beginWord = "lead"endWord = "gold"wordList = ["load", "goad", "gold", "lend", "lewd", "bold"]
- المخرجات
- 4
- الشرح
- يختلف
leadوgoldفي ثلاثة أحرف، لذا يحتاج سلم الكلمات إلى 4 كلمات على الأقل، ويتكوّنleadوloadوgoadوgoldمن 4 كلمات. أماlendوlewdفهما طريقان مسدودان.
- المدخلات
- beginWord = "cat"endWord = "dog"wordList = ["cot", "cog", "dot", "dig"]
- المخرجات
- 0
- الشرح
- تختلف
catوcotوcogعنdogبحرف واحد، لكنdogغير موجودة في القائمة، لذا لا يمكن لأي سلسلة أن تنتهي بها.
- المدخلات
- beginWord = "ab"endWord = "cd"wordList = ["ab", "cb", "cd", "ad"]
- المخرجات
- 3
- الشرح
- يتكوّن كلٌّ من
abوadوcd، ومنabوcbوcd، من 3 كلمات. وتظهرabأيضًا في القائمة، لكن تُحتسب كلمة البداية مرة واحدة.
سؤال إضافي
هل يمكنك إرجاع أقصر سلسلة كلمات نفسها، مرتبةً، بدلًا من طولها؟
تلميحات
افتحها واحدًا تلو الآخر. كل تلميح يكشف أكثر قليلًا.
تخيّل كل كلمة كنقطة، وارسم خطًا بين كلمتين تختلفان في حرف واحد بالضبط. ما السُّلَّم في هذا التصوّر، وما أقصر سُلَّم؟
أقصر مسار هو المسار الذي يحتوي على أقل عدد من الخطوات، وتُحتسب كل خطوة بالقدر نفسه. يصل البحث بالعرض أولًا إلى جميع الكلمات التي تفصلها خطوة واحدة قبل الوصول إلى أي كلمة تفصلها خطوتان، لذا ففي المرة الأولى التي يصل فيها إلى
endWordيكون قد استخدم أقل عدد من الخطوات. علِّم الكلمة بأنها زِيرت لحظة الوصول إليها لأول مرة.مقارنة كلمة بالقائمة بأكملها للعثور على الكلمات المجاورة لها بطيئة. بدلًا من ذلك، أخفِ حرفًا واحدًا في كل مرة: تصبح كل من
hotوhatوhitعلى الصورةh*t. ضع كل كلمة في مجموعة كل نمط من أنماطها. الكلمات المجاورة لكلمة ما هي الكلمات الأخرى الموجودة في مجموعاتها. نفّذ البحث مستوىً تلو الآخر بدءًا منbeginWord، وعدّ المستويات.
الحل
تعامل مع الكلمات بوصفها عُقدًا في رسم بياني، مع وجود حافة بين الكلمات التي تختلف بحرف واحد. تكلفة كل الحواف متساوية، لذا يعثر البحث بالعرض أولًا على أقصر سلسلة. الجزء الصعب هو العثور على الحواف بسرعة. أدناه، n هو عدد الكلمات وL طولها.
جرّب كل سُلّم باستخدام البحث بالعمق أولًا
صحيحة، لكنها لا تنتهي في أكبر الاختبارات
الفكرة
من الكلمة الحالية، جرّب كل كلمة غير مستخدمة تختلف بحرف واحد، وتعمّق مع الحفاظ على الطول عندما تصل إلى endWord. علّم الكلمات الموجودة في المسار على أنها مستخدمة، وأعِد إتاحتها أثناء التراجع. بمجرد وجود سلسلة من best كلمات، توقّف عن تمديد المسارات المكوّنة من best-1 كلمة.
يجرّب هذا كل سلسلة من دون تكرار كلمة، والسلسلة الأقصر لا تكرّر كلمة أبدًا: فحذف الجزء الواقع بين نسختين منها سيجعلها أقصر.
الكلمات الـ26 aaa وbaa حتى zaa تختلف كل واحدة منها عن الأخرى بحرف واحد، ويمكن ترتيبها بنحو 4 × 10^26 طريقة. إذا تعذّر الوصول إلى endWord، فلن يحدث القطع أبدًا وسيجرّب البحث كلّها.
الخوارزمية
- علِّم
beginWordعلى أنه مستخدم إذا كان موجودًا في القائمة، واضبطbestعلى 0. search(word, length): عند الوصول إلىendWord، احتفظ بـlengthإذا كانت أفضل منbest، ثم ارجع. إذا كانتbestمضبوطة وكانlength + 1 ≥ best، فارجع.- وإلا، فلكل كلمة غير مستخدمة تختلف بحرف واحد، علِّمها، واستدعِ
search(next, length + 1)، ثم أزل العلامة عنها. - أعِد
bestبعدsearch(beginWord, 1).
def ladderLength(beginWord, endWord, wordList):
def one_letter_apart(a, b):
differences = 0
for x, y in zip(a, b):
if x != y:
differences += 1
if differences > 1:
return False
return differences == 1
best = 0 # words in the shortest sequence found so far, 0 while there is none
used = [word == beginWord for word in wordList] # words on the current path
def search(word, length):
nonlocal best
if word == endWord:
if best == 0 or length < best:
best = length
return
if best != 0 and length + 1 >= best:
return # any longer path cannot beat the best one
for i, candidate in enumerate(wordList):
if not used[i] and one_letter_apart(word, candidate):
used[i] = True
search(candidate, length + 1)
used[i] = False # free the word for other paths
search(beginWord, 1)
return bestالبحث بعرض الشجرة، ومقارنة كل زوج
صحيحة، لكنها لا تنتهي في أكبر الاختبارات
الفكرة
يستكشف الطابور الكلمات حسب ترتيب المسافة: تغادره كل الكلمات التي تبعد مسافة d قبل أي كلمة تبعد مسافة d + 1. لذا، عندما يُوصَل إلى كلمة لأول مرة على مسافة d، تكون كل الكلمات الأقرب قد استُكشفت بالفعل، ولو وُجد مسار أقصر إليها لوصل إليها في وقت أبكر. تصبح مسافتها نهائية لحظة انضمامها إلى الطابور، لذا علِّمها على أنها زِيرت عندئذٍ، وعندما تظهر endWord لأول مرة، تكون مسافتها هي الإجابة.
يجد هذا الإصدار الكلمات المجاورة لكلمة ما بمقارنتها بكل كلمة في القائمة، ويتوقف عند الاختلاف الثاني: O(n² × L)، أي ما يصل إلى 25 مليون مقارنة بين الكلمات عند وجود 5,000 كلمة.
الخوارزمية
- إذا لم تكن
endWordضمنwordList، فأعِد 0. - أضف
beginWordإلى قائمة الانتظار بطول 1، مع وضع علامة «تمت زيارته» عليه إذا كان ضمن القائمة. - خذ الكلمة التالية وقارنها بكل كلمة لم تتم زيارتها. لكل كلمة تختلف بحرف واحد: إذا كانت
endWord، فأعِد الطول + 1؛ وإلا فضع علامة عليها وأضفها إلى قائمة الانتظار بطول + 1. - إذا فرغت قائمة الانتظار، فأعِد 0.
from collections import deque
def ladderLength(beginWord, endWord, wordList):
def one_letter_apart(a, b):
differences = 0
for x, y in zip(a, b):
if x != y:
differences += 1
if differences > 1:
return False
return differences == 1
if endWord not in wordList:
return 0
visited = [word == beginWord for word in wordList]
queue = deque([(beginWord, 1)]) # (word, words in the sequence up to it)
while queue:
word, length = queue.popleft()
# Compare against every word to find the neighbours.
for i, candidate in enumerate(wordList):
if not visited[i] and one_letter_apart(word, candidate):
if candidate == endWord:
return length + 1
visited[i] = True
queue.append((candidate, length + 1))
return 0البحث بالعرض أولاً باستخدام مجموعات أحرف البدل
الفكرة
تكون كلمتان مختلفتين بحرف واحد بالضبط عندما يؤدي إخفاء الموضع نفسه إلى جعلهما متساويتين: تصبح كل من hot وhit هي h*t. أنشئ لكل كلمة L أنماط، نمطًا لكل موضع مخفي، وأضفها إلى حاوية لكل نمط. جيران الكلمة هم الكلمات الأخرى الموجودة في حاوياتها ذات الأنماط L، ويُعثر عليهم بإجراء L عمليات بحث تجزئة بدلًا من المرور على القائمة.
في المثال الأول، تحتوي l*ad على load، وتحتوي le*d على lend وlewd، لذا يتكون المستوى 2 من هذه الكلمات الثلاث؛ ثم تعطي *oad الكلمة goad، وتعطي go*d الكلمة gold في المستوى 4.
بمجرد فحص حاوية، تكون قد وصلت إلى كل كلمة فيها، لذا أفرغها: تُفحص الحاوية ذات الكلمات الـ26 *aa مرة واحدة، وليس 26 مرة. تبلغ الكلفة O(n × L²) من حيث الزمن والمساحة، أي نحو 500,000 خطوة حرفية لـ5,000 كلمة مكوّنة من 10 أحرف.
الخوارزمية
- إذا لم تكن
endWordموجودة فيwordList، فأعِد 0. - ضع كل كلمة، بما فيها
beginWord، ضمن مجموعة كل نمط من أنماطهاL. - أضف
beginWordإلى قائمة الانتظار، وعلّمها بأنها زِيرت، بطول 1. - لكل كلمة في مستوى: إذا كانت
endWord، فأعِد الطول. وإلا، انقل كل كلمة لم تُزَر في كل مجموعة من مجموعات أنماطها إلى المستوى التالي، وعلّمها بأنها زِيرت، وأفرغ المجموعة. - أضف 1 إلى الطول لكل مستوى. إذا فرغت قائمة الانتظار، فأعِد 0.
from collections import defaultdict, deque
def ladderLength(beginWord, endWord, wordList):
if endWord not in wordList:
return 0
size = len(beginWord)
# "h*t" -> every word that matches it: hot, hat, hit... are one letter apart.
buckets = defaultdict(list)
for word in set(wordList) | {beginWord}:
for i in range(size):
buckets[word[:i] + "*" + word[i + 1:]].append(word)
visited = {beginWord}
queue = deque([beginWord])
length = 1 # words in the sequence up to the current level
while queue:
for _ in range(len(queue)): # one level: every word at this distance
word = queue.popleft()
if word == endWord:
return length
for i in range(size):
pattern = word[:i] + "*" + word[i + 1:]
for neighbour in buckets[pattern]:
if neighbour not in visited:
visited.add(neighbour)
queue.append(neighbour)
buckets[pattern] = [] # all of them are visited now: never scan it again
length += 1
return 0
أخطاء شائعة وحالات حدّية
- عدّ التغييرات بدلًا من الكلمات. الانتقال من
leadإلىgoldيتطلب 3 تغييرات و4 كلمات؛ لذا فالإجابة هي 4. - تخطّي التحقق من وجود
endWordفيwordList. يصل المثال الثاني إلىcog، التي تختلف بحرف واحد عنdog، لكن الإجابة هي 0. - تحديد الكلمة على أنها زِيرت عند إخراجها من قائمة الانتظار. عندها يمكن لكلمة في المجموعة
*aaأن تنضم حتى 25 مرة، وينمو حجم قائمة الانتظار إلى ما يتجاوزnبكثير. - استخدام الاستدعاء التعاودي على طول سلسلة التحويل. قد تتكون أقصر سلسلة من آلاف الكلمات، وهو عدد كافٍ لتجاوز سعة مكدس الاستدعاءات.
أسئلة شائعة3
ما هو التعقيد الزمني لمسألة Word Ladder؟
باستخدام مجموعات أحرف البدل، يكون التعقيد O(n × L²) لـ n كلمة طول كل منها L: لكل كلمة L نمطًا يتكوّن كل منها من L أحرف. تتطلب مقارنة كل زوج من الكلمات O(n² × L)، وتجربة كل مسار باستخدام البحث بالعمق أولًا أُسّية التعقيد.
كيف تجد الكلمات التي تختلف بحرف واحد؟
مجموعات أحرف البدل إحدى الطريقتين. والطريقة الأخرى هي تغيير كل موضع إلى كل حرف من الأحرف الـ26، ثم البحث عن النتيجة في مجموعة تجزئة للكلمات: 26 × L عملية بحث لكلمات طول كل منها L أحرف، أي O(n × 26 × L²) إجمالًا. كلتا الطريقتين أفضل من المرور على القائمة.
هل يمكن للبحث بالعرض ثنائي الاتجاه أن يجعل Word Ladder أسرع؟
نعم. ابحث من الطرفين في الوقت نفسه، مع توسيع الجانب الأصغر مستوىً واحدًا في كل مرة، وتوقّف عندما يلتقي الجانبان. مع نحو b من الجيران لكل كلمة وd من التغييرات، قد يصل البحث الواحد إلى نحو b^d كلمة؛ أما بحثان يلتقيان في المنتصف فيصِلان إلى نحو 2 × b^(d/2).
مسائل مشابهة
مسائل تعتمد على الأفكار نفسها. حلّ اثنتين أو ثلاث منها يثبّت النمط.
def ladderLength(beginWord, endWord, wordList):
# اكتب الكود هنااختبار #1
اختبار #2
اختبار #3
الوسائط
beginWord = "lead" endWord = "gold" wordList = ["load", "goad", "gold", "lend", "lewd", "bold"]
الإخراج المتوقع
4