본문 바로가기
cs

트리와 힙, 그리고 그래프

자료구조 복습하기 3탄

5

자료구조 포스팅 하며 복습하기 📦

  1. 선형 자료구조 (배열, 연결 리스트, 스택, 큐)
  2. 해시 테이블
  3. 트리와 힙, 그리고 그래프

트리란?

한 노드가 여러 노드를 자식으로 거느리는 계층 구조입니다.

1탄에서 본 구조들은 한 줄이라 다음이 하나뿐이었습니다. 트리는 아래로 갈라집니다. 갈라지되 위로 되돌아가는 길은 없어서, 어느 노드에서 출발해도 결국 아래로만 내려갑니다.

용어부터 정리하고 가겠습니다.

  • 루트: 맨 위 노드. 하나뿐입니다.
  • 부모와 자식: 바로 위아래로 연결된 관계입니다.
  • 잎(leaf): 자식이 없는 노드입니다.
  • 높이: 루트에서 가장 깊은 잎까지의 거리입니다.

자식이 최대 두 개인 트리를 이진 트리라고 하고, 아래에서 볼 것들은 전부 이진 트리입니다.

프론트엔드에서 매일 보는 트리도 있습니다. DOM이 그렇습니다. document가 루트고 각 엘리먼트가 자식을 가지며, 텍스트 노드처럼 자식이 없는 것이 잎입니다.

이진 탐색 트리

값을 넣을 때 규칙을 하나 두면 트리가 탐색용 구조가 됩니다.

왼쪽 자식은 부모보다 작고, 오른쪽 자식은 부모보다 큽니다. 이 규칙을 지키면 찾을 때 절반씩 버릴 수 있습니다. 찾는 값이 현재 노드보다 작으면 오른쪽은 볼 필요가 없기 때문입니다.

코드

class Node {
  constructor(value) {
    this.value = value;
    this.left = null;
    this.right = null;
  }
}
 
class BinarySearchTree {
  constructor() {
    this.root = null;
  }
 
  insert(value) {
    const node = new Node(value);
 
    // 비어 있으면 새 노드가 루트가 된다
    if (!this.root) {
      this.root = node;
 
      return;
    }
 
    let current = this.root;
 
    while (true) {
      // 작으면 왼쪽, 크면 오른쪽으로 내려간다
      const direction = value < current.value ? 'left' : 'right';
 
      // 그쪽이 비어 있으면 거기가 제자리다
      if (!current[direction]) {
        current[direction] = node;
 
        return;
      }
 
      current = current[direction];
    }
  }
 
  has(value) {
    let current = this.root;
 
    while (current) {
      if (current.value === value) {
        return true;
      }
 
      // 비교 결과에 따라 한쪽을 통째로 버리고 내려간다.
      // 한 번 내려갈 때마다 후보가 절반으로 줄어든다
      current = value < current.value ? current.left : current.right;
    }
 
    return false;
  }
}
 
const tree = new BinarySearchTree();
 
[8, 3, 10, 1, 6].forEach(value => tree.insert(value));
 
console.log(tree.has(6)); // true
console.log(tree.has(7)); // false

위 값들을 넣으면 이런 모양이 됩니다.

      8
    /   \
   3     10
  / \
 1   6

6을 찾을 때 8보다 작으니 오른쪽 가지를 통째로 버리고, 3보다 크니 왼쪽을 버립니다. 다섯 개가 들어 있는데 두 번만 내려가면 도착합니다.

시간 복잡도

  • 균형이 잡혀 있으면 한 번 내려갈 때마다 남은 후보가 절반씩 줄어듭니다. 트리 높이만큼만 내려가면 되므로 O(logn) 입니다.
  • 문제는 균형이 항상 잡히지는 않는다는 점입니다.

이미 정렬된 값을 순서대로 넣으면 한쪽으로만 자랍니다.

const tree = new BinarySearchTree();
 
[1, 3, 6, 8, 10].forEach(value => tree.insert(value));
1
 \
  3
   \
    6
     \
      8
       \
        10

