CS 및 알고리즘 공부/CS

[자료구조🧬] 트리(Tree)

Gogumiing 2024. 8. 27. 10:38

목차

💬 트리(Tree)

💬 이진 트리(Binary Tree)

💬 이진 트리 - 순회(traversal)

💬 이진 트리의 표현

💬 이진 트리의 표현 코드

💬 힙(Heap)

💬 연습문제

 


💬 트리(Tree)

더보기

비선형 구조로, 원소들 간에 1:n의 계층 관계를 가지는 자료구조이다.

  • 원소들 간에 계층관계를 가지는 계층형 자료구조
  • 하나 이상의 노드로 이루어진 유한 집합으로, 노드 중 최상위 노드를 루트(root)라고 하며 나머지 노드들은 n개의 분리 집합(부 트리, subtree)으로 분리될 수 있음
  • 트리는 사이클이 없는 무향 연결 그래프이다.
    • 두 노드(정점) 사이에는 유일한 경로만이 존재함
    • 각 노드(자식 노드)는 최대 하나의 부모 노드가 존재함
    • 각 노드(부모 노드)는 자식 노드가 없거나 하나 이상이 존재할 수 있음
  • 트리는 파일 시스템과 같이 계층적이고 정렬된 데이터를 다루기에 적합함

 

<용어 정리>

  • 노드(node)
  • 간선(edge) - 부모 노드와 자식 노드를 연결하는 선
  • 루트 노드(root node) - 트리의 시작 노드
  • 형제 노드(sibling node) 
  • 조상 노드 - 간선을 따라 루트 노드까지 이르는 경로에 있는 모든 노드들
    • 예를 들어, K의 조상 노드는 F, B, A이다.
  • 서브 트리(subtree) - 부모 노드와 연결된 간선을 끊었을 때 생성되는 트리 (재귀적 정의)
  • 자손 노드 - 서브 트리에 있는 하위 레벨의 노드들
    • 예를 들어, B의 자손 노드는 E, F, K이다.
💡 차수(degree)

- 노드의 차수: 노드에 연결된 자식 노드의 수
  (위 트리에서 B의 차수는 2, C의 차수는 1이다.)

- 트리의 차수: 트리에 있는 노드의 차수 중에서 가장 큰 값
  (위 트리T의 차수는 3이다.)

- 단말 노드(리프 노드): 차수가 0인 노드. 즉 자식 노드가 없는 노드를 의미
💡 높이
- 노드의 높이: 루트에서 노드에 이르는 간선의 수, 노드의 레벨
- 트리의 높이: 트리에 있는 노드의 높이 중에서 가장 큰 값, 최대 레벨
선형 자료구조와 비선형 자료구조

- 선형(Linear) 자료구조
  자료를 구성하는 원소들을 하나씩 순차적으로 나열시킨 형태로, 원소들 간의 앞·뒤 관계가 명확한 자료구조

- 비선형(Non-Linear) 자료구조
  하나의 자료 뒤에 여러 개의 자료가 존재할 수 있는 형태로, 자료들 간의 앞·뒤 관계가 1:n 또는 n:n을 이룸
  (대표적으로 트리와 그래프가 있다.)

 

데이터베이스는 내부적으로 대용량 데이터 처리에 적합한 트리(Tree) 자료구조로 정렬되어 있다.
따라서 데이터베이스에서의 탐색은 이진 탐색과 유사한 방법을 이용하여 빠르게 탐색을 완료한다.

 

💬 이진 트리(Binary Tree)

더보기

모든 노드들이 2개의 서브트리(subtree)를 갖는 특별한 형태의 트리로, 각 노드가 자식 노드를 최대 2개까지만 가질 수 있다. (왼쪽 자식 노드는 left child node, 오른쪽 자식 노드는 right child node라고 한다.)

포화 이진 트리 (Full Binary Tree)

 

<특징>

  • 레벨 i에서의 최대 노드 개수는 2^i개이다.
    • 따라서 높이가 h인 이진 트리가 가질 수 있는 노드의 최소 개수는 (h + 1)개이며, 최대 개수는 (2^{h+1} - 1)개이다.

 

