5. Linked List Cycle
«Всякое движение по замкнутому кругу бесконечно, пока на трассу не выйдет тот, кто бежит быстрее».
— Брайан Керниган
Анатомия бесконечного цикла
Задача Linked List Cycle (LeetCode 141) — одна из самых известных задач в программировании. В реальных распределенных архитектурах циклические зависимости в графах владения ресурсами приводят к взаимным блокировкам (Deadlocks), утечкам памяти и вечным циклам обработки задач в брокерах сообщений.
На технических собеседованиях эта задача служит проверкой инженерной зрелости. Практически любой кандидат способен решить её за пару минут «в лоб», сохранив посещенные узлы в хеш-таблицу. Но от Senior-разработчика ожидают бескомпромиссного решения: константная память $O(1)$, нулевая нагрузка на сборщик мусора Go и работа на уровне регистров CPU.
Архитектура решения: Алгоритм Флойда («Черепаха и Заяц»)
Представьте кольцевую гоночную трассу. Если дорога прямая, скоростной болид (Заяц) стремительно удалится от грузовика (Черепахи) и пересечет финишную черту в бесконечности (nil). Но если трасса замкнута в кольцо, то быстрый автомобиль неизбежно настигнет медленный сзади и обойдет его ровно на один круг.
flowchart LR
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,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 Track["Связный список с кольцом"]
Head["Head"]:::entry --> N1["Узел 1"]:::process
N1 --> N2["Узел 2"]:::process
N2 --> LoopEntry["Вход в цикл"]:::accent
LoopEntry --> L1["Узел L1"]:::process
L1 --> Meet["Узел встречи<br>(slow == fast)"]:::error
Meet --> L2["Узел L2"]:::process
L2 -->|"Петля возврата"| LoopEntry
end
Механика двух указателей:
- Запускаем указатели
slowиfastиз начального узлаhead. - На каждой итерации:
slowделает 1 шаг вперед:slow = slow.Next.fastделает 2 шага вперед:fast = fast.Next.Next.
- Если
fastвстретилnilилиfast.Next == nil— список конечен, зацикливания нет. - Если указатели совпали (
slow == fast) — они находятся по одному физическому адресу в памяти, цикл гарантированно обнаружен.
Идиоматичный Go-код
package list
// hasCycle определяет наличие цикла в связном списке за O(N) времени и O(1) памяти.
func hasCycle(head *ListNode) bool {
// Базовый случай: пустой список или единственный узел без петли
if head == nil || head.Next == nil {
return false
}
slow := head
fast := head
// КРИТИЧНО: проверяем fast и fast.Next.
// Короткое замыкание логического И в Go исключает nil pointer dereference!
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
// Сравниваем физические адреса памяти (указатели), а не значения узлов Val!
if slow == fast {
return true
}
}
return false
}
Взгляд под капот: Разгром наивного решения на Hash Map
Если интервьюер спросит:
«Зачем писать алгоритм с двумя указателями, если можно сложить посещенные узлы в мапу seen := make(map[*ListNode]struct{})?»
Опытный инженер разложит наивный подход по полочкам:
- Escape Analysis и утечка в кучу:
Поскольку длина связного списка заранее неизвестна, компилятор Go не сможет разместитьhmapна стеке. Мапа утечет в кучу (Heap allocation). Для выделения структуры рантайм Go обратится к центральному аллокатору памяти. - Давление на сборщик мусора (GC Pressure):
Для списка из 500 000 элементов хеш-таблица выделит сотни тысяч бакетов, многократно вызывая эвакуацию при росте емкости. По завершении функции этот массив бакетов повиснет мертвым грузом в памяти. Фаза Mark сборщика мусора будет вынуждена просканировать всю таблицу. - Такты CPU и машинные инструкции:
- Алгоритм Флойда: ровно 16 байт на стеке (два указателя по 8 байт). Проверка
slow == fastкомпилируется в одну ассемблерную инструкциюCMP, работающую прямо в регистрах CPU. - Хеш-таблица: вычисление хеша от указателя (
memhash64), вычисление остатка бакета, проход по цепочке коллизий, множественные Cache Misses в RAM.
- Алгоритм Флойда: ровно 16 байт на стеке (два указателя по 8 байт). Проверка
Углубление: Поиск точки входа в цикл (LeetCode 142)
На собеседовании уровня Senior задачу немедленно усложнят:
«Цикл найден. А теперь верните точный узел, с которого этот цикл начинается. И также за строгое $O(1)$ памяти!»
Это задача Linked List Cycle II, опирающаяся на фундаментальную теорему Флойда:
Теорема о начале цикла:
Расстояние от начала списка до узла входа в цикл в точности равно расстоянию от точки первой встречи указателей до узла входа в цикл при движении вперед.
// detectCycle возвращает узел входа в цикл за O(N) времени и O(1) памяти.
func detectCycle(head *ListNode) *ListNode {
if head == nil || head.Next == nil {
return nil
}
slow, fast := head, head
var hasLoop bool
// Шаг 1: фиксируем факт встречи внутри кольца
for fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
hasLoop = true
break
}
}
if !hasLoop {
return nil
}
// Шаг 2: переносим slow в head, а fast оставляем в точке встречи.
// Теперь оба двигаются с равной скоростью (по 1 шагу)!
slow = head
for slow != fast {
slow = slow.Next
fast = fast.Next
}
// Точка повторной встречи — математически строгий узел входа в цикл
return slow
}
Итог
Задача Linked List Cycle — это демонстрация превосходства математических инвариантов над грубой силой выделения памяти. Знание алгоритма Флойда позволяет находить циклы любой сложности без единой аллокации в куче.
Освоив механику встречных скоростей, перейдем к задаче на координацию относительных расстояний: как удалить элемент с конца списка без предварительного подсчета его длины? Разбираем в статье 6. Remove Nth Node From End.