JavaRush /Курсы /Kotlin SELF /Вложенные циклы: табличные шаблоны, поиск и labels

Вложенные циклы: табличные шаблоны, поиск и labels

Kotlin SELF
4 уровень , 5 лекция
Открыта

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 был бы понятнее. Метки хорошо работают точечно — там, где они реально объясняют намерение: “выйти из внешнего цикла поиска”.

1
Задача
Kotlin SELF, 4 уровень, 5 лекция
Недоступна
Карта локации
Карта локации
1
Задача
Kotlin SELF, 4 уровень, 5 лекция
Недоступна
Лестница бонусов
Лестница бонусов
1
Задача
Kotlin SELF, 4 уровень, 5 лекция
Недоступна
Рамка экрана
Рамка экрана
1
Задача
Kotlin SELF, 4 уровень, 5 лекция
Недоступна
Таблица урона
Таблица урона
1
Опрос
Циклы, 4 уровень, 5 лекция
Недоступен
Циклы
Циклы для управления потоком выполнения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