פרק 1 – רקורסיה


היכרות עם חשיבה רקורסיבית ומקרים בסיסיים

רקורסיה היא טכניקה מרכזית במדעי המחשב שבה פונקציה פותרת בעיה על ידי פירוקה לגרסה קטנה יותר של אותה בעיה. חשוב להבדיל בין מקרה בסיסי שבו הפונקציה עוצרת, לבין מקרה רקורסיבי שבו היא קוראת לעצמה. הבנת עקרונות אלו תלווה אתכם לאורך הקורס .

מבוא לרקורסיה

רקורסיה מתארת תהליך שבו פונקציה קוראת לעצמה לצורך פתרון בעיה. למשל, קביעת סכום של מערך יכולה להתבצע על ידי סכימת האיבר הראשון עם סכום המערך החל מהאיבר השני. כדי להבטיח שהאלגוריתם מסתיים, עלינו להגדיר מקרה בסיס – תנאי, שבו הפונקציה מחזירה תוצאה ללא קריאה נוספת.

עדי גרין מסבירה לנו מהי רקורסיה

דוגמא לרקורסיה - סכום איברי מערך?
1
2
3
4
5
6
7
8
9
10
// סכום אלמנטים במערך באמצעות רקורסיה
public static int Sum(int[] arr, int index)
{
    // מקרה בסיס – הגענו לסוף המערך
    if (index == arr.Length)
        return 0;

    // מקרה רקורסיבי – מוסיפים את הערך הנוכחי לסכום שאר האיברים
    return arr[index] + Sum(arr, index + 1);
}
הבנת מחסנית הקריאות. או שלא...

הבנת מחסנית הקריאות

כל קריאה לפונקציה יוצרת רשומת קריאה חדשה במחסנית. כאשר אנחנו קוראים לפונקציה ברקורסיה, נוצרת שרשרת רשומות עד שמגיעים למקרה הבסיס. לאחר מכן הקריאות מסתיימות בסדר הפוך (LIFO), כמו במחסנית.

graph TD A["Sum(arr, 0)"] --> B["Sum(arr, 1)"] B --> C["Sum(arr, 2)"] C --> D["Sum(arr, 3)"] D --> E["Sum(arr, n)"] E --> F[0]

הדיאגרמה ממחישה כיצד הקריאות נערמות עד למקרה בסיס, ומסמנות חזרה עם הערך 0. כל קריאה מחכה לפתרון הקריאה העמוקה יותר לפני שהיא מחזירה את ערכה.

בקוד זה המקרה הבסיסי הוא כאשר index שווה לגודל המערך. המקרה הרקורסיבי מוסיף את האיבר הנוכחי לתוצאה של קריאה מחדש לפונקציה עם אינדקס מתקדם.

סיכום הרעיון. אז מה זה רקורסיה?

אלגוריתם רקורסיבי הוא אלגוריתם אשר על מנת לפתור בעיה מסוימת, מפעיל את עצמו על מקרים פשוטים יותר של הבעיה.

בכל אלגוריתם רקורסיבי תמיד יש:

  • תנאי עצירה
  • זימון רקורסיבי על קלט קטן יותר

אז למה להשתמש ברקורסיה?

  • בהרבה מקרים ההגדרה הרקורסיבית קצרה בהרבה מההגדרה האיטרטיבית.
  • במקרים מסויימים, ההגדרה הרקורסיבית היא ההגדרה הטבעית והנוחה ביותר של מה שרוצים לחשב.
  • במבני נתונים מסויימים אין ברירה.
  • כי זה למבחן.

✅ מקרים בסיסיים חשובים

א. מגדירים תנאי עצירה ברור (למשל, גודל אפס או אינדקס מחוץ לתחום).

ב. אסור לקרוא לפונקציה עצמה מתוך המקרה הבסיסי – אחרת נתקע בלולאה אינסופית.

ג. חשוב לזהות את מקרה הבסיס כפתרון הבעיה הפשוטה ביותר האפשרית.

