刷题库或者准备机试、面试算法题的时候,难免会遇到一类让人第一眼发怵的题目:给你一串数字,让你构建一棵“完全二叉搜索树”。光听名字就觉得要同时处理两个概念,好像既要保证搜索树的有序性,又要保证树形结构的完全性,很多同学第一反应就是先排序,再一个个插入建树,结果折腾半天写出来的代码又长又容易错。其实这个题本质上并没有那么复杂——它考的就是完全二叉树的数组存储和二叉搜索树中序遍历的有序性这两件事能不能真正理解到位。只要打通了这两个点,代码可以短到十几行,而且连指针都不用。
这篇内容我会从题目拆解开始,把“为什么排序之后按中序遍历位置填进去就得到正确答案”讲透,再给出可直接抄作业的完整代码,顺便把我自己反复踩过的坑和调试过程都记录下来。不管是准备研究生机试、找工作笔试,还是单纯想把这棵树的构建逻辑弄明白,都可以参考这篇的思路。如果你是那种看着递归就头疼的选手,也不用怕,我会尽量把每一步在脑子里发生了什么解释清楚。
1. 题目到底在问什么:把“完全二叉搜索树”拆成三个关键词
很多问题之所以显得难,不是解法真的复杂,而是题干里叠加了好几个术语。CBST这个缩写看起来唬人,拆开就是 Complete Binary Search Tree,三个词各管一件事。先逐个搞清楚,再组合起来看,思路就顺了。
1.1 完全二叉树不是“满二叉树”
树形结构里有两个容易混的词:满二叉树和完全二叉树。满二叉树要求每一层的节点都是满的,比如高度为3的满二叉树,第一层1个、第二层2个、第三层4个,总共7个节点,一个不多一个不少。完全二叉树的要求稍微宽松一些:除了最后一层,上面每一层都必须满;最后一层可以不满,但节点必须从左到右连续排列,中间不能空着。
举个例子,假设现在有6个节点,第1层1个、第2层2个、第3层3个,这样第3层只有3个节点,且它们都靠左排列,这就是一棵完全二叉树。但如果第3层的节点分布是左1个、右2个,中间空了一个位置,那就不是完全二叉树了。这个“从左到右连续”的约束非常关键,因为它让整棵树可以像数组一样顺序存储——每个节点的位置都能用下标的算术关系算出来,而不需要额外记录左右孩子指针。很多以数组为基础的树形结构,比如堆,就依赖这个性质。
1.2 二叉搜索树的中序遍历是升序的
二叉搜索树(BST)的定义比较直白:对于任意一个节点,它的左子树里所有节点的值都小于当前节点,右子树里所有节点的值都大于当前节点。这个定义保证了整棵树在“结构”上是有序的,但有序性最直观的体现并不是层序遍历,而是中序遍历。
所谓中序遍历,就是按照“先左子树、再根节点、最后右子树”的顺序去访问整棵树。数学上很容易证明,对于一棵BST,中序遍历的结果恰好是一个严格递增的序列。反过来也一样:如果你拿到一个BST的中序遍历序列,它一定是有序的。这意味着什么?意味着给定一串乱序数字,只要把它们从小到大排好序,那这个有序序列就能直接当作某棵BST的中序遍历结果来用。这是解开这道题的核心钥匙,后文构建方法的所有合理性都建立在这一条性质上。
1.3 两个性质叠加:为什么这题能成为经典
单独看完全二叉树,它解决的是“存储结构”问题——可以用连续数组,不用指针;单独看BST,它解决的是“节点取值”问题——中序遍历结果有序。把两者合在一起,就出现了一个非常漂亮的巧合:
如果一棵树既是完全二叉树又是BST,那么它的结构完全由节点数量N决定,与具体数值无关;它的数值排列又完全由中序遍历的有序序列决定。那构建这棵树,就等价于回答一个问题:给定一棵完全二叉树的“空壳子”,把有序序列按什么顺序塞进每个节点的位置?
答案就是按中序遍历的顺序去塞。因为在完全二叉树里,每个节点在数组中的下标对应了它在这棵树里的“位置”;而对这棵空壳树做一次中序遍历,就得到了一个访问顺序。把排好序的数列从小到大逐个填入这个访问顺序里的每个位置,最终得到的每个节点都会满足“左子树都比自己小、右子树都比自己大”——这不就原地构建出一棵CBST了吗?
这个思路的好处在于:整个过程不需要真的去“建”树,不需要malloc节点,不需要维护左右子树指针,只需要一个数组和一次递归填充。我第一次意识到这个解法的时候,感觉像发现了一个藏在题目里的捷径,代码量少得令人怀疑。
2. 为什么用中序遍历填充数组就能构建成功
上一节说了结论,这节把推导过程完整捋一遍。搞清楚“为什么”之后,代码怎么写都是水到渠成的事,哪怕忘了细节也能自己推出来。
2.1 完全二叉树的数组编号规则
要从数组构建完全二叉树,首先得确立一个编号规则。最常用的是从1开始编号:根节点放在数组下标1的位置,那么对于任意下标为i的节点,它的左孩子下标是2i,右孩子下标是2i+1。反过来,当前节点的父节点下标是i/2(整数除法向下取整)。
这个规则很容易验证。根节点下标是1,左孩子是2、右孩子是3;下标2的节点,左孩子4、右孩子5;下标3的节点,左孩子6、右孩子7。画出图来看,就是一个从上到下、从左到右逐层编号的过程。只要树是完全的,所有节点编号就连续,不存在中间跳号的情况,因此可以用一个长度为N+1的数组严丝合缝地把整棵树存下来。
从0开始编号也不是不行,只是孩子下标变成2i+1和2i+2,父节点变成(i-1)/2,计算稍微绕一点。我个人强烈建议做题时统一用从1开始的方案,一来写起来直观,二来判断“当前下标是否越界”只需要比较i和n的大小,一行搞定。
2.2 中序遍历填充的完整推演
光说不练没有感觉,我拿一个N=5的例子完整走一遍。假设题目给的原始序列是:4,2,5,1,3。第一步先排序,得到有序序列:1,2,3,4,5。这个序列就是要填充进树的所有值。
接下来用一个全局指针pos从0开始,表示下一个要填入的有序序列元素。然后对“完全二叉树的空壳”做中序遍历。所谓空壳,就是一棵已经定好形状、但所有节点都还没有赋值的树,这个形状完全由N=5决定:根节点下标1,左子树以2为根,右子树以3为根;下标2的左孩子是4,右孩子是5。
中序遍历的访问顺序是:先访问根为2的那棵子树,再访问根节点1,最后访问根为3的那棵子树。子树内部同样递归。一步一步来看:
- 访问下标1,先进入左子树(下标2)
- 下标2有左孩子下标4,先进入(下标4)
- 下标4没有孩子,作为第一个访问位置,填入1,pos变成1
- 回到下标2,填入2,pos变成2
- 进入下标2的右孩子(下标5)
- 下标5作为第三个访问位置,填入3,pos变成3
- 下标2的子树访问完毕,回到根节点下标1,填入4,pos变成4
- 最后进入右子树下标3,没有孩子,填入5
最终数组存放的结果是:下标1=4,下标2=2,下标3=5,下标4=1,下标5=3。这个数组对应的完全二叉树的层序遍历就是4,2,5,1,3,而它的中序遍历结果恰好是1,2,3,4,5。你把这棵树画出来,会看到它确实满足BST的所有约束。这个推演过程其实就是整个算法的核心,理解了它,代码里递归的逻辑就只是一行“左、中、右”的事。
2.3 反直觉点:构建过程中你从来看不见“整棵树”
这个解法最初让我不适应的地方在于,递归过程中我并没有建立任何显式的节点连接,代码跑着跑着树就自己“长”出来了。原因是完全二叉树的结构信息完全隐藏在数组下标的算术关系里,递归函数每一次调用传入的index,本身就代表着“当前正在访问树中的哪一个位置”。访问完左子树回到当前节点,再进入右子树,这种顺序恰好就是中序遍历对真实二叉树的访问方式。
换句话说,我们其实是对“数组抽象出来的树”做中序遍历,而不是对“指针连起来的树”做遍历。这两者在逻辑上是等价的,只是后者需要你手动创建节点,前者不需要。把这一点想明白之后,很多类似问题都能打开新思路:只要是能用数组存储的完全二叉树结构,都可以用这种“对下标递归”的方式操作,比如后面会提到的堆、线段树的某些场景。
3. 完整代码实现与逐行拆解
思路清楚了,代码就是翻译。我用C++写一个完整版本,并逐段说明每个变量的作用。如果你习惯Java或者Python,把数组操作换一下语法就行,核心递归逻辑完全一致。
3.1 递归版核心代码
#include <bits/stdc++.h> using namespace std; const int MAXN = 1005; int n; int a[MAXN]; // 排序后的中序序列 int tree[MAXN]; // 存储完全二叉树的数组,下标从1开始 int pos = 0; // 指向a数组中下一个要填入的值 void build(int idx) { if (idx > n) return; build(idx * 2); // 先处理左子树 tree[idx] = a[pos++]; // 再填入当前根节点 build(idx * 2 + 1); // 最后处理右子树 } int main() { cin >> n; for (int i = 0; i < n; ++i) cin >> a[i]; sort(a, a + n); build(1); for (int i = 1; i <= n; ++i) { if (i > 1) cout << " "; cout << tree[i]; } cout << endl; return 0; }核心函数build只有四行,但每一行都有讲究。idx表示当前正在访问的节点在完全二叉树数组中的下标。进入函数后首先判断idx是否越界,一旦下标大于n,说明这个位置没有节点,直接返回。build(idx * 2)代表去访问左子树,访问完之后,有序序列的下一个值就应该填到当前节点上,所以执行tree[idx] = a[pos++]。最后再递归访问右子树。这个“左→赋值→右”的顺序,完美复刻了BST中序遍历的定义。
3.2 层序遍历:为什么直接输出数组就是答案
普通二叉树做层序遍历需要借助队列,从根节点出发,一层一层把节点入队再出队。但完全二叉树就不需要,因为它的数组下标本身就已经是层序遍历的顺序了:下标1是根,下标2、3是第二层,下标4、5、6、7是第三层,依此类推。
所以构建完成之后,只要从下标1到n把数组按顺序输出,得到的就恰好层序遍历序列,中间不用任何额外处理。这也是这个算法省事的第二个地方——不但不需要建树,连层序遍历的队列都省了。很多人在这一步卡住,是因为惯性思维总觉得“树”必须有指针,其实在这个问题里,数组就是树,树就是数组。
3.3 复杂度分析:为什么这个方案优于“建树再层序遍历”
排序的时间复杂度是O(N log N),递归构建每个节点只访问一次,是O(N),最后输出也是O(N)。空间上除了输入数组和树数组,只有递归栈的消耗。在完全二叉树中树高大约是log2(N+1),所以递归深度很浅,完全不用担心爆栈。
如果按“先构建真正的二叉树再层序遍历”的老思路做,你需要先给每个节点动态分配内存,再写插入函数,最后还要写队列层序遍历,代码量是上面的三四倍,时间复杂度最坏还会退化到O(N^2)。两者一对比,数组方案在简洁性、时间空间开销上都明显占优。这个差距不是微小的优化,而是思想层面上的降维打击。
4. 踩坑实录:那些让提交卡住的细节
代码再短,写的时候也有一堆地方容易出错。我自己第一次写这道题时,连续栽了好几个跟头,后来把这些问题整理出来才发现,几乎全是细节意识的问题,搞清楚之后就能稳定一遍过。
4.1 数组从0开始还是从1开始,千万别混
这是最经典的一个坑。如果你习惯C语言的数组天然从0开始,很容易把根节点放在tree[0],然后递归传build(0),孩子下标变成build(2 * i + 1)和build(2 * i + 2)。这样写理论上也能对,但判断越界、计算父节点时都要修改公式,稍不留神就写串。更常见的情况是代码里一会儿用下标1一会儿用下标0,结果数组第一个元素总是没有被正确赋值,输出的时候出现莫名其妙的0。
我的建议是:研究清楚一个版本,然后用习惯。在这篇文章的代码里,数组大小开成n+1,递归入口传1,孩子下标直接用2i和2i+1。所有地方统一用从1开始的编号,你不信试试,代码几乎不会因为下标问题出bug。如果最后输出时你发现第一个数不对,先检查是不是把tree[0]当成tree[1]用了。
4.2 输出格式:行末空格和换行
在线评测系统对输出格式的容忍度不同,有些系统会忽略行末多余空格,有些则不会。保险起见,用我在代码里的写法:在第一个数之前不输出空格,之后每个数前置一个空格。这样输出的行末尾不会有空格,格式最干净。
很多同学习惯每输出一个值就跟着一个空格,最后手动删掉行尾空格,其实没必要这么麻烦。用一个if(i > 1)判断,或者i从1循环到n,在i之前打印空格,都是更简单的处理方式。换行符也要记得输出,尤其是最后一行,有些评测机对缺少结尾换行很敏感。
4.3 递归深度与重复数据问题
完全二叉树的递归深度最多是树高,N在几百、几千甚至十万时都很安全,因为log2级别的高度怎么都压不爆栈。但如果你的递归函数写得不对,比如左右子树调用写反了,或者忘了越界判断,就可能变成无限递归直接崩掉。调试时如果程序卡死,先检查递归终止条件。
重复元素是另一个容易纠结的点。题目一般会说输入是互不相同的整数,但偶尔也会遇到没说清楚的情况。如果数列里有重复值,排序之后序列仍然是非递减的,按照中序填充的办法,最后得到的树仍然可以满足“左子树小于等于根、右子树大于等于根”的弱化版本。大多数题目的判定逻辑不会因此出错,所以不用特别担心。保险起见可以读题时注意一下是否明确说明元素互异。
4.4 排序前别忘了读入所有数据
还有一个新手常犯的错:边读入边排序,或者排序写在了读入循环内部,导致数据还没读全就开始处理。应该先完整读入n个元素,存进数组,然后一次性sort。这个错误和题目无关,属于基础编码习惯,但刷题压力大的时候真的容易犯。建议养成习惯:凡是需要排序的题,都严格遵循“完整读入→排序→处理”三步走。
5. 从这道题迁移出去的三种玩法
说实话,这道题本身已经覆盖了两个重要的算法基础点,但它的价值不止于AC一道题。把这个“数组下标映射树结构 + 中序遍历填充”的思路记牢,你在很多其他地方都会再次遇到它。
5.1 变体一:要求输出后序遍历怎么办
后序遍历的顺序是“左子树→右子树→根节点”。完全二叉树的数组存储规则完全不变,只需要把递归函数里的三行代码重新排列一下:先build(idx * 2),再build(idx * 2 + 1),最后执行tree[idx] = a[pos++]。这样填入的顺序就变成了后序遍历要访问节点的顺序。如果把“中序填坑”理解成一种模板,那么无论题目要什么遍历顺序,你只需要调整填充动作在递归调用的位置,其他代码一行都不用改。这个套路可以自己写几个小数据验证一下,理解会非常深刻。
5.2 变体二:构建普通的二叉搜索树
如果是普通BST而不是完全BST,数组存储就不适用了,因为普通BST的形状随插入顺序变化,节点编号是不连续的。这时只能老老实实定义结构体,用指针或索引把节点串起来,先排序也不一定有用,因为插入顺序决定了树的形状。这正好从反面说明了“完全二叉树能用数组存”这个性质有多珍贵。面试时如果被问到“为什么要用数组而不是链表”,你可以直接拿这个题目当例子,说明在树形完全确定的场景下,数组方案更简单、更快、还不容易泄漏内存。
5.3 变体三:堆和线段树里的同款下标映射
堆是另一个典型的完全二叉树,它也用数组存储,父子节点也用2i和2i+1这套下标规则。只是堆关心的是大根小根性质,而不是搜索树的有序性,所以构建方式从“中序遍历填充”变成了“上浮/下沉调整”。线段树虽然不一定完全,但很多实现里也用下标方式组织节点,父子关系同样是2倍关系。如果你能把这道题的下标映射彻底吃透,再看堆排序和线段树的代码会感觉亲切很多,因为它们底层共享了同一套“数组即树”的思想。
最后再分享一个实操里的小经验
我后来做这类树结构题,都会在草稿纸上先画一个N=5或N=7的完全二叉树,标好每个节点的数组下标,然后手动模拟一遍中序遍历顺序,把所有位置编号写出来。这个习惯帮我省下了大量调试时间——很多bug其实在写代码之前就能从纸上暴露出来。这道1064题虽然只是众多树结构题中的基础款,但把它做透,后面接触堆、线段树、以及各种遍历顺序调整的变体题都会顺畅得多。如果你在被这道题折磨,不妨先停下来,把那个“空壳树”画一遍,再回头看代码,你会有一种“原来它在做什么”的透彻感。