Связные списки
Устройство
Связный список — цепочка узлов: каждый хранит значение и ссылку на следующий. Узлы лежат в памяти где угодно, в отличие от массива.
class ListNode:
def __init__(self, val, next=None):
self.val = val
self.next = next
def __repr__(self):
return f"ListNode({self.val})"
head = ListNode(1, ListNode(2, ListNode(3))) # 1 → 2 → 3 → None
node = head
while node: # обход — O(n)
print(node.val, "→", node.next)
node = node.next
Пройдите пример 👣 Пошагово: визуализатор нарисует узлы в памяти и стрелки-ссылки между ними.
| Операция | Связный список | Массив (list) |
|---|---|---|
| доступ по индексу | O(n) | O(1) |
| вставка/удаление в начале | O(1) | O(n) |
| вставка/удаление по ссылке на узел | O(1) (двусвязный) | O(n) |
| поиск | O(n) | O(n) |
| память | + ссылка на каждый узел | компактно, дружит с кешем процессора |
В Python связные списки на практике почти не пишут (есть list и deque), но на собеседованиях это одна из любимых тем: задачи проверяют аккуратную работу с указателями.
Приём 1: фиктивный узел
Многие крайние случаи (пустой список, удаление головы) исчезают, если завести «фиктивный» узел перед головой:
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def build(values):
head = None
for v in reversed(values):
head = ListNode(v, head)
return head
def to_list(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
def remove_all(head, target):
dummy = ListNode(0, head) # не нужно отдельно обрабатывать голову
node = dummy
while node.next:
if node.next.val == target:
node.next = node.next.next # «перешагнуть» узел
else:
node = node.next
return dummy.next
print(to_list(remove_all(build([7, 7, 1, 7, 2, 7]), 7)))
Приём 2: разворот
Три указателя: предыдущий, текущий, следующий. Это задача номер один про списки.
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def build(values):
head = None
for v in reversed(values):
head = ListNode(v, head)
return head
def to_list(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
def reverse(head):
prev = None
current = head
while current:
nxt = current.next # 1. запомнить следующий
current.next = prev # 2. развернуть стрелку
prev = current # 3. сдвинуть prev
current = nxt # 4. сдвинуть current
return prev
def reverse_recursive(head):
if head is None or head.next is None:
return head
new_head = reverse_recursive(head.next)
head.next.next = head # следующий узел теперь указывает на нас
head.next = None
return new_head
print(to_list(reverse(build([1, 2, 3, 4]))))
print(to_list(reverse_recursive(build([1, 2, 3, 4]))))
Нарисуйте стрелки на бумаге — это лучший способ не запутаться. Рекурсивный вариант тратит O(n) стека.
Приём 3: быстрый и медленный указатели
slow идёт на 1 шаг, fast — на 2.
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def build(values):
head = None
for v in reversed(values):
head = ListNode(v, head)
return head
def middle(head):
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
return slow.val # когда fast в конце, slow — в середине
print(middle(build([1, 2, 3, 4, 5])), middle(build([1, 2, 3, 4, 5, 6])))
def cycle_start(head):
"""Алгоритм Флойда: есть ли цикл и где он начинается."""
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow is fast: # встретились внутри цикла
slow = head
while slow is not fast: # идём с одинаковой скоростью — встретимся в начале цикла
slow, fast = slow.next, fast.next
return slow.val
return None
nodes = [ListNode(i) for i in range(6)]
for a, b in zip(nodes, nodes[1:]):
a.next = b
nodes[-1].next = nodes[2] # 0 → 1 → 2 → 3 → 4 → 5 → (снова 2)
print("цикл начинается в узле", cycle_start(nodes[0]))
Почему заяц догонит черепаху: внутри цикла расстояние между ними сокращается на 1 каждый шаг.
Приём 4: слияние
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def build(values):
head = None
for v in reversed(values):
head = ListNode(v, head)
return head
def to_list(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
def merge(a, b):
dummy = tail = ListNode(0)
while a and b:
if a.val <= b.val:
tail.next, a = a, a.next
else:
tail.next, b = b, b.next
tail = tail.next
tail.next = a or b # прицепить остаток
return dummy.next
print(to_list(merge(build([1, 4, 7]), build([2, 3, 8, 9]))))
Комбинация приёмов: палиндром = найти середину + развернуть вторую половину + сравнить; сортировка списка = середина + рекурсия + слияние (merge sort за O(n log n)).
Двусвязный список
У узла есть и next, и prev — удаление узла по ссылке за O(1). Используется в LRU-кеше (вместе со словарём) и внутри collections.deque (там узлы — блоки по 64 элемента).
class Node:
def __init__(self, key, value):
self.key, self.value = key, value
self.prev = self.next = None
class LRUCache:
"""LRU-кеш без OrderedDict: словарь + двусвязный список со стражами."""
def __init__(self, capacity):
self.capacity = capacity
self.map = {}
self.head, self.tail = Node(None, None), Node(None, None) # фиктивные концы
self.head.next, self.tail.prev = self.tail, self.head
def _remove(self, node):
node.prev.next, node.next.prev = node.next, node.prev
def _add_front(self, node):
node.next, node.prev = self.head.next, self.head
self.head.next.prev = node
self.head.next = node
def get(self, key):
if key not in self.map:
return -1
node = self.map[key]
self._remove(node)
self._add_front(node)
return node.value
def put(self, key, value):
if key in self.map:
self._remove(self.map[key])
node = Node(key, value)
self.map[key] = node
self._add_front(node)
if len(self.map) > self.capacity:
lru = self.tail.prev
self._remove(lru)
del self.map[lru.key]
cache = LRUCache(2)
cache.put("a", 1); cache.put("b", 2); cache.get("a"); cache.put("c", 3)
print(cache.get("b"), cache.get("a"), cache.get("c"))
Как решать задачи на списки
1) Нарисуйте 3–4 узла и стрелки. 2) Подумайте о крайних случаях: пустой список, один узел, два узла, изменение головы. 3) Используйте фиктивный узел. 4) Перед node.next = ... сохраните то, что потеряете.
Итог
- Связный список: O(1) вставка/удаление по ссылке, O(n) доступ по индексу.
- Фиктивный узел убирает особые случаи с головой.
- Разворот — три указателя
prev,current,next. - Быстрый/медленный указатели: середина, цикл и его начало (Флойд), n-й с конца.
- Слияние отсортированных списков; двусвязный список + словарь = LRU-кеш.
Практика решено 0 из 5
Разворот списка. Разверните односвязный список и верните новую голову. Итеративно, O(n) времени,
O(1) памяти. Класс ListNode и помощники build/to_list уже есть.
Какие тесты проверяют код (3)
assert to_list(reverse(build([1, 2, 3, 4, 5]))) == [5, 4, 3, 2, 1] assert reverse(None) is None and to_list(reverse(build([7]))) == [7] nodes = build([1, 2, 3]); first = nodes; new_head = reverse(nodes); assert first.next is None and new_head.next.next is first, "Разворачивайте сами узлы, а не создавайте новые"
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def build(values):
head = None
for v in reversed(values):
head = ListNode(v, head)
return head
def to_list(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
def reverse(head):
prev = None
while head:
head.next, prev, head = prev, head, head.next
return prev
Слияние двух отсортированных списков. Слейте два отсортированных связных списка в один отсортированный (из тех же узлов) и верните голову. Используйте фиктивный узел.
Какие тесты проверяют код (3)
assert to_list(merge_lists(build([1, 2, 4]), build([1, 3, 4]))) == [1, 1, 2, 3, 4, 4] assert merge_lists(None, None) is None and to_list(merge_lists(None, build([0]))) == [0] assert to_list(merge_lists(build([5, 6]), build([1, 2, 3]))) == [1, 2, 3, 5, 6]
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def build(values):
head = None
for v in reversed(values):
head = ListNode(v, head)
return head
def to_list(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
def merge_lists(a, b):
dummy = tail = ListNode(0)
while a and b:
if a.val <= b.val:
tail.next, a = a, a.next
else:
tail.next, b = b, b.next
tail = tail.next
tail.next = a or b
return dummy.next
Цикл в списке. Определите, есть ли в связном списке цикл, за O(1) памяти (алгоритм Флойда: «черепаха и заяц»). Без множеств и словарей.
Какие тесты проверяют код (4)
a, b, c, d = ListNode(1), ListNode(2), ListNode(3), ListNode(4)
a.next, b.next, c.next, d.next = b, c, d, b
assert has_cycle(a) is True
a, b = ListNode(1), ListNode(2)
a.next = b
assert has_cycle(a) is False and has_cycle(None) is False
a = ListNode(1); a.next = a
assert has_cycle(a) is True
import inspect; src = inspect.getsource(has_cycle); assert "set(" not in src and "{}" not in src and "dict(" not in src, "Решите за O(1) памяти — двумя указателями"
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
Удалить n-й узел с конца за один проход. Верните голову списка. [1, 2, 3, 4, 5], n = 2 → [1, 2, 3, 5].
Подсказка: фиктивный узел и два указателя на расстоянии n.
Какие тесты проверяют код (3)
assert to_list(remove_nth_from_end(build([1, 2, 3, 4, 5]), 2)) == [1, 2, 3, 5] assert remove_nth_from_end(build([1]), 1) is None and to_list(remove_nth_from_end(build([1, 2]), 2)) == [2] assert to_list(remove_nth_from_end(build([1, 2]), 1)) == [1]
class ListNode:
def __init__(self, val, next=None):
self.val, self.next = val, next
def build(values):
head = None
for v in reversed(values):
head = ListNode(v, head)
return head
def to_list(head):
out = []
while head:
out.append(head.val)
head = head.next
return out
def remove_nth_from_end(head, n):
dummy = ListNode(0, head)
fast = slow = dummy
for _ in range(n):
fast = fast.next
while fast.next:
fast, slow = fast.next, slow.next
slow.next = slow.next.next
return dummy.next
Сложность операции: связный список против массива (list)
| O(1) у обоих | быстрее у связного списка | быстрее у массива | |
|---|---|---|---|
| доступ к i-му элементу | |||
| вставка в начало | |||
| удаление узла, на который уже есть ссылка (двусвязный) | |||
| добавление в конец (с указателем на хвост / list.append) | |||
| перебор всех элементов (с учётом кеша процессора) |
Войдите, чтобы отмечать пройденные уроки.