투 포인터(Two Pointers) 알고리즘 완벽 가이드 – 배열 탐색 O(n) 최적화의 정석

Updated Feb 6, 2026

투 포인터 알고리즘이란?

투 포인터(Two Pointers) 알고리즘은 배열이나 리스트에서 두 개의 포인터(인덱스)를 동시에 움직이며 원하는 조건을 만족하는 값을 탐색하는 기법입니다. 단순 이중 반복문의 O(n2)O(n^2) 시간 복잡도를 O(n)O(n)으로 줄일 수 있어, 코딩테스트에서 가장 자주 출제되는 최적화 패턴 중 하나입니다.

핵심 아이디어: 배열에서 두 포인터를 양 끝 또는 같은 방향에서 출발시켜, 조건에 따라 한쪽 포인터만 이동시키면 불필요한 탐색을 건너뛸 수 있다. 대향 이동 패턴은 정렬이 전제되고, 같은 방향 이동 패턴은 정렬 없이도 적용 가능하다.

왜 투 포인터를 배워야 하는가?

  • 시간 복잡도 개선: 브루트포스 O(n2)O(n^2) → 투 포인터 O(n)O(n)
  • 추가 공간 불필요: 대부분 O(1)O(1) 공간 복잡도
  • 출제 빈도 높음: 백준, 프로그래머스, LeetCode 등에서 꾸준히 출제
  • 다양한 응용: 부분합, 구간 탐색, 문자열 처리 등 폭넓게 활용

투 포인터의 두 가지 패턴

투 포인터는 크게 두 가지 패턴으로 나뉩니다. 문제 유형에 따라 적합한 패턴을 선택해야 합니다.

패턴 포인터 시작 위치 이동 방향 대표 문제
대향 이동 (Opposite Direction) 양 끝 (left=0, right=n-1) 서로를 향해 이동 두 수의 합, 물통 문제
같은 방향 이동 (Same Direction) 같은 쪽 (left=0, right=0) 같은 방향으로 이동 부분합, 슬라이딩 윈도우

패턴 1: 대향 이동 (Opposite Direction)

정렬된 배열의 양 끝에서 포인터를 출발시키고, 조건에 따라 한쪽씩 좁혀 나갑니다.

배열: [1, 3, 5, 7, 9, 11]
       ↑                 ↑
      left              right
  • 현재 합이 목표보다 작으면left를 오른쪽으로 (더 큰 값 선택)
  • 현재 합이 목표보다 크면right를 왼쪽으로 (더 작은 값 선택)
  • 현재 합이 목표와 같으면 → 정답 발견

패턴 2: 같은 방향 이동 (Same Direction)

두 포인터가 같은 방향으로 이동하며, right가 구간을 확장하고 left가 구간을 축소합니다. 슬라이딩 윈도우와 밀접한 관련이 있습니다.

배열: [1, 3, 5, 7, 9, 11]
       ↑  ↑
      left right  → 둘 다 오른쪽으로 이동

난이도별 문제 풀이

난이도 쉬움: 두 수의 합 (Two Sum – Sorted)

문제: 정렬된 배열에서 두 수의 합이 target이 되는 쌍을 찾아라.

이 문제는 투 포인터의 가장 기본적인 형태입니다. 대향 이동 패턴을 사용합니다.

브루트포스 vs 투 포인터 비교

방법 시간 복잡도 공간 복잡도 설명
브루트포스 (이중 for문) O(n2)O(n^2) O(1)O(1) 모든 쌍을 확인
해시맵 O(n)O(n) O(n)O(n) 추가 메모리 필요
투 포인터 O(n)O(n) O(1)O(1) 시간·공간 모두 최적

풀이 코드

def two_sum_sorted(arr, target):
    """
    정렬된 배열에서 합이 target인 두 수의 인덱스를 반환한다.

    시간 복잡도: O(n) - 각 포인터가 최대 n번 이동
    공간 복잡도: O(1) - 포인터 2개만 사용
    """
    left = 0              # 왼쪽 포인터: 배열의 시작
    right = len(arr) - 1  # 오른쪽 포인터: 배열의 끝

    while left < right:
        current_sum = arr[left] + arr[right]

        if current_sum == target:
            # 정답 발견
            return (left, right)
        elif current_sum < target:
            # 합이 부족 → 더 큰 값이 필요 → left를 오른쪽으로
            left += 1
        else:
            # 합이 초과 → 더 작은 값이 필요 → right를 왼쪽으로
            right -= 1

    return None  # 합이 target인 쌍이 없음


