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