Связный список: когда слайс не подходит
Связный список: когда слайс не подходит
В 95% случаев слайс лучше. Но есть ситуации, где связный список выигрывает.
Проще всего это понять на очереди. Сидишь в очереди. Знаешь только, за кем занимать. За кем идёшь - помнишь, дальше туман. Это связный список: у каждого ссылка только на следующего. Запомнил ещё и того, кто занял после тебя? Уже двусвязный список - ходишь в обе стороны: «кто передо мной? кто за мной?».
| В жизни | В коде |
|---|---|
| сосед, за которым занимал | указатель Next |
| тот, кто занял после тебя | указатель Prev |
| первый в очереди | Head |
| «а кто тут девятый?» - идти и считать | доступ по индексу O(n) |
Спросить «кто девятый?» нельзя - надо идти вдоль очереди и пересчитывать. Зато встать в начало или уйти из середины - мгновенно, соседи просто перезнакомятся. А если нужен мгновенный ответ про любого - это уже бабуля из урока про map.
Односвязный список
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);
Для 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_*трюки.