EasyТеория12 min

Двусвязный список

Реализация двусвязного списка, преимущества и применение

Отличие от односвязного

В двусвязном списке каждый узел хранит ссылки на следующий и предыдущий узлы. Это позволяет перемещаться в обоих направлениях.

Односвязный:
[1] -> [2] -> [3] -> [4] -> null
 head

Двусвязный:
null <- [1] <-> [2] <-> [3] <-> [4] -> null
         head                     tail

Структура узла

class DListNode
{
    public function __construct(
        public int $val = 0,
        public ?DListNode $prev = null,
        public ?DListNode $next = null,
    ) {}
}
## Реализация двусвязного списка
class DoublyLinkedList
{
    private DListNode $head; // sentinel
    private DListNode $tail; // sentinel
    private int $size = 0;

    public function __construct()
    {
        // Sentinel nodes (dummy boundaries)
        $this->head = new DListNode(0);
        $this->tail = new DListNode(0);
        $this->head->next = $this->tail;
        $this->tail->prev = $this->head;
    }

    /** Insert at the beginning — O(1) */
    public function addFirst(int $val): void
    {
        $node = new DListNode($val);
        $node->next = $this->head->next;
        $node->prev = $this->head;
        $this->head->next->prev = $node;
        $this->head->next = $node;
        $this->size++;
    }

    /** Insert at the end — O(1) */
    public function addLast(int $val): void
    {
        $node = new DListNode($val);
        $node->prev = $this->tail->prev;
        $node->next = $this->tail;
        $this->tail->prev->next = $node;
        $this->tail->prev = $node;
        $this->size++;
    }

    /** Remove a specific node — O(1) */
    public function remove(DListNode $node): void
    {
        $node->prev->next = $node->next;
        $node->next->prev = $node->prev;
        $this->size--;
    }

    /** Remove from the beginning — O(1) */
    public function removeFirst(): ?int
    {
        if ($this->size === 0) {
            return null;
        }
        $node = $this->head->next;
        $this->remove($node);
        return $node->val;
    }

    /** Remove from the end — O(1) */
    public function removeLast(): ?int
    {
        if ($this->size === 0) {
            return null;
        }
        $node = $this->tail->prev;
        $this->remove($node);
        return $node->val;
    }

    /** Traverse forward */
    public function printForward(): void
    {
        $current = $this->head->next;
        while ($current !== $this->tail) {
            echo $current->val . ' <-> ';
            $current = $current->next;
        }
        echo "null\n";
    }

    /** Traverse backward */
    public function printBackward(): void
    {
        $current = $this->tail->prev;
        while ($current !== $this->head) {
            echo $current->val . ' <-> ';
            $current = $current->prev;
        }
        echo "null\n";
    }
}
## Визуализация операций

Вставка в начало

До:
  head <-> [A] <-> [B] <-> tail

Вставка X:
  1. X->next = head->next (= A)
  2. X->prev = head
  3. A->prev = X
  4. head->next = X

После:
  head <-> [X] <-> [A] <-> [B] <-> tail

Удаление узла

До:
  ... <-> [A] <-> [B] <-> [C] <-> ...
                   ^
              удаляем B

  1. A->next = C    (B->prev->next = B->next)
  2. C->prev = A    (B->next->prev = B->prev)

После:
  ... <-> [A] <-> [C] <-> ...

Sentinel (фиктивные узлы)

Sentinel nodes — это фиктивные узлы на границах списка. Они упрощают код, убирая необходимость проверять null.

// Without sentinel (many checks):
public function addFirstNoSentinel(int $val): void
{
    $node = new DListNode($val);
    if ($this->head === null) {
        $this->head = $node;
        $this->tail = $node;
    } else {
        $node->next = $this->head;
        $this->head->prev = $node;
        $this->head = $node;
    }
}

// With sentinel (clean code):
public function addFirstWithSentinel(int $val): void
{
    $node = new DListNode($val);
    $node->next = $this->head->next;
    $node->prev = $this->head;
    $this->head->next->prev = $node;
    $this->head->next = $node;
    // Works even for empty list!
}
## Практическое применение: LRU Cache

LRU Cache (Least Recently Used) — классическая задача, которая идеально решается двусвязным списком + HashMap.

class LRUNode
{
    public function __construct(
        public int $key = 0,
        public int $val = 0,
        public ?LRUNode $prev = null,
        public ?LRUNode $next = null,
    ) {}
}