갈라지지 않고 한 줄이 되어버려 연결 리스트와 같아집니다. 이 경우 탐색은 O(n)입니다. 퀵 정렬이 pivot을 잘못 골라 한쪽으로 쏠렸을 때와 똑같은 상황입니다. AVL 트리나 레드블랙 트리처럼 실무에서 쓰이는 트리들이 넣을 때마다 노드를 회전시켜 균형을 맞추는 이유가 이것입니다.

부모와 자식 사이에만 크기 규칙이 있는 완전 이진 트리입니다.

정렬편 6탄에서 힙 정렬을 하며 만들었던 구조입니다. 그때는 정렬을 위한 도구로 봤지만, 힙 자체가 쓸모 있는 자료구조입니다.

이진 탐색 트리와 헷갈리기 쉬운데 규칙이 다릅니다.

  • 이진 탐색 트리: 왼쪽 < 부모 < 오른쪽. 좌우가 구분됩니다.
  • 힙: 부모가 자식보다 크기만(또는 작기만) 하면 됩니다. 좌우 구분이 없습니다.

그래서 힙은 정렬된 상태가 아닙니다. 형제끼리는 아무 관계가 없습니다. 대신 루트가 항상 최댓값(또는 최솟값)이라는 것만 보장합니다.

우선순위 큐

1탄의 큐는 먼저 들어온 것이 먼저 나왔습니다. 우선순위 큐는 들어온 순서와 상관없이 우선순위가 가장 높은 것이 먼저 나옵니다.

힙이 정확히 이 일을 합니다. 루트가 항상 최댓값이니 꺼내기만 하면 됩니다.

코드

6탄에서 만든 heapify를 그대로 씁니다.

class PriorityQueue {
  constructor() {
    this.items = [];
  }
 
  // 맨 뒤에 넣고 제자리를 찾을 때까지 위로 올린다
  push(value) {
    this.items.push(value);
 
    let index = this.items.length - 1;
 
    while (index > 0) {
      const parent = Math.floor((index - 1) / 2);
 
      // 부모가 더 크면 이미 규칙을 지키고 있다
      if (this.items[parent] >= this.items[index]) {
        break;
      }
 
      [this.items[parent], this.items[index]] = [this.items[index], this.items[parent]];
      index = parent;
    }
  }
 
  // 루트(최댓값)를 꺼내고 마지막 값을 루트로 올린 뒤 다시 내려보낸다
  pop() {
    if (this.items.length <= 1) {
      return this.items.pop();
    }
 
    const top = this.items[0];
 
    this.items[0] = this.items.pop();
    this.heapify(0);
 
    return top;
  }
 
  heapify(index) {
    const size = this.items.length;
    let largest = index;
    const left = index * 2 + 1;
    const right = index * 2 + 2;
 
    if (left < size && this.items[left] > this.items[largest]) {
      largest = left;
    }
 
    if (right < size && this.items[right] > this.items[largest]) {
      largest = right;
    }
 
    if (largest === index) {
      return;
    }
 
    [this.items[index], this.items[largest]] = [this.items[largest], this.items[index]];
    this.heapify(largest);
  }
}
 
const queue = new PriorityQueue();
 
[3, 10, 1, 8].forEach(value => queue.push(value));
 
console.log(queue.pop()); // 10
console.log(queue.pop()); // 8
console.log(queue.pop()); // 3

넣은 순서가 3, 10, 1, 8인데 큰 값부터 나옵니다.

넣을 때는 맨 뒤에 붙인 뒤 부모와 비교하며 위로 올라가고, 꺼낼 때는 루트를 빼낸 자리에 마지막 값을 올린 뒤 아래로 내려보냅니다. 양쪽 모두 트리 높이만큼만 움직이므로 O(logn)입니다.

매번 전체를 훑어 최댓값을 찾으면 O(n)이고, 정렬해두면 넣을 때마다 O(n)이 듭니다. 힙은 넣기와 꺼내기 모두 O(logn)이라 둘 사이의 균형이 좋습니다.

그래프

앞에서 트리는 아래로만 갈라지고 위로 되돌아가는 길이 없다고 했습니다. 그 제약을 풀면 그래프가 됩니다.

