서비스
교육 철학

LV18 레드-블랙 트리 구현하기 (Implement Red-Black Tree)

레드-블랙 트리 소개

레드-블랙 트리(Red-Black Tree)는 자가 균형 이진 탐색 트리(BST) 로, 트리의 높이가 O(log N) 을 넘지 않도록 제한됩니다. 덕분에 일반적인 이진 탐색 트리가 최악의 경우 O(N)까지 느려질 수 있는 것과 달리, 탐색, 삽입, 삭제를 모두 O(log N) 시간에 수행할 수 있습니다.
•
각 노드는 추가 속성으로 색(Color) 을 가지며, 값은 빨강(Red) 또는 검정(Black) 입니다.
•
이 색은 삽입과 삭제 과정에서 균형을 유지하기 위한 규칙을 적용하는 데 사용되어, 데이터 조회와 수정이 효율적으로 이루어지도록 합니다.

왜 C++ STL은 레드-블랙을 선택했나?

map/set의 실제 사용 패턴: ✓ 삽입: 매우 빈번 ✓ 삭제: 빈번 ✓ 탐색: 빈번 (but O(log n)이면 충분) AVL을 선택했다면: ✓ 탐색 10% 빠름 ✗ 삽입/삭제 5배 느림 ← 치명적! ✗ 실무 전체 성능 저하 레드-블랙을 선택: ✓ 삽입/삭제 5배 빠름 ← 결정적 장점! ✓ 실무 전반적 성능 우수 △ 탐색 약간 느림 (실용적으로 무시 가능)
Plain Text
복사

레드-블랙 트리의 성질(규칙)

레드-블랙 트리는 다음 성질을 만족합니다.
1.
노드 색: 각 노드는 빨강 또는 검정이다.
2.
루트 성질: 루트는 항상 검정이다.
3.
빨강 노드 성질: 빨강 노드는 빨강 자식을 가질 수 없다. (빨강이 연속으로 등장할 수 없다.)
4.
검정 높이 성질: 어떤 노드에서 그 노드의 모든 리프(NIL)까지 가는 모든 경로에는 같은 개수의 검정 노드가 존재한다.
5.
리프 성질: 모든 리프(NIL 노드)는 검정이다.
이 성질들(특히 빨강 연속 금지와 검정 높이 동일) 덕분에 루트에서 임의의 리프까지 가는 경로에서 가장 긴 경로가 가장 짧은 경로의 2배를 넘지 않게 되어, 트리의 균형과 성능이 유지됩니다.
invalid_red_black_tree_2-1.webp

균형(Balancing)

균형을 직관적으로 이해하는 간단한 예로, 레드-블랙 트리에서는 노드 3개가 일렬로 이어진 체인 형태가 성질을 만족하면서 유지되기 어렵습니다. 가능한 색 조합을 직접 대입해보면, 어떤 방식이든 레드-블랙 트리의 규칙을 위반하게 되는 경우를 확인할 수 있습니다.
2.webp

유도되는 성질(Inferred properties)

•
루트에서 리프(NIL 포함)까지의 경로에 있는 검정 노드의 개수(Black Height) 를 blackHeight 라고 하며, 트리 높이를 h 라고 할 때 보통 blackHeight >= h/2 관계가 성립합니다.
•
노드 개수가 N인 레드-블랙 트리의 높이 h는 h <= 2log(N+1) 을 만족합니다.
•
어떤 노드의 검정 깊이(Black depth) 는 루트에서 해당 노드까지 내려오면서 만나는 검정 노드의 개수를 의미합니다.

레드-블랙 트리의 기본 연산

