JavaRush /Курси /Swift SELF /Вибір патерна: швидкість, памʼять і читабельність

Вибір патерна: швидкість, памʼять і читабельність

Swift SELF
Рівень 21 , Лекція 4
Відкрита

1. Навіщо обирати патерн замість двох вкладених циклів

Якщо ви новачок, бажання написати «просто два цикли» цілком нормальне. Це як узяти лопату й викопати котлован: працює, зрозуміло, мозок не перегрівається. Проблема починається тоді, коли «котлован» раптом розміром зі стадіон: програма формально правильна, але відповіді доводиться чекати довше, ніж вариться гречка. І це вже боляче.

Зафіксуймо просту річ: ми обираємо патерн не заради моди, а заради часу виконання й спокою. Вкладені цикли по масиву часто дають O(n²). Один прохід — O(n). Різниця звучить як «ну, удвічі», але насправді це «у тисячу разів і ще трохи зверху».

Щоб відчути це на практиці, можна зробити невелику оцінку «скільки операцій» — дуже грубо, зате корисно для інтуїції:

import Foundation

func roughOperationsQuadratic(n: Int) -> Int { n * n }
func roughOperationsLinear(n: Int) -> Int { n }

let n = 10_000
print("O(n)  ~= \\(roughOperationsLinear(n: n))")      // O(n) ≈ 10000
print("O(n²) ~= \\(roughOperationsQuadratic(n: n))")   // O(n²) ≈ 100000000

І ось тут зазвичай настає момент: «А можна без ста мільйонів?» Можна — якщо задача підходить до одного з патернів на сьогодні.

2. Як за умовою зрозуміти, чого від вас хочуть

Коли ви бачите задачу, важливо не одразу бігти в код, а на 20–30 секунд поставити собі запитання: що саме потрібно знайти — пару, вікно, найкращий відрізок чи багато запитів на діапазонах? Ця пауза потім економить пів години налагодження.

Нижче — компактна «карта вибору». Це не священна таблиця, але вона чудово допомагає, поки ви набиваєте руку.

Формулювання задачі Типовий наївний код Відповідний патерн Що саме пришвидшуємо
«Знайти пару елементів…» два цикли i/j two pointers (якщо є правило руху) один індекс зліва, інший справа / або схема read-write
«Порахувати для всіх відрізків довжини k…» зовнішній цикл по старту + внутрішній на k fixed sliding window сума/агрегат оновлюється за O(1)
«Знайти мінімальний/максимальний відрізок під умову…» два цикли, перебір усіх стартів і кінців variable sliding window right іде вперед, left наздоганяє
«Багато разів запитати суму на діапазоні…» для кожного запиту рахувати циклом prefix sums одноразовий попередній підрахунок, далі запити O(1)

Важливо: якщо ви читаєте задачу й у голові одразу зʼявляється «ну тут два цикли» — це не помилка. Це ваш базовий спосіб мислення пропонує гарантовано робоче рішення. Просто тепер у вас є другий шар мислення: «а чи немає тут вікна, відрізка, пар або запитів на діапазони?»

3. Швидкий детектор O(n²): червоні прапорці

Є маленька радість: більшість квадратичних рішень упізнаються за запахом. Прямо як підгорілі тости: ви ще не бачите їх, але вже розумієте, що щось пішло не так.

Перший червоний прапорець — вкладені цикли по одному масиву заради пошуку відповіді. Не завжди це погано, але це привід зупинитися й запитати себе: «а чи можна так, щоб кожен індекс проходив масив максимум один раз?»

Подивімося на класичний наївний приклад: усі суми вікон розміру k:

import Foundation

func windowSumsNaive(_ numbers: [Int], k: Int) -> [Int] {
    guard k > 0, numbers.count >= k else { return [] }
    var result: [Int] = []

    for start in 0...(numbers.count - k) {
        var sum = 0
        for i in start..<(start + k) { sum += numbers[i] }
        result.append(sum)
    }
    return result
}

Цей код милий і зрозумілий, але в ньому чітко видно: для кожного start ми знову й знову перераховуємо майже одні й ті самі елементи. Саме такі повтори й є «паливом» для sliding window.

