Структуры данных
Стеки: LIFO и трюк со скобками
Каждый раз, когда нажимается **Ctrl+Z**, редактор «вспоминает» последнее действие и отменяет его. Как он знает, какое действие было последним? Ответ - **стек**.
Цели урока
- Понять принцип LIFO (Last In, First Out)
- Реализовать стек на массиве и связном списке
- Решить задачу проверки сбалансированных скобок
- Понять роль стека вызовов в рекурсии
Предварительные знания
- Связные списки и их операции
2006 год. Firefox 2.0 выходит с функцией Undo для закрытых вкладок. За кулисами - стек URL-ов. Сегодня Chrome держит стек навигации на каждой вкладке, VS Code держит стек правок на каждом файле, Node.js держит стек вызовов на каждый async-контекст. Одна структура - тысячи применений.
- **VS Code Undo/Redo** - стек правок, Ctrl+Z извлекает последнее изменение
- **Браузерная навигация** - кнопки назад/вперёд - два стека истории
- **TypeScript-компилятор** - проверка вложенности generic-типов через стек
- **React reconciler** - Fiber-алгоритм обходит дерево компонентов через explicit стек
Call stack и рождение структурного программирования
В 1960 году ALGOL-60 стал первым языком с вложенными процедурами и локальными переменными. Это потребовало системного решения для хранения контекста вызовов - так появился call stack как обязательный элемент рантайма. Эдсгер Дейкстра участвовал в разработке ALGOL-60 и формализовал понятие activation record. Шестьдесят лет спустя JavaScript V8, Python CPython и JVM используют ту же модель.
LIFO - Last In, First Out
Каждый раз, когда браузер нажимает «Назад» - он достаёт последний URL из стека истории. Каждый раз, когда TypeScript-компилятор раскрывает вложенные generic-типы - он использует стек. Chrome DevTools показывает call stack при каждой ошибке. LIFO - принцип, который работает повсеместно.
**LIFO** (Last In, First Out) - принцип стека. Последний пришёл - первый ушёл.
LIFO в реальных системах
**Ctrl+Z (Undo)**: последнее действие отменяется первым - VS Code хранит историю правок в стеке **Кнопка «Назад» в браузере**: последняя страница открывается первой **Стек вызовов**: последняя вызванная функция завершается первой - JavaScript event loop живёт этим
В стек положили элементы в порядке: A, B, C, D. В каком порядке они выйдут при последовательных pop?
Операции: push, pop, peek
У стека всего **3 основные операции**, и все они O(1). Три операции - вся API. V8 JavaScript engine обрабатывает миллионы функциональных вызовов в секунду именно благодаря этой простоте.
- **push(x)** - положить элемент на вершину
- **pop()** - снять и вернуть элемент с вершины
- **peek() / top()** - посмотреть вершину без удаления
**pop() на пустом стеке** - ошибка! В Node.js это может обрушить процесс с unhandled exception. Всегда проверяй isEmpty() перед pop.
Выполнили: push(1), push(2), pop(), push(3), peek(). Что вернёт peek()?
Реализация: массив или список?
Стек можно реализовать двумя способами. Оба дают O(1) для всех операций. Разница - в константах и cache behavior. V8 предпочитает array-based: CPU cache locality ускоряет доступ к вершине в разы.
**Что выбрать?** Массив: проще, лучше cache locality, но может потребовать resize. Список: стабильный O(1) без resize, но больше памяти на указатели.
В Python лучше использовать list как стек (append/pop). Почему НЕ стоит использовать insert(0)/pop(0)?
Задача: Проверка скобок
Классическая задача - проверить, что все скобки **сбалансированы**. TypeScript-компилятор делает это на каждом файле. ESLint - тоже. За O(n) одним проходом. Инструмент - стек.
**Алгоритм со стеком:**
- Встретили открывающую скобку - push в стек
- Встретили закрывающую - pop и проверить соответствие
- В конце стек должен быть пустым
Достаточно посчитать количество открывающих и закрывающих скобок
Нужно проверять соответствие типов И порядок вложенности. "([)]" имеет равное количество, но не сбалансировано
Строка "([)]" - сбалансирована?
Call Stack - стек вызовов
Когда функция вызывает функцию - куда сохраняется контекст? В **стек вызовов**. Каждый Node.js процесс, каждый Python интерпретатор, каждый JVM thread имеют свой стек вызовов. Stack trace в Sentry - это дамп этого стека в момент ошибки.
**Stack Overflow** - переполнение стека. Python: >1000 вызовов по умолчанию. Node.js: ~15000. Глубокая рекурсия без tail-call optimization - прямой путь к crash.
**Отладка**: stack trace в Sentry/Datadog показывает именно стек вызовов в момент ошибки - путь от точки входа до места падения.
Функция A вызывает B, B вызывает C. В каком порядке функции завершатся?
Реализуй функцию, которая переворачивает строку с помощью стека: ```python def reverse_string(s): # Используй стек для разворота строки pass # reverse_string("hello") -> "olleh" ```
def reverse_string(s): stack = [] # Кладём все символы в стек for char in s: stack.append(char) # Извлекаем в обратном порядке result = [] while stack: result.append(stack.pop()) return ''.join(result)
Вычисли выражение в обратной польской записи (RPN): ```python def eval_rpn(tokens): # tokens = ["2", "1", "+", "3", "*"] -> (2+1)*3 = 9 # tokens = ["4", "13", "5", "/", "+"] -> 4+(13/5) = 6 pass ```
def eval_rpn(tokens): stack = [] for token in tokens: if token in '+-*/': b = stack.pop() # Второй операнд a = stack.pop() # Первый операнд if token == '+': stack.append(a + b) elif token == '-': stack.append(a - b) elif token == '*': stack.append(a * b) elif token == '/': stack.append(int(a / b)) else: stack.append(int(token)) return stack[0]
Реализуй MinStack - стек с операцией getMin() за O(1): ```python class MinStack: def __init__(self): pass def push(self, val): pass def pop(self): pass def top(self): pass def getMin(self): # O(1)! pass ```
class MinStack: def __init__(self): self.stack = [] # Основной стек self.min_stack = [] # Стек минимумов def push(self, val): self.stack.append(val) # Добавляем в min_stack только если val <= текущему минимуму if not self.min_stack or val <= self.min_stack[-1]: self.min_stack.append(val) def pop(self): val = self.stack.pop() # Если удаляем текущий минимум - удаляем и из min_stack if val == self.min_stack[-1]: self.min_stack.pop() return val def top(self): return self.stack[-1] def getMin(self): return self.min_stack[-1]
Связанные темы
Стек - основа для многих алгоритмов:
- Очереди — FIFO - противоположный принцип (First In, First Out)
- DFS — Поиск в глубину использует стек
- Backtracking — Любую рекурсию можно переписать через явный стек
Ключевые идеи
- **LIFO**: последний вошёл - первый вышел
- **Операции**: push, pop, peek - все O(1)
- **Реализация**: массив (лучше cache) или связный список (стабильный O(1))
- **Применения**: undo, скобки, call stack, DFS, backtracking
- **Stack Overflow**: переполнение при глубокой рекурсии без tail-call
Вопросы для размышления
- Почему стек идеально подходит для проверки скобок?
- Как реализовать Redo (повторить) поверх Undo?
- Можно ли реализовать очередь с помощью двух стеков?
Связанные уроки
- ds-02-linked-lists — Linked stack строится поверх узлов из урока о списках
- ds-04-queues — Очередь - противоположный принцип, реализуется через два стека
- alg-13-dfs — DFS обходит граф через явный стек или системный call stack
- alg-03-amortized — Амортизация объясняет O(1) для array-based stack с resize
- alg-22-backtracking — Backtracking - это явный стек состояний поиска
- ds-05-deque — Deque обобщает стек - push/pop с обоих концов
- comp-12-lr-parsing