二叉树的序列化与反序列化 图解题解
把一棵树变成字符串,还能原样还原——关键在空节点怎么处理。
给树拍「带空位说明书」的全家福:用前序遍历依次写下每个节点的值,遇到空孩子就写一个占位符 #——不能省,因为省了就不知道这里是空还是有子树。还原时照着说明书前序顺序读:读到值就建节点、递归建它的左右;读到 # 就停,返回空。序列化和反序列化消费顺序完全镜像,树结构可完美重建。
这道题到底在问什么
- 输入
- 树 [1,2,3,null,null,4,5]
- 输出
- "1,2,#,#,3,4,#,#,5,#,#"
最优解:为什么这么做
一句话答案:LeetCode 297 二叉树的序列化与反序列化的经典解法是先序 DFS 加占位符:序列化按根、左、右的顺序把节点值写成字符串,空孩子写 # 占位;反序列化把字符串切成 token 队列,按完全相同的先序顺序逐个取出重建。两个方向各处理每个 token 一次,时间 O(n)、空间 O(n)。
这道题真正要设计的是什么
题目要求写一对互逆的函数:serialize 把任意二叉树编码成一个字符串,deserialize 再把这个字符串还原成一模一样的树,deserialize(serialize(root)) 必须等于原树。难点不在遍历本身,而在于字符串必须携带足够的信息——不但要记录每个节点的值,还要能还原树的形状。同样一串值 1,2,3,既可能是一条斜向一边的链,也可能是一棵根带两个孩子的树,只记值是不够的。
为什么必须给空孩子写 # 占位符
形状信息丢失的根源在于:普通遍历序列把空位置跳过了,读串的人分不清「下一个值是当前节点的孩子,还是拐回上层挂在别处」。补救办法是把空也当成一个字符写出来:先序遍历时每碰到一个空孩子就写一个 #。这样每个节点名下左、右两个孩子位置都有明确交代,值序列和树的形状就一一对应了。
代价是串变长:n 个节点会带出 n+1 个 #(共 2n 个孩子位置,其中 n-1 个被真实节点占据)。多出的这点长度换来的是零歧义,这是序列化方案里常见的交易。
为什么选先序遍历而不是中序或层序
先序的好处是根永远排在串的最前面:反序列化取出第一个 token 就能立刻建出根;按「根、左、右」的定义,紧随其后的一段恰好是完整的左子树,再往后是完整的右子树,天然适合递归重建。中序把根夹在左右子树中间,拿到串时不知道该从哪里切开,难以定位根。层序配合占位符同样可行(用队列逐层重建),但先序递归的代码最短,还和序列化共用同一套遍历骨架。真正的铁律只有一条:序列化和反序列化必须用同一种顺序,顺序一旦错位,孩子就会挂错位置、整棵树扭曲。
反序列化为什么取一个 token 就能精确归位
把字符串按逗号切成 token 队列后,重建函数的逻辑是:从队头取一个 token,是 # 就返回空,否则建节点、先递归建左子树、再递归建右子树。正确性来自先序串的结构不变量:任何一棵子树在串里都是连续的一段,且这段的开头就是该子树的根。递归建左子树时会恰好消费掉左子树对应的那段 token,返回时队头自动停在右子树的开头——不需要任何额外的分隔符或长度标记。
一个隐蔽的坑:# 也必须从队列里消费掉。它虽然不建节点,但占着一个位置,不弹出的话后续 token 全部错位,还原出来的树会整体歪掉。
复杂度是多少,哪里容易翻车
序列化和反序列化都是每个 token 恰好处理一次,时间各为 O(n)。空间 O(n):字符串本身是线性长度,递归栈深等于树高 h,最坏退化成链时也是 O(n)。
两个高频错误:一是不写空占位符,看似省了空间,实则丢了形状信息,根本还原不出唯一的树;二是两边遍历顺序不一致,比如序列化用先序、反序列化却按别的顺序读,token 数量对得上但结构全错。
▶ 动画逐步走查(共 42 步)——想跟着动画一帧帧对照就展开
- 3一句话抓住本质:序列化用先序(根→左→右)把值和 # 占位写成一串;反序列化把这串切成 token 队列,按完全相同的先序顺序一个个取出来重建。顺序一致是可逆的关键。下面两阶段逐帧看。
- 4从根 1 出发,先序遍历先看清这棵树,根是 1,一共 9 个节点。序列化就是把这棵树压成一行字符串:我们用先序遍历(根→左→右),每碰到一个节点就把它的值记下来;碰到空位置就记一个 # 占位,这样结构才不会丢。
- 5访问节点 1(根)→ 追加 "1"先序遍历踩到节点 1(根)。根先写:把它的值 1 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 6访问节点 2(1 的左孩子)→ 追加 "2"先序遍历踩到节点 2(1 的左孩子)。根先写:把它的值 2 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 7访问节点 4(2 的左孩子)→ 追加 "4"先序遍历踩到节点 4(2 的左孩子)。根先写:把它的值 4 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 8访问节点 8(4 的左孩子)→ 追加 "8"先序遍历踩到节点 8(4 的左孩子)。根先写:把它的值 8 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 98 的左孩子 为空 → 追加 "#"走到「8 的左孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 108 的右孩子 为空 → 追加 "#"走到「8 的右孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 11访问节点 9(4 的右孩子)→ 追加 "9"先序遍历踩到节点 9(4 的右孩子)。根先写:把它的值 9 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 129 的左孩子 为空 → 追加 "#"走到「9 的左孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 139 的右孩子 为空 → 追加 "#"走到「9 的右孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 14访问节点 5(2 的右孩子)→ 追加 "5"先序遍历踩到节点 5(2 的右孩子)。根先写:把它的值 5 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 155 的左孩子 为空 → 追加 "#"走到「5 的左孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 165 的右孩子 为空 → 追加 "#"走到「5 的右孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 17访问节点 3(1 的右孩子)→ 追加 "3"先序遍历踩到节点 3(1 的右孩子)。根先写:把它的值 3 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 18访问节点 6(3 的左孩子)→ 追加 "6"先序遍历踩到节点 6(3 的左孩子)。根先写:把它的值 6 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 196 的左孩子 为空 → 追加 "#"走到「6 的左孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 206 的右孩子 为空 → 追加 "#"走到「6 的右孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 21访问节点 7(3 的右孩子)→ 追加 "7"先序遍历踩到节点 7(3 的右孩子)。根先写:把它的值 7 追加到字符串末尾,标蓝表示已写出。接着按「先左后右」继续往下走。
- 227 的左孩子 为空 → 追加 "#"走到「7 的左孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 237 的右孩子 为空 → 追加 "#"走到「7 的右孩子」,这里是空的。先序序列化遇到空位置不能跳过——必须写一个 # 占位,否则反序列化时分不清「有没有这个孩子」。串末尾追加 #。
- 24序列化字符串 = "1,2,4,8,#,#,9,#,#,5,#,#,3,6,#,#,7,#,#"整棵树走完了,先序序列化得到这一行字符串:1,2,4,8,#,#,9,#,#,5,#,#,3,6,#,#,7,#,#。9 个节点值 + 10 个 # 占位,共 19 个 token。结构信息全在里面、毫无歧义——下面把它反序列化,原样重建出这棵树。
- 25token 队列 = "1,2,4,8,#,#,9,#,#,5,#,#,3,6,#,#,7,#,#"反序列化从一棵空树开始。关键:用和序列化完全相同的先序顺序,把字符串切成 token 队列,从队头一个个取。取到值就建节点,取到 # 就是空、回头。顺序一致,重建出的树就和原树一模一样。
- 26根:token = 1 → 新建节点从队头取出 1,新建一个值为 1 的节点(根),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 271 的左孩子:token = 2 → 新建节点从队头取出 2,新建一个值为 2 的节点(1 的左孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 282 的左孩子:token = 4 → 新建节点从队头取出 4,新建一个值为 4 的节点(2 的左孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 294 的左孩子:token = 8 → 新建节点从队头取出 8,新建一个值为 8 的节点(4 的左孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 308 的左孩子:token = # → 这里是空从队头取出 #。# 代表「8 的左孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 318 的右孩子:token = # → 这里是空从队头取出 #。# 代表「8 的右孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 324 的右孩子:token = 9 → 新建节点从队头取出 9,新建一个值为 9 的节点(4 的右孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 339 的左孩子:token = # → 这里是空从队头取出 #。# 代表「9 的左孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 349 的右孩子:token = # → 这里是空从队头取出 #。# 代表「9 的右孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 352 的右孩子:token = 5 → 新建节点从队头取出 5,新建一个值为 5 的节点(2 的右孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 365 的左孩子:token = # → 这里是空从队头取出 #。# 代表「5 的左孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 375 的右孩子:token = # → 这里是空从队头取出 #。# 代表「5 的右孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 381 的右孩子:token = 3 → 新建节点从队头取出 3,新建一个值为 3 的节点(1 的右孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 393 的左孩子:token = 6 → 新建节点从队头取出 6,新建一个值为 6 的节点(3 的左孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 406 的左孩子:token = # → 这里是空从队头取出 #。# 代表「6 的左孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 416 的右孩子:token = # → 这里是空从队头取出 #。# 代表「6 的右孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 423 的右孩子:token = 7 → 新建节点从队头取出 7,新建一个值为 7 的节点(3 的右孩子),它在树上浮现出来(绿)。接着先递归建它的左子树、再建右子树——和序列化时「根→左→右」的顺序严丝合缝。
- 437 的左孩子:token = # → 这里是空从队头取出 #。# 代表「7 的左孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
- 447 的右孩子:token = # → 这里是空从队头取出 #。# 代表「7 的右孩子」是空的——不建节点、直接返回,让上一层去处理它的下一个孩子。# 占位在这里发挥作用:精准告诉我们「此处无孩子」。
⚠️ 容易写错的地方
✗ 错:不写空节点占位
✓ 对:空位置必须写 # 标记
不记空位,[1,2,3] 会有歧义、还原不出形状
✗ 错:反序列化顺序和序列化不一致
✓ 对:两边都用先序「根→左→右」
顺序一旦错位,挂错左右子树、整棵树扭曲
✗ 错:忘了 # 也要消费一个 token
✓ 对:取到 # 同样要从队列弹出
不弹出会指针错位,后面全乱
完整代码(Java / Python / C++)
Java
public class Codec {
// 序列化:先序遍历,空节点写 "#",逗号分隔
public String serialize(TreeNode root) {
StringBuilder sb = new StringBuilder();
dfsSer(root, sb);
return sb.toString();
}
private void dfsSer(TreeNode node, StringBuilder sb) {
if (node == null) { sb.append("#,"); return; } // 空 → # 占位
sb.append(node.val).append(","); // 根先写
dfsSer(node.left, sb); // 再左
dfsSer(node.right, sb); // 后右
}
// 反序列化:按逗号切成队列,先序逐个重建
public TreeNode deserialize(String data) {
Deque<String> q = new ArrayDeque<>(Arrays.asList(data.split(",")));
return build(q);
}
private TreeNode build(Deque<String> q) {
String t = q.poll(); // 取队头
if (t.equals("#")) return null; // # → 空
TreeNode node = new TreeNode(Integer.parseInt(t));
node.left = build(q); // 先建左
node.right = build(q); // 再建右
return node;
}
}Python
class Codec:
def serialize(self, root):
out = []
def dfs(node):
if node is None:
out.append("#") # 空 → # 占位
return
out.append(str(node.val)) # 根先写
dfs(node.left) # 再左
dfs(node.right) # 后右
dfs(root)
return ",".join(out)
def deserialize(self, data):
vals = iter(data.split(",")) # token 队列
def build():
t = next(vals) # 取队头
if t == "#":
return None # # → 空
node = TreeNode(int(t))
node.left = build() # 先建左
node.right = build() # 再建右
return node
return build()C++
class Codec {
public:
string serialize(TreeNode* root) {
string s;
function<void(TreeNode*)> dfs = [&](TreeNode* n){
if (!n) { s += "#,"; return; } // 空 → # 占位
s += to_string(n->val) + ","; // 根先写
dfs(n->left); // 再左
dfs(n->right); // 后右
};
dfs(root);
return s;
}
TreeNode* deserialize(string data) {
stringstream ss(data); string t;
queue<string> q;
while (getline(ss, t, ',')) if (!t.empty()) q.push(t);
function<TreeNode*()> build = [&]() -> TreeNode* {
string x = q.front(); q.pop(); // 取队头
if (x == "#") return nullptr; // # → 空
TreeNode* n = new TreeNode(stoi(x));
n->left = build(); // 先建左
n->right = build(); // 再建右
return n;
};
return build();
}
};复杂度
时间
O(n)
序列化/反序列化各访问每个 token 一次
空间
O(n)
字符串 O(n) + 递归栈 O(h);最坏(链)O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 二叉树的序列化与反序列化 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这道题为什么用「二叉树」,换最直接的暴力解会差在哪?+
二叉树抓住了本题的结构特征,把暴力解里重复的工作省掉;暴力解通常要多嵌套一层枚举,数据一大就超时。具体对比见上文「暴力解及其卡点」与「最优解逐步推演」两节。
时间复杂度为什么是 undefined?怎么推出来的?+
按上文复杂度小节的推导,时间复杂度为 undefined。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 二叉树的序列化与反序列化 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。