|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
קורס:"שפת C"
מתוך הספר: C - מדריך מקצועי
טיפוסים מופשטיםהכרנו את הטיפוסים הבסיסיים הקיימים בשפת C : שלם (int), ממשי (float), תו (char), מחרוזת (char []), מערך וכן תתי-טיפוסים שלהם. לעתים, הטיפוסים הבסיסיים אינם מספיקים ואנו מגדירים טיפוסים חדשים. לדוגמא, בכדי להגדיר טיפוס מחרוזת נוח, השתמשנו בהוראה typedef : typedef char String[256]; באופן זה יצרנו טיפוס מחרוזת חדש המבוסס על מערך בגודל 256 תווים. בדומה, כאשר רצינו לייצג רשימה מקושרת (ראה/י פרק 12, "הקצאת זיכרון דינמית ורשימות מקושרות") הגדרנו טיפוס רשימה ע"י typedef struct { ListNode *head; /* head of the book list */ ListNode first; /* first dummy element */ Boolean changed_fl; /* list change flag */ int count; } List; וכן הגדרנו טיפוסים ופונקציות לטיפול בצמתי הרשימה. טיפוסי נתונים מופשטים (ADT, Abstract Data Types) הם טיפוסים המורכבים מטיפוסים בסיסיים יותר וכוללים גם פונקציונליות לטיפול בנתונים. אנחנו נכיר בפרק זה את הטיפוסים המופשטים הבאים:
הפרדה בין מימוש לממשקהעיקרון החשוב ביותר ביצירת טיפוסי נתונים מופשטים הוא הפרדה בין ממשק הטיפוס, כלומר אבטיפוסי הפונקציות, הטיפוסים והקבועים שהוא מייצא לבין מימושם בפועל. עקרון זה חשוב מאוד לצורך פיתוח תוכנה באופן מודולרי: התלות בין מודולים שונים במערכת צריכה להיות מינימלית. למשל, אם מודול A משתמש במודול B, הוא אינו צריך לדעת כיצד ממומש מודול B:
יש לשים לב לכך שהמהדר אינו יודע את פרטי המימוש בזמן ההידור: הוא מבצע את בדיקות ההידור רק עפ"י הממשק. לדוגמא, בהידור המודול A, המהדר בודק שימוש בשמות פונקציות קיימות, שימוש בטיפוסים קיימים, טיפוסי פרמטרים מתאימים לפונקציות ושימוש בקבועים. כל הבדיקות הן עפ"י קובץ הממשק (B.h) של המודול B (וכמובן גם עפ"י קובץ הממשק של A עצמו, A.h). יתרון חשוב בהפרדה בין ממשק ומימוש של מודול מסוים הוא היכולת להחליף את המימוש של המודול בשלב מסוים, אם מסיבות של שיפור יעילות התכנית או עקב תיקוני שגיאות, וזאת מבלי שיהיה צורך לשנות את המודול המשתמש:
הסתרת מידעהסתרת מידע היא הטכניקה שבה מפרידים בין הממשק למימוש. אם מודול A משתמש במודול B, מודול B מייצא את כל המרכיבים (פונקציות, טיפוסים וקבועים) המהווים את הממשק אליו, ומצד שני מסתיר את מרכיבי המימוש (מבני נתונים, פונקציות פנימיות, משתנים). קיימות שפות התומכות באופן מובנה בהסתרת מידע, בעיקר שפות לתכנות מונחה עצמים. לדוגמא, שפות C++ ו- Java מאפשרות להגדיר אילו חלקים הם פרטיים (private) במודול (מחלקה) ואילו ציבוריים (public). בשפת C האפשרויות מוגבלות יותר, אך ניתן בכל זאת להסתיר מרכיבים מסוימים:
כעת למודול A גישה רק לפונקציה g() שב- B, והוא אינו "רואה" את המשתנה הגלובלי x ואת הפונקציה f(), מכיוון שהם פרטיים. אולם מודול A יכול לבצע ייצוא עצמאי ע"י הוראת extern ובכך לעקוף את קובץ הממשק של B:
הערה: בייצוא פונקציה ההוראה extern היא אופציונלית, כלומר אבטיפוס הפונקציה הוא כשלעצמו ייצוא חוקי. בדוגמא הנ"ל ניתן לרשום ייצוא עבור הפונקציה f() גם כך: void f();
המציין static גורם למשתנה גלובלי או לפונקציה להיות מקושרים באופן פנימי בלבד למודול שבו הוגדרו. השגיאות במודול A יתקבלו ע"י המקשר (linker) בזמן הקישור של A, והן יהיו "לא נמצא המשתנה הגלובלי x" ו- "לא נמצאה הפונקציה הגלובלית f()". מדד ליעילות: סיבוכיותסיבוכיות כפרמטר ביצועימשווים בין סוגי מבני נתונים ואלגוריתמים עפ"י סיבוכיות הביצוע של פעולות שונות עליהם, כלומר עפ"י משך הזמן וכמות הזיכרון שהן צורכות. הסיבוכיות מצויינת ע"י סדר גודל בלבד. נהוג לציין את הסיבוכיות ע"י האות O (קיצור של Order) בצירוף סדר הסיבוכיות בסוגריים: (<סדר של n>O(. דוגמאות:
n n/2 3n 4n 23n + 255 2000000n + 60000 כלומר, בחישוב הסיבוכיות לא מתחשבים בקבועים ובגורמים המעורבים בביטוי, אלא רק בסדר של n בביטוי.
5n<sup>2</sup> 3n<sup>2 </sup>+ 2n + 5
23 0 1000000000 בטבלה שבעמ' 354 מובאים סדרי הסיבוכיות הקיימים. סוגי סיבוכיותקיימים שני סוגי סיבוכיות: סיבוכיות זמן וסיבוכיות מקום. סיבוכיות זמןסיבוכיות זמן מתארת את סדר הגודל של כמות הזמן הנדרשת לביצוע פעולה נתונה על מבנה נתונים נתון. דוגמאות:
סיבוכיות מקוםסיבוכיות מקום מתארת את סדר הגודל של כמות הזיכרון הנדרשת לביצוע פעולה נתונה על מבנה נתונים נתון. דוגמאות:
השוואה בין מערך ורשימההטבלה הבאה משווה בין מערך ורשימה מבחינת סיבוכיות הזמן בביצוע פעולות שונות על איבריהם:
מבנה יישום ADTקובץ הגדרות כלליכאשר כותבים תוכנה הכוללת מספר רב של מודולים, מקובל להציב הגדרות בסיסיות משותפות בקובץ יחיד, ולהכלילו בכל המודולים. דוגמאות להגדרות כאלו הן הגדרת מחרוזת כמערך, טיפוס בוליאני ועוד. לדוגמא, נגדיר קובץ בשם base.h באופן הבא: /* base.h - basic type and constant definitions */ #ifndef BASE_H #define BASE_H /**************** TYPES *******************************/ /* a boolean type */ typedef enum {FALSE=0, TRUE=1} Boolean; /* a String type */ typedef char String[256]; #endif טיפוס איבר כללי Dataבטיפוסי הנתונים המופשטים נטפל באיבר מסוג כללי שיוגדר כך: /* Data type */ typedef struct { int ID; float value; } Data; typedef Data * DataPtr;
הטיפוס Data - המקביל לטיפוס Book מהפרקים הקודמים - ישמש כשם טיפוס כללי לנתונים כלשהם: בדוגמא זו, המבנה Data מכיל שני נתונים - שלם וממשי.
קבצי הממשק והמימוש מובאים בעמ' 356-357. רשימה מקושרתבפרק 12, "הקצאת זיכרון דינמית ורשימות מקושרות", הכרנו את מבנה הרשימה המקושרת ומימשנו באמצעותה את רשימת הספרים בתכנית הספרייה. בסעיף זה נגדיר רשימה מקושרת כללית, המתאימה לא רק לספרים - אלא לטיפול ברשומת Data כללית:
הממשק הנדרש מרשימה מקושרת כללית:
תרשים פעולות ההכנסה וההוצאה:
קובץ הממשק, list.hקובץ הממשק מובא בעמ' 359. קובץ המימוש, list.cקובץ המימוש מובא בעמ' 360-363, עפ"י חלוקה לפונקציות פרטיות וציבוריות, כלומר, פונקציות שאינן מיוצאות וכאלו שמיוצאות. תכנית הבדיקה מובאת בעמ' 363-365. סיבוכיות זמן פעולות על הרשימה המקושרתסיבוכיות הרשימה המקושרת היא מסדר גודל קבוע (O(1)) עבור פעולות הכנסה והוצאה בתחילת הרשימה. הכנסה והוצאה מסופה דורשות מעבר על כלל צמתי הרשימה עד להגעה לסופה, ולכן גם הן מסיבוכיות O(n) (ראה/י תרגיל להלן). הכנסת איבר באופן ממוין דורשת, בממוצע, מעבר על n/2 איברים, ולכן גם היא מסיבוכיות O(n). הטבלה הבאה מציגה את סיבוכיות הפעולות על הרשימה המקושרת:
תרגולקרא/י סעיף זה בספר ובצע/י את תר' 1-3 שבעמ' 366. מחסניתמחסנית (Stack) היא מבנה נתונים שאיבריו נשלפים בסדר ההפוך מזה שבו הוכנסו:
סדר זה של הכנסת והוצאת נתונים נקרא LIFO (Last In First Out) - האיבר האחרון שהוכנס מוצא ראשון. שימושים נפוצים למחסנית הם ייצוג פונקציות בזמן ריצה (מחסנית הקריאות) וניתוח תחבירי במהדרים. בכדי לממש מחסנית של איברים מסוג מצביע לטיפוס Data, נגדיר מבנה Stack באופן הבא: #define STACK_MAX_SIZE 500 /* stack structure */ typedef struct { DataPtr array[STACK_MAX_SIZE]; int count; } Stack;
הסבר: המחסנית מוגדרת כמערך של מצביעים לטיפוס Data, DataPtr. המשתנה count מציין את מספר האיברים הקיימים בפועל במחסנית. פעולות על המחסניתנגדיר את הפעולות הבאות על המחסנית:
הפונקציה להחזרת האיבר העליון (stack_top) מחזירה את האיבר שבראש המחסנית מבלי להוציאו. ממשק הפונקציות: void stack_init(Stack *pstack); void stack_clear(Stack *pstack); void stack_push(Stack *pstack, DataPtr pdata); DataPtr stack_pop(Stack *pstack); DataPtr stack_top(Stack *pstack); Boolean stack_is_empty(Stack *pstack); Boolean stack_is_full(Stack *pstack); void stack_print(Stack *pstack); מימוש המחסנית ע"י מערךאנו נראה כעת מימוש של פעולות המחסנית תוך שימוש במערך לשמירת האיברים:
בשלב מאוחר יותר נראה מימוש שונה של המחסנית ע"י רשימה מקושרת, וזאת מבלי לשנות את ממשק הפעולות על המחסנית. הפונקציה stack_init מאתחלת את המחסנית: void stack_init(Stack *pstack) { pstack->count = 0; } הפונקציה stack_clear מנקה ומשחררת את נתוני המחסנית: void stack_clear(Stack *pstack) { int i; for(i=0; i<pstack->count; i++) data_clear(pstack->array[i]); pstack->count = 0; } הפונקציה stack_push דוחפת איבר לראש המחסנית (כולל בדיקה שהמחסנית לא מלאה): void stack_push(Stack *pstack, DataPtr pdata); הפונקציה stack_pop מוציאה איבר מראש המחסנית ומחזירה אותו (אם המחסנית ריקה מוחזר NULL): DataPtr stack_pop(Stack *pstack); הפונקציה stack_top מחזירה את האיבר שבראש המחסנית, מבלי להוציא אותו (אם המחסנית ריקה מוחזר NULL): DataPtr stack_top(Stack *pstack); הפונקציה stack_is_empty מחזירה ערך בוליאני - האם המחסנית ריקה? Boolean stack_is_empty(Stack *pstack); הפונקציה stack_is_full מחזירה ערך בוליאני - האם המחסנית מלאה? Boolean stack_is_full(Stack *pstack); הפונקציה stack_print עוברת ומדפיסה את כל איברי המחסנית, החל מהתחתית : void stack_print(Stack *pstack); עיין/י בקוד הפונקציות המלא בעמ' 369-370. פונקצית הבדיקה מובאת בעמ' 370-372. מימוש המחסנית ע"י רשימהמבנה הנתונים מחסנית אינו מחייב מימוש מסוים - בדוגמא הקודמת, מימשנו את המחסנית ע"י מערך בעל גודל קבוע, אך ניתן למשל לממש אותה ע"י רשימה מקושרת:
מבנה המחסנית מכיל כעת משתנה מסוג רשימה מקושרת, list. דרך משתנה זה יבוצעו כל הפעולות על המחסנית. בקובץ הממשק של המחסנית, list_l.h (הסיומת _l מציינת מימוש ע"י רשימה), יוכרז מבנה המחסנית כך: typedef struct { List list; } Stack; שים/י לב: ממשק הפונקציות של המחסנית נשאר כפי שהיה - התכנית המשתמשת במחסנית אינה משתנה כתוצאה משינוי המימוש! נראה כעת מספר פונקציות בקובץ המימוש, stack_l.c :
void stack_push(Stack *pstack, DataPtr pdata) { if(stack_is_full(pstack)) return; list_push_front(&pstack->list, pdata); }
DataPtr stack_pop(Stack *pstack) { if(stack_is_empty(pstack)) return NULL; else return list_pop_front(&pstack->list); }
DataPtr stack_top(Stack *pstack) { if(stack_is_empty(pstack)) return NULL; else return pstack->list.head->next->pdata; }
Boolean stack_is_full(Stack *pstack) { return FALSE; }
פונקצית הבדיקהפונקצית הבדיקה היא כמו קודם מכיוון שתכנית הבדיקה לא השתנתה כתוצאה משינוי מימוש המחסנית! סיבוכיות זמן פעולות על המחסניתכפי שניתן לראות, המחסנית היא מבנה נתונים פשוט יחסית המיועד למצב בו מדיניות הכנסה והוצאת הנתונים היא LIFO. לכן פעולות ההכנסה וההוצאה מהמחסנית הן בעלות סיבוכיות O(1). הטבלה הבאה מציגה את סיבוכיות הפעולות:
תרגילקרא/י סעיף זה בספר ובצע/י את תר' 1-3 שבעמ' 375. תורתור (Queue) הוא מבנה נתונים בעל מדיניות "נכנס-ראשון-יוצא-ראשון" התור, כשמו, שימושי למתן שרות עפ"י סדר הגעה:
בדומה למחסנית, ניתן לממש תור הן כמערך סטטי והן ע"י רשימה מקושרת. אנו נייצג את התור ע"י רשימה מקושרת בלבד: typedef struct { List list; } Queue;
תרשים התור ממומש ע"י רשימה:
פעולות על התורנגדיר את הפעולות הבאות על התור:
הכנסת איבר חדש מבוצעת בסוף הרשימה, והוצאת האיבר הראשון בתור מבוצעת ע"י הוצאת האיבר מתחילתה. התרשים הבא מציג את כוון ההכנסה וההוצאה מהתור הממומש ע"י רשימה:
הפונקציה להחזרת האיבר העליון (queue_top) מחזירה את האיבר שבראש התור מבלי להוציאו. בתרשים הנ"ל, הפונקציה תחזיר את האיבר 111. ממשק הפונקציות: void queue_init(Queue *pqueue); void queue_clear(Queue *pqueue); void queue_push(Queue *pqueue, DataPtr pdata); DataPtr queue_pop(Queue *pqueue); DataPtr queue_top(Queue *pqueue); Boolean queue_is_empty(Queue *pqueue); void queue_print(Queue *pqueue); מימוש הפונקציות:
void queue_init(Queue *pqueue) { list_init(&pqueue->list); }
void queue_clear(Queue *pqueue) { list_clear(&pqueue->list); }
void queue_push(Queue *pqueue, DataPtr pdata) { list_push_back(&pqueue->list, pdata); }
DataPtr queue_pop(Queue *pqueue) { if(queue_is_empty(pqueue)) return NULL; else return list_pop_front(&pqueue->list); } עיין/י בקוד שאר הפונקציות בהמשך. פונקצית הבדיקהפונקצית הבדיקה של התור מובאת בעמ' 378-379. סיבוכיות זמן פעולות על התורסיבוכיות ההכנסה בסוף התור, הממומש ע"י רשימה מקושרת, היא O(n). לעומת זאת ההוצאה מראש התור היא מיידית ומסיבוכיות O(1). פעולות הניקוי וההדפסה הן מסדר גודל של מספר האיברים בתור (O(n)). הטבלה הבאה מציגה את סיבוכיות הפעולות על התור:
תרגולקרא/י סעיף זה בספר ובצע/י את תר' 1-3 שבעמ' 380.
עץ בינריעץ בינרי (Binary Tree) הוא מבנה נתונים הבנוי בצורת עץ הפוך: שורש העץ מצוייר למעלה וה"ענפים" למטה:
כל צומת בעץ מכיל מצביע לנתונים, pdata, ושני מצביעים לשני צמתים בנים: בן שמאלי (left son) ובן ימני (right son). הצומת שבראש העץ הוא צומת השורש (root). ערכי הנתונים מסודרים כך שהערך בכל צומת הוא מקסימלי ביחס לכל הצמתים שבתת-העץ השמאלי שלו, ומינימלי ביחס לאלו שבתת-העץ הימני שלו. מבנה הצומת מוגדר כך: typedef struct BTreeNode_tag { DataPtr pdata; struct BTreeNode_tag *left; struct BTreeNode_tag *right; } BTreeNode; אם לצומת אין בן מסוים, ערך המצביע לבן יהיה NULL. מבנה העץ בכללותו מוגדר כך: typedef struct { BTreeNode *root; /* root of the binary tree */ int count; /* number of elements in the tree */ } BTree; מבנה העץ מכיל מצביע לצומת שבשורש העץ ומונה מספר האיברים. פעולות על העץנגדיר את הפעולות הבאות על העץ:
אתחול העץהפונקציה הבאה מאתחלת מבנה עץ המועבר לה כפרמטר: void btree_init(BTree *pbtree) { pbtree->root = NULL; pbtree->count = 0; } שורש העץ מצביע ל- NULL ומספר האיברים מאופס. הוספת איבר לעץבכדי להוסיף איבר למקום המתאים בעץ, עלינו להשוות את ערכו עם זה של כל צומת, החל בשורש.
פעולה זו מתרחשת ברקורסיה: במעבר לבן השמאלי או הימני מבוצעת ההשוואה מחדש, כמו בצומת האב. ושוב, אם ערך האיבר החדש גדול מזה של הצומת עוברים לבן הימני, אחרת לבן השמאלי. פעולת הרקורסיה נעצרת כאשר אנו מגיעים לנתיב המוביל ל- NULL, כלומר לקצה העץ, ואז פשוט מוסיפים את האיבר החדש ע"י הוספת צומת חדש לעץ וקישורו במקום המתאים. לדוגמא, בהינתן העץ הקודם, נניח שמוכנס איבר חדש עם ערך 5:
תרשים העץ לאחר ההוספה:
עקב האופי הרקורסיבי של הטיפול באיברים בהכנסה ובהוצאת איברים מהעץ, הגדרת פונקציות רקורסיביות היא הדרך הפשוטה והטבעית לכך. לתזכורת לגבי פונקציות רקורסיביות, ראה/י פרק 6, "פונקציות", סעיף "רקורסיה". הקצאת צומת ואתחולוראשית, נגדיר פונקציה המקצה ומאתחלת צומת בודד: static BTreeNode *btree_new_node(DataPtr pdata) { BTreeNode *pnode = malloc(sizeof(BTreeNode)); pnode->pdata = pdata; pnode->left = NULL; pnode->right = NULL; return pnode; } הסבר: הפונקציה מקצה זיכרון עבור צומת חדש ומציבה לו את הפרמטר, מצביע ל- Data, כערך הצומת. לאחר מכן מאופסים המצביעים לבנים. הערך המוחזר של הפונקציה הוא המצביע לצומת החדש. הפונקציה מוגדרת כסטטית (static) מכיוון שהיא לצורך פנימי של מודול העץ הבינרי, כלומר פונקציה פרטית. הוספה רקורסיבית לעץלצורך הוספת איבר לעץ, נגדיר פונקציה פרטית המקבלת כפרמטרים מצביע לתת-עץ (כלומר, צומת כלשהו בעץ) וצומת חדש להוספה. הפונקציה בודקת את ערך הצומת החדש ביחס לערך שבצומת, ובהתאם מוסיפה אותו בצידו השמאלי או הימני: static void btree_add_to_subtree(BTreeNode *root, BTreeNode *pnode) { if(data_compare(pnode->pdata, root->pdata) < 0) /* add as left son */ { if(root->left == NULL) root->left = pnode; else btree_add_to_subtree(root->left, pnode); } else /* add as right son */ { if(root->right == NULL) root->right = pnode; else btree_add_to_subtree(root->right, pnode); } } הסבר: לאחר השוואת ערך הצומת החדש עם זה של שורש תת-העץ, נבחר הצד המתאים בעץ - שמאל או ימין. אם נבחר הצד השמאלי, בודקים אם קיים בן:
בדיקה מקבילה מבוצעת בצד הימני של העץ. הפונקציה btree_add_to_subtree קוראת לעצמה באופן רקורסיבי עד להגעה ל"עלה" בעץ - כלומר לצומת שאין לו בן ימני/שמאלי - ומוסיפה את הצומת החדש במקום המתאים. מודול העץ הבינרי מספק פונקציה ציבורית להוספת נתון לעץ. פונקציה זו קוראת ל- btree_add_to_subtree להוספה רקורסיבית לעץ: void btree_add(BTree *pbtree, DataPtr pdata) { if(btree_is_empty(pbtree)) pbtree->root = btree_new_node(pdata); else btree_add_to_subtree(pbtree->root, pdata); pbtree->count++; } האם איבר מסוים קיים בעץכדי לבדוק אם איבר מסוים קיים בעץ יש צורך לסרוק את העץ החל מהשורש כלפי מטה, ובכל צומת להשוות את ערך האיבר עם זה של הצומת:
הפונקציה הפרטית btree_exist_in_subtree מבצעת בדיוק את האלגוריתם הנ"ל באופן רקורסיבי. הפונקציה מקבלת כפרמטר מצביע לתת-עץ ומצביע לנתון. static Boolean btree_exist_subtree(BTreeNode *root, DataPtr pdata); קוד הפונקציה מובא בעמ' 385. פונקציה זו נקראת ע"י הפונקציה הציבורית, btree_exist, הבודקת קיום של איבר בעץ ע"י קריאה ל- btree_exist_subtree עם מצביע לשורש: Boolean btree_exist(BTree *pbtree, DataPtr pdata) { return btree_exist_subtree(pbtree->root, pdata); } שחרור זיכרון העץגם מחיקת איברי העץ מבוצעת ע"י פונקציה רקורסיבית פרטית: static void btree_del_subtree(BTreeNode *root) { if(root==NULL) return; btree_del_subtree(root->left); btree_del_subtree(root->right); data_clear(root->pdata); /* free the data */ free(root); /* free the node */ } הסבר: הפונקציה btree_del_subtree היא פונקציה פרטית של המודול, הנקראת ע"י הפונקציה הציבורית btree_clear (להלן). הפונקציה בודקת את תנאי העצירה של הרקורסיה - הגעה לצומת NULL - וחוזרת אם התשובה חיובית: if(root==NULL) return; בשלב הבא היא מבצעת קריאה רקורסיבית לעצמה למחיקת תת-העץ השמאלי ואח"כ הימני: btree_del_subtree(root->left); btree_del_subtree(root->right); לבסוף משוחררים נתוני הצומת הנוכחי והצומת עצמו: data_clear(root->pdata); /* free the data */ free(root); /* free the node */ הפונקציה הרקורסיבית brtee_del_subtree נקראת ע"י הפונקציה btree_clear המוחקת את כלל העץ ומאפסת את המבנה ע"י קריאה ל- btree_init: void btree_clear(BTree *pbtree) { btree_del_subtree(pbtree->root); btree_init(pbtree); } סיורים רקורסיביים בעץבהתאם למבנה של עץ בינרי קיימות שלוש דרכים לסריקה רקורסיבית של האיברים שבו:
סיור Pre-Orderלדוגמא, הפונקציה הרקורסיבית (הפרטית) הבאה מדפיסה תת-עץ נתון ב- pre-order: static void btree_preorder(BTreeNode *root) { if(root) { data_output(root->pdata); btree_preorder(root->left); btree_preorder(root->right); } } הסבר: הפונקציה מקבלת כפרמטר מצביע לצומת שורש, root. אם המצביע אינו NULL מבצעים בסדר הבא:
data_output(root->pdata);
btree_preorder(root->left);
btree_preorder(root->right);
תנאי העצירה של הרקורסיה הוא הגעה ל- NULL. תרשים הסיור:
פלט הפונקציה עבור העץ שבתרשים: ID=9, value=99.000000 ID=7, value=77.000000 ID=4, value=44.000000 ID=1, value=11.000000 ID=5, value=55.000000 ID=8, value=88.000000 ID=13, value=133.000000 ID=11, value=111.000000 ID=10, value=100.000000 ID=17, value=177.000000 ID=22, value=222.000000 סיור in-orderהפונקציה btree_inorder() סורקת את צמתי העץ ומדפיסה את ערכיהם ב- In-Order: static void btree_inorder(BTreeNode *root) { if(root) { btree_inorder(root->left); data_output(root->pdata); btree_inorder(root->right); } } תרשים הסיור:
פלט הפונקציה: ID=1, value=11.000000 ID=4, value=44.000000 ID=5, value=55.000000 ID=7, value=77.000000 ID=8, value=88.000000 ID=9, value=99.000000 ID=10, value=100.000000 ID=11, value=111.000000 ID=13, value=133.000000 ID=17, value=177.000000 ID=22, value=222.000000
סיור Post-Orderהפונקציה btree_postorder() מבצעת סיור Post-Order: static void btree_postorder(BTreeNode *root) { if(root) { btree_postorder(root->left); btree_postorder(root->right); data_output(root->pdata); } } תרשים הסיור:
פלט הפונקציה: ID=1, value=11.000000 ID=5, value=55.000000 ID=4, value=44.000000 ID=8, value=88.000000 ID=7, value=77.000000 ID=10, value=100.000000 ID=11, value=111.000000 ID=22, value=222.000000 ID=17, value=177.000000 ID=13, value=133.000000 ID=9, value=99.000000 פונקציה להדפסה כללית של העץהפונקציה הציבורית btree_print() מדפיסה את איברי העץ בכל שלוש הצורות, ע"י קריאה מתאימה לכל אחת מהפונקציות הפרטיות. קוד הפונקציה מובא בעמ' 390. פונקצית הבדיקהפונקצית הבדיקה של העץ הבינרי מובאת בעמ' 391. סיבוכיות זמן פעולות על העץ הבינריסיבוכיות פעולות ההכנסה והחיפוש של איבר בעץ תלויה בגובה העץ, h: בממוצע, בכדי להכניס איבר לעץ או בחיפושו נידרש לעבור על h/2 איברים, ולכן הסיבוכיות היא O(h). שאלה: מהו גבהו של העץ, h, ביחס למספר האיברים, n? תשובה: אם נניח שלכל צומת שני בנים והעץ מאוזן, אזי נתונה הנוסחה: n = 2h - 1 כלומר, בכל רמה בעץ גדל מספר האיברים פי 2. לדוגמא, עבור עץ בגובה 3:
מספר האיברים הוא: n = 23 - 1 = 7 מכיוון שאנו עוסקים בסיבוכיות, ניתן להשמיט את המספר 1 מהמשוואה n = 2h וכעת ניתן לבטא את h ע"י לוגריתם: h = log n בסיס הלוגריתם הוא 2, אך נהוג להשמיטו בחישובי סיבוכיות. לכן סיבוכיות הכנסה וחיפוש של איבר בעץ הבינרי היא O(log n). פעולות הניקוי וההדפסה הן מסיבוכיות O(n) מכיוון שהן מחייבות מעבר על כלל האיברים. הטבלה הבאה מציגה את סיבוכיות הפעולות על העץ:
סוגי עצים נוספיםקיימים עצים מסוגים שונים ובוריאציות שונות, בין הנפוצים:
תרגילקרא/י סעיף זה בספר ובצע/י את התרגיל שבעמ' 393. סיכום
תרגילי סיכוםבצע/י את תרגילי הסיכום שבסוף פרק זה.
|