EasyТеория11 min

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

Реализация односвязного списка, базовые операции и анализ сложности

Что такое связный список?

Связный список — это структура данных, где каждый элемент (узел) хранит значение и ссылку на следующий узел. В отличие от массива, элементы не лежат в памяти подряд.

Массив:
[10|20|30|40|50]  <- непрерывная память

Связный список:
[10|->] -> [20|->] -> [30|->] -> [40|->] -> [50|null]
  head                                        tail

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

class ListNode
{
    public function __construct(
        public int $val = 0,
        public ?ListNode $next = null,
    ) {}
}
## Создание списка
// Manual creation
$node1 = new ListNode(1);
$node2 = new ListNode(2);
$node3 = new ListNode(3);
$node1->next = $node2;
$node2->next = $node3;
// 1 -> 2 -> 3 -> null

// Create from array (helper)
function createList(array $arr): ?ListNode
{
    if (empty($arr)) {
        return null;
    }
    $head = new ListNode($arr[0]);
    $current = $head;
    for ($i = 1; $i < count($arr); $i++) {
        $current->next = new ListNode($arr[$i]);
        $current = $current->next;
    }
    return $head;
}

$head = createList([1, 2, 3, 4, 5]);
## Обход списка
function printList(?ListNode $head): void
{
    $current = $head;
    while ($current !== null) {
        echo $current->val . ' -> ';
        $current = $current->next;
    }
    echo "null\n";
}
// 1 -> 2 -> 3 -> 4 -> 5 -> null
## Операции и их сложность
Операция Сложность Пояснение
Доступ по индексу O(n) Нужно пройти с начала
Поиск значения O(n) Линейный обход
Вставка в начало O(1) Просто перенаправить head
Вставка в конец O(n)* Нужно дойти до конца
Вставка после узла O(1) Перенаправить указатели
Удаление из начала O(1) Сдвинуть head
Удаление конкретного O(n) Нужно найти предыдущий
Длина O(n) Пройти весь список

* O(1) если хранить ссылку на tail

Вставка в начало — O(1)

function insertAtHead(?ListNode $head, int $val): ListNode
{
    $newNode = new ListNode($val);
    $newNode->next = $head;
    return $newNode; // New head
}

// Before: 1 -> 2 -> 3
// insertAtHead($head, 0)
// After:  0 -> 1 -> 2 -> 3
Визуализация:
До:
head -> [1] -> [2] -> [3] -> null

Создаём новый узел:
[0] -> ???

Перенаправляем:
[0] -> [1] -> [2] -> [3] -> null
  ^
  head (новый)

Вставка в конец — O(n)

function insertAtTail(?ListNode $head, int $val): ListNode
{
    $newNode = new ListNode($val);
    if ($head === null) {
        return $newNode;
    }

    $current = $head;
    while ($current->next !== null) { // Go to last node
        $current = $current->next;
    }
    $current->next = $newNode; // Attach

    return $head;
}
### Удаление узла по значению
function deleteNode(?ListNode $head, int $val): ?ListNode
{
    // If deleting head
    if ($head !== null && $head->val === $val) {
        return $head->next;
    }

    $current = $head;
    while ($current !== null && $current->next !== null) {
        if ($current->next->val === $val) {
            $current->next = $current->next->next; // Skip node
            return $head;
        }
        $current = $current->next;
    }

    return $head;
}
Визуализация удаления:
До:  [1] -> [2] -> [3] -> [4] -> null
           удаляем ^

current = [1]
current->next = [2], [2]->val == 2

Перенаправляем:
[1] -> [3] -> [4] -> null
       ^
  current->next = current->next->next

Вставка после данного узла — O(1)

function insertAfter(ListNode $node, int $val): void
{
    $newNode = new ListNode($val);
    $newNode->next = $node->next;
    $node->next = $newNode;
}
``` До: [A] -> [C] -> ... Вставляем B после A:

Шаг 1: newNode->next = A->next (= C) [A] -> [C] -> ... [B] -> [C]

Шаг 2: A->next = newNode [A] -> [B] -> [C] -> ...


## Техника Dummy Node

Dummy node (фиктивный узел) упрощает обработку **edge cases** с head.

<div class="code-group" data-languages="php,go,csharp,python"><div class="code-group-tabs"><button class="code-tab active" data-lang="php" data-index="0">PHP</button><button class="code-tab" data-lang="go" data-index="1">Go</button><button class="code-tab" data-lang="csharp" data-index="2">C#</button><button class="code-tab" data-lang="python" data-index="3">Python</button></div><div class="code-block" data-lang="php" data-index="0">

```php
function removeElements(?ListNode $head, int $val): ?ListNode
{
    // Remove ALL nodes with given value
    $dummy = new ListNode(0);    // Dummy node before head
    $dummy->next = $head;
    $current = $dummy;

    while ($current->next !== null) {
        if ($current->next->val === $val) {
            $current->next = $current->next->next;
        } else {
            $current = $current->next;
        }
    }

    return $dummy->next; // New head (may be null)
}

// Without dummy, we'd need to separately handle:
// - deleting head
// - deleting multiple heads in a row
// - empty list
## Разворот списка

Одна из самых классических задач на интервью.

function reverseList(?ListNode $head): ?ListNode
{
    $prev = null;
    $current = $head;

    while ($current !== null) {
        $nextTemp = $current->next;  // Save next
        $current->next = $prev;      // Reverse pointer
        $prev = $current;            // Move prev
        $current = $nextTemp;        // Move current
    }

    return $prev; // New head
}

// Steps for [1 -> 2 -> 3]:
// prev=null, curr=1
//   1->next=null, prev=1, curr=2     null <- 1   2 -> 3
// prev=1, curr=2
//   2->next=1, prev=2, curr=3        null <- 1 <- 2   3
// prev=2, curr=3
//   3->next=2, prev=3, curr=null     null <- 1 <- 2 <- 3
// return 3 (new head)
### Рекурсивный разворот
function reverseListRecursive(?ListNode $head): ?ListNode
{
    if ($head === null || $head->next === null) {
        return $head;
    }

    $newHead = reverseListRecursive($head->next);
    $head->next->next = $head;  // Reverse link
    $head->next = null;          // Old head becomes tail

    return $newHead;
}
## Поиск середины списка
function findMiddle(?ListNode $head): ?ListNode
{
    $slow = $head;
    $fast = $head;

    while ($fast !== null && $fast->next !== null) {
        $slow = $slow->next;
        $fast = $fast->next->next;
    }

    return $slow; // slow is at the middle
}

// [1, 2, 3, 4, 5]
// slow: 1, 2, 3    <- stops at 3
// fast: 1, 3, 5

// [1, 2, 3, 4]
// slow: 1, 2, 3    <- stops at 3 (second middle)
// fast: 1, 3, null
## Сравнение с массивом
Операция Массив Связный список
Доступ по индексу O(1) O(n)
Вставка в начало O(n) O(1)
Вставка в конец O(1)* O(n)**
Вставка в середину O(n) O(1)***
Удаление из начала O(n) O(1)
Поиск O(n) O(n)
Память Компактная Больше (указатели)

* амортизированно; ** O(1) с tail; *** если есть ссылка на узел

Запомни: Связный список выигрывает у массива в операциях вставки/удаления в начале (O(1) vs O(n)). Проигрывает в доступе по индексу (O(n) vs O(1)). Используй dummy node для упрощения edge cases. Разворот списка — must-know для интервью.

Итоги

  1. Узел = значение + ссылка на следующий
  2. Вставка/удаление в начале — O(1), доступ по индексу — O(n)
  3. Dummy node решает проблемы с edge cases (удаление head, пустой список)
  4. Разворот списка: запомни паттерн prev/current/next
  5. Поиск середины: fast/slow pointers

Проверь себя

Какое главное преимущество dummy node при удалении элементов из списка?

Что выведет функция после выполнения кода? ```php $head = createList([1, 2, 3]); $head = insertAtHead($head, 0); $head = deleteNode($head, 2); printList($head); ```

В каком случае связный список лучше массива?

Зачем в алгоритме разворота списка нужна временная переменная nextTemp? ```php $nextTemp = $current->next; $current->next = $prev; $prev = $current; $current = $nextTemp; ```

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