18 minute read

1. 트리의 개념 (Tree)

1-1. 트리의 개념

tree-in-data-structure_Procdess treeandelse_Procdess
이미지 출처자료 구조의 트리(Tree). 나무를 거꾸로 엎어놓은 것 같은 모습을 하고 있다.

이전에 배운 선형 자료 구조와 달리 데이터 간의 계층이 존재한다면
우리는 트리(Tree)라는 자료구조를 사용해야 한다.

  • 부모 - 자식 관계를 가진 노드들로 이루어져 있다.
  • 계층적인 자료구조(hierarchical structure):
    • 트리(Tree)
    • 가계도, 컴퓨터의 디렉토리 구조 등
  • 선형 자료 구조 (linear data structure):
    • 리스트, 스택, 큐 등

요즘 가장 큰 이슈가 되고 있는 인공지능(AI)도 트리를 사용한다.
대표적인 것이 결정 트리(decision tree)이다.
결정 트리는 인간의 의사 결정 구조를 표현하는 한 가지 방법이다.



1-2. 트리의 용어

treeWords_Procdess
트리(Tree)의 모습.

  • 트리는 한 개 이상의 노드로 이루어진 유한 집합.
  • 트리의 최상단에 위치하는 하나의 노드는 루트노드(Root Node)라고 불리며,
  • 그 하위의 나머지 노드들은 서브트리(Sub Tree) 라고 불린다.
    • 노드 (node) : A, B, C, D, E, F, G, H, I, J
      • 루트 노드 : A
      • A의 서브 트리 : {B, E, F, G}, {C, H}, {D, I, J} 와 같은 3개의 집합.
      • B의 서브 트리 : {E}, {F}, {G}
        • 위 처럼 루트 노드와 서브 트리는 자식 노드가 존재 하는 한 하위로 내려가며 계속 분류된다.
    • 간선 (Edge) : 루트와 서브 트리를 잇는 선.
      • 노드들 간에는 부모 관계, 형제 관계, 조상 관계가 존재.
        • A는 B의 부모 노드(Parent node)가 된다.
        • 마찬가지로 B는 A의 자식 노드(Children node)가 된다.
        • B, C, D는 형제 관계(Sibling)이다.
        • 조상 노드(Ancestor node) : 루트 노드에서 임의의 노드 까지의 경로를 이루고 있는 노드들.
        • 후손 노드(Descendent node) : 임의의 노드 하위에 연결된 모든 노드들. (어떤 노드의 서브 트리에 속하는 모든 노드들)
        • 단말 노드(Terminal node / Leaf node) : 자식 노드가 없는 트리의 최하위 노드. (<–> 비단말 노드)
    • 노드의 차수(degree) : 어떤 노드가 가지고 있는 자식 노드의 개수.
      • (A의 경우 B, C, D의 세 자식을 가지고 있으므로 deg(A) = 3 이 된다.)
      • 단말 노드의 경우 자식이 없으므로 차수는 0이 된다.
      • 트리에서의 레벨(level)은 트리의 각 층에 번호를 나타낸다.
        • 루트의 레벨은 1이고, 한 층씩 내려갈수록 레벨은 1씩 증가한다.
          • (루트의 레벨은 책 및 사람마다 0부터 시작하는 경우도 있다.)
        • A의 레벨은 1, B의 레벨은 2이다.
    • 트리의 높이(height) : 트리가 가지고 있는 최대 레벨
      • 위 트리의 레벨은 3.
      • 트리들의 집합 : Forest



1-3. 트리의 종류

트리의 종류에는 크게 이진 트리(binary tree)와 일반 트리가 있다.



이진 트리의 소개 (Binary Tree)

-----------------------------------------------------
이진 트리는 다음과 같이 정의된다.
-----------------------------------------------------
1. 공집합이거나, 
2. 루트와 왼쪽 서브 트리, 오른쪽 서브 트리로 구성된 노드들의 유한 집합.
3. 이진 트리의 서브 트리들은 모두 이진 트리여야 . 
-----------------------------------------------------

Btreevalidation_Procdess
이진 트리의 검증

  • 이진 트리 :
    • 모든 노드가 2개의 서브 트리를 가지고 있는 트리.
      • 서브 트리는 공집합일수 있다.
      • 일반 트리와는 다르게 이진트리는 노드를 하나도 갖지 않을 수도 있다.
    • 이진트리의 노드에는 최대 2개 까지의 자식 노드가 존재한다.
    • 모든 노드의 차수가 2 이하. -> 구현하기가 편리한 장점
    • 이진 트리에는 서브 트리간의 순서가 존재한다.
      • 왼쪽 서브트리와 오른쪽 서브트리를 구분한다.



2-1. 이진 트리의 성질

