# теория · шаг 3 из 6
Очередь на двух стеках и дека
Коротко
Очередь можно собрать из двух стеков - in (для прихода) и out (для ухода).
enqueue кладёт в in. dequeue снимает с out; если out пуст, сначала
ПЕРЕЛИВАЕМ в него весь in, переворачивая порядок, - и снимаем. Каждый элемент
проходит ровно четыре дешёвых шага за всю жизнь (push в in, pop из in, push
в out, pop из out), поэтому по серии dequeue - O(1) АМОРТИЗИРОВАННО, хотя
отдельный перелив стоит O(k). Это ровно амортизация из прошлого раздела. Дека -
обобщение: очередь с дешёвыми операциями на ОБОИХ концах (O(1) слева и справа).
Если ты умеешь доказать амортизированное O(1) для очереди на двух стеках (каждый элемент переливается один раз) и знаешь, что такое дека, листай до «Теперь сам».
Как думаешь, можно ли собрать очередь из стеков
Стек и очередь - противоположности: один отдаёт в обратном порядке, другой в прямом.
Казалось бы, из стека очередь не сделать. Но переворот легко чинится ВТОРЫМ
переворотом: перевернул дважды - вернул прямой порядок. Стек переворачивает порядок;
перелей стек в другой стек - и порядок перевернётся снова, то есть станет прямым. На
этом и стоит очередь из двух стеков. И заметь: у нас в кармане уже есть отличный стек -
list с append/pop, обе O(1). Значит, очередь можно собрать из того, что массив
делает дёшево, не трогая дорогую голову вовсе.
На что это похоже
Два лотка для бумаг: «входящие» и «на выход». Новые документы бросаешь во «входящие» - они копятся стопкой, свежий сверху. Когда надо взять самый СТАРЫЙ (по правилам очереди), а лоток «на выход» пуст, ты перекладываешь всю стопку из «входящих» в «на выход» - и она переворачивается: самый старый оказывается сверху. Дальше берёшь с «на выход», пока он не опустеет, и только тогда переливаешь снова. Каждый документ переложат из лотка в лоток ровно один раз - не на каждый запрос, а один раз за свою жизнь.
Механизм: два стека и перелив
Держим два стека: in (сюда enqueue) и out (отсюда dequeue).
enqueue(x):push(x)вin. ВсегдаO(1).dequeue(): еслиoutНЕ пуст -popсout,O(1). Еслиoutпуст - сначала переливаем: покаinне опустеет,popизinиpushвout(перелив переворачивает порядок, и старейший оказывается на вершинеout), потомpopсout.
Перелив стоит O(k), где k - сколько было в in. Кажется дорого. Но посмотри на
жизнь ОДНОГО элемента: его push-нули в in (1 шаг), при переливе pop-нули из in
(1) и push-нули в out (1), при выдаче pop-нули из out (1). Четыре дешёвых шага
за всю жизнь, и переливается он РОВНО раз - из in в out он попадает единожды и
обратно не возвращается. Значит n элементов дают не больше 4n шагов на всю серию,
то есть O(1) амортизированно на операцию.
Правило
Очередь на двух стеках: in для прихода, out для ухода. enqueue - push в in
(O(1)). dequeue - pop с out, а если out пуст, сперва перелить в него весь
in (перелив переворачивает порядок), потом снять. Отдельный перелив O(k), но каждый
элемент переливается ровно раз за жизнь, поэтому dequeue - O(1) АМОРТИЗИРОВАННО.
Это та же амортизация, что у динамического массива: редкая дорогая операция, оплаченная
дешёвыми.
Разбор: докажем O(1) методом потенциалов
Возьмём потенциал Φ = 2 · (размер стека in) - по ДВА за каждый элемент в in.
Почему по два: каждый элемент, лежащий в in, ждёт двух будущих дешёвых операций -
pop из in и push в out при переливе, - и заранее носит на них запас 2.
Φ неотрицателен и в пустой очереди равен нулю: условия метода из прошлого раздела
выполнены. Считаем ĉ = c + ΔΦ.
enqueue: настоящая ценаc = 1(одинpushвin),inвырос на один, значитΦвырос на2. Амортизированноĉ = 1 + 2 = 3 = O(1).dequeueбез перелива (outне пуст):c = 1(popсout),Φне изменился (inне трогали).ĉ = 1 + 0 = 1 = O(1).dequeueс переливом (inсодержалk): перелив - этоkснятий изinиkдобавлений вout, то есть2kдешёвых операций, плюс само снятие сout(1):c = 2k + 1. При этомΦупал с2kдо0, то естьΔΦ = −2k. Амортизированноĉ = (2k + 1) − 2k = 1 = O(1).
Во всех трёх случаях ĉ - константа. Дорогой перелив (его 2k) гасится падением
потенциала, который до этого накопили дешёвые enqueue (каждый поднял Φ на 2 -
как раз на будущие pop из in и push в out). Ровно как копирование при удвоении
массива, и коэффициент 2 тот же, что в Φ = 2·num − size.
Разбор: кольцевой буфер или два стека - что когда
Оба дают быструю очередь без сдвига головы, но по-разному. Кольцевой буфер - O(1) в
ХУДШЕМ случае на каждую операцию (никаких переливов, всегда одна запись и сдвиг
индекса), но ёмкость фиксирована и рост требует ручного удвоения. Очередь на двух
стеках - O(1) лишь АМОРТИЗИРОВАННО (отдельный dequeue с переливом бывает O(k)),
зато сама собой растёт (стеки на динамических массивах) и не знает переполнения. Выбор
- по знакомому правилу: нужен предсказуемый худший случай на каждый вызов (реальное
время) - кольцевой буфер; хватает гарантии по серии и хочется, чтобы очередь росла сама
- два стека. Разные виды цены под разные задачи, ровно как в главе про инвариант.
Что ломается без этого
Первая ошибка - переливать in в out, когда в out ещё что-то лежит. Новые элементы
лягут поверх старых и выйдут раньше них: enqueue 1, enqueue 2, dequeue, enqueue 3,
dequeue выдаст 1 и 3 вместо 1 и 2. Наивная починка - после каждого dequeue
переливать out обратно в in - порядок возвращает, но тогда каждый элемент льётся
многократно, и O(1) амортизированно рассыпается в O(n). Условие «переливаем лишь
при пустом out» держит и порядок, и цену: только оно гарантирует «ровно один перелив
за жизнь». Вторая -
принять амортизированную O(1) за гарантию на каждый вызов: отдельный dequeue с
переливом честно стоит O(k), и там, где важен худший ОДИН шаг, нужен кольцевой буфер.
Третья - перепутать концы и снимать/класть не туда, сломав порядок FIFO.
Зачем это в работе
Это классическая задача (в CLRS - упражнение к главе про амортизацию), и её любят на
собеседованиях: «реализуйте очередь на двух стеках» проверяет, понимаешь ли ты
амортизацию, а не только определения. На практике же за словом «очередь» в стандартных
библиотеках стоит обычно дека - collections.deque в Python, ArrayDeque в Java, -
структура с дешёвыми операциями на ОБОИХ концах: O(1) добавить и снять и слева, и
справа. Дека обобщает и стек (работай одним концом), и очередь (клади с одного, снимай
с другого), поэтому одной структурой закрывают оба случая. Внутри дека - либо кольцевой
буфер, либо двусвязный список: обе реализации ты теперь знаешь и понимаешь, откуда у
них O(1).
Теперь сам
В очередь на двух стеках сделали enqueue(1), enqueue(2), enqueue(3), потом три
dequeue(). Опиши, что происходит со стеками in и out, в каком порядке выйдут числа
и сколько всего переливов случится. Почему это O(1) амортизированно, а не O(n²)?
Ответ. После трёх enqueue: in = [1, 2, 3] (3 на вершине), out пуст. Первый
dequeue: out пуст → переливаем весь in в out, он переворачивается в
out = [3, 2, 1] (1 на вершине), снимаем 1. Второй dequeue: out не пуст, снимаем
2 без перелива. Третий: снимаем 3 без перелива. Выйдут 1, 2, 3 - порядок прихода,
FIFO соблюдён. Перелив был РОВНО один за всю серию: каждый элемент перетёк из in в
out единожды. Поэтому суммарная работа линейна (≤ 4n шагов), а не квадратична:
дорогой перелив случается редко и переливает каждый элемент лишь раз - O(1)
амортизированно.
Главное
Очередь на двух стеках: in для enqueue (push, O(1)), out для dequeue
(pop); если out пуст, сперва перелить в него весь in (порядок перевернётся),
потом снять. Перелив O(k), но каждый элемент переливается ровно раз за жизнь, поэтому
dequeue - O(1) амортизированно (потенциал Φ = 2·(размер in) - по два на элемент,
на будущие pop из in и push в out; дорогой перелив гасится его падением).
Переливать только при пустом out - сердце доказательства. Кольцевой
буфер даёт O(1) в худшем случае, но фиксированной ёмкости; два стека - амортизированно,
зато растут сами. Дека - обобщение с O(1) на обоих концах, за ней стоит кольцевой
буфер или двусвязный список.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий