awesome-everything EN
↑ Обратно к восхождению

Алгоритмы с нуля

Манипуляция указателями на связных списках

Суть Вставь узел в связный список, удали узел, и разверни список используя обновление указателей. Выучи паттерн dummy-head (sentinel) для исключения граничных случаев и более чистого кода.
◷ 22 min

В прошлом уроке ты выучил, что push в начало это O(1), но доступ к позиции i это O(n). Сегодня ты выучишь как вставлять и удалять на произвольных позициях, как разворачивать список, и как использовать хитрый трюк — dummy head — что исключает большинство граничных случаев и делает твой код проще.

Цель

После этого урока ты можешь вставить узел на данную позицию в связный список обновляя указатели, удалить узел из связного списка (если есть ссылка на предыдущий узел), развернуть весь связный список используя только манипуляцию указателями, реализовать паттерн dummy-head (sentinel) чтобы элегантно обработать граничные случаи с head, и узнать когда dummy heads делают код чище и безопаснее.

Идея

Основная проблема: связывание и отсоединение

Чтобы вставить или удалить узел в связном списке, ты должен внимательно обновить указатели. Это не про создание или удаление узлов в памяти (это работа аллокатора); это про изменение того, на что указывает next.

Вставка: операция splice

Представь связный список [10] → [20] → [30] и ты хочешь вставить 15 между 10 и 20.

Чтобы вставить узел после данного узла:

  1. Создай новый узел (15).
  2. Установи next нового узла на старого наследника (20).
  3. Установи next данного узла на новый узел (15).

Это “splice” — ты разломал ссылку 10 → 20, вставил 15 посередине, и пересобрал цепь: 10 → 15 → 20.

Раньше:  [10 | next → ] → [20 | next → ] → [30 | null]

После:   [10 | next → ] → [15 | next → ] → [20 | next → ] → [30 | null]
                            ↑ вновь вставлен

Операция это O(1) по времени потому что ты только изменяешь указатели; ты не проходишь список. Но ты должен иметь ссылку на узел перед точкой вставки. Если ты хочешь вставить на позицию i (и у тебя есть только head), ты сначала проходишь на позицию i-1, что стоит O(i) времени всего.

Удаление: операция отсоединения

Теперь удали 15 из [10] → [15] → [20] → [30].

Чтобы удалить узел, ты нуждаешься в ссылке на узел перед ним. Тогда:

  1. Установи next предыдущего узла чтобы пропустить цель, указывая прямо на наследника цели.
Раньше:  [10 | next → ] → [15 | next → ] → [20 | next → ] → [30 | null]

После:   [10 | next → ] ─────────────────> [20 | next → ] → [30 | null]
                        (15 теперь недостижим)

Время: O(1) удалить если у тебя есть ссылка на предыдущий узел. O(i) если ты должен сначала пройти чтобы найти.

Граничный случай с head: почему dummy heads важны

Вставка и удаление легко если цель в середине. Но что если ты хочешь вставить в head или удалить head? Тогда нет “предыдущего узла”. Это заставляет тебя писать специальные случаи:

if (insertingAtHead) {
  // Специальный случай: создай новый head
  const newNode = new Node(value);
  newNode.next = head;
  head = newNode;
} else {
  // Нормальный случай: вставь после какого-то узла
  const prev = findNodeBefore(position);
  const newNode = new Node(value);
  newNode.next = prev.next;
  prev.next = newNode;
}

Это дублирование уродливо. Хитрость: представь dummy узел.

Паттерн dummy head (sentinel node)

Создай специальный узел, dummy head, что всегда указывает на настоящий head. Этот dummy не часть твоих данных; это просто якорь.

Dummy (0) → [10 | next → ] → [20 | next → ] → [30 | null]

 Всегда существует; действует как предыдущий узел для вставок в настоящий head

Теперь каждая операция имеет “предыдущий узел” — либо настоящий узел либо dummy. Вставка и удаление используют одинаковую логику везде:

// Нет специальных случаев нужно
const newNode = new Node(value);
newNode.next = prev.next;  // prev может быть dummy
prev.next = newNode;

Преимущества:

  1. Единая логика. Одна вставка/удаление логика работает для head и середины.
  2. Более чистые удаления. Даже если ты удалишь head, dummy всё ещё указывает на новый head.
  3. Безопаснее. Ты исключаешь проверки на null-указатель для “есть ли предыдущий узел?”
  4. Легче кодировать правильно. Меньше граничных случаев = меньше багов.

Разворот: развораживание всех указателей

Развёртывание [10] → [20] → [30] → [null] в [30] → [20] → [10] → [null] означает изменение каждого next указателя чтобы он указывал назад.

Один подход: итерируй по списку, и в каждом узле разверни его указатель.

Шаг 0: prev = null,  current = 10 → 20 → 30
        next = 20 (сохрани это перед тем как мы изменим current.next)
        
Шаг 1: prev = 10 ← null (мы развернули это),  current = 20 → 30
        next = 30 (сохрани это)
        
Шаг 2: prev = 20 ← 10,  current = 30 → null
        next = null (сохрани это)
        
Шаг 3: prev = 30 ← 20,  current = null (готово)

Результат: prev указывает на новый head (30).

Ключевая идея: сохрани next указатель перед тем как ты его перезапишешь. Иначе ты теряешь ссылку и не можешь продолжить.

let prev = null;
let current = head;

while (current !== null) {
  const next = current.next;  // Сохрани следующий узел
  current.next = prev;        // Разверни указатель
  prev = current;             // Продвинься вперёд
  current = next;
}

head = prev;  // Новый head это последний узел что мы обработали

Время: O(n) (один проход по списку). Пространство: O(1) (только несколько указателей, нет новых данных).