דוגמא 1/תרגול: מכפלה של שני מספרים באופן רקורסיבי

כתבו את הפונקציה Mul(int a, int b) המחשבת כפל של \(a·b\) ברקורסיה.

הרעיון: \(a⨯b = a+a+ ... b פעמים\) נניח ש-\(b\) אינו שלילי.

הדרכה. חשבו על המקרה בו עלינו לכפול 4 ב-1, או בעצם, אפילו 4 ב-0, כעל המקרה הפשוט. זה ישמש תנאי עצירה

כיצד נחשב כפל של 4 ב-2 אם אנחנו יודעים כמה זה כפל ב-1?

פתרון
1
2
3
4
5
6
7
public static int Mul(int a, int b)
{
  if (b == 0) 
    return 0; // תנאי עצירה

  return a + Mul(a, b - 1); // b חזרה עם הקטנת 
}
מעקב בשיטת המלבנים
flowchart TD A["Mul(4,3) (b==0? false) return 4 + Mul(4,2)"] -->|קריאה רקורסיבית| B["Mul(4,2) (b==0? false) return 4 + Mul(4,1)"] B -->|קריאה רקורסיבית| C["Mul(4,1) (b==0? false) return 4 + Mul(4,0)"] C -->|קריאה רקורסיבית| D["Mul(4,0) (b==0? true) return 0"] D -.->|חזרה: 0| C C -.->|חזרה: 4| B B -.->|חזרה: 8| A A -.->|תוצאה: 12| OUT(("Mul(4,3) = 12"))

מעקב רקורסיה בשיטת המלבנים: בשורה העליונה – שם הפונקציה והארגומנטים, בשורה האמצעית – תנאי העצירה, ובשורה השלישית – ביטוי ה־ return.
בחיצים: (חץ מלא) מציין קריאה רקורסיבית (הלוך), ו־-.-> (חץ מקווקו) מציין החזרת ערך (חזור). במעקב שלהלן הכפל \(4·3\)

❌ טעויות נפוצות ברקורסיה

א. שִׁיכְחַת מקרה בסיס – תגרום ל־StackOverflow.

ב. שינוי נתונים משותפים במיקום לא נכון – עלול לגרום לתוצאה שגויה. לא רלוונטי עבורנו. לא נשתף נתונים. לא נבצע Memoization.

ג. כתיבת קוד רקורסיבי כאשר פתרון איטרטיבי פשוט וברור יותר. לא רלוונטי עבורנו. ביקשו רקורסיה - יקבלו רקורסיה.

דוגמא 2/תרגול: מנה של חלוקה שלמה בחיסור חוזר : \(\lfloor (9/4) \rfloor = 2\)

רעיון: כמה פעמים ניתן לחסר את b מתוך a עד שהמספר קטן מ־b.

פתרון
1
2
3
4
5
6
7
public static int Div(int a, int b)
{
    if (a < b) 
        return 0;       // תנאי עצירה

    return 1 + Div(a - b, b);  // b חזרה עם חיסור 
}
מעקב בשיטת המלבנים
flowchart TD X["Div(9,4) (a < b? false) return 1 + Div(5,4)"] -->|קריאה רקורסיבית| Y["Div(5,4) (a < b? false) return 1 + Div(1,4)"] Y -->|קריאה רקורסיבית| Z["Div(1,4) (a < b? true) return 0"] Z -.->|חזרה: 0| Y Y -.->|חזרה: 1| X X -.->|תוצאה: 2| OUT2(("Div(9,4) = 2"))

שיטת המלבנים (סיכום קצר):

  • שורה עליונה: שם הפונקציה והארגומנטים שקיבלה.
  • שורה אמצעית: תנאי העצירה (כאן עד ההגעה לבסיס הוא false).
  • שורה שלישית: return עם הקריאה הרקורסיבית/הביטוי.
  • חץ מלא = הלוך (קריאה), חץ מקווקו = חזור (החזרת ערך).