<종류>

  • 포화 이진 트리(Full Binary Tree)
    • 모든 레벨에 노드가 포화상태로 차 있는 이진 트리로, 높이가 h일 때 최대 노드 개수인 (2^{h+1} - 1)의 노드를 가진 이진 트리이다.
    • 루트(root)를 1번으로 하여 마지막 노드까지 정해진 위치에 대한 노드 번호를 가진다.
  • 완전 이진 트리(Complete Binary Tree)
    • 높이가 h이고 노드 수가 n개 (2^h <= n <= 2^{h+1} - 1)일 때, 포화 이진 트리의 노드 번호 1번부터 n번까지 빈 자리가 없는 이진 트리

 

  • 편향 이진 트리(Skewed Binary Tree)
    • 높이 h에 대한 최소 개수의 노드를 가지면서 한쪽 방향의 자식 노드만을 가진 이진 트리
왼쪽 편향 이진 트리
오른쪽 편향 이진 트리

 

💬 이진 트리 - 순회(traversal)

더보기
💡 순회 (traversal)
트리의 노드들을 체계적으로 방문하는 것. 각 노드들을 중복되지 않게 모두 방문하는 것을 의미함
(트리는 비선형 구조이므로 선형 구조에서와 같이 선후 연결 관계를 알 수 없다는 문제점이 있다.)

 

 

 

✅ 전위 순회(Preorder traversal) - VLR

부모 노드를 방문하고 자식 노드를 좌, 우 순서로 방문한다.

  1. [V] 현재 노드 n을 방문하여 처리함
  2. [L] 현재 노드 n의 왼쪽 서브트리로 이동함
  3. [R] 현재 노드 n의 오른쪽 서브트리로 이동함
# 전위 순회
def preorder_traverse(T):
    if T:
        visit(T)            # print(T.item)
        preorder_traverse(T.left)
        preorder_traverse(T.right)

 

 

 

 ✅ 중위 순회(Inorder traversal) - LVR

왼쪽 자식 먼저 보고 나를 보고 오른쪽 자식 보러 감!

  1. [L] 현재 노드 n의 왼쪽 서브트리(subtree)로 이동함
  2. [V] 현재 노드 n을 방문하여 처리함
  3. [R] 현재 노드 n의 오른쪽 서브트리(subtree)로 이동함
# 중위 순회
def inorder_traverse(T):
    if T:
        inorder_traverse(T.left)
        visit(T)            # print(T.item)
        inorder_traverse(T.right)

 

 

✅ 후위 순회(postorder traversal) - LRV

   자식부터 다 본다!

  1. [L] 현재 노드 n의 왼쪽 서브트리(subtree)로 이동함
  2. [R] 현재 노드n의 오른쪽 서브트리(subtree)로 이동함
  3. [V] 현재 노드n을 방문하여 처리함
# 후위 순회
def postorder_traverse(T):
    if T:
        postorder_traverse(T.left)
        postorder_traverse(T.right)
        visit(T)            # print(T.item)

 

💬 이진 트리 표현

더보기

✅ 배열을 이용한 이진 트리의 표현

레벨 n에 있는 노드에 대해 왼쪽 → 오른쪽 순서로 2^n 부터 2^{n+1} - 1까지의 번호를 차례로 부여한다. 그리고 해당 노드 번호를 배열의 인덱스로 사용한다.

  • 노드 번호가 i인 노드의 부모 노드 번호 → i / 2
  • 노드 번호가 i인 노드의 왼쪽 자식 노드 번호 → 2 * i
  • 노드 번호가 i인 노드의 오른쪽 자식 노드 번호 → 2 * i + 1
  • 레벨 n의 노드 번호 시작 번호 → 2^n
Q. 높이가 h인 이진 트리를 위한 배열의 크기는?

a. 레벨 i의 최대 노드 수는 2^i이므로, ∑2^i 이다.
왼쪽 편향 이진 트리
배열로 표현한 왼쪽 편향 이진 트리

 

오른쪽 편향 이진 트리
배열로 표현한 오른쪽 편향 이진 트리

 

 

✅ 이진 트리 저장 방법

 

  1. 부모 번호를 인덱스로 하여 자식 번호를 저장함

