나뭇잎 교과서

트리와 그래프의 구현

지하철 노선도의 역과 노선, 폴더 안의 폴더처럼 ‘무엇이 무엇과 이어져 있는가’를 담은 자료는 줄 세운 리스트만으로는 표현하기 어렵습니다. 같은 연결 관계도 표로 담느냐, 이웃 목록으로 담느냐에 따라 필요한 메모리와 빠른 연산이 달라집니다.

본문

트리와 그래프의 구현

본문

길 찾기 프로그램을 만들려고 도시의 교차로와 도로를 컴퓨터에 담는다. 교차로 사이가 도로로 이어졌는지를 가로세로 모두 교차로 이름이 붙은 표에 0과 1로 적으면 이해하기 쉽다. 그런데 교차로가 1만 개이면 표는 1억 칸이 되고, 그 칸 대부분은 0이다. 교차로 하나에 이어진 도로는 기껏해야 네댓 개이기 때문이다. 같은 연결 관계를 더 적은 메모리로 담는 방법이 있을까? 그리고 두 방법은 어떤 일에서 각각 빠를까?

그림 1. 같은 그래프를 인접 행렬과 인접 리스트로 담을 때 필요한 칸 수(막대는 로그 길이). 이웃이 적은 그래프일수록 행렬은 빈칸이 대부분이다.
먼저 알아야 할 것리스트와 이중 리스트(정보), 스택과 큐(스택·큐로 문제 해결), 경우의 수(수학).

2.3.1그래프와 트리

그래프는 정점(점)과 그 사이를 잇는 간선(선)으로 '무엇이 무엇과 이어져 있는가'를 나타내는 자료 구조이다. 지하철역과 노선, 사람과 친구 관계, 웹 문서와 링크가 그래프이다. 간선에 방향이 있으면 방향 그래프, 거리나 시간 같은 값이 붙으면 가중치 그래프이다. 트리는 그래프 가운데 고리가 없고 모든 정점이 이어진 것으로, 맨 위의 뿌리에서 가지가 갈라진다. 폴더 구조, 가계도, 토너먼트 대진표가 트리이다. 정점이 \(n\)개인 트리에는 간선이 늘 \(n - 1\)개 있다.

정리 2.3.1 인접 행렬과 인접 리스트

정점이 \(V\)개, 간선이 \(E\)개인 그래프를 인접 행렬로 담으면 \(V \times V\) 칸이 필요하고, 두 정점이 이웃인지는 칸 하나를 보면 바로 안다. 인접 리스트로 담으면 정점마다 이웃의 목록을 두어 대략 \(V + 2E\) 칸이 필요하고, 한 정점의 이웃을 모두 훑는 일이 빠르다.

그림 1에서 두 값을 바꾸어 보자. 교차로 1만 개, 교차로마다 이웃 4개이면 행렬은 1억 칸, 리스트는 약 5만 칸으로 2000배 차이 난다. 정점이 100만 개인 큰 도시라면 행렬은 1조 칸이라 보통 컴퓨터에 담을 수조차 없다. 그런데 이웃 수가 정점 수에 가까운 빽빽한 그래프라면 두 방법의 크기 차이가 줄고, 행렬이 '이웃인가'를 빨리 답하는 장점이 커진다. 그래서 그래프의 모양과 자주 할 연산을 보고 저장 방법을 고른다.1

2.3.2트리의 구현

트리는 각 정점이 자식들의 목록을 가지게 하여 만든다. 자식이 많아야 둘인 이진 트리는 각 정점이 값, 왼쪽 자식, 오른쪽 자식을 가지게 하거나, 리스트 하나에 1번부터 차례로 담고 \(i\)번 정점의 자식을 \(2i\)번과 \(2i + 1\)번 자리에 두기도 한다. 리스트로 담는 방식은 트리가 고르게 채워져 있을 때 메모리를 아끼고, 부모와 자식을 계산만으로 찾을 수 있다. 하지만 트리가 한쪽으로 치우치면 빈자리가 많아진다. 그래프와 마찬가지로 트리도 모양에 따라 알맞은 구현이 다르다.

연산인접 행렬인접 리스트 필요한 메모리V²V + 2E 두 정점이 이웃인가칸 하나로 바로이웃 목록을 훑음 한 정점의 모든 이웃V칸을 다 봄이웃 수만큼만 봄
그림 2. 같은 연결 관계도 담는 방법에 따라 필요한 메모리와 빠른 연산이 다르다.

2.3.3예제 풀이

예제 2.3.2

