JavaRush /Курси /Kotlin SELF /Двовимірні масиви

Двовимірні масиви

Kotlin SELF
Рівень 7 , Лекція 4
Відкрита

1. Вступ

Поки ми працювали з одновимірними масивами, усе було доволі просто: дані йшли в лінію — одне значення за іншим. Такий формат чудово підходить для списків: оцінок одного студента, температур за днями, номерів задач або товарів у кошику.

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

У всіх цих випадках є важлива особливість: кожен рядок таблиці має однакову довжину. Якщо в одного студента є оцінки із пʼяти предметів, то й у всіх інших студентів цих предметів теж пʼять. Якщо ігрове поле має ширину десять клітинок, то кожен рядок складається рівно з десяти елементів. Саме ця властивість дає нам змогу мислити дані як прямокутну таблицю, а не просто як набір незалежних рядків.

Саме така структура й називається двовимірним масивом.

2. Тип Array<IntArray> і зміст, який ми в нього вкладаємо

З погляду Kotlin двовимірний масив не є окремим типом. Використовується все той самий, уже знайомий нам тип Array<IntArray>. Формально це означає: «масив, елементами якого є масиви цілих чисел». Kotlin не знає, хочемо ми використати цю структуру як прямокутну таблицю чи як набір рядків різної довжини.

Тому важливо розрізняти технічний і логічний бік. Технічно Array<IntArray> — це просто вкладені масиви. Логічно ж у межах цієї лекції ми вважаємо, що кожен внутрішній масив має однаковий розмір. Це не правило мови, а наша домовленість як програмістів.

Інакше кажучи, компілятор ніяк не завадить вам створити рядки різної довжини. Але якщо ви так зробите, то порушите модель «двовимірної таблиці», і код, який розраховує на прямокутну форму, почне працювати неправильно. Тож варто одразу зафіксувати домовленість: у цій лекції Array<IntArray> — це саме таблиця, а не зубчаста структура.

3. Як правильно уявляти двовимірний масив

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

Коли ми пишемо table[0], ми не отримуємо «перший стовпець» або «частину таблиці». Ми отримуємо повноцінний одновимірний масив — перший рядок. У нього є свій розмір, свої індекси та свої елементи. І лише потім, звертаючись до table[0][2], ми беремо конкретний елемент усередині цього рядка.

Саме тому доступ до елементів виглядає як два послідовні кроки: спочатку вибираємо рядок, а потім — елемент усередині рядка. Такий порядок природний і логічний, якщо сприймати таблицю як набір рядків.

Звідси й звичне позначення координат: [row][col], де row — номер рядка, а col — номер стовпця. Індексація, як і в усіх масивах Kotlin, починається з нуля. Перший рядок — це row = 0, перший стовпець — col = 0. Якщо таблиця має три рядки й чотири стовпці, то допустимі значення індексів — від 0 до 2 для рядків і від 0 до 3 для стовпців.

4. Створення двовимірного масиву

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

val table = arrayOf(
    intArrayOf(10, 20, 30),
    intArrayOf(40, 50, 60),
    intArrayOf(70, 80, 90)
)

Із цього фрагмента одразу видно, що таблиця складається з трьох рядків, і в кожному рядку — по три елементи. Такий спосіб особливо зручний для прикладів, тестових даних і навчальних задач.

Однак часто буває інакше: розміри таблиці відомі заздалегідь, а значення обчислюватимуться або заповнюватимуться пізніше. Наприклад, потрібно створити ігрове поле певного розміру або таблицю для зберігання проміжних результатів. У такому разі використовують конструктор Array, який дає змогу задати кількість рядків, а потім створити для кожного рядка масив потрібної довжини.

val rows = 3
val cols = 4
val table = Array(rows) { IntArray(cols) }

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

5. Доступ до елементів і розміри таблиці

Доступ до елементів двовимірного масиву завжди здійснюється через подвійну індексацію. Спочатку вказується номер рядка, потім — номер стовпця. Наприклад, вираз table[1][2] означає: взяти другий рядок таблиці й усередині нього — елемент з індексом 2.

Читання й запис елементів виглядають однаково просто:

val value = table[0][1]
table[2][0] = 42

Оскільки table[row] — це звичайний одновимірний масив, з ним можна працювати так само, як і раніше: дізнаватися його розмір, перебирати елементи, змінювати значення.

Для прямокутної таблиці зручно одразу визначити її розміри. Кількість рядків — це table.size, а кількість стовпців — table[0].size. Це безпечно саме тому, що ми домовилися: усі рядки мають однакову довжину. У зубчастих масивах такий прийом уже не працює — і це стане важливою темою наступної лекції.

Працюючи з індексами, завжди варто памʼятати про межі масиву. Щоб доступ був коректним, індекс рядка має належати table.indices, а індекс стовпця — допустимим індексам відповідного рядка. Для прямокутної таблиці, коли потрібно перевірити лише стовпець, досить орієнтуватися на перший рядок, адже всі інші мають таку саму довжину.

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

6. Обхід двовимірного масиву: як читати таблицю послідовно

Робота з двовимірним масивом майже завжди означає вкладені цикли. Але перш ніж писати код, корисно зафіксувати модель мислення. Двовимірний масив читається так само, як таблиця на папері: спочатку беремо рядок цілком, а потім рухаємося елементами всередині цього рядка. Тому зовнішній цикл відповідає за рядки, а внутрішній — за стовпці.

Коли нам важливо знати координати елемента, ми працюємо з індексами. Зовнішній цикл перебирає допустимі індекси рядків, а внутрішній — допустимі індекси стовпців. Оскільки в цій лекції ми розглядаємо прямокутну таблицю, кількість стовпців однакова для всіх рядків. Тому діапазон стовпців можна безпечно брати за першим рядком.

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(4, 5, 6)
    )

    for (r in table.indices) {
        for (c in table[0].indices) {
            print("[$r,$c]=${table[r][c]} ")
        }
        println()
    }
}

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

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

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(4, 5, 6)
    )

    for (row in table) {
        for (x in row) {
            print("$x ")
        }
        println()
    }
}

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

7. Базові операції з двовимірним масивом

Багато операцій, знайомих за одновимірними масивами, у двовимірному випадку просто «розгортаються» на два рівні. Наприклад, щоб порахувати суму всіх елементів таблиці, ми спочатку беремо один рядок, потім перебираємо всі значення всередині нього й накопичуємо суму. Далі переходимо до наступного рядка.

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(4, 5, 6)
    )

    var sum = 0
    for (row in table) {
        for (x in row) {
            sum += x
        }
    }

    println(sum)   // 21
}

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

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(4, 5, 6)
    )

    for (r in table.indices) {
        var rowSum = 0
        for (x in table[r]) {
            rowSum += x
        }
        println("рядок $r: сума = $rowSum")
    }
}

Сума за стовпцями потребує трохи іншого погляду на таблицю. Стовпці не представлені як окремі масиви, тому для кожного стовпця ми фіксуємо його індекс. Після цього проходимо всі рядки й дістаємо елемент із цим індексом.

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(4, 5, 6)
    )

    val cols = table[0].size

    for (c in 0 until cols) {
        var colSum = 0
        for (r in table.indices) {
            colSum += table[r][c]
        }
        println("стовпець $c: сума = $colSum")
    }
}

Цей код працює коректно саме тому, що таблиця прямокутна. Ми впевнені: у кожного рядка є елемент з індексом c.

8. Зміна елементів за координатами

Зміна значення у двовимірному масиві відбувається через ті самі квадратні дужки — тільки їх стає дві. Спочатку вказуємо номер рядка, потім — номер стовпця. Важливо читати такий запис суворо зліва направо: спочатку рядок, потім стовпець.

fun main() {
    val table = Array(3) { IntArray(3) }

    table[0][0] = 1
    table[1][1] = 5
    table[2][2] = 9

    for (row in table) {
        println(row.joinToString())
    }
}

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

if (row in table.indices && col in table[0].indices) {
    table[row][col] += 1
}

9. Типові помилки під час роботи з двовимірними масивами

Помилка № 1: плутати рядки й стовпці в індексації.
Одна з найпоширеніших проблем під час роботи з двовимірними масивами — переплутаний порядок індексів. У записі table[row][col] перший індекс завжди вказує на рядок, а другий — на стовпець. Якщо написати table[col][row], код може скомпілюватися й навіть якийсь час працювати, але логіка програми буде порушена. Особливо небезпечно те, що така помилка не завжди призводить до аварійного завершення застосунку, а отже, може довго залишатися непоміченою. Корисна звичка — використовувати імена row і col (або r і c) і читати вираз уголос: «рядок r, стовпець c».

Помилка № 2: використовувати table.indices для обходу стовпців.
table.indices описує допустимі індекси рядків, а не стовпців. Початківці часто використовують цей діапазон і в зовнішньому, і у внутрішньому циклі, вважаючи, що таблиця «симетрична». У прямокутній таблиці кількість рядків і стовпців може відрізнятися. Тому для стовпців потрібно використовувати table[0].indices або table[0].size. Це працює саме тому, що в межах цієї лекції ми припускаємо однакову довжину всіх рядків.

Помилка № 3: вихід за межі через неправильний діапазон.
Класична помилка «на одиницю» виглядає так: for (c in 0..table[0].size). Такий цикл обовʼязково спробує звернутися до елемента з індексом size, якого не існує. Останній допустимий індекс завжди дорівнює size - 1. Щоб не тримати це в голові постійно, краще використовувати indices: цей діапазон уже коректний і безпечний.

Помилка № 4: забувати, що стовпці не є масивами.
На відміну від рядків, які реально існують як масиви (IntArray), стовпці — це логічне представлення. Їх не можна взяти безпосередньо: у них немає власних size і indices. Щоб обробити стовпець, потрібно зафіксувати його індекс і пройтися по всіх рядках. Спроби «звертатися до стовпця як до масиву» майже завжди призводять до плутанини й помилок у логіці.

Помилка № 5: вважати, що будь-який Array<IntArray> — прямокутний.
Це найважливіша помилка в контексті всього блоку лекцій. У цій лекції ми свідомо працюємо з Array<IntArray> як із прямокутною таблицею й використовуємо це припущення в коді. Але сам тип цього не гарантує. Уже в наступній лекції ми побачимо масиви, де рядки мають різну довжину. І код, який тут виглядає природно, перестане бути безпечним. Саме тому важливо розуміти не лише синтаксис, а й модель даних, з якою ви працюєте.

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