Files
eduplan-cpp-eltex/lessons/D3_algo.md
T

27 KiB
Raw Blame History

D3, часть 1. Списки, стек, очередь, монотонный стек (урок)

Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах можно копировать и запускать — это образец, а не готовый ответ на задачу.

1. Односвязный и двусвязный список: сложности

struct Node { int value; Node* next; };          // односвязный
struct DNode { int value; DNode* prev, *next; };  // двусвязный

Вставка и удаление узла, когда указатель на нужное место уже есть, — O(1): операция это перелинковка 2–3 указателей соседей, без сдвига остальных элементов. Поиск по значению или по номеру — всегда O(n): у списка нет арифметики адреса, только последовательный проход по next от головы.

Двусвязный список хранит ещё и prev, поэтому удаление узла по указателю на него самого не требует отдельно знать предыдущий узел — это цена дополнительного указателя (8 байт на 64-бит системе) в каждом узле. Узлы списка разбросаны по куче отдельными аллокациями — отсюда плохая локальность памяти и частые промахи кэша процессора по сравнению с массивом, где элементы лежат подряд.

Факты для карточек

  • base | Сложность вставки в список, если указатель на место уже есть? — O(1)
  • base | Сложность поиска элемента в связном списке? — O(n)
  • core | Почему список медленнее массива на практике, хотя вставка O(1) на бумаге? — узлы разбросаны по куче, промахи кэша при обходе
  • core | Чем двусвязный список платит за удаление по указателю на сам узел без знания предыдущего? — лишним указателем prev в каждом узле

Почему дальше: раз вставка/удаление — это перелинковка указателей, логично разобрать классическую операцию на этих указателях — разворот списка.

2. Разворот списка тремя указателями

Node* reverse_list(Node* head) {
    Node* prev = nullptr;
    Node* curr = head;
    while (curr) {
        Node* next = curr->next;   // сохранить хвост ДО перезаписи
        curr->next = prev;
        prev = curr;
        curr = next;
    }
    return prev;                   // новая голова
}

Три указателя нужны потому, что curr->next перезаписывается на prev, а без сохранённого next связь с остальной частью списка потеряется безвозвратно. Один проход, O(n) по времени, O(1) дополнительной памяти — ни одной аллокации, только перестановка указателей.

Ловушки

  • Забыть сохранить next до перезаписи curr->next → потеря хвоста списка → утечка памяти, видна по ASAN/LeakSanitizer.
  • Вернуть curr вместо prev как новую голову → curr в конце цикла равен nullptr, функция вернёт пустой список.

Факты для карточек

  • base | Сложность разворота списка тремя указателями? — O(n) по времени, O(1) по памяти
  • core | Зачем нужен третий указатель next? — сохранить связь с остатком списка до перезаписи curr->next

Почему дальше: раз можно пройти список одним указателем, следующий вопрос — как найти элемент на заданном расстоянии от конца за один проход, не зная длины заранее.

3. k-й элемент с конца

Идея: два указателя с фиксированным сдвигом в k узлов. Сначала продвигаем «ведущий» указатель на k шагов вперёд от головы, затем двигаем оба указателя одновременно по одному шагу, пока ведущий не дойдёт до конца — «отстающий» указатель в этот момент стоит на k-м узле с конца.

Один проход, O(n) по времени (ведущий указатель проходит список один раз, с задержкой в k шагов для отстающего), O(1) дополнительной памяти — длину списка знать заранее не нужно.

Факты для карточек

  • base | Сколько проходов по списку нужно, чтобы найти k-й элемент с конца без знания длины? — один
  • core | На сколько шагов вперёд продвигают ведущий указатель перед стартом совместного движения? — на k шагов

Почему дальше: тот же приём с двумя указателями разной скорости, а не разного стартового сдвига, даёт середину списка и обнаружение цикла — логично разобрать оба.

4. Середина списка: find_middle через slow/fast