1. 삽입(Insertion)
새 노드를 삽입할 때는 보통 다음 2단계를 거칩니다.
•
1단계: BST 규칙으로 먼저 삽입
•
2단계: 삽입으로 레드-블랙 성질이 깨질 수 있으므로 위반을 수정(Fix-Up)
상황을 나눠 보면 다음과 같습니다.
•
새 노드의 부모가 검정이면, 성질이 깨지지 않는 경우가 많습니다.
•
새 노드의 부모가 빨강이면, “빨강이 연속으로 올 수 없다”는 성질을 위반할 수 있어서 수정이 필요합니다.
일반적으로 새 노드는 빨강으로 삽입한 뒤, 아래와 같은 케이스로 처리합니다.
•
Case 1: 삼촌(uncle)이 빨강
◦
부모와 삼촌을 검정으로 바꾸고, 조부모를 빨강으로 바꾼 뒤
◦
조부모로 올라가며 같은 과정을 반복합니다.
•
Case 2: 삼촌이 검정
◦
노드의 위치에 따라 부모 또는 조부모를 기준으로 회전을 수행하고,
◦
필요하면 색도 함께 바꿔서 성질을 복구합니다.
2. 탐색(Searching)
탐색은 일반 BST와 완전히 동일합니다.
1.
루트에서 시작합니다.
2.
찾는 값이 현재 노드 값과 같으면 성공입니다.
3.
찾는 값이 더 작으면 왼쪽으로, 더 크면 오른쪽으로 이동합니다.
4.
값을 찾거나 NIL에 도달할 때까지 반복합니다.
3. 삭제(Deletion)
삭제 역시 기본 흐름은 다음과 같습니다.
1.
BST 규칙으로 노드를 삭제합니다.
2.
삭제 과정에서 (특히) 검정 노드가 제거되면, "double black" 상황이 생길 수 있어 성질 복구가 필요합니다.
검정 노드를 삭제한 뒤에는, 형제(sibling) 노드의 색에 따라 보정 방법이 달라집니다.
•
형제가 빨강이면: 부모를 기준으로 회전하고 색을 재배치합니다.
•
형제가 검정이면:
◦
형제의 자식이 모두 검정이면 형제를 재색칠하고 문제를 위로 전파합니다.
◦
형제의 자식 중 빨강이 있으면, (가까운/먼 자식 위치에 따라) 회전과 재색칠을 조합해 해결합니다.
4. 회전(Rotation)
회전은 레드-블랙 트리의 균형을 유지하는 핵심 연산입니다. BST의 정렬 성질을 유지하면서 트리 구조만 바꾸어, 루트에서 리프까지의 최장 경로가 최단 경로의 2배를 넘지 않도록 만듭니다.
회전은 크게 두 가지입니다.
i. 좌회전(Left Rotation)
노드 x를 기준으로 좌회전을 하면, x의 오른쪽 자식 y가 x 자리로 올라가고 x는 y의 왼쪽 자식이 됩니다. 큰 흐름은 다음과 같습니다.
1.
y의 왼쪽 서브트리를 떼어 x의 오른쪽 자식으로 옮깁니다.
2.
y를 x가 있던 자리(부모 연결)에 붙입니다.
3.
x를 y의 왼쪽 자식으로 붙이고, 부모 포인터를 정리합니다.
420851487
좌회전 의사코드(Pseudo-Code)
void leftRotate(Node* x) { // 올릴 노드(오른쪽 자식) Node* y = x.right; // 1) y의 왼쪽 서브트리를 x의 오른쪽으로 넘긴다 x.right = y.left; if (y.left != NIL) { y.left.parent = x; } // 2) y를 x가 있던 위치에 연결한다 y.parent = x.parent; if (x.parent == NIL) { root = y; // x가 루트였던 경우 } else if (x == x.parent.left) { x.parent.left = y; } else { x.parent.right = y; } // 3) x를 y의 왼쪽 자식으로 둔다 y.left = x; x.parent = y; }
C++
복사
ii. 우회전(Right Rotation)
우회전은 좌회전의 반대입니다. 노드 x를 기준으로 우회전을 하면, x의 왼쪽 자식 y가 x 자리로 올라가고 x는 y의 오른쪽 자식이 됩니다.
1.
y의 오른쪽 서브트리를 떼어 x의 왼쪽 자식으로 옮깁니다.
2.
y를 x가 있던 자리(부모 연결)에 붙입니다.
3.
x를 y의 오른쪽 자식으로 붙이고, 부모 포인터를 정리합니다.
우회전 의사코드(Pseudo-Code)
void rightRotate(Node* x) { // 올릴 노드(왼쪽 자식) Node* y = x.left; // 1) y의 오른쪽 서브트리를 x의 왼쪽으로 넘긴다 x.left = y.right; if (y.right != NIL) { y.right.parent = x; } // 2) y를 x가 있던 위치에 연결한다 y.parent = x.parent; if (x.parent == nullptr) { root = y; // x가 루트였던 경우 } else if (x == x.parent.right) { x.parent.right = y; } else { x.parent.left = y; } // 3) x를 y의 오른쪽 자식으로 둔다 y.right = x; x.parent = y; }
C++
복사
아래에는 위에서 언급한 모든 연산을 포함한 레드-블랙 트리의 상세 구현 예시가 이어집니다.

삽입 (Insert)

노드를 삽입하면 처음에는 빨강(Red) 으로 색칠합니다.
이때 규칙을 어길 수 있어서 리컬러링(Recoloring) 과 리스트럭처링(Restructuring, 회전) 이 필요합니다.

1) 리컬러링(Recoloring)

•
삽입한 노드의 부모와 삼촌이 모두 빨강인 경우 → 부모와 삼촌을 검정, 조부모를 빨강으로 바꿉니다.
•
만약 루트까지 올라가서 충돌이 발생하면 다시 같은 규칙을 적용합니다.

2) 리스트럭처링(Restructuring, 회전)