정점 A, B, C, D, E와 간선 A–B, A–C, B–D, C–D, D–E로 된 무방향 그래프가 있다. 인접 행렬과 인접 리스트로 나타내고, 각각 필요한 칸 수를 구하여라. 이 그래프에서 간선 하나를 지워 트리로 만들려면 어떤 간선을 지울 수 있는가?

인접 행렬은 5 × 5 = 25칸이고, A–B, A–C, B–D, C–D, D–E에 해당하는 칸이 양쪽으로 1이라 1이 10칸, 0이 15칸이다. 인접 리스트는 A: [B, C], B: [A, D], C: [A, D], D: [B, C, E], E: [D]로, 정점 5칸과 이웃 10칸을 합쳐 15칸이다.

이 그래프는 정점 5개에 간선 5개로, 트리의 간선 수 4보다 하나 많아 고리가 하나 있다. 고리는 A–B–D–C–A이다. 이 고리에 속한 A–B, B–D, C–D, A–C 가운데 하나를 지우면 트리가 된다. D–E를 지우면 E가 떨어져 나가므로 안 된다. 즉 자료 구조를 고르는 일은 연결 관계의 모양을 이해하는 일에서 시작한다. 그래서 정점과 간선의 수, 고리가 있는지를 먼저 살펴 그래프인지 트리인지, 어떤 저장 방법이 알맞은지 정한다.

아래 탐구 카드에서 지하철 노선도와 폴더 구조를 그래프와 트리로 담아 보고, 두 저장 방법을 비교해 보자.

  1. 1736년 오일러는 쾨니히스베르크의 다리 일곱 개를 한 번씩만 건너는 길이 없음을 보이며, 땅을 점으로, 다리를 선으로 바꾸어 생각했다. 이것이 그래프 이론의 시작으로 꼽힌다.
마무리

그래프는 정점과 간선으로 연결 관계를 나타내고, 트리는 고리가 없이 모든 정점이 이어진 그래프이다. 같은 그래프도 인접 행렬은 \(V^2\) 칸으로 이웃 여부를 바로 답하고, 인접 리스트는 \(V + 2E\) 칸으로 이웃을 훑는 일이 빠르다. 그래서 처음 질문의 교차로 1만 개는 이웃이 몇 개뿐이라 행렬로 담으면 1억 칸 대부분이 비어 메모리가 모자라고, 인접 리스트로 담으면 5만 칸 남짓이면 된다.

탐구 1

같은 그래프를 표로 저장할까, 목록으로 저장할까?

그래프의 구현 · 인접 행렬 · 인접 리스트 · 방향 그래프
간선 수 m
0
행렬의 칸 수
0
리스트의 항목 수
0
이웃 찾기 (행렬/리스트)
—

              

행렬의 칸을 눌러도 간선을 잇거나 지울 수 있다. 무방향 그래프에서는 u–v 간선 하나를 u→v, v→u 두 방향으로 저장한다.

  1. 질문지하철 노선도, 친구 관계, 웹 페이지의 링크는 모두 ‘무엇과 무엇이 이어져 있다’는 정보이다. 이것을 프로그램 안에 저장하려면 어떤 모양으로 담아야 할까?
  2. 복원그래프는 정점(점)과 간선(두 정점을 잇는 선)으로 이루어진다. 간선에 방향이 있으면 방향 그래프, 없으면 무방향 그래프이다. 한 정점에 이어진 간선 수를 그 정점의 차수라 한다. 사이클이 없고 모든 정점이 이어진 그래프가 트리이다.
  3. 설명인접 행렬은 n × n 표에서 u행 v열을 1(간선 있음) 또는 0으로 채운다. 파이썬에서는 리스트의 리스트 adj[u][v]이다. 인접 리스트는 정점마다 이웃의 목록을 둔다. graph = [[] for _ in range(n)]을 만들고 간선마다 graph[u].append(v)를 한다(무방향이면 반대 방향도). 무방향 그래프의 인접 행렬은 대각선을 기준으로 대칭이다.
  4. 연결행렬은 간선이 몇 개든 항상 n²칸이 필요하지만, u와 v가 이어졌는지는 칸 하나만 보면 안다. 리스트는 무방향이면 2m개, 방향이면 m개 항목만 저장하지만, 이어졌는지 알려면 u의 목록을 훑어야 한다. 대신 ‘u의 이웃을 모두 방문하기’는 리스트가 차수만큼만 보면 되어, n칸을 모두 봐야 하는 행렬보다 빠르다. 그래프 탐색(너비 우선, 깊이 우선)은 이 연산을 되풀이하므로 대개 인접 리스트를 쓴다.
  5. 적용정점 3을 고르고 이웃을 찾을 때 두 방식이 보는 칸 수를 비교해 보자. ‘완전 그래프’와 ‘트리’에서 행렬의 칸 수와 리스트의 항목 수를 비교해 보자. ‘방향 그래프’를 켜고 행렬 칸을 하나 누르면 대칭이 어떻게 깨지는가? n = 8 트리에는 간선이 몇 개인가?
  6. 판단“인접 리스트가 항상 더 좋다”는 아니다. 간선이 n²에 가까운 조밀한 그래프나, 두 정점이 이어졌는지 자주 묻는 문제에는 행렬이 낫다. 간선이 적은 희소한 그래프에서는 리스트가 메모리를 크게 아낀다. 예를 들어 사용자 10만 명이 평균 100명과 친구인 관계망은 행렬로 100억 칸이 필요하지만, 리스트로는 약 1000만 개 항목이면 된다. 간선에 거리나 비용이 있으면 행렬 칸에 그 값을 넣거나 리스트에 (이웃, 가중치) 쌍을 넣는다.

