news 2026/9/29 22:56:44

中序+后序还原二叉树:P1030递归分治求先序

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中序+后序还原二叉树:P1030递归分治求先序

1. 题目在说什么:两行输入,考的是递归分治

1.1 来自NOIP 2001的经典小题

做题讲究性价比,P1030 求先序排列就是一道典型的"短小精悍"的普及组题。它出自NOIP 2001普及组,输入格式极其简单:第一行是二叉树的中序遍历字符串,第二行是同一棵树的后序遍历字符串,要求输出这棵二叉树的先序遍历字符串。树结点用不同的大写字母表示,字符串长度不超过8,输入数据保证合法。

很多初学者看到"求先序排列"五个字,第一反应是:先序排列是不是要把中序和后序拿来拼一拼?或者是不是要找规律把两个字符串穿插起来?如果你也有这个想法,那这道题的意义就体现出来了——它真正想让你理解的是二叉树的递归结构,而不是字符串的排列组合。我在刷题群里见过不少同学,拿到题目先试各种双指针、双循环,折腾半小时,不如老老实实从"后序的最后一个字符就是根"这个角度入手。下面我把整道题的推导链路完整拆开。

1.2 三种遍历的顺序先捋一遍

为了避免后面推导时绕晕,先把三种遍历的定义固定下来。假设一棵二叉树有根节点 R、左子树 L、右子树 Rr:

  • 先序遍历:先访问 R,再先序遍历 L,最后先序遍历 Rr,简称"根左右"。
  • 中序遍历:先中序遍历 L,再访问 R,最后中序遍历 Rr,简称"左根右"。
  • 后序遍历:先后序遍历 L,再后序遍历 Rr,最后访问 R,简称"左右根"。

注意这里的"遍历 L"不是笼统地"访问 L",而是再套一遍相同的规则。比如中序遍历的完整定义就是:对左子树做中序遍历,访问根,对右子树做中序遍历。这天然就是递归定义。

正因为每一棵子树都遵守同样的顺序规则,所以一个遍历序列里其实隐藏着"根在哪、左右子树各占多少"的信息。中序和后序两条序列放一起,恰好把"根是谁"和"左右边界在哪"这两条信息都补全了,这就是整道题的解题钥匙。

2. 核心原理:后序最后一个是根,中序负责切两半

2.1 后序里藏着根的位置

后序遍历的最后一个节点,一定是整棵树的根。因为后序顺序是左、右、根,根压轴出场。同理,中序遍历里根一定在序列的某个位置,根左边的子串就是左子树的中序遍历,根右边的子串就是右子树的中序遍历。只要知道根是谁,就能在中序中确定左右子树各自包含哪些节点;知道左右子树各有多少个节点,就能在后序中精确切出左子树的后序和右子树的后序;然后递归处理。这就是整个算法的一行式总结。

反过来先序+中序也一样:先序第一个是根,也能分割中序。但先序+后序不行,这个放到2.3专门说。

2.2 用样例完整模拟一遍

题目样例给的是:

  • 中序:BADC
  • 后序:BCDA

第一步,看后序的最后一个字符:A。A 就是整棵树的根。

第二步,在中序 BADC 里找到 A,它在下标1(从0开始数)。于是:

  • 左子树的中序:B(A左边)
  • 右子树的中序:DC(A右边)

左子树有1个节点,右子树有2个节点。

第三步,回到后序 BCDA,去掉最后的根 A,剩下 BCD。前1个字符是左子树的后序:B;后面2个字符是右子树的后序:CD。

此时树结构已经出来了:

A / \ B D / C

第四步,递归求左右子树。左子树中序B、后序B,唯一确定根B。右子树中序DC、后序CD,后序最后一个是D,说明D是右子树的根;中序DC中D在左边,所以C是D的左子树,右子树为空。整理一下:

  • 整棵树先序 = A + 左子树先序(B) + 右子树先序(DC) = ABCD

对一下样例输出,就是 ABCD。这个推导过程很重要,别看它简单,它已经把递归的两个关键动作都体现出来了:一是每次处理后序的最后一个字符作为根,二是根据中序里根的位置计算左右子树的节点数量,再去切割后序。后面的代码只是把这个过程翻译给计算机。

2.3 为什么先序+后序不能唯一确定

