📖 '누구나 자료구조와 알고리즘'책을 공부한 내용을 담고 있습니다.
데이터를 특정 순서로 정리할 때, 가장 효율적인 알고리즘은 무엇일까?
정렬 알고리즘은 아무리 빨라도 O(logN)이다. 정렬된 배열에선 삽입과 삭제할 때 O(N)이 걸린다.
해시 테이블은 검색, 삽입, 삭제가 O(1)이지만, 순서를 유지하지 못한다.
순서를 유지하면서 빠른 검색, 삽입, 삭제가 가능한 자료구조하면 어떻게 해야할까?
트리(tree)는 노드 기반 자료구조이다. 또 다른 노드 기반 자료구조인 연결 리스트는 노드와 다른 한 노드를 연결하는 링크를 포함한다. 트리는 각 노드에 여러 노드의 링크를 포함할 수 있다.
이미지 출처: http://www.ktword.co.kr/test/view/view.php?no=4726
이진 트리는 각 노드에 자식이 0개나 1개,2개다.
이진 탐색 트리는 다음 규칙이 추가된 트리다.
class TreeNode {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null;
}
insert(value) {
const newNode = new TreeNode(value);
if (!this.root) {
this.root = newNode;
return;
}
let currentNode = this.root;
while (currentNode) {
if (value < currentNode.value) {
if (!currentNode.left) {
currentNode.left = newNode;
return;
}
currentNode = currentNode.left;
} else {
if (!currentNode.right) {
currentNode.right = newNode;
return;
}
currentNode = currentNode.right;
}
}
}
}
찾는 값이 61일 때, 루트 50보다 크기 때문에 오른쪽 하위 노드에서 값을 찾는다.
오른쪽 노드 75보다 작기 때문에 왼쪽 하위 노드 56로 내려가고, 56보다 크기 때문에 오른쪽 하위 노드에 61를 찾을 수 있다. 61를 찾는 데 총 4단계가 걸렸다.
이진 탐색 트리 검색 과정을 보면 각 단계마다 검색할 대상이 남은 노드의 반으로 줄어든다. 따라서 이진 탐색 트리 검색은 O(logN)이다.
이진 트리에 노드가 N개면 레벨이 약 logN개이다.
트리의 모든 노드가 채우져 있다고 가정하자. 하나의 레벨이 추가되면 트리 노드 개수가 대략 두 배로 늘어난다. 레벨 4인 노드에 레벨을 하나 추가하면, 노드 개수가 16개가 추가 되면서 트리 크기가 대략 2배로 늘어난다. log31를 하면 약 5가 된다. 따라서 노드 N개인 트리에서 모든 자리마다 노드를 두려면 logN 레벨이 필요하다.
이진 탐색 트리 검색은 선택한 각 수가 가능한 남은 값 중 반을 제거해서 정렬된 배열의 이진 검색과 효율성이 같다. 그래서 O(logN)의 시간 복잡도를 갖는다.
/**
기저조건: 노드가 없으면 None를, 찾고 있던 노드면 노드를 반환
elseif 찾고 있는 값이 현재 노드보다 작으면 왼쪽 노드만 재귀적으로 호출
현재 보다 크면 오른쪽 노드만 재귀적으로 호출
*/
class BinarySearchTree {
// ... insert 메서드는 생략
search(value) {
let currentNode = this.root;
while (currentNode && currentNode.value !== value) {
if (value < currentNode.value) {
currentNode = currentNode.left;
} else {
currentNode = currentNode.right;
}
}
return currentNode;
}
}
45를 삽입하려고 할때, 검색 4단계와 삽입 1단계가 걸린다. 즉 logN + 1이므로 O(logN)이다. 정렬된 배열에서의 삽입은 O(N)이 걸린다. 그래서 이진 탐색 트리는 색과 삽입 모두 O(logN)이므로 매우 효율적이다.
/**
현재 노드와 값을 비교해서 값이 작으면 왼쪽, 크면 오른쪽에 삽입해야 한다.
만약 왼쪽 자식이 없으면 그곳에 값이 들어간다. 이 코드가 기저 조건이다.
*/
class BinarySearchTree {
// ... insert 메서드는 생략
insert(value) {
const newNode = new TreeNode(value);
if (!this.root) {
this.root = newNode;
return;
}
let currentNode = this.root;
while (currentNode) {
if (value < currentNode.value) {
if (!currentNode.left) {
currentNode.left = newNode;
return;
}
currentNode = currentNode.left;
} else {
if (!currentNode.right) {
currentNode.right = newNode;
return;
}
currentNode = currentNode.right;
}
}
}
}
무작위로 정렬된 데이터로 트리를 생성해야 균형 잡힌 트리가 생성된다.
정렬된 데이터는 불균형이 심하고 효율적이지 않다. 정렬된 데이터는 완벽히 선형이라 O(N) 걸린다. 균형 트리일 때만 검색이 대략 O(logN) 걸린다.
이진 탐색 트리에서 삭제 알고리즘은 다음과 같은 규칙을 따른다.
class BinarySearchTree {
// ... insert 메서드는 생략
delete(value) {
this.root = this.deleteRec(this.root, value);
}
deleteRec(root, value) {
if (!root) {
return root;
}
if (value < root.value) {
root.left = this.deleteRec(root.left, value);
} else if (value > root.value) {
root.right = this.deleteRec(root.right, value);
} else {
// 노드를 찾음
if (!root.left) {
return root.right;
} else if (!root.right) {
return root.left;
}
root.value = this.minValue(root.right);
root.right = this.deleteRec(root.right, root.value);
}
return root;
}
minValue(node) {
let current = node;
while (current.left) {
current = current.left;
}
return current.value;
}
}
삭제된 노드 자리에 후속자 노드로 대체한다. 삭제된 노드 다음으로 큰 수가 후속자 노드다.
컴퓨터는 후속자 노드를 어떻게 찾을까?
삭제할 노드의 오른쪽 하위 트리에서 가장 작은 값을 가진 노드가 후속자 노드가 된다.
O(logN)이다. 삭제에는 검색 한 번과 연결이 끊긴 자식을 처리하느 단계가 추가로 필요하기 때문이다.
이진 탐색 트리는 데이터 삽입 삭제가 정렬된 배열보다 훨씬 빠르기 때문에 데이터를 자주 수정하는 경우 효율적이다.
모든 노드를 방문하는 과정을 자료 구조 순회라 부른다.
각 제목을 알파벳 순으로 출력하는 것은 중위 순회이다.
이진 탐색 트리는 데이터 구조에서 검색, 삽입, 삭제를 효율적으로 수행할 수 있는 구조로, 특히 데이터를 자주 수정해야 하는 경우에 매우 유리하다. 이진 탐색 트리의 주요 특성과 동작을 정리하면 다음과 같다.
이진 탐색 트리의 특성
시간 복잡도
삽입과 삭제
트리 순회
이진 탐색 트리는 정렬된 배열보다 삽입과 삭제가 빠르고, 해시 테이블보다 순서를 유지할 수 있다는 장점이 있다. 다만, 균형 잡힌 트리를 유지하기 위해서는 균형 트리 알고리즘(예: AVL 트리, 레드-블랙 트리 등)을 적용하는 것이 좋다.
// 이진 탐색 트리 구현 예시
class TreeNode {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null;
}
insert(value) {
const newNode = new TreeNode(value);
if (!this.root) {
this.root = newNode;
return;
}
let currentNode = this.root;
while (currentNode) {
if (value < currentNode.value) {
if (!currentNode.left) {
currentNode.left = newNode;
return;
}
currentNode = currentNode.left;
} else {
if (!currentNode.right) {
currentNode.right = newNode;
return;
}
currentNode = currentNode.right;
}
}
}
search(value) {
let currentNode = this.root;
while (currentNode && currentNode.value !== value) {
if (value < currentNode.value) {
currentNode = currentNode.left;
} else {
currentNode = currentNode.right;
}
}
return currentNode;
}
delete(value) {
this.root = this.deleteRec(this.root, value);
}
deleteRec(root, value) {
if (!root) {
return root;
}
if (value < root.value) {
root.left = this.deleteRec(root.left, value);
} else if (value > root.value) {
root.right = this.deleteRec(root.right, value);
} else {
if (!root.left) {
return root.right;
} else if (!root.right) {
return root.left;
}
root.value = this.minValue(root.right);
root.right = this.deleteRec(root.right, root.value);
}
return root;
}
minValue(node) {
let current = node;
while (current.left) {
current = current.left;
}
return current.value;
}
inorderTraversal(node = this.root, result = []) {
if (node) {
this.inorderTraversal(node.left, result);
result.push(node.value);
this.inorderTraversal(node.right, result);
}
return result;
}
}
이 코드는 이진 탐색 트리의 기본적인 기능을 포함하고 있으며, 이를 통해 효율적인 데이터 검색, 삽입, 삭제, 그리고 트리 순회를 수행할 수 있다.