DSA Interview Prep · 강의

단조 덱을 사용한 슬라이딩 윈도우 최댓값

인덱스의 감소형 덱을 유지해 원소당 O(1)에 윈도우 최댓값 질의에 답하고, O(n) 시간에 슬라이딩 윈도우 최댓값 문제를 해결합니다.

레슨 3/413개 단계

단조 덱을 사용한 슬라이딩 윈도우 최댓값은(는) CoddyKit의 무료 DSA Interview Prep 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 DSA Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

슬라이딩 윈도 최댓값 문제

슬라이딩 윈도 최댓값 문제(LeetCode 239)는 배열과 윈도 크기 k를 제공합니다. 윈도가 왼쪽에서 오른쪽으로 한 칸씩 이동할 때 각 윈도의 최댓값을 출력하세요. 완전 탐색 방식은 k개의 원소로 이루어진 각 윈도의 최댓값을 O(k)에 계산하므로 전체 O(nk)가 되며, k가 큰 경우에는 너무 느립니다.

단조 덱(양방향 대기열) 해법은 인덱스의 감소 덱을 유지하여 전체 O(n)을 달성합니다. 앞쪽에는 항상 현재 윈도의 최댓값 인덱스가 있으므로 O(1)에 최댓값을 질의할 수 있고, 앞과 뒤 양쪽에서 연산할 수 있습니다.

from collections import deque

# Brute force O(nk) for comparison
def sliding_max_brute(nums, k):
    return [max(nums[i:i+k]) for i in range(len(nums) - k + 1)]

nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3
print('Input:', nums, 'k=', k)
print('Expected: [3, 3, 5, 5, 6, 7]')
print('Brute:   ', sliding_max_brute(nums, k))

단조 덱: 핵심 아이디어

단조 감소 덱에는 값이 아니라 인덱스를 저장하고 유지하세요. 불변식은 nums[deque[0]] >= nums[deque[1]] >= ... >= nums[deque[-1]]입니다. 인덱스 i를 추가하기 전에 다음을 수행합니다.

  • 만료된 인덱스 제거: 앞쪽에서 제거합니다. deque[0] <= i - k이면 해당 인덱스는 윈도를 벗어난 것입니다.
  • 더 작은 인덱스 제거: 뒤쪽에서 제거합니다. nums[deque[-1]] <= nums[i]인 동안 해당 인덱스들은 앞으로 어떤 윈도에서도 최댓값이 될 수 없으므로 버립니다(왼쪽에 있고 더 작기 때문입니다).

이 연산을 마친 뒤 i를 뒤쪽에 넣으세요. 앞쪽에는 항상 현재 윈도의 최댓값이 있습니다.

from collections import deque

def sliding_window_max(nums, k):
    dq = deque()  # stores indices; values are decreasing
    result = []

    for i, n in enumerate(nums):
        # 1. Remove indices outside the current window
        while dq and dq[0] <= i - k:
            dq.popleft()

        # 2. Remove indices with smaller values from the back
        while dq and nums[dq[-1]] <= n:
            dq.pop()

        dq.append(i)

        # 3. Record max when first full window is complete
        if i >= k - 1:
            result.append(nums[dq[0]])   # front = max of current window

    return result

nums = [1, 3, -1, -3, 5, 3, 6, 7]
print(sliding_window_max(nums, 3))  # [3, 3, 5, 5, 6, 7]

덱을 단계별로 추적하기

k=3일 때 [1, 3, -1, -3, 5, 3, 6, 7]을 추적해 보겠습니다.

  • i=0 (1): dq=[0]
  • i=1 (3): pop 0 (1<3), dq=[1]
  • i=2 (-1): -1<3이므로 유지, dq=[1,2]. 윈도 [1,3,-1], 최댓값=nums[1]=3
  • i=3 (-3): -3<-1, dq=[1,2,3]. 앞쪽 확인: 1 > 3-3=0, OK. 윈도 최댓값=3
  • i=4 (5): pop 3,2,1 (모두 더 작음), dq=[4]. 앞쪽 4 > 4-3=1, OK. 최댓값=5
  • i=5 (3): 3<5, dq=[4,5]. 앞쪽 4 > 5-3=2, OK. 최댓값=5
  • i=6 (6): pop 5,4 (둘 다 더 작음), dq=[6]. 최댓값=6
  • i=7 (7): pop 6, dq=[7]. 최댓값=7
