ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二叉树进阶实战指南:BST增删查、AVL旋转、堆与哈夫曼编码

二叉树进阶实战指南:BST增删查、AVL旋转、堆与哈夫曼编码 上一篇咱们把二叉树的底子捋完了节点怎么定义、递归怎么建树、先序中序后序怎么走。但说句实在话光会这些实战里真不够用。面试题随手一拐就问“二叉搜索树的删除怎么办”课程设计里想用树存数据却不知道怎么保持性能刷题碰到的非递归遍历、层序、序列化更是让不少人直接发懵。这一篇咱们就把二叉树继续往下挖按实战顺序把这些进阶内容讲透二叉搜索树的完整增删查、AVL平衡旋转、用数组实现的堆与优先队列、非递归遍历和序列化最后再用哈夫曼编码练个手。适合谁看刚学完二叉树基础、准备面试或者做课程设计的朋友都可以直接读。代码思路我都按最稳妥的路线来写编译环境只要支持C99就行。上一篇文章提到的节点定义和递归遍历算是今天的前置知识心里有数再往下读效率会高不少。1. 二叉搜索树插入、查找、删除的完整实现1.1 为什么二叉搜索树是必学应用二叉搜索树简称BST是二叉树最经典的应用形态。它的规则只有一条左子树所有节点都小于根右子树所有节点都大于根左右子树自身也要满足这个规则。就这一条规则让普通的树变成了能“二分查找”的数据结构。从根出发目标值比当前节点小就往左比当前节点大就往右每一步都砍掉一半搜索空间思路和二分查找完全一致。我最近帮人审过的二叉树相关题目里十道有八道绕不开它。我拿日常场景打个比方图书馆按索书号排书。你找一本书不会从第一本翻到最后一本而是先看区域号再看书架号最后定位到某一行这就是搜索二叉树查找的过程。理解这个类比后面AVL的“为什么要保持平衡”就好懂了如果书架不按规则排所有书都歪到一排找书效率就退回线性扫描了。所以BST不是炫技它是把“有序数组的二分”移植到动态链表结构上的方案。1.2 核心代码查找、插入的实现节点的定义和普通二叉树完全一样一个key加左右孩子指针但操作思路完全不同。查找推荐写迭代版循环跳指针不容易递归爆栈typedef struct BNode { int key; struct BNode *left, *right; } BNode; BNode* bstSearch(BNode* root, int key) { while (root root-key ! key) { if (key root-key) root root-left; else root root-right; } return root; }迭代版的好处是栈空间恒定树退化严重时也不会打爆系统调用栈。返回结果就是目标节点找不到就返回NULL。注意这里的while条件和key的比较顺序很多人先判root为NULL再判key其实没有错但把root和key的判断合在一起写代码更紧凑。插入我反而建议写递归。原因是插入后要把新节点“挂”回原来的位置递归写法天然帮你完成挂接每一层递归返回子树新根你在上一层把它赋给left或right即可。BNode* createNode(int key) { BNode* n (BNode*)malloc(sizeof(BNode)); n-key key; n-left n-right NULL; return n; } BNode* bstInsert(BNode* root, int key) { if (root NULL) return createNode(key); if (key root-key) root-left bstInsert(root-left, key); else if (key root-key) root-right bstInsert(root-right, key); return root; }很多人第一次写BST插入会漏掉最后一行return root。这个返回值非常重要树里已有相同key时等于什么也不做原样返回创建了新节点后新节点沿着递归一层层回传并挂接。没有return root整棵树的指针结构会丢这是我见大家栽过最多的地方。另外如果树允许重复key工程上通常的做法是每个节点额外存一个count计数而不是搞出两个相同key的节点。1.3 删除的三种情况与易错点BST删除是所有操作里最磨人的一个拆开看就三种情况。第一种删叶子节点直接free并返回NULL。第二种只有一个孩子用孩子顶替自己free掉自己。第三种有两个孩子不能直接删得找一个“替身”继承位置。替身通常选右子树里的最小节点因为它是右子树中比root大的最小节点用它顶上来既能保持左小右大的顺序又不会破坏结构。| 删除情况 | 操作方式 | 注意点 | | 无孩子 | free(root)返回NULL | 最简单 | | 只有一个孩子 | 用孩子替换root | 记得先保留孩子指针 | | 有两个孩子 | 用右子树最小值覆盖root | 最小节点必无左孩子 |直接看代码实现BNode* findMin(BNode* root) { while (root root-left) root root-left; return root; } BNode* bstDelete(BNode* root, int key) { if (root NULL) return NULL; if (key root-key) root-left bstDelete(root-left, key); else if (key root-key) root-right bstDelete(root-right, key); else { if (root-left NULL) { BNode* tmp root-right; free(root); return tmp; } if (root-right NULL) { BNode* tmp root-left; free(root); return tmp; } BNode* min findMin(root-right); root-key min-key; root-right bstDelete(root-right, min-key); } return root; }这版代码建议直接背下来面试手写非常管用。两个关键点单孩子情况必须先存孩子指针再free不然free完孩子也找不到了双孩子情况用“值替换”而不是“指针替换”避免了父子节点重新接线的复杂逻辑。删除完成的返回值一层层赋回root-left或root-right整个操作就闭环了。但值替换有个副作用如果节点还存了其他字段比如卫星数据、计数、颜色标记等需要一起拷贝别只拷贝一个key。1.4 BST退化的原因如果插入顺序是严格递增的比如依次插入1、2、3、4、5BST会退化成一条单链表。查找复杂度从期望的O(log n)直接掉到O(n)。原因很直接每个新节点都只能作为右孩子接到末尾树的形态已经不再“平摊”。真实场景里如果数据有序到达不做处理BST的性能和线性表没区别。要解决这个问题核心思路是让树在插入和删除之后自修复保持左右子树高度相近这就是下一节AVL树引入的动机。2. AVL树让二叉树保持“劳逸结合”2.1 平衡因子与失衡形状AVL树给每个节点记录height即子树高度然后定义平衡因子BF 左子树高度 - 右子树高度。AVL要求任意节点的BF只能是-1、0、1。一旦插入或删除后某个节点的BF变成2或-2就需要旋转修复。旋转前要认清失衡形状LL、RR、LR、RL四种。LL表示左孩子的左子树过重表现为BF大于1且新节点插在左孩子的左子树用右旋修复。RR对称用左旋。LR表示左孩子的右子树过重需要先对左孩子左旋再对当前节点右旋。RL对称先右旋再左旋。我常用的记忆方法是名字就是失衡方向修复方向和失衡方向相反LL就向右转一下把多出来的那层往回收。| 失衡类型 | 判定 | 旋转操作 | | LL | 插入点在左孩子的左子树 | 右旋当前节点 | | RR | 插入点在右孩子的右子树 | 左旋当前节点 | | LR | 插入点在左孩子的右子树 | 先左旋左孩子再右旋当前节点 | | RL | 插入点在右孩子的左子树 | 先右旋右孩子再左旋当前节点 |2.2 旋转实现与高度更新顺序旋转的代码看起来就是几个指针互换但高度更新顺序经常被忽略。以右旋为例y是原根x是y的左孩子T2是x的右子树。旋转后y变成x的右孩子T2变成y的左子树。此时必须先把y的高度算好再更新x的高度因为x的新高度依赖y的高度。顺序反了算出来全是旧高度后面平衡因子判断全错。我第一次手写AVL就是在这里翻的车。typedef struct AVLNode { int key; int height; struct AVLNode *left, *right; } AVLNode; int getHeight(AVLNode* n) { return n ? n-height : 0; } int getBF(AVLNode* n) { return getHeight(n-left) - getHeight(n-right); } void updateHeight(AVLNode* n) { int l getHeight(n-left), r getHeight(n-right); n-height (l r ? l : r) 1; } AVLNode* rotateRight(AVLNode* y) { AVLNode* x y-left; AVLNode* T2 x-right; x-right y; y-left T2; updateHeight(y); updateHeight(x); return x; } AVLNode* rotateLeft(AVLNode* x) { AVLNode* y x-right; AVLNode* T2 y-left; y-left x; x-right T2; updateHeight(x); updateHeight(y); return y; }关注旋转里指针的操作顺序先保存T2再改x-right或y-left最后把y接到x的右侧或左侧。顺序错乱会导致节点丢失或者形成循环引用这是旋转代码里最常见的低级错误。写完旋转函数后建议立刻用只有三个节点的树测试一个根加两个子节点分别跑四种旋转眼睛观察节点挂接是否正确。2.3 插入后的四分支判断与删除的麻烦插入代码是在普通BST插入的基础上回溯时更新高度然后根据平衡因子分四支处理。AVLNode* avlInsert(AVLNode* root, int key) { if (root NULL) { AVLNode* n (AVLNode*)malloc(sizeof(AVLNode)); n-key key; n-height 1; n-left n-right NULL; return n; } if (key root-key) root-left avlInsert(root-left, key); else if (key root-key) root-right avlInsert(root-right, key); else return root; updateHeight(root); int bf getBF(root); if (bf 1 key root-left-key) return rotateRight(root); if (bf -1 key root-right-key) return rotateLeft(root); if (bf 1 key root-left-key) { root-left rotateLeft(root-left); return rotateRight(root); } if (bf -1 key root-right-key) { root-right rotateRight(root-right); return rotateLeft(root); } return root; }插入时判断LL还是LR可以直接拿插入的key和root-left-key比较因为新插入的点必然落在某个方向。但删除后判断就不同了被删的key可能已经不在树里你不能拿key去比只能实时看左子树和右子树的高度关系。如果左高右低再看左孩子的左右子树高度谁高高的那边就是插入点方向据此决定LL还是LR。删除的完整代码这里不展开核心就是“递归删除、回溯更新高度、发现失衡、判断形状、旋转”由于回溯路径上每个节点都可能失衡递归版天然支持从叶子到根一路修过去写起来反而比迭代版简单。3. 用数组存树堆与优先队列3.1 完全二叉树与数组下标映射堆是一种完全二叉树除了最后一层其他层必须填满最后一层的节点从左到右连续。因为形态固定直接用数组存储最合适。下标从1开始有一个大好处节点i的左孩子是2i右孩子是2i1父节点是i/2。举个例子小顶堆里heap[1]3heap[2]5heap[3]7那么2号节点的父节点是1号3号节点的父节点也是1号所有关系都能用一次整数除法或移位算出来。用数组存树省掉了指针内存紧凑、访问快、缓存命中率高。代价是只能存完全二叉树非完全结构一旦插入删除连续性就保不住。堆不关心节点之间关系的“形态”只关心堆序性父节点小于等于孩子就是小顶堆大于等于就是大顶堆。这句话理解透了建堆和调整操作的逻辑就顺了。3.2 上浮与下沉实现插入新元素时先把值放到数组末尾然后不断和父节点比较小于父节点就交换一路“上浮”到合适位置。删除堆顶时因为堆顶要移出去我们通常先把最后一个元素搬到堆顶再让这个值不断和较小的孩子比较一路“下沉”到合适位置。上浮和下沉是堆的两个核心原语理解了它们插入、删除、建堆、堆排序、TopK全是同一套东西。#define MAXN 100005 int heap[MAXN]; int size 0; void swapInt(int* a, int* b) { int t *a; *a *b; *b t; } void siftUp(int i) { while (i 1 heap[i] heap[i / 2]) { swapInt(heap[i], heap[i / 2]); i / 2; } } void siftDown(int i) { while (i * 2 size) { int child i * 2; if (child 1 size heap[child 1] heap[child]) child; if (heap[i] heap[child]) break; swapInt(heap[i], heap[child]); i child; } } void heapInsert(int v) { heap[size] v; siftUp(size); } int heapPopMin() { int minv heap[1]; heap[1] heap[size--]; siftDown(1); return minv; } void buildHeap() { for (int i size / 2; i 1; i--) siftDown(i); }建堆为什么从size/2开始而不是从1开始因为size/2是最后一个非叶子节点它之后全是叶子叶子本身已经是合法堆。从size/2往前逐个下沉每个非叶子节点都能把自己的子树调整成堆。建堆的总代价是O(n)很多人以为是O(n log n)其实仔细算每层节点的下沉次数累加起来是线性量级。这个结论面试里常被问到搞清楚原理比背结论靠谱。3.3 堆排序与TopK堆排序的思路很直白把数组建堆反复执行popMin弹出的顺序就是有序序列。整个过程可以在原数组上做不需要额外空间时间复杂度稳定O(n log n)。注意堆排序不稳定相同值的相对顺序可能变化需要稳定排序的场景别选它。TopK问题也是高频考点比如从海量数据里找最大的k个。维护一个大小为k的小顶堆新来的元素如果比堆顶大就把堆顶换掉再下沉。最后堆里留下的就是最大的k个。这个方案对“数据流”特别友好不用一次性把所有数据都加载进内存。我实际项目中写过类似场景日志数据流里截取热度最高的若干条按这个思路稳定跑。想验证TopK结果对不对直接排序后再取前k个做对照就行。4. 非递归遍历与序列化4.1 递归的缺陷与栈式写法递归遍历代码短、可读性好但有一个隐患树深度大时递归栈会爆。二叉搜索树退化成链表后递归遍历的操作系统栈很容易被打爆所以掌握非递归写法很有必要。先序遍历的栈写法最直白根先入栈出栈访问然后先压右孩子再压左孩子这样弹出顺序就是左孩子先被访问。中序遍历的思路稍微绕一点从当前节点开始一路把左孩子压栈直到NULL然后弹出访问再转向右孩子继续。这个过程用显式栈描述如下。void inorderIter(BNode* root) { // 需要配合一个链表实现的栈来使用 Node* stack NULL; BNode* cur root; while (cur ! NULL || !isEmpty(stack)) { while (cur ! NULL) { push(stack, cur); cur cur-left; } cur pop(stack); printf(%d , cur-key); cur cur-right; } }后序遍历的双栈法是我觉得最好记的第一个栈做前序变形入栈顺序改为根、左、右弹出后放入第二个栈。因为第二个栈后进先出最后访问顺序恰好是左、右、根。这个技巧第一次看像变魔术其实本质是“两次栈倒序”的性质。还有一种prev指针标记法不依赖双栈但逻辑绕一些新手容易写错建议先把双栈背熟。4.2 层序遍历与队列层序遍历本质是广度优先搜索。用队列保存待访问节点从根开始出队一个节点就访问它然后把左孩子、右孩子入队。队列的FIFO特性保证每一层按顺序访问完再进入下一层。层序的应用面很广判断完全二叉树可以按层序走遇到第一个空孩子之后后面不允许再出现非空节点求树的层数、打印“树的右视图”、锯齿遍历这些题底子全是层序。我在实际写代码时会把“访问节点”抽象成一个回调函数这样同一套层序逻辑可以复用在打印、收集、统计等不同场景。队列实现可以复用之前写好的链表队列但要注意节点指针入队不是复制节点否则内存管理会出大问题。4.3 序列化与反序列化树的序列化是把内存中的树变成字符串方便存文件和网络传输。最常用的是前序遍历加空标记遇到NULL输出#否则输出节点值用空格分隔。反序列化按同样顺序读先读根再递归读左子树然后递归读右子树遇到#就返回NULL。这套方法代码简洁能唯一重建二叉树。void serialize(BNode* root, FILE* out) { if (root NULL) { fprintf(out, # ); return; } fprintf(out, %d , root-key); serialize(root-left, out); serialize(root-right, out); } BNode* deserialize(FILE* in) { char token[16]; if (fscanf(in, %s, token) ! 1) return NULL; if (token[0] #) return NULL; BNode* root createNode(atoi(token)); root-left deserialize(in); root-right deserialize(in); return root; }有人问中序遍历序列化行不行不行。中序序列化后的字符串对应不了唯一一棵二叉树原因是前序序列的第一个元素一定是根中序序列里根的位置是未知的反序列化时无法确定从哪里分裂左右子树。工程上如果想更紧凑可以用层序序列化把空节点一并编码思路是一样的。序列化在缓存、分布式传输、数据持久化场景里很常用值得练手。5. 哈夫曼树从频率到编码5.1 哈夫曼构建的贪心逻辑哈夫曼树解决的问题是给定一组字符和出现频率构造一棵二叉树让高频字符拥有尽量短的编码从而压缩总体长度。构建方法是一个典型贪心策略每次从集合里取出两个权值最小的节点合并成一个新节点权值为两者之和放回集合重复直到只剩一个节点这个根就是哈夫曼树。举个例子字符A、B、C、D频率分别是7、5、2、4。先取2和4合并成6集合变成7、5、6再取5和6合并成11集合变成7、11最后取7和11合并成18。构建出来的树权值越大的节点离根越近编码越短权值越小的节点离根越远编码越长。这就是“高频短码、低频长码”的由来。5.2 优先队列版本实现C语言实现哈夫曼构建核心是配合最小堆存节点指针。堆里每个元素是节点指针比较的是weight字段。我直接在3.2节的堆代码上改一下把堆元素类型从int换成HNode*即可。typedef struct HNode { int weight; unsigned char ch; struct HNode *left, *right; } HNode; HNode* buildHuffman(HNode* nodes[], int n) { // 初始把nodes全部放入最小堆节点数sizen while (size 1) { HNode* a heapPopMin(); HNode* b heapPopMin(); HNode* parent (HNode*)malloc(sizeof(HNode)); parent-weight a-weight b-weight; parent-left a; parent-right b; heapInsert(parent); } return heapPopMin(); }得到哈夫曼树之后从根出发沿左子树写0沿右子树写1叶子到达时深度就是编码长度。给每个叶子生成编码串用递归遍历char code[100]; void genCode(HNode* root, int depth) { if (root NULL) return; if (root-left NULL root-right NULL) { code[depth] \0; printf(%c - %s\n, root-ch, code); return; } code[depth] 0; genCode(root-left, depth 1); code[depth] 1; genCode(root-right, depth 1); }这个递归把编码串当成字符数组用进入左子树写0进入右子树写1回溯时深度自然回退。有人担心数组越界只要树是正经构建的深度不会超过节点数给数组预留100字符在实际测试里足够用。如果要做真正的压缩系统就把code动态分配和叶子节点绑定存储。5.3 解码与前缀码特性哈夫曼编码是前缀码任何一个字符的编码都不是另一个字符编码的前缀。这意味着解码时从根节点出发按0走左、按1走右走到叶子就输出一个字符然后回到根继续。整个过程不需要额外分隔符不会出现歧义。很多初学者担心“连续的01怎么切分才正确”正是前缀特性保证了每一步切分都唯一。使用中还有几个容易忽略的点左右子树谁当左谁当右没有硬性规定权值正确情况下最终编码长度不变只是具体编码串不同压缩时如果字符很少或者频率很均匀哈夫曼编码带来的收益有限还必须把哈夫曼树结构或频率表一起存下来额外开销可能抵消压缩收益对于小数据直接定长编码反而更简单。这些在实际做文件压缩工具时都需要权衡课堂练习一般不会涉及。6. 常见问题与调试实录6.1 崩溃类问题二叉树相关的运行崩溃八成都出在指针上。最常见的就是空指针访问某次删除后父节点的孩子指针没有更新仍然指向已经free的内存下次遍历一路走进去就段错误。我实战里遇见过一次很隐蔽的版本删除中间节点后新根正确返回了但某个临时变量还持有旧地址后续代码误用了它一解引用就崩。排查这类问题经验是先打印树的遍历结果确认形态是否正确然后靠调试器在崩溃点位看具体是哪个指针值异常。递归爆栈也常见根因通常是树退化成链表深度等于节点数递归遍历几百上千层就能让系统栈非常紧张。如果测试数据会构造极端有序序列要么改用非递归遍历要么在上游就做好平衡。我给自己留了一个“统计最大深度”的小工具函数每次跑测试顺手看看深度超过节点数的一半就要警惕退化风险。6.2 逻辑类问题逻辑类问题比崩溃更隐蔽。错误一AVL高度没更新或者更新顺序反了。旋转后先更新原根再更新新根道理前面说过但手写时经常写顺了反过来。错误二堆下沉时child下标越界。要先判断child1是否在范围内再比较两个孩子大小不然会读到数组边界外的垃圾值。错误三递归释放树时只free了根节点没有先递归free左右子树造成一整棵子树的内存泄漏。| 现象 | 可能原因 | 排查手段 | | 段错误 | 父节点悬空指向已释放节点 | 打印中序序列确认形态 | | 高度计算乱 | 旋转后未按原根→新根顺序更新 | 打印每个节点BF | | 堆排序结果错 | child1边界判断缺失 | 打印数组序列检查堆序性 | | 内存泄漏 | 释放只处理根节点 | 检测工具跑完整用例 |内存泄漏在C语言树代码里尤其隐蔽。释放一棵树必须是后序递归先free左子树再free右子树最后free自己顺序不能颠倒。我习惯写一个destroyTree函数在测试末尾统一调用配合计数器确认节点数归零。这些手段虽然原始但比空想快得多。6.3 排查习惯与最后几句定位树的问题我最常用的是“暴力输出法”。中序遍历一下BST应该输出有序序列一旦发现顺序不对马上就能定位是哪一步破坏了规则。AVL则在每次插入后打印所有节点的平衡因子看看是否都在[-1,1]。堆就检查数组序列是否满足父子比较关系。这些手段都不高级但胜在直观比大脑空想快太多。最后分享一个个人习惯树这种数据结构画图永远比硬记代码有用。每次手写删除或旋转之前第一件事是把当前树画在纸上标好左右子树和高度然后再动手写代码。对着图几乎不会写错旋转方向也基本不会漏掉更新高度的逻辑。这个习惯帮我避开了很多次无谓的调试建议你下次写树代码之前也试试。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进