open atlas
↑ К треку
Алгоритмы с нуля ALG · 06 · 02

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

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

ALG ◷ 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 или удалить 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. Легче кодировать правильно. Меньше граничных случаев = меньше багов.

Вместе эти преимущества означают одно: когда встретишь задачу на связный список с вставкой или удалением в голове, первым делом добавляй dummy-head — без него граничные случаи расползутся по разным if-веткам и будут рано или поздно пропущены.

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

Развёртывание [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. Манипуляция указателями это всё про обновления, не выделение. Ты переупорядочиваешь существующие узлы, не создаёшь новые.

Теперь, когда встретишь задачу на связный список с разворотом, вставкой в середину или удалением узла, ты потянешься за тремя указателями и dummy-head — и не напишешь ни одного лишнего специального случая.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
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.