본 시리즈는 프로그래밍 자료구조의 개념을 정리하고 실습을 진행해보는 시리즈입니다.
Binary Search Tree (BST)는 이진 트리의 일종으로, 데이터를 효율적으로 검색, 삽입, 삭제할 수 있는 자료구조입니다.
BST는 다음과 같은 특징을 가집니다:
이진 탐색 규칙:
시간 복잡도:
중복 데이터 처리:
재귀적 구조:
BST는 그 특성상 다양한 응용 분야에서 매우 유용하게 사용됩니다. 주요 용도를 살펴보면 다음과 같습니다:
데이터 탐색(Search):
동적 데이터 관리(Dynamic Data Management):
정렬된 데이터 출력(In-order Traversal):
Set과 Map 구조:
TreeSet
과 TreeMap
은 BST를 활용한 대표적인 예입니다.트리 기반 알고리즘:
BST는 이처럼 탐색과 데이터 관리가 빈번하게 이루어지는 다양한 응용 분야에서 사용되며, 기본적인 자료구조로 널리 활용됩니다.
Binary Search Tree(BST)는 탐색, 삽입, 삭제와 같은 주요 연산을 이진 탐색(Binary Search)의 원리를 기반으로 합니다. 이 섹션에서는 각 연산의 개념과 과정을 설명합니다.
탐색 연산은 트리에서 특정 값을 찾는 과정입니다.
null
을 반환하며 탐색이 종료됩니다.삽입 연산은 트리에서 새로운 값을 추가하는 연산입니다.
삭제 연산은 트리에서 특정 노드를 제거하는 연산으로, 다른 연산보다 복잡한 편입니다.
자식 노드가 없는 경우 (리프 노드):
33
인 리프 노드를 삭제하는 경우, 부모 노드(40
)의 왼쪽 자식 포인터를 null
로 설정해서 값이 33
인 노드를 제거합니다.90
인 노드를 삭제하는 경우, 부모 노드(75
)의 오른쪽 자식 포인터를 삭제 노드의 자식노드인 99
노드로 설정해서 값이 90
인 노드를 제거합니다.자식 노드가 두 개인 경우:
삭제 연산에서 자식 노드가 두 개 있는 경우, 후계자 또는 선행자를 찾아 삭제할 노드와 교체하는 과정이 필요합니다. 일반적으로 오른쪽 서브트리에서 후계자(successor)를 찾는 방법이 더 자주 사용되며, 이는 균형을 덜 깨뜨리는 방식으로 알려져 있습니다.
후계자와 선행자 찾는 과정
- 후계자(successor): 삭제할 노드의 오른쪽 서브트리에서 가장 작은 값을 찾습니다.
- 선행자(predecessor): 삭제할 노드의 왼쪽 서브트리에서 가장 큰 값을 찾습니다.
- 다만 오른쪽 서브트리에서 찾는 경우엔, 후계자의 부모노드를 선행자(predecessor)라고 부르기도 하여 해당 노드를 함께 추적합니다.
후계자 예시 (오른쪽 서브트리에서 가장 작은 값)
50
이고, 자식 노드가 두 개 있을 때, 후계자를 찾아 교체하는 과정을 살펴보겠습니다.50
)의 오른쪽 서브트리에서 가장 작은 값을 찾습니다. 즉, 55
가 후계자가 됩니다.55
와 50
을 교체합니다.55
자리에 있던 노드(후계자)를 삭제합니다. (후계자가 자식을 가지면, 후계자의 자식과 부모를 연결하고 후계자를 삭제합니다.)선행자 예시 (왼쪽 서브트리에서 가장 큰 값)
55
로 변경되었을 때, 선행자를 찾아 교체하는 방식도 가능합니다.50
의 왼쪽 서브트리에서 가장 큰 값(40)을 찾습니다.40
과 50
을 교체한 후, 40의 자리를 제거합니다.Tip: 일반적으로는 오른쪽 서브트리의 최소값을 찾는 것이 왼쪽 서브트리의 최대값을 찾는 것보다 효율적일 때가 많습니다. 하지만 특정 상황에서는 왼쪽 서브트리의 최대값을 선택하는 것이 더 적절할 수 있습니다.
- 쉽게 말해 취향 차이인 셈입니다.
이제 탐색, 삽입, 삭제 연산을 살펴보았으니, 이러한 연산을 실제로 어떻게 구현할 수 있는지 Java와 Python으로 구현하는 단계로 넘어가 보겠습니다.
Java에서는 각 노드를 클래스로 정의하고, BST의 탐색, 삽입, 삭제 연산을 메서드로 구현합니다.
각 노드는 데이터와 왼쪽, 오른쪽 자식에 대한 참조를 가지고 있습니다.
class Node {
int data;
Node left, right;
public Node(int item) {
data = item;
left = right = null; // 노드 생성 시 자식 노드는 null로 초기화
}
}
BST 클래스에서는 루트 노드를 관리하며, 탐색, 삽입, 삭제 메서드를 정의합니다.
class BinarySearchTree {
Node root;
BinarySearchTree() {
root = null; // 트리의 루트 노드는 처음에 null
}
// 탐색 연산
public Node search(Node root, int key) {
if (root == null || root.data == key)
return root; // key를 찾으면 해당 노드를 반환, 아니면 null
if (key < root.data)
return search(root.left, key);
return search(root.right, key);
}
// 삽입 연산
public Node insert(Node root, int key) {
if (root == null) {
root = new Node(key); // 비어있는 자리에 노드 삽입
return root;
}
if (key < root.data)
root.left = insert(root.left, key);
else if (key > root.data)
root.right = insert(root.right, key);
return root;
}
// 삭제 연산
public Node delete(Node root, int key) {
if (root == null) return root; // 노드를 찾지 못하면 그대로 반환
if (key < root.data)
root.left = delete(root.left, key);
else if (key > root.data)
root.right = delete(root.right, key);
else {
// 자식이 하나도 없는 경우
if (root.left == null) return root.right;
if (root.right == null) return root.left;
// 자식이 두 명인 경우: 후계자(successor)를 찾음 (오른쪽 서브트리에서 최소값)
root.data = minValue(root.right);
// 후계자 삭제
root.right = delete(root.right, root.data);
}
return root;
}
// 오른쪽 서브트리에서 가장 작은 값을 찾는 함수 (후계자 찾기)
int minValue(Node root) {
int minv = root.data;
while (root.left != null) {
minv = root.left.data; // 왼쪽으로 계속 이동해 가장 작은 값을 찾음
root = root.left;
}
return minv;
}
// 중위 순회 (In-order Traversal) 출력
public void inOrder(Node root) {
if (root != null) {
inOrder(root.left);
System.out.print(root.data + " ");
inOrder(root.right);
}
}
}
다음은 BST 트리를 생성하고, 탐색, 삽입, 삭제 연산을 테스트하는 코드입니다.
public class Main {
public static void main(String[] args) {
BinarySearchTree bst = new BinarySearchTree();
// 삽입 연산
bst.root = bst.insert(bst.root, 50);
bst.insert(bst.root, 30);
bst.insert(bst.root, 20);
bst.insert(bst.root, 40);
bst.insert(bst.root, 70);
bst.insert(bst.root, 60);
bst.insert(bst.root, 80);
System.out.print("In-order Traversal: ");
bst.inOrder(bst.root); // 출력: 20 30 40 50 60 70 80
// 삭제 연산
System.out.println("\n\nAfter deleting 20");
bst.root = bst.delete(bst.root, 20);
bst.inOrder(bst.root); // 출력: 30 40 50 60 70 80
System.out.println("\n\nAfter deleting 30");
bst.root = bst.delete(bst.root, 30);
bst.inOrder(bst.root); // 출력: 40 50 60 70 80
System.out.println("\n\nAfter deleting 50");
bst.root = bst.delete(bst.root, 50);
bst.inOrder(bst.root); // 출력: 40 60 70 80
}
}
Python에서도 Node와 BST 클래스를 정의하고, 탐색, 삽입, 삭제 메서드를 구현할 수 있습니다.
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
class BinarySearchTree:
def __init__(self):
self.root = None # 루트 노드 초기화
# 탐색 연산
def search(self, node, key):
if node is None or node.key == key:
return node
if key < node.key:
return self.search(node.left, key)
return self.search(node.right, key)
# 삽입 연산
def insert(self, node, key):
if node is None:
return Node(key) # 빈 자리에 새 노드를 삽입
if key < node.key:
node.left = self.insert(node.left, key)
else:
node.right = self.insert(node.right, key)
return node
# 삭제 연산
def delete(self, node, key):
if node is None:
return node
if key < node.key:
node.left = self.delete(node.left, key)
elif key > node.key:
node.right = self.delete(node.right, key)
else:
if node.left is None:
return node.right
elif node.right is None:
return node.left
# 자식이 두 명인 경우 후계자(successor)를 찾음 (오른쪽 서브트리에서 최소값)
temp = self.minValueNode(node.right)
node.key = temp.key
node.right = self.delete(node.right, temp.key)
return node
# 오른쪽 서브트리에서 가장 작은 값을 찾는 함수 (후계자 찾기)
def minValueNode(self, node):
current = node
while current.left is not None:
current = current.left # 왼쪽으로 계속 이동하여 최소값을 찾음
return current
# 중위 순회 (In-order Traversal) 출력
def inOrder(self, node):
if node:
self.inOrder(node.left)
print(node.key, end=" ")
self.inOrder(node.right)
다음은 BST 트리를 생성하고, 탐색, 삽입, 삭제 연산을 테스트하는 코드입니다.
# 트리 생성 및 테스트
bst = BinarySearchTree()
bst.root = bst.insert(bst.root, 50)
bst.insert(bst.root, 30)
bst.insert(bst.root, 20)
bst.insert(bst.root, 40)
bst.insert(bst.root, 70)
bst.insert(bst.root, 60)
bst.insert(bst.root, 80)
print("In-order Traversal:", end=" ")
bst.inOrder(bst.root) # 출력: 20 30 40 50 60 70 80
# 삭제 연산
print("\n\nAfter deleting 20")
bst.root = bst.delete(bst.root, 20)
bst.inOrder(bst.root) # 출력: 30 40 50 60 70 80
print("\n\nAfter deleting 30")
bst.root = bst.delete(bst.root, 30)
bst.inOrder(bst.root) # 출력: 40 50 60 70 80
print("\n\nAfter deleting 50")
bst.root = bst.delete(bst.root, 50)
bst.inOrder(bst.root) # 출력: 40 60 70 80
Binary Search Tree (BST)는 탐색, 삽입, 삭제가 O(log n)의 성능을 제공하는 매우 효율적인 자료구조입니다.
BST는 이진 탐색 규칙에 의해 노드가 삽입되지만, 데이터의 삽입 순서에 따라 비균형 트리가 만들어질 수 있습니다.
편향 트리의 예시
이렇게 비균형 상태가 되면, BST의 성능은 선형 탐색(Linked List)과 동일해져 O(n)의 시간 복잡도를 갖게 됩니다.
비균형 트리에서는 특정 연산을 수행할 때, 한쪽 방향으로 깊이 탐색을 해야 하는 경우가 생깁니다. 이러한 구조는 탐색, 삽입, 삭제 연산 모두에서 성능 저하를 유발할 수 있습니다. 특히 다음과 같은 상황에서 문제가 발생합니다.
따라서, BST의 장점을 제대로 활용하려면 트리가 균형 상태를 유지해야 합니다.
비균형 트리의 문제를 해결하기 위해, 스스로 균형을 유지하는 트리가 필요합니다. 이러한 트리를 균형 이진 탐색 트리(Self-balancing BST)라고 부릅니다.
균형 이진 탐색 트리의 대표적인 두 가지 종류로 AVL 트리와 Red-Black 트리가 있습니다.
1. AVL 트리
2. Red-Black 트리
TreeMap
과 TreeSet
자료구조가 Red-Black 트리로 구현되어 있습니다.다음 포스팅부터는 BST의 균형을 유지하는 AVL 트리와 Red-Black 트리를 각각 다루어 보겠습니다.
이번 포스팅에서는 이진 탐색 트리(BST)의 개념과 주요 연산인 탐색, 삽입, 삭제를 살펴보았습니다.
이러한 한계를 해결하기 위해, 균형을 유지하는 트리인 AVL 트리와 Red-Black 트리와 같은 균형 이진 탐색 트리(Self-balancing BST)가 필요하게 됩니다.
다음 포스팅에서 균형 이진 탐색 트리의 대표적인 두 가지 유형 중 AVL 트리를 먼저 다루고 이후 포스팅에서 Red-Black 트리에 대해 다루어 보겠습니다.