from collections import deque

def sliding_window_max_trace(nums, k):
    dq = deque()
    result = []
    for i, n in enumerate(nums):
        while dq and dq[0] <= i - k:
            print(f'  Remove expired index {dq[0]} from front')
            dq.popleft()
        while dq and nums[dq[-1]] <= n:
            print(f'  Remove smaller index {dq[-1]} (val={nums[dq[-1]]}) from back')
            dq.pop()
        dq.append(i)
        print(f'i={i} n={n}: dq={list(dq)} vals={[nums[j] for j in dq]}')
        if i >= k - 1:
            win_max = nums[dq[0]]
            result.append(win_max)
            print(f'  Window {nums[max(0,i-k+1):i+1]} -> max={win_max}')
    return result

nums = [1, 3, -1, -3, 5, 3, 6, 7]
result = sliding_window_max_trace(nums, 3)
print('Result:', result)

각 원소가 최대 한 번씩 추가되고 제거되는 이유

O(n)이 보장되는 이유는 단조 스택에서와 같은 분할 상환 분석에 있습니다. 각 인덱스는 덱에 정확히 한 번 추가되고, 만료될 때 앞쪽에서 제거되거나 더 큰 원소로 대체될 때 뒤쪽에서 제거되며, 최대 한 번만 제거됩니다. 전체 반복문에서 덱 연산은 최대 2n번입니다.

내부 while 반복문은 전체 복잡도를 증가시키지 않습니다. 이 반복문에서 수행되는 pop은 앞서 원소를 추가할 때 이미 비용을 치른 것으로 볼 수 있기 때문입니다. 단조 스택과 같은 논리이지만, 양쪽 끝에서 제거할 수 있는 덱으로 확장한 것입니다.

from collections import deque

def sliding_window_max_instrumented(nums, k):
    dq = deque()
    result = []
    front_pops = back_pops = pushes = 0

    for i, n in enumerate(nums):
        while dq and dq[0] <= i - k:
            dq.popleft(); front_pops += 1
        while dq and nums[dq[-1]] <= n:
            dq.pop(); back_pops += 1
        dq.append(i); pushes += 1
        if i >= k - 1:
            result.append(nums[dq[0]])

    print(f'n={len(nums)}: pushes={pushes}, front_pops={front_pops}, back_pops={back_pops}')
    print(f'Total deque ops = {pushes + front_pops + back_pops} <= 3n = {3*len(nums)}')
    return result

import random; random.seed(0)
nums = [random.randint(-100, 100) for _ in range(20)]
sliding_window_max_instrumented(nums, 5)

슬라이딩 윈도 최솟값

슬라이딩 윈도 최솟값은 대칭적인 대응 개념입니다. 단조 증가 덱을 유지하고, 새 원소가 뒤쪽 원소보다 작으면 뒤쪽에서 pop합니다. 앞쪽에는 항상 현재 윈도의 최솟값이 있습니다. 나머지 단계는 최댓값을 구하는 방식과 동일하며, 비교 방향만 반대로 바꾸면 됩니다.

슬라이딩 윈도 최솟값을 묻는 문제는 더 큰 알고리즘 안의 하위 문제로 자주 등장합니다. 예를 들어 중간 정류장을 k개 거쳐 경로를 따라 물건을 옮기는 최소 비용을 구하려면 DP 배열에 대해 슬라이딩 윈도 최솟값을 계산해야 할 수 있습니다.

from collections import deque

def sliding_window_min(nums, k):
    dq = deque()  # increasing monotonic deque
    result = []

    for i, n in enumerate(nums):
        while dq and dq[0] <= i - k:
            dq.popleft()               # expired
        while dq and nums[dq[-1]] >= n:
            dq.pop()                   # pop larger values from back
        dq.append(i)
        if i >= k - 1:
            result.append(nums[dq[0]])  # front = min
    return result

nums = [1, 3, -1, -3, 5, 3, 6, 7]
print('Max k=3:', sliding_window_min.__name__, '->', end=' ')
print(sliding_window_min(nums, 3))   # [-1, -3, -3, -3, 3, 3]

