数据结构入门 (十):“左小右大”的秩序 —— 深入二叉搜索树

文章目录

引言:对"效率"的极致追求

在之前的探索中,我们已经掌握了多种数据结构。我们学习了数组,它能通过索引实现 O(1) 的随机访问,但前提是你知道索引;如果我们想在其中查找 某个特定值,只能从头到尾遍历,效率是 O(n)

后来我们学习了二分查找 ,它能将查找效率一举提升 O(log n)。但它有一个苛刻的前提:数据必须存储在有序的数组中 。这个前提,使得插入和删除操作变成了 O(n) ,因为你必须移动大量元素来维持秩序。

那么有没有一种"两全其美"的结构?它既能像二分查找那样高效(O(log n)),又能像链表那样灵活地插入和删除(O(log n))?

于是,我们利用二分查找的思想,与"树"的结构巧妙结合,创建了另一类至关重要的二叉树结构------二叉搜索树(BST) 。它通过一条规则------左子树所有节点小于根,右子树所有节点大于根,使得二叉树变得有序,查找、插入、删除等这些操作在平均情况下都达到对数级的时间复杂度。二叉搜索树是后续平衡二叉搜索树等更复杂结构的基础。

一、"左小右大":二叉搜索树的铁律

二叉搜索树,也常被称为二叉排序树。它之所以能实现高效查找,只因它在二叉树的基础上,坚定不移地执行着一条"铁律":

  1. 若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值
  2. 若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值
  3. 它的左右树也分别为二叉搜索树

这个递归的定义,赋予了整棵树一种全局的有序性。这种有序性,让我们在查找、插入、删除时,拥有了和二分查找一样的"决策能力"------在任何一个节点,我们都能通过一次比较,立刻知道下一步该向左走,还是向右走。

结构 查找元素 插入元素 删除元素
普通数组 O(n) O(n) O(n)
顺序数组 O(logn) O(n) O(n)
二叉搜索树 O(logn) O(logn) O(logn)

二、二叉搜索树递归实现

二叉搜索树的递归定义,天然适合用递归函数来操作。

1.结构设计与创建

首先,我们定义节点(BSNode)和树的"管家"(BSTree)。这个"管家"结构体(树头)不存储实际数据,而是负责维护 root 指针和 count 等元信息,这与我们之前实现链表的思想(头节点)一致。

c 复制代码
typedef int Element;
// 定义二叉搜索树的节点结构
typedef struct _bs_node {
    Element data;
    struct _bs_node *left;
    struct _bs_node *right;
} BSNode;

// 定义二叉搜索树的头节点(树头)
typedef struct {
    BSNode *root;
    int count;
    const char *name;
} BSTree;

BSTree* createBSTree(const char* name) {
    BSTree* tree = malloc(sizeof(BSTree));
    if (tree == NULL) {
        return NULL;
    }
    tree->name = name;
    tree->root = NULL;
    tree->count = 0;

    return tree;
}

2.插入:按"规矩"找到自己的位置

插入操作是BST递归思想的完美体现。我们提供一个对外接口 insertBSTree,它内部调用一个 static (私有) 的辅助函数 insertBSNode。BST插入必须通过值比较来确保结构的有序性。

c 复制代码
// 辅助函数:创建新节点
static BSNode *createBSNode(Element e) {
    BSNode *node = malloc(sizeof(BSNode));
    if (node == NULL) {
        return NULL;
    }
    node->data = e;
    node->left = node->right = NULL;
    return node;
}

// 辅助函数:递归插入的"引擎"
static BSNode* insertBSNode(BSTree *tree, BSNode *node, Element e) {
    // 1. 递归终止条件:找到了插入的空位
    if (node == NULL) {
        tree->count++;
        return createBSNode(e); // 创建新节点并返回
    }

    // 2. 递归查找:根据"左小右大"规则,决定向哪边走
    if (e < node->data) {
        // 递归插入到左子树,并更新左子树的链接
        node->left = insertBSNode(tree, node->left, e);
    } else if (e > node->data) {
        // 递归插入到右子树,并更新右子树的链接
        node->right = insertBSNode(tree, node->right, e);
    }
    // (如果 e == node->data,我们默认不做操作,保持树的唯一性)
    
    // 3. 返回当前节点(保持链接不变)
    return node;
}

// 对外接口:插入操作
void insertBSTree(BSTree* tree, Element e) {
    // 递归的"启动"
    tree->root = insertBSNode(tree, tree->root, e);
}

3. 遍历:天然的"排序器"

由于"左小右大"的铁律,当我们对二叉搜索树进行中序遍历 (左 -> 根 -> 右)时,会得到一个严格递增的有序序列。这不仅是验证BST正确性的绝佳手段,也是它"排序树"别名的由来。

c 复制代码
void visitBSNode(const BSNode* node) {
	printf("\t%d", node->data); // 打印节点值
}

static void inOrderBSNode(const BSNode *node) {
    if (node) {
        inOrderBSNode(node->left);  // 左
        visit(node);                // 根
        inOrderBSNode(node->right); // 右
    }
}

