Red-Black 트리 삭제: LLRB 색 이동과 검은 높이 복구

·설명 약 8분·작성: 해비·블로그 소개

LLRB 적흑 트리 삭제에서 색 이동과 검은 높이는 어떻게 복구하나요?

LLRB 삭제는 검은 잎을 먼저 없애고 뒤늦게 고치는 대신, 내려갈 경로에 필요한 빨강을 준비한 뒤 실제 삭제를 진행합니다. 돌아올 때 좌편향 형태를 복구하고 최종 루트를 검정으로 만듭니다.

적용 조건·범위: 삭제할 키가 있는지 먼저 탐색합니다. 없는 키이면 거짓을 반환하고 구조를 바꾸지 않습니다. 이 확인은 아래쪽 삭제 함수가 내려갈 자식이 존재한다고 가정할 수 있게 해 줍니다. 존재 확인과 삭제를 두 번 탐색해도 각각 로그 시간이므로 전체 차수는 같습니다. 키는 삽입과 마찬가지로 안전한 정수만 받으며, 빈 트리에서의 삭제도 존재 확인 단계에서 종료합니다.

본문 근거: Algorithms, 4th Edition — Balanced Search Trees

읽기 전에 알아두면 좋아요

아래 개념이 익숙하면 본문을 바로 읽으세요. 낯선 개념만 링크에서 복습하면 됩니다.

개념과 예제는 실행하지 않아도 읽을 수 있습니다. 내 컴퓨터·터미널에서 직접 실행하는 과정은 선택 실습입니다.

과정 위치와 선택 복습 안내

과정 위치: 심화 선택 실습 · 자료구조 개념

과정 위치는 이 글이 속한 묶음입니다. 이 단계의 모든 개념을 먼저 읽을 필요는 없습니다. 위에 연결한 개념 중 낯선 내용만 복습하세요. 문제의 조건·상태·갱신 순서는 익숙한 언어로 설명하면 됩니다.

JavaScript로 문법·실행 복습하기 (선택)

개념 글의 코드는 JavaScript 예시입니다. 문제 화면에서는 JavaScript·Python·Java·C++·C# 중 익숙한 언어의 함수를 작성하세요. 본문의 Node.js 입출력 예시는 로컬 실행을 위한 별도 안내입니다.

Red-Black 트리 삭제: LLRB 색 이동과 검은 높이 복구 주제를 표현한 밝은 일러스트와 작은 곰 표지

LLRB 삭제는 검은 잎을 먼저 없애고 뒤늦게 고치는 대신, 내려갈 경로에 필요한 빨강을 준비한 뒤 실제 삭제를 진행합니다. 돌아올 때 좌편향 형태를 복구하고 최종 루트를 검정으로 만듭니다.

선수 지식: 적흑 트리 불변식, LLRB 삽입과 회전, BST 후계자 삭제

  • 검은 노드 삭제가 경로별 검은 수에 미치는 영향을 설명합니다.
  • 내려가기 전 색 이동과 올라오며 균형 복구를 구분합니다.
  • 연속 삭제와 마지막 노드 삭제 후에도 불변식을 검사합니다.

검은 노드를 없애면 경로의 검은 수가 줄어듭니다

빨간 잎을 없애는 일은 검은 높이를 바꾸지 않지만 검은 잎을 그대로 없애면 그 경로에만 검정 하나가 부족해집니다. BST의 연결만 올바르게 바꾸어도 적흑 조건은 깨질 수 있는 이유입니다. 이 글은 삽입 글과 동일한 LLRB를 사용합니다. 일반 RB의 부모·형제 분기식 삭제와 전체 불변식은 같지만, 빨간 연결이 왼쪽을 향한다는 추가 규칙과 색을 옮기는 처리 순서는 다릅니다.

검은 잎 1B를 준비 없이 지우면 좌우 검은 노드 수가 달라지는 도식
BST 연결이 맞아도 검은 높이가 달라질 수 있어 색 준비가 필요합니다. 원본 보기 (새 탭)

삭제할 키가 있는지 먼저 탐색합니다. 없는 키이면 거짓을 반환하고 구조를 바꾸지 않습니다. 이 확인은 아래쪽 삭제 함수가 내려갈 자식이 존재한다고 가정할 수 있게 해 줍니다. 존재 확인과 삭제를 두 번 탐색해도 각각 로그 시간이므로 전체 차수는 같습니다. 키는 삽입과 마찬가지로 안전한 정수만 받으며, 빈 트리에서의 삭제도 존재 확인 단계에서 종료합니다.

