הרצאה 1 – מבוא למדמ“ח בשפת C
שלום לכולם! בקורס זה נלמד איך פותרים בעיות שונות באמצעות תוכניות מחשב שנכתוב בעצמנו, בשפת תכנות שנקראת C. אנחנו נתחיל מהיסודות הבסיסיים ביותר ונסביר הכל, עם מגוון רחב של דוגמאות והסברים.
אם הגעתם לקורס ללא רקע בתכנות - זהו המקום בשבילכם! אנחנו נבנה את היסודות בצורה מעמיקה ולא נדלג על אף פרט. אם הגעתם עם רקע, זהו גם המקום בשבילכם! אנחנו נעמיק בפתרון תרגילים, נכיר את כל הטריקים להצלחה במבחן ונפרק את הבעיות הקשות ביותר.
למי הקורס מיועד
מטרת הקורס היא ללמוד תכנות בשפת C מהבסיס ומעלה. יש כמה קטגורויות שיכולות להנות ממנו:
- סטודנטים - אם אתם לוקחים קורס מבוא לתכנות בשפת C באוניברסיטאות השונות, הקורס הזה בנוי להצלחה שלכם במבחן ובקורסי העתיד. נבנה בסיס חזק לקונספטים השונים שלומדים בקורס ונפתור כמה שיותר תרגילים ברמת מבחן על מנת שתוכלו להצליח בו. אפשר להשתמש בקורס לאורך הסמסטר, לקראת המבחן או כחזרה כללית על החומר
- הכנה לראיונות עבודה - הרבה חברות עורכות ראיונות לתפקידי תוכנה, מהנדסי חשמל ומקצועות אחרים בשפת C או ++C. שליטה בשפה מהמאפיינים הבסיסיים ביותר למורכבים ומאתגרים שביניהם היא קריטית להצלחה בראיונות, וגם כאן הקורס מותאם להצלחתכם.
- לימוד תכנות כללי - הקורס מלמד שפה אוניברסלית לתכנות, ממנה אפשר להתקדם למגוון עצום של נושאים ושפות אחרות.
- לימוד מחשבים כללי - למידת שפת C טומנת בחובה הבנה עמוקה של עקרונות בסיסיים במדעי המחשב - זכרון, אלגוריתמיקה ועוד. הקורס יעשה זאת ועוד.
בהצלחה לכולם!
הקדמה להרצאה זו
בהרצאה זו נעשה שני דברים עיקריים:
- נפגוש כמה מושגים בסיסיים בעולם התכנות והמחשוב.
- נחשף לדוגמאות קוד עם קונספטים שונים (אותם כמובן עוד לא למדנו).
המטרה היא לקבל תחושה כללית וטעם ראשוני ממה שנלמד לאורך הקורס. אין צורך להבין אותם בצורה עמוקה, אלא רק להתרשם. בהמשך נפרק כל מרכיב שנפגוש בהרצאה זו (ועוד רבים אחרים) ונהפוך למומחים, אבל מטרת ההרצאה הראשונה היא רק לקבל תחושה כללית.
בסיס מדעי המחשב
הגדרה: תוכנית היא סדרה של פקודות מחשב שמטרתן ביצוע משימה מסויימת.
כמה דוגמאות לתוכניות:
- מחשבון: אם תפתחו את אפליקציית המחשבון במחשב שלכם, תוכלו ללחוץ על האייקונים של המספרים ולבצע כל חישוב שתרצו. לא קשה לדמיין מה הפעולות שהמחשב עושה בשביל להמיר את הקלט לפלט הרצוי.
- ChatGPT: תוכנית שתוכלו להזין אליה טקסט ולקבל תשובה כלשהי. במקרה זה קשה מאוד לדעת מהן הפעולות שהמחשב עושה בשביל להוציא את הפלט הרצוי.
- תחזית: אם תפתחו את אפליקציית מזג האוויר בטלפון שלכם, יוצג ללא צורך בקלט מצידכם מהי התחזית לימים הקרובים.
חומרה, תוכנה ושפות תכנות
נהוג להפריד את הנדסת המחשבים ומדעי המחשב לשני חלקים:
- חומרה (hardware), ההתקנים הפיזיים עליהם שקוראים את התוכנה ומבצעים אותה.
- תוכנה (software), רצף פקודות שמכונה מבצעת על מנת להשלים משימה כלשהי.
הגדרה: שפת תכנות היא דרך מוסכמת לכתוב תוכנה שלאחר מכן החומרה תבצע.
איך זה מתבצע בפועל?
- במחשב, בטלפון ובמכשירים אחרים שאנחו משתמשים בהם קיימת יחידה חומרתית בשם מעבד (CPU - Core Processing Unit).
- תפקיד המעבד הוא לבצע את תוכניות המחשב שהמשתמש מורה לו לבצע.
- המעבד עובד עם יחידה חומרתית נוספת הנקראת זכרון, בו נשמר המידע בו המעבד משתמש.
- תוכניות מחשב כתובות בשפה הבינארית - רצף של 0 ו-1 אותן הוא מפורש בדרך מוסכמת.
- הדרך המוסכמת לפרש את השפה הבינארית נקראת שפת מכונה. לסוגי מחשבים שונים (למשל, מחשבי אפל לעומת ווינדוס) יש בדרך כלל שפת מכונה שונה.
- קשה מאוד לכתוב תוכניות מורכבות בשפת מכונה, ולכן המתכנתים הראשונים המציאו את שפות התכנות - טקסט קריא יותר לכתיבת תוכניות, שבהמשך יתורגם לשפת מכונה אותה המעבד מבין.
Note
בהמשך נכיר לעומק את השפה הבינארית, אך לא נתעסק בשפת מכונה בקורס זה. בקורסי המשך כמו מערכות ספרתיות ומבנה מחשבים בדרך כלל לומדים לעומק כיצד שפת מכונה ספציפית (למשל, Risc-V) מתרגמת לפעולות חומרתיות על מעבד.
חומרה
בעולם המודרני אנחנו פוגשים בכל מקום מכשירים מסוגים שונים - ממחשבים, טלפונים למכוניות ואף מכונות כביסה חולקים כולם מבנה חומרתי דומה.
נפרוט את החלקים השונים:
- המעבד (CPU - Central Processing Unit) מכיל יחידה לוגית שמבצעת חישובים שונים, ויחידת בקרה שמורה ליחידה הלוגית איזה חישובים לבצע.
- המעבד מתקשר עם יחידת הזכרון, בה נשמר המידע בה משתמש המעבד. הזכרון לרוב מחולק ליחידה בשם RAM (Random Access Memory) שמכילה זכרון נדיף, ויחידה בשם דיסק שמכילה זכרון שאינו נדיף.
- במכשירים רבים קיימות יחידות פריפריאליות שמזינות קלט למכשיר (למשל - מקלדת) או מוציאות פלט ממנו (למשל - מסך).
Note
לארכיטקטורה זו קוראים Von Neumann Architecture, על שם מתמטיקאי ומהנדס הונגרי בשם ג׳ון פון נוימן, שפרסם מודל ראשון שלה ב-1945!
ברוב המקרים, המעבד מוגבל לביצוע פקודות פשוטות מאוד - חיבור וחיסור, טעינה מהזכרון וכתיבה אליו, וכו׳ - מפקודות פשוטות אלו מרכיבים תוכניות מורכבות יותר. כמו כן, מחשבים מודרניים מבצעים פעולות בקצב גבוה מאוד: קצב העבודה של מחשב מודרני הוא בסביבות ה-3.5GHz, או 3.5 מיליארד פעולות בשנייה.
שפות נמוכות וגבוהות
יש המון שפות תכנות שונות, ונהוג לסווג אותן על סקאלה שבין שפות תכנות נמוכות לגבוהות (high level language & low level language). שפת תכנות נמוכה היא שפה שקרובה יותר לשפת מכונה – כל פעולה שנכתבת בשפה נמוכה לרוב הופכת (לאחר התרגום) למספר מצומצם של פעולות בשפת מכונה.
שפות גבוהות יותר, לעומתן, רחוקות יותר משפת מכונה ולכן כל פעולה שמתבצעת בהן יכולה להפוך למספר גדול של פעולות בשפת מכונה. שפות גבוהות יותר יכולות להיות פשוטות יותר לכתיבה, אבל מכיוון שהן “מסתירות” מהמתכנת חלק גדול ממה שמתבצע מאחורי הקלעים, הן מאפשרות שליטה פחות מדויקת (אם בכלל) בכל הפרמטרים של ביצוע תוכנית – זה יכול להוביל לתוכניות יעילות פחות בפרמטרים מסוימים. כך או כך.
Important
שפות הן עיליות/גבוהות רק ביחס זו לזו, והיותה של שפה עילית או תחתית ביחס לאחרת לא קובע האם היא חשובה או רלוונטית יותר בעולם המודרני.
שפת C
בקורס זה נלמד את שפת C, אחת משפות התכנות המוכרות והנפוצות בעולם. השפה יצאה לאור ב-1972 ומאז נמצאת בשימוש נרחב בעולם התכנות ומתאפיינת בפשטות וגמישות, תוך כדי שליטה מדויקת מאוד על המכונה עליה היא רצה. תוכנות מהחשובות בהיסטוריה נכתבו בשפת C - מערכות ההפעלה Windows, Linux ו-MacOS/iOS הן הידועות מביניהן.
ביחס לשפת מכונה C נחשבת שפה גבוהה - עם זאת, ביחס לרבה שפות תכנות מודרניות אחרות (למשל Python), C נחשבת לשפה נמוכה יחסית. כך או כך, היא נמצאת בשימוש נרחב במגוון עצום של שימושים, והפשטות שלה הופכת אותה לטובה מאוד כבחירה לשפת תכנות ראשונה.
בהרצאה הזו נפגוש כמה תוכניות בסיסיות על מנת לקבל תחושה כללית לשפה, ולאחר מכן בהרצאה הבאה נתחיל להגדיר אובייקטים בצורה מסודרת ולכתוב תוכניות משלנו.
Note
שפת C נקראת כך מכיוון שהיא פותחה לאחר שפה קודמת ופרימיטיבית יותר בשם B. באופן מעט מכעיס לא הייתה שפה בשם A לפני B, אלא שפה בשם BCPL. השפה BCPL היוותה את הבסיס לשפה B, שבעצמה הפכה בסופו של דבר לשפת C.
שפת C נכתבה על מנת לשלוט בצורה מדויקת ופשוטה במכונה עליו היא רצה. בהתאם לכך, בשביל ללמוד איך לתכנת בשפה צריך ללמוד כיצד מחשב בנוי ועובד - מהו זכרון,
Hello World
אחד הדברים הבסיסיים ביותר שתוכנית מחשב יוכלה לעשות היא לכתוב על המסך הודעה שהמשתמש יכול לקרוא. כשלומדים שפת תכנות לראשונה, נהוג להציג תוכנית שמדפיסה על המסך את התווים “!Hello world” – בשפת C, זה יראה כך:
#include <stdio.h>
int main() {
printf("Hello world!\n");
}
פירוק לרכיבים
ללמוד שפת תכנות בפעם הראשונה יכול להראות מפחיד, במיוחד למי שלא תכנת בעבר – מה כל הסימבולים אומרים? איך אדע להשתמש בהם? אנחנו נבנה לאורך הקורס את אוצר המילים שלנו בצורה הדרגתית, וכעת נתחיל במרכיבים החיוניים בלבד. נבחן בפירוט את השורה הבאה:
printf("Hello world!\n");
printf– זוהי דוגמא לפונקציה (function), אובייקט שנעבוד איתו המון בקורס ונגדיר בהמשך בצורה מסודרת. בקצרה עכשיו – פונקציה היא קטע קוד שאפשר “לקרוא” לו מכל מיני מקומות ובכך להפעיל אותו. לפונקציות קוד, כמו פונקציות מתמטיות, יש ארגומנטים שהן מקבלות וערך שהן מחזירות. בדוגמא הזו, הארגומנט הוא"Hello world", ואנו לא משתמשים בערך שהיא מחזירה. הפונקציהprintf(קיצור של print formatted) מדפיסה למסך את הארגומנטים שהיא מקבלת. נשים לב שהדרך לקרוא לפונקציה היא עם סוגריים () וביניהם הארגומנטים שלה, בדוגמא לכך שבמתמטיקה כותבים \(sin(x)\) עבור קריאה לפונקציה \(sin\) עם הארגומנט \(x\). בהמשך נפרט בהרחבה על איך משתמשים בפונקציות ואיך לכתוב פונקציות בעצמנו."Hello world!\n"– זוהי דוגמא למחרוזת (string) - אוסף של תווים, שגם עליהם נרחיב מאוד בהמשך הקורס. ב-C מחרוזות מוקפות בגרשיים כפולים" ", והרבה פעמים משמשות ליצוג מייצגות טקסט כלשהו. במקרה זה הטקסט שרצינו להדפיס הוא “Hello world”, בעוד שהתוn\הוא סימן מוסכם ל-“שורה חדשה” – כלומר, הפונקציה printf תדפיס"Hello world", תגיע לתוn\ואז תדפיס שורה חדשה. נשים לב שבמקרה זה נתנו לפונקציה ארגומנט אחד והוא"Hello world!\n"– בהמשך נראה שניתן לתת לפונקציות מספר גדול כרצוננו של ארגומנטים.;– בסוף השורה נכתב התו';'. התו הזה מסמן סוף של “משפט” בקטע הקוד שכתבנו. בהמשך נרחיב על משפטי קוד, אבל כרגע ניתן להתייחס ל-;כמו נקודה בין משפטים שונים.
שורה זו מרכיבה את עיקר הלוגיקה של התוכנית הפשוטה הזו. שאר המרכיבים מעט מורכבים יותר, אבל נסביר גם אותם בקצרה:
<include <stdio.h#- ב-C שורת קוד המתחילה ב-#נקראת מאקרו (macro). בקרוב נפגוש תוכנית חשובה הנקראת קומפיילר (compiler) - זוהי תוכנית שתפקידה לקחת את הקוד שכתבנו ולתרגם אותו לשפת מכונה. שורת מאקרו אינה מייצגת קוד בפני עצמה, אלא הוראה לקומפיילר לבצע פעולה כלשהי. במקרה זה, פקודת ה-include#מורה לקומפיילר להדביק את הקובץstdio.hבקובץ שלנו - הסיבה שאנו זקוקים לכך היא על מנת לקבל גישה לפונקציהprintf, הנמצאת בקובץ הזה.()int main– זו עוד פונקציה. כשהקומפיילר קורא את הקוד שלנו, הוא מחפש פונקציה בשם main ושם קובע את נקודת ההתחלה של התוכנית.{}– התווים ‘{’ ו-‘}’ משמשים בשפת C לאגד ביחד מספר משפטים ליצירת בלוק קוד. במקרה זה התווים האלה משומשים על מנת לאגד ביחד את “הגוף” של פונקציית ה-main בתוכנית.- נשים לב שלאחר ה-
{הראשון, השורה הרביעית מתחילה ב-4 רווחים. זוהי קונבנציה הנקראת הזחה או אינדנטציה (indentation) - כל ״בלוק״ קוד חדש מתחיל במספר רווחים על מנת להפריד אותו ויזואלית מאחרים. בשפת C הקומפיילר מתעלם לחלוטין מהזחות ולכן מדובר בקונבנציה בלבד, אבל היא שימושית מאוד בהפיכת הקוד לקריא יותר.
קריאת קלט מהמשתמש
עכשיו כשאנחנו קצת מכירים מרכיבים בסיסיים של תוכנית בסיסית, נכתוב תוכנית פשוטה נוספת:
#include <stdio.h>
int main() {
int number;
scanf("%d", &number);
printf("You entered %d\n", number);
}
יש הרבה מרכיבים שאנחנו כבר מכירים מהתוכנית הקודמת וכמה חדשים. נסביר אותם בקצרה:
-
int number– זוהי נקראת “הצהרה” (declaration) של משתנה. הצהרות מאפשרות לנו לתת שם למשתנה כלשהו איתו נבצע פעולות וחישובים. לכל משתנה יש טיפוס (type) שמגדיר איך התוכנית מפרשת אותו וכמה מקום הוא צורך בזכרון. להצהרה תמיד יש שני חלקים – הטיפוס (type) של המשתנה ולאחר מכן השם שלו. במקרה זה הצהרנו על משתנה מטיפוס int (קיצור עבור integer, או מספר שלם) ששמו number. -
scanf("%d", &number)- זוהי שורה מורכבת יותר, נפרק אותה לרכיביה:scanf- קיצור של scan formatted, זוהי פונקציה מאותה ״משפחה״ כמוprintשמתעסקות בקבלת קלט מהמשתמש והצגתו על המסך.d%- הפונקציהscanfצריכה לדעת איזה סוג משתנה היא צפויה לקבל מהמשתמש בשביל לדעת איך לכתוב אותו בזכרון. הסימוןd%הוא סימון מוסכם למשתנה מסוגint- בהמשך נראה סימונים נוספים כמוlf%ו-s%שיסמנו משתנים מסוגים אחרים.number&- הפונקציהscanfשומרת את את המספר שהמשתמש כתב לתוכנית בתוך המשתנהnumberבשביל שנוכל לעשות בו שימוש אחרי זה. נשים לב לשימוש ב-&לפני שם המשתנה - אנחנו צריכים עוד מעט רקע בשביל להבין מה תפקידו פה בדיוק ולכן נשים לב לקיומו אבל לא ננתח אותו כרגע. ככלל אצבע לבינתיים, כאשר משתמשים ב-scanfבשביל לקרוא קלט מהמשתמש נשתמש ב-&לפני שם המשתנה - ב-printfלא נעשה זאת.
-
לאחר מכן אנחנו מדפיסים את המספר שהכניס המשתמש. גם כאן אנחנו משתמשים במזהה
d%בשביל לספר ל-printfאיזה סוג משתנה היא צריכה להדפיס, ואז מעבירים אותו כארגומנט נוסף לפונקציה.
איך לומדים בקורס?
חלק גדול מלמידת תכנות היא לדעת איך ללמוד, כיצד להתמודד עם בעיות ואתגרים. מהנדסים מוכשרים בנו בעבר כמעט כל סוג תוכנית שניתן לחשוב עליה, ורוב מוחלט של הבעיות שתתקלו בהן במהלך הלמידה נפתרו כבר בעבר - אפשר למצוא תשובות לשאלות רבות בחיפוש חכם באינטרנט. גם עבודה מושכלת עם מודלי AI יכולה לתת מענה לשאלות שונות.
עם זאת, אם המטרה שלנו היא ללמוד לתכנת עצמאית - יתכן ובשביל להצליח במבחן, בראיון עבודה או בשביל לפתח את הכישורים הללו באופן כללי - שימוש מופרז ב-AI היא סכנה אמיתית להצלחה שלנו. השימוש ב-AI דומה בהקשר הזה לנהיגה במכונית אוטונומית: מחקרים שנעשו על נהגים שעברו למכונית אוטונומית 50% מזמן הנהיגה שלהם מצאו כי הם איבדו בקצב מהיר את יכולת הנהיגה העצמאית שלהם, היו משמעותית בסכנה גדולה יותר לתאונה והתקשו ברכישת הכישורים האלו בחזרה. במידה ולמרות זאת החלטתם להשתמש ב-AI, נסו להשתמש בו יותר בתור מנוע חיפוש ולא בתור פותר בעיות.
למשל, במקום לכתוב:
“make this do binary search correctly”
כתבו:
“guide me in the steps of writing a binary search algorithm in C, write no code and nudge me along so I can learn instead of telling me the answer”
לכן, בקורס חייבים להקפיד על למידה עצמאית - חייבים לפתוח את יכולת ההתמודדות העצמאית מול שאלה קשה או נקודה למחשבה. במידה ולא נפתח את הכישורים האלו ונפתח תלות ב-AI לכל צרכינו, סביר מאוד להניח שלא נצליח בסיטואציית אמת של מבחן או ראיון עבודה. לכן, ההמלצה שלי היא ללמוד ללא שימוש במודלי AI בכלל. אין ספק כי ל-AI יש תפקיד עצום לשחק בעתיד של כל מתכנת, אבל פוטנציאל הנזק שלו להצלחה שלנו בסיטואציה בה הוא לא ימצא היא ענקית.
לכן, לאורך כל הקורס נציג תרגילים מסוגים שונים - תרגילים מחשבתיים, טכניים וכמובן בעיות קוד לפתרון. נסו לעצור את ההקלטה ולענות על השאלות בעצמכם. חלק מהשאלות יהיו קלות וישמשו לתרגול, וחלק מהשאלות יהיו מורכבות יותר וידרשו חומר המשך או רעיונות יצירתיים. במידה ויש שאלה שלא הצלחתם בשום צורה למצוא לה פתרון,
שאלות ותרגילים
- מצאו את דף הויקיפדיה של הפונקציה
printfומצאו את הטבלה בה מפרטים את כל המזהים להדפסות (לדוגמאd%). - מהי שפת מכונה? למה קשה לכתוב בה תוכניות מורכבות?
- מה ההבדל בין שפת תכנות נמוכה לגבוהה?
- מה מיוחד בפונקציית ה-
mainבשפת C? - מה תפקיד הפונקציות
printfו-scanf? - איזה שורת מאקרו (macro) צריך לכתוב בקוד לפני שניתן להשתמש ב-
printfו-scanf?
דוגמאות נוספות
כעת נקבל טעימה ממספר קונספטים מתקדמים יותר, אותם נלמד בהרחבה בהמשך הקורס.
קבלת קלט מהמשתמש
#include <stdio.h>
int main() {
// This program will print the sum of 3 numbers
/*
This is the start of a
multiline comment
*/
int sum, number1, number2, number3;
scanf("%d", &number1);
scanf("%d", &number2);
scanf("%d", &number3);
sum = number1 + number2 + number3;
printf("sum = %d\n", sum);
}
בתוכנית הזו יש שלושה מרכיבים חדשים:
- בתחילת הקוד מופיעות הערות (comments).כשנרצה לכתוב טקסט חופשי על הקוד שמסביר אותו, נותן לו קונטקסט או עוזר בדרך כזו או אחרת, נשתמש בהערות. הקומפיילר מתעלם לחלוטין מהערות ולכן ניתן לכתוב בהם מה שנרצה. יש בקוד את שני סוגי ההערות בהן תומכת השפה:
- שורה המתחילה ב-
//הופכת להערה עד לסוף השורה. - הסימון
*/הוא סימון לתחילת הערה מרובת שורות (multiline comment), בעוד שהסימון/*מסמן את סוף ההערה. כך ניתן לכתוב הערות שאורכן מספר שורות בלי להתחיל כל אחת מהן ב-//.
- שורה המתחילה ב-
- הצהרנו בתוכנית הזו על ארבעה משתנים –
number1, number2, number3, sum. נשים לב שלקיצור ניתן להצהיר על מספר משתנים מאותו סוג באותה שורה עם רווחים ביניהם, כפי שעשינו. - לאחר שקראנו 3 מספרים מהמשתמש, ביצענו חישוב – חיברנו את שלושת המספרים וכתבנו את התוצאה למשתמש sum. זו דוגמא לפעולה בסיסית בין כמה משתנים – בהמשך נראה פעולות נוספות שניתן לצפות שאפשר לעשות, כמו חיסור, כפל, חילוק ועוד. נשים לב ל-
+בין המספרים - זוהי דוגמא לאופרטור שפועל בין שני משתנים.
לולאות
מה היה קורה אם היינו רוצים לחבר מאה משתנים? האם היינו צריכים להצהיר על מאה משתנים? כמובן שלא – השפה עוזרת לנו במקרה זה ע“י קונספט שנקרא לולאה, כמו בתוכנית הבאה:
#include <stdio.h>
int main() {
int sum, i, num;
sum = 0;
i = 0;
while (i < 5) {
scanf("%d", &num);
sum = sum + num;
i = i + 1;
}
printf("sum = %d\n", sum);
fflush(stdout);
}
בתוכנית זו מופיעה הדוגמא הראשונה שראינו ללולאה – דרך לבצע את אותו קטע קוד מספר כלשהו של פעמים. הלולאה בקוד היא מסוג while וניתן לקרוא אותה כך - while (כל עוד) התנאי שבסוגריים מתקיים, בצע את קטע הקוד שבתוך הבלוק. במקרה זה אתחלנו משתנה בשם i ל-0, וביצענו את קטע הקוד כל עוד הוא קטן מ-5. בכל ביצוע של הלולאה הגדלנו אותו ב-1, ולכן לאחר 5 איטרציות הלולאה תסיים ונדפיס את הסכום. זוהי דרך פשוטה לחבר בין מספר כלשהו של מספרים. ביצוע פעולות על האיברים ברשימה מסוימת היא תבנית נפוצה מאוד - בהמשך נראה סוג נוסף של לולאות בשם for הנראה כך:
#include <stdio.h>
int main() {
int sum = 0, num;
for (int i = 0; i < 100; i++) {
scanf("%d", &num);
sum = sum + num;
}
printf("sum = %d\n", sum);
}
לולאת ה-for מכילה מעט יותר מרכיבים (וכמובן שאת כולם נלמד בהמשך). את הלולאה הספציפית שמופיעה כאן ניתן לקרוא כך - התחל עם משתנה int i = 0, בצע את הלולאה כל עוד i < 100, ובכל איטרציה הגדל את i ב-1.
קומפילציה והרצה
עכשיו כשפגשנו כמה תוכניות והכרנו את הרכיבים הבסיסיים, נתעסק מעט בתאוריה ובצורה שבה טקסט שכתבנו בעצמנו הופך להוראות שרצות על המכונה.
נזכיר - המעבד יודע לקרוא שפת מכונה, 0 ו-1 מסודרים בסדר מוסכם אותם הוא מבצע זה אחר זה. צריך לבצע תרגום מהשפה שכתבנו לשפת מכונה - לטובת משימה זו נכתבה תוכנית חשובה מאוד בשם קומפיילר (compiler). הקומפיילר קורא את הקוד שכתבנו, מנתח אותו ומנסה לבצע את התרגום משפת מכונה. אם ביצענו טעות כלשהי, הקומפיילר יכול לבחור לא לקבל אותו - אם כתבנו משהו שלא עומד בדיוק בתחביר (syntax) של השפה, הקומפיילר ידחה את בקשת הקומפילציה שלנו וידפיס שגיאה כלשהי. כמו כן, הקומפיילר יכול להחליט להדפיס אזהרה כלשהי, למשל אם ביצענו פעולה שהיא חוקית אבל הוא חושד שלא תוביל לתוצאה שאנחנו מתכוונים אליה. הקומפיילר הוא כלי מאוד שימושי וכדאי ללמוד להשתמש בו בצורה יעילה.
קוד שאינו עובר קומפילציה
נבחן את התוכנית הבאה:
int main() {
int x = y;
printf("%d\n", x);
}
אינטואטיבית, התוכנית אינה מרגישה הגיונית. מהו y? איך אפשר לתת ל-x ערך של משתנה שאינו קיים? כמו כן, לא כתבנו #include <stdio.h> כפי שראינו שיש לעשות קודם.
הקומפיילר יסרב לבצע קומפילציה של התוכנית, וידפיס לנו את השגיאה הבאה:
bad_comp.c:2:13: error: use of undeclared identifier 'y'
2 | int x = y;
| ^
bad_comp.c:3:5: error: call to undeclared library function 'printf' with type
'int (const char *, ...)'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
3 | printf("%d\n", x);
| ^
bad_comp.c:3:5: note: include the header <stdio.h> or explicitly provide a declaration for
'printf'
הקומפיילר מדפיס מידע שימושי מאוד בשביל לתקן את הקומפילציה של התוכנית!
- ראשית, הוא מסמן לנו במדויק מה הבעיה.
use of undeclared identifier, עם סימון על המשתנהy- הקומפיילר זיהה שהשתמשנו במזהה (identifier) שאינו מוצהר (undeclared) ולכן אין אפשרות לקמפל את התוכנית. - כמו כן, הקומפיילר מציין כי השתמשנו בפונקציה שאינה מזוהה
printf. במקרה זה, מכיוון שמדובר בשגיאה נפוצה, הקומפיילר גם אומר לנו מה צריך לעשות בשביל לתקן אותה - לבצעincludeל-<stdio.h>.
לסיכום, כדאי ללמוד לקרוא את הפלט של הקומפיילר ולהשתמש בו - הוא הכלי החשוב ביותר עבור מתכנת בשפה C.
Note
קיימים מספר קומפיילרים שונים לשפת C - הפופולריים ביניהם הם
gccו-clang. ההבדלים העיקריים בין הקומפיילרים לא רלוונטי לצרכים שלנו בקורס ולכן ניתן להשתמש בכל אחד מהם, אבל יתכן שבהתאם לקומפיילר בו תשתמשו תראו הודעות שגיאה מעט שונות ביחס לאלו שמופיעים בקורס עבור אותו הקלט. בקורס נשתמש בקומפיילרclang.
לסיכום, אלו השלבים בהם קוד הופך לתוכנית שרצה על החומרה:
- כתיבת קוד - הרבה פעמים בעורך קוד כלשהו, כמו VS Code או Code::Blocks
- תיקון שגיאות/אזהרות קומפיילר עד לקומפילציה תקינה
- תרגום ע״י הקומפיילר משפת C לשפת מכונה
- הרצה של הקוד - הרבה פעמים ע“י עורך הקוד בו אנחנו משתמשים
Note
לא כל שפות התכנות עוברות קומפילציה. כדוגמא אחרת, קיימות שפות הנקראות ״מפורשות״ (interpreted), אותן מריצים בצורה אחרת. קיימת תוכנה הנקראת interpreter שקוראת קוד בשפות כאלו שורה אחר שורה ומבצעות אותן. דוגמא לשפות כאלה הן Python ו-JavaScript.
עכשיו כשראינו כמה דוגמאות בסיסיות לתוכניות וקיבלנו תחושה כללית עבור השפה, נתחיל ללמוד אותה בצורה מסודרת בהרצאה הבאה.
הרצאה 1.5 - התקנות
בהרצאת ביניים זו ננחה בקצרה כיצד ניתן להתקין אחד משתי תוכנות לכתיבת והרצת קוד - VS Code או Code::Blocks. שתי התוכנות סטנדרטיות, משומשות באופן רחב ע“י סטודנטים ומהנדסים וניתן לכתוב ולהריץ בהן בקלות תוכניות C.
התקנת VS Code
- גשו ללינק הבא - VS Code Installation.
הרצאה 2 - טיפוסים ואופרטורים
בהרצאה זו נתחיל להכיר בצורה מסודרת את מאפייני השפה. ההרצאות הקרובות יהיו מעין ״רשימת מכולת״ של התחביר בשפת C, או סינטקס (syntax) - מאפיינים בשפה ואיך להשתמש בהם. נתחיל בטיפוסים (types) - דרך להגדיר איך התוכנית מפרשת משתנים שונים. ראינו בהרצאה הקודמת דוגמאות להצהרות משתנים, וכעת נגדיר זאת בצורה מסודרת.
Note
כמו בלימוד של כל שפה, הרבה מהמרכיבים תלויים בזה. יתכן ורק לאחר מספר הרצאות וחזרה מעשית על מה שנלמד תרגישו כי אתם שולטים מספיק בשפה בשביל לקרוא אותה בצורה אינטואיטיבית - זה בסדר ומצופה!
הפונקציה printf
לפני שנתחיל לדבר על טיפוסים ואופרטורים, נדון בקצרה באחת הפונקציות החשובות והבסיסיות ביותר בשפה - printf. משמעות שם הפונקציה הוא print formatted, או הדפסת טקסט בפורמט. הפונקציה תהיה שימושית עבורנו בדוגמאות הקרובות ולכן נציג אותה בקצרה כעת.
Tip
בהמשך נגדיר בצורה מדויקת מהי פונקציה. כרגע נשתמש בהגדרה האינטואיטיבית כפי שפגשנו בהרצאת המבוא - פונקציה היא קטע קוד ש-״קוראים״ לו ע״י שימוש בסוגריים
()וביניהן הארגומנטים הנדרשים.
הדפסת טקסט סטטי
בתכנות, נהוג לקרוא להצגת טקסט על המסך הדפסה, או print. הצגת טקסט למשתמשים של התוכניות שנכתוב היא הדרך הבסיסית והטבעית לתקשר עם איתם, וקל להבין מדוע זה שימושי ורצוי. כמו כן, כמתכנתים ניתן להשתמש בהצגת טקסט על מנת להבין בדיוק מה קורה בתוכנית שלנו. למשל:
#include <stdio.h>
int main() {
printf("The program is on its first line.\n");
printf("The program has reached its second line.\n");
}
Warning
לפני שימוש ב-
printfבקוד, חובה לכתוב את השורה<include <stdio.h#, בעדיפות בראשית הקובץ בו אנו עובדים. בהמשך נתעכב לעומק על משמעות השורה הזו - בקצרה, היא מבקשת מהקומפיילר ״להביא״ לקובץ הנוכחי את הקוד של הפונקציהprintfואחרות על מנת שנוכל להשתמש בהן.
ראינו כבר דוגמאות פשוטות להדפסה של Hello World - נבחן דוגמא נוספת:
#include <stdio.h>
int main() {
printf("Hello from Studies C language programming course!\n");
printf("The printf function can be used to print arbitrary text");
printf("If you have a really long line, you can end the line with quotes "
"and continue on a new line, like this.\n");
}
הדפסת טקסט דינמי
כוח העל של הפונקציה printf היא בכך שהיא יכולה לקרוא משתנים בזמן ריצת התוכנית ולהדפיס את תוכנם. כך למעשה היא מנגישה לנו מידע דינמי על הקורה בתוכנית שלנו תוך כדי ריצתה. היינו רוצים לכתוב, למשל, משהו שנראה ככה:
int variable = 5;
printf("value = variable");
הבעיה בסינטקס מסוג זה הוא ש-printf לא יכולה לדעת האם אנחנו רוצים להדפיס את התווים variable עצמם או את ערך המשתנה variable - במקרה זה, 5.
לטובת זאת משתמשים בתווים מיוחדים הנקראים מזהי הדפסה (format specifiers) - תווים מוסכמים מראש ש-printf מזהה בתור שומרי מקום (placeholders) למשתנים, ומדפיסה במקומם את ערך המשתנה. מיד נגדיר במדויק מהו טיפוס של משתנה - בדוגמא variable הוא משתנה מטיפוס int, ומזהה ההדפסה שלו הוא d%. כלומר, כאשר נרצה להדפיס משתנה מטיפוס int, נכתוב במקומו את מזהה ההדפסה d%, כך:
#include <stdio.h>
int main() {
int x = 7;
printf("x is an int variable with value %d\n", x);
}
במידה וסיפקנו ל-printf מזהי הדפסה כלשהם, יש להגיד לה איזה משתנים להדפיס לפי הסדר הרצוי. למשל:
#include <stdio.h>
int main() {
int x = 54;
int y = -5;
printf("x = %d, y = %d\n", x, y);
}
מספר מזהי ההדפסה צריך להיות שווה למספר הפרמטרים הנוספים שהעברנו - למשל, שתי הדוגמאות הבאות לא תקינות:
#include <stdio.h>
int main() {
int x = 7, y = 3;
printf("x = %d, y = %d\n", x);
printf("x = %d\n", x, y);
}
Note
כפי שציינו קודם, פונקציית ה-
mainהיא נקודת ההתחלה שהקומפיילר קובע לכל התוכניות שלנו, ולכן כל הדוגמאות מכילות אותה.
הצהרה ומתן ערך למשתנה
כעת נגדיר מהם משתנים, כיצד משתמשים בהם ומהם המאפיינים שלהם.
מאפייני משתנים
משתנים הם האובייקט הבסיסי ביותר בשפה. הם הם הדרך שלנו לייצג מאפיינים ומידע של הבעיות האמיתיות אותן נרצה לפתור. על מנת להשתמש במשתנה בתוכנית שלנו, עלינו להצהיר עליו - זה נראה כך:
int result = 42;
מה כתבנו כאן? נפרק את הההצהרה. כל משתנה בכל תוכנית שנכתוב מכיל 3 מאפיינים:
-
טיפוס (type): טיפוס הוא סוג האובייקט שהמשתנה מייצג - כל טיפוס מגדיר קבוצה של ערכים שמשתנים מאותו הטיפוס יכולים לקבל. המשתנה בדוגמא הוא מטיפוס
int- קיצור של integer, או מספר שלם בעברית. משתניintיכולים לקבל ערכים שלמים בלבד, חיוביים ושליליים, כמו5,3-או201, אבל הם אינם יכולים לקבל ערכים שאינם שלמים, כמו2.6-או500.25. -
שם/מזהה (name/identifier): לכל משתנה יש שם, בו נשתמש לאחר הצהרתו על מנת להתייחס אליו שוב בהמשך התוכנית. נהוג ורצוי לתת למשתנים שם המסביר מה הם מייצגים: למשל
resultעבור תוצאת חישוב,milk_priceעבור מחיר חלב בחנות ו-passwordעבור סיסמא. -
ערך (value): לכל משתנה יש ערך מספרי. בדוגמא, ערך המשתנה הוא
42.
Note
כל ביטוי בשפת C נגמר בנקודה-פסיק,
;על מנת לציין את סופו.
Note
בקורסים בעברית לפעמים משתמשים במונח סוג משתנה במקום טיפוס - אין הבדל בין המינוחים.
לסיכום, בדוגמא הגדרנו משתנה מטיפוס int, ששמו result, וערכו 42.
בשפת C ניתן להצהיר על משתנה בלי לתת לו ערך:
int result;
char this_is_a_char;
float floatingPointNumber;
double DoubleNumber;
כמו כן, ניתן להצהיר על משתנה ללא ערך ולתת לו ערך מאוחר יותר:
int x;
int y = 7;
x = y + 1;
Warning
כפי שניתן לראות, בדוגמאה לא נתנו ערך לארבעת המשתנים. שאלה טבעית היא - מהו הערך של משתנים שלא נתנו להם ערך? זוהי נקודה חשובה וניגע בה בהמשך.
סגנונות כתיבה לשמות משתנים
קיימים סגנונות רבים לכתיבת שמות משתנים. למשל:
int my_variable_name; // snake_case
int myVariableName; // camelCase
int MyVariableName; // PascalCase
int my-variable-name; // kebab-case
int MY_VARIABLE_NAME; // SCREAMING_SNAKE_CASE
אין משמעות לסגנון בו כותבים, אבל רצוי מאוד לדבוק בסגנון אחד לטובת קריאות ונוחות. אנחנו נדבוק בקורס ב-snake_case מכיוון שזו הקונבנציה הרווחת ביותר, אבל יתכן ותפגשו בקורס סגנונות נוספים.
Note
להזכירכם, שורה המתחילה ב-
//היא הערה וניתן לכתוב בה טקסט חופשי לבחירתנו שאינו חלק מהתוכנית - בדרך כלל נשתמש בהן בשביל להסביר ולתת הקשר לקטע הקוד שמופיע, או בתור קיצור לקטע קוד כלשהו (כמו בדוגמא הזו).
טיפוסים בסיסיים בשפת C
כעת יש לנו את הכלים לכתוב ולהבין את התוכנית הבאה, בה נפגוש את הטיפוסים החשובים בשפה וכיצד להדפיס אותם:
#include <stdbool.h>
#include <stdio.h>
int main() {
char grade = 'A';
int age = 20;
float height = 1.75;
double pi = 3.14159;
bool is_student = true;
char *string = "I am a string!";
printf("Grade: %c\n", grade);
printf("Age: %d\n", age);
printf("Height: %.2f meters\n", height);
printf("Pi: %.5lf\n", pi);
printf("Student status: %d\n", true);
printf("My string is: %s\n", string);
}
בתוכנית מופיעים למעשה רוב הטיפוסים הבסיסיים בשפת C. כפי שאמרנו, טיפוס של משתנה מגדיר את קבוצת הערכים שהוא יכול לקבל:
char (character)- מייצג תו בודד כמוFאו=. המזההc%משמש להדפסת תו בודד. בהמשך נראה מהי קבוצת הערכים שמשתנה מטיפוסcharיכול לקבל.int (integer)- מייצג מספר שלם חיובי/שלילי/אפס. ראינו כי המזההd%משמש להדפסת מספר שלם.float (floating point)- מספר עשרוני - floating point היא שיטה ליצוג מספרים עשרוניים ע״י מחשב אליה לא נכנס בקורס. המזההxf.%מדפיסxספרות אחרי הנקודה העשרונית במספר, כאשר בדוגמא הדפסנו 2 ספרות לאחר הנקודה.double- טיפוס נוסף ליצוג מספרים עשרוניים, המדויק יותר מ-floatאבל משתמש ביותר בזכרון. בקורס אין צורך לעמוד על ההבדל ביניהם. המזההxlf.%מדפיסxספרות אחרי הנקודה העשרונית, כאשר בדוגמא הדפסנו 5 ספרות לאחר הנקודה.bool (boolean)- מייצג משתנה שיכול לקבל אחד משני ערכים בלבד - אמת או שקר, true או false. ערכים אלו מיוצגים ע״י המספרים0עבורfalseו-1עבורtrue. בשביל להשתמש בטיפוס יש לבצעinclude#ל-stdbool.h. נשים לב שבדומה ל-intהדפסנו אתboolעםd%- נעמוד על הסיבות המדויקות לכך בהמשך.char *(string)- כל רצף של תווים בתוך מרכאות כפולות"..."נקרא מחרוזת. הסינטקס* charמעט מבלבל - בהמשך נלמד שיש קשר הדוק בין תווים לבין מחרוזות ולכן הסינטקס עבורם דומה.
הערות
- קיימים טיפוסים רבים נוספים וכפילויות רבות ביניהם. למשל, הטיפוסים
uint32_t,int64_t,longו-long longכולם מייצגים מספרים בצורות שונות. בקורס נסתפק ביצוג מספרים ע״י הטיפוסintולא נעמוד על ההבדל ביניהם. - יש הבדל מהותי מבחינת התוכנה בין
int x = 1לביןfloat x = 1.0- הם מיוצגים בצורה שונה לחלוטין בזכרון ויש להזהר מבלבול ביניהם. - ראינו כי ניתן להדפיס שורה חדשה ב-
printfעם המזההn\. ישנם מספר תווים מיוחדים אחרים - נסכם אותם פה לשימוש עתידי:t\- הדפסת טאב (רווח מיוחד שבדרך כלל באורך 4 רווחים רגילים)"\- הדפסת"\\- הדפסת\%%- הדפסת%(למניעת בלבול עם מזהה הדפסה)
זכרון
כעת נעשה הפסקה קצרה מטיפוסים ונצלול לתוך אחד הנושאים החשובים ביותר במדעי המחשב והנדסת מחשבים - הזכרון.
כפי שאמרנו בפרק ההקדמה, שפת C נחשבת שפה נמוכה יחסית לאחרות מכיוון שהיא מאפשרת שליטה מדויקת במכונה עליה היא רצה. אחד האלמנטים החשובים ביותר במכונה הוא הזכרון - המקום בו נשמר המידע בו התוכנית שלנו משתמשת. הבנה עמוקה של הזכרון והצורה שבה השפה שולטת בו היא הקונספט החשוב ביותר להצלחה בקורס, ולכן נקדיש מאמץ בשלב מוקדם של הקורס לבנות אותה.
חלק זה נכנס מעט יותר לעומק מרוב קורסי ה-C הבסיסיים באוניברסיטאות, אבל הוא מאוד שימושי להבנה ולכן אנחנו כוללים אותו. אין צורך לתרגל לעומק את המתמטיקה וההמרות שנציג כאן, אלא להבין את הקונספט הכללי והשימוש שלו בתכנות.
השפה הבינארית
כמו שאולי שמעתם בעבר, מחשבים מדברים בשפה שנקראת בינארית - רצפים של 0 ו-1. אבל מה זה אומר בפועל? נכנס לפרטים.
המספרים הרגילים שאנחנו עובדים איתם נמצאים בבסיס 10, הידוע גם בתור בסיס דצימלי או בסיס עשרוני. הספרות במספרים כמו 13 ו-5 יכולות לקבל 10 אופציות, מתוך הקבוצה 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. בשפה בינארית אנחנו עובדים בבסיס 2, כאשר כל ספרה יכולה לקבל אחד משני ערכים - 0 או 1. לדוגמא - 13 בבינארית הוא 1101, ו-5 בבינארית הוא 101. איך מבצעים המרה כזו?
כתיבת מספר בבינארית ודצימלית
נבחן למשל את המספר 549. מה המשמעות של כתיבה כזו בבסיס 10?
$$ 549 = 9 \cdot 10^0 + 4 \cdot 10^1 + 5 \cdot 10^2 = 9 + 40 + 500 $$
במספר 549, 9 היא ספרת האחדות ולכן היא תורמת למספר הכולל \(9 \cdot 1\). בצורה דומה, 4 היא ספרת העשרות ולכן היא תורמת למספר הכולל \(4 \cdot 10\), ו-5 היא ספרת המאות ולכן היא תורמת למספר הכולל \(5 \cdot 100 \). כל ספרה תורמת את ערכה, כפול הבסיס הדצימלי (10) בחזקת מיקום הספרה, כאשר מתחילים לספור מ-0.
מספרים בבינארית מתנהגים בדיוק אותו דבר, רק שהבסיס הוא 2:
$$ 1101 = 1 \cdot 2^0 + 0 \cdot 2^1 + 1 \cdot 2^2 + 1 \cdot 2^3=1 + 4 + 8 =13 $$
בפשטות, אם קוראים את המספר מימין לשמאל, כל ספרה כופלת חזקות עולות מ-0 של 2 - בדיוק כמו בבסיס 10. בבינארית זה אפילו קל יותר, מכיוון שחזקה נכפלת ב-0 או 1 בלבד.
דוגמא
נמיר את המספר \(11110010\) מבינארי לעשרוני. על מנת לעשות זאת, נקרא את המספר מימין לשמאל ונכפול כל ספרה בחזקות עולות של 2:
$$ 11010 = 0 \cdot 2^0 + 1 \cdot 2^1 + 0 \cdot 2^2 + 1 \cdot 2^3 + 1 \cdot 2^4 $$ $$ = 0 \cdot 1 + 1 \cdot 2 + 0 \cdot 4 + 1 \cdot 8 + 1 \cdot 8 $$ $$ = 2 + 4 + 8 $$ $$ = 14 $$
בשביל להמיר מבסיס עשרוני לבינארי, נחלק את המספר ב-2 עד שנגיע ל-0, ואז נכתוב את השאריות מלמטה למעלה. למשל, נמיר את המספר 549 מעשרוני לבינארי - נסמן את השארית ב-\(r\):
$$ \frac{549}{2} = 274, r=1 $$ $$ \frac{274}{2} = 137, r=0 $$ $$ \frac{137}{2} = 68, r=1 $$ $$ \frac{68}{2} = 34, r=0 $$ $$ \frac{34}{2} = 17, r=0 $$ $$ \frac{17}{2} = 8, r=1 $$ $$ \frac{8}{2} = 4, r=0 $$ $$ \frac{4}{2} = 2, r=0 $$ $$ \frac{2}{2} = 1, r=0 $$ $$ \frac{1}{2} = 0, r=1 $$
כלומר, המספר 1000100101 בבינארית היא 549 בדצימלית.
שאלות ותרגילים
- המירו את המספרים הבאים לבינארי לעשרונים:
- 101
- 1101
- 10010
- 111111
- 001111
- המירו את המספרים הבאים מעשרוני לבינארי:
- 7
- 333
- 32
- 100
- נתון מספר \(x = 2^k\) עבור \(k\) שלם. כתבו את \(x\) בבינארית, כאשר ניתן לסמן \(k\) ספרות חוזרות בבינארית בכל סימן לבחירתכם.
- נתון המספר הבינארי \(abcd\), כאשר כל ספרה \(a, b, c, d\) מייצגת ספרה בינארית אחת. מהו מספר הערכים השונים שהמספר יכול לקבל? כמה ערכים שונים יכול לקבל מספר בינארי בעל \(k\) ספרות יכול לקבל עבור \(k\) שלם?
בייטים, ביטים והצגת משתנים
כפי שהזכרנו, לאורך ריצה של תוכניות מחשב הן עושות שימוש ביחידה חומרתית הנקראת זכרון, בו נשמר המידע שהתוכנית מכילה. את הזכרון אנחנו מחלקים לחלקים הבאים:
- היחידה הבסיסית ביותר נקראת ביט (bit), או סיבית בעברית. ביט יכול לקבל את הערכים 0 או 1 בלבד - ניתן לחשוב על ביטים כמו מתגים, הנמצאים במצב דלוק או כבוי. בתוכנה נייצג כל ביט כספרה במספר בינארי אחד.
- כל קבוצה של 8 ביטים נקראת בייט (byte), או בית בעברית. כשאנחנו אומרים למשל שלמחשב שלנו יש זכרון של 4 ג׳יגהבייט (4GB), אלו הבייטים עליהם מדברים.
- כל משתנה שנגדיר יתפוס בזכרון כמות מסוימת של בייטים - לדוגמא, משתנה
charתופס בייט אחד, לעומת משתנהintשתופס 4 בייטים. - ניתן לחשוב על הזכרון בתור שורה ארוכה וממוספרת של המון בייטים. המספור שמקבל כל בייט נקרא כתובת. לדוגמא, אם הבייט בכתובת
100מכיל כרגע משתנה כלשהיint x, אנחנו אומרים שהכתובת שלxבזכרון היא100. בהמשך נעשה שימוש מקיף בנושא הכתובות של משתנים בזכרון. רוב המשתנים יתפסו יותר מבייט אחד בזכרון, ובמקרה זה אנחנו אומרים שהכתובת שלהם היא הכתובת של הבייט הראשון שהם נמצאים בו. - הביטים שמרכיבים כל בייט נותנים לו ערך, וביחד כל משתנה מפורש לפי הבייטים שלו. ניקח משתנה
intלמשל שערכו 549:
int x = 549;
ראינו כי היצוג הבינארי של המספר הדצימלי 549 הוא 1000100101. לכן ארבעת הבייטים של המשתנה x יראו כך, עם רווח בין בייט לבייט:
00000000 00000000 00000010 00100101
נסו להמיר בעצמכם את המספר בייט לאחר בייט לספרות דצימליות וראו שאתם מקבלים את התשובה הרצויה!
כלומר: כשאנו כותבים את שורת הקוד int x = 549, הקומפיילר שומר את הבייטים 00000000 00000000 00000010 00100101 במקום שהורנו לו. הבנה עמוקה של כיצד הזכרון נראה היא לא פחות מקריטית להמשך ולכן נשקיע מאמץ רב בבנייה עמוקה שלה.
שאלה: אמרנו שגודל משתנה int הוא 4 בייטים - כלומר, הערך המספרי של משתנה מסוג זה מיוצג ע״י 4 בייטים שהם 32 ביטים. בהתאמה, מהו הערך המקסימלי שמשתנה מסוג זה יכול לקבל?
כתובת של משתנה
דיברנו על כך שהזכרון הוא שורה ארוכה וממוספרת של בייטים, כאשר כל הערך של כל משתנה נכתב לבייטים שהוא נמצא בהם. כתובת של משתנה היא המספר של הבייט הראשון שלו בשורה הארוכה הזו, והיא פשוט מספר שאפשר לקבל בקלות מהמשתנה עצמו:
#include <stdio.h>
int main() {
int x = 5;
char c = 'c';
float f = -53.4;
printf("x address = %p\n", &x);
printf("c address = %p\n", &c);
printf("f address = %p\n", &f);
}
אופרטור ה-& לפני משתנה מחזיר את הכתובת שלו בזכרון. ההדפסה נראת מעט מרתיעה מכיוון שהיא נעשת בבסיס אחר שלא נפגוש בקורס הנקרא הקסדצימלי (hexadecimal), אבל מדובר במספר לכל דבר.
בהמשך נעשה שימוש נרחב באופרטור זה - כעת אנו יודעים שכל משמעותו היא לקיחת הכתובת של המשתנה המדובר בזכרון.
Note
בהמשך כשנדבר על מצביעים נדון בנושא זה בהרחבה - כעת אנו מציגים את אופרטור הכתובת לשלמות בלבד.
sizeof וגדלים של משתנים
השפה מעמידה לרשותנו אופרטור בשם sizeof, שמקבל טיפוס ומחזיר את הגודל שלו בבתים:
#include <stdio.h>
#include <stdbool.h>
int main() {
printf("Size of char: %d bytes\n", sizeof(char));
printf("Size of bool: %d bytes\n", sizeof(bool));
printf("Size of int: %d bytes\n", sizeof(int));
printf("Size of float: %d bytes\n", sizeof(float));
printf("Size of double: %d bytes\n", sizeof(double));
}
Caution
כאשר אנו אומרים ש-
intתופס 4 בתים ו-charתופס בית אחד, זהו המקרה הרווח במחשבים מודרנים. עם זאת, קיימים יוצאי דופן - בפלטפורמות שונות יתכנו גדלים שונים של משתנים. כך או כך, אין צורך לזכור מהו גודל משתנה בשום שלב - המטרה היא להבין כיצד תמונת הזכרון מתנהגת.
מהו טיפוס?
כעת סוף סוף יש לנו את הכלים להגדיר מהו טיפוס.
כאשר אנחנו מצהירים על משתנה מטיפוס מסוים ונותנים לו ערך, הקומפיילר עושה את הפעולות הבאות:
- מקצה תא בזכרון בגודל של הטיפוס שביקשנו
- כותב לתא הזה את הערך הרצוי
לדוגמא, נקצה משתנה int:
int x = 65;
הקומפיילר מקצה 4 בייטים בזכרון (גודל int הוא 4 בייטים) וכותב אליהם את הערך 65, או בבינארית 1000001. כלומר, תוכן הביטים בארבעת הבייטים הוא (עם רווח בין בייט לבייט):
00000000 00000000 00000000 01000001
כדוגמא נוספת, נקצה משתנה char:
char c = 65;
הקומפיילר מקצה בייט אחד (גודל char הוא בייט אחד) וכותב אליו את הערך 65, או 01000001. בהמשך נראה כיצד זה מתיישב עם הצהרות כמו 'char c = 'A שעשינו קודם.
Note
ההצגה הבינארית של משתנים מסוג
floatו-doubleמעט מורכבת יותר. למשל, ההצגה הבינארית של הצהרה כגוןfloat f = 65היא00000000 00000000 10000010 01000010- זאת מכיוון שעל מנת לייצג מספרים עשרוניים הקומפיילר מפרש את הביטים אחרת ממספרים שלמים. לא נדון בכך בקורס הזה.
פירוש ערך טיפוסים בהדפסה
#include <stdio.h>
int main() {
int x = 65;
char c = 65; // ?
printf("x = %d, c = %c\n", x, c);
}
בדוגמא זו הקומפיילר הקצה מקום לשני משתנים - x ו-c, וכתב לשניהם את הערך 65. למעשה, יש שתי כתובות בזכרון בהן ערך הבייטים הוא 65 (10000001 בבינארי - נסו להמיר בעצמכם!). מהי המשמעות של אתחול c ל-65? למה כשהדפסנו את c קיבלנו את התו A?
הסיבה לכך היא הטיפוס של המשתנה. ביקשנו להדפיס את הערך 65 פעם אחת כ-int כשהדפסנו את x, ופעם שנייה כ-char כשהדפסנו את c. כלומר, הקומפיילר מפרש את הערך 65 בתור התו A - מדוע?
לתשובה הזו יש שאלה עמוקה - נתחיל ראשית בהמרה בין סוגי משתנים ונמשיך לצורה שבה מפרשים את המספר 65 כמשתנה char.
המרת סוג (Casting)
המרת סוג (casting)
ראינו כי הערך 65 מפורש ע״י printf בצורה אחת כאשר הוא מודפס כמשתנה int, ואחרת כאשר הוא מודפס כמשתנה char. השאלה הנדרשת היא למה, והאם ניתן להמיר בין שתי ההצגות? למשל, האם ניתן לקחת משתנה int ולפרש מחדש את הבייטים בזכרון שלו בתור char? מסתבר שבאופן כללי כן, והמנגנון שמבצע זאת נקרא casting.
כפי שאמרנו בהתחלה, משתנה מורכב משני חלקים - ערך, שהוא המספר שכתוב בבתים בזכרון, וטיפוס, שהוא הוראה לקומפיילר כיצד לפרש את הערך. ראינו כי הערך 65 מפורש בצורה אחת ע“י הקומפיילר כאשר הוא נקרא כמשתנה int, ובצורה אחרת כאשר הוא נכתב כמשתנה char. לכן ניתן לשאול - האם אפשר להמיר ערך בין טיפוסים שונים? למשל, האם ניתן לקחת משתנה int ולפרש מחדש את הזכרון שלו בתור char?
התשובה היא כן, והמנגנון לבצע זאת נקרא casting. מצבעים זאת כך:
#include <stdio.h>
#include <stdbool.h>
int main() {
int x = 65;
float x_as_float = (float)x;
char x_as_char = (char)x;
bool x_as_bool = (bool)x;
printf("As int: %d\n", x);
printf("As float: %f\n", x_as_float);
printf("As char: %c\n", x_as_char);
printf("As bool: %d\n", x_as_bool);
}
כאשר נתון משתנה x, הביטוי (type1)x הוא מטיפוס type1 וערכו נקבע לפי ההמרה שמבצע הקומפיילר בין הטיפוסים השונים.
חוקי casting
ל-casting בין טיפוסים משתנים שונים יש חוקים רבים. נזכיר את העיקריים שבהם:
#include <stdbool.h>
#include <stdio.h>
int main(void) {
int i = 42;
float f = 3.75f;
double d = 3.1415926535;
char c = 'A';
bool b = true;
printf("=== Integer and floating point ===\n");
printf("int -> float: %d -> %.2f\n", i, (float)i);
printf("int -> double: %d -> %.5f\n", i, (double)i);
printf("float -> int: %.2f -> %d\n", f, (int)f);
printf("double -> int: %.5f -> %d\n", d, (int)d);
printf("double -> float: %.10f -> %.5f\n", d, (float)d);
printf("\n=== Truncation ===\n");
printf("(int) 3.99 = %d\n", (int)3.99);
printf("(int) 3.01 = %d\n", (int)3.01);
printf("(int) -3.99 = %d\n", (int)-3.99);
printf("\n=== Characters and integers ===\n");
printf("char -> int: '%c' -> %d\n", c, (int)c);
printf("int -> char: %d -> '%c'\n", 66, (char)66);
printf("\n=== Boolean conversions ===\n");
printf("bool -> int: false -> %d\n", (int)false);
printf("bool -> int: true -> %d\n", (int)true);
printf("int -> bool: 0 -> %d\n", (bool)0);
printf("int -> bool: 1 -> %d\n", (bool)1);
printf("int -> bool: 42 -> %d\n", (bool)42);
printf("int -> bool: -7 -> %d\n", (bool)-7);
printf("\n=== Floating point and boolean ===\n");
printf("float -> bool: 0.0 -> %d\n", (bool)0.0f);
printf("float -> bool: 3.14 -> %d\n", (bool)3.14f);
printf("float -> bool: -2.5 -> %d\n", (bool)-2.5f);
printf("bool -> float: false -> %.1f\n", (float)false);
printf("bool -> float: true -> %.1f\n", (float)true);
printf("\n=== A conversion during arithmetic ===\n");
int x = 5;
int y = 2;
printf("5 / 2 = %d\n", x / y);
printf("(float)5 / 2 = %.2f\n", (float)x / y);
return 0;
}
נסכם:
1. משתנה `int` המומר למשתנה `float/double` מומר בדרך כלל בצורה מדויקת עבור ערכים קטנים מספיק.
1. משתנה `float/double` המומר ל-`int` מעוגל כלפי מטה ולכן אינו מומר בצורה מדויקת.
1. משתנה `char` המומר ל-`int` הופך לערך ה-ASCII של התו - נסביר בפירוט מיד!
1. כל משתנה המומר ל-`bool` שאינו `0` (או `0.0` עבור משתני `double/float`) הוא `true`, ואם `0` בדיוק הוא `false`.
**הערה:* הקומפיילר מבצע במקרים מסוימים casting גם בלי שנבקש ממנו מפורשות. למשל, ההצהרות הנ״ל:
```c
int x = 5;
float y = x;
שקולות ל-:
int x = 5;
float y = (float)x;
או במשפט if שנפגוש בקרוב - כל ערך הניתן לו מומר אוטומטית ל-bool:
int x = 5;
if (x) { ... }
שקול ל-:
int x = 5;
if ((bool)x) { ... }
תקן ASCII
כעת יש ברשותנו את הכלים להבין מדוע כאשר כתבנו char c = 65 והדפסנו את c, קיבלנו את התו A. מכיוון שמשתנים מיוצגים כמספרים בינאריים בתוך בתים בזכרון, אין לנו דרך לכתוב ישירות את התו A לזכרון - אנחנו צריכים יצוג כלשהו שלו כמספר, ממנו נמיר לתו. יצוג זה נקרא ASCII - הוא נותן ערך מספרי לכל תו שהמחשב יודע להדפיס.
לכל תו בעמודה char הטבלה מצמידה ערך מספרי בעמודה decimal. כך ניתן לראות שהערך המספרי של התו A הוא 65 - כלומר, על פי מוסכמה, כאשר printf מנסה להדפיס תא בזכרון כ-char שערכו הוא 65, היא מדפיסה את התו A - זו משמעות תקן ה-ASCII.
נשים לב שגם לספרות רגילות יש ערך ASCII ששונה מהערך המספרי שלהן! למשל, לתו '5' יש את ערך ASCII של 53.
לדוגמא, נכתוב תוכנית שמקבלת ספרה מהמשתמש ומדפיסה את ערך ה-ASCII שלה:
#include <stdio.h>
int main() {
int x;
scanf("%d", &x);
int x_ascii = x + '0';
printf("%d ASCII value = %d", x, x_ascii);
}
בשביל להבין לעומק את הדוגמא, נזכיר כי יש הבדל בין המספר 0 לבין התו '0'. באופן כללי, המספר 0 הוא int שערכו המספרי הוא 0, בעוד שהתו '0' הוא char שערכו המספרי הוא 48 (לפי טבלת ASCII). אם לדוגמא המשתמש הכניס את הספרה 5, ונרצה לקבל את ערך ה-ASCII שלה 53, נוסיף לה '0', ונקבל:
5 + '0' = 5 + 48 = 53 = '5'
לסיכום, יש להזהר מאוד ולהבדיל בין מספרים לבין תווים בשפת C, ולדעת כיצד להשתמש בהם.
משתנים בלתי מאותחלים
שאלה טבעית שניתן לשאול לאחר הדיון הקודם שלנו היא - מה יקרה אם בכל זאת ננסה להדפיס או לקרוא משתנה בלתי מאותחל? החלק הנחמד בשאלות תכנותיות מהסוג הזה היא שתמיד ניתן לבדוק. נבחן את התוכנית הבאה:
#include <stdio.h>
int main() {
int x;
printf("x = %d\n", x);
}
התוצאה תהיה הדפסה של מספר לא צפוי. מדוע?
כפי שאמרנו בעבר, המידע של התוכנית שלנו נשמר ביחידה הנקראת זכרון. בתוכנית זו, תוכן המשתנה x נמצא בזכרון - הקומפיילר הקצה מקום בשביל ערך המשתנה, למרות שלא כתבנו אליו ערך. משתנה שלא אותחל מכיל ערך הנקרא ערך זבל - ערך בלתי צפוי שיכול להיות כל דבר. בפועל, מדובר על הערך שהיה בכתובת זו בזכרון בפעם האחרונה שהשתמשו בה.
שימוש במשתנים בלתי מאותחלים הוא מקור קלאסי לבאגים קשים לתפעול בתוכניות שנכתוב. לכן, חשוב מאוד להמנע מהם - נהוג לתת ערך “ברירת מחדל” למשתנים, כמו 0 או 1- למשתני int.
שאלות ותרגילים
- נכתוב תוכנית שמקבלת אותיות קטנות מהמשתמש וממירה אותן לאותיות גדולות:
#include <stdio.h>
int main() {
char c;
scanf("%c", &c);
printf("%c", c + 'a' - 'A') // 'a' - 'A' = 32
}
- מה יקרה אם נדפיס משתנה מסוג
intעם מזההc? מה יקרה אם המשתנה יהיה קטן מ-0 או גדול מ-127? - כתבו כל אות בשם שלכם כמשתנה
intע“י מיפוי ASCII והדפיסו אותו. - כתבו תוכנית המקבלת מהמשתמש תו ומדפיסה את התו ואת ערך ה-ASCII שלו.
- המירו את המספר הדצימלי
2044לבינארי ואת המספר הבינארי10011101לדצימלי.
אופרטורים
לאחר שהגדרנו מהם טיפוסים והגדרנו בעזרתם משתנים, נרצה להתחיל לבצע עליהם פעולות וחישובים. נגדיר את הפעולות הבאות:
פעולות אריתמטיות (+, -, *, /)
אופרטורים אלו מייצגים את הפעולות הקלאסיות של חיבור, חיסור, כפל וחילוק:
#include <stdio.h>
int main() {
int x = 5;
int y = 2;
float f = 3.5;
int add = x + y;
int sub = x - y;
int int_div = x / y;
float float_div = (float)x / y;
printf("add: %d\n", add);
printf("sub: %d\n", sub);
printf("int division: %d\n", int_div);
printf("float division: %.2f\n", float_div);
}
נשים לב להבדלים בין חלוקת שלמים לחלוקת מספרים עשרוניים.
השמה (=)
פעולה מהצורה x = y - זו פעולה שכמובן פגשנו כבר, אבל נגדירה כעת בצורה מסודרת. נשים לב שבניגוד למשוואות מתמטיות בהן = מייצג שוויון סימטרי בין שני צדדים, הפעולה אינה סימטרית - היא מחשבת את הביטוי שמופיע באגף ימין וכותבת אותו כערך למשתנה שמופיע מצד שמאל. צד שמאל חייב להיות משתנה בעוד שצד ימין יכול להיות כל ביטוי עם ערך מתאים. למשל הביטויים הבאים הם חוקיים:
int x = 6;
x = x + 2;
int y = x;
int z = x + y - 2;
חשוב להדגיש כי סדר הפעולות היא חישוב הביטוי אותו משימים ורק לאחר מכן כתיבת הערך למשתנה. בתחילת שורה 2 הערך של x הוא 6 - מחשבים את ערך הביטוי x + 2 שתוצאתו 8, ורק לאחר מכן כותבים אותו למשתנה x.
לעומת זאת, הביטויים הבאים אינם חוקיים:
int x = y; // y not declared yet
5 = x; // Can't assign to a constant
כדוגמא נוספת, נעקוב אחרי ערכי משתנים בדוגמא הבאה:
double x;
double y = 2.1;
x = y - 2.2;
y = x + 0.2;
| Line | x | y |
|---|---|---|
double x | garbage | not declared yet |
double y = 2.1 | garbage | 2.1 |
x = y - 2.2 | -0.1 | 2.1 |
y = x + 0.2 | -0.1 | 0.1 |
מתברר כי ביטויים בהם משתמשים בערך הנוכחי של המשתנה כחלק מחישוב הם נפוצים, ולכן השפה מציעה לנו קיצורים מהסוג הבא:
int x = 2;
x += 3; // x = x + 3
x *= -4; // x = x * (-4)
x /= 7; // x = x / 7
כמו כן, מסתבר שפעולות כמו x += 1 הן אפילו יותר נפוצות ולכן השפה מציעה גם עבורם את הקיצורים הבאים. יש כמה נקודות עדינות בשימוש באופרטורים האלו, לכן קודם נגדיר אותם ואז נראה דוגמא:
x++- אופרטור++משמאל למשתנה מגדיל את המשתנה ב-1 לפני חישוב שאר הביטוי.++x- אופרטור++מימין למשתנה קודם כל מחשב את הביטוי ולאחר מכן מגדיל את המשתנה ב-1.x--- זהה ל-x++, רק שמקטין את המשתנה ב-1 במקום להגדיל.--x- זהה ל-++x, רק שמקטין את המשתנה ב-1 במקום להגדיל.
מה הכוונה בכל סדר הפעולות שדיברנו עליו למעלה? נבין ע“י דוגמא:
int x = 5; // x is 5
int y = ++x; // x is 6, y is 6
int z = y--; // x is 6, y is 5, z is 6
נפרק את השורות לפי הסדר:
- הצהרה על משתנה ומתן ערך
- האופרטור משמאל למשתנה ולכן אנחנו קודם כל מגדילים אותו ואז מחשבים את הביטוי. אז הגדלנו את
xל-6, ואז כתבנו את התוצאה ל-yכך שערכו גם 6. - האופרטור מימין למשתנה ולכן קודם כל חישבנו את הביטוי - כתבנו ל-
zאת הערך 6 ורק לאחר מכן הקטנו אתyב-1, כך שערכו בסוף השורה 5.xללא שינוי.
מודולו %
בניגוד למה שראינו עד כה, זהו אופרטור קצת פחות מוכר ואינטואיבי אבל יש לו שימושים רבים בתכנות. אופרטור המודולו (לפעמים גם מודולוס, modulus) מוציא את השארית של חלוקת שני שלמים. לדוגמא:
int a = 17, b = 5;
int result = a % b; // result = 2
כאשר a / b ללא שארית, המודולו a % b הוא 0. אחרת, הוא מחזיר את שארית החלוקה בין שני המספרים.
דוגמא - קבלת הספרה האחרונה ממספר ע“י מודולו
בהנתן מספר int, אפשר להשתמש במודולו בשביל לקבל את הספרה האחרונה שלו:
int x = 65154;
int last_digit = x % 10; // 4
פעולות שוויון והשוואה
שוויון == ואי שוויון =!
אינטואיטיבי מאוד - בודק האם שני ערכים כלשהם שווים או שונים. ניתן להשוות בין מספרים שלמים, עשרוניים, תווים ועוד. אפשר לכתוב את התוצאה של אופרטור ההשוואה למשתנה, או להשתמש בה כתנאי במשפטים מורכבים יותר שנפגוש בהמשך:
int a = 17, b = 5, c = 17;
bool a_b_equal = a == b; // False!
bool a_c_equal = a == c;
if (a_c_equal) { // True!
// Do some stuff
}
char char1 = 'a', char2 = 'a';
if (char1 != char2) { // Use directly in comparison
// Don't do stuff
}
Warning
הזהרו - באג קלאסי הוא בלבול בין אופרטור ההשוואה
==לאופרטור ההשמה=. בדקו תמיד האם השתמשתם באופרטור שהתכוונתם אליו.
אופרטור גדול מ/קטן מ
בדומה לאופרטור השוויון, קיימים גם גרסאות סטנדרטיות לבדיקת אי שוויונות:
<ו->לבדיקת קטן/גדול<=ו-=>לבדיקת קטן שווה/גדול שווה
סוגריים ()
ולבסוף, כמו במתמטיקה רגילה, ניתן להשתמש בסוגריים () בשביל להגדיר ביטוי עם קדימות גבוהה יותר מכל ביטוי אחר. לדוגמא:
int a = 5, b = 3;
int result1 = a + (b * 2);
int x = 10, y = 4, z = 2;
int result2 = (x - y) / z;
int p = 7, q = 2;
int result3 = (p++) * (--q);
סדר פעולות
כמו בכל ביטוי מתמטי, צריך להגדיר בצורה מסודרת מהו סדר הפעולות של אופרטורים על מנת שביטויים יתנהגו בצורה נגדיר צפויה. נגדיר את הקדימות של האופרטורים שהצגנו עד כה, מגבוה לנמוך:
- אופרטורי קידום ונסיגה:
++,-- - כפל, חילוק ומודולו:
*,/,% - חיבור וחיסור:
+,- - שוויון:
==
שאלות ותרגילים:
- עבור כל אחת מהתוכניות הבאות, מה יהיה הערך של המשתנה
resultבסופן?
// Program 1
int a = 5, b = 3;
int result1 = a + b * 2;
// Program 2
int x = 10, y = 4, z = 2;
int result2 = (x - y) / z;
// Program 3
int p = 7, q = 2;
int result3 = p++ * --q;
// Program 4
int m = 8, n = 3, k = 2;
int result4 = m / n * k + m % n;
// Program 5
int aa = 3, bb = 4, cc = 5;
int result5 = (aa++ + --bb) * (cc-- - ++aa);
אופרטורים לוגים (&&, || ו-!)
נסכם את נושא האופרטורים עם אופרטורים לוגיים. יתכן ושמעתם כבר על חלק מהקונספטים שנציג בחלק הזה.
אופרטורים לוגיים הם דרך לייצר משתנים בוליאנים כקומבינציה של משתנים אחרים. לדוגמא, נניח שאנחנו רוצים להכין עוגה ורוצים לבדוק האם יש מספיק קמח ומספיק ביצים, כאשר קיים מספר קבוע כלשהו של ביצים וקמח שצריך בשביל להכין את העוגה. לא משנה מהו מספר הביצים וכמות הקמח שיש - קיימות ארבע אופציות:
- אין מספיק קמח ואין מספיק ביצים - אי אפשר להכין עוגה.
- אין מספיק קמח ויש מספיק ביצים - אי אפשר להכין עוגה.
- יש מספיק קמח ואין מספיק ביצים - אי אפשר להכין עוגה.
- יש מספיק קמח ויש מספיק ביצים - אפשר להכין עוגה!
כלומר, ניתן להכין עוגה רק אם יש מספיק ביצים וגם יש מספיק קמח. ניתן לחשוב על זה בקוד כך:
#define EGGS_NEEDED 2
#define FLOUR_NEEDED 5
#include <stdio.h>
#include <stdbool.h>
int main() {
int eggs = -1;
int flour = -1;
printf("Please enter amount of eggs and flour\n");
scanf("%d", &eggs);
scanf("%d", &flour);
bool have_enough_eggs = eggs >= EGGS_NEEDED;
bool have_enough_flour = flour >= FLOUR_NEEDED;
}
הגדרנו את שני התנאים שצריכים להתקיים בשביל שיהיה אפשר להכין עוגה. מהי קומבינציה של שני התנאים שמאפשרת להכין עוגה? אפשר לחשוב על משתנה בשם can_make_cake שהוא קומבינציה לוגית של שני התנאים הקודמים. נשים בטבלה את כל האופציות עבור שני התנאים ונרכיב מהם את המשתנה החדש:
| have_enough_eggs | have_enough_flour | can_make_cake |
|---|---|---|
| false | false | false |
| true | false | false |
| false | true | false |
| true | true | true |
למעשה ניתן להכין עוגה רק כאשר שני התנאים הם true. ליחס מהסוג הזה קוראים יחס and - התנאי להכנת עוגה הוא true רק אם גם התנאי הראשון וגם התנאי הראשון מתקיימים. אלטרנטיבית, ניתן לחשוב על כך שמספיק שתנאי אחד לא מתקיים והשני גם לא.
הערה: לטבלה מהסוג הזה קוראים טבלת אמת והיא שימושית בתחום חשוב מאוד הנקרא אלגברה בוליאנית, עליו תרחיבו בקורסי המשך.
הדרך להגדיר משתנה בוליאני שהוא and של שני משתנים אחרים נראת כך:
bool can_make_cake = have_enough_flours && have_enough_eggs;
בצורה דומה, ניתן לחשוב על עוגה שצריך או קמח או ביצים בשביל להכין אותה. מספיק שאחד המשתנים הוא אמת - הדרך להגדיר זאת נראת כך:
bool can_make_cake = have_enough_flours || have_enough_eggs;
אופרטור ה-|| נקרא אופרטור or והוא מייצג מצב בו מספיק שאחד מהתנאים יתקיים בשביל שצירופם יתקיים גם.
אופציה נוספת היא שלילה - נניח שהתוכנית שלנו נראת דווקא כך:
#define EGGS_NEEDED 2
#define FLOUR_NEEDED 5
#include <stdio.h>
#include <stdbool.h>
int main() {
int eggs = -1;
int flour = -1;
printf("Please enter amount of eggs and flour\n");
scanf("%d", &eggs);
scanf("%d", &flour);
bool not_enough_eggs = eggs < EGGS_NEEDED;
bool not_enough_flour = flour < FLOUR_NEEDED;
}
הגדרנו את התנאים שלנו הפוך. במידה ונרצה לקבל את התנאי הדואלי, ניתן להשתמש באופרטור השלילה ! כך:
bool have_enough_flour = !not_enough_flour;
אופרטור השלילה פשוט מאוד - הוא בוחן את הערך שהוא קיבל. במידה והוא קיבל ערך שאינו 0, הוא הופך אותו ל-0 - ובמידה וקיבל 0, הוא הופך אותו ל-1.
ניתן גם לשרשר אופרטורים לוגיים זה לזה - למשל כמו כאן:
#define EGGS_NEEDED 2
#define FLOUR_NEEDED 5
#include <stdio.h>
#include <stdbool.h>
int main() {
int eggs = -1;
int flour = -1;
printf("Please enter amount of eggs and flour\n");
scanf("%d", &eggs);
scanf("%d", &flour);
bool not_enough_eggs = eggs < EGGS_NEEDED;
bool not_enough_flour = flour < FLOUR_NEEDED;
bool can_make_cake = !not_enough_eggs && !not_enough_flour;
}
נסכם את טבלאות האמת של האופרטורים השונים. נסמן ב-b1 ו-b2 שני משתנים בוליאנים כלשהם ונקבל:
| b1 | b2 | b1 or b2 | b1 and b2 | !b1 | !b2 |
|---|---|---|---|---|---|
| false | false | false | false | true | true |
| true | false | true | false | false | true |
| false | true | true | false | true | false |
| true | true | true | true | false | false |
Caution
בשפה קיימים גם האופרטורים
&ו-|, הנבדלים מהאופרטורים&&ו-|. האופטורים נקראים bitwise operators והם מבצעים פעולה אחרת מהאופרטורים הלוגיים - לא נרחיב עליהם בקורס זה.
הערה: יש להזהר - קיימים בשפה גם אופרטורים & ו-|, השונים בהגדרתם מ-&& ו-|| ונקראים bitwise operators. נרחיב עליהם בהמשך.
חישוב עצל (Lazy Evaluation)
כדוגמא, נניח שאנו בונים מערכת כניסה לאתר אינטרנט. על מנת להכנס לאתר, המשתמש צריך לעמוד בתנאים הבאים:
- להכניס סיסמא נכונה.
- להיות מעל לגיל 10 או להיות בעל אישור הורים להכנס לאתר.
ניתן לייצג בקוד את התנאים כך:
#define PASSWORD 1234
#define MIN_AGE 10
int password;
int age;
bool has_parents_permit;
bool can_enter = password == PASSWORD && (age > MIN_AGE || has_parents_permit);
נבנה טבלה של המקרים האפשריים:
| password | age | permit | can enter |
|---|---|---|---|
false | false | false | false |
false | false | true | false |
false | true | false | false |
false | true | true | false |
true | false | false | false |
true | false | true | true |
true | true | false | true |
true | true | true | true |
נשים לב כשכש-password הוא false, אין שום משמעות למשתנים age ו-parents - התשובה היא תמיד false. הסיבה לכך שעבור כל משתנה בוליאני x, הביטוי x && false הוא תמיד false.
השפה תמיד שואפת להמנע מחישובים ופעולות מיותרות, ולכן משתמשת במקרה זה בקונספט הנקרא חישוב עצל - כאשר מבצעים חישוב של ביטוי לוגי, מיד כאשר ניתן לקבוע את ערכו הסופי, השפה עוצרת את החישוב וקובעת אותו. נושא זה יהיה חשוב בהמשך כאשר ביטויים לוגיים יורכבו לא רק מביטויים בוליאנים אלא מקריאות לפונקציות עם השפעות מעבר לביטוי.
בצורה דומה, מכיוון שלכל משתנה בוליאני הביטוי x x || true הוא תמיד true, ניתן לפעמים להמנע מחישובים על ידי חישוב עצל. למשל, נניח בדוגמא הקודמת שאין סיסמא לאתר - הקוד יהפוך ל-:
#define MIN_AGE 10
int age;
bool has_parents_permit;
bool can_enter = age > MIN_AGE || has_parents_permit;
אם מתקיים age > MIN_AGE, אין צורך לבדוק בכלל את המשתנה has_parents_permit.
Note
קיימים מקומות בהם משתמשים במונח short-circuit evaluation במקום ב-lazy evaluation.
define# ו-const
define#
באופן כללי אין שום מניעה שמשתנים ישתנו לאורך התוכנית. למשל:
float f = 5.4;
int x;
scanf("%d", &x);
f += x;
מצד שני, לפעמים התוכניות שלנו יכילו קבועים שלא נרצה שישתנו לאורך ריצת התוכנית.
int main() {
int radius = -1;
printf("Enter circle radius");
scanf("%d", &radius);
printf("Circle area: %d", 3.14 * radius * radius);
}
התוכנית הזאת עובדת בצורה תקינה ומדפיסה השטח של המעגל, אבל יש בה בעיה - מהו 3.14? יתכן והצלחנו לזהות שמדובר בקבוע Pi, אבל יתכן גם שמישהו אחר שקורא את הקוד לא ידע על מה מדובר. לטובת כך אפשר להשתמש בהוראת קומפיילר בשם define#, הנראת כך:
#define PI 3.14
int main() {
int radius = -1;
printf("Enter circle radius");
scanf("%d", &radius);
printf("Circle area: %d", PI * radius * radius);
}
הוראה זו פשוטה - היא מספרת לקומפיילר שיש שם חדש בתוכנית בשם PI, והוא בתורו בכל מקום בו השם מופיע מחליף אותו בערך שנתנו לו. התוכנית עכשיו הרבה יותר קריאה - ברור שהכוונה היא לקבוע המעגל. נדגיש כי define# לא מגדיר משתנה חדש והקומפיילר לא מפרש את הערך הניתן לפי טיפוס מסוים - הקומפיילר מחליף מפורשות כל מקום בו כתבנו PI בערך שנתנו לו, 3.14.
ניתן להגדיר כל מה שנרצה ב-define#, למשל:
#define MESSAGE "HELLO WORLD"#define MAX_STUDENTS 500#define ELEMENTS 1024
הערה: נהוג להגדיר define# בפורמט SCREAMING_SNAKE_CASE, עם אותיות גדולות בלבד עם _ בין מילה למילה.
const
אופציה נוספת היא המזהה const, קיצור ל-constant. בהצרה על משתנה. משתנה שמוגדר כ-const יבדק ע“י הקומפיילר - בכל מקום בו משתמשים בו, הקומפיילר יבדוק האם מנסים לכתוב אליו. אם ימצא כתיבה כזו, הקומפיילר יסרב לקמפל את התוכנית. זו דוגמא אחת מבן רבות שנפגוש לעזרה שהקומפיילר מציע לנו בכתיבת קוד.
const double pi = 3.14159;
double r1 = 5.4, r2 = 7.0
const double area = pi * r1 * r1;
// Line below will not compile if uncommented
// area = pi * r2 * r2;
קריאת קלט מהמשתמש עם scanf
השתמשנו בפונקציה זו כבר, כעת נגדיר אותה בצורה מסודרת. הפונקציה קוראת ערכים לתוך משתנים הניתנים לה עם מזהים בדומה ל-printf. למשל:
int n;
scanf("%d", &n);
הפונקציה תציג למשתמש סמן מהבהב כשתתחיל ותחכה לקלט שלו. המזהה d% אומר לפונקציה שאנחנו מצפים למשתנה אחד מסוג int. המשתמש יכניס קלט ולאחר שילחץ על enter הפונקציה תנסה לעבד את הקלט שלו ולכתוב את התוצאה למשתנה n. שימו לב ל-& לפני n בקריאה לפונקציה - הוא חובה! בהמשך נראה מדוע ומה תפקידו.
אפשר לקרוא כמה משתנים מאותה שורה שהכניס המשתמש בצורה הבאה:
char c1, c2;
scanf("%c%c", &c1, &c2);
הערה: נניח שציפינו לקבל מהמשתמש מספר:
int n;
scanf("%d", &n);
אבל הוא הכניס משהו שאינו מספר, למשל כתב hello ואז enter. מה יקרה? הפונקציה תכשל במקרה זה - היא לא תצליח לכתוב למשתנה שלנו את התוצאה. איך נדע אם זה המצב?
בהמשך נגדיר זאת מפורשות, אבל בפשטות כרגע - פונקציות בשפת C מחזירות ערך, בדומה לפונקציות מתמטיות. scanf מחזירה את מספר הביטויים שהצליחה לקרוא מתוך הקלט של המשתמש. לכן אינטואיטיבית ניתן לכתוב משהו בסגנון הזה:
int n;
int result = scanf("%d", &n);
if (result != 1) {
// ...
}
כאשר בהמשך נגדיר את כל החלקים שמופיעים בקטע הזה בצורה מסודרת.
לאורך רוב הדוגמאות בקורס אנחנו נניח כי הקלט ש-scanf מקבלת תקין, אבל באופן כללי יש לבדוק את ערך ההחזרה שלה.
דוגמאות
וואו! עבדנו קשה מאוד בשביל לאסוף את הסינטקס בהרצאה הזו, אבל עכשיו נוכל לכתוב לא מעט תוכניות מעניינות רק עם הכלים הבסיסיים שיש לנו. נייצר כמה דוגמאות - נסו לעצור ולכתוב בעצמכם לפני שתראו את הפתרון. הפקידו על הזחות נכונות ומתן שמות משמעותיים למשתנים.
- כתבו תוכנית שמחשבת את המהירות של כדור נופל תחת כוח הכבידה אחרי 5.4 שניות, אם הגוף מתחיל ממנוחה. הנוסחא למהירות של גוף מאיץ בזמן \(t\) היא:
$$ v_0 + a \cdot t $$
עבור \(v_0\) מהירות התחלתית ו-\(a\) תאוצתו, כאשר תאוצת הנפילה החופשית היא בערך \(9.8\).
#define GRAVITY 9.8
int main() {
const double time = 5.4;
const double velocity = time * GRAVITY;
printf("Velocity after %.2f secs is %.2f meters/second", time, velocity);
}
- כתבו תוכנית שמחשבת שטח של משולש שגובהו 5 וצלעו 3.
int main() {
const double length = 5.0, width = 3.0;
const double area_rect = length * width * 0.5;
printf("Rectangle area: %.2f\n", area_rect);
}
- כתבו תוכנית שמחשבת ממוצע של המספרים 5, 3.4 ו-1.2.
int main() {
printf("Average: %.2f", (5 + 3.4 + 1.2) / 3);
}
Note
שימו לב שהקפדנו לתת למשתנים שמות משמעותיים כמו
length,area_rectangleוכו’. במתמטיקה נהוג לתת למשתנים שמות מאוד בסיסיים שקל לכתוב ביד כמוgלכבידה וכו’ - בתכנות זה כמעט תמיד רעיון לא טוב. יתכן שאנחנו עובדים בצוות ואז כדאי שהשמות יהיו מובנים לשאר המתכנתים, או אפילו אם אנחנו עובדים לבד על מנת שנוכל לקרוא קוד שכתבנו לפני מספר חודשים ולהבין מה הכוונה בצורה מהירה ויעילה.
תרגילים
- כתבו תוכנית שמדפיסה את הסכום, ההפרש, המכפלה, חלוקת השלמים, חלוקת העשרוניים והשארית בין המספר 17 למספר 5.
- כתבו תוכנית המקבלת טמפרטורה מהמשתמש בצלזיוס וממירה אותה לפרנהייט. נוסחת ההמרה היא \(F = 1.8 \cdot C + 32 \)
- קבלו מהמשתמש שלושה מספרים עבור הציונים שלהם, והדפיסו את הממוצע שלהם לפי חלוקת שלמים ולפי חלוקת עשרוניים.
- קבלו מספר מהמשתמש שניתן להניח שהוא בן 0 ל-999. הדפיסו את ספרת האחדות, העשרות והמאות שלו.
- קבלו מספר מהמשתמש שייצג אורך זמן כלשהו בשניות. הדפיסו כמה שעות, דקות ושניות שלמות יש באורך הזמן הזה.
- קבלו מהמשתמש שני מספרים
xו-yוהדפיסו אותם. לאחר מכן, החליפו בין הערכים שלהם והדפיסו אותם שוב, וודאו שהם השתנו באמת. - קבלו מהשמתמש מספר בן 3 ספרות, והדפיסו את היפוך הספרות שלו. למשל עבור
527הדפיסו725. - כתבו תוכנית המקבלת מהמשתמש שני מספרים ומדפיסה את סכום הערכים המוחלטים שלהם.
- כתבו תוכנית המקבלת מהשמתמש מספר חודש בין 1 ל-12 ומדפיסה את מספר הימים באותו החודש. בחודשים אי זוגיים יש 31 ימים, ובחודשים זוגיים יש 30 ימים, פרט לפברואר (2) בו יש 28 ימים.
תרגיל
לכל אחת מהתוכניות הבאות, קבעו מה יודפס ללא הרצת התוכנית, ולאחר מכן תריצו וודאו את תשובותכם:
int a = 2;
int b = 5;
int c = 8;
a += b++;
c -= ++a;
printf("%d %d %d\n", a, b, c);
int a = 6;
int b = 4;
int c = 2;
int x = a + b * c;
int y = x % 5;
int z = y == 1 || a / b == 1;
printf("%d %d %d\n", x, y, z);
a++;
b *= z;
printf("%d %d %d\n", a, b, c);
int a = 8;
int b = 3;
int x = a % b == 2;
int y = a / b > 2;
int z = x && y;
printf("%d %d %d\n", x, y, z);
printf("%d\n", x * 10 + y * 5 + z);
הרצאה 3 - משפטים, תנאים ולולאות
בהרצאה זו נכיר את ליבת השפה - תנאים ולולאות. תנאים יאפשרו לנו לבצע הסתעפות (branch) בתוכנית - לקבל החלטה על בסיס מידע שיש לנו ולבצע פעולות בהתאם, בעוד שלולאות יאפשרו לנו לחזור על אותה פעולה מספר שרירותי של פעמים.
משפט if
סינטקס והגדרות
משפט if יאפשר לתוכנית שלנו להחליט האם לבצע או לדלג על קטע קוד מסוים בהתאם להאם תנאי כלשהו מתקיים. מספר דוגמאות לתנאים שימושיים בתכנות:
- בדיקת סיסמא - נבקש מהמשתמש להכניס סיסמא. אם הכניס אחת נכונה, ניתן לו גישה למחשב - אחרת נמנע ממנו להכנס.
- בדיקת נתונים - אם מישהו מבקש להזמין באינטרנט 4 מגני מסך, נבדוק מול הנתונים האם יש מספיק לתת לו ובמידה וכן נאשר את המשלוח שלו.
- צ’אט - במידה ואדם א’ רוצה לשלוח הודעה לאדם ב’, נבודק האם אדם ב’ לא חסם את אדם א’, ובמידה ולא נאשר את שליחת ההודעה.
לפני שנגדיר את הסינטקס בצורה מדויקת, נבחן דוגמא אינטואיטיבית:
#include <stdio.h>
#define PASSWORD 7521
int main() {
int password;
printf("Please enter your password\n");
scanf("%d", &password);
if (password == PASSWORD) {
printf("Access granted!\n");
} else {
printf("Access denied!\n");
}
}
ניתן להבין בצורה אינטואיטיבית מה קורה כאן - אנחנו מבקשים מהמשתמש סיסמא. אם הסיסמא שלו נכונה, אנחנו מדפיסים הודעת הצלחה - אחרת, הודעת שגיאה. לאחר ה-if מופיע תנאי כלשהו.
Note
נזכיר כי הביטוי
password == PASSWORDהוא מסוג בוליאני - ערכו יכול להיותtrueאוfalse, ולכן הוא מתאים לשמש תנאי שיתכן ומתקיים ויתכן ולא.
כעת בצורה מדויקת יותר - תנאים ממומשים ע“י מילת המפתח if, כאשר הסינטקס לכתיבת משפט if הוא:
if (boolean statement) {
// Execute only if statement is true
}
// Continue execution here
נפרק את המרכיבים:
- המילה
ifמתחילה את המשפט ולאחריה סוגריים רגילים(). - בתור הסוגריים נמצא ביטוי בוליאני כלשהו - כמו שראינו בהרצאה הקודמת, ביטוי בוליאני יכול לקבל את הערכים
trueאוfalseבלבד. - כאשר הביטוי הבוליאני הוא
true, נעבור לביצוע בלוק הקוד בתוך הסוגריים המסולסלים. אחרת, נקפוץ מיד לאחר קצה הבלוק.
Note
ב-C ובשפות אחרות קיימות מספר מילות מפתח (keywords) בהן לא ניתן להשתמש לאף צורך חוץ ממה שהשפה מגדירה.
ifהיא מילת מפתח כזו - לא ניתן להגדיר משתנה בשםint if = 5למשל.
לדוגמא - נבדוק האם יש מספיק מצרכים להכין עוגה:
#include <stdio.h>
#include <stdbool.h>
int main() {
int eggs_in_stock = 3;
int eggs_needed;
printf("Enter number of needed eggs\n");
scanf("%d", &eggs_needed);
if(eggs_in_stock >= eggs_needed) {
printf("You can make cake!\n");
}
}
Note
אין צורך להגדיר משתנה בוליאני
bool can_make_cake = eggs_in_stock >= eggs_needed- הקומפיילר מבצע casting לבוליאני באופן אוטומטי לכל ביטוי שנכתוב בתוך משפטif.
Lazy Evaluation
נזכיר כי בחישוב ביטוי בוליאני ניתן לדלג על חלק מהחישובים במידה והביטוי כבר ידוע, כמו במקרים x && false ו-x || true.
נבחן כדוגמא תוכנית שבוחנת עבור מספר כלשהו \(n\) האם המספר \(\frac{1}{n}\) קטן מ-\(\frac{1}{2}\):
#include <stdio.h>
#define UPPER_BOUND 0.5
int main() {
int n;
scanf("%d", &n);
if (n != 0) {
if ((1 / n) < UPPER_BOUND) {
printf("1 / %d is less than %.2lf\n", n, UPPER_BOUND);
}
}
}
נשים לב לבדיקה האם \(n\) אינו אפס על מנת להגן על התוכנית מחלוקה ב-0. ניתן להשתמש ב-lazy evaluation על מנת לפשט את התוכנית, ולכתוב במקום:
#include <stdio.h>
#define UPPER_BOUND 0.5
int main() {
int n;
scanf("%d", &n);
if (n != 0 && (1 / n) < UPPER_BOUND) {
printf("1 / %d is less than %.2lf\n", n, UPPER_BOUND);
}
}
במידה ו-n < 0, מגננון ה-lazy evaluation נכנס לפעולה ולכן החלק השני בתנאי לא יתבצע - ההגנה מתבצעת כך או כך.
כתיבה ללא סוגריים מסולסלים
השפה מאפשרת לכתוב משפט if ללא סוגריים מסולסלים, כך:
// Code before
if(eggs_in_stock >= eggs_needed)
printf("You can make cake!");
// Do more stuff after
למעשה, השפה מתייחסת לשורה הזו בתור בלוק בפני עצמה. הבעיה עם צורת הכתיבה הזו שהיא יכולה להוביל לקוד שנראה תקין אבל הוא שגוי לחלוטין - למשל בדוגמא הבאה:
if(eggs_in_stock >= eggs_needed)
printf("You can make cake!\n");
printf("Cake for everybody!");
// Do other stuff
ניתן לחשוב בגלל ההזחות שההדפסה השנייה תקרא רק אם יש מספיק ביצים - זה לא נכון! כפי שראינו, הקומפיילר אינו קורא בכלל את את ההזחות והרווחים שאנחנו מכניסים לקוד, ולכן במקרה זה אם תנאי ה-if מתקיים תתבצע ההדפסה הראשונה, אבל גם אם לא - מיד לאחר מכן תתבצע ההדפסה השנייה. למעשה, קטע הקוד הזה שקול לחלוטין לבא:
if(eggs_in_stock >= eggs_needed) {
printf("You can make cake!\n")
}
printf("Cake for everybody!")
// Do other stuff
זוהי שגיאה מאוד נפוצה - הרבה פעמים ניתן לכתוב משפט ללא סוגריים מסולסלים, ולאחר מכן להוסיף עוד שורה ולא לשים לב שאין אותם. לכן מומלץ מאוד תמיד להשתמש בהן, גם אם הבלוק שבתוך ה-if מכיל שורה אחת בלבד.
משפט else
מקרה נפוץ נוסף הוא בו נרצה לבצע משהו אם תנאי מתקיים (ובכך מתאים להשתמש ב-if) אבל במידה והוא לא מתקיים נרצה לבצע משהו אחר. לטובת כך ניתן להוסיף ל-if חלק נוסף בשם else, בסינטקס הבא:
if (boolean expression) {
// Execute only if statement is true
} else {
// Execute only if it isn't
}
ה-else הוא רשות לחלוטין ואין חובה להוסיף אותו. נוסיף למשל לתוכנית של עוגה:
#include <stdio.h>
#include <stdbool.h>
int main() {
int eggs_in_stock = 3, eggs_needed;
printf("Eggs needed:\n");
scanf("%d", &eggs_needed);
if(eggs_in_stock >= eggs_needed) {
printf("You can make cake!\n");
} else {
printf("You cannot make cake!\n");
}
}
הערה: נשים לב שה-else נמצא באותה שורה של הסוגר התחתון של ה-if. זוהי דרך לחסוך בשורות כך שיהיה ניתן לראות יותר קוד על המסך במקביל - מדובר בקונבנציה ולא מעבר לכך. אנחנו נקפיד בקורס על הסגנון הזה, אבל ניתן לכתוב גם כך:
if(...) {
// Execute if true
}
else {
// Execute if not
}
או אפילו כך:
if(...)
{
// Execute if true
}
else
{
// Execute if not
}
לבחירת סגנון אין משמעות פרקטית על הקוד שמתבצע. עם זאת, יש לסגנון משמעות גדולה על קריאות (readability) - היכולת של מתכנת לפתוח את התוכנית שכתבתם, להבין אותה ולדעת לעבוד איתה בצורה מהירה ויעילה. לכן, חשוב לבחור סגנון כלשהו שנוח ולהקפיד לא לקפוץ בין סגנונות שונים במהלך כתיבת תוכנית. אנחנו בקורס נקפיד על הסגנון הבא, שהוא מהמקובלים ביותר ולכן כדאי להתרגל אליו:
if(something) {
// do...
} else {
/// do else ...
}
משפט else-if
בצורה דומה, לפעמים נרצה לבחור מתוך כמה אופציות. לכן, ניתן להוסיף למשפט if כמה חלקים נוספים שנרצה בשם else if שיטופלו לפי סדר. למשל, נניח שנרצה לחשב ציון לתלמיד:
#include <stdio.h>
int main() {
int score;
char grade;
scanf("%d", &score);
if(score >= 90) {
grade = 'A';
} else if(score >= 80) {
grade = 'B';
} else if(score >= 70) {
grade = 'C';
} else if(score >= 60) {
grade = 'D';
} else {
grade = 'F';
}
printf("Your grade is %c\n", grade);
}
כאשר משרשרים מספר משפטי if ו-else זה לזה, כל אחד מהתנאים מחושב לפי הסדר בהם הם מופיעים. במידה ואחד התנאים הוא true, מבצעים את בלוק הקוד המתאים. במידה ואף אחד מהתנאים לא מתקיים, אף אחד מהבלוקים לא מתבצע - אלא אם מוסיפים משפט else כמו שראינו בדוגמא.
אם בשרשרת if-else יותר מתנאי אחד מתקיים, הראשון מביניהם הוא זה שיתבצע בלבד:
if(5 > 7) {
printf("5 is greater than 7\n");
} else if(5 < 7) {
printf("5 is smaller than 7\n");
} else if(1 > 0) {
printf("1 is greater than 0\n");
}
הערה: בלוק הקוד של משפט if יכול להכיל בעצמו משפט if:
#include <stdio.h>
int main() {
int ADULT = 18, SENIOR = 65;
int age;
scanf("%d", &age);
if(age >= ADULT) {
printf("You are an adult\n");
if(age >= SENIOR) {
printf("You are a senior\n");
} else {
printf("You are not a senior\n");
}
} else {
printf("You are not an adult\n");
}
}
נשים לב כיצד פתיחה וסגירה נכונה של סוגריים יחד עם הזחה נכונה הופכים את הקוד לקריא וברור.
דוגמא: מה תדפיס התוכנית הבאה?
#include <stdio.h>
int main() {
int val = 0;
if (val > 0)
printf("Positive\n");
printf("Check complete\n");
else
printf("Non-positive\n");
return 0;
}
זו דוגמא לאיך כתיבה ללא סוגריים מסולסלים יכולה להוביל לבאגים: הקוד הזה לא יתקמפל בכלל! משפט else חייב להופיע מיד לאחר סיום הגוף של משפט if. כמו שראינו, הגוף של משפט if הוא המשפט שמגיע מיד אחריו - במקרה זה מכיוון שלא השתשמנו בסוגריים מדובר רק על השורה printf("Positive\n"). כלומר המשפט שמגיע לפני ה-else הוא בכלל printf("Check complete\n"), ולכן הקומפיילר יסרב לקמפל את הקוד.
משפט if טרנרי (ternary if)
אחד המקרים הנפוצים ביותר שמערבים משפטי if הוא בו נרצה לחשב ערך אחד אם תנאי נכון, ואחרת ערך אחר. במקרה זה ניתן להשתמש במשפט מיוחד שהתוצאה שלו היא ערך אחד או אחר בהתאם לתנאי. נבחן את התוכניות הבאות:
#define DISCOUNT_LIMIT 1000
#include <stdio.h>
int main() {
int total_price;
float discount;
if (total_price > DISCOUNT_LIMIT) {
discount = 0.05;
} else {
discount = 0;
}
printf("Discount on purchase: %f", discount);
}
#include <stdio.h>
#define ADULT 18
int main() {
int age;
scanf("%d", &age);
if (age < ADULT) {
printf("You are not an adult\n");
} else {
printf("You are an adult\n");
}
}
זה מאוד מסורבל ביחס לפעולה מאוד פשוטה. ביטוי if טרנרי מאפשר לנו לשלב את כל השורות האלו לאחת ע״פ הסינטקס הבא:
boolean expression ? <value if true> : <value if false>;
כלומר, אם הביטוי הבוליאני הוא true, נקבל את הביטוי הראשון, ואחרת את השני - במשפט אחד!
ניתן לחשוב על משפט כזה בתור עם שתי תשובות אפשריות - אנחנו שואלים את השאלה שהיא התנאי (ולכן יש סימן שאלה אחריה), מיד לאחר מכן מציגים את הערך אם התשובה היא כן, ובמידה ולא לאחר ה-: מציגים את הערך השני.
בדוגמאות שלנו נקבל:
float discount = total_price > DISCOUNT_LIMIT ? 0.05 : 0;
#include <stdio.h>
#define ADULT 18
int main() {
int age;
scanf("%d", &age);
printf(
"%s\n", age < ADULT ?
"You are not an adult\n" :
"You are an adult\n"
);
}
Note
אין חובה לכתוב כל מחרוזת בשורה חדשה, אך לפעמים זה הופך את הקוד לקריא ונוח יותר מאשר לכתוב הכל בשורה אחת ארוכה.
וזה באמת יותר קצר ואלגנטי. לדוגמא נוספת - ניתן לכתוב כך בתוך printf על מנת לפרמט טקסט בהתאם לתנאי:
int jim_age = 30;
int pam_age = 28;
printf("oldest age of jim and pam: %d", jim_age > pam_age ? jim_age : pam_age);
משחק ניחושים
יש לנו כעת מספיק כלים בשביל לבנות תוכנית קצרה ופשוטה אבל שעדיין עושה משהו מעניין. נבנה משחק ניחוש - נבחר מספר סודי, והמשתמש ינסה לנחש אותו. אם הוא לא יצליח, נגיד לו האם היה קטן/גדול מהמספר הסודי כדי שיוכל לשפר את הניחוש הבא שלו.
#include <stdio.h>
int main() {
int secret = 9;
int guess;
printf("Enter a guess for the secret number!\n");
scanf("%d", &guess);
if (guess == secret) {
printf("You win - secret was %d!\n", secret);
} else if (guess < secret) {
printf("Your guess is smaller than secret!\n");
} else if (guess > secret) {
printf("Your guess is bigger than secret!\n");
}
}
תרגילים
- כתבו תוכנית המקבלת מהמשתמש מספר ומדפיסה האם הוא חיובי, שלילי או אפס.
- כתבו תוכנית המקבלת מהמשתמש שני מספרים ומדפיסה את הגדול מביניהם.
- כתבו תוכנית המקבלת מהמשתמש שנה ומדפיסה האם היא שנה מעוברת. שנה מעוברת היא שנה שמתחלקת ב-400, או שמתחלקת ב-4 אבל לא ב-100.
- כתבו תוכנית שמקבלת שלושה מספרים מהמשתמש ומדפיסה אותם בסדר עולה.
- כתבו תוכנית המקבלת שלושה מספרים מהמשתמש וקובעת האם הם תאריך חוקי. למשל, התאריך 1 15 2024 אינו חוקי מכיוון שאין חודש 15, בעוד שהתאריך 13 3 5 הוא חוקי.
- כתבו תוכנית שמקבלת מהמשתמש מספר בין 10 ל-99. אם המספר אינו בטווח, יש להדפיס הודעת שגיאה. אחרת, יש להדפיס ״!BOOM“ אם אחת הספרות של המספר היא 7, אחרת להדפיס את המספר עצמו.
תרגיל מסכם
נתון מנעול המקבל שלושה מספרים. המנעול נפתח רק אם כל התנאים הבאים מתקיימים:
- מספר אחד בלבד מבין השלושה הוא שלילי, והשאר חיוביים (אינם אפס).
- סכום שני המספרים החיוביים גדול מ-100.
- הערך המוחלט של המספר השלישי קטן משני המספרים החיוביים.
- לפחות אחד מהמספרים זוגי.
לדוגמא, המספרים \(-20, 60, 50\) פותחים את המנעול, בעוד ש-\(-70, 60, 50\) לא פותחים את המנעול.
כתבו תוכנית שמקבלת שלושה מספרים מהמשתמש ומדפיסה “open” אם המספרים פותחים את המנעול, ו-“closed״ אחרת.
בניית מחשבון פשוט
נבנה מחשבון פשוט לשני מספרים ואופרטור כלשהו ביניהם. המשתמש יכניס שני מספרים ואת האופרטור הרצוי ואנו נדפיס את התוצאה:
#include <stdio.h>
int main() {
char op;
double a, b, result;
printf("Enter an operator (+, -, *, /, %%,): ");
scanf(" %c", &op);
printf("Enter two numbers: ");
scanf("%lf %lf", &a, &b);
if (op == '+') {
result = a + b;
printf("Result = %.2f\n", result);
} else if (op == '-') {
result = a - b;
printf("Result = %.2f\n", result);
} else if (op == '*') {
result = a * b;
printf("Result = %.2f\n", result);
} else if (op == '/') {
if (b != 0) {
printf("Result = %.2f\n", a / b);
} else {
printf("Error: division by zero\n");
}
} else {
printf("Unknown operator: %c\n", op);
}
}
נשים לב שבדוגמא זו כל משפטי ה-if שכתבנו בדקו האם ערך מסוים שווה לערך כלשהו מתוך הקבוצה [+, -, *, /]. מיד נפגוש את משפט ה-switch שהופך את התבנית הזו לפשוטה יותר.
משפט switch
שרשורי else if
נניח שיש לנו בתוכנית משתנה כלשהו שערכו הוא בחירה כלשהי מתוך קבוצה סופית. למשל - משתנה char grade, שערכו יכול להיות כל אות מ-A ועד F ומייצג ציון במבחן. נניח שנרצה להדפיס הודעה כלשהי בהתאם לציון המתקבל. יתכן ונכתוב תוכנית שנראת כך:
#include <stdio.h>
int main() {
char grade;
scanf("%c", &grade);
if (grade == 'A') {
printf("Excellent!\n");
} else if (grade == 'B') {
printf("Good job\n");
} else if (grade == 'C') {
printf("Fair\n");
} else if (grade == 'D') {
printf("Needs improvement\n");
} else if (grade == 'F') {
printf("Failed\n");
} else {
printf("Invalid grade\n");
}
}
שרשורי else if ארוכים מסוג זה הם קשים לקריאה, קשים לכתיבה וקל לעשות בהם טעויות. כאשר נרצה לבדוק האם משתנה כלשהו שווה לאחד מבין מספר ערכים שונים, נשתמש בו.
דוגמת הציונים תכתב כך בשימוש במשפט switch:
#include <stdio.h>
int main() {
char grade;
scanf("%c", &grade);
switch (grade) {
case 'A':
printf("Excellent!\n");
break;
case 'B':
printf("Good job\n");
break;
case 'C':
printf("Fair\n");
break;
case 'D':
printf("Needs improvement\n");
break;
case 'F':
printf("Failed\n");
break;
default:
printf("Invalid grade\n");
}
}
נפרק:
- המשפט מתחיל ב-
switch(x), כאשרxהוא המשתנה שנרצה לבדוק שוויון שלו לערכים אחרים. - לאחר מכן מגיעה רשימה של מקרים שונים עם מילת המפתח
caseבתחילתן, נקודתיים ואז בלוק קוד. - בסוף כל בלוק קוד מופיעה מילת המפתח
breakשמסמנת את סוף הבלוק. - ניתן לספק קטע קוד דיפלוטיבי
defaultהמתבצע במידה ואף אחד מה-cases לא התאמת על המשתנה מעליו ביצענוswitch.
לסיכום, הסינטקס עבור משפט switch נראה כך:
switch (value) {
case case1:
// Body if case1
break;
case case2:
// Body if case2
break;
// Add more cases if needed
case default:
// Body if no other case
}
לפעמים לא מזיחים את קטעי הקוד על מנת לקצר, וזה נראה כך:
switch (day) {
case 1: printf("Monday\n"); break;
case 2: printf("Tuesday\n"); break;
case 3: printf("Wednesday\n"); break;
case 4: printf("Thursday\n"); break;
case 5: printf("Friday\n"); break;
case 6: printf("Saturday\n"); break;
case 7: printf("Sunday\n"); break;
default: printf("Invalid day\n");
}
כרגיל, הקומפיילר מתעלם מרווחים ושורות, ולכן אין חובה לרדת שורה לאחר ה-case או לפני ה-break, כך שניתן לייצר משפטים קצרים ופשוטים. ניתן גם להקיף כל case ב-{} במידה ורוצים להיות יותר ברורים.
כדוגמא נוספת, נשכתב את המחשבון שלנו לשימוש ב-switch:
#include <stdio.h>
int main() {
char op;
double a, b;
printf("Enter an operator (+, -, *, /, %%, ^): ");
scanf(" %c", &op);
printf("Enter two numbers: ");
scanf("%lf %lf", &a, &b);
switch (op) {
case '+':
printf("Result = %.2f\n", a + b);
break;
case '-':
printf("Result = %.2f\n", a - b);
break;
case '*':
printf("Result = %.2f\n", a * b);
break;
case '/':
if (b != 0) {
printf("Result = %.2f\n", a / b);
} else {
printf("Error: division by zero\n");
}
break;
default:
printf("Unknown operator\n");
}
}
Note
על מנת לוודא שכל
caseמתבצע רק פעם אחת, חובה לכתובbreakבסוף כל בלוק. במידה ולא נעשה זאת, התוכנית תמשיך לביצוע ה-caseהבא אחריו, שזו בדרך כלל לא ההתנהגות הרצויה. עם זאת, זו תכונה שיכולה להיות שימושית - למשל כאשר נרצה להגדיר כמה בלוקים שיבצעו את אותה הפעולה, כמו בדוגמא הבאה:
#include <stdio.h>
int main() {
int month;
printf("Enter month (1–12): ");
scanf("%d", &month);
switch (month) {
case 2:
printf("28 or 29 days\n");
break;
case 4:
case 6:
case 9:
case 11:
printf("30 days\n");
break;
case 1:
case 3:
case 5:
case 7:
case 8:
case 10:
case 12:
printf("31 days\n");
break;
default:
printf("Invalid month\n");
}
return 0;
}
לולאות (loops)
לולאה היא דרך לגרום לקטע קוד להתבצע מספר פעמים לבחירתנו. ישנן סיבות מרובות מדוע נרצה לעשות זאת - הנה כמה דוגמאות:
- נרצה לחשב את העצרת של משתנה כלשהו. אין פעולת עצרת מובנת בשפת C - אפשר לפרק את העצרת לכפל שחוזר על עצמו עד שמגיעים ליעד הנדרש, כך שכל ביצוע של הלולאה יבצע פעולת כפל אחת.
- נניח שהקוד שלנו מייצג קבוצה כלשהי של סטודנטים - יתכן ונרצה לסכום את הציונים של כולם, למשל לטובת חישוב ממוצע.
כל חזרה על קטע הקוד שבלולאה נקראת איטרציה (iteration). ב-C יש שני סוגי לולאות - while ו-for. נבחן כל אחת מהן ונבין מה השימושים והיתרונות של כל אחת, אך נדגיש לפני כי ההבדל ביניהם הוא בסינטקס בלבד וניתן להשתמש בכל סוג לפי מה שנוח באותה סיטואציה.
לולאת while
לולאת while מאפשרת לנו לחזור על קטע קוד כלשהו כל עוד תנאי בוליאני כלשהו מתקיים. הסינטקס עבור לולאה כזו הוא:
while (boolean expression) {
// Loop body
}
כשנגיע לתחילת הלולאה, נבדוק האם התנאי מתקיים. במידה וכן, נבצע את קטע הקוד שבתוכה (גוף הלולאה). בסיום גוף הלולאה, נבדוק שוב את התנאי - במידה והוא עדיין מתקיים, נבצע את גוף הלולאה שוב.
לדוגמא, נחשב עצרת של מספר כלשהו הניתן מהמשתמש. כידוע, מתקיים עבור \(n\) שלם:
$$ n! = 1 \cdot 2 \cdot 3 \cdot … \cdot (n-1) \cdot n $$
#include <stdio.h>
int main() {
int number = -1;
scanf("%d", &number);
int i = 2, factorial = 1;
while (i <= number) {
factorial *= i++;
}
printf("%d! = %d\n", number, factorial);
}
נניח שהכנסנו לתוכנית את המספר 5. נבחן את המשתנים בכניסה לכל איטרציה של הלולאה:
i | factorial | i <= number |
|---|---|---|
| 2 | 1 | true |
| 3 | 2 | true |
| 4 | 6 | true |
| 5 | 24 | true |
| 6 | 120 | false |
אפשר גם לדוגמא להשתמש בלולאה בשביל לבצע בדיקה של נתונים מהמשתמש במקרה זה. נניח שאנחנו רוצים לבדוק שהמשתמש לא הכניס מספר שלילי - אפשר להוסיף לקוד הקודם כך:
#include <stdio.h>
int main() {
int number = -1;
while( number <= 0) {
printf("Enter a positive number\n");
scanf("%d", &number);
}
int i = 2, factorial = 1;
while(i <= number) {
factorial *= i++;
}
printf("%d! = %d\n", number, factorial);
}
כעת הלולאה תחזור על עצמה כל עוד המשתמש לא הכניס מספר חיובי. האתחול למספר שלילי במקרה זה משמש גם את המטרה הזו - לוודא שרק לאחר הכנסת מספר חיובי הלולאה תתקדם. לולאות while, בניגוד ללולאות for שנפגוש מיד, מתאימות מאוד למקרה בו אנחנו צריכים לבצע קטע קוד מספר כלשהו של פעמים שאינו ידוע מראש - בדיוק כמו במקרה הזה, בו קיימת אפשרות שמשתמש יכניס בטעות או בזדון מספר כלשהו של פעמים.
Note
ניתן באמצעות
whileליצר לולאות אינסופיות, ע״י הגדרת תנאי אמת שלא משתנה - למשל כך:
#include <stdbool.h>
while (1) {
// Infinite loop
}
while (true) {
// Infinite loop
}
בהמשך נראה כיצד ניתן לעצור לולאות אינסופיות.
לולאת do while
do while היא וריאנט של לולאת while, ושימושית כאשר נרצה לוודא שגוף הלולאה רץ לפחות פעם אחת. נחזור למשל לדוגמא של משחק ניחוש המספרים - נניח שנרצה לתת למשתמש לחזור על הניחוש עד שהוא מצליח. דרך אפשרית אחת תהיה כך:
#include <stdio.h>
int main() {
int secret = 9, guess = -1;
while (secret != guess) {
printf("Enter a guess for the secret number!\n");
scanf("%d", &guess);
if (guess == secret) {
printf("You win - secret was %d!\n", secret);
}
}
}
זה קצת עקום - צריך להגדיר את guess לאיזשהו מספר שאינו הסוד לפני הלולאה ולבצע בדיקה של התנאי שאנחנו יודעים שבוודאות תתקיים. לולאת do while תאפשר לנו לכתוב קטע קוד שבוודאות יתבצע פעם אחת, ואז ימשיך כל עוד תנאי כלשהו מתקיים. הסינטקס נראה כך:
do {
// Loop body
} while (boolean condition);
הרעיון בלולאת do while הוא העברת בדיקת התנאי לסוף הלולאה במקום לתחילתה. גוף הלולאה מתבצע פעם אחת ואז נבדק התנאי. במידה והתנאי מתקיים, נמשיך לאיטרציה נוספת של הלולאה. ניתן כעת לכתוב את משחק הניחושים בצורה אלגנטית יותר:
#include <stdio.h>
int main() {
int secret = 0, guess;
do {
printf("Enter a guess for the secret number!\n");
scanf("%d", &guess);
} while (guess != secret);
printf("You win - secret was %d!\n", secret);
}
ניתן לממש כל לולאת do while בצורה שקולה עם לולאת while, אבל יש מקרים מסוימים בהם הראשונה אלגנטית ופשוטה יותר לקריאה.
לולאת for
לולאת for קצת יותר מסובכת בסינטקס ביחס ללולאת while, אבל היא מאפשרת לכתוב בצורה פשוטה וקריאה פעולה מאוד נפוצה בשפה. על מנת לפשט את ההבנה, נבין מהי הפעולה עם דוגמא ולאחר מכן נציג את הסינטקס.
נחזור לדוגמא של חישוב העצרת. מה בעצם רצינו לעשות?
- לאתחל משתנה כלשהו לערך התחלתי (במקרה העצרת ל-2, הערך הראשון שיש להכפיל בו בחישוב עצרת)
- לספור מהערך ההתחלתי עד לערך כלשהו (במקרה העצרת עד ל-
numberשהכניס המשתמש) - לבצע פעולה על כל המספרים בין הערך ההתחלתי לסופי (במקרה העצרת להכפיל)
לולאת for מאפשרת לנו לשלב את שלושת המרכיבים האלו לכדי שורה אחת שלאחריה תגיע הלולאה. נכתוב מחדש את העצרת:
int main() {
int number = -1;
while (number <= 0) {
printf("Enter a positive number\n");
scanf("%d", &number);
}
int factorial = 1;
for (int i = 2; i <= number; i++) {
factorial *= i;
}
}
קצת סינטקס חדש! נפרק:
- בתוך הסוגריים
()של לולאת ה-forמופיעות שני;שמפרידות לשלושה חלקים. int i = 2- החלק הראשון הוא משפט שמתבצע פעם אחת לפני האיטרציה הראשונה של הלולאה. במקרה זה השתמשנו בו בשביל להצהיר על משתנה חדש ולתת לו ערך. בדרך כלל נשתמש בחלק זה בשביל להצהיר על משתנה שיבצע את הספירה המדוברת, הנקראת enumeration.i <= number- החלק האמצעי הוא תנאי בוליאני שנבדוק לפני כל איטרציה של הלולאה (גם הראשונה). אם התנאי לא מתקיים, מפסיקים את ביצוע הלולאה וממשיכים לביצוע קוד שאחריה. בדרך כלל נשתמש בחלק זה עם תנאי כמו>או=!בשביל להגדיר ביצוע עד תנאי מסוים.++i- החלק האחרון של גוף הלולאה מכיל משפט שמתבצע לאחר סיום כל איטרציה של הלולאה. בדרך כלל נשתמש בחלק זה בשביל לקדם את המשתנה שהצהרנו עליו בחלק הראשון.
לאחר שראינו דוגמא, הסינטקס המלא של לולאת for הוא:
for (expr1; expr2; expr3) {
body
}
כאשר:
- לפני תחילת הלולאה נבצע את
expr1. - לפני כל איטרציה (כולל הראשונה) נבדוק האם
expr2הואtrueובמידה וכן נבצע אתbody, אחרת נסיים את ריצת הלולאה. - לאחר כל איטרציה נבצע את
expr3.
הצורה הנפוצה ביותר בה נפגוש ונשתמש בלולאת for היא ריצה על כל המספרים בין 0 לבין ערך כלשהו. למשל, נסכום את כל המספרים בין 0 ל-1000 במספר שורות קצרות:
#include <stdio.h>
#define UPPER_BOUND 1000
int main() {
int sum = 0;
for (int i = 0; i < UPPER_BOUND; i++) {
sum += i;
}
printf("sum = %d\n", sum);
}
הערות
-
לולאות בשפת C הרבה פעמים משתמשות במשתנה הנקרא אינדקס, שאיתו בדרך כלל בודקים את התנאים הנדרשים לקיום הלולאה. בניגוד למשתנים אחרים, להם בדרך כלל נקרא בשמות מלאים (כמו
eggs_neededאוfactorial), מקובל לקרוא לאינדקסים באותi. במידה וצריך יותר מאינדקס אחד, בדרך כלל קוראים להםi, j, kוכך הלאה. בלולאתfor, התאי שאנחנו מאתחלים כחלק מהלולאה יהיה לרוב אינדקס. -
ניתן להשמיט אחד או יותר מחלקי הלולאה - אין חובה לאתחל משתנה למשל או לבדוק תנאי.
-
בגרסאות ישנות יותר של שפת C לא היה ניתן להצהיר על משתנה חדש בתור לולאת
for, ולכן לפעמים תפגשו את הסינטקס הבא:
int i;
for (i = 0; i < n; i++) {
// ...
}
ניתן לכתוב כך גם היום, אם כי כשנדבר על זמן חיות (lifetime) של משתנים נבין שיש יתרונות לצורת הכתיבה בה מצהירים על האינדקס i כחלק מהלולאה.
- גם עם לולאות
forניתן לייצר לולאות אינסופיות, עם כי הסינטקס עבור כך קצת פחות קריא:
for(;;) {
// Infinite loop
}
פקודות break ו-continue
לפעמים נרצה שליטה עדינה יותר על ביצוע הלולאות שלנו. לטובת כך השפה מעמידה לידינו שתי פקודות מיוחדות, אותן ניתן לכתוב בתוך לולאה מכל סוג:
- פקודת
break- פקודה זו עוצרת את ביצוע הלולאה באופן מיידי ויוצאת ממנה, ללא ביצוע האיטרציה הבאה. - פקודת
continue- פקודה זו עוצרת את האיטרציה הנוכחית ומדלגת מיד לביצוע האיטרציה הבאה, אם היא אמורה להתבצע לפי תנאי הלולאה.
לדוגמא, נכתוב תוכנית שמדפיסה את הריבוע של המספר שהמשתמש הכניס בלולאה אינסופית, עם אופציה לסיום:
#include <stdio.h>
int main() {
int num;
while (1) {
printf("Enter a positive number to square, or 0 to stop\n");
scanf("%d", &num);
if (num == 0) {
break;
}
printf("%d^2 = %d\n", n, n * n);
}
printf("Done!\n");
}
כדוגמא נוספת, נכתוב תוכנית שסוכמת כל מספר בין 0 ל-100 פרט לאלו שספרת האחדות שלהם היא 3 או שספרת המאות שלהם היא 7:
#include <stdio.h>
#define N 100
int main() {
int sum = 0;
for (int i = 0; i < N; i++) {
if (i % 10 == 3 || (i / 10) % 10 == 7) {
continue;
}
sum += i;
}
printf("sum = %d\n", sum);
}
אלגוריתם אוקליד
כעת יש לנו את הכלים על מנת להתמודד עם דוגמא מלמדת מעט מורכבת יותר - כתיבת תוכנית שפותרת בעיה מתמטית אמיתית, אם כי פשוטה. נגדיר את הבעיה:
בהנתן שני מספרים חיוביים \(m, n\), מצאו את המכנה המשותף הגדול ביותר שלהם - השלם הגדול ביותר שמחלק את שניהם (ה-greatest common divisor, או GCD).
לדוגמא, נמצא את ה-GCD של 54 ו-24. על מנת לעשות זאת, נמצא את כל המחלקים של שני המספרים.
המחלקים של 54 הם:
$$ { 1, 2, 3, 6, 9, 18, 27, 54 } $$
ושל 24:
$$ 1, 2, 3, 4, 6, 8, 12, 24 $$
ולכן ע״י מציאת המקסימום של החיתוך בין שתי הקבוצות נקבל:
$$ gcd(54, 24) = 6 $$
הבעיה בפתרון זה הוא שהוא דורש למצוא את כל המחלקים של שני המספרים ואז להצליב ביניהם. עבור מספרים גדולים פעולה זו הופכת להיות מאוד לא יעילה - נחפש פתרון שמאפשר לנו להקטין את גודל המספרים איתם אנו עובדים.
אלגוריתם אוקליד
אלגוריתם אוקליד מאפשר לנו להקטין באופן שיטתי את המספרים להם אנו מוצאים את ה-GCD עד שהבעיה הופכת לפשוטה
Note
נשתמש בסימון \(a | b\) עבור המקרה בו \(b\) הוא מחלק של \(a\) ללא שארית.
בדוגמא הקודמת, שאלנו מהם המחלקים של 54 ו-24. אלגוריתם אוקלידס מציע לנו הצגה אחרת - נכתוב את 54 (הגדול מבין שני המספרים) ככפולה שלמה של הקטן עם שארית. כלומר:
$$ gcd(54, 24) = gcd(24 \cdot 2 + 6, 24) $$
כעת נסמן ב-\(d\) מחלק של 54 ו-24, כלומר \(54 | d\) ו-\(24 | d\). לכן גם מתקיים:
$$ 54 | d \Longrightarrow (24 \cdot 2 + 6) | d \Longrightarrow 6 | d $$
כלומר, \(d\) הוא מחלק של אותה השארית, ולכן גם מתקיים:
$$ gcd(54, 24) = gcd(24 \cdot 2 + 6) = gcd(24, 6) $$
הקטנו משמעותית את הבעיה המקורית! כעת מתקיים גם ש-\(24|6\), ולכן ה-GCD הוא 6.
הוכחת אלגוריתם אוקליד
הצהרת האלגוריתם
כעת נציין את המקרה הכללי של אלגוריתם אוקליד ונוכיח אותו:
אלגוריתם אוקליד: נתונים שני מספרים שלמים \(m > n > 0\).
אם מתקיים:
$$ m = qn + r, 0 \le r \le b, r = m \% n $$
אז:
$$ gcd(m, n) = gcd(b, r) $$
ראינו בדוגמא הקודמת - עלינו להחליף בכל שלב את הגדול מבין שני המחלקים בשארית החלוקה שלו בשני, עד להגעה לתשובה הסופית.
הוכחה
נשתמש באינדוקציה על מנת להראות את הטענה. נניח שמתקיים \(m = qn+r\) ונניח כי קיים מחלק \(d\) של \(m, n\):
$$ m | d, n | d $$
לכן גם מתקיים:
$$ r = m - qn \Longrightarrow r | d $$
כלומר, \(d\) הוא ה-GCD של \(r, n\).
נמשיך באינדוקציה עד שאחד מהמספרים הוא 0.
מימוש בקוד של האלגוריתם
{{# src/euclid.c}}
דוגמאות
כעת עומד לרשותנו חלק מאוד משמעותי מהסינטקס של השפה וכדאי להתחיל לתרגל את מה שלמדנו על מנת להתמקצע בו. הקורס נבנה בצורה היררכית - כל שלב שנלמד יתבסס על השלב הקודם ולכן כדאי לוודא שאתם מצליחים לפתור בהצלחה את התרגילים לפני שממשיכים להרצאה הבאה.
דוגמא 1
נניח שאנחנו רוצים להדפיס את כל המספרים בין 0 ל-100 חוץ ממה שמתחלק ב-3:
#define N 100
int main() {
for(int i = 0; i < N; i++) {
if(i % 3 == 0) {
continue;
}
printf("%d\n", i);
}
}
דוגמא 2
נמצא את המספר החיובי הראשון שקטן ממיליון וסכום ספרותיו הוא 34:
#include <stdio.h>
#define UPPER_BOUND 1000 * 1000
#define WANTED_SUM 34
#define NOT_FOUND -1
int main() {
int wanted_num = NOT_FOUND;
for (int i = 0; i < UPPER_BOUND; i++) {
int sum = 0;
int n = i;
while (n > 0) {
sum += n % 10;
if (sum > WANTED_SUM) {
break;
}
n /= 10;
}
if (sum == WANTED_SUM) {
wanted_num = i;
break;
}
}
if (wanted_num != NOT_FOUND) {
printf("wanted number = %d\n", wanted_num);
} else {
printf("No such number exists\n");
}
}
לאחר מציאת מספר המקיים את התנאי אין צורך להמשיך לחשב ולכן נבצע break.
דוגמא 3
בהנתן שני מספרים חיוביים, מצאו את המספר הקטן ביותר ביניהם שמכפלת ספרותיו גדולה ממיליון ושאין לו אף ספרה זוגית:
#define SUM_LIMIT 500
#include <stdbool.h>
#include <stdio.h>
#define PRODUCT_LIMIT 1000 * 1000
#define NOT_FOUND -1
int main() {
int lower_limit, upper_limit;
do {
scanf("%d %d", &lower_limit, &upper_limit);
} while (lower_limit < 0 || upper_limit < 0 || lower_limit >= upper_limit);
int result = NOT_FOUND;
for (int i = lower_limit; i < upper_limit; i++) {
int product = 1;
bool found = true;
for (int n = i; n > 0; n /= 10) {
int digit = n % 10;
if (digit % 2 == 0) {
found = false;
break;
}
product *= digit;
}
if (found && product > PRODUCT_LIMIT) {
result = product;
break;
}
}
if (result != NOT_FOUND) {
printf("Result = %d\n", result);
} else {
printf("No result between %d and %d\n", lower_limit, upper_limit);
}
}
הערה: פקודות break ו-continue מתייחסות ללולאה הפנימית ביותר בהן הן משומשות. לכן למשל בדוגמא השנייה היינו צריכים להשתמש פעמיים ב-break בשביל לצאת משתי לולאות בהתאמה.
דרך נפוצה להשתמש ב-break היא ביציאה מלולאות אינסופיות:
#include <stdio.h>
int main() {
int input;
while(1) {
printf("Enter a positive number\n");
scanf("%d", &number);
if(number <= 0) {
printf("%d is not positive\n", number);
} else {
break;
}
// Do stuff with positive number
}
}
דוגמאות
כעת עומד לרשותנו חלק מאוד משמעותי מהסינטקס של השפה וכדאי להתחיל לתרגל את מה שלמדנו על מנת להתמקצע בו. הקורס נבנה בצורה היררכית - כל שלב שנלמד יתבסס על השלב הקודם ולכן כדאי לוודא שאתם מצליחים לפתור בהצלחה את התרגילים לפני שממשיכים להרצאה הבאה.
- כתבו תוכנית המקבלת מהמשתמש רצף של תווי ASCII וסוכמת את כל המספרים מתוכם. אם התו הוא מספר בין 0-9, התוכנית מוסיפה אותו לסכום - אם הוא אינו מספר, התוכנית מסיימת ומדפיסה את התוצאה. אם המשתמש לא הכניס אף מספר נגדיר את הסכום להיות 0.
- כתבו תוכנית שמקבלת מספר שלם מהמשתמש ומדפיסה כמה ספרות יש בו.
- כתבו תוכנית שמקבלת מספר שלם מהמשתמש ומדפיסה את הספרה הכי גדולה בו.
- כתבו תוכנית המדפיסה את לוח הכפל מ-1 ועד 9.
#include <stdio.h>
int main() {
int sum = 0;
char input;
while(1) {
scanf("%d", &input);
if(input >= '0' && input <= '9') {
sum += input - '0';
} else {
break;
}
}
printf("sum = %d\n", sum);
}
#include <stdio.h>
int main() {
int num = -1;
scanf("%d", &num);
int digits = 1;
while(num > 0) {
num /= 10;
}
printf("%d digits\n", digits);
}
#include <stdio.h>
int main() {
for (int i = 1; i < 10; i++) {
for (int j = 1; j < 10; j++) {
printf("%d ", i * j);
}
printf("\n");
}
}
שאלות נוספות
-
מספר פלינדרום הוא מספר שנקרא אותו דבר כאשר הופכים אותו. למשל, המספר
12321הוא פלינדרום בעוד ש-12345אינו פלינדרום. כתבו תוכנית שמקבלת מספר מהמשתמש ומדפיסה האם הוא פלינדרום או לא. -
כתבו תוכנית שמקבלת מספר מהמשתמש ומדפיסה באופן סדרתי את סכום ספרותיו עד שנשארת רק ספרה אחת. לדוגמא, עבור המספר
923יודפס:923, 14, 5. -
כתבו תוכנית שמקבלת מספר מהמשתמש ומדפיסה את הפירוק שלו לגורמים ראשוניים. למשל, עבור המספר
360יודפס2 2 2 3 5, מכיוון שמתקיים \(360 = 2^3 \cdot 3^2 \cdot 5\). -
קראו מספר בינארי מהמשתמש שמכיל רק את הספרות
0ו-1והדפיסו את ההצגה העשרונית של אותו מספר. לדוגמא, עבור111יודפס7, ועבור1011יודפס11.
הרצאה 4 - פונקציות
בהרצאה זו נגדיר ונרכיב בצורה מסודרת אובייקט שכבר השתשמנו בו המון - פונקציות. פונקציות הן אבן בניין בסיסית בכל שפת תכנות שתלמדו, וסצפיפית ב-C ניתן להשתמש בהן על מנת להפוך את הקוד שלנו להרבה יותר יעיל, קריא ומודולרי.
פגשנו כמה פונקציות עד כה:
main- הפונקציה המיוחדת שהקומפיילר קובע בה את תחילת התוכנית. זו פונקציה שמימשנו בעצמנו ולא קראנו לה - מתברר שהקומפיילר הוא זה שקרה לה. כמו כן, אין לה ארגומנטים.printf&scanf- אלו שתי פונקציות שלא כתבנו בעצמנו, אלא הבאנו ממקום אחר ע״י השימוש במאקרוinclude <stdio.h>#. קראנו לפונקציות אלו באמצעות הסוגריים()והעברנו להן ארגומנטים על מנת שידעו מה להדפיס והיכן לשים קלט מהמשתמש.
כעת הגיע הזמן להגדיר, לממש ולהשתמש בפונקציות משלנו בעצמנו. בניגוד למה שנעשה בדרך כלל בקורס, ראשית נצלול טכנית לכיצד מנגנון הפונקציות עובד, ורק לאחר מכן נדון במוטיבציה ובבעיות שהוא פותר עבורנו. הסיבות לכך יתבררו מיד.
נמשיך בדיון קצר על אורך החיים של משתנים שונים, מתי ניתן להשתמש בהם וכיצד.
זמן חיים ו-scope של משתנים
עד כה כל הגדרנו את כל המשתנים שלנו בתוך פונקציית ה-main. יכולנו להשתמש במשתנה רק לאחר שהצהרנו עליו. עוד מעט כאשר נציג קונספטים חדשים נבחן מדוע ישנם משתנים שמתנהגים אחרת. הנושא הזה מעט טכני ויכול להיות מסובך להבנה - אחרי שנבחן כיצד תמונת הזכרון נראת בקריאה לפונקציות נבין מדוע הוא מהותי וחשוב. לטובת כך נכיר את המושגים הבא:
הגדרה: זמן החיים (lifetime) של משתנה הוא הזמן בו המשתנה קיים בזכרון. לדוגמא, נבחן את התוכנית הבאה:
int main() {
int x = 5;
int y = 7;
}
באופן כללי, משתנים חיים מהרגע שהוגדרו ועד סוף הבלוק בהם הוגדרו. בתוכנית הזו זמן החיים של x ארוך משל y - בשורה הראשונה ניתן להשתמש ב-x אך לא ניתן עדיין להשתמש ב-y. כאשר הגענו לסוף הפונקציה בסוגר התחתון {, שני המשתנים ״מתים״ ולא ניתן להשתמש בהם יותר.
נבחן דוגמא מעט מורכבת יותר:
int func() {
int x = 5;
printf("%d\n", x);
}
int main() {
int y = 7;
func1();
printf("%d\n", y);
}
זמן החיים של המשתנה x הוא מהשורה הראשונה של func ועד לסופה, בעוד ששל y הוא מהשורה הראשונה ב-main עד לסוף ה-main.
לנושא זמן החיים יש משמעות מהותית - למשל בתוכנית הבאה:
#define N 100
int main() {
int i = 0;
while(i < N) {
char c;
scanf("%c", &c);
i++;
}
printf("last character you entered is %c\n", c);
}
מתברר שתוכנית זו לא תתקמפל בכלל. מדוע? המשתנה c מוגדר בתוך בלוק ה-while ולכן מת בסופו. לכן אי אפשר להשתמש בו לאחר שהבלוק הסתיים. בצורה שונה, אם היינו מנסים להשתמש ב-i לא הייתה בעיה בכלל - i מוגדר בתוך בלוק פונקציית ה-main ולכן אין מניעה להשתמש בו לאחר סיום ה-while.
הערה: יתכן ובקריאה ראשונה לא ברור למה בכלל שהשפה תתנהג בצורה הזאת. מה זה משנה איפה הגדרתי את המשתנה? למה שלא אוכל להשתמש בו? מתברר שהתשובה לכך עמוקה וקשורה לדרך בה שהשפה מתרגמת תוכנה לשפת מכונה ולתמונת הזכרון הנוצרת מכך. קשה להסביר בשלב זה מדוע זה המצב, ולכן נעשה עבודה כעת לשלוט במונחים ובמנגנונים ובהמשך נבין מדוע זו הסיטואציה.
הגדרה: scope (טווח ראייה) של משתנה הוא האיזור בתוכנית בו משתנה ניתן לשימוש.
כמו שראינו, פונקציות יכולות להשתמש רק במשתנים שהעברנו להם. לכן אם נחזור למשל לדוגמא הקודמת:
int func() {
int x = 5;
printf("%d\n", x);
}
int main() {
int y = 7;
func1();
printf("%d\n", y);
}
ה-scope של y הוא פונקציית ה-main וה-scope של x הוא func. אם היינו כותבים שורה כמו x = 7 ב-main או y = 2 ב-funcz, התוכנית לא הייתה מתקמפלת. הסיבה לכך? ה-scope של המשתנים מוגבל לבלוק בו הם מוגדרים.
הגדרה: משתנים המוגדרים בתוך בלוק ומתים בסופו נקראים משתנים לוקאליים. זמן החיים וה-scope של משתנים לוקאליים זהה - מהרגע שהוגדרו ועד סוף הבלוק.
כעת נציג כמה אופציות שונות למיקום ההגדרה של משתנים ונבחן את המשמעות והשימוש של כל אחד.
משתנים גלובאליים
משתנה גלובאלי הוא משתנה שלא מוכרז בתוך אף בלוק {}, אלא כשורה בפני עצמה בקובץ - לרוב בראש הקובץ. לדוגמא:
#include <stdio.h>
char c = 'a';
int main() {
printf("global var = %c\n", c);
c = 'b';
printf("global var = %c\n", c);
}
זמן החיים של משתנים גלובאליים וה-scope שלהם זהה - מתחילת התוכנית ועד סופה.
ניתן לקרוא ולכתוב למשתנים גלובאליים מכל מקום בתוכנית. עוד מעט נראה שפונקציות יהוו בלוק קוד משל עצמן, ולכן משתנים גלובאליים יכולים להיות דרך מפתה להציג מידע שצריך לדעת בהרבה מקומות בתוכנית. זמן החיים של משתנה גלובאלי הוא זמן החיים של התוכנית עצמה. עם זאת, בהמשך נראה כי משתנים גלובאליים יכולים להוביל לבאגים משמעותיים ולכן רצוי לצמצם את השימוש בהם למינימום.
משתנים סטאטיים
משתנים סטאטיים הם מעין שילוב בין משתנים לוקאליים למשתנים גלובאליים. זמן החיים שלהם הוא כמו של גלובאלי - מתחילת התוכנית ועד סופה, אבל ה-scope שלהם מוגבל לבלוק בו הם מוגדרים - בדרך כלל פונקציה. הדרך להגדיר משתנה סטאטי הוא ע“י מילת המפתח static - למשל כמו בדוגמא הבא:
void count_times_called() {
static int times_called = 0;
printf("I was called %d times", times_called++);
}
int main() {
count_times_called();
count_times_called();
count_times_called();
}
משתנה סטאטי מאותחל פעם אחת בתחילת התוכנית, כלומר השורה הראשונה בפונקציה שהגדרנו מתבצעת פעם אחת בלבד.
הגדרות וסינטקס
מהי פונקציה?
פונקציה היא:
- בלוק קוד בעל שם
- יכולה (אך אינה חייבת) לקבל ארגומנטים מהקורא
- יכולה (אך אינה חייבת) להחזיר ערך מסוים
- מבודדת משאר התוכנית
על מנת להדגים את הקונספט, נכתוב תוכנית שמדפיסה את את ה-GCD של כל שני מספרים בין 0 ל-100:
#include <stdio.h>
#define UPPER_LIMIT 100
int main() {
for (int i = 0; i < UPPER_LIMIT; i++) {
for (int j = i + 1; j < UPPER_LIMIT; j++) {
while (i != 0) {
int tmp = i;
i = j % i;
j = tmp;
}
printf("gcd(%d, %d) = %d\n", j, i, j);
}
}
}
מיד ניתן לראות שיש בעיה מהותית - אנחנו משנים את i, j לאורך הריצה, למרות שהם משמשים אותנו לבצע את הספירה בין 0 ל-100! נתקן:
#include <stdio.h>
#define UPPER_LIMIT 100
int main() {
for (int i = 0; i < UPPER_LIMIT; i++) {
for (int j = i + 1; j < UPPER_LIMIT; j++) {
int m = j;
int n = i;
while (n != 0) {
int tmp = n;
n = m % n;
m = tmp;
}
printf("gcd(%d, %d) = %d\n", j, i, m);
}
}
}
תוכנית זו עובדת בסדר גמור ואין בה שום בעיה. עם זאת, יש בה זכר לבעיה הראשונה - אנחנו מערבבים בין שתי המשימות שהתוכנית שלנו מבצעת:
- ריצה על כל המספרים בין 0 ל-100
- חישוב ה-GCD של אותם המספרים
לערבוב הזה יש ביטוי מובהק - בשביל לפתור את בעיית ה-GCD אנחנו צריכים להעתיק את המשתנים i ו-j ל-m ו-n. אידיאלית, היינו רוצים לפתור בצורה מבודדת את הבעיות האלו, בלי שיפריעו אחת לשנייה. לטובת כך, ניתן לכתוב פונקציה נפרדת לבעיית ה-GCD:
#include <stdio.h>
#define UPPER_LIMIT 100
int gcd(int m, int n) {
while (n != 0) {
int tmp = n;
n = m % n;
m = tmp;
}
return m;
}
int main() {
for (int i = 0; i < UPPER_LIMIT; i++) {
for (int j = i + 1; j < UPPER_LIMIT; j++) {
int gcd_result = gcd(j, i);
printf("gcd(%d, %d) = %d\n", j, i, gcd_result);
}
}
}
כעת, גם לפני שהגדרנו את הסינטקס בצורה מדויקת, ניתן להבין מה עשינו! הפונקציה gcd מחשבת א-GCD של שני המספרים הנתונים לה, ומחזירה אותם. בודדנו את הבעיה של חישוב ה-GCD לפונקציה נפרדת.
כעת מעבר לאינטואיציה, נפרק כיצד נראת הצהרת פונקציה וכיצד היא מממשת את התכונות שדיברנו עליהם למעלה.
הגדרות
הסינטקס להגדרה של פונקציה הוא:
return_type function_name(type1 arg1, type2 arg2) {
// Function body
return some_value;
}
לפונקציה function_name יש ערך החזרה מטיפוס return_type, מקבלת שני ארגומנטים מטיפוסים type1 ו-type2 בהתאמה, מבצעת חישוב כלשהו ומחזירה את הערך some_value.
בדוגמא של הפונקציה gcd - שם הפונקציה הוא gcd, היא מחזירה משתנה מטיפוס int ומקבלת כארגומנטים שני משתנים מטיפוס int.
Note
אם פונקציה
fקוראת לפונקציהg, הפונקציהfנקראת ה-caller והפונקציהgנקראת הפונקציה ה-callee. במקרה שלנו,mainהיא ה-caller ו-gcdהיא ה-callee.
נקודות 1-3 מרשימת התכונות של פונקציות אכן מתבטאות כאן - בעל שם, יכולה לקבל ארגומנטים ויכולה להחזיר ערך. מה עם תכונה 4?
בידוד פונקציות
ניתן לחשוב כי לא פתרנו את הבעיה כלל בדוגמא הקודמת. העברנו את הארגומנטים j, i לפונקציה והיא תשנה אותם ובכך תשבור את הלולאות שבנינו! עם זאת, מסתבר שזהו אינו המקרה.
בשפת C, ארגומנטים של פונקציות מועתקים אל הפונקציה הנקראת. כלומר, m, n בפונקציה gcd הם עותקים של i, j מפונקציית ה-main - מכילים את אותו ערך, אבל במקום אחר בזכרון. כלומר, במידה ונשנה אותם, לא תהיה השפעה על המשתנים כפי שהם מופיעים בפונקצייה main. לכן ניתן לחשוב על פונקציה בתור מעין קופסא מבודדת - היא לא יכולה להשפיע על הסביבה מחוץ לה, פרק ללהחזיר ארגומנט כלשהו.
Note
בהמשך הפרק נרחיב משמעותית על נושא הזכרון וההעתקות של ארגומנטים לפונקציות.
מאפייני פונקציות
כעת נרחיב יותר על שימוש בפונקציות והמאפיינים שלהן.
חזרה מפונקציה
כמו פונקציה מתמטית, פונקציות יכולות (אך אינן חייבות) להחזיר ערך כלשהו שחישבו. פונקציה יכולה לחזור בכל נקודה לאורך ביצועה. לטובת זאת משתמשים במילת המפתח return ואחריה המשתנה אותו נרצה להחזיר. למשל:
#include <stdbool.h>
#include <stdio.h>
bool is_even(int n) { return n % 2 == 0; }
int main() {
int n;
scanf("%d", &n);
if (is_even(n)) {
printf("%d is even!", n);
}
}
אין מניעה שההערך שהפונקציה לא יהיה משתנה אלא ביטוי כלשהו לחישוב. במקרה זה הפונקציה הנקראת is_even החזירה ערך והפונקציה הקוראת main קראה אותו ועשתה בו שימוש.
Early Return
חשוב לציין כי הפקודה return עוצרת מיידית את ביצוע הפונקציה ומחזירה את הערך הנדרש, גם אם יש המשך לפונקציה. למשל - נניח שנרצה פונקציה שתסווג מספר לפי כמה קטגוריות מעניינות:
#define IS_NEGATIVE -1
#define IS_ZERO 0
#define BETWEEN_0_AND_100 1
#define LARGER_THAN_100 2
int classify_number(int n) {
if (n < 0) {
return IS_NEGATIVE;
} else if (n == 0) {
return IS_ZERO;
} else if (n <= 100) {
return BETWEEN_0_AND_100;
} else {
return LARGER_THAN_100;
}
}
זוהי דוגמא מעניינת מכיוון שהיה אפשר לכתוב אותה בכמה דרכים שונות. נשים לב שבכל if מתבצע return, ולכן אפשר לפשט:
#define IS_NEGATIVE -1
#define IS_ZERO 0
#define BETWEEN_0_AND_100 1
#define LARGER_THAN_100 2
int classify_number(int n) {
if (n < 0) {
return IS_NEGATIVE;
}
if (n == 0) {
return IS_ZERO;
}
if (n <= 100) {
return BETWEEN_0_AND_100;
}
return LARGER_THAN_100;
}
נבחן את הביצוע שלה:
- בהתחלה אנחנו בודקים אם המספר שלילי ואם כן חוזרים.
- ב-
ifהשני אנחנו בודקים האם המספר שווה ל-0 ואם כן חוזרים. נשים לב שאין צורך ל-else if- אם הגענו ל-ifהשני, מובטח שלא נכנסו ב-ifהראשון, אחרת היינו מבצעיםreturn. - ב-
ifהשלישי אנחנו בודקים האםn <= 100. נשים לב שאין צורך לכתוב ביטוי מורכב מהצורהn > 0 && n <= 100- אם הגענו ל-ifהשלישי, מובטח שלא נכנסנו ל-ifהראשון והשני, כלומרnגם לא שלילי וגם לא 0. - לבסוף אנחנו מחזירים
LARGER_THAN_100- משיקולים דומים, אין צורך לבדוק האםn > 100- אם הגענו לשורה האחרונה, מובטח כי לא נכנסו לאףifקודם.
צורת כתיבה זו נקראת early return והיא יכולה להיות מאוד אלגנטית ונוחה ביחס לאלטרנטיבות - למשל, ניתן לדמיין סיטואציה בה הפונקציה לעיל נכתבה כך:
int classify_number(int n) {
if(n >= 0) {
if(n == 0) {
return IS_ZERO;
} else {
if(n <= 100) {
return BETWEEN_0_AND_100;
} else {
return LARGER_THAN_100;
}
}
} else {
return IS_NEGATIVE;
}
}
כתיבת בלוקים בתוך בלוקים נקראת קינון (nesting) - באופן כללי כדאי להגביל את הקינון ככל שאפשר על מנת “להחזיק בראש” פחות תנאים ובלוקים פתוחים. ההבדל בין כתיבה עם early return לעומת כתיבה עם nesting היא בהיפוך התנאים - שימו לב לכך!
הטיפוס void
לעיתים נכתוב פונקציות שאין צורך שיחזירו ערך. למשל:
#include <stdio.h>
#define ADULT_AGE 18
#define MAX_AGE 120
void print_age(int age) {
if (age < 0 || age > MAX_AGE) {
printf("Invalid age: %d\n", age);
return;
}
if (age < ADULT_AGE) {
printf("Child: %d\n", age);
return;
}
if (age < MAX_AGE) {
printf("Adult: %d\n", age);
return;
}
}
int main(void) {
for (int i = 0; i < 120; i++) {
print_age(i);
}
}
הפונקציה print_age לא מחשבת שום דבר ולכן אין צורך שתחזיר ערך. לטובת כך משתמשים בטיפוס void, שמשמש עבור פונקציה ללא ערך החזרה.
בצורה דומה, אין חובה לעשות זאת, אבל זהו best practice לסמן פונקציות שאינן מקבלות ערך בתוך פונקציות שמקבלות void - כך עשינו עבור פונקציית ה-main ונעשה כך מעתה.
חתימה והצהרה על פונקציה
הקומפיילר מתייחס לפונקציה בדיוק כמו שהוא מתייחס לכל משתנה. נבחן את הדוגמא הבאה:
int main() {
int x = y;
int y = 7;
}
תוכנית זו לא תתקמפל, וקל לנו להבין למה - אנחנו משתמשים ב-y לפני שהגדרנו אותו. למעשה, הקומפיילר בשפת C מבצע סריקה של הקוד שלנו מלמעלה למטה באופן סדרתי, ולכן אין זה מפתיע שהוא מסרב לקמפל את התוכנית כמו שהיא - מבחינתו, כתבנו ג׳יבריש מוחלט.
בצורה דומה נבחן דוגמא נוספת:
void f1(void) {
printf("Hi from f1\n");
f2();
}
void f2(void) {
printf("Hi from f2\n");
}
int main() {
f1();
}
אנחנו קוראים ל-f2 מ-f1. עם זאת, כשהקומפיילר קורא את הקוד שלנו ב-f1, הוא עוד לא פגש את f2 - לכן קטע קוד זה לא יתקמפל, בצורה זהה לחלוטין לקטע הקודם. ניתן לפתור בעיה זו באחת משתי דרכים:
- החלפה בין הסדר של
f1ו-f2. כאשר התוכנית קטנה וסדר הקריאות לא מורכב, זה לחלוטין פתרון תקין. - כתיבת הצהרות על פונקציות בנוסף לכתיבת הפונקציות עצמן. בצורה דומה למשתנים, ניתן להצהיר על פונקציות - אך בשונה ממשתנים, אין מניעה להצהיר על פונקציה ורק לאחר מכן לממש אותה. הדרך לעשות זאת היא כך:
void f1(void);
void f2(void);
void f1(void) {
printf("Hi from f1\n");
f2();
}
void f2(void) {
printf("Hi from f2\n");
}
int main() {
f1();
}
Note
לביטוי מהצורה
;int my_function(int x, int y)אנחנו קוראים חתימה של פונקציה. לפעמים מוותרים גם על שמות המשתנים וכותבים;int my_function(int, int).
כיצד משתמשים בצורה נכונה בפונקציות?
כעת יש לנו את הכלים להבין מהן הסיבות לשימוש בפונקציות ומה היתרונות שלהן מעל תקיעת כל הקוד שלנו בבלוק ארוך אחד:
-
בידוד: פונקציה חיה בעולם משלה ובאופן כללי אינה מושפעת או משפיעה ממה שקורה מחוץ לה. ישנם יוצאי דופן למקרה זה, כפי שנראה בהמשך הקורס, אבל הבידוד מאפשר לנו לתת לפונקציה לפתור בעיה קטנה אחת בצורה טובה, ללא השפעה חיצונית.
-
פשטות: כשפונקציה מנסה לפתור בעיה אחת בבידוד מכל שאר התוכנית, הרבה יותר קל להבין מה צריך לעשות מאשר בקשר לכל דבר אחר שקורה בתוכנית.
-
פישוט ממשק: פונקציה יכולה להיות מסובכת ככל שנרצה, אבל השימוש בה תמיד נראה אותו דבר - קריאה לה עם הארגומנטים הנכונים. לדוגמא, אולי יפתיע אותכם לדעת ש-
printfהיא פונקציה מורכבת שעושה פעולות מסובכות רבות מאחורי הקלעים על מנת להדפיס למסך את הטקסט המפורמט שלנו. עם זאת, אין לנו שום צורך לדעת או להבין זאת - רק לקרוא לה לפי הממשק שהיא מגדירה. -
פירוק: הרבה מהמשימות שנפתור בקורס בצורה תכנותית יהיו מורכבות מאוד. פירוק משימה מורכבת להרבה תת משימות קטנות, שכל אחת מהן מיוצגת ע״י פונקציה, מאפשר לנו להתמקד כל פעם בשלב אחר בבעיה, ולהרכיב מאוחר יותר את התמונה הכללית.
-
מניעת חזרה: אם קטע קוד חוזר על עצמו, הוצאה שלו לפונקציה וקריאה שלו מונעת טעויות והופכת את הקוד לקריא וברור הרבה יותר.
-
קריאות: גם אם קטע קוד אינו חוזר על עצמו (ובמיוחד אם כן) הרבה יותר קל להבין מה השורה
if (is_even(x)) {...}עושה, על פני השורהif (x % 2 == 0). מתן שם לפעולות מאפשר לנו לכתוב קוד שמתנהג ונראה הרבה יותר כמו שפה טבעית מאשר פעולות מכונה שאינן ברורות לנו.
מהי פונקציה טובה?
קיימים אינספור חוקים לכתיבת פונקציות מוצלחות - נציין כמה מהם:
- פונקציה טובה עושה משימה אחת בלבד.
- שם של פונקציה טובה אומר בדיוק מה היא עושה. למשל:
- פונקציה שמחזירה
boolיכולה להיות מנוסחת כשאלה, כמוbool is_even(int x). - פונקציה שמחשבת ערך ומחזירה אותו יכולה להיות מנוסחת בתור פעולת הבאה, כמו
float get_discr(float a, float b, float c). - פונקציה שמבצעת פעולה יכולה להיות מנוסחת כפועל, כמו
void print_age(int age).
- פונקציה שמחזירה
- פונקציה טובה היא קצרה - כאשר פונקציה נעשת ארוכה, ככל הנראה קיימת פונקציה נוספת המסתתרת בתוכה.
- פונקציה טובה היא פונקציה עם מספר ארגומנטים קטן ככל הניתן.
Note
לפעמים קשה להגדיר מהי ״משימה אחת״ בהקשר של עקרון #1. אפשר לנסח אותו מעט אחרת - פונקציה נשארת באותה רמה של פעולה תמיד, ובמידה ולא יש לייצר פונקציה נוספת. נראה לכך דוגמא בהמשך.
דוגמאות
Clamp
נתחיל מפונקציה בשם clamp שמקבלת 3 מספרים - גבול תחתון lower, גבול עליון higher ומספר x.
אם x קטן מ-lower, נחזיר את lower. אם הוא גדול מ-higher, נחזיר את higher - אחרת, נחזיר את x עצמו.
מימוש אפשרי יהיה:
int clamp(int lower, int higher, int x) {
if (x <= lower) {
return lower;
}
if (x >= higher) {
return higher;
}
return x;
}
כעת נניח שמבקשים מאיתנו לכתוב פונקציה שמחזירה את המקסימום מבין ה-clamp של שני ערכים. מתמטית, אנחנו מחפשים את הערך הבא:
$$ \max\big(\text{clamp}(a), \text{clamp}(b)\big) $$
ואז המרת הפתרון לקוד נהית יחסית פשוטה - נכתוב פונקציה שמבצעת max ואז נבצע הרכבה לפונקציות:
int clamp(int lower, int higher, int x) {
if(x <= lower) {
return lower;
} else if(x >= higher) {
return higher;
}
return x;
}
int max2(int x, int y) {
return a > b ? a : b;
}
int max_of_clamp(int lower, int higher, int x, int y) {
return max2(clamp(lower, higher, x), clamp(lower, higher, y));
}
זו דוגמא קלאסית לפירוק בעיה מורכבת למספר שלבים ע“י כתיבת פונקציה לכל שלב.
משוואה ריבועית
נמשיך בדוגמא לפונקציה שפותרת משוואה ריבועית - חתימה של פונקציה כזו יכולה להיות כזו:
// Pass arguments to equation in the format ax^2 + bx + c = 0
float solve_quadratic(float a, float b, float c);
אנחנו מכירים את הפתרון למשוואה ריבועית:
$$ x_{1,2} = \frac{-b \pm \sqrt{b^2 - 4ac} }{2a} $$
כפי שאולי אתם מכירים (ובמידה ולא - זוהי עובדה שקשורה לדוגמא בלבד, אין צורך לדעת אותה בכללי) הביטוי שבתוך הסוגריים נקרא הדיסקרימיננטה ומסומן באות \(\Delta\). כאשר \(0\le\Delta\) קיים פתרון ממשי למשוואה הריבועית, ולכן נרצה לבדוק אותו בנפרד מחישוב שאר הפתרון.
כלומר, ניתן לכתוב:
$$ x_{1,2} = \frac{-b \pm \sqrt{\Delta} }{2a}, \Delta = b^2 -4ac $$
לכן, חלוקה של הפתרון לשלבים יכולה להיות כזו:
- חשב את הדיסקרימיננטה של הארגומנטים.
- אם היא שלילית - הדפס הודעת שגיאה וסיים את הפונקציה אם שגיאה.
- אחרת - חשב את הפתרון בשימוש בדיסקרימיננטה (אין צורך לחשב אותה פעם נוספת).
לצורך הדוגמא נחזיר רק את הפתרון שמתקבל ע“י ה-+ בנוסחא הריבועית. מימוש אפשרי יכול להיות כזה:
#include <math.h>
#include <stdio.h>
#define NO_REAL_SOLS 1
// discr - short for discriminant
float calculate_discr(float a, float b, float c) {
return pow(b, 2) - 4 * a * c;
}
float calculate_solution(float a, float b, float discr) {
return (-b + sqrt(discr)) / (2 * a);
}
float solve_quadratic(float a, float b, float c) {
float discr = calculate_discr(a, b, c);
if (discr < 0) {
printf("No real solutions!\n");
return NO_REAL_SOLS;
}
return calculate_solution(a, b, discr);
}
נשים לב להפרדה ברורה - הפונקציה solve_quadratic כתובה, כמעט באנגלית ברורה, כשרשור של פונקציות. פונקציות העזר calculate_solution ו-calculate_discr מבצעות כל אחת משימה אחת - מה ששם הפונקציה אומר.
Note
השתמשנו בפונקציה
sqrtהמוגדרת בקובץmath.hלצורך הדוגמא, בצורה דוגמא להבאת הפונקציהprintfמהקובץstdio.h.
המרת זמנים
כדוגמא נוספת, נכתוב פונקציה שמקבלת אורך זמן כלשהו בשניות seconds ותפקידה יהיה להדפיס המרה של אורך הזמן הזה לשעות, דקות ושניות:
#include <stdio.h>
#define SECONDS_IN_MINUTE 60
#define MINUTES_IN_HOUR 60
#define SECONDS_IN_HOUR (SECONDS_IN_MINUTE) * (MINUTES_IN_HOUR)
int extract_hours(int seconds) { return seconds / SECONDS_IN_HOUR; }
int extract_minutes(int seconds) { return seconds / SECONDS_IN_MINUTE; }
void convert_seconds_to_time(int seconds) {
int hours = extract_hours(seconds);
seconds %= SECONDS_IN_HOUR;
int minutes = extract_minutes(seconds);
seconds %= SECONDS_IN_MINUTE;
printf("%d : %d : %d\n", hours, minutes, seconds);
}
תרגילים
תרגיל
מספר חיובי כלשהו יקרא סודי באופן מלא אם כל התנאים הבאים מתקיימים עבורו:
- הוא מתחלק בסכום כל ספרותיו.
- מספר המחלקים שלו זוגי.
- הספרה הראשונה שלו קטנה מהספרה האחרונה שלו.
- הסכום של הספרות הזוגיות שלו גדול מהסכום של הספרות האי-זוגיות שלו.
מספר יקרא \(x/4\) סודי אם מתקיימים עבורו \(x\) מבין ארבעת התנאים הללו.
כתבו תוכנית שמדפיסה את כל המספרים בין 0 למיליון ואת רמת הסודיות שלהם. כתבו פונקציה נפרדת לכל אחד מהתנאים.
תרגיל
בהנתן מספר חיובי כלשהו \(n > 0\), נגדיר את סדרת קולץ (Collatz Sequence) שלו כסדרה רקורסיבית המוגדרת ע״י:
$$ n_{i + 1} = \frac{n_i}{2} \text{ if } n_i \text{ is even} $$
$$ n_{i + 1} = 3 \cdot n + 1 \text{ if } n_i \text{ is odd} $$
הסדרה נעצרת כאשר \(n_i = 1\).
כתבו תוכנית שמוצאת עבור כל מספר בין 0 ל-1000 את אורך סדרת קולץ שלו ואת המספר המקסימלי בסדרת קולץ שלו.
תרגיל
כתבו תוכנית לסימולציה של קרב יריות בין שני קאובויים. לכל קאובוי יש אקדח עם מקום ל-3 כדורים שמתחיל לא טעון. בכל שלב קראו מספר מכל קאובוי, שמשמעותו בחירת פעולה:
-
0 - טעינה:
- אם לקאובוי יש פחות מ-3 כדורים באקדח, היא מעלה את מספר הכדורים ב-1.
- אם לקאובוי יש בדיוק 3 כדורים, היא לא עושה כלום.
-
1 - התקפה:
- אם לקאובוי אין כדורים באקדח, היא לא עושה כלום.
- אם לקאובוי יש לפחות כדור אחד, היא תוקפת ומורידה את כמות הכדורים ב-1.
- אם תקפנו בזמן שהקאובוי השני לא הגן, הפעולה מורידה חיים מהקאובוי השני.
-
2 - הגנה:
- אם הקאובוי השני לא תקף באותו תור, הפעולה לא עושה כלום.
- אם הקאובוי השני תקף באותו התור, הפעולה מגנה מההתקפה שלו.
לכל קאובוי יש 3 חיים. כתבו תוכנית שמסמלצת את הקרב ביניהם ומדפיסה מי ניצח.
מחסנית הקריאות ותמונת הזכרון
כעת ניגש לאחד הנושאים החשובים והמורכבים בקורס - כיצד הקומפיילר משתמש בזכרון בשביל ליצר את התוכניות שכתבנו עד כה. נושא זה הוא קריטי ביותר להבנה עמוקה של הנושאים הבאים, ולכן רצוי לוודא שהוא מובן לפני שממשיכים הלאה.
לפני שניגע בנושא הזכרון, נגדיר מושג שישמש אותנו בחלק זה של הקורס:
תרגילים
- כתבו פונקציה שמוצאת את הפתרון למשוואה לינארית מהצורה $0=b+ax$. נסו להתחשב בכל מקרה אפשרי של ארגומנטים.
- כתבו פונקציה שמקבלת מספר כלשהו ומדפיסה את לוח הכפל עד למספר זה.
- כתבו פונקציה שמקבלת מספר ומחזירה האם המספר ראשוני או לא.
- פלינדרום הוא מספר שקריאת ספרותיו מימין לשמאל שווה לקריאת ספרותיו משמאל לימין. כתבו פונקציה המקבלת מספר וקובעת האם הוא פלינדרום.
מחסנית
ראשית נגדיר מושג שישמש אותנו לבניית תמונת הזכרון:
מחסנית (stack) היא דרך לסדר רשימת איברים מסוג כלשהו בפורמט LIFO, או last in first out.
מה הכוונה?
-
האיברים יכולים להיות כל דבר - מספרים, תווים, דמויות וכו׳. בקורס נדבר בעיקר על מחסניות של זכרון.
-
כאשר איבר נכנס למחסנית, הוא נכנס מעל כל שאר האיברים שכבר במחסנית.
-
כאשר מוציאים איבר מהמחסנית, האיבר האחרון שנכנס הוא זה שלמעלה, ולכן הוא זה שיצא - מכאן מגיע המונח LIFO.
-
דוגמאות אמיתיות למחסניות:
- מחסנית רובה - כאשר דוחפים כדורים למחסנית, כל כדור חודש עולה מעל כל האיברים שכבר נמצאים בה. כשמוציאים כדור, הראשון שיוצא הוא האחרון שנכנס.
- ערימת צלחות - כאשר עורמים צלחות, כל צלחת שנכנסת לערימה עולה מעל כל שאר הצלחות שכבר בערימה. כאשר שולפים צלחת ניתן לשלוף מהחלק העליון ביותר.
נגדיר שתי פעולות שאפשר לבצע על מחסנית:
- push - דחיפת איבר לתוך המחסנית, מעל כל שאר האיברים.
- pop - הוצאת האיבר העליון של המחסנית. אפשר לבצע pop רק אם המחסנית אינה ריקה.
לדוגמא:
נניח שדחפנו את המספרים 1, 2 ו-3 לפי הסדר למחסנית. אם נכניס את 4, הוא יעלה מעל 3 - אם נוציא איבר, נוכל לשלוף את 3 בלבד ורק לאחר מכן את 2.
Note
מחסנית היא דוגמא לקונספט הנקרא מבנה נתונים, אובייקט חשוב מאוד אותו מנתחים בהרחבה בקורסי המשך.
מחסנית הקריאות
קריאות LIFO
הצגנו את מושג המחסנית מכיוון שמסתבר שכך נראת תמונת הזכרון בקריאה לפונקציות, ולכן המחסנית היא דרך טבעית להחזיק את המידע בזכרון. מה הכוונה? נבחן את התוכנית הבאה:
#include <stdio.h>
void f2(int x) {
printf("f2 started: arg = %d\n", x);
printf("f2 finished: arg = %d\n", x);
}
void f1(char c) {
printf("f1 started: arg = %c\n", c);
f2(42);
printf("f1 finished: arg = %c\n", c);
}
int main() {
printf("main started\n");
f1('a');
printf("main finished\n");
}
ננתח את סדר הקריאות בתוכנית:
- לאחר הקריאה לפונקציית ה-
main, אנחנו קופצים ל-f1, כאשרmainעוד לא סיימה. f1קוראת מיד ל-f2, כאשרmainו-f1עוד לא הסתיימו.f2מתחילה ביצוע, קוראת ל-printfואז מסיימת בעצמה -mainו-f1עוד לא הסתיימו.- לאחר החזרה מ-
f2, הפונקציהf1קוראת ל-printfואז מסיימת בעצמה -mainעדיין לא הסתיימה. - לאחר החזרה מ-
f1פונקציית ה-mainקוראת ל-printfואז מסתיימת בעצמה.
כלומר, סדר הקריאה הוא main->f1->f2, וסדר הסיום הוא f2->f1->main - בדיוק הפוך, או LIFO!
מחסנית הקריאות
למעשה, מתברר כי התוכנית שלנו מחזיקה מחסנית הנקראת מחסנית הקריאות על מנת לייצר את מנגנון הפונקציות. הקומפיילר משתמש במחסנית הקריאות בשביל לשמור את כל המידע הנדרש לביצוע הפונקציה - כשהכי רלוונטי לצרכינו הוא הארגומנטים והמשתנים הלוקאליים של הפונקציה הנקראת. מחסנית הקריאות היא חלק מהזכרון הכולל של התוכנית ולרוב נקראת פשוט ״המחסנית״ או ה-stack.
כיצד זה עובד?
-
כאשר פונקציה נקראת, התוכנית דוחפת (push) על מחסנית הקריאות שבזכרון אובייקט עם כל המידע הרלוונטי לביצוע הפונקציה - ארגומנטים, משתנים לוקאליים וכו׳.
-
אובייקט זה נקרא stack frame.
-
הפונקציה הנקראת משתמשת בזכרון שהוקצה לה על מחסנית הקריאות - קוראת ארגומנטים, משתמשת במשתנים לוקאליים וכו׳.
-
בסיום הפונקציה הנקראת, התוכנית מוציאה (pop) ממחסנית הקריאות את ה-stack frame האחרון, בו השתמשה בפונקציה הנקראת. פעולה זו נהוג לקרוא קיפול (fold) של המחסנית. במידה והיה ערך חזרה מהפונקציה, גם הוא נקרא מתוך מחסנית הקריאות לפני שהיא מקופלת.
-
הפונקציה הקוראת ממשיכה לבצע מאחרי הנקודה בה התבצעה הקריאה.
Note
חלק מעניין נוסף ב-stack frame שלא נתעסק בו בקורס הוא כתובת החזרה. במהלך קריאה לפונקציה, התוכנית שומרת את המקום ממנו קפצה לפונקציה על מנת שתדע לאן לחזור בסיום הפונקציה הנקראת.
Note
בהמשך נפגוש איזור נוסף בזכרון שאינו על המחסנית בשם הערימה (heap).
דוגמא
נצייר את מחסנית הקריאות של התוכנית הבאה, המחשבת ומדפיסה שטח של אליפסה:
#include <stdio.h>
#define PI 3.14
double calculate_ellipse_area(double r1, double r2) {
return PI * r1 * r2;
}
void show_ellipse_area(double r1, double r2) {
printf("area: %.2lf", calculate_ellipse_area(r1, r2));
}
int main() {
double r1, r2;
scanf("%lf%lf", &r1, &r2);
show_area(r);
}
בתחילת התוכנית התוכנית מחסנית הקריאות נראת כך:
כלומר, המחסנית מכילה רק את המידע הנדרש עבור main. לאחר הקריאה ל-show_ellipse_area נקבל את התמונה הבאה:
דחפנו על המחסנית עותקים של המשתנים r1 ו-r2. נדגיש - המשתנים r1, r2 בפונקציה main אינם באותו מקום בזכרון כמו r1, r2 של show_ellipse_area, או של calculate_ellipse_area. על אף שיש להם את אותו שם ואתו ערך, הם לא אותו משתנה וכתיבה לאחד מהם לא תשנה את האחר.
בצורה דומה לחלוטין דחפנו עותקים נוספים על המשתנים.
Note
לא ציירנו על המחסנית את הקריאה ל-
printfאבל היא גם נמצאת על מחסנית הקריאות.
תרגיל מסכם - משחק קוביות
תרגיל זה יסכם את החלק הראשון של הקורס - מההתחלה עד לסוף הפרק על מערכים. התרגיל יהיה ברמה של תרגילי בית בקורס ולכן הצלחה בו תסייע מאוד להצלחה במהלך הסמסטר.
יש כמה דרכים מומלצות לגשת לתרגיל בהתאם לכמות הזמן הפנוי שיש לכם:
- רמה 1 - הסטודנט הממוצע מחוסר הזמן. זה אתם במידה ויש לכם שעה לכל היותר להקדיש לנושא, והסמסטר כבר קשה לכם מאוד מבחינת עומס. במקרה זה המשיכו כרגיל עם ההרצאה, בה נפתור בזמן אמת ביחד את התרגיל.
- רמה 2 - יש לכם שעתיים-שלוש להשקיע בתרגיל. במקרה זה התחילו בקריאה של דרישות התרגיל וכתבו סקיצה כללית שמתקמפלת ורצה לפני שאתם ממשיכים בהרצאה. בהמשך ההרצאה בוודאי תראו פתרונות ושיטות כלליות להתמודד עם כל מיני בעיות שפגשתם במהלך המימוש, אבל 80% מהלמידה תהיה שלכם.
- רמה 3 - יש לכם זמן להשקיע בתרגיל. במקרה זה, פתרו אותו כמו תרגיל בית רגיל לחלוטין - אתם עם עצמכם מול המחשב. ודאו שהתרגיל עובד באופן מושלם לפי דרישות התרגיל לפני שתמשיכו בהרצאה - ודאי תמצאו דרכים פשוטות ואלגנטיות יותר לבצע כל מיני משימות שניסיתם לבצע.
בחרו ברמת ההשקעה המתאימה לכם, ונתחיל.
המטלה
המשחק pig dice הוא משחק קוביות קלאסי ופשוט. החוקים שלו עובדים כך:
- כל השחקנים מתחילים בניקוד 0.
- כל שחקן מטיל זוג קוביות כמה פעמים שהוא בוחר, וסוכם את סכום הקוביות בכל הטלה.
- אם אחד הקוביות היא 1, התור של השחקן נגמר מיד והוא לא מקבל נקודות.
- אם שתי הקוביות מראות 1, התור של השחקן נגמר מיד והניקוד שלו מתאפס.
- אם שתי הקוביות מראות 6, הניקוד של השחקן מוכפל ותורו נגמר מיד.
- אם השחקן בוחר להפסיק להטיל, סכום הקוביות שהטיל עד כה מוסף לנקודות שלו.
- השחקן הראשון שמגיע ל-100 נקודות מנצח.
לדוגמא, נניח שלשחקן א׳ יש 35 נקודות, לשחקן ב׳ יש 20 נקודות ולשחקן ג׳ יש 25 נקודות. שחקן א׳ מטיל קוביות עם התוצאות הבאות:
(2, 6)(3, 4)(6, 6)
תור שחקן א׳ נגמר מיד והניקוד שלו הוא עכשיו 70. שחקן ב׳ מטיל:
(3, 4)(5, 6)
ובוחר להפסיק להטיל. סכום הנקודות שלו הוא 18 ולכן הניקוד שלו כעת הוא 38. שחקן ג׳ מטיל:
(1, 1)
ניקודו מתאפס מיד והתור שלו נגמר.
פתרון
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define WIN_SCORE 100
#define ROLL_ACTION 0
#define BANK_ACTION 1
#define DIE_SIDES 6
#define PENALTY_DICE 1
#define MULT_DICE 6
#define ROLLED_SUPER_PENALTY -1
#define ROLLED_PENALTY -2
#define ROLLED_MULT -3
#define COMPUTER_ROLLS 3
bool rolled_super_penalty(int dice1, int dice2) {
return dice1 == PENALTY_DICE && dice2 == PENALTY_DICE;
}
bool rolled_penalty(int dice1, int dice2) {
return (dice1 == PENALTY_DICE && dice2 != PENALTY_DICE) ||
(dice1 != PENALTY_DICE && dice2 == PENALTY_DICE);
}
bool rolled_mult(int dice1, int dice2) {
return dice1 == MULT_DICE || dice2 == MULT_DICE;
}
int get_dice(void) { return (rand() % DIE_SIDES) + 1; }
int get_roll_result(void) {
int dice1 = get_dice();
int dice2 = get_dice();
printf("Dices show (%d, %d)\n", dice1, dice2);
if (rolled_super_penalty(dice1, dice2)) {
return ROLLED_SUPER_PENALTY;
}
if (rolled_penalty(dice1, dice2)) {
return ROLLED_PENALTY;
}
if (rolled_mult(dice1, dice2)) {
return ROLLED_MULT;
}
return dice1 + dice2;
}
int get_player_action(void) {
int selection;
do {
printf("Press %d to roll, %d to bank\n", ROLL_ACTION, BANK_ACTION);
scanf("%d", &selection);
} while (selection != ROLL_ACTION || selection != BANK_ACTION);
return selection;
}
int take_player_turn(int player_score) {
printf("Player turn!\n");
int banked_score = 0;
while (1) {
switch (get_player_action()) {
case ROLL_ACTION:
int roll = get_roll_result();
switch (roll) {
case ROLLED_SUPER_PENALTY:
printf("Rolled super penalty!\n");
return 0;
case ROLLED_PENALTY:
printf("Rolled penalty!\n");
return player_score;
case ROLLED_MULT:
printf("Rolled multiplier!\n");
return player_score * 2;
default:
banked_score += roll;
printf("Player banked %d points, total bank: %d\n",
roll,
banked_score);
continue;
}
case BANK_ACTION:
return player_score + banked_score;
default:
printf("Unreachable");
break;
}
}
}
int take_computer_turn(int computer_score) {
printf("Computer turn!\n");
int banked_score = 0;
for (int i = 0; i < COMPUTER_ROLLS; i++) {
int roll = get_roll_result();
switch (roll) {
case ROLLED_SUPER_PENALTY:
printf("Rolled super penalty!\n");
return 0;
case ROLLED_PENALTY:
printf("Rolled penalty!\n");
return computer_score;
case ROLLED_MULT:
printf("Rolled multiplier!\n");
return computer_score * 2;
default:
banked_score += roll;
printf("Computer banked %d points, total bank: %d\n",
roll,
banked_score);
}
}
printf("Computer banks %d points\n", banked_score);
return computer_score + banked_score;
}
void print_scores(int player_score, int computer_score) {
printf("Player score: %d\n", player_score);
printf("Computer score: %d\n", computer_score);
}
int main(void) {
srand(time(NULL));
int player_score = 0, computer_score = 0;
while (1) {
print_scores(player_score, computer_score);
player_score = take_player_turn(player_score);
printf("Finishing player turn! Player score is %d\n", player_score);
if (player_score > WIN_SCORE) {
printf("Player won!\n");
break;
}
computer_score = take_computer_turn(computer_score);
printf("Finishing computer turn! Computer score is %d\n",
computer_score);
if (computer_score > WIN_SCORE) {
printf("Computer won!\n");
break;
}
}
}
מצביעים (pointers)
עד כה התעסקנו במשתנים מסוגים שונים. כמה דוגמאות לתזכורת:
int x = 5- מספר שלם.float pi = 3.14- מספר עשרוני.double e = 2.17- מספר עשרוני ברמת דיוק גבוהה.'char c = 'g- תו בודד.
כעת נתחיל לדבר על מצביעים - משתנים חדשים שיסייעו לנו מאוד בפתרון בעיות מורכבות יותר, ולמעשה ישלימו לנו כמעט לגמרי את כל הכלים שאנחנו צריכים בשביל לבנות כל תוכנית שנרצה.
מצביעים ידועים לשמצה בתור נושא קשה מאוד להבנה, ובמידה מסוימת של צדק - הם קונספטואלית שונים ממה שפגשנו עד כה. עם זאת, העקרון העומד מאחוריהם מאוד פשוט. אנו השקענו מאמץ גדול בלמידה של תמונת הזכרון, וכעת נראה שזה ישתלם עבורינו מאוד.
Pass By Value
תמונת הזכרון
בשביל להבין מה הבעיה שאנחנו מנסים לפתור בכלל, נסתכל על הדוגמא הבאה:
#include <stdio.h>
void swap(int a, int b) {
int tmp = a;
a = b;
b = tmp;
}
int main() {
int a = 1;
int b = 5;
printf("before call to swap: a = %d, b = %d", a, b);
swap(a, b);
printf("after call to swap: a = %d, b = %d", a, b);
}
לכאורה מדובר על פונקציה פשוטה - מקבלת שני int-ים ומחליפה ביניהם. עם זאת, כשנריץ את התוכנית, נקבל פעמיים את אותה ההדפסה. קחו דקה או שתיים לנסות לחשוב למה.
העברת ארגומנטים לפונקציות ומחסנית הקריאות
הסיבה שהקריאה לפונקציה swap לא החליפה את הערכים של הארגומנטים שלנו נוגעת ישירות לצורה שבה קריאה לפונקציה מתבצעת. נזכיר את השלבים כפי שלמדנו בפרק הקודם:
-
לפני הקריאה לפונקציה
swap, הקומפיילר מייצר פקודות לבניית ה-stack frame שלה - ספציפית, הוא מעתיק את הארגומנטיםaו-bמהפונקציהmainלתוך ה-stack frame החדש. -
לפני תחילת ריצת הפונקציה
swap, האיבר העליון ביותר בזכרון במחסנית הקריאות מכיל את המשתניםaו-bשל הפונקציהswap. מתחתיו בזכרון במחסנית הקריאות נמצא ה-stack frame של הפונקציהmain, בו נמצאים במקום אחר לגמרי המשתניםaו-bשל הפונקציהmain. -
בסיום הפונקציה
swap, הקומפיילר מייצר פקודות לקיפול מחסנית הקריאות וחזרה ל-stack frame של הפונקציהmain. המשתניםaו-bשבאותו ה-stack frame לא השתנו בכלל!
כלומר, בדיעבד אחרי שאנחנו מבינים קצת יותר איך מתבצעת בפועל קריאה לפונקציה, לא מפתיע שהמשתנים ב-main לא משתנים - הפונקציה swap קיבלה עותק של המשתנים ולא את המשתנים עצמם. בפועל, a ו-b שהגדרנו ב-main נמצאים בכתובת שונה לחלוטין בזכרון מהעותקים שלהם שעוברים לפונקציה.
הסיבה לכך, כפי שראינו בעבר, היא שבשפת C ארגומנטים עוברים לפונקציות ע״י הערך שלהם בלבד. מנגנון זה נקרא pass by value - מיד נראה דרך נוספת להעביר ארגומנטים לפונקציה שתאפשר לנו לבצע את המשימה שרצינו.
מצביעים
הגדרת הבעיה
ננסה לחשוב על מימוש אחר לפונקציית ה-swap שלנו על מנת שהיא תוכל לערוך את המשתנים בפונקציה הקוראת.
לפי עקרון הבידוד שבהגדרנו בפרק הפונקציות, קיימת בעיה עקרונית לעשות זאת - פונקציות יכולות לגשת לארגומנטים והמשתנים הלוקאליים שלהן, ואנחנו רוצים לתת להן גישה למידע שנמצא בזכרון ב-stack frame אחר מהם. למעשה, אם נזקק את הבעיה לבסיסה, אנחנו נרצה דרך להגיד לפונקציה swap ״איפה״ בזכרון המשתנים שאנו רוצים להחליף נמצאים.
למזלנו, כפי שראינו כשדנו בנושא הזכרון בתחילת הקורס, לשאלה ״איפה נמצא המשתנה x בזכרון״ יש תשובה פשוטה - הכתובת שלו בזכרון! וכפי שראינו, האופרטור & נותן את אותה הכתובת. מעתה, נתחיל להשתמש בכתובות אלו ע״י משתנים מיוחדים הנקראים מצביעים.
מצביעים
מצביע (pointer) הוא משתנה שערכו הוא כתובת של משתנה אחר בזכרון.
נגדיר שני אופרטורים חדשים שישמשו אותנו עם מצביעים:
-
אופרטור ה-address-of (לקיחת הכתובת):
&. עבור משתנה כלשהוx, הביטוי&xהוא הכתובת של המשתנהxבזכרון. -
אופרטור ה-dereference (גישה לכתובת):
*. עבור מצביע כלשהוptr, הביטוי*ptrהוא הערך של המשתנה אליוptrמצביע.
Note
השתמשנו רבות כבר באופרטור לקיחת הכתובת כאשר העברנו משתנים לפונקציה
scanf, למשל:scanf("%d", &n)עבורnמשתנהintכלשהו.
הסינטקס לשימוש במצביעים יכול להיות מבלבל מאוד. לכן, לפני שנגדירו במדויק, נבחן כיצד דוגמאת ה-swap נראת בשימוש במצביעים:
#include <stdio.h>
void swap(int *a_ptr, int *b_ptr) {
int tmp = *a_ptr;
*a_ptr = *b_ptr;
*b_ptr = tmp;
}
int main() {
int a = 1;
int b = 5;
printf("before call to swap: a = %d, b = %d", a, b);
swap(&a, &b);
printf("after call to swap: a = %d, b = %d", a, b);
}
נפרק:
-
הפונקציה
swapכעת מקבלת שני משתנים מטיפוס* intבמקום מטיפוסint. זהו הסינטקס למשתנה שהוא מצביע למשתנה מטיפוסint. -
בקריאה לפונקציה אנחנו מעבירים את
&a, &bבמקום אתa, b. אופרטור לקיחת הכתובת לוקח אתa, bממשתנים מטיפוסintומחזיר את הכתובת שלהם - מצביעים אליהם מטיפוס* intכפי שהפונקציה דורשת. -
הפונקציה משתמשת באופרטור ה-dereference כמה פעמים - למשל, בשורה הראשונה היא כותבת למשתנה
tmpאת הערך אליוa_ptrמצביעה ע״י האופרטורa_ptr*.
הסינטקס עבור מצביעים יכול להיות מאוד מבלבל - זו בעיה מוכרת בקרב מתכנתי C מתחילים, ולכן נקדיש זמן לפרק אותו בצורה מסודרת ונלמד עם הרבה דוגמאות. נמשיך בהגדרה פורמאלית של הסינטקס.
אתחול ושימוש של מצביע למשתנה מסוג type נראה כך:
type var = some_value;
type *ptr = &var;
type another_var = *ptr;
נפרק את השורות שכתבנו:
-
בשורה הראשונה אנחנו מגדירים משתנה כללי מטיפוס
type, שיכול להיות כל טיפוס -int,charוכו’. -
בשורה השנייה אנחנו מצהירים על משתנה מטיפוס
* type. משתנה מהטיפוס* typeהוא מצביע למשתנה אחר מטיפוסtype. ערכו של המצביע הוא הכתובת של המשתנהvar, הנלקח ע״י אופרטור ה-&. -
לבסוף אנחנו מגדירים משתנה נוסף מטיפוס
typeומגדירים שערכו הואptr*- כלומר, ערכו של המשתנה אליוptrמצביע, שהואvar. אופרטור ה-dereference למעשה מורה למעבד לגשת אל הכתובת בזכרון שכתובה במצביע ולקחת ממנה את הערך.
בקצרה, כאשר קיים משתנה בשם x ומשתנה אחר המצביע אליו בשם xptr, הביטוי xptr* והביטוי x שקולים לחלוטין.
לסיכום: העברה של משתנה לפונקציה ע״י הכתובת שלו עם מצביע אליו נקראת pass by reference.
סגנונות בכתיבת מצביעים
הסינטקס עבור מצביעים מאוד לא אחוד בשפה וקיימים סגנונות רבים אחרים:
int x = 42;
int *xptr = &x;
int* xptr2 = &x;
int * xptr3 = &x;
אנחנו נדבוק בסגנון int *xptr = &x מכמה סיבות:
- הוא פופולרי מאוד.
- כאשר אנחנו כותבים
int *xptr = &x, יש הגיון מסוים בטיפוסים - המשתנה*xptrהוא משתנה מסוגint, בדיוק כמו המשתנהx. - וגם כי…
הצהרה על מספר מצביעים בשורה אחת
באופן כללי, כפי שדיברנו בעבר, רצוי להמנע מהצהרה של כמה משתנים באותה שורה. במצביעים ספציפית קיים באג קשה מאוד לפענוח שקל ליפול אליו כאשר עושים זאת.
כאשר מצהירים על מספר משתנים באותה שורה:
int *ptr1, ptr2, ptr3;
מתברר כי המשתנים ptr2 ו-ptr3 כלל אינם מצביעים, אלא מטיפוס int. לכן יש להקפיד על הכוכבית במקום הנכון:
int *ptr1, *ptr2, *ptr3;
או, הרבה יותר טוב, פשוט להצהיר על כל אחד בשורה משלו:
int *ptr1;
int *ptr2;
int *ptr3;
מצביע * void ו-casting של מצביעים
לפעמים נרצה אובייקט שייצג כתובת כללית בזכרון, ללא התייחסות לטיפוס ספציפי ממנה הכתובת הגיעה. לטובת כך נשתמש במצביע מטיפוס void *:
int x = 42;
void *ptr = &x;
אי אפשר לעשות dereference למצביע מסוג * void:
#include <stdio.h>
int main() {
int x = 42;
void *p = &x;
printf("%d\n", *p);
}
void_deref.c:5:20: error: argument type 'void' is incomplete
מצד שני, ניתן להמיר בין סוגי מצביעים שונים. נבחן שתי תוכניות לדוגמא:
#include <stdio.h>
void print_ptrs(char *char_ptr, int *int_ptr) {
printf("Accessing through char pointer: %c\n", *char_ptr);
printf("Accesing through int pointer: %d\n", *int_ptr);
printf("\n");
}
int main() {
int i = 123456;
char *char_ptr = (char *)&i;
int *int_ptr = &i;
print_ptrs(char_ptr, int_ptr);
char c = 65;
char_ptr = &c;
int_ptr = (int *)&c;
print_ptrs(char_ptr, int_ptr);
}
Caution
להוסיף פה תרשים של הבייטים
דוגמא
כתבו פונקציה שמקבלת מצביעים ל-3 משתני char בשם a, b, c, שמובטח שמכילים אותיות קטנות באנגלית. הפונקציה תסדר אותם כך ש-a יכיל את האות הנמוכה ביותר, b את הבאה אחריה ו-c את הבאה אחריה.
בשביל לפתור את הבעיה נבחן מה השלבים שאנחנו צריכים לבצע. צריכים להתקיים בין האותיות היחסים הבאים:
a <= ba <= cb <= c
אם מתקיים למשל a >= b, נוכל להחליף בין a ל-b ואז מובטח שהיחס ההפוך מתקיים - לכן נכתוב פונקציית עזר שתחליף בין שני char. נקבל את הקוד הבא:
void swap(char *x, char *y) {
char tmp = *x;
*x = *y;
*y = tmp;
}
void sort(char *a, char *b, char *c) {
if (a <= b) {
swap(a, b);
}
if (a <= c) {
swap(a, c);
}
if (b <= c) {
swap(b, c);
}
}
נזכיר כי למרות שמדובר על משתני char אין שום מניעה להשוות ביניהם עם < או > - כמו שראינו בעבר, משתני char הם מספרים שאנחנו מפרשים כתווים עם מיפוי ASCII.
Dangling & Null Pointers
מעתה והלאה, כמעט כל תוכנית שנכתוב תכלול מצביעים בדרך כזו או אחרת. בהתאמה, חשוב להבין בצורה עמוקה את תמונת הזכרון של התוכנית שלנו. קל מאוד לעשות טעויות בעבודה עם מצביעים המוביל לבאגים קשים. נבחן את הדוגמא הבאה:
#include <stdio.h>
int main() {
int *ptr;
scanf("%d", ptr);
printf("%d\n", *ptr);
}
זהו לכאורה קטע קוד תקין לחלוטין - אנחנו מעבירים מצביע לפונקציה scanf כמו שראינו בעבר. עם זאת, קטע קוד זה יכשל מיד. מדוע? נבחן את תמונת הזכרון:
ננתח את התוכנית:
-
ב-stack frame של הפונקציה
mainנמצא המשתנהint *ptr. הוא מצביע, אבל הוא אינו מאותחל. -
כפי שראינו, משתנים לא מאותחלים מכילים ערך זבל. ערך הזבל הוא הוא מספר כלשהו, אבל הוא בבירור לא כתובת מדויקת של אף משתנה בתוכנית שלנו.
-
scanfתקרא את ערך הזבל הזה ותנסה לפרש אותו ככתובת של משתנה בזכרון על מנת לכתוב אליו את התוצאה. -
גישה לכתובת לא חוקית בזכרון היא שגיאה ידועה לשמצה בשפת C הנקראת בדרך כלל segmentation fault.
Dangling Pointer
מצביע שאינו מאותחל ו/או מצביע לאף כתובת חוקית נקרא dangling pointer. באופן כללי, ביצוע dereference ל-dangling pointer גורם ל-segmentation fault שמקריסה את התוכנית.
בגלל פוטנציאל הסיכון הגדול בשימוש במצביעים לא חוקיים, כדאי לנקוט משנה זהירות בכל שימוש במצביע. מסקנה אחת נדרשת היא שכדאי לאתחל את המצביעים שלנו תמיד. עם זאת, לאיזה ערך כדאי לאתחל?
השפה מציע עבורנו ערך ברירת מחדל למצביעים בשם NULL שבהגדרה לא מצביע לאף כתובת חוקית. נהוג לאתחל מצביעים ל-NULL כאשר לא ניתן מיד לתת להם כתובת של המשתנה אליו הם מצביעים. הקבוע NULL מוגדר (בנוסף למקומות אחרים) בקובץ <stdio.h> בו עשינו כבר לא מעט שימוש.
נהוג גם “להגן” על פונקציות משימוש בפרמטרים לא חוקיים - למשל במקרה של swap:
#include <stdio.h>
void swap(int* a, int* b) {
if (a == NULL || b == NULL) {
printf("passed NULL pointer!\n");
return;
}
// ... rest of swap
}
לרוב לא נשתמש בסינטקס של ptr == NULL, אלא בצורה הבאה השקולה לו:
#include <stdio.h>
void swap(int* a, int* b) {
if (!a || !b) {
printf("passed NULL pointer!\n");
return;
}
// ... rest of swap
}
Note
הסיבה ששתי הצורות
ptr == NULLו-!ptrשקולות היא ש-NULLמוגדר להיות הערך0רק בצורה של מצביע:
#define NULL ((void*)0)
כך שתוצאת הביטוי NULL! היא 0!, שבפירוש כמשתנה בוליאני היא true.
הרצאה 6 - מערכים סטאטיים ודינמיים
מערכים סטאטיים
נפענח בעיה פשוטה. נניח שיש לנו קורס עם 3 סטודנטים ואנחנו רוצים לבצע כל מיני פעולות פשוטות - קבלת ממוצע, חציון וכו’. יתכן ונכתוב קוד שנראה כך:
int main() {
double grade1, grade2, grade3;
double avg = calc_avg(grade1, grade2, grade3);
/// etc...
}
זה נראה הגיוני. עם זאת, זה מאוד לא ניתן להרחבה (scalable) - מה יקרה שיהיו בקורס 10 סטודנטים? או 100 או 1000? כמובן שלא מצופה מאיתנו להגדיר משתנה לכל ערך שנרצה להגדיר. הבעיה אותה נרצה לפתור היא למעשה - כיצד נטפל במספר שרירותי של אובייקטים מסוג מסוים?
הגדרה: מערך (array) הוא סדרה באורך שרירותי של משתנים מאותו הטיפוס.
לפני הגדרה מדויקת, כרגיל ניתן דוגמא להתרשמות. נרצה להגדיר מערך של 100 ציונים 100 סטודנטים ולגשת לחלק מהציונים עצמם. ניתן לעשות זאת כך:
int main() {
double grades[100];
grades[64] = 90;
// grades[500] = 30; <- Error!
}
נפרט מה אנו רואים בדוגמא. בהתחלה, הגדרנו את המשתנה grades -
נפרט:
- המשתנה
gradesהוא מערך באורך 100 של משתנים מסוגdouble. - עבור
iכלשהו המקייםi >= 0 && i < 100, המשתנהgrades[i]הוא האיבר ה-iבמערך. במקרה זה,iנקראה האינדקס של האיבר במערך.
הערות:
- האיבר הראשון במערך הוא
grades[0]ולאgrades[1]. ברוב שפות התכנות מתחילים לספור מערכים מ-0 ולא מ-1. - כרגע אנו עוסקים במערכים סטאטיים שגודלם הוא בהכרח מספר קבוע. למשל, קטע הקוד הבא לא יתקמפל:, והסיבה לכך עמוקה וקשורה למחסנית הקריאות:
int array_length;
scanf("%d", &array_length);
int array[array_length];
ניתן לתת ערכים התחלתיים למשתנים שבתוך המערך ע“י הסינטקס הבא:
int first_primes[6] = {2, 3, 5, 7, 11, 13};
- איברים במערך סטאטי יושבים בכתובת עוקבות בזכרון. למשל אם הקצנו מערך של
intבכתובת100, מכיוון שגודל כל איבר הוא 4 בתים (sizeof(int) == 4) האיברים ישבו בכתובות108, 104, 100וכך הלאה.
לדוגמא - ננתח את הציונים כפי שרצינו קודם:
#define STUDENTS 100
int main() {
double grades[STUDENTS];
for(int i = 0; i < STUDENTS; i++) {
scanf("%.2lf", &students[i]);
}
int grades_sum = 0;
for(int i = 0; i < STUDENTS; i++) {
sum += grades[i];
}
printf("Course average: %.2lf\n", grades_sum / STUDENTS);
}
אלגוריתמים
כעת, נפגש עם האלגוריתם הראשון שננתח בקורס. יתכן ויש לנו הבנה אינטואטיבית של המושג, אבל לשלמות רצוי שנגדיר אותו על מנת שכולנו נדבר את אותה השפה:
הגדרה: אלגוריתם הוא סדרת פעולות מדויקות לביצוע משימה או פתרון בעיה כלשהי המתחשב בכל המקרים האפשריים.
כלומר, בהנתן בעיה מסוימת, אלגוריתם הוא סדר פעולות שיש לבצע על מנת לקבל פתרון לבעיה.
הגדרה מדויקת של הבעיה היא קריטית לחלוטין על מנת למצוא לה פתרון. בדרך כלל בקורס נתאר בעיות בצורה של קלט ופלט - כלומר, בהנתן קלט מסוים, נרצה להפעיל אלגוריתם כלשהו על מנת לקבל פלט מסוים. כמה דוגמאות לבעיות:
- בהנתן מספר חיובי
n, יש לקבוע האם הוא ראשוני או לא. הקלט הוא מספר חיובי והפלט הוא משתנה בוליאני שערכוtrueאם הקלט ראשוני ו-falseאחרת. - בהנתן מערך של ציונים במבחן, יש לחשב את הממוצע. הקלט הוא מערך הציונים והפלט הוא משתנה שמכיל את הממוצע שלהם.
- בהנתן מערך כלשהו של מספרים
arrומספר כלשהוx, יש לקבוע האםxנמצא במערך או לא. הקלט הוא מערך ומספר והפלט הוא משתנה בוליאני שערכוtrueאםxנמצא במערך ו-falseאחרת.
דוגמא
ננתח את הבעיה הבאה: נרצה להדפיס את כל המספרים הראשוניים בין 0 למספר כלשהו N הנתון ב-define#. לטובת זאת קיים אלגוריתם עתיק בשם ה-Sieve of Eratosthenes, שפותר את הבעיה בצורה יעילה. האלגוריתם פועל לפי השלבים הבאים:
- צור רשימה של מספרים בין 2 ל-
N. - הגדר משתנה בשם
p = 2. - כל המספרים מהצורה
2p, 3pוכך הלאה הקטנים מ-Nאינם ראשוניים (כי הם מתחלקים ב-p) - סמן ברשימה שאינם ראשוניים. - קבע את הערך החדש של
pלהיות המספר הבא אחריpברשימה שלא סומן. - חזור לשלב 3 עד שאין מספרים נוספיים לרישום.
בתרגום לקוד, התוכנית שלנו תראה כך:
- נייצר מערך של משתנים שגודלו
N + 1- איבר במערך עבור כל המספרים מ-0 ועדN, כולל (לכןN + 1ולאN). - מעניין אותנו רק האם האיבר באינדקס מסוים ראשוני או לא ולא מה ערכו, ולכן המערך יהיה מסוג
bool. - מכיוון שהאלגוריתם פוסל מספרים ראשונים, נאתחל את כל המערך להיות
true.
נממש:
#include <stdbool.h>
#define N 1000
void sieve(void) {
bool is_prime[N + 1];
for(int i = 2; i <= N + 1; i++) {
is_prime[i] = true;
}
is_prime[0] = false;
is_prime[1] = false;
for(int p = 2; p * p <= N; p++) {
if(!is_prime(p)) {
continue;
}
for(int i = p; i <= N / p; i++) {
prime[i * p] = false;
}
}
printf("Primes between 0 and %d are:\n", N);
for(int i = 0; i < N; i++) {
if(is_prime[i]) {
printf("%d, " i);
}
}
}
הערה: שימוש ב-sizeof על מערך מחזיר את מספר הבתים במערך:
#define N 10
#define K 20
int main() {
int int_arr[N];
char char_arr[K];
printf("int_arr size = %d, char_arr size = %d\n", sizeof(int_arr), sizeof(char_arr));
}
מערכים רב-מימדיים
בדוגמא הקודמת, הגדרנו מערך באופן הבא int grades[NUM_STUDENTS] - רשימה של מספרים שמייצגת את הציונים של הסטודנטים בקורס מסוים. זו דוגמא למערך חד-מימדי - לכל אובייקט יש אינדקס אחד בלבד. למשל, הציון של הסטודנט הראשון בקורס הוא grades[0]. בצורה דומה, ניתן להגדיר מערכים דו-מימדיים - מערכים בהם לכל אובייקט יש שני אינדקסים. כמו כן, באופן כללי ניתן להגדיר מערכים רב-מימדיים עם מספר מימדים לבחירתנו.
למשל, נניח שיש קבוצה של 50 סטודנטים ושכל אחד מהם לוקח 3 קורסים. ניתן לחשוב על כל ציון של סטודנט בתור בתור איבר עם שני אינדקסים - אחד עבור מספר הקורס והשני עבור מספר הסטודנט. הדרך להגדיר מערך דו-מימדי מסוג כזה דומה מאוד להגדרת מערך חד-מימדי:
#define NUM_COURSES 3
#define NUM_STUDENTS 50
int grades[NUM_COURSES][NUM_STUDENTS];
אינטואיטיבית, מערך דו-מימדי הוא מערך שכל איבר בו הוא בעצמו מערך. המערך grades כעת הוא מערך באורך 3 שכל איבר בו הוא מערך באורך 50.
דוגמא קלאסית למערך דו-מימדי היא הגדרת מטריצה בקוד. אינטואיטיבית ניתן לחשוב על מטריצה בתור מערך דו-מימדי - כמו שכותבים במתמטיקה עם שני אינדקסים $a_{ij}$, נרצה לכתוב בקוד משהו בסגנון של a[i][j].
int matrix[3][4];
נדגיש שיש משמעות מהותית לסדר בו האינדקסים מופיעים: המשתנה matrix הוא מערך שכל איבר בו הוא מערך של 4 איברי int. למשל, matrix[1] (אינטואיטיבית - השורה השנייה במטריצה) הוא מערך של 4 int-ים. המשתנה matrix[1][2] הוא משתנה int רגיל.
בשביל לאתחל מערך דו-מימדי, ניתן לקנן (nest) את אתחולי המערכים שראינו קודם. נניח למשל שנרצה לכתוב את המטריצה הבאה בקוד:
$$\begin{pmatrix} 1 & 2 & 3 & 4 \ 5 & 6 & 7 & 8 \ 9 & 10 & 11 & 12 \end{pmatrix}$$
הדרך להצהיר עליה בקוד תהיה כך:
int matrix[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
להזכירכם, הקומפיילר מתעלם מרווחים בקוד - ההזחות והחלוקה לשורות היא למען קריאות הקוד עבור המתכנת ואין לה משמעות פרקטית. המשמעות החשובה היא פתיחת סוגריים מסולסלים {} לאתחול המערך הכולל matrix, ואז בתוכו אתחול כך מערך כשורה עם {} משל עצמו.
אין הגבלה למספר המימדים שמערך יכול לקבל:
int multidim_array[5][4][99][205][10];
דוגמא: כתבו תוכנית שמחברת ומכפילה 2 מטריצות וכותבת את התוצאה לתוך מטריצה שלישית, כאשר גדלי המטריצות הם 10x10.
#define SIZE 10
int main() {
int A[SIZE][SIZE], B[SIZE][SIZE], C[SIZE][SIZE];
// Matrix addition
for(int i = 0; i < SIZE; i++) {
for(int j = 0; j < SIZE; j++) {
C[i][j] = A[i][j] + B[i][j];
}
}
// Matrix multiplication
for(int i = 0; i < SIZE; i++) {
for(int j = 0; j < SIZE; j++) {
for(int k = 0; k < SIZE; k++) {
C[i][j] += A[i][k] * B[k][j];
}
}
}
}
מערכים, מצביעים, ותמונת הזכרון
ראינו בהרצאה קודמת כיצד פונקציות מרכיבות את תמונת הזכרון על מחסנית קריאות - כל פונקציה הנקראת (גם main) מקצה על מחסנית הקריאות את הארגומנטים שהיא מקבלת והמשתנים הלוקאליים שהיא מגדירה. כל הקצאה כזו נקראת stack frame. כעת נרצה לבחון כיצד האירוע הזה נראה כשמשתמשים במערכים.
נבחן למשל את התוכנית הבאה:
int main() {
int x = 78;
char letters[4] = {'a', 'b', 'c', 'd'};
float pi = 3.14;
}
כאשר אנו מגדירים מערך סטאטי באורך n בזכרון, הקומפיילר מתייחס לכך כאילו הקצנו n משתנים - דוחף אותם ברצף זה אחר זה למחסנית הקריאות. מחסנית הקריאות תראה כך:
כלומר, מערכים סטאטיים תמיד מוגדרים בצורה רציפה בזכרון.
בצורה דומה, מערכים רב-מימדיים נדחפים על מחסנית הקריאות כמערך אחד גדול של איברים - למשל בתוכנית הבאה:
#define N 2
int main() {
char x = 'x';
int matrix[N][N] = {
{1, 2},
{3, 4}
};
}
מחסנית הקריאות תראה כך:
הקומפיילר מאפשר לנו “לראות” בקוד את המערך הרב-מימדי בתוך מערך של מערכים כפי שנוח לנו, אבל בפועל בזכרון מקצה את האיברים ברצף זה אחר זה.
מערכים ומצביעים
עכשיו כשהכרנו מהם מערכים, הגיע הזמן לחבר אותם לאובייקט שאנחנו כבר מכירים היטב - מצביעים. כפי שראינו כעת, כשאנחנו מגדירים מערך סטאטי int numbers[5], הקומפיילר מקצה 5 int-ים זה אחר ברצף על המחסנית. מעבר לכך, מסתבר שהמשתנה numbers עצמו הוא מצביע - ספציפית, לאיבר הראשון במערך! כלומר מתקיים:
int numbers[5];
numbers == &numbers[0];
כלומר, הטיפוס של המשתנה numbers הוא *int. כשאנחנו משתמשים בסינטקס numbers[i], אנחנו מבקשים מהקומפיילר לבצע את הפעולות הבאה:
- התחל בכתובת בזכרון ש-
numbersמכיל - התקדם בזכרון
iאיברים - בצע dereference
*לאיבר הגעת אליו
הדבר הזה מאוד דומה לאריתמטיקה של מצביעים, ומסתבר שלא במקרה! הסינטקס numbers[i] הוא קיצור נחמד שהשפה מציעה לנו לביטוי קצת יותר ארוך שכבר פגשנו:
// Lines are equivalent
numbers[i];
*(numbers + i);
כלומר, ניתן לחשוב שמערכים ומצביעים הם שקולים לחלוטין. זה נכון לרוב השיקולים - בהמשך נראה מספר הבדלים ביניהם.
מערכים ופונקציות
נניח כעת שנרצה להעביר מערכים לפונקציות. מכיוון שראינו שמערך הוא למעשה מצביע לאיבר הראשון במערך, נוכל להעביר מצביע כפי שעשינו כבר:
int calc_avg(int* grades, int grades_len) {
int sum = 0;
for(int i = 0; i < grades_len; i++) {
sum += grades[i];
}
printf("sizeof grades: %d\n", sizeof(grades));
return sum / grades_len;
}
#define NUM_GRADES 5
int main() {
int grades[NUM_GRADES] = {90, 45, 32, 100, 93};
printf("Average is: %d", calc_avg(grades, NUM_GRADES));
}
נשים לב שאנחנו חייבים לתת לפונקציה את אורך המערך במידה ונרצה לבצע איטרציות מעליו - אחרת, אין לה דרך לדעת כמה איברים יש בו - מצביע הוא כתובת בזכרון ולא מעבר לכך. כמו כן, כאשר נדפיס את גודל המשתנה grades, נקבל 8 בתים - גודל של מצביע סטנדרטי במערכת 64bit.
עם זאת, כפי שראינו קודם, מערכים סטאטיים הם מערכים שהקומפיילר יודע מראש בזמן קומפילציה מה גודלם - אחרת הוא לא יכל להקצות אותם. לכן, קיימות שתי דרכים נוספות להעביר מערכים סטאטיים לפונקציות, שנראת מעט שונה מהעברת המצביע:
int calc_avg(int grades[], int grades_len);
// or like this:
int calc_avg(int grades[NUM_GRADES], int grades_len);
#define NUM_GRADES 5
int main() {
int grades[NUM_GRADES] = {90, 45, 32, 100, 93};
printf("Average is: %d", calc_avg(grades, NUM_GRADES));
}
הסינטקס שונה קצת, אבל המשמעות זהה - הקומפיילר מזהה את grades בתוך מצביע לאיבר כלשהו, שאנחנו לאחר מכן עושים בו שימוש כמערך. במקרה זה אנחנו אומרים שהמערך דועך למצביע (array decay to pointer). הסיבה שנגיד שהוא דועך היא מכיוון שהפונקציה מפרשת את המערך כמצביע, לא ניתן לבצע sizeof ולקבל את גודל המערך כמו שראינו בעבר:
int calc_avg(int grades[], int grades_len) {
// rest of code
printf("sizeof(grades) = %d\n", sizeof(grades)); // 8! sizeof(ptr)
}
#define NUM_GRADES 5
int main() {
int grades[NUM_GRADES] = {90, 45, 32, 100, 93};
printf("Average is: %d", calc_avg(grades, NUM_GRADES));
}
המערך “מאבד” את המידע על גודלו מכיוון שהוא מועבר לפונקציה כמצביע. לכן, צורות הכתיבה עם [] בהעברה לפונקציה
אינן מומלצות - כתיבה בצורה int fn(int* arr, int len) היא המועדפת ובה נשתמש בקורס.
התמונה מסתבכת כאשר מדברים על מערכים דו-מימדיים. להזכירכם, מערכים רב-מימדיים נשמרים בזכרון כרצף של מערכים על מחסנית הקריאות. נבחן למשל את התוכנית הבאה:
int main() {
int array[4][3];
array[1][2] = 0;
}
כך שתמונת הזכרון נראת כך:
כאשר נזכיר שה-“רווחים” הקטנים שציירנו בין המערכים אינם באמת קיימים.
מה למעשה אנחנו מבקשים מהקומפיילר לעשות?
- הקצה עבורינו מערך של 4 איברים, בו כל איבר הוא מערך של 3 משתני
int. - גש למערך השני מתוך החמישה ובתוכו לאיבר השלישי.
- כתוב לתוכו 0.
נפענח את שלב 2. בשביל להגיע למערך השני, הקומפיילר צריך לדעת כמה איברים יש בכל מערך - הוא יודע את המידע הזה מההקצאה, 3 איברים. לכן הוא מפענח את הפקודה array[1][2] בתור - התקדם 3 איברים ואז 2 איברים וכך תגיע לאיבר הרצוי.
באופן כללי, עבור מערך דו-מימדי int arr[N][M], גישה לאיבר arr[i][j] תפורש באופן הבא כאריתמטיקת מצביעים:
arr + M * i + j
כלומר, הקומפיילר צריך לדעת כמה איברים יש בכל שורה, אבל לא רלוונטי לדעת כמה שורות יש. כעת נבחן את אותה הדוגמא בדיוק, רק בתוך פונקציה:
void fn(int array[][]) {
array[1][2] = 0;
}
int main() {
int array[4][3];
fn(array);
}
כעת הקומפיילר בתוך הפונקציה לא יודע כמה איברים יש בכל שורה! זו בעיה מהותית, ולכן נהיה חייבים להעביר אותו:
void fn(int array[][3]) {
array[1][2] = 0;
}
int main() {
int array[4][3];
fn(array);
}
כל הסיפור המורכב הזה יכול להמנע אם נעביר מערכים לפונקציות כמצביעים יחד עם אורך המערכים שהם מייצגים במקום להתעקש על מערכים, ולכן כך נעשה בדרך כלל.
סיכום
נסכם את ההבדלים בין מערכים סטאטיים למצביעים:
- הצהרה על מערך סטאטי, למשל
int arr[5], מקצה עבורו זכרון ישירות. לעומת זאת, הצהרה על מצביעint* arrשלא מצביע לכלום לא מקצה זכרון עבורו. - מידע על גודל הזכרון - שימוש ב-
sizeofעל מערך סטאטי תחזיר את גודל כל הזכרון שכל האיברים במערך תופסים, לעומת על מצביע שתחזיר את הגודל שהמצביע לוקח. - לא ניתן לשנות לאן מערך “מצביע” - למשל כאן נקבל שגיאת קומפילציה:
int arr1[4];
int arr2[3];
arr1 = arr2; // Compilation error!
הערות:
- נשים לב שמערכים נשלחים לפונקציה כמצביע, ולכן בהכרח עוברים by reference! אין דרך להעביר מערך by value לפונקציה.
- נשים לב שכשאנחנו מעבירים מצביע שמייצג מערך לפונקציה, אנחנו חייבים לספק ביחד איתו גם את אורך המערך - הפונקציה מקבלת מצביע בלבד, ללא מידע על כמה איברים יש בו.
הקצאה דינמית (dynamic allocation)
עד כה המערכים בהם השתמשנו היו בעלי גודל סטאטי - כלומר, ידוע בזמן קומפילציה, לפני שהתוכנית רצה. כמו כן, ראינו שהצהרה על מערך עם גודל דינמי - כלומר, ידוע רק בזמן ריצת התוכנית - הוא בלתי אפשרי. מדוע? הסיבה לכך קשורה למחסנית הקריאות וזו אחת הסיבות העיקריות שהקדשנו זמן רב להבנה עמוקה שלה. זו נקודה קריטית ולכן הקדישו זמן להבנה עמוקה שלה.
נניח שנרצה מערך בגודל דינמי ונכתוב משהו בסגנון הזה:
int array_length;
scanf("%d", &array_length);
int array[array_length];
מה ביקשנו בעצם מהקומפיילר? תקצה לנו על מחסנית הקריאות מערך באורך array_length. עם זאת, לקומפיילר אין מושג איזה מספר המשתמש יכניס! הוא לא יודע להגיד בזמן הקומפילציה כמה בתים על מחסנית הקריאות צריך לפנות בשביל שיהיה מספיק מקום לכל המערך, ולכן יסרב לקמפל את הקוד שלנו.
ניתן לחשוב שמדובר בבעיה לא קריטית, אבל זו תהיה טעות. נניח שאנחנו כותבים תוכנית שקוראת מקובץ אקסל את הציונים של כל הסטודנטים בקורס ומכניסה אותם למערך בשביל לחשב עליהם סטטיסטיקות. כל סמסטר יש מספר שונה של סטודנטים - איך על התוכנית לפעול? תוכניות שצריכות לבצע פעולות התלויות בקלט הן מהנפוצות ביותר ולכן לא נוכל להסתפק בהקצאת זכרון סטאטית בלבד.
נאפיין את הפתרון שאנחנו רוצים, בדומה לאיך שהקומפיילר עובד עם הקצאות סטאטיות:
- אנחנו צריכים דרך בה התוכנית שלנו תקצה גודל מסוים של זכרון שאינו על מחסנית הקריאות - זאת בדומה לדחיפה על מחסנית הקריאות של הקומפיילר.
- אנחנו צריכים לקבל מצביע לאותו איזור בזכרון שהקצנו - זאת בדומה להכרזה על מערך סטאטי.
- אנחנו צריכים לדעת “לשחרר” את הזכרון אחרי שסיימנו איתו - זאת בדומה לקיפול המחסנית בסוף קריאה לפונקציה.
פונקציית malloc
לטובת הבעיה הזו קיימת פונקציה מיוחדת וחשובה מאוד בשפה בשם malloc (קיצור של memory allocation), המוגדרת בספרייה <stdlib.h>. הפונקציה מקבלת את גודל הזכרון בבתים שהמתכנת מבקש ומחזירה מצביע לאותו הזכרון. מכיוון שהקומפיילר לא אחראי כבר על ההקצאה הזו, הוא לא יכול “לקפל” אותו עבורינו כשלא נצטרך אותו יותר - באחריותנו כמתכנתים יהיה לעשות זאת.
חתימת הפונקציות הרלוונטיות היא:
#include <stdlib.h>
void* malloc(size_t size);
void free(void* ptr);
הפונקציה מחזירה כתובת כללית בזכרון מסוג *void - הגיוני מכיוון שאנחנו מבקשים ממנה בלוק זכרון ללא מידע על הטיפוס שבלוק זה ייצג. size_t הוא טיפוס המוגדר בקובץ <stdlib.h> שמייצג גודל אי-שלילי כלשהו. free תקבל מצביע שהוחזר ע״י malloc ותשחרר אותו.
דוגמא: נניח שאנחנו רוצים תוכנית בה המשתמש יכניס זה אחר זה ציונים של סטודנטים ולאחר מכן יקבל סטטיסטיקות עליהם. ננסה לכתוב תוכנית מלאה לחלוטין - בדיקות של כל המשתנים הרלוונטים, לולאות במקומות המתאימים וכו׳.
#include <stdlib.h>
#include <stdio.h>
int calc_avg(int* grades);
int calc_median(int* grades);
void grades_statistics(void) {
int num_students;
do {
printf("Enter a positive number of students in course\n");
scanf("%d", &num_students);
} while(num_students <= 0);
int* grades = (int*)malloc(num_students * sizeof(int));
if(!grades) { // can also write grades != NULL
printf("could not allocate memory - exiting\n");
return;
}
for(int i = 0; i < num_students; i++) {
printf("Enter grade for student number %d\n", i);
scanf("%d", &grades[i]);
}
printf("Course with %d students: %d average, %d median\n",
num_students, calc_avg(grades), calc_median(grades));
free(grades);
}
נשים לב לכמה נקודות חשובות:
- במידה ו-
mallocנכשלת היא מחזירהNULL. חובה לבדוק כל קריאה ל-mallocעבור החזרתNULL! באופן כללי,mallocאמורה להכשל רק במצבי קיצון ולכן סיטואציה זו אינה נפוצה ברוב המקרים, אבל כדאי להכנס להרגל של הבדיקה - אחרת יתכן והתוכנית שלנו נכשל ולא נדע אפילו למה. - הארגומנט שהעברנו ל-
mallocהואnum_students * sizeof(int). זו דרך קלאסית להשתמש ב-malloc- גודל הזכרון שאנחנו צריכים הוא מספר המשתנים הרצויים כפול גודל הזכרון הנדרש לכל אחד מהם. באופן כללי יש לבצע כך הקצאת מערך דינמי. - לאחר סיום השימוש בזכרון ביצענו
free. נרחיב מיד על נושא זה, אבל חובה לשחרר כל זכרון שהקצנו לאורך התוכנית. כמו כן, את השחרור עצמו רצוי לבצע מיד עם סיום השימוש בזכרון.
free ושחרור זכרון
free יכולה להיות מבלבלת מעט וקשה להבנה מכיוון שהיא לא נותנת שום אינדיקציה להצלחתה, ובמידה ולא נשתמש בה - לא נבחין מיידית בזה גם. אם כן, למה היא חשובה בכלל?
הזכרון במערכת שלנו הוא משאב מוגבל במגוון דרכים - הדרך הבסיסית ביותר היא גודל הזכרון הפיזי במחשב בו אנחנו עובדים. כמו שראינו קודם, גודל הזכרון הפיזי במחשב הוא מספר סופי כלשהו. כאשר אנחנו מבצעים malloc, התוכנית שלנו מסמנת בלוק זכרון כמוקצה מתוך כלל הזכרון הזמין - לאף גורם אחר על המחשב אין רשות לגשת לבלוק הזכרון שלנו.
כאשר אנחנו משחררים בלוק זכרון, אנחנו מאפשרים לגורמים אחרים במחשב שלנו לעשות שימוש חוזר באותו הבלוק. כשאנחנו שוכחים לעשות זאת, אף אחד אחר לא יכול להשתמש בבלוק זה, למרות שהוא לא נמצא בשימוש שלנו יותר.
אם שכחנו לשחרר הקצאת malloc אחת שלא מקצה הרבה זכרון, ככל הנראה לא תהיה לכך השפעה שלילית על ריצת התוכנית והמחשב שלנו. נניח למשל שנוריד את שורת ה-free בפונקציית הסטטיסטיקות שכתבנו לעיל, ופונקציה זו נקראת כל פעם שסטודנט רוצה לחשב את הממוצע שלו. מהר מאוד הקצאת הזכרון של התוכנית תעלה דרמטית, עד לקריסה של התוכנית ו/או המחשב - אף תוכנית אחרת לא יכולה להתנהל כי אין זכרון פנוי.
באג מסוג זה נקרא דליפת זכרון והוא עם פוטנציאל חמור מאוד. בהמשך כשרצף הקריאות בין פונקציות יתחיל להסתבך, נראה שגם קשה לדעת בדיוק איפה יש לבצע free, למשל כשפונקציה A תחזיר מצביע שהגיע מ-malloc ופונקציה B אחרת ״תקבל עליו אחריות״ ותצטרך לשחרר אותו בהמשך.
לדוגמא, נבחן תוכנית הקוראת ל-grades_statistics שהגדרנו קודם, בה הורדנו את שחרור עם free:
int calc_avg(int* grades);
int calc_median(int* grades);
void grades_statistics(void) {
int num_students;
do {
printf("Enter a positive number of students in course\n");
scanf("%d", &num_students);
} while(num_students <= 0);
int* grades = (int*)malloc(num_students * sizeof(int));
// ... rest of function
// free(grades);
}
int main() {
int i = 0;
while(1) {
printf("Printing stats forcourse number %d\n", i++);
grades_statistics();
}
}
התוכנית מבצעת בלולאה אינסופית את הפונקציה. כל הקצאה כזו מקצה גודל כלשהו ולא משחררת אותו - לאורך זמן וקריאות מרובות, הזכרון של התוכנית יתחיל להתכלות, ובמידה והתוכנית תרוץ לאורך זמן יתכן והזכרון כולו יגמר.
הערות:
- כאשר אנו מקצים דינמית בתוך פונקציה ומשחררים בסופה, דרך טובה לזכור לעשות זאת היא לכתוב את
mallocו-freeבזוגות:
void my_fn(int n) {
int* mem = (int*)malloc(sizeof(int) * n);
// ... rest of program using mem
free(m);
}
ואז להשלים את שאר התוכנית בין malloc ל-free.
-
(העשרה)
mallocמבקשת את הזכרון הזה ממערכת ההפעלה, שמקצה אותו על איזור מיוחד בזכרון שסימטרי למחסנית ונקרא “ערימה” (heap). זה ההבדל הטכני מהקצאה סטאטית ע״י הקומפיילר שמבצע זאת על גבי המחסנית. -
(העשרה) לא נכון בדיוק להגיד ש-
freeמקצה זכרון פיזי ושהוא המשאב שנגמר כשלא משחררים זכרון. בקורסים מתקדמים תלמדו שהמשאב שנגמר נקרא זכרון וירטואלי המנוהל ע״י מערכת ההפעלה, והקשר שלו לזכרון הפיזי הוא מיוחד ומעניין.
חיפוש מספר במערך
כעת נשתמש בידע שצברנו על מערכים על מנת לייצר אלגוריתם הפותר בעיה קלאסית - בהנתן מערך ומספר כלשהו, כיצד נמצא את האינדקס של המספר במערך, אם קיים?
פתרון פשוט, קל ונכון הוא חיפוש בכל המערך. המימוש אינטואיטיבי מאוד:
#define NOT_IN_ARRAY -1
int search_in_array(int* arr, int len, int target) {
for(int i = 0; i < len; i++) {
if(arr[i] == target) {
return i;
}
}
return NOT_IN_ARRAY;
}
במקרה בו המספר אינו במערך, נצטרך לעבור על המספרים לפני שנדע זאת - נבצע סה“כ len השוואות ואז נצא. זהו למעשה המקרה הגרוע ביותר - אם המספר במערך נסיים לכל היותר ב-len השוואות גם כן.
ניתן לשאול האם זהו האלגוריתם היעיל ביותר שניתן לייצר לבעיה הזו - זוהי שאלה שננתח בצורה מדויקת ומתמטית בהמשך הקורס כאשר נדון בסיבוכיות, אבל כעת ננסח אותה בצורה אינטואיטיבית:
בעיה: בהנתן מערך באורך n, האם ניתן למצוא את האינדקס של מספר target, אם קיים, בפחות מ-n השוואות במקרה הגרוע ביותר?
זוהי לא שאלה קלה, אבל ניתן להוכיח שהתשובה היא לא בצורה מדויקת:
- נניח בשלילה שקיים אלגוריתם שמוצא את האינדקס של
target, אם קיים, ב-k < nהשוואות. - מכיוון שיש
nאיברים וביצענוkהשוואות, נותרוn - kאיברים אליהם לא ביצענו השוואה. - עבור כל מערך בו
targetהוא אחד מה-n - kאיברים הללו, האלגוריתם שלנו נכשל.
כלומר - הוכחנו בצורה מדויקת שהאלגוריתם בו מבצעים איטרציה על כל המערך הוא היעיל ביותר. האם נוכל למצוא בעיה קרובה לבעיה הזו בה יש אלגוריתם יעיל יותר?
חיפוש מספר במערך ממוין
מסתבר שיש בעיה קרובה לבעיה הקודמת - חיפוש מספר במערך ממוין.
הגדרה: מערך ממוין של שלמים באורך n הוא מערך בו לכל i < j בין 0 ל-n מתקיים arr[i] <= arr[j].
כלומר, המערך לא יורד - כל איבר קטן/שווה מהאיבר שמימינו במערך.
נגדיר את הבעיה מחדש:
בעיה: בהנתן מערך ממוין arr באורך n ומספר target, האם ניתן למצוא את האינדקס של target, אם קיים, בפחות מ-n השוואות?
מתברר שניתן לנצל את התכונה הזו בשביל לחתוך בצורה משמעותית. נבחן דוגמא:
נניח שאנחנו מחפשים את את המספר 2 במערך, שנמצא באינדקס 3. במקום להתחיל מתחילת המערך, נתחיל מהאמצע. האמצע בין 0 ל-10 הוא 5 - arr[5] = 6. זהו לא המספר שאנחנו מחפשים, אבל כאן ניתן להשתמש בצורה חכמה בעובדה שהמערך ממוין! המספר שמצאנו גדול מהמספר שאנחנו מחפשים, ולכן אם המספר שאנחנו מחפשים קיים, מובטח שהוא נמצא אך ורק לשמאלו של המספר שמצאנו. מימין ל-6 מובטח ששאר המספרים גדולים מ-6, ולכן גם בהכרח גדולים מ-2. כלומר, תחום החיפוש החוקי שלנו הוא עכשיו האיברים המסומנים:
צמצמנו בחצי את מספר האיברים שיש לחפש בהם! נחצה את האיברים הירוקים שוב באמצע ונשווה לאמצעי. כעת האמצע בין 0 ל-5 (עם עיגול כלפי מטה) הוא 2, כאשר arr[2] = 1. שוב לא מצאנו את המספר שחיפשנו, אבל מובטח לנו שהוא מימין ל-arr[2], אם הוא קיים! נחפש מימין לו בלבד כעת, ולא נשכח שצמצמנו את האיברים האחרים:
כעת נותרו שני איברים בלבד. האמצע בין 3 ו-4 (עם עיגול כלפי מטה) הוא 3 = 2 / (4 + 3), כאשר arr[3] = 2 - מצאנו את האיבר הרצוי ב-3 השוואות בלבד!
לאלגוריתם זה קוראים חיפוש בינארי, והוא מהווה שיטה קלאסית לחיפוש במערכים ממוינים. נכתוב אותו בצורה מסודרת, בהנתן מערך ממוין באורך n ומספר מטרה target:
- התחל את החיפוש בכל המערך - הגדר גבול שמאלי
left = 0וגבול ימניright = n - 1, ובצע איטרציות כל עודlow < high. - חשב את אמצע המערך,
mid = (low + high) / 2. - אם המספר באמצע קטן מ-
target, המשך את החיפוש מימין - קבע אתlow = mid + 1. - אם המספר באמצע גדול מ-
target, המשך את החיפוש משמאל - קבע אתhigh = mid - 1. - אחרת, המספר באמצע שווה ל-
target- החזר אותו. - אם יצאנו מהלולאה, סימן שלא מצאנו את המספר במערך.
נכתוב מימוש:
#define NOT_FOUND -1
int binary_search(int* arr, int n, int target) {
int low = 0;
int high = n - 1;
while(low <= high) {
int mid = (low + high) / 2;
if(target > arr[mid]) {
low = mid + 1;
} else if(target < arr[mid]) {
high = mid - 1;
} else {
return mid;
}
}
return NOT_FOUND;
}
הערה: בהרבה קורסים וספרים קוראים לאלגוריתם זה “אריה במדבר”.
חיפוש בינארי - ניתוח מתמטי
כעת נבצע ניתוח מתמטי של כמות הפעולות הנדרשות לביצוע חיפוש בינארי. בהמשך כשנלמד על סיבוכיות נבין יותר את החשיבות של ניתוח זה. כעת, ננתח שאלה פשוטה - כמה פעולות יש לבצע בחיפוש בינארי של מערך באורך n?
נניח לפשטות ש-n הוא חזקה של 2, כלומר k שלם כך ש-n = 2^k. בכל איטרציה במהלך החיפוש הבינארי, גודל תת-המערך בו אנחנו מחפשים נחתך בחצי, עד שגודלו 1 בעת ההשוואה האחרונה. כלומר, גודל המערך בכל איטרציה יהיה:
nn / 2n / 4… עד לאיטרציה ה-tבה גודל תת-המערך הואn / 2^t. כמה איטרציות יקח להגיע ל-1?
[ \frac{n}{2^{t}} = 1 ]
[ n = 2^{t} ]
[ t = \log_{2}(2^{k}) ]
כלומר, מספר האיטרציות הוא $\log(n)$. בחיפוש רגיל בו נשווה איטרטיבית לכל איבר במערך, מספר הפעולות יהיה $n$. האם מדובר בהבדל משמעותי? מסתבר שכן!
| $n$ | $log(n)$ |
|---|---|
| $10^3$ | $9.9$ |
| $10^4$ | $13.2$ |
| $10^5$ | $16.6$ |
| $10^6$ | $19.9$ |
| $10^7$ | $23.2$ |
| $10^8$ | $26.5$ |
כלומר, כשהחיפוש האיטרטיבי יבצע מיליון איטרציות, חיפוש בינארי מבצע 20 בלבד! זה הבדל משמעותי מאוד, עם אינדיקציה ברורה ללמה החיפוש שלנו אחרי אלגוריתמים יעילים יותר הוא נכון וחשוב.
תרגילים
תרגיל 1
נתון מערך ממוין של int באורך n > 0 ומספר מטרה target. כתבו פונקציה המחזירה את המספר הקרוב ביותר ל-target במערך.
פתרון
פתרון פשוט ואינטואיטיבי יהיה חיפוש איטרטיבי במערך לאורכו. נשתמש בפונקציית עזר לערך מוחלט בשביל לוודא שאנחנו מסתכלים על מרחקים אבסולוטים:
int abs(int a) {
return a >= 0 ? a : -a;
}
int find_closest(int* arr, int n, int target) {
int min_distance = MAX_INT;
int closest = arr[0];
for(int i = 0; i < n; i++) {
int curr_distance = abs(target - arr[i]);
if(curr_distance < min_distance) {
min_distance = curr_distance;
closest = arr[i];
}
}
return closest;
}
עם זאת, זהו פתרון שלא משתמש בעובדה שהמערך ממוין. תמיד כשנתון מערך ממוין כדאי לחשוב בכיוון של חיפוש בינארי. יש הרבה אופציות שונות למקרים שהפונקציה שלנו צריכה לטפל בהם - נכתוב אותם:
- כל המספרים במערך גדולים מהמספר.
- כל המספרים במערך קטנים מהמספר.
- המספר נמצא במערך - אם כן, חיפוש בינארי ימצא אותו.
- המספר נמצא בין האיבר הקטן ביותר במערך לאיבר הגדול ביותר במערך.
למקרים 1 ו-2 מספיקים בדיקות לאיבר הראשון והאחרון של המערך - מכיוון שהמערך ממוין מספיק להשוות אליהם. למקרה 3 אפשר להשתמש בחיפוש בינארי. עבור מקרה 4, יתקבל כי low ו-high יהיו חסמים תחתון ועליון בהתאמה של target, ורק צריך לבדוק איזה מהם יותר קרוב - נשתמש עבור כך בפונקציית עזר לערך מוחלט:
int abs(int a) {
return a >= 0 ? a : -a;
}
int find_closest(int* arr, int n, int target) {
if(target <= arr[0]) {
return arr[0];
}
if(target >= arr[n - 1]) {
return arr[n - 1];
}
int low = 0;
int high = n - 1;
while(low <= high) {
int mid = (low + high) / 2;
if(target > arr[mid]) {
low = mid + 1;
} else if(target < arr[mid]) {
high = mid - 1;
} else {
return mid;
}
}
if(abs(arr[low] - target) < abs(arr[high] - target)) {
return arr[low];
} else {
return arr[high];
}
}
תרגיל 2 - סעיף א׳
נתון מטריצה בפורמט מערך דו-מימדי של שלמים int** matrix עם מימדים int n, int m. נתון כי כל שורה במערך ממוינת. כתבו פונקציה המבצעת חיפוש בינארי במטריצה.
פתרון
אין לנו מידע מעבר לכך שכל המערכים במטריצה ממוינים, ולכן אין אופטימיזציות שניתן לעשות מעבר לחיפוש בינארי בכל אחד מהמערכים עד למציאה:
#define NOT_FOUND -1
int bsearch(int* arr, int n);
int bsearch_matrix(int** matrix, int n, int m, int target) {
for(int i = 0; i < n; i++) {
int index = bsearch(matrix[i], m, target);
if(index != NOT_FOUND) {
return index;
}
}
return NOT_FOUND;
}
תרגיל 2 - סעיף ב׳
כעת נתון כי מעבר לכך שכל שורה ממוינת, האיבר האחרון בכל שורה קטן/שווה מהאיבר הראשון בשורה אחריו. למשל:
$\begin{pmatrix} 1 & 2 & 3 \ 3 & 5 & 6 \ 7 & 8 & 9 \end{pmatrix}$
כתבו פונקציה המבצעת חיפוש בינארי אופטימלי במטריצה.
פתרון
כעת נתון כי השורות במטריצה ממוינות וכל שורה ממוינת ביחס לשורות מעליה ומתחתיה. כלומר, אם היינו ״משטחים״ את המטריצה לשורה אחת היינו יכולים לבצע חיפוש בינארי רגיל. ניתן לעשות זאת, אבל זה ידרוש העתקה של כל המטריצה וזכרון נוסף, ואין בכך צורך אמיתי. במקום זאת, נתייחס למטריצה כמערך ממוין אחד ארוך, מחולק לשורות. האתגר כעת מגיע מחישוב האינדקסים הנכונים בלי להתלבל במימדי המטריצה. למשל, עבור המטריצה בדוגמא, היינו רוצים לבצע חיפוש בינארי למטריצה ״מושטחת״ שנראת כך:
$\begin{pmatrix} 1 & 2 & 3 & 3 & 5 & 6 & 7 & 8 & 9 \end{pmatrix}$
נבחן את הבעיה מחדש:
- במטריצה יש
m*n - 1איברים. נמספר את כל האיברים במטריצה מ-0ל-mn - 1בהתאמה. - בהנתן אינדקס כלשהו
iבמערך ה-״מושטח״, הוא שייך לשורה ה-i / mולעמודה ה-i % m. ניתן לחשוב על זאת כך - בהנתןi, המספרi / mהוא מספר השורות המלאות שניתן להכניס ב-iמספרים, ו-i % mהוא השארית שיש להתקדם לתוך אותה שורה לאחר מכן. - נבצע את החיפוש הבינארי בצורה זהה למקרה המוכר, רק שאת השליפה של האיבר מהמערך נעשה בצורה מעט זהירה יותר עם האינדקסים הנכונים.
#define NOT_FOUND -1
int bsearch_matrix(int** matrix, int n, int m, int target) {
int low = 0, high = n * m - 1;
while (low <= high) {
int mid = (low + high) / 2;
int row = mid / m;
int col = mid % m;
int val = matrix[row][col];
if (val == target) {
return mid;
} if (val < target) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return NOT_FOUND;
}
תרגיל 3
בהנתן מספר שלם חיובי n, כתבו פונקציה שמחשבת את השורש שלו מעוגל לשלם הקרוב ביותר. על הפונקציה שלכם לבצע לכל היותר $c\log_2 n$ פעולות, כאשר $c$ קבוע חיובי כלשהו.
פתרון
ניתן בהתחלה לחשוב - מה הקשר בין השאלה הזו לחיפוש בינארי? הרמז נמצא בדרישה לביצוע לכל היותר $c\log_2n$ פעולות. ראינו כי חיפוש בינארי מבצע סקאלה של $\log_2n$ פעולות, ולכן יתכן שאפשר לבצע פה משהו שדומה לחיפוש בינארי. כיצד?
שאלות ותרגילים
- נתונה וריאציה על בעית החיפוש במערך הממוין - בהנתן מערך ממוין ומספר כלשהו
lower_bound, יש למצוא מספר כלשהו במערך שקטן מ-lower_bound. כתבו את הפונקציהbsearch_find_lower(int* arr, int n, int lower_bound)שפותרת את הבעיה. - נתון מערך
int* tempsבאורך שמדד את הטמפרטורה בכוס כל דקה לאורךnדקות. מצאו את הדקה הראשונה בה המים היו רותחים (כלומר, הטמפרטורה הייתה גדולה;שווה מ-100מעלות). - מה יחזיר אלגוריתם החיפוש הבינארי במידה וקיימים מספר מקרים של
targetבמערך נתון? - נתונה וריאציה על בעית החיפוש במערך ממוין - יש למצוא את האינדקס הראשון של המספר המבוקש. למשל, אם המערך הוא:
[1, 1, 3, 3, 5]
ורוצים למצוא את המספר 3, יש להחזיר את האינדקס 2 בלבד - האינדקס 3 הוא טעות. כתבו את הפונקציה bsearch_find_first(int* arr, int n) הפותרת את הבעיה.
- נתון מערך ממוין שהזיזו אותו בצורה ציקלית ב-
kאיברים. למשל, עבור המערך הבא:
[1, 2, 3, 4, 5]
והזזה ציקלית של k = 2, נקבל את המערך הבא:
[4, 5, 1, 2, 3]
כתבו פונקציה המבצעת חיפוש בינארי במערך שהוזז ציקלית ב-k איברים.
- נתון מערך ממוין של שלמים באורך
n - 1. נתון כי כל השלמים בין0ל-n - 1(כולל) מופיעים במערך, חוץ מאחד בודד שאינו מופיע. כתבו פונקציה שמוצאת את המספר שאינו מופיע.
תרגיל - מימוש מבנה נתונים
כעת רכשנו מספיק כלים בשביל לממש מבנה נתונים פשוט. בתרגיל נכתוב מערכת שמנהלת בית קולנוע - נוכל למכור כרטיסים, להוסיף צופים, להקרין סרטים לפי לוח זמנים וכו׳.
כל צופה שמזמין כרטיס מזין את הפרטים הבאים במערכת:
- תעודת זהות - מספר
intחיובי בין0ל-999, כולל. - שם - מחרוזת
*charשאורכה לכל היותרMAX_NAME_LEN, קבוע שערכו100. - חבילת הטבות - מספר
intשערכו מספר בין0ל-2. כל צופה מקבל אוכל ושתייה במזנון לפי חבילת ההטבות שבחר -0לא מקבל כלום,1מקבל פופקורן ו-2מקבל פופקורן ושתייה.
על המערכת להשתמש ב-id של כל צופה כמזהה למבנה הנתונים:
char* names;
int* package;
לדוגמא, מגיע צופה בשם "John" עם תעודת זהות 534 ורוכש חבילת הטבות 1. הקולנוע מעניק לו את המזהה 0, ובהתאמה מעדכן את מבני הנתונים:
int id = 534;
names[id ] == "John";
package[id] = 1;
דרישות למימוש
בתרגיל יש לממש את הפונקציות הבאות:
void theater_init(char** names, int* packages);
bool insert_viewer(char** names, int* packages);
void theater_close(char** names, int* packages);
שיפעלו לפי הדרישות הבאות:
theater_initתאתחל את מבנה הנתונים בכל דרך לבחירתכם.insert_viewerתקבל את מבנה הנתונים ותקרא עםscanfאת תעודת הזהות, השם וחבילת ההטבות של צופה חדש אחד, עםEnterביניהם. הפונקציה תחזירtrueהפונקציה תוודא שהערכים שהצופה החדש מכניס חוקיים, ולא תכניס אותו אם לא. הפונקציה לא תכניס צופה חדש אם אין לו מקום בקולנוע. הפונקציה תחזירtrueאם הצליחה להכניס את הצופה, ו-falseאחרת.theater_closeתשחרר את כל הזכרון הדינמי שהוקצה ותסיים את ריצת הקולנוע.
פתרון
הרצאה 7 - מחרוזות (strings)
אחרי שלמדנו להשתמש במערכים, נדגים הרבה ממה שלמדנו על סוג מיוחד של מערכים - מחרוזות. נזכיר כמה פרטים שראינו כבר בעבר:
- הטיפוס
charמייצג תו בודד, שגודלו בייט אחד (sizeof(char) = 1). - את הערך בזכרון שמכיל המשתנה אנחנו ממירים באמצעות טבלת ASCII לקבלת התו עצמו.
- ניתן לייצר מערך של תווים
*charבצורה דומה למערך של שלמים שפגשנו,*int.
כעת נדון במערכי תווים מסוג *char, שהרבה פעמים ישמשו אותנו בשביל לייצג טקסט. בדרך כלל כשהשתמשנו במערכים, העברנו ביחד עם המערך את האורך שלו - כמו למשל בחיפוש בינארי:
int binary_search(int* array, int len, int target);
לעומת זאת, במערכי תווים ננקוט בגישה שונה. במקום להעביר ביחד עם מערך תווים את האורך שלו, נסמן בתו מיוחד את סוף המערך. התו 0\ הוא בעל ערך ASCII של 0 ונקרא null terminator. נשתמש בו לטובת הסימון הזה:
הגדרה: מחרוזת היא מערך תווים []char שהתו האחרון בו הוא 0\.
למשל, המחרוזת “HelloWorld” בזכרון נראת כך:
ניתן להגדיר מחרוזות בכמה צורות שקולות:
- כמערך תווים:
char* str = {'H', 'e', 'l', 'l', 'o', '\0'};
- עם string literal:
char* str = "Hello";
- בכתיבה לכל איבר:
char str[6]; // string len is 5 + 1 fo
str[0] = 'H';
str[1] = 'e';
str[2] = 'l';
str[3] = 'l';
str[4] = 'o';
str[5] = '\0';
הערות:
- נשים לב שיש הבדל דק אבל חשוב שכעת אפשר להבין בנוגע להצהרה על משתני
charלעומת מחרוזות*char. הצהרה עם גרשיים יחידיים'x'היא על משתנהcharשמכיל תו, בעוד שעם גרשיים כפולים"x"היא על המחרוזת המתאימה:
char single_char = 'x'; // 1 byte
char* string = "x"; // pointer to {'x', '\0'}, 2 bytes
- המשמעות של הוספת תו הסיום
0\היא בשביל להכילnתווי מחרוזת יש להשתמש ב-n + 1בתים -nבשביל התווים עצמם ו-1עבור0\. - ניתן להדפיס מחרוזת עם
printfעם המזההs%:
#include <stdio.h>
char* str = "My favorite number is - ";
int fave;
scanf("%d", &fave);
printf("%s %d", str, fave);
מחרוזת היא מערך לכל דבר - ניתן לבצע עליו איטרציה:
char* str = "Learning computer science in C!";
for(int i = 0; str[i] != '\0'; i++) {
printf("str[i] = %c\n", str[i]);
}
דוגמאות
- כתבו פונקציה המקבלת מחרוזת ומחזירה את מספר האותיות הקטנות שבה.
int count_lowercase(char* str) {
int lowercase = 0;
for(int i = 0; str[i] != '\0'; i++) {
if(str[i] >= 'a' && str[i] >= 'z') {
lowercase++;
}
}
return lowercase;
}
- כתבו פונקציה שמחשבת אורך מחרוזת.
int strlen(char* str) {
int len = 0;
while(*str) {
len++;
str++; // Pointer arithmetic!
}
return len;
}
הערה: ניתן להשתמש בכתיבה while(*str) באופן שקול לכתיבה while(*str != '\0') מכיוון שערך ה-ASCII של 0\ הוא 0, ולכן ערכו בהמרה לבוליאני הוא false - כל תו אחר יקבל את הערך true.
<string.h>
בדומה לספריות אחרות שפגשנו כמו <stdlib.h> או <stdio.h>, קיימת ספריה בשם <string.h> שמכילה מספר גדול של פונקציות שימושיות בשימוש במחרוזות. כמה דוגמאות:
strcmp- פונקציה המשווה בין שתי מחרוזות ומחזירה 0 אם כל תו בהן זהה, אחרת ערך גדול מ-0.strcat- משרשרת שתי מחרוזות זו לזו.strcpy- מעתיקה את כל התווים ממחרוזת אחת לאחרת.
רוב הפונקציות בספריה פשוטות ואינטואיטיביות, וזה תרגיל מצוין לנסות לממש אותן בעצמכם.
תרגיל
נתונה מחרוזת str המורכבת מתווים ומספר אי-שלילי של רווחים. למשל:
"Hello world""i am learning ""5rasd33asd"""
כתבו את הפונקציה int last_word_len(char* str) שמחזירה את האורך של המילה האחרונה במחרוזת, כאשר כל מילה מופרדת מהאחרות על ידי מספר גדול מ-0. למשל, עבור הדוגמאות הבאות יוחזר:
- 5
- 8
- 10
- 0
פתרון
פתרון אינטואיטיבי יכול להיות לסרוק מתחילת המחרוזת לסופה למציאת המילה האחרונה. עם זאת, נשים לב שאין צורך להתחיל בתחילת המחרוזת אלא בסופה - כך נצטרך לסרוק רק את המילה האחרונה. נפעל לפי השלבים הבאים:
- נחשב את אורך המחרוזת בשביל להתחיל לסרוק את המילה מהסוף להתחלה.
- נדלג על רווחים שיתכן וקיימים בסוף המילה.
- נסרוק מימין לשמאל עד שנמצא רווח נוסף או את תחילת המחרוזת.
int strlen(char* str) {
int len = 0;
while(*str++) {
len++;
}
return len;
}
int last_word_len(char* str) {
int i = strlen(str) - 1; // step 1
while(str[i] == ' ') { // step 2
i--;
}
int word_len = 0;
while(i >= 0 && s[i] != ' ') { // step 3
i--;
word_len++;
}
return word_len;
}
תרגיל
נתונה מחרוזת letters ומחרוזת word המורכבות מאותיות קטנות באנגלית בלבד. כתבו פונקציה bool can_create_word(char* letters, char* word) שקובעת האם ניתן להרכיב את המחרוזת word מהאותיות במחרוזת letters בלבד, כאשר ניתן להשתמש בכל אות כמספר הפעמים שמופיעה ב-letters. אין חובה להשתמש בכל האותיות מ-letters.
לדוגמא, מהמחרוזת pudgy penguins ניתן להרכיב את המילים design ו-genius, אבל לא ניתן להרכיב את paparazzi.
פתרון
נפרק את השאלה שאנחנו שואלים. למעשה, אנחנו שואלים - האם עבור כל אות ב-word, מספר הפעמים שבה היא מופיעה ב-word היא לפחות מספר הפעמים שהיא מופיעה ב-letters?
מכיוון שמספר האותיות הקטנות באנגלית הוא סופי, נקצה מערך סטאטי שיספור את התדירות של הופעת כל אות ב-letters, ולאחר מכן נעבור על כל האותיות ב-word ו-״נחלק״ אותן.
bool can_create_word(char* letters, char* word) {
int letter_freq['z' - 'a' + 1] = {0};
for(int i = 0; letters[i]; i++) {
char letter = letters[i];
letter_freq[letter - 'a']++;
}
for(int i = 0; word[i]; i++) {
char letter = word[i];
if(--letter_freq[letter - 'a'] < 0) {
return false;
}
}
return true;
}
יש בפתרון כמה נקודות מורכבות:
- מספר האותיות הקטנות הוא:
מכיוון ש-
'z' - 'a' + 1'a'ו-'z'הן אותיות קטנות בפני עצמן. אי-הוספת ה-1היא טעות קלאסית הנקראת off-by-one וכדאי מאוד להזהר מהן ע“י בדיקה מדויקת של האלגוריתם. - הגדרנו מערך בו לכל אות קטנה יש מיקום בו נספרת התדירות שלו במחרוזת
letters. בשביל לקבל את המיקום של אות כלשהי במערך, חיסרנו את'a'ממנה - כך קיבלנו שהמקום של'a'במערך הוא0, של'b'במערך הוא1וכך הלאה. - לאחר מכן, בכל שלב “חילקנו” את התדירויות ב-
lettersל-wordע“י אופרטור ה---(לפני המשתנה!), ואז בדקנו האם “חרגנו” ע:“י השוואה ל-0.
תרגיל
ממשו את שתי הפונקציות הבאות:
int str_to_int(char* str);
char* int_to_str(int n);
הפונקציה הראשונה תקבל מחרוזת המורכבת מתווי ASCII של ספרות בלבד ([0-9]) ותחזיר את המספר שהן מייצגות - לדוגמא, המחרוזת "953" תחזיר את המספר 953. הפונקציה השנייה תקבל מספר ותחזיר מחרוזת תקינה המייצגת את המספר.
פתרון
הפונקציה הראשונה יחסית פשוטה למימוש. מחרוזות קוראים משמאל לימין, ולכן נתקל בהתחלה בספרה הגבוהה ביותר של המספר, ואז אחת אחריה וכו’. עבור כל ספרה נכפיל ב-10 בשביל להסיט את הספרה מקום אחד שמאלה, נהפוך כל תו ASCII במחרוזת למקביל המספרי שלו ע“י חיסור של התו '0' ואז נוסיף את הספרה עצמה. נשים לב למקרה קצה חשוב - אם המספר שלילי, התו הראשון הוא - ויש לטפל בכך!
#include <stdbool.h>
int str_to_int(char* str) {
int n = 0;
int sign = str[0] == '-' ? -1 : 1;
int i = 0;
if(sign == -1) {
i++;
}
while(str[i]) {
n = n * 10 + (str[i++] - '0');
}
return n * sign;
}
הפונקציה השנייה מעט מורכבת יותר למימוש. נפעל לפי השלבים הבאים:
- נתחיל במציאת מספר הספרות במספר. נעשה זאת ע“י חלוקת ב-
10עד שנגיע ל-0- מכיוון שחלוקתint-ים ב-C מעגלת כפי מטה, נקבל בדיוק את מספר הספרות. נזהר להשתמש ב-do-whileבמקום ב-whileעל מנת שעבור המספר0עדיין נקבל אורך של1לפחות. - נקצה מחרוזת באורך שקיבלנו ועוד
1עבור ה-null terminator. נכתוב לסוף המחרוזת את ה-null terminator. - כעת, מכיוון שמחרוזת קוראים משמאל לימין, נסרוק את המספר בצורה הפוכה. בכל שלב ניקח את המודולו לחלוקה ב-
10על מנת לחלץ את הספרה עצמה ונוסיף לכך'0'בשביל להמיר ממספר לתו ASCII. לאחר זאת נחלק את המספר ב-10בשביל לקדם בספרה אחת את האיטרציה.
#include <stdlib.h>
#include <stdbool.h>
char* int_to_str(int n) {
int tmp = n, len = 0;
bool negative = n < 0;
do {
len++;
tmp /= 10;
} while(tmp);
int total_len = len + (negative ? 1 : 0);
char* s = malloc(total_len + 1);
if(!s) {
return NULL;
}
s[total_len] = '\0';
int abs_n = negative ? -n : n;
for (int i = total_len - 1; i >= negative; i--) {
s[i] = (abs_n % 10) + '0';
abs_n /= 10;
}
if (negative) {
s[0] = '-';
}
return s;
}
תרגיל מסכם - כתיבת ספריית מחרוזות
מהי ספרייה?
בתרגיל זה נכתוב ספריית קוד לטיפול במחרוזות. פגשנו ספריות אחרות בשפת C - למשל <stdlib.h> או <stdio.h>. בצורה הפשוטה ביותר, ניתן להגדיר ספרייה בשפת C בתוך קוד שאינו מכיל פונקציית main - כלומר, הקוד בספריה אינו מיועד לשימוש בפני עצמו, אלא כחלק מקטע קוד אחר. לדוגמא, הפונקציה printf המופיעה ב-<stdio.h> היא בדיוק כזו - זו לא תוכנית בפני עצמה, אלא פונקציה שמטרתה להיות בשימוש בקטעי קוד אחרים, כפי שראינו לאורך הקורס.
טסטים ובדיקות
הצורה בה מממשים וכותבים ספרייה הוא מעט שונה ממה שראינו עד כה. במקום לכתוב תוכנית ראשית ופונקציות נוספות שתומכות אותה, המטרה היא לכתוב פונקציות כלליות שמבצעות משימה ספציפית. עם זאת, בתהליך בדיקת הקוד שלנו, רצוי מאוד דווקא כן לכתוב תוכנית ראשית שמוודאת שהפונקציות שלנו אכן מבצעות את העבודה שיעדנו להן.
לדוגמא, נניח שנרצה לכתוב פונקציה שמקבלת ספרה [0-9] ומדפיסה את ערך ה-ASCII שמתאים לה. ניתן לעשות זאת כך (שימו לב לבאגים!)
int ascii_value(int digit) {
return digit + '0';
}
יתכן ואנחנו מרוצים מהפונקציה שלנו, ויתכן שלא (רמז - לא בהכרח). צעד נוסף קלאסי בתכנות היא כתיבת תוכנית בדיקה, שבוחנת את התנהגות הפונקציה שלנו:
int ascii_value(int digit) {
return digit + '0';
}
#define N_TEST_CASES 5
int main() {
int test_cases[] = {0, 1, 5, 12, -1};
for(int i = 0 ; i < N_TEST_CASES; i++) {
int test = test_cases[i];
printf("ascii_value(%d) = %d", test, ascii_value(test));
}
}
זוהי דוגמא פשוטה לאחת הקונספטים החשובים ביותר בתכנות - טסטים! טסטים היא הדרך הטובה ביותר לבדוק שהקוד שלנו עושה מה שאנחנו מצפים שהוא יעשה. הרעיון הוא פשוט - נריץ את הפונקציה שלנו ונשווה את התוצאות שקיבלנו לתוצאות הצפויות. אם הן שוות, מעולה - אחרת, נתקן את הקוד שלנו עד שנצליח. טסט טוב הוא טסט שמכסה גם את המקרים הקלאסיים עליהם חשבנו כשכתבנו את הפונקציה והיא נבנתה בשביל לטפל בהם, אבל לא רק - הוא צריך להכיל מקרי קצה שונים ומשונים שלא בהכרח חשבנו עליהם בהתחלה, אבל כדאי שהפונקציה שלנו תדע לטפל בהם.
לדוגמא, בקטע הקוד הזה אנחנו בודקים את הפונקציה עבור 0, 1, 5 - אלו אכן המקרים להם ציפינו, בהם מתקבל ספרה בודדת ואי-שלילית. עם זאת, המקרים הנוספים שונים מאוד - מה כדאי שהפונקציה שלנו תעשה כאשר היא מקבלת מספר בעל 2 ספרות, או מספר שלילי? כמובן שערך ה-ASCII של 12 אינו 12 + '0' - זהו התו > בכלל!
לכן, יתכן שבעקבות הטסטים נשנה את הפונקציה שלנו באופן הבא:
#define INVALID_DIGIT -1
int ascii_value(int digit) {
if(digit < 0) {
printf("passed negative number, no ASCII value - two chars!");
return INVALID_DIGIT;
}
if(digit / 10 > 0) {
printf("passed more than 1 digit, no ASCII value");
return INVALID_DIGIT;
}
return digit + '0';
int use_ascii_value(int digit) {
// Some code ...
int val = ascii_value(digit);
if(val == INVALID_DIGIT) {
printf("Could not use ascii_value with %d", digit);
return INVALID_DIGIT;
}
// ... some more code that uses val
}
וכעת הפונקציה שלנו הרבה יותר עמידה בפני תקלות ובעיות. מעבר לכך, היא אינפורמטיבית - היא מספרת לכל מי שקורא לה בדיוק מה קרה במידת ומתקיימת שגיאה.
ספריית מחרוזות
כעת נבחן את רשימת הפונקציות שיש לממש. בכל הפונקציות ניתן להניח שקיים למחרוזות הניתנות null terminator (כלומר, הן מחרוזות חוקיות).
int strlen(char* str)
פונקציה זו תחזיר את מספר התווים במחרוזת, לא כולל ה-null terminator.
לדוגמא, עבור המחרוזת char* s = {'a', 'b', 'c', '\0'} יוחזר הערך 3.
על המימוש לעמוד בסיבוכיות זמן של $\Theta(n)$ וסיבוכיות מקום $\Theta(1)$, כאשר $n$ הוא אורך המרחוזת.
int strcmp(char* str1, char* str2)
פונקציה זו תשווה בין שתי מחרוזות. אם בכל אינדקס התווים משני המחרוזות זהים, הפונקציה תחזיר 0 - אחרת, הפונקציה תחזיר את ההפרש בין שני התווים הראשונים ששונים. במידה ואחת המחרוזות קצרה מהשנייה, כל תו ״חסר״ במחרוזת זו יחשב כתו 0\ עם ערך 0.
לדוגמא, עבור המחרוזות "char* str1 = "abc ו-"char* str2 = "abc יוחזר 0, בעוד שעבור "char* str1 = "abc ו-"char* str2 = "a יוחזר 'b' - '\0'.
על המימוש לעמוד בסיבוכיות זמן של $\Theta(k)$ וסיבוכיות מקום $\Theta(1)$, כאשר $k$ הוא אורך המחרוזת המינימלי מבין שתי המחרוזות.
int strcat(char* dst, char* src)
פונקציה זו תשרשר את המחרוזת src לקצה המחרוזת dst כך שתתקבל מחרוזת אחת ב-dst. המחרוזת תחזיר את אורך המחרוזת dst לאחר השרשור. ניתן להניח ש-dst ו-src הן מחרוזות חוקיות וכי יש מספיק מקום במערך ה-char ש-dst מצביע אליו בשביל להכיל את src בנוסף.
לדוגמא, עבור המחרוזות "char* s1 = "abc ו-"char* s2 = "xyz, קריאה ל-strcat(s1, s2) תחזיר 6 והמחרוזת s1 תכיל את התווים הבאים בתחילתה:
s1 = {'a', 'b', 'c', 'x', 'y', 'z', '\0'}
על הפתרון לעמוד בסיבוכיות זמן של $\Theta(k)$ וסיבוכיות מקום של $\Theta(1)$, כאשר $k$ הוא אורך המחרוזת המקסימלי מבין שתי המחרוזות.
char* strstr(char* str, char* substr)
פונקציה זו תחזיר מצביע למקרה הראשון בו מופיע substr ב-str, ו-NULL במידה ואין מקרה כזה.
הרצאה 8 - מיון
עשינו המון עד כה! להכניס רשימה של כל הנושאים
בהרצאה זו, בניגוד להרצאות קודמות, לא נפגוש אלמנט חדש של השפה -למעשה פגשנו כבר רוב מוחלט של הסינטקס הקיים בשפת C. כעת, נשתמש בכל הידע והטכניקות שלמדנו בשביל לפתור בעיה קלאסית במדעי המחשב - מיון (sorting). המטרות יהיו:
- להכיר וללמוד לממש מספר אלגוריתמים שונים שיפתרו את הבעיה
- שימוש בכל הכלים שלמדנו עד כה בצורה נכונה
- ניתוח של האלגוריתמים - איזה אלגוריתם טוב יותר או פחות, ולמה? נעשה זאת לפי עקרון הסיבוכיות, אותו נלמד בהרצאה הבאה.
כפי שנפגוש מיד, מיון היא בעיה קלאסית וחשובה במדעי המחשב - היא פשוטה להבנה, שימושית מאוד ומופיעה באינספור בעיות אחרות. נשתמש בה כמעין דוגמא מסכמת לכל מה שלמדנו, בה נשתמש בכל הכלים שהכרנו עד כה וננתח בעזרתה כדאיות של שימוש במימושים ואלגוריתמים.
בעיית המיון
הגדרה: נתון מערך של איברים arr מסוג כלשהו באורך n. נקרא למערך ממוין אם לכל שני איברים עוקבים במערך a[i] ו-a[i + 1] (כאשר i ו-i + 1 בין 0 ל-n - 1) מתקיים a[i] <= a[i + 1].
ההגדרה די אינטואיטיבית - מערך ממוין הוא מערך בו האיברים מסודרים בסדר עולה - או ליתר דיוק, לא יורד. מערך int ממוין למשל יכול להיות:
1, 3, 3, 7, 25, 25, 100
בעוד שמערך char ממוין יכול להיות:
'+', '0', '5', 'Q', 'q', 'q' '~'
בקורס נתעסק בעיקר במיון מערכי int לפשטות, אבל כפי שלמדנו, מערכי char הם גם רק מספרים שאנחנו ממפים באמצעות ASCII לתווים - המיון שלהם נעשה בדיוק באותה צורה.
בשביל לקבל תחושה למורכבות הבעיה, נסו למיין בעצמכם מערך - ללא מחשב או קוד, אלא על דף נייר או בראש. קחו לדוגמא את המערך הבא:
3, 100, 1, 3, 25, 7, 25
זהו המערך מהדוגמא הקודמת, רק מבולגן מעט. לנסות לפתור בעיה במדעי המחשב בעצמכם לפני מעבר לקוד ומתן הוראות למחשב היא דרך קלאסית למצוא אלגוריתמים פשוטים - לתת למחשב לחקות את המעשים הפשוטים שלנו.
מיון “אינטואיטיבי”
הנה דרך אינטואיטיבית שבה אני עבדתי. כתבתי על נייר את המערך ועוד עותק ריק שלו:
חיפשתי את האיבר המקסימלי ברשימה - זה 100, שמתי אותו במקום האחרון:
סימנתי גם את ה-100 באדום כדי שיהיה ברור שהוא כבר מוין. המשכתי לאיבר המקסימלי הבא שהוא לא 100 - יש לנו שני איברי 25 ולכן לא משנה איזה ניקח, למשל את הראשון:
וכך הלאה עד למערך הממוין. פשוט סה“כ, נכון? לנו כבני אדם לחלוטין. כעת, ננסה לפרק את האלגוריתם הפשוט שהגדרנו לפתרון הבעיה להוראות שהמחשב ידע לעקוב אחריהן:
- ראשית אנחנו צריכים, בנוסף למערך הנתון, מערך נוסף לשבץ בתוכו את התוצאות. נשתמש ב-
mallocאו נקצה סטאטית מערך נוסף בשביל זה. - צריך כעת למצוא את האיבר המקסימלי במערך - נעבור על כל המערך ונחפש אותו. נצטרך לעבור ממש על כל האיברים מכיוון שתמיד יתכן שהאיבר המקסימלי יהיה בסוף (כפי שקרה באיטרציה השנייה שלנו).
- נצטרך לסמן באיזשהי דרך את המספרים שכבר “הוצאנו” מהמערך המקורי למערך המתמיין. אנחנו לא יכולים פשוט “לצבוע” את המערך - אפשר למשל להקצות מערך נוסף של משתנים
boolבשםsortedהמאותחל ל-false- כאשרsorted[i] == trueאם כבר הוצאנו את האיבר ה-iמהמערך המקורי. אם מובטח לנו למשל שהמערך חיובי בלבד, אפשר למשל לסמן איברים שהוצאנו ב-1-ובכך לייתר את הקצאת המערך הנוסף. - אם נניח ש-
nהוא אורך המערך, לאחרnאיטרציות המערך החדש יהיה ממוין. נצטרך להחזיר את המערך הממוין ולוודא שיודעים שצריך להשתמש בו ולא במערך הקודם.
מורכב יותר מהצורה האינטואיטיבית שבה הגענו למימוש בעצמנו! בעיות שמרגישות פשוטות לפעמים קשות לתרגום לשפת תכנות. כעת נפגוש מספר אלגוריתמים פשוטים יותר שפותרים את בעיית המיון בדרך שיותר מותאמת לצורה שבה מחשב עובד ולא בני אדם.
Max Sort
הבעיה העיקרית במימוש האינטואיטיבי שניסינו הייתה שצריך להקצות מערכים נוספים להתסבך עם בדיקות איתם. אולי אפשר לקחת את הקונספט של בחירת האיבר המקסימלי כל פעם ולשדרג אותו מעט?
Max Sort הוא אלגוריתם מיון שעושה בדיוק את זה. במקום להכניס את האיבר המקסימלי למערך נוסף, ניקח את האיבר המקסימלי ונחליף אותו עם האיבר במקום הנכון. נראה דוגמא - נחזור למערך שהתחלנו איתו:
נעבור איבר-איבר ונזהה את האיבר המקסימלי - זהו 100 ולכן מקומו במערך הממוין הוא האיבר האחרון. נחליף בינו לבין האחרון:
כעת נרצה למצוא את האיבר המקסימלי השני, זה שקטן/שווה ל-100. נשים לב שמובטח לנו ש-100 נמצא במקום הנכון, ולכן אין צורך להסתכל על האיבר האחרון במערך, ויש להחליף הלפני אחרון במערך. נקבל ש-25 במקום ה-1 (מתחילים לספור מ-0) צריך להתחלף עם 7 במקום הלפני אחרון:
נשים לב שקיימים שני 25 במערך, ולכן אין מניעה שהיינו בוחרים גם את השני מביניהם להחלפה. נמשיך כך עד שהמערך יהיה ממוין!
כעת נרצה לממש בקוד מיון את האלגוריתם. נכתוב במילים את השלבים שינחו אותנו במימוש:
- יש לקבל כפרמטרים למימוש מצביע למערך
int* arrואורכוint n. - יש למיין את ה-
lenאיברים הראשונים של המערך בכל איטרציה, כאשרlenמאותחל לאורך המערךnויורד ב-1בכל איטרציה. - נמצא את האיבר המקסימלי מתוך
lenהאיברים הראשונים ונחליף אותו עם האיבר ה-lenבמערך.
עבור שלב 2 נכתוב פונקציה שמוצאת את האינדקס של האיבר המקסימלי מתוך ה-len איברים הראשונים, ועבור שלב 3 נשתמש בפונקציית ה-swap שלמדנו לממש:
void swap(int* a, int* b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
int get_max_index(int* arr, int len) {
int max_index = 0;
for(int i = 1; i < len; i++) {
if(arr[i] > arr[max_index]) {
max_index = i;
}
}
return max_index;
}
void max_sort(int* arr, int n) {
for(int len = n; len > 0; len--) {
int max_index = get_max_index(arr, len);
swap(&arr[len - 1], &arr[max_index]);
}
}
הערה: קיים אלגוריתם מקביל ל-Max Sort בשם Min Sort, שעובד בצורה דואלית ע“י מציאת האלמנט המינימלי בכל שלב.
Bubble Sort
מיון בועות הוא דוגמא נוספת לאלגוריתם פשוט למימוש למיון. הרעיון הוא כזה - נעבור על כל המערך, איבר אחר איבר, ונשווה בין כל שני איברים סמוכים. אם הם לא ממוינים - כלומר, a[i - 1] > a[i] - נחליף ביניהם. כך איברים גדולים “יבעבעו” כלפי מעלה. כאשר לא נשארו איברים לבעבע, נדע שסיימנו.
נדגים על המערך הקודם - זוהי הצורה ההתחלתית שלו:
באיטרציה הראשונה 3 ו-100 בסדר הנכון ולכן לא נחליף ביניהם, ואז:
וכך הלאה, כאשר 100 מבעבע עד הסוף באיטרציה הראשונה:
כלומר, בסוף האיטרציה הראשונה האיבר המקסימלי יהיה בסוף, בדומה ל-Max Sort - כעת יש לבעבע רק את ה-n - 1 איברים שנשארו.
נממש:
#include <stdbool.h>
void swap(int* a, int* b);
void bubble_sort(int* arr, int n) {
bool sorted = true;
int len = n;
while(len > 0 && !sorted) {
for(int i = 1; i < n; i++) {
if(arr[i - 1] > arr[i]) {
swap(&arr[i - 1], &arr[i]);
sorted = false;
}
}
}
}
תרגילים
- נתונה חתימת הפונקציה וההגדרות:
#define ASCENDING 0
#define DESCENDING 1
void hybrid_max_sort(int* arr, int n, int direction);
כאשר הפרמטר direction קובע אם הפונקציה ממיינת עם Max Sort את האיברים בסדר עולה או בסדר יורד. ממשו את הפונקציה.
- מיון Bidirectional Bubble Sort הוא וריאציה של Bubble Sort בו לאחר ביצוע איטרציה עולה לאורך המערך ובעבוע איברים גדולים כלפי מעלה, מתבצעת איטרציה יורדת לאורך המערך ובעבוע איברים קטנים כלפי מטה. ממשו את
void bidir_bubble_sort(int* arr, int n). - נתון מערך
int* arrבאורך2nכאשרnמספר שלם חיובי. כתבו פונקציה המקבלת מערך כזה ואורכו הממיינת בסדר עולה את המספרים באינדקסים הזוגיים ובסדר יורד את המספרים באינדקסים האי-זוגיים. - כתבו פונקציה המקבלת מערך ואורכו וממיינת את המספרים לפי ערכם המוחלט.
- כתבו פונקציה המבצעת מיון לבחירתכם ומחזירה את התוצאה במערך חדש, תוך כדי שהיא לא משנה את המערך המקורי ולא מבצעת העתקות מיותרות בין המערך המקורי לחדש.
- כתבו פונקציה המקבלת מטריצה, מספר שורותיה ומספר עמודותיה וממיינת כל עמודה בסדר עולה.
תכונות אלגוריתמי מיון
עכשיו שהכרנו מספר פתרונות לבעיה, נבצע עצירה קצרה בסיור שלנו בעולם אלגוריתמי המיון בשביל לדון בתכונות השונות לפיהן ניתן לסווג אלגוריתמים. לכל אלגוריתם יש יתרונות וחסרונות, עם סיבות להשתמש בו במקרים מסוימים או לוותר עליו.
יציבות
הגדרה: מיון יציב הוא מיון בו הסדר בין אלמנטים שווים במערך הלא ממוין נשמר לאחר המיון.
נבחן למשל את המערך המיוחד הזה:
במקרה זה, כל איבר במערך מכיל שני “שדות” - מספר ואות. בתכנות C מעט מתקדם יותר ניתן לייצר משתנים עם מספר שדות הנקראים Structs (דומים ל-Classes משפות תכנות אחרות). נניח כעת שנרצה למיין את המערך לפי המספר של איבר. כאשר נבצע למשל Bubble Sort נקבל את השלבים הבאים:
אם נסמן ב-i(x) האינדקס של איבר x במערך, נשים לב שמתקיים i(2a) < i(2b) גם במערך המקורי וגם במערך הממוין. זו דוגמא לכך ש-Bubble Sort הוא באופן כללי מיון יציב.
שאלה: איזה שינוי קטן ניתן לבצע ב-Bubble Sort שלא ישבור את האלגוריתם (כלומר, האלגוריתם עדיין יבצע מיון) אבל יהפוך אותו ללא יציב?
In Place
הגדרה: אלגוריתם מיון נקרא In Place אם הוא מבצע את המיון על גבי המערך המקורי ללא הקצאת מערך נוסף.
Bubble Sort ו-Max Sort, שני האלגוריתמים שפגשנו עד כה, הם In Place - מיד נפגוש אלגוריתמים נוספים שמשתמשים בזכרון נוסף בשביל להאיץ את תהליך המיון.
סיבוכיות זמן ומקום (Time & Space Complexity)
השארנו את התכונות המעניינות ביותר של האלגוריתמים לסוף - כמה הם יעילים. באופן כללי יש שני משאבים שאלגוריתמים צורכים מאיתנו ונבדלים זה מזה בהם:
- זמן: כמה זמן מעבד נדרש בשביל לבצע את האלגוריתם. באופן כללי נרצה שהזמן שלוקח אלגוריתם יהיה מינימלי, על מנת לסיים את המשימה כמה שיותר מהר ולהתפנות למשימות אחרות.
- זכרון: כמה זכרון נדרש בשביל לבצע את האלגוריתם. באופן כללי, זכרון הוא משאב מוגבל, ולכן נרצה שהזכרון שלוקח אלגוריתם יהיה מינימלי.
בהרצאה הבאה נלמד לעומק כיצד מנתחים בצורה מתמטית ומדויקת את צריכת הזמן והזכרון של אלגוריתמים שונים לפי תורה הנקראת סיבוכיות (complexity). סיבוכיות זמן וזכרון תהיה לרוב הגורם המכריע לפיו נבחר באלגוריתם כזה או אחר.
דוגמא: Bogosort הוא אלגוריתם מיון בו מגרילים רנדומלית לכל איבר במערך מקום חדש, ואז בודקים האם המערך ממוין. למעשה, “מערבבים” את המערך כמו חפיסת קלפים ומקווים שהוא יהיה ממוין. זהו כמובן אלגוריתם לדוגמא בלבד, ללא שימושים פרקטיים - עם זאת, ניתן אינטואיטיבית להבין כי סיבוכיות הזמן שלו תהיה משמעותית גדולה יותר מאשר של Max Sort או Bubble Sort.
מיון מיזוג (Merge Sort)
נמשיך בסקירת אלגוריתמי המיון עם מיון מיזוג. זהו אלגוריתם משמעותית יותר מורכב מהשניים שפגשנו עד כה, אבל בהרצאה הבא נראה שסיבוכיות הזמן שלו טובה יותר משל האלגוריתמים שפגשנו עד כה. תחילה, נדון בתת בעיה פשוטה יותר ונשתמש בפתרון שלה לטובת המיון.
מיזוג מערכים ממוינים
נניח שנתונים שני מערכים קלט ממוינים arr1, arr2. נרצה לקבל מערך פלט שלישי ממוין arr3 שמכיל את כל האיברים בשני המערכים. אין חובה שהמערכים יהיו באותו האורך. לדוגמא:
כיצד נעשה זאת?
- נחזיק אינדקס לכל אחד משלושת המערכים - שני אינדקסים לקריאה משני מערכי הקלט ואינדקס לכתיבה למערך הפלט.
- בכל שלב נשווה בין האיברים משני מערכי הקלט, נכתוב את המינימלי למערך הפלט ונקדם את האינדקס של המערך שלקחנו.
- לאחר שנגמרו האיברים באחד המערכים, נכתוב ישירות את האיברים שנשארו בשני למערך הפלט.
נסמן את האינדקסים בהתאמה ב-i1, i2, i3. נתאחל את כולם ל-0:
נשווה בין שני האיברים הראשונים. 9 < 4 ולכן נכתוב אותו ראשון ונקדם את האינדקסים i1 ו-i3:
כעת יש מספר איברים שווים - ניתן לקחת אותם למערך הפלט בכל סדר לבחירתנו, עד שנגיע למצב הבא:
נכניס את 10 מ-arr2, את 11 מ-arr1 ואז נוותר ללא איברים ב-arr1. נותר עוד איברים ב-arr2 - נכניס את 15 מ-arr2 ונקבל:
מימוש מיזוג מערכים בקוד
האלגוריתם שלנו עובד! נכתוב מימוש שלו - נניח שאנחנו מקבלים את שלושת המערכים כקלט ואת אורכם של השניים הראשונים:
void merge(int* arr1, int len1, int* arr2, int len2, int* arr3) {
for(int i1 = 0, i2 = 0, i3 = 0; i1 < len1 && i2 < len2; i3++) {
if(arr1[i1] < arr2[i2]) {
arr3[i3] = arr1[i1++];
} else {
arr3[i3] = arr2[i2++];
}
}
// Copy leftover elements from either array
while(i1 < len1) {
arr3[i3++] = arr1[i1++];
}
while(i2 < len2) {
arr3[i3++] = arr1[i2++];
}
}
מיון מיזוג
כעת נראה כיצד ניתן להשתמש במיזוג מערכים ממוינים לטובת מיון. זה נשמע קצת כמו הגדרה מעגלית - בשביל למזג אנחנו צריכים מראש למיין, אז כיצד נשתמש במיזוג בשביל מיון? נשים לב לעובדה חשובה - מערכים באורך 1 הם בהגדרה ממוינים. נוכל להשתמש בעובדה זו באופן הבא - נתחיל במיזוג של “מערכים” באורך 1 זה עם זה, עד שכל המערך יורכב מרצף של מערכים ממוינים באורך 2. נמזג את כל המערכים באורך 2 עד שכל המערך יורכב מרצף של מערכים ממוינים באורך 4. כך נמשיך במיזוג עד שנקבל מערך אחד ממוין.
לפשטות, נניח שאורך המערך הוא חזקה של 2, n = 2^k. המימוש של האלגוריתם מעט מורכב, ביחוד החלק של חישוב האינדקסים בצורה מדויקת. מכיוון ש-malloc יכולה להכשל, נגדיר שהמיון מחזיר false אם היא נכשלה, אחרת true, ולא נשכח לשחרר את הזכרון:
#include <stdlib.h>
#include <stdbool.h>
void merge(int* arr1, int len1, int* arr2, int len2, int* arr3);
int merge_sort(int* arr, int n) {
int* tmp_arr = (int*)malloc(sizeof(int) * n);
if(!tmp_arr) {
return false;
}
for (int len = 1; len < n; len *= 2) {
for (int i = 0; i < n; i += 2 * len) {
merge(arr + i, len, arr + i + len, len, tmp_array);
for (int j = 0; j < 2 * len; j++) {
arr[i + j] = temp_array[j];
}
}
}
free(tmp_arr);
return true;
}
נפרק את שלבי האלגוריתם בצורה מסודרת:
- נתחיל בהקצאת עבור המערך לתוכו נבצע מיזוג.
- הלולאה החיצונית מגדירה איטרציות על גדלי מערך הולכים וגדלים - 1, ואז 2, ואז 4, וכך הלאה.
- הלולאה הפנימית מבצעת איטרציות על כל תתי המערכים שיש למזג. למשל, כשאורך המערכים אותם ממזגים הוא 2,
iמקבל את הערכים 0, 4, 8 וכך הלאה. - המערכים הנשלחים ל-
mergeנכתבים באריתמטיקת מצביעים. למשל, כשאורך המערכים אותם ממזגים הוא 2, מתחילים בשליחה שלarrו-arr + 2, כאשר המיזוג מתבצע לתוך המערך הזמני. - בסיום המיזוג מעתיקים את האיברים הרלוונטים חזרה למערך המקורי.
נבחן דוגמא:
נתחיל במיזוג מערכים באורך 1 למערכים באורך 2, כאשר מיד לאחר שלב זה נעתיק את התוצאה מהמערך הזמני למקורי:
נמשיך במיזוג מערכים באורך 2 למערכים באורך 4:
נסיים במיזוג שני המערכים באורך 4 למערך אחד באורך 8:
וקיבלנו מערך ממוין!
הערות:
- ההנחה כי אורך המערך הוא חזקה של 2 נעשת כאן לפשטות על מנת להמנע ממקרי קצה, אבל ניתן לספק אותה מעשית בצורה פשוטה - נרחיב כל מערך לחזקה הקרובה ביותר של 2 אליו, ו-“נרפד” את הקצוות המורחבים באיברים לא רלוונטיים למיון (למשל
INT_MAX). - לפעמים נהוג ללמד את השלב של ההעתקה מהמערך הזמני חזרה למערך שלנו עם
memcpy, פונקציה המעתיקה מכתובת אחת בזכרון לאחרת. חתימת הפונקציה היאvoid* memcpy(void* dst, void* src, size_t count)- שני מצביעים ליעד והמקור, ומספר הבתים שיש להעביר (דומה ל-malloc). כך ניתן להחליף את הלולאה הפנימית ביותר עם השורה הבאה:
memcpy(arr + i, tmp_arr, 2 * len * sizeof(int));
תרגילים ושאלות
- כתבו מימוש לפונקצית
merge_descendשממזגת שני מערכים ממוינים בסדר יורד למערך אחד ממוין בסדר יורד. חתימת הפונקציה תהיה:
void merge_descend(int* arr1, int len1, int* arr2, int len2, int* arr3);
- קבעו האם Merge Sort הוא אלגוריתם מיון יציב או לא.
- נתונה הפונקציה הבאה:
int* merge_array_list(int** array_list, int array_list_len, int* arrays_len);
כאשר arrays_list הוא מערך של מערכים ממוינים שמספר המערכים בו הוא arrays_list_len, והאורך של המערך array_list[i] הוא arrays_len[i]. כתבו פונקציה הממזגת את כל המערכים למערך אחד שיוקצה דינמית ויוחזר מהפונקציה.
הרצאה 9 - סיבוכיות זמן ומקום (Time & Space Complexity)
הרצאה זו תהיה מעט שונה מהקודמות - לא נלמד בה סינטקס שימושי, נפגוש בעיות חדשות או נפתח אלגוריתמים לפתרון שלהם. בהרצאה נצלול לעולם הסיבוכיות, ונרכוש כלים לניתוח האלגוריתמים אותם אנחנו כותבים, בדגש על צריכת המשאבים שלהם.
ההרצאה תדרוש מאיתנו מספר כישורים מתמטיים שנזכיר בקצרה לפני, והיא יכולה להרגיש “זרה” ולא קשורה בדיוק לשאר נושאי הקורס. אנחנו נסביר את הכל מההתחלה ועד הסוף ולא נוותר על אף פרט, אבל חשוב להדגיש שהיא דורשת מעט פתיחות מחשבתית - זהו נושא חדש לחלוטין עם אוריאנטציה הרבה יותר מתמטית משאר הקורס.
באופן כללי, אלגוריתמים צורכים שני משאבים עיקריים:
- זמן: אם נפתח שעון בתחילת האלגוריתם ונסגור אותו בסופו, כמה זמן יעבור?
- זכרון: אם נמדוד את כמות הבתים שהאלגוריתם מקצה לטובת ריצתו, כמה נקבל?
נפתח דרך שיטתית להשוות בין צריכת המשאבים הללו בין אלגוריתמים שונים.
Big-O Notation
בחלק זה נגדיר בצורה מתמטית מדויקת מספר מושגים שישמשו אותנו לטובת הגדרת הסיבוכיות - בהמשך נראה כיצד נוכל להשתמש במושגים אינטואיטיביים יותר.
ראשית, נרצה לבודד אינוריאנטים בין אלגוריתמים שונים - דברים שאינם משתנים בין אם נשתמש באלגוריתם A או B. הקבוע המשמעותי ביותר הוא גודל הקלט שהאלגוריתם צריך לעבד - למשל, נזכר בדוגמא של חיפוש במערך ממוין:
int find_in_array(int* arr, int n);
בין אם נפעיל חיפוש בינארי או חיפוש איטרטיבי במערך, גודל המערך זהה בין שני האלגוריתמים.לכן, נרצה להביע את צריכת המשאבים של האלגורימים כפונקציה של גודל הקלט, אותו נסמן בדרך כלל ב-$n$. למעשה, לכל אלגוריתם קיימת פונקציה $f(n)$ שמתארת את זמן הריצה שלו בהנתן גודל קלט מסוים. נסיק כמה מסקנות לגביה:
- היא חיובית - כל אלגוריתם לוקח לפחות צעד אחד.
- היא מונוטונית לא יורדת - כאשר $n$ גדל, מספר הצעדים לא יכול לקטון.
- היא משתנה בין מחשב למחשב - מחשב חזק יותר יכול לבצע יותר פעולות ליחידת זמן ממחשב חלש יותר.
נקודה 3 בעייתית מבחינתנו. אנחנו לא רוצים להשוות בין כל מחשב לכל מחשב אחר - לכן הניתוח שלנו יתמקד באותם אינוריאנטים שלא משתנים ממחשב למחשב. בנקודות 1-2 נוכל להשתמש בצורה חכמה יותר.
חסם עליון
הגדרה: יהיו שתי פונקציות חיוביות ומונוטוניות לא יורדות \( f(n), g(n) \). נסמן כי \( f(n) = O(g(n)) \) אם קיים \(c>0\) ו-\( N \) שלם כך שלכל \(n>N\) מתקיים \( c \cdot g(n) \ge f(n) \).
אינטואיטיבית, ההדרה אומרצ
הגדרה: יהיו שתי פונקציות חיוביות ומונוטוניות לא יורדות $f(n), g(n)$. נסמן כי $O(g(n)) = f(n)$ אם קיים $0<c$ ו-$N$ שלם כך שלכל $N<n$ מתקיים $g(n) \cdot c \ge f(n)$.
אינטואטיבית, ההגדרה אומרת שהחל ״ממקום מסוים״, $g(n)$ תמיד גדולה מ-$f(n)$. בלשון הדיבור, אומרים ש-“$f(n)$ היא O (האות O) של $g(n)$״.
לדוגמא, עבור:
$f(n) = 4n^2 + 3n \log n + 1$
אפשר להראות כי:
$f(n) = 4n^2 + 3n \log n + 1 \le 4n^2 + 3n \cdot n + n^2 = 8n^2$
כאשר החלפנו את האיברים $\log n$ ו-$1$. לכן מתקיים $O(n^2)=f(n)$.
חסם תחתון
קיימת הגדרה סימטרית לכיוון ההפוך, עם סימון אחר:
הגדרה: יהיו שתי פונקציות חיוביות ומונוטוניות לא יורדות $f(n), g(n)$. נסמן כי $\Omega (g(n)) = f(n)$ אם קיים $0<c$ ו-$N$ שלם כך שלכל $N<n$ מתקיים $g(n) \cdot c \le f(n)$.
וכעת ניתן לשלב את שתי ההגדרות להגדרה שתעניין אותנו:
הגדרה: אם מתקיים $O(g(n)) = f(n)$ ו-$\Omega (g(n)) = f(n)$, אז נאמר ש-$g(n)$ היא חסם אסימפטוטי של $f(n)$ ונסמן $\Theta(g(n)) = f(n)$.
השיטה לחישוב החסם האסימפטוטי של פונקציה נתונה כלשהי $f(n)$ פשוטה - צריך למצוא את הגורם ״החזק ביותר״ בפונקציה, זה שגדל במהירות הגדולה ביותר - והוא החסם, לאחר השמטה של האיברים ה-״חלשים״ יותר. למעוניינים, מצורפת הוכחה לכך בהמשך - נקבל עובדה זו באופן אינטואטיבי לשאר.
דוגמאות:
- עבור פולינום כללי:
$f(n)=a_k n^k + a_{k-1}n^{k-1} + … + a_1 n + a_0$
ניתן לחסום את $f(n)$ משני הכיוונים ע״י פולינומים $^kn$, ולכן $\Theta(n^k)=f(n)$. למעשה השמטנו את כל האיברים שאינם החזק ביותר. כמו כן, נשים לב שהשמטנו את הקבוע הכפלי של $^kn$ - אין צורך בו, מכיוון שההגדרה מדברת על כך שקיים קבוע כללי.
- עבור:
$f(n)=n \log n + \sqrt n$
ניתן לכתוב:
$f(n)=n \log n + \sqrt n \le 2n \log n \Rightarrow f(n)=\Theta (n \log n)$
גם כאן, זיהינו כי מתקיים $\sqrt n < n$ באופן כללי, ולכן גם $\sqrt n < n \log n$. כלומר, $n \log n $ הוא הגורם השולט על הפונקציה ולכן ניתן להשמיט את $\sqrt n$.
סיבוכיות אלגוריתם
הגדרה: נתונה פונקציה $f(n)$ המתארת את זמן הריצה או צריכת הזכרון של אלגוריתם מסוים. סיבוכיות הזמן או המקום בהתאמה של האלגוריתם היא החסם האסימפטוטי של הפונקציה: $\Theta (f(n))$.
דוגמא: נניח שקיימת פונקציה שזמן הריצה שלה מתואר ע״י הפונקציה הבאה:
$f(n) = k_1 n^2 + k_2 n \log n + k_3 n$
כאשר $_ik$ הם קבועים הקשורים למחשב הספציפי בו האלגוריתם רץ. סיבוכיות הזמן של האלגוריתם הוא, כפי שראינו עבור פולינום כללי, $\Theta (n^2)$. נשים לב לכמה יתרונות עיקריים בגישת הגדרה זו:
- אנחנו מסתכלים על האיבר הדומיננטי כאשר הקלט גדול - כאשר $\infty \rightarrow n$ האיברים שאינם $^2n$ פחות רלוונטים, וזה מאפשר לנו להתעסק בעיקר ולהשוות בין אלגוריתמים עם התנהגות אסימפטוטית דומה.
- אנחנו מבטלים את התלות במחשב הספציפי עליו האלגוריתם רץ, ומתעסקים בניתוח כללי של האלגוריתם עצמו.
- יש לנו כעת דרך שיטתית להשוות בין אלגוריתמים שונים ולהחליט מי מהן עדיף לנו.
תלות הסיבוכיות בקלט
שאלה שניתן לשאול כעת היא תלות הסיבוכיות בקלט. נבחן למשל את הדוגמא הבאה: נתון מערך arr לא ממוין באורך n, ואנו מעוניינים למצוא את האינדקס (אם קיים) של מטרה כלשהי target. מכיוון שהמערך לא ממוין ואין לנו מידע נוסף עליו, המטרה יכולה להיות בכל מקום בו, ואין דרך להמנע מאיטרציה על כל המערך. עם זאת, אפשר לבחון שני מקרי קיצון שונים:
- המטרה נמצאת באינדקס
0- במקרה זה, נמצא את המטרה בפעולה אחת והסיבוכיות היא לכאורה $\Theta(1)$. זהו גם המקרה הטוב ביותר האפשרי עבור הקלט. - המטרה נמצאת באינדקס
n - 1- במקרה זה, נמצא את המטרה ב-nפעולות והסיבוכיות היא לכאורה $\Theta(n)$. זהו גם המקרה הגרוע ביותר האפשרי עבור הקלט.
מה נכון?
מעתה והלאה, כאשר נדבר על סיבוכיות, תמיד נדבר על המקרה הגרוע ביותר. הסיבה לכך היא שכך ניתן להבטיח חסם עליון על זמן הריצה של האלגוריתם. בדוגמא הזו של חיפוש לינארי בקלט, יתכנו מקרים בהם אורך הקלט לא יהיה גורם משמעותי על זמן הריצה של האלגוריתם, מכיוון שהאינדקס בו נמצא המטרה הוא 0. עם זאת, ניתן להגיד בוודאות כי זמן הריצה של לכל היותר אסימפטוטי ל-$\Theta(n)$.
דוגמאות
חיפוש בינארי
ניתחנו קודם את הסיבוכיות של חיפוש בינארי וראינו כי הפונקציה המתארת את זמן הריצה היא:
$f(n)=\log_{2}(n)$
מכיוון ש-$\log$ בבסיסים שונים נבדלים זה מזה רק בקבוע כפלי, ניתן לכתוב בפשטות כי סיבוכיות הזמן של חיפוש בינארי היא $\Theta (\log n)$. כאשר ננתח את סיבוכיות המקום של האלגוריתם, נשים לב שאנחנו מקצים 3 משתנים בלבד - מספר קבוע שאינו תלוי בגודל הקלט $n$ בכלל. לכן, סיבוכיות המקום של האלגוריתם היא $\Theta (1)$.
Bubble Sort
במיון בועות יש להסתכל על המקרה הגרוע ביותר. נסתכל למשל על מערך ממוין הפוך:
[5, 4, 3, 2, 1]
באיטרציה הראשונה של מיון בועות, ה-5 יבעבע כלפי מעלה עד למצב הבא:
[4, 3, 2, 1, 5]
ובאיטרציה הבאה 4 יצטרך לבעבע שוב עד למעלה, אבל מכיוון שאנחנו יודעים שהאיבר הגדול ביותר כבר נמצא באינדקס n - 1, נצטרך לבעבע רק עד ל-n - 2:
[3, 2, 1, 4, 5]
וכך הלאה. כלומר, במקרה הגרוע ביותר, נבצע בכל איטרציה מספר יורד של פעולות, לסה״כ הבא:
$(n-1) + (n-2) + (n-3) + … + 1$
זוהי סדרה חשבונית עם הפרש $1=d$ ו-$n$ איברים, שסכומה:
$(n-1) + (n-2) + (n-3) + … + 1 = \sum_{k=1}^{n-1} k = \frac{n}{2}(1 + n-1) $
$=\dfrac{n^2}{2} = \Theta(n^2) $
כלומר, סיבוכיות הזמן היא $\Theta(n^2)$. בהמשך נראה שזוהי סיבוכיות זמן לא מוצלחת מאוד עבור אלגוריתם מיון, ושניתן לעשות זאת בסיבוכיות טובה יותר.
מיזוג
להזכירכם, הנה הקוד לביצע מיזוג מערכים ממוינים:
void merge(int* arr1, int n, int* arr2, int m, int* arr3) {
for(int i1 = 0, i2 = 0, i3 = 0; i1 < len1 && i2 < len2; i3++) {
if(arr1[i1] < arr2[i2]) {
arr3[i3] = arr1[i1++];
} else {
arr3[i3] = arr2[i2++];
}
}
// Copy leftover elements from either array
while(i1 < len1) {
arr3[i3++] = arr1[i1++];
}
while(i2 < len2) {
arr3[i3++] = arr1[i2++];
}
}
נסמן ב-n את אורך המערך הראשון וב-m את אורך המערך השני.
בכל איטרציה של המיון אנחנו מקדמים או את i1 או את i2 - גם לאחר מכן, בהעתקת שארית המערכים, אנחנו בכל איטרציה מקדמים אינדקס למערך הרלוונטי. כמו כן, בכל איטרציה אנו מבצעים מספר קבוע של פעולות, שאינו תלוי בגודל הקלט.
כלומר, מספר הפעולות פרופורציונלי (שווה עד כדי קבוע כפלי) לסכום אורכי המערכים, n + m. לכן, נגיד כי סיבוכיות הזמן של מיזוג מערכים ממוינים היא לינארית בסכום אורכי המערכים, או:
הקצנו לטובת הפונקציה גודל קבוע שאינו תלוי בקלט (מספר משתנים בודדים), ולכן סיבוכיות המקום של המיזוג היא $\Theta(1)$.
הערה: פונקציה לינארית היא פולינום ממעלה ראשונה, מהצורה $b+an=f(n)$.
מיון מיזוג
מצאנו את סיבוכיות הזמן של מיזוג, ולכן יהיה פשוט יותר למצוא את סיבוכיות הזמן של מיון באמצעות מיזוג (Merge Sort). במיון מיזוג מתחילים במיזוג של מערכים באורך 1, לאחר מכן 2, 4 עד למיון של כל המערך (כאשר נניח לפשטות שהמערכים באורך חזקה של 2, $^k2=n$). לכן, כפי שראינו, מספר האיטרציות של מיזוג שיתבצעו הוא $\log_2n$. כמו כן, ראינו כי כל מיזוג מכיל $m+n$ איטרציות כאשר $m,n$ הם אורכי המערכים הממוזגים. כמו כן, בכל איטרציה יש למיין מספר יורד של מערכים - בהתחלה $^{k-1}2$, ואז $^{k-2}2$ וכך הלאה.
כלומר, בשלב ב-$h$ של המיון, ישנם $^{k-h}2$ מערכים למיין, שאורך כל אחד מהם $^{h}2$. לכן מספר הפעולות בכל שלב היא:
$2^{k-h}\cdot 2^h=2^k=2^{\log_2n}=\Theta(n)$
וישנם $\Theta(n)=\log_2n$ שלבים למיון, כך שסה״כ סיבוכיות הזמן של מיון מיזוג היא:
$\Theta(n \log n)$
הוכחה - מציאת חסם אסימפטוטי
ננסח את הטענה הקודמת בצורה מדויקת יותר:
טענה: תהי $f(n)$ פונקציה
תרגילים
תרגיל 1
מצאו את סיבוכיות הזמן והמקום של הפונקציה הבאה כתלות ב-n:
int f(int n) {
int a = 1;
while(a < n) {
a *= 2;
}
for(int i = 0; i < 10000000; i++) {
printf("a + i = %d", a + i);
}
return a;
}
כרגיל, אנחנו מעוניינים למצוא את סיבוכיות הזמן כתלות בגודל הקלט n ולכן ניתן להשמיט כל מספר קבוע של פעולות. אף על פי ש-10000000 הוא מספר שנראה גדול, הוא עדיין קבוע, ולכן כל הלולאה השנייה מתבצעת ב-$\Theta(1)$. הלולאה הראשונה לעומתה מתבצעת כל עוד a < n, כאשר a מוכפל ב-2 בכל איטרציה.
דרך נוחה להבין כמה איטרציות לולאה מתבצעת היא לחשוב מה קורה באיטרציה ה-k עבור מספר טבעי כלשהו. באיטרציה ה-k מתקיים $^k2=a$, ולכן האיטרציה האחרונה תתקיים כאשר:
$2^k=n \Longrightarrow k =\log_2 n$
כלומר, יתקיימו לכל היותר $\log_2n$ איטרציות של הלולאה, לסה״כ סיבוכיות זמן של $\Theta(\log n)$. סיבוכיות המקום היא $\Theta(1)$ מכיוון שמוקצה מספר קבוע של משתנים שאינו תלוי בקלט.
תרגיל 2
מצאו את סיבוכיות הזמן והמקום של הפונקציה הבאה כתלות ב-n. הניחו שסיבוכיות הזמן של malloc היא $\Theta(1)$.
void f(int n) {
int* arr = malloc(n * sizeof(int));
int** ptrs = malloc(n * sizeof(int*));
for(int i = 0; i < n; i++) {
ptrs[i] = malloc(n * sizeof(int));
for(int j = 0; j < n; j++) {
arr[i] = j;
arr[j] = i;
}
}
free(arr);
for(int i = 0; i < n; i++) {
free(ptrs[i]);
}
free(ptrs);
}
נתחיל בסיבוכיות הזמן. הקוד מכיל זוג לולאות מקוננות הרצות שתיהן מ-0 ועד n, ועוד לולאה יחידה ללא קינון עם אותן גבולות - היא קצרה יותר ולכן תהיה זניחה ביחס לראשונה.
הלולאה החיצונית רצה n איטרציות, כאשר בכל איטרציה של הלולאה החיצונית יש n איטרציות של הלולאה הפנימית. לכן סיבוכיות הזמן היא:
$n \cdot n = n^2 = \Theta(n^2)$
מבחינת סיבוכיות המקום, הקצנו מערך אחד arr באורך n ואז מערך של מערכים באורך n, שאורך כל אחד מהמערכים בו הוא n. לכן סיבוכיות המקום היא גם:
$n \cdot n = n^2 = \Theta(n^2)$
שאלה 3
חשבו את סיבוכיות הזמן והמקום של הפונקציה הבאה כתלות ב-n:
void f(int* a, int n) {
for(int i = 0; i < n; i++) {
for(int j = 0; j < n && a[i] < a[j]; j++);
}
}
הפונקציה אינה משתמשת בזכרון דינמי (או רקורסיה, עליה נלמד בהרצאה הבאה) ולכן סיבוכיות המקום שלה היא $\Theta(1)$.
מבחינת סיבוכיות המקום, הפונקציה מכילה שתי לולאות מקוננות. החיצונית רצה n איטרציות ללא תלות בפנימית. עם זאת, מספר האיטרציות של הלולאה הפנימית תלוי בקלט! כיצד נדע?
להזכירכם, אנחנו תמיד מסתכלים בסיבוכיות על המקרה הגרוע ביותר - הקלט עבורו סיבוכיות הזמן הכי גרועה שיכולה להיות. ניתן לייצר מערך קלט a עבורו התנאי a[i] < a[j] לכל i, j , כך שלכל אחת מ-n האיטרציות החיצוניות יהיו n איטרציות פנימיות. לכן סיבוכיות הזמן היא:
$n\cdot n = n^2 = \Theta(n^2)$
המקרה הגרוע ביותר הוא תמיד זה שלפיו נבחן את הסיבוכיות.
שאלה 4
מצאו את סיבוכיות הזמן והמקום של הפונקציה הבאה כתלות ב-n:
void f(int n) {
for(int i = 2; i < n; i *= i);
}
מבחינת סיבוכיות מקום, מוקצה מספר קבוע של משתנים ולכן היא $\Theta(1)$.
סיבוכיות הזמן מעט יותר מורכבת. ננסה לחשב - מהו ערכו של i באיטרציה ה-k? נכתוב כמה מהערכים הראשונים:
$2, 4, 16, 256$
אם נכתוב את הערכים הראשונים כחזקות של 2, קצת יותר קל לראות את הסדרה:
$ 2^1, 2^2, 2^4, 2^8, … $ $ 2^{2^1}, 2^{2^2}, 2^{2^3}, … 2^{2^k} $
בכל איטרציה מתקיים מספר קבוע של פעולות, כאשר מספר האיטרציות $k$ מקיים:
$2^{2^k}=n \Longrightarrow 2^k = \log_2n \Longrightarrow k = \log(\log_2 n)$ $=\Theta(\log \log n)$
שאלה 5
מצאו את סיבוכיות הזמן והמקום של הפונקציה הבאה כתלות ב-n:
void g(int k) {
for(int l = 0, m = 0; l < n || m < n * n; l++, m++);
}
void f(int n) {
for(int i = 0; i <= n; i += 10) {
g(i);
g(n - i);
}
}
הרצאה 10 - רקורסיה
מבוא
רקורסיה היא אחד הקונספטים המעניינים ושימושיים במדעי המחשב, וספציפית בשפת C. הוא ידוע לשמצה בתור קשה להבנה ושימוש, אבל אנחנו נעשה מאמץ לפרק אותו לחלקים ולהבין כיצד להשתמש בו בצורה נכונה וחכמה. ישנן בעיות שפתרונן הרקורסיבי פשוט לאין שיעור מפתרונן ״הרגיל״.
לטובת הבנה עמוקה של הנושא, נבחן מקרה דוגמא קלאסי - חישוב עצרת של מספר.
חישוב עצרת איטרטיבי
הנה בעיה - בהנתן מספר n שלם כלשהו, חשבו את העצרת שלו. מימוש פשוט וקל יכול להיות כזה:
int factorial(int n) {
if(n >= 2) {
return n;
}
int result = 1;
for(int i = n; i > 1; i++) {
result *= i;
}
return result;
}
זהו פתרון פשוט שיעבוד בצורה מצוינת לפי הגדרת העצרת:
$n!=n(n-1)(n-2)…3\cdot 2 \cdot 1$
נרצה לבחון דרך אחרת לפתור את הבעיה. היא אולי תראה בהתחלה לא טבעית, אבל הבנה טובה שלה תהיה קריטית להמשך.
חישוב עצרת רקורסיבי
נשים לב לעובדה הבאה בנוגע לעצרת - ניתן בקלות לפשט את הבעיה של חישוב $!n$ לבעיה פשוטה יותר, של חישוב $!(1-n)$, מכיוון שמתקיים:
$n! = n \cdot (n-1)!$
כלומר, אין צורך בכלל לדעת את $!n$ אם אנחנו יודעים את $!(1-n)$. מצד שני, ניתן לחשוב שלא עשינו כלום בכך - איך נחשב כעת את $!(1-n)$? באופן מפתיע, נעשה את אותה הפעולה שוב!
$n! = n \cdot (n-1)! = n \cdot (n-1) \cdot (n-2)!$
למעשה, נוכל לעשות זאת עד שנגיע למספר שאנחנו יודעים בוודאות מהי העצרת שלו ללא חישוב - במקרה זה, המספר $1$:
$n! = n \cdot (n-1)! = n \cdot (n-1) \cdot (n-2)!$ $=n\cdot (n-1)\cdot(n-2)\cdot…\cdot3\cdot2\cdot1$
כאשר הגענו ל-$!1$ הפסקנו לבצע את הפירוק של $!(1-n)\cdot n=!n$, מכיוון שאנו יודעים שמתקיים על פי ההגדרה $1=!1$.
פתרון בעיה ע״י פירוק שלה למספר מקרים של פשוטים יותר של אותה בעיה נקרא רקורסיבי. למעשה, בכל שלב כתבנו את בעיית העצרת כבעיית עצרת פשוטה יותר, עם כפל במספר כלשהו - עד שהגענו למקרה כלשהו בו ידענו לפתור נאיבית את הבעיה. מקרה זה נקרא מקרה הבסיס ונרחיב עליו בהמשך.
כעת נממש את הפתרון שלנו בקוד. הוא יכיל מאפיין מעניין שטרם פגשנו:
int factorial(int n) {
if(n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
פונקציה כמו factorial הזו, הקוראת לעצמה, נקראת רקורסיבית. אין שום מניעה שפונקציה תקרא לעצמה בשפת C - ניתן לחשוב על זאת כמעין לולאה. כאשר הוספנו את מקרה הבסיס של n <= 1, דאגנו לתנאי עצירה לאותה הלולאה.
הערה: המימוש הנ״ל בעייתי במספר מובנים - n יכול להיות שלילי, ועבור n > 12 מתקיים כי !n גדול מטווח היצוג של משתנה int. ניתן לכתוב מימושים נכונים יותר המתחשבים ביותר מקרי קצה או מכירים בהם.
רקורסיה
כעת נגדיר בצורה פורמאלית יותר את הדרך לפתרון בעיות בצורה רקורסיבית. מה אנחנו צריכים? נניח שנתונה לנו:
- בעיה כלשהי שיש לפתור עבור גודל קלט $n$ כלשהו.
- מקרה בסיס (או מקרי בסיס) עבורו אנחנו יודעים את פתרון הבעיה.
- דרך לכתוב את הבעיה מחדש בהנתן $n$ אחר, כאשר שימוש חוזר בכתיבה תביא אותנו למקרה הבסיס. כל כתיבה כזו נקראת צעד רקורסיה.
נבחן דוגמא נוספת - נחשב חזקה של מספר כלשהו באופן רקורסיבי. נתון הביטוי, עבור $n$ שלם כלשהו:
$p(n,x)=x^n$
ראשית יש למצוא את מקרה הבסיס כדי שנדע לאיזה כיוון יש לצעוד רקורסיבית. אנחנו יודעים , למשל, כי לכל $x$ מתקיים:
$ p(x,0) = x^0 = 1 $
ולכן נרצה לצעוד רקורסיבית עם $n$ יורד - זה בדרך כלל יהיה המקרה. כיצד ניתן להוריד את $n$? אפשר לכתוב את הפונקציה כך:
$ p(x,n) = x^n = x \cdot x^{n-1} = x p(n-1,x) $
מצאנו את מקרה הבסיס ואת צעד הרקורסיה! נכתוב מימוש:
int power(int x, int n) {
if(n == 0) {
return 1;
}
return x * power(x, n - 1);
}
דוגמא 1
כעת, כשאנחנו מרגישים יותר בנוח עם רקורסיה, נפתור בעיה שהפתרון הרקורסיבי שלה טוב חכם ונכון הרבה יתר מהפתרון האיטרטיבי שלה. בעיה: כתבו פונקציה שמקבלת מספרים מהמשתמש ומדפיסה אותם בסדר הפוך לסדר קבלתם, עד לקבלת מספר שלילי.
פתרון איטרטיבי
ראשית נדגים כיצד הפתרון האיטרטיבי מסורבל מאוד וקשה. מכיוון שצריך להדפיס את המספרים בסדר הפוך, אנחנו צריכים בהכרח לשמור את כל המספרים אצלנו עד האחרון ורק אז להתחיל להדפיס אותן הפוך. אנחנו לא יודעים כמה מספרים הולכים להגיע, ולכן אי אפשר לדעת מראש כמה מקום צריך להקצות. נקצה מערך בגודל התחלתי כלשהו, ובמידה ונחרוג ממנו נקצה אחד כפול ממנו בגודל ונעתיק את התוצאות אליו:
#include <stdlib.h>
#include <stdio.h>
void print_reverse(void) {
int len = 32;
int* arr = malloc(len * sizeof(int));
int i = 0;
while(i < len) {
scanf("%d", &arr[i]);
if(arr[i] < 0) {
break;
}
if(++i == len) {
len *= 2;
int* new_arr = malloc(len * sizeof(int));
for(int i = 0; i < len / 2; i++) {
new_arr[i] = arr[i];
}
free(arr);
arr = new_arr;
}
}
for(int j = i - 1; j >= 0; j--) {
printf("%d\n", arr[j]);
}
free(arr);
}
הפתרון מאוד לא פשוט - הוא דורש לקיים מערך שגדל במידת הצורך, לבדוק לו קצוות וכו׳ - מורכב ולא טריוויאלי בכלל.
פתרון רקורסיבי
הפתרון הרקורסיבי פשוט מאוד. תנאי העצירה הוא קבלת מספר שלילי, וצעד הרקורסיה הוא קבלת מספר נוסף.
#include <stdio.h>
void print_reverse(void) {
int n;
scanf("%d", &n);
if(n < 0) {
return;
}
print_reverse();
printf("%d\n", n);
}
כמעט קשה להאמין שהפתרון הזה תקין, אבל מסתבר שזה כל מה שצריך בשביל לפתור את הבעיה באופן רקורסיבי. טוב להזכר כאן בקונספט מחסנית הקריאות שהתעסקנו בו בעבר - מכיוון שכל קריאה חדשה ל-()print_reverse נדחפת על מחסנית הקריאות, בהכרח האחרונה תסתיים לפני שאחת לפניה תסתיים, ומובטח סדר הפוך להדפסות כרצוי.
סיבוכיות זמן ומקום בתכנות רקורסיבי
נבחן את הפתרון לבעיית ההדפסה ההופכית שפיתחנו קודם:
#include <stdio.h>
void print_reverse(void) {
int n;
scanf("%d", &n);
if(n < 0) {
return;
}
print_reverse();
printf("%d\n", n);
}
נרצה לנתח את סיבוכיות הזמן והמקום של האלגוריתם הזה.
סיבוכיות הזמן פשוטה יחסית - אם אורך הקלט הוא n, נבצע n קריאות לפונקציה print_reverse כאשר בכל קריאה נבצע מספר קבוע של פעולות, לסה״כ $\Theta(n)$.
סיבוכיות המקום מעט מורכבת יותר. להזכירכם, כל קריאה לפונקציה דוחפת stack frame חדש על מחסנית הקריאות - המשתנים הלוקאלים של הפונקציה, כתובות חזרה וכו׳. הקריאה הרקורסיבית מתבצעת $n$ פעמים, ולכן נקצה $\Theta(n)$ זכרון על מחסנית הקריאות!
זהו חסרון מפתיע של תכנות רקורסיבי - בגלל שאנחנו משתמשים במחסנית הקריאות בתור מבנה הנתונים שלנו, באופן טבעי הקצנו עליה זכרון בסקאלה של הפתרון שאנחנו צריכים.
כדוגמא נוספת, נחשב את סיבוכיות הזמן והמקום של חישוב עצרת איטרטיבי אל מול רקורסיבי:
int factorial_recursive(int n) {
if(n == 1) {
return 1;
}
return n * factorial(n - 1);
}
int factorial_iterative(int n) {
int result = 1;
for(int i = n; i > 1; i--) {
result *= n;
}
return result;
}
בפתרון הרקורסיבי נבצע $n$ קריאות רקורסיביות עם $\Theta(1)$ פעולות בכל קריאה, ולכן נקבל סיבוכיות זמן של $\Theta(n)$ וסיבוכיות מקום של $\Theta(n)$ - בגלל ההקצאות על הקריאות הרקורסיביות על מחסנית הקריאות. הפתרון האיטרטיבי, לעומתו, לוקח את אותו $\Theta(n)$ בסיבוכיות הזמן, אבל מקצה מספר משתנים בודדים ולכן סיבוכיות המקום שלו היא $\Theta(1)$!
הערות:
- למרות החסרון בשימוש במקום הזה של פתרון רקורסיבי, הרבה פעמים שימוש ברקורסיה מפשט משמעותית פתרון בעיות והרבה פעמים נרצה להשתמש בו. חשוב להיות מודעים למשמעויות ולוודא שאנחנו לא מקצים יותר מדי זכרון.
- חשוב מאוד להיות מודעים לצריכת הזכרון של פתרונות רקורסיביים - טעות קלאסית במבחן היא לשכוח זאת ולקבל סיבוכיות מקום שגויה עבור פונקציות רקורסיביות.
- ניתן לחשוב שאולי אפשר להמנע מהצורך להקצות stack frame חדש בעת קריאה רקורסיבית - הרי ניתן לכל הפחות להפטר מכתובת החזרה, מכיוון שאנחנו חוזרים לאותו מקום! מתסבר שזה נכון, וקיימות שפות תכנות גבוהות יותר עם מנגנון בשם Tail Call Optimization שמעלימות את הצורך בשימוש בזכרון נוסף לצורך קריאות רקורסיביות. בשפת C המנגנון הזה לא קיים לצערנו.
תרגילים
תרגיל 2
סדרת פיבונאצ׳י היא סדרת מספרים רקורסיבית המוגדרת ע״פ הנוסחא הבאה:
[ \left{ \begin{array}{l} F_n = F_{n - 1} + F_{n-2} \ F_0 = F_1 = 1 \end{array} \right. ]
כלומר, כל איבר בסדרה הוא סכום שני האיברים לפניו, כאשר שני האיברים הראשונים מוגדרים להיות $1$.
סעיף א׳
כתבו פונקציה רקורסיבית שמקבלת מספר אי-שלילי n ומחזירה את המספר במקום ה-n בסדרת פיבונאצ׳י.
פתרון
סדרת פיבונאצ׳י מוגדרת באופן טבעי בצורה רקורסיבית - צעד הרקורסיה ומקרי הבסיס מוגדרים לנו באופן טבעי בהגדרת הסדרה. לכן כתיבת הפונקציה אינה מאוד מסובכת:
int fib(int n) {
if(n == 0 || n == 1) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
סעיף ב׳
חשבו כמה קריאות לפונקציה fib מתבצעות עבור fib(5). מהו fib(5)?
פתרון
נפרק לגורמים את הקריאות הרקורסיביות עד למקרה הבסיס:
$fib(5) = fib(4) + fib(3) = (fib(3) + fib(2)) + (fib(2) + fib(1)) $ $ = ((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1)) $ $ (1 + 1) + (1 + 1) + (1 + 1 + 1) = 2 + 2 + 3 = 7 $
כלומר, מתבצעות 7 קריאות לפונקציה! נשים לב שחישבונ את fib(2) למשל 3 פעמים שונות - מאוד לא יעיל.
סעיף ג׳
נתון כעת כי יש לחשב את fib של מספרים שונים מספר מאוד גדול של פעמים לאורך ריצת תוכנית כלשהי, אבל עדיין באופן רקורסיבי. כתבו שיפור לפונקציה הקודמת שמשתמשת במערך עזר סטאטי כך שחישוב כל איבר בסדרה מתבצע לכל היותר פעם אחת לאורך ריצת התוכנית. נתון כי הערך המקסימלי עבורו תקרא fib הוא 20.
פתרון
בשביל לחשב רק פעם אחת כל איבר בסדרה, נוכל להשתמש במערך עזר שיכיל את החישובים הקודמים. נאתחל אותו לערך לא חוקי כדי שנוכל להבדיל בין ערכים שחושבו לערכים שעוד לא הגענו אליהם, ונקבל את הפתרון הבא:
#include <stdbool.h>
#define MAX_FIB 21
#define NEW_CALC -1
int fib(int n) {
static int fibs[MAX_FIB];
static bool initialized = false;
if(!initialized) {
fibs[0] = 1;
fibs[1] = 1;
for(int i = 2; i < MAX_FIB; i++) {
fibs[i] = NEW_CALC;
}
initialized = true;
}
if(fibs[n] != NEW_CALC) {
return fibs[n];
}
fibs[n] = fib(n - 1) + fib(n - 2);
return fibs[n];
}
שאלה 3
כתבו פונקציה רקורסיבית שהופכת מחרוזת נתונה in-place ללא לולאות. ניתן להניח שהמחרוזת שניתנת תקינה באורך $0\le n$.
פתרון
ראשית, משמעות הדרישה ל-in-place היא לבצע את ההיפוך למחרוזת על גבי הזכרון עליו המחרוזת הוקצאה, ללא העתקה למקום אחר בזכרון. שאלה זו דורשת מפורשות מימוש ללא לולאה, מכיוון שבמקרה זה המימוש האיטרטיבי הרבה יותר קל מהמימוש הרקורסיבי. הוא יראה בערך כך:
void swap(char* c1, char* c2);
void reverse_str(char* str) {
int len = strlen(str);
int l = 0, r = len - 1;
while(l < r) {
swap(&str[l++], &str[r--]);
}
}
מדובר על לולאה עם שני אינדקסים שנעים זה לכיוון זה. כאשר ממירים לולאה לאינדוקציה, נוח להכניס פונקציה נוספת לקוד, אליה נקרא עם האינדקסים המעודכנים של הלולאה. זו נשתמש טכניקה קלאסית ברקורסיה - פונקציית עזר עם פרמטרים אחרים, שתקרא מתוך הפונקציה שאנו מממשים:
void swap(char* c1, char* c2);
void reverse_str_aux(char* str, int l, int r) {
if(l >= r) {
return;
}
swap(&str[l++], &str[r--]);
revese_str_aux(str, l + 1, r - 1);
}
void reverse_str(char* str) {
reverse_str_aux(str, 0, strlen(str) - 1);
}
שאלה 4
כתבו פונקציה רקורסיבית ללא לולאות שמחזירה true אם מחרוזת היא פלינדרום, ואחרת false. פלינדרום הוא מחרוזת ששווה למחרוזת ההופכית שלה.
פתרון
מדובר על עדכון קל לאלגוריתם בשאלה הקודמת שכתבנו. במקום להחליף בין כל שני תווים הופכיים, נבדוק אם הם שווים:
void is_pali_aux(char* str, int l, int r) {
if(l >= r) {
return true;
}
if(str[l] != str[r]) {
return false;
}
return is_pali_aux(str, l + 1, r - 1);
}
void is_pali(char* str) {
return is_pali_aux(str, 0, strlen(str) - 1);
}
שאלה 5
כתבו פונקציה שלוקחת מחרוזת ומוחקת ממנה תווים סמוכים זהים in-place. לאחר הפונקציה המחרוזת לא תכיל שום שני תווים סמוכים זהים. לדוגמא, המחרוזת aaaabbccc תהפוך ל-abc. כתבו ללא לולאות.
פתרון
נחזיק שני מצביעים למחרוזת - אחד לקריאה ואחד לכתיבה. המצביע לקריאה יקרא קדימה כל עוד הוא רואה את אותו תו - וכשיסיים, נבצע כתיבה. נמשיך כך עד לסוף המחרוזת, כאשר שני המצביעים יתחילו מאותו מקום:
void remove_duplicates_aux(char* read, char* write) {
if(!(*read)) {
*write = '\0';
return;
}
*write = *read;
char* next = skip_duplicates(read);
}
void remove_duplicates(char* str) {
remove_duplicates_aux(str, str);
}
הרצאה 11 - מיון רקורסיבי
בהמשך להרצאה על רקורסיה, מסתבר כי ישנם אלגורימי מיון שמימוש רקורסיבי שלהם (במקום איטרטיבי) הוא מוצלח ואלגנטי. בהרצאה זו נפגוש מספר אלגוריתמים כאלו וננתח אותם לעומק.
מיון מיזוג רקורסיבי
מתברר כי את אלגוריתם המיון היעיל שלמדנו, Merge Sort, ניתן לממש בצורה רקורסיבית באופן מוצלח. האלגוריתם מעצם הוויתו מתאים למימוש רקורסיבי - הרי אנחנו שוברים את המערך הממוין לחלקים קטנים יותר, ומשתמשים בתת-מערכים ממוינים על מנת למזג בצורה יעילה.
תנאי העצירה באלגוריתם יהיה גודל תת-המערך הממוזג - ניתן לעצור עבור מערכים בגודל 1. צעד הרקורסיה יהיה הקטנת גודל המערך ואז מיזוג שלו, יחד עם העתקה ממערך העזר למערך המקורי:
#include <stdlib.h>
void merge(int* arr1, int len1, int* arr2, int len2, int* dst);
void merge_sort_aux(int* arr, int n, int* tmp_arr);
void merge_sort(int* arr, int n) {
int* tmp_arr = malloc(n * sizeof(int));
merge_sort_aux(arr, n, tmp_arr);
free(tmp_arr);
}
void merge_sort_aux(int* arr, int n, int* tmp_arr) {
if(n <= 1) {
return;
}
int low = n / 2;
int high = n - low;
merge_sort_aux(arr, low, tmp_arr);
merge_sort_aux(arr + low, right, tmp_arr);
merge(arr, low, arr + low, high, tmp_arr);
for(int i = 0; i < n; i++) {
arr[i] = tmp_arr[i];
}
}
מבחינת סיבוכיות זמן, מתבצעות $\Theta(\log n)$ קריאות לפונקציה הרקורסיבית שאורכה $\Theta(n)$, ולכן בדומה למימוש האיטרטיבי נקבל $\Theta(n \log n)$. מבחינת סיבוכיות המקום, אנו מקצים $\Theta(n)$ זכרון עבור מערך העזר, ומכיוון שמתבצעות $\log n$ קריאות רקורסיביות אנחנו מקצים גם $\Theta(\log n)$ מקום עבור מחסנית הקריאות. סה״כ מתקיים שסיבוכיות המקום היא:
$\Theta(n + \log n) = \Theta(n)$
כלומר, הסיבוכיות זהה לחלוטין לסיבוכיות במימוש האיטרטיבי.
Quick Sort
שאלות מבחן
הנחות
- ניתן להשתמש בטיפוס
boolובפונקציהmallocללא שימוש ב-include#. - ניתן להניח שסיבוכיות הזמן של
malloc(n)היא $\Theta(1)$ לכלn. - שלום שלום, ( \Theta = 5 ).
- למשל אפשר לקבל \( x = 5 \)
- ואולי גם אפשר לכתוב \( \theta(5) = 5 \)
שאלה 1
נתון מערך שלמים שאורכו n, בו כל האיברים אי-שליליים (גדולים/שווים ל-0) וקטנים ממש מקבוע x נתון. נגדיר מערך k-יחודי בתור מערך בו אם האיבר a מופיע, כל המספרים שבמרחק k ממנו לא מופיעים. כלומר, בהנתן איבר a במערך k-יחודי, האיברים הבאים לא מופיעים:
$ a-k, a-(k + 1), …, a, a+1, …, a +k $
למשל, המערך הבא הוא 2-יחודי, ואינו 3-יחודי:
$$ [1, 4, 10, 13] $$
סעיף א
כתבו פונקציה המקבלת מערך ואורכו, וקובעת האם הוא 1-יחודי. על הפתרון לעמוד בדרישות סיבוכיות זמן \( \Theta(n + x) \) וסיבוכיות מקום \( \Theta(x) \).
פתרון
ננסה לפרק את הדרישה. מערך 1-יחודי הוא מערך בו אם המספר a מופיע, המספרים a - 1 ו-a + 1 אינם מופיעים. השאלה מאפשרת להשתמש בסיבוכיות מקום $\Theta(x)$ - זו אינדיקציה טובה מאוד להשתמש במערך באורך $x$. אפשר להשתמש במערך כזה על מנת לסמן את האיברים שכבר פגשנו בדגל בוליאני - בהגעה לאיבר a, נבדוק האם פגשנו כבר את האיברים a - 1 ו-a + 1. כמו כן, נבדוק האם a לא מופיע פעמיים.
נבדוק בזהירות את האינדקסים על מנת לוודא שאנחנו לא חורגים מגודל המערך ונקבל את הפתרון הבא:
bool is_one_unique(int* arr, int n, int x) {
bool* visited = malloc(sizeof(bool) * x);
for(int i = 0; i < x; i++) {
visited[i] = false;
}
bool is_unique = true;
for(int i = 0; i < n; i++) {
int a = arr[i];
if((a >= 0 && visited[a - 1]) ||
(a < x - 1 && visited[a + 1]) ||
visited[a]) {
is_unique = false;
break;
}
visited[a] = true;
}
free(visited);
return true;
}
מבחינת סיבוכיות, הפונקציה שלנו מבצעת שתי לולאות של x ו-n איטרציות בהתאמה, ובכל אחת מבצעת מספר קבוע של פעולות, ולכן סיבוכיות הזמן היא אכן $\Theta({n+x})$. סיבוכיות המקום היא $\Theta(x)$.
סעיף ב
כתבו פונקציה שמקבלת את אותם הפרמטרים ומחזירה האם מערך הוא k-יחודי. על הפתרון לעמוד בסיבוכיות זמן של $\Theta({nk + x})$.
פתרון
הגישה לא מאוד שונה מהסעיף הקודם. טריק חשוב שניתן ללמוד בשאלה הוא להשתמש בסיבוכיות הזמן המבוקשת בשביל להבין מה הפתרון הנדרש. בשביל לקבוע האם מערך הוא k-יחודי כש-$1 = k$, קיבלו סיבוכיות של $\Theta({n+x})$, השקולה ל-$\Theta({nk+x})$ עבור $1=k$. לכן נדרש רק להתאים מעט את הגישה שלנו.
לכל מספר a במערך, במקום לבדוק אותו רק מול שני שכניו המיידים, כעת יש לבדוק אותו מול k שכניו - זה יהפוך ל-$nk$ בשתי לולאות מקוננות:
bool is_one_unique(int* arr, int n, int x) {
bool* visited = malloc(sizeof(bool) * x);
for(int i = 0; i < x; i++) {
visited[i] = false;
}
bool is_unique = true;
for(int i = 0; i < n; i++) {
int a = arr[i];
int start = (a - k < 0) 0 : a - k;
int end = (a + k >= x) ? x - 1 : a + k;
for(int j = start; j <= end; j++) {
if(visited[j]) {
is_unique = false;
break;
}
}
visited[a] = true;
}
free(visited);
return true;
}
שאלה 2
נתוך מערך שלמים באורך n. לכל אינדקס i במערך, נגדיר פונקציה $f(i)$ שערכה הוא ההפרש בין סכום האיברים בין 0 ל-i (כולל) לבין סכום האיברים בין i + 1 ל-n - 1:
$ f(i) = \sum_{j = 0}^{i}arr[i] - \sum_{j=i+1}^{n - 1}arr[i] $
למשל, עבור המערך [1, 5, 3-, 10, 2] והאינדקס i = 1, מתקיים:
$ f(i) = (2 + 10) - (-3 + 5 + 1) = 12 - 3 = 9 $
כתבו פונקציה המקבלת מערך ואורכו ומחזירה את האינדקס עבורו $f(i)$ מינימלית. אם יש מספר אינדקסים $i,j$ עבורם $f(i) = f(j)$ ניתן להחזיר כל אחד מהם. על הפתרון שלכם לעמוד בסיבוכיות זמן $\Theta(n)$ וסיבוכיות מקום $\Theta(1)$.
פתרון
זו שאלה שדורשת מעט להתחכם עם הגישה שלנו. פתרון אחד יכול להיות לחשב מפורשות את $f(i)$ לכל $i$ בגבולות המערך ולקחת את המינימלי - עם זאת, זה לא עומד בדרישות הסיבוכיות (חישוב n פעמים סכום של כל המערך יגיע לסיבוכיות $\Theta(n^2)$).
עם זאת, נשים לב שאם נעשה חישוב עזר קטן של סכום איברי המערך, נוכל לחסוך חישוב חוזר בכל איטרציה. נסמן:
- $s$ יהיה סכום כל המערך (אותו ניתן לחשב בריצה אחת על איברי המערך ב-$\Theta(n)$)
- $_1p$ יהיה סכום איברי המערך מ-
0ועדiכלשהו (האיבר הראשון ב-$f(i)$) - $_2p$ יהיה סכום איברי המערך מ-
i + 1ועדn - 1(האיבר השני ב-$f(i)$)
בהנחה שחישבנו את $s$ ו-$_1p$, מתקיים:
$ p_1 + p_2 = s \Longrightarrow p_2 = s - p_1 $
כלומר, אפשר לחשב את סכום המערך, ואז לבצע איטרציה על המערך, לעדכן את שני הסכומים ולבחור את המינימלי שראינו עד כה.
int min_f(int* arr, int n) {
int sum = 0;
for(int i = 0; i < n; i++) {
sum += arr[i];
}
int s1 = 0, s2;
int min_val = INT_MAX;
int min_index = 0;
for(int i = 0; i < n; i++) {
s1 += arr[i];
s2 = sum - s1;
if(s1 - s2 <= min_val) {
min_val = s1 - s2;
min_index = i;
}
}
return min_index;
}
שאלה 3
בהנתן מערך ממוין arr, נגדיר נקודת שבת בתור אינדקס i עבורו מתקיים arr[i] = i. כתבו פונקציה שמקבלת מערך ואורכו ומחזירה אינדקס של נקודת שבת אם קיימת, אחרת 1-. על הפתרון לעמוד בסיבוכיות זמן של $\Theta(\log n)$ וסיבוכיות מקום $\Theta(1)$.
פתרון
הרצאה XXX - Preprocessor
אחד האובייקטים המעניינים והשימושיים ביותר למתכנת מתקדם ב-C הוא ה-C preprocessor, או ה-CPP (לא להתבלבל עם השפה ++C). למעשה השתמשנו בו לא מעט עד כה - צירפנו קבצים ע״י include#, הגדרנו קבועים ע״י define# וכו׳. עם זאת, לא גידרנו את תחילת יכולתו של ה-CPP - הוא יכול לחסוך המון זמן וכתיבת קוד מחודשת, אך יכול להיות גם מרושע ולגרום לבאגים קשים מאוד לדיבוג. בהרצאה זו נפגוש מספר שימושיים - מספר מהם יהיה קלאסיים וכאלה שחשוב להכיר בשביל להשתמש בשפה בצורה נכונה, אחרים יהיו יותר משונים ומעניינים.
אופרטור ה-stringify
האופרטור מאפשר לקחת שורת טקסט בדיוק כמו שהיא ולהפוך אותה ל-string literal, או מחרוזת פשוטה. האופרטור מדביק את ה-token שמימינו בצורה מפורשת במקום שבו הוא נמצא.