מבני נתונים ואלגוריתמים - מחברת קורס/מבני נתונים/עצי חיפוש בינריים

מתוך ויקיספר, אוסף הספרים והמדריכים החופשי

דף זה עוסק בעצי חיפוש בינריים.

עץ חיפוש בינרי, או BST‏ (binary search tree),‏ הוא מבנה נתונים שימושי מאד לשמירת קבוצת מפתחות כאשר:

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


כדאי לדעת:

#תוכל לראות את הפסוודו-קוד המלא למבנה זה עצי חיפוש בינריים - פסוודו-קוד.
  1. בספר הקורס, הפרק "Binary Search Trees" (תתי פרקים 1-3) מכסה נושאים אלה.



מימוש C++

std::set, std::map

הגדרות[עריכה]

עץ חיפוש בינרי (או BST ) הוא עץ המורכב מצמתים. כל צומת מכיל את השדות הבאים:

  1. מפתח
  2. (מצביע ל)אב
  3. (מצביע ל)בן השמאלי
  4. (מצביע ל)בן הימני


להלן הפסוודו-קוד לצומת:


Node
	# The key this node holds.
1	key

	# Parent.
2	parent

	# Left child.
3	l-child
	# Right child.
4	r-child


# Makes a node with a given key (k)
Make-Node(k)
1	nd = Node()

2	nd.key = k

3	nd.parent = nd.l-child = nd.r-child = Nil

4	return nd


למבנה המתאר את העץ עצמו יש שני שדות:

  1. (מצביע ל)צומת השורש
  2. שדה המציין את גודל העץ (כלומר, את מספר הצמתים בו)

להלן הפסוודו-קוד לעץ:


Binary-Search-Tree
	# The root (shoresh).
1	root
	# Number of nodes.
2	size


# Makes an empty BST.	
Make-Binary-Search-Tree()
1	t = Binary-Search-Tree()

2	t.root = Nil
3	t.size = 0

4	return t



הגדרה:

עץ חיפוש בינרי חייב לקיים את תכונת הBST - לכל צומת x:

  1. אם y הוא צומת בתת העץ ה שמאלי של x, אז y.key x.key.
  2. אם y הוא צומת בתת העץ ה ימני של x.key y.key.


כדאי לדעת:

זו אינה ההגדרה היחידה האפשרית לתכונת הBST.

להלן שתי הגדרות אחרות אפשריות.


הגדרה:

עץ חיפוש בינרי חייב לקיים את תכונת הBST - לכל צומת x:

  1. אם y הוא צומת בתת העץ ה שמאלי של x, אז y.key x.key.
  2. אם y הוא צומת בתת העץ ה ימני של x.key y.key.


הגדרה:

עץ חיפוש בינרי חייב לקיים את תכונת הBST - לכל צומת x:

  1. אם y הוא צומת בתת העץ ה שמאלי של x, אז y.key x.key.
  2. אם y הוא צומת בתת העץ ה ימני של x.key y.key.

ההבדלים הנגזרים משלוש ההגדרות קטנים למדי.



דוגמה:

התרשים הבא מראה שני עצי חיפוש בינריים לקבוצת המפתחות :


עץ חיפוש בינרי.
עץ חיפוש בינרי.



כדאי לדעת:

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

נגדיר את גובה העץ כמרחק המקסימום מהשורש לעלה כלשהו.



דוגמה:

בתרשים הקודם, A הוא בעל גובה 3, וB הוא בעל גובה 4.


חיפוש[עריכה]

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

להלן הפסוודו-קוד לפונקציה המקבלת עץ ומפתח, ומחזירה (מצביע ל)צומת המתאים למפתח (או Nil אם אין כזה).

# Searches a tree (t) for a key (k).
# Returns a (pointer to a) node containing an equivalent key to k
#	if t has one; Nil otherwise.
Find(t, k)
1	nd = t.root

2	while nd != Nil
3		if k < nd.key
4			nd = nd.l-child
5		else if k > nd.key
6			nd = nd.r-child
7		else
8			return nd
		
9	return Nil




משפט:

סיבוכיות הפעולה היא במקרה הגרוע, כאשר הוא גובה העץ.


נוכל לכתוב פונקציה Member, המקבלת עץ ומפתח, ומחזירה האם המפתח נמצא בקבוצת האיברים של העץ.

# Searches a tree (t) for a key (k).
# Returns whether the tree contains the key.
Member(t, k)
1	return Find(t, k) != Nil

הכנסה[עריכה]

הכנסת מפתח לעץ מתבצעת באופן הבא: מבצעים אותו מעבר משורש לעלה כמו בחיפוש, ומחברים צומת חדש כבן של העלה.




דוגמה:

אם מכניסים את 4 לעץ בA שבתרשים הבא, מקבלים את B.


