AVL 트리 삭제: 높이 갱신과 연속 균형 복구

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

AVL 트리 삭제 후에는 어디까지 높이와 균형을 복구해야 하나요?

AVL 삭제는 키를 지운 뒤 실제로 높이가 달라진 경로를 루트까지 되짚으며 균형을 복구하는 연산입니다. 삭제 위치만 고치면 위쪽 조상에 남은 높이 차이를 놓칠 수 있습니다.

적용 조건·범위: Algorithms, 4th Edition — AVLTreeST — AVL의 높이 차이 조건과 삭제 뒤 높이·크기를 고치고 균형을 복구하는 흐름을 참고했습니다. 이 예제의 빈 트리 높이는 0이며, 해당 자료의 -1 기준과 한 단계 차이가 있습니다.

본문 근거: Algorithms, 4th Edition — AVLTreeST

읽기 전에 알아두면 좋아요

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

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

과정 위치와 선택 복습 안내

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

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

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

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

AVL 트리 삭제: 높이 갱신과 연속 균형 복구 주제를 표현한 밝은 일러스트와 작은 곰 표지

AVL 삭제는 키를 지운 뒤 실제로 높이가 달라진 경로를 루트까지 되짚으며 균형을 복구하는 연산입니다. 삭제 위치만 고치면 위쪽 조상에 남은 높이 차이를 놓칠 수 있습니다.

선수 지식: 이진 탐색 트리의 삭제, AVL 높이와 네 가지 회전

  • 후계자를 이용한 삭제와 균형 복구를 구분합니다.
  • 삭제 후 자식의 균형 계수 0을 포함해 회전 방향을 고릅니다.
  • 순서·높이·크기 불변식을 함께 확인합니다.

먼저 BST의 삭제 구조를 확정합니다

키가 현재 노드보다 작으면 왼쪽, 크면 오른쪽으로 내려갑니다. 찾은 노드가 잎이면 그 자리를 비우고, 자식이 하나이면 자식을 그 자리에 연결합니다. 자식이 둘이면 오른쪽 서브트리의 최솟값인 후계자를 현재 자리에 복사한 뒤 오른쪽에서 그 후계자를 실제로 지웁니다. 이렇게 하면 왼쪽은 작고 오른쪽은 크다는 탐색 순서를 유지합니다.

잎·자식 한 개·자식 두 개를 삭제할 때의 연결 처리와 새 루트 반환
두 자식이면 오른쪽 최솟값을 복사하고 원래 위치의 노드를 지웁니다. 원본 보기 (새 탭)

현재 자리에 값을 복사하는 일과 노드를 실제로 떼어 내는 일은 다릅니다. 후계자가 오른쪽 아래에 깊게 있으면 높이가 줄어드는 출발점도 그 아래입니다. 따라서 오른쪽 서브트리의 재귀 삭제 결과를 반드시 다시 연결해야 합니다. 본 예제는 안전한 정수 키를 한 번씩만 저장하는 집합이며 중복 삽입과 없는 키 삭제는 크기를 바꾸지 않습니다.

재귀 함수가 돌려주는 값은 삭제한 키가 아니라 그 구간의 새로운 루트입니다. 회전이 발생하면 루트가 달라지므로 부모는 반환값을 왼쪽 또는 오른쪽 자식으로 받아야 합니다. 최상위 호출도 같은 원리로 트리의 루트를 교체합니다. 부모 포인터는 사용하지 않아 재귀 경로와 별도로 연결을 관리할 필요가 없습니다.

돌아오는 모든 경로에서 높이를 먼저 갱신합니다

빈 자식의 높이는 0, 잎의 높이는 1로 정합니다. 노드의 높이는 두 자식 높이의 최댓값에 1을 더한 값이고 균형 계수는 왼쪽 높이에서 오른쪽 높이를 뺀 값입니다. 삭제 재귀가 돌아오면 자식 연결을 확정한 다음 높이를 새로 계산하고, 계수의 절댓값이 1을 넘으면 회전합니다. 저장된 크기도 같은 시점에 두 자식 크기와 현재 노드 하나를 더해 고칩니다.

자식 연결 후 높이와 크기를 갱신하고 회전하여 위로 반환하는 순서
아래에서 위로 복구하며, 회전 뒤에는 내려온 이전 루트부터 높이를 고칩니다. 원본 보기 (새 탭)

