1. Рядки різної довжини
Коли ми доходимо до таблиць, розкладів, матриць чи «даних за днями», зазвичай хочеться мислити так: є рядки й є стовпці. Але реальний світ рідко буває ідеальним. В одного студента 5 оцінок, в іншого — 3 (пропустив, хворів, кіт висмикнув Wi‑Fi). В одному тижні 7 днів, а в останньому тижні місяця — 2–3. І ось тут «прямокутна таблиця» починає страждати: доводиться заповнювати порожні клітинки нулями або якимись заглушками.
Зубчастий масив розвʼязує цю задачу. Це буквально «полиця з коробками», де кожна коробка може мати свою довжину. Ззовні в нас один масив (припустимо, тижні), а всередині — окремий масив для кожного тижня (наприклад, витрати за днями). І в кожного тижня може бути своя кількість днів або записів.
Щоб не плутатися в термінах, домовимося так: зовнішній масив — це таблиця, його елементи — це рядки, а кожен внутрішній масив — це рядок (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, вкладені цикли стають логічними та передбачуваними.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