JavaRush /Курси /C++ SELF /2D‑масиви: layout та індексація

2D‑масиви: layout та індексація

C++ SELF
Рівень 11 , Лекція 4
Відкрита

1. Синтаксис 2D‑масиву та базові операції

Коли ви вперше бачите двовимірний масив, мозок зазвичай робить одразу дві речі: уявляє собі Excel і згадує шкільну алгебру з матрицями — інколи з легким тремтінням. Але в програмуванні 2D‑масив — це не «страшна математика», а просто зручний спосіб зберігати дані, які природно мають вигляд таблиці: рядки, стовпці, клітинки, координати.

Наприклад, це може бути таблиця оцінок (студенти × контрольні), розклад (дні × пари), невелике ігрове поле (3×3) або «температура за містами й днями тижня». У таких задачах одного індексу «i» вже не вистачає, щоб описати реальність: хочеться сказати «рядок r, стовпець c» і отримати значення в цій клітинці.

Оголошення T m[R][C]

Запис int m[R][C] під час першого знайомства виглядає як заклинання, особливо якщо ви ще не звикли до квадратних дужок. Хороша новина: це заклинання легко перекласти людською мовою. m — це масив із R елементів, і кожен із цих R елементів — ще один масив із C елементів типу int. Тобто це буквально «масив масивів».

Зазвичай розміри задають константами часу компіляції. На нашому рівні це означає: використовуємо constexpr std::size_t, щоб випадково не переплутати числа в коді й не отримати «магічні константи» у пʼяти місцях.

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t R = 2;
    constexpr std::size_t C = 3;

    int m[R][C] = {}; // усі елементи — 0
    std::cout << "Rows=" << R << " Cols=" << C << '\n'; // Rows=2 Cols=3
}

Зверніть увагу: це фіксований двовимірний масив. Він створюється повністю й одразу, а розміри R і C не змінюються впродовж роботи програми — принаймні в межах цієї теми.

Індексація m[r][c]

З індексацією в 2D‑масиві головне — не намагатися «вгадувати» і не сподіватися на вдачу. Правило просте: m[r][c] читається так: «у масиві m взяти рядок r, а в цьому рядку — стовпець c». Тобто перший індекс вибирає рядок, а другий — елемент усередині рядка. І все: жодної магії, лише дисципліна.

Межі тут такі самі, як і в одновимірному масиві. Для R рядків допустимі r = 0..R-1, для C стовпців допустимі c = 0..C-1. Будь-який вихід за межі — це UB (непередбачувана поведінка), тобто «може працювати, може не працювати, може вдавати, що працює, а потім раптово зіпсувати вам вечір».

Корисно бодай раз побачити індекси у вигляді таблиці:

r\c
0 1 2
0
m[0][0]
m[0][1]
m[0][2]
1
m[1][0]
m[1][1]
m[1][2]

А ось невеликий приклад, у якому ми «беремо конкретну клітинку»:

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t R = 2, C = 3;
    int m[R][C] = {{1, 2, 3}, {4, 5, 6}};

    std::cout << m[1][2] << '\n'; // 6
}

Обхід: вкладені цикли

Коли зʼявляються два індекси, зʼявляються й два цикли. Це звучить очевидно, але саме тут виникають найпоширеніші помилки: переплутали, який цикл за що відповідає, або поставили неправильну межу. Надійний шаблон такий: зовнішній цикл іде по рядках, внутрішній — по стовпцях. Так код читається так само, як ми зазвичай читаємо таблиці: рядок за рядком.

Прислухайтеся, як природно це звучить: «для кожного рядка r, для кожного стовпця c». Це майже звичайна українська мова — тільки з крапками з комою.

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t R = 2, C = 3;
    int m[R][C] = {{1, 2, 3}, {4, 5, 6}};

    for (std::size_t r = 0; r < R; ++r) {
        for (std::size_t c = 0; c < C; ++c) {
            std::cout << m[r][c] << ' ';
        }
        std::cout << '\n';
    }
    // 1 2 3
    // 4 5 6
}

Зауважте: ми використовуємо std::size_t для індексів. Це узгоджується з розмірами й менше провокує дивні порівняння.

Ініціалізація: фігурні дужки «у два поверхи»