from collections import deque
def sliding_window_max(nums, k):
    dq = deque(); result = []
    for i, n in enumerate(nums):
        while dq and dq[0] <= i-k: dq.popleft()
        while dq and nums[dq[-1]] <= n: dq.pop()
        dq.append(i)
        if i >= k-1: result.append(nums[dq[0]])
    return result

print('Max k=3:', sliding_window_max(nums, 3))   # [3,3,5,5,6,7]

점프 게임 VI: 단조 덱을 사용한 DP

점프 게임 VI(LeetCode 1696)는 DP와 단조 덱을 결합하는 대표적인 예입니다. 배열과 최대 점프 크기 k가 주어지고, 인덱스 0에서 시작하여 매번 1~k칸 앞으로 점프하면서 도착한 칸의 점수를 더합니다. 전체 점수를 최대로 만드세요. DP 점화식은 dp[i] = nums[i] + max(dp[i-k], ..., dp[i-1])입니다. DP 배열에 슬라이딩 윈도 최댓값을 적용하면 전체 O(n)에 해결할 수 있습니다.

각 칸이 고정 크기 윈도에 있는 이전 칸들의 최댓값에 의존하는 DP 점화식이라는 이 패턴은 자주 등장하며, 항상 단조 덱을 사용해야 합니다.

from collections import deque

def max_result(nums, k):
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    dq = deque([0])   # indices of max dp values in current window

    for i in range(1, n):
        # Remove expired indices
        while dq and dq[0] < i - k:
            dq.popleft()
        # dp[i] = nums[i] + max dp in window [i-k, i-1]
        dp[i] = nums[i] + dp[dq[0]]
        # Maintain decreasing deque on dp values
        while dq and dp[dq[-1]] <= dp[i]:
            dq.pop()
        dq.append(i)

    return dp[n - 1]

print(max_result([1,-1,-2,4,-7,3], 2))    # 7: path 1->4->3
print(max_result([10,-5,-2,4,0,3], 3))    # 17: path 10->4->3
print(max_result([1,-5,-20,4,-1,3,-6,-3], 2))  # 0

슬라이딩 윈도 최댓값: 세그먼트 트리 대안

윈도 크기가 고정된 k가 아니라 가변적인 문제에는 단조 덱을 직접 적용할 수 없습니다. 대신 전처리에 O(n log n)을 사용하고 각 질의에 O(1)이 걸리는 정적 구간 최댓값 질의에는 희소 테이블을 사용하고, O(log n)의 질의 시간으로 동적 갱신을 처리하려면 세그먼트 트리를 사용하세요. 하지만 k가 고정된 슬라이딩 윈도에서는 O(n)의 덱을 능가할 수 없습니다.

면접에서는 윈도 크기가 일정할 때 O(n log n)의 세그먼트 트리보다 O(n)의 단조 덱을 항상 우선하세요. 다음과 같은 장단점도 언급하세요. 덱은 임의의 윈도 크기나 갱신을 처리할 수 없지만, 세그먼트 트리는 처리할 수 있습니다.

# Sparse table for static RMQ (range maximum query)
import math

def build_sparse_table(arr):
    n = len(arr)
    LOG = int(math.log2(n)) + 1 if n else 1
    table = [[0]*n for _ in range(LOG)]
    table[0] = arr[:]
    j = 1
    while (1 << j) <= n:
        for i in range(n - (1 << j) + 1):
            table[j][i] = max(table[j-1][i], table[j-1][i + (1 << (j-1))])
        j += 1
    return table

def query(table, l, r):
    k = int(math.log2(r - l + 1))
    return max(table[k][l], table[k][r - (1 << k) + 1])

arr = [1, 3, -1, -3, 5, 3, 6, 7]
table = build_sparse_table(arr)
k = 3
result = [query(table, i, i + k - 1) for i in range(len(arr) - k + 1)]
print('Sparse table result:', result)  # [3, 3, 5, 5, 6, 7]

원소 하나를 삭제한 후의 가장 긴 1 부분 배열

LeetCode 1493: 이진 배열이 주어질 때, 원소를 정확히 하나 삭제한 후(0 또는 1을 삭제할 수 있음) 1로 이루어진 가장 긴 부분 배열의 길이를 구하세요. 이는 슬라이딩 윈도 문제입니다. 0을 최대 하나 포함하는 윈도를 유지하세요. 윈도에 0이 두 개보다 많아지면 왼쪽에서 줄이세요.

