이진 트리 / Binary Tree

이진 탐색 트리

 

이진 트리(Binary Tree) 구조는 각 노드가 최대 두 개의 자식 노드를 가질 수 있는 트리 구조로 가장 널리 사용된다

두 자식 노드를 왼쪽 자식 노드와 오른쪽 자식 노드로 구분한다. 

1차원 배열 또는 연결 리스트로 구현할 수 있다. 

루트 이진 트리(Rooted Binary Tree)라고도 한다. 

 

기본 용어

  • 노드(Node)
    • 데이터를 저장하는 기본 단위
    • 노드 하나만 존재해도 트리가 성립
  • 간선(Edge, Branch)
    • 노드 간의 연결을 나타냄
  • 루트 노드(Root Node)
    • 트리의 가장 위에 있는 최상위 노드
    • 부모 노드가 존재하지 않음
  • 리프 노드(Leaf Node)
    • 트리의 가장 아래에 있는 최하단 말단 노드
    • 자식 노드가 없음
  • 인테리어 노드(Interior Node)
    • 루트 노드도 아니고 리프 노드도 아닌 중간 노드
  • 부모 노드(Parent Node) & 자식 노드(Child Node)
    • 하나의 간선 기준 위는 부모 노드, 아래는 자식 노드
  • 형제 노드(Sibling Node)
    • 같은 부모를 공유하는 자식 노드들
  • 조상 노드(Ancestor Node)
    • 어떤 노드 기준 루트 노드까지 경로 상에 있는 모든 부모 노드
  • 자손 노드(Descendant Node)
    • 어떤 노드 기준 리프 노드까지 경로 상에 있는 모든 자식 노드
  • 깊이(Depth) / 레벨(Level)
    • 루트 노드에서 특정 노드까지의 거리
    • 루트 노드의 깊이는 0이며, 레벨은 1이다.
    • 레벨 = 깊이 + 1
  • 높이(Height)
    • 전체 트리에서 깊이의 최댓값
  • 크기(Size)
    • 어떤 노드 자신과 그 모든 자손 노드의 총 개수
    • 트리의 크기 = 루트 노드의 크기
  • 차수(Degree)
    • 어떤 노드의 자식의 개수
    • 트리의 차수 = 루트 노드의 차수

 

종류

  • 일반 이진 트리(Binary Tree)
    • 각 노드가 최대 두 개의 자식 노드를 가질 수 있음
    • 특별한 조건이 없음
    • 트리 최대 레벨이 k일 때,
      • k 레벨에 있을 수 있는 최대 노드 개수 = 2^(k-1)
      • 트리 내에 있을 수 있는 최대 노드 개수 = 2^k - 1

일반 이진 트리

  • 정 이진 트리(Full Binary Tree)
    • 각 노드가 0개 또는 2개의 자식 노드를 가짐

정 이진 트리

  • 포화 이진 트리(Perfect Binary Tree)
    • 모든 노드가 2개의 자식 노드를 가지며, 모든 리프 노드가 같은 레벨을 가짐
    • 트리 최대 레벨이 k일 때, 가질 수 있는 최대 노드 개수를 가짐

포화 이진 트리

  • 완전 이진 트리(Complete Binary Tree)
    • 왼쪽 자식부터 빈 곳 없이 채워짐
    • 포화 이진 트리는 완전 이진 트리의 부분 집합

완전 이진 트리

  • 이진 탐색 트리(Binary Search Tree, BST)
    • 왼쪽 자식 노드 < 부모 노드 < 오른쪽 자식 노드의 규칙을 만족함
    • 탐색, 삽입, 삭제 연산에서 평균적으로 O(log N)의 시간 복잡도

이진 탐색 트리

  • 균형 이진 트리(Balanced Binary Tree)
    • 특정 규칙을 통해 스스로 균형을 유지하는 이진 탐색 트리
    • 모든 리프 노드의 높이 차이가 최대 1 이하
    • AVL 트리, 레드-블랙 트리 등
  • 힙(Heap)
    • 부모 노드가 자식 노드보다 크거나/작거나 같은 완전 이진 트리
    • 전자는 최대 힙(Max Heap), 후자는 최소 힙(Min Heap)
    • 우선순위 큐에 사용