Ініціалізація двовимірного масиву — це той момент, коли фігурні дужки починають виглядати як конструкція «одна в одній». Але ідея тут дуже проста: зовнішні дужки задають рядки, внутрішні — значення в рядку. Якщо ви бачите {{...}, {...}, {...}}, це майже завжди означає «три рядки», і в кожному рядку — свій набір значень.

Масив можна ініціалізувати повністю, частково або виконати «нульову» ініціалізацію. Часткова ініціалізація корисна, коли ви задаєте лише кілька елементів, а решту хочете гарантовано заповнити нулями, а не «сміттям».

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t R = 3, C = 3;

    int grid[R][C] = {
        {1, 2},      // третій елемент стане 0
        {3},         // другий і третій стануть 0
        {}           // увесь рядок — нулі
    };

    std::cout << grid[0][2] << ' ' << grid[2][1] << '\n'; // 0 0
}

Якщо вам потрібен передбачуваний старт, а він майже завжди потрібен, то = {} або просто {} — це ваш невеликий щит від хаосу.

2. Layout та звʼязок із плоским масивом

Як 2D‑масив розташований у памʼяті

Слово layout звучить так, ніби ми зараз будемо верстати сайт і сперечатися, де поставити кнопку. Але тут ідеться про інше: як дані фізично розміщені в памʼяті. Для двовимірного масиву важливо знати хоча б одну ідею: елементи кожного рядка лежать підряд, а потім іде наступний рядок. Це називається розміщенням «за рядками» (row-major order) — так прийнято в C/C++.

Чому це корисно знати? По‑перше, щоб не плутатися, чому формула «плоского індексу» має саме такий вигляд. По‑друге, щоб розуміти, чому обхід «рядок за рядком» зазвичай вважають природним. Ми не заглиблюватимемося в оптимізацію, але модель «рядки підряд» стане у пригоді вже зараз, коли ми повʼязуватимемо двовимірну індексацію з одновимірною.

Уявімо масив 2×3:

m[0][0] m[0][1] m[0][2]  m[1][0] m[1][1] m[1][2]

Тобто спочатку йдуть три елементи нульового рядка, а потім — три елементи першого рядка.

Перевірка «на пальцях» через sizeof теж дає корисні підказки:

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t R = 2, C = 3;
    int m[R][C] = {};

    std::cout << sizeof(m) << '\n';      // розмір усього 2D-масиву в байтах
    std::cout << sizeof(m[0]) << '\n';   // розмір одного рядка (C елементів) у байтах
}

Якщо у вас int займає 4 байти, то sizeof(m[0]) буде 3 * 4 = 12, а sizeof(m) буде 2 * 12 = 24. Важлива думка: рядок m[0] — це теж масив, лише з C елементів.

Формула r * C + c

А тепер прийом, який часто дає зрозуміти 2D‑індексацію краще за довгі пояснення. Якщо рядки лежать підряд, то клітинка (r, c) у плоскому масиві довжини R*C матиме індекс r * C + c. Тут C — це саме кількість стовпців, тобто ширина рядка. Це критично: множник завжди показує, скільки елементів міститься в одному рядку, бо ми «перестрибуємо» через цілі рядки.

Порівняймо доступ:

  • у 2D: m[r][c]
  • у 1D: flat[r * C + c]

Ось невелика демонстрація, у якій дані однакові:

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t R = 2, C = 3;

    int m[R][C] = {{1, 2, 3}, {4, 5, 6}};
    int flat[R * C] = {1, 2, 3, 4, 5, 6};

    std::size_t r = 1, c = 2;
    std::cout << m[r][c] << '\n';              // 6
    std::cout << flat[r * C + c] << '\n';      // 6
}

Це не означає, що ми завжди маємо зберігати все «плоско». Це означає, що ви розумієте, що відбувається під капотом: рядок — це блок довжини C, а між рядками ми переходимо множенням.

3. Практичний приклад: температури за містами й днями

Щоб не залишати 2D‑масиви на рівні «теорії про матриці», розгляньмо невеликий сценарій. Нехай у нас є кілька міст (рядки) і кілька днів (стовпці). Ми хочемо ввести температури й акуратно вивести їх у вигляді таблиці. Зверніть увагу: це все ще базові конструкції — введення, вкладені цикли та чіткі межі.

