0Pricing
Coding Interview Prep · Урок

Взаимная имитация стека и очереди

Реализуйте очередь с помощью двух стеков и стек с помощью двух очередей, объясняя амортизированную стоимость каждого подхода

«Взаимная имитация стека и очереди» — бесплатный урок 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()) # False

LeetCode 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 — локальная установка не требуется.

Все уроки этого курса

  1. Реализация стека и его применение
  2. Реализация очереди и дека
  3. Приём с монотонным стеком
  4. Взаимная имитация стека и очереди
← Назад к Coding Interview Prep