15、递归与树:二叉搜索树的查找、插入、删除(递归实现)

树结构,尤其是二叉搜索树,是我个人在C语言项目中用得最多的数据结构之一。为什么?因为它把递归的优雅展现得淋漓尽致。你想想看,一棵树的每个节点,本身又是一棵子树的根——这种自相似性,天然就是递归的舞台。

今天我们就来聊聊,怎么用递归搞定二叉搜索树的三个核心操作:查找、插入、删除。我会结合自己踩过的坑,给你讲明白。

二叉搜索树长什么样?

先简单回顾一下。二叉搜索树(BST)有个关键性质:

  • 左子树所有节点的值,都小于根节点
  • 右子树所有节点的值,都大于根节点
  • 左右子树本身也是二叉搜索树

说白了,就是中序遍历能得到一个递增序列。这个性质,让查找变得极其高效。

核心思想:每次比较都能排除一半的子树,平均时间复杂度 O(log n)。

下面我用一张图帮你理清递归操作的整体脉络:

二叉搜索树递归操作核心流程 根节点 查找 (search) 插入 (insert) 删除 (delete) 比较 → 相等则返回 小于则递归左子树 大于则递归右子树 找到空位 → 创建新节点 小于则递归插入左子树 大于则递归插入右子树 找到目标节点 三种情况处理 返回新子树根节点

先定义节点结构

写代码之前,先把数据结构定下来。我习惯这样定义:

typedef struct TreeNode {
    int data;
    struct TreeNode *left;
    struct TreeNode *right;
} TreeNode;

嗯,这里要注意,左右指针必须初始化成 NULL。我在项目中见过有人忘了初始化,结果递归时访问野指针,程序直接崩了。

递归查找:最直观的操作

查找的逻辑其实很简单。给定一个值,从根开始比较:

  • 相等?找到了,返回节点指针。
  • 小于当前节点?去左子树找。
  • 大于当前节点?去右子树找。
  • 走到 NULL?说明不存在,返回 NULL。

代码写出来是这样的:

TreeNode* search(TreeNode *root, int target) {
    if (root == NULL) {
        return NULL;  // 没找到
    }
    
    if (target == root->data) {
        return root;  // 找到了
    } else if (target < root->data) {
        return search(root->left, target);  // 去左边找
    } else {
        return search(root->right, target); // 去右边找
    }
}

你看,整个函数只有 10 行左右。递归的威力就在这里——逻辑清晰,代码简洁。

小技巧:我建议把递归终止条件写在最前面。这样阅读代码时,一眼就能看到「什么时候停下来」,逻辑更清楚。

递归插入:在正确的位置种下新节点

插入操作和查找很像。区别在于:查找是「找到了就返回」,插入是「找到了空位就放进去」。

具体思路:

  1. 如果当前节点是 NULL,说明找到空位了,创建新节点返回。
  2. 如果要插入的值小于当前节点,递归插入到左子树。
  3. 如果要插入的值大于当前节点,递归插入到右子树。
  4. 如果相等?嗯,这取决于你的设计。我一般不允许重复值,直接返回原节点。
TreeNode* insert(TreeNode *root, int value) {
    if (root == NULL) {
        TreeNode *newNode = (TreeNode*)malloc(sizeof(TreeNode));
        newNode->data = value;
        newNode->left = NULL;
        newNode->right = NULL;
        return newNode;
    }
    
    if (value < root->data) {
        root->left = insert(root->left, value);
    } else if (value > root->data) {
        root->right = insert(root->right, value);
    }
    // 相等时什么都不做,或者你可以选择覆盖
    
    return root;
}

这里有个关键点:递归调用的返回值要赋值给对应的子指针。为什么?因为当子树根节点发生变化时(比如子树为空时创建了新节点),父节点需要更新它的指向。

我曾经犯过一个错误:写了 insert(root->left, value); 却忘了接收返回值。结果插了半天,树一点没变。排查了好久才发现这个低级错误。

递归删除:最复杂的操作