Node* find_middle(Node* head) {
    Node* slow = head;
    Node* fast = head;
    while (fast && fast->next) {   // fast не может продвинуться на 2 — стоп
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;                   // для чётной длины — второй из двух средних
}

fast движется на 2 узла за шаг, slow — на 1: когда fast доходит до конца, slow прошёл ровно половину пути. Для списка из 4 узлов (1→2→3→4) цикл даёт slow на узле 3 — это второй из двух средних (2 и 3), что совпадает с требованием задачи 02_list. Условие цикла fast && fast->next — единственно верное: без проверки fast->next обращение fast->next->next на последнем узле разыменует nullptr.

Факты для карточек

  • base | На сколько узлов за шаг двигается fast в поиске середины? — на 2, slow — на 1
  • core | Что вернёт find_middle для списка из 4 узлов (1→2→3→4)? — узел 3 (второй из двух средних)
  • deep | Почему условие цикла — fast && fast->next, а не только fast? — без fast->next обращение fast->next->next на последнем узле разыменует nullptr

Почему дальше: та же пара slow/fast, если список зацикленный, не выходит из цикла вовсе — это и есть способ обнаружить цикл в списке.

5. Цикл Флойда: обнаружение и вход в цикл

bool has_cycle(Node* head) {
    Node* slow = head;
    Node* fast = head;
    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) return true;   // встретились внутри цикла
    }
    return false;
}

Почему указатели вообще встречаются: если в списке есть цикл, fast внутри цикла обгоняет slow с относительной скоростью 1 узел за шаг (двигаясь на 2, а slow — на 1), поэтому расстояние между ними в цикле сокращается на 1 каждый шаг и обязательно дойдёт до 0 — не позже чем за c шагов, где c — длина цикла. Без цикла fast просто первым дойдёт до nullptr и цикл остановится по условию fast && fast->next.

Поиск входа в цикл — отдельный шаг после обнаружения встречи. Пусть a — расстояние от головы до входа в цикл, b — от входа до точки встречи, c — длина цикла. В момент встречи slow прошёл a+b, а fast — вдвое больше шагов, чем slow, то есть 2(a+b) = a+b + n·c для какого-то целого n (лишние n полных обхода цикла). Отсюда a = (n-1)·c + (c-b) — то есть путь длины a от головы совпадает по конечной точке с путём длины c-b от точки встречи (остаток цикла до входа). Практический вывод: после обнаружения встречи ставим один указатель обратно на голову, второй оставляем в точке встречи, двигаем оба по одному узлу за шаг — они встретятся ровно на входе в цикл.

Факты для карточек

  • base | С какой относительной скоростью fast догоняет slow внутри цикла? — 1 узел за шаг
  • core | Сколько указателей нужно сбросить в голову списка, чтобы найти вход в цикл после первой встречи? — один, второй остаётся в точке встречи, оба идут дальше по 1 шагу
  • deep | Почему после первой встречи путь от головы длиной a и путь от точки встречи длиной c-b сходятся в одной точке? — из равенства 2(a+b) = a+b+n·c, откуда a = (n-1)c + (c-b)

Ловушки

  • Сравнивать значения узлов вместо указателей (slow->value == fast->value) → ложное срабатывание на списке с повторяющимися значениями без реального цикла.
  • Забыть проверку fast && fast->next перед fast->next->next → падение по nullptr на списке без цикла нечётной/чётной длины на границе.

Почему дальше: и разворот, и середина, и обнаружение цикла работают без единой аллокации — логично разобрать приём, который избавляет от лишних проверок на границах списка ещё до самого алгоритма — dummy-узел.

6. Зачем dummy-узел

Dummy (sentinel) — фиктивный узел перед настоящей головой списка, который никогда не несёт полезных данных. Механизм: без dummy операции вставки/удаления в начало списка требуют отдельной ветки кода («если это голова — обнови указатель head отдельно»), а с dummy у любого настоящего узла всегда есть предшественник, и вставка/удаление в начало становится тем же кодом, что и вставка/удаление в середину — специальный случай исчезает.

Типичное применение — задачи слияния двух списков и удаления узлов по условию: результат собирают, привязывая новые узлы к dummy->next, а в конце возвращают dummy->next как настоящую голову.

