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

תרגול 6 — Linear-time Sorting

CountingSort, RadixSort, BucketSort, ובסיסים מספריים — תרגילים ופתרונות

תוכן עניינים

  1. 0. תזכורת מהירה
  2. 1. בסיסים מספריים — Decimal, Binary, ומה שביניהם
  3. 2. חישוב ספרות של מספר בבסיס b
  4. תרגיל 1: מיון מערך ב-\([0, n^3 - 1]\) — איזה אלגוריתם?
  5. תרגיל 2: Bucket Sort עם שתי התפלגויות
  6. תרגיל 3: \(k\) האיברים הקרובים לחציון ב-\(O(n)\)
  7. תרגיל 4: מבנה נתונים מיוחד למולטיסט
  8. 5. טיפים לקראת המבחן

0. תזכורת מהירה

מתי משתמשים במיון לינארי?
  • אלגוריתמי מיון מבוססי השוואות (Heap/Merge/Quick) הם הטובים ביותר כשאין מידע מוקדם על המערך — \(\Omega(n \log n)\).
  • אם יש מידע (טווח שלמים, התפלגות אחידה, מספר ספרות קבוע) — ניתן לעיתים \(\Theta(n)\).
  • חסם הטריוויאלי: כל מיון חייב \(\Omega(n)\) (חייב לקרוא את כל הקלט).

שלושת אלגוריתמי המיון הלינאריים

תנאי שימושWorstAverageStable
Countingשלמים ב-{1..k}\(\Theta(n + k)\)\(\Theta(n + k)\)כן
Bucketאחיד ב-[0, 1)\(\Theta(n^2)\)\(\Theta(n)\)תלוי במיון פנימי
Radixd ספרות, stable per digit\(\Theta(d(n + k))\)\(\Theta(d(n + k))\)כן (אם הפנימי יציב)

CountingSort — Pseudocode מ-CLRS

CountingSort(A, n, k):
  let B[1:n] and C[0:k] be new arrays
  for i = 0 to k:
    C[i] = 0
  for j = 1 to n:
    C[A[j]] = C[A[j]] + 1       // C[i] = #elements equal to i
  for i = 1 to k:
    C[i] = C[i] + C[i − 1]      // C[i] = #elements ≤ i
  // Copy A to B, starting from end (לשמירת יציבות):
  for j = n downto 1:
    B[C[A[j]]] = A[j]
    C[A[j]] = C[A[j]] − 1
  return B

סיבוכיות: שני לולאות של \(\Theta(k)\) + שני לולאות של \(\Theta(n)\) = \(\Theta(n + k)\). אם \(k = O(n)\): \(\Theta(n)\).

RadixSort — Pseudocode

RadixSort(A, n, d):
  for i = 1 to d:
    use a stable linear sort (e.g., CountingSort) on A by digit i

סיבוכיות: \(\Theta(d(n + k))\) כאשר \(k\) = הבסיס של הספרה.

1. בסיסים מספריים — Decimal, Binary, ומה שביניהם

הבנה של בסיסים קריטית לפתרון שאלות Radix Sort במבחן. הספרות הן כלי, לא מטרה.

הרעיון
ספרות מציגות מספר כסכום של כפולות של חזקות.

במספרים עשרוניים (בסיס 10):

\[1234 = 1 \cdot 10^3 + 2 \cdot 10^2 + 3 \cdot 10^1 + 4 \cdot 10^0\]

בבינארי (בסיס 2):

\[10011010010_2 = 1 \cdot 2^{10} + 0 \cdot 2^9 + \ldots + 1 \cdot 2^1 + 0 \cdot 2^0 = 1234\]

בסיסים פופולריים

2. חישוב ספרות של מספר \(x > 0\) בבסיס \(b\)

הרעיון: Modulo + Division

השיטה
digits = array of size ⌈log_b x⌉, initialized to zeros
i = 0
while x > 0:
  digits[i] = x mod b
  x = ⌊x / b⌋
  i = i + 1

זמן ריצה: \(O(\log_b x)\) — מספר הספרות.

דוגמה: 1234 בבסיס 2

חישוב
xx mod 2 (=digit)⌊x/2⌋ (=next x)
12340617
6171308
3080154
154077
77138
38019
1919
914
402
201
110

קוראים את הספרות מלמעלה למטה (LSB ראשון): 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1

קוראים מ-MSB לכיוון LSB: 1234₁₀ = 10011010010₂

בדיקה: \(2^{10} + 2^7 + 2^6 + 2^4 + 2^1 = 1024 + 128 + 64 + 16 + 2 = 1234\) ✓

תרגיל 1: מיון מערך ב-\([0, n^3 - 1]\) — איזה אלגוריתם?

