Two Pointers와 Sliding Window: 배열과 문자열 최적화 기법
1. 개요
배열이나 문자열을 순회하는데 brute-force 해법이 O(n²) 또는 O(n³)이 되는 문제에서 two pointers와 sliding window 기법을 사용하면 보통 시간 복잡도를 O(n)으로 낮출 수 있습니다. 이 글에서는 두 기법의 핵심 개념을 설명하고 구체적인 Java 예제를 제시합니다: 정렬된 배열에서의 two sum, palindrome 확인, 고정 크기 윈도우에서의 최대합, 반복 문자가 없는 가장 긴 부분 문자열. 이 글을 다 읽고 나면 패턴을 인식하고 유사한 많은 문제에 이 기법들을 적용할 수 있을 것입니다.
2. Two Pointers란 무엇인가
Two pointers는 두 개의 인덱스 변수(포인터)를 사용하여 자료 구조(보통은 정렬된 배열이나 문자열) 위에서 움직이며 중첩 순회를 피하는 기법입니다. 두 포인터는 다음과 같이 이동할 수 있습니다:
- 같은 방향 (fast/slow): 중복 요소 제거, 사이클 감지에 사용.
- 반대 방향 (양쪽 끝에서 left/right): 합이 target인 쌍 찾기, palindrome 확인에 사용.
2.1. 반대 방향 two pointers: 목표값 합을 갖는 쌍 찾기
고전적인 문제: 정렬된 배열이 주어졌을 때, 합이 target과 같은 두 수를 찾아야 합니다.
public class TwoSumSorted {
public int[] findPair(int[] numbers, int target) {
int left = 0;
int right = numbers.length - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) {
return new int[]{numbers[left], numbers[right]};
} else if (sum < target) {
left++;
} else {
right--;
}
}
return new int[0];
}
}
설명:
left는 배열의 처음에서 시작하고,right는 배열의 끝에서 시작합니다.sum < target이면,left쪽의 수가 너무 작으므로left를 증가시켜 더 큰 수를 선택합니다.sum > target이면,right쪽의 수가 너무 크므로right를 감소시킵니다.- 루프는 최대 n번 실행되는데, 매번
left또는right중 하나가 한 칸씩 이동하므로 중첩 순회의 O(n²) 대신 O(n) 시간 복잡도가 됩니다.
JUnit 5 + AssertJ로 테스트:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TwoSumSortedTest {
private final TwoSumSorted twoSum = new TwoSumSorted();
@Test
void shouldFindPairWhenSumMatchesTarget() {
int[] numbers = {1, 3, 5, 8, 11};
int[] result = twoSum.findPair(numbers, 13);
assertThat(result).containsExactly(5, 8);
}
}
주의: 이 기법은 배열이 정렬되어 있어야 합니다. 배열이 정렬되지 않았다면 먼저 정렬해야 하거나 (O(n log n))
HashMap을 사용하여 정렬 없이 O(n)을 유지할 수 있습니다.
2.2. Palindrome 확인을 위한 two pointers
public class PalindromeChecker {
public boolean isPalindrome(String text) {
int left = 0;
int right = text.length() - 1;
while (left < right) {
if (text.charAt(left) != text.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
}
두 포인터는 문자열의 양쪽 끝에서 시작하여 문자를 비교하며 가운데로 나아갑니다. 다른 문자 쌍을 만나면 즉시 false를 반환하며, 전체를 순회할 필요가 없습니다. 이 기법이 문자열을 뒤집고 비교하는 방식(reverse().equals())보다 빠른 이유는 중간에 StringBuilder 객체를 생성하지 않기 때문입니다.
3. Sliding Window란 무엇인가
Sliding window는 two pointers의 변형으로, 연속 부분 수열(subarray/substring)과 관련된 문제에 적용됩니다. 각 시작 위치마다 처음부터 다시 계산하는 대신, [left, right] “윈도우”를 유지하고 윈도우가 이동할 때마다 점진적으로 업데이트하여 이전에 계산한 결과를 재사용합니다.
두 가지 유형이 있습니다:
- Fixed-size window: 윈도우 크기가 고정됨 (예: k개 요소)
- Variable-size window: 문제의 조건에 따라 윈도우 크기가 변함
3.1. Fixed-size window: 크기 k인 최대 부분합
public class MaxSumSubarray {
public int maxSum(int[] numbers, int windowSize) {
int windowSum = 0;
for (int i = 0; i < windowSize; i++) {
windowSum += numbers[i];
}
int maxSum = windowSum;
for (int right = windowSize; right < numbers.length; right++) {
windowSum += numbers[right] - numbers[right - windowSize];
maxSum = Math.max(maxSum, windowSum);
}
return maxSum;
}
}
설명:
- 첫 번째 루프는 첫 번째 윈도우의 합을 계산합니다 (인덱스 0부터
windowSize - 1까지). - 그 다음부터는 윈도우를 한 단계씩 오른쪽으로 이동할 때마다, 새로운 요소를 더하고(
numbers[right]), 윈도우를 벗어난 요소를 빼줍니다(numbers[right - windowSize]). - 이렇게 하면 각 단계에 O(1)만 걸리며, 합을 다시 계산하는 O(k) 대신 전체 시간 복잡도가 O(n)이 됩니다.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MaxSumSubarrayTest {
private final MaxSumSubarray solver = new MaxSumSubarray();
@Test
void shouldReturnMaxSumOfWindowSizeThree() {
int[] numbers = {2, 1, 5, 1, 3, 2};
int result = solver.maxSum(numbers, 3);
assertThat(result).isEqualTo(9); // 윈도우 [5, 1, 3]
}
}
3.2. Variable-size window: 반복 문자가 없는 가장 긴 부분 문자열
이 문제에서는 ’반복 문자가 없다’는 조건에 따라 윈도우가 확장하고 축소됩니다.
import java.util.HashMap;
import java.util.Map;
public class LongestUniqueSubstring {
public int lengthOfLongestSubstring(String text) {
Map<Character, Integer> lastSeenIndex = new HashMap<>();
int left = 0;
int maxLength = 0;
for (int right = 0; right < text.length(); right++) {
char current = text.charAt(right);
if (lastSeenIndex.containsKey(current) && lastSeenIndex.get(current) >= left) {
left = lastSeenIndex.get(current) + 1;
}
lastSeenIndex.put(current, right);
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
설명:
right는 항상 앞으로 이동하여 윈도우를 확장하고,left는 현재 윈도우 내에 반복되는 문자가 있을 때만 점프합니다.lastSeenIndex는 각 문자의 가장 최근 위치를 저장하므로left가 어디로 이동해야 하는지 알 수 있습니다.lastSeenIndex.get(current) >= left조건은 문자가 현재 윈도우 내에 있을 때만 ’반복’으로 간주하여left가 실수로 뒤로 이동하지 않도록 보장합니다.- 각 문자는
right에서 정확히 한 번씩 처리되므로 전체 시간 복잡도는 O(n)입니다.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LongestUniqueSubstringTest {
private final LongestUniqueSubstring solver = new LongestUniqueSubstring();
@Test
void shouldReturnLengthOfLongestUniqueSubstring() {
int result = solver.lengthOfLongestSubstring("abcabcbb");
assertThat(result).isEqualTo(3); // "abc"
}
@Test
void shouldHandleAllSameCharacters() {
int result = solver.lengthOfLongestSubstring("bbbbb");
assertThat(result).isEqualTo(1);
}
}
"abcabcbb"을 수동으로 디버그할 때의 출력:
right=0 'a' -> window="a" length=1
right=1 'b' -> window="ab" length=2
right=2 'c' -> window="abc" length=3
right=3 'a' -> 반복, left=1 -> window="bca" length=3
right=4 'b' -> 반복, left=2 -> window="cab" length=3
...
maxLength = 3
4. 기법 비교 및 언제 각 기법을 사용할지
| 상황 | 적절한 기법 |
|---|---|
| 정렬된 배열에서 합 조건을 만족하는 쌍/삼중쌍 찾기 | 반대 방향 two pointers |
| 대칭성 확인(Palindrome), 배열을 제자리에서 뒤집기 | 반대 방향 two pointers |
| 정렬된 배열에서 중복 요소 제거 | 같은 방향 two pointers (fast/slow) |
| 크기가 고정된 부분배열/부분 문자열 | Sliding window (크기 고정) |
| 동적 조건을 만족하는 부분배열/부분 문자열 (반복 없음, 합 ≤ target…) | Sliding window (크기 변수) |
공통점: 두 기법 모두 이미 처리한 부분을 다시 계산하는 것을 피하며, 포인터/윈도우가 이동할 때 상태(sum, set, map)를 유지함으로써 O(n²)에서 O(n)으로 복잡도를 낮춥니다.
5. 자주 나타나는 실수
left < right종료 조건을 잊음: 무한 루프를 야기하거나left == right일 때 한 요소와 자기 자신을 비교할 수 있습니다.- 미정렬 배열에 two pointers를 적용하면서 먼저 정렬하는 것을 잊음 — 포인터 증감 로직이 배열의 단조성을 기반으로 하므로 결과가 완전히 잘못됩니다.
- Sliding window에서
left가 이동할 때 상태 업데이트를 잊음: 예를 들어HashMap/sum에서 오래된 요소를 제거하는 것을 잊으면 윈도우가 더 이상 속하지 않는 데이터를 ’기억’하게 됩니다. - 윈도우 확장/축소 조건을 혼동함: variable-size window에서는 어떤 조건이
right를 이동시키고 어떤 조건이left를 이동시키는지 명확히 해야 합니다. 반대로 하면 윈도우가 제대로 축소되지 않습니다.
주의: 이 두 기법은 문제가 연속하지 않은 모든 부분 집합을 고려해야 할 때(예: subset sum) 적합하지 않습니다. 이런 경우에는 backtracking이나 dynamic programming이 필요하지, sliding window가 아닙니다.
6. 결론
Two pointers와 sliding window는 배열/문자열 문제를 선형 시간 복잡도로 해결하기 위한 두 가지 기본 도구입니다. 패턴을 인식할 수 있다면 — 정렬된 배열에서 쌍을 찾거나 연속 부분배열/부분 문자열을 최적화해야 하는 경우 — 올바른 기법을 선택하고 위의 예제처럼 간단하고 테스트하기 쉬운 코드를 작성할 수 있습니다. 연습할 때는 코드를 작성하기 전에 각 단계별로 left/right 상태를 직접 그려보고 불변식을 명확히 이해해야 합니다.