JavaRush /Курси /Kotlin SELF /Зубчасті масиви: Array<IntArray>

Зубчасті масиви: Array<IntArray>

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

1. Рядки різної довжини

Коли ми доходимо до таблиць, розкладів, матриць чи «даних за днями», зазвичай хочеться мислити так: є рядки й є стовпці. Але реальний світ рідко буває ідеальним. В одного студента 5 оцінок, в іншого — 3 (пропустив, хворів, кіт висмикнув Wi‑Fi). В одному тижні 7 днів, а в останньому тижні місяця — 23. І ось тут «прямокутна таблиця» починає страждати: доводиться заповнювати порожні клітинки нулями або якимись заглушками.

Зубчастий масив розвʼязує цю задачу. Це буквально «полиця з коробками», де кожна коробка може мати свою довжину. Ззовні в нас один масив (припустимо, тижні), а всередині — окремий масив для кожного тижня (наприклад, витрати за днями). І в кожного тижня може бути своя кількість днів або записів.

Щоб не плутатися в термінах, домовимося так: зовнішній масив — це таблиця, його елементи — це рядки, а кожен внутрішній масив — це рядок (row). Якщо ви звикли до слів «двовимірний масив», думайте саме так, але з важливою обмовкою: «двовимірність» тут не зобовʼязана бути прямокутною.

Створюємо зубчасту структуру: рядки різної довжини

Найчастіше на перших кроках зручніше створювати зубчастий масив буквально «вручну» — через arrayOf(...) і intArrayOf(...). Це найчитабельніший спосіб, і він не потребує жодних додаткових знань.

Уявімо, що ми робимо мінізастосунок «Навчальний журнал», де в кожної теми різна кількість задач (то 2, то 5 — викладач теж людина). Нехай кожен рядок — це тема, а числа в рядку — бали за задачі.

fun main() {
    val scoresByTopic: Array<IntArray> = arrayOf(
        intArrayOf(10, 8, 9),        // тема 0: 3 задачі
        intArrayOf(7, 10),           // тема 1: 2 задачі
        intArrayOf(6, 6, 8, 10)      // тема 2: 4 задачі
    )

    println(scoresByTopic.size)      // 3
}

Тут scoresByTopic.size — це кількість тем (рядків). А ось кількість задач усередині теми — це вже scoresByTopic[topicIndex].size. Саме тут і зʼявляється перша складність: у кожного рядка свої межі.

Доступ table[row][col]: дві перевірки меж

Коли ви працюєте з одновимірним масивом, у вас одна перевірка: індекс має бути в a.indices. У зубчастому масиві перевірок стає дві: спочатку перевіряємо, що існує рядок, а вже потім — що всередині рядка існує стовпець.

Найчастіша помилка новачка — думати так: «я перевірив col за довжиною першого рядка, отже, все гаразд». Ні, не гаразд: другий рядок може бути коротшим. У зубчастому масиві немає „спільної ширини“.

Ось акуратний шаблон безпечного доступу:

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(10, 20)
    )

    val row = 1
    val col = 1

    if (row in table.indices && col in table[row].indices) {
        println(table[row][col])     // 20
    } else {
        println("поза межами")     // (не виведеться)
    }
}

Зверніть увагу на порядок: спочатку row in table.indices, а вже потім table[row]. Усе це не просто так: у такому порядку ви не ризикуєте звернутися до неіснуючого рядка.

2. Робота з рядками різної довжини

Вкладені цикли: як обходити таблицю, якщо рядки різної довжини

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

Найпростіший і безпечний обхід масиву — це отримання всіх значень:

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(10, 20)
    )

    for (row in table) {
        for (x in row) {
            print("$x ")
        }
        println()
    }
    // 1 2 3
    // 10 20
}

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

Але іноді індекси потрібні. Наприклад, для гарного виведення з координатами ([row][col]) або для зміни масиву. Тоді використовуємо indices і памʼятаємо, що вони можуть бути різними в кожного рядка:

fun main() {
    val table = arrayOf(
        intArrayOf(1, 2, 3),
        intArrayOf(10, 20)
    )

    for (r in table.indices) {
        for (c in table[r].indices) {
            print("[$r,$c]=${table[r][c]} ")
        }
        println()
    }
    // [0,0]=1 [0,1]=2 [0,2]=3
    // [1,0]=10 [1,1]=20
}

Чому це правильно? Тому що table[r].indices описує допустимі індекси стовпців саме для рядка r, а не «для всієї таблиці». Такий підхід прямо випливає з ідеї indices як безпечного діапазону індексів.

Межі рядків: чому не можна «міряти всіх за першим рядком»

Зараз буде важливий момент, який заощаджує години налагодження.

Уявіть таблицю:

  • рядок 0: довжина 3
  • рядок 1: довжина 2
  • рядок 2: довжина 4

Якщо ви зробите внутрішній цикл як for (c in 0..2), бо в першому рядку 3 елементи, то на другому рядку ви рано чи пізно полізете в table[1][2], якого не існує.

Саме тому варто тримати в голові просту думку: масив — це не завжди прямокутник. Найзручніше уявляти його як набір рядків, де кожен рядок — окремий масив зі своїм size.

Тримайте перед очима маленьку табличку:

Модель Як виглядає в коді Ідея ширини
«Прямокутна» таблиця умовно Array<Array<Int>>, де всі рядки однакові ширина спільна
Зубчаста таблиця Array<IntArray> ширина в кожного рядка своя

3. Практика: «Журнал задач за темами»

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

Виводимо таблицю