השאלה
נתון מערך של \(n\) שלמים בטווח \([0, n^3 - 1]\). כל שלם נשמר ב-word יחיד (מודל Word-RAM).

הצע שיטה יעילה למיון, ונתח worst-case של:

  • אלגוריתם מבוסס השוואות (כמו QuickSort/MergeSort)
  • Counting Sort
  • Radix Sort
ניתוח של כל אופציה

אופציה 1: מיון מבוסס השוואות

\(\Omega(n \log n)\) (חסם תחתון מתאים גם ל-worst-case).

אופציה 2: Counting Sort

הטווח \(k = n^3\). סיבוכיות: \(\Theta(n + k) = \Theta(n + n^3) = \Theta(n^3)\). גרוע מאוד! מקום הזיכרון גם \(\Theta(n^3)\) — לא ריאלי.

אופציה 3: Radix Sort בבסיס 10

מספר ספרות: \(d = \log_{10}(n^3) = 3 \log_{10} n\). אז:

\[\Theta(d \cdot (n + k)) = \Theta(\log_{10} n \cdot (n + 10)) = \Theta(n \log n)\]

זה רק כמו מיון רגיל — לא משתלם!

אופציה 4 (המנצחת): Radix Sort בבסיס \(n\)

אם נשתמש בבסיס \(k = n\):

  • כל מספר ב-\([0, n^3 - 1]\) הוא בעל \(d = \log_n(n^3) = 3\) ספרות (קבוע!).
  • חישוב הספרות לכל מספר: \(O(3) = O(1)\). לכל המערך: \(O(n)\).
  • כל ספרה ב-\([0, n - 1]\) → Counting Sort בה הוא \(\Theta(n + n) = \Theta(n)\).
  • סך הכל: \(\Theta(d \cdot (n + k)) = \Theta(3 \cdot 2n) = \Theta(n)\). לינארי! 🎉
איך מבצעים מבחינה טכנית?

נניח \(n\) חזקה של 2 (אחרת לעבוד עם החזקה הקרובה). אז ניצול הייצוג הבינארי של המספרים:

  • כל מספר מיוצג כ-\(\log_2(n^3) = 3 \log_2 n\) ביטים.
  • נחלק את הביטים ל-3 בלוקים של \(\log_2 n\) ביטים — כל בלוק מייצג ספרה בבסיס \(n\).
  • בדוגמה: \(n = 1024 = 2^{10}\), מספרים ב-\([0, 2^{30}]\), כל מספר 30 ביטים, מחולקים ל-3 בלוקים של 10 ביטים כל אחד.

מיון לפי כל בלוק = Counting Sort על n=1024 ערכים אפשריים = \(\Theta(n)\).

💡 התובנה הגדולה
בחירת הבסיס משנה הכל! Radix Sort הוא לא רק על "ספרות עשרוניות". בחירת בסיס נכון יכולה להפוך אלגוריתם של \(\Theta(n \log n)\) לאלגוריתם של \(\Theta(n)\).

הכלל הזהוב: בחר את הבסיס \(b\) כך ש:

  • הטווח של המספרים \([0, b^d]\) עם \(d\) קבוע. אז \(d = O(1)\).
  • \(b = O(n)\), כדי שכל ספרה תהיה ניתנת למיון ב-Counting Sort ב-\(\Theta(n)\).
הכלל המעשי: אם הטווח של המספרים הוא \([0, n^c]\) לחזקה קבועה \(c\), הבחירה \(b = n\) נותנת \(\Theta(n)\).

תרגיל 2: Bucket Sort עם שתי התפלגויות

השאלה
נתונים \(n\) מספרים ממשיים:
  • חצי מהם מתפלגים אחיד ב-\([0, 1)\).
  • החצי האחר מתפלגים אחיד ב-\([1, 5)\).
איך להשתמש ב-Bucket Sort כדי למיין אותם ביעילות מקסימלית?
פתרון

הבעיה: אם נשתמש ב-\(n\) דליים שווי-גודל על \([0, 5)\) (כל דלי בגודל \(5/n\)):

  • חצי מהאיברים נופלים ב-\([0, 1)\) — שזה \(n/5\) דליים בלבד.
  • תוחלת מספר איברים בדלי בחלק זה: \(\frac{n/2}{n/5} = 2.5\). יוצא משוקלל לא טוב.

הרעיון: דליים לא שווי-גודל

נחלק את \([0, 5)\) ל-\(n\) דליים — אבל כל חצי מקבל \(n/2\) דליים שווי-גודל פנימה:

  • \(n/2\) דליים בגודל \(2/n\) על \([0, 1)\): הדלי \(i\) הוא \(\left[\frac{2i}{n}, \frac{2(i+1)}{n}\right)\) (לא! פשוט יותר):
    • תיקון: \(n/2\) דליים בגודל \(1/(n/2) = 2/n\)... רגע, יש פה בלגן.

