한양과학기술고등학교

에듀테크 프로젝트 대시보드

플로이드 순환 탐지 (토끼와 거북이)

플로이드 순환 탐지 (토끼와 거북이)

연결 리스트에 고리(사이클)가 있는지, 있다면 어디서 시작하는지를 추가 메모리 없이 알아내는 방법. 느린 포인터(거북이 🐢)는 한 칸, 빠른 포인터(토끼 🐇)는 두 칸씩 움직인다. 고리가 있으면 토끼가 반드시 거북이를 한 바퀴 안에서 따라잡는다.

왜 필요할까? — "방문한 노드를 set에 넣고 다시 나오면 사이클"이라고 하면 간단하지만 노드 수만큼 메모리(O(n))를 쓴다. 플로이드 방법은 포인터 딱 2개, 즉 O(1) 메모리로 끝낸다. 리스트가 10억 개여도 변수 두 개뿐.

1. 눈으로 보기 — 두 포인터 경주

아래는 꼬리 μ칸 + 고리 λ칸 짜리 리스트다. 「시작」을 누르면 거북이는 1칸, 토끼는 2칸씩 전진한다. 1단계에서 둘이 만나는 지점을 찾고, 2단계에서 한 포인터를 머리로 되돌려 같은 속도로 걸어 고리의 시작점을 찾는다.

🐢 거북이 = slow (1칸/스텝) 🐇 토끼 = fast (2칸/스텝) ■ 만나는 지점 ■ 고리 시작점 (정답)
현재 단계
–
🐢 위치
–
🐇 위치
–
전진 스텝
0
「한 단계」 또는 「자동」을 눌러 경주를 시작하세요.

2. 왜 반드시 만나는가

두 포인터가 모두 고리 안에 들어온 뒤를 생각하자. 고리 둘레는 λ. 매 스텝마다 토끼는 거북이보다 정확히 1칸 더 다가간다(2 − 1 = 1). 즉 둘 사이 거리가 매 스텝 1씩 줄어든다. 거리는 0 ~ λ−1 중 하나이므로, 늦어도 λ 스텝 안에 거리가 0 = 같은 노드에 선다. 고리가 없으면 토끼가 먼저 리스트 끝(null)에 도달 → "사이클 없음".

거리가 2씩 벌어지면 못 만나는 것 아닌가? 원형 트랙에서는 "1칸 뒤"가 곧 "λ−1칸 앞"이다. 토끼가 매 스텝 1칸씩 접근하므로 어긋나서 스쳐 지나갈 일이 없다. (속도차가 1일 때의 특권이다. 3칸·1칸이면 스쳐 지나갈 수 있다.)

3. 만난 뒤 — 고리 시작점 찾기 (μ)

머리에서 고리 시작까지 거리를 μ, 고리 둘레를 λ라 하자. 거북이가 k칸 갔을 때 처음 만났다면 토끼는 2k칸 갔고, 둘 다 고리 위 같은 점에 있으므로 두 거리의 차 2k − k = k는 λ의 배수다.

k ≡ 0 (mod λ)  ⟹  만난 지점은 시작점에서 (k − μ)칸,  그리고 μ + (k − μ) = k ≡ 0

따라서 만난 지점에서 μ칸 더 가면 고리 시작점이다. 그런데 머리에서도 시작점까지 정확히 μ칸이다! 그래서:

  1. 포인터 하나(ptr)를 머리로 옮긴다. 다른 하나는 만난 지점에 둔다.
  2. 이제 둘 다 1칸씩 움직인다.
  3. 둘이 처음 만나는 노드가 바로 고리의 시작점이다.

4. 코드

def detect_cycle(head):
    slow = fast = head
    # --- 1단계: 만남 지점 찾기 ---
    while fast and fast.next:
        slow = slow.next            # 1칸
        fast = fast.next.next       # 2칸
        if slow is fast:
            break                   # 사이클 발견
    else:
        return None                # fast가 null 도달 → 사이클 없음

    # --- 2단계: 고리 시작점 찾기 ---
    ptr = head
    while ptr is not slow:
        ptr = ptr.next
        slow = slow.next
    return ptr                       # 고리 시작 노드

    # --- (보너스) 고리 길이 λ ---
    # 시작점에서 한 바퀴 돌아 제자리로 오는 횟수를 세면 된다
for...else / while...else — Python에서 else는 break 없이 루프가 끝났을 때 실행된다. 위 코드에서 fast가 null에 닿아 자연 종료되면 "사이클 없음"으로 None을 돌려준다.

5. 다른 방법과 비교

방법시간메모리비고
방문 노드를 set에 저장O(n)O(n)가장 직관적. 큰 리스트에서 메모리 부담
노드에 "방문" 플래그 기록O(n)O(1)원본 데이터를 훼손. 불변 구조엔 불가
토끼와 거북이 (플로이드)O(n)O(1)포인터 2개. 시작점·길이까지 계산 가능
브렌트(Brent) 알고리즘O(n)O(1)비교 횟수가 더 적은 개선판

6. 실제로 어디에 쓰이나

연결 리스트 버그 탐지 — 포인터를 잘못 이어 붙여 생긴 무한 루프를 찾는다. LeetCode Linked List Cycle 계열 문제의 정석.

난수열의 주기 찾기 — xₙ₊₁ = f(xₙ) 형태 의사난수 생성기는 유한 상태라 언젠가 반복된다. 그 주기(period)를 메모리 없이 측정.

폴라드 로(Pollard's rho) 소인수분해 — 큰 수를 나누는 인수를 찾을 때, 수열 x² + 1 mod n 의 사이클을 토끼·거북이로 감지한다. 이름의 "ρ" 모양이 바로 이 그림.

"중복된 수 찾기" — 1..n 범위 배열에서 값을 다음 인덱스로 해석하면 리스트가 되고, 중복 값이 곧 고리 시작점이 된다. (LeetCode 287)

한 줄 요약 — 속도가 다른 두 포인터를 원형 트랙에 풀면 반드시 만난다. 그 만남을 이용해 O(1) 메모리로 사이클의 존재·시작점·길이를 모두 구한다. 관련: BFS/DFS, 위상 정렬(사이클 감지), 해시 함수.