0Pricing
DSA Interview Prep · 강의

투 포인터: 느린 포인터와 빠른 포인터

느린 포인터와 빠른 포인터 패턴을 적용해 제자리에서 중복을 제거하고, 0을 이동하며, 피벗 값을 기준으로 배열을 분할합니다.

투 포인터: 느린 포인터와 빠른 포인터은(는) CoddyKit의 무료 DSA Interview Prep 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 DSA Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

느린 포인터와 빠른 포인터 설명

느린 포인터-빠른 포인터 패턴은 거북이와 토끼 패턴이라고도 하며, 같은 시퀀스에서 서로 다른 속도로 이동하는 두 포인터를 사용합니다. 양 끝 포인터와 달리 두 포인터 모두 시작 지점에서 출발합니다. 느린 포인터는 한 번에 한 단계씩 이동하고, 빠른 포인터는 두 단계 이상 이동합니다. 두 포인터의 속도 차이는 유용한 불변식을 만듭니다. 느린 포인터는 유효한 접두부를 추적하고, 빠른 포인터는 앞쪽을 훑으며 조건을 확인합니다.

# Slow pointer marks the write position;
# Fast pointer scans for next non-duplicate.

def remove_duplicates(nums):
    if not nums: return 0
    slow = 0  # next position to write a unique value
    for fast in range(1, len(nums)):
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]
    return slow + 1  # new length

nums = [1, 1, 2, 3, 3, 3, 4]
k = remove_duplicates(nums)
print(nums[:k])  # [1, 2, 3, 4]

정렬된 배열에서 중복 제거

정렬된 배열에서는 중복 값이 서로 인접해 있습니다. 느린 포인터는 기록된 마지막 고유 값을 추적하고, 빠른 포인터는 앞쪽을 훑습니다. 빠른 포인터가 nums[slow]와 다른 값에 도달할 때마다 느린 포인터를 이동하고 새 값을 복사합니다. 이 제자리 알고리즘은 O(n) 시간과 O(1) 추가 공간으로 실행되며, 읽기-쓰기 포인터 패턴의 숙련도를 확인하는 대표적인 면접 문제입니다.

def remove_duplicates_v2(nums):
    slow = 0
    for fast in range(len(nums)):
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]
    return slow + 1

# Allow at most 2 occurrences
def remove_duplicates_k2(nums):
    slow = 0
    for fast in range(len(nums)):
        if slow < 2 or nums[fast] != nums[slow - 2]:
            nums[slow] = nums[fast]
            slow += 1
    return slow

print(remove_duplicates_k2([1,1,1,2,2,3]))
# Result: 5, nums[:5] = [1,1,2,2,3]

느린 포인터와 빠른 포인터로 0 이동하기

0이 아닌 요소의 상대적 순서를 유지하면서 모든 0을 끝으로 이동합니다. 느린 포인터는 0이 아닌 요소를 기록할 다음 위치를 가리킵니다. 빠른 포인터는 0이 아닌 값을 찾으며 배열을 훑습니다. 빠른 포인터가 그런 값을 찾으면 느린 포인터의 위치에 복사하고 두 포인터를 모두 이동합니다. 탐색이 끝난 뒤 느린 포인터부터 끝까지의 위치를 0으로 채웁니다. 시간 복잡도는 O(n), 공간 복잡도는 O(1)입니다.

def move_zeroes(nums):
    slow = 0  # next position for a non-zero
    for fast in range(len(nums)):
        if nums[fast] != 0:
            nums[slow] = nums[fast]
            slow += 1
    # Fill rest with zeroes
    while slow < len(nums):
        nums[slow] = 0
        slow += 1

nums = [0, 1, 0, 3, 12]
move_zeroes(nums)
print(nums)  # [1, 3, 12, 0, 0]

피벗을 기준으로 배열 분할하기

퀵 정렬의 분할 단계는 모든 값이 < 피벗인 값은 >= 피벗인 값보다 앞에 오도록 요소를 제자리에서 재배열합니다. Lomuto 방식은 작은 요소의 마지막 위치를 표시하는 느린 포인터와 앞쪽을 훑는 빠른 포인터를 사용합니다. 빠른 포인터가 작은 요소를 찾으면 느린 포인터를 증가시키고 두 요소를 교환합니다. 이 방식은 O(n) 시간과 O(1) 추가 공간으로 실행됩니다.

def lomuto_partition(nums, low, high):
    pivot = nums[high]
    slow = low - 1  # last position of small element
    for fast in range(low, high):
        if nums[fast] <= pivot:
            slow += 1
            nums[slow], nums[fast] = nums[fast], nums[slow]
    # Place pivot in final position
    nums[slow+1], nums[high] = nums[high], nums[slow+1]
    return slow + 1  # pivot's final index

arr = [3, 1, 4, 1, 5, 9, 2, 6]
p = lomuto_partition(arr, 0, len(arr)-1)
print(arr)   # elements before p are <= pivot

연결 리스트의 중간 찾기