btreelogic_Procdess
이진 트리의 성질 1

  • 노드의 개수가 n개 이면 간선(edge)의 개수는 n-1 개.
    • [why ?]
    • 이진트리에서의 노드는 루트를 제외하면 정확하게 하나의 부모 노드를 가진다.
    • 부모와 자식 간에는 정확하게 하나의 간선만이 존재 하므로 간선의 개수는 항상 n-1 개가 된다.



btreelogic2_Procdess
이진 트리의 성질 2

  • 높이가 최대 h인 이진트리의 경우, 최소 h개의 노드를 가지며 최대 2^h-1개의 노드를 가진다.
    • [why ?]
    • 한 레벨에는 적어도 하나의 노드가 존재.
    • 높이가 h인 이진트리는 적어도(최소) h개의 노드를 가진다. = 경사 이진트리
    • 하나의 노드는 최대 2개의 자식 노드를 가질 수 있다.
      • 따라서, 레벨 i에서의 최대 노드 개수는 2^(i-1) 이 된다.
      • 그러므로, 아래와 같이 전체 노드의 최대 개수를 구할 수 있는 식이 완성된다.
        maxnodecal_Procdess
        이진 트리의 최대 노드 개수 (전체)



btreelogic3_Procdess
이진 트리의 성질 3

  • n개의 노드를 가지는 이진 트리의 높이 [why ?]
    • 최대 n
      • 레벨 당 최소 하나의 노드가 존재 해야 하므로 n개의 노드 존재 시 가능한 최대 높이는 n이다.
    • 최소 ⌈log(n+1)⌉
      • 높이 h의 이진 트리가 가질 수 있는 노드의 최대값은 2^h - 1
      • n <= 2^h - 1 의 부등식이 성립 할 수 있다.
      • 양 변에 log를 취하여 정리 : h >= log(n+1)
      • h는 정수이어야 하므로 h >= ⌈log(n+1)⌉ 와 같이 올림 연산을 취해준다.



2-2. 이진 트리의 분류

kindofbtree_Procdess
이진 트리의 분류

1. 포화 이진 트리 (full binary tree)

maxbtree_Procdess
이진 트리의 분류

  • 말 그대로 이진 트리의 각 레벨 마다의 최대 노드 만큼 꽉 채워져 있는 이진트리이다.
    • 높이가 k인 포화 이진트리는 정확히 2^k - 1 개의 노드를 가진다.
    • 일반적으로 포화이진트리의 노드 개수는 아래와 같이 계산된다.
      • maxbtreecal_Procdess
        포화 이진 트리에서의
        전체 노드 개수 = 2^(1-1) + 2^(2-1) + 2^(3-1) + 2^(4-1) = 2^4 - 1 = 15
    • 포화 이진 트리는 위 사진과 같이 번호를 붙일 수 있으며, 레벨 단위로 왼쪽에서 오른쪽으로 부여한다.
      • 포화 이진 트리에서의 번호는 항상 일정하다. - 루트 노드의 오른쪽 자식 번호는 항상 ‘3’이다.

2. 완전 이진 트리 (full binary tree)

completebtree_Procdess
완전 이진 트리의 모습

  • 완전 이진 트리는 높이가 k일때 레벨 1, 즉 루트 노드 부터 k-1 즉 leaf 노드 전 레벨까지 가능한 모든 노드가 채워져 있고, 마지막 레벨 k에서는 왼쪽부터 오른쪽으로 노드가 순서대로 채워져 있는 이진트리이다.
  • 마지막 레벨에서는 노드가 꽉 차있지 않아도 되지만, 중간에 빈 곳이 있으면 안된다. (왼쪽 부터 쭉 채워져 있어야 한다.)
    • 포화이진트리는 항상 완전이진트리. 그 반대는 성립하지 않는다.
    • 포화이진트리의 노드 번호와 완전이진트리의 노드 번호는 1대1로 대응한다.
  • 위 그림의 a는 완전이진트리 이지만, b는 마지막 레벨에서 중간(3의 왼쪽 자식)이 비어있으므로 완전이진트리가 아니다.



이진 트리의 표현

이진 트리를 컴퓨터 프로그램 안에서 어떻게 표현 할 수 있는지 알아보자.
다음의 두 가지 방법이 있다.

1. 배열을 이용하는 방법
2. 포인터를 이용하는 방법

