템플릿 엔진이 {{#if}}와 {{/if}}의 짝을 확인한다. 설정 파일 검증기는 괄호와 대괄호가 제대로 닫혔는지 본다. 편집기는 Ctrl+Z를 누를 때마다 가장 최근 편집부터 되돌린다. 주가 차트에는 "이날 이후 처음으로 더 높았던 날"을 표시한다.
네 장면 모두 가장 최근에 넣은 것을 먼저 꺼내는 구조로 풀린다. 이 구조가 스택(stack)이다. 이 장은 스택으로 괄호 짝을 검사하고, 스택 안의 값을 한 방향으로 정렬된 상태로 유지하는 단조 스택(monotonic stack)으로 "다음 더 큰 값"을 한 번 훑기로 구한다. 개념 정리는 스택 노트에 있다.
스택: 마지막에 넣은 것을 먼저
스택은 한쪽 끝(맨 위)에서만 넣고 꺼낸다. 마지막에 넣은 것이 먼저 나오는 이 규칙을 LIFO(last-in, first-out)라고 한다. 따로 자료 구조를 만들 필요 없이 배열의 끝을 맨 위로 쓰면 된다.
JavaScript pop()은 배열의 마지막 원소를 빼서 돌려주고, 빈 배열이면 undefined를 돌려준다(MDN: Array.prototype.pop). Python 튜토리얼도 리스트를 스택으로 쓸 때 append()로 넣고 인덱스 없는 pop()으로 꺼내라고 안내한다. 빈 리스트에서 pop()을 부르면 IndexError가 난다(Python 튜토리얼: 리스트를 스택으로 쓰기). 끝에서 넣고 빼는 것은 평균 O(1)이다(Python Wiki: TimeComplexity). 반대로 맨 앞에서 빼면 나머지 원소를 모두 한 칸씩 옮겨야 해서 느리다.
빈 스택에서 꺼낼 때 한 언어는 조용히 undefined를, 다른 언어는 예외를 낸다는 차이를 기억해 두자. 다음 절의 괄호 검사기에서 바로 쓰인다.
실행 취소(undo)도 스택이다. 편집할 때마다 이전 상태를 undo 스택에 넣고, Ctrl+Z를 누르면 맨 위를 꺼내 되돌린다. 되돌린 상태를 다시 살리는 다시 실행(redo)은 두 번째 스택에 쌓는다. 새로 편집하면 다시 실행할 것이 없어지므로 redo 스택을 비운다.
괄호 검사기: 닫을 때 맨 위를 확인한다
여는 괄호는 스택에 쌓는다. 닫는 괄호를 만나면 맨 위를 꺼내 짝인지 본다. 가장 최근에 연 괄호가 가장 먼저 닫혀야 한다는 규칙이 스택의 LIFO와 똑같다. 다 읽은 뒤 스택이 비어 있어야 모든 괄호가 닫힌 것이다.
예측먼저 답해 보세요
'())'의 괄호 짝을 스택으로 검사한다. 셋째 문자 )를 만났을 때 스택은 어떤 상태일까?
선택지와 해설 보기
실험 괄호 짝 검사기
실험괄호 짝 검사기
여는 괄호는 스택에 쌓고, 닫는 괄호를 만나면 맨 위를 꺼내 짝인지 봅니다. 아래 스택 줄은 왼쪽이 바닥, 오른쪽 끝이 맨 위입니다.
언어
입력
▶ 다음에 실행할 줄(디버거가 멈춘 줄)
방금 실행한 줄
변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
카드 안에서 ← → 키로도 넘길 수 있습니다.
1const text = 'f(a[0], {b})'2const pairs: Record<string, string> = { ')': '(', ']': '[', '}': '{' }3const stack: string[] = []4let result = ''5for (let i = 0; i < text.length; i++) {6const ch = text[i]7if ('([{'.includes(ch)) {8 stack.push(ch)9 } elseif (')]}'.includes(ch)) {10const top = stack.length > 0 ? stack.pop() : ''11if (top !== pairs[ch]) {12 result = `${i}번 '${ch}'의 짝이 없다`13 break14 }15 }16}17if (result === '' && stack.length > 0) result = `닫히지 않은 괄호 ${stack.length}개`18if (result === '') result = '짝이 맞다'19console.log(result)
'())'에서 셋째 문자를 만나면 스택이 비어 있다. 검사기는 빈 스택에서 꺼낼 때 ''(빈 문자열)을 꺼낸 것으로 쳐서 짝이 아니라고 판단한다. 두 언어 모두 스택이 비었는지(stack.length > 0, len(stack) > 0)를 먼저 확인하는 이유는 Python에서는 빈 리스트의 pop()이 예외를 내기 때문이다. '((a)'는 끝까지 읽는 동안 어긋난 짝이 없지만 다 읽은 뒤 스택에 ( 하나가 남는다. 이 마지막 확인을 빠뜨리면 닫히지 않은 괄호를 통과시킨다.
문자 하나마다 push나 pop이 많아야 한 번이라 전체 O(n)이다. ([)]처럼 순서가 엇갈린 경우도 같은 코드가 잡는다. 셋째 문자 )가 꺼내는 맨 위는 [이기 때문이다.
단조 스택: 다음 더 큰 값
날짜순 가격 prices가 있다. 날마다 그 뒤로 처음 나오는 더 높은 가격(없으면 -1)을 구한다. 날마다 오른쪽을 모두 훑으면 O(n²)이다.
한 번 훑기로 바꾸는 방법은 이렇다. 아직 답을 못 찾은 날의 위치를 스택에 쌓아 둔다. 새 가격이 스택 맨 위 날의 가격보다 큰 동안 맨 위를 계속 꺼내며 "네 답은 지금 가격이다"라고 적는다. 예를 들어 스택에 쌓인 날들의 가격이 [7, 5, 4](맨 위 날의 가격이 4)일 때 가격 6인 날이 오면 가격 4, 5인 날을 꺼내고 7인 날에서 멈춘다. 그런 다음 지금 날을 쌓는다. 꺼낸 위치는 다시 넣지 않는다. 이렇게 하면 스택 안의 가격은 바닥에서 맨 위로 갈수록 작아지거나 같은 순서(단조 비증가, monotonic non-increasing)를 늘 유지한다. 같은 가격은 꺼내지 않고 함께 쌓인다. 맨 위보다 큰 값이 오면 그 값보다 작은 것은 모두 꺼내지기 때문이다.
예측먼저 답해 보세요
prices = [2, 7, 3, 5, 4, 6, 8](7개)를 단조 스택으로 처리한다. while 안의 pop은 모두 몇 번 일어날까?
선택지와 해설 보기
실험 단조 스택으로 다음 더 큰 값
실험단조 스택으로 다음 더 큰 값
스택에는 아직 답을 못 찾은 위치(인덱스)를 쌓습니다. 새 값이 맨 위 위치의 값보다 크면, 그 위치의 답이 새 값입니다.
▶ 2 const answer: number[] = new Array(prices.length).fill(-1)
해볼 것
[2, 7, 3, 5, 4, 6, 8]을 끝까지 실행해 pop이 몇 번 일어나는지 세어 본다
'내리막' 입력에서 스택이 계속 쌓이기만 하는 것을 본다
출력은 7, 8, 5, 6, 6, 8, -1이고, 마지막 8을 뺀 여섯 날이 한 번씩 꺼내져 pop은 모두 6번이다. 6이 들어올 때 꺼내기가 두 번 일어난다. 맨 위 4(4번 날)를 꺼내 답을 6으로 적고, 그 아래 5(3번 날)도 6보다 작으니 꺼내 답을 6으로 적는다. 그다음 맨 위 7은 6보다 커서 멈춘다. 가격이 내리막이면 새 가격이 늘 맨 위보다 작아서 꺼내기가 한 번도 일어나지 않고, 모든 날이 스택에 남아 답이 -1이다.
while 안의 pop이 있는데 왜 O(n)일까
for 안에 while이 있으면 O(n²)처럼 보인다. 하지만 세는 단위를 바꾸면 다르다. 위치 하나는 스택에 한 번 들어가고, 많아야 한 번 나온다. 꺼낸 위치를 다시 넣는 코드는 없다. 그래서 전체 실행 동안 push는 정확히 n번, pop은 많아야 n번이다. while 조건 검사도 pop 한 번마다 한 번, 그리고 for 한 바퀴마다 멈추는 한 번이라 많아야 2n번이다. 모두 더해 O(n)이다.
for 한 바퀴가 아니라 전체 실행을 통틀어 세는 이 방식은 0장 보너스의 (amortized analysis, 여러 번의 연산에 비용을 나눠 보는 분석)과 같은 생각이다. 어떤 날은 while이 여러 번 돌지만, 그만큼 앞에서 쌓아 둔 위치가 줄어든 것이다.
단조 스택은 "다음 더 큰 값"만이 아니라 "이전 더 작은 값", "이 막대가 왼쪽·오른쪽으로 얼마나 펼쳐질 수 있나" 같은 질문에도 쓴다. 비교 방향(<인지 >인지)과 스택의 정렬 방향만 바꾸면 된다. 장 끝 보너스 문제가 그 예다.
실무 코드에서 찾아보기
함정 찾기 AI가 짠 괄호 검사기와 다음 더 큰 값, 어디가 틀렸을까?
함정 찾기AI가 짠 괄호 검사기와 다음 더 큰 값, 어디가 틀렸을까?
내가 AI에게 한 요청 “문자열의 괄호 짝이 맞는지 보는 함수와, 가격 배열에서 각 값의 오른쪽에 처음 나오는 더 큰 값(없으면 -1)을 구하는 함수를 짜 줘.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.
정리하면 틀린 줄은 셋이다. 끝난 뒤 스택이 비었는지 확인하지 않아 '(('가 통과하고, 꺼내기를 while 대신 if로 써서 맨 위 하나만 답을 얻고, 스택에 위치 대신 값을 넣어 엉뚱한 칸에 답을 적는다.
확인 문제 장을 덮기 전에 떠올려 보기
확인 문제장을 덮기 전에 떠올려 보기
1. 빈 스택에 1, 2, 3을 차례로 넣고 두 번 꺼냈다. 남은 원소는?
2. 괄호 검사기에 '([)]'를 넣으면 어느 문자에서 실패할까? (0번부터 센다)
3. 단조 스택으로 원소 n개의 "다음 더 큰 값"을 구할 때 전체 시간 복잡도(time complexity)는?
더 풀어 보기(선택) · 히스토그램에서 가장 큰 직사각형
보너스 문제히스토그램에서 가장 큰 직사각형
폭 1인 막대들의 높이 배열 heights가 있다. 막대들이 이루는 히스토그램 안에 그릴 수 있는 직사각형 중 가장 큰 넓이를 O(n)에 구해 보자. 직사각형의 높이는 그 폭에 걸친 막대 중 가장 낮은 막대의 높이를 넘을 수 없다.