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

Chèn vào BST

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

  • Cài chèn bằng đệ quy theo mẫu trả về gốc mới
  • Cài chèn bằng vòng lặp
  • Chọn cách xử lý khóa trùng và ghi rõ vào hợp đồng
  • Xử lý hết bộ nhớ giữa chừng

Chèn vào BST chỉ có một chỗ đúng cho mỗi khóa, và tìm chỗ đó là đi từ gốc xuống theo đúng một đường. Cái khó không nằm ở thuật toán mà ở chữ ký hàm: làm sao vừa sửa được cây vừa báo được lỗi hết bộ nhớ.

#Bản đệ quy trả về gốc

bst.c
#include <stdlib.h>

#include "bst.h"

/* Chèn v vào cây gốc r. Trả về gốc MỚI của cây con đó.
   Khóa trùng bị bỏ qua. Trả về r cũ nếu hết bộ nhớ, tức cây không đổi. */
TNode *bst_chen(TNode *r, int v)
{
    if (r == NULL) return tn_tao(v);      /* tìm được chỗ trống, tạo nút */

    if      (v < r->data) r->left  = bst_chen(r->left,  v);
    else if (v > r->data) r->right = bst_chen(r->right, v);
    /* v == r->data: bỏ qua, không cho trùng */

    return r;                             /* gốc không đổi ở mọi tầng khác */
}

/* Dùng: */
TNode *goc = NULL;

goc = bst_chen(goc, 50);
goc = bst_chen(goc, 30);
goc = bst_chen(goc, 70);
  1. Cây rỗng thì tạo nút mới và trả về nó

    Đây là trường hợp cơ sở, và cũng là chỗ duy nhất cây thật sự thay đổi.

  2. Khóa nhỏ hơn thì chèn vào cây con trái

    Gán kết quả trở lại r->left. Ở hầu hết các tầng thì phép gán này ghi lại đúng giá trị cũ, và nó chỉ thật sự thay đổi ở tầng cuối cùng.

  3. Khóa lớn hơn thì chèn vào cây con phải

    Đối xứng hoàn toàn.

  4. Khóa bằng thì không làm gì

    Theo quy ước không cho trùng ở Bài 24.3. Đổi quy ước thì sửa đúng nhánh này.

#Hai chữ ký, hai cách xử lý lỗi

Cách 1: nhận con trỏ tới gốc, trả mã lỗi

bst.c (tiếp)
/* Trả về  0 nếu chèn được
            1 nếu khóa đã có, cây không đổi
           -1 nếu hết bộ nhớ, cây không đổi                    */
int bst_chen2(TNode **pgoc, int v)
{
    if (pgoc == NULL) return -1;

    if (*pgoc == NULL) {
        TNode *n = tn_tao(v);

        if (n == NULL) return -1;

        *pgoc = n;

        return 0;
    }

    if (v < (*pgoc)->data) return bst_chen2(&(*pgoc)->left,  v);
    if (v > (*pgoc)->data) return bst_chen2(&(*pgoc)->right, v);

    return 1;      /* đã có */
}

/* Dùng: */
TNode *goc = NULL;

if (bst_chen2(&goc, 50) < 0) { /* xử lý hết bộ nhớ */ }

Cách 2: gói cây vào một struct

bst.h
typedef struct {
    TNode *goc;
    size_t size;      /* số nút, để hỏi trong O(1) */
} BST;

void bst_khoi_tao(BST *t);
void bst_huy(BST *t);
int  bst_chen3(BST *t, int v);      /* 0 ổn, 1 đã có, -1 hết bộ nhớ */
int  bst_co(const BST *t, int v);
bst.c (tiếp)
int bst_chen3(BST *t, int v)
{
    int kq = bst_chen2(&t->goc, v);

    if (kq == 0) ++t->size;      /* chỉ tăng khi thật sự thêm nút */

    return kq;
}
Trả gốc mớiCon trỏ tới gốcGói vào struct
Ngắn gọnNhấtVừaVừa
Báo được hết bộ nhớKhôngCóCó
Phân biệt được khóa đã cóKhôngCóCó
Đếm số nút trong O(1)KhôngKhôngCó
Người gọi dễ quên gánRất dễKhông thểKhông thể
Dùng trongSách giáo khoa, mã dạy họcMã thậtThư viện

#Bản lặp

bst.c (tiếp)
/* Bản lặp: không đệ quy, O(1) bộ nhớ, không sợ tràn ngăn xếp. */
int bst_chen_lap(TNode **pgoc, int v)
{
    if (pgoc == NULL) return -1;

    TNode *cha = NULL;
    TNode *cur = *pgoc;

    /* Đi xuống tìm chỗ trống, nhớ nút cha */
    while (cur != NULL) {
        if      (v < cur->data) { cha = cur; cur = cur->left;  }
        else if (v > cur->data) { cha = cur; cur = cur->right; }
        else                    return 1;      /* đã có */
    }

    TNode *n = tn_tao(v);

    if (n == NULL) return -1;

    if      (cha == NULL)     *pgoc      = n;   /* cây trước đó rỗng */
    else if (v < cha->data)   cha->left  = n;
    else                      cha->right = n;

    return 0;
}
Đệ quyLặp
Số dòng822
Bộ nhớ ngăn xếpO(chiều cao)O(1)
Sợ tràn ngăn xếp khôngCó với cây suy biếnKhông
Tốc độChậm hơn khoảng 20 phần trămNhanh hơn
Dễ đọcRấtKém hơn
Dùng lại được cho AVL khôngCó, mẫu trả gốc mới hợp với phép xoayKhó, phải giữ cả đường đi
terminal
./do-chen 5000000
chen 5000000 so ngau nhien:
de quy : 3.812 s
lap    : 3.104 s
nhanh hon: 1.23 lan
# Nhưng với dữ liệu đã sắp, cây suy biến
./do-chen-sap 300000
de quy : Segmentation fault (tran ngan xep o do sau ~262000)
lap    : 42.187 s (cham vi cay suy bien, nhung KHONG sap)

