Стек на списке: реализация push и pop через массив

Стек - структура данных с дисциплиной LIFO (last in, first out): последний добавленный элемент уходит первым. Самая частая реализация стека на списке - это не связный список, а обычный массив (в Python его называют list, отсюда и путаница в терминах): элементы лежат подряд в памяти, а вершина стека отслеживается одним целым числом top. Ниже разберём, как устроены push и pop через индекс вершины, почему при переполнении массив приходится расширять вдвое, и почему средняя сложность push всё равно остаётся O(1), хотя отдельная операция может стоить O(n). Чтобы увидеть это на числах, покрути калькулятор ниже: он симулирует последовательность push и pop и показывает, как растёт ёмкость массива и сколько ячеек пришлось скопировать при расширениях.
Что такое стек и зачем ему список (массив)
Абстрактный стек поддерживает три операции: push(x) - положить элемент на вершину, pop() - снять и вернуть верхний элемент, peek() - посмотреть верхний элемент, не снимая. Реализовать это можно на связном списке (каждый элемент хранит указатель на предыдущий) или на списке-массиве: непрерывном блоке памяти фиксированной ёмкости с указателем вершины , равным числу элементов сейчас в стеке. Массив предпочитают, когда важна компактность и скорость: элементы лежат подряд, без накладных расходов на указатели, и обращение по индексу - мгновенное. Плата за это - необходимость время от времени расширять массив, когда место заканчивается.
Операции push и pop через индекс вершины
Реализация предельно проста. Пока в массиве есть свободное место, push кладёт элемент в ячейку с индексом и увеличивает на единицу:
Обе операции в этом случае - константное время: ни поиска, ни сдвига элементов не требуется, меняется только значение индекса. Проблема возникает, когда достигает текущей ёмкости массива: класть новый элемент уже некуда.
Расширение ёмкости: почему push всё равно O(1) в среднем
Когда , происходит resize: выделяется новый массив ёмкостью , все элементов копируются в него, старый массив освобождается, и только после этого новый элемент дописывается на вершину:
Один такой push стоит - нужно скопировать всё содержимое. Но расширения случаются редко: ёмкость удваивается, поэтому между двумя соседними расширениями число дешёвых push растёт экспоненциально. Если считать по методу агрегата, суммарная цена копирований после операций push при стартовой ёмкости равна геометрической сумме:
Добавив ещё операций записи (по одной на каждый push), получаем, что суммарная стоимость вызовов push меньше , то есть амортизированная стоимость одного push - константа, . Именно рост коэффициентом 2 (а не, скажем, добавление фиксированных 10 ячеек за раз) даёт эту оценку: при линейном приросте ёмкости суммарная цена копирований становится квадратичной, и амортизированная сложность push деградирует до .