연결 리스트에서 느린 포인터와 빠른 포인터를 사용하면 빠른 포인터는 한 단계마다 노드 두 개를 이동하고 느린 포인터는 한 개를 이동합니다. 빠른 포인터가 끝에 도달하면 느린 포인터가 중간에 있습니다. 이 O(n) 단일 탐색 방식은 노드 수를 센 다음 중간까지 다시 이동하는 것보다 훨씬 깔끔합니다. 연결 리스트의 병합 정렬과 회문 연결 리스트 탐지에서 하위 단계로 사용됩니다.

class Node:
    def __init__(self, val, nxt=None):
        self.val = val
        self.next = nxt

def find_middle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow  # slow is at middle

# Build 1->2->3->4->5
h = Node(1, Node(2, Node(3, Node(4, Node(5)))))
mid = find_middle(h)
print(mid.val)  # 3  (middle of 5 nodes)

사이클 탐지: Floyd의 거북이와 토끼

Floyd의 사이클 탐지 알고리즘은 연결 리스트의 시작 노드에 느린 포인터와 빠른 포인터를 둡니다. 느린 포인터는 한 번에 노드 하나를 이동하고, 빠른 포인터는 두 개를 이동합니다. 사이클이 존재하면 빠른 포인터가 결국 느린 포인터를 따라잡아 사이클 내부에서 만납니다. 빠른 포인터가 비어 있는 값을 가리키면 사이클이 없습니다. 매 반복에서 빠른 포인터가 느린 포인터보다 한 단계씩 더 앞서므로 만남은 반드시 일어납니다. 사이클 길이가 k라면 느린 포인터가 사이클에 진입한 뒤 k단계 이내에 만납니다.

class ListNode:
    def __init__(self, val=0, nxt=None):
        self.val = val
        self.next = nxt

def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
        if slow is fast:  # identity check (same object)
            return True
    return False

# 1->2->3->4->2 (cycle at node 2)
n1 = ListNode(1)
n2 = ListNode(2)
n3 = ListNode(3)
n4 = ListNode(4)
n1.next=n2; n2.next=n3; n3.next=n4; n4.next=n2
print(has_cycle(n1))  # True

사이클 진입점 찾기

사이클을 탐지한 뒤 한 포인터를 시작 노드로 되돌립니다. 이제 두 포인터를 한 번에 한 단계씩 함께 이동합니다. 두 포인터는 사이클의 진입점에서 만나게 됩니다. 이는 시작 노드에서 사이클 진입점까지의 거리와 만남 지점에서 사이클 진입점까지의 거리가 사이클 길이를 기준으로 같다는 수학적 성질을 이용합니다. 이 아름다운 수학적 결과는 난도 높은 면접 문제에 자주 등장합니다.

def detect_cycle(head):
    slow = fast = head
    # Phase 1: detect
    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
    slow = head
    while slow is not fast:
        slow = slow.next
        fast = fast.next
    return slow  # cycle entry node

# Using same cycled list as previous scene
print(detect_cycle(n1).val)  # 2  (cycle entry)

행복한 수에 느린 포인터와 빠른 포인터 적용

느린 포인터와 빠른 포인터는 연결 리스트를 넘어 사이클이 발생하는 모든 과정에 적용할 수 있습니다. 행복한 수는 각 자릿수의 제곱을 더한 값들을 순환합니다. n이 행복한 수가 아니면 이 시퀀스는 결국 반복됩니다. 느린 포인터는 한 단계씩, 즉 한 번의 자릿수 제곱 합씩 이동하고 빠른 포인터는 두 단계씩 이동하도록 하여 반복을 탐지합니다. 두 포인터가 1에서 만나면 n은 행복한 수이고, 그렇지 않으면 1이 아닌 사이클에 갇힌 것입니다. 이는 가상의 값 연결 리스트에 Floyd 알고리즘을 적용한 것입니다.

def is_happy(n):
    def next_val(x):
        total = 0
        while x:
            x, d = divmod(x, 10)
            total += d * d
        return total

    slow = n
    fast = next_val(n)
    while fast != 1 and slow != fast:
        slow = next_val(slow)
        fast = next_val(next_val(fast))
    return fast == 1

print(is_happy(19))   # True  (1->9->...->1)
print(is_happy(2))    # False (enters a cycle)

리스트 끝에서 n번째 노드

두 포인터를 사용하면 연결 리스트를 한 번만 탐색하면서 끝에서 n번째 노드를 찾을 수 있습니다. 빠른 포인터를 n단계 앞까지 먼저 이동합니다. 그런 다음 빠른 포인터가 끝에 도달할 때까지 두 포인터를 함께 이동하면 느린 포인터가 끝에서 n번째 노드를 가리키게 됩니다. 이 노드를 삭제하려면 느린 포인터보다 한 단계 뒤에 있는 이전 노드 포인터를 유지하십시오. 전체 길이를 먼저 세지 않아도 되는 대표적인 단일 탐색 연결 리스트 문제입니다.