# 예제 실행
arr = [1, 3, 5, 7, 9, 11]
target = 12
result = two_sum_sorted(arr, target)
print(f"인덱스: {result}")  # (0, 5) → arr[0]+arr[5] = 1+11 = 12

동작 과정 시각화

배열 [1, 3, 5, 7, 9, 11], target = 12:

단계 left right arr[left] arr[right] 판단
1 0 5 1 11 12 정답!

만약 target = 10이었다면:

단계 left right arr[left] arr[right] 판단
1 0 5 1 11 12 12 > 10 → right–
2 0 4 1 9 10 정답!

난이도 보통: 연속 부분합 (Subarray Sum)

문제: 양수로 이루어진 배열에서 합이 S 이상인 가장 짧은 연속 부분 배열의 길이를 구하라. (백준 1806번 변형)

같은 방향 이동 패턴을 사용합니다. right로 구간을 확장하고, 조건을 만족하면 left로 구간을 축소하여 최소 길이를 갱신합니다.

핵심 원리

  1. right를 오른쪽으로 이동하며 구간 합을 확장
  2. 구간 합이 SS 이상이 되면, left를 오른쪽으로 이동하며 구간을 축소
  3. 축소하면서도 조건을 만족하는 동안 최소 길이를 갱신
  4. 조건을 만족하지 않으면 다시 right를 확장

풀이 코드

def min_subarray_length(arr, S):
    """
    합이 S 이상인 가장 짧은 연속 부분 배열의 길이를 반환한다.

    시간 복잡도: O(n) - left와 right 각각 최대 n번 이동
    공간 복잡도: O(1)
    """
    n = len(arr)
    left = 0
    current_sum = 0
    min_length = float('inf')  # 최소 길이를 무한대로 초기화

    for right in range(n):
        # 1단계: 구간 확장 - right가 가리키는 값을 합에 추가
        current_sum += arr[right]

        # 2단계: 구간 축소 - 합이 S 이상인 동안 left를 이동
        while current_sum >= S:
            # 현재 구간의 길이로 최솟값 갱신
            min_length = min(min_length, right - left + 1)
            # left를 오른쪽으로 이동 (구간 축소)
            current_sum -= arr[left]
            left += 1

    # 조건을 만족하는 구간이 없으면 0 반환
    return min_length if min_length != float('inf') else 0


# 예제 실행
arr = [2, 3, 1, 2, 4, 3]
S = 7
result = min_subarray_length(arr, S)
print(f"최소 길이: {result}")  # 2 → [4, 3]

동작 과정 시각화

배열 [2, 3, 1, 2, 4, 3], S = 7:

단계 left right 구간 구간 합 판단 min_length
1 0 0 [2] 2 2 < 7 → 확장
2 0 1 [2,3] 5 5 < 7 → 확장
3 0 2 [2,3,1] 6 6 < 7 → 확장
4 0 3 [2,3,1,2] 8 8 ≥ 7 → 갱신 & 축소 4
5 1 3 [3,1,2] 6 6 < 7 → 확장 4
6 1 4 [3,1,2,4] 10 10 ≥ 7 → 갱신 & 축소 4
7 2 4 [1,2,4] 7 7 ≥ 7 → 갱신 & 축소 3
8 3 4 [2,4] 6 6 < 7 → 확장 3
9 3 5 [2,4,3] 9 9 ≥ 7 → 갱신 & 축소 3
10 4 5 [4,3] 7 7 ≥ 7 → 갱신 & 축소 2
11 5 5 [3] 3 3 < 7 → 종료 2

최종 답: 2 (부분 배열 [4, 3])

시간 복잡도 분석

언뜻 보면 for 안에 while이 있어 O(n2)O(n^2)처럼 보이지만, left는 전체 실행 동안 최대 nn번만 증가합니다.