פתרון מדויק (לפי התרגול)

נחלק את \([0, 5)\) ל-\(n\) דליים לא שווים:

  • עבור \(0 \leq i < n/2\): דלי \(i\) על \(\left[\frac{2i}{n}, \frac{2(i+1)}{n}\right)\) — \(n/2\) דליים שווי-גודל ב-\([0, 1)\).
  • עבור \(n/2 \leq i < n\): דלי \(i\) על \(\left[1 + \frac{8(i - n/2)}{n}, 1 + \frac{8(i - n/2 + 1)}{n}\right)\) — \(n/2\) דליים שווי-גודל ב-\([1, 5)\) (גודל 8/n כל אחד).

עכשיו בשני החלקים המפתחות מתפלגים אחיד בתוך הדליים. תוחלת איברים בדלי בכל חלק:

\[\frac{n/2}{n/2} = 1\]

תוצאה: התפלגות אחידה ב-buckets ⟹ Bucket Sort רץ ב-\(\Theta(n)\) ממוצע.

💡 התובנה
Bucket Sort דורש לא הנחה של "התפלגות אחידה על כל הטווח" אלא "כמות צפויה דומה בכל דלי". אם הקלט בא משתי התפלגויות שונות, מתאימים את הדליים — לא הולכים שניהם מאותו גודל.

הרעיון מתרחב: גם להתפלגויות לא-אחידות מורכבות. למשל אם הקלט בא ממכפלת התפלגות עם פונקצית פיצוץ ידועה — בונים דליים לפי המקבלות של הפיצוץ.

תרגיל 3: \(k\) האיברים הקרובים לחציון ב-\(O(n)\)

השאלה
תאר אלגוריתם בזמן \(O(n)\) המקבל כקלט מערך \(A\) של \(n\) מספרים מובחנים ושלם חיובי \(k \leq n\) (נניח \(n\) אי-זוגי).

האלגוריתם מחזיר את \(k\) האיברים ב-\(A\) הקרובים ביותר לחציון של \(A\). המרחק בין שני מספרים \(x, y\) מוגדר \(|x - y|\).

פתרון
  1. מצא את החציון: השתמש ב-MM-Select (Median of Medians) על \(A\) ב-\(O(n)\). תקרא לחציון \(m\).
  2. חשב מרחקים: עבור על \(A\), בנה מערך חדש \(D\) של גודל \(n\): \(D[i] = |A[i] - m|\). זמן: \(\Theta(n)\).
  3. מצא את ה-k-th order stat ב-D: השתמש שוב ב-MM-Select על \(D\) למציאת \(d_k\) = האיבר הקטן ה-k בגודלו ב-\(D\). זמן: \(O(n)\).
  4. הפק את התוצאה: עבור על \(A\) שוב, החזר כל \(A[i]\) שבו \(|A[i] - m| \leq d_k\). זמן: \(\Theta(n)\).

סיבוכיות כוללת: \(O(n) + \Theta(n) + O(n) + \Theta(n) = O(n)\). ✓

💡 תבנית פתרון: "מצא k איברים שמקיימים תכונה X" בזמן O(n)
כשמבקשים "מצא k איברים" בזמן \(O(n)\), השלבים תמיד דומים:
  1. הגדר ערך עזר לכל איבר (כאן: מרחק לחציון).
  2. מצא את הסף — האיבר ה-k בגודלו של ערך העזר — בזמן \(O(n)\) באמצעות Selection.
  3. סנן את האיברים שמתחת לסף ב-\(O(n)\).
הסוד: אסור למיין הכל (זה \(\Omega(n \log n)\)). שימוש ב-Select הוא הכרחי.

תרגיל 4: מבנה נתונים מיוחד למולטיסט

השאלה
עצב מבנה נתונים שמייצג multiset של \(n\) מספרים תחת הפעולות הבאות:
  • Build: \(\Theta(n)\)
  • Return median: \(\Theta(1)\)
  • Delete median: \(\Theta(\log n)\)
פתרון — שני heaps

הרעיון

נשמור שני heaps:

  • Max-Heap \(H_1\): \(\lceil n/2 \rceil\) הקטנים ביותר (השורש = הגדול ביותר מהקטנים → חציון).
  • Min-Heap \(H_2\): \(\lfloor n/2 \rfloor\) הגדולים ביותר (השורש = הקטן ביותר מהגדולים).

אינווריאנט: כל איבר ב-\(H_1\) \(\leq\) כל איבר ב-\(H_2\).

