Очередь на шести стеках: различия между версиями
СВ (обсуждение | вклад) (Новая страница: «== TL;DR == Обычная очередь на '''двух стеках''' работает амортизированно за <math>O(1)</math>, но отде...») |
СВ (обсуждение | вклад) |
||
| Строка 430: | Строка 430: | ||
# Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. ''Introduction to Algorithms'' (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 10 (элементарные структуры: стек, очередь). | # Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. ''Introduction to Algorithms'' (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 10 (элементарные структуры: стек, очередь). | ||
# Хабрахабр. Персистентная очередь (пост 241231). URL: https://habr.com/ru/post/241231/ ''(проверено 2026-11-03: статья о персистентной очереди, другой алгоритм — на деревьях)''. | # Хабрахабр. Персистентная очередь (пост 241231). URL: https://habr.com/ru/post/241231/ ''(проверено 2026-11-03: статья о персистентной очереди, другой алгоритм — на деревьях)''. | ||
[[Category:Алгоритмы и структуры данных]] | |||
Текущая версия на 21:07, 4 ноября 2026
TL;DR
Обычная очередь на двух стеках работает амортизированно за [math]\displaystyle{ O(1) }[/math], но отдельная операция может стоить [math]\displaystyle{ \Theta(n) }[/math]: когда выходной стек пуст, приходится разом перевернуть весь входной. Очередь на шести стеках (real-time queue) распределяет этот перенос на маленькие порции — не более 3 действий за операцию — и тем самым даёт [math]\displaystyle{ O(1) }[/math] в худшем случае на каждую операцию, то есть работает в реальном времени. Стеки [math]\displaystyle{ L, L', R, R_c, R_c', S }[/math] и переменные [math]\displaystyle{ \mathtt{recopy}, \mathtt{toCopy}, \mathtt{copied} }[/math] следят за фоновым перекопированием, а копии [math]\displaystyle{ R_c, R_c' }[/math] обслуживают pop, пока выход перестраивается. Из этой конструкции одной заменой [math]\displaystyle{ (R_c, R_c') \to R' }[/math] получается персистентная очередь на пяти стеках: каждая версия занимает [math]\displaystyle{ O(1) }[/math] новых ячеек, а операции остаются [math]\displaystyle{ O(1) }[/math] в худшем случае.
Инфобокс
| Свойство | Значение |
|---|---|
| Класс | Очередь (FIFO) в реальном времени; на стеках |
| Порядок доступа | FIFO |
| Худший случай | [math]\displaystyle{ O(1) }[/math] на push, pop, empty
|
| Амортизированный | [math]\displaystyle{ O(1) }[/math] (совпадает с худшим — амортизация не нужна) |
| Память | [math]\displaystyle{ O(n) }[/math] под элементы; [math]\displaystyle{ \le 3 }[/math] переноса на операцию (не более 6 стеков) |
| Стеки | [math]\displaystyle{ L, L', R, R_c, R_c', S }[/math] |
| Переменные | [math]\displaystyle{ \mathtt{recopy} }[/math], [math]\displaystyle{ \mathtt{toCopy} }[/math], [math]\displaystyle{ \mathtt{copied} }[/math] |
| Режимы | обычный и перекопирования |
| Сравнение | 2 стека — амортизированно [math]\displaystyle{ O(1) }[/math], худший [math]\displaystyle{ \Theta(n) }[/math]; 6 стеков — худший [math]\displaystyle{ O(1) }[/math]; 5 стеков — ещё и персистентность |
| Авторы идеи | Hood, Melville (1980); персистентный вариант — учебная традиция ИТМО, Okasaki |
| Где встречается | функциональные языки, персистентные структуры, системы реального времени, версионирование |
Определения
Очередь (см. Очередь (план)) — коллекция с порядком FIFO: push добавляет в конец, pop забирает из начала. Реализуется кольцевым буфером, списком или двумя стеками.
Реализация на двух стеках. Входной стек [math]\displaystyle{ L }[/math] принимает push, выходной [math]\displaystyle{ R }[/math] обслуживает pop. Когда [math]\displaystyle{ R }[/math] пуст, все элементы [math]\displaystyle{ L }[/math] переворачиваются в [math]\displaystyle{ R }[/math]: [math]\displaystyle{ L }[/math] разбирается сверху, каждый элемент кладётся в [math]\displaystyle{ R }[/math]. Разовый перенос стоит [math]\displaystyle{ \Theta(n) }[/math], поэтому [math]\displaystyle{ O(1) }[/math] здесь — амортизированный, а не худший.
Очередь в реальном времени (real-time queue). Очередь, у которой каждая операция выполняется за [math]\displaystyle{ O(1) }[/math] в худшем случае, без «долгих» вызовов. Синоним: очередь с ограниченным (постоянным) временем отклика.
Перекопирование (recopy mode). Фоновый режим, в котором часть работы переноса [math]\displaystyle{ (L \to R) }[/math] выполняется маленькими порциями параллельно с обычными операциями. Начинается, когда [math]\displaystyle{ L.size \gt R.size }[/math], и заканчивается, когда служебный стек [math]\displaystyle{ S }[/math] опустеет.
Шесть стеков. Основные: [math]\displaystyle{ L }[/math] — вход обычного режима, [math]\displaystyle{ R }[/math] — выход обычного режима. Вспомогательные: [math]\displaystyle{ L' }[/math] — вход во время перекопирования; [math]\displaystyle{ S }[/math] — буфер двойного переворота; [math]\displaystyle{ R_c }[/math] — копия текущего [math]\displaystyle{ R }[/math] (из неё идут pop во время перекопирования); [math]\displaystyle{ R_c' }[/math] — копия строящегося [math]\displaystyle{ R }[/math].
Переменные состояния. [math]\displaystyle{ \mathtt{recopy} }[/math] — булев флаг режима; [math]\displaystyle{ \mathtt{toCopy} }[/math] — сколько элементов в [math]\displaystyle{ S }[/math] ещё «корректны» (принадлежат очереди); [math]\displaystyle{ \mathtt{copied} }[/math] — начат ли перенос [math]\displaystyle{ L \to R }[/math].
Персистентная структура данных. Структура, к которой доступны все прошлые версии. Функциональная персистентность — сильнейшая форма: ячейка памяти инициализируется один раз и больше не меняется, поэтому версии разделяют неизменяемые данные. См. Персистентные структуры данных.
Персистентная очередь на пяти стеках. Вариант, где пара [math]\displaystyle{ (R_c, R_c') }[/math] заменена одной неизменяемой версией [math]\displaystyle{ R' }[/math], а версия очереди описывается записью [math]\displaystyle{ Q = \langle L, L', R, R', S, \mathtt{recopy}, \mathtt{toCopy}, \mathtt{copied}\rangle }[/math].
Обозначения. [math]\displaystyle{ n }[/math] — число элементов очереди; [math]\displaystyle{ |X| }[/math] — размер стека [math]\displaystyle{ X }[/math]; [math]\displaystyle{ \log }[/math] — двоичный. Стеки трактуются как последовательности «дно … верх».
Мотивация и интуиция
Очередь на двух стеках красива, но у неё есть скрытый дефект: один pop на пустом [math]\displaystyle{ R }[/math] тянет за собой перенос всех элементов — [math]\displaystyle{ \Theta(n) }[/math] работы и, что важнее для персистентности, [math]\displaystyle{ \Theta(n) }[/math] новых ячеек памяти в одной операции. Для систем реального времени (планировщики, обработка сигналов, сетевые буферы) задержка должна быть ограничена сверху; один вызов, пропорциональный размеру структуры, недопустим. См. рис. 1.
Ключевая идея: дробить дорогую работу. Пока очередь обслуживает обычные push/pop, она потихоньку готовит новый выходной стек, выполняя ровно 3 действия за операцию. Если начать перекопирование заранее — когда [math]\displaystyle{ |L| }[/math] лишь на единицу превысил [math]\displaystyle{ |R| }[/math], — то на перенос останется ровно столько операций, сколько нужно, и «долгих» вызовов не будет вовсе.
Почему именно шесть стеков? Во время переноса стек [math]\displaystyle{ L }[/math] разбирается, поэтому новые push надо куда-то девать — так появляется [math]\displaystyle{ L' }[/math]. Пока строится новый [math]\displaystyle{ R }[/math], старый выход ещё должен отвечать на pop — так появляется копия [math]\displaystyle{ R_c }[/math]. У копии тоже есть версия «на будущее» — [math]\displaystyle{ R_c' }[/math]. Наконец, старый [math]\displaystyle{ R }[/math] может не успеть опустеть (если операций push было много), поэтому его содержимое временно уводят в буфер [math]\displaystyle{ S }[/math], чтобы через двойной переворот вернуть уже над новыми элементами [math]\displaystyle{ L }[/math]. Итого [math]\displaystyle{ L, L', R, R_c, R_c', S }[/math] — шесть стеков.
Устройство
Очередь хранит шесть стеков и три переменные; вместе они реализуют FIFO с постоянным временем отклика (см. рис. 2).
Работа идёт в двух режимах (см. рис. 3):
- Обычный режим.
pushкладёт в [math]\displaystyle{ L }[/math],popберёт из [math]\displaystyle{ R }[/math] (и синхронно из [math]\displaystyle{ R_c }[/math]); [math]\displaystyle{ \mathtt{empty} = (R = \varnothing) }[/math]. После каждой операции проверка [math]\displaystyle{ \mathtt{recopy} \leftarrow (|L| \gt |R|) }[/math]. - Режим перекопирования.
pushкладёт в [math]\displaystyle{ L' }[/math],popберёт из [math]\displaystyle{ R_c }[/math]; [math]\displaystyle{ \mathtt{empty} = \text{false} }[/math]; каждая операция делает не более 3 переносов. Режим длится, пока [math]\displaystyle{ S \neq \varnothing }[/math].
Перекопирование не должно затянуться дольше, чем простоит старый выход, — иначе не хватит свободного стека. Гарантию дают инварианты (см. рис. 4). В обычном режиме: [math]\displaystyle{ L }[/math] и [math]\displaystyle{ R }[/math] — половины очереди, [math]\displaystyle{ |L| \le |R| }[/math], [math]\displaystyle{ R = \varnothing \iff Q = \varnothing }[/math], [math]\displaystyle{ R_c }[/math] — копия [math]\displaystyle{ R }[/math], а [math]\displaystyle{ R_c' }[/math] и [math]\displaystyle{ S }[/math] подчищены так, что [math]\displaystyle{ |R_c'| \le |R| - |L| }[/math] и [math]\displaystyle{ |L'| = |S| = 0 }[/math]. В режиме перекопирования: [math]\displaystyle{ |R_c| \ge \mathtt{toCopy} }[/math] (равенство, пока из [math]\displaystyle{ S }[/math] не переносили корректных элементов), и если [math]\displaystyle{ |L| = 0 }[/math], то либо первые [math]\displaystyle{ \mathtt{toCopy} }[/math] элементов [math]\displaystyle{ S }[/math] корректны, либо (при [math]\displaystyle{ \mathtt{toCopy} \le 0 }[/math]) стек [math]\displaystyle{ R }[/math] уже содержит весь правый кусок очереди в правильном порядке.
Операции и алгоритмы
Ниже — операции очереди. Обозначим [math]\displaystyle{ |X| = \mathtt{X.size} }[/math]; стек как объект со стандартными [math]\displaystyle{ \mathtt{push}, \mathtt{pop} }[/math]; [math]\displaystyle{ \mathtt{swap} }[/math] меняет местами переменные-стеки.
Операция: Empty
Идея. В обычном режиме очередь пуста тогда и только тогда, когда пуст [math]\displaystyle{ R }[/math]; в режиме
перекопирования очередь заведомо непуста (см. лемму о корректности Empty ниже).
Операция Empty(Q)
Вход: очередь Q
Выход: true, если элементов нет
Инвариант: R = ∅ ⟺ Q = ∅ (в обычном режиме)
Сложность: O(1) времени, O(1) памяти
1 return (not Q.recopy) and (|R| = 0)
Крайние случаи. Во время перекопирования Empty всегда false, даже если все «старые» элементы уже извлечены: за время переноса выполняется не более [math]\displaystyle{ n }[/math] извлечений, а в очереди было [math]\displaystyle{ 2n+1 }[/math] элементов, поэтому неперекопированный остаток лежит в [math]\displaystyle{ R_c }[/math] или [math]\displaystyle{ R }[/math].
Операция: Push
Идея. В обычном режиме элемент идёт в [math]\displaystyle{ L }[/math]; попутно подчищаем [math]\displaystyle{ R_c' }[/math] и проверяем, не пора ли начать перекопирование. В режиме перекопирования элемент идёт в [math]\displaystyle{ L' }[/math] (стек [math]\displaystyle{ L }[/math] сейчас разбирается), и продолжаем фоновую работу.
Операция Push(Q, x)
Вход: очередь Q; x — элемент
Выход: x добавлен в конец очереди
Инвариант: в обычном режиме Q = R (сверху вниз) ++ reverse(L)
Сложность: O(1) в худшем случае (не более 3 переносов)
1 if not Q.recopy
2 push(L, x)
3 if |Rc'| > 0
4 pop(Rc') # рассасываем остаток копии
5 Check-Recopy(Q)
6 else
7 push(L', x)
8 Check-Normal(Q)
Крайние случаи. Если очередь была пуста, push запускает перекопирование (так как [math]\displaystyle{ |L| = 1 \gt 0 = |R| }[/math]), но завершает его сразу же за один вызов, попадая в обычный режим.
Операция: Pop
Идея. В обычном режиме берём верх [math]\displaystyle{ R }[/math] (это самый старый элемент) и синхронно чистим [math]\displaystyle{ R_c }[/math]. В режиме перекопирования элемент берётся из копии [math]\displaystyle{ R_c }[/math]; если [math]\displaystyle{ \mathtt{toCopy} \gt 0 }[/math], он «оплачен» корректным элементом из [math]\displaystyle{ S }[/math], иначе берём и из перестраиваемого [math]\displaystyle{ R }[/math].
Операция Pop(Q)
Вход: очередь Q
Выход: самый старый элемент; error, если очередь пуста
Инвариант: возвращаемый элемент — минимальный по времени поступления
Сложность: O(1) в худшем случае (не более 3 переносов)
1 if not Q.recopy
2 x ← pop(R)
3 pop(Rc)
4 if |Rc'| > 0
5 pop(Rc')
6 Check-Recopy(Q)
7 return x
8 else
9 x ← pop(Rc)
10 if Q.toCopy > 0
11 Q.toCopy ← Q.toCopy − 1
12 else
13 pop(R)
14 pop(Rc')
15 Check-Normal(Q)
16 return x
Крайние случаи. Если в режиме перекопирования [math]\displaystyle{ \mathtt{toCopy} }[/math] дошёл до нуля, значит, оставшиеся старые элементы уже продублированы в [math]\displaystyle{ R }[/math]; тогда pop синхронно снимает элемент из [math]\displaystyle{ R_c }[/math], [math]\displaystyle{ R }[/math] и [math]\displaystyle{ R_c' }[/math]. Обращение к пустой очереди — ошибка (underflow), как и в любой очереди.
Операция: Check-Recopy
Идея. После операции в обычном режиме проверить, не перерос ли вход выход; если да — переключиться в режим перекопирования и сделать первый пакет переносов.
Операция Check-Recopy(Q)
1 Q.recopy ← (|L| > |R|)
2 if Q.recopy
3 Q.toCopy ← |R| # столько старых элементов ещё «в деле»
4 Q.copied ← false # перенос L → R ещё не начат
5 Check-Normal(Q)
Операция: Check-Normal
Идея. Доделать пакет переносов; режим перекопирования продолжается, пока непуст [math]\displaystyle{ S }[/math].
Операция Check-Normal(Q)
1 Additional-Operations(Q)
2 Q.recopy ← (|S| ≠ 0) # S пуст — перекопирование завершено
Операция: Additional-Operations
Идея. За один вызов выполнить не более 3 действий над вспомогательными стеками (см. рис. 5). Четыре фазы:
- [math]\displaystyle{ R \to S }[/math] — пока [math]\displaystyle{ R }[/math] не пуст и [math]\displaystyle{ \mathtt{copied} = \text{false} }[/math], перекладывать [math]\displaystyle{ R }[/math] в [math]\displaystyle{ S }[/math] (временный переворот выхода).
- [math]\displaystyle{ L \to R, R_c' }[/math] — как только начали, ставим [math]\displaystyle{ \mathtt{copied} \leftarrow \text{true} }[/math]; каждый элемент [math]\displaystyle{ L }[/math] идёт и в [math]\displaystyle{ R }[/math], и в [math]\displaystyle{ R_c' }[/math].
- [math]\displaystyle{ S \to R, R_c' }[/math] — первые [math]\displaystyle{ \mathtt{toCopy} }[/math] элементов [math]\displaystyle{ S }[/math] возвращаются в [math]\displaystyle{ R }[/math] и [math]\displaystyle{ R_c' }[/math] (двойной переворот восстанавливает порядок [math]\displaystyle{ R }[/math] и кладёт его над элементами [math]\displaystyle{ L }[/math]); остальные (уже логически извлечённые) отбрасываются.
- Смена ролей — если [math]\displaystyle{ S }[/math] пуст: [math]\displaystyle{ \mathtt{swap}(L, L') }[/math], [math]\displaystyle{ \mathtt{swap}(R_c, R_c') }[/math].
Операция Additional-Operations(Q)
Вход: очередь Q в режиме перекопирования
Выход: сделано не более 3 переносов
Инвариант: фазы 1–4 выполняются строго по порядку
Сложность: O(1) (счётчик toDo = 3)
1 toDo ← 3
2 while (not Q.copied) and toDo > 0 and |R| > 0
3 push(S, pop(R)) # фаза 1: R → S
4 toDo ← toDo − 1
5 while toDo > 0 and |L| > 0
6 Q.copied ← true
7 x ← pop(L)
8 push(R, x) # фаза 2: L → R
9 push(Rc', x) # и L → Rc'
10 toDo ← toDo − 1
11 while toDo > 0 and |S| > 0
12 x ← pop(S) # фаза 3: S → R, Rc'
13 if Q.toCopy > 0
14 push(R, x)
15 push(Rc', x)
16 Q.toCopy ← Q.toCopy − 1
17 toDo ← toDo − 1
18 if |S| = 0
19 swap(L, L') # фаза 4: смена ролей
20 swap(Rc, Rc')
Разбор. Точные состояния стеков на примере push(1..7) и последующих pop приведены в разделе «Трассировка примера». Ключевой момент фазы 3 — двойной переворот [math]\displaystyle{ R \to S \to R }[/math]: он возвращает относительный порядок старого выхода неизменным, но ставит его поверх элементов [math]\displaystyle{ L }[/math]; поскольку старые элементы и есть «перед» очереди, pop из нового [math]\displaystyle{ R }[/math] выдаёт FIFO.
Крайние случаи. Если [math]\displaystyle{ R }[/math] уже пуст, фаза 1 пропускается и работа сразу начинается с фазы 2. Если [math]\displaystyle{ L }[/math] пуст, фаза 2 пропускается. Если [math]\displaystyle{ \mathtt{toCopy} = 0 }[/math], элементы [math]\displaystyle{ S }[/math] просто извлекаются и выбрасываются — они уже были отданы через [math]\displaystyle{ R_c }[/math].
Сводная таблица сложности
| Операция | Худший случай | Амортизированный | Переносов за вызов | Память |
|---|---|---|---|---|
Empty |
[math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | 0 | [math]\displaystyle{ O(1) }[/math] |
Push |
[math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ \le 3 }[/math] | [math]\displaystyle{ O(1) }[/math] |
Pop |
[math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ \le 3 }[/math] | [math]\displaystyle{ O(1) }[/math] |
Check-Recopy / Check-Normal |
[math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ \le 3 }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Вся очередь | — | — | — | [math]\displaystyle{ O(n) }[/math] под элементы |
Здесь худший и амортизированный случаи совпадают: амортизация не нужна, потому что никакая операция не выполняет больше трёх переносов. Для сравнения, очередь на двух стеках имеет амортизированный [math]\displaystyle{ O(1) }[/math], но худший [math]\displaystyle{ \Theta(n) }[/math]. В режиме перекопирования каждая «обычная» операция дополнительно тратит до 3 переносов — это уже учтено в оценке [math]\displaystyle{ O(1) }[/math].
Трассировка примера
Проследим push(1) … push(7), затем семь pop. Стеки записаны в виде «дно … верх»; в столбце «режим»: обыч. — обычный, перекоп. — перекопирование. Значения получены прогоном реализации из раздела «Служебное».
| Операция | Режим | [math]\displaystyle{ L }[/math] | [math]\displaystyle{ L' }[/math] | [math]\displaystyle{ R }[/math] | [math]\displaystyle{ R_c }[/math] | [math]\displaystyle{ R_c' }[/math] | [math]\displaystyle{ S }[/math] | toCopy
|
|---|---|---|---|---|---|---|---|---|
push(1) |
обыч.→перекоп.→обыч. | — | — | 1 | 1 | — | — | 0 |
push(2) |
обыч. | 2 | — | 1 | 1 | — | — | 0 |
push(3) |
обыч.→перекоп. | — | — | 3,2 | 1 | 3,2 | 1 | 1 |
push(4) |
перекоп.→обыч. | 4 | — | 3,2,1 | 3,2,1 | 1 | — | 0 |
push(5) |
обыч. | 4,5 | — | 3,2,1 | 3,2,1 | — | — | 0 |
push(6) |
обыч. | 4,5,6 | — | 3,2,1 | 3,2,1 | — | — | 0 |
push(7) |
обыч.→перекоп. | 4,5,6,7 | — | — | 3,2,1 | — | 1,2,3 | 3 |
pop() → 1 |
перекоп. | 4 | — | 7,6,5 | 3,2 | 7,6,5 | 1,2,3 | 2 |
pop() → 2 |
перекоп. | — | — | 7,6,5,4,3 | 3 | 7,6,5,4,3 | 1 | 0 |
pop() → 3 |
перекоп.→обыч. | — | — | 7,6,5,4 | 7,6,5,4 | — | — | 0 |
pop() → 4 |
обыч. | — | — | 7,6,5 | 7,6,5 | — | — | 0 |
pop() → 5 |
обыч. | — | — | 7,6 | 7,6 | — | — | 0 |
pop() → 6 |
обыч. | — | — | 7 | 7 | — | — | 0 |
pop() → 7 |
обыч. | — | — | — | — | — | — | 0 |
Разберём два важных момента. Во-первых, push(3) запускает перекопирование сразу, но успевает сделать только 3 переноса (старый [math]\displaystyle{ R = \{1\} }[/math] уходит в [math]\displaystyle{ S }[/math], два элемента [math]\displaystyle{ L }[/math] — в [math]\displaystyle{ R }[/math] и [math]\displaystyle{ R_c' }[/math]); завершается перенос на push(4), после чего пары [math]\displaystyle{ L \leftrightarrow L' }[/math] и [math]\displaystyle{ R_c \leftrightarrow R_c' }[/math] меняются ролями. Во-вторых, при push(7) вход ([math]\displaystyle{ |L| = 4 }[/math]) перерос выход ([math]\displaystyle{ |R| = 3 }[/math]), начался новый перенос: [math]\displaystyle{ R }[/math] целиком ушёл в [math]\displaystyle{ S }[/math], toCopy = 3. Следующие pop берут элементы из [math]\displaystyle{ R_c }[/math] (копия старого [math]\displaystyle{ R }[/math]), пока в фоне [math]\displaystyle{ L }[/math] переливается в новый [math]\displaystyle{ R }[/math]; когда toCopy доходит до нуля, корректная часть [math]\displaystyle{ S }[/math] переносится в [math]\displaystyle{ R }[/math], а остальное отбрасывается, и перекопирование заканчивается. Порядок выдачи — [math]\displaystyle{ 1,2,\dots,7 }[/math] — ровно FIFO, и ни один pop не сделал больше трёх внутренних переносов.
Корректность и оценка сложности
Инвариант и FIFO
Будем считать, что в обычном режиме элементы очереди равны [math]\displaystyle{ R }[/math] (сверху вниз) [math]\displaystyle{ \mathbin{+\!\!+} }[/math] [math]\displaystyle{ \mathrm{reverse}(L) }[/math]. Тогда верх [math]\displaystyle{ R }[/math] — самый старый элемент, и Pop возвращает его.
Лемма (сохранение порядка при перекопировании). После завершения перекопирования стек [math]\displaystyle{ R }[/math] содержит все элементы очереди в правильном порядке для извлечения.
Доказательство. Пусть до перекопирования очередь равна [math]\displaystyle{ R_0 \mathbin{+\!\!+} L_0 }[/math] (в указанном смысле). Фаза 1 переносит [math]\displaystyle{ R_0 }[/math] в [math]\displaystyle{ S }[/math], разворачивая: [math]\displaystyle{ S = \mathrm{reverse}(R_0) }[/math]. Фаза 2 переносит [math]\displaystyle{ L_0 }[/math] в [math]\displaystyle{ R }[/math], разворачивая: [math]\displaystyle{ R = \mathrm{reverse}(L_0) }[/math]. Фаза 3 переносит [math]\displaystyle{ S }[/math] (в порядке [math]\displaystyle{ \mathrm{reverse}(R_0) }[/math]) обратно в [math]\displaystyle{ R }[/math], что даёт [math]\displaystyle{ R = \mathrm{reverse}(L_0) \mathbin{+\!\!+} R_0 }[/math] в смысле стека, то есть сверху оказывается [math]\displaystyle{ R_0 }[/math] в исходном порядке, а под ним — [math]\displaystyle{ L_0 }[/math] в исходном порядке. Поскольку элементы [math]\displaystyle{ R_0 }[/math] старее элементов [math]\displaystyle{ L_0 }[/math], это и есть FIFO-порядок. [math]\displaystyle{ \square }[/math]
Лемма (обслуживание во время перекопирования). Пока перекопирование не завершено, Pop возвращает корректный самый старый элемент, не дожидаясь конца переноса.
Доказательство. В режиме перекопирования Pop читает [math]\displaystyle{ R_c }[/math] — копию старого [math]\displaystyle{ R_0 }[/math]; в варианте на шести стеках она синхронно уменьшается вместе с [math]\displaystyle{ R }[/math], а в персистентном заменена неизменяемой версией [math]\displaystyle{ R' }[/math]. Элементы [math]\displaystyle{ R_0 }[/math] старее всех элементов [math]\displaystyle{ L_0 \cup L' }[/math], поэтому выдача из [math]\displaystyle{ R_c }[/math] сохраняет FIFO. Счётчик [math]\displaystyle{ \mathtt{toCopy} }[/math] уменьшается и при извлечении из [math]\displaystyle{ R_c }[/math], и при переносе корректного элемента из [math]\displaystyle{ S }[/math]; поэтому [math]\displaystyle{ |R_c| \ge \mathtt{toCopy} }[/math]. Пока [math]\displaystyle{ \mathtt{toCopy} \gt 0 }[/math], верх [math]\displaystyle{ R_c }[/math] — ещё не перенесённый корректный элемент; когда счётчик доходит до нуля, старый правый кусок уже распределён между [math]\displaystyle{ R }[/math] и [math]\displaystyle{ R_c }[/math], и Pop синхронно снимает элемент из [math]\displaystyle{ R_c }[/math], [math]\displaystyle{ R }[/math] и [math]\displaystyle{ R_c' }[/math]. Инвариант [math]\displaystyle{ \mathtt{toCopy} }[/math] не даёт прочитать «мусорные» элементы в дне [math]\displaystyle{ S }[/math]. [math]\displaystyle{ \square }[/math]
Лемма (корректность Empty). В обычном режиме [math]\displaystyle{ R = \varnothing \iff Q = \varnothing }[/math]; в режиме перекопирования очередь непуста.
Доказательство. В обычном режиме инвариант [math]\displaystyle{ |L| \le |R| }[/math] и представление очереди как [math]\displaystyle{ R \mathbin{+\!\!+} \mathrm{reverse}(L) }[/math] дают эквивалентность. В режиме перекопирования в момент старта (после запускающей операции) в очереди [math]\displaystyle{ n + (n + 1) = 2n + 1 }[/math] элементов. Перенос занимает [math]\displaystyle{ n + 1 }[/math] операций, включая запускающую; после неё остаётся не более [math]\displaystyle{ n }[/math] операций, поэтому извлечь можно не более [math]\displaystyle{ n }[/math] элементов. Значит, хотя бы один элемент остаётся: при [math]\displaystyle{ \mathtt{toCopy} \gt 0 }[/math] он лежит в [math]\displaystyle{ R_c }[/math], а при [math]\displaystyle{ \mathtt{toCopy} = 0 }[/math] весь правый кусок уже находится в [math]\displaystyle{ R }[/math]. Поэтому Empty возвращает false корректно. [math]\displaystyle{ \square }[/math]
Оценка [math]\displaystyle{ O(1) }[/math] в худшем случае
Все переносы выполняются только в Additional-Operations, где счётчик toDo ограничен тройкой, и в фазах 1–3 каждый шаг — один перенос (pop и один-два push). Значит, одна операция очереди делает не более 3 переносов, то есть [math]\displaystyle{ O(1) }[/math] в худшем случае (см. рис. 6).
Покажем, что перекопирование успевает завершиться. В момент старта [math]\displaystyle{ |R| = n }[/math] и [math]\displaystyle{ |L| = n + 1 }[/math]. Требуется: [math]\displaystyle{ R \to S }[/math] — [math]\displaystyle{ n }[/math] действий; [math]\displaystyle{ L \to R, R_c' }[/math] — [math]\displaystyle{ n + 1 }[/math]; [math]\displaystyle{ S \to R, R_c' }[/math] — [math]\displaystyle{ n }[/math]; две смены ролей — 2. Итого [math]\displaystyle{ 3n + 3 }[/math] действий. В распоряжении — [math]\displaystyle{ n + 1 }[/math] операций (сама запускающая перекопирование плюс следующие за ней до завершения переноса), по 3 действия каждая, то есть ровно [math]\displaystyle{ 3(n + 1) = 3n + 3 }[/math]. Расчёт сходится: перенос гарантированно завершается.
После перекопирования. Пусть за [math]\displaystyle{ n }[/math] операций после старта произошло [math]\displaystyle{ x }[/math] извлечений и [math]\displaystyle{ n - x }[/math] добавлений. Тогда в новых стеках останется [math]\displaystyle{ n - x }[/math] элементов в [math]\displaystyle{ L }[/math] и [math]\displaystyle{ 2n + 1 - x = (n - x) + (n + 1) }[/math] в [math]\displaystyle{ R }[/math], значит, до следующего перекопирования не менее [math]\displaystyle{ n + 2 }[/math] операций. За это время остаток [math]\displaystyle{ R_c' }[/math] (не более [math]\displaystyle{ n }[/math] элементов) успевает рассосаться: он уменьшается на 1 при каждой операции в обычном режиме. Это и есть содержание инварианта [math]\displaystyle{ |R_c'| \le |R| - |L| }[/math].
Персистентная очередь на пяти стеках
Сделаем все стеки персистентными (неизменяемыми): операция возвращает новую версию, старые остаются доступны. Тогда шесть стеков превращаются в пять: пара [math]\displaystyle{ (R_c, R_c') }[/math] избыточна — достаточно одной версии [math]\displaystyle{ R' }[/math], записанной в момент старта перекопирования; pop обращается к ней, и никакой синхронизации не нужно. Освобождать [math]\displaystyle{ R' }[/math] тоже не требуется: ячейки используются другими версиями очереди. Версия очереди — запись [math]\displaystyle{ Q = \langle L, L', R, R', S, \mathtt{recopy}, \mathtt{toCopy}, \mathtt{copied}\rangle }[/math]; push и pop возвращают новую версию вместе с результатом. Переносы по-прежнему ограничены тремя на операцию, поэтому каждая версия занимает [math]\displaystyle{ O(1) }[/math] новых ячеек и время [math]\displaystyle{ O(1) }[/math] в худшем случае. Псевдокод (упрощённо, по источнику):
Версия Push(Q, x)
1 if not Q.recopy
2 Ln ← push(Q.L, x)
3 return Check-Recopy(⟨Ln, Q.L', Q.R, Q.R', Q.S, Q.recopy, Q.toCopy, Q.copied⟩)
4 else
5 Ln' ← push(Q.L', x)
6 return Check-Normal(⟨Q.L, Ln', Q.R, Q.R', Q.S, Q.recopy, Q.toCopy, Q.copied⟩)
Версия Pop(Q)
1 if not Q.recopy
2 ⟨Rn, x⟩ ← pop(Q.R)
3 return ⟨Check-Recopy(⟨Q.L, Q.L', Rn, Q.R', Q.S, …⟩), x⟩
4 else
5 ⟨Rn', x⟩ ← pop(Q.R')
6 if Q.toCopy > 0
7 cur ← Q.toCopy − 1
8 return ⟨Check-Normal(⟨Q.L, Q.L', Q.R, Rn', Q.S, …, cur, Q.copied⟩), x⟩
9 else
10 ⟨Rn, x⟩ ← pop(Q.R)
11 return ⟨Check-Normal(⟨Q.L, Q.L', Rn, Rn', Q.S, …, 0, Q.copied⟩), x⟩
Функции Check-Recopy, Check-Normal, Additional-Operations повторяют версию на шести стеках, но вместо мутации создают новые версии стеков. Важное отличие: в пятистековом варианте Check-Recopy при входе в перекопирование фиксирует [math]\displaystyle{ R' \leftarrow R }[/math] — текущую версию выхода; без этого pop в режиме перекопирования читал бы устаревшую версию. См. рис. 7.
Сравнение с альтернативами
| Реализация | Худший | Амортизированный | Память на версию | Персистентность |
|---|---|---|---|---|
| Два стека | [math]\displaystyle{ \Theta(n) }[/math] | [math]\displaystyle{ O(1) }[/math] | неприменима ([math]\displaystyle{ \Theta(n) }[/math]) | нет |
| Шесть стеков (реальное время) | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | — (не персистентна) | нет |
| Пять стеков (персистентная) | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] новых ячеек | да (функциональная) |
| Кольцевой буфер | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ \Theta(n) }[/math] на копию | нет |
| Связный список | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ \Theta(1) }[/math], но с риском разделения | частично (copy-on-write) |
Когда что выбирать. Нужна простая очередь общего назначения — кольцевой буфер или двусвязный список из Очередь (план). Нужна гарантия времени отклика — шесть стеков. Нужны прошлые версии (undo, функциональный язык) — персистентная очередь на пяти стеках. Если допустимы амортизированные оценки и не важна задержка, двух стеков достаточно.
Где применяется
- Функциональные языки и библиотеки. В Haskell, OCaml, Standard ML очереди и деки — персистентные структуры; техника раскрытия «ленивого» переноса восходит к реальному времени и используется в
Data.Sequence, очередях Okasaki и структурах с неизменяемыми версиями. Почему: в чисто функциональном коде структура не мутируется, а старые версии должны сохраняться [4][5].
- Системы реального времени. Планировщики, обработчики прерываний, буферы событий требуют ограниченного сверху времени отклика. Очередь с [math]\displaystyle{ O(1) }[/math] в худшем случае исключает редкие «долгие» вызовы, которые опасны в контурах управления и телеком-оборудовании [1][3].
- Персистентность и версионирование. Undo/redo в редакторах, транзакционные снимки, инкрементальные вычисления и системы контроля версий выигрывают от структур, где каждая версия стоит [math]\displaystyle{ O(1) }[/math] новых ячеек. Персистентная очередь — базовый кирпич таких систем [4].
- Логи событий и распределённые потоки. Очередь сообщений, к которой нужен доступ к прошлым состояниям (replay, аудит), строится на персистентных структурах; неизменяемость упрощает репликацию и откат [4].
- Обучение алгоритмам. Конструкция «очередь на шести стеках» — классическая задача курса дискретной математики и алгоритмов (Викиконспекты ИТМО): она тренирует инварианты, амортизационный анализ и персистентность [1][2].
- Олимпиадное программирование. Персистентная очередь и её «друзья» (персистентный стек, дек, приоритетная очередь) — инструменты задач на версии и откаты; разборы публикуются в блогах сообщества [2].
Историческая справка
Операции над очередью в реальном времени первыми описали Роберт Худ и Роберт Мелвилл (R. Hood, R. Melville) в работе Real Time Queue Operations in Pure LISP (Cornell University, 1980; отчёт доступен через библиотечный депозитарий Корнеллского университета) [3]. Они показали, как реализовать очередь без долгих операций в чистом LISP, где данные неизменяемы, — именно персистентность и подталкивала к распределению переноса по операциям.
Идею подхватил Крис Окасаки (Chris Okasaki): в статье Simple and Efficient Purely Functional Queues and Deques (Journal of Functional Programming, 1995) и книге Purely Functional Data Structures (Cambridge University Press, 1998) он систематизировал персистентные очереди — «банкирскую», «физическую» и очередь реального времени [4]. Формальный амортизированный анализ (и метод потенциала) восходит к Роберту Тарьяну (R. E. Tarjan, «Amortized Computational Complexity», 1985) [5] и служит языком описания оценок в этой области.
Изложение конструкции «очередь на шести стеках» и её персистентного варианта на пяти стеках — учебная традиция кафедры компьютерных технологий ИТМО; конспект доступен на «Викиконспектах» (neerc.ifmo.ru) [1]. Популярные разборы — в блоге Codeforces [2] и на Хабрахабре [7]. Отметим, что конкретное число стеков (шесть против пяти) зависит от того, требуется ли явная копия выхода: persistence позволяет обойтись меньшим числом стеков [1].
Типичные ошибки и подводные камни
- Путать амортизированное с худшим. Очередь на двух стеках — амортизированно [math]\displaystyle{ O(1) }[/math], но худший случай [math]\displaystyle{ \Theta(n) }[/math]; только конструкция на шести стеках даёт [math]\displaystyle{ O(1) }[/math] в каждом вызове.
- Забыть подчищать [math]\displaystyle{ R_c' }[/math]. В обычном режиме остаток копии нужно уменьшать на 1 за операцию (инвариант [math]\displaystyle{ |R_c'| \le |R| - |L| }[/math]), иначе к следующему перекопированию не найдётся свободного стека.
- Неверный момент запуска. Перекопирование нужно начинать ровно при [math]\displaystyle{ |L| = |R| + 1 }[/math]; позже — не хватит операций на перенос, раньше — нарушится инвариант и лишняя работа.
- Ошибка на единицу в
toCopy. Это число корректных элементов в [math]\displaystyle{ S }[/math]; приpopво время перекопирования его нужно уменьшать, иначе можно прочитать уже извлечённый элемент. - Забыть фазу 1 ([math]\displaystyle{ R \to S }[/math]). Без буфера [math]\displaystyle{ S }[/math] старый выход не опустеет, если операций
pushбольше, чемpop, и не хватит стека для нового [math]\displaystyle{ R }[/math]; двойной переворот через [math]\displaystyle{ S }[/math] обязателен. - Не сделать смену ролей. Без [math]\displaystyle{ \mathtt{swap}(L, L') }[/math] и [math]\displaystyle{ \mathtt{swap}(R_c, R_c') }[/math] новые результаты останутся в служебных стеках, а очередь будет читать старое.
- Попытка сделать двухстековую очередь персистентной «в лоб». Дорогая операция переноса порождает [math]\displaystyle{ \Theta(n) }[/math] новых ячеек на версию — нужна именно конструкция с дроблением.
- Мутируемые стеки в персистентном варианте. Персистентность требует неизменяемости; изменение «на месте» разрушит прошлые версии.
- Закольцованная рекурсия проверок.
Check-RecopyвызываетCheck-Normal, который может выставитьrecopyснова; рекурсии здесь нет — это разные шаги одной операции, но при переносе в код важно не зациклиться. - Предположение, что
Emptyложен всегда в перекопировании. Так и есть (в [math]\displaystyle{ R_c }[/math] или [math]\displaystyle{ R }[/math] остаются элементы), но если реализация не поддерживает инвариант [math]\displaystyle{ |L| = |R| + 1 }[/math] в момент старта,Emptyможет соврать.
Задачи для практики
- Два стека. Реализуйте очередь на двух стеках и определите худший случай. Ограничение: не более [math]\displaystyle{ O(1) }[/math] памяти на элемент. Подсказка: перенос происходит при опустошении выхода; худший вызов — [math]\displaystyle{ \Theta(n) }[/math].
- Инвариант. Докажите, что в обычном режиме [math]\displaystyle{ R = \varnothing \iff Q = \varnothing }[/math]. Подсказка: используйте [math]\displaystyle{ |L| \le |R| }[/math] и представление [math]\displaystyle{ R \mathbin{+\!\!+} \mathrm{reverse}(L) }[/math].
- Счёт действий. Покажите, что при старте перекопирования ([math]\displaystyle{ |L| = |R| + 1 = n + 1 }[/math]) требуется ровно [math]\displaystyle{ 3n + 3 }[/math] действий и что их укладывает ровно [math]\displaystyle{ n + 1 }[/math] операция по 3. Подсказка: сложите вклады фаз 1–4.
toCopy. Объясните, почему некорректные элементы [math]\displaystyle{ S }[/math] скапливаются у дна, и почему условиеtoCopy > 0защищает от чтения мусора. Подсказка: элементы [math]\displaystyle{ S }[/math] переносятся по одному, а уменьшениеtoCopyсоответствует уже отданным элементам.
- Трассировка. Вручную повторите семь
pushи семьpopиз раздела «Трассировка примера» и сверьте состояния стеков. Подсказка: следите за флагамиrecopy,copied,toCopy.
- Реальное время vs персистентность. Объясните, почему шестистековый вариант годится для реального времени, а для персистентности достаточно пяти стеков. Подсказка: роль копии [math]\displaystyle{ R_c }[/math] берёт на себя версия [math]\displaystyle{ R' }[/math].
- Пятистековая очередь. Реализуйте персистентную очередь на пяти неизменяемых стеках так, чтобы каждая версия занимала [math]\displaystyle{ O(1) }[/math] новых ячеек. Ограничение: стеки неизменяемы.
- Расширение до дека. Попробуйте добавить
pop_back/push_frontи обсудите, почему дек на стеках сложнее очереди. Подсказка: симметрия «лево/право» требует mirrored-структуры.
Связанные темы
- Очередь (план) — базовая FIFO, кольцевой буфер, очередь на двух стеках.
- Стек (план) — строительный блок; стек как персистентная структура.
- Персистентный стек (план) — неизменяемый стек; основа персистентной очереди.
- Персистентная очередь (план) — персистентность очереди, ленивые очереди Окасаки.
- Асимптотическая сложность — амортизация, худший случай, худший и амортизированный.
- Персистентные структуры данных — неизменяемость и версии.
- Декартово дерево (план) — персистентный treap как ещё один пример персистентности.
- Очередь с приоритетом (план) — родственная структура; персистентный вариант.
Источники
- Викиконспекты ИТМО: Персистентная очередь (разделы «Реализация очереди на шести стеках», «Персистентная очередь на пяти стеках»). URL: https://neerc.ifmo.ru/wiki/index.php?title=Персистентная_очередь.
- Codeforces. Персистентная очередь и её друзья (блог). URL: https://codeforces.com/blog/entry/15685.
- Hood R., Melville R. Real Time Queue Operations in Pure LISP. — Cornell University, 1980. URL: http://hdl.handle.net/1813/6273.
- Okasaki C. Simple and Efficient Purely Functional Queues and Deques // Journal of Functional Programming. — 1995. — Vol. 5, № 4; и Okasaki C. Purely Functional Data Structures. — Cambridge University Press, 1998.
- Tarjan R. E. Amortized Computational Complexity // SIAM J. Algebraic and Discrete Methods. — 1985. — Vol. 6, № 2.
- Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. Introduction to Algorithms (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 10 (элементарные структуры: стек, очередь).
- Хабрахабр. Персистентная очередь (пост 241231). URL: https://habr.com/ru/post/241231/ (проверено 2026-11-03: статья о персистентной очереди, другой алгоритм — на деревьях).