Связный список: когда слайс не подходит

Связный список: когда слайс не подходит

В 95% случаев слайс лучше. Но есть ситуации, где связный список выигрывает.

Проще всего это понять на очереди. Сидишь в очереди. Знаешь только, за кем занимать. За кем идёшь - помнишь, дальше туман. Это связный список: у каждого ссылка только на следующего. Запомнил ещё и того, кто занял после тебя? Уже двусвязный список - ходишь в обе стороны: «кто передо мной? кто за мной?».

В жизниВ коде
сосед, за которым занималуказатель Next
тот, кто занял после тебяуказатель Prev
первый в очередиHead
«а кто тут девятый?» - идти и считатьдоступ по индексу O(n)

Спросить «кто девятый?» нельзя - надо идти вдоль очереди и пересчитывать. Зато встать в начало или уйти из середины - мгновенно, соседи просто перезнакомятся. А если нужен мгновенный ответ про любого - это уже бабуля из урока про map.

Связный список: узлы с указателями next и prev, односвязный vs двусвязный

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

type Node[T any] struct {
    Value T
    Next  *Node[T]
}

type LinkedList[T any] struct {
    Head *Node[T]
    Size int
}

func (l *LinkedList[T]) PushFront(val T) {
    node := &Node[T]{Value: val, Next: l.Head}
    l.Head = node
    l.Size++
}

func (l *LinkedList[T]) PopFront() (T, bool) {
    if l.Head == nil {
        var zero T
        return zero, false
    }
    val := l.Head.Value
    l.Head = l.Head.Next
    l.Size--
    return val, true
}
<?php
declare(strict_types=1);

$list = new \SplDoublyLinkedList();
$list->push('a');    // в хвост
$list->push('b');
$list->push('c');
$list->unshift('0'); // в голову

foreach ($list as $value) {
    echo $value . "\n"; // 0, a, b, c
}

// O(1) операции на концах
$first = $list->shift(); // удаляем '0' из головы
$last = $list->pop();    // удаляем 'c' из хвоста

echo count($list); // 2

// Доступ по индексу O(n) - честный linked list
$middle = $list[0];

В PHP писать связный список руками не нужно - есть \SplDoublyLinkedList (база для \SplStack и \SplQueue). Он реализует Iterator, Countable, ArrayAccess, поддерживает обход в обе стороны.

\SplDoublyLinkedList - двусвязный список (как container/list в Go). Если нужен односвязный - встроенного нет, проще написать руками class Node { public ?Node $next; }.

Сложность операций

<ComparisonTable data={{ headers: ["Операция", "Slice", "Linked List"], rows: [ ["Доступ по индексу", "O(1)", "O(n)"], ["Вставка в начало", "O(n)", "O(1)"], ["Вставка в конец", "O(1) amortized", "O(1) с tail"], ["Удаление из начала", "O(n)", "O(1)"], ["Удаление из середины", "O(n)", "O(1) если есть указатель"], ["Поиск", "O(n)", "O(n)"], ["Cache-friendly", "Да", "Нет"] ] }} />

Строка «доступ по индексу O(n)» - это та самая очередь: чтобы узнать девятого, надо пройти от соседа к соседу.

container/list из stdlib

Go предоставляет двусвязный список в стандартной библиотеке:

import "container/list"

func main() {
    l := list.New()

    // Вставка
    l.PushBack("first")
    l.PushBack("second")
    l.PushFront("zero")

    // Итерация
    for e := l.Front(); e != nil; e = e.Next() {
        fmt.Println(e.Value) // zero, first, second
    }

    // Удаление
    l.Remove(l.Front())
}
<?php
declare(strict_types=1);

$list = new \SplDoublyLinkedList();
$list->push('first');
$list->push('second');
$list->unshift('zero');

// Итерация (порядок такой же: zero, first, second)
foreach ($list as $value) {
    echo $value . "\n";
}