Другий червоний прапорець — повторювані запити до одного й того ж масиву, особливо «сума на діапазоні». Якщо у вас один запит — цикл нормальний. Якщо 1000 запитів — уже хочеться префікси.

Третій червоний прапорець — неочевидна вартість операцій. Іноді код виглядає «майже O(n)», але всередині ховається щось дороге. Навіть стандартні операції можуть несподівано стати O(n) залежно від типу колекції та того, як ви її отримали. У Swift, наприклад, бувають ситуації, коли обчислення count або отримання last може виявитися дорожчим, ніж ви інтуїтивно очікуєте, якщо ви працюєте не зі звичайним масивом, а з більш «хитрою» послідовністю або колекцією. Такі моменти обговорюються в матеріалах про колекції та вартість операцій, і це добрий приклад того, що складність іноді ховається в деталях API.

З практичного погляду правило просте: на цьому етапі ми працюємо зі звичайними масивами, і в них усе достатньо передбачувано. Але звичку ставити запитання «а це точно дешево?» варто вирощувати вже зараз.

4. Вибір патерна: швидкість vs памʼять

Дуже легко потрапити в пастку «O(n) за будь-яку ціну». Але алгоритми — це завжди компроміс. Ми сьогодні обираємо між трьома ресурсами: часом, памʼяттю та зрозумілістю коду.

Prefix sums — ідеальний приклад. Вони дають швидкі запити O(1), але потребують додаткового масиву довжини n + 1. Це чесна угода: ми платимо памʼяттю, щоб не перераховувати суми заново.

Щоб це відчути, давайте зробимо крихітний приклад для нашого навчального файла ArrayPatternsDemo.swift. Уявіть, що ми пишемо консольну утиліту «аналітика за числами» — умовно про кроки за день, сторінки чи витрати; не має значення. Нам потрібно іноді швидко відповідати на запити «сума з дня l по день r».

Побудова префіксів:

import Foundation

func buildPrefixSums(_ numbers: [Int]) -> [Int] {
    var prefix = Array(repeating: 0, count: numbers.count + 1)
    for i in 0..<numbers.count {
        prefix[i + 1] = prefix[i] + numbers[i]
    }
    return prefix
}

Запит суми на включному діапазоні [l, r]:

import Foundation

func rangeSum(prefix: [Int], l: Int, r: Int) -> Int? {
    guard l >= 0, r >= l, (r + 1) < prefix.count else { return nil }
    return prefix[r + 1] - prefix[l]
}

І тут усе просто: якщо запитів мало — можна без префіксів. Якщо багато — префікси окуповуються.

5. Читабельність: коли швидше означає складніше

Дуже хочеться сказати «завжди робіть O(n)». Але давайте чесно: іноді O(n²) рішення простіше, а вхідні дані малі — і це нормально. Проблема починається тоді, коли ви не проговорили собі й читачеві коду, чому ви дозволили собі O(n²).

Професійна звичка тут проста: перед тим, як ускладнювати код патерном, оцініть ціну ускладнення.

Two pointers зазвичай читабельні, коли:

  • є чітке правило руху,
  • інваріант можна сформулювати одним реченням,
  • умова завершення проста (left < right).

Prefix sums читабельні майже завжди, якщо ви строго дотримуєтеся домовленості щодо індексів: prefix[i] = сума numbers[0..<i].

Variable window стає нечитабельним, якщо ви не можете словами пояснити, чому left рухається саме так. Тоді код перетворюється на «магічне смикання індексів», і через тиждень ви самі собі не повірите, що це писали ви.

Гарний критерій читабельності: якщо ви не можете пояснити алгоритм уголос за 30 секунд, не торкаючись коду, — значить, ви поки не готові писати його “на автоматі”. Краще спростити або хоча б залишити собі зрозумілий коментар-інваріант.

6. Дерево рішень: як обрати патерн

Зараз зберемо все в один простий алгоритм вибору. Уявіть, що ви стоїте перед задачею, і у вас є 6 запитань. Це не список «для галочки», а коротка внутрішня діагностика.