Для 10 последовательных push при стартовой ёмкости 1 расширения происходят на 2-м, 3-м, 5-м и 9-м push (ёмкость проходит путь ), суммарно копируется ячеек - меньше границы . Средняя цена одного push здесь операции, что и есть иллюстрация константной амортизированной сложности при переменном фактическом времени отдельных вызовов.
Куда девается память после pop
Здесь кроется частая путаница. Операция pop - это просто : она не уменьшает ёмкость массива и не освобождает память под неиспользуемые ячейки. Даже если из стека вынуты почти все элементы, выделенный блок памяти остаётся прежнего размера - используется только его начало до индекса . Коэффициент заполнения (load factor) после серии pop может стать сколь угодно малым, и память при этом простаивает. Некоторые реализации добавляют «сжатие» - уменьшение ёмкости вдвое, когда заполнение падает ниже четверти, - но в учебной и большинстве практических реализаций (включая стандартный list в Python и Vec в Rust без явного shrink_to_fit) этого не делают: постоянные пере-аллокации в обе стороны обошлись бы дороже, чем немного лишней памяти.
Стек на списке против стека на связном списке
У связного списка каждый push и pop - гарантированно в худшем случае, без всяких амортизаций: новый узел просто присоединяется или отсоединяется от головы, копировать ничего не нужно. Плата - память: на каждый элемент нужен ещё указатель (обычно 8 байт на 64-битной системе) и отдельная аллокация в куче, которая на порядок медленнее одного присваивания в массиве. Список-массив, напротив, тратит меньше памяти на элемент и быстрее в среднем случае за счёт локальности данных (все элементы лежат рядом, что дружелюбно к кэшу процессора), но платит редкими дорогими resize. На практике для стеков предсказуемого небольшого размера почти всегда выбирают именно список-массив: средняя цена ниже, а редкие пики не критичны, если не работать в системах жёсткого реального времени.
Пример пошагового построения стека
Возьмём стартовую ёмкость и выполним 10 push подряд. Ёмкость проходит путь , расширения случаются на push номер 2 (копия 1 ячейки), номер 3 (копия 2), номер 5 (копия 4) и номер 9 (копия 8). После десятого push стек заполнен полностью: , ёмкость , суммарно скопировано ячеек. Теперь выполним 4 pop подряд: последовательно уменьшается , а ёмкость остаётся равной 16 - ни одного resize вниз не происходит. Итог: в стеке 6 элементов, под них выделено место на 16, коэффициент заполнения . Ровно эту последовательность операций можно проиграть в калькуляторе выше, изменяя число push и pop.
Частые ошибки
- Путать список-массив со связным списком. «Реализация на списке» без уточнения часто означает именно массив (как Python
list), а не структуру с указателями - от этого зависит, есть ли вообще амортизация. - Забывать про амортизацию и говорить «push - это O(n)». Отдельный push при resize действительно стоит , но в среднем по длинной последовательности - ; это разные утверждения о сложности.
- Считать, что pop уменьшает выделенную память. Без явного сжатия ёмкость после pop не меняется - это не баг, а стандартное поведение.
- Использовать линейный прирост ёмкости (например,
C += 10вместоC *= 2). Это ломает амортизационную оценку: суммарная цена копирований становится вместо . - Проверять переполнение уже после записи в массив. Условие
top === capacityнужно проверять ДО записи нового элемента, иначе запись уйдёт за границу массива.
FAQ
Почему ёмкость расширяют именно вдвое, а не на фиксированное число? Удвоение даёт геометрическую прогрессию расходов на копирование, поэтому их сумма ограничена и амортизированная стоимость push остаётся . Прирост на константу даёт квадратичную суммарную стоимость.
Какая сложность у pop в стеке на списке? Всегда - операция лишь уменьшает индекс вершины, копирования и пере-аллокации не требуется (если реализация не делает явного сжатия ёмкости).
Что произойдёт, если попытаться сделать pop у пустого стека? Это ошибка underflow: индекс уже равен нулю, брать элемент неоткуда. Корректная реализация обязана проверять перед pop и бросать исключение или возвращать признак ошибки.
Коротко
Стек на списке (массиве) хранит элементы подряд и следит только за индексом вершины : push кладёт элемент и увеличивает , pop уменьшает - обе операции , пока хватает места. Когда достигает ёмкости , происходит resize с удвоением () и копированием всех элементов - разовая цена , но благодаря геометрическому росту суммарная стоимость push остаётся меньше , то есть амортизированная сложность push - . Pop, в отличие от push, никогда не уменьшает ёмкость - освобождение памяти требует отдельной логики сжатия, которую большинство реализаций сознательно не делают.
Читайте также

Высота и глубина дерева: формулы и примеры
Разбираем высоту и глубину дерева в информатике: точные определения, формулы для полного двоичного и вырожденного дерева, примеры задач и типичные ошибки.

Задача о рюкзаке: динамическое программирование
Разбор задачи о рюкзаке (0/1 Knapsack) методом ДП: таблица dp[i][w], рекуррентный переход, traceback-восстановление набора. Пошаговые примеры и анализ сложности O(n*W).

Бинарный поиск по ответу: метод и примеры задач
Бинарный поиск по ответу: как применять метод к задачам на оптимизацию, как формулировать предикат, строить монотонную функцию и избегать ошибок в границах.

Кольцевой буфер: реализация на массиве и индексах
Как реализовать кольцевой буфер на обычном массиве: индексы head и tail, формулы через остаток от деления, проблема совпадения head и tail и её решение счётчиками операций.

Поиск в двоичном дереве поиска: алгоритм и сложность
Как работает поиск в двоичном дереве поиска (BST): пошаговый алгоритм, число сравнений в среднем и худшем случае, влияние баланса дерева и типичные ошибки в задачах.

Алгоритм Рабина-Карпа: поиск подстроки за O(n+m)
Разбираем алгоритм Рабина-Карпа: как полиномиальный хеш и скользящее окно ускоряют поиск подстроки до O(n+m) в среднем, почему бывают ложные совпадения и при чём тут плагиат.