for i: 1 -> N:
    read p, c
    if(c1[p] == 0)
        c1[p] = c
    else
        c2[p] = c

 

   2. 자식 번호를 인덱스로 하여 부모 번호를 저장함

   for i: 1 -> N
        read p, c
        par[c] = p

 

 

 

✅ 루트 찾기 or 조상 찾기

Q. 5번 노드의 조상은?

 

자식 번호를 인덱스로 하여 부모 번호를 저장한 배열

```

c = 5

while(a[c] != 0)        # 루트인지 확인

    c = a[c]

    anc.append(c)    # 조상 목록

root = c

```

 

💡 배열을 이용한 이진 트리 표현의 단점 

  • 편향 이진 트리의 경우, 사용하지 않는 배열 원소에 대한 메모리 공간 낭비가 발생함
  • 트리의 중간에 새로운 노드를 삽입하거나 기존 노드를 삭제할 경우 배열의 크기 변경이 어려워 비효율적임

 

 

✅ 연결 자료구조를 이용한 이진 트리의 표현

배열을 이용한 이진 트리 표현의 단점 보완을 위해 연결리스트를 사용하여 트리를 표현할 수 있다.

(이진 트리의 모든 노드는 최대 2개의 자식 노드를 가지므로 일정한 구조의 단순 연결리스트 노드를 사용해 표현함) 

▶ 실제 개발에서는 class 내부에 left, right 멤버 변수를 이용해 연결리스트로 표현하거나, 인접리스트를 사용함.

    (트리 구조 상관 없이 그래프로 해결하는 것이 가장 난이도가 낮은 방법!)

 

 

 

수식 트리

수식을 표현하는 이진 트리를 수식 이진 트리(Expression Binary Tree)라고 부르기도 한다.

연산자를 루트 노드 혹은 가지 노드로 두며, 피연산자는 모두 잎 노드로 저장한다.

 

💬 이진 트리 표현 코드

더보기

연결 리스트를 이용한 이진 트리 표현

from collections import deque

class TreeNode:
    def __init__(self, key):
        self.key = key  # 노드의 값
        self.left = None  # 왼쪽 자식 노드를 가리킴
        self.right = None  # 오른쪽 자식 노드를 가리킴

class BinaryTree:
    def __init__(self):
        self.root = None  # 트리의 루트 노드

    # 새로운 노드를 삽입하는 함수 (레벨 순서 삽입)
    def insert(self, key):
        new_node = TreeNode(key)
        if self.root is None:
            self.root = new_node
            return

        # 레벨 순서로 트리를 탐색하기 위해 큐를 사용
        queue = deque([self.root])

        while queue:
            node = queue.popleft()

            # 왼쪽 자식이 비어있으면 삽입
            if node.left is None:
                node.left = new_node
                break
            else:
                queue.append(node.left)

            # 오른쪽 자식이 비어있으면 삽입
            if node.right is None:
                node.right = new_node
                break
            else:
                queue.append(node.right)

    def inorder_traversal(self):
        # 중위 순회를 통해 트리의 노드들을 출력하는 함수
        return self._inorder_traversal(self.root, [])

    def _inorder_traversal(self, node, result):
        if node:
            self._inorder_traversal(node.left, result)
            result.append(node.key)
            self._inorder_traversal(node.right, result)
        return result

# 예제 사용법
if __name__ == "__main__":
    tree = BinaryTree()
    tree.insert(50)
    print("Inorder Traversal:", tree.inorder_traversal())
    tree.insert(30)
    print("Inorder Traversal:", tree.inorder_traversal())
    tree.insert(20)
    print("Inorder Traversal:", tree.inorder_traversal())
    tree.insert(40)
    print("Inorder Traversal:", tree.inorder_traversal())
    tree.insert(70)
    print("Inorder Traversal:", tree.inorder_traversal())
    tree.insert(60)
    print("Inorder Traversal:", tree.inorder_traversal())
    tree.insert(80)

    print("Inorder Traversal:", tree.inorder_traversal())

 

 

인접 리스트를 이용한 이진 트리 표현

def dfs(node):
    if node == -1:
        return

    preorder.append(node)
    dfs(graph[node][0])
    inorder.append(node)
    dfs(graph[node][1])
    postorder.append(node)