배열 표현법

  • 배열을 이용하는 방법은 주로 포화이진트리 / 완전이진트리에서 주로 사용되는 방법 (일반 이진트리에서도 사용은 가능)
  1. 저장하고자 하는 이진 트리를 완전이진트리라 가정.
  2. 이진트리의 깊이가 k이면 최대 2^k - 1 개의 공간을 연속적으로 할당한다.
  3. 완전이진트리의 번호대로 노드들을 저장한다. btreewitharr_Procdess
    이진 트리의 배열 표현법
    • 그림 a를 보면, 트리의 각 노드에 번호가 먼저 부여되고, 해당 번호를 index로 하여 배열에 순차적으로 저장된다.
      • 포화이진트리 및 완전이진트리의 경우에는 노드가 번호 순으로 전부 존재하므로 배열의 중간에 빈 공간이 생기지 않는다.
    • 간선의 밀도(density)가 높다면 배열 표현법을 사용하자. - 극단적인 예로 경사이진트리와 같은 일반 이진트리의 경우에는 각 레벨마다 빈 노드가 존재하므로 배열 표현법을 사용시 많은 메모리의 누수가 발생한다.

배열 표현법에서는 인덱스만 알면 노드의 부모나 자식을 쉽게 알 수 있으며 다음과 같은 공식이 존재한다.

노드 i의 부모노드 인덱스 = i/2
노드 i의 왼쪽 자식 노드 인덱스 = 2 * i
노드 i의 부모노드 인덱스 = 2 * i + 1

인덱스 0을 사용하지 않는 편이 계산을 간단하게 만들기 때문에 index[0]은 사용하지 않는다.



링크 표현법

  • 트리에서의 노드가 구조체로 표현되고, 각 노드가 포인터를 가지고 있다.
  • 해당 포인터를 이용하여 노드와 노드를 연결하는 방법이다.

btreewithlink_Procdess
이진 트리 - 링크 표현법

  • 각 노드의 모습을 보면, 노드마다 3개의 필드를 가지는 것을 볼 수 있다.
    • 데이터 필드 : 데이터를 저장
    • 왼쪽 포인터 필드 : 왼쪽 자식 노드를 가리키는 포인터 필드
    • 오른쪽 포인터 필드 : 오른쪽 자식 노드를 가리키는 포인터 필드
      • 좌우의 포인터 필드를 사용하여 부모 노드와 자식 노드를 연결한다.

노드를 C언어의 구조체와 포인터를 사용하여 정의해보자.

// 노드의 정의
// 구조체를 사용하여 노드의 구조를 정의
// 포인터를 사용하여 링크의 정의
// TreeNode 는 트리 노드에 대한 타입을 새로 정의 한 것.
typedef struct TreeNode {
        int data;
        struct TreeNode* left;
        struct TreeNode* right;
} TreeNode;

링크로 표현된 트리는 루트노드(최상단 노드)를 가리키는 포인터만 있으면 트리 내부의 모든 노드들에 접근할 수 있다.



C언어와 링크 표현법을 사용하여 간단하게 이진트리를 구성해보자.

#include<stdio.h>
#include<stdlib.h>
#include<memory.h>

typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

int main(void) {
    
    // 노드 세개 생성
    TreeNode *n1; 
    TreeNode *n2;
    TreeNode *n3;
    
    n1 = (TreeNode*)malloc(sizeof(TreeNode));
    n2 = (TreeNode*)malloc(sizeof(TreeNode));
    n3 = (TreeNode*)malloc(sizeof(TreeNode));

    // 첫번째 노드 n1을 설정
    n1->data = 10;
    n1->left = n2;
    n1->right = n3;

    // 두번째 노드 n2을 설정
    n2->data = 10;
    n2->left = NULL; // 자식노드가 없다 == leaf 노드
    n2->right = NULL;

    // 세번째 노드 n3을 설정
    n3->data = 30;
    n3->left = NULL; // 자식노드가 없다 == leaf 노드
    n3->right = NULL;

    drawBinarySearchTree(n1, 0);

    // 동적 할당 메모리 반환
    free(n1);
    free(n2);
    free(n3);
    
    return 0;
}

basicBtree_Procdess
실행 결과

  • 왼쪽 부터 최상단 루트 노드이다.
    • 노드 n1 = 10
    • 노드 n2 = 20
    • 노드 n3 = 30

      위와 같이 트리가 잘 구성된 것을 볼 수 있다.

  • 아래는 이진 탐색 트리를 시각화 시켜주는 함수이다.
// 이진 탐색 트리를 그리는 함수
void drawBinarySearchTree(TreeNode* root, int space) {
   if (root == NULL) {
       return;
   }

   space += 5;

   drawBinarySearchTree(root->right, space);

   printf("\n");
   for (int i = 5; i < space; i++) {
       printf(" ");
   }
   printf("%d\n", root->data);

   drawBinarySearchTree(root->left, space);
}



이진 트리의 순회

이진트리의 순회(traversal) 란?

이진트리에 속하는 모든 노드를 한 번씩 방문하여 노드가 가지고 있는 데이터를 목적에 맞게 처리하는 것을 의미.
이전의 선형 자료구조는 데이터를 순회하는 방법이 하나 뿐이었으나 트리는 여러 순서로 각 노드가 가지고 있는 데이터에 접근 가능하다.



이진 트리의 순회 방법 3가지

전위, 중위, 후위 순회

이진 트리를 순회하는 표준 방법에는 전위, 중위, 후위 세 가지 방법이 있다.
세가지 방법은 루트 노드와 좌, 우 자식 노드중 어떤 노드를 먼저 방문하는 지에 따라 구분 된 것이다.

루트 방문을 V, 왼쪽 서브 트리 방문을 L, 오른쪽 서브 트리 방문을 R 이라 하자. (일반적으로 왼쪽 서브트리 -> 오른쪽 서브트리 순으로 방문한다)

전위 순회(preorder traversal) - VLR : 루트(V) -> 왼쪽 서브 트리(L) -> 오른쪽 서브 트리(R)

중위 순회(inorder traversal) - LVR : 왼쪽 서브 트리(L) -> 루트(V) -> 오른쪽 서브 트리(R)

후위 순회(postorder traversal) - LRV : 왼쪽 서브 트리(L) -> 오른쪽 서브 트리(R) -> 루트(V)

Btreetraversal_Procdess
이진 트리의 순회 방법

  • 이진트리에서 루트의 좌, 우 서브 트리도 마찬가지로 이진트리로 구성되어 있는 것을 알 것이다.
  • 따라서 각 서브 트리 방문시에도 같은 순회 방법을 계속해서 적용 해나가며 방문한다.
    • 이진 트리를 보면 전체 트리와 각 서브트리들의 모양이 같다(공집합을 가진 노드까지 생각하자).
    • 따라서 전체 트리 순회에 사용한 알고리즘은 서브트리에서도 그대로 사용할 수 있는 것이다.
    • 대신 작업(문제)의 크기가 점점 작아진다.
      • 어디서 많이 본 구조이지 않은가?
문제의 구조는 같으나 크기만 점차 작아지는 경우는 이전에 배웠던 "순환(recursion)"을 사용할 수 있다
이와 같은 이유로 이진 트리의 순회는 순환 알고리즘을 적용하여 구현할 수 있다.
순환 호출을 사용하게 되면 스택을 사용하게 되는 것이다. (코드상에는 존재하지 않는다.)

추가적으로 수식에서 각 순회 방법을 사용하면 이전에 배운 표기법을 아래와 같이 도출 할 수 있다.

전위 순회 - 전위표기법
후위 순회 - 후위표기법
중위 순회 - 중위 표기법



1. 전위 순회 (preorder)

전위 순회는 루트를 먼저 방문한 후 왼쪽 서브트리, 오른쪽 서브트리 순으로 방문한다.

1. 루트 노드를 방문한다.
2. 왼쪽 서브트리를 방문한다.
3. 오른쪽 서브트리를 방문한다.

preorder_Procdess
이진 트리의 전위 순회

  • 전위 순회에서 루트 노드를 방문했다고 가정해보자.
    • 다음 차례는 왼쪽 서브트리인데, 왼쪽 서브트리의 어떤 노드를 먼저 방문해야 할까?
    • 정답은 왼쪽 서브트리의 왼쪽 서브트리를 다시 방문해야 한다.
    • 앞서 말한 것처럼, 모든 트리는 같은 구조를 가지고 있기 때문에 왼쪽 서브트리에서도 마찬가지로 LVR 을 적용, 왼쪽 서브트리부터 방문한다.
      • 만약 NULL(트리의 끝)을 만나게 되면, 이후 순서인 오른쪽 서브트리를 방문하고, LVR을 종료하며 이전 레벨로 돌아간다.
        • 왼쪽 서브트리 각 노드마다의 전체 LVR이 종료 되었으니 가장 처음 실행되었으나 끝나지 않은 처음의 LVR 중 R을 오른쪽 서브트리를 방문하여 실행한다.
        • 왼쪽 서브트리처럼 오른쪽 서브트리의 각 노드마다의 LVR이 끝나게 되면 전위 순회가 종료 된다.

즉, 모든 서브트리에 대하여 같은 알고리즘을 반복하는 것이다.

preorderusage_Procdess

전위 순회는 구조화된 문서와 같은 자료를 관리하는 것에 유용하게 사용된다.



2. 중위 순회 (inorder)

중위 순회는 왼쪽 서브트리를 먼저 방문한 후 루트 노드, 오른쪽 서브트리 순으로 방문한다.

1. 왼쪽 서브트리를 방문한다.
2. 루트 노드를 방문한다.
3. 오른쪽 서브트리를 방문한다.

inorder_Procdess

이진 트리의 중위 순회

  • 중위 순회도 전위 순회와 같은 방식의 알고리즘이나, 순서만 다르다.

inorderusage_Procdess

중위 순회는 수식과 같은 자료를 관리하는 것에 유용하게 사용된다.



3. 후위 순회 (postorder)