T(n)=O(n)+O(n)=O(2n)=O(n)T(n) = O(n) + O(n) = O(2n) = O(n)

  • 첫 번째 O(n)O(n): right가 0에서 n1n-1까지 이동
  • 두 번째 O(n)O(n): left가 전체 실행 동안 총 최대 nn번 이동

amortized 분석: 각 원소는 right에 의해 최대 1번 추가되고, left에 의해 최대 1번 제거됩니다. 따라서 총 연산은 O(2n)=O(n)O(2n) = O(n)입니다.


난이도 어려움: 세 수의 합 (3Sum)

문제: 배열에서 합이 0이 되는 중복 없는 세 수의 조합을 모두 찾아라. (LeetCode 15번)

정렬 + 고정 포인터 1개 + 투 포인터로 해결합니다. 이 문제는 중복 처리가 핵심 난이도입니다.

접근 전략

  1. 배열을 정렬한다: O(nlogn)O(n \log n)
  2. 첫 번째 수 arr[i]를 고정하고, 나머지 구간에서 투 포인터로 두 수를 찾는다
  3. arr[i] + arr[left] + arr[right] == 0인 조합을 수집
  4. 중복 건너뛰기로 같은 조합이 반복되지 않도록 처리

풀이 코드

def three_sum(nums):
    """
    합이 0인 세 수의 조합을 모두 반환한다 (중복 없음).

    시간 복잡도: O(n^2) - 외부 루프 O(n) × 내부 투 포인터 O(n)
    공간 복잡도: O(1) - 결과 리스트 제외 (정렬은 in-place)
    """
    nums.sort()  # 정렬: 투 포인터 사용을 위한 전제 조건
    result = []
    n = len(nums)

    for i in range(n - 2):
        # 최적화: 가장 작은 수가 양수면 합이 0이 될 수 없음
        if nums[i] > 0:
            break

        # 중복 건너뛰기: 같은 값의 i는 한 번만 처리
        if i > 0 and nums[i] == nums[i - 1]:
            continue

        # i를 고정하고, 나머지 구간에서 투 포인터 탐색
        left = i + 1
        right = n - 1
        target = -nums[i]  # 나머지 두 수의 합이 -nums[i]여야 함

        while left < right:
            current_sum = nums[left] + nums[right]

            if current_sum == target:
                result.append([nums[i], nums[left], nums[right]])

                # 중복 건너뛰기: left와 right 모두 같은 값은 스킵
                while left < right and nums[left] == nums[left + 1]:
                    left += 1
                while left < right and nums[right] == nums[right - 1]:
                    right -= 1

                # 양쪽 포인터 모두 이동
                left += 1
                right -= 1
            elif current_sum < target:
                left += 1
            else:
                right -= 1

    return result


# 예제 실행
nums = [-1, 0, 1, 2, -1, -4]
result = three_sum(nums)
print(f"결과: {result}")  # [[-1, -1, 2], [-1, 0, 1]]

동작 과정 시각화

입력: [-1, 0, 1, 2, -1, -4] → 정렬 후: [-4, -1, -1, 0, 1, 2]

i = 0 (nums[i] = -4), target = 4:

left right nums[left] nums[right] 판단
1 5 -1 2 1 1 < 4 → left++
2 5 -1 2 1 1 < 4 → left++
3 5 0 2 2 2 < 4 → left++
4 5 1 2 3 3 < 4 → left++
left ≥ right → 종료

i = 1 (nums[i] = -1), target = 1:

left right nums[left] nums[right] 판단
2 5 -1 2 1 ✅ 발견! [-1, -1, 2]
3 4 0 1 1 ✅ 발견! [-1, 0, 1]
left ≥ right → 종료

i = 2 (nums[i] = -1): nums[2] == nums[1]이므로 중복 건너뛰기

최종 결과: [[-1, -1, 2], [-1, 0, 1]]


투 포인터 vs 슬라이딩 윈도우

투 포인터와 슬라이딩 윈도우는 자주 혼동됩니다. 둘의 관계를 정리합니다.