최대 힙 & 최소 힙

 

순회(Traversal) 방법

노드를 방문하는 순서

  • 깊이 우선 탐색(Depth-First, DFS)
    • 전위 순회(Preorder, NLR)
      • 루트 → 왼쪽 자식 → 오른쪽 자식
      • 연산 표기법 : Prefix Expression (연산자 - 피연산자 - 피연산자)
    • 중위 순회(Inorder, LNR)
      • 왼쪽 자식 → 루트 → 오른쪽 자식
      • 이진 트리가 아니라면 중위 순회 불가능 (임의 순서 설정 필요)
      • 연산 표기법 : Infix Expression (피연산자 - 연산자 - 피연산자)
    • 후위 순회(Postorder, LRN)
      • 왼쪽 자식 → 오른쪽 자식 → 루트
      • 연산 표기법 : Postfix Expression (피연산자 - 피연산자 - 연산자)
  • 너비 우선 탐색(Breadth-First, BFS)
    • 레벨 순회(Level Order Traversal)
      • 루트부터 차례대로 방문
      • 첫 레벨부터 각 레벨의 모든 노드를 거치고 다음 레벨로 이동
      • 순회 순서를 k라고 할 때, 
        • k번째 노드의 부모 노드의 순서 = k/2의 내림
        • k번째 노드의 왼쪽 자식의 순서 = 2k
        • k번째 노드의 오른쪽 자식의 순서 = 2k + 1

트리 / Tree

트리(Tree) 구조는 여러 개의 노드(Node)로 구성되는 그래프의 한 종류이다. 

뿌리(Root)가 아래에 있는 일반적인 나무의 형태를 뒤집어 놓은 듯한 형태를 가진다. 

크기 6, 높이 2인 이진 트리

 

트리 성립 조건

아래의 조건을 모두 만족해야 한다.

  1. 연결 그래프(Acyclic Connected Graph)
    • 모든 노드가 연결되어 있어야 하며, 분리된 노드가 존재할 수 없다.
  2. 사이클(Cycle)이 없어야 함
    • 사이클이 존재하는 순환 구조를 가질 수 없다.
    • 간선(Edge)를 추가했을 때 사이클이 생긴다면 조건을 위반하게 된다.
  3. 노드 개수(N)과 간선 개수(E)의 관계 : E = N - 1
    • 전체 간선의 개수는 전체 노드 개수보다 1 작다.

 

주의 사항

  1. 루트 노드(Root Node)는 필수가 아니다.
    • 일반적으로 트리는 하나의 루트 노드를 가지지만 루트 노드가 필수는 아니다.
    • 대표적으로 포레스트(Forest, 독립적인 트리의 모음)가 있다. 
  2. 유향 그래프(Directed Graph)에서도 트리 구조가 가능하다.
    • 위의 조건을 모두 만족시키는 DAG(Directed Acyclic Graph, 방향 비순환 그래프) 형태가 가능하다. 
  3. 비계층적(Non-Hierarchical) 트리가 존재할 수 있다.
    • 일반적으로 트리는 부모-자식 관계를 가지는 계층적 구조 형태지만 그 계층성이 모호한 경우가 있다. 
    • 포레스트 구조는 특정한 계층 구조를 가지지 않을 수 있다. 
    • Union-Find, DFS 등에서 트리는 계층적 의미보다 집합을 표현하는 도구 또는 경로 등을 나타낼 수 있다. 

 