후위 순회는 왼쪽 서브트리를 먼저 방문한 후 오른쪽 서브트리, 루트 노드 순으로 방문한다.

1. 왼쪽 서브트리를 방문한다.
2. 오른쪽 서브트리를 방문한다.
3. 루트 노드를 방문한다.

postorder_Procdess

이진 트리의 후위 순회

  • 후위 순회도 전위, 중위 순회와 같은 방식의 알고리즘이나, 순서만 다르다.
  • 아래에서부터 거슬러 올라가며 데이터를 쌓아 최종 데이터를 도출하는 방식에 유용하다.
    • 아래에서부터 데이터를 저장한 후 해당 노드를 지워가는 방식으로 구현.

postorderusage_Procdess

후위 순회는 디렉터리의 용량 계산과 같은 알고리즘에 유용하게 사용된다.



전위, 중위, 후위 순회 구현

알고리즘

함수의 매개변수는 루트를 가리키는 포인터
좌, 우 서브트리 방문 함수 = 전체트리 방문 함수를 재호출 (다른점 : 매개변수 - 서브 트리의 루트노드를 매개변수로)

#include<stdio.h>
#include<stdlib.h>
#include<memory.h>

typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

// 전위 순회
void preorder(TreeNode* root) {
    if (root != NULL){
        printf("[%d]", root->data); // 노드 방문 및 작업 수행
        preorder(root->left); // 왼쪽 서브트리 순회
        preorder(root->right); //오른쪽 서브트리 순회
    }
}


// 중위 순회
void inorder(TreeNode* root){
    if(root != NULL){
        inorder(root->left); // 왼쪽 서브트리 순회
        printf("[%d]", root->data); // 노드 방문 및 작업 수행
        inorder(root->right); //오른쪽 서브트리 순회
    }
}

// 후위 순회
void postorder(TreeNode* root) {
    if(root != NULL){
        postorder(root->left); // 왼쪽 서브트리 순회
        postorder(root->right); //오른쪽 서브트리 순회
        printf("[%d]", root->data); // 노드 방문 및 작업 수행
    }
}

//		 15
//	 4		 20
// 	1	   16  25
TreeNode n1={1, NULL, NULL};
TreeNode n2={4, &n1, NULL};
TreeNode n3={16, NULL, NULL};
TreeNode n4={25, NULL, NULL};
TreeNode n5={20, &n3, &n4};
TreeNode n6={15, &n2, &n5};
TreeNode *root= &n6;

int main(void) {
	printf("중위 순회=");
	inorder(root);
	printf("\n");

	printf("전위 순회=");
	preorder(root);
	printf("\n");

	printf("후위 순회=");
	postorder(root);
	printf("\n");
	return 0;
}

prepostinorder_Procdess
실행 결과



레벨 순회 (level order)

레벨 순회는 각 노드를 레벨 순으로 검사하는 순회 방법이다. 
  • 루트 노드의 레벨은 1이고 하위 레벨로 내려갈수록 레벨은 증가한다.
  • 동일한 레벨의 경우에는 좌 -> 우 순으로 방문한다.
  • 지금까지의 순회법이 스택(순환 호출)을 사용했던 것에 비해 레벨 순회는 큐(Queue)를 사용한다.

leveltraversal_Procdess
큐(queue)를 사용하는 레벨 순회의 모습

  • 레벨 순회 코드는 큐에 노드가 하나라도 있으면 계속 반복하는 코드로 이루어짐.
  • 한번의 반복 : 큐에 있는 노드를 꺼내(dequeue) 방문(작업) 후 해당 노드의 자식 노드를 큐에 삽입(enqueue)
    • 큐에 더 이상 노드가 남아있지 않을 때까지 위와 같은 과정을 반복.
#include<stdio.h>
#include<stdlib.h>
#include<memory.h>

// ================ 원형큐 코드 시작 =================
#define MAX_QUEUE_SIZE 100

typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

typedef TreeNode * element;

typedef struct { // 큐 타입
	element data[MAX_QUEUE_SIZE];
	int front, rear;
} QueueType;

// 오류 함수
void error(char *message){
	fprintf(stderr, "%s\n", message);
	exit(1);
}
// 공백 상태 검출 함수
void init_queue(QueueType *q){
	q->front = q->rear = 0;
}

// 공백 상태 검출 함수
int is_empty(QueueType *q){
	return (q->front == q->rear);
}

// 포화 상태 검출 함수
int is_full(QueueType *q){
	return ((q->rear + 1) % MAX_QUEUE_SIZE == q->front);
}

// 삽입 함수
void enqueue(QueueType *q, element item){
	if (is_full(q))
		error("큐가 포화상태입니다");
	q->rear = (q->rear + 1) % MAX_QUEUE_SIZE;
	q->data[q->rear] = item;
}

