7 minute read

  1. Understand binary-tree concepts and important properties, such as the Binary Tree Theorem and the External Path Length Theorem.
  2. Be able to perform various traversals of a binary tree.

Definition of Binary Tree

A binary tree t is either empty or consists of an element, called the root element, and two distinct binary trees, called the left subtree and right subtree of t. → root요소 + left subtree + right subtree

  • 이진트리는 재귀적으로 정의되며, 이진트리 관련 많은 정의들도 자연스럽게 재귀적
  • Functional notation (예: leftTree(t))을 사용하는 이유는, Object notation (예: t.leftTree())을 사용하기에는 통일된 이진트리 자료구조가 존재하지 않기 때문.
  • 다양한 이진트리들은 삽입(insert)이나 삭제(remove) 등의 연산에 대해 서로 다른 메서드나 파라미터 리스트를 가진다

  1. (a)와 (b)의 이진트리는 구조가 다르다.
  2. 서브트리(subtree), 이진트리의 서브트리도 하나의 이진트리이다.

Properties of Binary Trees

  • Branch: 루트 요소에서 서브트리로 가는 선.
  • Leaf (잎 노드): 왼쪽과 오른쪽 서브트리가 모두 빈 노드. 즉, 자식이 없는 노드. ex) Figure 9.1e에서는 15, 28, 36, 68이 leaf이다.
if t is empty
    leaves(t)= 0
else if t consists of a root element only
    leaves(t)= 1
else
    leaves(t)= leaves(leftTree(t)) + leaves(rightTree(t))
  • 왼쪽 서브트리와 오른쪽 서브트리의 leaf 수를 합친 것이 전체 tree의 leaf 수
  • 트리의 각 요소는 위치(location) 로 고유하게 결정된다.
  • 같은 값(-)을 가진 두 노드도 “어디에 위치했는가”로 구별할 수 있다.
  • 실제로는 “요소”를 언급할 때 “특정 위치의 요소”를 뜻한다.

Familial Terms

  • Sibling: 같은 부모를 공유하는 노드.
  • Ancestor: A가 B의 조상이라면, B는 A로부터 이어진 서브트리에 존재한다.
    • 재귀적 정의: A가 B의 부모이거나, A가 B의 부모의 조상이다.
  • Descendant (후손): B가 A의 후손이면, A가 조상이고 B는 그 하위 서브트리에 있다.
    • 재귀적 정의: B가 A의 자식이거나, B가 A의 자식의 후손이다.

높이(Height)

루트에서 가장 먼 leaf까지 가는 경로상의 branch 수를 트리의 높이라고 한다.

if t is empty,
    height(t)= 1
else
    height(t)= 1+ max (height(leftTree(t)), height(rightTree(t)))
  • height(t):
    • t가 비어있으면 → -1
    • t가 루트 하나만 가지면 → 0
    • 그 외 → 1 + max(height(leftTree(t)), height(rightTree(t)))
    • 주의: 빈 트리의 높이는 -1로 정의한다.

빈 서브트리의 높이 = -1로 정의!!

요소 하나만 있는 트리의 높이 = 0

레벨(Level)

루트로부터 요소 x까지 몇 개의 branch를 거치는지 나타냄.** Depth = Level (같은 개념)**

if x is the root element,
    level(x)= 0
else
    level(x)= 1+ level(parent(x))
  • level(x) = 0 (x가 루트이면)
  • level(x) = 1 + level(parent(x)) (x가 루트가 아니면)

    트리의 높이(height) = 가장 깊은 leaf의 레벨(depth)

Two-Tree (2-tree)

  • 트리가 비어 있거나,
  • 모든 비-leaf 노드가 왼쪽과 오른쪽 둘 다 자식을 가질 때.
  • Figure 9.5a는 → two-tree (모든 비-leaf가 자식 2개)
  • Figure 9.5b는 → two-tree 아님 (어떤 노드는 자식 1개만 가짐)

Full Binary Tree

높이가 h인 full binary tree는 정확히 2^(h+1) - 1개의 요소를 가짐.

Complete Binary Tree

트리가 다음-하위 레벨(next-to-lowest level)까지는 꽉 차 있어야 하고, 가장 마지막 레벨의 노드들은 왼쪽부터 채워져야 함.

  • 모든 full binary tree는 complete binary tree임.
  • 하지만 complete binary tree가 반드시 full은 아님.

  • Figure 9.8a는 complete binary tree (하지만 full은 아님).
  • Figure 9.8b는 next-to-lowest level이 꽉 차지 않아 complete 아님.
  • Figure 9.8c는 마지막 레벨이 왼쪽부터 채워지지 않아 complete 아님.

Position of Complete BT

  • 루트: position = 0
  • 왼쪽 자식: position = 2i + 1
  • 오른쪽 자식: position = 2i + 2 (여기서 i는 부모 노드의 position)

즉, 부모 i로부터 왼쪽/오른쪽 자식의 위치를 간단히 계산할 수 있음.

빠른 계산을 위해 비트 연산 사용:

왼쪽 쉬프트 « 1 → 2배 곱하기와 같음.

(i << 1) + 1 and (i << 1) + 2

