Longest Common Substring
מקבלים שתי מחרוזות, s1 ו־s2. תת־מחרוזת היא רצף רציף של אותיות שנלקח ממחרוזת, כמו ork מתוך clockwork. מצאו את תת־המחרוזת הארוכה ביותר המשותפת ל־s1 ול־s2 והחזירו את האורך שלה. כשאין אות שמופיעה בשתי המחרוזות, התשובה היא 0.
פונקציה
- s1string
- המחרוזת הראשונה
- s2string
- המחרוזת השנייה
- מחזירהinteger
- האורך של רצף האותיות הארוך ביותר שנמצא בשתי המחרוזות
אילוצים
1 ≤ s1.length ≤ 10001 ≤ s2.length ≤ 1000s1ו-s2מורכבים מאותיות אנגליות קטנות.
דוגמאות
- קלט
- s1 = "clockwork"s2 = "workshop"
- פלט
- 4
- הסבר
workמסיימת אתclockworkומתחילה אתworkshop, ולכן היא מחרוזת משנה משותפת באורך 4, ולא מופיע בלוק ארוך יותר בשניהם.
- קלט
- s1 = "hammer"s2 = "harmed"
- פלט
- 2
- הסבר
haו-meמופיעים בשתי המילים, אך אין בהן רצף של שלוש אותיות. בשתי המילים האותיות h, a, m, e מופיעות בסדר הזה, אבל ב-harmedיש r בין a ל-m.
- קלט
- s1 = "fig"s2 = "plum"
- פלט
- 0
- הסבר
- לשתי המילים אין אות משותפת, לכן תת־המחרוזת המשותפת הארוכה ביותר ריקה ואורכה 0.
שאלת המשך
האם אפשר לבדוק אם קיימת תת־מחרוזת משותפת באורך נתון L בזמן של בערך O(n + m) באמצעות גיבוב מתגלגל, ואז לבצע חיפוש בינארי על L?
רמזים
פתחו אותם אחד אחד. כל רמז חושף קצת יותר.
השוואה מכל זוג של מיקומי התחלה חוזרת על אותה עבודה: הבלוק שמתחיל ב־
iוב־jמכיל את הבלוק שמתחיל ב־i+1וב־j+1. נסו להשתמש בתשובה אחת גם עבור האחרת.שאל שאלה ממוקדת יותר עבור כל זוג מיקומים: מה אורכו של הבלוק המשותף שמסתיים בדיוק ב-
s1[i]וב-s2[j]? אם שתי האותיות שונות, התשובה היא 0. אם הן זהות, התשובה היא אחת יותר מהתשובה עבור שתי האותיות שלפניהן.מלאו טבלה
run[i][j]באורכים האלה, כאשר שורה 0 ועמודה 0 מכילות אפסים עבור הקידומות הריקות. התשובה היא הערך הגדול ביותר בכל מקום בטבלה, ולא התא האחרון. כל תא קורא רק את התא שממוקם באלכסון מעליו ומשמאלו, ולכן מספיק לעבור על שורה אחת מימין לשמאל.
פתרון
הרחבת התאמה מכל זוג נקודות התחלה גורמת לקריאה חוזרת של אותן אותיות. בדוק היכן בלוק מסתיים במקום זאת: הבלוק המשותף שמסתיים ב־s1[i] וב־s2[j] ארוך באות אחת מזה שמסתיים בשתי האותיות שלפניהם, או ריק אם האותיות שונות. טבלה של האורכים האלה נותנת את התשובה בזמן O(n × m).
הרחבה מכל זוג של מיקומי התחלה
נכונה, אבל לא מסתיימת בבדיקות הגדולות ביותר
האינטואיציה
כל תת־מחרוזת משותפת מתחילה במיקום כלשהו i של s1 ובמיקום כלשהו j של s2. עבור כל זוג, מתקדמים קדימה כל עוד האותיות זהות וסופרים את הצעדים; הספירה הגדולה ביותר היא התשובה.
טקסט חזרתי מאריך את ההתקדמות. כאשר שתי המחרוזות מורכבות מ־1000 עותקים של a, כל זוג מתקדם עד לסוף הסיומת הקצרה יותר: בערך 3.3 × 10^8 השוואות אותיות. ההתקדמות מ־(i, j) חוזרת על כל ההתקדמות מ־(i+1, j+1), אות אחת מאוחר יותר.
אלגוריתם
- הגדר את
best = 0. - עבור כל
iב־s1וכלjב־s2, הגדרk = 0. - כל עוד גם
i+kוגםj+kנמצאים בתוך המחרוזות שלהם, וגםs1[i+k] == s2[j+k], הוסף 1 ל־k. - שמור את ערך
kהגדול ביותר ב־best, והחזר אותו.
def longestCommonSubstring(s1, s2):
n, m = len(s1), len(s2)
best = 0
for i in range(n):
for j in range(m):
# Walk forward while the two strings agree
k = 0
while i + k < n and j + k < m and s1[i + k] == s2[j + k]:
k += 1
if k > best:
best = k
return bestטבלה של אורכי הבלוקים שמסתיימים בכל זוג
האינטואיציה
נניח ש־run[i][j] הוא האורך של המקטע המשותף הארוך ביותר שמסתיים בדיוק ב־s1[i-1] וב־s2[j-1]; בשורה 0 ובעמודה 0 הערך הוא 0. מקטע כזה חייב להכיל את שתי האותיות. אם הן שונות, לא קיים מקטע והערך בתא הוא 0. אם הן זהות, המקטע מורכב מהזוג הזה ומהמקטע שמסתיים אות אחת קודם בשתי המחרוזות: run[i-1][j-1] + 1. כל תת־מחרוזת משותפת מסתיימת במקום כלשהו, ולכן התשובה היא התא הגדול ביותר, בכל מקום שבו הוא נמצא.
עבור hammer ו־harmed, ה־h-ים נותנים 1 וה־a-ים מאריכים את המקטע ל־2, ואז ה־r קוטעת אותו. ה־m השנייה של hammer פוגשת את ה־m של harmed אחרי אי־התאמה בין m ל־r, ולכן היא מתחילה באורך 1, וה־e-ים מעלים אותו ל־2. אף תא לא מגיע ל־3.
השוואה אחת לכל תא: כמיליון עבור שתי מחרוזות בנות 1000 אותיות, לא משנה מהו הטקסט.
אלגוריתם
- הקצה ל־
runn+1שורות ו־m+1עמודות, שכולן 0, והגדרbest = 0. - עבור על
iבטווח 1..n, ובתוכו עלjבטווח 1..m, תוך השוואה ביןs1[i-1]ל־s2[j-1]. - אם יש התאמה, הגדר
run[i][j] = run[i-1][j-1] + 1ועדכן אתbest. - אם אין התאמה, השאר את התא בתור 0.
- החזר את
best.
def longestCommonSubstring(s1, s2):
n, m = len(s1), len(s2)
# run[i][j]: length of the longest block that ends at s1[i-1] and at s2[j-1]
run = [[0] * (m + 1) for _ in range(n + 1)]
best = 0
for i in range(1, n + 1):
for j in range(1, m + 1):
if s1[i - 1] == s2[j - 1]:
run[i][j] = run[i - 1][j - 1] + 1
if run[i][j] > best:
best = run[i][j]
# a mismatch leaves run[i][j] at 0
return bestשורה אחת, מעודכנת מימין לשמאל
האינטואיציה
כל תא קורא רק את התא שנמצא באלכסון מעליו ומשמאלו. לכן מערך אחד run של m+1 מספרים יכול להכיל את הטבלה אם עוברים על j מימין לשמאל: כשכותבים את run[j], המשבצת run[j-1] עדיין מכילה את הערך של האות הקודמת ב-s1, שהוא התא האלכסוני.
כעת, במקרה של אי-התאמה, צריך לכתוב במפורש 0, כי המשבצת עדיין מכילה מספר מהשורה הקודמת. זמן הריצה נשאר O(n × m), והזיכרון פוחת מכמיליון מספרים ל-1001.
אלגוריתם
- צרו מערך
runשלm+1אפסים והגדירוbest = 0. - עבור כל אות
chשלs1, עברו עלjמ־mעד 1. - אם
ch == s2[j-1], הגדירוrun[j] = run[j-1] + 1ועדכנו אתbest. - אחרת, הגדירו
run[j] = 0. - החזירו את
best.
def longestCommonSubstring(s1, s2):
m = len(s2)
# run[j]: length of the common block that ends at the current letter of s1 and at s2[j-1]
run = [0] * (m + 1)
best = 0
for ch in s1:
# Right to left, so run[j-1] still holds the value from the previous letter of s1
for j in range(m, 0, -1):
if ch == s2[j - 1]:
run[j] = run[j - 1] + 1
if run[j] > best:
best = run[j]
else:
run[j] = 0
return best
מלכודות ומקרי קצה
- החזרת התא האחרון.
run[n][m]מכסה רק בלוקים שמסתיימים בשתי האותיות האחרונות. עבורabcו־abdהערך הוא 0, ואילו עבורabהוא 2. - העתקת השכן הגדול יותר כשיש אי־התאמה. זהו כלל תת־הרצף, והוא מאפשר לבלוק לדלג על אותיות: עבור
hammerו־harmedהוא מחזיר 4 במקום 2. - מעבר על השורה היחידה משמאל לימין. הערך האלכסוני נדרס לפני שקוראים אותו, ולכן עבור
aמולaaמתקבל 2. - דילוג על האיפוס בשורה היחידה. אורך מהאות הקודמת של
s1נשמר, ובהמשך מאריך בלוק שאינו קיים. - מעבר על כוח גס ללא בדיקת גבולות. עבור
zzzzמולzzמתבצעת קריאה מעבר לסוף המחרוזת הקצרה יותר.
שאלות נפוצות3
מה ההבדל בין תת־מחרוזת משותפת ארוכה ביותר לבין תת־רצף משותף ארוך ביותר?
תת־מחרוזת היא רצף רציף של אותיות; תת־רצף שומר על הסדר, אך עשויים להיות בו פערים. עבור hammer ו־harmed, אורך תת־המחרוזת המשותפת הארוכה ביותר הוא 2, ואורך תת־הרצף המשותף הארוך ביותר הוא 4 (hame). בטבלאות, אי־התאמה מאפסת את תא תת־המחרוזת, אך גורמת לתא תת־הרצף להעתיק את ערכו של השכן הגדול יותר.
מהי סיבוכיות הזמן של תת־המחרוזת המשותפת הארוכה ביותר?
פתרון התכנות הדינמי דורש זמן של O(n × m) וזיכרון של O(m) עם שורה אחת. הרחבה מכל זוג נקודות התחלה דורשת במקרה הגרוע O(n × m × min(n, m)). אוטומט סיומות מגיע לכ־O(n + m), במחיר של תוכנית ארוכה בהרבה.
איך מחזירים את המחרוזת המשותפת הארוכה ביותר עצמה?
בכל פעם שתא מנצח את best, תעדו גם את המיקום i ב-s1 שבו הבלוק מסתיים. בסוף, תת־המחרוזת היא אותיות best של s1 שמסתיימות שם. השוואה של גדול ממש משאירה את הבלוק הראשון הארוך ביותר שנמצא ב-s1.
בעיות דומות
בעיות שמשתמשות באותם רעיונות. פתרון של שתיים או שלוש מהן מקבע את הדפוס.
def longestCommonSubstring(s1, s2):
# כתבו כאן קודבדיקה #1
בדיקה #2
בדיקה #3
ארגומנטים
s1 = "clockwork" s2 = "workshop"
הפלט הצפוי
4