•
삽입한 노드의 부모는 빨강인데, 삼촌은 검정이거나 없는 경우.
•
이때 조부모, 부모, 새 노드의 위치 관계에 따라 회전(Rotation) 을 합니다.

1️⃣ 삽입 시 기본 규칙

•
새로운 노드는 항상 빨강(Red) 으로 삽입한다.
•
이때 부모가 빨강이면 Double Red 문제가 발생할 수 있다.
•
Double Red 해결에는 Restructuring(회전) 과 Recoloring(색변경) 두 가지 전략이 있다.

2️⃣ Double Red 해결 전략

(A) 삼촌(U)이 검정일 때 → Restructuring

삼촌이 검정일 때 Restructuring(회전)을 사용하는 이유:
삼촌이 검정이면 Recoloring만으로는 검정 노드 개수 균형을 맞출 수 없기 때문입니다.
구체적으로:
•
검정 높이 규칙 위반: 부모(P)와 삼촌(U) 중 하나만 검정이면, 단순히 색을 바꾸는 것만으로는 "루트에서 모든 리프까지의 경로에서 검정 노드 개수가 동일해야 한다"는 규칙을 유지할 수 없습니다.
•
구조 변경 필요: 따라서 노드들의 위치 관계 자체를 바꿔야 하며, 이때 회전(Rotation)을 사용합니다.
•
해결 방법: 새 노드(N), 부모(P), 조상(G)을 값 기준으로 오름차순 정렬하여 중간값을 새로운 부모로 두고, 새로운 부모는 검정, 자식 둘은 빨강으로 색을 바꿉니다.
반대로 삼촌이 빨강일 때는 부모와 삼촌을 모두 검정으로, 조상을 빨강으로 바꿔도 검정 노드 개수 균형이 유지되므로 간단한 Recoloring으로 해결할 수 있습니다.
구조를 바꿔서 해결한다.
새 노드(N), 부모(P), 조상(G)을 값 기준으로 오름차순 정렬한다.
중간값을 새로운 부모로 두고, 나머지 둘을 자식으로 만든다.
새로운 부모는 검정, 자식 둘은 빨강으로 색을 바꾼다.
➡️ 즉, 회전 + 색상 교체로 Double Red를 해결.

(B) 삼촌(U)이 빨강일 때 → Recoloring

