Перейти до основного вмісту

Стек з мінімумом / черга з мінімумом

У цій статті ми розглянемо три задачі: спершу ми модифікуємо стек так, щоб можна було знаходити найменший елемент стеку за O(1)O(1), потім зробимо те саме з чергою, і нарешті скористаємося цими структурами даних, щоб знаходити мінімум на всіх підмасивах фіксованої довжини в масиві за O(n)O(n).

Коли підходить цей алгоритм?
  • Чи потрібен мінімум (або максимум) на ковзному вікні фіксованої довжини за сумарний час O(n)O(n)? (якщо вікно довільне й змінюється → дерево відрізків або розріджена таблиця)
  • Чи доступ до елементів відбувається лише з кінців структури за принципом стеку (LIFO) або черги (FIFO)?
  • Чи достатньо однієї агрегувальної функції (min\min/max\max), яку можна підтримувати інкрементно при додаванні/видаленні з краю?

Модифікація стеку

Ми хочемо модифікувати структуру даних «стек» так, щоб можна було знаходити найменший елемент у стеку за час O(1)O(1), зберігаючи при цьому ту саму асимптотику для додавання та видалення елементів зі стеку. Коротке нагадування: у стеку ми додаємо й видаляємо елементи лише з одного кінця.

Для цього ми зберігатимемо у стеку не лише самі елементи, а зберігатимемо їх парами: сам елемент і мінімум у стеку, починаючи з цього елемента й нижче.

stack<pair<int, int>> st;

Зрозуміло, що знаходження мінімуму в усьому стеку зводиться лише до перегляду значення stack.top().second.

Так само очевидно, що додавання чи видалення нового елемента зі стеку можна виконати за сталий час.

Реалізація:

  • Додавання елемента:
int new_min = st.empty() ? new_elem : min(new_elem, st.top().second);
st.push({new_elem, new_min});
  • Видалення елемента:
int removed_element = st.top().first;
st.pop();
  • Знаходження мінімуму:
int minimum = st.top().second;

Модифікація черги (спосіб 1)

Тепер ми хочемо досягти тих самих операцій із чергою, тобто ми хочемо додавати елементи в кінець і видаляти їх із початку.

Тут ми розглянемо простий спосіб модифікації черги. Однак у нього є велика вада, бо модифікована черга насправді не зберігатиме всіх елементів.

Ключова ідея — зберігати в черзі лише ті елементи, які потрібні для визначення мінімуму. А саме, ми триматимемо чергу в неспадному порядку (тобто найменше значення зберігатиметься на початку) і, звісно, не довільним чином, а так, щоб фактичний мінімум завжди містився в черзі. У такий спосіб найменший елемент завжди буде на початку черги. Перед додаванням нового елемента до черги достатньо зробити «зріз»: ми видалимо всі хвостові елементи черги, які більші за новий елемент, а потім додамо новий елемент до черги. Так ми не порушуємо порядку черги, а також не втрачаємо поточний елемент, якщо на якомусь наступному кроці він виявиться мінімумом. Усі елементи, які ми видалили, ніколи не зможуть самі бути мінімумом, тому така операція дозволена. Коли ми хочемо витягнути елемент з початку, його там насправді може й не бути (бо ми видалили його раніше при додаванні меншого елемента). Тому при видаленні елемента з черги нам потрібно знати значення цього елемента. Якщо на початку черги стоїть елемент із таким самим значенням, ми можемо безпечно його видалити, інакше нічого не робимо.

Розгляньмо реалізації наведених вище операцій:

deque<int> q;

Зверніть увагу: у Python collections.deque дає O(1)O(1) для обох кінців, а от у TypeScript та Go вбудованої деки немає — щоб уникнути O(n)O(n)-зсуву масиву при видаленні з фронту, ми тримаємо окремий індекс head на початок «живої» частини.

  • Знаходження мінімуму:
int minimum = q.front();
  • Додавання елемента:
while (!q.empty() && q.back() > new_element)
q.pop_back();
q.push_back(new_element);
  • Видалення елемента:
if (!q.empty() && q.front() == remove_element)
q.pop_front();

Зрозуміло, що в середньому всі ці операції займають лише O(1)O(1) часу (бо кожен елемент може бути доданий і вилучений лише один раз).

Модифікація черги (спосіб 2)

Це модифікація способу 1. Ми хочемо мати змогу видаляти елементи, не знаючи, який саме елемент маємо видалити. Цього можна досягти, зберігаючи в черзі індекс для кожного елемента. А ще ми пам'ятаємо, скільки елементів уже додали та видалили.

deque<pair<int, int>> q;
int cnt_added = 0;
int cnt_removed = 0;
  • Знаходження мінімуму:
int minimum = q.front().first;
  • Додавання елемента:
while (!q.empty() && q.back().first > new_element)
q.pop_back();
q.push_back({new_element, cnt_added});
cnt_added++;
  • Видалення елемента:
if (!q.empty() && q.front().second == cnt_removed)
q.pop_front();
cnt_removed++;

Модифікація черги (спосіб 3)

Тут ми розглянемо ще один спосіб модифікувати чергу, щоб знаходити мінімум за O(1)O(1). Цей спосіб дещо складніший у реалізації, але цього разу ми насправді зберігаємо всі елементи. А ще ми можемо видаляти елемент із початку, не знаючи його значення.

Ідея полягає в тому, щоб звести задачу до задачі про стеки, яку ми вже розв'язали. Отже, нам потрібно лише навчитися моделювати чергу за допомогою двох стеків.

Ми створюємо два стеки, s1 і s2. Звісно, ці стеки будуть у модифікованій формі, щоб ми могли знаходити мінімум за O(1)O(1). Нові елементи ми додаватимемо до стеку s1, а видалятимемо елементи зі стеку s2. Якщо в якийсь момент стек s2 порожній, ми переносимо всі елементи з s1 до s2 (що по суті змінює порядок цих елементів на протилежний). Нарешті, знаходження мінімуму в черзі зводиться просто до знаходження мінімуму обох стеків.

Таким чином, ми виконуємо всі операції в середньому за O(1)O(1) (кожен елемент буде один раз доданий до стеку s1, один раз перенесений до s2 й один раз вилучений із s2).

Реалізація:

stack<pair<int, int>> s1, s2;
  • Знаходження мінімуму:
if (s1.empty() || s2.empty())
minimum = s1.empty() ? s2.top().second : s1.top().second;
else
minimum = min(s1.top().second, s2.top().second);
  • Додавання елемента:
int minimum = s1.empty() ? new_element : min(new_element, s1.top().second);
s1.push({new_element, minimum});
  • Видалення елемента:
if (s2.empty()) {
while (!s1.empty()) {
int element = s1.top().first;
s1.pop();
int minimum = s2.empty() ? element : min(element, s2.top().second);
s2.push({element, minimum});
}
}
int remove_element = s2.top().first;
s2.pop();

Знаходження мінімуму на всіх підмасивах фіксованої довжини

Припустимо, нам дано масив AA довжини NN і задане MNM \le N. Нам потрібно знайти мінімум кожного підмасиву довжини MM у цьому масиві, тобто нам потрібно знайти:

min0iM1A[i],min1iMA[i],min2iM+1A[i],  ,minNMiN1A[i]\min_{0 \le i \le M-1} A[i], \min_{1 \le i \le M} A[i], \min_{2 \le i \le M+1} A[i],~\dots~, \min_{N-M \le i \le N-1} A[i]

Нам потрібно розв'язати цю задачу за лінійний час, тобто O(n)O(n).

Для розв'язання задачі ми можемо скористатися будь-якою з трьох модифікованих черг. Розв'язки мають бути зрозумілими: ми додаємо перші MM елементів масиву, знаходимо й виводимо їхній мінімум, потім додаємо до черги наступний елемент і видаляємо перший елемент масиву, знаходимо й виводимо мінімум і так далі. Оскільки всі операції з чергою виконуються в середньому за сталий час, складність усього алгоритму буде O(n)O(n).

Задачі для практики

Відеоматеріали