가격순으로 정렬된 상품 목록에서 합이 예산과 같은 두 상품을 찾는다. 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를 한 칸 안쪽으로 옮깁니다.
9/31단계: 12 > 10. 남은 것 중 가장 싼 1과 더해도 넘치니 11은 어느 짝과도 안 된다.
10/31단계.
11/31단계.
12/31단계: 1 + 8 = 9.
13/31단계.
14/31단계: 9 < 10. 남은 것 중 가장 비싼 8과 더해도 모자라니 1은 어느 짝과도 안 된다.
15/31단계.
16/31단계.
17/31단계: 3 + 8 = 11.
18/31단계.
19/31단계: 11 > 10. 남은 것 중 가장 싼 3과 더해도 넘치니 8은 어느 짝과도 안 된다.
20/31단계.
21/31단계.
22/31단계: 3 + 6 = 9.
23/31단계.
24/31단계: 9 < 10. 남은 것 중 가장 비싼 6과 더해도 모자라니 3은 어느 짝과도 안 된다.
25/31단계.
26/31단계.
27/31단계: 4 + 6 = 10.
28/31단계.
29/31단계.
30/31단계.
31/31단계(실행 끝).
1/31단계.
▶ 2 const budget = 10
해볼 것
가격 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를 그 문자를 마지막으로 본 자리 다음으로 당깁니다.
언어
입력
▶ 다음에 실행할 줄(디버거가 멈춘 줄)
방금 실행한 줄
변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
카드 안에서 ← → 키로도 넘길 수 있습니다.
1const s = 'tomorrow'2const last = new Map<string, number>()3let left = 04let best = 05for (let right = 0; right < s.length; right++) {6const ch = s[right]7if (last.has(ch) && last.get(ch)! >= left) {8 left = last.get(ch)! + 19 }10 last.set(ch, right)11 best = Math.max(best, right - left + 1)12}13console.log(`가장 긴 길이 ${best}`)
23/47단계: left를 1 + 1 = 2번으로 당긴다. 그 사이 문자는 한꺼번에 윈도우에서 빠진다.
24/47단계.
25/47단계: 윈도우 'mo'의 길이는 2.
26/47단계.
27/47단계.
28/47단계: 'r'는 처음 본다.
29/47단계.
30/47단계: 윈도우 'mor'의 길이는 3.
31/47단계.
32/47단계.
33/47단계: 'r'를 4번에서 봤고, 지금 윈도우(2번부터) 안이다. 중복이다.
34/47단계: left를 4 + 1 = 5번으로 당긴다. 그 사이 문자는 한꺼번에 윈도우에서 빠진다.
35/47단계.
36/47단계: 윈도우 'r'의 길이는 1.
37/47단계.
38/47단계.
39/47단계: 'o'를 3번에서 봤지만 윈도우(5번부터) 밖이다. left를 움직이지 않는다.
40/47단계.
41/47단계: 윈도우 'ro'의 길이는 2.
42/47단계.
43/47단계.
44/47단계: 'w'는 처음 본다.
45/47단계.
46/47단계: 윈도우 'row'의 길이는 3.
47/47단계(실행 끝).
1/47단계.
▶ 2 const last = new Map<string, number>()
해볼 것
'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초 이상 지난 요청을 앞에서부터 빼는 윈도우다. 시각은 늘 커지는 순서로 들어오니 왼쪽 끝은 앞으로만 움직인다.
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)에 구해 보자. 그런 구간이 없으면 없음이다.