flowchart TD
    A["Задача на масиві"] --> B{"Потрібна пара елементів?"}
    B -->|Так| C["Two pointers (якщо масив відсортований / є правило руху)"]
    B -->|Ні| D{"Працюємо з відрізками (неперервні індекси)?"}
    D -->|Ні| E["Не наш випадок: можливо, потрібна інша структура (але сьогодні не заглиблюємося в Dictionary/Set)"]
    D -->|Так| F{"Потрібні значення для всіх вікон розміру k?"}
    F -->|Так| G["Fixed sliding window: running sum/агрегат"]
    F -->|Ні| H{"Потрібен найкращий/мінімальний відрізок під обмеження?"}
    H -->|Так| I["Variable sliding window (якщо умова \"піддається\" зсуву left)"]
    H -->|Ні| J{"Багато запитів суми по діапазонах?"}
    J -->|Так| K["Prefix sums: build O(n) + query O(1)"]
    J -->|Ні| L["Можливо, достатньо простого циклу O(n) або навіть O(n²) за малого n"]

Цей граф спеціально завершується варіантом «можливо, достатньо простого рішення». Тому що реальна мета — не впхнути патерн, а розвʼязати задачу коректно, швидко й так, щоб код не виглядав як шифровка.

7. Контракт функції та точка оновлення відповіді

У новачків часто «зависає» не алгоритм, а домовленість щодо результату. Наприклад: «якщо пару не знайшли — повернути 0?» А якщо 0 — це валідний індекс? А якщо масив порожній? Оце все — не дрібниці, це фундамент.

На цьому етапі ми вже вміємо повертати Optional, і це найкращий спосіб чесно сказати: «відповідь може бути відсутня».

Контракт: що повертаємо, якщо відповіді немає

Приклад: функція пошуку пари індексів у відсортованому масиві. Зверніть увагу, як сигнатура заздалегідь фіксує контракт: ми повертаємо саме індекси, і саме nil, якщо не знайшли.

import Foundation

func twoSumSorted(_ numbers: [Int], target: Int) -> (Int, Int)? {
    guard numbers.count >= 2 else { return nil }

    var left = 0
    var right = numbers.count - 1

    while left < right {
        let sum = numbers[left] + numbers[right]
        if sum == target { return (left, right) }
        sum < target ? (left += 1) : (right -= 1)
    }
    return nil
}

І тепер код, що викликає цю функцію, теж стає чистішим: ви не плутаєте «0 як відповідь» і «0 як ознаку помилки».

Точка оновлення відповіді: де саме ви “фіксуєте найкращий варіант”

Є одна річ, яка псує настрій частіше, ніж Index out of range: ви оновили відповідь не в той момент.

Наприклад, у variable sliding window для задачі «мінімальна довжина відрізка із сумою ≥ target» правильна логіка така: щойно умова виконується (sum >= target), вікно вже валідне, і ви можете покращити відповідь. А потім починаєте звужувати вікно, щоб зробити його ще коротшим.

import Foundation

func minLenAtLeast(_ target: Int, numbers: [Int]) -> Int? {
    guard target > 0 else { return nil }

    var left = 0
    var sum = 0
    var best: Int? = nil

    for right in 0..<numbers.count {
        sum += numbers[right]

        while sum >= target {
            let len = right - left + 1
            best = best.map { min($0, len) } ?? len

            sum -= numbers[left]
            left += 1
        }
    }
    return best
}

Якщо ви випадково переставите рядки й оновите best після left += 1, ви почнете втрачати кандидатів. Код компілюватиметься, іноді навіть працюватиме, але на підступному тесті дасть хибний результат.

Це важливий висновок для вибору патерна: патерн — це не лише «яка складність», а й де саме в циклі відбувається логіка відповіді. Якщо ви не впевнені в «точці оновлення», краще спочатку написати наївне рішення й порівняти результати на маленьких прикладах.

8. Мініпроєкт: один масив і різні запити

Щоб лекція не перетворилася на набір окремих трюків, зберемо їх в одну історію. Уявіть, що в нас є масив daily — скільки сторінок, кроків або балів ви набрали щодня. І нам раптом знадобилися різні аналітичні запити.

Запитання: чи є два дні, які дають рівно target

Тут ключове: потрібне правило руху, яке зазвичай зʼявляється, коли масив відсортований.

import Foundation

