Список
TL;DR
Связный список (linked list) — линейная структура из узлов, каждый из которых хранит ключ и ссылку на следующий узел, а в двусвязном варианте — ещё и на предыдущий. Порядок элементов задаётся ссылками, а не расположением в памяти, поэтому вставка после известного узла и удаление при известном предшественнике стоят O(1), а поиск и доступ по позиции — O(n). Главный компромисс: гибкая перестройка и стабильные адреса узлов в обмен на память под указатели и плохую локальность кэша при обходе.
| Поле | Значение |
|---|---|
| Класс / тип | Линейная последовательность узлов, связанных указателями; доступ по индексу не поддерживается |
| Изобретение | 1955–1956, А. Ньюэлл, К. Шоу, Г. Саймон (IPL, RAND) [5]
|
| Время операций | Худшее: поиск и удаление по ключу O(n); вставка после известного узла и удаление при известном prev — O(1); удаление по указателю без prev — O(n); среднее: то же; амортизированное: совпадает с худшим
|
| Память | Θ(n) узлов; на узел — ключ, один-два указателя и накладные расходы аллокатора
|
| Упорядоченность | Порядок задаётся связями; ключи могут идти в любом порядке |
| Стабильность / детерминированность | Детерминирован; адреса узлов не меняются при вставке и удалении других узлов |
| Альтернативы | Динамический массив, дека, сбалансированное дерево, хеш-таблица, unrolled list |
| Где встречается | Цепочки хеш-таблиц, free list аллокаторов, LRU-кэши, ядра ОС, LISP и функциональные языки, списки смежности |
Определения
Узел (node) — запись, хранящая ключ и ссылки на соседние узлы. В односвязном списке это пара (key, next), в двусвязном — тройка (key, prev, next).
Односвязный список (singly linked list) — конечная последовательность узлов v₁, v₂, …, vₙ, в которой next(vᵢ) = vᵢ₊₁, а next(vₙ) = NIL. Формально список — это набор (V, head, next, key), где V — конечное множество узлов, K — множество ключей, key: V → K сопоставляет узлу ключ, next: V → V ∪ {NIL}, head ∈ V ∪ {NIL}, и выполняются два условия:
- если
head = NIL, тоV = ∅; иначе существует единственная последовательностьv₁, …, vₙ, покрывающая всёV, сv₁ = headиnext(vᵢ) = vᵢ₊₁,next(vₙ) = NIL; - у каждого узла, кроме
v₁, ровно один предшественник.
Условия исключают циклы, недостижимые узлы и «раздвоение» списка.
Голова (head) — первый узел списка; для пустого списка head = NIL.
Хвост (tail) — последний узел vₙ; его next равен NIL. Отдельный указатель на хвост не обязателен, но без него вставка в конец стоит O(n).
NIL — терминатор, означающий отсутствие узла. Это не адрес и не «нулевой элемент»: NIL нельзя разыменовать, а проверка curr = NIL — единственный признак конца линейного списка.
Двусвязный список (doubly linked list) — список, в котором каждый узел хранит ещё и prev, причём prev(next(v)) = v, если v — не хвост, и next(prev(v)) = v, если v — не голова; в кольце с sentinel оба равенства верны для всех узлов. Он позволяет обходить список в обе стороны и удалять узел по указателю без поиска предшественника.
Циклический список (circular list) — список, в котором next(vₙ) = v₁; терминатор NIL не используется. Обход такого списка обязан останавливаться на стартовом узле, иначе он не завершится.
Сторожевой узел (sentinel, dummy node) — фиктивный узел nil без ключа, который замыкает список в кольцо: next(vₙ) = nil, next(nil) = v₁. Пустой список состоит из одного nil; в двусвязном варианте next(nil) = prev(nil) = nil. Sentinel убирает особые случаи «пустой список» и «операция у головы».
Длина n — число узлов с ключами; nil в длину не входит.
Позиция i — номер узла от головы; в псевдокоде позиции нумеруются 1..n, в C++ — 0..n−1.
Владение (ownership) — соглашение о том, кто и когда освобождает узел. В односвязном списке владелец — список: каждый узел освобождается ровно один раз, либо при удалении, либо в деструкторе.
Интрузивный список (intrusive list) — список, у которого поля next/prev встроены в полезную структуру, а не лежат в отдельном узле. Один объект может входить в несколько списков без отдельного узла; на каждое членство нужно своё встроенное поле.
Персистентный список (persistent list) — неизменяемый список, в котором новые версии делят хвост со старыми: добавление в голову создаёт один узел и не копирует остальные.
Обозначения. n — длина первого списка, m — второго; T — список, T.head — его голова; p, q, curr, prev, next — указатели на узлы. log — двоичный логарифм (в этой теме он почти не нужен). Стоимость считаем в элементарных операциях: чтение и запись указателя, сравнение ключей, выделение и освобождение узла.
Мотивация и интуиция
Массив хранит элементы подряд, и это даёт быстрый доступ по индексу. Но непрерывность приходится поддерживать: вставка в начало массива из n = 10⁶ элементов сдвигает все 10⁶ элементов, то есть переписывает около 4 МБ памяти при 4-байтовом int. Связный список не переписывает данные: он хранит рядом с ключом ссылку на следующий узел, и вставка после известного узла — это две записи указателей.
Вторая выгода — стабильные адреса. Узел не переезжает при вставке или удалении других узлов, поэтому указатели на элементы остаются действительными. У динамического массива реаллокация инвалидирует все указатели и итераторы; у списка — только указатель на удалённый узел.
Плата тоже двойная. Во-первых, память: на каждый узел нужен указатель (в двусвязном — два) и служебные данные аллокатора; для маленьких ключей это может быть в несколько раз больше самого ключа. Во-вторых, доступ: чтобы дойти до позиции i, нужно пройти i узлов, а каждый переход — это чтение указателя и, как правило, промах кэша. Поэтому «список быстрее массива» — неверное утверждение без уточнения: он быстрее на перестройке при известном узле и предшественнике, но медленнее на поиске, обходе и доступе по индексу.
Ключевая идея: заменить непрерывность адресов явными связями. Тогда перестройка структуры становится локальной и дешёвой, а цена переносится на навигацию и память.
Устройство
Односвязный список состоит из трёх видов объектов:
- переменная
head— указатель на первый узел илиNIL; - узлы — каждая запись содержит ключ и указатель
next; - терминатор
NIL— значение, которым заканчивается цепочка.
Дополнительно можно хранить указатель на хвост tail и длину n. Хвост ускоряет вставку в конец до O(1), но требует аккуратного обновления при удалении последнего узла; длина убирает необходимость считать узлы обходом.
Инварианты, которые обязаны выполняться после каждой операции:
head = NILтогда и только тогда, когдаn = 0;- все узлы достижимы из
headровно одним путём, циклов нет; next(vₙ) = NIL, и у каждого узла, кроме головы, ровно один предшественник;- каждый выделенный узел принадлежит списку не более одного раза и освобождается ровно один раз;
- в двусвязном списке с sentinel дополнительно
prev(next(v)) = vиnext(prev(v)) = vдля всех узлов, включаяnil.
См. рис. 1: стрелки next задают порядок 7 → 3 → 9 → 1 → 5, хотя узлы расположены в куче вразброс. Именно поэтому список не поддерживает арифметику адресов: следующий элемент находится только чтением поля next.
Операции и алгоритмы
Операция: поиск по ключу
Идея. Пройти список от головы, сравнивая ключи, и остановиться на первом совпадении. Другого способа нет: ссылки не позволяют перепрыгнуть часть списка.
Шаги.
- Поставить
currнаT.head. - Пока
curr ≠ NIL, сравнитьcurr.keyс искомым ключом. - При совпадении вернуть
curr. - Иначе перейти к
curr.next. - Если достигнут
NIL, вернутьNIL.
Инвариант. Все узлы до curr проверены; если ключ есть в списке, он есть в подсписке с головой curr.
Псевдокод.
Алгоритм Search(T, k)
Вход: T — односвязный список с головой T.head; k — ключ; позиции 1..n
Выход: указатель на первый узел с ключом k либо NIL
Инвариант: узлы до curr проверены; если k есть в T, то он есть в подсписке с головой curr
1 curr ← T.head
2 while curr ≠ NIL
3 if curr.key = k
4 return curr
5 curr ← curr.next
6 return NIL
Сложность: O(n) времени, O(1) памяти.
Корректность: при переходе curr ← curr.next инвариант сохраняется; при выходе либо
найден первый узел с ключом k, либо просмотрены все узлы и ключа нет.
Пример. В списке 4 → 8 → 15 → 16 → 23 → 42 поиск 15 делает три сравнения (4, 8, 15) и возвращает третий узел. Поиск 17 делает шесть сравнений и возвращает NIL.
Сложность. Время O(n): в худшем случае просматриваются все узлы. Память O(1): хранится только указатель curr. Среднее число сравнений при равновероятном успешном поиске — (n+1)/2, то есть Θ(n).
Крайние случаи. Пустой список: сразу NIL. Ключ в голове: одно сравнение. Дубликаты: возвращается первый; если нужен последний, список обходят до конца. Отсутствующий ключ: полный проход.
Рисунок. См. рис. 1: поиск идёт по стрелкам next от head.
Операция: вставка после узла
Идея. Если узел p уже известен, новый узел вставляется между p и p.next двумя записями указателей. Никакого сдвига элементов и обхода не требуется.
Шаги.
- Выделить новый узел
nodeи записать в него ключ. node.next ← p.next— новый узел получает преемникаp.p.next ← node—pначинает указывать на новый узел.- Увеличить длину.
Инвариант. До шага 4 новый узел ещё не достижим из head; после шага 4 он достижим, а все остальные связи не изменились.
Псевдокод.
Операция Insert-After(T, p, x)
Вход: T — список; p — узел T (p ≠ NIL); x — ключ
Выход: T с новым узлом сразу после p
Инвариант: p — узел T; новый узел ещё не в списке
1 node ← новый узел
2 node.key ← x
3 node.next ← p.next # сначала новый узел получает преемника
4 p.next ← node # только теперь p указывает на новый узел
5 T.n ← T.n + 1
Сложность: O(1) времени и памяти.
Корректность: node.next = v(k+1), p.next = node; последовательность становится
v1..vk, x, v(k+1)..vn, остальные ссылки не тронуты.
Порядок строк 3 и 4 критичен. Если сначала выполнить p.next ← node, ссылка на прежний p.next потеряется, и хвост списка станет недостижим.
Пример. Список 7 → 3 → 9 → 1 → 5, p — узел 9, вставляем 4. После node.next ← 1 новый узел ещё не в списке; после 9.next ← 4 получается 7 → 3 → 9 → 4 → 1 → 5.
Сложность. O(1) по времени и дополнительной памяти. Стоимость не зависит от n, но только если p уже известен. Поиск позиции p — отдельная операция, обычно O(i).
Крайние случаи. p — хвост: node.next получает NIL, это корректно. Пустой список: вставлять «после» нечего, нужна вставка в голову. p = NIL — ошибка; в C++ это неопределённое поведение.
Рисунок. См. рис. 2.
Операция: вставка в голову
Идея. Вставка в голову — это Insert-After с виртуальным sentinel перед первым узлом. Меняется только head.
Шаги.
- Создать узел
nodeс ключомx. node.next ← T.head.T.head ← node.- Увеличить длину.
Инвариант. Новый узел стал первым; прежний список целиком следует за ним.
Псевдокод.
Операция Push-Front(T, x)
Вход: T — список; x — ключ
Выход: T с x в голове
Инвариант: T.head указывает на первый узел или NIL
1 node ← новый узел
2 node.key ← x
3 node.next ← T.head
4 T.head ← node
5 T.n ← T.n + 1
Сложность: O(1) времени и памяти.
Корректность: новый узел указывает на прежнюю голову; все прежние узлы остаются
достижимыми в том же порядке.
Пример. Для 7 → 3 → 9 → 1 → 5 вставка 0 в голову даёт 0 → 7 → 3 → 9 → 1 → 5. Пустой список: head был NIL, становится узлом 0 с next = NIL.
Сложность. O(1). Это самая дешёвая операция списка и основа реализации стека.
Крайние случаи. Пустой список обрабатывается тем же кодом, без ветвления. Если хранится tail, при вставке в пустой список его нужно обновить.
Рисунок. Отдельной схемы нет: перестановка двух ссылок та же по аналогии с рис. 2, только p — виртуальный sentinel перед головой.
Операция: удаление
Идея. Чтобы удалить узел, нужно перенаправить ссылку предшественника через него. В односвязном списке предшественника приходится искать, поэтому удаление по ключу стоит O(n); если указатель на предшественника уже есть, удаление стоит O(1).
Шаги для удаления по ключу.
- Идти по списку, храня
prev— узел передcurr. - Остановиться, когда
curr = NILилиcurr.key = k. - Если
curr = NIL, ключа нет — вернутьfalse. - Если
prev = NIL, удаляется голова:head ← curr.next. - Иначе
prev.next ← curr.next. - Освободить
currи уменьшить длину.
Инвариант. Если curr ≠ NIL, то prev — его предшественник (prev = NIL, если curr — голова); все узлы до curr проверены и не совпали с ключом.
Псевдокод.
Операция Delete(T, k)
Вход: T — список; k — ключ
Выход: true, если узел удалён; false, если ключа нет
Инвариант: если curr ≠ NIL, то prev — его предшественник (prev = NIL, если curr — голова)
1 prev ← NIL
2 curr ← T.head
3 while curr ≠ NIL and curr.key ≠ k
4 prev ← curr
5 curr ← curr.next
6 if curr = NIL
7 return false
8 if prev = NIL
9 T.head ← curr.next # удаление головы — особый случай
10 else
11 prev.next ← curr.next
12 освободить curr
13 T.n ← T.n − 1
14 return true
Сложность: O(n) времени, O(1) памяти.
Корректность: при выходе curr — первый узел с ключом k; ссылка prev.next или head
переводится через curr, поэтому остальные узлы сохраняют порядок.
Удаление после узла. Если p известен, удаляемый узел q = p.next исключается за O(1).
Операция Delete-After(T, p)
Вход: T — список; p — узел T (p ≠ NIL)
Выход: T без узла, следующего за p; ошибка, если p — хвост
Инвариант: q = p.next; если q ≠ NIL, то q — удаляемый узел
1 q ← p.next
2 if q = NIL
3 error "после p нет узла"
4 p.next ← q.next
5 освободить q
6 T.n ← T.n − 1
Сложность: O(1) времени и памяти.
Корректность: p.next переводится на q.next; остальные ссылки не меняются.
Это та же операция, что и вставка, только в обратную сторону.
Пример. Список 7 → 3 → 9 → 1 → 5, удаляем 9. Обход: prev = 7, curr = 3; затем prev = 3, curr = 9 — совпадение. Так как prev ≠ NIL, выполняется 3.next ← 1; получается 7 → 3 → 1 → 5. Если затем удалить голову 7 из уже полученного списка, обход сразу даёт prev = NIL, и меняется head: head ← 3, результат 3 → 1 → 5.
Сложность. Время O(n): доминирует поиск. Сама перестройка — две-три операции, O(1). Память O(1). Если известен prev, удаление O(1); если известен только указатель на удаляемый узел в односвязном списке, предшественника всё равно нужно искать, поэтому O(n).
Крайние случаи. Единственный узел: head становится NIL. Удаление хвоста: prev.next получает NIL. Удаление отсутствующего ключа: полный проход и false. Дубликаты: удаляется первый. Удаление NIL — ошибка.
Приём «копирование следующего». Иногда предлагают удалить узел x за O(1) без предшественника: скопировать ключ из x.next в x и удалить x.next. Приём работает не всегда: для последнего узла он невозможен, а личность узла подменяется — внешний указатель на удаляемый узел продолжает существовать, но видит уже другой ключ, а указатель на x.next становится висячим. В обычном списке с владением этот приём применять не следует.
Рисунок. См. рис. 3.
Операция: двусвязный список с sentinel
Идея. В двусвязном списке у каждого узла есть prev, поэтому удаление по указателю не требует поиска предшественника. Sentinel nil замыкает список в кольцо и убирает отдельные случаи для головы, хвоста и пустого списка.
Шаги удаления узла x.
x.prev.next ← x.next— предшественник перешагивает черезx.x.next.prev ← x.prev— преемник получает нового предшественника.- Освободить
x, уменьшить длину.
Инвариант. x.prev.next = x и x.next.prev = x; после двух записей кольцо снова согласовано, а nil остаётся достижимым с обеих сторон.
Псевдокод.
Операция Delete-Node(L, x)
Вход: L — двусвязный список с sentinel L.nil; x — узел данных (x ≠ L.nil)
Выход: L без узла x
Инвариант: x.prev.next = x и x.next.prev = x
1 x.prev.next ← x.next
2 x.next.prev ← x.prev
3 освободить x
4 L.n ← L.n − 1
Сложность: O(1) времени и памяти.
Корректность: обе ссылки, ведущие к x, переводятся на его соседей; порядок остальных
узлов не меняется, sentinel не участвует в особых случаях.
Для сравнения, вставка после узла p требует четырёх записей: node.prev ← p, node.next ← p.next, p.next.prev ← node, p.next ← node. Порядок снова важен: строку p.next ← node выполняют последней, иначе прежний p.next потеряется.
Пример. Кольцо nil ↔ 7 ↔ 3 ↔ 9 ↔ nil. Удаляем узел 9: 3.next ← nil и nil.prev ← 3; получается nil ↔ 7 ↔ 3 ↔ nil. Если удалять 7 (первый узел данных), отдельного случая нет: nil.next ← 3 и 3.prev ← nil.
Сложность. Удаление и вставка при известном узле — O(1). Память на узел больше: два указателя вместо одного. Обход — O(n) в любую сторону.
Крайние случаи. Пустой список: nil.next = nil.prev = nil, удалять нечего. Один узел данных: обе ссылки nil указывают на него, после удаления снова замыкаются на nil. Попытка удалить nil — ошибка.
Рисунок. См. рис. 4.
Операция: обход
Идея. Обход — базовая операция, на которой строятся поиск, печать, подсчёт и преобразование списка. Он последовательно посещает каждый узел ровно один раз.
Шаги.
curr ← T.head.- Пока
curr ≠ NIL, обработатьcurr.key. - Перейти к
curr.next.
Инвариант. Посещены ровно узлы v₁..v_{i−1}, а curr указывает на vᵢ.
Псевдокод.
Алгоритм Traverse(T, visit)
Вход: T — список; visit — процедура обработки ключа
Выход: visit вызвана для каждого узла ровно один раз, в порядке списка
Инвариант: посещены ровно узлы v1..v(i−1), curr = vi
1 curr ← T.head
2 while curr ≠ NIL
3 visit(curr.key)
4 curr ← curr.next
Сложность: O(n) времени, O(1) памяти сверх visit.
Корректность: индукция по i; ацикличность гарантирует, что через n шагов curr = NIL.
Пример. Обход 7 → 3 → 9 → 1 → 5 вызывает visit пять раз с аргументами 7, 3, 9, 1, 5.
Сложность. Θ(n) — меньше нельзя, если нужно увидеть все элементы. Память O(1) у итеративного варианта; рекурсивный обход занимает O(n) стека.
Крайние случаи. Пустой список: ноль вызовов. Циклический список: условие curr ≠ NIL всегда истинно, поэтому цикл не завершается; нужен счётчик n или остановка на стартовом узле. Мутация списка во время обхода — отдельная проблема: удаление curr без сохранения next обрывает обход.
Рисунок. См. рис. 1.
Операция: разворот
Идея. Пройти список один раз, на каждом шаге разворачивая ссылку curr.next назад. Три указателя prev, curr, next позволяют не потерять остаток списка.
Шаги.
prev ← NIL,curr ← T.head.- Пока
curr ≠ NIL: сохранитьnext ← curr.next. - Направить
curr.next ← prev. - Сдвинуть
prev ← curr,curr ← next. - После цикла
T.head ← prev.
Инвариант. prev — голова уже развёрнутого префикса v_{i−1} → … → v₁ → NIL (при i = 1 префикс пуст и prev = NIL), curr — первый неразвёрнутый узел vᵢ; связи от curr вперёд ещё не изменены.
Псевдокод.
Операция Reverse(T)
Вход: T — односвязный список
Выход: T, развёрнутый в обратном порядке
Инвариант: префикс до prev развёрнут; curr — первый неразвёрнутый узел
1 prev ← NIL
2 curr ← T.head
3 while curr ≠ NIL
4 next ← curr.next # сохранить остаток до перезаписи
5 curr.next ← prev
6 prev ← curr
7 curr ← next
8 T.head ← prev
Сложность: O(n) времени, O(1) памяти.
Корректность: каждая итерация добавляет один узел к развёрнутому префиксу; после n
итераций prev указывает на прежний хвост, curr = NIL.
Пример. 1 → 2 → 3 → 4 → 5 → 6 после разворота становится 6 → 5 → 4 → 3 → 2 → 1; прежняя голова 1 становится хвостом с next = NIL. Пошаговое состояние — в таблице раздела «Трассировка примера».
Сложность. O(n) времени, O(1) дополнительной памяти. Рекурсивный вариант тоже O(n) по времени, но требует O(n) стека.
Крайние случаи. Пустой список: цикл не выполняется, head остаётся NIL. Один узел: next = NIL, curr.next ← NIL, head не меняется по существу. Два узла: после первой итерации prev = 1, curr = 2; после второй — 2 → 1.
Рисунок. См. рис. 6 в разделе «Трассировка примера».
Операция: слияние двух отсортированных списков
Идея. Идти по двум спискам двумя указателями и каждый раз присоединять меньший из голов к результату. Узлы не копируются: меняются только ссылки, поэтому дополнительная память — O(1).
Шаги.
- Завести
headиtailрезультата, пустые. - Сравнить головы
aиb. - Присоединить меньшую к хвосту результата и сдвинуть соответствующий указатель.
- Когда один список исчерпан, присоединить остаток второго целиком.
Инвариант. Цепочка от head до tail — отсортированный результат слияния уже просмотренных префиксов; a и b — головы необработанных остатков.
Псевдокод.
Операция Merge-Sorted(T1, T2)
Вход: T1, T2 — списки, отсортированные по неубыванию
Выход: голова списка из тех же узлов, отсортированного по неубыванию
Инвариант: результат отсортирован и содержит ровно просмотренные узлы
1 head ← NIL; tail ← NIL
2 a ← T1.head; b ← T2.head
3 while a ≠ NIL and b ≠ NIL
4 if b.key < a.key # при равенстве берём a — устойчивость
5 node ← b; b ← b.next
6 else
7 node ← a; a ← a.next
8 node.next ← NIL
9 if head = NIL
10 head ← node
11 else
12 tail.next ← node
13 tail ← node
14 rest ← a
15 if rest = NIL
16 rest ← b
17 if head = NIL
18 head ← rest
19 else
20 tail.next ← rest
21 return head
Сложность: O(n + m) времени, O(1) дополнительной памяти.
Корректность: на каждом шаге в результат попадает минимальная из двух голов;
остаток одного списка уже отсортирован и не меньше последнего добавленного.
Пример. A = 1, 4, 7, B = 2, 4, 8. Сравнения: 1 из A, 2 из B, 4 из A (при равенстве берётся A), 4 из B, 7 из A, 8 из B. Результат: 1, 2, 4, 4, 7, 8. Первая четвёрка — из A, вторая — из B: слияние устойчиво.
Сложность. O(n + m) времени: на каждом шаге один узел покидает входной список. Дополнительная память O(1) — только указатели; новые узлы не выделяются. Если реализовывать слияние копированием, память станет O(n + m).
Крайние случаи. Один список пуст: результат — второй список. Оба пусты: NIL. Одинаковые ключи: при строгом сравнении b.key < a.key элементы A идут раньше, что сохраняет их взаимный порядок. Списки с разной длиной: остаток присоединяется за O(1).
Рисунок. Отдельной схемы нет: каждая итерация — одна перестановка ссылки, по аналогии с рис. 2.
Сводная таблица сложности
| Операция | Худшее | Среднее | Амортизированное | Память |
|---|---|---|---|---|
| Поиск по ключу | O(n)
|
O(n)
|
— | O(1)
|
| Вставка в голову | O(1)
|
O(1)
|
— | O(1)
|
Вставка после узла p
|
O(1)
|
O(1)
|
— | O(1)
|
Вставка в позицию i
|
O(i)
|
O(n)
|
— | O(1)
|
| Удаление головы | O(1)
|
O(1)
|
— | O(1)
|
Удаление после узла p
|
O(1)
|
O(1)
|
— | O(1)
|
Удаление по указателю (с prev)
|
O(1)
|
O(1)
|
— | O(1)
|
Удаление по указателю (без prev)
|
O(n)
|
O(n)
|
— | O(1)
|
| Удаление по ключу | O(n)
|
O(n)
|
— | O(1)
|
| Обход | O(n)
|
O(n)
|
— | O(1)
|
| Разворот | O(n)
|
O(n)
|
— | O(1)
|
Слияние n и m
|
O(n + m)
|
O(n + m)
|
— | O(1)
|
| Память контейнера | — | — | — | Θ(n) узлов
|
Примечание. У списка нет «скрытой» дорогой работы, которую можно разложить на много операций, поэтому амортизированная оценка совпадает с худшей, а столбец «амортизированное» заполнен прочерками. Это отличает список от динамического массива, где push_back амортизированно O(1) при O(n) в худшем случае.
Почему асимптотика не всё решает
O(1) и O(n) описывают рост, но не константы. Обход массива читает соседние элементы: на типичной x86-64 линия кэша — 64 байта, и в неё помещается 16 последовательных int. Аппаратный префетчер замечает последовательный доступ и подтягивает следующие линии заранее.
У списка каждый переход curr ← curr.next зависит от предыдущего: пока не прочитан текущий узел, адрес следующего неизвестен. Узлы выделялись в разное время и могут лежать в разных линиях; в худшем случае — по одной линии на узел. Это не меняет O(n), но увеличивает константу в разы, а иногда и на порядок.
Отсюда практическое правило: если операция — обход или поиск, массив почти всегда быстрее; если операция — вставка и удаление в известную позицию, список может выиграть, но только когда эти операции действительно часты. Подробнее о массивах — динамический-массив.
Трассировка примера
Развернём список 1 → 2 → 3 → 4 → 5 → 6. В таблице — состояние после каждого шага; next сохраняется в начале итерации, до перезаписи curr.next.
| Шаг | prev
|
curr
|
Развёрнутый префикс | Неразвёрнутый остаток |
|---|---|---|---|---|
| 0 | NIL
|
1 | — | 1 → 2 → 3 → 4 → 5 → 6
|
| 1 | 1 | 2 | 1 → NIL
|
2 → 3 → 4 → 5 → 6
|
| 2 | 2 | 3 | 2 → 1 → NIL
|
3 → 4 → 5 → 6
|
| 3 | 3 | 4 | 3 → 2 → 1 → NIL
|
4 → 5 → 6
|
| 4 | 4 | 5 | 4 → 3 → 2 → 1 → NIL
|
5 → 6
|
| 5 | 5 | 6 | 5 → 4 → 3 → 2 → 1 → NIL
|
6 → NIL
|
| 6 | 6 | NIL
|
6 → 5 → 4 → 3 → 2 → 1 → NIL
|
— |
После шага 6 head ← prev = 6. Прежняя голова 1 стала хвостом, её next равен NIL — именно это и означает «развёрнутый список».
Корректность и оценка сложности
Лемма 1 (инвариант обхода). Перед i-й итерацией Traverse указатель curr указывает на vᵢ, а посещены ровно v₁..v_{i−1}.
Доказательство. Индукция по i. База: i = 1, curr = head = v₁, посещённых узлов нет. Шаг: обрабатывается vᵢ, затем curr ← next(vᵢ) = v_{i+1}; множество посещённых становится v₁..vᵢ. Так как список ацикличен и конечен, после n шагов curr = NIL, и цикл завершается. [math]\displaystyle{ \blacksquare }[/math]
Лемма 2 (корректность вставки). После Insert-After(T, p, x), где p = v_k, последовательность списка равна v₁, …, v_k, x, v_{k+1}, …, vₙ.
Доказательство. Новый узел получает next = p.next = v_{k+1} (или NIL, если p — хвост), затем p.next начинает указывать на новый узел. Ссылки остальных узлов не меняются, поэтому единственный путь из head проходит через v_k → x → v_{k+1}. [math]\displaystyle{ \blacksquare }[/math]
Лемма 3 (корректность удаления). Delete(T, k) удаляет первый узел с ключом k и не меняет порядок остальных; при отсутствии ключа список не меняется.
Доказательство. Инвариант цикла: prev — предшественник curr. При выходе либо curr = NIL (просмотрены все узлы), либо curr.key = k. В первом случае возвращается false. Во втором ссылка prev.next (или head, если prev = NIL) переводится на curr.next; остальные ссылки не тронуты, поэтому все узлы, кроме curr, сохраняют порядок. [math]\displaystyle{ \blacksquare }[/math]
Теорема 4 (корректность разворота). После Reverse(T) список содержит те же узлы в обратном порядке, а head указывает на прежний хвост.
Доказательство. Инвариант: перед i-й итерацией префикс v₁..v_{i−1} развёрнут в цепочку v_{i−1} → … → v₁ → NIL (при i = 1 префикс пуст, prev = NIL), prev указывает на v_{i−1}, curr — на vᵢ, а связи от vᵢ вперёд не изменены. Итерация сохраняет next = v_{i+1}, затем направляет vᵢ.next на prev, то есть на v_{i−1}; теперь развёрнутый префикс — vᵢ → … → v₁, prev = vᵢ, curr = v_{i+1}. Инвариант восстановлен. После n итераций prev = vₙ, curr = NIL, и head ← vₙ. [math]\displaystyle{ \blacksquare }[/math]
Теорема 5 (нижняя граница поиска и удаления по ключу). В худшем случае любой детерминированный алгоритм поиска или удаления по ключу в неупорядоченном односвязном списке делает Ω(n) сравнений ключей.
Доказательство. В односвязном списке доступна только операция next, а признак конца — NIL. Чтобы корректно вернуть NIL при отсутствии ключа, алгоритм обязан дойти до NIL; это требует ровно n переходов от head и n сравнений. Если алгоритм остановился раньше, он не прочитал next текущего узла и не может отличить отсутствие ключа от ключа в непросмотренном хвосте. Для удаления дополнительно нужно найти узел и его предшественника, что также требует Ω(n). [math]\displaystyle{ \blacksquare }[/math]
Теорема 6 (слияние). Merge-Sorted возвращает отсортированный список из n + m узлов за O(n + m) времени и O(1) дополнительной памяти.
Доказательство. Инвариант: результат отсортирован и состоит ровно из просмотренных узлов. На каждом шаге сравниваются головы остатков; меньшая (при равенстве — из первого списка) присоединяется к хвосту и не может быть больше ранее добавленных, потому что оба остатка отсортированы. Когда один остаток исчерпан, второй уже отсортирован и его элементы не меньше последнего добавленного, поэтому присоединение целого остатка сохраняет порядок. Каждая итерация удаляет один узел из входов, всего итераций не больше n + m. [math]\displaystyle{ \blacksquare }[/math]
Рекуррентности. Рекурсивный разворот описывается как T(n) = T(n−1) + Θ(1), T(0) = Θ(1). Подстановка даёт T(n) = T(0) + n·Θ(1) = Θ(n). Для слияния сцепкой цикл делает не больше n + m − 1 итераций (для n, m ≥ 1): каждая итерация поглощает один узел, а цикл завершается, когда один из списков опустел. Поэтому T(n, m) = O(n + m), а при чередовании ключей достигается Θ(n + m). В виде рекуррентности для верхней оценки: T(n, m) ≤ max(T(n−1, m), T(n, m−1)) + Θ(1), базы T(0, m) = T(n, 0) = Θ(1) — остаток присоединяется одной ссылкой. Если остаток копировать, база становится T(0, m) = Θ(m), и тогда T(n, m) = Θ(n + m). Итеративные варианты из псевдокода имеют ту же оценку времени и O(1) дополнительной памяти, поэтому предпочтительнее там, где важна глубина стека.
Память. Каждый узел хранит ключ и один указатель (два в двусвязном списке), то есть O(1) слов на элемент, суммарно Θ(n). Нижняя граница Ω(n) следует из того, что n различных ключей нужно где-то хранить. Для int на 64-битной платформе узел односвязного списка обычно занимает 16 байт (4 байта ключа, 8 байт указателя, 4 байта выравнивания), двусвязного — 24 байта; массив хранит те же ключи по 4 байта плюс запас ёмкости. Накладные расходы аллокатора на каждый узел в эту оценку не входят и могут быть заметными.
Сравнение с альтернативами
| Вариант | Доступ по индексу | Вставка после узла / удаление при prev
|
Вставка в голову | Удаление по указателю | Обход | Память на элемент | Когда выбирать |
|---|---|---|---|---|---|---|---|
| Динамический массив | O(1)
|
O(n)
|
O(n)
|
O(n)
|
быстрый, локальный | ключ + запас ёмкости | частый доступ по индексу и обход |
| Односвязный | O(i)
|
O(1) после узла
|
O(1)
|
O(n) без prev
|
медленный | ключ + 1 указатель | вставки после известного узла, стек, персистентность |
| Двусвязный | O(i)
|
O(1)
|
O(1)
|
O(1)
|
медленный, в обе стороны | ключ + 2 указателя | удаление по указателю, LRU, дека |
| Циклический | O(i)
|
O(1)
|
O(1)
|
O(1) с prev
|
медленный, без NIL
|
ключ + 1–2 указателя | round-robin, кольцевые буферы |
| Двусвязный с sentinel | O(i)
|
O(1)
|
O(1)
|
O(1)
|
медленный | ключ + указатели (+1 узел на список) | убрать особые случаи головы и пустого списка |
Вердикт. Динамический массив — выбор по умолчанию, если нужен доступ по индексу, обход или сортировка. Односвязный список оправдан, когда вставки и удаления происходят в уже найденной позиции, а память под указатель не критична; он же естественен для функциональных языков и стека. Двусвязный список нужен, когда требуется удаление по указателю за O(1) или обход в обе стороны. Циклический вариант удобен для структур, в которых нет естественного конца. Sentinel не меняет асимптотику, но уменьшает число ветвлений и ошибок.
Кэш-локальность (рис. 5) часто важнее асимптотики: обход массива из 10⁶ int и обход списка из 10⁶ узлов имеют одинаковую O(n)-оценку, но различаются по константе в разы. Поэтому «список вместо массива» — не оптимизация по умолчанию, а осознанный выбор в пользу перестройки.
Где применяется
- Цепочки в хеш-таблицах. Каждый бакет — связный список элементов с одинаковым хешем. Вставка в голову бакета —
O(1), поиск —Θ(1 + α)при равномерном хешировании и коэффициенте заполненияα, удаление при известном узле —O(1)в двусвязном варианте. Список удобен тем, что размер бакета заранее неизвестен, а перестраивать таблицу при каждой вставке не нужно[1]. - Free list в аллокаторах памяти. Свободные блоки кучи связываются в список: выделение — изъятие головы, освобождение — вставка в голову, обе операции
O(1). Такой список не требует отдельной памяти под узлы, если хранить ссылку внутри самого свободного блока[3]. - LRU-кэш. Хеш-таблица
ключ → узелплюс двусвязный список: при обращении узел переносится в голову заO(1), при переполнении удаляется хвост заO(1). Без двусвязного списка пришлось бы искать предшественника хвоста заO(n)[6]. - Ядро ОС и драйверы. Двусвязные интрузивные списки (
list_headв ядре Linux) связывают задачи, устройства и буферы. Интрузивность позволяет одному объекту находиться в нескольких списках одновременно и не выделять память под членство; удаление по указателю —O(1)[5]. - Функциональные и персистентные списки. В LISP, Scheme и Haskell список строится из cons-ячеек: добавление в голову —
O(1)и не копирует хвост, поэтому старые версии остаются доступными. Это делает односвязный список естественной структурой для неизменяемых данных[5]. - Списки смежности графов. Граф с
nвершинами иmрёбрами хранится как массив изnсписков: памятьΘ(n + m), перебор соседей вершины —O(deg(v)). Для разреженных графов это экономнее матрицы смежности[1]. - Стандартные библиотеки.
std::list— двусвязный список,std::forward_list— односвязный;std::list::spliceиstd::forward_list::splice_afterпереставляют один узел или целый список заO(1)и не инвалидируют указатели на остальные элементы. Плата — отсутствие доступа по индексу и дополнительная память[4].
Историческая справка
- 1953. Ханс Петер Лун (Hans Peter Luhn) предложил использовать связные списки в цепочках хеш-таблиц во внутренней записке IBM
[5]. - 1955–1956. Аллен Ньюэлл, Клифф Шоу и Герберт Саймон в RAND Corporation разработали связный список как основную структуру языка IPL (Information Processing Language) для задач искусственного интеллекта, включая Logic Theory Machine и General Problem Solver. Отчёты появились в IRE Transactions on Information Theory в 1956 году
[5]. - 1957. В работе «Programming the Logic Theory Machine» (Proc. WJCC, февраль 1957) появилась классическая схема из блоков-узлов и стрелок к следующим узлам
[5]. - 1958–1960. Джон Маккарти создал LISP, где список из cons-ячеек стал основной структурой данных; описание опубликовано в Communications of the ACM в 1960 году
[5]. - 1968. Дональд Кнут систематизировал связное распределение памяти в TAOCP, том 1, §2.2.5 «Linked Allocation»
[3]. - 1975. Ньюэлл и Саймон получили премию Тьюринга, в том числе за вклад в list processing
[5]. - 1998–2011.
std::listвходит в STL C++ с C++98,std::forward_list— с C++11[4].
Название «linked list» подчёркивает главное: элементы связаны ссылками, а не следуют друг за другом в памяти.
Типичные ошибки и подводные камни
- Утечка при удалении. Ссылка переставлена, а узел не освобождён. В C++ это утечка; при большом числе операций память растёт линейно. Лечится парой «переставить ссылку — освободить узел» в одном месте.
- Двойное освобождение. Два указателя считают себя владельцами узла. Причина — поверхностное копирование списка или передача указателя без передачи владения. Решение — Rule of Five и явное владение.
- Висячий указатель. После
deleteузел нельзя читать, но внешний указатель на него остаётся. Список не может это обнаружить; помогает дисциплина «не хранить указатели на удалённые узлы». - Потеря остатка при вставке. Если выполнить
p.next ← nodeдоnode.next ← p.next, прежний хвост теряется. Порядок строк 3–4 вInsert-Afterобязателен. - Освобождение до сохранения
next. Если выполнитьdelete currраньше, чемnext ← curr.next, следующий узел уже не найти, а чтение поля освобождённого узла — неопределённое поведение. Сначала сохранитеnext, затем освобождайте. - Вставка в конец без
tail. Каждая такая вставка —O(n); построение списка изnэлементов превращается вO(n²). Хранитеtailили стройте список вставками в голову и разворачивайте в конце. - Копирование указателя вместо узла.
b = aдля указателя создаёт второго «владельца» того же списка; для независимой копии нужны новые узлы. - Бесконечный обход циклического списка. Условие
curr ≠ NILвсегда истинно, поэтому цикл не завершается. Нужен счётчикnили остановка на стартовом узле; для проверки цикла — алгоритм Флойда с двумя указателями. - Удаление последнего узла «копированием следующего». Приём не работает для хвоста, а для среднего узла подменяет ключ в узле: внешние указатели на удалённый ключ начинают видеть другой ключ, хотя адрес узла не меняется.
- Рекурсия на большом списке. Рекурсивный обход или разворот на
10⁵–10⁶узлах исчерпывает стек. Итеративный вариант используетO(1)памяти. - Вывод «список быстрее массива». Верен только для вставки после известного узла и удаления при известном
prev(в двусвязном — по указателю). На поиске и обходе список проигрывает из-за локальности (рис. 5).
Задачи для практики
- Разворот. Реализуйте разворот односвязного списка итеративно и рекурсивно. Ограничения:
O(n)времени. Подсказка: сохраняйтеnextдо перезаписи. Ответ: итеративноO(1)памяти, рекурсивноO(n)стека. - Середина. Найдите средний узел за один проход. Ограничения:
O(1)памяти. Подсказка: медленный указатель на один шаг, быстрый на два. Ответ: при чётномnмедленный остановится на втором из двух средних;O(n)времени. - Цикл. Определите, есть ли в списке цикл, и найдите его начало. Ограничения:
O(1)памяти. Подсказка: алгоритм Флойда; после встречи один указатель верните в голову. Ответ:O(n)времени,O(1)памяти. k-й с конца. Найдитеk-й узел с конца за один проход. Ограничения:k ≤ n,O(1)памяти. Подсказка: два указателя с зазоромk. Ответ:O(n).- Палиндром. Проверьте, читается ли список одинаково в обе стороны. Ограничения:
O(n)времени,O(1)памяти. Подсказка: найдите середину, разверните вторую половину, сравните, восстановите список. Ответ:O(n)времени,O(1)памяти. - Слияние. Слейте два отсортированных списка, не создавая новых узлов. Ограничения:
O(1)дополнительной памяти. Подсказка: сравнение голов и перестановка ссылок. Ответ:O(n + m); при равенстве берите элемент из первого списка — слияние устойчиво. - Двусвязный список. Реализуйте кольцо с sentinel и удаление по указателю за
O(1). Ограничения: без особых случаев для головы. Подсказка:x.prev.next ← x.nextиx.next.prev ← x.prev. Ответ: проверьте инвариант после случайной серии вставок и удалений. - Нижняя граница. Докажите, что удаление по ключу в неупорядоченном списке требует
Ω(n)в худшем случае. Подсказка: два входа, неразличимые для алгоритма, который просмотрел не все узлы.
Ответы (кратко). 1. Итеративно — три указателя; рекурсивно — стек O(n). 2. Медленный и быстрый указатели. 3. Флойд: встреча, затем синхронный шаг от головы и от точки встречи. 4. Зазор k. 5. Середина + разворот половины. 6. Сцепка узлов. 7. Sentinel убирает ветвления. 8. Аргумент противника из теоремы 5.
Реализация на C++
Учебная реализация SinglyLinkedList с владением. Класс следует Rule of Five: деструктор, копирующий конструктор, копирующее присваивание (copy-and-swap), перемещающий конструктор и перемещающее присваивание. Копия глубокая, перемещение передаёт владение и оставляет источник пустым. Тест проверяет вставку, удаление головы и середины, разворот, копирование, перемещение и слияние.
#include <cassert>
#include <cstddef>
#include <initializer_list>
#include <iostream>
#include <utility>
#include <vector>
template <class T>
class SinglyLinkedList {
struct Node {
T value;
Node* next;
explicit Node(const T& v, Node* n = nullptr) : value(v), next(n) {}
explicit Node(T&& v, Node* n = nullptr) : value(std::move(v)), next(n) {}
};
Node* head_ = nullptr;
std::size_t size_ = 0;
public:
SinglyLinkedList() noexcept = default;
~SinglyLinkedList() { clear(); }
// Копирование: глубокая копия узлов.
SinglyLinkedList(const SinglyLinkedList& other) {
Node** tail = &head_;
try {
for (Node* p = other.head_; p != nullptr; p = p->next) {
*tail = new Node(p->value);
tail = &((*tail)->next);
++size_;
}
} catch (...) {
clear();
throw;
}
}
SinglyLinkedList& operator=(const SinglyLinkedList& other) {
if (this != &other) {
SinglyLinkedList tmp(other); // copy-and-swap
swap(tmp);
}
return *this;
}
// Перемещение: владение переходит, источник становится пустым.
SinglyLinkedList(SinglyLinkedList&& other) noexcept
: head_(other.head_), size_(other.size_) {
other.head_ = nullptr;
other.size_ = 0;
}
SinglyLinkedList& operator=(SinglyLinkedList&& other) noexcept {
if (this != &other) {
clear();
head_ = other.head_;
size_ = other.size_;
other.head_ = nullptr;
other.size_ = 0;
}
return *this;
}
void swap(SinglyLinkedList& other) noexcept {
std::swap(head_, other.head_);
std::swap(size_, other.size_);
}
bool empty() const noexcept { return head_ == nullptr; }
std::size_t size() const noexcept { return size_; }
void clear() noexcept {
Node* p = head_;
while (p != nullptr) {
Node* next = p->next;
delete p;
p = next;
}
head_ = nullptr;
size_ = 0;
}
void push_front(const T& x) {
head_ = new Node(x, head_);
++size_;
}
void push_back(const T& x) { // O(n) без указателя на хвост
Node** link = &head_;
while (*link != nullptr) link = &((*link)->next);
*link = new Node(x);
++size_;
}
bool contains(const T& x) const {
for (Node* p = head_; p != nullptr; p = p->next)
if (p->value == x) return true;
return false;
}
bool erase(const T& x) {
Node** link = &head_; // ссылка на поле, ведущее к curr
while (*link != nullptr && !((*link)->value == x))
link = &((*link)->next);
if (*link == nullptr) return false;
Node* victim = *link;
*link = victim->next;
delete victim;
--size_;
return true;
}
void reverse() noexcept {
Node* prev = nullptr;
Node* curr = head_;
while (curr != nullptr) {
Node* next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
head_ = prev;
}
// предусловие: &other != this; слияние сцепкой узлов
void merge_sorted(SinglyLinkedList&& other) {
Node* a = head_;
Node* b = other.head_;
Node* merged = nullptr;
Node** tail = &merged;
while (a != nullptr && b != nullptr) {
Node* node;
if (b->value < a->value) { node = b; b = b->next; }
else { node = a; a = a->next; } // при равенстве — из *this
node->next = nullptr;
*tail = node;
tail = &(node->next);
}
*tail = (a != nullptr) ? a : b;
head_ = merged;
size_ += other.size_;
other.head_ = nullptr;
other.size_ = 0;
}
std::vector<T> to_vector() const {
std::vector<T> out;
out.reserve(size_);
for (Node* p = head_; p != nullptr; p = p->next)
out.push_back(p->value);
return out;
}
};
int main() {
SinglyLinkedList<int> a;
for (int x : {7, 3, 9, 1, 5}) a.push_back(x);
assert(a.size() == 5);
assert((a.to_vector() == std::vector<int>{7, 3, 9, 1, 5}));
a.push_front(0);
assert(a.size() == 6);
assert(a.to_vector().front() == 0);
assert(a.erase(9));
assert((a.to_vector() == std::vector<int>{0, 7, 3, 1, 5}));
assert(a.erase(0)); // удаление головы
assert((a.to_vector() == std::vector<int>{7, 3, 1, 5}));
assert(!a.erase(42)); // ключа нет
assert(a.size() == 4);
a.reverse();
assert((a.to_vector() == std::vector<int>{5, 1, 3, 7}));
SinglyLinkedList<int> b = a; // копирование
b.push_front(100);
assert(a.size() == 4 && b.size() == 5); // списки независимы
assert(a.to_vector().front() == 5);
SinglyLinkedList<int> d;
d = a; // копирующее присваивание
assert(d.size() == 4);
d.erase(7);
assert(a.size() == 4 && d.size() == 3);
SinglyLinkedList<int> c = std::move(b); // перемещение
assert(b.size() == 0 && b.to_vector().empty());
assert(c.size() == 5);
b = std::move(c); // перемещающее присваивание
assert(b.size() == 5 && c.size() == 0);
SinglyLinkedList<int> s1, s2;
for (int x : {1, 4, 7}) s1.push_back(x);
for (int x : {2, 4, 8}) s2.push_back(x);
s1.merge_sorted(std::move(s2));
assert((s1.to_vector() == std::vector<int>{1, 2, 4, 4, 7, 8}));
assert(s2.size() == 0);
SinglyLinkedList<int> empty;
empty.reverse();
assert(empty.size() == 0);
assert(!empty.erase(1));
assert(empty.to_vector().empty());
std::cout << "ok\n";
return 0;
}
Замечания по реализации.
eraseиспользуетNode**: указатель наhead_или на полеnextпредшественника; отдельный случай головы не нужен.merge_sortedперецепляет существующие узлы и не выделяет память; после вызоваotherпуст, а его узлы принадлежат результату. Это соответствует оценкеO(1)дополнительной памяти из псевдокода.- Копирующий конструктор освобождает частично построенный список при любом исключении; сильная гарантия для копирования достигнута ценой
try/catch. push_backнамеренно оставленO(n), чтобы показать разницу сpush_front; в production-коде хранятtailили берут контейнер с дешёвой вставкой в конец (std::list,std::vector).- Перегрузки с
T&&иemplace_frontопущены для краткости.
Связанные темы
- Динамический массив — альтернатива с непрерывным буфером, доступом
O(1)и амортизированной вставкой в конец. - Асимптотическая сложность —
O,Ω,Θи нижние границы, использованные в доказательствах. - Амортизационный анализ — почему амортизация не даёт списку выигрыша, а массиву даёт.
- Хеш-таблица — цепочки как основной способ разрешения коллизий.
- Дека — двусвязный список блоков и операции с двух концов.
- Стек — односвязный список с вставкой и удалением в голове.
- Очередь — список с указателем на хвост.
- Графы — списки смежности как представление разреженного графа.
Источники
- Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. Introduction to Algorithms (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 10 «Elementary Data Structures», §10.2 «Linked lists»; гл. 11 «Hash Tables»; гл. 22 «Elementary Graph Algorithms», §22.1.
- Sedgewick R., Wayne K. Algorithms. 4th ed. — Addison-Wesley, 2011. — §1.3 «Bags, Queues, and Stacks» (связные реализации).
- Knuth D. E. The Art of Computer Programming. Vol. 1: Fundamental Algorithms. 3rd ed. — Addison-Wesley, 1997. — §2.2.5 «Linked Allocation».
- cppreference.com:
std::list. URL: https://en.cppreference.com/w/cpp/container/list;std::forward_list. URL: https://en.cppreference.com/w/cpp/container/forward_list. - Wikipedia: Linked list. URL: https://en.wikipedia.org/wiki/Linked_list.
- LeetCode 146: LRU Cache. URL: https://leetcode.com/problems/lru-cache/.