// Удаление головы
$list->shift();
// Или offsetUnset по индексу:
$list->offsetUnset(0);
LRU-кеш (его делают и поверх [Redis](../redis/03-caching-patterns.md)), очередь с приоритетным удалением, реализация deque. В остальных случаях слайс быстрее из-за cache locality.

Для PHP такой же выбор: \SplDoublyLinkedList или \Ds\Deque для двусторонней очереди. Для готового LRU-кеша - Symfony\Component\Cache\Adapter\ArrayAdapter (или LRUCache-обёртка вокруг него); реализовывать самому почти никогда не нужно.

Практика: разворот списка

func (l *LinkedList[T]) Reverse() {
    var prev *Node[T]
    current := l.Head

    for current != nil {
        next := current.Next
        current.Next = prev
        prev = current
        current = next
    }

    l.Head = prev
}

Разворот - это когда вся очередь разом переспрашивает «а за кем я теперь?»: каждый запоминает предыдущего вместо следующего.

LRU-кеш на связном списке

type LRUCache struct {
    capacity int
    items    map[string]*list.Element
    order    *list.List
}

type entry struct {
    key   string
    value string
}

func NewLRUCache(capacity int) *LRUCache {
    return &LRUCache{
        capacity: capacity,
        items:    make(map[string]*list.Element),
        order:    list.New(),
    }
}

func (c *LRUCache) Get(key string) (string, bool) {
    if elem, ok := c.items[key]; ok {
        c.order.MoveToFront(elem)
        return elem.Value.(*entry).value, true
    }
    return "", false
}

func (c *LRUCache) Put(key, value string) {
    if elem, ok := c.items[key]; ok {
        c.order.MoveToFront(elem)
        elem.Value.(*entry).value = value
        return
    }

    if c.order.Len() >= c.capacity {
        oldest := c.order.Back()
        c.order.Remove(oldest)
        delete(c.items, oldest.Value.(*entry).key)
    }

    elem := c.order.PushFront(&entry{key, value})
    c.items[key] = elem
}
<?php
declare(strict_types=1);

final class LRUCache
{
    /** @var \SplDoublyLinkedList */
    private \SplDoublyLinkedList $order;

    /** @var array<string, \SplDoublyLinkedList> Map key → node (упрощено: храним key→list-ref) */
    private array $items = [];

    public function __construct(private readonly int $capacity) {
        $this->order = new \SplDoublyLinkedList();
    }

    public function get(string $key): ?string
    {
        if (!isset($this->items[$key])) {
            return null;
        }

        $value = $this->items[$key];
        $this->touch($key, $value);
        return $value;
    }

    public function put(string $key, string $value): void
    {
        if (isset($this->items[$key])) {
            $this->touch($key, $value);
            return;
        }

        if (count($this->items) >= $this->capacity) {
            $oldest = $this->order->shift();
            unset($this->items[$oldest]);
        }

        $this->order->push($key);
        $this->items[$key] = $value;
    }

    private function touch(string $key, string $value): void
    {
        // Удаляем старую позицию ключа и кладём в хвост (most recently used)
        foreach ($this->order as $idx => $k) {
            if ($k === $key) {
                $this->order->offsetUnset($idx);
                break;
            }
        }
        $this->order->push($key);
        $this->items[$key] = $value;
    }
}

// Прагматичный вариант для прода - Symfony Cache:
// use Symfony\Component\Cache\Adapter\ArrayAdapter;
// $cache = new ArrayAdapter(defaultLifetime: 0, storeSerialized: false, maxLifetime: 0, maxItems: 100);
// $cache->get('user:42', fn () => $repo->find(42));

В PHP LRU удобно собирать на \SplDoublyLinkedList + массив-индекс. Но если задача прикладная (HTTP-кеш, мемоизация репозитория) - бери готовое: Symfony\Component\Cache\Adapter\ArrayAdapter или LRUCacheTrait из wikimedia/at-ease-стиля библиотек.

Учебная реализация выше теряет O(1) на touch (linear scan для поиска ноды). Промышленный путь - ArrayAdapter с maxItems, который под капотом делает то же самое за O(1) через array_key_* трюки.

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