序列化二叉树:数字记内容,井号记形状
前序把树拍平。节点值保住「写了什么」;每个空孩子写成 #,保住「哪里没有孩子」。少一边都还原不了。
序列化:前序遍历,nil 写成 "#",节点值转字符串后以逗号连接。反序列化:把字符串拆成 token 队列,递归函数每次消费一个 token——"#" 返回 nil,否则创建节点并递归左右。时间 O(n),空间 O(n)。
要设计一套编解码:把二叉树写成字符串,再从这串字还原出结构完全相同的树。节点值要在,左右孩子的有无也要在;只交一串数字不够。
主例树 [1, 2, 3, null, null, 4, 5]:根 1,左叶 2,右子树是 3,3 再挂 4 和 5。前序编码是 "1,2,#,#,3,4,#,#,5,#,#"。五个数字是内容,六个 # 是 2、4、5 各自缺失的左右孩子。
只存层序里的非空值,1,2,3,4,5 可以对应许多种挂法。本文要回答:# 究竟补上了哪一块缺失信息,以及重建时为什么「每吃一个 token 就建好一棵子树」。
值留下内容,# 留下空位
第一直觉是层序把非空节点列出来,或只做前序不写空。主例前序非空是 1,2,3,4,5。少了空位之后,2 的两个空孩子和 3 的两个非空孩子在序列里分不出来:你不知道 3 该接在 2 后面当右兄弟,还是接在 2 下面当孩子。缺的不是值,是形状。
约定:碰到节点就写下它的值,碰到空孩子就写一个 #。前序顺序是根、整棵左、整棵右,和递归的调用顺序同一句话。每个节点固定贡献 1 个值 token 加它左右两个子树的编码;每个空指针贡献恰好一个 #。整串因此自包含。
用手走主例。先写根 1,演示第一帧只有 ["1"]。进入左子树:节点 2,再写它的左空、右空,编码变成 1,2,#,#。2 是叶子,两个 # 钉死「这里没有第三层」。回到 1 再走右子树:3,然后 4 带两个 #,5 带两个 #。完整串 1,2,#,#,3,4,#,#,5,#,#,与演示第三帧一致。
数一下:5 个值 token 对应 5 个节点;6 个 # 对应 6 个空指针。二叉树 n 个节点恰好有 n+1 个空孩子指针(把「没有的左右」都算上),再加根非空,token 总数是 2n+1。主例 n=5,11 个 token,对得上。少写一个 #,后面的数字就会被吃进错误的子树。
自包含编码值回答「节点上写了什么」,# 回答「这个位置有没有节点」。两样都写进字符串,形状才能唯一还原。只存 1,2,3,4,5 会丢形状。
一个 token 换一棵子树
解码侧缺的是「现在该建哪一块」。把字符串按逗号拆开,用下标 i 指向下一个未消费的 token。递归合同只有一句:给我当前这个 token,我建好一棵子树,并把它占用的全部 token 吃完。
token 是 #:这棵子树是空的,i 加一,返回 nil。token 是数字:新建节点,i 加一,然后先递归建左、再递归建右——因为编码就是前序,左子树的 token 紧挨在根后面,右子树的 token 紧挨在左子树编码后面。
用手走主例。i=0 读到 1,建根,演示第一帧。根的左子树接着读 2,2 的左孩子读到 #,返回 nil,演示第二帧标的正是「2 的左孩子为空」。2 的右孩子再吃一个 #。左子树结束,轮到 3、4、5 各自带上自己的 #。最后一个 # 被 5 的右孩子吃掉,i 走到 11,整棵树完成。
每个 token 恰好消费一次,不会回头。前序保证「左整棵的编码是一段连续前缀」,所以不必在字符串里再标左右边界。层序加 # 也能编,队列写法更长;BST 可以省略部分空位,普通二叉树不行。
一个递归合同「给我下一个 token,我建好一棵子树并消费它占用的全部 token」。# 消费 1 个;非空节点消费 1 个值,再加上左右两棵子树消费的全部。
Go:前序编解码
func serialize(root *TreeNode) string {if root == nil { return "#" }return strconv.Itoa(root.Val) + "," + serialize(root.Left) +"," + serialize(root.Right)}func deserialize(data string) *TreeNode {tokens := strings.Split(data, ",")i := 0var build func() *TreeNodebuild = func() *TreeNode {if tokens[i] == "#" { i++; return nil }v, _ := strconv.Atoi(tokens[i])i++return &TreeNode{Val: v, Left: build(), Right: build()}}return build()}
1空指针写成 #,这是形状信息。漏写一个,后面的值会错位。
2非空节点:值 + 左编码 + 右编码,逗号隔开。主例从 1 接到 2,#,# 再接到 3 那一段。
3拆 token 后用闭包里的 i 做游标。build 每调用一次就消费当前这棵子树。
4读到 # 立刻返回 nil,不要再去建左右。
5否则先前进 i,再 Left: build()、Right: build(),顺序必须与序列化一致。
总结
值记内容,# 记形状;前序拍平,按 token 再长回来。主例 11 个 token。
- 只存 1,2,3,4,5 会丢空位,2 的叶子和 3 的内节点分不清。
- 前序顺序就是递归消费顺序,不必另标左右边界。
- n 个节点对应 2n+1 个 token。主例 5 个值加 6 个 #。