לתרגול עצמי של מעקב (בפעם אחרת)

  • שרטט/י מעקב עבור Mul(3,2) ו־Div(10,3) והוסיפ/י תוויות על ערכי ההחזרה בכל שלב.
  • כמה קריאות/חזרות יש בכל אחד מהמקרים? הסבירו בקצרה כיצד נובע המספר מהפרמטרים.

תרגול מעקב - שאלת קמפוס

קישור למקור בקמפוס

עקבו אחר קטע הקוד הבא. מה יוחזר עבור הקריאה Mystery(3)?

1
2
3
4
5
6
7
8
9
10
11
12
13
public static int Mystery  (int n)
{
  if (n <= 0) // תנאי עצירה
      return 0;

  else
    {
      if (n % 3 == 0) // n הקריאות הרקורסיביות מקטינות את 
        return Mystery (n - 1) + n;
      else 
        return Mystery (n - 1);
    }
}
תרשים המלבנים של Mystery(3)
flowchart TD A["Mystery(3) (n <= 0? false) (n % 3 == 0? true) return Mystery(2) + 3"] -->|קריאה רקורסיבית| B["Mystery(2) (n <= 0? false) (n % 3 == 0? false) return Mystery(1)"] B -->|קריאה רקורסיבית| C["Mystery(1) (n <= 0? false) (n % 3 == 0? false) return Mystery(0)"] C -->|קריאה רקורסיבית| D["Mystery(0) (n <= 0? true) return 0"] D -.->|חזרה: 0| C C -.->|חזרה: 0| B B -.->|חזרה: 0| A A -.->|תוצאה: 3| OUT(("Mystery(3) = 3"))
נסו לעקוב מה יודפס עבור Mystery(10)
flowchart TD A["Mystery(10) (n <= 0? false) (n % 3 == 0? false) return Mystery(9)"] -->|קריאה רקורסיבית| B["Mystery(9) (n <= 0? false) (n % 3 == 0? true) return Mystery(8) + 9"] B -->|קריאה רקורסיבית| C["Mystery(8) (n <= 0? false) (n % 3 == 0? false) return Mystery(7)"] C -->|קריאה רקורסיבית| D["Mystery(7) (n <= 0? false) (n % 3 == 0? false) return Mystery(6)"] D -->|קריאה רקורסיבית| E["Mystery(6) (n <= 0? false) (n % 3 == 0? true) return Mystery(5) + 6"] E -->|קריאה רקורסיבית| F["Mystery(5) (n <= 0? false) (n % 3 == 0? false) return Mystery(4)"] F -->|קריאה רקורסיבית| G["Mystery(4) (n <= 0? false) (n % 3 == 0? false) return Mystery(3)"] G -->|קריאה רקורסיבית| H["Mystery(3) (n <= 0? false) (n % 3 == 0? true) return Mystery(2) + 3"] H -->|קריאה רקורסיבית| I["Mystery(2) (n <= 0? false) (n % 3 == 0? false) return Mystery(1)"] I -->|קריאה רקורסיבית| J["Mystery(1) (n <= 0? false) (n % 3 == 0? false) return Mystery(0)"] J -->|קריאה רקורסיבית| K["Mystery(0) (n <= 0? true) return 0"] %% חיצי החזרה K -.->|חזרה: 0| J J -.->|חזרה: 0| I I -.->|חזרה: 0| H H -.->|חזרה: 3| G G -.->|חזרה: 3| F F -.->|חזרה: 3| E E -.->|חזרה: 9| D D -.->|חזרה: 9| C C -.->|חזרה: 9| B B -.->|חזרה: 18| A A -.->|תוצאה: 18| OUT(("Mystery(10) = 18"))

סרטון על טכניקות לחשיבה רקורסיבית