Build (\(\Theta(n)\))

  1. מצא את החציון בשיטת MM-Select: \(\Theta(n)\).
  2. בצע partition סביב החציון (כמו ב-QuickSort): \(\Theta(n)\). שתי חצאים: קטנים-שווים לחציון, וגדולים-שווים.
  3. בנה Max-Heap על החצי הקטן: \(\Theta(n/2) = \Theta(n)\) (BuildHeap לינארי).
  4. בנה Min-Heap על החצי הגדול: \(\Theta(n)\).

סך הכל: \(\Theta(n)\). ✓

Return median (\(\Theta(1)\))

  • אם \(n\) אי-זוגי: החציון הוא השורש של ה-heap הגדול יותר (\(H_1\) או \(H_2\)). \(O(1)\) גישה.
  • אם \(n\) זוגי: שני חציונים, השורשים של שני ה-heaps.

Delete median (\(\Theta(\log n)\))

  • אם \(n\) אי-זוגי: extract-max מ-\(H_1\) (ה-heap הגדול יותר). זמן: \(\Theta(\log n)\). אחרי המחיקה, ה-heaps שווים.
  • אם \(n\) זוגי: extract-min מ-\(H_2\) (כדי לאזן — אחרת ה-Max-Heap יישאר גדול יותר). זמן: \(\Theta(\log n)\).

הסוד: אחרי כל מחיקה, האינווריאנט נשמר (כל \(H_1\) ≤ כל \(H_2\)), והגודלים נשארים מאוזנים (הפרש לכל היותר 1).

💡 הרחבה — Two-Heaps Streaming Median
המבנה הזה מורחב לאלגוריתם streaming median: בכל זמן, ניתן להוסיף איבר חדש ולהחזיר את החציון העדכני. פעולה Insert ב-\(\Theta(\log n)\):
  1. אם האיבר \(\leq\) חציון הנוכחי: הוסף ל-\(H_1\) (Max-Heap).
  2. אחרת: הוסף ל-\(H_2\) (Min-Heap).
  3. אם ההפרש בגדלים יותר מ-1: העבר את השורש מה-heap הגדול ל-heap הקטן.
שאלה קלאסית ב-coding interviews ב-Google/Facebook.

5. טיפים לקראת המבחן

💡 טיפ 1 — זיהוי "Linear Time" במבחן
כשנותנים לך טווח של מספרים מסוים, תמיד שאל את עצמך:
  • שלמים? → Counting Sort או Radix Sort.
  • טווח קטן (\(k = O(n)\))? → Counting Sort.
  • טווח גדול (\(k = n^c\))? → Radix Sort בבסיס \(n\).
  • מספרים ממשיים עם התפלגות ידועה? → Bucket Sort.
💡 טיפ 2 — איך לבחור את הבסיס ב-Radix Sort?
  1. טווח המספרים = \([0, M]\)?
  2. בסיס \(b\) → מספר ספרות \(d = \lceil \log_b M \rceil\).
  3. סיבוכיות = \(\Theta(d(n + b))\).
  4. אופטימום: \(b = O(n)\) → \(d(n + n) = 2dn\) → אם \(d = O(1)\), זה \(\Theta(n)\).
  5. מתי \(d = O(1)\)? כאשר \(M \leq n^c\) לחזקה קבועה \(c\). אז בסיס \(n\) נותן \(d = c = O(1)\).
💡 טיפ 3 — Order Statistics לחיים
כל בעיה שמבקשת "מצא k איברים שעונים על תכונה" או "מצא איבר במיקום מסוים":
  1. הגדר ערך עזר \(D[i] = f(A[i])\) (כאן ה-f מבוסס על "התכונה").
  2. השתמש ב-Select למציאת הערך ה-k-בגודלו ב-D.
  3. סנן ב-O(n).
אל תמיין הכל — זה \(\Omega(n \log n)\).
💡 טיפ 4 — שאלות עם hybrid data structures
כשמבקשים מבנה נתונים עם הרבה דרישות (Build O(n) + Median O(1) + Delete O(log n)):
  1. חשוב על שתי מבני נתונים שונים שכל אחד תורם משהו (כאן: Max-Heap + Min-Heap).
  2. הקפד על אינווריאנט בין שני המבנים (כאן: כל H1 ≤ כל H2).
  3. הסבר איך כל פעולה מקיימת את האינווריאנט.
תבניות נוספות: סיגריס + Hash Table (BST + Hash for fast both ordered and unordered lookup), Stack + Min-Stack (לתחזק מינימום ב-O(1)).
💡 טיפ 5 — סדר אלגוריתמי המיון לזכרון
אלגוריתםWorstתזכור
Insertionפשוט, in-place, יציב
Heapn log nin-place, לא יציב
Mergen log nO(n) זיכרון, יציב
Quickאך average n log n, in-place
Countingn+kשלמים ב-{1..k}
Bucketאך average n, אחיד
Radixd(n+k)d ספרות