Tablica wielowymiarowa to tablica, której elementy same są tablicami. int grid[3][4]; to nie specjalny typ siatki, tylko trzy elementy, z których każdy jest tablicą czterech intów, ułożone jeden za drugim. Gdy to zaskoczy, reszta tematu wynika sama: układ w pamięci, arytmetyka indeksów i na pierwszy rzut oka niezrozumiała reguła przekazywania ich do funkcji.
Dwa wymiary obejmują prawie każde praktyczne zastosowanie: siatki, tabele, macierze, plansze gier, obrazy. Na nich skupia się ta strona.
Deklaracja i inicjalizacja
int grid[3][4]; // 3 wiersze, 4 kolumny: 12 intów
Pierwsza liczba to liczba wierszy, druga to liczba kolumn. Inicjalizatory można pisać płasko albo z wewnętrznymi klamrami; klamry warto stosować, bo pokazują kształt.
Forma int e[][3] ma znaczenie: liczbę wierszy możesz zostawić pustą i pozwolić, żeby ustalił ją inicjalizator, ale liczba kolumn nigdy nie jest opcjonalna. Następna sekcja wyjaśnia dlaczego.
Układ wierszowy
C przechowuje tablicę 2D w porządku wierszowym (row-major): cały wiersz 0, potem cały wiersz 1 i tak dalej, w jednym nieprzerwanym bloku pamięci. Za kulisami nie ma tablicy wskaźników na wiersze.
int grid[3][4] = {{ 1, 2, 3, 4},
{ 5, 6, 7, 8},
{ 9,10,11,12}};
jak to sobie wyobrażasz jak to naprawdę leży w pamięci
+----+----+----+----+
| 1 | 2 | 3 | 4 | wiersz 0 +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+ | 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|
| 5 | 6 | 7 | 8 | wiersz 1 +--+--+--+--+--+--+--+--+--+--+--+--+
+----+----+----+----+ \_ wiersz 0 _/\_ wiersz 1 _/\_ wiersz 2 _/
| 9 | 10 | 11 | 12 | wiersz 2
+----+----+----+----+ grid[i][j] ma indeks elementu i*4 + j
Ten wzór, i * columns + j, to cały mechanizm i dlatego kompilator musi znać liczbę kolumn, żeby cokolwiek zaindeksować. Liczba wierszy nigdy nie wchodzi do obliczeń.
Układ widać bezpośrednio po wypisaniu adresów:
Adresy rosną o sizeof(int) bez przerw, także tam, gdzie jeden wiersz się kończy, a następny zaczyna. Płaska pętla to udowadnia: flat[k] przechodzi wszystkie dwanaście elementów jako jeden ciąg.
Ten układ ma też konsekwencję dla wydajności, którą warto znać: pętla najpierw po wierszach, potem po kolumnach dotyka pamięci po kolei, co lubi pamięć podręczna procesora. Zamiana zagnieżdżenia pętli tak, żeby wewnętrzna szła w dół kolumny, za każdym razem przeskakuje o cały wiersz i na dużej tablicy może działać kilka razy wolniej.
Pętle zagnieżdżone
Dwa wymiary wymagają dwóch pętli for: zewnętrzna wybiera wiersz, wewnętrzna przechodzi kolumny tego wiersza.
Nazywaj liczniki zgodnie z tym, co oznaczają (i/row dla wierszy, j/col dla kolumn), i trzymaj się stałej kolejności: wszędzie grid[row][col]. Połowa błędów z tablicami 2D to zamieniona para indeksów.
Rozmiary z #define też nie są ozdobą: granice pętli i deklaracja nie mogą się teraz rozjechać, gdy zmienisz kształt.
Przekazywanie tablicy 2D do funkcji
Oto reguła, na której wykłada się każdy: parametr funkcji musi deklarować liczbę kolumn.
Powodem jest degradacja. Przekazanie grid zamienia ją we wskaźnik na jej pierwszy element, a jej elementami są wiersze, więc typem jest int (*)[4]: wskaźnik na tablicę 4 intów. Żeby grid[i][j] miało sens, kompilator musi wiedzieć, jak długi jest jeden wiersz, i tym jest właśnie 4. Liczby wierszy w typie naprawdę nie ma, dlatego wędruje jako osobny argument.
Zwróć uwagę, że int (*grid)[COLS] i int grid[][COLS] to ten sam parametr zapisany na dwa sposoby. Nawiasy są wymagane, bo int *grid[COLS] byłoby tablicą wskaźników. To rozróżnienie omawia strona o wskaźnikach i tablicach.
Jeśli liczba kolumn jest znana dopiero w czasie działania, parametry o zmiennej modyfikacji z C99 pozwalają przekazać ją najpierw:
void print_any(int rows, int cols, int grid[rows][cols]);
rows i cols muszą być zadeklarowane przed parametrem tablicowym, który ich używa. Tam, gdzie to niedostępne, typową alternatywą jest płaska tablica 1D plus ręczna arytmetyka indeksów:
data[i * cols + j] to dokładnie to, co kompilator pisze za ciebie w przypadku stałego rozmiaru. Zrobienie tego ręcznie kosztuje jedną linię i działa dla każdego kształtu ustalonego w czasie działania.
Przykład z macierzami
Mnożenie macierzy łączy całą tę stronę: trzy zagnieżdżone pętle po pamięci w układzie wierszowym.
Dwa szczegóły warto skopiować. Wewnętrzna pętla k łączy a[i][k] z b[k][j]: jeden indeks idzie po wierszu, drugi po kolumnie. A transpozycja zaczyna wewnętrzną pętlę od j = i + 1: start od 0 zamieniłby każdą parę dwa razy i zostawił macierz bez zmian.
Trzy wymiary i więcej
Wzorzec się rozszerza, podobnie jak reguła dla parametrów funkcji: każdy wymiar poza pierwszym musi być zadeklarowany.
W praktyce przy trzech wymiarach tablice o stałym rozmiarze zaczynają być nieporęczne i większość kodu przechodzi na płaski blok z obliczanymi indeksami albo na tablicę struktur, która nazywa, co oznacza każda oś.
Częste błędy
- Pisanie
grid[i, j]. Operator przecinka obliczai, odrzuca je i indeksuje przezj. Kompiluje się. Jest błędne. Używajgrid[i][j]. - Zamienione indeksy.
grid[col][row]odczytuje prawdziwy element z niewłaściwego miejsca, więc nie ma błędu, który by to wyłapał. Wszędzie trzymaj się kolejności[row][col]. - Pominięcie liczby kolumn w parametrze.
void f(int grid[][])się nie kompiluje i to kompilator cię ratuje. - Wyjście poza zakres. Jak w każdej tablicy, granice nie są sprawdzane.
grid[0][5]na siatce[3][4]po cichu odczytujegrid[1][1], bo układ jest ciągły, a arytmetyce to nie przeszkadza.
Najczęściej zadawane pytania
Jak zadeklarować tablicę dwuwymiarową w C?
Podaj dwa rozmiary w nawiasach kwadratowych: int grid[3][4]; deklaruje 3 wiersze po 4 kolumny, razem 12 intów. Czytaj to jako "tablica 3 elementów, z których każdy jest tablicą 4 intów", bo dokładnie tak C ją przechowuje.
Jak tablica dwuwymiarowa jest przechowywana w pamięci w C?
W porządku wierszowym: wszystkie elementy wiersza 0, potem wszystkie elementy wiersza 1 i tak dalej, w jednym ciągłym bloku. grid[i][j] leży o i * columns + j elementów od początku i dlatego kompilator potrzebuje właśnie liczby kolumn.
Jak przekazać tablicę dwuwymiarową do funkcji w C?
Parametr musi deklarować liczbę kolumn: void print(int grid[][4], int rows) albo równoważnie void print(int (*grid)[4], int rows). Liczbę wierszy można pominąć, bo tablica degraduje się do wskaźnika na wiersz, ale bez liczby kolumn kompilator nie obliczy, gdzie zaczyna się wiersz.
Czy można wyzerować całą tablicę dwuwymiarową przy inicjalizacji?
Tak: int grid[3][4] = {0}; zeruje każdy element, bo każdy element, którego nie wymienisz, jest inicjalizowany zerem. int grid[3][4] = {{1, 2}}; ustawia dwa pierwsze elementy wiersza 0, a pozostałe dziesięć zostawia na zerze.