let sortedDaily = [1, 3, 4, 7, 10, 12]
if let (i, j) = twoSumSorted(sortedDaily, target: 14) {
    print("Знайшли пару: \\(sortedDaily[i]) + \\(sortedDaily[j])") // Знайшли пару: 4 + 10
} else {
    print("Пари немає")
}

Якщо масив не відсортований, two pointers «з кінців» не працює так красиво — і це добрий приклад межі застосовності патерна.

Запитання: максимальна сума за будь-які k днів поспіль

Це типова задача на «усі вікна однакової довжини».

import Foundation

func maxSumWindow(_ numbers: [Int], k: Int) -> Int? {
    guard k > 0, numbers.count >= k else { return nil }

    var sum = 0
    for i in 0..<k { sum += numbers[i] }

    var best = sum
    for i in k..<numbers.count {
        sum += numbers[i]
        sum -= numbers[i - k]
        best = max(best, sum)
    }
    return best
}

Запитання: мінімальна кількість днів поспіль, щоб набрати щонайменше target

Тут ключова передумова: якщо числа можуть бути відʼємними, сума поводиться немонотонно, і вікно працює погано. Тому, обираючи патерн, ви зобовʼязані запитати себе: які обмеження на дані?

import Foundation

let daily = [2, 3, 1, 2, 4, 3] // усі невідʼємні
print(minLenAtLeast(7, numbers: daily) ?? -1) // 2 (відрізок [4,3])

Запитання: у мене 500 запитів «сума з дня l по день r»

Prefix sums окуповуються саме великою кількістю запитів.

import Foundation

let prefix = buildPrefixSums(daily)
print(rangeSum(prefix: prefix, l: 1, r: 3) ?? -1) // 6 (3+1+2)
print(rangeSum(prefix: prefix, l: 0, r: 5) ?? -1) // 15

І в цей момент стає видно, навіщо взагалі потрібна лекція про вибір патерна: один і той самий масив, але різні запити, і «найкращий» підхід щоразу інший.

9. Типові помилки

Помилка №1: «патерн про всяк випадок», без причини.
Іноді студент вивчив prefix sums і починає будувати префікси всюди, навіть якщо потрібен один запит. Проблема не в тому, що це «погано», а в тому, що ви платите складністю коду й памʼяттю за вигоду, якою не користуєтеся. Це як купити вантажівку заради одного пакета молока: доїдете, але навіщо.

Помилка №2: змішування домовленостей щодо діапазонів [l, r] і [l, r) в одному рішенні.
У префіксів і вікон є непорушне правило — домовленість про індекси. Якщо ви сьогодні рахуєте суму для включного [l, r] через prefix[r + 1] - prefix[l], а завтра в сусідній функції раптом використовуєте логіку half-open [l, r), ви отримаєте «правильний код із неправильною відповіддю». Це особливо підступно, тому що компілятор вам не допоможе — усе легально.

Помилка №3: variable window без передумов (особливо за відʼємних чисел).
Змінне вікно працює, коли умова «піддається» руху left уперед без відкатів. Для сум це зазвичай означає невідʼємні елементи. Якщо в масиві є відʼємні числа, сума може зменшуватися під час розширення вікна, і алгоритм перестає бути коректним. Помилка виглядає як «ну я ж рухаю left і right, чому не працює?» — тому що інваріант відсутній.

Помилка №4: забули посунути вказівник в одній із гілок — нескінченний цикл.
Two pointers і variable window вимагають залізного правила: на кожній ітерації хтось має зсунутися. Якщо ви написали while left < right, але в гілці else не змінюєте ні left, ні right, цикл стає вічним. Це той випадок, коли програма не падає, не свариться, а просто «думає про вічне» й нічого не виводить.

Помилка №5: неправильна точка оновлення відповіді.
У fixed window зазвичай усе просто: оновили суму — оновили й відповідь. У variable window часто потрібно оновлювати відповідь до зсуву left, інакше ви втрачаєте найкращого кандидата. Ця помилка особливо неприємна тим, що на маленьких прикладах може «випадково проходити», а на справжніх даних ламає результат.

1
Опитування
Два вказівники, рівень 21, лекція 4
Недоступний
Два вказівники
Патерни two pointers і sliding window
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