Алгоритмы с нуля
Манипуляция указателями на связных списках
В прошлом уроке ты выучил, что push в начало это O(1), но доступ к позиции i это O(n). Сегодня ты выучишь как вставлять и удалять на произвольных позициях, как разворачивать список, и как использовать хитрый трюк — dummy head — что исключает большинство граничных случаев и делает твой код проще.
После этого урока ты можешь вставить узел на данную позицию в связный список обновляя указатели, удалить узел из связного списка (если есть ссылка на предыдущий узел), развернуть весь связный список используя только манипуляцию указателями, реализовать паттерн dummy-head (sentinel) чтобы элегантно обработать граничные случаи с head, и узнать когда dummy heads делают код чище и безопаснее.
Основная проблема: связывание и отсоединение
Чтобы вставить или удалить узел в связном списке, ты должен внимательно обновить указатели. Это не про создание или удаление узлов в памяти (это работа аллокатора); это про изменение того, на что указывает next.
Вставка: операция splice
Представь связный список [10] → [20] → [30] и ты хочешь вставить 15 между 10 и 20.
Чтобы вставить узел после данного узла:
- Создай новый узел (15).
- Установи
nextнового узла на старого наследника (20). - Установи
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].
Чтобы удалить узел, ты нуждаешься в ссылке на узел перед ним. Тогда:
- Установи
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;Преимущества:
- Единая логика. Одна вставка/удаление логика работает для head и середины.
- Более чистые удаления. Даже если ты удалишь head, dummy всё ещё указывает на новый head.
- Безопаснее. Ты исключаешь проверки на null-указатель для “есть ли предыдущий узел?”
- Легче кодировать правильно. Меньше граничных случаев = меньше багов.
Разворот: развораживание всех указателей
Развёртывание [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) |
| Вставка на позицию i | O(i) | O(1) |
| Удаление после данного узла | O(1) | O(1) |
| Удаление на позицию i | O(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). Нет новых структур данных что выделены.
Ты хочешь вставить узел со значением 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?
Манипуляция указателями на связных списках:
-
Вставка после узла: Создай новый узел, установи его next на старого наследника, установи узел’s next на новый узел. Время: O(1) если у тебя есть узел; O(i) если ты должен пройти на позицию i сначала.
-
Удаление после узла: Установи next предыдущего узла чтобы пропустить цель и указать на её наследника. Время: O(1) если дан предыдущий узел; O(n) если ты должен искать с head.
-
Разворот: Используй три указателя (prev, current, next) чтобы пройти по списку и перевернуть каждый указатель. Время: O(n), пространство: O(1). Последний узел становится новым head.
-
Паттерн dummy-head (sentinel node): Помести dummy узел перед настоящим head. Теперь каждая операция имеет гарантированный “предыдущий узел”, исключая специальные случаи для операций с head. Более чистый код, меньше багов.
-
Всегда сохрани next указатель перед его перезаписью. Когда ты установишь
current.next = prev, ты теряешь ссылку на наследника если ты не сохранил его сначала. -
Манипуляция указателями это всё про обновления, не выделение. Ты переупорядочиваешь существующие узлы, не создаёшь новые.
Далее ты выучишь стек и очередь — две последовательные структуры данных построенные на связных списках, каждая налагая дисциплину на когда ты можешь вставлять и удалять.