删除操作是二叉搜索树里最麻烦的。为什么?因为删除一个节点后,你还得保持二叉搜索树的性质。

删除分三种情况:

情况 描述 处理方法
情况一 被删除节点是叶子节点(没有子节点) 直接删除,返回 NULL
情况二 被删除节点只有一个子节点 用子节点替换它
情况三 被删除节点有两个子节点 找右子树的最小节点(或左子树的最大节点)替换它

代码实现:

TreeNode* deleteNode(TreeNode *root, int target) {
    if (root == NULL) {
        return NULL;
    }
    
    // 先找到要删除的节点
    if (target < root->data) {
        root->left = deleteNode(root->left, target);
    } else if (target > root->data) {
        root->right = deleteNode(root->right, target);
    } else {
        // 找到了!处理三种情况
        
        // 情况一:叶子节点
        if (root->left == NULL && root->right == NULL) {
            free(root);
            return NULL;
        }
        
        // 情况二:只有一个子节点
        if (root->left == NULL) {
            TreeNode *temp = root->right;
            free(root);
            return temp;
        }
        if (root->right == NULL) {
            TreeNode *temp = root->left;
            free(root);
            return temp;
        }
        
        // 情况三:有两个子节点
        // 找右子树的最小节点
        TreeNode *minNode = root->right;
        while (minNode->left != NULL) {
            minNode = minNode->left;
        }
        // 用最小节点的值替换当前节点
        root->data = minNode->data;
        // 删除右子树中的那个最小节点
        root->right = deleteNode(root->right, minNode->data);
    }
    
    return root;
}

注意:情况三中,我们并没有真的删除当前节点,而是用右子树最小节点的值覆盖它,然后去删除那个最小节点。这样做的好处是:最小节点最多只有一个子节点(或者没有),删除起来简单多了。

一个完整的例子

光说不练假把式。我们来跑一个完整的例子:

#include <stdio.h>
#include <stdlib.h>

// ... 上面定义的 TreeNode 结构体和三个函数 ...

void inorder(TreeNode *root) {
    if (root != NULL) {
        inorder(root->left);
        printf("%d ", root->data);
        inorder(root->right);
    }
}

int main() {
    TreeNode *root = NULL;
    int values[] = {50, 30, 70, 20, 40, 60, 80};
    
    // 插入
    for (int i = 0; i < 7; i++) {
        root = insert(root, values[i]);
    }
    
    printf("中序遍历: ");
    inorder(root);  // 输出: 20 30 40 50 60 70 80
    printf("\n");
    
    // 查找
    TreeNode *found = search(root, 40);
    if (found) {
        printf("找到了: %d\n", found->data);
    }
    
    // 删除
    root = deleteNode(root, 50);
    printf("删除50后: ");
    inorder(root);  // 输出: 20 30 40 60 70 80
    printf("\n");
    
    return 0;
}

运行结果:

中序遍历: 20 30 40 50 60 70 80
找到了: 40
删除50后: 20 30 40 60 70 80

递归的代价与取舍

递归虽然优雅,但也不是没有代价的。每次递归调用都会消耗栈空间。如果树的高度很大(比如退化成链表),递归深度可能达到 n,导致栈溢出。

我个人在实际项目中的经验是:

  • 如果树是平衡的(高度约 log n),递归完全没问题。
  • 如果树可能退化成链表(比如插入有序数据),我会考虑用迭代实现,或者引入平衡机制(如 AVL 树、红黑树)。

不过话说回来,作为学习阶段,递归实现能帮你更好地理解树的结构。等你把递归思想吃透了,再去看迭代实现,会发现其实都是相通的。

总结一下:二叉搜索树的递归操作,核心就是「分而治之」。每次递归只处理当前节点,剩下的交给子递归。你只需要关注三件事:终止条件、当前节点的处理、递归调用的返回值处理。

好了,这一章的内容就到这里。代码你可以直接拿去跑,有问题欢迎交流。


公众号:蓝海资料掘金营,微信deep3321