투 포인터와 슬라이딩 윈도우 — 배열 문제를 O(n)으로 바꾸는 기법
배열에서 특정 조건을 만족하는 구간을 찾아야 합니다. 이중 for문을 돌리면 O(n²)... 더 빠른 방법은 없을까요?
개념 정의
투 포인터는 두 개의 포인터(인덱스) 를 사용하여 배열을 효율적으로 탐색하는 기법입니다. 이중 for문이 필요한 문제를 O(n)으로 줄여주는 경우가 많습니다.
두 가지 유형이 있습니다:
- **양끝에서 시작 **: 왼쪽 끝과 오른쪽 끝에서 시작하여 안쪽으로 좁혀감
- ** 같은 방향 **: 두 포인터가 같은 방향으로 이동 (슬라이딩 윈도우와 유사)
유형 1: 양끝에서 좁히기
정렬된 배열에서 Two Sum
// 정렬된 배열에서 합이 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)
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 이상인 최소 길이
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)입니다.
중복 없는 가장 긴 부분 문자열
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)
슬라이딩 윈도우는 투 포인터의 특수한 형태입니다. 연속된 구간(윈도우) 을 유지하면서 한 칸씩 밀어가며 계산합니다.
고정 크기 윈도우
// 크기 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)입니다.
가변 크기 윈도우
조건을 만족하는 동안 윈도우를 확장하고, 조건이 깨지면 축소합니다.
// 합이 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)에 해결할 수 있습니다.
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) 이 필요합니다.
- 포인터가 한 방향으로만 이동해야 합니다 (뒤로 돌아가지 않음)
- 배열이 정렬되어 있거나, 윈도우의 확장/축소가 단조적이어야 합니다
- 원소가 음수를 포함하면 단조성이 깨질 수 있습니다
// 이런 경우 투 포인터가 안 됩니다:
// 배열 [-1, 2, -3, 4]에서 합이 2인 부분 배열
// → 음수 때문에 합이 감소했다 증가하므로 단조성 X
// → 이 경우 해시맵(prefix sum)을 사용합니다
Prefix Sum + HashMap
투 포인터가 안 되는 경우의 대안입니다.
// 합이 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를 받으면 윈도우가 앞으로 이동합니다.
[전송완료 | 전송가능 윈도우 | 아직 전송 불가]
←ACK→ ← rwnd →
알고리즘의 슬라이딩 윈도우와 동일한 개념입니다. 윈도우 안의 데이터만 처리하고, 조건에 따라 윈도우를 이동시킵니다.
Rate Limiting
API Rate Limiting에서 "최근 N초 동안의 요청 수"를 관리할 때 슬라이딩 윈도우 카운터를 사용합니다.
// 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, 스트리밍 처리 등 실무에서도 동일한 개념이 사용됩니다.