나뭇잎 교과서

트리·그래프로 문제 해결

지도 앱은 수많은 교차로 가운데 가장 가까운 길을 순식간에 찾고, 사전 앱은 수십만 개의 단어에서 한 단어를 바로 꺼냅니다. 데이터를 그래프와 트리로 저장해 두면, 어떤 순서로 살펴보느냐에 따라 같은 문제를 훨씬 적은 일로 풀 수 있습니다.

본문

트리·그래프로 문제 해결

본문

사전 앱에 단어를 입력하면 순식간에 뜻이 나온다. 단어가 100만 개 들어 있다면, 앞에서부터 하나씩 견주는 방법으로는 최악의 경우 100만 번 견주어야 한다. 그런데 단어를 트리로 잘 정리해 두면 스무 번 남짓만 견주어도 찾는다. 같은 단어들을 어떻게 저장하느냐가 왜 이렇게 큰 차이를 만들까? 그리고 지도 앱은 수많은 교차로 가운데 가장 가까운 길을 어떤 순서로 살펴 찾을까?

그림 1. 이진 탐색 트리의 높이(찾을 때의 최대 비교 횟수). 균형 잡힌 트리는 원소가 두 배가 될 때마다 높이가 1씩만 늘고, 한쪽으로 치우친 트리는 원소 수만큼 높아진다(막대는 로그 길이).
먼저 알아야 할 것트리와 그래프의 구현(트리와 그래프의 구현), 큐(스택과 큐의 구현), 로그(수학).

2.4.1이진 탐색 트리

이진 탐색 트리는 각 정점의 왼쪽 아래에는 그보다 작은 값만, 오른쪽 아래에는 큰 값만 두는 이진 트리이다. 값을 찾을 때는 뿌리에서 시작해, 찾는 값이 정점보다 작으면 왼쪽으로, 크면 오른쪽으로 내려간다. 한 번 견줄 때마다 남은 후보의 절반쯤을 버리므로, 트리가 고르게 퍼져 있으면 찾는 데 드는 비교 횟수가 트리의 높이와 같다. 그런데 이미 정렬된 순서로 값을 넣으면 모든 값이 오른쪽으로만 붙어 한 줄짜리 트리가 되고, 리스트를 앞에서부터 훑는 것과 같아진다.

정리 2.4.1 트리의 높이와 탐색 횟수

원소가 \(n\)개인 이진 트리의 높이는 적어도 \(\lceil \log_2(n+1) \rceil\)이고, 균형 잡힌 이진 탐색 트리는 이 높이에 가까워 \(\log_2 n\)번 남짓의 비교로 값을 찾는다. 한쪽으로 치우친 트리는 높이가 \(n\)이 되어 최악 \(n\)번을 비교한다.

그림 1에서 단어 수를 바꾸어 보자. 단어 1000개는 균형 잡힌 트리에서 10번, 100만 개는 20번, 10억 개도 30번이면 찾는다. 원소가 두 배가 될 때마다 비교가 한 번씩만 늘기 때문이다. 같은 100만 개를 사전 순서대로 넣어 치우친 트리는 최악 100만 번 비교한다. 그래서 실제 사전 앱과 데이터베이스는 넣고 지울 때마다 트리를 다시 고르게 맞추는 균형 트리를 쓴다.1

2.4.2그래프를 살펴보는 순서

그래프에서 어떤 정점에 이르는 길을 찾으려면 정점들을 빠짐없이, 겹치지 않게 살펴야 한다. 너비 우선 탐색은 출발점에서 가까운 정점부터 차례로 큐에 넣어 살핀다. 그래서 간선마다 거리가 같으면 처음 도착한 길이 가장 짧은 길이다. 깊이 우선 탐색은 한 방향으로 갈 수 있는 데까지 가다가 막히면 되돌아오며, 스택이나 재귀로 짠다. 미로의 출구가 있는지, 그래프가 몇 덩어리로 나뉘는지 알아볼 때 쓴다. 도로마다 걸리는 시간이 다르면 다익스트라 알고리즘이 지금까지 가장 가까운 정점부터 살펴 가장 빠른 길을 찾는다.

탐색쓰는 자료 구조알맞은 문제 너비 우선큐가장 적게 갈아타는 길 깊이 우선스택, 재귀길이 있는지, 덩어리 찾기 다익스트라우선순위 큐가장 빠른 길 (가중치)
그림 2. 정점을 살펴보는 순서가 탐색으로 풀 수 있는 문제를 정한다.

2.4.3예제 풀이

예제 2.4.2

값 1부터 7까지를 (가) 1, 2, 3, 4, 5, 6, 7의 순서로, (나) 4, 2, 6, 1, 3, 5, 7의 순서로 빈 이진 탐색 트리에 넣는다. 두 트리의 높이를 구하고, 각 트리에서 7을 찾을 때 몇 번 견주는지 구하여라.