그래서 자식의 위치:

  • 왼쪽 자식: (i « 1) + 1
  • 오른쪽 자식: (i « 1) + 2

부모 위치 계산:

  • 자식 i에 대해 부모 위치는 (i - 1) » 1
  • (오른쪽 쉬프트 » 1 → 2로 나누기)

Complete binary tree는 ArrayList나 배열로 쉽게 구현 가능! 부모-자식 관계를 index 연산만으로 O(1) 시간에 찾을 수 있음.

트리 요소수 세기 n(t)

if t is empty:
    n(t) = 0
else:
    n(t) = 1 + n(leftTree(t)) + n(rightTree(t))
  • Figure 9.10:

Complete binary tree를 array로 표현.

  • 각 요소를 position에 맞는 인덱스에 저장함.
  • Complete binary tree를 array로 표현가능.

각 요소를 position에 맞는 인덱스에 저장함.

  • 즉, 현재 노드 1개 + 왼쪽 서브트리의 요소 수 + 오른쪽 서브트리의 요소 수.

Binary Tree Theorem

임의의 non-empty 이진 트리 t에 대해:

  • leaves(t) ≤ n(t)
  • leaves(t) = n(t) ⟺ 동트리 t가 단 하나의 요소만 가질 때 (leaf가 root와 동일)

For any non-empty binary tree t,

  • $leaves(t) \le \frac{n(t) + 1}{2.0}$
    • 1번의 등호가 성립 ⟺ t는 two-tree (각 노드가 0개나 2개의 자식을 가짐)
  • $\frac{n(t) + 1}{2.0} \le 2^{height(t)}$
    • 2번의 등호가 성립 ⟺ t는 full (가득 찬 트리)

Special Case: Chain

  • Chain: 각 노드가 자식 하나만 갖는 트리.
  • Chain에서는:
    • height(t) = n(t) - 1
    • 즉, 높이가 선형(linear) (O(n)).

그래서 대부분의 트리 기반 알고리즘은 높이를 log n으로 유지하는 것이 핵심 목표 → 높이가 커지면 탐색, 삽입, 삭제 시간 복잡도가 O(n)까지 늘어날 수 있음.

External Path Length

이 정리는 이진 트리의 외부 경로 길이(E(t))에 대한 하한선을 제공합니다. 외부 경로 길이는 트리의 모든 리프(말단 노드)의 깊이 합을 의미합니다. E(t) 는 다음과 같은 하한을 갖는다. → $E(t) \ge \frac{k}{2} * \lfloor \log_2 k \rfloor$

  • 완전 이진 트리: 완전 이진 트리의 높이가 ℎ일 때, 그 트리는 $2^h$개의 리프를 가짐.
  • 트리 높이가 log2K일 때, 이 트리의 리프는 최대 k개임. 높이가 log2K이면, 리프의 수는 k개 이하로 제한.
  • 이 레벨보다 한 단계 낮은 레벨에서 리프들을 제거했을 때, 남은 트리의 높이는 Log2K - 1임, 즉 이 트리에는 리프가 최대 k/2개 있을 수 있음.

외부경로의 길이는 최소한 $\frac{k}{2} * \log_2 k $

Traversals of a Binary Tree

  • 트리 순회(Traversal): 트리에서 각 요소를 정확히 한 번씩 처리하는 알고리즘.
  • 알고리즘의 초점: 이진 트리 순회에 대한 알고리즘에 초점을 맞추고 있으며, BinaryTree 클래스나 인터페이스를 선언하는 것은 포함되지 않습니다.
    • 이유: BinaryTree 클래스나 인터페이스는 Java Collections Framework에서 이미 다양한 삽입 및 제거 방법을 지원하고 있기 때문에, 그것보다 더 유연한 방법이 필요합니다.

in-order순회 (Left - Root - Right)

이 알고리즘은 재귀적으로 동작함. 순회의 기본 아이디어는 이렇다.

inOrder(t)
{
    if (t is not empty)
    {
        inOrder(leftTree(t)); // 왼쪽 서브트리 순회
        process the root element of t; // 루트 처리
        inOrder(rightTree(t)); // 오른쪽 서브트리 순회
    } 
}
  1. 왼쪽 서브트리에서 inOrder순회를 먼저 수행함.
  2. 루트요소를 처리함.
  3. 오른쪽 서브트리에서 inOrder 순회를 수행함.

시간 복잡도 분석 n은 트리 내의 요소 개수입니다. 트리의 각 요소마다 왼쪽 서브트리와 오른쪽 서브트리에 대한 순회를 각각 한 번씩 수행하게 되므로, 트리 내 모든 노드에 대해 2번의 재귀 호출이 발생합니다. 즉, 각 요소에 대해 2번씩 재귀가 호출되므로, 전체 호출 횟수는 2n이 됩니다.

따라서 최악의 경우 시간 복잡도(worstTime(n))평균 시간 복잡도(averageTime(n))O(n)입니다.

post-Order 순회 (Left - Right - Root)

postOrder 순회의 알고리즘은 왼쪽 서브트리와 오른쪽 서브트리를 먼저 순회한 후, 마지막에 루트를 처리하는 방식입니다.

