Menu

מערך דו-ממדי ב-C: מערכים רב-ממדיים, פריסה בזיכרון ומטריצות

איך מצהירים, מאתחלים ועוברים בלולאה על מערכים דו-ממדיים ב-C, מה פריסת row-major אומרת בפועל בזיכרון, למה העברת מערך דו-ממדי לפונקציה דורשת את מספר העמודות, ודוגמה מלאה עם מטריצה.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

מערך רב-ממדי הוא מערך שהאיברים שלו הם בעצמם מערכים. int grid[3][4]; הוא לא טיפוס מיוחד של רשת: אלה שלושה איברים, שכל אחד מהם הוא מערך של ארבעה int, שמאוחסנים ברצף. ברגע שזה מתיישב, כל השאר נובע מזה: הפריסה, החשבון של האינדקסים, והכלל שאחרת נראה מבלבל לגבי העברה שלהם לפונקציות.

שני ממדים מכסים כמעט כל שימוש מעשי: רשתות, טבלאות, מטריצות, לוחות משחק, תמונות, ולכן בזה העמוד הזה עוסק.

הצהרה ואתחול

int grid[3][4];        // 3 שורות, 4 עמודות: 12 int

המספר הראשון הוא כמה שורות, השני כמה עמודות. אפשר לכתוב אתחול שטוח או עם סוגריים מסולסלים פנימיים; כדאי להשתמש בסוגריים המסולסלים כי הם מראים את הצורה.

הצורה int e[][3] חשובה: אפשר להשאיר ריק את מספר השורות ולתת לאתחול להחליט, אבל מספר העמודות אף פעם לא אופציונלי. החלק הבא מסביר למה.

פריסת row-major

C שומרת מערך דו-ממדי בסדר row-major: כל שורה 0, אחר כך כל שורה 1, וכן הלאה, בבלוק זיכרון רציף אחד. אין מאחורי הקלעים מערך של מצביעים לשורות.

int grid[3][4] = {{ 1, 2, 3, 4},
                  { 5, 6, 7, 8},
                  { 9,10,11,12}};

how you picture it            how it actually sits in memory
+----+----+----+----+
|  1 |  2 |  3 |  4 |   row 0   +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+           | 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|
|  5 |  6 |  7 |  8 |   row 1   +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+            \__ row 0 __/\__ row 1 __/\_ row 2 _/
|  9 | 10 | 11 | 12 |   row 2
+----+----+----+----+           grid[i][j] is at element index i*4 + j

הנוסחה הזו, i * columns + j, היא כל המנגנון, וזו הסיבה שהקומפיילר חייב לדעת את מספר העמודות כדי לגשת לכל איבר. מספר השורות לא נכנס לחישוב אף פעם.

אפשר לראות את הפריסה ישירות על ידי הדפסת כתובות:

הכתובות עולות ב-sizeof(int) בלי רווחים, כולל במקום שבו שורה אחת נגמרת והבאה מתחילה. הלולאה השטוחה מוכיחה את זה: flat[k] עובר על כל שנים עשר האיברים כרצף אחד.

לפריסה הזו יש גם השלכה על ביצועים ששווה להכיר: לולאה על שורות ואז עמודות נוגעת בזיכרון לפי הסדר, וה-cache של המעבד אוהב את זה. החלפת הקינון כך שהלולאה הפנימית יורדת לאורך עמודה קופצת בכל פעם בשורה שלמה ועלולה לרוץ לאט פי כמה על מערך גדול.

לולאות מקוננות

שני ממדים דורשים שתי לולאות for: החיצונית בוחרת את השורה, הפנימית סורקת את העמודות של השורה הזו.

תנו למונים שמות לפי המשמעות שלהם (i/row לשורות, j/col לעמודות) ושמרו על סדר עקבי: grid[row][col] בכל מקום. חצי מכל הבאגים במערכים דו-ממדיים הם זוג אינדקסים שהתחלף.

הגדלים שהוגדרו עם #define הם גם לא קישוט: גבולות הלולאה וההצהרה כבר לא יכולים להתרחק זה מזה כשמשנים את הצורה.

העברת מערך דו-ממדי לפונקציה

זה הכלל שמכשיל את כולם: הפרמטר של הפונקציה חייב להצהיר על מספר העמודות.

הסיבה היא דעיכה. העברת grid ממירה אותו למצביע לאיבר הראשון שלו, והאיברים שלו הם שורות, כך שהטיפוס הוא int (*)[4]: מצביע למערך של 4 int. כדי ש-grid[i][j] יהיה בעל משמעות, הקומפיילר חייב לדעת מה האורך של שורה אחת, וזה ה-4. מספר השורות באמת לא נמצא בטיפוס, ולכן הוא עובר כארגומנט נפרד.