트리의 종류

  • 일반 트리(General Tree)
    • 각 노드가 임의 개수의 자식 노드를 가질 수 있는 트리
  • 이진 트리(Binary Tree)
    • 각 노드가 최대 두 개의 자식 노드를 가질 수 있는 트리
    • 정 이진 트리, 포화 이진 트리, 완전 이진 트리, 이진 탐색 트리, 균형 이진 트리, 힙 등으로 세분화
  • 트라이(Trie)
    • 문자열 검색을 빠르게 수행할 수 있는 트리 구조
  • m-Way Tree
    • 이진 트리를 확장하여 각 노드가 2개 이상의 자식 노드를 가질 수 있는 트리
    • m-Way Trie, 2-3 Tree, 2-3-4 Tree 등
  • Height-Balanced m-Way Tree
    • 특정 규칙을 통해 스스로 균형을 유지하는 m-Way Tree
    • B-Tree, B+-Tree, K-d B-Tree 등
  • Spatial Tree
    • 다차원 정보를 다루기 위한 트리 구조
    • Quad Tree, Oct Tree, K-d Tree, R-Tree, R* Tree 등

Stack

LIFO

Stack은 쌓아 올리는 방식의 자료구조이다. 

 

LIFO (Last In First Out, 후입선출), 또는 FILO (First In Last Out, 선입후출) 방식으, 가장 먼저 들어온 데이터가 가장 나중에 나가며, 가장 나중에 들어온 데이터가 가장 먼저 나간다.

 

변수 'top'을 선언하며, 이는 가장 위에 있는, 가장 최근에 들어온 Element를 가리킨다. 

최초 구현 시, 변수 'top'을 -1로 초기화하여 빈 Stack을 만든다. 

Element를 추가할 경우, 현재 'top'이 가리키는 Element의 위에 쌓이며 'top' 값이 1 증가한다. 

Element를 제거할 경우, 현재 'top'이 가리키는 Element를 제거하고 'top' 값이 1 감소한다.

 

Array와 Linked List 모두 구현이 가능하며, Array 방식을 사용하여 구현할 경우 Buffer의 구현 방식에 따라 다시 두 종류로 나눌 수 있다.

  • Non-Circular buffer
  • Circular buffer

 

주요 연산

  • push (or insert) : Stack의 가장 위에 Element를 추가
  • pop (or delete) : Stack의 가장 위의 Element를 제거하고 값을 반환
  • peek (or top) : Stack의 가장 위의 Element를 반환 (제거는 하지 않음)
  • isEmpty (or stack_empty) : Stack이 비어 있는지 확인
  • isFull (or stack_full) : Stack이 가득 차 있는지 확인

 

주요 사용 예시

  • 함수 호출 시의 스택 프레임 관리
  • 수식의 괄호 검사 : 왼쪽 괄호와 오른쪽 괄호의 개수와 순서를 순차적 검사
  • 깊이 우선 탐색(DFS) 알고리즘 구현 : 순차적 탐색으로 Stack의 방식과 유사
  • 웹 브라우저의 뒤로 가기 기능 : 가장 마지막에 열린 페이지 복구

 

C++ 예시 코드

#include <iostream>
#define SIZE 5  // Stack size

using namespace std;

class Stack {
private:
    int arr[SIZE];  // Array 기반
    int top;

public:
    Stack() { top = -1; }  // 초기화

    bool isEmpty() { return top == -1; }
    bool isFull() { return top == SIZE - 1; }

    void push(int value) {
        if (isFull()) {
            cout << "Stack Overflow\n";
            return;
        }
        arr[++top] = value;
    }

    int pop() {
        if (isEmpty()) {
            cout << "Stack Underflow\n";
            return -1;
        }
        return arr[top--];
    }

    int peek() {
        if (isEmpty()) {
            cout << "Stack is empty\n";
            return -1;
        }
        return arr[top];
    }

    void display() {
        if (isEmpty()) {
            cout << "Stack is empty\n";
            return;
        }
        for (int i = top; i >= 0; i--)
            cout << arr[i] << " ";
        cout << endl;
    }
};

int main() {
    Stack s;
    s.push(10);		// 10
    s.push(20); 	// 10 20
    s.push(30); 	// 10 20 30
    s.display();
    cout << "Popped: " << s.pop() << endl;	// 10 20
    s.display();
}

Queue

FIFO

