Связные списки и персистентные структуры

Узлы, ссылки, sentinel и локальные изменения списков. Неизменяемые версии, общие хвосты и границы амортизации при ветвлении.

01

От RAM-машины к машине указателей

Массив [4,9,12,7,5] позволяет прочитать пятый элемент как A[4]. Цепочка head → 4 → 9 → 12 → 7 → 5 требует четырёх переходов next. Узлы могут лежать в памяти далеко друг от друга: соседство хранится в ссылках, а не определяется близостью адресов.

Переходы по ссылкам
адрес 40[0]
адрес 11[1]
адрес 73[2]
адрес 25[3]
адрес 98[4]

От head до узла 2 нужно 2 переходов next. Номера адресов не определяют соседство.

Массив A[2] читается одним прямым доступом. Список требует сначала 2 переходов next и затем чтения value.

Нужен пятый элемент (индекс 4). Сравните доступ при известной только голове списка.

head — ссылка на первый узел. Сам узел хранит value (значение) и next (ссылку на следующий). null означает, что следующего узла нет. Известная ссылка даёт быстрый доступ к полям одного узла; произвольный номер позиции ещё нужно найти.

Необязательное углубление: RAM и pointer machine
RAM разрешает прямой доступ по вычисленному адресу A[i]. Pointer machine разрешает читать поля уже доступных узлов и следовать их ссылкам. Узлы имеют фиксированный размер; произвольная индексация за O(1) в эту модель не входит. Представить ссылки индексами массива можно в RAM, но нельзя считать это разрешением мгновенно находить i-й элемент связной цепочки.

В учебном коде Python и Rust номера в массиве next моделируют ссылки. Например next[4]=1 означает переход из узла #4 в #1, даже если значение лежит не в соседней ячейке. Option<usize> в Rust и None в Python моделируют ссылку или её отсутствие.

В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

Номер участника не равен адресу

Если команды называют узлы произвольными большими id, сначала постройте отображение id в ссылку или индекс. Стоимость этой подготовки нужно включить в решение. Сам список не умеет находить заданный id за O(1).

Стоимость доступа определена. Теперь проследим одно локальное изменение ссылок в односвязном списке.

Примените алгоритм к новому примеру

Задания раздела

Пятый элемент: индекс 4. Известна только голова.

Что означает next_node[4]=1?

Попыток: 0
02

Односвязный список

Этот раздел откроется после предыдущих проверок.

Продолжить: От RAM-машины к машине указателей →

В цепочке 4 → 9 → 12 поле value первого узла равно 4, next ссылается на узел со значением 9. head ссылается на 4, а next узла 12 равен null. Новый узел 7 сначала должен запомнить старую ссылку на 9; иначе после замены ссылки у 4 старый хвост будет недоступен.

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

Односвязный список (Singly linked list) — Последовательность узлов, где каждый хранит значение и ссылку next на следующий узел.

Ссылка head указывает на первую вершину. Для обхода достаточно повторять x = x.next до null. Поиск i-го элемента требует i переходов: O(i).

Вставка после известного узла
4 → 9[0]
9 → 12[1]
12 → null[2]

Поиск узла по индексу требует обхода; после получения ссылки само изменение next — O(1).

Вставка: выберите порядок присваиваний

head → x(4) → 9 → 12 → null. Новый узел y хранит 7 и пока не входит в список. Сначала соедините y со старым хвостом, потом x с y. Попробуйте и обратный порядок.

#0: value=4; next=#1[0]
#1: value=9; next=#2[1]
#2: value=12; next=null[2]
#3: value=7; next=null[3]

Обход от head: 4 → 9 → 12 → null. Выполните второе присваивание.

Массив next хранит ссылки на узлы по их номерам. Это модель узлов: соседство задаёт next, а не соседние ячейки массива.

Вставить y после известного x
y.next = x.next
x.next = y

Порядок присваиваний важен: иначе хвост может оказаться потерян.

ОперацияСтоимость при известном узле
Обход всех узловO(n)
Доступ к i-муO(i)
Вставка после xO(1)
Удаление x при известном предшественникеO(1)
Типичная ошибкаЗнание указателя на сам x не всегда позволяет удалить x из односвязного списка без поиска предыдущего узла или дополнительных предположений.

