6. Decode String
«Всякая вложенность — это лишь отложенное вычисление: сохрани текущий контекст, реши подзадачу и аккуратно соедини результаты».
— Брайан Керниган
Распаковка вложенных строк: Парсинг, стек и управление памятью
Задача Decode String (LeetCode 394) моделирует классический синтаксический анализ рекурсивно сжатых или вложенных текстовых структур.
Условие задачи: дана закодированная строка вида k[encoded_string], где фрагмент внутри квадратных скобок должен быть повторен ровно k раз. Вложенность может быть произвольной глубины.
Вход: "3[a]2[bc]"
Выход: "aaabcbc"
Вход: "3[a2[c]]"
Выход: "accaccacc"
Вход: "2[abc]3[cd]ef"
Выход: "abcabccdcdcdef"
В реальной бэкенд-инженерии данный паттерн лежит в основе:
- Десериализации бинарных и текстовых сетевых протоколов со сжатием (Run-Length Encoding / TLV).
- Парсинга выражений в шаблонных движках (Template Engines вроде Mustache или Go
html/template). - Распаковки вложенных конфигураций (Macro Expansion, HCL, JSON-patch).
- Разбора абстрактных синтаксических деревьев (AST) в компиляторах и интерпретаторах DSL.
На собеседованиях уровня Middle+/Senior эта задача проверяет умение кандидата проектировать детерминированные конечные автоматы (FSM), управлять состоянием стека без утечек памяти и защищать распределенные сервисы от атак типа «Zip-бомба» (Memory Exhaustion).
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 accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef warn fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Start["Старт парсинга: итерация по символам s[i]"]:::entry --> Switch{"Тип текущего символа"}:::process
Switch --> Digit["Цифра ('0'..'9'):<br/>k = k * 10 + int(ch - '0')"]:::process
Switch --> OpenBracket["'[': Сохранить контекст в стек<br/>Push k в countStack<br/>Push currentStr в strStack<br/>Сбросить k и currentStr"]:::accent
Switch --> CloseBracket["']': Восстановить контекст<br/>Pop k из countStack<br/>Повторить currentStr k раз<br/>Pop prevStr и объединить"]:::warn
Switch --> Literal["Буква ('a'..'z'):<br/>Добавить в текущий буфер"]:::process
Digit --> Next["Следующий символ"]:::process
OpenBracket --> Next
CloseBracket --> Next
Literal --> Next
Next --> CheckEnd{"Конец строки?"}
CheckEnd -- "Нет" --> Switch
CheckEnd -- "Да" --> ReturnResult["Вернуть currentStr"]:::success
Архитектурный каркас: Явный стек против рекурсии
Задачу можно решить двумя путями:
- Рекурсивный спуск:
Естественный математический подход. Но в Go каждый рекурсивный вызов занимает место на стеке горутины. При глубокой вложенности (например, $10\,000$ уровней скобок) рантайм Go будет непрерывно выделять новые блоки памяти (runtime.morestack), что чревато аварийным падением сервиса:runtime: goroutine stack exceeds 1000000000-byte limit. - Итеративный парсер с явными стеками (Production-Ready):
Мы выделяем два среза-стека в куче:countStack []int— хранит коэффициенты повторенияk.strStack []string— хранит накопленные префиксы строк до входа во вложенность.
Такой подход защищен от переполнения стека горутины, полностью контролируется программистом и легко профилируется через pprof.
Идиоматичная реализация на Go
package parser
import (
"strings"
)
// DecodeString распаковывает вложенную строку вида k[encoded_string].
// Временная сложность: O(S), где S — длина итоговой декодированной строки.
// Пространственная сложность: O(D + S), где D — максимальная глубина вложенности скобок.
func DecodeString(s string) string {
countStack := make([]int, 0, 16)
strStack := make([]string, 0, 16)
var currentStr strings.Builder
currentStr.Grow(len(s)) // Предвыделение базовой емкости
k := 0
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case ch >= '0' && ch <= '9':
// Накопление многозначного числа (например, 125)
k = k*10 + int(ch-'0')
case ch == '[':
// Вход во вложенный уровень: сохраняем состояние
countStack = append(countStack, k)
strStack = append(strStack, currentStr.String())
// Сбрасываем контекст для сбора внутреннего блока
k = 0
currentStr.Reset()
case ch == ']':
// Выход из вложенного уровня: извлекаем множитель
repeatCount := countStack[len(countStack)-1]
countStack = countStack[:len(countStack)-1]
// Текущая декодированная подстрока
innerStr := currentStr.String()
// Восстанавливаем сохраненный префикс
prevStr := strStack[len(strStack)-1]
strStack = strStack[:len(strStack)-1]
// Собираем новый объединенный результат: prevStr + (innerStr * repeatCount)
var nextBuilder strings.Builder
nextBuilder.Grow(len(prevStr) + len(innerStr)*repeatCount)
nextBuilder.WriteString(prevStr)
for r := 0; r < repeatCount; r++ {
nextBuilder.WriteString(innerStr)
}
currentStr = nextBuilder
default:
// Обычный литерал (буква)
currentStr.WriteByte(ch)
}
}
return currentStr.String()
}
Безопасность в продакшене: Защита от «Zip-бомбы» (Billion Laughs Attack)
В боевом сервисе нельзя слепо доверять входящей строке.
Злоумышленник может отправить компактный payload:
100[100[100[100[100[a]]]]]
Размер входной строки — всего 26 байт.
Но размер распакованного текста равен $100^5 = 10\,000\,000\,000$ байт $\approx$ 10 Гигабайт!
Сервис мгновенно исчерпает оперативную память контейнера и будет убит Linux OOM Killer.
Паттерн защитного лимита (Quota Guard)
const maxAllowedDecodedLength = 10 << 20 // Защитный лимит: 10 МБ
// Внутри цикла обработки скобки ']':
predictedLen := len(prevStr) + len(innerStr)*repeatCount
if predictedLen > maxAllowedDecodedLength {
// Немедленное прерывание до аллокации памяти!
return "", errors.New("decoded string exceeds memory safety limit (zip bomb detected)")
}
Подводные камни и граничные случаи
- Многозначные коэффициенты повтора:
Случай100[leetcode]. Формулаk = k * 10 + int(ch - '0')корректно обрабатывает числа любой разрядности. - Литералы без скобок:
Случай"abc3[cd]xyz". Литералы"abc"накапливаются вcurrentStrдо встречи скобки. При встрече[они сохраняются вstrStackкакprevStr, а"xyz"корректно дописываются в конец после выхода из скобок. - Пустые скобки:
Случай"3[]". Внутренняя строкаinnerStrпустая, повторение пустой строки дает пустую строку без паники и сбоев.
Вопросы для собеседования
Резюме
- Decode String элегантно решается детерминированным конечным автоматом с двумя явными стеками:
countStack(множители) иstrStack(префиксы). - Итеративный стек превосходит рекурсию по предсказуемости памяти и защите от Stack Overflow.
- Для высоконагруженных API обязателен защитный лимит на максимальный размер распаковки для предотвращения атак типа «Zip-бомба».
- Использование
strings.Builderс предвыделениемGrow()обеспечивает оптимальную линейную скорость без фрагментации памяти.
В заключительной лекции строкового модуля мы разберем классическую задачу системного синтаксического анализа: 7. String to Integer atoi, где исследуем опережающий контроль целочисленных переполнений и битовую проверку границ.