3. Climbing Stairs
«Лестница к совершенству строится по одной ступени за раз».
— Дональд Кнут
Задача о подъеме по лестнице (Climbing Stairs, LeetCode 70) — общепризнанный «Hello, World!» в мире динамического программирования. Большинство кандидатов пишут решение за пару минут и переходят дальше, упуская фундаментальные концепции: от экспоненциального взрыва дерева вызовов и поведения стека горутин до матричного возведения в степень за $O(\log N)$.
Мы разберем эту задачу с позиции Principal Go-инженера: препарируем математическую модель, визуализируем отсечение дублирующихся подзадач, исследуем работу аппаратного кэша и устраним аллокации в куче до абсолютного нуля.
1. Постановка задачи и комбинаторная модель
Условие:
Вы поднимаетесь по лестнице из $n$ ступеней. За один шаг разрешается преодолеть либо 1, либо 2 ступени.
Сколько существует уникальных способов добраться до самой верхней, $n$-й ступени?
Вход: n = 3
Выход: 3
Способы:
1. 1 шаг + 1 шаг + 1 шаг
2. 1 шаг + 2 шага
3. 2 шага + 1 шаг
Рекуррентное соотношение
Зададимся вопросом: в какой точке мы находились за мгновение до того, как ступили на ступень $i$?
- Либо на ступени $i-1$ (и сделали шаг на 1 ступень).
- Либо на ступени $i-2$ (и сделали прыжок на 2 ступени).
Третьего не дано. Поскольку эти события взаимоисключающие, общее число способов $dp[i]$ строго равно сумме вариантов:
$$dp[i] = dp[i-1] + dp[i-2]$$
Базовые случаи очевидны:
- $dp[1] = 1$ (единственный путь:
[1]) - $dp[2] = 2$ (два пути:
[1, 1]и[2])
Перед нами классическая последовательность Фибоначчи со сдвигом индекса на единицу!
2. Дерево рекурсии и почему наивный подход умирает при $N=40$
Наивная рекурсия без мемоизации строит бинарное дерево вызовов глубины $N$:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
C5["climb(5)"]:::entry --> C4["climb(4)"]:::process
C5 --> C3_dup["climb(3)<br/>[КЭШИРОВАНО!]"]:::error
C4 --> C3["climb(3)"]:::process
C4 --> C2_dup["climb(2)<br/>[КЭШИРОВАНО!]"]:::error
C3 --> C2["climb(2) = 2"]:::success
C3 --> C1["climb(1) = 1"]:::success
C3_dup -. "Отсекается мемоизацией" .-> X1["❌ Повторный пересчет"]:::warning
C2_dup -. "Отсекается мемоизацией" .-> X2["❌ Повторный пересчет"]:::warning
В наивном рекурсивном коде сложность составляет $O(2^N)$. При $n = 45$ число операций превышает $35 \times 10^9$ тактов — ваш HTTP-хэндлер гарантированно словит таймаут.
3. Top-Down в Go: Скрытые системные ловушки
Многие начинают с мемоизации на хэш-таблице:
func climbStairsMemo(n int) int {
memo := make(map[int]int)
var dfs func(int) int
dfs = func(i int) int {
if i <= 2 {
return i
}
if val, ok := memo[i]; ok {
return val
}
memo[i] = dfs(i-1) + dfs(i-2)
return memo[i]
}
return dfs(n)
}
4. Bottom-Up: От $O(N)$ памяти к абсолютному $O(1)$
Этап 1: Табуляция со слайсом $O(N)$
func climbStairsSlice(n int) int {
if n <= 2 {
return n
}
dp := make([]int, n+1)
dp[1], dp[2] = 1, 2
for i := 3; i <= n; i++ {
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
}
Слайс читается последовательно, идеально утилизируя L1-кэш процессора (64-байтные кэш-линии подгружаются аппаратным префетчером). Но для $N = 10^6$ создание среза требует 8 МБ памяти в куче.
Этап 2: Идиоматичный Go — сжатие до регистров CPU ($O(1)$ памяти)
Так как для шага $i$ требуются только два предыдущих результата, заменим массив скользящим окном из двух скалярных переменных:
package stairs
// ClimbStairs возвращает количество уникальных способов взобраться на n ступеней.
// Временная сложность: O(N).
// Пространственная сложность: O(1) — 0 аллокаций.
func ClimbStairs(n int) int {
if n <= 2 {
return n
}
// prev2 соответствует dp[i-2], prev1 соответствует dp[i-1]
prev2, prev1 := 1, 2
for i := 3; i <= n; i++ {
curr := prev1 + prev2
prev2 = prev1
prev1 = curr
}
return prev1
}
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
subgraph Step["Итерация i = 4"]
P2["prev2 = 2 (шаг 2)"]:::process
P1["prev1 = 3 (шаг 3)"]:::process
C["curr = 5"]:::entry
P2 & P1 --> C
end
subgraph Shift["Сдвиг окна для i = 5"]
NewP2["prev2 = 3"]:::warning
NewP1["prev1 = 5"]:::success
C --> NewP1
P1 --> NewP2
end
Mechanical Sympathy: Zero-Alloc и ABIInternal
Запустив компилятор с флагом анализа утечек:
go build -gcflags="-m" stairs.go
Вы увидите: в функции ClimbStairs нет ни единой аллокации в куче.
Переменные prev2, prev1 и curr размещаются непосредственно в регистрах процессора (например, RAX, RDX, RCX в архитектуре x86-64). Время выполнения цикла для $N = 10^4$ занимает считанные микросекунды без малейшего следа в Garbage Collector.
5. Высший пилотаж: Матричное возведение в степень за $O(\log N)$
На собеседовании уровня Lead / Principal вас могут спросить:
«Что если $n = 10^9$? Цикл $O(N)$ займет секунду на ядре CPU. Можем ли мы вычислить ответ быстрее?»
Ответ — матричное умножение.
Линейный переход можно представить в виде векторно-матричного равенства:
$$\begin{pmatrix} dp[i] \\ dp[i-1] \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} dp[i-1] \\ dp[i-2] \end{pmatrix}$$
Разворачивая рекуррентность до начального состояния:
$$\begin{pmatrix} dp[n] \\ dp[n-1] \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-2} \begin{pmatrix} dp[2] \\ dp[1] \end{pmatrix}$$
Используя алгоритм быстрого бинарного возведения матрицы в степень (Binary Exponentiation), матрицу размера $2 \times 2$ можно возвести в степень $n$ всего за $O(\log N)$ умножений! Для $n = 10^9$ потребуется всего около 30 матричных операций.
6. Вопросы для собеседования
Итог
- Комбинаторная суть: Задача о лестнице — это последовательность Фибоначчи со смещением.
- Отказ от рекурсии: В Go Top-Down подход приводит к росту стека и оверхеду хэш-таблиц. Bottom-Up табуляция — стандарт де-факто.
- Регистровое сжатие: Использование двух скалярных переменных сводит память к $O(1)$ и обеспечивает 0 аллокаций в куче.
- Матричный горизонт: При экстремальных $N$ бинарное возведение матрицы в степень вычисляет ответ за $O(\log N)$.
В следующей статье мы перейдем к задаче, где каждый шаг требует выбора между выгодой и ограничением на смежность элементов: 4. House Robber.