Код

Вставка после узла:

function insertAfter(prevNode, value) {
  if (prevNode === null) {
    throw new Error("Не могу вставить после null");
  }
  const newNode = new Node(value);
  newNode.next = prevNode.next;
  prevNode.next = newNode;
}

Удаление узла (если дан предыдущий узел):

function deleteAfter(prevNode) {
  if (prevNode === null || prevNode.next === null) {
    throw new Error("Нечего удалять");
  }
  prevNode.next = prevNode.next.next;
}

Разворот (на месте):

function reverseList(head) {
  let prev = null;
  let current = head;
  
  while (current !== null) {
    const next = current.next;  // Сохрани next перед тем как мы его изменим
    current.next = prev;        // Разверни указатель
    prev = current;             // Продвинь prev вперёд
    current = next;             // Продвинь current вперёд
  }
  
  return prev;  // prev теперь новый head
}

Паттерн dummy-head:

function insertDummy(head, position, value) {
  // Создай dummy узел что указывает на head
  const dummy = new Node(0);  // Значение не важно
  dummy.next = head;
  
  // Пройди на позицию - 1 (теперь мы всегда найдём предыдущий узел)
  let prev = dummy;
  for (let i = 0; i < position; i++) {
    if (prev.next === null) {
      throw new Error("Позиция вне границ");
    }
    prev = prev.next;
  }
  
  // Вставь (та же логика как всегда)
  const newNode = new Node(value);
  newNode.next = prev.next;
  prev.next = newNode;
  
  return dummy.next;  // Верни новый head
}

Попробуй развернуть список и используй dummy head:

Вывод
 
Пошаговый разбор

Давай проследим разворот [10] → [20] → [30] пошагово.

1 let prev = null;
2 let current = head;
3 while (current !== null) {
4 const next = current.next;
5 current.next = prev;
6 prev = current;
7 current = next;
8 }

Заметь: мы никогда не выделяем и не освобождаем узлы. Мы только изменяем указатели. Структура списка полностью развернута обновлением указателей одного.

Сложность

Манипуляция указателями на связных списках:

ОперацияВремяПространство
Вставка после данного узлаO(1)O(1)
Вставка на позицию iO(i)O(1)
Удаление после данного узлаO(1)O(1)
Удаление на позицию iO(i)O(1)
Разворот всего спискаO(n)O(1)

Почему O(1) для вставки/удаления после узла? Ты только обновляешь указатели; проход не нужен.

Почему O(i) для вставки/удаления на позицию i? Ты должен пройти с head на позицию i (или i-1) чтобы найти цель. Проход стоит O(i) времени.

Почему O(n) для разворота? Ты посещаешь каждый узел один раз и переворачиваешь его указатель. Один проход через все n узлов.

Почему O(1) пространство для всех? Ты используешь только несколько указателей (prev, current, next). Нет новых структур данных что выделены.

Практика 0 / 5

Ты хочешь вставить узел со значением 15 после узла со значением 10 в списке [10] → [20] → [30]. Какие обновления указателя?

Чтобы удалить узел со значением 20 из [10] → [20] → [30], что ты делаешь?

После разворота [10] → [20] → [30], новый head и финальная структура должны быть:

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

Почему паттерн dummy-head полезен?

Граничные случаи

Вставка в head с dummy: С dummy head, вставка на позицию 0 (настоящий head) это просто вставка после dummy. Специальный случай не нужен. Новый узел становится первым реальным узлом данных, и dummy.next указывает на него.

Частая ошибка

«Я могу удалить узел просто установив node.next = null.» Нет. Это не отсоединяет узел из списка. Предыдущий узел всё ещё указывает на него, и узел всё ещё указывает на его наследника. Истинное удаление требует изменения next указателя предыдущего узла.

Частая ошибка

«Когда разворачиваю, я могу просто использовать рекурсию чтобы перевернуть указатели.» Рекурсия работает (и это элегантно), но это стоит O(n) пространства для call stack. Итеративный подход (с prev, current, next указателями) делает это в O(1) пространстве.

Проверь себя
Викторина

Почему удаление узла в связном списке это O(1) только если у тебя есть ссылка на предыдущий узел, но O(n) если у тебя только head?

Итог

Манипуляция указателями на связных списках:

  1. Вставка после узла: Создай новый узел, установи его next на старого наследника, установи узел’s next на новый узел. Время: O(1) если у тебя есть узел; O(i) если ты должен пройти на позицию i сначала.

  2. Удаление после узла: Установи next предыдущего узла чтобы пропустить цель и указать на её наследника. Время: O(1) если дан предыдущий узел; O(n) если ты должен искать с head.

  3. Разворот: Используй три указателя (prev, current, next) чтобы пройти по списку и перевернуть каждый указатель. Время: O(n), пространство: O(1). Последний узел становится новым head.

  4. Паттерн dummy-head (sentinel node): Помести dummy узел перед настоящим head. Теперь каждая операция имеет гарантированный “предыдущий узел”, исключая специальные случаи для операций с head. Более чистый код, меньше багов.

  5. Всегда сохрани next указатель перед его перезаписью. Когда ты установишь current.next = prev, ты теряешь ссылку на наследника если ты не сохранил его сначала.

  6. Манипуляция указателями это всё про обновления, не выделение. Ты переупорядочиваешь существующие узлы, не создаёшь новые.

Далее ты выучишь стек и очередь — две последовательные структуры данных построенные на связных списках, каждая налагая дисциплину на когда ты можешь вставлять и удалять.

Продолжить восхождение ↑Быстрый и медленный указатели
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources5
expand
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05

Trademarks belong to their respective owners. Editorial reference only.