공개 삭제를 시작할 때 루트 양쪽이 검정이면 루트를 일시적으로 빨강으로 바꿉니다. 아래로 색을 나누어 줄 여지를 마련하는 준비이며 작업 완료 상태의 루트 규칙을 바꾼 것이 아닙니다. 삭제가 끝나고 루트가 남아 있으면 반드시 검정으로 돌립니다. 마지막 키를 지워 루트가 빈 연결이 되었다면 색에 접근하지 않습니다. 이 구분이 단일 노드 삭제를 안전하게 만듭니다.

내려갈 자식의 색을 먼저 준비합니다

왼쪽으로 내려갈 때 왼쪽 자식과 그 왼쪽 자식이 모두 검정이면 그대로 내려가 삭제하기 어렵습니다. 이때 부모와 양쪽 자식의 색을 뒤집어 왼쪽에 빨간 여유를 만듭니다. 오른쪽 자식의 왼쪽 연결에 빨강이 있으면 그것을 회전으로 빌려 옵니다. 오른쪽 자식을 오른쪽으로, 현재 노드를 왼쪽으로 돌린 뒤 색을 다시 뒤집는 순서입니다. 이것이 moveRedLeft의 역할입니다.

moveRedLeft에서 색을 뒤집고 오른쪽의 왼쪽 빨강 여부에 따라 회전하는 흐름
내려가기 전에 색을 준비하며, 회전 분기는 조건을 만족할 때만 실행합니다. 원본 보기 (새 탭)

오른쪽 경로도 같은 목적이지만 좌편향 표현 때문에 코드를 단순 복사해 좌우만 바꾸지는 않습니다. 먼저 왼쪽 자식이 빨강이면 현재 노드를 오른쪽으로 돌려 삭제 후보를 다루기 쉬운 위치로 옮깁니다. 오른쪽 자식과 그 왼쪽 자식이 모두 검정일 때 moveRedRight로 색을 준비합니다. 이때 왼쪽의 왼쪽 빨강이 있으면 오른쪽 회전과 추가 색 변환으로 필요한 묶음을 재분배합니다.

색 뒤집기는 두 자식이 있는 구조에서 호출됩니다. 예제의 자식 접근은 유효한 LLRB와 존재하는 삭제 키라는 전제를 사용합니다. 검은 높이가 같은 트리에서 내려갈 쪽이 검정 묶음이면 반대쪽에도 필요한 연결이 있어 이 전제가 유지됩니다. 임의로 만든 색이 깨진 노드를 공개 루트에 주입한 뒤 삭제하는 입력은 지원하지 않습니다. 일반적인 자료구조도 자신의 연산이 유지한 유효 상태를 출발점으로 삼습니다.

후계자는 오른쪽 최솟값 경로에서 실제로 지웁니다

찾은 키에 오른쪽 자식이 없으면 색과 방향을 준비한 상태에서 그 노드를 비웁니다. 오른쪽이 있으면 오른쪽 서브트리의 최소 키를 현재 노드에 복사하고 deleteMin으로 원래 최소 노드를 실제로 제거합니다. 최소 노드까지 내려가는 과정에서도 검은 묶음으로 그냥 들어가지 않도록 같은 색 이동을 적용합니다. 키 복사만 하고 최소 노드를 남겨 두면 중복 키가 생겨 엄격한 BST 순서가 깨집니다.

세 키 트리에서 1 삭제를 위한 루트 준비·색 이동·실제 삭제·왼쪽 회전
내려갈 때 삭제할 색을 준비하고, 돌아오며 좌편향 형태를 복구합니다. 원본 보기 (새 탭)

재귀가 돌아올 때는 삽입과 같은 balance를 호출합니다. 빨간 오른쪽 연결을 왼쪽으로 돌리고, 왼쪽의 연속 빨강을 펴고, 양쪽 빨강의 색을 나눕니다. 내려갈 때의 색 이동은 삭제할 수 있는 상태를 만드는 목적이고 올라올 때의 복구는 완료된 LLRB 형태를 되찾는 목적입니다. 크기는 실제 자식 연결이 정해진 뒤 새로 계산하므로 삭제 뒤 남은 노드 수를 정확히 나타냅니다.

작은 세 키 트리에서 1 삭제: 시작은 2B의 두 자식 1B,3B
단계 상태 검은 높이를 지키는 이유
루트 준비 2R, 왼쪽 1B, 오른쪽 3B 전체 경로에서 함께 검정 하나를 임시 제거
왼쪽 색 이동 2B, 왼쪽 1R, 오른쪽 3R 양쪽 검은 수가 함께 유지됨
실제 삭제 1R을 비움 빨강 삭제는 검은 수를 줄이지 않음
돌아오며 왼쪽 회전 3B의 왼쪽 2R 오른쪽 빨강을 없애고 정렬 순서를 보존

