Взаимная имитация стека и очереди
Реализуйте очередь с помощью двух стеков и стек с помощью двух очередей, объясняя амортизированную стоимость каждого подхода
«Взаимная имитация стека и очереди» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.
Зачем имитировать одну структуру с помощью другой
Реализация очереди с помощью двух стеков и стека с помощью двух очередей — классические задачи на проектирование на собеседованиях. Они проверяют понимание инвариантных условий обеих структур и способность сохранять гарантию одной структуры, используя базовые операции другой. Кроме того, с их помощью часто переходят к обсуждению амортизированной сложности.
Главная идея такова: стеки работают по принципу LIFO, а очереди — по принципу FIFO. Чтобы преобразовать одну структуру в другую, необходимо изменить порядок элементов, а перенос элементов из одного стека в другой восстанавливает исходный порядок добавления, то есть порядок FIFO.
Очередь с помощью двух стеков (ленивый подход)
Ленивый подход: используйте стек inbox для операций добавления и стек outbox для операций извлечения. Когда требуется извлечь элемент из очереди, проверьте, пуст ли outbox. Если он пуст, перенесите все элементы из inbox в outbox — это изменение порядка восстанавливает порядок FIFO. Если outbox не пуст, извлеките элемент непосредственно из него. Перенос выполняется лениво, поэтому стоимость операции за O(n) амортизируется на множестве операций.
class MyQueue:
def __init__(self):
self.inbox = []
self.outbox = []
def push(self, x):
self.inbox.append(x)
def _transfer(self):
if not self.outbox:
while self.inbox:
self.outbox.append(self.inbox.pop())
def pop(self):
self._transfer()
return self.outbox.pop()
def peek(self):
self._transfer()
return self.outbox[-1]
def empty(self):
return not self.inbox and not self.outbox
q = MyQueue()
q.push(1); q.push(2); q.push(3)
print(q.peek()) # 1
print(q.pop()) # 1
print(q.pop()) # 2
q.push(4)
print(q.pop()) # 3Амортизированный анализ O(1) для очереди из стеков
Каждый элемент переносится из inbox в outbox не более одного раза. Извлечение из outbox выполняется за O(1), а переносы происходят только тогда, когда outbox пуст. Поэтому общее количество операций для n добавлений и n извлечений составляет не более 2n операций со стеком — всего O(n), то есть амортизированно O(1) на операцию. Это означает, что в худшем случае отдельные операции могут занимать O(n), но в среднем стоимость операции равна O(1).
# Trace transfer costs for 10 push/pop interleaved
class TrackedQueue:
def __init__(self):
self.inbox = []; self.outbox = []; self.transfers = 0
def push(self, x): self.inbox.append(x)
def pop(self):
if not self.outbox:
while self.inbox:
self.outbox.append(self.inbox.pop())
self.transfers += 1
return self.outbox.pop()
q = TrackedQueue()
for i in range(5):
q.push(i)
for _ in range(5):
q.pop()
q.push(10); q.push(20)
q.pop()
print('Total transfer operations:', q.transfers) # at most nСтек с помощью двух очередей (ленивое извлечение)
Реализовать стек с помощью двух очередей сложнее, поскольку очереди работают по принципу FIFO. При ленивом извлечении используются одна основная и одна временная очередь. При выполнении push добавьте элемент в основную очередь (O(1)). При выполнении pop или peek извлеките все элементы, кроме последнего, во временную очередь, сохраните последний элемент, а затем поменяйте очереди местами. Это занимает O(n) на каждое извлечение, но O(1) на каждое добавление.
from collections import deque
class MyStack:
def __init__(self):
self.main = deque()
self.temp = deque()
def push(self, x):
self.main.append(x) # O(1)
def pop(self):
# Move all but last element to temp
while len(self.main) > 1:
self.temp.append(self.main.popleft())
val = self.main.popleft() # the 'top'
self.main, self.temp = self.temp, self.main # swap
return val
def top(self):
while len(self.main) > 1:
self.temp.append(self.main.popleft())
val = self.main[0]
self.temp.append(self.main.popleft())
self.main, self.temp = self.temp, self.main
return val
def empty(self):
return len(self.main) == 0
s = MyStack()
s.push(1); s.push(2); s.push(3)
print(s.top()) # 3
print(s.pop()) # 3
print(s.pop()) # 2Стек с помощью одной очереди (поворот при добавлении)
Элегантная реализация с одной очередью: при выполнении push добавьте новый элемент, а затем перестройте очередь так, чтобы новый элемент оказался в начале. Для этого извлеките и снова добавьте в очередь все элементы, которые находились в ней до операции добавления. После этого операции pop и peek выполняются за O(1) (достаточно извлечь элемент или посмотреть на первый). Операция push занимает O(n) — это противоположный компромисс по сравнению с вариантом с двумя очередями.
from collections import deque
class MyStackOneQueue:
def __init__(self):
self.q = deque()
def push(self, x):
self.q.append(x)
# Rotate: move all preceding elements behind x
for _ in range(len(self.q) - 1):
self.q.append(self.q.popleft())
def pop(self):
return self.q.popleft()
def top(self):
return self.q[0]
def empty(self):
return len(self.q) == 0
s = MyStackOneQueue()
s.push(1); s.push(2); s.push(3)
print(s.top()) # 3
print(s.pop()) # 3
print(s.top()) # 2Итоги компромиссов: какой вариант выбрать
Для очереди из двух стеков: добавление за O(1), извлечение и peek — амортизированно за O(1); выбирайте этот вариант, когда операции извлечения выполняются часто. Для стека из двух очередей: добавление за O(1), извлечение за O(n); выбирайте его, когда добавления выполняются намного чаще извлечений. Для стека из одной очереди: добавление за O(n), извлечение за O(1); выбирайте его, когда преобладают операции извлечения. На собеседовании явно проговаривайте эти компромиссы, чтобы показать, что вы думаете не только о том, «работает ли решение».
print('Queue from 2 stacks: push O(1), pop O(1) amortised')
print('Stack from 2 queues: push O(1), pop O(n)')
print('Stack from 1 queue: push O(n), pop O(1)')Почему изменение порядка восстанавливает FIFO
Когда элементы 1, 2, 3 добавляются в стек (inbox), снизу вверх они располагаются в порядке 1, 2, 3. Если извлечь их все и поместить во второй стек (outbox), порядок изменится на обратный: в outbox снизу будет 3, а сверху — 1. Извлечение из outbox вернёт сначала 1, затем 2, затем 3 — в точности исходный порядок добавления FIFO. Поэтому именно два изменения порядка (два стека) восстанавливают FIFO, тогда как один стек даёт LIFO.
# Demonstrate double-reversal = FIFO
inbox = [1, 2, 3] # pushed in this order
outbox = []
while inbox:
outbox.append(inbox.pop())
print('outbox (one reversal):', outbox) # [3, 2, 1] top-to-bottom
# Pop from outbox gives FIFO
result = []
while outbox:
result.append(outbox.pop())
print('dequeued:', result) # [1, 2, 3] — FIFO!LeetCode 232: реализация очереди с помощью стеков
LeetCode 232 — это классическая задача на реализацию очереди с помощью двух стеков. Ожидаемое решение использует ленивый перенос из входного стека в выходной. На собеседовании скажите, что каждый элемент перемещается из одного стека в другой не более одного раза, поэтому все операции имеют амортизированную сложность O(1). Упомяните, что отдельный вызов pop в худшем случае может занимать O(n), когда выходной стек пуст, но средняя сложность за n операций равна O(1).
class MyQueue:
def __init__(self):
self.inbox = []
self.outbox = []
def push(self, x):
self.inbox.append(x)
def pop(self):
self.peek() # ensure outbox is populated
return self.outbox.pop()
def peek(self):
if not self.outbox:
while self.inbox: # transfer lazily
self.outbox.append(self.inbox.pop())
return self.outbox[-1]
def empty(self):
return not self.inbox and not self.outbox
# Simulation
q = MyQueue()
q.push(1); q.push(2)
print(q.peek()) # 1
print(q.pop()) # 1
print(q.empty()) # FalseLeetCode 225: реализация стека с помощью очередей
LeetCode 225 — это задача на реализацию стека с помощью очередей. Самым ясным является решение с одной очередью и поворотом при добавлении. После добавления элемента x поверните очередь, переместив все элементы, которые уже находились в ней, за x. Это занимает O(n) на каждое добавление, но делает операции top и pop выполняемыми за O(1). Обозначьте этот компромисс и подтвердите, что он соответствует ограничениям, например нагрузке с редкими push или частыми pop.
from collections import deque
class MyStack:
def __init__(self):
self.q = deque()
def push(self, x): # O(n)
self.q.append(x)
for _ in range(len(self.q) - 1):
self.q.append(self.q.popleft())
def pop(self): # O(1)
return self.q.popleft()
def top(self): # O(1)
return self.q[0]
def empty(self):
return len(self.q) == 0
s = MyStack()
s.push(1); s.push(2); s.push(3)
print(s.top()) # 3
print(s.pop()) # 3
print(s.top()) # 2
print(s.empty()) # FalseРасширение до трёх стеков в одном массиве
Связанная задача проектирования: реализуйте три стека с использованием одного массива. Один подход делит массив на три равные фиксированные области. Более гибкий подход использует чередующееся хранение с указателями: каждый стек растёт в своей области, а при столкновении границ данные копируются. Это проверяет навыки управления динамическими массивами и встречается на собеседованиях на позицию старшего разработчика. Подход с фиксированными областями проще, но тратит место впустую, если стеки растут неравномерно.
class ThreeStacks:
def __init__(self, size):
self.data = [0] * (3 * size)
self.tops = [-1, -1, -1] # relative top of each stack
self.size = size
def push(self, stack_num, val):
self.tops[stack_num] += 1
if self.tops[stack_num] >= self.size:
raise OverflowError('stack full')
self.data[stack_num * self.size + self.tops[stack_num]] = val
def pop(self, stack_num):
if self.tops[stack_num] < 0:
raise IndexError('stack empty')
val = self.data[stack_num * self.size + self.tops[stack_num]]
self.tops[stack_num] -= 1
return val
ts = ThreeStacks(5)
ts.push(0, 10); ts.push(1, 20); ts.push(2, 30)
print(ts.pop(0), ts.pop(1), ts.pop(2)) # 10 20 30Главные выводы: шаблоны имитации
Задачи на взаимную имитацию учат более общему принципу: любую структуру данных можно построить на основе другой, если использовать достаточный промежуточный буфер и разворот. Стоимость имитации зависит от того, какие операции Вы оптимизируете: всегда можно сделать добавление в стек за O(1) или извлечение из стека за O(1), но для выполнения обеих операций за O(1) нужна амортизация или несколько вспомогательных структур.
На собеседовании всегда спрашивайте: «Какие операции выполняются чаще?» Это помогает выбрать вариант реализации и показывает умение мыслить на уровне требований к операциям.
Быстрая проверка
Проверьте, насколько Вы поняли концепции курса «Структуры данных и алгоритмы — подготовка к собеседованию по программированию» из этого урока.
Итоги урока
В этом уроке Вы узнали: очередь из двух стеков обеспечивает амортизированное извлечение за O(1), лениво перенося элементы из входного стека в выходной, стек из одной очереди обеспечивает извлечение за O(1), поворачивая очередь при каждом добавлении (добавление за O(n)), и выбор операции, для которой следует обеспечить O(1), зависит от характера использования. Далее мы рассмотрим внутреннее устройство хеш-таблиц и обработку коллизий.
Часто задаваемые вопросы
Урок «Взаимная имитация стека и очереди» бесплатный?
Да — полный текст урока «Взаимная имитация стека и очереди» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Взаимная имитация стека и очереди»?
Реализуйте очередь с помощью двух стеков и стек с помощью двух очередей, объясняя амортизированную стоимость каждого подхода Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать Coding Interview Prep?
Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.
Сколько времени занимает урок «Взаимная имитация стека и очереди»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке Coding Interview Prep?
Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Реализация стека и его применение
- Реализация очереди и дека
- Приём с монотонным стеком
- Взаимная имитация стека и очереди