很多同学会问:既然中序+后序能还原,中序+先序也能还原,那先序+后序是不是也可以?答案是通常不行。

先序序列和后序序列都只告诉我们"根在哪一头",但当中序缺失时,我们无法判断某个内部节点到底只有左孩子还是只有右孩子。比如一棵只有根A和左孩子B的树:先序是AB,后序是BA;一棵只有根A和右孩子B的树:先序还是AB,后序也还是BA。这两种完全不同的树,先序和后序的排列一模一样。所以先序+后序无法唯一还原二叉树,除非额外约定"没有左子树"之类的规则。

这个知识点前半部分主要是为了让你深刻理解"中序的作用就是区分左右"这一本质。明白了这一点,即使题目变成中序+先序求后序,你也能快速迁移。

3. 递归设计:区间参数才是真正的分水岭

3.1 先会写"子串版本",再理解"区间版本"

最朴素的实现是每次递归都传入新的字符串子串。用C++的话大概是这样:

void solve(string in, string post) { if (in.empty()) return; char root = post.back(); int pos = in.find(root); cout << root; solve(in.substr(0, pos), post.substr(0, pos)); solve(in.substr(pos + 1), post.substr(pos, in.size() - pos - 1)); }

这个写法逻辑和手工推导完全一致,但有两个工程上的问题:一是字符串拷贝频繁,长度小时无所谓,长度大了就是O(n^2)以上的额外开销;二是substr的参数容易写混,特别是右子树的后序子串。所以更推荐用区间下标来做,这也是下面要详细讲的。

3.2 四个参数把两个序列切成四段

递归函数设计成 dfs(l1, r1, l2, r2),含义是:当前考虑的中序区间是 in[l1..r1],后序区间是 post[l2..r2]。整个算法每次做三件事:

  1. 取 post[r2] 作为当前子树的根 root;
  2. 在中序区间 [l1..r1] 中找到 root 的位置 pos,于是左子树的中序区间为 [l1, pos - 1],长度为 leftLen = pos - l1;右子树的中序区间为 [pos + 1, r1];
  3. 根据长度切割后序区间:左子树的后序区间是 [l2, l2 + leftLen - 1],右子树的后序区间是 [l2 + leftLen, r2 - 1]。

为什么左子树的后序区间起点还是 l2?因为后序区间里,除了最后一个root,前半段长度就是左子树的节点数,后半段长度就是右子树的节点数。左右子树的节点总数等于后序区间长度减一,这是由后序定义保证的。

递归出口是 l1 > r1 或 l2 > r2,也就是区间为空。需要注意左右两个条件通常同时成立,但判断任何一个都足够,两个都写上更安全。

3.3 输出顺序决定"先序"

先序是根左右,所以函数体内先输出 root,再递归左子树,再递归右子树。如果你想返回一个字符串而不是直接打印,就把这三部分拼起来:root + dfs(...) + dfs(...)。这个拼接顺序千万别写成左+右+根,那是后序;也别写成左+根+右,那是中序。我在批改学生作业时,经常看到递归逻辑完全正确,最后输出顺序写反,非常可惜。

另外一个小细节:求先序时,根信息在递归入口就能输出,所以递归函数甚至不需要返回值。这种"边递归边输出"的写法,比赛里最省事。

4. 完整可跑的代码:C++和Python两版

4.1 C++区间递归版本

#include <bits/stdc++.h> using namespace std; string in, post; void dfs(int l1, int r1, int l2, int r2) { if (l1 > r1 || l2 > r2) return; char root = post[r2]; cout << root; int pos = in.find(root, l1); int leftLen = pos - l1; dfs(l1, pos - 1, l2, l2 + leftLen - 1); dfs(pos + 1, r1, l2 + leftLen, r2 - 1); } int main() { cin >> in >> post; int n = (int)in.size(); dfs(0, n - 1, 0, n - 1); return 0; }

逐行解释几个关键点:

  • in.find(root, l1):从下标 l1 开始查找 root,返回值是 root 在当前中序中的下标。由于数据保证合法,不会返回string::npos。
  • leftLen = pos - l1:左子树节点数,也是切分后序的关键。
  • 第一个递归:左子树的中序是 [l1, pos - 1],后序是 [l2, l2 + leftLen - 1]。
  • 第二个递归:右子树的中序是 [pos + 1, r1],后序是 [l2 + leftLen, r2 - 1]。

