1. המוטיבציה — Sequence ADT וה-"wish list"
בהרצאות הקודמות התחלנו עם מבני נתונים ל-ADTs פשוטים (sequence, stack, queue, priority queue), ניתחנו סיבוכיות, ולמדנו על אלגוריתמי מיון. עכשיו חוזרים ל-Sequence ADT עם תקווה לפעולות יעילות יותר.
סיבוכיות זמן — Array & Linked List
| פעולה | Sorted Array | Sorted Linked List | Unsorted Array | Desired |
| Find | O(log n) | O(n) | O(n) | O(1) |
| Insert | O(n) | O(n) | O(1) | O(1) |
| Delete | O(n) | O(n) | O(n) | O(1) |
אי אפשר להגיע ל-O(1) בכל הפעולות
הסיבה: חסם תחתון Ω(n log n) למיון מבוסס השוואות מעיד שלא ניתן להחזיק Sequence עם כל הפעולות ב-O(1).
מה נשיג עם BST
| פעולה | Sorted Array | Linked List | Unsorted | BST (מאוזן) |
| Find | O(log n) | O(n) | O(n) | O(log n) |
| Insert | O(n) | O(n) | O(1) | O(log n) |
| Delete | O(n) | O(n) | O(n) | O(log n) |
2. הגדרה רקורסיבית: עץ, עץ בינארי, BST
עץ (Tree)
קבוצה של צמתים שהיא או:
- ריקה, או
- יש לה צומת אחד הנקרא שורש, שמצביע לשורשים של אפס או יותר עצים זרים (תתי-עצים).
"זרים" = אין צמתים משותפים.
עץ בינארי (Binary Tree)
קבוצה של צמתים שהיא או:
- ריקה, או
- יש לה שורש שמצביע ל-{עץ בינארי שמאלי \(T_L\), עץ בינארי ימני \(T_R\)} כאשר \(T_L\) ו-\(T_R\) זרים.
\(T_L\) ו-\(T_R\) נקראים תת-העץ השמאלי והימני של השורש.
עץ חיפוש בינארי (Binary Search Tree, BST)
עץ בינארי עם
תכונת ה-BST: לכל צומת \(v\):
- כל המפתחות בתת-העץ השמאלי של \(v\) קטנים מ-\(\text{key}(v)\).
- כל המפתחות בתת-העץ הימני של \(v\) גדולים מ-\(\text{key}(v)\).
(לעת עתה — כל המפתחות שונים.)
3. תכונת ה-BST ועומק העץ
תכונת ה-BST (נכונה לכל \(y\) בתת-עץ של \(v\))
יהי \(v\) צומת ב-BST:
- אם \(y\) בתת-העץ השמאלי אז \(\text{key}(y) < \text{key}(v)\).
- אם \(y\) בתת-העץ הימני אז \(\text{key}(y) > \text{key}(v)\).
עומק BST
- עומק מקסימלי: \(O(n)\) — כשהעץ "מנוון" (chain).
- עומק מינימלי: \(\Omega(\log n)\) — עץ מאוזן.
דוגמה
לאותה קבוצת מפתחות \(\{2,3,5,6,7,8\}\) יש BSTs שונים — אחד מאוזן (עומק \(\Theta(\log n)\)) ואחד מנוון (עומק \(\Theta(n)\)). מבנה ה-BST תלוי בסדר ההכנסה.
4. מימוש BST (עם מצביעי הורה)
כל צומת מכיל:
- Key — המפתח.
- Data — מטען נוסף.
- L, R — מצביעים לבנים.
- P — מצביע להורה (Parent). שימושי לפעולות כמו Successor, Insert, Delete.
נשמור גם מצביע T לשורש העץ.
5. Find — חיפוש מפתח
מימוש רקורסיבי
Find(T, x):
case
T = null : return null
T.key = x : return T
T.key > x : return Find(T.left, x)
T.key < x : return Find(T.right, x)
מימוש לא-רקורסיבי
Find(T, x):
while T ≠ null and T.key ≠ x:
if x < T.key: T = T.left
else: T = T.right
return T
סיבוכיות
O(h) — בלכל איטרציה יורדים רמה אחת בעץ, ולכן בחסם הגרוע מבצעים h צעדים, כאשר h = גובה העץ.
6. FindMin / FindMax
FindMin: ירידה רצופה שמאלה עד לצומת ללא בן שמאל.
FindMin(T):
if T.left = null then return T
else return FindMin(T.left)
// או לולאה:
FindMin(T):
while T.left ≠ null do T = T.left
return T
FindMax: סימטרי — ירידה רצופה ימינה.
7. היכן בעץ נמצאים האיברים הקטנים/גדולים מ-\(x\)?
איברים קטנים מ-\(x\)
- כל הצמתים בתת-העץ השמאלי של \(x\) (אם קיים).
- כל אבוֹת (ancestors) \(y\) של \(x\) כך ש-\(x\) בתת-העץ הימני של \(y\), וגם כל הצמתים בתת-העץ השמאלי של \(y\).
איברים גדולים מ-\(x\)
- כל הצמתים בתת-העץ הימני של \(x\) (אם קיים).
- כל אבוֹת \(y\) של \(x\) כך ש-\(x\) בתת-העץ השמאלי של \(y\), וגם כל הצמתים בתת-העץ הימני של \(y\).
תובנה
הניתוח הזה הוא הבסיס לאלגוריתמים של Successor, Predecessor, Rank, ו-SumSmaller. כשעולים בעץ — כל פעם שעוברים מבן ימני להורה, ההורה ותת-העץ השמאלי שלו
קטנים מ-\(x\); ולהפך מבן שמאלי.
8. Successor & Predecessor
Successor של \(x\)
- אם ל-\(x\) יש תת-עץ ימני: ה-Successor הוא ה-minimum של תת-העץ הימני.
דוגמה: successor(7) = 9 (ירידה שמאלה מ-15).
- אחרת: ה-Successor הוא ה-ancestor הנמוך ביותר ש-\(x\) בתת-העץ השמאלי שלו.
דוגמאות: successor(11) = 15, successor(18) = 20.
Tree-Successor(x):
if x.right ≠ null then
return FindMin(x.right)
else
y ← x.parent
while y ≠ null and x = y.right:
x ← y
y ← y.parent
return y
Predecessor: סימטרי — חפש max בתת-עץ שמאלי, אחרת עלה עד שמגיעים מבן שמאלי.
9. Insert — הכנסה
רעיון
- בצע "Find" עבור \(x\).
- אם \(x\) נמצא — הכרז שהמפתח כבר קיים.
- אחרת, ה-Find עוצר ב-pointer null. הוסף שם צומת חדש עם המפתח \(x\).
Tree-Insert(T, z):
y ← null // prev node
x ← T // current node
While x ≠ null:
y ← x
if z.key < x.key
then x ← x.left
else x ← x.right
z.parent ← y
if y = null
then T ← z
elseif z.key < y.key
then y.left ← z
else y.right ← z
10. Delete — מחיקה (3 מקרים)
בעיה
כשמוחקים צומת \(v\), איך ממלאים את ה-"חור" בעץ?
שלושת המקרים
- ל-\(v\) אין בנים: מצביע אליו → null. דוגמה: Delete(5) — סתם מנקים.
- ל-\(v\) בן אחד: מחליפים את המצביע ל-\(v\) במצביע לבן שלו. דוגמה: Delete(24) → הבן 11 תופס את מקומו.
- ל-\(v\) שני בנים: מעתיקים את Successor (= ה-minimum בתת-עץ הימני) ל-\(v\), ואז מוחקים את ה-Successor רקורסיבית. ה-Successor לעולם אין לו בן שמאלי, ולכן ה-מחיקה הרקורסיבית נופלת למקרה 1 או 2.
Tree-Delete(T, z):
1 if z.left = null or z.right = null
2 then y ← z
3 else y ← Tree-Successor(z)
4 if y.left ≠ null
5 then x ← y.left
6 else x ← y.right
7 if x ≠ null
8 x.parent ← y.parent
9 if y.parent = null
10 then T.root ← x
11 else if y = (y.parent).left
12 then (y.parent).left ← x
13 else (y.parent).right ← x
14 if y ≠ z
15 then z.key ← y.key // also copy all data fields
16 return y
סמלים: \(y\) — הצומת שבאמת נמחק; \(x\) — בנו היחיד של \(y\) (או null).
11. Join & Split
Join(T₁, x, T₂)
קלט / פלט
קלט: שני עצי BST \(T_1, T_2\) וצומת \(x\), כך שמפתחות \(T_1\) < key(\(x\)) < מפתחות \(T_2\).
פלט: BST אחד המכיל \(T_1 \cup \{x\} \cup T_2\).
פתרון: שים את \(x\) כשורש, \(T_1\) כתת-עץ שמאלי, \(T_2\) כתת-עץ ימני.
סיבוכיות: O(1).
Split(T, x)
קלט / פלט
קלט: BST \(T\) ואיבר עם מפתח \(x\) בעץ.
פלט: שני BSTs ו-\(x\) — \(T_1\) עם המפתחות הקטנים מ-\(x\), \(T_2\) עם הגדולים, ו-\(x\) ביניהם.
אלגוריתם:
- סרוק את המסלול מ-root ל-\(x\).
- חבר את תת-העץ השמאלי של \(x\) עם כל העצים משמאל למסלול → \(T_1\).
- חבר את תת-העץ הימני של \(x\) עם כל העצים מימין למסלול → \(T_2\).
סיבוכיות: O(h).
המשמעות של Time Complexity
כל הפעולות שראינו עד כה הן ב-\(O(h)\).
Worst case: \(h\) עשוי להיות \(\Omega(n)\) (עץ מנוון).
Best case: \(O(\log n)\) (עץ מאוזן). שמירה על איזון העץ — נושא של הרצאות הבאות.
12. מעבר על עצים: Preorder, Inorder, Postorder
שלושה מעברים נפוצים, מוגדרים רקורסיבית:
- Preorder (DLR): צומת, אז ילדים (שמאל קודם).
- Inorder (LDR): בן שמאל, צומת, בן ימין.
- Postorder (LRD): ילדים, אז צומת.
Inorder — אלגוריתם ודוגמה
inorder-tree-walk(T):
if T ≠ null then
inorder-tree-walk(T.left)
print T.key
inorder-tree-walk(T.right)
תוצאה חשובה
Inorder על BST מדפיס את המפתחות בסדר ממויין!
לדוגמה, על עץ עם המפתחות {5,10,11,17,24,94,97}: הפלט הוא
5, 10, 11, 17, 24, 94, 97.
Postorder — שימוש להערכת ביטויים מתמטיים
בעץ בינארי מלא שבו כל צומת פנימי = פעולה בינארית וכל עלה = מספר, postorder מחשב את ערך הביטוי:
- (11+(2*7))+(3*10) = 11 + 14 + 30 = 55
- ((11+2)*(7+3))*10 = 13 * 10 * 10 = 1300
Preorder — שימושים
- יצירת עותק של עץ.
- Serializing של BST (לשמירה בקובץ).
- הדפסת מבנה תיקיות (תיקייה לפני התוכן).
סיבוכיות מעברים
כל צומת מבוקר לכל היותר 3 פעמים. סה"כ:
Θ(n).
13. בניית BST — חסם תחתון Ω(n log n)
Naïve insert
- סיבוכיות: \(\Theta(nh)\).
- Best case: \(\Theta(n \log n)\).
- Worst case: \(\Theta(n^2)\) (כשהמפתחות ממויינים מראש → עץ מנוון).
טענה (חסם תחתון)
הכנסת \(n\) מפתחות לא-ממויינים ל-BST, באמצעות השוואות בלבד, דורשת \(\Omega(n \log n)\) זמן.
כלומר —
בלתי אפשרי לבנות BST מ-keys לא-ממויינים ב-\(o(n \log n)\).
הוכחה (בשלילה):
נניח שאפשר לבנות BST ב-o(n log n).
נבצע Inorder ב-Θ(n) ונקבל את n המפתחות בסדר ממויין.
זה אומר שניתן למיין n מפתחות ב-o(n log n) ע"י השוואות —
סתירה לחסם התחתון Ω(n log n) של מיון מבוסס-השוואות! ∎
14. בניית BST מאיברים ממויינים — Θ(n)
רעיון
קח את
החציון כשורש. הפעל רקורסיבית על שני החצאים. כל צומת מתווסף ב-\(\Theta(1)\), והעץ הנוצר מאוזן.
BuildBST(A, q, r):
if q > r then return null
else
m = ⌊(q+r)/2⌋
create a new node v
v.data = A[m]
v.left = BuildBST(A, q, m-1)
v.right = BuildBST(A, m+1, r)
return v
סיבוכיות
Θ(n) — כי \(A\) ממויין, אין השוואות, כל יצירת צומת ב-\(\Theta(1)\).
בונוס: העץ הנוצר
מאוזן, בגובה \(\Theta(\log n)\).
15. Augmented Data — שדות נוספים
רעיון
נשמור בכל צומת שדה נוסף שמסייע לענות על שאילתות יעיל. השדה צריך להיות ניתן
עדכון תוך כדי Insert/Delete בסיבוכיות \(\Theta(h)\).
שאלות נפוצות שאפשר לענות עליהן בעזרת augmented data
- Rank — מהי הדרגה של מפתח נתון?
- Select — מהו המפתח בעל דרגה \(k\)?
- חישוב סכום של כל המפתחות הגדולים/קטנים ממפתח נתון.
- חישוב הממוצע של ה-10% התחתונים.
Size augmentation
בכל צומת \(v\) נשמור:
- \(v.\text{size}\) = מספר הצמתים בתת-העץ ששורשו \(v\) (כולל \(v\) עצמו).
עדכון השדה
- Node insert: צומת חדש \(x\) הוא תמיד עלה — \(x.\text{size} = 1\). הגדל ב-1 את \(v.\text{size}\) לכל \(v\) במסלול מ-\(x\) ל-root.
- Node delete: הקטן ב-1 את \(v.\text{size}\) לכל \(v\) במסלול מ-root לצומת שנמחק. במקרה של 2 בנים — הצומת שבאמת נמחק הוא ה-Successor.
16. SELECT(T, k) — האיבר ב-rank \(k\)
מטרה
החזר pointer לצומת המכיל את האיבר ב-rank \(k\) ב-\(T\).
רעיון
\(r[x]\) = דרגת \(x\) בתת-העץ ששורשו \(x\). בעזרת שדה \(\text{size}\): \(r[x] = (x.\text{left}).\text{size} + 1\).
SELECT(x, k):
if (x = null) return null
if x.left ≠ null
then r ← (x.left).size + 1
else r ← 1
if k = r: return x
else if k < r: return SELECT(x.left, k)
else: return SELECT(x.right, k - r)
סיבוכיות
O(h) — יורדים בעץ פעם אחת.
דוגמה — SELECT(T, 3) כאשר T שורש 10
- בשורש 10: \(r[10] = 3 + 1 = 4\). 4 > 3 → לך שמאלה.
- בצומת 8: \(r[8] = 1 + 1 = 2\). 2 < 3 → לך ימינה עם \(k = 3 - 2 = 1\).
- בצומת 9: \(r[9] = 0 + 1 = 1\). 1 = 1 → נמצא! החזר 9.
17. RANK(T, x) — דרגה של איבר נתון
מטרה
בהינתן pointer \(x\) לצומת ב-BST \(T\), החזר את הדרגה של \(x\) ב-\(T\).
רעיון: סופרים את המפתחות שקטנים מ-\(x\)
- הוסף את האיברים בתת-העץ השמאלי של \(x\) (= \(x.\text{left}.\text{size}\)), \(+1\) ל-\(x\) עצמו.
- עבור על המסלול \(p\) מ-\(x\) ל-root: לכל \(v \in p\), אם \(v\) הוא בן ימני — הוסף את ה-size של תת-העץ השמאלי של אבא של \(v\), \(+1\) על האבא עצמו.
RANK(T, x):
if x.left ≠ null
then r ← (x.left).size + 1
else r ← 1
y ← x
while y.parent ≠ null:
if y = (y.parent).right
then if (y.parent).left ≠ null
then r ← r + ((y.parent).left).size + 1
else r ← r + 1
y ← y.parent
return r
סיבוכיות
O(h) — עולים בעץ פעם אחת.
18. Sum of Keys < \(x\)
מטרה
בהינתן \(x\), החזר את סכום כל המפתחות בעץ הקטנים מ-\(x\).
הרחבה חדשה: \(v.\text{sum}\)
בכל צומת \(v\): \(v.\text{sum}\) = סכום כל המפתחות בתת-העץ ששורשו \(v\). (אנלוגי ל-size, אבל סוכמים מפתחות במקום סופרים צמתים.)
SumSmaller(x, k):
if x = null then return 0
if k = x.key
then s ← (x.left).sum
elseif k < x.key
then s ← SumSmaller(x.left, k)
else
s ← x.key + (x.left).sum + SumSmaller(x.right, k)
return s
סיבוכיות
Θ(h) — בכל רמה צעד אחד.
עדכון \(v.\text{sum}\) — דומה ל-size
Insert: צומת חדש \(x\) → \(x.\text{sum} = x.\text{key}\). הוסף \(x.\text{key}\) ל-\(v.\text{sum}\) של כל אב.
Delete: חסר את ה-key של הצומת הנמחק מ-\(v.\text{sum}\) של כל אב.
19. סיכום ונקודות מפתח
- BST = עץ בינארי + תכונה שמאל < שורש < ימין. מימוש Sequence ADT עם Find/Insert/Delete ב-\(O(h)\).
- גובה: בין \(\Omega(\log n)\) (מאוזן) ל-\(O(n)\) (מנוון). שמירת איזון — בהמשך.
- Find, FindMin, FindMax, Successor, Insert, Delete — כולם O(h).
- Join: \(O(1)\) (כשידוע שכל \(T_1 < x < T_2\)). Split: \(O(h)\).
- 3 מקרי מחיקה: אין בנים / בן אחד / שני בנים (= העתק successor ומחק רקורסיבית).
- Inorder על BST = סדר ממויין. שימוש זה נותן חסם תחתון \(\Omega(n \log n)\) לבניית BST מ-keys לא-ממויינים.
- בניית BST מ-סדרה ממויינת: \(\Theta(n)\) ע"י חציון רקורסיבי — והעץ יוצא מאוזן.
- Augmented data: שמירה של שדות נוספים (size, sum, …) מאפשרת לענות על שאלות מורכבות יותר ב-\(O(h)\). עדכון השדות תוך כדי Insert/Delete הוא גם ב-\(O(h)\).
- SELECT(T, k) ו-RANK(T, x) ע"י size augmentation — שניהם \(O(h)\).
- SumSmaller(T, x) ע"י sum augmentation — \(O(h)\).
מבט קדימה — שאלות פתוחות
- איך שומרים על איזון העץ? נדון בעצי AVL / Red-Black בהרצאות הבאות.
- Augmented data נוסף: כל שדה שמתעדכן ב-\(O(h)\) מתאים — height, min/max, count של איברים בטווח, ועוד.