Факты для карточек

  • base | Что решает dummy-узел? — убирает отдельную ветку кода для вставки/удаления в начало списка
  • core | Что возвращают в конце вместо dummy? — dummy->next как настоящую голову результата

Почему дальше: список — это структура с O(1) вставкой по указателю, но что если нужен доступ только с одного (или двух) концов — это уже стек и очередь.

7. Стек и очередь на массиве; очередь на двух стеках

Стек на массиве — индекс вершины top плюс сам массив: push пишет по top и увеличивает индекс, pop уменьшает индекс и отдаёт значение — O(1), без аллокаций, LIFO (last in, first out).

Очередь на массиве наивно требует сдвига элементов при каждом pop с начала — O(n). Решение без сдвигов — два индекса head/tail с заворотом по модулю ёмкости (разбор в следующем разделе, кольцевой буфер).

Альтернативная конструкция без ёмкости заранее — очередь на двух стеках (in, out): push всегда кладёт в in — O(1). pop: если out пуст, целиком переливаем in в out (это разворачивает порядок — самый старый элемент in окажется на вершине out), затем берём с вершины out; если out не пуст — берём сразу. Каждый элемент физически перекладывается из in в out не более одного раза за всё время своей жизни в очереди, поэтому суммарная стоимость n операций — O(n), то есть амортизированная O(1) на операцию, хотя отдельный вызов pop с переливом стоит O(n).

Факты для карточек

  • base | Сложность push/pop у стека на массиве? — O(1)
  • core | Сколько раз за свою жизнь в очереди на двух стеках элемент перекладывается между in и out? — не более одного раза
  • core | Средняя (амортизированная) сложность pop в очереди на двух стеках? — O(1), несмотря на то что отдельный вызов с переливом стоит O(n)

Почему дальше: очередь на массиве без сдвигов элементов — это ровно то, что нужно для сетевых буферов, где сдвигать байты на каждый пакет непозволительно дорого. Это кольцевой буфер.

8. Кольцевой буфер: head/tail, wrap-around, полный/пустой

Массив фиксированного размера capacity плюс два индекса: head — куда пишем следующим, tail — откуда читаем следующим. Когда индекс доходит до конца массива, он возвращается в начало: idx = (idx + 1) % capacity, либо быстрее без деления — if (++idx == capacity) idx = 0;. Сдвигать элементы не нужно никогда — в этом весь смысл структуры: O(1) на push/pop.

Ключевая ловушка, из-за которой структура — частый вопрос на собеседовании: если head и tail совпали, буфер пуст или полон? Оба состояния дают одинаковое совпадение индексов. Решения — либо отдельный счётчик size (тогда empty() — это size == 0, full() — это size == capacity), либо сознательно держать одну ячейку всегда свободной и не использовать её для данных.

Именно эта структура лежит в основе буферов приёма/передачи пакетов и буферов DMA в драйверах: аппаратура пишет в буфер по одному индексу, программа читает по другому, оба двигаются по кругу независимо, без перемещения самих данных в памяти. В однопоточном варианте head/tail — обычные переменные; если буфер общий между потоком и прерыванием или между двумя потоками, индексы нужно защищать (атомарными операциями или мьютексом) — это разбирается в уроке про многопоточность.

Факты для карточек

  • base | Формула перехода индекса на начало массива в кольцевом буфере? — idx = (idx + 1) % capacity
  • base | Сложность push/pop в кольцевом буфере? — O(1), без сдвига элементов
  • core | Как отличить полный кольцевой буфер от пустого, если head == tail? — счётчик size, либо держать одну ячейку всегда свободной
  • core | Более быстрая замена % capacity без деления? — if (++idx == capacity) idx = 0;
  • deep | Где кольцевой буфер встречается в драйверах? — буферы DMA и приёма/передачи пакетов, head/tail двигаются независимо без перемещения данных

