 
        שיא הסי-ים
)(C/C++
)חלק ב'(
מאת :יורם ביברמן
© כל הזכויות שמורות למחבר.
אין לעשות כל שימוש מסחרי בספר זה או בקטעים ממנו .ניתנת הרשות להשתמש
בו לצורכי לימוד של המשתמש בלבד.
1
 .11מצביעים ומערכים 7 .....................................................................................
עקרונות בסיסיים 7 ..........................................................................
11.1
הדמיון והשוני בין מצביע לבין מערך 9 ................................................
11.2
נוטציה מערכית לעומת נוטציה מצביעית 11 .............................
11.2.1
פונקציות המקבלות מצביעים או מערכים 15 ......................................
11.3
מצביע כפרמטר הפניה 19 ..................................................................
11.4
פונקציה המחזירה מצביע 22 .............................................................
11.5
שינוי גודלו של מערך 24 ....................................................................
11.6
מערך של מצביעים כפרמטר ערך 25 ...................................................
11.7
מערך של מצביעים כפרמטר הפניה 28 ................................................
11.8
מצביעים במקום פרמטרי הפניה 31 ...................................................
11.9
11.10
מצביע מדרגה שנייה )**  (intכפרמט קבוע )33 ......................... (const
פרמטרים המועברים ל argc :main -ו33 ............................ argv -
11.11
תרגילים 35 ..................................................................................
11.12
תרגיל מספר אחד :איתור תת-מערך במערך35 .........................
11.12.1
תרגיל מספר שתיים :מסד נתוני משפחות 36 ............................
11.12.2
תרגיל מספר שלוש :טיפול בסיסי במערכים ומצביעים 36 ..........
11.12.3
38 .......................................................................................... struct
12
מוטיבציה 38 ...................................................................................
12.1
12.2
העברת  structכפרמטר לפונקציה 39 ..............................................
מערך של 41 ....................................................................... struct
12.3
מצביע ל struct -כפרמטר ערך 45 ...................................................
12.4
מצביע ל struct -כפרמטר הפניה 46 ................................................
12.5
תרגילים 48 .....................................................................................
12.6
תרגיל מספר אחד :פולינומים 48 .............................................
12.6.1
תרגיל מספר שתיים :סידור כרטיסים בתבנית רצויה 49 ............
12.6.2
תרגיל מספר שלוש :בעיית מסעי הפרש 49 ................................
12.6.3
תרגיל מספר ארבע :נתוני משפחות50 ......................................
12.6.4
תרגיל מספר חמש :סימולציה של רשימה משורשרת 52 .............
12.6.5
 13רשימות מקושרות )משורשרות( 54 .................................................................
 13.1בניית רשימה מקושרת 54 ........................................................................
הצגת הנתונים השמורים ברשימה מקושרת 56 ....................................
13.2
בניית רשימה מקושרת ממוינת 58 .....................................................
13.3
מחיקת איבר מרשימה 67 .................................................................
13.4
שחרור תאי רשימה 68 ......................................................................
13.5
היפוך רשימה 69 ..............................................................................
13.6
בניית רשימה ממוינת יחידאית מרשימה לא ממוינת 74 ........................
13.7
מיון מיזוג של רשימות משורשרות 77 .................................................
13.8
 13.8.1הצגת אלגוריתם המיון :מיון מיזוג 77 ................................................
 13.8.2מיון מיזוג של רשימות משורשרות 81 .................................................
 13.8.3זמן הריצה של מיון מיזוג 84 ..............................................................
בניית רשימה מקושרת ממוינת בשיטת המצביע למצביע 86 .................
13.9
 13.10הערה בנוגע ל93 ..................................................................... const-
תרגילים 95 ..................................................................................
13.11
תרגיל מספר אחד :רשימות משורשרות של משפחות95 .............
13.11.1
תרגיל מספר שתיים :איתור פרמידה ברשימה 97 ......................
13.11.2
תרגיל מספר שלוש :רשימה מקושרת דו-כיוונית 98 .................
13.11.3
תרגיל מספר ארבע :רשימה מקושרת עם מיון כפול98 ..............
13.11.4
2
תרגיל מספר חמש :רשימה מקושרת עם מיון כפול 99 ..............
13.11.5
תרגיל מספר שש :מאגר ציוני תלמידים 100 .............................
13.11.6
תרגיל מספר שבע :מיון הכנסה של רשימה מקושרת 101 ..........
13.11.7
תרגיל מספר שמונה :פולינומים102 ........................................
13.11.8
תרגיל מספר תשע :הפרדת רשימות משורשרות שהתמזגו102 ....
13.11.9
תרגיל מספר עשר :בדיקת איזון סוגריים 103 .........................
13.11.10
תרגיל מספר אחת-עשרה :המרת תא ברשימה ברשימה 104 .....
13.11.11
 13.11.12תרגיל מספר שתים-עשרה :מחיקת איבר מינימלי מסדרות
המרכיבות רשימה מקושרת 104 .................................................................
 14עצים בינאריים 106 ......................................................................................
 14.1הגדרת עץ בינארי 106 .............................................................................
מימוש עץ בינארי בתכנית מחשב 108 .................................................
14.2
בניית עץ חיפוש בינארי 109 ...............................................................
14.3
 14.4ביקור בעץ חיפוש בינארי 122 ...................................................................
 14.5חיפוש ערך מבוקש בעץ בינארי כללי ובעץ חיפוש בינארי 124 .......................
 14.6ספירת מספר הצמתים בעץ בינארי 126 .....................................................
 14.7מציאת עומקו של עץ בינארי 127 ..............................................................
 14.8האם עץ בינארי מקיים שלכל צומת פנימי יש שני בנים 128 .........................
 14.9האם עץ בינארי מקיים שעבור כל צומת סכום הערכים בבת-העץ השמאלי
קטן או שווה מסכום הערכים בתת-העץ הימני 129 ............................................
 14.11מחיקת צומת מעץ בינארי 134 ...............................................................
 14.11.1תיאור האלגוריתם 134 ...................................................................
 14.11.2המימוש 135 ..................................................................................
 14.12כמה זה עולה? 143 ................................................................................
 14.12.1הוספת נתון 143 .............................................................................
 14.12.2מחיקת נתון 144 .............................................................................
 14.12.3חיפוש נתון 144 ..............................................................................
 14.13האם עץ בינארי מלא 144 .......................................................................
 14.14האם בכל הצמתים בעץ השוכנים באותה רמה מצוי אותו ערך 150 .............
 14.14.1הפונקציה151 ...................................... equal_vals_at_level :
 14.14.2הפונקציה151 ........................................ get_val_from_level :
 14.14.3הפונקציה156 .......................................................check_level :
 14.14.4זמן הריצה של 157 ................................. equal_vals_at_level
 14.14.5פתרון חלופי לבעיה158 ...................................................................
 14.15האם כל הערכים בעץ שונים זה מזה 158 .................................................
 14.16תרגילים 160 ........................................................................................
 13.14.1תרגיל ראשון :עץ 160 ............................................................. radix
 13.14.2תרגיל שני :מציאת קוטר של עץ 161 .................................................
 13.14.3תרגיל שלישי :מה עושה התכנית? 162 ..............................................
 13.14.4תרגיל רביעי :בדיקה האם עץ א' משוכן בעץ ב' 162 ............................
 13.14.5תרגיל חמישי :תת-עץ מרבי בו השורש גדול מצאצאיו 164 ..................
 13.14.6תרגיל שישי :בדיקה האם תת-עץ שמאלי מחלק כל שורש ,וכל שורש
מחלק תת-עץ ימני 165 ................................................................................
 13.14.7תרגיל שביעי :בדיקה האם עץ סימטרי 165 .......................................
 13.14.8תרגיל שמיני :איתור הערך המזערי בעץ 166 ......................................
 13.14.9תרגיל תשיעי :עץ ביטוי )עבור ביטוי אריתמטי( 166 ............................
 13.14.10תרגיל עשירי :חישוב מספר עצי החיפוש בני  nצמתים 167 ................
 13.14.11תרגיל מספר אחד-עשר :איתור הצומת העמוק ביותר בעץ 167 ..........
 13.14.12תרגיל מספר שניים-עשר :בדיקה האם עץ כמעט מלא168 .................
3
 13.14.13תרגיל מספר שלושה-העשר :האם כל תת-עץ מכיל מספר זוגי של
צמתים 168 ...............................................................................................
 13.14.14תרגיל מספר ארבעה-עשר :שמירת עומקם של עלי העץ במערך 168 ....
 15מצביעים לפונקציות 170 ...............................................................................
 15.1דוגמה פשוטה ראשונה למצביע לפונקציה 170 ...........................................
 15.2דוגמה שניה למצביע לפונקציה 172 ..........................................................
אפשרות א' לכתיבת פונקצית המיון :שימוש בדגל בלבד 172 ......
15.2.1
אפשרות ב' לכתיבת פונקצית המיון :שימוש בפונקציות השוואה
15.2.2
174
ובדגל
אפשרות ג' לכתיבת פונקצית המיון :שימוש במצביע לפונקציה
15.2.3
176
תרגילים 180 ...................................................................................
15.5
 15.3.1תרגיל מספר אחד :האם פונקציה אחת גדולה יותר משניה בתחום
כלשהו 180 ................................................................................................
 15.3.2תרגיל מספר שתיים :מציאת שורש של פונקציה 181 ............................
 .16מצביעים גנריים )* 182 ........................................................................ (void
 16.1דוגמה ראשונה :פונ' המשתמשת במצביע גנרי 182 .....................................
 16.2דוגמה שניה ,משופרת :פונ' המשתמשת במצביע גנרי183 ............................
 16.3דוגמה שלישית :פונ' מיון גנרית 186 .........................................................
 16.4דוגמה שלישית :בניית רשימה מקושרת ממוינת גנרית 189 .........................
 .17חלוקת תכנית לקבצים 195 ...........................................................................
 17.1הנחיית הקדם מהדר 195 ............................................................. #define
 17.2הנחיית הקדם מהדר 198 ............................................................... #ifdef
 17.3הנחיית הקדם מהדר 199 ............................................................ #include
 17.4חלוקת תכנית לקבצים ,דוגמה א'202 .......................................................
 17.4.1הקבצים שנכללים בתכנית 202 ..........................................................
 17.4.2הידור התכנית 205 ...........................................................................
 17.4.3שימוש בקובצי כותר )208 ...................................................... (header
 17.5חלוקת תכנית לקבצים ,דוגמה ב' :תרומתה של הנחיית המהדר 210 ... ifndef
 17.5.1הקובץ 211 ............................................................................Point.h
 17.5.2הקובץ 212 .......................................................................... Point.cc
 17.5.3הקובץ 213 ..................................................................... Rectangle.h
 17.5.4הקובץ 214 .................................................................... Rectangle.cc
 17.5.5הקובץ 214 ..................................................................... my_prog.cc
 17.5.6בעיות ההידור המתעוררות והתיקונים הדרושים בעזרת 215 ...... #ifndef
 17.5.7קובץ ה makefile -לפרויקט 217 ..........................................................
 17.6קבועים גלובליים 217 .............................................................................
 17.7מבוא לתכנות מונחה עצמים :עקרון הכימוס 219 .......................................
 17.8יצירת ספריה סטאטית 220 .....................................................................
 17.9יצירת ספריה דינאמית )משותפת( 221 ......................................................
4
ליעננ
שבכל אחד מאיתנו,
לנ.
של כולנו
ולמאבק איתם ובהם
ָב ֶע ֶרב
ֲר ִתי
אָמ ָרה ִלי ַנע ָ
ְכ ֶש ְ
ֵל ְך
ָר ְד ִתי ָל ְרחוֹב ְל ִה ְת ַה ֵל ְך
יַ
וּמ ְס ַת ֵב ְך
הוֹל ְך ִ
יתי ֵ
וְ ָהיִ ִ
הוֹל ְך
ִמ ְס ַת ֵב ְך וְ ֵ
וּמ ְס ַת ֵב ְך
הוֹל ְך ִ
הוֹל ְך וְ ֵ
וְ ֵ
)נתן זך(
5
6
עודכן 1/2011
 .11מצביעים ומערכים
בקצרה ובפשטות ניתן לומר כי מצביע הוא משתנה המכיל כתובת של משתנה אחר,
במילים אחרות מצביע הוא משתנה אשר ַמפנה אותנו למשתנה אחר.
 11.1עקרונות בסיסיים
נניח כי בתכנית כלשהי הגדרנו את המשתנים הבאים:
; int num1, num2, *intp1, *intp2
מה קיבלנו? קיבלנו ארבעה משתנים ,טיפוסם של שני הראשונים ביניהם מוכר לנו
היטב מימים ימימה :אלה הם משתנים אשר מסוגלים לשמור מספר שלם; טיפוסם
של השניים האחרונים ,אלה שלפני שמם כתבנו כוכבית )*( ,הוא *  ,intומהותם
חדשה לנו :משתנים אלה הם מצביעים לתאי זיכרון )כלומר למשתנים( המכילים
מספרים שלמים .ראשית ,נצייר כיצד נראית המחסנית בעקבות הגדרת המשתנים
הללו ,ואחר נמשיך בהסבר .שימו לב כי מצביעים אנו מציירים עם חץ שראשו הוא
בצורת  ,vוזאת בניגוד לפרמטרי הפניה אותם אנו מציירים עם חץ שראשו הוא
משולש שחור:
num1
num2
intp1
intp2
המשתנים  num1, num2מסוגלים לשמור מספר שלם ,על-כן נוכל לכתוב:
; num1 = 17; num2 = 3879ובתאי הזיכרון המתאימים ישמרו הערכים
הרצויים .מצב המחסנית אחרי ביצוע שתי השמות אלה יהיה:
num1= 17
num2 = 3879
intp1
intp2
7
לעומת זאת intp1, intp2 ,הם מצביעים .עליהם אנו רשאים לבצע פעולה כגון:
; . intp1 = &num1נסביר את משמעות הפעולה :אופרטור ה & -מחזיר את
כתובתו של המשתנה )של האופרנד( עליו הוא מופעל ,במקרה שלנו הוא מחזיר את
כתובתו של  ,num1כתובת זאת אנו משימים למשתנה  ,intp1ועל כן עתה intp1
מצביע על  ,num1מבחינה ציורית נציג זאת:
num1= 17
num2 = 3879
intp1
intp2
עתה אנו יכולים לפנות לתא הזיכרון הקרוי  num1גם באופן הבא . *intp1 :למשל
אנו רשאים לכתוב:
; *intp1 = 0והדבר יהיה שקול לכך שנכתוב:
; . num1 = 0אנו גם רשאים לכתוב , (*intp1)++; :והדבר יהיה שקול
לפקודה . num1++; :כלומר  *intp1הוא עתה שם נרדף ל .num1 -נדגיש כי
 intp1הוא מצביע ל ,int -לעומת זאת *intp1 :הוא תא הזיכרון עליו intp1
מורה
מצביע .הפעולה בה אנו פונים באמצעות מצביע לשטח הזיכרון עליו המצביע ֵ
נקראת .dereferencing
עוד נדגיש כי בשפת סי לכוכבית יש משמעות שונה לגמרי בהגדרת המשתנה )עת אנו
כותבים ; ,(int *pלעומת ַבשימוש בו בהמשך :בעוד בהגדרת המשתנה הכוכבית
מורה שמדובר במצביע ל) int -ולא ב ;(int -בעת השימוש ַבמשתנה ַבהמשך
הכוכבית מורה שיש לפנות לתא הזיכרון עליו המצביע מורה .על כן ההשמה:
; p = 0מכניסה את הערך אפס למצביע )ובכך ,למעשה ,מסמנת שהמצביע אינו
מורה לשום מקום( ,בניגוד לה ,ההשמה *p =0; :מכניסה את הערך אפס לתא
הזיכרון עליו  pמורה .היא חוקית רק אם קודם לכן שלחנו את  pלהצביע על תא
זיכרון כלשהו )למשל ,עת ביצענו.(p = &num1; :
באופן דומה אנו רשאים לבצע גם את ההשמה intp2 = &num2; :והאפקט שלה
יהיה דומה .עתה נוכל לבצע את ההשמה , *intp1 = *intp2; :מה עושה
השמה זאת? היא מכניסה לתוך תא הזיכרון עליו מצביע  ,intp1כלומר למשתנה
 num1את הערך המצוי בתא הזיכרון עליו מצביע  ,intp2כלומר את הערך המצוי
ב .num2 -במילים אחרות ההשמה *intp1 = *intp2; :שקולה להשמה:
; . num1=num2
לעומת ההשמה האחרונה שראינו ,ההשמה intp1 = intp2; :מכניסה למצביע
 intp1את הערך המצוי במצביע  .intp2במילים אחרות ב intp1 -תהיה אותה
כתובת המצויה ב ;intp2 -ובמילים פשוטות ,עתה שני המצביעים יצביעו על תא
הזיכרון  .num2מבחינה ציורית נציג זאת כך:
num1= 17
num2 = 3879
intp1
intp2
8
במצב הנוכחי  *intp1, *intp2, num2הם שלושה שמות שונים לאותו תא
זיכרון .הדבר עלול לגרום לתוצאות לכאורה מפתיעות .לדוגמה נביט בקטע התכנית
הבא )אשר מניח כי מצב המצביעים הוא כפי שמתואר בציור האחרון(:
; num2 = 3
; *intp1 = 5
; cout << num2
הפלט שיוצג יהיה חמש ,וזאת למרות שהפקודה האחרונה אשר הכניסה ערך
למשתנה  num2שמה בו את הערך שלוש .הסיבה לכך שהפלט יהיה חמש היא שגם
הפקודה ; *intp1 = 5השימה ערך לאותו שטח זיכרון ,ועשתה זאת אחרי
ההשמהַ . num2 = 3; :למצב בו לאותו שטח בזיכרון ניתן לפנות באמצעות מספר
שמות אנו קוראים  .aliasזהו מצב לא רצוי ,שכן הוא עלול לגרום לתופעות לכאורה
משונות ,כפי שראינו .למרות שהוא מצב לא רצוי ,בהמשך נעשה בו שימוש רב.
שימו לב כי הפקודה intp1 = 3; :היא פקודה שגויה ,שכן אנו מנסים להכניס
למצביע )שטיפוסו (int * :ערך שלם )כלומר ערך מטיפוס  .(intזה לא יהיה בלתי
סביר לחשוב שכתובות במחשב מיוצגות באמצעות מספרים טבעיים ,ולמרות זאת
השמה כגון הנ"ל היא שגויה.
 11.2הדמיון והשוני בין מצביע לבין מערך
כל השימוש שעשינו עד כאן במצביעים היה כדי להפנותם לתאי זיכרון קיימים .יבוא
הקנטרן וישאל :אם זה כל השימוש שניתן לעשות במצביעים ,אז מה תועלתם?
התשובה היא שבמצביעים ניתן לעשות גם שימושים אחרים .מצביע הוא למעשה
פוטנציאל למערך .נסביר :נניח שהגדרנו . double a[5], *floatp; :נציג את
המחסנית:
=a
4
3
1
2
0
=floatp
מה קיבלנו על-גבי המחסנית? קיבלנו מערך בשם  aבן חמישה תאים ,ומצביע בשם
) .floatpבהמשך יובהר מדוע גם לצד  aציירנו חץ( .עתה אנו רשאים לבצע את
פקודהְ . floatp = new (std::nothrow) double[7]; :למה תגרום
פקודה זאת? פקודה זאת מממשת את הפוטנציאל הגלום במצביע ,והופכת אותו
למערך ככל מערך אחר .מייד ניתן הסבר מפורט; אך נקדים לתשובה רקע הכרחי:
כל המשתנים שראינו עד כה הוקצו על-גבי המחסנית .המחסנית היא שטח בזיכרון
הראשי אשר מערכת ההפעלה מקצה לתכניתכם .בשלב זה של חיינו אנו גם מבינים
מדוע שטח הזיכרון הזה מכונה מחסנית :בשל שיטת ה last in first out -על-פיה הוא
מתנהל ,ואשר קובעת באיזה אופן חלקים נוספים מזיכרון זה מוקצים לתכנית,
ואחר משוחררים .פרט למחסנית ,מקצה מערכת ההפעלה לתכניתכם גם שטח נוסף
בזיכרון הראשי .שטח זה נקרא ערמה ) .(heapהוא נקרא כך שכן הוא מנוהל בבלגן
יחסי ,באופן דומה לדרך בה אנו זורקים דברים נוספים על ערמה ,או מושכים
floatp = new
דברים מערמה .עת תכנית מבצעת פקודה כדוגמת:
;] (std::nothrow) double[7קורה הדבר הבא :על-גבי הערמה מוקצה
מערך בן  7תאים ממשיים ,והמצביע  floatpעובר להצביע לתא מספר אפס במערך
זה.
9
מבחינה ציורית נציג זאת כך:
=a
6
5
4
3
2
1
4
0
3
2
1
0
=floatp
המלבן השמאלי מייצג את המחסנית ,והימני את הערמה.
המערך שהתקבל יקרא  .floatpאל תאיו נפנה כפי שאנו פונים לכל מערך אחר.
לדוגמה אנו רשאים לכתוב floatp[0]=17; :או .cin >> float[6]; :אנו
רואים ,אם כן ,כי אחרי שהקצנו למצביע מערך )באמצעות הפקודהfloatp = :
… ,(newכלומר אחרי שמימשנו את הפוטנציאל הגלום במצביע ,אנו מתייחסים ל-
 floatpכאל כל מערך אחר.
אנו אומרים כי המערך  floatpהוקצה דינאמית ,בעת ריצת התכנית ,וזאת בניגוד
למערך  aשהוקצה סטאטית ,וגודלו נקבע בעת שהמתכנת כתב את התכנית.
מספר הערות על פקודת ה:new -
א .כמובן ששם הטיפוס שיופיע אחרי המילה השמורה  newיהיה תואם לטיפוס
המצביע .כלומר לא יתכן שנגדיר מצביע , int *p; :ובהמשך נכתוב:
;].p = new (std::nothrow) float[7
ב .הקפידו שגודל המערך אותו ברצונכם להקצות יופיע בתוך סוגריים מרובעים,
)ולא בתוך סוגריים מעוגלים!(.
ג .פקודת ה new -עלולה להיכשל ,כלומר היא עלולה שלא להצליח להקצות מערך
כנדרש .במקרה זה ערכו של המצביע יהיה הקבוע  .NULLהערך  NULLמורה
שהמצביע אינו מצביע לשום כתובת שהיא .נדון במשמעותו ביתר הרחבה
בהמשך .כלל הזהב למתכנת המתחיל הוא כי אחרי ביצוע  newיש לבדוק האם
ערכו של המצביע המתאים שונה מ .NULL -במידה וערכו של המצביע הוא
 ,NULLסביר שתרצו לעצור את ביצוע התכנית ,תוך שאתם משגרים הודעה
מתאימה .נציג דוגמה:
; ]floatp = new (std::nothrow) double[7
{ )if (floatp == NULL
; "cout << "Can not allocate memory\n
; )exit(EXIT_FAILURE
}
הסבר :אם בעקבות ביצוע פקודת ה new -ערכו של  floatpהוא  NULLאזי
אנו משגרים הודעת שגיאה ,ועוצרים את ביצוע התכנית באמצעות הפקודה
) . exit(EXIT_FAILUREפקודה זאת תחזיר את הקוד המופיע בסוגריים
למערכת ההפעלה .על-מנת שהקומפיילר יכיר את פקודת ה , exit -ואת הקבוע
 NULLיש לכלול בתכניתכם את ההוראה . #include <stdlib.h> :בדרך
כלל נוהגים לכלול את זוג הפקודות המבוצעות במקרה וההקצאה נכשלה
בפונקציה קטנה נפרדת שמקבלת מחרוזת ,וערך שלם; הפונקציה מציגה את
המחרוזת ,וקוטעת את ביצוע התכנית תוך החזרת ערך השלם.
ד .הפסוקית ) (std::nothrowהיא זו שדואגת שעת הקצאת הזיכרון נכשלת
התכנית לא תעוף ,אלא למצביע יוכנס הערך  .NULLלו לא היינו כותבים פסוקית
זאת ,עת הקצאת הזיכרון נכשלת 'נזרקת חריגה' ,מונח שאינו מוכר לנו עדיין,
אך שתוצאתו הבסיסית היא העפת התכנית .על מנת שהמהדר יכיר את
הפסוקית הנ"ל יש לכלול בתכנית את הנחיית המהדר#include <new> :
10
האפשרות להקצות מערך דינמית גורמת לכך שעתה יש ביכולתנו לכתוב קטע קוד
כדוגמת הבא )נניח את ההגדרות:(int num_of_stud, *bible; :
; cin >> num_of_stud
; ]bible = new (std::nothrow) int[num_of_stud
)if (bible == NULL
; )terminate(“can not allocate memory\n”, 1
)for (int i = 0; i < num_of_stud; i++
; ]cin >> bible[i
אחר אנו
הסבר :ראשית אנו קוראים מהמשתמש כמה תלמידים יש בכיתתוַ ,
מקצים מערך בגודל הדרוש ,ולבסוף )בהנחה שההקצאה הצליחה( ,אנו קוראים
נתונים לתוך המערך .במידה וההקצאה נכשלה ,אנו מזמנים את הפונקציה
 terminateכפי שתואר בפסקה הקודמת.
11.2.1
נוטציה מערכית לעומת נוטציה מצביעית
אמרנו כי מצביע הוא פוטנציאל למערך .הוספנו שאנו מממשים את הפוטנציאל
באמצעות פקודת ה ,new -ואחרי שמימשנו את הפוטנציאל המצביע והמערך היינו
הך הם )כמעט( .עוד אמרנו כי המצביע ,אחרי שהוקצה לו מערך מצביע למעשה על
תא מספר אפס במערך .נעקוב אחרי קביעות אלה שוב :נניח כי הגדרנו:
; int a[5], *ip
ואחר הקצנו ל ip -מערך:
;]ip = new (std::nothrow) int[5
)נניח כי ההקצאה הצליחה(.
עתה יש לנו בתכנית שני מערכים בגודל של חמישה תאים .על כן אנו יכולים לכתוב
קוד כדוגמת הבא:
)for (int i= 0; i< 5; i++
; a[i] = ip[i] = 0
האם אנו יכולים לכתוב גם ? *ip = 3879; :ואם כן מה משמעות ההשמה הנ"ל?
התשובה היא שאנו יכולים לכתוב השמה כנ"ל ,וההשמה תכניס לתא מספר אפס
במערך  ipאת הערך  ;3879שכן כפי שאמרנו  ipמצביע על התא מספר אפס ַבמערך
שהוקצה דינמית.
;*a = 3879
מה שאולי יפתיע אתכם יותר הוא שאנו רשאים לכתוב גם:
ופקודה זאת תכניס את הערך  3879לתא מספר אפס במערך  .aכלומר גם  aהוא
למעשה מצביע :מצביע אשר מורה על התא מספר אפס ַבמערך שהוקצה סטאטית
על-גבי המחסנית .עתה אתם גם מבינים מדוע גם לצד  aציירנו חץ שהצביע על התא
מספר אפס במערך המתאים.
מערכית' ,אנו רשאים
את לולאת איפוס המערכים שכתבנו קודם בצורת כתיבה ' ַ
לכתוב גם בצורת כתיבה 'מצביעית':
)for (i=0; i<5; i++
; *(a + i) = *(ip + i) = 0
נסביר :אנו מבצעים כאן אריתמטיקה של מצביעים .ערכו של הביטוי(a + i) :
הוא המצביע אשר מורה על התא שבהסטה  iמהתא עליו מורה  .aלכן אם  aמורה
על התא מספר אפס במערך ,וערכו של  iהוא שלוש ,אזי הביטוי ) (a + iהוא
המצביע לתא מספר שלוש במערך .לכן אם אנו כותבים ,*(a + i) :כלומר אנו
הולכים בעקבות המצביע ,אזי אנו פונים לתא מספר שלוש במערך ,ובלולאה שלנו
11
אנו מאפסים אותו .במילים פשוטות :הכתיבה *(a + i) :שקולה לכתיבה:
] .a[iאנו רואים אם כן שוב את הדמיון בין מערך לבין מצביע שהוקצה לו מערך.
האם קיימת זהות מלאה בין מערך שהוקצה סטטית )על-גבי המחסנית( ,לבין כזה
שהוקצה דינמית )על-גבי הערמה(? לא לגמרי .עתה נציין את ההבדלים .נבחן את
קטע הקוד הבא ,תוך שאנו מניחים את ההגדרות:
; int a[3], *p1, *p2, *p3
; ]p1 = new (std::nothrow) int[4
; ]p2 = new (std::nothrow) int[2
; p3 = p1
; p1 = p2
; a = p2
מקטע הקוד השמטנו את הבדיקה האם ההקצאה הדינמית
נסביר :ראשית ֵ
הצליחה .אנו עושים זאת לצורך הקיצור ,וההתמקדות ַבעיקר .בתכניות שלכם
הקפידו תמיד לערוך את הבדיקה המתאימה .מעבר לכך :אנו מקצים ל p1 -ולp2 -
שני מערכים .עתה אנו מפנים את  p3להצביע על המערך  ,p1ולכן אם נכתוב ]p3[0
או נכתוב ] p1[0נִ פנה לאותו שטח זיכרון .נתאר זאת על-גבי המחסנית:
=a
=p1
=p2
=p3
מפנה את  p1להצביע על אותו מערך כמו ) p2ולכן עתה
הפקודה הבאהְ p1 = p2; :
רק  p3מצביע על המערך בן ארבעת התאים שהוקצה דינמית( .הפקודה האחרונהa :
 = p2היא שגיאה שתמנע מהתכנית להתקמפל בהצלחה a .הוא מצביע אשר
קשור לנצח נצחים ַלמערך שהוקצה לו על-גבי המחסנית; שלא כמו  p1את  aלא
ניתן להפנות להצביע על מערך אחר .מבחינה פורמאלית aהוא מצביע קבוע ,כזה
שלא ניתן לשנות את ערכו.
מאותה סיבה הפעולה ; p1++אשר מקדמת את  p1להצביע על התא הבא במערך,
היא תקינה :את  p1ניתן להזיז באופן חופשי .לעומתה הפקודה ; a++היא שגיאה.
שימו לב כי הפקודה ; p1++שונה לחלוטין מהפקודה ; (*p1)++אשר שקולה
לפקודה . p1[0]++; :שתי הפקודות האחרונות מגדילות באחד את ערכו של תא
הזיכרון עליו מצביע  ,p1במילים אחרות הן מגדילות באחד את הערך המצוי ַבתא
מספר אפס במערך  ,p1כלומר הן מבצעות אריתמטיקה במספרים שלמים .בעוד
הפקודה  p1++היא אריתמטיקה במצביעים :היא מסיטה את  p1להצביע מקום
אחד ימינה יותר במערך.
נִ ראה שימוש לפקודת ההגדלה העצמית במצביעים .נניח כי הגדרנו,char *cp; :
אחר הקצנו ל cp -מערך )באמצעות הפקודהcp = new (std::nothrow) :
;] .(char[3879עתה אנו יכולים לאפס את המערך גם באופן הבא:
)for (int i=0; i<3879; i++
} ; { *cp = 0; cp++
הסבר :בכל סיבוב בלולאה אנו מאפסים את התא במערך עליו מצביע ) ,cpוכזכור
בתחילת התהליך  cpמצביע על התא מספר אפס במערך( ,ואחר מקדמים את  cpכך
שהוא יצביע על התא הבא במערך.
12
להיכן מצביע  cpאחרי ביצוע הלולאה? אל מחוץ לגבולות המערך ,ליתר דיוק ַלתו
הראשון שאחרי המערך .כדי להשיב את  cpלהצביע על התא מספר אפס במערך אנו
יכולים לבצע את הפקודה cp -= 3879; :פקודה זאת מחזירה את  cpלהצביע
 3879תאים אחורנית ,כלומר לראש המערך .כמובן שלוּ היינו כותבים במקום את
הפקודה cp -= 3879; :את הפקודה cp -= 4000; :אזי היינו שולחים את
 cpלהצביע אי שם למקום לא ידוע ַבזיכרון ,וזאת פעולה מסוכנת ,שאת אחריתה מי
יישור.
הדוגמה האחרונה רמזה לנו על כמה מהסכנות הטמונות בשימוש לא די זהיר וקפדני
במצביעים .נראה דוגמה נוספת :נניח שהגדרנו bool *bp; :ואחר ,בלא שהפננו
את  bpלהצביע על תא זיכרון מוגדר כלשהו ,אנו מבצעים . *bp = true; :מה
אנו עושים בכך? ב ,bp -כמו בכל משתנה שלא נקבע לו עדיין ערך ,יש ערך 'זבל'
מקרי כלשהו ,כלומר הוא מצביע לתא מקרי כלשהו בזיכרון .עתה אנו נגשים לאותו
תא מקרי ומכניסים לתוכו את הערך  .trueהתא המקרי שעליו 'דרכנו' עשוי להכיל
משתנים אחרים של התכנית .התוצאה עלולה להיות שתכניתנו תתנהג בצורה
ביזארית ,שלא לומר אכזרית .לצערי ,כבר ראיתי מתכנתים מתחילים שבאמצעות
שימוש לא די זהיר במצביעים יצרו באגים שאת האפקט שלהם ניתן לתאר בלשון
המעטה כ'-מפתיע ,יצירתי ,עולה על כל דמיון' .הצד המרגיע של הנושא הוא שכל
התקלות שתחוללו באמצעות שימוש קלוקל במצביעים תוגבלנה לזיכרון הראשי,
ולכן תעלמנה עת תכבו את המחשב; במילים אחרות ,לא תגרמו לנזק בלתי הפיך
למחשב ,או לתוכנו של הדיסק.
אחד ממנגנוני הבטיחות שעוזר לנו להישמר מפני תקלות כדוגמת זו שראינו הוא
השימוש ב .NULL -כאשר ערכו של מצביע  pהוא  NULLמשמעות הדבר היא כי p
אינו מצביע לשום תא בזיכרון ,ועל-כן לא ניתן לפנות באמצעותו ַלזיכרון ,כלומר לא
ניתן לבצע . *p = …; :כיצד נוכל להיעזר ב NULL -בתכניות שאנו כותבים?
א .בעת הגדרת המצביע נאתחל אותו לערך . char *bp = NULL; :NULL
בַ .בהמשך אולי כן ואולי לא נפנה את  bpלהצביע על תא כלשהו ַבזיכרון )למשל,
באמצעות פקודה כגון … .(bp = new
ג .לבסוף ,לפני שאנו פונים לתא הזיכרון עליו מצביע ) bpכלומר לפני שאנו
נישאל האם ערכו של  bpשונה מ .NULL -אם ערכו של bp
מבצעיםְ (*bp = … :
שונה מ NULL -אנו מובטחים כי  bpהופנה לתא כלשהו ַבזיכרון ,ועל כן הפנִ יה
… =  *bpהיא בטוחה .אם ,לעומת זאת bp ,לא הופנה להצביע על תא כלשהו
ַבזיכרון ,אזי ערכו של  bpיישאר כפי שהוא היה בעקבות האיתחול ,כלומר ערכו
יהיה  ,NULLואז לא ננסה לפנות לתא הזיכרון עליו  bpמצביע.
נראה דוגמה:
הגדרת המצביע. char *bp = NULL; :
אחר -כך ,אי שם בהמשך התכנית נכתוב:
{ )…( if
; ]bp = new (std::nothrow) char[17
)if (bp == NULL
; )terminate("Can not allocate memory\n”, 1
}
כלומר אם תנאי כלשהו )שלא פרטנו( מתקיים אנו מקצים מערך ,ומפנים את bp
להצביע עליו.
ולבסוף ,הלאה יותר בתכנית אנו כותבים:
)if (bp != NULL
; bp[1] = bp[3] = 3879
13
בזכות האיתחול של  bpל NULL -בעת הגדרתו ,מתקיים כי הבדיקה:
) if(bp != NULLמעידה האם ל bp -הוקצה מערך ,ועל כן אנו יכולים לפנות
בבטחה לתאים מספר אחד ,ומספר שלוש במערך ,או שמא ל bp -לא הוקצה מערך,
ועל-כן ערכו נותר כפי שהוא היה בעת הגדרתו ,כלומר .NULL
כלל הזהב האחרון אותו עלינו ללמוד ַב ֵהקשר של מצביעים ומערכים הוא :כל מערך
שהוקצה באמצעות פקודת  ,newישוחרר בשלב זה או אחר) ,אולי רק לקראת סיום
ריצת התכנית( ,באמצעות פקודת  .deleteלדוגמה ,כדי לשחרר את המערך עליו
מצביע  bpנכתוב . delete [] bp; :משמעות הפקודה היא שאנו מורים
למערכת ההפעלה כי איננו זקוקים יותר למערך ,ולכן ניתן למחזר את שטח הזיכרון
שלו .פניה לזיכרון שמוחזר מהווה שגיאה; על כן היזהרו מקוד כדוגמת הבא:
; ]intp1 = new (std::nothrow) int[17
; inp2 = intp1
…
; delete [] intp2
; intp1[0] = 12
בקוד זה  intp2מצביע לאותו שטח זיכרון כמו  .intp1על-כן עת שחררנו את שטח
הזיכרון ,באמצעות הפקודה' , delete [] intp2; :שמטנו את השטיח גם
מתחת לרגלי  ,'intp1כלומר שטח הזיכרון עליו מצביע )עדיין(  intp1כבר אינו
עומד לרשותנו ,וזו שגיאה לפנות אליו ) ַבפקודה.(intp1[0] = 12; :
פקודת ה delete -אינה מכניסה למצביע ערך  .NULLהיא מותירה במצביע ערך
'זבל'.
טעות שכיחה אצל מתכנתים מתחילים היא לכתוב קטע קוד כדוגמת הבא:
; ] … [intp2 = new int
…
; ] … [intp1 = new (std::nothrow) int
; intp1 = intp2
מקְצה מערך ל .intp2 -בהמשך
נסביר :הפקודה הראשונה בין השלוש המתוארות ְ
התכניתַ ) ,בקטע המתואר בשלוש הנקודות( ,אנו עושים שימושים שונים במערך.
עתה אנו רוצים להפנות את  intp1להצביע לאותו מערך כמו  ,intp2וזה לגיטימי;
אולם להקדים ַלפקודה intp1 = intp2; :את הפקודהintp1 = new … :
; ] … [ intזו שטות .נסביר מדוע :נתאר את מצב הזיכרון אחרי ביצוע שתי
פקודות ההקצאה:
intp1
intp2
עתה ,הפקודה האחרונה intp1 = intp2; :יוצרת את המצב הבא:
intp1
intp2
14
התוצאה היא שהמערך עליו הצביע  intp1עד עתה הפך להיות מין  ,zombieשמחד
גיסא מצוי בזיכרון ,ומאידך גיסא אין כל דרך לפנות אליו .לכן אם ברצונכם להפנות
מצביע  pלהצביע על מערך עליו מורה מצביע  ,qלעולם אל תקדימו לפקודת ההשמה
; p = qפקודה. p = new … :
עד כאן הצגנו את אופן השימוש במצביעים לשם יצירת מערכים .הדוגמות שבהמשך
הפרק תסייענה לכם ,ראשית ,להטמיע את שהוסבר עד כה ,ושנית ללמוד כיצד לשלב
את השימוש במצביעים ובפונקציות.
 11.3פונקציות המקבלות מצביעים או מערכים
בעבר הכרנו את הפונקציה  .strlenלהזכירכם ,פונקציה זאת מקבלת מערך של
תווים ,ומחזירה את אורכו של הסטרינג השמור במערך )לא כולל ה .(‘\0’ -אנו
יכולים לכתוב את הפונקציה באופן הבא:
{ )][int strlen( const char s
; int i
)for (i=0; s[i] != ‘\0’; i++
;
; )return(i
}
הסבר :לולאת ה for -מתקדמת על המערך כל עוד התו המצוי בתא הנוכחי במערך
אינו ’ .‘\0בגוף הלולאה מתבצעת הפקודה הריקה .אחרי הלולאה אנו מחזירים
את ערכו של .i
עתה ,שלמדנו את השקילות בין מערכים ומצביעים ,אנו יכולים לכתוב את
הפונקציה בצורה דומה אך שונה:
{ )int strlen( const char *s
; int i
)for (i=0; s[i] != ‘\0’; i++
;
; )return(i
}
הסבר :את הפרמטר תיארנו הפעם בנוטציה מצביעית ,במקום בנוטציה מערכית.
בשל השקילות בין מערך למצביע הדבר לגיטימי .את גוף הפונקציה לא שינינו .נדגיש
כי נוסח זה ,כמו הנוסח שראינו לפניו ,וכמו אלה שנראה בהמשך ,אינו מחייב
קריאה עם מערך שהוקצה דינמית או כזה שהוקצה סטטית; ניתן לקרוא לכל אחת
מהפונקציות שאנו כותבים בסעיף זה הן עם מערך שהוקצה סטטית על המחסנית,
והן עם כזה שהוקצה דינמית על הערמה.
אנו יכולים לכתוב את אותה פונקציה גם בצורה שלישית:
{ )int strlen( const char *s
; int i
)for (i=0; *(s +i) != ‘\0’; i++
;
15
; )return(i
}
הסבר :הפעם גם את תנאי הסיום בלולאה המרנו מנוטציה מערכית לנוטציה
מצביעית .כפי שאנו יודעים הביטוי ) *(s+iפונה לתא מספר  iבמערך עליו מצביע
 .sבמילים אחרות ) (s+iהוא מצביע לתא המצוי בהסטה  iמהתא עליו מצביע ,s
ולכן ) *(s+iפונה לתא המצוי בהסטה  iמהתא עליו מצביע .s
ולבסוף נציג את אותה פונקציה בצורת כתיבה רביעית:
{ )int strlen( const char *s
; int i
)for (i=0; *s != ‘\0’; i++, s++
;
; )return(i
}
הסבר :הפעם אנו משתמשים באריתמטיקה על מצביעים .אנו מקדמים את
המצביע  sכל עוד התא עליו הוא מצביע כולל ערך שונה מ .‘\0’ -במקביל אנו
סופרים כמה פעמים קידמנו את  ,sוזהו אורכו של הסטרינג.
16
הגרסה האחרונה שכתבנו מדגימה נקודה אותה חשוב להבין :המצביע המועבר
לפונקציה מועבר כפרמטר ערך ,ועל כן שינויים שהפונקציה עושה לפרמטר שלה ,s
לא ישפיעו ,אחרי תום ביצוע הפונקציה ,על הארגומנט עמו הפונקציה נקראה.
ארגומנט זה ימשיך להצביע על התא הראשון במערך .עת מצביע מועבר כפרמטר
ערך לפונקציה ,המחשב פועל באותו אופן בו הוא פועל עת משתנה פרימיטיבי כלשהו
מועבר כפרמטר ערך :המחשב מעתיק את ערכו של הארגומנט המתאים על הפרמטר
המתאים .במילים אחרות הכתובת המוחזקת בארגומנט מועתקת לפרמטר .מבחינה
ציורית לפרמטר יוקצה חץ משלו ,חץ זה יאותחל להצביע על אותו מקום כמו
הארגומנט .נדגים זאת בציור .נניח כי בתכנית הוגדר מערך ] ,name[4ועתה אנו
קוראים לפונקציה ) strlenבכל אחד מארבעת הנוסחים שכתבנו( .נציג את מצב
המחסנית טרם הקריאה:
= name
’‘\0
c
a
b
אנו זוכרים כי מערך )כדוגמת  (nameהוא למעשה מצביע לתא מספר אפס במערך.
עתה נציג כיצד תראה המחסנית אחרי בניית רשומת ההפעלה של ) strlenנשמיט
מרשומת ההפעלה את כתובת החזרה ,ואת המשתנה הלוקלי  ,iשאינם מעניינים
אותנו עתה(.
= name
’‘\0
c
b
a
= s
הסבר :גם  sהוא מצביע )אפילו אם הוא מוגדר בכותרת הפונקציה כ,(char s[] -
ולכן ציירנו אותו עם חץ בצורת ) vכפי שאנו מציירים מצביעים; בניגוד למשולש
שחור המשמש לציור פרמטרי הפניה( .לאן מצביע  ?sלאותו מקום כמו הארגומנט
המתאים לו ,כלומר  .nameבמילים אחרות ערכו של  nameהועתק על  .sאם עתה
נסיט את  sלמקום אחר איננו משנים בכך את המקום אליו מצביע ) nameכפי
שמתאים שיקרה עם פרמטר ערך(.
הסבר זה מסייע לנו להבין תופעה שמוכרת לנו כבר מזמן :עת מערך מועבר כפרמטר
ְלפונקציה ,הפונקציה יכולה לשנות את תוכנם של תאי המערך .עתה נניח כי
בפונקציה שלנו אנו מבצעים *s = 'x’; :או במילים אחרות s[0]=’x’; :מה
יקרה? אנו פונים לתא מספר אפס במערך עליו מצביע  sולשם מכניסים את הערך
’ .‘xערך זה יוותר כמובן במערך גם אחרי תום ביצוע הפונקציה.
כדי לחדד את התופעה נציג את הפונקציה הבאה:
{ )void f( int *pp, int nn
;np = 3879; *pp = 17; pp = NULL
}
לפונקציה יש שני פרמטרי ערך :הראשון בהם הוא מצביע )במילים אחרות מערך(,
והשני הוא מספר שלם.
17
נניח כי בתכנית הראשית הוגדרו, int n = 0, *p = new int[5]; :
ולחמשת תאי המערך  ,pהוכנס הערך חמש.
הזיכרון הכולל את משתני התכנית הראשית נראה:
n = 0
5
5
5
5
5
= p
עתה אנו קוראים לפונקציהf(p, n); :
בעקבות הקריאה לפונקציה נוספת על-גבי המחסנית רשומת ההפעלה של  .fנציג
את מצב הזיכרון:
n = 0
5
5
5
5
5
= p
np = 0
= pp
נסביר :כפי של np -הועתק ערכו של  ,nכך ל pp -הועתק ערכו של  ,pולכן  ppמצביע
לאותו מערך כמן .p
עתה הפונקציה  fמתחילה להתבצע .ההשמה np = 3879; :מכניסה ערך ל,np -
ואינה משנה את ערכו של  .nההשמה) *pp = 17; :השקולה להשמה:
; (pp[0] = 17מכניסה את הערך  17לתא עליו מצביע  .ppההשמה:
; pp = NULLמכניסה ל pp -את הערך  ,NULLואינה משנה את ערכו של  .pמצב
הזיכרון אחרי ביצוע שלוש הפעולות הללו יהיה:
n = 0
5
5
5
5
17
= p
np = 3879
= pp
סימון 'ההארקה' לצד  ppהוא הדרך בה אנו מציירים מצביע שערכו הוא .NULL
עתה הפונקציה  fמסתיימת ,ורשומת ההפעלה שלה מוסרת מעל המחסנית .כפי
שאנו יודעים השינויים שהפונקציה הכניסה לתוך  npאינם נותרים ב .n -באופן
דומה ,השינויים שהפונקציה הכניסה ל pp -אינם נותרים ב .p -ולבסוף ,בהתאמה
למה שאנו כבר מכירים היטב ,שינויים שהפונקציה הכניסה לתוך המערך עליו הורה
 ppנותרים במערך גם אחרי תום ביצוע הפונקציה.
18
בעבר ראינו כי אם אנו רוצים למנוע מפונקציה המקבלת מערך לשנות את ערכם של
תאי המערך אנו יכולים להגדיר את הפרמטר של הפונקציה באופן הבאconst :
][ .int aאם מתכנת כלשהו ינסה בגוף הפונקציה לכתוב פקודה כגוןa[0]=17; :
אזי התכנית לא תעבור קומפילציה )ולכן העברת מערך כפרמטר שהינו קבוע אינה
שקולה להעברת משתנה פרימיטיבי כפרמטר ערך( .עתה למדנו כי אם פונקציה
מקבלת מערך כפרמטר אנו יכולים לתאר את הפרמטר של הפונקציה גם באופן
הבא . int *a :גם במקרה זה כדי למנוע מהפונקציה לשנות את ערכם של תאי
המערך אנו רשאים לכתוב .const int *a :אולם כתיבה זאת לא תמנע
מהפונקציה לשנות את ערכו של המצביע .בגוף הפונקציה נוכל לכתוב פקודה כגון:
; a = NULLולא יהיה בכך כל פסול .מה שלא נוכל לכתוב הוא*a = 3879; :
כלומר אין ביכולתנו לשנות את ערכם של תאי המערך עליו מצביע  ,aאך יש
ביכולתנו לשנות את ערכו של  .aכדי למנוע מפונקציה לשנות את ערכו של המצביע
המועבר לה נכתוב . int * const a :עתה פקודה כגון a= NULL; :תגרום
לשגיאת קומפילציה .לעומתה פקודה כגון *a = 1; :היא עתה לגיטימית ,שכן
הפעם לא אסרנו לשנות את ערכם של תאי המערך עליו מצביע  ,aרק אסרנו לשנות
את המקום עליו  aמצביע .כמובן שניתן לשלב את שני האיסורים גם יחדconst :
 . int * const aלבסוף נזכור כי אם מצביע הועבר כפרמטר ערך אזי בדרך כלל
לא כל-כך יפריע לנו שהפונקציה תוכל לשנות את ערכו של הפרמטר שלה .שהרי
השינוי לא יוותר בארגומנט המתאים לאותו פרמטר.
 11.4מצביע כפרמטר הפניה
עתה נניח כי ברצוננו לכתוב תכנית אשר מגדירה מצביע );,(int *p = NULL
מקצה את המערך
קוראת מהמשתמש גודל מערך רצוי )לתוך המשתנה ְ ,(size
)באמצעות פקודת  ,(newולבסוף ,קוראת נתונים לתוך המערך .עוד נניח כי כדי
לסמן את סוף המערך )שגודלו אינו ידוע לתכנית הראשית( ,מציבה הפונקציה בתא
האחרון במערך את הערך הקבוע  .THE_ENDנציג את קטע התכנית הבא ,אשר
מבצע את המשימה באמצעות פונקציה?
{ )void read_data(int *arr
; int size
; cin >> size
; ]arr = new (std::nothrow) int[size
… )if (arr == NULL
)for (int i=0; i< size -1; i++
; ]cin >> arr[i
; a[size –1] = THE_END
}
נניח כי אחרי שהגדרנו את , int *bible = NULL; :אנו מזמנים את
הפונקציה . read_data(bible); :האם קטע תכנית זה עונה על צרכינו?
התשובה היא :לא ולא .נסביר :הפונקציה  read_dataמקבלת את המצביע
כפרמטר ערך .על כן שינויים שהפונקציה מכניסה ל ,arr -אינם נותרים בתום ביצוע
הפונקציה ב ,bible -וערכו נותר  NULLכפי שהוא היה לפני הקריאה .נציג את
התהליך בעזרת המחסנית :נתחיל בהצגת מצב המחסנית עליה מוקצה המשתנה
 bibleשל התכנית הראשית:
= bible
19
ערכו של  bibleאותחל בהגדרה להיות  ,NULLועל כן ציירנו אותו כמתואר .עתה
נערכת קריאה לפונקציה .מצב המחסנית בעקבות הקריאה יהיה הבא )מהציור
השמטנו פרטים שאינם מהותיים לצורך דיוננו הנוכחי(:
= bible
= arr
מציור המחסנית השמטנו את המשתנים הלוקליים של  ,read_dataואת כתובת
החזרה .ערכו של  arrהוא  NULLשכן הארגומנט המתאים ל arr -הוא ,bible
ערכו של  bibleהוא  ,NULLוערך זה מועתק ל.arr -
עתה הפונקציה מתחילה להתבצע .הפקודה arr = new …; :יוצרת את המצב
הבא:
= bible
= arr
אחר הפונקציה
כלומר על המערך שהוקצה מצביע ) arrאך לא ַ .(bible
 read_dataקוראת נתונים לתוך המערך ,ועת היא מסתיימת ,ורשומת הפעלה
שלה מוסרת מעל-גבי המחסנית ,מצב הזיכרון הינו:
= bible
כלומר המערך הפך ל zombie -שאין דרך לפנות אליו ,והמצביע  bibleנותר
בתומתו :משמע ערכו עדיין .NULL
אנו רואים אם כן ש read_data -לא עשתה את המצופה .הסיבה היא שהיא קבלה
את המערך כפרמטר ערך .כיצד נשנה את הפונקציה על-מנת שהיא כן תשיג את
המבוקש? כלומר שבתום פעולתה המצביע  bibleיורה על המערך שהפונקציה
הקצתה? התשובה היא שעלינו להעביר את המערך כפרמטר הפניה; ואז שינויים
20
שהפונקציה תבצע על הפרמטר  ,arrיוותרו בתום ביצוע הפונקציה בארגומנט
המתאים .bible
עתה ,אם כך ,נשאל כיצד מעבירים מצביע כפרמטר הפניה? התשובה היא שבהגדרת
הפרמטר ,אחרי שם הטיפוס יש להוסיף את התו & .טיפוסו של מצביע ל int -הוא
*  ,intועל-כן כדי שהפרמטר מטיפוס *  intיהיה פרמטר הפניה עלינו להגדירו
כ.int *& -
נראה עתה את הפונקציה  read_dataבגרסתה המתוקנת:
{ )void read_data(int *&arr
; int size
; cin >> size
; ]arr = new (std::nothrow) int[size
… )if (arr == NULL
)for (int i=0; i< size -1; i++
; ]cin >> arr[i
; arr[size –1] = THE_END
}
השינוי היחיד שהכנסנו הוא תוספת ה & -אחרי שם הפרמטר .נתאר את התנהלות
העניינים מבחינת מצב הזיכרון :ראשית ,כמו קודם ,מוגדר המשתנה  bibleשל
התכנית הראשית .מצב הזיכרון הוא:
= bible
עתה נערכת קריאה לפונקציה  .read_dataאולם בגרסתה הנוכחית ל-
 read_dataיש פרמטר הפניה .כדרכם של פרמטרי הפניה ,אנו שולחים חץ )עם
ראש משולש שחור( ,מהפרמטר ַלארגומנט המתאים .נצייר את מצב המחסנית )תוך
שאנו מתרכזים רק בפרמטר  ,arrומשמיטים מהציור את יתר הפרטים(:
= bible
=arr
אנו רואים כי החץ נשלח מ ,arr -אל  ,bibleוכל שינוי שיערך ב ,arr -יתבצע
למעשה על .bible
עתה הפונקציה מתחילה לרוץ .פקודת הקצאת הזיכרון תיצור ,בגרסה הנוכחית של
הפונקציה ,את המצב הבא:
= bible
=arr
21
כלומר המצביע שערכו השתנה הוא  ,bibleשכן עת הפונקציה בצעה
…  , arr = newומכיוון ש arr -הוא פרמטר הפניהַ ,הלך המחשב בעקבות החץ
)עם הראש השחור( ,וביצע את השינוי על הארגומנט המתאים.
ַבהמשך קוראת הפונקציה נתונים לתוך המערך .עת הפונקציה מסיימת ,ורשומת
ההפעלה שלה מוסרת מעל-גבי המחסנית ,המערך לא נותר  ,zombieו bible -אינו
 :NULLכי אם  bibleמצביע על המערך כמבוקש.
 11.5פונקציה המחזירה מצביע
הפונקציה  read_dataשכתבנו שינתה את הפרמטר היחיד שלה .כפי שמיצינו
בעבר ,פונקציה אשר מחזירה רק נתון יחיד ראוי לכתוב לא עם פרמטר הפניה ,אלא
כפונקציה המחזירה את הנתון המתאים באמצעות פקודת  .returnנראה עתה
גרסה של  read_dataהפועלת על-פי עקרון זה .נקרא לה  .read_data2הקריאה
לפונקציה מהתכנית הראשית תהיה:
; )(bible = read_data2
מהקריאה אנו למדים ,באופן לא מפתיע ,שהפונקציה מחזירה מצביע ל) int -שכן
הערך המוחזר על-ידה מוכנס לתוך משתנה שזה טיפוסו( .כיצד מוגדרת הפונקציה:
{)(int *read_data2
; int *arr, size
; cin >> size
; ]arr = new (std::nothrow) int[size
… )if (arr == NULL
)for (int i=0; i< size; i++
; ]cin >> arr[i
; arr[size –1] = THE_END
; ) return( arr
}
נסביר :הפונקציה אינה מקבלת כל פרמטרים .יש לה משתנה לוקלי מטיפוס * int
ששמו הוא  .arrהפונקציה מקצה מערך ,מפנה את המצביע להצביע על המערך,
וקוראת נתונים לתוך המערך .בסוף פעולתה הפונקציה מחזירה את ערכו של
המצביע )כלומר את הכתובת עליה המצביע מורה( .מכיוון שכתובת זאת מוכנסת
למשתנה  bibleשל התכנית הראשית ,אזי מעתה  bibleיצביע על המערך
שהוקצה.
22
כדרכנו נציג את התנהלות העניינים בזיכרון .נתחיל עם המחסנית עליה הוגדר
המשתנה  bibleשל התכנית הראשית:
= bible
עתה נערכת קריאה לפונקציה  .read_data2נציג את מצב המחסנית )תוך
התרכזות ַבעיקר בלבד(:
= bible
= arr
הסברַ :בפונקציה מוגדר משתנה לוקלי בשם  .arrהמשתנה אינו מאותחל בהגדרה,
ועל כן הוא מצביע למקום מקרי כלשהו.
עתה הפונקציה מתחילה לרוץ ,בפרט היא מקצה מערך ,ומפנה את  arrלהצביע
עליו .מצב הזיכרון הוא עתה:
= bible
= arr
אַחר הפונקציה קוראת ערכים לתוך המערך ,ולבסוף היא מסיימת ,תוך שהיא
מחזירה את  .arrאנו זוכרים כי עת פונקציה מחזירה ערך ,הערך שהיא מחזירה
'מושאר בצד' )ואחר מוכנס ַלמשתנה המתאים(ַ .במקרה של מצביע הערך ש'-מושם
בצד' היא הכתובת אליה המצביע מורה ,ומבחינה ציורית המקום עליו החץ מורה.
נציג זאת כך:
= bible
2
5
2
7
המלבן הימני התחתון מציג את הערך ש' -הושאר בצד' ,והחץ שבו מורה לאותו
מקום עליו הורה .arr
עתה ,מכיוון שהקריאה לפונקציה הייתה , bible = read_data2(); :אזי
הערך ש'-הושאר בצד' מוכנס למשתנה  ,bibleכלומר  bibleעובר להצביע על
המערך שהוקצה על-ידי הפונקציה .והציור המתאים:
= bible
5
2
7
2
23
ושוב ,השגנו את האפקט הרצוי :בתום ביצוע הפונקציה מצביע  bibleעל מערך
שהוקצה על-ידי הפונקציה ,ומכיל נתונים כנדרש.
 11.6שינוי גודלו של מערך
עתה ברצוננו לכתוב פונקציה אשר תוכל לקרוא מחרוזת באורך לא ידוע .הפונקציה
תקרא את תווי המחרוזת בזה אחר זה עד קליטת התו מעבר-שורה ,אשר יציין את
סופה של המחרוזת .הקריאה לפונקציה תהיה , a_s = read_s(); :עבור
משתנה ; . char *a_sכלומר הפונקציה תחזיר מצביע ַלסטרינג שהיא קראה לתוך
מערך שהוקצה דינאמית על-גבי הערמה.
הפונקציה שונה מזו שראינו בסעיף הקודם בכך שהיא מקצה מערך בגודל  Nתאים,
וקוראת לתוכו תווים .עת המערך מתמלא הפונקציה מגדילה אותו בשיעור N
וממשיכה בתהליך הקריאה; וכך שוב ושוב כל עוד יש בכך צורך.
נציג את הפונקציה ,ואחר נסבירה ביתר פירוט:
// pointer to the array the func alloc
][// the size of the_s
// how many chars were read, so far
{ )(char *read_s
char *the_s,
*temp,
; c
int arr_size,
; s_size
the_s = new (nothrow) char[N] ; // alloc initial array
… )if (the_s == NULL
; arr_size = N
; )(c= cin.get
)'while (c != '\n
{
'// read the string until u read '\n
if (s_size == arr_size -1) { // if the_s[] is full
; ]temp = new (nothrow) char[arr_size + N
// alloc bigger 1
if (temp == NULL) ...
)for (int i = 0; i < s_size; i++
; ]temp[i] = the_s[i
// copy old onto new
; delete [] the_s
// free old
; the_s = temp
// the_s point to new
; arr_size += N
// update size
}
; the_s[ s_size++ ] = c
; )(c = cin.get
}
; ’the_s[s_size -1] = ‘\0
; return the_s
}
יתר כדי להקל על הבנתה( :אנו
נסביר את הפונקציה )אף שתיעדנו אותה תיעוד ֵ
מתחילים בכך שאנו מקצים למצביע  the_sמערך )וכמובן בודקים שההקצאה
24
הצליחה!( .המשתנה  arr_sizeשומר את גודלו של המערך שהוקצה .עתה אנו
נכנסים ללולאת  whileאשר קוראת תו ,תו עד קליטת ’ .‘\nבכל סיבוב בלולאה
אנו בודקים האם כבר אין מקום במערך להוספת תו נוסף )כלומר האם s_size
 .(== arr_size-1אם אכן זה המצב אנו) :א( מקצים מערך חדש בגודל
ומפנים את
 ,arr_size +Nכלומר מערך הגדול ב N -תאים מהמערך הנוכחיְ ,
המשתנה  tempלהצביע על המערך החדש) .ב( אנו מעתיקים ַלמערך החדש את
תוכנו של המערך הישן) .ג( אנו משחררים את המערך הישן )באמצעות פקודת ה-
מפנים את המצביע ) the_sשכבר אינו מצביע על מערך כלשהו(
) .(deleteד( אנו ְ
להצביע על המערך עליו מורה ) .tempה( אנו מעדכנים את  arr_sizeעל-ידי
הגדלתו בשיעור  .Nאחרי שהגדלנו )או לא הגדלנו( את המערך ,אנו פונים לקריאת
התו הבא מהקלט.
 11.7מערך של מצביעים כפרמטר ערך
בסעיפים הקודמים ראינו כי במקום להגדיר מערך סטטי char s[N]; :אנו
יכולים להגדיר מצביע char *sp; :אשר בהמשך יהפוך למערך )שמוקצה
דינאמית( .אנו גם זוכרים כי בשפת  Cמערך דו-ממדי הוא למעשה סדרה של מערכים
חד-ממדיים ,או במילים אחרות מערך שכל תא בו הוא מערך חד-ממדי .אם נצרף
שבמקום להגדיר מערך דו-ממדי:
ְ
את שתי המסקנות הללו יחד נוכל להסיק
; ] ,char ss[ROWS][COLSאנו יכולים להגדיר מערך של מצביעיםchar :
;] . *ssp[ROWSכל תא במערך זה הוא מצביע )כלומר יֵצור מטיפוס * (char
שיכול בהמשך להפוך למערך חד-ממדי ,אשר יוכל לשמור מחרוזת.
עתה נניח כי הגדרנו בתכנית הראשית את המשתנהchar *strings[ROWS]; :
)כלומר הגדרנו מערך של מצביעים ,שהינו מערך של פוטנציאלים למערכים חד-
ממדיים ,כלומר פוטנציאל למערך דו-ממדי(; אחר אתחלנו את תאיו )שכל אחד
מהם הוא מצביע( להכיל את הערך :NULL
)for (int i=0; i < ROWS; i++
; strings[i] = NULL
עתה אנו קוראים לפונקציה.read_strings(strings); :
הגדרת הפונקציה:
{ ) ]void read_strings( char *strings[N
; ]char temp[COLS
; int i = 0
{ )while (i < ROWS
; cin >> setw(COLS) >> temp
)if (strcmp(temp, ".”) == 0
; break
; ] strings[i]= new (nothrow) char[ strlen(temp) +1
if (strings[i] == NULL) ...
; )strcpy(strings[i++], temp
}
}
נסביר :הפונקציה מתנהלת כלולאה .בכל סיבוב בלולאה אנו קוראים מחרוזת לתוך
משתנה העזר ) tempשהינו מערך סטאטי( .במידה והסטרינג הוא ” “.אנו עוצרים
את תהליך הקריאה )ולכן גם את ביצוע הפונקציה באופן כללי( .במידה והסטרינג
אינו ” “.אנו מקצים מערך ַבגודל הדרוש )כלומר כאורכו של הסטרינג השמור ב-
מפנים את ] strings[iלהצביע על
 tempועוד תא אחד ,בו יאוחסן הְ ,(‘\0’ -
אותו מערך ,ומעתיקים את הסטרינג המצוי ב temp -על ] .strings[iשימו לב
25
שהתוצאה המתקבלת היא לא בדיוק מערך דו-ממדי ,אלא מערך בו כל שורה היא
באורך שונה.
עתה ברצוננו לשאול שאלה כללית יותר :האם הפונקציה תבצע את משימתה
כהלכה? האם טיפוס הפרמטר של הפונקציה הוא כנדרש? האם שינויים
שהפונקציה תכניס לתוך הפרמטר  stringsיישארו בארגומנט המתאים בתום
ביצוע הפונקציה? התשובה היא :כן! וכן! נסביר מדוע :אנו יודעים היטב כי שינויים
שפונקציה מכניסה לתוך תאי מערך שהינו פרמטר של הפונקציה נשארים בתום
ביצוע הפונקציה בארגומנט המתאים; וְ כלל זה נכון גם עבור מערך שכל תא בו הוא
מצביע )אשר הופנה ַבפונקציה להצביע על סטרינג שהוקצה דינמית על הערמה(.
נתאר את השתלשלות העניינים בזיכרון.
26
ראשית ,בתכנית הראשית ,על-גבי המחסנית ,מוגדר מערך של מצביעים ,ותאי
מאותחלים לערך  .NULLנציג את המחסנית:
=strings
עתה מתבצעת קריאה ַלפונקציה .המערך מועבר כפרמטר ערך ,כלומר ערכו של
הארגומנט  ,stringsמועתק על הפרמטר  .stringsמבחינה ציורית משמעות
הדבר היא שהפרמטר יצביע לאותו מקום כמו הארגומנט .נציג את מצב המחסנית:
=strings
=strings
עת הפונקציה מבצעת פקודה כגון strings[0] = new char[4]; :מתקבל
המצב הבא בזיכרון:
=strings
=strings
כלומר המצביע שבתא מספר אפס במערך המצביעים המקורי )והיחיד הקיים( הוא
שעובר להצביע על הסטרינג המוקצה .אם אחרי קריאת הסטרינג השני יוקצה מערך
בגודל שלושה תאים אזי מצב הזיכרון יהיה:
=strings
=strings
27
ַבפונקציה  read_stringsשכתבנו הגדרנו את הפרמטר באופן הבא:
] . *strings[Nכפי שאנו יודעים במקום לתאר פרמטר כמערך )ואפילו הוא מערך
של מצביעים( אנו יכולים לתארו כמצביע .לכן במקום להגדיר את הפרמטר כ:
] char *strings[Nאנו יכולים להגדירו כ ,char **strings :במילים:
כמצביע למצביע לתו )מבחינה תחבירית אנו ממירים ] [Nב .(* -גוף הפונקציה לא
יצטרך לעבור שינוי גם אם נבחר לתאר את הפרמטר באופן זה.
char
 11.8מערך של מצביעים כפרמטר הפניה
מערך המצביעים  ,char **stringsשראינו בסעיף שעבר ,הועבר כפרמטר ערך,
ולכן ביכולתה של הפונקציה היה לשנות רק את ערכם של תאי המערך ,אך לא את
גודלו של המערך .נניח כי ברצוננו לכתוב פונקציה אשר קוראת סדרת מחרוזות.
מספר המחרוזות אינו ידוע למתכנת ,ולכן בתכנית הראשית לא ניתן להגדיר
משתנה ,char *strings[ROWS] :שכן משתנה שכזה מגדיר את מספר
המחרוזות להיות לכל היותר  .ROWSמה יהיה הפתרון? אנלוגי לזה שראינו עם מערך
חד-ממדי שגודלו היה לא ידוע למתכנת :במקום להגדיר מערך ],char s[N
הגדרנו מצביע ) char *sכלומר פוטנציאל למערך .מבחינה תחבירית המרנו את
] [Nב .(* -את המצביע העברנו כפרמטר הפניה ְלפונקציה אשר יכלה לממש אותו
לכדי מערך ַבגודל הרצוי לה )ואף לשנות את גודלו של המערך עת זה מה שנדרש( .גם
במקום להגדיר מערך סטטי של
עם מערך של מצביעים ננקוט באותו פתרוןְ :
מצביעים ] char *strings[ROWSנגדיר מצביע למצביע .char **strings
)שוב המרנו את ה [N] -ב .(* -את המצביע למצביע נעבר כפרמטר הפניה לפונקציה
אשר תקצה מערך של מצביעים בגודל הרצוי )ואף תוכל לשנות את גודלו של המערך
עת זה מה שיידרש( .כיצד מתארים פרמטר הפניה שהינו מצביע למצביע? כותבים
אחרי שם הטיפוס ,לדוגמה אחרי **  ,charאת התו &.
נציג עתה את הפונקציה  alloc_arr_n_read_stringsאשר תקבל מצביע
למצביע כפרמטר הפניה ,תקרא מהמשתמש כמה מחרוזות ברצונו להזין ,תקצה
ואחר תקרא סדרת מחרוזות תוך שימוש
ַ
מערך של מצביעים בגודל הדרוש,
בפונקציה  read_stringsשכתבנו בסעיף שעבר ,ואשר מקבלת מערך מוכן של
מצביעים .בפונקציה  read_stringsעלינו לבצע שינוי קל :הפונקציה תקבל
פרמטר ערך נוסף ,int arr_size ,אשר יורה לה מה גודלו של מערך המצביעים
המועבר לה) .להזכירכם ,בגרסה שכתבנו הנחנו כי גודלו של המערך הוא הקבוע
תקרא
מהיותו גלובלי( .הפונקציה ְ read_strings
 ,ROWSאשר מוכר לפונקציה ֵ
במקום לכל היותר  ROWSמחרוזות ,כפי שהיא
לכל יותר  arr_sizeמחרוזות ) ְ
עשתה במקור(.
הפונקציה  alloc_arr_n_read_stringsשנכתוב תחזיר באמצעות פרמטר
הפניה את גודלו של מערך המצביעים שהוקצה.
בתכנית הראשית נגדיר ,אם כן:
; char **arr_of_strings
; int arr_size
ונזמן את הפונקציה באופן הבא:
; )alloc_arr_n_read_strings(arr_of_strings, arr_size
28
הגדרת הפונקציה:
void alloc_arr_n_read_strings(char **&arr_of_strings,
)int &arr_size
{
; cin >> arr_size
; ]arr_of_strings = new (nothrow) char *[arr_size
if (arr_of_strings == NULL) ...
)for (int i = 0; i < arr_size; i++
; arr_of_strings[i] = NULL
; ) read_strings( arr_of_strings, arr_size
}
הסבר :הפונקציה מקבלת את המצביע למצביע )כלומר את הפוטנציאל למערך של
מצביעים( כפרמטר הפניה .לכן שינויים שהפונקציה תערוך ַלפרמטר שלה יוותרו
בתום ביצוע הפונקציה בארגומנט המתאים .פקודת ה new -המבוצעת על-ידי
ַ
הפונקציה
מקצה מערך של מצביעים .שימו לב כי עת אנו כותבים ]…[new char
אנו מקצים מערך של תווים ,ועת אנו כותבים ]…[*  new charאנו מקצים מערך
של מצביעים לתווים .כמובן שאחרי ביצוע ההקצאה יש לבדוק שערכו של המצביע
שונה מ.NULL -
אחרי שהקצאנו מערך של מצביעים ,ואיתחלנו את תאיו לערך  ,NULLאנו יכולים
לזמן את הפונקציה  read_stringsאשר מצפה לקבל מערך קיים של מצביעים.
נציג את התנהלות התכנית מבחינת ציור הזיכרון .כמו תמיד נתרכז רק ַבהיבטים
המשמעותיים לצורך דיוננוַ .בתחילה ,אנו מגדירים ַבתכנית הראשית מצביע למצביע
 ,arr_of_stringsומשתנה שלם  .arr_sizeנציג את מצב המחסנית:
= Arr_of_strings
= arr_size
מכיוון שהמצביע לא אותחל ציירנו אותו כמורה למקום מקרי.
עתה אנו מזַמנים את הפונקציה ַ . alloc_arr_n_read_stringsלפונקציה יש
שני פרמטרי הפניה ,ועל כן אנו שולחים חץ )עם ראש בצורת משולש שחור( מכל אחד
משני הפרמטרים אל הארגומנט המתאים לאותו פרמטר .נציג את מצב המחסנית:
= arr_of_strings
= arr_size
= arr_of_strings
= arr_size
29
עתה הפונקציה מתחילה להתבצע .עת הפונקציה מבצעת את פקודה ה new -מוקצה
על-גבי הערמה מערך של מצביעים .מי יצביע על מערך זה? הפרמטר
 arr_of_stringsשל הפונקציה הוא פרמטר הפניה )חץ עם ראש משולש שחור(,
כדרכו של פרמטר הפניה אנו הולכים בעקבות החץ ומטפלים בארגומנט המתאים,
כלומר במשתנה  arr_of_stringsשל התכנית הראשית; ומצביע זה )שכדרכו של
מצביע צויר עם חץ שראשו בצורת  (vהוא שמצביע על המערך שהוקצה .נתאר זאת
ציורית:
= arr_of_strings
= arr_size
= arr_of_strings
= arr_size
שהנם ,כזכור לנו ,מצביעים לתווים( .ולכן
אחר ,אנו מאתחלים את תאי המערך ) ִ
ַ
בשלב הבא אנו קוראים לפונקציה
בציור תארנו כל תא כמורה על ְ .NULL
 read_stringsומעבירים לה את מערך המצביעים כפרמטר ערך ,כלומר הפרמטר
של הפונקציה )שהנו חץ עם ראש בצורת  (vיצביע גם הוא על מערך המצביעים .גם
גודלו של המערך מועבר כפרמטר ערך ,ולכן הערך המתאים מועתק לפרמטר
 .arr_sizeנציג זאת בציור:
= arr_of_strings
arr_size = 4
= arr_of_strings
= arr_size
= strings
arr_size = 4
עת הפונקציה  read_stringsמתחילה להתבצע אנו חוזרים למצב שתיארנו
מבצעת:
הפונקציה
עת
לדוגמה,
הקודם.
בסעיף
;] strings[0] = new char[3מוקצה מערך ,ותא מספר אפס ַבמערך
 stringsעובר להצביע על מערך זה .הציור המתאים יהיה:
= arr_of_strings
arr_size = 4
= arr_of_strings
= arr_size
= strings
arr_size = 4
30
 11.9מצביעים במקום פרמטרי הפניה
בעבר ,ראינו את הפונקציה  swapאשר מחליפה בין ערכם של שני הפרמטרים
המועברים לה .כתבנו אותה באופן הבא:
{ )void swap( int &var1, int &var2
; int temp = var1
; var1 = var2
; var2 = temp
}
int
כלומר לפונקציה  swapיש שני פרמטרי הפניה .אם בתכנית מוגדרים:
;  a, bולתוך זוג המשתנים הכנסנו ערכים ,אזי אנו יכולים לקרוא לפונקציה
;) swap(a, bוהפונקציה תחליף בין ערכיהם של  aושל .b
יש המסתייגים מפונקציה כדוגמת  swapהנ"ל .המסתייגים טוענים כי מהקריאה
לפונקציה ) ;) (swap(a, bלא ניתן לדעת שהפרמטרים של הפונקציה הינם
לשנות את ערכם של הפרמטרים,
פרמטרי הפניה ,ולכן) :א( ַלפונקציה יש את הכוח ַ
וכן) :ב( לא ניתן לזמן את הפונקציה באופן הבא . swap(a, 17); :המסתייגים
טוענים כי עת תכניות נעשות גדולות מאוד ,ונכתבות לכן על-ידי צוות של מתכנתים,
יש חשיבות עליונה לכך שמאופן הקריאה ַלפונקציה נוכל לדעת את סוג הפרמטרים
של הפונקציה .מצביעים עשויים לבוא לעזרנו גם בסוגיה זאת.
התחלנו את דיוננו בנושא מצביעים בכך שראינו שניתן להפנות מצביע להצביע על
משתנה קיים .אם הגדרנו בתכנית , int num1 =17, *ip ; :אזי הפקודה:
; ip = &num1תפנה את המצביע  ipלהורות על המשתנה  .num1מבחינה ציורית
אנו מתארים זאת כך:
num1 = 17
= ip
ַבעקרון שהצגנו ִ
בפסקה האחרונה נעשה שימוש כדי לכתוב את  swapבצורה שונה,
שתענה על דרישות המסתייגים .שוב נניח כי בתכנית הוגדרו המשתנים:
; int a, bולצורך דיוננו הנוכחי נניח כי הוגדרו גם . int *pa, *pb; :עוד
נניח כי ל a -ול b -הוכנסו ערכים ,ועתה ברצוננו להחליף בין ערכי המשתנים הללו.
;pa = &a
נוכל לבצע זאת בדרך הבאה :ראשית בתכנית הראשית נכתוב:
; . pb = &bועתה נזמן את הפונקציה . new_swap(pa, pb); :נציג את
הגדרת הפונקציה :new_swap
{ )void new_swap( int *pvar1, int *pvar2
; int temp = *pvar1
; *pvar1 = *pvar2
; *pvar2 = temp
}
31
עתה נבחן מדוע ואיך הפונקציה שהצגנו עושה את מלאכת ההחלפה בין הערכים.
נתחיל בהצגת המחסנית הכוללת את ארבעת משתני התכנית הראשית .ונניח כי
למשתנים  a, bהוכנסו ערכים כמתואר בציור:
a = 17
b = 3879
= pa
= pb
הפקודותpb = &b; :
;ִ pa = &aתצורנה את המצב הבא:
a = 17
b = 3879
= pa
= pb
כלומר כל מצביע מורה על המשתנה המתאים לו .עתה אנו מזמנים את הפונקציה
;) new_swap(pa, pbאשר מקבלת את שני המצביעים כפרמטרי ערך ,ולכן ערכם
של הארגומנטים  pa, pbמועתק על הפרמטרים  pvar1, pvar2בהתאמה;
מבחינה ציורית pvar1, pvar2 ,יצביעו גם הם על אותם מקומות כמו ,pa, pb
כלומר על  .a, bנציג את מצב המחסנית:
a = 17
b = 3879
= pa
= pb
= pvar1
= pvar2
= temp
עתה הפונקציה מתחילה להתבצע :הפקודה temp = *pvar1; :מכניסה לtemp -
את הערך המצוי בתא עליו מצביע  ,pvar1כלומר את הערך  .17הפקודה:
; *pvar1 = *pvar2מכניסה לתא הזיכרון עליו מצביע  pvar1את הערך המצוי
בתא הזיכרון עליו מצביע  ,pvar2לכן לתוך תא הזיכרון  aמוכנס ערכו של התא
הזיכרון  ,bכלומר  .3879לבסוף הפקודה *pvar2 = temp; :מכניסה לתוך תא
הזיכרון עליו מצביע  ,pvar2כלומר לתוך תא הזיכרון  ,bאת הערך  17השמור ב-
 .tempבכך השלימה הפונקציה את פעולתה בהצלחה.
עתה נשאל מדוע  new_swapעדיפה על-פני  ?swapהתשובה היא שעת אנו מעבירים
מצביע ְלמשתנה הדבר ניכר לעין גם בקריאה ַלפונקציה ,ומרמז על כך שהפונקציה
עתידה לשנות את ערכו של המשתנה ,שהרי אחרת היינו מעבירים לה את ערכו של
המשתנה )כלומר היה לה פרמטר ערך( ,ולא מצביע ַלמשתנה .מטעמים דידקטיים
אנו קראנו לפונקציה  new_swapבשני שלבים :ראשית הפננו את המצביעיםpa, :
 pbלהצביע על  , a, bושנית קראנו לפונקציה עם  .pa, pbלמעשה ניתן לקרוא
;) . new_swap(&a, &bבצורת קריאה זאת אנו
לפונקציה בצעד אחד:
מוותרים על משתני העזר  ,pa, pbהקריאה לפונקציהֵ ,ראשית מחשבת את
כתובתם של  aושל  ,bושנית משימה בפרמטרים  pvar1, pvar2את הכתובות
הללו ,באופן שהפרמטרים מצביעים על הארגומנטים המתאימים כנדרש .איחוד שני
32
הצעדים לכדי צעד אחד מדגיש יותר את העובדה כי  new_swapמקבלת מצביעים
ַלמשתנים ) a,bולכן סביר להניח שהיא עתידה לשנות את ערכם(.
11.10
מצביע מדרגה שנייה )**  (intכפרמט קבוע )(const
פונ' שאמורה לקבל **  intלקריאה בלבד תקבלוconst int * const * :
הסיבה :נניח תסריט:
// assume this is legel
// (actually, fortunately, it is not).
// legal as both have the same type:
* // const int
// but now p points to one
* // BAD, but legal, p's type is int
11.11
; const int one = 1
; int *p = NULL
; const int **p2p = &p
*p2p = &one
; *p = 2
פרמטרים המועברים ל argc :main -וargv -
בפרק תשע ראינו כי כדי לשמור מערך של  Nסטרינגים כל אחד באורך של לכל
היותר  M-1תווים אנו מגדירים מערך דו-ממדי של תווים. char s[N][M]; :
במקום להגדיר מערך סטטי כנ"ל אנו יכולים להגדיר מערך
בפרק הנוכחי ראינו כי ְ
של מצביעים . char *s[N]; :מערך זה יוכל לשמור לכל היותר  Nסטרינגים ,כל
אחר ראינו כי במקום להגדיר את
אחד מהם באורך שייקבע במהלך ריצת התכניתַ .
גודלו של המערך להיות בן  Nמצביעים אנו יכולים להגדיר מצביע למצביעchar :
; . **sבאופן זה נוכל תוך כדי ריצת התכנית לקבוע )ולשנות( הן את גודלו של
שישמר במערך.
ֵ
המערך ,והן את אורכו של כל סטרינג וסטרינג
בעבר העלנו את השאלה האם גם ַלתכנית הראשית ,main ,ניתן להעביר
ַ
ארגומנטים? ענִ ינו אז על השאלה בחיוב ,אך לא יכולנו להסביר כיצד בדיוק הדבר
מתבצע .עתה חכמנו מספיק כדי ללמוד כיצד הדבר מתבצע .נניח כי ברצוננו לכתוב
תכנית אשר מקבלת מספר כלשהו של מספרים שלמים ,ומציגה את סכומם .נוכל
לעשות זאת ַבאופן הבא:
א .נכתוב תכנית ,אותה נציג מייד ,ונשמור את התכנית בקובץ בשם . sum.cc
)הסיומת  ccמעידה כי זהו קובץ המכיל תכנית מקור בשפת  ,C++התחילית sum
שנתנו ַלקובץ כדי להעיד על מהות התכנית המצויה בו .במערכת הפעלה
היא שם ַ
חלונות ,ייקרא הקובץ .(sum.cpp
בשפת מכונה שיקרא ) .sum.בחלונות
ונייצר קובץ ְ
ֵ
ב .נקמפל את הקובץ ,sum.cc
ייקרא הקובץ  ,sum.exeהסיומת  exeמעידה כי קובץ זה מכיל תכנית ניתנת
להרצה.(executable code ,
ג .אם עבדנו בסביבת עבודה אזי עתה נצא מסביבת העבודה ,ונעבור לעבוד אל מול
מערכת ההפעלה באופנוּת הקרויה  ,command lineכלומר לאופנות בה אנו
מקלידים )בשורה ,אות אחר אות( את הפקודות שברצוננו לבצע .בסביבת
חלונות ,למשל ,אנו עוברים לעבוד בצורת עבודה של .MS-DOS prompt
ד .נתמקם במחיצה בה מצוי הקובץ . sum.exe
33
הבא:
באופן
או
ה .נריץ את התכנית באופן הבאsum 3879 17 9 :
 , sum –6 4 1 1כלומר תוך שאנו כותבים את שם התכנית ) (sumואחר את סדרת
תסכום .לשם הפשטות נניח כי התכנית
המספרים השלמים שברצוננו שהתכנית ְ
מורצת עם סדרת מספרים תקינה.
עתה נציג את התכנית ,ואחר נסבירה:
>#include <iostream.h
>#include <stdlib.h
)(// needed for atoi
{ )int main(int argc, char **argv
; int sum = 0
)for (int i = 1; i < argc; i++
; )]sum += atoi(argv[i
; cout << sum << endl
; return EXIT_SUCCESS
}
אנו רואים כי לתכנית הראשית יש שני פרמטרים:
א .הפרמטר ) argcקיצור של  (argument counterמעיד כמה מחרוזות כוללת
הפקודה באמצעותה הורצה התכנית .מספר המחרוזות יהיה לכל הפחות אחד:
שם התכנית .במידה והמשתמש הקליד בנוסף לשם התכנית מחרוזות נוספות
יהיה ערכו של  argcכמספר המחרוזות שהוקלדו )כולל שם התכנית( .לדוגמה
אם התכנית מורצת באופן sum 643 –49 –3 1 :אזי ערכו של  argcהוא חמש,
שכן הוזנו חמיש מחרוזות) :א( ) ,sumב( ) ,643ג( ) ,49-ד( ) ,3-ה( .1
ב .הפרמטר ) argvקיצור של  (argument vectorהוא מערך של מחרוזות .התא מספר
אפס ַבמערך כולל את שם התכנית )בדוגמה שלנו  .(sumהתאים הבאים כוללים
את המחרוזות הנוספות ,זו אחר זו.
בתכנית שלנו אנו מגדירים משתנה בשם  sumשיכיל את סכום הארגומנטים .אחר
אנו מבצעים לולאה שעוברת על הארגומנטים ,החל בארגומנט מספר אחד )שכן
] argv[0כולל ,כזכור ,את שם התכנית( ,עבוּר כל אחד ואחד מארגומנטים הלולאה
ממירה את המחרוזת המצויה בתא המתאים במערך  argvלכדי ערך שלם
)באמצעות הפונקציה  ,atoiששמה הוא קיצור של ,(ascii to integer
ומוסיפה את הערך השלם ַלמשתנה  .sumלבסוף הפונקציה מציגה את ערכו של
.sum
נראה דוגמה נוספת :נניח כי ברצוננו לכתוב תכנית בשם  progאשר מקבלת שתי
ְ
מחרוזות ומודיעה האם כל אחד ואחד מתווי המחרוזת הראשונה מצוי במחרוזת
השנייה .לדוגמה עבור ההרצה prog abbca cba :תציג התכנית את הפלט 'כן',
שכן כל אחד ואחד מתווי המחרוזת  abbcaמצוי במחרוזת  .cbaלעומת זאת עבור
ההרצה prog xyz xy :יוצג הפלט 'לא' ,שכן התו ’ ‘zמופיע במחרוזת הראשונה
אך לא בשנייה.
34
התכנית תיכתב באופן הבא:
{ )int main(int argc, char **argv
{ )if (argc != 3
]cout << "Program should be run:" << arv[0
; ”<< “ <string> <string> \n
;) return( EXIT_FAILURE
}
)for (int i1 = 0; i1 < strlen( argv[1] ); i1++
{
)for (int i2 = 0; i2 < strlen( argv[2] ); i2++
)]if (arvg[2][i2] == argv[1][i1
; break
) ) ]if (i2 == strlen( argv[2
{
; "cout << "no\n
; )return(EXIT_SUCCESS
}
}
; ”cout << "yes\n
; )return(EXIT_SUCCESS
}
נסביר :ראשית ,אנו יודעים כי בתכנית זאת ערכו של  argcצריך להיות בדיוק שלוש
)שם התכנית פלוס שתי המחרוזות המועברות כארגומנטים לתכנית( ,לכן אם ערכו
של  argcשונה משלוש סימן שהמשתמש לא הריץ את התכנית כהלכה; אנו
משגרים לו הודעת שגיאה ,ועוצרים את ביצוע התכנית.
ַבהמשך אנו נכנסים ללולאה כפולה .הלולאה החיצונית עוברת על כל תווי
] .argv[1עבור כל תו ותו הלולאה בודקת האם הוא מצוי ב .argv[2] -עבור כל
ערך קבוע של  ,i1כלומר עבור כל תו של ] argv[1הלולאה הפנימית בודקת האם
תא זה מצוי ב .argv[2] -הלולאה הפנימית רצה על כל תאי ] argv[2ובמידה
והיא מוצאת תא ] argv[2][i2שערכו שווה ל argv[1][i1] -היא מסיקה
שהתו המתאים ב argv[1] -מצוי ב .argv[2] -במקרה שכזה אנו שוברים את
ביצוע הלולאה הפנימית .אחרי הרצת הלולאה הפנימית ,אם ערכו של  i2הוא
כאורכה של המחרוזת ] argv[2סימן שהתו לא נמצא .במקרה שכזה אנו מציגים
הודעה מתאימה למשתמש ,ועוצרים את ביצוע התכנית .מנֵגד ,אם מיצינו את
הלולאה החיצונית ,בלי שהודענו כי תו כלשהו מ argv[1] -אינו מצוי ב,arv[2] -
סימן שכל תווי ] argv[1מצויים ב .argv[2] -אנו מודיעים על כך ,ועוצרים.
11.12
תרגילים
11.12.1
תרגיל מספר אחד :איתור תת-מערך במערך
ממשו את הפונקציה הבאה:
int array[],
int array_size,
int lower_bound,
int upper_bound,
)*subarray_sizep
35
int *subarray_in_range(const
const
const
const
=int
בהינתן המערך  arrayשגודלו  array_sizeעליכם למצוא את תת המערך
הראשון )קרי הקרוב ביותר לתחילת המערך  (arrayמגודל לא טריביאלי )עם יותר
מאבר יחיד( שכל אבריו נמצאים בין  lower_boundלבין  ,upper_boundכולל
שני הגבולות.
אם מצאתם תת מערך כזה ערך החזרה של הפונקציה יהיה כתובתו של האבר
הראשון בתת המערך ,ו *subarray_sizep -יעודכן לגודלו של תת מערך זה.
אחרת ערך החזרה ו *subarray_sizep -יעודכנו שניהם לאפס.
שימו לב :על הפונקציה שלכם להיכתב תוך שימוש במצביעים בלבד.
אל תגדירו משתנים מכל סוג אחר ,ואל תשתמשו בsubarray_sizep -
לאף מטרה למעט זו שלשמה נועד.
11.12.2
תרגיל מספר שתיים :מסד נתוני משפחות
תרגיל  11.6.4מתרגל גם מערכים ומצביעים באופן אינטנסיבי.
11.12.3
תרגיל מספר שלוש :טיפול בסיסי במערכים ומצביעים
בתכנית מוגדרים:
{ struct a_nums_list
; int *nums
; unsigned int list_len
; }
{ struct many_nums_lists
; a_nums_list *lists
; unsigend int num_of_lists
; }
; many_nums_lists arr
המשתנה  arrמסוגל )אם יעשה בו שימוש מתאים( להיהפך למערך שיכיל שורות
של מספרים שלמים ,כל-אחת באורך שונה .לצד כל שורה נחזיק את אורכה ,וכן
נחזיק את מספר השורות שהמשתנה  arrמחזיק בכל נקודת זמן) .ראשית בררו
לעצמכם מדוע וכיצד מתקיימים כל ההיגדים שנזכרו עד כה( .בתכנית שנכתוב נשאף
שלא להחזיק שורות יותר מהמינימום ההכרחי ,ואורכה של כל שורה לא יהיה ארוך
מהמינימום ההכרחי.
כתבו את הפונקציה ַ .add_valלפונקציה ארבעה פרמטרים :מערך )בשם (arr
מטיפוס  ,many_nums_listsערך שלם ) (valאותו יש להכניס ַלמערך ,מספר
שורה ) (lineלתוכה יש להכניס את הערך ,ומספר תא רצוי ) (cellבאותה שורה.
הפונקציה תכניס את הערך  valלמקום  cellבשורה  lineבמערך  arrתוך שהיא
דואגת להיבטים הבאים:
 .1אם ב arr -אין די שורות אזי הוא יוגדל כך שתהייה בו שורה מספר .line
 .2אם בשורה הרצויה אין די מקום אזי היא תוגדל כך שיהיה בה תא מספר
ַ .placeבתאים הריקים בשורה יושם הערך הקבוע .EMPTY
36
37
תוקן 10/10
struct 12
מבנים ) (structuresהם כלי נוסף אשר יסייע לנו לשפר את סגנונן של התכניות שאנו
כותבים.
 12.1מוטיבציה
נניח כי ברצוננו לכתוב תכנית אשר מחזיקה נתונים אודות המשתמש בה .בתכנית
נרצה לשמור את שמו הפרטי ,מינו ,ומספר הנעליים של המשתמש .לשם כך נוכל
להגדיר משתנים:
; ]char name[N
; bool gender
; unsigned int shoe_num
כדי לקרוא את הנתונים הדרושים נכתוב את הפונקציה:
void read_data(char name[], bool &gender,
;)unsigned int &shoe_num
הפונקציה מקבלת את פרמטרי ההפניה הדרושים.
צורת הכתיבה שהצגנו היא לגיטימית ,אך היא אינה די מדגישה כי שלושת
המשתנים שהגדרנו מתארים שלושה היבטים של אותו אדם ,ועל כן הם אינם בלתי
קשורים זה לזה כפי ששלושה משתנים כלשהם עשויים להיות.
נרצה ,על-כן ,כלי באמצעותו נוכל 'לארוז' יחד לכדי 'חבילה אחת' מספר משתנים
המתארים מספר היבטים שונים של אותו אובייקט .הכלי שמעמידה לרשותנו שפת
 Cלשם השגת המטרה הוא ה.struct -
ַבתכנית בה אנו דנים ,נוכל לכתוב מתחת להגדרת הקבועים:
{ struct User
; ]char _name[N
; bool _gender
; unsigned int _shoe_num
; }
שימו לב לכמה הערות קטנות:
א .שם של טיפוס\סוג מבנה נהוג שיתחיל באות גדולה.
ב .שם של חברים במבנה נהוג להתחיל עם קו תחתון.
ג .אחרי הסוגר הימני מופיעה נקודה-פסיק )כמו ב.(enum -
מה קבלנו? אמרנו בכך למחשב כי אנו מגדירים בזאת 'סוג חבילה' שנקרא User
)המילה השמורה  structהיא ֵשמורה שאנו מגדירים כאן 'סוג חבילה' ,והמילה
שמופיעה אחריה מתארת את שם 'החבילה' ,במקרה שלנו .(User :בתוך הסוגריים
המסולסלים אנו מתארים אילו מרכיבים יהיו בכל חבילה מסוג .User
אם אנו מעוניינים בכך אנו יכולים להגדיר בתכנית גם סוגים נוספים של חבילות.
לדוגמה:
{ struct Course
; ]char _name[N
; unsigned int _weight
; char _for_year
38
;}
'חבילה' מסוג  Courseמיועדת לתיאור נתונים אודות קורסים :שם הקורס ,מספר
נקודות הזכות שהוא מקנה ,לאיזה שנה אקדמית הוא מיועד )….(a,b,c,
נדגיש כי הגדרת 'חבילה' אינה מקצה זיכרון בו מאוחסן משתנה כלשהו; היא רק
מתארת סוג 'מארז' המכיל היבטים שונים המתארים נושא כלשהו אותו ברצוננו
לייצג בתכניתנו .כלומר ,לעת עתה לא ניתן לשמור בתכנית שלנו מידע אודות
משתמשים )או אודות קורסים(.
אחרי שהגדרנו -structים כפי צרכינו ) ַבמקום שבין הגדרת הקבועים להצהרה על
הפרוטוטיפים( אנו יכולים בגוף התכנית להגדיר משתנים:
; struct User user1, user2
; struct Course c1
בכך הגדרנו זוג משתנים  user1, user2שהם חבילות ,כלומר כל-אחד מהם כולל
user1._name, user1._gender,
שלושה מרכיבים .המרכיבים הם
 . user1._shoe_numמשמע כדי לפנות למרכיב כלשהו עלינו לציין את שם
המשתנה ,אחר-כך את התו נקודה ,ואחר-כך את שם המרכיב .בשפת  Cאנו קוראים
לכל מרכיב ב struct -בשם חבר ) .(memberבשפות אחרות המרכיבים נקראים
שדות ) .(fieldsחבר ְבמבנה הוא משתנה ככל משתנה אחר מהטיפוס המתאים .לכן
אנו רשאים לכתוב פקודות כגון הבאות:
; cin >> setw(N) >> user1._name
; )cout << strlen(user1._name
; cin >> user1._shoe_num
)if (user1._shoe_num < 19 || user1._shoe_num > 55
; ”cout << "Are u shure?\n
; cin >> i
)if (i == 1
; user1._gender = FEMALE
אנו רואים ,אם כן ,כי  struct Userמאפשר לנו לאגד את ההיבטים השונים של
המשתמש בתכניתנו לכדי משתנה יחיד.
אעיר כי בשפת סי חובה להגדיר את המשתנה באופןstruct User user1 ; :
בעוד בשפת  C++ניתן לכתוב גם ,User usr1 :כלומר להשמיט את המילה
 .structלטעמי ,גם בשפת  C++לא כדאי להשמיטה וזאת כדי שלקורא יהיה ברור
שהטיפוס  Userהוא טיפוס של מבנים.
 12.2העברת  structכפרמטר לפונקציה
נניח כי בתכנית כלשהי הגדרנו את  , struct Userואת המשתנים
 .user2עתה נרצה לכתוב פונקציה  read_dataאשר תקרא נתונים לתוך משתנה
מטיפוס  .struct userהפונקציה תיכתב באופן הבא:
user1,
{ )void read_data(struct User &an_user
; int temp
; cin >> setw(N) >> an_user._name
; cin >> an_user._shoe_num
; cin >> temp
; an_user._gender = (temp == 1) ? FEMALE : MALE
39
}
זימון הפונקציה יעשה באופן הבא . read_data(user1); :מכיוון שהפרמטר של
הפונקציה הוא פרמטר הפניה אזי שינויים שהפונקציה תכניס לתוך הפרמטר
 an_userיישארו בתום ביצוע הפונקציה בארגומנט המתאים )בדוגמה שלנו:
.(user1
נבחן עתה פונקציה מעט שונה שתדגים לנו מה קורה עת מבנה מועבר כפרמטר ערך.
ראשית נציג את הפונקציה ,ואחר נדון בה:
{ )void f(struct User u
; )”strcpy(u._name, “dana
}
של-
ַמנים את הפונקציה  fבאופן הבא f(user1); :וזאת אחרי ֵ
נניח כי אנו מז ְ
 user1הוזנו פרטי המשתמש יוסי .האם בתום ביצוע הפונקציה יכיל user1.name
את הערך דנה? בעבר ראינו כי לפונקציה יש את הכוח לשנות את ערכם של תאי
מערך המועבר לה כפרמטר .בפרק  10גם הבנו מדוע הדבר קורה :שכן ַלפונקציה
מועבר מצביע לתא הראשון של המערך ,והשינויים שהפונקציה מבצעת קורים על
המערך המקורי .עתה נלמד כי אם המערך הוא חלק ממבנה המועבר לפונקציה
כפרמטר ערך אזי אין בכוחה של הפונקציה לשנות את ערכם של חברי המבנה
בארגומנט ִעמו היא נקראה ,בפרט לא את ערכו של החבר  ;nameועל-כן בתום
ביצועה של הפונקציה  fימשיך  user1.nameלהכיל את הערך ” .“yosiהסיבה
לכך היא שעת מבנה מועבר כפרמטר ערך יוצר המחשב עותק חדש של המבנה
במסגרת המחסנית של הפונקציה )הנבנית על-גבי המחסנית( .השינויים שהפונקציה
שבפרמטר )ולא על הארגומנט(.
עורכת מתבצעים על העותק ַ
נדגים זאת בעזרת ציור המחסנית :נתחיל מכך שבתכנית הראשית הוגדרו שני
משתנים  user1, user2המכילים ערכים כמתואר )בציור הצגנו כל מבנה כמלבן
נפרד בתוך המלבן המייצג את המחסנית(:
= user1
"_name = "yosi
_shoe_num = 36
_gender = MALE
= user2
"_name = "yafa
_shoe_num = 46
_gender = FEMALE
40
stack frame
of main
עתה נניח כי אנו קוראים . f(user1); :נציג את מצב המחסנית:
= user1
"name = "yosi
shoe_num = 36
gender = MALE
stack frame
of main
= user2
"name = "yafa
shoe_num = 46
gender = FEMALE
=u
"name = "yosi
shoe_num = 36
gender = MALE
stack frame
of f
עת  fמבצעת את הפעולה strcpy(u._name, “dana”); :מועתק הסטרינג
” “danaעל החבר  _nameבפרמטר  uשל  .fערכו של  user1._nameאינו משתנה.
ראינו כי עת מבנה מועבר כפרמטר ערך יוצר המחשב עותק נוסף של המבנה .עבור
מבנה גדול ,הכולל חברים רבים ,ביניהם מערכים ,תחייב עבודת ההעתקה השקעה
של זמן ושל זיכרון .על-כן נהוג להיזהר מלהעביר מבנים גדולים כפרמטרי ערך.
במקום זאת נוהגים להעבירם כפרמטרי הפניה ,ואז נשלח חץ )עם ראש משולש
שחור( מהפרמטר לארגומנט המתאים ,ולא מתבצעת ההעתקה של המבנה.
לחילופין ,ניתן להעביר מצביע למבנה ,ובכך שוב לחסוך את עבודת ההעתקה.
אם ברצוננו למנוע מהפונ' לשנות את המבנה אזי נעבירו כפרמטר הפניה קבוע:
 . const User &uבאופן זה ,מצד אחד תודות לכך שזהו פרמטר הפניה לא
מבוצעת עבודת העתקה )הדורשת זמן וזיכרון( ,ומצד שני ,תודות לכך שהפרמטר
הוא קבוע ,לא ניתן לשנותו .שימו לב שפרמטר שהוגדר כך אינו זהה לפרמטר ערך,
שכן פרמטר ערך פונ' רשאית לשנות )רק שהשינוי לא ייוותר בארגומנט( ,כאן הפונ'
אינה רשאית לשנות כלל את הפרמטר.
 12.3מערך של struct
בסעיף הקודם ראינו כיצד מגדירים מבנה יחיד .לעיתים אנו זקוקים למערך של
מבנים .לדומה :נניח כי ברצוננו לשמור נתונים אודות כל תלמיד ותלמיד הלומד את
הקורס 'מבוא לאגיפטולוגיה' .עבור כל תלמיד נרצה לשמור את שמו הפרטי ,את
מינו ,מספר הנעליים שלו ,ומערך שיכיל את ציוניו בתרגילים השונים שניתנו בקורס.
נגדיר לכן:
{ struct Stud
; ]char _name[N
; bool _gender
; unsigned _int shoe_num
; ]int _grades[MAX_EX
; }
41
אחר ,בתכנית הראשית ,נגדיר . struct Stud egypt[MAX_STUD]; :מה
קיבלנו? מערך שכל תא בו הוא מבנה מטיפוס .struct Stud
עתה נוכל לקרוא לפונקציה . read_data(egypt); :אנו מעבירים לפונקציה את
המערך ,על-מנת שהיא תקרא לתוכו נתונים .כיצד תראה הגדרת הפונקציה? בפרט:
האם את הפרמטר שלה נצטרך להגדיר כפרמטר הפניה? לא ,ולא! אנו יודעים כי עת
מערך מועבר כפרמטר לפונקציה בכוחה של הפונקציה לשנות את ערכם של תאי
המערך ,שכן הפונקציה למעשה מקבלת מצביע לתא מספר אפס במערך ,וכל
השינויים שהפונקציה עורכת מתבצעים על המערך המקורי .נציג את הפונקציה
 ,read_dataנסבירה ,ואחר גם נציג את מצב המחסנית בעקבות הקריאה:
{ ) ][void read_data( struct Stud egypt
; int temp
{ )for (int i= 0; i < MAX_STUD; i++
; cin >> setw(N) >> egypt[i]._name
; cin >> egypt[i]._shoe_num
)for (int ex = 0; ex < MAX_EX; ex++
; ]cin >> egypt[i]._grades[ex
; cin >> temp
; egypt[i]._gender = (temp == 1) ? FEMALE : MALE
}
}
הסבר הפונקציה :הפונקציה מקבלת מערך של -Studים ,ולכן טיפוס הפרמטר שלה
הוא ][ .struct Stud egyptהפונקציה מתנהלת כלולאה אשר קוראת נתונים
בפקודה:
למשל
נדון
המערך.
תאי
לתוך
; . cin >> setw(N) >> egypt[i]._nameהפרמטר  egyptהוא מערך,
לכן כדי לפנות לתא רצוי בו עלינו לכתוב ביטוי עם סוגריים מרובעים ,כדוגמת
] . egypt[iהתא ] egypt[iהוא מבנה מטיפוס  ;Studכדי לפנות לחבר רצוי
במבנה אנו משתמשים בצורת הכתיבה :שם-המבנה.שם-החבר ,ובמקרה שלנו:
 . egypt[i]._nameזהו מערך של תווים ,ולכן אנו יכולים לקרוא לתוכו מחרוזת.
עתה נבחן את לולאת קריאת הציונים .בלולאה זאת המשתנה  exעובר על מספרי
התרגילים השונים .עת ערכו הוא ,למשל ,שלוש אנו קוראים ערך לתוך
] egypt[i]._grades[3נסביר egypt :הוא מערך ,ולכן כדי לפנות ַלתא של
התלמיד מספר  iעלינו לכתוב . egypt[i] :התא ] egypt[iהוא תא ְבמערך של
מבנים ,ולכן משתנה מטיפוס  ;Studלכן כדי לפנות לחבר  gradesבמבנה עלינו
לכתוב . egypt[i]._grades :החבר  egypt[i]._gradesהוא מערך ,ולכן
כדי לפנות לתא מספר שלוש בו עלינו לציין אינדקס רצוי:
]. egypt[i]._grades[3
עתה נבחן את מצב המחסנית .נתחיל במערך שהוגדר בתכנית הראשית:
= egypt
=name
=gender
=shoe
=grades
=name
=gender
=shoe
=grades
42
=name
=gender
=shoe
=grades
המערך  egyptכולל שלושה תאים .כל תא הוא מטיפוס  ,struct Studולכן מכיל
ארבעה חברים :החבר  _nameהוא למעשה מערך ,אולם מכיוון שאנו חושבים עליו
כעל מחרוזת לא ציירנו אותו כמערך; החברים  _shoe_numו _gender -הם
חברים פרימיטיביים ,וציירנו אותם כפי שאנו מציירים כל משתנה פרימיטיבי;
החבר  _gradesהוא מערך סטטי בן ארבעה תאים ,ולכן ציירנו את תאיו ,וכן
מצביע משם החבר לתא מספר אפס במערך .אנו זוכרים כי  egyptהוא למעשה
מצביע לתא מספר אפס במערך המבנים ,ולכן לצד שם המשתנה ציירנו מצביע לתא
הראשון במערך.
עת מתבצעת קריאה לפונקציה  ,read_dataאשר מקבלת את המערך ,egypt
כלומר מצביע לתא מספר אפס של המערך ,נראית המחסנית באופן הבא:
= egypt
=name
=gender
=shoe
=grades
=name
=gender
=shoe
=grades
=name
=gender
=shoe
=grades
= egypt
= i
= ex
אנו רואים כי מהפרמטר  egyptשל הפונקציה נשלח מצביע לתא מספר אפס במערך
של התכנית הראשית) .בדיוק כפי שנשלח מצביע מהמשתנה  egyptשל התכנית
הראשית לאותו מערך( .לכן עת הפונקציה פונה ל egypt[1]._shoe_num -היא
מעדכנת ישירות את החבר  _shoe_numבתא מספר אחד במערך המקורי )והיחיד
הקיים(.
עתה נניח כי ברצוננו לכתוב את הפונקציה  read_dataבצורה שונה:
{ ) ][void read_data( struct tud egypt
)for (int i= 0; i < MAX_STUD; i++
; ) ]read_1_stud( egypt[i
}
כלומר הפונקציה  read_dataקוראת בלולאה לפונקציה נוספת .read_1_stud
הפונקציה הנוספת תקרא נתונים של תלמיד יחיד ,ולכן הפונקציה read_data
מעבירה לפונקציה  read_1_studכארגומנט תא יחיד במערך  ,egyptכלומר
מבנה יחיד מטיפוס  .studעתה נשאל כיצד יראה הפרוטוטיפ של הפונקציה
 ?read_1_studהאם הפרמטר המועבר לה הוא פרמטר ערך או פרמטר הפניה?
התשובה היא שעל-מנת שלפונקציה  read_1_studיהיה את הכוח לשנות את ערכו
של הארגומנט )מטיפוס  (studהמועבר לה עליה לקבלו כפרמטר הפניה .שהרי
הפונקציה  read_1_studמקבלת מבנה יחיד ,וכבר מיצינו כי על-מנת שלפונקציה
43
המקבלת מבנה יהיה את הכוח לשנות את הארגומנט המועבר לה ,היא חייבת לקבל
את המבנה כפרמטר הפניה .נציג ראשית את הפונקציה:
{ )void read_1_stud(sruct Stud &a_stud
; int temp
; cin >> a__stud.name
; cin >> a_stud._shoe_num
)for (int ex = 0; ex < MAX_EX; ex++
; ]cin >> a_stud._grades[ex
; cin >> temp
; a_stud._gender = (temp == 1) ? FEMALE : MALE
}
בקוד של הפונקציה אין רבותא .אך נציג את מצב המחסנית עת אנו קוראים
לפונקציה באופן הבאread_1_stud(egypt[2]); :
= egypt
=name
=gender
=shoe
=grades
=name
=gender
=shoe
=grades
=name
=gender
=shoe
=grades
= egypt
= i
= ex
= a_stud
= temp
= ex
אנו רואים כי מהפרמטר  a_studשל הפונקציה נשלח חץ )עם ראש משולש שחור(
לתא מספר שתיים במערך של התכנית הראשית .ועל כן כל שינוי שהפונקציה
מכניסה לפרמטר שלה מתחולל למעשה על התא המתאים במערך המקורי.
מה היה קורה לו הפונקציה  read_1_studהייתה מוגדרת באופן הבא:
; )void read_1_stud(struct Stud a_stud
כלומר לוּ הפרמטר של הפונקציה היה פרמטר ערך? אזי הקריאה
)] read_1_stud(egypt[2הייתה מעתיקה את תוכנו של התא מספר שתיים
במערך  egyptעל מבנה בשם  a_studשהיה מוקצה ברשומת ההפעלה של
 . read_1_studהפרמטר  a_studכבר לא היה מורה על התא מספר שתיים
במערך המקורי .כל שינויי ש read_1_stud -הייתה מכניסה לפרמטר שלה היה
מתחולל על העותק השמור ברשומת ההפעלה שלה במחסנית .כמובן שבתום ביצוע
הפונקציה ערכו של ] egypt[2היה נותר כפי שהוא היה טרם הקריאה.
44
 12.4מצביע ל struct -כפרמטר ערך
את הפונקציה  read_1_studאנו יכולים לכתוב גם בדרך שונה .במקום
שהפונקציה תקבל פרמטר הפניה מטיפוס  ,studהיא תקבל מצביע למבנה מטיפוס
.studהאפקט שיתקבל מבחינה תכנותית יהיה אותו אפקט .את הפונקציה נכתוב
באופן הבא:
{ )void read_1_stud( stud *stud_p
; int temp
; cin >> setw(N) >> (*stud_p)._name
; cin >> (*stud_p)._shoe_num
)for (int ex = 0; ex < MAX_EX; ex++
; ]cin >> (*stud_p)._grades[ex
; cin >> temp
; (*stud_p)._gender = (temp == 1) ? FEMALE : MALE
}
נסביר :עתה הפרמטר של הפונקציה מוגדר להיות ,sruct Stud *stud_p
כלומר מצביע למבנה מטיפוס  .Studעל כן בגוף הפונקציה ,כדי לפנות למבנה עליו
 stud_pמצביע עלינו להשתמש בכתיבה' .*stud_p :היצור' *stud_p :הוא
מבנה ,ועל כן בו עלינו לפנות לחבר המתאים ,למשל. (*stup_p).gender :
הקריאה לפונקציה בגרסתה הנוכחית תהיהread_1_stud( &(egypt[i])); :
 ,כלומר אנו מעבירים לפונקציה מצביע לתא מספר  iבמערך.
ציור המחסנית יישאר כפי שהוא היה בגרסה הקודמת פרט לשינוי יחיד :החץ )עם
הראש השחור( שנשלח בגרסה הקודמת מהפרמטר  a_studלתא המתאים במערך,
מומר עתה במצביע )עם ראש בצורת  (vאשר נשלח מאותו מקום ולאותו מקום.
הסיבה לכך היא שעתה הפונקציה מקבלת מצביע )ולא פרמטר הפניה( .ובקריאה
לפונקציה מעבירים בצורה מפורשת מצביע לתא המתאים )באמצעות הכתיבה …&(,
ולא את התא המתאים כפרמטר הפניה )כפי שהיה בגרסה הראשונית(.
מכיוון שבשפת  Cמקובל מאוד להשתמש במצביע למבנה ,מאפשרת לנו השפה
לכתוב במקום ביטוי כגון (*stup_p)._gender :ביטוי כדוגמת:
במקום ראשית לפנות למבנה עליו מצביע stud_p
 . stup_p -> _genderכלומר ְ
) ,באמצעות  (*stud_pושנית ַלחבר המתאים במבנה זה )באמצעות
 ,( (*stud_p)._genderאנו כותבים ישירותְ :פנֵה לחבר ַבמבנה עליו מצביע
 .stud_pלכן את הפונקציה שכתבנו לאחרונה נוכל לכתוב גם בצורת הכתיבה
הבאה ,תוך שימוש בנוטציה שהצגנו זה עתה:
{ )void read_1_stud( struct Stud *stud_p
; int temp
; cin >> setw(N) >> stud_p-> _name
; cin >> stud_p-> _shoe_num
)for (int ex = 0; ex < MAX_EX; ex++
; ]cin >> stud_p-> _grades[ex
; cin >> temp
; stud_p-> _gender = (temp == 1) ? FEMALE : MALE
}
45
 12.5מצביע ל struct -כפרמטר הפניה
במקום להגדיר מערך סטאטי , int a[N]; :אנו יכולים להגדיר
בעבר ראינו כי ְ
פוטנציאל למערך בדמות מצביע ,ובהמשך לממש את הפוטנציאל על-פי צרכי
התכנית בעת ריצתה.
מצב דומה חל עם מבנים ,ואין כל הבדל עקרוני בין מבנים למשתנים פרימיטיביים.
נציג את הנושא עבור מבנים ,ולו בשם החזרה.
נניח כי בתכנית הראשית הגדרנו struct Stud *egypt_p; :ועתה ברצוננו
לקרוא לפונקציה  alloc_arr_n_read_dataאשר תקצה מערך בגודל המתאים,
ותקרא לתוכו נתונים .ניתן לכתוב את הפונקציה בדרכים שונות )למשל הפונקציה
עשויה לקבל פרמטר הפניה מטיפוס מצביע ל ,(stud -אנו נציג את הגרסה הבאה,
)בה הפונקציה מחזירה באמצעות פקודת  returnמצביע למערך שהוקצה על-ידה(:
{ )(sruct Stud *alloc_arr_n_read_data
; sruct Stud *arr_p
; int size
; cin >> size
; ]arr_p = new stud[size +1
)if (arr_p == NULL
; )terminate(“Memory allocation error\n“, 1
// end of data sign
; )”“ strcpy(arr_p[size]._name,
; )read_data(arr_p, size
) return( arr_p
}
הקריאה לפונקציה תהיה. egypt_p = alloc_arr_n_read_data(); :
הסבר הפונקציה :לפונקציה יש משתנה לוקלי . stud *arr_p :הפונקציה קוראת
מהמשתמש את גודלו של המערך הדרוש לו ואחר מקצה מערך הכולל תא אחד יותר
מהדרוש) .במידה וההקצאה נכשלת אנו עוצרים את ביצוע התכנית (.לתוך המרכיב
 _nameבתא האחרון שבמערך משימה הפונקציה את הסטרינג הריק ,וכך תדענה
פונקציות אחרות לזהות את סוף המערך .אחר הפונקציה קוראת לפונקציה
 read_dataאשר תקרא נתונים לתאי המערך .הפונקציה  read_dataדומה
לפונקציה שכתבנו בעבר ,פרט לכך שאנו מעבירים לה גם את מספר התאים לתוכם
 read_dataאמורה לקרוא נתונים ) read_dataכפי שנכתבה בעבר קראה ערכים
לתוך  MAX_STUDתאים במערך(.
לרצות
הדוגמה האחרונה תשרת אותנו כדי להכיר מצב נוסף בו אנו עשויים ְ
מצביע:
הוגדר
לאחרונה
שראינו
בתכנית
במבנה:
להשתמש
 , struct Stud *egypt_pואחר למצביע הוקצה מערך .את התא האחרון
במקום
במערך סימנו באמצעות שדה  nameשכלל את הסטרינג הריק )”“(ְ .
להשתמש בשיטת הסטרינג הריק לסימון סוף המערך נוכל לחשוב על אפשרות
אחרת ,נקייה יותר .ראשית ,בנוסף למבנה  ,studנגדיר מבנה:
{ struct Stud_arr
; struct Stud *_studs
; int _size
; }
46
כפי שאנו רואים ,מבנה מסוג  Stud_arrמכיל שני מרכיבים :המרכיב _studs
הוא פוטנציאל למערך של נתוני סטודנטים ,המרכיב  _sizeמציין את גודלו של
המערך עליו מצביע ._studs
עתה נגדיר בתכנית הראשית משתנה .stud_arr egypt; :שימו לב כי איננו
מגדירים מצביע כי אם מבנה )הכולל בתוכו מצביע( .נתאר את מצב המחסנית:
= egypt
= _studs
= _size
עתה נקרא לפונקציה alloc_arr_n_read_data2(egypt); :הפונקציה
מקבלת את הארגומנט  egyptכפרמטר הפניה .נבחן את הגדרת הפונקציה:
{ )void alloc_arr_n_read_data2(struct Stud_arr &egypt
; cin >> egypt._size
; ] egypt._studs = new stud[ egypt._size
)if (egypt._size == NULL
; )terminate(“Memory allocation error\n“, 1
; ) read_data2( egypt
}
הסבר :הפונקציה ראשית קוראת מהמשתמש את גודלו של המערך הדרוש לו לתוך
החבר  _sizeבפרמטר המשתנה  .egyptאחר הפונקציה מקצה מערך ל-
 egypt._studsשהוא מרכיב מטיפוס * ) struct Studכפי שאנו רואים
בהגדרת הטיפוס  ,Stud_arrשהוא טיפוסו של הפרמטר  . (egyptלבסוף
הפונקציה מזמנת את . read_data2( egypt ); :נבחן את מצב הזיכרון עם
הכניסה לפונקציה ) alloc_arr_n_read_data2וטרם שהיא הקצתה את
המערך(:
= egypt
= studs
= size
= egypt
47
הסבר :הפונקציה מקבלת פרמטר הפניה בשם  .egyptמכיוון שזה פרמטר הפניה
אנו שולחים חץ מהפרמטר לארגומנט המתאים .לכן עת הפונקציה פונה ל-
 egypt._sizeאנו הולכים בעקבות החץ ,ומעדכנים את המרכיב _size
בארגומנט שהועבר לפונקציה בקריאה לה .באפן דומה גם הקצאת זיכרון ַתפנה את
המצביע  studsבארגומנט של הפונקציה להצביע על המערך שהוקצה .נציג זאת
בציור:
= egypt
=name
=genger
=shoe
=grades
=name
=genger
=shoe
=grades
=name
=genger
=shoe
=grades
= _studs
_size = 3
= egypt
מטרתה של הדוגמה האחרונה הייתה להראות כי לעיתים מבנה עשוי להכיל) :א(
מערך כלשהו ,או מבנה אחר ,אשר שומרים את הנתונים בהם מעוניין המשתמש) ,ב(
נתונים אודות המערך )או המבנה( כגון גודלו ,מתי הוא עודכן לאחרונה ,לאילו מתאי
המערך יש להתייחס כריקים וכו'.
 12.6תרגילים
12.6.1
תרגיל מספר אחד :פולינומים
נחזור לתרגיל הפולינומים שהוצג בפרק שש .הפעם נייצג מונום באופן הבא:
{ struct monom
; float coef, degree
; }
כלומר עבור כל מונום נשמור את המקדם והחזקה.
פולינום ייוצג באופן הבא:
{ polynom
; monom *the_poly
; unsiged int num_of_monoms
; }
כלומר נחזיק מערך של מונומים ,ושדה המציין כמה מונומים מרכיבים את
הפולינום.
סדרה של פולינומים תיוצג ,לפיכך ,כמערך של מבנים מטיפוס  .polynomאת
המערך נוכל להגדיר סטטית ַבאופן:
; ]polynom polynoms[MAX_POLY
או שנקצה את המערך דינמית ,על הערמה ,ואז נוכל גם לשנות את גודלו במידת
הצורך; אם נבחר באפשרות זאת נגדיר את המצביע:
; polynom *polynoms
עתה כתבו שנית את תכנית הפולינומים כפי שתוארה בפרק שש ,תוך שאתם
משתמשים במבנה הנתונים שתיארנו בפרק זה.
48
12.6.2
תרגיל מספר שתיים :סידור כרטיסים בתבנית רצויה
המשחק לסדר תשעה קלפים
ֵ
במשחק בו על
ְ
בפרק שמונה הצגנו תרגיל אשר דן
בתבנית רצויה .ראשית חיזרו לתיאור המשחק כפי שמופיע שם .עתה נרצה לכתוב
שוב את התכנית הדרושה תוך שאנו עושים שימוש במבני נתונים חכמים יותר ,אשר
יגבירו את קריאותה של התכנית.
מבנה הנתונים הראשון שנגדיר ישמש לתיאור כל אחד מארבעת צדדיו של כל
כרטיס:
; } enum color { RED, YELLOW, GREEN, BLUE
{ stuct side
; bool head_tail
; color a_color
; }
כרטיס שלם מורכב מארבעה צדדים:
; } enum urdl { UP=0, RIGHT=1, DOWN=2, LEFT=3
{ struct card
; ]side sides[4
; urdl direction
; }
הטיפוס  urdlישמש אותנו הן כאינדקס למערך  sidesכדי להגביר את הקריאות
)] sides[UPהוא ביטוי קריא יותר מ ,(sides[0] -והן כדי לציין האם הכרטיס
מוצב כאשר הצד המתואר כ UP -אכן למעלה ,או שמא הוא מסובב ימינה ,הפוך
)כשצדו התחתון הוא שפונה כלפי מעלה( ,או שמאלה.
מאגר הכרטיסים אותם יש לסדר במהלך המשחק יתואר באופן הבא:
; ]card cards[9
ולתוכו יהיה על התכנית לקרוא את הנתונים מהמשתמש.
לבסוף ,על התכנית לסדר את תשעת הכרטיסים בתבנית הרצויה )תוך שימוש
באלגוריתם שתואר בפרק שמונה( ,ואת הסידור הנ"ל יתאר המערך:
; ]unsigned int arrange[3][3
כל תא במערך יכיל אינדקס של כרטיס במערך .cards
12.6.3
תרגיל מספר שלוש :בעיית מסעי הפרש
נחזור לבעיית מסעי הפרש ,כפי שהוצגה בפרק שמונה )תרגיל  .(8.7.10להזכירכם,
המשימה אותה היה עלינו לממש היא מלוי לוח שחמט בגודל  NxNבצעדי פרש ,תוך
שהפרש מבקר פעם אחת בדיוק בכל משבצת בלוח.
הפעם נרצה להשלים את המשימה תוך שימוש במבנים .נגדיר על-כן את המבנה:
ונגדיר את המשתנה:
{ stuct square
; bool visited
; unsigned int next_x, next_y
; }
;] .square board[N][Nמשתנה זה ייצג את הלוח.
התכנית הראשית תאתחל את המשתנה כך שערכי השדה  visitedבכל תאי
המערך יכילו את הערך .false
49
אחר ,התכנית הראשית תקרא מהמשתמש את מקומה של המשבצת ממנה על הפרש
ַ
להתחיל את מסעו.
הפונקציה הרקורסיבית שתהווה את לב ליבה של התכנית תמלא את הלוח בצעדי
הפרש תוך שהיא מסמנת בכל תא בו הפרש מבקר ,בשדה  ,visitedכי הפרש ביקר
בתא זה .השדות  next_x, next_yיתארו את המשבצת אליה התקדם הפרש
מהמשבצת הנוכחית.
בתום תהליך החיפוש הציגו פלט שיכלול שני מרכיבים:
א .רישום של סדרת המשבצות בהן ביקר הפרש ,בסדר בו בוקרו המשבצות השונות.
ב .תיאור של הלוח )כטבלה דו-ממדית( ,כאשר עבור כל משבצת מופיע מספר הצעד
בו בוקרה משבצת זאת.
12.6.4
תרגיל מספר ארבע :נתוני משפחות
בתרגיל זה נחזיק מסד נתונים הכולל נתוני משפחות.
כל משפחה בתרגיל תהיה משפחה גרעינית הכוללת לכל היותר אם ,אב ומספר
כלשהו של ילדים .נתוני משפחה יחידה ישמרו במבנה מהטיפוס:
struct family
{
; char *family_name
char *mother_name, *father_name ; // parents' names
; child *children_list
// list of the children in this family
; unsigned child_list_size
// size of child_list array
;}
נתוני כל ילד ישמרו ב:
struct child
{
; char *child_name
; bool sex
;}
מבנה הנתונים המרכזי של התכנית יהיהfamily *families :
התכנית תאפשר למשתמש לבצע את הפעולות הבאות:
 .1הזנת נתוני משפחה נוספת .לשם כך יזין המשתמש את שם המשפחה )וזהו
מרכיב חובה( ,ואת שמות בני המשפחה .במידה והמשפחה אינה כוללת אם יזין
המשתמש במקום שם האם את התו מקף )) (-המרכיב  mother_nameבמבנה
המתאים יקבע אז ,כמובן ,להיות  ,(NULLבאופן דומה יצוין שאין אב במשפחה.
לפני הזנת נתוני הילדים יוזן מספר הילדים במשפחה .התכנית תקצה מערך
הכולל שני תאים נוספים ,מעבר למספר הילדים הנוכחי במשפחה ,וזאת לשימוש
עתידי .שמות הילדים בתאים הריקים ייקבעו להיות  NULLוכך נסמן כי התאים
ריקים .תיתכן משפחה ללא ילדים .לא תיתכן משפחה בה אין לא אם ולא אב
)יש לתת הודעת שגיאה במידה וזה הקלט מוזן ,ולחזור על תהליך קריאת שמות
ההורים( .אתם רשאים להגדיר משתנה סטטי יחיד מסוג מערך של תווים לתוכו
תקראו כל סטרינג מהקלט .את רשימת המשפחות יש להחזיק ממוינת על-פי שם
משפחה .את רשימת ילדי המשפחה החזיקו בסדר בה הילדים מוזנים .הניחו כי
שם המשפחה הוא מפתח קבוצת המשפחות ,כלומר לא תתכנה שתי משפחות
50
להן אותו שם משפחה )משמע ניסיון להוסיף משפחה בשם כהן ,עת במאגר כבר
קיימת משפחת כהן מהווה שגיאה( .אין צורך לבדוק כי לא מוזנים באותה
משפחה שני ילדים בעלי אותו שם .במידה ומערך המשפחות התמלא יש
להגדילו.
דוגמה לאופן קריאת הנתונים:
Enter family name: Cohen
Enter father name: Yosi
 Enter mother name:Enter children's names: Dani Dana -
.2
.3
.4
.5
.6
.7
הוספת ילד למשפחה .יוזן שם המשפחה ושמו של הילד .הילד יוסף בסוף רשימת
הילדים .במידה ומשפחה בשם הנ"ל אינה קיימת יש להודיע על-כך למשתמש
)ולחזור לתפריט הראשי( .במידה ומערך הילדים מלא יש להגדילו בשיעור
שלושה תאים נוספים.
פטירת בן משפחה .יש להזין) :א( את שם המשפחה) ,ב( האם מדובר באם )ולשם
כך יוזן הסטרינג  ,(motherבאב )יוזן  ,(fatherאו באחד הילדים )יוזן ) (childג(
במידה ומדובר בילד יוזן גם שם הילד שנפטר .במידה ואין במאגר משפחה
כמצוין ,או שבמשפחה אין בן-משפחה כמתואר )למשל אין אב והתבקשתם
לעדכן פטירת אב ,או אין ילד בשם שהוזן( יש לדווח על שגיאה )ולחזור לתפריט
הראשי( .כמו כן לא ניתן לדווח על פטירת הורה במשפחה חד-הורית )ראשית יש
להשיא את המשפחה למשפחה חד-הורית אחרת ,כפי שמתואר בהמשך ,ורק אז
יוכל ההורה המתאים לעבור לעולם שכולו טוב(.
נשואי שתי משפחות קיימות .יש לציין את שמות המשפחות הנישאות ,ואת שם
המשפחה החדש שתישא המשפחה המאוחדת .פעולה זאת לגיטימית רק אם
אחת משתי המשפחות אינה כוללת אם בעוד השניה אינה כוללת אב .בעקבות
ביצוע הפעולה תאוחדנה רשימות הילדים והתא שהחזיק את נתוני אחת משתי
המשפחות ייחשב כריק ,ולכן תוזזנה הרשומות בהמשך המערך ,כדי לצמצם
הפער.
הצגת נתוני המשפחות בסדר עולה של שמות משפחה) .זהו ,כזכור ,הסדר בו
מוחזקת רשימת המשפחות( .עבור כל משפחה ומשפחה יש להציג את שם
המשפחה ,ואת שמות בני המשפחה.
הצגת נתוני משפחה בודדת רצויה .יש להזין את שם המשפחה ויוצגו פרטי בני
המשפחה) .במידה והמאגר אינו כולל משפחה כמבוקש תוצג הודעת שגיאה(.
הצגת נתוני המשפחות בסדר יורד של מספר בני המשפחה .יש להציג את שם
המשפחה ואת מספר בני המשפחה .לשם סעיף זה החזיקו מערך נוסף .כל תא
במערך יהיה מטיפוס:
struct family_size
{ unsigned the_family ; // index of a family in the families array
; int size
; // number of children in this family
;}
המצביע למערך זה יהיה  .family_size *families_sizesהמערך ימוין בסדר יורד
של המרכיב  .sizeעל-ידי סריקה של מערך זה ופניה ממנו בכל פעם למשפחה
המתאימה )תוך שימוש באינדקס  (the_familyתוכלו להציג את המשפחות בסדר
יורד של גודל המשפחות .שימו לב כי יש גם לתחזק רשימה זאת .בין כל
המשפחות להן אותו גודל אין חשיבות לסדר ההצגה
 .8הצגת נתוני שכיחות שמות ילדים .יש להציג עבור כל שם ילד המופיע במאגר,
כמה פעמים הוא מופיע במאגר .אין צורך להציג את הרשימה ממוינת.
)רמז\הצעה :סרקו את מאגר המשפחות משפחה אחר משפחה ,עבור כל משפחה
עיברו ילד אחר ילד ,במידה ושמו של הילד הנוכחי עדיין לא מופיע ברשימת
51
הילדים ששמם הודפס סיפרו כמה פעמים מופיע שם הילד במאגר הנתונים,
ואחר הוסיפו את שמו של הילד לרשימת שמות הילדים שהוצגו(.
 .9סיום .בשלב זב עליכם לשחרר את כל הזיכרון שהוקצה על-ידכם דינמית.
הערות כלליות:
א .התכנית תנהל כלולאה בה בכל שלב המשתמש בוחר בפעולה הרצויה לו ,הפעולה
מתבצעת )או שמוצגת הודעת שגיאה( ,והתכנית חוזרת לתפריט הראשי.
ב .הקפידו שלא לשכפל קוד ,למשל כתבו פונקציה יחידה אשר מציגה נתוני
משפחה ,והשתמשו בה במקומות השונים בהם הדבר נדרש .וכו'.
 Needless to sayשיש להקפיד על כללי התכנות המקובלים ,בפרט ובמיוחד
מודולריות ,פרמטרים מסוגים מתאימים )פרמטרי ערך  vsפרמטרי הפניה( ,ערכי
החזרה של פונקציות ושאר ירקות .תכנית רצה אינה בהכרח גם תכנית טובה! )קל
וחומר לגבי תכנית מקרטעת(…
תרגיל מספר חמש :סימולציה של רשימה משורשרת
12.6.5
נתונות ההגדרות הבאות:
; … = const int N
{ struct node
; char ch
; unsigned int next
; }
{ struct linked_list
; ]node list[N
; unsigned int head
; }
משתנה מטיפוס  linked_listמכיל שני מרכיבים:
הפניה לתא נוסף במערך .דוגמה אפשרית
א .מערך  listהמכיל בכל תא תו ,וכן ְ
למרכיב  listברשומה כלשהי:
d
i
a
n
a
o
x
y
x
s
7
#9
17
#8
6
#7
5
#6
1#5
0
#4
1
#3
4
#2
3
#1
8
#0
ב .מספר של תא כלשהו במערך ,השמור בשדה .head
מספר מחרוזות ,את כתובת ההתחלה של
נוכל להתייחס למרכיב המערך כמכיל ְ
אחת מהן מחזיק המרכיב  .headלדוגמה :אם ערכו של  headהוא  ,2אזי המחרוזת
בה מתמקדים מורכבת מהתווים הבאים :התו בתא מספר שתיים במערך ) ,(yמתו
זה אנו מתקדמים על פי השדה  nextלתא מספר ארבע במערך ,בו שמור התו ,o
משם לתא מספר אפס המכיל את  ,sומשם לתא מספר שמונה המכיל את  .iמכיוון
שבתא מספר שמונה מצוי ערך שאינו בתחום  0..N-1אנו מסיקים כי בכך תם
הסטרינג ,ולפיכך הסטרינג בו עסקינן הוא  .yosiבאופן דומה בתא מספר תשע
במערך מתחיל הסטרינג .dana
לעומת זאת בתא מספר שלוש במערך אנו מוצאים ַה ְפניה לתא מספר חמש ,בתא
מספר חמש קיימת הפניה לתא מספר שלוש ,וחוזר חלילה .אנו אומרים כי סטרינג
זה אינו נגמר ,ולכן הוא בגדר שגיאה.
52
כתבו פונקציה המקבלת מבנה מסוג  linked_listומציגה את הסטרינג שעל התו
הראשון שלו מורה המרכיב  headאלא אם הסטרינג הינו שגוי ,ואז תוצג הודעת
שגיאה ,ולא יוצג כל תו מהסטרינג.
53
 1/2011עודכן
 13רשימות מקושרות )משורשרות(
בפרקים הקודמים ראינו כיצד יש ביכולתנו לשמור סדרה של נתונים במערך
שמוקצה באופן סטטי או בכזה שמוקצה באופן דינמי .בפרק זה ,ובבא אחריו ,נכיר
דרך אחרת לשמירת סדרת נתונים.
 13.1בניית רשימה מקושרת
נניח כי בתכנית כשלהי עלינו לשמור כמות שאינה ידועה מראש של מספרים שלמים.
כמו כן לעיתים נרצה להוסיף נתונים לרשימה ,ולעיתים נרצה להסיר מהרשימה
נתונים שאין לנו בהם עוד צורך .נוכל נקוט לשם כך בפתרון הבא :נגדיר בתכנית את
המבנה:
{ struct Node
; int _data
; struct Node *_next
; }
מהביצה,
ִ
מה קיבלנו? כמו הברון מנכאוזן שמשך את עצמו בשערות ראשו וכך נחלץ
כך אנו שקענו בביצה שעה שהגדרנו מבנה בשם  Nodeאשר אחד ממרכיביו הוא
מצביע ַלמבנה  .Nodeמייד נראה לאן תובילנו הגדרה מפתיעה ,אך לגיטימית זאת.
עתה נניח כי בתכנית הגדרנו , struct Node *head = NULL, *temp; :וכן
משתנה שלם  .numנציג את מצב המחסנית:
= head
= temp
= num
בגוף התכנית נכלול את הלולאה הבאה:
; cin >> num
{ )while (num != 0
; temp = new (std::nothrow) struct Node
)if (temp == NULL
; )terminate(“cannot alloc mem”,1
; temp -> _data = num
; temp -> _next = head
; head = temp
; cin >> num
}
נניח כי המשתמש מזין את הקלט 17) 0 3 3879 17 :מוזן ראשון 0 ,מוזן אחרון(.
נעקוב אחר התנהלות התכנית :אחרי קריאת הערך  17ל num -מוקצית על-גבי
הערמה קופסה יחידה מסוג  ,Nodeו temp -עובר להצביע עליה) .כאשר אנו כותבים
מבין
 ,new (std::nothrow) Nodeואיננו מציינים כמה תאים יש להקצותִ ,
המחשב כי ברצוננו להקצות תא יחיד( .לתוך המרכיב  _dataבקופסה עליה מצביע
 tempמוכנס הערך  ,17ולתוך המרכיב ) _nextשהינו מצביע לקופסות מסוג (Node
מוכנס הערך המצוי ב ,head -כלומר הערך  . NULLנציג את מצב המחסנית אחרי
54
ביצוע הפעולות הללו )ולפני ביצוע ההשמה .(head = temp; :מעתה נניח כי כל
מה שלא מופיע בתוך המסגרת המציינת את המחסנית ,מוקצה על הערמה.
= head
= temp
= num
=data
17
=next
מה תעשה ,עתה ,ההשמה ? head = temp; :היא תשלח את  headלהצביע על
אותה קופסה כמו  .tempנציג זאת על הזיכרון:
= head
= temp
= num
=data
17
=next
בכך מסתיים הסיבוב הראשון של הלולאה .אני מסב את תשומת לבכם לנקודה
חשובה :המצביע בקופסה של  ,17קיבל את ערכו של  headעת ערכו של האחרון היה
 ,NULLולכן ערכו הוא  .NULLהלולאה ממשיכה להתנהל .נפנה עתה למעקב אחר
ביצוע הסיבוב השני בה .בסיבוב זה  numמקבל את הערך  .3879הפקודה = temp
; new (std::nothrow) Nodeיוצרת קופסה חדשה על הערמה ,ומפנה את
 tempלהצביע על קופסה זאת ) ִב ְמקום על הקופסה עליה הוא הצביע בעבר ,ושעתה
רק  headמצביע עליה( .למרכיב ה _data -בקופסה עליה מצביע  tempאנו
ולמרכיב  _nextאנו משימים את הערך השמור ב,head -
משימים את הערך ַ ,3879
על מה מצביע  ?headעל הקופסה של  ,17לכן גם המצביע _ nextבקופסה עליה
מצביע  ,tempיצביע על הקופסה של  .17נציג את מצב המחסנית:
= head
= temp
= num
=data
17
=next
=data
3879
=next
הפקודה האחרונה בסיבוב זה של הלולאה הינה head = temp; :והיא מפנה את
 headלהצביע על אותה קופסה כמו  ,tempכלומר על הקופסה של  .3879מצב
המחסנית הינו:
= head
= temp
= num
=data
17
=next
=data
3879
=next
עתה אנו פונים לסיבוב השלישי בלולאה .בסיבוב זה אנו קוראים את הערך  3לתוך
המשתנה  .numשוב אנו מקצים קופסה חדשה ,ושולחים את  tempלהצביע עליה.
שוב אנו מכניסים ל ,temp-> _data -כלומר למרכיב ה data_ -בקופסה עליה
מצביע  ,tempאת הערך שב .num -ושוב אנו שולחים את  ,temp-> _nextכלומר
את המצביע )שטיפוסו *  (struct Nodeבקופסה עליה מצביע  tempלהצביע כמו
) headשגם טיפוסו הוא *  ;(struct Nodeהפעם הדבר גורם לtemp-> -
 _nextלהצביע על הקופסה של  .3879נציג זאת בציור:
= head
= temp
= num
=data
17
=next
=data
3879
data= 3
=next
=next
55
הפקודה האחרונה בגוף הלולאה היא head = temp; :והיא מפנה את head
להצביע על אותה קופסה כמו  ,tempכלומר על הקופסה של  .3נציג זאת:
= head
= temp
= num
=data
17
=next
=data
3879
data= 3
=next
=next
בסיבוב הבא בלולאה אנו קוראים את הערך אפס ,ובזאת מסיימים את ביצוע
הלולאה.
מה קיבלנו? המצביע  headמצביע על הקופסה של הנתון שהוזן אחרון )שלוש(,
המצביע בקופסה זאת מצביע על הקופסה הכוללת את הנתון שהוזן לפני אחרון
) ;(3879והמצביע בקופסה של  3879מורה על הקופסה של הנתון שהוזן ראשון ).(17
ולבסוף ,ערכו של המצביע בקופסה האחרונה הוא .NULL
קטע הקוד שבנה את הרשימה המקושרת כולל קריאה לפונקציה .terminate
פונקציה זו אינה חלק מהשפה ,אלא עלינו לממשה .הייתי ממליץ לכם לכלול
פונקציה זאת בכל תכנית שמקצה זיכרון:
{ )void terminate(const char *msg, int err_code
; cout << msg << endl
; ) exit( err_code
}
הסבר הפונקציה :הפונקציה מקבלת סטרינג ,וערך שלם .היא מציגה את הסטרינג
על המסך ,ואחר קוטעת את ביצוע התכנית ,תוך החזרת הערך השלם המועבר לה.
אני מזכיר לכם כי אם ברצונכם לכלול בתכניתכם את הפקודה  ,exitאזי עליכם
לכלול בתכנית הוראת  includeלקובץ . stdlib.h
מתכנתים מתחילים מוטרדים לעיתים מכך שהנתונים מופיעים ברשימה בסדר הפוך
לסדר הזנתם .מספר מענים יש לנו על-כך:
א .בקטע תכנית זה אנו מניחים כי מבחינת המשתמש אין חשיבות לסדר בו
מוחזקים הנתונים שבין כה וכה אינם ממוינים.
ב .אני מזמין אתכם לבנות את הרשימה כך שהנתונים ישמרו בה בסדר בו הם
הוזנו .כדי לעשות זאת החזיקו מצביע נוסף struct Node *last; :אשר
נק ֵצה קופסה
יצביע כל פעם לתא האחרון ברשימה .כל פעם שנקרא נתון נוסף ְ
חדשה עליה יצביע בתחילה  .tempאחר נַפנה את  last->_nextלהצביע כמו
 .tempולבסוף נקדם את  lastלהצביע על הקופסה שנוספה אך זה עתה לקצה
הרשימה .כיצד יאותחל  ?lastוכיצד יטופל  ?headזאת אני משאיר לכם.
ג .בהמשך נראה כיצד ניתן לבנות את הרשימה המקושרת כך שהנתונים יוחזקו בה
ממוינים.
 13.2הצגת הנתונים השמורים ברשימה מקושרת
לשם הפשטות לא הצגנו את בניית הרשימה המקושרת בפונקציה נפרדת .אולם
מעתה נבצע כל פעולה על הרשימה בפונקציה נפרדת )כפי שראוי היה לעשות גם עם
תהליך בניית הרשימה( .עתה נכתוב פונקציה אשר מציגה את הנתונים השמורים
56
ברשימה .הפונקציה תזומן באופן הבא . display_list(head); :נציג את
הפונקציה:
{ )void display_list(const struct Node *run
{ )while (run != NULL
; " " << cout << run-> _data
; run = run-> _next
}
}
הסבר :הפונקציה מתנהלת כלולאה אשר מתבצעת כל עוד ) . (run != NULLאנו
רשאים לנסח תנאי זה שכן עת בנינו את הרשימה דאגנו לכך שערכו של המצביע
 _nextבקופסה האחרונה יהיה  ,NULLולכן הלולאה תעצור בדיוק במקום
המתאים .בכל סיבוב בלולאה אנו מציגים את ערכו של המרכיב  _dataבקופסה
עליה מצביע  ,runואחר מקדמים את  runלאיבר הבא ברשימה באמצעות הפקודה:
; . run = run -> _nextנציג סימולציית הרצה שתסייע לנו להשתכנע בנכונות
הפונקציה ,ולהטמיע את השימוש ברשימה מקושרת :מצב הזיכרון עם הקריאה
לפונקציה הוא כדלהלן:
= head
= temp
= num
=data
17
=next
=data
3879
data= 3
=next
=next
= run
הסבר :הפרמטר  runהוא פרמטר ערך ,על-כן ערכו של  headמועתק על ,run
ומבחינה ציורית המצביע  runמצביע לאותו מקום כמו  .headמכיוון ש run -הוא
פרמטר ערך אזי שינויים שנערוך עליו במהלך ביצוע הפונקציה לא ישנו את ערכו של
 headשל התכנית הראשית;  headימשיך להצביע לראש הרשימה שבנינו.
עתה אנו מתחילים לבצע את הפונקציה .התנאי בלולאה מתקיים ) runמצביע על
קופסה נחמדה ויפה ,וערכו שונה מ ,(NULL -ולכן אנו נכנסים לסיבוב ראשון
בלולאה .אנו מציגים את  ,run-> _dataכלומר את ערכו של שדה הdata_ -
בקופסה עליה מצביע  ,runמשמע את הערך  .3אחר אנו מבצעים את הפקודה:
 . run = run-> _nextנסביר פקודה זאת :ראשית נבחן את אגף ימין של
ההשמה run .הוא מצביע run-> ,היא הקופסה עליה  runמצביע ,כלומר הקופסה
של  run-> _next ,3הוא השדה  nextבקופסה עליה  runמצביע ,כלומר המצביע
אשר מורה על הקופסה של  .3879את ערכו של מצביע זה אנו מכניסים ל ,run -ולכן
עתה גם  runמצביע לקופסה של  .3879בזאת הסתיים הסיבוב הראשון בלולאה.
נציג את מצב הזיכרון:
= head
= temp
= num
=data
17
=next
=data
3879
data= 3
=next
=next
= run
לפני כניסה לסיבוב השני בלולאה אנו בודקים את תנאי הלולאה .התנאי עדיין
מתקיים run ,מצביע ,כאמור ,לקופסה של  ,3879וערכו אינו  NULLכלל וכלל .אנו
57
נכנסים לסיבוב שני בלולאה .בסיבוב זה אנו שוב מציגים את ,run-> _data
כלומר את מרכיב ה _data -בקופסה עליה מצביע  ,runמשמע את הערך .3879
אחר אנו מבצעים שוב את הפקודה . run = run-> _next; :נבחן את האפקט
של פקודה זאת :כמו קודם נתחיל בבחינת אגף ימין של ההשמה run :הוא מצביע,
> run-היא הקופסה עליה  runמצביע run-> _next ,הוא המרכיב _next
בקופסה עליה  runמצביע .מרכיב זה הוא מצביע ,והוא מורה על הקופסה של .17
לכן אם אנו מפנים את  runלהצביע כמו  run-> _nextאנו שולחים גם את run
להצביע על הקופסה של  .17בזאת סיימנו את הסיבוב השני בלולאה .לקראת כניסה
לסיבוב השלישי אנו שוב בודקים את התנאי שבראש הלולאה; והוא עדיין מתקיים.
אנו נכנסים לסיבוב שלישי בלולאה .שוב אנו מציגים את  ,run-> _dataכלומר
את הערך  ,17ושוב אנו מקדמים את
 runבאמצעות הפקודה:
; . run = run-> _nextנבחן פקודה זאת )בפעם האחרונה ,אני מבטיח(run :
הוא מצביע run-> ,היא הקופסה עליה  runמצביע ,כלומר הקופסה של .17
 run-> _nextהוא מרכיב ה _next -בקופסה עליה  runמצביע; וערכו של
מרכיב זה הוא  .NULLלכן עת אנו משימים ל run -את ערכו של  run-> _nextאנו
מכניסים ל run -את הערך  .NULLלקראת סיבוב נוסף בלולאה אנו בודקים שוב את
התנאי ) .(run != NULLהפעם תנאי זה לא מתקיים ,וטוב שכך .איננו נכנסים
לסיבוב נוסף בלולאה ,ומכיוון שהפונקציה אינה כוללת דבר פרט ללולאה ,אנו
מסיימים בזאת את ביצוע הפונקציה.
 13.3בניית רשימה מקושרת ממוינת
בעבר ראינו כיצד בונים רשימה מקושרת בה כל איבר חדש נוסף בראש הרשימה.
עתה נרצה לבנות רשימה מקושרת ממוינת )מקטן לגדול( ,בה כל איבר חדש נוסף
לרשימה ַבמקום המתאים ,כך שבעקבות כל הוספה הרשימה נשארת ממוינת.
נניח כי בתכנית הוגדר המבנה  ,struct Nodeובתכנית הראשית הוגדר:
;struct Node *head = NULL
עתה ברצוננו לקרוא לפונקציה . build_list(head); :פונקציה זאת תבנה את
הרשימה ה מקושרת הממוינת .הפונקציה תקבל כארגומנט את .head
נתחיל בכך שנשאל את עצמנו האם הפונקציה צריכה לקבל את  headכפרמטר ערך
או כפרמטר הפניה? התשובה היא שעל הפונקציה לבנות רשימה מקושרת שעל
ראשה יצביע הארגומנט המועבר לה ,ובמקרה הנוכחי המשתנה  headשל .main
ערכו של  headהוא עתה  ,NULLכלומר על הפונקציה לשנות את ערכו של ,head
ולכן הפרמטר של פונקציה צריך להיות פרמטר הפניה.
נציג את הגדרת הפונקציה:
{ )void build_list(struct Node *&head
; int num
; cin >> num
{ )while (num != 0
; )insert(num, head
; cin >> num
}
}
58
הפונקציה שכתבנו מתנהלת כלולאה ,בה בכל פעם אנו קוראים נתון חדש )עד
קליטת אפס( ,ואז קוראים לפונקציה  insertאשר מכניסה את הנתון החדש
לרשימה .נעיר כי בתכניות המטפלות במבני נתונים שונים )כדוגמת רשימות
משורשרות כאלה או אחרות( מקובל לכתוב פונקציה ייעודית להוספת נתון נוסף
)שהתקבל ממקור כזה או אחר( למבנה הנתונים.
לפני שאנו פונים לפונ'  insertנעיר שאת הפונ'  build_lustאפשר ,וגם עדיף
לכתוב כך שהיא תחזיר מצביע לראש הרשימה המקושרת הנבנית על-ידה )ולא
תשתנה את ערכו של פרמטר ההפניה המועבר לה( .שהרי  build_listמחזירה לנו
ערך יחיד ,וכבר מיצינו שעת פונ' מחזירה ערך יחיד ,ראוי דהוא יוחזר באמצעות ערך
החזרה .על כן ראוי לכתוב את הפונ' באופן הבא:
{ )(struct Node *build_list
; struct Node *head = NULL
; int num
; cin >> num
{ )while (num != 0
; )insert(num, head
; cin >> num
}
; return head
}
זימון הפונ' ,בגירסתה נוכחית ,מהתכנית הראשית יהיה:
; )(head = build_list
59
נפנה עתה לדיון בפונ'  .insertלפונקציה  insertשני פרמטרים :הראשון הוא
הנתון שיש להוסיף לרשימה המקושרת הממוינת ,השני הוא מצביע לראש הרשימה.
נציג את הגדרת הפונקציה ואחר נדון בה בהרחבה:
{ )void insert(int num, struct Node *&head
; Node *front, *rear, *temp
{)if (head == NULL
// insert first item
; head = new (std::nothrow) struct Node
)if (head == NULL
;)terminate("can not alloc mem", 1
; head -> _data = num
; head -> _next = NULL
// end of list sign
}
{ )else if (num < head -> _data
// insert before
temp = new (std::nothrow) Node ; // current first
)if (head == NULL
;)terminate("can not alloc mem", 1
; temp -> _data = num
; temp -> _next = head
; head = temp
}
{ else
; rear = head
; front = rear -> _next
{ )while (front != NULL && front-> _data < num
; rear = front
; front = front -> _next
}
; temp = new (std::nothrow) struct Node
)if (temp == NULL
;)terminate("can not alloc mem", 1
; temp -> _data = num
; temp -> _next = front
; rear -> _next = temp
}
}
עת אנו כותבים פונקציה המטפלת ברשימה אנו מבחינים בין מספר מקרים שונים,
במילים אחרות בין מספר מצבים שונים .חובתנו לדאוג לכך שהפונקציה שנכתוב
תטפל בכל המצבים הללו:
א .הרשימה המועברת ריקהַ .בפונקציה  insertמשמעותו של מקרה זה היא כי
יש להוסיף לרשימה איבר ראשון.
ב .יש לשנות את ראש הרשימה .בפונקציה  insertמצב זה חל עת יש להוסיף
נתון בראש הרשימה.
ג .יש לערוך שינוי אי שם באמצע רשימה קיימת .בפונקציה  insertמצב זה חל
עת יש להוסיף איבר נוסף באמצע הרשימה.
ד .יש לערוך שינוי באיבר האחרון ברשימה .בפונקציה  insertמקרה זה מכוסה
על-ידי הטיפול המתבצע עבור המקרה מסעיף ג' .בפונקציות אחרות יש להכניס
טיפול מיוחד גם למקרה שכזה.
60
כדי להשתכנע שהפונקציה שלנו פועלת כהלכה נעקוב אחר ריצת התכנית עבור
הקלט 17) 0 3879 15 3 13 17 :מוזן ראשון  0מוזן אחרון( .נסמלץ את הריצה של
 build_listבגירסתה הראשונה ,הפחות מוצלחת ,זו בה היא מקבלת פרמטר
הפניה.
בתחילה ,מוגדר בתכנית הראשית המשתנה. struct Node *head = NULL; :
נציג את מצב המחסנית:
= head
עתה נערכת קריאה לפונקציה  ,build_listאשר מקבלת את  headכפרמטר
הפניה .מצב המחסנית הוא:
= head
= head
= num
אני מזכיר לכם כי פרמטר הפניה אנו מציירים באמצעות חץ עם ראש משולש שחור
הנשלח לכיוון הארגומנט המתאים; בניגוד לכך מצביע מצוייר כחץ עם ראש בצורת
 ,vהנשלח לכיוון האובייקט עליו המצביע מורה )במילים אחרות ,האובייקט שאת
כתובתו המצביע מכיל(.
הפונקציה  build_listמתחילה להתבצע .היא קוראת את הערך  17לתוך
המשתנה הלוקלי שלה  ,numומזמנת את  . insertל insert -יש שני פרמטרים:
פרמטר ערך שלם ,לתוכו מועתק ערכו של הארגומנט  , numופרמטר הפניה שהינו
מצביע ,ועל-כן ממנו נשלח חץ לארגומנט  headשל ) build_listומשם למצביע
 headשל  .(mainנציג את מצב המחסנית:
= head
= head
num = 17
= head
num = 17
= temp
= rear
= front
 insertמתחילה להתבצע .ראשית אנו בודקים . if (head == NULL) :בכך
אנו מטפלים במקרה הראשון מבין ארבעת המקרים בהם ציינו כי עלינו לטפל:
המקרה שבו הרשימה ריקה .במצב הנוכחי הרשימה אכן ריקה .על כן אנו מבצעים
את הקוד הבא:
; head = new new (std::nothrow) struct Node
; head -> _data = num
; head -> _next = NULL
אנו מקצים קופסה חדשה ,ושולחים את המצביע  headשל התכנית הראשית
להצביע על קופסה זאת) .אנו מגיעים למשתנה  headשל התכנית הראשית תוך
שימוש בחצים עם הראש השחור המובילים מהפרמטרים המשתנים  headשל שתי
61
הפונקציות הנוספות אל המשתנה  headשל התכנית הראשית( .למרכיב ה_data -
בקופסה שנוצרה אנו מכניסים את הערך  ,17ולמרכיב ה _next -את הערך .NULL
מצב הזיכרון הוא עתה:
= head
= head
num = 17
data=17
=next
= head
num = 17
= temp
= rear
= front
עתה  insertמסתיימת ,ורשומת ההפעלה שלה מוסרת מעל-גבי המחסנית .אנו
שבים ל ,build_list -קוראים את הערך  13לתוך המשתנה  ,numומזמנים שוב
נראה בשרטוט האחרון פרט לכך
את  .insertמצב הזיכרון יראה כפי שהוא ֵ
שבמשתנה  numשל  ,build_listובפרמטר  numשל  insertמצוי עתה הערך ,13
)במקום  17כפי שמופיע בציור האחרון(.
 insertמתחילה להתבצע .התנאי ) (head == NULLאינו מתקיים; לעומת זאת
התנאי ) (num < head-> _dataכן מתקיים .שימו לב כי אנו רשאים לבדוק
תנאי זה רק משום שאנו מובטחים שערכו של  headשונה מ ,NULL -כלומר ש-
 headמצביע על קופסה כלשהי )הכוללת בין היתר את המרכיב _ .(dataאנו
מובטחים שערכו של  headשונה מ NULL -שכן לו ערכו היה  NULLהתנאי הקודם
היה מתקיים ,ולא היינו מגיעים עד הלום) .אני מדגיש נקודה זאת שכן היא עקרונית
וחשובה :אם  pהוא מצביע אזי מותר להתעניין בערכו של …> p-רק אחרי שהבטחנו
ש p -מצביע על קופסה קיימת כלשהי (.מכיוון שהתנאי >(num < head-
) _dataמתקיים אנו מבצעים את הגוש:
; = new (std::nothrow) struct Node
; -> _data = num
; -> _next = head
; = temp
temp
temp
temp
head
שתי הפקודות הראשונות מקצות קופסה חדשה ,ומכניסות למרכיב ה_data -
בקופסה את הערך  .13מצב הזיכרון הוא:
= head
= head
num = 17
data=17
=next
data=13
=next
62
= head
num = 17
= temp
= rear
= front
הפקודה השלישית מבין ארבע הפקודות שאנו מבצעים ְ
מפנה את המצביע _next
בקופסה עליה מצביע  tempלהצביע לאותו מקום כמו  ,headכלומר לקופסה של
 .17מצב הזיכרון הוא:
= head
= head
num = 17
data=17
=next
data=13
=next
= head
num = 17
= temp
= rear
= front
הפקודה הרביעית מפנה את  headלהצביע לאותו מקום כמו  ,tempכלומר לקופסה
של  .13מצב הזיכרון הוא:
= head
= head
num = 17
data=17
=next
data=13
=next
= head
num = 17
= temp
= rear
= front
בכך מסתיימת  .insertמה היה לנו?  headמצביע על הקופסה של  ,13המצביע
בקופסה זאת מצביע על הקופסה של  .17וערכו של המצביע בקופסה של  17הוא
 .NULLהרשימה שלנו מוחזקת ממוינת כנדרש .שימו לב כי הפעם טיפלנו במקרה בו
יש להוסיף איבר בראש הרשימה.
עם סיומה של  insertחוזר הביצוע ל .build_list -הערך שנקרא עתה הוא .3
לא נעקוב אחר תהליך הוספתו לרשימה שכן מקרה זה דומה לקודמו :גם בו אנו
מוסיפים איבר בראש הרשימה .מצב הזיכרון בתום ריצתה של  ,insertאחרי
שהיא הוסיפה את הערך  3הוא:
= head
= head
num = 3
data=17
=next
data=13
=next
data=3
=next
63
= head
num = 3
= temp
= rear
= front
מסגרת המחסנית של  insertמוסרת מהמחסנית .אנו שבים לbuild_list -
אשר קוראת את הערך  ,15ומזמנת שוב את  .insertעתה יהיה על  insertלטפל
במקרה השלישי מבין המקרים שמנינו :הוספת איבר באמצע רשימה .נראה כיצד
הדבר קורה :התנאי (head ==NULL) :אינו מתקיים .כך גם לגבי התנאי:
) ,(num < head-> _dataולכן אנו מגיעים ַל else -הכולל את הגוש:
; rear = head
; front = rear -> next
{ )while (front != NULL && front-> _data < num
; rear = front
; front = front -> _next
}
; temp = new (std::nothrow) struct Node
; temp -> _data = num
; temp -> _next = front
; rear -> _next = temp
שתי הפקודות הראשונות מפנות את  rearלהצביע כמו ) headכלומר על הקופסה
של  ,(3ואת  frontלהצביע כמו  ,head-> _nextכלומר כמו המצביע next
בקופסה עליה מצביע  ,headמשמע על הקופסה של  .13שימו לב כי גם הפעם הפניה
ל head-> _next -הינה בטוחה רק משום שאנו מובטחים שאם הגענו עד הלום
אזי ערכו של  headשונה מ.NULL-
נציג את מצב הזיכרון:
= head
= head
num = 15
data=17
=next
data=13
=next
data=3
=next
= head
num = 15
= temp
= rear
= front
עתה אנו נכנסים לביצוע הלולאה:
{ )while (front != NULL && front-> _data < num
; rear = front
; front = front -> _next
}
שני התנאים שבכותרת לולאה מתקיימים ,ועל כן אנו נכנסים לגוף הלולאה.
בלולאה אנו מקדמים את זוג המצביעים  rear, frontתא אחד קדימה.
שימו לב לדרך בה אנו מנסחים את התנאי שבכותרת הלולאה :המרכיב השמאלי
בודק האם ערכו של  frontשונה מ .NULL -במידה ותנאי זה אינו מתקיים כבר
ברור שלא ניכנס לסיבוב )נוסף( בגוף הלולאה ,ועל כן המחשב אינו בודק את התנאי
הימני ) (front-> _data < numוטוב שכך ,שכן לו הוא היה מנסה לבדוק תנאי
זה התכנית הייתה עפה )שכן אנו מנסים לפנות לשדה ה _data -בקופסה עליה
64
מצביע  frontשעה שערכו של  frontהוא  .(NULLמנגד ,אם התנאי השמאלי
מתקיים ,אזי המחשב יכול לגשת בבטחה לבדיקת התנאי הימני :אם התנאי
השמאלי מתקיים אזי  frontמצביע על קופסה כשלהי ,ולכן ניתן לבדוק את ערכו
של  .front-> _dataכמובן שלו היינו כותבים את התנאים בסדר הפוך תכניתנו
הייתה שגויה.
נציג את מצב הזיכרון אחרי ביצוע הסיבוב הראשון בלולאה:
= head
= head
num = 15
data=17
=next
data=13
=next
data=3
=next
= head
num = 15
= temp
= rear
= front
לקראת כניסה לסיבוב השני בלולאה נבדק שוב התנאי ,אולם עתה הוא כבר אינו
מתקיים )לא נכון ש .(front-> _data < num :שימו לב כי הלולאה הביאה
אותנו למצב בו  frontמצביע לאיבר הראשון ברשימה הכולל נתון שערכו גדול או
שווה ל ,num -בעוד  rearמצביע על האיבר האחרון שכולל ערך שעדיין קטן מ-
 .numעתה עלינו לצור קופסה חדשה ,ולשרשרה בין הקופסה עליה מצביע rear
לקופסה עליה מצביע  .frontקטע הקוד שאחראי על כך הוא:
; = new (std::nothrow) struct Node
; -> _data = num
; -> _next = front
; -> _next = temp
temp
temp
temp
rear
שתי הפקודות הראשונות יוצרות קופסה חדשה ,ומפנות את  tempלהצביע על אותה
קופסה .מצב הזיכרון הוא:
data=3
= head
=next
= head
num = 15
data=17
=next
data=13
=next
data=3
=next
65
= head
num = 15
= temp
= rear
= front
עתה מתבצעת הפקודה . temp-> _next = front; :פקודה זאת מפנה את
המצביע בקופסה עליה מצביע  tempלהצביע כמו  ,frontכלומר על הקופסה של
 .17ולבסוף מתבצעת הפקודה . rear-> _next = temp; :פקודה זאת מסיטה
את המצביע בקופסה עליה מצביע  ,rearכלומר המצביע בקופסה של  ,13להצביע
על אותה קופסה עליה מצביע  ,tempכלומר על הקופסה של ) 15במקום להצביע על
הקופסה של  ,17עליה הוא הצביע עד עתה( .מצב הזיכרון הוא:
data=3
= head
=next
= head
num = 15
data=17
=next
data=13
=next
data=3
=next
= head
num = 15
= temp
= rear
= front
כפי שנקל לראות מהציור ,הרשימה שלנו נותרה ממוינת .בכך מסתיימת ריצתה
הנוכחית של  ,insertואנו שבים ל build_list -אשר קוראת את ) 3879הבלתי
נמנע( .הפעם יהיה על  insertלהוסיף איבר אחרון לרשימה .אני מזמין אתכם
לבצע את סימולציית ההרצה ,ולהשתכנע שתכניתנו מטפלת כהלכה גם במקרה
)רביעי ואחרון מבין המקרים שמנינו שבהם יש לטפל( זה.
66
 13.4מחיקת איבר מרשימה
מחיקת איבר מרשימה )ממוינת או שאינה( מתבצעת באופן דומה ביותר להוספת
איבר לרשימה ממוינת .אנו מציגים גם פעולה זאת שכן היא אחת הפעולות
הסטנדרטיות על רשימה )כמו על כל מבנה נתונים אחר( .יחד עם זאת ,בשל הדמיון,
לא נסביר את הפונקציה שנכתוב בהרחבה .כדי לשנות מעט מהפונקציה שהוסיפה
איבר לרשימה ממוינת נקבע כי הפונקציה שנכתוב תחזיר ערך בולאני שיעיד האם
הנתון הרצוי נמצא ברשימה ,והוסר ממנה ,או שמא מלכתחילה הוא לא היה מצוי
ברשימה .במקרה בו הנתון נמצא ברשימה אנו מסירים מופע יחיד שלו מהרשימה.
נציג את הפונקציה המתאימה:
{ )bool del(int num, struct Node *&head
struct Node *front, *rear,
; *temp
)if (head == NULL
; ) return( false
{ )if (head-> _data == num
; temp = head
; head = head -> _next
; delete temp
; ) retun( true
}
; rear = head
; front = rear -> _next
{ )while (front != NULL && front -> _data < num
; rear = front
; front = front -> _next
}
)if (front == NULL || front -> _data != num
; ) return( false
; rear -> _next = front -> _next
; delete front
; ) return( true
}
הסבר :לפונקציה אותם פרמטרים כמו במקרה הקודם .כמו קודם ראשית אנו
בודקים האם הרשימה ריקה .במידה וזה אכן המצב אין מה למחוק ואנו מחזירים
את הערך .false
אחר אנו בודקים האם יש למחוק את האיבר שבראש הרשימה .אם זה המצב אנו
שולחים את  tempלהצביע על האיבר שבראש הרשימה ,אחר מקדמים את head
להצביע על האיבר הבא ברשימה ,ולבסוף מקפידים לשחרר את הזיכרון המכיל את
הנתון שיש למחוק .שימו לב כי אנו משתמשים בפקודה , delete temp; :ואיננו
כותבים ][ שכן במקרה זה איננו משחררים מערך ,אלא רק איבר יחיד.
לבסוף ,אם הרשימה אינה ריקה ,והנתון המבוקש אינו בראשה אנו סורקים את
הרשימה עם זוג מצביעים ) (rear, frontעד אשר אנו מגיעים) :א( לאיבר שמכיל
ערך גדול מהערך המבוקש ,או) :ב( לסיומה של הרשימה )ואז אנו מסיקים שהנתון
המבוקש אינו מצוי ברשימה( ,או) :ג( עד שאנו מגיעים ַלקופסה שמכילה את האיבר
67
הרצוי .אם קופסה כזאת נמצאת אזי  frontמצביע עליה ,ו rear -מצביע על
הקופסה שלפניה .עתה עלינו לשנות את השרשור כך שהקופסה עליה מצביע front
תנותק מהשרשרת ,כלומר המצביע  _nextבקופסה עליה מצביע  rearיעבור
להצביע על הקופסה שמייד אחרי הקופסה שעליה מצביע  .frontאני מזמין אתכם
לבדוק בדקדקנות שזה אכן מה שנעשה.
נקודה נוספת עליה כדאי לתת את הדעת היא הביטוי הבולאני:
) . (front == NULL || front -> _data != numשימו לב כי זוג
התנאים קשורים בניהם ב .or -לכן אם התנאי השמאלי מתקיים ברור שאין כל
צורך לבדוק את התנאי הימני ,שכן התנאי בכללותו יתקיים .המחשב ימנע על-כן
מלהעריך את הביטוי הבולאני הימני )עת השמאלי מסופק( ,וטוב שכך.
הפרמטר  headשל הפונקציה הוא פרמטר הפניה .שימו לב כי הצורך בכך נובע רק
מהמקרה בו יש למחוק את האיבר שבראש הרשימה ,שכן רק אז ערכו של head
משתנה ) headעובר אז להצביע על האיבר שהיה שני ברשימה ,או על  NULLאם
הרשימה כללה איבר יחיד( .בכל המקרים האחרים השינוי הוא אי-שם בהמשך
הרשימה .מכך אתם יכולים להסיק מסקנה נוספת :בכוחה של פונקציה המקבלת
מצביע לראש רשימה מקושרת כפרמטר ערך לשנות את מצב הרשימה ,עת היא
משנה מצביעים שנמצאים בתאי הרשימה .הפונקציה לא תוכל לשנות את ערכו של
 .headאולם שינוי שהפונקציה תכניס למשל ל , head->next -יישאר בתום ביצוע
הפונקציה .נחזור לנקודה זאת בהמשך ,ואז נסבירה ביתר פירוט ,וגם נלמד איך
להתגונן מכך.
שאלה :הפונקציה שכתבנו מותאמת למחיקת איבר מרשימה ממוינת .היכן באה
לידי ביטוי בקוד שכתבנו ממוינותה של הרשימה? במילים אחרות :איזה שינוי )קל
ביותר( יש להכניס בפונקציה שכתבנו על-מנת שהיא תטפל כהלכה גם ברשימה
שאינה ממוינת?
 13.5שחרור תאי רשימה
תכנית אשר הקצתה רשימה מקושרת צריכה גם לשחרר את תאי הרשימה לפני
סיומה )או עת היא אינה זקוקה להם יותר( .אם התכנית תבצע רק פקודהdelete :
 headאזי כל שהיא תשחרר יהיה את האיבר הראשון ברשימה .יתר תאי הרשימה
יישארו  zombiesשמחד גיסא לא שוחררו ,ומאידך גיסא גם אין דרך לפנות אליהם.
על כן הדרך לשחרר רשימה היא בלולאה שתשחרר את התאים בזה אחר זה.
נציג את קוד הפונקציה הדרושה:
{ )void free_list(struct Node *head
; struct Node *temp = head
{ )while (head != NULL
; temp = head
; head = head -> _next
;delete temp
}
}
מפנים את  tempלהצביע
הפונקציה מתנהלת כלולאה .בכל סיבוב בלולאה אנו ְ
לאיבר שעליו מצביע  ,headמקדמים את  ,headואז משחררים את האיבר עליו
מצביע  .tempשימו לב כי בתום התהליך הרשימה כולה מוחזרה ,וערכו של head
68
הוא  .NULLכאן עלינו לתת את הדעת לנקודה מעט עדינה :הפרמטר של הפונקציה
הוא פרמטר ערך ,ועל כן שינוים שהפונקציה הכניסה לפרמטר שלה לא יוותרו בתום
ביצוע הפונקציה בארגומנט המתאים .בפרט ערכו של הארגומנט לא יהיה ,NULL
אלא הוא ימשיך להצביע לאותו איבר עליו הוא הצביע לפני הקריאה לפונקציה; רק
שעתה הזיכרון בו שכן האיבר אינו שייך עוד לתכניתנו ,שכן הוא מוחזר על-ידי
הפונקציה .כמובן שניתן להגדיר את הפרמטר גם כפרמטר הפניה ,ואז בתום ביצוע
הפונקציה יהיה ערכו של הארגומנט המתאים .NULL
 13.6היפוך רשימה
עתה נרצה לכתוב פונקציה המקבלת רשימה מקושרת ,והופכת את כיוון ההצבעה
של המצביעים ברשימה .לדוגמה ,אם לפונקציה יועבר מצביע לראש הרשימה:
data=3
=next
data=13
=next
data=3879
=next
data=17
=next
אזי הפונקציה תחזיר מצביע לראש הרשימה:
data=17
=next
data=3879
=next
data=13
=next
data=3
=next
הקריאה לפונקציה תהיה. rev_head = reverse(head); :
נרצה שהפונקציה שנכתוב לא תקצה תאים חדשים )על-גבי הערמה( ,אלא תשנה את
כיוון הצבעתם של המצביעים הקיימים באופן שיושג הרצוי .לדוגמה :את המצביע
בקופסה של  3היא תפנה להצביע על הקופסה של ) 13במקום שהמצביע יכיל את
הערך  ,NULLכפי שמצוי בו בעת הקריאה לפונקציה(.
הרעיון הבסיסי הוא שנחזיק שלושה מצביעים  front, mid, rearשיתקדמו
על-פני הרשימה .בכל שלב  midיצביע על איבר כלשהו front ,יצביע על האיבר
שלפני האיבר עליו מצביע  ,midו rear -יצביע על האיבר שמאחורי האיבר עליו
מצביע  .midבכל סיבוב בלולאה נפנה את  mid-> _nextלהצביע על אותו איבר
כמו  ,rearואחר נסיט את שלושת המצביעים תא אחד ימינה יותר.
69
הגדרת הפונקציה:
{ )struct Node *reverse( struct Node *head
; struct Node *rear, *mid, *front
)if (head == NULL || head-> _next == NULL
; ) return( head
// nothing to do
; rear = head
mid = head-> _next ; // safe assignment as head!=NULL.
front = mid-> _next ; // safe, as head->next != NULL.
{ )while (front != NULL
; mid -> _next = rear
; rear = mid
; mid = front
; front = front-> _next
}
; mid -> _next = rear
; head -> _next = NULL
; ) return( mid
}
נציג סימולציית הרצה אשר תסייע לנו להשתכנע בנכונות פעולה של הפונקציה.
נתחיל בכך שהמשתנה  headשל התכנית הראשית מצביע על הרשימה הבאה:
=head
data=3
=next
data=13
=next
data=3879
=next
data=17
=next
עתה נערכת קריאה לפונקציה ,ו head -מועבר כפרמטר ערך .מצב הזיכרון הינו:
=head
)(of main
data=3
=next
data=13
=next
data=3879
=next
data=17
=next
=head
=rear
=mid
=front
התנאי:
להתבצע:
מתחילה
הפונקציה
עתה
) (head==NULL || head-> _next ==NULLאינו מתקיים) .אני מסב
את תשומת לבכם לסדר הופעתם של שני המרכיבים בתנאי .יש חשיבות לסדר(.
תנאי זה מתייחס למקרה בו הרשימה ריקה ,או כוללת איבר יחיד .בשני המקרים
הללו אין צורך לעשות דבר :היפוך הרשימה שווה לרשימה הנתונה; ניתן לסיים
מיידית ,ולהחזיר את .head
70
 מצב.rear, mid, front שלוש הפקודות הבאות מאתחלות את ערכם של
:הזיכרון בתום ביצוע שלוש ההשמות הוא
head=
(of main)
data=17
next=
data=3879
next=
data=13
next=
data=3
next=
head=
rear=
mid=
front=
 בסיבוב הראשון.(front !=NULL) עתה אנו נכנסים ללולאה המתבצעת כל עוד
 כלומר את המצביע בקופסה של,mid-> _next  מפנים את, ראשית,בלולאה אנו
 מצב הזיכרון.17  כלומר על הקופסה של,rear  להצביע על אותו איבר כמו,3879
:הוא
head=
(of main)
data=17
next=
data=3879
next=
data=13
next=
data=3
next=
head=
rear=
mid=
front=
:עתה אנו מקדמים את שלושת המצביעים
rear = mid ;
mid = front ;
front = front-> _next ;
:מצב הזיכרון הוא
head=
(of main)
data=17
next=
data=3879
next=
head=
rear=
mid=
front=
71
data=13
next=
data=3
next=
בזאת הסתיים הסיבוב הראשון בלולאה .התנאי שבכותרת הלולאה עדיין מתקיים,
ולכן אנו נכנסים לסיבוב נוסף .בפקודה הראשונה בגוף הלולאה אנו מפנים את
 ,mid->nextכלומר את המצביע בקופסה של  ,13להצביע על הקופסה עליה מצביע
 ,rearכלומר על הקופסה של  .3879מצב הזיכרון הוא:
=head
)(of main
data=3
=next
data=13
=next
data=3879
=next
data=17
=next
=head
=rear
=mid
=front
שלוש הפקודות הבאות בגוף הלולאה מקדמות את שלושת המצביעים .מצב הזיכרון
הוא:
=head
)(of main
data=3
=next
data=13
=next
data=3879
=next
data=17
=next
=head
=rear
=mid
=front
שימו לב כי  frontקיבל את ערכו של  ,front-> _nextולכן את הערך .NULL
72
איננו נכנסים לסיבוב נוסף בלולאה )התנאי  front!= NULLכבר אינו מתקיים(.
ואנו פונים לביצוע שלוש הפקודות המסיימות את הפונקציה .השתיים הראשונות
ביניהן הן:
; mid -> _next = rear
; head -> _next = NULL
הראשונה שולחת את המצביע בקופסה עליה מצביע  ,midכלומר את המצביע
בקופסה של  ,3להצביע על הקופסה עליה מצביע  ,rearכלומר על הקופסה של .13
השניה מכניסה ל ,head->next -כלומר למצביע בקופסה של  ,17את הערך .NULL
מצב הזיכרון הוא:
=head
)(of main
data=3
=next
data=13
=next
data=3879
=next
data=17
=next
=head
=rear
=mid
=front
אם תבחנו את הציור ,המעט מסורבל ,תגלו שההצבעה התהפכה כנדרש :המצביע
בקופסה של  3מצביע על הקופסה של  ,13המצביע בקופסה של  13מצביע על
הקופסה של  ,3879המצביע בקופסה של  3879מצביע על הקופסה של  ,17והמצביע
בקופסה של  17ערכו  .NULLכמו כן  midמצביע על הקופסה של  3שנמצאת בראש
הרשימה המהופכת .על-כן עת אנו מחזירים את ערכו של  ,midאנו מחזירים מצביע
לראש הרשימה המהופכת.
73
 13.7בניית רשימה ממוינת יחידאית מרשימה לא ממוינת
נניח כי בתכניתנו בנינו רשימה מקושרת לא ממוינת ,כדוגמת הבאה:
data=2
=next
data=15
=next
data=17
=next
data=17
=next
data=15
=next
עתה ברצוננו לבנות על-פי הרשימה הנתונה רשימה מקושרת ממוינת בה כל נתון
יופיע פעם יחידה ,ולצדו יופיע מונה אשר יציין כמה פעמים הנתון הופיע ברשימה
המקורית )הלא ממוינת(.
ראשית ,נגדיר את טיפוס המבנה הדרוש:
{ struct Data_counter
; int _data, _counter
; Data_counter * _next_dc
; }
למען שוחרי איכות הסביבה נעיר כי ניתן להגדיר את המבנה בצורה נקיה יותר:
תחילה נגדיר מבנה שיכיל את הנתונים הדרושים:
{ struct D_c
; int _data, _counter
; }
ועתה ,נגדיר רשימה מקושרת שכל איבר בה מכיל מבנה כנ"ל:
{ struct List_of_data_counter
; struct _d_c _info
; struct List_of_data_counter * _next
; }
הגדרה זאת נקיה יותר שכן היא מפרידה בין הנתונים אותם ברצוננו לשמור
)ושמיוצגים באמצעות המבנה  ,(D_cלבין הרשימה ה מקושרת שאנו יוצרים ואשר
אין קשר הכרחי בינה לבין הנתונים המיוצגים; היא רק דרך לארגן את הנתונים
במקום ברשימה מקושרת
המיוצגים .לפחות לכאורה יכולנו לארגן את הנתונים ְ
במערך . struct D _c arr[N]; :עד כאן הערה לחובבי ח"נ .אנו נסתפק בצורה
הפחות נקיה אך היותר קלה לעיקול ,ונשמש במבנה .struct Data_counter
ברצוננו לכתוב את הפונקציה:
). struct Data_counter *rem_dup_n_sort(struct Node *head
הפונקציה תקבל מצביע לרשימה מקושרת לא ממוינת הכוללת חזרות ,כפי
שתיארנו בתחילת הסעיף ,ותחזיר מצביע לרשימה מקושרת ממוינת ,שאינה כוללת
חזרות ,אך שמכילה מונה כפי שתיארנו .הקריאה לפונקציה תהיה:
;)head_sort = rem_dup_n_sort(head
עבור המשתנים:
; struct Node *head
; struct Data_counter *head_sort = NULL
תחת ההנחה שיצרנו רשימה מקושרת לא ממוינת עליה מצביע .head
הפונקציה שנכתוב תשתמש בשתי פונקציות עזר:
74
א .הפונקציה  searchמקבלת שני פרמטרים :ערך שלם  ,numומצביע
המורה על ראשה של הרשימה המקושרת הממוינת
ֵ
sort_n_unique_p
נטולת כפילויות שנבנתה עד כה .הפונקציה מחפשת את הערך  numברשימה
עליה מצביע  .sort_n_unique_pהפונקציה מחזירה אחד מהערכים הבאים:
 .1במידה והרשימה ריקה יוחזר הערך .NULL
 .2במידה והנתון לא מצוי ברשימה ,ויש להוסיפו בראש הרשימה יוחזר הערך
.NULL
 .3במידה והנתון מצוי ברשימה יוחזר מצביע לאיבר הכולל את הנתון.
 .4במידה והרשימה אינה ריקה ,הנתון אינו מצוי ברשימה ,ואין להוסיפו
בראש הרשימה ,יוחזר מצביע לאיבר שאחריו יש להוסיף את הנתון החדש.
ב .הפונקציה  insertמקבלת שלושה פרמטרים :ערך שלם  ,numמצביע
 sort_n_unique_pהמורה על ראשה של הרשימה ה מקושרת הממוינת
נטולת כפילויות שנבנתה עד כה )או שערכו הוא  ,(NULLמצביע  whereהמורה
היכן יש לשלב תא חדש שיכיל את  numברשימה עליה מצביע
 .sort_n_unique_pהפונקציה פועלת באופן הבא:
במידה וערכו של המצביע  sort_n_unique_pהוא  NULLהפונקציה
1
יוצרת איבר חדש .מפנה את המצביע להורות על התא ,ומשימה באיבר
את  ,numתוך ציון שזה מופעו הראשון ברשימה הממוינת שתבנה.
במידה וערכו של  whereהוא  NULLהפונקציה מוסיפה את  numבראש
2
הרשימה הממוינת.
בכל מקרה אחר ,הפונקציה יוצרת איבר חדש ,ומשלבת אותו ברשימה
3
במקום שארי האיבר עליו מצביע .where
הגדרת הפונקציה:
{ )struct Data_counter *rem_dup_n_sort(struct Node *head
; struct Data_counter *new_list = NULL, *where
{ )while (head != NULL
; )where = search(head-> _data, new_list
)if (where != NULL && where-> _data == head-> _data
; (where -> counter)++
; )else insert(head-> _data, new_list, where
; head = head-> _next
}
; ) return( new_list
}
הסבר הפונקציה :הפונקציה כוללת לולאה המתנהלת כל עוד לא סיימנו לעבור על
כל אברי הרשימה על-פיה יש לבנות את הרשיתה הממוינת נטולת הכפילויות .בכל
סיבוב בלולאה אנו ראשית מחפשים את הערך המצוי בתא הנוכחי ברשימה הלא
ממוינת בקרב אברי הרשימה הממוינת .אם הערך נמצא אזי אנו מגדילים את
המונה באחד ,ואחרת אנו מוסיפים איבר חדש לרשימה הנבנית.
לפני שנפנה להגדרת פונקציות העזר ברצוני להדגיש שלכאורה אם הנתון >head-
 _dataמצוי ברשימה עליה מורה  ,new_listאזי  searchכבר יכולה ,עת היא
מאתרת את האיבר בו מצוי הנתון ,להגדיל את ערכו של המונה באחד .לכאורה
 searchאכן יכולה לעשות זאת ,למעשה מטעמי מבניות לא ראוי שהיא תעשה
75
זאת ,שכן  searchכשמה כן היא צריכה לנהוג :לחפש בלבד )ולא לשנות( .לכן את
מלאכת עדכון המונה הטלנו על הפונקציה הראשית. rem_dup_n_sort ,
מנגד ,שימו לב שכתבנו את  searchבצורה יחסית חכמה :היא לא מחזירה רק
איתות בולאני האם הנתון הרצוי נמצא או לא ברשימה שהועברה לה .היא גם
מחזירה מצביע שיסייע לנו הן במקרה בו הנתון נמצא )לעדכן את המונה( ,והן
במקרה בו הוא לא נמצא )ויש להוסיפו לרשימה( .באופן זה בין אם יש להוסיף את
הנתון לרשימה הנבנית ,ובין אם יש רק לעדכן את המונה ,כבר אין צורך לסרוק את
הרשימה פעם נוספת' .תבונתה' של  searchאינה משנה את מורכבות זמן הריצה
של הפונקציה  ,rem_dup_n_sortאך היא הופכת אותה ליעילה יותר) .אגב ,מהי
מורכבות זמן הריצה של הפונקציה?(.
עתה נציג את הפונקציה :search
struct Data_counter *search(int num,
* struct Data_counter
{ )sort_n_unique_p
; ; struct Data_counter *rear, *front
|| if (sort_n_unique_p == NULL
)num < sort_n_unique_p-> _data
; )return(NULL
; front = sort_n_unique_p->next_dc
; rear = sort_n_unique_p
{ )while (front != NULL && front-> _data < num
; rear = front
; front = front ->next_dc
}
|| if (front == NULL
// num shld be added as last
front -> data > num) // should be added in middle
; )return(rear
; )return(front
// num exists in list
}
הסבר הפונקציה :אם ערכו של המצביע לראש הרשימה הנבנית הוא  ,NULLאו
שהאיבר שיש להוסיף קטן מהערך המצוי בראש הרשימה אזי  searchמחזירה את
הערך  .NULLאחרת אנו נכנסים ללולאה אשר מתקדמת עד אשר  frontמצביע על
איבר הכולל את הנתון הרצוי ,או עד ש front -מגיע לאיבר הכולל ערך גדול מהערך
הרצוי )ואז ניתן להסיק שהערך הרצוי אינו ברשימה( ,או עד אשר ערכו של front
הוא ) NULLואז ניתן להסיק כי יש להוסיף את האיבר בסוף הרשימה הממוינת(.
אחרי היציאה מהלולאה אנו מחזירים את הערך המתאים ,כפי שמוסבר בתיעוד
בגוף הקוד.
עתה נותר לנו להציג את  .insertשימו לב כי בזכות 'תבונתה' של ,search
הפונקציה  insertאינה כוללת כל פקודות לולאה.
void insert(int num,
struct Data_counter *&sort_n_unique_p,
{ )struct Data_counter *where
; struct Data_counter *temp
// list is empty
{ )if (sort_n_unique_p == NULL
76
; = new struct Data_counter
; -> data = num
; -> counter = 1
; -> _next_dc = NULL
sort_n_unique_p
sort_n_unique_p
sort_n_unique_p
sort_n_unique_p
}
// insert as first in non empty list
{ )else if (where == NULL
; temp = new struct Data_counter
; temp -> data = num
; temp -> counter = 1
; temp -> _next_dc = sort_n_unique_p
; sort_n_unique_p = temp
}
{ else
// insert in the middle, or as last
; temp = new struct Data_counter
; temp -> data = num
; temp -> counter = 1
; temp -> _next_dc = where -> _next_dc
; where -> _next_dc = temp
}
}
אני מזמין אתכם לערוך בעצמכם מעקב אחרי  insertולבדוק את נכונותה.
 13.8מיון מיזוג של רשימות משורשרות
בעבר ראינו את אלגוריתם המיון )הלא יעיל( מיון בועות ,ואת אלגוריתם מיון היעיל
מיון מהיר )אשר ,כזכור לנו)?( רץ בד"כ בזמן ) n*log(nעבור  nשמציין את כמות
הנתונים שיש למיין ,אך עלול להתדרדר לכדי זמן ריצה של  n2במקרים קיצוניים(.
עתה נרצה להכיר אלגוריתם מיון יעיל נוסף :מיון מיזוג.
מיון מיזוג ניתן לבצע על מערך ,או על רשימה מקושרת .מיון מיזוג על מערך מחייב
שימוש במערך עזר נוסף שגודלו כגודלו של המערך המקורי ,כלומר מיון מיזוג על
מערכים מחייב הכפלת גודל הזיכרון הנצרך ע"י התכנית .זה הופך אותו לאלגוריתם
לא מעשי עבור מערכים שאינם קטנים .בניגוד לכך ,מיון מיזוג של רשימה לא מחייב
תוספת של זיכרון )ובמובן זה הוא דומה למיון מהיר שלא מחייב תוספת של זיכרון(,
וזה כבר משמח .בנוסף לכך ,מיון מיזוג )הן של מערך ,והן של רשימה( רץ תמיד בזמן
של ) ,n*log(nובמובן זה יש לו יתרון על-פני מיון מהיר )אשר ,כאמור ,במצבים
קיצוניים ,עלול להתדרדר לכדי זמן ריצה ריבועי(.
 13.8.1הצגת אלגוריתם המיון :מיון מיזוג
מיזוג ) (mergingהוא תהליך בו יוצרים סדרה ממוינת אחת משתיים )או יותר(
סדרות ממוינות .הסדרות עשויות להיות מאוחסנות במערך ,ברשימה מקושרת ,או
אחר שמאפשר שמירה של סדרת נתונים .לדוגמה אם נתונים
בכל מבנה נתונים ֵ
המערכים הממוינים )מקטן לגדול(:
3879
17
5
13
77
7
5
5-
5
אזי תהליך מיזוג ייצר מהם מערך בן שמונה ערכים:
5
5
7
13
17
3879
5
5-
תהליך המיזוג פועל האופן הבא:
א .התחל באיבר הראשון בכל אחת משתי הסדרות שיש למזג .קבע אותו כאיבר
הנוכחי.
ב .כל עוד הסדרה הראשונה לא הסתיימה וגם הסדרה שניה לא הסתיימה בצע:
 .1אם האיבר הנוכחי בסדרה הראשונה קטן או שווה מהאיבר הנוכחי בסדרה
השניה ,אזי) :א( העבר את האיבר הנוכחי בסדרה הראשונה לסדרה הנבנית,
ו) -ב( התקדם על הסדרה הראשונה לאיבר הבא.
 .2אחרת )האיבר הנוכחי בסדרה השניה קטן מהאיבר הנוכחי בסדרה
הראשונה( ,בצע) :א( העבר את האיבר הנוכחי בסדרה השניה לסדרה
הנבנית ,ו) -ב( התקדם על הסדרה השניה לאיבר הבא.
עם היציאה מהלולאה שתוארה בסעיף ב' אחת הסדרות הסתיימה והשניה עדיין לא,
לכן יש להעתיק בזה אחר זה את הנתונים מהסדרה שלא הסתימה לסדרה הנבנית.
לכן בצע:
ג .כל עוד לא הסתימה הסדרה הראשונה ,העתק בזה אחר זה את אבריה על
הסדרה הנבנית.
ד .כל עוד לא הסתימה הסדרה השניה ,העתק בזה אחר זה את אבריה על הסדרה
הנבנית.
בפועל רק אחת משתי הלולאות תתבצע )בלולאה שלא תבוצע התנאי לא יתקיים
מייד עם ההגעה ללולאה(.
בדוגמה שלנו :נתחיל מהתא הראשון בשני המערכים.
א .הערך  -5במערך השני קטן מהערך  5במערך הראשון ,לכן נעתיק את  –5למערך
שאנו יוצרים ,ונתקדם לתא השני במערך השני.
ב .עתה הערך  5במערך הראשון קטן או שווה מהערך  5במערך השני ,לכן נעתיק
את הערך  5מהמערך הראשון למערך הנבנה ,ונתקדם לתא השני במערך
הראשון.
ג .הערך  5בתא השני של המערך השני קטן מהערך  13בתא השני של המערך
הראשון ,לכן נעתיק את  5למערך שאנו יוצרים ,ונתקדם לתא השלישי במערך
השני.
ד .הערך  5בתא השלישי של המערך השני קטן מהערך  13בתא השני של המערך
הראשון ,לכן נעתיק את  5למערך שאנו יוצרים ,ונתקדם לתא הרביעי במערך
השני.
ה .הערך  7בתא הרביעי של המערך השני קטן מהערך  13בתא השני של המערך
הראשון ,לכן נעתיק את  7למערך שאנו יוצרים ,ונתקדם לתא החמישי במערך
השני.
בזאת גמרנו להעביר את נתוני המערך השני .לכן אנו יוצאים מהלולאה הראשונה
)המתנהלת כל עוד שני המערכים גם יחד לא מוצו( .כל שנותר לנו עתה לעשות הוא
להעתיק את הנתונים מהמערך הראשון שטרם הועברו למערך הנבנה.
ו .עתה נעתיק בזה אחר זה את הערכים  3879 ,17 ,13מהמערך הראשון למערך
הנבנה.
78
כפי שראינו ,תהליך המיזוג סורק כל אחד משני המערכים פעם יחידה .ועל כן כדי
למזג שני מערכים ממוינים בגודל  Nלכדי מערך ממוין בגודל  2*Nעלינו להשקיע
עבודה בסדר גודל של  Nפעולות.
אלגוריתם המיון מיון מיזוג מקבל סדרה של נתונים שיש למיין ,ומחזיר את הסדרה
ממוינת .האלגוריתם פועל באופן הבא:
א .אם הסדרה שיש למיין ריקה ,או הסדרה שיש לציין כוללת נתון יחיד ,אזי החזר
את הסדרה עצמה) .אין צורך למיינה(.
ב .אחרת )הסדרה שיש למיין כוללת לפחות שני נתונים(:
 .1פצל את הסדרה לשתי סדרות בעלות אורך שווה ככל שניתן )אם אורכה של
הסדרה שיש למיין זוגי אזי היא תפוצל לשתי סדרות בעלות אורך שווה,
אחרת :אחת הסדרות תכיל איבר אחד יותר מהשניה(.
 .2מיין את תת-הסדרה הראשונה באמצעות מיון מיזוג.
 .3מיין את תת-הסדרה השניה באמצעות מיון מיזוג.
 .4מזג את שתי תת-הסדרות הממוינות לכדי סדרה אחת שלמה ממוינת.
 .5החזר את הסדרה שהתקבלה בסעיף הקודם.
נדגים ריצה של האלגוריתם על סדרת הנתונים הבאה{2 ,5 ,17 ,17 ,3879 ,13 ,7} :
א .יש למיין סדרה בת  6ערכים על-כן:
 .1נפצלה לשתי סדרות } {17 ,3879 ,13 ,7ו. {2 ,5 ,17} -
 .2נקרא רקורסיבית עבור הסדרה.{17 ,3879 ,13 ,7} :
 .3נקרא רקורסיבית עבור הסדרה. {2 ,5 ,17} :
 .4הקריאה הרקורסיבית מסעיף  #2תחזיר את הסדרה.{3879 ,17 ,13 ,7} :
הקריאה הרקורסיבית מסעיף  #3תחזיר את הסדרה . {17 ,5 ,2} :נמזג את
שתי הסדרות לכדי הסדרה ,{3879 ,17 ,17 ,13 ,7 ,5 ,2} :ואותה נחזיר.
ב .נדון בקריאה הרקורסיבית מסעיף א .'2כתובת החזרה שלה היא סעיף א.'2
קריאה זאת מקבלת את הסדרה .{17 ,3879 ,13 ,7} :היא מבצעת ארבעה
צעדים:
 .1פיצול הסדרה לשתי סדרת {13 ,7} :ו.{17 ,3879} -
 .2קריאה רקורסיבית עבור הסדרה.{13 ,7} :
 .3קריאה רקורסיבית עבור הסדרה.{17 ,3879} :
 .4מיזוג הסדרה } {13 ,7שחזרה מסעיף  ,'2עם הסדרה } {3879 ,17שחזרה
מסעיף  ,'3לכדי הסדרה } .{3879 ,17 ,13 ,7והחזרת סדרה זאת.
ג .נדון בקריאה הרקורסיבית מסעיף ב .'2כתובת החזרה שלה היא סעיף ב.'3
קריאה זאת מקבלת את הסדרה .{13 ,7} :היא מבצעת ארבעה צעדים:
 .1פיצול הסדרה לשתי סדרת {7} :ו.{13} -
 .2קריאה רקורסיבית עבור הסדרה.{7} :
 .3קריאה רקורסיבית עבור הסדרה.{13} :
 .4מיזוג הסדרה } {7שחזרה מסעיף  ,'2עם הסדרה } {13שחזרה מסעיף  ,'3לכדי
הסדרה } .{13 ,7והחזרת סדרה זאת.
ד .נדון בקריאה הרקורסיבית מסעיף ג .'2כתובת החזרה שלה היא סעיף ג.'3
קריאה זאת מקבלת את הסדרה .{7} :ועל כן היא חוזרת מיידית :הסדרה
שהיא קבלה כבר ממוינת.
79
ה .חזרנו לסעיף ג .'3הוא מנפיק קריאה רקורסיבית עבור הסדרה .{13} :כתובת
החזרה של קריאה זאת היא סעיף ג .'4הקריאה עבור הסדרה } {13חוזרת
מיידית.
ו .אנו חוזרים לסעיף ג .'4ממזגים את שתי הרשימות לכדי הרשימה,{13 ,7} :
וחוזרים מסעיף ג' .כתובת היא סעיף ב.'3
ז .סעיף ב '3מנפיק קריאה רקורסיבית עם הסדרה .{17 ,3879} :כתובת החזרה של
קריאה זאת היא סעיף ב .'4הקריאה מבצעת ארבעה שלבים:
 .1פיצול הסדרה לשתי סדרת {3879} :ו.{17} -
 .2קריאה רקורסיבית עבור הסדרה.{3879} :
 .3קריאה רקורסיבית עבור הסדרה.{17} :
 .4מיזוג הסדרה } {3879שחזרה מסעיף  ,'2עם הסדרה } {17שחזרה מסעיף ,'3
לכדי הסדרה } .{3879 ,17והחזרת סדרה זאת.
ח .נדלג על הקריאות הרקורסיביות שמנפיקים סעיפים ז '2ו -ז.'3
ט .סעיף ז' משלים את פעולת המיזוג ומחזיר את הסדרה .{3879 ,17} :כתובת
החזרה של סעיף ז' היא סעיף ב .'4עתה סעיף ב '4יכול למזג את שתי הסדרות
הממוינות שהחזירו סעיפים ג' ו-ז' לכדי הסדרה .{3879 ,17 ,13 ,7} :סדרה זאת
תוחזר לסעיף א.'2
י .עתה סעיף א' יכול להתקדם לסעיף א ,'3ולזמן קריאה רקורסיבית עם הסדרה:
} .{2 ,5 ,17כתובת החזרה של קריאה זאת היא סעיף א .'4הקריאה
הרקורסיבית עם הסדרה {2 ,5 ,17} :מתפצלת לארבעה שלבים:
 .1פיצול הסדרה לשתי סדרת {5 ,17} :ו.{2} -
 .2קריאה רקורסיבית עבור הסדרה.{5 ,17} :
 .3קריאה רקורסיבית עבור הסדרה.{2} :
 .4מיזוג הסדרה } {17 ,5שחזרה מסעיף  ,'2עם הסדרה } {2שחזרה מסעיף ,'3
לכדי הסדרה .{17 ,5 ,2} :והחזרת סדרה זאת.
יא .נדלג על הקריאות הרקורסיביות שמנפיק סעיף י '2וסעיף י .'3סעיף י '4ימזג את
הסדרות החוזרת לכדי הסדרה .{17 ,5 ,2} :בכך סעיף י' יסתיים ,תוך שהוא
מחזיר סדרה ממוינת זאת.
יב .כתובת החזרה של סעיף י' היא סעיף א .'4סעיף א '4יכול עתה למזג את שתי
הסדרות הממוינות {3879 ,17 ,13 ,7} :ו {17 ,5 ,2} -לכדי הסדרה הממוינת,2} :
 ,{3879 ,17 ,17 ,13 ,7 ,5ואותה נחזיר.
80
 13.8.2מיון מיזוג של רשימות משורשרות
מיון מיזוג של רשימות משורשרות הוא מקרה פרטי של מיון מיזוג ,בו מבנה
הנתונים באמצעותו מוחזקת סדרת הנתונים הוא רשימה מקושרת.
נציג את הפונקציה:
{ )struct Node *merge_sort(struct Node *head
struct Node *list1 = NULL, *list2= NULL,
; *list
)if (head == NULL || head-> _next == NULL
; return head
; ) split( head, list1, list2
; )list1 = merge_sort(list1
; )list2 = merge_sort(list2
; )list = merge(list1, list2
; return list
}
הפונקציה  merge_sortהיא מימוש ישיר של האלגוריתם :במידה והרשימה שיש
למיין ריקה ,או כוללת נתון יחיד היא כבר ממוינת ,וניתן להחזיר את  .headאחרת,
אנו מפצלים את הרשימה עליה מצביע  headלשתי רשימות ,עליהן מצביעים
 .list1, list2אחר אנו קוראים רקורסיבית עם  list1ועם  ,list2ואת הערך
המוחזר אנו מכניסים חזרה ל .list1, list2 -לבסוף אנו ממזגים את שתי
הרשימות הממוינות עליהם מצביעים  list1, list2לכדי רשימה מקושרת
ממוינת שלמה עליה מצביע  ,listואותה אנו מחזירים.
כדרכו של תכנות מעלה-מטה את עיקר העבודה 'דחפנו מתחת לשטיח' לפונקציות
 ,splitו .merge -על-כן עתה נפנה אליהן.
גישה נאיבית לכתיבת הפונקציה  splitתאמר שאת המחצית הראשונה של אברי
הרשימה נַפנֵה לרשימה  ,list1ואת המחצית השניה נפנה לרשימה  .list2זוהי
גישה נאיבית שכן היא מחייבת מעבר מקדים אשר יספור כמה איברים יש ברשימה.
אנו נציג חלופה יעילה מעט יותר לכתיבת הפונקציה :את האיברים במקומות
הפרדיים נפנה לרשימה עליה יצביע  ,list1ואת אלה שבמקומות הזוגיים נפנה
לרשימה עליה יצביע  .list2באופן כזה פעולת הפיצול תתבצע על-ידי ביצוע מעבר
יחיד על הרשימה.
הפונקציה שנכתוב מקבלת את  list1, list2כפרמטרי הפניה ,שכן עליה
להפנותם להצביע על רשימות שהפונקציה מקצה להם .הפונקציה אינה מקצה
תאים חדשים בזיכרון ,וזו תכונה יפה שלה ,שכן כך היא אינה מגדילה את צריכת
הזיכרון של התכנית.
81
נציג את הפונקציה ,ואחר נסבירה ביתר פירוט:
void split(struct Node *list,
{ )struct Node *&list1, struct Node *&list2
; bool to_list1 = true
; struct Node *temp
{ )while (list != NULL
; temp = list
; list = list -> _next
{ )if (to_list1
; temp-> _next = list1
; list1 = temp
}
{ else
; temp-> _next = list2
; list2 = temp
}
; to_list1 = !to_list1
}
}
המשתנה הבולאני  to_list1מורה להיכן יש להפנות את האיבר הבא ברשימה
אותה מפצלים :לרשימה עליה מורה  list1או לזאת עליה מורה  .list2לכן בכל
סיבוב בלולאה אנו מנגדים את ערכו ) האופרטור ! מחזיר את הערך הבולאני ההפוך
לערכו של האופרנד שלו ,לכן הביטוי !to_list1 :מחזיר את הערך ההפוך לערך
המצוי כרגע במשתנה  ,to_list1וערך זה אנו מכניסים ל.(to_list1 -
בכל סיבוב בלולאה אנו שומרים במשתנה  tempמצביע לאיבר עליו מורה עתה
 ,listאחר אנו מקדמים את  listלאיבר הבא ברשימה אותה יש לפצל ,ואז את
האיבר עליו מורה  tempמעבירים לראש הרשימה הרצויה )זו שעליה מורה list1
או זו שעליה מצביע  .(list2האפקט המתקבל הוא שאיברים שהיו מאוחרים יותר
ברשימה  listיהיו מוקדמים יותר ברשימות  list1, list2אך לכך אין כל
משמעות שכן כל תפקידה של  splitהוא לפצל את הרשימה עליה מצביע ,list
ולא משנה באיזה סדר יועברו האיברים המתפצלים לרשימות הנוצרות .בכל מקרה
הרשימות הנוצרות תמוינה )על-ידי קריאה רקורסיבית ל.(merge_sort -
נקודה מעט עדינה אליה ראוי לתת את הדעת היא שאנו מסתמכים על כך שערכם
של  list1, list2בעת הקריאה לפונקציה  splitהוא  .NULLההסתמכות היא
שכן המצביע  _nextבאיבר הראשון שנוסף לכל רשימה )ושיהיה לכן האיבר
האחרון ברשימה הנבנית( מקבל את ערכו של ) list1או  list2בהתאמה( .כדי
שערכו של המצביע  _nextבאיבר האחרון יהיה  NULLחשוב שערכם של list1,
 list2בעת הקריאה יהיה .NULL
82
עתה נציג את הפונקציה .merge
struct Node *merge(struct Node *list1,
{ )struct Node *list2
struct Node *list = NULL,
// head of new list
*last,
// pointer to last item in list
; *temp
// while both lists r not empty
{ )while (list1 != NULL && list2 != NULL
{ )if (list1-> _data <= list2-> _data
; temp = list1
// hold the item that moves
list1 = list1 -> _next ; // advance in its list
}
{ else
; temp = list2
; list2 = list2 -> _next
}
temp -> _next = NULL ; // rem the itm from its list
)if (list == NULL
; last = list = temp
{ else
; last -> _next = temp
// append as last
; last = temp
// and update pointer 2 last
}
}
)if (list1 == NULL
; last-> _next = list2
else
; last-> _next = list1
; )return(list
}
הסבר הפונקציה :הפונקציה מחזיקה שני מצביעים לרשימה הנבנית list :מצביע
על ראש הרשימה last ,מצביע על האיבר האחרון ברשימה.
הלולאה מתנהלת כל עוד שתי הרשימות שיש למזג אינן ריקות .בכל סיבוב בלולאה
אנו ראשית בודקים איזה איבר יש להעביר לרשימה הנבנית )האם את האיבר המצוי
בראשה של  ,list1או את זה שבראשה של  .(list2המצביע  tempעובר להצביע
על האיבר המתאים ,והמצביע  last1או  last2מקודם לאיבר הבא ברשימה
המתאימה .הפקודה temp -> _next = NULL ; :מנתקת את האיבר שיש
להעביר לרשימה הנבנית מהרשימה שלו .מכיוון שאיבר זה יתווסף בסופה של
הרשימה הנבנית מתאים שהמצביע  _nextבו יכיל את הערך  .NULLבמקרה הקצה
בו אנו מוסיפים את האיבר הראשון לרשימה הנבנית ) (list == NULLאנו מפנים
הן את  listוהן את  lastלהצביע על האיבר המועבר .בכל מקרה אחר אנו מפנים
את המצביע שבתא האחרון ברשימה הנבנית ) (last-> _nextלהצביע על האיבר
המוסף )עליו מצביע  ,(tempואחר מקדמים את  lastלהצביע על האיבר החדש
)שהינו עתה האיבר האחרון ברשימה הנבנית(.
83
נצא מהלולאה עת אחת מהרשימות אותן יש למזג תמוצה .אם הלולאה שמוצתה
היא זו עליה הצביע  lilst1אזי  list2מצביע על זנב הרשימה השניה ,שאת
אבריה יש לשרשר בהמשכה של הרשימה הנבנית .הפקודה:
 last-> _next = list2מבצעת את החיבור המתאים :של זנב הרשימה עליה
מצביע  list2להמשכה של הרשימה הנבנית .אם הלולאה שמוצתה היא זו עליה
הצביע  lilst2אזי זהו המקרה הסימטרי.
אתם מוזמנים לבצע סימולציית הרצה דקדקנית לפונקציה כדי לבדוק את נכונותה…
 13.8.3זמן הריצה של מיון מיזוג
נפנה עתה לשאלה הבלתי נמנעת מהו זמן הריצה של התכנית? הניתוח יהיה דומה
מאוד לזה שביצענו עבור מיון מהיר .כל ריצה של האלגוריתם מבצעת את הצעדים
הבאים:
א .פיצול הרשימה לשתי תת-רשימות ,תוך השקעת עבודה לינארית באורך הרשימה
שיש לפצל.
ב .קריאה רקורסיבית עבור מחצית הרשימה האחת.
ג .קריאה רקורסיבית עבור מחצית הרשימה השניה.
ד .מיזוג שתי הרשימות שהחזירו הקריאות הרקורסיביות ,תוך השקעת עבודה
לינארית באורך הרשימה המתקבלת.
נניח כי האלגוריתם התבקש למיין רשימה בת  Nנתונים .נציג את עץ הקריאות
הרקורסיביות המתקבל .כל צומת בעץ מכיל ארבעה מרכיבים המתאימים לארבעת
הצעדים המבוצעים בקריאה הרקורסיבית המתאימה.
הציור:
split
)(N
הציור:
(ms
)N/2
הציור:
(merge
N/2,
)N/2
מייצג פיצול של רשימה בארוך  Nלכדי שתי רשימות.
מייצג קריאה רקורסיבית עם רשימה באורך N/2
מייצג מיזוג של שתי רשימות באורך  N/2לכדי רשימה באורך .N
84
עץ הקריאות יראה לפיכך באופן הבא:
(merge
N/2,
)N/2
(merge
N/4,
)N/4
(ms
)N/4
(ms
)N/4
(merge
N/8,
)N/8
(merge
N/8,
)N/8
(ms
)N/8
(ms
)N/8
(ms
)N/2
(ms
)N/2
split
)(N/2
(ms
)N/8
split
)(N/4
(merge
N/4,
)N/4
split
)(N/4
(ms
)N/8
(merge
N/8,
)N/8
split
)(N
(ms
)N/4
(merge
N/8,
)N/8
(ms
)N/8
(ms
)N/8
(ms
)N/4
(ms
)N/8
split
)(N/2
(ms
)N/8
split
)(N/4
עתה נשאל את עצמנו כמה רמות תהינה בעץ? ומה כמות העבודה שתתבצע בכל
קטן בחצי בכל רמה .ברמה
רמה? אנו רואים שגודלה של הרשימה אותה יש למיין ֵ
התחתונה יש למיין רשימה בת איבר יחיד .על-כן מספר הרמות יהיה ) ,log2(Nשכן
הפונקציה ) log2(xמתארת כמה פעמים ניתן לחצות את  xעד שמגיעים לערך אחד.
כמות העבודה הנעשית בכל רמה הינה בסדר גודל של  :Nבשורש אנו מפצלים
רשימה בגודל ) ,Nתוך השקעת עבודה בשיעור  ,(Nואחר ממזגים שתי רשימות
באורך ) N/2תוך השקעת עבודה בשיעור  .(Nברמה שמתחת לשורש :פעמיים אנו
מפצלים רשימה בגודל  N/2לכדי שתי רשימות בגודל ) N/4ועל כל פיצול כזה אנו
משלמים עבודה בשיעור  ,(N/2ופעמיים אנו ממזגים שתי רשימות בגודל  N/4לכדי
רשימה בגודל ) N/2ועל כל מיזוג משלמים עבודה בשיעור  .(N/2וכך הלאה.
מכיוון שבעץ יש ) log2(Nרמות ,ובכל רמה מתבצעת עבודה בשיעור  ,Nאזי סך כל
העבודה הנעשית היא ) . N*log2(Nכלומר כמות עבודה לזאת הנעשית על-ידי מיון
מהיר על קלט מיטבי )ולמעשה ,אך אנו לא הוכחנו זאת ,גם על קלט ממוצע( .שימו
לב כי במיון מיזוג זאת תהא כמות העבודה שתתבצע עבור כל קלט שהוא )בעוד מיון
מהיר ,על קלט גרוע ,יידרש לבצע עבודה בשיעור .(N2
85
split
)(N/4
 13.9בניית רשימה מקושרת ממוינת בשיטת המצביע למצביע
בסעיף  12.3ראינו כיצד ניתן לבנות רשימה מקושרת ממוינת .לשם הוספת איבר
ַלרשימה השתמשנו בשני מצביעי עזר  rear, frontאשר סרקו את הרשימה
הקיימת כדי לאתר את המקום בו יש לשלב את האיבר החדש .הלולאה שהרצנו
התקדמה עם המצביעים עד אשר  frontהגיע לאיבר שהכיל ערך גדול מהערך אותו
יש להוסיף .המצביע  rearהצביע אז לאיבר שאחריו יש לשלב את האיבר החדש .זו
היא הדרך הארוכה אך הקצרה לבניית רשימה מקושרת ממוינת :היא ארוכה
באשר היא לא הכי אלגנטית ,היא קצרה שכן היא יחסית קלה יותר להבנה .בסעיף
זה נרצה לראות את הדרך הקצרה אך ארוכה לבניית רשימה מקושרת ממוינת:
דרך זו תהיה אלגנטית יותר ,אך קשה יותר להבנה.
הכלי הבסיסי בו נשתמש לבניית הרשימה הוא מצביע למצביע.
נניח כי בתכנית הראשית הוגדר . struct Node *head = NULL; :אחר
זומנה הפונקציה . build_list(head); :הפונקציה  build_listדומה לזו
שראינו בעבר .נציג אותה:
{ )void build_list(struct Node *&head
; int num
; cin >> num
{ )while (num != 0
; )insert(num, &head
; cin >> num
}
}
הנקודה היחידה אליה יש צורך לתת את הדעת היא זימונה של הפונקציה .insert
שימו לב כי אנו קוראים ל insert -עם  ,&headכלומר עם מצביע ַלמצביע .head
הוא:
insert
הפונקציה
של
השני
הפרמטר
טיפוס
משמע
) . struct Node **headמי שהקריאה הנ"ל מבלבלת אותו יכול לבצעה בשני
; struct Node **tempלהכניס את כתובתו של
צעדים) :א( ַלמשתנה:
 headכלומר את  ,&headוזאת באמצעות ההשמה) , temp = &head; :ב( לקרוא
לפונקציה.( insert(num, temp); :
86
: נציגה ואחר נסבירה.insert נפנה עתה לפונקציה
void insert(int num, struct Node **p_2_head) {
struct Node *temp ;
if (*p_2_head == NULL) {
*p_2_head = new (std::nothrow) struct Node ;
if (*p_2_head == NULL)
terminate("cannot allocate memory", 1) ;
(*p_2_head)-> _data = num ;
(*p_2_head)-> _next = NULL ;
return ;
}
while ( (*p_2_head) != NULL &&
(*p_2_head)-> _data < num )
p_2_head = & ( (*p_2_head)-> _next ) ;
temp = new (std::nothrow) struct Node ;
if (temp == NULL)
terminate("cannot allocate memory", 1) ;
temp -> _data = num ;
temp -> _next = (*p_2_head);
(*p_2_head) = temp ;
}
 ומי שנתקף בפיק ברכיים עת הוא רואה,יש להודות שהפונקציה אינה קלה להבנה
.(*)  טרם שהוא פונה לראות כוכביות, כדאי שיחזק את ברכיו,( לנגד עיניו->) חץ
5) .0 ,17 ,3879 ,3 ,5 :כדי להבינה נבצע לה סימולציית הרצה מדוקדקת עבור הקלט
.( מוזן אחרון0 ,מוזן ראשון
-נתחיל בהצגת מצב המחסנית של התכנית הראשית טרם הקריאה ל
:build_list
head =
 מצב המחסנית. כפרמטר הפניהhead את
head =
head =
num=
87
 מקבלתbuild_list הפונקציה
:כן-בעקבות הקריאה הוא על
הפונקציה  build_listמתחילה להתבצע .היא קוראת את הערך  5לתוך ,num
וקוראת ל .insert -הפונקציה  insertמקבלת מצביע למצביע; נסמן מצביע זה
כפי שאנו מציירים מצביעים בדרך כלל )עם ראש בצורת  ,(vונַפנה את המצביע
להצביע על המצביע שעליו הוא מורה ) headשל התכנית הראשית( .מצב המחסנית
יהיה:
= head
= head
num= 5
= p_2_head
num= 5
=temp
עתה הפונקציה  insertמתחילה להתבצע .נזכור כי  *p_2_headהוא ה'-יצור'
עליו ) p_2_headשל  (insertמצביע ,וזהו המשתנה  headשל התכנית הראשית.
לכן עת הפונקציה שואלת האם ערכו של  *p_2_headהוא  NULLהיא שואלת האם
 headשל התכנית הראשית ערכו הוא  .NULLנוכל לראות זאת גם מהציור:
 p_2_headהוא מצביע *p_2_head ,הוא היצור עליו המצביע מצביע ,ומהציור אנו
רואים שהחץ היוצא מ p_2_head -מצביע על המשתנה  headשל התכנית
הראשית.
אם כן ,התנאי ) (*p_2_head == NULLמתקיים .לכן מבוצע הגוש הכפוף לו:
המצביע  ,*p_2_headכלומר המצביע עליו  p_2_headמורה )שהוא המצביע head
של התכנית הראשית( עובר להצביע על קופסה חדשה )מטיפוס (struct Node
שמוקצית על הערמה .הביטוי (*p_2_head)-> _data :מתייחס למרכיב ה-
 _dataבקופסה שהוקצתה .נסביר מדוע p_2_head :הוא המצביע למצביע
)טיפוסו ,(struct Node ** :לכן  *p_2_headהוא המצביע עליו p_2_head
מצביע ,כלומר המצביע  headשל התכנית הראשית )שטיפוסו הוא struct Node
*( .הביטוי (*p_2_head)-> :מפנה אותנו לקופסה שהוקצתה אך זה עתה,
מורה .ולבסוף:
ֵ
ושעליה ) ,(*p_2_headכלומר  headשל התכנית הראשית,
 (*p_to_head)-> _dataמתייחס לשדה ה data_ -בקופסה שעליה המצביע
מצביע .לשדה  (*p_2_head)-> _dataאנו משימים את הערך  .5באופן דומה
לשדה המצביע בקופסה עליה מצביע  headשל התכנית הראשית אנו מכניסים את
ְ
הערך  .NULLבזאת  insertמסתיימת.
מצב הזיכרון לקראת סיום ריצתה הראשונה של  insertהוא:
=data
5
= head
= head
num= 5
= p_2_head
num= 5
=temp
88
עם תום ריצתה של  insertרשומת ההפעלה שלה מוסרת מעל-גבי המחסנית ,ואנו
שבים ל build_list -אשר קוראת את הערך  ,3ומזמנת שנית את  .insertגם
בקריאה הנוכחית מעבירה  build_listל insert -מצביע ל .head -מצב
הזיכרון דומה למתואר בציור האחרון ,פרט לכך שערכו של  numבשתי רשומות
ההפעלה הוא עתה ) 3במקום .(5
אחרי בניית רשומת ההפעלה מתחילה הפונקציה להתבצע .התנאי:
) (*p_2_head == NULLאינו מתקיים )ערכו של המצביע עליו מצביע
 ,p_2_headכלומר המצביע  headשל התכנית הראשית ,אינו  .(NULLלכן אנו פונים
ללולאה אשר התנאי בכותרתה הוא:
) ( (*p_2_head) != NULL && (*p_2_head)-> _data < num
נעקוב אחר הביטוי p_2_head :הוא מצביע *p_2_head .הוא האובייקט עליו
 p_2_headמצביע ,כלומר המצביע  headשל התכנית הראשית(*p_2_head)-> .
 _dataהוא מרכיב הנתונים בקופסה עליה  *p_2_headמצביע ,כלומר מרכיב
הנתונים בקופסה עליה  headמצביע ,כלומר הערך  .5לכן התנאי:
(*p_2_head)-> _data < numאינו מתקיים )ערכו של  numהוא  ,(3ולכן איננו
נכנסים אף לא לסיבוב יחיד בלולאה .ערכו של  *p_2_headהוא עדיין המצביע
 headשל התכנית הראשית )כפי שהיה עת הפונקציה נקראה( .ברמה העקרונית
הסיבה לכך היא שאת הנתון הנוכחי עלינו להוסיף בראש הרשימה.
89
עתה אנו פונים לארבע הפקודות שאחרי לולאת קידומו של  .p_2_headהראשונה
מבין הארבע מקצה קופסה חדשה על הערמה ,ומפנה את המצביע )הרגיל ,שטיפוסו
*  temp (struct Nodeלהצביע על הקופסה המוקצית )מחמת מיאוס( .הפקודה
השניה מבין הארבע שותלת בשדה ה _data -של הקופסה את הערך  .3הפקודה
השלישית מפנה את  temp-> _nextלהצביע כמו  .*p_2_headלאן מצביע
 *p_2_head ? *p_2_headהוא המצביע  headשל התכנית הראשית ,אשר מצביע
על הקופסה של  .5לכן עתה גם  temp-> _nextמצביע על הקופסה של  .5מצב
הזיכרון הוא:
= head
=data
5
= head
num= 3
=data
3
= p_2_head
num= 3
=temp
הפקודה הרביעית ,והאחרונה ,מפנה את  ,*p_2_headכלומר את המצביע
של התכנית הראשית ,להצביע כמו  ,tempכלומר על הקופסה של ) 3זו שנוצרה אך
זה עתה( .מצב הזיכרון הוא:
head
= head
=data
5
= head
num= 3
=data
3
= p_2_head
num= 3
=temp
אנו רואים כי  3הוסף בראש הרשימה .כפי שראוי היה .בכך מסתיימת .insert
אנו שבים ל build_list -אשר קוראת את הערך  ,3879ומזמנת שוב את
 .insertציור הזיכרון הוא כמו בציור האחרון ,פרט לכך שערכו של  numבשתי
רשומות ההפעלה הוא .3879
90
הפונקציה  insertמתחילה להתבצע .התנאי ) (*p_2_head == NULLאינו
מתקיים ,שכן  *p_2_headהוא המצביע  headשל התכנית הראשית ,וערכו אינו
 .NULLאנו מתקדמים ללולאה שהתנאי בראשה הוא:
) ( (*p_2_head) != NULL && (*p_2_head)-> _data < num
שני המרכיבים של התנאי מסופקים .עבור השמאלי הדבר ברור .עבור הימני:
 *p_2_headהוא המצביע  ,headולכן  (*p_2_head)-> _dataהוא הערך ,3
וערך זה קטן מערכו של ) numשהינו  .(3879על-כן אנו נכנסים לגוף הלולאה.
הפרמטר  p_2_headעובר להצביע על מצביע חדש .על איזה מצביע? נבדוק:
 (*p_2_head)-> _nextהוא המצביע _ nextבקופסה עליה מצביע
 ,*p_2_headכלומר המצביע _ nextבקופסה עליה מצביע  ,headכלומר המצביע
שמצביע על הקופסה של  .5המצביע למצביע  p_2_headמקבל את כתובתו של
מצביע זה )בשל ה & -שנכתב לפני שמו של המצביע .( (*p_2_head)->next
מבחינה ציורית המצב הוא:
= head
=data
5
= head
num=3879
=data
3
= p_2_head
num= 3879
=temp
כלומר  p_2_headמצביע על :המצביע שמורה על הקופסה של  .5לקראת כניסה
לסיבוב נוסף בלולאה אנו שוב בודקים את התנאי ,ושוב הוא מסופק ,על כן
 p_2_headעובר להצביע על המצביע  .(*p_2_head)->nextנבדוק מיהו מצביע
זה *p_to_head :הוא המצביע המורה על הקופסה של  .5על כן
 (*p_2_head)-> _nextהוא שדה המצביע בקופסה עליה מצביע ,*p_2_head
כלומר המצביע בקופסה של ) 5שערכו הוא  .(NULLמבחינה ציורית מצב הזיכרון
הוא:
= head
=data
5
= head
num=3879
=data
3
91
= p_2_head
num= 3879
=temp
לקראת כניסה לסיבוב נוסף בלולאה אנו שוב בודקים את התנאי:
) ( (*p_2_head) != NULL && (*p_2_head)-> _data < num
אולם המרכיב השמאלי בו אינו מתקיים :ערכו של המצביע  *p_2_headאינו שונה
מ .NULL -על כן איננו נכנסים לסיבוב נוסף בלולאה .אנו פונים לארבע הפקודות
שאחרי הלולאה .השתיים הראשונות בניהן מביאות אותנו למצב:
= head
=data
5
= head
num=3879
=data
3
=data
3879
= p_2_head
num= 3879
=temp
הפקודה השלישית היא . temp->next = (*p_2_head); :ערכו של המצביע
) *p_2_headכלומר המצביע עליו מצביע  ,(p_2_headהוא  .NULLעל-כן גם ערכו
של  temp->nextיהיה  .NULLהפקודה הרביעית היא(*p_2_head) = temp; :
 .המצביע )(*p_2_headהוא המצביע בקופסה של  ,5והוא עובר להצביע לאותו
מקום כמו  ,tempכלומר לקופסה של  .3879מצב הזיכרון הוא:
= head
=data
5
= head
num=3879
=data
3
=data
3879
= p_2_head
num= 3879
=temp
ושוב הרשימה ממוינת כנדרש.
את המעקב אחרי תהליך הוספתו של  17לרשימה אשאיר לכם כתרגיל.
לסיכום ,אנו רואים שבאמצעות שימוש במצביע למצביע קיצרנו את הפונקציה
 ,insertאשר אינה משתמשת עוד בזוג מצביעים  ,rear, frontואינה מחייבת
התייחסות למקרה הקצה בו יש להוסיף איבר בראש הרשימה הממוינת .מנגד
הפונקציה שכתבנו קשה יותר להבנה.
92
 13.10הערה בנוגע לconst-
עת למדנו להעביר מערך כפרמטר לפונקציה אמרנו שבכוחה של הפונקציה לשנות
את ערכם של תאי המערך .לכן אם בתכנית הוגדר, int a[3]={17,17,17}; :
והוגדרה:
)][void f1(int arr
{
} ; arr[0] = 0; arr[1] = 1
אזי ערכו של ] a[0לפני הקריאה לפונקציה הוא  ,17וערכו אחרי ביצוע הפונקציה,
עבור הקריאה f1(a); :הוא אפס.
בהמשך ,ראינו כי אם ברצוננו למנוע מפונקציה לשנות את ערכם של תאי מערך
המועבר לה כפרמטר ,אזי אנו יכולים להוסיף לפני שם הפרמטר את מילת המפתח
 .constהוספת מילת המפתח תמנע מהפונקציה לשנות את ערכם של תאי המערך.
אמרנו כי האפקט אינו זהה לזה של פרמטר ערך ,שכן את ערכו של פרמטר ערך
הפונקציה רשאית לשנות; )אולם הערך החדש יוכנס ַלפרמטר בלבד ,ולא ַלארגומנט
המתאים(.
עוד ראינו כי עת מערך מועבר כפרמטר לפונקציה אנו רשאים לתאר את הפרמטר גם
באופן הבא:
} … { )void f2(int *ip
עתה נשאל את עצמנו :נניח שברצוננו למנוע מ f2 -לשנות את ערכם של תאי המערך
עליו מצביע )פרמטר הערך(  ,ipכיצד נעשה זאת? התשובה היא :כמו קודם ,כלומר
נוסיף את מילת המפתח  constלפני שם הטיפוס:
} … { )void f2(const int *ip
אם עתה  f2תכלול פקודה כגון ip[0]=0; :אזי התכנית לא תתקמפל בהצלחה.
שאלה :האם בפונקציה  f2אנו רשאים לשנות את ערכו של  ,?ipכלומר ,האם
פקודה כגון ip = NULL; :או ;]ִ ip = new int[5ת ְמנע מהפונקציה
להתקמפל בהצלחה? תשובה :לא; מילת המפתח  constהמופיעה לפני שם הטיפוס
רק מונעת מהפונקציה לשנות את ערכם של תאי המערך עליו מצביע  ,ipהיא אינה
מונעת מאתנו לשנות את ערכו של  .ipנשים לב כי בפונקציה  f2הפרמטר  ipהוא
פרמטר ערך ,ועל-כן שינויים שהפונקציה תכניס לתוכו לא יוותרו בארגומנט
המתאים ל ip -אחרי ש f2 -תסתיים.
נדגים את מה שהסברנו באמצעות הפונקציה  .strlenבעבר ראינו את הגרסה
הבאה של הפונקציה:
{ )int strlen(char *s
; int i
)for (i = 0; *s != '\0’; i++, s++
;
; return i
}
הפונקציה מקבלת מצביע לתו .היא מגדירה משתנה עזר  ,iורצה על הסטרינג
המועבר לה כל עוד התו עליו מצביע  sשונה מ .‘\0’ -בכל סיבוב בלולאה הפונקציה
גם מגדילה את ערכו של  ,iוכך היא מונה כמה פעמים הסתובבנו בלולאה ,במילים
אחרות מה אורכו של הסטרינג .ערך זה הפונקציה מחזירה .שימו לב כי גוף הלולאה
כולל את הפקודה הריקה ,וזאת משום שכל העבודה שיש לבצע נעשית בכותרת
הלולאה ,אשר גם מקדמת את  iואת .s
93
הפונקציה  strlenאינה אמורה לשנות את תוכנו של הסטרינג המועבר לה .מאידך,
בגרסה שאנו כתבנו הפונקציה משנה את ערכו של הפרמטר  ,sשכן בכל סיבוב
בלולאה היא מסיטה אותו לתא הבא במערך )באמצעות הפקודה  s++שהינה
אריתמטיקה של מצביעים ,המקדמת את המצביע  sתא אחד הלאה במערך( .האם
אנו מעונינים להוסיף את מילת המפתח  constלפני הפרמטר  sשל  ?strlenהאם
אנו רשאים להוסיף את מילת המפתח  constלפני הפרמטר  sשל ?strlen
התשובה לשתי השאלות היא :כן .מילת המפתח  constתמנע מהפונקציה לשנות
את ערכם של תאי המערך ,היא לא תמנע ממנה לשנות את ערכו של המצביע.
עתה ישאלו השואלים :ומה קורה אם ברצוננו למנוע מהפונקציה גם לשנות את
ערכו של המצביע? התשובה היא שגם לכך יש פתרון .ראשית ,נזכיר כי שינוי ערכו
של מצביע שהינו פרמטר ערך אינו משפיע על הארגומנט המתאים ,ועל-כן נזק נוראי
הוא לא יגרום .שנית אם נתאר את הפרמטר באופן הבא char * const s :אזי
בכך אנו מונעים מהפונקציה לשנות את ערכו של המצביע )אך לא את תוכנם של תאי
הזיכרון עליהם המצביע מורה( .אנו רשאים גם לשלב את שתי ההגבלות:
 ,const char * const sובכך נמנע מהפונקציה לשנות הן את ערכו של
המצביע ,והן את ערכם של תאי הזיכרון עליהם המצביע מורה.
עתה נבדוק את התסריט הבא) :א( פרמטר כלשהו ,בפונקציה כלשהי ,הוגדר באופן
הבא) ,const int *ip :שכן רצינו למנוע מהפונקציה לשנות את ערכו של המערך
אחר הגדרנו משתנה לוקלי , int *temp = ip; :כלומר
עליו מצביע ) ,(ipב( ַ
הגדרנו מצביע  tempוהפננו אותו להצביע על המערך עליו מורה ) ,ipג( לבסוף
כתבנו . *temp = 0; :מה עשינו? 'עבדנו' על התכנית ,ושינינו את ערכו של התא
מספר אפס במערך לא דרך הפרמטר  ,ipאלא באמצעות המצביע  .tempהאומנם?
פריֵרית .אם  tempהוגדר כ ,int *temp -אזי השפה תמנע
לא ,ולא! שפת  Cאינה ַ
מכם לבצע את ההשמה , temp = ip; :וזאת בדיוק כדי למנוע מכם לבצע
תעלולים )במילים אחרות שטויות( כפי שתיארנו קודם לכן .ומה קורה אם ברצונכם
בכל אופן להכניס את ערכו של  ipלמשתנה  ?tempהגדירו את  tempבאופן הבא:
; . const int *tempעתה תוכלו להשים את ערכו של  ipלתוך  ,tempאך בשל
הדרך בה הוגדר  tempכבר לא תוכלו לבצע השמה. *temp = 0; :
העקרונות שחלים ביחס למצביעים ,מערכים ו ,const -תקפים גם לגבי רשימות
משורשרות .נבחן לדוגמה את הפונקציה  disply_listשכתבנו בעבר ,ואשר
אמורה להציג את הערכים השמורים ברשימה מקושרת:
{ ) void display_list( struct Node *head
{ )while (head != NULL
; cout << head -> _data
; head = head -> _next
}
}
ראשית נשים לב כי אם מתכנת לא אחראי יוסיף מייד בתחילת הפונקציה את
הפקודה head = NULL; :אזי הפונקציה לא תפעל כהלכה ,אולם היא לא תפגע
ברשימה ה מקושרת שנבנתה .שינוי שהוכנס ְלפרמטר הערך של הפונקציה לא ישפיע
על הארגומנט המתאים ,והאחרון ימשיך להצביע על האיבר הראשון ברשימה
שבפונקציה אנו מקדמים את הפרמטר  headללא חשש
שנבנתה; זו גם הסיבה ַ
שנפגע בארגומנט ִעמו הפונקציה מזומנת.
אולם ,אם המתכנת הלא אחראי יכתוב את הפקודהhead-> _next = NULL; :
בעינה גם אחרי ביצוע הפונקציה; וזאת משום ש-
אזי הפגיעה שהוא מבצע תישאר ֵ
94
 head-> _nextהוא מצביע המצוי בתאי הרשימה )המוקצים על הערמה( .ותאים
אלה אינם משוכפלים עם הקריאה לפונקציה .עם הקריאה לפונקציה משוכפל רק
הפרמטר  .headשינוי שמתבצע על  head-> _nextחל על המצביע האחד והיחיד
היוצא מהאיבר הראשון ברשימה ,ואין דרך לשחזר את ערכו של מצביע זה.
האם יש ביכולתנו לגרום לכך שהוספתה של פקודה כגוןhead-> _next = :
;ַ NULLלפונקציה  display_listתמנע מהתכנית להתקמפל בהצלחה ,ועל-ידי
כך תשמור עלינו מפני מתכנתים לא אחראיים? התשובה היא :כן! את הפרמטר של
הפונקציה נגדיר כ . const struct Node *head :בכך אנו מונעים הכנסת
שינויים לתאי הזיכרון עליהם  headמצביע ,אך איננו מונעים הכנסת שינויים לערכו
headעצמו ,וטוב שכך ,שכן בפונקציה אנו משנים את ערכו של head
של
באמצעות הפקודה. head = head -> _next; :
בחלק ניכר מהפונקציות שכתבנו ניתן היה להגדיר את הפרמטר כconst -
 . struct Node *headאתם מוזמנים לסקור את הפונקציות השונות שהוגדרו
בפרק זה ,ולאתר את אותן בהן היה ראוי לקבוע את הפרמטר כמצביע לקבוע.
 13.11תרגילים
התרגילים המופיעים להלן נלקחו כולם מבחינות ומתרגילים שניתנו על-ידי מורים
במוסדות להשכלה גבוהה שונים .על-כן התרגילים אינם קלים ,ומחייבים השקעה
מרובה הן של מחשבה והן של עבודה.
13.11.1
תרגיל מספר אחד :רשימות משורשרות של משפחות
בתרגיל זה נדון ברשימות משורשרות של משפחות משולשלות )במובן שושלות(.
כל משפחה בתרגיל תהיה משפחה גרעינית הכוללת לכל היותר אם ,אב ומספר
כלשהו של ילדים .נתוני משפחה יחידה ישמרו במבנה מהטיפוס:
struct family
{
; char *family_name
char *mother_name, *father_name ; // parents' names
; child *children_list
// list of the children in this family
; family *next_family
// pointer to the next family in our database
;}
נתוני כל ילד ישמרו ב:
struct child
{
; char *child_name
; child *next_child
;}
מבנה הנתונים המרכזי של התכנית יהיהfamily *families :
התכנית תאפשר למשתמש לבצע את הפעולות הבאות:
 .10הזנת נתוני משפחה נוספת .לשם כך יזין המשתמש את שם המשפחה )וזהו
מרכיב חובה( ,ואת שמות בני המשפחה .במידה והמשפחה אינה כוללת אם יזין
המשתמש במקום שם האם את התו מקף )) (-המרכיב  mother_nameבמבנה
המתאים יקבע אז ,כמובן ,להיות  ,(NULLבאופן דומה יצוין שאין אב במשפחה,
גם סיום רשימת שמות הילדים יצוין באופן זה )הרשימה עשויה ,כמובן ,להיות
95
ריקה( .לא תיתכן משפחה בה אין לא אם ולא אב )יש לתת הודעת שגיאה במידה
וזה הקלט מוזן ,ולחזור על תהליך קריאת שמות ההורים( .אתם רשאים להגדיר
משתנה סטטי יחיד מסוג מערך של תווים לתוכו תקראו כל סטרינג מהקלט .את
רשימת המשפחות יש להחזיק ממוינת על-פי שם משפחה .את רשימת ילדי
המשפחה החזיקו בסדר בה הילדים מוזנים .הניחו כי שם המשפחה הוא מפתח
קבוצת המשפחות ,כלומר לא תתכנה שתי משפחות להן אותו שם משפחה
)משמע ניסיון להוסיף משפחה בשם כהן ,עת במאגר כבר קיימת משפחת כהן
מהווה שגיאה( .אין צורך לבדוק כי לא מוזנים באותה משפחה שני ילדים בעלי
אותו שם.
דוגמה לאופן קריאת הנתונים:
Enter family name: Cohen
Enter father name: Yosi
 Enter mother name:Enter children's names: Dani Dana -
 .11הוספת ילד למשפחה .יוזן שם המשפחה ושמו של הילד .הילד יוסף בסוף רשימת
הילדים .במידה ומשפחה בשם הנ"ל אינה קיימת יש להודיע על-כך למשתמש
)ולחזור לתפריט הראשי(.
 .12פטירת בן משפחה .יש להזין) :א( את שם המשפחה) ,ב( האם מדובר באם )ולשם
כך יוזן הסטרינג  ,(motherבאב )יוזן  ,(fatherאו באחד הילדים )יוזן ) (childג(
במידה ומדובר בילד יוזן גם שם הילד שנפטר .במידה ואין במאגר משפחה
כמצוין ,או שבמשפחה אין בן-משפחה כמתואר )למשל אין אב והתבקשתם
לעדכן פטירת אב ,או אין ילד בשם שהוזן( יש לדווח על שגיאה )ולחזור לתפריט
הראשי( .כמו כן לא ניתן לדווח על פטירת הורה במשפחה חד-הורית )ראשית יש
להשיא את המשפחה למשפחה חד-הורית אחרת ,כפי שמתואר בהמשך ,ורק אז
יוכל ההורה המתאים לעבור לעולם שכולו טוב(.
 .13נשואי שתי משפחות קיימות .יש לציין את שמות המשפחות הנישאות ,ואת שם
המשפחה החדש שתישא המשפחה המאוחדת .פעולה זאת לגיטימית רק אם
אחת משתי המשפחות אינה כוללת אם בעוד השניה אינה כוללת אב .בעקבות
ביצוע הפעולה תאוחדנה רשימות הילדים )ראוי לעשות זאת ע"י מניפולציה של
מצביעים ,ללא הקצאת מבנים חדשים!( ,והמבנה שתיאר את אחת המשפחות
ישוחרר.
 .14הצגת נתוני המשפחות בסדר עולה של שמות משפחה) .זהו ,כזכור ,הסדר בו
מוחזקת רשימת המשפחות( .עבור כל משפחה ומשפחה יש להציג את שם
המשפחה ,ואת שמות בני המשפחה.
 .15הצגת נתוני משפחה בודדת רצויה .יש להזין את שם המשפחה ויוצגו פרטי בני
המשפחה) .במידה והמאגר אינו כולל משפחה כמבוקש תוצג הודעת שגיאה(.
 .16הצגת נתוני המשפחות בסדר יורד של מספר בני המשפחה .יש להציג את שם
המשפחה ואת מספר בני המשפחה .לשם סעיף זה החזיקו רשימה מקושרת
נוספת .כל איבר ברשימה יהיה מטיפוס:
struct family_size
{
family *the_family ; // pointer to a family at the families linked list
; int size
; // number of children in this family
family_size *next_families_sizes_item ; // pointer to next item in the current list
}
המצביע לראש הרשימה הזאת יהיה  .family_size *families_sizesהרשימה
תמוין בסדר יורד של המרכיב  .sizeעל-ידי סריקה של רשימה זאת ופניה ממנה
בכל פעם למשפחה המתאימה )תוך שימוש במצביע  (the_familyתוכלו להציג את
96
המשפחות בסדר יורד של גודל המשפחות .שימו לב כי יש גם לתחזק רשימה
זאת :עת חל שינוי במשפחה כלשהי ברשימת המשפחות ,ונניח כי המצביע p
מצביע על אותה משפחה ,יש לסרוק את הרשימה על ראשה מצביע
 ,families_sizesעד שמאתרים מרכיב ברשימה זאת שהמצביע  the_familyבו
מצביע לאותו מקום כמו המצביע  ,pזוהי המשפחה שגודלה שונה .עתה יש
להזיז את המרכיב המתאים ברשימת גודלי המשפחות למקום המתאים )על-ידי
הסרתו מהרשימה ,והוספתו במקום חדש( .בין כל המשפחות להן אותו גודל אין
חשיבות לסדר ההצגה .תלמיד שלא יענה על סעיף זה ציונו המרבי בתרגיל יהיה
 ,90תלמיד שיענה על סעיף זה יקבל בונוס של עד  7נקודות ,ובלבד שציונו לא
יעלה על מאה.
 .17הצגת נתוני שכיחות שמות ילדים .יש להציג עבור כל שם ילד המופיע במאגר,
כמה פעמים הוא מופיע במאגר .אין צורך להציג את הרשימה ממוינת.
)רמז\הצעה :סרקו את מאגר המשפחות משפחה אחר משפחה ,עבור כל משפחה
עיברו ילד אחר ילד ,במידה ושמו של הילד הנוכחי עדיין לא מופיע ברשימת
הילדים ששמם הודפס סיפרו כמה פעמים מופיע שם הילד במאגר הנתונים,
ואחר הוסיפו את שמו של הילד לרשימת שמות הילדים שהוצגו(.
 .18סיום .בשלב זב עליכם לשחרר את כל הזיכרון שהוקצה על-ידכם דינמית) .עבור
כל משפחה ומשפחה :יש לעבור על רשימת הילדים ולשחרר כל מרכיב ברשימה
זאת ,לפני שמשחררים כל קודקוד ברשימת הילדים יש לשחרר את הזיכרון
שמחזיק את שמו של הילד ,אחרי שמשחררים את רשימת הילדים יש לשחרר
את הזיכרון שמחזיר את שמות ההורים ואת שם המשפחה(.
הערות כלליות:
ג .התכנית תנהל כלולאה בה בכל שלב המשתמש בוחר בפעולה הרצויה לו ,הפעולה
מתבצעת )או שמוצגת הודעת שגיאה( ,והתכנית חוזרת לתפריט הראשי.
ד .הקפידו שלא לשכפל קוד ,למשל כתבו פונקציה יחידה אשר מציגה נתוני
משפחה ,והשתמשו בה במקומות השונים בהם הדבר נדרש .וכו'.
 Needless to sayשיש להקפיד על כללי התכנות המקובלים ,בפרט ובמיוחד
מודולריות ,פרמטרים מסוגים מתאימים )פרמטרי ערך  vsפרמטרים משתנים( ,ערכי
החזרה של פונקציות ושאר ירקות .תכנית רצה אינה בהכרח גם תכנית טובה! )קל
וחומר לגבי תכנית מקרטעת(…
13.11.2
תרגיל מספר שתיים :איתור פרמידה ברשימה
בתכנית מוגדר:
struct struct Node
{
; int _data
; struct Node *next
; }
ברשימה מקושרת נגדיר פרמידה באורך  ,2n +1כסדרה של איברים רציפים
ברשימה המסודרים באופן הבא n +1 :האיברים הראשונים בפרמידה מקיימים
שהערך בכל איבר גדול מהערך באיבר שקדם לו ,ו n +1 -האיברים האחרונים
 ,n +1מקיימים שהערך בכל איבר קטן
בפרמידה ,המתחילים בתא שמספרו
מהערך באיבר שקדם לו.
97
א .כתבו שגרה המקבלת מצביע מטיפוס *  struct Nodeלרשימה מקושרת
ומחזירה מצביע לפרמידה הארוכה ביותר המוכלת ברשימה ,בפרמטר הפניה
יוחזר אורך הפרמידה.
לדוגמה ברשימה:
0
0
1
1
6
7
10
2
9
5
קיימות פרמידה באורך  5המורכבת מהאיברים ,0 1 7 6 2 :ושלוש פרמידות
באורך  3המורכבות מהאיברים 0 1 0 ;1 7 6 ;2 10 9
במידה וקיימות כמה פרמידות מרביות באורכן ניתן להחזיר מצביע לאחת
מהן.
ב .מה זמן הריצה של השגרה שכתבתם?
13.11.3
תרגיל מספר שלוש :רשימה מקושרת דו-כיוונית
נגדיר:
struct struct Node
{
; int _data
; struct Node *next, *prev_odd_even
;}
בתכנית כלשהי ברצוננו לשמור רשימה מקושרת ממוינת אשר תתוחזק באופן
הבא:
המצביע _ nextבכל תא יצביע על האיבר הבא ברשימה.
באיבר שהערך בו זוגי יצביע המצביע  prev_odd_evenלאיבר הזוגי הקודם
ברשימה; באיבר שהערך בו פרדי יצביע המצביע  prev_odd_evenלאיבר הפרדי
הקודם ברשימה.
דוגמה לרשימה אפשרית )המצביע _ nextצויר בקו שלם prev_odd_even ,בקו
מרוסק .מצביעים שערכם ] NULLכגון _ nextמהתא של  ,8וכן prev_odd_even
מהתאים של  2ושל  [1הושמטו מהציור(:
8
4
7
2
1
כתבו פונקציה אשר מקבלת מצביע לרשימה מקושרת בעלת מבנה כמתואר,
וערך שלם ,ומוסיפה את הערך לרשימה.
הערה :חשיבות יתרה תינתן לשיקולי יעילות.
13.11.4
תרגיל מספר ארבע :רשימה מקושרת עם מיון כפול
בתכנית מוגדר:
struct info
{
98
; ]int _data[2
; ]info *next[2
;}
בתכנית הוגדרו גם המצביעים  ,info *head0, *head1ונבנתה רשימה מקושרת.
הרשימה נבנתה כך שאם נפנה אליה דרך המצביע  head0ואחר נתקדם לאורכה
באמצעות המצביעים _ ] next[0בכל תא ,נסרוק את הרשימה כשהיא ממוינת על-
פי השדה _] .data[0מנגד ,אם נפנה לרשימה דרך המצביע  head1ואחר נתקדם
לאורכה באמצעות המצביעים _ ] next[1בכל תא ,נסרוק את הרשימה כשהיא
ממוינת על-פי השדה _].data[1
דוגמה לרשימה:
data[0] =6
data[1] =17
= ]next[0
= ]next[1
data[0] =5
data[1] =1
= ]next[0
= ]next[1
data[0] =2
data[1] =19
= ]next[0
= ]next[1
head0
head1
מקושרת כנ"ל ,וכן
כתבו פונקציה המקבלת זוג מצביעים לרשימה
מערך ] .int new_data[2הפונקציה תוסיף לרשימה איבר חדש שיחזיק את ערכי
][ new_dataאם ורק אם לא קיים ברשימה איבר עם שדה _] data[0השווה בערכו
ל new_data[0] -וכן לא קיים ברשימה איבר עם שדה ] data[1השווה בערכו ל-
]. new_data[1
13.11.5
תרגיל מספר חמש :רשימה מקושרת עם מיון כפול
בתכנית מוגדר:
struct info
{
; int _data
; ]info *next[2
;}
בתכנית הוגדר גם המצביע  ,info *headונבנתה רשימה מקושרת באופן הבא:
א .המצביע ] next[0מורה על הנתון הבא ברשימה ,כלומר הנתון שהוכנס בעקבות
הנתון הנוכחי )במילים אחרות אם נסרוק את הרשימה תוך התקדמות על
המצביעים ] next[0בכל תא ,אזי נסרוק את הנתונים בסדר הכנסתם(.
ב .המצביע ] next[1בכל תא מורה על התא הבא ברשימה בו קיים אותו ערך כמו
בתא הנוכחי )כשאנו אומרים 'התא הבא' כוונתנו תא שנמצא בהמשך הרשימה
מבחינת סדר המצביעים ] .(next[0במידה ולא קיים תא בהמשך בו מצוי אותו
ערך ,יצביע המצביע בתא לאיבר הראשון ברשימה בו קיים הערך )כשאנו
אומרים 'ראשון ברשימה' אנו מתכוונים :ראשון אם נסרוק את הרשימה
באמצעות ] .(next[0בפרט ,אם ברשימה קיים איבר יחיד בו יש ערך  xכלשהו,
אזי ] next[1באותו תא יצביע על התא עצמו.
99
דוגמה לרשימה :סדר הכנסת הערכים) 2 :שבתא השמאלי() 2 ,17 ,הימני(
data = 2
= ]next[0
= ]next[1
data = 17
= ]next[0
= ]next[1
data = 2
= ]next[0
= ]next[1
head
כתבו פונקציה המקבלת מצביע לרשימה מקושרת כנ"ל ,וערך שלם ,val
הפונקציה תוסיף לרשימה איבר חדש שיחזיק את הערך .val
13.11.6
תרגיל מספר שש :מאגר ציוני תלמידים
כתבו תכנית המטפלת בציוני תלמידים .לכל תלמיד תחזיק התכנית את הנתונים
הבאים) :א( שם משפחה) ,ב( שם פרטי) ,ג( רשימת הקורסים שהתלמיד לקח ,כאשר
לכל קורס יוחזק (1) :שם הקורס (2) ,כמות השעות השבועיות המוקצות לקורס )(3
ציונו של התלמיד בקורס.
התכנית תאפשר למשתמש לבצע את הפעולות הבאות:
 .1תוספת תלמיד חדש לרשימת התלמידים )במידה וברשימה כבר קיים תלמיד
שזה שמו תתקבל הודעת שגיאה(.
 .2מחיקת תלמיד מרשימת התלמידים )במידה והתלמיד המבוקש לא קיים
ברשימה תתקבל הודעת שגיאה(.
 .3הוספת קורס נוסף לתלמיד המצוי ברשימה )במידה והתלמיד כבר רשום לאותו
קורס תינתן הודעת שגיאה(.
 .4ביטול קורס לו רשום תלמיד כלשהו )במידה והתלמיד אינו מופיע ברשימה ,או
שהתלמיד אינו רשום לאותו קורס תינתן הודעת שגיאה(.
 .5עדכון ציון של תלמיד כלשהו בקורס כלשהו )הודעות שגיאה בהתאם(.
 .6הצגת רשימת הקורסים והציונים עבור תלמיד מבוקש כלשהו; הרשימה
המוצגת תחולק לשניים ראשית יוצגו הקורסים אותם התלמיד עבר ,ואחר
הקורסים בהם התלמיד נכשל .מעבר להדפסת הרשימה הנ"ל יודפס גם ממוצע
ציוני התלמיד )ממוצע משוקלל שישקלל עבור כל קורס את משקלו == מספר
השעות השבועיות המוקצות לקורס( ,ומספר הקורסים בהם התלמיד נכשל.
מבנה הנתונים המרכזי בתכנית יוגדר באופן הבא:
struct course // holds info about a single course
{
; char *course_name
; int grade
;}
struct stud // holds info about a single student
{
; char *last, *first
course *courses ; // array of courses stud took
stud *next_stud ; // pointer to the next student in
// the students’ list
; }
100
פי שם משפחה-התכנית תחזיק רשימה מקושרת ממוינת של התלמידים )ממוינת על
 רשימת הקורסים של כל תלמיד תוחזק כמערך ממוין )על פי שם.( שם פרטי+
קורס( שיוקצה באופן דינמי ויוגדל בכל פעם שיש להוסיף קורס נוסף ואין די מקום
.במערך בגודלו הנוכחי
:התכנית הראשית תראה לערך כך
void main()
{
stud *class ; // pointer to the list of students
... prototypes should be declared here...
init(class) ;
do
{
int option = get_option() ;
switch (option)
{
case ADD_STUD : add_stud(&class) ; break ;
case REM_STUD : rem_stud(&class) ; break ;
case ADD_COURSE : add_course(class) ; break ;
case REM_COURSE : rem_course(class) ; break ;
case UPDATE_GRADE : update_grade(class) ; break ;
case DISPLAY_GRADES : display_grades(class) ; break ;
case END : dispose(&class) ; break
}
}
while (option != END) ;
}
: תראהadd_stud :השגרה
void add_stud(stud **my_class)
{
void get_stud_name( char **last_name, char **first_name) ;
void add_stud_to_list(stud **a_class, char *a_last, char *a_first) ;
char *last_n, *first_n ;
get_stud_name(&last_n, &first_n) ; // read last/first name from user
add_stud_to_list(my_class, last_n, first_n) ;
}
...ואידך זיל גמור
 מיון הכנסה של רשימה מקושרת:תרגיל מספר שבע
13.11.7
:בתכנית מוגדר
struct info
{
int _data ;
info *next ;
101
;}
א .כתבו פונקציה אשר מקבלת כפרמטר מצביע לראשה של רשימה מקושרת
כנ"ל .על הפונקציה למיין את אברי הרשימה בסדר עולה .בתום פעולתה של
הפונקציה יצביע הפרמטר לראשה של הרשימה הממוינת.
מיון הרשימה יתבצע באופן הבא :במעבר מספר  iעל הרשימה )…(i = 1, 2, 3,
יאותר האיבר ה-i -י בקוטנו )לדוגמה :במעבר הראשון יאותר האיבר הכי
קטן ,במעבר השני יאותר האיבר השני הכי קטן ,וכו'( ,ויועבר למקום ה-i -י
ברשימה )לדוגמה :האיבר השני הכי קטן יועבר למקום השני ברשימה(.
הערות) :א( הקפידו על יעילות הפונקציה שתכתבו) .ב( עדיף שהפונקציה לא
תקצה תאים חדשים! )ג( אין להניח דבר על תכולתה של הרשימה המועברת
לפונקציה) .ד( כרגיל ,תנו דעתכם למודולריות.
ב .מה זמן הריצה של הפונקציה שכתבתם? הסבירו בקצרה.
13.11.8
תרגיל מספר שמונה :פולינומים
בפרק שש הצגנו תכנית המטפלת בפולינומים .בפרק אחת-עשרה שינינו את מבנה
הנתונים באמצעותו יישמרו הפולינומים לכדי מערך של מבנים מטיפוס .monom
עתה ברצוננו להכניס שינוי נוסף במבנה הנתונים ,ולייצג כל פולינום כרשימה
מקושרת של מונומים .נוכל לעשות זאת באופן הבא:
{ struct monom
; float coef, degree
; }
כלומר מונום מיוצג כמו בפרק אחת-עשרה.
ייצוגו של פולינום יהיה:
{ struct polynom
; monom a_monom
; polynom *next_monom
;}
סדרת פולינומים תיוצג באופן הבא:
{ list_of_polynoms
; polynom *a_polynom
; list_of_polynoms next_poly
; }
ובתכנית נחזיק משתנהlist_of_polynoms *database; :
אתם מוזמנים ,ראשית ,להתעמק במבנה הנתונים ולהבינו ,ושנית לכתוב את תכנית
הפולינומים תוך שימוש במבנה נתונים זה.
13.11.9
תרגיל מספר תשע :הפרדת רשימות משורשרות שהתמזגו
בתכנית כלשהי מוגדר  struct Nodeכפי שמוכר לנו היטב ,והוגדרו
;.Node head1, head2
102
struct
בתכנית עשוי לקרות מצב בו שתי הרשימות המשורשרות עליהן מצביעים head1,
 head2התמזגו החל ממקום מסוים ,כלומר קיימים איברים השייכים הן לרשימה
עליה מצביע  head1והן לרשימה עליה מצביע .head2
לדוגמה:
3
2
5
2
1
1
עליכם לכתוב פונקציה המקבלת שני מצביעים לשתי רשימות ,ואשר מפרידה את
הרשימות באופן הבא :כל התאים השייכים )גם( לרשימה עליה מצביע הפרמטר
הראשון ישויכו ,בעקבות ביצוע הפונקציה ,אך ורק לרשימה זאת .כל האיברים
השייכים רק לרשימה עליה מצביע הפרמטר השני ישויכו לרשימה עליה מורה
פרמטר זה.
בדוגמה שלנו ,תיראנה הרשימות בתום ביצוע הפונקציה באופן הבא:
3
2
5
2
1
1
13.11.10
תרגיל מספר עשר :בדיקת איזון סוגריים
מחסנית היא מבנה נתונים )במילים אחרות משתנה( המתנהל על-פי העיקרון הבא:
בקצה המחסנית )כלומר בקצה המשתנה ,ולא באמצעו( .כל
כל איבר חדש מוסף ְ
איבר שמוסר מהמחסנית מוסר מאותו קצה אליו מוּספים הנתונים החדשים )ועל כן
המחסנית מתנהלת בשיטה 'אחרון שנוסף הוא הראשון שמוסר' ובלע"ז last in first
 outובקיצור  .LIFOנקל לממש מחסנית באמצעות רשימה מקושרת :כל איבר חדש
יתווסף בראש הרשימה; בעת שיש להסיר איבר שולפים את זה המצוי בראש
הרשימה.
כתבו תכנית אשר קוראת ביטוי חשבוני שעשוי להכיל סוגריים ממסר סוגים) :א( ) (,
)ב( ] [) ,ג( } {) ,ד( > <  .התכנית תבדוק האם הסוגריים בביטוי מאוזנים כהלכה.
)התכנית לא תתעניין כלל ביתר מרכיבי הביטוי פרט לסוגריים.
לדוגמה הביטוי (5+3)*< (2+2) / ([6 / 7] + 2) > -6 :הוא ביטוי תקין ,אך הביטויים
הבאים אינם תקינים:
)א( ) (5+3*< (2+2) / ([6 / 7] + 2) > -6בביטוי זה נשמט הסוגר הימני שהופיע בביטוי
הראשון מייד אחרי ה.(3 -
)ב( )ב( ) (5+3)*< (2+2) / ([6 / 7] + 2) ) –6בביטוי זה הסוגר הימני הזוויתי שהופיע
בביטוי המקורי לפני  6- -הוחלף בסוגר מעוגל(.
103
)ג( )ג( ) (5+3)*< (2+2) / ([6 / 7) + 2] > -6בביטוי זה הסוגר המרובע שהופיע במקור
אחרי ה ,7 -הומר בסוגר מעוגל ,והסוגר המעוגל שהופיע אחרי ה 2 -השלישי
הומר בסוגר מרובע.
)ד( (.)5+5
האלגוריתם לבדיקת ביטוי אותו עליכם לממש הוא כמתואר:
קרא את הביטוי מרכיב אחר מרכיב )כאשר מארכיב עשוי להיות :מספר טבעי
כלשהו ,סימן פעולה ,או סוגר(:
א .במידה ונתקלת בסוגר פותח מסוג כלשהו דחף אותו על-גבי המחסנית.
ב .במידה ונתקלת בסוגר סוגר מסוג כלשהו בדוק האם בראש המחסנית מצוי סוגר
פותח מאותו סוג; אם אכן מצוי סוגר כנדרש אזי שלוף אותו מהמחסנית,
אחרת :הודע על שגיאה.
בתום קריאת הביטוי בדוק האם המחסנית ריקה ,אם לא הודע על שגיאה.
הניחו כי כל ביטוי יוזן בשורה יחידה ,וכי לא יוזנו שני ביטויים שונים באותה
שורה.
13.11.11
תרגיל מספר אחת-עשרה :המרת תא ברשימה ברשימה
בתכנית הוגדר המבנה  nodeכמקובל.
כתבו את הפונקציה  replaceאשר מקבלת:
א .מצביע  head_mainלראשה של רשימה מקושרת אחת.
ב .מצביע  head_secondaryלראשה של רשימה מקושרת שניה.
ג .מספר שלם .wanted
הפונקציה תחליף כל תא המכיל את הערך  wantedברשימה עליה מצביע
 head_mainבאברי הרשימה .head_secondary
לדוגמה :אם  head_mainמצביע על הרשימה:
5
2
7
ו head_secondary -מצביע על הרשימה:
2
3
9
6
וערכו של  wanedהוא שתיים ,אזי בתום פעולה של הפונקציה יצביע head_main
על הרשימה:
5
9
6
7
9
6
3
 13.11.12תרגיל מספר שתים-עשרה :מחיקת איבר מינימלי מסדרות
המרכיבות רשימה מקושרת
בתכנית הוגדר  struct struct Nodeכמקובל ,ונבנתה רשימה מקושרת .אנו
אומרים כי הרשימה ה מקושרת מורכבת ממספר סדרות של מספרים .כל סדרה
מסתיימת בערך אפס )שאינו חלק מהסדרה( ,ובתא שאחר-כך מתחילה הסדרה
הבאה.
104
לדוגמה :הרשימה ה מקושרת הבאה:
0
1
2
5
0
2
9
2
0
5
מורכבת מהסדרות) :א( ) , 5ב( ) ,2 ,9 ,2ג( .1 ,2 ,5
כתבו פונקציה המקבלת מצביע לרשימה מקושרת כנ"ל ,ומוחקת את האיבר
המזערי בכל סדרה .במידה ובסדרה כלשהי מופיע האיבר המזערי מספר פעמים יש
למחוק את המופע הראשון שלו .במידה ואחרי מחיקת האיבר המזערי הסדרה
התרוקנה )שכן הוא היה האיבר היחיד בסדרה( ,יש למחוק גם את האיבר המכיל
את הערך אפס ,והמציין את תום הסדרה.
לדוגמה :אחרי ביצוע הפונקציה תראה הרשימה הנ"ל באופן הבא:
0
2
105
5
0
2
9
עודכן 1/2011
 14עצים בינאריים
עץ בינארי הוא אמצעי לאחסון נתונים .אנו אומרים כי עץ בינארי הוא מבנה נתונים
מופשט ) ,(Abstract Data Type, ADTאותו נוכל לממש בתכנית מחשב בדרכים שונות.
 14.1הגדרת עץ בינארי
עת בינארי מוגדר באופן הבא:
א .עץ בינארי עשוי להיות ריק.
ב .עץ בינארי שאינו ריק מכיל:
 .1שורש )שהינו צומת בעץ(.
 .2ילד שמאלי שהינו עץ בינארי.
 .3ילד ימני שהינו עץ בינארי.
ג .כל צומת ) (nodeבעץ בינארי הוא ילד של צומת אחד בדיוק ,פרט לצומת יחיד,
הקרוי שורש העץ ,שאינו ילד של אף צומת אחר) .יש המשתמשים במילה
'קודקוד' במקום במילה 'צומת' .בעבר ,עת העולם היה פחות תקין פוליטית,
נהגו להשתמש במונחים אב ובנים בקשר לעץ ,כיום הוחלפו הכינויים להורה
וילדים(.
אנו רואים כי הגדרת עץ בינארי היא רקורסיבית :עץ בינארי מוגדר תוך שימוש
במונח עץ בינארי; אולם בשלב זה של חיינו הדבר אינו גורם לנו למורא )קל וחומר
שלא למשוא פנים( ,שכן יש גם מקרה קצה :העץ עשוי להיות ריק.
נביט בדוגמה של עץ בינארי בה כל צומת מכיל נתון מספרי:
17
77
13
83
נבדוק האם ומדוע זהו עץ בינארי?
א .הוא אינו ריק ,לכן עליו להכיל שורש ,ילד שמאלי שהוא עץ בינארי ,וילד ימני
שהינו עץ בינארי .הצומת המכיל את  17הוא שורש העץ ,ועתה כדי להשלים את
תהליך ההצדקה יש להראות כי ילדו השמאלי של הצומת של  ,17וילדו הימני של
הצומת של  17הם עצים בינאריים .ילדו השמאלי של השורש הוא תת-העץ:
13
וילדו הימני של השורש הוא תת-העץ:
83
106
77
ב .עתה נראה כי האובייקט:
13
הוא עץ בינארי .הוא כולל שורש ,שהינו
הצומת של  ,13ושני הילדים שהינם ריקים וככאלה הם עצים בינאריים.
ג .עתה נבחן את:
77
83
הוא אינו ריק .הוא כולל שורש שהינו הצומת של  ,77ילד שמאלי שהינו ריק
)ועל-כן הוא עץ בינארי( ,ילד ימני שכולל את :הצומת של  ,83ושני ילדים ריקים.
הילד הימני של  77הוא עץ בינארי מאותה סיבה שתת-העץ שכלל את  13הוא עץ
בינארי .הילד השמאלי של  77הוא עץ בינארי שכן הוא ריק ,ולכן גם כל הציור
שבסעיף ג' הוא עץ בינארי.
מעבר למה שבדקנו נשים לב כי כל צומת בעץ הוא ילד של צומת אחד בדיוק ,פרט
לצומת של  17שאינו ילד של אף צומת אחר בעץ.
107
נשים לב כי ארבעת הבאים אינם עצים בינאריים ,ואני מזמין אתכם לבחון מדוע כל
אחד ואחד מהם אינו עונה על ההגדרה:
77
57
83
17
77
17
83
57
77
57
88
83
17
77
83
17
עצים בינאריים נקראים 'בינאריים' שכן לכל צומת בהם ייתכנו לכל היותר שני
ילדים.
עץ בינארי יקרא עץ חיפוש בינארי ) binary search treeאו בקיצור  (BSTאם הוא
מקיים את התנאי הבא :עבור כל צומת  nבעץ מתקיים שכל הערכים המצויים בתת
העץ השמאלי של  nקטנים או שווים מהערך המצוי בצומת  ,nוכל הערכים המצויים
בתת-העץ הימני של  nגדולים מהערך המצוי ב .n -העץ הראשון שהצגנו הוא עץ
חיפוש בינארי :בתת-העץ השמאלי של הצומת של  17מצוי ערך קטן מ ,17 -ובתת
העץ הימני ערכים גדולים מ .17 -גם עבור הצומת של  77בתת-העץ הימני מצוי ערך
גדול מ .77 -לו היינו ממירים את הערך  83בערך  53אזי העץ כבר לא היה עץ חיפוש
שכן בתת-העץ הימני של  77כבר לא היו רק ערכים גדולים מ) .77 -אעיר כי מסיבות
שונות יש המאפשרים לערך השווה לערך המצוי בשורש של עץ חיפוש בינארי
להימצא בתת העץ השמאלי או בתת העץ הימני; אצלנו ערך שווה יימצא בהכרח
בתת העץ השמאלי( .תלימידים לעתים מכנים עץ חיפוש בינארי בשם עץ ממוין; זה
אינו שם מופרך ,אך גם לא מקובל באופן 'רשמי'.
 14.2מימוש עץ בינארי בתכנית מחשב
עד כה הצגנו את העץ הבינארי בצורתו המופשטת ,עתה נפנה לשאלה כיצד נייצג את
העץ בתכנית מחשב בשפת  ?Cבשלב הנוכחי של חיינו המשותפים רבים מכם ודאי
יציעו לעשות זאת באמצעות מצביעים .זו אכן תשובה מתאימה ,ומייד נפנה להציגה
בצורה מלאה; אולם ראשית נִ ראה כי ניתן לייצג עצים בינאריים גם באמצעות
108
מערכים; כלומר את מבנה הנתונים המופשט 'עץ בינארי' ניתן לממש בתכנית מחשב
במספר אופנים.
כיצד ימומש עץ בינארי באמצעות מערך?
 .Iתא מספר אחד במערך יכיל את הנתונים שבשורש העץ) .בתא מספר אפס
במערך לא נעשה כל שימוש(
 .IIאם הנתונים שבצומת  nשל העץ נשמרו בתא מספר  xבמערך ,אזי הנתונים
שבילדו השמאלי של  nיישמרו בתא מספר  ,2*xוהנתונים שבילדו הימני של n
יישמרו בתא מספר .2*x +1
לדוגמה ,העץ שראינו בתחילת הפרק יישמר במערך של מספרים שלמים באופן
הבא:
83
77
13
17
#9
#8
#7
#6
#5
#4
#3
#2
#1
#0
לתאים בהם לא מצוין ערך יש להתייחס כאל תאים ריקים .אנו רואים כי שורש
העץ ,הנתון  ,17מצוי בתא  ,#1שני ילדיו של השורש שמורים בתאים  .#3 ,#2לצומת
שמכיל את הערך ) 13ושמצוי בתא  #2במערך( אין ילדים ,ועל כן התאים #5 ,#4
במערך נותרו ריקים .גם לצומת שמכיל את  77אין ילד שמאלי ,ועל כן התא  #6נותר
ריק .התא  #7מכיל את ילדו הימני של הצומת שנתוניו מופיעים בתא .#3
כמובן שגודלו של העץ שניתן לייצג חסום על-ידי גודלו של המערך.
עתה נפנה לייצוג עץ בינארי באמצעות מצביעים .לאורך הפרק נניח כי כל צומת בעץ
מכיל נתון מספרי בודד .במקרה הכללי הצומת עשוי להכיל מספר נתונים ,שיקובצו,
קרוב לודאי ,ל , struct -ואז במקום השדה  int _dataיהיה לנו שדה struct
 . infoנציג עתה את המבנה הבסיסי שישמש אותנו לאורך הפרק:
{ struct Node
; int _data
; struct Node *_left, *_right
; }
טיפוס המבנה שהגדרנו כולל חבר שמכיל את הנתון המספרי ,ושני מצביעים לשני
ילדיו של הצומת.
 14.3בניית עץ חיפוש בינארי
נניח כי בתכניתנו הוגדר . struct Node *root; :המצביע  rootיצביע לשורשו
של העץ הבינארי שנבנה בתכנית .את הפונקציה  ,build_bstשבונה את העץ ,אנו
מזמנים באופן הבא . root = build_bst() ; :הפונקציה  build_bstתבנה
עץ חיפוש בינארי ,ותחזיר מצביע לשורש העץ שנבנה .מצביע זה יוכנס למשתנה
הבא:
באופן
הפונקציה
את
לכתוב
יכולנו
לחילופין
.root
;) . other_build_bst(rootכלומר יכולנו להעביר לפונקציה את המשתנה
 rootכארגומנט .הפונקציה  other_build_bstהייתה מקבלת פרמטר הפניה:
&*  .struct Nodeכפי שציינו עת דנו בפונ' ,מבחינת סגנון תכנותי ,הגרסה בה
מחזירים ערך ,עדיפה על-פני זו המקבלת את המצביע כפרמטר הפניה.
נציג את הפונקציה :build_bst
{ )(struct Node *build_bst
; struct Node *root = NULL
109
; int num
cin >> num
{ )while (num != 0
; )insert_into_bst(num, root
; cin >> num
}
; return root
}
בפונקציה  build_bstאין רבותא .הדבר היחיד עליו ראוי לתת את הדעת הוא
שהיא מגדירה מצביע). struct Node *root; :כלומר משתנה המוקצה על גבי
המחסנית( ,מצביע זה יורה על שורש העץ הבינארי שייבנה )ושצמתיו ישכנו ַבערמה(.
עם סיום ריצתה מחזירה הפונקציה את המצביע לשורש העץ.
110
 14.3.1הכנסה לעץ חיפוש באמצעות פונ' רקורסיבית
הפונקציה שתעניין אותנו יותר היא  .insert_into_bstנציג שלוש גרסות שונות
שלה.
נתחיל בראשונה:
{ )void insert_into_bst(int num, struct Node *&root
{ )if (root == NULL
; root = new (std::nothrow) struct Node
)if (root == NULL
; )terminate(“Can not allocate memory\n", 1
; root -> _data = num
; root->_left = root->_right = NULL
}
)else if (num <= root->_data
; )insert_into_bst(num, root->_left
else
; )insert_into_bst(num, root->_right
}
כפי שנקל לראות ,גרסתה הראשונה של  ,insert_into_bstכמו מרבית
הפונקציות לטיפול בעצים ,היא פונקציה רקורסיבית .היא פועלת באופן הבא :אם
הגענו ְלמצביע שמורה על )תת(-עץ ריק ,אזי יש להוסיף את הערך  numכאן ועכשיו
ְלצומת עליו המצביע יורה .אחרת :יש להוסיף את הערך החדש או לבנו השמאלי של
השורש או לבנו הימני )בהתאם לערכו של המספר המוסף( ,ועל כן אנו קוראים
רקורסיבית עם המצביע המתאים .כלומר  insert_into_bstמוסיפה ערכים
חדשים בתחתית העץ; במילים אחרות ערך שהוכנס מאוחר יותר לעולם לא יהיה
הורה של ערך שהוכנס מוקדם יותר ,הערך שהוכנס מאוחר יותר יהיה צאצאו של
הערך שהוכנס מוקדם יותר ,או שהוא יוסף על ענף אחר של העץִ .בדקו לעצמכם כי
לא יתכן שלא יהיה מקום בעץ לערך חדש .כל ערך נוסף תמיד יוכל למצוא את
המקום המתאים לו ַבעץ )גם אם ערך זהה לו כבר מצוי אי-שם בעץ(.
מכיוון ש insert_into_bst -מקבלת את המצביע המועבר לה כפרמטר הפניה,
אזי שינויים שהפונקציה תכניס למצביע יוותרו בתום פעולתה בארגומנט המתאים
אשר יעבור להצביע על הצומת שהוסף לעץ.
כדרכנו ,נבצע סימולציית הרצה לפונקציה .נניח שהקלט המוזן הוא,13 ,83 ,77 ,17 :
 17) 0מוזן ראשון 0 ,מוזן אחרון( .בתחילה ,בתכנית הראשית מוגדר המשתנה root
)אשר אינו מאותחל ,ועל-כן כולל ערך 'זבל'( .נציג את מצב המחסנית:
=root
עת התכנית הראשית מזמנת את  build_bstמצב הזיכרון הוא:
=root
=root
=num
נזכור כי  rootשל  build_bstהוא משתנה לוקלי ,ועל-כן אין קשר בינו לבין
 rootשל  .mainהפונקציה  build_bstמתחילה להתבצע .היא קוראת את הערך
 17ומזמנת את ) insert_into_bstאשר מקבלת את המצביע כפרמטר משתנה(,
מצב הזיכרון הוא:
111
=root
=root
num=17
=root
num=17
עת  insert_into_bstשואלת ) if (root == NULLאנו הולכים בעקבות
החץ )עם הראש המשולש השחור( ,ובודקים את ערכו של  rootשל .build_bst
ערכו הוא אכן  NULLועל-כן מוקצית קופסה חדשה על הערמה ו root -של
 build_bstעובר להצביע על הקופסה .כמו כן השדות הרצויים מאותחלים כפי
שמתואר בקוד.
מצב הזיכרון בתום ריצתה של  insert_into_bstהוא:
17
a
=root
b
=root
num=17
=root
num=17
)האותיות  a, bשהוספו לציור תסייענה לנו בהמשך לזהות את המצביעים
המתאימים .הן אינן חלק ממצב הזיכרון ,אלא רק תוספות שהוספנו כדי להקל על
ההסברים(.
אנו שבים ל build_bst -אשר קוראת את הערך  ,77ומזמנת שוב את
 .insert_into_bstגם עתה  build_bstמעבירה ל insert -כארגומנט את
) rootשל  .(build_bstמצב הזיכרון דומה לציור האחרון ,פרט לכך שערכו של
 numבשתי רשומות ההפעלה הוא ) 77ולא  17כפי שמופיע בציור האחרון(.
112
עת  insert_into_bstמתחילה להתבצע ,ובודקת את ערכו של  ,rootהיא מגלה
כי ערכו שונה מ .NULL -גם התנאי ) (num <= root-> _dataאינו מתקיים ,ועל
כן  insert_into_bstקוראת רקורסיבית לעצמה ,ומעבירה לעותק הנקרא
הרקורסיבית את המצביע ) root-> _rightכלומר את המצביע הימני היוצא
מהקופסה של  ,17זה שסומן באות  .(aשימו לב כי מצביע זה מועבר ְלפונקציה
המקבלת פרמטר הפניה .לכן אם וכאשר הפונקציה תשנה את ערכו של הפרמטר
שלה )ותפנה אותו להצביע על קופסה כלשהי שתוקצה על-גבי הערמה( ,אזי
השינויים שהפונקציה תבצע יוותרו במצביע ,המסומן באות  ,aהמועבר כארגומנט
)ושערכו עתה הוא  .(NULLנציג את מצב הזיכרון בעקבות הקריאה הרקורסיבית
המזומנת:
17
=root
b
a
=root
num=77
=root
num=77
=root
num=77
113
העותק השני של  insert_into_bstמתחיל להתבצע .עת הוא שואל האם ערכו
של הפרמטר  rootשלו הוא  ,NULLהמחשב הולך בעקבות החץ השחור ,מגיע
למצביע  ,aומגלה שערכו הוא אכן  .NULLלכן עתה המחשב מקצה קופסה חדשה
ומפנה את המצביע  aלהצביע עליה .שימו לב שוב שהשינוי קורה על המצביע  aשכן
מצביע זה הועבר כארגומנט לפונקציה המקבלת פרמטר הפניה; ועל-כן שינויים
שהפונקציה עורכת לפרמטר חלים למעשה על הארגומנט .בעקבות ההקצאה גם
מוכנסים ערכים לקופסה שהוקצתה .מצב הזיכרון הוא:
17
=root
b
a
=root
num=77
77
c
=root
num=77
=root
num=77
בכך העותק השני של  insert_into_bstמסתיים .כתובת החזרה שלו היא סיומו
של העותק הראשון של  ,insert_into_bstולכן גם העותק הראשון מסתיים.
אנו שבים ל build_bst -אשר קוראת את הערך  ,83ומזמנת שוב את
 insert_into_bstתוך שהיא מעביר לה את  rootשל .build_bst
מצב הזיכרון הוא:
17
=root
b
a
=root
num=83
77
c
=root
num=83
הפונקציה  insert_into_bstמתחילה להתבצע .ערכו של  rootאינו  .NULLגם
 num <= root-> _dataאינו מתקיים ולכן insert_into_bst
התנאי:
קוראת רקורסיבית לעצמה ,תוך שהיא מעבירה לקריאה הרקורסיבית את המצביע
המסומן באות  .aמצב הזיכרון בעקבות הקריאה הוא:
17
=root
b
a
=root
num=83
77
c
=root
num=83
=root
num=83
114
העותק השני של  insert_into_bstמתחיל להתבצע .גם בו ערכו של  rootאינו
 ,NULLוגם בו לא נכון ש) num <= root->_data :שכן ערכו של  numהוא ,83
וערכו של  root->_dataהוא  .(77ולכן העותק הנוכחי של insert_into_bst
מזמן קריאה לעותק שלישי של  .insert_into_bstהעותק השני מעביר לעותק
השלישי את  root->_ _rightשהינו המצביע המסומן באות  cבציור שלנו .מצב
הזיכרון:
17
=root
b
a
=root
num=83
77
c
=root
num=83
=root
num=83
=root
num=83
עת העותק השלישי של  insert_into_bstמתחיל להתבצע ,ובוחן את ערכו של
 rootשלו ,הוא מגלה כי ערכו הוא  .NULLעל כן העותק השלישי יוצר קופסה חדשה
ומפנה את הארגומנט שהועבר לו ,כלומר את המצביע  ,cלהורות על הקופסה
החדשה .אחר העותק השלישי גם מכניס ערכים לקופסה כפי שמתואר בקוד .מצב
הזיכרון הוא עתה:
17
=root
b
a
=root
num=83
77
c
=root
num=83
83
=root
num=83
=root
num=83
שלושת העותקים של  insert_into_bstמסתיימים בזה אחר זה ,ואנו שבים ל-
 build_bstאשר קוראת את הערך  ,13ומזמנת שוב את  insertכדי להוסיף ערך
זה לעץ .אני משאיר לכם כתרגיל לצייר את השתלשלות העניינים בזיכרון .אחרי ש-
) insert_into_bstבאמצעות קריאה רקורסיבית אחת נוספת( תוסיף את 13
לעץ נשוב ל .build_bst -עתה  build_bstתקרא את הערך אפס ,ולכן תצא
מלולאת קריאת הערכים build_bst .תתקדם לפקודת ה return -שלה בה היא
מחזירה את ערכו של  rootשלה .הערך המוחזר יושם למשתנה  rootשל התכנית
הראשית )להזכירכם  build_bstזומנה מה main -באמצעות הפקודה:
115
;)( .( root = build_bstלכן עתה  rootשל התכנית הראשית יעבור להצביע
על העץ ש build_bst -בנתה.
 14.3.2הכנסה לעץ חיפוש באמצעות פונ' איטרטיבית המקבלת פרמטר
הפניה
הפונקציה  insert_into_bstשכתבנו פעלה באופן רקורסיבי .הפונקציה
התקדמה על הענף המתאים של העץ עד קצהו ,כלומר עד שהיא הגיע ְלמצביע שערכו
הוא  ,NULLבאותו מקום הפונקציה הוסיפה את הצומת החדש .עת פונקציה
מתקדמת על ענף יחיד של העץ ,ואינה סורקת מספר ענפים ,ניתן לכתבה גם באופן
איטרטיבי ,תוך שימוש בלולאות .לכן עתה נציג גרסה איטרטיבית של
 .insert_into_bstאקדים את המאוחר ואציין שהגרסה אומנם איטרטיבית
ולא רקורסיבית ,וזו הקלה ,אך היא מסורבלת למדי ,וזו מגרעת ,ועל כן בשלב
שלישי נציג גרסה איטרטיבית ולא מסורבלת ,אך ,למרבה הצער)?( כזו העושה
שימוש במצביע למצביע .אני תקווה שהשוואת שתי הגרסות גם תאפשר לכם ללמוד
על היתרונות הגלומים בשימוש במצביע למצביע:
)void insert_into_bst(int num, struct Node *&root
{
struct Node *temp = new (std::nothrow) struct Node,
;*run = root
)if (temp == NULL
; )retminate("cannot allocate memoty", 1
)//(4
; temp -> _data = num
; temp -> _left = temp ->_right = NULL
{ )if (toor == NULL
; root = temp
; return
}
)// (1
)// (2
)// (3
{ )while(true
)if (num <= run -> _data
)if (run -> _left != NULL
; run = run -> _left
{ else
; run -> _left = temp
; break
}
else
)if (run -> _right != NULL
;run = run -> _ right
{ else
; run -> _right = temp
; break
}
}
}
נסביר את הפונ'.
116
נניח שהפונ' מזומנת כבר אחרי שנבנה העץ כמתואר בציור )מצביעים שערכם NULL
הושמטו מהציור ,פרט למצביע המתויג באות : (a
17
=root
num=83
12
...
14
10
=root
num=13
=temp
=run
a
ראשית מוקצה איבר חדש )קופסה חדשה( עליו מצביע משתנה העזר  .tempהערך
 13מוכנס למרכיב הנתונים בקופסה ,ושני המצביעים מקבלים את הערך .NULL
עתה מצביע העזר  runמקבל את ערכו של ) rootשל הפונ'( שהינו פרמטר הפניה,
ולכן  runעובר להצביע גם הוא על הקופסה של  ,17כלומר על שורש העץ(.
מכאן פונים ללולאה ,שהתנאי שלה מתקיים תמיד )לכאורה זו לולאה אינסופית,
ובפועל פקודות ה break -בגוף הלולאה הופכות אותה ללולאה סופית(.
התנאי המסומן בהערת התיעוד ) (1מתקיים ,וכך גם זה המסומן בהערה ) (2ולכן
 , run = run -> _leftכלומר  runעובר להצביע על הקופסה של  ,12ובזאת
תם הסיבוב הראשון בלולאה.
בסיבוב השני בלולאה ,התנאי ) (1אינו מתקיים ,והתנאי ) (3כן מתקיים ולכן run
עובר להצביע על הקופסה של .14
בסיבוב השלישי בלולאה התנאי ) (1מתקיים ,והתנאי ) (2אינו מתקיים ,ולכן
 , run-> _leftשהינו המצביע המסומן ב a -בציור ,עובר להצביע על הקופסה
החדשה שהוקצתה בתחילת הפונ' ,כלומר  13משולב בעץ במקום המתאים; ובזאת
ביצוע הפונ' מסתיים.
שאלות למחשבה:
א .האם ולמה יש צורך בהעברת rootכפרמטר הפניה )ולא כפרמטר ערך(.
ב .האם הפונ' הייתה שגויה לו בלולאה היינו מרצים את ) rootולא את runכפי
שאנו עושים(?
ג .האם ולמה יש חשיבות בהשמה המסומנת ב (4) -בפונ'?
117
נסכם :ראינו גרסה רקורסיבית של פונ' ההוספה לעץ חיפוש בינארי ,וגירסה
איטרטיבית )אך מעט מסורבלת( .שתי הגרסות קיבלו את המצביע לשורש העץ
כפרמטר הפניה.
 14.3.3הכנסה לעץ חיפוש באמצעות פונ' איטרטיבית המקבלת מצביע
למצביע
עתה נציג גרסה איטרטיבית לא מסורבלת אשר תקבל כפרמטר שלה מצביע ַלמצביע
לשורש העץ )וכמבן ,את הערך שיש להוסיף( .הגרסה שנראה עתה תזומן על ידי
 build_bstבאופן הבא .insert_into_bst(num, &root); :מכיוון ש-
 rootהוא מצביע ,אזי  &rootהוא מצביע למצביע .נציג את קוד הפונקציה:
{ )void insert_into_bst(int num, struct Node **p_2_root
)while (*p_2_root != NULL
) if ( num <=(*p_2_root)-> _data
; ) p_2_root = &( (*p_2_root)-> _left
; ) else p_2_root = &( (*p_2_root)-> _right
; NULL
; *p_2_root = new (std::nothrow) struct Node
if (*p2root == NULL) ...
; (*p_2_root)-> _data = num
= (*p_2_root)-> _left = (*p_2_root)-> _right
}
בפונקציה אנו מקדמים בלולאה את המצביע למצביע על הענף הרצוי ,עד אשר
המצביע עליו המצביע-למצביע מורה ערכו  .NULLבשלב זה אנו מקצים קופסה
ומפנים את המצביע עליו המצביע-למצביע מורה להצביע על הקופסה
ְ
חדשה,
החדשה.
נציג דוגמת הרצה של הפונקציה על העץ הבא .נניח כי עתה בנוסף לערכים ,77 ,17
 83עלינו להוסיף את  70תוך שימוש גרסה השלישית של פונ' ההכנסה .כאמור,
 build_bstמזמנת את הפונקציה באופן הבא:
) .insert_into_bst (num, &rootמצב הזיכרון עם הקריאה לפונקציה הוא:
17
=root
b
a
=root
num=70
77
c
=p_2_root
num=70
83
אנו שמים לב כי  p_2_rootהוא מצביע ,ועל-כן מצויר עם חץ בעל ראש בצורת .v
מכיוון שהוא מצביע למצביע )ולא מצביע לקופסה( הוא מצויר בקו מקווקוו .בעת
הקריאה  p_2_rootמצביע על המצביע  rootשל .build_bst
עת הפונקציה מתחילה להתבצע היא פונה ללולאה:
)while (*p_2_root != NULL
118
) if ( num <=(*p_2_root)-> _data
; ) p_2_root = &( (*p_2_root)-> _left
; ) else p_2_root = &( (*p_2_root)-> _right
נזכור כי *p_2_root :הוא המצביע עליו  p_2_rootמורה ,ובתחילה זהו root
של  build_bstשערכו שונה מ .NULL -לכן אנו נכנסים לסיבוב ראשון בלולאה.
התנאי ( num <=(*p_2_root)-> _data ) :אינו מתקיים ,שכן
 (*p_2_root)-> _dataהוא שדה ה data -בקופסה עליה מורה המצביע
) ,(*p_2_rootכלומר שדה ה _data -בקופסה עליה מורה המצביע עליו מצביע
 ,p_2_rootבמילים פשוטות זהו שדה ה _data -בקופסה עליה מורה  rootשל
 ,build_bstכלומר זה הערך  .17לכן אנו מבצעים את הפקודה הכפופה ל,else -
ומשימים למצביע  p_2_rootאת כתובתו של  ,root-> _leftכלומר את כתובתו
של המצביע  ,aמבחינה ציורית המצב הוא:
17
=root
b
a
=root
num=70
77
d
c
=p_2_root
num=70
83
לקראת כניסה לסיבוב שני בלולאה אנו בודקים את התנאי בכותרתה*p_2_root .
הוא עתה המצביע ) aהמצביע עליו מורה  ,(p_2_rrotולכן התנאי מתקיים .אנו
נכנסים לגוף הלולאה ,ובו התנאי( num <=(*p_2_root)-> _data :
)מתקיים ,שכן  (*p_2_root)-> _dataהוא שדה ה _data -בקופסה עליה
מצביע  ,aכלומר זהו הערך  .77לכן עתה  p_2_rootמקבל את הכתובת של הבן
השמאלי של התא עליו מצביע המצביע עליו  ,p_2_rootבמילים פשוטות:
 p_2_rootמקבל את כתובתו של המצביע  .dוהציור המתאים:
17
=root
b
a
=root
num=70
77
c
d
=p_2_root
num=70
83
לקראת כניסה לסיבוב נוסף בלולאה אנו בוחנים שוב האם*p_2_root!=NULL :
והפעם התשובה היא 'לא' ,ערכו של המצביע עליו מורה  p_2_rootהוא  .NULLאנו
הפקודה:
הלולאה.
שאחרי
לפקודות
מתקדמים
 *p_2_root = new struct Nodeמקצה קופסה חדשה על הערמה ,ושולחת
את המצביע עליו  p_2_rootמורה להצביע על קופסה זאת .הציור המתאים:
119
17
=root
b
a
=root
num=70
77
d
c
=p_2_root
num=70
83
שלוש הפקודות הבאות מכניסות ערכים לקופסה שהוקצתה .לדוגמה ,הפקודה:
; (*p_2_root)-> _data = numמכניסה את הערך  80לשדה ה_data -
בקופסה עליה מורה ) , (*p_2_rootכלומר מכניסה את הערך  80לשדה ה_data-
בקופסה עליה מורה המצביע ) dשעליו מצביע עתה .(p_2_root
כדי לחדד את הבנתנו את הפונקציה שהצגנו ,נבחן מה היה קורה לו היינו כותבים
אותה באופן הבא:
{ )void iterative_insert(int num, struct Node *&root
)while (root != NULL
) if ( num <= root-> _data
; root = root-> _left
; ) else root = root-> _right
; root = new (std::nothrow) struct Node
if (root == NULL) ...
; root-> _data = num
; root-> _left = root->_right = NULL
}
במקום מצביע למצביע ,פרמטר משתנה
הגרסה הנוכחית של הפונקציה מקבלת ְ
שהנו מצביע .שוב נניח כי יש להוסיף את  ,70לעץ הכולל את  .83 ,77 ,17הקריאה מ-
(iterative_insert
 build_bstלפונקציה ,בגרסתה הנוכחית תהא:
;) num, rootמצב הזיכרון בעקבות הקריאה יהא:
17
=root
b
a
=root
num=70
77
c
d
=root
num=70
83
עת הפונקציה מתחילה להתבצע ושואלת האם  ,root!=NULLאנו הולכים בעקבות
החץ )עם המשולש השחור( ,ובודקים את ערכו של  rootשל  ,build_bstשערכו
אכן אינו  .NULLלכן אנו נכנסים ללולאה .בגוף הלולאה אנו בודקים האםnum <= :
 ,root-> _dataוהתשובה לכך היא שלילית .לכן אנו מבצעים את ההשמה
120
הפטלית .root = root-> _right; :מדוע השמה זאת הינה פטלית? שכן אנו
מפנים את המצביע  rootשל ) build_bstכלומר את הארגומנט שהועבר
ְ
לפונקציה( להצביע כמו המצביע  , root-> _rightכלומר כמו המצביע  .aבזאת
הפננו את  rootשל  build_bstלהצביע על הקופסה של  ,77ואיבדנו לעולמים את
הקופסה של !17
 14.3.4זמן הריצה של בניית עץ חיפוש בינארי
עתה נשאל את עצמנו כמה עולה בניית עץ חיפוש בינארי במקרה הגרוע ביותר? אנו
זוכרים שכל נתון חדש נוסף כעלה ,אחרי שאנו יורדים לאורך המסלול המתאים
בעץ .לכן זמן הבניה יהיה הגדול עת כל עלה חדש יתווסף מעבר לקודמו ,ויאריך את
המסלול היחיד הקיים ,כלומר עת העץ 'יתנוון' לכדי רשימה מקושרת ,למשל מפני
שהנתונים יוזנו ממוינים .17, 20, 23, 25, 30, ... :העץ יראה:
17
20
23
25
30
במצב זה ,הכנסת הנתון ה-n -י תעלה  nצעדים ,ולכן עלות בניית העץ השלם תהיה:
 1 + 2 + …+nכלומר סד"ג של  n2צעדים.
לעומת זאת ,אם העץ הינו מאוזן' ,יפה' ,אזי אורך המסלול הארוך ביותר בו
מהצומת לעלה העמוק ביותר הוא לכל היותר  lg2(n+1)-1צעדים ,או סד"ג של ).lg2(n
לא נוכיח זאת ,אך נביט בכמה דוגמות שיעזרו לנו להשתכנע בכך:
בעץ השמאלי יש צומת יחיד .יש לבצע אפס צעדים מהשורש לעלה העמוק ביותר,
כלומר מספר הצעדים הוא  lg2(1+1)-1כפי שמורה נוסחה שלנו .בעץ האמצעי יש
שלושה צמתים .מספר הצעדים הדרוש כדי להגיע מהשורש לעלה העמוק ביותר הוא
121
אחד ,ולכן ,שוב בהתאמה לנוסחה .lg2(3+1)-1 :בעץ הימני lg2(7+1)-1 = 2 :ואכן יש
להתקדם שני צעדים מהשורש לעלה העמוק ביותר.
מכיוון שפעולת ההכנסה מחייבת להתקדם מהשורש לעלים ,אזי  nפעולות הכנסה
יחייבו עבודה בשיעור של לכל היותר ) n*lg2(nצעדים.
 14.4ביקור בעץ חיפוש בינארי
עתה ברצוננו לכתוב פונקציה אשר מציגה את כל הנתונים השמורים בעץ החיפוש
שבנינו .נרצה להציג את הנתונים ממוינים .כמובן שכדי להציג את כל הנתונים עלינו
לסרוק את כל צמתי העץ) ,ולא די בסריקת ענף יחיד( .מסיבה זאת הפונקציה
שנכתוב תהא בהכרח רקורסיבית.
עת בנינו את העץ הכנסנו נתון שערכו קטן או שווה מהנתון המצוי ַבשורש לתת-העץ
השמאלי; נתון שערכו גדול מהשורש הוכנס לתת-העץ הימני .על-כן גם ַבביקור בעץ,
ראשית נבקר בתת-העץ השמאלי ,המכיל נתונים קטנים או שווים מאלה שבשורש,
אחר נבקר בשורש ,ולבסוף נבקר בתת העץ הימני ,המכיל נתונים גדולים מאלה
המצויים ַבשורש .אם נחזיר על עקרון זה עבור כל תת-עץ שהוא אזי הנתונים יוצגו
תוֹכי או באנגלית:
ממוינים מקטן לגדול .לביקור שכזה בעץ אנו קוראים ביקור ִ
 in-orderיען כי אנו מבקרים בשורש בין ביקורנו בילד השמאלי ,לביקורנו בילד
הימני .אל מול  in-orderעומדים) :א( ביקור תחילי או  :pre-orederבו אנו מבקרים
ראשית בשורש ,אחר בילד השמאלי ,ולבסוף בילד הימני) ,ב( ביקור סופי או post-
 :orderבו אנו מבקרים ראשית בילד השמאלי ,שנית בימני ,ורק לבסוף בשורש .שימו
לב כי בכל שלושת הביקורים אנו מבקרים בילד השמאלי לפני הביקור בילד הימני.
ההבדל בין הביקורים השונים הוא בתשובה לשאלה :מתי מבקרים בשורש? לעת
עתה נדון בביקור  .in orderביקורים אחרים יעזרו לנו במשימות אחרות )למשל ,עת
נשחרר את העץ נרצה ראשית לשחרר את הילדים ,ורק לבסוף את השורש ,ולכן
ננקוט בביקור סופי(.
פונקצית הביקור ,וההצגה הינה:
{ )void display(const struct Node * root
{ )if (root != NULL
; )display(root-> _left
; cout << root-> _data
; )display(root-> _right
}
}
קוד הפונקציה פשוט למדי :אם )תת( העץ בו אנו מבקרים אינו ריק )(root!=NULL
אזי אנו פונים ראשית לבקר בילדו השמאלי ,אחר אנו מציגים את הנתון שבשורש,
ולבסוף אנו מבקרים בילד הימני.
122
נבצע סימולציית הרצה מעט מקוצרת על עץ החיפוש הבא ,עליו מצביע המשתנה
 rootשל התכנית הראשית:
17
=root
ב
א
17
35
ד
ג
ה
ו
35
ז
ח
עם הקריאה לפונקציה מצב הזיכרון הוא שעל גבי המחסנית נוספת רשומת ההפעלה
של הפונקציה:
=root
עתה הפונקציה מתחילה להתבצע .ערכו של הפרמטר ) rootשל הפונקציה( אינו
 NULLועל כן הפונקציה קוראת רקורסיבית לעותק שני של הפונקציה ,ומעבירה לו
את  root-> _leftכלומר עותק של המצביע ב' )המועבר כפרמטר ערך( .על גבי
המחסנית נוספת רשומת ההפעלה:
=root
העותק השני של הפונקציה מתחיל להתבצע .גם בו ערכו של  rootאינו  ,NULLועל-
כן העותק השני קורא רקורסיבית לעותק שלישי של הפונקציה ומעביר לעותק
השלישי את בנו השמאלי ,כלומר את המצביע ו' .בעותק השלישי ערכו של root
הוא  ,NULLועל כן העותק השלישי אינו מבצע דבר ,ומסיים מיידית .אנו שבים
לעותק השני אשר עתה מציג את ערכו של  root-> _dataכלומר את הערך .17
אחר מזמן העותק השני קריאה רקורסיבית שנסמנה כקריאה הרביעית ,ומעביר לה
עותק של המצביע ה' .בקריאה הרביעית ערכו של  rootהוא  ,NULLעל-כן היא
מסיימת בלי לבצע דבר ,אנו שבים לעותק השני אשר מסתיים בזאת .את העותק
השני זימן העותק הראשון )עת האחרון קרא רקורסיבית עם ילדו השמאלי( .עתה
העותק הראשון יכול להציג את  root-> _dataשלו ,כלומר את הערך  .17לאחר
מכן קורא עותק הראשון רקורסיבית עם ילדו הימני ,כלומר עם עותק של המצביע
א' .נסמן את הקריאה שזומנה עם עותק של המצביע א' כקריאה החמישית .ערכו
של  rootבה שונה מ NULL -ועל כן היא קוראת רקורסיבית עם ילדה השמאלי,
כלומר עם המצביע ד' ,אחר הקריאה החמישית תציג את הערך  ,35ולבסוף הקריאה
החמישית תקרא רקורסיבית עם המצביע ג' .הקריאה עם ג' תקרא רקורסיבית
לילדה השמאלי )הריק( ,אחר תציג את הערך  ,35ולבסוף תקרא רקורסיבית עם
ילדה הימני )הריק( .בזאת תסתיים הקריאה עם ג' .היא תחזור לקריאה עם א' ,אשר
תסתיים בזאת ,ותחזור לקריאה הרקורסיבית הראשונה .גם קריאה זאת תסתיים,
וזאת אחרי שהנתונים בעץ הוצגו ממוינים מקטן לגדול כנדרש.
הערת תזכורת :את הפרמטר של פונקציה הגדרנו באופן הבא:
 . const struct Node * rootהמילה  constמונעת מהפונקציה לשנות את
ערכו של האיבר עליו מצביע המצביע .root
123
 14.4.1זמן הריצה של סריקת העץ
זמן הריצה של סריקת העץ הוא לינארי במספר הצמתים בעץ ,שכן עבור כל צומת
אנו קוראים לפונ' פעם יחידה ,ועושים אז עבודה בישעור קבוע )בכל ביקור בכל
צומת( .מעבר לכך אנו קוראים לפונ' פעם יחידה גם עבור כל המצביעים שערכם הוא
) NULLשכן אם המציע לשורש תת-עץ כלשהו אינו  NULLאזי אנו קוראים לפונ' עם
שני תתי-העצים ,כלומר עם שני הילדים ,ואלה עשויים להיות  .(NULLמשפט קטן,
שלא נוכיח ,טוען שבעץ בינארי שאינו ריק ,מספר המצביעים שערכם הוא  NULLהוא
לכל היותר כמספר הצמתים ,ולכן זימון הפונ' גם עבור מצביעים אלה מכפיל את זמן
הריצה ,אך לא הופך אותו להיות לא לינארי במספר הצמתים.
 14.5חיפוש ערך מבוקש בעץ בינארי כללי ובעץ חיפוש בינארי
 14.5.1חיפוש ערך בעץ שאינו עץ חיפוש
עתה ברצוננו לכתוב פונקציה אשר מקבלת מצביע לשורשו של עץ בינארי )שאינו
דווקא עץ חיפוש( ,וערך שלם .הפונקציה תחזיר את הערך  trueאם ורק אם הערך
מצוי בעץ .הקריאה לפונקציה תהיה למשל:
) )if (search(num, root
; ”cout << “It exists in data\n
הגדרת הפונקציה:
{ )bool search(int num, const struct Node * root
; bool found
)if (root == NULL
; ) return( false
)if (root -> _data == num
;) return( true
; )found = search(root-> _left
)if (found
; return true
; )found = search(root-> _right
; ) return( found
}
הסבר הפונקציה :במידה ולפונקציה מועבר מצביע ל)תת(-עץ ריק אזי ברור שהערך
לא נמצא בעץ ,ויש להחזיר את הערך  .falseמנגד ,אחרי שוידאנו שהעץ אינו ריק,
אנו רשאים לבדוק את ערכו של  ,root-> _dataואם ערכו שווה לערך המבוקש
ניתן להחזיר מיידית את הערך  ,trueואין צורך להמשיך ולחפש בילדיו של השורש
הנוכחי .במידה ושני התנאים הראשונים לא התקיימו אנו קוראים רקורסיבית
לפונקציה על-מנת לבדוק האם הערך המבוקש נמצא בילד השמאלי .את הערך
המוחזר על-ידי הקריאה הרקורסיבית אנו מכניסים למשתנה  .foundבמידה וערכו
של המשתנה הוא  ,trueניתן להחזיר מייד איתות על מציאה ,ואין צורך להמשיך
ולבדוק את הילד הימני .לבסוף ,אם העץ אינו ריק ,הערך המבוקש לא נמצא לא
בשורש ולא בילד השמאלי ,אזי נבדוק האם הוא נמצא בילד הימני ,ותוצאת
הבדיקה היא שתוחזר על-ידי העותק הנוכחי של הפונקציה; שכן אם הערך נמצא
בילד הימני ,אזי ערכו של  foundיהיה  ,trueוזה הערך שיש להחזיר .מנגד אם
הערך לא נמצא גם בילד הימני ,אזי ערכו של  foundיהיה  ,falseוזה הערך שיש
להחזיר.
124
פונקציה זאת מדגימה לנו מצב שכיח למדי בפונקציות שפועלות על עצים ומחזירות
ערך :בפונקציה אנו בודקים האם השורש מקיים תכונה רצויה כלשהי )למשל מכיל
ערך מבוקש( .במידה והשורש אכן מקיים את התכונה )או לעיתים במידה והשורש
אינו מקיים את התכונה( ,אזי ניתן לעצור את תהליך הבדיקה ולהחזיר ערך מתאים.
אחרת יש לקרוא רקורסיבית עם הילדים בזה אחר זה ,לקלוט את הערך המוחזר
על-ידי כל-אחד מהם ,ולהחזיר לבסוף ערך המייצג את מה שהוחזר מהילדים.
מתכנתים מתחילים שוכחים לעיתים להקפיד על מילוי ההיבטים הללו:
 .Iהחזרת ערך במידה והשורש מקיים )לא מקיים( התכונה הרצויה.
 .IIקליטת הערך המוחזר על-ידי הקריאה הרקורסיבית לכל אחד משני הילדים.
 .IIIהחזרת ערך 'המסכם' את מה שהוחזר מהילדים.
את הפונקציה שהצגנו יכולנו לכתוב גם בצורה קומפקטית יותר:
{ )bool search(int num, const struct Node * root
)if (root == NULL
; ) return( false
)if (root -> _data == num
;) return( true
;))return(search(root-> _left) || search(root-> _right
}
בגרסה הנוכחית אנו מחזירים ישירות )בלי שימוש במשתנה עזר( את תוצאת
הקריאה הרקורסיבית ַלילדים .האם הגרסה הנוכחית פחות יעילה מקודמתה? האם
בגרסה הנוכחית נקרא רקורסיבית גם עם הילד הימני אפילו אם הערך נמצא כבר
בילד השמאלי )ועל כן הקריאה הרקורסיבית עם הילד השמאלי החזירה את הערך
 ?(trueהתשובה היא :לא .מכיוון ששפת  Cמבצעת הערכה מקוצרת של ביטויים
בולאניים ,ומכיוון שאנו מחזירים את תוצאת ה or -בין שתי הקריאות
הרקורסיביות ,אזי אם הקריאה עם הילד השמאלי תחזיר את הערך  ,trueברור
שערכו של הביטוי השלם יהיה  trueואין צורך לבדוק את המרכיב הימני בביטוי
הבולאני שבפקודת ה.return -
 14.5.2חיפוש ערך בעץ חיפוש
כיצד נחפש ערך מבוקש בעץ חיפוש בינארי? האם גם כאן אנו עלולים להידרש
לסרוק את כל העץ )תוך שימוש בפונקציה רקורסיבית(? לא .בעץ חיפוש בינארי
איננו חייבים לסרוק את כל העץ ,אנו יכולים להתקדם על ענף מתאים עד שנאתר
את הערך ,או עד שנגיע לתת-עץ ריק .במקרה השני נוכל להסיק כי הערך אינו מצוי
בעץ .נציג את הפונקציה ואחר נסבירה:
{ )bool search(int num, const struct Node * root
)while (root != NULL
)if (root -> _data == num
;) return( true
)else if (num < root -> _data
; root = root-> _left
else
; root = root-> _right
; ) return( false
}
125
ַבלולאה אנו מתקדמים עם המצביע  rootכל עוד ערכו אינו  .NULLאם הגענו
לצומת המקיים שערכו של  root-> _dataשווה לערך המבוקש ניתן לקטוע את
ביצוע הפונקציה ,ולהחזיר מייד את הערך  .trueאחרת ,על-פי היחס בין הערך
המבוקש לערך המצוי בצומת הנוכחי אנו מקדמים את  rootשמאלה או ימינה .אם
מיצינו את הלולאה בלא שהחזרנו את הערך  trueבשלב כל שהוא ,אות הוא וסימן
שהערך המבוקש לא מצוי בעץ ,ויש ,על-כן ,להחזיר את הערך .false
שימו לב כי תודות לכך ש root -מועבר כפרמטר ערך איו בעיה בכך שבפונ' אנו
משנים את ערכו .הדבר לא יגרום לאובדן העץ בתכנית הראשית .הצורך בconst -
הוא על מנת למנוע מהפונ' לשנות את הערכים שבעץ.
שאלה לא קשה :מהו זמן הריצה של חיפוש ערך בעץ בינארי כללי ,ובעץ חיפוש
בינארי במקרה הגרוע ביותר?
 14.6ספירת מספר הצמתים בעץ בינארי
עתה נרצה לכתוב פונקציה אשר מקבלת מצביע לשורשו של עץ בינארי ,ומחזירה את
מספר הצמתים בעץ .הפונקציה דומה לפונקציית הביקור שראינו בסעיף  ,4גם בה
עלינו לבקר בכל צמתי העץ ,אולם הפעם איננו מתעניינים בערך המצוי בכל צומת,
אלא רק מוסיפים אחד למניה שאנו עורכים .קוד הפונקציה יהיה על-כן:
{ )unsigned int count_nodes(const struct Node * root
; unsigned int lcount, rcount
)if (root == NULL
; ) return( 0
; )lcount = count_nodes( root-> _left
; )rcount = count_nodes(root -> _right
; )return(lcount + rcount + 1
}
הסבר :הפונקציה פשוטה למדי :בעץ ריק יש אפס צמתים .בעץ שאינו ריק נמנה את
מספר הצמתים בילד השמאלי ,נמנה את מספר הצמתים בילד הימני ,ואז מספר
הצמתים בעץ שהצומת בו אנו נמצאים הוא שורשו הוא סכום הצמתים בשני
הילדים ועוד אחד )עבור הצומת המשמש כשורש )תת( העץ(.
יכולנו לכתוב את הפונקציה באופן מעט יותר קצר ,ללא שימוש במשתני העזר
 .lcount, rcountאני מזמין אתכם לחשוב כיצד.
שימו לב שכפי שהזכרנו קודם:
 .Iאנו מקפידים להחזיר ערך עבור מקרה הקצה.
 .IIאנו מקפידים לקלוט את הערך המוחזר על-ידי הקריאות הרקורסיביות
שמזמנת הקריאה הנוכחית.
 .IIIאנו מקפידים להחזיר ערך שמסכם את מה שהוחזר מהקריאות
הרקורסיביות ואת 'תוצאת הבדיקה' שנערכה עבור השורש.
זמן הריצה הוא כמו בכל סריקה של העץ :לינארי במספר הצמתים בעץ.
126
 14.7מציאת עומקו של עץ בינארי
עומקו של עץ בינארי מוגדר באופן הבא:
 .Iעומקו של עץ ריק הוא .-1
 .IIעומקו של עץ שאינו ריק הוא אחד ועוד המקסימום בין עומקם של שני בניו של
השורש.
במילים פשוטות עומקו של עץ בינארי הוא כמספר הצעדים שיש להתקדם מהשורש
אל העלה העמוק ביותר) .עלה בעץ בינארי הוא צומת שאין לו בנים(.
לדוגמה :העץ הבא הוא בעומק ) 3לא ציירנו את הערכים בכל צומת שכן אין להם כל
משמעות עת דנים בעומק העץ .כמו כן מצביעים שערכם  NULLהושמטו מהציור(:
הפונקציה לחישוב עומקו של עץ בינארי תסתמך על פונקציה אשר מחזירה את
הערך הגדול בין שני המספרים הטבעיים שהועברו לה:
{ )int max(int num1, int num2
; ) return( (num1 > num2) ? num1 : num2
}
הגדרת הפונקציה לחישוב עומק:
{ )int depth(const struct Node * root
)if (root == NULL
; ) return( -1
; )int ldepth = depth(root-> _left
; )int rdepth = depth(root-> _right
; ) return( max(ldepth, rdepth) + 1
}
הסבר הפונקציה :הפונקציה היא מימוש ישיר של הגדרת עומק עץ .עומקו של עץ
ריק הוא  ,-1ועומקו של עץ שאינו ריק הוא אחד ועוד המקסימום בין עומקי
הילדים.
זמן הריצה של הפונ' הוא לינארי במספר הצמתים בעץ ,שכן כל צומת אנו פוקדים
פעם יחידה; במילים אחרות ,הפונ' פוקדת כל צומת פעם יחידה ,ועושה עבודה
בשיעור קבוע עבור הצומת.
127
 14.8האם עץ בינארי מקיים שלכל צומת פנימי יש שני בנים
עבור הפונקציה שנכתוב בסעיף זה נגדיר מספר מושגים הקשורים לעת בינארי:
 .Iעלה ) (leafבעץ בינארי הוא צומת שאין לו ילדים כלל.
 .IIצומת בעץ בינארי שאינו עלה נקרא צומת פנימי ).(inner node
)אעיר כי עת העץ כולל רק שורש ,אזי השורש הוא גם עלה ,ולכן שורש ועלה אינם
מושגים מנוגדים זה לזה .לעומת זאת עלה וצומת פנימי הם כן מושגים מנוגדים :כל
צומת בעץ הוא צומת פנימי או עלה ,אך לא שני המושגים גם יחד(.
עתה נציג פונקציה אשר בודקת האם לכל צומת פנימי בעץ בינארי יש שני ילדים .על
הפונקציה להחזיר את הערך  trueעבור שני העצים השמאליים בציור ,ואת הערך
 falseעל העץ הימני )שכן בעץ הימני ,הילד הימני של השורש הוא צומת פנימי עם ילד
יחיד(.
{ )bool non_leaf_2_sons( const struct Node * root
; bool ok
)if (root == NULL
; ) return( true
|| )if ( (root->_left == NULL && root-> _right != NULL
) )(root-> _right == NULL && root->_left != NULL
; ) return( false
; ) ok = non_leaf_2_sons( root->_left
)if (!ok
; )return(false
; ) ok = non_leaf_2_sons( root->_left
; )return(ok
}
בפונקציה אין רבותא ,והיא דומה לפונקציות שכבר כתבנו בעבר :אם הגענו לתת-עץ
ריק ,אזי הוא 'תקין' וניתן להחזיר את הערך  .trueמנגד ,אם הגענו לצומת עם ילד
יחיד )כלומר שילדו השמאלי הוא  NULLוילדו הימני אינו  ,NULLאו להפך( ,אזי יש
להחזיר את הערך  .falseבכל מקרה אחר )לצומת שני ילדים ריקים ,או שני ילדים
שאינם ריקים( אנו קוראים רקורסיבית ראשית עבור הילד השמאלי .אם בילד זה
התגלתה 'תקלה' יש להחזיר  .falseלבסוף ,אם הילד השמאלי אינו בעייתי אזי יש
להחזיר את תוצאת הבדיקה של הילד הימני.
128
זמן הריצה של הפונ' הינו לינארי במספר הצמתים בעץ ,שכן עבור כל צומת הפונ'
נקראת פעם יחידה )וכן היא נקראת גם עבור הילדים הריקים של עלי העץ ,כלומר
המצביעים שערכם  NULLבעלים ,אולם כאלה יש לכל היותר כמספר צמתי העץ(
 14.9האם עץ בינארי מקיים שעבור כל צומת סכום הערכים
בבת-העץ השמאלי קטן או שווה מסכום הערכים בתת-העץ
הימני
עתה נרצה להשלים את המשימה הבאה :נתון עץ בינארי שאינו עץ חיפוש ,ואין זה
מעניינו עתה כיצד העץ נבנה .הפונ' שנכתוב מקבלת מצביע לשורשו של עץ זה ,ועליה
להחזיר האם שורש העץ השלם ,וכל צומת אחר בעץ מקיימים שסכום הערכים בתת
העץ השמאלי של הצומת קטן או שווה מסכום הערכים בתת-העץ הימני.
לדוגמה :עבור העץ הבא ,הפונ' אמורה להחזיר את הערך :true
1
21
7
3
0
4
2
4
נסביר :לדוגמה ,תת-העץ השמאלי של שלוש הוא ריק ,ולכן סכומו אפס ,וסכום זה
קטן משתיים ,שהינו סכמו של תת-העץ הימני של שלוש .עבור הצמות של  ,7סכומו
של תת-העץ השמאלי הוא חמש ,בעוד סכומו של תת-העץ הימני הוא שמונה .וכך
הלאה .לו ,למשל ה 4-השמאלי היה מוחלף ב ,5-אזי עבור הצומת של אפס סכום
הערכים בתת-העץ השמאלי היה גדול מסכום הערכים בתת-העץ הימני ,ולכן על
הפונ' היה להחזיר את הערך .false
זימון הפונ' יהיה:
))if (sum_left_leq_sum_right(root
; "cout << "sum left <= sum right\n
else
; "cout << "sum left > sum right for some subtree\n
נפנה עתה להגדרת הפונ' ,ונציג שתי דרכים שונות להשלמת המשימה :הדרך
הראשונה יותר פשוטה ופחות יעילה ,הדרך השניה פחות פשוטה ויותר יעילה .נפתח
בדרך הראשונה.
רעיון האלגוריתם ַבפתרון הראשון הוא לסרוק את העץ )בסריקה תחילית( .עבור כל
צומת אליו מגיעים לסכום את תת-העץ השמאלי של הצומת ,לסכום את תת-העץ
129
הימני של הצומת )וזאת על-ידי זימון פונ' אחרת ,כזו היודעת לסכום עץ( .אם סכומו
של תת-העץ השמאלי גדול מזה של תת-העץ הימני אזי גילינו הפרה של הדרישה,
ועל כן נחזיר מייד את הערך  .falseאחרת ,נמשיך בבדיקה עם תת העץ השמאלי )ע"י
קריאה רקורסיבית לפונ' עם תת העץ השמאלי( ,ואם הוא יתגלה כתקין אזי נמשיך
בבדיקה גם עם תת-העץ הימני .אם גם הוא יתגלה כתקין אזי נחזיר את הרך .true
כמובן שאם אחד מתתי-העצים יתגלה כלא תקין אזי נחזיר את הערך .false
כלומר עבור הפתרון אנו זקוקים לפונ' עזר אשר סוכמת עץ .זוהי פונ' פשוטה מאוד
ונציג אותה עתה:
)int sum_tree(const struct Node *root
{
)if (root == NULL
; return 0
return(sum_tree(root -> _left) +
sum_tree(root -> _right) +
; )root -> _data
}
הפונ' דומה מאוד לפונ' שראינו בעבר ,ועל כן לא נסביר אותה .נשתמש בה לשם
השלמת המשימה עימה אנו מתמודדים בסעיף זה .נציג עתה את הגרסה הראשונה
של הפונ'  ,sum_left_leq_sum_rightכלומר הפונ' המרכזית בסעיף זה.
)bool sum_left_leq_sum_right(const struct Node *root
{
; int suml, sumr
; bool ok
)if (root == NULL
; return true
)// (1
)// (2
; )suml = sum_tree(root -> _left
; )sumr = sum_tree(root -> _right
)if (suml > sumr
; return false
; )ok = sum_left_leq_sum_right(root -> _left
)if (!ok
)// (3
; return false
; )ok = sum_left_leq_sum_right(root -> _right
; return ok
}
נדגים את התנהלות הפונ' על העץ שמופיע בדוגמה מעל :עת הפונ' נקראת לראשונה,
עבור שורש העץ ,התנאי  root==NULLאינו מתקיים ,ולכן לא ניתן להחזיר
מיידית את הערך  .trueעל כן אנו מתקדמים להשמה המסומנת ב (1) -וקוראים
לפונ' אשר תסכום את תת-העץ השמאלי .בדוגמה שלנו יוחזר הערך  ,20אשר יוכנס
למשתנה  .sumlבאופן דומה ,בהשמה ) (2אנו סוכמים את ת-העץ הימני של הצומת
של  ,1וסכומו כמובן  .21על כן התנאי  suml>sumrאינו מתקיים ,ואין להחזיר
 .falseבזאת גמרנו לבדוק את השורש )אשר נבדק תחילה ,ולכן אמרנו קודם
שהפונ' סורקת את העץ בביקור תחילי( .עת הפונ' שלנו קוראת רקורסיבית עם ת-
העץ השמאלי ,כלומר עם המצביע לצומת של to make a long story .7
 shortקריאה זאת תייצר עוד קריאות רקורסיביות רבות )תריסר ,ליתר דיוק.
130
בדקו( ובסופו של גבר תחזיר את הערך trueאשר ייכנס למשתנה  .okהתנאי )(3
לא יתקיים ,ועל כן לא יוחזר הערך  .falseעתה תזמן הקריאה הרקורסיבית
הראשונה קריאה רקורסיבית לפונ'  sum_left_leq_sum_rightעם תת-העץ
הימני ,כלומר עם המצביע לצומת של  .21גם קריאה זאת תחזיר את הערך true
)אחרי שהיא ,מצדה ,תזמן עוד שתי קריאות רקורסיביות( .ולכן הפונ' שלנו תחזיר
את הערך  ,trueכראוי.
נשאל עתה את עצמנו מהו זמן הריצה של הפונ' שכתבנו? קל לראות שזמן הריצה של
 sum_treeהוא לינארי במספר הצמתים בעץ )בדומה ל.(non_leaf_2_sons -
הפונ' שלנו ,עבור כל צומת קוראת ל sum_tree -עם שני ילדיו של הצומת .נניח
לרגע שהעץ שלנו 'התנוון' לכדי רשימה מקושרת )בת  nאיברים( ,ולכן הוא נראה
באופן
הבא:
1
2
...
n
עבור שורש העץ נקרא ל sum_tree -עם הילד השמאלי ,נעשה עבודה בשיעור
קבוע ,וכן נקרא ל sum_tree -עם הילד הימני ,ועל כך נשלם סד"ג של n-1
צעדים .נגלה שבינתיים הכל בסדר ,ולכן נקרא רקורסיבית לפונ' עם ילדו השמאלי,
הריק ,של הצומת של  ,1ואח"כ עם הצומת של  .2כאן שוב נקרא פעמיים ל-
 ,sum_treeונשלם על כך  n-2צעדים .שוב הכל יהיה בסדר ,ולכן נקרא לפונ' עם
ילדו השמאלי של  ,2ומה שחשוב יותר ,עם ילדו הימני של  ,2וכך הלאה .כמות
העבודה שאנו עושים ,בכל הקריאות ל sum_tree -גם יחיד היא לפיכך:
 (n-1)+(n-2)+(n-3)+…+1כלומר סד"ג של  n2צעדים.
כמות העבודה הרבה יחסית נגרמת מכך שעבור כל צומת של העץ אנו קוראים
רקורסיבית לפונ'  sum_treeעם שני תתי-העצים של הצומת .זאת נרצה לחסוך .על
כן עתה נכתוב גרסה שניה ,יעילה יותר ,של הפונ' .רעיון הגרסה השניה הוא שכל
צומת יחזיר לנו באמצעות ערך ההחזרה האם הוא תקין או לא )כמו בגרסה
הראשונה( ,אולם מעבר לכך ,באמצעות פרמטר הפניה ,יחזיר לנו הצומת גם מה
סכום הערכים בתת-העץ שלו .בערך זה נשתמש כדי לבדוק האם השורש בו אנו
נמצאים בכל קריאה רקורסיבית הינו תקין או לא.
הזימון לפונ' הפעם יהיה:
))if (sum_left_leq_sum_right(root, x
; "cout << "sum left <= sum right\n
else
; "cout << "sum left > sum right for some subtree\n
131
כלומר ,הפונ' מקבלת גם משתנה )בשם  (xלתוכו הפונ' תכניס את סכום הערכים בעץ
השלם .בתכנית הראשית ,ערכו של  xלא יעניין אותנו .נשתמש בפרמטר זה רק
בקריאות הרקוסיביות.
נציג עתה את הפונ' ,ואחר נדון בה:
bool sum_left_leq_sum_right(const struct Node *root,
)int &sum
{
; int suml, sumr
; bool ok
{ )if (root == NULL
; sum = 0
; return true
}
; )ok = sum_left_leq_sum_right(root-> _left, suml
)if (!ok
; return false
; )ok = sum_left_leq_sum_right(root-> _right, sumr
)if (!ok
; return false
; sum = suml + sumr + root -> _data
; ) return( (suml <= sumr) ? true : false
}
נסביר :תנאי העצירה של הרקורסיה הוא עת אנו מגיעים לתת-עץ ריק .במצב זה אנו
מחזירים שני דברים :באמצעות פרמטר ההפניה אנו מחזירים שסכום הערכים
בתת-העץ 'שלנו' הוא אפס ,ובאמצעות ערך ההחזרה אנו מחזירים שתת-העץ 'שלנו'
תקין.
במקרה הכללי )עת לא הגענו לתת-עץ ריק( :אנו מזמנים את הפונ' עם תת-נעץ
השמאלי ,ומעבירים לקריאה גם את המשתנה  .sumlהקריאה עם תת-העץ השמאלי
תחזיר לנו שני דברים :האחד האם תת-העץ השמאלי תקין ,והשני ,באמצעות
 ,sumlאת סכומו של תת-העץ השמאלי .כמובן שאם תת-העץ השמאלי אינו תקין,
אין לנו מנוס אלא להחזיר  .falseאחרת ,נקרא רקורסיבית באופן דומה ,עם תת-
העץ הימני .בהנחה שגם קריאה זו החזירה לנו  ,trueאנו פונים לטפל בערך שעלינו
להחזיר למי שקרא לנו :ראשית ,לפרמטר ההפניה  sumנכניס את סכום הערכים
בתת-העץ שלנו ,שהינו  ,suml + sumr + root -> _dataושנית נחזיר את
הערך הבולאני על-פי היחס בין  sumlל) sumr -כמובן שאם אנו מחזירים את הערך
 falseאזי למעשה אין חשיבות לסכום הערכים בתת-העץ שלנו(.
ומהו זמן הריצה של הפונ' בגרסתה נוכחית?
עתה אנו מבצעים סריקה סופית רגילה של העץ :עבור כל צומת ,מזמנים את הפונ'
עם ילדו השמאלי ,מזמנים את הפונ' עבור ילדו הימני )כמובן לא שוכחים להתעניין
בערך שמחזירות שתי הקירות הרקורסיביות( ,ולבסוף 'מטפלים' בשורש עצמו:
מעדכנים את  sumומחזירים את הערך הרצוי .לכן זמן הריצה של הפונ' הוא לינארי
במספר הצמתים בעץ .זהו כמובן שיפור ניכר יחסית לגרסה שראינו קודם ,ואשר זמן
ריצתה עלול היה להתדרדר לכדי .n2
132
השיפור הושג ע"י שימוש בפרמטר הפניה אשר מאפשר לנו להחזיר מכל קריאה
רקורסיבית שני נתונים :גם האם תת העץ הנוכחי תקין ,וגם את סכומו.
 14.10שחרור עץ בינארי
עת איננו זקוקים יותר לעץ הבינארי עלינו לשחררו .תהליך השחרור מחייב
סריקה של העץ .הפעם נשתמש בסריקה סופית :עבור כל תת-עץ שאינו ריק,
ראשית נשחרר את הילד השמאלי ,שנית נשחרר את הילד הימני ,ובסוף נשחרר
את השורש.
הקוד:
)void free_tree(struct Node *root
{
)if (root != NULL
{
; ) free_tree(root -> _left
; ) free_tree(root -> _right
; delete root
}
}
זימון הפונ' )מהתכנית הראשית(free_tree(root); :
הסבר הפונ' :אם הגענו לתת עץ שאינו ריק אזי נשחחר את תת -העץ השמאלי שלו,
וזאת ע"י קריאה רקורסיבית עם הילד השמאלי ,אחר נשחרר את תת-העץ הימני של
השורש )ע"י קריאה רקוסיבית עם הילד הימני( ,ולבסוף נשחרר את השורש.
שאלה :האם יש להעביר את הפרמטר לפונ' כפרמטר הפניה )כלור עם &(?
תשובה :זה לא הכרחי .על מנת לשחרר את הזיכרון שהוקצה די שיש לנו מצביע
לשטח זיכרון זה ,כפי שמעמיד לרשותו פרמטר ערך.
נשים לב שאחרי הקריאה לפונ' ערכו של המשתנה  rootשל התכנית הראשית נותר
בעינו ,כלומר הוא מצביע לאותו מקום אליו הוא הצביע קודם לכן ,אולם שטח
הזיכרון הזה כבר לא שייך לנו ,שכן שחררנו אותו ,ובזאת העמדנו אותו שוב לרשות
מערכת ההפעלה ,כך שהיא תוכל להקצותו שוב בפעולות הקצאה עתידיות.
133
 14.11מחיקת צומת מעץ בינארי
בסעיף זה ברצוננו לכתוב פונקציה אשר מקבלת מצביע לשורשו של עץ חיפוש
בינארי ,וערך שלם .הפונקציה מוחקת מהעץ את הצומת בו מצוי הערך .במידה
והערך אינו מצוי בעץ כלל ,לא ַתעשה הפונקציה דבר; במידה והערך מצוי בעץ מספר
תמחק הפונקציה מופע יחיד שלו.
פעמים ְ
 14.11.1תיאור האלגוריתם
נביט בעץ החיפוש הבא:
17
ב'
א'
13
27
ג'
43
ה'
ד'
3
16
ו'
14
האותיות שלצד המצביעים שבציור תשמשנה אותנו כדי לסמן )לתייג( את
המצביעים השונים בנוחות.
נבחן מקרים שונים של מחיקה:
 .Iמחיקת עלה :נניח שיש למחוק את הצומת המכיל את הערך  ,43או את זה
המכיל את הערך  .14זה מקרה פשוט ,שכן יש למחוק צומת שהינו עלה בעץ .כל
שיש לעשות הוא (1) :לשמור באמצעות מצביע עזר )נניח  (tempמצביע לצומת
שאותו עלינו למחוק (2) .להכניס את הערך  NULLלמצביע אשר מורה על הצומת
)המצביע ג' במקרה של  ,43או המצביע ו' במקרה של  (3) .(14לשחרר את הצומת
עליו מצביע .temp
 .IIמחיקת צומת בעל ילד יחיד :עתה נניח כי עלינו למחוק את הצומת המכיל את
הערך  ,27או את זה המכיל את  .16גם זה מקרה פשוט למדי .הפעולות הנדרשות
הפנה את מצביע העזר  tempלהורות על הצומת שיש למחוק.
במקרה זה הןְ (1) :
) (2הפנה את המצביע המורה על הצומת שיש למחוק להצביע על ילדו הלא ריק
נפנֵה את המצביע א'
של הצומת שיש למחוקַ ) .במקרה של הצומת של ְ 27
]המצביע על הצומת של  [27להצביע כמו ג' ]ילדו הלא ריק של הצומת של .[27
במקרה של  ,16נפנה את המצביע ד' להצביע כמו המצביע ו' ,כלומר ד' יעבור
להצביע על הצומת של  (3) .(14שחרר את הצומת עליו מצביע .temp
 .IIIמחיקת צומת בעל שני ילדים :זהו המקרה המורכב .כדי לטפל בו עלינו
להגדיר את המושג :העוקב ַבעץ של ערך  .v1העוקב בעץ של ערך  ,v1הוא הערך
הקטן ביותר  v2המקיים ש .v2 >= v1 :לדוגמה :בעץ שהצגנו ,העוקב של  13הוא
 ,14והעוקב של  17הוא  .27שימו לב כי ְלערך המצוי ְבצומת עם בן ימני תמיד
ימצא בתת-העץ עליו מצביע בנו הימני של הצומת
יהיה עוקב בעץ ,והעוקב ֵ
134
המכיל את  .v1נשים לב כי העוקב של ערך  v1בעץ מתקבל על-ידי ירידה לבנו
הימני של הצומת של  ,v1ואחר כל-עוד ניתן :ירידה לבן השמאלי של הצומת
הנוכחי .לדוגמה :לעוקב של  13נגיע על-ידי ירידה לבנו הימני של הצומת בו מצוי
) 13הצומת של  ,(16ואחר כך ירידה שמאלה לבנו השמאלי של  .16באופן דומה
לעוקב של  17נגיע על-ידי שנרד לבנו הימני של הצומת של ) 17כלומר לצומת של
 ,(27ומשם לא ניתן לרדת שמאלה ,לכן ַבצומת של  27נעצור .שימו לב כי לוּ ל27 -
היה בן שמאלי שהכיל את  ,23ולאחרון היה בן שמאלי שהכיל את  ,21אזי היינו
יורדים שמאלה לשני בנים אלה ,וכך מאתרים את העוקב של  17בעץ .עתה נשים
לב כי העוקב של ַ v1בעץ מצוי ְבצומת שאין לו שני בנים :העוקב של  v1בעץ מצוי
בעלה ,או בצומת שיש לו רק בן ימני )לוּ היה לצומת גם בן שמאלי היינו
מתקדמים ממנו לבנו השמאלי(.
עתה ,אחרי שהגדרנו את העוקב של ערך  v1בעץ ,ואחרי שלמדנו כיצד מגיעים
מהצומת בו מצוי  v1לצומת בו מצוי העוקב שלו ,נוכל לתאר את תהליך
המחיקה של ערך מצומת לו שני בנים:
הפנה את
 .Iאתר את הצומת בו מצוי העוקב בעץ של הערך אותו יש למחוקְ .
מצביע העזר  tempלהצביע על צומת זה.
העתק את הערך ַ temp-> _dataלצומת בו מצוי הערך שיש למחוק.
.II
.III
מחק מהעץ את הצומת עליו מצביע  .tempמכיוון שצומת זה הוא
עלה ,או מכיל בן ימני בלבד ,אזי המחיקה תעשה על פי סעיף א' או סעיף ב'
באלגוריתם המחיקה.
לדוגמה :כדי למחוק את  17מהעץ המלווה אותנו) :א( נאתר את הצומת המכיל
את העוקב של  17בעץ .כפי שראינו זהו הצומת של ) .27ב( נעתיק את הערך 27
לצומת של  ,17כלומר לשורש העץ) .ג( נמחק את הצומת של ) 27כלומר הצומת
עליו מורה המצביע א'( .מכיוון שמדובר בצומת עם בן יחיד נבצע את המחיקה
על פי המתואר בסעיף ב' באלגוריתם .דוגמה שניה :כדי למחוק את  13מהעץ:
)א( נאתר את הצומת המכיל את העוקב ל 13 -בעץ ,כלומר את הצומת של ) .14ב(
נעתיק את הערך  14לצומת של ) .13ג( נמחק את הצומת של  14מהעץ .מכיוון
שמדובר בעלה אזי המחיקה תתבצע על-פי סעיף א' באלגוריתם.
עד כאן תיארנו את אלגוריתם המחיקה .עתה נציג את מימושו.
 14.11.2המימוש
כפי שנראה ,כדי שהפונקציה למחיקת ערך מעץ לא תהא מסורבלת ,יהיה עלינו
להעביר לה מצביע ַלמצביע אשר מורה על שורש העץ .נבחן ראשית מדוע העברת
המצביע לשורש העץ כפרמטר משתנה תחייב אותנו לכתוב פונקציה מסורבלת )ועל
כן לא ננקוט בגישה זאת ,אלא נעביר מצביע למצביע לשורש העץ( .לכן עתה נניח כי
אנו מעבירים לפונקציה את המצביע לשורש העץ כפרמטר משתנה ,ונראה כיצד
עלינו לכתוב את פונקצית המחיקה .מחיקת צומת מעץ דומה ,במובנים רבים,
למחיקת צומת מרשימה משורשרת ,ועל כן נרצה להתקדם עם שני מצביעים .עת
המצביע הקדמי יצביע על הצומת שמכיל את הערך הרצוי ,נַפנה את המצביע לבנו
המתאים של הצומת עליו מצביע המצביע האחורי ַל ֵבן המתאים של הצומת שיש
למחוק .אם כל כך טוב ,אז מה כל-כך רע? שלכל צומת יתכנו שני בנים ,ולא רק אחד
כמו ברשימה משורשרת .על כן יש לנו ארבע אפשרויות שונות של שינויים :אנו
עשויים להידרש להפנות את בנו השמאלי או הימני של הצומת עליו מצביע המצביע
האחורי ,ואת המצביע שיש להפנות אנו עשויים להידרש להפנות לבנו השמאלי או
הימני של הצומת שיש למחוק.
נדגים את הקוד:
{ )void delete_node(int val, struct Node * &root
135
struct Node *wanted_node = root,
*father_of_wanted = NULL ;
 הוא: ברשימות משורשרותfront - הוא האנלוגי לwanted_node המצביע
father_of_wanted  המצביע.שיתקדם עד לצומת שמכיל את הערך שיש למחוק
 ברשימותrear -כן האנלוגי ל- ויהיה על,wanted_node יצביע על אביו של
.משורשרות
: ואביו,הלולאה הבאה תתקדם עד לאיתור הצומת הרצוי
while (wanted_node != NULL) {
// find the node and
// its father
if (wanted_node -> _data == val) break ;
if (wanted_node -> _data > val) {
father_of_wanted = wanted_node ;
wanted_node = wanted_node -> _left ;
}
else {
father_of_wanted = wanted_node ;
wanted_node = wanted_node -> _right ;
}
}
 מצביע על הצומתwanted_node  אזי, אם הערך המבוקש מצוי בעץ,בשלב זה
 אם הערך, מנגד. מצביע על אביו של הצומתfather_of_wanted ;שכולל אותו
: לפיכך עתה נבדוק.NULL  הואwanted_node  אזי ערכו של,אינו מצוי בעץ
if (wanted_node == NULL) return ;
// val not found
 המקרה הפשוט ביותר הינו.עתה נתקדם לטיפול במצב בו הערך המבוקש מצוי בעץ
 מקרה זה עלינו לחלק למספר.זה שבו הצומת בו מצוי הערך המבוקש הינו עלה
 )ב( הצומת בו מצוי הערך, )א( הצומת בו מצוי הערך הוא שורש העץ:מצבים שונים
 נציג. )ג( הצומת בו מצוי הערך הוא בנו הימני של אביו,הוא בנו השמאלי של אביו
:את הקוד
if (wanted_node->_left == NULL && // del a leaf
wanted_node-> _right == NULL ) {
if (wanted_node == root) {
root = NULL ;
delete wanted_node ;
}
else { // the wanted val is not in root
if (father_of_wanted -> _left == wanted_node)
father_of_wanted - >_left = NULL ;
else // father_of_wanted -> son == wanted_node
father_of_wanted -> _right = NULL ;
delete wanted_node ;
}
 ַלצומת בו שוכן הערך המבוקש יש רק בן:נפנה עתה לטפל במקרה הפשוט השני
: והוא עצמו בן ימני או שמאלי של אביו,(יחיד )ימני או שמאלי
else
// wanted val is not in leaf
136
if (wanted_node -> _left == NULL || // but has 1 son
{ )wanted_node -> _right == NULL
if (wanted_node -> _left != NULL) // has a left son
// and wanted_node is left son of its father
)if (father_of_wanted -> _left == wanted_node
= father_of_wanted -> _left
;wanted_node->_left
else
// wanted is a right son of its dad
= father_of_wanted -> _right
;wanted_node->_left
else // wanted has only right son
// and wanted_node is a left son of its dad
{ )if (father_of_wanted -> _left == wanted_node
= father_of_wanted -> _left
;wanted_node-> _right
else
// wanted is a right son of its dad
= father_of_wanted -> _right
;wanted_node-> _right
}
else // wanted_node has two sons
בשלב זה ,כשאנו כבר יגעים למדי ,הגענו למקרה המורכב .אשאיר לכם להשלימו
כתרגיל.
כפי שאמרנו בתחילת הסעיף ,הדרך האלגנטית יותר לכתוב את פונקצית המחיקה
היא להעביר לה מצביע למצביע .מדוע גישה זאת מקצרת את הפונקציה? כפי
שראינו עם רשימות משורשרות ,עת אנו מטפלים במצביע למצביע אין לנו צורך בזוג
מצביעים ,די לנו באחד; קל וחומר שאיננו מסתבכים עם עיסוק בשאלה האם מדובר
בבן שמאלי או בבן ימני.
בגרסה הנוכחית של פונקצית המחיקה ,הפרמטר השני מוגדר כ:
 struct Node **root_ptrכלומר מצביע למצביע .הקריאה לפונקציה תהא
לפיכך delete_node(num, &root); :עבור משתנה  rootשל התכנית
הראשית ,אשר הוגדר כ , struct Node *root; :ושעתה מצביע לשורש העץ.
מסיבה זאת בגוף הפונקציה ,עת אנו רוצים להשתמש במצביע  rootאנו צריכים
לכתוב  ,*root_ptrשכן  root_ptrמצביע ל ,root -ועל-כן  *root_ptrהוא
האובייקט עליו  root_ptrמצביע ,כלומר  .rootכדי לפנות לשדה הdata -
ַבקופסה עליה מצביע  rootעלינו לכתוב ,(*root_ptr)-> _data :שכן כאמור
 *root_ptrמביא אותנו למצביע  ,rootולכן  (*root_ptr)-> _dataמביא
אותנו למרכיב ה data -בקופסה עליה מצביע  .rootבהמשך נראה זאת גם
בציור.
כיצד תיערך קריאה רקורסיבית לפונקציה )מתוך הפונקציה(? ַלקריאה הרקורסיבית
נרצה להעביר מצביע למצביע לאחד מבניו של שורש תת-העץ הנוכחי .כאמור
 ,(*root_ptr)->_leftמביא אותנו לבנו השמאלי של הצומת עליו מצביע
המצביע ,שעליו מצביע  .root_ptrלכן הביטוי:
)  &( (*root_ptr)-> _dataמחזיר לנו מצביע לבנו השמאלי של הצומת עליו
מצביע המצביע ,שעליו מצביע .root_ptr
נציג עתה את קוד הפונקציה בגרסתו הרצויה:
137
void delete_node(int num, struct Node **root_ptr)
{
if (*root_ptr == NULL)
return ;
// if root_ptr points to the pointer
// that points to the wanted node
if ((*root_ptr) -> data == num)
{
// if no _left then move
// pointer so it points to
// _right
if ((*root_ptr) -> _left ==NULL)
{
struct Node *temp = *root_ptr ;
(*root_ptr) = (*root_ptr) -> _right ;
delete temp ;
}
else if ((*root_ptr) -> _right ==NULL)
{
struct Node *temp = *root_ptr ;
(*root_ptr) = (*root_ptr) -> _left ;
delete temp ;
}
else
// wanted node has 2 sons
{
struct Node *temp ;
struct Node **succ_ptr =
find_succ(root_ptr);
(*root_ptr)-> _data =
(*succ_ptr)-> _data;
temp = *succ_ptr ;
(*succ_ptr) = temp -> _right ;
delete temp;
}
}
else if (num < (*root_ptr) -> data)
delete_node(num,
&((*root_ptr) -> _left));
else
delete_node(num, &((*root_ptr) -> _right));
}
//============================================
struct Node **find_succ( struct Node **root_ptr)
{
struct Node **temp =
&( (*root_ptr) -> _right ) ;
while ((*temp) -> _left != NULL)
temp = & ((*temp) -> _left ) ;
return temp ;
138
}
נציג דוגמת הרצה של הפונקציה על העץ המלווה אותנו .נניח כי יש למחוק את הערך
 13מהעץ .מצב הזיכרון טרם הקריאה לפונקציה הינו:
17
א'
ב'
13
27
ג'
43
=root
ה'
ד'
3
16
ו'
14
עם הקריאה לפונקציה נוספת על המחסנית רשומת
ההפעלה של הפונקציה .המצביע  root_ptrמצביע על
המצביע  rootשל התכנית הראשית .נציג את רשומת
ההפעלה:
=root_ptr
num=13
=temp
return=main
שימו לב כי בעוד  root_ptrהוא מצביע למצביע ,כלומר יודע להצביע על מצביע,
 tempהוא מצביע 'פשוט' אשר יודע להצביע על קופסה )לעיתים נשתמש בביטוי
'מצביע מדרגה ראשונה' ,בניגוד למצביע למצביע שהינו מצביע מדרגה שניה(.
139
של
הראשון
העותק
התנאי:
להתבצע.
מתחיל
delete_node
)(*root_ptr == NULLאינו מתקיים ,שכן  root_ptrמצביע על מצביע שערכו
אינו  .NULLגם התנאי )((*root_ptr)-> _data == numאינו מתקיים ,שכן
בשדה ה data -בקופסה עליה מצביע המצביע  *root_ptrיש את הערך ) 17ולא
את הערך  .(13אנו מתקדמים ,לפיכך ,לתנאי (num < (*root_ptr)-> _data
) אשר מתקיים ,דבר שגורם לפונקציה לקרוא רקורסיבית עם המצביע
) . &((*root_ptr)->_leftנבדוק מיהו מצביע זה *root_ptr :הוא המצביע
לשורש העץ ,על כן ) ((*root_ptr)->_leftהוא הבן השמאלי של שורש העץ,
כלומר המצביע ב' .הביטוי &(…) :מחזיר לנו מצביע למצביע ב' ,ומצביע זה הוא
שמועבר )כפרמטר ערך( לקריאה הרקורסיבית השניה .כתובת החזרה של הקריאה
הרקורסיבית השניה היא סיומה של הקריאה הראשונה ל . delete_node -נציג
את מצב הזיכרון:
17
א'
ב'
13
27
ג'
43
=root
ה'
ד'
=root_ptr
num=13
=temp
return=main
3
16
ו'
14
=root_ptr
num=13
=temp
return=delete
העותק השני של  delete_nodeמתחיל להתבצע .גם הפעם התנאי:
>(*root_ptr)-
) (*root_ptr ==NULLאינו מתקיים .לעומתו התנאי:
)_data == numמתקיים ,שכן  *root_ptrהוא הפעם המצביע ב' ,ולכן
 (*root_ptr)-> _dataהוא הערך המצוי בקופסה עליה מורה המצביע ב',
כלומר הערך  .13לכן אנו נכנסים לגוש בו מתבצעת המחיקה .התנאי:
 (*root_ptr)->_left == NULLאינו מתקיים ,שכן ערכו של הבן השמאלי של
הקופסה עליה מצביע המצביע ב' )שהינו ,כזכור (*root_ptr ,אינו  .NULLבאופן
דומה גם התנאי(*root_ptr)-> _right == NULL :אינו מתקיים ,ולכן אנו
מגיעים ל else -האחרון ,המטפל במקרה בו לצומת שיש למחוק יש שני בנים .עתה
ַמנים את הפונקציה  find_succאשר תחזיר מצביע ,למצביע אשר מורה על
אנו מז ְ
הצומת שמכיל את העוקב של  13בעץ .אנו מעבירים לפונקציה  find_succעותק
של ) root_ptrהפונקציה מקבלת פרמטר ערך מאותו טיפוס כמו :root_ptr
מצביע למצביע( .נציג את מצב הזיכרון בעקבות זימון .find_succ
140
17
א'
=root
ב'
13
27
ג'
ד'
43
ה'
=root_ptr
num=13
=temp
return=main
3
16
=root_ptr
num=13
=temp
return=delete
ו'
14
=root_ptr
=temp
הפונקציה  find_succמתחילה להתבצע .למשתנה ) tempשהינו מטיפוס מצביע
למצביע( מוכנס הערך . &( (*root_ptr)-> _right ) :נבחן ביטוי זה:
 *root_ptrהוא המצביע ב'; לכן  (*root_ptr)-> _rightהוא המצביע ד'
לכן:
)בנו הימני של הצומת עליו מצביע המצביע ,שעליו מצביע ; (root_ptr
)  &( (*root_ptr)-> _rightהוא מצביע למצביע ד'; וזה ,כאמור ,הערך
שמוכנס ל .temp -בכך פנינו לבנו הימני של הצומת שברצוננו למחוק .נציג את מצב
רשומת ההפעלה של ) find_succהתעלמו מהמלבן המקווקו ומהמצביע שבו(:
17
א'
ב'
13
27
ג'
43
=root
ה'
ד'
=root_ptr
num=13
=temp
return=main
3
16
ו'
14
=root_ptr
num=13
=temp
return=delete
=root_ptr
=temp
עתה אנו פונים לולאה .התנאי( (*temp)->_left != :
)  NULLמתקיים .שכן  *tempהוא המצביע ד' ,ולכן
 (*temp)->_leftהוא המצביע ו' ,שערכו אינו  .NULLלכן אנו
ומפנים את  tempלהצביע על (*temp)-
נכנסים לגוף הלולאהְ ,
 ,>_leftכלומר על המצביע ו' .רשומת ההפעלה של
 find_succנראית באופן הבא ) ִב ְמקום כפי שהיא צוירה
קודם:
)לא ציירנו שוב את  root_ptrשכן ערכו נותר כמו קודם(.
141
=root_ptr
=temp
לקראת סיבוב נוסף בלולאה שוב נבדק התנאי( (*temp)->_left != NULL) :
אולם הפעם הוא כבר אינו מתקיים ,שכן בנו השמאלי של הצומת עליו מצביע
 ,*tempכלומר בנו השמאלי של הצומת המצביע ו' ,מכיל את הערך .NULL
הפונקציה אינה נכנסת ללולאה ,והיא מחזירה את ערכו של  ,tempכלומר מצביע
למצביע ו'.
הערך המוחזר על-ידי
 .delete_nodeמצב הזיכרון הוא איפה:
find_succ
למשתנה
מוכנס
succ_ptr
של
17
א'
=root
ב'
13
27
ג'
ד'
43
ה'
=root_ptr
num=13
=temp
return=main
3
16
=root_ptr
num=13
=temp
=succ_ptr
return=delete
ו'
14
העותק השני של  delete_nodeמוצא מקיפאונו ,וממשיך להתבצע .הפקודה:
 (*root_ptr)-> _data = (*succ_ptr)-> _dataגורמת לאפקט הבא:
 *root_ptrהוא המצביע ב' ,לכן  (*root_ptr)-> _dataהוא שדה הdata -
בקופסה עליה מורה המצביע ב'; לשדה זה אנו מכניסים את הערך של:
 ; (*succ_ptr)-> _dataמכיוון ש *succ_ptr -הוא המצביע ו' ,אזי
 (*succ_ptr)-> _dataהוא הערך  .14לסיכומון :הערך  13בקופסה עליה
מצביע ב' מוחלף בערך  .14הפקודה אחר-כך מפנה את המצביע ) ,tempשיודע
להצביע על קופסות ,ולא על מצביעים( ,להצביע כמו  ,*succ_ptrכלומר כמו
המצביע ו' .מצב הזיכרון הוא עתה:
17
א'
ב'
14
27
ג'
43
=root
ה'
ד'
=root_ptr
num=13
=temp
return=main
3
16
ו'
14
=root_ptr
num=13
=temp
=succ_ptr
return=delete
>temp-
הפקודה אחר-כך מפנה את המצביע ) (*succ_ptrלהצביע כמו
 ._rightערכו של  temp-> _rightהוא  ,NULLועל-כן גם המצביע ,*succ_ptr
כלומר המצביע ו' ,מקבל ערך זה .הפקודה האחרונה בגוש משחררת את הזיכרון
142
עליו מצביע  ,tempכלומר את הקופסה בה שכן ) 14לפני העתקתו גם לצומת של .(13
בכך מסתיים העותק השני של  .delete_nodeעותק זה חוזר לנקודת הסיום של
העותק הראשון ,ולכן בכך הסתיים תהליך המחיקה.
 14.12כמה זה עולה?
עץ חיפוש בינארי הינו מבנה נתונים חלופי למערך ממוין ,ועל כן מתאים להשוות את
עלות הפעולות השונות על שני ה'-מתחרים' הללו .הפעולות בהן נתעניין הן הוספת
נתון ,מחיקת נתון ,וחיפוש נתון .נשווה את עלותן היחסית של הפעולות השונות הן
במקרה הממוצע והן במקרה הגרוע )כלומר במקרה בו הנתונים מסודרים כך שעלינו
לעשות הכי הרבה עבודה כדי להשלים את הפעולה הרצויה(.
 14.12.1הוספת נתון
נניח שברצוננו להוסיף נתון שערכו  vלמערך ממוין הכולל  nנתונים .לשם כך,
ראשית ,יהיה עלינו לאתר את המקום בו יש להוסיף את הנתון  .vאיתור המקום
המתאים יתבצע על-ידי הרצת חיפוש בינארי של ַ vבמערך .החיפוש הבינארי יעלה
) log2(nפעולות הן במקרה הממוצע )וזאת לא הוכחנו( ,והן במקרה הגרוע )וזאת
הראנו( .במידה והחיפוש יסתיים בהצלחה ,כלומר הערך  vכבר מצוי במערך ,נוסיף
את הערך החדש לצד הערך הקיים .במידה והחיפוש יסתיים בכישלון ,כלומר הערך
 vלא מצוי במערך ,אזי המקום בו החיפוש הסתיים הוא המקום בו על הערך ִלשכון,
לשם נכניס את הערך .בשני המצבים) ,הן אם הערך כבר מצוי במערך ,והן אם
ולכן ַ
במקרה הממוצע יהיה עלינו להזיז מחצית מאברי המערך תא אחד ימינה כדי
לא(ִ :
ַ
לפנות מקום לנתון החדש ,ובמקרה הגרוע יהיה עלינו להזיז  nנתונים תא אחד
ימינה( .הן במקרה הממוצע ,והן במקרה הגרוע ,פעולת ההזזה תחייב עבודה בשיעור
של  nצעדים .כלומר הוספת איבר למערך ממוין תחייב עבודה בשיעור  nצעדים ,הן
במקרה הממוצע )בו הנתון מוכנס באמצע המערך( ,והן במקרה הגרוע )בו הנתון
מוסף בתחילת המערך() .מעלוּת החיפוש התעלמנו שכן היא זניחה יחסית ל.(n -
עתה נניח כי ברצוננו להוסיף נתון לעץ חיפוש הכולל  nנתונים .במקרה הממוצע
יהיה עומקו של העץ בסדר גודל של )) lg2(nוזאת לא הוכחנו( .כדי להוסיף את הנתון
נצטרך לרדת על הענף המתאים ,עד למקום בו יש להוסיף את הנתון .ההתקדמות על
הענף הרצוי תדרוש ) lg2(nצעדים .במקרה הגרוע העץ 'יתנוון' לכדי רשימה מקושרת.
מציאת המקום בו יש להוסיף את הנתון ברשימה מקושרת מחייבת אותנו לסרוק
בממוצע מחצית הרשימה ,ובמקרה הגרוע את כל הרשימה ,כלומר לבצע עבודה
בשיעור של  nפעולות .על-כן הוספת נתון לעץ חיפוש עלולה לחייב במקרה הגרוע
ביצוע של  nצעדים.
לסיכום :הוספת איבר לעץ חיפוש יעילה יותר מאשר הוספתו למערך ממוין במקרה
הממוצע ,אך לא במקרה הגרוע.
כדי שלא להקלע בעצי חיפוש בינאריים למקרה הגרוע ,בו העץ התנוון לכדי רשימה,
ישנם עצי חיפוש אשר יודעים 'לאזן את עצמם' ,כלומר להישאר בעומק של )lg2(n
תמיד .דוגמות לעצים כאלה הם עצים אדומים שחורים ,או עצי שתיים שלוש.
בקורס שלנו לא נכיר עצים אלה .רק כדי לסבר את העין ,בעץ כזה אם הקלט כולל
את הנתונים  ,1אח"כ ,2 :אח"כ ,3 :אזי העץ ידע 'לשנות את עצמו' כך שבשורש העץ
יוצב הערך  ,2בנו השמאלי יהיה  ,1ובנו הימני  ,3והוא יוותר מאוזן .כל זאת ,כמובן,
בלי להשקיע בכך 'יותר מדי עבודה'.
143
 14.12.2מחיקת נתון
עלות מחיקת נתון דומה לעלות הוספת נתון .במערך ממוין נאלץ ,ראשית ,לאתר את
הנתון ,תוך שימוש בחיפוש בינארי .אחר יהיה עלינו 'לצופף' את הנתונים כדי
'לסגור' את התא שנותר ריק .עלות החיפוש היא ) log2(nפעולות הן במקרה הממוצע
והן במקרה הגרוע ,ועלות ההזזה תא אחד ימינה היא  nפעולות הן במקרה הממוצע
והן במקרה הגרוע.
כדי למחוק איבר מעץ חיפוש יהיה עלינו ראשית לאתרו .במקרה הממוצע הדבר
ידרוש ) lg2(nצעדים ,ובמקרה הגרוע  nצעדים .מחיקת הנתון תחייב הן במקרה
הממוצע והן במקרה הגרוע איתור של העוקב של הערך) .שימו לב כי אנו טוענים
כאן ,בלי להוכיח בצורה מסודרת ,כי גם ַבמקרה הממוצע נאלץ לאתר את העוקב;
שבעץ מלא ,שהינו הקרוּב ְלעץ ממוצע ,מחצית
האינטואיציה שמאחורי הטענה היא ְ
מהערכים מצויים בצמתים פנימיים ,יש להם שני בנים ,ועל-כן מחיקתם מחייבת
איתור של העוקב להם( .איתור העוקב יעלה במקרה הממוצע ) log2(nצעדים.
במקרה הגרוע ידרוש איתור העוקב סדר גודל של  nצעדים) ,אתם מוזמנים לצייר
לעצמכם עץ בו זה המצב( .אחרי שאיתרנו את העוקב ,דורשת פעולת המחיקה רק
מספר קבוע )ועל כן זניח( של צעדים .לכן מחיקת ערך מעץ דורשת במקרה הממוצע
) lg2(nצעדים ,ובמקרה הגרוע  nצעדים.
שוב אנו רואים כי עץ חיפוש שקול )מבחינת כמות העבודה הנדרשת( למערך ממוין
ַבמקרה הגרוע ,אך עדיף על-פני מערך ממוין ַבמקרה הממוצע.
 14.12.3חיפוש נתון
עתה נשאל את עצמנו כמה עולה חיפוש נתון בכל אחד משני מבני הנתונים .כבר
אמרנו כי במערך ממוין ידרוש החיפוש ) log2(nצעדים הן במקרה הגרוע והן במקרה
הממוצע .לעומת זאת בעץ ידרוש החיפוש ) log2(nצעדים במקרה הממוצע ,אך n
צעדים במקרה הגרוע )בו העץ התנוון לכדי רשימה(.
לסיכום ,במקרה הממוצע )או בעץ 'שיודע לאזן את עצמו'( :עץ יעיל יותר ממערך
בהוספה ובמחיקה )שתי הפעולות עולות ) log2(nצעדים בעץ ,לעומת  nצעדים
במערך( ,ושקול למערך בחיפוש )שתי הפעולות עולות ) log2(nצעדים בשני מבני
הנתונים( .במקרה גרוע :מערך ועץ שקולים בהוספה ומחיקה )שתי הפעולות עולות n
צעדים בשני מבני הנתונים( ,ומערך יעיל יותר מעץ בחיפוש )אשר עולה )log2(n
צעדים במערך ,לעומת  nצעדים בעץ(.
== מכאן ואילך לא עודכן ==2010-11
 14.13האם עץ בינארי מלא
עץ בינארי נקרא מלא אם:
 .Iלכל צומת פנימי יש שני בנים ,וכן:
 .IIכל העלים מצויים באותה רמה.
מבין שלושת העצים שהצגנו בציור האחרון )בסעיף הקודם( ,רק האמצעי הוא עץ
בינארי מלא .כפי שראינו העץ הימני אינו מקיים את התנאי א' ,ובעץ השמאלי לא
כל העלים מצויים באותה רמה.
144
עתה ברצוננו לכתוב פונקציה אשר מקבלת מצביע לעץ בינארי ,ומחזירה ערך בולאני
המעיד האם העץ מלא .נציג שתי גרסות שונות לפונקציה .הגרסה הראשונה תהא
מימוש ישיר של ההגדרה .ראשית נציג אותה:
{ )bool is_fool(const struct Node * const on_the_hill
; bool ok
; unsigned int the_depth
; ) if (on_the_hill == NULL) return( true
; )ok = non_leaf_2_sons(on_the_hill
; ) if (! ok) return( false
)the_depth = depth(on_the_hill
; )ok = all_leaves_at_depth(on_the_hill, depth, 1
; )return(ok
}
את הפונקציות  non_leaf_2_sonsו depth -כבר פגשנו .נותר לנו עוד לכתוב את
הפונקציה  .all_leaves_at_depthפונקציה זאת מקבלת שלושה פרמטרים:
 .Iמצביע לשורשו של )תת( עץ בינארי.
 .IIעומקו של העץ המקורי )השלם( עבורו נערכת הבדיקה.
 .IIIהעומק בעץ המקורי בו מצוי שורש תת-העץ הנוכחי .מכיוון ששורש העץ
השלם מצוי בעומק של אחד ,אזי בקריאה הראשונה לפונקציה מועבר לה הערך
 .1כל קריאה רקורסיבית לפונקציה ,עם בניו של השורש הנוכחי ,תעביר ערך
גדול באחד מהערך שהועבר לה.
אנו מניחים כי לא יקראו לפונקציה עם עץ ריק .על-כן בפונקציה  ,if_foolבמידה
והעץ ריק אנו מחזירים את הערך  trueמיידית ,ולכן איננו מזמנים את הפונקציה
 .all_leaves_at_depthגם בפונקציה  all_leaves_at_depthאנו
מקפידים לקרוא רקורסיבית רק עבור בן שאינו ריק.
נציג את הפונקציה:
bool all_leaves_at_depth(const struct Node * const root,
{ )unsigned int tree_depth, unsigned int curr_depth
; bool ok = true
)if (root->_left == NULL && root-> _right == NULL
; ) return( curr_depth == tree_depth
)if (root->_left != NULL
ok = all_leaves_at_depth(root->_left,
)// (-
; )tree_depth, curr_depth +1
; )if (!ok) return(false
)if (root-> _right != NULL
ok = all_leaves_at_depth(root-> _right,
)// (+
; )tree_depth, curr_depth +1
; )return(ok
145
}
הסבר הפונקציה :במידה והצומת הנוכחי הוא עלה )ערכם של המצביעים _ leftו-
_ rightבצומת הוא  (NULLאזי יש להחזיר את הערך  trueבמידה ו-
 ,curr_depth==tree_depthכלומר במידה והעלה הנוכחי מצוי ַברמה שינה
עומק העץ .פקודת ה ,return -ראשית ,תעריך את הביטוי הבולאני:
 ,curr_depth==tree_depthושנית ,תחזיר את ערכו של הביטוי; ולכן יוחזר
הערך המתאים.
אם הבן השמאלי אינו ריק אנו קוראים עבורו .במידה ומוחזר ערך  falseהעותק
הנוכחי מחזיר מייד ערך זה .שימו לב כי אם הבן השמאלי הוא ריק) ,ולכן לא נערכת
קריאה רקורסיבית( ,אזי תודות לכך ש ok -אותחל לערך  trueהתנאי (!ok) :לא
יתקיים ,ולא יוחזר הערך  falseבאופן נחפז .באותו אופן ,אם הבן הימני אינו ריק
אזי נערכת קריאה עבורו ,ומוחזר הערך שחזר מהקריאה הרקורסיבית .אם הבן
הימני ריק ,אזי לא תיערך קריאה רקורסיבית עבורו ,ובמקרה זה יוחזר הערך true
שחזר מהקריאה עם הבן השמאלי) .בדקו לעצמכם מדוע אם הבן הימני ריק אזי אנו
מובטחים ש) :א( נערכה קריאה עם הבן השמאלי ,וכן) :ב( קריאה זאת החזירה את
הערך  trueלתוך המשתנה .(ok
מכיוון שמדובר בפונקציה לא טריוויאלית נציג שתי דוגמות הרצה.
בדוגמה הראשונה מצב הזיכרון טרם הקריאה לפונקציה  is_foolהוא:
root (of
= )main
הפונקציה מקבלת עותק של המצביע לשורש העץ .לכן מצב הזיכרון בעקבות זימון
הפונקציה הוא:
root (of
= )main
=on_the_hill
146
הפונקציה מתחילה להתבצע .ערכו של המצביע  on_the_hillאינו .NULL
הפונקציה  non_leaf_2_sonsמחזירה את הערך  trueלתוך המשתנה  ,okועל-
כן פקודת ה return -העוקבת לקריאה לפונקציה אינה מתבצעת .בשלב הבא
מוכנס למשתנה  the_depthעומקו של העץ ,שהוא שתיים .עתה מזמנת is_fool
את הפונקציה  . all_leaves_at_depthהיא מעבירה לה את שורש העץ ,את
עומקו של העץ ,כלומר את הערך שתיים ,ואת הקבוע  .1נציג את מצב הזיכרון:
root (of
= )main
=on_the_hill
=root
tree_depth=2
curr_depth=1
התנאי בפקודת ה if -הראשונה אינו מתקיים ,שכן לשורש הנוכחי יש שני בנים
שאינם ריקים .התנאי )(root->_left != NULLמתקיים ,ולכן אנו קוראים
רקורסיבית עם בנו השמאלי של השורש .שימו לב לערכם של הפרמטרים ,כפי
שמופיע בציור .כתובת החזרה של הקריאה הרקורסיבית המזומנת היא ההשמה ).(-
נציג את מצב הזיכרון:
root (of
= )main
=on_the_hill
=root
tree_depth=2
curr_depth=1
return=is_fool
=root
tree_depth=2
curr_depth=2
)return= (-
147
העותק השני של  all_leaves_at_depthמתחיל להתבצע .הפעם התנאי:
)(root->_left == NULL && root-> _right == NULLמתקיים ,ולכן
מוחזרת תוצאת ההשוואה  . curr_depth == tree_depthמכיוון שערכם של
שני הפרמטרים הוא  2מוחזר הערך  .trueערך זה חוזר לעותק הראשון של
 ,all_leaves_at_depthלמשתנה  .okבשלב זה העותק הראשון 'מופשר
מקפאונו' .התנאי ) (!okאינו מתקיים ,ולכן אנו מתקדמים לפקודת הif -
הבודקת האם ) .(root-> _right != NULLהתנאי בפקודה מתקיים ,ולכן אנו
מזמנים שוב את  .all_leaves_at_depthהפעם כתובת החזרה היא ההשמה )(+
בעותק הראשון של הפונקציה .נציג את מצב הזיכרון ,בפרט את רשומת ההפעלה של
הפונקציה הנקראת:
root (of
= )main
=on_the_hill
=root
tree_depth=2
curr_depth=1
return=is_fool
=root
tree_depth=2
curr_depth=2
)return= (+
העותק הנוכחי של הפונקציה מתחיל להתבצע .התנאי:
)(root->_left == NULL && root-> _right == NULLמתקיים ,ולכן
מוחזרת תוצאת ההשוואה  . curr_depth == tree_depthמכיוון שערכם של
שני הפרמטרים הוא  2מוחזר הערך  .trueערך זה חוזר לעותק הראשון של
 ,all_leaves_at_depthלמשתנה  okבהשמה ) .(+ערכו של  ,okכלומר הערך
 ,trueמוחזר על-ידי העותק הראשון )שנקרא על-ידי  .(is_foolבשלב זה
 is_foolיכולה להחזיר את הערך  trueכראוי.
עתה נראה כיצד תתנהל ריצה של  is_foolעל העץ הבא )עליו מצביע  rootשל
התכנית הראשית(:
מצב הזיכרון עם הקריאה לפונקציה  is_foolהוא:
148
root (of
= )main
=on_the_hill
כפי שראינו בסעיף הקודם ,הפונקציה  non_leaf_2_sonsתחזיר את הערך
 ,trueכלומר לא היא שתעיד על כך שהעץ אינו מלא .הפונקציה  depthתחזיר את
הערך  3לתוך המשתנה  .the_depthבשלב הבא אנו מזמנים את הפונקציה
 ,all_leaves_at_depthוהיא שאמורה להחזיר את הערך  ,falseאשר יוחזר
על-ידי  .is_foolנבחן עתה את פעולתה של  .all_leaves_at_depthבקריאה
הראשונה לה מועברים לה ארגומנטים כמתואר בציור:
root (of
= )main
=on_the_hill
=root
the_depth= 3
curr_depth= 1
return=is_fool
העותק הראשון של  all_leaves_at_depthמתחיל להתבצע .התנאי
) (root->_left == NULL && root-> _right == NULLאינו מתקיים.
בפקודה הבאה ,התנאי ) (root->_left != NULLמתקיים ,ולכן מזומנת
קריאה רקורסיבית .ערכי הארגומנטים המועברים לקריאה הרקורסיבית הם כפי
שמופיע בציור:
root (of
= )main
=on_the_hill
=root
the_depth= 3
curr_depth= 1
return=is_fool
=root
the_depth= 3
curr_depth= 2
)return=(-
149
העותק השני של  all_leaves_at_depthמתחיל להתבצע .עת התנאי
) (root->_left == NULL && root-> _right == NULLמתקיים ,ולכן
הערך המוחזר על-ידי הקריאה השניה ל all_leaves_at_depth -הוא ערכו של
הביטוי הבולאני . ( curr_depth == tree_depth ) :מכיוון שערכם של
 curr_depthו the_depth -שונֵה ,מוחזר הערך  .falseערך זה מוכנס בהשמה
) (-למשתנה  okשל העותק הראשון של  .all_leaves_at_depthמכאן העותק
הראשון של הפונקציה מתקדם לפקודה , if (!ok)… :אשר גורמת לו להחזיר את
הערך  .falseערך זה מוחזר לפונקציה  is_foolכראוי; והיא על-כן מחזירה
בתורה את הערך .false
עד כאן הצגנו גרסה ראשונה של הפונקציה הבודקת האם עץ בינארי מלא .הגרסה
הראשונה הינה מימוש ישיר של ההגדרה .עתה נרצה להציג גרסה שניה ,שלא
תסתמך ישירות על ההגדרה ,אלא על אובזרבציה פשוטה .נשים לב כי בעץ מלא
בעומק  1קיים צומת יחיד; בעץ מלא שעומקו  2קיימים  3צמתים ,בעץ מלא שעומקו
 3קיימים  7צמתים ,ובאופן כללי בעץ מלא שעומקו  nקיימים  2n -1צמתים .ומכאן
נוכל לענות על השאלה האם עץ בינארי מלא באופן הבא:
{ )bool simple_is_full(const struct Node * const root
; unsigned int the_depth, num_of_nodes
; )the_depth = depth(root
; )num_of_nodes = count_nodes(root
; ) return( power(2, the_depth) –1 == num_of_nodes
}
מה עשינו? נאתר את עומקו של העץ) ,באמצעות פונקציה שכתבנו בעבר(; נספור את
מספר הצמתים בעץ )באמצעות פונקציה שכתבנו בעבר(; נחזיר את תוצאת
ההשוואה בין מספר הצמתים בעץ ל :שתיים בחזקת עומק העץ ,מינוס אחד) .את
הפונקציה  powerהמעלה מספר טבעי אחד בחזקת מספר טבעי שני יהיה עלינו
לממש(.
 14.14האם בכל הצמתים בעץ השוכנים באותה רמה מצוי אותו
ערך
עתה ברצוננו לכתוב פונקציה אשר מקבלת מצביע לעץ בינארי )שאינו עץ חיפוש(,
ומחזירה את הערך  trueאם ורק אם בכל אחת מהרמות בעץ יש אותו ערך בכל
הצמתים .לדוגמה ,על הפונקציה להחזיר את הערך  trueעל העץ הימני ,אך לא על
השמאלי:
7
5
2
7
5
2
5
2
2
150
5
2
1
 14.14.1הפונקציהequal_vals_at_level :
הפונקציה שנכתוב תכלול לולאה אשר תעבור על רמות העץ בזו אחר זו ,החל
מהרמה מספר אחת )שורש העץ( ,ועד רמת העלים .עבור כל רמה ראשית נשלוף ערך
כלשהו המצוי באותה רמה ,ושנית נבדוק האם בכל הצמתים באותה רמה מצוי ערך
המפרה את הדרישות נחזיר מיידית את הערך  .falseנציג
ֵ
זה .במידה וגילינו רמה
את הפונקציה:
)bool equal_vals_at_level(const struct Node * const root
{
; unsigend int a_depth
; int val
; bool ok
{ )for (a_depth= 1; a_depth <= depth(root); a_depth ++
; )ok = get_val_from_level(root, a_depth, 1, val
; )ok = check_level(root, a_depth, val, 1
; ) if (!ok) return( false
}
; )return(true
}
הפונקציה פשוטה למדי ,היא כתובה על-פי עקרונות התכנות המודולרי ,ועצוב
מעלה-מטה .שימו לב כי עבור עץ ריק יוחזר הערך  trueבלי שהפונקציה תכנס
ללולאה אף לא פעם אחת )שכן התנאי a_depth <= depth(root) :לא יתקיים
כבר בפעם הראשונה שנגיע ללולאה ,וזאת משום שעומקו של עץ ריק הוא אפס(.
 14.14.2הפונקציהget_val_from_level :
נִ פנה עתה להצגת הפונקציות בהן  equal_vals_at_levelעושה שימוש.
לפונקציה  get_val_from_levelארבעה פרמטרים:
 .Iפרמטר ערך באמצעותו מועבר לה מצביע לשורש העץ ממנו עליה לשלוף את
הערך הרצוי.
 .IIפרמטר ערך המציין מאיזו רמה בעץ השלם יש לשלוף את הערך.
 .IIIפרמטר ערך באמצעותו מועבר ַלפונקציה העומק בעץ השלם בו מצוי הצומת
שמצביע אליו מועבר לה בקריאה הנוכחית.
 .IVפרמטר משתנה לתוכו יוכנס ערך כלשהו מהרמה המבוקשת בעץ המבוקש.
הפונקציה מחזירה ערך בולאני המורה האם היא הצליחה לאתר ערך כלשהו ברמה
המבוקשת .הערך הבולאני המוחזר ישרת אותנו בקריאות הרקורסיביות
תקרא לעצמה .מכיוון שבקריאה הראשונה לפונקציה מתוך
ְ
שהפונקציה
 equal_vals_at_levelאנו מובטחים שמועבר עומק תקין ,כלומר כזה שאינו
גדול מעומק העץ השלם ,אזי בערך המוחזר על-ידי הפונקציה
 get_val_from_levelל equal_vals_at_level -איננו עושים שימוש.
נציג עתה את קוד הפונקציה:
bool get_val_from_level(const struct Node * const root,
unsigned int wanted_depth, unsigned int
curr_depth,
151
{ )int &val
; bool success
; ) if (root == NULL) return( false
{ )if (wanted_depth == curr_depth
; val = root -> data
; ) return( true
}
success = get_val_from_level(root->_left,
)// (-
wanted_depth, curr_depth +1,
; )val
; ) if (success) return( true
success = get_val_from_level(root->_left,
)// (+
wanted_depth, curr_depth +1,
; )val
; ) return( success
}
הסבר :ראשית ,אם העץ ריק ,סימן שההתקדמות הייתה על ענף בו לא מצוי ערך
ברמה המבוקשת ,ועל-כן הפונקציה מחזירה את הערך  .falseשנית ,אם הגענו
לרמה המקיימת ש(wanted_depth == curr_depth) :אזי אנו ַבעומק
המבוקש; על-כן יש להכניס לפרמטר המשתנה  valאת הערך המצוי בצומת זה ,ויש
להחזיר את הערך  ,trueכדי לציין שמצאנו ערך בעומק המבוקש .לבסוף ,אם העץ
אינו ריק ,וכן עדיין לא הגענו ַלעומק המבוקש ,אזי אנו תחילה קוראים רקורסיבית
עם הבן השמאלי .במידה והקריאה הרקורסיבית מחזירה את הערך  ,trueסימן
שהיא הצליחה לאתר ערך בעומק המבוקש )בבן השמאלי( ,ולכן הקריאה הנוכחית
יכולה לסיים ,ולהחזיר את הערך  .trueאם הקריאה עם הבן השמאלי לא הצליחה
לאתר ערך בעומק המבוקש )ועל כן החזירה את הערך  ,(falseאזי אנו קוראים
ֵ
רקורסיבית עם הבן הימני ,ומחזירים את האיתות המוחזר מהקריאה הרקורסיבית.
152
נדגים הרצה של הפונקציה על העץ הבא:
7
8
5
6
נניח כי הפונקציה מתבקשת להחזיר ערך המצוי ברמה שלוש בעץ .מצב הזיכרון עם
הקריאה לפונקציה הוא הבא:
)root (of main
=
7
8
5
root (of
)…equal_vals
=
=val
return= main
6
= root
wanted_depth= 3
curr_depth= 1
=val
return=equal..
שימו לב לערכם של הפרמטרים השונים :הפרמטר  rootמצביע על שורש העץ;
 wanted_depthמציין את הרמה ממנה יש לשלוף ערך ,כלומר curr_depth ;3
מציין את העומק בעץ בו אנו מצויים עתה ,כלומר  val ;1הוא פרמטר משתנה ולכן
שלחנו חץ לארגומנט המתאים; כתובת החזרה של העותק הראשון של
 get_val_from_levelהיא הפונקציה .equal_vals_at_level
הפונקציה  get_val_from_levelמתחילה להתבצע .ערכו של  rootאינו .NULL
גם התנאי ) (wanted_depth == curr_depthאינו מתקיים ,ועל-כן אנו
מתקדמים לפקודה אשר מזמנת קריאה רקורסיבית לפונקציה עם הבן השמאלי של
השורש הנוכחי .כתובת החזרה של העותק השני של הפונקציה היא ההשמה )(-
בעותק הראשון .נציג את מצב המחסנית עם הקריאה לעותק השני:
153
)root (of main
=
7
8
5
root (of
)…equal_vals
=
=val
return= main
6
= root
wanted_depth= 3
curr_depth= 1
=val
return=equal..
= root
wanted_depth= 3
curr_depth= 2
=val
)return= (-
אני מסב את תשומת לבכם לערכם של הפרמטרים השונים בקריאה השניה
לפונקציה.
הקריאה השניה מתחילה להתבצע .התנאים בשתי פקודות ה if -הראשונות אינם
מתקיימים ,על כן הפונקציה קוראת רקורסיבית עם הבן השמאלי של הצומת אשר
מצביע אליו מועבר לה בפרמטר  .rootהבן השמאלי דנן ערכו  ,NULLועל כן
הקריאה הרקורסיבית השלישית מחזירה מיידית את הערך  falseלמשתנה
 successשל הקריאה הרקורסיבית השניה .עתה הקריאה הרקורסיבית השניה
קוראת עם בנה הימני .גם ערכו של מצביע זה הוא  ,NULLועל כן גם הקריאה עמו
מחזירה את הערך  .falseלקריאה הרקורסיבית השניה לא נותר עוד דבר לעשותו,
ואין לה בררה אלא להחזיר את הערך  falseהמעיד שמכיווּן זה בעץ לא הצלחנו
לשלוף ערך כלשהו מעומק שלוש בעץ .הערך המוחזר על-ידי הקריאה הרקורסיבית
השניה מוכנס בהשמה ) (-למשתנה  successשל הקריאה הרקורסיבית הראשונה.
הקריאה הרקורסיבית הראשונה ניסתה ,עד כה ,לשלוף ערך מעומק שלוש בעץ על-
ידי התקדמות לבנה השמאלי; הניסיון נכשל .על-כן עתה הקריאה הרקורסיבית
הראשונה תנסה לשלוף את הערך מבנה הימני .הקריאה הראשונה מתקדמת
להשמה ) (+ומזמנת קריאה רקורסיבית נוספת .נציג את מצב הזיכרון:
154
root (of main)
=
root (of
equal_vals…)
=
val=
return= main
7
8
5
6
root =
wanted_depth= 3
curr_depth= 1
val=
return=equal..
root =
wanted_depth= 3
curr_depth= 2
val=
return= (+)
 התנאים בשתי.נציין את הקריאה הרקורסיבית הנוכחית כקריאה מספר חמש
כן הקריאה- על,פקודות התנאי הראשונות בקריאה מספר חמש אינם מתקיימים
.(6 מספר חמש קוראת רקורסיבית עם בנה השמאלי )כלומר עם מצביע לצומת של
: נציג את מצב הזיכרון.נסמן את הקריאה המזומנת כקריאה מספר שש
root (of main)
=
root (of
equal_vals…)
=
val=
return= main
7
8
5
6
root =
wanted_depth= 3
curr_depth= 1
val=
return=equal..
root =
wanted_depth= 3
curr_depth= 2
val=
return= (+)
root =
wanted_depth= 3
curr_depth= 3
val=
return= (-)
155
הקריאה מספר שש מתחילה להתבצע .התנאי )(root == NULLאינו מתקיים;
אך התנאי (tree_depth == curr_depth) :מתקיים מאוד .על כן הפונקציה
מכניסה לתוך הפרמטר המשתנה  valאת ערכו של  ,root-> _dataכלומר את
הערך שש .מכיוון ש val -הוא פרמטר משתנה אנו עוקבים אחר החצים כל הדרך
אל הבנק עד למשתנה  valשל הפונקציה  ,equal_vals_at_levelושם שמים
אחר העותק מספר שש מחזיר את הערך  .trueערך זה חוזר בהשמה (-
את הערךַ .
) למשתנה  successשל העותק מספר חמש .בזאת מסתיים גם העותק מספר חמש
תוך שהוא מחזיר את ערכו של  successלהשמה ) (+של העותק הראשון .עתה
העותק הראשון יכול להחזיר את הערך  trueהמצוי במשתנה  successשלו
לפונקציה  equal_vals_at_levelאשר זימנה את העותק הראשון של
 .get_val_from_levelבזאת השלמנו את המשימה של שליפת ערך כשלהו
מעומק שלוש בעץ.
 14.14.3הפונקציהcheck_level :
עתה עלינו לכתוב את הפונקציה השניה בה  equal_vals_at_levelעושה
שימוש ,את הפונקציה  .check_levelפונקציה זאת מקבלת ארבעה פרמטרי ערך:
 .Iמצביע לשורש )תת(-העץ המועבר לה.
 .IIהעומק אותו עליה לבדוק) .להזכירכם ,על הפונקציה לבדוק האם בכל
הצמתים ַבעומק הרצוי קיים אותו ערך(.
 .IIIהערך שאמור להימצא בכל הצמתים בעומק הרצוי.
 .IVהעומק בעץ השלם בו מצוי הצומת שמצביע אליו מועבר בפרמטר הראשון.
בקריאה הראשונה ל) ,check_level -זו המזומנת על-ידי
 equal_vals_at_levelיועבר שורש העץ השלם ,והערך  ,1שכן שורש העץ
מצוי בעומק אחד( .במהלך הבדיקה יהיה על הפונקציה להתקדם באמצעות
קריאות רקורסיביות לעומק הרצוי .כל קריאה רקורסיבית תעביר לקריאה
שהיא מזמנת את עומקו בעץ השלם של הצומת המועבר בפרמטר הראשון(.
נציג את הפונקציה :check_level
bool check_level( const struct Node * const root,
unsigned int wanted_depth, int val ,
{ )unsigned int curr_depth
; ) if (root == NULL) return( true
)if (curr_depth == wanted_depth
; ) return( root-> _data == val
return( check_level(root->_left, wanted_depth, val,
)curr_depth +1
&&
check_level(root-> _right, wanted_depth, val,
)curr_depth +1
; )
}
הסבר :אם הגענו לתת-עץ ריק לא גילינו הפרה של הדרישה שבכל הצמתים בעומק
הרצוי ) (wanted_depthיהיה הערך המבוקש ) ,(valולכן אנו מחזירים את הערך
 .trueבמילים אחרות ,אם הגענו לתת-עץ ריק משמע הענף הנוכחי מסתיים בעומק
קטן מ ,wanted_depth -ועל כן בו אין צומת המצוי בעומק ,wanted_depth
156
בפרט אין צומת שמפר את הדרישה שבכל הצמתים בעומק המבוקש יהיה את הערך
ְ
.val
מנגד ,אם ) (curr_depth == wanted_depthאזי הגענו ַלעומק בו אנו
מתעניינים ,ועל-כן יש להחזיר את הערך  trueאם ורק אם בצומת קיים הערך .val
פקודת ה return -ראשית ,משווה את הערך המצוי בצומת עם הערך המבוקש,
ושנית ,מחזירה את תוצאת ההשוואה.
לבסוף ,אם אף אחד משני התנאים הקודמים לא התקיים אזי מחד טרם הגענו
לעומק הדרוש ,ומאידך יש עדיין לאן להתקדם על הענף עליו אנו מתקדמים ,לכן יש
לקרוא רקורסיבית עם שני בניו של הצומת הנוכחי ,ולהחזיר את תוצאת הבדיקה
המוחזרת על-ידם .הקוד הבא עושה זאת:
return( check_level(root->_left, wanted_depth, val,
)curr_depth +1
&&
check_level(root-> _right, wanted_depth, val,
)curr_depth +1
; )
נסביר מדוע וכיצד :הקוד כולל פקודת  returnאשר מחזירה את תוצאת הקריאה
הרקורסיבית עם הבן השמאלי וגם תוצאת הקריאה הרקורסיבית עם הבן הימני.
אם לפחות אחת משתי הקריאות תחזיר את הערך  falseאזי פקודת הreturn -
תחזיר את הערך  ,falseואם שתי הקריאות תחזרנה את הערך  trueאזי פקודת
ה return -תחזיר את הערך .true
 14.14.4זמן הריצה של equal_vals_at_level
כמה עבודה מבצעת הפונקציה  equal_vals_at_levelעת היא בודקת עץ
כלשהו? נניח כי על הפונקציה לבדוק עץ בעומק ַ .nבמקרה הגרוע ,כלומר ַבמקרה בו
על הפונקציה לבצע את מירב העבודה ,העץ הינו מלא ,ולכן כולל  2n –1צמתים,
וכמו כן בצמתים המצויים בכל רמה ורמה יש אותו ערך )ולכן הבדיקה אינה נעצרת
באמצע התהליך ,אלא ִמתמצה( .עבוּר כל אחת ואחת מרמות העץ ,החל ברמה מספר
אחת ועד הרמה מספר  nמבצעת הפונקציה שתי פעולות:
 .Iהיא שולפת ערך המצוי בעומק הרצוי )נסמן עומק זה באות .(d
 .IIהיא בודקת את הצמתים ברמה מספר .d
עבוּר כל אחת משתי הפעולות על פונקציה לסרוק כל צמתי העץ ברמות ,1..d
כלומר לסרוק כ 2d -צמתים.
כאמור ,את הבדיקה הנ"ל יש לבצע עבור  ,d=1,…,nולכן כמות העבודה המתבצעת
היא בשיעור . 21+…+2n :הביטוי שקיבלנו הוא סכומו של טור הנדסי בן  nאיברים,
שהאיבר הראשון בו ) (a1ערכו שתיים ,האיבר האחרון ערכו  ,2nוהיחס בין כל זוג
איברים עוקבים ) (qהוא שתיים .סכומו של טור זה מתקבל על-ידי הנוסחהs= :
) . a1*(qn –1)/(q –1יישומה של הנוסחה למקרה שלנו ייתן את הביטוי:
) , 2*(2n –1)/(2 –1עבור  nשהינו עומק העץ .מספר הצמתים בעץ הוא,
להזכירכם ,2n ,כלומר כמות העבודה המתבצעת על-ידי הפונקציה לינארית בכמות
הצמתים.
מה ,לעומת זאת ,יקרה אם העץ יתנוון לכדי רשימה משורשרת )כלומר לכל צומת
יהיה רק בן יחיד(? במקרה זה בעץ שעומקו  nיש  nצמתים .עתה כדי לבדוק את
הצומת )היחיד( מעומק  dיש לסרוק  dצמתים ,וזאת יש לבצע עבור ,d=1,…,n
157
כלומר כמות העבודה הינה בסדר גודל של ,1+2+…+n :כלומר בסדר גודל של ,n2
עבור  nשמציין את עומקו של העץ ,ואת מספר הצמתים בעץ.
על-כן במקרה הגרוע הפונקציה שכתבנו תרוץ בזמן ריבועי במספר הצמתים .לא
נוכיח כאן כי במקרה הממוצע כמות העבודה שהפונקציה תבצע הינה כמו במקרה
הראשון שראינו )בו העץ מלא( כלומר לינארית במספר הצמתים.
 14.14.5פתרון חלופי לבעיה
את הבעיה המוצגת בסעיף זה )האם בכל הצמתים באותה רמה בעץ מצוי אותו ערך(
ניתן לפתור בצורה שונה ,פשוטה יותר .נציג כאן את קווי המתאר של הפתרון
החלופי:
d
 .Iאתר את עומק העץ ,שיסומן כ) .d -כזכור ,בעץ יש לכל היותר  2 -1צמתים(.
 .IIהקצה מערך בגודל  2d –1תאים שלמים.
 .IIIשכן את עץ במערך ,כפי שמתואר בסעיף .13.2
i
i+1
 .IVסרוק את המערך סדרתית .התאים מספר ) 2 ,…,2 -1עבור (i=1,…,d-1
במערך ,שאינם ריקים ,מכילים ערכים המצויים באותה רמה בעץ ,ועל-כן בדוק
האם הם שווים.
 14.15האם כל הערכים בעץ שונים זה מזה
עתה ברצוננו לכתוב פונקציה בשם  uniqueאשר מקבלת מצביע לעץ בינארי )שאינו
דווקא עץ חיפוש( ומחזירה את הערך  trueאם ורק אם כל הערכים בעץ שונים זה
מזה ,במילים אחרות :אם לא קיימים בעץ שני צמתים שיש בהם אותו ערך.
תמ ֵצא בצומת node
הפונקציה  uniqueתסרוק את כל צמתי העץ .עת הפונקציה ַ
כלשהו ,בו מצוי הערך  ,vיהיה עליה לקרוא לפונקציה  count_valאשר תספור
כמה פעמים מופיע הערך  vבעץ בשלמותו .אם כל ערך מופיע בעץ רק פעם יחידה,
אזי הפונקציה  count_valתחזיר את הערך אחד )שכן היא תספור גם את המופע
הנוכחי של  .(vלכן אם  count_valמחזירה ערך גדול מאחד מצאנו הפרה של
הדרישה ,וניתן לסיים מיידית )את הרצתה של  (uniqueתוך החזרת הערך .false
קל יחסית להבין כי  uniqueתקבל מצביע )בשם  (curr_nodeלצומת כלשהו בעץ,
והרקורסיה תתבצע על  ,curr_nodeכלומר ַבקריאות הרקורסיביות השונות
ַלפונקציה ישתנה ערכו של  .curr_nodeהנקודה העדינה בפונקציה  uniqueהיא
שעל מנת ש unique -תוכל להעביר ל count_val -את שורש העץ השלם היא
צריכה לקבל ְפרט למצביע  curr_nodeמצביע לשורש העץ השלם )שיקרא
 .(tree_rootעל מצביע זה לא תיערך רקורסיה ,והוא יועבר כמות שהוא בקריאות
הרקורסיביות השונות ל ,unique -וזאת על-מנת שכל קריאה רקורסיבית תוכל
להעבירו ל.count_val -
נציג ,ראשית ,את :unique
bool unique(const struct Node * const curr_node,
{ )const struct Node * const tree_root
; if (curr_node == NULL) return true
)if (count_val(tree_root, curr_node-> _data) > 1
158
; return false
; )
&& )return( unique(curr_node->_left, tree_root
)unique(curr_node-> _right, tree_root
}
הסבר הפונקציה :ראשית ,אם תת-העץ אליו הגענו ריק אזי אין בו ערך המופיע בעץ
יותר מפעם יחידה ,ועל-כן ניתן להחזיר את הערך  .trueשנית ,אם תת-העץ אליו
הגענו אינו ריק ,אזי יש לספור כמה פעמים מופיע בעץ השלם הערך >curr_node-
 ,_dataואת זאת אנו עושים על-ידי קריאה ל count_val -אשר מקבלת מצביע
לשורש העץ השלם וערך רצוי .אם הפונקציה מחזירה ערך גדול מאחד ,אזי ניתן
להסיק כי הערך המצוי בצומת הנוכחי אינו ייחודי ,ועל-כן יש להחזיר את הערך
 .falseלבסוף ,אם הערך בצומת הנוכחי התגלה כייחודי ,אזי יש להחזיר את
תוצאת הקריאה הרקורסיבית לבן השמאלי ,וגם תוצאת הקריאה הרקורסיבית לבן
המזוּמנות מקבלת את הבן
הימני .שימו לב כי כל אחת מהקריאות הרקורסיביות ְ
המתאים ,ואת ְ tree_rootכמות שהוא ,כלומר את שורש העץ השלם.
שאלות למחשבה והשלמות נדרשות לסעיף זה:
 .Iכתבו את הפונקציה )הפשוטה למדי( .count_val
 .IIכמה עבודה מבצעת ) uniqueבמקרה הגרוע ביותר( עת עליה לבדוק עץ בן n
צמתים?
 .IIIכתבו את  uniqueעבור עץ חיפוש.
כמה עבודה מבצעת  uniqueעל עץ חיפוש?
159
 14.16תרגילים
 13.14.1תרגיל ראשון :עץ radix
בהינתן אוסף מחרוזות המורכבות משתי אותיות בלבד  a,bנרצה להחזיקן במבנה
נתונים שיאפשר לנו להשיב ביעילות על 'שאילתות שייכות' ,כלומר ,בהינתן מחרוזת
נרצה לדעת האם היא מופיעה באוסף המחרוזות הנתון ,או לא מופיעה.
לדוגמא עבור אוסף המחרוזות } {a,abb,ba,baa,babbאנו עשויים להישאל האם
המחרוזת  baaמופיעה באוסף? )כן( האם המחרוזת  babמופיעה בו? )לא( האם
המחרוזת  abbaמופיעה בו? )לא( וכדומה.
דרך אפשרית לממש את המטרה היא להחזיק את האוסף הנתון בעץ בינרי הנקרא
 :radix treeהעץ יבנה ,על-סמך אוסף המחרוזות באופן הבא:
 .Iראשית ,בנה עץ הכולל שורש בלבד )לא עץ ריק(.
אחר ,עבוֹר על כל מחרוזות האוסף ,בזו אחר זו ,והוסף אותן לעץ באופן הבא:
ַ .II
אחר אות ,משמאל לימין.
התחל משורש העץ .קרא את המחרוזת הנוכחית אות ַ
בכל צעד אם התו הבא הוא  aרד לבן השמאלי של הקודקוד הנוכחי ,אם התו
נדרשת לרדת לבן
ַ
הבא הוא  bרד לבן הימני של הקודקוד הנוכחי .בכל שלב בו
שאינו קיים – צור אותו .אם המחרוזת הסתימה זה עתה – סמן את הקודקוד בו
היא הסתימה.
העץ שיווצר עבור הדוגמה לעיל:
+
-
+
-
+
+
+
בהינתן מחרוזת ,הסיקו כיצד תשתמשו בעץ )בלבד( על-מנת להשיב על שאילתת
שייכות )לא להגשה(.
ִ .Iכתבו את ההגדרות הדרושות למימוש העץ הנ"ל )כלומר אילו מערכים-struct ,ים
או מבני נתונים אחרים יידרשו למימוש עץ כנ"ל(.
160
 .IIכתבו את הפונקציה  addהמוסיפה מחרוזת לעץ נתון .הפונקציה מקבלת כקלט
מצביע לשורש העץ ,ואת המחרוזת המבוקשת) .הניחו כי העץ מכיל לכל הפחות
שורש .אתם רשאים להוסיף ַלפונקציה פרמטרים נוספים על-פי שיקול דעתכם(.
 .IIIכתבו את הפונקציה  findהמחפשת מחרוזת בעץ נתון )כנ"ל( .הפונקציה מקבלת
כקלט מצביע לשורש העץ ,ואת המחרוזת המבוקשת ,ומחזירה ערך אמת
) (true/falseמתאים) .אתם רשאים להוסיף לפונקציה פרמטרים נוספים על-פי
שיקול דעתכם(.
 .IVנסמן ב n :את מספר המחרוזות באוסף ,וב m :את אורך המחרוזת הארוכה
ביותר האפשרית )באוסף ,או בשאילתת השייכות( .הניחו כי פעולת השוואה של
זוג תווים )זה עם זה( אורכת יחידת זמן אחת )וכל שאר הפעולות זניחות(.
 .1חשבו והסבירו את זמן הריצה של שאילתת שייכות על העץ.
 .2כנ"ל לגבי חיפוש במערך דו ממדי ממוין הגדול דיו להחזיק את כל המחרוזות
באוסף.
ה .בחרו את אחת משתי הפונקציות שכתבתם – וכתבו אותה שוב :אם בחרתם
במימוש איטרטיבי ,השתמשו כעת ברקורסיה ,ולהפך.
הערה :בכל המקרים ניתן להניח קלט תקין.
 13.14.2תרגיל שני :מציאת קוטר של עץ
בתכנית מוגדר:
struct Node
{
; int _data
; struct Node *_left, * _right
}
נגדיר קוטר של עץ בינארי כמספר הקשתות על המסלול הארוך ביותר בין שני
צמתים בעץ.
הקוטר של העץ הריק יוגדר להיות  ,1-וקוטרו של עץ הכולל צומת יחיד יוגדר
להיות .0
דוגמה :קוטרם של שני העצים הבאים הוא :5
רמז :שימו לב כי בכל עץ קיים צומת )הצבוע בדוגמות שלנו באפור( כך ששני
הצמתים המצויים בשני קצותיו של הקוטר )שני הצמתים הצבועים בשחור(
הינם שניהם צאצאיו של אותו צומת.
כתבו פונקציה המקבלת מצביע לעץ בינארי )מטיפוס *  (struct Nodeומחזירה את
קוטרו )יש לכתוב כל פונקצית עזר בה הפונקציה שלכם משתמשת(.
161
 13.14.3תרגיל שלישי :מה עושה התכנית?
נגדיר
struct Node
{
;int _number
;struct Node *_left, * _right
}
נתונה הפונקציה הבאה:
)void secret (struct Node *t, float &x, int &n
{
;float x1, x2
;int n1, n2
)if (t == NULL
{
;x= 0
;n= 0
}
else
{
;)secret (t->_left, x1, n1
;)secret (t-> _right, x2, n2
;n= n1 +n2 +1
;x= (t->number + x1*n1 + x2*n2) / n
}
}
 .Iאם מריצים את הפונקציה הזו על עץ בינרי ,מהם הערכים שיוחזרו בפרמטרים
 xו?n-
;)secret (t-> _right, x2, n2
 .IIאם במקום השורה
;x2 = 0; n2 = 0
יופיעו שתי הפקודות:
מהם הערכים שיוחזרו אז?
 13.14.4תרגיל רביעי :בדיקה האם עץ א' משוכן בעץ ב'
נגדיר:
struct Node
{
;int _data
; struct Node *_left, *_right
}
נאמר שעץ בינארי  T1משוכן בעץ בינארי שני  T2אם ב T2 -קיים צומת N
המהווה שורש של תת עץ )של  (T2שהינו זהה לחלוטין ל.T1 -
לדוגמא העץ:
2
162
1
3
משוכן בימני מבין שלושת העצים הבאים ,אך לא בשנים האחרים:
)הערה :מצביעים שערכם  NULLהושמטו מכל ארבעת הציורים(
5
5
2
7
3
2
7
1
5
7
1
4
2
3
1
4
כתבו את הפונקציה:
)bool nested(struct Node *small, struct Node *large
הפונקציה תחזיר את הערך אמת אם העץ עליו מצביע הפרמטר  smallמשוכן בעץ
עליו מצביע הפרמטר  ,largeושקר אחרת.
163
 13.14.5תרגיל חמישי :תת-עץ מרבי בו השורש גדול מצאצאיו
כתבו פונקציה המקבלת מצביע  rootלשורשו של עץ בינארי ,ומחזירה מצביע
 root_largerלשורש תת-העץ הגדול ביותר )כלומר לתת-העץ שכולל את מספר הגדול
ביותר של צמתים( ,המקיים שעבור כל צומת בתת-העץ עליו מצביע ,root_larger
הערכים המצויים בצאצאיו של הצומת קטנים או שווים מהערך המצוי ַבצומת
עצמו.
במידה וקיימים כמה תתי-עצים כנ"ל יש להחזיר מצביע לאחד מהם.
לדוגמה ,עבור העץ הבא:
4
5
7
0
6
3
2
4
יוחזר מצביע לתת-העץ ששורשו הוא הצומת של שבע.
164
 13.14.6תרגיל שישי :בדיקה האם תת-עץ שמאלי מחלק כל שורש ,וכל
שורש מחלק תת-עץ ימני
כתבו פונקציה המקבלת מצביע לשורשו של עץ בינארי ומחזירה את הערך  trueאם
ורק אם כל צומת  nבעץ מקיים שהערכים בכל הצמתים בתת-העץ עליו מצביע בנו
השמאלי של  nמחלקים את הערך שבצומת  ,nבעוד הערכים בכל הצמתים בתת-העץ
עליו מצביע בנו הימני של  nמחולקים על-ידי הערך שבצומת .n
לדוגמה ,על העץ הבא יוחזר הערך :true
16
8
64
64
32
4
8
4
 13.14.7תרגיל שביעי :בדיקה האם עץ סימטרי
הגדרה :עץ בינארי יקרא סימטרי אם בנו השמאלי הוא תמונת ראי של בנו הימני.
כתבו פונקציה המקבלת מצביע לשורשו של עץ בינארי ,ומחזירה את הערך  trueאם
ורק אם העץ סימטרי.
לדוגמה ,העץ הבא סימטרי:
16
8
8
4
7
7
5
4
5
9
9
165
 13.14.8תרגיל שמיני :איתור הערך המזערי בעץ
כתבו פונקציה המקבלת מצביע לשורשו של עץ בינארי שאינו ריק ,ומחזירה את
הערך המזערי בעץ.
 13.14.9תרגיל תשיעי :עץ ביטוי )עבור ביטוי אריתמטי(
בתרגיל זה נעסוק בביטויים אריתמטיים כדוגמת. 5, (5+3), (2*(5+3)) :
הגדרה :ביטוי אריתמטי מורכב מ:
 .Iמספר טבעי .או
 .IIסוגריים המכילים בתוכם (1) :ביטוי אריתמטי (2) ,אופרטור )שעשוי להיות אחד
מהבאים (3) ,(% ,/ ,* ,- ,+ :ביטוי אריתמטי.
ראשית ,בדקו לעצמכם ששלושת הביטויים הנ"ל עונים על ההגדרה.
התכנית שנכתוב תבצע את הפעולות הבאות:
 .Iהתכנית תקרא מהקלט ביטוי אריתמטי ותבנה ממנו עץ ביטוי על-פי אלגוריתם
שיתואר בהמשך.
 .IIהתכנית תעריך את הביטוי תוך שימוש בעץ הביטוי שנבנה.
התכנית תשתמש בפונקציה שנהוג לקרוא בשם ) tokenizerואשר תיכתב ,כמובן ,על-
ידכם( .באחריותה של פונקצית ה tokenizer -יהיה לקרוא את הקלט ,ולהחזיר ,בכל
פעם שהיא נקראת ,את האסימון ) (tokenהבא בקלט .אסימון הוא מרכיב אטומי
כלשהו בקלט .בדוגמה שלנו האסימונים האפשריים הינם :סוגר שמאלי ,סוגר ימני,
אחד מחמשת האופרטורים ,מספר טבעי .כדי להחזיר את האסימון הדרוש נשתמש
במבנה שיוגדר באופן הבא לערך:
{ struct op_op
bool oprnd_oprtr ; // which of the other 2 fields is
; char oprnd
// in use
; unsigned int oprtr
; }
אלגוריתם לבניית עץ ביטוי:
קרא שוב ושוב ל tokenizer -עד קריאת הביטוי השלם .על-פי הערך המוחזר על-ידי
ה tokenizer -בצע:
 .Iאם אותר מספר אזי החזר מצביע לשורש עץ הכולל את המספר שנקרא בלבד.
 .IIאם הוחזר סוגר פותח )סוגר שמאלי( אזי:
 .1קרא רקורסיבית לאלגוריתם ,והכנס את הערך המוחזר למצביע שיהיה בן
שמאלי של הצומת שמצביע אליו תחזיר.
 .2קרא ל tokenizer -שיחזיר את האופרטור שישכון בשורש העץ שמצביע אליו
תחזיר.
 .3קרא רקורסיבית לאלגוריתם ,והכנס את הערך המוחזר למצביע שיהיה בן
ימני של הצומת שמצביע אליו תחזיר.
 .4קרא ל tokenizer -שיחזיר את הסוגר הימני הסוגר את הסוגר השמאלי
שנקרא בתחילת סעיף ב'.
 .5החזר מצביע לעץ שמבנהו תואר בסעיפים  1עד .3
166
לדוגמה :עבור הביטוי ))(2*(5+3יבנה עץ הבא:
*
+
3
2
5
בהינתן עץ ביטוי אתם מוזמנים לחשוב בעצמכם כיצד ניתן להעריכו ,כדי לקבל את
ערכו של הביטוי .בדוגמה שלנו ערכו של הביטוי הוא . (2*(5+3))=16
 13.14.10תרגיל עשירי :חישוב מספר עצי החיפוש בני  nצמתים
כתבו פונקציה המקבלת מספר טבעי  ,nומחזירה את מספר עצי החיפוש הבינאריים
השונים בני  nצמתים ,עם  nערכים שונים .1..n
דוגמה:
 .Iעבור  n=0יוחזר הערך אחד ,שכן יש רק עץ חיפוש בינארי ריק יחיד.
 .IIעבור  n=1יוחזר הערך אחד ,שכן יש רק עץ חיפוש בינארי יחיד המכיל צומת אחד
 .IIIעבור  n=2יוחזר הערך שתיים ,שכן קיימים שני עצי חיפוש המכילים שני ערכים
שונים:
 .IVעבור  n=3יוחזר הערך חמש.
רמז:
 .Iכל אחד מ n -הערכים עשוי להיות בשורש העץ ,וכל שורש כזה מגדיר ,כמובן ,עץ
שונה.
 .IIבעקבות ההחלטה כי בשורש ישכון הערך  ,xנקבע גם גודלו של הבן השמאלי,
והבן הימני של השורש.
 13.14.11תרגיל מספר אחד-עשר :איתור הצומת העמוק ביותר בעץ
כתבו פונקציה המקבלת מצביע לעץ בינארי ,ומחזירה מצביע לצומת העמוק ביותר
בעץ ,וכן את עומקו של צומת זה.
167
 13.14.12תרגיל מספר שניים-עשר :בדיקה האם עץ כמעט מלא
עץ בינארי נקרא כמעט מלא אם כל עליו נמצאים ברמה  dאו ברמה  ,d-1ואם לכל
צומת המקיים שיש לו צאצא ימני ברמה  ,iיש גם צאצא שמאלי ברמה זאת.
לדוגמה :השמאלי במין שני העצים הבאים הוא עץ כמעט מלא ,בעוד הימני אינו.
*
הסיבה לכך שהימני אינו כמעט מלא היא שלצומת המסומן בכוכב יש צאצא ימני
בעומק ארבע ,אך אין לו בן שמאלי בעומק זה.
כתבו פונקציה המקבלת מצביע לשורשו של עץ בינארי ,ומחזירה את הערך  trueאם
ורק אם העץ כמעט מלא.
 13.14.13תרגיל מספר שלושה-העשר :האם כל תת-עץ מכיל מספר זוגי
של צמתים
כתבו פונקציה המקבלת מצביע לשורשו של עץ בינארי .על הפונקציה להחזיר את
הערך  trueאם ורק אם כל תת-עץ מכיל מספר זוגי של צמתים ,וזאת מבלי לספור
את מספר הצמתים שבכל תת-עץ.
 13.14.14תרגיל מספר ארבעה-עשר :שמירת עומקם של עלי העץ במערך
כתבו פונקציה המקבלת מצביע לשורשו של עץ בינארי ,וכן מערך חד-ממדי של
משתנים בולאניים ,עליו ידוע כי תאיו אותחלו לערך .false
בתום ביצוע הפונקציה יכיל התא מספר  iבמערך את הערך  trueאם ורק אם בעץ
קיים עלה בעומק ) iעבור .(i = 0…N
168
 13.14.15תרגיל מספר חמישה-עשר :חיוט של עץ
עץ בינארי נקרא מחויט אם כל צומת בו מכיל גם מצביע לצומת שמצוי באותה רמה
כמו הצומת הנוכחי ,והינו השכן הימני של הצומת הנוכחי בעץ.
דוגמה לעץ מחויט:
החצים המקווקווים הם שמדגימים את החיוט.
כדי להחזיק עת מחויט נזדקק למבנה מעט שונה מזה המשמש אותנו בדרך כלל ,על-
כן נגדיר:
{ struct T_node
; int _data
; T_node *_left, * _right, *_brother
; }
כתבו פונקציה המקבלת מצביע מטיפוס *  t_nodeלשורשו של עץ בינארי שנבנה
 left,בכל צומת מחזיקים ערכים
אך טרם חוייט )המצביעים __right
תקינים ,אך ערכו של המצביע  brotherבכל צומת הוא  .(NULLעל הפונקציה
לחייט את העץ.
רמזים לפתרון:
א .הגדירו מבנה:
{ struct List_of_p_2_t_node
; struct T_node *the_p
; struct List_of_p_2_t_node *_next
; }
 .IIIבקרו בעץ רמה אחר רמה .בכל רמה בקרו כרגיל משמאל לימין .עת אתם
מבקרים ַבצמתים מרמה  lכלשהי:
מטיפוס
מאיברים
המורכבת
משורשרת
רשימה
ְבנו
.1
 .List_of_p_2_t_nodeכל תא ַברשימה יחזיק בראש וראשונה מצביע
לאיבר כלשהו ברמה  lבעץ ,וכן מצביע לאיבר הבא ברשימה המשורשרת.
יתוַסף בסופה.
את הרשימה המשורשרת בנו ְכתור ,כלומר כל איבר חדש ְ
השתמשו ברשימה המשורשרת כדי לחייט את העץ.
.2
169
עודכן 5/2010
 15מצביעים לפונקציות
בפרקים הקודמים ראינו כי מצביעים עשויים להורות על נתונים השמורים
במערכים ,רשימות או עצים .אמרנו כי למעשה עת משתנה מטיפוס מצביע מורה על
משתנה כלשהו הוא )המצביע( מכיל את כתובתו של המשתנה עליו המצביע מורה;
במילים אחרות ,עת אנו מציירים חץ הנשלח מהמצביע לאובייקט עליו המצביע
מורה ,אנו מדגימים בכך בצורה מוחשית ,וקלה יותר להבנה ,כי המצביע מכיל את
הכתובת המתאימה .בפרק הראשון אמרנו כי אחד ממאפייני מחשבי פון-נויימן,
בהם אנו עושים שימוש ,הוא שזיכרון יחיד מכיל הן תכניות ,והן נתונים עליהם
פועלות התכניות .הוספנו כי לכל תא בזיכרון יש כתובת; משמע הן לתא המכיל
נתונים יש כתובת ,והן לתא המכיל קטע תכנית .אם זה המצב ,אזי כפי שמצביע
עשוי להצביע על נתון ,הוא יכול להצביע על קטע תכנית .בפרק זה נכיר את
ההשתמעויות של אפשרות זאת.
 15.1דוגמה פשוטה ראשונה למצביע לפונקציה
נניח שברצוננו לכתוב תכנית אשר) :א( קוראת מהמשתמש האם ברצונו לקבל את
המינימום או המקסימום בין זוגות המספרים שהוא יזין בהמשך) ,ב( קוראת
מהמשתמש סדרת מספרים ,ועבור כל זוג מציגה את המינימום או המקסימום בין
שני המספרים שהוזנו ,על-פי הבקשה שהוזנה קודם לכן .אנו יכולים לכתוב את קטע
התכנית באופן הבא:
int min_max,
// 0 => min, 1 => max
; num1, num2
; cin >> min_max
; cin >> num1 >> num2
{ )while (num1 != 0 || num2 != 0
)if (min_max == 0
; )cout << min(num1, num2
; )else cout << max(num1, num2
; cin >> num1 >> num2
}
הגדרת הפונקציות  min, maxפשוטה ביותר:
{ )int min(int num1, int num2
; ) return( num1 < num2 ? num1 : num2
}
{ )int max(int num1, int num2
; ) return( num1 > num2 ? num1 : num2
}
בפרק זה נרצה להציג דרך חלופית ,אלגנטית יותר ,לכתיבת קטע הקוד .הדרך
החלופית תסתמך על שימוש במצביע לפונקציה.
בשפת  Cאנו רשאים להגדיר משתנה:
; )int (*func_ptr)(int, int
170
נסביר :הגדרנו משתנה בשם ) func_ptrוזהו כמובן שם שאנו בחרנו( .המשתנה
הינו מצביע )ולכן מופיעה כוכבית לפני שמו( אשר מסוגל להורות על פונקציה
המקבלת שני ערכים שלמים )ולכן בסוגריים המופיעים אחרי שם המצביע כתבנו
 ,(int, intומחזירה ערך שלם )ולכן לפני שם המשתנה כתבנו  .(intכלומר כפי
שמצביע 'רגיל' לא מצביע על כל טיפוס נתונים שהוא ,אלא עלינו להגדירו כמצביע ל-
 intאו מצביע ל ,struct Node -ולא ניתן לערבב בין סוגי מצביעים שונים
)מצביע ל int -לא יוכל להצביע על  ,(struct Nodeכך גם מצביע לפונ' לא יוכל
להצביע על כל פונ' שהיא ,אלא בהגדרתו עלינו לקבוע על אילו סוגים של פונ'
המצביע ידע להצביע :על פונ' שבהכרח מקבלות א' ,ב' ,ג' ,ומחזירות ד'.
באופן דומה כדי להגדיר מצביע בשם  fpשידע להורות על פונקציות אשר מקבלות
מצביע ל ,float -ופרמטר הפניה מטיפוס תו ,ומחזירות ערך בולאני נכתוב:
; )& bool (*fp)(float *, char
נניח ,אם כן ,כי הגדרנו משתנה  func_ptrכנ"ל ,וכן משתנים שלמים
 . num2, min_maxקטע הקוד המשופר שנציג הינו:
num1,
; cin >> min >> max
)if (min_max == 0
; func_ptr = min
else
; func_ptr = max
; cin >> num1 >> num2
{ )while (num1 != 0 || num2 != 0
; )cout << func_ptr(num1, num2
; cin >> num1 >> num2
}
נסביר :ההשמה ; func_ptr = minמכניסה ַלמצביע  func_ptrאת כתובתה
של הפונקציה ) minכפי שהופיעה קודם( .במילים אחרות ,ההשמה הנ"ל גורמת
למצביע להצביע על הפונקציה  .minאחרי שהפננו את המצביע להורות על הפונקציה
הפקודה:
את
לכתוב
הלולאה,
בגוף
יכולים,
אנו
;) cout << func_ptr(num1, num2ופקודה זאת מציגה את הערך שמחזירה
הפונקציה  minעת היא מזומנת עם  . num1, num2כלומר  func_ptrהפך,
בעקבות ביצוע ההשמה ,לשם נרדף לפונקציה  ,minובכל מקום בו מופיע שם
המשתנה  func_prtמבין המחשב כי אנו מזמנים את הפונקציה .min
אעיר כי במקום לכתוב func_ptr = min ; :ניתן ,ויש האומרים שגם עדיף,
לכתוב func_ptr = &min ; :כדי להדגיש את השימוש במצביעים .הן אם
ביצענו את ההשמה באופן הראשון ,ובין אם ביצענו אותה באופן השני ,את זימון
הפונ' עליה מצביע  func_ptrנוכל עשות באופןfunc_ptr(num1, num2) ; :
)כפי שהופיע בדוגמה מעל( או באופן . (*func_ptr)(num1, num2) ; :כמובן
שכדאי להיות עקביים ואם השתמשנו ב & -להשתמש גם ב ,* -ולהיפך ,אך מבחינת
הקומפיילר זה לא הכרחי.
171
 15.2דוגמה שניה למצביע לפונקציה
הדוגמה הקודמת שראינו נועדה לשם הכרות ראשונית עם המצביעים לפונקציות.
ככזאת ,הייתה זאת דוגמה פשוטה ומעט מאולצת של שימוש במצביעים
לפונקציות .עתה נרצה להציג דוגמה טובה יותר לחשיבותו של הכלי.
נניח כי בתכנית כלשהי אנו מטפלים בנתוני תלמידים .לשם כך אנו מגדירים את
המבנה:
{ struct Stud
;]char _last[NAME_LEN], _first[NAME_LEN
; bool _gender
; unsigned int _shoe_num
; ]unsigned int _grades[MAX_GRADE
; }
כפי שניתן לראות מהגדרת ה ,struct -עבור כל תלמיד ברצוננו לשמור את שמו
הפרטי ) ,(_firstשם משפחתו ) ,(_lastמינו ) ,(_genderמספר נעליו
) ,(_shoe_numומערך של ציונים ).(_grades
אחר ,בתכנית הראשית ,נגדיר מערך של נתוני תלמידים:
ַ
; ]stud my_class[CLASS_SIZE
נניח כי התכנית קראה נתונים לתוך המערך ,ועתה המשתמש יכול לבחור האם
ברצונו למיין את המערך:
א .על-פי שם המשפחה והשם הפרטי.
ב .על-פי מספר הנעליים.
ג .על-פי ממוצע הציונים.
לשדות על-פיהם אנו ממיינים את הרשומות נקרא מפתח המיון.
כדי לציין מהו מפתח המיון הרצוי נגדיר טיפוס:
enum sort_by_t {LAST_FIRST_SRT,
SHOE_NUM_SRT,
; }AVG_GRADE_SRT
נניח כי תכניתנו כוללת פונקציה  read_sort_keyאשר מחזירה אחד משלושת
הערכים הנ"ל ,על-פי בקשתו של המשתמש.
עתה ,כדי למיין את המערך )מקטן לגדול( עלינו להשוות בין הרשומות בו על-פי
השדות הרצויים )כלומר על-פי מפתח המיון( .לדוגמה ,אם יש למיין על-פי שם
משפחה ושם פרטי אזי המבנה של  cohen yosiצריך להופיע במערך לפני המבנה
של  ;levi danaאולם אם יש למיין את התלמידים על-פי מספר הנעליים אזי יוסי
כֹהן שמספר נעליו  49צריך להופיע אחרי דנה לוי שמספר נעליה הוא  ;19ואם יש
למיין את הנתונים על-פי ממוצע הציונים אזי מי משניהם שממוצע ציוניו גבוה יותר
צריך להופיע אחרי מי שממוצע ציוניו נמוך יותר.
15.2.1אפשרות א' לכתיבת פונקצית המיון :שימוש בדגל בלבד
לשם הפשטות נניח כי ברצוננו למיין את המערך תוך שימוש במיון בועות )אם כי
העקרונות שנציג ישימים גם ַלמיונים היעילים יותר( .האפשרות הראשונה ,והפחות
מוצלחת ,לכתיבת פונקצית המיון תסתמך על הרעיון הבא :פונקצית המיון תקבל
ְפרט למערך התלמידים פרמטר נוסף ,באמצעותו נאותת לפונקציה על-פי אילו שדות
172
פי מפתח- הפונקציה תשווה בין כל זוג רשומות במערך על.עליה למיין את המערך
. ותחליף ביניהן אם הן מפרות את סדר המיון,המיון
:הקריאה לפונקצית המיון מהתכנית הראשית תעשה בהקשר כדוגמת הבא
sort_by_t key = read_sort_key() ;
bubble_sort(my_class, key) ;
מזמנים את הפונקציה אשר קוראת
ְ
ַבפקודה הראשונה מבין השתיים אנו
,פי אילו שדות ברצונו למיין את רשומות המערך; בפקודה השניה-מהמשתמש על
, אנו מעבירים ַלפונקציה את המערך שעליה למיין.אנו מזמנים את פונקצית המיון
: פונקצית המיון תראה.ואת מציין מפתח המיון הרצוי
void bubble_sort( stud my_class[], sort_by_t key) {
for (int round = 0; round < CLASS_SIZE -1; round++)
for (int i =0; i < CLASS_SIZE – round –1; i++)
{
bool need_to_swap = false ; // should class[i], class[i+1]
// be swapped
switch (key) { // check if class[i], class[i+1]
// violate the correct order
// according to the sorting key
case LAST_FIST:
if ( ( strcmp(my_class[i]._last,
my_class[i+1]._last)> 0) ||
( strcmp(my_class[i]._last,
my_class[i+1]._last)==0 &&
strcmp(my_class[i]._first,
my_class[i+1]._first)> 0))
need_to_swap = true ;
break ;
case SHOE_NUM:
need_to_swap =
my_class[i]._shoe_num >
my_class[i+1]._shoe_num ;
break ;
case AVG_GRADE:
need_to_swap =
avg(my_class[i]._grades) >
avg(my_class[i+1].grades) ;
break ;
}
if (need_to_swap)
swap(my_class[i], my_class[i+1]) ;
}
}
unsigned int avg(const unsigned int grades[MAX_GRADES]) {
int sum = 0 ;
for (int i=0; i < MAX_GRADES; i++)
sum += grades[i] ;
return( sum / MAX_GRADES ) ;
}
void swap( stud &s1, stud &s2) {
stud temp = s1 ;
s1 = s2 ;
s2 = temp ;
}
173
הסבר הפונקציה  :bubbble_sortכדרכו של מיון בועות ,הפונקציה כוללת לולאה
כפולה .בכל סיבוב בלולאה הפנימית מחליפים או לא מחליפים בין שתי רשומות
סמוכות .קטע הקוד הכולל את פקודת ה ,switch -והמהווה את עיקר הפונקציה,
בודק האם על-פי מפתח המיון יש צורך להחליף בין שתי הרשומות הסמוכות,
] my_class[iו .my_class[i+1] -ההחלטה האם יש להחליף בין שתי
הרשומות או לא נשמרת ַבמשתנה הבולאני  .need_to_swapבמידה וערכו של
המשתנה הוא  trueאזי אנו מחליפים בין שתי הרשומות.
הסתייגותנו מקוד זה היא שאם בעתיד נרצה למיין את הרשומות על-פי מפתח מיון
אחר )למשל נרצה למיין את הרשומות בסדר יורד של השמות ]מגדול לקטן[ ,או
נרצה למיינן לפי שדה המין ,דווקא( אזי יהיה עלינו לשוב לפונ' המיון ולעדכנה .מצב
זה אינו רצוי :היינו מעדיפים לכתוב את פונ' המיון פעם אחת ולתמיד ,ולא להצטרך
לשנותה גם עת נוצר הצורך למיין את המערך בסידור שונה כלשהו.
15.2.2אפשרות ב' לכתיבת פונקצית המיון :שימוש בפונקציות
השוואה ובדגל
עתה נציג אפשרות אלגנטית יותר לכתיבת פונקצית המיון .עבור אפשרות זאת נגדיר
פונקציַת השוואה תשווה בין זוג רשומות
ְ
שלוש פונקציות השוואה בין רשומות .כל
על-פי מפתח מיון מסוים .פונקציות ההשוואה תכתבנה על-פי אותו עיקרון כמו
 ,strlenכלומר אם ערכו של הפרמטר הראשון קטן )על-פי מפתח המיון( מערכו של
הפרמטר השני אזי יוחזר ערך שלילי; אם ,לעומת זאת ,שתי הרשומות שוות
)מבחינת ערכו של מפתח המיון( אזי יוחזר ערך אפס; ולבסוף ,אם הרשומה השניה
גדולה מהראשונה אזי יוחזר ערך חיובי .נציג ,ראשית ,את פונקציות ההשוואה:
int comp_by_last_first(const struct Stud &s1,
{ )const sturct Stud &s2
; )int temp = strcmp(s1._last, s2._last
; if (temp != 0) return temp
; )return strcmp(s1._first, s2._first
}
//-----------------------------------------------int comp_by_shoe_num(const struct Stud &s1,
{ )const sturct Stud &s2
)if (s1._shoe_num < s2._shoe_num
; return –1
)if (s1._shoe_num == s2._shoe_num
; return 0
)if (s1._shoe_num > s2._shoe_num
; return 1
}
//-----------------------------------------------int comp_by_avg_grade(const struct Stud &s1,
{ )const sturct Stud &s2
unsigned int avg1 = avg(s1._grades),
; )avg2 = avg(s2._grades
; is (avg1 > avg2) return –1
174
; if (avg1 == avg2) return 0
; if (avg1 < avg2) return 1
}
הסבר :נסביר לדוגמה את  ,comp_by_last_firstשתי הפונקציות האחרות
דומות לה למדי .ראשית ,אנו משווים בין שני שמות המשפחה ,כפי שהם שמורים ב-
 . s1._last, s2._lastאת תוצאת ההשוואה אנו שומרים במשתנה העזר
 .tempבמידה והשם הראשון קטן מהשני )כלומר  strcmpמחזירה ערך שלילי( ,או
שהשם השני גדול מהראשון )ואז ערכו של  tempחיובי( אזי כבר שם המשפחה קובע
את יחס הסדר בין שתי הרשומות ,וניתן להחזיר את ערכו של  .tempאם ,לעומת
זאת ,ערכו של  tempהוא אפס ,סימן ששני שמות המשפחה זהים ,ועל כן יש להחזיר
את תוצאת ההשוואה בין שני השמות הפרטיים של זוג התלמידים; וזאת אנו
עושים.
הערה נוספת :מדוע הגדרנו את הפרמטרים לפונקציות ההשוואה באופןconst :
 . struct Stud &s1מדוע לא העברנו את המבנים כפרמטרי ערך? מדוע נזקקנו
לשימוש במילת המפתח  ?constהסיבה לכך היא שמבנה עשוי להיות גדול )למשל
אם מערך הציונים בו גדול( ,במקרה כזה העברתו כפרמטר ערך תחייב השקעה של
זמן ,והקצאה של זיכרון ,שכן על המחשב יהיה להעתיק את ערכו של הארגומנט על
שטח הזיכרון המוקצה לפרמטר .כדי לחסוך בזמן ובזיכרון נוהגים להעביר מבנים
כפרמטרים משתנים ,ואז נשלח רק 'חץ' מהפרמטר ַלארגומנט המתאים ,ואין צורך
בהשקעת זמן וזיכרון מרובים .עתה משהפכנו את הפרמטר לפרמטר משתנה יש
תפגע בנתונים המועברים
חשש שהפונקציה ,אם תיכתב על-ידי מתכנת לא אחראיְ ,
לה; כדי למנוע זאת הוספנו לפני טיפוס הפרמטר את הקביעה שהפרמטר הינו קבוע,
ולא ניתן לשנותו.
את פונקצית המיון נוכל לכתוב תוך שימוש בפונקציות ההשוואה באופן יותר נקי:
{ )void bubble_sort(struct Stud my_class[], sort_by_t key
)for (int round = 0; round < CLASS_SIZE -1; round++
)for (int i =0; i < CLASS_SIZE – round –1; i++
{
]bool need_to_swap = false ; // should class[i], class[i+1
// be swapped
]switch (key) { // check if class[i], class[i+1
// violate the correct order
// according to the sorting key
case LAST_FIST_SRT:
if ( comp_by_last_first(my_class[i],
)my_class[i+1]) > 0
; need_to_swap = true
; break
case SHOE_NUM_SRT:
)if ( comp_by_shoe_num(my_class[i], my_class[i+1]) > 0
; need_to_swap = true
; break
case AVG_GRADE_SRT:
)if ( comp_by_avg_grade(my_class[i], my_class[i+1])> 0
; need_to_swap = true
; break
}
)if (need_to_swap
; )]swap(my_class[i], my_class[i+1
}
175
}
פונקציַת המיון הנוכחית שונה מקודמתה בפקודת הַ .switch -בפונקציה
ְ
הסבר:
הנוכחית ,על-פי ערכו של הדגל  ,keyאנו מזמנים את פונקצית ההשוואה הדרושה,
ומעדכנים את ערכו של המשתנה  need_to_swapעל-פי הערך שמחזירה פונקצית
ההשוואה .באופן זה פונקצית המיון הפכה מסודרת יותר ,במילים אחרות רגולרית
יותר .התוצאה היא שבמידה ובעתיד נרצה למיין את התלמידים על-פי מפתח מיון
שונה קל יהיה לנו יותר לעדכן את פונקצית המיון.
15.2.3אפשרות ג' לכתיבת פונקצית המיון :שימוש במצביע לפונקציה
עתה נציג את האפשרות האלגנטית ביותר להשלמת המשימה .אפשרות זאת
תשתמש במצביע לפונקציה.
בתכנית הראשית נגדיר את המשתנה:
int (*comp_func_ptr)(const struct Stud &,
; )& const struct Stud
כלומר הגדרנו מצביע לפונקציה .המצביע ידע להצביע על פונקציות שמקבלות שני
מבנים מטיפוס  Studכפרמטרי הפניה קבועים ,ומחזירות ערך שלם .במילים
פשוטות ,המצביע ידע להצביע על כל אחת משלוש פונקציות ההשוואה שכתבנו.
פרט לכך תכיל התכנית אותן הגדרות כפי שהצגנו קודם לכן.
התכנית הראשית תראה עתה לערך כך:
; )read_data(my_class
; )(sort_by key = read_sort_order
{ )switch (key
case LAST_FIRST_SRT :
;comp_func_ptr = comp_by_last_first
; break
case SHOE_NUM_SRT :
; comp_func_ptr = comp_by_shoe_num
; break
case GRADES_SRT :
; comp_func_ptr = comp_by_avg_grade
; break
}
; )bubble_sort(my_class, comp_func_ptr
מה עשינו עד כה )בתכנית הראשית(? את בחירת פונקצית ההשוואה העברנו לחלוטין
לתכנית הראשית .כפי שתיראו בהמשך ,פונקצית המיון פשוט תפעיל את פונקצית
ההשוואה שמועברת לה ) ַבפרמטר השני( ,היא אינה מתעניינת כלל בפונקצית
ההשוואה .מבחינת פונקצית המיון ,פונקצית ההשוואה הפכה להיות קופסה שחורה
שרק מחזירה ערך רצוי ,ועל-פי הערך המוחזר פונקצית המיון מחליפה ,או לא
מחליפה ,ביו רשומות .אם בעתיד נרצה למיין את הרשומות על-פי מפתח מיון שונה,
לא נאלץ לשנות כלל את פונקצית המיון ,וזו תכונה רצויה ביותר של פונקצית מיון
אשר אמורה להיות כללית ככל שניתן ,ולמיין כל מה שמעבירים לה.
176
נציג את פונקצית המיון:
void bubble_sort( struct Stud my_class[],
int (*comp_func_ptr)(const struct Stud &,
{ ))& const struct Stud
)for (int round = 0; round < CLASS_SIZE -1; round++
)for (int i =0; i < CLASS_SIZE – round –1; i++
{
)if (comp_func_ptr(my_class[i], my_class[i+1]) > 0
; )]swap(my_class[i], my_class[i+1
}
}
הסבר :פונקצית המיון מקבלת שני פרמטרים :מערך של מבנים אותו עליה למיין,
ומצביע לפונקציה )אשר מקבלת שני מבנים מסוג  studכפרמטרים משתנים
קבועים ,ומחזירה ערך שלם( .פונקצית המיון מתנהלת כמו כל מיון בועות :היא
כוללת לולאה כפולה .בכל סיבוב בלולאה אנו משווים בין שני תאים סמוכים ַבמערך
ובמידת הצורך מחליפים ביניהם .כיצד אנו יודעים האם יש להחליף בין שני התאים
הסמוכים או לא? על-פי מה שמחזירה לנו פונקצית ההשוואה המועברת בפרמטר
השני :אם היא מחזירה ערך חיובי ,סימן שעל פי מפתח המיון הרשומה
] my_class[iגדולה מהרשומה ] ,my_class[i+1ועל-כן יש להחליף בין
הרשומות .באחריות מי שקורא לפונקצית המיון להעביר לה פונקצית השוואה
מתאימה .פשוט ,נקי ואלגנטי ,האין זאת?
בדוגמה האחרונה ראינו כי את המשתנה  comp_func_ptrאנו מפנים להצביע על
כגון:
השמה
בעזרת
הרצויה
הפונ'
; . comp_func_ptr = comp_by_last_firstאעיר כי ,למרות שהדבר עשוי
באופן:
גם
ההשמה
את
לכתוב
ניתן
מפתיע,
להיראות
; comp_func_ptr = &comp_by_last_firstכלומר הוספנו & לפני שם
הפונ' .יש המעדיפים צורת כתיבה זאת המדגישה שאנו עוסקים כאן במצביעים.
הפרוטוטיפ של )( bubble_sortנותר זהה בשני המקרים .בלי תלות באופן בו
ביצענו את ההשמה הקודמת ,בגוף הפונ' )( bubble_sortעת אנו מזמנים את פונ'
ההשוואה הדרושה ,אנו יכולים לעשות זאת באחת משתי צורות הכתיבה:
)] comp_func_ptr(my_class[i], my_class[i+1כפי שמופיע בדוגמה
מעל ,או (*comp_func_ptr)(my_class[i], my_class[i+1]) :כדי
להדגיש שמדובר במצביע )לפונ'(.
כאשר פונ'  fמקבלת פרמטר  pfשהינו מצביע לפונ' כלשהי ,ניתן בעת הזימון של f
להעביר לה בארגומנט המתאים ערך  ,NULLכלומר לא להעביר שום פונ' שהיא.
כמובן שאז אל ל f -לזמן את הפונ'  .pfייתכן כי במצב בו מועבר ל f -הערך NULL
היא תזמן פונ' קבועה כלשהי )לא כזו שמועברת לה כפרמטר(.
המונח  callback functionמתאר פונ' המזומנת באמצעות מצביע אליה שמועבר לפונ'
שניה; לפיכך הדוגמה האחרונה שכתבנו הינה .callback function
 15.3הדפסת מערך תוך שימוש במצביע לפונ'
אציג עתה דוגמה נוספת לשימוש במצביע לפונ' .הדוגמה ,כמו גם זו שמופיעה בסעיף
הבא ,אינה מחדשת דבר מעבר למה שכבר ראינו .הדוגמות רק מציגות שוב את אותו
נושא.
נניח שבכמה מקומות בתכנית עלינו להדפיס מערך של מספרים שלמים .אולם
במקומות השונים יש להדפיס את נתוני המערך באופן מעט שונה:
א .במקום אחד יש להפרידם באמצעות פסיקים.
177
ב .במקום אחר יש להדפיסם כך שלפני כל נתון יופיע התו < אם הוא גדול מקודמו,
יופיע התו = אם הוא שווה לקודמו ,ויופיע התו > אם הוא קטן מקודמו.
ג .במקום שלישי בתכנית יש להדפיס לצד כל נתון גם את מספר התא במערך בו
הוא מצוי.
על כן נרצה פונ' הדפסה אחת ,אשר מפעילה בכל שלושת המקרים אותו מנגנון
בקרה :ריצה על-פני המערך ,אך מזמנת בכל אחד משלושת המקרים פונ' אחרת
להדפסת הערך המצוי בתא שיש להדפיס בכל סיבוב בלולאה.
נכתוב את הפונ' באופן דומה לגרסה המתקדמת ביותר של פונ' המיון כפי שראינו
בסעיף הקודם ,כלומר פונ' הדפסת המערך תקבל מצביע לפונ' אשר יודעת להדפיס
תא בודד במערך.
נכין לנו ,על כן ,שלוש פונ' בעלות אותו פרוטוטיפ ,שכל אחת מהן מדפיסה תא
במערך על פי הדרישות שהצבנו קודם לכן:
{ )void print_cell_comma(const int arr[], int index
; " cout << arr[index] << ",
}
//---------------------------------------------------{ )void print_cell_sign(const int arr[], int index
; ' ' = char sign
)if (index > 0
)]if (arr[index] > arr[index -1
; '>' = char
)]else if (arr[index] == arr[index -1
; '=' = sign
else
'<' = sign
; " " << ]cout << sign << arr[index
}
//---------------------------------------------------{ )void print_cell_index(const int arr[], int index
; " cout << index << "=" << arr[index] << ",
}
שלוש הפונ' מקבלות את המערך ,ואת מספר התא הרצוי .כל אחת מהן מדפיסה את
ערכו של התא המבוקש בפורמט שהגדרנו.
עתה נכתוב את פונ' הדפסת המערך ,אשר עושה שימוש בפונ' הדפסת התא שהועברה
לה:
void print_arr(const int arr[],
))void (*print_cell_func)(const int*, int
{
)for (int i=0; i < N ; i++
; )print_cell_func(arr, i
}
178
נסביר :הפונ'  print_arrמקבלת את המערך שעליה להדפיס )באמצעות הפרמטר:
][ ,(const int arrומצביע לפונ' הדפסת תא בודד במערך .הפרמטר השני של
הפונ'  print_arrהוא מצביע לפונ' .שמו של הפרמטר השני הוא
 .print_cell_funcהפרמטר מצביע על פונקציות שמקבלות מצביע קבוע למערך
הביטוי:
לנו
מורה
)וזאת
שלם
וערך
) ) ((const int*, intומחזירות כלום )וזאת מורה לנו המילה  voidשלפני שם
הפרמטר ,והכוכבית שלצדו (.
הפונ' רצה סידרתית על תאי המערך ,ועבור כל תא מזמנת את הפונ' שהועברה לה
כפרמטר )ושמורה בפרמטר  (print_cell_funcומעבירה לאותה פונ' את המערך
השלם ,ואת מספר התא הרצוי.
בהנחה שבתכנית הוגדר מערך:
; ]int int_arr[N
והוזנו לתוכו נתונים ,נוכל לזמן את פונ' ההדפסה בכל אחד משלושת האופנים
הבאים:
; )print_arr(int_arr, print_cell_comma
; )print_arr(int_arr, print_cell_sign
; )print_arr(int_arr, print_cell_index
בכל אחד משלושת הזימונים אנו מעבירים לפונ' את המערך ,ומצביע לפונ' שונה
להדפסת תא במערך.
 15.4מציאת הערך המזערי של פונ' בעזרת מצביע לפונ'
נניח כי בתכנית הוגדרו מספר פונ' ממשיות של משתנה אחד )במובן המתמטי של
המילה( ,כלומר פונ' המקבלות ערך ממשי  xומחזירות את ערך ה y -הממשי
המתאים לו .לדוגמה:
{ )double f1(int x
; return 3*x + 2
}
//--------------------------------{ )double f2(int x
; return 3*x*x -5*x + 4
}
//--------------------------------{ )double f3(int x
; )return 3*sin(x) – 2*cos(x
}
//---------------------------------
במקומות שונים בתכנית ברצונו למצוא את ערך המינימום של הפונקציות הנ"ל
בקטעים שונים של הישר .לדוגמה :ברצוננו למצוא את המינימום של  f1בקטע
] , [-3, 2או שברצוננו למצוא את המינימום של  f3בקטע ] ,[0, 5וכן הלאה.
כמו כן ,נרצה לפתור את הבעיה לא בשיטות אנליטיות ,ע"י גזירת כל אחת מהפונ'
ובדיקת הנגזרת ,אלא בשיטות חישוביות :בהינתן פונ'  fוקטע שבין  x0ל ,x1 -נרצה
179
לחשב את ערכה של  fב N -נקודות בקטע )עבור  Nשהינו קבוע של התכנית(:
הנקודות:
x0, x0 +(x1-x0)/N, x0 +(x1-x0)/N*2, ... , x1
וכך לאתר את המינימום של הפונ'.
נכתוב ,על כן ,פונ' )שתקרא  (find_minהמקבלת מצביע לפונ' כדוגמת שלוש הפונ'
שהגדרנו מעל )פרמטר זה יקרא  ,(func_ptrושני ערכים ממשיים המציינים קטע
של הישר ) .(x0, x1הפונ'  find_minתזמן את  N func_ptrפעמים ,עם N
נקודות בקטע )כפי שתואר מעל( ,ותחזיר את הערך המזערי שהוחזר ע"י
func_ptrבכל  Nהקריאות .הקוד:
double find_min( double (*func_ptr)(double),
)double x0, double x1
{
double min = func_ptr(x0),
; delta = (x1 – x0)/N
)for (double x = x0 + delta; x <= x1; x+= delta
)if (func_ptr(x) < min
; )min = func_ptr(x
; return min
}
עתה,
כדי
למצוא
את
המינימיום
של
f3
בקטע
]1
[0,
נזמן:
).find_min(f3, 0, 1
למותר לציין שאם בעתיד נגדיר בתכנית פונ' ממשית נוספת במשתנה יחיד כלשהי,
ונרצה לאתר גם את המינימום שלה בקטע כלשהו ,נוכל לעשות זאת בנקל בלי
שנצטרך לשנות את  find_minשכתבנו עתה.
 15.5תרגילים
 15.3.1תרגיל מספר אחד :האם פונקציה אחת גדולה יותר משניה בתחום
כלשהו
נניח שבתכנית מסוימת קיימות מספר פונקציות שכל אחת מהן מקבלת שני
מספרים שלמים )נסמנם  xו (y-ומחזירה מספר שלם )נסמנו  .(zעליכם לכתוב
פונקציה  greaterהמקבלת שישה פרמטרים:
א .זוג פונקציות כנ"ל )נסמנן  f1ו.(f2-
ב .זוג ערכי גבול  x0ו x1-עבור .x
ג .זוג ערכי גבול  y0ו y1-עבור .y
על הפונקציה  greaterלקבוע האם עבור כל ערך  xכך ש ,x0 ≤ x ≤ x1-ועבור כל ערך
של  yכך ש f1 y0 ≤ y ≤ y1-הינה 'גדולה יותר' מ f2-במובן ש f1-מחזירה ערך גדול
יותר מהערך שמחזירה  f2עבור זוג הערכים .השגרה  greaterתבצע את משימתה
באופן הבא N :פעמים תגריל  greaterזוג ערכים  xוַ y-בתחום הרצוי ,ותקרא ל f1-ול-
 f2עבור זוג הערכים .במידה ובכל  Nהפעמים החזירה  f1ערך גדול יותר מכפי
שהחזירה  ,f2תסיק  greaterש f1-אכן גדולה מ f2-בתחום הנ"ל ותחזיר את הערך
 ,trueאם לפחות באחד המקרים לא החזירה  f1ערך גדול יותר מזה שהוחזר על-ידי
 f2תחזיר  greaterאת הערך .false
180
 15.3.2תרגיל מספר שתיים :מציאת שורש של פונקציה
נניח כי בתכנית כלשהי הוגדרו מספר פונקציות של משתנה ממשי יחיד )כלומר
כאלה המקבלות ארגומנט ממשי יחיד ,ומחזירות ערך ממשי יחיד( .כתבו פונקציה
המקבלת:
א .מצביע  fלפונקציה כנ"ל.
ב .ערך ממשי  x0המקיים כי ערכה של  fשלילי בנקודה .x0
ג .ערך ממשי  x1המקיים כי ערכה של  fחיובי בנקודה .x1
על הפונקציה שתכתבו לאתר שורש של .f
הפונקציה תפעל באופן הבא:
א .במידה ו f(x0) -אינו שלילי יוחזר ערך קטן מ.x0 -
ב .במידה ו f(x1) -אינו חיובי יוחזר ערך גדול מ.x1 -
ג .חזור על תהליך חיפוש בינארי:
 .1קבע.mid = (x0+x1)/2 :
 .2אם ערכו המוחלט של ) f(midקטן מ EPSILON -אזי החזר את הערך .mid
 .3אם  f(mid)>0אזי עדכן. x1 = mid :
 .4אחרת עדכן. x0 = mid :
181
עודכן 5/2010
 .16מצביעים גנריים )* (void
עת דנו במצביעים עד כה הדגשנו שמצביע הינו משתנה המכיל כתובת של משתנה
אחר .אולם אם נדייק אזי המצביע מכיל לא רק כתובת אלא גם את טיפוס הנתונים
עליהם הוא מצביע ,זו הסיבה שאנו מבחינים בין *  intל ,double * -לדוגמה; וזו גם
הסיבה שעת אנו כותבים cout << *ip :יודע המחשב כמה בתים בזיכרון עליו להציג,
וכיצד יש לפרש את הבתים הללו )כמספר שלם? כמספר ממשי? כמחרוזת?( .מאותה
סיבה ,עת אנו כותבים  ip++יודע המחשב כיצד לקדם את המצביע כך שהוא יצביע
על האיבר הבא במערך ,בין אם מדובר במערך של תווים )בו כל תא צורך בית יחיד(,
ובין אם מדובר במערך של מבנים )בו כל תא עשוי להיות גדול(.
בפרק זה נדון במצביעים גנריים ) (generic pointersכלומר כלליים ,כאלה שמסוגלים
להצביע לכל טיפוס של נתונים מצד אחד ,אולם טיפוס הנתונים המוצבע על-ידם
אינו ידוע להם מצד שני .במלים אחרות ,המצביע הגנרי כולל רק כתובת של תא
בזיכרון ,ולא כולל את טיפוס הנתונים השמור בבית זה )ובבתים העוקבים לו,
ומרכיבים יחד נתון בודד מטיפוס כלשהו( .על כן את טיפוס הנתונים יהיה עלינו
לשמור ,כך או אחרת בנפרד.
המצביעים הגנריים נותנים לנו אפשרות לטפל בטיפוסי נתונים רב-צורניים
) ,(polymorphic data typeכלומר לכתוב קוד שיטפל במגוון של טיפוסי נתונים .אולם,
וזו נקודה שמאוד חשוב להפנים ,כדי לטפל בטיפוסי הנתונים השונים יהיה עלינו
ראשית להמיר את המצביע הגנרי )חזרה( לכדי מצביע מטיפוס מסוים .אבהיר :נניח
משתנה ,או פרמטר , void *p :אזי  pיכול להצביע הן על משתנים מטיפוס  ,intהן על
משתנים מטיפוס  ,doubleוהן על משתנים מכל טיפוס שהוא .אולם ניסיון לבצע
פעולות כגון cout << *p; :או p++; :לא יתקמפל ,בדיוק מהסיבה שהמחשב אינו יודע
איזה סוג נתון עליו להדפיס עת יש פניה ל ,*p :ובאיזה שיעור יש להסיט עת  pעת
מבצעים . p++ :לכן ,כך או אחרת ,כדי להשתמש ַבמצביע ניאלץ ראשית להמירו
חזרה לטיפוס מסוים כלשהו ,ורק אז לעשות בו שימוש; כלומר לכתוב:
;  cout << * (int *) pאו ; p= (int *)p + 1; :כלומר יהי עלינו לשמור 'בצד' גם מידע
אודות טיפוס הנתונים עליהם מצביע  pעתה.
כדרכנו ,נתחיל מדוגמה פשוטה .נניח כי בתכניתנו הוגדרו המערכים:
; ]int iarr[N1
; ]double darr[N2
ואולי גם מערכים נוספים שתאיהם מטיפוסים שונים.
נניח כי ברצוננו לכתוב קטע קוד הקורא נתונים למערכים השונים .בכלים שעמדו
לרשותנו עד-היום נאלצנו לכתוב פונ' נפרדת עבור כל אחד משני המערכים השונים;
עתה נרצה ,בהדרגה ,לצמצם כפילות הזאת.
 16.1דוגמה ראשונה :פונ' המשתמשת במצביע גנרי
אציג תחילה אפשרות פשוטה יותר ,ומוצלחת פחות ,המשתמשת במצביע גנרי ,וע"י
כך מאפשרת לנו לכתוב פונ' יחידה לקריאת נתוני שני המערכים .על-פי גישה זאת,
נכתוב פונ' המקבלת מצביע גנרי  void *arrאשר יוכל להורות על כל אחד משני
המערכים .בזאת צעדנו צעד קדימה ,במובן זה שתהיה לנו פונ' אחת ,שתקבל את
המערך בין אם מדובר במערך של שלמים ,ובין אם מדובר מערך של ממשיים )ובין
182
אם מדובר במערך של תאים מכל סוג שהוא( .פרמטר נוסף שיהיה מטיפוס char
יורה לפונ' על איזה טיפוס למעשה מצביע הפרמטר  ,arrכלומר לאיזה טיפוס יש
לעשות  castלפרמטר  .arrולבסוף ,אם לא כל המערכים מכילים אותו מספר תאים,
אזי פרמטר שלישי יחזיק את גודל המערך )כלומר כמה תאים הוא כולל(.
//-----------------------------------------------------{ )void read_data(void *arr, char type, int arr_size
)for (int i = 0; i < arr_size; i++
{ )switch (type
; ]case 'i' : std::cin >> ((int *) arr)[i
; break
; )case 'd' : std::cin >> ((double *) arr) +i
; break
default:
; "std::cerr << "Error: wrong type\n
; )exit(1
}
}
קוד הפונ' פשוט למדי :הפונ' מנהלת לולאה בה היא קוראת את הנתונים תא אחר
תא ,בכל סיבוב בלולאה פקודת ה switch -בודקת מהו טיפוס המערך ,ועל-פי ערכו
של הפרמטר  typeעושה  castלמצביע  arrלטיפוס המתאים .אחרי שהמצביע
הומר לטיפוס רצוי ,לדוגמה (int *) arr :ניתן להוסיף לו את  iבתור ההסטה
הרצויה ,כלומר התא המתאים במערך ,ומכיוון שעתה כבר מוגדר שהמערך הוא
מערך של שלמים גם ברור מה צריך להיות שיעור ההסטה בבתים כדי להגיע לתא
מספר  .iלכן הפעולה ((int *) arr) +i :מחזירה לנו מצביע לתא מספר i
במערך .לבסוף פניה ל *(((int *) arr) +i) :פונה לאותו תא ,ועל כןcin :
;) >> *(((int *) arr) +iקוראת נתונים לתא המתאים ְבמערך של
שלמים .לחילופין ,ניתן לקרוא לתא רצוי במערך בעזרת הכתיבה הפשוטה יותר:
] ; ((int *) arr)[iעתה ,המרנו את ) arrרק לצורך ביצוע הפעולה הנוכחית,
לא באופן גורף!( לכדי *  ,intולכן אנו יכולים לפנות לתא מספר  iבמערך עליו
 arrמצביע.
זימון הפונ' מהתכנית הראשית יעשה באופן הבא:
; )read_data(iarr, 'i', N1
; )read_data(darr, 'd', N2
מגבלתה של הפונ' שכתבנו היא שכדי להכלילה ,כך שהיא תהיה מסוגלת לקרוא
נתונים גם לתוך מערך של תווים עלינו לחזור לפונ' ,ולשנות את הקוד שלה :להוסיף
 caseנוסף בפקודת ה ,switch -ולכך איננו ששים :היינו רוצים לכתוב אחת
ולתמיד פונ' אשר תדע לקרוא נתונים לתוך כל מערך שהוא ,בלי שנצטרך לשנותה כל
פעם שנרצה להכלילה לטיפוס נתונים נוסף .נרצה שהשינויים ,או הרחבות ,יבוצעו
מחוץ לפונ' ,ע"י מי שקורא לפונ'.
 16.2דוגמה שניה ,משופרת :פונ' המשתמשת במצביע גנרי
עתה נציג גרסה שניה של פונ' המסוגלת לקרוא נתונים למערכים ממגוון טיפוסים.
כמו בגרסה הראשונה ,גם בגרסה השניה הפונ' תקבל מצביע גנרי  void *arrאשר
יורה על המערך הרצוי .אולם בגרסה זאת ,בניגוד לקודמתה ,לא נעביר פרמטר
שמציין את טיפוס המערך ,אלא נעביר מצביעים לזוג פונ':
א .פונ'  get_p2_wanted_cellהמקבלת את המצביע הגנרי ומספר של תא רצוי
במערך ,ומחזירה מצביע לאותו תא.
183
ב .פונ'  read_into_wanted_cellהמקבלת מצביע גנרי לתא רצוי במערך,
וקוראת נתונים לאותו תא אחרי שהיא ממירה את המצביע הגנרי לטיפוס רצוי.
באחריות מי שירצה לזמן את הפונ'  read_dataיהיה לספק גם שתי פונ' פשוטות
אלה .הפונ'  read_dataתעשה בהן שימוש :בכל סיבוב בלולאה היא תזמן את
הפונק'  get_p2_wanted_cellתעביר לה את המצביע לתחילת המערך ומספר
תא רצוי ,ותקבל חזרה מצביע גנרי אך לתא הרצוי; מצביע זה תעביר read_data
לפונק' ) read_into_wanted_cellאותה היא קיבלה כפרמטר( .הפונ'
 read_into_wanted_cellאותה סיפק מי שקרא ל ,read_data -כלומר מי
שיודע מהו טיפוסו האמיתי של המערך  ,arrתבצע למצביע הגנרי לתא הרצוי המרת
טיפוס לטיפוס 'האמיתי' ,ותקרא נתונים לתא המתאים.
נציג עתה את הקוד של :read_data
//------------------------------------------------------void read_data(void *arr,
void *(*get_p2_wanted_cell)(void *, int),
{ ) )* void (*read_into_wanted_cell)(void
{ )for (int i = 0; i < N; i++
; )void *cell = get_p2_wanted_cell(arr, i
; )read_into_wanted_cell(cell
}
}
נסביר :הפונ' מקבלת שלושה פרמטרים:
א .הפרמטר void *arr :הינו מצביע גנרי המורה על התא מספר אפס במערך.
ב .הפרמטר) void *(*get_p2_wanted_cell)(void *, int) :שמו הוא
 (get_p2_wanted_cellהוא מטיפוס מצביע לפונ' המקבלת שני פרמטרים:
) (void *, intהמציינים את המצביע לתחילת המערך ומספר תא רצוי;
הפונ' מחזירה *  :voidמצביע )גנרי( לתא המבוקש.
)* ) void (*read_into_wanted_cell)(voidשמו הוא
ג .הפרמטר:
 (read_into_wanted_cellהוא מצביע לפונ' המקבלת מצביע גנרי מסוג
*  ,voidומחזירה  .voidהפונ' עושה למצביע המרת טיפוס לטיפוס מסויים
כלשהו ,וקוראת נתונים לתוך התא עליו המצביע מורה.
גוף הפונ' כולל לולאה פשוטה :בכל סיבוב בלולאה נקראים נתונים לתוך תא יחיד
במערך )הפונ' מניחה שבמערך  Nתאים( .הדבר נעשה ע"י שראשית מזמנים את הפונ'
שהועברה כפרמטר  ,get_p2_wanted_cellושנית את הפונ' )שהועברה
כפרמטר(.read_into_wanted_cell :
יופיה של הפונ' ,לעומת זאת שהצגנו בסעיף הקודם ,הוא שהיא מסוגלת לקרוא
ערכים לתוך כל מערך שהוא )בן  Nתאים( ,ובלבד שהיא תקבל את הפרמטרים
הדרושים :מצביע לתחילת המערך ,מצביע לפונ' שיודעת להמיר מצביע גנרי  +מספר
תא במצביע לאותו תא ,ומצביע לפונ' שבהינתן תא יודעת לקרוא לתוכו נתונים.
למעשה  read_dataשלנו מספקת את הבקרה על התהליך :את הלולאה ,ומזמנת
את הפונ' הספציפיות לכל טיפוס וטיפוס.
כיצד ינהג מי שרוצה לזמן את הפונ' ?read_data
נניח שבתכנית הוגדר:
; ]int iarr[N
כדי שניתן יהיה להשתמש בפונ'  read_dataעלינו להגדיר עוד זוג פונ':
184
{ )void *get_wanted_int_cell(void *p, int i
; )return (void *)((int *)p + i
}
//------------------------------------------------------{ )void read_into_wanted_int_cell(void *p
; cin >> *(int *) p
}
הראשונה בין השתיים מחזירה מצביע גנרי לתא רצוי במערך ,והשניה מקבלת
מצביע גנרי ,ממירה אותו למצביע ל int -וקוראת נתונים לתא עליו המצביע מורה.
אחרי כתיבת שתי הפונ' הללו נוכל לזמן את :read_data
read_data(iarr,get_wanted_int_cell,
; )read_into_wanted_int_cell
נסביר :אנו מעבירים ל read_data -את המערך ,ואת שתי הפונ' הדרושות לה.
עתה נניח כי הגדרנו בתכניתנו:
{ struct Stud
; unsigned _id
; ]char _name[MAX_NAME_LEN
; }
; ]struct Stud studs[N
כיצד נוכל להשתמש בפונ'  read_dataשלנו על-מנת לקרוא נתונים גם למערך
?studs
ראשית ,כאמור עלינו לכתוב את שתי פונ' העזר:
האחת שמחזירה מצביע לתא רצוי במערך
{ )void *get_wanted_Stud_cell(void *p, int i
; )return (void *)(( struct Stud *)p + i
}
והשניה שקוראת נתונים לתוך תא רצוי:
//------------------------------------------------------{ )void read_into_wanted_Stud_cell(void *p
; cin >> (( struct Stud *) p) -> _id
>> )cin >> setw(MAX_NAME_LEN
; (( struct Stud *) p) -> _name
}
עתה נוכל לזמן את :read_data
read_data(studs, get_wanted_Stud_cell,
; )read_into_wanted_Stud_cell
כמובן שכדי להרחיב את  read_dataכך שהיא תקרא נתונים גם למערך של
תלמידים לא שינינו כלל את גוף הפונ'.
עת תלמדו תכנות מונחה עצמים תגלו שאת הפונ' הללו ,לטיפול במבנה הנתונים
 struct Studניתן לכלול בתוך הגדרת ה ,struct -וכך לקבל 'עצם' )(object
הכולל הן נתונים ,והן את הפונ' לטיפול בנתונים )בגישת התכנות מונחה העצמים
קוראים לפונ' אלה בדרך-כלל בשם 'שיטות'  .(methodsעת העצם מועבר כפרמטר
לפונ' מועברים הן הנתונים והן השיטות כאחת.
185
 16.3דוגמה שלישית :פונ' מיון גנרית
כפי שציינו בדוגמה הקודמת ,תכונתה של הפונ' הגנרית הינה שהיא מספקת את
מנגנון הבקרה )בדוגמה הקודמת זו הייתה לולאה פשוטה( ,ומשתמשת בפונ'
המועברות לה כדי למממש את הפעולות עצמן .בפונ' פשוטה כגון  read_dataמנגנון
הבקרה הוא פשוט למדי ,ועל-כן התועלת משימוש בפונ' גנרית מוגבלת .על-כן עתה
נציג משימה מורכבת יותר :מיון; בה תהליך הבקרה מורכב יותר ,וככזה השמוש
בפונ' גנרית נראה מוצדק יותר.
לשם הפשטות אציג מימוש של מיון בועות ,אולם לצורך ענייננו אין לכך כל חשיבות,
ומימוש של פונ' מיון יעילות יותר נעשה באופן דומה לגמרי.
כפי שאנו זוכרים)?( מיון בועות מריץ לולאה כפולה .בכל סיבוב בלולאה הוא משווה
בין שני תאים סמוכים במערך ,ובמידת הצורך )במידה והם מפרים את סדר המיון
הרצוי( האלג' מחליף בין ערכי התאים .על-כן ,פונ' המיון הגנרית שלנו תקבל את
הפרמטרים הבאים:
א .המערך אותו עליה למיין ).(void *arr,
ב .פונ' המקבלת מצביע לתחילת המערך ומספר של תא רצוי ,ומחזירה מצביע ַלתא
הקודמת.
בדוגמה
ראינו
כבר
כזו
)פונ'
)( void *(*get_p2_wanted_cell)(void *, int
ג .פונ' המקבלת מצביעים לשני תאים )סמוכים( במערך ,משווה בין שני התאים
במערך ,ומחזירה האם שני התאים בהתאמה עם הסדר הרצוי או לא.
))* ( bool (*cmp)(void *, void
ד .פונ' המקבלת מצביעים לזוג תאים )סמוכים( במערך ומחליפה בין ערכיהם
)* .void (*swap)(void *, void
נציג את קוד הפונ ואחר נסבירו:
//------------------------------------------------------void bubble_sort(void *arr,
void *(*get_p2_wanted_cell)(void *, int),
bool (*cmp)(void *, void *),
{ ))* void (*swap)(void *, void
)for (int round = 0; round < N -1; round++
{ )for (int place = 0; place < N -round -1; place++
if (!cmp(get_p2_wanted_cell(arr, place),
) ) )get_p2_wanted_cell(arr, place +1
swap(get_p2_wanted_cell(arr, place),
; ) )get_p2_wanted_cell(arr, place +1
}
}
הפונ' מנהלת את הלולאה הכפולה 'הרגילה' המורצת על-ידי מיון בועות .בכל סיבוב
בלולאה היא מזמנת את פונ' ההשוואה ) cmpאשר הועברה לה כפרמטר( על שני תאי
המערך ] arr[place], arr[place+1אשר מצביעים אליהם מחזירה לה הפונ'
 .get_p2_wanted_cellבמידה ופונ' ההשוואה מורה כי זוג התאים מפרים את
הסדר )כלומר )] ( if (!cmp(arr[place], arr[place+1אזי יש להחליף
בין ערכי התאים ,וזה נעשה ע"י הפונ' )שהועברה כפרמטר(  swapאשר מקבלת
מצביעים לשני התאים ,ומחליפה בין ערכיהם.
186
על-כן ,כדי להשתמש בפונ'  bubble_sortהנ"ל יש צורך לממש את הפונ'
הדרושות .נדגים כיצד נעשה הדבר ,ראשית עבור מערך של מספרים שלמים.
נניח שהגדנו:
; ]int idata[N
וקראנו נתונים למערך.
נציג את הפונ' אותן יש לממש כדי לזמן את פונ' מיון הבועות .ראשית ,את הפונ'
) void *get_wanted_int_cell(void *p, int iכבר ראינו ,והיא
שתועבר כארגומנט שני ל.bubble_sort -
עתה אציג את פונ' ההשוואה ,אותה נעביר כארגומנט שלישי:
//------------------------------------------------------{ )bool cmp_int( void *p1, void *p2
; return *(int *)p1 <= *(int *)p2
}
הפונ' ממירה את שני המצביעים הגנריים שהועברו לה לכדי מצביעים על  ,intואז
מחזירה את תוצאת ההשוואה בין השלם עליו מצביע הפרמטר הראשון ,למספר
השלם עליו מצביע הפרמטר השני.
פונ' ההחלפה ,המועברת כארגומנט הרביעי והאחרון ל bubble_sort -הינה:
//------------------------------------------------------{ )void swap_int(void *p1, void *p2
; int temp = *(int *) p1
; *(int *) p1 = *(int *)p2
; *(int *) p2 = temp
}
גם פונ' זו מקבלת שני מצביעים גנריים .היא ממירה אותם למצביעים ל,int -
ומכאן מעבירה ערכים בין תאי הזיכרון כפי שאנו כבר מכירים מימים ימימה.
עתה ,כשבאמתחתנו שלוש פונ' עזר הדרושות נוכל לזמן את  bubble_sortכדי
למיין את המערך :idata
bubble_sort(iarr, get_wanted_int_cell,
cmp_int,
; )swap_int
נניח שעתה ברצוננו למיין את המערך ,אולם כל שאנו רוצים הוא שהמספרים
השליליים ישכנו בתחילתו ,והחיוביים )או האי שליליים( בסופו .מה יהיה בדיוק
הסדר בין השליליים לבין עצמם ,או בין החיוביים לבין עצמם לא משנה לנו .כדי
להשיג מיון זה עלינו רק לכתוב פונ' השוואה חדשה .פונ' ההשוואה תקבע ששני
שליליים הם בהתאמה לסדר הדרוש ,ואין צורך להחליף ביניהם ,כך גם שני
חיוביים ,וכך גם שלילי וחיובי .הזוג היחיד שמפר את הסדר הוא עת מספר חיובי
מופיע לפני מספר שלילי ,ולכן רק במקרה זה על פונ' ההשוואה להחזיר ערך
 .falseהפונ' תכתב לפיכך:
//------------------------------------------------------{ )bool cmp_neg_pos( void *p1, void *p2
)if (*(int *)p1 >= 0 && *(int *)p2 < 0
; return false
; return true
}
עתה נוכל למיין את המערך שוב ,בלי שנכניס כל שינוי בפונ' המיון עצמה ,ע"י
שנעביר ל bubble_sort -את  cmp_neg_posכפונ' ההשוואה בה יש לעשות
שימוש:
187
bubble_sort(iarr, get_wanted_int_cell,
cmp_neg_pos,
; )swap_int
באופן דומה ,כדי ש bubble_sort -תוכל למיין מערך של ) struct Studכפי
שהוצג בסעיף הקודם( ,עלינו למממש את שלוש פונ' העזר הדרושות על משתנים
מטיפוס זה .את הפונ'  get_wanted_Stud_cellכבר מימשנו בסעיף הקודם.
את הפונ'  cmp_struct_Studנממש עתה .נניח שברצוננו למיין את התלמידים
על-פי מספר הזהות )שדה ה ,(id -ובין שני תלמידים להם אותו מספר זהות )ולרגע
נניח שהדבר אפשרי( ,נקבע את הסדר על-פי שדה השם ) .(nameעל כן פונ' ההשוואה
בין שני תלמידים תהיה:
{ )bool cmp_stuct_Stud( void *p1, void *p2
אם מספר הזהות של הראשון קטן משל השני החזר :true
< if ((struct Stud *)p1 -> _id
) (struct Stud *)p2 -> _id
; return true
אם מספרי הזהות שלהם שווים ,ושמו של הראשון קטן משמו של השני
אזי החזר :true
== _id
&& _id
*)p1 -> _name,
)*)p2 -> _name) <= 0
>if ((struct Stud *)p1 -
>(struct Stud *)p2 -
strcmp((struct Stud
(struct Stud
; return true
בכל מקרה אחר החזר :false
; return false
}
הפונ'  swap_struct_Studדומה מאוד לזו המחליפה שלמים:
//------------------------------------------------------{ )void swap_struct_Stud(void *p1, void *p2
; struct Stud temp = *( struct Stud *) p1
; *( struct Stud *) p1 = *( struct Stud *)p2
; *( struct Stud *) p2 = temp
}
עתה נוכל לזמן את :bubble_sort
bubble_sort(studs, get_wanted_ struct_Stud _cell,
cmp_struct_Stud,
; )swap_struct_Stud
כדי לא להעמיס על הדוגמות הקודמות הסרתי מהן את כל נושא הפרמטרים
הקבועים ) ,(constמעשה ,שכמובן ,לא ראוי לעשות .הפונ' המחזירה מצביע לתא
במערך ,כמו גם זו המשווה בין שני תאי מערך ,יכולה וצריכה להיכתב תוך שימוש
בפרמטרים קבועים .על-כן ,לסיום ,אציג את הפרוטוטיפים של הפונ' השונות כשהן
כתובות כהלכה ,ועם פרמטרים קבועים:
//------------------------------------------------------שתי הפונ' המחזירות תא במערך מקבלות :const void *p
; )void *get_wanted_int_cell(const void *p, int i
; )void *get_wanted_double_cell(const void *p, int i
בהתאמה ,גם  read_dataמקבלת כפרמטר שני פונ' המקבלת מצביע שהינו קבוע:
void read_data(void *arr,
188
)void *(*get_p2_wanted_cell
(const void *, int),
; ) )* void (*read_into_wanted_cell)(void
; )void read_into_wanted_int_cell(void *p
; )void read_into_wanted_double_cell(void *p
פונ' ההשוואה יכולות לקבל מצביע קבוע:
; )bool cmp_int(const void *p1,const void *p2
; )bool cmp_double(const void *p1,const void *p2
; )void swap_int(void *p1, void *p2
; )void swap_double(void *p1, void *p2
הפרוטוטיפ של מיון בועות משקף את הפרוטוטיפים של הפונ' שהוא מקבל:
void bubble_sort(void *arr,
)void *(*get_p2_wanted_cell
(const void *, int),
bool (*cmp)(const void *,const void *),
; ))* void (*swap)(void *, void
 16.4דוגמה שלישית :בניית רשימה מקושרת ממוינת גנרית
נניח שבתכנית הגדרנו מבנה בעזרתו נשמור נתוני תלמיד בודד )מספר הזהות שלו,
שמו ,ומינו( ,ומבנה שני בעזרתו נשמור נתוני קורס בודד )קוד הקורס ,שמו ,ושנת
הלימודים לה הוא מיועד(:
{ struct Stud
; unsigned long _id
; ]char _name[MAX_NAME_LEN
; enum gender_t _gender
; }
{ struct Course
; unsigned int _course_code
; ]char _name[MAX_NAME_LEN
; char _for_year
; }
נשים לב כי עת הגדרנו מבנים אלה לא נתנו דעתנו לשאלה האם נרצה לאחסן את
נתוני התלמידים או הקורסים במערך ,ברשימה מקושרת ,בעץ ,או בכל מבנה נתונים
אחר .מבני הנתונים כוללים רק את המידע אותו ברצוננו לאחסן .במילים אחרות,
המבנים אינם כוללים מצביעים שיתמכו ברשימה או בעץ.
עתה נניח כי החלטנו שבתכנית כלשהי ברצוננו לבנות רשימה מקושרת ממוינת אחת
של תלמידים )שתמוין על-פי מספר הזהות( ,ושניה של קורסים )שתמוין על-פי שם
הקורס( .אדגיש כי כל רשימה תכיל מבנים מסוג יחיד )לא נכניס לרשימה אחת הן
תלמידים והן קורסים(.
את המשימה של בניית הרשימה הממוינת נרצה לממש באמצעות פונ' גנרית ,אשר
תממש עבורנו את הלולאות הדרושות ,תוך שהיא עושה שימוש בפונ' שנממש עבור
189
כל אחד ואחד ממבני הנתונים ,בפרט פונ' השוואה בין שני תלמידים ,ופונ' השוואה
בין שני קורסים.
190
לשם ביצוע המשימה נגדיר מבנה שלישי:
{ struct List_node
; void *_item
; struct List_node *_next
; }
מבנה זה הוא שיתמוך בקיומה של רשימה מקושרת .נסביר :כל איבר מסוג
 List_nodeכולל מצביע למבנה של תלמיד בודד או קורס בודד )ולכן זהו מצביע
מסוג *  ,voidעל מנת שהוא יוכל להצביע על כל מבנה שהוא ,ומצביע שני לאיבר
הבא ברשימה ,איבר שהוא תיד ובהכרח מסוג  .List_nodeמבחינה ציורית
הרשימה נראית:
_next
_next
_next
_next
_item
_item
_item
_item
תלמיד או
קורס בודד
תלמיד או
קורס בודד
תלמיד או
קורס בודד
תלמיד או
קורס בודד
נסביר :בשורה העליונה בציור מופיעים איברים מטיפוס  List_nodeהכוללים שני
מצביעים בכל איבר .את המצביעים מסוג *  List_nodeציירתי בקו שלם .בשורה
מתחת מופיעים האיברים הכוללים את הנתונים שיש לשמור )נתוני תלמידים או
לחילופין קורסים( .כל איבר מסוג  List_nodeמצביע )עם מצביע שמצויר בקו
מקווקו( על איבר בודד של תלמיד או של קורס .אברי התלמידים\קורסים כמובן
שאינם משורשרים זה לזה ישירות ,הרי אין בהם כלל מצביעים ,רק שדות של
נתונים.
כדי להשלים את המשימה יהיה עלינו לכתוב שלוש פונ' עבור ,struct Stud
ושלוש מקבילות עבור  .struct Nodeשלוש הפונ' יבצעו:
א .הקצאת איבר של תלמיד\קורס והחזרת מצביע אליו )מצביע זה נכניס למרכיב
 _itemבקופסה מסוג .List_noe
ב .קריאת נתוני תלמיד\קורס בודד )לתוך איבר עליו מורה מצביע גנרי ,כלומר כזה
מטיפוס * .(void
ג .השוואה בין שני תלמידים )או בין שני קורסים( עליהם מורים מצביעים גנריים.
בנוסף להן אציג גם פונ' הדפסת נתוני תלמיד בודד .לפונ' זו נזדקק עת נרצה להציג
את הרשימות הממוינות שבנינו.
אציג את שלוש הפונ' עבור  ,struct Studאלה של קורס דומות לגמרי:
פונ' זו
//-----------------------------------------------------------מקצה איבר מסוג  struct Studומחזירה מצביע אליו
{ )(void* alloc_stud
; struct Stud *p = new (std::nothrow) struct Stud
{ )if (p == NULL
; "cerr << "cannot allocate a new sturct Stud\n
; )exit(EXIT_FAILURE
}
; return p
}
//------------------------------------------------------------
פונ' זו מקבלת מצביע גנרי ,אשר אנו מובטחים כי מצביע לאיבר של תלמיד ,וקוראת
את נתוני התלמיד:
191
{ )void read_stud(void *p
; int temp
; cin >> ((struct Stud *) p) -> _id
; cin >> setw(MAX_NAME_LEN) >> ((struct Stud *) p) -> _name
; cin >> temp
? )((struct Stud *) p) -> _gender = ((temp == 0
;)FEMALE_GNDR : MALE_GNDR
}
//------------------------------------------------------------
פונ' זו מקבלת מצביע גנרי ,אשר אנו מובטחים שמצביע אל מבנה של תלמיד,
ומציגה את נתוני התלמיד )נזדקק לה לשם הצגת הרשימות שנבנה(:
{ )void print_stud(const void *p
; " " << cout << ((struct Stud *) p) -> _id
; cout << ((struct Stud *) p) -> _name
? cout << (((struct Stud *) p) -> _gender == FEMALE_GNDR
; )""F" : "M
; cout << endl
}
//------------------------------------------------------------
פונ' זו מקבלת זוג מצביעים גנריים ,אשר אנו מובטחים שמצביעים על מבנים של
תלמידים )כל אחד על מבנה יחיד( ,ומחזירה את תוצאת ההשוואה בין שני המבנים.
שרירותית ,ולשם הפשטות בחרתי להשוות בין שני תלמידים על-פי מספר הזהות
שלהם ,אולם לכך אין ,כמובן ,חשיבות:
{ )bool cmp_stud(const void *p1, const void *p2
=< return ((struct Stud *) p1) -> _id
; ((struct Stud *) p2) -> _id
}
עתה ,כשבצקלוננו הפונ' הפשוטות הללו נוכל לכתוב את הפונ' הגנרית
 .build_sorted_listהפונ' תקבל מצביעים לשלוש פונ' :פונ' הקצאת איבר
מהטיפוס הרצוי ,פונ' קיראת נתונים לתוך איבר מהטיפוס הרצוי ,ופונ' להשוואה
בין שני איברים מהטיפוס הרצוי .הפונ' תבנה רשימה מקושרת ממוינת ,ותחזיר
מצביע לראש הרשימה .מצביע מטיפוס *  .List_nodeאציג את קוד הפונ':
//-----------------------------------------------------------(struct List_node *build_sorted_list
void* alloc_struct() ,
void read_struct(void *p) ,
{ ) )bool cmp_struct(const void *p1, const void *p2
; struct List_node *head = NULL
; 'char another_one = 'y
{ )'while (another_one == 'y
; )(void *p = alloc_struct
; )read_struct(p
; )insert(head, p, cmp_struct
; " ?cout << "insert another item
; cin >> another_one
}
; return head
}
נסביר את הפונ' :כפי שאנו רואים מכתורת הפונ' היא אכן מקבלת מצביעים לשלוש
פונ' :האחת מקצה איבר ומחזירה מצביע גנרי אליו ,השניה מקבלת מצביע גנרי
וקוראת נתונים לתוך האיבר )תוך שהיא ,כמובן ,מבצעת המרת טיפוס למצביע
הגנרי לכדי הטיפוס המתאים( ,והשלישית משווה בין איברים כדי לקבוע את
מקומם ברשימה הממוינת.
192
הפונ' מגדירה מצביע struct List_node *head = NULL ; :אשר יורה על אברי
הרשימה .היא מנהלת לולאה בה כל עוד המשתמש מעוניין להוסיף איברים נוספים
לרשימה אותה הוא בונה עתה :א .מזמנים את הפונ' להקצאת איבר בודד) ,ועליו
מצביע המשתנה  (pב .קוראים נתונים לתוך אותו איבר )זה שעליו מצביע  ,(pג.
מכניסים את האיבר הבודד )עליו מצביע  (pלרשימה השלמה הנבנית )עליה מצביע
 (headבמקום המתאים )תוך שימוש בפונ' ההשוואה(.
נפנה עתה להצגת הפונ'  insertאשר מוסיפה איבר בודד לרשימה:
//-----------------------------------------------------------void insert(struct List_node *&head,
void *p,
{ ) )bool cmp_struct(const void *p1, const void *p2
= struct List_node *new_item
; new (std::nothrow) struct List_node
{ )if (new_item == NULL
; "cerr << "Cannot allocate a new List_node\n
; )exit(EXIT_FAILURE
}
; new_item -> _item = p
אם הרשימה ריקה ,זה האיבר הראשון המוסף לה:
{ )if (head == NULL
; head = new_item
; head -> _next = NULL
}
אם האיבר החדש צריך להיות מוסף בראש הרשימה )פונ' ההשוואה מורה לנו שהוא
קטן מהאיבר שבראש הרשימה(:
{ ))else if (cmp_struct(new_item -> _item, head -> _item
; new_item -> _next = head
; head = new_item
}
אם האיבר החדש צריך להיות מוסף באמצע או בסוף הרשימה:
else
{
struct List_node *rear = head,
; *front = head->_next
&& while (front != NULL
{ ))cmp_struct(front->_item, new_item->_item
; rear = front
; front = front -> _next
}
; new_item -> _next = front
; rear -> _next = new_item
}
}
נסביר את הפונ' :הפונ' מקבלת שלושה פרמטרים :הראשון head ,מצביע על
הרשימה הנבנית .הוא מועבר כפרמטר הפניה שכן הפונ' עשויה לשנות את ערכו )בעת
הוספת איבר ראשון ,או עת מוסף יבר חדש שקטן מהאיבר המצוי בראש הרשימה(.
המצביע הוא מסוג *  List_nodeשכן אברי הרשימה הם כולם מסוג זה .בכל
איבר יהיה מצביע גנרי שיצביע על תלמיד בודד או על קורס בודד ,ואותו מצביע
)השדה  _itemהוא מצביע גנרי( .הפרמטר השני ) pשל הפונ'( ,מצביע על האיבר
הבודד אותו יש להוסיף לרשימה )איבר זה עשוי להיות מסוג  struct Studאו
מסוג  struct Courseולכן המצביע הוא גנרי( .הפרמטר השלישי הוא פונ'
השוואה בין שני איברים ,בעזרתה ממוינת הרשימה.
193
קוד הפונ' דומה לזה שראינו עת בנינו רשימה מקושרת ממוינת 'רגילה' .המשתנה
 new_itemיצביע על האיבר שיוּסף ַלרשימה )איבר מסוג  ,(List_nodeעל כן
ראשית אנו מקצים לו זיכרון ,ושנית ְ
מפנים את המצביע  _itemלהצביע על נתוני
התלמיד\קורס החדש .עתה עלינו לשלב את האיבר עליו מצביע ַ new_itemברשימה
עליה מצביע  ,headולשם כך אנו מבחינים בין שלושה מקרים:
נפנה את head
א .אם הרשימה ריקה ,כלומר זהו האיבר הראשון המוסף לה ,אזי ְ
להצביע כמו .new_item
ב .אם על-פי פונ' ההשוואה שהועברה לנו עולה כי האיבר הנוכחי קטן מזה שניצב
בראש הרשימה ,אזי יש להוסיף את האיבר הנוכחי בראש הרשימה ,וזה מה
שאנו עושים.
ג .במקרה הכללי :אנו מתקדמים על-פני הרשימה עם זוג מצביעים rear,
 frontעד אשר  frontמצביע על איבר גדול יותר מהאיבר שיש להוסיף .עתה
)אחרי שגמרנו להתקדם( ,אנו משלבים את האיבר החדש ברשימה בין האיבר
עליו מצביע  rearלאיבר עליו מצביע .front
התכנית הראשית תראה:
//-----------------------------------------------------------{ )(int main
; struct List_node *studs
; struct List_node *courses
; )studs = build_sorted_list(alloc_stud, read_stud, cmp_stud
courses = build_sorted_list(alloc_course, read_course,
; )cmp_course
; )print_list(studs, print_stud
; )print_list(courses, print_course
; return EXIT_SUCCESS
}
את מימוש הפונ'  print_listאני משאיר לכם כתרגיל.
194
עודכן 5/2010
 .17חלוקת תכנית לקבצים
עת התכניות שאנו כותבים גדלות קורה לא אחת שאנו מעוניינים לחלק את התכנית
לכמה קבצים שיקומפלו יחד לכדי תכנית אחת שלמה )לעתים נאמר :לכדי
אפליקציה אחת( .סיבות שונות מביאות אותנו לחלוקת התכנית לכמה קבצים:
א .מתכנתים שונים יכולים לעבוד במקביל על חלקים שונים בתכנית; כל חלק
ייכתב בקובץ נפרד.
ב .פונ' אשר מבצעות משימה דומה )למשל פונ' המבצעות משימות מתמטיות שונות
בתכניתנו( יושמו בקובץ יחיד .עת נזדקק גם בתכנית אחרת למשימות אלה ,נוכל
לשלב את הקובץ ַבתכנית האחרת בנקל )בלי שנצטרך לבצע 'העתק\הדבק'
מקובץ לקובץ ַלפונ' הדרושות לנו(.
ג .עת מתגלית שגיאה בחלק כלשהו של התכנית ,או עת אנו רוצים לשנות חלק
כלשהו בתכנית איננו צריכים לקמפל את כל התכנית )דבר שעלול לקחת זמן רב
אם התכנית כוללת אלפים רבים של שורות קוד( ,אלא אנו מקמפלים )במובן
הצר והמדויק של המילה( רק את הקובץ שמכיל את הקוד שהשתנה ,ואחר אנו
כורכים )עושים  (linkingלכלל הקבצים המקומפלים )אלה שלא שונו ,וזה ששונה(
לכדי תכנית שלמה חדשה.
לסיכום :מגוון של סיבות מביאות אותנו לרצות לחלק את תכניתנו למספר קבצים.
בפרק זה נלמד כיצד לעשות זאת.
 17.1הנחיית הקדם מהדר #define
אתחיל בסטייה לנושא מעט שונה ,אשר נזדקק לו בהמשך ,עת נתקדם עם המשימה
של חלוקת התכנית לקבצים.
עת דנו בתרגומה של התכנית משפה עילית לשפת מכונה אמרנו שלמרות שבשפה
חופשית אנו קוראים לתהליך קומפילציה )או הידור( הרי ליתר דיוק התהליך
מתחלק לשני שלבים מרכזיים :שלב הקומפילציה ,בו הקומפיילר מתרגם את
התכנית משפה עילית לשפת מכונה )אלא אם הוא מצא בה שגיאות תחביריות ,ואז
הוא מודיע עליהן ,ולא מתרגם את התכנית( ,ושלב הלינקינג )או הכריכה( בה נוספים
לתכנית חלקי קוד נוספים ,בפרט של פונ' סיפריה שונות אותן זימנו בתכניתנו
)כדוגמת cin, sqrt :וכולי( כך שמתקבלת תכנית שלמה ,ניתנת להרצה.
עתה נוסיף ונפרט ,ונאמר שגם שלב ההידור מתחלק לשני שלבים :שלב ה'-קדם
מהדר' ) ,(pre-processorושלב ההידור המרכזי .בסעיף זה נתאר חלק מפעולותיו של
קדם המהדר.
קדם המהדר סורק את קובץ התכנית שלנו .עת הוא נתקל בפקודה המתחילה בסימן
הסולמית ) (#הוא מבצע אותה ,ובכך משנֵה את הקובץ שלנו ,באופן אותו נתאר
מייד) .אם נדייק ,השינוי אינו מבוצע על קובץ המקור ,קובץ המקור אינו משתנה,
השינוי הוא בכך שהפלט של קדם המהדר כולל את קובץ המקור שלנו ,אחרי
שינויים(.
לדוגמה :אם בתכנית שלנו נכלול את השורה:
17
195
#define MAX
אזי הפקודה ) #defineכמו כל ההנחיות המתחילות ב (# -הינה פקודה המופנית
לקדם המהדר .הפקודה מורה לקדם המהדר שבהמשך התכנית ,בכל מקום בו תופיע
המחרוזת  MAXיש להמירה במספר  .17הפקודה גם מדגימה לנו את אופן פעולתו של
קדם המהדר באופן כללי :קדם המהדר הוא רכיב 'טיפש' אשר יודע לבצע פעולות
המרה של מחרוזות .מבחינה עניינית ,הפקודה הנ"ל היא הדרך המקובלת בשפת C
)בניגוד לשפת  (C++להגדיר קבועים .דרך זו נחשבת לפחות מוצלחת או רצויה
מהדרך המקובלת ב C++ -שכן אין בה אפשרות לבדוק טיפוסים—המהדר עצמו ,עת
מטפל בתכנית ,מוצא את המספר הקבוע ) .17כרגיל ,אנו נוהגים לכתוב קבועים
באות גדולה(.
אם נדייק אזי הפקודה  #defineתביא להמרת המחרוזת שמופיעה אחריה
בשארית השורה .מסיבה זאת אנו יכולים לכתוב גם את פקודות ה#define -
הבאות:
{
}
#define BEGIN
#define END
#define THEN
אם בהמשך התכנית שלנו יופיע הקוד:
if (a < b) THEN BEGIN a = b; b = 0; END
if (a == 0) THEN BEGIN ; END BEGIN ; END
אזי בעקבות פעולת הקדם מעבד יתקבל הקוד:
} ;if (a < b) { a = b; b = 0
} ; { } ; { )if (a == 0
נסביר :כל מופע של המחרוזת  BEGINמומר במחרוזת { ,באופן דומה כל מופע של
 ENDמוחלף בשארית השורה ,כלומר ב , } :ולבסוף ,כל מופע של  THENמוחלף
בכלום ,במילים פשוטות ,נמחק מהקוד.
שימו לב שהקדם מעבד מבצע פעולה על מחרוזות ,במובן זה ניתן להפעילו גם על
קובץ שאינו כולל קוד ,והוא יבצע את פעולות ההמרה המתבקשות ממנו.
במהדר  g++נוכל לבקש לראות את פלט קדם ההידור של התכנית שלנו מבלי
שהמהדר ימשיך ביתר תהליך הקומפילציה ,בעזרת הפקודה:
g++ -E my_prog.cc
הדגל  -Eמורה ל g++ -לבצע רק את שלב הקדם מהדר.
הפלט של קדם המהדר יוצג על-גבי המסך.
נציג עתה את פעולת הקדם מעבד הקרויה 'מאקרו' .המאקרו מגדיר מספר פעולות
שיש לבצע עת 'מזמנים' אותו ,ליתר דיוק עת 'פורשים' אותו .לדוגמה המאקרו:
; #define ZERO num1 = 0; num2 = 0; num3 = 0
אומר שכל פעם שנכתוב בהמשך התכנית את המילה  ZEROהיא תוחלף על-ידי קדם
המהדר בשלוש פעולות ההשמהnum1 = 0; num2 = 0; num3 = 0 ; :
כלומר נוכל בתחילת הקוד שלנו להגדיר את המאקרו הנ"ל )באמצעות פעולת ה-
 ,(#defineובהמשך לכתוב כל פעם  ZEROואז בקוד ישתלו שלוש ההשמות הנ"ל.
לפני שנפנה לדוגמה נוספת נציג ונסביר את הקוד הבא:
; num1 = num1 + num2
; num2 = num1 – num2
; num1 = num1 – num2
196
קוד זה מהווה דוגמה טובה ַלדרך בה נקל לכתוב קוד פשוט בדרך קשה להבנה .אם
תסמלצו את הקוד תגלו שהוא מחליף בין ערכי  num1ו num2 -בלי להשתמש
במשתנה עזר.
עתה נציג את המאקרו הבא:
;#define SWAP(_A, _B) _A = _A +_B; _B= _A- _B; _A = _A - _B
זהו מאקרו 'מתקדם' ,כזה המקבל פרמטרים _A :ו . _B -בהמשך התכנית ,אחרי
הגדרת המאקרו נוכל לכתוב , SWAP(num1, num2) :והקדם מהדר ימיר כתיבה
זאת בשלוש הפקודות:
;num1 = num1 +num2; num2= num1- num2; num1 = num1 - num2
תוך שהוא ממיר כל  _Aב ,num1 -וכל  _Bב .num2 -התוצאה היא מעין קריאה
לפונ' ,אולם זו לא באמת קריאה לפונ' עם הסתעפות לקוד הפונ' ,ותוך בדיקה של
המהדר שהזימון הינו תקין; כל שנעשה פה הוא שהמחרוזת
) SWAP(num1, num2מומרת על-ידי קדם המעבד בשלוש ההשמות:
;num1 = num1 +num2; num2= num1- num2; num1 = num1 - num2
אגב ,שימו לב שאחרי שם המאקרו איננו כותבים נקודה-פסיק ,שכן המאקרו אינו
פקודה בתכנית שיש לסיים בנקודה-פסיק ,הוא הנחיה לקדם-מהדר.
נביט עתה במאקרו פשוט אחר:
#define DOUBLE(_A) _A+_A
אם נכתוב בהמשך התכנית:
; )cout << DOUBLE(num1
אזי המאקרו ייפרש ונקבל שהמהדר יקמפל את הקוד:
; cout << num1 + num1
אך מה יקרה אם נכתוב:
; )cout << num2 * DOUBLE(num1
אזי תוצאת פרישת המאקרו תהיה:
; cout << num2 * num1 + num1
ובשל סדר הפעולות האריתמטיות קרוב לוודאי שלא זה מה שהמתכנת רצה להשיג.
הפתרון הוא להגדיר את המאקרו באופן:
)#define DOUBLE(_A) (_A+_A
תוצאת פרישת המאקרו:
; )cout << num2 * DOUBLE(num1
תהיה:
; )cout << num2 * (num1 + num1
מין הסתם ,התוצאה הרצויה.
לבסוף ,אנו רשאים להגדיר את המאקרו:
\ )#define SWAP(_TYPE, _A, _B
} ;{_TYPE temp = _A; _A= _B; _B = temp
ראשית ,אעיר שהגדרת מאקרו צריכה לשכון על שורה יחידה ,עת המאקרו גדול מדי
אנו רשאים לחלקו למספר שורות ,אך בסוף כל שורה עלינו לכתוב את התו
 (\) backslashשמורה למהדר להתייחס לשורה הבאה כאילו היא המשכה של השורה
הנוכחית .כאילו לא שברנו שורה.
אם בהמשך הקוד נכתוב:
)SWAP(int, num1, num2
אזי תוצאת הפרישה של המאקרו תהיה:
} ;{int temp = num1; num1= num2; num2 = temp
197
הפרמטר  _TYPEבמאקרו מוחלף במילה  ,intהפרמטרים  _A, _Bמוחלפים ב:
 num1, num2בהתאמה ,והמאקרו יוצר בלוק ,בו הוא מגדיר משתנה עזר בשם
 tempובעזרתו מחליף בין ערכי .num1, num2
 17.2הנחיית הקדם מהדר #ifdef
הנחיית המהדר #ifdef :שולטת על קימפולו או אי קימפולו של קטע קוד כלשהו.
נראה דוגמה קטנה:
#ifdef SYMBOL1
; "!cout << "Wow
; num = 0
#endif
עת הקדם מהדר יגיע לקטע קוד זה הוא ישאל את עצמו :האם כבר הוגדר לי
)באמצעות פקודת  (#defineהסימן ) SYMBOL1ולא משנה איזה ערך הוא מייצג(?
במידה והתשובה לשאלה היא כן אזי שתי הפקודות:
; "!cout << "Wow
; num = 0
תכללנה בפלט של קדם-המהדר ,כלומר תקומפלנה על-ידי המהדר עצמו .אם לעומת
זאת הסימן  SYMBOL1לא הוגדר עד כה אזי שתי הפקודות הללו )ובמקרה הכללי עד
ה (#endif -לא תשלחנה לפלט ,ועל כן לא תקומפלנה ,כלומר יתקבל מצב כאילו הן
לא נכללו כלל בתכנית שלנו.
נראה דוגמה שנייה .נניח את הקוד:
#define A
{ )(int main
; int num = 0
#ifdef A
; num = 1
#endif
#ifdef B
; num = 2
#endif
; cout << num
; return EXIT_SUCCESS
}
ונשאל :מה יהיה פלט התכנית? התשובה :הפלט יהיה ,1 :שכן ההשמה:
; num = 1תיכלל בקוד המקומפל ,אך ההשמה num= 2 ; :לא תיכלל .אם,
לעומת זאת ,נוסיף לצד ההנחיה #define A :את ההנחיה) #define B :לפני
או אחרי ההנחיה  ,#define Aאין לכך חשיבות( ,אזי פלט התכנית יהיה .2
נציג דוגמה בה הנחיית המהדר עשויה להיות שימושית .נביט בקטע הקוד:
)for (int div = 2; div <= sqrt(num); d++
198
{
#ifdef DEBUG
; cout << num << div << num%div << endl
#endif
)if (num % div == 0
; break
}
קטע קוד זה נכלל בפונ' הבודקת האם המספר  numהינו ראשוני או פריק.
חשיבותו לצורך ענייננו הנוכחי היא שבמידה והסימן  DEBUGהוגדר כבר ַל ְקדם
המהדר אזי בכל סיבוב בלולאה יבוצעו שתי פקודות :פקודת פלט )העוזרת לנו לבחון
את ערכי המשתנים ,ועל-ידי כך את הקורה בלולאה( ,ופקודת התנאי .אם ,לעומת
זאת ,הסימן  DEBUGלא הוגדר עד כה לקדם המהדר ,אזי בכל סיבוב בלולאה תבוצע
רק פקודת התנאי ,פקודת הפלט לא תיכלל כלל בקוד שיקומפל.
וכיצד הדבר ישמש אותנו? דרך אחת היא שבזמן העבודה על התכנית ,עת יש לבדוק
את תקינותה ,נוסיף לפני הקוד הנ"ל את השורה:
#define DEBUG
ובכך אנו מגדירים את הסימן ) DEBUGוקובעים שיש להחליפו בשום דבר ,אולם לכך
אין חשיבות לצורך העניין( ,ולכן גם פקודת הפלט תופיע בקוד שלנו .בתום העבודה
על התכנית נסיר את השורה #define DEBUG :ועל כן בתכנית הגמורה כבר לא
תופיע פקודת הפלט.
דרך חלופית ,פשוטה יותר ,היא במהלך העבודה על התכנית לקמפלה ַבאופן:
g++ -Wall –DDEBUG my_prog.cc
הדגל -DDEBUG :כולל ראשית את הסימן  –Dובעקבותיו את המחרוזת .DEBUG
מרכיב זה בפקודת ההידור שקול בדיוק לכתיבתַ #define DEBUG :בקוד; כלומר
הוא מגדיר לקדם מהדר את הסימן  .DEBUGהתוצאה תהיה כמו בדרך שתוארה
קודם :קוד התכנית יכלול גם את פקודת הפלט .לעומת זאת ,עת נסיים לעבוד על
התכנית ,נקמפלה 'כרגיל':
g++ -Wall my_prog.cc
בפקודת הידור זו איננו מגדירים את הסמל  ,DEBUGולכן קוד התכנית לא יכלול את
פקודת הפלט.
לפקודה  #ifdefיש 'אחות תאומה' והיא פקודת ה #ifndef :אשר שואלת את
השאלה ההפוכה :האם לא הוגדר הסימן המופיע בהמשך השורה? רק אם הסימן
לא הוגדר קטע הקוד המופיע בין ההנחיה הנ"ל לבין ה #endif -יקומפל.
בהמשך ,עת נציג כיצד מחלקים את תכנית למספר קבצים ,נראה שימושים נוספים,
מובהקים יותר להנחיות קדם המהדר. #ifdef, #ifndef :
 17.3הנחיית הקדם מהדר #include
ההנחיה #include "my_file" :גורמת לקדם המהדר להכניס לקובץ בו מופיע
ההנחיהַ ,במקום בו מופיעה ההנחיה ,את הקובץ  my_fileאשר אמור לשכון
במדריך הנוכחי )אחרת יש לציין נתיב מלא לקובץ(.
נראה דוגמה:
נניח את ההנחות הבאות:
הקובץ  f1כולל את השורה )ורק אותה ,כלומר זהו כל הקובץ(:
199
I am f1
הקובץ  f2כולל את השורות )ורק אותן(:
I am f2 1
"#include "f1
I am f2 2
הקובץ  f3כולל את השורות:
I am f3 1
"#include "f1
"#include "f2
I am f3 2
כיצד יראה פלט קדם ההידור של הקובץ :f3
ראשית ,השורה I am f3 1 :תופיע בפלט קדם ההידור ,כמות שהיא.
שנית ,השורה #include "f1" :תגרום להכנסת הקובץ  f1כמות שהוא,
כלומר תוסיף לפלט קדם ההידור את השורה.I am f1 :
שלישית ,השורה #include "f2" :תגרום להכללת הקובץ  f2בפלט ,ולכן תופיע
בפלט קדם ההידור השורה ,I am f2 1 :אחריה תופעל פעולת ההכללה של f1
כפי שמופיעה ב ,f2 -ולכן תופיע בפלט השורה ,I am f1 :ולבסוף תופיע השורהI :
.am f2 2
רביעית תופיע בפלט קדם ההידור השורה.I am f3 2 :
התוצאה הכוללת תהיה:
1
1
2
2
200
f3
f1
f2
f1
f2
f3
am
am
am
am
am
am
I
I
I
I
I
I
עתה נניח את ההנחות הבאות:
הקובץ  f1כולל את ההגדרה:
{ struct S1
; double _x
; }
הקובץ  f2כולל את השורות הבאות:
"#include "f1
{ struct S2
; struct S1 s1, s2
; }
הקובץ  f3כולל את הקוד:
"#include "f1
"#include "f2
כיצד יראה פלט קדם ההידור של הקובץ ?f3
ראשית יוכלל בו  f1ולכן בפלט יופיע:
{ struct S1
; double _x
; }
שנית ,יוכלל בו ' f2שמביא איתו' שוב את  f1ולכן לפלט יתווסף:
{
; _x
{
; S1 s1, s2
struct S1
double
; }
struct S2
struct
; }
כלומר הפלט יכלול את ההגדרה }…{  struct S1פעמיים.
אם פלט זה אמור לעבור לשלב ההידור המרכזי ,כפי שצפוי שיקרה ,אזי המהדר
'יצעק' עלינו שאנו מגדירים את  struct S1פעמיים )גם אם בשתי הפעמים זו
בדיוק אותה הגדרה(.
כיצד נמנע מתקלה בלתי רצויה זאת? התשובה היא :בעזרת פקודות .#ifdef
נסביר :את הקובץ  f1נשנה באופן הבא:
#ifndef STRUCT_S1
#define STRUCT_S1
{ struct S1
; double _x
; }
#endif
אנו אומרים ש':אנו מגנים על המבנה  s1מפני הכללה כפולה'..
נסביר מה הועילו חכמים בתקנתם :כיצד יראה עתה פלט קדם ההידור של הקובץ
 :f3ראשית ,ההנחיה " #include "f1תביא להכללת הקובץ  f1בפלט .עת קדם
המהדר יעבור על  f1הוא ישאל את עצמו :האם לא הוגדר לי עדיין )בריצתי
הנוכחית( הסימן  ? STRUCT_S1והתשובה תהיה' :כן .עד כה לא הוגדר לקדם
המהדר הסימן  .'STRUCT_S1על כן השורות שעד ה #endif -תשלחנה לפלט ,וכן
הסימן  STRUCT_S1כבר יוגדר לקדם המהדר.
עת נתקדם בקובץ  f3לשורה השנייה #include "f2" :נפנה לכלול את הקובץ
 .f2בקובץ  f2נמצא את ההנחיה ,#include f1 :ולכן קדם המהדר יפנה לקובץ
 .f1הוא ישאל את עצמו שוב :האם לא הוגדר לי עדיין )בריצתי הנוכחית( הסימן
201
 ? STRUCT_S1הפעם התשובה תהיה 'לא .כבר הוגדר לי הסימן  .'STRUCT_S1על
כן כל הקוד עד ה #endif -לא ישלח לפלט ,בפרט הגדרת ה struct S1 -לא תופיע
פעמיים בפלט קדם ההידור .קדם המהדר יחזור לקובץ  ,f2ויוסיף את הגדרת
המבנה  struct S2לפלט שלו.
התוצאה המרכזית ,כאמור ,היא שבתוצאת קדם ההידור ,המבנה  S1מוגדר פעם
יחידה ,ועל כן המהדר כבר לא יתלונן על הגדרה כפולה של המבנה .נחזור לנושא
בהמשך ,עם תכנית שלמה.
כמה מכם ודאי שאלו את עצמם מה הקשר בין הפקודה:
>#include <iostream
 #includeכפי שמוסברת בסעיף
שאנו כותבים בכל תכנית שלנו ,לבין הנחיית ה-
זה? התשובה היא ,כמובן ,שמדובר באותו דבר .ההבדל היחיד הוא שאת הקובץ
 iostreamלא אנחנו יצרנו ,ובהתאמה הוא אינו שוכן בתיקיה שלנו .סוגרי הזווית
)> <( שעוטפים את שם הקובץ מורים לקדם המהדר שקובץ זה עליו לחפש לא
במדריך הנוכחי ,אלא במדריך )המוכר לו( הכולל את כל קובצי הinclude -
הסטנדרטיים .מעבר לכך ,הקובץ  iostreamהוא קובץ הכולל הצהרות חיוניות,
אשר הקדם מהדר 'דוחף' בקובץ שלנו ,בדיוק כפי שתיארנו את פעולת ה.include -
ההצהרות המופיעות בקובץ  iostreamגורמות למהדר להכיר את פקודות
הספרייה הסטנדרטית cin, cout, ... :ולא 'לצעוק' עלינו עת אנו כוללים
פקודות אלה )שאינן חלק משפת  c++הבסיסית( בתכניתנו.
בהתאמה ,אנו יכולים לצור קובץ בשם  . my_includes.hבהזדמנות זאת אעיר
שהסיומת  .hשמציינת  headerאו 'כותר' בעברית ,היא סיומת מקובלת לקבצים
הכוללים הגדרות שונות; קבצים אשר עושים להם  ,includeולא מקמפלים אותם
)כלומר לעולם לא נכתוב g++ -Wall my_file.h :עבור  my_file.hשהינו
קובץ כותר ,הכולל הצהרות מועילות שונות (.הקובץ  my_include.hעשוי
להראות:
><iostream
><iomanip
><cmath
><cstdlib
#include
#include
#include
#include
; using std::cin
; using std::cout
; using std::endl
בכל תכנית שלנו נוכל לעשות  includeלקובץ זה ,והדבר יחסוך לנו את הצורך
לכתוב שוב ושוב ,בראש כל תכנית ,את השורות הללו .אעיר שאני לא בטוח שאני
ממליץ על גישה זאת ,שכן לבצע  includeבכל מקרה ,לקבצים בהם אין לנו צורך
מעמיס על המהדר ,ומאריך את משך ההידור; גודלה של התכנית הנוצרת בסופו של
דבר ,התכנית בשפת מכונה ,לא יגדל ,אולם גם הכבדה שלא לצורך על המהדר אינה
דבר ראוי .לכן ,למרות שהרעיון שהצגתי כאן הינו אפשרי ותקין ,אני לא בטוח שהוא
גם מומלץ.
 17.4חלוקת תכנית לקבצים ,דוגמה א'
 17.4.1הקבצים שנכללים בתכנית
נדון בתכנית הפשוטה הבאה:
202
//---------- include and using sections -------#include <iostream>
using std::cin ;
using std::cout ;
//-------------- prototypes section -----------void read_int(int &n) ;
void print_int(int n) ;
int add_int(int n1, int n2) ;
int sub_int(int n1, int n2) ;
//-------------- main -----------int main()
{
int num1, num2,
result ;
read_int(num1) ;
read_int(num2) ;
result = add_int(num1, num2) ;
print_int(result) ;
result = sub_int(num1, num2) ;
print_int(result) ;
return(EXIT_SUCCESS) ;
}
//-----------------------------------void read_int(int &n)
{
cin >> n ;
}
//-----------------------------------void print_int(int n)
{
cout << n << " " ;
}
//-----------------------------------int add_int(int n1, int n2)
{
return n1+n2 ;
}
//-----------------------------------void sub_int(int n1, int n2)
{
Return n1 – n2 ;
}
//------------------------------------
203
מדובר בתכנית קצרה ופשוטה ,אולם לצורך דיוננו 'נשחק בכאילו'—כאילו מדובר
בתכנית גדולה ,ועל כן ברצוננו לחלקה למספר קבצים .נתחיל בכך שנחלק את
התכנית לשלושה קבצים:
א .קובץ שייקרא  ex17g.ccויכלול את התכנית הראשית.
ב .קובץ שייקרא  io.ccויכלול את הפונ' העוסקות בקלט פלט ) read_intו-
(print_int
ג .קובץ שייקרא  arithmetic.ccויכלול את הפונ' העוסקות באריתמטיקה
) add_intו.(sub_int -
החלוקה מלמדת אתכם על האופן בו אנו מחלקים תכנית לקבצים :את אוסף הפונ'
העוסקות בהיבט מסוים אנו מרכזים בקובץ אחד.
הקובץ  , io.ccכאמור ,יכלול את שתי הפונ'  read_intו .sub_int -פונ' אלה
עושות שימוש בפקודות  cin, coutועל כן יש צורך לכלול בקובץ גם את פקודות
ה include -וה using -הדרושות .על כן תכולתו של  io.ccהינה:
>#include <iostream
; using std::cin
; using std::cout
//-----------------------------------)void read_int(int &n
{
; cin >> n
}
//-----------------------------------)void print_int(int n
{
; " " << cout << n
}
//------------------------------------
הקובץ  arithmetic.ccיכיל את שתי הפונ' האריתמטיות ,פונ' אלה אינן עושות
שימוש בפונ' ספריה כלשהן ,ולכן בקובץ זה אין צורך בכל פקודת  includeאו
 .usingתכולתו של  arithmetic.ccהינה לפיכך:
//-----------------------------------)int add_int(int n1, int n2
{
; return n1+n2
}
//-----------------------------------)int sub_int(int n1, int n2
{
; return n1 – n2
}
//------------------------------------
בקובץ  ex17g.ccשוכנת התכנית הראשית .התכנית הראשית אינה מזמנת פונ'
ספריה ,אולם משתמשת ב EXIT_SUCCESS -המוגדר ב .cstdlib -התכנית
הראשית מזמנת את ארבע הפונ' שכתבנו ,ועל כן ,על מנת שהמהדר לא 'יצעק' עלינו
204
בעת הידורה ,יש לכלול בה את ההצהרה על הפרוטוטיפים של פונ' הללו .תכולתו של
 ex17g.ccהינה לפיכך:
>#include <cstdlib
//-------------- prototypes section -----------; )void read_int(int &n
; )void print_int(int n
; )int add_int(int n1, int n2
; )int sub_int(int n1, int n2
//-------------- main -----------)(int main
{
int num1, num2,
; result
; )read_int(num1
; )read_int(num2
; )result = add_int(num1, num2
; )print_int(result
; )result = sub_int(num1, num2
; )print_int(result
; )return(EXIT_SUCCESS
}
 17.4.2הידור התכנית
עד כאן הכנו את שלושת הקבצים .עתה נשאל :כיצד 'נתפור' את שלושת הקבצים
לכדי תכנית שלמה אחת .נציג מספר דרכים לעשות זאת בעזרת המהדר g++
)בסביבת יוניקס(.
אפשרות א' ,הפשוטה ביותר ,היא לכתוב:
g++ -Wall ex17g.cc io.cc arithmetic.cc –o ex17g
בשיטת הידור זאת אנו מציינים בפקודת ההידור את שלושת הקבצים .המהדר
יעבור על שלושתם יקמפלם ,ויכרוך אותם לכדי תכנית ניתנת להרצה אחת ,אשר על-
פי הוראתנו תשכון בקובץ .ex17g
חסרונה של גישה זאת הוא שאם עתה עלינו לשנות רק את אחד הקבצים אנו
מקמפלים מחדש גם את שני הקבצים האחרים ,ובכך משקיעים זמן ועבודה
מיותרים של המהדר .היינו מעדיפים שאם רק אחד הקבצים השתנה אזי נהדר
)במובן הצר של המילה( רק את הקובץ שהשתנה ,ואז נכרוך )נבצע לינקינג( של
שלושת הקבצים לכדי  executableחדש.
כדי להשיג מטרה רצויה זאת ננקוט בגישה הבאה ,שהינה אפשרות ב' לקימפול
התכנית .לפי שיטה זאת אנו ,ראשית ,מקמפלים )במובן הצר והמדויק של המילה(
כל אחד משלושת הקבצים ,ומקבלים את תרגום הקובץ לשפת מכונה; אנו אומרים
שאנו מקבלים קובץ  .objectבשלב השני אנו כורכים את שלושת קובצי ה-
205
 objectלכדי תכנית שלמה ניתנת להרצה ) g++ .(executableיעשה עבורנו הן
את עבודת הקימפול ,והן את עבודת הלינקינג ,והדבר יתבצע באופן הבא :הפקודה:
g++ -Wall –c io.cc
מפעילה את  g++ומורה לו ,באמצעות הדגל  –cלהסתפק רק בקומפילציה )במובן
הצר של המילה( ,כלומר רק לתרגם את התכנית לשפת מכונה ,בלי להמשיך לשלב
הלינקינג ,ולנסות לייצר תכנית ניתנת להרצה )דבר שלא ניתן לעשות ,למשל מפני
שאין לנו בקובץ זה כל תכנית ראשית(.
באופן דומה ,נקמפל את שני הקבצים האחרים:
g++ -Wall –c arithmetic.cc
g++ -Wall –c ex17g.cc
io.o,
שלוש פקודות הידור אלה ייצרו שלושה קובצי :object code
) arithmetic.o, ex17g.oהמהדר בעצמו יודע שעת יש רק לקמפל קובץ,
ולייצר ממנו קובץ  objectיש לתת לקובץ ה object -אותו שם כמו לקבוץ
המקור ,עם סיומת .(o
את שלושת קובצי ה object -נכרוך יחד לכדי תכנית שלמה ניתנת להרצה באופן
הבא:
g++ io.o arithmetic.o ex17g.o –o ex17g
שוב אנו עושים שימוש ב ,g++ -אולם הפעם כלינקר )ולא כקומפיילר( .אנו מורים לו
לכרוך את שלושת הקבצים io.o arithmetic.o ex17g.o :לכדי תכנית
שלמה ,אשר תשכון בקובץ) ex17g :הסיומת  –o ex17gמורה שזה יהיה שמו של
הקובץ שיכיל את התכנית הניתנת להרצה .לו היינו משמיטים את סיומת ,הייתה
התכנית הניתנת להרצה שוכנת תמיד בקובץ ,בעל השם המוזר(.a.out ,
אם עתה אנו משנים את אחד מקובצי המקור ,אזי נקמפל שוב )במובן הצר של
המילה( רק קובץ זה ,ואחר-כך נכרוך יחדיו שוב את שלושת קובצי ה .object -בכך
נחסוך לנו את הצורך לקמפל שוב את כל שלושת הקבצים ,עת רק אחד מהם
השתנה.
עת הפרויקט שלנו כולל שלושה קבצים קטנים ,קל יחסית לעקוב אחר השינויים
שאנו עורכים בכל קובץ ,ולקמפל בדיוק את מה שצריך )לא פחות ,ולא יותר( .אולם
עת האפליקציה )התכנית( שלנו כוללת מספר גדול של קבצים אזי יקשה עלינו לזכור
אילו קבצים השתנו ,ולפיכך איזה קבצים יש לקמפל .כאן באה לעזרתנו פקודת ה-
 Shellשל יוניקס הנקראת .make
פקודת ה make -עושה שימוש בקובץ הנקרא ) makefileזה בדיוק צריך להיות
שמו של הקובץ; ללא סיומת( .הקובץ  makefileמתאר ,לפקודת ה ,make -את
תהליך ההידור שעליה לבצע .אציג כאן צורה בסיסית ביותר לבניית קובץ ה-
 ,makefileהמתעניינים מוזמנים ,כמובן ,להרחיב את ידיעותיהם בנושא.
206
תכולת הקובץ היא:
ex17g: ex17g.o io.o arithmetic.o
g++ ex17g.o io.o arithmetic.o –o ex17g
"echo "a new ex17g was created
ex17g
ex17g.o: ex17g.cc
g++ -Wall –c ex17g.cc
io.o: io.cc
g++ -Wall –c io.cc
arithmetic.o: arithmetic.cc
g++ -Wall –c arithmetic.cc
אדגיש כי בשורות שאינן מתחילות בעמודה הראשונה )כדוגמת השורה השנייה,
השלישית ,הרביעית ,השישית וכולי( יש להקיש על מקש ה tab -בתחילת השורה
)ולא על מקשי רווח(.
נסביר את הקובץ :כאמור ,הקובץ הנ"ל הינו הקלט לפקודת ה .make -הפקודה
עוברת על הקובץ משורתו הראשונה ב'-רברס' .היא מתחילה בקובץ  ex17gכפי
שמופיע משמאל לנקודותיים בשורה הראשונה .מימין לנקודותיים מתוארים
)שלושת( הקבצים בהם קובץ זה תלוי—אם אחד הקבצים הללו עודכן במועד
מאוחר יותר מהמועד בו עודכן  ex17gאזי יש לבצע את הפקודות המופיעות בשלוש
השורות הבאות )שכל אחת מהן מתחילה ,כאמור ,ב .(tab -שלוש הפקודות הן
ראשית לינקינג של שלושת קובצי ה object -לכדי תכנית ניתנת להרצה ,ומעבר
לכך ,כדי לסבר את העין ,הוספנו שיש לעשות עוד שני דברים :לשלוח למסך פלט
האומר  ,a new ex17g was createdולסיום גם להריץ את התכנית .בד"כ,
קובץ ה makefile -לא יכיל הרצה של התכנית ,וגם הודעה למסך ספק אם יש
צורך להוציא.
כאמור ,פקודת ה make -מתקדמת על הקובץ מראשיתו כלפי סופו :אחרי שהיא
גילתה ש ex17g :תלוי ב ex17g.o io.o arithmetic.o :היא עוברת לבדוק
עבור כל אחד ואחד משלושת הקבצים הללו במי הוא תלוי .כך היא מגלה ,למשל,
שהקובץ  io.oתלוי ב) io.cc -זאת מורה לה השורה (io.o: io.cc :ועל כן אם
 io.ccמאוחר משל  io.oאזי יש לבצע:
תאריך עדכונו של
.g++ -Wall –c io.cc
207
לכן פקודת ה make -ראשית תהדר את  io.ccותייצר  io.oחדש ,ורק שנית
תכרוך את  io.oהחדש עם קובצי האובג'קט האחרים לכדי  executableחדש.
כלומר ,למרות שבקובץ פקודת הלינקינג מופיעה בשורה קודמת לפקודת ההידור,
ַבפועל ,בשל ההתקדמות 'ברברס' ,פקודת ההידור תבוצע לפני פקודת הכריכה.
פקודת ה make -למעשה מייצרת מעין 'עץ תלויות' שבמקרה שלנו נראה באופן
הבא:
ex17g
g++ ex17g.o io.o arithmetic.o –o ex17g
arithmetic.o
g++ -Wall –c arithmetic.cc
arithmetic.c
ex17g.o
o.io
g++ -Wall –c io.cc
io.c
g++ -Wall –c ex17g.cc
ex17g.c
שורש העץ הוא התכנית שיש לייצר ,כפי שמופיעה משמאל לנקודותיים בשורה
הראשונה .חץ מצומת אחד לשני מעיד שהצומת השני תלוי בצומת הראשון ,ואם
הצומת הראשון עדכני יותר אזי יש לעדכן גם את הצומת השני .הפעולה שיש לבצע
כתובה לצד החץ או החיצים .פקודת ה make -בונה את העץ מהשורש כלפי העלים,
אך מבצעת את הפעולות מהעלים כלפי השורש )כפי שהגיוני לעשות(.
 17.4.3שימוש בקובצי כותר )(header
נמשיך בחלוקת התכנית שלנו לקבצים .כאמור ,אחת המטרות של חלוקת התכנית
לקבצים היא שאם בהמשך נרצה לכתוב תכנית הזקוקה לפונקציות המתמטיות
שכתבנו ) (add_int, sub_intנוכל לצרף פונקציות אלה בנקל לאותה תכנית ,ע"י
שנכרוך את הקובץ  add_sub.oעם קובצי האובג'קט האחרים שירכיבו את אותה
תכנית .נניח שבאותה תכנית קיים גם הקובץ  .prog.ccנניח שבקובץ זה ברצוננו לזמן
את הפונ' .add_int, sub_int :על כן עלינו להצהיר על הפרוטוטיפ שלהן באותו קובץ.
כדי להקל עלינו בכך ,נהוג לצרף לכל קובץ מקור ,הכולל הגדרה של פונ' )כדוגמת
 (add_sub.ccגם קובץ כותר אשר כולל הצהרות על הפרוטוטיפים של הפונקציות
הנכללות בקובץ המקור) .נהוג ששמו של קובץ הכותר מסתיים ב, .h :האות  hהיא
עבור.(header :
הדוגמה שלנו ,כוללת את קובץ המקור :io.cc
>#include <iostream
; using std::cin
208
using std::cout ;
//-----------------------------------void read_int(int &n)
{
cin >> n ;
}
//-----------------------------------void print_int(int n)
{
cout << n << " " ;
}
//------------------------------------
: שיראהio.h עתה נכלול בתכנית גם את קובץ הכותר
void read_int(int &n) ;
void print_int(int n) ;
 עתה נוסיף.arithmetic.cc  התכנית שלנו כוללת את קובץ המקור,באופן דומה
:arithmetic.h לתכנית את קובץ הכותר
int add_int(int n1, int n2) ;
int sub_int(int n1, int n2) ;
 כבר לא תמנה את הפרוטוטיפים של ארבע הפונ' הללו באופן,התכנית הראשית
: התכנית הראשית תראה. אלא תכליל את קובצי הכותר המתאימים,מפורש
#include <cstdlib>
#include "io.h"
#include "arithmetic.h"
//-------------- main -----------int main()
{
int num1, num2,
result ;
read_int(num1) ;
read_int(num2) ;
result = add_int(num1, num2) ;
print_int(result) ;
result = sub_int(num1, num2) ;
print_int(result) ;
return(EXIT_SUCCESS) ;
}
( יתבצעmake  באופן ישיר )שלא באמצעות,הידור התכנית במתכונתה הנוכחית
:בדיוק כמו קודם; דבר לא משתנה
g++ -Wall –c io.cc
g++ -Wall –c arithmetic.cc
g++ -Wall –c ex17g.cc:
209
g++ io.o arithmetic.o ex17g.o –o ex17g
עת אנו משתמשים בפקודת ה make -יש לתקן את קובץ ה makefile -כך שהוא
יורה שגם עת חל שינוי בקובץ כותר כלשהו יש לקמפל את קובצי המקור אליהם
קובץ הכותר מתייחס )למשל ,אם חל שינוי ב io.h -אזי יש לקמפל את ,(io.cc
וכן יש לקמפל את הקבצים בהם נכלל קובץ הכותר המתאים )בדוגמה שלנו ,עת חל
שינוי ב io.h -יש לקמפל גם את  ex17g.ccהכולל אותו( .לכן קובץ ה-
 makefileיראה:
ex17g: ex17g.o io.o arithmetic.o
g++ ex17g.o io.o arithmetic.o –o ex17g
"echo "a new ex17g was created
ex17g
ex17g.o: ex17g.cc io.h arithmetic.h
g++ -Wall –c ex17g.cc
io.o: io.cc io.h
g++ -Wall –c io.cc
arithmetic.o: arithmetic.cc arithmetic.h
g++ -Wall –c arithmetic.cc
נסביר :שינוי ב io.h -מעיד על שינוי בפרוטוטיפים של הפונ' הממומשות ב-
 ,io.cעל כן יש לקמפל קובץ זה מחדש .באופן דומה ,שינוי בקובץ זה אשר נכלל
בקובץ  ex17g.ccמשמעו ,מבחינת המהדר ,שינוי בקובץ  ,ex17g.ccולכן גם את
הקובץ האחרון יש לקמפל .כמובן שקימפול כל אחד מהקבצים הללו יביא גם
לכריכה מחדש של כלל קובצי האובג'קט לכדי תכנית ניתנת להרצה חדשה.
 17.5חלוקת תכנית לקבצים ,דוגמה ב' :תרומתה של הנחיית
המהדר ifndef
נניח כי עתה עלינו לכתוב תכנית המטפלת בנקודות ,ובמלבנים אשר צלעותיהם
מקבילות לצירים .לשם כך ,בפרט עלינו להגדיר את המבנים הבאים:
{ struct Point
; double _x, _y
}
נקודה אנו מייצגים באמצעות הקואורדינאטות שלה ,ומלבן באמצעות פינתו
השמאלית העליונה ,ופינתו הימנית התחתונה:
{ struct Rectangle
; struct Point _top_left, _bottom_right
}
כמו כן ,יש להגדיר פונ' שונות אשר פועלות על נקודות ועל מלבנים.
מעבר לאלה תהייה לנו תכנית ראשית אשר תשתמש בנקודות ובמלבנים.
במצב זה התכנית שלנו תורכב מחמשת הקבצים הבאים:
א .קובץ כותר בשם  Point.hאשר כולל את הגדרת המבנה  ,Pointואת ההצהרה על
הפונ' הפועלות על נקודות.
ב .קובץ מקור בשם  Point.ccהכולל את מימוש הפונ' אשר עליהן הצהרנו ב:
 .Point.hמכיוון שהפונ' עושות שימוש בהגדרת המבנה  Pointאזי בקובץ זה נכלול
)בין היתר( את הקובץ Point.h
210
ג .קובץ כותר בשם  Rectangle.hהכולל את הגדרת המבנה  ,Rectangleואת ההצהרה
על הפונ' הפועלות על מלבנים .מכיוון שהגדרת מלבן ,כפי שראינו ,מסתמכת על
הגדרת נקודה אזי קובץ זה יכלול את הקובץ .Point.h
ד .קובץ מקור בשם  Rectangle.ccהכולל את מימוש הפונ' עליהן הצהרנו ב:
 .Rectangle.hמכיוון שהפונ' עושות שימוש בהגדרת המבנה  Rectangleאזי בקובץ
זה נכלול )בין היתר( את הקובץ .Rectangle.h
ה .קובץ בשם  my_prog.ccבו תשכון התכנית הראשית .מכיוון שהתכנית הראשית
תגדיר משתנים מהטיפוסים  Point, Rectangleאזי עלינו לכלול בקובץ my_prog.cc
את  Point.hואת  .Rectangle.hנעיר כאן שמישהו עשוי לטעון ,ובצדק ,שאין צורך
לכלול בקובץ זה גם את  Point.hשכן  Rectangle.hכבר מכליל בתוכו קובץ זה .זה
נכון ,אולם פעמים רבות מתכנתים שונים כותבים חלקים שונים בתכנית; )כפי
שעוד נרחיב בהמשך( המתכנת שמקודד את התכנית הראשית עשוי שלא לדעת
שמלבן מיוצג באמצעות שתי נקודות ,ועל כן ש Rectangle.h -כבר הכליל את
 .Point.hהמתכנת שכותב את התכנית הראשית יודע שהוא משתמש הן בנקודות
והן במלבנים ,ועל כן הוא ,באופן טבעי ,מכליל את שני קובצי הכותר .בבעיות
שהדבר גורם ,והפתרונות להן ,נדון בהמשך) .למעשה הבעיות והפתרונות כבר
נסקרו עד דנו בהנחיית המהדר (.#ifndef
נציג עתה את הקבצים השונים .אתחיל בכך שאציג את הקבצים כפי שמתכנת שלמד
את החומר עד כה ,אך לא הפנים את נושא ה ,#ifndef -היה כותב אותם .אחר כך
אסביר את בעיית הקומפילציה שמתעוררת עת כותבים את התכנית באופן זה,
ולבסוף אסביר מה יש להוסיף לתכנית על מנת להתגבר על הבעיה.
 17.5.1הקובץ Point.h
כפי שאמרנו ,נניח שאנו מגדירים נקודה באופן הבא:
{ struct Point
; double _x, _y
}
עוד נניח שעל נקודה אנו מגדירים את הפעולות הבאות:
א .קריאת נקודה מהמשתמש.
ב .הדפסת נקודה לפלט הסטנדרטי..
ג .החזרת הרביע בו מצויה הנקודה.
ד .החזרת המרחק בין שתי נקודות.
כל אחת ,כמובן ,תמומש באמצעות פונ'.
לכן הקובץ  Point.hיראה באופן הבא:
{ struct Point
; double _x, _y
}
; )void read_point(struct Point &p
; )void print_point(const struct Point &p
; )int quarter((const struct Point &p
double distance(const struct Point & p1,
; )const struct Point &p2
אעיר שעת פונ' אמורה רק להשתמש בנקודה כלשהי ,אך לא לשנותה ,אנו מעבירים
את הפרמטר כפרמטר הפניה קבוע ) .(const struct Point &pאנו לא מעבירים את
המבנה כפרמטר ערך כדי לחסוך את הזמן והזיכרון הדרושים לשם העתקת המבנה,
211
העתקה שהייתה נדרשת לו הפרמטר היה פרמטר ערך .דנו בכך בהקשר לש מבנים,
ולא ארחיב על כך כאן.
 17.5.2הקובץ Point.cc
הקובץ  Point.ccכולל ,כאמור ,את המימוש של הפונ' המטפלות בנקודות ,פונ' עליהם
הצהרנו בקובץ  .Point. hעל כן הוא יכלול למשל את הפונ':
)void read_point(struct Point &p
{
; cin >> p. _x >> p._y
}
//-----------------------------------)void print_point(const struct Point &p
{
; ')' << cout << '(' << p._x << ", " << p._y
}
על מנת שקימפולו של קובץ זה יעבור בהצלחה על הקומפיילר להכיר את הפרמטר
של הפונ' ,מטיפוס  struct Pointהגדרת המבנה  Pointמצויה בקובץ
 Point.hעל-כן יש לכלול קובץ זה בקובץ  .Point.ccמכיוון שהפונ'
 read_pointכוללת פקודת  , cinוהפונ'  print_pointכוללת פקודת ,cout
אזי יש לכלול גם את  iostreamבקובץ .הכללתו יכולה להתבצע מפורשות בקובץ
 Point.ccאו בקובץ הכותר  Point.hהמוכלל ב .Point.cc -כמובן ,במידה
ורוצים בכך יש צורך בפקודות .using
באותו אופן ,נממש בקובץ  Point.ccאת הפונ' האחרות.
כפי שתיארתי את קובץ הכותר נתקן לכדי:
>#include <iostream
>#include <cmath
; using std::cin
; using std::cout
{ struct Point
; double _x, _y
}
; )void read_point(struct Point &p
; )void print_point(const struct Point &p
; )int quarter((const struct Point &p
double distance(const struct Point & p1,
; )const struct Point &p2
הקובץ  Point.ccיראה:
"#include "Point.h
//-----------------------------)void read_point(struct Point &p
{
212
cin >> p. _x >> p._y ;
}
//-----------------------------void print_point(const struct Point &p)
{
cout << '(' << p._x << ", " << p._y << ')' ;
}
//---------------------------------... implementation of the other two functions
Rectangle.h  הקובץ17.5.3
 נניח שעבור מלבן אנו.עתה נטפל במלבן באופן דומה לדרך בה נהגנו עם נקודה
:מגדירים את הפעולות
. קריאת נתונים מלבן מהמשתמש.א
. הדפסת נתוני מלבן למשתמש.ב
. חישוב והחזרת שטח מלבן.ג
. חישוב והחזרת היקף מלבן.ד
. בדיקה האם נקודה מצויה בתוך מלבן.ה
:קובץ הכותר ייראה
#include <iostream>
#include "Point.h"
using std::cin ;
using std::cin ;
struct Rectangle {
struct Point _top_left,
_bottom_right ;
}
void read_rectangle(struct Rectangle &r) ;
void print_rectangle(const struct Rectangle &r) ;
double rectangle_area(const struct Rectangle &r) ;
double rectangle_circumference(
const struct Rectangle &r) ;
bool is_point_in_rect(const struct Point &p,
const struct Rectangle &r) ;
 שכן מלבן מוגדרת תוך שימושPoint.h  אתRectangle.h אנו מכלילים בקובץ
 בעת שהוא מקמפל אתstruct Point  ועל כן על הקומפיילר להכיר את,בנקודה
 )למרות שהוא מוכלל כבר ע"יiostream  אנו מכלילים גם את.הקוד הקשור למלבן
( מתוך הנחה שהמתכנת שכותב את הקוד הקשור למלבן הוא מתכנתPoint.h
213
 והוא אינו יודע מה כלל עמיתו בקוד,אחר מזה שכותב את הקוד הקשור לנקודה
 הוא מכלילוiostream - מכיוון שהמתכנת שכותב את קוד של מלבן זקוק ל.שלו
.בקובץ הכותר שלו בעצמו
Rectangle.cc  הקובץ17.5.4
:Point.cc - יהיה דומה במבנהו לRectangle.cc הקובץ
#include "rectangle.h"
//----------------------------------------------void read_rectangle(struct Rectangle &r) {
read_point(r._top_left) ;
read_point(r._bottom_right) ;
}
//----------------------------------------------void print_rectangle(const struct Rectangle &r) {
cout << "[ " '
print_point(r._top_left) ;
cout << " – " ;
print_point(r._bottom_right) ;
cout << " ]" ;
}
//----------------------------------------------... the other three functions
my_prog.cc  הקובץ17.5.5
 אשר עושה שימוש בנקודות, יכיל את התכנית הראשיתmy_prog.cc הקובץ
 תחת ההנחה שהמתכנת שכתב אותו אינו יודע מה הכלילו עמיתיו, על כן.ובמלבנים
Point.h,  קובץ זה צריך לכלול בתוכו את שני קובצי הכותר,בקבצים שהם כתבו
.פי הצורך- על, ואולי קובצי כותר אחרים,Rectangle.h
:הקובץ יראה באופן הבא
#include <iostream>
#include "Point.h"
#include "Rectangle.h"
using std::cout ;
//----------------------------------------int main()
{
struct Point p1, p2 ;
struct Rectangle r ;
read_point(p1) ;
read_point(p2) ;
214
; )read_rectangle(r
)if (distance(p1, p2) == 0
; "cout << "It is the same point\n
))if (is_point_in_rect(p1, r
; "cout << "p1 is in r\n
...
}
 17.5.6בעיות ההידור המתעוררות והתיקונים הדרושים בעזרת #ifndef
נניח שעתה ברצוננו להדר )לקמפל( את התכנית על מנת לקבל קובץ ניתן להרצה.
בפרט יהיה עלינו להדר )במובן הצר של המילה( את הקובץ  .my_prog.ccפקודת
ההידור היא:
g++ -Wall –c my_prog.cc
נבחן מה יקרה בתהליך ההידור ,ואיזה בעיה תתעורר :כזכור לנו ,ההידור מתחיל
בהפעלת קדם המהדר ,אשר 'שותל' את הקבצים המוכללים במקום בו הם
מוכללים .על-כן:
א .ראשית' ,ישתול' קדם המהדר את  iostreamבקובץ שלנו;
ב .שנית ,הוא ישתול את  ,Point.hועל-כן הגדרת  struct Pointתוכנס לקובץ
 ,my_prog.ccוטוב שכך ,שכן אחרת המהדר )המרכזי( בשלב ההידור העיקרי לא
יכיר את  ,struct Pointו'-יצעק' עלינו ,כלומר יוציא הודעת שגיאה.
ג .שלישית ,הוא ייגש 'לשתול' את  Rectangle.hבקובץ הנוכחי Rectangle.h .כולל
בתוכו את ההנחיה " #include "Point.hמה שיגרום לקדם מהדר 'לשתול' שוב את
 Point.hבקובץ שלנו ,ואחר-כך 'לשתול' את יתר הקובץ  Rectangle.hבקובץ
my_prog.cc
התוצאה :הגדרת  struct Pointמופיעה בקובץ  my_prog.ccפעמיים .אומנם בשתי
הפעמים זו בדיוק אותה הגדרה ,אך ,כאמור ,היא מצויה בקובץ פעמיים .עת המהדר
)המרכזי( ייתקל פעמיים בהגדרת המבנה הוא 'יצעק' עלינו שזו שגיאה; וזה כמובן
רע מאוד.
כיצד נתקן את התקלה ,ונאפשר לתכניתנו להתקמפל כהלכה?
הכלי שיעזור לנו בכך הוא הנחיית הקדם מהדר .#ifndef
215
: ואחר אסביר את התיקון,אציג את קובצי הכותר המעודכנים
The file: Point.h
#ifndef POINT
#define POINT
struct Point {
double _x, _y ;
}
void read_point(struct Point &p) ;
void print_point(const struct Point &p) ;
int quarter((const struct Point &p) ;
double distance(const struct Point & p1,
const struct Point &p2) ;
#endif
The file: Rectangle.h
#ifndef RECTANGLE
#define RECTANGLE
#include <iostream>
#include "Point.h"
using std::cin ;
using std::cin ;
struct Rectangle {
struct Point _top_left,
_bottom_right ;
}
void read_rectangle(struct Rectangle &r) ;
void print_rectangle(const struct Rectangle &r) ;
double rectangle_area(const struct Rectangle &r) ;
double rectangle_circumference(
const struct Rectangle &r) ;
bool is_point_in_rect(const struct Point &p,
const struct Rectangle &r) ;
#endif
. #ifndef : 'הגנו' על כל אחד מקובצי הכותר באמצעות השאלה:כפי שניתן לראות
 עת הוא עובר על, תוך שאנו חוזרים על פעולתו של קדם המהדר,אסביר ביתר פירוט
: עבור הקבצים המתוקניםmy_prog.cc הקובץ
 כאן לא.(my_prog.cc)  בקובץ שלנוiostream  'ישתול' קדם המהדר את, ראשית.א
.ifndef  בה לא הוספנו עדיין את ההנחיות,חל כל שינוי יחסית לדוגמה הקודמת
: כלומר,#ifndef POINT : בפרט הוא יישאל.Point.h  הוא ייגש לשתול את, שנית.ב
 ? התשובה תהיהPOINT  לא הוגדר לי הסימן, בהרצה נוכחית שלי,האם עד כה
 ייכלל, כלומר בדוגמה שלנו עד סוף הקובץ,endif - ועל כן כל מה שמופיע עד ה,כן
struct  הגדרת, בפרט.(בפלט של קדם המהדר )ולכן יקומפל ע"י המהדר המרכזי
 מגדיר#define POINT : בשורה, מעבר לכך.my_prog.cc  תוכנס לקובץPoint
.לעצמו קדם המהדר סימן זה
216
ג .שלישית ,הוא ייגש 'לשתול' את  Rectangle.hבקובץ הנוכחי Rectangle.h .כולל
בתוכו את ההנחיה " #include "Point.hמה שיגרום לקדם מהדר לפנות שוב ל-
 Point.hעל מנת להכלילו בקובץ  .my_prog.ccשוב ישאל עצמו קדם המהדר :האם
עד כה ,בהרצה נוכחית שלי ,לא הוגדר לי הסימן  ? POINTהפעם ,התשובה תהיה
לא ,שכן בסעיף ב' שמעל כבר הגדיר לעצמו קדם המהדר את הסימן הזה ,על כן
כל מה שמופיע עד ה #endif -לא 'יישתל' בקובץ שלנו ,בפרט הגדרת struct Point
לא תופיע פעמיים ,וההידור יעבור בהצלחה )לכל הפחות מהיבט זה( .לא
התייחסתי בדברי לשאלה המקבילה שקדם המהדר שואל:
#ifndef
 ? RECTANGLEשכן הדיון בה זהה לזה שערכנו עבור  .POINTבתכנית הנוכחית
הגנה זאת מפני הכללה כפולה של הגדרת  struct Rectangleאינה חיונית ,ולכן
בתכנית הנוכחית יכולנו לוותר על ההנחיות#ifndef RECTANGLE, #define :
 . RECTANGLE, #endifאנו כותבים אותן לטובת מקרה בן בעתיד מישהו עשוי
לכלול בתכניתו את  rectangle.hיותר מפעם אחת.
 17.5.7קובץ ה makefile -לפרויקט
קובץ ה makefile -לבניית תכנית ניתנת להרצה עבור הקבצים שלנו דומה בעיקרון
לזה שראינו בדוגמה הקודמת:
my_prog: my_prog.o Point.o Rectangle.o
g++ my_prog.o Point.o Rectangle.o –o my_prog
my_prog.o: my_prog.cc Point.h Rectangle.h
g++ -Wall –c my_prog.cc
Rectangle.o: Rectangle.cc Rectangle.h Point.h
g++ -Wall –c Rectangle.cc
Point.o: Point.cc Point.h
g++ -Wall –c Point.cc
נסביר:
 Rectangle.oתלוי כמובן ב .Rectangle.cc :מכיוון ש Rectangle.cc :כולל בתוכו את
שני קובצי הכותר  Rectangle.h Point.hאזי לצורך הקומפילציה )המרכזית(
שני קבצים אלה הם חלק מ Rectangle.cc :ולכן  Rectangle.oתלוי גם בהם .דין דומה
חל על  .my_prog.oכמובן שה ,executable -הקובץ  ,my_progתלוי בשלושת קובצי ה-
 objectולכן אם אחד מהם עדכני יותר ממנו יש לבצע לינקינג מחדש לשלושת
הקבצים על מנת לקבל תכנית ניתנת להרצה חדשה.
 17.6קבועים גלובליים
נניח שאנו כותבים תכנית המורכבת מכמה קבצים .לשם הפשטות נניח ששמות
הקבצים הם  .f1.cc, f2.ccעוד נניח כי ברצוננו להגדיר קבועים בהם נעשה
שימוש.
אם נגדיר בכל אחד משני הקבצים את הקבועים:
; const int N = 1
אזי הקבוע  Nיוכר רק בקובץ בו הוא מוגדר .לכן ,מבחינת תקינות התכנית ,נוכל
להגדיר בקובץ  f1.ccאת הקבוע:
; const int N = 1
ובקובץ f2.ccאת הקבוע:
; const int N = 2
217
ומבחינת הקומפיילר לא תהיה בכך כל בעיה )מבחינת הסגנון התכנותי זה עלול
להיות מבלבל ,אך זו סוגיה אחרת( .כמובן בכל קובץ עת יתעניינו בערכו של N
יתקבל הערך שהוגדר באותו קובץ.
אולם ישנם מצבים בהם ברצוננו שאותו קבוע ,אשר יוגדר פעם אחת בלבד ,יוכר
בקבצים שונים .כיצד נשיג אפקט זה?
התשובה היא שבקובץ מקור אחד בלבד )קובץ  , .ccלא קובץ כותר( נגדירו באופן:
; extern const int N = 1
בקובצי המקור האחרים ,או בקובץ כותר אותו מכללים בקובצי מקור אחרים
נצהיר עליו באופן:
; extern const int N
כלומר בלי לקבוע את ערכו.
מילת המפתח  externבקובץ בו הקבוע הוגדר ,ונקבע לו ערך ,אומרת שעליו להיות
מוכר בכל הקבצים המרכיבים את התכנית )ולא רק בקובץ בו הוא מוגדר ,כפי
שקורה עת לא כותבים את מילת המפתח .(extern
ההצהרה extern const int N ; :מורה למהדר שהקבוע הוגדר ,ונקבע לו
ערך ,בקובץ אחר ,וללינקר היא מורה שעליו לאתר את ערכו של הקבוע עת הוא כורך
את קובצי האובג'קט השונים לכדי תכנית שלמה אחת.
נראה דוגמה קטנה ביותר:
הקובץ  f1.ccהינו:
>#include <iostream
; extern const int N = 1
; )(void f
{ )(int main
; " " << std::cout << N
; )(f
; return 0
}
הקובץ  f2.ccהינו:
>#include <iostream
;
extern const int N
{ )(void f
; std::cout << N
}
218
 17.7מבוא לתכנות מונחה עצמים :עקרון הכימוס
בדוגמה האחרונה ראינו תכנית אשר משתמשת בנקודות ובמלבנים .לטיפוסי
משתנים של מבנים )כדוגמת נקודות ומלבנים( אנו קוראים לעתים אובייקטים .אחד
העקרונות התכנותיים המקובלים ביותר ,עת תכנית משתמשת באובייקט ,כדוגמת
נקודה או מלבן ,הוא עיקרון הכימוס ) .(encapsulationעקרון הכימוס קובע שהתכנית
הראשית כלל לא צריכה לדעת כיצד המתכנת שהגדיר את  struct Pointבחר לייצג
נקודה .האם הוא עושה זאת באמצעות קואורדינאטת ה x -וה y -של הנקודה )כפי
שאכן עשינו בדגומה מעל( ,כלומר בייצוג קרטזי? או שמא הוא בחר לייצג נקודה
באמצעות המרחק שלה מראשית הצירים )מה שקרוי הרדיוס של הנקודה( ,והזווית
בין הישר המחבר אותה לראשית הצירים ,לבין הכיוון החיובי של ציר ה) x -מה
שקרוי זווית אלפה של הנקודה( ,כלומר בייצוג קוטבי? לפי עקרון הכימוס ,התכנית
הראשית ,אמורה לקבל אוסף של פעולות על נקודה ולהשתמש בהן ,ורק בהן ,עת
היא מגדירה משתנים מטיפוס נקודה .כלומר ,התכנית הראשית לא תוכל לבצע
פעולה כגון) p. _x = 0; :עבור נקודה  ,(pשכן התכנית הראשית כלל לא אמורה לדעת
שנקודה מיוצגת באמצעות קואורדינאטת ה x -שלה .כיצד אם כן תוכל התכנית
הראשית לבצע פעולה פשוטה ובסיסית זאת? התשובה היא :בעזרת פונקציות
שהמתכנת שכתב את  struct Pointהעמיד לרשות התכנית הראשית ,למשל באמצעות
פעולה . set_x(p, 0) :באותו אופן ,בתכנית הראשית נוכל לכתוב set_radius(p, 1) :בלי
להתעניין כיצד בפועל מיוצגת נקודה; וזאת ,כאמור ,מתוך הנחה שהמתכנת שכתב
את הקוד של נקודה העמיד לרשותנו את שתי הפונ' הללו.
באופן דומה ,התכנית הראשית לא תוכל לכלול פקודה ,cout << p._x :אלא היא
תכלול פקודה cout << get_x(p) :או פקודה cout << get_radius(x) :ובאחריות
המתכנת שכתב את הקוד של נקודה להעמיד לרשות התכנית הראשית את שתי
הפונ' הללו.
על כן ,סביר להניח שעבור נקודה ,שנניח שהוגדרה כפי שהדגמנו קודם:
{ struct Point
; double _x, _y
}
תסופקנה לנו ,מעבר לפעולות שראינו )החזרת הרבע בו מצויה נקודה ,החזרת מרחק
בין שתי נקודות( גם הפעולות:
)void set_x(struct Point &p, double x
} ; { p. _x = x
)void set_y(struct Point &p, double y
} ;{ p. _x = y
void set_radius_and_alpha(struct Point &p,
)double r, double alpha
;{ p. _x = r / cos(alpha
p._y = r/ sin(alpa} ; :
)double get_x(const struct Point &p
} ; { return p._x
)double get_y(const struct Point &p
} ; { return p._y
219
)double get_raduis(const struct Point &p
} ; ){ return sqrt(p._x*p._x + p._y*p._y
)double get_alpha(const struct Point &p
} ; ){ return atan(y/x
התכנית הראשית ,תוכל להגדיר משתנה:
; struct Point p
כדי להציב את הנקודה בראשית הצירים תבצע התכנית הראשית:
; )set_x(p, 0
; )set_y(p, 0
וכדי לבדוק האם הנקודה נמצאת על הישר  y = xתשאל התכנית הראשית:
))if (get_x(p) == get_y(p
...
על דרך השלילה ,התכנית הראשית לא תוכל לבצע את הפעולות הבאות:
; p._x = 0
)if (p._x == p._y
...
 17.8יצירת ספריה סטאטית
נחזור לשני זוגות הקבצים ששימשו אותנו בדוגמה הראשונה בה חילקנו את
תכניתנו לקבצים. io.h, io.cc, arithmetic.h, arithmetic.cc :
נניח שאנו סוברים כי הפונ' שכתבנו בקבצים אלה מאוד שימושיות ,ועל כן נרצה
להשתמש בהן בתכניות רבות שנכתוב בעתיד .כדי לייעל את תהליך הקומפילציה של
אותן תכניות נרצה לצור משני הקבצים הללו ספריה ) ,(libraryולא רק לכרוך את
שני הקבצים הללו עם תכניות עתידיות שנכתוב ,כפי שעשינו בדוגמות הקודמות.
יצירת ספריה עדיפה על-פני כריכת הקבצים  io.o arithmetic.oעם התכנית
המזמנת את הפונ' הנכללות באותם קבצים שכן היא מקצרת את זמן ההידור.
בתכניות שלכם ,ההידוק לוקח הרף עין ,אולם בתכניות גדולות ההידור עלול לקחת
זמן רב )סדר גודל של דקות ואפילו שעות( ,ועל כן המוטיבציה לקצרו רבה.
הסיבה לכך שמשך ההידור מתקצר היא שהלינקר צריך לפתוח פחות קבצים )קובץ
ספריה יחיד ,במקום שני קובצי אובג'קט( .לקובץ הספרייה יש בדרך כלל אינדקס,
ולכן נקל לאתר בו פונ' אותה יש לכרוך עם התכנית.
בסעיף זה נלמד כיצד יוצרים ספריה סטאטית .תכונתה של ספריה סטאטית היא
שבזמן הכריכה הלינקר שולף מהספרייה את קטעי הקוד הדרושים ,ומצרף אותם
לקובץ הניתן להרצה שהוא יוצר .עת התכנית רצה כבר אין יותר פניה לקובץ
הספרייה ,ולכן ,למשל ,ניתן להעביר את הקובץ הניתן להרצה למחשב אחר ,בלי
להעביר איתו את קובץ הספרייה.
יצירת הספרייה מתחילה ,כמובן בקימפול שני הקבצים ,וקבלת קובצי אובג'קט:
g++ -Wall –c add_sub.cc
g++ -Wall –c io.cc
220
עתה ,כדי לצור משני קובצי האובג'קט הללו קובץ ספריה ,נשתמש בפקודת ה-
 Shellשל יוניקס הקרויה  ar) .arהוא קיצור של  archiveאו קובץ ארכיב(.
אופן השימוש בפקודה:
ar rc libadd_sub_io.a add_sub.o io.o
נסביר:
א .כאמור הפקודה אותה אנו מפעילים היא הפקודה .ar
ב .זוג האותיות  rcהנכתבות מייד אחרי שם הפקודה הן קיצור שלreaplce :
 createוהן מורות למערכת ההפעלה לייצר את הספרייה אם היא אינה קיימת,
או להחליף את הספרייה הקיימת אם כבר הייתה אחת כזאת.
ג .המרכיב השלישי  libadd_sub_io.a :מציין את שם הספרייה שתווצר .שם
ספרייה חייב להתחיל בשלוש האותיות  ,libולהסתיים בזוג התווים) .a :התו
 aעומד בשביל.(archive :
ד .המשך הפקודה מציין את שמתו הקבצים שנכלול בספרייה .במקרה שלנו אלה
הקבצים .add_sub.o io.o
קובץ הספרייה שנוצר הוא חסר אינדקס .אינדקס בקובץ ספריה ,כמו אינדקס
המוכר לכם מספר ,עוזר לאתר במהירות פונ' רצויה בקובץ הספרייה )כפי שהוא
עוזר לאתר במהירות נושא רצוי בספר( .כדי לייצר אינדקס לקובץ הספרייה
)אינדקס שיכלל בקובץ הספרייה עצמו( ניתן להריץ את פקודת ה ar -באופן הבא:
ar rcs lib_add_sub_io.a add_sub.o io.o
הדגל s :מורה שיש לייצר את הספרייה עם אינדקס.
לחילופין :ניתן להוסיף לספריה הקיימת אינדקס באמצעות הפקודה:
ranlib libadd_sub_io.a
הפקודהnm –s libadd_sub_io.a :
מציגה לכם את תכולתו של האינדקס שנוצר.
 17.9יצירת ספריה דינאמית )משותפת(
עת יצרנו ספריה סטאטית הלינקר ,בשלב הכריכה ,צירף לתכנית שלנו את
הפונקציות אותן זימנו מתוך הספרייה הסטאטית ,כך שהתקבל קובץ ניתן להרצה
) (executableשכבר אינו תלוי בספרייה .לדוגמה :אם נעביר קובץ זה למחשב
אחר )כמובן אחד עם אותה שפת מכונה( ,התכנית תוכל לרוץ ַבמחשב האחר ,גם אם
לא נעביר ַלמחשב האחר את קובץ הספרייה .צדו השני של המטבע :אם במחשב
שלנו רצות תכניות רבות שנכרכו באופן סטאטי עם אותה פונ' ספרייה )לדוגמה:
 ,(cinאזי כל אחת מהן גדלה בנפחה בשיעור הנפח של אותה פונ' ספרייה .מכיוון
שהזיכרון הראשי הוא משאב מוגבל ,אזי יש טעם לפגם בכך שאנו מגדילים כל אחת
ואחת מהתכניות באותו קוד )לדוגמה :בפונ'  .(cinעל כן ,במצבים בהם פונ'
הספרייה נכרכת לתכניות רבות ,נעדיף לכרכה באופן שקוד הפונ' לא ייכרך עם כל
קובץ הרצה בנפרד ,נעדיף שקוד הפונ' יוחזק פעם יחידה בזיכרון ,עבור כלל התכניות
הזקוקות לקוד זה .ספרייה דינאמית היא הכלי שיעזור לנו להשיג זאת.
עת אנו יוצרים ספרייה דינאמית ,במלים אחרות ספרייה משותפת ,הלינקר ,בשלב
הכריכה ,לא מצרף את קוד הפונ' הדרושה לקובץ הניתן להרצה שלנו ,אלא שותל
בקובץ רק בדל ) ,(stubכלומר מעין 'קצה חוט' ,אשר יאפשר ,בעת ריצת התכנית,
לזמן פונ' שאינה חלק מקובץ התכנית שלנו ,פונ' שמצויה בספרייה דינאמית אשר
שוכנת בזיכרון פעם יחידה עבור כל התכניות שמריצות אותו קוד .לדוגמה :בהנחה
ש cin -שוכנת בספרייה דינאמית ,הקוד של  cinנמצא בזיכרון פעם יחידה בלבד.
221
עת תכנית א' מזמנת את הפונ' פונים להריץ את קוד הפונ' ,כפי שמצוי בזיכרון )גם
אם לא בקובץ התכנית הניתנת להרצה של תכנית א'(; עת תכנית ב' מזמנת את הפונ'
פונים לאותו קוד ,אשר כאמור מצוי בזיכרון פעם יחידה .התוצאה :מצד אחד קובץ
התכנית )ה (executable -שלנו כבר לא עצמאי ,הוא חייב שבזיכרון יהיה גם קובץ
הספרייה עת התכנית רצה; אולם מצד שני ,גם אם תכניות רבות הרצות במקביל,
ומצויות בזיכרון המחשב ,כוללות את הפונ' ,הפונ' נמצאת בזיכרון רק פעם אחת,
וכך נחסך בזבוז של הזיכרון )בזבוז שנגרם עת אותה פונ' מצויה בזיכרון פעמים
רבות ,כחלק מתכניות רבות(.
לשימוש בספרייה דינאמית יש עוד מחיר :טעינת התכנית לזיכרון לשם ריצתה,
ובהמשך הסתעפות לפונ' שאינה חלק מהתכנית ,הינם איטיים יותר.
כדי לצור מזוג הקבצים שראינו בעבר ספרייה דינאמית נקמפלם באופן:
g++ -Wall –c –fPIC add_sub.cc
g++ -Wall –c –fPIC add_sub.cc
פקודה ההידור כולל את הדגל המוכר לנו  –cהמורה שאין לייצר תכנית ניתנת
להרצה ,אלא רק קובץ אובג'קט ,ומעברל ו את הדגל )החדש לנו( . -fPIC :דגל זה
הוא שמאפשר לכלול את האובג'קט בספרייה דינאמית כפי שמייד נייצר.
את הספרייה עצמה נייצר באמצעות הפקודה:
g++ -shared -o libadd_sub_io.so add_sub.o io.o
הספרייה תקרא libadd_sub_io.so :שמה בהכרח יתחיל ב lib :וייגמר ב ,so :היא תכלול
את הקבצים כמתואר ,ותהיה משותפת so ) .הוא קיצור של (shared object
את התכנית הראשית נהדר ,כרגיל ,באופן:
g++ -Wall –c main.cc
פקודת הכריכה תהייה:
g++ main.o -L. -ladd_sub_io –o my_prog
הדגל  -Lמורה היכן יש לחפש ספריות ,והמיקום הוא . :כלומר גם במדריך הנוכחי
)מעבר למקום בו בד"כ הכורך יודע לחפש את הספריות הסטנדרטיות( .הדגל –l
)האות אנגלית  Lקטנה( מורה שיש לכלול את הספרייה libadd_sub_io.so :כאשר
את הרישא ) (libוהסיפא ) (.soאין כותבים.
במידה ויש לנו הן ספריה סטאטית  ,libadd_sub_io.aוהן ספריה דינאמית:
 libadd_sub_io.soיעדיף הכורך לכרוך את תכניתנו עם הספריה הדינאמית.
כדי להריץ את התכנית יש ,ראשית ,להורות לטוען הדינמי היכן הוא יימצא את
הספרייה הדינמית שלנו .בהנחה שאנו עובדים ביוניקס על ה tcsh :Shell -נעשה
זאת על-ידי:
setenv LD_LIBRARY_PATH /cs/teach/yoramb/intro2cs2/dividing_program/example1/dynamic_lib
כלומר :אנו מכניסים למשתנה  LD_LIBRARY_PATHאת הנתיב המלא לספריה
שלנו .כזכור ,פקודת ה pwd :Shell -תוכל לתת לנו נתיב זה )בהנחה שאנו במדריך
בו מצויה הספרייה(.
הרצת התכנית היא כרגיל ,בדוגמה מעל. my_prog :
222
במידה ואנו מתעניינים בכך ,הפקודה ldd a.out :תלמד אותנו היכן מצויות הספריות
בהן תוכניתנו עושה שימוש ,בפרט הספרייה שיצרנו
223
				
											        © Copyright 2025