הכנסה לעץ חיפוש בינרי.
הכנסה לעץ חיפוש בינרי.



# Inserts a key (k) to a tree (t).
Insert(t, k)
1	++t.size
	
2	parent = Nil
3	nd = t.root
	
4	while nd != Nil
5		parent = nd
		
6		nd = k < nd.key ? nd.l-child : nd.r-child
		
7	new-nd = Make-Node(k)
	
8	if parent == Nil
9		t.root = New_nd
10		return
 
11	if k < parent.key
12		parent.l-child = new-nd
13	else
14		parent.r-child = new-nd
			
15	new-nd.parent = parent




משפט:

סיבוכיות הפעולה היא במקרה הגרוע, כאשר הוא גובה העץ.


מינימום ומקסימום[עריכה]

נניח שאנו מחזיקים (מצביע ל)צומת nd. כיצד נוכל למצוא את (המצביע ל)צומת בעל המפתח הקטן ביותר בתת-העץ שלו? לפי תכונת הBST, יש להתקדם מטה ושמאלה, עד שאין להיכן להמשיך. לפי אותו הרעיון, כדי למצוא את המפתח הגדול ביותר, יש להתקדם מטה וימינה.

להלן הפסוודו-קוד המתאים.

# Takes a (pointer to a) node (nd). 
# Retuns (a pointer to) the minimum node in the subtree of nd.
Min(nd)
1	while nd.l-child != Nil
2		nd = nd.l-child
	
3	return nd


# Takes a (pointer to a) node (nd). 
# Retuns (a pointer to) the maximum node in the subtree of nd.
Max(nd)
1	while nd.r-child != Nil
2		nd = nd.r-child
	
3	return nd




משפט:

סיבוכיות הפעולה היא במקרה הגרוע, כאשר הוא גובה העץ.



מציאת הצומת הבא והקודם[עריכה]

כעת נראה כיצד למצוא את הצומת הבא (successor באנגלית) והקודם (predecessor באנגלית). נניח לרגע שבעץ אין שני מפתחות זהים. אם אנו נמצאים בצומת כלשהו שמפתחו הוא :

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

מטעמי סימטריה, נדבר רק על הצומת הבא.


הצמתים ה"חשודים"[עריכה]

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

  • אפשרות א' - הצומת המבוקש הוא צאצא של הצומת בעל המפתח .
  • אפשרות ב' - לצומת המבוקש ולצומת בעל המפתח יש הורה משותף (או שהצומת המבוקש הוא בעצמו הורה של ).

אפשרות א'[עריכה]

נתבונן בצאצאים של (אם יש לו).


ברור למדי שהצאצאים היחידים הרלוונטיים הם הצאצאים הימניים; השמאליים הרי קטנים מ.

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


שימו לב:

ייתכנו כמובן מצבים בהם לצומת אין צאצאים ימניים. במקרים אלה, אין צומת כמתואר כאן.

אפשרות ב'[עריכה]

נבדוק כעת את הצמתים שאינם צאצאים של . לכל צומת כזה יש הורה משותף ל (או שהוא עצמו הורה של ). קבוצת הצמתים הללו היא בדיוק קבוצת הצאצאים של הצמתים מ לשורש העץ. המסלול הזה, באופן כללי, נראה כך:

  • אפס או יותר צמתים בכוון שמאלה ומעלה, ולאחר מכן
  • אפס או יותר צמתים בכוון ימינה ומעלה, ולאחר מכן
  • אפס או יותר צמתים בכוון שמאלה ומעלה, ולאחר מכן
  • וכולי וכולי.

התרשים הבא מראה מסלול אפשרי.


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

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


שימו לב:

ייתכנו כמובן מצבים בהם לצומת אין הורים ימניים. במקרים אלה, אין צומת כמתואר כאן.

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


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

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

לאחר מעט מחשבה, קל לראות שאפשר להוכיח (פורמלית, באינדוקציה) את המשפט הבא.



משפט:

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


היחס בין הצמתים ה"חשודים"[עריכה]

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

לכן, אם יש ל בן ימני, בהכרח הצומת המבוקש הוא הצאצא השמאלי ביותר של אותו הבן הימני. לחלופין, אם אין בן ימני, יש צורך למצא את ההורה הימני הראשון של .

פסוודו-קוד[עריכה]

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


# Takes a (pointer to a) node (nd). 
# Retuns (a pointer to) the "next" node.
Successor(nd)
1	if nd.r-child != Nil
2		return Min(nd.r_child)
	
3	parent = nd.parent

4	while parent != Nil and nd == parent.r-child
5		nd = parent
6		parent = parent.parent
	
7	return parent