// 삭제 함수
element dequeue(QueueType *q){
	if (is_empty(q))
		error("큐가 공백상태입니다");
	q->front = (q->front + 1) % MAX_QUEUE_SIZE;
	return q->data[q->front];
}

// 레벨 순회
void level_order(TreeNode* ptr){
	QueueType q;

	init_queue(&q);	 // 큐 초기화

	if (ptr == NULL) return;
	
	// 노드 삽입
	enqueue(&q, ptr);
	
	// 큐에 노드가 한 개도 없을 때까지 실행.
	while (!is_empty(&q)) {
		ptr = dequeue(&q); // 큐에서 노드를 하나 꺼낸다.		
		
		printf(" [%d] ", ptr->data); // 해당 logic의 작업을 한다.
        
		if (ptr->left) // 해당 노드의 왼쪽 자식이 있으면 삽입.
			enqueue(&q, ptr->left);
		if (ptr->right) // 해당 노드의 오른쪽 자식이 있으면 삽입.
			enqueue(&q, ptr->right);
	}
}
//		 15
//	 4		 20
// 	1	   16  25
TreeNode n1={1, NULL, NULL};
TreeNode n2={4, &n1, NULL};
TreeNode n3={16, NULL, NULL};
TreeNode n4={25, NULL, NULL};
TreeNode n5={20, &n3, &n4};
TreeNode n6={15, &n2, &n5};
TreeNode *root= &n6;

int main(void) {
	// printf("중위 순회=");
	// inorder(root);
	// printf("\n");

	// printf("전위 순회=");
	// preorder(root);
	// printf("\n");

	// printf("후위 순회=");
	// postorder(root);
	// printf("\n");

    // 큐를 사용하는 레벨 순회 추가
	printf("레벨 순회=");
	level_order(root);
	printf("\n");
	return 0;
}

leveltraversaltest_Procdess
레벨 순회 실행 결과

그래서 어떤 순회를 사용해야 하는 건가요?

순서에 상관없이 모든 노드를 방문하기만 하면 되는 알고리즘에서는 3가지 순회 중 구현하기 편한 것을 사용하면 된다.

만약 노드를 방문하여 작업하는 것에 순서가 필요하다면,

(1.) 자식노드를 처리한 후에 부모노드를 처리 -> 후위 순회
(디렉토리의 용량 계산 - 하위 디렉토리의 용량부터 계산하며 올라와 전체 용량을 계산)

(2.) 부모노드를 처리한 후에 자식노드를 처리 -> 전위 순회
(구조화 된 문서의 출력 - 제목 -> 목차 -> 챕터 -> 내용)

(3.) 자식노드의 처리 중간에 부모노드의 처리가 필요한 경우 -> 중위 순회
(수식 트리 - 두 피연산자 사이에 연산자 필요 전위, 중위, 후위 모두 가능)



트리의 응용(1) : 수식 트리

이진 트리는 수식 트리(expression tree)를 처리하는데 사용될 수 있다.
피연산자들은 단말 노드(leaf node)가 되고, 연산자들은 비단말 노드가 된다.

expressiontree_Procdess
이진 트리를 활용한 수식 트리 - a + b 를 수식 트리로 표현한 것

위와 같은 수식 트리를 앞에서 배운 전위, 중위, 후위 순회를 사용하여 읽게 되면
각각 전위, 중위, 후위 표기 수식이 된다.

btreenumcal_Procdess
어떤 순회 방법을 사용하냐에 따라 이진 수식 트리의 표기법을 바꿀 수 있다.

  • 수식 트리의 루트 노드는 연산자이고, 그 자식 노드들은 피연산자인 단말 노드부터 연산자인 해당 부모 노드와 만나 연산이 실행된다.
  • 따라서, 연산자들의 피연산자인 양쪽의 자식 노드들을 먼저 꺼내 부모 노드의 연산자로 연산.
    • 이는 자식노드를 먼저 방문한 후 부모노드를 방문하는 후위 순회를 사용해야 하는 것이다.
- 프로그램의 구조 -

1. 후위 순회를 사용.
2. 서브 트리의 값을 순환 호출로 계산.
3. 비단말 노드를 방문할 때 양쪽 서브 트리의 값을 노드에 저장된 연산자를 이용하여 계산.

btreecalsequence_Procdess
수식 트리의 계산 순서



수식트리의 계산 알고리즘을 C 코드와 함께 살펴보자.
#include<stdio.h>
#include<stdlib.h>

typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
}TreeNode;

//      +
//  *       +
//1   4   16   25
TreeNode n1={1, NULL, NULL};
TreeNode n2={4, NULL, NULL};
TreeNode n3={'*', &n1, &n2};
TreeNode n4={16, NULL, NULL};
TreeNode n5={25, NULL, NULL};
TreeNode n6={'+', &n4, &n5};
TreeNode n7={'+', &n3, &n6};
TreeNode *root= &n7;

