트리
트리는 루트 노드
각 서브트리의 루트 노드는
- 각 노드는 임의의 수의 자식을 가질 수 있으며, 없을 수도 있다.
- 조부모grandparent, 손자grandchildren 관계도 비슷한 방식으로 정의된다.
트리 용어
아래 트리를 기준으로 용어를 정리한다.
- 루트root: 부모가 없는 최상위 노드. 아래 트리에서는
. - 부모/자식: 노드
는 를 부모로 가지고, , , 을 자식으로 가진다. - 리프leaf: 자식이 없는 노드. 아래 트리에서는
, , , , , , , , . - 형제자매sibling: 같은 부모를 가진 노드.
, , 은 형제자매이다. - 깊이depth: 루트에서 그 노드까지의 경로 길이(루트의 깊이는 0).
- 높이height: 그 노드에서 가장 깊은 리프까지의 경로 길이(리프의 높이는 0). 트리의 높이는 루트의 높이이다.
일반 트리의 구현: 왼쪽 자식-오른쪽 형제
일반 트리는 노드마다 자식 수가 다르므로, 자식마다 포인터를 두면 공간이 낭비된다. 대신 임의 노드의 첫 번째 자식 노드와 바로 옆 형제자매 노드를 잇는 연결 리스트를 사용하는 것이 합리적이다(왼쪽 자식-오른쪽 형제left-child right-sibling 표현).
이진 트리
이진 트리binary tree란 어떠한 노드도 2개보다 많은 자식을 갖지 않는 트리를 의미한다.
- 이진 트리의 깊이는
보다 현저하게 작을 수 있으며, 이는 이진 트리의 중요한 성질이다. - 평균적으로
의 깊이를 가지며, 이진 트리의 특별한 경우인 이진 탐색 트리는 만큼의 깊이를 가진다.
일반적인 트리와 달리, 이진 트리는 자식을 최대 2개만 가지므로 노드 클래스를 선언할 때 자식 노드를 직접 가리키는 포인터를 사용해도 공간 복잡도space complexity의 낭비가 없다.
표현 트리와 순회
아래 표현 트리expression tree는
순회traversal는 노드를 방문하는 순서에 따라 세 가지로 나뉜다. 방문(노드값 출력)의 위치가 가운데/뒤/앞 중 어디인지가 이름과 결과를 결정한다.
중위 순회inorder traversal — 방문이 가운데. 표현 트리를 중위 순회하면 중위 표기법infix notation의 수식을 얻는다.
void inOrder(BinaryNode* node) {
if (node != nullptr) {
inOrder(node->left); // 왼쪽 자식
cout << node->key << " "; // in-order 방문
inOrder(node->right); // 오른쪽 자식
}
}후위 순회postorder traversal — 방문이 뒤. 표현 트리를 후위 순회하면 후위 표기법postfix notation
void postOrder(BinaryNode* node) {
if (node != nullptr) {
postOrder(node->left); // 왼쪽 자식
postOrder(node->right); // 오른쪽 자식
cout << node->key << " "; // post-order 방문
}
}전위 순회preorder traversal — 방문이 앞. 표현 트리를 전위 순회하면 전위 표기법prefix notation
void preOrder(BinaryNode* node) {
if (node != nullptr) {
cout << node->key << " "; // pre-order 방문
preOrder(node->left); // 왼쪽 자식
preOrder(node->right); // 오른쪽 자식
}
}이 세 순회는 깊이 우선(재귀)인 반면, 레벨 순서 순회level-order traversal는 너비 우선으로 루트부터 레벨을 따라 위에서 아래로, 각 레벨은 왼쪽에서 오른쪽으로 방문한다. 큐를 이용해 구현한다.
순회 이름 외우는 법
방문(출력) 시점이 앞이면 전위(pre), 가운데면 중위(in), 뒤면 후위(post). 세 순회 모두 왼쪽 → 오른쪽 순서로 자식을 내려간다는 점은 같다.
이진 탐색 트리
이진 트리가 사용되는 중요한 응용은 탐색searching이다.
이진 탐색 트리binary search tree는 이진 트리 중 하나이며, 임의의 노드
BST의 다섯 가지 연산
contains: 찾고자 하는 원소 가 트리 에 있으면true, 없으면false를 반환한다. 루트에서 시작해 와 비교하며 작으면 왼쪽, 크면 오른쪽으로 내려간다.findMin: 루트부터 시작하여 가장 왼쪽 리프까지 가면 가장 작은 원소를 찾는다.findMax: 루트부터 시작하여 가장 오른쪽 리프까지 가면 가장 큰 원소를 찾는다.insert: 우선 의 위치를 탐색하고, 이미 가 있으면 종료, 없으면 그 자리(리프)에 삽입한다.
insert(5): 탐색이 끝난 리프 자리에 새 노드를 붙인다.remove 연산은 몇 가지 경우로 나누어 고려한다.
가 리프 노드이면 그냥 삭제 후 종료한다. 의 자식 노드가 1개이면, 그 자식 노드를 의 부모 노드에 연결시킨 후 종료한다. 의 자식 노드가 2개이면, 의 오른쪽 서브트리 중 가장 작은 원소(후계자successor)를 찾아 자리에 옮긴 뒤, 오른쪽 서브트리에서 그 후계자를 재귀적으로 제거한다.
remove: 자식이 하나면 자식을 끌어올리고, 둘이면 오른쪽 서브트리의 최소값(후계자)으로 대체한다.편향의 문제
BST의 연산 비용은 트리의 높이에 비례한다. 균형이 잘 잡히면 평균
실습 — BST와 세 가지 순회
노드를 삽입하고 전위·중위·후위 순회의 방문 순서를 출력한다. 중위 순회는 항상 정렬된 순서로 나온다는 점, 그리고 자식이 2개인 노드를 삭제할 때 후계자로 대체된다는 점을 확인하자. (시험의 전위/후위 순회 문제와 직결된다.)
#include <iostream>
using namespace std;
class BinaryNode {
public:
int key;
BinaryNode* left;
BinaryNode* right;
BinaryNode(int value) : key(value), left(nullptr), right(nullptr) {}
};
class BinarySearchTree {
public:
BinarySearchTree() : root(nullptr) {}
void insert(int key) { root = _insert(root, key); }
void remove(int key) { root = _remove(root, key); }
int findMin() {
if (root == nullptr) { cout << "The tree is empty." << endl; return -1; }
return _findMin(root)->key;
}
int findMax() {
if (root == nullptr) { cout << "The tree is empty." << endl; return -1; }
return _findMax(root)->key;
}
void preOrder() { _preOrder(root); cout << endl; }
void inOrder() { _inOrder(root); cout << endl; }
void postOrder() { _postOrder(root); cout << endl; }
private:
BinaryNode* root;
BinaryNode* _insert(BinaryNode* node, int key) {
if (node == nullptr) return new BinaryNode(key);
else if (key < node->key) node->left = _insert(node->left, key);
else if (key > node->key) node->right = _insert(node->right, key);
return node;
}
BinaryNode* _remove(BinaryNode* node, int key) {
if (node == nullptr) return node;
if (key < node->key) node->left = _remove(node->left, key);
else if (key > node->key) node->right = _remove(node->right, key);
else {
if (node->left == nullptr) { // 오른쪽 자식만 존재
BinaryNode* temp = node->right;
delete node;
return temp;
} else if (node->right == nullptr) { // 왼쪽 자식만 존재
BinaryNode* temp = node->left;
delete node;
return temp;
}
BinaryNode* temp = _findMin(node->right); // 자식 2개: 후계자로 대체
node->key = temp->key;
node->right = _remove(node->right, temp->key);
}
return node;
}
void _preOrder(BinaryNode* node) {
if (node != nullptr) {
cout << node->key << " ";
_preOrder(node->left);
_preOrder(node->right);
}
}
void _inOrder(BinaryNode* node) {
if (node != nullptr) {
_inOrder(node->left);
cout << node->key << " ";
_inOrder(node->right);
}
}
void _postOrder(BinaryNode* node) {
if (node != nullptr) {
_postOrder(node->left);
_postOrder(node->right);
cout << node->key << " ";
}
}
BinaryNode* _findMin(BinaryNode* node) {
BinaryNode* current = node;
while (current && current->left != nullptr) current = current->left;
return current;
}
BinaryNode* _findMax(BinaryNode* node) {
BinaryNode* current = node;
while (current && current->right != nullptr) current = current->right;
return current;
}
};
int main() {
BinarySearchTree bst;
int keys[] = {6, 2, 8, 1, 4, 3, 7};
for (int k : keys) bst.insert(k);
cout << "preorder : "; bst.preOrder();
cout << "inorder : "; bst.inOrder();
cout << "postorder: "; bst.postOrder();
cout << "min = " << bst.findMin() << ", max = " << bst.findMax() << endl;
bst.remove(2); // 자식 2개 -> 후계자 3으로 대체
cout << "after remove(2), inorder: "; bst.inOrder();
return 0;
}출력에서 중위 순회는 1 2 3 4 6 7 8로 항상 정렬됨을 확인하라.
AVL 트리
만약 이진 트리의 균형balance이 맞지 않다면, 평균적으로
균형을 유지하는 트리는, 어떠한 서브트리도 너무 깊어지지 않도록 제한하여 최악의 경우에도
AVL 트리Adelson-Velskii and Landis tree는 균형 조건balance condition을 만족하는 이진 탐색 트리이다.
- AVL 트리는 임의의 노드에서, 왼쪽 서브트리와 오른쪽 서브트리의 깊이depth 차가
이하인 성질을 가진다.
회전으로 균형 맞추기
AVL 트리의 삽입과 삭제는 연산 후 AVL 특성을 깰 수 있으므로, 연산 종료 전에 트리를 수선하여 균형을 다시 맞춰야 한다. 이는 회전rotation이라 불리는 간단한 트리 변형으로 이루어진다.
AVL 균형을 깬 노드(왼쪽·오른쪽 서브트리 깊이 차가
- LL: 균형을 깬 노드가 왼쪽 서브트리의 왼쪽 자식인 경우 → 우회전right rotation 1회
- LR: 균형을 깬 노드가 왼쪽 서브트리의 오른쪽 자식인 경우 → 좌회전left rotation 후 우회전
- RL: 균형을 깬 노드가 오른쪽 서브트리의 왼쪽 자식인 경우 → 우회전 후 좌회전
- RR: 균형을 깬 노드가 오른쪽 서브트리의 오른쪽 자식인 경우 → 좌회전 1회
LL과 RR은 거울 대칭이고 LR과 RL도 거울 대칭이므로, 이론적으로는 2가지 경우만 고려하면 충분하다. 다만 구현을 위해서는 4가지를 모두 다뤄야 한다.
단일 회전 (LL, RR)
LL과 RR은 불균형이 트리의 바깥쪽outside에서 일어나는 경우로, 단일 회전single rotation으로 수선된다. 아래 트리를 수선하려면
LL의 시나리오는 다음과 같다. 트리가 유연하다고 가정하고, 불균형 노드
- BST 성질상
이므로 는 의 오른쪽 자식이 된다. 와 는 그대로 각각 의 왼쪽 자식, 의 오른쪽 자식이 된다.- 기존에
과 사이에 있던 서브트리 는 의 왼쪽 자식이 되어, BST 성질을 유지한다.
이중 회전 (LR, RL)
단일 회전 알고리즘은 LR, RL 경우를 수선할 수 없다. 아래처럼 안쪽 서브트리
LR, RL은 불균형이 트리의 안쪽inside에서 일어나는 경우로, 이중 회전double rotation으로 수선된다. 우선 단일 회전으로 LR은 LL로, RL은 RR로 변환한 뒤, 다시 단일 회전하여 균형을 맞춘다.
LR의 시나리오는 다음과 같다.
- 불균형 노드
의 왼쪽 자식 의 오른쪽 자식 를 피봇으로 잡아 좌회전한다. 그러면 의 왼쪽 자식은 , 의 왼쪽 자식은 이 되어 LL 형태가 된다. - 이제
를 피봇으로 우회전한다. 가 새 루트가 되고, 과 가 각각 의 왼쪽·오른쪽 자식이 된다.
실습 — AVL 삽입과 자동 재균형
1이 아니라 4가 되는 것을 확인하자. 공개 메서드와 순회는 앞의 BST 구현을 그대로 채워 넣었다.
#include <iostream>
#include <algorithm>
using namespace std;
class AVLNode {
public:
int key; int height;
AVLNode* left; AVLNode* right;
AVLNode(int value) : key(value), left(nullptr), right(nullptr), height(1) {}
};
class AVLTree {
public:
AVLTree() : root(nullptr) {}
void insert(int key) { root = _insert(root, key); }
void remove(int key) { root = _remove(root, key); }
int rootKey() { return root ? root->key : -1; }
void preOrder() { _preOrder(root); cout << endl; }
void inOrder() { _inOrder(root); cout << endl; }
private:
AVLNode* root;
int height(AVLNode* node) { return node == nullptr ? 0 : node->height; }
int getBalance(AVLNode* node) {
return node == nullptr ? 0 : height(node->left) - height(node->right);
}
AVLNode* rightRotate(AVLNode* y) {
AVLNode* x = y->left;
AVLNode* T2 = x->right;
x->right = y;
y->left = T2;
y->height = max(height(y->left), height(y->right)) + 1;
x->height = max(height(x->left), height(x->right)) + 1;
return x;
}
AVLNode* leftRotate(AVLNode* x) {
AVLNode* y = x->right;
AVLNode* T2 = y->left;
y->left = x;
x->right = T2;
x->height = max(height(x->left), height(x->right)) + 1;
y->height = max(height(y->left), height(y->right)) + 1;
return y;
}
AVLNode* _insert(AVLNode* node, int key) {
if (node == nullptr) return new AVLNode(key);
if (key < node->key) node->left = _insert(node->left, key);
else if (key > node->key) node->right = _insert(node->right, key);
else return node;
node->height = 1 + max(height(node->left), height(node->right));
int balance = getBalance(node);
if (balance > 1 && key < node->left->key) return rightRotate(node); // LL
if (balance < -1 && key > node->right->key) return leftRotate(node); // RR
if (balance > 1 && key > node->left->key) { // LR
node->left = leftRotate(node->left); // LL로 변환
return rightRotate(node);
}
if (balance < -1 && key < node->right->key) { // RL
node->right = rightRotate(node->right); // RR로 변환
return leftRotate(node);
}
return node;
}
AVLNode* _findMin(AVLNode* node) {
AVLNode* current = node;
while (current && current->left != nullptr) current = current->left;
return current;
}
AVLNode* _remove(AVLNode* node, int key) {
if (node == nullptr) return node;
if (key < node->key) node->left = _remove(node->left, key);
else if (key > node->key) node->right = _remove(node->right, key);
else {
if ((node->left == nullptr) || (node->right == nullptr)) {
AVLNode* temp = node->left ? node->left : node->right;
if (temp == nullptr) { temp = node; node = nullptr; }
else *node = *temp;
delete temp;
} else {
AVLNode* temp = _findMin(node->right);
node->key = temp->key;
node->right = _remove(node->right, temp->key);
}
}
if (node == nullptr) return node;
node->height = 1 + max(height(node->left), height(node->right));
int balance = getBalance(node);
if (balance > 1 && getBalance(node->left) >= 0) return rightRotate(node); // LL
if (balance < -1 && getBalance(node->right) <= 0) return leftRotate(node); // RR
if (balance > 1 && getBalance(node->left) < 0) { // LR
node->left = leftRotate(node->left);
return rightRotate(node);
}
if (balance < -1 && getBalance(node->right) > 0) { // RL
node->right = rightRotate(node->right);
return leftRotate(node);
}
return node;
}
void _preOrder(AVLNode* node) {
if (node != nullptr) {
cout << node->key << " ";
_preOrder(node->left);
_preOrder(node->right);
}
}
void _inOrder(AVLNode* node) {
if (node != nullptr) {
_inOrder(node->left);
cout << node->key << " ";
_inOrder(node->right);
}
}
};
int main() {
AVLTree avl;
for (int k = 1; k <= 7; ++k) avl.insert(k); // 편향될 뻔하지만 회전으로 균형
cout << "root = " << avl.rootKey() << endl; // 균형 잡힌 루트
cout << "preorder: "; avl.preOrder(); // 완전 균형 트리
cout << "inorder : "; avl.inOrder(); // 항상 정렬됨
avl.remove(1); avl.remove(2);
cout << "after remove(1), remove(2) -> root = " << avl.rootKey()
<< ", preorder: ";
avl.preOrder();
return 0;
}1~7을 넣어도 루트는 4, 전위 순회는 4 2 1 3 6 5 7인 완전 균형 트리가 된다.
레드-블랙 트리
레드-블랙 트리red-black tree는 수선을 통해 균형을 맞추는 이진 탐색 트리의 일종이다.
- 레드-블랙 트리의 깊이는 최대
이다. - 따라서 모든 연산은
이다.
색깔 성질
레드-블랙 트리는 아래 색깔color 성질을 따른다.
- 모든 노드는 레드 혹은 블랙이다.
- 루트는 블랙이다.
- 임의의 레드 노드의 자식 노드는 블랙이다(레드가 연속될 수 없다).
- 루트부터 임의의 리프 노드(
nullptr)까지의 모든 경로는 같은 수의 블랙 노드를 포함한다(블랙 높이가 일정하다).
삽입 시 재조정
원소
경우 1: 가 블랙
규칙 위반이 발생하지 않는다. 그대로 둔다.
경우 2: 가 레드
삽입 이전에는 레드-블랙 트리였으므로,
(a)
가 루트였다면 다시 블랙으로 되돌린다.- 그렇지 않다면
의 부모(증조부모)에서 레드-블랙 특성을 재귀적으로 확인한다.
(b) nullptr):
가 의 오른쪽 자식이면, 를 피봇으로 좌회전하여 경우 2.(b).ii로 바꾼다. 가 의 왼쪽 자식이면, 를 피봇으로 우회전한 후 와 의 색깔을 맞바꾼다.
삭제 시 재조정
삭제는 조금 더 복잡하며, 삭제할 원소
경우 1: 가 레드이거나, 는 블랙이지만 유일한 자식 이 레드
문제가 발생하지 않으며, 일반 BST의 노드 삭제와 같은 방식을 따른다(레드였던
경우 2: 와 이 블랙, 가 레드
(a)
(b)
(c)
경우 3: , , 가 모두 블랙
마찬가지로
(a)
- (b)
는 블랙, 은 레드 또는 블랙, 이 레드인 경우: 경우 2.(b)와 의 색만 다르며 처리에 영향이 없으므로 2.(b)와 동일하게 처리한다. - (c)
와 은 블랙, 이 레드인 경우: 경우 2.(c)와 의 색만 다르므로 2.(c)와 동일하게 처리한다. - (d)
가 레드, 과 이 모두 블랙인 경우: 를 피봇으로 좌회전한 후 와 의 색을 맞바꾸면, 경우 2.(a)·2.(b)·2.(c) 중 하나로 이동한다.
복잡도
- 탐색:
. - 삽입·삭제: 대부분은 상수 시간
의 색 조정으로 끝나지만, 최악의 경우 재조정이 루트까지 재귀적으로 반복되므로 이다.
트라이
트라이trie는 전위 트리prefix tree 또는 디지털 트리digital tree라고도 불리며, 특정한 키(주로 문자열)를 사용하는 탐색 트리의 일종이다.
성질
- 트라이 노드들은 문자열 키값들의 공용 접두어prefix를 공유하여, 메모리 사용량을 최적화하고 검색 효율을 높인다.
- 문자열 안의 문자들을 따라 트리를 순회하며 문자열을 검색한다.
- 문자열의 끝을 의미하는 노드는 보통 플래그flag나 문자열 값과 같은 특별한 방식으로 표시되어, 트라이 안에 그 문자열이 존재함을 나타낸다.
- 대부분 루트 노드는 빈 문자열을 나타내기 위해 어떤 문자도 포함하지 않으며, 각 자식 노드는 문자열의 다음 글자를 나타낸다.
복잡도와 공간 효율
다루고자 하는 문자열의 길이가
- 짧은 길이의 문자열이 많은 경우에는, 각 노드와 포인터 값들로 인해 메모리 공간을 과도하게 차지할 수 있다.
- 그러나 접두어들이 많이 공유되는 경우(겹치는 문자열이 많은 경우)에는, 트라이가 훨씬 더 공간적으로 효율적일 수 있다.
트리 종류 한눈에 정리
| 트리 | 균형 보장 | 대표 연산 복잡도 |
|---|---|---|
| 이진 탐색 트리(BST) | 없음(편향 시 | 평균 |
| AVL 트리 | 좌우 깊이 차 | |
| 레드-블랙 트리 | 깊이 | |
| 트라이 | — (문자열 기반) | 검색 |