10. Generate Parentheses
«Синтаксис — это дисциплина равновесия: ничто не может быть закрыто, пока не было открыто, и ничто не может остаться открытым вовек».
— Никлаус Вирт
Задача Generate Parentheses (LeetCode 22) гармонично завершает кластер поиска с возвратом:
Условие: Задано целое число $n$ — количество пар круглых скобок. Требуется сгенерировать все синтаксически правильные скобочные последовательности длины $2n$.
Правильная скобочная последовательность (ПСП) удовлетворяет двум строгим грамматическим инвариантам:
- В любом префиксе строки количество закрывающих скобок
')'не превышает количества открывающих'('. - В конце строки суммарное количество
'('строго равно количеству')'.
В отличие от задачи о ферзях, где ограничения были глобальными, здесь условия локальны и элементарно контролируются двумя целочисленными счетчиками. Задача демонстрирует, как правильная модель пространства состояний превращает слепой перебор в обход дерева путей Дика (Dyck Paths) и чисел Каталана без единой лишней тупиковой ветви.
Архитектура: Дерево правильных префиксов для $n = 3$
Вместо наивной генерации всех $2^{2n}$ строк с последующей валидацией через стек, мы строим строку посимвольно, допуская к добавлению только те символы, которые гарантированно не нарушают инвариант:
- Символ
'('разрешено добавить, если число уже поставленных открывающих скобокopen < n. - Символ
')'разрешено добавить, если число закрывающих скобок строго меньше открывающих:close < open.
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Root["Корень: \#quot;\#quot; (open=0, close=0)"]:::entry --> O1["\#quot;(\#quot; (1, 0)"]:::process
O1 --> O2["\#quot;((\#quot; (2, 0)"]:::process
O1 --> C1["\#quot;()\#quot; (1, 1)"]:::process
O2 --> O3["\#quot;(((\#quot; (3, 0)"]:::process
O2 --> C2["\#quot;(()\#quot; (2, 1)"]:::process
O3 --> L1["\#quot;((()))\#quot;"]:::success
C2 --> O21["\#quot;(()(\#quot; (3, 1)"]:::process
C2 --> C22["\#quot;(())\#quot; (2, 2)"]:::process
O21 --> L2["\#quot;(()())\#quot;"]:::success
C22 --> L3["\#quot;(())()\#quot;"]:::success
C1 --> O12["\#quot;()(\#quot; (2, 1)"]:::process
O12 --> O123["\#quot;()((\#quot; (3, 1)"]:::process
O12 --> C123["\#quot;()()\#quot; (2, 2)"]:::process
O123 --> L4["\#quot;()(())\#quot;"]:::success
C123 --> L5["\#quot;()()()\#quot;"]:::success
Количество валидных комбинаций для $n$ пар строго задается $n$-м числом Каталана:
$$C_n = \frac{1}{n+1}\binom{2n}{n}$$
Для $n = 3$: $C_3 = \frac{1}{4}\binom{6}{3} = \frac{20}{4} = 5$ комбинаций.
Наш алгоритм обходит ровно эти 5 путей, не совершая ни единого ошибочного шага!
Идиоматичная реализация на Go
package main
// generateParenthesis возвращает все валидные скобочные комбинации из n пар.
// Временная сложность: O(4^n / sqrt(n)) — асимптотика чисел Каталана
// Пространственная сложность: O(n) глубина стека и буфера cur
func generateParenthesis(n int) []string {
if n <= 0 {
return []string{""}
}
var result []string
// Предвыделяем емкость под точную длину скобочной строки 2*n
cur := make([]byte, 0, 2*n)
var backtrack func(open int, close int)
backtrack = func(open int, close int) {
// Базовый случай: набрано ровно 2*n символов
if len(cur) == 2*n {
// Преобразование []byte в string создает иммутабельную копию строки
result = append(result, string(cur))
return
}
// Ветвь 1: Можем открыть новую скобку
if open < n {
cur = append(cur, '(')
backtrack(open+1, close)
cur = cur[:len(cur)-1] // Откат
}
// Ветвь 2: Можем закрыть открытую скобку
if close < open {
cur = append(cur, ')')
backtrack(open, close+1)
cur = cur[:len(cur)-1] // Откат
}
}
backtrack(0, 0)
return result
}
Взгляд под капот: Mechanical Sympathy
1. Переиспользование []byte против конкатенации строк
В наивных реализациях часто пишут:
// Антипаттерн:
backtrack(s + "(", open+1, close)
Конкатенация строк s + "(" на каждом шаге рекурсии аллоцирует в куче новый заголовок строки и новый массив байт. Для $n = 8$ при глубине 16 это создаст десятки тысяч мелких объектов в куче и вызовет остановку рантайма сборщиком мусора (Stop-The-World GC).
Использование предвыделенного среза cur := make([]byte, 0, 2*n):
- Выделяет память ровно один раз при старте.
- Операции
appendиcur[:len(cur)-1]происходят в пределах одного кэш-блока L1d. - Аллокация происходит исключительно в момент
result = append(result, string(cur))при фиксации валидного ответа.
2. Спекулятивное исполнение процессора (Branch Prediction)
В цикле backtrack два условия if open < n и if close < open абсолютно независимы (не объединены через else). Это позволяет конвейеру процессора спекулятивно вычислять обе ветви, минимизируя задержки предсказателя переходов.
Подводные камни и вопросы с собеседований
Резюме Кластера 10 (Backtracking)
Мы полностью завершили фундаментальный модуль алгоритмов поиска с возвратом:
- Теория: Трехфазный инвариант Choose $\rightarrow$ Explore $\rightarrow$ Unchoose, работа со стеком горутины и безаллокационный откат через
path[:len(path)-1]. - Комбинаторные каркасы: Подмножества ($C(n,k)$ со сдвигом
start+1), перестановки ($N!$ сvisitedи битовыми масками), неограниченные сочетания (startбез сдвига). - Борьба с дубликатами: Сортировка и фильтрация ветвей на одном горизонтальном уровне дерева (
i > start && nums[i] == nums[i-1]). - Пространственный поиск: Обход 2D-матриц с маскированием клеток in-place (
board[r][c] = '#'). - Глобальные ограничения: Быстрые проекции диагоналей в $N$-Queens и локальный баланс счетчиков в ПСП.
В следующем кластере мы применим навыки обхода дерева состояний к графовым структурам данных — переходим к Поиску в глубину (DFS): 1. Теория. DFS.