Design Add and Search Words Data Structure
בנה מילון מילים עם שתי פעולות. addWord w מאחסנת את המילה w. search p מציינת אם מילה כלשהי שאוחסנה תואמת לתבנית p: אותו אורך, ובכל מיקום התבנית מכילה את אותה אות או נקודה ., שמייצגת אות אחת כלשהי.
פעולה i היא ops[i] על המילה או התבנית words[i]. הרץ אותן לפי הסדר על מילון ריק והחזר מחרוזת אחת לכל פעולה: "null" עבור addWord, ו-"true" או "false" עבור search.
פונקציה
- opsstring-array
- הפעולות, לפי סדר ביצוען
- wordsstring-array
- המילה שיש להוסיף או התבנית שיש לחפש, אחת לכל פעולה
- מחזירהstring-array
- תשובה אחת לכל פעולה, כטקסט
אילוצים
1 ≤ ops.length ≤ 2500words.length == ops.length- כל
ops[i]הואaddWordאוsearch. 1 ≤ words[i].length ≤ 20- המילה שרוצים להוסיף מכילה אותיות קטנות באנגלית בלבד.
- תבנית לחיפוש מכילה אותיות אנגליות קטנות ולכל היותר 3 נקודות.
דוגמאות
- קלט
- ops = ["addWord", "addWord", "search", "search", "search", "search"]words = ["rose", "rise", "r.se", "ros", "rose.", "..s."]
- פלט
- ["null", "null", "true", "false", "false", "true"]
- הסבר
r.seמתאים לשתי המילים: הנקודה מכסה את האות o במילהroseואת האות i במילהrise. נקודה לעולם אינה מייצגת אות חסרה, ולכןrosו־rose.לא מתאימים...s.מתאים, מכיוון שבשתי המילים האות s היא השלישית.
- קלט
- ops = ["search", "addWord", "search", "search", "search"]words = ["...", "sky", "...", "s.y", "sk"]
- פלט
- ["false", "null", "true", "true", "false"]
- הסבר
- המילון מתחיל ריק, ולכן
...לא מוצא דבר. לאחר שמוסיפים אתsky, גם...וגםs.yתואמים לו, ואילוskקצר מדי.
- קלט
- ops = ["addWord", "addWord", "search", "search", "search"]words = ["maple", "mango", "ma..o", "m...y", "m.p.e"]
- פלט
- ["null", "null", "true", "false", "true"]
- הסבר
- הנקודה הראשונה ב־
ma..oיכולה להיות ה־p שלmapleאו ה־n שלmango;mapleמסתיימת ב־e, אבלmangoמתאימה. אף מילה לא מסתיימת ב־y, ולכןm...yלא מתאימה, ו־m.p.eתואמת ל־maple.
שאלת המשך
איך היית מרחיב את search אם תבנית הייתה יכולה להכיל גם *, שמייצג רצף כלשהו של אותיות, כולל רצף ריק?
רמזים
פתחו אותם אחד אחד. כל רמז חושף קצת יותר.
בלי נקודות, קבוצת גיבוב של המילים המאוחסנות הייתה עונה על כל חיפוש באמצעות חיפוש אחד. נקודה שוברת את זה:
c.tיכולה להיותcat,cotאוcut. איזו מבנה מאפשר לך לעקוב אחר מילה אות אחת בכל פעם ולראות כל אות שיכולה לבוא אחריה?אחסן את המילים בעץ trie, עם דגל בכל צומת שבו מסתיימת מילה מאוחסנת. אות בתבנית עוברת דרך קישור יחיד לצומת ילד. נקודה יכולה להיות כל אות, ולכן צריך לנסות כל צומת ילד של הצומת הנוכחי, ולחזור לאחור כשענף מגיע למבוי סתום.
כתבו פונקציה רקורסיבית
match(node, i). בסוף התבנית, החזירו את דגל הסיום של הצומת. באות, עקבו אחר הצאצא המתאים, או נכשלו אם הוא חסר. בנקודה, החזירו true ברגע ש־match(child, i+1)מצליחה עבור צאצא כלשהו, והחזירו false אם אף אחד מהם לא מצליח.
פתרון
נקודה פירושה שחיפוש לא יכול לנקוב בשם המילה שהוא מחפש: ma..o מייצגת 676 מילים אפשריות, ורק המילים המאוחסנות חשובות. בטרייה, מילים שמתחילות באותו אופן חולקות מסלול אחד. אות בתבנית עוקבת אחר קישור אחד, נקודה מסתעפת רק לילדים שקיימים, וחיפוש לעומק חוזר לאחור מענפים ללא מוצא.
השוו את התבנית לכל מילה שמורה
האינטואיציה
שמור את המילים שנוספו ברשימה ובדוק כל אחת מהן מול התבנית: אותו אורך, ובכל מיקום נקודה או אותה אות. זו ההגדרה להתאמה, שנבדקת מול כל מילה שמורה, ולכן התשובה נכונה.
כל חיפוש עובר על הרשימה כולה, O(n·L) עבור n מילים באורך של עד L אותיות, אפילו כשמדובר בתבנית ללא נקודות. 1,250 הוספות ואחריהן 1,250 חיפושים בודקים 1,250 × 1,250 = 1,562,500 מילים.
אלגוריתם
- שמור את המילים שנוספו ברשימה. עבור
addWord w, הוסף אתwותעד"null". - עבור
search p, השווה אתpלכל מילה באותו אורך, מיקום אחר מיקום, ואפשר לנקודה להתאים לכל אות. - תעד
"true"בהתאמה הראשונה, או"false"אם אין התאמה.
def matches(pattern, word):
# Same length, and every position holds a dot or the same letter.
if len(pattern) != len(word):
return False
for p, w in zip(pattern, word):
if p != "." and p != w:
return False
return True
def wordDictionaryOps(ops, words):
stored = []
result = []
for op, word in zip(ops, words):
if op == "addWord":
stored.append(word)
result.append("null")
else:
found = any(matches(word, s) for s in stored)
result.append("true" if found else "false")
return resultTrie עם חיפוש לעומק על נקודות
האינטואיציה
הכניסו את המילים לטרייה: rose ו-rise חולקות את הצומת r, ואז מתפצלות ל-o ול-i. דגל סיום מסמן כל צומת שבו מסתיימת מילה שמורה. תבנית של אותיות היא מעבר אחד לאורך הטרייה. נקודה יכולה להיות כל אות, לכן בנקודה נסו כל ילד קיים והחזירו true ברגע שאחד מהם מוביל להתאמה.
עבור ma..o, לצומת של ma יש ילדים p ו-n. דרך p, הנקודה השנייה מגיעה ל-l, שאין לו ילד o, לכן החיפוש חוזר לאחור ומנסה את n: אחריו g, ואז o, שמשלים את mango.
התאמה חייבת להשתמש בכל התבנית ולהסתיים בצומת מסומן, וכך גם עניין האורך מתבהר: ros נעצרת בצומת לא מסומן, ו-rose. מגיעה לקצה הקישורים. נקודה פותחת לכל היותר 26 ענפים, לכן d נקודות מבקרות לכל היותר ב-26^d · L צמתים, ותבנית ללא נקודות עולה O(L).
אלגוריתם
- לכל צומת יש קישורים לצאצאים ודגל סיום, שמכובה בתחילה.
- עבור
addWord w: יש לעבור לאורךwמהשורש, ליצור צמתים חסרים ולהפעיל את דגל הסיום במקום שבו ההתקדמות נעצרת. match(node, i): אםiהוא אורך התבנית, יש להחזיר את דגל הסיום של הצומת.- אם
p[i]היא אות, יש להחזיר false אם הצאצא שלה חסר, אחרתmatch(child, i+1). אם היא נקודה, יש להחזיר true אםmatch(child, i+1)מצליחה עבור צאצא קיים כלשהו, אחרת false. - יש לתעד
match(root, 0)בכל חיפוש, ו-"null"בכל הוספה.
class TrieNode:
def __init__(self):
self.children = {} # letter -> TrieNode
self.is_end = False # does a stored word end at this node?
class WordDictionary:
def __init__(self):
self.root = TrieNode()
def add_word(self, word):
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def search(self, pattern):
return self._match(self.root, pattern, 0)
def _match(self, node, pattern, i):
# Can the path below node spell pattern[i:] and end on a stored word?
if i == len(pattern):
return node.is_end
ch = pattern[i]
if ch == ".":
# A dot stands for any letter: try each child until one works.
for child in node.children.values():
if self._match(child, pattern, i + 1):
return True
return False
child = node.children.get(ch)
return child is not None and self._match(child, pattern, i + 1)
def wordDictionaryOps(ops, words):
dictionary = WordDictionary()
result = []
for op, word in zip(ops, words):
if op == "addWord":
dictionary.add_word(word)
result.append("null")
else:
result.append("true" if dictionary.search(word) else "false")
return result
מלכודות ומקרי קצה
- לתת לילד הראשון להכריע לגבי נקודה. ב־
ma..oענף p נכשל וענף n מצליח, לכן קוד שמחזיר את התוצאה של הילד הראשון עונה false כשהוא מנסה את p קודם. ענה false רק אחרי שכל הילדים נכשלים. - לענות true כשהתבנית מסתיימת בכל צומת. אחרי הוספת
rose,rosמגיע לצומת, אבל אף מילה לא מסתיימת שם. בדוק את דגל הסיום. - לאפשר לנקודה להתאים לאות חסרה.
card.מתאים ל־cardsאבל לא ל־card. - להתייחס לנקודה כאילו היא אות. שימוש באינדקס של מערך בן 26 תאים באמצעות
'.' - 'a'קורא מחוץ למערך, לכן בדוק קודם אם מדובר בנקודה.
שאלות נפוצות3
מהי סיבוכיות הזמן של Add ו-Search Words?
הוספת מילה באורך L אורכת O(L), וכך גם חיפוש ללא נקודות, ללא קשר למספר המילים המאוחסנות. חיפוש עם d נקודות אורכת במקרה הגרוע O(26^d · L). הזיכרון הוא O(T) צמתים עבור T אותיות שנוספו.
האם תוכל לפתור זאת באמצעות קבוצת גיבוב במקום trie?
כן, עם פשרות. קבוצה עונה על תבנית ללא נקודות בחיפוש אחד. כשיש נקודות, אפשר לסרוק כל מילה שמורה באורך הזה, O(n·L), או להחליף כל נקודה בכל 26 האותיות ולחפש כל תוצאה, 17,576 חיפושים עבור שלוש נקודות.
האם החיפוש בטריי צריך להיות רקורסיבי או איטרטיבי?
שתי האפשרויות עובדות. הרקורסיה מתקדמת רמה אחת עבור כל אות בתבנית, לכל היותר 20 כאן, ולכן מחסנית הקריאות נשארת קטנה. גרסה איטרטיבית מחזיקה מחסנית של זוגות (node, position) ודוחפת אליה כל ילד בנקודה.
בעיות דומות
בעיות שמשתמשות באותם רעיונות. פתרון של שתיים או שלוש מהן מקבע את הדפוס.
def wordDictionaryOps(ops, words):
# כתבו כאן קודבדיקה #1
בדיקה #2
בדיקה #3
ארגומנטים
ops = ["addWord", "addWord", "search", "search", "search", "search"] words = ["rose", "rise", "r.se", "ros", "rose.", "..s."]
הפלט הצפוי
["null", "null", "true", "false", "false", "true"]