[참고 사이트]
https://yeongjaekong.tistory.com/38

최대 3개의 키와 4개의 자식을 가질수 있는 차수가 3인 B-Tree
B-Tree는 이진트리에서 발전되어 모든 리프노드들이 같은 레벨을 가질 수 있도록 자동으로 균형을 맞추는 트리입니다. 정렬된 순서를 보장하고 멀티레벨 인덱싱을 통한 빠른 검색을 할 수 있기 때문에 DB에서 사용하는 자료구조 중 한 종류라고 합니다. B-Tree뿐아니라 B+Tree도 존재합니다.
B트리는 이진트리와 다르게 하나의 노드에 많은 수의 정보를 가지고 있을 수 있습니다. 최대 N개의 자식을 가질 수 있는 B트리를 N차 B트리라고 합니다.
키(key): 각 내부 노드의 키는 하위 트리를 구분하는 구분값 역할
왜 B-Tree는 모든 리프노드들이 같은 레벨이어야할까?
→ 근본적으로 트리의 깊이가 같아야 일관된 검색 속도를 보장할 수 있습니다. 이에 따라 삽입/삭제 시 균형 유지, 디스크 접근 최적화 성능이 안정적으로 유지되기 때문입니다.
→ 이러한 특징으로 B-Tree는 디스크 접근을 최소화하기 위해 설계되었고, 대용량 데이터 저장에 효율적입니다.
GIF 추가
보통 DB에서 자주 사용하는 트리입니다. 큰 양의 정렬된 데이터를 관리하는 DB특성상 B-Tree를 사용하면 데이터 검색 성능이 향상될 수 있습니다. B-Tree인덱스를 사용할 때와 사용하지 않을 때(Tree 등) 데이터 검색 시간 복잡도를 Big O 표기법을 활용하여 비교하고 설명해보겠습니다.
→ B-Tree 인덱스를 사용할 때의 데이터 검색 시간 복잡도는 O(logN)이다. 여기서 N은 DB 내의 레코드 수입니다. B-Tree 구조는 균형 이진 트리와 유사하므로 데이터를 효율적으로 검색할 수 있습니다.
반면, B-Tree 인덱스를 사용하지 않을 경우 선형 검색을 수행하므로 검색시간 복잡도는 O(N)이 됩니다. (모든 데이터 확인) 따라서, 데이터 양이 많을수록 B-Tree인덱스를 사용하는 것이 성능 면에서 훨씬 유리합니다.

위 B-Tree는 차수가 3(M)입니다. 한 노드당 키는 2개(M-1)까지 가능!
파란색 부분: 각 노드의 키를 나타낸다. [키(key): 각 내부 노드의 키는 하위 트리를 구분하는 구분값 역할]
빨간색 부분: 자식 노드들을 가르키는 포인터 입니다.
→ 키(key) 특징
노드안에서 항상 정렬된 값을 가진다.
이진검색 트리처럼 각 key들의 왼쪽 자식들은 항상 key보다 작은 값을 / 오른쪽 자식은 큰 값을 가집니다.
#define MAX_KEYS 3 // 3차 B-트리 예시 (최대 3개의 키)
#define MIN_KEYS 1 // 최소 키 개수 (MAX_KEYS/2 내림)
typedef struct BTreeNode {
int keys[MAX_KEYS]; // 키 배열
struct BTreeNode* children[MAX_KEYS + 1]; // 자식 포인터 배열
int keyCount; // 현재 노드에 저장된 키의 개수
bool leaf; // 리프 노드 여부
} BTreeNode;
typedef struct BTree {
BTreeNode* root; // 루트 노드 포인터
int t; // 최소 차수
} BTree;
BTreeNode* createNode(bool leaf) {
BTreeNode* newNode = (BTreeNode*)malloc(sizeof(BTreeNode));
if (newNode) {
newNode->leaf = leaf;
newNode->keyCount = 0;
for (int i = 0; i < MAX_KEYS + 1; i++) {
newNode->children[i] = NULL;
}
}
return newNode;
}
BTree* createBTree() {
BTree* tree = (BTree*)malloc(sizeof(BTree));
if (tree) {
tree->root = createNode(true);
tree->t = (MAX_KEYS + 1) / 2; // 최소 차수 설정
}
return tree;
}
루트노드에서 시작해서 하향식으로 검색을 수행합니다. 검색하고자 하는 key를 k이라고 하였을 때 검색 과정입니다.
밑의 그림은 18을 키 검색하는 과정입니다.


