מבני נתונים — אוניברסיטת רייכמן

הרצאה 5 — QuickSort & Lower Bounds

QuickSort עם ניתוח Average מלא, משפט Master, וחסם תחתון \(\Omega(n \log n)\) למיון מבוסס השוואות

תוכן עניינים

  1. 0. תמונה גדולה — למה צריך עוד מיון?
  2. 1. QuickSort — האלגוריתם הרקורסיבי
  3. 2. Partition — הלב של QuickSort
  4. 3. ניתוח Best Case + סיבת המאזן
  5. 4. ניתוח Worst Case + מתי הוא קורה
  6. 5. ניתוח Average Case — הוכחה רשמית מלאה
  7. 6. תכונות QuickSort (in-place, יציבות, מקום)
  8. 7. אופטימיזציה מעשית — SimpleSort
  9. 8. משפט Master + דוגמאות לכל מקרה
  10. 9. חסם תחתון למיון מבוסס השוואות
  11. 10. בונוס — תובנות נוספות שצריך לדעת
  12. 11. סיכום וטבלת השוואה

0. תמונה גדולה — למה צריך עוד מיון?

עד עכשיו הכרנו: InsertionSort (\(\Theta(n^2)\)), HeapSort (\(\Theta(n \log n)\)) ו-MergeSort (\(\Theta(n \log n)\)).

אז למה QuickSort?
  • HeapSort מהיר אסימפטוטית, אבל לא יציב, ובפועל יש לו קבועים גדולים בגלל ה-percolate-down.
  • MergeSort יציב ו-\(\Theta(n \log n)\) אפילו ב-worst case — אבל דורש \(\Theta(n)\) מקום עזר. זה בעיה במערכים ענקיים.
  • QuickSort — בממוצע \(\Theta(n \log n)\), in-place (\(\Theta(\log n)\) מקום עזר בלבד), קבוע קטן בפועל, ובפרקטיקה המהיר ביותר ברוב המקרים. החיסרון: worst case \(\Theta(n^2)\), אבל זה נדיר.
