배열에서 특정 조건을 만족하는 구간을 찾아야 합니다. 이중 for문을 돌리면 O(n²)... 더 빠른 방법은 없을까요?

개념 정의

투 포인터는 두 개의 포인터(인덱스) 를 사용하여 배열을 효율적으로 탐색하는 기법입니다. 이중 for문이 필요한 문제를 O(n)으로 줄여주는 경우가 많습니다.

두 가지 유형이 있습니다:

  • **양끝에서 시작 **: 왼쪽 끝과 오른쪽 끝에서 시작하여 안쪽으로 좁혀감
  • ** 같은 방향 **: 두 포인터가 같은 방향으로 이동 (슬라이딩 윈도우와 유사)

유형 1: 양끝에서 좁히기

정렬된 배열에서 Two Sum

JAVA
// 정렬된 배열에서 합이 target인 두 수의 인덱스 찾기
public static int[] twoSum(int[] arr, int target) {
    int left = 0, right = arr.length - 1;

    while (left < right) {
        int sum = arr[left] + arr[right];

        if (sum == target) {
            return new int[]{left, right};
        } else if (sum < target) {
            left++;   // 합을 키워야 하므로 왼쪽 포인터를 오른쪽으로
        } else {
            right--;  // 합을 줄여야 하므로 오른쪽 포인터를 왼쪽으로
        }
    }
    return new int[]{-1, -1}; // 없음
}

왜 이게 되는 걸까요? 배열이 정렬되어 있으므로:

  • 합이 작으면 더 큰 수가 필요 → left를 오른쪽으로
  • 합이 크면 더 작은 수가 필요 → right를 왼쪽으로
  • 각 포인터는 한 방향으로만 이동하므로 O(n)

물 담기 문제 (Container With Most Water)

JAVA
public static int maxArea(int[] height) {
    int left = 0, right = height.length - 1;
    int maxWater = 0;

    while (left < right) {
        int h = Math.min(height[left], height[right]);
        int width = right - left;
        maxWater = Math.max(maxWater, h * width);

        // 더 낮은 쪽을 이동시켜야 높이를 높일 가능성이 있음
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    return maxWater;
}

유형 2: 같은 방향 이동

부분 배열의 합이 target 이상인 최소 길이

JAVA
public static int minSubArrayLen(int target, int[] nums) {
    int left = 0, sum = 0;
    int minLen = Integer.MAX_VALUE;

    for (int right = 0; right < nums.length; right++) {
        sum += nums[right]; // 오른쪽 포인터 확장

        while (sum >= target) {
            minLen = Math.min(minLen, right - left + 1);
            sum -= nums[left]; // 왼쪽 포인터 축소
            left++;
        }
    }

    return minLen == Integer.MAX_VALUE ? 0 : minLen;
}

right는 항상 앞으로, left도 항상 앞으로만 이동합니다. 총 이동 횟수가 O(n)이므로 전체 시간 복잡도도 O(n)입니다.

중복 없는 가장 긴 부분 문자열

JAVA
public static int lengthOfLongestSubstring(String s) {
    Map<Character, Integer> lastIndex = new HashMap<>();
    int left = 0, maxLen = 0;

    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);

        // 중복 문자가 현재 윈도우 안에 있으면 left를 이동
        if (lastIndex.containsKey(c) && lastIndex.get(c) >= left) {
            left = lastIndex.get(c) + 1;
        }

        lastIndex.put(c, right);
        maxLen = Math.max(maxLen, right - left + 1);
    }
    return maxLen;
}

슬라이딩 윈도우(Sliding Window)

슬라이딩 윈도우는 투 포인터의 특수한 형태입니다. 연속된 구간(윈도우) 을 유지하면서 한 칸씩 밀어가며 계산합니다.

고정 크기 윈도우

JAVA
// 크기 k인 연속 부분 배열의 최대 합
public static int maxSumSubarray(int[] arr, int k) {
    // 첫 윈도우의 합 계산
    int windowSum = 0;
    for (int i = 0; i < k; i++) {
        windowSum += arr[i];
    }

    int maxSum = windowSum;

    // 윈도우를 한 칸씩 밀기
    for (int i = k; i < arr.length; i++) {
        windowSum += arr[i];       // 새로 들어오는 원소 추가
        windowSum -= arr[i - k];   // 나가는 원소 제거
        maxSum = Math.max(maxSum, windowSum);
    }

    return maxSum;
}

매번 k개를 다시 합산하면 O(nk)이지만, 슬라이딩 윈도우를 쓰면 O(n)입니다.

가변 크기 윈도우

조건을 만족하는 동안 윈도우를 확장하고, 조건이 깨지면 축소합니다.

JAVA
// 합이 target 이하인 가장 긴 부분 배열 (양수만 있는 경우)
public static int longestSubarrayWithSum(int[] arr, int target) {
    int left = 0, sum = 0, maxLen = 0;

    for (int right = 0; right < arr.length; right++) {
        sum += arr[right];

        while (sum > target) {
            sum -= arr[left];
            left++;
        }

        maxLen = Math.max(maxLen, right - left + 1);
    }
    return maxLen;
}

슬라이딩 윈도우 최대값 (Deque 활용)