구분 투 포인터 슬라이딩 윈도우
정의 두 포인터로 조건 탐색 고정/가변 크기 구간을 이동
관계 상위 개념 투 포인터의 특수한 형태
포인터 방향 대향 또는 같은 방향 항상 같은 방향
구간 의미 두 위치를 가리킴 연속 구간(윈도우)을 나타냄
대표 문제 Two Sum, 3Sum 최소 부분합, 최장 부분 문자열

슬라이딩 윈도우는 투 포인터의 부분집합입니다. 같은 방향 이동 패턴에서 두 포인터 사이의 구간이 “윈도우” 역할을 할 때 슬라이딩 윈도우라고 부릅니다.


실전 응용: 물통 문제 (Container With Most Water)

문제: 높이 배열이 주어질 때, 두 벽을 골라 가장 많은 물을 담을 수 있는 넓이를 구하라. (LeetCode 11번)

물의 양은 다음 수식으로 계산됩니다:

Area=min(h[left],h[right])×(rightleft)\text{Area} = \min(h[\text{left}],\, h[\text{right}]) \times (\text{right} – \text{left})

  • h[left]h[\text{left}], h[right]h[\text{right}]: 왼쪽, 오른쪽 벽의 높이
  • rightleft\text{right} – \text{left}: 두 벽 사이의 거리 (밑변)
  • 물의 높이는 낮은 쪽 벽에 의해 결정됨

포인터 이동 전략의 수학적 근거

두 포인터 중 높이가 낮은 쪽을 이동시킵니다. 왜일까요?

현재 넓이가 A=min(hl,hr)×(rl)A = \min(h_l, h_r) \times (r – l)일 때:

  • 어느 쪽을 이동하든 밑변 (rl)(r – l)1 줄어듭니다
  • 높은 쪽을 이동하면: min\min 값이 같거나 줄어듦 → 넓이가 반드시 감소
  • 낮은 쪽을 이동하면: min\min 값이 커질 가능성 있음 → 넓이가 증가할 수도 있음

따라서 높은 쪽을 이동하는 것은 절대 이득이 없으므로, 낮은 쪽을 이동하는 것이 최적입니다.

def max_area(height):
    """
    가장 많은 물을 담을 수 있는 넓이를 반환한다.

    시간 복잡도: O(n)
    공간 복잡도: O(1)
    """
    left = 0
    right = len(height) - 1
    max_water = 0

    while left < right:
        # 현재 두 벽으로 담을 수 있는 물의 양
        width = right - left
        h = min(height[left], height[right])
        max_water = max(max_water, width * h)

        # 낮은 쪽 벽을 이동 (높은 벽을 만날 가능성을 열어둠)
        if height[left] < height[right]:
            left += 1
        else:
            right -= 1

    return max_water


# 예제 실행
height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
print(f"최대 물의 양: {max_area(height)}")  # 49

자주 하는 실수와 엣지 케이스

실수 모음

실수 설명 해결책
정렬을 잊음 대향 이동 패턴은 정렬이 전제 투 포인터 적용 전 반드시 정렬 여부 확인
중복 처리 누락 3Sum 등에서 같은 조합 반복 출력 같은 값은 while로 건너뛰기
무한 루프 포인터를 이동시키지 않는 분기 존재 모든 분기에서 최소 하나의 포인터가 이동하는지 확인
off-by-one 에러 left <= right vs left < right 두 포인터가 같은 위치를 가리켜도 되는지 문제 조건 확인
음수 원소 간과 음수가 포함되면 같은 방향 패턴이 안 될 수 있음 양수 전용인지 확인 후 패턴 선택

엣지 케이스 체크리스트

  • 배열 길이가 0 또는 1: 쌍을 만들 수 없음
  • 모든 원소가 같은 값: 중복 처리 로직 검증
  • 정답이 배열의 양 끝에 있는 경우: 첫 번째 비교에서 바로 발견되어야 함
  • 정답이 없는 경우: None 또는 빈 리스트 반환 처리
  • 매우 큰 배열: O(n)O(n) 알고리즘이 맞는지 확인

투 포인터 적용 판별 체크리스트