#Bản dùng con trỏ tới con trỏ

Mẹo Node **pp ở Bài 21.4 áp dụng nguyên vẹn cho cây, và nó cho bản chèn ngắn nhất trong ba bản lặp.

bst.c (tiếp)
/* Ngắn nhất: pp luôn là địa chỉ của ô đang trỏ tới cây con hiện tại. */
int bst_chen_pp(TNode **pgoc, int v)
{
    TNode **pp = pgoc;

    while (*pp != NULL) {
        if      (v < (*pp)->data) pp = &(*pp)->left;
        else if (v > (*pp)->data) pp = &(*pp)->right;
        else                      return 1;
    }

    TNode *n = tn_tao(v);

    if (n == NULL) return -1;

    *pp = n;      /* ghi thẳng vào ô trống vừa tìm được */

    return 0;
}
Đệ quyLặp có chaLặp có pp
Số dòng thân hàm61813
Nhánh xử lý cây rỗngGộp vào cơ sởRiêngKhông có
Bộ nhớ ngăn xếpO(h)O(1)O(1)
Cần làm quen với con trỏ hai cấpKhôngKhôngCó

Chương trình thử đầy đủ

main.c
#include <stdio.h>

#include "bst.h"

int main(void)
{
    BST t;

    bst_khoi_tao(&t);

    int nguon[] = { 50, 30, 70, 20, 40, 60, 80, 30, 50 };

    for (size_t i = 0; i < sizeof nguon / sizeof nguon[0]; ++i) {
        int kq = bst_chen3(&t, nguon[i]);

        printf("chen %2d -> %s\n", nguon[i],
               kq == 0 ? "ok" : kq == 1 ? "da co" : "het bo nho");

        if (kq < 0) { bst_huy(&t); return 1; }
    }

    printf("so nut     : %zu\n", t.size);
    printf("chieu cao  : %d\n",  tree_chieu_cao(t.goc));
    printf("la BST     : %d\n",  la_bst(t.goc));

    printf("trung thu tu: ");
    trung_thu_tu(t.goc);
    printf("\n");

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

    bst_huy(&t);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g bst.c tree.c main.c -o t && ./t
chen 50 -> ok
chen 30 -> ok
chen 70 -> ok
chen 20 -> ok
chen 40 -> ok
chen 60 -> ok
chen 80 -> ok
chen 30 -> da co
chen 50 -> da co
so nut     : 7
chieu cao  : 2
la BST     : 1
trung thu tu: 20 30 40 50 60 70 80 
cay:
    ,-- 80
,-- 70
|   '-- 60
50
|   ,-- 40
'-- 30
    '-- 20
# 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

#Khóa trùng

Hợp đồng của hàm chèn
Phần tài liệu nói rõ hàm làm gì khi khóa đã tồn tại. Không có nó thì người gọi phải đoán, và mã của họ sẽ sai theo cách khó tìm.

Quy ước 1: bỏ qua khóa trùng

if (v == r->data) return r;      /* không làm gì, giữ nút cũ */

Quy ước 2: đếm số lần xuất hiện

Thêm trường đếm
typedef struct TNodeD {
    int             data;
    size_t          dem;         /* số lần khóa này được chèn */
    struct TNodeD  *left, *right;
} TNodeD;

int bstd_chen(TNodeD **pp, int v)
{
    while (*pp != NULL) {
        if      (v < (*pp)->data) pp = &(*pp)->left;
        else if (v > (*pp)->data) pp = &(*pp)->right;
        else { ++(*pp)->dem; return 0; }      /* đã có, chỉ tăng đếm */
    }

    TNodeD *n = malloc(sizeof *n);

    if (n == NULL) return -1;

    n->data = v;
    n->dem  = 1;
    n->left = n->right = NULL;
    *pp     = n;

    return 0;
}

Quy ước 3: cho trùng, đặt bên phải

Tự làm thử

  1. Cài bst_chen bản trả gốc mới, rồi cố tình quên gán kết quả một lần và giải thích hiện tượng nhận được.
  2. Cài cả ba bản chèn, kiểm rằng chúng dựng ra cây giống hệt nhau bằng tree_bang trên một nghìn dãy ngẫu nhiên.
  3. Cài bản dùng TNode **pp và đếm số nhánh if so với bản có biến cha.
  4. Cài quy ước đếm số lần xuất hiện, rồi dùng nó đếm tần suất từ trong một tệp văn bản.
  5. Chèn một trăm nghìn bản sao của cùng một khóa theo quy ước cho trùng bên phải, đo chiều cao và giải thích.

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

  • Mẫu trả về gốc mới cho bản chèn ngắn nhất, và nó là mẫu dùng lại cho xóa ở Bài 24.6 và cho AVL ở Bài 24.7.
  • Bản trả gốc mới không báo được hết bộ nhớ. Mã thật nên nhận TNode ** hoặc gói cây vào một struct.
  • Bản lặp tiết kiệm ngăn xếp và nhanh hơn khoảng hai mươi phần trăm, nhưng không cứu được vấn đề cây suy biến.
  • Mẹo TNode **pp xóa hẳn nhánh xử lý cây rỗng, đúng như nó đã làm với danh sách liên kết.
  • Phải chọn một quy ước cho khóa trùng và ghi rõ vào tài liệu. Thêm trường đếm thường là lựa chọn tốt nhất.