크기 k인 윈도우의 최대값을 매번 구하는 문제입니다. Deque를 사용하면 O(n)에 해결할 수 있습니다.

JAVA
public static int[] maxSlidingWindow(int[] nums, int k) {
    Deque<Integer> deque = new ArrayDeque<>(); // 인덱스 저장
    int[] result = new int[nums.length - k + 1];

    for (int i = 0; i < nums.length; i++) {
        // 윈도우 범위를 벗어난 원소 제거
        while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            deque.pollFirst();
        }

        // 현재 원소보다 작은 원소들은 더 이상 최대값이 될 수 없으므로 제거
        while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
            deque.pollLast();
        }

        deque.offerLast(i);

        // 윈도우가 완성된 시점부터 결과 기록
        if (i >= k - 1) {
            result[i - k + 1] = nums[deque.peekFirst()];
        }
    }
    return result;
}

투 포인터 적용 조건

투 포인터가 O(n)을 보장하려면 단조성(Monotonicity) 이 필요합니다.

  • 포인터가 한 방향으로만 이동해야 합니다 (뒤로 돌아가지 않음)
  • 배열이 정렬되어 있거나, 윈도우의 확장/축소가 단조적이어야 합니다
  • 원소가 음수를 포함하면 단조성이 깨질 수 있습니다
JAVA
// 이런 경우 투 포인터가 안 됩니다:
// 배열 [-1, 2, -3, 4]에서 합이 2인 부분 배열
// → 음수 때문에 합이 감소했다 증가하므로 단조성 X
// → 이 경우 해시맵(prefix sum)을 사용합니다

Prefix Sum + HashMap

투 포인터가 안 되는 경우의 대안입니다.

JAVA
// 합이 target인 부분 배열 개수 (음수 포함)
public static int subarraySum(int[] nums, int target) {
    Map<Integer, Integer> prefixCount = new HashMap<>();
    prefixCount.put(0, 1);

    int sum = 0, count = 0;
    for (int num : nums) {
        sum += num;
        count += prefixCount.getOrDefault(sum - target, 0);
        prefixCount.merge(sum, 1, Integer::sum);
    }
    return count;
}

백엔드/실무에서의 연결

TCP 슬라이딩 윈도우

TCP 프로토콜에서 흐름 제어를 위해 슬라이딩 윈도우를 사용합니다. 수신자의 윈도우 크기(rwnd)만큼만 데이터를 보낼 수 있고, ACK를 받으면 윈도우가 앞으로 이동합니다.

PLAINTEXT
[전송완료 | 전송가능 윈도우 | 아직 전송 불가]
  ←ACK→   ←   rwnd    →

알고리즘의 슬라이딩 윈도우와 동일한 개념입니다. 윈도우 안의 데이터만 처리하고, 조건에 따라 윈도우를 이동시킵니다.

Rate Limiting

API Rate Limiting에서 "최근 N초 동안의 요청 수"를 관리할 때 슬라이딩 윈도우 카운터를 사용합니다.

JAVA
// Redis를 이용한 슬라이딩 윈도우 Rate Limiter 개념
// ZADD rate_limit:{userId} {timestamp} {requestId}
// ZRANGEBYSCORE rate_limit:{userId} {now - windowSize} {now}
// 윈도우 밖의 오래된 요청은 제거

로그 분석

시계열 로그 데이터에서 "최근 5분간 에러율"을 계산하는 것도 슬라이딩 윈도우입니다.

스트리밍 데이터 처리

Apache Kafka Streams, Apache Flink 등에서 윈도우 기반 집계(Window Aggregation)를 할 때 슬라이딩 윈도우를 사용합니다.

주의할 점

음수 원소가 있을 때 투 포인터를 적용하는 실수

투 포인터는 단조성(Monotonicity)이 전제입니다. 배열에 음수가 포함되면 윈도우 합이 감소했다 증가할 수 있어 단조성이 깨집니다. 이 경우 Prefix Sum + HashMap을 사용해야 합니다.

슬라이딩 윈도우에서 나가는 원소 처리를 빠뜨리는 실수

윈도우를 밀 때 새로 들어오는 원소만 추가하고 나가는 원소를 제거하지 않으면 결과가 틀어집니다. "추가 + 제거"를 항상 쌍으로 처리해야 합니다.


정리

기법시간 복잡도적용 조건대표 문제
양끝 투 포인터O(n)정렬된 배열Two Sum, 물 담기
같은 방향 투 포인터O(n)단조성 필요최소 길이 부분 배열
고정 윈도우O(n)윈도우 크기 고정최대 합 부분 배열
가변 윈도우O(n)조건 기반 확장/축소최장 부분 문자열

기억할 포인트:

  • 투 포인터의 핵심은 두 포인터가 한 방향으로만 이동 한다는 것입니다.
  • 슬라이딩 윈도우는 새로 들어오는 원소 추가 + 나가는 원소 제거 로 O(1) 갱신합니다.
  • 음수가 포함되면 단조성이 깨질 수 있으니, Prefix Sum + HashMap을 고려하세요.
  • TCP 흐름 제어, Rate Limiting, 스트리밍 처리 등 실무에서도 동일한 개념이 사용됩니다.
댓글 로딩 중...