4.2 更保险的做法:用数组记录中序位置

很多选手不习惯 find,更愿意在建树前先预处理一个映射数组。因为题目字符串由大写字母构成,可以直接开一个大小为128的 int 数组:

int posInIn[128]; for (int i = 0; i < n; i++) { posInIn[(int)in[i]] = i; }

递归里就直接int rootPos = posInIn[(int)root];,然后拿 rootPos 去判断是否在区间内。这样复杂度更干净。同理,Python 可以用字典:

pos_map = {ch: i for i, ch in enumerate(inorder)}

这个预处理的好处是:把"查询根在中序里的位置"从 O(n) 变成 O(1),递归总复杂度从 O(n^2) 降到 O(n)。对于长度不超过8的数据,find 版完全够用;但如果题目把数据范围加大到 10^5 级别,预处理映射就变成了必选项。这个优化思路值得记下来,很多树相关的题都会用到。

4.3 Python简洁版

如果你用 Python 做这道题,字符串切片版反而可读性更高:

def get_preorder(inorder, postorder): if not inorder: return "" root = postorder[-1] pos = inorder.index(root) left_in = inorder[:pos] right_in = inorder[pos + 1:] left_post = postorder[:pos] right_post = postorder[pos:-1] return root + get_preorder(left_in, left_post) + get_preorder(right_in, right_post) if __name__ == "__main__": inorder = input().strip() postorder = input().strip() print(get_preorder(inorder, postorder))

注意right_post = postorder[pos:-1],它去掉最后一个 root 之后取中间一段;left_post = postorder[:pos]是因为左子树节点数恰好等于 pos,这个 pos 是中序里根的下标,同时也是左子树节点数。为什么?因为中序里根左边的字符数就是左子树节点数,后序序列里前 pos 个字符正好就是这些左子树节点构成的子序列。这个对应关系是整道题唯一需要绕一下的点,想通了代码就是翻译问题了。

Python 版的时间复杂度包含切片和index,严格说是 O(n^2),但题目 n ≤ 8,比赛环境完全够用。如果追求更规范的写法,也可以用区间递归加字典映射,逻辑和 C++ 版一致。

5. 新手最容易翻车的三个点与调试技巧

5.1 区间端点加减一错位

这是最经典的坑。切割左子树后序时,很多人会误写成dfs(l1, pos - 1, l2, pos - 1),把中序的端点直接套到后序上。中序和后序区间不共享下标体系,必须通过 leftLen 换算。我的体会是:把"中序切出来的左子树长度"先写成一个单独变量 leftLen,再用它去算后序的区间端点,能避免80%的区间错误。

还有右子树后序右端点r2 - 1,这是后序中的根被去掉后的结果。有人会写r2,把根也包进右子树递归,结果是死循环或乱序输出。判断自己有没有错,造一条只有左孩子的链测试就知道了。

5.2 find返回npos的隐患

用in.find(root)时,如果 root 不在 in 中,会返回string::npos,也就是一个极大的无符号数。拿它减 l1,leftLen 会变成一个莫名其妙的巨大值,然后区间越界。虽说题目保证数据合法,不会出现这种情况,但你在本地造数据测试时,如果输入的中序和后序不是同一棵树,立刻就会踩中。建议在 find 之后加一句:

if (pos == string::npos) return;

或者用预处理数组,写一个循环判断 root 是否在区间内,这样代码更健壮。另一个容易忽略的点是:in.find(root, l1)的意思是"从下标 l1 开始找",如果 root 实际出现在 l1 左边,它也能正常返回;但你要的是 root 必须落在 [l1, r1] 内。如果用了自定义查找,务必判断pos >= l1 && pos <= r1。

5.3 用打印法亲眼看到递归过程

区间递归最容易出bug又最难定位的就是"变量值看起来都对了,但输出不对"。我的习惯是先在入口打印整行参数:

cout << "enter: [" << l1 << "," << r1 << "] [" << l2 << "," << r2 << "] root=" << root << endl;

然后观察:每一层的 root 是否和手工推导一致?左右区间长度对不对?如果发现某个递归区间长度和预期不符,基本就是 leftLen 算错了。