// 노드 내에서 주어진 키를 검색
int findKey(BTreeNode* node, int key) {
int idx = 0;
while (idx < node->keyCount && node->keys[idx] < key)
idx++;
return idx;
}
// B-트리에서 키 검색
BTreeNode* search(BTreeNode* root, int key) {
int i = findKey(root, key);
// 키를 찾았다면 현재 노드 반환
if (i < root->keyCount && root->keys[i] == key)
return root;
// 리프 노드인데 키를 찾지 못했다면 NULL 반환
if (root->leaf)
return NULL;
// 적절한 자식 노드로 재귀 호출
return search(root->children[i], key);
}
key를 삽입하기 위해서는 다음과 같은 과정이 필요합니다.
→ 요소 삽입에 적절한 리프 노드를 검색, 필요한 경우 노드를 분할
리프노드 검색은 하향식이지만 노드 분할의 과정은 상향식으로 이루어진다고 볼 수 있습니다. 다음은 삽입하고자 하는 값을 k로 하였을 때 삽입 과정입니다.
이후 두가지 케이스로 나뉘게 됩니다.
리프 노드가 가득차지 않았으면, 오름차순으로 k를 삽입합니다.
밑에 그림은 9를 삽입하는 과정입니다.

만일 리프노드에 key노드가 가득 찬 경우, 노드를 분할해야 합니다.
밑에는 13을 삽입하는 과정입니다.



// 노드 분할 함수 (노드가 가득 찼을 때 호출)
void splitChild(BTreeNode* parent, int index, BTreeNode* child) {
BTreeNode* newNode = createNode(child->leaf);
newNode->keyCount = MIN_KEYS;
// 새 노드로 키 복사 (중간 이후)
for (int j = 0; j < MIN_KEYS; j++) {
newNode->keys[j] = child->keys[j + MIN_KEYS + 1];
}
// 리프 노드가 아니면 자식 포인터도 복사
if (!child->leaf) {
for (int j = 0; j <= MIN_KEYS; j++) {
newNode->children[j] = child->children[j + MIN_KEYS + 1];
}
}
// 원래 노드의 키 개수 조정
child->keyCount = MIN_KEYS;
// 부모 노드에 공간 확보
for (int j = parent->keyCount; j > index; j--) {
parent->children[j + 1] = parent->children[j];
}
// 새 노드를 부모의 자식으로 연결
parent->children[index + 1] = newNode;
// 부모 노드에 키 이동
for (int j = parent->keyCount - 1; j >= index; j--) {
parent->keys[j + 1] = parent->keys[j];
}
// 중앙 키를 부모로 이동
parent->keys[index] = child->keys[MIN_KEYS];
parent->keyCount++;
}
// 노드에 키 삽입 (노드가 가득 차지 않았다고 가정)
void insertNonFull(BTreeNode* node, int key) {
int i = node->keyCount - 1;
if (node->leaf) {
// 리프 노드면 키를 적절한 위치에 삽입
while (i >= 0 && key < node->keys[i]) {
node->keys[i + 1] = node->keys[i];
i--;
}
node->keys[i + 1] = key;
node->keyCount++;
} else {
// 내부 노드면 적절한 자식을 찾아 재귀 호출
while (i >= 0 && key < node->keys[i])
i--;
i++;
// 자식 노드가 가득 찼는지 확인
if (node->children[i]->keyCount == MAX_KEYS) {
splitChild(node, i, node->children[i]);
if (key > node->keys[i])
i++;
}
insertNonFull(node->children[i], key);
}
}
// B-트리에 키 삽입
void insert(BTree* tree, int key) {
BTreeNode* root = tree->root;
// 루트가 가득 찼는지 확인
if (root->keyCount == MAX_KEYS) {
BTreeNode* newRoot = createNode(false);
newRoot->children[0] = root;
tree->root = newRoot;
splitChild(newRoot, 0, root);
insertNonFull(newRoot, key);
} else {
insertNonFull(root, key);
}
}
요소 삭제를 위한 조건
삭제를 위해선 몇가지 용어를 아는 것이 좋습니다.
다른 노드들에 영향 없이 k를 단순 삭제



