二叉搜索树节点删除详解:递归与非递归实现及排错指南
二叉搜索树的节点删除几乎是C语言数据结构学习里最容易让人卡壳的操作。链表删除节点很简单改一下前驱的next指针就行数组删除元素也很直接后面往前覆盖。但二叉搜索树的删除不一样删完之后树还得是一棵二叉搜索树节点的相对顺序不能被破坏。很多初学者能做到查找和插入写得很顺一到删除就翻车根节点删没了、指针悬空了、中序遍历乱序了各种问题。我当年也是在这上面踩了不少坑。花了一晚上把几种情况画在纸上又用完整可运行的代码反复测试才算真正吃透。回头看BST节点删除这个点其实是检验你对递归、指针、边界条件这三样东西掌握程度的试金石。这篇文章不聊虚的就从原理到实现从递归到非递归从测试到排错一步一坑地讲清楚。1. 节点删除在二叉搜索树里的地位为什么它比插入查找难一个量级1.1 删除操作难在哪里插入和查找是同类活本质上都是沿着树边走边比较。插入在空位上挂一个新节点查找只是读不写。但删除不一样它要求你在拆掉一个节点之后仍然保证树满足二叉搜索树的核心性质对于任意节点左子树所有节点的值小于它右子树所有节点的值大于它。这个性质用中序遍历看最直观。一棵二叉搜索树的中序遍历结果一定是一个递增序列。插入一个节点、查找一个节点都不会破坏这个序列。而删除一个节点相当于要从这个递增序列里拿掉一个值同时还得保证剩下的值仍然能组成一棵合法的二叉搜索树。麻烦就麻烦在这里。删除的节点可能没有孩子、可能有一个孩子、也可能有两个孩子三种情况处理方式完全不同。两个孩子的节点被删之后它的左孩子和右孩子都还在这俩分支得有一个新的“领导者”顶上而且顶上的这个值还必须能镇住左子树的最大值和右子树的最小值。1.2 读懂这篇文章你能得到什么这篇内容适合正在学C语言数据结构的在校生、准备面试的求职者也适合工作里要用C语言写底层逻辑、对树结构还不够熟的开发者。我会先把基础的结构体和查找、插入过一遍再重点拆解删除的三种情况然后给出递归版和非递归版两套可直接运行的实现。测试和排错部分会带上我实际踩过的坑比如递归返回值没接住、释放节点后指针悬空、双孩子删除时覆盖顺序写反等问题。2. 打好地基结构体、查找、插入与中序遍历2.1 结构体设计与辅助函数二叉搜索树的节点在C语言里最常见的定义方式是用结构体加指针。每个节点存一份数据然后指向左孩子和右孩子typedef struct Node { int data; struct Node *left; struct Node *right; } Node;有人习惯写struct TreeNodeterm加上 typedef有人直接用 Node看个人偏好。我习惯用短一点的 Node因为后面写指针和递归函数时长名字会让代码看起来冗长。建节点的函数也需要提前备好。每次插入新节点都得先向堆上申请一块内存Node *createNode(int value) { Node *node (Node *)malloc(sizeof(Node)); node-data value; node-left NULL; node-right NULL; return node; }这里有一个很多教程提得不够透的点node-left NULL和node-right NULL必须初始化。malloc 分配出来的内存里的值是随机的残留数据如果不置空后面遍历时你不知道什么时候会遇到一个野指针程序直接崩溃。这种问题很难一眼看出来因为崩溃的时机并不一定是分配内存的时刻。二叉树里找最小值、最大值也是后面的关键操作。左子树一直往左走就是最小值右子树一直往右走就是最大值Node *findMin(Node *root) { if (root NULL) { return NULL; } while (root-left ! NULL) { root root-left; } return root; }这里用循环实现就够了不需要递归因为路径是唯一的一直往一个方向走就行。2.2 递归插入为删除操作做铺垫插入函数是递归思想在树结构上的第一次实战。它的关键设计是函数的返回值是插入完成后这颗子树的根节点。调用者把这个返回值接到自己的左或右孩子指针上Node *insert(Node *root, int value) { if (root NULL) { return createNode(value); } if (value root-data) { root-left insert(root-left, value); } else if (value root-data) { root-right insert(root-right, value); } return root; }有些教程把插入写成一个传入二级指针、返回void的版本比如void insert(Node **root, int value)。两种风格都能用但我更推荐返回值版本原因很简单它和后面要写的递归删除函数在对接上非常顺。删除也是同样的模式返回子树新根让上层调整指针。如果你能先理解插入里“返回值不断向上一层传递”的过程删除中就成功了一半。2.3 中序遍历验证二叉树性质的最直观工具中序遍历左→根→右在二叉搜索树里有着特殊地位。一棵BST的中序遍历结果一定是严格递增的。反过来说如果删除完节点之后中序遍历不再是递增序列说明删除操作出错了。void inorder(Node *root) { if (root NULL) { return; } inorder(root-left); printf(%d , root-data); inorder(root-right); }我这里强调中序遍历是因为它在排错时特别好用。删完节点一棵中序遍历打出来如果有序大概率树结构是对的如果中间出现逆序通常是指针接错了嵌套关系被破坏了。这个习惯我一直保持到现在。3. 删除节点三种情况思路决定代码3.1 情况一目标节点是叶子节点叶子节点没有左孩子也没有右孩子删起来最省心。直接释放掉这块内存然后把父节点指向它的指针置为NULL就行。不过这里面有一个细节需要留意在递归写法中你并不需要显式地找到父节点。你只要在递归回溯时返回NULL给上一层上一层自然会把这个NULL接到自己的孩子指针上。换句话说叶子节点的父节点处理逻辑被递归的返回值“隐式”完成了。打个比方这就像你把一个货架上的盒子拿走之后让管理员在原位置贴上“本位置空缺”的标签。管理员不需要知道是哪个货架只需要知道这个位置空了。3.2 情况二目标节点只有一个孩子假设目标节点只有左孩子没有右孩子或者只有右孩子没有左孩子。这种情况下直接让唯一的那个孩子顶上自己的位置。从链表的角度理解就是删掉当前节点之后让它的孩子接替自己继续保持父节点与孙节点的连接。这里有个很容易犯的错有人会先释放当前节点再去取它的孩子指针。这等于先砸了招牌再问地址取到的已经是野指针。正确做法是先保存孩子指针再free当前节点最后返回孩子指针。if (root-left NULL) { Node *temp root-right; free(root); return temp; } if (root-right NULL) { Node *temp root-left; free(root); return temp; }这两个if看起来像是在判断“左孩子为空”和“右孩子为空”。仔细想一下执行到这里的时候目标节点至少有一个孩子是NULL所以这两个分支能完整覆盖情况二。那如果是叶子节点呢叶子节点的左右孩子都是NULL走第一个if分支返回NULL刚好也覆盖了情况一。所以情况一和情况二可以在代码里合并处理很多教材也是这样写的。3.3 情况三目标节点有两个孩子这是删除里唯一让人头疼的场景。目标节点左右孩子都在你不能简单地用某一个孩子去顶替因为无论选左孩子还是右孩子都会破坏左小右大的结构关系。标准解法有两种找前驱或者找后继。前驱左子树中最大的节点后继右子树中最小的节点这两个节点都有一个特点它的值跟目标节点的值挨得最近。用前驱或后继的值覆盖目标节点的值然后再去删除那个前驱或后继节点这样整棵树的二叉搜索性质不会变。我通常在代码里选后继右子树一直往左走找到最小的那个。这个选择不是唯一的但用起来很顺手。注意后继节点一定没有左孩子。因为它已经是右子树里的最小值了如果它还有左孩子那个左孩子的值会更小。这个性质特别关键它保证接下来删除后继节点时最多只会遇到情况二只有一个右孩子不会陷入“又要处理双孩子”的死循环。4. 递归版删除最优雅也最容易出错的实现4.1 递归删除的核心设计递归版删除函数的核心是让每个递归层都返回“子树删除完成后的新根”。当value等于当前节点的值时就找到了目标节点如果value小于当前节点值说明目标在左子树把左子树递归删除的结果接回root-left如果value大于当前节点值右子树同理。完整代码如下Node *deleteNode(Node *root, int value) { if (root NULL) { return NULL; } if (value root-data) { root-left deleteNode(root-left, value); } else if (value root-data) { root-right deleteNode(root-right, value); } else { if (root-left NULL) { Node *temp root-right; free(root); return temp; } if (root-right NULL) { Node *temp root-left; free(root); return temp; } Node *temp findMin(root-right); root-data temp-data; root-right deleteNode(root-right, temp-data); } return root; }这段代码的巧妙之处在于它通过返回值把“父节点指针如何更新”这个复杂问题从函数里抹掉了。每次递归返回时上一层的调用点写成root-left deleteNode(...)或者root-right deleteNode(...)于是不管叶子节点返回NULL、单孩子节点返回孩子指针还是双孩子节点返回调整后的root上一层都能正确地完成指针重连。4.2 完整可运行代码与测试效果下面是一份可以直接编译运行、用于验证删除逻辑的完整程序。测试树是50 / \ 30 70 / \ / \ 20 40 60 80#include stdio.h #include stdlib.h typedef struct Node { int data; struct Node *left; struct Node *right; } Node; Node *createNode(int value) { Node *node (Node *)malloc(sizeof(Node)); node-data value; node-left NULL; node-right NULL; return node; } Node *insert(Node *root, int value) { if (root NULL) { return createNode(value); } if (value root-data) { root-left insert(root-left, value); } else if (value root-data) { root-right insert(root-right, value); } return root; } Node *findMin(Node *root) { while (root-left ! NULL) { root root-left; } return root; } void inorder(Node *root) { if (root NULL) { return; } inorder(root-left); printf(%d , root-data); inorder(root-right); } Node *deleteNode(Node *root, int value) { if (root NULL) { return NULL; } if (value root-data) { root-left deleteNode(root-left, value); } else if (value root-data) { root-right deleteNode(root-right, value); } else { if (root-left NULL) { Node *temp root-right; free(root); return temp; } if (root-right NULL) { Node *temp root-left; free(root); return temp; } Node *temp findMin(root-right); root-data temp-data; root-right deleteNode(root-right, temp-data); } return root; } int main(void) { Node *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); printf(\n); root deleteNode(root, 50); printf(删除50后: ); inorder(root); printf(\n); root deleteNode(root, 30); printf(删除30后: ); inorder(root); printf(\n); root deleteNode(root, 20); printf(删除20后: ); inorder(root); printf(\n); root deleteNode(root, 99); printf(删除不存在的99后: ); inorder(root); printf(\n); return 0; }运行效果初始中序遍历: 20 30 40 50 60 70 80 删除50后: 20 30 40 60 70 80 删除30后: 20 40 60 70 80 删除20后: 40 60 70 80 删除不存在的99后: 40 60 70 80删除50之后60顶上删除30之后40顶上删除20之后直接少一个叶子。每次删除完成后中序遍历依然有序。4.3 递归返回值最容易踩的三个坑第一个坑主函数里调用删除时没有把返回值接住。如果你写deleteNode(root, 50);而不是root deleteNode(root, 50);那删除的如果是根节点返回的新根压根没人接收外层的root还指着已经被free掉的旧根继续访问就是野指针问题。第二个坑在递归内部找到了目标节点但忘记把子树递归删除的结果接回原指针。也就是说处理完目标节点后直接return root;左子树或右子树的删除结果丢掉了等于白删。第三个坑双孩子分支中覆盖值和递归删除的顺序反了。正确顺序是先用后继的值覆盖当前节点值再去删除后继如果反着来可能出现找不到删除目标的情况。因为这时候当前节点的值已经被改成目标值了但树里又同时存在两个相同值逻辑就会乱。5. 非递归版删除摆脱栈空间限制的写法5.1 非递归删除的思路与难点递归版好写但有两个潜在问题。第一递归深度受栈空间限制。极端情况下BST退化成一条链递归深度等于节点数量几万层递归就可能爆栈。第二如果你在公司里维护的是嵌入式或者内核代码很多场景禁止递归必须用迭代。非递归版的核心难点在于你自己必须手动维持“父节点”的引用。因为删除节点时改的是父节点的子指针。遍历树找目标节点时得同时记住它的父节点是谁。另外处理根节点时父节点为NULL删除根节点不能去更新一个不存在的父节点而是要直接更新树根指针。这里有两种实现思路一种是把树根指针声明为Node *root在函数里用二级指针Node **root来修改另一种是用一个父指针变量加一个方向标记。二级指针的方式更C风格。5.2 非递归删除完整代码void deleteNodeIterative(Node **root, int value) { Node *current *root; Node *parent NULL; while (current ! NULL current-data ! value) { parent current; if (value current-data) { current current-left; } else { current current-right; } } if (current NULL) { // 没找到目标节点 return; } if (current-left ! NULL current-right ! NULL) { // 双孩子找右子树的最小节点后继 Node *succParent current; Node *succ current-right; while (succ-left ! NULL) { succParent succ; succ succ-left; } current-data succ-data; if (succParent-left succ) { succParent-left succ-right; } else { succParent-right succ-right; } free(succ); } else { // 单孩子或叶子 Node *child (current-left ! NULL) ? current-left : current-right; if (parent NULL) { *root child; } else if (parent-left current) { parent-left child; } else { parent-right child; } free(current); } }这段代码乍看不长但细节不少。双孩子分支里succParent初始化为current这一点很关键。如果后继恰好就是当前节点的右孩子那while (succ-left ! NULL)不会进入循环succParent保持为current。接下来判断succParent-left succ由于后继是当前节点的右孩子这个条件不成立走succParent-right succ-right把当前节点的右孩子指向后继的右孩子。这正好符合预期。如果进入循环说明后继在右子树的更下层此时succParent会更新为后继的父节点。这时候succ一定是succParent的左孩子所以走的是succParent-left succ-right这个分支。5.3 递归版和非递归版怎么选对比维度递归版非递归版代码量少逻辑直观多需手动维护父节点栈空间受递归深度限制无额外栈开销可读性好与树的递归结构天然契合相对差分支细节多出错风险返回值容易漏接父节点关系和left/right方向容易写错适用场景学习算法思想、普通应用开发嵌入式、内核、对栈空间敏感的场景我的建议是学习阶段先吃透递归版因为递归版把树的递归本质暴露得更清楚。工作里如果需要写非递归版再把上面这段代码照着思路改。6. 内存管理与指针操作这里写错就是半夜调bug的命6.1 malloc 与 free 的基本盘C语言的二叉搜索树每个节点都是malloc来的删除节点的时候必须free。这是基本要求。一个常见的坏习惯是只在删除时free却忘了在程序结束前把整棵树释放干净。虽然现代操作系统会在进程退出时回收全部内存但如果你的程序会长期运行反复增删节点泄漏的节点会越攒越多内存占用持续上涨。释放整棵树的逻辑和后序遍历很相似先释放两个孩子再释放自己void freeTree(Node *root) { if (root NULL) { return; } freeTree(root-left); freeTree(root-right); free(root); }注意顺序不能反过来。如果你先free(root)再递归释放孩子那递归访问的root-left和root-right已经是悬空指针行为未定义。6.2 悬空指针的隐蔽后果删除节点后如果还有人握着指向那块内存的指针这个指针就成了悬空指针。悬空指针最坑的地方在于那块内存可能还没被系统回收里面的data字段还能读出一个看似合理的数字。于是你的程序在一段时间内表现完全正常直到有新的malloc复用了这块内存数据被覆盖程序才开始莫名其妙地崩溃。我曾经在调试一个任务调度器时遇到过类似问题。删除树节点后某个地方还缓存着指向节点的指针查了下一天时间。排查手段是用编译器自带的地址消毒器编译时加-fsanitizeaddress参数程序一旦访问到已释放内存运行时立刻报错问题当场暴露。6.3 valgrind 与常见内存错误速查在Linux上valgrind是检测内存错误的主力工具gcc -g -o bst bst.c valgrind --leak-checkfull ./bst如果输出里有definitely lost或者Invalid read、Invalid free这类信息基本可以断定涉及堆内存的非法操作。编译时加-g是必须的否则valgrind只能给你一串地址没法精确定位到源码行。除了valgrind地址消毒器更适合日常开发环境因为它不用虚拟化跑起来快很多。给编译器加上-fsanitizeaddress之后运行程序时如果访问了已释放内存程序会立即打印错误位置并终止。7. 测试用例与排查技巧实录7.1 测试用例怎么设计才不白测写测试前先想清楚有哪些分支要走。二叉搜索树删除至少覆盖以下情况测试目标删除的值预期效果叶子节点2030的左指针变为NULL叶子节点4030的右指针变为NULL只有右孩子的节点30测试树中去掉2040顶上只有左孩子的节点30测试树中去掉4020顶上双孩子节点50后继60覆盖然后删除60根节点50树根被替换为60不存在节点99树结构不变删空整棵树依次删全部节点树根最终为NULL还有一种很实用的做法把随机值插入BST构建一棵相对随机的树再随机删除一些节点。每次删除后都中序遍历一次验证是否递增用断言或者手写检查函数来判定。这种方法我自己写测试时常用比手列测试用例覆盖得更全面。7.2 常见的错误和排查手段现象可能原因解决方向删除后中序遍历出现逆序双孩子节点删除后结构没处理好或者接错孩子指针画出删除前后的树形结构重新走一遍递归逻辑程序崩溃退出码139访问了空指针或悬空指针用-fsanitizeaddress编译定位非法访问的代码行删除的节点还在递归返回值没有接住或者删除路径没找到目标值检查是否写了root deleteNode(root, value)内存越用越多删除时漏了free或者整棵树未释放valgrind的--leak-checkfull看具体泄漏位置删除双孩子节点后丢失子树后继节点的右子树没有正确接到父节点上重点检查succParent-left succ-right或succParent-right succ-right的分支逻辑最后一个错误是我见过最多的。比如删除50时正确做法是用60覆盖50再把60原来的位置让给60的右孩子如果有的话。有人会错误地写current-right deleteNode(current-right, succ-data)这其实是对的递归写法但有人写成current-left deleteNode(current-right, succ-data)把整个右子树接到左孩子上去了树的形态直接乱套。7.3 我调试这类问题的一个习惯如果是递归版删除出了问题我经常直接在纸上画出目标节点的左右子树结构然后手动模拟一次递归调用过程。把每一层递归进入时的root指向哪个节点、走到哪个分支、返回给上一层什么值一行一行写下来。很多时候问题就在这个模拟过程中自己蹦出来了。如果是非递归版建议在每个分支的关键位置加打印输出parent-data、current-data、succ-data看看指针关系是否符合预期。这种土办法比盯着代码发呆效率高得多。8. 删除之后的再思考从BST延伸到更复杂的树结构8.1 为什么删除双孩子节点用值覆盖而不是结构拼接有人会问既然后继节点是右子树的最小值为什么不能直接把当前节点的左子树接到后继节点的左孩子上然后让后继顶替当前节点这么做在不少情况下也能保持有序性但问题在于后继节点可能本身就有右子树而且它的原父节点连接关系也得处理。相比之下先用值覆盖、再删除后继节点的方案更简洁、更安全而且删除后继时最多只涉及一个右孩子逻辑清晰。这个思路在很多地方都会复用。比如数据库的B树删除同样需要处理“合并”“借位”等复杂情况核心思想也是一样的删除后必须保证数据结构的关键性质不被破坏。BST的关键性质就是中序遍历有序。8.2 从BST到AVL树、红黑树BST删除不要求平衡修正这是它简单的地方。但极端的输入序列可能让BST退化成链表查找、删除都变成O(n)。AVL树和红黑树就是在BST基础上增加平衡约束。AVL树的删除非但不简单反而比插入更麻烦。插入最多一次旋转就能恢复平衡删除可能导致多个祖先失衡需要从被删位置一路向上检查并旋转。写AVL删除时BST删除的递归框架可以先抄过来然后在此基础上补充平衡因子更新和旋转逻辑。红黑树的删除就更复杂了涉及颜色的修复甚至需要处理“双黑”问题。很多公司面试时会把红黑树删除当作压轴题其实真正把BST删除的指针操作练熟了理解红黑树的删除修复过程会顺畅很多。8.3 学习建议如果你正在学这块我给你两条建议。第一不要只看代码。找张纸亲手画一棵有七八个节点的BST模拟删除每一个节点把每种情况的指针变化画出来。我当年就是这么干的画完三棵树的删除过程递归版代码几乎不用背直接水到渠成。第二把递归版代码敲出来之后一定要用测试用例跑一遍不只是编译通过就完事。把中序遍历打印加进去每次删除后都看一遍输出是否有序。我见过太多人只盯着代码看不看运行结果结果代码写了一大半全是细节错误。我个人在实际操作中的体会是二叉搜索树的节点删除表面上是考你代码写没写对实际上是考你有没有想清楚“删除后性质怎么保持”这件事。后面的AVL、红黑树、B树本质上都在重复回答同一个问题数据结构变了性质是什么怎么在动态操作中维持住它。把BST删除这个点搞透你得到的其实是一整套分析树结构操作的方法论。