把探索路线当括号序列,用显式栈把每个子树折叠成排序儿子规范串拼接成的规范形,两串同构当且仅当规范形相同。
OJ: roj
题目 ID: 3051
难度:普及+/提高-
标签:树的最小表示哈希栈
创建: 2026-10-01 12:15
更新: 2026-10-07 13:50
形式化题目
设 0/1 串:
0:沿一条边走到离根更远的一站(第一次进入某个结点);1:沿一条边退回离根更近的一站(回到父亲)。
给定两条长度相等的探索路线串,判断是否存在同一棵树,使两条串都是它的探索路线(即两串描述的无标号有根树同构)。同构输出 same,否则 different。
数据范围:串长最多
样例第一组的两条串对应同一棵树(根的两个子树分别是:一棵挂“叶子 + 二元结点”的子树、一棵挂三片叶子的子树):
graph TD
R[中央车站] --- A
R --- B
A --- C
A --- D
D --- E
D --- F
B --- G
B --- H
B --- I
同一条路线在"每个结点先走哪个儿子"上有自由度,所以同一棵树可以产生很多不同的路线串——这正是本题要消除的信息。
正解
思路
朴素做法。 最直接的想法是按括号匹配把两条路线串各自还原成一棵显式树,再对两棵树做有根树同构判定(AHU:一个结点等价于"排序后的儿子子树")。但路线串本身就是"进入/离开"型的括号序列,建树只是把括号信息翻译一遍再读出来,绕了一圈。
关键观察:子树 ↔ 括号区间。 把 0 看成左括号、1 看成右括号,路线串是一个合法括号序列。每个结点对应其中一段区间(从第一次进入它到离开它),父子区间嵌套。同一棵树的两种路线,差别只在每一层儿子被访问的先后顺序——只要给每个子树一个与儿子顺序无关的"指纹",两条串等价当且仅当根的指纹相同。
规范形(树的最小表示)。 递归定义结点
- 前导
0和尾1对应"进入/离开"这两步路线动作,中间是各儿子子树的动作; - 对儿子规范串排序,正好抹掉"先走哪个儿子"这一同构不计的自由度;
- 叶子的规范串是
01。
整条路线串 0/尾 1,于是定义
对子树规模归纳可得:两条串来自同一棵树
在栈上直接折叠,跳过建树。 还原树时栈里保存的就是当前路径上的结点;而 AHU 自底向上合并时,每个结点只需要"收齐儿子的规范串"。于是让栈的每一层不存结点,直接存该结点已收集的儿子规范串列表:
- 读到
0(进入新结点):压入一个空列表; - 读到
1(退回父亲):弹出栈顶列表,把 追加进新栈顶——这个结点"封口"; - 串结束:弹出根的儿子规范串列表,排序拼接即规范形。
用样例二第一组演示(两个串描述的树不同构,规范形应当不同):
| 串 | 还原出的树(部分) | 根儿子规范串排序拼接 |
|---|---|---|
0100101100100111 |
根有两个儿子:一棵叶子,和一棵二元内结点(挂两棵叶子) | 0001101100101101 |
0011000111010101 |
根有四个儿子:二元结点、二元结点、叶子链结点、叶子(各挂若干叶子) | 0001110011010101 |
两个规范形不相等,输出 different。观察要点:排序把"访问顺序"这一自由度彻底消掉,剩下的差异全部来自树的真实形状。
实现细节与两个坑。
- 根层没有前导
0/尾1:循环结束时栈里只剩根的儿子列表,直接sort + join,不能再套"封口"(多出一对括号会让两条串的约定不一致)。只要两条串用同一套约定比较,结果就正确。 - 求值顺序坑:不能写
stack[-1].append(wrap(stack.pop()))——Python 先求值stack[-1](此刻栈顶还是即将弹出的那层),pop()之后再追加,结果被追加进已弹出的列表里直接丢失。必须先kids = stack.pop()再stack[-1].append(wrap(kids))。 - 递归深度:串长 3000 时链形树(
000…111…)的递归深度可达 1500,超出 CPython 默认递归限制,所以用显式栈迭代,不用递归 +setrecursionlimit。
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 12:20
# update_at: 2026-10-01 12:20
import sys
def wrap(kids: list[str]) -> str:
"""把若干儿子的规范串封成一个结点:0 + 儿子规范串排序拼接 + 自己回父亲的 1。"""
return '0' + ''.join(sorted(kids)) + '1'
def canon(s: str) -> str:
"""把一条探索路线折叠成树的同构不动点:每个点 = 0 + 儿子规范串排序拼接 + 一个 1。
同构的树无论从哪条路线遍历,折叠结果都相同;用显式栈自底向上合并,
避免链形地铁把递归深度推到 1500。
"""
stack: list[list[str]] = [[]] # 栈里是每个"还没封口"的结点的儿子规范串,栈底是根
for c in s:
if c == '0': # 走向更远的一站:开一个新结点
stack.append([])
else: # 退回:该结点的儿子已全部折叠完,封口
# 不能写 stack[-1].append(wrap(stack.pop())):绑定 append 时栈顶还没弹出,
# 结果会追加进刚弹出的那层被丢掉
kids = stack.pop()
stack[-1].append(wrap(kids))
return ''.join(sorted(stack.pop())) # 循环结束回到中央车站:根没有前导 0 和尾 1
def solve() -> None:
it = iter(sys.stdin.buffer.read().split())
t = int(next(it))
out = ['same' if canon(next(it).decode()) == canon(next(it).decode()) else 'different'
for _ in range(t)]
print('\n'.join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 16:30
* update_at: 2026-10-06 16:30
*/
// 把每条探索路线折叠成"树的最小表示"(规范形):0 看成进入、1 看成退回,
// 一个结点的规范串 = "0" + 儿子规范串排序后拼接 + "1"。
// 两条串来自同一棵树 <=> 规范形相同(AHU 有根树同构)。
// 用显式栈自底向上折叠,不建树也不递归(链形树递归深度可达 1500)。
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
typedef long long ll;
const int MAXLEN = 3005;
char route[MAXLEN]; // 当前读入的一条探索路线串
// 栈的每一层是"还没封口的结点"已收集的儿子规范串;栈底是根(没有前导 0 和尾 1)
vector<string> stk[MAXLEN / 2];
int top; // 栈顶下标,从 0 开始,stk[0] 是根的儿子列表
// 把若干儿子的规范串封成一个结点:0 + 排序拼接 + 回父亲的 1
string wrap_node(vector<string> &kids) {
sort(kids.begin(), kids.end()); // 排序抹掉"先走哪个儿子"这一同构不计的自由度
string res = "0";
for (int i = 0; i < (int)kids.size(); i++) res += kids[i];
res += "1";
return res;
}
// 把一条探索路线折叠成规范形
string canon() {
top = 0;
stk[0].clear();
for (int i = 0; route[i] != '\0'; i++) {
if (route[i] == '0') { // 走向更远的一站:开一个新结点
top++;
stk[top].clear();
} else { // 退回父亲:栈顶结点封口,并入父亲(新栈顶)的儿子列表
string node = wrap_node(stk[top]);
top--;
stk[top].push_back(node);
}
}
// 串结束回到中央车站:根层不封口,直接排序拼接(保证两条串用同一套约定比较)
sort(stk[0].begin(), stk[0].end());
string res = "";
for (int i = 0; i < (int)stk[0].size(); i++) res += stk[0][i];
return res;
}
int main() {
ll t;
scanf("%lld", &t);
while (t--) {
scanf("%s", route);
string c1 = canon();
scanf("%s", route);
string c2 = canon();
if (c1 == c2) printf("same\n");
else printf("different\n");
}
return 0;
}复杂度
设串长
总结
- 路线串是括号序列:
0= 进入、1= 离开,每个结点对应一个嵌套区间。 - 树同构 ⟺ 消除儿子访问顺序后的指纹相同:规范形 =
0+ 儿子规范串排序拼接 +1。 - 不必显式建树:栈的每层直接收集儿子规范串,
1触发封口,最后对根层排序拼接比较。 - 两个实现坑:根层不封口;
stack[-1].append(wrap(stack.pop()))的求值顺序陷阱。
图示解析
规范形的折叠过程
以样例一第一组的 0010011101001011 为例(位置从 1 数起),列出每个 1 触发的封口动作:
串: 0 0 1 0 0 1 1 1 0 1 0 0 1 0 1 1
位置: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
封口: 01 01 0011 00011011 01 01 001011
└2棵叶┘- 位置 3、6 的
1:两棵叶子封口成01;位置 7 的1让含这两棵叶子的二元结点封口成0011(它本身有两个儿子01+01); - 位置 8 的
1:左子树的父结点封口成00011011(儿子是0011和01); - 位置 10、13、15 的
1:右子树里三棵叶子封口;位置 16 的1让右子树的父结点封口成001011(三个儿子都是01); - 根层把两个儿子规范串
00011011、001011排序拼接,得到0001101100101101。
第二条路线 0100011011001011 访问儿子的顺序不同,中间封口位置也不同(第一个封口在位置 2),但根层拿到的儿子规范串集合完全相同,排序拼接后同样是 0001101100101101,故输出 same。可以看到:排序前两条路线的中间产物不同,排序后收敛到同一个不动点——"同构的不动点"正是这个算法的全部思想。
