알고리즘 - 2. 슬라이딩 윈도우 (가변 크기)
가변 크기 슬라이딩 윈도우를 사용하는 시기
크기가 미리 정해지지 않은 최적의 연속 부분 배열을 찾아야 할 때 가변 크기 슬라이딩 윈도우를 사용합니다. 이 알고리즘은 크기를 최적화하면서 특정 조건을 유지하기 위해 윈도우 경계를 동적으로 조정합니다.
- 합계 ≥ 목표값인 최소/최대 크기 부분 배열 (대표적인 사용 사례)
- 중복 문자가 없는 가장 긴 부분 문자열
- 필요한 모든 문자를 포함하는 최소 윈도우 부분 문자열
- 특정 속성을 가진 부분 배열의 개수
- 종류 제약이 있는 최대 아이템 수집 (예: 과일 바구니)
- 조건을 유지하기 위해 윈도우 크기가 적응하는 모든 문제
예제 문제
최소 장보기 가방: 아이템 무게 [2, 3, 1, 2, 4, 3]
이 주어지고 최소 7kg이 필요할 때, 선택해야 할 연속된 아이템의 최소 개수는?
- 아이템 [0-3]: 2 + 3 + 1 + 2 = 8 kg ✓ (4개 아이템)
- 아이템 [1-4]: 3 + 1 + 2 + 4 = 10 kg ✓ (4개 아이템)
- 아이템 [2-4]: 1 + 2 + 4 = 7 kg ✓ (3개 아이템)
- 아이템 [4-5]: 4 + 3 = 7 kg ✓ (2개 아이템) ← 최적!
답: 2개 아이템 (위치 4-5의 아이템).
가장 중요한 통찰
가능성을 탐색하기 위해 윈도우를 확장하고, 조건이 만족되면 왼쪽에서 축소한다 - 이 확장-축소 댄스는 모든 O(n²) 가능성을 확인하지 않고도 최적의 크기를 찾기 위해 경계를 체계적으로 조정함으로써 모든 유효한 윈도우를 검사하는 것을 보장합니다.
멘탈 노트: 이 알고리즘의 천재성은 "유효한 윈도우 불변식"을 유지하는 데 있습니다. 각 단계에서 우리는 단지 "이 윈도우를 축소해도 여전히 조건을 만족하는가?"라는 간단한 결정만 내립니다. 이 간단한 결정이 자동으로 최적의 윈도우를 찾아냅니다. 모든 가능한 윈도우를 확인하는 대신, 새로운 요소를 포함하도록 확장한 다음, 각 위치에서 끝나는 가장 작은 유효한 윈도우를 찾기 위해 탐욕적으로 축소합니다.
결정 프레임워크
모든 위치에서 알고리즘은 두 가지 순차적인 결정을 내립니다:
- 확장: 항상 현재 요소를 포함 (오른쪽 포인터 이동)
- 축소: 조건이 만족되는 동안 왼쪽에서 제거하여 최소화 시도
코드
// 합계 >= 목표값인 최소 크기 부분 배열 찾기
// 중첩 루프가 있지만 O(n) 시간 - 각 요소는 최대 2번 방문됨
function shortestSubarray(nums, target) {
let left = 0;
let windowSum = 0;
let minLength = Infinity;
for (let right = 0; right < nums.length; right++) {
// 단계 1: 확장 - 항상 현재 요소 포함
const elementEnteringWindow = nums[right];
windowSum += elementEnteringWindow;
// 결정 프레임워크: 축소해도 여전히 조건을 만족하는가?
// 단순히 유효한 윈도우가 아닌, 최적의 윈도우를 찾기 위해 while 사용 (if가 아님)
while (windowSum >= target) {
// 현재 윈도우가 조건을 만족함, 가장 작은 경우 기록
const currentWindowSize = right - left + 1;
minLength = Math.min(minLength, currentWindowSize);
// 왼쪽에서 축소하여 더 작은 유효한 윈도우 찾기 시도
const elementLeavingWindow = nums[left];
windowSum -= elementLeavingWindow;
left++;
}
}
return minLength === Infinity ? 0 : minLength;
}
// 최대 k개의 서로 다른 문자를 가진 가장 긴 부분 문자열
// 문제: 최대 k개의 서로 다른 문자를 포함하는 가장 긴 부분 문자열 찾기
// 예: k=2인 "eceaba" → "eceab"는 3개 문자 ✗, "eceba"는 3개 문자 ✗, "ece"는 2개 문자 ✓
function longestSubstringKDistinct(s, k) {
const charFrequency = new Map(); // 윈도우 내 각 문자의 개수 추적
let left = 0;
let maxLength = 0;
for (let right = 0; right < s.length; right++) {
// 확장: 윈도우에 문자 추가
const charEntering = s[right];
charFrequency.set(charEntering, (charFrequency.get(charEntering) || 0) + 1);
// 결정 프레임워크: k개를 초과하는 서로 다른 문자가 있으면 왼쪽에서 축소
// 왜 오른쪽이 아닌 왼쪽에서 축소하는가? 이유:
// 1. 우리는 체계적으로 모든 위치를 오른쪽 경계로 시도하고 있음
// 2. 각 오른쪽 위치에 대해 가장 왼쪽의 유효한 위치를 찾음
// 3. 이는 모든 가능한 윈도우를 확인하는 것을 보장함 (아래 의문 해소 참조)
while (charFrequency.size > k) {
const charLeaving = s[left];
const count = charFrequency.get(charLeaving);
// 윈도우를 떠나는 문자의 빈도 감소
if (count === 1) {
// 마지막 출현, 맵에서 제거
charFrequency.delete(charLeaving);
} else {
// 윈도우에 아직 더 많은 출현이 있음
charFrequency.set(charLeaving, count - 1);
}
left++;
}
// 여기서 윈도우는 유효함 (≤ k개의 서로 다른 문자), 최대값 업데이트
const currentWindowSize = right - left + 1;
maxLength = Math.max(maxLength, currentWindowSize);
}
return maxLength;
}
의문 해소
일반적인 의문: "왜 왼쪽에서만 축소하는가? 오른쪽에서 제거하면 더 나은 결과를 얻을 수 있지 않을까?"
오른쪽에서 제거하는 것이 더 나을 수 있다고 생각하는 이유
이것은 자연스러운 우려입니다! 윈도우에 너무 많은 서로 다른 문자가 있을 때 다음과 같이 생각할 수 있습니다:
- "방금 추가한 문자(오른쪽)가 문제인가?"
- "오른쪽에서 제거하면 더 긴 유효한 윈도우를 유지할 수 있지 않을까?"
- "양쪽 방향을 시도하고 더 나은 것을 선택해야 하지 않을까?"
- "항상 왼쪽에서 축소하는 탐욕적 선택이 임의적으로 보인다"
예를 들어, 문자열 "abcba"와 k=2인 경우:
- 인덱스 2(문자 'c')에 도달하면 윈도우는 3개의 서로 다른 문자를 가진 "abc"가 됨
- 왼쪽에서 제거하면 "bc" (길이 2)
- 하지만 오른쪽에서 제거하면 "ab" (역시 길이 2)?
- 두 옵션을 모두 고려해야 하지 않을까?
이 생각이 틀린 이유
핵심 깨달음: 오른쪽에서 제거할 필요가 없습니다. 왜냐하면 그 옵션을 향후 반복에서 자연스럽게 탐색할 것이기 때문입니다!
결정적인 통찰은 다음과 같습니다: 알고리즘은 체계적으로 모든 위치를 오른쪽 경계로 시도합니다. 따라서 오른쪽에서 제거하는 것이 더 나은 윈도우를 제공한다면, 그 위치에 도달했을 때 발견할 것입니다.
"abcba"와 k=2로 이를 증명해 보겠습니다:
right=0: 윈도우 ["a"], 1개의 서로 다른 문자, 유효 ✓
right=1: 윈도우 ["ab"], 2개의 서로 다른 문자, 유효 ✓, maxLen=2
right=2: 윈도우 ["abc"], 3개의 서로 다른 문자, 무효 ✗
- 왼쪽에서 축소: ["bc"], 2개의 서로 다른 문자, 유효 ✓
right=3: 윈도우 ["bcb"], 2개의 서로 다른 문자, 유효 ✓, maxLen=3
right=4: 윈도우 ["bcba"], 3개의 서로 다른 문자, 무효 ✗
- 축소: ["cba"], 3개의 서로 다른 문자, 여전히 무효 ✗
- 축소: ["ba"], 2개의 서로 다른 문자, 유효 ✓
핵심 통찰: "abc"가 있고 왼쪽에서 제거하여 "bc"를 얻었을 때, "ab"를 놓쳤다고 걱정했습니다. 하지만 주목하세요:
- 윈도우 "ab"는 right=1일 때 이미 평가되었습니다!
- 그 시점에 길이(2)를 기록했습니다
- 나중에 길이 3인 "bcb"를 찾았는데, 이것이 더 좋습니다
모든 가능한 윈도우가 평가됩니다, 왜냐하면:
- 위치 1에서 끝나는 윈도우: ["a"], ["ab"]를 시도
- 위치 2에서 끝나는 윈도우: ["abc"]→["bc"], ["c"]를 시도
- 위치 3에서 끝나는 윈도우: ["bcb"], ["cb"], ["b"]를 시도
- 계속...
알고리즘은 어떤 윈도우도 놓치지 않습니다 - 체계적으로 모두 평가합니다!
구체적인 증명: 왜 왼쪽에서 축소하는 것만으로 충분한가
이렇게 생각해 보세요: 최적의 윈도우 [i, j]가 있다면, 알고리즘은 그것을 찾을 것입니다:
- 오른쪽 포인터가 j에 도달했을 때: 어떤 왼쪽 위치부터 j까지의 모든 요소를 가짐
- while 루프가 축소: 윈도우가 유효해질 때까지 왼쪽 포인터를 오른쪽으로 이동
- i 또는 그 이전에 멈춤: [i, j]가 유효하므로, 축소는 위치 i까지 멈출 것
- 이 윈도우를 기록: 지금까지 본 것 중 최고라면 최대값 업데이트
오른쪽에서 제거하는 것이 중복인 이유
위치 right=5에서 윈도우 [2,3,4,5]가 있고 무효하다고 상상해 보세요. "[2,3,4]가 [3,4,5]보다 나을 수 있다"고 생각할 수 있습니다.
하지만 [2,3,4]를 확인할 필요가 없는 이유는:
- right가 위치 4에 있을 때 이미 [2,3,4]를 확인했습니다!
- [2,3,4]가 유효하고 좋았다면 그때 기록했습니다
- 이제 위치 5에서 5로 끝나는 최고의 윈도우를 찾고 있습니다
- 다음 반복(right=6)에서 6으로 끝나는 최고의 윈도우를 찾을 것입니다
알고리즘의 보장: 배열의 모든 가능한 윈도우에 대해 다음과 같은 순간이 있을 것입니다:
- 오른쪽 포인터가 윈도우의 끝 위치에 있음
- 왼쪽 포인터가 윈도우의 시작 위치로 (또는 지나서) 이동됨
- 따라서 모든 윈도우가 정확히 한 번 평가됨
이것이 O(n²)가 필요해 보이지만 알고리즘이 O(n)인 이유입니다 - 백트래킹이나 이전 결정을 재고할 필요가 없습니다.