מסקנת ההרצאה
QuickSort הוא ה-default sort של רוב השפות (Java's Arrays.sort לפרימיטיביים, C's qsort, Python's TimSort הוא היברידי). ההרצאה גם מוכיחה למה אי אפשר לסדר מהר מ-\(\Theta(n \log n)\) אם מותר רק להשוות איברים — חסם תחתון יסודי בתורת האלגוריתמים.

1. QuickSort — האלגוריתם הרקורסיבי

אלגוריתם Divide-and-Conquer. השוני המרכזי מ-MergeSort: כל העבודה נעשית ב-Divide, וה-Combine ריק לחלוטין.

שלבי האלגוריתם
  • Divide: בוחרים איבר מהמערך — ה-pivot. מסדרים מחדש כך ש: איברים \(\leq\) pivot משמאל, איברים \(>\) pivot מימין. ה-pivot עצמו "במקומו הסופי" במערך הממוין.
  • Conquer: קוראים רקורסיבית על שני תת-המערכים.
  • Combine: שום דבר! המערך כבר ממוין במקום. זה היתרון הגדול על MergeSort.
  • Base case: תת-מערך באורך 0 או 1 — ממוין טריוויאלית.

Pseudocode

Quicksort(A, p, r):
  if r − p > 0 then                         // יותר מאיבר אחד
    q ← Partition(A, p, r)                   // q = המיקום של ה-pivot אחרי החלוקה
    Quicksort(A, p, q − 1)                   // ממיין את הצד השמאלי
    Quicksort(A, q + 1, r)                   // ממיין את הצד הימני
  Return

// קריאה ראשונית:
Quicksort(A, 1, length(A))
למה אין Combine?
כי אחרי Partition כל איבר משמאל \(\leq\) pivot, וכל איבר מימין \(>\) pivot. אם נמיין את שני הצדדים — המערך הסופי ממוין באופן אוטומטי. ב-MergeSort, לעומת זאת, אחרי שני המיונים הרקורסיביים יש שני תת-מערכים ממוינים, אבל לא ידוע איזה איבר משמאל קטן מאיזה איבר מימין — לכן צריך Merge.
דוגמה מהירה: [2, 8, 7, 1, 3, 5, 6, 4]
נבחר pivot = 4 (האחרון). אחרי Partition: [2, 1, 3, 4, 7, 5, 6, 8] ה-4 במקום ה-4 (אינדקס q=4). עכשיו נקרא רקורסיבית:
  • שמאל: Quicksort על [2, 1, 3]
  • ימין: Quicksort על [7, 5, 6, 8]
ה-4 לא מטופל שוב — הוא נעול במקומו.

2. Partition — הלב של QuickSort

זה השלב היחיד שעושה עבודה אמיתית. רוב הטעויות במבחנים קורות כאן — שווה להבין לעומק.

הרעיון: 3 אזורים במערך

חלוקה לוגית של המערך תוך כדי ריצה
עבור Partition(A, l, r): בוחרים x = A[r] (האיבר האחרון) כ-pivot. מנהלים שני מצביעים i, j:
  • אזור "Less than pivot": A[l..i] — כל איברים \(\leq x\).
  • אזור "Greater than pivot": A[i+1..j−1] — כל איברים \(> x\).
  • אזור "Waiting": A[j..r−1] — עוד לא בדוק.
  • A[r] = ה-pivot עצמו.

Pseudocode

Partition(A, l, r):
  x = A[r]                  // pivot = האיבר האחרון
  i = l − 1                 // i מצביע על סוף האזור הקטן
  for j = l to r − 1:
    if A[j] ≤ x:
      i = i + 1
      exchange A[i] with A[j]
  exchange A[i+1] with A[r] // מעבירים את ה-pivot למקומו הסופי
  return i + 1              // מיקום ה-pivot
דוגמה מלאה (פלט אחרי כל איטרציה): [2, 8, 7, 1, 3, 5, 6, 4], pivot=4
jA[j]≤4?i (אחרי)המערך
0[2, 8, 7, 1, 3, 5, 6, 4]
12כן1[2, 8, 7, 1, 3, 5, 6, 4]
28לא1[2, 8, 7, 1, 3, 5, 6, 4]
37לא1[2, 8, 7, 1, 3, 5, 6, 4]
41כן2[2, 1, 7, 8, 3, 5, 6, 4]
53כן3[2, 1, 3, 8, 7, 5, 6, 4]
65לא3[2, 1, 3, 8, 7, 5, 6, 4]
76לא3[2, 1, 3, 8, 7, 5, 6, 4]
סוף הלולאה: swap בין A[i+1]=A[4]=8 ל-A[r]=A[8]=4: [2, 1, 3, 4, 7, 5, 6, 8]. החזרה: \(i+1 = 4\).

שים לב: 4 במקום ה-4. שמאל = {2,1,3} (כולם \(\leq 4\)). ימין = {7,5,6,8} (כולם \(>4\)). ✓

משפט (אינווריאנט הלולאה)
לכל j, אחרי האיטרציה ה-j:
  • A[l..i] \leq x
  • A[i+1..j] > x
  • A[j+1..r−1] עוד לא בדוק
  • A[r] = x (pivot)
ניתן להוכיח באינדוקציה על j. ההוכחה נדרשת במבחנים לעיתים.

סיבוכיות של Partition

הלולאה רצה מ-l ל-r-1, כלומר עוברים על \(n\) איברים (כאשר \(n = r-l+1\)). כל איטרציה: \(O(1)\). סה"כ: \(\Theta(n)\).

3. ניתוח Best Case

"Best case" של QuickSort הוא כאשר ה-pivot בכל קריאה הוא החציון של תת-המערך — חלוקה מאוזנת לחלוטין \(n/2 : n/2\).

רקורסיה במקרה הטוב
\[T(n) = 2T(n/2) + \Theta(n)\] לפי משפט Master (מקרה 2): \(T(n) = \Theta(n \log n)\).
למה זה כמו MergeSort?
כי המבנה הרקורסיבי זהה: \(2T(n/2) + \Theta(n)\). ההבדל הוא איפה נעשית העבודה הלינארית: ב-MergeSort ב-Merge (אחרי הקריאות), ב-QuickSort ב-Partition (לפני).

למה זה "Best" ולא "Average"?

הבחנה חשובה
הביטוי "best case" קצת מטעה: כל חלוקה קבועה (לא בהכרח חציון, אפילו 1%:99%) נותנת בסופו של דבר \(\Theta(n \log n)\)! זה כי גם 99%·1% מוביל לעץ רקורסיה בעומק \(\log_{100/99} n = \Theta(\log n)\). הגרוע באמת זה רק כש-pivot הוא קצה אחד (1:n−1).

4. ניתוח Worst Case

קורה כשבכל קריאה ה-pivot הוא הקצה — חלוקה \(0 : n-1\). שום שאלה רקורסיבית לא מתבצעת בצד השמאלי (ריק), והצד הימני בגודל \(n-1\).

רקורסיה במקרה הגרוע
\[T(n) = T(n-1) + \Theta(n) = \Theta(n) + \Theta(n-1) + \ldots + \Theta(1) = \Theta(n^2)\] (טור אריתמטי).

מתי זה קורה בפועל?

פתרון מעשי
כדי להימנע מ-\(O(n^2)\) על מערכים ממוינים (שכיחים מאוד!): בוחרים pivot אקראי, או בשיטה median-of-three (חציון של A[l], A[r], A[(l+r)/2]). זה לא משנה את ה-worst case אסימפטוטית — אבל הופך אותו לסבירות אסטרונומית.

5. ניתוח Average Case — הוכחה רשמית מלאה

זה החלק הקשה בהרצאה — לעיתים קרובות מבקשים אותו במבחן. שווה ללמוד את ההצבות.

משפט
ההנחה: כל פרמוטציה של הקלט שכיחה באותה הסתברות. אז: \[T_{\text{avg}}(n) = O(n \log n)\]

שלב 1 — נסחים את הרקורסיה הממוצעת

אם ה-pivot הוא האיבר ה-p בגודלו (rank p), אזי החלוקה היא \(p-1\) משמאל ו-\(n-p\) מימין. p אקראי אחיד מ-1 ל-n:

\[T_{\text{avg}}(n) = \Theta(n) + \frac{1}{n}\sum_{p=1}^{n} \bigl[T_{\text{avg}}(p-1) + T_{\text{avg}}(n-p)\bigr]\] לפי סימטריה (כל סכום מופיע פעמיים): \[T_{\text{avg}}(n) = \Theta(n) + \frac{2}{n}\sum_{k=0}^{n-1} T_{\text{avg}}(k)\]

שלב 2 — הצבת ההמרה (חוכמה מתמטית)

נגדיר \(S(n) = \dfrac{T_{\text{avg}}(n)}{n+1}\). אז \(T_{\text{avg}}(n) = (n+1)S(n)\). הצבה ב-recurrence ופישוט נותנת:

\[S(n) \leq S(n-1) + \frac{2d}{n+1}\] כאשר \(d\) קבוע (מהמקדם של \(\Theta(n)\)).

שלב 3 — פותרים את S(n) הלינאריזציה

שורה אחר שורה:

\[S(n) \leq S(0) + 2d \sum_{k=1}^{n} \frac{1}{k+1} \leq 2d \cdot H_n\] כאשר \(H_n = 1 + \tfrac{1}{2} + \tfrac{1}{3} + \ldots + \tfrac{1}{n}\) הוא המספר ההרמוני ה-n-י.

ידוע ש- \(H_n = \ln n + \gamma + O(1/n) = O(\log n)\).

לכן: \(S(n) = O(\log n)\), ובהמשך: \[T_{\text{avg}}(n) = (n+1) \cdot S(n) = (n+1) \cdot O(\log n) = O(n \log n). \quad \blacksquare\]
💡 בונוס — למה ההמרה ל-S(n) עובדת?
הרעיון: ה-recurrence המקורי כולל סכום של תוצאות קודמות. אם נחלק ב-\(n+1\) (בערך מספר ה-recursive calls), נקבל סדרה שמסכימה לטור הרמוני — וזה טור גדל לאט מאוד. הטריק נקרא telescoping: כל \(S(n)\) מבטא את ההפרש בין מצבים, ומחק את האיברים האמצעיים.

אותו טריק עובד גם בניתוח QuickSelect (הרצאה 6)!

6. תכונות QuickSort

תכונהQuickSortהסבר
In-place?כן ✓Partition מבצע swap-im במקום, ללא מערך עזר.
מקום עזר ממוצע\(\Theta(\log n)\)עומק מחסנית הרקורסיה.
מקום עזר Worst-case\(\Theta(n)\)עומק רקורסיה במקרה הגרוע = \(n\). פתרון: tail recursion על הצד הקטן.
Stable?לא ✗ה-swap-im בתוך Partition משנים סדר של איברים שווים.
גרסה איטרטיבית?אין טבעיתבלי רקורסיה צריך מחסנית מפורשת.
מקבילי?טבעי ✓שני תת-המערכים בלתי תלויים — אפשר למיין בו-זמנית בליבות שונות.
💡 בונוס — למה Stable חשוב?
כשממיינים אובייקטים עם מספר שדות (לדוגמה: שם + ציון), stable sort מאפשר מיון רב-שלבי. נמיין קודם לפי שם, ואז לפי ציון — והאיברים שווי-ציון יישארו ממוינים לפי שם. QuickSort לא יציב, אז הוא לא מתאים לסיטואציה הזו — אלא MergeSort/CountingSort.

7. אופטימיזציה מעשית — SimpleSort

בפועל, QuickSort נתקל בתופעה מעניינת: בתת-מערכים קטנים מאוד (10–20 איברים), ה-overhead של הרקורסיה גדול יותר מ-\(n^2\) של InsertionSort.

השיפור
Quicksort(A, p, r):
  if r − p ≤ 20:               // סף קטן
    SimpleSort(A, p, r)        // לרוב InsertionSort
  else
    q ← Partition(A, p, r)
    Quicksort(A, p, q − 1)
    Quicksort(A, q + 1, r)

הסיבוכיות האסימפטוטית לא משתנה — נשארה \(\Theta(n \log n)\) — אבל הקבועים בפועל קטנים יותר משמעותית. std::sort ב-C++ ו-Java's Arrays.sort משתמשים בטריק הזה.

8. משפט Master

כלי כללי לפתרון נוסחאות רקורסיביות מהצורה הסטנדרטית — מאוד שכיח במבחנים.

משפט Master
עבור \(T(n) = a \cdot T(n/b) + \Theta(n^k)\) כאשר \(a \geq 1, b > 1, k \geq 0\):
  1. אם \(a > b^k\): \(T(n) = \Theta(n^{\log_b a})\)
  2. אם \(a = b^k\): \(T(n) = \Theta(n^k \log n)\)
  3. אם \(a < b^k\): \(T(n) = \Theta(n^k)\)

כיצד לזכור את שלושת המקרים?

אינטואיציה — איפה רוב העבודה?
  • מקרה 1 (\(a > b^k\)): רוב העבודה בעלים של עץ הרקורסיה — הרבה תתי-בעיות. הסיבוכיות נשלטת על-ידי מספר העלים.
  • מקרה 2 (\(a = b^k\)): איזון — כל רמה תורמת אותו דבר. סך הכל = רמה אחת × מספר הרמות = \(n^k \cdot \log n\).
  • מקרה 3 (\(a < b^k\)): רוב העבודה בשורש — הצמצום מהיר מהריבוי.

דוגמאות לכל מקרה

מקרה 1: \(T(n) = 4T(n/2) + n\)
\(a=4, b=2, k=1\). \(b^k = 2 < a = 4\) ⟹ מקרה 1. \(\log_b a = \log_2 4 = 2\). ⟹ \(T(n) = \Theta(n^2)\).
מקרה 2: \(T(n) = 2T(n/2) + n\)
(זה MergeSort/QuickSort בייסט-קייס). \(a=2, b=2, k=1\). \(b^k = 2 = a\) ⟹ מקרה 2. ⟹ \(T(n) = \Theta(n \log n)\). ✓
מקרה 3: \(T(n) = T(n/2) + n\)
(זה Binary Search על דברים שצריך לבדוק לינארית). \(a=1, b=2, k=1\). \(b^k = 2 > a = 1\) ⟹ מקרה 3. ⟹ \(T(n) = \Theta(n)\).
מתי המשפט לא חל?
  • אם החלוקה לא שווה: \(T(n) = T(n-1) + n\) — אין \(n/b\). פותרים בעץ רקורסיה ידני.
  • אם החלק הלא-רקורסיבי לא פולינומי: \(T(n) = 2T(n/2) + n \log n\) — צריך גרסה מורחבת של המשפט.

9. חסם תחתון למיון מבוסס השוואות

השאלה הגדולה: האם ניתן למיין מהר מ-\(\Theta(n \log n)\)? התשובה תלויה במודל החישוב.

מודל ההשוואות
אלגוריתם מיון "מבוסס השוואות" יכול לגשת לאיברים רק דרך השוואות (\(<, =, >\)) ולהעתקות. לא יכול להשתמש בערך כאינדקס, בייצוג הבינארי, וכו'.

InsertionSort, HeapSort, MergeSort, QuickSort — כולם מבוססי השוואות. CountingSort (הרצאה 6) — לא.

הכלי: עץ ההחלטה

Decision Tree
  • כל קודקוד פנימי = השוואה בין שני איברים (לדוגמה: "האם \(a_2 < a_5\)?").
  • שני בנים = "כן" / "לא".
  • כל עלה = פרמוטציה אפשרית של הקלט.
הריצה של האלגוריתם על קלט מסוים = מסלול מהשורש לעלה כלשהו. מספר ההשוואות = עומק העלה.

הוכחת \(\Omega(n \log n)\) — Worst Case

משפט
כל אלגוריתם מיון מבוסס השוואות מבצע \(\Omega(n \log n)\) השוואות במקרה הגרוע.

הוכחה

  1. קלט של \(n\) איברים מובחנים → יש \(n!\) פרמוטציות אפשריות. עץ ההחלטה חייב להכיל לפחות \(n!\) עלים (אחד לכל פרמוטציה — חייב להבחין ביניהם).
  2. עץ בינארי בעומק \(h\) יכול לכלול לכל היותר \(2^h\) עלים. לכן: \(2^h \geq n! \;\Rightarrow\; h \geq \log_2(n!)\).
  3. נוסחת סטירלינג: \(\log(n!) = n \log n - n + O(\log n) = \Theta(n \log n)\).
  4. זמן הריצה במקרה הגרוע = עומק העץ = מספר ההשוואות במסלול הארוך ביותר ≥ \(\log_2(n!) = \Omega(n \log n)\). ∎

הוכחה ש-\(\log(n!) = \Theta(n \log n)\)

חישוב יסודי שהמרצה מצפה לראות במבחן

חצי תחתון:

\[\log(n!) = \log 1 + \log 2 + \ldots + \log n \geq \sum_{k=n/2+1}^{n} \log k \geq \frac{n}{2}\log\frac{n}{2} = \Omega(n \log n)\]

חצי עליון:

\[\log(n!) \leq \sum_{k=1}^{n} \log n = n \log n = O(n \log n)\] לכן \(\log(n!) = \Theta(n \log n)\).

חסם תחתון גם ב-Average

החסם תקף גם במקרה הממוצע: עומק ממוצע של עלים בעץ בינארי full עם \(k\) עלים הוא לפחות \(\log k - O(1)\). מינימום מתקבל בעץ מאוזן.

מסקנה חשובה
HeapSort, MergeSort ו-QuickSort הם אופטימליים אסימפטוטית במודל מיון מבוסס השוואות. אי אפשר לבנות אלגוריתם מבוסס-השוואות שטוב מהם.

מי שרוצה שיפור (לינארי) חייב לוותר על מודל ההשוואות — מה שמוביל ל-CountingSort, BucketSort, RadixSort בהרצאה הבאה.

10. בונוס — תובנות נוספות שצריך לדעת

💡 1. למה דווקא pivot אחרון?
הבחירה של A[r] שרירותית. אפשר גם A[l] או אקראי. שלוש אופציות פופולריות:
  • Last element: פשוט, אבל פגיע למערכים ממוינים.
  • Random: מבטיח average \(\Theta(n \log n)\) על כל קלט (האקראיות בבחירת ה-pivot, לא בקלט).
  • Median-of-three: חציון של A[l], A[r], A[(l+r)/2] — מאזן יותר טוב בפועל, במיוחד על נתונים מסודרים-חלקית.
💡 2. Dutch National Flag — Partition משופר
כשיש הרבה כפילויות, פיצול ל-3 אזורים: \(<\) pivot, \(=\) pivot, \(>\) pivot. מבטיח שגם פיצולים גרועים לא מובילים ל-\(O(n^2)\) כשיש איברים שווים. שיטה זו נמצאת ב-Java's Arrays.sort.
💡 3. הקשר ל-QuickSelect
ניתן להשתמש ב-Partition כדי לפתור Selection (מציאת איבר ה-k בגודלו) ב-\(\Theta(n)\) ממוצע! זה QuickSelect — נלמד אותו בהרצאה הבאה. כל QuickSort הוא בעצם n recursive applications של QuickSelect.
💡 4. למה לא כל מערכת משתמשת ב-QuickSort?
  • Linux kernel משתמש ב-HeapSort — כי אסור לאפשר ל-attacker לבחור קלט שגורם ל-\(O(n^2)\) ולתקוף את המכונה (DoS).
  • Python משתמש ב-TimSort — היברידי MergeSort+InsertionSort, יציב, חזק על נתונים חצי-מסודרים שכיחים בפרקטיקה.
  • Java משתמש ב-DualPivot QuickSort (Yaroslavskiy 2009) — גרסה משופרת.
💡 5. הקשר בין QuickSort ל-BST
הרצף של החלוקות ב-QuickSort מקביל לבנייה של עץ חיפוש בינארי (BST) רנדומלי: כל pivot הופך לשורש של תת-עץ. זה אומר שאותה אנליזה ממוצעת תופסת גם ל-BST! הסיבה: גובה ממוצע של BST רנדומלי = \(\Theta(\log n)\).

11. סיכום וטבלת השוואה

HeapSortMergeSortQuickSort
Worst-case\(\Theta(n \log n)\)\(\Theta(n \log n)\)\(\Theta(n^2)\)
Best/Average\(\Theta(n \log n)\)\(\Theta(n \log n)\)\(\Theta(n \log n)\)
In-placeכןלא (\(\Theta(n)\) עזר)כן (\(\Theta(\log n)\) עזר ממוצע)
Stableלאכןלא
בפועל מהיר?בינוניטובהכי טוב
מה הכי חשוב לזכור?
  1. QuickSort: Partition + 2 recursive calls. Best/Avg \(\Theta(n \log n)\), Worst \(\Theta(n^2)\), in-place, לא יציב.
  2. ניתוח Average: הצבה \(S(n) = T(n)/(n+1)\) ⟹ \(S(n) \leq 2d \cdot H_n = O(\log n)\) ⟹ \(T(n) = O(n \log n)\).
  3. משפט Master: \(T(n) = aT(n/b) + \Theta(n^k)\) — 3 מקרים לפי \(a\) מול \(b^k\).
  4. Lower bound: כל מיון מבוסס השוואות חייב \(\Omega(n \log n)\) (worst & average). הוכחה דרך עץ החלטה: \(2^h \geq n! \Rightarrow h \geq \log(n!) = \Omega(n \log n)\).

ההרצאה הבאה: איך לעקוף את החסם של \(\Omega(n \log n)\) — מיון לינארי (CountingSort, BucketSort, RadixSort) ובעיית הסלקציה.