부모노드를 루트노드로 한 부분 트리의 높이가 줄어드는 경우이기 때문에 재구조화의 과정이 일어납니다.
상황3의 2번 과정으로 이동합니다.

삭제할 key k가 있는 노드도 최소, 자식노드들도 최소의 key 개수를 가지므로, k를 삭제하면 트리의 높이가 줄어들어 재구조화가 일어나는 케이스입니다.
재구조화의 과정은 다음과 같습니다.


void removeFromNode(BTreeNode* node, int k);
void removeFromLeaf(BTreeNode* node, int idx) {
// 리프 노드에서 단순히 제거
for (int i = idx + 1; i < node->n; ++i)
node->keys[i - 1] = node->keys[i];
node->n--;
}
void removeFromNonLeaf(BTreeNode* node, int idx) {
int k = node->keys[idx];
BTreeNode* leftChild = node->children[idx];
BTreeNode* rightChild = node->children[idx + 1];
// Case 1: 왼쪽 자식에서 전임자(pred)를 가져와 대체
if (leftChild->n >= T) {
int pred = getPredecessor(leftChild);
node->keys[idx] = pred;
removeFromNode(leftChild, pred);
}
// Case 2: 오른쪽 자식에서 후임자(succ)를 가져와 대체
else if (rightChild->n >= T) {
int succ = getSuccessor(rightChild);
node->keys[idx] = succ;
removeFromNode(rightChild, succ);
}
// Case 3: 양쪽 자식 모두 최소 키 개수 → 병합 후 재귀 삭제
else {
merge(node, idx);
removeFromNode(leftChild, k);
}
}
int getPredecessor(BTreeNode* node) {
while (!node->leaf)
node = node->children[node->n];
return node->keys[node->n - 1];
}
int getSuccessor(BTreeNode* node) {
while (!node->leaf)
node = node->children[0];
return node->keys[0];
}
void merge(BTreeNode* node, int idx) {
BTreeNode* left = node->children[idx];
BTreeNode* right = node->children[idx + 1];
left->keys[T - 1] = node->keys[idx];
for (int i = 0; i < right->n; ++i)
left->keys[i + T] = right->keys[i];
if (!left->leaf) {
for (int i = 0; i <= right->n; ++i)
left->children[i + T] = right->children[i];
}
for (int i = idx + 1; i < node->n; ++i)
node->keys[i - 1] = node->keys[i];
for (int i = idx + 2; i <= node->n; ++i)
node->children[i - 1] = node->children[i];
left->n += right->n + 1;
node->n--;
free(right);
}
void removeFromNode(BTreeNode* node, int k) {
int idx = 0;
while (idx < node->n && node->keys[idx] < k)
idx++;
// Case 1: 키가 현재 노드에 존재하는 경우
if (idx < node->n && node->keys[idx] == k) {
if (node->leaf)
removeFromLeaf(node, idx);
else
removeFromNonLeaf(node, idx);
}
// Case 2: 키가 자식 노드에 존재
else {
if (node->leaf) {
printf("Key %d not found\n", k);
return;
}
bool flag = (idx == node->n);
// 재귀 들어가기 전, 최소 개수 미만일 경우 보충
if (node->children[idx]->n < T)
fill(node, idx);
if (flag && idx > node->n)
removeFromNode(node->children[idx - 1], k);
else
removeFromNode(node->children[idx], k);
}
}
너무 길고 그림을 그리는 문제를 풀때 해석하기 난해하다. 그러므로 예찬 선생님이 풀어주신 해법으로 다음 포스팅에 정리해보겠다.