Стек, очередь, дек и монотонный стек
Перед началом: хеш-таблицы (модуль 4). Инструменты: Go 1.27,
slices,container/list— для сравнения. Трудоёмкость: ~5 ч (теория 2 ч · практика 3 ч).
🔥 Инцидент: «Незакрытая скобка в конфиге унесла полфайла»
Ночной деплой поднял сервис с пустым DSN для базы: в переменную уехала
пустая строка, сработал fallback на localhost с дефолтным паролем.
Конфиг-сервис, раскрывающий плейсхолдеры ${...}, искал их регуляркой
«от ${ до ближайшего }». В шаблоне остался незакрытый ${ в
закомментированной строке — выражение склеило его с первым } через
четыреста строк, вырезало кусок чужого значения и подставило как имя
переменной. Ни одна проверка не сработала: строка оставалась
синтаксически валидной.
Второй дефект: парсер проверял «в конфиге есть }», а не вложенность.
На ([)] количество скобок сходится, но структура сломана — это ловит
только стек. Чиним в модуле: стек как память об ожидании — для скобок,
плейсхолдеров, вычислений и обходов; заодно разберём очередь, дек и
монотонный стек.
📖 Теория
5.1 Стек на срезе: push, pop, peek
Стек — это LIFO: добавляем и забираем с одного конца. В Go его не нужно изобретать: срез, у которого вершина — последний элемент.
gostack := make([]int, 0, 16) // преаллокация — как обычно stack = append(stack, 42) // push: амортизированно O(1) top := stack[len(stack)-1] // peek без удаления stack = stack[:len(stack)-1] // pop: O(1), ничего не сдвигается
Все операции — O(1): append амортизирован (модуль 1), а усечение среза
ничего не копирует; пустота — это len(stack) == 0.
5.2 min-stack: минимум за O(1)
Задача: push, pop, min за O(1). Наивный min — проход по всему
стеку, O(n); решение — второй стек с минимумом на каждой глубине.
gotype minStack struct { data, mins []int // mins[i] — минимум стека на глубине i } func (s *minStack) push(v int) { s.data = append(s.data, v) if len(s.mins) == 0 || v <= s.mins[len(s.mins)-1] { s.mins = append(s.mins, v) // новый минимум (или дубликат прежнего) } }
При pop минимум снимается, только если уходит именно он. Условие
v <= top (не <) принципиально: с < дубликаты минимума теряются и
mins останется с устаревшим значением. Цена — до двух копий данных.
5.3 Очередь: почему срез не годится, и кольцевой буфер
FIFO-очередь «из головы» на срезе выглядит как q = q[1:] — и это
ловушка: массив не освобождается (память держится), а ёмкость тает;
честный copy для сдвига — это O(n) на каждый pop. Одна операция из
двух всегда плоха — пока не вспомнить про кольцо.
Кольцевой буфер — массив фиксированной длины плюс head и count.
Хвост — это (head + count) % len(buf): индексы «заворачиваются».
gotype ring[T any] struct { buf []T head, count int } func (r *ring[T]) pop() (T, bool) { if r.count == 0 { var zero T return zero, false } v := r.buf[r.head] r.head = (r.head + 1) % len(r.buf) // замыкание кольца r.count-- return v, true }
Оба конца — O(1), память фиксирована. Дек — тот же буфер с операциями
и с головы: она двигается назад по модулю len(buf). Это базовый
инструмент для окна переменной ширины и «скользящего максимума».
5.4 Монотонный стек: next greater за O(n)
Задача: для каждого элемента найти ближайший справа больший. Перебор — O(n²); монотонный стек решает за один проход: держим индексы с убывающими значениями; приходит элемент больше вершины — вершина «дождалась ответа» и покидает стек.
gofunc nextGreater(a []int) []int { res := make([]int, len(a)) for i := range res { res[i] = -1 } stack := make([]int, 0, len(a)) // индексы, не значения: нужны позиции for i, v := range a { for len(stack) > 0 && a[stack[len(stack)-1]] < v { res[stack[len(stack)-1]] = v // ответ вершине stack = stack[:len(stack)-1] } stack = append(stack, i) } return res }
Почему O(n): каждый индекс кладётся в стек один раз и снимается не более одного раза. Тот же шаблон «первый меньший слева и справа» даёт идею largest rectangle in histogram.
5.5 Где это живёт
Один приём — четыре сценария: скобки и плейсхолдеры (валидность вложенности из инцидента), RPN (два операнда со стека на каждый оператор), undo/redo (стек действий), итеративный обход дерева — рекурсию заменяет явный стек; понадобится в модуле 11. Очередь — это BFS (модуль 14) и буферизация событий.
5.6 Компромиссы
| Решение | За | Против |
|---|---|---|
| Стек на срезе | O(1) с конца, плотная память, кэш-дружелюбно | append может перевыделять |
container/list | O(1) вставка/удаление в середине, стабильные узлы | Аллокация на узел, плохой кэш, any-бокс |
Очередь q = q[1:] | Две строки кода | Память не освобождается, ёмкость тает |
| Кольцевой буфер | O(1) с обоих концов, фиксированная память | Больше кода и арифметика модуло |
| Очередь на двух стеках | Простая реализация | Амортизация «пачками»: пики латентности |
| Монотонный стек | O(n) вместо O(n²) | Работает только при монотонности сравнения |
Правило: срез — для стека и для очереди, которую можно крутить
кольцом; container/list — когда нужны стабильные указатели на узлы
(LRU из модуля 6); монотонный стек — когда вопрос звучит «первый
больше-меньше».
💻 Практика
- Валидация вложенности. Реализуйте
validBracketsдля()[]{}и напишите версиюfirstBad(s string) (int, bool), возвращающую индекс первого символа, ломающего структуру. Тесты:"","([)]","(]","{", лишняя")"; почему счётчики скобок без стека не ловят"([)]"? - min-stack. Реализуйте стек с
push,pop,top,minза O(1). Тест:push 2, push 2, pop, min→ 2 (дубликаты минимума);push 5, push 1, pop, min→ 5. Объясните, что сломает условие<вместо<=. - next greater. Реализуйте
nextGreaterиз §5.4. Тесты:[2,1,2,4,3]→[4,2,4,-1,-1]; убывающий вход — все-1; возрастающий — сдвиг влево на один. Затем вариант circular: массив по кругу, ответ ищется после «заворота» — с чего начать обход и сколько нужно проходов? - RPN-вычислитель.
evalRPN(expr string) (int, error)для"+ - * /"на целых:"3 4 + 2 *"→ 14,"5 1 2 + 4 * + 3 -"→ 14. Деление на ноль, недостаток операндов и мусорные токены — ошибка, не паника. Тестовые случаи включите все три. - Кольцевой буфер. Реализуйте
ringс фиксированной ёмкостью 4:push,pop,len. Выберите политику переполнения (ошибка или перезапись старейшего) и обоснуйте. Тест на заворачивание: push 3, pop 2, push 3 — проверьтеhead,countи порядок. Сравните сq = q[1:]: что происходит с памятью иcapу каждого.
🧩 Quiz
q = q[1:]?v <= mins.top, а не <?✅ Итоги модуля
- Стек в Go — это срез: push —
append, pop — усечение; O(1) с конца за счёт амортизации. - min-stack показывает приём «вторая структура рядом»: минимум на глубине за O(1) ценой дополнительной памяти.
- Очередь на срезе с удалением головы — ловушка; кольцевой буфер даёт O(1) с обоих концов и фиксированную память.
- Монотонный стек: индексы с монотонными значениями, каждый входит и выходит один раз — O(n) для next greater и подобных задач.
- Скобки, RPN, undo и итеративные обходы — один стек, четыре разных продовых сценария.
🔗 Мостик к Модулю 6
Стек и очередь хороши, пока доступ нужен с краёв. Дальше — структура,
где вставка и удаление работают в любой точке по указателю: связные
списки. Развернём список, найдём середину методом fast/slow и соберём
LRU-кэш из map и двусвязного списка — первый в курсе пример структуры,
собранной из двух.