6. Remove Nth Node From End
«Дистанция, однажды установленная между двумя точками, позволяет исследовать будущее, не теряя связи с прошлым».
— Брайан Керниган
Дистанция вслепую
В предыдущих главах мы освоили технику фиктивного узла (Dummy Node) и алгоритм встречных скоростей. Задача Remove Nth Node From End of List (LeetCode 19) — это классический пример того, как скользящее окно фиксированной ширины (Sliding Window Gap) переносится с непрерывных массивов на динамические топологии связных списков.
На технических интервью эта задача часто дается в качестве разминочной, но архитектор или Staff-интервьюер непременно задаст проверочный вопрос:
«Зачем мы стремимся выполнить удаление строго за один проход (One-Pass), если асимптотически два прохода $2N$ дают то же самое линейное время $O(N)$?»
Архитектура: Скользящее окно фиксированного разрыва (Gap)
Наивный подход Junior-разработчика (Two-Pass):
- Пройти по списку от начала до конца и сосчитать длину $L$ ($N$ операций).
- Вычислить порядковый номер удаляемого узла от начала: $idx = L - n$.
- Запустить второй проход от головы до узла $idx - 1$ и перебросить ссылку ($L - n$ операций).
Суммарно: почти $2N$ обращений к памяти.
Подход Senior-инженера (One-Pass с фиксированным зазором):
Мы создаем скользящее окно между двумя указателями fast и slow с фиксированным интервалом ровно в $n + 1$ шагов.
- Устанавливаем фиктивный узел
dummyперед головой списка (dummy.Next = head). - Оба указателя
slowиfastстартуют изdummy. - Сначала выдвигаем
fastвперед на $n + 1$ шагов.
Зачем $+1$? Чтобы когдаfastдойдет доnil, указательslowостановился ровно перед удаляемым узлом, имея прямой доступ к его ссылкеslow.Next! - Затем двигаем
slowиfastсинхронно с одинаковой скоростью (по 1 шагу), покаfastне выйдет вnil. - Перебрасываем ссылку
slow.Next = slow.Next.Next.
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
subgraph Phase1["Фаза 1: Разгон fast на (n + 1) шагов"]
D1["dummy<br>(slow)"]:::entry --> A1["1"]:::process --> B1["2"]:::process --> C1["3<br>(fast)"]:::warning
end
subgraph Phase2["Фаза 2: Синхронный сдвиг до fast == nil"]
D2["dummy"]:::entry --> A2["1"]:::process --> B2["2"]:::process --> C2["3<br>(slow)"]:::entry
C2 --> Target["4<br>(удаляемый n=2)"]:::error
Target --> EndNode["5"]:::process --> NilNode["nil<br>(fast)"]:::warning
end
C2 -.->|"slow.Next = slow.Next.Next"| EndNode
Идиоматичный Go-код
package list
// removeNthFromEnd удаляет n-й узел с конца списка за один проход O(N) и O(1) памяти.
func removeNthFromEnd(head *ListNode, n int) *ListNode {
// 1. Создаем Dummy Node на стеке.
// Это безупречно изолирует случай удаления реальной головы (head).
var dummy ListNode
dummy.Next = head
slow := &dummy
fast := &dummy
// 2. Создаем зазор ровно в (n + 1) шагов
for i := 0; i <= n; i++ {
fast = fast.Next
}
// 3. Синхронно двигаем оба указателя до самого конца
for fast != nil {
slow = slow.Next
fast = fast.Next
}
// 4. Удаляем целевой узел in-place
target := slow.Next
slow.Next = target.Next
target.Next = nil // Инженерная гигиена: отсекаем старую связь
return dummy.Next
}
Взгляд под капот: Mechanical Sympathy и Cache Misses
Почему инженерный мир отдает предпочтение One-Pass решению, если $O(2N) = O(N)$?
Вспомним физику работы процессора: узлы связного списка разбросаны по куче (Heap). Каждый переход node.Next — это обращение к новому случайному участку DRAM, которое почти со 100% вероятностью приводит к L1/L2 Cache Miss.
При промахе кэша конвейер процессора простаивает (CPU Pipeline Stall) около 100–250 тактов.
- Двухпроходный алгоритм (Two-Pass): Процессор дважды читает один и тот же связный список. Если в списке 100 000 элементов, мы получаем до 200 000 полных промахов кэша.
- Однопроходный алгоритм (One-Pass): Указатель
fastидет первым, аslowследует прямо за ним по горячим следам. Данные, подгруженные контроллером памяти дляfast, с высокой вероятностью оказываются в общем L3-кэше процессора к моменту, когда до них доходитslow.
В реальном времени выполнения (wall-clock time) однопроходный код на связанных списках отрабатывает в 1.5–2 раза быстрее, демонстрируя истинное механическое сочувствие к машине.
Итог
Задача Remove Nth Node From End демонстрирует, как скользящее окно с фиксированным зазором превращает двухпроходный перебор в лаконичный и быстрый однопроходный алгоритм.
Что произойдет, если два независимых списка в какой-то момент сойдутся и продолжат путь как единое целое? Как обнаружить точку их слияния без хеш-таблицы? Разбираем в следующей статье: 7. Intersection of Two Linked Lists.