4. Стек
Стек: дисциплина LIFO и архитектура исполнения
Стек (Stack) — это фундаментальный абстрактный тип данных, который, в отличие от массивов или связных списков общего назначения, жестко регламентирует порядок добавления и извлечения элементов. Он подчиняется непреложному правилу LIFO (Last-In, First-Out — «Последним пришел, первым ушел»).
Классическая физическая аналогия — стопка чистых тарелок в буфете. Вы можете положить новую тарелку только на самый верх стопки, и взять тарелку для обеда тоже можете только с самого верха. Попытка выдернуть тарелку из середины неизбежно приведет к катастрофе — в программировании это означает грубое нарушение инварианта структуры данных.
Для бэкенд-инженера концепция стека выходит далеко за рамки алгоритмических этюдов: стек лежит в самом сердце исполнения любого скомпилированного машинного кода, операционных систем и планировщика горутин Go.
Mechanical Sympathy: Стек вызовов (Call Stack) в Go
Любая запущенная программа использует аппаратный или виртуальный стек для управления вызовами функций, сохранения регистров, передачи аргументов и возврата управления.
В традиционных языках (C, C++, Java) потоки операционной системы (OS threads) получают монолитный фиксированный стек солидного размера — обычно от 2 до 8 мегабайт. В высоконагруженном сетевом сервисе, где одновременно открыты сотни тысяч сетевых соединений, модель «один поток на соединение» потребовала бы сотен гигабайт оперативной памяти только под пустые неиспользуемые стеки!
Язык Go решает эту проблему на фундаментальном уровне через легковесные горутины.
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
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph GoroutineStack["Эволюция стека горутины"]
direction TB
s1["Старт горутины: 2 КБ на стеке"]:::entry
s2["Глубокая цепочка вызовов: стек заполнен"]:::process
s3["runtime.morestack -> runtime.copystack<br/>Выделение 4 КБ в куче и побайтовое копирование"]:::system
end
s1 --> s2 --> s3
Реализация стека в Go: Выбор базы
Стек — это поведенческий контракт (интерфейс). Физически он должен быть реализован поверх некоторой базовой структуры данных в памяти. Ранее мы изучили двух главных кандидатов:
- Связный список (см. 3. Связные списки): вставка в голову
headи удаление из головы стоят честное $O(1)$. - Динамический массив / Слайс (см. 2. Слайсы в Go как структура данных): добавление в конец и удаление с конца стоят амортизированное $O(1)$.
Инженерный выбор однозначен: всегда выбирайте слайс!
Связный список страдает от постоянных промахов кэша процессора (Cache Misses) из-за погони за указателями (Pointer Chasing) и создает тяжелую нагрузку на сборщик мусора Go из-за непрерывных аллокаций узлов. Слайс же гарантирует монолитное расположение в памяти, превосходную пространственную локальность (Spatial Locality) и на порядки опережает связный список по реальной пропускной способности.
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 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
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph SliceStack["Стек LIFO на базе среза []T"]
direction BT
Bottom["Дно стека<br/>(Индекс 0)"] --- E1["Элемент 1"]
E1 --- E2["Элемент 2"]
E2 --- Top["Вершина стека<br/>(Индекс len - 1)"]
end
Push["Push(item)"] -->|"append в хвост"| Top
Top -->|"Срезка len - 1"| Pop["Pop() -> item"]
Production-Ready реализация на Go
Создадим типобезопасный, обобщенный (generic) стек на базе слайса, полностью готовый к использованию в production:
package main
import (
"errors"
"fmt"
)
var ErrStackEmpty = errors.New("stack is empty")
// Stack реализует структуру данных стек на базе слайса.
type Stack[T any] struct {
items []T
}
// NewStack создает новый стек с опциональным резервированием памяти.
// Предварительная аллокация (capacity) спасает от лишних вызовов runtime.growslice.
func NewStack[T any](capacity int) *Stack[T] {
return &Stack[T]{
items: make([]T, 0, capacity),
}
}
// Push добавляет элемент на вершину стека (в конец слайса).
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop извлекает и возвращает элемент с вершины стека.
func (s *Stack[T]) Pop() (T, error) {
var zero T // Нулевое значение для типа T
if len(s.items) == 0 {
return zero, ErrStackEmpty
}
lastIndex := len(s.items) - 1
item := s.items[lastIndex]
// Важно: зачищаем ссылку, чтобы помочь Garbage Collector-у!
s.items[lastIndex] = zero
// Сужаем слайс (slicing)
s.items = s.items[:lastIndex]
return item, nil
}
// Peek возвращает элемент на вершине без его удаления.
func (s *Stack[T]) Peek() (T, error) {
if len(s.items) == 0 {
var zero T
return zero, ErrStackEmpty
}
return s.items[len(s.items)-1], nil
}
// IsEmpty проверяет, пуст ли стек.
func (s *Stack[T]) IsEmpty() bool {
return len(s.items) == 0
}
Применение стека в алгоритмах (Паттерны собеседований)
Стек — один из излюбленных инструментов на алгоритмических секциях собеседований (LeetCode). Он незаменим в задачах синтаксического анализа, отката к предыдущему состоянию и построения монотонных структур.
1. Валидация скобочных последовательностей (Valid Parentheses)
Классическая задача: Проверить сбалансированность скобок в произвольной строке, содержащей символы (, ), {, }, [, ], например {[()()]}.
Алгоритм решения:
- Итерируемся по символам строки слева направо.
- Встречая открывающую скобкулюбого типа, помещаем ее в стек через
Push. - Встречая закрывающую скобку, проверяем стек: если он пуст, последовательность невалидна. Если нет — извлекаем верхний элемент через
Popи сверяем соответствие типов (например,(соответствует)). - По окончании строки стек обязан быть пустым (проверка
IsEmpty()). Время работы строго $O(n)$, память $O(n)$.
2. Обратная польская запись (Reverse Polish Notation)
Задача: Вычислить арифметическое выражение, представленное в постфиксной нотации: 2 3 + 4 * (эквивалентно выражению (2 + 3) * 4).
Алгоритм решения:
- Сканируем токены входной последовательности.
- Если токен является операндом (числом) — помещаем его в стек через
Push. - Если токен является бинарным оператором (например,
+,-,*,/) — извлекаем два верхних операнда через два последовательных вызоваPop, применяем к ним операцию и кладем результат обратно на вершину стека черезPush. - Итоговый результат вычисления остается единственным числом на вершине стека.
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
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph MonotonicState["Состояние монотонно убывающего стека"]
direction BT
m0["Индекс 2: 75°"] --- m1["Индекс 3: 71°"] --- m2["Индекс 4: 69° (Top)"]
end
NewDay["Пришел день 5 с температурой 72°!"] -->|"72° > 69° -> Pop 4 (ответ: 5 - 4 = 1)"| m2
NewDay -->|"72° > 71° -> Pop 3 (ответ: 5 - 3 = 2)"| m1
NewDay -.->|"72° < 75° -> Push 5 на стек"| m0
Итог
- Стек (LIFO) — строгая поведенческая модель, лежащая в основе вызова функций (Call Stack), парсинга выражений и рекурсивных алгоритмов.
- Стеки горутин в Go: Начинаются с компактных 2 КБ и динамически масштабируются рантаймом через перевыделение и побайтовое копирование памяти (
runtime.morestack). - Выбор фундамента: В прикладном коде стек строится строго поверх слайса, обеспечивая идеальное использование кэша L1/L2 и нулевые накладные расходы на аллокации при предварительном задании емкости
capacity. - Защита от утечек памяти: При выполнении операции
Popв долгоживущем стеке всегда обнуляйте освободившуюся позицию в базовом массиве (s.items[lastIndex] = zero), снимая нагрузку со сборщика мусора. - Алгоритмическая мощь: Монотонный стек позволяет понижать сложность поиска ближайших больших или меньших элементов с квадратичной $O(n^2)$ до строго линейной $O(n)$.
Стек позволяет нам надежно фиксировать пройденный путь и возвращаться назад во времени. Но что если требования задачи диктуют прямо противоположный порядок обработки — строго по времени прибытия (First-In, First-Out), как в балансировщиках входящего трафика или брокерах очередей? В следующей лекции мы детально изучим структуру FIFO: 5. Очередь.