왼쪽이 무거우면 왼쪽 자식의 계수를 봅니다. 그 값이 음수일 때만 왼쪽 자식을 먼저 왼쪽으로 돌리고, 이어 현재 노드를 오른쪽으로 돌립니다. 자식 계수가 영이거나 양수이면 오른쪽 단일 회전입니다. 오른쪽이 무거운 경우는 정확히 대칭입니다. 삭제에서는 무거운 자식의 계수가 영일 수 있으므로 삽입한 키의 대소 관계로 회전 종류를 고르면 안 됩니다.

회전 안에서도 높이 계산 순서가 있습니다. 아래로 내려온 이전 루트의 자식부터 확정하고 그 높이를 고친 뒤, 위로 올라온 새 루트의 높이를 고칩니다. 이 순서가 뒤집히면 새 루트가 아직 갱신되지 않은 높이를 읽습니다. 한 번 회전했더라도 더 위의 높이는 달라질 수 있어 재귀 반환을 루트까지 계속합니다.

자식 균형 계수가 0인 삭제를 따라갑니다

예제의 삽입 순서는 [9,5,10,0,6,11,-1,1,2]입니다. 삽입이 끝난 루트는 9이고 왼쪽 루트는 1입니다. 이때 10을 지우면 그 하나뿐인 자식 11이 오른쪽 자리를 이어받습니다. 오른쪽 높이는 2에서 1로 줄고 왼쪽 높이는 3으로 남아 루트 9의 균형 계수는 +2가 됩니다.

10 삭제 후 루트 9에서 오른쪽 단일 회전하여 루트 1이 되는 두 AVL 상태
무거운 자식의 균형 계수가 0이어도 부모의 불균형은 복구해야 합니다. 원본 보기 (새 탭)
키 10을 삭제한 뒤 위로 올라오는 과정
위치 상태 선택 이유
10 자식 11로 대체 자식이 하나이므로 연결만 바꿈
9 왼쪽 높이 3, 오른쪽 높이 1 균형 계수 +2
왼쪽 자식 1 양쪽 높이 2, 계수 0 오른쪽 단일 회전
새 루트 1 중위 순서 [-1,0,1,2,5,6,9,11] 순서 유지, 높이·크기 재계산

자식 계수가 0이라는 이유로 아무 회전도 하지 않으면 9의 높이 차이 2는 그대로 남습니다. 반대로 이중 회전을 선택하면 필요 없는 구조 변경이 생깁니다. 삭제 대상의 위치, 현재 높이 차이, 무거운 자식의 기울기를 순서대로 확인하면 0인 경우도 같은 규칙으로 처리할 수 있습니다.

정렬 결과와 구조 조건을 함께 점검합니다

중위 순회 결과가 정렬되었다는 사실만으로 AVL이 되지는 않습니다. 오름차순 연결 리스트처럼 편향된 BST도 같은 결과를 내기 때문입니다. 각 노드에서 실제 자식 높이를 다시 구해 저장된 높이와 비교하고, 높이 차이가 일 이하인지 검사해야 합니다. 저장된 부분 트리 크기도 재귀적으로 센 개수와 같아야 합니다.

중위 순서·실제 높이·높이 차·크기를 모두 검사하는 AVL 검증 그림
정렬된 결과만 보지 않고 각 노드의 높이·균형·크기도 확인합니다. 원본 보기 (새 탭)

빈 트리에서 삭제하면 그대로 빈 트리이며, 마지막 키를 지우면 루트는 빈 연결이 됩니다. 없는 키를 반복해서 지워도 기존 키가 남아 있어야 합니다. 두 자식 삭제는 후계자의 키만 옮겨 같은 키가 두 개 남지 않는지 확인합니다. 테스트에서는 집합의 정렬 결과와 비교하면서 매 연산 직후 높이·균형·크기를 점검하므로 나중 회전이 앞선 오류를 가리기 어렵습니다.

실습에서는 작은 키를 오름차순으로 넣고 짝수 키부터 지워 보세요. 이어 현재 루트를 계속 지우면 두 자식 삭제와 마지막 원소 삭제를 함께 만납니다. 어떤 순서에서도 탐색 경로가 짧게 유지되는지가 목표이며, 최종 배열만 맞추기 위해 삭제 때마다 전체 트리를 다시 만드는 방법은 여기서 구현한 경로 복구와 다른 비용을 가집니다.

