한입 알고리즘

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

버려도 되는 후보를 버리며 한 번 훑기

2장 · 훑기 · 읽는 시간 약 18분

목차 · 진행 0%

    가격순으로 정렬된 상품 목록에서 합이 예산과 같은 두 상품을 찾는다. API 게이트웨이는 "사용자마다 최근 1초 동안 요청 3번까지"만 받는다. 로그 분석 화면은 "같은 이벤트가 반복되지 않은 가장 긴 구간"을 찾는다.

    세 문제 모두 쉬운 풀이는 모든 쌍이나 모든 구간을 보는 것이다. 원소 n개면 쌍은 약 n²/2개, 구간도 약 n²/2개다(원소 6개면 15쌍, 100개면 약 5,000쌍. 1장의 n(n − 1)/2이고, 그 합이 n²/2에 가까운 이유는 『개발 수학』 Unit 4). 이 장은 포인터(pointer, 배열의 한 자리를 가리키는 인덱스 변수) 두 개를 한 방향으로만 움직이며 한 번 훑기(O(n))로 끝내는 두 가지 방법을 본다. 양 끝에서 좁혀 오는 투 포인터(two pointers)와, 구간의 왼쪽·오른쪽 끝을 함께 밀고 가는 슬라이딩 윈도우(sliding window)다. 개념 정리는 투 포인터, 슬라이딩 윈도우 노트에 있다.

    양 끝 포인터: 합이 작으면 왼쪽, 크면 오른쪽

    가격표가 정렬되어 있다. 가장 싼 것(lo)과 가장 비싼 것(hi)부터 더해 본다. 합이 예산보다 작으면 합을 키워야 하니 lo를 오른쪽으로 한 칸, 크면 합을 줄여야 하니 hi를 왼쪽으로 한 칸 옮긴다. 둘이 만나면 남은 쌍이 없다.

    예측먼저 답해 보세요

    가격 [1, 3, 4, 6, 8, 11], 예산 10원이다. 양 끝 포인터로 찾으면 두 가격의 합을 몇 번 계산할까? (모든 쌍은 15개다)

    선택지와 해설 보기
    실험 양 끝에서 좁혀 오는 두 포인터
    실험양 끝에서 좁혀 오는 두 포인터

    정렬된 가격표 양 끝에 lo와 hi를 둡니다. 합이 예산보다 작으면 lo를, 크면 hi를 한 칸 안쪽으로 옮깁니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const prices = [1, 3, 4, 6, 8, 11]const budget = 10let lo = 0let hi = prices.length - 1let answer = '없음'while (lo < hi) {  const total = prices[lo] + prices[hi]  if (total === budget) {    answer = `${prices[lo]} + ${prices[hi]}`    break  } else if (total < budget) {    lo++  } else {    hi--  }}console.log(answer)
    변수
    prices
    [1, 3, 4, 6, 8, 11]
    출력
     
    prices
    1. 1
    2. 3
    3. 4
    4. 6
    5. 8
    6. 11
    1/31단계.
    • 해볼 것
    • 가격 6개 입력을 끝까지 실행해 합을 몇 번 계산하는지 세어 본다
    • '답 없음' 입력에서 두 포인터가 만나 멈추는 것을 본다

    가격 [1, 3, 4, 6, 8, 11]에서는 다섯 번째 합 4 + 6 = 10에서 찾는다. 합을 계산할 때마다 포인터 하나가 안쪽으로 한 칸 움직이고, 두 포인터는 되돌아가지 않는다. 두 포인터의 거리 hi − lo가 처음 n − 1이고 계산마다 1씩 줄어 0이 되면 끝나니, 원소 n개면 합 계산은 많아야 n − 1번이고 O(n)이다. 1장의 해시맵(hash map) 풀이도 O(n)이었지만 본 값을 적어 둘 메모리가 n만큼 들었다. 이미 정렬된 배열이라면 투 포인터는 변수 두 개, O(1) 메모리로 끝난다. 정렬이 안 되어 있다면 정렬에 O(n log n)(n보다 조금 더 큰 비용. 7장 정렬, 『개발 수학』 Unit 2)이 들어서, 짝 찾기를 한 번만 한다면 해시맵이 낫다.

    버린 후보에 답이 없는 이유

    투 포인터가 맞는 답을 내려면, 한 칸 옮길 때 버리는 후보에 답이 없어야 한다. 첫 단계를 보자. 1 + 11 = 12로 예산 10을 넘었다. 이때 11은 남은 가격 중 가장 싼 1과 더해도 넘친다. 그러니 11과 짝이 되는 상품은 남은 목록에 없고, 11을 버려도 된다(hi--). 반대로 1 + 8 = 9로 모자라면, 1은 남은 가격 중 가장 비싼 8과 더해도 모자란다. 1을 버려도 된다(lo++).

    매 단계 이 논리가 성립하는 것은 배열이 정렬되어 있어서다. lo 쪽이 남은 것 중 가장 작고 hi 쪽이 가장 크다는 사실이 버리는 근거다. 정렬이 깨진 배열에 같은 코드를 쓰면 답이 있는데도 없음을 낼 수 있다. 투 포인터를 볼 때마다 "무엇이 이 후보를 버려도 된다고 보장하나?"를 먼저 묻자.

    슬라이딩 윈도우: 오른쪽은 늘리고, 왼쪽은 당긴다

    이번엔 문자열 s에서 같은 문자가 두 번 나오지 않는 가장 긴 구간을 찾는다. 구간의 왼쪽 끝 left와 오른쪽 끝 right 사이를 윈도우라고 부른다. right를 한 칸씩 늘리다가, 새 문자가 윈도우 안에 이미 있으면 left를 그 문자를 마지막으로 본 자리 바로 다음으로 당긴다. 문자마다 마지막으로 본 위치는 last(문자 → 위치)에 적어 둔다. 마지막으로 본 자리가 left보다 앞이면 이미 윈도우 밖이니 left를 건드리지 않는다.

    예측먼저 답해 보세요

    s = 'abba'에서 중복 없는 가장 긴 구간을 윈도우(left~right)로 찾는다. right가 마지막 a(3번)에 왔을 때 last(문자별 마지막 위치)에는 a의 0이, left에는 2가 들어 있다. 이 단계를 마치면 left는?

    선택지와 해설 보기
    실험 중복 없는 가장 긴 구간
    실험중복 없는 가장 긴 구간

    right가 한 칸씩 늘며 윈도우를 넓힙니다. 윈도우 안에 같은 문자가 다시 나오면 left를 그 문자를 마지막으로 본 자리 다음으로 당깁니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const s = 'tomorrow'const last = new Map<string, number>()let left = 0let best = 0for (let right = 0; right < s.length; right++) {  const ch = s[right]  if (last.has(ch) && last.get(ch)! >= left) {    left = last.get(ch)! + 1  }  last.set(ch, right)  best = Math.max(best, right - left + 1)}console.log(`가장 긴 길이 ${best}`)
    변수
    s
    'tomorrow'
    출력
     
    s
    1. t
    2. o
    3. m
    4. o
    5. r
    6. r
    7. o
    8. w
    1/47단계.
    • 해볼 것
    • 'tomorrow'를 끝까지 실행해 본다
    • 'abba'에서 마지막 'a'가 윈도우 밖에 있다고 판단하는 단계를 찾아본다

    'abba'에서 둘째 b(2번)를 만나면 left가 2로 뛴다. 마지막 a(3번)는 last에 0으로 적혀 있지만 0은 윈도우 밖이다. 그래서 조건이 'ch를 본 적이 있나'만이 아니라 '그 위치가 left 이상인가'까지 본다(TS last.get(ch)! >= left, Python last[ch] >= left). 이 검사를 빼면 left가 1로 뒤로 돌아가 'bba'를 중복 없는 구간으로 잘못 센다.

    right는 문자열 길이만큼 한 칸씩, left는 앞으로만 움직인다. 문자 하나마다 Map·dict 조회와 기록이 한 번씩이라 전체 O(n)이다. 모든 구간을 하나씩 검사하면 구간이 약 개이고 구간마다 중복도 확인해야 한다.

    속도 제한: 최근 1초 동안의 요청

    윈도우는 위치가 아니라 시간으로도 잡는다. "최근 1초 동안 요청 3번까지"는 요청 시각을 순서대로 쌓아 두고, 지금 시각보다 1초 이상 지난 요청을 앞에서부터 빼는 윈도우다. 시각은 늘 커지는 순서로 들어오니 왼쪽 끝은 앞으로만 움직인다.

    const LIMIT = 3const WINDOW_MS = 1000const times: number[] = []let head = 0 function allow(now: number) {  while (head < times.length && times[head] <= now - WINDOW_MS) head++  if (times.length - head >= LIMIT) return false  times.push(now)  return true} console.log([0, 100, 200, 300, 1000, 1150].map(t => allow(t)).join(' '))

    0, 100, 200밀리초 요청은 통과하고 300밀리초 요청은 막힌다(최근 1초에 이미 3번). 1000밀리초에는 0밀리초 요청이 윈도우 밖으로 빠져 다시 통과하고, 1150밀리초에는 100밀리초 요청도 빠져 통과한다. 출력은 true true true false true true(Python은 True True True False True True)다. TypeScript 쪽은 배열 앞에서 빼는 shift()가 남은 원소를 모두 한 칸씩 당기는 연산이라(MDN: Array.prototype.shift) 읽을 위치 head만 옮겼다. 이 예제는 배열을 비우지 않으니 실제 서비스라면 head가 일정 크기를 넘을 때 앞부분을 잘라 내야 한다. Python은 양쪽 끝 넣기·빼기가 빠른 collections.deque를 쓴다(Python 튜토리얼: 리스트를 큐로 쓰기).

    이 방식은 사용자마다 최근 요청 시각을 최대 LIMIT개 들고 있어야 한다. 사용자가 많으면 메모리를 줄이려고 1초 단위 칸마다 요청 수만 세는 근사 방식을 쓰기도 한다. 그때도 "왼쪽 끝을 앞으로 밀며 지난 것을 뺀다"는 윈도우의 뼈대는 같다.

    경계에서 틀리는 줄

    투 포인터와 윈도우 코드는 짧아서 경계 조건 하나가 틀려도 눈에 잘 띄지 않는다. 대부분의 입력에서 맞는 답을 내다가 특정 입력에서만 틀린다.

    함정 찾기 AI가 짠 두 함수, 경계가 틀린 줄은 어디일까?
    함정 찾기AI가 짠 두 함수, 경계가 틀린 줄은 어디일까?
    내가 AI에게 한 요청
    “문자열에서 중복 문자 없는 가장 긴 구간의 길이를 구하는 함수와, 정렬된 가격표에 합이 예산인 서로 다른 두 상품이 있는지 보는 함수를 짜 줘.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.

    정리하면 틀리는 경계는 셋이다. 윈도우 밖 위치로 left를 뒤로 당기기(Math.max로 막는다), 길이를 right - left로 세기(+ 1이 빠졌다), lo <= hi로 같은 상품 두 번 더하기(lo < hi가 맞다).

    확인 문제 장을 덮기 전에 떠올려 보기
    확인 문제장을 덮기 전에 떠올려 보기

    1. 정렬되지 않은 가격 배열에서 합이 예산인 두 상품을 한 번만 찾는다. 시간이 가장 적게 드는 방법은? (상품 n개)

    2. 중복 없는 가장 긴 구간 문제에서 윈도우의 왼쪽 끝 left가 뒤로 움직이면 안 되는 이유는?

    3. 슬라이딩 윈도우 풀이가 O(n)인 핵심 이유는?

    더 풀어 보기(선택) · 합이 target 이상인 가장 짧은 구간
    보너스 문제합이 target 이상인 가장 짧은 구간

    양의 정수 배열 nums와 양의 정수 target이 있다. 합이 target 이상인 연속 구간(subarray) 중 가장 짧은 것의 길이를 O(n)에 구해 보자. 그런 구간이 없으면 없음이다.

    nums = [1, 4, 2, 3, 5, 1], target = 8 → 2 ([3, 5])
    nums = [1, 2, 1], target = 5 → 없음

    이 장을 언급한 노트

    다음3장. 스택과 단조 스택괄호 짝 맞추기와 "다음 더 큰 값"에는 마지막에 넣은 것을 먼저 꺼내는 스택이 맞습니다.

    『한입 알고리즘』 2장. 투 포인터와 슬라이딩 윈도우 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션