שימו לב ש-int (*grid)[COLS] ו-int grid[][COLS] הם אותו פרמטר בשתי צורות כתיבה. הסוגריים הכרחיים, כי int *grid[COLS] היה מערך של מצביעים. ההבחנה הזו מוסברת בעמוד על מצביעים ומערכים.

אם מספר העמודות ידוע רק בזמן ריצה, הפרמטרים עם גודל משתנה של C99 מאפשרים להעביר אותו קודם:

void print_any(int rows, int cols, int grid[rows][cols]);

את rows ואת cols חייבים להצהיר לפני פרמטר המערך שמשתמש בהם. במקומות שבהם זה לא זמין, החלופה הנפוצה היא מערך חד-ממדי שטוח ועוד חשבון אינדקסים ידני:

data[i * cols + j] הוא בדיוק מה שהקומפיילר כותב בשבילכם במקרה של גודל קבוע. לעשות את זה ביד עולה שורה אחת ועובד לכל צורה שנקבעת בזמן ריצה.

דוגמה עם מטריצה

כפל מטריצות מחבר את כל העמוד: שלוש לולאות מקוננות על אחסון row-major.

שני פרטים ששווה להעתיק. הלולאה הפנימית על k מצמידה את a[i][k] ל-b[k][j]: אינדקס אחד הולך לאורך שורה, השני לאורך עמודה. ופעולת ה-transpose מתחילה את הלולאה הפנימית שלה ב-j = i + 1: התחלה ב-0 הייתה מחליפה כל זוג פעמיים ומשאירה את המטריצה בלי שינוי.

שלושה ממדים ומעבר להם

הדפוס מתרחב, וכך גם הכלל על פרמטרים של פונקציות: כל ממד חוץ מהראשון חייב להיות מוצהר.

בפועל, בשלושה ממדים מערכים בגודל קבוע מתחילים להרגיש מסורבלים, ורוב הקוד עובר לבלוק שטוח עם אינדקסים מחושבים או למערך של structs שנותן שם למשמעות של כל ציר.

טעויות נפוצות

  • לכתוב grid[i, j]. אופרטור הפסיק מחשב את i, זורק אותו, וניגש עם j. זה מתקמפל. זה שגוי. השתמשו ב-grid[i][j].
  • להחליף בין האינדקסים. grid[col][row] קורא איבר אמיתי מהמקום הלא נכון, כך שאין שגיאה שתתפוס את זה. שמרו על הסדר [row][col] בכל מקום.
  • להשמיט את גודל העמודות בפרמטר. void f(int grid[][]) לא מתקמפל, וזה הקומפיילר שמציל אתכם.
  • לחרוג מהגבולות. כמו בכל מערך, אין בדיקת גבולות. grid[0][5] ברשת של [3][4] קורא בשקט את grid[1][1], כי הפריסה רציפה והחשבון לא אכפת לו.

שאלות נפוצות

איך מצהירים על מערך דו-ממדי ב-C?

כתבו שני גדלים בסוגריים מרובעים: int grid[3][4]; מצהיר על 3 שורות של 4 עמודות, 12 int בסך הכול. קראו את זה כ"מערך של 3 דברים, שכל אחד מהם הוא מערך של 4 int", וזה ממש האופן שבו C שומרת אותו.

איך מערך דו-ממדי נשמר בזיכרון ב-C?

בסדר row-major: כל האיברים של שורה 0, אחר כך כל האיברים של שורה 1, וכן הלאה, בבלוק רציף אחד. grid[i][j] נמצא בהיסט של i * columns + j איברים מההתחלה, ולכן מספר העמודות הוא המספר שהקומפיילר צריך.

איך מעבירים מערך דו-ממדי לפונקציה ב-C?

הפרמטר חייב להצהיר על מספר העמודות: void print(int grid[][4], int rows) או באופן שקול void print(int (*grid)[4], int rows). אפשר להשמיט את מספר השורות כי המערך דועך למצביע לשורה, אבל בלי גודל העמודות הקומפיילר לא יכול לחשב איפה שורה מתחילה.

אפשר לאתחל מערך דו-ממדי לאפסים בלבד?

כן: int grid[3][4] = {0}; מאפס כל איבר, כי כל איבר שלא מופיע ברשימה מאותחל לאפס. int grid[3][4] = {{1, 2}}; קובע את שתי הרשומות הראשונות של שורה 0 ומשאיר את עשר האחרות באפס.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל