Алгоритмы с нуля
Стек
В прошлом уроке ты выучил методику двух указателей на связном списке. Сегодня ты выучишь про стек — простую и мощную структуру данных. Стек это как стопка тарелок: ты добавляешь тарелки сверху и удаляешь тарелки сверху. Последняя добавленная тарелка это первая что снимается. В программировании, ты используешь стеки для истории undo/redo (каждое действие push-ит новое состояние на стек), парсинга выражений (сопоставление скобок и операторов) и везде где самое свежее это самое важное. Стек налагает одно правило: LIFO (последний-вошедший-первый-вышедший).
После этого урока ты можешь объяснить дисциплину LIFO и три основные операции стека (push, pop, peek), реализовать стек используя массив или связный список, объяснить почему все операции стека выполняются за O(1) время, решать задачи сопоставления скобок и валидации выражений используя стек, и узнать когда стек это правильная структура данных для задачи.
Правило LIFO:
Стек это линейная структура данных где все вставки и удаления происходят на одном конце, называемом верхом. Правило это LIFO (последний вошедший, первый вышедший): элемент добавленный совсем недавно это тот что удаляется первым.
Представь стопку обеденных тарелок в шкафу. Когда ты хватаешь тарелку, ты берёшь сверху (последнюю положенную там). Когда ты кладёшь тарелку назад, она идёт сверху. Ты никогда не хватаешь из середины или снизу. Это LIFO.
Три операции:
- Push: Добавь элемент на верх стека. Возвращает ничего; обновляет стек на месте.
- Pop: Удали и верни верхний элемент стека. Если стек пуст, или верни null или подними ошибку (зависит от реализации).
- Peek (или top): Верни верхний элемент без его удаления. Позволяет тебе увидеть что там перед тем как ты обязуешься pop-нуть.
const stack = []; // Пустой стек
// Push: добавь 10, 20, 30
stack.push(10); // Стек: [10]
stack.push(20); // Стек: [10, 20]
stack.push(30); // Стек: [10, 20, 30]
// Peek: увидь верх
console.log(stack[stack.length - 1]); // 30 (без удаления)
// Pop: удали верх
const top = stack.pop(); // Возвращает 30. Стек: [10, 20]Реализация с массивом:
Массив естественно моделирует стек. Конец массива это верх. Push добавляет в конец; pop удаляет из конца. Оба выполняются за O(1) амортизированное время (в JavaScript язык скрывает resize массива).
class Stack {
constructor() {
this.items = [];
}
push(value) {
this.items.push(value);
}
pop() {
if (this.items.length === 0) return null;
return this.items.pop();
}
peek() {
if (this.items.length === 0) return null;
return this.items[this.items.length - 1];
}
isEmpty() {
return this.items.length === 0;
}
}Реализация со связным списком:
Стек может быть также подкреплён связным списком. Push и pop оба работают на голове (нет traverse нужно). Это также O(1).
class Node {
constructor(value, next = null) {
this.value = value;
this.next = next;
}
}
class StackLinkedList {
constructor() {
this.top = null; // Head pointer
}
push(value) {
const newNode = new Node(value, this.top);
this.top = newNode;
}
pop() {
if (this.top === null) return null;
const value = this.top.value;
this.top = this.top.next;
return value;
}
peek() {
if (this.top === null) return null;
return this.top.value;
}
isEmpty() {
return this.top === null;
}
}Подробный пример: сбалансированные скобки
Одно из самых частых использований стеков это проверка сбалансированы ли скобки, круглые скобки и фигурные скобки в выражении.
Задача: Дана строка со знаками (, ), [, ], {, }, проверь сбалансирована ли каждая открывающая скобка с соответствующей закрывающей скобкой в правильном порядке.
Пример: "({[]})" сбалансирована. "({[}])" не сбалансирована (закрывающая } закрывает неправильную скобку).
Алгоритм: Обработай строку слева направо.
- Если ты видишь открывающую скобку (
(,[,{), push её на стек. - Если ты видишь закрывающую скобку (
),],}), pop стек и проверь совпадает ли. Если не совпадает, возврати false. Если стек пуст, возврати false (нет открывающей скобки для сопоставления). - В конце, стек должен быть пуст. Если нет, есть незакрытые открывающие скобки.
Пример trace: "{[]}"
- Видишь
{: push{. Стек:[{] - Видишь
[: push[. Стек:[{, [] - Видишь
]: pop[. Совпадает ли[с]? Да. Стек:[{] - Видишь
}: pop{. Совпадает ли{с}? Да. Стек:[] - Конец: стек пуст. Сбалансирована!
Пример trace: "{[}]"
- Видишь
{: push{. Стек:[{] - Видишь
[: push[. Стек:[{, [] - Видишь
}: pop[. Совпадает ли[с}? Нет. Возврати false. Не сбалансирована.
Стек подкреплённый массивом:
class Stack {
constructor() {
this.items = [];
}
push(value) {
this.items.push(value);
}
pop() {
if (this.items.length === 0) return null;
return this.items.pop();
}
peek() {
if (this.items.length === 0) return null;
return this.items[this.items.length - 1];
}
isEmpty() {
return this.items.length === 0;
}
size() {
return this.items.length;
}
}Проверка сбалансированных скобок:
function isBalanced(s) {
const stack = new Stack();
const pairs = { ')': '(', ']': '[', '}': '{' };
for (let char of s) {
if (char === '(' || char === '[' || char === '{') {
stack.push(char);
} else if (char === ')' || char === ']' || char === '}') {
const top = stack.pop();
if (top !== pairs[char]) {
return false;
}
}
}
return stack.isEmpty();
}Попробуй обе в интерактивном runner:
Давай проследим проверку сбалансирована ли "({[]})".
1
for (let char of s) {
2
if (char === '(' || char === '[' || char === '{') {
3
stack.push(char);
4
} else if (char === ')' || char === ']' || char === '}') {
5
const top = stack.pop();
6
if (top !== pairs[char]) return false;
7
}
8
}
9
return stack.isEmpty();
Операции стека:
| Операция | Время | Пространство |
|---|---|---|
| Push | O(1) | O(1) |
| Pop | O(1) | O(1) |
| Peek | O(1) | O(1) |
| isEmpty | O(1) | O(1) |
Все операции выполняются за O(1) постоянное время потому что они только трогают верх стека. Нет traverse нужно; нет цикла что итерирует над структурой.
Реализация массивом vs связным списком:
Обе имеют O(1) push и pop. Разница в раскладке памяти:
- Массив: Cache-дружественный, но resize (когда массив растёт) это O(n) и редкий.
- Связный список: Нет resize, но каждый узел стоит дополнительную память для указателя.
Для большинства задач, массив проще. Используй связный список если память scarce или если resize неприемлемый.
Проверка сбалансированных скобок:
- Время: O(n), где n это длина строки. Каждый символ обработан один раз.
- Пространство: O(n) в худшем случае (все открывающие скобки), но обычно намного меньше.
После push 10, 20, и 30 на стек (в том порядке), что возвращает pop?
Какая операция позволяет тебе проверить что на верху стека без его удаления?
Сбалансирована ли строка '([)]'?
Для алгоритма проверки сбалансированных скобок, что это временная сложность?
Какая реализация стека менее память-эффективна потому что каждый узел хранит указатель?
Граничные случаи
Операции на пустом стеке: Когда стек пуст и ты пытаешься pop или peek, что должно произойти? Разные языки отличаются: некоторые возвращают null, некоторые подымают ошибку. В JavaScript, Array.pop() возвращает undefined. Когда реализуешь стек, ты должен решить заранее: возврати null/undefined для пустого pop, или подними ошибку? Для задачи сопоставления скобок, мы возвращаем null и проверяем это перед используя значение.
Частая ошибка
«Стек это просто массив.» Нет. Стек это абстрактная дисциплина (LIFO с только верхним доступом), не структура данных. Массив это структура данных (случайный доступ, индексирование). Ты можешь реализовать стек используя массив, но стек скрывает способность массива доступить середину или дно. Не думай что они одно и то же — стек налагает правила.
Ещё практика
Последующее: Реально мировое использование стеков это undo/redo в text editors. Каждое действие push состояние на undo стек. Когда ты нажимаешь Ctrl+Z, pop из undo стека и восстанови предыдущее состояние, пока push текущее состояние на redo стек. Это красивое использование LIFO.
Почему стек это идеальная структура данных для проверки сбалансированных скобок вместо, скажи, очередь или массив?
Стек: LIFO и три операции
-
LIFO правило: Последний элемент push-ленный это первый что pop-нут. Думай о стопке тарелок.
-
Три операции: push (добавь на верх, O(1)), pop (удали с верха, O(1)), peek (видь верх без удаления, O(1)). Все постоянное время.
-
Две реализации: Массив (проще, cache-дружественный, редкий O(n) resize) или связный список (нет resize, дополнительный указатель на узел).
-
Проверка сбалансированных скобок: Обработай слева направо. Push открывающие скобки, pop и сопоставь закрывающие скобки. Если стек пуст в конце, все скобки сбалансированы. O(n) время, O(n) пространство.
-
Реально мировые использования: Undo/redo, парсинг выражений, стек вызовов функций, traverse поиск в глубину.
-
Ключевое понимание: Стек это абстрактная дисциплина, не структура данных. Ты можешь реализовать его с массивом или связным списком — оба дают O(1) операции.
Далее, ты выучишь очереди — противоположность стеков. Очередь налагает FIFO (первый вошедший, первый вышедший). Ты enqueue на спине и dequeue на передней. Очереди моделируют очередь ожидания, поиск в ширину, и любую ситуацию где справедливость (первый пришедший, первый обслужен) имеет значение.