문서 읽는 데 46분 · D4

D-4: 투 포인터·슬라이딩 윈도우 — O(n²)로 훑던 구간을 O(n)으로

목차 27
전체 20강 중 12강 · 자료구조·알고리즘
난이도 · 중급선수지식파이썬 기초

ℹ️코딩테스트의 관문 — 자료구조·알고리즘을 직접 구현하고 유형별로 풀어요. 언어 하나(파이썬·자바)를 뗀 다음에 권해요.

안녕하세요, 여러분의 알고리즘 길잡이 홍순구 튜터입니다. 지난 시간엔 정렬된 배열을 절반씩 버려 O(log n)에 값을 찾는 이진 탐색을 손으로 짰죠. "정렬은 다음 알고리즘의 발판"이라고 했던 말, 기억나시나요? 오늘 그 발판 위에 두 번째 무기를 올립니다.

코딩테스트에서 가장 자주 만나는 벽이 "되긴 되는데 시간 초과"입니다. 특히 "배열 안에서 두 수를 고르거나, 연속한 구간을 훑는" 문제에서 초보자는 거의 반사적으로 이중 반복문을 씁니다. 그런데 이중 반복문은 O(n²)라, 입력이 10만만 돼도 100억 번 연산이라 1초를 훌쩍 넘겨요. 오늘 배우는 세 무기 — 투 포인터, 슬라이딩 윈도우, 누적합 — 는 바로 이 O(n²)를 O(n)으로 끌어내리는 도구들입니다.

텍스트
 오늘의 여정 — O(n²)로 훑던 구간을 O(n)으로 끌어내리는 세 무기

   투 포인터        두 손가락으로 배열을 훑는다
     │  ├─ 마주 보기 (양 끝  안쪽)      두 수의 합 · 세 수의 합
     │  └─ 같은 방향 (나란히 )          제자리 중복 제거
     
   슬라이딩 윈도우   구간(창문)을 미끄러뜨린다
     │  ├─ 고정 길이 (크기 k 고정)        길이 k 최대 부분합
     │  └─ 가변 길이 (늘였다 줄인다)       최소 길이 · 최장 부분 문자열
     
   누적합           미리 더해 두고 빼서 쓴다
        ├─ 구간 합 O(1)                   prefix[j] - prefix[i]
        └─ 해시와 만나면                   합이 K인 구간 세기

💡 오늘 수업의 핵심 — "두 포인터와 창문, 그리고 미리 더해 둔 합으로, 이중 반복문 O(n²)를 O(n)으로 누른다"

🎯 학습 목표

  • 정렬된 배열의 양 끝에서 조여 오는 마주 보는 투 포인터와, 같은 방향으로 나아가는 slow/fast 투 포인터로 구간 문제를 O(n)에 푼다.
  • 구간을 미끄러뜨리는 슬라이딩 윈도우를 고정 길이와 가변 길이 두 형태로 구현한다.
  • 미리 더해 두는 누적합(prefix sum)으로 구간 합을 O(1)에 구하고, 해시와 결합해 합이 K인 구간을 O(n)에 센다.

Step 1: "양 끝에서 조여 온다" (마주 보는 투 포인터)

가장 유명한 코딩테스트 문제 하나로 시작하죠. "정렬된 정수 배열에서 두 수를 더해 target이 되는 짝을 찾아라." 처음 떠오르는 풀이는 대개 이렇습니다. 모든 두 수의 짝을 다 만들어 보는 거예요.

Python
# 초보자의 첫 풀이 — 모든 짝을 다 만들어 본다
for i in range(len(arr)):
    for j in range(i + 1, len(arr)):
        if arr[i] + arr[j] == target:
            return (i, j)

정답은 맞습니다. 그런데 반복문이 두 겹이죠. 짝의 개수가 약 n²/2개라 O(n²)입니다. n이 10만이면 50억 번, "1초 ≈ 1억 연산" 잣대로 50초입니다. 시간 초과예요.

여기서 결정적인 힌트가 "정렬돼 있다"는 조건입니다. 정렬된 배열이라면, 두 손가락을 양 끝에 하나씩 두고 안쪽으로 조여 올 수 있어요.

텍스트
 two_sum_sorted([2, 7, 11, 15], 9)   — 양 끝에서 조여 온다

   index:  0    1    2    3
   값:     2    7    11   15
           lo             hi     2 + 15 = 17 > 9   너무 크다, hi를 왼쪽으로
           lo        hi          2 + 11 = 13 > 9   아직 크다, hi를 왼쪽으로
           lo   hi               2 +  7 =  9       찾았다! (0, 1)

원리는 이렇습니다. 두 끝 값의 합이 target보다 크면, 합을 줄여야 하니 큰 쪽(hi)을 안으로 당깁니다. 합이 target보다 작으면, 합을 키워야 하니 작은 쪽(lo)을 안으로 밀어요. 정렬돼 있으니 이 판단이 항상 옳습니다. 한 번 비교에 lo나 hi가 한 칸씩 확실히 좁혀지니, 최대 n번이면 끝나 O(n)입니다.

