גלו את יסודות עצי החיפוש הבינאריים (BST) ולמדו לממש אותם ביעילות ב-JavaScript. המדריך כולל מבנה, פעולות ודוגמאות מעשיות למפתחים ברחבי העולם.
עצי חיפוש בינאריים: מדריך מימוש מקיף ב-JavaScript
עצי חיפוש בינאריים (BSTs) הם מבנה נתונים יסודי במדעי המחשב, הנמצא בשימוש נרחב לחיפוש, מיון ואחזור נתונים יעיל. המבנה ההיררכי שלהם מאפשר סיבוכיות זמן לוגריתמית בפעולות רבות, מה שהופך אותם לכלי רב עוצמה לניהול מערכי נתונים גדולים. מדריך זה מספק סקירה מקיפה של BST ומדגים את מימושם ב-JavaScript, ופונה למפתחים ברחבי העולם.
הבנת עצי חיפוש בינאריים
מהו עץ חיפוש בינארי?
עץ חיפוש בינארי הוא מבנה נתונים מבוסס עץ שבו לכל צומת יש לכל היותר שני ילדים, המכונים הילד השמאלי והילד הימני. תכונת המפתח של BST היא שעבור כל צומת נתון:
- כל הצמתים בתת-העץ השמאלי מכילים מפתחות הקטנים ממפתח הצומת.
- כל הצמתים בתת-העץ הימני מכילים מפתחות הגדולים ממפתח הצומת.
תכונה זו מבטיחה שהאלמנטים ב-BST תמיד מסודרים, ומאפשרת חיפוש ואחזור יעילים.
מושגי מפתח
- צומת: יחידה בסיסית בעץ, המכילה מפתח (הנתונים) ומצביעים לילדיו השמאלי והימני.
- שורש: הצומת העליון ביותר בעץ.
- עלה: צומת ללא ילדים.
- תת-עץ: חלק מהעץ המתחיל בצומת מסוים.
- גובה: אורך המסלול הארוך ביותר מהשורש לעלה.
- עומק: אורך המסלול מהשורש לצומת ספציפי.
מימוש עץ חיפוש בינארי ב-JavaScript
הגדרת מחלקת הצומת (Node)
ראשית, נגדיר מחלקת `Node` כדי לייצג כל צומת ב-BST. כל צומת יכיל `key` לאחסון הנתונים ומצביעי `left` ו-`right` לילדיו.
class Node {
constructor(key) {
this.key = key;
this.left = null;
this.right = null;
}
}
הגדרת מחלקת עץ החיפוש הבינארי
לאחר מכן, נגדיר את מחלקת `BinarySearchTree`. מחלקה זו תכיל את צומת השורש ומתודות להכנסה, חיפוש, מחיקה וסריקה של העץ.
class BinarySearchTree {
constructor() {
this.root = null;
}
// מתודות יתווספו כאן
}
הכנסה
מתודת `insert` מוסיפה צומת חדש עם המפתח הנתון ל-BST. תהליך ההכנסה שומר על תכונת ה-BST על ידי הצבת הצומת החדש במיקום המתאים ביחס לצמתים קיימים.
insert(key) {
const newNode = new Node(key);
if (this.root === null) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
insertNode(node, newNode) {
if (newNode.key < node.key) {
if (node.left === null) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
} else {
if (node.right === null) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
}
}
דוגמה: הכנסת ערכים ל-BST
const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);
חיפוש
מתודת `search` בודקת אם קיים צומת עם המפתח הנתון ב-BST. היא סורקת את העץ, משווה את המפתח עם מפתח הצומת הנוכחי ועוברת לתת-העץ השמאלי או הימני בהתאם.
search(key) {
return this.searchNode(this.root, key);
}
searchNode(node, key) {
if (node === null) {
return false;
}
if (key < node.key) {
return this.searchNode(node.left, key);
} else if (key > node.key) {
return this.searchNode(node.right, key);
} else {
return true;
}
}
דוגמה: חיפוש ערך ב-BST
console.log(bst.search(9)); // פלט: true
console.log(bst.search(2)); // פלט: false
מחיקה
מתודת `remove` מוחקת צומת עם המפתח הנתון מה-BST. זוהי הפעולה המורכבת ביותר מכיוון שהיא צריכה לשמור על תכונת ה-BST תוך כדי הסרת הצומת. ישנם שלושה מקרים שיש לשקול:
- מקרה 1: הצומת למחיקה הוא צומת עלה. פשוט מסירים אותו.
- מקרה 2: לצומת למחיקה יש ילד אחד. מחליפים את הצומת בילדו.
- מקרה 3: לצומת למחיקה יש שני ילדים. מוצאים את העוקב בסדר (the smallest node in the right subtree), מחליפים את הצומת בעוקב, ולאחר מכן מוחקים את העוקב.
remove(key) {
this.root = this.removeNode(this.root, key);
}
removeNode(node, key) {
if (node === null) {
return null;
}
if (key < node.key) {
node.left = this.removeNode(node.left, key);
return node;
} else if (key > node.key) {
node.right = this.removeNode(node.right, key);
return node;
} else {
// המפתח שווה למפתח הצומת
// מקרה 1 - צומת עלה
if (node.left === null && node.right === null) {
node = null;
return node;
}
// מקרה 2 - לצומת יש ילד אחד בלבד
if (node.left === null) {
node = node.right;
return node;
} else if (node.right === null) {
node = node.left;
return node;
}
// מקרה 3 - לצומת יש 2 ילדים
const aux = this.findMinNode(node.right);
node.key = aux.key;
node.right = this.removeNode(node.right, aux.key);
return node;
}
}
findMinNode(node) {
let current = node;
while (current != null && current.left != null) {
current = current.left;
}
return current;
}
דוגמה: הסרת ערך מה-BST
bst.remove(7);
console.log(bst.search(7)); // פלט: false
סריקת עצים
סריקת עצים כוללת ביקור בכל צומת בעץ בסדר מסוים. ישנן מספר שיטות סריקה נפוצות:
- סדר תוכִי (In-order): מבקרים בתת-העץ השמאלי, לאחר מכן בצומת, ואז בתת-העץ הימני. התוצאה היא ביקור בצמתים בסדר עולה.
- סדר קִדְמִי (Pre-order): מבקרים בצומת, לאחר מכן בתת-העץ השמאלי, ואז בתת-העץ הימני.
- סדר סוֹפִי (Post-order): מבקרים בתת-העץ השמאלי, לאחר מכן בתת-העץ הימני, ואז בצומת.
inOrderTraverse(callback) {
this.inOrderTraverseNode(this.root, callback);
}
inOrderTraverseNode(node, callback) {
if (node !== null) {
this.inOrderTraverseNode(node.left, callback);
callback(node.key);
this.inOrderTraverseNode(node.right, callback);
}
}
preOrderTraverse(callback) {
this.preOrderTraverseNode(this.root, callback);
}
preOrderTraverseNode(node, callback) {
if (node !== null) {
callback(node.key);
this.preOrderTraverseNode(node.left, callback);
this.preOrderTraverseNode(node.right, callback);
}
}
postOrderTraverse(callback) {
this.postOrderTraverseNode(this.root, callback);
}
postOrderTraverseNode(node, callback) {
if (node !== null) {
this.postOrderTraverseNode(node.left, callback);
this.postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
דוגמה: סריקת ה-BST
const printNode = (value) => console.log(value);
bst.inOrderTraverse(printNode); // פלט: 3 5 8 9 10 11 12 13 14 15 18 20 25
bst.preOrderTraverse(printNode); // פלט: 11 5 3 9 8 10 15 13 12 14 20 18 25
bst.postOrderTraverse(printNode); // פלט: 3 8 10 9 12 14 13 18 25 20 15 11
ערכים מינימליים ומקסימליים
מציאת הערכים המינימליים והמקסימליים ב-BST היא פשוטה, הודות למבנה המסודר שלו.
min() {
return this.minNode(this.root);
}
minNode(node) {
let current = node;
while (current !== null && current.left !== null) {
current = current.left;
}
return current;
}
max() {
return this.maxNode(this.root);
}
maxNode(node) {
let current = node;
while (current !== null && current.right !== null) {
current = current.right;
}
return current;
}
דוגמה: מציאת ערכים מינימליים ומקסימליים
console.log(bst.min().key); // פלט: 3
console.log(bst.max().key); // פלט: 25
יישומים מעשיים של עצי חיפוש בינאריים
עצי חיפוש בינאריים משמשים במגוון רחב של יישומים, כולל:
- מסדי נתונים: אינדוקס וחיפוש נתונים. לדוגמה, מערכות מסדי נתונים רבות משתמשות בווריאציות של BST, כגון עצי B, כדי לאתר רשומות ביעילות. חשבו על קנה המידה העולמי של מסדי נתונים המשמשים תאגידים רב-לאומיים; אחזור נתונים יעיל הוא בעל חשיבות עליונה.
- מהדרים (Compilers): טבלאות סמלים, המאחסנות מידע על משתנים ופונקציות.
- מערכות הפעלה: תזמון תהליכים וניהול זיכרון.
- מנועי חיפוש: אינדוקס של דפי אינטרנט ודירוג תוצאות חיפוש.
- מערכות קבצים: ארגון וגישה לקבצים. דמיינו מערכת קבצים בשרת המשמש גלובלית לאירוח אתרי אינטרנט; מבנה מבוסס BST מאורגן היטב מסייע בהגשת תוכן במהירות.
שיקולי ביצועים
הביצועים של BST תלויים במבנה שלו. בתרחיש הטוב ביותר, BST מאוזן מאפשר סיבוכיות זמן לוגריתמית עבור פעולות הכנסה, חיפוש ומחיקה. עם זאת, בתרחיש הגרוע ביותר (למשל, עץ מוטה), סיבוכיות הזמן יכולה לרדת לזמן ליניארי.
עצים מאוזנים לעומת עצים לא מאוזנים
עץ BST מאוזן הוא עץ שבו גובה תתי-העצים השמאלי והימני של כל צומת נבדל בלכל היותר אחד. אלגוריתמים לאיזון-עצמי, כגון עצי AVL ועצי אדום-שחור, מבטיחים שהעץ נשאר מאוזן, ומספקים ביצועים עקביים. אזורים שונים עשויים לדרוש רמות אופטימיזציה שונות בהתבסס על העומס על השרת; איזון מסייע לשמור על ביצועים תחת שימוש גלובלי גבוה.
סיבוכיות זמן
- הכנסה: O(log n) בממוצע, O(n) במקרה הגרוע ביותר.
- חיפוש: O(log n) בממוצע, O(n) במקרה הגרוע ביותר.
- מחיקה: O(log n) בממוצע, O(n) במקרה הגרוע ביותר.
- סריקה: O(n), כאשר n הוא מספר הצמתים בעץ.
מושגי BST מתקדמים
עצים בעלי איזון-עצמי
עצים בעלי איזון-עצמי הם עצי BST שמתאימים אוטומטית את המבנה שלהם כדי לשמור על איזון. זה מבטיח שגובה העץ נשאר לוגריתמי, ומספק ביצועים עקביים לכל הפעולות. עצים נפוצים בעלי איזון-עצמי כוללים עצי AVL ועצי אדום-שחור.
עצי AVL
עצי AVL שומרים על איזון על ידי הבטחה שהפרש הגבהים בין תת-העץ השמאלי והימני של כל צומת הוא לכל היותר אחד. כאשר איזון זה מופר, מתבצעים סיבובים כדי להחזיר את האיזון.
עצי אדום-שחור
עצי אדום-שחור משתמשים בתכונות צבע (אדום או שחור) כדי לשמור על איזון. הם מורכבים יותר מעצי AVL אך מציעים ביצועים טובים יותר בתרחישים מסוימים.
דוגמת קוד JavaScript: מימוש מלא של עץ חיפוש בינארי
class Node {
constructor(key) {
this.key = key;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null;
}
insert(key) {
const newNode = new Node(key);
if (this.root === null) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
insertNode(node, newNode) {
if (newNode.key < node.key) {
if (node.left === null) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
} else {
if (node.right === null) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
}
}
search(key) {
return this.searchNode(this.root, key);
}
searchNode(node, key) {
if (node === null) {
return false;
}
if (key < node.key) {
return this.searchNode(node.left, key);
} else if (key > node.key) {
return this.searchNode(node.right, key);
} else {
return true;
}
}
remove(key) {
this.root = this.removeNode(this.root, key);
}
removeNode(node, key) {
if (node === null) {
return null;
}
if (key < node.key) {
node.left = this.removeNode(node.left, key);
return node;
} else if (key > node.key) {
node.right = this.removeNode(node.right, key);
return node;
} else {
// המפתח שווה למפתח הצומת
// מקרה 1 - צומת עלה
if (node.left === null && node.right === null) {
node = null;
return node;
}
// מקרה 2 - לצומת יש ילד אחד בלבד
if (node.left === null) {
node = node.right;
return node;
} else if (node.right === null) {
node = node.left;
return node;
}
// מקרה 3 - לצומת יש 2 ילדים
const aux = this.findMinNode(node.right);
node.key = aux.key;
node.right = this.removeNode(node.right, aux.key);
return node;
}
}
findMinNode(node) {
let current = node;
while (current != null && current.left != null) {
current = current.left;
}
return current;
}
min() {
return this.minNode(this.root);
}
minNode(node) {
let current = node;
while (current !== null && current.left !== null) {
current = current.left;
}
return current;
}
max() {
return this.maxNode(this.root);
}
maxNode(node) {
let current = node;
while (current !== null && current.right !== null) {
current = current.right;
}
return current;
}
inOrderTraverse(callback) {
this.inOrderTraverseNode(this.root, callback);
}
inOrderTraverseNode(node, callback) {
if (node !== null) {
this.inOrderTraverseNode(node.left, callback);
callback(node.key);
this.inOrderTraverseNode(node.right, callback);
}
}
preOrderTraverse(callback) {
this.preOrderTraverseNode(this.root, callback);
}
preOrderTraverseNode(node, callback) {
if (node !== null) {
callback(node.key);
this.preOrderTraverseNode(node.left, callback);
this.preOrderTraverseNode(node.right, callback);
}
}
postOrderTraverse(callback) {
this.postOrderTraverseNode(this.root, callback);
}
postOrderTraverseNode(node, callback) {
if (node !== null) {
this.postOrderTraverseNode(node.left, callback);
this.postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
}
// דוגמת שימוש
const bst = new BinarySearchTree();
bst.insert(11);
bst.insert(7);
bst.insert(15);
bst.insert(5);
bst.insert(3);
bst.insert(9);
bst.insert(8);
bst.insert(10);
bst.insert(13);
bst.insert(12);
bst.insert(14);
bst.insert(20);
bst.insert(18);
bst.insert(25);
const printNode = (value) => console.log(value);
console.log("סריקה תוכית (In-order):");
bst.inOrderTraverse(printNode);
console.log("סריקה קדמית (Pre-order):");
bst.preOrderTraverse(printNode);
console.log("סריקה סופית (Post-order):");
bst.postOrderTraverse(printNode);
console.log("ערך מינימלי:", bst.min().key);
console.log("ערך מקסימלי:", bst.max().key);
console.log("חיפוש אחר 9:", bst.search(9));
console.log("חיפוש אחר 2:", bst.search(2));
bst.remove(7);
console.log("חיפוש אחר 7 לאחר הסרה:", bst.search(7));
סיכום
עצי חיפוש בינאריים הם מבנה נתונים חזק ורב-תכליתי עם יישומים רבים. מדריך זה סיפק סקירה מקיפה של BST, המכסה את המבנה שלהם, פעולות, ומימוש ב-JavaScript. על ידי הבנת העקרונות והטכניקות שנדונו במדריך זה, מפתחים ברחבי העולם יכולים להשתמש ביעילות ב-BST כדי לפתור מגוון רחב של בעיות בפיתוח תוכנה. מניהול מסדי נתונים גלובליים ועד לאופטימיזציה של אלגוריתמי חיפוש, הידע על BST הוא נכס שלא יסולא בפז עבור כל מתכנת.
ככל שתמשיכו במסעכם במדעי המחשב, חקירת מושגים מתקדמים כמו עצים בעלי איזון-עצמי והמימושים השונים שלהם תשפר עוד יותר את הבנתכם ויכולותיכם. המשיכו להתאמן ולהתנסות עם תרחישים שונים כדי לשלוט באמנות השימוש היעיל בעצי חיפוש בינאריים.