전체 코드와 직접 실행

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

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

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

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

function requireKey(key) {
  if (!Number.isSafeInteger(key)) throw new TypeError('key must be a safe integer');
}
class AVLNode {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
    this.height = 1;
    this.size = 1;
  }
}
function height(node) { return node ? node.height : 0; }
function sizeOf(node) { return node ? node.size : 0; }
function refresh(node) {
  node.height = 1 + Math.max(height(node.left), height(node.right));
  node.size = 1 + sizeOf(node.left) + sizeOf(node.right);
  return node;
}
function balanceFactor(node) { return height(node.left) - height(node.right); }
function rotateLeft(node) {
  const top = node.right;
  node.right = top.left;
  top.left = node;
  refresh(node);
  return refresh(top);
}
function rotateRight(node) {
  const top = node.left;
  node.left = top.right;
  top.right = node;
  refresh(node);
  return refresh(top);
}
function rebalance(node) {
  refresh(node);
  if (balanceFactor(node) > 1) {
    if (balanceFactor(node.left) < 0) node.left = rotateLeft(node.left);
    return rotateRight(node);
  }
  if (balanceFactor(node) < -1) {
    if (balanceFactor(node.right) > 0) node.right = rotateRight(node.right);
    return rotateLeft(node);
  }
  return node;
}
function insertNode(node, key) {
  if (!node) return new AVLNode(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 rebalance(node);
}
function removeNode(node, key) {
  if (!node) return null;
  if (key < node.key) node.left = removeNode(node.left, key);
  else if (key > node.key) node.right = removeNode(node.right, key);
  else {
    if (!node.left) return node.right;
    if (!node.right) return node.left;
    let successor = node.right;
    while (successor.left) successor = successor.left;
    node.key = successor.key;
    node.right = removeNode(node.right, successor.key);
  }
  return rebalance(node);
}
class AVLTree {
  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);
    return this.size !== before;
  }
  remove(key) {
    requireKey(key);
    const before = this.size;
    this.root = removeNode(this.root, key);
    return this.size !== before;
  }
  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 AVLTree();
[9, 5, 10, 0, 6, 11, -1, 1, 2].forEach(key => tree.insert(key));
tree.remove(10);
console.log(JSON.stringify(tree.values()));
console.log(JSON.stringify({ size: tree.size }));

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

node algorithm-avl-tree-deletion.mjs

기대 출력

[-1,0,1,2,5,6,9,11]
{"size":8}

비용과 적용 범위

키가 N개인 유효한 AVL에서 탐색·삽입·삭제는 O(log N) 시간이며 재귀 경로 공간은 O(log N)입니다. 한 삭제가 여러 조상에서 회전할 수 있어도 각 높이에서 O(1) 작업만 합니다. 전체 저장 공간은 O(N), values()는 O(N) 시간과 결과 공간을 씁니다.

AVL의 루트에서 잎까지 경로와 로그 시간 및 공간 비용
여러 조상에서 회전하더라도 각 높이에서의 복구 비용은 일정합니다. 원본 보기 (새 탭)

직접 확인해 보기

루트 4의 왼쪽이 2(자식 1,3), 오른쪽이 잎 5일 때 5를 지우면 어떤 회전이 필요한가요?

루트 4의 자식 2와 5, 2의 자식 1과 3에서 5 삭제를 묻는 트리
5를 지운 뒤 높이 차이와 필요한 회전을 직접 판단해 보세요. 원본 보기 (새 탭)
답과 이유 확인

왼쪽 자식 2의 균형 계수는 0이고 루트 4의 계수는 +2이므로 4에서 오른쪽 단일 회전합니다. 새 루트는 2, 오른쪽은 4이며 3은 4의 왼쪽으로 옮겨 순서를 지킵니다.

참고 자료

  • Algorithms, 4th Edition — AVLTreeST — AVL의 높이 차이 조건과 삭제 뒤 높이·크기를 고치고 균형을 복구하는 흐름을 참고했습니다. 이 예제의 빈 트리 높이는 0이며, 해당 자료의 -1 기준과 한 단계 차이가 있습니다.

이 글이 도움이 되었나요?

조회 중

이어서 공부하기

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

다음 추천 · 이 개념으로 풀 문제: 코딩테스트 AVL 트리: 기준 이상 최솟값 찾기

같은 주제의 문제에 적용하며 방금 배운 개념을 확인합니다.

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

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

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기