Floyd 알고리즘으로 순환 탐지
느린 포인터와 빠른 포인터 방식으로 순환을 탐지하고 순환의 진입점을 찾은 뒤 알고리즘의 정확성을 수학적으로 증명합니다.
Floyd 알고리즘으로 순환 탐지은(는) CoddyKit의 무료 DSA Interview Prep 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 DSA Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
연결 리스트의 사이클이란 무엇인가요?
연결 리스트의 사이클은 어떤 노드의 next 포인터가 이전에 방문한 노드를 다시 가리켜 무한 루프를 만들 때 발생합니다. 이러한 리스트를 while head 반복문으로 순회하면 영원히 끝나지 않습니다. 사이클 탐지는 고전적인 면접 문제이며, 더 발전된 포인터 알고리즘의 기반입니다.
단순한 방식은 방문한 모든 노드를 집합에 저장하고 포함 여부를 확인하는 것으로, O(n) 시간과 O(n) 공간이 필요합니다. 플로이드 알고리즘은 같은 문제를 O(n) 시간과 O(1) 공간으로 해결하며, 이것이 면접관이 기대하는 방식입니다.
플로이드 느린-빠른 포인터 알고리즘
플로이드의 사이클 탐지 알고리즘(‘거북이와 토끼’)은 두 포인터를 사용합니다. slow는 한 번에 한 단계씩 이동하고, fast는 두 단계씩 이동합니다. 사이클이 없으면 fast가 먼저 None에 도달합니다. 사이클이 있으면 빠른 포인터가 사이클 안에서 느린 포인터를 결국 따라잡아 같은 노드에서 만납니다. 이 만남은 사이클이 존재한다는 증거입니다.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def hasCycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
# Build: 3 -> 2 -> 0 -> -4 -> (back to 2)
nodes = [ListNode(v) for v in [3, 2, 0, -4]]
for i in range(3):
nodes[i].next = nodes[i+1]
nodes[3].next = nodes[1] # cycle: -4 -> 2
print(hasCycle(nodes[0])) # True느린 포인터와 빠른 포인터는 왜 항상 만날까요
직관적으로 설명하면, 두 포인터가 모두 사이클에 들어간 뒤 두 포인터 사이의 거리는 단계마다 1씩 줄어듭니다(빠른 포인터는 2만큼, 느린 포인터는 1만큼 이동하므로 매 라운드 간격이 1씩 줄어듭니다). 결국 간격은 0이 되고 두 포인터는 같은 노드에 있게 됩니다. 더 형식적으로 말하면 사이클 길이가 C일 때 사이클 안에서 가능한 최대 간격은 C-1이며, 간격이 매 단계 1씩 줄어들기 때문에 두 포인터는 모두 사이클에 들어간 뒤 C단계 안에 만납니다.
만나기 전 총 단계 수는 최대 O(n + C) = O(n)입니다. C <= n이기 때문입니다.
# Visualise convergence: simulate gap in cycle
cycle_length = 5
for start_gap in range(1, cycle_length + 1):
gap = start_gap
steps = 0
while gap != 0:
gap = (gap - 1) % cycle_length
steps += 1
print(f'Start gap {start_gap}: meet after {steps} step(s)')사이클 진입점 찾기
사이클을 탐지한 후 플로이드 알고리즘으로 진입 노드도 찾을 수 있습니다. 이는 사이클이 시작되는 노드입니다. 느린 포인터와 빠른 포인터가 사이클 안에서 만난 뒤, 한 포인터는 헤드로 되돌리고 다른 포인터는 만난 지점에 그대로 둡니다. 그런 다음 두 포인터를 모두 한 단계씩 이동합니다. 두 포인터는 정확히 사이클 진입 노드에서 만납니다. 헤드에서 진입점까지의 거리와 만난 지점에서 진입점까지의 거리가 사이클 길이를 법으로 하여 같기 때문에 가능한 방법입니다.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def detectCycle(head):
slow = fast = head
# Phase 1: detect meeting point
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
break
else:
return None # no cycle
# Phase 2: find entry
pointer = head
while pointer is not slow:
pointer = pointer.next
slow = slow.next
return pointer # cycle entry node
nodes = [ListNode(v) for v in [3, 2, 0, -4]]
for i in range(3):
nodes[i].next = nodes[i+1]
nodes[3].next = nodes[1] # entry is nodes[1] (val=2)
entry = detectCycle(nodes[0])
print(entry.val) # 2진입 노드의 수학적 증명
F를 헤드에서 사이클 진입점까지의 거리, C를 사이클 길이, a를 진입점에서 사이클 내부의 만난 지점까지의 거리라고 하겠습니다. 두 포인터가 만났을 때 느린 포인터는 F + a만큼 이동했고, 빠른 포인터는 F + a + n*C만큼 이동했습니다(n번의 완전한 순회를 더 앞서 있습니다). 빠른 포인터의 이동 거리는 느린 포인터의 2배이므로 다음 식이 성립합니다. 2(F+a) = F+a+nC → F = nC - a. 즉 헤드에서 진입점까지의 거리는 만난 지점에서 진입점까지의 거리와 C를 법으로 하여 같습니다. 한 포인터를 헤드로 되돌리고 두 포인터를 1씩 이동하면 진입 노드에서 만나게 됩니다.
# Verify with our example: F=1 (head to node 2), C=3 (cycle: 2->0->-4->2), a=?
# Meeting inside cycle after F+a slow steps
# Let us measure a by counting from entry to meeting point
# In practice the code handles this automatically
F = 1 # head(3) to entry(2)
C = 3 # cycle length 2->0->-4
# n=1: F = 1*C - a => a = C - F = 3 - 1 = 2
a = C - F
print(f'F={F}, C={C}, a={a}')
print(f'After meeting, {F} more steps reach entry: {F == C - a or F % C == (C - a) % C}')사이클 길이 측정
플로이드 알고리즘의 1단계에서 사이클 내부의 만난 지점을 찾았다면 사이클 길이를 측정할 수 있습니다. 한 포인터는 고정하고 다른 포인터를 다시 만날 때까지 이동합니다. 이동한 단계 수가 사이클 길이와 같습니다. 사이클 길이를 명시적으로 묻는 문제에서 유용한 방법입니다.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def cycle_length(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast: # found meeting point
length = 1
fast = fast.next
while fast is not slow:
fast = fast.next
length += 1
return length
return 0 # no cycle
nodes = [ListNode(v) for v in [1, 2, 3, 4, 5]]
for i in range(4):
nodes[i].next = nodes[i+1]
nodes[4].next = nodes[2] # cycle: 3->4->5->3, length=3
print(cycle_length(nodes[0])) # 3행복한 수 (리스트 없이 사이클 탐지)
플로이드 알고리즘은 연결 리스트에만 한정되지 않습니다. LeetCode 202의 '행복한 수'는 n을 각 자릿수의 제곱합으로 반복해서 바꿀 때 결국 1에 도달하는지 묻습니다. 1을 포함하지 않는 사이클에 들어가면 영원히 반복됩니다. 이를 각 노드의 ‘다음 값’이 다음에 계산된 값인 가상의 연결 리스트 순회로 모델링한 다음, 플로이드 알고리즘을 적용해 사이클을 탐지할 수 있습니다.
def isHappy(n):
def next_val(x):
total = 0
while x:
x, d = divmod(x, 10)
total += d * d
return total
slow, fast = n, next_val(n)
while fast != 1 and slow != fast:
slow = next_val(slow)
fast = next_val(next_val(fast))
return fast == 1
print(isHappy(19)) # True (1->81+1=82->68->100->1)
print(isHappy(2)) # False (enters cycle)단순 집합 기반 탐지와 플로이드 방식 비교
집합 기반 방식은 방문한 각 노드를 집합에 저장하고 방문하기 전에 포함 여부를 확인합니다. O(n) 시간과 O(n) 공간이 필요합니다. 플로이드 방식도 O(n) 시간이지만 추가 자료 구조 없이 O(1) 공간만 사용합니다. 메모리가 제한된 환경(임베디드 시스템, 운영 체제 커널)에서는 O(1) 공간을 보장하는 것이 중요합니다. 면접관은 집합을 사용한 해법을 제시한 뒤 후속 질문으로 O(1) 공간을 요구하는 경우가 있습니다.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
# Naive O(n) space approach
def hasCycle_set(head):
seen = set()
while head:
if id(head) in seen:
return True
seen.add(id(head))
head = head.next
return False
# Floyd's O(1) space approach
def hasCycle_floyd(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
print('Both implementations give the same result')사이클 탐지의 예외 사례
처리해야 할 예외 사례는 세 가지입니다. 첫째, 빈 리스트입니다. head is None인 경우 플로이드 알고리즘의 반복 조건인 fast and fast.next가 즉시 거짓이 되어 반복이 끝나고 False를 반환합니다. 둘째, 사이클이 없는 단일 노드입니다. fast.next가 None이므로 반복이 끝나고 False를 반환합니다. 셋째, 사이클이 있는 단일 노드입니다. 노드의 next가 자기 자신을 가리키는 경우, 느린 포인터와 빠른 포인터는 모두 헤드에서 시작합니다. 한 단계 후 빠른 포인터는 두 번 이동해 다시 헤드에 도달하고, 느린 포인터는 한 번 이동해 헤드에 있습니다. 따라서 첫 번째 반복에서 바로 두 포인터가 같아집니다.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def hasCycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
# Edge cases
print(hasCycle(None)) # False: empty
node = ListNode(1)
print(hasCycle(node)) # False: single, no cycle
node.next = node
print(hasCycle(node)) # True: single node cycle연결 리스트 사이클 II: LeetCode 142
LeetCode 142의 '연결 리스트 사이클 II'는 사이클이 시작되는 노드를 반환하도록 요구합니다(사이클이 없으면 없음). 이는 2단계 플로이드 알고리즘을 직접 적용하는 문제입니다. 면접관은 기본 사이클 탐지 문제의 후속 질문으로 이 문제를 제시합니다. 전체 해법은 다음과 같습니다. 1단계에서 사이클 내부의 만난 지점을 찾고, 2단계에서 한 포인터를 헤드로 되돌린 다음 두 포인터를 모두 앞으로 이동하여 만날 때까지 진행합니다. 그 만난 지점이 사이클 진입점입니다.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def detectCycle(head):
slow = fast = head
# Phase 1
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
break
else:
return None
# Phase 2
ptr = head
while ptr is not slow:
ptr = ptr.next
slow = slow.next
return ptr
nodes = [ListNode(v) for v in [1, 2, 3, 4, 5]]
for i in range(4):
nodes[i].next = nodes[i+1]
nodes[4].next = nodes[2] # cycle entry: node with val=3
entry = detectCycle(nodes[0])
print(entry.val) # 3플로이드 방식이 집합 방식을 능가하는 이유
두 방식 모두 O(n) 시간이 필요하지만 실제로는 상수 계수가 다릅니다. 집합 방식은 각 노드 포인터를 해시해야 합니다(해시를 계산하고, 해시 테이블을 조회하고, 포인터를 저장합니다). 반면 플로이드 방식은 포인터 역참조만 수행하므로 단계당 비용이 훨씬 낮습니다. 더 중요한 점은 O(1) 공간을 보장하므로 메모리 부족 위험 없이 임의로 긴 리스트에서도 플로이드 방식을 실행할 수 있다는 것입니다.
면접에서 이러한 공간상의 이점을 먼저 언급하면 단순한 빅오 표기법을 넘어 알고리즘상의 장단점을 깊이 이해하고 있음을 보여 줄 수 있습니다.
빠른 확인
이 수업에서 배운 자료 구조 및 알고리즘 — 코딩 면접 준비 개념에 대한 이해도를 확인해 보십시오.
수업 복습
이 수업에서는 다음을 배웠습니다. 플로이드의 느린-빠른 포인터 알고리즘은 O(n) 시간과 O(1) 공간으로 사이클을 탐지합니다. 2단계에서 한 포인터를 헤드로 되돌리고 두 포인터를 1씩 이동하면 정확한 사이클 진입 노드를 찾을 수 있습니다. 또한 ‘다음 값’이 함수로 정의되는 모든 암시적 수열에 같은 기법을 연결 리스트 외에도 적용할 수 있습니다. 다음으로 정렬된 리스트 병합, 중간 지점에서의 리스트 분할, 뒤에서 n번째 노드 찾기를 다루겠습니다.
자주 묻는 질문
“Floyd 알고리즘으로 순환 탐지” 강의는 무료인가요?
네 — “Floyd 알고리즘으로 순환 탐지” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DSA Interview Prep 강의 전체를 잠금 해제할 수 있습니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.
“Floyd 알고리즘으로 순환 탐지”에서 뭘 배우나요?
느린 포인터와 빠른 포인터 방식으로 순환을 탐지하고 순환의 진입점을 찾은 뒤 알고리즘의 정확성을 수학적으로 증명합니다. 브라우저에서 직접 실행하는 실습 코드로 DSA Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
DSA Interview Prep을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 DSA Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.
“Floyd 알고리즘으로 순환 탐지” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 DSA Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 DSA Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 노드 클래스와 리스트 구성
- 연결 리스트 뒤집기
- Floyd 알고리즘으로 순환 탐지
- 병합, 분할, 뒤에서 N번째 찾기