Target Sum
נתון לך מערך nums של מספרים שלמים אי־שליליים ומספר שלם target. הצב סימן פלוס או מינוס לפני כל מספר, שמור על הסדר שלהם וחשב את ערך הביטוי. החזר כמה בחירות של סימנים הופכות את הביטוי לשווה ל־target. כל אחת מ־2^n הבחירות נספרת בפני עצמה, כך ש־+0 ו־-0 הן שתי בחירות שונות.
פונקציה
- numsinteger-array
- המספרים, שכל אחד מהם מקבל סימן
- targetinteger
- הערך שהביטוי חייב להיות שווה לו
- מחזירהinteger
- מספר אפשרויות הסימן שהביטוי שלהן שווה למטרה
אילוצים
1 ≤ nums.length ≤ 200 ≤ nums[i] ≤ 1000- הסכום של
numsהוא לכל היותר 1000. -1000 ≤ target ≤ 1000- התשובה היא לכל היותר 220, ונמצאת הרבה בתוך הטווח של מספר שלם חתום בן 32 סיביות.
דוגמאות
- קלט
- nums = [2, 1, 1]target = 2
- פלט
- 2
- הסבר
+2 +1 -1וגם+2 -1 +1שווים שניהם ל-2. עם מינוס על ה-2, הערך הגבוה ביותר שאפשר להגיע אליו הוא -2 + 1 + 1 = 0, ולכן שום אפשרות אחרת לא מתאימה.
- קלט
- nums = [0, 4, 4]target = 0
- פלט
- 4
- הסבר
- לשני ה־4 צריך להיות סימן מנוגד,
+4 -4או-4 +4, ובשני המקרים אפשר לתת ל־0 כל אחד מהסימנים. כך מתקבלות 2 × 2 = 4 אפשרויות.
- קלט
- nums = [6, 3]target = 4
- פלט
- 0
- הסבר
- ארבעת הערכים האפשריים הם 9, 3, -3 ו-9-. הפיכת סימן משנה את הסכום פי שניים מהמספר הזה, ולכן כל תוצאה היא אי-זוגית, כמו 6 + 3 = 9, ולעולם לא מגיעים ליעד הזוגי 4.
שאלת המשך
האם תוכל לטפל בעד 40 מספרים, שערכיהם עד 10^9, כשמערך שמאונדקס לפי הסכום לא יכול להיכנס לזיכרון?
רמזים
פתחו אותם אחד אחד. כל רמז חושף קצת יותר.
אפשר לנסות את שני הסימנים עבור כל מספר, כלומר
2^nביטויים. שים לב שקידומות סימנים שונות רבות מובילות לאותו סכום ביניים, ומאותו רגע הן מתנהגות בדיוק באותו אופן.נסמן ב־
Pאת סכום המספרים שמקבלים סימן פלוס, וב־Nאת סכום המספרים שמקבלים סימן מינוס. ידוע לך ש־P - N = targetו־P + N = sum(nums). מה זה אומר לך עלP?P = (sum + target) / 2, לכן סופרים את תתי־הקבוצות שלnumsשסכומן הוא הערך הזה. שומרים אתways[s], מספר תתי־הקבוצות שמגיעות ל־s, ומעדכנים אותו עבור כל מספר, מהערך הגדול ביותר שלsכלפי מטה. מחזירים 0 מיד כאשרsum + targetהוא אי־זוגי או כאשר|target| > sum.
פתרון
לכל מספר יש שני סימנים, ולכן יש 2^n ביטויים. ספירה לפי מיקום וסכום מצטבר מאחדת קידומות של סימנים שמגיעות לאותו סכום. טוב יותר, המספרים עם סימן הפלוס יוצרים תת־קבוצה שסכומה קבוע, ולכן הבעיה הופכת לספירת תת־קבוצות שסכומן נתון, בזמן O(n × S), כאשר S הוא סכום הערכים של nums.
נסו את שני הסימנים עבור כל מספר
נכונה, אבל לא מסתיימת בבדיקות הגדולות ביותר
האינטואיציה
עבור על המספרים משמאל לימין עם סכום מצטבר והסתעף פעמיים בכל אחד מהם: הוסף אותו או החסר אותו. כשיש סימן לכל מספר, ספור 1 אם הסכום שווה ל־target. כל בחירת סימן היא מסלול אחד בדיוק, ולכן סכומי המסלולים מסתכמים בתשובה.
יש עלה אחד לכל בחירה: 2^20 ≈ 10^6 עלים עבור 20 מספרים, וכל מספר נוסף מכפיל את העבודה.
אלגוריתם
- כתבו את
count(i, total), מספר הדרכים להקצות סימנים ל־nums[i..]כאשר המספרים שלפניiכבר מסתכמים ב־total. - אם
i == n, החזירו 1 כאשרtotal == target, אחרת החזירו 0. - אחרת, החזירו
count(i+1, total + nums[i]) + count(i+1, total - nums[i]). - התשובה היא
count(0, 0).
def findTargetSumWays(nums, target):
n = len(nums)
def count(i, total):
# Ways to sign nums[i:] so that the whole expression ends at target
if i == n:
return 1 if total == target else 0
return count(i + 1, total + nums[i]) + count(i + 1, total - nums[i])
return count(0, 0)שמור במטמון לפי המיקום והסכום המצטבר
האינטואיציה
count(i, total) תלויה רק ב־i וב־total, ולא בסימנים שהובילו ל־total. עם nums = [1, 1, 1, 1], הקידומות +1 -1 ו־-1 +1 מגיעות שתיהן לסכום 0 במיקום 2, ולכן החיפוש משם זהה. שמור כל תוצאה בפעם הראשונה והשתמש בה שוב.
הסכום המצטבר נשאר בין -S ל־S, ולכן הוסף לו S כדי להשתמש בו כאינדקס: memo[i][total + S]. יש לכל היותר n × (2S+1) מצבים, בערך 4 × 10^4 עבור 20 מספרים שסכומם 1000, וכל אחד דורש עבודה בזמן קבוע.
אלגוריתם
- חשבו את
S = sum(nums)וצרו מטמוןn × (2S+1)שמלא בערכים -1. - ב־
count(i, total), החזירו את מקרה הבסיס כאשרi == n, כמו קודם. - אם
memo[i][total + S]הוא -1, מלאו אותו בערךcount(i+1, total + nums[i]) + count(i+1, total - nums[i]). - החזירו את הערך השמור וקראו ל־
count(0, 0).
def findTargetSumWays(nums, target):
n = len(nums)
whole = sum(nums)
# memo[i][total + whole]: ways for nums[i:] from this running total, -1 while unknown
memo = [[-1] * (2 * whole + 1) for _ in range(n)]
def count(i, total):
if i == n:
return 1 if total == target else 0
if memo[i][total + whole] == -1:
memo[i][total + whole] = count(i + 1, total + nums[i]) + count(i + 1, total - nums[i])
return memo[i][total + whole]
return count(0, 0)ספרו את תתי-הקבוצות שסכומן הוא (S + target) / 2
האינטואיציה
נסמן את סכום המספרים שסימניהם פלוס ב־P, ואת סכום שאר המספרים ב־N. אז P - N = target וגם P + N = S, ולכן 2P = S + target. בחירת סימנים מתאימה בדיוק כאשר סכום המספרים שסימניהם פלוס הוא (S + target) / 2, וכל תת־קבוצה כזו היא בחירת סימנים אחת.
ways[s] סופר את תת־הקבוצות של המספרים שנראו עד כה שסכומן s, ומתחיל בערך ways[0] = 1. עבור כל מספר x, תת־קבוצה שסכומה s משאירה את x בחוץ או מוסיפה אותו לתת־קבוצה שסכומה s - x. עוברים מ־s הגדול ביותר כלפי מטה, כך ש־ways[s - x] עדיין מכיל את הערך שלו מלפני x, ושום מספר לא משמש פעמיים. אפס מוסיף כל ערך לעצמו, ובכך מכפיל את כל הספירות: אלה שני הסימנים האפשריים של אפס. מדובר ב־n × goal פעולות חיבור, לכל היותר 2 × 10^4.
אלגוריתם
- חשבו את
S = sum(nums). אם|target| > Sאו ש־S + targetהוא אי־זוגי, החזירו 0. - הגדירו
goal = (S + target) / 2, צרו אתwaysעםgoal + 1אפסים והגדירוways[0] = 1. - עבור כל
xב־nums, עבורsמ־goalועדxבסדר יורד, הוסיפו אתways[s - x]אלways[s]. - החזירו את
ways[goal].
def findTargetSumWays(nums, target):
whole = sum(nums)
# plus - minus = target and plus + minus = whole, so plus = (whole + target) / 2
if abs(target) > whole or (whole + target) % 2 == 1:
return 0
goal = (whole + target) // 2
# ways[s]: how many subsets of the values seen so far add up to s
ways = [0] * (goal + 1)
ways[0] = 1
for x in nums:
# From the top down, so each value is used at most once
for s in range(goal, x - 1, -1):
ways[s] += ways[s - x]
return ways[goal]
מלכודות ומקרי קצה
- דילוג על בדיקת הזוגיות. עבור
[6, 3]ויעד 4,(9 + 4) / 2מתעגל כלפי מטה ל־6, ותת-הקבוצה{6}נותנת 1 במקום 0. - דילוג על בדיקת הטווח. עבור
[1, 2, 3]ויעד -10,(6 - 10) / 2 = -2, גודל מערך שלילי. - מילוי
waysמלמטה למעלה. מספר מצטרף אז לתת-קבוצה שכבר משתמשת בו:[1, 3]עם יעד 2 מחזיר 2 במקום 1. - התייחסות לאפס כאל אפשרות אחת. עבור
[0, 0, 0, 1, 0, 0]עם יעד -1 יש 32 תשובות, אחת לכל תבנית סימנים של חמשת האפסים. - שימוש בסכום רץ שלילי כאינדקס במערך memo. יש להזיז כל סכום ב־
S, או להשתמש במפת גיבוב.
שאלות נפוצות3
מה הקשר בין סכום יעד לבעיית סכום תת־קבוצה?
המספרים עם סימן פלוס חייבים להסתכם ב־(S + target) / 2, ולכן התשובה היא מספר תתי־הקבוצות של nums שסכומן זה. כל תכנות דינמי לספירת סכומי תתי־קבוצות יפתור זאת לאחר הוספת שתי בדיקות: הערך חייב להיות מספר שלם, ו־|target| לא חייב להיות גדול מ־S.
מהי סיבוכיות הזמן של Target Sum?
תכנות דינמי לספירת תתי־קבוצות רץ בזמן O(n × S) ומשתמש בזיכרון O(S), כאשר S הוא הסכום של nums. לרקורסיה עם שמירת תוצאות ביניים יש אותו זמן ריצה, אבל טבלה בגודל n × (2S+1). ניסיון של כל סימן נותן O(2^n).
האם אפשר לפתור את Target Sum באמצעות מפת גיבוב של סכומים מצטברים?
כן. מיפוי כל סכום מצטבר שניתן להגיע אליו למספר הקידומות של סימנים שמגיעות אליו, החל מ־{0: 1}, ובניית המפה הבאה מתוך total + x ו־total - x עבור כל מספר. התשובה היא הספירה ב־target. זוהי הרקורסיה עם שמירת תוצאות ביניים שרצה קדימה, באותו זמן O(n × S).
בעיות דומות
בעיות שמשתמשות באותם רעיונות. פתרון של שתיים או שלוש מהן מקבע את הדפוס.
def findTargetSumWays(nums, target):
# כתבו כאן קודבדיקה #1
בדיקה #2
בדיקה #3
ארגומנטים
nums = [2, 1, 1] target = 2
הפלט הצפוי
2