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("out of bounds") // (не выведется)
}
}
Обратите внимание на порядок: сначала 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("Topic $t: ")
for (score in scoresByTopic[t]) {
print("$score ")
}
println()
}
// Topic 0: 10 8 9
// Topic 1: 7 10
// Topic 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("row sum = $sum") // row sum = ...
}
// row sum = 27
// row sum = 17
// row sum = 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 $topic best = $best")
}
// topic 0 best = 10
// topic 1 best = 10
// topic 2 best = 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, вложенные циклы становятся логичными и предсказуемыми.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