삭제된 뒤의 키는 2와 3이고 루트는 3입니다. 이 예제처럼 결과 모양은 다른 적흑 구현과 다를 수 있습니다. 어느 모양이 정답인지 한 그림만 외우기보다 남은 키, 좌편향, 빨강 연속 금지, 검은 높이가 모두 맞는지 확인하는 편이 정확합니다. 모든 유효한 트리가 같은 루트 키를 가져야 하는 것은 아닙니다.

한 번 성공한 삭제보다 연속 연산을 확인합니다

삭제 검증에서 중위 순서만 맞으면 색 버그를 놓칠 수 있습니다. 루트가 검정인지, 오른쪽 빨강이 없는지, 빨강의 두 자식이 검정인지, 각 노드에서 왼쪽과 오른쪽 검은 높이가 같은지 검사합니다. 각 노드가 저장한 크기도 재귀로 센 값과 비교합니다. 부모 포인터를 쓰지 않으므로 부모 연결의 별도 불변식은 없으며, 회전의 반환값이 부모 자식에 다시 들어가는지를 구조 검사로 확인합니다.

LLRB의 다섯 불변식 검사와 연속 삽입·삭제 예제
매 연산 직후 남은 키와 색·연결·크기를 함께 검사합니다. 원본 보기 (새 탭)

예제는 [17,9,19,75,85]를 넣고 [19,75,85]를 지워 9와 17을 남깁니다. 이어 남은 두 키까지 지워 빈 루트를 확인합니다. 실습에서는 오름차순 삽입 후 내림차순 삭제, 현재 루트 반복 삭제, 없는 키 반복 삭제도 해 보세요. 각 연산 직후 집합 기준과 구조 조건을 함께 확인해야 일시적 오류가 다음 연산에 우연히 가려지지 않습니다.

특히 두 자식 삭제에서 복사한 자리의 색과 실제로 사라진 노드의 색을 혼동하면 일반 RB 구현의 복구 판단이 틀릴 수 있습니다. LLRB 예제는 그 색 변수를 뒤늦게 추정하는 분기를 사용하지 않고 내려가기 전 준비를 적용합니다. 또한 루트를 검정으로 만든다는 의도는 비교식이 아니라 대입으로 구현해야 합니다. 색 변수의 이름이 같아도 각 구현의 규칙과 실제 상태 변화를 끝까지 따라가야 합니다.

전체 코드와 직접 실행

개념을 읽었다면 아래 예제를 직접 실행하며 상태 변화를 확인할 수 있습니다.

전체 JavaScript 코드와 실행 방법 펼치기

별도 패키지는 필요하지 않습니다. Node.js가 처음이라면 브라우저와 Node.js 시작 안내부터 확인하세요.

algorithm-red-black-tree-deletion.mjs 파일을 만들고 아래 전체 코드를 저장합니다.

function requireKey(key) {
  if (!Number.isSafeInteger(key)) throw new TypeError('key must be a safe integer');
}
class RBNode {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
    this.red = true;
    this.size = 1;
  }
}
function isRed(node) { return node !== null && node.red; }
function sizeOf(node) { return node ? node.size : 0; }
function refresh(node) {
  node.size = 1 + sizeOf(node.left) + sizeOf(node.right);
  return node;
}
function rotateLeft(node) {
  const top = node.right;
  node.right = top.left;
  top.left = node;
  top.red = node.red;
  node.red = true;
  refresh(node);
  return refresh(top);
}
function rotateRight(node) {
  const top = node.left;
  node.left = top.right;
  top.right = node;
  top.red = node.red;
  node.red = true;
  refresh(node);
  return refresh(top);
}
function flipColors(node) {
  node.red = !node.red;
  node.left.red = !node.left.red;
  node.right.red = !node.right.red;
}
function balance(node) {
  if (isRed(node.right) && !isRed(node.left)) node = rotateLeft(node);
  if (isRed(node.left) && isRed(node.left.left)) node = rotateRight(node);
  if (isRed(node.left) && isRed(node.right)) flipColors(node);
  return refresh(node);
}
function insertNode(node, key) {
  if (!node) return new RBNode(key);
  if (key < node.key) node.left = insertNode(node.left, key);
  else if (key > node.key) node.right = insertNode(node.right, key);
  else return node;
  return balance(node);
}
function moveRedLeft(node) {
  flipColors(node);
  if (isRed(node.right.left)) {
    node.right = rotateRight(node.right);
    node = rotateLeft(node);
    flipColors(node);
  }
  return node;
}
function moveRedRight(node) {
  flipColors(node);
  if (isRed(node.left.left)) {
    node = rotateRight(node);
    flipColors(node);
  }
  return node;
}
function deleteMin(node) {
  if (!node.left) return null;
  if (!isRed(node.left) && !isRed(node.left.left)) node = moveRedLeft(node);
  node.left = deleteMin(node.left);
  return balance(node);
}
function removeNode(node, key) {
  if (key < node.key) {
    if (!isRed(node.left) && !isRed(node.left.left)) node = moveRedLeft(node);
    node.left = removeNode(node.left, key);
  } else {
    if (isRed(node.left)) node = rotateRight(node);
    if (key === node.key && !node.right) return null;
    if (!isRed(node.right) && !isRed(node.right.left)) node = moveRedRight(node);
    if (key === node.key) {
      let successor = node.right;
      while (successor.left) successor = successor.left;
      node.key = successor.key;
      node.right = deleteMin(node.right);
    } else {
      node.right = removeNode(node.right, key);
    }
  }
  return balance(node);
}
class RedBlackTree {
  constructor() { this.root = null; }
  get size() { return sizeOf(this.root); }
  has(key) {
    requireKey(key);
    let node = this.root;
    while (node) {
      if (key === node.key) return true;
      node = key < node.key ? node.left : node.right;
    }
    return false;
  }
  insert(key) {
    requireKey(key);
    const before = this.size;
    this.root = insertNode(this.root, key);
    this.root.red = false;
    return this.size !== before;
  }
  remove(key) {
    requireKey(key);
    if (!this.has(key)) return false;
    if (!isRed(this.root.left) && !isRed(this.root.right)) this.root.red = true;
    this.root = removeNode(this.root, key);
    if (this.root) this.root.red = false;
    return true;
  }
  values() {
    const result = [];
    function visit(node) {
      if (!node) return;
      visit(node.left); result.push(node.key); visit(node.right);
    }
    visit(this.root);
    return result;
  }
}
const tree = new RedBlackTree();
[17, 9, 19, 75, 85].forEach(key => tree.insert(key));
[19, 75, 85].forEach(key => tree.remove(key));
console.log(JSON.stringify(tree.values()));
console.log(JSON.stringify({ size: tree.size, rootBlack: !tree.root.red }));
[9, 17].forEach(key => tree.remove(key));
console.log(JSON.stringify({ size: tree.size, root: tree.root }));