(가)는 넣을 때마다 이전 값보다 커서 오른쪽에만 붙어 1–2–3–4–5–6–7의 한 줄이 되므로 높이가 7이다. 7을 찾으려면 1부터 차례로 7번 견주어야 한다.

(나)는 4가 뿌리, 그 왼쪽에 2, 오른쪽에 6이 오고, 2 아래에 1과 3, 6 아래에 5와 7이 붙어 높이가 3인 꽉 찬 트리가 된다. 7을 찾으려면 4, 6, 7로 3번 견주면 된다. \(\lceil \log_2 8 \rceil = 3\)으로 가장 낮은 높이이다. 즉 같은 값이라도 넣는 순서가 트리의 모양을, 트리의 모양이 탐색의 빠르기를 정한다. 그래서 트리를 쓰는 프로그램은 트리가 치우치지 않도록 관리해야 한다.

아래 탐구 카드에서 이진 탐색 트리와 그래프 탐색으로 단어 찾기와 길 찾기 문제를 풀어 보자.

  1. AVL 트리와 레드–블랙 트리는 원소를 넣거나 지울 때 트리를 조금씩 돌려 높이를 늘 \(\log n\) 근처로 지키는 균형 트리이다. 많은 프로그래밍 언어의 정렬된 사전 자료형이 이 방법으로 구현된다.
마무리

이진 탐색 트리는 작은 값은 왼쪽, 큰 값은 오른쪽에 두어 한 번 견줄 때마다 후보의 절반을 버리므로, 균형이 잡히면 \(\log_2 n\)번 남짓으로 값을 찾지만 한쪽으로 치우치면 \(n\)번이 걸린다. 그래프는 너비 우선, 깊이 우선, 다익스트라처럼 살펴보는 순서를 정해 길을 찾는다. 그래서 처음 질문의 사전 앱은 단어 100만 개를 균형 잡힌 트리에 담아, 스무 번 남짓만 견주어 한 단어를 찾는다.

탐구 1

미로의 최단 경로는 큐와 스택 중 무엇으로 찾을까?

그래프 탐색 · BFS와 DFS · 격자 미로

꺼내서 방문한 칸(숫자 = 따라온 경로 길이)todo에 들어 있는 칸벽

 미로
꺼낸 횟수
0
todo에 든 칸 수
1
찾은 경로 / 최단
—

              

격자의 칸이 정점, 이웃한 두 빈칸 사이가 간선인 그래프이다. 이웃은 위·오른쪽·아래·왼쪽 순서로 넣는다. 두 방법의 코드는 꺼내는 한 줄(popleft와 pop)만 다르다.

  1. 질문출발점 S에서 도착점 G까지 가장 적게 움직이는 길을 찾고 싶다. 사람은 눈으로 훑어보지만, 컴퓨터는 칸을 하나씩 확인할 수밖에 없다. 어떤 순서로 칸을 확인해야 처음 찾은 길이 곧 가장 짧은 길이 될까?
  2. 복원그래프는 정점과 간선으로 이루어지고, 각 정점의 이웃을 인접 리스트로 저장한다. 큐는 먼저 넣은 것을 먼저 꺼내고(FIFO), 스택은 나중에 넣은 것을 먼저 꺼낸다(LIFO). 파이썬에서는 collections.deque 하나로 둘 다 쓸 수 있다.
  3. 설명탐색은 "아직 볼 칸 목록(todo)"에서 하나를 꺼내 방문하고, 그 칸의 이웃을 다시 목록에 넣는 일을 되풀이한다. 목록을 큐로 쓰면 출발점에서 1칸 떨어진 칸을 모두 본 뒤에 2칸 떨어진 칸을 보게 되어, 물결처럼 퍼지는 너비 우선 탐색(BFS)이 된다. 스택으로 쓰면 방금 넣은 칸부터 파고드는 깊이 우선 탐색(DFS)이 된다. prev 사전에는 각 칸에 처음 들어올 때 거쳐 온 칸을 적어 두어, 도착한 뒤 거꾸로 따라가면 경로가 나온다.
  4. 연결BFS는 거리 순서대로 칸을 꺼내므로, 간선의 길이가 모두 같은 그래프에서 G를 처음 꺼낸 순간의 경로가 최단 경로이다. DFS는 길을 찾기는 하지만 가장 짧다는 보장이 없다. 두 방법 모두 칸 하나를 한 번만 방문하므로, 칸이 V개, 연결이 E개이면 작업량은 V + E에 비례한다.
  5. 적용갈림길 미로에서 BFS와 DFS를 각각 끝까지 실행하고, 찾은 경로 길이와 최단 거리를 비교해 보자. 빈 방에서는 방문한 칸의 숫자가 어떻게 퍼지는가? todo에 든 칸 수가 가장 클 때는 BFS와 DFS 중 어느 쪽이 더 큰가? 칸을 눌러 벽을 세워 DFS가 최단 경로를 찾게 만들 수 있는가? 고리 없는 미로에서는 왜 두 방법의 경로 길이가 같을까?
  6. 판단"DFS는 틀린 방법"이 아니다. 길이 있는지 확인하거나, 연결된 부분을 모두 칠하거나, 모든 경우를 따지는 백트래킹에는 DFS가 자연스럽고 메모리도 적게 쓰는 경우가 많다. 또 BFS가 최단을 보장하는 것은 간선 길이가 모두 같을 때뿐이다. 길마다 걸리는 시간이 다른 도로망에서는 데이크스트라 알고리즘처럼 우선순위 큐를 쓰는 방법이 필요하다. 고리가 없는 미로는 그래프로 보면 트리이고, 트리에서는 두 정점을 잇는 경로가 하나뿐이다.

