Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 24.628 phút đọc

Xóa khỏi BST

Sau bài này bạn sẽ làm được

  • Phân biệt ba trường hợp xóa
  • Cài xóa đúng cho cả ba trường hợp
  • Giải thích vì sao dùng nút nhỏ nhất của cây con phải
  • Kiểm tra lại tính chất BST sau khi xóa

Xóa là thao tác khó nhất trên BST. Nút không con và nút một con đều dễ, nhưng nút hai con thì không có chỗ nào để nối vào, nên phải mượn một nút khác tới thế chỗ.

#Ba trường hợp

Ba trường hợp xóa, phân biệt theo số con của nút bị xóa.
Trường hợpSố conCách xử lýĐộ khó
10Giải phóng nút, trả về NULL cho chaDễ nhất
21Nối con duy nhất lên thế chỗ, giải phóng nútDễ
32Chép giá trị của nút kế tiếp lên, rồi xóa nút kế tiếp ở cây con phảiKhó, và là chỗ mọi lỗi nằm

#Cài đặt

bst.c
#include <stdlib.h>

#include "bst.h"

/* Nút nhỏ nhất của cây con gốc r. r phải khác NULL. */
static TNode *nho_nhat(TNode *r)
{
    while (r->left != NULL) r = r->left;

    return r;
}

/* Xóa nút có giá trị v khỏi cây gốc r.
   Trả về gốc MỚI của cây con đó, theo đúng mẫu ở Bài 24.4.
   Không tìm thấy v thì cây không đổi. */
TNode *bst_xoa(TNode *r, int v)
{
    if (r == NULL) return NULL;

    if      (v < r->data) r->left  = bst_xoa(r->left,  v);
    else if (v > r->data) r->right = bst_xoa(r->right, v);
    else {
        /* Đây là nút cần xóa */

        /* TH1: không con */
        if (r->left == NULL && r->right == NULL) {
            free(r);

            return NULL;
        }

        /* TH2: chỉ có con phải */
        if (r->left == NULL) {
            TNode *con = r->right;

            free(r);

            return con;
        }

        /* TH2: chỉ có con trái */
        if (r->right == NULL) {
            TNode *con = r->left;

            free(r);

            return con;
        }

        /* TH3: hai con.
           Lấy nút kế tiếp, tức nút nhỏ nhất của cây con PHẢI.
           Chép giá trị của nó lên, rồi xóa nó khỏi cây con phải. */
        TNode *ke = nho_nhat(r->right);

        r->data  = ke->data;
        r->right = bst_xoa(r->right, ke->data);
    }

    return r;
}
  1. Đi xuống tìm nút cần xóa

    Giống hệt hàm tìm, nhưng mỗi tầng gán kết quả trở lại r->left hoặc r->right. Ở hầu hết các tầng phép gán này không đổi gì.

  2. Tới nút cần xóa, phân loại theo số con

    Ba trường hợp không con, một con, hai con, xử lý riêng từng cái.

  3. Với hai con, chép giá trị nút kế tiếp lên

    Nút kế tiếp là nút nhỏ nhất của cây con phải. Nó lớn hơn mọi giá trị bên trái và nhỏ hơn mọi giá trị còn lại bên phải, nên đặt nó vào chỗ này giữ nguyên tính chất BST.

  4. Rồi xóa nút kế tiếp khỏi cây con phải

    Lời gọi đệ quy này chắc chắn rơi vào trường hợp 1 hoặc 2, vì nút nhỏ nhất không bao giờ có con trái. Nên không có đệ quy vô hạn.

Chương trình thử

main.c
#include <stdio.h>

#include "bst.h"