Для цепочки 4 → 9 → 12 вставка 7 после узла 4 сначала сохраняет y.next = x.next, а затем устанавливает x.next = y. Меняя присваивания местами, легко создать ссылку y на самого себя и потерять старый хвост. Удаление узла после известного x аналогично: x.next = x.next.next, если следующий узел существует.

Разворачиваем ссылки, а не значения

Узлы находятся в арене; индекс используется как ссылка. Перед изменением next сохраняем старого следующего. Обход занимает O(n), дополнительная память O(1); исходная цепочка меняется. Такой разворот нельзя применять к общему неизменяемому хвосту версий.

Разворачиваем ссылки, а не значения / Python
def reverse_links(next_node, head):
    previous = None
    while head is not None:
        following = next_node[head]
        next_node[head] = previous
        previous, head = head, following
    return previous

values = [1, 2, 3]
links = [1, 2, None]
head = reverse_links(links, 0)
result = []
while head is not None:
    result.append(values[head])
    head = links[head]
print(*result)
Ожидаемый вывод
3 2 1

В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

Редактор с курсором

Когда курсор уже указывает на узел, вставка после него меняет две ссылки. Если перед каждой вставкой надо дойти до позиции i от головы, последовательность таких команд может стать квадратичной.

Быстрая вставка после известного узла не решила удаление самого узла. Для этого добавим обратные связи.

Сначала обход и вставка

Перед разворотом всей цепочки реализуйте два коротких действия: дойти по next до нужного узла и вставить после известного узла, сохранив хвост. Массив next_node моделирует ссылки между узлами; None завершает цепочку.

Узлы заданы values,next_node,head; None завершает цепочку, циклов нет. Верните индекс первого достижимого узла со значением target либо None. Идите по next, не по порядку массива.

Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