// 수식 계산 함수
int evaluate(TreeNode *root){
    if(root == NULL){
        return 0;
    }
    // 자식 노드가 없는 말단 노드(== 피연산자)이면 방문.(숫자 데이터 꺼냄)
    if(root->left == NULL && root->right == NULL){
        return root->data;
    }
    // 말단 노드가 아닌 부모노드(== 연산자) 이면 말단노드(== 피연산자)가 나올 때까지 후위 순회.
    else{
        int op1 = evaluate(root->left); // evaluate 함수의 recursion. (L)
        int op2 = evaluate(root->right);// evaluate 함수의 recursion. (R)
        printf("%d %c %d 연산 실행.\n", op1, root->data, op2);
        switch(root->data){ // L -> R 이 끝난 후 루트 노드(V)에 방문하여 연산 작업 실행. : 후위 순회
            case '+':
                return op1 + op2;
            case '-':
                return op1 - op2;
            case '*':
                return op1 * op2;
            case '/':
                return op1 / op2;   
        }
    }
    return 0;
}

int main(void){
    printf("수식의 값은 [%d] 입니다.\n", evaluate(root));
    return 0;
}

Evaluate_Btree_Procdess
수식 트리 계산 프로그램 실행 결과



트리의 응용 (2) : 디렉토리 용량 계산 - 후위 순회

directoryCalEX_Procdess
이진 트리의 후위 순회를 이용하여 디렉토리 용량 계산하기

  • 트리의 응용 : 디렉토리 용량 계산
  • 이진 트리를 사용하기 때문에 하나의 디렉토리 안에 2개를 초과하는 디렉토리가 존재하면 안된다.
  • 상위 디렉토리의 용량은 하위 디렉토리 용량의 합을 통해 구할 수 있다.
  • 후위 순회를 사용하여 하위 디렉토리를 먼저 방문한 후 루트 노드의 용량을 계산하자.
  • 순환 호출되는 함수가 용량을 반환하도록 구현
#include<stdio.h>
#include<stdlib.h>

typedef struct TreeNode {
int data;
struct TreeNode *left;
struct TreeNode *right;
}TreeNode;

int cal_dir_size(TreeNode* root){
int left_size, right_size = 0;
if(root == NULL){
return 0;
}
left_size = cal_dir_size(root->left); // 왼쪽 자식 값 반환 (L)
right_size = cal_dir_size(root->right); // 오른쪽 자식 값 반환 (R)

   return root->data + left_size + right_size; // L + R + V 용량 반환
}

int main (void){
TreeNode n4 = { 700, NULL, NULL };
TreeNode n5 = { 300, NULL, NULL };
TreeNode n3 = { 130, &n4, &n5 };
TreeNode n2 = { 20, NULL, NULL };
TreeNode n1 = { 0, &n2, &n3 };

	printf("디렉토리의 크기=%d\n", cal_dir_size(&n1));
  return 0;
}

BTreeForDirc_Procdess
디렉토리 용량 계산 결과



트리의 응용 (3) : 이진 트리의 추가 연산

1. 노드의 개수 구하기

BTree_find_node_Procdess
노드의 개수 구하기

  • 탐색 트리 안의 노드의 개수를 세어 표시한다.
  • 각각의 서브트리에 대하여 순환 호출 후, 반환 되는 값에 1을 더하여 반환.
  • int get_node_count(TreeNode* node){
      int count = 0;
      if(node != NULL){
          count = 1 + get_node_count(node->left) + get_node_count(node->right);
      }
      return count;
    }
    



2. 단말 노드의 개수 구하기

  • 왼쪽 자식과 오른쪽 자식이 동시에 0(NULL)이 되면 단말 노드이므로 1 반환.
  • 그 외는 비단말 노드이므로 각각의 서브트리에 대하여 순환호출, 반환되는 값을 서로 더한다.
  • int get_leaf_count(TreeNode* node){
      int count = 0;
      if(node!=NULL){
          if(node->left == NULL && node->right == NULL){
              return 1;
          }
          else{
              count = get_leaf_count(node->left) + get_leaf_count(node->right);
          }
      }
      return count;
    }
    



3. 높이 구하기

Btree_find_subtree_max_height_Procdess
전체 트리의 높이 구하기(이진 트리)

  • 각 서브트리에 대한 순환 호출이 끝나면 각각의 서브트리에 대한 높이가 반환되어 온다.
  • 전체 트리의 높이는 각 서브트리의 높이 합으로 구할 수 없다.
    • 서브트리의 높이의 최대 값을 찾아야 한다.
  • int get_height(TreeNode* node){
      int height = 0;
      
      if(node != NULL){
          height = 1 + max(get_height(node->left), get_height(node->right));
      }
    }
    



