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, יש להחזיר length + 1; אחרת יש לסמן אותה ולהכניס אותה לתור עם length + 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²) בסך הכול. שתי הדרכים יעילות יותר ממעבר על הרשימה.
האם חיפוש BFS דו־כיווני יכול להאיץ את 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