N = int(input())
E = N - 1
arr = list(map(int, input().split()))
graph = [[] for _ in range(N + 1)]
# append 를 통해 갈 수 있는 경로를 추가하기
for i in range(E):
    p, c = arr[i * 2], arr[i * 2 + 1]
    graph[p].append(c)

# 없는 경우 -1로 데이터를 저장하기 위한 코드("좌우 경로가 있는가 ?")
# 탐색 시 index 오류를 방지하기 위해 없는 경로를 -1로 저장하였습니다.
for i in range(N + 1):
    while len(graph[i]) < 2:
        graph[i].append(-1)


preorder = []
inorder = []
postorder = []

dfs(1)

print(*inorder)
print(*preorder)
print(*postorder)

 

💬 힙(Heap)

더보기

완전 이진 트리에 있는 노드 중에서 키 값이 가장 큰 노드나 키 값이 가장 작은 노드를 찾기 위해 만든 자료구조

  • 힙에서는 루트 노드의 원소만을 삭제할 수 있다.
  • 루트 노드의 원소를 삭제하여 반환(return)한다.
  • 힙의 종류에 따라 최대값 또는 최소값을 구할 수 있다. → 최대 힙 / 최소 힙
  • Python의 공식 Library에 내장되어 있다.
  • 배열을 통해 트리 형태를 쉽게 구현할 수 있다.
    • 부모나 자식 노드를 O(1) 연산으로 쉽게 찾을 수 있음
    • n 위치에 있는 노드의 자식은 2n과 2n+1에 위치함
    • 완전 이진 트리의 특성에 의해 추가/삭제 위치는 자료의 시작과 끝 인덱스로 쉽게 판단할 수 있음
# 입력 값 예시
'''
7
20 15 19 4 13 11 17

7
20 15 19 4 13 11 23
'''

from heapq import heappush, heappop

N = int(input())          # 필요한 노드 수
arr = list(map(int, input().split()))

heap = []  # 최대힙을 구현하기 위한 리스트

# 최소힙 ( 기본 )
for num in arr:
    heappush(heap, num)

print([x for x in heap])  # 힙의 내부 상태를 출력 (음수로 저장된 상태)

while heap:
    print(heappop(heap), end=' ')

print('\n------------------------------------')

# 최대힙
# 삽입 시 음수로 곱하여 저장 (제일 큰 수가 제일 작아짐)
# 삭제 후 음수값을 곱하여 출력 (다시 원래 수로 복구하여 출력)
for num in arr:
    heappush(heap, -num)

print([-x for x in heap])  # 힙의 내부 상태를 출력 (음수로 저장된 상태)

while heap:
    print(-heappop(heap), end=' ')

 

 

✅ 최대 힙 (max heap)

키(key) 값이 가장 큰 노드를 찾기 위한 완전 이진 트리

  • (부모 노드의 키 값) > (자식 노드의 키 값)
  • 루트 노드: 키 값이 가장 큰 노드
# 입력 값 예시
'''
7
20 15 19 4 13 11 17

7
20 15 19 4 13 11 23
'''


# 최대힙
def enq(n):
    global last
    last += 1   # 마지막 노드 추가(완전이진트리 유지)
    h[last] = n # 마지막 노드에 데이터 삽입
    c = last    # 부모>자식 비교를 위해
    p = c//2    # 부모번호 계산
    while p >= 1 and h[p] < h[c]:   # 부모가 있는데, 더 작으면
        h[p], h[c] = h[c], h[p]  # 교환
        c = p
        p = c//2


def deq():
    global last
    tmp = h[1]   # 루트의 키값 보관
    h[1] = h[last]
    last -= 1
    p = 1           # 새로 옮긴 루트
    c = p*2
    while c <= last:  # 자식이 있으면
        if c+1 <= last and h[c] < h[c+1]: # 오른쪽자식이 있고 더 크면
            c += 1
        if h[p] < h[c]:
            h[p], h[c] = h[c], h[p]
            p = c
            c = p*2
        else:
            break
    return tmp


N = int(input())          # 필요한 노드 수
arr = list(map(int, input().split()))