문제를 읽고 아래 조건 중 2개 이상 해당하면 투 포인터를 고려하세요.

  1. ✅ 배열이 정렬되어 있거나 정렬해도 답에 영향이 없다
  2. 두 개의 위치를 동시에 고려해야 한다 (쌍, 구간, 양 끝)
  3. ✅ 브루트포스가 O(n2)O(n^2)이고 O(n)O(n)으로 줄여야 한다
  4. ✅ “연속 부분 배열”, “구간의 합/곱”, “가장 긴/짧은” 같은 키워드가 있다
  5. ✅ 추가 공간 사용이 제한된다 (O(1)O(1) 또는 in-place)

: “정렬된 배열에서 두 수의 조합”이 보이면 거의 100% 투 포인터 문제입니다.


변형 문제와 응용

난이도별 추천 문제

난이도 문제 플랫폼 패턴
Two Sum II (정렬된 배열) LeetCode 167 대향 이동
수들의 합 2 백준 2003 같은 방향
⭐⭐ 부분합 백준 1806 같은 방향
⭐⭐ 3Sum LeetCode 15 고정 + 대향
⭐⭐ Container With Most Water LeetCode 11 대향 이동
⭐⭐ 두 용액 백준 2470 대향 이동
⭐⭐⭐ 4Sum LeetCode 18 이중 고정 + 대향
⭐⭐⭐ Trapping Rain Water LeetCode 42 대향 이동 + 최대값 추적
⭐⭐⭐ 회전 초밥 백준 2531 슬라이딩 윈도우

응용 팁

  • k-Sum 문제: 외부 루프 (k2)(k-2)개 + 내부 투 포인터로 일반화 가능. 시간 복잡도 O(nk1)O(n^{k-1})
  • 정렬이 안 된 배열: 해시맵과 조합하거나, 정렬 비용 O(nlogn)O(n \log n)이 허용되는지 확인
  • 문자열에서의 투 포인터: 팰린드롬 판별, 유효한 괄호 등에도 활용 가능
  • 연결 리스트에서의 투 포인터: 플로이드 순환 검출(토끼와 거북이), 중간 노드 찾기

투 포인터 문제 풀이 템플릿

코딩테스트에서 빠르게 활용할 수 있는 템플릿입니다.

대향 이동 템플릿

def opposite_direction(arr, target):
    arr.sort()  # 정렬 필수
    left, right = 0, len(arr) - 1

    while left < right:
        val = arr[left] + arr[right]  # 조건에 맞게 수정

        if val == target:
            # 정답 처리
            return (left, right)
        elif val < target:
            left += 1
        else:
            right -= 1

    return None

같은 방향 (슬라이딩 윈도우) 템플릿

def same_direction(arr, condition):
    left = 0
    current = 0  # 윈도우 내 상태 (합, 개수 등)
    answer = float('inf')  # 또는 0 (최대/최소에 따라)

    for right in range(len(arr)):
        # 윈도우 확장
        current += arr[right]  # 조건에 맞게 수정

        # 조건 만족 시 윈도우 축소
        while current >= condition:  # 조건에 맞게 수정
            answer = min(answer, right - left + 1)
            current -= arr[left]
            left += 1

    return answer

마무리

투 포인터 알고리즘의 핵심을 정리합니다.

  • 핵심 원리: 두 개의 포인터를 조건에 따라 이동시켜 불필요한 탐색을 제거하는 기법
  • 두 가지 패턴: 대향 이동(양 끝 → 중앙)과 같은 방향 이동(슬라이딩 윈도우)
  • 시간 복잡도: 브루트포스 O(n2)O(n^2)O(n)O(n)으로 개선 (정렬 필요 시 O(nlogn)O(n \log n))
  • 공간 복잡도: 대부분 O(1)O(1)로 메모리 효율적
  • 적용 조건: 정렬된 배열, 연속 부분 배열, 쌍(pair) 탐색 문제에서 위력 발휘
  • 중복 처리: 3Sum 같은 문제에서 같은 값 건너뛰기가 핵심 난이도
  • 확장: k-Sum, 문자열 팰린드롬, 연결 리스트 순환 검출 등으로 응용 가능

투 포인터는 단순하지만 강력한 기법입니다. 위의 세 가지 난이도별 문제를 직접 구현해 보면, 대부분의 투 포인터 유형을 자신 있게 풀 수 있을 것입니다.

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 75 | TOTAL 2,772