파일이 있는 폴더의 터미널에서 실행합니다.

node algorithm-red-black-tree-deletion.mjs

기대 출력

[9,17]
{"size":2,"rootBlack":true}
{"size":0,"root":null}

비용과 적용 범위

유효한 LLRB에서 존재 검사와 삭제는 각각 O(log N) 시간입니다. 후계자 탐색·최솟값 삭제를 포함해도 한 삭제의 전체 경로 길이는 O(log N)입니다. 각 높이의 회전·색 변환은 O(1), 재귀 추가 공간은 O(log N), 저장 공간은 O(N)입니다.

LLRB 존재 검사·전체 삭제·추가 공간·저장 공간 비용 표
유효한 LLRB에서 후계자 처리까지 포함한 삭제는 O(log N)입니다. 원본 보기 (새 탭)

직접 확인해 보기

2B의 자식이 1B와 3B인 트리에서 1을 그냥 비우면 어떤 조건이 깨지나요?

루트 2B와 자식 1B·3B 및 검은 NIL에서 1B의 직접 삭제를 묻는 문제
삭제 전 각 경로의 검은 수를 직접 비교해 보세요. 원본 보기 (새 탭)
답과 이유 확인

왼쪽 경로는 2B→빈 연결로 검은 수가 2이고 오른쪽은 2B→3B→빈 연결로 3이 되어 검은 높이가 달라집니다. 색 이동으로 1을 빨강으로 준비해 지우고 최종 좌편향 복구를 해야 합니다.

참고 자료

  • Algorithms, 4th Edition — Balanced Search Trees — 균형 탐색 트리의 높이, 2–3 트리와 좌편향 적흑 트리의 대응, 회전·색 변환을 설명하는 저자 자료입니다. 예제의 추적과 검사는 직접 작성했습니다.

이 글이 도움이 되었나요?

조회 중

이어서 공부하기

체크포인트: 예제의 상태 변화를 손으로 추적하고 아래 문제에 적용하세요. 값이 없는 경우와 원소 하나인 경우도 설명해 보세요. 표본 하나에 더해 경계 입력과 작은 기준 풀이를 비교하고 왜 이 구조를 고르는지 설명하세요.

다음 추천 · 알고리즘 개념: 포드 풀커슨 최대 유량: 잔여 용량과 역방향 간선으로 선택 수정하기

다음 주제는 유량·상태 압축과 계산 복잡도 (선택)입니다. 잔여 경로와 선택 집합을 표현하고 완전탐색·동적 계획법·검증의 비용을 비교합니다.

권장 순서는 학습 가이드와 같습니다. 다른 순서는 접힌 참고 목록에서 확인할 수 있습니다. 읽음 표시는 숙달이나 채점 결과가 아닙니다.

원래 과정 목록 순서 (선택)

읽음 기록

읽음 — / 전체 23개 · 필수 23개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기