Queue은 줄을 서는 방식과 유사한 자료구조이다. 

 

FIFO (First In First Out, 선입선출) 방식으로, 가장 먼저 들어온 데이터가 가장 먼저 나간다. 

 

한 쪽에서만 연산이 이루어지는 Stack과 다르게 Queue는 두 쪽에서 연산이 이루어진다. 

변수 'front'와 'rear'를 선언하며, 각각 Queue의 가장 앞(먼저 온 쪽)과 가장 뒤(나중에 온 쪽)를 가리키게 된다. 

최초 구현 시, 변수 'front'와 'rear'를 -1로 초기화하여 빈 Queue를 만든다. 

Element를 추가할 경우, 'rear' 값이 1 증가하며  Element를 처음 추가하면 추가로 'front' 값을 0으로 한다.

Element를 제거할 경우, 현재 'front'가 가리키는 가장 앞의 Element를 제거하고 'front' 값이 1 증가한다. 

 

마찬가지로 Array와 Linked List 모두 구현이 가능하며, Array 방식을 사용하여 구현할 경우 Buffer의 구현 방식에 따라 다시 두 종류로 나눌 수 있다.

  • Non-Circular buffer
  • Circular buffer

 

주요 연산

  • enqueue (or insert) : Queue의 가장 뒤에 Element를 추가
  • dequeue (or delete) : Queue의 가장 앞의 Element를 제거하고 값을 반환
  • peek (or front) : Queue의 가장 앞의 Element를 반환 (제거는 하지 않음)
  • isEmpty (or queue_empty) : Queue가 비어 있는지 확인
  • isFull (or queue_full) : Queue가 가득 차 있는지 확인

 

주요 사용 예시

  • 프린터 인쇄 작업 대기열
  • 운영체제의 프로세스 스케줄링
  • 네트워크 패킷 처리

 

C++ 예시 코드

#include <iostream>
#define SIZE 5  // Stack size

using namespace std;

class Queue {
private:
    int arr[SIZE];  // Array 기반
    int front, rear;

public:
    Queue() {
        front = -1;
        rear = -1;
    } // 초기화

    bool isEmpty() { return front == -1; }
    bool isFull() { return rear == SIZE - 1; }

    void enqueue(int value) {
        if (isFull()) {
            cout << "Queue Overflow\n";
            return;
        }
        if (isEmpty()) front = 0;
        arr[++rear] = value;
    }

    int dequeue() {
        if (isEmpty()) {
            cout << "Queue Underflow\n";
            return -1;
        }
        int data = arr[front];
        if (front >= rear) { // 마지막 요소 제거 시 초기화
            front = -1;
            rear = -1;
        }
        else {
            front++;
        }
        return data;
    }

    int peek() {
        if (isEmpty()) {
            cout << "Queue is empty\n";
            return -1;
        }
        return arr[front];
    }

    void display() {
        if (isEmpty()) {
            cout << "Queue is empty\n";
            return;
        }
        for (int i = front; i <= rear; i++)
            cout << arr[i] << " ";
        cout << endl;
    }
};

int main() {
    Queue q;
    q.enqueue(10);	// 10
    q.enqueue(20);	// 10 20
    q.enqueue(30);	// 10 20 30
    q.display();
    cout << "Dequeued: " << q.dequeue() << endl;	// 20 30
    q.display();
}

Circular Queue

Circular Queue는 Queue의 가장 앞과 가장 뒤가 원형으로 연결된 자료구조이다. 

일반적 Queue에서 Element를 제거할 경우 기존 'front'가 가리키던 공간을 사용할 수 없게 된다.

Circular Queue는 Queue의 공간 낭비를 해결하여 고정된 크기에서 효율적인 공간 활용이 가능하다. 

 

Circular Queue에서 Element를 추가할 경우, 단순히 'rear' 값을 1 증가시키는 대신, (rear + 1) % SIZE를 사용한다. 

이를 통해 'rear' 값이 SIZE보다 같거나 커질 경우 비어있는 'front' 자리를 사용할 수 있다. 