Спочатку просто створимо й заповнимо таблицю:

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t Cities = 2;
    constexpr std::size_t Days = 3;

    int t[Cities][Days] = {};

    for (std::size_t city = 0; city < Cities; ++city) {
        for (std::size_t day = 0; day < Days; ++day) {
            std::cin >> t[city][day];
        }
    }

    std::cout << "OK\n"; // OK
}

Тепер виведемо таблицю. Тут зручно згадати форматування виведення: ми вже бачили setw раніше, і воно ідеально підходить для таблиць. Якщо вам здається, що це «надто красиво для новачка», то повірте: таблиця без форматування зазвичай виглядає так, ніби «цифри посварилися й розбіглися».

#include <cstddef>
#include <iomanip>
#include <iostream>

int main() {
    constexpr std::size_t Cities = 2;
    constexpr std::size_t Days = 3;

    int t[Cities][Days] = {{-1, 0, 2}, {3, 1, -2}};

    for (std::size_t city = 0; city < Cities; ++city) {
        for (std::size_t day = 0; day < Days; ++day) {
            std::cout << std::setw(4) << t[city][day];
        }
        std::cout << '\n';
    }
    //  -1   0   2
    //   3   1  -2
}

Якщо захочете додати рядок заголовка з номерами днів, це теж можна зробити звичайним циклом — без нових тем. Важливо лише памʼятати: виведення заголовка — це окремий прохід по стовпцях.

4. Ментальна модель «масив масивів»

У 2D‑масивах дуже легко почати думати: «це якась особлива структура». І тут корисно повернутися до простої моделі: m — масив рядків, а кожен рядок — масив елементів. Тоді багато речей одразу стають логічними. Наприклад, m[r] — це рядок; у нього є C елементів; тому m[r][c] — це елемент рядка.

Навіть читання оголошення починає сприйматися спокійніше. int m[R][C]; означає: «m зберігає R рядків, а кожен рядок зберігає C цілих чисел». І коли ви пишете вкладені цикли, то, по суті, формулюєте просту ідею: «обійти всі рядки, а в кожному рядку — всі елементи». Це майже пояснення алгоритму звичайними українськими словами — тільки компілятору потрібні фігурні дужки.

Якщо тримати в голові цю модель, ви рідше плутатимете індекси місцями й рідше ставитимете неправильні межі. А ще вона підготує вас до моменту, коли ви побачите, що «рядок» можна передавати й використовувати окремо, — але це вже поза межами сьогоднішньої теми.

5. Типові помилки під час роботи з 2D‑масивами

Помилка № 1: переплутали межі R і C у циклах.
Дуже поширена ситуація: зовнішній цикл іде до C, внутрішній — до R, а потім ви дивуєтеся, чому програма читає «не туди» або виходить за межі. Тут допомагає проста домовленість щодо імен: rows/cols або r/c, а також дисципліна: r < R, c < C.

Помилка № 2: використали <= замість <.
У двовимірних масивах ця помилка стає «вдвічі веселішою», бо можна вийти за межі або за рядками, або за стовпцями. Формулювання тут має бути як мантра: останній допустимий індекс — R-1 або C-1, тому в циклі завжди r < R і c < C.

Помилка № 3: переплутали порядок індексів і почали писати m[c][r].
Компілятор не сваритиметься: це все ще «два числа в дужках». Але сенс буде неправильний, а баги — підступні. Рятує просте правило: завжди m[row][col], і якщо ви друкуєте таблицю, то зовнішній цикл — це рядки.

Помилка № 4: неправильний множник у формулі плоского індексу.
Якщо ви переводите (r, c) у flat[...], множник має бути C, а не R. У цьому місці помилка особливо прикра: дані читатимуться «ніби перемішані», і виглядає це майже як містика. Насправді ж це просто неправильна «ширина рядка» в обчисленні.

Помилка № 5: забули про передбачувану ініціалізацію.
int m[R][C]; створює масив, але не гарантує, що там будуть нулі. Якщо ви потім друкуєте «що там лежить» до введення, то читаєте сміття. Якщо потрібен безпечний старт, пишіть = {} або {} і спіть спокійно — принаймні в цій частині життя.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