확인조건이 달라지면

지하철 노선도에서 "환승을 포함해 가장 적은 역을 거치는 경로"를 찾으려 한다. 역은 정점, 이웃한 두 역은 간선이다. 알맞은 방법은?

탐구 2

15개 중 하나를 몇 번 비교해서 찾을까?

트리 활용 · 이진 탐색 트리 · 넣는 순서와 높이

트리에서 비교 횟수
0
리스트를 앞에서부터 볼 때
—
트리 높이 (층 수)
—
평균 비교 (트리 / 리스트)
—

              

노드는 key, left, right 세 속성을 가진 객체이다. 새 키는 루트에서 같은 비교를 따라 내려가 비어 있는 자리에 붙인다. 리스트 칸은 넣은 순서 그대로 저장한 리스트를 앞에서부터 확인할 때의 비교 횟수이다. 평균은 15개 키를 한 번씩 찾을 때의 값이다.

  1. 질문회원 번호 15개를 리스트에 넣어 두고 하나를 찾으면, 운이 나쁘면 15번을 모두 확인해야 한다. 회원이 100만 명이면 어떨까? 저장하는 모양을 바꾸면 확인 횟수를 줄일 수 있을까?
  2. 복원트리는 고리가 없는 연결 그래프이고, 이진 트리는 각 노드의 자식이 최대 둘이다. 노드 객체가 왼쪽·오른쪽 자식을 가리키도록 하거나, 리스트의 인덱스로 자식을 표시해 구현할 수 있다.
  3. 설명이진 탐색 트리(BST)는 모든 노드에서 "왼쪽 아래의 키는 모두 작고, 오른쪽 아래의 키는 모두 크다"는 규칙을 지킨다. 찾을 때는 루트에서 시작해 값이 같으면 멈추고, 작으면 왼쪽, 크면 오른쪽으로 내려간다. 한 번 비교할 때마다 남은 후보의 한쪽 가지 전체가 통째로 빠진다.
  4. 연결비교 횟수는 많아야 트리의 높이(층 수)이다. 키 n개가 고르게 퍼지면 높이는 약 log₂n이라 15개는 4층, 100만 개도 약 20층이면 된다. 그러나 높이는 키를 넣은 순서가 정한다. 정렬된 순서로 넣으면 한쪽으로만 자라 리스트와 같아진다.
  5. 적용가운데부터 넣은 트리에서 65, 15, 52를 찾아 비교 횟수를 세어 보자. 트리에 없는 값은 어디에서 멈추는가? 같은 15개를 작은 것부터 차례로 넣으면 트리 높이와 평균 비교 횟수가 어떻게 되는가? 무작위 순서를 여러 번 눌러 높이가 대개 몇 층인지 확인해 보자.
  6. 판단"트리에 넣기만 하면 빨라진다"는 틀린 생각이다. 빠른 이유는 높이가 낮기 때문이고, 높이는 넣는 순서에 따라 4층에서 15층까지 달라진다. 그래서 실제 데이터베이스나 언어 라이브러리는 넣고 뺄 때마다 모양을 고르게 다시 맞추는 균형 트리(AVL 트리, 레드-블랙 트리, B-트리 등)를 쓴다. 파이썬에서 값이 있는지만 빠르게 확인하려면 set이나 dict가 보통 더 간단하다.

확인조건이 달라지면

도서관이 책 번호 1023개를 번호가 작은 순서대로 이진 탐색 트리에 하나씩 넣었다(균형 맞추기 없음). 한 책을 찾을 때 비교 횟수의 최댓값은?

AI 출제 문항연습문제 풀어보기11문항
Esc

↑↓ 고르기Enter 열기