# Takes a (pointer to a) node (nd). 
# Retuns (a pointer to) the "previous" node.
Predecessor(nd)
1	if nd.l-child != Nil
2		return Max(nd.l_child)
	
3	parent = nd.parent

4	while parent != Nil and nd == parent.l-child
5		nd = parent
6		parent = parent.parent
	
7	return parent




משפט:

סיבוכיות הפעולה היא במקרה הגרוע, כאשר הוא גובה העץ.


מחיקה[עריכה]

הקדמה[עריכה]

מטרתנו היא כתיבת פונקציה Delete(t, nd), אשר מוחקת (מצביע ל)צומת nd מהעץ t. ראשית נתמודד עם בעיה פשוטה יותר - הוצאת צומת שאין לו שני ילדים. נשתמש בפתרון בעיה זו כדי לממש את Delete.


פעולת הsplice[עריכה]

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




דוגמה:

התרשים הבא מראה כיצד להוציא את הצומת בעל המפתח 7:


splice בעץ חיפוש בינרי.
splice בעץ חיפוש בינרי.


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


# Splices (removes) a node (nd) from a tree (t).
# The node (nd) must not have two children.
Splice(t, nd)
1	child = nd.l_child == Nil ? nd.r-child : nd.l-child

2	parent = nd.parent

3	if child != Nil
4		child.parent = parent

5	if parent == Nil
6		t.root = child
7		return
	
8	if nd.key < parent.key
9		parent.l-child = child
10	else 
11		parent.r-child = child

האלגוריתם[עריכה]

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



משפט:

אם לצומת יש בן ימני, אז לצאצא השמאלי ביותר של בנו הימני אין בן שמאלי.


כלומר, יוצא שתמיד נוכל להשתמש בפעולת splice:

  1. אם לצומת רק בן אחד (או פחות), אז אפשר לבצע עליו splice.
  2. אם לצומת יש שני בנים, אז לבנו הימני יש צאצא שלו לכל היותר בן אחד. נוכל לבצע splice על אותו הצאצא, לכן.
# Erases a node (nd) from a tree (t).
Delete(t, nd)
1	--t.size	

2	if nd.l-child == Nil or nd.r-child == Nil
3		Splice(t, nd)
4		return
	
5	spliced = Min(nd.r-child)

6	Splice(t, spliced)

7	nd.key = spliced.key




דוגמה:

התרשים הבא מראה כיצד למחוק את צומת המפתח 3:


מחיקת צומת מעץ חיפוש בינרי.
מחיקת צומת מעץ חיפוש בינרי.




משפט:

סיבוכיות הפעולה היא במקרה הגרוע, כאשר הוא גובה העץ.


פעולות למעבר על כל איברי העץ[עריכה]

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

מעבר Pre-Order[עריכה]

בצורת מעבר זו, כאשר מגיעים לצומת:

  1. ראשית מדפיסים את (איבר) הצומת עצמו
  2. לאחר מכן עוברים על תת העץ השמאלי שלו
  3. לאחר מכן עוברים על תת-העץ הימני שלו
Pre-Order(nd)
1	if nd == Nil
2		return

3	Print(nd.value)

4	Pre-Order(nd.l-child)
5	Pre-order(nd.r-child)

התרשים הבא מראה את סדר המעבר בצומת:

מעבר pre-order.
מעבר pre-order.

מעבר Post-Order[עריכה]

בצורת מעבר זו, כאשר מגיעים לצומת:

  1. ראשית עוברים על תת-העץ השמאלי שלו
  2. לאחר מכן עוברים על תת העץ הימני שלו
  3. לאחר מכן מדפיסים את (איבר) הצומת עצמו


Post-Order(nd)
1	if nd == Nil
2		return

4	Post-Order(nd.l-child)
5	Post-order(nd.r-child)

3	Print(nd.value)

התרשים הבא מראה את סדר המעבר בצומת:

מעבר post-order.
מעבר post-order.


מעבר In-Order[עריכה]

בצורת מעבר זו, כאשר מגיעים לצומת:

  1. ראשית עוברים על תת-העץ השמאלי שלו
  2. לאחר מכן מדפיסים את (איבר) הצומת עצמו
  3. לאחר מכן עוברים על תת העץ הימני שלו


In-Order(nd)
1	if nd == Nil
2		return

3	In-Order(nd.l-child)

4	Print(nd.value)

5	In-order(nd.r-child)

התרשים הבא מראה את סדר המעבר בצומת:

מעבר in-order.
מעבר in-order.

בשיעורי הבית תתבקש להוכיח שצורת מעבר זו תדפיס את כל איברי העץ בסדר עולה.

סיכום[עריכה]

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

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


הפרק הקודם:
תורים
עצי חיפוש בינריים
תרגילים
הפרק הבא:
עצים אדומים-שחורים