Python
# algorithms/two_pointers.py
def two_sum_sorted(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo < hi:
        total = arr[lo] + arr[hi]
        if total == target:
            return (lo, hi)
        elif total < target:
            lo += 1
        else:
            hi -= 1
    return None
연산 시간 공간
이중 반복문 O(n²) O(1)
마주 보는 투 포인터 O(n) O(1)

투 포인터의 힘이 이겁니다. 이진 탐색이 "정렬돼 있으니 절반을 통째로 버린다"였다면, 투 포인터는 "정렬돼 있으니 한쪽 끝을 안심하고 당긴다"예요. 두 기법 모두 정렬이라는 발판 위에서만 성립합니다.

💡 한 줄 정리

정렬된 배열이면 양 끝에 둔 두 포인터를 합의 크기에 따라 안으로 조여, 이중 반복문 O(n²)로 풀던 두 수의 합을 O(n)에 푼다.

🙋 학생 질문 — "정렬이 안 된 배열이면 투 포인터를 못 쓰나요?"

양 끝에서 조이는 이 방식은 정렬을 전제로 합니다. 정렬이 안 돼 있으면 "합이 크니 오른쪽을 당긴다"는 판단이 성립하지 않아요. 뒤죽박죽인 배열에선 오른쪽 끝이 큰 값이라는 보장이 없으니까요. 그래서 정렬 안 된 배열이라면 두 갈래입니다. 먼저 정렬(O(n log n))한 뒤 투 포인터를 쓰거나, 아니면 지난 시간에 배운 해시(dict)로 "target - arr[i]가 앞에 있었나"를 O(1)에 물어 O(n)에 푸는 방법이죠. 정렬이 이미 돼 있으면 투 포인터가 공간 O(1)로 가장 깔끔하고, 원래 인덱스를 지켜야 하면 해시가 유리합니다. 다음 Step에서 "정렬을 발판으로 삼는" 쪽을 더 깊이 봅니다.


Step 2: "정렬이 발판이다" (정렬 + 투 포인터)

지난 시간에 "정렬은 투 포인터의 발판이기도 하다"고 예고했죠. 그 말을 정면으로 회수할 차례입니다. 이번엔 두 수가 아니라 세 수의 합이 0이 되는 조합을 모두 찾아봅시다(유명한 3Sum 문제예요).

세 수를 다 뒤지면 반복문이 세 겹, O(n³)입니다. n이 1000이면 10억 번이라 벌써 아슬아슬해요. 어떻게 줄일까요? 발상은 이렇습니다. 세 수 중 하나를 고정하면, 남은 문제는 "나머지 두 수의 합이 (고정한 수의 부호를 뒤집은 값)이 되는 짝 찾기"입니다. 그건 바로 Step 1에서 푼 두 수의 합이죠!

그런데 두 수의 합을 O(n)에 풀려면 배열이 정렬돼 있어야 합니다. 그래서 맨 앞에서 한 번 정렬(O(n log n))해 두면, 고정한 수의 오른쪽 구간을 마주 보는 투 포인터로 훑을 수 있어요.

텍스트
 three_sum — 한 수를 고정하고, 나머지 둘을 양 끝에서 조인다

   정렬 후:  -4   -1   -1    0    1    2
             고정  lo ────────────── hi        고정 = -1 일 때
                   목표: 나머지 두 수의 합 = +1
                   -1 + 2 = 1   찾았다: [-1, -1, 2]
                   0  + 1 = 1   찾았다: [-1,  0, 1]

여기서 까다로운 건 중복 제거입니다. 정렬해 두면 같은 값이 이웃하니, 고정한 수와 두 포인터 모두 "직전과 같은 값이면 건너뛴다"로 중복 조합을 걸러낼 수 있어요.

Python
# algorithms/two_pointers.py
def three_sum(nums):
    nums = sorted(nums)
    n = len(nums)
    result = []
    for i in range(n - 2):
        if nums[i] > 0:                       # 가장 작은 수가 이미 양수면 합이 0일 수 없다
            break
        if i > 0 and nums[i] == nums[i - 1]:  # 고정 수 중복 건너뛰기
            continue
        lo, hi = i + 1, n - 1
        while lo < hi:
            total = nums[i] + nums[lo] + nums[hi]
            if total < 0:
                lo += 1
            elif total > 0:
                hi -= 1
            else:
                result.append([nums[i], nums[lo], nums[hi]])
                lo += 1
                hi -= 1
                while lo < hi and nums[lo] == nums[lo - 1]:   # 두 포인터 중복 건너뛰기
                    lo += 1
                while lo < hi and nums[hi] == nums[hi + 1]:
                    hi -= 1
    return result

빅오를 정직하게 짚고 갑시다. 바깥 반복문(고정 수)이 O(n)이고, 그 안에서 투 포인터가 O(n)이라 곱하면 O(n²)입니다. 정렬 비용 O(n log n)은 여기 묻히고요. 삼중 반복문 O(n³)를 O(n²)로 내린 거지, O(n)까지 내린 게 아니에요.

여기서 중요한 감각 하나. 투 포인터가 항상 O(n)인 건 아닙니다. 투 포인터는 "한 번 훑기를 O(n)에 한다"는 도구일 뿐이고, 그 훑기를 몇 번 반복하느냐에 따라 전체 복잡도가 정해집니다. Step 1처럼 한 번만 훑으면 O(n), 여기처럼 고정 수마다 훑으면 O(n²)예요.

💡 한 줄 정리

세 수의 합은 한 수를 고정하고 나머지 둘을 마주 보는 투 포인터로 훑어 O(n³)를 O(n²)로 내리며, 이때 정렬이 투 포인터의 발판이자 중복 제거의 근거가 된다.

🙋 학생 질문 — "고정한 수가 양수면 왜 바로 멈추나요? (nums[i] > 0 → break)"

정렬해 뒀기 때문에 쓸 수 있는 지름길입니다. 배열이 오름차순이라 고정한 수 nums[i]가 이미 양수라면, 그 오른쪽의 두 수 nums[lo], nums[hi]는 전부 그보다 크거나 같아 셋 다 양수예요. 양수 셋을 더해 0이 될 수는 없죠. 그러니 그 뒤는 더 볼 것도 없이 반복문을 끝내는 겁니다. 정렬이 "합이 크면 hi를 당긴다"는 투 포인터 판단만 가능하게 하는 게 아니라, 이런 조기 종료(가지치기)까지 선물해 줘요. 정렬 한 번이 얼마나 많은 걸 열어 주는지 느껴지시죠?


Step 3: "두 손가락이 같은 쪽으로" (slow/fast 투 포인터)

지금까지 두 포인터는 양 끝에서 마주 봤습니다. 투 포인터엔 또 하나의 형태가 있어요. 두 포인터가 같은 방향으로, 다른 속도로 나아가는 방식입니다. 연결 리스트를 배울 때 사이클을 잡던 "빠른 포인터와 느린 포인터"를 잠깐 언급했었죠? 그 사고를 배열에서 정식으로 봅니다.

문제는 이겁니다. "정렬된 배열에서 중복을 제거하되, 새 배열을 만들지 말고 제자리에서 처리하고 남은 길이를 반환하라." 정렬돼 있으니 같은 값은 서로 이웃해 있어요. 두 포인터 slowfast를 이렇게 씁니다.

  • slow: 유일한 값들이 채워진 마지막 위치
  • fast: 배열을 훑어 나가는 위치

fastslow와 다른 값을 만나면, 그건 새로 발견한 유일값이니 slow를 한 칸 늘려 그 값을 옮겨 적습니다. 같은 값이면 그냥 지나쳐요.

텍스트
 remove_duplicates([0, 0, 1, 1, 1, 2, 2, 3, 3, 4])

   시작:   0  0  1  1  1  2  2  3  3  4
          slow
             fast                           fast가 slow와 같으면 지나침
                                             다르면 slow++ 후 그 값을 덮어씀
   결과:  [0][1][2][3][4] 1  2  3  3  4       앞쪽 5칸에 유일값이 모임
          └──── 반환값 5 ────┘                arr[:5] = [0, 1, 2, 3, 4]
Python
# algorithms/two_pointers.py
def remove_duplicates(arr):
    if not arr:
        return 0
    slow = 0
    for fast in range(1, len(arr)):
        if arr[fast] != arr[slow]:
            slow += 1
            arr[slow] = arr[fast]
    return slow + 1

배열을 딱 한 번 훑으니 O(n) 시간입니다. 그리고 새 배열을 만들지 않고 앞쪽 칸을 덮어쓰니 추가 공간은 O(1)이에요. 만약 새 리스트에 유일값을 담았다면 공간이 O(n)이 됩니다. "제자리에서 처리(in-place)"가 공간을 아끼는 핵심이죠.

이 같은 방향 투 포인터가 중요한 이유가 하나 더 있습니다. 두 포인터가 앞으로만 가고 되돌아오지 않는다는 점, 이게 바로 다음에 배울 슬라이딩 윈도우의 뿌리예요. 창문의 왼쪽과 오른쪽 경계가 곧 두 포인터이고, 둘 다 오른쪽으로만 움직입니다.

💡 한 줄 정리

같은 방향 투 포인터는 slow(채운 끝)와 fast(탐색)를 나란히 오른쪽으로 굴려, 정렬 배열의 중복을 O(n) 시간·O(1) 공간에 제자리 제거하며, 이것이 슬라이딩 윈도우의 뿌리다.

🙋 학생 질문 — "그냥 set으로 중복을 없애면 한 줄인데 왜 이렇게 하나요?"

맞아요, 실전에서 "중복만 없애면 된다"면 sorted(set(arr)) 한 줄이 가장 빠릅니다. 원리를 굳이 손으로 짜 보는 건 두 가지 때문이에요. 첫째, set은 순서를 흩뜨리고 추가 공간 O(n)을 씁니다. "정렬을 유지한 채, 제자리에서, 공간 O(1)로"라는 조건이 붙으면 set으로는 안 돼요. 둘째, 같은 방향 투 포인터라는 사고 자체가 슬라이딩 윈도우로 곧장 이어집니다. 여기서 "두 포인터가 나란히 오른쪽으로"를 익혀 두면 다음 Step이 훨씬 수월해요. 코딩테스트에서 "제자리(in-place)"나 "추가 배열 없이"라는 단서가 보이면 이 slow/fast 형태를 떠올리세요.


Step 4: "창문을 한 칸씩 민다" (슬라이딩 윈도우 ① 고정 길이)

이제 슬라이딩 윈도우로 넘어갑니다. 지난 시간 예고에서 "창문을 미끄러뜨리듯 구간을 훑는다"고 했던 그 기법이에요. 먼저 창문 크기가 고정된 경우부터 봅시다.

문제는 이렇습니다. "배열에서 연속한 k개의 합 중 최댓값을 구하라." 순진하게 풀면 각 시작 위치마다 k개를 더합니다.

Python
# 순진한 풀이 — 매 구간마다 k개를 새로 더한다
best = max(sum(arr[i:i+k]) for i in range(len(arr) - k + 1))

구간이 약 n개이고 매번 k개를 더하니 O(n·k)입니다. n과 k가 둘 다 크면 시간 초과예요. 낭비가 보이시나요? 창문이 한 칸 오른쪽으로 밀릴 때, 겹치는 k-1개는 이미 더한 값인데 매번 처음부터 다시 더하고 있어요.

슬라이딩 윈도우는 이 겹침을 활용합니다. 창문이 한 칸 밀리면, 빠져나간 값 하나를 빼고 새로 들어온 값 하나를 더하면 끝이에요. 두 번의 연산, O(1)로 다음 창문의 합이 나옵니다.

텍스트
 max_sum_window([1, 4, 2, 10, 2, 3, 1, 0, 20], k=4)

   [1  4  2 10] 2  3  1  0 20     합 17   (첫 창문 = 앞 4개를 한 번만 더한다)
    1[4  2 10  2]3  1  0 20       합 18 = 17 + 2 - 1     들어온 2, 나간 1
    1  4[2 10  2  3]1  0 20       합 17 = 18 + 3 - 4
    ...
    1  4  2 10  2[3  1  0 20]     합 24 = 6 + 20 - 2      최댓값
                  └ 한 칸 밀 때마다 '들어온 값 - 나간 값'만 갱신 (O(1))
Python
# patterns/sliding_window.py
def max_sum_window(arr, k):
    n = len(arr)
    if k <= 0 or k > n:
        return 0
    window = sum(arr[:k])
    best = window
    for i in range(k, n):
        window += arr[i] - arr[i - k]        # 들어온 값 더하고 나간 값 뺀다 (O(1))
        if window > best:
            best = window
    return best
연산 시간 공간
매 구간 새로 더하기 O(n·k) O(1)
고정 슬라이딩 윈도우 O(n) O(1)

두 트랙을 짚어 둘게요. 파이썬에서 sum(arr[i:i+k])는 짧고 편합니다. 하지만 슬라이싱은 매번 k개를 다시 더하니 O(k)예요. 창문이 많으면 그 편함이 O(n·k)로 돌아옵니다. 그래서 "빼고 더하는" 갱신을 익혀 두는 거예요. 한 번 첫 창문을 만들 때만 sum을 쓰고, 이후엔 O(1)로 굴립니다.

💡 한 줄 정리

고정 길이 슬라이딩 윈도우는 창문이 한 칸 밀릴 때 나간 값을 빼고 들어온 값만 더해 O(1)로 갱신하니, 매 구간 새로 더하는 O(n·k)를 O(n)으로 내린다.

🙋 학생 질문 — "슬라이싱 sum이 한 줄이라 편한데, 굳이 빼고 더하는 게 이득인가요?"

k가 작으면 차이가 안 느껴질 수 있어요. 하지만 k도 입력의 일부라, 문제에 따라 k가 수만까지 커집니다. 그러면 O(n·k)는 금세 수십억이 되고, O(n)과의 차이가 합격과 시간 초과를 가릅니다. 게다가 슬라이딩 윈도우 사고는 다음 Step의 가변 창문으로 그대로 이어져요. 고정 창문에서 "겹치는 부분을 다시 계산하지 않는다"는 감각을 잡아 두면, 조건에 따라 창문을 늘였다 줄이는 문제도 같은 원리로 풀립니다. 편한 한 줄은 원리를 이해한 다음에 "작은 입력이면 이걸로 충분"할 때 쓰는 거예요.


Step 5: "창문을 늘였다 줄인다" (슬라이딩 윈도우 ② 가변 길이)

고정 창문은 크기 k가 정해져 있었죠. 그런데 "조건을 만족하는 가장 짧은(또는 가장 긴) 구간"을 찾으라면, 창문 크기가 미리 정해지지 않습니다. 이럴 땐 창문을 상황에 따라 늘였다 줄여요. 이게 가변 길이 슬라이딩 윈도우입니다.

첫 문제. "양수 배열에서 합이 target 이상이 되는 가장 짧은 연속 구간의 길이를 구하라." 오른쪽 경계 right를 밀며 창문을 넓히다가, 창문 합이 target 이상이 되는 순간 왼쪽 경계 left를 조여 "조건을 유지하는 최소 길이"를 노립니다.

텍스트
 min_len_subarray([2, 3, 1, 2, 4, 3], target=7)

   [2 3 1 2]         합 8 ≥ 7   길이 4 기록, 왼쪽을 조인다
      [3 1 2]        합 6 < 7   오른쪽을 넓힌다
      [3 1 2 4]      합 10 ≥ 7  왼쪽을 조인다
        [1 2 4]      합 7 ≥ 7   길이 3 기록, 더 조인다
          [2 4]      합 6 < 7   오른쪽을 넓힌다
          [2 4 3]    합 9 ≥ 7   왼쪽을 조인다
            [4 3]    합 7 ≥ 7   길이 2 기록  최소
Python
# patterns/sliding_window.py
def min_len_subarray(arr, target):
    n = len(arr)
    left = 0
    window = 0
    best = n + 1
    for right in range(n):
        window += arr[right]                 # 오른쪽으로 창문을 넓힌다
        while window >= target:              # 조건을 만족하면 왼쪽을 조여 최소 길이를 노린다
            best = min(best, right - left + 1)
            window -= arr[left]
            left += 1
    return best if best <= n else 0

안쪽에 while이 있어서 "이거 이중 반복문 아닌가? O(n²)?" 싶을 수 있어요. 그런데 자세히 보면 leftright는 각자 오른쪽으로만 가고 절대 되돌아오지 않습니다. 각 원소는 창문에 딱 한 번 들어오고(right가 지날 때) 딱 한 번 나갈(left가 지날 때) 뿐이에요. 그래서 두 포인터가 움직이는 총 횟수가 2n을 넘지 않아 O(n)입니다. Step 3의 "같은 방향, 되돌아오지 않는다"가 여기서 O(n)을 보장해요.

두 번째 문제도 봅시다. "중복 문자가 없는 가장 긴 부분 문자열의 길이"예요. 여기선 창문 안에 어떤 문자가 있는지를 지난 시간의 이웃, 해시(dict)로 기억합니다. 각 문자를 마지막으로 본 위치를 저장해 두고, 창문 안에서 이미 본 문자를 다시 만나면 왼쪽 경계를 그 다음으로 점프시켜요.

Python
# patterns/sliding_window.py
def longest_unique(s):
    last = {}
    left = 0
    best = 0
    for right, ch in enumerate(s):
        if ch in last and last[ch] >= left:
            left = last[ch] + 1              # 중복을 만나면 창문 왼쪽을 그 다음으로 점프
        last[ch] = right
        best = max(best, right - left + 1)
    return best

last[ch] >= left라는 조건이 함정을 막는 핵심입니다. 이미 창문 밖으로 밀려난 옛 중복은 무시해야 창문을 헛되이 줄이지 않아요. 예를 들어 "abba"에서 마지막 a를 만날 때, 맨 앞의 a는 이미 창문 밖이라 점프하지 않습니다. 이 조건이 없으면 답이 틀어져요.

💡 한 줄 정리

가변 길이 슬라이딩 윈도우는 오른쪽으로 창문을 넓히다 조건을 넘으면 왼쪽을 조이며, 두 포인터가 되돌아오지 않아 이중 while처럼 보여도 O(n)이다.

🙋 학생 질문 — "min_len_subarray는 왜 '양수 배열'이라는 단서가 붙나요?"

가변 슬라이딩 윈도우가 성립하려면 "창문을 넓히면 합이 커지고, 줄이면 합이 작아진다"는 단조성이 있어야 해요. 원소가 전부 양수라야 이게 보장됩니다. 오른쪽을 넓히면 무조건 합이 늘고, 왼쪽을 조이면 무조건 줄죠. 그래서 "합이 target 이상이 되면 더 넓힐 필요 없이 왼쪽을 조인다"가 옳습니다. 만약 음수가 섞이면, 창문을 넓혔는데 합이 오히려 줄 수도 있어 이 판단이 무너져요. 음수가 있는 구간 합 문제는 슬라이딩 윈도우가 아니라 다른 도구가 필요한데, 그게 바로 Step 7에서 볼 누적합과 해시입니다.


Step 6: "미리 더해 두면 구간 합이 공짜" (누적합)

투 포인터와 슬라이딩 윈도우는 창문을 "연속으로 밀며" 구간을 훑었죠. 그런데 "임의의 구간 합을 여러 번, 제각각 물어보는" 상황은 어떨까요? "3번부터 7번까지 합", "1번부터 5번까지 합"처럼요. 매번 그 구간을 훑으면 질의 하나에 O(구간 길이)라, 질의가 많으면 느려집니다.

지난 시간 예고에서 "미리 더해 두고 빼서 쓰는 누적합"이라고 했죠. 발상은 단순해요. "앞에서부터 i개를 더한 값"을 미리 전부 계산해 배열로 쌓아 둡니다. 그러면 어떤 구간의 합이든 뺄셈 한 번으로 나와요.

텍스트
 prefix_sum([1, 2, 3, 4]) = [0, 1, 3, 6, 10]

   arr:          1    2    3    4
   prefix:  0    1    3    6    10       prefix[i] = 앞에서 i개의 합
            │                   │
         0개 합               4개 합

   구간 arr[1:3] 의 합 = prefix[3] - prefix[1] = 6 - 1 = 5   (2 + 3)
                        └ 앞 3개 합에서 앞 1개 합을 빼면 가운데만 남는다
Python
# algorithms/two_pointers.py
def prefix_sum(arr):
    prefix = [0]
    for x in arr:
        prefix.append(prefix[-1] + x)
    return prefix


def range_sum(prefix, i, j):
    return prefix[j] - prefix[i]

prefix[j] - prefix[i]가 왜 arr[i:j]의 합인지 그림으로 보면 명확해요. prefix[j]는 앞에서 j개의 합, prefix[i]는 앞에서 i개의 합이니, 둘을 빼면 i번부터 j-1번까지, 즉 가운데 구간만 남습니다. 맨 앞에 0을 하나 덧대(prefix[0] = 0) 두는 이유가 여기 있어요. "앞에서 0개를 더한 값"이 있어야 맨 앞부터 시작하는 구간도 뺄셈 하나로 깔끔하게 표현됩니다.

빅오를 나눠 봅시다. 누적합 배열을 만드는 준비가 O(n)입니다. 하지만 한 번 만들어 두면 이후 구간 합 질의는 전부 O(1)이에요. 질의가 Q번이면 전체가 O(n + Q)로, 매번 훑는 O(Q·n)과는 비교가 안 됩니다. 실전에선 itertools.accumulate가 같은 일을 하는데, 맨 앞 0만 직접 덧대 주면 돼요.

💡 한 줄 정리

누적합은 "앞에서 i개의 합"을 미리 O(n)에 쌓아 두고, 어떤 구간 합이든 prefix[j] - prefix[i] 뺄셈 한 번 O(1)로 답하니, 구간 합을 여러 번 물을 때 결정적으로 빠르다.

🙋 학생 질문 — "슬라이딩 윈도우도 구간 합을 다뤘는데, 누적합이랑 뭐가 다른가요?"

둘 다 구간 합을 O(1)에 갱신한다는 점은 닮았지만 쓰임이 달라요. 슬라이딩 윈도우는 창문이 "연속으로 한 칸씩" 이동할 때 씁니다. 이전 창문과 다음 창문이 대부분 겹치니 그 차이만 갱신하죠. 반면 누적합은 "제각각 떨어진 임의의 구간"을 여러 번 물을 때 진가를 발휘합니다. 3~7번을 물었다가 갑자기 10~12번을 물어도, 미리 쌓아 둔 누적합이 있으니 각각 뺄셈 한 번이면 돼요. 정리하면 "창문이 연속으로 미끄러지면 슬라이딩 윈도우, 임의 구간을 반복해서 물으면 누적합"입니다. 다음 Step에선 이 누적합이 해시와 만나 한 단계 더 나아가요.


Step 7: "누적합과 해시가 만나면" (구간 합 K 세기 + 유형 분별)

오늘의 마지막이자, 세 무기를 종합하는 Step입니다. 문제는 이거예요. "합이 정확히 K인 연속 부분 배열이 몇 개인지 세라. 단, 배열엔 음수도 있다."

음수가 있다는 단서가 결정적입니다. Step 5에서 봤듯, 음수가 섞이면 슬라이딩 윈도우가 안 통해요. 창문을 넓혀도 합이 줄 수 있어 단조성이 깨지니까요. 그럼 어떻게 할까요? 누적합으로 다시 바라봅니다.

구간 arr[i:j]의 합은 prefix[j] - prefix[i]죠. "합이 K"라는 조건은 곧 prefix[j] - prefix[i] == K, 다시 쓰면 prefix[i] == prefix[j] - K입니다. 즉 각 위치 j에서, "앞선 누적합 중 prefix[j] - K와 같은 값이 몇 번 나왔나"만 세면 돼요. 그 개수를 O(1)에 물으려면? 지난 시간의 이웃, 해시(dict)입니다. 지금까지 본 누적합의 빈도를 dict에 쌓아 두면서 훑어요.

텍스트
 count_subarrays_with_sum([1, 1, 1], k=2)

   x=1:  running=1   need 1-2=-1  없음(0)        seen={0:1, 1:1}
   x=1:  running=2   need 2-2= 0  seen[0]=1  +1  seen={0:1, 1:1, 2:1}
   x=1:  running=3   need 3-2= 1  seen[1]=1  +1  seen={0:1, 1:1, 2:1, 3:1}
                                            합계 2 = [1,1] 두 군데
Python
# algorithms/two_pointers.py
from collections import defaultdict


def count_subarrays_with_sum(arr, k):
    seen = defaultdict(int)
    seen[0] = 1
    running = 0
    count = 0
    for x in arr:
        running += x
        count += seen[running - k]      # 합이 k인 구간의 왼쪽 끝 후보 수
        seen[running] += 1
    return count

seen[0] = 1로 시작하는 이유가 미묘해요. "앞에서부터 통째로" 더한 구간(즉 prefix 자체가 K인 경우)도 세야 하는데, 그러려면 "아무것도 안 더한 상태(prefix[0] = 0)"가 한 번 있었다고 쳐 줘야 합니다. 이 한 줄을 빼면 맨 앞부터 시작하는 구간을 놓쳐요. 빅오는 한 번 훑으니 O(n) 시간, dict에 누적합을 담으니 O(n) 공간입니다.

자, 오늘 배운 세 무기를 언제 꺼낼지 정리하죠. 코딩테스트에서 이 신호들이 보이면 반사적으로 손이 나가야 해요.

문제의 신호 꺼낼 무기 복잡도
정렬된 배열에서 두/세 수의 합·차 관계 마주 보는 투 포인터 O(n)~O(n²)
제자리 처리, 추가 배열 없이 같은 방향(slow/fast) 투 포인터 O(n)
연속 구간, 길이가 고정 고정 슬라이딩 윈도우 O(n)
연속 구간, 조건 만족 최소/최대 길이 (양수) 가변 슬라이딩 윈도우 O(n)
임의 구간 합 반복 질의 / 합이 K인 구간 (음수 가능) 누적합 (+해시) O(n)

💡 한 줄 정리

음수가 섞여 슬라이딩 윈도우가 막히면, 구간 합을 prefix[j] - prefix[i]로 바꿔 "prefix[j] - K가 몇 번 나왔나"를 해시로 O(1)에 물어, 합이 K인 구간을 O(n)에 센다.

🙋 학생 질문 — "왜 여기선 슬라이딩 윈도우가 안 되고 누적합+해시를 쓰나요?"

핵심은 음수의 존재예요. 슬라이딩 윈도우는 "오른쪽을 넓히면 합이 커진다"는 단조성 위에서 돕니다. 합이 K를 넘으면 "더 넓힐 필요 없다"고 왼쪽을 조이죠. 그런데 음수가 있으면 오른쪽을 넓혔는데 합이 오히려 줄 수 있어, "넘었으니 그만"이라는 판단이 틀립니다. 뒤에 큰 음수와 더 큰 양수가 있으면 다시 K가 될 수도 있으니까요. 그래서 창문으로는 못 잡아요. 누적합+해시는 단조성에 기대지 않습니다. "두 누적합의 차가 K"라는 등식만 보니 값의 부호와 무관하게 통해요. "구간 합인데 음수가 있다"가 보이면 창문을 접고 누적합+해시를 떠올리세요. 오늘 배운 세 무기가 각자 어디서 통하고 어디서 막히는지, 이 경계를 아는 게 진짜 실력입니다.


마무리

오늘은 정렬이라는 발판 위에, 지난 시간 이진 탐색에 이은 두 번째 무기 묶음을 올렸습니다. 이중 반복문 O(n²)로 훑던 구간 문제들을, 두 포인터와 창문과 미리 더한 합으로 O(n)까지 눌러 봤어요.

오늘 배운 핵심 세 가지

  • 💡 하나 — 투 포인터는 두 손가락으로 배열을 훑는다. 정렬된 배열의 양 끝에서 조여 오면(마주 보기) 두 수의 합을 O(n)에, 같은 방향으로 나란히 가면(slow/fast) 제자리 중복 제거를 O(n)·O(1)에 푼다. 정렬이 그 발판이다.
  • 💡 둘 — 슬라이딩 윈도우는 창문을 미끄러뜨린다. 고정 길이는 나간 값을 빼고 들어온 값만 더해 O(1)로 갱신하고, 가변 길이는 오른쪽으로 넓히다 조건을 넘으면 왼쪽을 조인다. 두 포인터가 되돌아오지 않아 O(n)이다.
  • 💡 셋 — 누적합은 미리 더해 둔다. 앞에서 i개의 합을 쌓아 두면 구간 합이 뺄셈 한 번 O(1)이고, 해시와 만나면 음수가 섞인 배열에서도 합이 K인 구간을 O(n)에 센다.

이걸로 카테고리 D(정렬과 탐색)를 마칩니다. 돌아보면 한 줄기였어요. A-1에서 빅오라는 잣대를 쥐고, B와 C에서 자료구조를 익힌 뒤, D에서 그 위에 정렬(D-1·D-2)을 깔고, 정렬된 배열을 절반씩 버리는 이진 탐색(D-3)과 양 끝에서 조이는 투 포인터(D-4)를 올렸죠. "정렬은 이진 탐색과 투 포인터의 공통 발판"이라는 한마디가 카테고리 D 전체를 꿰뚫습니다.

다음 시간 예고

지금까지는 "영리하게 훑어" O(n)으로 눌렀습니다. 그런데 세상엔 영리한 지름길이 없어 "모든 경우를 다 뒤져야만" 하는 문제들이 있어요. 다음 시간(E-1)엔 그 완전탐색으로 들어갑니다. 완전탐색의 뼈대는 자기 자신을 부르는 재귀예요. 함수가 자기를 부르고 종료 조건에서 되돌아오는 그 호출 스택 그림, 커리큘럼 첫머리에서 봤던 그 그림을 정식으로 펼칩니다. 그리고 가망 없는 가지를 미리 쳐 내는 백트래킹으로, 다 뒤지되 헛일은 줄이는 법까지 나아가요. 오늘 O(n)의 우아함을 봤다면, 다음 시간엔 O(2ⁿ)·O(n!)의 세계와 그걸 다루는 법을 만납니다.


과제

오늘 배운 세 무기를 손에 붙이는 문제들입니다. 코드베이스 algorithms/two_pointers.pypatterns/sliding_window.py의 함수를 가져다 쓰거나, 직접 짜서 풀어 보세요. 각 문제의 시간·공간 복잡도를 빅오로 함께 적는 걸 잊지 마세요.

[기초] 회문(팰린드롬) 검사하기

문자열 s가 앞으로 읽으나 뒤로 읽으나 같은지(회문인지) True/False로 반환하는 is_palindrome(s)를 마주 보는 투 포인터로 작성하세요. 예를 들어 is_palindrome("level")True, is_palindrome("hello")False, is_palindrome("")True입니다.

  • 양 끝에 포인터를 두고 안쪽으로 조여 오는 Step 1의 형태 그대로입니다. 두 포인터가 가리키는 문자가 다르면 그 자리에서 회문이 아니에요.
  • 뒤집은 문자열과 비교하는 s == s[::-1]도 답은 같지만 공간 O(n)을 씁니다. 투 포인터로 짜면 공간 O(1)인 이유를 한 줄로 설명하세요.

[응용] 합이 정확히 target인 연속 구간 찾기

양수로만 이루어진 배열 arr와 값 target이 주어질 때, 합이 정확히 target인 연속 부분 배열이 있으면 그 (시작 인덱스, 끝 인덱스)를, 없으면 None을 반환하는 subarray_sum_positive(arr, target)을 가변 슬라이딩 윈도우로 작성하세요. 끝 인덱스는 포함입니다. 예를 들어 arr = [1, 2, 3, 7, 5]에서 subarray_sum_positive(arr, 12)(1, 3)(2+3+7), subarray_sum_positive(arr, 100)None입니다.

  • Step 5의 가변 창문을 응용합니다. 합이 target보다 작으면 오른쪽을 넓히고, 크면 왼쪽을 조이고, 정확히 같으면 찾은 거예요.
  • 왜 이 문제는 양수 배열에서만 슬라이딩 윈도우가 성립하는지, Step 5·7의 단조성 이야기로 설명하세요.

[심화] 0과 1의 개수가 같은 최장 구간

0과 1로만 이루어진 배열 arr에서, 0의 개수와 1의 개수가 같은 가장 긴 연속 부분 배열의 길이를 반환하는 longest_equal_binary(arr)를 누적합과 해시로 작성하세요. 예를 들어 longest_equal_binary([0, 1, 0])2(앞의 [0, 1]), longest_equal_binary([0, 1, 1, 0, 1, 1, 1, 0])4입니다.

  • 0을 -1로 바꿔서 생각해 보세요. 그러면 "0과 1의 개수가 같다"가 "그 구간의 합이 0"으로 바뀝니다. Step 7에서 본 "구간 합 = 누적합의 차"가 곧장 쓰여요.
  • 누적합이 같은 두 위치 사이는 합이 0인 구간입니다. 각 누적합 값이 "처음 나온 위치"를 해시에 저장해 두면, 같은 값을 다시 만났을 때 그 사이 길이가 후보가 돼요. 왜 "처음 나온 위치"만 저장하면 최장이 보장되는지 생각해 보세요.

생각해볼 주제

1. 투 포인터는 왜 정렬을 발판으로 삼을까, 정렬이 없으면 무엇이 무너지나

마주 보는 투 포인터는 "합이 크면 오른쪽을 당기고, 작으면 왼쪽을 민다"는 판단으로 한쪽을 통째로 버립니다. 이 판단이 옳으려면 배열이 정렬돼 있어야 해요. 정렬을 지우면 이 버리기가 왜 성립하지 않는지, 그리고 이게 지난 시간 이진 탐색이 정렬을 전제하던 이유와 어떻게 같은 뿌리인지 이어서 생각해 보세요. "한쪽을 버려도 그 안에 답이 없다는 보장"이라는 표현이 힌트입니다.

2. 슬라이딩 윈도우가 통하는 문제와 안 통하는 문제의 경계는 어디일까

Step 5의 최소 길이 구간은 슬라이딩 윈도우로 풀렸지만(양수 배열), Step 7의 합이 K인 구간 세기는 누적합+해시로 풀었습니다(음수 포함). 무엇이 이 둘을 갈랐을까요? "창문을 넓히면 합이 반드시 커진다"는 단조성이 언제 성립하고 언제 깨지는지, 그리고 단조성이 깨진 문제를 만났을 때 창문 대신 무엇을 꺼내야 하는지 정리해 보세요. 이 경계를 아는 게 유형을 잘못 골라 헤매는 걸 막아 줍니다.

3. "미리 계산해 두기"의 트레이드오프 — 누적합은 언제 이득이고 언제 낭비일까

누적합은 O(n)을 들여 미리 배열을 만들어 두고, 이후 구간 합 질의를 O(1)에 답합니다. 그런데 구간 합을 딱 한 번만 물어볼 거라면 어떨까요? 미리 만드는 O(n)이 아까울 수 있어요. 질의 횟수를 기준으로 "미리 계산해 두는 전처리"가 언제 이득이고 언제 낭비인지 따져 보세요. 이건 지난 시간 "정렬 후 이진 탐색이 언제 이득인가"와 정확히 같은 결의 사고입니다. 전처리 비용을 몇 번의 질의로 나눠 갚는지가 판단의 핵심이에요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 회문(팰린드롬) 검사하기

채점 포인트

항목 확인
마주 보는 투 포인터 lo(맨 앞)와 hi(맨 뒤)가 안으로 조여 오며 견준다
조기 종료 한 쌍이라도 다르면 그 자리에서 False
빅오 한 번 훑으니 O(n)·공간 O(1)
실전 감각 s == s[::-1]은 뒤집은 문자열을 새로 만들어 공간 O(n)

풀이 예시

Step 1의 마주 보는 투 포인터 그대로입니다. 양 끝에 포인터를 두고 안으로 조여 오며 문자를 견줘요. 두 문자가 다르면 그 순간 회문이 아니니 바로 False를 내고, 끝까지 어긋나지 않으면 회문입니다.

Python

def is_palindrome(s):
    lo, hi = 0, len(s) - 1
    while lo < hi:
        if s[lo] != s[hi]:
            return False
        lo += 1
        hi -= 1
    return True

"abba"를 따라가 봅시다.

텍스트
 is_palindrome("abba")

   index:  0   1   2   3
   문자:   a   b   b   a
           lo         hi     s[0]=a == s[3]=a   같다, 안으로 조인다
               lo hi         s[1]=b == s[2]=b   같다, 조인다
                 (교차)      lo >= hi  끝까지 어긋나지 않음  True

두 포인터가 가운데서 만날 때까지 어긋난 쌍이 없으니 True입니다. "abca"라면 첫 쌍(a, a)은 같지만 두 번째 쌍(b, c)이 달라 그 자리에서 False가 나와요. 빈 문자열이나 한 글자는 lo < hi가 처음부터 거짓이라 곧장 True입니다. 빅오는 최대 n/2번 비교라 O(n), 추가 공간은 포인터 두 개뿐이라 O(1)이에요.

💡 튜터의 한마디: s == s[::-1] 한 줄도 답은 같지만, s[::-1]이 뒤집은 문자열을 새로 만들어 O(n) 공간을 씁니다. 투 포인터는 원본을 그대로 두고 인덱스만 움직여 O(1)이에요. 짧은 문자열이면 슬라이싱이 편하지만, "추가 공간 없이"라는 조건이 붙거나 아주 긴 문자열이면 투 포인터가 정답입니다. LeetCode 125 "Valid Palindrome"이 여기에 "영문자·숫자만 보고 대소문자 무시"를 얹은 형태라, 이 골격을 알아 두면 곧장 확장돼요.


🎯 [과제 2 예시답안] 합이 정확히 target인 연속 구간 찾기

채점 포인트

항목 확인
가변 슬라이딩 윈도우 right로 넓히고, 넘으면 left로 조인다
세 갈래 판단 작으면 넓히고, 크면 조이고, 같으면 찾았다
단조성 근거 양수 배열이라 넓히면 커지고 조이면 작아진다
빅오 두 포인터가 안 되돌아와 O(n)·공간 O(1)

풀이 예시

Step 5의 가변 창문을 응용합니다. 오른쪽 right로 창문을 넓히며 합을 더하다가, 합이 target을 넘으면 왼쪽 left를 조여 되돌립니다. 그러다 합이 정확히 target이 되는 순간의 (left, right)가 답이에요.

Python
# algorithms/exercises_d4.py
def subarray_sum_positive(arr, target):
    left = 0
    window = 0
    for right in range(len(arr)):
        window += arr[right]                     # 오른쪽으로 창문을 넓힌다
        while window > target and left <= right:  # 넘으면 왼쪽을 조인다
            window -= arr[left]
            left += 1
        if window == target:
            return (left, right)
    return None

arr = [1, 2, 3, 7, 5]에서 target = 12를 따라가 봅시다.

텍스트
 subarray_sum_positive([1, 2, 3, 7, 5], 12)

   right=0  [1]              합 1  < 12   넓힌다
   right=1  [1 2]            합 3  < 12   넓힌다
   right=2  [1 2 3]          합 6  < 12   넓힌다
   right=3  [1 2 3 7]        합 13 > 12   왼쪽을 조인다
             [2 3 7]         합 12 = 12   찾았다! (1, 3)

합이 13으로 넘친 순간 왼쪽의 1을 덜어내니 정확히 12가 됩니다. 그 구간의 인덱스 (1, 3)이 답이에요. 만약 끝까지 정확히 target이 되는 구간이 없으면(target = 100) None을 반환합니다. 빅오는 leftright가 각자 오른쪽으로만 최대 n번 움직이니 O(n), 공간은 O(1)입니다.

💡 튜터의 한마디: 이 풀이가 왜 양수 배열에서만 성립하는지가 핵심 채점 포인트예요. 양수라야 "넓히면 합이 커지고 조이면 작아진다"는 단조성이 보장되고, 그래야 "넘었으니 조인다"는 판단이 옳습니다. 만약 음수가 섞이면 넓혔는데 합이 줄 수도 있어 이 창문이 무너져요. 그땐 Step 7의 누적합+해시로 갈아타야 합니다. 유형을 고르기 전에 "이 배열에 음수가 있나"를 먼저 확인하는 습관을 들이세요.


🎯 [과제 3 예시답안] 0과 1의 개수가 같은 최장 구간

채점 포인트

항목 확인
문제 치환 0을 -1로 바꾸면 '개수가 같다' = '구간 합이 0'
누적합의 차 누적합이 같은 두 위치 사이는 합 0 구간
해시로 첫 위치 각 누적합 값이 '처음 나온 위치'만 저장 → 최장 보장
빅오 한 번 훑으니 O(n)·공간 O(n)

풀이 예시

바로 슬라이딩 윈도우로 덤비면 안 됩니다. -1과 +1이 섞여(음수 존재) 단조성이 없으니까요. Step 7에서 배운 대로 누적합+해시로 풉니다. 먼저 0을 -1로 바꾸면 "0과 1의 개수가 같다"가 "그 구간의 합이 0"으로 바뀌어요. 그리고 "구간 합이 0"은 "누적합이 같은 두 위치 사이"와 같은 말입니다.

Python
# algorithms/exercises_d4.py
def longest_equal_binary(arr):
    first = {0: -1}
    running = 0
    best = 0
    for i, x in enumerate(arr):
        running += 1 if x == 1 else -1
        if running in first:
            best = max(best, i - first[running])
        else:
            first[running] = i
    return best

[0, 1, 1, 0, 1, 1, 1, 0]을 따라가 봅시다. 0을 -1로 본 누적합을 굴려요.

텍스트
 longest_equal_binary([0, 1, 1, 0, 1, 1, 1, 0])   — 0을 -1로

   index:    -1   0    1    2    3    4    5    6    7
   값(±1):        -1  +1   +1   -1   +1   +1   +1   -1
   누적합:    0   -1    0    1    0    1    2    3    2
              │         │         │
            처음 0    다시 0    또 0
              └── i=3에서 0 재등장  3-(-1) = 4 (최장) ──┘

누적합이 처음 0이었던 위치는 맨 앞(index -1)이고, index 3에서 다시 0이 됩니다. 그 사이 길이 3 - (-1) = 4가 답이에요([0, 1, 1, 0], 0 두 개·1 두 개). 여기서 "처음 나온 위치만 저장"이 최장을 보장하는 이유가 있어요. 같은 누적합을 나중에 또 만나면, 가장 이른 위치와의 거리가 가장 길기 때문입니다. 그래서 이미 본 값이면 갱신하지 않고(else에서만 저장) 거리만 재요. first = {0: -1}로 시작하는 건 맨 앞부터 시작하는 구간을 놓치지 않기 위한 빈 접두입니다. 빅오는 한 번 훑어 O(n), 누적합을 dict에 담아 O(n) 공간이에요.

💡 튜터의 한마디: "개수가 같다 → 하나를 -1로 → 합이 0 → 누적합의 차가 0"이라는 치환의 사슬이 이 문제의 전부예요. 언뜻 슬라이딩 윈도우 문제처럼 보이지만, 값에 부호가 섞이는 순간 창문이 막히고 누적합+해시가 답이 됩니다. LeetCode 525 "Contiguous Array"가 정확히 이 문제라, "무언가의 개수가 같은 최장 구간"이 보이면 이 치환을 떠올리세요. Step 7의 "합이 K인 구간 세기"와 뼈대가 같고, K가 0인 특수한 경우에 "개수"가 아니라 "길이"를 재는 변형입니다.


🤔 [생각해볼 주제 1] 투 포인터는 왜 정렬을 발판으로 삼을까, 정렬이 없으면 무엇이 무너지나

문제 상황 요약

마주 보는 투 포인터는 "합이 크면 오른쪽을 당기고, 작으면 왼쪽을 민다"는 판단으로 한쪽을 통째로 버립니다. 이 판단이 옳으려면 배열이 정렬돼 있어야 해요. 정렬을 지우면 이 버리기가 왜 성립하지 않을까요?

튜터의 가이드 및 해설

투 포인터가 한쪽을 당길 때, 그건 "지금 버리는 후보들 안에는 답이 없다"는 확신 위에서 이뤄집니다. 정렬된 배열에서 arr[lo] + arr[hi]가 target보다 크다고 합시다. hi를 그대로 두면, lo를 아무리 오른쪽으로 밀어도 arr[lo]는 커지기만 하니 합은 더 커질 뿐이에요. 즉 "hi와 짝지을 수 있는 답은 없다"가 확실하니, hi를 안심하고 한 칸 당길 수 있습니다.

정렬을 지우면 이 확신이 사라져요. 뒤죽박죽인 배열에선 hi 왼쪽에 더 큰 값이 있을 수도, 더 작은 값이 있을 수도 있습니다. "합이 크니 오른쪽을 당긴다"가 옳다는 보장이 없어, 당겨서 버린 절반 안에 진짜 답이 숨어 있을 수 있어요. 그러면 투 포인터는 답을 놓칩니다.

이게 지난 시간 이진 탐색과 정확히 같은 뿌리예요. 이진 탐색도 "가운데보다 크면 왼쪽 절반엔 답이 없다"는 보장 위에서 절반을 버렸죠. 그 보장의 출처가 바로 정렬입니다. 투 포인터든 이진 탐색이든, 정렬이 만들어 주는 건 "한쪽을 버려도 그 안에 답이 없다"는 확신이에요. 그 확신이 O(n²)·O(n)을 O(n)·O(log n)으로 누르는 힘의 원천입니다.

🎯 코테·면접에선 이렇게 나온다

"두 수의 합을 투 포인터로 풀려면 뭐가 전제돼야 하나요?"라는 질문에 "정렬"이라고만 답하면 절반이에요. "정렬돼 있어야 한쪽을 버려도 그 안에 답이 없다는 게 보장되고, 그래서 포인터를 한 방향으로 확신을 갖고 움직일 수 있다"까지 말하면 원리를 꿰뚫은 답이 됩니다. 정렬이 안 된 배열이면 "정렬 후 투 포인터 O(n log n)"이나 "해시로 O(n)"이라는 대안까지 얹으면 완벽해요.

💡 실무에선

"버려도 되는가"를 보장하는 근거가 무엇인지 따지는 습관은 최적화 전반에 쓰입니다. 탐색 공간을 줄이는 모든 기법(가지치기·이분·투 포인터)은 "이 영역엔 답이 없다"는 보장 위에서만 안전해요. 그 보장이 정렬인지, 단조성인지, 문제의 다른 성질인지를 먼저 확인해야 합니다. 보장 없이 영역을 버리면 빠르지만 틀린 답을 내니까요. 빠른 코드일수록 "왜 이걸 안 봐도 되는가"의 근거가 탄탄해야 합니다.


🤔 [생각해볼 주제 2] 슬라이딩 윈도우가 통하는 문제와 안 통하는 문제의 경계는 어디일까

문제 상황 요약

Step 5의 최소 길이 구간은 슬라이딩 윈도우로 풀렸지만(양수 배열), Step 7의 합이 K인 구간 세기는 누적합+해시로 풀었습니다(음수 포함). 무엇이 이 둘을 갈랐을까요?

튜터의 가이드 및 해설

경계는 "단조성"입니다. 슬라이딩 윈도우는 "오른쪽을 넓히면 합이 반드시 커지고, 왼쪽을 조이면 반드시 작아진다"는 성질 위에서 돌아요. 이 성질이 있어야 "합이 target을 넘었으니 더 넓힐 필요 없이 왼쪽을 조인다"는 판단이 옳습니다. 원소가 전부 양수라야 이게 보장돼요.

음수가 섞이면 이 단조성이 깨집니다. 오른쪽으로 창문을 넓혔는데 그 값이 음수라 합이 오히려 줄 수도 있어요. 그러면 "지금 넘었으니 그만 넓혀도 된다"가 틀립니다. 조금 더 넓히면 뒤의 큰 양수를 만나 다시 조건을 만족할 수도 있으니까요. 넓히기를 멈추면 그런 구간을 통째로 놓쳐요. 그래서 음수가 있으면 창문으로는 못 잡습니다.

이때 누적합+해시로 갈아탑니다. 누적합은 단조성에 기대지 않아요. "구간 합 = 두 누적합의 차"라는 등식만 보니, 값의 부호와 무관하게 통합니다. Step 7에서 음수가 섞인 배열의 합이 K인 구간을 셌던 게 바로 이 이유예요. 정리하면 판단 기준은 이렇습니다. "연속 구간 + 전부 양수(또는 단조 보장)"면 슬라이딩 윈도우, "연속 구간인데 음수가 섞임"이면 누적합+해시.

🎯 코테·면접에선 이렇게 나온다

"이 구간 합 문제를 슬라이딩 윈도우로 풀 수 있나요?"의 판단 근거가 단조성이에요. "원소가 모두 양수라 창문을 넓히면 합이 단조 증가하니 슬라이딩 윈도우가 되고, 음수가 있으면 단조성이 깨져 누적합+해시로 간다"고 답하면 유형 분별 감각을 보여 줍니다. 면접관이 좋아하는 지점이 바로 이 "왜 이 도구가 여기선 막히는가"예요.

💡 실무에선

"이 최적화가 성립하는 전제가 무엇인가"를 명시하는 습관으로 이어집니다. 슬라이딩 윈도우가 양수를 전제하듯, 실무의 많은 빠른 알고리즘도 숨은 전제(정렬됨·단조·중복 없음)를 깔고 있어요. 그 전제가 깨지는 입력(음수·중복·비정렬)이 들어오면 조용히 틀린 답을 냅니다. 그래서 빠른 풀이를 쓸 땐 "이게 성립하려면 입력이 어때야 하는가"를 주석이나 검증으로 못 박아 두는 게 안전해요.


🤔 [생각해볼 주제 3] "미리 계산해 두기"의 트레이드오프 — 누적합은 언제 이득이고 언제 낭비일까

문제 상황 요약

누적합은 O(n)을 들여 미리 배열을 만들어 두고, 이후 구간 합 질의를 O(1)에 답합니다. 그런데 구간 합을 딱 한 번만 물어볼 거라면, 미리 만드는 O(n)이 아깝지 않을까요?

튜터의 가이드 및 해설

비용을 "질의 횟수"로 나눠 봐야 합니다. 누적합은 준비에 O(n)을 한 번 쓰고, 이후 질의마다 O(1)이에요. 질의가 Q번이면 전체가 O(n + Q)입니다. 반면 매번 그 구간을 직접 훑으면 질의마다 O(구간 길이), 최악엔 O(n)이라 Q번이면 O(Q·n)이죠.

질의가 딱 한 번이라면(Q=1) 어떨까요? 누적합은 O(n + 1) = O(n), 직접 훑기도 O(n)입니다. 둘 다 O(n)이라 굳이 누적합을 만들 이유가 없어요. 오히려 배열 하나를 더 만드는 공간 O(n)만 손해입니다. 즉 한 번만 물으면 그냥 훑는 게 낫습니다.

반대로 질의가 많아지면(Q가 커지면) 이야기가 뒤집혀요. 누적합의 O(n + Q)는 준비 비용 O(n)이 여러 질의에 나뉘어 갚아지니, Q가 커질수록 질의당 비용이 O(1)로 수렴합니다. 직접 훑는 O(Q·n)과 비교하면 격차가 급격히 벌어져요. 대략 "구간 합을 두 번 이상 물으면 누적합이 이득"이라는 감각이면 충분합니다.

이게 지난 시간 "정렬 후 이진 탐색이 언제 이득인가"와 정확히 같은 결의 사고예요. 거기서도 정렬 비용 O(n log n)을 탐색 횟수로 나눠 갚았죠. 전처리(정렬·누적합)는 "한 번 비싸게 준비하고 여러 번 싸게 쓰는" 투자입니다. 쓰는 횟수가 준비 비용을 넘어서면 이득이에요.

🎯 코테·면접에선 이렇게 나온다

"구간 합을 여러 번 물어보는 문제"가 나오면 누적합이 정석입니다. "질의가 여러 개라 매번 훑으면 O(Q·n)인데, 누적합을 한 번 O(n)에 만들어 두면 질의마다 O(1)이라 O(n + Q)로 준다"고 설명하면 돼요. 반대로 "한 번만 계산하면 되는데 굳이 누적합을 만들 필요가 있나?"라는 함정 질문엔 "한 번이면 이득 없다, 반복 질의라야 전처리 비용이 갚아진다"고 답하면 트레이드오프를 이해한 인상을 줍니다.

💡 실무에선

전처리의 트레이드오프는 캐싱·인덱싱의 본질과 같아요. 데이터베이스 인덱스도 "미리 정렬·구조를 만들어 두고(쓰기 비용) 조회를 빠르게(읽기 이득)"하는 투자입니다. 조회가 잦으면 인덱스가 이득이고, 쓰기만 잦고 조회가 드물면 인덱스 유지 비용만 손해예요. "얼마나 자주 읽느냐로 전처리 비용을 나눠 갚는다"는 이 감각은 누적합에서 데이터베이스 튜닝까지 그대로 이어집니다.

전체 목록 자료구조·알고리즘