Ловушки

  • Сдвиг элементов вместо движения индексов → теряется весь смысл O(1), фактически O(n) на операцию.
  • Путаница «пусто/полно» при совпавших head/tail без отдельного счётчика → неверный ответ empty()/full() на границе.
  • Инкремент индекса до записи/чтения вместо после → значения идут в неправильном порядке после первого же заворота.

Почему дальше: если элементов много и нужно быстро находить «следующий больший» для каждого из них за один проход, наивный перебор даёт O(n²) — монотонный стек снижает это до O(n).

9. Монотонный стек: Next Greater Element, Daily Temperatures

Задача Next Greater Element: для каждого элемента массива найти первый элемент справа от него, который больше. Наивно — вложенный цикл, O(n²). Монотонный стек решает за O(n): идём слева направо, в стеке храним индексы элементов, для которых ответ ещё не найден (значения в стеке убывают снизу вверх). Для каждого нового элемента a[i]: пока стек не пуст и a[i] > a[top] — это и есть «следующий больший» для индекса на вершине, снимаем его со стека и записываем ответ; затем кладём индекс i в стек.

Daily Temperatures — та же схема, только вместо значения элемента в ответ пишут расстояние (число дней) до дня с более высокой температурой.

Почему это O(n), хотя внутри есть вложенный while: каждый индекс попадает в стек ровно один раз (push вызывается n раз суммарно) и снимается со стека не более одного раза (pop вызывается максимум n раз суммарно за весь проход) — суммарно операций со стеком не больше 2n, поэтому общая стоимость всех итераций внешнего цикла вместе с внутренним while — O(n). Это классический пример амортизированного анализа: отдельная итерация внешнего цикла может выполнить несколько pop, но в сумме по всему проходу лишних операций не набегает.

Факты для карточек

  • base | Наивная сложность Next Greater Element вложенным циклом? — O(n²)
  • base | Сложность Next Greater Element через монотонный стек? — O(n)
  • core | Почему монотонный стек даёт O(n), если внутри есть вложенный while? — каждый индекс кладётся в стек и снимается не более одного раза, суммарно ≤2n операций за весь проход
  • core | Что хранит монотонный стек в задаче Next Greater Element — значения или индексы? — индексы (значения по ним убывают снизу вверх)
  • deep | Чем Daily Temperatures отличается от Next Greater Element по сути алгоритма? — тем же алгоритмом, но в ответ пишут расстояние в днях, а не значение

Почему дальше: списки и кольцевые буферы из этого урока становятся общими структурами между несколькими потоками в задаче 06_threads — там начинается вопрос синхронизации доступа к ним.

Ссылки на задачи этого дня: tasks/02_list (разворот, середина, цикл), tasks/03_ring (кольцевой буфер, ступенями).

Проверь себя
  1. Список из 5 узлов (1→2→3→4→5). Что вернёт find_middle по алгоритму slow/fast из этого урока?

    ОтветУзел 3 — ровно середина при нечётной длине 5.
  2. Почему pop в очереди на двух стеках всё равно считается амортизированной O(1), если конкретный вызов с переливом стоит O(n)?

    ОтветКаждый элемент переливается из `in` в `out` не более одного раза за всё время жизни в очереди, поэтому суммарная стоимость n операций — O(n), то есть в среднем O(1) на операцию.
  3. В кольцевом буфере ёмкости 4 записали 4 элемента, сняли 2, записали ещё 3. Сколько раз head за это время перешёл через границу массива (сделал wrap-around) при последовательной индексации с 0?

    ОтветОдин раз: после 4 записей `head` дошёл до конца и вернулся в 0, при следующих 3 записях он снова дойдёт до 3, второго заворота ещё не будет (это можно проверить руками по формуле `idx=(idx+1)%4`, начиная с `head=0`).
  4. Почему монотонный стек для Next Greater Element хранит убывающую последовательность значений, а не произвольную?

    ОтветКак только встречается элемент больше вершины стека, это и есть ответ для всех подходящих элементов на вершине — их снимают со стека; то, что остаётся в стеке, по построению всегда убывает сверху вниз, иначе элемент уже был бы снят раньше.

Материалы