스레드 이진 트리 (Threaded Binary Tree)

  • 이진트리의 NULL 링크를 이용하여 순환호출 없이도 트리의 노드들을 순회
    • 중위 순회시 후속 노드인 중위 후속자(inorder successor)나 선행 노드인 중위 선행자(inorder predecessor)를 NULL 링크에 저장시켜 놓은 트리가 스레드 이진 트리이다.
    • 스레드를 이용하여 노드들을 순회 순서대로 연결시켜 놓은 트리.
    • ThreadBTree_Procdess
      스레드 이진트리의 모습
    • 스레드 트리는 순회를 빠르게 하는 장점이 있으나, 스레드를 설정하기 위하여 삽입 / 삭제 함수가 더 많은 일을 해야 한다.
  • 단말 노드와 비단말 노드의 구별을 위해 구조체에 is_thread 필드가 필요하다.
    • 만약 위와 같이 NULL 링크가 스레드에 저장되면 링크에 저장된 것이 (자식을 가리키는 포인터 or NULL을 대신한 스레드) 중 어떤 것이 저장되어 있는 것인지를 구별할 태그 필드가 필요.
    • 따라서, 다음과 같이 노드의 구조를 변경
      • typedef struct TreeNode {
          int data;
          struct TreeNode* left;
          struct Treenode* right;
          int is_thread; // 만약 오른쪽 link가 스레드(중위 후속자) 이면 TRUE 오른쪽 자식이면 FALSE
        }
        
  • 노드의 중의 후속자를 반환하는 함수 find_successor 를 작성.
// find_successor(TreeNode* node)

// node의 is_thread 가 TRUE 이면, 바로 오른쪽 자식이 중위 후속자이므로 오른쪽 자식을 반환.
// 만약 오른쪽 자식이 NULL이면 더 이상 후속자가 없다는 것이므로 NULL 반환.

// node의 is_thread가 FALSE이면 서브 트리의 가장 왼쪽 노드로 가야한다.
//  -> 왼쪽 자식이 NULL 이 될 때까지 왼쪽 링크를 타고 이동한다.

TreeNode* find_succesor(TreeNode* node){
    // nodeR 은 node의 오른쪽 포인터
    TreeNode* nodeR = node->right;
    // 만약 오른쪽 포인터가 NULL 이거나 node가 스레드이면 즉각 오른쪽 포인터를 반환
    // node == 스레드: 오른쪽 자식이 중위 후속자이므로 반환
    // node->right == NULL: 더 이상 후속자가 없다는 뜻이므로 NULL 반환     
    if(nodeR == NULL || node->is_thread == TRUE){
        return nodeR;    
    }    
    // 만약 nodeR이 위의 if문에서 걸리지 않은 오른쪽 자식이면 다시 가장 왼쪽 노드로 이동.
    while(nodeR->left != NULL) nodeR = nodeR->left;
    
    return nodeR;
}

// 스레드 버전 중위 순회 함수
// 중위 순회는 트리의 가장 왼쪽 노드부터 시작. -> 왼쪽 자식이 NULL이 될 때까지 왼쪽으로 이동.
// 이후 데이터를 출력하고, 중위 후속자를 찾는 함수를 호출하여 후속자가 NULL이 아니면 계속 루프를 반복.

void thread_inorder(TreeNode* root){
    TreeNode* node;
    node = root;
    
    while(node->left) node = node->left // 가장 왼쪽 노드로 이동
    do{
        printf("%c ", node->data); // 데이터 출력
        node = find_succesor(node) // 후속자 함수 호출
    }while(node) // NULL이 아니면 게속 loop.
}



최대한의 설명을 코드 블럭 내의 주석으로 달아 놓았습니다.

혹시 이해가 안가거나 추가적인 설명이 필요한 부분, 오류 등의 피드백은 언제든지 환영합니다!

긴 글 읽어주셔서 감사합니다. 포스팅을 마칩니다.



처음으로~

참고: C언어로 쉽게 풀어쓴 자료구조 <개정 3판> 천인국, 공용해, 하상국 지음


Task Lists

  • 트리의 개념 (Tree)
  • 트리의 용어
  • 트리의 종류
  • 이진 트리의 성질
  • 이진 트리의 분류
  • 이진 트리의 표현 : 배열 / 링크
  • 이진 트리의 순회
  • 이진 트리의 순회 방법 3가지 : 전위, 중위, 후위
  • 전위, 중위, 후위 순회 구현
  • 레벨 순회 (level order)
  • 트리의 응용(1) : 수식 트리
  • 트리의 응용 (2) : 디렉토리 용량 계산 - 후위 순회
  • 트리의 응용 (3) : 이진 트리의 추가 연산
  • 스레드 이진 트리 (Threaded Binary Tree)

Comments