h = [0]*(N+1)   # 최대힙
last = 0        # 힙의 마지막 노드 번호

for num in arr:
    enq(num)

print(h)

while last > 0:
    print(deq(), end=' ')

 

 

✅ 최소 힙 (min heap)

키(key) 값이 가장 작은 노드를 찾기 위한 완전 이진 트리

  • (부모 노드의 키 값) < (자식 노드의 키 값)
  • 루트 노드: 키 값이 가장 작은 노드
# 입력 값 예시
'''
7
20 15 19 4 13 11 17

7
20 15 19 4 13 11 23
'''


# 최소힙
def enq(n):
    global last
    last += 1   # 마지막 노드 추가(완전이진트리 유지)
    h[last] = n # 마지막 노드에 데이터 삽입
    c = last    # 부모>자식 비교를 위해
    p = c//2    # 부모번호 계산
    while p >= 1 and h[p] > h[c]:   # 부모가 있는데, 더 크면
        h[p], h[c] = h[c], h[p]  # 교환
        c = p
        p = c//2


def deq():
    global last
    tmp = h[1]   # 루트의 키값 보관
    h[1] = h[last]
    last -= 1
    p = 1           # 새로 옮긴 루트
    c = p*2
    while c <= last:  # 자식이 있으면
        if c+1 <= last and h[c] > h[c+1]:  # 오른쪽자식이 있고 더 작으면
            c += 1
        if h[p] > h[c]:
            h[p], h[c] = h[c], h[p]
            p = c
            c = p*2
        else:
            break
    return tmp


N = int(input())          # 필요한 노드 수
arr = list(map(int, input().split()))

h = [0]*(N+1)   # 최대힙
last = 0        # 힙의 마지막 노드 번호

for num in arr:
    enq(num)

print(h)

while last > 0:
    print(deq(), end=' ')

 

 

 

✅ 힙 연산 - 삽입(insertion)

시간복잡도 →  O(log N)

 

 

✅ 힙 연산 - 삭제(delete)

시간복잡도 →  O(log N)

 

 

힙의 활용

대표적으로 특별한 큐(e.g. 우선순위 큐) 구현과 정렬에 활용됨

  • 우선순위 큐 구현 장점
    • 노드 하나의 추가/삭제가 시간 복잡도 O(logN)이며, 최대값/최소값을 O(1)에 구할 수 있음
    • 완전 정렬보다 관리 비용이 적게 듦
  • 힙 정렬은 힙 자료구조를 이용하여 이진 탐색과 유사한 방법으로 수행
    • 배열에 저장된 자료를 정렬하기에 유용하다. 
    • 힙 정렬의 시간복잡도
      삽입/삭제 연산은 각각 O(logN)이므로, N개의 노드를 삽입 연산하고 삭제 연산하는 경우, 전체 정렬의 시간복잡도는 O(NlogN)이다.
    • 정렬 단계
      1. 하나의 값을 힙에 삽입함(반복)
      2. 힙에서 순차적(오름차순)으로 값을 하나씩 제거함

 

💬 연습문제

더보기

1. [이진 트리 - 순회] 아래 트리(Tree)에서의 전위 순회, 중위 순회, 후위 순회에 대해 설명하시오.

 

"""
전위 순회: A - B - D - H - I - E - J - C - F - K - G - L - M
중위 순회: H - D - I - B - J - E - A - F - K - C - L - G - M
후위 순회: H - I - D - J - E - B - K - F - L - M - G - C - A
"""

 

2. [수식 트리] 아래 수식 이진 트리(Expression Binary Tree)의 전위 순회, 중위 순회, 후위 순회에 대해 설명하시오.

"""
전위순회 (식의 전위 표기법): + * * / A B C D E
중위순회 (식의 중위 표기법): A / B * C * D + E
후위순회 (식의 후위 표기법): A B / C * D * E +
"""

  

3. [이진 트리] 아래 이진 트리 표현에 대해 전위 순회하여 정점의 번호를 출력하시오.

  • 첫 줄에는 트리 정점의 총 수(v)가 주어지고, 다음 줄부터 V - 1 개 간선이 나열된다.
  • 간선은 그것을 이루는 두 정점으로 표기되며 항상 부모, 자식 순서로 표현된다.
  • 간선은 부모 정점 번호가 작은 것부터 나열되고, 부모 정점이 동일하다면 자식 정점 번호가 작은 것부터 나열된다.