也可以用极小的数据验证边界:

  • n = 1:中序 A、后序 A,输出 A。
  • n = 2:中序 AB、后序 BA,这棵树根是 A,右孩子 B,输出 AB。
  • n = 2:中序 BA、后序 AB,根是 B,左孩子 A,输出 BA。

这三个用例都能过,基本说明区间切分没问题。这套"最小用例测试法"比盯着代码空想高效得多。

6. 从P1030延伸出去:一类还原题的通法

6.1 中序+先序求后序的对称写法

学会了 P1030,中序+先序求后序完全就是套模板。先序第一个字符是根,把它拿到中序里分割左右;然后递归处理左右子树,但最后才输出根。伪代码:

dfs(l1, r1, l2, r2): root = pre[l2] pos = 根在中序中的位置 leftLen = pos - l1 dfs(l1, pos - 1, l2 + 1, l2 + leftLen) dfs(pos + 1, r1, l2 + leftLen + 1, r2) print(root)

注意这里先序区间的切割方式和后序略有不同:右子树先序起点是l2 + leftLen + 1。USACO 有一道 American Heritage(洛谷 P1827)就是这个题,正好用来巩固。遇到还原类的题,抓住一句口诀就行:后序倒数找根、先序正数找根、中序定区间。

6.2 从知识到能力:不建树的递归遍历思维

这道题还有一个隐藏考点:即使不真正建出二叉树节点,仅靠区间递归就能完成遍历输出。这说明二叉树的遍历本质上依赖的是"递归结构",而不是物理指针。如果你自己实现一个 TreeNode 结构,用递归函数把中序+后序还原成真正的树,再走一遍先序遍历,也能 AC,但代码会长很多。在理解区间递归之前,先把 TreeNode 版写一遍是一个很好的过渡练习,能帮你把"字符序列"和"树形结构"对应起来。

6.3 后续刷题怎么衔接

把 P1030 吃透之后,建议顺手做同类的 P1305 新二叉树、P1827 American Heritage,横向比较几种还原方式。再往后就可以挑战综合性更强的题目了,比如 NOIP 2016 提高组的 P2831 愤怒的小鸟——它表面上和二叉树没关系,是搜索加状态压缩,但题目分析和递归分治的思维是一脉相承的。很多提高组选手回头看,都会发现普及组的 P1030 就是最早帮你建立"不要被题目表面吓住,先找递归出口"这种思维的题。

说实话,这道题我前前后后给不少人讲过。每次有同学说"我看懂了代码,但自己写就错",我都建议他别背代码,把 2.2 那一节样例推导自己手推三遍,推到"中序切长度,后序按长度切区间"变成肌肉记忆,再上机写代码。这道题不是难题,但它是一把很好的钥匙——它让你第一次意识到:所谓遍历序列,并不是一串没有结构的字符,而是递归树在某个顺序规则下留下的投影。把这道题彻底弄懂,后面学建树、学树的序列化、学表达式树,都会顺很多。如果你现在正卡在 P1030,别急,每个人都卡过,按这个思路慢慢推,很快就能 AC。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 22:55:03

LangChain爆改AI Agent实战:用Harness配置TaoToken,单模型性能飙升13.7%

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 22:54:51

BL350异构双核:独立M4F实时核如何重构工业控制

1. 为什么一个M4F核让工业控制方案彻底变了 做工业控制的工程师&#xff0c;尤其是碰过伺服驱动、变频器、PLC、运动控制卡这几类产品的&#xff0c;肯定对“实时性”这三个字有切肤之痛。你写完了位置环、速度环、电流环的控制算法&#xff0c;仿真波形也漂亮&#xff0c;一上…

作者头像 李华
网站建设 2026/9/29 22:54:51

CAN、UDS与OTA升级:嵌入式面试必考的7大核心模块详解

上个月给一位有两年嵌入式经验的同事做模拟面试&#xff0c;我问了一个不算冷门的问题&#xff1a;“UDS的19服务和29服务&#xff0c;分别在什么场景下用&#xff1f;”他当场卡住了。这位同事的简历上明确写着“熟悉CAN、UDS、OTA升级”&#xff0c;可真被问到协议细节&#…

作者头像 李华
网站建设 2026/9/29 22:54:34

ClawTeam 深度解析:用 git worktree 与 tmux 搭建多 Agent CLI 协作骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华