BST: 비교해서 방향을 고르고, 삭제 후 연결을 다시 잇습니다
작은 숫자 트리로 탐색 방향과 삭제의 세 경우를 배웁니다. 남은 자식과 후계자를 어디에 연결하는지 먼저 확인하고 전체 구현을 읽습니다.
먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

숫자를 비교하며 갈림길을 고릅니다
이진 탐색 트리는 숫자를 찾을 때 매 갈림길에서 왼쪽 또는 오른쪽 하나를 고릅니다. 현재 숫자보다 작으면 왼쪽, 크면 오른쪽으로 갑니다.
| 넣는 값 | 비교 경로 | 놓이는 자리 |
|---|---|---|
| 8 | 첫 값 | 맨 위 |
| 3 | 3 < 8 | 8의 왼쪽 |
| 10 | 10 > 8 | 8의 오른쪽 |
| 6 | 6 < 8 → 6 > 3 | 3의 오른쪽 |
| 1 | 1 < 8 → 1 < 3 | 3의 왼쪽 |
모양을 글로 쓰면 맨 위 8, 왼쪽 3, 오른쪽 10이며 3 아래에는 왼쪽 1과 오른쪽 6이 있습니다. 이 규칙 덕분에 6을 찾을 때 8의 오른쪽 가지는 확인하지 않아도 됩니다.
탐색은 같은 비교를 반복합니다
const node = { key: 8, left: { key: 3 }, right: { key: 10 } };
const target = 3;
const next = target < node.key ? node.left : node.right;
console.log(next.key);
3
현재 노드는 8이고 목표는 3입니다. 3이 더 작으므로 왼쪽 연결을 선택합니다. 비교 방향을 거꾸로 쓰면 값이 실제로 있는 반대편 가지를 버려 찾지 못합니다. 이 축소 코드는 목표가 현재 값과 다른 한 단계만 보여 줍니다. 완성 탐색 함수는 목표와 현재 키가 같으면 그 자리에서 즉시 반환하고, 다르면 이 단계를 빈 연결을 만날 때까지 반복합니다.
삭제는 자식 수에 따라 세 경우로 나눕니다
값을 지운 뒤에도 작은 값은 왼쪽, 큰 값은 오른쪽이라는 배치를 지켜야 합니다. 그래서 삭제할 노드의 자식 수를 먼저 봅니다.
| 삭제할 값 | 현재 자식 | 연결을 바꾸는 방법 |
|---|---|---|
| 1 | 없음 | 부모 3의 왼쪽을 빈칸으로 |
| 3 (1을 먼저 지운 뒤) | 오른쪽 6 하나 | 부모 8의 왼쪽을 6에 바로 연결 |
| 8 | 왼쪽과 오른쪽 둘 | 오른쪽 가지의 가장 작은 값으로 자리 교체 |
자식이 없는 1은 연결만 끊으면 됩니다. 자식 하나를 가진 3을 지울 때는 남은 6을 위로 이어 줍니다. 자식 둘인 8은 양쪽 가지를 모두 버릴 수 없으므로, 정렬 순서를 깨지 않는 대체 값을 찾습니다.
자식 하나는 남은 연결을 부모에게 넘깁니다
const parent = { key: 8, left: { key: 3, right: { key: 6 } } };
const removed = parent.left;
parent.left = removed.right;
console.log(parent.left.key);
6
지울 3을 removed로 기억한 뒤, 부모의 왼쪽 연결이 3의 남은 자식 6을 가리키게 합니다. 단순히 parent.left = null로 만들면 6까지 트리에서 사라집니다. 삭제에서 “값을 지운다”보다 “어느 연결을 어디로 다시 잇는가”가 중요한 이유입니다.
자식 둘이면 오른쪽에서 가장 작은 값을 찾습니다
const rightTree = { key: 12, left: { key: 10, left: { key: 9 } } };
let successor = rightTree;
while (successor.left != null) successor = successor.left;
console.log(successor.key);
9
오른쪽 가지의 값은 모두 삭제 대상보다 큽니다. 그중 가장 작은 값은 왼쪽 연결을 끝까지 따라가면 만납니다. 이 값을 후계자라고 합니다. != null은 왼쪽 자리가 null인 경우와 프로퍼티를 생략해 undefined인 경우를 모두 빈자리로 봅니다. 오른쪽 가지의 첫 값 12를 무조건 고르면 그 아래의 9와 10을 어떻게 다시 놓을지 더 복잡해집니다.
확인 문제: 8의 왼쪽이 3, 오른쪽이 12이고 12의 왼쪽이 10이라면 8의 후계자는?
정답은 10입니다. 오른쪽 가지로 한 번 간 뒤 왼쪽으로 끝까지 이동한 값이기 때문입니다.
같은 키가 여러 번 들어오면 개수로 보관할 수 있습니다
const item = { key: 6, count: 2 };
item.count -= 1;
console.log(item.key);
console.log(item.count);
6 1
같은 6을 별도 노드로 어느 방향에 놓을지 정하는 대신 한 노드의 count를 늘릴 수 있습니다. 하나를 삭제할 때 count가 2 이상이면 연결을 바꾸지 않고 개수만 줄입니다. 자식 둘인 노드를 후계자로 바꿀 때는 후계자의 키뿐 아니라 count도 함께 옮겨야 중복 개수가 사라지지 않습니다.
이제 기존 전체 구현을 읽는 순서
먼저 삽입과 탐색에서 작은 값은 왼쪽, 큰 값은 오른쪽으로 가는지 봅니다. 삭제에서는 count 감소가 먼저인지 확인하고, 그 다음 자식 없음, 자식 하나, 자식 둘의 세 갈래를 위 표와 맞춥니다. 마지막으로 후계자의 키와 count를 옮긴 뒤 원래 후계자 노드를 연결에서 제거하는지 읽으세요.
아래 전체 구현에는 범위 검색과 경계 검사도 들어 있습니다. 먼저 다섯 값의 작은 트리로 연결 변화를 이해한 다음 전체 코드를 읽으면 각 조건문의 목적이 훨씬 선명해집니다.
관련 코딩테스트 문제로 이어서 연습합니다
이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.
전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기
여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.
순서 정보를 저장하면 어떤 일이 쉬워지는가
일반 이진 트리는 왼쪽과 오른쪽의 값 관계를 보장하지 않아 특정 값을 찾으려면 모든 노드를 볼 수 있습니다. BST는 각 노드의 왼쪽 부분 트리에 더 작은 키, 오른쪽 부분 트리에 더 큰 키만 두어 한쪽을 제외합니다. 단순 존재 확인뿐 아니라 정렬 순회와 범위 검색이 필요한 상황에 의미가 있습니다.
정렬 배열은 이진 탐색이 빠르지만 중간 삽입·삭제에 이동이 필요합니다. 해시 테이블은 정확한 키 조회에 적합하지만 순서에 따른 범위 열거를 직접 지원하지 않습니다. BST는 연결을 바꿔 갱신하면서 순서를 유지하는 선택이며, 이 글의 비균형 BST는 높이까지 보장하지 않는다는 대가가 있습니다.
키·중복·반환값 계약
키는 JavaScript 안전 정수만 허용합니다. NaN은 작다·크다 비교가 모두 false이므로 검사 없이 넣으면 다른 키를 같은 키처럼 처리할 위험이 있습니다. 실수나 문자열 정렬이 필요하다면 모든 메서드에서 일관된 비교 규칙을 별도로 설계해야 합니다.
같은 키는 새 노드로 만들지 않고 count를 증가시킵니다. 따라서 부분 트리 간 비교는 엄격한 부등호이고 각 키는 노드 하나에만 존재합니다. delete는 해당 키 한 개만 지워 성공 여부를 반환합니다. count가 2라면 첫 삭제 후 노드는 남고 두 번째 삭제에서 연결 구조를 바꿉니다.
root와 노드는 설명을 위해 노출하지만 외부에서 key·count·연결을 바꾸지 않는 계약입니다. insert는 인스턴스를 반환하고 has는 불리언, inorder와 range는 중복 횟수만큼 키를 담은 새 배열을 반환합니다. 범위는 양 끝을 포함하며 low > high면 빈 배열입니다.
전체 코드와 실행
아래를 bst.cjs로 저장하고 Node.js 터미널에서 node ./bst.cjs로 실행합니다. CommonJS 한 파일에 전체 클래스와 범위 함수, 직접 실행 예제가 들어 있습니다. 재귀 대신 반복문을 사용해 한쪽으로 긴 트리도 호출 스택을 계속 늘리지 않습니다.
'use strict';
function checkKey(key) {
if (!Number.isSafeInteger(key)) throw new TypeError('key must be a safe integer');
}
function rangeValues(root, low, high) {
checkKey(low);
checkKey(high);
const result = [];
if (low > high) return result;
const stack = [];
let current = root;
while (current != null || stack.length > 0) {
while (current != null) {
if (current.key < low) {
current = current.right;
} else {
stack.push(current);
current = current.left;
}
}
if (stack.length === 0) break;
current = stack.pop();
if (current.key > high) break;
for (let copy = 0; copy < current.count; copy += 1) {
result.push(current.key);
}
current = current.right;
}
return result;
}
class BinarySearchTree {
constructor() {
this.root = null;
}
insert(key) {
checkKey(key);
const fresh = { key, count: 1, left: null, right: null };
if (this.root === null) {
this.root = fresh;
return this;
}
let current = this.root;
while (true) {
if (key === current.key) {
current.count += 1;
return this;
}
const side = key < current.key ? 'left' : 'right';
if (current[side] === null) {
current[side] = fresh;
return this;
}
current = current[side];
}
}
has(key) {
checkKey(key);
let current = this.root;
while (current !== null) {
if (key === current.key) return true;
current = key < current.key ? current.left : current.right;
}
return false;
}
delete(key) {
checkKey(key);
let parent = null;
let current = this.root;
while (current !== null && current.key !== key) {
parent = current;
current = key < current.key ? current.left : current.right;
}
if (current === null) return false;
if (current.count > 1) {
current.count -= 1;
return true;
}
if (current.left !== null && current.right !== null) {
let successorParent = current;
let successor = current.right;
while (successor.left !== null) {
successorParent = successor;
successor = successor.left;
}
current.key = successor.key;
current.count = successor.count;
if (successorParent === current) {
successorParent.right = successor.right;
} else {
successorParent.left = successor.right;
}
return true;
}
const child = current.left !== null ? current.left : current.right;
if (parent === null) {
this.root = child;
} else if (parent.left === current) {
parent.left = child;
} else {
parent.right = child;
}
return true;
}
range(low, high) {
return rangeValues(this.root, low, high);
}
inorder() {
return this.range(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
}
}
module.exports = { BinarySearchTree, rangeValues };
if (require.main === module) {
const tree = new BinarySearchTree();
for (const key of [10, 5, 20, 15, 15, 17]) tree.insert(key);
const before = tree.inorder();
tree.delete(10);
const afterRoot = tree.inorder();
tree.delete(15);
console.log(JSON.stringify({ before, afterRoot, afterOne: tree.inorder(), found: tree.has(10) }));
}
실행 출력
{"before":[5,10,15,15,17,20],"afterRoot":[5,15,15,17,20],"afterOne":[5,15,17,20],"found":false}
삽입과 탐색: 같은 비교로 같은 경로를 따라갑니다
insert는 현재 노드와 비교해 left 또는 right를 선택합니다. 빈 연결을 만나면 그 위치에 새 노드를 연결합니다. 처음부터 root가 비어 있으면 루트를 만드는 별도 분기가 필요합니다. 중복이면 count만 증가시켜 같은 키가 양쪽에 흩어지는 상황을 막습니다.
has도 같은 경로를 따르지만 연결을 만들지 않습니다. null에 도착하면 그 키가 들어갈 자리가 비었다는 뜻입니다. 검색 실패를 예외가 아니라 false로 돌려주는 이유는 정상적인 조회 결과이기 때문입니다. 반면 잘못된 키 타입은 입력 계약 위반으로 예외를 던집니다.
삭제는 어느 연결을 다시 이어야 하는가
삭제 대상을 찾는 동안 parent를 함께 기억합니다. 목표 노드가 없어졌을 때 부모가 어느 자식을 가리켜야 하는지 바꾸기 위해서입니다. 대상이 루트인 경우에는 부모가 없으므로 this.root 자체를 교체해야 합니다. 이 구분을 놓치면 루트 삭제만 실패합니다.
| 대상 상태 | 처리 | 유지되는 조건 |
|---|---|---|
| 없음 | false 반환 | 트리를 바꾸지 않음 |
| count > 1 | count를 1 줄임 | 키당 노드 하나 |
| 자식 없음 | 부모 연결을 null로 | 나머지 키의 순서 유지 |
| 자식 하나 | 부모 연결을 그 자식으로 | 해당 부분 트리의 키 범위 유지 |
| 자식 둘 | 오른쪽 최솟값의 key와 count 이동 후 후계자 노드 제거 | 왼쪽 < 새 키 < 오른쪽 |
자식이 둘이면 한쪽만 부모에 연결할 수 없으므로 중위 후계자를 사용합니다. 오른쪽 부분 트리에서 가장 왼쪽에 있는 노드는 대상보다 큰 키 중 최솟값입니다. 후계자에게 왼쪽 자식은 없으므로 그 노드의 제거는 오른쪽 자식 하나를 올려 잇는 문제로 줄어듭니다.
중복 정책 때문에 후계자의 key뿐 아니라 count 전체를 옮깁니다. 예를 들어 15가 두 개면 현재 노드에 15×2를 담고 원래 후계자 노드는 통째로 제거합니다. 일반 delete(15)를 한 번 호출해 count만 줄이면 두 노드에 같은 키가 남아 엄격한 순서 불변식이 깨집니다.
| 작업 | 중위 결과 | 상태 해석 |
|---|---|---|
| 10,5,20,15,15,17 삽입 | 5,10,15,15,17,20 | 루트 10, 오른쪽 최솟값 15×2 |
| delete(10) | 5,15,15,17,20 | 루트에 15×2 이동, 20.left는 17 |
| delete(15) | 5,15,17,20 | 루트 count만 2→1 |
| has(10) | false | 10은 더 이상 저장되지 않음 |
후계자가 바로 오른쪽 자식이면 successorParent가 current입니다. 이때는 current.right를 후계자의 오른쪽 자식으로 바꿉니다. 더 깊은 왼쪽 경로에 있었다면 successorParent.left를 바꿉니다. 두 경우를 분리해야 바로 옆 후계자에서 자기 참조나 연결 손실이 생기지 않습니다.
범위 검색은 중위 순회에서 불가능한 가지를 건너뜁니다
rangeValues에서 current.key < low이면 그 노드와 왼쪽 전체가 범위 밖이므로 오른쪽으로 바로 이동합니다. 나머지는 왼쪽을 먼저 방문하는 중위 순서로 스택에 쌓습니다. 꺼낸 키가 high를 넘으면 이후 키도 모두 더 크므로 전체 반복을 끝냅니다. 범위 안의 키는 count번 출력합니다.
inorder는 안전 정수 전체를 범위로 지정해 같은 함수를 재사용합니다. 별도 중위 구현을 복사하지 않으므로 중복 확장 규칙이 서로 달라지지 않습니다. 다만 count가 매우 크면 반환 배열도 그만큼 커지므로 키별 개수만 필요할 때는 [key,count] 형태로 반환하는 별도 API가 적합합니다.
최악 O(n)을 숨기지 않는 복잡도
| 연산 | 시간 | 반환 결과 제외 보조 공간 |
|---|---|---|
| insert / has / delete | O(h+1), 최악 O(u) | O(1) |
| range, 결과 k개 | O(h+k+1), 최악 O(u+k) | O(h+1) |
| inorder, 전체 중복 포함 n개 | O(u+n) | O(h+1) |
| 저장 공간 | 서로 다른 키 u개 노드 | O(u) |
u는 서로 다른 키 수, n은 count 합, h는 높이입니다. 삽입 순서가 1,2,3,…이면 높이가 u-1인 연결 리스트 같은 모양이 됩니다. 균형 잡힌 모양이나 특정 무작위 삽입 모델에서 평균 로그 높이를 기대할 수 있지만, 이 삭제 방식을 반복한다고 균형이 보장되지는 않습니다. 최악 로그 시간이 필요하면 AVL이나 레드-블랙 트리를 선택해야 합니다.
경계 확인과 연습
리프 삭제, 한 자식 삭제, 루트 삭제, 바로 오른쪽인 후계자, 깊은 후계자와 중복 count를 별도로 확인하세요. 매 연산 후 중위 결과를 정렬된 다중집합과 비교하면 개수 유실을 찾을 수 있고, 모든 노드의 좌우 범위를 재검사하면 중복 키가 두 노드에 나뉜 오류도 찾을 수 있습니다.
JavaScript BST 연습: 닫힌 구간의 중복 키 보고서에서 직접 구현을 적용해 보세요.
함께 연습하고 확인할 자료
Delete Node in a BST: BST 노드 삭제를 연습하는 외부 문제입니다. 이 글의 count 중복 정책과 달리 공식 입력의 키 조건을 따르세요.
관련 개념 참고: Princeton Algorithms — Binary Search Trees 공식 자료와 문제 페이지는 2026년 9월 10일 확인했습니다. 외부 문제의 지문·예시를 복제하지 않고 이 글의 예제와 창작 문제를 별도로 구성했습니다.
직접 실습: 연산 비용으로 구조를 설명합니다
실습 주제: JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리
- 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
- 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
- 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
- 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과
메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.
테스트 체크리스트
- 빈 구조에 대한 조회·삭제 처리
- 첫 원소와 마지막 원소 변경
- 중복값 또는 동일 우선순위 처리
- 입력 크기가 커졌을 때 예상 복잡도 유지
이 글이 도움이 되었나요?
자료구조 학습 순서
필수 18개 · 전체 18개
읽음 기록 관리
전체 과정 목차 (18개)
- 필수 학습 · 자료구조 선택 가이드: 연산 비용으로 배열·스택·큐·Set 고르기
- 필수 학습 · JavaScript 배열: 인덱스 조회와 삽입·삭제 비용
- 필수 학습 · JavaScript Map·Set: 값 조회와 중복 제거 실습
- 필수 학습 · 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기
- 필수 학습 · 큐와 FIFO: head 인덱스로 JavaScript 대기열 만들기
- 필수 학습 · 단방향 연결 리스트: head·tail 삽입과 삭제
- 필수 학습 · JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼
- 필수 학습 · JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화
- 필수 학습 · 트리 자료구조 차이: 이진 트리 BST MST 구분하기
- 필수 학습 · JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리 현재 글
- 필수 학습 · JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수
- 필수 학습 · JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS
- 필수 학습 · JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기
- 필수 학습 · JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현
- 필수 학습 · JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현
- 필수 학습 · JavaScript 반복형 세그먼트 트리: 구간 합·단일 대입·결합 순서
- 필수 학습 · JavaScript LRU 캐시: Map과 이중 연결 리스트의 불변식
- 필수 학습 · JavaScript AVL 트리: 높이 불변식과 LL·RR·LR·RL 삽입 회전
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.