void inOrderBSTree(const BSTree *tree) {
    printf("[%s]Tree:", tree->name);
    inOrderBSNode(tree->root);
    printf("\n");
}

4. 查找:高效的"二分"

查找操作是非递归的,它完美地再现了"二分查找"的过程。

c 复制代码
BSNode* searchBSTree(const BSTree* tree, Element e) {
    BSNode *node = tree->root;
    while (node != NULL) {
        if (e < node->data) {
            node = node->left; // 目标更小,去左边找
        } else if (e > node->data) {
            node = node->right; // 目标更大,去右边找
        } else {
            return node; // 找到了!
        }
    }
    return NULL; // 遍历到底也没找到
}

5.树高:递归丈量"深度"

树的高度,是从根节点到最远叶节点的最长路径上的边数(或节点数,此处按节点数定义)。在二叉搜索树中,高度反映了树的"纵深"程度,也间接影响着查找、插入等操作的时间复杂度------理想情况下,树越"矮胖",效率越高;越"瘦高",越接近链表,性能退化。

计算树高天然适合递归:当前节点的高度,等于其左右子树高度的最大值加一。空节点高度为0,这是递归的边界条件。

c 复制代码
static int heightBSNode(const BSNode* node) {
    if (node == NULL) {
        return 0; // 空节点,高度为0
    }
    int leftHeight = heightBSNode(node->left); // 递归求左子树高度
    int rightHeight = heightBSNode(node->right); // 递归求右子树高度

    if (leftHeight > rightHeight) {
        return ++leftHeight;
    }
    return ++rightHeight;
}

int heightBSTree(const BSTree* tree) {
    return heightBSNode(tree->root);
}

6.删除:棘手的"重建"艺术

删除是BST中最复杂的操作,因为它必须在移除一个节点后,依然保持"左小右大"的铁律。我们同样使用递归实现,根据被删除节点的"度的数量"分三种情况讨论:

  • 度为 0 (叶子节点) :可以直接free,并让其父节点指向 NULL
  • 度为 1 (只有一个孩子):也比较简单,让其父节点"跳过"自己,直接指向自己的那个唯一孩子。
  • 度为 2 (左右孩子俱全) :最棘手。
    • 策略 :为了不破坏结构,我们不能直接删除它。我们从它的左子树 中,找到那个值最大 的节点(即它的"前驱"),或者从右子树 中找到值最小的节点(即它的"后继")。
    • 替换 :将这个"前驱"(或"后继")节点的值,复制到当前要删除的节点上。
    • 转化:转而去删除那个"前驱"(或"后继")节点。由于这个"前驱"(或"后继")节点一定是其子树中最边缘的,它自己的度必然只可能是0或1,这就把一个复杂问题转化成了我们能解决的情况1或情况2。
c 复制代码
// 辅助函数:在 node 的子树中找到值最大的节点
static BSNode* maxValueBSNode(BSNode *node) {
    while (node && node->right) {
        node = node->right;
    }
    return node;
}

static BSNode* deleteBSNode(BSTree* tree, BSNode *node, Element e) {
    if (node == NULL) {
        return NULL;
    }

	// 1. 递归查找
    if (e < node->data) {
        node->left = deleteBSNode(tree, node->left, e);
    } else if (e > node->data) {
        node->right = deleteBSNode(tree, node->right, e);
    } else {
    	// 2. 找到了, e == node->data,开始执行删除
        BSNode *tmp;
        if (node->left == NULL) { // 度为0或1
            tmp = node->right;
            free(node);
            tree->count--;
            return tmp; // 返回右孩子(或NULL)给上一层
        }
        if (node->right == NULL) { // 度为1
            tmp = node->left;
            free(node);
            tree->count--;
            return tmp; // 返回左孩子给上一层
        }
        // 此时说明待删除节点的度为2,替换当前节点值(后继或前驱)
        // 找这个节点的左节点的最大值
        tmp = maxValueBSNode(node->left);
        // 复制前驱的值到当前节点
        node->data = tmp->data;
        // 递归地从左子树中删除那个前驱节点
        // (注意:此时删除的 e 变成了 node->data,也就是 tmp->data)
        node->left = deleteBSNode(tree, node->left, node->data);
    }
    return node;
}

// 对外接口:删除操作
void deleteBSTree(BSTree* tree, Element e) {
    if (tree) {
        tree->root = deleteBSNode(tree, tree->root, e);
    }
}

7.销毁:"斩草除根"

对于二叉搜索树的销毁,我们需要先把左右子树删除,最后删除根节点。可以用后续遍历的方法。

c 复制代码
static void freeBSNode(BSTree *tree, BSNode *node) {
    if (node) {
        freeBSNode(tree, node->left); // 左
        freeBSNode(tree, node->right);// 右
        free(node);                   // 根
        tree->count--;
    }
}

