1. Введение
Вложенный цикл — это когда внутри одного цикла живёт другой цикл. Звучит как матрёшка, и, в общем-то, так и есть. Зачем это нужно? Потому что реальный мир часто “двумерный”: строки и столбцы в таблице, координаты на сетке, пары чисел (a, b) в диапазоне 1..9, перебор вариантов “каждый с каждым”. Один цикл отвечает за внешний “шаг”, второй — за внутреннюю “начинку” шага.
Самое важное, что нужно понять на старте: вложенность — это не магия и не отдельная конструкция языка. Это обычный цикл, внутри которого вы написали ещё один обычный цикл. И выполняется оно предсказуемо: внешний цикл делает одну итерацию — и на этой итерации внутренний цикл пробегает все свои итерации целиком.
Чтобы не потеряться, будем мыслить так: внешний цикл задаёт “строки” (или “попытки”, или “первый параметр”), внутренний цикл — “столбцы” (или “перебор внутри попытки”, или “второй параметр”).
2. Как выполняются вложенные циклы и табличный вывод
Модель выполнения: внешний шаг → внутренний пробег → следующий внешний шаг
Когда читаешь вложенные циклы, мозг сначала пытается представить “два времени одновременно” и начинает перегреваться, как ноутбук на пледе. Спасает очень простая модель: внешний цикл делает один шаг, затем внутренний цикл полностью отрабатывает от начала до конца, затем внешний цикл делает следующий шаг, и снова запускается внутренний целиком. Это похоже на ситуацию “на каждой строке распечатай все столбцы”.
Представим мини-табличку (идея, не код):
| Внешний row | Внутренний col пробегает |
|---|---|
| 1 | 1, 2, 3 |
| 2 | 1, 2, 3 |
| 3 | 1, 2, 3 |
И вот важная деталь, которая потом станет ключевой для break: без дополнительных трюков break и continue относятся только к ближайшему (самому внутреннему) циклу. То есть “сломать внутренний цикл” — легко, “сломать сразу оба” — уже требует либо хитрости, либо метки (labels).
Печатаем координаты (row, col) как сетку
Самый “честный” способ почувствовать вложенность — напечатать сетку координат. Это настолько прямолинейно, что даже приятно: внешний цикл печатает строки, внутренний — элементы строки. Тут очень важно вспомнить разницу между print() и println(): print() не переводит строку, а println() переводит. Мы будем печатать элементы строки через print(), а в конце каждой строки делать println().
Небольшой пример:
fun main() {
for (row in 1..3) {
for (col in 1..3) {
print("($row,$col) ")
}
println() // переход на новую строку после ряда
}
// (1,1) (1,2) (1,3)
// (2,1) (2,2) (2,3)
// (3,1) (3,2) (3,3)
}
Здесь внешний row идёт от 1 до 3. На каждом значении row внутренний col снова идёт от 1 до 3 и печатает пары. А println() после внутреннего цикла — это наш “конец строки” в табличном смысле.
Если вы вдруг случайно поставите println() внутри внутреннего цикла, то “таблица” развалится: каждый элемент будет на новой строке. Это не ошибка компиляции — это логическая ошибка, и она особенно популярна у новичков, потому что println выглядит “нормальнее”, чем print.
Рисуем прямоугольник символами и рамку
После координат хочется чего-то чуть более “визуального”, чтобы вложенность ощущалась не как математика, а как “рисование”. Мы пока не используем графику, но у нас есть консоль и символы. Сделаем прямоугольник из "#", например 4 строки на 6 столбцов. Внешний цикл — строки, внутренний — столбцы, и на каждом столбце печатаем "#".
fun main() {
val height = 4
val width = 6
for (row in 1..height) {
for (col in 1..width) {
print("#")
}
println()
}
// ######
// ######
// ######
// ######
}
А теперь маленькая “добавка смысла”: можно печатать рамку, а внутри точки. Для этого нам нужна проверка “мы на границе или внутри”. Граница — это первая/последняя строка или первый/последний столбец. Всё остальное — внутренность.
fun main() {
val height = 4
val width = 6
for (row in 1..height) {
for (col in 1..width) {
val isBorder = row == 1 || row == height || col == 1 || col == width
print(if (isBorder) "#" else ".")
}
println()
}
// ######
// #....#
// #....#
// ######
}
Да, это уже похоже на “программирование”: вложенные циклы дают форму, if задаёт правило, как заполнить эту форму.
Мини-таблица умножения с вводом размера
Таблица умножения — классика жанра. Она хороша тем, что даёт понятную цель: число row * col. И вложенные циклы тут естественны: строка — это множитель row, столбец — множитель col. А ещё это хороший повод аккуратно встроить устойчивый ввод: попросим пользователя ввести размер таблицы и будем повторять запрос, пока он не введёт корректное число.
Сделаем небольшой кусочек “нашего общего приложения”, условно назовём его LoopLab: программа спрашивает размер таблицы и печатает таблицу умножения.
fun main() {
var size: Int? = null
while (size == null) {
print("Размер таблицы (1..9): ")
val n = readln().trim().toIntOrNull()
size = if (n != null && n in 1..9) n else null
if (size == null) println("Нужно целое число от 1 до 9.")
}
for (row in 1..size) {
for (col in 1..size) {
print("${row * col} ")
}
println()
}
}
Этот пример специально печатает без красивого выравнивания, потому что “форматирование колонок” — это отдельная тема (и мы до неё доберёмся позже). Сейчас наша цель — увидеть вложенность: один row порождает целую строку значений row*1, row*2, …
3. Поиск во вложенных циклах: как остановиться вовремя
Наивный поиск по парам и ловушка break
Теперь перейдём к более “алгоритмической” (но всё ещё простой) части: вложенные циклы часто нужны не ради красивой таблицы, а ради перебора вариантов. Типичный сценарий звучит так: “найти два числа a и b из диапазона 1..9, чтобы a * b == target”. Это поиск по сетке вариантов. И снова: внешний цикл перебирает a, внутренний — b.
Начнём с наивного, но очень важного примера — он покажет главную ловушку:
fun main() {
val target = 12
for (a in 1..9) {
for (b in 1..9) {
if (a * b == target) {
println("Нашли: $a * $b = $target") // например: 2 * 6
break
}
}
}
}
На первый взгляд кажется: “мы нашли — делаем break — значит всё закончилось”. Но нет. Этот break завершает только внутренний цикл for (b in 1..9). Внешний цикл for (a in 1..9) продолжит работу, и вы получите несколько находок (например 2*6 и 3*4 и 4*3 и 6*2), а иногда — получите повторные сообщения, хотя хотели остановиться на первом совпадении.
И вот тут мы приходим к двум стратегиям остановки “двух циклов”: через флаг и через метку.
Остановка без labels: флаг found и проверка во внешнем цикле
Стратегия “флаг” — самая прямолинейная и часто самая читаемая для начинающих. Мы заводим переменную found (обычно типа Boolean), а также переменные для результата (foundA, foundB). Как только нашли — выставляем found = true, сохраняем значения и выходим из внутреннего цикла обычным break. Внешний цикл либо учитывает found в условии, либо сам делает break, когда видит, что found == true.
Покажу вариант на while, потому что там удобно усилить условие внешнего цикла:
fun main() {
val target = 12
var found = false
var foundA = -1
var foundB = -1
var a = 1
while (a <= 9 && !found) {
var b = 1
while (b <= 9) {
if (a * b == target) {
found = true
foundA = a
foundB = b
break
}
b = b + 1
}
a = a + 1
}
println("Результат: ($foundA, $foundB)") // например: (2, 6)
}
Плюс флага в том, что он не требует никаких “особых” конструкций. Минус — нужно следить, чтобы внешнее условие действительно учитывало found, иначе внешний цикл продолжит работу и смысл флага потеряется.
4. Labels для break и continue
Labels в Kotlin — это способ “подписать” кусок кода (часто цикл) именем и потом обратиться к нему в jump-операторах: break@имя или continue@имя. Это не ежедневный инструмент, но иногда он делает код короче и понятнее, чем флаги.
Синтаксис выглядит так: outer@ for (...) { ... } — это цикл с меткой outer. А выйти из него можно break@outer.
Пример “нашли — выходим сразу из обоих циклов”:
fun main() {
val target = 12
var foundA = -1
var foundB = -1
outer@ for (a in 1..9) {
for (b in 1..9) {
if (a * b == target) {
foundA = a
foundB = b
break@outer
}
}
}
println("Результат: ($foundA, $foundB)") // например: (2, 6)
}
Здесь break@outer завершает именно цикл с меткой outer, то есть внешний цикл, а вместе с ним автоматически прекращается и внутренний, потому что мы “выпрыгнули” наружу.
С continue@outer идея похожая: это “перейти к следующей итерации внешнего цикла”. Это бывает полезно, когда внутренний цикл обнаружил условие, из-за которого продолжать текущий внешний a бессмысленно. Но предупрежу честно: continue@outer читается тяжелее, чем break@outer, и новичкам проще начинать с флагов.
Чтобы не превращать код в квест, держите в голове простое правило вкуса: если флаг делает код понятным — используйте флаг. Если флагов становится два-три и логика расползается, а метка делает код короче и прямее — тогда метка может быть оправдана.
Небольшая табличка для ощущения выбора:
| Ситуация | Флаг | Метка |
|---|---|---|
| Надо “остановить два цикла” один раз | Часто читаемо | Часто короче |
| Новичку важно понимать, “почему остановилось” | Обычно проще | Нужно привыкнуть к break@name |
| Логика сложная и флагов много | Может стать кашей | Метка иногда спасает |
5. Типичные ошибки
Ошибка №1: ожидать, что break остановит оба цикла.
Это одна из самых частых ловушек: break завершает только ближайший (внутренний) цикл. В результате программа продолжает внешний перебор и печатает несколько “найдено”, хотя вы хотели одно. Если нужно остановить оба, используйте либо флаг во внешнем условии, либо break@label.
Ошибка №2: забыть напечатать перевод строки после внутреннего цикла в табличном выводе.
Если вы печатаете элементы строки через print, но забыли println() после внутреннего цикла, то вся “таблица” превратится в одну бесконечную строку. Это не ошибка синтаксиса, поэтому компилятор не поможет — помогает только внимательное чтение: “где заканчивается строка таблицы?”.
Ошибка №3: неправильное место для println() — внутри внутреннего цикла.
Обратная проблема: вы ставите println() внутрь внутреннего цикла, и вместо таблицы получаете столбик. Формально программа “работает”, но смысл сломан. Хороший приём самопроверки: внутренняя итерация должна добавлять элемент в текущую строку, а внешняя — завершать строку.
Ошибка №4: флаг found есть, но внешний цикл его не учитывает.
Иногда пишут found = true, выходят из внутреннего цикла, но внешний цикл продолжает крутиться, потому что в условии внешнего цикла нет && !found (или после внутреннего цикла нет if (found) break). В итоге переменные результата могут перезаписываться, а поведение становится “случайным”.
Ошибка №5: метки превращаются в “обязательную магию” и ухудшают читаемость.
Labels — это инструмент, а не стиль жизни. Если вы ставите метку на каждый цикл “на всякий случай”, код становится тяжелее для чтения: появляются break@something там, где обычный break был бы понятнее. Метки хорошо работают точечно — там, где они реально объясняют намерение: “выйти из внешнего цикла поиска”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