Открыто 0 из 3

    5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

    Тест 1 find_node([4, 9, 12], [2, None, 1], 0, 9)Ожидается: 1
    Тест 2 find_node([], [], None, 7)Ожидается: None
    Тест 3 find_node([4], [None], 0, 8)Ожидается: None

    Дополнительные тесты: 2. Проверяются границы и повторы.

    Попыток: 0

    Добавьте в next_node узел с индексом len(next_node). Вставьте его после известного достижимого x. При x=None вставьте перед головой, в том числе в пустой список. Верните [новая_голова, изменённый_next_node]. Циклов нет.

    Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

    Открыто 0 из 3

      5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

      Тест 1 insert_after([1, 2, None], 0, 0)Ожидается: [0, [3, 2, None, 1]]
      Тест 2 insert_after([], None, None)Ожидается: [0, [None]]
      Тест 3 insert_after([None], 0, 0)Ожидается: [0, [1, None]]

      Дополнительные тесты: 3. Проверяются границы и повторы.

      Попыток: 0

      Список задан массивом next_node и индексом головы head; None завершает цепочку. Разверните next в достижимой цепочке. Верните [новая_голова, изменённый_массив_next]. Вход не содержит циклов.

      Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

      Открыто 0 из 3

        5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

        Тест 1 reverse_links([1, 2, None], 0)Ожидается: [2, [None, 0, 1]]
        Тест 2 reverse_links([], None)Ожидается: [None, []]
        Тест 3 reverse_links([None], 0)Ожидается: [0, [None]]

        Дополнительные тесты: 4. Проверяются границы и повторы.

        Попыток: 0
        03

        Двусвязные и циклические списки

        Этот раздел откроется после предыдущих проверок.

        Продолжить: От RAM-машины к машине указателей →

        Сначала обычный список 4 ↔ 9 ↔ 12. Удаляя 9, меняем 4.next на 12 и 12.prev на 4. Если удаляется голова, head становится правым соседом; у хвоста меняется tail. У единственного узла оба внешних указателя становятся null. Sentinel позволит объединить эти крайние случаи одним кодом.

        Обычный список 4 ↔ 9 ↔ 12: какие ссылки меняем, удаляя известный узел 9?

        В циклическом списке S — sentinel. После удаления единственного узла x каковы ссылки?

        В односвязном списке перед удалением нужен предшественник. Сохраним обратную ссылку prev и получим возможность отсоединять известный узел локально. Затем уберём особые случаи первого и последнего элементов с помощью sentinel.

        Двусвязный список (Doubly linked list) — Список, где у каждого узла есть ссылки prev и next.

        Добавление или удаление известного узла выполняется изменением постоянного числа связей. Циклическая структура с фиктивной вершиной (sentinel) устраняет специальные случаи головы и хвоста.

        Циклический список с sentinel
        Узелprevnext
        sentinel#3#1
        #1: 4#0#2
        #2: 9#1#3
        #3: 12#2#0

        Sentinel не удаляется. В пустом списке его prev и next указывают на него самого.

        Удалить x из двусвязного списка
        x.prev.next = x.next
        x.next.prev = x.prev
        // отсоединить x / освободить память при необходимости

        При корректной циклической структуре с sentinel код одинаков для начала, середины и конца.

        Sentinel и пустой список
        У пустого циклического списка next и prev фиктивного узла указывают на него самого. Фиктивный узел не является элементом данных.

        Sentinel не хранит пользовательское значение и замыкает пустой список сам на себя. Для любого живого узла сохраняются равенства x.next.prev = x и x.prev.next = x. Они дают простой способ проверять корректность после вставки и удаления. После удаления ссылка на узел больше не должна использоваться как ссылка на член списка.

        В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

        Выбывание известных участников

        При удалении участника по заранее сохранённой ссылке двусвязный список быстро соединяет соседей. Sentinel позволяет одинаково обрабатывать крайние узлы; идентификатор уже удалённого участника нужно отклонять.

        Согласованные prev/next позволяют не только удалять узлы, но и соединять целые контейнеры за постоянное число переназначений.

        Примените алгоритм к новому примеру

        Задания раздела

        4 ↔ 9 ↔ 12: какие связи меняются при удалении 9?

        Удалили единственный пользовательский узел цикла с sentinel S.

        Попыток: 0
        04

        Списки как множества

        Этот раздел откроется после предыдущих проверок.

        Продолжить: От RAM-машины к машине указателей →

        Есть два уже найденных контейнера: A содержит 4 ↔ 9, B содержит 12 ↔ 7. Чтобы получить A: 4 ↔ 9 ↔ 12 ↔ 7, соединяем хвост A с головой B и хвост B с sentinel A. Значения не копируются, но B перестаёт быть независимым списком. Проверить наличие 7 без готовой ссылки по-прежнему требует обхода.

        Список задаёт последовательность, однако его можно использовать как контейнер элементов множества, если порядок не важен. При этом быстрые перестановки ссылок не превращают поиск произвольного значения в O(1).

        Лекция показывает, как с помощью циклического двусвязного списка представлять коллекцию с операциями создания, добавления, удаления известного элемента, выдачи произвольного элемента и объединения списков.

        Если у каждого множества известен указатель на его фиктивный узел, конкатенация двух непустых списков сводится к постоянному числу переназначений prev/next.

        Область применимостиБыстрое склеивание не означает O(1) для произвольной проверки членства или поиска значения без указателя на соответствующий узел.
        Удаление из середины
        В двусвязном списке два соседа известны по полям удаляемого узла. В односвязном — только правый сосед. Это объясняет различие.

        Слияние двух списков с известными концами может переназначить O(1) ссылок, но это разрушающая конкатенация: исходные списки перестают быть двумя независимыми контейнерами. Уникальность значений и обновление поля «владелец» у каждого узла в эту оценку не входят. Если нужно отвечать, принадлежат ли элементы одному множеству, потребуется другая структура — DSU из следующей лекции.

        Склеиваем два непустых циклических списка A и B в A. aTail/aHead и bTail/bHead известны; B после этого станет пустым. Какие связи нужны?

        В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

        Объединение контейнеров

        Два списка можно склеить без обхода значений, если их концы известны и независимость исходных контейнеров больше не нужна. Если каждый узел хранит id владельца, обновление всех этих полей уже не O(1).

        Прежде чем переходить к версиям, реализуем на узлах два интерфейса, уже знакомых по массивам.

        Примените алгоритм к новому примеру

        Задания раздела

        Склеиваем известные непустые циклы A и B в A, опустошаем B. Какие изменения нужны?

        Даёт ли такая склейка поиск произвольного значения за O(1)?

        Попыток: 0
        05

        Стек и очередь на ссылках

        Этот раздел откроется после предыдущих проверок.

        Продолжить: От RAM-машины к машине указателей →

        Сравним новую память с уже знакомым интерфейсом. Чтобы получить стек, достаточно одного конца списка; очередь требует обоих. Здесь особенно полезно проверить переход от одного элемента к пустому состоянию.

        Стек реализуется вставкой/удалением первого узла списка. Для FIFO-очереди удобно хранить указатель на голову и хвост; при добавлении к хвосту и удалении из головы обе операции занимают O(1).

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

        РеализацияЧто сохраняемВставкаИзвлечение
        СтекtopO(1)O(1)
        Очередьhead, tailO(1)O(1)
        Массив с кольцевой индексациейhead, tail, capacityO(1)O(1)
        Очередь на узлах (схема)
        enqueue(x):
            tail.next = new Node(x)
            tail = tail.next
        
        dequeue():
            assert head.next != null
            x = head.next.value
            head.next = head.next.next
            if head.next == null: tail = head
            return x

        Здесь head — фиктивный узел, tail указывает на последний узел или на head при пустой очереди.

        В схеме с фиктивным head очередь из одного пользовательского узла имеет head.next = tail. После его удаления tail возвращается к sentinel head; следующая вставка снова присоединяется к живой очереди. В схеме без sentinel единственный узел действительно задаётся head=tail, а пустое состояние head=tail=null. Не смешивайте эти два соглашения.

        Одна очередь: массив или узлы?

        Массив с начальной ёмкостью 1 и удвоением: 15 копирований, 9 записей, ёмкость 16. Узлы: 9 выделений, 9 записей значений, копирований старых значений нет; нужны ссылки и накладные расходы выделения.

        При чередовании добавить/извлечь в кольцевом буфере одной выделенной ёмкости может хватать на все команды. Узловая очередь всё равно создаёт узел на каждое добавление. Само выделение памяти не считается бесплатным.

        Нужен обычный FIFO для 200 000 команд, старые версии не нужны. Что выбрать в Python?

        Очередь на узлах с головой и хвостом

        Ссылки представлены индексами арены. При удалении последнего узла очищаем tail. Операции меняют O(1) ссылок; расширение Vec/list амортизировано. Эта компактная арена не переиспользует удалённые ячейки: её память O(числа всех добавлений), а не только текущей длины очереди.

        Очередь на узлах с головой и хвостом / Python
        class NodeQueue:
            def __init__(self):
                self.nodes = []
                self.head = self.tail = None
            def push(self, value):
                i = len(self.nodes)
                self.nodes.append([value, None])
                if self.tail is None:
                    self.head = i
                else:
                    self.nodes[self.tail][1] = i
                self.tail = i
            def pop(self):
                if self.head is None:
                    return None
                value, self.head = self.nodes[self.head]
                if self.head is None:
                    self.tail = None
                return value
        
        q = NodeQueue()
        q.push(4)
        q.push(9)
        first, second = q.pop(), q.pop()
        q.push(7)
        print(first, second, q.pop())
        Ожидаемый вывод
        4 9 7

        В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

        Очередь фронта обхода

        При обходе по слоям новые вершины добавляются в хвост, старые уходят из головы. Храните tail корректно даже после опустошения; иначе ошибка обнаружится только на повторном наполнении очереди.

        Мы научились менять текущее состояние. Следующий шаг — сохранить прежнее состояние доступным после изменения.

        Примените алгоритм к новому примеру

        Задания раздела

        Схема с фиктивным head: удалили последний пользовательский узел. Что сделать?

        Обычная Python-очередь без стабильных ссылок: что проще?

        Попыток: 0
        06

        Что такое персистентность

        Этот раздел откроется после предыдущих проверок.

        Продолжить: От RAM-машины к машине указателей →

        История документа: V0 пустая, V1 добавляет запись 5, V2 добавляет 8. Вернитесь к V1 и вместо 8 добавьте 3 — появится отдельная V3. Должны оставаться доступны и [5,8], и [5,3], и исходная [5]. Сначала попробуйте ветвление в модели, затем назовём такую структуру персистентной.

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

        Персистентная структура (Persistent data structure) — Структура, у которой после обновления сохраняется доступ к предыдущим версиям состояния.

        В отличие от обычного «перезаписать указатель», новая версия создаёт необходимые новые узлы, а старые узлы и ссылки остаются неизменными. Разные версии могут разделять общие хвосты.

        Ветвление версий стека

        v0

        Пустой массив

        v1

        #0: 5[0]

        v2 ← выбрана

        #1: 8[0]
        #0: 5[1]

        Версий: 3. Физических узлов: 2. Узел с одним номером используется совместно и не копируется при создании версии.

        Ссылок версий на вершины, включая null: 3. Непустых next-ссылок между физическими узлами: 1. При полном копировании версий хранилось бы 3 значений.

        Персистентный стек: push создаёт новый узел с next = старый top, pop возвращает top.next. Обе операции O(1), старые версии остаются доступны.

        Смысл терминаЗдесь «персистентность» — сохранение прежних логических версий структуры в памяти; это не обязательно хранение данных на диске.

        Создадим V1 = push(5,V0), V2 = push(8,V1) и V3 = push(3,V1). В V2 и V3 новый только верхний узел; старый узел 5 общий. Ни одно поле старого узла не меняется. Классификацию частичной и полной персистентности можно прочитать в дополнительном блоке следующей главы.

        Неизменяемый стек с общими хвостами

        Push создаёт один узел, pop возвращает ссылку на хвост. В Rust Rc позволяет версиям совместно владеть хвостом; clone копирует ссылку, а не весь список. Операции O(1), новые узлы — O(числа push). Освобождение последней ссылки на длинную цепочку может потребовать отдельного времени.

        Неизменяемый стек с общими хвостами / Python
        from dataclasses import dataclass
        
        @dataclass(frozen=True)
        class Node:
            value: int
            next: 'Node | None' = None
        
        def push(top, value):
            return Node(value, top)
        
        def pop(top):
            return top.next if top is not None else None
        
        v1 = push(None, 5)
        v2 = push(v1, 8)
        v3 = push(v1, 3)
        print(v2.value, v3.value, pop(v2).value)
        Ожидаемый вывод
        8 3 5

        В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

        Возврат к предыдущему запросу

        В Persistent Bookcase операции могут ссылаться на старое состояние. Сначала постройте дерево версий: номер запроса становится версией, а возврат задаёт её предка. Это помогает отделить историю от физического хранения.

        Сохранение версий будет эффективным только при совместном использовании неизменяемой памяти. Разберём, какие узлы действительно новые.

        Начальная версия 0 — пустой стек. Команда [version, "push", x] создаёт новую версию из указанной старой; [version, "pop"] убирает вершину, если она есть. Верните содержимое всех версий, включая 0, в порядке от дна к вершине. Старые версии не меняйте. Индексы версий корректны.

        Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

        Открыто 0 из 3

          5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

          Тест 1 stack_versions([[0, "push", 5], [1, "push", 8], [1, "push", 3], [2, "pop"]])Ожидается: [[], [5], [5, 8], [5, 3], [5]]
          Тест 2 stack_versions([[0, "pop"]])Ожидается: [[], []]
          Тест 3 stack_versions([])Ожидается: [[]]

          Дополнительные тесты: 2. Проверяются границы и повторы.

          Попыток: 0
          07

          Структурное разделение

          Этот раздел откроется после предыдущих проверок.

          Продолжить: От RAM-машины к машине указателей →

          Если копировать весь список при каждом push, история из q вставок займёт Θ(q²) памяти. Неизменяемость позволяет избежать этого: версии отличаются верхними узлами и совместно используют уже готовый хвост.

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

          Общие хвосты и количество новых узлов

          v0

          Пустой массив

          v1

          #0: 5[0]

          v2 ← выбрана

          #1: 8[0]
          #0: 5[1]

          Версий: 3. Физических узлов: 2. Узел с одним номером используется совместно и не копируется при создании версии.

          Ссылок версий на вершины, включая null: 3. Непустых next-ссылок между физическими узлами: 1. При полном копировании версий хранилось бы 3 значений.

          additional memory(push) = O(1)

          Новый верхний узел плюс ссылка на уже существующий хвост.

          В общем случае персистентность может потребовать копирования пути к изменяемому месту; именно поэтому простой список со вставкой в середину устроен иначе, чем персистентный стек.

          Частичная и полная персистентность
          Частичная разрешает запросы к старым версиям, но изменения только новой; полная позволяет ветвиться от любой версии. Демонстрация стека позволяет полную персистентность.

          В Python неизменяемый узел может хранить пару (value, next). В Rust тот же общий хвост выражается Rc<Node>: clone у Rc копирует счётчик ссылки, а не всё поддерево. При изменении элемента на глубине k приходится скопировать k узлов пути, поэтому O(1) для push нельзя переносить на произвольное редактирование списка.

          V0=пусто; V1=push(5,V0); V2=push(8,V1); V3=push(3,V1). Посчитайте хранение.

          В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

          Разветвляющаяся история стека

          После push от одной старой версии можно получить несколько новых. У каждой новый верхний узел, а длинный общий хвост хранится один раз. Сравнивайте O(q) новых узлов с Θ(q²) при полном копировании.

          Вы умеете сохранять версии стека с общим неизменяемым хвостом. Тонкости очереди при ветвлении — необязательное углубление; к практике списков можно перейти сразу.

          Примените алгоритм к новому примеру

          Задания раздела

          V1=push(5,V0), V2=push(8,V1), V3=push(3,V1). Сколько физических узлов?

          Сколько значений содержали бы полные отдельные копии V0,V1,V2,V3?

          Попыток: 0
          А—Я

          Справочник терминов

          Справочник собирает определения из разобранных разделов. Используйте поиск, если нужно восстановить термин; затем переходите к оценке ограничений и задачам для самостоятельного решения.

          Общий справочник всех лекций ↗

          Найдено: 12 из 12

          RAM-модель / Random-access machineАбстракция памяти с доступом по адресу за O(1).
          Pointer machine / Pointer machineМодель узлов с прямым доступом через ссылки.
          Односвязный список / Singly linked listУзел хранит значение и ссылку next.
          Двусвязный список / Doubly linked listУзел хранит prev и next.
          Циклический список / Circular linked listПоследний узел связан с первым или с фиктивным узлом.
          Фиктивный узел / Sentinel nodeСлужебный узел, упрощающий граничные случаи.
          Указатель / PointerСсылка на другой узел модели.
          Персистентная структура / Persistent data structureПозволяет обращаться к прежним версиям после обновления.
          Полная персистентность / Full persistenceОбновление допустимо из любой предыдущей версии.
          Разделение хвостов / Structural sharingНесколько версий используют одни неизменяемые узлы.
          Копирование пути / Path copyingСоздание новых узлов на пути к месту изменения.
          Очередь на двух стеках / Two-stack queueFIFO через входной и выходной стеки.
          08

          Ограничения и выбор алгоритма

          Этот раздел откроется после предыдущих проверок.

          Продолжить: От RAM-машины к машине указателей →

          Сначала подставьте максимальные размеры из условия в оценку времени и памяти. Оценка числа действий — ориентир: константы, язык и ввод проверяются отдельно.

          Сравнение уже разобранных структур

          ПредставлениеИнвариантИзменение известного местаПоиск значения / позицииСохранение версии
          Односвязный списокnext ведёт к следующему узлу, хвост — в null.O(1) после известного предшественникаO(n)При изменении старые ссылки перезаписываются.
          Двусвязный циклический списокx.next.prev=x; x.prev.next=x; sentinel замыкает цикл.O(1) для известного узлаO(n)Обычные операции разрушают прежнее состояние.
          Персистентный стекВсе узлы неизменяемы; версия задаётся вершиной.push/pop O(1), push создаёт один узелO(n) по цепочкеСтарые вершины и общие хвосты остаются доступны.

          Ссылка и поиск — разные затраты

          При n = q = 200 000 поиск каждого узла обходом может потребовать до 4·10¹⁰ переходов. O(1) для удаления известного узла не спасёт, если этот узел сначала ищется за O(n). Если вход использует id, оцените отображение id → узел отдельно.

          Сколько памяти стоит история

          q полных копий растущего списка могут содержать Θ(q²) значений. Для персистентного стека q операций push создают q узлов и O(q) ссылок на вершины версий. Копирование пути длины k стоит O(k), а не O(1).

          В Python объект узла имеет служебные поля; в Rust Rc несёт счётчик ссылок. Арена из Vec с целыми индексами компактнее, но без списка свободных ячеек сохраняет даже удалённые узлы. Различайте O(текущего размера) и O(всех выделений).

          Границы и версия доказательства

          Проверьте удаление головы, хвоста, единственного элемента и повторное заполнение пустой очереди. Для циклического списка проверьте prev/next в обе стороны и сохранность sentinel.

          При q ветках из версии с n элементами наивная очередь с повторным разворотом даёт Θ(qn). Нужна конструкция с доказанной работой на всё дерево версий либо офлайн-обход истории с откатами. Рекурсивный обход дерева из 200 000 запросов может переполнить стек: используйте явный стек.

          Примените алгоритм к новому примеру

          Задания раздела

          Поиск узла перед каждым запросом стоит O(n), изменение ссылки O(1). Для q запросов итог?

          1000 push с общими хвостами: сколько новых узлов?

          Попыток: 0
          09

          Практика: 3 уровня

          Этот раздел откроется после предыдущих проверок.

          Продолжить: От RAM-машины к машине указателей →

          Разминка: найти узел, вставить, развернуть

          Повторите поиск узла и вставку после него в главе об односвязном списке, затем разверните цепочку. В этих задачах массив next_node моделирует ссылки: next_node[4]=1 означает «узел #4 ссылается на #1». Это не перестановка значений соседних ячеек. None — отсутствие следующего узла.

          Список задан массивом next_node и индексом головы head; None завершает цепочку. Разверните next в достижимой цепочке. Верните [новая_голова, изменённый_массив_next]. Вход не содержит циклов.

          Tab — табуляция · Ctrl / ⌘ + L — выделить строку · Alt + ↑/↓ — переместить строку или выделенный блок · Ctrl / ⌘ + Enter — запустить. Esc, затем Tab — выйти из редактора.

          Открыто 0 из 3

            5 секунд на тесты. При первом запуске загрузится Python; нужен интернет. Код выполняется в браузере.

            Тест 1 reverse_links([1, 2, None], 0)Ожидается: [2, [None, 0, 1]]
            Тест 2 reverse_links([], None)Ожидается: [None, []]
            Тест 3 reverse_links([None], 0)Ожидается: [0, [None]]

            Дополнительные тесты: 4. Проверяются границы и повторы.

            Попыток: 0

            Основное применение: Reverse Linked List, Merge Two Sorted Lists и Remove Duplicates. Persistent Bookcase — продвинутое необязательное испытание после освоения версий.

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

            Уровни задают порядок практики в курсе, а не единую шкалу платформ. Если уровень отличается от отметки LeetCode, это указано в карточке. Для Codeforces используется оценка курса.

            01

            Лёгкие

            Три задачи для закрепления основных приёмов.

            1. LeetCode · 206

              Reverse Linked List

              Сохранить next перед разворотом ссылки.

              Ограничения. От 0 до 5000 узлов.

              Идея и оценка сложности

              Идите тремя ссылками previous/current/next. Переписывайте только next текущего узла; значения менять не требуется. O(n) времени и O(1) дополнительной памяти.

            2. Переподключать готовые узлы и сохранять порядок.

              Ограничения. В каждом списке до 50 узлов; списки отсортированы.

              Идея и оценка сложности

              Sentinel задаёт голову ответа. Каждый шаг присоединяет меньший первый узел; после исчерпания одного списка присоедините остаток другого. O(n+m), без копирования значений.

            3. Удалять узел после известного предшественника.

              Ограничения. От 0 до 300 узлов, значения отсортированы.

              Идея и оценка сложности

              При равенстве текущего и следующего пропустите следующий узел. Иначе двигайтесь дальше. Дубликаты идут подряд благодаря инварианту сортировки. O(n), O(1) памяти.

            02

            Средние

            Две задачи на сочетание идей и анализ ограничений.

            1. LeetCode · 707

              Design Linked List

              Разделить поиск индекса и локальное изменение ссылок.

              Ограничения. До 2000 вызовов, индексы и значения до 1000.

              Идея и оценка сложности

              Храните размер и sentinel. Вставка по индексу допустима при index == size, удаление — только при index < size. Поиск позиции O(n), переназначения O(1).

            2. Найти предшественника удаляемого узла двумя указателями.

              Ограничения. От 1 до 30 узлов; n не больше длины.

              Идея и оценка сложности

              Сдвиньте быстрый указатель на n узлов, затем двигайте оба. Sentinel позволяет удалить исходную голову тем же способом. O(length) времени, O(1) памяти.

            03

            Тяжёлая

            Одна задача для переноса идеи на новую постановку.

            1. Codeforces · 707D

              Persistent Bookcase

              Отделить дерево версий от способа хранения данных.

              Ограничения. n,m ≤ 1000, q ≤ 10⁵.

              Идея и оценка сложности

              Полная копия n×m после каждого запроса слишком велика. Постройте дерево зависимостей версий и обходите его с откатами; инверсию строки поддерживайте флагом и счётчиком. При прямом обходе строки цена инверсий O(qm); битовые представления могут её снизить. Стек обхода не должен упасть на цепочке q версий.

            Примените алгоритм к новому примеру

            Задания раздела

            Восстановите порядок одного шага разворота.

            1. next_node[current]=previous
            2. saved=next_node[current]
            3. current=saved
            4. previous=current

            Вставьте y после x без потери хвоста.

            1. x.next=y
            2. y.next=x.next
            Попыток: 0
            Дополнительные материалыДля углублённого разбора · не влияют на прохождение
            ДОП.

            Персистентная очередь: осторожнее с амортизацией

            Необязательное углублениеДля первого знакомства достаточно версии стека и общих хвостов. Здесь анализируется специальная проблема повторного переноса при ветвлении.

            Персистентный стек получился простым, но очередь на двух стеках скрывает тонкость. Доказательство из шестой лекции относилось к одной цепочке операций; теперь история обновлений может разветвляться.

            Амортизированная очередь на двух стеках работает хорошо для одной последовательности обновлений. Но при ветвлении от старых версий можно повторять один и тот же дорогой перенос много раз — стандартный аргумент «переложили элемент только раз» перестаёт работать.

            Повторная оплата переноса при ветвлении

            Старая версия: input содержит 8 элементов, output пуст. Каждая ветка начинает с неё заново.

            Наивный перенос: 32 элементов
            Для одной цепочки: не более 8 переносов этих элементов

            4 веток повторяют одну работу. Совместное использование хвоста само по себе не сохраняет результат разворота: требуется отдельная конструкция.

            Лекция обсуждает конструкцию, в которой перенос организуется постепенно, как небольшое расписание операций, чтобы обеспечить ограничения на отдельный шаг без повторной оплаты.

            Критическое различиеДоказанная для обычной изменяемой структуры амортизированная O(1) автоматически не становится такой же гарантией для полностью персистентной структуры.
            Что сохраняет функциональный подход
            Новые версии можно создавать из прежних без модификаций старых узлов, но при оценке времени необходимо учитывать повторное использование разных ветвей истории.

            Пусть старая версия содержит n элементов только во входном стеке. Если q раз вызвать dequeue именно от неё без общего мемоизированного переноса, каждый вызов снова обращает n элементов: итог Θ(qn). Линейный аргумент «каждый элемент перенесли один раз» неприменим. Реальные функциональные очереди используют разделяемые ленивые вычисления и/или расписание переноса с доказательством, учитывающим ветвление; простое копирование Python-списков такой гарантии не даёт.

            В СПОРТИВНОМ ПРОГРАММИРОВАНИИ

            Когда повторный перенос ломает оценку

            Серия запросов dequeue от одной старой версии повторяет один и тот же разворот. Для задач с ветвлением версий нельзя без доказательства переносить амортизированную оценку обычной очереди на персистентную.

            Итак, локальное изменение и сохранение версии — разные гарантии. В DSU мы снова будем менять ссылки, но уже ради объединения классов эквивалентности.

            Что произойдёт при q независимых dequeue от одной старой версии с n элементами только во входном стеке, если перенос не разделяется?

            Выберите один ответ
            Попыток: 0

            ПРОДОЛЖИТЬ ОБСУЖДЕНИЕ

            Промпт для ИИ

            Скопируйте промпт с текстом лекции в чат с ИИ, чтобы обсудить непонятные места. В него входят дополнительные материалы, псевдокод и примеры на обоих языках.