השלבים של Reductible

  1. מה המקרה הפשוט ביותר? הוא ישמש כתנאי העצירה
  2. עבודה עם דוגמאות פשוטות, סמוכות למקרה הבסיס, ויזואליציה של הבעיה
  3. קישור בין בעיה גדולה לבעיות קטנות יותר. כיצד ניתן לפתור מקרה אם ידוע מקרה קטן יותר?
  4. הכללת הקשר שמצאנו
  5. כתיבת קוד: תנאי העצירה ואחריו הקריאה הרקורסיבית.
  6. עם הזמן מתגבשת חשיבה / אמונה, שאני (הפונקציה) רק צריכה לעשות את השלב שלי, והקריאה הרקורסיבית תחזיר תשובה נכונה.

רקורסיה מול איטרציה

בפעמים רבות ניתן לפתור בעיה הן בשיטה רקורסיבית והן בשיטה איטרטיבית. בשיטה רקורסיבית מקבלים קוד קריא ופשוט לעיתים, אך הוא עלול לבזבז זיכרון עקב שימור מצבי הקריאה במחסנית. אלגוריתם איטרטיבי משתמש בלולאות ויכול להיות יעיל יותר בזיכרון, במיוחד כאשר העומק הרקורסיבי גדול.

טבלה – השוואה בין רקורסיה לאיטרציה

מאפיין רקורסיה איטרציה
שימוש בזיכרון כל קריאה מוסיפה רשומת מחסנית לרוב שימוש קבוע בזיכרון
קריאות חוזרות קריאה חוזרת לפונקציה עצמה לולאה (for / while)
קריאות מקרה בסיס חיוני לעצירת הקריאות אין צורך במקרה בסיס
בהירות קוד לעיתים קריא יותר לעיתים נדרשים משתנים נוספים
דוגמא לרקורסיה במחרוזת: הפיכת מחרוזת
public static string StrReverse(string str)
{
   //null כדאי גם לבדוק 
   if (str.Length < 2) // תנאי עצירה: ריק או תו בודד
      return str;

   return StrReverse(str.Substring(1)) + str[0];
}
מעקב
flowchart TD A["StrReverse(abc) (len < 2? false) return StrReverse(bc) + a"] -->|קריאה רקורסיבית| B["StrReverse(bc) (len < 2? false) return StrReverse(c) + b"] B -->|קריאה רקורסיבית| C["StrReverse(c) (len < 2? true) return c"] C -.->|חזרה: c| B B -.->|חזרה: cb| A A -.->|תוצאה: cba| OUT(("StrReverse(abc) = cba"))
דוגמא לרקורסיה: הפיכת מספר - ללא שימוש במחרוזת. כיוון לפתרון

הפיכת מחרוזת - קשה יותר מהפיכת מספר. הטכניקה שעליכם לחשוב עליה היא שימוש במשתנה עזר, והעברה שלו בתוך הקריאה הרקורסיבית. חישבו על מצב שבו עובדים עם 2 מספרים:

  • המספר שאתם מחלקים ב-10
  • והתוצאה שאנחנו בונים: זו שכופלים ב-10.
public static Rev(int num, int result = 0)

שימוש: int rev = Rev(1234); (כלומר, בקריאה מבחוץ לא מתייחסים לפרמטר הנוסף והוא יתחיל כ-0)

בקריאה הראשונה נקרא ל-: Rev(123, 4)

בקריאה השניה נקרא ל: Rev(12, 43)

מכאן ניתן לחשוב גם על תנאי עצירה…

תרגול וקישורים

כדי לתרגל את הנושא נפתור הרבה שאלות רקורסיביות. תוכלו למצוא תרגילים במערכת ההגשות. <!– ⬅ עברו לתרגיל סכום ספרות במספר

בנוסף, נסו לכתוב פונקציה שמחשבת עצרת באופן רקורסיבי. התנסו גם בפתרון איטרטיבי והשוו ביניהם. \(5! = 1·2·3·4·5\) –>

קישור לתרגול בקמפוס

סרטונים

פלייליסט רקורסיה בקמפוס

מעקב באמצעות עץ מעקב. לא מומלץ