def remove_nth_from_end(head, n):
    dummy = ListNode(0)
    dummy.next = head
    fast = slow = dummy
    # Advance fast n+1 steps
    for _ in range(n + 1):
        fast = fast.next
    # Advance together
    while fast:
        slow = slow.next
        fast = fast.next
    # slow.next is the nth from end
    slow.next = slow.next.next
    return dummy.next

# Build 1->2->3->4->5, remove 2nd from end
h2 = ListNode(1,ListNode(2,ListNode(3,ListNode(4,ListNode(5)))))
result = remove_nth_from_end(h2, 2)
# Should give 1->2->3->5

문자열 문제에서 느린 포인터와 빠른 포인터

느린 포인터와 빠른 포인터를 활용하는 사고방식은 배열과 문자열 문제에도 적용됩니다. 런 길이 인코딩 문자열을 압축할 때 느린 포인터는 기록 위치를 표시하고 빠른 포인터는 각 연속 구간의 끝까지 훑습니다. 구간의 모든 문자가 느린 포인터가 가리키는 문자와 같으면 빠른 포인터를 이동하고, 그렇지 않으면 해당 구간을 기록한 뒤 느린 포인터를 갱신합니다. 이를 통해 O(1) 공간으로 한 번의 탐색에서 O(n)을 달성할 수 있습니다.

def compress(chars):
    slow = fast = 0
    while fast < len(chars):
        char = chars[fast]
        count = 0
        # Count the run
        while fast < len(chars) and chars[fast] == char:
            fast += 1
            count += 1
        chars[slow] = char
        slow += 1
        if count > 1:
            for c in str(count):
                chars[slow] = c
                slow += 1
    return slow

chars = list('aabcccccaa')
print(compress(chars))  # 6
print(chars[:6])        # ['a','2','b','c','5','a']... wait
# Actually: ['a','2','b','c','5','a','2']

느린 포인터와 빠른 포인터, 양 끝 포인터 중 선택하기

목표값을 만드는 쌍, 회문 검사 또는 양쪽에서 범위를 좁히는 문제가 포함되면 양 끝 포인터를 사용하십시오. 기록용 포인터가 필요하거나 요소를 제거 또는 이동해야 할 때, 연결 리스트 구조를 처리할 때(중간, 사이클), 또는 어떤 값 시퀀스에서든 사이클을 탐지할 때는 느린 포인터와 빠른 포인터를 사용하십시오. 두 방식 모두 중첩 반복을 제거하고 O(n)을 달성하며, 어느 방식을 선택할지는 순회 구조에 따라 결정됩니다.

# Pattern matcher:
# 1. Sorted array, target sum -> OPPOSITE ENDS
# 2. Remove/filter elements in-place -> SLOW-FAST (read-write)
# 3. Linked list middle/cycle -> SLOW-FAST (1x vs 2x speed)
# 4. Detect cycle in value sequence -> SLOW-FAST (Floyd)

# Example: given sorted array, remove val in-place
def remove_sorted(nums, val):
    slow = 0
    for fast in range(len(nums)):
        if nums[fast] != val:
            nums[slow] = nums[fast]
            slow += 1
    return slow

nums = [0,1,2,2,3,0,4,2]
print(remove_sorted(nums, 2))  # 5

빠른 점검

이 단원에서 배운 자료 구조 및 알고리즘 — 코딩 면접 준비 개념에 대한 이해도를 점검합니다.

단원 요약

이 단원에서는 다음을 배웠습니다. 느린 포인터와 빠른 포인터를 사용하는 읽기-쓰기 패턴은 빠른 포인터가 앞쪽을 훑는 동안 기록용 포인터를 다음 유효한 위치에 유지하며, 제자리 제거, 중복 제거, 0 이동의 기반이 됩니다. Floyd의 거북이와 토끼 알고리즘은 두 포인터의 속도 차이를 이용해 O(n) 시간과 O(1) 공간으로 사이클을 탐지합니다. 또한 사이클을 탐지한 뒤 한 포인터를 시작 노드로 되돌리고 두 포인터를 같은 속도로 이동하면 증명 가능한 거리의 등식에 따라 사이클 진입점을 찾을 수 있습니다. 다음에는 면접을 위한 Python 문자열 API를 살펴보겠습니다.

자주 묻는 질문

“투 포인터: 느린 포인터와 빠른 포인터” 강의는 무료인가요?

네 — “투 포인터: 느린 포인터와 빠른 포인터” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DSA Interview Prep 강의 전체를 잠금 해제할 수 있습니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

“투 포인터: 느린 포인터와 빠른 포인터”에서 뭘 배우나요?

느린 포인터와 빠른 포인터 패턴을 적용해 제자리에서 중복을 제거하고, 0을 이동하며, 피벗 값을 기준으로 배열을 분할합니다. 브라우저에서 직접 실행하는 실습 코드로 DSA Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

DSA Interview Prep을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 DSA Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“투 포인터: 느린 포인터와 빠른 포인터” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 DSA Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 DSA Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 배열 기초와 제자리 연산
  2. 누적 합과 누적 합계
  3. 투 포인터: 양끝에서 이동하기
  4. 투 포인터: 느린 포인터와 빠른 포인터
← DSA Interview Prep(으)로 돌아가기