이 문제는 덱이 아니라 가변 크기 슬라이딩 윈도 패턴을 사용합니다. 그러나 이를 최대 윈도 기법과 결합할 수 있습니다. 유효한 모든 윈도를 찾은 뒤 최댓값인 길이가 답이 됩니다. '원소 하나를 삭제한다'는 조건은 1로 이루어진 윈도에 0을 정확히 하나 포함할 수 있게 한다는 뜻입니다.

def longest_subarray(nums):
    left = 0
    zeros = 0
    max_len = 0

    for right in range(len(nums)):
        if nums[right] == 0:
            zeros += 1
        while zeros > 1:
            if nums[left] == 0:
                zeros -= 1
            left += 1
        # Window [left, right] has at most 1 zero
        # After deleting one element, length = right - left (not +1, since we delete one)
        max_len = max(max_len, right - left)

    return max_len

print(longest_subarray([1,1,0,1]))       # 3: delete the 0
print(longest_subarray([0,1,1,1,0,1,1,0,1]))  # 5
print(longest_subarray([1,1,1]))          # 2: must delete one 1

덱, 큐, 스택 비교

면접에서는 각 자료구조를 언제 사용할지 이해하는 것이 핵심입니다.

  • 스택(list): LIFO, 한쪽 끝에서만 접근합니다. DFS, 표현식 구문 분석, 단조 스택 문제에 사용합니다.
  • 큐(appendleft/popleft를 사용하는 덱): FIFO, 한쪽 끝에서 넣고 다른 쪽 끝에서 꺼냅니다. BFS, 작업 일정 관리에 사용합니다.
  • 덱: 양쪽 끝에 O(1)로 접근할 수 있습니다. 만료 처리가 있는 슬라이딩 윈도(앞쪽에서 제거)와 단조 불변식(뒤쪽에서 제거)에 사용합니다. 슬라이딩 윈도 최댓값은 대표적인 덱 문제입니다.

파이썬의 collections.deque는 세 가지 모두에 사용하는 도구입니다. 스택 동작에는 append/pop을 사용하고, 큐나 덱 동작에는 append/popleft 또는 appendleft/pop을 사용하세요.

from collections import deque

# deque as stack
stack = deque()
stack.append(1); stack.append(2); stack.append(3)
print('Stack pop:', stack.pop())  # 3 (LIFO)

# deque as queue
queue = deque()
queue.append(1); queue.append(2); queue.append(3)
print('Queue pop:', queue.popleft())  # 1 (FIFO)

# deque as sliding window with front expiry + back monotonic
dq = deque()
nums = [3, 1, 4, 1, 5, 9, 2, 6]
k = 3
for i, n in enumerate(nums):
    while dq and dq[0] <= i - k: dq.popleft()   # expire front
    while dq and nums[dq[-1]] <= n: dq.pop()     # maintain back
    dq.append(i)
    if i >= k - 1:
        print(f'Window {nums[max(0,i-k+1):i+1]}: max={nums[dq[0]]}')

K 이상 합을 갖는 가장 짧은 부분 배열: 덱과 누적 합

K 이상 합을 갖는 가장 짧은 부분 배열(LeetCode 862)은 누적 합과 단조 덱을 결합하는 고급 문제입니다. 누적 합을 만든 다음 덱을 사용하여 각 오른쪽 끝점에 대해 prefix[right] - prefix[left] >= k를 만족하는 가장 왼쪽 누적 합을 찾습니다. 덱은 누적 합을 증가하는 순서로 유지하고(증가 순서를 유지하기 위해 뒤쪽에서 제거), 유효한 답을 모으기 위해 앞쪽에서 pop합니다.

음수가 포함되므로 단순한 투 포인터를 사용할 수 없고, 덱이 단조 구조와 만료 처리 수단의 역할을 모두 해야 하기 때문에 가장 어려운 슬라이딩 윈도 문제 중 하나입니다.

from collections import deque

def shortest_subarray(nums, k):
    n = len(nums)
    prefix = [0] * (n + 1)
    for i in range(n):
        prefix[i + 1] = prefix[i] + nums[i]

    dq = deque()    # monotonic increasing deque of indices into prefix
    result = float('inf')

    for right in range(n + 1):
        # Pop from front: valid subarrays ending at `right`
        while dq and prefix[right] - prefix[dq[0]] >= k:
            result = min(result, right - dq.popleft())
        # Pop from back: maintain increasing deque
        while dq and prefix[dq[-1]] >= prefix[right]:
            dq.pop()
        dq.append(right)

    return result if result != float('inf') else -1