정점끼리 자유롭게 연결되고, 돌아오는 길이 생겨도 됩니다. 즉 트리는 사이클이 없고 모든 정점이 하나로 이어진 그래프의 특수한 경우입니다.

  • 정점(vertex): 노드에 해당합니다.
  • 간선(edge): 정점을 잇는 선입니다.
  • 방향의 유무: 한쪽으로만 갈 수 있으면 방향 그래프, 양쪽 다 오갈 수 있으면 무방향 그래프입니다.
  • 사이클: 출발한 정점으로 되돌아오는 경로입니다. 트리에는 없고 그래프에는 있을 수 있습니다.

표현하는 두 가지 방법

그래프 자체는 개념이라 코드로 옮기려면 담을 그릇을 정해야 합니다. 방법이 두 가지인데, 고르는 기준이 1탄의 배열과 연결 리스트를 고르던 것과 같습니다.

인접 행렬은 정점 수만큼의 2차원 배열을 만들고 연결 여부를 칸에 적습니다. matrix[a][b]만 보면 되니 연결 확인이 O(1)이지만, 간선이 몇 개든 정점 수의 제곱만큼 메모리를 씁니다.

인접 리스트는 정점마다 이웃 목록을 들고 있습니다. 실제로 있는 간선만큼만 메모리를 쓰지만, 연결 여부를 알려면 그 목록을 뒤져야 합니다.

간선이 촘촘하면 행렬이, 듬성듬성하면 리스트가 유리합니다. 현실의 그래프는 대개 듬성듬성해서 리스트를 더 많이 씁니다.

코드

class Graph {
  constructor() {
    // 정점 -> 이웃 목록. 인접 리스트 방식이다
    this.adjacency = new Map();
  }
 
  addVertex(vertex) {
    if (!this.adjacency.has(vertex)) {
      this.adjacency.set(vertex, new Set());
    }
  }
 
  addEdge(a, b) {
    this.addVertex(a);
    this.addVertex(b);
 
    // 무방향 그래프라 양쪽에 서로를 등록한다.
    // 방향 그래프라면 a -> b 한 줄만 남긴다
    this.adjacency.get(a).add(b);
    this.adjacency.get(b).add(a);
  }
 
  neighbors(vertex) {
    return [...(this.adjacency.get(vertex) ?? [])];
  }
 
  hasEdge(a, b) {
    return this.adjacency.get(a)?.has(b) ?? false;
  }
}
 
const graph = new Graph();
 
graph.addEdge('a', 'b');
graph.addEdge('a', 'c');
graph.addEdge('b', 'c');
 
console.log(graph.neighbors('a')); // ['b', 'c']
console.log(graph.hasEdge('b', 'c')); // true
console.log(graph.hasEdge('a', 'z')); // false

이웃 목록을 배열이 아니라 Set으로 담은 이유가 있습니다. 배열이면 hasEdge가 목록을 훑어야 하는데, 2탄에서 본 대로 Set은 해시 기반이라 평균 O(1)에 확인됩니다. 같은 간선을 두 번 넣어도 알아서 하나로 유지되는 것도 덤입니다.

프론트엔드에서의 그래프

번들러가 다루는 모듈 의존 관계가 그래프입니다. 파일이 정점이고 import가 간선입니다.

컴포넌트 구조는 트리인데 의존 관계는 왜 그래프냐면, import는 서로를 가리킬 수 있기 때문입니다. A가 B를 부르고 B가 다시 A를 부르면 사이클이 생깁니다. 순환 참조 경고가 바로 이것이고, 트리에는 애초에 생길 수 없는 모양입니다.

정리

  • 트리는 아래로 갈라지는 계층 구조이고, DOM이 대표적인 예입니다.
  • 이진 탐색 트리는 좌우 규칙 덕분에 절반씩 버리며 찾지만, 한쪽으로 쏠리면 연결 리스트가 됩니다.
  • 힙은 부모와 자식 사이에만 규칙이 있어 정렬된 상태가 아니고, 루트가 최댓값이라는 것만 보장합니다.
  • 그 성질을 그대로 쓰면 우선순위 큐가 됩니다.
  • 트리에서 사이클 제약을 풀면 그래프입니다. 담는 방법은 인접 행렬과 인접 리스트 두 가지입니다.

그래프는 만들어 두는 것보다 훑는 것이 본론입니다. 어떤 정점에서 다른 정점까지 갈 수 있는지, 가장 짧은 길은 무엇인지는 다음 시리즈인 탐색편에서 다루겠습니다.