
写二叉树程序时为什么总是报运行时错误这个问题的搜索热度一直居高不下我当年初学C时也在这上面摔过不少跟头。后来回头总结绝大多数报错原因其实很朴素指针没初始化就拿来用了、递归边界写错了导致无限递归、内存释放之后还在访问诸如此类。二叉树这门课表面看是学一种数据结构实际上练的是两件事——递归思维以及彻底搞懂指针的生命周期。这篇学习整理不打算讲空洞的理论而是把二叉树和它的一种重要变体——线索二叉树——从头到尾梳理一遍。内容覆盖二叉树的C实现、遍历方法、深度计算再到线索二叉树的动机、原理、完整实现和常见的调试错误。适合正在上数据结构课的学生、准备面试刷题的开发者以及所有想把C指针和递归彻底弄明白的人。文章里所有思路都是我在学习和写代码过程中反复验证过的有些坑会特别标注出来希望帮你省掉一些弯路。1. 从“运行时错误”说起先把最劝退的一环解决掉不少新手在写二叉树代码时最崩溃的不是概念不懂而是程序编译能过一运行就崩。而且崩得毫无规律有时跑两次崩一次换个输入又崩非常痛苦。先花点篇幅把这类问题拆透因为后面实现线索二叉树时如果基本功不扎实报错会更隐蔽。1.1 最常见的五类运行时错误按照我见过的频率排序二叉树程序里的运行时错误基本来自这五种情况未初始化指针就使用。定义一个节点指针后没有赋初值直接拿来判断或解引用。例如Node* p; if (p nullptr) { ... } // 行为不确定 p-data 1; // 大概率崩溃C里局部指针变量如果没有初始化值是随机的不是nullptr。判断它是否为空本身就没意义更要命的是直接对随机地址解引用段错误就是这么来的。正确的做法是声明指针时就初始化为nullptr或者用new分配后马上赋值。解引用nullptr。典型的场景是递归遍历时某个节点子节点为空但代码没判断就访问。比如void preOrder(Node* root) { visit(root); // root可能为nullptr preOrder(root-left); preOrder(root-right); }修复方式很简单进入函数先判断root是否为nullptr或者把root-left、root-right传入前先检查。递归边界写错导致无限递归。栈溢出时程序会报segmentation fault很多人以为是指针问题其实是递归没有出口。典型的错误是边界条件写错位置比如应该在递归调用前判断结果写成了递归调用后判断。还有一种是把root nullptr写成root-left nullptr如果root本身为空访问root-left又会崩溃两个问题叠加。悬空指针与重复释放。这是C里最隐蔽的坑。两个指针指向同一块堆内存其中一个被delete后另一个还持有原来的地址再去访问就是undefined behavior。更常见的是同一块内存被delete两次会触发double free错误。二叉树结构天然存在多指针共享结点的场景父节点的指针、遍历用的临时指针都指向同一个结点所以特别容易踩到这个坑。递归深度过大栈空间耗尽。二叉树如果长得跟链表一样比如一直往左插入递归深度就是节点数。几万个节点的树递归可能会把默认的栈空间撑爆。这不算写法错误但属于运行时错误的一种后面聊深度时会详细说。1.2 学习二叉树真正要练的东西把这些错误归拢一下你会发现学习二叉树的核心其实不是“记住遍历顺序”而是两件事第一递归思维。二叉树的定义本身就是递归的一个节点左边是一棵树右边也是一棵树。所以很多操作——遍历、求深度、求节点数、销毁整棵树——都能用递归几行写完。理解了“把问题交给更小的子树”这个思路很多代码是自然涌现的不需要背。第二指针生命周期。C的二叉树几乎都是用指针连接的。创建节点、遍历、销毁每一步都在和内存打交道。谁负责new谁负责delete什么时候指针会悬空这些必须在脑子里形成条件反射。后面写线索二叉树时指针指向的东西不再只是孩子节点还可能是前驱后继的线索处理起来更要小心。2. 二叉树的结构设计与C实现细节二叉树的基础结构不难但实现层面有不少容易忽略的细节。这个章节把从定义到销毁的完整链路写清楚代码可以直接抄来用。2.1 节点结构和引擎函数一个最朴素的二叉树节点长这样struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };构造函数里把left和right都初始化为nullptr这一点非常重要。如果你实现的时候省略了初始化每一个节点都要手动记得赋空值一旦漏了后续遍历全部爆炸。C的构造函数初始化列表就是用来干这件事的。接下来是几个基础函数求节点数、求深度、销毁整棵树。这里我把销毁单独拎出来说因为很多新手学二叉树根本不管析构树用完就扔程序跑完进程退出操作系统会回收内存。但如果你是在一个长期运行的服务器程序里反复创建树不销毁就是内存泄漏跑几天就挂了。递归销毁要用后序遍历的顺序先销毁左子树再销毁右子树最后释放当前节点。先释放当前节点再去销毁子树代码就访问不到子节点了等于先把入口拆了还在想怎么进屋。void destroyTree(TreeNode* root) { if (root nullptr) return; destroyTree(root-left); destroyTree(root-right); delete root; }这里有个关键点函数参数是按值传递的函数内部delete掉root后外部那个root指针并没有变成nullptr而是变成了悬空指针。如果你在销毁后还会用到这个指针变量需要在调用后手动置空或者用引用/指针的指针来接收void destroyTree(TreeNode* root) { if (root nullptr) return; destroyTree(root-left); destroyTree(root-right); delete root; root nullptr; // 让外部指针也置空 }用引用传参就能自动把外部指针置空这个细节很值得记住。2.2 拷贝构造深拷贝与浅拷贝的教训C类里如果直接持有TreeNode指针默认拷贝构造函数会做浅拷贝——两个对象共享同一棵树的节点。任何一个对象析构时销毁这棵树另一个对象里的指针就全悬空了后续使用必崩。这是很多程序员封装二叉树类时踩的大坑。正确的做法是实现深拷贝。递归地复制每一个节点TreeNode* cloneTree(TreeNode* root) { if (root nullptr) return nullptr; TreeNode* newNode new TreeNode(root-val); newNode-left cloneTree(root-left); newNode-right cloneTree(root-right); return newNode; }封装成类的还需要配拷贝构造函数、拷贝赋值运算符和析构函数也就是所谓的“三/五法则”。如果觉得维护这些太繁琐也可以考虑直接用智能指针但二叉树用智能指针有个特殊的坑下面单独讲。2.3 智能指针能避免崩溃吗不一定很多用习惯了共享所有权语义的人写C二叉树时会直接上std::shared_ptr。但这里有一个非常隐蔽的问题如果以后做了线索二叉树或者用了带父指针的“三叉链表”节点和节点之间会形成环状引用——parent指向孩子孩子的parent又指回来。shared_ptr的引用计数在循环引用时永远归不了零内存根本释放不掉。即使不做线索二叉树纯二叉树其实没问题因为树是单向的不存在环。但如果用了shared_ptr父节点持有子节点的引用释放根节点时子节点的引用计数会不会归零会因为只有父节点一个持有者。想用智能指针解决内存问题二叉树场景下基本是可行的除非你引入了额外的指针形成环。我的建议是初学阶段就用裸指针配合递归删除逻辑最直接也能逼自己搞清楚内存到底是怎么流转的。等有了经验再去讨论智能指针的取舍。2.4 从数组或字符串构建二叉树做二叉树练习时最常见的输入形式是按层序排列的数组其中null表示空节点。比如数组[1,2,3,null,null,4,5]表示根节点1左孩子2右孩子32没有孩子4是3的左孩子5是3的右孩子。用数组建树本质是一个BFS过程。用一个队列保存“待设置孩子”的节点依次消费数组里的元素TreeNode* buildTree(vectorint data, int nullMarker) { if (data.empty()) return nullptr; TreeNode* root new TreeNode(data[0]); queueTreeNode* q; q.push(root); int i 1; while (i data.size()) { TreeNode* cur q.front(); q.pop(); if (data[i] ! nullMarker) { cur-left new TreeNode(data[i]); q.push(cur-left); } i; if (i data.size() data[i] ! nullMarker) { cur-right new TreeNode(data[i]); q.push(cur-right); } i; } return root; }这种建树方法在LeetCode等平台刷题时很常用也是把“层序”这个概念落地的第一步。代码里需要注意每次从队列头部取出节点后要判断它左右孩子对应的数组下标是否越界上面的写法用i data.size()做保护否则最后一个节点会尝试读越界数据。3. 遍历、深度与递归的精髓遍历是二叉树最核心的操作也是所有后续操作查找、删除、线索化的基础。很多人实现遍历只背模板不理解为什么递归能“自己走完”整棵树。这一节先写清楚递归模板再把递归改成迭代最后结合“二叉树的深度”这个高频问题讲透递归栈的用法。3.1 三种深度优先遍历递归版前序、中序、后序的区别只看访问当前节点的时机void preOrder(TreeNode* root) { if (root nullptr) return; cout root-val ; preOrder(root-left); preOrder(root-right); } void inOrder(TreeNode* root) { if (root nullptr) return; inOrder(root-left); cout root-val ; inOrder(root-right); } void postOrder(TreeNode* root) { if (root nullptr) return; postOrder(root-left); postOrder(root-right); cout root-val ; }三个函数只有一行位置不同背起来零压力。但理解比背更重要递归遍历的本质是“按固定的顺序去访问每个节点且每个节点都会被访问到三次”——第一次从左子树回来第二次从右子树回来第三次是函数返回。哪个时机打印当前节点就是哪一种遍历。后序你会发现一个有趣的规律中序序列的左、中、右相对次序固定但“前序”“后序”其实是“哪一次访问时打印”的区别。3.2 递归改迭代本质是手动维护一个栈面试官特别喜欢问“不用递归怎么写遍历”因为递归虽然简洁但有栈溢出风险而且递归调用的开销在大规模数据下不可忽视。把递归改成迭代核心思路是编译器用系统调用栈来保存“当前处理到哪个节点”我们迭代时就自己用一个栈来模拟。前序遍历迭代版最直观void preOrderIterative(TreeNode* root) { if (root nullptr) return; stackTreeNode* st; st.push(root); while (!st.empty()) { TreeNode* cur st.top(); st.pop(); cout cur-val ; if (cur-right) st.push(cur-right); if (cur-left) st.push(cur-left); } }注意这里先压右孩子再压左孩子因为栈是后进先出先把右孩子压进去左孩子就能先弹出来保证访问顺序是根-左-右。这是一个很经典的反直觉点初学者容易写反结果遍历输出就变成了根-右-左。中序遍历的迭代版不这么直观因为要先一路走到最左下角才能打印第一个节点void inOrderIterative(TreeNode* root) { stackTreeNode* st; TreeNode* cur root; while (cur ! nullptr || !st.empty()) { while (cur ! nullptr) { st.push(cur); cur cur-left; } cur st.top(); st.pop(); cout cur-val ; cur cur-right; } }外层循环条件有两部分cur非空说明还有新子树要处理栈非空说明还有等待打印的祖先节点。这个写法在二叉搜索树相关题目里出现频率极高建议熟练到条件反射。后序遍历迭代版稍微麻烦些常见做法是双栈或标记法。这里分享一个实用的小技巧先做一个“根-右-左”的遍历再把结果反转就是后序。因为栈的特性用类似前序的方法可以直接输出根-右-左反转后恰好是左-右-根。3.3 层序遍历二叉树的BFS层序遍历是按深度逐层从左到右访问标准做法是队列void levelOrder(TreeNode* root) { if (root nullptr) return; queueTreeNode* q; q.push(root); while (!q.empty()) { int levelSize q.size(); for (int i 0; i levelSize; i) { TreeNode* cur q.front(); q.pop(); cout cur-val ; if (cur-left) q.push(cur-left); if (cur-right) q.push(cur-right); } cout endl; // 每层输出后换行 } }这里用levelSize在循环前保存当前层节点数保证for循环只处理这一层不会把下一层也混进来。如果想返回一个二维数组按层分组把这个核心逻辑套进去就行。3.4 二叉树深度递归版与非递归版“二叉树的深度”是入门必考递归版代码极短int maxDepth(TreeNode* root) { if (root nullptr) return 0; return 1 max(maxDepth(root-left), maxDepth(root-right)); }这个函数可以说是二叉树上递归思想的缩影当前节点这棵树的高度等于左右子树中更高的那个再加1。空树高度为0是递归出口也是所有子问题最终收敛的地方。非递归版可以借层序遍历来统计层数。每一轮while循环就是一个层级循环次数就是深度int maxDepthIterative(TreeNode* root) { if (root nullptr) return 0; queueTreeNode* q; q.push(root); int depth 0; while (!q.empty()) { int levelSize q.size(); depth; for (int i 0; i levelSize; i) { TreeNode* cur q.front(); q.pop(); if (cur-left) q.push(cur-left); if (cur-right) q.push(cur-right); } } return depth; }二选一掌握哪种都行我的经验是理解递归版如何收敛再会用队列版求层数二叉树的基础基本上就稳了。4. 线索二叉树为什么要折腾空指针二叉树本身已经能完成所有遍历操作那线索二叉树存在的意义是什么这个问题如果没想明白实现线索化时就会觉得多余。先讲清楚动机再讲原理线索化才好理解。4.1 一个容易被忽略的资源浪费一颗有n个节点的二叉树每个节点有两个指针域left和right总共2n个指针域。其中用来指向孩子节点的指针有n-1个除了根节点每个节点都恰好被一个父指针指向。于是剩下的空指针数量是2n - (n-1) n1 个。这n1个空指针白白占着内存。一个指针在64位系统上是8字节n一旦大起来浪费就很可观。更关键的是空指针不仅在空间上浪费在功能上也没发挥价值。线索二叉树的思路就是把这n1个空指针利用起来让它们指向遍历序列中的前驱或后继节点。4.2 需求驱动频繁找前驱/后继的痛点实际的程序里经常需要在中序遍历序列里快速找一个节点的前驱或后继。比如在“查找某个节点按中序的下一个是谁”的场景下普通二叉树怎么做只能从根节点重新遍历整棵树用一个pre指针记录上一次访问的节点直到找到目标节点。这个过程的时间复杂度是O(n)如果频繁做这样的查找代价太高了。如果你手头是中序线索二叉树找后继的时间可以优化到均摊O(1)——因为右空指针已经直接指向后继了。这就是线索二叉树最核心的收益在遍历和找前驱/后继这两个操作上不再需要依赖栈或递归空间复杂度降到O(1)。当然线索二叉树的代价也很明显插入和删除节点后维护线索比较麻烦。线索是从“遍历序列”角度看问题的一旦树的结构变了序列就变了所有受影响位置的线索都要重连。所以做线索二叉树的决策要看业务上到底是“读多写少”还是“写多读少”。读多写少、且频繁遍历或查前驱后继线索二叉树很有用动态增删频繁的话维护线索的代价反而不划算。4.3 原理用标志位区分线索和实指针问题来了如果空指针被用来指向前驱或后继那遍历的时候怎么知道它到底是指向孩子节点还是指向线索在C里光靠指针本身区分不了所以规定ltag为0时left指向左孩子ltag为1时left指向前驱线索rtag为0时right指向右孩子rtag为1时right指向后继线索这样每个节点不管left和right是否为空都被赋予了明确的语义。原本的空指针也变成了有意义的线索指针。拿一个简单的中序线索二叉树举例中序遍历序列是某个顺序那么序列中第一个节点没有前驱它的left线索指向一个头节点或nullptr最后一个节点没有后继它的right线索指向头节点或nullptr。中间每一个left或right为空的节点都会被线索填补上。5. 中序线索二叉树的C实现线索化可以在先序、中序、后序任意一种遍历过程中完成但实践中最常用、也最好理解的是中序线索化。先讲结构定义再讲线索化递归过程最后讲如何用O(1)空间完成中序遍历。5.1 节点结构的调整在线索二叉树里节点需要额外两个标志位struct ThreadNode { int val; ThreadNode* left; ThreadNode* right; bool ltag; // false表示left指向左孩子true表示left指向前驱 bool rtag; // false表示right指向右孩子true表示right指向后继 ThreadNode(int x) : val(x), left(nullptr), right(nullptr), ltag(false), rtag(false) {} };这里我把标志位设为布尔值语义清晰。有些教材用0和1本质一样但bool可读性更好。5.2 中序线索化过程中序线索化的过程本质是在中序遍历的模板上加两句话。核心思想用全局变量pre记录“当前访问节点的前一个节点”。当当前节点的左指针为空时就把它指向pre当pre的右指针为空时就把它指向当前节点。这一步非常关键因为中序遍历序列里pre的后继正是当前节点。void inThread(ThreadNode* root, ThreadNode* pre) { if (root nullptr) return; inThread(root-left, pre); if (root-left nullptr) { root-left pre; root-ltag true; } if (pre ! nullptr pre-right nullptr) { pre-right root; pre-rtag true; } pre root; inThread(root-right, pre); }逐段拆解先递归线索化左子树这是中序遍历的第一步。处理完左子树后pre就是左子树里最后一个被访问的节点也就是当前节点的前驱。第二步检查当前节点的左指针。如果为空就把它指向上一次访问的节点pre同时把ltag设为true。这里的时空巧合很妙递归执行到右子树之前中序序列里当前节点的前驱就是刚才左子树过程的最后一个节点。第三步检查pre的右指针。如果为空就把pre的right指向当前节点同时置rtag。为什么能这样做因为中序序列里pre的下一个节点就是当前节点。这个操作填补的是pre的后继线索而不是当前节点的后继线索——当前节点的后继要等递归到右子树时再处理。理解这个不对称关系是写对线索化的关键。最后更新pre为当前节点继续递归右子树。5.3 头节点的妙用如果线索化结束后直接使用这个根节点会发现一个问题中序遍历的第一个节点的left线索指向nullptr最后一个节点的right线索也指向nullptr遍历到末尾后无法区分“序列结束”和“当前节点没有后继”。更优雅的做法是增加一个头节点让链表形成循环结构。具体做法是头节点的left指向根节点right指向自身或者指向中序最后一个节点根节点的中序前驱线索指向头节点中序最后一个节点的后继线索也指向头节点。这样无论是从头正向遍历还是从尾反向遍历都能在头节点处停下来。完整的带头节点中序线索化可以这样写void inThreadWithHead(ThreadNode* root, ThreadNode* head) { head new ThreadNode(-1); // 头节点值无所谓 head-ltag false; head-rtag true; head-right head; // 头节点右指针先指向自己 ThreadNode* pre head; inThreadCore(root, pre); pre-right head; // 最后一个节点指向头节点 pre-rtag true; head-left root; // 头节点左指针指向根 } void inThreadCore(ThreadNode* root, ThreadNode* pre) { if (root nullptr) return; inThreadCore(root-left, pre); if (root-left nullptr) { root-left pre; root-ltag true; } if (pre ! nullptr pre-right nullptr) { pre-right root; pre-rtag true; } pre root; inThreadCore(root-right, pre); }有了头节点后线索树就变成了一个双向循环链表的中序“骨架”。从头节点开始可以正向走一圈回到头节点从最后一个节点反向也能回到头节点遍历的终止条件就非常清晰了。5.4 无需递归和栈的中序遍历线索二叉树最惊艳的地方在于中序遍历不再需要调用栈也不需要自己维护栈。只需要找到中序第一个节点然后不断利用后继线索前进即可。找中序第一个节点的方法是从根开始一路沿left往下走直到遇到ltag true的节点为止。这个节点的left可能是指向前驱线索或头节点它本身没有左孩子所以它就是整个中序序列的第一个节点。拿到第一个节点后如何找中序后继两条规则如果rtag true说明right指向的是直接后继线索直接取right即可如果rtag false说明right指向的是右孩子那么后继节点应该是右子树中最左下角的节点基于这两条规则遍历代码如下ThreadNode* firstNode(ThreadNode* root) { while (root root-ltag false) { root root-left; } return root; } ThreadNode* nextNode(ThreadNode* cur) { if (cur-rtag true) { return cur-right; } return firstNode(cur-right); } void inOrderByThread(ThreadNode* head) { ThreadNode* cur firstNode(head-left); while (cur ! head) { cout cur-val ; cur nextNode(cur); } cout endl; }这段遍历的空间复杂度是O(1)连函数调用的递归栈都省了。在嵌入式或对栈使用有严格限制的平台上这个特性价值非常大。面试时如果能答出这个O(1)空间遍历是很加分的点。同样地找中序前驱也有对称规则如果ltag trueleft指向直接前驱直接取left如果ltag falseleft指向左孩子那么前驱是左子树中最右下角的节点5.5 线索化之后别再用原来的递归遍历这是很多学习者会踩的坑对一棵树先做了线索化然后又用之前的递归中序去遍历它。线索化后原本为空的指针被改成了指向其他节点的线索但ltag和rtag会告诉遍历逻辑“这是线索不是孩子”。如果你再用递归版遍历递归代码不会看ltag和rtag它只看到left、right非空就往里走结果会走进线索指向前任节点的路径形成环或者访问到错误的节点。记住一个原则一旦树被线索化所有遍历都必须走基于线索的版本递归遍历只适用于未线索化的普通二叉树。6. 线索化实战调试过程中最常见的三个坎6.1 只处理了左线索遗漏了右线索的填补写线索化时新手最容易犯的错误是只判断当前节点的left是否为空却忘了处理pre节点的right。于是中序序列里每个“非最后一个节点”的右空指针不会被线索化遍历到那个节点时rtag还是false就跑去它的右子树找后继而右子树可能是空的逻辑出错。我当时是怎么发现这个问题的调试时单步跟踪发现某个节点的rchild明明是nullptr却跳转到了一个完全不对的地址最后才意识到是pre的right线索没补上。检查思路很简单线索化的核心是“把前一个节点的右指针指向当前节点”这一步和“把当前节点的左指针指向前一个节点”是成对出现的缺一不可。6.2 递归线索化时pre的更新时机另一个高频bug是pre的更新位置。如果把它放在递归左子树之前或者放在递归右子树之后都会导致线索指向错误。正确的时机是在当前节点处理完自己的左指针线索后、递归右子树之前更新pre为当前节点。原因很直接pre永远是“已经访问过的最后一个节点”。只有等当前节点被访问完毕——即左子树遍历完、当前节点本身处理完——它才有资格成为下一个节点的前驱。如果递归右子树完成后再更新pre那么右子树里的每个节点的pre都是当前节点但它们在序列上其实是当前节点右子树内部的节点前驱关系完全错乱。这类bug不会立刻崩溃但遍历结果随机错位是我认为最难受的调试场景。6.3 带头节点的遍历死循环加了头节点后如果遍历的终止条件没写对会在头节点和最后一个节点之间反复横跳看起来像是死循环。原因通常是while (cur)写成了非空就继续而头节点的right又指向自己循环就永远结束不了。正确的终止条件是while (cur ! head)。做这个循环结构设计时建议先在纸上画一下从第一个节点走到最后一个节点最后一个节点的right指向head此时循环体执行完后cur变为head条件不成立退出。同样地如果头节点的left没有指向根节点firstNode(head-left)会把头节点本身当成根来处理遍历结果也会乱。所以建头节点时四个指针必须一次性设置正确left指向根right指向自己ltag为falsertag为true。6.4 测试建议小树手动验证大树对比输出调试线索二叉树我建议用两组测试数据第一组是只有3~5个节点的小树手工写出它的中序序列然后单步跟踪线索化的过程验证每个n1个空指针是否正确补齐。这一步虽然慢但对建立直觉极其有效。第二组是随机生成的较大树分别用“普通中序遍历”和“线索中序遍历”输出序列对比结果是否一致。两边的输出如果完全相同说明线索化本身没破坏遍历顺序。我自己调试时的经验是用一棵极度不平衡的链状树每个节点都只有右孩子来测线索化。这种树的空指针很多线索关系最密集最容易暴露pre更新时机和右线索遗漏的问题。把这种极端情况调通了一般正常形状的树就不会有大问题。7. 一些学习上的个人建议写代码是一回事把知识沉淀下来是另一回事。这篇整理的最后分享几个我在实际学习和项目里体会到的建议。第一个建议先把普通二叉树的递归遍历写成本能。线索二叉树本质上是在遍历过程中“做手脚”如果你对递归遍历本身还不够熟练线索化的代码就会像天书。我见过不少人直接跳过基础去啃线索化结果卡在最基础的pre更新上。确保自己能不假思索写出前中后序递归版、层序遍历、求深度再进入线索二叉树会比较顺畅。第二个建议线索二叉树对你理解“空间换时间”和“利用已有资源”很有帮助。它的本质不是发明新结构而是把闲置的资源盘活。这种思维在系统设计里很常见——缓存、连接池、索引本质上都是在利用本来闲置或重复的东西。数据结构课上学的不只是代码还有这种优化意识。第三个建议C环境的问题。我用的是VS Code配的C环境配合gdb单步调试观察指针值和标志位变化非常方便。如果你还在“写二叉树程序总是报运行时错误”的阶段强烈建议学会单步调试而不是靠加打印日志猜问题。看着指针从nullptr变成node地址线索化的每一步都在眼前展开比看一百遍教程都有用。二叉树和线索二叉树是数据结构里承上启下的一环。前面是线性表的指针操作后面是更复杂的树形结构、图、高级搜索树。把这个基础打牢后面学AVL树、红黑树、B树时很多概念会亲切很多。希望这篇整理对你有帮助也欢迎在实践中遇到更隐蔽的坑时回来对照着看。