print(shortest_subarray([1], 1))               # 1
print(shortest_subarray([1, 2], 4))            # -1
print(shortest_subarray([2, -1, 2], 3))        # 3
print(shortest_subarray([84,-37,32,40,95], 167))  # 3

덱 문제를 위한 면접 전략

다음 신호가 보이면 단조 덱 문제인지 확인하세요. (1) 고정 크기의 슬라이딩 윈도에서 최댓값 또는 최솟값이 필요하거나, (2) dp[i] = f(nums[i], max(dp[i-k..i-1])) 형태의 DP 점화식이 필요하거나, (3) 단조 조건을 만족하는 가장 가까운 유효 인덱스가 필요할 때입니다.

면접에서는 덱 해법을 깔끔하게 작성하세요. deque를 가져오고, 두 가지 불변식(앞쪽 만료 처리와 뒤쪽 단조성)을 유지한 뒤 인덱스 k-1부터 결과를 반환하세요. 항상 O(n) 시간 복잡도와 덱에 필요한 O(k) 공간(한 번에 최대 k개의 인덱스 저장)을 언급하고, O(nk) 완전 탐색과 비교하여 개선된 점을 보여 주세요.

from collections import deque

# Clean, interview-ready template
def sliding_window_max_template(nums, k):
    if not nums or k == 0:
        return []

    dq = deque()   # monotonic decreasing, stores indices
    result = []

    for i in range(len(nums)):
        # Invariant 1: remove expired indices (outside window)
        while dq and dq[0] < i - k + 1:
            dq.popleft()

        # Invariant 2: remove indices with smaller values (useless)
        while dq and nums[dq[-1]] < nums[i]:
            dq.pop()

        dq.append(i)

        # Record result once first full window is established
        if i >= k - 1:
            result.append(nums[dq[0]])

    return result

# Complexity: O(n) time, O(k) space
print(sliding_window_max_template([1,3,-1,-3,5,3,6,7], 3))
print(sliding_window_max_template([1], 1))
print(sliding_window_max_template([], 3))

빠른 확인

이 레슨에서 다룬 자료 구조 및 알고리즘 — 코딩 면접 준비 개념에 대한 이해도를 확인해 보세요.

레슨 복습

이 레슨에서 배운 내용은 다음과 같습니다. 단조 감소 덱은 새로 들어온 원소보다 작은 원소를 뒤쪽에서 버리면서 앞쪽에 윈도 최댓값을 유지합니다. 만료된 인덱스는 윈도 경계 밖으로 벗어나면 앞쪽에서 제거됩니다. 또한 각 인덱스는 최대 한 번 추가되고 제거되므로 전체 시간 복잡도는 O(n)이고 덱 공간은 O(k)입니다. 다음에는 단조 스택과 투 포인터 방식을 모두 사용해 빗물을 가두는 문제를 해결합니다.

무료로 시작

AI 튜터와 함께 Python을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
30
레슨
120

자주 묻는 질문

“단조 덱을 사용한 슬라이딩 윈도우 최댓값” 강의는 무료인가요?

네 — “단조 덱을 사용한 슬라이딩 윈도우 최댓값” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DSA Interview Prep 강의 전체를 잠금 해제할 수 있습니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

“단조 덱을 사용한 슬라이딩 윈도우 최댓값”에서 뭘 배우나요?

인덱스의 감소형 덱을 유지해 원소당 O(1)에 윈도우 최댓값 질의에 답하고, O(n) 시간에 슬라이딩 윈도우 최댓값 문제를 해결합니다. 브라우저에서 직접 실행하는 실습 코드로 DSA Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

DSA Interview Prep을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 DSA Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.

“단조 덱을 사용한 슬라이딩 윈도우 최댓값” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 DSA Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 DSA Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 단조 스택: 증가형과 감소형
  2. 히스토그램에서 가장 큰 직사각형
  3. 단조 덱을 사용한 슬라이딩 윈도우 최댓값
  4. 빗물 받기: 스택과 투 포인터
← DSA Interview Prep(으)로 돌아가기