class LRUCache
{
    private array $cache = [];  // key -> LRUNode
    private LRUNode $head;
    private LRUNode $tail;

    public function __construct(private int $capacity)
    {
        $this->head = new LRUNode();
        $this->tail = new LRUNode();
        $this->head->next = $this->tail;
        $this->tail->prev = $this->head;
    }

    private function addToFront(LRUNode $node): void
    {
        $node->next = $this->head->next;
        $node->prev = $this->head;
        $this->head->next->prev = $node;
        $this->head->next = $node;
    }

    private function removeNode(LRUNode $node): void
    {
        $node->prev->next = $node->next;
        $node->next->prev = $node->prev;
    }

    private function moveToFront(int $key): void
    {
        $node = $this->cache[$key];
        $this->removeNode($node);
        $this->addToFront($node);
    }

    public function get(int $key): int
    {
        if (!isset($this->cache[$key])) {
            return -1;
        }
        $this->moveToFront($key);
        return $this->cache[$key]->val;
    }

    public function put(int $key, int $value): void
    {
        if (isset($this->cache[$key])) {
            $this->cache[$key]->val = $value;
            $this->moveToFront($key);
        } else {
            if (count($this->cache) >= $this->capacity) {
                // Remove least recently used (end of list)
                $lru = $this->tail->prev;
                $this->removeNode($lru);
                unset($this->cache[$lru->key]);
            }

            $node = new LRUNode($key, $value);
            $this->cache[$key] = $node;
            $this->addToFront($node);
        }
    }
}
``` LRU Cache (capacity=3):

Начало: head <-> tail

put(1, A): head <-> [1:A] <-> tail put(2, B): head <-> [2:B] <-> [1:A] <-> tail put(3, C): head <-> [3:C] <-> [2:B] <-> [1:A] <-> tail

get(1): head <-> [1:A] <-> [3:C] <-> [2:B] <-> tail (1 перемещён в начало)

put(4, D): head <-> [4:D] <-> [1:A] <-> [3:C] <-> tail (2:B вытеснен — был последним)


Все операции — **O(1)**: HashMap для O(1) доступа, двусвязный список для O(1) перемещения/удаления.

## Сравнение одно- и двусвязных списков

| Свойство                      | Односвязный | Двусвязный  |
|-------------------------------|-------------|-------------|
| Память на узел                | val + next  | val + prev + next |
| Обход вперёд                  | O(n)        | O(n)        |
| Обход назад                   | Невозможен  | O(n)        |
| Вставка в начало              | O(1)        | O(1)        |
| Вставка в конец (с tail)      | O(1)        | O(1)        |
| Удаление по ссылке на узел    | O(n)*       | O(1)        |
| Удаление с конца              | O(n)        | O(1)        |
| Реализация сложность          | Простая     | Умеренная   |

*\* Нужен предыдущий узел, а его найти за O(n)*

### Когда использовать двусвязный

- LRU Cache и подобные задачи
- Нужно удалять узлы по ссылке за O(1)
- Нужен обход в обоих направлениях
- Реализация deque (двусторонней очереди)
- Текстовые редакторы (курсор может двигаться вперёд и назад)

### Когда хватит односвязного

- Простые стеки и очереди
- Обход только в одном направлении
- Минимизация памяти
- Большинство задач на интервью

> **Запомни:** Двусвязный список + HashMap = O(1) для всех операций в LRU Cache. Sentinel nodes устраняют edge cases при вставке и удалении. Используй двусвязный, когда нужно удалять узлы по ссылке за O(1) или двигаться в обоих направлениях.

## Итоги

1. Двусвязный = prev + val + next в каждом узле
2. Удаление узла по ссылке за O(1) (главное преимущество)
3. Sentinel nodes упрощают код, устраняя проверки на null
4. LRU Cache = HashMap + Doubly Linked List
5. Больше памяти на узел, но гибче в операциях

Проверь себя

Почему LRU Cache использует именно двусвязный список + HashMap, а не только HashMap?

В LRU Cache с capacity=3 выполнены операции: put(1,A), put(2,B), put(3,C), get(1), put(4,D). Какой элемент будет вытеснен?

Сколько ссылок нужно обновить при удалении узла из середины двусвязного списка?

Зачем в реализации двусвязного списка используются sentinel (фиктивные) узлы head и tail?

Какова сложность удаления узла по ссылке в двусвязном списке vs односвязном?