ברוכים הבאים ליחידת הלימוד בנושא מבני נתונים מקושרים, יחידה קריטית בקורס "מעבדה בתכנות מערכות". ביחידה זו נצלול לעומק ההטמעה של רשימות מקושרות בשפת C, נבין את יתרונותיהן על פני מערכים סטטיים, ונלמד כיצד לתפעל אותן ביעילות. שליטה בחומר זה חיונית להבנת מבני נתונים מורכבים יותר ולפתרון בעיות תכנות רבות.
מבנה הצומת (Node) – אבן הבניין של הרשימה המקושרת
רשימה מקושרת מורכבת מסדרה של צמתים, כאשר כל צומת מכיל נתון כלשהו ומצביע לצומת הבא ברצף (או גם לקודם, במקרה של רשימה דו-כיוונית). הבנת מבנה הצומת היא המפתח להבנת כל סוגי הרשימות המקושרות.
malloc ו-free ב-C), המאפשר לרשימות מקושרות לגדול ולהתכווץ לפי הצורך, בניגוד למערכים בעלי גודל קבוע מראש.הגדרת מבנה צומת ב-C
מבנה צומת טיפוסי מוגדר באמצעות struct. חשוב להשתמש ב-typedef כדי לפשט את הכתיבה.
typedef struct Node {
int data; // שדה הנתונים של הצומת
struct Node *next; // מצביע לצומת הבא ברשימה
} Node;
// עבור רשימה דו-כיוונית:
typedef struct DNode {
int data;
struct DNode *next; // מצביע לצומת הבא
struct DNode *prev; // מצביע לצומת הקודם
} DNode;
סוגי רשימות מקושרות והטמעתן ב-C
קיימים שלושה סוגים עיקריים של רשימות מקושרות, הנבדלים באופן הקישור בין הצמתים ובאפשרויות התנועה בתוך הרשימה:
רשימה מקושרת חד-כיוונית (Singly Linked List)
ברשימה זו, כל צומת מכיל מצביע אחד בלבד לצומת הבא. הצומת האחרון ברשימה מצביע ל-NULL. התנועה אפשרית רק קדימה (מההתחלה לסוף). יעילה להוספה ומחיקה מההתחלה (O(1)), אך הוספה/מחיקה מהסוף או מהאמצע דורשת מעבר על חלק מהרשימה (O(N)).
רשימה מקושרת דו-כיוונית (Doubly Linked List)
כל צומת מכיל שני מצביעים: אחד לצומת הבא (next) ואחד לצומת הקודם (prev). הצומת הראשון מצביע ב-prev ל-NULL, והאחרון מצביע ב-next ל-NULL. מאפשרת תנועה קדימה ואחורה. הוספה ומחיקה בכל מקום ברשימה (בהינתן הצומת) הן פעולות ב-O(1), אך דורשת יותר זיכרון לכל צומת.
רשימה מקושרת מעגלית (Circular Linked List)
ברשימה זו, הצומת האחרון מצביע בחזרה לצומת הראשון (במקום ל-NULL). יכולה להיות חד-כיוונית או דו-כיוונית. אין "סוף" מוגדר לרשימה, מה שמצריך טיפול מיוחד בתנאי עצירה בלולאות. שימושית במצבים כמו תורים מעגליים או הקצאת משאבים בסבב (round-robin).
פעולות נפוצות ברשימות מקושרות
הבנת הפעולות הבסיסיות היא המפתח לשליטה ברשימות מקושרות. כל פעולה כרוכה בשינוי מצביעים.
הוספת צומת (Node Insertion)
- הוספה להתחלה: הצומת החדש מצביע לראש הרשימה הנוכחי, וראש הרשימה מתעדכן להצביע לצומת החדש.
- הוספה לסוף: יש לעבור על הרשימה עד הצומת האחרון, ואז לגרום לו להצביע לצומת החדש.
- הוספה באמצע: יש למצוא את הצומת שאחריו רוצים להוסיף, ולעדכן את המצביעים של הצומת הקודם, הצומת החדש והצומת שאחריו.
מחיקת צומת (Node Deletion)
- מחיקה מההתחלה: יש לשמור מצביע לצומת הראשון, לעדכן את ראש הרשימה לצומת הבא, ואז לשחרר את הצומת המקורי.
- מחיקה מהסוף: יש לעבור על הרשימה עד הצומת שלפני האחרון, ולגרום לו להצביע ל-
NULL(או לראש הרשימה במעגלית). - מחיקה מהאמצע: יש למצוא את הצומת שברצוננו למחוק ואת הצומת שלפניו. הצומת שלפניו יצביע כעת לצומת שאחרי הצומת הנמחק, ואז לשחרר את הזיכרון של הצומת הנמחק.
מעבר על הרשימה (List Traversal)
מעבר על הרשימה מתבצע באמצעות מצביע עזר (לדוגמה, current) שמתחיל בראש הרשימה ומתקדם מצומת לצומת באמצעות current = current->next, עד שמגיע ל-NULL (או חוזר לראש הרשימה במעגלית).
free כאשר צומת נמחק.יתרונות וחסרונות של רשימות מקושרות
הבחירה במבנה נתונים תלויה בצרכי היישום. רשימות מקושרות מציעות גמישות רבה, אך גם באות עם עלויות מסוימות.
יתרונות
- גודל דינמי: יכולות לגדול ולהתכווץ בזמן ריצה ללא צורך בהקצאה מחדש של כל המבנה.
- הוספה/מחיקה יעילה: במקרים רבים (כמו הוספה/מחיקה מההתחלה או במקום ידוע ברשימה דו-כיוונית), הפעולות הן בסיבוכיות זמן O(1).
- שימוש יעיל בזיכרון: מקצות זיכרון רק לצמתים הדרושים בפועל.
חסרונות
- גישה איטית: גישה לאיבר במיקום מסוים (לדוגמה, האיבר ה-k) דורשת מעבר על k איברים (O(N)), בניגוד למערך בו הגישה היא O(1).
- צריכת זיכרון גבוהה יותר: כל צומת דורש זיכרון נוסף עבור המצביעים (
next,prev). - מורכבות במימוש: טיפול במצביעים דורש זהירות רבה ונוטה לשגיאות.
שאלות לדיון
- הסבירו מתי תעדיפו להשתמש ברשימה מקושרת חד-כיוונית על פני דו-כיוונית, ומתי ההפך. תנו דוגמאות לתרחישי שימוש.
- כיצד תשנו את מבנה הצומת של רשימה חד-כיוונית כדי להפוך אותה למעגלית? מהם היתרונות והחסרונות של שינוי זה?
- תארו את השלבים הנדרשים למחיקת צומת ספציפי (לפי ערך הנתון שלו) מרשימה מקושרת דו-כיוונית. התייחסו למקרי קצה.
- מהם הסיכונים העיקריים בטיפול במצביעים ברשימות מקושרות, וכיצד ניתן למזער אותם?
נקודות לתשובת מודל
- הבחירה בין רשימות תלויה בצרכים: חד-כיוונית חסכונית בזיכרון ופשוטה יותר, אך איטית יותר בגישה אחורה או מחיקה מהסוף. דו-כיוונית מהירה יותר בפעולות אלו אך צורכת יותר זיכרון.
- רשימה מעגלית: הצומת האחרון מצביע לראשון. יתרונות: גישה קלה לראש הרשימה מהסוף, שימוש בתורים מעגליים. חסרונות: מורכבות בבדיקת סיום מעבר, קושי בזיהוי רשימה ריקה ללא מצביע נוסף.
- מחיקת צומת: יש למצוא את הצומת ואת קודמו. עדכון מצביעים:
prev->next = current->next;ו-current->next->prev = prev;(אם קיים). יש לטפל במקרה של מחיקת ראש הרשימה או צומת יחיד. לבסוף,free(current). - סיכונים: דליפות זיכרון (אי-שחרור זיכרון), מצביעים תלויים (dangling pointers), גישה לזיכרון לא חוקי. מזעור: הקפדה על
mallocו-free, אתחול מצביעים ל-NULL, בדיקתNULLלפני גישה למצביעים, סדר פעולות נכון בעדכון קישורים.