postOrder(t)
{
    if (t is not empty)
    {
        postOrder(leftTree(t)); // 왼쪽 서브트리 순회
        postOrder(rightTree(t)); // 오른쪽 서브트리 순회
        process the root element of t; // 루트 처리
    }
}

postOrder 순회는 트리의 모든 요소를 순차적으로 처리하는 방식인데, 이 순서는 왼쪽 서브트리 → 오른쪽 서브트리 → 루트입니다.

시간 복잡도 분석 n은 트리 내의 요소 개수입니다.

트리의 각 요소마다 왼쪽 서브트리와 오른쪽 서브트리에 대한 순회를 각각 한 번씩 수행하므로, 트리 내의 모든 노드에 대해 2번의 재귀 호출이 발생합니다.

결과적으로, 2n번의 재귀 호출이 발생하므로 최악의 경우 시간 복잡도(worstTime(n))평균 시간 복잡도(averageTime(n))O(n)입니다.

preOrder 순회 (Root - Left - Right)

preOrder 순회는 트리의 루트를 먼저 처리한 뒤, 왼쪽 서브트리와 오른쪽 서브트리를 차례대로 순회하는 방식입니다.

preOrder(t)
{
    if (t is not empty)
    {
        process the root element of t; // 루트 처리
        preOrder(leftTree(t)); // 왼쪽 서브트리 순회
        preOrder(rightTree(t)); // 오른쪽 서브트리 순회
    }
}

preOrder 순회는 루트 → 왼쪽 서브트리 → 오른쪽 서브트리의 순서로 트리의 요소를 처리합니다.

시간 복잡도 분석 n은 트리 내의 요소 개수입니다. 트리의 각 요소에 대해 한 번씩 루트 처리, 왼쪽 서브트리 순회, 오른쪽 서브트리 순회가 이루어집니다.

결과적으로, 모든 노드에 대해 2n번의 재귀 호출이 발생하므로 최악의 경우 시간 복잡도(worstTime(n))평균 시간 복잡도(averageTime(n))O(n)입니다.

preOrder 순회는 깊이 우선 탐색(DFS, Depth-First Search)의 한 예입니다. 이 방식은 왼쪽 서브트리를 최대한 깊이 탐색한 후, 오른쪽 서브트리를 탐색하는 방식입니다.

DFS에서는 트리의 깊은 곳을 우선적으로 탐색하고, 더 이상 깊이가 없을 때 오른쪽 서브트리로 이동합니다.

breadthFirst 순회 (Level-By-Level)

breadthFirst 순회는 레벨 단위로 트리의 노드를 처리하는 방식입니다. 즉, 트리의 루트를 먼저 처리하고, 그 다음으로 루트의 자식들을 왼쪽부터 오른쪽으로 순차적으로 처리한 후, 그의 자식들을 또 왼쪽부터 오른쪽으로 순차적으로 처리하는 방식입니다.

레벨별로 서브트리의 참조를 리스트에 저장하고, 이 리스트에서 각 서브트리를 왼쪽부터 오른쪽 순으로 꺼내 처리합니다.

이러한 방식은 큐(queue)를 사용하여 구현할 수 있습니다. 큐는 선입선출(FIFO, First In, First Out) 방식으로 작동하므로, 입력된 순서대로 요소를 처리할 수 있습니다.

breadthFirst(t)
{
    if (t is not empty)
    {
        queue = new Queue();
        queue.enqueue(t); // 루트 트리를 큐에 추가

        while (queue is not empty)
        {
            current = queue.dequeue(); // 큐에서 트리의 노드를 꺼냄
            process(current); // 현재 노드 처리

            if (current has left child) 
                queue.enqueue(leftTree(current)); // 왼쪽 자식 큐에 추가
            if (current has right child) 
                queue.enqueue(rightTree(current)); // 오른쪽 자식 큐에 추가
        }
    }
}

시간 복잡도 분석 n은 트리 내의 요소 개수입니다. breadthFirst 순회는 트리의 모든 노드를 한 번씩 처리하므로, 모든 노드를 처리하는 데 걸리는 시간은 O(n)입니다.

각 노드는 큐에 한 번 들어가고, 한 번 나가므로 큐에 관련된 연산은 O(1)입니다. 결국 전체 알고리즘의 시간 복잡도는 O(n)입니다.

완전 이진 트리와 배열 사용

만약 트리가 완전 이진 트리라면, 트리를 배열(Array)로 구현할 수 있습니다. 이 경우, 너비 우선 순회는 배열을 순차적으로 순회하는 방식으로 매우 효율적으로 처리됩니다.

루트 노드는 배열의 인덱스 0에 위치하고, 루트의 왼쪽 자식은 인덱스 1, 오른쪽 자식은 인덱스 2, 그 다음으로 왼쪽 자식의 자식은 인덱스 3, 이런 식으로 트리의 노드를 배열 인덱스에 맞춰 저장합니다.

배열에서는 트리의 레벨 순서를 그대로 반영할 수 있기 때문에, 배열을 한 번 순회하는 것만으로 너비 우선 순회를 구현할 수 있습니다.

Leave a comment