void releaseBSTree(BSTree *tree) {
    if (tree) {
        freeBSNode(tree, tree->root);
        printf("There are %d nodes.\n", tree->count);
        free(tree);
    }
}

三、迭代实现:告别"栈溢出"的循环

递归虽然优雅,但在树极深的情况下可能导致栈溢出。使用循环(迭代)是更稳健的选择。

1.非递归插入

非递归插入的思路,就是用一个 pre 指针始终"尾随" cur 指针,以便在 cur 找到空位时,pre 能执行链接操作。

c 复制代码
void deleteBSTree(BSTree* tree, Element e) {	
	BSNode *cur = tree->root;
	BSNode *pre = NULL;
	while (cur) {
	    pre = cur;
	    if (e < cur->data) {
	        cur = cur->left;
	    } else if (e > cur->data) {
	        cur = cur->right;
	    } else {
	        return;
	    }
	}
	// 可能插入的是第一个根元素,可能插入的是一个普通的位置
	BSNode *node = createBSNode(e);
	if (pre) {
	    if (e < pre->data) {
	        pre->left = node;
	    } else if (e > pre->data) {
	        pre->right = node;
	    }
	} else {
	    tree->root = node;
	}
	tree->count++;

2.非递归删除

非递归删除是所有操作中最复杂的,因为它需要处理的指针关系和边界情况最多。我们将递归中的三种情况(度为0、1、2)在迭代中实现。

核心思路

  1. 查找:首先,我们必须通过循环找到要删除的节点 cur,并且始终要保留其父节点 pre 的指针。
  2. 处理:根据 cur 的度(0, 1, 2)执行不同的逻辑。
c 复制代码
// 辅助函数:度为2时进行的操作
// 策略:找到 cur 的后继节点(右子树中最小的节点),用其值覆盖 cur,然后转化为删除那个后继节点(后继节点度必为0或1)
static void deleteMiniNode(BSNode *node) {
	// 1. 查找后继节点及其父节点
    BSNode *mini = node->right;
    BSNode *pre = node;
    while (mini->left) {
        pre = mini;
        mini = mini->left;
    }
    // 2.转化为删除后继节点
    if (pre->data == mini->data) {
        pre->right = mini->right;
    } else {
        pre->left = mini->right;
    }
    // 3.将后继节点的值 复制 到当前节点
    node->data = mini->data;
    free(mini);
}

void deleteBSTree(BSTree* tree, Element e) {
	// 查找要删除的节点(cur)及其父节点(pre)
    BSNode *node = tree->root;
    BSNode *pre = NULL;

    while (node) {
        pre = node;
        if (e < node->data) {
            node = node->left;
        } else if (e > node->data) {
            node = node->right;
        } else {
            break;
        }
    }

    if (node == NULL) {
        printf("No %d element!\n", e);
        return;
    }
    
    BSNode *tmp = NULL;
    if (node->left == NULL) {
        tmp = node->right;
    } else if (node->right == NULL) {
        tmp = node->left;
    } else {
        deleteMiniNode(node->right);
        --tree->count;
        return;
    }
    if (pre) {
        if (node->data < pre->data) {
            pre->left = tmp;
        } else {
            pre->right = tmp;
        }
    } else {
        tree->root = tmp;
    }
    free(node);
    --tree->count;
}

四、总结:秩序的代价

今天,我们掌握了二叉搜索树------这个为了兼顾"查找"与"增删"效率 而生的精妙结构。它依靠"左小右大"的铁律,在平均情况下为我们提供了 O(log n) 的全能表现。

然而,这种秩序是有"代价"的。

请思考一个问题:如果我们按顺序 (1, 2, 3, 4, 5)向一棵空的BST中插入数据,会发生什么? 1 成为根,2 成为 1 的右孩子,3 成为 2 的右孩子... 这棵树会"退化"成一条链表

在这种最坏情况下,BST的所有操作都将退化回 O(n) 的复杂度。我们费尽心机构建的"秩序",反而成了我们的枷锁。

如何让这棵树在插入时保持"平衡",永不退化?这就是"自我平衡"的艺术,也是我们下一篇文章的主题:AVL树

相关推荐
努力学算法的蒟蒻2 小时前
day11(11.11)——leetcode面试经典150
算法·leetcode·面试
im_AMBER2 小时前
Leetcode 51
笔记·学习·算法·leetcode·深度优先
做怪小疯子3 小时前
LeetCode 热题 100——哈希——字母异位词分组
算法·leetcode·哈希算法
Einsail3 小时前
贪心算法,优先队列(大小根堆使用)
算法·贪心算法
小欣加油3 小时前
leetcode 474 一和零
c++·算法·leetcode·职场和发展·动态规划
Ace_31750887763 小时前
京东商品详情接口深度解析:从反爬绕过到数据结构化重构
数据结构·python·重构
旭意4 小时前
数据结构顺序表
数据结构·c++·蓝桥杯
码银4 小时前
【数据结构】单链表核心知识点梳理
数据结构
一只老丸4 小时前
HOT100题打卡第36天——二分查找
数据结构·算法