Mid💻Практика6 min

Паттерны: Fast & Slow Pointers

Обнаружение цикла, поиск середины, проверка палиндрома в связных списках

Паттерны: Fast & Slow Pointers

Идея паттерна

Fast & Slow Pointers (Черепаха и Заяц) — два указателя двигаются с разной скоростью: slow на 1 шаг, fast на 2. Это позволяет решать задачи на циклы, середину и структуру списка.

slow:  o . o . o . o . o
fast:  o . . o . . o . . o

Если есть цикл — fast догонит slow.
Если нет цикла — fast дойдёт до конца.

Задача 1: Обнаружение цикла (Floyd's Algorithm)

PHPGo
function hasCycle(?ListNode $head): bool
{
    $slow = $head;
    $fast = $head;

    while ($fast !== null && $fast->next !== null) {
        $slow = $slow->next;          // 1 step
        $fast = $fast->next->next;    // 2 steps

        if ($slow === $fast) {
            return true;
        }
    }

    return false; // fast reached the end — no cycle
}
func hasCycle(head *ListNode) bool {
    slow := head
    fast := head

    for fast != nil && fast.Next != nil {
        slow = slow.Next          // 1 step
        fast = fast.Next.Next     // 2 steps

        if slow == fast {
            return true
        }
    }

    return false // fast reached the end — no cycle
}
Визуализация:
Список с циклом:
1 -> 2 -> 3 -> 4 -> 5
               ^         |
               |         v
               8 <- 7 <- 6

Шаг 0: slow=1, fast=1
Шаг 1: slow=2, fast=3
Шаг 2: slow=3, fast=5
Шаг 3: slow=4, fast=7
Шаг 4: slow=5, fast=3  (fast уже в цикле)
Шаг 5: slow=6, fast=5
Шаг 6: slow=7, fast=7  <- ВСТРЕТИЛИСЬ!

Почему это работает?

Когда оба указателя внутри цикла длины C:

  • Расстояние между ними уменьшается на 1 каждый шаг
  • Значит, за максимум C шагов они встретятся

Задача 2: Найти начало цикла

После обнаружения цикла: перемещаем один указатель на head, оба двигаются по 1 шагу.

PHPGo
function detectCycleStart(?ListNode $head): ?ListNode
{
    $slow = $head;
    $fast = $head;

    // Step 1: Find meeting point
    while ($fast !== null && $fast->next !== null) {
        $slow = $slow->next;
        $fast = $fast->next->next;
        if ($slow === $fast) {
            // Found cycle, now find start
            $slow = $head;
            while ($slow !== $fast) {
                $slow = $slow->next;
                $fast = $fast->next; // Both 1 step!
            }
            return $slow; // Start of cycle
        }
    }

    return null; // No cycle
}
func detectCycleStart(head *ListNode) *ListNode {
    slow := head
    fast := head

    // Step 1: Find meeting point
    for fast != nil && fast.Next != nil {
        slow = slow.Next
        fast = fast.Next.Next
        if slow == fast {
            // Found cycle, now find start
            slow = head
            for slow != fast {
                slow = slow.Next
                fast = fast.Next // Both 1 step!
            }
            return slow // Start of cycle
        }
    }

    return nil // No cycle
}
### Математическое доказательство
head          start
  |             |
  v             v
  o--o--o--o--o--o--o--o
  |<-- a -->|  |        |
                |<- c ->|
                |        |
                o--o--o--o
                |<- b ->|

a = расстояние от head до начала цикла
b = расстояние от начала цикла до точки встречи
C = длина цикла

При встрече:
  slow прошёл: a + b
  fast прошёл: a + b + k*C (k полных кругов)
  fast = 2 * slow:
  a + b + k*C = 2(a + b)
  k*C = a + b
  a = k*C - b

Значит: если идти a шагов от head и от точки встречи —
оба окажутся в начале цикла!

Задача 3: Поиск середины списка

PHPGo
function findMiddle(?ListNode $head): ?ListNode
{
    $slow = $head;
    $fast = $head;

    while ($fast !== null && $fast->next !== null) {
        $slow = $slow->next;
        $fast = $fast->next->next;
    }

    return $slow;
}

// Odd count: 1->2->3->4->5
// slow: 1, 2, 3    fast: 1, 3, 5
// Middle: 3

// Even count: 1->2->3->4
// slow: 1, 2, 3    fast: 1, 3, null
// Returns 3 (second middle)
func findMiddle(head *ListNode) *ListNode {
    slow := head
    fast := head

    for fast != nil && fast.Next != nil {
        slow = slow.Next
        fast = fast.Next.Next
    }

    return slow
}

// Odd count: 1->2->3->4->5
// slow: 1, 2, 3    fast: 1, 3, 5
// Middle: 3

// Even count: 1->2->3->4
// slow: 1, 2, 3    fast: 1, 3, nil
// Returns 3 (second middle)
Если нужна **первая** середина для чётного:
PHPGo
function findMiddleFirst(?ListNode $head): ?ListNode
{
    $slow = $head;
    $fast = $head;

    while ($fast->next !== null && $fast->next->next !== null) {
        $slow = $slow->next;
        $fast = $fast->next->next;
    }

    return $slow;
}

// 1->2->3->4
// slow: 1, 2    fast: 1, 3
// Returns 2 (first middle)
func findMiddleFirst(head *ListNode) *ListNode {
    slow := head
    fast := head

    for fast.Next != nil && fast.Next.Next != nil {
        slow = slow.Next
        fast = fast.Next.Next
    }

    return slow
}

// 1->2->3->4
// slow: 1, 2    fast: 1, 3
// Returns 2 (first middle)
## Задача 4: Палиндром в связном списке

Алгоритм:

  1. Найти середину (fast/slow)
  2. Развернуть вторую половину
  3. Сравнить обе половины
PHPGo
function isPalindromeList(?ListNode $head): bool
{
    if ($head === null || $head->next === null) {
        return true;
    }

    // 1. Find middle
    $slow = $head;
    $fast = $head;
    while ($fast->next !== null && $fast->next->next !== null) {
        $slow = $slow->next;
        $fast = $fast->next->next;
    }

    // 2. Reverse second half
    $secondHalf = reverseList($slow->next);

    // 3. Compare
    $firstHalf = $head;
    while ($secondHalf !== null) {
        if ($firstHalf->val !== $secondHalf->val) {
            return false;
        }
        $firstHalf = $firstHalf->next;
        $secondHalf = $secondHalf->next;
    }

    return true;
}

function reverseList(?ListNode $head): ?ListNode
{
    $prev = null;
    while ($head !== null) {
        $next = $head->next;
        $head->next = $prev;
        $prev = $head;
        $head = $next;
    }
    return $prev;
}
func isPalindromeList(head *ListNode) bool {
    if head == nil || head.Next == nil {
        return true
    }

    // 1. Find middle
    slow := head
    fast := head
    for fast.Next != nil && fast.Next.Next != nil {
        slow = slow.Next
        fast = fast.Next.Next
    }

    // 2. Reverse second half
    secondHalf := reverseList(slow.Next)

    // 3. Compare
    firstHalf := head
    for secondHalf != nil {
        if firstHalf.Val != secondHalf.Val {
            return false
        }
        firstHalf = firstHalf.Next
        secondHalf = secondHalf.Next
    }

    return true
}

func reverseList(head *ListNode) *ListNode {
    var prev *ListNode
    for head != nil {
        next := head.Next
        head.Next = prev
        prev = head
        head = next
    }
    return prev
}
Визуализация для `1 -> 2 -> 3 -> 2 -> 1`:
Шаг 1: Середина = 3
1 -> 2 -> 3 -> 2 -> 1

Шаг 2: Разворачиваем после середины
1 -> 2 -> 3    1 -> 2

Шаг 3: Сравниваем
1 == 1 ✓
2 == 2 ✓
Палиндром!

Задача 5: Удаление n-го с конца

PHPGo
function removeNthFromEnd(?ListNode $head, int $n): ?ListNode
{
    $dummy = new ListNode(0);
    $dummy->next = $head;
    $fast = $dummy;
    $slow = $dummy;

    // Fast is ahead of slow by n+1 steps
    for ($i = 0; $i <= $n; $i++) {
        $fast = $fast->next;
    }

    // Move both until fast reaches the end
    while ($fast !== null) {
        $slow = $slow->next;
        $fast = $fast->next;
    }

    // slow->next is the target node
    $slow->next = $slow->next->next;

    return $dummy->next;
}

// Remove 2nd from end in [1, 2, 3, 4, 5]
// fast ahead by n+1=3 steps: fast=3
// Move: slow=1,fast=4 -> slow=2,fast=5 -> slow=3,fast=null
// slow->next = 4 (remove)
// Result: [1, 2, 3, 5]
func removeNthFromEnd(head *ListNode, n int) *ListNode {
    dummy := &ListNode{Next: head}
    fast := dummy
    slow := dummy

    // Fast is ahead of slow by n+1 steps
    for i := 0; i <= n; i++ {
        fast = fast.Next
    }

    // Move both until fast reaches the end
    for fast != nil {
        slow = slow.Next
        fast = fast.Next
    }

    // slow.Next is the target node
    slow.Next = slow.Next.Next

    return dummy.Next
}

// Remove 2nd from end in [1, 2, 3, 4, 5]
// fast ahead by n+1=3 steps: fast=3
// Move: slow=1,fast=4 -> slow=2,fast=5 -> slow=3,fast=nil
// slow.Next = 4 (remove)
// Result: [1, 2, 3, 5]
## Задача 6: Пересечение двух списков
PHPGo
function getIntersection(?ListNode $headA, ?ListNode $headB): ?ListNode
{
    if ($headA === null || $headB === null) {
        return null;
    }

    $a = $headA;
    $b = $headB;

    // When a reaches the end — switch to headB
    // When b reaches the end — switch to headA
    // They will meet at the intersection (or both become null)
    while ($a !== $b) {
        $a = ($a !== null) ? $a->next : $headB;
        $b = ($b !== null) ? $b->next : $headA;
    }

    return $a; // Intersection point or null
}
func getIntersection(headA, headB *ListNode) *ListNode {
    if headA == nil || headB == nil {
        return nil
    }

    a := headA
    b := headB

    // When a reaches the end — switch to headB
    // When b reaches the end — switch to headA
    // They will meet at the intersection (or both become nil)
    for a != b {
        if a != nil {
            a = a.Next
        } else {
            a = headB
        }
        if b != nil {
            b = b.Next
        } else {
            b = headA
        }
    }

    return a // Intersection point or nil
}
``` Список A: 1 -> 2 -> | +--> 8 -> 9 -> null Список B: 3 -> 4 -> 5 -> |

a: 1, 2, 8, 9, null, 3, 4, 5, 8 b: 3, 4, 5, 8, 9, null, 1, 2, 8 ^ встретились на 8!

Почему работает: len(A) + len(B) = len(B) + len(A) Оба пройдут одинаковое расстояние до точки пересечения.


## Сводная таблица паттернов

| Задача                   | Подход            | Сложность       |
|--------------------------|-------------------|-----------------|
| Обнаружение цикла        | Fast/Slow         | O(n) / O(1)     |
| Начало цикла             | Fast/Slow + reset | O(n) / O(1)     |
| Середина списка          | Fast/Slow         | O(n) / O(1)     |
| Палиндром                | Середина + reverse| O(n) / O(1)     |
| N-й с конца              | Fast ahead by n   | O(n) / O(1)     |
| Пересечение              | Two pointer switch| O(n+m) / O(1)   |

> **Запомни:** Fast & Slow — это не просто про связные списки. Этот паттерн применяется для: обнаружения циклов в любых последовательностях, нахождения дубликатов (Floyd's для массивов), определения структурных свойств (середина, длина). На интервью: если задача про связный список — первым делом подумай о двух указателях.

## Итоги

1. Fast (2 шага) + Slow (1 шаг) = обнаружение цикла
2. Reset к head после встречи = начало цикла
3. Когда fast дошёл до конца, slow на середине
4. Палиндром = середина + разворот + сравнение
5. N-й с конца = опережение fast на n шагов
6. Все задачи решаются за O(n) времени и O(1) памяти

Проверь себя

🧪

Как проверить, является ли связный список палиндромом, используя O(1) дополнительной памяти?

🧪

После обнаружения цикла в алгоритме Флойда, как найти НАЧАЛО цикла?

🧪

В задаче «удалить n-й элемент с конца» списка, зачем fast опережает slow на n+1 шагов, а не на n?

🧪

Для списка 1->2->3->4->5, где окажется slow-указатель когда fast дойдёт до конца?

🧪

В алгоритме Флойда для обнаружения цикла, почему fast двигается именно на 2 шага, а не на 3?