확인조건이 달라지면

정점 1,000개, 간선 1,500개인 도로망(무방향)에서 “각 교차로와 이어진 교차로를 모두 방문”하는 작업을 모든 정점에 대해 한 번씩 한다. 두 구현에서 살펴보는 칸(항목) 수로 옳은 것은?

탐구 2

이진 트리를 리스트 하나에 담을 수 있을까?

트리의 구현 · 이진 트리 · 배열 표현 · 자식 목록(인접 리스트)

노드 수
0
높이
0
배열 길이
0
배열 사용률
—

              

높이는 루트에서 가장 먼 잎까지의 간선 수이다. 이 카드는 깊이 4(인덱스 30)까지만 노드를 둘 수 있다.

  1. 질문폴더 안의 폴더, 대회 대진표, 수식 (3 + 4) * 2는 모두 위에서 아래로 갈라지는 구조이다. 이런 계층 구조를 파이썬의 리스트나 딕셔너리에 어떻게 담을 수 있을까?
  2. 복원트리는 사이클이 없이 모두 이어진 그래프로, 노드가 n개면 간선은 n − 1개이다. 맨 위 노드를 루트, 바로 아래 노드를 자식, 자식이 없는 노드를 잎이라 한다. 모든 노드의 자식이 2개 이하인 트리가 이진 트리이다.
  3. 설명배열 표현은 루트를 0번에 두고, i번 노드의 왼쪽 자식을 2i + 1번, 오른쪽 자식을 2i + 2번에 둔다. 부모는 (i − 1) // 2번이다. 연결 정보를 따로 저장하지 않고 인덱스 계산으로 찾으며, 노드가 없는 자리는 None으로 채운다. 자식 목록 표현은 노드마다 [왼쪽 자식, 오른쪽 자식]을 저장한다. 그래프의 인접 리스트를 트리에 쓴 것으로, 딕셔너리나 left, right 속성을 가진 노드 클래스로 만든다.
  4. 연결노드가 위에서부터 왼쪽부터 빈틈없이 채워진 완전 이진 트리는 배열에 빈칸 없이 들어간다. 우선순위 큐를 만드는 힙(heapq)이 이 방식으로 리스트 하나에 트리를 담는다. 수식 트리에서 왼쪽 하위 트리, 오른쪽 하위 트리, 자기 자신 순으로 방문하면 3 4 + 2 *, 곧 후위 표기식이 나온다. 왼쪽 하위 트리 → 자기 자신 → 오른쪽 하위 트리 순서(중위 순회)로 방문하면 원래의 중위 표기식이 나온다. 트리와 그래프를 탐색하는 방법(너비 우선, 깊이 우선)과 이진 탐색 트리는 뒤에서 다룬다.
  5. 적용노드를 하나씩 눌러 부모·자식 인덱스 계산이 맞는지 확인해 보자. ‘한쪽으로 치우친 트리’는 노드 5개에 배열 길이가 얼마인가? ‘예시’에서 노드를 추가해 배열 사용률을 100 %로 만들어 보자. 그때 트리는 어떤 모양인가?
  6. 판단배열 표현은 높이가 h인 트리에 최대 2h+1 − 1칸이 필요하다. 한쪽으로 치우친 트리는 노드가 적어도 칸 대부분이 비어 메모리를 낭비하므로, 이런 트리는 자식 목록 표현이 알맞다. 자식이 셋 이상일 수 있는 일반 트리(폴더 구조 등)는 2i + 1 공식을 쓸 수 없고, 노드마다 자식 리스트를 둔다.

확인조건이 달라지면

배열로 표현한 이진 트리에서 인덱스 9인 노드의 부모 인덱스와 왼쪽 자식 인덱스는?

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

↑↓ 고르기Enter 열기