삼촌이 빨강일 때 Recoloring을 하는 이유는 구조 변경 없이 색상만으로 문제를 해결할 수 있기 때문입니다.
구체적으로:
•
부모(P)와 삼촌(U)이 모두 빨강이면, 둘 다 검정으로 바꾸고 조상(G)을 빨강으로 만들어도 검정 노드 개수 균형이 유지됩니다.
•
회전(Restructuring)은 트리 구조를 바꾸는 복잡한 작업인 반면, Recoloring은 단순히 색만 바꾸면 되므로 더 효율적입니다.
•
반대로 삼촌이 검정이면 색상만으로는 균형을 맞출 수 없어서 Restructuring(회전)이 필요합니다.
핵심 원리:
레드-블랙 트리는 "루트에서 모든 리프까지 경로의 검정 노드 개수가 동일해야 한다"는 규칙이 있습니다. 삼촌이 빨강일 때는 부모와 삼촌을 검정으로, 조상을 빨강으로 바꿔도 이 규칙이 깨지지 않기 때문에 간단한 Recoloring으로 해결할 수 있는 것입니다.
색을 바꿔서 해결한다.
부모(P)와 삼촌(U)을 검정으로, 조상(G)을 빨강으로 바꾼다.
만약에 G가 루트라면 반드시 검정으로 다시 바꿔준다.
Recoloring은 간단해 보이지만 문제는 조상 노드(G)가 루트 노드가 아니면서, 조상 노드(G)가 또다시 Double Red 문제가 발생하는 경우이다.
조상(G)이 빨강이 되어 또 Double Red가 생기면, 같은 방식(Restructuring/Recoloring)을 반복한다.
해결 과정
1.
현재 상태
•
G=5가 빨강, P=9도 빨강 → Double Red 발생.
•
삼촌 U=14는 빨강.
2.
Recoloring 수행
•
P(9)와 U(14)를 검정으로 변경.
•
G(11)을 빨강으로 변경.
3.
문제 확인
•
이제 루트인 11이 빨강이 되었는데, 규칙상 루트는 반드시 검정이어야 한다.
4.
최종 조정
•
루트 11을 검정으로 바꾸면 모든 규칙이 만족된다.
•
Recoloring은 단순히 색을 바꾸는 작업이지만, 조상(G)이 빨강이 되면서 새로운 Double Red를 만들 수 있다.
•
이 경우 Recoloring을 반복하거나, 상황에 따라 Restructuring(회전) 으로 넘어가야 한다.
•
루트 노드는 무조건 검정이 되도록 마지막에 조정한다.
#include <iostream> #include <initializer_list> #include <string> using namespace std; // ===================================================== // 1. 기본 자료구조 // ===================================================== enum class Color { Red, Black }; enum class RotationCase { LL, LR, RR, RL }; struct Node { int key; Color color; Node* left; Node* right; Node* parent; }; class RedBlackTree { private: Node* root; Node* nil; public: RedBlackTree() { nil = new Node{ 0, Color::Black, nullptr, nullptr, nullptr }; nil->left = nil->right = nil->parent = nil; root = nil; } ~RedBlackTree() { Destroy(root); delete nil; } RedBlackTree(const RedBlackTree&) = delete; RedBlackTree& operator=(const RedBlackTree&) = delete; // ================================================= // 2. 삽입 (재귀 방식) // ================================================= void Insert(int key) { Node* inserted = nullptr; // STEP 1. BST 방식으로 삽입 root = InsertBST(root, nil, key, inserted); // 중복 키는 복구할 필요 없음 if (!inserted) return; // STEP 2. Double Red 복구 InsertFixup(inserted); } private: // 일반 BST 재귀 삽입 Node* InsertBST(Node* current, Node* parent, int key, Node*& inserted) { // 삽입 위치 발견 if (current == nil) { inserted = new Node{ key, Color::Red, nil, nil, parent }; return inserted; } // 왼쪽 또는 오른쪽으로 재귀 탐색 if (key < current->key) { current->left = InsertBST( current->left, current, key, inserted ); } else if (key > current->key) { current->right = InsertBST( current->right, current, key, inserted ); } return current; } // ================================================= // 3. Double Red 복구 (재귀 방식) // ================================================= void InsertFixup(Node* node) { // CASE 0. 루트는 항상 BLACK if (node == root) { node->color = Color::Black; return; } // 부모가 BLACK이면 정상 if (!IsRed(node->parent)) return; Node* parent = node->parent; Node* grand = parent->parent; Node* uncle = GetUncle(node); // CASE 1. 삼촌 RED -> 색상 변경 if (IsRed(uncle)) { Recolor(parent, uncle, grand); // 조부모에서 Double Red 재검사 InsertFixup(grand); return; } // CASE 2. 삼촌 BLACK -> 회전 switch (GetRotationCase(node)) { case RotationCase::LL: RotateLL(grand); break; case RotationCase::LR: RotateLR(parent, grand); break; case RotationCase::RR: RotateRR(grand); break; case RotationCase::RL: RotateRL(parent, grand); break; } } // ================================================= // 4. 삼촌 RED -> 색상 변경 // ================================================= void Recolor(Node* parent, Node* uncle, Node* grand) { parent->color = Color::Black; uncle->color = Color::Black; grand->color = Color::Red; } // ================================================= // 5. 삼촌 BLACK -> 회전 유형 판단 // ================================================= RotationCase GetRotationCase(Node* node) const { bool parentLeft = IsLeft(node->parent); bool nodeLeft = IsLeft(node); if (parentLeft) return nodeLeft ? RotationCase::LL : RotationCase::LR; return nodeLeft ? RotationCase::RL : RotationCase::RR; } // ================================================= // 6. LL / LR / RR / RL 회전 // ================================================= // LL: 조부모 오른쪽 회전 void RotateLL(Node* grand) { Node* parent = grand->left; parent->color = Color::Black; grand->color = Color::Red; RotateRight(grand); } // LR: 부모 왼쪽 회전 -> LL 처리 void RotateLR(Node* parent, Node* grand) { RotateLeft(parent); RotateLL(grand); } // RR: 조부모 왼쪽 회전 void RotateRR(Node* grand) { Node* parent = grand->right; parent->color = Color::Black; grand->color = Color::Red; RotateLeft(grand); } // RL: 부모 오른쪽 회전 -> RR 처리 void RotateRL(Node* parent, Node* grand) { RotateRight(parent); RotateRR(grand); } // ================================================= // 7. 실제 포인터 회전 // ================================================= // 왼쪽 회전: 오른쪽 자식이 부모로 올라옴 void RotateLeft(Node* L1) { Node* L2 = L1->right; // L2의 왼쪽 서브트리를 L1에 연결 L1->right = L2->left; if (L2->left != nil) L2->left->parent = L1; // 상위 부모와 L2 연결 ReplaceParent(L1, L2); // L1을 L2의 왼쪽 자식으로 L2->left = L1; L1->parent = L2; } // 오른쪽 회전: 왼쪽 자식이 부모로 올라옴 void RotateRight(Node* L1) { Node* L2 = L1->left; // L2의 오른쪽 서브트리를 L1에 연결 L1->left = L2->right; if (L2->right != nil) L2->right->parent = L1; // 상위 부모와 L2 연결 ReplaceParent(L1, L2); // L1을 L2의 오른쪽 자식으로 L2->right = L1; L1->parent = L2; } // 회전 후 상위 부모 연결 변경 void ReplaceParent(Node* oldNode, Node* newNode) { Node* parent = oldNode->parent; newNode->parent = parent; if (parent == nil) root = newNode; else if (IsLeft(oldNode)) parent->left = newNode; else parent->right = newNode; } // ================================================= // 8. 보조 함수 // ================================================= bool IsRed(Node* node) const { return node->color == Color::Red; } bool IsLeft(Node* node) const { return node == node->parent->left; } Node* GetUncle(Node* node) const { Node* parent = node->parent; Node* grand = parent->parent; return IsLeft(parent) ? grand->right : grand->left; } void Destroy(Node* node) { if (node == nil) return; Destroy(node->left); Destroy(node->right); delete node; } // ================================================= // 9. 학습용 트리 출력 (재귀) // ================================================= void PrintNode(Node* node, int depth) const { if (node == nil) return; PrintNode(node->right, depth + 1); cout << string(depth * 4, ' ') << node->key << (IsRed(node) ? "(R)" : "(B)") << '\n'; PrintNode(node->left, depth + 1); } public: void Print() const { PrintNode(root, 0); cout << "--------------------\n"; } }; // ===================================================== // 10. 실습 // ===================================================== int main() { const initializer_list<int> cases[] = { { 20, 10, 30, 5 }, // 삼촌 RED { 30, 20, 10 }, // LL { 30, 10, 20 }, // LR { 10, 20, 30 }, // RR { 10, 30, 20 } // RL }; const char* names[] = { "Uncle RED", "LL Rotation", "LR Rotation", "RR Rotation", "RL Rotation" }; for (int i = 0; i < 5; ++i) { cout << "\n=== " << names[i] << " ===\n"; RedBlackTree tree; for (int key : cases[i]) { cout << "Insert: " << key << '\n'; tree.Insert(key); tree.Print(); } } return 0; }
C++
복사