이 문서는 알고리즘과 자료구조를 혼자 읽어 학사 과정 수준까지 도달하도록 정리한 자습서다. 표준 과목의 내용을 제대로 세우되, 거기서 끝내지 않고 실무에서 실제로 만나는 것들까지 이어 간다. 블룸 필터와 HyperLogLog 같은 확률적 자료구조, 지오해시와 S2 같은 공간 색인, TOTP·SCRAM·JWT 같은 인증 프로토콜이 그것이다.
이 과목의 목표는 코드를 외우는 것이 아니다. 주어진 상황에서 무엇을 고를지 판단하고, 그 선택의 대가를 숫자로 말할 수 있는 것이다. 그래서 이 문서의 예제에는 "복잡도를 구하라" 만큼이나 "이 상황에서 무엇을 쓰겠는가" 가 많다.
프로그래밍 경험은 있으면 좋지만 특정 언어를 요구하지 않는다. 코드는 파이썬 꼴의 유사코드로 적었고, 실행 가능한 완성 코드가 아니라 읽으라고 쓴 것이다. 코드 앞에는 항상 말로 된 절차가 먼저 나온다.
이 문서는 스스로 완결되어 있다. 점근 표기, 그래프 용어, 자료구조 용어, 병행성 용어, 모듈러 산술을 전부 필요한 자리에서 도입한다. 다른 문서를 먼저 읽고 올 필요가 없다.
이 문서가 지키려는 약속
어떤 기호도 정의하기 전에 쓰지 않는다. 처음 나오는 자리에서 이름·뜻·읽는 법을 함께 밝힌다.
코드를 읽어야만 알고리즘을 알 수 있게 쓰지 않는다. 말로 된 절차가 먼저이고 코드는 확인용이다.
복잡도는 값만 적지 않는다. 왜 그 값인지를 한 문장으로 함께 적는다.
자료구조는 그림으로 그린다. 포인터가 어디를 가리키는지, 배열이 메모리에 어떻게 놓이는지는 말로 전달되지 않는다.
각 장은 "언제 이것을 고르는가"로 닫는다. 쓸 자리를 모르는 도구는 익혀도 남지 않는다.
이 문서의 짜임
열네 개 장이 다섯 덩어리로 묶인다. 어느 덩어리를 읽고 있는지 알면 각 장이 왜 거기 있는지가 보인다.
부
장
무엇을 하는가
1부 재는 법
3
무엇을 좋다고 할 것인지 자를 먼저 만든다. 뒤의 모든 판단이 이 자를 쓴다
2부 자료구조
4~6
데이터를 어떻게 놓느냐가 무엇을 빨리 할 수 있는지를 정한다
3부 설계 기법과 그 적용
7~12
문제를 푸는 세 가지 큰 틀과, 그 틀이 실제로 무엇을 푸는지
4부 한계
13
아무리 잘 설계해도 안 되는 문제가 있다는 것과, 그때 무엇을 하는가
5부 실무에서 만나는 것들
14~16
정확함을 포기해 공간을 얻기, 동시에 돌 때 깨지는 것, 그리고 암호
3부의 배열에 규칙이 있다. 기법에 먼저 이름을 붙이고, 바로 다음 장에서 그 기법이 실제로 무엇을 푸는지 본다.
7장 분할정복 → 8장 정렬. 병합 정렬과 퀵 정렬은 분할정복의 대표 사례다. 기법을 먼저 세우면 정렬 장에서 "왜 𝑛log𝑛 인가"를 재귀식 하나로 답할 수 있다.
9장 그래프는 다음 두 기법이 함께 쓰일 무대다. 정점과 간선이라는 말을 여기서 정한다.
10장 탐욕 · 11장 동적 계획법 → 12장 최단경로와 최소신장트리. 12장이 다루는 알고리즘 일곱 개 가운데 여섯이 앞 두 장 중 하나다. 다익스트라·크루스칼·프림은 탐욕이고, 벨만-포드·플로이드-워셜·DAG 최단경로는 동적 계획법이다. 남는 하나인 A* 만 탐욕에 어림값을 얹은 제3의 것이다. 기법을 먼저 알고 보면 그 일곱이 따로 외울 목록이 아니라 두 틀의 변주로 읽힌다.
10장과 11장은 그 안에서도 하나의 실로 묶여 있다. 10장이 거스름돈 문제로 탐욕이 실패하는 것을 보이고, 11장이 같은 문제를 해결한다. 두 장을 붙여 읽어야 "언제 탐욕이고 언제 동적 계획법인가"가 남는다.
권장 순서와 장별 난이도
장
주제
난이도
예상 시간
앞에 필요한 장
3
알고리즘을 어떻게 재는가
★★
5시간
-
4
배열, 연결 리스트, 스택, 큐
★
4시간
3
5
해시 테이블
★★
4시간
3, 4
6
트리와 힙
★★★
6시간
3, 4
7
분할정복
★★
4시간
3
8
정렬과 선택
★★★
6시간
3, 4, 6, 7
9
그래프 — 표현과 탐색
★★
5시간
3, 4, 6
10
탐욕 알고리즘
★★
4시간
6, 8
11
동적 계획법
★★★★
7시간
3, 9, 10
12
최단경로와 최소신장트리
★★★
6시간
3, 6, 9, 10, 11
13
계산 복잡도 — P와 NP
★★★
5시간
3, 7, 9, 10, 11, 12
14
확률적 자료구조와 공간 색인
★★★
5시간
3, 5, 6, 13
15
병행성과 동기화
★★★
5시간
4, 9, 10
16
암호와 보안 프로토콜
★★★
6시간
5, 7, 13, 15
마지막 열은 각 장 첫머리의 "앞에서 가져오는 것"을 그대로 옮긴 것이다. 그 장을 안 읽고 들어가면 첫 문단에서 모르는 말을 만난다는 뜻이므로, 건너뛰어 읽을 때는 이 열을 먼저 보라.
총 72시간 남짓이다. 하루 2시간이면 다섯 주 정도의 분량이다.
가장 걸리는 곳을 미리 알려 둔다. 8장의 비교 정렬 하한 증명과 11장의 동적 계획법이다. 앞의 것은 "모든 비교 정렬에 대해"라는 논법이 낯설어서 걸리고, 뒤의 것은 상태와 전이를 스스로 설계해야 해서 걸린다. 11장은 문제를 여러 개 풀어 보는 것 말고는 방법이 없다.
14 · 15 · 16장은 서로 다른 주제를 다루므로 관심 있는 것부터 골라 읽어도 된다. 셋 사이의 의존은 16장이 15장의 TOCTOU 를 쓴다는 것 하나뿐이다. 다만 그것이 앞의 장들까지 건너뛸 수 있다는 뜻은 아니다. 14장은 5장의 해시와 13장의 "무엇을 포기할 것인가" 위에 서 있고, 15장은 4장의 스택·큐와 9장의 순환 탐지를 쓰며, 16장은 첫 문단부터 13장의 P와 NP 와 15장의 TOCTOU 를 건다.
이 문서의 표기 규약
입력 크기는 𝑛 이다. 그래프에서는 정점 수 𝑉 와 간선 수 𝐸 를 쓴다.
복잡도는 𝑂(상한), Ω(하한), Θ(둘 다)로 쓴다. 3장에서 정의한다. 관습에 따라 𝑇(𝑛)=𝑂(𝑛2) 처럼 등호로 적지만 실제로는 소속이다.
로그는 밑을 생략하면 밑이 2다. 복잡도 안에서는 밑이 상수배 차이뿐이라 밑을 적지 않는 관습이 있다.
배열의 첨자는 0부터 센다.
코드는 파이썬 꼴 유사코드다. 예외 처리와 경계값 검사는 뜻을 흐리지 않는 선에서 생략했다.
알고리즘(algorithm)이란 입력을 받아 유한한 단계 안에 답을 내놓는, 애매한 곳이 없는 절차다. 요리법과 같다. 다만 "소금 적당히" 같은 말이 없어야 한다. 그 절차를 특정 언어로 적어 기계가 실행할 수 있게 한 것이 프로그램이다.
알고리즘 둘 중 어느 쪽이 나은지 재는 가장 소박한 방법은 돌려 보고 시계를 보는 것이다. 그런데 그 값은 알고리즘의 성질이 아니다.
기계가 다르면 다르다. 새 노트북에서 2초 걸린 것이 낡은 기계에서 20초 걸린다.
언어와 컴파일러가 다르면 다르다. 같은 절차를 두 언어로 적으면 수십 배 차이가 나기도 한다.
같은 기계에서도 그때그때 다르다. 다른 프로그램이 함께 돌고 있으면 느려진다.
입력에 따라 다르다. 원소 10개를 정렬한 시간으로 원소 1000만 개를 정렬한 시간을 짐작할 수 없다.
우리가 알고 싶은 것은 "이 기계에서 지금 몇 초"가 아니라 입력이 커질 때 일거리가 어떤 모양으로 늘어나는가다. 그것은 기계를 바꿔도 변하지 않는다. 그래서 시계 대신 연산의 개수를 센다.
무엇을 입력 크기로 잡는가
세려면 기준이 필요하다. 입력 크기(input size)를 𝑛 이라 적고, 연산 횟수를 𝑛 에 대한 식으로 적는 것이 목표다. 무엇을 𝑛 으로 잡을지는 문제마다 다르고, 잘못 잡으면 답이 통째로 틀린다.
문제
자연스러운 입력 크기 n
수 목록을 정렬한다
원소의 개수
문자열에서 낱말을 찾는다
문자열의 길이
지도에서 길을 찾는다
교차로 수와 길 수 두 개
어떤 수가 소수인지 판정한다
그 수 자체가 아니라 그 수를 적는 데 드는 자릿수
마지막 줄이 함정이다. 이유는 뒤의 예제에서 본다.
무엇을 세는가
모든 연산을 다 셀 필요는 없다. 가장 자주 일어나는 연산 하나를 골라 그것만 센다. 나머지는 그것에 비례하거나 그보다 적기 때문이다. 정렬에서는 원소끼리의 비교, 탐색에서는 원소 검사, 행렬 계산에서는 곱셈이 보통 그 역할을 한다. 이렇게 고른 것을 기본 연산(basic operation)이라 한다.
예제 1
크기 𝑛 인 배열에서 어떤 값을 앞에서부터 하나씩 비교하며 찾는다. 기본 연산을 정하고 그 횟수를 𝑛 의 식으로 적어라.
풀이 보기
묻는 것은 "몇 번 일하는가"를 𝑛 의 식으로 적는 것이다.
이 절차에서 반복되는 일은 배열의 한 칸을 찾는 값과 견주어 보는 것 하나뿐이다. 이것을 기본 연산으로 삼는다.
찾는 값이 어디 있느냐에 따라 횟수가 달라진다. 첫 칸에 있으면 1번, 마지막 칸에 있으면 𝑛 번, 아예 없으면 𝑛 번이다.
하나의 수로 적을 수 없으니 가장 나쁜 경우를 적는다. 𝑇(𝑛)=𝑛.
값을 못 찾았을 때도 𝑛 번인 것이 중요하다. 없는 것을 확인하려면 전부 봐야 하기 때문이다. 뒤에서 이 사실이 해시 테이블과 이진 탐색의 존재 이유가 된다.
예제 2
다음 절차의 기본 연산 횟수를 𝑛 의 식으로 적어라.
↓ python
총합 = 0
for i in range(n):
for j in range(i, n):
총합 = 총합 + a[i] * a[j]
풀이 보기
기본 연산은 안쪽 줄의 곱셈이다. 몇 번 도는지 세면 된다.
바깥 반복에서 𝑖 를 하나 고정하면 안쪽은 𝑗=𝑖 부터 𝑗=𝑛−1 까지 도므로 𝑛−𝑖 번이다.
전부 더한다. 시그마로 적고 𝑘=𝑛−𝑖 로 바꾸면 1부터 𝑛 까지의 합이 된다.
𝑇(𝑛)=∑𝑛−1𝑖=0(𝑛−𝑖)=∑𝑛𝑘=1𝑘=𝑛(𝑛+1)2=𝑛22+𝑛2
𝑛=1000 이면 약 50만 번이다. 값을 검산해 보자. 이중 반복이 전부 돌면 𝑛2=106 번인데, 안쪽이 절반씩만 도니 그 절반인 것이 맞다.
여기서 눈여겨볼 것은 𝑛2/2 의 계수 1/2 이 곧 사라진다는 점이다. 다음 절에서 왜 버려도 되는지 본다.
예제 3
어떤 자연수 𝑁 이 소수인지 2 부터 √𝑁 까지 모두 나눠 보아 판정한다. 이 절차는 "빠른" 알고리즘인가?
풀이 보기
나눗셈 횟수는 약 √𝑁 번이다. 𝑁 을 입력 크기로 보면 √𝑁 은 𝑁 보다 훨씬 작으니 아주 빨라 보인다.
그런데 입력 크기는 𝑁 이 아니다. 우리가 기계에 건네는 것은 𝑁 개의 무엇이 아니라 𝑁 을 적은 숫자 하나다. 이진법으로 적으면 자릿수는 약 𝑛=log2𝑁 개다. 이것이 입력의 크기다.
그러면 𝑁=2𝑛 이므로 나눗셈 횟수는
√𝑁=√2𝑛=2𝑛/2
입력 자릿수 𝑛 에 대해 지수적으로 늘어난다. 자릿수가 20개만 늘어도 일거리는 1000배가 된다. 이 절차는 빠르지 않다.
이런 것을 유사 다항 시간이라 부른다. 값에 대해서는 다항식이지만 입력 길이에 대해서는 지수인 알고리즘이다. 암호가 여기에 기대어 서 있다.
증가율이 전부다
연산 횟수를 𝑇(𝑛)=3𝑛2+5𝑛+7 처럼 정확히 구했다고 하자. 𝑛 이 커지면 이 셋 중 3𝑛2 만 의미가 있다. 𝑛=1000 이면 첫 항이 300만이고 나머지는 5007이다. 0.2%도 되지 않는다.
계수 3도 곧 버린다. 기계를 세 배 빠른 것으로 바꾸면 그 3이 1이 되기 때문이다. 기계를 바꿔도 변하지 않는 것만 남기려는 것이 이 버리기의 목적이다. 남는 것은 𝑛2, 곧 증가율(growth rate)뿐이다.
<식이 아니라 모양이 다르다. 2ⁿ 과 n² 은 화면을 금방 벗어나는 반면 log n 은 n 이 20 이 되도록 4 를 겨우 넘는다. 왼쪽 끝에서는 어느 것이 위인지 뒤바뀔 수도 있다는 점도 함께 보라>[원본 보기]
얼마나 다른지는 숫자로 보아야 감이 온다. 𝑛=106 (백만) 개의 자료를 다룬다고 하자. 1초에 10억 번(109) 연산하는 기계를 가정한다.
증가율
부르는 이름
𝑛=106 일 때 연산 횟수
그 기계에서 걸리는 시간
log2𝑛
로그
약 20
약 20 ns
√𝑛
제곱근
1000
약 1 μs
𝑛
선형
106
약 1 ms
𝑛log2𝑛
선형 로그
약 2×107
약 20 ms
𝑛2
이차
1012
약 1000 s (17분쯤)
𝑛3
삼차
1018
약 30년
2𝑛
지수
자릿수가 30만 자리를 넘는 수
말할 수 없다
표에서 읽어야 할 것은 개별 숫자가 아니라 줄과 줄 사이의 벽이다. 𝑛log𝑛 까지는 백만 개를 눈 깜짝할 새에 처리하고, 𝑛2 부터는 커피를 마시러 가야 하며, 𝑛3 부터는 살아서 답을 보지 못한다. 이 벽을 넘는 것이 뒤 장들에서 하는 일 전부다.
2𝑛 은 백만은커녕 훨씬 작은 수에서 이미 무너진다. 같은 기계에서 𝑛=40 이면 약 18분, 𝑛=60 이면 약 37년이다. 스무 개 늘렸을 뿐인데 그렇다.
예제 4
알고리즘 A 는 100𝑛 번, 알고리즘 B 는 𝑛2 번 연산한다. 어느 쪽을 쓰겠는가?
풀이 보기
묻는 것은 "증가율이 낮으면 항상 빠른가"다. 답은 아니오다. 두 식이 같아지는 자리를 찾자.
100𝑛=𝑛2⟹𝑛=100
𝑛<100 이면 B 가 빠르고, 𝑛>100 이면 A 가 빠르다. 𝑛=10 에서는 A 가 1000번, B 가 100번이니 B 가 열 배 빠르다.
그러니 다루는 𝑛 이 항상 작다면 B 를 쓰는 것이 옳다. 실제로 라이브러리의 정렬 함수들은 원소가 몇십 개 아래면 증가율이 나쁜 삽입 정렬로 갈아탄다.
그럼에도 점근적 성질을 먼저 보는 이유는, 자료는 대체로 늘어나기만 하고 줄지 않기 때문이다. 오늘 100개인 것이 내년에 100만 개가 되면 A 는 1억 번, B 는 1조 번이 된다.
점근 표기 — 위와 아래로 누르기
이제 "3𝑛2+5𝑛+7 은 본질적으로 𝑛2 이다"라는 말을 기호로 정확히 적을 차례다. 이 절의 세 기호는 문서 전체의 공용어다.
빅오 — 위로 눌린다
양의 실수에서 정의된 함수 𝑓(𝑛) 과 𝑔(𝑛) 에 대하여
𝑂(𝑔(𝑛))={𝑓(𝑛)∣∃𝑎,𝑏>0,∀𝑛≥𝑎,0≤𝑓(𝑛)≤𝑏𝑔(𝑛)}
말로 옮기면 이렇다. 어떤 상수 𝑎 를 지나고 나서부터는, 𝑓 가 𝑔 를 상수배 한 것 아래로 눌린다. 상수 두 개(𝑎 와 𝑏)를 마음대로 고를 수 있다는 것이 핵심이다.
𝑎 를 고를 수 있으니 작은 𝑛 에서 무슨 일이 벌어지든 상관없다.𝑏 를 고를 수 있으니 상수배 차이도 상관없다. 남는 것은 모양뿐이다.
<회색으로 칠한 오른쪽 영역 안에서만 조건을 따진다. 왼쪽 끝에서 f 가 위에 있어도 괜찮다. 세 표기의 차이는 어느 쪽으로 누르는가 하나뿐이다>[원본 보기]
기호는 원래 집합이므로 𝑓(𝑛)∈𝑂(𝑔(𝑛)) 이라고 적는 것이 옳다. 그런데 관습적으로 𝑓(𝑛)=𝑂(𝑔(𝑛)) 로 쓰는 책이 더 많다. 이 문서도 그 관습을 따르되, 등호가 아니라 소속이라는 것을 기억하라. 𝑂(𝑛)=𝑂(𝑛2) 같은 식은 성립하지 않는다.
예제 5
𝑓(𝑛)=4𝑛+1 이 𝑂(𝑛) 에 속함을 정의대로 보여라.
풀이 보기
정의가 요구하는 것은 상수 𝑎 와 𝑏 를 실제로 하나 찾아 내미는 것이다. 모든 𝑎, 𝑏 에 대해 보일 필요가 없다. 하나면 된다.
𝑛≥1 이면 1≤𝑛 이므로
4𝑛+1≤4𝑛+𝑛=5𝑛
그러니 𝑎=1, 𝑏=5 로 두면 𝑛≥1 인 모든 𝑛 에서 0≤4𝑛+1≤5𝑛 이 성립한다. 정의를 만족하므로 4𝑛+1∈𝑂(𝑛) 이다.
𝑏 를 다르게 잡아도 된다. 𝑎=2 로 두면 4𝑛+1≤4.5𝑛 이니 𝑏=4.5 로도 된다. 상수를 어떻게 잡든 결론은 같다는 것이 이 표기의 요점이다.
같은 𝑓 에 대해 𝑂(𝑛2), 𝑂(2𝑛) 도 모두 참이다. 위로 누르기만 하면 되니 더 큰 것으로 눌러도 거짓이 아니다. 다만 쓸모 있는 것은 가장 빡빡한 상한이므로 𝑂(𝑛) 이라 적는다.
오메가와 세타 — 아래로, 그리고 양쪽으로
위로 누르는 것이 있으면 아래로 누르는 것도 있다.
Ω(𝑔(𝑛))={𝑓(𝑛)∣∃𝑎,𝑏>0,∀𝑛≥𝑎,0≤𝑏𝑔(𝑛)≤𝑓(𝑛)}
어떤 자리를 지나고 나서부터 𝑓 가 𝑔 의 상수배 위에 있다는 뜻이다. 위아래 둘 다 되면 그것이 세타다.
Θ(𝑔(𝑛))=𝑂(𝑔(𝑛))∩Ω(𝑔(𝑛))
𝑓∈Θ(𝑔) 라는 것은 𝑓 와 𝑔 가 상수배를 무시하면 같은 속도로 자란다는 뜻이다. 가장 강하고 가장 정확한 주장이다.
예제 6
𝑓(𝑛)=4𝑛3+2𝑛+1 에 대해 𝑓∈Θ(𝑛3) 임을 보여라.
풀이 보기
두 방향을 각각 보이면 된다.
아래로.𝑛≥1 이면 2𝑛+1>0 이므로 4𝑛3+2𝑛+1≥4𝑛3. 𝑎=1, 𝑏=4 로 Ω(𝑛3) 이 나온다.
위로.𝑛≥1 이면 2𝑛≤2𝑛3 이고 1≤𝑛3 이므로
4𝑛3+2𝑛+1≤4𝑛3+2𝑛3+𝑛3=7𝑛3
𝑎=1, 𝑏=7 로 𝑂(𝑛3) 이 나온다. 둘 다 되었으므로 𝑓∈Θ(𝑛3) 이다.
이 풀이의 요령은 낮은 차수 항을 전부 최고차 항으로 바꿔치기하는 것이다. 𝑛≥1 이면 𝑛𝑘≤𝑛𝑚 (𝑘≤𝑚)이므로 언제나 쓸 수 있다. 다항식이면 이 방법으로 항상 최고차 항의 Θ 가 나온다.
흔한 오해
"𝑂 는 최악의 경우, Ω 는 최선의 경우를 뜻한다"는 설명을 흔히 본다. 이것이 왜 틀렸는가?
풀이 보기
두 가지가 뒤섞여 있다. 최선·평균·최악은 어떤 입력을 재느냐의 문제이고, 𝑂·Ω·Θ 는 잰 결과를 어떻게 어림하느냐의 문제다. 서로 상관이 없는 두 축이다.
넷을 조합할 수 있다. "선형 탐색의 최선 시간은 Θ(1) 이다", "선형 탐색의 최악 시간은 Θ(𝑛) 이다", "선형 탐색의 최악 시간은 𝑂(𝑛2) 이다" 셋 모두 참이다. 마지막 문장은 참이지만 헐거워서 쓸모가 적을 뿐이다.
오해가 생긴 데는 이유가 있다. 책들이 "최악의 경우"라는 말을 생략하고 그냥 𝑂(𝑛) 이라 적는 일이 많기 때문이다. 아무 말이 없으면 최악의 경우를 𝑂 로 적은 것이라고 읽으면 대체로 맞는다.
아래에서 위로 막는 주장도 쓸모가 있다. 8장에서 "비교로 하는 정렬은 어떤 방법을 쓰든 Ω(𝑛log𝑛) 이다"를 증명한다. 그것은 최선의 경우에 대한 말이 아니라 모든 알고리즘에 대한 하한이다.
최선 · 평균 · 최악
같은 크기의 입력이라도 내용에 따라 걸리는 시간이 다르다. 그래서 세 가지를 따로 잰다.
최악(worst case) — 크기 𝑛 인 모든 입력 중 가장 오래 걸리는 것. 보장이 필요할 때 쓴다.
최선(best case) — 가장 빨리 끝나는 것. 실무에서는 거의 쓸모가 없다.
평균(average case) — 입력이 어떤 확률로 들어오는지를 가정하고 계산한 기댓값.
평균은 반드시 가정을 동반한다. "찾는 값이 어느 자리에 있을 확률이 모두 같다" 같은 가정 없이는 평균이라는 말 자체가 성립하지 않는다. 논문이든 문서든 평균 시간을 말할 때는 어떤 분포를 가정했는지를 함께 봐야 한다.
<찾는 값이 놓인 자리마다 비교 횟수가 다르다. 최선 1, 최악 12, 평균 6.5 다. 셋이 모두 n 에 비례하거나 상수이므로, 이 알고리즘에서는 평균을 따져도 최악과 같은 결론이 나온다>[원본 보기]
예제 8
크기 𝑛 인 배열에서 선형 탐색을 한다. (1) 찾는 값이 반드시 안에 있고 어느 자리에 있을 확률이 모두 같다면 평균 비교 횟수는? (2) 값이 있을 확률이 1/2 이라면?
풀이 보기
묻는 것은 비교 횟수의 기댓값이다.
(1) 값이 𝑘 번째 자리에 있으면 비교는 𝑘 번이고, 그럴 확률은 1/𝑛 이다.
𝐸[𝑇]=∑𝑛𝑘=1𝑘⋅1𝑛=1𝑛⋅𝑛(𝑛+1)2=𝑛+12
𝑛=12 이면 6.5 번이다. 그림의 점선이 그 값이다. 최선 1 과 최악 𝑛 의 한가운데이니 말이 된다.
(2) 경우가 둘이므로 각각의 기댓값을 확률로 가중해 더한다. 없는 경우(확률 1/2)는 끝까지 봐야 하므로 𝑛 번이다.
𝐸[𝑇]=12⋅𝑛+12+12⋅𝑛=3𝑛+14
약 0.75𝑛 으로, (1)의 약 0.5𝑛 보다 늘었다. 없는 경우가 섞이면 평균이 최악 쪽으로 끌려간다는 상식과 맞는다. 가정 하나를 바꾸었을 뿐인데 답이 달라졌다. 평균을 말하면서 분포를 밝히지 않으면 아무 말도 하지 않은 것이다.
두 답 모두 상수배 차이라 점근적으로는 Θ(𝑛) 이다. 평균을 따로 재는 수고가 아깝지 않으려면 최악과 평균의 증가율 자체가 다른 알고리즘이어야 한다. 퀵 정렬(8장)과 해시 테이블(5장)이 그런 예다.
분할상환 분석 — 가끔 비싼 것을 나눠 갚기
어떤 연산은 대부분 싸고 아주 가끔 비싸다. 이럴 때 "최악 한 번"만 보면 실제 성능을 크게 잘못 알게 된다. 연속된 여러 번의 연산 전체 비용을 횟수로 나눈 값을 보는 것이 분할상환 분석(amortized analysis)이다.
가장 중요한 예는 동적 배열이다. 배열은 크기를 미리 정해야 하는데, 몇 개가 들어올지 모를 때는 곤란하다. 그래서 이렇게 한다.
넉넉한 배열을 하나 잡아 두고 뒤에서부터 채운다.
자리가 다 차면 두 배 크기의 새 배열을 잡고, 있던 것을 전부 옮긴 뒤 계속 채운다.
옮기는 순간의 비용은 𝑂(𝑛) 이다. 최악만 보면 "넣기 한 번에 𝑂(𝑛)" 이라고 말해야 하는데, 그것은 실제와 너무 다르다. 옮기는 일은 아주 드물게 일어나기 때문이다.
<위 막대는 한 번의 넣기에 든 비용이고 아래 곡선은 여기까지의 누적 평균이다. 값비싼 순간이 2, 3, 5, 9, 17 번째로 점점 드물어져서 평균은 3 아래에 머문다>[원본 보기]
𝑛 번 넣는 데 든 총 비용을 세어 보자. 값 하나를 쓰는 비용이 𝑛 번, 그리고 옮기는 비용이 자리가 찰 때마다 든다. 옮기는 개수는 1,2,4,8,… 로 두 배씩 늘어나므로, 마지막 옮김이 2𝑘≤𝑛 개였다면
넣기 한 번의 분할상환 비용은 상수다. 어떤 한 번은 정말로 𝑂(𝑛) 이 걸리지만, 그런 일이 일어나기 전까지 싼 연산이 그만큼 쌓여 있었으므로 평균을 내면 상수가 된다.
이 논법을 총계법(aggregate method)이라 한다. 다른 방법으로 회계법이 있다. 넣기 한 번마다 실제 비용 1 에 더해 "미리 2 를 적립"한다고 보는 것이다. 자리가 찰 때 필요한 옮김 비용은 그 적립금으로 정확히 충당된다. 적립금이 모자라는 일이 없음을 보이면 증명이 끝난다.
분할상환은 평균과 다르다. 평균은 입력의 확률분포를 가정하지만, 분할상환에는 확률이 없다. 어떤 입력이 오든 𝑛 번의 연속된 연산에 대해 반드시 성립하는 보장이다.
예제 9
자리가 찰 때 두 배가 아니라 10칸씩 늘린다면 𝑛 번 넣기의 총 비용은 얼마인가?
풀이 보기
옮김은 10번 넣을 때마다 한 번 일어나고, 그때 옮기는 개수는 그 시점의 원소 수다. 곧 10,20,30,…,𝑛 개다.
10+20+⋯+𝑛=10(1+2+⋯+𝑛10)=10⋅(𝑛/10)(𝑛/10+1)2≈𝑛220
총 비용이 Θ(𝑛2) 이므로 한 번당 분할상환 비용은 Θ(𝑛) 이다. 상수가 아니다.
두 배 늘리기와 10칸 늘리기의 차이는 계수가 아니라 증가율이다. 등차로 늘리면 급수가 이차식이 되고, 등비로 늘리면 급수가 유한한 배수에 머문다. '배수로 늘린다'는 것이 핵심이지 그 배수가 2인 것은 아니다. 1.5배로 늘려도 총 비용은 𝑂(𝑛) 이다.
예제 10
동적 배열이 자리가 절반만 차면 곧바로 절반 크기로 줄인다고 하자. 무슨 문제가 생기는가?
풀이 보기
정확히 절반이 찬 상태에서 하나 넣고 하나 빼기를 반복하는 입력을 생각해 보자.
하나 넣으면 자리가 차서 두 배로 늘리고 전부 옮긴다(𝑂(𝑛)). 하나 빼면 절반이 되어 다시 절반으로 줄이고 전부 옮긴다(𝑂(𝑛)). 이것이 계속된다.
연산 한 번마다 𝑂(𝑛) 이 드니 분할상환 비용도 𝑂(𝑛) 이다. 상수가 아니다. 이런 입력을 진동(thrashing)이라 한다.
해결책은 늘리는 문턱과 줄이는 문턱을 벌려 놓는 것이다. 예를 들어 가득 차면 두 배로 늘리되 1/4 이하로 떨어졌을 때만 절반으로 줄인다. 그러면 한 번 크기가 바뀐 뒤 다음 변경까지 최소한 원소 수의 상수 배만큼 연산이 일어나야 하므로 적립금이 늘 충분하다.
실제 라이브러리들이 이 방식을 쓴다. 두 문턱 사이의 틈이 진동을 막는 완충 장치다.
공간복잡도
시간과 똑같은 방식으로 메모리도 잰다. 관습은 입력 자체가 차지하는 공간은 빼고, 알고리즘이 추가로 쓰는 공간만 세는 것이다. 이것을 보조 공간(auxiliary space)이라 한다.
입력까지 세면 어떤 알고리즘이든 최소 Ω(𝑛) 이 되어 비교가 무의미해지기 때문이다. 배열을 제자리에서 정렬하는 알고리즘이 "공간 𝑂(1)" 이라 불리는 것은 이 관습 덕분이다.
잊기 쉬운 것이 재귀 호출이 쓰는 공간이다. 함수가 자기를 부를 때마다 되돌아올 자리와 지역 변수가 호출 스택에 쌓인다. 재귀 깊이가 𝑑 면 공간이 𝑂(𝑑) 든다. 깊이 𝑛 짜리 재귀는 눈에 보이는 배열을 하나도 만들지 않아도 𝑂(𝑛) 공간을 쓴다.
예제 11
다음 두 절차는 같은 답을 낸다. 시간과 공간을 각각 비교하라.
↓ python
def 합_반복(a):
총합 = 0
for x in a:
총합 = 총합 + x
return 총합
def 합_재귀(a, i):
if i == len(a): return 0
return a[i] + 합_재귀(a, i + 1)
풀이 보기
시간은 둘 다 원소를 한 번씩 보므로 Θ(𝑛) 이다.
공간이 다르다. 반복 쪽은 변수 하나뿐이라 𝑂(1) 이다. 재귀 쪽은 𝑖=0 부터 𝑖=𝑛 까지 호출이 겹겹이 쌓인 뒤에야 첫 덧셈이 일어나므로 𝑂(𝑛) 이다.
원소가 백만 개면 재귀 쪽은 백만 겹이 쌓여 대개 스택 넘침으로 죽는다. 대부분의 언어가 재귀 깊이에 수천에서 수만 정도의 한도를 둔다.
시간복잡도가 같다고 두 절차가 같은 것이 아니다. 공간을 함께 보지 않으면 돌아가지 않는 프로그램을 '효율적'이라고 부르게 된다.
재귀와 재귀식
재귀(recursion)란 함수가 자기 자신을 부르는 것이다. 문제를 같은 모양의 더 작은 문제로 줄일 수 있을 때 쓴다. 두 가지가 반드시 있어야 한다.
바닥 조건(base case) — 더 줄이지 않고 곧바로 답하는 가장 작은 경우. 이것이 없으면 영원히 부른다.
줄이는 단계 — 문제를 반드시 더 작게 만들어 자기를 부르는 부분. 크기가 줄지 않으면 역시 영원히 부른다.
재귀 알고리즘의 비용은 하나의 식이 아니라 자기 자신을 참조하는 식으로 나온다. 이것을 재귀식(recurrence)이라 한다. 세우는 법은 기계적이다. 코드에서 재귀 호출은 𝑇(⋅) 로, 나머지 일은 그 비용으로 그대로 옮겨 적으면 된다.
절차
재귀식
뜻
한 개 줄이고 상수 일
𝑇(𝑛)=𝑇(𝑛−1)+1
한 층 내려갈 때마다 일 하나
한 개 줄이고 n 만큼 일
𝑇(𝑛)=𝑇(𝑛−1)+𝑛
선택 정렬이 이렇다
반으로 줄이고 상수 일
𝑇(𝑛)=𝑇(𝑛/2)+1
이진 탐색이 이렇다
반씩 둘로 나누고 n 만큼 일
𝑇(𝑛)=2𝑇(𝑛/2)+𝑛
병합 정렬이 이렇다
재귀식을 푸는 가장 직관적인 방법은 재귀 트리다. 호출을 나무로 펼쳐 놓고 층마다 드는 비용을 구한 뒤 층 수를 곱한다.
<문제를 반으로 쪼개므로 층은 log₂ n 개 남짓 생기고, 층마다 합치는 비용은 언제나 n 이다. 두 값을 곱하면 전체 비용이 나온다>[원본 보기]
예제 12
𝑇(𝑛)=𝑇(𝑛/2)+1, 𝑇(1)=1 을 풀어라.
풀이 보기
한 번 부를 때마다 문제가 반이 되고 일은 상수 하나만 한다. 몇 번 부르는지만 세면 된다.
𝑛 을 반으로 계속 나눠 1 이 될 때까지의 횟수는 정의상 log2𝑛 이다. 층마다 비용이 1 이므로
𝑇(𝑛)=log2𝑛+1=Θ(log𝑛)
확인해 보자. 𝑛=1024 이면 10번 나누면 1 이 된다. 실제로 log21024=10 이다.
밑이 2 인지 10 인지는 적지 않는다. 밑변환 공식 log2𝑛=log10𝑛/log102 이 말하듯 밑이 다른 로그는 상수배 차이일 뿐이고, 점근 표기는 상수배를 무시하기 때문이다. 그래서 Θ(log𝑛) 이라고만 적는다.
예제 13
𝑇(𝑛)=2𝑇(𝑛/2)+𝑛, 𝑇(1)=1 을 재귀 트리로 풀어라.
풀이 보기
그림을 그대로 따라간다. 뿌리에서 문제 크기가 𝑛 이고 합치는 비용이 𝑛 이다. 그 아래 층에는 크기 𝑛/2 인 문제가 2개, 그 아래에는 𝑛/4 이 4개다.
층별 비용을 보자. 𝑘 번째 층에는 크기 𝑛/2𝑘 인 문제가 2𝑘 개 있으므로 그 층의 비용 합은
2𝑘×𝑛2𝑘=𝑛
층이 몇 개인가. 크기가 1 이 될 때까지 반으로 나누므로 log2𝑛+1 개다.
𝑇(𝑛)=𝑛×(log2𝑛+1)=Θ(𝑛log𝑛)
어느 층이 특별히 무겁지 않고 모든 층이 똑같이 𝑛 이라는 것이 이 재귀식의 특징이다. 다음 절의 마스터 정리에서 이것이 "둘째 경우"로 정리된다. 7장의 병합 정렬이 정확히 이 식이다.
마스터 정리
앞의 재귀 트리를 매번 그리는 대신, 자주 나오는 꼴에 대해 답을 미리 정리해 둔 것이 마스터 정리(master theorem)다. 대상은 이런 모양의 재귀식이다.
𝑇(𝑛)=𝑎𝑇(𝑛/𝑏)+𝑓(𝑛)(𝑎≥1,𝑏>1)
읽는 법은 이렇다. 문제를 𝑏 분의 1 크기로 줄여 𝑎 개 풀고, 그 결과를 합치는 데 𝑓(𝑛) 이 든다.
여기서 잎 층의 비용이 열쇠다. 층을 내려갈수록 문제 수는 𝑎 배로 늘고 크기는 𝑏 분의 1 이 되므로, 잎은 𝑛log𝑏𝑎 개쯤 생긴다. 이 값과 합치는 비용 𝑓(𝑛) 중 어느 쪽이 무거운가로 답이 셋으로 갈린다.
<막대의 가로 길이가 그 층 전체의 비용이다. 아래가 무거우면 잎이 답을 정하고, 위가 무거우면 뿌리가 답을 정하며, 고르면 층 수를 곱한다>[원본 보기]
경우
조건
답
1. 잎이 무겁다
𝑓(𝑛)=𝑂(𝑛log𝑏𝑎−𝜖) 인 𝜖>0 이 있다
𝑇(𝑛)=Θ(𝑛log𝑏𝑎)
2. 고르다
𝑓(𝑛)=Θ(𝑛log𝑏𝑎)
𝑇(𝑛)=Θ(𝑛log𝑏𝑎log𝑛)
3. 뿌리가 무겁다
𝑓(𝑛)=Ω(𝑛log𝑏𝑎+𝜖) 인 𝜖>0 이 있고 정칙 조건을 만족한다
𝑇(𝑛)=Θ(𝑓(𝑛))
셋째 경우의 정칙 조건은 𝑎𝑓(𝑛/𝑏)≤𝑐𝑓(𝑛) 을 만족하는 𝑐<1 이 있어야 한다는 것이다. 합치는 비용이 위층에서 확실히 더 크다는 뜻이고, 실제로 만나는 대부분의 𝑓 는 이 조건을 만족한다.
세 경우 사이에는 틈이 있다.𝑓(𝑛)=𝑛log𝑏𝑎log𝑛 처럼 잎 비용보다 크지만 어떤 𝑛𝜖 배만큼 크지는 않은 함수가 그렇다. 이런 식에는 마스터 정리를 쓸 수 없고 재귀 트리로 직접 풀어야 한다.
예제 14
다음 재귀식을 마스터 정리로 풀어라. (1) 𝑇(𝑛)=2𝑇(𝑛/2)+𝑛 (2) 𝑇(𝑛)=4𝑇(𝑛/2)+𝑛 (3) 𝑇(𝑛)=2𝑇(𝑛/2)+𝑛2
풀이 보기
세 문제 모두 𝑛log𝑏𝑎 를 먼저 구하고 𝑓(𝑛) 과 견주는 같은 절차다.
(1)𝑎=2,𝑏=2 이므로 log22=1, 잎 비용은 𝑛1=𝑛. 𝑓(𝑛)=𝑛 이라 정확히 같다. 둘째 경우다.
𝑇(𝑛)=Θ(𝑛log𝑛). 앞 예제에서 재귀 트리로 얻은 것과 같다.
(2)𝑎=4,𝑏=2 이므로 log24=2, 잎 비용은 𝑛2. 𝑓(𝑛)=𝑛 은 그보다 작다(𝜖=1 로 잡으면 𝑛=𝑂(𝑛2−1)). 첫째 경우다.
𝑇(𝑛)=Θ(𝑛2). 합치는 일은 아무리 해도 잎에서 나는 비용에 묻힌다.
(3)𝑎=2,𝑏=2 이므로 잎 비용은 𝑛. 𝑓(𝑛)=𝑛2 이 더 크다(𝜖=1). 정칙 조건도 2(𝑛/2)2=𝑛2/2≤𝑐𝑛2 를 𝑐=1/2 로 만족한다. 셋째 경우다.
𝑇(𝑛)=Θ(𝑛2). 맨 위 한 번의 합치기가 전체를 지배한다.
세 문제의 차이는 𝑎 와 𝑓 뿐인데 답이 셋으로 갈렸다. 먼저 𝑛log𝑏𝑎 를 구하고 𝑓 와 견준다는 순서만 지키면 헷갈리지 않는다.
예제 15
𝑇(𝑛)=𝑇(2𝑛/3)+1 을 풀어라. 그리고 𝑇(𝑛)=2𝑇(𝑛/2)+𝑛log𝑛 에 마스터 정리를 쓸 수 있는지 판정하라.
풀이 보기
앞의 것.𝑎=1,𝑏=3/2 이므로 log3/21=0, 잎 비용은 𝑛0=1. 𝑓(𝑛)=1 이니 정확히 같아 둘째 경우다.
𝑇(𝑛)=Θ(𝑛0log𝑛)=Θ(log𝑛)
문제를 3분의 2 로 줄이든 2분의 1 로 줄이든 일정 비율로 줄이기만 하면 로그가 나온다. 줄어드는 비율은 로그의 밑에만 영향을 주고 밑은 상수배이므로 사라진다.
뒤의 것.𝑎=2,𝑏=2 이므로 잎 비용은 𝑛 이다. 𝑓(𝑛)=𝑛log𝑛 은 𝑛 보다 크지만, 셋째 경우가 되려면 어떤 𝜖>0 에 대해 𝑛log𝑛≥𝑐𝑛1+𝜖 이어야 한다. 그런데 log𝑛 은 어떤 𝑛𝜖 보다도 천천히 자라므로 그런 𝜖 은 없다.
둘째 경우도 아니다. 𝑛log𝑛 은 Θ(𝑛) 이 아니기 때문이다. 세 경우의 틈에 빠진 것이므로 마스터 정리를 쓸 수 없다.
이럴 때는 재귀 트리로 돌아간다. 층별 비용을 세어 보면 𝑘 층의 비용이 𝑛(log𝑛−𝑘) 이고, 이를 𝑘=0 부터 log𝑛 까지 더하면 Θ(𝑛log2𝑛) 이 나온다.
이 장의 정리
알고리즘 하나를 재는 일은 다섯 단계의 판단이다. 각 단계에서 무엇을 버리는지가 점근 표기의 정체다.
<입력 크기를 정하고 기본 연산을 골라 세고 어느 경우인지 밝힌 뒤 상수와 낮은 차수를 버린다. 재귀 알고리즘이면 세는 단계에서 재귀식이 나오고 마스터 정리로 푼다>[원본 보기]
기호 · 용어
뜻
처음 나온 곳
𝑛
입력 크기. 무엇을 n 으로 잡을지가 첫 판단이다
1절
𝑇(𝑛)
입력 크기가 n 일 때의 기본 연산 횟수
1절
𝑂(𝑔)
어떤 자리 뒤부터 g 의 상수배 아래로 눌린다 (상한)
3절
Ω(𝑔)
어떤 자리 뒤부터 g 의 상수배 위에 있다 (하한)
3절
Θ(𝑔)
위아래 둘 다. 상수배를 빼면 같은 속도로 자란다
3절
최선 · 평균 · 최악
어떤 입력을 재는가. 표기와는 다른 축이다
4절
분할상환
n 번의 연속된 연산 총비용을 n 으로 나눈 값. 확률 가정이 없다
5절
보조 공간
입력을 뺀 추가 메모리. 재귀는 호출 스택을 쓴다
6절
재귀식
자기 자신을 참조하는 비용 식. 𝑇(𝑛)=𝑎𝑇(𝑛/𝑏)+𝑓(𝑛)
7절
마스터 정리
잎 비용 𝑛log𝑏𝑎 와 𝑓(𝑛) 중 무거운 쪽이 답을 정한다
7절
다음 장부터는 이 어휘로 자료구조를 잰다. 먼저 가장 기본이 되는 배열과 연결 리스트를 보는데, 거기서 같은 𝑂(𝑛) 이라도 실제 시간이 몇 배씩 다른 이유를 만나게 된다. 점근 표기가 버린 상수 안에 무엇이 들어 있었는지가 그 장의 이야기다.
배열, 연결 리스트, 스택, 큐
자료를 담는 그릇을 자료구조(data structure)라 한다. 그릇이 여럿인 이유는 취향 때문이 아니다. 메모리에 값을 어떻게 늘어놓느냐가 어떤 연산이 싸고 어떤 연산이 비싼지를 결정하기 때문이다. 이 장의 요지는 그 한 문장이다.
답할 질문은 셋이다. 왜 값을 붙여 두면 빠른가, 붙여 두면 무엇이 불편해지는가, 그리고 넣고 빼는 자리를 한 곳으로 제한하면 무엇을 얻는가.
3장에서 가져오는 것은 점근 표기와 분할상환이다. 동적 배열이 왜 평균 𝑂(1) 인지는 3장에서 이미 계산했고, 여기서는 그 결과를 쓴다.
메모리는 번호가 붙은 칸의 줄이다
컴퓨터의 메모리는 1바이트짜리 칸이 일렬로 늘어선 것이고, 칸마다 0부터 시작하는 번호가 붙어 있다. 이 번호를 주소(address)라 한다. 기계가 할 수 있는 일은 "몇 번 칸의 값을 가져와라"와 "몇 번 칸에 이 값을 써라" 둘뿐이고, 둘 다 번호만 알면 어느 칸이든 같은 시간이 걸린다. 이것을 임의 접근(random access)이라 한다.
값을 이 줄 위에 늘어놓는 방법은 크게 둘이다. 붙여 놓거나, 흩어 놓고 서로 가리키게 하거나.
<위는 붙여 놓은 것이고 아래는 흩어 놓고 화살표로 이은 것이다. 위에서는 세 번째 값의 주소를 계산으로 얻지만 아래에서는 화살표를 두 번 따라가야 한다>[원본 보기]
배열 — 붙여 놓기
배열(array)은 같은 크기의 값을 메모리에 빈틈없이 이어서 놓은 것이다. 첫 칸의 주소가 𝑝 이고 값 하나가 𝑠 바이트라면, 𝑖 번째 값의 주소는
𝑝+𝑖×𝑠
곱셈 하나와 덧셈 하나다. 𝑖 가 0 이든 100만이든 계산량이 같다. 배열의 첨자 접근이 𝑂(1) 인 이유가 이 산술 한 줄이다. 첨자를 0부터 세는 관습도 여기서 나온다. 첫 칸이 𝑝+0×𝑠=𝑝 가 되어 식이 깔끔해지기 때문이다.
대신 조건이 붙는다. 값의 크기가 모두 같아야 하고, 그만큼의 연속된 자리가 한꺼번에 비어 있어야 한다. 이 두 조건이 배열의 모든 불편함의 출처다.
예제 16
정수 하나가 4바이트인 배열이 주소 2000번부터 시작한다. 𝑎[7] 의 주소는? 그리고 𝑎[7] 을 읽는 것이 𝑎[0] 을 읽는 것보다 느린가?
풀이 보기
주소는 𝑝+𝑖×𝑠 에 그대로 넣으면 된다.
2000+7×4=2028
느리지 않다. 기계가 하는 일은 곱셈 한 번, 덧셈 한 번, 그리고 "2028번 칸을 가져와라" 한 번이다. 𝑖 가 0 일 때와 정확히 같은 일이다.
여기서 흔한 오해 하나를 짚어 두자. "배열의 뒤쪽 원소는 앞에서부터 세어 가므로 느리다"는 생각은 연결 리스트의 이야기이지 배열의 이야기가 아니다. 다음 절에서 그 차이가 성능 표 전체를 가른다.
예제 17
3×4 짜리 이차원 배열을 일차원 메모리에 담아야 한다. 𝑎[𝑖][𝑗] 의 자리를 어떻게 계산하겠는가?
풀이 보기
메모리는 일렬이므로 이차원을 일차원으로 펴야 한다. 가장 흔한 방식은 행 우선(row-major), 곧 한 행을 통째로 적고 다음 행을 이어 적는 것이다.
행 하나가 4칸이므로 𝑖 행의 시작은 𝑖×4 번째 칸이고, 거기서 𝑗 칸 더 간다.
자리=𝑖×4+𝑗
일반적으로 열이 𝐶 개면 𝑖×𝐶+𝑗 다. 여기서도 곱셈 하나와 덧셈 하나뿐이라 𝑂(1) 이다.
확인하자. 𝑎[2][3] 은 2×4+3=11 번째 칸이고, 전체가 12칸이므로 마지막 칸이 맞다.
어느 첨자를 바깥에 두느냐는 취향처럼 보이지만 아니다. 같은 행의 원소는 메모리에서 이웃하고 같은 열의 원소는 𝐶 칸씩 떨어져 있다. 이 차이가 다음 절에서 실제 속도로 나타난다.
캐시 — 붙어 있으면 빠르다
앞에서 "어느 칸이든 같은 시간"이라고 했지만, 실제 기계에서는 그렇지 않다. 메모리는 처리기보다 훨씬 느려서, 그 사이에 작고 빠른 캐시(cache)를 둔다. 그리고 캐시는 값을 한 칸씩 가져오지 않고 수십 바이트짜리 덩어리(캐시 줄)를 통째로 가져온다.
결과가 이렇다. 어떤 값을 읽으면 그 옆의 값들이 공짜로 따라온다. 이어서 옆 값을 읽으면 느린 메모리를 건드리지 않고 끝난다. 이 성질을 지역성(locality)이라 한다.
<한 번 퍼 올 때 여러 칸이 함께 온다. 배열을 훑으면 그 칸들을 모두 쓰지만 흩어진 노드를 훑으면 하나만 쓰고 버린다. 복잡도는 둘 다 O(n) 인데 실제 시간은 몇 배씩 다르다>[원본 보기]
그래서 점근 표기가 같아도 실제 속도는 크게 다를 수 있다. 3장에서 버린 상수 안에 이것이 들어 있다. 배열이 이론보다 실무에서 더 사랑받는 가장 큰 이유가 지역성이다.
설계 판단
1000×1000 짜리 이차원 배열(행 우선 저장)의 모든 원소를 더한다. 다음 두 순서 중 어느 쪽이 빠르겠는가? 복잡도는 둘 다 Θ(𝑛2) 이다.
↓ python
# (가) 행을 따라
for i in range(1000):
for j in range(1000):
총합 += a[i][j]
# (나) 열을 따라
for j in range(1000):
for i in range(1000):
총합 += a[i][j]
풀이 보기
복잡도가 같으니 메모리를 건드리는 순서를 봐야 한다.
행 우선 저장에서 𝑎[𝑖][𝑗] 의 자리는 𝑖×1000+𝑗 다. (가)는 𝑗 를 1씩 늘리므로 메모리에서 바로 옆 칸으로 간다. 캐시 줄 하나를 가져오면 그 안의 값을 전부 쓴다.
(나)는 𝑖 를 1씩 늘리므로 한 번 읽을 때마다 1000칸씩 건너뛴다. 캐시 줄을 가져와도 그중 하나만 쓰고 버린 뒤 다음 줄을 다시 가져와야 한다.
답은 (가)다. 실제로 몇 배 차이가 나며, 배열이 캐시보다 훨씬 크면 그 차이가 더 벌어진다.
실무에서 쓸 규칙은 이렇다. 안쪽 반복이 메모리에서 이웃한 값을 훑도록 첨자 순서를 맞춰라. 코드는 한 글자도 늘지 않는데 속도가 달라진다.
동적 배열 — 크기를 모를 때
배열의 첫 번째 불편은 크기를 미리 정해야 한다는 것이다. 자료가 몇 개 들어올지 모르는 상황이 실무에서는 오히려 흔하다.
해결책이 동적 배열(dynamic array)이다. 실제로 잡아 둔 자리를 용량(capacity), 그중 실제로 쓰는 개수를 크기(size)라 하고 둘을 따로 관리한다. 절차는 이렇다.
뒤에 넣을 때 크기가 용량보다 작으면 그냥 쓴다. 상수 시간이다.
크기가 용량과 같아지면 용량이 두 배인 새 배열을 잡고, 있던 값을 전부 옮긴 뒤 넣는다.
↓ python
def 뒤에_넣기(값):
if 크기 == 용량:
새배열 = 자리_잡기(용량 * 2)
새배열[0:크기] = 배열[0:크기] # 전부 옮긴다
배열, 용량 = 새배열, 용량 * 2
배열[크기] = 값
크기 = 크기 + 1
한 번의 최악은 𝑂(𝑛) 이지만 분할상환 비용은 𝑂(1) 이다. 3장에서 총계법으로 계산했다. 옮기는 개수가 1,2,4,8,… 로 두 배씩 늘어 그 합이 2𝑛 을 넘지 못하기 때문이다.
공짜는 아니다. 값을 치른다.
공간을 최대 두 배까지 낭비한다. 방금 두 배로 늘렸다면 절반이 비어 있다.
어떤 한 번은 정말로 오래 걸린다. 응답 시간을 일정하게 지켜야 하는 프로그램에서는 이 튐이 문제가 된다.
옮기는 동안 값들의 주소가 바뀐다. 원소를 가리켜 두었던 것이 있다면 전부 무효가 된다.
예제 19
빈 동적 배열(초기 용량 1)에 값을 17번 넣었다. 옮김은 몇 번 일어났고 옮긴 값은 모두 몇 개인가? 마지막 용량은?
풀이 보기
용량은 1→2→4→8→16→32 로 바뀐다. 옮김은 자리가 꽉 찬 상태에서 하나 더 넣을 때 일어나므로 2, 3, 5, 9, 17 번째 넣기에서, 모두 5번이다.
그때 옮긴 값의 개수는 각각 1, 2, 4, 8, 16 개이므로
1+2+4+8+16=31
총 비용은 값을 쓴 17 에 옮김 31 을 더한 48 이고, 한 번당 평균은 48/17≈2.8 이다. 3장 그림의 곡선이 3 아래에 머물던 것이 이 값이다.
마지막 용량은 32 다. 17개를 담는 데 32칸을 쓰고 있으니 15칸이 놀고 있다. 이것이 동적 배열이 치르는 공간 값이다.
설계 판단
동적 배열에서 가운데 원소 하나를 지워야 한다. 표에 따르면 𝑂(𝑛) 이다. 이것을 𝑂(1) 로 만들 수 있는가?
풀이 보기
𝑂(𝑛) 이 드는 이유는 구멍을 메우려고 뒤 원소를 전부 한 칸씩 당기기 때문이다. 그런데 그것은 순서를 지켜야 할 때의 이야기다.
순서를 지킬 필요가 없다면 이렇게 한다. 맨 뒤 원소를 지운 자리에 옮겨 놓고 크기를 하나 줄인다. 대입 한 번과 뺄셈 한 번이므로 𝑂(1) 이다.
↓ python
def 순서_무시_삭제(i):
배열[i] = 배열[크기 - 1]
크기 = 크기 - 1
값을 치른다. 원소의 순서가 뒤바뀐다. 그리고 훑는 도중에 이 삭제를 하면 방금 옮겨 온 원소를 건너뛰게 되므로, 훑기와 삭제를 섞을 때는 첨자를 늘리지 않고 그 자리를 한 번 더 봐야 한다.
이 수법은 게임처럼 "살아 있는 것들의 목록"을 매 순간 갱신하는 곳에서 널리 쓰인다. 순서에 뜻이 없기 때문이다. 요구사항에서 필요 없는 보장 하나를 걷어 내면 복잡도가 한 단계 내려간다는 것이 여기서 배울 점이다.
연결 리스트 — 흩어 놓고 가리키기
붙여 놓기의 반대는 흩어 놓고 서로 가리키게 하기다. 값 하나와 "다음 값이 있는 주소"를 묶은 덩어리를 노드(node)라 하고, 노드가 사슬처럼 이어진 것을 연결 리스트(linked list)라 한다.
얻는 것과 잃는 것이 분명하다.
얻는 것: 연속된 자리가 필요 없다. 가운데에 끼워 넣어도 다른 값을 옮기지 않는다. 크기를 미리 정할 필요도 없다.
잃는 것: 𝑖 번째 값을 보려면 앞에서부터 𝑖 번 따라가야 한다. 값마다 주소를 하나씩 더 저장해야 한다. 흩어져 있어 캐시가 돕지 못한다.
<배열은 뒤 원소를 전부 한 칸씩 밀어야 자리가 나지만 연결 리스트는 화살표 두 개만 고치면 된다. 다만 그 자리까지 찾아가는 비용은 별개다>[원본 보기]
가운데에 값을 끼워 넣는 절차는 이렇다. 새 노드를 만들고, 그 노드가 앞 노드의 다음을 가리키게 한 뒤, 앞 노드가 새 노드를 가리키게 한다. 순서를 뒤집으면 뒷부분을 통째로 잃어버린다.
↓ python
def 뒤에_끼우기(앞노드, 값):
새노드 = 노드(값)
새노드.다음 = 앞노드.다음 # 먼저 새 노드가 뒤를 붙잡고
앞노드.다음 = 새노드 # 그다음에 앞을 옮긴다
두 줄 모두 상수 시간이므로 𝑂(1) 이다. 단 "앞노드"를 이미 알고 있을 때의 이야기다. 첫 노드부터 찾아가야 한다면 그 비용이 𝑂(𝑛) 이다. 표에서 이 둘을 반드시 구분해 읽어야 한다.
<단일 연결은 앞으로만 가고 이중 연결은 뒤로도 간다. 원형은 끝이 처음을 가리켜 시작과 끝의 구분이 사라진다. 주소를 하나 더 저장하는 값을 치르면 뒤로 가는 능력을 얻는다>[원본 보기]
종류
노드가 가리키는 것
얻는 것
치르는 것
단일 연결
다음 하나
가장 적은 공간
뒤로 갈 수 없다
이중 연결
다음과 이전
노드만 알면 그 자리에서 삭제 O(1)
주소 하나만큼의 공간과 갱신 부담
원형
끝이 처음을 가리킨다
끝을 검사할 필요가 없다. 돌아가며 쓰기에 좋다
끝을 잘못 다루면 무한 반복
예제 21
단일 연결 리스트에서 어떤 노드 𝑣 를 삭제하려 한다. 𝑣 의 주소만 알고 있다. 왜 어려운가? 이중 연결이면 무엇이 달라지는가?
풀이 보기
삭제란 "𝑣 의 앞 노드가 𝑣 의 다음을 가리키게 하는 것"이다. 그러려면 앞 노드가 필요한데, 단일 연결에서는 앞으로 갈 방법이 없다. 첫 노드부터 다시 훑어 𝑣 를 가리키는 노드를 찾아야 하므로 𝑂(𝑛) 이다.
이중 연결이면 𝑣.이전 이 바로 그 앞 노드이므로
𝑣.이전.다음←𝑣.다음,𝑣.다음.이전←𝑣.이전
두 줄로 끝나 𝑂(1) 이다. 주소 하나를 더 저장한 값으로 삭제를 상수 시간으로 만든 것이다.
단일 연결에서도 쓰는 편법이 있다. 𝑣 의 다음 노드 값을 𝑣 에 덮어쓰고 다음 노드를 지우면 겉보기 결과가 같다. 다만 𝑣 가 마지막 노드면 쓸 수 없고, 𝑣 를 가리키던 다른 참조가 있으면 엉뚱한 값을 보게 된다.
예제 22
단일 연결 리스트를 뒤집어라. 추가 공간은 상수만 쓴다.
풀이 보기
말로 먼저 정리한다. 노드를 하나씩 지나가면서 각 노드의 화살표 방향을 반대로 돌린다. 화살표를 돌리는 순간 뒤로 가는 길이 끊기므로, 돌리기 전에 다음 노드를 따로 붙잡아 두어야 한다.
↓ python
def 뒤집기(첫노드):
이전, 현재 = None, 첫노드
while 현재 is not None:
다음 = 현재.다음 # 끊기기 전에 붙잡는다
현재.다음 = 이전 # 방향을 돌린다
이전, 현재 = 현재, 다음
return 이전 # 새 첫 노드
노드마다 상수 번의 일을 하므로 시간은 Θ(𝑛), 변수 세 개만 쓰므로 공간은 𝑂(1) 이다.
검산은 짧은 것으로 한다. 노드가 하나면 반복이 한 번 돌고 그 노드의 다음이 None 이 되며 자기 자신이 반환된다. 맞다. 리스트가 비어 있으면 반복이 돌지 않고 None 이 반환된다. 이것도 맞다.
"끊기기 전에 붙잡는다"가 포인터를 다루는 일의 전부라고 해도 지나치지 않다. 위 코드에서 첫 줄을 지우면 리스트의 뒷부분이 통째로 사라진다.
예제 23
값 하나가 4바이트이고 주소 하나가 8바이트인 기계에서, 정수 100만 개를 배열과 단일 연결 리스트에 각각 담으면 메모리를 얼마나 쓰는가?
풀이 보기
배열은 값만 담으므로
106×4바이트=4MB
연결 리스트는 노드마다 값 4바이트와 다음 주소 8바이트가 필요하므로 최소 12바이트다. 실제로는 주소가 8바이트 경계에 맞춰져야 해서 대개 16바이트가 된다.
106×16바이트=16MB
네 배다. 게다가 노드를 하나씩 따로 잡느라 관리 정보가 더 붙는 경우가 많다.
값이 작을수록 이 손해가 커진다는 것이 요점이다. 값 하나가 1 KB 짜리 구조체라면 주소 8바이트는 1%도 되지 않아 신경 쓸 일이 아니다. "연결 리스트는 공간을 더 쓴다"는 말은 값의 크기에 견주어 판단해야 한다.
예제 24
단일 연결 리스트가 고리를 이루고 있는지(어떤 노드의 다음을 따라가다 이미 지난 노드로 돌아오는지) 판정하라. 추가 공간은 상수만 쓴다.
풀이 보기
지나온 노드를 전부 적어 두고 만날 때마다 대조하면 되지만 그러면 공간이 𝑂(𝑛) 이다. 상수 공간으로 하려면 다른 발상이 필요하다.
빠른 것과 느린 것을 동시에 보낸다. 하나는 한 칸씩, 하나는 두 칸씩 간다. 고리가 없으면 빠른 쪽이 먼저 끝에 닿는다. 고리가 있으면 둘 다 고리 안을 영원히 돌게 되는데, 매 걸음마다 둘 사이의 거리가 정확히 1 씩 줄어들므로 반드시 만난다.
↓ python
def 고리가_있는가(첫노드):
느림 = 빠름 = 첫노드
while 빠름 is not None and 빠름.다음 is not None:
느림, 빠름 = 느림.다음, 빠름.다음.다음
if 느림 is 빠름: return True
return False
시간은 𝑂(𝑛) 이다. 고리가 없으면 빠른 쪽이 𝑛/2 걸음에 끝나고, 있으면 느린 쪽이 고리에 들어간 뒤 고리 길이만큼 안에 반드시 만나기 때문이다. 공간은 변수 두 개뿐이라 𝑂(1) 이다.
값이 같은지가 아니라 같은 노드인지를 견주는 것이 중요하다. 값이 같은 서로 다른 노드가 있을 수 있기 때문이다.
고리는 남의 이야기가 아니다. 삽입이나 삭제에서 화살표 하나를 잘못 이으면 곧바로 생기고, 그다음 훑기는 영원히 끝나지 않는다.
스택과 큐 — 쓰는 끝을 제한하기
지금까지는 "어디든 넣고 뺄 수 있는" 그릇을 보았다. 반대로 일부러 제한을 거는 그릇이 있다. 넣고 빼는 자리를 한두 곳으로 못 박으면 모든 연산이 𝑂(1) 이 되고, 무엇보다 쓰는 쪽의 의도가 자료구조에 드러난다.
<스택은 같은 쪽에서 넣고 빼므로 나중에 넣은 것이 먼저 나오고, 큐는 반대쪽으로 빼므로 먼저 넣은 것이 먼저 나온다. 차이는 이것뿐이고 나머지는 모두 여기서 따라 나온다>[원본 보기]
스택
스택(stack)은 한쪽 끝에서만 넣고 빼는 그릇이다. 나중에 넣은 것이 먼저 나오므로 후입선출(LIFO)이라 한다. 연산은 셋이다. 넣기(push), 빼기(pop), 맨 위 보기(peek). 셋 다 𝑂(1) 이다.
동적 배열의 뒤끝만 쓰면 그대로 스택이 된다. 연결 리스트의 앞끝만 써도 된다.
스택이 필요한 곳은 "가장 최근에 시작한 것을 가장 먼저 끝내야 하는" 모든 상황이다.
함수 호출 — 나중에 부른 함수가 먼저 끝나 돌아간다. 3장에서 본 호출 스택이 이것이다.
괄호 짝 맞추기, 수식 계산
편집기의 되돌리기
깊이 우선 탐색(9장)
예제 25
괄호 문자열이 올바른지 판정하라. 예를 들어 ([]{}) 는 올바르고 ([)] 는 틀렸다.
풀이 보기
말로 절차를 먼저 세운다. 여는 괄호를 만나면 쌓아 두고, 닫는 괄호를 만나면 가장 최근에 쌓아 둔 것과 짝이 맞는지 본다. "가장 최근" 이라는 말이 나왔으므로 스택이다.
↓ python
def 올바른가(문자열):
쌓기 = []
짝 = { ')': '(', ']': '[', '}': '{' }
for 글자 in 문자열:
if 글자 in '([{': 쌓기.append(글자)
elif not 쌓기 or 쌓기.pop() != 짝[글자]: return False
return len(쌓기) == 0
글자마다 상수 번의 일을 하므로 시간은 Θ(𝑛), 최악에 모든 글자가 여는 괄호이므로 공간은 𝑂(𝑛) 이다.
마지막 줄이 중요하다. 스택이 비어 있어야 올바르다. (( 는 끝까지 오류가 없지만 스택에 둘이 남아 있으므로 틀렸다.
([)] 을 손으로 돌려 보자. ( 와 [ 를 쌓고, ) 를 만나 꺼내면 [ 가 나온다. 짝이 아니므로 거짓이다. 중첩이 어긋난 것을 잡아내는 것이 스택의 일이다.
큐
큐(queue)는 한쪽으로 넣고 반대쪽으로 빼는 그릇이다. 먼저 넣은 것이 먼저 나오므로 선입선출(FIFO)이라 한다. 줄 서기가 그대로 큐다.
배열로 큐를 만들 때 소박하게 "앞에서 빼기"를 하면 뒤 원소를 전부 한 칸씩 당겨야 해서 𝑂(𝑛) 이 된다. 이것을 피하는 표준 수법이 원형 버퍼(circular buffer)다.
<두 표시가 배열 위를 돌면서 앞과 뒤를 가리킨다. 끝에 닿으면 나머지 연산으로 처음으로 돌아가므로 원소를 옮기는 일이 없다>[원본 보기]
앞과 뒤를 가리키는 표시 두 개를 두고, 하나 넣거나 뺄 때마다 표시를 하나씩 옮기되 배열 끝에 닿으면 처음으로 돌린다. 돌리는 계산은 나머지 연산 하나면 된다.
↓ python
def 넣기(값):
배열[뒤] = 값
뒤 = (뒤 + 1) % 용량 # 끝에 닿으면 0 으로 돌아간다
def 빼기():
값, 앞 = 배열[앞], (앞 + 1) % 용량
return 값
두 연산 모두 상수 시간이므로 𝑂(1) 이다. 원소를 옮기지 않기 때문이다.
함정이 하나 있다. 가득 찬 상태와 텅 빈 상태가 둘 다 "앞 = 뒤"로 보인다. 개수를 따로 세거나 한 칸을 늘 비워 두는 식으로 구분해야 한다.
큐는 "온 순서대로 처리해야 하는" 모든 곳에 쓰인다. 작업 대기열, 인쇄 대기열, 그리고 9장의 너비 우선 탐색이 그렇다.
양쪽 끝에서 모두 넣고 뺄 수 있게 한 것을 덱(deque)이라 한다. 원형 버퍼에서 표시를 양방향으로 움직이면 그대로 덱이 된다. 스택으로도 큐로도 쓸 수 있어 라이브러리들이 이것 하나만 제공하는 일이 많다.
예제 26
스택 두 개로 큐를 만들 수 있는가? 만들 수 있다면 빼기 한 번의 분할상환 비용은?
풀이 보기
스택은 순서를 뒤집는다. 뒤집힌 것을 한 번 더 뒤집으면 원래 순서이므로 스택 둘이면 될 것 같다.
절차는 이렇다. 넣기는 항상 "들어오는 스택"에 쌓는다. 뺄 때는 "나가는 스택"에서 꺼내되, 그것이 비어 있으면 들어오는 스택의 내용을 전부 옮겨 담는다. 옮기면서 순서가 뒤집혀 가장 오래된 것이 맨 위로 온다.
옮기는 순간의 비용은 𝑂(𝑛) 이다. 그러나 각 원소는 평생 정확히 한 번 옮겨진다. 넣기 한 번, 옮기며 꺼내기 한 번, 옮기며 넣기 한 번, 빼기 한 번 — 원소당 상수 번이다.
따라서 𝑛 번의 연산 총비용이 𝑂(𝑛) 이고 분할상환 비용은 𝑂(1) 이다. 3장의 총계법과 같은 논법이다.
값비싼 연산을 "각 원소가 몇 번 겪는가"로 바꿔 세는 것이 분할상환 분석의 전형적인 요령이다.
무엇을 고를 것인가
지금까지의 결론을 한 표로 모은다. 여기서 "자리를 안다"와 "값으로 찾는다"를 구분해 읽는 것이 가장 중요하다. 연결 리스트가 빠르다는 말의 대부분이 이 구분을 흐려 놓은 데서 나온다.
연산
배열
동적 배열
단일 연결 리스트
이중 연결 리스트
𝑖 번째 읽기
𝑂(1)
𝑂(1)
𝑂(𝑛)
𝑂(𝑛)
맨 뒤에 넣기
불가 (자리 없음)
분할상환 𝑂(1)
𝑂(1) (꼬리 표시가 있으면)
𝑂(1)
맨 앞에 넣기
𝑂(𝑛)
𝑂(𝑛)
𝑂(1)
𝑂(1)
자리를 아는 곳에 끼우기
𝑂(𝑛)
𝑂(𝑛)
𝑂(1)
𝑂(1)
값으로 찾기
𝑂(𝑛)
𝑂(𝑛)
𝑂(𝑛)
𝑂(𝑛)
노드를 아는 곳 지우기
𝑂(𝑛)
𝑂(𝑛)
𝑂(𝑛)
𝑂(1)
값 하나당 추가 공간
없음
빈 자리 최대 100%
주소 1개
주소 2개
캐시 지역성
가장 좋다
가장 좋다
나쁘다
나쁘다
넣고 빼는 자리를 제한한 그릇들은 이렇게 정리된다.
그릇
넣는 곳
빼는 곳
두 연산 모두
대표적 쓰임
스택
한쪽 끝
같은 끝
𝑂(1)
함수 호출, 괄호 검사, 깊이 우선 탐색
큐
한쪽 끝
반대쪽 끝
𝑂(1)
작업 대기열, 너비 우선 탐색
덱
양쪽 끝
양쪽 끝
𝑂(1)
슬라이딩 윈도, 스택과 큐를 겸함
<자주 하는 연산이 무엇인가라는 질문 하나로 대부분 갈린다. 첨자 접근이 필요하면 배열 쪽, 끝만 쓰면 스택과 큐, 가운데를 자주 건드리고 그 자리를 이미 알고 있으면 이중 연결 리스트다>[원본 보기]
설계 판단
사용자가 편집기에서 한 일을 기록해 두었다가 되돌리기와 다시하기를 지원하려 한다. 어떤 자료구조를 몇 개 쓰겠는가?
풀이 보기
필요한 동작을 먼저 적는다. 되돌리기는 가장 최근에 한 일을 무르는 것이다. "가장 최근"이므로 스택이다.
다시하기까지 하려면 스택 두 개가 필요하다.
새 작업을 하면 '한 일' 스택에 쌓고, '무른 일' 스택은 비운다.
되돌리기: '한 일'에서 하나 꺼내 무르고 그것을 '무른 일'에 쌓는다.
다시하기: '무른 일'에서 하나 꺼내 다시 실행하고 그것을 '한 일'에 쌓는다.
모든 연산이 𝑂(1) 이다. 새 작업을 할 때 '무른 일'을 비우는 것이 핵심이다. 되돌린 뒤 다른 일을 하면 원래의 미래는 사라져야 하기 때문이다.
기록을 최근 100개로 제한하고 싶다면 스택 대신 덱을 쓴다. 뒤로 넣고 뒤에서 꺼내되, 100개를 넘으면 앞에서 버린다. 스택만으로는 "가장 오래된 것 버리기"를 𝑂(1) 에 할 수 없다.
설계 판단
초당 수천 건의 로그를 받아 가장 최근 1분치만 메모리에 두려 한다. 조회는 "최근 것부터 순서대로 훑기"뿐이다. 무엇을 쓰겠는가?
풀이 보기
하는 일이 셋이다. 뒤에 넣기, 앞에서 버리기, 순서대로 훑기. 첨자로 아무 데나 읽는 일은 없다.
뒤에 넣고 앞에서 버리는 것은 큐의 모양이다. 크기 상한이 정해져 있으므로 고정 크기 원형 버퍼가 가장 알맞다.
두 표시를 옮기는 것뿐이라 넣기와 버리기가 𝑂(1) 이다.
메모리를 처음에 한 번만 잡으므로 동적 배열처럼 도중에 크게 옮기는 일이 없다. 초당 수천 건이 들어오는 상황에서 그 튐이 없다는 것이 큰 장점이다.
연속 메모리라 훑기가 캐시에 잘 맞는다.
연결 리스트도 넣기와 버리기가 𝑂(1) 이지만, 건마다 노드를 새로 잡았다 버려야 하고 흩어져 있어 훑기가 느리다. 복잡도 표만 보면 둘이 같아 보이는데 실제로는 원형 버퍼가 낫다. 이 장에서 배운 것이 정확히 그 차이다.
다만 상한이 "1분치"라 개수가 들쭉날쭉하다면 최대치를 넉넉히 잡아야 하고, 그만큼 메모리를 늘 붙들고 있게 된다. 이것이 이 선택이 치르는 값이다.
이 장의 정리
용어
뜻
기억할 점
주소 · 임의 접근
메모리 칸의 번호. 번호를 알면 어느 칸이든 같은 시간
배열 첨자 접근이 𝑂(1) 인 근거
배열
같은 크기 값을 빈틈없이 이어 놓은 것
주소 = 시작 + 첨자 × 크기
캐시 지역성
한 값을 읽으면 옆 값이 함께 온다
같은 𝑂(𝑛) 도 실제 속도가 다른 이유
동적 배열
용량과 크기를 따로 두고 꽉 차면 두 배로 늘린다
뒤에 넣기가 분할상환 𝑂(1)
연결 리스트
값과 다음 주소를 묶은 노드의 사슬
자리를 알면 끼우기 𝑂(1), 찾아가면 𝑂(𝑛)
스택 (LIFO)
한쪽 끝에서만 넣고 뺀다
가장 최근 것을 먼저 처리할 때
큐 (FIFO)
한쪽으로 넣고 반대쪽으로 뺀다
온 순서대로 처리할 때
원형 버퍼
나머지 연산으로 배열 끝을 처음에 잇는다
가득 참과 텅 빔이 같아 보이는 함정
덱
양쪽 끝에서 넣고 뺀다
스택과 큐를 겸한다
이 장의 모든 구조에서 값으로 찾기는 𝑂(𝑛) 이었다. 전부 훑는 것 말고 방법이 없기 때문이다. 다음 장은 그 벽을 깬다. 값에서 자리를 계산해 내면 찾기가 평균 𝑂(1) 이 된다.
해시 테이블
4장의 표에서 한 줄만 끝까지 𝑂(𝑛) 이었다. 값으로 찾기다. 배열이든 연결 리스트든 "이 값이 안에 있는가"를 알려면 전부 훑어보는 수밖에 없었다. 이 장은 그 줄을 평균 𝑂(1) 로 만든다.
발상은 한 줄로 요약된다. 찾아다니지 말고, 값에서 자리를 계산해 내자.
답할 질문은 넷이다. 어떻게 계산하는가, 다른 값이 같은 자리를 받으면 어떻게 하는가, 얼마나 채워도 되는가, 그리고 표가 한 기계에 안 들어가면 어떻게 하는가.
3장에서 점근 표기와 평균 분석·분할상환을, 4장에서 배열과 연결 리스트를 가져온다. 기초수학에서는 확률과 여사건, 그리고 지수함수 𝑒𝑥 의 근사를 쓴다.
자리를 계산한다
학번이 0부터 999 까지인 학생 1000명의 점수를 담는다고 하자. 가장 빠른 방법은 크기 1000짜리 배열을 만들고 학번을 그대로 첨자로 쓰는 것이다. 찾기도 넣기도 지우기도 𝑂(1) 이다. 이런 표를 직접 주소 표(direct address table)라 한다.
문제는 키가 조금만 커지면 이 방법이 무너진다는 것이다.
예제 29
키가 13자리 숫자(예: 상품 바코드)라면 직접 주소 표에 몇 칸이 필요한가? 실제로 담을 상품은 10만 개다.
풀이 보기
13자리 숫자는 1013 가지다. 첨자로 쓰려면 그만큼의 칸이 필요하다.
칸 하나가 8바이트라면 8×1013 바이트, 곧 80 TB 다. 어떤 기계에도 들어가지 않는다.
게다가 그중 실제로 쓰는 칸은 10만 개뿐이다. 1013 칸 중 105 칸, 곧 1억분의 1 만 쓰고 나머지는 비어 있다.
여기서 문제가 정확히 무엇인지 보인다. 있을 수 있는 키의 가짓수는 어마어마한데 실제로 쓰는 것은 조금뿐이라는 것이다. 그러니 "키를 그대로 첨자로" 대신 키를 실제 크기에 맞게 줄여서 첨자로 바꾸면 된다.
그 줄이는 함수가 해시 함수(hash function)다. 크기 𝑚 인 배열을 두고
ℎ:키전체→{0,1,…,𝑚−1}
인 함수를 정한 뒤, 키 𝑘 의 값을 ℎ(𝑘) 번 칸에 둔다. 이 배열을 해시 테이블(hash table), 각 칸을 버킷(bucket)이라 한다.
<키가 무엇이든 해시 함수는 0 부터 m − 1 사이의 수 하나를 내놓고 그 수가 곧 배열 첨자다. 그런데 키의 가짓수가 칸 수보다 많으므로 두 키가 같은 칸을 받는 일을 피할 수 없다>[원본 보기]
가장 흔한 해시 함수는 나눗셈 방법이다. 키를 정수로 본 뒤
ℎ(𝑘)=𝑘mod𝑚
나머지는 반드시 0 부터 𝑚−1 사이이므로 정의역과 치역이 맞는다. 계산은 나눗셈 한 번이다.
예제 30
𝑚=7 이고 ℎ(𝑘)=𝑘mod7 이다. 키 15, 22, 8, 3 은 각각 몇 번 칸으로 가는가?
풀이 보기
나머지를 구하면 된다.
15mod7=1,22mod7=1,8mod7=1,3mod7=3
15, 22, 8 이 모두 1번 칸으로 간다. 셋이 7로 나눈 나머지가 같기 때문이다.
이 상황을 충돌(collision)이라 한다. 이 장의 나머지 절반이 충돌을 다루는 이야기다.
여기서 𝑚 을 잘 고르는 것이 왜 중요한지도 보인다. 만약 키가 모두 7의 배수 근처에 몰려 있다면 𝑚=7 은 최악의 선택이다. 키의 규칙성과 𝑚 의 규칙성이 겹치면 충돌이 몰린다.
좋은 해시 함수의 요건
아무 함수나 되는 것이 아니다. 갖춰야 할 성질이 넷이다.
요건
뜻
어기면 생기는 일
결정성
같은 키는 언제나 같은 값을 낸다
넣은 것을 다시 찾지 못한다
치역이 정의역보다 작다
키 전체보다 칸 수가 적다
직접 주소 표와 같아져 공간이 감당되지 않는다
균등성
키가 칸 전체에 고르게 흩어진다
한 칸에 몰려 찾기가 𝑂(𝑛) 이 된다
빠름
계산이 상수 시간에 끝난다
찾기를 아끼려다 계산에서 그만큼 잃는다
여기에 하나를 더한다. 눈사태 효과(avalanche effect) — 키가 한 비트만 달라져도 결과가 절반쯤 뒤집혀야 한다. 그래야 비슷한 키들이 이웃한 칸으로 몰리지 않는다. 실제로 쓰는 키는 "user_1", "user_2" 처럼 비슷비슷한 경우가 압도적으로 많기 때문에 이 성질이 중요하다.
보안이 걸린 자리라면 요건이 더 붙는다. 해시 값에서 원래 입력을 찾기 어려울 것, 같은 해시 값을 내는 다른 입력을 찾기 어려울 것, 입력과 결과 쌍을 아무리 모아도 함수 자체를 알아내기 어려울 것. 이런 성질을 갖춘 것을 암호학적 해시 함수라 하며 16장에서 따로 다룬다. 여기서 쓰는 해시 함수와는 목적이 다르다. 자료구조용 해시는 빠른 것이 미덕이고 암호학적 해시는 느린 것이 미덕인 경우까지 있다.
예제 31
문자열의 해시로 "모든 글자의 코드 값을 더한 뒤 𝑚 으로 나눈 나머지"를 쓴다면 무엇이 문제인가?
풀이 보기
결정성과 빠름은 만족한다. 문제는 균등성과 눈사태 효과다.
글자를 더하기만 하므로 순서가 다른 같은 글자 묶음은 모두 같은 값이 된다. 'abc', 'acb', 'bca' 가 전부 한 칸으로 간다. 자연어 낱말에는 이런 짝이 아주 많다.
또 영어 소문자 코드는 97부터 122 사이의 좁은 범위에 있다. 길이 10짜리 낱말이면 합이 970에서 1220 사이에 갇힌다. 𝑚 이 1000쯤이면 쓰이는 칸이 250개뿐이고 나머지는 영원히 빈다.
고치는 방법은 자리마다 다른 무게를 주는 것이다. 흔한 방식은 자리마다 상수를 곱해 누적하는 것이다.
ℎ=(ℎ×31+글자)mod𝑚
이러면 순서가 다르면 값이 달라지고, 앞 글자의 영향이 곱셈을 통해 위쪽 비트까지 퍼진다. 31 같은 홀수 소수를 쓰는 것도 규칙성이 겹치지 않게 하기 위해서다.
충돌은 반드시 생긴다
키의 가짓수가 칸 수보다 많으면 서로 다른 두 키가 같은 칸을 받는 일은 반드시 일어난다. 비둘기 𝑛 마리를 둥지 𝑚<𝑛 개에 넣으면 두 마리가 든 둥지가 반드시 있다는 것과 같은 이야기다. 이것은 확률이 아니라 논리다.
그런데 더 중요한 것은 훨씬 적게 넣어도 충돌이 이미 일어난다는 사실이다. 이 감각이 없으면 "칸을 넉넉히 잡으면 충돌 처리는 없어도 되겠지"라고 생각하게 된다.
<칸이 365개인 표에 키를 23개만 넣어도 충돌 확률이 절반을 넘는다. 절반인 182개쯤은 되어야 할 것 같은데 그 8분의 1에서 이미 반반이다>[원본 보기]
예제 32
칸이 365개인 해시 테이블에 키 23개를 넣는다. 각 키가 어느 칸으로 갈 확률이 모두 같다면 충돌이 한 번이라도 일어날 확률은?
풀이 보기
"한 번이라도"가 나왔으므로 여사건을 쓴다. 충돌이 하나도 없을 확률을 구해 1에서 빼면 된다.
첫 키는 어디로 가든 좋다(확률 1). 둘째 키는 첫 키를 피해야 하므로 364/365. 셋째는 둘을 피해야 하므로 363/365. 이런 식이다.
𝑃(충돌없음)=365365⋅364365⋯343365=∏22𝑖=0365−𝑖365
계산하면 약 0.4927 이다. 따라서
𝑃(충돌)=1−0.4927=0.5073
절반을 넘는다. 사람 23명이 모이면 생일이 같은 두 사람이 있을 확률이 절반을 넘는다는 유명한 사실과 같은 계산이라 생일 문제라 부른다.
왜 이렇게 빠른가. 견주는 대상이 키 하나가 아니라 키의 쌍이기 때문이다. 23개에서 둘을 고르는 방법은 (232)=253 가지다. 253번의 기회가 있으면 확률 1/365 짜리 일도 어지간히 일어난다.
예제 33
앞 예제의 계산을 일반화하라. 칸이 𝑚 개일 때 키를 몇 개쯤 넣으면 충돌 확률이 절반이 되는가?
풀이 보기
곱셈을 그대로 다루기 어려우니 근사한다. 𝑥 가 작으면 𝑒−𝑥≈1−𝑥 이므로 1−𝑖/𝑚≈𝑒−𝑖/𝑚 로 바꾼다.
𝑃(충돌없음)≈∏𝑘−1𝑖=0𝑒−𝑖/𝑚=𝑒−1𝑚∑𝑘−1𝑖=0𝑖=𝑒−𝑘(𝑘−1)2𝑚
이것이 1/2 가 되는 자리를 찾는다. 양변에 로그를 취하면
𝑘(𝑘−1)2𝑚=ln2⟹𝑘≈√2𝑚ln2≈1.177√𝑚
𝑚 이 아니라 √𝑚 에 비례한다.𝑚=365 이면 1.177×19.1≈22.5 로 앞 예제와 맞는다.
크기를 실감해 보자. 칸이 100만 개여도 1.177×1000≈1177 개만 넣으면 충돌이 반반이다. 100만 칸에 1177개면 0.1%도 차지 않았는데 그렇다.
결론은 분명하다. 충돌 처리는 선택이 아니라 필수다. 그리고 이 "제곱근 법칙"은 16장에서 해시 함수를 공격하는 방법의 뼈대가 되기도 한다.
충돌을 처리하는 두 방법
<체이닝은 칸마다 목록을 매달아 표 밖으로 넘기고 개방 주소법은 표 안의 빈 칸을 찾아 옆으로 민다. 어느 쪽도 공짜가 아니며 각각 다른 문제를 안고 있다>[원본 보기]
체이닝
체이닝(chaining)은 칸마다 연결 리스트를 하나씩 매다는 것이다. 같은 칸으로 온 키들은 그 목록에 이어 붙인다. 찾기는 "칸을 계산하고 그 목록을 훑기"다.
성능을 정하는 값은 적재율(load factor)이다.
𝛼=든원소수칸수=𝑛𝑚
키가 고르게 흩어진다는 가정 아래 목록 하나의 평균 길이가 정확히 𝛼 다. 그러므로 찾는 키가 없을 때 훑는 원소 수의 기댓값은 𝛼, 칸을 계산하는 비용까지 더하면
1+𝛼
𝛼 를 상수로 유지하면 찾기가 평균 𝑂(1) 이다. 여기서 "평균"이라는 말을 흘려 읽으면 안 된다. 3장에서 말했듯 평균에는 가정이 붙어 있다. 최악의 경우는 모든 키가 한 칸으로 몰리는 것이고 그때는 𝑂(𝑛) 이다.
예제 34
어떤 웹 서비스가 사용자가 보낸 문자열을 키로 해시 테이블에 넣는다. 공격자가 해시 함수를 알고 있다면 무엇을 할 수 있는가?
풀이 보기
해시 함수를 알면 같은 칸으로 가는 키를 마음대로 만들 수 있다. 그런 키를 수만 개 보내면 목록 하나가 수만 개짜리가 된다.
그러면 넣기와 찾기가 모두 𝑂(𝑛) 이 되고, 𝑛 개를 넣는 데 𝑂(𝑛2) 이 든다. 평범해 보이는 요청 몇 번으로 서버가 멈춘다. 이것을 해시 충돌 공격이라 한다.
막는 방법은 해시 함수에 프로그램이 시작할 때마다 달라지는 비밀 값을 섞는 것이다. 같은 프로그램이라도 실행할 때마다 키가 다른 칸으로 가므로 공격자가 미리 충돌 쌍을 만들 수 없다.
이 때문에 여러 언어에서 "해시 테이블을 훑는 순서"가 실행할 때마다 달라진다. 순서에 의존한 코드가 어제는 되다가 오늘 깨지는 일의 흔한 원인이다.
다른 대비책은 목록이 너무 길어지면 그 칸만 균형 트리로 바꾸는 것이다. 그러면 최악이 𝑂(𝑛) 에서 𝑂(log𝑛) 로 내려간다. 6장의 트리가 여기에 쓰인다.
개방 주소법
개방 주소법(open addressing)은 표 밖에 아무것도 두지 않는다. 칸이 차 있으면 정해진 규칙으로 다음 칸을 찾아 간다. 이 과정을 탐사(probing)라 한다. 𝑖 번째 시도에서 보는 칸을 정하는 방식이 셋 있다.
방식
𝑖 번째로 보는 칸
문제
선형 탐사
(ℎ(𝑘)+𝑖)mod𝑚
찬 칸이 덩어리로 뭉친다 (일차 군집)
이차 탐사
(ℎ(𝑘)+𝑐1𝑖+𝑐2𝑖2)mod𝑚
같은 칸에서 출발한 키들이 같은 길을 간다 (이차 군집)
이중 해싱
(ℎ1(𝑘)+𝑖⋅ℎ2(𝑘))mod𝑚
해시를 두 번 계산해야 한다
군집화(clustering)가 개방 주소법의 핵심 문제다. 선형 탐사에서 찬 칸이 이어져 있으면, 그 덩어리 어디로 떨어지든 덩어리 끝까지 걸어야 한다. 그리고 덩어리 끝에 새 키가 앉으면 덩어리가 더 길어진다. 긴 덩어리일수록 더 자라기 쉬워 눈덩이처럼 커진다.
이중 해싱은 ℎ2(𝑘) 가 키마다 다르므로 같은 칸에서 출발해도 서로 다른 길을 간다. 군집이 거의 생기지 않는 대신 계산이 두 배다. ℎ2(𝑘) 는 절대 0 이 되면 안 되고 𝑚 과 서로소여야 한다. 그래야 모든 칸을 빠짐없이 훑는다.
선형 탐사가 여전히 널리 쓰이는 이유는 4장에서 배운 것이다. 바로 옆 칸을 보므로 캐시에 맞는다. 군집 때문에 탐사 횟수가 늘어도, 한 번의 탐사가 훨씬 싸서 실제로는 더 빠른 경우가 많다.
예제 35
이중 해싱에서 𝑚=10, ℎ2(𝑘)=2 로 두었다. 무엇이 잘못되는가? 𝑚 을 어떻게 정하면 이 문제가 사라지는가?
풀이 보기
탐사하는 칸은 ℎ1(𝑘),ℎ1(𝑘)+2,ℎ1(𝑘)+4,… 를 10으로 나눈 나머지다. 2를 계속 더하고 10으로 나누면 나머지가 짝수만(또는 홀수만) 나온다.
곧 칸의 절반을 영원히 보지 못한다. 그쪽이 텅 비어 있어도 "자리가 없다"고 판단하고, 그 절반에 이미 든 키는 찾지 못한다.
원인은 gcd(ℎ2(𝑘),𝑚)=gcd(2,10)=2≠1 이라는 것이다. ℎ2(𝑘) 를 계속 더해 모든 칸을 훑으려면 ℎ2(𝑘) 와 𝑚 이 서로소여야 한다.
가장 간편한 처방은 𝑚 을 소수로 잡는 것이다. 그러면 1≤ℎ2(𝑘)<𝑚 인 어떤 값이든 𝑚 과 서로소가 된다. ℎ2(𝑘)=0 만 막으면 된다.
다른 처방은 𝑚 을 2의 거듭제곱으로 두고 ℎ2(𝑘) 를 항상 홀수로 만드는 것이다. 이러면 나머지 연산을 비트 연산으로 대신할 수 있어 빠르다. 실제 구현들이 자주 쓰는 조합이다.
예제 36
𝑚=7, ℎ(𝑘)=𝑘mod7, 선형 탐사를 쓴다. 15, 22, 8, 29 를 차례로 넣어라. 각각 몇 번 탐사했는가?
풀이 보기
넷 모두 𝑘mod7=1 이다. 최악의 경우를 일부러 만든 예다.
15: 1번 칸이 비었다. 탐사 1번.
22: 1번이 찼다 → 2번이 비었다. 탐사 2번.
8: 1, 2 가 찼다 → 3번. 탐사 3번.
29: 1, 2, 3 이 찼다 → 4번. 탐사 4번.
표는 [,15,22,8,29,,] 가 된다. 1번부터 4번까지가 하나의 덩어리다.
여기서 군집화가 눈에 보인다. 𝑘 번째 키가 𝑘 번 탐사한다. 넷을 넣는 데 1+2+3+4=10 번이고, 𝑛 개면 𝑛(𝑛+1)/2, 곧 Θ(𝑛2) 이다.
이것은 해시 함수가 나빠서 생긴 극단이다. 그래도 교훈은 남는다. 같은 칸에 몰리는 정도가 조금만 심해져도 개방 주소법은 이차식으로 나빠진다.
<지운 자리를 그냥 비우면 탐사가 거기서 멈춰 뒤에 밀려 있던 키를 찾지 못한다. 빈 칸이 아니라 ‘지웠음’ 표시를 남겨야 탐사가 계속된다>[원본 보기]
개방 주소법에서 삭제는 생각보다 까다롭다. 지운 칸을 그냥 비우면, 그 칸을 지나쳐 뒤에 앉았던 키를 찾을 때 탐사가 빈 칸에서 멈춰 "없다"고 답해 버린다. 그래서 "여기 있었지만 지웠다"는 표시를 남긴다. 이 표시를 묘비(tombstone)라 한다. 찾기는 묘비를 지나쳐 계속 가고, 넣기는 묘비 자리를 다시 쓸 수 있다.
묘비가 쌓이면 표가 실제보다 꽉 찬 것처럼 굴어 느려진다. 그래서 묘비가 일정 비율을 넘으면 표를 다시 짓는다.
설계 판단
다음 두 상황에서 체이닝과 개방 주소법 중 무엇을 고르겠는가? (1) 값이 큰 구조체이고 삭제가 잦다. (2) 값이 4바이트 정수이고 넣기와 찾기만 한다. 개수는 미리 안다.
풀이 보기
두 방식이 갈리는 지점은 셋이다. 삭제가 잦은가, 값이 큰가, 적재율을 낮게 유지할 수 있는가.
(1) 체이닝. 삭제가 잦으면 개방 주소법은 묘비가 계속 쌓여 성능이 떨어지고 주기적으로 표를 다시 지어야 한다. 체이닝은 목록에서 노드 하나를 떼면 끝이라 삭제가 깔끔하다. 값이 크면 노드마다 붙는 주소 8바이트는 상대적으로 작은 값이다.
(2) 개방 주소법. 값이 4바이트인데 노드마다 8바이트짜리 주소를 매다는 것은 손해가 크다. 삭제가 없으니 묘비 문제도 없다. 개수를 미리 아니 적재율을 0.5쯤으로 낮게 잡아 표를 한 번에 만들 수 있고, 그러면 평균 탐사가 2번이면서 모든 접근이 연속된 메모리에서 일어난다.
정리하면 이렇다. 삭제가 잦거나 값이 크면 체이닝, 값이 작고 적재율을 낮게 유지할 수 있으면 개방 주소법. 요즘 라이브러리들이 개방 주소법 쪽으로 기우는 것은 캐시의 영향력이 계속 커졌기 때문이다.
적재율과 리해싱
<체이닝은 α 가 1 을 넘어도 완만하게 나빠지지만 개방 주소법은 1 근처에서 수직으로 치솟는다. 그래서 문턱을 정해 두고 넘으면 표를 키운다>[원본 보기]
찾는 키가 없을 때의 평균 탐사 횟수는 대략 이렇다(키가 고르게 흩어진다는 가정 아래).
방식
평균 탐사 횟수
𝛼=0.5
𝛼=0.9
𝛼→1
체이닝
1+𝛼
1.5
1.9
완만히 늘어난다
개방 주소법
1/(1−𝛼)
2
10
무한대로 발산한다
두 줄의 차이가 결정적이다. 체이닝은 𝛼 가 1을 넘어도 동작하고 성능이 완만하게 나빠진다. 개방 주소법은 표가 가득 차면 넣을 자리가 아예 없다.
그래서 적재율에 문턱을 정해 두고, 넘으면 칸 수를 늘리고 모든 원소를 다시 넣는다. 이것을 리해싱(rehashing)이라 한다. 문턱은 체이닝이면 1 근처, 개방 주소법이면 0.5에서 0.75 사이를 흔히 쓴다.
여기서 중요한 것은 "다시 넣는다"가 "그대로 옮긴다"가 아니라는 점이다. ℎ(𝑘)=𝑘mod𝑚 에서 𝑚 이 바뀌면 같은 키의 자리가 달라진다. 옛 자리를 그대로 복사하면 표가 망가진다.
예제 38
칸이 8개인 해시 테이블에 6개가 들어 있다. 문턱이 0.75 라면 지금 무슨 일이 일어나는가? 리해싱 비용은 넣기 한 번당 얼마인가?
풀이 보기
적재율은 𝛼=6/8=0.75 로 문턱에 닿았다. 칸을 16개로 늘리고 6개를 새 𝑚=16 으로 다시 해시해 넣는다.
따라서 넣기 한 번의 분할상환 비용은 𝑂(1) 이다. 리해싱이 있어도 "해시 테이블의 넣기는 평균 상수 시간"이라는 말이 유지된다.
여기에는 두 가지 '평균'이 겹쳐 있다. 하나는 키가 고르게 흩어진다는 확률 가정이고, 다른 하나는 여러 번의 연산에 걸친 분할상환이다. 둘은 다른 것이며 둘 다 필요하다.
설계 판단
응답 시간이 밀리초 단위로 일정해야 하는 서비스가 있다. 큰 해시 테이블의 리해싱이 문제가 될 수 있는가? 있다면 어떻게 하겠는가?
풀이 보기
문제가 된다. 원소가 1000만 개인 표를 리해싱하면 그 한 번의 넣기가 초 단위로 걸린다. 분할상환 비용이 상수여도 그 한 번을 겪는 요청은 죽는다.
분할상환은 "여럿을 합쳐서 보면 싸다"는 보장이지 "매번 싸다"는 보장이 아니다. 이 구분이 실무에서 사고를 가른다.
대응은 셋이다.
미리 크게 잡는다. 담을 개수를 알거나 어림할 수 있으면 처음부터 그만한 표를 만든다. 가장 간단하고 가장 효과가 크다.
조금씩 옮긴다. 새 표를 만들되 한 번에 다 옮기지 않고, 이후 모든 연산에서 옛 표의 원소를 몇 개씩 옮긴다. 찾기는 두 표를 모두 봐야 한다. 최악 한 번의 비용이 상수로 내려간다.
표를 잘게 쪼갠다. 하나의 큰 표 대신 여러 개의 작은 표로 나누면 리해싱이 그중 하나에서만 일어난다. 다음 절의 분산 해시로 이어지는 발상이다.
어느 쪽이든 최악 지연을 줄이는 대가로 평균 성능이나 코드 복잡도를 내준다. 무엇을 지켜야 하는지 정해야 고를 수 있다.
분산 해시와 일관 해싱
표가 한 기계의 메모리에 들어가지 않으면 여러 기계에 나눠 담아야 한다. 각 기계가 해시 값의 한 구간을 맡는 것이다. 이것이 분산 해시다. 이제 칸 하나의 크기를 늘리는 대신 버킷의 개수 자체를 늘리고 줄인다.
가장 소박한 방법은 기계번호=ℎ(𝑘)mod𝑁 (𝑁 은 기계 수)이다. 그런데 이 방법에는 치명적인 약점이 있다.
예제 40
기계가 4대에서 5대로 늘었다. ℎ(𝑘)mod𝑁 방식이면 키의 몇 %가 자리를 옮겨야 하는가?
풀이 보기
키 𝑘 가 자리를 지키려면 ℎ(𝑘)mod4=ℎ(𝑘)mod5 여야 한다. ℎ(𝑘) 를 고르게 퍼진 큰 수라고 보면 이 조건이 맞을 확률은 대략 1/5 이다.
곧 약 80%의 키가 다른 기계로 옮겨져야 한다. 기계 하나를 더했을 뿐인데 자료를 거의 전부 옮기는 것이다.
옮기는 동안 서비스는 느려지거나 멈추고, 옮기는 중에 또 한 대가 고장 나면 상황이 겹친다. 기계 수를 바꾸는 일이 사실상 불가능해진다.
우리가 원하는 것은 "기계 하나를 더하면 1/𝑁 만 옮기는" 방식이다. 그것이 다음의 일관 해싱이다.
일관 해싱(consistent hashing)의 발상은 이렇다. 키와 기계를 같은 해시 값의 고리 위에 얹고, 키는 고리를 시계 방향으로 돌다가 처음 만나는 기계가 맡는다.
<기계를 하나 끼워 넣으면 그 기계 바로 앞 구간의 키만 옮겨 간다. 나머지 키는 만나는 기계가 그대로이므로 자리를 지킨다>[원본 보기]
기계를 하나 더하면 새 기계와 그 앞 기계 사이의 구간에 있는 키만 옮긴다. 평균 1/𝑁 이다. 기계 하나가 죽어도 마찬가지로 그 기계가 맡던 구간만 다음 기계로 넘어간다.
문제가 하나 남는다. 기계가 고리 위에 고르게 놓이지 않으면 맡는 구간의 크기가 들쭉날쭉해진다. 어떤 기계는 놀고 어떤 기계는 터진다. 자료 자체가 고르게 퍼지지 않았을 때도 같은 일이 생긴다.
해결책은 기계 하나를 고리 위의 여러 자리에 올리는 것이다. 기계 3대를 구간 셋으로 나누는 대신, 각 기계에 이름표를 여러 개 붙여 𝐵1𝑅1,𝐵2𝑅1,𝐵3𝑅1,𝐵1𝑅2,𝐵2𝑅2,𝐵3𝑅2 처럼 잘게 나눈다. 조각이 많아질수록 각 기계가 맡는 총량이 고르게 된다. 이 이름표들을 가상 노드라 한다.
옮기는 동안의 처리도 매끄럽게 할 수 있다. 키를 즉시 옮기는 대신 "현재 담당 기계"와 "이웃 기계"가 함께 요청을 받게 하는 것이다.
기계가 새로 들어오면, 그 구간을 맡던 옛 기계는 키를 곧바로 지우지 않고 새 기계의 준비가 끝날 때까지 읽기 요청을 대신 처리한다.
기계 하나가 장애 중이면 이웃 기계가 그 몫을 임시로 받는다.
이렇게 하면 자료가 옮겨 가는 중에도 읽기가 끊기지 않는다. 대신 한동안 같은 키가 두 곳에 존재하므로 쓰기를 어느 쪽에 반영할지 정해 두어야 한다. 옮기는 비용을 줄인 값이 여기서 나온다.
설계 판단
기계 3대에 일관 해싱으로 자료를 나눴는데 한 대에 자료가 몰린다. 가상 노드를 기계당 100개로 늘리면 무엇이 좋아지고 무엇이 나빠지는가?
풀이 보기
기계가 3대면 고리 위의 자리가 3개뿐이라, 자리가 어디에 떨어지느냐에 따라 구간 길이가 크게 달라진다. 자리 3개를 무작위로 놓았을 때 가장 긴 구간이 평균의 두세 배가 되는 일은 아주 흔하다.
가상 노드를 100개씩 두면 고리 위의 자리가 300개가 된다. 기계 하나가 맡는 총량은 100개 조각의 합이고, 여러 조각을 더하면 값이 평균 가까이로 모인다. 조각 수가 늘수록 기계 간 편차가 줄어드는 것이다.
부수 효과도 있다. 기계 하나가 죽었을 때 그 몫이 한 이웃에게 통째로 가지 않고 여러 기계에 흩어진다. 살아남은 기계 하나가 두 배 부하를 받아 연쇄로 죽는 일을 막아 준다.
값도 있다. 고리에 올릴 자리가 300개이므로 어느 기계가 맡는지 찾는 표가 100배 커지고 그 표를 관리하는 비용도 는다. 기계를 더하고 뺄 때 갱신할 항목도 100배다.
실무에서는 기계당 100에서 수백 개 사이를 쓴다. 편차는 조각 수의 제곱근에 반비례해 줄어들므로 어느 지점부터는 더 늘려도 얻는 것이 별로 없다.
한 가지 더. 자료 자체가 한 키에 몰리는 경우(예: 인기 상품 하나에 요청이 집중)는 가상 노드로 풀 수 없다. 그때는 키를 쪼개거나 앞단에 따로 캐시를 두어야 한다.
이 장의 정리
해시 테이블은 부품 네 개의 조합이다. 어느 부품을 바꾸면 무엇이 좋아지고 무엇이 나빠지는지가 이 장의 내용 전부다.
<해시 함수 · 버킷 배열 · 충돌 처리 · 적재율 관리 넷을 정하면 해시 테이블이 정해진다. 평균 O(1) 은 좋은 해시 함수와 낮은 적재율을 값으로 치르고 얻는 것이다>[원본 보기]
용어
뜻
기억할 점
해시 함수
키를 0 부터 𝑚−1 사이의 수로 보내는 함수
결정성 · 균등성 · 빠름 · 눈사태 효과
버킷
해시 테이블의 한 칸
칸 수가 𝑚
충돌
서로 다른 키가 같은 칸을 받는 일
반드시 생기며 생각보다 훨씬 일찍 생긴다
생일 문제
칸이 𝑚 개면 약 1.177√𝑚 개에서 충돌 확률이 절반
𝑚 이 아니라 √𝑚 에 비례한다
체이닝
칸마다 목록을 매단다
평균 탐사 1+𝛼, 최악 𝑂(𝑛)
개방 주소법
표 안의 빈 칸을 찾아 넣는다
평균 탐사 1/(1−𝛼), 군집화 주의
적재율 𝛼
든 원소 수 ÷ 칸 수
이 값 하나가 성능을 정한다
리해싱
문턱을 넘으면 칸을 늘리고 전부 다시 넣는다
분할상환 𝑂(1) 이지만 그 한 번은 길다
묘비
개방 주소법에서 지웠음을 남기는 표시
빈 칸을 만들면 탐사가 끊긴다
일관 해싱
키와 기계를 고리에 얹고 시계 방향으로 만나는 기계가 맡는다
기계를 더해도 1/𝑁 만 옮긴다
해시 테이블이 못 하는 일도 분명히 해 두자. 순서가 없다. 가장 작은 키를 찾거나, 어떤 범위 안의 키를 모두 꺼내거나, 정렬된 순서로 훑는 일을 할 수 없다. 그런 일이 필요하면 다음 장의 트리를 써야 한다.
트리와 힙
5장의 해시 테이블은 "이 키가 있는가"에는 평균 𝑂(1) 로 답하지만 순서를 전혀 모른다. 가장 작은 키가 무엇인지, 100과 200 사이의 키가 몇 개인지 물으면 전부 훑어보는 수밖에 없다.
이 장의 자료구조들은 순서를 지키면서도 𝑂(log𝑛) 에 답한다. 값을 일렬로 늘어놓는 대신 갈라지는 모양으로 늘어놓기 때문이다. 한 번 물어볼 때마다 후보가 절반씩 줄어드는 것이 그 모양에서 나온다.
답할 질문은 넷이다. 트리란 무엇인가, 왜 균형이 깨지면 못 쓰게 되는가, 어떻게 균형을 유지하는가, 그리고 가장 큰 것 하나만 필요할 때는 어떻게 하는가.
3장의 점근 표기와 재귀, 4장의 배열·연결 리스트·스택·큐를 그대로 쓴다.
트리의 말
트리(tree)는 노드들이 부모와 자식 관계로 이어진 구조다. 정확히 말하면 순환이 없고 모든 노드가 이어져 있는 구조인데, 그림으로 보는 편이 빠르다.
<뿌리는 하나, 잎은 자식이 없는 노드, 깊이는 뿌리에서 내려온 간선 수, 높이는 가장 깊은 잎까지의 깊이다. 어떤 노드 아래를 통째로 떼어 내면 그것도 다시 트리다>[원본 보기]
말
뜻
노드 · 간선
값이 담긴 자리와 그것을 잇는 선
뿌리(root)
부모가 없는 단 하나의 노드
부모 · 자식
간선으로 이어진 두 노드 중 위쪽과 아래쪽
잎(leaf)
자식이 없는 노드
차수(degree)
그 노드의 자식 수
깊이(depth)
뿌리에서 그 노드까지의 간선 수. 뿌리는 0
높이(height)
그 노드에서 가장 깊은 잎까지의 간선 수. 트리의 높이는 뿌리의 높이
부분트리(subtree)
어떤 노드와 그 아래 전부. 그 자체로 다시 트리다
부분트리가 다시 트리라는 것이 이 장 전체를 관통한다. 트리를 다루는 알고리즘이 거의 전부 재귀로 적히는 이유가 이것이다. "왼쪽 부분트리에 대해 같은 일을 하고, 오른쪽 부분트리에 대해 같은 일을 하고, 자기 것을 처리한다"가 기본 꼴이다.
노드가 𝑛 개인 트리의 간선은 언제나 𝑛−1 개다. 뿌리를 뺀 모든 노드가 부모와 이어지는 간선을 정확히 하나씩 갖고, 모든 간선이 그렇게 짝지어지기 때문이다.
이진트리
이진트리(binary tree)는 자식이 많아야 둘인 트리다. 두 자식을 왼쪽과 오른쪽으로 구분하며, 자식이 하나뿐일 때도 그것이 왼쪽인지 오른쪽인지가 다른 트리다.
모양에 이름이 둘 붙어 있다.
포화 이진트리 — 모든 층이 빈틈없이 꽉 찬 것. 높이가 ℎ 면 노드가 정확히 2ℎ+1−1 개다.
완전 이진트리(complete binary tree) — 잎의 깊이가 ℎ 아니면 ℎ−1 이고, 마지막 층은 왼쪽부터 빈틈없이 채워진 것.
완전 이진트리가 중요한 이유는 뒤의 힙에서 드러난다. 빈틈이 없으면 배열로 적을 수 있기 때문이다.
예제 42
노드가 𝑛 개인 이진트리의 높이는 최소 얼마이고 최대 얼마인가?
풀이 보기
최대부터 보자. 층마다 노드를 하나씩만 두면 한 줄로 늘어선 모양이 된다. 이때 높이는 𝑛−1 이다. 사실상 연결 리스트다.
최소는 층을 빈틈없이 채울 때다. 깊이 𝑑 인 층에는 노드가 많아야 2𝑑 개이므로, 높이 ℎ 인 트리의 노드 수는
𝑛≤1+2+4+⋯+2ℎ=2ℎ+1−1
양변을 정리하면 2ℎ+1≥𝑛+1, 곧 ℎ≥log2(𝑛+1)−1 이다. 높이는 정수이므로 최소 높이는 ⌈log2(𝑛+1)⌉−1, 간단히 말해 약 log2𝑛 이다. 여기서 ⌈𝑥⌉ 는 𝑥이상인 가장 작은 정수를 뜻하고(천장), 반대로 ⌊𝑥⌋ 는 𝑥이하인 가장 큰 정수를 뜻한다(바닥). 곧 ⌈2.1⌉=3, ⌊2.9⌋=2 다. 이 문서에서 나눗셈의 몫을 적을 때 계속 쓴다.
𝑛=106 이면 최소 높이가 약 20 이고 최대 높이가 999999 다. 같은 개수의 노드로 만든 트리인데 높이가 5만 배 차이 난다.
이 장의 나머지 절반은 이 차이를 다룬다. 트리 연산의 비용이 대부분 높이에 비례하기 때문이다.
순회 — 언제 자기를 처리하는가
트리의 모든 노드를 한 번씩 방문하는 것을 순회(traversal)라 한다. 이진트리에서 할 일은 셋이다. 왼쪽 가기, 오른쪽 가기, 자기 처리하기. 순서를 정하는 방법은 3!=6 가지인데, 왼쪽을 오른쪽보다 먼저 간다는 관습을 두면 셋으로 줄어든다.
<같은 트리를 네 가지 순서로 훑은 결과다. 자기를 언제 처리하느냐가 이름이 된다. 중위 순회의 결과가 오름차순인 것은 이 트리가 이진 탐색 트리이기 때문이다>[원본 보기]
이름
순서
주로 쓰는 곳
전위(preorder)
자기 → 왼쪽 → 오른쪽
트리를 그대로 복사하거나 파일로 적을 때
중위(inorder)
왼쪽 → 자기 → 오른쪽
이진 탐색 트리를 정렬된 순서로 훑을 때
후위(postorder)
왼쪽 → 오른쪽 → 자기
자식을 다 처리한 뒤 자기를 정리할 때. 트리 지우기, 크기 계산
코드는 세 줄의 순서만 바꾸면 된다.
↓ python
def 중위(노드):
if 노드 is None: return
중위(노드.왼쪽) # 이 줄과
처리(노드) # 이 줄의
중위(노드.오른쪽) # 순서만 바꾸면 전위 · 후위가 된다
노드마다 정확히 한 번씩 방문하므로 시간은 Θ(𝑛) 이다. 공간은 재귀 깊이만큼, 곧 트리의 높이에 비례해 𝑂(ℎ) 이다. 3장에서 본 호출 스택의 비용이다.
네 번째 순서도 있다. 레벨 순회는 위층부터 왼쪽에서 오른쪽으로 훑는 것인데, 재귀로는 나오지 않고 4장의 큐가 필요하다. 뿌리를 큐에 넣고, 하나 꺼내 처리한 뒤 그 자식들을 큐에 넣기를 반복한다. 9장의 너비 우선 탐색이 정확히 이 절차다.
예제 43
이진트리의 높이를 구하는 절차를 쓰고 복잡도를 구하라.
풀이 보기
말로 먼저 적는다. 어떤 노드의 높이는 두 자식의 높이 중 큰 것에 1 을 더한 값이다. 자식의 답을 알아야 자기 답이 나오므로 후위 순회다.
바닥 조건을 정해야 한다. 빈 트리의 높이를 −1 로 두면 잎의 높이가 max(−1,−1)+1=0 이 되어 "잎의 높이는 0" 이라는 정의와 맞는다.
↓ python
def 높이(노드):
if 노드 is None: return -1
return 1 + max(높이(노드.왼쪽), 높이(노드.오른쪽))
노드마다 정확히 한 번 방문하므로 시간은 Θ(𝑛) 이다. 공간은 재귀 깊이만큼이라 𝑂(ℎ) 이고, 치우친 트리라면 𝑂(𝑛) 까지 간다.
여기서 바닥 조건을 0 으로 잘못 두면 모든 높이가 1 씩 커진다. 이런 오차는 트리 코드에서 가장 흔한 버그이므로, 노드 하나짜리 트리를 손으로 넣어 검산하는 습관이 필요하다.
예제 44
어떤 이진트리의 전위 순회가 5,3,1,4,8,7, 중위 순회가 1,3,4,5,7,8 이다. 트리를 복원하라.
풀이 보기
두 순회의 성질을 각각 쓴다. 전위의 첫 원소는 언제나 뿌리이고, 중위에서 뿌리의 왼쪽은 전부 왼쪽 부분트리, 오른쪽은 전부 오른쪽 부분트리다.
전위의 첫 원소가 5 이므로 뿌리는 5 다. 중위에서 5 를 찾으면 왼쪽이 1,3,4, 오른쪽이 7,8 이다.
왼쪽 부분트리는 노드가 3개이므로 전위에서 5 다음의 3개, 곧 3,1,4 가 그 전위 순회다. 여기에 같은 논법을 되풀이한다. 뿌리는 3, 그 중위 1,3,4 에서 왼쪽은 1, 오른쪽은 4 다.
오른쪽 부분트리의 전위는 8,7, 중위는 7,8 이다. 뿌리는 8 이고 7 은 그 왼쪽이다.
복원한 트리는 뿌리 5 아래 왼쪽에 3(자식 1과 4), 오른쪽에 8(왼쪽 자식 7)이다.
전위와 후위만 주어지면 복원할 수 없다. 자식이 하나뿐인 노드에서 그것이 왼쪽인지 오른쪽인지 알 방법이 없기 때문이다. 중위가 필요한 이유가 이것이다.
이진 탐색 트리
이진 탐색 트리(binary search tree, BST)는 이진트리에 규칙 하나를 더한 것이다.
어떤 노드의 왼쪽 부분트리에 있는 모든 값은 그 노드보다 작고, 오른쪽 부분트리의 모든 값은 그 노드보다 크다.
"왼쪽 자식보다 크다"가 아니라 "왼쪽 부분트리 전체보다 크다"는 것이 중요하다. 이 규칙 덕분에 값 하나를 견줄 때마다 후보의 절반이 사라진다.
찾기는 절차 그대로다. 찾는 값이 지금 노드보다 작으면 왼쪽으로, 크면 오른쪽으로 내려간다. 같으면 찾은 것이고, 내려갈 곳이 없으면 없는 것이다.
↓ python
def 찾기(노드, 키):
if 노드 is None or 노드.키 == 키: return 노드
if 키 < 노드.키: return 찾기(노드.왼쪽, 키)
else: return 찾기(노드.오른쪽, 키)
한 번 견줄 때마다 한 층 내려가므로 비용은 트리의 높이에 비례한다. 균형이 잡혀 있으면 𝑂(log𝑛), 아니면 𝑂(𝑛) 이다.
넣기도 같다. 찾기를 그대로 하다가 내려갈 곳이 없어진 자리에 새 노드를 매단다. 그래서 새 노드는 언제나 잎으로 들어간다.
지우기는 세 경우로 나뉜다.
잎이면 그냥 뗀다.
자식이 하나면 그 자식을 자기 자리에 올린다.
자식이 둘이면 곧바로 뗄 수 없다. 대신 중위 순회에서 바로 다음에 오는 값(오른쪽 부분트리의 최솟값)을 찾아 그 값을 이 자리에 덮어쓰고, 원래 있던 그 노드를 지운다.
셋째 경우가 왜 옳은지 보자. 중위 후속자는 "이 노드보다 큰 값 중 가장 작은 것"이므로 그 자리에 놓아도 규칙이 깨지지 않는다. 그리고 그 노드는 왼쪽 자식이 없으므로(있으면 그것이 더 작았을 것이다) 1번이나 2번 경우로 환원된다.
예제 45
이진 탐색 트리를 중위 순회하면 왜 반드시 오름차순이 나오는가? 노드 수에 대한 귀납법으로 보여라.
풀이 보기
보일 것은 "노드가 𝑛 개인 어떤 이진 탐색 트리를 중위 순회하면 결과가 오름차순"이다.
바닥.𝑛=0 이면 결과가 빈 나열이고 오름차순이다. 𝑛=1 이면 원소 하나뿐이라 역시 오름차순이다.
귀납 단계. 노드가 𝑛 개인 트리를 보자. 뿌리를 𝑟, 왼쪽 부분트리를 𝐿, 오른쪽을 𝑅 이라 하면 𝐿 과 𝑅 은 각각 노드가 𝑛 개보다 적은 이진 탐색 트리다. 귀납 가정에 따라 𝐿 의 중위 결과와 𝑅 의 중위 결과가 각각 오름차순이다.
중위 순회는 이 셋을 𝐿 의 결과, 𝑟, 𝑅 의 결과 순서로 이어 붙인 것이다. 이어 붙인 것이 오름차순이려면 두 이음매가 맞아야 한다.
첫 이음매는 "𝐿 의 마지막 값 <𝑟" 인데, 이진 탐색 트리의 규칙이 𝐿 의 모든 값이 𝑟 보다 작다고 말하므로 성립한다. 둘째 이음매도 같은 이유로 성립한다.
그러므로 𝑛 개에서도 성립하고, 귀납법에 따라 모든 𝑛 에서 성립한다.
증명이 쓴 것은 "왼쪽 자식보다"가 아니라 "왼쪽 부분트리 전체보다"라는 규칙이다. 규칙을 "자식과만 견주는" 것으로 약하게 하면 이음매가 깨지고 중위 순회가 정렬되지 않는다. 규칙을 왜 그렇게 세웠는지가 이 증명에 들어 있다.
예제 46
뿌리가 8 이고 왼쪽에 3(자식 1과 6, 6의 자식은 4와 7), 오른쪽에 10(오른쪽 자식 14, 14의 왼쪽 자식 13)인 이진 탐색 트리에서 3 을 지워라.
풀이 보기
3 은 자식이 둘(1과 6)이므로 세 번째 경우다. 중위 후속자를 찾는다.
3 의 오른쪽 부분트리는 6(자식 4와 7)이다. 그중 최솟값은 왼쪽으로 계속 내려가서 만나는 4 다.
4 를 3 의 자리에 덮어쓴다. 그러면 트리는 뿌리 8 의 왼쪽이 4 이고 4 의 자식이 1과 6 이 된다.
이제 원래 자리의 4 를 지운다. 4 는 잎이므로 그냥 뗀다. 6 의 왼쪽 자식이 없어졌다.
결과를 중위 순회로 확인하자. 1,4,6,7,8,10,13,14 로 여전히 오름차순이다. 중위 순회가 정렬되어 있는지 보는 것이 이진 탐색 트리 연산의 가장 확실한 검산법이다.
4 대신 중위 선행자(왼쪽 부분트리의 최댓값, 여기서는 1)를 써도 똑같이 옳다. 어느 쪽을 쓸지는 구현하는 사람의 선택이며, 둘을 번갈아 쓰면 트리가 한쪽으로 치우치는 것을 조금 늦출 수 있다.
<같은 일곱 값을 넣었는데 넣은 순서가 달라 높이가 2 와 6 으로 갈렸다. 오른쪽도 이진 탐색 트리의 규칙은 하나도 어기지 않았다. 규칙만으로는 모양을 보장하지 못한다는 것이 문제다>[원본 보기]
여기가 이 장에서 가장 중요한 대목이다. 이진 탐색 트리의 규칙은 모양을 아무것도 보장하지 않는다. 정렬된 자료를 순서대로 넣으면 트리가 한 줄로 늘어서고, 모든 연산이 𝑂(𝑛) 이 된다.
그리고 정렬된 자료를 순서대로 넣는 일은 실무에서 예외가 아니라 기본값이다. 날짜순 기록, 자동 증가하는 식별자, 정렬된 파일을 읽어 들이기 — 전부 최악의 입력이다. 그래서 규칙을 하나 더 붙여 모양을 강제하는 트리들이 나왔다.
AVL 트리 — 회전으로 균형 잡기
AVL 트리는 이진 탐색 트리에 조건을 하나 더 건다.
모든 노드에서 왼쪽 부분트리와 오른쪽 부분트리의 높이 차가 1 이하다.
이 차이(왼쪽 높이 − 오른쪽 높이)를 균형 인수(balance factor)라 하고, 값이 −1,0,1 중 하나여야 한다.
이 조건만으로 높이가 𝑂(log𝑛) 로 묶인다. 높이 ℎ 인 AVL 트리가 가질 수 있는 최소 노드 수를 𝑁(ℎ) 라 하면, 한쪽 부분트리는 높이 ℎ−1, 다른 쪽은 ℎ−2 이므로
𝑁(ℎ)=𝑁(ℎ−1)+𝑁(ℎ−2)+1
피보나치 수열과 같은 꼴이라 𝑁(ℎ) 가 지수적으로 자란다. 뒤집어 말하면 ℎ 는 𝑛 의 로그로 묶이며, 실제로 ℎ<1.44log2(𝑛+2) 임이 알려져 있다. 완전히 균형 잡힌 트리보다 44% 이상 높아지지 않는다는 뜻이다.
넣거나 지우면 균형이 깨질 수 있다. 그때 이웃한 세 노드의 자리를 바꿔 균형을 되찾는데, 이것을 회전(rotation)이라 한다. 기준이 되는 세 노드는 이렇게 정한다.
𝑧 — 넣거나 지운 자리에서 뿌리로 올라가는 길에 처음 만나는 불균형 노드
𝑦 — 𝑧 의 두 자식 중 높이가 큰 쪽
𝑥 — 𝑦 의 두 자식 중 높이가 큰 쪽
그러면 규칙이 한 줄로 정리된다. 𝑥←𝑦←𝑧 가 같은 방향으로 늘어서 있으면 한 번 돌리고, 꺾여 있으면 두 번 돌린다.
<y 가 z 의 자리로 올라가고 z 는 y 의 반대쪽 자식으로 내려온다. 이때 y 가 원래 데리고 있던 반대쪽 부분트리만 z 의 빈 자리로 옮겨 간다. 나머지는 그대로다>[원본 보기]
이름
언제
무엇을 하는가
LL 회전
𝑥 와 𝑦 가 둘 다 왼쪽 자식
𝑧 를 오른쪽으로 한 번 돌린다
RR 회전
𝑥 와 𝑦 가 둘 다 오른쪽 자식
𝑧 를 왼쪽으로 한 번 돌린다
LR 회전
𝑦 는 왼쪽, 𝑥 는 오른쪽 자식
𝑦 를 왼쪽으로 돌려 편 뒤 𝑧 를 오른쪽으로 돌린다
RL 회전
𝑦 는 오른쪽, 𝑥 는 왼쪽 자식
𝑦 를 오른쪽으로 돌려 편 뒤 𝑧 를 왼쪽으로 돌린다
코드로 옮기면 이렇다. 𝑧 자신을 가리키는 것이 this 이고, parent.change 는 부모가 가리키던 자식을 새것으로 바꿔 끼우는 일이다.
↓ python
def rotate_ll():
parent.change(this, this.left)
left.parent, parent = this.parent, left
left.right, left = this, left.right
def rotate_rr():
parent.change(this, this.right)
right.parent, parent = this.parent, right
right.left, right = this, right.left
두 함수 모두 정해진 개수의 화살표만 고치므로 𝑂(1) 이다. 오른쪽 항을 먼저 다 계산한 뒤 왼쪽에 대입하는 파이썬의 동시 대입이라, 값이 덮이기 전에 필요한 것을 붙잡아 둔다. 4장의 리스트 뒤집기에서 본 "끊기기 전에 붙잡는다"와 같은 이야기다.
<꺾인 모양은 한 번 돌려도 반대쪽으로 꺾일 뿐이다. 아래쪽을 먼저 돌려 한 방향으로 편 다음 전체를 돌려야 균형이 잡힌다>[원본 보기]
꺾여 있을 때는 아래쪽을 먼저 펴서 같은 방향으로 만든 뒤 단일 회전을 쓴다. 그래서 코드도 두 줄이다.
회전 자체가 𝑂(1) 이고, 불균형 노드를 찾아 올라가는 길이 높이만큼이므로 넣기와 지우기 전체가 𝑂(log𝑛) 이다.
예제 47
빈 AVL 트리에 3, 2, 1 을 차례로 넣는다. 어느 회전이 언제 일어나는가?
풀이 보기
3, 2 까지는 문제가 없다. 3 의 왼쪽에 2 가 붙고 균형 인수는 1−(−1)=1 이내다.
1 을 넣으면 2 의 왼쪽에 붙는다. 이제 3 에서 왼쪽 높이가 1, 오른쪽 높이가 −1(빈 트리)이므로 균형 인수가 2 가 되어 조건을 어긴다.
세 노드를 정한다. 𝑧=3(처음 만난 불균형 노드), 𝑦=2(3의 높은 쪽 자식), 𝑥=1(2의 높은 쪽 자식). 𝑥 도 𝑦 도 왼쪽 자식이므로 LL 회전이다.
3 을 오른쪽으로 돌리면 2 가 뿌리가 되고 1 과 3 이 자식이 된다. 높이가 2 에서 1 로 줄고 균형 인수가 모두 0 이 되었다.
회전 뒤 중위 순회는 1,2,3 이다. 회전은 모양만 바꾸고 중위 순서는 절대 바꾸지 않는다. 이것이 회전이 이진 탐색 트리의 규칙을 지키는 이유이며, 검산법이기도 하다.
예제 48
빈 AVL 트리에 9, 3, 11, 1, 5 를 넣은 상태에서 7 을 넣으면 어떤 일이 벌어지는가? 원문의 LR 예제다.
풀이 보기
7 은 5 의 오른쪽 자식으로 들어간다. 이제 뿌리 9 에서 왼쪽 높이가 2, 오른쪽 높이가 0 이라 균형 인수가 2 다.
세 노드를 정한다. 𝑧=9, 𝑦=3(9의 높은 쪽 자식, 왼쪽), 𝑥=5(3의 높은 쪽 자식, 오른쪽). 왼쪽 다음 오른쪽으로 꺾였으므로 LR 회전이다.
1단계.𝑦=3 을 왼쪽으로 돌린다(rotate_rr). 5 가 올라가고 3 이 5 의 왼쪽 자식이 되며, 1 은 3 의 왼쪽에 남고 7 은 5 의 오른쪽에 남는다. 이제 9, 5, 3 이 모두 왼쪽으로 한 방향이다.
2단계.𝑧=9 를 오른쪽으로 돌린다(rotate_ll). 5 가 뿌리가 되고 왼쪽에 3(자식 1), 오른쪽에 9(자식 7과 11)가 온다.
모든 노드의 균형 인수가 1 이하가 되었다. 중위 순회는 1,3,5,7,9,11 로 여전히 오름차순이다.
1단계를 건너뛰고 곧바로 9 를 오른쪽으로 돌리면 어떻게 되는지도 확인해 두자. 3 이 뿌리가 되고 오른쪽에 9, 9 의 왼쪽에 5, 5 의 오른쪽에 7 이 붙어 여전히 높이 3 이다. 꺾인 모양이 반대쪽으로 옮겨 갔을 뿐이다. 두 번 돌려야 하는 이유가 이것이다.
레드-블랙 트리와 B-트리
AVL 트리는 균형이 빡빡해서 찾기가 빠르지만, 그만큼 넣고 지울 때 회전이 자주 일어난다. 갱신이 잦은 곳에서는 그 값이 크다. 그래서 균형을 좀 느슨하게 잡는 대신 회전을 줄인 것이 레드-블랙 트리다. 여기서는 왜 필요한지와 성질만 보고 구현은 넘긴다.
노드마다 빨강 또는 검정 표시를 두고 다음을 지킨다.
뿌리는 검정이다.
빨강 노드의 자식은 둘 다 검정이다. 곧 빨강이 연달아 오지 않는다.
어떤 노드에서 그 아래 잎까지 가는 모든 경로의 검정 노드 수가 같다.
셋째 조건이 "검정만 세면 완전히 균형"을 뜻하고, 둘째 조건이 "빨강은 검정 사이사이에만 낄 수 있다"를 뜻한다. 그러면 가장 긴 경로가 가장 짧은 경로의 두 배를 넘지 못하고, 높이가 2log2(𝑛+1) 이하로 묶인다.
AVL 보다 높이가 조금 크지만 넣기와 지우기에서 회전이 상수 번이면 끝난다. 그래서 여러 언어의 표준 라이브러리가 정렬된 사전을 이것으로 만든다.
B-트리는 아예 다른 문제를 푼다. 자료가 메모리가 아니라 디스크에 있을 때다. 디스크는 한 번 읽을 때 수 KB짜리 블록을 통째로 읽고, 그 한 번이 메모리 접근보다 수만 배 느리다. 그러면 중요한 것은 비교 횟수가 아니라 디스크를 몇 번 건드리는가다.
B-트리는 노드 하나를 디스크 블록 하나에 맞추고, 그 안에 키를 수십에서 수백 개 담는다. 자식도 그만큼 많아진다. 이진트리가 한 번에 후보를 절반으로 줄인다면 B-트리는 수백 분의 일로 줄인다.
설계 판단
자료가 10억 건이다. 균형 이진 탐색 트리와 자식이 100개인 B-트리 중 어느 쪽이 디스크에서 유리한가? 몇 번 읽어야 하는가?
풀이 보기
묻는 것은 트리의 높이다. 노드 하나를 읽을 때마다 디스크를 한 번 건드리기 때문이다.
이진 탐색 트리의 높이는 약 log2109≈30 이다. 곧 디스크를 30번 읽는다.
자식이 100개면 한 층 내려갈 때마다 후보가 100분의 1 이 되므로 높이는
log100109=92=4.5
곧 5번이면 된다. 여섯 배 차이다.
비교 횟수만 보면 B-트리가 오히려 많다. 노드 하나 안에서 키 100개를 뒤져야 하기 때문이다. 그러나 그 노드는 이미 메모리에 올라와 있으므로 그 비교는 사실상 공짜다. 무엇이 비싼 연산인지 정하고 나서야 무엇을 세어야 할지 정해진다.
데이터베이스의 인덱스와 파일 시스템이 거의 예외 없이 B-트리 계열을 쓰는 이유가 이것이다. 4장에서 캐시 때문에 배열이 유리했던 것과 같은 이야기가 한 단계 큰 규모에서 되풀이되는 셈이다.
힙과 우선순위 큐
정렬된 전체 순서가 필요 없고 가장 큰 것 하나만 계속 꺼내면 되는 일이 많다. 응급실의 환자 순서, 작업 스케줄러, 12장의 다익스트라 알고리즘이 그렇다. 이런 그릇을 우선순위 큐(priority queue)라 한다. 필요한 연산은 셋이다. 넣기, 가장 큰 것 보기, 가장 큰 것 꺼내기.
정렬된 배열로 만들면 꺼내기는 𝑂(1) 이지만 넣기가 𝑂(𝑛) 이다. 정렬 안 된 배열이면 반대다. 둘 다 𝑂(log𝑛) 으로 만드는 것이 힙(heap)이다.
힙은 조건 둘을 지키는 이진트리다.
모양 — 완전 이진트리다. 빈틈이 없다.
순서 — 모든 노드가 자기 자식보다 크거나 같다(최대 힙). 작거나 같게 하면 최소 힙이다.
형제끼리는 아무 관계가 없다는 점을 놓치기 쉽다. 힙은 정렬된 것이 아니다. 보장하는 것은 "가장 큰 값이 뿌리에 있다" 하나뿐이고, 우선순위 큐에는 그것으로 충분하다.
<완전 이진트리라 빈틈이 없으므로 위층부터 왼쪽에서 오른쪽으로 배열에 적으면 자리가 어긋나지 않는다. 부모와 자식을 첨자 산술로 찾으므로 주소를 저장할 필요가 없다>[원본 보기]
여기서 힙의 진짜 이점이 나온다. 포인터가 하나도 필요 없다. 완전 이진트리를 위층부터 왼쪽에서 오른쪽으로 배열에 적으면
자식(𝑖)=2𝑖+1,2𝑖+2부모(𝑖)=⌊𝑖−12⌋
로 오르내릴 수 있다. 4장에서 본 대로 연속 메모리라 캐시에도 잘 맞는다.
<규칙을 어긴 값이 더 큰 자식과 자리를 바꾸며 한 층씩 내려간다. 한 번 바꿀 때마다 한 층이므로 많아야 트리 높이만큼 반복한다>[원본 보기]
두 연산이 전부다. 말로 먼저 적는다.
올리기 — 자기가 부모보다 크면 부모와 자리를 바꾸고, 더 올라갈 수 없을 때까지 되풀이한다.
내리기 — 자기가 자식보다 작으면 더 큰 쪽 자식과 자리를 바꾸고, 더 내려갈 수 없을 때까지 되풀이한다.
한 번 반복할 때마다 한 층 움직이므로 둘 다 𝑂(log𝑛) 이다. 내리기에서 더 큰 쪽 자식을 고르는 것이 중요하다. 작은 쪽과 바꾸면 그 자리가 다시 규칙을 어기게 된다.
나머지 연산은 이 둘의 조합이다.
연산
어떻게
비용
가장 큰 것 보기
뿌리를 읽는다
𝑂(1)
넣기
배열 맨 뒤에 붙이고 올린다
𝑂(log𝑛)
꺼내기
뿌리를 떼고 맨 뒤 값을 뿌리에 얹은 뒤 내린다
𝑂(log𝑛)
힙 만들기
아래층부터 잎이 아닌 노드마다 내린다
𝑂(𝑛)
꺼내기에서 "맨 뒤 값을 뿌리에 얹는" 것이 요령이다. 그래야 완전 이진트리 모양이 유지된다. 가운데를 비우면 모양이 깨져 배열 표현이 무너진다.
<아래층에 노드가 몰려 있고 아래층일수록 내려갈 칸이 적다. 노드 수와 내려갈 칸 수의 곱을 층마다 더하면 노드 수를 넘지 않는다>[원본 보기]
예제 50
힙 만들기가 왜 𝑂(𝑛log𝑛) 이 아니라 𝑂(𝑛) 인가?
풀이 보기
노드가 𝑛 개이고 내리기가 𝑂(log𝑛) 이니 곱해서 𝑂(𝑛log𝑛) 이라 짐작하기 쉽다. 그런데 그것은 모든 노드가 끝까지 내려간다고 본 것이다. 실제로는 그렇지 않다.
높이가 ℎ 인 자리에 있는 노드는 많아야 ℎ 칸 내려간다. 그리고 그런 자리는 위로 갈수록 적다. 높이 ℎ 짜리 노드는 많아야 𝑛/2ℎ+1 개다.
총비용≤∑log2𝑛ℎ=0𝑛2ℎ+1⋅ℎ=𝑛2∑∞ℎ=0ℎ2ℎ
남은 급수는 상수로 수렴한다. ∑ℎ𝑥ℎ=𝑥/(1−𝑥)2 에 𝑥=1/2 를 넣으면 값이 2 다. 따라서 총 비용은 𝑛 을 넘지 않는다.
그림의 숫자로 확인하자. 노드 15개면 층별 곱이 3,4,4,0 이고 합이 11 이다. 15log215≈59 가 아니다.
핵심은 노드의 절반이 잎이라 아예 일하지 않고, 많이 내려가야 하는 노드는 위층에 몇 개뿐이라는 것이다. 그래서 아래층부터 훑어야 한다. 위층부터 하면 이 이점이 사라진다.
예제 51
배열 [4,1,3,2,16,9] 를 최대 힙으로 만들어라. 손으로 돌려 보라.
풀이 보기
첨자는 0부터 5 다. 잎이 아닌 노드는 첨자 ⌊6/2⌋−1=2 부터 0 까지이므로 2, 1, 0 순서로 내리기를 한다.
𝑖=2 (값 3). 자식은 첨자 5(값 9)뿐이다. 9 가 더 크므로 바꾼다. 배열은 [4,1,9,2,16,3].
𝑖=1 (값 1). 자식은 첨자 3(값 2)과 4(값 16)다. 더 큰 16 과 바꾼다. 배열은 [4,16,9,2,1,3]. 1 이 간 자리(첨자 4)는 잎이라 더 내려가지 않는다.
𝑖=0 (값 4). 자식은 16 과 9 다. 16 과 바꾼다. 배열은 [16,4,9,2,1,3]. 4 는 이제 첨자 1 이고 자식이 2 와 1 이므로 더 내려가지 않는다.
결과는 [16,4,9,2,1,3] 이다. 검산하자. 첨자 0의 자식은 1, 2 → 16≥4,9. 첨자 1의 자식은 3, 4 → 4≥2,1. 첨자 2의 자식은 5 → 9≥3. 모두 성립한다.
자리바꿈은 모두 3번이었다. 원소가 6개이니 6log26≈15 보다 훨씬 적다. 앞 예제가 말한 대로다.
설계 판단
스트리밍으로 들어오는 수 중 가장 큰 𝑘 개만 계속 유지하려 한다. 전체 개수 𝑛 은 아주 크고 𝑘 는 작다. 어떻게 하겠는가?
풀이 보기
전부 모아 정렬하면 𝑂(𝑛log𝑛) 이고 공간도 𝑂(𝑛) 이다. 𝑛 이 아주 크면 애초에 담을 수 없다.
필요한 것을 다시 보자. 후보 𝑘 개를 들고 있다가 새 값이 오면 그중 가장 작은 것과 견주어 크면 갈아 끼우면 된다. "가장 작은 것"이 필요하므로 크기 𝑘 짜리 최소 힙이다.
절차는 이렇다. 힙이 𝑘 개보다 적으면 그냥 넣는다. 꽉 찼으면 새 값을 뿌리(가장 작은 값)와 견주어, 크면 뿌리를 꺼내고 새 값을 넣는다.
값 하나당 비용이 𝑂(log𝑘) 이므로 전체는 𝑂(𝑛log𝑘), 공간은 𝑂(𝑘) 이다. 𝑘=100, 𝑛=109 이면 log2100≈7 이라 정렬보다 네 배 이상 적은 일이고 무엇보다 메모리가 상수다.
흔한 실수는 최대 힙을 쓰는 것이다. 가장 큰 것을 찾는 문제이니 최대 힙일 것 같지만, 우리가 매번 꺼내야 하는 것은 후보 중 가장 약한 것이다. "무엇을 버릴지 빨리 알아야 하는가"를 물으면 방향이 정해진다.
트라이 — 문자열을 위한 트리
키가 문자열이면 다른 선택지가 하나 더 있다. 트라이(trie)는 간선에 글자를 하나씩 두고, 뿌리에서 내려온 길 자체가 문자열이 되게 한 트리다. 노드에는 문자열을 저장하지 않는다.
<car · cat · cap 은 앞 두 글자를 공유하고 그만큼 공간을 아낀다. 초록 표시는 여기서 단어가 끝난다는 뜻인데 do 처럼 다른 단어의 앞부분인 단어가 있어서 반드시 필요하다>[원본 보기]
길이 𝑚 인 문자열을 찾는 데 드는 시간은 𝑂(𝑚) 이다. 글자 수만큼 내려가면 되고, 사전에 든 단어가 몇 개든 상관없다. 해시 테이블도 문자열 해시를 계산하느라 𝑂(𝑚) 이 드니 시간은 비슷하다.
차이는 할 수 있는 일에 있다. 트라이는 "ca 로 시작하는 단어를 전부" 같은 접두어 질의를 그대로 처리한다. 그 접두어까지 내려간 뒤 아래를 모두 훑으면 되기 때문이다. 해시 테이블로는 전부 뒤지는 수밖에 없다. 자동완성과 사전 검색이 트라이를 쓰는 이유다.
값도 있다. 노드마다 자식 표를 두어야 해서 공간을 많이 쓴다. 알파벳 26자를 배열로 두면 노드 하나에 26칸이 필요하고 대부분 비어 있다. 해시나 정렬된 목록으로 자식을 두면 공간이 줄지만 상수 시간을 잃는다. 자식이 하나뿐인 사슬을 하나로 압축하는 변형도 널리 쓰인다.
설계 판단
검색창 자동완성을 만든다. 단어 100만 개에 대해 사용자가 친 접두어로 시작하는 단어 10개를 보여야 한다. 해시 테이블과 트라이 중 무엇을 쓰겠는가?
풀이 보기
요구사항의 핵심은 "완전히 일치하는 것"이 아니라 "접두어로 시작하는 것"이다.
해시 테이블은 키 전체를 해시하므로 접두어로는 아무것도 알 수 없다. 100만 개를 전부 훑어 접두어를 대조해야 하니 글자 하나 칠 때마다 𝑂(𝑛) 이다.
트라이는 접두어 길이 𝑚 만큼 내려간 뒤 그 아래를 훑으면 된다. 게다가 사용자가 글자를 하나 더 치면 지금 있는 노드에서 한 칸만 더 내려가면 되므로 앞의 작업이 그대로 재활용된다.
각 노드에 "이 아래에서 가장 인기 있는 단어 10개"를 미리 저장해 두면 훑는 일조차 사라져 응답이 𝑂(𝑚) 이 된다. 저장 공간을 써서 시간을 사는 것이며, 단어 목록이 자주 바뀌지 않는 자동완성에 잘 맞는 맞바꿈이다.
정렬된 배열에 이진 탐색을 쓰는 방법도 있다. 접두어의 시작과 끝 위치를 𝑂(𝑚log𝑛) 에 찾을 수 있어 나쁘지 않고 공간도 훨씬 적게 쓴다. 목록이 고정되어 있고 메모리가 빠듯하면 이 편이 나을 수 있다.
이 장의 정리
트리는 종류가 많아 보이지만 무엇을 보장하려고 규칙을 하나 더 붙였는가로 줄을 세우면 한 줄기다.
<이진트리에 좌우 대소 관계를 주면 이진 탐색 트리, 위아래 대소 관계를 주면 힙이다. 이진 탐색 트리의 최악을 막으려고 규칙을 더 붙인 것이 AVL · 레드-블랙 · B-트리다>[원본 보기]
구조
보장하는 것
찾기
넣기 · 지우기
언제 쓰는가
이진 탐색 트리
중위 순회가 정렬되어 있다
𝑂(ℎ)
𝑂(ℎ)
균형을 스스로 지킬 수 있을 때만
AVL 트리
높이 차 1 이하
𝑂(log𝑛)
𝑂(log𝑛)
찾기가 압도적으로 많을 때
레드-블랙 트리
가장 긴 길이 가장 짧은 길의 2배 이하
𝑂(log𝑛)
𝑂(log𝑛)
넣고 지우기가 잦을 때
B-트리
노드 하나가 디스크 블록 하나
𝑂(log𝑡𝑛)
𝑂(log𝑡𝑛)
자료가 디스크에 있을 때
힙
가장 큰 값이 뿌리에 있다
가장 큰 것만 𝑂(1)
𝑂(log𝑛)
가장 큰 것만 계속 꺼낼 때
트라이
뿌리에서 온 길이 문자열이다
𝑂(𝑚)
𝑂(𝑚)
접두어 질의가 필요할 때
용어
뜻
깊이 · 높이
뿌리에서 내려온 간선 수 · 가장 깊은 잎까지의 간선 수
완전 이진트리
빈틈없이 채워 배열로 적을 수 있는 모양
전위 · 중위 · 후위 · 레벨 순회
자기를 언제 처리하는가. 레벨 순회는 큐가 필요하다
균형 인수
왼쪽 높이 − 오른쪽 높이. AVL 은 절댓값 1 이하
회전
이웃한 세 노드의 자리를 바꿔 높이를 줄이는 𝑂(1) 연산
올리기 · 내리기
힙에서 규칙을 어긴 값을 제자리로 보내는 𝑂(log𝑛) 연산
우선순위 큐
가장 큰(작은) 것만 빠르게 꺼내는 그릇. 힙으로 만든다
여기까지가 자료구조다. 다음 장부터는 문제를 푸는 방법 쪽으로 넘어가고, 이 장의 도구들이 거기서 계속 불려 나온다. 8장의 힙 정렬이 여기서 만든 힙을 그대로 쓰고, 12장의 다익스트라가 우선순위 큐 위에서 돈다. 9장의 너비 우선 탐색은 레벨 순회를 그래프로 넓힌 것이다.
분할정복
문제를 푸는 큰 틀 셋 가운데 첫 번째다. 큰 문제를 통째로 붙들고 씨름하는 대신 같은 모양의 더 작은 문제 여러 개로 쪼개고, 각각을 같은 방법으로 풀고, 결과를 이어 붙인다. 알고리즘 하나를 배우는 것이 아니라 알고리즘을 만드는 자리 하나를 배우는 것이므로, 여기서 세운 것이 뒤의 여러 장에서 되돌아온다.
이 장에서는 그 모양에 이름을 붙이고 도구로 만든다. 답할 질문은 셋이다. 어떤 문제를 나눌 수 있는가, 나눈 뒤의 비용을 어떻게 계산하는가, 그리고 나누는 것이 언제 손해인가.
앞에서 가져오는 것은 3장의 재귀와 재귀식과 마스터 정리 둘뿐이다. 마스터 정리는 이 장에서 계속 쓰므로 세 경우가 기억나지 않으면 먼저 그 절을 다시 보라. 여기 나오는 알고리즘은 전부 이 장에서 처음 소개하므로 미리 알고 와야 할 것은 없다. 바로 다음 장이 이 틀의 가장 큰 응용이다. 8장에서 정렬 전체를 다룰 때, "왜 𝑛log𝑛 인가"에 이 장의 재귀식 하나로 답하게 된다.
이 장의 마지막 절이 11장으로 이어진다. 나눈 조각이 서로 겹칠 때 무슨 일이 벌어지는지가 거기서 동적 계획법이 되는 이유다.
나누고 · 풀고 · 합친다
분할정복(divide and conquer)은 세 단계다.
나눈다(divide) — 문제를 같은 모양의 더 작은 문제 여러 개로 쪼갠다.
정복한다(conquer) — 조각을 각각 같은 방법으로 푼다. 충분히 작아지면 바로 답한다.
합친다(combine) — 조각의 답을 모아 원래 문제의 답을 만든다.
둘째 줄이 재귀이므로 3장에서 본 재귀의 두 조건이 필요하다. 바닥 조건이 있어야 하고, 조각이 반드시 더 작아야 한다.
<조각을 몇 개로 나누는가가 a, 조각이 얼마나 작아지는가가 b, 나누고 합치는 데 드는 일이 f(n) 이다. 코드를 읽어 이 세 자리를 채우는 것이 재귀식을 세우는 일 전부다>[원본 보기]
그림이 말하는 대로 비용은 곧바로 재귀식이 된다.
𝑇(𝑛)=𝑎𝑇(𝑛/𝑏)+𝑓(𝑛)
여기서 𝑓(𝑛) 에는 나누는 값과 합치는 값이 함께 들어간다. 이 둘을 따로 세는 일은 없다. 재귀 호출 바깥에서 하는 일을 전부 더하면 그것이 𝑓(𝑛) 이다.
알고리즘
나누는 방법
𝑎
𝑏
𝑓(𝑛)
결과
이진 탐색
가운데와 견주어 반을 버린다
1
2
Θ(1)
Θ(log𝑛)
병합 정렬
반씩 둘로
2
2
Θ(𝑛)
Θ(𝑛log𝑛)
빠른 거듭제곱
지수를 반으로
1
2
Θ(1)
Θ(log𝑛)
카라추바 곱셈
자릿수를 반으로
3
2
Θ(𝑛)
Θ(𝑛1.585)
최근접 점 쌍
x 좌표로 반씩
2
2
Θ(𝑛)
Θ(𝑛log𝑛)
표의 마지막 칸은 전부 마스터 정리로 읽은 것이다. 𝑛log𝑏𝑎 를 먼저 구하고 𝑓(𝑛) 과 견준다는 순서만 지키면 된다.
예제 54
어떤 절차가 크기 𝑛 인 문제를 크기 𝑛/3 인 문제 아홉 개로 나누고, 나누고 합치는 데 Θ(𝑛2) 이 든다. 전체 비용은?
풀이 보기
묻는 것은 𝑇(𝑛)=9𝑇(𝑛/3)+Θ(𝑛2) 의 답이다.
잎 비용부터 구한다. 𝑎=9, 𝑏=3 이므로
𝑛log39=𝑛2
𝑓(𝑛)=Θ(𝑛2) 이라 잎 비용과 정확히 같다. 마스터 정리 둘째 경우다.
𝑇(𝑛)=Θ(𝑛2log𝑛)
답이 말이 되는지 보자. 곧이곧대로 풀면 Θ(𝑛2) 이었을 일을 나눠서 log𝑛 배로 늘렸다. 이런 나누기는 하지 않는 것이 낫다. 나눌 수 있다는 것이 나누는 것이 이득이라는 뜻은 아니다.
예제 55
다음 절차의 재귀식을 세우고 풀어라. 배열을 세 토막으로 나누어 앞 두 토막만 재귀로 처리하고, 나머지는 배열을 한 번 훑는 일이다.
정수 배열 𝐴[0..𝑛−1] 에서 연속한 구간의 합 중 가장 큰 값을 구한다(구간은 비어 있지 않다고 하자). 음수가 섞여 있어 전부 더하는 것이 최선이라는 보장이 없다. 이 문제를 분할정복으로 풀고 비용을 구하라. 𝐴=[−2,1,−3,4,−1,2,1,−5] 로 확인해 보라.
풀이 보기
묻는 것은 나누는 기준과 합치는 방법, 그리고 그것이 낳는 재귀식이다.
나누는 기준은 자리다. 배열을 가운데에서 왼쪽 절반과 오른쪽 절반으로 자른다. 그러면 답이 되는 구간이 놓일 자리는 세 가지뿐이다. 왼쪽 안에 다 들어가거나, 오른쪽 안에 다 들어가거나, 가운데 경계를 걸친다. 앞의 둘은 같은 문제의 절반짜리이므로 재귀가 맡고, 남는 것은 셋째뿐이다.
셋째를 어떻게 세는가가 이 문제의 요점이다. 걸치는 구간은 반드시 경계를 포함하므로 왼쪽 부분과 오른쪽 부분이 서로 독립이다. 그러니 경계에서 왼쪽으로 한 칸씩 넓혀 가며 누적합의 최댓값을 잡고, 오른쪽으로도 똑같이 한 뒤 두 최댓값을 더하면 그것이 걸치는 구간의 최대 합이다. 양쪽을 각각 한 번씩 훑으므로 Θ(𝑛) 이다.
↓ python
def 최대구간합(A, 왼, 오):
if 왼 == 오: return A[왼]
가운데 = (왼 + 오) // 2
좌 = 최대구간합(A, 왼, 가운데)
우 = 최대구간합(A, 가운데 + 1, 오)
합 = 0; 왼최대 = 음의무한 # 경계에서 왼쪽으로
for i in range(가운데, 왼 - 1, -1):
합 += A[i]; 왼최대 = max(왼최대, 합)
합 = 0; 오최대 = 음의무한 # 경계에서 오른쪽으로
for i in range(가운데 + 1, 오 + 1):
합 += A[i]; 오최대 = max(오최대, 합)
return max(좌, 우, 왼최대 + 오최대)
비용을 읽는다. 크기 𝑛 을 절반짜리 둘로 나누고(𝑎=2, 𝑏=2), 재귀 바깥에서 하는 일이 걸치는 구간 세기 Θ(𝑛) 이다.
𝑇(𝑛)=2𝑇(𝑛/2)+Θ(𝑛)
잎 비용은 𝑛log22=𝑛 이고 𝑓(𝑛)=Θ(𝑛) 이라 둘이 같다. 마스터 정리 둘째 경우다.
𝑇(𝑛)=Θ(𝑛log𝑛)
표의 병합 정렬과 글자 하나 다르지 않은 재귀식이다. 나누는 값이 없고 합치는 값이 Θ(𝑛) 이라는 모양이 같기 때문이다.
주어진 배열로 확인한다. 왼쪽 절반 [−2,1,−3,4] 의 답은 [4] 의 4, 오른쪽 절반 [−1,2,1,−5] 의 답은 [2,1] 의 3 이다. 걸치는 것은 경계에서 왼쪽으로 넓히며 얻는 최댓값 4(구간 [4])와 오른쪽으로 넓히며 얻는 최댓값 2(구간 [−1,2,1])를 더한 6 이다. 셋 중 가장 큰 것은 6 이고, 실제로 [4,−1,2,1] 의 합이 6 이다.
답이 말이 되는가. 곧이곧대로 모든 구간을 세면 시작과 끝을 고르는 Θ(𝑛2) 개의 구간마다 합을 다시 더하는 Θ(𝑛3) 이고, 누적합을 미리 만들어도 Θ(𝑛2) 이다. Θ(𝑛log𝑛) 은 그보다 확실히 낫다.
흔한 실수 둘. 첫째, 걸치는 구간을 왼쪽의 최대 구간과 오른쪽의 최대 구간을 이어 붙인 것으로 계산하는 것이다. 그 둘은 경계에 닿아 있지 않을 수 있어서 이어 붙이면 구간이 아니게 된다. 반드시 경계에서 출발해 넓혀야 한다. 둘째, 왼최대와 오른최대를 0 으로 초기화하는 것이다. 그러면 원소가 전부 음수일 때 빈 구간을 답으로 골라 0 을 내놓는다.
이것이 끝이 아니다. 나눈 두 조각이 겹치지 않았기 때문에 분할정복이 통했는데, 같은 문제를 "𝑖 에서 끝나는 구간"이라는 축으로 다시 보면 조각이 겹친다. 그때 쓰는 것이 11장의 동적 계획법이고, 값이 Θ(𝑛) 까지 내려간다.
이진 탐색 — 합치지 않는 분할정복
가장 단순한 분할정복은 합치는 단계가 없는 것이다. 조각 하나만 답을 갖고 있으면 나머지는 볼 필요가 없다.
이진 탐색(binary search)이 그렇다. 정렬된 배열에서 값을 찾을 때, 가운데 칸과 한 번 견주면 답이 어느 쪽 절반에 있는지가 정해진다. 반대쪽은 통째로 버린다.
<남은 후보가 16 에서 8, 4, 2, 1 로 줄어든다. 견주기 한 번이 후보의 절반을 지우므로 필요한 횟수는 log₂ n 을 넘지 않는다>[원본 보기]
말로 적으면 세 줄이다. 가운데를 본다. 찾는 값과 같으면 끝난다. 크면 오른쪽, 작으면 왼쪽만 남긴다.
↓ python
def 이진탐색(a, 값):
낮은쪽, 높은쪽 = 0, len(a) - 1
while 낮은쪽 <= 높은쪽:
가운데 = 낮은쪽 + (높은쪽 - 낮은쪽) // 2
if a[가운데] == 값: return 가운데
if a[가운데] < 값: 낮은쪽 = 가운데 + 1
else: 높은쪽 = 가운데 - 1
return -1
반복 한 번에 구간이 반이 되므로 𝑇(𝑛)=𝑇(𝑛/2)+Θ(1), 곧 Θ(log𝑛) 이다. 마스터 정리에서 𝑛log21=𝑛0=1 이고 𝑓(𝑛)=1 이라 둘째 경우다.
재귀로 적을 수도 있지만 합치는 일이 없으므로 반복문으로 펴는 것이 낫다. 그러면 공간이 𝑂(log𝑛) 에서 𝑂(1) 로 준다. 이렇게 재귀 호출이 함수의 마지막 일인 것을 꼬리 재귀라 하고, 언제나 반복문으로 펼 수 있다.
코드에서 눈여겨볼 곳은 가운데를 구하는 줄이다. (낮은쪽+높은쪽)/2 로 적으면 두 값이 클 때 덧셈이 넘칠 수 있다. 뺄셈을 먼저 하면 그 일이 없다.
예제 57
정렬된 배열 [2,5,8,12,16,23,38,45] 에서 20 을 찾는다. 손으로 돌려 보고, 못 찾았을 때 낮은쪽이 어디를 가리키는지 확인하라.
풀이 보기
첨자는 0부터 7이다.
1회.낮은쪽=0, 높은쪽=7, 가운데는 3(값 12). 12<20 이므로 낮은쪽=4.
2회. 구간 [4,7], 가운데는 5(값 23). 23>20 이므로 높은쪽=4.
3회. 구간 [4,4], 가운데는 4(값 16). 16<20 이므로 낮은쪽=5.
이제 낮은쪽>높은쪽 이라 반복이 끝난다. 못 찾았다.
끝났을 때 낮은쪽=5 인데, 이 자리는 20 을 끼워 넣어야 할 자리다. 실제로 […,16,20,23,…] 가 된다.
이 성질이 실무에서 더 자주 쓰인다. "있는가"보다 "어디에 넣어야 하는가"나 "이 값 이상인 첫 원소는 어디인가"를 묻는 일이 많기 때문이다. 견주기를 < 하나로만 하고 끝까지 구간을 좁히면 그 자리가 나온다.
설계 판단
어떤 값 𝑥 에 대해 "𝑥 명을 태울 수 있는가"를 판정하는 함수가 있다. 이 함수는 𝑥 가 커질수록 참에서 거짓으로 한 번만 바뀐다. 참이 되는 가장 큰 𝑥 를 어떻게 찾겠는가?
풀이 보기
묻는 것은 배열이 아닌 곳에서 이진 탐색을 쓰는 법이다.
이진 탐색이 요구하는 것은 "정렬된 배열"이 아니라 단조성이다. 어떤 자리를 기준으로 한쪽은 전부 참이고 다른 쪽은 전부 거짓이면 된다. 문제가 그 조건을 그대로 주었다.
답의 범위를 [낮은쪽,높은쪽] 로 잡고, 가운데 값으로 판정 함수를 부른다. 참이면 답이 그보다 크거나 같으므로 왼쪽 절반을 버리고, 거짓이면 오른쪽을 버린다.
범위의 크기가 𝑅 이면 판정 함수를 log2𝑅 번 부른다. 판정 한 번이 𝑂(𝑛) 이면 전체는 𝑂(𝑛log𝑅) 이다.
이것을 답에 대한 이진 탐색이라 한다. "최솟값을 구하라"를 "이 값이 가능한가"로 바꾸어 푸는 요령이고, 13장에서 최적화 문제를 판정 문제로 바꾸는 이야기와 같은 발상이다.
주의할 것은 단조성이다. 함수가 참 · 거짓 · 참으로 오락가락하면 이진 탐색은 아무 답이나 내놓는다. 쓰기 전에 단조인지 반드시 확인해야 한다.
병합 정렬 — 값은 어느 층에 있는가
첫 번째 본보기로 병합 정렬(merge sort)을 만든다. 배열을 오름차순으로 늘어놓는 문제이고, 앞 절의 세 단계를 그대로 따르면 알고리즘이 저절로 나온다.
나눈다 — 배열을 반으로. 나누는 값이 Θ(1) 이다. 첨자만 계산하면 된다.
정복한다 — 두 반쪽을 재귀로 정렬한다. 원소가 하나면 이미 정렬되어 있으니 그것이 바닥 조건이다.
합친다 — 정렬된 두 줄을 훑으며 잇는다. Θ(𝑛).
셋째 단계가 이 알고리즘의 전부다. 두 줄이 이미 정렬되어 있다는 것이 열쇠다. 그러면 각 줄의 맨 앞만 견주어 작은 쪽을 꺼내면 되고, 꺼낸 자리는 다시 볼 일이 없다. 한 번 꺼낼 때 견주기가 한 번이므로 두 줄의 길이 합에 비례하는 한 번의 훑기로 끝난다.
그러면 재귀식이 곧바로 선다. 크기 𝑛 을 크기 𝑛/2 짜리 둘로 나누고 합치는 데 Θ(𝑛) 이 드니
𝑇(𝑛)=2𝑇(𝑛/2)+Θ(𝑛)
잎 비용이 𝑛log22=𝑛 이고 𝑓(𝑛)=𝑛 이라 정확히 같으므로 마스터 정리 둘째 경우이고, 답은 Θ(𝑛log𝑛) 이다. 앞 표의 둘째 줄이 이것이다.
값이 전부 셋째 단계에 있다. 나누는 데는 일이 들지 않고, 층마다 원소 𝑛 개가 한 번씩 옮겨 갈 뿐이며, 그 층이 log2𝑛 개다. 이 알고리즘이 정렬로서 어떤가 — 같은 값의 앞뒤가 남는가, 보조 배열이 얼마나 드는가, 다른 정렬과 견주면 어디에 쓰는가 — 는 8장의 몫이다. 여기서 볼 것은 뼈대다.
그 뼈대를 조금 고치면 정렬 말고 다른 것도 세어진다.
예제 59
배열에서 역위(inversion)의 개수를 세려 한다. 역위란 𝑖<𝑗 인데 𝑎[𝑖]>𝑎[𝑗] 인 쌍이다. 곧이곧대로 세면 Θ(𝑛2) 이다. 병합 정렬을 고쳐 Θ(𝑛log𝑛) 에 세어라.
풀이 보기
묻는 것은 "합치는 단계에서 무엇을 덤으로 알 수 있는가"다.
역위를 세 무리로 나눈다. 둘 다 왼쪽 반에 있는 것, 둘 다 오른쪽에 있는 것, 왼쪽과 오른쪽에 걸친 것. 앞의 둘은 재귀가 세어 준다. 셋째만 합치는 단계에서 세면 된다.
합치는 중에 오른쪽 줄의 값을 꺼내는 순간을 보자. 그 값보다 큰 왼쪽 값들이 아직 남아 있고, 그것들은 전부 원래 배열에서 앞에 있었다. 곧 남아 있는 왼쪽 원소의 개수만큼 역위가 생긴다.
역위+=(왼쪽에남은개수)
예를 들어 왼쪽이 [3,7,9], 오른쪽이 [2,8] 이라 하자. 2 를 꺼낼 때 왼쪽에 3개가 남아 있으므로 역위 3개((3,2),(7,2),(9,2))를 센다. 8 을 꺼낼 때는 9 하나만 남아 있으므로 1개다. 합쳐서 4개다.
세는 일이 합치기에 상수 하나를 더할 뿐이므로 비용은 그대로 Θ(𝑛log𝑛) 이다.
역위 수는 "두 순위표가 얼마나 다른가"를 재는 값이기도 하다. 8장에서 삽입 정렬을 볼 때 이 수가 다시 나온다. 삽입 정렬이 값을 미는 횟수가 정확히 역위의 개수다. 여기서는 분할정복이 정렬 말고 다른 것을 세는 데 쓰인 첫 예라는 점만 기억하면 된다.
예제 60
병합 정렬의 "반으로 나누기"를 "1:𝑛−1 로 나누기"로 바꾸면 무슨 일이 생기는가?
풀이 보기
재귀식이 바뀐다. 크기 1 짜리 하나와 크기 𝑛−1 짜리 하나이고, 합치는 값은 그대로 Θ(𝑛) 이다.
𝑇(𝑛)=𝑇(𝑛−1)+𝑇(1)+Θ(𝑛)=𝑇(𝑛−1)+Θ(𝑛)
이 재귀식은 3장의 표에 있던 것이다. 풀면
𝑇(𝑛)=𝑛+(𝑛−1)+⋯+1=Θ(𝑛2)
실제로 이것은 삽입 정렬이다. 원소 하나를 이미 정렬된 줄에 끼워 넣는 일을 되풀이하는 것이니 그렇다.
여기서 배울 것은 고르게 나누는 것이 분할정복의 핵심이라는 점이다. 치우쳐 나누면 층 수가 log𝑛 에서 𝑛 으로 늘어난다. 8장에서 볼 퀵 정렬의 최악이 정확히 이 모양이며, 거기서는 나누는 자리가 입력에 따라 정해져 운이 나쁘면 매번 치우친다. 마스터 정리가 𝑇(𝑛)=𝑎𝑇(𝑛/𝑏) 꼴만 다루는 이유이기도 하다. 𝑏>1 이 "일정 비율로 줄어든다"를 보증한다.
거듭제곱을 빠르게
𝑥𝑛 을 구하려면 곱셈을 몇 번 해야 하는가. 곧이곧대로 하면 𝑛−1 번이다. 분할정복으로 하면 Θ(log𝑛) 번이다.
발상은 한 줄이다. 𝑥𝑛=(𝑥𝑛/2)2 이므로 절반짜리를 한 번만 구해 제곱하면 된다.𝑛 이 홀수면 𝑥 를 한 번 더 곱한다.
<지수를 이진법으로 적으면 어느 제곱을 곱해야 하는지 그대로 보인다. 제곱 세 번으로 x, x², x⁴, x⁸ 을 만들고 켜진 자리 셋을 곱한다>[원본 보기]
↓ python
def 거듭제곱(x, n):
if n == 0: return 1
절반 = 거듭제곱(x, n // 2)
if n % 2 == 0: return 절반 * 절반
else: return 절반 * 절반 * x
한 번 부를 때마다 지수가 반이 되므로 𝑇(𝑛)=𝑇(𝑛/2)+Θ(1), 곧 Θ(log𝑛) 이다. 여기서 𝑛 은 지수의 값이므로, 지수를 적는 데 드는 자릿수로 재면 곱셈 횟수는 자릿수에 비례한다.
절반 을 변수에 담는 것이 핵심이다. 이것을 잊고 거듭제곱(𝑥,𝑛/2)×거듭제곱(𝑥,𝑛/2) 라고 적으면 재귀 호출이 두 번이 되어 𝑇(𝑛)=2𝑇(𝑛/2)+1, 곧 Θ(𝑛) 이 된다. 이득이 통째로 사라진다. 11장에서 볼 "같은 것을 두 번 계산하지 않는다"가 여기서 이미 나온다.
예제 61
345mod1000 을 이 방법으로 구하는 절차를 적어라. 왜 큰 수를 다룰 때 이 방법이 필수인가?
풀이 보기
묻는 것은 모듈러 거듭제곱이다. 절차는 위와 같되 곱할 때마다 나머지를 취한다.
45=1011012 이므로 지수를 반씩 줄이면 45→22→11→5→2→1→0 로 여섯 층이다. 곱셈은 층마다 한두 번이니 열 번 남짓이다.
나머지를 매번 취하는 것이 중요하다. (𝑎×𝑏)mod𝑚=((𝑎mod𝑚)×(𝑏mod𝑚))mod𝑚 이 성립하므로 결과가 달라지지 않는다.
취하지 않으면 어떻게 되는가. 345 는 22자리 수다. 지수가 실무에서 쓰이는 크기, 예컨대 22048 급이면 중간값이 메모리에 담기지 않는다.
곧이곧대로 45번 곱하는 것과 견주면 곱셈 횟수가 넷 대 하나 정도로 줄었지만, 지수가 커지면 차이가 걷잡을 수 없이 벌어진다. 지수가 1018 이면 곧이곧대로는 1018 번이고 이 방법은 60번이다.
16장의 RSA 와 디피-헬만이 이 계산 위에 서 있다. 답을 구하는 것은 쉽고 되돌리는 것은 어렵다는 비대칭이 그 암호의 뼈대다.
예제 62
피보나치 수 𝐹𝑛 을 Θ(log𝑛) 번의 곱셈으로 구할 수 있다. 어떻게 하겠는가?
풀이 보기
실마리는 다음 관계다. 두 항을 세로로 세우면 한 걸음이 같은 행렬을 곱하는 일이 된다.
(𝐹𝑛+1𝐹𝑛)=(1110)(𝐹𝑛𝐹𝑛−1)
𝑛 걸음을 가려면 그 행렬을 𝑛 제곱하면 된다. 행렬 곱셈도 결합법칙이 성립하므로 위의 거듭제곱 절차를 그대로 쓸 수 있다.
2×2 행렬 하나를 곱하는 데 곱셈 8번이 드니 상수이고, 곱하는 횟수가 Θ(log𝑛) 이므로 전체가 Θ(log𝑛) 번의 곱셈이다.
11장에서 피보나치를 표로 구하면 Θ(𝑛) 이 된다. 여기서는 그보다도 빠르다. 다만 값 자체가 자릿수 Θ(𝑛) 으로 자라므로 큰 수 곱셈의 값을 세면 이야기가 달라진다. "곱셈 횟수"와 "비트 연산 횟수"를 구분해야 하는 자리다.
이 요령은 "다음 값이 앞 몇 개의 일차식으로 정해지는" 모든 점화식에 쓸 수 있다.
카라추바 곱셈 — 곱셈 하나를 덜다
자릿수가 𝑛 인 두 수를 곱하는 데 초등학교 방법은 Θ(𝑛2) 번의 한 자리 곱셈을 쓴다. 1960년까지 사람들은 이보다 나을 수 없다고 믿었다.
나누어 보자. 두 수를 반씩 잘라 𝑋=𝑎⋅10𝑛/2+𝑏, 𝑌=𝑐⋅10𝑛/2+𝑑 로 적으면
𝑋𝑌=𝑎𝑐⋅10𝑛+(𝑎𝑑+𝑏𝑐)⋅10𝑛/2+𝑏𝑑
곱셈이 네 개다. 재귀식은 𝑇(𝑛)=4𝑇(𝑛/2)+Θ(𝑛) 이고 잎 비용이 𝑛log24=𝑛2 이므로 Θ(𝑛2) 그대로다. 나눈 보람이 없다.
카라추바가 알아챈 것은 이것이다. 우리에게 필요한 것은 𝑎𝑑 와 𝑏𝑐 가 아니라 그 합 하나뿐이다.
(𝑎+𝑏)(𝑐+𝑑)=𝑎𝑐+𝑎𝑑+𝑏𝑐+𝑏𝑑⟹𝑎𝑑+𝑏𝑐=(𝑎+𝑏)(𝑐+𝑑)−𝑎𝑐−𝑏𝑑
<오른쪽에서 곱셈이 셋으로 준다. 덧셈과 뺄셈이 몇 번 늘지만 그것은 Θ(n) 이라 f(n) 에 묻힌다. 바뀐 것은 재귀식의 a 하나뿐이다>[원본 보기]
𝑎𝑐 와 𝑏𝑑 는 어차피 필요하니 이미 있다. 곱셈 한 번을 더 하면 세 개로 끝난다.
𝑇(𝑛)=3𝑇(𝑛/2)+Θ(𝑛)
잎 비용은 𝑛log23≈𝑛1.585 이고 𝑓(𝑛)=𝑛 은 그보다 작으므로 마스터 정리 첫째 경우다.
𝑇(𝑛)=Θ(𝑛log23)=Θ(𝑛1.585)
스트라센 행렬 곱셈
같은 수법이 행렬에도 통한다. 𝑛×𝑛 행렬 둘을 곱하는 곧이곧대로의 방법은 Θ(𝑛3) 이다. 행렬을 네 조각으로 나누면 2×2 블록 곱셈이 되고, 그러면 블록 곱셈이 여덟 번이다.
𝑇(𝑛)=8𝑇(𝑛/2)+Θ(𝑛2)⟹Θ(𝑛log28)=Θ(𝑛3)
역시 이득이 없다. 스트라센(Strassen)은 덧셈과 뺄셈을 섞어 블록 곱셈을 일곱 번으로 줄이는 조합을 찾아냈다.
𝑇(𝑛)=7𝑇(𝑛/2)+Θ(𝑛2)⟹Θ(𝑛log27)≈Θ(𝑛2.807)
<지수가 눈금 위에서 왼쪽으로 옮겨 간다. 오른쪽 끝의 두 방법은 상수가 아주 커서 실제로 이득이 나는 크기가 서로 다르다>[원본 보기]
예제 63
자릿수를 셋으로 잘라 곱셈을 다섯 번으로 줄이는 방법이 있다면 지수는 얼마가 되는가? 카라추바보다 나은가?
풀이 보기
조각이 𝑏=3 분의 1 크기이고 재귀 호출이 𝑎=5 번이다. 합치는 값은 여전히 덧셈과 자리 옮기기뿐이라 Θ(𝑛) 이다.
𝑇(𝑛)=5𝑇(𝑛/3)+Θ(𝑛)
잎 비용은 𝑛log35 이고
log35=ln5ln3≈1.6091.099≈1.465
𝑓(𝑛)=𝑛 이 더 작으므로 첫째 경우이고 𝑇(𝑛)=Θ(𝑛1.465) 다.
카라추바의 1.585 보다 작다. 실제로 이런 방법이 있고 툼-쿡(Toom-Cook)이라 부른다. 조각 수를 늘릴수록 지수가 1 에 가까워지지만 대신 덧셈과 뺄셈의 개수가 급격히 늘어 상수가 커진다.
그래서 실제 큰 수 라이브러리는 자릿수에 따라 방법을 갈아탄다. 짧으면 초등학교 방법, 중간이면 카라추바, 아주 길면 툼-쿡이나 푸리에 변환 기반의 방법을 쓴다. 8장에서 볼 인트로소트가 크기에 따라 정렬을 갈아타는 것도 같은 이야기다.
설계 판단
자릿수 20 짜리 수 두 개를 곱하는 데 카라추바를 쓰는 것이 옳은가?
풀이 보기
지수만 보면 201.585≈100 이 202=400 보다 작으니 이득처럼 보인다. 그런데 점근 표기가 버린 상수가 여기서 살아난다.
카라추바는 재귀 호출마다 덧셈 세 번, 뺄셈 두 번, 자리 옮기기, 그리고 함수 호출이 붙는다. 자릿수가 20이면 층이 서너 개밖에 안 되는데 그 부대비용이 매 층마다 붙는다.
게다가 실제 기계는 한 자리씩 곱하지 않는다. 64비트 정수 두 개의 곱은 명령 하나다. 자릿수 20 짜리 십진수는 64비트 두 칸이면 담기므로 곧이곧대로 곱해도 곱셈 네 번이면 끝난다.
답은 쓰지 않는다이다. 실제 라이브러리들이 카라추바로 갈아타는 문턱은 대개 수십에서 수백 자리 언저리이고, 그 값은 기계마다 재서 정한다.
이 판단은 이 장의 마지막 절이 다룰 이야기의 예고다. 나눌 수 있다고 나누는 것이 이득은 아니다.
최근접 점 쌍
평면에 점이 𝑛 개 있다. 가장 가까운 두 점을 찾아라. 모든 쌍을 견주면 (𝑛2)=Θ(𝑛2) 이다. 분할정복으로 Θ(𝑛log𝑛) 에 된다.
절차를 말로 적는다.
점을 𝑥 좌표로 정렬해 반으로 나눈다.
왼쪽의 최소 거리 𝛿𝐿 과 오른쪽의 최소 거리 𝛿𝑅 을 재귀로 구하고 𝛿=min(𝛿𝐿,𝛿𝑅) 로 둔다.
가운데 선에서 좌우 𝛿 안에 있는 점들만 모아, 그 안에서 𝛿 보다 가까운 쌍이 있는지 본다.
셋째 줄이 이 알고리즘의 산이다. 띠 안의 점이 많으면 그 안에서 다시 모든 쌍을 견주게 되어 Θ(𝑛2) 로 돌아간다.
<띠 밖의 쌍은 이미 δ 보다 멀어 볼 필요가 없다. 띠 안에서도 y 순으로 늘어놓고 뒤로 여섯 점만 견주면 되는 이유가 오른쪽 격자다>[원본 보기]
다행히 띠 안에서 한 점이 견주어야 할 상대는 상수 개다. 이유는 이렇다. 띠 안의 점을 𝑦 좌표로 정렬해 두고, 어떤 점에서 𝑦 방향으로 𝛿 안에 있는 점만 보면 된다. 그 범위는 가로 2𝛿, 세로 𝛿 짜리 직사각형이다.
그 직사각형을 한 변이 𝛿/2 인 칸 여덟 개로 나누면, 한 칸에는 점이 둘 이상 들어갈 수 없다. 들어가면 그 둘의 거리가 𝛿/√2<𝛿 가 되어 𝛿 가 최소였다는 사실에 어긋나기 때문이다.
따라서 견주는 상대는 많아야 일곱이고, 띠 처리 전체가 Θ(𝑛) 이다.
𝑇(𝑛)=2𝑇(𝑛/2)+Θ(𝑛)⟹Θ(𝑛log𝑛)
예제 65
위 절차에서 "띠 안의 점을 𝑦 좌표로 정렬한다"를 재귀 호출마다 하면 전체 비용이 어떻게 되는가? 어떻게 고치겠는가?
풀이 보기
층마다 정렬이 Θ(𝑛log𝑛) 이 되므로 재귀식이 이렇게 된다.
𝑇(𝑛)=2𝑇(𝑛/2)+Θ(𝑛log𝑛)
3장의 마지막 예제에서 본 그 식이다. 잎 비용은 𝑛 인데 𝑓(𝑛)=𝑛log𝑛 이라 마스터 정리의 세 경우 틈에 빠진다. 재귀 트리로 풀면 Θ(𝑛log2𝑛) 이다.
고치는 법은 두 가지다.
미리 정렬해 둔다. 시작할 때 𝑦 순 목록을 한 번 만들고, 나눌 때 그 목록을 왼쪽 · 오른쪽으로 훑으며 갈라 넘긴다. 가르는 일이 Θ(𝑛) 이므로 층 비용이 Θ(𝑛) 로 돌아온다.
합치면서 정렬한다. 재귀가 정렬된 목록을 돌려주게 하고, 합치는 단계에서 병합 정렬의 합치기를 쓴다. 역시 Θ(𝑛) 이다.
어느 쪽이든 Θ(𝑛log𝑛) 가 된다. 같은 알고리즘인데 자료를 어떻게 들고 다니느냐로 log𝑛 배가 갈렸다. 분할정복에서 자주 나오는 함정이다.
예제 66
점이 세 개뿐일 때 위 절차를 그대로 부르면 무슨 일이 생기는가?
풀이 보기
반으로 나누면 한쪽에 점이 하나만 남는다. 점이 하나면 "가장 가까운 두 점"이 없으므로 𝛿𝐿 을 정의할 수 없다.
그래서 바닥 조건이 필요하다. 점이 서너 개 이하가 되면 나누지 말고 모든 쌍을 곧이곧대로 견준다. 세 점이면 쌍이 세 개뿐이다.
이것은 이 알고리즘만의 사정이 아니다. 앞 절의 카라추바에서도 자릿수가 짧아지면 초등학교 방법으로 갈아탔고, 8장의 인트로소트도 구간이 작아지면 삽입 정렬로 갈아탄다.
모든 분할정복은 바닥 문턱을 정해야 한다. 정하지 않으면 답이 틀리거나(이 예제) 값이 나빠진다(카라추바). 다음 절의 첫 번째 이야기가 그것이다.
언제 분할정복이 손해인가
나눌 수 있다는 것과 나누는 것이 이득이라는 것은 다르다. 손해가 나는 자리가 셋이다.
<왼쪽은 상수의 문제, 가운데는 부분문제가 겹치는 문제, 오른쪽은 합치는 값이 지배하는 문제다. 셋에 필요한 답이 각각 다르다>[원본 보기]
입력이 작을 때
재귀는 공짜가 아니다. 호출마다 스택에 자리를 잡고, 되돌아올 주소를 적고, 지역 변수를 만든다. 게다가 조각이 흩어지면 4장에서 본 캐시 지역성이 나빠진다.
그래서 𝑛 이 작을 때는 증가율이 나쁜 쪽이 이긴다. 3장에서 100𝑛 과 𝑛2 을 견준 예제와 같은 이야기다. 답은 문턱을 정해 갈아타는 것이고, 그 값은 재서 정한다.
부분문제가 겹칠 때
이것이 가장 중요하다. 분할정복은 나눈 조각이 서로 상관없다고 가정한다. 왼쪽을 푸는 일과 오른쪽을 푸는 일이 겹치지 않아야 층별 비용 계산이 성립한다.
겹치면 같은 것을 여러 번 푼다. 피보나치가 그렇다. 𝐹(𝑛)=𝐹(𝑛−1)+𝐹(𝑛−2) 를 그대로 재귀로 적으면 𝐹(𝑛−2) 를 두 갈래 모두에서 다시 푼다.
이때는 나누지 말고 답을 적어 두고 다시 쓰는 쪽으로 가야 한다. 그것이 11장의 동적 계획법이다.
합치는 값이 비쌀 때
마스터 정리 셋째 경우다. 𝑓(𝑛) 이 잎 비용을 누르면 𝑇(𝑛)=Θ(𝑓(𝑛)) 이 되어, 맨 위 한 번의 합치기가 전체를 정한다. 나누는 수고를 해도 답이 나아지지 않는다.
앞의 첫 예제(9𝑇(𝑛/3)+𝑛2)에서는 한술 더 떠 나누는 바람에 log𝑛 배가 늘었다.
설계 판단
다음 넷에 분할정복을 쓰겠는가. (1) 정렬된 100만 개 배열에서 값 찾기 (2) 𝐹1000 구하기 (3) 원소 12개짜리 배열 정렬 (4) 큰 수 두 개 곱하기(각 5000자리)
풀이 보기
각각 어느 함정에 걸리는지 보면 된다.
(1) 쓴다. 이진 탐색이다. 조각이 겹치지 않고 합치는 값이 없다. 견주기 20번이면 끝난다.
(2) 쓰지 않는다 — 그대로는.𝐹(𝑛−1) 과 𝐹(𝑛−2) 의 부분문제가 통째로 겹친다. 그대로 재귀하면 호출 수가 지수로 늘어난다. 11장의 표로 Θ(𝑛) 에 풀거나, 앞 절의 행렬 거듭제곱으로 Θ(log𝑛) 번의 곱셈에 푼다. 같은 문제에 세 가지 답이 있고 셋의 값이 다 다르다.
(3) 쓰지 않는다.𝑛=12 면 삽입 정렬이 이긴다. 122=144 이고 12log212≈43 이지만 재귀 호출과 보조 배열의 상수가 그 차이를 삼킨다.
(4) 쓴다. 5000자리면 곧이곧대로가 2.5×107 번이고 카라추바는 50001.585≈6×105 번 규모다. 문턱을 훌쩍 넘었다.
넷을 가르는 질문은 언제나 같다. 조각이 겹치는가, 그리고 상수를 이길 만큼 큰가.
예제 68
배열에서 연속된 구간의 합이 가장 큰 것을 찾는 문제를 분할정복으로 풀어라. 예를 들어 [−2,1,−3,4,−1,2,1,−5,4] 에서 답은 4+(−1)+2+1=6 이다.
풀이 보기
배열을 반으로 나누면 답이 있을 곳이 셋이다. 왼쪽 안, 오른쪽 안, 그리고 가운데를 걸치는 것. 앞의 둘은 재귀가 준다.
걸치는 것은 어떻게 구하는가. 가운데에서 왼쪽으로 뻗어 가며 최대 합을 구하고, 오른쪽으로 뻗어 가며 최대 합을 구해 더하면 된다. 걸치는 구간은 반드시 가운데를 지나므로 이 둘의 조합이 전부다.
뻗어 가는 일이 각각 한 번의 훑기이므로 합치는 값이 Θ(𝑛) 이다.
𝑇(𝑛)=2𝑇(𝑛/2)+Θ(𝑛)⟹Θ(𝑛log𝑛)
그런데 이 문제에는 Θ(𝑛) 짜리 답이 있다. 왼쪽부터 훑으며 "여기서 끝나는 구간 중 최대"를 들고 가면 되고, 그것이 11장에서 볼 동적 계획법이다.
이 예제가 말하는 것은 분명하다. 분할정복으로 풀린다고 그것이 가장 좋은 답은 아니다. 나누는 대신 "왼쪽에서 오른쪽으로 한 번 훑으며 필요한 것만 들고 간다"가 가능한지 늘 함께 물어야 한다.
이 장의 정리
분할정복은 재귀식을 세우는 기술이다. 알고리즘을 고안하는 일의 절반이 "무엇을 기준으로 나눌 것인가"를 정하는 일이고, 나머지 절반이 "조각의 답을 어떻게 이을 것인가"다.
<나눌 수 있는가와 나누는 것이 이득인가는 다른 질문이다. 조각이 겹치면 11장으로, 합치는 값이 무거우면 나누지 않는 쪽으로 간다>[원본 보기]
알고리즘
재귀식
비용
무엇이 요점인가
이진 탐색
𝑇(𝑛/2)+1
Θ(log𝑛)
합치는 단계가 없다. 한쪽만 재귀한다
병합 정렬
2𝑇(𝑛/2)+𝑛
Θ(𝑛log𝑛)
값이 전부 합치는 단계에 있다
역위 세기
2𝑇(𝑛/2)+𝑛
Θ(𝑛log𝑛)
합치는 김에 덤을 센다
최대 연속 구간합
2𝑇(𝑛/2)+𝑛
Θ(𝑛log𝑛)
경계를 걸치는 경우만 따로 센다
빠른 거듭제곱
𝑇(𝑛/2)+1
Θ(log𝑛)
절반짜리를 변수에 담아 한 번만 구한다
카라추바 곱셈
3𝑇(𝑛/2)+𝑛
Θ(𝑛1.585)
필요한 것이 합 하나뿐이라 곱셈을 던다
스트라센 곱셈
7𝑇(𝑛/2)+𝑛2
Θ(𝑛2.807)
블록 곱셈 여덟 번을 일곱 번으로
최근접 점 쌍
2𝑇(𝑛/2)+𝑛
Θ(𝑛log𝑛)
띠 안에서 여섯 점만 보면 된다는 기하 사실
용어 · 사실
뜻
분할정복
나누고 · 재귀로 풀고 · 합친다. 조각이 겹치지 않아야 한다
바닥 문턱
조각이 작아지면 단순한 방법으로 갈아타는 크기
꼬리 재귀
재귀 호출이 함수의 마지막 일. 반복문으로 펼 수 있다
답에 대한 이진 탐색
최적값 찾기를 판정 문제의 이진 탐색으로 바꾸는 것. 단조성이 필요하다
역위
앞에 있는데 더 큰 쌍. 합치기 도중 왼쪽 잔량으로 센다
유사 다항 시간
값에 대해서는 다항, 입력 자릿수에 대해서는 지수 (3장)
다음 장은 이 틀이 실제로 무엇을 푸는지 본다. 정렬이다. 여기서 만든 병합 정렬이 거기서 다시 나오고, 일을 합치는 쪽이 아니라 나누는 쪽에 몰아넣은 퀵 정렬이 그 짝으로 붙는다. 값은 이 장의 재귀식이 이미 답했으므로 8장이 묻는 것은 다른 것이다. 정렬로서 어떤가 — 같은 값의 앞뒤가 남는가, 보조 배열이 드는가, 캐시에 맞는가, 그래서 어느 상황에 무엇을 고르는가. 남은 두 설계 기법인 탐욕과 동적 계획법은 10 · 11장에서 세운다.
정렬과 선택
정렬은 알고리즘 책에서 가장 많은 자리를 차지하는 주제다. 그런데 이상하다. 답이 하나뿐인 문제인데 방법이 왜 열 가지가 넘는가. 라이브러리에 정렬 함수가 이미 있는데 왜 이것들을 다 배워야 하는가.
답할 질문은 넷이다. 왜 정렬 방법이 이렇게 많은가, 𝑂(𝑛log𝑛) 보다 빠를 수는 없는가, 없다면 그것을 어떻게 증명하는가, 그리고 그 증명을 비껴가는 방법이 있는가.
앞에서 가져오는 것이 많다. 3장의 점근 표기와 마스터 정리, 최선·평균·최악, 4장의 캐시 지역성(같은 𝑂(𝑛log𝑛) 인데 실제 속도가 다른 이유가 여기서 나온다), 6장의 힙, 그리고 7장의 분할정복이다. 마지막 것이 이 장의 뼈대다. 병합 정렬과 퀵 정렬은 7장에서 세운 그 틀의 대표 사례이고, 재귀식과 마스터 정리로 값을 읽는 일은 거기서 이미 했다. 그러니 이 장은 그 계산을 되풀이하는 대신 정렬로서 어떤가를 묻는다.
정렬을 고를 때 보는 것
정렬 알고리즘이 여럿인 이유는 정렬이 하나의 문제가 아니기 때문이다. 무엇을 정렬하는지, 메모리가 얼마나 있는지, 입력이 이미 얼마나 정렬되어 있는지에 따라 다른 답이 나온다. 그 차이를 재는 축이 넷이다.
<점수가 같은 두 사람의 앞뒤가 남는 것이 안정성이다. 제자리 정렬은 배열 밖에 상수 칸만 쓰고 병합 정렬은 n 칸을 더 쓴다>[원본 보기]
안정성(stability) — 키가 같은 원소들의 입력 순서가 결과에서도 유지되면 안정 정렬이다.
제자리(in-place) — 입력 배열 밖에 쓰는 보조 공간이 𝑂(1) 이면 제자리 정렬이다. 3장에서 정의한 보조 공간이 그대로 쓰인다.
비교 기반인가 — 원소를 견주는 것만으로 정렬하는가, 아니면 키의 값 자체를 자리로 쓰는가.
무엇이 비싼가 — 견주기가 비싼가 옮기기가 비싼가. 원소가 큰 구조체면 옮기기가 훨씬 비싸다.
안정성이 왜 중요한지는 키가 여럿일 때 드러난다. 이름순으로 한 번 정렬한 뒤 점수순으로 안정 정렬하면 점수가 같은 사람들 사이에서는 이름순이 그대로 남는다. 두 번 정렬해 두 단계 기준을 만드는 이 요령은 안정 정렬로만 된다.
비교 기반 정렬은 원소에 대해 "어느 쪽이 큰가"만 물을 수 있다. 값이 얼마인지, 몇 자리인지는 묻지 않는다. 그래서 정수든 문자열이든 사람이 만든 임의의 순서든 똑같이 쓸 수 있다. 이 장 중반의 하한은 정확히 이 제약에서 나온다.
예제 69
이미 오름차순으로 정렬된 배열을 넣었을 때, 견주기 횟수가 𝑛−1 번으로 끝나는 정렬은 어느 것인가? 그리고 그것이 왜 실무에서 중요한가?
풀이 보기
묻는 것은 최선의 경우다. 3장에서 본 대로 최선·평균·최악은 "어떤 입력을 재는가"의 문제이고, 점근 표기와는 다른 축이다.
이미 정렬되어 있으면 이웃한 두 개가 전부 제 순서다. 그러니 이웃끼리만 견주는 알고리즘은 한 바퀴 돌면서 "한 번도 바꾸지 않았다"를 확인하고 끝낼 수 있다. 버블 정렬과 삽입 정렬이 그렇다.
선택 정렬은 다르다. 남은 구간의 최솟값을 찾으려면 구간 전체를 봐야 하고, 그 일은 이미 정렬되어 있든 아니든 똑같이 𝑛(𝑛−1)/2 번이다. 최선도 최악도 Θ(𝑛2) 다.
실무에서 중요한 이유는 실제 자료가 대체로 부분적으로 정렬되어 있기 때문이다. 로그 파일에 새 기록을 붙이고, 이미 정렬된 목록에 몇 건을 추가하고, 거의 정렬된 두 목록을 이어 붙인다. 이 성질을 활용하는 정렬이 뒤에 나올 팀소트다.
설계 판단
원소가 하나에 4 KB 인 구조체 100만 개를 정렬한다. 견주기는 정수 하나만 보면 되어 아주 싸다. 어떤 점을 가장 먼저 보겠는가?
풀이 보기
묻는 것은 무엇을 세어야 하는가다. 3장에서 "기본 연산을 무엇으로 잡는가"가 첫 판단이라고 했다.
여기서 비싼 것은 견주기가 아니라 옮기기다. 4 KB 를 한 번 복사하는 값이 정수 비교보다 수백 배 크다. 그러니 견주기 횟수가 같더라도 이동 횟수가 적은 쪽을 골라야 한다.
더 나은 답이 있다. 구조체를 옮기지 말고 포인터(또는 첨자)만 정렬하는 것이다. 정렬 대상이 8바이트짜리 주소가 되므로 이동이 500배 싸진다.
이 방법의 대가는 캐시 지역성이다. 4장에서 본 대로 정렬된 포인터를 따라 원본을 읽으면 메모리를 뛰어다니게 된다. 정렬한 뒤 순서대로 한 번 훑을 일이 많다면, 마지막에 한 번 실제 자료를 재배치하는 편이 낫다.
정렬을 고르는 문제가 아니라 무엇을 정렬할지를 고르는 문제였다. 자료구조를 바꾸면 알고리즘 선택이 무의미해지는 일이 자주 있다.
가장 단순한 셋 — 버블 · 선택 · 삽입
셋 다 Θ(𝑛2) 이고 셋 다 "확정된 구간을 조금씩 늘려 간다"는 뼈대가 같다. 다른 것은 그 구간이 어느 쪽에서 자라는가와 한 걸음에 무엇을 하는가다.
<초록으로 칠한 칸이 이미 확정된 자리다. 버블은 뒤에서, 선택과 삽입은 앞에서 자란다. 선택 정렬의 앞부분은 최종 위치이지만 삽입 정렬의 앞부분은 아직 뒤로 밀릴 수 있다>[원본 보기]
말로 적으면 각각 한 줄이다.
버블 정렬 — 이웃한 두 개를 견주어 순서가 어긋나면 바꾼다. 한 바퀴 돌면 가장 큰 값이 맨 뒤에 놓인다. 한 바퀴 동안 한 번도 바꾸지 않았으면 끝난 것이다.
선택 정렬 — 아직 정하지 않은 구간에서 최솟값을 찾아 그 구간의 맨 앞과 바꾼다.
삽입 정렬 — 앞쪽 구간은 늘 정렬되어 있다. 다음 값을 그 구간의 제자리에 끼워 넣는다.
↓ python
def 삽입정렬(배열):
for 자리 in range(1, len(배열)):
값 = 배열[자리]
뒤 = 자리 - 1
while 뒤 >= 0 and 배열[뒤] > 값: # 값보다 큰 것들을 한 칸씩 민다
배열[뒤+1] = 배열[뒤]
뒤 -= 1
배열[뒤+1] = 값
바깥 반복이 𝑛−1 번이고 안쪽에서 많아야 그 앞의 원소 수만큼 밀므로 최악이 1+2+⋯+(𝑛−1)=𝑛(𝑛−1)/2=Θ(𝑛2) 이다. 이미 정렬되어 있으면 while 이 곧바로 끝나 Θ(𝑛) 이다.
정렬
최선
평균 · 최악
교환 횟수
안정
제자리
버블
Θ(𝑛)
Θ(𝑛2)
최악 Θ(𝑛2)
예
예
선택
Θ(𝑛2)
Θ(𝑛2)
언제나 𝑛−1 번 이하
아니오
예
삽입
Θ(𝑛)
Θ(𝑛2)
최악 Θ(𝑛2)
예
예
선택 정렬이 불안정한 이유를 짚고 가자. 최솟값을 멀리 있는 자리와 통째로 바꾸기 때문에, 그 사이에 있던 같은 키의 원소를 건너뛰어 앞뒤가 뒤집힌다. 예를 들어 (3𝑎,3𝑏,1) 을 정렬하면 1 과 3𝑎 가 자리를 바꿔 (1,3𝑏,3𝑎) 가 된다.
셋 다 느린데도 삽입 정렬은 지금도 쓰인다. 상수가 아주 작고, 제자리이고, 안정이고, 거의 정렬된 입력에서 Θ(𝑛) 이기 때문이다. 3장의 표에서 본 대로 𝑛 이 작으면 증가율보다 상수가 이긴다.
예제 71
삽입 정렬의 이동 횟수가 역위(inversion)의 개수와 정확히 같음을 보여라. 역위란 𝑖<𝑗 인데 𝑎𝑖>𝑎𝑗 인 쌍이다.
풀이 보기
보일 것은 "한 번 밀 때마다 역위가 정확히 하나 없어진다"는 것이다.
안쪽 while 이 한 번 돌 때 무슨 일이 일어나는지 보자. 배열[뒤]>값 이므로 이 둘은 역위를 이루는 쌍이다. 그리고 이 둘의 앞뒤를 바꾸는 것이 미는 동작이다.
이 동작은 다른 쌍의 순서를 바꾸지 않는다. 이웃한 두 개만 건드리기 때문이다. 그러므로 미는 동작 한 번에 역위가 정확히 하나 줄어든다.
정렬이 끝나면 역위가 0 개이므로, 미는 횟수의 합은 처음 배열의 역위 개수 𝐼 와 같다. 견주기까지 세면 전체 비용은 Θ(𝑛+𝐼) 다.
확인하자. 이미 정렬된 배열은 𝐼=0 이라 Θ(𝑛) 이고, 완전히 뒤집힌 배열은 모든 쌍이 역위라 𝐼=𝑛(𝑛−1)/2 여서 Θ(𝑛2) 이다. 앞의 표와 맞는다.
이 결과가 말하는 것은 삽입 정렬이 "정렬된 정도"에 정확히 비례한다는 것이다. 원소가 제자리에서 많아야 몇 칸 떨어져 있는 자료라면 사실상 선형이다.
예제 72
버블 정렬에서 "한 바퀴 동안 한 번도 바꾸지 않으면 멈춘다"는 장치를 뺐다고 하자. 최선의 경우가 어떻게 달라지는가? 선택 정렬도 같은 장치를 달 수 있는가?
풀이 보기
묻는 것은 조기 종료가 복잡도의 어느 축을 바꾸는가다.
장치를 빼면 이미 정렬된 입력에서도 𝑛−1 바퀴를 다 돈다. 최선이 Θ(𝑛) 에서 Θ(𝑛2) 로 나빠진다. 평균과 최악은 그대로다. 어차피 대부분의 입력에서는 마지막 바퀴까지 교환이 일어난다.
선택 정렬에는 이 장치를 달 수 없다. 조기 종료가 성립하려면 "더 할 일이 없다"를 싸게 확인할 수 있어야 하는데, 선택 정렬이 하는 일은 최솟값 찾기이고 그것은 구간을 다 봐야 안다.
다르게 말하면, 버블과 삽입은 지역적인 정보(이웃한 두 개)로 진행하므로 "지역적으로 다 맞다 = 전체가 맞다"가 성립한다. 선택 정렬은 전역적인 정보(구간의 최솟값)를 쓰므로 그런 지름길이 없다.
병합 정렬 — 합치기가 성질을 정한다
지금까지의 셋은 한 번에 원소 하나를 제자리에 놓았다. 병합 정렬(merge sort)은 다르게 접근한다. 반으로 나누고, 두 반쪽을 각각 같은 방법으로 정렬하고, 정렬된 둘을 합친다. 7장의 병합 정렬에서 만든 그 절차이고, 𝑇(𝑛)=2𝑇(𝑛/2)+Θ(𝑛) 을 마스터 정리 둘째 경우로 읽어 Θ(𝑛log𝑛) 이 나온 것도 거기서 했다. 이 절이 묻는 것은 값이 아니라 성질이다.
<쪼개는 데는 일이 들지 않고 값은 전부 합치는 층에서 움직인다. 합치는 층이 log₂ n 개이고 층마다 값 n 개가 한 번씩 옮겨 가는 것이 이 정렬의 전부다>[원본 보기]
그 성질은 전부 합치는 단계에서 나온다. 이미 정렬된 두 줄을 합치는 일은 두 줄의 맨 앞만 견주면 되어 𝑂(𝑛) 인데, 그 한 번의 견주기를 어떻게 하느냐가 안정성을 정하고, 결과를 어디에 담느냐가 보조 공간을 정한다.
<주황 삼각형이 각 줄에서 아직 꺼내지 않은 첫 칸이다. 작은 쪽을 결과로 옮기고 그 줄의 삼각형만 한 칸 나아간다. 삼각형이 뒤로 가는 일이 없으므로 전체 이동이 두 줄의 길이 합이다>[원본 보기]
↓ python
def 합치기(왼쪽, 오른쪽):
결과 = []
while 왼쪽 and 오른쪽:
작은쪽 = 왼쪽 if 왼쪽[0] <= 오른쪽[0] else 오른쪽
결과.append(작은쪽.pop(0))
return 결과 + 왼쪽 + 오른쪽 # 한쪽이 비면 나머지를 그대로 잇는다
합치기의 부등호가 ≤ 인 것이 안정성을 만든다. 두 값이 같을 때 왼쪽 것을 먼저 꺼내므로 앞선 원소가 앞에 남고, < 로 바꾸면 그 자리에서 불안정해진다. 값은 공간이다. 합치기가 결과를 담을 곳을 따로 요구하므로 보조 공간이 Θ(𝑛) 이다.
설계 판단
8 GB 짜리 파일을 정렬해야 하는데 메모리가 1 GB 뿐이다. 어떻게 하겠는가?
풀이 보기
묻는 것은 자료 전체를 메모리에 올릴 수 없을 때 무엇이 달라지는가다.
퀵 정렬이나 힙 정렬은 배열 전체에 임의 접근을 하므로 쓸 수 없다. 6장에서 B-트리를 볼 때와 같은 상황이다. 비싼 연산은 견주기가 아니라 디스크 읽기·쓰기이고, 디스크는 순차 접근이 임의 접근보다 압도적으로 빠르다.
병합 정렬의 합치기는 양쪽을 앞에서부터 한 번씩만 훑는다. 정확히 순차 접근이다. 여기에 맞춰 두 단계로 한다.
1단계. 파일을 1 GB 보다 작은 조각(예컨대 800 MB)으로 잘라 하나씩 메모리에 올려 정렬하고 임시 파일로 쓴다. 조각이 10개 생긴다.
2단계. 10개 파일의 맨 앞 값만 들고 있다가 가장 작은 것을 꺼내 결과에 쓰기를 반복한다. "여럿 중 가장 작은 것"이 필요하므로 6장의 최소 힙을 크기 10 으로 둔다.
읽고 쓰는 총량은 조각 만들기에서 2×8 GB, 합치기에서 다시 2×8 GB 다. 파일을 통째로 네 번 지나가는 셈이고, 이것을 외부 정렬이라 한다.
조각 수가 아주 많아 한 번에 합칠 수 없으면 여러 번에 나눠 합친다. 그때 지나가는 횟수가 log𝑘(조각수) 로 늘어난다.
힙 정렬
6장에서 만든 힙은 가장 큰 것을 𝑂(log𝑛) 에 꺼내 준다. 그러면 정렬은 곧바로 나온다. 전부 넣고 하나씩 꺼내면 된다.
그런데 그렇게 하면 힙을 담을 배열이 따로 필요해 제자리가 아니다. 요령은 꺼낸 값을 배열의 뒤쪽에 쌓는 것이다. 힙이 한 칸 줄어들 때마다 뒤쪽에 한 칸이 비므로 자리가 정확히 맞는다.
<뿌리와 맨 뒤 칸을 바꾸면 최댓값이 최종 위치로 가고 힙은 하나 줄어든다. 배열 하나 안에서 앞은 힙, 뒤는 정렬 완료 구간으로 쓰므로 추가 칸이 상수다>[원본 보기]
절차를 말로 적는다. 배열 전체를 최대 힙으로 만든다. 뿌리와 맨 뒤를 바꾸고 힙 크기를 하나 줄인 뒤, 새 뿌리를 내린다. 힙 크기가 1 이 될 때까지 되풀이한다.
↓ python
def 힙정렬(배열):
힙만들기(배열) # 아래층부터 내리기. O(n)
for 끝 in range(len(배열)-1, 0, -1):
교환(배열, 0, 끝) # 최댓값을 제자리로
내리기(배열, 0, 끝) # 앞의 끝 칸만 힙으로 다시 맞춘다
힙 만들기가 𝑂(𝑛) 이고 꺼내기를 𝑛−1 번 하는데 각각 내리기가 𝑂(log𝑛) 이므로 전체가 𝑂(𝑛log𝑛) 이다. 최악에서도 그렇다는 것이 힙 정렬의 강점이다.
그런데 실무에서 힙 정렬은 퀵 정렬보다 보통 두세 배 느리다. 같은 𝑂(𝑛log𝑛) 인데 그렇다. 이유는 4장에 있다. 내리기는 첨자 𝑖 에서 2𝑖+1 로 뛰므로 배열을 멀리 건너뛰며 접근하고, 캐시가 미리 읽어 둔 줄이 매번 헛것이 된다. 그래서 힙 정렬의 자리는 "가장 빠른 정렬"이 아니라 "최악이 없는 보험"이다. 뒤에서 볼 인트로소트가 이 성질을 그렇게 쓴다.
예제 74
최대 힙 [16,4,9,2,1,3] 을 힙 정렬로 정렬하는 첫 두 단계를 손으로 따라가라.
풀이 보기
6장에서 만든 그 힙이다. 배열의 첨자는 0부터 5 다.
1단계. 뿌리(첨자 0, 값 16)와 맨 뒤(첨자 5, 값 3)를 바꾼다. [3,4,9,2,1,16] 이 되고 16 은 확정이다. 힙 크기를 5 로 줄인다.
이제 3 을 내린다. 자식은 첨자 1(값 4)과 2(값 9)다. 더 큰 쪽인 9 와 바꾼다. [9,4,3,2,1∣16]. 3 은 첨자 2 로 갔고 그 자식은 첨자 5 인데 힙 크기 5 밖이므로 멈춘다.
1 을 내린다. 자식은 4 와 3 이니 4 와 바꾼다. [4,1,3,2∣9,16]. 1 은 첨자 1 이고 자식은 첨자 3(값 2)뿐이다. 2>1 이므로 한 번 더 바꾼다. [4,2,3,1∣9,16].
두 단계 만에 뒤쪽 두 칸이 9,16 로 확정되었다. 최댓값부터 뒤에서 채워 나가므로 결과는 오름차순이 된다. 최소 힙을 쓰면 내림차순이 나온다는 점도 기억해 두면 좋다.
퀵 정렬
병합 정렬은 나눌 때는 아무 생각 없이 나누고 합칠 때 일을 다 했다.퀵 정렬(quick sort)은 반대다. 나눌 때 일을 다 하고 합칠 때는 아무것도 하지 않는다. 둘 다 7장의 분할정복이고 갈리는 것은 일이 어느 단계에 있느냐 하나인데, 정렬로서의 성질 차이는 거의 전부 그 하나에서 나온다. 병합 정렬은 합치는 자리에서 순서를 통제할 수 있어 안정적이고, 퀵 정렬은 합칠 것이 없어 보조 배열이 필요 없다.
나누는 방법은 이렇다. 값 하나를 피벗(pivot)으로 고르고 피벗보다 작은 것을 왼쪽으로, 크거나 같은 것을 오른쪽으로 몬다. 그러면 피벗은 최종 위치에 놓이고 왼쪽과 오른쪽을 각각 정렬하면 이어 붙일 필요조차 없다.
<경계는 지금까지 확인한 작은 값들의 끝이고 자리는 지금 보고 있는 칸이다. 작은 값을 만날 때만 경계와 교환하며 경계를 한 칸 밀고, 마지막에 피벗을 경계 자리로 옮긴다>[원본 보기]
절차를 말로 적는다. 맨 뒤 값을 피벗으로 삼는다. 왼쪽부터 한 칸씩 보면서 피벗보다 작은 값을 만나면 경계 자리와 바꾸고 경계를 한 칸 민다. 다 보고 나서 피벗을 경계 자리로 옮긴다.
↓ python
def 분할(배열, 시작, 끝): # 피벗은 배열[끝]
경계 = 시작
for 자리 in range(시작, 끝):
if 배열[자리] < 배열[끝]:
교환(배열, 자리, 경계)
경계 += 1
교환(배열, 경계, 끝)
return 경계
구간을 한 번 훑으므로 분할은 Θ(𝑛) 이고 보조 공간은 상수다. 정렬 전체는 분할한 뒤 양쪽을 재귀로 부르는 두 줄이다.
비용은 분할이 얼마나 고르게 되느냐에 달렸다. 7장에서 "고르게 나누는 것이 분할정복의 핵심"이라 한 것이 여기서 현실이 된다. 나누는 자리를 우리가 정하는 것이 아니라 입력이 정하기 때문이다. 재귀식과 마스터 정리는 그 장의 것을 그대로 쓴다.
반반으로 갈리면 𝑇(𝑛)=2𝑇(𝑛/2)+𝑛 이라 마스터 정리 둘째 경우로 Θ(𝑛log𝑛) 이다.
한쪽이 비면 𝑇(𝑛)=𝑇(𝑛−1)+𝑛 이라 𝑛+(𝑛−1)+⋯=Θ(𝑛2) 이다.
평균은 Θ(𝑛log𝑛) 이다. 대충 말하면, 피벗이 가운데 절반(25번째~75번째 백분위)에 들면 큰 쪽이 원래의 4분의 3 이하가 되는데 그럴 확률이 절반이다. 그러니 두 번에 한 번꼴로 크기가 4분의 3 이 되고, (3/4)𝑘𝑛=1 이 되는 𝑘 는 log𝑛 의 상수배다.
그런데 맨 뒤를 피벗으로 삼으면 이미 정렬된 입력이 최악이 된다. 그리고 6장에서 본 대로 정렬된 입력은 실무의 기본값이다. 그래서 피벗을 이렇게 고른다.
피벗 고르기
방법
무엇을 막는가
무작위
구간에서 하나를 무작위로 골라 맨 뒤와 바꾼다
특정 입력이 항상 최악이 되는 것
세 값의 중앙값
맨 앞 · 가운데 · 맨 뒤 셋 중 중앙값을 쓴다
정렬된 입력과 역순 입력
중앙값의 중앙값
다섯씩 묶어 중앙값을 재귀로 구한다
최악 자체. 대신 상수가 크다
재귀 깊이도 손봐야 한다. 최악에서 깊이가 𝑛 이 되면 호출 스택이 터지므로, 작은 쪽을 먼저 재귀로 부르고 큰 쪽은 반복문으로 처리한다. 작은 쪽은 언제나 절반 이하이므로 스택 깊이가 𝑂(log𝑛) 로 묶인다. 이렇게 손보고 나면 퀵 정렬은 실측에서 가장 빠른 비교 정렬이다. 제자리라 추가 메모리가 없고, 분할이 배열을 앞에서 뒤로 순차 접근하므로 캐시에 잘 맞는다. 4장에서 말한 "같은 𝑂(𝑛log𝑛) 인데 실제 속도가 다른 이유"가 여기서 결론에 이른다.
예제 75
배열 [1,2,3,4,5] 에 맨 뒤 피벗 퀵 정렬을 쓰면 견주기가 몇 번 일어나는가? 무작위 피벗은 이 문제를 어떻게 푸는가?
풀이 보기
피벗이 5 이므로 모든 값이 그보다 작아 경계가 끝까지 밀린다. 분할 결과는 왼쪽 4개, 오른쪽 0개다. 한 개도 못 떼어 낸 셈이다.
견주기는 크기 5 에서 4번, 크기 4 에서 3번, ... 해서 4+3+2+1=10 번이다. 일반적으로 𝑛(𝑛−1)/2 번, 곧 Θ(𝑛2) 다.
무작위 피벗은 입력을 바꾸는 것이 아니라 알고리즘을 바꾼다. 어떤 입력이 들어와도 피벗이 고르게 뽑히므로, 특정 입력이 항상 최악이 되는 일이 사라진다.
최악 자체가 없어지지는 않는다. 운이 나쁘면 여전히 Θ(𝑛2) 다. 다만 그 확률이 𝑛 이 커질수록 급격히 작아진다. "최악의 입력"이 "최악의 운"으로 바뀐 것이고, 악의를 가진 사용자가 입력을 고를 수 있는 상황에서는 이 차이가 결정적이다.
실제로 이 성질을 노린 공격이 있었다. 해시 테이블의 충돌을 노린 공격(5장)과 같은 발상이며, 대응도 같다. 알고리즘에 예측할 수 없는 요소를 넣는 것이다.
예제 76
원소가 전부 같은 배열에 위 분할 코드를 쓰면 어떻게 되는가? 왜 그것이 문제이며 어떻게 고치는가?
풀이 보기
조건이 배열[자리]<배열[끝] 인데 모든 값이 같으므로 참이 되는 일이 없다. 경계가 한 번도 움직이지 않고 마지막에 피벗이 맨 앞으로 간다.
그러면 분할이 "왼쪽 0개, 오른쪽 𝑛−1 개"로 갈린다. 최악이다. 값이 다 같아 아무 일도 할 필요가 없는 입력인데 Θ(𝑛2) 이 걸린다.
고치는 방법은 세 구간으로 나누는 것이다. 피벗보다 작은 것, 같은 것, 큰 것으로 나누고 같은 구간은 재귀하지 않는다. 원문에 나오는 세 구간 분할이 이것이고, 네덜란드 국기 문제라는 이름으로 알려져 있다.
이 경우 값이 전부 같으면 한 번의 분할로 끝나 Θ(𝑛) 이 된다. 중복 키가 많은 자료(성별, 등급, 상태 코드)에서 큰 차이가 난다.
흔한 실수는 조건을 ≤ 로 바꾸는 것이다. 그러면 모든 값이 왼쪽으로 몰려 여전히 한쪽으로 치우친다. 방향만 반대일 뿐 같은 최악이다.
비교 정렬의 하한 — Ω(𝑛log𝑛)
여기가 이 장에서 가장 걸리는 곳이다. 지금까지 본 것은 "이 알고리즘은 𝑂(𝑛log𝑛) 이다"라는 위쪽 이야기였다. 이제 아래쪽을 묻는다 — 아무리 영리해도 그보다 빠를 수 없다는 것을 어떻게 보이는가.
어려운 이유는 존재하지 않는 것을 증명해야 하기 때문이다. 알고리즘 하나가 느리다는 것은 세어 보면 되지만, 앞으로 나올 것까지 포함해 전부 느리다는 것은 다른 논법이 필요하다. 그 논법은 알고리즘을 하나하나 보는 대신 알고리즘이 무엇을 할 수 있는지를 제한하는 것이다. 비교 정렬이 할 수 있는 일은 하나뿐이다. 두 원소를 견주어 예 또는 아니오를 얻는 것. 그러면 어떤 비교 정렬이든 예·아니오 질문의 나무로 그릴 수 있다. 이것을 결정 트리(decision tree)라 한다.
<내부 노드는 견주기 하나이고 잎은 최종 순서 하나다. 알고리즘 하나를 실행한다는 것은 뿌리에서 잎까지 내려가는 길 하나이며 그 길의 길이가 그 입력에서의 견주기 횟수다>[원본 보기]
이 나무의 성질 셋을 확인하자.
내부 노드는 견주기 하나이고 자식이 둘(예·아니오)이므로 이진트리다.
잎은 알고리즘이 내놓는 결과 하나다. 원소 𝑛 개의 가능한 순서가 𝑛! 가지이고 알고리즘은 그 전부를 구분해야 하므로 잎이 적어도 𝑛! 개 있어야 한다.
어떤 입력에서의 견주기 횟수는 그 입력에 해당하는 잎까지의 깊이다. 최악의 경우는 나무의 높이다.
둘째 항목이 이 증명의 핵심이므로 한 번 더 짚는다. 서로 다른 두 순서가 같은 잎에 도달하면 알고리즘은 그 둘을 구별하지 못하고 같은 답을 내놓는다. 둘 중 하나에는 틀린 답이다. 그러니 잎이 𝑛! 개보다 적을 수 없다.
증명
어떤 비교 정렬도 최악의 경우 Ω(𝑛log𝑛) 번 견주어야 함을 보여라.
풀이 보기
임의의 비교 정렬 알고리즘을 하나 잡고 그 결정 트리를 𝑇 라 하자. 높이를 ℎ 라 하면 ℎ 가 곧 최악의 견주기 횟수다.
1단계. 6장에서 본 대로 높이 ℎ 인 이진트리의 잎은 많아야 2ℎ 개다. 층마다 노드가 두 배씩 늘기 때문이다.
2단계. 잎이 적어도 𝑛! 개이므로 𝑛!≤2ℎ 다. 양변에 밑이 2 인 로그를 취하면
ℎ≥log2(𝑛!)
3단계.log2(𝑛!) 이 얼마나 큰지 봐야 한다. 로그를 곱의 합으로 풀면
log2(𝑛!)=∑𝑛𝑘=1log2𝑘
이 합의 뒤쪽 절반만 남기고 앞쪽은 버린다. 버린 항이 모두 음이 아니므로 부등호는 유지된다. 남은 항은 𝑛/2 개이고 각각 log2(𝑛/2) 이상이다.
log2(𝑛!)≥𝑛2log2𝑛2=𝑛2log2𝑛−𝑛2
4단계.𝑛≥4 면 𝑛2log2𝑛−𝑛2≥𝑛4log2𝑛 이다. 3장의 정의에 따라 상수 1/4 와 자리 𝑛0=4 를 잡은 것이므로
ℎ=Ω(𝑛log𝑛)
증명이 쓴 가정은 "알고리즘이 얻는 정보가 예·아니오뿐" 하나다. 어떤 자료구조를 쓰든, 얼마나 영리하든 상관없다. 그러니 다음 절의 정렬들은 이 가정을 깨야만 한다.
참고로 더 정밀한 근사(스털링 공식)를 쓰면 log2(𝑛!)≈𝑛log2𝑛−1.44𝑛 이다. 𝑛=100 이면 약 525 번인데, 실제로 100개를 정렬하는 데 필요한 최소 견주기가 525번임이 알려져 있다. 하한이 헐겁지 않다.
예제 78
원소 3개를 정렬하는 데 최악의 경우 견주기가 3번 필요하다. 하한 ⌈log2(3!)⌉ 로 이것이 설명되는가? 그럼 원소 5개는 몇 번인가?
풀이 보기
3!=6 이고 log26≈2.58 이므로 ⌈2.58⌉=3 이다. 하한이 3 이고 그림의 결정 트리가 높이 3 으로 실제로 3번에 해내므로 하한이 정확히 달성된다.
5!=120 이고 log2120≈6.9 이므로 하한은 7 이다. 실제로 5개를 7번에 정렬하는 방법이 알려져 있어 여기서도 하한이 달성된다.
그러나 하한이 언제나 달성되지는 않는다.𝑛=12 에서는 하한이 29 인데 실제 최솟값은 30 이다. 결정 트리 논법은 "잎이 그만큼 있어야 한다"만 쓰고, 트리가 실제로 그 모양을 가질 수 있는지는 따지지 않기 때문이다.
이것이 하한 증명의 일반적인 성격이다. 하한은 "이보다 잘할 수 없다"를 말할 뿐 "이만큼 잘할 수 있다"를 말하지 않는다. 뒤쪽은 알고리즘을 실제로 만들어 보여야 한다.
견주지 않는 정렬
앞 절의 증명은 "견주기만 한다"는 가정에 전부 걸려 있다. 그 가정을 깨면 하한도 함께 사라진다. 키의 값을 직접 자리로 쓰는 정렬들이 그렇게 한다.
<계수 정렬은 값 3 을 세 번째 칸의 개수로 세고, 기수 정렬은 낮은 자리부터 안정 정렬을 되풀이한다. 둘 다 원소끼리 견주는 일이 한 번도 없다>[원본 보기]
계수 정렬(counting sort)은 키가 0 부터 𝑘 까지의 정수일 때 쓴다. 절차는 셋이다. 값마다 몇 개인지 센다. 개수를 앞에서부터 누적해 각 값이 결과의 몇 번째 칸부터 들어갈지 구한다. 입력을 뒤에서부터 훑으며 그 자리에 놓는다.
↓ python
def 계수정렬(배열, k):
개수 = [0] * (k+1)
for 값 in 배열: 개수[값] += 1
for 값 in range(1, k+1): 개수[값] += 개수[값-1] # 누적
결과 = [None] * len(배열)
for 값 in reversed(배열): # 뒤에서부터라야 안정
개수[값] -= 1
결과[개수[값]] = 값
return 결과
훑는 일이 𝑛 번과 𝑘 번이므로 Θ(𝑛+𝑘) 다. 𝑘 가 𝑛 과 비슷한 크기면 선형이고, 𝑘 가 아주 크면(예컨대 32비트 정수 전체) 개수 배열이 감당할 수 없이 커진다.
입력을 뒤에서부터 훑는 것이 안정성을 만든다. 같은 값이 여럿일 때 나중 것이 뒤 칸을 차지하므로 원래 순서가 남는다. 앞에서부터 훑으면 뒤집힌다. 다음 정렬이 이 성질에 의존하므로 그냥 넘어갈 수 없는 대목이다.
기수 정렬(radix sort)은 계수 정렬의 "𝑘 가 크면 못 쓴다"를 자릿수로 푼다. 세 자리 수 1000개를 정렬하는 데 크기 1000 짜리 개수 배열을 만들 것 없이 한 자리씩 세 번 정렬하면 되고, 자리마다 값은 0부터 9 까지뿐이다. 순서가 중요하다. 낮은 자리부터 올라가며 각 단계가 반드시 안정 정렬이어야 한다. 안정성이 깨지면 앞 단계에서 맞춰 놓은 순서가 흐트러져 전부 무너진다.
자릿수가 𝑑 이고 각 자리의 값이 0 부터 𝑘 까지면 Θ(𝑑(𝑛+𝑘)) 다. 𝑑 와 𝑘 가 상수로 취급될 만큼 작으면 Θ(𝑛) 이다.
버킷 정렬(bucket sort)은 세 번째 방식이다. 값의 범위를 𝑛 개의 구간(버킷)으로 나눠 각 값을 해당 버킷에 넣고, 버킷마다 삽입 정렬로 정리한 뒤 이어 붙인다. 값이 고르게 퍼져 있다면 버킷마다 평균 한 개씩 들어가 평균 Θ(𝑛) 이고, 한 버킷에 몰리면 Θ(𝑛2) 로 무너진다. 5장 해시 테이블의 체이닝과 사실상 같은 구조이며 같은 이유로 분포 가정에 기댄다.
셋 다 하한을 비껴간 것이 아니라 다른 계산 모형에서 논 것이다. 대가는 조건이다.
정렬
시간
조건
보조 공간
계수
Θ(𝑛+𝑘)
키가 0 부터 k 까지의 정수
Θ(𝑛+𝑘)
기수
Θ(𝑑(𝑛+𝑘))
키를 자릿수로 쪼갤 수 있다
Θ(𝑛+𝑘)
버킷
평균 Θ(𝑛)
값이 고르게 퍼져 있다
Θ(𝑛)
설계 판단
32비트 정수 1억 개를 정렬한다. 기수 정렬과 퀵 정렬 중 어느 쪽이 유리한가?
풀이 보기
숫자를 넣어 보자. 𝑛=108 이다.
퀵 정렬은 𝑛log2𝑛≈108×27≈2.7×109 번의 견주기다.
기수 정렬은 자리를 어떻게 자르느냐에 달렸다. 8비트씩 끊으면 𝑑=4, 𝑘=255 다. 비용은 4×(108+256)≈4×108 이니 일곱 배쯤 적다.
그런데 상수를 봐야 한다. 기수 정렬은 매 단계마다 배열 전체를 읽고 다른 배열에 흩어 쓴다. 쓰는 자리가 값에 따라 튀므로 4장의 캐시 지역성이 나쁘고, 보조 배열 108 칸(400 MB 이상)이 필요하다.
퀵 정렬은 제자리이고 순차 접근이라 캐시에 잘 맞는다. 그래서 실측에서는 차이가 일곱 배까지 벌어지지 않고, 메모리가 빠듯하면 오히려 퀵이 낫다.
정리하면 이렇다. 메모리가 넉넉하고 키가 고정 길이 정수이고 자료가 아주 많으면 기수 정렬, 그 밖에는 퀵 계열이다. 문자열처럼 길이가 들쭉날쭉한 키에서는 𝑑 가 최대 길이가 되어 기수 정렬이 불리해진다.
선택 문제 — 𝑘 번째 원소만 필요할 때
중앙값을 알고 싶다. 상위 1% 경계를 알고 싶다. 이럴 때 전체를 정렬하는 것은 필요 이상의 일이다. 묻는 것은 𝑘 번째로 작은 값 하나뿐인데 𝑛 개 전부의 순서를 정했기 때문이다. 퀵 정렬의 분할이 힌트를 준다. 분할이 끝나면 피벗이 몇 번째로 작은지가 확정된다. 그 번호를 𝑘 와 견주면 어느 쪽을 봐야 하는지 알 수 있고, 반대쪽은 통째로 버릴 수 있다.
<퀵 정렬은 분할 뒤 양쪽을 다 정렬하지만 quickselect 는 한쪽만 판다. 그래서 볼 양이 등비수열로 줄어 합이 2n 을 넘지 않는다>[원본 보기]
↓ python
def 선택(배열, 시작, 끝, k): # k 번째로 작은 값
자리 = 분할(배열, 시작, 끝)
번째 = 자리 - 시작 + 1
if 번째 == k: return 배열[자리]
if k < 번째: return 선택(배열, 시작, 자리-1, k)
else: return 선택(배열, 자리+1, 끝, k - 번째)
최악은 여전히 𝑂(𝑛2) 다. 피벗을 중앙값의 중앙값으로 고르면 최악까지 𝑂(𝑛) 이 되지만 상수가 커서 실무에서는 무작위 피벗을 쓴다. 𝑘 가 아주 작으면 다른 답이 낫다. 6장에서 본 크기 𝑘 짜리 힙으로 𝑂(𝑛log𝑘) 에 상위 𝑘 개를 유지하는 방법이다. 배열을 통째로 들고 있을 필요가 없어 스트리밍 자료에 쓸 수 있다.
예제 80
배열 [7,10,4,3,20,15] 에서 3번째로 작은 값을 quickselect 로 찾아라. 피벗은 맨 뒤를 쓴다.
풀이 보기
찾는 것은 𝑘=3 이다.
1회. 피벗은 15 다. 15 보다 작은 것은 7,10,4,3 네 개이므로 분할 결과는 [7,10,4,3,15,20] 이고 15 는 첨자 4, 곧 5번째로 작다.
3<5 이므로 왼쪽 [7,10,4,3] 만 본다. 오른쪽의 20 은 다시 보지 않는다.
2회. 피벗은 3 이다. 3 보다 작은 것이 없으므로 분할 결과는 [3,7,10,4] 이고 3 은 1번째로 작다.
3>1 이므로 오른쪽 [7,10,4] 을 보되, 찾는 번호를 3−1=2 로 줄인다. 번호를 줄이는 것을 잊는 것이 이 알고리즘의 가장 흔한 버그다.
3회. 피벗은 4 다. 4 보다 작은 것이 없으므로 분할 결과는 [4,7,10] 이고 4 는 1번째다. 2>1 이므로 오른쪽 [7,10] 에서 2−1=1 번째를 찾는다.
4회. 피벗은 10 이고 7 이 더 작으므로 [7,10], 10 은 2번째다. 1<2 이므로 왼쪽 [7] 에서 1번째, 곧 7 이다.
검산하자. 정렬하면 [3,4,7,10,15,20] 이고 3번째는 7 이다. 맞는다. 그리고 정렬은 한 번도 하지 않았다는 점을 확인하라.
실제 라이브러리가 쓰는 것
지금까지 본 정렬은 모두 어딘가에 약점이 있다. 퀵은 최악이 𝑂(𝑛2) 이고, 힙은 캐시가 나쁘고, 병합은 공간을 쓰고, 삽입은 𝑂(𝑛2) 이다. 그래서 실제 라이브러리는 하나를 고르지 않고 섞는다.인트로소트(introsort)는 퀵 정렬로 시작하되 두 가지 안전장치를 단다.
구간이 작아지면(대개 16개 안팎) 삽입 정렬로 갈아탄다. 작은 구간에서는 상수가 작은 쪽이 이긴다.
재귀 깊이가 2log2𝑛 을 넘으면 힙 정렬로 갈아탄다. 분할이 계속 치우치고 있다는 신호이므로, 최악이 없는 정렬로 갈아타 𝑂(𝑛log𝑛) 을 보장한다.
팀소트(timsort)는 다른 관찰에서 출발한다. 실제 자료는 이미 정렬된 토막을 여럿 품고 있다. 그래서 자료를 훑으며 이미 오름차순(또는 내림차순)인 토막을 찾아 그것을 그대로 쓰고, 짧은 토막은 삽입 정렬로 늘린 뒤, 토막들을 병합한다.
이미 정렬된 입력이면 토막이 하나뿐이라 한 번 훑고 끝나 최선이 𝑂(𝑛) 이다. 안정이고 최악도 𝑂(𝑛log𝑛) 이라 파이썬과 자바가 객체 정렬에 이것을 쓴다.
무엇
뼈대
고르는 이유
인트로소트
퀵 + 삽입 + 힙
제자리이고 빠르며 최악이 보장된다
팀소트
자연 런 찾기 + 병합 + 삽입
안정이고 부분 정렬된 자료에 아주 강하다
기수 계열
자릿수별 안정 정렬
키가 고정 길이 정수이고 자료가 아주 많을 때
설계 판단
다음 넷에 어떤 정렬을 고르겠는가. (1) 원소 20개짜리 배열을 아주 자주 정렬한다 (2) 사용자 입력을 정렬하는 웹 서비스 (3) 이미 날짜순인 로그에 하루치를 덧붙여 다시 정렬 (4) 임베디드 기기, 메모리 여유 거의 없음
풀이 보기
각각 무엇이 제약인지를 먼저 정한다.
(1) 삽입 정렬.𝑛=20 이면 𝑛2=400 이고 𝑛log2𝑛≈86 이다. 증가율은 지지만 재귀 호출도 없고 분기도 단순해 상수가 작다. 실제 라이브러리도 이 크기에서는 삽입 정렬로 갈아탄다.
(2) 인트로소트나 팀소트. 사용자가 입력을 고를 수 있으므로 최악을 노린 입력이 들어올 수 있다. 최악이 보장되지 않는 순수 퀵 정렬은 위험하다. 퀵을 쓴다면 무작위 피벗이 필수다.
(3) 팀소트. 자료가 "정렬된 큰 덩어리 + 정렬된 작은 덩어리" 꼴이다. 팀소트는 이것을 토막 두 개로 알아보고 한 번의 병합으로 끝낸다. 사실상 𝑂(𝑛) 이다. 퀵 정렬은 이 구조를 전혀 활용하지 못한다.
(4) 힙 정렬. 제자리이고 최악이 𝑂(𝑛log𝑛) 로 보장된다. 병합 정렬은 𝑂(𝑛) 짜리 보조 배열 때문에 탈락이고, 퀵 정렬은 재귀 스택과 최악 때문에 불안하다. 응답 시간의 상한을 보장해야 하는 곳이 힙 정렬의 자리다.
네 답이 다 다르다는 것이 이 장의 요점이다. "가장 빠른 정렬"은 없고 "이 상황에서 가장 빠른 정렬"만 있다.
이 장의 정리
정렬은 무엇을 아느냐로 갈린다. 아무것도 모르면 견주는 수밖에 없고 그러면 𝑛log𝑛 이 벽이다. 키에 대해 무언가를 알면 그 벽 아래로 내려갈 수 있다.
<먼저 비교 정렬을 써야 하는지부터 판단한다. 그다음 안정성 · 메모리 · 최악 보장 중 무엇이 필요한지가 남은 선택을 정한다>[원본 보기]
정렬
평균
최악
보조 공간
안정
고르는 이유
삽입
Θ(𝑛2)
Θ(𝑛2)
𝑂(1)
예
작은 구간, 거의 정렬된 입력
병합
Θ(𝑛log𝑛)
Θ(𝑛log𝑛)
Θ(𝑛)
예
안정성 필요, 외부 정렬, 연결 리스트
힙
Θ(𝑛log𝑛)
Θ(𝑛log𝑛)
𝑂(1)
아니오
최악 보장과 제자리가 동시에 필요
퀵
Θ(𝑛log𝑛)
Θ(𝑛2)
𝑂(log𝑛)
아니오
평균 속도가 가장 빠르다
계수 · 기수
Θ(𝑛) 급
Θ(𝑛) 급
Θ(𝑛+𝑘)
예
키가 정수이고 범위나 자릿수를 안다
용어 · 사실
뜻
안정성
키가 같은 원소의 입력 순서가 결과에서도 유지된다
제자리
보조 공간이 𝑂(1) 이다
역위
앞에 있는데 더 큰 쌍. 삽입 정렬의 이동 횟수와 같다
결정 트리
비교 정렬을 예 · 아니오 질문의 이진트리로 본 것
비교 정렬의 하한
잎이 𝑛! 개 이상이므로 높이가 Ω(𝑛log𝑛)
분할
피벗보다 작은 것과 큰 것으로 나누고 피벗을 제자리에 놓는 Θ(𝑛) 연산
선택 문제
k 번째로 작은 값 찾기. quickselect 로 평균 𝑂(𝑛)
다음 장에서 자료구조가 한 번 더 바뀐다. 지금까지는 원소들이 일렬(배열·리스트)이거나 갈라지는 나무(트리·힙)였다. 그래프는 그 제약을 다 푼다. 아무 정점이나 아무 정점과 이어질 수 있고, 되돌아오는 길도 허용된다. 이 장에서 "순서를 정한다"는 문제를 모든 둘을 견줄 수 있다는 가정 위에서 다뤘다는 점도 기억해 두라. 9장의 위상 정렬은 그 가정을 버린 정렬이고, 그래서 답이 하나가 아니다.
그래프 — 표현과 탐색
지금까지 다룬 자료구조에는 제약이 있었다. 배열과 리스트는 일렬이고, 트리는 갈라지되 되돌아오지 않는다. 그런데 우리가 다루는 것들은 대체로 그렇지 않다. 도시와 도로, 사람과 친구 관계, 함수와 호출 관계, 웹 문서와 링크 — 전부 아무나 아무나와 이어질 수 있고 되돌아오는 길도 있다.
그래프(graph)는 그 제약을 다 푼 구조다. 그리고 제약을 푼 대가로 새로운 질문이 생긴다. 여기서 저기로 갈 수 있는가. 몇 번 만에 갈 수 있는가. 되돌아오는 길이 있는가. 순서를 정할 수 있는가.
답할 질문은 셋이다. 그래프를 어떻게 적는가, 어떻게 빠짐없이 훑는가, 그리고 훑기만 해서 답할 수 있는 것이 무엇인가.
이 장은 용어를 전부 여기서 정의한다. 앞에서 가져오는 것은 4장의 스택과 큐(이 둘의 차이가 곧 두 탐색의 차이다), 6장의 트리와 레벨 순회, 3장의 점근 표기다.
그래프의 말
그래프는 정점(vertex)의 집합과 간선(edge)의 집합으로 이루어진 구조다. 정점을 모은 집합을 𝑉, 간선을 모은 집합을 𝐸 라 하고 그래프를 𝐺=(𝑉,𝐸) 로 적는다. 간선 하나는 정점 두 개를 잇는다.
정점 수를 |𝑉|, 간선 수를 |𝐸| 로 적는데, 복잡도를 쓸 때는 𝑂(𝑉+𝐸) 처럼 절댓값 기호를 생략하는 것이 관례다.
<정점 · 간선 · 차수 · 경로 · 순환 · 연결 요소가 한 그림에 다 있다. 오른쪽 두 정점은 왼쪽 다섯과 아무 간선으로도 이어져 있지 않아 따로 세는 덩어리가 된다>[원본 보기]
말
뜻
정점 · 간선
동그라미와 그것을 잇는 선. 노드와 링크라고도 한다
인접(adjacent)
두 정점 사이에 간선이 있다
차수(degree)
그 정점에 붙은 간선의 수
보행(walk)
간선을 따라 이어 간 정점의 나열. 같은 것을 여러 번 지나도 된다
경로(path)
같은 정점을 두 번 지나지 않는 보행. 단순 경로라고도 한다
순환(cycle)
출발점으로 돌아오는 경로. 길이는 지나간 간선 수
연결(connected)
어느 두 정점 사이에도 경로가 있다
연결 요소(component)
서로 오갈 수 있는 정점들의 덩어리
특수한 간선도 이름이 있다. 자기 순환(self-loop)은 한 정점이 자기 자신과 이어진 것이고, 다중 간선(multi-edge)은 같은 두 정점을 잇는 간선이 여러 개인 것이다. 둘 다 없는 그래프를 단순 그래프라 하며, 특별히 말하지 않으면 단순 그래프를 뜻한다.
정점이 𝑛 개인 단순 무방향 그래프의 간선 수는 0 개부터 𝑛(𝑛−1)/2 개까지다. 위쪽 한계는 서로 다른 두 정점을 고르는 경우의 수, 곧 (𝑛2) 이다. 간선 수가 이 한계에 가까우면 밀집(dense), 훨씬 적으면 희소(sparse) 그래프라 한다. 이 구분이 다음다음 절에서 표현 방식을 가른다.
예제 82
어떤 무방향 그래프에서 모든 정점의 차수를 더하면 간선 수의 두 배가 된다. 왜 그런가? 그리고 차수가 홀수인 정점은 반드시 짝수 개임을 보여라.
풀이 보기
앞의 것은 세는 방향을 바꾸면 나온다. 차수를 더한다는 것은 "정점마다 붙은 간선을 센다"는 뜻인데, 간선 하나는 양 끝 정점 각각에서 한 번씩 세어진다. 그러므로
∑𝑣∈𝑉deg(𝑣)=2|𝐸|
뒤의 것은 이 식의 결과다. 좌변을 차수가 짝수인 정점의 합 𝑆짝 과 홀수인 정점의 합 𝑆홀 로 나누자. 우변 2|𝐸| 는 짝수이고 𝑆짝 도 짝수이므로 𝑆홀 도 짝수여야 한다.
그런데 𝑆홀 은 홀수를 여러 개 더한 것이다. 홀수를 홀수 개 더하면 홀수이고 짝수 개 더하면 짝수이므로, 𝑆홀 이 짝수이려면 더한 개수가 짝수여야 한다.
이 사실을 악수 정리라 부른다. 모임에서 악수한 횟수가 홀수인 사람은 반드시 짝수 명이라는 뜻이다.
예제 83
정점이 𝑛 개인 무방향 그래프가 연결되려면 간선이 적어도 몇 개 필요한가? 그리고 간선이 𝑛 개면 반드시 순환이 있음을 보여라.
풀이 보기
앞의 것. 답은 𝑛−1 이다. 간선을 하나도 없는 상태에서 시작하면 연결 요소가 𝑛 개다. 간선 하나를 그으면 연결 요소가 많아야 하나 줄어든다. 이미 같은 요소 안에 있는 두 정점을 이으면 줄지 않는다.
요소를 𝑛 개에서 1 개로 만들려면 적어도 𝑛−1 번 줄여야 하므로 간선이 𝑛−1 개 이상이어야 한다. 실제로 한 줄로 늘어놓으면 𝑛−1 개로 연결되므로 이 값이 최소다.
뒤의 것. 순환이 하나도 없는 그래프의 각 연결 요소는 트리이고, 6장에서 본 대로 노드가 𝑘 개인 트리의 간선은 𝑘−1 개다.
연결 요소가 𝑐 개이고 각각의 정점 수가 𝑘1,…,𝑘𝑐 라면 전체 간선 수는 ∑(𝑘𝑖−1)=𝑛−𝑐≤𝑛−1 이다.
그러므로 간선이 𝑛 개면 "순환이 없다"는 가정이 깨진다. 순환이 반드시 있다.
여기서 나오는 결론 하나를 기억해 두자. 연결되어 있으면서 간선이 정확히 𝑛−1 개면 그것은 트리다. 트리가 "간선을 하나만 더 그어도 순환이 생기고 하나만 지워도 끊어지는" 아슬아슬한 상태인 이유가 이것이며, 12장의 최소신장트리가 이 성질 위에 선다.
방향 · 가중치 · 순환
간선에 무엇을 얹느냐에 따라 그래프의 이름이 달라진다. 셋만 알면 된다.
<같은 네 정점 위에 여섯 가지를 그렸다. 트리도 그래프의 한 종류이고, 완전 그래프의 간선 수 6 이 정점 4 개일 때의 최대치다>[원본 보기]
방향(directed)이 있으면 간선을 화살표로 그리고 한 방향으로만 지나갈 수 있다. 일방통행, 웹 링크, 함수 호출, 선수 과목이 그렇다. 방향 그래프에서는 차수가 둘로 갈린다.
진입차수(in-degree) — 그 정점으로 들어오는 화살표 수
진출차수(out-degree) — 그 정점에서 나가는 화살표 수
가중치(weight)는 간선에 붙은 값이다. 거리, 시간, 요금, 용량, 신뢰도 — 무엇이든 될 수 있다. 가중치가 없는 그래프는 모든 간선의 가중치가 1 인 그래프와 같다고 보면 된다. 12장 전체가 가중치를 다룬다.
순환의 유무가 마지막 축이다. 방향 그래프에 순환이 하나도 없으면 DAG(directed acyclic graph, 방향 비순환 그래프)라 한다. 앞뒤 관계가 모순 없이 정해지는 모든 상황이 DAG 다. 작업 의존성, 선수 과목, 빌드 순서, 스프레드시트의 수식 참조가 그렇다. 이 장 뒤쪽의 위상 정렬과 12장의 DAG 최단경로가 DAG 만 다룬다.
트리와의 관계도 여기서 정리하자. 트리는 연결되어 있고 순환이 없는 무방향 그래프다. 6장에서 뿌리를 정해 위아래를 준 것은 편의였고, 뿌리 없이 보면 그냥 그래프의 특별한 경우다. 앞 예제에서 본 대로 연결 + 간선 𝑛−1 개가 트리와 같은 말이다.
예제 84
방향 그래프에서 모든 정점의 진입차수 합과 진출차수 합이 같음을 보이고, 그것이 무엇을 뜻하는지 말하라.
풀이 보기
간선 하나를 보자. 그 간선은 어떤 정점의 진출을 하나 늘리고 다른 정점의 진입을 하나 늘린다. 정확히 하나씩이다.
그러므로 두 합은 모두 간선 수와 같다.
∑𝑣deg−(𝑣)=∑𝑣deg+(𝑣)=|𝐸|
실무에서 이것은 검산 도구다. 그래프를 파일에서 읽어 들인 뒤 두 합이 다르면 어딘가에서 간선을 잘못 넣은 것이다. 인접 리스트를 만들 때 한쪽 방향만 넣고 반대쪽을 빼먹는 실수가 흔한데, 이 검사로 잡힌다.
무방향 그래프를 인접 리스트로 만들 때도 같은 실수가 난다. 간선 (𝑢,𝑣) 하나를 𝑢 의 목록과 𝑣 의 목록에 각각 넣어야 한다. 한쪽만 넣으면 사실상 방향 그래프가 된다.
어떻게 저장하는가
그래프를 코드로 적는 방법은 크게 둘이다. 어느 쪽을 고르느냐가 이후 모든 알고리즘의 복잡도를 바꾼다.
<인접 행렬은 간선이 몇 개든 칸 수가 정점 수의 제곱으로 고정된다. 인접 리스트는 간선 수에 비례한다. 대신 두 정점이 이어졌는지 묻는 데 드는 비용이 반대로 뒤집힌다>[원본 보기]
인접 행렬(adjacency matrix) — |𝑉|×|𝑉| 짜리 표를 만들고 𝑢 행 𝑣 열에 간선이 있으면 1, 없으면 0 을 적는다. 가중 그래프면 1 대신 가중치를 적고 간선이 없는 자리는 ∞ 로 둔다.
인접 리스트(adjacency list) — 정점마다 이웃 목록을 하나씩 들고 있는다. 4장의 배열이나 연결 리스트를 그대로 쓴다.
하는 일
인접 행렬
인접 리스트
공간
Θ(𝑉2)
Θ(𝑉+𝐸)
u 와 v 가 이어졌나
𝑂(1)
𝑂(deg𝑢)
u 의 이웃을 전부 훑기
Θ(𝑉)
Θ(deg𝑢)
간선 추가 · 삭제
𝑂(1)
추가 𝑂(1), 삭제 𝑂(deg𝑢)
세 번째 줄이 결정적이다. 이 장의 탐색 알고리즘은 모든 정점의 이웃을 한 번씩 훑는다. 인접 리스트면 차수의 합, 곧 Θ(𝑉+𝐸) 다. 인접 행렬이면 정점마다 𝑉 칸을 보므로 Θ(𝑉2) 이다.
𝑉=106 이고 𝐸=5×106 인 희소 그래프에서 이 차이는 600만 대 1조다. 실무의 그래프는 거의 다 희소하므로 기본 선택은 인접 리스트다. 인접 행렬은 그래프가 작거나 밀집이거나, 두 정점이 이어졌는지를 아주 자주 묻는 경우에 쓴다.
세 번째 방법도 있다. 간선 목록(edge list)은 (𝑢,𝑣,𝑤) 를 그냥 배열에 죽 늘어놓은 것이다. 이웃을 찾는 데는 쓸모없지만 모든 간선을 훑기만 하면 되는 알고리즘에는 이것으로 충분하다. 12장의 벨만-포드와 크루스칼이 그렇다.
설계 판단
사용자 10억 명, 평균 친구 200명인 소셜 그래프를 저장한다. 어떤 표현을 쓰겠는가? 필요한 공간은 대략 얼마인가?
풀이 보기
먼저 밀집인지 희소인지 본다. 𝑉=109 이고 𝐸≈109×200/2=1011 이다. 가능한 최대 간선 수 𝑉2/2=5×1017 에 비하면 지극히 희소하다.
인접 행렬은 계산할 필요도 없다. 1018 칸은 어떤 저장 장치에도 담기지 않는다.
인접 리스트로 하면 간선 방향이 2×1011 개이고 이웃 하나를 8바이트 식별자로 적으면 약 1.6 TB 다. 여러 기계에 나눠야 하는 크기이고, 그 나누는 방법이 5장에서 본 일관 해싱이다.
그런데 "A 와 B 가 친구인가"를 자주 물어야 한다면 인접 리스트로는 𝑂(200) 이 든다. 이럴 때는 정점마다 이웃을 해시 집합으로 두면 평균 𝑂(1) 이 된다. 5장의 해시 테이블을 인접 리스트 안에 넣는 것이다.
정리하면 인접 리스트를 기본으로 하되 목록의 자료구조를 질의에 맞춰 고른다. 순서대로 훑기만 하면 배열(캐시에 유리), 존재 확인이 잦으면 해시 집합, 정렬된 순회가 필요하면 균형 트리다. 4·5·6장의 선택이 여기서 되풀이된다.
두 가지 훑기 — 깊이 우선과 너비 우선
그래프의 거의 모든 질문은 "여기서 갈 수 있는 곳을 빠짐없이 한 번씩 본다"로 시작한다. 그 훑기가 두 가지 있는데, 둘은 같은 알고리즘이고 자료구조만 다르다.
뼈대는 이렇다. 아직 가 보지 않은 정점을 담아 두는 그릇(프런티어)을 하나 만든다. 출발점을 넣는다. 그릇이 빌 때까지, 하나를 꺼내 방문하고 그 이웃 중 아직 표시하지 않은 것을 표시하며 넣는다.
↓ python
def 탐색(그래프, 시작):
표시 = {시작}
그릇 = [시작]
while 그릇:
정점 = 그릇.꺼내기() # 스택이면 깊이 우선, 큐면 너비 우선
방문(정점)
for 이웃 in 그래프[정점]:
if 이웃 not in 표시:
표시.add(이웃)
그릇.넣기(이웃)
정점마다 한 번씩 꺼내고 간선마다 한 번씩(무방향이면 양쪽에서 한 번씩) 보므로 𝑂(𝑉+𝐸) 다. 인접 리스트를 쓸 때의 이야기이며, 인접 행렬이면 𝑂(𝑉2) 이다.
꺼내기가 어느 쪽 끝에서 일어나는지가 전부다. 4장의 스택은 나중에 넣은 것을 먼저 꺼내고, 큐는 먼저 넣은 것을 먼저 꺼낸다.
<왼쪽 그래프의 숫자는 위가 깊이 우선, 아래가 너비 우선 방문 순서다. 오른쪽 두 칸이 그릇의 내용인데 코드는 같고 꺼내는 끝만 다르다. 깊이 우선은 고리를 한 방향으로 끝까지 돌고 너비 우선은 양쪽으로 동시에 퍼진다>[원본 보기]
깊이 우선 탐색
깊이 우선 탐색(depth-first search, DFS)은 갈 수 있는 데까지 가 보고, 막히면 되돌아와 다른 길을 본다. 그릇이 스택이므로 방금 발견한 곳을 가장 먼저 본다.
스택을 명시적으로 쓰는 대신 재귀로 적는 것이 더 흔하다. 6장에서 본 대로 재귀 호출 자체가 스택을 쓰기 때문이다.
↓ python
def dfs(그래프, 정점, 표시):
표시.add(정점)
방문(정점)
for 이웃 in 그래프[정점]:
if 이웃 not in 표시:
dfs(그래프, 이웃, 표시)
방문 표시가 없으면 순환에서 영원히 돈다. 트리에는 순환이 없어 이 표시가 필요 없었지만 그래프에는 반드시 있어야 한다. 6장의 순회와 그래프 탐색의 유일한 차이가 이것이다.
재귀 깊이가 최악에 𝑉 까지 가므로 정점이 100만 개인 그래프에서는 호출 스택이 넘친다. 그럴 때는 명시적 스택으로 바꿔 쓴다.
너비 우선 탐색
너비 우선 탐색(breadth-first search, BFS)은 출발점에서 가까운 것부터 본다. 그릇이 큐이므로 먼저 발견한 곳을 먼저 본다. 6장의 레벨 순회를 그래프로 넓힌 것이 정확히 이것이다.
그래서 BFS 는 공짜로 하나를 더 준다. 간선 수로 잰 최단 거리다. 큐에서 꺼내는 순서가 출발점으로부터의 거리 순서이므로, 정점을 처음 발견할 때 "발견한 정점의 거리 = 꺼낸 정점의 거리 + 1"로 적어 두면 그것이 최단 거리다.
↓ python
def bfs거리(그래프, 시작):
거리 = {시작: 0}
큐 = deque([시작])
while 큐:
정점 = 큐.popleft()
for 이웃 in 그래프[정점]:
if 이웃 not in 거리:
거리[이웃] = 거리[정점] + 1
큐.append(이웃)
return 거리
거리를 적는 자리가 큐에 넣을 때라는 점이 중요하다. 꺼낼 때 적으면 같은 정점이 큐에 여러 번 들어가 거리가 덮어써진다.
예제 86
BFS 가 구한 거리가 정말 최단인지 보여라. 곧 𝑑[𝑣] 는 출발점에서 𝑣 까지 가는 간선 수의 최솟값임을 보여라.
풀이 보기
보일 것은 두 방향이다. 𝑑[𝑣] 보다 짧은 길이 없다는 것과, 길이 𝑑[𝑣] 인 길이 실제로 있다는 것이다.
뒤의 것은 쉽다.𝑑[𝑣]=𝑑[𝑢]+1 로 적었다는 것은 𝑢 에서 간선 하나로 𝑣 에 갔다는 뜻이므로, 그 자취를 거꾸로 따라가면 길이 𝑑[𝑣] 인 길이 실제로 있다.
앞의 것을 거리에 대한 귀납법으로 보인다. 주장은 "출발점에서 거리 𝑘 인 정점은 모두 𝑑 값이 𝑘 로 적힌다"이다.
𝑘=0 이면 출발점 자신뿐이고 𝑑=0 이다. 성립한다.
거리 𝑘 까지 성립한다고 하자. 실제 거리가 𝑘+1 인 정점 𝑣 를 잡으면, 최단 경로에서 𝑣 바로 앞의 정점 𝑢 는 실제 거리가 𝑘 다. 귀납 가정에 따라 𝑑[𝑢]=𝑘 이고, 큐는 거리 순서로 꺼낸다는 성질에 따라 𝑢 는 거리 𝑘+1 인 정점들이 꺼내지기 전에 꺼내진다.
그러므로 𝑢 를 처리하는 순간 𝑣 는 아직 표시되어 있지 않거나 이미 𝑘+1 로 표시되어 있다. 어느 쪽이든 𝑑[𝑣]=𝑘+1 이다.
이 증명이 쓴 것은 "큐가 거리 순서를 지킨다" 하나다. 스택으로 바꾸면 이 성질이 깨져 DFS 로는 최단 거리를 얻을 수 없다. 그리고 간선마다 가중치가 다르면 "간선 하나 = 거리 1"이 무너져 이 논법도 깨진다. 12장의 다익스트라가 그 자리를 메운다.
예제 87
정점이 500만 개인 그래프에서 재귀 DFS 를 돌렸더니 스택이 넘쳤다. 어떻게 고치겠는가? 고친 뒤 방문 순서가 그대로인가?
풀이 보기
원인은 재귀 깊이다. 한 줄로 늘어선 그래프면 깊이가 500만이 되고, 호출 하나에 수십 바이트만 잡아도 수백 MB 다.
고치는 방법은 명시적 스택이다. 앞의 공통 뼈대 코드에서 그릇을 리스트로 두고 뒤에서 꺼내면 그대로 반복형 DFS 다. 스택은 힙 메모리에 잡히므로 호출 스택보다 훨씬 크게 쓸 수 있다.
방문 순서는 그대로가 아니다. 재귀 DFS 는 이웃을 하나 보고 곧바로 내려가지만, 반복형은 이웃을 전부 스택에 넣은 뒤 마지막 것부터 꺼낸다. 그래서 이웃을 보는 순서가 뒤집힌다. 같게 하려면 이웃을 역순으로 넣으면 된다.
더 큰 차이도 있다. 재귀 DFS 에는 "자식이 다 끝난 뒤"라는 시점이 자연스럽게 있지만(함수가 반환되는 자리), 반복형에는 없다. 다음 절의 종료 시각이 필요하면 스택에 "이 정점을 끝냈다"는 표시를 함께 넣어야 한다.
탐색 트리와 간선 분류
탐색이 지나간 간선만 모으면 트리가 된다. 정점마다 "처음 발견될 때 지나온 간선"이 하나씩이므로 간선이 𝑉−1 개이고, 앞에서 본 대로 연결 + 간선 𝑉−1 개는 트리다. 이것을 탐색 트리라 한다.
나머지 간선도 이름이 있다. DFS 에서 간선 𝑢→𝑣 를 따라갈 때 𝑣 가 어떤 상태인가로 갈린다.
<각 정점의 숫자는 발견 시각과 종료 시각이다. 뒤 간선은 아직 끝나지 않은 조상으로 되돌아가는 간선이고, 그것이 하나라도 있으면 순환이 있다>[원본 보기]
간선
𝑣 의 상태
뜻
트리 간선
아직 발견되지 않음
탐색이 실제로 내려간 간선
뒤 간선(back)
발견되었으나 아직 안 끝남
조상으로 되돌아간다
앞 간선(forward)
이미 끝남. 자손이다
이미 아는 자손으로 건너뛴다
교차 간선(cross)
이미 끝남. 자손이 아니다
다른 가지로 건너뛴다
무방향 그래프에는 트리 간선과 뒤 간선만 생긴다. 앞 간선이 될 뻔한 것은 반대 방향에서 이미 뒤 간선으로 세어지고, 교차 간선이 되려면 두 가지가 서로를 못 본 채 끝나야 하는데 무방향이면 그럴 수 없기 때문이다.
여기서 이 절의 값이 나온다. 뒤 간선이 하나라도 있으면 순환이 있고, 하나도 없으면 순환이 없다. 뒤 간선은 조상으로 되돌아가는 간선이므로 그 조상까지 내려온 길과 합치면 순환이 되고, 반대로 순환이 있으면 그 순환 위에서 가장 먼저 발견된 정점으로 되돌아가는 간선이 반드시 뒤 간선이다.
곧 순환 탐지는 DFS 한 번이다. 𝑂(𝑉+𝐸) 다.
예제 88
무방향 그래프에서 순환을 찾으려고 "이미 표시된 이웃을 만나면 순환"이라고 판정했더니 순환이 없는 그래프에서도 순환이 있다고 나온다. 무엇이 잘못되었는가?
풀이 보기
정점 두 개가 간선 하나로 이어진 그래프를 넣어 보자. 𝑢 에서 시작해 𝑣 로 간다. 𝑣 에서 이웃을 보면 𝑢 가 있고 𝑢 는 이미 표시되어 있다. 방금 타고 온 그 간선을 순환으로 잘못 센 것이다.
무방향 그래프에서는 간선 하나가 양쪽 인접 목록에 모두 들어 있다는 것을 잊은 결과다.
고치는 방법은 부모를 기억하는 것이다. 𝑣 의 이웃을 볼 때 "여기로 오게 한 그 정점"은 건너뛴다. 그러면 남은 표시된 이웃만이 진짜 뒤 간선이다.
다중 간선이 있으면 이 방법도 부족하다. 𝑢 와 𝑣 사이에 간선이 둘이면 그것 자체가 길이 2 짜리 순환인데, 부모를 통째로 건너뛰면 놓친다. 그때는 간선 식별자를 기억해 "타고 온 그 간선"만 건너뛴다.
방향 그래프에서는 반대로 부모를 건너뛰면 안 된다.𝑢→𝑣 와 𝑣→𝑢 가 둘 다 있으면 그것이 진짜 순환이기 때문이다. 대신 "표시되었다"를 "아직 안 끝났다"로 좁혀야 한다. 이미 끝난 정점으로 가는 것은 교차 간선이지 순환이 아니다.
탐색으로 답하는 것들
탐색 하나로 답이 나오는 질문이 여럿이다. 모두 "탐색을 돌리면서 무엇을 기록하는가"만 다르다.
연결 요소 세기. 모든 정점을 훑으면서 아직 표시되지 않은 것을 만나면 거기서 탐색을 새로 시작하고 개수를 하나 센다. 탐색이 끝나면 그 덩어리 전체가 표시된다. 전체 비용은 여전히 𝑂(𝑉+𝐸) 이다. 탐색을 여러 번 하지만 정점과 간선은 통틀어 한 번씩만 보기 때문이다.
이분 그래프 판정. 정점을 두 무리로 나누되 모든 간선이 서로 다른 무리를 잇게 할 수 있으면 이분 그래프(bipartite graph)다. 학생과 과목, 사람과 회사, 작업과 기계처럼 두 종류가 서로만 이어지는 관계가 그렇다.
<너비 우선으로 층을 매기고 층의 홀짝으로 칠하면 판정이 끝난다. 오른쪽처럼 같은 층 두 정점이 이어져 있으면 어떻게 칠해도 실패이고, 그것은 홀수 길이 순환이 있다는 뜻이다>[원본 보기]
판정은 BFS 한 번이다. 층 번호의 홀짝으로 칠하고, 간선을 볼 때마다 두 끝의 홀짝이 다른지 확인한다. 같은 층끼리 잇는 간선이 하나라도 있으면 이분 그래프가 아니다.
그리고 이분 그래프인 것과 홀수 길이 순환이 없는 것은 같은 말이다. 순환을 따라 색이 번갈아 바뀌어야 하는데, 홀수 번 바뀌면 출발점으로 돌아왔을 때 색이 달라져 모순이기 때문이다.
예제 89
격자 지도에서 1 은 땅, 0 은 물이다. 상하좌우로 이어진 땅을 한 섬으로 볼 때 섬의 개수를 구하는 절차를 쓰고 복잡도를 구하라.
풀이 보기
묻는 것은 연결 요소의 개수다. 격자를 그래프로 보면 칸 하나가 정점이고 상하좌우 이웃이 간선이다. 그래프를 실제로 만들 필요는 없다. 이웃을 좌표 계산으로 구하면 된다.
절차는 이렇다. 모든 칸을 훑는다. 땅이고 아직 방문하지 않았으면 섬 개수를 하나 늘리고 거기서 탐색을 시작해 이어진 땅을 전부 방문 표시한다.
격자가 𝑅×𝐶 면 정점이 𝑅𝐶 개, 간선이 𝑂(𝑅𝐶) 개(칸마다 많아야 4개)이므로 𝑂(𝑅𝐶) 다. 칸마다 상수 번 본다는 뜻이다.
DFS 와 BFS 중 어느 쪽이든 답은 같지만 실무에서는 BFS 가 안전하다. 1000×1000 격자가 전부 땅이면 재귀 DFS 의 깊이가 100만이 되어 스택이 넘친다.
이 문제는 5장의 해시나 6장의 트리로는 접근조차 어렵다. "이어져 있다"는 관계가 주제라면 그래프로 보는 것이 첫 수다.
예제 90
어떤 그래프의 간선이 (1,2),(2,3),(3,4),(4,1),(1,3) 이다. 이분 그래프인가?
풀이 보기
정점 1 에서 BFS 를 돌려 층을 매긴다.
층 0 은 {1} 이다. 1 의 이웃은 2, 4, 3 이므로 층 1 은 {2,3,4} 다.
이제 간선을 하나씩 확인한다. (1,2) 는 층 0 과 1 로 홀짝이 다르다. (1,3), (4,1) 도 마찬가지다.
그런데 (2,3) 은 둘 다 층 1 이다. 홀짝이 같으므로 이분 그래프가 아니다.
확인해 보자. 1→2→3→1 이 길이 3 짜리 순환, 곧 홀수 길이 순환이다. 앞 문단의 성질과 맞는다.
참고로 간선 (1,3) 만 빼면 1−2−3−4−1 짜리 4-순환이 되어 이분 그래프가 된다. 무리는 {1,3} 과 {2,4} 다. 간선 하나가 판정을 뒤집는다.
위상 정렬(Topological sort)
할 일들 사이에 선후관계 일부만 정해져 있다. 자료구조를 배우기 전에 기초를 끝내야 하고, 알고리즘 전에 자료구조를 끝내야 한다. 그런데 기초와 이산수학 사이에는 아무 관계가 없다. 이런 상황에서 모든 선후관계를 어기지 않는 전체 순서 하나를 만드는 것이 위상 정렬이다.
8장의 정렬과 다르다는 점을 먼저 짚자. 정렬은 모든 두 원소를 견줄 수 있다고 가정했고 답이 하나였다. 위상 정렬은 견줄 수 없는 쌍이 있고 답이 여러 개다.
선후관계를 방향 그래프의 간선으로 적으면(𝑢→𝑣 는 "𝑢 를 𝑣 보다 먼저") 문제가 그래프 문제가 된다. 그리고 순환이 있으면 답이 아예 없다. 서로가 서로보다 먼저여야 하기 때문이다. 곧 위상 정렬은 DAG 에서만 가능하다.
<두 방법이 서로 다른 답을 내놓지만 둘 다 옳다. 진입차수를 쓰는 쪽은 지금 당장 할 수 있는 일을 꺼내는 것이고, 깊이 우선을 쓰는 쪽은 가장 늦게 끝난 것을 맨 앞에 놓는 것이다>[원본 보기]
방법 1 — 진입차수. 들어오는 화살표가 없는 정점은 지금 당장 해도 되는 일이다. 그것을 꺼내 답에 적고 그래프에서 지운다. 지우면 그 정점이 가리키던 것들의 진입차수가 줄어 새로 0 이 되는 것이 생긴다.
↓ python
def 위상정렬(그래프, 진입차수):
큐 = [v for v in 그래프 if 진입차수[v] == 0]
결과 = []
while 큐:
정점 = 큐.pop()
결과.append(정점)
for 이웃 in 그래프[정점]:
진입차수[이웃] -= 1
if 진입차수[이웃] == 0: 큐.append(이웃)
return 결과 if len(결과) == len(그래프) else None # None 이면 순환이 있다
정점마다 한 번 꺼내고 간선마다 한 번 진입차수를 줄이므로 𝑂(𝑉+𝐸) 다. 마지막 줄이 순환 탐지다. 순환에 속한 정점들은 진입차수가 끝내 0 이 되지 않아 결과에 들어가지 못한다.
방법 2 — DFS 의 종료 순서. DFS 를 돌리고 정점이 끝나는 순서대로 적은 뒤 뒤집는다. 어떤 정점이 끝났다는 것은 거기서 갈 수 있는 곳을 전부 끝냈다는 뜻이므로, 늦게 끝난 것일수록 앞에 와야 한다.
두 방법 다 𝑂(𝑉+𝐸) 이고 답이 서로 다를 수 있다. 어느 쪽도 틀리지 않았다. 다만 진입차수 방법은 그릇을 큐 대신 우선순위 큐로 바꾸면 "사전순으로 가장 앞선 답"을 얻을 수 있어, 답을 하나로 정해야 할 때 쓰기 좋다.
설계 판단
빌드 시스템이 "순환 의존성" 오류를 냈다. 어떤 알고리즘이 이것을 잡았겠는가? 그리고 순환을 이루는 파일들을 사용자에게 보여 주려면 무엇이 더 필요한가?
풀이 보기
빌드 시스템은 파일 사이의 의존성을 방향 그래프로 두고 위상 정렬로 컴파일 순서를 정한다. 그 과정에서 순환이 잡힌다.
진입차수 방법을 썼다면 큐가 비었는데 결과에 안 들어간 정점이 남아 있는 상태로 드러난다. 남은 정점 전부가 "순환에 속하거나 순환 뒤에 매달린" 것들이다.
그런데 그것만으로는 어느 파일들이 고리를 이루는지 알 수 없다. 사용자에게 "A 와 B 와 C 가 서로를 참조합니다"라고 말하려면 순환 자체를 찾아야 한다.
그래서 실무에서는 DFS 로 뒤 간선을 찾는다. 뒤 간선 𝑢→𝑣 를 만나면, 지금 재귀 스택에 있는 정점들 중 𝑣 부터 𝑢 까지가 정확히 그 순환이다. 스택을 그대로 출력하면 된다.
진입차수 방법은 순환의 존재를 알려 주고 DFS 방법은 순환의 내용까지 알려 준다. 오류 메시지를 좋게 만들려면 뒤쪽이 필요하다. 두 방법이 같은 복잡도인데도 둘 다 알아 둘 이유가 여기에 있다.
예제 92
앞 그림의 DAG 에서 위상 정렬의 답이 몇 개인지 세어라. 간선은 𝑎→𝑐, 𝑏→𝑐, 𝑏→𝑑, 𝑐→𝑑, 𝑐→𝑒, 𝑑→𝑒 다.
풀이 보기
맨 앞에 올 수 있는 것은 진입차수가 0 인 𝑎 와 𝑏 뿐이다.
남은 정점들의 순서는 사실상 정해져 있다. 𝑐 는 𝑎 와 𝑏 둘 다 뒤에, 𝑑 는 𝑏 와 𝑐 뒤에, 𝑒 는 𝑐 와 𝑑 뒤에 와야 한다. 곧 𝑐→𝑑→𝑒 순서는 고정이다.
그러므로 자유로운 것은 𝑎 와 𝑏 중 어느 것을 먼저 놓을까뿐이다.
𝑎 를 먼저 놓으면 𝑎,𝑏,𝑐,𝑑,𝑒 다. 𝑏 를 먼저 놓으면 그다음에 놓을 수 있는 것은 𝑎 뿐이므로(𝑐 는 𝑎 를 기다린다) 𝑏,𝑎,𝑐,𝑑,𝑒 다.
답은 두 개다. 그림의 두 방법이 각각 하나씩 내놓은 것이 우연이 아니다.
일반적으로 답의 개수는 간선이 적을수록 많아진다. 간선이 하나도 없으면 𝑛! 가지이고, 한 줄로 늘어서면 1 가지다. 위상 정렬은 "정해진 만큼만 정하고 나머지는 자유"라는 점에서 8장의 정렬과 다르다.
양방향 탐색
출발점과 도착점이 둘 다 정해져 있으면 BFS 를 한쪽에서만 넓힐 이유가 없다. 양쪽에서 동시에 넓혀 가운데서 만나게 하면 훨씬 적게 훑는다.
<한쪽에서만 넓히면 반지름 d 짜리 공을 다 훑어야 하지만 양쪽에서 넓히면 반지름 d/2 짜리 공 두 개면 된다. 지수의 절반이 되는 것이라 차이가 크다>[원본 보기]
이유는 지수다. 정점마다 이웃이 평균 𝑏 개이고 거리가 𝑑 면 한쪽 탐색이 훑는 정점 수가 대략 𝑏𝑑 다. 양쪽에서 𝑑/2 까지만 넓히면 2𝑏𝑑/2 다.
𝑏=10, 𝑑=6 이면 106=1,000,000 과 2×103=2,000 이다. 500배다.
조건이 있다. 도착점을 알아야 하고, 간선을 거꾸로도 따라갈 수 있어야 한다. 방향 그래프라면 역방향 인접 리스트를 따로 만들어야 한다. 그리고 멈추는 조건이 까다롭다. 두 탐색이 처음 만난 순간의 경로가 최단이 아닐 수 있어(가중 그래프에서 특히), 만난 뒤에도 같은 층을 마저 확인해야 한다.
예제 93
이웃이 평균 30명인 소셜 그래프에서 "여섯 다리 건너면 누구나 이어진다"를 확인하려 한다. 한쪽 BFS 와 양방향 BFS 가 각각 몇 개의 정점을 훑는가?
풀이 보기
𝑏=30, 𝑑=6 을 넣는다.
한쪽 BFS 는 306=7.29×108, 곧 약 7억 개다. 정점이 그보다 적으면 사실상 그래프 전체를 훑는 것이다.
양방향은 2×303=2×27,000=54,000 개다. 1만 배 이상 적다.
실제로는 소셜 그래프에 겹치는 이웃이 많아 이 추정보다 적게 훑는다. 그러나 비율은 그대로다. 지수가 절반이 되는 이득이 겹침 때문에 사라지지는 않는다.
여기서 얻을 교훈은 "어디로 가는지 알면 그 정보를 써라"는 것이다. 12장의 A* 가 같은 발상을 한 걸음 더 밀고 나간다. 목적지 방향을 추정값으로 삼아 탐색을 그쪽으로 기울인다.
이 장의 정리
이 장의 알고리즘은 사실상 하나다. 탐색 한 번, 𝑂(𝑉+𝐸). 나머지는 그 탐색 중에 무엇을 기록하느냐의 차이다.
<질문이 무엇이냐에 따라 훑는 순서와 기록할 것이 정해진다. 가중치가 없는 최단 거리까지가 이 장의 몫이고, 가중치가 붙으면 12장으로 넘어간다>[원본 보기]
질문
무엇을 쓰는가
무엇을 기록하는가
비용
여기서 저기로 갈 수 있나
DFS 또는 BFS
방문 표시
𝑂(𝑉+𝐸)
덩어리가 몇 개인가
표시 안 된 정점마다 탐색
탐색을 시작한 횟수
𝑂(𝑉+𝐸)
간선 수로 잰 최단 거리
BFS
큐에 넣을 때의 거리
𝑂(𝑉+𝐸)
순환이 있나
DFS
뒤 간선을 만났는가
𝑂(𝑉+𝐸)
이분 그래프인가
BFS
층의 홀짝
𝑂(𝑉+𝐸)
선후관계를 지키는 순서
진입차수 또는 DFS 종료 순서
꺼낸 순서 · 종료 순서
𝑂(𝑉+𝐸)
용어
뜻
정점 · 간선 · 차수
동그라미 · 선 · 그 정점에 붙은 간선 수
진입차수 · 진출차수
방향 그래프에서 들어오는 · 나가는 화살표 수
경로 · 순환
같은 정점을 두 번 안 지나는 보행 · 출발점으로 돌아오는 경로
연결 요소
서로 오갈 수 있는 정점의 덩어리
DAG
방향이 있고 순환이 없는 그래프. 위상 정렬이 되는 조건
인접 행렬 · 인접 리스트
𝑉2 짜리 표 · 정점마다의 이웃 목록
프런티어
아직 안 가 본 정점을 담는 그릇. 스택이면 DFS, 큐면 BFS
탐색 트리 · 뒤 간선
탐색이 지나간 간선의 트리 · 조상으로 되돌아가는 간선
다음 두 장은 다시 설계 기법이다. 10장의 탐욕 알고리즘과 11장의 동적 계획법을 세운 뒤 12장에서 이 그래프로 돌아와 간선에 가중치를 붙인다. 그러면 "가장 짧은 길"의 뜻이 달라져 BFS 가 더는 답을 주지 못하고, 큐를 우선순위 큐로 바꾸는 것이 그 답인데 6장에서 만든 힙이 바로 그 자리에 들어간다. 이 장에서 세운 위상 정렬과 DAG 는 그보다 먼저 11장에서 쓰인다. 동적 계획법이 표를 채우는 순서가 곧 부분문제 DAG 의 위상 순서이기 때문이다.
탐욕 알고리즘
7장의 분할정복은 문제를 쪼개 전부 풀었다. 이 장의 방법은 정반대다. 고를 것이 여럿일 때 지금 가장 좋아 보이는 것 하나를 고르고 나머지는 보지 않는다. 그리고 한 번 고른 것을 다시 뒤집지 않는다.
이 방법이 빠른 것은 당연하다. 문제는 거의 언제나 틀린다는 것이다. 그런데 어떤 문제에서는 놀랍게도 항상 옳다. 이 장의 질문은 그래서 하나다. 언제 옳고 언제 틀리는가.
순서를 뒤집어 틀리는 예부터 본다. 옳은 예를 먼저 보면 "대체로 되는 방법"이라는 잘못된 인상이 남기 때문이다. 그 반례가 11장에서 다시 풀린다. 두 장이 그 실로 이어져 있다.
앞에서 가져오는 것은 6장의 힙과 우선순위 큐(허프만 부호화에 쓴다), 8장의 정렬의 성질(탐욕 알고리즘은 거의 언제나 한 번 정렬하는 것으로 시작한다), 그리고 기초수학의 확률과 기댓값이다. 여기서 세우는 탐욕 선택 성질과 바꿔치기 논법은 12장에서 최소신장트리를 다룰 때 "자르기 성질"이라는 이름으로 다시 나온다.
지금 가장 좋아 보이는 것을 고른다
탐욕 알고리즘(greedy algorithm)은 이런 모양이다.
남은 문제에서 어떤 기준으로 가장 좋아 보이는 것을 하나 고른다.
그 선택을 확정하고, 그것과 어긋나는 것들을 후보에서 지운다.
남은 것이 없을 때까지 되풀이한다.
되돌아보는 단계가 없다는 것이 전부다. 그래서 값이 싸다. 대개 한 번 정렬하고 한 번 훑는 것이 전부라 𝑂(𝑛log𝑛) 이고, 표를 만들지 않으니 공간도 거의 안 쓴다.
그리고 그래서 틀린다. 한 번 고른 것이 뒤에 무엇을 막는지 알 수 없기 때문이다.
거스름돈 — 가장 작은 반례
동전이 1원, 4원, 6원짜리뿐인 나라가 있다. 8원을 거슬러 주되 동전 개수를 가장 적게 하려 한다.
탐욕은 이렇게 한다. 남은 금액에 넣을 수 있는 가장 큰 동전을 집는다. 8원이니 6원을 집고, 남은 2원에는 4원이 안 들어가니 1원을 두 번 집는다. 세 개다.
그런데 4원 두 개면 두 개로 끝난다.
<6 원을 집는 순간 남은 2 원을 1 원으로만 채울 수 있게 된다. 지금 가장 큰 동전이 전체로는 손해였다>[원본 보기]
무엇이 잘못되었는가. 6원을 집는 것은 그 순간에는 분명히 최선이다. 남은 금액이 가장 많이 줄어든다. 그런데 그 선택이 남긴 2원이라는 잔돈은 1원으로밖에 채울 수 없다. 탐욕은 자기가 무엇을 막았는지 볼 수 없다.
재미있는 것은 우리가 쓰는 동전(1, 5, 10, 50, 100, 500)에서는 탐욕이 항상 옳다는 점이다. 그래서 사람들은 이 방법이 늘 되는 줄 안다. 동전의 값 하나만 바꾸면 무너진다.
예제 94
동전이 1원, 3원, 4원일 때 6원을 만든다. 탐욕과 최적을 각각 구하라. 그리고 이보다 더 작은 반례가 있는지 찾아라.
풀이 보기
탐욕. 4원을 집으면 남은 2원은 1원 두 개다. 4+1+1 로 세 개다.
최적.3+3 으로 두 개다.
더 작은 금액을 살펴보자. 1부터 5까지는 탐욕이 각각 1, 2, 1, 1, 2 개를 내는데 모두 최적이다. 5=4+1 이 최적이고 탐욕도 그렇게 한다.
그러니 이 동전 집합에서 6원이 가장 작은 반례다. 여기서 배울 것이 있다. 반례는 아주 작은 입력에 숨어 있다. 큰 값을 무작위로 넣어 보는 시험으로는 영영 안 나올 수도 있다.
탐욕이 맞는지 확인하려면 무작위 시험이 아니라 작은 경우를 전부 훑어 보는 편이 낫다. 11장의 방법으로 정확한 답을 구해 놓고 탐욕과 비교하면 반례를 기계가 찾아 준다.
예제 95
동전이 1원, 2원, 4원, 8원처럼 배수 관계로만 이루어져 있으면 탐욕이 언제나 옳다. 왜 그런지 설명하라.
풀이 보기
묻는 것은 "어떤 구조가 탐욕을 지켜 주는가"다.
핵심은 이것이다. 어떤 동전보다 작은 동전들을 아무리 모아도 그 동전 하나를 대신하려면 반드시 그 개수만큼 든다. 4원을 1원과 2원으로 만들려면 최소 두 개(2+2)가 필요하고, 4원 하나보다 많다.
좀 더 정확히 보자. 최적해에 8원짜리를 쓸 수 있는데 안 썼다고 하자. 그러면 나머지 동전들로 8 이상을 만들어야 하는데, 4원 이하만으로 8을 채우려면 적어도 두 개가 필요하다. 그 두 개를 8원 하나로 바꾸면 개수가 줄거나 같다.
이 "바꿔도 나빠지지 않는다"가 다음 절에서 정리할 탐욕 선택 성질이다.
배수 관계가 아니면 이 논법이 깨진다. {1,4,6} 에서 6을 쓰지 않고 4 두 개로 8을 만드는 편이 나았던 것이 그 예다. 4+4=8 인데 두 개뿐이라 6 하나를 쓴 쪽보다 적다.
언제 탐욕이 옳은가 — 두 조건
탐욕이 옳다고 말하려면 두 가지를 보여야 한다.
탐욕 선택 성질(greedy choice property) — 탐욕이 고르는 것을 첫 선택으로 삼는 최적해가 반드시 존재한다.
최적 부분구조(optimal substructure) — 첫 선택을 떼어 내고 남은 문제의 최적해가 전체 최적해의 나머지 부분과 같다.
둘째 조건은 "문제를 줄여도 성질이 유지된다"는 말이고, 11장의 동적 계획법도 이 조건을 요구한다. 두 기법의 차이는 첫째 조건에 있다. 탐욕은 여러 선택 중 하나만 보고, 동적 계획법은 전부 견준다.
바꿔치기 논법
탐욕 선택 성질을 보이는 표준 수법이 바꿔치기 논법(exchange argument)이다.
<최적해에서 탐욕과 처음 갈리는 자리를 찾아 탐욕의 선택으로 바꾼다. 바꾼 것이 여전히 답이고 나빠지지 않으면 되풀이해서 최적해를 탐욕해로 밀어붙일 수 있다>[원본 보기]
절차는 넷이다.
어떤 최적해 𝑂 를 가져온다. 무엇인지는 몰라도 된다. 있다는 것만 쓴다.
𝑂 와 탐욕해 𝐺 가 처음 갈리는 자리를 찾는다.
그 자리의 𝑂 의 선택을 𝐺 의 선택으로 바꿔치기한다.
바꾼 것이 여전히 답이고 값이 나빠지지 않음을 보인다.
그러면 갈리는 자리가 한 칸 뒤로 밀린다. 되풀이하면 𝑂 가 𝐺 가 되고, 값은 한 번도 나빠지지 않았으므로 𝐺 도 최적이다.
이 네 단계는 12장에서 이름을 하나 더 얻는다. 거기서 최소신장트리의 자르기 성질을 증명할 때 하는 일이 정확히 이 모양이다. 최소신장트리 𝑇 에 간선 𝑒 를 넣으면 순환이 하나 생기는데, 그 순환에서 다른 간선 𝑓 를 빼는 것이 바로 3단계의 바꿔치기다. 자르기 성질은 최소신장트리 문제의 탐욕 선택 성질이다. 지금은 그 문장을 미리 적어 두는 것으로 충분하고, 논법 자체는 위의 넷이 전부다.
예제 96
여러 파일을 하나씩 차례로 처리하는 기계가 있다. 파일 𝑖 의 처리 시간이 𝑡𝑖 일 때, 모든 파일의 완료 시각의 합을 가장 작게 하려면 어떤 순서로 처리해야 하는가?
풀이 보기
묻는 것은 순서 정하기다. 완료 시각의 합은 이렇게 적힌다. 𝑘 번째로 처리하는 파일의 완료 시각은 앞의 모든 처리 시간의 합이므로,
합=𝑡[1]+(𝑡[1]+𝑡[2])+⋯=∑𝑛𝑘=1(𝑛−𝑘+1)𝑡[𝑘]
먼저 처리하는 것일수록 계수가 크다. 그러니 짧은 것에 큰 계수를 주는 것이 유리하다. 답은 짧은 것부터다.
바꿔치기로 확인하자. 어떤 최적 순서에서 이웃한 두 파일 𝑎,𝑏 가 𝑡𝑎>𝑡𝑏 인데 𝑎 가 앞에 있다고 하자. 두 파일을 맞바꾸면 다른 파일의 완료 시각은 전혀 변하지 않는다(둘의 시간 합이 그대로이므로).
두 파일 자신만 보면, 바꾸기 전 합은 𝑡𝑎+(𝑡𝑎+𝑡𝑏) 이고 바꾼 뒤는 𝑡𝑏+(𝑡𝑎+𝑡𝑏) 다. 𝑡𝑏<𝑡𝑎 이므로 줄었다. 최적이라는 가정에 어긋난다.
따라서 최적 순서에는 "긴 것이 앞"인 이웃 쌍이 없고, 그것은 곧 오름차순이다.
이 문제를 이웃한 둘만 견주어 결론을 낸 것에 주목하라. 순서 문제에서 자주 통하는 요령이다.
예제 97
탐욕이 틀리는지 확인할 때 "최적 부분구조가 없다"와 "탐욕 선택 성질이 없다"는 다른 이야기다. 거스름돈 문제 {1,4,6} 은 어느 쪽인가?
풀이 보기
최적 부분구조는 있다. 8원을 만드는 최적해에서 동전 하나를 떼어 내면, 남은 것은 "남은 금액을 만드는 최적해"다. 아니라면 그 부분을 더 좋은 것으로 갈아 끼워 전체가 나아질 테니 최적이 아니었다는 뜻이 된다.
예를 들어 8원의 최적 {4,4} 에서 4원 하나를 떼면 {4} 가 남고, 이것은 4원을 만드는 최적해다.
없는 것은 탐욕 선택 성질이다. 탐욕은 6원을 고르는데, 6원을 첫 동전으로 쓰는 최적해가 없다. 8원의 최적해는 {4,4} 뿐이기 때문이다.
이 구분이 중요한 이유는 다음에 무엇을 할지가 달라지기 때문이다. 최적 부분구조가 있으니 이 문제는 11장의 동적 계획법으로 풀린다. 표를 세워 "마지막 동전이 무엇이었나"를 전부 견주면 된다.
반대로 최적 부분구조까지 없으면 동적 계획법도 통하지 않는다. 그때는 상태를 다시 설계하거나 13장의 방법으로 넘어가야 한다.
활동 선택 — 겹치지 않게 가장 많이
강의실이 하나 있다. 쓰겠다는 신청이 여럿 들어왔고 각각 시작 시각과 끝 시각이 정해져 있다. 서로 겹치지 않게 가장 많은 신청을 받으려면 어떻게 고르는가.
기준 후보가 여럿이다. 일찍 시작하는 것부터? 짧은 것부터? 겹치는 것이 적은 것부터? 셋 다 틀린다.
<위는 일찍 끝나는 것부터 골라 넷을 얻는다. 아래는 가장 짧은 것을 골랐다가 양옆 둘을 함께 잃는다>[원본 보기]
옳은 기준은 일찍 끝나는 것부터다. 이유를 한 문장으로 적으면 이렇다. 가장 일찍 끝나는 것을 고르면 남는 시간이 가장 많다.
↓ python
def 활동선택(활동들): # 활동은 (시작, 끝)
활동들.sort(key=lambda 활동: 활동[1]) # 끝나는 시각 순
고른것, 마지막끝 = [], 0
for 시작, 끝 in 활동들:
if 시작 >= 마지막끝:
고른것.append((시작, 끝))
마지막끝 = 끝
return 고른것
정렬이 𝑂(𝑛log𝑛) 이고 훑기가 𝑂(𝑛) 이므로 전체가 𝑂(𝑛log𝑛) 이다. 이미 끝 시각 순으로 정렬되어 있으면 𝑂(𝑛) 다.
옳음은 바꿔치기로 나온다. 어떤 최적해 𝑂 의 첫 활동을 𝑜, 가장 일찍 끝나는 활동을 𝑔 라 하자. 𝑔 의 끝 시각이 𝑜 보다 이르거나 같으므로, 𝑂 에서 𝑜 를 빼고 𝑔 를 넣어도 뒤의 활동들과 여전히 겹치지 않는다. 개수도 그대로다. 따라서 𝑔 로 시작하는 최적해가 존재한다.
예제 98
왜 "일찍 시작하는 것부터"는 틀리는가? 활동 세 개로 반례를 만들어라.
풀이 보기
일찍 시작하지만 아주 늦게 끝나는 활동을 하나 넣으면 된다.
𝐴=(0,20), 𝐵=(1,5), 𝐶=(6,10) 이라 하자.
일찍 시작하는 것부터 고르면 𝐴 를 집는다. 𝐴 가 20까지 이어지므로 𝐵 도 𝐶 도 못 고른다. 하나다.
일찍 끝나는 것부터 고르면 𝐵(5에 끝남), 그다음 𝐶(10에 끝남)로 둘이다.
어디서 갈렸는가. 시작 시각은 그 활동이 뒤를 얼마나 막는지에 대해 아무것도 말해 주지 않는다. 뒤에 남는 시간을 정하는 것은 끝 시각이다.
이것이 탐욕에서 가장 중요한 교훈이다. 기준을 고를 때는 "무엇이 좋아 보이는가"가 아니라 "무엇이 남은 문제를 가장 크게 남기는가"를 물어야 한다.
설계 판단
이번에는 강의실이 여러 개다. 모든 신청을 다 받되 강의실 개수를 가장 적게 쓰려 한다. 어떻게 하겠는가?
풀이 보기
문제가 바뀌었다. 앞은 "하나의 자원으로 최대 개수"였고 이번은 "전부 처리하는 데 필요한 최소 자원"이다.
답의 하한부터 생각하자. 어떤 순간에 동시에 진행 중인 활동이 𝑘 개라면 강의실이 적어도 𝑘 개는 있어야 한다. 그러니 답은 그 최댓값 이상이다.
그 값에 실제로 도달할 수 있다. 시각을 따라 훑으며 시작 시각을 만나면 강의실 하나를 쓰고, 끝 시각을 만나면 하나를 돌려준다. 쓰는 개수의 최댓값이 답이다.
절차로 적으면 시작 시각과 끝 시각을 전부 모아 정렬한 뒤 훑으며 세는 것이고, 𝑂(𝑛log𝑛) 이다.
같은 일을 6장의 최소 힙으로도 한다. 활동을 시작 시각 순으로 보며, 힙에 "쓰고 있는 강의실의 끝 시각"을 담는다. 가장 이른 끝 시각이 지금 시작 시각보다 이르면 그 방을 재사용하고, 아니면 방을 하나 늘린다. 힙 크기가 곧 쓰는 방의 개수다.
여기서 눈여겨볼 것은 같은 자료에 대한 다른 질문이 다른 탐욕 기준을 요구한다는 점이다. 하나는 끝 시각으로 정렬하고 하나는 시작 시각으로 훑는다. 기준은 문제마다 새로 찾아야 한다.
배낭 — 쪼갤 수 있을 때와 없을 때
배낭에 𝑊 킬로그램까지 담을 수 있다. 물건마다 무게와 가치가 있다. 가치 합을 가장 크게 담아라.
물건을 쪼갤 수 있으면 이것을 분할 가능 배낭(fractional knapsack)이라 한다. 금가루나 곡물처럼 나눌 수 있는 것을 생각하면 된다. 쪼갤 수 없으면 0-1 배낭이다. 통째로 넣거나 안 넣거나 둘 중 하나다.
<가치 밀도 순으로 부으면 쪼갤 수 있을 때는 언제나 최적이다. 쪼갤 수 없으면 마지막에 남는 빈자리 때문에 같은 순서가 손해가 된다>[원본 보기]
분할 가능 배낭의 답은 탐욕이다. 가치 밀도(가치 ÷ 무게)가 높은 것부터 붓고, 마지막 물건은 남은 자리만큼만 잘라 넣는다.
옳은 이유도 바꿔치기다. 최적해에 밀도가 낮은 물건이 들어 있고 밀도가 높은 물건에 아직 여유가 있다면, 낮은 것 1킬로그램을 빼고 높은 것 1킬로그램을 넣으면 가치가 늘거나 같다. 이 교환을 계속하면 밀도 순으로 채운 것이 된다.
값은 정렬이 지배하므로 𝑂(𝑛log𝑛) 이다.
0-1 배낭에서는 이 논법이 통째로 깨진다. 1킬로그램만 빼고 넣을 수가 없기 때문이다. 그림의 아래쪽이 그것이다. 밀도 순으로 A와 B를 넣고 나면 20킬로그램이 남는데 C는 30킬로그램이라 못 들어간다. 애초에 A를 포기했다면 B와 C를 넣어 더 많이 담을 수 있었다.
예제 100
물건이 A(10 kg, 60원), B(20 kg, 100원), C(30 kg, 120원)이고 배낭이 50 kg 이다. 분할 가능 배낭과 0-1 배낭의 답을 각각 구하라.
풀이 보기
밀도를 먼저 구한다. A는 60/10=6, B는 100/20=5, C는 120/30=4 이다. 순서는 A, B, C 다.
분할 가능. A 전부(10 kg, 60원), B 전부(20 kg, 100원)를 넣으면 30 kg 을 썼고 20 kg 이 남는다. C 를 20/30=2/3 만 넣으면 120×2/3=80 원이다.
60+100+80=240 원
0-1. 가능한 조합을 전부 적어 보자. {𝐴}=60, {𝐵}=100, {𝐶}=120, {𝐴,𝐵}=160 (30 kg), {𝐴,𝐶}=180 (40 kg), {𝐵,𝐶}=220 (50 kg), {𝐴,𝐵,𝐶} 는 60 kg 이라 안 된다.
최적은 {𝐵,𝐶}=220 원이다.
그런데 탐욕은 A 부터 담으므로 {𝐴,𝐵}=160 원에서 멈춘다. 최적의 73%밖에 안 된다.
두 답이 240 과 220 으로 다른 것도 확인하자. 쪼갤 수 있으면 언제나 답이 크거나 같다. 쪼갤 수 있는 쪽이 할 수 있는 일이 더 많기 때문이다.
예제 101
0-1 배낭에서 탐욕이 얼마나 나쁠 수 있는가를 보여라. 물건 두 개로 최적의 비율을 마음대로 나쁘게 만들 수 있는가?
풀이 보기
밀도가 아주 조금 높지만 아주 가벼운 물건 하나를 넣으면 된다.
배낭이 𝑊 이고 물건이 둘이라 하자. A는 무게 1, 가치 2 (밀도 2). B는 무게 𝑊, 가치 𝑊 (밀도 1).
탐욕은 밀도가 높은 A를 먼저 넣는다. 그러면 남은 자리가 𝑊−1 이라 B가 안 들어간다. 얻는 가치는 2다.
최적은 B 하나로 𝑊 이다.
비율이 𝑊/2 이므로 𝑊 를 키우면 얼마든지 나빠진다. 탐욕이 최적의 1%에도 못 미치게 만들 수 있다.
이것은 단순히 "가끔 틀린다"가 아니라 "틀린 정도에 보장이 없다"는 뜻이다. 13장에서 볼 근사 알고리즘은 바로 이 보장을 붙이려는 시도다. 배낭 문제에는 실제로 "최적의 몇 퍼센트 안"을 보장하는 방법이 있다.
정확한 답이 필요하면 11장으로 간다. 표를 세워 각 물건에 대해 넣는 경우와 안 넣는 경우를 둘 다 견주면 된다.
허프만 부호화
글자를 비트로 적는다. 서로 다른 글자가 여섯 개면 고정 길이로는 한 글자에 3비트가 필요하다. 그런데 글자마다 나오는 빈도가 크게 다르다면, 자주 나오는 글자에 짧은 부호를 주는 편이 전체 길이를 줄인다.
길이를 다르게 하면 문제가 하나 생긴다. 부호를 이어 붙였을 때 어디서 끊어 읽어야 할지 모른다. 예컨대 a=0, b=01 이면 "01"이 b 인지 a 다음에 무언가인지 알 수 없다.
해결책은 접두 부호(prefix code)다. 어떤 부호도 다른 부호의 앞부분이 되지 않게 하면 앞에서부터 읽어 나가다 부호 하나가 완성되는 순간 끊으면 된다.
접두 부호는 이진트리로 만들면 저절로 얻어진다. 왼쪽 가지를 0, 오른쪽을 1로 읽고 글자를 전부 잎에 두면 어떤 글자도 다른 글자로 가는 길 위에 있지 않다.
이제 문제는 "어떤 트리가 전체 비트 수를 가장 작게 하는가"다. 허프만의 답은 탐욕이다. 가장 가벼운 두 개를 합치기를 되풀이한다.
<줄에서 가장 가벼운 둘을 꺼내 합치고 그 합을 다시 줄에 넣는다. 원소가 하나 남을 때까지 n−1 번 되풀이한다>[원본 보기]
↓ python
def 허프만(빈도표):
힙 = 최소힙([(빈도, 글자) for 글자, 빈도 in 빈도표])
while len(힙) > 1:
왼쪽 = 힙.꺼내기() # 가장 가벼운 것
오른쪽 = 힙.꺼내기() # 그다음
힙.넣기((왼쪽.빈도 + 오른쪽.빈도, 새_내부노드(왼쪽, 오른쪽)))
return 힙.꺼내기() # 뿌리
합치기가 𝑛−1 번이고 힙 연산이 𝑂(log𝑛) 이므로 전체가 𝑂(𝑛log𝑛) 이다. 6장에서 만든 우선순위 큐가 여기서 쓰인다.
<빈도가 45 인 a 는 뿌리 바로 아래에 놓여 1비트를 받고, 빈도가 5 인 f 는 가장 깊은 곳에서 4비트를 받는다. 글자가 모두 잎이므로 어떤 부호도 다른 부호의 앞부분이 아니다>[원본 보기]
왜 옳은가. 탐욕 선택 성질을 이렇게 적을 수 있다. 빈도가 가장 작은 두 글자가 형제로 놓이는 최적 트리가 존재한다.
증명은 바꿔치기다. 어떤 최적 트리에서 가장 깊은 두 잎을 보자. 그 자리에 놓인 글자가 가장 가벼운 두 글자가 아니라면, 가벼운 것과 자리를 맞바꾼다. 가벼운 것이 깊이 가고 무거운 것이 얕게 오므로 전체 비트 수가 줄거나 같다. 최적이었으므로 같아야 하고, 따라서 가장 가벼운 둘이 가장 깊은 형제인 최적 트리가 존재한다.
예제 102
빈도가 a 45, b 13, c 12, d 16, e 9, f 5 일 때 허프만 부호를 만들고, 고정 길이 부호와 견주어 얼마나 줄었는지 계산하라.
트리에서 각 글자의 깊이를 읽으면 부호 길이가 나온다. a는 1, b · c · d는 3, e · f는 4다.
45(1)+13(3)+12(3)+16(3)+9(4)+5(4)=45+39+36+48+36+20=224 비트
고정 길이면 글자가 여섯 종류이므로 ⌈log26⌉=3 비트씩, 모두 100×3=300 비트다.
(300−224)/300≈25% 를 줄였다.
답이 말이 되는지 보자. 가장 흔한 a 가 100개 중 45개인데 그것이 1비트로 줄었으니 절약의 대부분이 거기서 왔다. 빈도가 고를수록 이득이 줄고, 모두 같으면 허프만도 고정 길이와 같아진다.
실제 파일 압축이 이 위에 서 있다. 다만 요즘 압축기는 앞뒤 문맥을 함께 쓰므로 이보다 훨씬 잘한다.
예제 103
허프만 트리에서 같은 빈도의 글자가 여럿이면 부호가 달라질 수 있다. 그래도 전체 비트 수는 같은가? 그리고 압축한 파일을 푸는 쪽은 부호표를 어떻게 아는가?
풀이 보기
앞의 것. 전체 비트 수는 같다. 합치기의 매 단계에서 "가장 가벼운 둘"의 빈도 값이 같다면 어느 것을 집어도 합쳐지는 값이 같고, 이후 모든 단계가 같은 값들을 다루기 때문이다. 트리 모양은 달라져도 각 깊이에 놓이는 빈도의 다중집합이 같으므로 합도 같다.
12장에서도 같은 일이 일어난다. 최소신장트리는 여럿일 수 있어도 무게 합은 하나로 정해진다. 같은 값이 여럿일 때 어느 것을 집어도 결과의 값은 같다는 것이 탐욕에서 되풀이되는 모양이다.
뒤의 것. 부호표를 모르면 풀 수 없으므로 파일에 함께 저장해야 한다. 그래서 짧은 파일에서는 부호표가 차지하는 자리 때문에 오히려 커지기도 한다.
표를 짧게 적는 요령이 있다. 각 글자의 부호 길이만 적어 두고, 길이가 같으면 사전순으로 부호를 매기기로 양쪽이 미리 약속하면 트리를 그대로 복원할 수 있다. 이것을 규범 허프만 부호라 하고 실제 압축 형식들이 쓴다.
또 하나. 압축하려면 빈도를 알아야 하므로 파일을 두 번 읽어야 한다. 한 번만 읽고 하려면 읽으면서 트리를 고쳐 나가는 적응형 방식을 쓴다.
탐색과 활용의 맞바꿈
지금까지의 탐욕은 "무엇이 좋은지"를 확실히 알고 있었다. 무게도 가치도 빈도도 문제에 주어져 있었다.
실제 문제는 그렇지 않은 일이 많다. 무엇이 좋은지를 해 봐야 아는 상황에서, 지금까지의 기록만 보고 가장 좋아 보이는 것을 고르면 어떻게 되는가. 이 절은 그 이야기다.
𝜖-탐욕 전략
손잡이가 여럿인 슬롯머신을 생각하자. 각 손잡이는 저마다 다른 확률로 돈을 준다. 그 확률은 모른다. 당길 수 있는 횟수는 정해져 있고, 딴 돈을 가장 많게 하고 싶다.
순수한 탐욕은 "지금까지 표본 평균이 가장 높은 손잡이"를 당긴다. 문제는 표본이 적을 때 그 평균이 우연에 크게 흔들린다는 것이다. 운 좋게 처음 두 번을 딴 나쁜 손잡이에 영원히 갇힐 수 있다.
<왼쪽은 처음의 우연 때문에 참 기댓값이 0.3 인 손잡이만 97번 당겼다. 오른쪽은 열 번에 한 번씩 다른 곳을 찔러 본 덕에 표본이 참값에 붙었다>[원본 보기]
해결책이 𝜖-탐욕(epsilon-greedy) 전략이다.
확률 1−𝜖 로 지금 가장 좋아 보이는 것을 당긴다 — 활용(exploitation).
확률 𝜖 로 아무거나 하나를 당긴다 — 탐색(exploration).
𝜖 이 크면 정보는 빨리 모이지만 당장의 손해가 크고, 작으면 그 반대다. 이것이 탐색과 활용의 맞바꿈이다.
흔히 쓰는 요령은 𝜖 을 시간에 따라 줄이는 것이다. 처음에는 많이 찔러 보고 나중에는 아는 것을 쓴다. 사람이 낯선 도시에서 첫 며칠은 새 식당을 찾다가 떠나기 전에는 가장 좋았던 곳으로 가는 것과 같다.
최적 멈춤 — 비서 문제
상황이 조금 다른 문제가 있다. 비서를 한 명 뽑는데 지원자가 𝑛 명이고, 한 명씩 면접을 보며 그 자리에서 뽑거나 보내야 한다. 보낸 사람은 다시 부를 수 없고, 뽑으면 면접이 끝난다. 지원자의 순서는 무작위다. 가장 좋은 지원자를 뽑을 확률을 가장 크게 하려면?
아무나 찍으면 확률이 1/𝑛 이다. 놀랍게도 그보다 훨씬 잘할 수 있다.
전략은 이렇다. 앞의 𝑟 명은 무조건 보내되 그중 최고 점수를 기억한다. 그 뒤로 그 점수를 넘는 첫 사람을 뽑는다.
<너무 일찍 멈추면 기준이 없어 아무나 뽑고, 너무 오래 보면 최고가 이미 지나가 버린다. 봉우리는 1/e 자리에 있고 그때의 성공 확률도 1/e 다>[원본 보기]
그림의 봉우리는 𝑟/𝑛≈0.37, 정확히는 1/𝑒 자리에 있다. 대략 37%를 흘려보내고 그 뒤로 최고를 만나면 뽑는다는 것이 답이고, 그때 성공 확률도 약 37%다. 사람 수가 아무리 늘어도 이 값은 변하지 않는다.
다중 슬롯머신과 기틴스 지수
슬롯머신 문제로 돌아가자. 𝜖-탐욕은 쓸 만하지만 최적은 아니다. "아무거나" 고르는 부분이 이미 나쁜 줄 아는 손잡이도 똑같이 고르기 때문이다.
미래의 이득을 할인율로 현재 가치로 환산하는 설정에서는 최적 전략이 알려져 있다. 각 손잡이에 대해 그 손잡이의 승패 기록만으로 계산되는 값 하나를 매기고 그 값이 가장 큰 것을 당기면 된다. 이 값을 기틴스 지수(Gittins index)라 한다.
놀라운 점은 다른 손잡이의 사정을 몰라도 각 손잡이의 값을 따로 계산할 수 있다는 것이다. 손잡이가 여럿인 문제가 손잡이 하나짜리 문제 여러 개로 쪼개진다.
할인율이란 "내일의 이득이 오늘의 이득보다 얼마나 덜 값진가"다. 원문의 비유가 좋다. 오늘 저녁을 먹고 나서 내일까지 살아 있지 못할 확률이 1%라면, 내일 저녁의 가치는 오늘 저녁의 99%다. 남은 기회가 적을수록 할인이 세지고, 할인이 세지면 탐색의 값이 떨어진다. 여행 마지막 날 점심에 새 식당을 개척하지 않고 가장 좋았던 곳으로 가는 것이 그래서 합리적이다.
기틴스 지수는 계산이 무겁다. 그래서 실무에서는 대신 후회(regret)를 줄이는 쪽으로 접근한다. 후회란 "처음부터 최고를 알았더라면 얻었을 것"과 "실제로 얻은 것"의 차이다. 표본이 적어 불확실한 손잡이에 점수를 얹어 주는 방식(신뢰 상한)이나, 각 손잡이가 최고일 확률에 비례해 뽑는 방식이 이런 접근이다.
예제 104
비서 문제에서 𝑟 명을 흘려보내는 전략의 성공 확률을 𝑛 과 𝑟 로 적어라.
풀이 보기
성공은 "최고를 뽑는 것"이다. 최고가 𝑘 번째에 있는 경우로 나누어 더한다.
최고가 𝑘 번째일 확률은 1/𝑛 이다. 순서가 무작위이므로 어느 자리든 같다.
최고가 𝑘 번째(𝑘>𝑟)에 있을 때 실제로 뽑히려면, 𝑟 번째와 𝑘 번째 사이에서 미리 멈추지 않아야 한다. 미리 멈추는 것은 그 사이에 앞 𝑟 명의 최고를 넘는 사람이 있을 때다.
뒤집어 말하면 앞 𝑘−1 명 중 최고가 앞 𝑟 명 안에 있어야 한다. 그래야 𝑟+1 부터 𝑘−1 까지 아무도 기준을 못 넘고, 𝑘 번째가 처음으로 넘는다.
앞 𝑘−1 명 중 최고가 어느 자리에 있을 확률도 모두 같으므로 그 확률은 𝑟/(𝑘−1) 이다.
두 사건은 독립이므로 곱해서 더한다.
𝑃(𝑟)=∑𝑛𝑘=𝑟+11𝑛⋅𝑟𝑘−1=𝑟𝑛∑𝑛−1𝑗=𝑟1𝑗
검산하자. 𝑟=0 이면 합이 정의되지 않는데, 실제로 기준이 없어 첫 사람을 뽑게 되므로 확률은 1/𝑛 이다. 𝑟=𝑛−1 이면 𝑃=(𝑛−1)/𝑛⋅1/(𝑛−1)=1/𝑛 이다. 양 끝이 모두 1/𝑛 이고 가운데가 봉우리인 그림과 맞는다.
보충
위 식에서 𝑛 이 클 때 봉우리가 𝑟/𝑛=1/𝑒 에 있음을 보여라.
풀이 보기
합 ∑𝑛−1𝑗=𝑟1/𝑗 를 어림해야 한다. 이 합은 1/𝑥 곡선 아래의 넓이로 어림되고, 기초수학에서 본 대로 그 넓이가 로그다.
∑𝑛−1𝑗=𝑟1𝑗≈∫𝑛𝑟𝑑𝑥𝑥=ln𝑛𝑟
𝑥=𝑟/𝑛 로 두면
𝑃≈𝑟𝑛ln𝑛𝑟=𝑥ln1𝑥=−𝑥ln𝑥
이 함수를 𝑥 로 미분한다.
𝑑𝑑𝑥(−𝑥ln𝑥)=−ln𝑥−1
0 이 되는 자리는 ln𝑥=−1, 곧 𝑥=𝑒−1=1/𝑒≈0.368 이다.
그 자리의 값도 구해 보자. −1𝑒ln1𝑒=1𝑒≈0.368 이다. 흘려보내는 비율과 성공 확률이 같은 값이라는 것이 이 문제의 유명한 결론이다.
답이 말이 되는가. 𝑛=100 이면 아무나 찍을 때 1%인데 37%가 된다. 서른일곱 배다. 정보를 "쓰지 않고 모으기만 하는" 구간을 두는 것만으로 이만큼이 나온다.
설계 판단
새로 만든 추천 화면 두 가지(A, B) 중 어느 쪽이 나은지 알아보려 한다. 하루에 방문자가 10만 명이다. (1) 절반씩 나눠 일주일 보여 준 뒤 좋은 쪽을 고른다 (2) 𝜖-탐욕으로 계속 조정한다. 각각의 이해득실은?
풀이 보기
두 방식이 무엇을 맞바꾸는지 보면 된다.
(1) 절반씩 나누기. 일주일 동안 나쁜 쪽에도 절반을 계속 보낸다. 손해가 확실하고 크다. 대신 일주일 뒤에 얻는 결론은 통계적으로 튼튼하다. 표본 크기가 정해져 있어 "차이가 우연일 확률"을 제대로 따질 수 있다.
(2) 𝜖-탐욕. 좋아 보이는 쪽으로 더 많이 보내므로 실험 중의 손해가 작다. 대신 자료가 한쪽에 쏠려 나중에 "정말 나은가"를 통계적으로 말하기 어려워진다. 초기의 우연으로 잘못된 쪽에 쏠릴 위험도 있다.
무엇이 목적인가로 갈린다. 결론을 얻는 것이 목적이면 (1), 실험 기간의 손해를 줄이는 것이 목적이면 (2) 다.
상황이 갈리는 조건도 보자. 두 화면의 차이가 아주 작으면 (2)는 좀처럼 한쪽으로 수렴하지 못하고 계속 반씩 보내게 된다. 반대로 차이가 크면 (2)가 빠르게 좋은 쪽으로 쏠려 손해를 크게 줄인다.
실제로는 섞어 쓴다. 처음 얼마간은 고르게 보내 기초 자료를 모으고(탐색), 그 뒤 𝜖 을 줄여 가며 좋은 쪽으로 옮긴다(활용). 앞에서 본 "𝜖 을 시간에 따라 줄인다"가 이것이다.
이 장의 정리
탐욕은 빠르지만 위험한 방법이 아니다. 조건을 만족하면 옳고 아니면 그냥 틀린 방법이다. 그 조건을 확인하지 않고 쓰면 언제 틀리는지도 모르는 코드가 된다.
<먼저 작은 반례를 찾아본다. 반례가 나오면 기준을 바꾸고, 어떤 기준으로도 반례가 남으면 11장의 동적 계획법으로 넘어간다>[원본 보기]
문제
탐욕 기준
옳은가
비용
활동 선택
끝나는 시각이 이른 것부터
옳다
𝑂(𝑛log𝑛)
강의실 최소 개수
시각 순으로 훑으며 세기
옳다
𝑂(𝑛log𝑛)
분할 가능 배낭
가치 밀도가 높은 것부터
옳다
𝑂(𝑛log𝑛)
허프만 부호화
가장 가벼운 둘을 합치기
옳다
𝑂(𝑛log𝑛)
최소신장트리 (12장에서)
가장 싼 간선부터 · 가장 가까운 정점부터
옳다
𝑂(𝐸log𝑉)
작업 순서 (완료 시각 합)
짧은 것부터
옳다
𝑂(𝑛log𝑛)
거스름돈 (일반 동전)
가장 큰 동전부터
틀린다
11장으로
0-1 배낭
가치 밀도가 높은 것부터
틀린다
11장으로
용어
뜻
탐욕 선택 성질
탐욕의 첫 선택을 포함하는 최적해가 존재한다
최적 부분구조
첫 선택을 뗀 나머지의 최적해가 전체 최적해의 일부다
바꿔치기 논법
최적해를 탐욕해 쪽으로 한 칸씩 밀어도 나빠지지 않음을 보인다
접두 부호
어떤 부호도 다른 부호의 앞부분이 아닌 부호. 잎에만 글자를 두면 된다
탐색과 활용
정보를 모으는 값과 아는 것을 쓰는 값의 맞바꿈
𝜖-탐욕
확률 𝜖 로 아무거나, 나머지는 가장 좋아 보이는 것
최적 멈춤
되돌릴 수 없는 선택을 언제 할 것인가. 비서 문제의 답은 1/𝑒
후회
처음부터 최고를 알았을 때의 이득과 실제 이득의 차이
이 장은 두 개의 미해결 문제를 남겼다. 거스름돈과 0-1 배낭이다. 둘 다 탐욕 선택 성질이 없었지만 최적 부분구조는 있었다. 다음 장이 정확히 그 조건 위에 서 있다. 하나만 고르고 넘어가는 대신 모든 선택을 견주되, 같은 부분문제를 두 번 풀지 않는다.
동적 계획법
10장은 두 문제를 풀지 못한 채 남겨 두었다. 동전 거스름돈과 0-1 배낭이다. 둘 다 탐욕이 틀렸는데, 틀린 이유가 같았다. 한 번 고른 것을 되돌릴 수 없어서 지금의 선택이 뒤에 무엇을 막는지 알 수 없었다.
그러면 모든 선택을 다 해 보면 된다. 문제는 그러면 경우의 수가 지수로 늘어난다는 것이다. 이 장의 방법은 그 사이에 있다. 모든 선택을 견주되, 같은 부분문제를 두 번 풀지 않는다.
답할 질문은 넷이다. 왜 같은 것을 여러 번 풀게 되는가, 어떻게 한 번만 풀 수 있는가, 어떤 문제에 쓸 수 있는가, 그리고 표의 한 칸을 무엇으로 정할 것인가. 마지막 질문이 이 장에서 가장 어려운 곳이고, 실무에서 실제로 하는 일도 그것이다.
앞에서 가져오는 것은 3장의 재귀와 재귀식, 9장의 위상 정렬(Topological sort), 그리고 10장의 최적 부분구조다. 이름이 이상하다는 말도 해 두자. 동적 계획법(dynamic programming)의 "계획법"은 계획표를 뜻하고 "동적"은 별 뜻이 없다. 1950년대에 연구비를 타내려고 그럴듯하게 붙인 이름이라고 지은이 자신이 밝혔다. 내용은 "표를 채운다"가 전부다.
같은 것을 몇 번이나 다시 세는가
피보나치 수를 정의 그대로 재귀로 적어 보자.
↓ python
def F(n):
if n <= 1: return n
return F(n-1) + F(n-2)
옳은 코드다. 그런데 𝐹(50) 을 부르면 답이 나오지 않는다. 왜 그런지는 호출을 펼쳐 보면 바로 보인다.
<F(6) 을 부르면 25번을 부르는데 서로 다른 것은 F(0) 부터 F(6) 까지 일곱 개뿐이다. 색이 같은 원은 똑같은 계산이다>[원본 보기]
호출 횟수를 세어 보면 𝐹(3) 을 3번, 𝐹(2) 를 5번, 𝐹(1) 을 8번 부른다. 아래로 갈수록 같은 것이 늘어난다.
몇 번 부르는지 정확히 세면 이렇다. 호출 수를 𝐶(𝑛) 이라 하면 𝐶(𝑛)=𝐶(𝑛−1)+𝐶(𝑛−2)+1 이고, 이것은 피보나치 수 자체와 같은 속도로 자란다. 곧 호출 수가 𝐹(𝑛) 만큼, 지수로 늘어난다.
𝐹(50)≈1.2×1010 이므로 1초에 10억 번 부르는 기계로도 열 몇 초가 걸린다. 𝐹(90) 이면 나이가 우주의 나이를 넘는다.
이것이 겹치는 부분문제(overlapping subproblems)다. 7장에서 본 분할정복은 나눈 조각이 서로 겹치지 않았다. 병합 정렬에서 왼쪽 반과 오른쪽 반은 아무 관계가 없다. 그런데 여기서는 𝐹(𝑛−1) 과 𝐹(𝑛−2) 가 통째로 겹친다.
답은 뻔하다. 한 번 구한 답을 적어 두고 다시 쓴다.
<겹치는 것을 하나로 합치면 트리가 노드 일곱 개짜리 DAG 가 되고, 그 위상 순서대로 펼치면 칸 일곱 개짜리 표가 된다>[원본 보기]
그림이 이 장 전체의 요약이다. 재귀 트리 → 부분문제 DAG → 표. 마지막 형태에서 계산 횟수는 이렇게 된다.
계산횟수=(서로다른부분문제의수)×(한칸을채우는값)
피보나치는 부분문제가 𝑛+1 개이고 한 칸이 덧셈 하나이므로 Θ(𝑛) 이다. 지수가 선형이 되었다.
예제 107
위 재귀 코드에서 𝐹(6) 을 부를 때 각 값이 몇 번 계산되는지 세어라. 그리고 𝐹(𝑛) 의 호출 수가 왜 지수인지 설명하라.
풀이 보기
그림을 세면 𝐹6 1번, 𝐹5 1번, 𝐹4 2번, 𝐹3 3번, 𝐹2 5번, 𝐹1 8번, 𝐹0 5번이다. 모두 25번이다.
1, 1, 2, 3, 5, 8 이라는 수열이 눈에 익을 것이다. 피보나치 수 자체다. 우연이 아니다. 𝐹𝑘 를 부르는 자리가 𝐹𝑘+1 과 𝐹𝑘+2 의 호출 아래에 각각 있으므로 횟수가 같은 점화식을 따른다.
피보나치 수는 𝜑=(1+√5)/2≈1.618 의 거듭제곱으로 자란다. 곧 𝐶(𝑛)=Θ(𝜑𝑛) 이고, 밑이 1보다 크므로 지수다.
검산하면 𝑛 을 하나 늘릴 때마다 호출이 약 1.6배가 된다. 𝐹(6) 이 25번이니 𝐹(10) 은 대략 165번일 텐데 실제로 177번이다. 재귀가 사실상 1을 𝐹(𝑛) 번 더하는 일을 하고 있다는 뜻이다.
예제 108
이항계수 (𝑛𝑘) 를 (𝑛𝑘)=(𝑛−1𝑘−1)+(𝑛−1𝑘) 로 재귀 계산하면 같은 문제가 생긴다. 서로 다른 부분문제는 몇 개인가?
풀이 보기
부분문제는 (𝑛,𝑘) 쌍 하나로 정해진다. 0≤𝑘≤𝑛 이므로 개수는 Θ(𝑛𝑘) 개다.
그런데 재귀 호출 수는 훨씬 많다. 값 자체가 (𝑛𝑘) 이고 바닥값이 전부 1 이므로, 재귀는 1을 (𝑛𝑘) 번 더하는 일을 한다. (3015)≈1.55×108 이니 이미 버겁다.
표로 채우면 Θ(𝑛𝑘) 이다. 𝑛=30,𝑘=15 이면 450칸이다. 108 대 450 이다.
이 표가 바로 파스칼의 삼각형이다. 부분문제를 세는 요령도 확인하자. 재귀 함수의 인자가 무엇이고 각각 몇 가지 값을 갖는가를 보면 된다. 여기서는 인자가 둘이라 곱해서 𝑂(𝑛𝑘) 다.
두 방향 — 메모이제이션과 표 채우기
표를 채우는 방향이 둘이다.
<왼쪽은 재귀를 그대로 두고 이미 구한 것을 표에서 꺼내 쓴다. 오른쪽은 재귀를 버리고 작은 것부터 순서대로 채운다. 답은 같고 성격이 다르다>[원본 보기]
하향식 — 메모이제이션
메모이제이션(memoization)은 재귀 코드를 거의 그대로 둔다. 함수 들머리에서 "이미 구했으면 그것을 돌려주고", 날머리에서 "구한 값을 적어 둔다"를 붙이면 끝이다.
↓ python
표 = {}
def F(n):
if n <= 1: return n
if n in 표: return 표[n] # 이미 구했으면 꺼낸다
표[n] = F(n-1) + F(n-2)
return 표[n]
각 𝑛 에 대해 실제 계산은 한 번만 일어나고 나머지는 표에서 꺼낸다. 그러니 비용이 Θ(𝑛) 이다.
이 방식의 이점은 필요한 칸만 채운다는 것이다. 부분문제가 아주 많은데 실제로 쓰이는 것은 일부일 때 크게 유리하다. 대신 재귀 깊이만큼 호출 스택을 쓰므로(3장) 깊이가 수만을 넘으면 스택이 넘친다.
상향식 — 표 채우기
재귀를 아예 버리고 작은 것부터 순서대로 채운다.
↓ python
def F(n):
표 = [0] * (n + 1)
표[1] = 1
for k in range(2, n + 1):
표[k] = 표[k-1] + 표[k-2]
return 표[n]
반복문 한 번이라 Θ(𝑛) 이고 스택을 안 쓴다. 대신 채우는 순서를 사람이 정해야 한다. 어떤 칸을 채울 때 그 칸이 참조하는 칸들이 이미 채워져 있어야 하기 때문이다.
이 "순서"가 무엇인지는 앞 그림이 이미 답했다. 부분문제를 노드로, "이것을 알아야 저것을 안다"를 간선으로 놓으면 DAG 가 되고, 채우는 순서란 그 DAG 의 위상 순서다. 9장에서 세운 도구가 여기서 쓰인다.
하향식 (메모이제이션)
상향식 (표 채우기)
코드
재귀 그대로 + 표 두 줄
반복문으로 다시 쓴다
채우는 칸
실제로 필요한 것만
전부
순서
저절로 정해진다
사람이 정해야 한다
스택
재귀 깊이만큼 쓴다
안 쓴다
유리한 때
부분문제가 성기게 쓰일 때
거의 다 쓴다 · 공간을 줄이고 싶다
예제 109
위 상향식 코드에서 𝑛=0 을 넣으면 무슨 일이 생기는가? 그리고 표 전체 대신 변수 두 개로 같은 일을 하라.
풀이 보기
앞의 것. 표의 크기가 1 인데 표[1]=1 을 하려다 범위를 벗어난다. 바닥값을 적기 전에 표가 그만큼 크기는 한지 확인해야 한다. 𝑛≤1 이면 곧바로 𝑛 을 돌려주는 줄을 앞에 두면 된다.
DP 코드에서 가장 흔한 버그가 이런 바닥값 처리다. 표의 크기, 바닥 칸의 값, 그리고 "그 바닥이 정말 맞는가"를 매번 따로 확인해야 한다.
뒤의 것.표[𝑘] 가 참조하는 것은 표[𝑘−1] 과 표[𝑘−2] 뿐이다. 그 앞의 칸은 다시 볼 일이 없으니 변수 둘을 두고 (앞앞,앞)←(앞,앞앞+앞) 로 밀어 가면 된다.
시간은 그대로 Θ(𝑛) 이고 공간이 Θ(𝑛) 에서 Θ(1) 로 줄었다. 이 요령이 뒤에서 표를 줄이는 이야기의 씨앗이다.
예제 110
메모이제이션이 상향식보다 확실히 나은 상황을 하나 들어라.
풀이 보기
부분문제의 가짓수는 많은데 실제로 도달하는 것이 적을 때다.
예를 들어 "합이 정확히 𝑡 가 되는 부분집합이 있는가"를 묻는 문제를 생각하자. 상태를 (몇번째원소까지,지금까지의합) 으로 잡으면 부분문제가 𝑛×𝑡 개다.
그런데 원소들의 값이 드문드문하면(예컨대 전부 1000의 배수라면) 실제로 나올 수 있는 합은 그중 일부뿐이다. 상향식은 못 쓰는 칸까지 다 채우지만 메모이제이션은 도달하는 상태만 채운다.
또 하나는 순서를 정하기 어려울 때다. 상태 사이의 의존이 복잡해 위상 순서를 손으로 적기 힘들면 재귀가 순서를 알아서 처리해 준다. 반대로 칸을 거의 다 쓸 때는 상향식이 낫다. 함수 호출과 표 조회의 상수가 없고 공간을 줄이기도 쉽다.
언제 쓸 수 있는가
동적 계획법은 두 조건이 함께 성립할 때 쓴다.
겹치는 부분문제 — 같은 부분문제가 여러 번 나온다. 이것이 없으면 표를 만들 이유가 없고, 그냥 7장의 분할정복이다.
최적 부분구조 — 큰 문제의 최적해가 부분문제의 최적해들로 이루어진다. 이것이 없으면 표에 최적값을 적어 두는 일 자체가 의미가 없다.
10장의 탐욕도 최적 부분구조를 요구했다. 둘의 차이는 첫 선택을 다루는 방식에 있다. 탐욕은 후보 중 하나만 보고, 동적 계획법은 전부 견주어 가장 좋은 것을 표에 적는다. 그래서 탐욕보다 느리지만 훨씬 넓게 통한다.
최적 부분구조가 없는 예를 하나 보아 두면 감이 잡힌다. 최단경로는 그것을 가졌다. 𝑠→𝑡 최단경로 위의 중간 정점 𝑣 를 잡으면 앞부분은 𝑠→𝑣 의 최단경로다. 아니라면 더 짧은 것으로 갈아 끼워 전체가 짧아질 테니 최단이 아니었다.
그런데 단순 경로 중 가장 긴 것은 그렇지 않다. 𝑠→𝑡 의 최장 단순경로의 앞부분이 𝑠→𝑣 의 최장 단순경로라는 보장이 없다. 앞부분에서 정점을 다 써 버리면 뒷부분이 갈 곳이 없어지기 때문이다. 두 부분문제가 서로 간섭한다. 실제로 일반 그래프의 최장 경로는 13장에서 볼 NP-난해 문제다.
예제 111
다음 셋 중 동적 계획법으로 풀 수 있는 것과 없는 것을 가려라. (1) 격자에서 왼쪽 위에서 오른쪽 아래로 오른쪽과 아래로만 가는 길의 개수 (2) 배열에서 연속 구간 합의 최댓값 (3) 그래프에서 모든 정점을 정확히 한 번씩 지나는 길이 있는가
풀이 보기
각각 두 조건을 확인한다.
(1) 된다. 칸 (𝑖,𝑗) 에 이르는 길의 수는 위 칸과 왼쪽 칸에 이르는 길의 수의 합이다. 부분문제가 격자 칸 수만큼이고, 여러 칸이 같은 칸을 참조하므로 겹친다. Θ(𝑚𝑛) 이다.
(2) 된다. "𝑖 에서 끝나는 구간의 최대 합"을 𝐷[𝑖] 라 하면 𝐷[𝑖]=max(𝐷[𝑖−1]+𝑎[𝑖],𝑎[𝑖]) 다. 앞 칸 하나만 보면 되므로 Θ(𝑛) 이고 공간도 Θ(1) 로 줄어든다. 7장의 나누고 · 풀고 · 합친다 절에서 같은 문제를 분할정복으로 풀어 Θ(𝑛log𝑛) 을 얻었는데, 여기서는 Θ(𝑛) 이다. 거기서는 배열을 자리로 잘라 겹치지 않는 두 조각을 만들었고, 여기서는 "어디서 끝나는가"로 잘라 조각이 겹치게 만들었다. 그 차이가 log𝑛 배다.
(3) 안 된다 — 그대로는. 해밀턴 경로 문제다. "𝑣 까지 왔다"만으로는 상태가 부족하다. 어느 정점들을 이미 썼는지를 함께 알아야 하기 때문이다.
그것까지 상태에 넣으면 실제로 풀린다. (쓴정점의집합,지금있는정점) 을 상태로 잡으면 부분문제가 2𝑉×𝑉 개다. 곧이곧대로 𝑉! 가지를 훑는 것보다는 훨씬 낫지만 여전히 지수다.
셋을 가른 것은 상태에 무엇을 담아야 하는가였다. 담을 것이 적으면 다항 시간이고, 지나온 것을 전부 기억해야 하면 지수가 된다. 이 구분이 13장의 주제로 이어진다.
동전 거스름돈 — 10장의 반례를 푼다
돌아왔다. 동전이 1원, 4원, 6원일 때 8원을 만드는 최소 동전 수를 구한다.
상태를 정하는 일부터 한다. 𝑑𝑝[𝑚] 을 "𝑚 원을 만드는 데 드는 가장 적은 동전 수"로 둔다. 이 한 문장이 설계의 절반이다.
전이는 마지막에 놓은 동전이 무엇이었나로 경우를 나눈다. 마지막이 𝑐 원이었다면 그 앞은 𝑚−𝑐 원을 만든 것이고, 그것은 반드시 𝑚−𝑐 원의 최적해다(최적 부분구조). 그러니
𝑑𝑝[𝑚]=min𝑐∈동전(𝑑𝑝[𝑚−𝑐])+1(𝑚≥𝑐)
바닥은 𝑑𝑝[0]=0 이다. 0원을 만드는 데는 동전이 하나도 필요 없다.
<0 원부터 오른쪽으로 채운다. 8 원 칸은 마지막 동전이 1 · 4 · 6 이었을 세 경우를 모두 견주므로 6 원을 안 쓰는 길을 놓치지 않는다>[원본 보기]
↓ python
def 최소동전(동전들, 금액):
dp = [무한] * (금액 + 1)
dp[0] = 0
for m in range(1, 금액 + 1):
for c in 동전들:
if c <= m:
dp[m] = min(dp[m], dp[m-c] + 1)
return dp[금액]
칸이 금액 개이고 칸마다 동전 종류만큼 견주므로 𝑂(금액×동전종류) 이다. 여기서는 8×3=24 번의 비교로 끝난다.
탐욕과 무엇이 달라졌는가. 탐욕은 8원 자리에서 "6원" 하나만 보고 확정했다. 표는 세 가지를 모두 적어 견주고 가장 작은 것을 남긴다. 되돌아보지 못하는 대신 애초에 앞을 다 본다.
예제 112
위 표를 손으로 채워라. 그리고 𝑑𝑝[8] 이 2 라는 답에서 실제로 쓴 동전을 어떻게 알아내는가?
풀이 보기
바닥부터 간다. 𝑑𝑝[0]=0.
𝑑𝑝[1] 부터 𝑑𝑝[3] 까지는 1원만 놓을 수 있으므로 각각 1, 2, 3 이다.
𝑑𝑝[4] 는 후보가 𝑑𝑝[3]+1=4 와 𝑑𝑝[0]+1=1 이라 1 이다. 이어서 𝑑𝑝[5] 는 2, 𝑑𝑝[6] 은 𝑑𝑝[0]+1=1, 𝑑𝑝[7] 은 2 가 된다.
𝑑𝑝[8]: 𝑑𝑝[7]+1=3, 𝑑𝑝[4]+1=2, 𝑑𝑝[2]+1=3. 최소는 2.
동전을 알아내는 법. 표는 개수만 적어 두었으므로 되짚어야 한다. 𝑑𝑝[8]=2 를 만든 것이 어느 후보였는지 다시 견주면 𝑑𝑝[4]+1 이다. 그러니 마지막 동전은 4원이고 𝑑𝑝[4] 로 간다. 𝑑𝑝[4]=1 을 만든 것은 𝑑𝑝[0]+1 이니 또 4원이다. 𝑑𝑝[0] 에 닿았으므로 끝. 4원 두 개다.
채울 때 "이 칸을 만든 동전"을 함께 적어 두면 되짚기가 한 번의 훑기로 끝난다. 공간을 조금 더 쓰는 맞바꿈이다.
예제 113
같은 표를 조금 바꾸어 "𝑚 원을 만드는 방법의 가짓수"를 세려 한다. 무엇을 고쳐야 하는가? 그리고 반복문의 순서가 왜 문제가 되는가?
풀이 보기
최솟값 대신 개수를 세는 것이니 min 을 합으로 바꾸고 바닥값을 𝑑𝑝[0]=1 로 둔다. 0원을 만드는 방법이 "아무것도 안 쓰는" 한 가지이기 때문이다.
그런데 위 코드의 순서 그대로 하면 순서가 다른 것을 다른 방법으로 센다.4+1 과 1+4 를 둘로 센다는 뜻이다.
조합으로 세려면 반복문을 뒤집어야 한다. 바깥을 동전으로, 안쪽을 금액으로 돌린다.
↓ python
dp = [0] * (금액 + 1)
dp[0] = 1
for c in 동전들: # 동전을 바깥에
for m in range(c, 금액 + 1):
dp[m] += dp[m-c]
이렇게 하면 "1원까지만 쓴 방법의 수"를 다 센 뒤 "4원도 쓸 수 있게 한 방법의 수"를 세는 식이 되어, 동전을 쓰는 순서가 하나로 고정된다.
여기서 배울 것이 중요하다. 같은 표처럼 보여도 반복문의 순서가 상태의 뜻을 바꾼다. 순열을 세느냐 조합을 세느냐가 그것 하나로 갈렸다. 표를 짜기 전에 "한 칸이 무엇인가"를 문장으로 적어야 하는 이유다.
막대 자르기와 0-1 배낭 — 표가 이차원이 될 때
막대 자르기
길이 𝑛 인 막대가 있고 길이별 값이 정해져 있다. 잘라 파는데 값의 합을 가장 크게 하려면 어떻게 자르는가. 자르는 값은 없다고 하자.
상태는 거스름돈과 같은 모양이다. 𝑟[𝑛] 을 "길이 𝑛 짜리 막대로 얻을 수 있는 최대 값"으로 둔다. 전이는 첫 토막의 길이로 나눈다.
𝑟[𝑛]=max1≤𝑖≤𝑛(𝑝[𝑖]+𝑟[𝑛−𝑖])
칸이 𝑛 개이고 칸마다 𝑛 가지를 견주므로 Θ(𝑛2) 이다.
예제 114
길이별 값이 𝑝[1]=1,𝑝[2]=5,𝑝[3]=8,𝑝[4]=9 일 때 길이 4 막대의 최대 값과 자르는 방법을 구하라.
<물건을 하나씩 늘려 가며 줄을 채운다. 한 칸은 바로 위 칸(안 넣는다)과 왼쪽 위 어딘가의 칸(넣는다) 둘만 본다>[원본 보기]
↓ python
def 배낭(무게, 가치, 용량):
dp = [[0] * (용량 + 1) for _ in range(len(무게) + 1)]
for i in range(1, len(무게) + 1):
for w in range(용량 + 1):
dp[i][w] = dp[i-1][w] # 안 넣는다
if 무게[i-1] <= w:
넣기 = dp[i-1][w - 무게[i-1]] + 가치[i-1]
dp[i][w] = max(dp[i][w], 넣기)
return dp[-1][-1]
칸이 𝑛×𝑊 개이고 칸마다 상수 일이므로 𝑂(𝑛𝑊) 이다.
여기에 함정이 하나 있다. 𝑊 는 입력에 적힌 수이지 입력의 길이가 아니다. 3장에서 소수 판정을 볼 때와 같은 이야기다. 𝑊 를 이진법으로 적으면 자릿수가 log𝑊 이므로, 입력 길이로 재면 𝑂(𝑛𝑊) 은 지수다. 이것을 유사 다항 시간이라 하고 13장에서 다시 만난다.
예제 115
물건이 1번(2 kg, 3원), 2번(3 kg, 4원), 3번(4 kg, 5원), 4번(5 kg, 6원)이고 용량이 5 kg 이다. 표를 채우고 어느 물건을 담았는지 되짚어라.
풀이 보기
줄을 하나씩 채운다. 열은 용량 0부터 5까지다.
0번 줄(물건 없음)은 전부 0 이고, 1번까지(2 kg, 3원)는 용량 2부터 넣을 수 있으므로 0,0,3,3,3,3 이다.
𝑑𝑝[4][5]=7=𝑑𝑝[3][5] → 4번을 안 넣었다. 𝑑𝑝[3][5]=7=𝑑𝑝[2][5] → 3번도 안 넣었다. 𝑑𝑝[2][5]=7≠𝑑𝑝[1][5]=3 → 2번을 넣었다. 용량을 5−3=2 로 줄여 𝑑𝑝[1][2] 로 간다. 𝑑𝑝[1][2]=3≠𝑑𝑝[0][2]=0 → 1번을 넣었다.
답은 1번과 2번이고 무게 합이 5, 가치 합이 7이다. 10장의 탐욕이 놓친 조합을 표는 놓치지 않는다.
두 문자열을 견주는 표
두 문자열에서 순서를 지키되 띄엄띄엄 골라 만들 수 있는 가장 긴 공통 문자열을 최장 공통 부분수열(longest common subsequence, LCS)이라 한다. 파일의 차이를 보여 주는 도구가 이것으로 만들어진다.
상태를 𝐿[𝑖][𝑗] = "𝑋 의 앞 𝑖 글자와 𝑌 의 앞 𝑗 글자의 LCS 길이"로 둔다. 전이는 마지막 글자가 짝이 되는가로 갈린다.
마지막 항의 대괄호는 "두 글자가 다르면 1, 같으면 0"이라는 뜻이다. 바닥은 𝑑[𝑖][0]=𝑖, 𝑑[0][𝑗]=𝑗 다. 빈 문자열로 만들려면 글자 수만큼 지워야 하기 때문이다.
예제 116
𝑋=AGCAT, 𝑌=GACT 의 LCS 를 구하고 그것을 되짚어 실제 부분수열을 적어라.
풀이 보기
표를 채운다. 줄은 𝑋 의 글자, 열은 𝑌 의 글자다.
A 줄: G 와 다르니 0, A 와 같으니 0+1=1, 나머지는 왼쪽 값을 물려받아 0,0,1,1,1.
G 줄은 G 자리에서 1 이 되어 0,1,1,1,1, C 줄은 C 자리에서 𝐿[2][2]+1=2 라 0,1,1,2,2, A 줄은 A 자리에서 𝐿[3][1]+1=2 라 0,1,2,2,2, T 줄은 T 자리에서 𝐿[4][3]+1=3 이라 0,1,2,2,3 이다. 답은 3이다.
되짚기. 오른쪽 아래 (5,4) 에서 시작한다. 글자가 같으면(T = T) 대각선으로 가고 그 글자를 적는다. 다르면 위와 왼쪽 중 값이 큰 쪽으로 간다. 같으면 아무 쪽이나 간다.
(5,4) T 적고 대각선 → (4,3) A 와 C 가 다르니 위로 → (3,3) C = C 적고 대각선 → (2,2) G 와 A 가 다르니 위로 → (1,2) A = A 적고 대각선 → (0,1) 끝.
거꾸로 적었으므로 뒤집으면 ACT 다. 검산하면 AGCAT 의 1 · 3 · 5번째가 ACT 이고 GACT 의 2 · 3 · 4번째도 ACT 다. GAT 도 길이 3인 답이므로 LCS 는 여럿일 수 있고 길이만 하나다.
예제 117
kitten 을 sitting 으로 바꾸는 편집 거리를 구하라. 그리고 편집 거리와 LCS 사이에 어떤 관계가 있는가?
풀이 보기
손으로 찾아보면 세 번이면 된다. k를 s로 교체, e를 i로 교체, 끝에 g를 삽입. 그러니 편집 거리는 최대 3이다.
2 이하가 안 되는 이유도 보자. 길이가 6과 7이라 삽입이 적어도 한 번은 필요하다. 남은 두 번으로 kitten 을 sittin 과 맞춰야 하는데 k · e 두 자리가 다르므로 두 번이 꼭 필요하다. 합쳐서 3이다.
관계. 교체를 허용하지 않고 삽입과 삭제만 쓴다면, 두 문자열을 맞추는 최소 편집 횟수는
(𝑚−𝐿)+(𝑛−𝐿)=𝑚+𝑛−2𝐿
이다. 공통으로 남길 것이 LCS 이고 나머지는 각각 지우고 넣어야 하기 때문이다.
교체를 허용하면 이 값보다 작거나 같아진다. 삭제 하나와 삽입 하나를 교체 하나로 묶을 수 있기 때문이다. 두 문제의 표가 같은 모양인 것이 우연이 아니다. 같은 격자 위에서 무엇을 세느냐만 다르다. 연산마다 값을 다르게 주고 싶으면 전이식의 상수만 고치면 된다.
상태를 𝑀[𝑖][𝑗] = "𝑖 번째부터 𝑗 번째까지를 곱하는 최소 곱셈 횟수"로 둔다. 전이는 마지막으로 어디서 두 덩이를 합쳤는가로 나눈다.
𝑀[𝑖][𝑗]=min𝑖≤𝑘<𝑗(𝑀[𝑖][𝑘]+𝑀[𝑘+1][𝑗]+𝑝𝑖−1𝑝𝑘𝑝𝑗)
채우는 순서가 앞의 것들과 다르다. 𝑀[𝑖][𝑗] 는 자기보다 짧은 구간들을 참조하므로 구간 길이가 짧은 것부터 채워야 한다. 이것이 이 문제의 위상 순서다. 구간이 Θ(𝑛2) 개이고 칸마다 𝑂(𝑛) 가지를 견주므로 Θ(𝑛3) 이다.
예제 118
행렬이 𝐴1(10×100), 𝐴2(100×5), 𝐴3(5×50) 이다. 두 가지 묶는 방법의 곱셈 횟수를 각각 구하라.
풀이 보기
(𝐴1𝐴2)𝐴3. 먼저 𝐴1𝐴2 는 10×100×5=5000 번이고 결과가 10×5 다. 여기에 𝐴3 를 곱하면 10×5×50=2500 번이다. 합쳐서 7500 번.
𝐴1(𝐴2𝐴3). 먼저 𝐴2𝐴3 는 100×5×50=25000 번이고 결과가 100×50 이다. 여기에 𝐴1 을 곱하면 10×100×50=50000 번이다. 합쳐서 75 000 번.
열 배 차이다. 답은 완전히 같은데 곱셈 횟수만 다르다.
어디서 갈렸는가. 첫째는 𝐴1𝐴2 를 먼저 해 중간 결과를 10×5 로 작게 만들었고, 둘째는 100×50 짜리 큰 중간 결과를 만들어 그것을 다시 곱하느라 값을 치렀다.
탐욕은 통하지 않는다. "가장 싼 곱셈부터"는 이 예에서 우연히 맞지만 행렬을 넷 이상으로 늘리면 반례가 나온다. 그래서 갈라지는 자리 𝑘 를 모두 견주는 표가 필요하다.
동적 계획법은 DAG 위의 길이다
이 장에서 푼 문제들을 다시 보면 모두 같은 구조였다. 부분문제를 노드로, "이것을 알아야 저것을 안다"를 간선으로 놓으면 DAG 가 되고, 표를 채우는 일은 그 DAG 를 위상 순서로 훑는 일이다.
이 시선은 12장에서 그대로 되돌아온다. 거기서 DAG 최단경로가 하는 일은 "위상 정렬하고 그 순서대로 완화한다"인데, 표를 채우는 일과 글자 그대로 같다. 벨만-포드와 플로이드-워셜을 볼 때는 이 장의 표가 이름만 바꿔 다시 나온다. 벨만-포드의 라운드 하나가 "간선을 𝑘 개까지 써서 갈 수 있는 최단 거리"라는 표의 한 줄이고, 플로이드-워셜의 바깥 반복이 "어느 정점까지 거쳐도 되는가"라는 표의 축이다.
이 시선은 실제로 쓸모가 있다.
순환이 없어야 한다. 부분문제가 서로를 참조하면 표를 채울 순서가 없다. 상태를 잘못 잡았다는 신호다.
채우는 순서는 위상 순서면 무엇이든 된다. 거스름돈은 금액 순, 배낭은 물건 순, 행렬 곱은 구간 길이 순이었다. 셋 다 각자의 위상 순서다.
되짚기는 경로 복원이다. 12장에서 직전 정점을 기록해 경로를 되짚게 되는데, 표에서 답 자체를 복원하는 일이 그것과 같다.
같은 DAG 위에서 무엇을 계산하느냐만 바꾸면 다른 문제가 된다는 것도 이 시선의 소득이다. 간선에 값을 주고 최솟값을 구하면 최단경로, 길의 개수를 세면 경우의 수, 확률을 곱해 더하면 확률 계산이 된다. 계단을 한 번에 1칸이나 2칸씩 오르는 방법의 수가 피보나치와 같은 값이 나오는 것도 두 문제의 DAG 가 같기 때문이다.
상태를 설계하는 법과 공간 줄이기
동적 계획법에서 어려운 것은 코드가 아니다. 표의 한 칸이 무엇인지 정하는 일이다. 절차로 적으면 이렇다.
상태를 문장으로 적는다. "𝑑𝑝[⋯] 는 ~일 때의 ~이다." 이 문장을 못 적으면 아직 설계가 끝나지 않은 것이다.
전이를 적는다.마지막 결정이 무엇이었는가로 경우를 나누는 것이 거의 언제나 통한다.
바닥값을 정한다. 빈 것, 0원, 0칸일 때 무엇인가.
채우는 순서를 정한다. 참조하는 칸이 먼저 채워지도록.
값을 센다. 상태 수 곱하기 한 칸의 값.
상태를 잘못 잡았다는 신호가 있다. 전이를 적으려는데 "그 전에 무엇을 했는지"를 또 알아야 하는 경우다. 그러면 그 정보를 상태에 넣어야 한다. 0-1 배낭에서 "남은 용량"만으로 부족해 "어느 물건까지"를 넣은 것이 그 예다. 반대로 너무 많이 넣으면 칸 수가 폭발한다. 해밀턴 경로에서 지나온 정점 집합까지 넣었더니 2𝑉 이 된 것이 그것이다.
공간 줄이기
<전이가 직전 줄만 보면 두 줄로 줄이고, 방향을 뒤집으면 한 줄로도 된다. 대신 표를 지우면 무엇을 골랐는지 되짚을 수 없다>[원본 보기]
배낭의 전이 𝑑𝑝[𝑖][𝑤] 는 𝑖−1 줄만 참조한다. 그러니 줄 두 개만 들고 있으면 된다. 공간이 𝑂(𝑛𝑊) 에서 𝑂(𝑊) 로 준다.
한 줄로도 된다. 다만 오른쪽에서 왼쪽으로 채워야 한다. 왼쪽에서 오른쪽으로 채우면 𝑑𝑝[𝑤−𝑤𝑖] 를 읽을 때 그 칸이 이미 이번 물건으로 갱신된 값이라, 같은 물건을 두 번 넣게 된다.
↓ python
dp = [0] * (용량 + 1)
for i in range(len(무게)):
for w in range(용량, 무게[i] - 1, -1): # 뒤에서부터
dp[w] = max(dp[w], dp[w - 무게[i]] + 가치[i])
재미있게도 앞에서부터 채우면 "물건을 몇 개든 쓸 수 있는 배낭"이 된다. 버그가 다른 문제의 정답인 셈이다. 방향 하나가 문제를 바꾼다.
공간을 줄이면 잃는 것이 있다. 표를 지우고 나면 되짚을 수 없다. 값만 필요하면 줄이고, 답 자체가 필요하면 표를 남기거나 되짚기용 표시를 따로 저장한다.
설계 판단
문자열 두 개의 길이가 각각 10만이다. LCS 의 길이만 필요할 때와 부분수열 자체가 필요할 때 각각 어떻게 하겠는가?
풀이 보기
표의 칸이 105×105=1010 개다. 한 칸에 4바이트만 써도 40 GB 다. 표를 통째로 들고 있을 수 없다.
길이만 필요하면 줄 두 개로 줄인다. 공간이 2×105 칸, 곧 1 MB 아래다. 시간은 여전히 1010 이라 수십 초에서 수 분 걸리지만 적어도 돌아간다.
부분수열 자체가 필요하면 곤란해진다. 되짚으려면 표가 있어야 하는데 담을 수 없다.
방법이 있다. 문자열을 반으로 나누고, 왼쪽 절반의 LCS 길이 줄과 오른쪽 절반을 뒤집어 계산한 길이 줄을 각각 구해 더하면, 두 문자열이 어디에서 갈라지는지를 알 수 있다. 그 자리에서 문제를 둘로 쪼개 재귀한다.
공간은 𝑂(min(𝑚,𝑛)) 이고 시간은 두 배다. 재귀 한 단계마다 문제가 절반이라 총 일거리가 𝑚𝑛+𝑚𝑛/2+⋯<2𝑚𝑛 이기 때문이다. 7장의 분할정복이 동적 계획법과 함께 쓰인 예다. 기법은 배타적이지 않다.
이 장의 정리
동적 계획법은 완전 탐색에서 중복을 걷어낸 것이다. 그러니 시작은 언제나 완전 탐색을 그려 보는 일이고, 거기서 같은 부분문제가 보이면 표를 세우면 된다.
<상태를 한 문장으로 적는 것이 첫걸음이고 나머지는 기계적이다. 전이를 적으려는데 다른 정보가 또 필요하면 상태를 잘못 잡은 것이다>[원본 보기]
문제
상태 𝑑𝑝[⋯] 의 뜻
전이를 무엇으로 나누는가
비용
피보나치 · 계단 오르기
𝑘 번째 값
마지막 걸음
Θ(𝑛)
동전 거스름돈
𝑚 원의 최소 동전 수
마지막 동전
𝑂(𝑚𝑐)
막대 자르기
길이 𝑛 의 최대 값
첫 토막의 길이
Θ(𝑛2)
0-1 배낭
앞 𝑖 개 · 용량 𝑤 의 최대 가치
𝑖 를 넣는가 마는가
𝑂(𝑛𝑊)
최장 공통 부분수열
앞 𝑖 · 앞 𝑗 글자의 답
마지막 글자가 짝인가
Θ(𝑚𝑛)
편집 거리
앞 𝑖 를 앞 𝑗 로 바꾸는 값
삽입 · 삭제 · 교체
Θ(𝑚𝑛)
행렬 곱 순서
구간 𝑖..𝑗 의 최소 곱셈 수
마지막으로 합친 자리
Θ(𝑛3)
용어 · 사실
뜻
겹치는 부분문제
같은 부분문제가 여러 번 나온다. 없으면 그냥 분할정복이다
최적 부분구조
큰 답이 부분문제의 최적해들로 이루어진다
메모이제이션
재귀를 두고 답을 적어 둔다. 필요한 칸만 채운다
상향식 표 채우기
작은 것부터 순서대로. 순서는 부분문제 DAG 의 위상 순서다
되짚기
표에서 답 자체를 복원하는 일. 경로 복원과 같다
구간 DP
상태가 구간인 것. 짧은 구간부터 채운다
유사 다항 시간
𝑂(𝑛𝑊) 처럼 값에는 다항, 입력 자릿수에는 지수
DP 인지 알아보는 신호로 이 장을 닫는다. 최대 · 최소 · 개수를 묻고, 결정을 순서대로 내리며, 완전 탐색을 그려 보니 같은 부분문제가 자꾸 나온다면 표를 세울 자리다.
다음 장은 이 표를 그래프 위에 얹는다. 10장과 이 장은 문제를 푸는 틀에 이름을 붙였을 뿐, 그 틀이 실제로 무엇을 푸는지는 아직 제대로 보이지 않았다. 12장이 그 자리다. 가중치가 붙은 그래프에서 최단경로와 최소신장트리를 구하는 알고리즘들이 나오는데, 절반은 10장의 탐욕이고 절반은 이 장의 표 채우기다. 특히 이 장의 DP 는 DAG 절이 거기서 그대로 되돌아온다. 거기서는 부분문제를 노드로 삼은 가상의 DAG 를 위상 순서로 훑었는데, 12장에서는 그 DAG 가 진짜 그래프다. 벨만-포드와 플로이드-워셜도 표의 축을 하나씩 늘리는 것이 전부라, 이 장을 지나온 사람에게는 새 알고리즘이라기보다 같은 표를 그래프의 말로 옮겨 적은 것으로 읽힌다.
그러고도 남는 것이 있다. 표를 세워도 칸이 2𝑛 개가 되는 문제들이다. 해밀턴 경로가 그랬고, 배낭도 자릿수로 재면 지수였다. 그것이 우리가 아직 못 푼 것인가, 아니면 원래 풀 수 없는 것인가. 12장을 지나 13장이 그 질문을 정면으로 다룬다.
최단경로와 최소신장트리
9장에서 간선은 "이어져 있다"는 사실만 나타냈다. 이 장에서는 간선에 값이 붙는다. 거리, 시간, 요금, 대역폭 — 무엇이든 좋다. 그러면 "갈 수 있는가"가 아니라 "얼마를 들여야 갈 수 있는가"가 질문이 된다.
답할 질문은 넷이다. 가장 짧은 길을 어떻게 찾는가, 왜 알고리즘이 여럿인가, 음수 가중치가 왜 문제인가, 그리고 모든 정점을 잇는 가장 싼 방법은 무엇인가. 둘째 질문의 답은 이 장 바깥에 있다. 이 장은 앞 두 장에서 세운 기법이 실제로 무엇을 푸는지 보는 자리이고, 알고리즘이 여럿인 것은 틀이 둘이기 때문이다.
다익스트라 · 크루스칼 · 프림은 10장의 탐욕이다. 매 걸음 지금 가장 싼 것 하나를 골라 확정하고 되돌아보지 않는다. 벨만-포드 · 플로이드-워셜 · DAG 최단경로는 11장의 동적 계획법이다. 부분문제의 답을 표에 적어 두고 그 표를 한 축씩 늘린다. 그래서 물을 것도 둘로 갈린다. 탐욕 쪽은 "왜 그 선택이 옳은가"가 문제이고(다익스트라가 음수 간선에서 깨지는 이유가 정확히 이것이다), 동적 계획법 쪽은 "표의 한 칸을 무엇으로 잡을 것인가"가 문제다. 이 장이 다루는 알고리즘 일곱 중 여섯이 그 두 칸에 나뉘어 들어가고, 남는 하나인 A* 는 탐욕에 어림값을 얹은 것이라 따로 본다. 일곱을 따로 외울 것이 아니라 두 틀의 변주로 읽으면 된다.
앞에서 가져오는 것은 9장의 그래프 용어와 표현·탐색·위상 정렬 전부, 10장의 탐욕 선택 성질과 바꿔치기 논법, 11장의 표 채우기와 DP 는 DAG, 6장의 힙과 우선순위 큐(이 장의 두 알고리즘이 그 위에서 돈다), 3장의 점근 표기다.
"가장 짧은"이 두 가지 뜻이다
9장의 너비 우선 탐색은 이미 최단경로를 구했다. 다만 간선 수로 잰 최단경로였다.
<간선 수로 재면 직행이 짧고 가중치 합으로 재면 세 번 갈아타는 쪽이 짧다. 가중치가 붙는 순간 걸음 수를 세는 것이 뜻을 잃는다>[원본 보기]
가중치가 붙으면 그 답이 무너진다. 간선 하나를 한 걸음으로 세는 것이 더는 옳지 않기 때문이다. 경로의 길이를 그 위 간선들의 가중치 합으로 다시 정의한다. 가중치가 전부 같으면 두 정의가 일치하므로, 너비 우선 탐색은 이 장의 특별한 경우다.
이 장의 알고리즘은 전부 같은 연산 하나를 되풀이한다. 정점마다 "지금까지 알아낸 최단 거리" 𝑑[𝑣] 를 들고 있다가, 더 짧은 길을 찾으면 갈아타는 것이다. 이것을 완화(relaxation)라 한다.
<식으로는 한 줄이다. u 까지의 거리에 간선 값을 더한 것이 v 의 현재 값보다 작으면 갈아탄다. 갈아탈 때 직전 정점을 함께 적어 두면 나중에 경로를 되짚을 수 있다>[원본 보기]
↓ python
def 완화(u, v, 무게):
if 거리[u] + 무게 < 거리[v]:
거리[v] = 거리[u] + 무게
직전[v] = u # 경로 복원용
시작은 언제나 같다. 출발점의 거리를 0, 나머지를 ∞ 로 둔다. ∞ 는 "아직 갈 방법을 못 찾았다"는 표시이고, 구현에서는 절대 넘칠 수 없는 큰 수를 쓴다.
그러면 알고리즘마다 다른 것은 완화를 어떤 순서로 몇 번 하느냐뿐이다. 그 선택이 복잡도와 쓸 수 있는 조건을 정한다.
하나 더 짚어 둔다. 최단경로의 부분도 최단경로다.𝑠 에서 𝑡 로 가는 최단경로 위에 𝑣 가 있다면, 그 경로의 𝑠→𝑣 토막은 𝑠 에서 𝑣 로 가는 최단경로다. 더 짧은 토막이 있었다면 그것으로 갈아타 전체가 더 짧아졌을 것이기 때문이다. 이 성질이 있어야 "직전 정점만 적어 두고 나중에 되짚는" 방식이 성립한다.
예제 120
정점이 𝑉 개인 그래프에서 최단경로가 지나는 간선은 많아야 몇 개인가? 음수 순환이 있으면 어떻게 되는가?
풀이 보기
최단경로는 같은 정점을 두 번 지나지 않는다. 두 번 지났다면 그 사이 구간이 순환이고, 순환의 무게가 음수가 아니라면 그것을 잘라 내도 길이가 늘지 않기 때문이다.
정점을 두 번 지나지 않으면 지나는 정점이 많아야 𝑉 개이고, 간선은 그보다 하나 적은 𝑉−1 개다.
이 사실이 뒤의 벨만-포드가 "𝑉−1 라운드면 충분하다"고 말하는 근거다.
음수 순환이 있으면 이야기가 달라진다. 그 순환을 한 번 더 돌 때마다 길이가 줄어드므로 최솟값이 존재하지 않는다. 이때 최단경로는 "아주 큰 값"이 아니라 정의되지 않는다. 알고리즘이 할 수 있는 일은 답을 내는 것이 아니라 "그런 순환이 있다"고 알려 주는 것뿐이다.
다만 음수 순환에서 갈 수 없는 정점의 최단경로는 여전히 잘 정의된다. 그래서 실무 구현은 "음수 순환의 영향을 받는 정점"만 따로 표시하기도 한다.
예제 121
알고리즘이 끝난 뒤 남는 것은 거리 배열과 직전 배열 둘뿐이다. 이것만으로 실제 경로를 어떻게 되짚는가? 그리고 직전 배열이 나타내는 것은 무엇인가?
풀이 보기
목적지 𝑡 에서 시작해 직전[𝑡], 직전[직전[𝑡]] 를 따라가다 출발점에 닿으면 멈추고, 얻은 나열을 뒤집는다. 경로의 간선 수만큼 걸리므로 𝑂(𝑉) 다.
직전 배열이 나타내는 것은 트리다. 출발점을 뺀 모든 정점이 직전 정점을 정확히 하나씩 갖고, 그 간선을 모으면 간선이 𝑉−1 개다. 9장에서 본 대로 연결 + 간선 𝑉−1 개는 트리이므로 최단경로 트리가 된다.
유용한 성질이 따라온다. 이 트리 하나에 출발점에서 모든 정점으로 가는 최단경로가 전부 들어 있다. 정점마다 경로를 따로 저장할 필요가 없다. 공간이 𝑂(𝑉2) 에서 𝑂(𝑉) 로 줄어든다.
같은 길이의 최단경로가 여럿이면 트리는 그중 하나만 담는다. 완화 조건을 < 로 둘지 ≤ 로 둘지에 따라 어느 것이 남는지가 달라지며, 어느 쪽이든 길이는 같다.
흔한 실수는 직전을 거리와 따로 갱신하는 것이다. 거리를 고칠 때 직전도 같은 자리에서 함께 고쳐야 한다. 그러지 않으면 거리는 맞는데 경로가 엉뚱하게 나오는, 찾기 어려운 버그가 된다.
다익스트라 알고리즘
발상은 9장의 너비 우선 탐색과 같다. 가까운 것부터 확정한다. 다만 "가깝다"의 기준이 걸음 수가 아니라 거리이므로, 그릇을 큐에서 우선순위 큐로 바꾼다. 6장에서 만든 힙이 정확히 그 자리에 들어간다.
절차를 말로 적는다. 아직 확정하지 않은 정점 중 거리가 가장 작은 것을 꺼내 확정한다. 그 정점에서 나가는 간선을 모두 완화한다. 값이 줄어든 정점을 우선순위 큐에 넣는다. 큐가 빌 때까지 되풀이한다. 첫 문장이 10장의 탐욕 그대로다. 지금 가장 가까운 것 하나를 고르고, 한 번 확정한 것은 다시 뒤집지 않는다. 그러니 물어야 할 것도 10장의 그 질문이다. 그 선택이 옳다는 보장이 어디서 오는가.
<초록 칸이 그 단계에서 확정된 정점이고 주황 칸이 완화로 값이 줄어든 정점이다. A 가 4 로 확정될 뻔했다가 C 를 거치는 3 으로 줄어든 것을 보라. 확정되기 전에는 값이 얼마든 줄어들 수 있다>[원본 보기]
↓ python
def 다익스트라(그래프, 시작):
거리 = {v: 무한 for v in 그래프}; 거리[시작] = 0
직전 = {v: None for v in 그래프}
큐 = [(0, 시작)] # 최소 힙
while 큐:
d, u = 큐.꺼내기()
if d > 거리[u]: continue # 낡은 항목은 건너뛴다
for v, 무게 in 그래프[u]:
if 거리[u] + 무게 < 거리[v]:
거리[v] = 거리[u] + 무게
직전[v] = u # 경로 복원용
큐.넣기((거리[v], v)) # 줄어든 값으로 새 항목
return 거리, 직전
간선마다 많아야 한 번씩 큐에 넣고 그때마다 𝑂(log𝑉) 가 들므로 𝑂((𝑉+𝐸)log𝑉) 다. 희소 그래프에서는 사실상 𝑂(𝐸log𝑉) 로 본다.
코드에서 눈여겨볼 곳은 낡은 항목을 건너뛰는 줄이다. 값이 줄어들 때마다 새 항목을 넣기 때문에 같은 정점이 큐에 여러 번 들어간다. 힙에서 임의의 원소를 찾아 값을 낮추는 연산은 비싸므로, 그냥 새로 넣고 꺼낼 때 낡은 것을 버리는 편이 간단하고 빠르다. 큐 크기가 𝐸 까지 커지지만 복잡도는 그대로다.
왜 음수 가중치에서 깨지는가
다익스트라가 옳은 이유는 하나다. 10장의 말로는 탐욕 선택 성질이고, 풀어 쓰면 "지금 꺼낸 것보다 짧은 길은 없다"는 것이다. 꺼낸 정점 𝑢 보다 짧은 길이 있으려면 그 길은 아직 확정 안 된 어떤 정점을 지나야 하는데, 그 정점의 거리는 이미 𝑑[𝑢] 이상이고, 거기서 더 가면 더 늘어나기만 한다.
마지막 한 마디가 가중치가 음이 아닐 때만 참이다.
<왼쪽에서 A 를 2 로 확정한 뒤에야 B 를 거치는 길이 1 임이 드러난다. 이미 확정한 것을 고칠 수 없으므로 답이 틀린 채 끝난다. 오른쪽처럼 음수 순환이 있으면 답 자체가 없다>[원본 보기]
그림의 왼쪽이 반례다. 𝑆→𝐴 가 2, 𝑆→𝐵 가 3, 𝐵→𝐴 가 −2 다.
𝑆 를 꺼내 확정한다. 완화로 𝑑[𝐴]=2, 𝑑[𝐵]=3.
가장 작은 𝐴(2)를 꺼내 확정한다.
𝐵(3)를 꺼내 완화하면 3+(−2)=1 로 𝐴 가 더 짧아진다. 그러나 𝐴 는 이미 확정되었다.
진짜 답은 1 인데 알고리즘은 2 를 내놓는다. 고칠 방법이 없다. 확정된 것을 다시 열면 그것은 더 이상 다익스트라가 아니다.
흔한 오해 하나를 짚는다. 모든 가중치에 같은 수를 더해 음수를 없애는 요령은 통하지 않는다. 간선 수가 다른 두 경로에 더해지는 양이 다르기 때문이다. 위 그래프에 2씩 더하면 𝑆→𝐴 가 4, 𝑆→𝐵→𝐴 가 5+0=5 가 되어 대소가 뒤바뀐다.
예제 122
그림의 그래프에서 다익스트라를 손으로 돌려 𝑆 에서 𝐸 까지의 최단경로와 그 길이를 구하라. 간선은 𝑆→𝐴 4, 𝑆→𝐶 2, 𝐶→𝐴 1, 𝐴→𝐵 5, 𝐶→𝐷 8, 𝐵→𝐷 2, 𝐵→𝐸 6, 𝐷→𝐸 3 이다.
풀이 보기
표를 따라간다. 처음에 𝑑[𝑆]=0 이고 나머지는 ∞ 다.
𝑆 확정(0). 완화로 𝑑[𝐴]=4, 𝑑[𝐶]=2.
𝐶 확정(2).𝐶→𝐴 로 2+1=3<4 이므로 𝑑[𝐴] 가 3 으로 줄어든다.𝐶→𝐷 로 𝑑[𝐷]=10.
𝐴 확정(3).𝐴→𝐵 로 𝑑[𝐵]=8.
𝐵 확정(8).𝐵→𝐷 는 8+2=10 로 지금 값과 같아 바뀌지 않는다.𝐵→𝐸 로 𝑑[𝐸]=14.
𝐷 확정(10).𝐷→𝐸 로 10+3=13<14 이므로 𝑑[𝐸] 가 13 으로 줄어든다.
𝐸 확정(13). 답은 13 이다.
경로는 직전 정점을 거꾸로 따라간다. 𝐸 의 직전은 𝐷, 𝐷 의 직전은 𝐶(10 을 만든 것이 𝐶→𝐷 다), 𝐶 의 직전은 𝑆. 곧 𝑆→𝐶→𝐷→𝐸 이고 길이는 2+8+3=13 이다.
검산하자. 다른 후보 𝑆→𝐶→𝐴→𝐵→𝐸 는 2+1+5+6=14, 𝑆→𝐶→𝐴→𝐵→𝐷→𝐸 는 2+1+5+2+3=13 이다. 같은 길이의 최단경로가 둘 있었고, 알고리즘은 그중 하나를 준 것이다.
설계 판단
지도 앱에서 매번 다익스트라를 돌린다. 도시 하나의 도로망이 정점 100만, 간선 300만이다. 응답이 느리다. 무엇을 먼저 고치겠는가?
풀이 보기
먼저 비용을 계산한다. 𝐸log2𝑉≈3×106×20=6×107 이니 한 번에 수십 밀리초 수준이다. 문제는 이것을 초당 수천 번 한다는 것이다.
가장 싼 개선은 목적지에 도착하면 멈추는 것이다. 다익스트라는 모든 정점까지의 거리를 구하지만 우리가 원하는 것은 하나뿐이다. 목적지를 확정하는 순간 멈추면 평균적으로 절반 이하만 훑는다.
다음은 9장의 양방향 탐색이다. 출발점과 도착점에서 동시에 다익스트라를 돌려 가운데서 만나게 한다. 다만 처음 만난 곳이 최단이라는 보장이 없어 멈추는 조건을 정확히 세워야 한다.
그다음이 뒤에 볼 A* 다. 직선 거리라는 어림값으로 탐색을 목적지 쪽으로 기울인다.
그런데 진짜 답은 다른 곳에 있다. 도로망은 거의 바뀌지 않는다. 그러면 미리 계산해 둘 수 있다. 실제 지도 서비스는 도로에 등급을 매겨 요약 그래프를 미리 만들어 두고(고속도로만으로 이루어진 작은 그래프 등), 질의 때는 그 위에서 짧은 탐색만 한다.
알고리즘을 바꾸기 전에 무엇이 고정이고 무엇이 변하는지를 먼저 본다. 고정된 부분은 미리 계산할 수 있고, 그것이 상수배가 아니라 자릿수를 바꾼다.
설계 판단
정점 2000개짜리 완전 그래프에서 다익스트라를 돌린다. 우선순위 큐를 쓰는 것과, 큐 없이 매번 배열을 훑어 최솟값을 찾는 것 중 어느 쪽이 빠른가?
풀이 보기
두 구현의 비용을 각각 세운다. 완전 그래프이므로 𝐸≈𝑉2/2=2×106 이다.
힙을 쓰면𝑂(𝐸log𝑉)=2×106×11≈2.2×107 이다.
배열을 훑으면 최솟값 찾기가 𝑂(𝑉) 이고 그것을 𝑉 번 하므로 𝑂(𝑉2)=4×106, 여기에 완화가 간선마다 한 번씩 𝑂(𝐸)=2×106 이다. 합쳐서 6×106 이다.
배열 쪽이 세 배 이상 적다. 게다가 배열을 순서대로 훑는 것은 4장의 캐시 지역성이 좋은 반면, 힙은 포인터를 따라 뛰어다닌다. 실측 차이는 계산보다 더 벌어진다.
일반적인 기준은 이렇다. 𝐸 가 𝑉2/log𝑉 보다 크면 배열 구현이 유리하다. 밀집 그래프면 힙을 쓰지 않는 편이 낫다.
여기서 얻을 것은 자료구조가 언제나 이득은 아니라는 것이다. 힙은 "최솟값 찾기"를 𝑂(𝑉) 에서 𝑂(log𝑉) 로 줄이지만, 대가로 "값 낮추기"가 𝑂(1) 에서 𝑂(log𝑉) 로 늘어난다. 어느 연산이 더 자주 일어나는지를 세어야 답이 나온다.
벨만-포드 알고리즘
음수 간선이 있으면 "가까운 것부터 확정"이라는 전략 자체를 버려야 한다. 벨만-포드는 확정이라는 개념 없이 모든 간선을 여러 번 완화한다.
절차는 두 줄이다. 모든 간선을 한 번씩 완화하기를 𝑉−1 번 되풀이한다. 그러고도 완화되는 간선이 있으면 음수 순환이 있는 것이다.
<간선을 훑는 순서가 나쁘면 한 라운드에 정점 하나씩만 정해진다. 그래서 V−1 라운드가 필요하다. 오른쪽처럼 V 번째 라운드에도 값이 줄어들면 음수 순환이 있다는 뜻이다>[원본 보기]
↓ python
def 벨만포드(정점들, 간선들, 시작):
거리 = {v: 무한 for v in 정점들}; 거리[시작] = 0
for _ in range(len(정점들) - 1):
for u, v, 무게 in 간선들:
완화(u, v, 무게)
for u, v, 무게 in 간선들: # 한 번 더
if 거리[u] + 무게 < 거리[v]: return None # 음수 순환
return 거리
라운드가 𝑉−1 번이고 라운드마다 간선 𝐸 개를 보므로 𝑂(𝑉𝐸) 다. 다익스트라보다 훨씬 느리다. 그 값을 치르고 얻는 것이 음수 간선을 다룰 수 있다는 것과 음수 순환을 탐지할 수 있다는 것이다.
왜 𝑉−1 번인가. 앞 예제에서 본 대로 최단경로는 간선을 많아야 𝑉−1 개 지난다. 그리고 𝑘 번째 라운드가 끝나면 간선 𝑘 개 이하로 갈 수 있는 모든 최단 거리가 확정된다. 간선을 어떤 순서로 보든 그렇다. 순서가 운 좋으면 훨씬 일찍 끝나므로, 실무 구현은 한 라운드 동안 아무 변화도 없으면 곧바로 멈춘다. 이 문단을 11장의 말로 옮기면 벨만-포드가 무엇인지가 분명해진다. 𝑘 번째 라운드가 끝난 뒤의 거리 배열은 "간선을 𝑘 개 이하로 써서 갈 수 있는 최단 거리"라는 표의 한 줄이고, 라운드 하나가 그 줄에서 다음 줄로 가는 전이다. 벨만-포드는 표를 채우는 알고리즘이다.
마지막 검사가 음수 순환 탐지인 이유도 같다. 𝑉−1 라운드로 끝나야 정상인데 한 번 더 해서 줄어든다면, 간선을 𝑉 개 이상 지나는 "더 짧은" 길이 있다는 뜻이고 그것은 순환을 돌았다는 뜻이다.
이 그래프가 간선 목록만으로 돌아간다는 점도 눈여겨볼 만하다. 이웃을 찾을 일이 없으므로 9장에서 본 간선 목록 표현으로 충분하다.
예제 125
정점이 𝑆,𝐴,𝐵,𝐶 이고 시작은 𝑆 다. 간선은 𝐵→𝐶 (−1), 𝐴→𝐵 (2), 𝑆→𝐴 (3) 이고, 간선 목록을 적힌 이 순서 그대로 훑는다. 라운드마다 거리가 어떻게 변하는가? 몇 라운드가 필요한가?
풀이 보기
거리를 (𝑑[𝑆],𝑑[𝐴],𝑑[𝐵],𝑑[𝐶]) 로 적는다. 초기값은 (0,∞,∞,∞) 다.
이 순서가 일부러 거꾸로 잡혀 있다는 점을 먼저 보아 두자. 값이 흘러가야 할 방향은 𝑆→𝐴→𝐵→𝐶 인데, 간선을 훑는 순서는 그 반대인 𝐶 쪽부터다.
1 라운드.𝐵→𝐶 는 𝑑[𝐵]=∞ 라 아무 일도 없다. 𝐴→𝐵 도 𝑑[𝐴]=∞ 라 마찬가지다. 마지막 𝑆→𝐴 에서만 0+3<∞ 이므로 𝑑[𝐴]=3 이 된다. 결과 (0,3,∞,∞).
2 라운드.𝐵→𝐶 는 여전히 𝑑[𝐵]=∞ 라 통과한다. 𝐴→𝐵 에서 3+2=5 이므로 𝑑[𝐵]=5. 𝑆→𝐴 는 더 줄일 것이 없다. 결과 (0,3,5,∞).
3 라운드. 이번에는 첫 간선 𝐵→𝐶 가 먹힌다. 5−1=4 이므로 𝑑[𝐶]=4. 나머지 둘은 변화가 없다. 결과 (0,3,5,4).
4 라운드(검사용). 아무 간선도 완화되지 않는다. 값이 줄지 않았으므로 음수 순환이 없다는 확인이기도 하다.
정점이 4개이므로 알고리즘이 도는 라운드는 𝑉−1=3 번인데, 이 순서에서는 그 3 라운드를 하나도 남김없이 다 썼다. 라운드마다 정점이 정확히 하나씩만 확정된 것이다.
순서를 바꾸면 어떻게 되는지 견주어 보면 요점이 분명해진다. 간선 순서를 𝑆→𝐴, 𝐴→𝐵, 𝐵→𝐶 로 바꾸면 1 라운드로 끝난다. 한 라운드 안에서 완화는 같은 배열을 제자리에서 고치므로, 𝑑[𝐴]=3 으로 고친 직후의 𝐴→𝐵 가 그 값을 곧바로 쓰고, 이어서 𝐵→𝐶 가 방금 정해진 𝑑[𝐵]=5 를 쓴다. 값이 한 라운드 만에 사슬 끝까지 흘러간다.
답이 말이 되는가. 실제 최단 거리는 𝑑[𝐴]=3, 𝑑[𝐵]=5, 𝑑[𝐶]=4 이고 경로는 각각 𝑆→𝐴, 𝑆→𝐴→𝐵, 𝑆→𝐴→𝐵→𝐶 다. 간선을 3개 지나는 경로가 있으니 최악의 순서에서 3 라운드가 걸린 것도 앞 절의 "𝑘 라운드가 끝나면 간선 𝑘 개 이하의 최단 거리가 확정된다"와 정확히 맞는다.
여기서 얻을 것은 𝑉−1 은 최악의 간선 순서를 대비한 값이라는 것이다. 같은 그래프가 순서 하나로 3 라운드도 되고 1 라운드도 된다. 그래서 "한 라운드 동안 변화가 없으면 멈춘다"는 조기 종료가 실무에서 큰 차이를 만든다.
예제 126
앞 예제와 같은 정점·간선에 𝐶→𝐴 (−2) 하나를 더 놓는다. 곧 정점은 𝑆,𝐴,𝐵,𝐶 이고 간선은 𝑆→𝐴 (3), 𝐴→𝐵 (2), 𝐵→𝐶 (−1), 𝐶→𝐴 (−2) 다. 이번에는 훑는 순서를 적힌 이 순서, 곧 값이 흐르는 방향과 같게 잡는다. 라운드마다 무슨 일이 일어나며, 알고리즘은 무엇을 보고 이상을 알아채는가? 그리고 이때 𝑑[𝑆] 는 믿어도 되는가?
풀이 보기
먼저 그래프를 보자. 𝐴→𝐵→𝐶→𝐴 가 순환이고 무게 합이 2+(−1)+(−2)=−1 이다. 음수 순환이다. 한 바퀴 돌 때마다 거리가 1씩 줄어드니 𝐴 · 𝐵 · 𝐶 의 최단 거리는 정의되지 않는다. 알고리즘이 할 수 있는 일은 답을 내는 것이 아니라 그 사실을 알려 주는 것이다.
거리를 (𝑑[𝑆],𝑑[𝐴],𝑑[𝐵],𝑑[𝐶]) 로 적는다. 초기값은 (0,∞,∞,∞) 다. 이 간선 순서는 값이 흐르는 방향과 같으므로 한 라운드 안에서 사슬 끝까지 간다.
1 라운드.𝑆→𝐴 로 𝑑[𝐴]=3, 𝐴→𝐵 로 𝑑[𝐵]=5, 𝐵→𝐶 로 𝑑[𝐶]=4, 𝐶→𝐴 로 4−2=2<3 이니 𝑑[𝐴]=2. 결과 (0,2,5,4).
2 라운드. 같은 순서로 𝑑[𝐵]=4, 𝑑[𝐶]=3, 𝑑[𝐴]=1. 결과 (0,1,4,3).
3 라운드.(0,0,3,2). 세 값이 라운드마다 정확히 1씩 줄어든다. 순환을 한 바퀴 도는 값이 −1 이기 때문이다.
여기서 𝑉−1=3 라운드가 끝난다. 이제 검사 라운드다. 𝐴→𝐵 를 보면 0+2=2<3 이라 아직도 줄어든다. 완화되는 간선이 하나라도 남아 있으면 음수 순환이 있는 것이고, 코드는 여기서 𝙽𝚘𝚗𝚎 을 돌려준다.
왜 이 검사가 옳은가. 음수 순환이 없다면 모든 최단경로가 간선을 많아야 𝑉−1 개 지나므로 𝑉−1 라운드로 값이 굳는다. 굳지 않았다는 것은 간선을 𝑉 개 이상 쓰는 더 짧은 걸음이 있다는 뜻이고, 정점이 𝑉 개뿐이니 그 걸음은 같은 정점을 두 번 지난다. 곧 순환을 돌았고, 돌아서 짧아졌으니 그 순환은 음수다.
마지막 물음. 𝑑[𝑆]=0 은 믿어도 된다. 음수 순환이 답을 망치는 것은 그 순환에서 갈 수 있는 정점뿐인데, 𝑆 로 들어오는 간선이 하나도 없어 순환의 영향을 받지 않는다. 그래서 실무 구현은 무턱대고 𝙽𝚘𝚗𝚎 을 돌려주는 대신, 검사 라운드에서 완화된 간선의 끝점에서 다시 한 번 탐색을 돌려 오염된 정점만 "정의되지 않음"으로 표시하고 나머지 거리는 그대로 쓰기도 한다.
흔한 실수는 검사 라운드를 𝑉−1 번째 라운드로 대신하는 것이다. 마지막 라운드에서 값이 줄었다고 음수 순환이라 판정하면 안 된다. 앞 예제처럼 순환이 없어도𝑉−1 번째 라운드에서 값이 줄어드는 그래프가 있기 때문이다. 반드시 𝑉−1 라운드를 다 돈 뒤에 한 번 더 봐야 한다.
DAG 에서는 더 쉽다
그래프가 DAG(9장) 라면 훨씬 싸게 끝난다. 순환이 없으므로 정점을 한 번씩만, 올바른 순서로 보면 되기 때문이다. 그 순서가 바로 9장의 위상 정렬(Topological sort) 다.
절차는 두 줄이다. 위상 정렬한다. 그 순서대로 정점을 방문하며 나가는 간선을 완화한다.
왜 이것으로 충분한가. 어떤 정점 𝑣 를 처리할 때, 𝑣 로 들어오는 모든 간선의 출발점은 위상 순서에서 𝑣앞에 있으므로 이미 처리가 끝났다. 곧 𝑑[𝑣] 는 그 시점에 이미 최종값이다.
위상 정렬이 𝑂(𝑉+𝐸) 이고 완화도 간선마다 한 번이므로 전체가 𝑂(𝑉+𝐸) 다. 우선순위 큐도 필요 없고 음수 가중치도 괜찮다. 순환이 없으니 음수 순환도 있을 수 없다. 그리고 이것이 11장의 DP 는 DAG에서 본 그 이야기다. 거기서는 부분문제를 노드로 삼은 가상의 DAG 를 위상 순서로 훑었는데 여기서는 그 DAG 가 진짜 그래프다. 동적 계획법과 DAG 최단경로는 같은 절차의 두 얼굴이다.
부호만 뒤집으면 최장 경로도 같은 비용으로 구해진다. 일반 그래프에서 최장 경로는 아주 어려운 문제(13장에서 다룬다)인데 DAG 에서는 쉽다. 공정 일정 계획에서 "가장 오래 걸리는 경로"를 구하는 일이 이것이다.
예제 127
작업 다섯 개가 있고 걸리는 시간이 A 3일, B 2일, C 4일, D 1일, E 2일이다. B 는 A 뒤에, C 는 A 뒤에, D 는 B 뒤에, E 는 C 와 D 뒤에 해야 한다. 전부 끝나는 데 최소 며칠이 걸리는가?
풀이 보기
묻는 것은 DAG 의 최장 경로다. 여러 작업을 동시에 할 수 있다면 전체 기간은 가장 오래 걸리는 사슬이 정한다.
작업을 정점으로, 선후관계를 간선으로 두되 간선의 무게를 앞 작업의 소요 시간으로 잡는다. 그러면 경로의 무게 합이 그 사슬을 마치는 데 걸리는 시간이 된다.
위상 순서는 𝐴,𝐵,𝐶,𝐷,𝐸 다(B 와 C 의 앞뒤는 상관없다). 이 순서대로 "각 작업을 시작할 수 있는 가장 이른 시각"을 구한다.
𝐴 는 0 에 시작한다. 𝐵 와 𝐶 는 𝐴 가 끝나는 3 에 시작한다. 𝐷 는 𝐵 가 끝나는 3+2=5 에 시작한다.
𝐸 는 𝐶 와 𝐷 를 둘 다 기다려야 하므로 더 늦은 쪽이다. 𝐶 는 3+4=7 에 끝나고 𝐷 는 5+1=6 에 끝나므로 𝐸 는 7 에 시작해 7+2=9 에 끝난다.
답은 9일이고 결정적인 사슬은 𝐴→𝐶→𝐸 다. 이 사슬을 임계 경로라 하며, 여기를 하루 줄이면 전체가 하루 줄고 다른 곳을 줄여도 전체는 그대로다.
최단경로가 아니라 최장 경로를 구했다는 점에 주의하라. 완화의 부등호만 뒤집으면 되고, 순환이 없으므로 "무한히 길어지는" 문제도 없다.
예제 128
정점 𝑆,𝐴,𝐵,𝑇 에 간선이 𝑆→𝐴 (1), 𝑆→𝐵 (5), 𝐴→𝐵 (1), 𝐴→𝑇 (10), 𝐵→𝑇 (1) 인 DAG 가 있다. 이 절의 절차대로 하면 𝑑[𝑇] 가 얼마인가? 그리고 위상 순서를 지키지 않고 𝑆,𝐵,𝐴,𝑇 순서로 정점을 훑으면 어떻게 되는가?
풀이 보기
먼저 올바른 순서다. 위상 순서는 𝑆,𝐴,𝐵,𝑇 다. 𝐴 가 𝐵 앞에 와야 하는 것은 간선 𝐴→𝐵 때문이다.
𝑑=(0,∞,∞,∞) 로 시작한다.
𝑆 를 처리하면 𝑑[𝐴]=1, 𝑑[𝐵]=5. 𝐴 를 처리하면 𝐴→𝐵 로 1+1=2<5 이니 𝑑[𝐵]=2, 𝐴→𝑇 로 𝑑[𝑇]=11. 𝐵 를 처리하면 𝐵→𝑇 로 2+1=3<11 이니 𝑑[𝑇]=3. 𝑇 는 나가는 간선이 없다. 답은 𝑆→𝐴→𝐵→𝑇 의 3 이다.
이제 순서를 어겼을 때다. 𝑆,𝐵,𝐴,𝑇 로 훑는다.
𝑆 를 처리해 𝑑[𝐴]=1, 𝑑[𝐵]=5. 다음이 𝐵 인데, 이 시점의 𝑑[𝐵] 는 아직 5 다. 𝐵→𝑇 를 완화하면 𝑑[𝑇]=6. 그다음 𝐴 를 처리하면 𝐴→𝐵 로 𝑑[𝐵] 가 2 로 줄고 𝐴→𝑇 는 11 이라 소용이 없다. 마지막 𝑇 는 나가는 간선이 없다.
끝난 뒤 𝑑[𝑇]=6 이다. 참값 3 이 아니다.𝑑[𝐵] 는 2 로 제대로 고쳐졌지만 그 고침이 𝑇 까지 전달되지 못했다.𝐵 의 나가는 간선을 볼 기회가 이미 지나갔기 때문이다.
이것이 이 절차가 요구하는 조건을 정확히 보여 준다. 정점을 처리하는 순간 그 정점의 값이 이미 최종값이어야 한다. 위상 순서는 "들어오는 간선의 출발점이 모두 앞에 있다"를 보장하므로 그 조건을 공짜로 준다. 순서를 어기면 값이 한 번밖에 흐르지 않는 이 알고리즘에는 고칠 기회가 없다.
견주어 둘 것이 있다. 다익스트라에는 이 문제가 없다. 순서를 미리 정하지 않고 매번 "남은 것 중 가장 작은 값"을 꺼내는데, 가중치가 음이 아니면 그렇게 꺼낸 값이 이미 최종값임이 보장되기 때문이다. 곧 다익스트라는 올바른 순서를 그때그때 알아내는 알고리즘이고, DAG 최단경로는 순서를 미리 위상 정렬로 받아 두는 알고리즘이다. 후자가 더 싼 대신 순환이 없어야 하고, 전자는 순환은 괜찮은 대신 음수 가중치를 못 견딘다.
흔한 실수 하나를 덧붙인다. 위상 순서를 간선 하나만 뒤집힌 채 써도 위 같은 일이 생긴다. 위상 정렬의 결과를 그대로 쓰지 않고 "대충 번호 순"으로 훑는 구현이 이 버그의 단골이다.
모든 쌍의 최단경로 — 플로이드-워셜
지금까지는 출발점 하나에서의 최단경로였다. 모든 쌍이 필요하면 다익스트라를 정점마다 돌려 𝑂(𝑉𝐸log𝑉) 로 할 수도 있지만, 밀집 그래프에서는 더 간단하고 빠른 방법이 있다.
플로이드-워셜은 발상이 다르다. 경로를 늘려 가는 대신 "거쳐 가도 되는 정점"을 하나씩 늘린다.
𝐷𝑘[𝑖][𝑗] 를 "1 부터 𝑘 까지의 정점만 중간에 거쳐 𝑖 에서 𝑗 로 가는 최단 거리"라 하자. 이것이 11장에서 말한 상태를 한 문장으로 적는 일이고, 이 알고리즘의 어려운 부분은 사실 그 한 문장이 전부다. 그러면 𝑘 를 새로 허용했을 때 답은 둘 중 하나다. 𝑘 를 안 거치거나, 거치거나. 표를 세우고 마지막 결정으로 경우를 나누는 동적 계획법의 정석이다.
𝐷𝑘[𝑖][𝑗]=min(𝐷𝑘−1[𝑖][𝑗],𝐷𝑘−1[𝑖][𝑘]+𝐷𝑘−1[𝑘][𝑗])
↓ python
def 플로이드워셜(거리): # 거리는 인접 행렬. 간선 없으면 무한
for k in 정점들:
for i in 정점들:
for j in 정점들:
거리[i][j] = min(거리[i][j], 거리[i][k] + 거리[k][j])
삼중 반복이므로 Θ(𝑉3) 이고 공간은 Θ(𝑉2) 다. 코드가 네 줄뿐이라 상수가 아주 작고, 9장의 인접 행렬을 그대로 쓴다.
𝑘 반복이 가장 바깥에 있어야 한다. 순서를 바꾸면 "아직 허용하지 않은 정점을 거친 값"을 쓰게 되어 틀린다. 이 코드에서 가장 흔한 실수다.
음수 간선이 있어도 된다. 다 돌린 뒤 거리[𝑖][𝑖]<0 인 정점이 있으면 음수 순환이 있는 것이다.
언제 쓰는가. 정점이 수백 개 이하로 작고 모든 쌍이 필요할 때다. 𝑉=500 이면 1.25×108 번이라 1초 안팎이고, 𝑉=5000 이면 1.25×1011 번이라 사실상 불가능하다.
설계 판단
정점 300개, 간선 4만 개인 그래프에서 모든 쌍의 최단 거리가 필요하다. 다익스트라를 300번 돌리는 것과 플로이드-워셜 중 어느 쪽이 낫겠는가?
풀이 보기
두 비용을 계산한다.
다익스트라 300번은 𝑉⋅𝐸log2𝑉=300×4×104×8.2≈108 이다.
플로이드-워셜은 𝑉3=2.7×107 이다. 연산 횟수만 보면 플로이드-워셜이 네 배 적다.
게다가 상수도 유리하다. 플로이드-워셜의 안쪽은 덧셈과 비교 하나씩인 데다 인접 행렬을 순서대로 훑으므로 4장의 캐시 지역성이 아주 좋다. 다익스트라는 힙 연산과 포인터 추적이 섞여 있어 상수가 크다.
이 그래프는 밀집이라는 점을 확인하자. 최대 간선 수가 300×299/2≈4.5×104 인데 실제가 4×104 이니 거의 완전 그래프다. 9장에서 본 대로 밀집이면 인접 행렬이 맞고, 그러면 플로이드-워셜이 자연스럽다.
답이 뒤집히는 조건도 알아 두자. 희소하고 정점이 많으면 다익스트라 반복이 이긴다. 𝑉=104, 𝐸=5×104 이면 플로이드-워셜은 1012 이고 다익스트라 반복은 7×109 이다. 게다가 𝑉2 짜리 행렬 자체가 1억 칸이라 메모리부터 문제가 된다.
예제 130
𝑘 반복을 가장 안쪽으로 옮긴 코드, 곧 𝑖 · 𝑗 · 𝑘 순으로 세 겹을 도는 코드가 왜 틀리는지 정점 4개짜리 예로 보여라. 간선은 1→4 (1), 4→3 (1), 3→2 (1) 뿐이고 구하려는 것은 𝑑[1][2] 다.
풀이 보기
참값부터 잡는다. 1→4→3→2 밖에 길이 없으므로 𝑑[1][2]=3 이다.
올바른 코드가 어떻게 그것을 얻는지 보자. 𝑘 가 바깥이므로 "거쳐도 되는 정점"이 하나씩 늘어난다.
𝑘=1 · 𝑘=2 — 1 로 들어오는 간선도 2 에서 나가는 간선도 없어 아무 변화가 없다.
𝑘=3 — 𝑑[4][2]=𝑑[4][3]+𝑑[3][2]=1+1=2 가 새로 채워진다. 𝑑[1][2] 는 𝑑[1][3] 이 아직 ∞ 라 그대로다.
𝑘=4 — 이제 𝑑[1][2]=𝑑[1][4]+𝑑[4][2]=1+2=3. 방금 𝑘=3 에서 만든 𝑑[4][2] 를 쓴 것이 요점이다.
순서를 바꾼 코드는 𝑖 를 한 번 정하면 그 행을 다 채우고 다시 돌아오지 않는다. 𝑖=1 부터 돈다.
𝑗=2 — 어떤 𝑘 를 넣어도 𝑑[1][𝑘]+𝑑[𝑘][2] 가 ∞ 다. 𝑘=3 이면 𝑑[1][3]=∞ 이고, 𝑘=4 이면 𝑑[4][2] 가 아직 ∞ 이기 때문이다. 𝑑[1][2] 는 ∞ 인 채로 넘어간다.
𝑗=3 — 𝑑[1][3]=𝑑[1][4]+𝑑[4][3]=2 가 채워진다. 그러나 𝑗=2 는 이미 지나갔다.
그 뒤 𝑖=4 차례에 𝑑[4][2]=2 가 채워지지만, 𝑖=1 행은 다시 보지 않는다. 세 겹이 다 끝났을 때 𝑑[1][2] 는 여전히 ∞ 다.
무엇을 놓쳤는지가 분명하다. 두 걸음짜리는 다 맞혔는데 세 걸음짜리를 놓쳤다.𝑑[1][3] 도 2, 𝑑[4][2] 도 2 로 제대로 나왔지만, 그 둘을 이어 붙일 기회가 없었다.
왜 그런가는 상태의 정의로 돌아가면 곧바로 보인다. 𝐷𝑘 는 "𝑘 까지의 정점만 거치는 답"이고, 이 정의가 성립하려면 𝑘 를 하나 늘릴 때 표 전체가 이미 𝑘−1 까지의 답으로 채워져 있어야 한다. 𝑘 를 안쪽에 두면 표의 일부만 갱신된 상태에서 다음 칸을 계산하게 되어 그 전제가 깨진다. 11장의 말로 하면 부분문제를 위상 순서로 풀지 않은 것이다.
검산 삼아 한마디 덧붙인다. 틀린 코드도 세 겹 전체를 여러 번 되풀이하면 결국 참값에 이른다. 한 번 돌 때마다 이어 붙일 수 있는 걸음 수가 늘기 때문이고, 안전하게 𝑉 번 돌리면 확실하다. 다만 그것은 Θ(𝑉4) 이라 벨만-포드를 정점마다 돌리는 것과 다를 바가 없다. 𝑘 를 바깥에 두는 한 줄이 그 되풀이를 통째로 없앤다.
A* — 어디로 가는지 알 때
다익스트라는 목적지가 어느 쪽인지 전혀 모른 채 사방으로 고르게 퍼진다. 서울에서 부산으로 가는 길을 찾는데 인천 쪽도 똑같이 살펴보는 셈이다. 목적지를 안다면 그 정보를 쓰지 않을 이유가 없다.
A* 는 정점마다 "여기서 목적지까지 남은 거리"의 어림값ℎ(𝑣) 를 하나 더 두고, 우선순위 큐의 기준을 바꾼다.
𝑓(𝑣)=𝑔(𝑣)+ℎ(𝑣)
여기서 𝑔(𝑣) 는 출발점에서 여기까지 실제로 온 거리(다익스트라의 𝑑 와 같다)이고 ℎ(𝑣) 는 남은 거리의 어림값이다. ℎ 를 0 으로 두면 정확히 다익스트라가 된다.
<왼쪽은 출발점을 중심으로 고르게 퍼지고 오른쪽은 목적지 쪽으로 기울어진 좁은 영역만 본다. 둘 다 같은 최단경로를 찾는다. 다른 것은 그러기 위해 들여다본 칸 수다>[원본 보기]
어림값을 아무렇게나 정하면 답이 틀린다. 조건이 있다.
허용성(admissible) — ℎ(𝑣) 가 실제 남은 거리를 넘지 않는다. 넘으면 진짜 최단경로를 "비싸 보인다"고 판단해 버릴 수 있다.
일관성(consistent) — 모든 간선 𝑢→𝑣 에 대해 ℎ(𝑢)≤𝑤(𝑢,𝑣)+ℎ(𝑣). 이것이 성립하면 한 번 확정한 정점을 다시 볼 필요가 없어 다익스트라와 똑같은 뼈대로 돌릴 수 있다.
격자 지도에서는 벽을 무시한 직선 거리가 자연스러운 어림값이다. 벽이 있으면 실제로는 돌아가야 하므로 어림값이 실제보다 클 수 없고, 허용성이 자동으로 성립한다.
맞바꿈이 분명하다. 어림값이 정확할수록 좁게 훑지만, 실제보다 크게 잡으면 답이 틀린다. 어림값을 0 으로 두면 안전하지만 아무 이득이 없다. 실무에서는 조금 부정확해도 훨씬 빠른 어림값을 골라 "최적은 아니지만 충분히 좋은" 답을 받아들이기도 한다.
예제 131
격자 지도에서 어림값으로 "실제 남은 거리의 두 배"를 썼다. 무슨 일이 벌어지는가?
풀이 보기
허용성이 깨진다. ℎ(𝑣) 가 실제 남은 거리보다 크기 때문이다.
구체적으로 어떻게 틀리는지 보자. 목적지까지 돌아가지만 짧은 길과 곧게 가지만 긴 길이 있다고 하자. 돌아가는 길 위의 정점은 목적지에서 멀어 보이므로 ℎ 가 크게 매겨지고, 𝑓=𝑔+ℎ 가 부풀려져 큐에서 뒤로 밀린다.
그러면 알고리즘은 긴 길을 먼저 끝까지 따라가 목적지에 도착하고 거기서 멈춘다. 짧은 길은 검토조차 되지 않는다.
답이 항상 틀리는 것은 아니다. 어림값이 정확한 상황에서는 맞는 답이 나온다. 그것이 이 오류의 고약한 점이다. 대부분의 입력에서 잘 돌다가 특정 지형에서만 최적이 아닌 답을 낸다.
이 성질을 일부러 쓰기도 한다. 어림값에 1보다 조금 큰 수를 곱하면 탐색이 훨씬 빨라지고, 답은 최적의 그 배수 안에 들어옴이 보장된다. 정확함을 조금 팔아 속도를 사는 맞바꿈이며, 실시간 경로 탐색에서 널리 쓰인다.
예제 132
3줄 5칸짜리 격자에서 칸을 (행,열) 로 적는다. 행은 0 · 1 · 2, 열은 0 부터 4 까지이고 벽은 없다. 상하좌우로 한 칸 옮기는 값이 1 이다. (0,0) 에서 (0,4) 로 가려 한다. (1) ℎ≡0 으로 A* 를 돌리면 무엇이 되는가? (2) ℎ 를 맨해튼 거리로 두면 큐에서 꺼내는 칸이 몇 개로 줄어드는가?
풀이 보기
(1) 다익스트라가 된다. 코드를 견주면 끝난다. A* 의 우선순위는 𝑓(𝑣)=𝑔(𝑣)+ℎ(𝑣) 인데 ℎ≡0 이면 𝑓(𝑣)=𝑔(𝑣) 이고, 𝑔 는 출발점에서 여기까지 실제로 온 거리이므로 다익스트라의 𝑑 와 같은 것이다. 큐의 기준값이 글자 그대로 같아지므로 두 알고리즘이 같은 절차가 된다.
조건도 확인해 둘 만하다. ℎ≡0 은 항상 허용성을 만족한다. 0 은 어떤 실제 남은 거리보다도 크지 않기 때문이다. 일관성도 ℎ(𝑢)=0≤𝑤(𝑢,𝑣)+0 이라 가중치가 음이 아닌 한 성립한다. 그래서 다익스트라는 A* 의 특별한 경우이고, A* 가 옳다는 증명 안에 다익스트라가 옳다는 증명이 들어 있다.
얻는 것이 없다는 것도 분명하다. ℎ 가 목적지에 대해 아무 말도 하지 않으므로 사방으로 고르게 퍼진다.
(2) 두 경우가 몇 칸을 꺼내는지 세어 본다. 벽이 없으므로 (𝑟,𝑐) 의 실제 거리는 𝑔(𝑟,𝑐)=𝑟+𝑐 다.
ℎ≡0 일 때. 큐는 𝑔 순으로 꺼내므로 목적지 (0,4)(𝑔=4)를 꺼내기 전에 𝑔<4 인 칸을 전부 꺼낸다. 0행에 (0,0)…(0,3) 넷, 1행에 (1,0),(1,1),(1,2) 셋, 2행에 (2,0),(2,1) 둘 — 아홉 칸이다. 𝑔=4 인 칸이 셋((0,4),(1,3),(2,2)) 있어 순서에 따라 두 칸이 더 나올 수도 있다.
맨해튼 거리를 쓸 때. 목적지가 (0,4) 이므로 ℎ(𝑟,𝑐)=𝑟+(4−𝑐) 다. 그러면
𝑓(𝑟,𝑐)=(𝑟+𝑐)+(𝑟+4−𝑐)=2𝑟+4
열이 통째로 사라지고 행만 남는다. 0행은 전부 𝑓=4, 1행은 6, 2행은 8 이다. 큐는 𝑓 가 작은 것부터 꺼내므로 1행과 2행에는 손도 대지 않는다. 목적지 (0,4) 를 꺼내기까지 앞서 꺼내는 것은 (0,0) 부터 (0,3) 까지 네 칸뿐이다.
아홉 대 넷이다. 격자를 넓힐수록 차이가 벌어지는 것이 아니라 비가 줄 수에 가까워진다. 3줄 𝑛 칸이면 ℎ≡0 은 3𝑛−6 칸을, 맨해튼은 𝑛−1 칸을 꺼내므로 𝑛 이 커질수록 비가 3 에 다가간다. 줄이 많아지면 그만큼 더 벌어진다.
답이 말이 되는가. 둘 다 길이 4 인 같은 최단경로를 찾는다. 여기서 맨해튼 거리는 벽이 없는 격자에서 실제 남은 거리와 정확히 같으므로 A* 가 낼 수 있는 최선의 성능이고, 곁길로 한 칸도 새지 않는다. 벽을 넣으면 어림값이 실제보다 작아지기 시작하고, 그만큼 꺼내는 칸이 늘어난다. 어림값이 실제에 가까울수록 좁게 훑는다는 말이 이 두 극단 사이의 이야기다.
최소신장트리와 자르기 성질
문제가 바뀐다. 이제는 "한 곳에서 다른 곳으로"가 아니라 "모든 곳을 잇되 가장 싸게"다. 전선 깔기, 배관, 통신망 구성이 이 문제다.
무방향 연결 그래프에서 모든 정점을 잇는 간선의 부분집합 중 무게 합이 가장 작은 것을 최소신장트리(minimum spanning tree, MST)라 한다. 이름에 트리가 들어간 이유는 9장에서 이미 봤다. 순환이 있으면 그 순환의 간선 하나를 빼도 여전히 이어져 있고 무게만 줄어드므로, 최소인 것에는 순환이 없다. 연결되어 있고 순환이 없으면 트리이고 간선은 정확히 𝑉−1 개다.
최소신장트리를 구하는 방법이 둘인데, 두 방법이 옳은 이유는 하나다.
<정점을 두 무리로 아무렇게나 나누고 그 자름을 건너는 간선들을 보면, 그중 가장 싼 것은 반드시 최소신장트리에 들어 있다. 오른쪽이 그 증명이다>[원본 보기]
자르기 성질(cut property). 정점 집합을 두 무리로 나눈 것을 자름(cut)이라 하고, 두 무리를 잇는 간선을 건너는 간선이라 하자. 그러면 어떤 자름에 대해서든, 그것을 건너는 간선 중 무게가 가장 작은 것은 어떤 최소신장트리에 반드시 들어 있다.
증명은 10장의 바꿔치기 논법을 그대로 쓴다. 최적해를 하나 가져와 탐욕이 고르는 것으로 바꿔치기해도 나빠지지 않음을 보이는 그 네 단계다. 가장 싼 건너는 간선 𝑒 를 포함하지 않는 최소신장트리 𝑇 가 있다고 하자.
𝑇 에 𝑒 를 넣으면 간선이 𝑉 개가 되어 순환이 정확히 하나 생긴다.
그 순환은 𝑒 로 자름을 건넜으므로 어딘가에서 되돌아와야 한다. 곧 자름을 건너는 다른 간선 𝑓 가 그 순환 위에 있다.
𝑒 가 건너는 간선 중 가장 싸므로 𝑤(𝑒)≤𝑤(𝑓) 다.
순환에서 𝑓 를 빼면 다시 트리이고, 무게는 𝑤(𝑓)−𝑤(𝑒)≥0 만큼 줄었다.
𝑤(𝑒)<𝑤(𝑓) 였다면 𝑇 가 최소였다는 가정에 모순이고, 같았다면 𝑒 를 쓴 최소신장트리도 존재한다. 어느 쪽이든 주장이 성립한다.
이 하나로 두 알고리즘이 동시에 정당화된다. 크루스칼은 간선을 먼저 고르고 그것이 건너는 자름을 나중에 찾고, 프림은 자름을 먼저 정해 놓고 그것을 건너는 간선을 고른다. 10장의 용어로 정리하면 이렇다. 자르기 성질은 최소신장트리 문제의 탐욕 선택 성질이다. 그것이 있으니 매 걸음 가장 싼 것 하나만 보고 확정해도 된다.
예제 133
어떤 그래프의 가장 가벼운 간선은 언제나 어떤 최소신장트리에 들어 있는가? 그리고 가중치가 모두 다르면 최소신장트리가 하나뿐임을 보여라.
풀이 보기
앞의 것. 가장 가벼운 간선을 𝑒=(𝑢,𝑣) 라 하자. 𝑢 만 든 무리와 나머지로 자름을 만들면 𝑒 는 그 자름을 건넌다. 그리고 그래프 전체에서 가장 가벼우니 그 자름을 건너는 것 중에서도 가장 가볍다. 자르기 성질에 따라 어떤 최소신장트리에 들어 있다.
크루스칼이 가장 싼 간선부터 집어도 되는 근거가 이것이다.
뒤의 것. 가중치가 모두 다른데 최소신장트리가 둘 있다고 하자. 𝑇1 과 𝑇2 라 하고, 한쪽에만 있는 간선 중 가장 가벼운 것을 𝑒 라 하자. 𝑒∈𝑇1 이라 두어도 일반성을 잃지 않는다.
𝑇2 에 𝑒 를 넣으면 순환이 하나 생긴다. 그 순환 위에는 𝑇1 에 없는 간선 𝑓 가 반드시 있다(다 있다면 𝑇1 안에 순환이 있게 된다).
𝑒 를 "한쪽에만 있는 간선 중 가장 가벼운 것"으로 잡았고 𝑓 도 한쪽에만 있으므로 𝑤(𝑒)<𝑤(𝑓) 다. 가중치가 모두 다르니 등호는 없다.
그러면 𝑇2 에서 𝑓 를 빼고 𝑒 를 넣은 것이 더 가벼운 신장트리다. 𝑇2 가 최소라는 가정에 모순이다.
뒤집어 말하면 같은 가중치가 있을 때만 최소신장트리가 여럿일 수 있다. 실무에서 답이 하나로 정해져야 한다면 가중치가 같을 때의 순서를 정해 두는 것으로 충분하다.
예제 134
앞의 다익스트라 절에서 모든 간선에 같은 수를 더해 음수를 없애는 요령은 통하지 않는다고 했다. 최소신장트리는 어떤가? 모든 간선 무게에 같은 수 𝑐 를 더하면 답이 바뀌는가? 무게가 전부 양수일 때 무게를 전부 제곱하면 어떤가? 두 물음을 최단경로와 견주어 답하라.
풀이 보기
묻는 것은 어떤 변환이 답을 보존하는가이고, 답을 가르는 것은 두 문제가 무엇을 견주는지의 차이다.
같은 수를 더하기 — 최소신장트리는 바뀌지 않는다. 신장트리는 어느 것이나 간선이 정확히 𝑉−1 개다. 그러니 무게마다 𝑐 를 더하면 어떤 신장트리든 합이 똑같이 (𝑉−1)𝑐 만큼 늘어난다. 모든 후보가 같은 값만큼 올라가면 대소 관계가 그대로이고, 가장 작았던 것이 여전히 가장 작다.
최단경로는 왜 다른가. 경로마다 간선 수가 다르기 때문이다. 간선 두 개짜리 경로는 2𝑐 만큼, 하나짜리는 𝑐 만큼 늘어난다. 더해지는 양이 달라지니 순위가 뒤집힐 수 있다. 본문의 반례가 그것이었다. "후보들이 원소를 같은 개수만큼 쓰는가"가 이 요령이 통하는지를 정한다.
제곱하기 — 이것도 최소신장트리는 바뀌지 않는다. 무게가 전부 양수면 𝑤↦𝑤2 는 순서를 그대로 보존하는 변환이다. 0<𝑤1<𝑤2 이면 𝑤21<𝑤22 다. 그리고 자르기 성질도 크루스칼도 프림도 간선 무게를 서로 견주기만 할 뿐 더하지 않는다. 크루스칼은 정렬한 순서대로 집고, 프림은 자름을 건너는 것 중 최소를 고른다. 견주는 순서가 그대로면 고르는 간선도 그대로다.
최단경로는 또 다르다. 경로의 값은 간선 무게의 합이라 개별 무게의 순서만으로 정해지지 않는다. 삼각형에서 𝐴𝐵=1, 𝐵𝐶=1, 𝐴𝐶=1.9 라 하자. 𝐴 에서 𝐶 로 가는 최단경로는 직행 1.9 다(돌아가면 2). 제곱하면 𝐴𝐵=1, 𝐵𝐶=1, 𝐴𝐶=3.61 이 되어 돌아가는 쪽이 2 로 이긴다. 답이 바뀌었다.
같은 삼각형의 최소신장트리를 확인하면 대비가 분명하다. 제곱 전에는 {𝐴𝐵,𝐵𝐶} 로 합이 2 이고, 제곱 뒤에도 무게 1 짜리 둘이 가장 싸므로 여전히 {𝐴𝐵,𝐵𝐶} 다. 바뀌지 않았다.
여기서 남길 것은 이것이다. 최소신장트리는 무게의 "순서"만 보는 문제이고 최단경로는 무게의 "값"을 더하는 문제다. 그래서 최소신장트리는 순서를 보존하는 어떤 변환에도 끄떡없고, 최단경로는 합을 보존하지 않는 변환이면 무엇이든 위험하다.
실무에서 쓸 데가 있다. 최소신장트리를 구할 때 무게가 실수라 비교가 불안하면 순위로 바꿔 정수로 다뤄도 답이 같다. 최단경로에서는 절대 그렇게 하면 안 된다.
크루스칼과 union-find
크루스칼의 절차는 한 줄이다. 간선을 무게 순으로 정렬해 하나씩 보되, 고리를 만들지 않으면 넣고 만들면 버린다. 간선 𝑉−1 개를 넣으면 끝난다. 10장에서 본 탐욕의 전형이다. 한 번 정렬하고 한 번 훑으며, 넣은 간선을 다시 빼지 않는다.
옳은 이유는 자르기 성질이다. 지금 보고 있는 간선이 이미 이어 놓은 덩어리와 나머지를 가르는 자름을 건너는 가장 싼 간선이기 때문이다. 자기보다 싼 간선은 이미 다 봤고, 그중 이 자름을 건너는 것은 없었다.
문제는 "고리를 만드는가"를 어떻게 빨리 판정하는가다. 간선 (𝑢,𝑣) 가 고리를 만든다는 것은 𝑢 와 𝑣 가 이미 같은 덩어리에 있다는 뜻이다. 매번 탐색을 돌리면 𝑂(𝑉+𝐸) 씩 들어 전체가 느려진다.
이 자리에서 자료구조를 하나 새로 만든다. union-find(서로소 집합, disjoint set)는 딱 두 연산만 지원한다.
find(x) — 𝑥 가 속한 덩어리의 대표를 돌려준다.
union(x, y) — 두 덩어리를 하나로 합친다.
덩어리를 트리로 나타내되 6장의 트리와 방향이 반대다. 자식이 부모를 가리키고 뿌리가 대표다. find 는 부모를 따라 뿌리까지 올라가는 것이고, union 은 한 뿌리를 다른 뿌리 아래에 붙이는 것이다.
그냥 하면 트리가 한 줄로 늘어서 find 가 𝑂(𝑉) 이 된다. 6장의 치우친 이진 탐색 트리와 같은 문제이며, 해결책도 두 가지 요령이다.
<랭크로 합치기는 낮은 트리를 높은 트리 밑에 붙여 높이가 늘지 않게 한다. 경로 압축은 find 를 부른 김에 지나간 노드를 전부 뿌리에 직접 붙인다. 둘을 함께 쓸 때만 거의 상수가 된다>[원본 보기]
랭크로 합치기 — 두 뿌리를 붙일 때 랭크(높이의 상한)가 작은 쪽을 큰 쪽 아래에 붙인다. 그러면 높이가 늘어나는 것은 두 랭크가 같을 때뿐이고, 높이 ℎ 인 트리에는 노드가 적어도 2ℎ 개 있어야 하므로 높이가 𝑂(log𝑉) 로 묶인다.
경로 압축 — find 를 부른 김에 지나온 노드를 전부 뿌리에 직접 붙인다. 어차피 뿌리까지 올라갔으니 공짜에 가깝고, 다음부터 그 노드들의 find 가 한 걸음에 끝난다.
↓ python
def find(x):
if 부모[x] != x:
부모[x] = find(부모[x]) # 올라간 김에 뿌리에 직접 붙인다
return 부모[x]
def union(x, y):
a, b = find(x), find(y)
if a == b: return False # 이미 한 덩어리 — 고리가 생긴다
if 랭크[a] < 랭크[b]: a, b = b, a
부모[b] = a
if 랭크[a] == 랭크[b]: 랭크[a] += 1
return True
둘을 함께 쓰면 연산 𝑚 번의 총비용이 𝑂(𝑚𝛼(𝑉)) 다. 여기서 𝛼 는 역 애커만 함수라는 극도로 천천히 자라는 함수인데, 우주에 있는 원자 수만큼의 원소를 넣어도 값이 5 를 넘지 않는다. 실무에서는 상수로 봐도 된다.
그러면 크루스칼 전체의 비용은 간선을 정렬하는 값이 지배한다. 정렬이 𝑂(𝐸log𝐸) 이고 union-find 연산이 사실상 상수이므로 𝑂(𝐸log𝐸) 다. 𝐸≤𝑉2 이므로 log𝐸≤2log𝑉 이고, 𝑂(𝐸log𝑉) 로 적기도 한다.
예제 135
원소 8개를 각각 따로 두고 union(1,2), union(3,4), union(1,3), union(5,6) 을 차례로 한 뒤 find(2) 와 find(4) 를 부르면 결과가 같은가? 그리고 랭크는 어떻게 변하는가?
풀이 보기
처음에는 모두 자기 자신이 부모이고 랭크가 0 이다.
union(1,2). 랭크가 같으므로 2 를 1 아래에 붙이고 랭크[1] = 1 이 된다.
union(3,4). 마찬가지로 4 가 3 아래로, 랭크[3] = 1.
union(1,3). 두 뿌리가 1 과 3 이고 랭크가 둘 다 1 이다. 하나를 다른 하나 아래에 붙이고(코드대로면 3 이 1 아래로) 랭크[1] = 2 가 된다. 이제 덩어리는 {1,2,3,4} 이고 대표는 1 이다.
union(5,6). 별개의 덩어리다. 6 이 5 아래로, 랭크[5] = 1.
find(2) 는 부모 1 을 따라가 1, find(4) 는 부모 3 을 거쳐 1 이다. 같다. 같은 덩어리이므로 당연하다.
find(4) 를 부르는 순간 경로 압축이 일어나 4 의 부모가 3 에서 1 로 바뀐다. 트리 높이가 2 에서 1 로 줄었다.
여기서 랭크[1] 은 여전히 2 다. 경로 압축으로 실제 높이가 줄어도 랭크는 고치지 않는다. 랭크는 높이가 아니라 높이의 상한이며, 고치려 들면 union 이 비싸져 이득이 사라진다.
예제 136
본문의 그림 설명은 "둘을 함께 쓸 때만 거의 상수가 된다"고 했다. 근거를 보자. 원소가 𝑛 개일 때 (1) 둘 다 안 쓰면, (2) 랭크로 합치기만 쓰면, (3) 경로 압축만 쓰면 각각 어떻게 되는가? 나쁜 입력을 실제로 만들어 보여라.
풀이 보기
(1) 둘 다 안 쓸 때. union 이 언제나 첫 인자의 뿌리를 둘째 인자의 뿌리 아래에 붙인다고 하자. 그러면 union(1,2), union(2,3), union(3,4), … 를 차례로 부르는 것만으로 트리가 한 줄이 된다.
union(1,2) 로 1→2.
union(2,3) 은 find(2) = 2 이므로 2→3 을 붙여 1→2→3.
이렇게 𝑛−1 번이면 길이 𝑛−1 짜리 사슬이 된다.
이제 find(1) 은 𝑛−1 걸음이다. 한 번의 연산이 𝑂(𝑛) 이고, 이런 find 를 𝑚 번 부르면 𝑂(𝑚𝑛) 이다. 크루스칼에 넣으면 전체가 𝑂(𝐸𝑉) 이라 정렬보다 훨씬 비싸져 이 자료구조를 쓴 뜻이 없어진다.
(2) 랭크로 합치기만 쓸 때. 위의 나쁜 입력이 통하지 않는다. union(1,2) 에서 두 랭크가 0 이라 2 가 1 밑에 붙고 랭크[1] 이 1 이 된다. 다음 union(2,3) 은 뿌리가 1(랭크 1)과 3(랭크 0)이므로 3 이 1 밑에 붙고 높이가 늘지 않는다. 그다음도 마찬가지라 트리는 뿌리 하나에 잎이 줄줄이 달린 높이 1 짜리로 남는다.
일반적으로도 높이가 묶인다. 본문의 논증대로 높이 ℎ 인 트리에는 노드가 적어도 2ℎ 개 있어야 한다. 높이가 늘어나는 것은 같은 랭크 둘을 합칠 때뿐인데, 그때 두 트리가 각각 적어도 2ℎ 개를 갖고 있으므로 합친 것은 적어도 2ℎ+1 개가 되기 때문이다. 노드가 𝑛 개이므로 2ℎ≤𝑛, 곧 ℎ≤log2𝑛 이다. 그러니 최악의 한 번도 𝑂(log𝑛) 이다.
그러면 랭크만으로 충분한가. 아니다. log𝑛 은 실제로 나온다. 원소 2ℎ 개를 둘씩 짝지어 합치고, 그 결과를 다시 둘씩 짝지어 합치기를 되풀이하면 매번 같은 랭크끼리 만나므로 랭크가 한 단계씩 오른다. ℎ 단계 뒤에는 높이가 정말로 ℎ=log2𝑛 인 트리가 선다. 그 뒤로는 find 마다 log𝑛 걸음을 계속 낸다. 깊어지는 것은 막았지만 이미 깊어진 것을 되돌리지는 못한다.
(3) 경로 압축만 쓸 때. (1)의 사슬을 그대로 만들 수 있으므로 한 번의 find 최악은 여전히 𝑂(𝑛) 이다. 다르게 만드는 것은 그 한 번이 값을 치르는 김에 트리를 통째로 납작하게 만든다는 점이다. 사슬 위에서 find(1) 을 한 번 부르면 지나온 노드 𝑛−1 개가 전부 뿌리를 직접 가리키게 되고, 그다음부터 그 노드들의 find 는 한 걸음이다. 3장의 분할상환이 정확히 이 모양이다. 비싼 연산이 다음 연산들을 싸게 만들어 준다.
그래서 평균으로 보면 (1)보다 훨씬 낫지만, 깊은 트리가 계속 새로 만들어질 수는 있으므로 역 애커만까지 내려가지는 않는다. 알려진 값은 log𝑛 급이다.
정리하면 이렇다. 랭크는 트리가 깊어지는 것을 미리 막고, 경로 압축은 이미 깊어진 트리를 쓰는 김에 편다. 막기만 하면 log𝑛 이 남고, 펴기만 하면 펴야 할 것이 계속 생긴다. 둘을 함께 써야 𝑂(𝛼(𝑛)) 이 된다.
실무에서 하나만 고른다면 랭크(또는 크기)로 합치기다. 최악을 𝑂(log𝑛) 으로 보장해 주기 때문이다. 다만 두 요령을 다 쓰는 데 드는 코드가 각각 두 줄뿐이라 하나만 고를 이유가 없다.
프림
프림은 자름을 먼저 정한다. 아무 정점 하나에서 시작해 지금까지 자란 덩어리와 나머지를 자름으로 삼고, 그것을 건너는 가장 싼 간선을 골라 덩어리를 하나 키운다. 정점이 다 들어올 때까지 되풀이한다. 이것도 탐욕이고, 옳은 근거도 앞 절의 자르기 성질 하나로 같다. 크루스칼과 다른 것은 자름을 언제 정하느냐뿐이다.
"건너는 간선 중 가장 싼 것"을 빨리 찾아야 하므로 우선순위 큐가 필요하다. 다익스트라와 거의 같은 코드이고, 다른 점은 큐의 기준값 하나다.
<같은 그래프에서 크루스칼은 1 2 3 5 7 순서로, C 에서 시작한 프림은 7 1 2 3 5 순서로 간선을 넣는다. 고르는 순서는 다르지만 결과는 같은 트리이고 무게 합도 18 로 같다>[원본 보기]
무엇
다익스트라
프림
큐에 담는 값
출발점에서 v 까지의 거리
v 를 덩어리에 붙이는 간선 하나의 무게
완화 조건
𝑑[𝑢]+𝑤<𝑑[𝑣]
𝑤<key[𝑣]
답
모든 정점까지의 최단 거리
간선 𝑉−1 개의 집합
차이가 한 줄뿐이라는 것이 요점이다. 다익스트라는 누적을 재고 프림은 마지막 한 걸음만 잰다. 최소신장트리가 최단경로 트리와 다른 이유가 여기에 있다.
비용은 𝑂(𝐸log𝑉) 로 크루스칼과 같은 급이다. 갈리는 지점은 이렇다. 밀집 그래프면 프림이 낫다(간선을 정렬할 필요가 없다). 희소하거나 간선이 이미 정렬되어 있으면 크루스칼이 낫다.
예제 137
그림의 그래프에서 최소신장트리와 최단경로 트리가 다름을 보여라. 정점 A 에서 출발한다고 하자.
풀이 보기
그림의 간선은 AB 1, BC 7, AD 4, BE 2, CF 8, DE 3, EF 5, AE 6, BD 9 다.
최소신장트리는 {𝐴𝐵1,𝐵𝐸2,𝐷𝐸3,𝐸𝐹5,𝐵𝐶7} 이고 무게 합은 18 이다.
이 트리에서 A 에서 D 로 가려면 𝐴→𝐵→𝐸→𝐷 이고 길이가 1+2+3=6 이다.
그런데 원래 그래프에는 𝐴𝐷=4 인 간선이 있다. 최단경로는 4 다.
곧 최소신장트리는 A 에서 D 까지의 최단경로를 담고 있지 않다. 두 문제가 최적화하는 대상이 다르기 때문이다. 최소신장트리는 전체 무게 합을 줄이고, 최단경로 트리는 출발점에서 각 정점까지의 거리를 줄인다.
다르게 말하면, 최소신장트리는 "전선 총 길이를 아끼는" 답이고 최단경로 트리는 "각자 집에서 시청까지 빨리 가는" 답이다. 전자는 출발점이 없고 후자는 있다.
설계 판단
데이터센터 40곳을 광케이블로 잇는다. 두 곳 사이 공사비는 미리 조사되어 있다. 어떤 알고리즘을 쓰겠는가? 만약 "본사에서 각 지점까지의 지연시간"을 줄이는 것이 목표라면 답이 달라지는가?
풀이 보기
앞의 목표는 총 공사비 최소이므로 최소신장트리다. 정점이 40개이고 모든 쌍의 공사비를 안다는 것은 간선이 40×39/2=780 개인 완전 그래프라는 뜻이다.
밀집 그래프이므로 프림이 자연스럽다. 인접 행렬로 두고 우선순위 큐 없이 매번 최솟값을 훑는 단순 구현도 𝑂(𝑉2)=1600 번이라 즉시 끝난다.
뒤의 목표는 다르다. 앞 예제에서 본 대로 최소신장트리는 각 지점까지의 거리를 보장하지 않는다. 본사를 출발점으로 다익스트라를 돌려 최단경로 트리를 구해야 한다.
두 답의 성격도 다르다. 최소신장트리는 총 케이블 길이가 짧지만 어떤 지점은 여러 곳을 거쳐 가고, 최단경로 트리는 각자 빠르지만 총 길이가 길다.
실무에서는 둘 다 아닌 답이 나오기도 한다. 고장 하나로 망 전체가 끊기면 안 된다는 요구가 붙으면 트리 자체가 답이 아니다. 트리는 간선 하나만 끊어도 두 덩어리가 되기 때문이다(9장). 그때는 순환을 일부러 넣어 여분 경로를 만든다. 제약을 다 적기 전에는 알고리즘을 고르지 마라.
이 장의 정리
최단경로 알고리즘은 완화를 어떤 순서로 하느냐로 갈리고, 최소신장트리 알고리즘은 자름을 언제 정하느냐로 갈린다.
<가중치가 있는가, 음수가 있는가, 순환이 있는가, 목적지를 아는가 — 네 질문이 최단경로 알고리즘을 정한다. 최소신장트리는 그래프가 밀집인지 희소인지로 갈린다>[원본 보기]
알고리즘
어느 틀인가
비용
쓸 수 있는 조건
무엇을 얻는가
너비 우선 탐색
탐색 (9장)
𝑂(𝑉+𝐸)
가중치가 없다
한 점에서 모든 점 (간선 수)
다익스트라
탐욕
𝑂(𝐸log𝑉)
가중치가 음이 아니다
한 점에서 모든 점
벨만-포드
동적 계획법
𝑂(𝑉𝐸)
음수 간선 허용
한 점에서 모든 점 + 음수 순환 탐지
DAG 최단경로
동적 계획법
𝑂(𝑉+𝐸)
순환이 없다
한 점에서 모든 점. 최장 경로도
플로이드-워셜
동적 계획법
Θ(𝑉3)
정점이 적고 밀집
모든 쌍
A*
탐욕 + 어림값
경우에 따라
목적지와 좋은 어림값을 안다
한 점에서 한 점
용어
뜻
완화
더 짧은 길을 찾으면 거리와 직전 정점을 갈아탄다
음수 순환
무게 합이 음인 순환. 있으면 최단경로가 정의되지 않는다
허용성
어림값이 실제 남은 거리를 넘지 않는다. A* 가 옳을 조건
최소신장트리
모든 정점을 잇는 간선 𝑉−1 개 중 무게 합이 최소인 것
자름 · 자르기 성질
정점을 두 무리로 나눈 것 · 자름을 건너는 최소 간선은 답에 들어간다
union-find
덩어리 대표를 찾고 합치는 구조. 랭크와 경로 압축으로 거의 상수
9장과 이 장에서 되풀이된 것이 하나 있다. 문제를 그래프로 옮기고 나면 쓸 알고리즘이 정해진다. 어려운 부분은 대개 알고리즘이 아니라 무엇을 정점으로 삼고 무엇을 간선으로 삼을지 정하는 것이다. 그리고 알고리즘을 고른 뒤에 옳은지 따지는 방법은 10 · 11장이 이미 준 두 가지였다. 탐욕이면 바꿔치기로 선택을 정당화하고, 동적 계획법이면 표의 한 칸을 문장으로 적는다.
여기까지가 3부다. 설계 기법 셋을 세우고(7 · 10 · 11장) 그것들이 실제로 무엇을 푸는지까지(8 · 12장) 보았다. 다음 장은 방향이 반대다. 지금까지는 문제를 받아 알고리즘을 만들었는데, 13장은 "이 문제에 쓸 만한 알고리즘이 있기는 한가"를 묻는다. 실마리는 이미 나왔다. 11장에서는 표를 세워도 칸이 2𝑉 개인 문제가 있었고, 이 장에서는 최단경로는 쉬웠는데 최장 경로는 DAG 를 벗어나는 순간 손댈 수 없었다. 그 차이가 어디서 오는지가 다음 장의 질문이다.
계산 복잡도 — P와 NP
3부에서 문제를 푸는 방법을 세 가지 배웠다. 7장의 나누기, 10장의 고르기, 11장의 표 채우기다. 그리고 바로 앞 12장에서 그 셋이 그래프 위에서 실제로 무엇을 푸는지 보았다. 잘 드는 도구였다.
그런데 같은 3부가 그 도구로 안 되는 것들도 함께 남겼다. 11장의 끝에서는 해밀턴 경로가 표를 세워도 칸이 2𝑉 개였고 배낭은 값에 대해서만 다항이었다. 12장의 끝에서는 더 이상하게 갈렸다. 최단경로는 그렇게 쉬웠는데 최장 경로는 DAG 를 벗어나는 순간 손댈 데가 없었다. 같은 그래프, 같은 완화, 부호 하나 뒤집었을 뿐인데 한쪽은 𝑂(𝐸log𝑉) 이고 다른 쪽은 아무도 다항 시간 방법을 모른다.
질문이 남는다. 그것은 우리가 아직 좋은 방법을 못 찾은 것인가, 아니면 원래 없는 것인가. 이 장은 그 질문을 정확히 던지는 법을 배우는 곳이다. 답은 아직 아무도 모른다.
답할 것은 넷이다. "풀기 어렵다"를 어떻게 정의하는가, 문제끼리의 어려움을 어떻게 견주는가, 왜 그 많은 문제들이 하나로 묶이는가, 그리고 어려운 문제를 만나면 무엇을 하는가. 마지막이 실무에서 실제로 쓰는 것이다.
앞에서 가져오는 것은 3장의 점근 표기와 유사 다항 시간, 9장의 그래프 용어, 7장의 이진 탐색, 10 · 11장의 문제들, 그리고 12장의 DAG 최단경로(최장 경로가 거기서만 쉬웠다)와 자르기 성질이다. 이 장에는 코드가 거의 없다. 다루는 것이 특정 알고리즘이 아니라 알고리즘이 있을 수 있는가이기 때문이다.
문제를 예 · 아니오로 바꾼다
이론을 세우려면 문제의 모양을 하나로 맞춰야 한다. 답이 수인 문제, 목록인 문제, 그림인 문제를 한 자리에서 견줄 수는 없다.
그래서 판정 문제(decision problem)만 다룬다. 답이 예 또는 아니오 하나인 문제다. "가장 큰 클리크의 크기는?" 대신 "크기 𝑘 인 클리크가 있는가?"를 묻는다.
이렇게 좁혀도 잃는 것이 없다.
<판정 문제를 다항 번 부르면 최적값도 답 자체도 얻어진다. 그래서 판정만 다루어도 원래 문제의 어려움을 그대로 잰다>[원본 보기]
왼쪽으로 가는 길은 7장에서 본 답에 대한 이진 탐색이다. 𝑘 를 1부터 𝑛 까지 이분 탐색하며 판정을 부르면 log2𝑛 번에 최적값이 나온다.
오른쪽으로 가는 길은 하나씩 지워 보는 것이다. 정점 하나를 지우고 다시 판정해 답이 그대로면 그 정점은 필요 없었던 것이고, 답이 바뀌면 필요했던 것이다. 정점 수만큼 판정을 부르면 답 자체가 복원된다.
셋이 다항 시간 안에서 서로 오가므로 "판정이 어렵다"와 "최적화가 어렵다"가 같은 말이 된다.
계산 모델 — 튜링 기계
"다항 시간"을 말하려면 무엇을 한 단계로 셀지 정해야 한다. 그 기준이 튜링 기계(Turing machine)다.
아주 단순한 기계다. 무한히 긴 테이프가 한 칸에 기호 하나씩 담고 있고, 기계는 그중 한 칸을 읽는다. 지금 읽은 기호와 지금 상태만 보고 기호를 고쳐 쓰고, 테이프를 한 칸 옮기고, 상태를 바꾼다. 그게 전부다.
이렇게 소박한 기계가 오늘날의 컴퓨터가 하는 일을 전부 할 수 있다. 게다가 어떤 합리적인 계산 모델로 바꿔도 걸리는 단계 수가 다항식 배 안에서만 달라진다. 그래서 "다항 시간"이라는 말이 기계에 상관없는 성질이 된다. 이 장이 상수는커녕 지수까지 뭉뚱그려 "다항이냐 아니냐"만 따지는 이유가 이것이다.
왜 하필 다항인가. 두 가지 이유가 있다. 다항식은 더하고 곱해도 다항식이라 알고리즘을 이어 붙여도 다항이 유지된다. 그리고 3장의 표에서 본 대로 𝑛3 과 2𝑛 사이에는 실제로 넘을 수 없는 벽이 있다.
예제 139
어떤 사람이 "가장 큰 클리크를 찾는 문제"를 다항 시간에 푸는 프로그램을 만들었다고 한다. 그러면 "크기 𝑘 인 클리크가 있는가"도 다항 시간에 풀 수 있는가? 반대도 성립하는가?
풀이 보기
앞의 것. 그렇다. 가장 큰 클리크의 크기를 구해 𝑘 와 견주면 끝이다. 판정을 한 번 부르는 값이 아니라 비교 한 번만 더 든다.
뒤의 것. 이쪽도 그렇다. 판정을 부를 수 있으면 𝑘=𝑛 부터 𝑘=1 까지 내려가며 물어도 되고, 이진 탐색을 쓰면 log2𝑛 번이면 된다.
판정이 다항 시간 𝑇(𝑛) 이라면 최적값 구하기는 𝑇(𝑛)log𝑛 이고, 다항식에 로그를 곱해도 여전히 다항식이다.
여기서 확인할 것은 "다항 번 부른다"가 다항 시간을 깨지 않는다는 점이다. 다항식끼리 곱해도 다항식이므로 그렇다. 지수였다면 이 논법이 통하지 않는다.
그래서 앞으로 "클리크 문제가 어렵다"라고 말할 때 판정형인지 최적화형인지 구분하지 않아도 된다.
예제 140
판정 문제 중에는 다항 시간은커녕 어떤 알고리즘도 없는 것이 있다. 정지 문제가 그렇다. 어떤 문제인지 적고, 왜 알고리즘이 없는지 설명하라.
풀이 보기
정지 문제(halting problem)는 이렇다. 프로그램 하나와 입력 하나를 받아 그 프로그램이 그 입력에서 멈추는가를 답하는 문제다.
답하는 프로그램 𝐻 가 있다고 하자. 그러면 이런 프로그램 𝐷 를 만들 수 있다. 𝐷 는 프로그램 𝑃 를 받아, 𝐻 에게 "𝑃 를 자기 자신에 넣으면 멈추는가"를 묻는다. 멈춘다고 하면 𝐷 는 영원히 돌고, 안 멈춘다고 하면 𝐷 는 곧바로 멈춘다.
이제 𝐷 에 𝐷 자신을 넣어 보자.
𝐷 가 멈춘다면, 𝐻 가 "안 멈춘다"고 답했다는 뜻이다. 그런데 𝐻 는 맞아야 하므로 𝐷 는 안 멈춰야 한다. 모순이다.
𝐷 가 안 멈춘다면, 𝐻 가 "멈춘다"고 답했다는 뜻이고 같은 이유로 모순이다.
어느 쪽도 안 되므로 𝐻 는 존재하지 않는다. 기초수학의 귀류법이고, 대각선 논법이라 부르는 논법의 한 형태다.
이 장에서 다루는 "어렵다"는 이것과 성격이 다르다는 점을 기억하라. 정지 문제는 알고리즘이 아예 없고, 뒤에 나올 NP-완전 문제들은 알고리즘은 있는데 느리다.
P 와 NP — 푸는 것과 확인하는 것
두 부류를 정의한다.
P — 결정론적 튜링 기계로 다항 시간에 풀 수 있는 판정 문제들.
NP — 답이 "예"일 때 그것을 뒷받침하는 증명서(certificate)가 있어서, 그 증명서를 보고 다항 시간에 확인할 수 있는 판정 문제들.
두 정의의 차이가 이 장 전부다. 찾아내는 것과 보여 주면 맞는지 아는 것은 다른 일이다.
<왼쪽은 답을 찾느라 2ⁿ 가지를 훑어야 하고 오른쪽은 답을 받아 덧셈 한 번으로 확인한다. P 는 왼쪽이 다항인 문제들이고 NP 는 오른쪽이 다항인 문제들이다>[원본 보기]
예를 보자. 스도쿠를 푸는 일은 오래 걸리지만 다 채워진 판이 옳은지 확인하는 일은 줄 · 열 · 상자를 한 번씩 훑으면 끝난다. 큰 수의 소인수분해는 어렵지만 주어진 두 수를 곱해 맞는지 보는 일은 곱셈 한 번이다.
이름의 유래도 짚어 두자. NP 는 "다항이 아니다(non-polynomial)"가 아니라 "비결정론적 다항 시간(nondeterministic polynomial time)"의 줄임이다. 비결정론적 튜링 기계란 갈림길에서 모든 갈래를 동시에 가 보는 상상의 기계다. 그런 기계라면 답을 찾는 일도 확인하는 일과 같아진다. 갈래 하나가 증명서 하나에 대응하기 때문이다. 그래서 두 정의가 같은 것을 가리킨다.
셋을 확실히 해 두자.
P⊆NP 는 쉽게 보인다. 다항 시간에 풀 수 있으면 증명서를 무시하고 그냥 풀어 확인하면 된다.
반대쪽 NP⊆P 는 아무도 모른다. 이것이 P=NP 문제다.
NP 는 예 · 아니오가 대칭이 아니다. "만족시키는 값이 있다"는 그 값을 내밀면 되지만 "없다"는 내밀 것이 없다. 아니오 쪽에 증명서가 있는 문제들을 따로 co-NP 라 부른다.
예제 141
다음 문제들이 NP 에 속함을 증명서가 무엇인지 밝혀 보여라. (1) 부분집합 합 (2) 해밀턴 경로 (3) 합성수 판정(어떤 수가 소수가 아닌가)
풀이 보기
NP 임을 보이려면 증명서가 짧고(다항 길이) 확인이 빠름(다항 시간)을 보이면 된다.
(1) 부분집합 합. 증명서는 고른 원소들의 목록이다. 길이가 원래 집합보다 길 수 없으니 짧고, 확인은 더해서 목표와 견주는 것이니 𝑂(𝑛) 이다.
(2) 해밀턴 경로. 증명서는 정점을 지나는 순서다. 길이가 𝑉 이고, 확인은 "모든 정점이 정확히 한 번씩 있는가"와 "이웃한 두 정점 사이에 간선이 있는가"를 보는 것이니 𝑂(𝑉+𝐸) 이다.
(3) 합성수 판정. 증명서는 1이 아닌 약수 하나다. 길이가 그 수의 자릿수보다 짧고, 확인은 나눗셈 한 번이다.
셋 다 "확인은 쉽다"가 성립한다. 그런데 셋의 사정이 서로 다르다는 것을 알아 두면 좋다. (3)은 실제로 P 에 속함이 2002년에 증명되었다. (1)과 (2)는 뒤에서 볼 NP-완전이다.
증명서가 짧아야 한다는 조건이 중요하다. "모든 경우를 적은 표"를 증명서로 삼으면 확인은 쉽겠지만 그 표가 지수 길이라 조건을 어긴다.
예제 142
"이 그래프에 크기 𝑘 인 클리크가 없다"는 문제도 NP 에 속하는가?
풀이 보기
증명서를 무엇으로 삼을지 생각해 보자. "있다"의 증명서는 클리크 하나를 내미는 것이었다. "없다"는 무엇을 내밀어야 하는가.
크기 𝑘 인 정점 조합이 (𝑛𝑘) 개인데 그것을 전부 확인해 보였다고 적으면 증명서가 지수 길이다. 조건을 어긴다.
짧은 증명서를 아무도 못 찾았고, 없다는 것도 증명되지 않았다. 이 문제는 co-NP 에 속한다고 말한다.
NP=co-NP 인지도 열린 문제이고, P=NP 와는 다른 질문이다. 다만 P=NP 이면 NP=co-NP 도 따라 나온다. 다항 시간에 풀 수 있으면 답을 뒤집는 것도 다항 시간이기 때문이다.
여기서 얻을 교훈은 NP 의 정의가 "예" 쪽으로 기울어져 있다는 점이다. 정의를 읽을 때 늘 확인해야 하는 비대칭이다.
다항 시간 환원 — 화살표의 방향
이제 문제끼리 어려움을 견줄 도구가 필요하다. 그것이 다항 시간 환원(polynomial-time reduction)이다.
문제 𝐴 를 문제 𝐵 로 환원한다는 것은, 𝐴 의 입력 𝑥 를 𝐵 의 입력 𝑓(𝑥) 로 바꾸는 다항 시간 변환 𝑓 가 있어서
𝑥의답이예⟺𝑓(𝑥)의답이예
가 성립한다는 뜻이다. 이때 𝐴≤𝑝𝐵 라 적는다.
<변환기를 B 의 해결기 앞에 붙이면 그 전체가 A 를 푸는 기계가 된다. 화살표는 A 에서 B 로 가는데 어려움은 거꾸로 전해진다>[원본 보기]
그림이 말하는 것은 하나다. 𝐵 를 푸는 기계가 있으면 그 앞에 변환기를 붙여 𝐴 를 풀 수 있다. 변환도 해결도 다항이므로 전체가 다항이다.
여기서 읽는 방법이 둘이고, 둘은 같은 말이다.
𝐵 가 쉬우면 𝐴 도 쉽다.
𝐴 가 어려우면 𝐵 도 어렵다. (위 문장의 대우다)
그러니 "𝐵 는 적어도 𝐴 만큼 어렵다"가 결론이다. 화살표는 𝐴→𝐵 로 가는데 어려움의 정보는 반대로 흐른다. 여기가 이 장에서 가장 헷갈리는 곳이다.
실전 규칙으로 외워 두면 편하다. 새 문제가 어렵다고 주장하려면, 이미 어렵다고 알려진 문제를 새 문제로 환원한다. 방향을 뒤집으면 아무것도 증명하지 못한다.
예제 143
어떤 사람이 "해밀턴 경로 문제를 정렬 문제로 환원했으니 해밀턴 경로도 𝑂(𝑛log𝑛) 이다"라고 주장한다. 무엇이 틀렸는가?
풀이 보기
주장한 것은 해밀턴≤𝑝정렬 이다. 만약 그런 환원이 정말 있다면 결론은 맞는다. 정렬은 다항 시간에 풀리므로 해밀턴도 다항이 된다.
문제는 그런 환원이 있을 리 없다는 것이다. 환원이 요구하는 것은 "𝑥 의 답이 예 ⟺ 𝑓(𝑥) 의 답이 예"인 다항 시간 변환인데, 이것을 만들 수 있다면 해밀턴 경로가 P 에 속한다는 뜻이 되고, 그것은 P=NP 를 증명한 것이 된다.
그러니 이 사람이 실제로 한 일은 변환이 아니라 그냥 그래프의 정점을 정렬해 본 것일 가능성이 높다. 정렬한 결과가 해밀턴 경로인지를 확인하는 데 다시 지수 시간이 든다면 환원이 아니다.
여기서 확인할 것은 환원이 문제를 "푸는" 것이 아니라는 점이다. 환원은 입력을 바꾸는 것뿐이고, 답을 내는 일은 뒤의 기계가 한다. 변환 과정에서 답을 알아야 한다면 그것은 환원이 아니다.
예제 144
𝐴≤𝑝𝐵 이고 𝐵≤𝑝𝐶 이면 𝐴≤𝑝𝐶 인가?
풀이 보기
그렇다. 두 변환을 이어 붙이면 된다. 𝑥 를 𝑓(𝑥) 로 바꾸고 그것을 다시 𝑔(𝑓(𝑥)) 로 바꾸면 𝐶 의 입력이 된다.
답의 관계도 그대로 이어진다. 𝑥 가 예 ⟺ 𝑓(𝑥) 가 예 ⟺ 𝑔(𝑓(𝑥)) 가 예다.
값도 확인하자. 𝑓 가 𝑂(𝑛𝑎) 이면 그 출력의 길이도 𝑂(𝑛𝑎) 을 넘지 못한다. 다항 시간 안에는 그보다 긴 것을 쓸 수조차 없기 때문이다. 그러니 𝑔 는 길이 𝑂(𝑛𝑎) 인 입력에 대해 𝑂((𝑛𝑎)𝑏)=𝑂(𝑛𝑎𝑏) 이다. 다항식이다.
환원이 이행적이라는 이 성질이 다음 절의 열쇠다. 어려운 문제 하나만 맨바닥에서 증명해 두면, 나머지는 화살표를 이어 가는 것만으로 전부 어려워진다.
NP-난해와 NP-완전
두 정의를 더한다.
NP-난해(NP-hard) — NP 의 모든 문제가 그 문제로 다항 시간에 환원되는 문제. 곧 NP 안의 어떤 문제보다도 쉽지 않다.
NP-완전(NP-complete) — NP-난해이면서 자기 자신도 NP 에 속하는 문제.
차이가 중요하다. NP-난해에는 NP 밖의 것도 들어간다. 정지 문제가 그렇다. NP 의 모든 문제보다 어렵지만 짧은 증명서가 없으므로 NP 에 속하지 않는다.
<NP 와 NP-난해가 겹치는 자리가 NP-완전이고 그것이 NP 안에서 가장 어려운 것들이다. 오른쪽처럼 P = NP 라면 세 겹이 하나로 무너진다>[원본 보기]
NP-완전 문제는 NP 전체를 대표한다. 그중 하나라도 다항 시간에 풀리면 환원을 따라 NP 의 모든 문제가 다항 시간에 풀린다. 반대로 하나라도 다항 시간에 풀릴 수 없음이 증명되면 P≠NP 다.
그런데 정의를 보면 곤란한 점이 있다. NP-난해임을 보이려면 NP 안의 모든 문제에 대해 환원을 만들어야 한다. NP 에 무엇이 들었는지도 모르는데 어떻게 하는가.
쿡-레빈 정리(Cook-Levin theorem)가 그 일을 한 번 해냈다.
SAT은NP-완전이다.
여기서 SAT(만족 가능성 문제)는 "논리 변수들로 이루어진 식을 참으로 만드는 값 배정이 있는가"를 묻는 문제다. 증명의 뼈대만 적으면 이렇다. NP 문제 하나를 잡으면 그것을 확인하는 튜링 기계가 있고, 그 기계가 다항 시간 동안 하는 모든 동작을 논리식으로 그대로 옮겨 적을 수 있다. 그 논리식이 참이 되는 배정은 그 기계가 "예"로 끝나는 실행에 대응한다.
이 하나가 있으면 나머지는 쉽다. SAT 을 새 문제로 환원하면 그 문제도 NP-난해다. 환원이 이행적이기 때문이다.
예제 145
NP-완전임을 증명하는 절차를 두 단계로 적어라. 그리고 "내 문제가 NP-완전이다"라고 말하려는 사람이 가장 흔히 저지르는 실수는 무엇인가?
풀이 보기
절차는 둘이다.
1. NP 에 속함을 보인다. 증명서를 정하고 확인이 다항 시간임을 보인다. 대개 몇 줄이면 끝난다.
2. NP-난해임을 보인다. 이미 NP-완전으로 알려진 문제 하나를 골라 그것을 내 문제로 다항 시간에 환원한다.
가장 흔한 실수는 2번의 방향을 뒤집는 것이다. "내 문제를 SAT 으로 바꿔 풀 수 있다"를 보이면, 그것은 내 문제가 SAT 보다 쉽거나 같다는 말이라 아무 소용이 없다. 사실 NP 안의 모든 문제가 SAT 으로 환원되므로 그 방향은 늘 성립한다.
두 번째로 흔한 실수는 1번을 빠뜨리는 것이다. 그러면 NP-난해까지만 보인 것이고, NP-완전이라 부를 수 없다.
세 번째는 환원의 양방향을 확인하지 않는 것이다. "𝑥 가 예면 𝑓(𝑥) 도 예"만 보이고 반대를 빠뜨리면 증명이 아니다. 거짓 답을 만들어 내는 변환도 그 절반은 만족하기 때문이다.
예제 146
정지 문제가 NP-난해이지만 NP-완전은 아닌 이유를 설명하라.
풀이 보기
NP-난해인 이유. NP 안의 어떤 문제든 정지 문제로 환원할 수 있다. 그 문제를 완전 탐색으로 푸는 프로그램을 만들되, 답이 예면 멈추고 아니면 무한 반복하게 짜면 된다. 그 프로그램을 만드는 일은 다항 시간이고, 원래 답이 예인 것과 그 프로그램이 멈추는 것이 정확히 같다.
NP 에 속하지 않는 이유. NP 에 속하려면 짧은 증명서로 다항 시간에 확인할 수 있어야 한다. 그런데 "멈춘다"를 확인하려면 실제로 멈출 때까지 돌려 보아야 하고, 그 시간에 상한이 없다.
사실 정지 문제에는 어떤 알고리즘도 없다. 앞에서 귀류법으로 본 대로다. 그러니 NP 는커녕 계산 가능한 문제 축에도 못 든다.
여기서 두 가지 "어렵다"를 확실히 구분하자. 계산 불가능은 알고리즘이 없다는 뜻이고, NP-완전은 알고리즘은 있는데 아는 것이 전부 지수라는 뜻이다. NP-완전 문제는 입력이 작으면 실제로 풀린다.
환원 지도
쿡-레빈 정리 이후 수십 년 동안 사람들이 한 일은 화살표를 잇는 것이었다. 지금은 수천 개의 문제가 NP-완전으로 알려져 있다.
<맨 위 SAT 하나만 맨바닥에서 증명했고 나머지는 화살표를 따라 얻는다. 위에 있는 것이 아래로 환원된다는 것은 아래가 위만큼 어렵다는 뜻이다>[원본 보기]
지도에서 가장 자주 쓰이는 두 화살표를 자세히 본다.
3SAT 에서 클리크로
3SAT 은 SAT 중에서 "절마다 리터럴이 정확히 셋"인 것만 모은 문제다. 리터럴이란 변수 하나나 그 부정이고, 절은 리터럴 몇 개를 ∨(또는)로 이은 것이며, 전체 식은 절들을 ∧(그리고)로 이은 것이다. 절 하나하나가 모두 참이 되어야 전체가 참이다.
3SAT 을 클리크 문제로 바꾸는 방법은 이렇다. 절마다 그 안의 리터럴 세 개를 정점으로 찍고, 서로 다른 절에 속하면서 모순이 아닌 두 정점을 간선으로 잇는다. 모순이란 𝑥 와 ¬𝑥 처럼 동시에 참일 수 없는 짝을 말한다.
<절이 셋이면 크기 3 인 클리크를 찾는 문제가 된다. 클리크에 든 리터럴을 전부 참으로 놓으면 모순이 없고 절마다 하나씩 참이 되므로 식 전체가 참이다>[원본 보기]
왜 이것이 통하는가. 절이 𝑘 개일 때 크기 𝑘 인 클리크가 있다는 것과 식이 만족 가능하다는 것이 같다.
클리크가 있으면, 그 안의 리터럴들은 서로 모순이 아니므로 전부 참으로 놓을 수 있다. 클리크의 정점이 절마다 하나씩이므로(같은 절 안끼리는 간선이 없다) 모든 절이 참이 된다.
거꾸로 만족하는 배정이 있으면, 절마다 참이 된 리터럴을 하나씩 고르면 그것들은 서로 모순일 수 없으므로 클리크를 이룬다.
정점이 3𝑘 개, 간선이 많아야 𝑂(𝑘2) 개이므로 변환 자체는 다항 시간이다. 이것이 환원의 마지막 조건이다.
클리크에서 정점 덮개로
정점 덮개(vertex cover)는 "모든 간선에 적어도 한쪽 끝이 닿는 정점 집합"이다. 크기 𝑘 인 정점 덮개가 있는지를 묻는 것이 판정형이다.
<간선을 전부 뒤집으면 서로 다 이어진 무리가 서로 하나도 안 이어진 무리가 된다. 그 나머지가 정점 덮개다>[원본 보기]
환원은 간선을 뒤집는 것 하나다. 그래프 𝐺 의 여집합 그래프 ¯𝐺 를 만든다.
𝐺에크기𝑘인클리크가있다⟺¯𝐺에크기𝑛−𝑘인정점덮개가있다
이유는 한 줄이다. 𝑆 가 𝐺 의 클리크라는 것은 𝑆 안에 ¯𝐺 의 간선이 하나도 없다는 뜻이고, 그것은 곧 ¯𝐺 의 모든 간선이 𝑆 바깥의 정점에 닿는다는 뜻이다. 바깥의 크기가 𝑛−𝑘 다.
변환이 𝑂(𝑛2) 이므로 다항이다.
예제 147
그림의 그래프에서 여집합 그래프를 직접 만들어 두 답이 맞는지 확인하라. 그리고 독립 집합(서로 하나도 안 이어진 정점 집합) 문제도 같은 방식으로 환원되는가?
풀이 보기
정점이 여섯 개이고 여집합 그래프의 간선은 {1,2,3,4} 안의 여섯 개뿐이다. 그러니 원래 그래프 𝐺 의 간선은 나머지 아홉 개, 곧 5 나 6 에 닿는 것들 전부다.
¯𝐺 에 크기 4 인 클리크(1, 2, 3, 4)가 있다. 그러면 𝐺 에 크기 6−4=2 인 정점 덮개가 있어야 한다.
확인하자. 𝐺 의 간선은 전부 5 나 6 에 닿으므로 {5,6} 이 모든 간선을 덮는다. 크기 2 다. 맞는다.
독립 집합. 훨씬 간단하다. 𝑆 가 𝐺 의 독립 집합이라는 것은 𝑆 안에 𝐺 의 간선이 없다는 뜻이고, 그것은 𝑆 가 ¯𝐺 의 클리크라는 말과 정확히 같다. 간선을 뒤집는 같은 변환이다.
게다가 정점 덮개와 독립 집합은 같은 그래프 안에서 서로 여집합이다. 𝑆 가 독립 집합이면 모든 간선이 𝑆 바깥에 한쪽 끝을 갖고, 그것이 정점 덮개의 정의다.
세 문제가 사실상 같은 문제를 세 가지로 적은 것이다. 하나가 NP-완전이면 셋 다 NP-완전이다.
예제 148
해밀턴 순환에서 TSP 로 가는 환원을 만들어라. TSP 의 판정형은 "길이 𝑘 이하로 모든 도시를 한 번씩 돌아 제자리로 오는 순회가 있는가"다.
풀이 보기
주어진 것은 그래프 𝐺 이고, 만들 것은 모든 도시 쌍에 거리가 정해진 TSP 입력이다.
정점은 그대로 도시로 삼는다. 거리는 이렇게 준다. 𝐺 에 간선이 있으면 1, 없으면 2. 그리고 𝑘=𝑛 으로 둔다.
왜 통하는가. 어떤 순회든 간선 𝑛 개를 쓰므로 길이가 적어도 𝑛 이고, 길이가 정확히 𝑛 이라는 것은 쓴 간선이 전부 값 1, 곧 전부 𝐺 의 진짜 간선이라는 뜻이다. 그것이 바로 해밀턴 순환이다.
거꾸로 해밀턴 순환이 있으면 그 순서대로 돌면 길이가 𝑛 이다. 양방향이 성립한다.
변환은 도시 쌍마다 값 하나를 적는 일이라 𝑂(𝑛2) 이다.
값 2 를 고른 것이 자의적으로 보이지만 상관없다. 1 보다 크기만 하면 된다. 다만 2 로 두면 삼각부등식이 성립해서, 뒤에서 볼 "삼각부등식을 만족하는 TSP"도 여전히 NP-완전임을 함께 보이게 된다.
P=NP 라면 무엇이 달라지는가
만약 누가 NP-완전 문제 하나를 다항 시간에 푸는 알고리즘을 내놓으면 P=NP 가 증명된다. 그러면 이런 일이 벌어진다.
공개키 암호가 무너진다. 16장에서 볼 RSA 는 소인수분해가 어렵다는 것에 기대어 있다. 소인수분해는 NP-완전이라고 알려지지는 않았지만 NP 에 속하므로 함께 무너진다.
최적화가 전부 쉬워진다. 일정 계획, 배차, 회로 배치, 단백질 접힘 같은 것들이 정확한 최적해를 낸다.
수학이 달라진다. "길이 𝑛 이하의 증명이 있는가"는 NP 문제다. 다항 시간에 풀린다면 정리를 찾는 일이 정리를 확인하는 일만큼 쉬워진다. 이것이 대부분의 연구자가 P≠NP 라고 믿는 가장 큰 이유다.
다만 조심할 것이 있다. 다항 시간이라고 실용적인 것은 아니다. 𝑛100 짜리 알고리즘이 나온다면 이론은 뒤집히지만 세상은 그대로다.
예제 149
P≠NP 를 증명하려면 무엇을 보여야 하는가? 그것이 왜 어려운가?
풀이 보기
NP-완전 문제 하나를 잡아 어떤 다항 시간 알고리즘도 그것을 풀 수 없음을 보이면 된다.
여기가 어려운 곳이다. 어떤 알고리즘 하나가 안 된다는 것이 아니라 아직 아무도 생각해 내지 않은 것을 포함해 모든 알고리즘에 대해 안 된다는 것을 보여야 한다.
하한을 증명한 예가 아주 없지는 않다. 8장의 비교 정렬의 하한이 그것이다. 거기서는 "비교만 쓴다"는 제약이 있어 결정 트리로 논증할 수 있었다.
그런데 이 문제에는 그런 제약이 없다. 알고리즘이 무엇이든 상관없이 하한을 말해야 하고, 그런 논증을 만드는 일이 지금까지의 어떤 수법으로도 되지 않는다는 것 자체가 여러 차례 증명되었다.
그래서 이 문제는 백만 달러 상금이 걸린 일곱 개의 밀레니엄 문제 가운데 하나로 남아 있다. 이 장에서 배울 실질적인 것은 "풀 수 있는가"가 아니라 "못 풀 것을 알아보고 대응하는 법"이다.
NP-완전을 만났을 때 — 세 가지 대응
실무에서 문제가 NP-완전임을 알아냈다면 그것은 포기하라는 신호가 아니다. 완벽한 답을 다항 시간에 얻으려는 시도를 그만두라는 신호다. 무엇을 포기할지 고르면 된다.
<먼저 문제에 특별한 구조가 있는지, 입력이 실제로 얼마나 큰지를 확인한다. 그다음 정확함 · 보장 · 일반성 중 무엇을 포기할지 고른다>[원본 보기]
정확함을 포기한다 — 근사 알고리즘
근사 알고리즘(approximation algorithm)은 최적해를 내놓지는 못하지만 최적에서 얼마나 벗어나는지를 증명해 둔 알고리즘이다. 최적의 𝑐 배 안이라고 보장하면 𝑐-근사라 한다.
<덮이지 않은 간선을 아무거나 골라 양 끝을 함께 넣기를 되풀이한다. 고른 간선들이 끝점을 공유하지 않으므로 최적해도 그 개수만큼은 써야 하고, 우리가 쓴 점은 정확히 그 두 배다>[원본 보기]
정점 덮개의 2-근사가 대표적이다. 절차가 두 줄이다. 아직 덮이지 않은 간선을 아무거나 골라 그 양 끝을 둘 다 답에 넣는다. 그 두 점에 닿는 간선을 모두 지운다.
놀라운 것은 최적을 모르면서도 "최적의 두 배 안"이라고 말할 수 있다는 점이다. 고른 간선들은 끝점을 하나도 공유하지 않으므로, 어떤 정점 덮개든 그 간선마다 적어도 한 점씩은 써야 한다. 그러니 최적해의 크기는 고른 간선 수 이상이고, 우리가 쓴 점은 정확히 그 두 배다.
보장을 포기한다 — 휴리스틱
최적에서 얼마나 벗어나는지 증명하지 않고 대개 잘 되는 방법을 쓴다. 지금의 답을 조금씩 고쳐 나가는 지역 탐색, 가끔 나쁜 방향으로도 움직여 갇히는 것을 피하는 담금질, 여러 답을 섞어 새 답을 만드는 유전 알고리즘 같은 것들이다. 10장의 탐색과 활용이 여기서 다시 나온다.
실무에서 가장 많이 쓰이는 것이 이 갈래다. 특히 SAT 은 이론적으로 NP-완전이지만, 현대의 SAT 해결기는 변수 수십만 개짜리 실제 문제를 자주 푼다. "최악이 지수"와 "실제 입력에서 느리다"는 다른 말이다.
일반성을 포기한다 — 매개변수화
어려움의 원인이 작은 값 하나에 몰려 있는 경우가 있다. 정점 덮개에서 "크기 𝑘 인 덮개가 있는가"는 𝑘 가 작으면 실제로 빨리 풀린다.
간선 하나를 잡으면 그 양 끝 중 적어도 하나는 덮개에 들어가야 하므로, 두 경우로 갈라 각각 𝑘 를 1 줄여 재귀하면 된다. 깊이 𝑘 짜리 이진 트리이므로 비용이 𝑂(2𝑘⋅𝑛) 이다.
지수가 붙었지만 그 지수가 𝑛 이 아니라 𝑘 에만 붙었다.𝑘=20 이면 220≈106 이라 𝑛 이 백만이어도 돌아간다. 이런 꼴을 고정 매개변수 다루기 쉬움이라 한다.
설계 판단
배달 기사 한 명이 오늘 들러야 할 곳이 (1) 8군데 (2) 30군데 (3) 5000군데 일 때 각각 어떻게 경로를 정하겠는가?
풀이 보기
TSP 다. NP-완전이지만 크기에 따라 답이 다르다.
(1) 8군데 — 정확히 푼다. 11장의 방법으로 상태를 (들른곳의집합,지금위치) 로 잡으면 𝑂(2𝑛𝑛2) 이다. 28×64≈1.6×104 이라 순식간이다.
(2) 30군데 — 경계다.230×900≈1012 이라 위 방법은 버겁다. 정확한 답이 꼭 필요하면 분기 한정을 쓴 전용 해결기로 시도해 볼 만하고, 아니면 근사로 간다.
(3) 5000군데 — 근사나 휴리스틱. 도로 거리는 삼각부등식을 만족하므로 최소신장트리(12장)를 이용한 근사가 쓸 수 있다. 실무에서는 대개 적당한 답을 만든 뒤 지역 탐색으로 고쳐 나간다. 두 구간을 뒤집어 짧아지면 받아들이는 식이다.
판단의 기준은 "최적이 얼마나 값진가"다. 배달 경로가 3% 길어도 실무에서는 대개 괜찮다. 반도체 배치처럼 한 번 정하면 수백만 개를 찍어 내는 곳이라면 며칠을 들여서라도 정확히 푸는 편이 낫다.
예제 151
11장의 0-1 배낭은 𝑂(𝑛𝑊) 에 풀렸다. 그런데 배낭 문제는 NP-완전이다. 모순 아닌가?
풀이 보기
모순이 아니다. 3장에서 본 입력 크기의 문제다.
𝑊 는 입력에 적힌 수이지 입력의 길이가 아니다. 𝑊 를 이진법으로 적으면 자릿수가 log2𝑊 이므로, 입력 길이를 𝑚 이라 하면 𝑊=2𝑚 규모다. 그러면
𝑂(𝑛𝑊)=𝑂(𝑛2𝑚)
입력 길이에 대해 지수다. 이런 것을 유사 다항 시간이라 하고, 3장에서 소수 판정을 볼 때 이미 만났다.
그래서 배낭 문제는 "𝑊 가 작으면 쉽고 𝑊 가 크면 어렵다". 무게가 킬로그램 단위 정수라면 표가 작아 잘 돌아가지만, 그램 단위나 소수점 아래까지 있는 값이라면 표가 감당이 안 된다.
여기서 실무 요령이 하나 나온다. 값을 반올림해 범위를 좁히면 표가 작아지고, 반올림 때문에 생기는 오차의 상한을 계산해 두면 그것이 곧 근사 알고리즘이 된다. 배낭 문제에는 실제로 "원하는 만큼 최적에 가깝게, 그 정확도에 따라 값이 늘어나는" 근사 방법이 있다.
이 장의 정리
이 장은 알고리즘을 하나도 가르치지 않았다. 대신 알고리즘이 있을 수 있는지를 묻는 법을 세웠다. 실무에서 이 어휘가 필요한 이유는 분명하다. 없는 것을 몇 달 동안 찾지 않기 위해서다.
부류
정의
예
P
다항 시간에 푸는 판정 문제
정렬 · 최단경로 · 소수 판정
NP
증명서를 보고 다항 시간에 확인하는 판정 문제
SAT · 클리크 · 해밀턴 경로
co-NP
아니오 쪽에 짧은 증명서가 있는 문제
어떤 수가 소수인가 · 클리크가 없다
NP-난해
NP 의 모든 문제가 이것으로 환원된다
TSP 최적화형 · 정지 문제
NP-완전
NP-난해이면서 NP 에 속한다
SAT · 3SAT · 클리크 · 정점 덮개 · 배낭
용어 · 사실
뜻
판정 문제
답이 예 · 아니오인 문제. 최적화 · 탐색과 다항 시간 안에서 서로 오간다
증명서
답이 예임을 뒷받침하는 짧은 자료. 길이가 다항이어야 한다
다항 시간 환원 𝐴≤𝑝𝐵
A 의 입력을 B 의 입력으로 바꾸는 다항 변환. B 가 A 만큼 어렵다는 뜻
쿡-레빈 정리
SAT 이 NP-완전이다. 맨바닥에서 증명한 유일한 출발점
P=NP 문제
NP-완전 하나가 다항이면 전부 다항이다. 아무도 모른다
c-근사
최적의 c 배를 넘지 않음을 증명해 둔 알고리즘
고정 매개변수 다루기 쉬움
비용이 𝑓(𝑘)⋅𝑛𝑂(1) 꼴. 어려움이 작은 k 에 몰려 있다
유사 다항 시간
값에는 다항, 입력 자릿수에는 지수. 배낭의 𝑂(𝑛𝑊) 가 그렇다
실무의 절차로 정리하면 이렇다. 문제를 만나면 먼저 이미 아는 문제로 환원되는지 본다. 정점 덮개나 배낭이나 SAT 의 모습이 보이면 다항 시간 알고리즘을 찾는 일을 그만두고, 대신 입력의 크기와 구조를 살핀 뒤 근사 · 휴리스틱 · 매개변수화 중에서 고른다.
여기까지가 알고리즘을 설계하는 방법의 블록이다. 다음 블록은 방향이 다르다. 지금까지는 정확한 답을 전제로 값을 따졌는데, 14장은 정확함을 조금 포기하고 공간을 크게 아끼는 자료구조들을 다룬다. 이 장의 "무엇을 포기할 것인가"가 거기서 다른 형태로 되풀이된다.
확률적 자료구조와 공간 색인
13장은 "무엇을 포기할 것인가"로 끝났다. 문제가 너무 어려우면 정확함이나 보장이나 일반성 중 하나를 내놓아야 한다는 이야기였다. 이 장은 같은 물음을 다른 자리에서 되풀이한다. 문제가 어려운 것이 아니라 자료가 너무 클 때다.
5장의 해시 테이블은 "이것이 집합에 있는가"에 평균 𝑂(1) 로 답했다. 값은 키를 통째로 저장하는 것이었다. 키가 1억 개면 키 1억 개만큼의 메모리가 든다. 그것이 감당되지 않을 때 어떻게 하는가.
이 장의 답은 이렇다. 답이 가끔 틀려도 된다면 공간을 수십 배 줄일 수 있다. 다만 아무렇게나 틀리는 것이 아니라 한쪽으로만 틀린다. 그 비대칭을 설계할 줄 아는 것이 이 장의 핵심이다.
답할 질문은 다섯이다. 있는가(블룸 필터·쿠쿠 필터), 몇 종류인가(HyperLogLog), 몇 번 나왔는가(Count-Min Sketch), 그리고 이 근처에 무엇이 있는가(공간 색인). 마지막 하나는 성격이 조금 다르지만 "큰 공간을 다루기 좋은 모양으로 쪼갠다"는 점에서 같은 자리에 있다.
앞에서 가져오는 것은 5장의 좋은 해시 함수와 충돌, 3장의 점근 표기와 기댓값, 그리고 6장의 트리 용어다. 확률 계산은 기초수학의 기댓값과 지수·로그로 충분하다.
정확함을 조금 포기하면 공간이 크게 줄어든다
구체적인 상황에서 시작하자. 웹 크롤러가 이미 방문한 주소를 기억해야 한다. 주소가 1억 개이고 하나가 평균 60바이트라 하자.
<위쪽 막대와 아래쪽 막대가 같은 질문에 답한다. 아래쪽은 키를 저장하지 않고 비트만 남기기 때문에 이만큼 작아진다>[원본 보기]
해시 집합에 넣으면 키만 6 GB 이고 여기에 해시 테이블의 칸과 포인터가 더 붙는다. 그런데 우리가 실제로 필요한 것은 예 또는 아니오 하나뿐이다. 주소 자체를 다시 꺼낼 일은 없다. 그렇다면 주소를 저장할 이유도 없다.
이 장의 자료구조들은 모두 같은 발상 위에 서 있다. 원소를 저장하는 대신 원소가 남긴 흔적만 저장한다. 흔적은 여러 원소가 겹쳐 쓰므로 되돌릴 수 없고, 그래서 가끔 남의 흔적을 자기 것으로 착각한다. 그 착각의 확률을 계산해 감당할 만큼으로 맞추는 것이 설계다. 그러니 자료구조마다 두 가지를 물어야 한다. 어느 방향으로 틀리는가, 그리고 틀릴 확률을 무엇으로 조절하는가.
예제 152
어떤 서비스가 "이 닉네임이 이미 쓰이고 있는가"를 확인한다. 아주 드물게 비어 있는 닉네임을 이미 쓰인다고 답하는 방식과, 아주 드물게 이미 쓰인 닉네임을 비어 있다고 답하는 방식 중 어느 쪽이 받아들일 만한가?
풀이 보기
두 오류가 낳는 결과를 견주면 된다.
비어 있는데 쓰인다고 답하면 이용자가 다른 닉네임을 고른다. 불편하지만 시스템은 온전하다. 그리고 실제로 그 닉네임을 원했다면 한 번 더 확인해 보면 된다.
쓰이고 있는데 비어 있다고 답하면 같은 닉네임을 가진 계정이 둘 생긴다. 자료가 깨진다. 뒤에 나오는 모든 조회가 어느 계정을 가리키는지 알 수 없게 된다.
그러니 앞쪽이 답이다. 이런 오류를 거짓 양성(false positive)이라 하고, 뒤쪽을 거짓 음성(false negative)이라 한다.
여기서 이 장 전체의 쓰임새가 나온다. 확률적 자료구조는 대개 앞단의 거르개로 쓴다. "없다"고 하면 그대로 믿고 끝내고, "있다"고 할 때만 진짜 저장소를 확인한다. 거짓 양성은 한 번 더 확인하는 값으로 끝나지만 거짓 음성이었다면 자료를 잃는다.
설계 판단
세 상황에서 확률적 자료구조를 쓸 것인지 판단하라. (1) 은행 계좌의 잔액 (2) 광고를 이미 본 사람인지 (3) 데이터베이스에서 그 키가 있는 파일을 찾기
풀이 보기
기준은 하나다. 틀린 답이 그 자리에서 되돌릴 수 있는 것인가.
(1) 잔액 — 안 된다. 돈은 근사할 수 있는 것이 아니고, 틀린 값이 그다음 계산으로 번진다. 정확한 자료구조를 쓰고 공간이 부족하면 다른 방법을 찾는다.
(2) 광고 — 쓴다. 이미 본 사람에게 또 보여 주거나, 안 본 사람을 건너뛰는 일이 아주 가끔 생겨도 손해가 광고 하나만큼이다. 대신 대상이 수억 명이라 공간에서 얻는 것이 크다.
(3) 파일 찾기 — 쓴다. 실제로 널리 쓰인다. 파일마다 블룸 필터를 하나 붙여 두고, "없다"고 하면 그 파일을 열지 않는다. "있다"고 했는데 실제로 없으면 파일을 한 번 헛읽는 값만 치른다. 디스크 읽기 수십 번을 없애는 값이 그것이다.
(2)와 (3)의 공통점을 보라. 틀렸을 때 값이 한 번 더 일하는 것으로 끝난다. 이 조건이 성립하지 않으면 확률적 자료구조를 꺼내면 안 된다.
블룸 필터 — 비트 배열 하나로 답하는 ‘있는가’
블룸 필터(Bloom filter)는 이 갈래에서 가장 단순하고 가장 널리 쓰인다. 부품이 둘뿐이다 — 0으로 채운 비트 배열 𝑚 칸과 서로 다른 해시 함수 𝑘 개. 절차도 두 줄이다.
넣기 — 원소를 𝑘 개 해시에 각각 넣어 나온 𝑘 개 자리의 비트를 1로 켠다. 이미 1이면 그대로 둔다.
찾기 — 같은 𝑘 개 자리를 본다. 하나라도 0이면 없는 것이 확실하고, 모두 1이면 있다고 답한다.
<넣기 두 번과 찾기 두 번. 마지막 줄이 거짓 양성이다 — 넣은 적 없는 원소인데 세 자리가 이미 남들에 의해 켜져 있었다>[원본 보기]
↓ python
def 넣기(비트, 키):
for i in range(k):
비트[해시(i, 키) % len(비트)] = 1
def 있는가(비트, 키):
return all(비트[해시(i, 키) % len(비트)] == 1 for i in range(k))
넣기도 찾기도 해시 𝑘 번과 비트 접근 𝑘 번이므로 𝑂(𝑘) 이고, 원소 수 𝑛 과 무관하다.𝑘 는 대개 10 이하의 상수이므로 사실상 상수 시간이다.
왜 "없다"가 확실한지 보자. 원소를 넣었다면 그 𝑘 자리는 반드시 1이 되었을 것이고, 한 번 1이 된 비트는 다시 0이 되지 않는다. 그러니 0을 하나라도 봤다면 그 원소는 넣은 적이 없다. 거짓 음성은 원리상 일어날 수 없다.
반대로 "있다"는 확실하지 않다. 그림의 마지막 줄이 그 경우다. 두리안은 넣은 적이 없는데, 그 세 자리가 사과와 바나나가 켜 둔 비트와 겹쳤다.
거짓 양성률을 구한다
얼마나 자주 그런 일이 생기는지 계산해 보자. 비트가 𝑚 개, 해시가 𝑘 개, 넣은 원소가 𝑛 개다.
첫째, 특정 비트 하나가 끝까지 0으로 남을 확률. 해시 한 번이 그 비트를 켤 확률이 1/𝑚 이므로 안 켤 확률은 1−1/𝑚 이다. 원소 하나가 해시를 𝑘 번 하고 원소가 𝑛 개이므로 해시는 모두 𝑘𝑛 번 일어난다. 그러니
𝑃(그비트가0)=(1−1𝑚)𝑘𝑛
둘째, 이 식을 보기 좋게 바꾼다. 기초수학에서 (1−1𝑚)𝑚→𝑒−1 임을 배웠다. 지수를 그 꼴로 묶으면
(1−1𝑚)𝑘𝑛=[(1−1𝑚)𝑚]𝑘𝑛/𝑚≈𝑒−𝑘𝑛/𝑚
셋째, 비트가 1일 확률은 그 여집합이므로 1−𝑒−𝑘𝑛/𝑚 이고, 넷째, 거짓 양성은 𝑘 자리가 모두 1인 경우이므로 각 자리가 독립이라고 보면
𝑝≈(1−𝑒−𝑘𝑛/𝑚)𝑘
마지막의 독립 가정은 엄밀하게는 참이 아니다. 한 비트가 켜져 있다는 사실은 다른 비트에 대한 정보를 아주 조금 준다. 다만 𝑚 이 크면 그 차이가 무시할 만하고, 실제 값보다 아주 조금 낮게 잡히는 것으로 알려져 있다. 공학적으로 쓰기에 충분하다.
해시를 몇 개 써야 하는가
𝑘 를 늘리면 두 가지가 동시에 일어난다. 확인해야 할 자리가 늘어 우연히 다 맞기 어려워지고, 동시에 켜지는 비트가 늘어 배열이 빨리 포화된다. 반대 방향이므로 어딘가에 최적이 있다.
<곡선마다 바닥이 하나씩 있다. 원소당 비트 수를 정하면 최적 해시 개수와 그때의 오류율이 함께 정해진다>[원본 보기]
𝑔=𝑒−𝑘𝑛/𝑚 로 두면 𝑝=(1−𝑔)𝑘 이고, 𝑘=−𝑚𝑛ln𝑔 이므로
ln𝑝=𝑘ln(1−𝑔)=−𝑚𝑛ln𝑔ln(1−𝑔)
ln𝑝 를 가장 작게 하려면 ln𝑔ln(1−𝑔) 를 가장 크게 해야 한다. 그런데 이 곱은 𝑔 와 1−𝑔 를 맞바꿔도 값이 같다. 대칭인 함수이므로 가운데인 𝑔=1/2 에서 극값을 갖는다. 두 인수가 모두 음수라 곱이 양수이고, 양 끝에서 0으로 가므로 그 극값이 최대다.
𝑒−𝑘𝑛/𝑚=1/2 를 𝑘 에 대해 풀면
𝑘∗=𝑚𝑛ln2≈0.693𝑚𝑛
그때의 오류율은 𝑝=(1−1/2)𝑘=2−𝑘 이다. 결과가 두 가지를 말해 준다. 가장 좋은 상태는 비트 배열의 정확히 절반이 켜져 있을 때이고 — 채워지는 정도로 필터의 건강을 잴 수 있다 — 최적 지점에서 해시를 하나 더 쓸 때마다 오류율이 절반이 된다.
거꾸로 원하는 오류율에서 필요한 비트 수를 구할 수도 있다. 𝑝=2−(𝑚/𝑛)ln2 에 로그를 취하면
𝑚𝑛=log2(1/𝑝)ln2≈1.44log21𝑝
𝑝=1% 면 원소당 약 9.6비트, 𝑝=0.1% 면 약 14.4비트다. 오류율을 10분의 1로 줄이는 값이 원소당 4.8비트뿐이라는 점이 놀랍다. 앞 절의 120 MB 가 여기서 나온 값이다.
예제 154
원소를 1000만 개 담고 거짓 양성률을 1% 이하로 하려 한다. 비트 배열의 크기와 해시 개수를 정하라.
풀이 보기
묻는 것은 𝑚 과 𝑘 이고, 아는 것은 𝑛=107 과 𝑝≤0.01 이다.
비트 수부터.𝑚/𝑛≈1.44log2(1/0.01)=1.44×6.64≈9.6 이므로
𝑚≈9.6×107비트=1.2×107바이트≈12MB
해시 개수는𝑘∗=9.6×0.693≈6.65 이므로 정수로 6 또는 7이다. 앞의 곡선이 바닥 근처에서 평평하므로 어느 쪽이든 큰 차이가 없다.
검산하자.𝑘=7,𝑚/𝑛=9.6 이면 𝑒−7/9.6=𝑒−0.729=0.482, (1−0.482)7=0.5187≈0.0100. 목표에 정확히 맞는다.
견주어 보자. 같은 1000만 개를 해시 집합에 담으면 키만으로도 수백 MB 다. 12 MB 는 CPU 캐시에 가까운 크기라 조회가 디스크는커녕 주 메모리도 거의 건드리지 않는다. 공간을 줄인 것이 속도로도 돌아온다.
예제 155
블룸 필터에서 원소를 지우려고 그 𝑘 자리의 비트를 0으로 되돌리면 무엇이 잘못되는가? 지울 수 있게 고치려면?
풀이 보기
앞의 그림에서 사과는 2 · 9 · 17번을, 바나나는 5 · 9 · 21번을 켰다. 9번을 둘이 함께 쓰고 있다.
바나나를 지우려고 5 · 9 · 21을 0으로 만들면 9번이 꺼지고, 그 순간 사과를 찾아도 없다고 답하게 된다. 넣은 적 있는 것을 없다고 하는 거짓 음성이고, 이 자료구조가 절대 하지 않기로 한 일이다.
고치는 방법은 비트 대신 작은 계수기를 두는 것이다. 넣을 때 1 올리고 지울 때 1 내린다. 0보다 큰지를 보면 "있는가"가 된다. 이것을 계수 블룸 필터(counting Bloom filter)라 한다.
값이 든다. 계수기 하나에 4비트를 쓴다면 공간이 네 배가 된다. 게다가 계수기가 넘치면(4비트면 15에서) 그 뒤로는 정확한 감소가 불가능해진다.
다음 절의 쿠쿠 필터는 같은 문제를 다른 방식으로 푼다. 흔적을 겹쳐 쓰지 않고 각 원소의 지문을 따로 보관하는 것이다.
예제 156
예상보다 원소를 훨씬 많이 넣으면 어떻게 되는가? 실무에서 이것을 어떻게 다루는가?
풀이 보기
𝑚 과 𝑘 는 만들 때 정해져 바뀌지 않는다. 𝑛 만 커지면 𝑝≈(1−𝑒−𝑘𝑛/𝑚)𝑘 에서 𝑝 가 계속 오른다.
앞의 예에서 𝑚/𝑛=9.6,𝑘=7 로 잡았는데 원소를 두 배 넣으면 𝑚/𝑛 이 4.8이 된다. 𝑒−7/4.8=0.232, 0.7687≈0.158 — 1%가 16%가 된다. 열 배를 넣으면 거의 모든 조회가 "있다"고 답해 필터가 무의미해진다.
실무의 대응은 계층을 쌓는 것이다. 처음 필터가 목표 크기에 이르면 그대로 두고, 더 큰 새 필터를 그 위에 얹어 이후의 원소는 거기에 넣는다.
찾기 — 모든 계층을 확인해 하나라도 "있다"고 하면 있다고 답한다.
넣기 — 먼저 모든 계층을 확인해 전부 "없다"일 때만 마지막 계층에 넣는다.
값은 찾기가 계층 수에 비례해 느려지고 전체 거짓 양성률이 계층별 오류의 합에 가까워지는 것이다. 그래서 계층마다 오류율을 절반씩 줄여 잡아 총합이 수렴하게 만든다.
여기서 얻을 교훈은 블룸 필터를 만들 때 𝑛 을 정직하게 잡아야 한다는 것이다. 이 값 하나가 이후 모든 성질을 정한다.
쿠쿠 필터 — 지우고 싶다면
블룸 필터가 지우지 못하는 이유는 흔적을 겹쳐 쓰기 때문이었다. 그렇다면 겹치지 않게 하면 된다. 쿠쿠 필터(cuckoo filter)는 원소마다 짧은 지문(fingerprint)을 따로 저장한다. 그러려면 먼저 쿠쿠 해싱을 알아야 한다.
쿠쿠 해싱 — 자리가 차 있으면 밀어낸다
5장의 개방 주소법은 자리가 차 있으면 다른 빈 자리를 찾아 옮겨 갔다. 그래서 찾을 때도 여러 칸을 훑어야 했다. 쿠쿠 해싱(cuckoo hashing)은 반대로 한다. 자기 자리를 차지하고 원래 있던 것을 내쫓는다.
뻐꾸기가 남의 둥지에 알을 낳고 원래 알을 밀어내는 데서 온 이름이다.
<밀려난 키는 자기의 다른 자리로 가고, 거기도 차 있으면 다시 누군가를 밀어낸다. 빈 자리를 만나면 연쇄가 끝난다>[원본 보기]
키마다 갈 수 있는 자리가 정확히 두 곳이다. 표 둘에 서로 다른 해시를 쓰거나, 한 표에 두 해시를 쓴다. 찾기는 그 두 자리만 보면 끝이고, 넣기는 첫 자리가 비었으면 넣되 차 있으면 그 자리를 빼앗고 원래 있던 키를 그 키의 다른 자리로 보낸다. 그 자리도 차 있으면 같은 일을 되풀이한다.
찾기가 최악에도 두 번이라는 것이 이 방식의 가장 큰 장점이다. 5장의 개방 주소법은 적재율이 높아지면 최악 탐사가 길어졌지만, 여기서는 표가 얼마나 찼든 두 번이다.
값은 넣기에 있다. 밀어내기가 고리를 돌아 끝나지 않을 수 있다. 그래서 정해진 횟수까지만 시도하고 그 뒤에는 표를 키워 전부 다시 넣거나 삽입을 거절한다. 두 자리씩만 있는 구조에서 이 일이 드물게 일어나려면 표의 절반 정도까지만 채워야 한다.
지문을 저장하면 지울 수 있다
쿠쿠 필터는 위 구조에서 키 대신 키의 지문을 넣는다. 지문은 해시의 앞 몇 비트, 흔히 8비트다.
여기서 문제가 생긴다. 밀어내기를 하려면 밀려나는 것의 다른 자리를 알아야 하는데, 우리가 가진 것은 원래 키가 아니라 지문뿐이다. 지문에서 원래 키를 되찾을 수는 없다.
해결이 영리하다. 두 자리를 이렇게 정한다.
ℎ1(𝑥)=hash(𝑥),ℎ2(𝑥)=ℎ1(𝑥)⊕hash(fp(𝑥))
⊕ 는 비트별 배타적 논리합(XOR)이다. 같은 것을 두 번 XOR 하면 원래대로 돌아온다는 성질이 있으므로
ℎ1(𝑥)=ℎ2(𝑥)⊕hash(fp(𝑥))
도 성립한다. 곧 지금 어느 자리에 있는지와 지문만 알면 다른 자리를 계산할 수 있다. 원래 키가 필요 없다.
이제 삭제가 된다. 두 자리를 보고 그 지문을 지우면 끝이다. 남의 흔적을 건드리지 않는다.
대신 새 오류가 생긴다. 지문이 같은 다른 키가 있으면 그것을 자기로 착각한다. 지문이 𝑓 비트이고 버킷 하나에 지문을 𝑏 개까지 담는다면, 찾기가 훑는 지문이 2𝑏 개이므로
𝑝≈2𝑏2𝑓
𝑓=8 로 고정한 구현에서 𝑏 를 바꾸면 이렇게 된다.
버킷 크기 b
채울 수 있는 비율
거짓 양성률
1
약 55%
약 0.78%
2
기본값
약 1.6%
3
약 80%
약 2.34%
4
약 95%
약 3.12%
버킷을 크게 잡을수록 표를 빽빽하게 채울 수 있지만 헛맞는 확률이 오른다. 버킷에 자리가 여럿이면 밀어내기 없이 들어갈 수 있는 경우가 늘기 때문이다.
예제 157
쿠쿠 필터와 블룸 필터 중 어느 쪽이 메모리를 덜 쓰는가?
풀이 보기
원소당 비트 수로 견주자.
블룸 필터는 1.44log2(1/𝑝) 비트다.
쿠쿠 필터는 지문 𝑓 비트를 저장하되 표를 다 채우지 못하므로 𝑓/𝛼 비트다. 𝑝≈2𝑏/2𝑓 에서 𝑓=log2(2𝑏/𝑝) 이므로
log2(1/𝑝)+log2(2𝑏)𝛼
계수를 보자. 블룸은 앞에 1.44가 붙고, 쿠쿠는 1/𝛼 가 붙는다. 𝑏=4,𝛼=0.95 면 1/𝛼≈1.05 로 1.44보다 작다. 대신 분자에 log28=3 비트가 더해진다.
그러니 오류율을 아주 낮게 잡을수록 쿠쿠가 유리하다.log2(1/𝑝) 가 커지면 앞의 계수 차이가 지배하고 더해진 3비트는 묻힌다. 반대로 오류율이 헐거우면 그 3비트가 커 보여 블룸이 낫다.
갈리는 지점이 대략 𝑝=3% 근처다. 다만 실제 판단 기준은 대개 메모리가 아니다. 삭제가 필요한가, 찾기의 최악을 보장해야 하는가, 구현을 얼마나 감당할 수 있는가가 먼저다.
예제 158
쿠쿠 필터에 같은 키를 다섯 번 넣으면 어떻게 되는가? 그리고 넣은 적 없는 키를 지우면?
풀이 보기
같은 키를 다섯 번 넣으면 지문이 다섯 개 저장된다. 블룸 필터는 같은 비트를 다시 켜는 것이라 아무 일도 없었지만, 여기서는 자리를 다섯 칸 쓴다.
문제가 둘이다. 표가 예상보다 빨리 차고, 지우기를 한 번 하면 하나만 지워져 여전히 있다고 답한다. 넣은 횟수만큼 지워야 사라진다.
뒤집어 보면 이것을 개수 세기로 쓸 수 있다는 뜻이기도 하다. 같은 지문이 몇 개인지 세면 대략의 빈도가 나온다.
넣은 적 없는 키를 지우면 더 위험하다. 그 키의 지문이 우연히 다른 키의 지문과 같으면 남의 것을 지워 버린다. 그 순간 거짓 음성이 생기고, 이 자료구조의 가장 중요한 보증이 깨진다.
그래서 규칙이 하나 붙는다. 넣은 것만 지운다. 넣은 기록을 확실히 아는 쪽에서만 삭제를 부를 수 있어야 한다.
HyperLogLog — 몇 종류인가
이제 다른 질문이다. "서로 다른 것이 몇 개인가." 하루 동안 방문한 사람이 몇 명인지, 검색어가 몇 종류인지 같은 것이다. 중복을 빼야 하므로 세는 것만으로는 안 되고, 본 것을 다 기억하면 원소 수만큼 메모리가 든다. HyperLogLog는 이 문제를 아주 적은 메모리로 추정한다. 발상이 기발하다.
<좋은 해시가 만드는 비트열은 동전 던지기와 같다. 앞머리에 0 이 세 개나 붙은 값을 봤다면 꽤 많이 뽑아 본 것이다>[원본 보기]
해시 함수가 고르게 흩어진다면 그 출력의 각 비트는 공정한 동전과 같다. 그러면
𝑃(선행0이𝑗개이상)=12𝑗
이다. 선행 0이 세 개인 값을 보려면 평균적으로 여덟 번쯤 뽑아야 한다. 이것을 거꾸로 쓴다. 지금까지 본 최대 선행 0이 𝑗 라면 원소가 대략 2𝑗+1 개쯤 있었을 것이다.
그리고 같은 원소는 몇 번을 넣어도 해시가 같다. 최대 선행 0이 변하지 않으므로 중복이 저절로 무시된다. 이것이 이 방법의 핵심이다.
이대로는 쓸 수 없다. 흔들림이 너무 크고(운 나쁘게 앞머리가 긴 값 하나가 일찍 걸리면 추정이 통째로 두 배씩 뛴다) 표현할 수 있는 값이 1, 2, 4, 8 … 뿐이라 그 사이를 나타낼 방법이 없다. 해결책은 하나뿐인 관측을 여럿으로 쪼개는 것이다.
<해시의 앞부분으로 버킷을 고르고 나머지에서 선행 0 을 센다. 산술평균은 큰 값 하나에 끌려가지만 조화평균은 그렇지 않다>[원본 보기]
해시 값을 둘로 나눈다. 앞 몇 비트로 버킷을 고르고, 나머지에서 선행 0을 세어 그 버킷의 최댓값만 갱신한다. 흔히 쓰는 설정은 64비트 해시에서 앞 14비트를 버킷 번호로 쓰는 것이다. 버킷이 214=16384 개다.
나머지 50비트의 선행 0은 최대 50이므로 6비트면 담을 수 있다. 그래서 전체 크기가
16384×6비트=98304비트=12KB
다. 이 12 KB 하나로 원소가 수억 개여도 종류 수를 추정한다.
버킷별 추정을 묶을 때 조화평균을 쓴다. 산술평균은 큰 값 하나가 전체를 끌어올리지만, 조화평균은 역수를 더하므로 큰 값의 영향이 작다. 이 이름의 "로그로그"는 저장하는 값이 loglog𝑛 규모라는 뜻이고, "하이퍼"는 조화평균을 쓴다는 뜻이다.
오차는 버킷 수 𝑚 에 대해
표준오차≈1.04√𝑚
이다. 𝑚=16384 면 1.04/128≈0.81% 다. 정확도를 두 배로 하려면 버킷을 네 배로 늘려야 한다는 뜻이기도 하다.
예제 159
위 식으로 (1) 버킷 2048개일 때의 오차와 그때의 메모리, (2) 오차 0.2%를 원할 때 필요한 버킷 수를 구하라.
곧 1.5 KB 로 오차 2% 안에서 크기를 잰다. 원소가 10억 개여도 같은 1.5 KB 다.
(2)1.04/√𝑚=0.002 에서 √𝑚=520, 𝑚≈270000. 메모리는 약 200 KB 다.
값을 견주자. 오차를 2.3%에서 0.81%로 줄이는 데 1.5 KB 가 12 KB 가 되었고(8배), 다시 0.2%로 줄이는 데 200 KB 가 든다(다시 16배). 정확도의 값이 제곱으로 오른다.
그래서 실무에서는 대개 1% 안팎에서 멈춘다. 방문자 수를 소수점 아래까지 알아야 할 이유가 별로 없다.
예제 160
HyperLogLog 로 "오늘 방문자"와 "어제 방문자"를 각각 재 두었다. 이틀 동안의 방문자 수를 구할 수 있는가? 두 날 모두 방문한 사람 수는?
풀이 보기
합집합은 구할 수 있다. 두 구조의 버킷별 최댓값을 취하면 두 집합을 합친 것에 대해 잰 것과 정확히 같아진다. 최댓값은 어느 쪽에서 왔든 상관없기 때문이다. 새 오차가 늘지도 않는다.
이것이 실무에서 아주 값진 성질이다. 시간대별로 하나씩 만들어 두면 어떤 구간이든 합쳐서 그 구간의 순 방문자를 낼 수 있다.
교집합은 다르다. 버킷별 최솟값을 취하는 것은 교집합을 재지 않는다. 두 집합에 없는 원소가 우연히 두 버킷을 모두 채웠을 수 있다.
포함-배제로 우회할 수는 있다. |𝐴∩𝐵|=|𝐴|+|𝐵|−|𝐴∪𝐵| 인데, 세 값이 모두 추정치라 오차가 더해진다. 교집합이 작을 때는 큰 수 둘의 차이라 상대 오차가 걷잡을 수 없이 커진다.
그러니 교집합이 필요하면 다른 도구를 써야 한다. 여기서 확인할 것은 자료구조가 지원하는 연산과 지원하지 않는 연산을 정확히 알아야 한다는 점이다.
Count-Min Sketch — 몇 번 나왔는가
세 번째 질문은 빈도다. "이 검색어가 오늘 몇 번 쓰였는가", "이 IP 에서 요청이 몇 번 왔는가". 종류가 수억 개면 계수기를 종류마다 둘 수 없다.
Count-Min Sketch는 블룸 필터와 같은 발상을 계수기에 적용한다. 비트 대신 계수기 격자를 두고, 행마다 다른 해시로 자리를 정한다.
<올릴 때는 행마다 한 칸씩 전부 올리고, 읽을 때는 그 칸들 중 가장 작은 것을 고른다. 어느 칸에도 남의 몫이 얹혀 있을 뿐 빠지지는 않는다>[원본 보기]
올릴 때는 행마다 그 원소의 자리를 구해 계수기를 1 올리고, 읽을 때는 같은 자리들의 값 중 가장 작은 것을 답한다.
왜 최솟값인가. 칸 하나에는 그 칸으로 해시된 모든 원소의 몫이 함께 쌓인다. 그러니 어느 칸이든 참값보다 작을 수 없고, 가장 작은 칸이 남의 몫이 가장 적게 얹힌 증언이다.
여기서 이 자료구조의 성격이 나온다. 오차가 한쪽으로만 생긴다. 과대 추정은 있어도 과소 추정은 없다. 블룸 필터가 거짓 양성만 내던 것과 같은 모양의 비대칭이다.
열 수 𝑤 와 행 수 𝑑 를 정하면 보장이 나온다. 전체 개수를 𝑁 이라 할 때
ˆ𝑎𝑖≤𝑎𝑖+𝜀𝑁을확률1−𝛿로보장
하려면 𝑤=⌈𝑒/𝜀⌉, 𝑑=⌈ln(1/𝛿)⌉ 이면 된다. 유도는 다음 예제에서 한다.
실무에서 중요한 것은 오차가 𝑁 에 비례한다는 점이다. 전체가 1억이고 𝜀=0.001 이면 오차가 10만까지 날 수 있다. 10만보다 훨씬 작은 빈도는 이 구조로 알 수 없다. 그래서 Count-Min Sketch 는 "가장 많이 나온 몇 개"를 찾는 데 쓰지, 드문 것을 세는 데 쓰지 않는다. 자료가 치우쳐 있을수록 잘 맞는 이유가 이것이다.
예제 161
위의 보장을 유도하라. 필요한 도구는 그 자리에서 세워도 된다.
풀이 보기
한 행 𝑗 만 보자. 원소 𝑖 의 칸에 얹힌 남의 몫을 𝑋𝑗 라 하자.
기댓값을 구한다. 다른 원소 𝑘 가 𝑖 와 같은 칸으로 갈 확률이 1/𝑤 이므로
𝐸[𝑋𝑗]=∑𝑘≠𝑖𝑎𝑘⋅1𝑤≤𝑁𝑤
여기서 도구가 하나 필요하다. 음이 아닌 확률변수 𝑋 에 대해
𝑃(𝑋≥𝑎)≤𝐸[𝑋]𝑎
가 성립한다(마르코프 부등식). 증명은 한 줄이다. 𝐸[𝑋] 는 모든 값에 확률을 곱해 더한 것인데, 𝑋≥𝑎 인 부분만 남기고 그 값들을 전부 𝑎 로 낮춰도 여전히 더 작으므로 𝐸[𝑋]≥𝑎𝑃(𝑋≥𝑎) 다.
적용한다.
𝑃(𝑋𝑗≥𝜀𝑁)≤𝑁/𝑤𝜀𝑁=1𝜀𝑤
𝑤=⌈𝑒/𝜀⌉ 로 두면 이 값이 1/𝑒 이하다.
행을 여럿 두면. 최솟값이 실패하려면 모든 행이 동시에 실패해야 한다. 행마다 해시가 독립이므로 확률이 (1/𝑒)𝑑=𝑒−𝑑 다. 𝑑=⌈ln(1/𝛿)⌉ 이면 ≤𝛿 다.
구조를 보면 익숙할 것이다. 기댓값으로 평균을 잡고, 마르코프로 꼬리를 누르고, 독립인 시도를 겹쳐 실패 확률을 지수로 떨어뜨린다. 확률적 알고리즘에서 되풀이해 나오는 세 걸음이다.
설계 판단
초당 수십만 건이 들어오는 서비스에서 "요청을 가장 많이 보내는 IP 열 개"를 실시간으로 알고 싶다. 어떻게 하겠는가?
풀이 보기
정확히 하려면 IP 마다 계수기를 두고 정렬해야 한다. IP 종류가 수천만이면 메모리도 정렬 값도 감당되지 않는다.
Count-Min Sketch 로 빈도를 재고, 상위 열 개는 작은 힙으로 따로 관리한다.
요청이 오면 스케치를 갱신하고 그 IP 의 추정 빈도를 읽는다.
6장의 최소 힙에 상위 열 개를 담아 두고, 추정 빈도가 힙의 최솟값보다 크면 바꿔 넣는다.
스케치가 104 열 × 5행이면 계수기 5만 개, 4바이트씩 잡아 200 KB 다. 힙은 열 칸이다.
이 조합이 왜 맞는지가 중요하다. 상위 열 개는 빈도가 큰 것들이라 𝜀𝑁 만큼의 과대 추정이 있어도 순위가 잘 뒤집히지 않는다. Count-Min Sketch 가 잘하는 일과 우리가 묻는 것이 정확히 맞는다.
반대로 "정확히 한 번만 요청한 IP 를 찾아라" 였다면 이 도구는 쓸모없다. 작은 값일수록 남의 몫에 묻히기 때문이다.
실무 요령도 하나. 추정값이 𝜀𝑁 보다 작으면 그 값을 믿지 말고 버려야 한다. 문턱 아래는 잡음이다.
공간 색인 — 이 근처에 무엇이 있는가
마지막 질문은 성격이 다르다. "내 위치에서 1 km 안에 있는 가게를 찾아라." 이런 질문에는 해시 테이블이 소용없다. 해시는 순서도 이웃 관계도 지우기 때문이다. 5장에서 "해시 테이블은 범위 질의를 못 한다"고 한 것이 여기서 문제가 된다.
그리고 위치는 2차원이다. 6장의 이진 탐색 트리는 한 줄로 세울 수 있는 것에만 쓸 수 있었다. 위도로 정렬하면 경도가 흩어지고 그 반대도 마찬가지다.
이 절의 방법들은 모두 같은 전략을 쓴다. 공간을 칸으로 쪼갠 뒤 칸에 번호를 붙여 1차원으로 만든다. 차이는 어떻게 쪼개고 어떻게 번호를 붙이는가에 있다.
균등 격자 — 가장 단순한 방법
위도와 경도를 각각 같은 간격으로 나눈다. 어떤 점이 어느 칸인지는 나눗셈 두 번으로 나온다. 근처를 찾으려면 자기 칸과 이웃 여덟 칸을 보면 된다.
문제는 자료가 고르게 퍼져 있지 않다는 것이다. 사막이나 대양을 담은 칸은 비어 있고 도심을 담은 칸에는 수십만 개가 들어간다. 빈 칸은 자리만 차지하고, 붐비는 칸은 결국 그 안을 전부 훑어야 한다.
지오해시 — 재귀로 나누고 접두어를 주소로
지오해시(geohash)는 나누기를 재귀로 한다. 전체를 넷으로 나눠 각 칸에 0부터 3까지를 주고, 각 칸을 다시 넷으로 나눠 뒤에 한 자리를 더 붙인다. 그러면 주소가 글자열이 되고 자릿수가 곧 정밀도가 된다. 이 방식의 값진 성질은 이것이다. 접두어가 같으면 같은 큰 칸 안에 있다. 곧 문자열 접두어 검색이 그대로 범위 질의가 된다. 기존의 문자열 색인 위에 공간 검색을 얹을 수 있다는 뜻이라 실무에서 아주 편하다.
다만 역은 성립하지 않는다. 지도에서 붙어 있어도 접두어가 완전히 다를 수 있다. 큰 칸의 경계를 사이에 두고 마주 본 두 점이 그렇다.
왜 그런지는 칸에 번호를 붙이는 순서를 그려 보면 바로 보인다.
<왼쪽 곡선은 한 걸음에 여러 칸을 건너뛰는 자리가 있다. 그 자리가 바로 지도에서는 가까운데 주소가 멀어지는 곳이다>[원본 보기]
왼쪽이 Z-곡선이다. 위도와 경도의 비트를 번갈아 끼워 만든 순서이고 지오해시가 쓴다. 오른쪽이 힐베르트 곡선이다. 사분면마다 방향을 뒤집어 언제나 옆칸으로만 움직인다.
쿼드트리 — 붐비는 곳만 잘게
재귀로 나누되 끝까지 다 나누지 않는다. 칸 하나에 든 점이 정해 둔 수를 넘을 때만 그 칸을 넷으로 쪼갠다. 이것이 쿼드트리(quadtree)다.
<왼쪽은 빈 칸에도 붐비는 칸에도 같은 크기를 쓴다. 오른쪽은 점이 몰린 곳만 잘게 나뉘어 어느 칸을 열어도 든 것이 비슷하다>[원본 보기]
구조가 트리다. 뿌리가 전체 공간이고 각 노드가 자식 넷을 갖는다. 6장의 트리 용어가 그대로 쓰인다. 점을 찾으려면 뿌리에서 내려가면 되고, 깊이가 그 자리의 밀도에 따라 달라진다.
얻는 것은 고른 부담이다. 어느 잎을 열어도 든 점의 수가 문턱 이하다. 값은 자리 계산이 나눗셈 한 번이 아니라 트리를 타고 내려가는 일이 된다는 것, 그리고 점이 들고 날 때 쪼개기와 합치기가 일어난다는 것이다.
S2 — 힐베르트 곡선으로
구글의 S2는 지구를 정육면체 여섯 면에 투영한 뒤 각 면을 재귀로 나누고 힐베르트 곡선으로 번호를 매긴다.
왜 굳이 힐베르트인가. 앞의 그림에서 봤듯 이 곡선은 이웃을 끊지 않는다. 그래서 "반경 1 km 안" 같은 영역을 번호 구간으로 덮을 때 구간이 덜 쪼개진다. Z-곡선이라면 같은 영역을 훨씬 많은 조각으로 나눠 읽어야 한다.
정육면체에 투영하는 이유도 있다. 위도·경도를 그대로 나누면 극지방으로 갈수록 칸의 실제 넓이가 급격히 줄어드는데 여섯 면으로 펴면 그 편차가 훨씬 작아진다. 그래서 반경 몇 km 안의 대상 찾기나, 학교 앞 같은 영역을 미리 정해 두고 드나듦을 감지하는 일(지오펜스)에 쓴다.
예제 163
배달 앱이 "내 위치에서 3 km 안의 가게"를 찾는다. 균등 격자로 한다면 칸 크기를 얼마로 잡겠는가? 너무 크거나 너무 작으면 각각 무엇이 나빠지는가?
풀이 보기
절차를 보면 답이 나온다. 반경을 덮는 칸들을 모두 읽고, 그 안의 가게마다 실제 거리를 재서 3 km 를 넘는 것을 버린다.
칸이 너무 크면(예: 20 km) 칸 하나만 읽어도 되지만 그 안에 3 km 밖의 가게가 훨씬 많이 들어 있다. 버리려고 읽는 것이 대부분이다.
칸이 너무 작으면(예: 100 m) 반경 3 km 를 덮는 데 칸이 수천 개 필요하다. 읽는 횟수 자체가 값이 된다. 저장소가 원격이면 왕복이 수천 번이다.
균형은 칸 크기를 질의 반경과 비슷하게 잡는 데서 온다. 3 km 짜리 칸이면 반경을 덮는 데 3×3 에서 4×4 칸이면 되고 헛읽는 넓이도 반경 원의 두세 배 안이다.
그런데 여기서 진짜 문제가 드러난다. 질의 반경이 여러 가지면 칸 크기 하나로 맞출 수 없다. 500 m 질의와 20 km 질의를 같은 격자로 하면 한쪽은 반드시 손해다. 그래서 실무에서는 정밀도가 다른 여러 층을 함께 두거나, 층이 자동으로 갈리는 쿼드트리·S2 로 간다.
예제 164
지오해시 주소 두 개의 접두어가 다섯 자 일치한다. 두 지점이 가깝다고 말할 수 있는가? 반대로 두 지점이 아주 가까우면 접두어가 일치하는가?
풀이 보기
앞의 것은 그렇다. 접두어 다섯 자가 같다는 것은 다섯 번째 단계의 같은 칸에 들어 있다는 뜻이고, 그 칸의 크기가 두 지점 사이 거리의 상한이 된다.
뒤의 것은 아니다. 두 지점이 칸 경계를 사이에 두고 마주 보고 있다면 거리가 1 m 여도 접두어가 첫 자부터 다를 수 있다. 앞의 곡선 그림에서 한 걸음에 멀리 건너뛰던 자리가 바로 이것이다.
그러니 지오해시로 근처를 찾을 때 자기 칸만 보면 안 된다. 이웃한 여덟 칸을 함께 봐야 한다. 지오해시 구현이 대개 "이웃 칸 구하기"를 함께 제공하는 이유가 이것이다.
이것은 지오해시만의 흠이 아니다. 2차원을 1차원으로 줄 세우면 이웃 관계를 완벽히 보존할 수 없다. 힐베르트 곡선은 그 손실을 줄일 뿐 없애지는 못한다.
설계 판단
전국의 편의점 5만 개와 전국의 배달 기사 실시간 위치 30만 개를 각각 색인해야 한다. 같은 구조를 쓰겠는가?
풀이 보기
두 자료의 성격이 다르다는 점부터 보자.
편의점은 거의 움직이지 않는다. 하루에 몇 개가 생기고 없어질 뿐이다. 도심에 몰려 있으므로 쿼드트리나 S2 로 미리 잘 나눠 두면 오래 쓴다. 만드는 값이 커도 한 번만 치른다.
기사 위치는 몇 초마다 바뀐다. 30만 개가 계속 움직이면 쿼드트리는 쪼개기와 합치기가 끊임없이 일어난다. 트리를 고치는 값이 조회하는 값보다 커진다.
그래서 움직이는 것에는 대개 균등 격자가 낫다. 갱신이 "옛 칸에서 빼고 새 칸에 넣기"로 끝나고, 그 계산이 나눗셈 두 번이다. 칸이 좀 불균등해도 갱신이 압도적으로 많으면 그쪽이 이긴다.
판단의 기준이 읽기와 쓰기의 비율이라는 것이 요점이다. 4장에서 자료구조를 고를 때 썼던 기준이 여기서도 그대로 쓰인다.
실무에서는 섞어 쓰기도 한다. 편의점은 쿼드트리에, 기사는 격자에 두고 질의할 때 둘을 각각 조회한 뒤 합친다. 하나의 구조로 통일해야 할 이유는 없다.
이 장의 정리
네 자료구조가 모두 같은 모양이다. 원소를 저장하는 대신 해시가 남긴 흔적을 저장하고, 흔적이 겹치는 만큼 한쪽 방향으로만 틀린다. 무엇을 묻느냐가 어느 구조를 쓸지 정한다.
<질문이 넷이고 답이 넷이다. 화살표 아래에 무엇을 포기하는지 적혀 있다 — 고르는 일은 결국 그것을 견주는 일이다>[원본 보기]
구조
무엇에 답하는가
어느 쪽으로 틀리는가
삭제
대표 크기
블룸 필터
있는가
거짓 양성만
안 된다
원소당 약 10비트로 오류 1%
쿠쿠 필터
있는가
거짓 양성만
된다
지문 8비트 ÷ 적재율
HyperLogLog
몇 종류인가
양쪽 (표준 오차)
안 된다
12 KB 로 오차 0.81%
Count-Min Sketch
몇 번 나왔는가
과대 추정만
된다 (조심해서)
𝑤×𝑑 개 계수기
식 · 값
뜻
𝑝≈(1−𝑒−𝑘𝑛/𝑚)𝑘
블룸 필터의 거짓 양성률. m 비트, k 해시, n 원소
𝑘∗=(𝑚/𝑛)ln2
최적 해시 개수. 그때 비트의 절반이 켜져 있다
𝑚/𝑛≈1.44log2(1/𝑝)
오류율 p 를 얻는 데 드는 원소당 비트 수
𝑝≈2𝑏/2𝑓
쿠쿠 필터의 거짓 양성률. 지문 f 비트, 버킷 크기 b
1.04/√𝑚
HyperLogLog 의 표준 오차. m 은 버킷 수
𝑤=⌈𝑒/𝜀⌉,𝑑=⌈ln(1/𝛿)⌉
Count-Min Sketch 의 오차 εN 을 확률 1−δ 로 보장
마르코프 부등식
음이 아닌 X 에 대해 𝑃(𝑋≥𝑎)≤𝐸[𝑋]/𝑎
공간 색인
어떻게 나누는가
강점
약점
균등 격자
같은 간격으로 자른다
자리 계산이 나눗셈 두 번. 갱신이 싸다
자료가 몰리면 칸마다 부담이 들쭉날쭉
지오해시
재귀로 넷씩. 주소가 문자열
접두어 검색이 곧 범위 질의
Z-곡선이라 이웃이 끊기는 자리가 있다
쿼드트리
붐비는 칸만 넷으로
어느 잎을 열어도 든 것이 비슷하다
움직이는 자료에는 쪼개기·합치기 값이 크다
S2
정육면체 여섯 면 + 힐베르트 곡선
이웃이 끊기지 않아 반경 질의에 좋다
구현이 무겁고 좌표 변환이 필요하다
마지막으로 이 장 전체에 걸리는 경고를 하나 남긴다. 확률적 자료구조는 어느 것도 원소 자체를 되돌려 주지 않는다. 넣은 것의 목록이 필요해질 가능성이 조금이라도 있다면 진짜 저장소를 따로 두어야 한다.
다음 장은 전혀 다른 종류의 어려움을 다룬다. 지금까지는 실행이 한 줄로 이어진다고 가정했다. 15장은 그 가정을 버린다. 여럿이 동시에 같은 자료를 건드릴 때 무엇이 무너지는가.
지금까지 열두 장 동안 한 가지를 조용히 가정하고 있었다. 명령이 한 줄로, 적은 순서대로, 하나씩 실행된다는 것이다. 이 장은 그 가정을 버린다.
가정을 버리는 순간 익숙한 것들이 무너진다. 𝑥=𝑥+1 이 두 번 일어났는데 𝑥 가 1만 늘어난다. 어제 만 번 돌려 멀쩡하던 코드가 오늘 새벽에 틀린다. 코드를 아무리 들여다봐도 틀린 곳이 보이지 않는다.
답할 질문은 넷이다. 정확히 무엇이 깨지는가, 깨진 것을 무엇으로 막는가, 막다가 생기는 새 문제는 무엇인가, 그리고 왜 검사로 잡히지 않는가. 마지막 질문이 이 장을 어렵게 만드는 것의 정체다.
먼저 용어를 세우자. 프로세스(process)는 실행 중인 프로그램 하나이고 자기만의 메모리를 갖는다. 스레드(thread)는 그 안에서 따로 도는 실행 흐름이고 메모리를 형제 스레드와 함께 쓴다. 이 장의 문제가 거의 다 그 "함께 쓴다"에서 나온다.
하나 더. 병행(concurrency)은 여러 일이 겹치는 기간에 진행되는 것이고, 병렬(parallelism)은 여러 일이 정말로 같은 순간에 실행되는 것이다. 핵이 하나뿐인 기계도 짧게 번갈아 실행하면 병행이 되고, 그것만으로도 이 장의 문제가 전부 생긴다. 그 번갈아 실행하는 일을 문맥 교환(context switch)이라 한다.
이 한 줄이 기계에서는 세 걸음이다. 메모리에서 값을 읽고, 1을 더하고, 다시 쓴다. 그리고 문맥 교환은 이 세 걸음 사이 어디에서든 일어날 수 있다.
<왼쪽과 오른쪽은 같은 코드를 같은 값에서 시작해 실행했다. 오른쪽은 A 가 아직 쓰기 전에 B 가 읽었고, 그래서 더한 것 하나가 사라졌다>[원본 보기]
오른쪽에서 무슨 일이 일어났는지 정확히 말하면 이렇다. B 가 읽은 값이 A 가 쓰기 전의 값이다. 그래서 B 의 계산은 A 의 결과를 모른 채로 이루어졌고, B 가 쓸 때 A 가 쓴 것을 덮어썼다.
이렇게 실행 순서에 따라 결과가 달라지는 상황을 경쟁 상태(race condition)라 한다.
이 장이 어려운 진짜 이유는 여기에 있다. 대부분의 순서에서는 맞는 답이 나온다. 그림의 왼쪽처럼 겹치지 않으면 아무 문제가 없고, 세 걸음이 짧아 겹칠 확률도 낮다. 그래서
시험에서 잡히지 않는다. 만 번을 돌려도 한 번도 안 겹칠 수 있다.
부하가 걸릴 때 나타난다. 스레드가 많고 문맥 교환이 잦은 바로 그때 확률이 오른다.
재현되지 않는다. 같은 입력으로 다시 돌리면 이번에는 맞는 답이 나온다.
그러니 이 장의 방법들은 "시험해서 확인하는 것"이 아니라 "논증해서 보장하는 것"이다. 여기서 감을 믿으면 안 된다.
예제 166
위 그림의 두 스레드가 각각 1을 더할 때 최종 잔액으로 나올 수 있는 값을 모두 적어라. 그리고 세 스레드라면?
풀이 보기
두 스레드는 1 또는 2다. 0은 나올 수 없다. 어느 쪽이든 마지막에 쓰는 값은 자기가 읽은 값에 1을 더한 것이고, 읽은 값은 0 이상이기 때문이다.
세 스레드는 1, 2, 3이 나올 수 있다. 셋이 모두 0을 읽고 셋이 모두 1을 쓰면 1이다.
여기서 중요한 것을 하나 확인하자. 잃어버리는 갱신의 수에 상한이 없다. 스레드가 𝑛 개면 최악에 결과가 1이고, 곧 𝑛−1 개의 갱신이 사라진다.
실무에서 이것이 어떻게 보이는지도 알아 두면 좋다. 조회수 계수기가 실제보다 조금 적게 나오고, 부하가 클수록 더 적게 나온다. 계수기가 "대체로 맞으니까" 넘어가기 쉬운 종류의 오류다.
예제 167
다음 코드는 "없으면 만든다"를 뜻한다. 두 스레드가 동시에 실행하면 무엇이 잘못되는가?
↓ python
if 사용자 not in 표:
표[사용자] = 새_계정()
풀이 보기
두 스레드가 둘 다 조건을 통과할 수 있다. A 가 조건을 확인한 직후, 아직 표에 넣기 전에 B 가 확인하면 B 도 "없다"를 본다.
결과는 새_계정() 이 두 번 불리고 하나가 다른 하나를 덮어쓰는 것이다. 앞의 잔액 문제와 같은 모양이지만 결과가 더 나쁘다. 덮어쓰인 계정을 이미 누가 쓰고 있었다면 그 자료가 통째로 사라진다.
문제의 뿌리는 검사와 그 결과에 따른 행동이 떨어져 있다는 것이다. 검사한 순간의 사실이 행동하는 순간에는 이미 참이 아닐 수 있다.
이 모양에 이름이 있다. TOCTOU이고, 이 장 마지막 절에서 따로 다룬다. 지금은 "확인하고 행동한다"는 코드를 보면 경계하라는 것만 기억하자.
고치는 방법은 둘이다. 검사와 행동을 하나의 임계 구역에 함께 넣거나, 자료구조가 제공하는 "없을 때만 넣기"라는 한 덩어리 연산을 쓰는 것이다. 후자가 있으면 그쪽이 낫다.
고쳐야 할 것이 셋이다 — 원자성 · 가시성 · 순서
앞의 문제를 보고 "세 걸음을 하나로 만들면 되겠다"고 생각했다면 절반만 맞았다. 고쳐야 할 것이 셋이다.
첫째, 원자성(atomicity) — 여러 걸음이 나뉘지 않고 한 덩어리로 일어나는 성질이다. 남이 보기에 "아직 안 했거나 다 했거나" 둘 중 하나여야 한다. 앞 절이 이것이 깨진 경우였다.
나머지 둘은 원자성만으로 풀리지 않는다.
<왼쪽은 쓴 값이 자기 캐시에만 있어 남이 못 보는 경우다. 오른쪽은 적은 순서와 실행되는 순서가 다른 경우다. 둘 다 각 연산은 나뉘지 않았다>[원본 보기]
둘째, 가시성(visibility) — 한 스레드가 쓴 값이 다른 스레드에게 보이는 성질이다. 오늘의 CPU 는 핵마다 자기 캐시를 갖는다. 한 핵이 쓴 값이 그 캐시에만 있고 아직 내려쓰이지 않았다면 다른 핵은 옛 값을 계속 본다.
그림 왼쪽의 반복문이 영원히 끝나지 않는다. 읽기도 쓰기도 각각은 나뉘지 않았는데 그렇다.
셋째, 순서 — 컴파일러와 CPU 는 성능을 위해 명령의 순서를 바꾼다. 한 스레드에서 본 결과가 같기만 하면 마음대로 바꿔도 된다는 것이 규칙이다. 그런데 다른 스레드에서 보면 결과가 같지 않다.
그림 오른쪽에서 다른 스레드는 준비됨이 참인데 자료가 아직 42가 아닌, 코드만 봐서는 있을 수 없는 상태를 본다.
좋은 소식이 있다. 락을 제대로 걸면 셋이 한꺼번에 해결된다. 락에는 상호 배제뿐 아니라 "풀 때 내려쓰고 걸 때 다시 읽는다"는 약속과 "그 경계를 넘어 명령을 옮기지 않는다"는 약속이 함께 붙어 있기 때문이다.
나쁜 소식도 있다. 락 없이 원자적 연산만으로 해결하려면 이 약속들을 손으로 챙겨야 한다. 락-프리 코드가 어려운 이유가 이것이고, 전문가도 자주 틀리는 자리다.
예제 168
반복문을 멈추려고 다른 스레드가 "멈춤" 이라는 참·거짓 변수를 참으로 바꾼다. 이 변수는 한 비트라 읽기도 쓰기도 나뉘지 않는다. 그런데도 반복문이 안 멈출 수 있는가?
풀이 보기
그렇다. 원자성은 갖췄지만 가시성이 없다.
두 가지 일이 겹쳐 일어난다. 쓴 값이 다른 핵의 캐시까지 가지 않았을 수 있고, 더 흔하게는 컴파일러가 반복문 안에서 변수를 다시 읽지 않도록 최적화한다. 반복문 안에서 그 변수를 바꾸는 곳이 없으므로 한 번만 읽어 레지스터에 두는 것이 옳은 최적화다. 한 스레드만 있다면 말이다.
고치려면 그 변수를 "다른 스레드가 바꿀 수 있다"고 표시해야 한다. 언어마다 이름이 다르지만 대개 그런 표시가 있고, 그러면 매번 실제 메모리에서 읽고 쓰며 그 지점을 넘는 재배치도 막힌다.
여기서 얻을 교훈은 "나뉘지 않는다"와 "보인다"가 다른 성질이라는 것이다. 병행성 문제를 진단할 때 이 둘을 구분하지 않으면 엉뚱한 곳을 고치게 된다.
예제 169
어떤 사람이 "우리 서버는 핵이 하나니까 경쟁 상태가 없다"고 한다. 맞는 말인가?
풀이 보기
아니다. 두 가지를 헷갈린 것이다.
핵이 하나면 병렬은 없다. 정말로 같은 순간에 두 명령이 실행되지는 않는다. 그러나 병행은 있다. 운영체제가 스레드를 짧게 번갈아 실행하고, 그 문맥 교환은 어느 명령 사이에서든 일어난다.
앞 그림의 오른쪽이 정확히 그 경우다. A 가 읽은 뒤 쓰기 전에 문맥 교환이 일어나면 핵이 하나여도 같은 일이 벌어진다.
오히려 핵이 하나면 더 나쁜 면도 있다. 겹치는 창이 더 드물게 열려 시험에서 잡힐 확률이 더 낮다. 그러다 부하가 오르거나 기계를 바꾸는 날 터진다.
가시성과 재배치는 사정이 조금 다르다. 캐시 문제는 핵이 하나면 생기지 않지만 컴파일러의 최적화는 그대로 남는다. 앞 예제의 반복문은 핵이 하나여도 안 멈출 수 있다.
임계 구역과 상호 배제
문제가 "여럿이 동시에 건드리는 것"이라면 답은 "한 번에 하나만 건드리게 하는 것"이다. 공유 자료를 건드리는 코드 구간을 임계 구역(critical section)이라 하고, 한 번에 한 스레드만 들어가게 하는 것을 상호 배제(mutual exclusion)라 한다.
<두 임계 구역 상자가 시간 위에서 겹치지 않는다. 아래의 세 가지를 모두 갖춰야 제대로 된 상호 배제다>[원본 보기]
상호 배제를 구현하는 방법이 갖춰야 할 것이 셋이다. 상호 배제(한 번에 하나만), 진행(아무도 안에 없으면 들어가려는 스레드 중 하나는 반드시 들어간다), 유한 대기(기다리는 스레드가 언젠가는 들어간다). 셋째가 빠지면 기아가 생기는데, 뒤에서 다시 본다.
가장 소박한 구현은 깃발을 세워 두고 확인하는 것이다.
↓ python
while 잠김: # 남이 들어가 있으면
pass # 계속 확인한다
잠김 = True # 내가 잠근다
이 코드는 틀렸다. 확인하는 것과 잠그는 것이 떨어져 있어 둘이 동시에 통과할 수 있다. 앞의 "없으면 만든다"와 똑같은 모양이다.
제대로 하려면 확인과 잠금이 한 덩어리로 일어나야 한다. 그런 것을 CPU 가 명령으로 제공한다. 그 위에서 도는 것이 스핀락(spinlock)이다.
스핀락은 못 들어가면 계속 돌면서 확인한다. 이렇게 기다리는 것을 바쁜 대기(busy waiting)라 한다. 기다리는 동안 CPU 를 그대로 쓴다.
낭비처럼 보이지만 늘 그런 것은 아니다. 스레드를 재우고 깨우는 데도 값이 든다. 문맥 교환 한 번이 마이크로초 단위인데, 임계 구역이 그보다 짧다면 그냥 도는 편이 싸다. 운영체제 안쪽이나 아주 짧은 구간에서 스핀락을 쓰는 이유가 이것이다.
반대로 임계 구역이 길면 스핀락은 재앙이다. 특히 핵이 하나면 최악이다. 기다리는 스레드가 CPU 를 붙잡고 도는 동안 락을 쥔 스레드는 실행되지 못한다. 아무도 나아가지 못한다.
설계 판단
어떤 임계 구역이 (1) 계수기 하나를 1 올리는 일 (2) 디스크에서 파일을 읽는 일이다. 각각 스핀락과 재우는 락 중 어느 쪽을 쓰겠는가?
풀이 보기
기준은 임계 구역의 길이를 문맥 교환의 값과 견주는 것이다.
(1) 계수기 — 스핀락. 몇 나노초면 끝난다. 재우고 깨우는 데 드는 마이크로초를 치를 이유가 없다. 실제로는 락도 필요 없고 다음 절의 원자적 연산 하나면 된다.
(2) 디스크 읽기 — 재우는 락. 밀리초 단위다. 그동안 CPU 를 붙잡고 도는 것은 다른 스레드의 시간을 빼앗는 것이다. 재워 두면 그 시간에 다른 일이 진행된다.
경계는 대략 문맥 교환 두 번의 값(재울 때와 깨울 때) 근처다. 그보다 짧으면 스핀, 길면 재우기다.
실무 구현은 대개 둘을 섞는다. 잠깐 돌아 보고 그래도 안 열리면 재운다. 짧은 경우의 이득을 챙기면서 긴 경우의 재앙을 피하는 것이다.
그리고 임계 구역 안에서는 느린 일을 하지 않는 것이 원칙이다. (2)라면 파일을 락 밖에서 읽고, 읽은 결과를 공유 자료에 반영하는 짧은 구간만 락으로 감싸는 편이 대개 낫다.
예제 171
임계 구역을 최대한 짧게 만드는 것이 늘 좋은가?
풀이 보기
아니다. 너무 짧으면 틀린다.
앞의 "없으면 만든다"를 보자. 검사만 락으로 감싸고 넣기를 밖에서 하면 락이 아무것도 지키지 못한다. 검사와 행동 사이에 다른 스레드가 끼어들 수 있기 때문이다.
규칙은 이렇다. "이 사실이 참인 동안"을 전제로 하는 행동은 그 사실을 확인한 것과 같은 임계 구역 안에 있어야 한다.
다시 말해 임계 구역의 경계를 정하는 것은 성능이 아니라 옳음이 먼저다. 짧게 만드는 일은 그 안에서 하는 것이다.
반대 방향의 실수도 있다. 락을 너무 넓게 잡으면 병행성이 사라져 스레드를 여럿 두는 뜻이 없어지고, 락을 쥔 채로 다른 락을 기다리게 되어 교착이 생길 확률도 오른다. 뒤에서 볼 문제다.
비교-교환과 락-프리
계수기 하나를 올리는 데 락을 거는 것은 과하다. CPU 는 몇 가지 연산을 나뉘지 않게 해 준다. 그중 가장 쓸모 있는 것이 비교-교환(compare-and-swap, CAS)이다.
CAS 는 세 가지를 받는다. 주소, 기대하는 옛값, 쓰려는 새값. 그리고 "지금 그 주소가 옛값과 같다면 새값을 쓰고 성공을 알린다"를 한 덩어리로 한다. 다르면 아무것도 안 하고 실패를 알린다.
<읽고 계산한 뒤 값이 아직 그대로일 때만 쓴다. 실패는 그 사이 누가 값을 바꿨다는 뜻이므로 처음부터 다시 한다>[원본 보기]
↓ python
while True:
옛값 = 잔액
새값 = 옛값 + 1
if CAS(잔액, 옛값, 새값):
break
절차를 말로 하면 이렇다. 지금 값을 읽고, 그것으로 새 값을 계산하고, 쓸 때 "아직 그대로인가"를 함께 묻는다. 아니면 처음부터 다시 한다.
락과 성격이 다르다. 락은 못 들어가면 기다리게 하고, CAS 는 일단 해 보고 실패하면 다시 한다. 그래서 CAS 기반 코드에서는 누군가는 언제나 앞으로 나아간다. 어떤 스레드가 중간에 멈춰도 나머지가 막히지 않는다. 이런 성질을 락-프리(lock-free)라 한다.
값도 분명하다. 경쟁이 심하면 재시도가 늘어 오히려 느려진다. 열 스레드가 같은 변수를 두드리면 대부분의 시도가 헛일이 된다.
여기서 10장의 탐색과 활용이 다시 나온다. 실패한 뒤 얼마나 기다렸다 다시 시도할 것인가가 같은 모양의 맞바꿈이다. 곧바로 다시 하면 성공했을 때 빠르지만 경쟁을 더 심하게 만들고, 오래 기다리면 그 시간을 잃는다. 흔한 해법은 지수 백오프(exponential backoff)다. 실패할 때마다 기다리는 시간을 두 배로 늘리되 무작위로 흔들어(지터) 여럿이 같은 순간에 다시 몰리지 않게 한다.
함정도 하나 있다. CAS 는 "값이 같은가"만 본다. 값이 A 에서 B 로 갔다가 A 로 돌아오면 아무 일도 없었던 것으로 본다. 이것을 ABA 문제라 하고, 값에 바뀔 때마다 오르는 번호를 함께 붙여 푼다.
예제 172
CAS 로 만든 계수기와 락으로 만든 계수기 중 어느 쪽이 빠른가?
풀이 보기
경쟁 정도에 따라 다르다.
경쟁이 거의 없으면 CAS 가 확실히 빠르다. 첫 시도에 성공하고, 문맥 교환도 시스템 호출도 없다.
경쟁이 심하면 뒤집힐 수 있다. 여덟 스레드가 두드리면 한 번에 하나만 성공하므로 나머지 일곱이 헛돈다. 게다가 그 변수가 든 캐시 줄이 핵 사이를 계속 오가면서 값이 크게 든다. 락은 못 들어간 스레드를 재워 그 소동을 없앤다.
진짜 답은 둘 다 피하는 것일 때가 많다. 스레드마다 자기 계수기를 두고 읽을 때만 더하면 경쟁이 아예 사라진다. 실제 라이브러리의 고성능 계수기가 이 방식이다.
여기서 얻을 원칙이 이 장 전체에 걸친다. 가장 좋은 동기화는 공유하지 않는 것이다. 나눌 수 있으면 나누고, 그럴 수 없을 때 비로소 도구를 꺼낸다.
예제 173
ABA 문제가 실제로 해를 끼치는 상황을 하나 만들어 보라.
풀이 보기
4장의 스택을 락 없이 만든다고 하자. 꼭대기를 가리키는 포인터를 CAS 로 바꾼다.
스택이 𝐴→𝐵→𝐶 일 때 스레드 1이 꺼내기를 시작한다. 꼭대기가 𝐴 임을 읽고, 새 꼭대기가 𝐵 일 것이라고 계산한다. 여기서 멈춘다.
그 사이 스레드 2가 𝐴 를 꺼내고 𝐵 도 꺼낸 뒤 𝐴 를 다시 넣는다. 이제 스택은 𝐴→𝐶 다.
스레드 1이 깨어나 CAS 를 한다. 꼭대기가 여전히 𝐴 이므로 성공한다. 그리고 꼭대기를 𝐵 로 바꾼다. 그런데 𝐵 는 이미 꺼내져 다른 곳에 쓰이고 있을지도 모르는 메모리다.
스택이 이미 없는 노드를 가리키게 되었다. 이후의 동작은 예측할 수 없다.
고치는 방법이 번호를 붙이는 것이다. 포인터와 변경 횟수를 함께 한 덩어리로 CAS 하면, 값이 되돌아왔어도 번호가 달라 실패한다. 락-프리 자료구조가 어렵다는 말이 무슨 뜻인지 보여 주는 예다.
뮤텍스 · 세마포어 · 모니터
상호 배제를 제공하는 도구가 여럿이다. 이름이 비슷해 헷갈리지만 각각 푸는 문제가 다르다.
뮤텍스 — 자리 하나와 소유권
뮤텍스(mutex)는 이름 그대로 상호 배제만 한다. 잠그고 풀고, 그것이 전부다. 못 잠그면 스레드가 잠든다.
결정적인 성질이 소유권이다. 잠근 스레드만 풀 수 있다. 이것 덕분에 "누가 이 자료를 쥐고 있는가"가 분명해지고, 뒤에 볼 우선순위 상속 같은 것도 가능해진다.
세마포어 — 개수를 센다
세마포어(semaphore)는 정수 하나를 관리한다. 얻기(acquire)는 값을 1 내리고, 0이면 잠든다. 반납(release)은 값을 1 올리고 자는 스레드 하나를 깨운다.
초기값이 𝑛 이면 "동시에 𝑛 개까지 허용"이다. 연결 개수를 제한하거나 자원 풀을 관리하는 데 쓴다.
뮤텍스와의 진짜 차이는 개수가 아니라 소유권이 없다는 것이다. 얻은 쪽과 반납하는 쪽이 달라도 된다. 그래서 세마포어는 배타적 접근뿐 아니라 신호 보내기에 쓸 수 있다. 한 스레드가 "준비됐다"고 반납하면 기다리던 스레드가 깨어난다.
값은 실수하기 쉽다는 것이다. 반납을 잊으면 아무도 알려 주지 않고, 두 번 반납하면 정원이 늘어난다.
모니터 — 락과 조건을 한 묶음으로
실제로 필요한 것은 대개 "배타적 접근"이 아니라 "어떤 조건이 맞을 때까지 기다렸다가 배타적으로 접근하는 것"이다. 모니터(monitor)가 그것을 한 묶음으로 준다.
구조를 큐 둘로 생각하면 쉽다.
들어가려는 스레드는 입장 큐에 선다. 한 번에 하나만 안으로 들어간다.
안에서 조건이 안 맞으면 기다린다 — 이때 락을 놓고대기 큐로 옮겨 잠든다. 락을 놓는 것이 핵심이다. 안 놓으면 조건을 바꿔 줄 스레드가 들어오지 못한다.
누군가 조건을 바꾸고 알리면 대기 큐의 스레드가 입장 큐로 돌아가고, 락을 다시 얻은 뒤 조건을 처음부터 다시 확인한다.
마지막 줄이 중요하다. 기다리기는 반드시 반복문 안에 두어야 한다.
↓ python
with 락:
while not 조건: # if 가 아니라 while
기다린다()
일한다()
왜 반복문인가. 깨어나서 락을 다시 얻기까지 사이에 다른 스레드가 먼저 들어와 조건을 도로 무너뜨렸을 수 있다. 게다가 아무도 알리지 않았는데 깨어나는 일도 규격상 허용된다. 깨어났다는 것은 "확인해 볼 만하다"일 뿐 "조건이 참이다"가 아니다.
고전 문제 둘
<막을 것이 셋이고 도구도 셋이다. 세마포어 둘이 개수를 세고 뮤텍스 하나가 버퍼 자체를 지킨다>[원본 보기]
생산자-소비자는 4장의 큐를 여럿이 함께 쓰는 문제다. 칸이 정해진 유계 버퍼를 사이에 두고 한쪽은 넣고 한쪽은 꺼낸다. 막아야 할 것이 셋이다. 가득 찼는데 넣기, 비었는데 꺼내기, 그리고 둘이 같은 칸을 동시에 건드리기.
앞의 둘은 세마포어 둘로 푼다. 빈 칸 수를 세는 것과 찬 칸 수를 세는 것이다. 세 번째는 뮤텍스가 맡는다.
순서가 중요하다. 세마포어를 먼저 얻고 뮤텍스를 나중에 잠가야 한다. 뮤텍스를 쥔 채로 세마포어를 기다리면, 깨워 줄 상대가 뮤텍스를 얻지 못해 둘 다 영원히 멈춘다. 다음 절의 교착이다.
독자-작가는 조건이 다르다. 읽기끼리는 겹쳐도 되지만 쓰기는 혼자여야 한다. 읽기가 압도적으로 많은 자료에서 모두를 배타적으로 막으면 손해가 크다. 그래서 독자-작가 락을 따로 둔다.
여기에 함정이 있다. 읽는 사람을 계속 받아 주면 쓰려는 사람이 영원히 못 들어갈 수 있다. 상호 배제는 지켜지는데 유한 대기가 깨진다. 그래서 실제 구현은 대개 쓰기가 기다리기 시작하면 새 읽기를 받지 않는다.
예제 174
위의 기다리기를 while 이 아니라 if 로 적으면 어떤 순서에서 틀리는가?
풀이 보기
소비자 둘이 빈 버퍼에서 기다리고 있다고 하자.
생산자가 자료 하나를 넣고 알린다. 소비자 A 와 B 가 모두 깨어나 입장 큐로 간다.
A 가 먼저 락을 얻어 자료를 꺼낸다. 버퍼가 다시 비었다. A 가 락을 놓는다.
이제 B 가 락을 얻는다. if 였다면 조건 확인을 건너뛰고 곧바로 꺼내려 한다. 빈 버퍼에서 꺼내는 것이다. 무엇이 나올지 알 수 없다.
while 이었다면 B 는 조건을 다시 확인하고 거짓이므로 도로 잠든다.
이 실수가 무서운 이유는 소비자가 하나뿐일 때는 절대 나타나지 않는다는 점이다. 시험에서 통과하고 운영에서 터진다.
설계 판단
서버가 데이터베이스 연결을 최대 20개까지만 쓸 수 있다. 스레드 200개가 그 연결을 나눠 쓰게 하려면 무엇을 쓰겠는가?
풀이 보기
세마포어다. 초기값 20으로 두고, 연결을 쓰기 전에 얻고 다 쓰면 반납한다.
뮤텍스로는 안 된다. 뮤텍스는 자리가 하나뿐이라 연결 20개를 동시에 쓰게 할 수 없다.
실무에서 주의할 것이 둘이다.
반드시 반납해야 한다. 예외가 나서 반납을 건너뛰면 정원이 영구히 줄어든다. 스무 번 그러면 서버가 통째로 멈춘다. 그래서 언어가 제공하는 "블록을 벗어날 때 반드시 실행" 구문 안에 반납을 두어야 한다.
기다리는 시간에 상한을 두어야 한다. 무한정 기다리면 요청이 쌓이다 전부 시간을 넘긴다. 정해진 시간 안에 못 얻으면 실패로 응답하는 편이 낫다. 이것을 부하 흘리기라 한다.
그리고 대개 이런 것은 직접 만들지 않는다. 연결 풀이라는 이름으로 이미 만들어진 것이 있고, 여기에 더해 연결 검사와 재연결까지 해 준다.
교착 상태 · 기아 · 우선순위 역전
락을 쓰기 시작하면 새로운 종류의 고장이 생긴다. 아무도 틀린 값을 쓰지 않는데 아무도 나아가지 못하는 고장이다.
<왼쪽 그림에서 기다림이 고리를 이루고 있다. 오른쪽 네 조건은 모두 갖춰졌을 때만 교착이 되므로 하나만 깨면 된다>[원본 보기]
교착 상태(deadlock)는 서로가 서로를 기다려 영원히 멈춘 상태다. 네 조건이 모두 갖춰졌을 때만 생긴다.
상호 배제 — 자원을 한 번에 하나만 쓴다.
점유하고 대기 — 쥔 채로 다른 것을 기다린다.
빼앗지 못함 — 남이 쥔 것을 강제로 뺏을 수 없다.
순환 대기 — 기다림이 고리를 이룬다.
넷이 모두 필요하므로 하나만 깨면 교착이 생길 수 없다. 대응이 셋이다.
예방은 네 조건 중 하나를 원천 봉쇄하는 것이다. 실무에서 가장 많이 쓰는 것이 순환 대기 깨기다. 자원에 번호를 매기고 언제나 번호가 커지는 방향으로만 잠근다. 그러면 고리가 만들어질 수 없다. 고리가 있으려면 어딘가에서 번호가 작아져야 하기 때문이다. 규칙 하나가 문제 전체를 없앤다.
회피는 자원을 줄 때마다 "이걸 주면 나중에 막힐 수 있는가"를 검사하는 것이다. 은행원 알고리즘이 대표적인데 각 스레드가 앞으로 얼마나 요구할지를 미리 알아야 해서 실무에서는 거의 쓰이지 않는다.
탐지는 일단 두었다가 주기적으로 찾아내는 것이다. 여기서 9장이 돌아온다. 자원 할당 그래프를 만들고 순환이 있는지 본다. DFS 로 뒤쪽 간선을 찾는 그 방법 그대로다. 찾으면 회복해야 하는데, 대개 참여자 중 하나를 골라 강제로 되돌린다. 데이터베이스가 교착을 만났을 때 트랜잭션 하나를 실패시키는 것이 이것이다.
교착과 자주 헷갈리는 것이 기아(starvation)다. 교착은 아무도 못 나아가는 것이고 기아는 나만 못 나아가는 것이다. 락이 풀릴 때마다 다른 스레드가 먼저 채 가면 특정 스레드가 영원히 못 들어간다.
진단이 더 어렵다. 교착은 멈춰 있으니 보면 알지만 기아는 시스템이 잘 도는 것처럼 보인다. 처리량도 정상이다. 다만 어떤 요청 하나가 영영 응답하지 않는다. 해결은 공정한 락을 쓰는 것 — 기다린 순서대로 들여보내는 것이다. 값은 처리량이 조금 떨어지는 것이다.
<H 가 기다리는 것은 L 이 쥔 락인데, 정작 H 를 붙잡아 두는 것은 락과 아무 상관 없는 M 이다>[원본 보기]
마지막이 우선순위 역전(priority inversion)이다. 우선순위가 높은 스레드가 낮은 스레드에 밀리는 현상이다.
그림을 따라가 보자. 낮은 우선순위 L 이 락을 쥐고 있다. 높은 우선순위 H 가 그 락을 원해 막힌다. 여기까지는 정상이다. 그런데 중간 우선순위 M 이 나타나 L 을 밀어낸다. M 은 락과 아무 상관이 없어 그럴 자격이 있다.
이제 L 이 실행되지 못하니 락이 풀리지 않고, 락이 안 풀리니 H 가 못 간다. 결과적으로 H 가 M 보다 늦게 끝난다.
해결책이 우선순위 상속이다. 락을 쥔 스레드에게 그 락을 기다리는 가장 높은 우선순위를 잠시 빌려준다. 그러면 L 이 H 의 우선순위로 달려 M 이 끼어들지 못하고, 락을 풀면 원래대로 돌아간다. 뮤텍스에 소유권이 있어야 이것이 가능하다는 점도 확인해 두자. 누가 쥐고 있는지 모르면 누구에게 빌려줄지도 모른다.
예제 176
두 스레드가 각각 이렇게 돈다. A 는 락1 을 잡고 락2 를 잡으며, B 는 락2 를 잡고 락1 을 잡는다. 교착이 생기는 순서를 적고, 코드를 어떻게 고치겠는가?
풀이 보기
교착이 생기는 순서. A 가 락1 을 잡는다. 여기서 문맥 교환이 일어나 B 가 락2 를 잡는다. A 가 락2 를 기다리고 B 가 락1 을 기다린다. 둘 다 영원히 멈춘다.
네 조건이 모두 갖춰졌는지 보자. 락은 배타적이고(상호 배제), 둘 다 하나를 쥔 채 기다리며(점유하고 대기), 뺏을 수 없고(빼앗지 못함), 기다림이 고리를 이룬다(순환 대기).
고치는 법은 순서를 정하는 것이다. 락에 번호를 매기고 둘 다 번호가 작은 것부터 잡게 한다. 그러면 A 도 B 도 락1 을 먼저 잡으려 하고, 하나가 이기면 다른 하나는 락1 을 기다릴 뿐 아무것도 쥐고 있지 않다. 고리가 생기지 않는다.
이 규칙이 실무에서 강력한 이유는 국소적으로 지킬 수 있다는 점이다. 각 함수가 자기 안에서 규칙만 지키면 전체가 안전해진다. 전역적인 검사나 조율이 필요 없다.
순서를 정할 수 없는 경우도 있다. 그때는 시간 제한을 쓴다. 정해진 시간 안에 둘째 락을 못 얻으면 쥔 것을 모두 놓고 물러났다가 다시 시도한다. 빼앗지 못함 조건을 스스로 깨는 것이다. 이때 물러나는 시간에 무작위를 섞어야 둘이 같은 박자로 계속 부딪히지 않는다.
예제 177
어떤 시스템이 이틀에 한 번쯤 "멈춘 것처럼" 되는데 CPU 사용률은 0에 가깝다. 무엇을 의심하고 어떻게 확인하겠는가?
풀이 보기
CPU 사용률이 0이라는 것이 핵심 단서다. 무한 반복이나 바쁜 대기라면 사용률이 100%에 가까웠을 것이다. 0에 가깝다는 것은 스레드들이 자고 있다는 뜻이다.
그러니 교착을 먼저 의심한다. 확인은 어렵지 않다. 그 순간 모든 스레드의 호출 스택을 떠 본다. 여러 스레드가 락을 얻는 함수에서 멈춰 있고, 각자가 다른 스레드가 쥔 락을 기다리고 있으면 교착이다.
스택에서 "누가 무엇을 쥐고 무엇을 기다리는가"를 뽑으면 그것이 곧 자원 할당 그래프다. 손으로 그려 순환을 찾으면 된다.
교착이 아니라면 다음 후보는 바깥에서 오는 응답을 기다리는 것이다. 시간 제한 없는 원격 호출이 대표적이다. 이것도 스택에 그대로 드러난다.
재현이 안 되는 문제이므로 사후 분석이 유일한 길이다. 그래서 병행 시스템에는 "멈췄을 때 스택을 자동으로 남기는" 장치를 미리 넣어 두는 것이 좋다.
TOCTOU — 검사와 사용 사이의 틈
이 장을 여는 예제에서 "확인하고 행동한다"가 위험하다는 말을 했다. 그 모양에 이름이 있다. TOCTOU(time-of-check to time-of-use), 곧 검사한 시점과 쓰는 시점 사이의 틈이다.
<검사와 사용 사이에 대상이 바뀌면 검사 결과가 아무 의미가 없다. 검사는 그때의 진실이었을 뿐이다>[원본 보기]
이것이 병행성 장에 있는 이유는 분명하다. 틈이 생기는 것은 그 사이에 다른 참여자가 끼어들 수 있기 때문이고, 그것이 곧 경쟁 상태다.
다만 참여자가 반드시 다른 스레드인 것은 아니다. 여기가 이 절의 요점이다.
파일을 다루는 코드를 보자. "이 파일이 내 것인지 확인한 뒤 연다". 스레드가 하나뿐이어도 그 사이에 다른 프로세스가 그 이름을 다른 파일로 가는 링크로 바꿔치기할 수 있다. 확인한 것과 연 것이 다른 대상이 된다.
그래서 이 틈은 보안 취약점이 된다. 권한 검사와 실제 사용 사이가 벌어져 있으면 공격자가 그 사이를 노린다.
고치는 방법은 두 갈래다.
틈을 없앤다. 검사와 사용을 한 덩어리 연산으로 만든다. 자료구조라면 "없을 때만 넣기" 같은 것이고, 락이라면 둘을 한 임계 구역에 넣는 것이다.
이름 대신 손잡이를 쓴다. 이름은 언제든 다른 것을 가리킬 수 있다. 한 번 연 대상을 그대로 들고 다니면서 그 대상에 대해 검사하면 바꿔칠 틈이 없다.
같은 모양을 다른 옷을 입고 자주 만난다. 잔액을 확인하고 인출하기, 자리가 남았는지 보고 예약하기, 파일이 없는지 보고 만들기. 셋 다 "확인한 사실이 행동하는 순간에도 참일 것"을 가정한다.
예제 178
다음 코드의 문제를 지적하고 고쳐라.
↓ python
if 잔액 >= 금액:
잔액 = 잔액 - 금액
내보낸다(금액)
풀이 보기
검사(첫 줄)와 사용(둘째·셋째 줄)이 떨어져 있다. 두 스레드가 같은 계좌로 동시에 인출하면 둘 다 조건을 통과할 수 있다.
잔액이 100이고 각각 80을 인출한다고 하자. 둘 다 "100 ≥ 80" 을 보고 통과하면 잔액이 −60 이 되고 160이 나간다.
고치기. 세 줄을 한 임계 구역에 넣는다.
↓ python
with 계좌_락:
if 잔액 >= 금액:
잔액 = 잔액 - 금액
나갈_금액 = 금액
else:
나갈_금액 = 0
if 나갈_금액:
내보낸다(나갈_금액)
내보내기를 락 밖으로 뺀 것에 주목하라. 실제로 돈을 보내는 일은 느리고 바깥과 통신할 수도 있다. 그것을 락 안에 두면 임계 구역이 길어지고, 그 안에서 다른 락을 기다리다 교착이 날 수도 있다.
락 안에 있어야 하는 것은 "잔액이 충분하다"는 사실에 기대는 부분, 곧 검사와 차감뿐이다. 앞 절의 "짧게, 그러나 옳게"가 여기서 구체적으로 무엇을 뜻하는지 보여 준다.
데이터베이스라면 같은 일을 트랜잭션과 행 잠금으로 한다. 이름이 다를 뿐 구조는 같다.
예제 179
스레드를 아예 하나만 쓰면 이 장의 문제가 전부 사라지는가?
풀이 보기
대부분 사라지지만 전부는 아니다. 얻는 것과 잃는 것을 나눠 보자.
사라지는 것. 경쟁 상태, 교착, 우선순위 역전이 전부 사라진다. 스레드 하나 안에서는 실행이 한 줄로 이어지므로 원자성 문제도 없다. 실제로 이 선택을 한 시스템이 많다.
남는 것이 둘. 첫째, 운영체제와 다른 프로세스는 여전히 다른 참여자다. 앞의 파일 바꿔치기는 그대로 일어난다. 둘째, 하나의 흐름 안에서도 일을 중간에 양보하면 같은 문제가 생긴다. 기다리는 지점마다 다른 일이 끼어들 수 있으므로, 그 지점을 사이에 두고 "확인하고 행동하기"를 하면 여전히 깨진다.
값도 있다. 핵을 하나밖에 못 쓴다. 그래서 실무에서는 흐름을 여럿 두되 각각이 자기 자료만 만지게 하고, 그 사이는 메시지로 주고받는 방식을 쓴다. 공유를 없애 문제를 없애는 것이지 병행을 없애는 것이 아니다.
이 장 전체의 결론이기도 하다. 동기화 도구를 잘 쓰는 것보다 동기화가 필요 없게 설계하는 것이 낫다.
이 장의 정리
이 장은 도구 목록이 아니라 고르는 순서로 기억하는 편이 낫다. 먼저 공유를 없앨 수 있는지 묻고, 없앨 수 없을 때 비로소 무엇을 기다리는지에 따라 도구를 고른다.
<가장 위의 갈림길이 가장 중요하다. 나눌 수 있으면 나누는 것이 어떤 동기화 도구보다 낫다>[원본 보기]
용어
뜻
기억할 점
병행 · 병렬
겹치는 기간에 진행 · 정말 같은 순간에 실행
핵이 하나여도 병행은 있다
경쟁 상태
실행 순서에 따라 결과가 달라진다
대부분의 순서에서는 맞아 시험에 안 걸린다
원자성
여러 걸음이 나뉘지 않는다
남이 보기에 아직이거나 다 했거나
가시성
쓴 값이 다른 스레드에 보인다
원자성과 다른 성질이다
재배치
적은 순서와 실행 순서가 다르다
한 스레드에서 결과가 같으면 허용된다
임계 구역
공유 자료를 건드리는 코드 구간
짧게, 그러나 옳게
상호 배제
한 번에 한 스레드만 들어간다
진행 · 유한 대기까지 갖춰야 한다
도구
무엇을 하는가
언제 쓰는가
스핀락
못 들어가면 돌면서 기다린다
임계 구역이 문맥 교환보다 짧을 때
뮤텍스
자리 하나. 잠근 스레드만 푼다
짧은 구간의 배타적 접근
세마포어
개수를 센다. 소유권이 없다
자원 풀, 그리고 신호 보내기
모니터
락과 조건 변수를 한 묶음으로
조건이 맞을 때까지 기다려야 할 때
독자-작가 락
읽기는 여럿, 쓰기는 혼자
읽기가 압도적으로 많을 때
CAS
옛값과 같을 때만 새값을 쓴다
변수 하나. 경쟁이 심하면 불리하다
고장
무엇인가
무엇으로 막는가
교착 상태
서로 기다려 아무도 못 나아간다
네 조건 중 하나를 깬다. 흔히 잠그는 순서 고정
기아
남들만 나아가고 나만 못 나아간다
공정한 락. 기다린 순서대로 들여보낸다
우선순위 역전
급한 일이 상관없는 일에 밀린다
우선순위 상속. 락을 쥔 쪽에 잠시 빌려준다
ABA
값이 돌아오면 CAS 가 변화를 못 본다
변경 번호를 함께 CAS 한다
TOCTOU
검사와 사용 사이에 대상이 바뀐다
한 덩어리로 만들거나, 이름 대신 손잡이를 쓴다
다음 장은 이 장의 마지막 절에서 이어진다. TOCTOU 는 병행성 문제이면서 동시에 보안 취약점이었다. 16장은 그 방향을 끝까지 밀고 간다. 실수로 끼어드는 것이 아니라 작정하고 끼어드는 상대가 있을 때, 무엇을 어떻게 지키는가.
암호와 보안 프로토콜
15장은 실수로 끼어드는 상대를 다뤘다. 다른 스레드는 우리를 해칠 뜻이 없었고 다만 순서가 나빴을 뿐이다. 이 장의 상대는 다르다. 작정하고, 가장 나쁜 순간을 골라, 우리가 생각하지 못한 방향에서 끼어든다.
그래서 판단 기준이 바뀐다. 지금까지 알고리즘을 평가한 기준은 "평균적으로 얼마나 빠른가"였다. 여기서는 "가장 영리한 공격자가 가장 좋은 방법을 썼을 때도 버티는가"다. 평균이 아니라 최악이고, 그 최악이 사람이다.
답할 질문은 넷이다. 정확히 무엇을 지키는가, 어떤 수학이 그것을 가능하게 하는가, 그 재료로 어떻게 실제 프로토콜을 만드는가, 그리고 그것이 실패하면 어떤 모습인가. 마지막 질문에 CSRF 를 놓았다.
앞에서 가져오는 것은 7장의 빠른 거듭제곱, 5장의 좋은 해시 함수, 13장의 P와 NP, 그리고 15장의 TOCTOU다. 5장에서 "암호학적 해시는 16장에서"라고 미뤄 둔 빚을 여기서 갚는다.
정수론이 필요한데 기초수학에 없다. 세 번째 절에서 필요한 만큼만 그 자리에서 세운다.
무엇을 지키려는가 — 그리고 직접 만들지 말라
"안전하게"라는 말은 너무 뭉뚱그린 말이다. 지키려는 것을 넷으로 나누어야 무엇이 필요한지 정해진다.
기밀성(confidentiality) — 남이 내용을 읽지 못한다.
무결성(integrity) — 남이 내용을 몰래 고치지 못한다.
인증(authentication) — 상대가 자기가 말하는 그 사람이 맞다.
부인 방지(non-repudiation) — 나중에 "내가 안 했다"고 발뺌하지 못한다.
넷이 각각 다른 도구에 대응한다. 섞으면 "암호화했으니 안전하다" 같은 결론에 이르는데, 이 장에서 보겠지만 암호화는 넷 중 하나만 준다.
<왼쪽 네 목표에서 오른쪽 도구로 가는 길이 각각 다르다. 화살표를 거꾸로 읽으면 ‘이 도구는 무엇을 지켜 주지 않는가’ 가 보인다>[원본 보기]
원칙이 하나 있다. 케르크호프스의 원리 — 알고리즘이 공개되어도 열쇠만 비밀이면 안전해야 한다. 방식을 숨겨서 얻는 안전은 안전이 아니다. 방식은 언젠가 새고, 새는 순간 전부 무너지며, 무엇보다 공개되지 않은 알고리즘은 검증받지 못한다.
이제 이 장에서 가장 중요한 말을 한다. 암호를 직접 구현하지 마라. 원리를 아는 것과 안전하게 구현하는 것은 완전히 다른 일이고, 이 장은 앞의 것을 위한 것이다. 이유가 넷이다.
수식이 맞아도 구현이 샌다. 비교에 걸리는 시간이 입력에 따라 다르면 그 차이만으로 열쇠를 알아낸다. 전력 소모나 소리로도 그렇게 한다.
난수 하나가 나쁘면 전부 무너진다. 서명에 쓰는 일회용 값을 두 번만 써도 비밀키가 그대로 계산된다.
맞는 조각을 잘못 이어 붙이면 깨진다. 곧 볼 ECB 모드가 그 예다. 부품은 멀쩡한데 조립이 틀렸다.
틀려도 티가 안 난다. 정렬이 틀리면 눈에 보이지만 암호가 틀리면 겉보기에는 똑같이 잘 돌아간다. 시험으로 잡을 수 없다.
그러니 실무의 규칙은 이렇다. 검증된 라이브러리를 쓰고, 정한 규격 그대로 쓰고, 기본값을 함부로 바꾸지 않는다.
예제 180
다음 상황에서 필요한 것이 네 목표 중 무엇인지 말하라. (1) 공개 게시판의 글이 위조되지 않았음을 모두가 확인 (2) 사내 문서를 외부에서 읽지 못하게 (3) 전자 계약서
풀이 보기
(1) 무결성 + 인증. 기밀성은 필요 없다. 내용은 공개되어야 한다. 누구나 확인할 수 있어야 하므로 전자 서명이 답이다. MAC 은 확인하는 쪽도 같은 열쇠를 가져야 해서 "모두가 확인"에 맞지 않는다.
(2) 기밀성. 대칭키 암호로 충분하다. 다만 열쇠를 어떻게 나눠 갖는지가 곧 문제가 되고, 그것이 공개키가 필요한 이유다.
(3) 넷 다. 특히 부인 방지가 핵심이다. 서명을 만들 수 있는 사람이 비밀키를 가진 한 사람뿐이어야 하므로 MAC 으로는 안 된다. MAC 은 양쪽이 같은 열쇠를 가져 상대가 위조했을 가능성을 배제하지 못한다. 이 구분이 실무에서 자주 어긋난다. "서명"이라는 말을 쓰면서 실제로는 MAC 을 쓰는 시스템이 흔하다. 부인 방지가 필요 없다면 문제없지만, 필요하다면 치명적이다.
대칭키 암호 — 블록과 운용 모드
대칭키 암호는 암호화와 복호화에 같은 열쇠를 쓴다. 가장 널리 쓰이는 것이 블록 암호다. 정해진 크기의 덩어리 하나를 같은 크기의 덩어리로 바꾼다. AES 는 128비트를 받아 128비트를 낸다.
되돌릴 수 있어야 하므로 이 함수는 일대일이고, 곧 열쇠 하나가 2128 개의 입력을 뒤섞는 순열 하나를 정한다. 이상적인 블록 암호란 그 순열이 무작위로 고른 것과 구별되지 않고, 열쇠가 다르면 순열끼리 아무 관계가 없는 것이다.
문제는 우리가 보내려는 것이 128비트보다 길다는 것이다. 블록을 어떻게 이어 붙이는가를 정해야 하고, 그것을 운용 모드라 한다. 여기서 사람들이 가장 많이 틀린다.
ECB — 하지 말아야 할 것
가장 단순한 방법은 블록마다 따로 암호화하는 것이다. 𝐶𝑖=𝐸(𝐾,𝑃𝑖). 이것을 ECB 모드라 하고 병렬로 처리할 수 있어 빠르다. 무엇이 잘못됐는지는 말로 설명하기 어려우니 그림을 보자.
<가운데 그림에서 블록 하나하나는 제대로 암호화되었다. 그런데 어느 블록과 어느 블록이 같은지가 그대로 남아 원래 모양이 보인다>[원본 보기]
ECB 는 같은 평문 블록을 언제나 같은 암호문 블록으로 보낸다. 결정적 함수이므로 당연하다. 그래서 배경 블록끼리 같은 색, 몸통 블록끼리 같은 색이 된다.
여기서 정확히 짚어야 할 것이 있다. 새어 나간 것은 블록 안의 내용이 아니다. 우리는 여전히 어느 블록도 읽지 못한다. 새어 나간 것은 "어느 블록과 어느 블록이 같은가"라는 관계이고, 그것만으로 그림이 보인다. 그림이 아니어도 같다. 되풀이되는 값이 있는 자료라면 그 구조가 그대로 드러난다.
사슬로 잇는 모드들
<세 모드 모두 같은 암호 함수 E 를 쓴다. 다른 것은 무엇을 함께 넣느냐뿐이고, 그 차이가 안전성과 병렬성을 정한다>[원본 보기]
CBC는 앞 블록의 암호문을 평문에 섞은 뒤 암호화한다. 𝐶𝑖=𝐸(𝐾,𝑃𝑖⊕𝐶𝑖−1). 같은 평문 블록이라도 앞이 다르면 결과가 달라진다. 첫 블록에는 앞이 없으므로 초기 벡터(IV)를 쓴다.
CTR은 접근이 다르다. 평문을 암호화하지 않는다. 대신 "한 번만 쓰는 값(nonce)에 블록 번호를 붙인 것"을 암호화해 열쇠 스트림을 만들고, 그것을 평문과 XOR 한다. 𝐶𝑖=𝑃𝑖⊕𝐸(𝐾,nonce‖𝑖).
CTR 의 장점이 셋이다. 암호화도 복호화도 병렬로 되고, 복호화 함수가 따로 필요 없고(XOR 을 한 번 더 하면 된다), 패딩이 필요 없다. 패딩 이야기를 해 두자. 평문이 블록 크기로 딱 떨어지지 않으면 채워 넣어야 한다. 그런데 채운 것을 벗겨 낼 때 형식을 검사하게 되고, 그 검사가 실패했는지 아닌지가 새어 나가면 그것만으로 평문을 복원하는 공격이 있다. 패딩이 필요 없는 모드가 그만큼 안전한 이유다.
IV 와 nonce 는 비밀이 아니다. 암호문과 함께 그냥 보낸다. 대신 같은 열쇠로 두 번 쓰면 안 된다. CTR 에서 그러면 두 암호문을 XOR 했을 때 열쇠 스트림이 지워져 두 평문의 XOR 이 그대로 드러난다. 그리고 세 모드 어느 것도 변조를 막지 못한다. 무결성은 뒤에 볼 MAC 이 따로 맡는다.
예제 181
CBC 모드에서 IV 를 매번 같은 값으로 쓰면 무엇이 새는가?
풀이 보기
첫 블록이 𝐶1=𝐸(𝐾,𝑃1⊕𝐼𝑉) 인데 𝐼𝑉 가 고정이면 첫 블록이 결정적이 된다. 곧 첫 블록에 대해서는 ECB 와 같아진다. 그래서 공격자는 두 메시지의 첫 암호문 블록만 견주어 "시작이 같은가"를 알 수 있다. 로그인 요청처럼 앞부분 형식이 정해진 자료라면 이것이 상당한 정보다.
더 나아가 공격자가 평문의 일부를 고를 수 있다면 추측한 값을 넣어 보고 첫 블록이 같은지 확인하는 방식으로 나머지를 한 조각씩 알아낼 수 있다. 그래서 IV 는 메시지마다 예측할 수 없게 골라야 한다. "앞의 암호문 마지막 블록을 다음 IV 로 쓴다"는 방식이 한때 쓰였는데, 예측이 가능해져 취약한 것으로 밝혀졌다.
여기서 확인할 것은 고장이 "전부 읽힌다"가 아니라 "조금 새어 나간다"로 시작한다는 점이다. 암호에서는 조금 새는 것이 대개 전부 새는 것으로 이어진다.
설계 판단
파일 저장소가 파일을 암호화해 보관한다. 이용자가 파일의 가운데 1 MB 만 읽으려 할 때 모드에 따라 무엇이 달라지는가?
풀이 보기
CTR 이면 그 부분만 읽으면 된다. 열쇠 스트림이 블록 번호로 정해지므로, 필요한 블록 번호로 스트림을 만들어 XOR 하면 끝이다. 임의 접근이 된다.
CBC 면 그 블록의 바로 앞 암호문 블록도 읽어야 한다. 복호화가 𝑃𝑖=𝐷(𝐾,𝐶𝑖)⊕𝐶𝑖−1 이므로 한 블록만 더 읽으면 되고, 복호화는 앞부터 순서대로 할 필요가 없다.암호화는 사정이 다르다. CBC 에서 가운데 한 블록을 고치면 그 뒤가 전부 바뀐다. 자주 고치는 자료라면 큰 손해다.
그래서 저장소 암호화에는 대개 CTR 계열을 쓰되 같은 자리를 고칠 때 같은 nonce 를 다시 쓰면 안 된다는 제약 때문에 파일을 조각으로 나눠 조각마다 다른 nonce 를 쓴다. 무결성까지 얹으려면 조각마다 MAC 을 붙인다. 전체에 하나만 붙이면 1 MB 를 읽으려고 전부 읽어야 한다.
그 자리에서 세우는 정수론
공개키 암호로 가려면 도구가 필요하다. 여기서 필요한 만큼만 세운다. 합동식.𝑎 와 𝑏 를 𝑛 으로 나눈 나머지가 같을 때
𝑎≡𝑏(mod𝑛)
이라 쓰고 "𝑎 와 𝑏 는 𝑛 을 법으로 합동"이라 읽는다. 같은 말로 𝑎−𝑏 가 𝑛 의 배수라는 뜻이다.
핵심 성질은 덧셈과 곱셈이 보존된다는 것이다. 𝑎≡𝑎′ 이고 𝑏≡𝑏′ 이면
𝑎+𝑏≡𝑎′+𝑏′,𝑎𝑏≡𝑎′𝑏′(mod𝑛)
이다. 그래서 계산 중간에 아무 때나 나머지를 취해도 결과가 달라지지 않는다. 7장에서 모듈러 거듭제곱을 할 때 매번 나머지를 취한 근거가 이것이다.
나눗셈은 다르다.3𝑥≡1(mod7) 을 풀려면 "3으로 나누기"가 필요한데 그런 연산이 없다. 대신 역원을 찾는다 — 𝑎𝑥≡1(mod𝑛) 인 𝑥 다. 역원은 𝑎 와 𝑛 이 서로소일 때만 있다. 아니면 𝑎𝑥 가 공약수의 배수만 되어 1이 될 수 없기 때문이다. 구하는 방법은 확장 유클리드 알고리즘이고 𝑂(log𝑛) 이다.
이제 두 정리다. 페르마의 소정리.𝑝 가 소수이고 𝑎 가 𝑝 의 배수가 아니면
𝑎𝑝−1≡1(mod𝑝)
오일러 정리. 이것을 소수가 아닌 수로 넓힌 것이다. 𝜑(𝑛) 을 𝑛 이하의 수 중 𝑛 과 서로소인 것의 개수라 하면, 𝑎 와 𝑛 이 서로소일 때
𝑎𝜑(𝑛)≡1(mod𝑛)
𝑛=𝑝𝑞 (서로 다른 소수)이면 𝜑(𝑛)=(𝑝−1)(𝑞−1) 이다. 𝑛 이하의 수에서 𝑝 의 배수 𝑞 개와 𝑞 의 배수 𝑝 개를 빼고 겹친 하나를 도로 더하면 𝑝𝑞−𝑞−𝑝+1=(𝑝−1)(𝑞−1) 이기 때문이다. 마지막으로 왜 이것이 암호가 되는가.
<왼쪽 점들에는 아무 규칙이 없다. 지수에서 값으로 가는 길은 곱셈 몇 번이지만 값에서 지수로 돌아오는 길은 알려진 방법이 없다>[원본 보기]
𝑔𝑥mod𝑝 를 구하는 것은 7장의 빠른 거듭제곱으로 곱셈 𝑂(log𝑥) 번이면 된다. 지수가 22048 급이어도 수천 번이다.
반대로 값을 보고 𝑥 를 찾는 것은 이산로그 문제이고 알려진 빠른 방법이 없다. 소인수분해도 마찬가지다. 둘 다 13장의 어휘로 말하면 NP 에는 속하는데 P 에 속하는지 아무도 모르는 문제들이다. 여기서 정직해야 한다. 이 문제들이 어렵다는 것은 증명된 적이 없다. 빠른 방법이 아직 발견되지 않았을 뿐이고, 오늘의 공개키 암호 전체가 그 위에 서 있다.
증명
페르마의 소정리를 증명하라.
풀이 보기
𝑎,2𝑎,3𝑎,…,(𝑝−1)𝑎 를 𝑝 로 나눈 나머지를 보자.
이 나머지들이 서로 다르다. 만약 𝑖𝑎≡𝑗𝑎 라면 (𝑖−𝑗)𝑎≡0(mod𝑝) 이고, 𝑝 가 소수이면서 𝑎 를 나누지 않으므로 𝑝 가 𝑖−𝑗 를 나눠야 하는데 |𝑖−𝑗|<𝑝 이므로 𝑖=𝑗 다. 0인 것도 없다.𝑖𝑎≡0 이려면 𝑝 가 𝑖 나 𝑎 를 나눠야 하는데 둘 다 아니다.
그러니 이 𝑝−1 개의 나머지는 1,2,…,𝑝−1 을 순서만 바꾼 것이다. 전부 곱하면 양쪽이 같아야 한다.
𝑎⋅2𝑎⋯(𝑝−1)𝑎≡1⋅2⋯(𝑝−1)(mod𝑝)
왼쪽을 정리하면 𝑎𝑝−1(𝑝−1)! 이다. (𝑝−1)! 은 𝑝 보다 작은 수들의 곱이라 𝑝 와 서로소이므로 역원이 있고, 양변에서 지울 수 있다.
𝑎𝑝−1≡1(mod𝑝)
증명의 열쇠는 "곱하기 𝑎" 가 나머지들을 뒤섞기만 할 뿐 잃거나 겹치지 않는다는 관찰이다. 곧 그것이 순열이라는 것이고, 앞 절에서 블록 암호를 순열이라 부른 것과 같은 생각이다.
예제 184
7100mod13 을 구하라. 그리고 5 의 역원을 mod13 에서 구하라.
풀이 보기
앞의 것. 13이 소수이고 7이 13의 배수가 아니므로 페르마를 쓸 수 있다. 712≡1 이다.
지수를 100에서 4로 줄인 것이 페르마의 값어치다. 빠른 거듭제곱만 써도 되지만 지수를 𝜑 로 나눈 나머지로 먼저 줄이는 것이 실제 구현이 하는 일이다.
뒤의 것.5𝑥≡1(mod13) 을 푼다. 페르마에서 511 이 역원이지만 작은 수라면 그냥 훑는 편이 빠르다. 5×8=40=3×13+1≡1 이므로 8이다.
공개키 암호 — 키 배분 문제와 RSA
대칭키에는 근본적인 문제가 있다. 열쇠를 어떻게 나눠 갖는가. 안전한 통로가 있다면 그 통로로 그냥 메시지를 보내면 될 것이고, 없다면 열쇠도 보낼 수 없다. 게다가 참여자가 𝑛 명이면 짝마다 열쇠가 필요해 𝑛(𝑛−1)/2 개가 든다. 공개키 암호가 이것을 푼다. 열쇠를 둘로 나눈다. 하나는 모두에게 공개하고 하나는 혼자만 갖는다. 공개키로 잠근 것은 비밀키로만 열린다.
<위의 네 상자가 열쇠를 만드는 절차이고 아래가 실제로 쓰는 모습이다. 되돌아오는 근거가 오일러 정리 한 줄이다>[원본 보기]
RSA의 절차는 넷이다.
큰 소수 둘 𝑝,𝑞 를 고른다.
𝑁=𝑝𝑞 와 𝜑(𝑁)=(𝑝−1)(𝑞−1) 을 구한다.
𝜑(𝑁) 과 서로소인 𝑒 를 고르고, 𝑒𝑑≡1(mod𝜑(𝑁)) 인 𝑑 를 확장 유클리드로 구한다.
(𝑁,𝑒) 를 공개하고 𝑑 를 감춘다. 𝑝 와 𝑞 는 지운다.
쓰는 것은 두 줄이다. 암호화는 𝐶=𝑀𝑒mod𝑁, 복호화는 𝑀=𝐶𝑑mod𝑁. 둘 다 7장의 모듈러 거듭제곱이다.
왜 되돌아오는가. 𝑒𝑑≡1(mod𝜑) 이므로 어떤 정수 𝑘 에 대해 𝑒𝑑=1+𝑘𝜑 다. 그러면
𝐶𝑑=𝑀𝑒𝑑=𝑀1+𝑘𝜑=𝑀⋅(𝑀𝜑)𝑘≡𝑀⋅1𝑘=𝑀(mod𝑁)
오일러 정리가 𝑀𝜑≡1 을 보장하므로 𝑀 만 남는다. 앞 절에서 세운 것이 여기서 정확히 쓰였다. 안전성이 기대는 것은 𝑁 을 알아도 𝑝 와 𝑞 를 되찾기 어렵다는 것뿐이다. 되찾으면 𝜑 가 나오고 𝜑 를 알면 𝑑 가 나온다. 그리고 여기 적은 그대로 쓰면 안전하지 않다. 같은 평문이 늘 같은 암호문이 되므로 ECB 와 같은 문제가 생기고 짧은 메시지는 다른 방법으로 깨진다. 평문에 무작위 채움을 섞는 규격을 반드시 함께 써야 한다.
예제 185
𝑝=11,𝑞=13 으로 RSA 열쇠를 만들려 한다. 𝑒=7 을 쓸 수 있는가? 쓸 수 있다면 𝑑 를 구하고 𝑀=5 를 암호화하라.
풀이 보기
𝑁=143 이고 𝜑(𝑁)=10×12=120 이다.
𝑒=7 을 쓰려면 𝜑 와 서로소여야 한다. 120=23×3×5 이라 7은 약수가 아니므로 쓸 수 있다. 𝑑 는 7𝑑≡1(mod120) 을 푸는 값이다. 7×103=721=6×120+1 이므로 𝑑=103 이다.
암호화.57mod143 을 빠른 거듭제곱으로 구한다. 52=25, 54=625≡53 이므로 57=53×25×5. 53×25=1325≡38 이고 38×5=190≡47. 답은 47이다.
47103mod143 이 다시 5가 되는 것은 오일러 정리가 보장한다. 실제 열쇠에서는 𝑝,𝑞 가 각각 300자리를 넘는다. 𝑁 을 소인수분해하지 못하면 𝜑 를 알 수 없고, 𝜑 를 모르면 𝑑 도 구할 수 없다. 안전성이 걸려 있는 곳이 정확히 거기다.
그리고 공개키는 느리다. 대칭키보다 수백 배 이상 느리므로 메시지 자체를 공개키로 암호화하지 않는다. 대칭키 하나를 만들어 그것만 공개키로 보내고, 실제 통신은 대칭키로 한다. 이것을 하이브리드 방식이라 하고, HTTPS 가 하는 일이 정확히 이것이다.
디피-헬만 — 만난 적 없이 같은 열쇠에 이르기
공개키로 대칭키를 보내는 대신, 양쪽이 각자 계산해서 같은 값에 이르는 방법도 있다. 디피-헬만 키 교환이다.
<두 사람이 공개색에 각자의 비밀색을 섞어 보내고, 받은 것에 자기 비밀색을 한 번 더 섞는다. 섞는 순서가 달라도 결과가 같다는 것이 전부다>[원본 보기]
색으로 보면 이렇다. 공개된 색이 하나 있고 각자 비밀 색이 있다. 공개색에 자기 비밀색을 섞어 교환하고, 받은 것에 자기 비밀색을 한 번 더 섞는다. 섞기가 교환법칙을 만족하므로 둘이 같은 색에 이른다. 엿듣는 사람은 세 색을 다 보지만 마지막 색을 만들 수 없다. 색을 다시 분리할 수 없기 때문이다.
수로 바꾸면 섞기가 모듈러 거듭제곱이고, 공개된 것은 소수 𝑝 와 밑 𝑔 다.
앨리스가 비밀 𝑎 를 골라 𝐴=𝑔𝑎mod𝑝 를 보낸다.
밥이 비밀 𝑏 를 골라 𝐵=𝑔𝑏mod𝑝 를 보낸다.
앨리스는 𝐵𝑎, 밥은 𝐴𝑏 를 구한다. 둘 다 𝑔𝑎𝑏mod𝑝 다.
엿듣는 사람은 𝑔,𝑝,𝑔𝑎,𝑔𝑏 를 안다. 𝑔𝑎𝑏 를 얻으려면 𝑎 나 𝑏 를 알아내야 하는데 그것이 이산로그 문제다.
큰 장점은 비밀 𝑎,𝑏 를 통신이 끝난 뒤 버릴 수 있다는 것이다. 그러면 나중에 장기 열쇠가 새더라도 지나간 통신은 풀리지 않는다. 이 성질을 순방향 비밀성이라 하고, 오늘날 TLS 가 RSA 키 전송 대신 디피-헬만을 쓰는 주된 이유다.
못 하는 것도 분명하다. 상대가 누구인지는 전혀 확인하지 않는다. 가운데 낀 사람이 양쪽과 따로 디피-헬만을 하면 양쪽 모두 안전하다고 믿으면서 모든 내용을 그 사람에게 넘긴다. 이것이 중간자 공격이고, 다음 절의 서명과 인증서가 필요한 이유다.
예제 186
𝑝=23,𝑔=5 이고 앨리스가 𝑎=6, 밥이 𝑏=15 를 골랐다. 주고받는 값과 공유되는 값을 구하라.
풀이 보기
앨리스가 보내는 값.52=25≡2, 54≡4 이므로 𝐴=56=54⋅52≡8. 밥이 보내는 값.𝐵=515mod23. 58≡42=16 이고 15=8+4+2+1 이므로 16×4×2×5=640. 640−27×23=640−621=19. 𝐵=19.
공유값. 앨리스는 196mod23 을 구한다. 19≡−4 이므로 (−4)6=46=4096, 4096−178×23=4096−4094=2.
둘 다 2다. 계산 과정은 전혀 달랐는데 같은 값에 이르렀다. 실제로는 𝑝 가 2048비트 이상이라, 이 예에서는 𝑎 를 찾으려고 23가지를 훑으면 되지만 그 크기에서는 훑을 수 있는 수가 아니다.
암호학적 해시와 MAC
5장에서 미뤄 둔 빚을 갚을 차례다. 자료구조용 해시와 암호학적 해시는 목적이 다르다. 요건이 셋 더 붙는다.
성질
뜻
깨지면
역상 저항성
해시 값에서 원래 입력을 찾기 어렵다
비밀번호 저장이 무너진다
제2역상 저항성
주어진 입력과 같은 해시를 내는 다른 입력을 찾기 어렵다
주어진 문서의 위조본을 만들 수 있다
충돌 저항성
같은 해시를 내는 두 입력을 아무거나 찾기 어렵다
서명을 옮겨 붙일 수 있다
셋의 강도가 다르다는 점이 중요하다. 충돌 저항성이 가장 약하다. 5장의 생일 문제 때문이다. 해시가 𝑏 비트면 충돌을 찾는 데 2𝑏/2 번이면 되지만 역상을 찾는 데는 2𝑏 번이 든다. 그래서 128비트 해시는 충돌 저항성 기준으로는 이미 부족하다. 그리고 자료구조용 해시는 빠른 것이 미덕이지만 암호학용은 충분히 느린 것이 미덕일 때도 있다.
왜 해시만으로는 안 되는가
<위쪽은 암호문의 비트를 뒤집으면 평문의 같은 비트가 뒤집히는 것을 보여 준다. 아래쪽 세 상자가 왜 키를 섞은 해시여야 하는지를 말한다>[원본 보기]
그림 위쪽이 이 절의 출발점이다. CTR 처럼 XOR 로 만드는 암호문은 비트를 뒤집으면 평문의 같은 비트가 뒤집힌다. 공격자는 내용을 읽지 못하는데도 내용을 원하는 방향으로 바꿀 수 있다. 그러면 해시를 함께 보내면 되는가. 안 된다. 해시는 비밀이 아니므로 공격자도 다시 계산할 수 있다. 그래서 열쇠를 섞은 해시가 필요하고, 그것이 MAC(메시지 인증 코드)다.
그런데 𝐻(𝐾‖𝑀) 처럼 앞에 붙이면 안 된다. 널리 쓰이는 해시 함수들은 블록을 차례로 먹으며 중간 상태를 갱신하는 구조라 결과 해시가 곧 중간 상태다. 공격자가 그 상태에서 이어서 계산하면 열쇠를 모르고도 𝐻(𝐾‖𝑀‖𝑀′) 을 만들 수 있다. 이것을 길이 확장 공격이라 하고, HMAC 은 두 번 해싱해서 이 길을 막는다.
HMAC(𝐾,𝑀)=𝐻((𝐾⊕opad)‖𝐻((𝐾⊕ipad)‖𝑀))
바깥 해시의 입력이 고정 길이이므로 이어서 계산할 것이 없다.
확인할 때 주의할 것이 하나 더 있다. 두 MAC 을 견주는 비교가 "다른 바이트를 만나면 곧바로 멈추는" 방식이면 안 된다. 걸린 시간으로 어디까지 맞았는지가 새어 나가 한 바이트씩 알아낼 수 있다. 언제나 끝까지 견주는 비교를 써야 한다.
비밀번호를 저장할 때
비밀번호는 해시해서 저장하는데 그냥 해시하면 부족하다. 소금(salt) — 사용자마다 다른 무작위 값 — 을 섞지 않으면 미리 계산해 둔 표 하나로 전부 뚫리고 같은 비밀번호를 쓴 사람들이 드러난다. 그리고 느린 해시를 써야 한다. 수만 번 되풀이하거나 메모리를 많이 쓰게 만드는 것이다. 정직한 사용자는 로그인 때 한 번 치르지만 공격자는 시도마다 치른다. 그 반복 횟수가 곧 무차별 대입에 드는 값이고, 이 둘이 다음 절 SCRAM 의 바탕이 된다.
예제 187
비밀번호를 SHA-256 으로 한 번 해싱해 저장하는 시스템이 있다. 무엇이 문제이고 어떻게 고치는가?
풀이 보기
첫째, 소금이 없다. 같은 비밀번호를 쓴 사람들의 저장값이 같아 그것만 봐도 드러나고, 미리 계산해 둔 표 하나로 여럿이 한꺼번에 뚫린다. 둘째, 너무 빠르다. SHA-256 은 빠른 것이 미덕인 함수다. 흔한 기계가 초당 수십억 번을 계산하므로 짧거나 흔한 비밀번호는 금방 찾힌다.
고치려면 사용자마다 다른 무작위 소금을 섞고 일부러 느린 함수를 쓴다. 반복 횟수나 메모리 사용량을 조절할 수 있게 만든 것들이 있다.
얼마나 느리게 잡는가. 로그인 한 번이 견딜 만한 시간이 기준이다. 5장에서 "해시 함수는 빠른 것이 미덕"이라 한 것과 정확히 반대다. 같은 이름의 도구라도 무엇에 쓰는지에 따라 고르는 기준이 뒤집힌다.
예제 188
두 서버가 같은 API 를 제공하는데 하나는 요청에 HMAC 을 붙이게 하고 다른 하나는 요청 전체를 TLS 로 보낸다. 둘 다 있으면 중복인가?
풀이 보기
중복이 아니다. 지키는 범위가 다르다.
TLS 는 구간을 지킨다. 클라이언트에서 서버까지 오는 동안의 도청과 변조를 막는다. 그런데 그 사이에 중계기가 있으면 거기서 TLS 가 한 번 끊긴다. 부하 분산기나 API 관문이 그렇다. 그 안쪽은 TLS 가 지키지 않는다.
HMAC 은 메시지를 지킨다. 보낸 사람이 붙인 값이므로 몇 단계를 거쳐도 맨 끝에서 확인할 수 있고, "이 열쇠를 가진 쪽이 만든 요청"임을 확인하므로 인증도 겸한다. 그러니 답은 "둘 다 쓴다"이다. TLS 로 기밀성과 구간 보호를, HMAC 으로 종단 간 무결성과 인증을 얻는다. 다만 HMAC 만으로 안 되는 것이 하나 있다. 같은 요청을 그대로 다시 보내는 재전송이다. HMAC 이 그대로 맞으므로 통과한다. 그래서 요청에 시각이나 한 번만 쓰는 번호를 넣고 그것까지 MAC 에 포함시켜야 한다.
전자 서명과 인증서
MAC 은 양쪽이 같은 열쇠를 갖는다. 그래서 "상대가 위조했을 가능성"을 배제하지 못하고, 제3자에게 보여 줄 수도 없다. 전자 서명이 그것을 해결한다.
<위쪽은 서명을 만들고 확인하는 절차다. 아래쪽 사슬이 ‘그 공개키가 정말 그 사람 것인가’ 에 답한다>[원본 보기]
서명은 공개키 암호를 거꾸로 쓴다. 비밀키로 만들고 공개키로 확인한다. 만들 수 있는 사람이 하나뿐이므로 부인 방지가 된다. 그리고 메시지를 통째로 서명하지 않고 해시를 서명한다. 공개키 연산이 느린 데다 메시지가 열쇠 크기보다 길 수 있기 때문이다. 여기서 해시의 충돌 저항성이 왜 중요한지가 드러난다. 같은 해시를 내는 다른 문서를 만들 수 있다면 서명을 그 문서로 옮겨 붙일 수 있다.
그런데 문제가 하나 남는다. 그 공개키가 정말 그 사람 것인지는 무엇이 보증하는가. 디피-헬만이 중간자를 못 막은 것과 같은 문제다.
답이 인증서다. "이 공개키는 이 이름의 것이다"라는 문장에 인증기관이 서명한 것이다. 그 인증기관의 공개키는 또 다른 인증기관이 보증하고, 그렇게 올라가다 뿌리 인증기관에서 멈춘다. 뿌리는 브라우저와 운영체제에 미리 심어져 있고, 믿음의 출발점이 거기다. 그래서 뿌리 하나가 뚫리면 그 아래 전부가 무너지고, 그래서 인증서에 만료 시각과 취소 절차가 따로 있다.
HTTPS 가 연결을 여는 절차를 이 장의 어휘로 정리하면 이렇다.
양쪽이 쓸 수 있는 알고리즘 목록과 난수를 주고받는다.
서버가 인증서를 보내고, 브라우저가 사슬을 따라 올라가며 서명을 확인하고 이름이 접속하려는 주소와 맞는지도 본다.
디피-헬만으로 대칭키를 만든다. 서버가 자기 몫에 서명해서 보내므로 중간자가 끼어들 수 없다. 이후의 모든 내용은 그 대칭키로 암호화하고 MAC 으로 무결성을 지킨다.
이 장에서 배운 도구가 전부 한 번씩 쓰였다. 어느 하나도 혼자서는 충분하지 않았다는 점이 요점이다.
예제 189
브라우저가 인증서를 확인할 때 "서명이 맞는가" 말고 또 무엇을 봐야 하는가?
풀이 보기
서명 확인만으로는 "어떤 인증기관이 이 공개키를 어떤 이름에 묶어 줬다"까지만 안다. 셋을 더 봐야 한다.
이름이 맞는가. 인증서에 적힌 이름이 지금 접속하려는 주소와 같아야 한다. 이것을 빼먹으면 공격자가 자기 소유 주소로 정당하게 발급받은 인증서를 다른 주소인 척 쓸 수 있다. 실제로 많은 라이브러리가 이 검사를 기본으로 안 해서 사고가 났다.
기간이 유효한가. 만료된 인증서는 거절한다. 만료가 있는 이유는 취소가 어렵기 때문이고, 만료가 자동 취소 노릇을 한다. 취소되지 않았는가. 비밀키가 샜다고 신고된 인증서는 만료 전이라도 무효다. 실무에서 가장 자주 건너뛰는 부분인데 확인하러 가는 것 자체가 느리고 실패할 수 있어서다. 그리고 사슬 끝이 내가 믿는 뿌리인가도 확인해야 한다. 사슬이 스스로 완결되어 있어도 그 뿌리를 내가 믿지 않으면 아무 의미가 없다.
프로토콜 셋 — TOTP · SCRAM · JWT
이제 재료를 조립해 실제 프로토콜을 만든다. 셋을 보는데 서로 겨루는 것이 아니라 각각 다른 문제를 푼다.
<왼쪽의 ‘무엇이 걱정인가’ 에서 출발해 오른쪽으로 읽는다. 오른쪽 끝의 약한 곳까지 함께 읽어야 제대로 고른 것이다>[원본 보기]
TOTP — 아는 것 하나에만 기대지 않는다
비밀번호는 새면 끝이다. 그래서 비밀번호 말고 하나를 더 요구한다. TOTP는 그 하나를 "지금 이 순간"으로 만든다. 서버와 기기가 공유한 비밀에 현재 시각을 30초 단위로 자른 값을 넣어 HMAC 을 구하고 그것을 6자리로 줄인다. 요점은 검증 대상 메시지가 시각이라는 것이다. 그래서 값이 30초 뒤에 죽고, 훔쳐봐도 오래 쓸 수 없다.
항목
내용
바탕 도구
HMAC + 공유 비밀 + 현재 시각
설정 전달
otpauth 로 시작하는 URI. 발급자 · 사용자 · 비밀(Base32) · 알고리즘 · 자릿수 · 주기
기본값
알고리즘 SHA1, 자릿수 6, 주기 30초
시계 오차
앞뒤 한 칸씩을 함께 받아들인다. 넓힐수록 편해지고 약해진다
약한 곳
서버가 공유 비밀을 그대로 들고 있다. 피싱은 막지 못한다
SCRAM — 비밀번호를 보내지 않고 안다는 것을 증명한다
비밀번호를 서버로 보내면 서버가 그것을 본다. 실수로 기록에 남기도 하고 관리자가 볼 수도 있다. SCRAM은 비밀번호를 한 번도 보내지 않으면서 양쪽이 그것을 안다는 사실만 확인한다. 얼개는 도전과 응답이다. 서버가 소금과 반복 횟수를 알려 주면 클라이언트가 그것으로 비밀번호를 느리게 해싱해 증거를 만들어 보낸다. 서버는 저장해 둔 값으로 그 증거를 확인할 수 있지만 그 저장값만으로는 증거를 만들 수 없다.
항목
내용
서버가 저장하는 것
사용자명 · 소금 · 반복 횟수 · 저장키 · 서버키. 비밀번호 평문은 없다
주고받는 순서
클라이언트가 사용자명과 난수를 보내고, 서버가 소금 · 반복 횟수 · 난수를 답하고, 클라이언트가 증거를 보내고, 서버가 자기 증거로 답한다
증거를 만드는 법
소금 친 비밀번호에서 클라이언트키를 얻고, 그것을 해시한 저장키로 전체 대화에 서명한 뒤 클라이언트키와 XOR 한다
서버가 확인하는 법
XOR 을 되돌려 클라이언트키를 복원하고, 해시한 값이 저장키와 같은지 본다
상호 인증
서버도 서버키로 증거를 만들어 보낸다. 가짜 서버를 걸러 낸다
약한 곳
서버 저장값이 새면 그 값으로 로그인할 수 있다. 채널 자체는 TLS 가 지켜야 한다
대화마다 양쪽 난수가 들어가는 것에 주목하라. 그래서 지난 대화를 그대로 다시 보내는 재전송이 막힌다. 앞 절에서 HMAC 만으로는 못 막는다고 한 그 문제를 이렇게 푼다.
JWT — 서버가 상태를 두지 않는다
요청마다 "이 사람이 로그인했는가"를 확인하려면 저장소를 뒤져야 하고, 서버가 여러 대면 그 저장소가 곧 병목이 된다. JWT는 확인할 내용을 토큰 안에 적고 서명해서 이용자에게 준다. 받은 서버는 서명만 확인하면 된다. 아무것도 뒤지지 않는다.
구조가 세 조각이고 점으로 잇는다. 헤더 · 내용 · 서명을 각각 패딩 없는 base64url 로 적는다. URL 과 HTTP 헤더에 그대로 넣을 수 있게 하려는 것이다.
항목
내용
두 형식
서명만 하는 JWS 와 암호화까지 하는 JWE
헤더에 적는 것
alg(알고리즘) · typ(종류) · cty(내용 형식) · crit(반드시 처리할 확장)
가장 흔한 사고가 둘이다. 첫째, 내용에 비밀을 담는 것. base64 는 암호가 아니라 표기법이라 누구나 풀어 읽는다. 감춰야 할 것이 있으면 JWE 를 쓰거나 아예 담지 않는다.
둘째, 헤더의 알고리즘을 그대로 믿는 것. 공격자가 알고리즘을 "없음"으로 바꾸고 서명을 지워 보내면 순진한 구현은 "서명이 필요 없다"고 읽고 통과시킨다. 공개키 방식을 대칭키 방식으로 바꿔치기하는 변형도 있다. 서버가 받아들일 알고리즘을 미리 정해 두고 그것으로만 확인해야 한다.
취소도 골칫거리다. 한 번 발급하면 만료까지 유효하므로 로그아웃이 즉시 반영되지 않는다. 대응은 만료를 짧게 두고 갱신용 토큰을 따로 두는 것이고, 갱신 때 저장소를 확인하므로 그 주기만큼만 늦어진다.
예제 190
TOTP 의 6자리 값을 공격자가 무작위로 찍어 맞힐 확률은 얼마인가? 서버는 무엇을 더 해야 하는가?
풀이 보기
6자리이므로 한 번에 맞힐 확률이 10−6 이다. 작아 보이지만 제한이 없으면 백만 번 시도하면 된다.
30초마다 값이 바뀌는 것도 도움이 되지 않는다. 바뀌어도 여전히 백만 분의 일이고 시도를 빠르게 하면 한 창 안에 수만 번도 가능하다. 그래서 시도 횟수를 제한해야 한다. 몇 번 틀리면 잠그거나 점점 느리게 답한다. 규격이 아니라 구현이 챙겨야 할 몫이다.
시계 오차를 위해 앞뒤 칸을 받아들이는 것도 값을 치른다. 창을 넓힐수록 맞힐 확률이 그만큼 곱해진다. 그리고 한 번 쓴 값은 다시 받지 않아야 한다. 안 그러면 훔쳐본 값을 같은 창 안에 그대로 다시 쓸 수 있다.
설계 판단
사내 시스템에 로그인 방식을 설계한다. TOTP · SCRAM · JWT 중 무엇을 어떻게 쓰겠는가?
풀이 보기
셋 다 쓰되 자리가 다르다. 각각이 다른 문제를 푸는 도구이기 때문이다.
로그인 순간에는 SCRAM 계열. 비밀번호를 서버로 보내지 않는다. 다만 TLS 위에서 하는 것이 전제다. 거기에 TOTP 를 더한다. 비밀번호가 새더라도 30초짜리 값을 함께 요구하면 그것만으로는 못 들어온다.
로그인 뒤에는 JWT. 요청마다 저장소를 뒤지지 않아도 되고 서버를 늘리기 쉽다. 만료는 짧게, 갱신 토큰은 저장소에서 확인한다.
판단의 기준은 이렇다. SCRAM 은 비밀번호를 지키고, TOTP 는 비밀번호가 샜을 때를 대비하고, JWT 는 확인 비용을 줄인다. 그리고 실무에서 진짜 답은 하나 더 있다. 이 셋을 직접 구현하지 않는 것이다. 규격이 있고 구현이 있다. 로그인은 검증된 인증 서비스에 맡기고, 우리가 하는 일은 그것을 올바르게 이어 붙이는 것이다.
CSRF — 프로토콜 설계의 실패 사례
마지막으로 암호가 전혀 등장하지 않는 취약점을 본다. 모든 통신이 TLS 로 암호화되고 인증서도 완벽한데 여전히 뚫린다. 프로토콜의 설계 자체가 틀렸기 때문이다.
<이용자는 ③ 을 누른 적도 본 적도 없다. 브라우저가 은행으로 가는 요청이라는 이유만으로 쿠키를 자동으로 붙였을 뿐이다>[원본 보기]
문제의 뿌리는 한 문장이다. 쿠키는 "어느 서버로 가는가"만 보고 붙는다. "누가 이 요청을 일으켰는가"는 담기지 않는다. 그래서 은행에 로그인해 둔 이용자가 공격자의 페이지를 열면 그 페이지에 숨어 있던 요청이 이용자의 쿠키를 그대로 달고 은행으로 가고, 은행은 정상적인 요청과 구별할 방법이 없다.
15장의 TOCTOU 와 성격이 같다는 점을 보라. 거기서는 검사한 시점과 쓰는 시점 사이가 벌어져 있었고 여기서는 인증한 시점과 요청한 시점 사이가 벌어져 있다. 그러니 방어는 "이 요청이 어디서 시작됐는가"를 확인하는 것이다.
방어
어떻게
한계
SameSite 쿠키
다른 사이트에서 시작된 요청에는 쿠키를 붙이지 않는다
가장 근본적이지만 정상적인 사이트 간 이동도 막을 수 있다
요청 토큰
서버가 준 값을 요청에 함께 넣게 한다. 남의 사이트는 그 값을 읽을 수 없다
값을 발급하고 확인하는 장치가 필요하다
출처 헤더 검사
Origin 이나 Sec-Fetch-Site 로 요청이 어디서 왔는지 서버가 직접 본다
헤더가 없는 옛 요청을 어떻게 다룰지 정해야 한다
그리고 반드시 알아 둘 것이 하나 있다. 남의 스크립트가 우리 페이지 안에서 돌 수 있다면 이 방어는 전부 무의미하다. 그 스크립트는 우리 사이트의 일부로 돌기 때문에 토큰도 읽고 요청도 마음대로 보낸다. 여기서 이 장 전체의 결론이 나온다. 보안은 가장 약한 고리에서 무너진다. 암호를 아무리 잘 써도 그 위의 프로토콜이 틀렸으면 소용없고, 프로토콜이 옳아도 그것을 담는 코드에 구멍이 있으면 소용없다.
예제 192
어떤 사이트가 CSRF 를 막으려고 "중요한 요청은 POST 로만 받는다"는 규칙을 세웠다. 충분한가?
풀이 보기
충분하지 않다. 공격자의 페이지는 POST 요청도 얼마든지 만들 수 있다. 숨긴 양식을 두고 자동으로 제출하게 하면 된다. 이용자에게는 아무것도 보이지 않는다.
그래도 아무 소용이 없는 것은 아니다. GET 으로 상태를 바꾸는 요청을 받으면 이미지 주소 하나만으로도 공격이 되어 게시판에 이미지를 올리는 것으로 끝난다. POST 로 제한하면 적어도 그 통로는 막힌다.
그러니 이 규칙은 지켜야 할 위생 수칙이지 방어가 아니다. 실제 방어는 앞의 표에 있는 셋이다. 여기서 흔한 사고방식 하나를 경계하자. "공격을 어렵게 만드는 것"과 "공격을 막는 것"은 다르다. 공격자는 조금 더 수고할 뿐이다. 앞의 케르크호프스 원리가 말한 것이 이것이다.
설계 판단
자바스크립트로 요청을 보내는 화면에서 CSRF 토큰을 어떻게 전달하겠는가? 쿠키에 담아 보내면 되는가?
풀이 보기
쿠키에 담아 그 값을 읽어 헤더에 실어 보내는 방식이 널리 쓰인다. 핵심은 쿠키는 자동으로 붙지만 헤더는 자동으로 붙지 않는다는 것이다.
공격자의 페이지는 그 쿠키를 읽을 수 없다. 브라우저가 다른 출처의 스크립트에게 남의 쿠키 내용을 주지 않으므로 헤더를 채울 수 없다. 서버는 쿠키의 값과 헤더의 값이 같은지만 보면 된다. 저장소에 아무것도 두지 않아도 된다는 점이 편하다.
조건이 붙는다. 그 쿠키에는 스크립트가 못 읽게 하는 표시를 달면 안 된다. 우리 스크립트가 읽어야 하므로 세션 쿠키와 따로 두어야 한다. 앞에서 말한 한계는 그대로 남는다. 우리 페이지 안에서 남의 스크립트가 돌면 그 값도 읽힌다. 그리고 이 방식은 공격자가 우리 도메인의 쿠키를 심을 수 없다는 전제 위에 서 있다.
이 장의 정리
이 장의 도구들은 각각 하나씩만 준다. 무엇이 필요한지 정하지 않고 도구부터 고르면 반드시 빈 곳이 생긴다.
도구
무엇을 주는가
무엇을 주지 않는가
대칭키 암호
기밀성. 빠르다
무결성 · 인증. 그리고 열쇠를 나눠 갖는 방법
공개키 암호
열쇠 배분 문제의 해결
속도. 그래서 대칭키를 나르는 데만 쓴다
디피-헬만
만난 적 없이 공유 열쇠. 순방향 비밀성
상대가 누구인지 — 중간자를 못 막는다
암호학적 해시
고정 길이 지문. 되돌릴 수 없다
무결성. 누구나 다시 계산할 수 있기 때문
MAC (HMAC)
무결성 + 열쇠를 아는 쪽이라는 인증
부인 방지 · 재전송 방지
전자 서명
무결성 + 인증 + 부인 방지
기밀성
인증서
공개키와 이름의 연결
뿌리 인증기관에 대한 믿음까지는 못 준다
용어 · 사실
뜻
케르크호프스의 원리
알고리즘이 공개돼도 열쇠만 비밀이면 안전해야 한다
𝑎≡𝑏(mod𝑛)
두 수를 n 으로 나눈 나머지가 같다. 덧셈과 곱셈이 보존된다
모듈러 역원
𝑎𝑥≡1(mod𝑛) 인 x. a 와 n 이 서로소일 때만 있다
페르마 소정리
p 가 소수이고 p 가 a 를 안 나누면 𝑎𝑝−1≡1(mod𝑝)
오일러 정리
a 와 n 이 서로소면 𝑎𝜑(𝑛)≡1(mod𝑛). 𝑛=𝑝𝑞 면 𝜑=(𝑝−1)(𝑞−1)
RSA
𝐶=𝑀𝑒mod𝑁, 𝑀=𝐶𝑑mod𝑁. 𝑒𝑑≡1(mod𝜑(𝑁))
이산로그 문제
𝑔𝑥mod𝑝 에서 x 를 되찾는 문제. 알려진 빠른 방법이 없다
ECB 모드
블록마다 따로 암호화. 같은 평문 블록이 같은 암호문이 되어 쓰면 안 된다
길이 확장 공격
𝐻(𝐾‖𝑀) 을 MAC 으로 쓰면 뒤에 덧붙인 것의 값을 만들 수 있다
마지막으로 이 장 전체에 걸리는 경고를 다시 적는다. 암호를 직접 구현하지 마라. 여기서 배운 것은 무엇이 무엇을 지켜 주는지 판단하기 위한 것이지 만들기 위한 것이 아니다. 실무에서 우리가 할 일은 검증된 구현을 규격대로 이어 붙이고, 빠진 목표가 없는지 확인하는 것이다.