Спочатку зробімо гарне виведення: номер теми та бали.

fun main() {
    val scoresByTopic = arrayOf(
        intArrayOf(10, 8, 9),
        intArrayOf(7, 10),
        intArrayOf(6, 6, 8, 10)
    )

    for (t in scoresByTopic.indices) {
        print("Тема $t: ")
        for (score in scoresByTopic[t]) {
            print("$score ")
        }
        println()
    }
    // Тема 0: 10 8 9
    // Тема 1: 7 10
    // Тема 2: 6 6 8 10
}

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

Сума для кожного рядка: знайомий підхід у новому місці

Тепер застосуємо знайомий підхід «сума масиву», але для кожного рядка окремо. Важливо: змінна sum має створюватися заново для кожної теми. Інакше ви випадково отримаєте «загальну суму всього масиву».

fun main() {
    val scoresByTopic = arrayOf(
        intArrayOf(10, 8, 9),
        intArrayOf(7, 10),
        intArrayOf(6, 6, 8, 10)
    )

    for (row in scoresByTopic) {
        var sum = 0					// sum оголошуємо всередині зовнішнього циклу
        for (x in row) sum += x
        println("сума рядка = $sum")    // сума рядка = ...
    }
    // сума рядка = 27
    // сума рядка = 17
    // сума рядка = 30
}

Максимум у кожному рядку: акуратно стартуємо з першого елемента

Максимум — саме той випадок, коли не можна писати «нехай максимум буде 0» (інакше відʼємні значення ламають логіку). Беремо перший елемент рядка як стартове значення. Але такий підхід вимагає обовʼязкової перевірки: рядок має бути непорожнім.

fun main() {
    val scoresByTopic = arrayOf(
        intArrayOf(10, 8, 9),
        intArrayOf(7, 10),
        intArrayOf(6, 6, 8, 10)
    )

    for (topic in scoresByTopic.indices) {
        val row = scoresByTopic[topic]
        var best = row[0]
        for (x in row) 
        	if (x > best) best = x
        println("тема $topic: найкраще = $best")
    }
    // тема 0: найкраще = 10
    // тема 1: найкраще = 10
    // тема 2: найкраще = 10
}

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

Тепер уявімо, що викладач вирішив: «Якщо задачу розвʼязано з підказкою — мінус 2 бали». Припустімо, це тема 2, задача 1. Тобто координати (row=2, col=1).

Змінимо значення в масиві (з перевірками меж):

fun main() {
    val scoresByTopic = arrayOf(
        intArrayOf(10, 8, 9),
        intArrayOf(7, 10),
        intArrayOf(6, 6, 8, 10)
    )

    val row = 2
    val col = 1

    if (row in scoresByTopic.indices && col in scoresByTopic[row].indices) {
        scoresByTopic[row][col] -= 2
    }

    println(scoresByTopic[2][1])    // 4
}

Ключовий момент: друга перевірка — саме col in scoresByTopic[row].indices. Не «в 0..something», не «як у першого рядка», а конкретно за тим рядком, до якого ви звертаєтеся.

4. Як влаштована зубчаста таблиця в голові та в коді

Іноді корисно закріпити вивчене не кодом, а картинкою. Ось як можна уявляти Array<IntArray>:

flowchart TD
    T["table: Array⟨IntArray⟩"] --> R0["table[0]: IntArray (size=3)"]
    T --> R1["table[1]: IntArray (size=2)"]
    T --> R2["table[2]: IntArray (size=4)"]

    R0 --> A00["[0][0]"]
    R0 --> A01["[0][1]"]
    R0 --> A02["[0][2]"]

    R1 --> A10["[1][0]"]
    R1 --> A11["[1][1]"]

    R2 --> A20["[2][0]"]
    R2 --> A21["[2][1]"]
    R2 --> A22["[2][2]"]
    R2 --> A23["[2][3]"]

Зверніть увагу: у table[1] немає [1][2]. Прямокутні таблиці залишилися далеко в минулому. Така реальність програміста.

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

Помилка № 1: вважати, що в таблиці є „спільна ширина“.
Це найпопулярніша пастка: ви берете table[0].size і використовуєте її як ширину для всіх рядків. На коротких рядках це закінчується виходом за межі. У зубчастій структурі ширина — властивість кожного рядка, тому у внутрішньому циклі межі мають бути table[r].indices, а не „щось одне для всіх“.

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

Помилка № 3: перевіряти лише один рівень меж.
Новачки інколи перевіряють row in table.indices, але забувають про col in table[row].indices. Це особливо підступно: здається, що «я ж перевірив!», а все одно падає. Для доступу table[row][col] потрібні дві перевірки, тому що це два доступи [] поспіль.

Помилка № 4: робити внутрішній цикл за діапазоном 0..row.size замість коректних індексів.
Тут класичний off-by-one: діапазон 0..row.size включає row.size, а останній допустимий індекс — row.size - 1. Результат — вихід за межі на останній ітерації. Якщо хочете цикл за індексами, використовуйте row.indices — там уже правильні межі.

Помилка № 5: забувати, що „рядок“ — теж масив зі своїм життям.
Іноді люди пишуть код так, ніби table[r] — це просто «частина таблиці», а не повноцінний масив. Але в table[r] є свій size, його можна обходити, у ньому можна шукати максимум, суму тощо. Щойно ви починаєте сприймати рядок як звичайний IntArray, вкладені цикли стають логічними та передбачуваними.

1
Опитування
Масиви в Kotlin, рівень 7, лекція 5
Недоступний
Масиви в Kotlin
Масиви в Kotlin
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