Circular Queue에서 Element를 제거할 경우에도 (front + 1) % SIZE를 사용하며,

Circular Queue가 가득 찬 상태는 (rear + 1) % SIZE == front 인 경우이다. 

 

C++ 예시 코드

#include <iostream>
#define SIZE 5  // Stack size

using namespace std;

class CircularQueue {
private:
    int arr[SIZE]; // Array 기반
    int front, rear;

public:
    CircularQueue() {
        front = -1;
        rear = -1;
    } // 초기화

    bool isEmpty() { return front == -1; }
    bool isFull() { return (rear + 1) % SIZE == front; }

    void enqueue(int value) {
        if (isFull()) {
            cout << "Circular Queue Overflow\n";
            return;
        }
        if (isEmpty()) front = 0;
        rear = (rear + 1) % SIZE;
        arr[rear] = value;
    }

    int dequeue() {
        if (isEmpty()) {
            cout << "Circular Queue Underflow\n";
            return -1;
        }
        int data = arr[front];
        if (front == rear) { // 마지막 요소 제거 시 초기화
            front = -1;
            rear = -1;
        }
        else {
            front = (front + 1) % SIZE;
        }
        return data;
    }

    int peek() {
        if (isEmpty()) {
            cout << "Queue is empty\n";
            return -1;
        }
        return arr[front];
    }

    void display() {
        if (isEmpty()) {
            cout << "Circular Queue is empty\n";
            return;
        }
        int i = front;
        while (true) {
            cout << arr[i] << " ";
            if (i == rear) break;
            i = (i + 1) % SIZE;
        }
        cout << endl;
    }
};

int main() {
    CircularQueue cq;
    cq.enqueue(10);	// 10
    cq.enqueue(20);	// 10 20
    cq.enqueue(30);	// 10 20 30
    cq.enqueue(40);	// 10 20 30 40
    cq.enqueue(50);	// 10 20 30 40 50
    cq.display();
    cout << "Dequeued: " << cq.dequeue() << endl;	// 20 30 40 50
    cq.enqueue(60);	// 20 30 40 50 60 (front = 1, rear = 0)
    cq.display();
}

자료구조

자료구조란 특정 규칙에 따라 데이터를 구성 및 저장하는 기술을 뜻한다. 

읽기, 업데이트, 삭제, 복사, 이동 등의 특정 목적을 위한 빠른 데이터 접근을 위해 사용한다. 

 

다루는 데이터의 양이 커질수록 자료구조의 중요성이 올라간다. 

이는 다시 말하면 다루는 데이터의 양이 적을수록 자료구조의 중요성이 낮아짐을 의미한다. 

 

자료구조의 방식은 여러 가지가 있으며, 거의 모든 자료구조는 각각의 장점과 단점을 갖는다. 

따라서 모든 목적에 부합하는 완벽한 자료구조는 없으며, 목적에 맞는 자료구조 방식을 사용하는 것이 중요하다.  


자료구조의 분류

자료구조의 종류는 크게 선형(Linear) 자료구조와 비선형(Non-Linear) 자료구조로 나눌 수 있다. 

  • 선형 자료구조 (Linear Data Structures)
    • Array (배열)
    • Linked List (연결 리스트)
    • Stack (스택)
    • Queue (큐)
  • 비선형 자료구조 (Non-Linear Data Structures)
    • Tree (트리)
    • Graph (그래프)
    • Hashing / Hash Table (해싱 / 해시 테이블)
    • Heap (힙)

알고리즘

알고리즘은 데이터 처리를 위한 일련의 단계적 절차 또는 처리 과정을 뜻한다. 

자료구조를 통해 처리할 데이터를 저장한 후, 특정 알고리즘을 통해 데이터를 단계적으로 처리하여 문제를 해결한다.

자료 구조 알고리즘에는 대표적으로 다음과 같은 방식이 있다. 

  • 알고리즘 (Algorithms)
    • Sorting
    • Tree Operations
    • Graph Traversal

+ Recent posts