int main(void)
{
    TNode *goc = NULL;
    int    nguon[] = { 50, 30, 70, 20, 40, 60, 80 };

    for (size_t i = 0; i < sizeof nguon / sizeof nguon[0]; ++i)
        goc = bst_chen(goc, nguon[i]);

    printf("ban dau     : "); trung_thu_tu(goc); printf("\n");

    goc = bst_xoa(goc, 20);      /* TH1: la */
    printf("sau xoa 20  : "); trung_thu_tu(goc); printf("\n");
    printf("van la BST  : %d\n", la_bst(goc));

    goc = bst_xoa(goc, 30);      /* TH2: mot con (40) */
    printf("sau xoa 30  : "); trung_thu_tu(goc); printf("\n");
    printf("van la BST  : %d\n", la_bst(goc));

    goc = bst_xoa(goc, 50);      /* TH3: hai con */
    printf("sau xoa 50  : "); trung_thu_tu(goc); printf("\n");
    printf("van la BST  : %d\n", la_bst(goc));
    printf("goc moi     : %d\n", goc->data);

    goc = bst_xoa(goc, 99);      /* khong co */
    printf("sau xoa 99  : "); trung_thu_tu(goc); printf("\n");

    printf("cay:\n");
    in_khung(goc, "", 1, 0);

    tree_huy(goc);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g bst.c tree.c main.c -o t && ./t
ban dau     : 20 30 40 50 60 70 80 
sau xoa 20  : 30 40 50 60 70 80 
van la BST  : 1
sau xoa 30  : 40 50 60 70 80 
van la BST  : 1
sau xoa 50  : 40 60 70 80 
van la BST  : 1
goc moi     : 60
sau xoa 99  : 40 60 70 80 
cay:
    ,-- 80
,-- 70
60
'-- 40
# Valgrind trên bản không bật sanitizer
valgrind --leak-check=full ./t
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts

#Chọn nút thế mạng

Nút thế mạng
Nút được chọn để thay vào chỗ nút bị xóa. Chỉ có hai lựa chọn giữ được tính chất BST: nút kế tiếp, tức nhỏ nhất của cây con phải, hoặc nút liền trước, tức lớn nhất của cây con trái.
Hai lựa chọn
/* Lựa chọn A: nút kế tiếp, nhỏ nhất của cây con PHẢI */
TNode *ke = nho_nhat(r->right);

r->data  = ke->data;
r->right = bst_xoa(r->right, ke->data);

/* Lựa chọn B: nút liền trước, lớn nhất của cây con TRÁI */
TNode *truoc = lon_nhat(r->left);

r->data = truoc->data;
r->left = bst_xoa(r->left, truoc->data);

#Chép dữ liệu hay nối lại con trỏ

Bản ở trên chép giá trị của nút kế tiếp lên nút bị xóa. Có một cách khác: gỡ hẳn nút kế tiếp ra rồi đặt nó vào đúng vị trí của nút bị xóa, tức nối lại con trỏ.

Bản nối lại con trỏ
/* Không chép data, mà thật sự thay nút. Dài hơn nhưng cần thiết
   khi có con trỏ từ bên ngoài trỏ vào nút. */
TNode *bst_xoa_noi(TNode *r, int v)
{
    if (r == NULL) return NULL;

    if      (v < r->data) { r->left  = bst_xoa_noi(r->left,  v); return r; }
    else if (v > r->data) { r->right = bst_xoa_noi(r->right, v); return r; }

    if (r->left  == NULL) { TNode *c = r->right; free(r); return c; }
    if (r->right == NULL) { TNode *c = r->left;  free(r); return c; }

    /* Hai con: gỡ nút kế tiếp ra khỏi cây con phải */
    TNode *cha_ke = r;
    TNode *ke     = r->right;

    while (ke->left != NULL) { cha_ke = ke; ke = ke->left; }

    if (cha_ke != r) {
        cha_ke->left = ke->right;      /* ke không có con trái */
        ke->right    = r->right;
    }

    ke->left = r->left;                /* ke nhận cả hai cây con của r */

    free(r);

    return ke;                         /* ke thành gốc mới của cây con này */
}
Chép dữ liệuNối lại con trỏ
Số dòngÍt hơnNhiều hơn
Dễ viết đúngRấtDễ sai ở chỗ cha_ke != r
Con trỏ từ ngoài vào nút còn dùng được khôngKhông, nút vẫn đó nhưng giá trị đổiCó, mỗi nút giữ nguyên giá trị suốt đời
Hợp khi data là struct lớnKém, phải chép cả structTốt, chỉ đổi con trỏ
Hợp khi data sở hữu bộ nhớPhải cẩn thận, dễ rò rỉ hoặc giải phóng hai lầnTốt, không đụng vào data

#Xóa lặp lại làm cây lệch

do-lech.c
#include <stdio.h>
#include <stdlib.h>

#include "bst.h"

/* Chèn n khóa ngẫu nhiên, rồi lặp lại: xóa một khóa ngẫu nhiên
   và chèn một khóa ngẫu nhiên khác. Đo chiều cao theo thời gian. */
int main(void)
{
    const long n   = 10000;
    const long lan = 200000;

    srand(777);

    TNode *goc  = NULL;
    int   *khoa = malloc((size_t)n * sizeof *khoa);

    if (khoa == NULL) return 1;

    for (long i = 0; i < n; ++i) {
        khoa[i] = rand();
        goc     = bst_chen(goc, khoa[i]);
    }

    printf("ban dau       : chieu cao %d\n", tree_chieu_cao(goc));

    for (long k = 0; k < lan; ++k) {
        long i = rand() % n;

        goc     = bst_xoa(goc, khoa[i]);
        khoa[i] = rand();
        goc     = bst_chen(goc, khoa[i]);

        if ((k + 1) % 50000 == 0)
            printf("sau %6ld luot: chieu cao %d\n", k + 1, tree_chieu_cao(goc));
    }

    free(khoa);
    tree_huy(goc);

    return 0;
}
terminal
gcc -std=c17 -O2 bst.c tree.c do-lech.c -o t && ./t
ban dau       : chieu cao 31
sau  50000 luot: chieu cao 42
sau 100000 luot: chieu cao 51
sau 150000 luot: chieu cao 58
sau 200000 luot: chieu cao 63
# Cùng phép thử với bản luân phiên hai lựa chọn nút thế mạng
./do-lech-luan-phien
ban dau       : chieu cao 31
sau 200000 luot: chieu cao 39
# Và với cây AVL ở Bài 24.7
./do-lech-avl
ban dau       : chieu cao 16
sau 200000 luot: chieu cao 16

Tự làm thử

  1. Cài bst_xoa đầy đủ và thử đủ ba trường hợp, gọi la_bst sau mỗi lần xóa.
  2. Viết bản sai free(ke) thay vì gọi lại hàm xóa, chạy dưới -fsanitize=address và đọc báo cáo.
  3. Cài bản nối lại con trỏ, rồi kiểm rằng con trỏ tới một nút vẫn dùng được sau khi xóa một nút khác.
  4. Cài bản luân phiên hai lựa chọn nút thế mạng và đo chiều cao sau hai trăm nghìn lượt chèn xóa.
  5. Xóa hết mọi nút của một cây bằng cách lặp xóa gốc, kiểm rằng cuối cùng cây rỗng và valgrind không báo rò rỉ.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Ba trường hợp xóa phân biệt theo số con: không con thì giải phóng, một con thì nối con lên, hai con thì mượn nút thế mạng.
  • Nút thế mạng chỉ có thể là nút kế tiếp hoặc nút liền trước, vì chỉ hai giá trị đó giữ được dãy trung thứ tự tăng dần.
  • Sau khi chép giá trị nút thế mạng lên, phải gọi lại hàm xóa trên cây con tương ứng chứ không được free thẳng.
  • Chép dữ liệu ngắn hơn nhưng nguy hiểm khi nút sở hữu bộ nhớ. Nối lại con trỏ an toàn hơn và giữ được con trỏ từ bên ngoài.
  • Chèn và xóa lặp lại làm BST lệch dần, chiều cao tăng theo thời gian dù dữ liệu ngẫu nhiên. Chỉ cây tự cân bằng chặn được điều đó.