13
1 2 1 3 2 4 3 5 3 6 4 7 5 8 5 9 6 10 6 11 7 12 11 13

 

# 연습문제 3번 답안 코드(left, right를 쓰는 버전)
# 단, 입력이 반드시 각 노드당 최대 2번씩만 들어온다고 가정한 코드

# 전위 순회
def preorder(node):
    if node == 0:
        return
    
    # 내 차례
    print(node, end = ' ')
    # 왼쪽 자식 차례
    preorder(left[node])
    # 오른쪽 자식 차례
    preorder(right[node])


# 중위 순회
def inorder(node):
    if node == 0: 
        return
    # 왼쪽 자식 차례
    inorder(left[node])
    # 내 차례
    print(node, end=' ')
    # 오른쪽 자식 차례
    inorder(right[node])


# 후위 순회
def postorder(node):
    if node == 0:
        return
    # 왼쪽 자식 차례
    postorder(left[node])
    # 오른쪽 자식 차례
    postorder(right[node])
    # 내 차례
    print(node, end = ' ')
    

N = int(input())
E = N - 1
arr = list(map(int, input().split()))

# 왼쪽 자식 번호를 저장할 리스트
left = [0] * (N + 1)

# 오른쪽 자식 번호를 저장할 리스트
right = [0] * (N + 1)

# 자식을 인덱스로 하여 부모 저장
par = [0] * (N + 1)

for i in range(E):
    parent, child = arr[i*2], arr[i*2+1]

    # 왼쪽 자식이 없다면, 왼쪽에 삽입함
    if left[parent] == 0:
        left[parent] = child
    # 왼쪽 자식은 있지만, 오른쪽 자식이 없다면 오른쪽에 삽입함
    else:
        right[parent] = child
    par[child] = parent

c = N

while par[c] != 0:          # 부모가 있으면
    c = par[c]              # 부모를 새로운 자식으로 두고

# 더이상 부모가 없다면, root
root = c

# 전위 순회
preorder(root)
print()
# 중위 순회
inorder(root)
print()
# 후위 순회
postorder(root)

 

4. 자료구조에서 트리와 그래프의 차이는?

출처: https://bigsong.tistory.com/33
  • 그래프(graph)
    노드와 노드 간을 연결하는 간선으로 구성된 자료구조로, 연결된 노드의 관계를 표현할 수 있다.
    • 순환/비순환 구조를 이룸
    • 무방향/방향/양방향 그래프가 가능하며, 따라서 2개 이상의 경로가 가능함
    • 루트(root) 노드의 개념이 없어서 부모 - 자식 관계의 개념이 없음
  • 트리(tree)
    그래프와 마찬가지로 노드 간의 연결을 간선으로 구성한 자료구조이다.
    • 두 개의 노드 사이에 반드시 1개의 경로만을 가짐 (Only 방향 그래프)
      • "최소 연결 트리"라고 부르기도 함
    • 부모 - 자식 관계가 성립하므로 "계층형 모델"이라고도 함
      • 레벨이 존재함 (최상위 노드 : root)
      • 완전이진트리의 경우 노드가 N개일 때, 간선은 N-1개이며 각 레벨(k)에 존재하는 노드는 2^k개이다.
    • 비순환 구조 이므로 사이클이 존재하지 않음
    • 트리의 순회에는 3가지(전위순회 / 중위순회 / 후위순회)가 존재한다.

 

5. [힙(heap)] 아래의 트리가 힙(heap)이 아닌 이유를 설명하시오.

    ▷ 트리1은 완전 이진 트리 형태가 아니므로 힙(heap)이 될 수 없고,

         트리2는 루트 노드인 8의 값이 왼쪽 서브트리의 값들보다 작기 때문에 힙(heap)이 될 수 없다.

 

6. [힙(heap)] N개의 데이터를 힙(heap)으로 만든다면 시간 복잡도는 어떻게 될까?

 

    ▷ O(Nlog N)