验证二叉树的前序序列化 图解题解
这道题到底在问什么
- 输入
- "9,3,4,#,#,1,#,#,2,#,6,#,#"
- 输出
- true
- 输入
- "1,#"
- 输出
- false
- 输入
- "9,#,#,1"
- 输出
- false
先想最直接的笨办法
先把序列按逗号切成 13 个记号,挨个读。我们不重建树,只维护一个栈:每读一个记号压栈,栈顶一旦凑出「真值, #, #」就把这三个压成一个 #。读完后栈里恰好只剩一个 #,就合法。(动画第 4 步)
最优解:为什么这么做
一句话答案:LeetCode 331 验证二叉树的前序序列化:把逗号分隔的记号逐个压栈,栈顶一凑出「真值,#,#」就坍成一个 #,扫完栈里恰好剩单个 # 就合法,时间 O(n)、空间 O(n)。
一串记号,怎么判断它是不是合法的前序序列化
给一串逗号分隔的记号,数字是真实节点、# 是空节点,判断它是不是某棵二叉树的合法前序序列化——即按「先根、再左子树、再右子树」记、空孩子也补个 # 的那串;且不许真把树建出来。题面 preorder="9,3,4,#,#,1,#,#,2,#,6,#,#" 返回 true,"1,#"、"9,#,#,1" 返回 false。
光数 # 的个数,为什么判不出来
一棵有 m 个真实节点的二叉树恰好带 m+1 个空位,数 # 够不够不就行?可数量对不代表拼得成树。"1,#" 里真节点 1 只跟着一个 #,少了一个孩子、搭不起完整的树。合不合法卡在记号的先后次序,不在个数多少。
一棵完成的子树,和一个 # 没有区别
一个真实节点,只要左右孩子都到齐,以它为根的子树就整体看成一个空位——一棵搭完的子树,在父节点眼里和一个 # 没区别。于是不必知道整棵树的样子,只要反复把「一个真值后面紧跟两个 #」这种最小完整子树折成一个 #,看整串最后能不能折成单个 # 就行。这需要一个栈,也就是后进先出的一叠记号。
压栈、折叠,栈里最后该剩什么
把整串按逗号切成记号逐个压栈。每压一个查栈顶:最上面两个都是 #、下面第三个是真值(不是 #),就说明这个真值配齐了两个空孩子,把这三个弹掉、压回一个 #。折完可能又让新栈顶凑成「真值,#,#」,就接着折,直到栈顶不再是这形状。记号读完栈里恰好剩一个 # 就合法。第三个必须是真值不能省:只盯栈顶两个 # 就折,"#,#,#" 这种没有父节点的空孩子也会被错折进去。
拿题面这串记号从头折到尾
用题面的 9,3,4,#,#,1,#,#,2,#,6,#,# 走一遍。9、3、4 压栈成 9,3,4;两个 # 进来成 9,3,4,#,#,栈顶两 # 底下是真值 4,配齐两孩子折成 #,栈变 9,3,#。再压 1、#、#,栈到 9,3,#,1,#,#:先折 1,#,# 成 #(栈成 9,3,#,#),又让 3,#,# 露头接着折成 #,栈只剩 9,#。
接着 2,#,6,#,# 进来成 9,#,2,#,6,#,#:6,#,# 折成 #,紧跟 2,#,# 折成 #,最后 9,#,# 也折成一个 #。十三个记号读尽,栈里正好剩一个 #,返回 true,这串是合法序列化。
线性一趟,哪些串一压就露馅
每个记号压栈一次,折叠次数也不超过记号数,整趟线性 O(n)。空间花在栈上,最坏栈里压着近 n 个记号,是 O(n)。有个实现坑:参考代码用 Python 切片 stk[:-3] 重建前缀,最坏退化到 n 的平方;换成 del stk[-3:] 原地删末尾三个就稳在 O(n),C++、Java 原地弹栈天然 O(n)。
两种串一压就露馅。"1,#" 是树没搭完就到头:真节点 1 只等来一个 #,栈里留着 1,# 折不动,读完剩两个记号而非单个 #,false。"9,#,#,1" 反过来是树搭完了还拖尾巴:9,#,# 先折成一个 #,本该到此结束,后面偏冒出个 1,栈成 #,1,再折不回单个 #,同样 false。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记住这一句:真节点配齐两个 # 就坍缩成一个 #。下面每一帧都在套它。
- 4先把序列按逗号切成 13 个记号,挨个读。我们不重建树,只维护一个栈:每读一个记号压栈,栈顶一旦凑出「真值, #, #」就把这三个压成一个 #。读完后栈里恰好只剩一个 #,就合法。
- 5读入第 0 个 “9”,它是真实节点,直接压入栈顶。栈顶还没凑出「真值, #, #」的形状,不坍缩,继续往后读。
- 6读入第 1 个 “3”,真实节点压入栈顶。栈顶暂时还不构成可坍缩的形状,接着读下一个。
- 7读入第 2 个 “4”,真实节点压入栈顶。栈顶暂时还不构成可坍缩的形状,接着读下一个。
- 8读入第 3 个 “#”(空节点)压栈。栈顶只有一个 #,还差一个,先不坍缩,继续读。
- 9读入第 4 个 “#”(空节点)压栈,这下栈顶正好是「真值, #, #」,可以坍缩了。
- 10“4” 的左右孩子都到齐了,以它为根的小子树整体退化成一个空位,把栈顶三个 4, #, # 压成一个 #。坍缩后栈顶不再是可坍缩形状,暂停,接着读下一个记号。
- 11读入第 5 个 “1”,真实节点压入栈顶。栈顶暂时还不构成可坍缩的形状,接着读下一个。
- 12读入第 6 个 “#”(空节点)压栈。栈顶只有一个 #,还差一个,先不坍缩,继续读。
- 13读入第 7 个 “#”(空节点)压栈,这下栈顶正好是「真值, #, #」,可以坍缩了。
- 14“1” 的左右孩子都到齐了,以它为根的小子树整体退化成一个空位,把栈顶三个 1, #, # 压成一个 #。坍缩后栈顶又凑出「真值, #, #」,继续往下坍。
- 15“3” 的左右孩子都到齐了,以它为根的小子树整体退化成一个空位,把栈顶三个 3, #, # 压成一个 #。坍缩后栈顶不再是可坍缩形状,暂停,接着读下一个记号。
- 16读入第 8 个 “2”,真实节点压入栈顶。栈顶暂时还不构成可坍缩的形状,接着读下一个。
- 17读入第 9 个 “#”(空节点)压栈。栈顶只有一个 #,还差一个,先不坍缩,继续读。
- 18读入第 10 个 “6”,真实节点压入栈顶。栈顶暂时还不构成可坍缩的形状,接着读下一个。
- 19读入第 11 个 “#”(空节点)压栈。栈顶只有一个 #,还差一个,先不坍缩,继续读。
- 20读入第 12 个 “#”(空节点)压栈,这下栈顶正好是「真值, #, #」,可以坍缩了。
- 21“6” 的左右孩子都到齐了,以它为根的小子树整体退化成一个空位,把栈顶三个 6, #, # 压成一个 #。坍缩后栈顶又凑出「真值, #, #」,继续往下坍。
- 22“2” 的左右孩子都到齐了,以它为根的小子树整体退化成一个空位,把栈顶三个 2, #, # 压成一个 #。坍缩后栈顶又凑出「真值, #, #」,继续往下坍。
- 23“9” 的左右孩子都到齐了,以它为根的小子树整体退化成一个空位,把栈顶三个 9, #, # 压成一个 #。坍缩后栈只剩一个 #,进入终局判定。
- 2413 个记号全部读完,栈里恰好只剩一个 “#”。这说明整串正好拼成一棵完整二叉树的前序序列化,合法,返回 true,正是示例 1 的答案。
⚠️ 容易写错的地方
✗ 错:只数 # 的个数、不管顺序
✓ 对:必须用栈判断「真值, #, #」的局部形状
顺序非法的串(如 #,1)即便 # 数量看着对也不合法
✗ 错:树已完成后多余记号没判出
✓ 对:坍缩到底若栈提前只剩一个 #,后面还来记号就非法
9,#,#,1 里树已完成又冒出 1
✗ 错:坍缩条件漏判第三个不是 #
✓ 对:必须确认被消的父节点 stk[-3] 是真值(不是 #)才坍缩
只看栈顶两个 # 就坍缩会错折叠 #,#,# 这种空孩子,真节点配齐两个 # 子树才算完整
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def isValidSerialization(self, preorder: str) -> bool:
stk = []
for c in preorder.split(","):
stk.append(c)
while len(stk) > 2 and stk[-1] == stk[-2] == "#" and stk[-3] != "#":
stk = stk[:-3]
stk.append("#")
return len(stk) == 1 and stk[0] == "#"C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
bool isValidSerialization(string preorder) {
vector<string> stk;
stringstream ss(preorder);
string s;
while (getline(ss, s, ',')) {
stk.push_back(s);
while (stk.size() >= 3 && stk[stk.size() - 1] == "#" && stk[stk.size() - 2] == "#" && stk[stk.size() - 3] != "#") {
stk.pop_back();
stk.pop_back();
stk.pop_back();
stk.push_back("#");
}
}
return stk.size() == 1 && stk[0] == "#";
}
};Java
import java.util.*;
class Solution {
public boolean isValidSerialization(String preorder) {
List<String> stk = new ArrayList<>();
for (String s : preorder.split(",")) {
stk.add(s);
while (stk.size() >= 3 && stk.get(stk.size() - 1).equals("#")
&& stk.get(stk.size() - 2).equals("#") && !stk.get(stk.size() - 3).equals("#")) {
stk.remove(stk.size() - 1);
stk.remove(stk.size() - 1);
stk.remove(stk.size() - 1);
stk.add("#");
}
}
return stk.size() == 1 && stk.get(0).equals("#");
}
}复杂度
时间·整体思路
O(n)
每个记号压栈一次,坍缩总次数也不超过记号数,本身是线性的
时间·分语言
C++/Java O(n)
C++/Java 原地弹栈是 O(1),整体 O(n);Python 这版用切片 stk[-3:] 重建前缀,最坏退化到 n 的平方,想保证 O(n) 可改成 del stk[-3:] 或连续 pop 三次再 append
空间
O(n)
最坏情况栈里同时存近 n 个记号
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 验证二叉树的前序序列化 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
除了栈,有没有更省空间的判断法?+
有,叫槽位法,只用一个计数器。把每个待填的位置看成一个槽:根一开始给 1 个槽;每读到一个真实节点,它占掉 1 个槽、同时带来 2 个新槽(留给左右孩子);每读到一个 #,占掉 1 个槽、不带来新槽。过程中槽数一旦提前归零、后面却还有记号,就非法;全部读完槽数恰好为 0 才合法。它判的是和栈法同一件事——真节点配齐两个孩子——但空间降到 O(1)。
题目为什么强调不许重建树?+
重建要额外开节点、还得递归拼左右子树,空间和代码都更重。而判合法性根本用不着知道整棵树长什么样:只要认准「一个真节点配齐两个孩子就退化成一个空位」这条局部规则,反复把搭完的子树折成 #,折到最后看是不是单个 # 就够了。栈式消除正是把这条规则一遍扫描落地,全程没有真的把树建起来。
为什么坍缩前非得确认第三个是真值?+
因为要折的是「一个真节点带齐两个空孩子」这种完整子树,父节点必须是真值。假如只看栈顶两个 # 就折,遇到 "#,#,#" 也会照折不误,可这三个 # 里最下面那个是别处的空位、不是这两个 # 的父亲,折了就把不相干的空孩子错并到一起,判断跟着出错。加上 stk[-3] 不是 # 这个条件,才只对真正配好对的子树动手。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 验证二叉树的前序序列化 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。