二叉树的编码与解码:Swift 算法俱乐部中的序列化与反序列化实战
【免费下载链接】swift-algorithm-clubAlgorithms and data structures in Swift, with explanations!项目地址: https://gitcode.com/gh_mirrors/sw/swift-algorithm-club
本文是 Swift Algorithm Club 中 Encode and Decode Tree 专题的深度解读。二叉树是非线性结构,节点之间携带父子关系等位置信息,无法像数组那样直接传输。本文围绕该项目提供的
BinaryNode与BinaryNodeCoder实现,完整讲解如何用pre-order(前序)遍历 + 分隔符 + 空节点哨兵将一棵树序列化为字符串(编码),再将该字符串还原为结构完全一致的树(解码)。读完本文,你将掌握一套可直接复用的二叉树序列化方案,并理解其背后的数据结构与算法取舍。
为什么树需要"编码"?
与数组、链表这类线性集合不同,树是非线性结构:每个元素不仅承载数据,还隐含了与其他元素之间的位置关系,例如父子关系、左右子树的划分。当我们需要把一棵树发送给后端、存入数据库或跨进程传递时,单靠节点值本身是不够的——接收方必须能从中恢复出树的"形状"。
因此我们需要一套规则,把树这种结构化的对象转换成可以传输的扁平形式(如String),这个过程称为编码(Encoding),也叫序列化(Serializing);反过来,把扁平的编码数据还原成树,则称为解码(Decoding),也叫反序列化(Deserializing)。
项目中 readme 原文给出了一句关键结论:
Encoding and decoding strategies are closely related. The way you choose to encode a tree directly affects how you might decode a tree.
也就是说,编码与解码策略必须配套设计:编码时丢失的信息,解码时永远无法找回;编码时额外保留的信息,则决定了树能否被唯一地重建。
前置知识:本项目中的二叉树节点模型
在深入编码之前,需要先了解二叉树的底层概念。项目中提供了独立的 Binary Tree 专题,其中定义了二叉树的经典递归结构:每个节点最多有 0、1、2 个子节点,分别称为左孩子与右孩子;没有子节点的节点称为叶子节点;最顶端的节点称为根节点。
本文的编码专题使用了基于类的节点实现。完整定义位于 EncodeAndDecodeTree.swift:
public class BinaryNode<Element: Comparable> { public var val: Element public var left: BinaryNode? public var right: BinaryNode? public init(_ val: Element, left: BinaryNode? = nil, right: BinaryNode? = nil) { self.val = val self.left = left self.right = right } }值得注意的细节:
Element: Comparable泛型约束表示节点值必须可比较。从源码结构看,这更多是沿用了二叉搜索树场景的约束习惯,编码算法本身只依赖值的字符串描述能力;left与right都是可选类型(Optional),空孩子节点在模型上就是nil,这一点是后续编码中用"空哨兵"标记的前提;- 初始化器提供了默认参数,可以便捷地构造叶子节点:
BinaryNode("a")。
编码策略的设计
三条核心规则
本项目采用的编码策略在 readme 中明确列出:
- 编码结果是一个
String对象; - 使用 pre-order(前序)遍历访问节点;
- 节点值之间用分隔符
,区分,空的孩子节点用哨兵字符X标记。
为什么选择前序遍历?因为配合"空节点也占位"的规则,前序序列可以唯一确定一棵二叉树:前序的第一个元素永远是根节点;之后每遇到一个值就建立节点,每遇到一个X就说明该分支到此为止。这种"边读边回溯"的性质使得解码可以只靠一个线性序列完成,不需要额外记录节点数量或层级信息。
两个关键约定:分隔符与空哨兵
编码实现中定义了两个私有常量(见 EncodeAndDecodeTree.swift):
private let separator: Character = "," private let nilNode = "X"分隔符separator:用于区分序列中相邻的节点值。readme 用一个非常形象的例子说明了它的必要性——如果某棵树的编码结果是字符串"banana",在没有分隔符的情况下,我们无法判断它原本是"b"和"anana"两个节点,还是"ban"和"ana",还是其他任何切分方式。分隔符的存在让节点边界变得明确、无歧义。
空哨兵nilNode:用于标识缺失的孩子节点。因为解码时需要重建完整的树结构,某个节点"没有左孩子"和"左孩子还没处理到"这两种状态必须能区分开来。X就是"此处没有节点"的显式标记。
前序遍历的实现
节点类型扩展了preOrderTraversal方法(EncodeAndDecodeTree.swift):
public func preOrderTraversal(visit: (Element?) throws -> ()) rethrows { try visit(val) if let left = left { try left.preOrderTraversal(visit: visit) } else { try visit(nil) } if let right = right { try right.preOrderTraversal(visit: visit) } else { try visit(nil) } }这段代码有两个容易被忽略的设计点:
- 遍历顺序是根 → 左子树 → 右子树,即经典前序;
- 当左或右孩子为
nil时,依然调用一次visit(nil),把"空"作为一个普通元素写入访问序列。这正是编码算法得以重建结构的关键——序列中每个真实节点都恰好对应两个空位标记(左、右孩子),位置信息因此被完整保留。
编码实现剖析
编码器BinaryNodeCoder类同时遵守两个协议(EncodeAndDecodeTree.swift):
protocol BinaryNodeEncoder { func encode<T>(_ node: BinaryNode<T>?) throws -> String } protocol BinaryNodeDecoder { func decode<T>(from string: String) -> BinaryNode<T>? } public class BinaryNodeCoder<T: Comparable>: BinaryNodeEncoder, BinaryNodeDecoder { // ... }encode方法(EncodeAndDecodeTree.swift):
public func encode<T>(_ node: BinaryNode<T>?) throws -> String { var str = "" node?.preOrderTraversal { data in if let data = data { let string = String(describing: data) str.append(string) } else { str.append(nilNode) } str.append(separator) } return str }逐步拆解:
- 对根节点调用
preOrderTraversal,用闭包消费每一次访问; - 访问到真实节点时,通过
String(describing: data)把任意Comparable值转换成字符串;访问到空节点时追加X; - 无论值还是空标记,每次访问后都追加一个分隔符
,,保证相邻 token 互不粘连。
例如一棵只有两个节点"ba"(根)和"nana"(左孩子)的树,编码结果大致为"ba,nana,X,X,X,"。注意编码结果是带尾部分隔符的,decode侧的split会自然忽略空尾元素。
值得补充的是:readme 中最初设计的接口是func encode<T>(_ node: BinaryNode<T>) throws -> String where T: Encodable,即计划借助 Swift 标准库的Codable体系;而仓库最终落地实现(见 EncodeAndDecodeTree.swift)选择用String(describing:)完成值到字符串的转换,使算法不依赖节点类型实现Encodable协议,通用性更强,同时保留了throws签名以兼容未来的错误处理。
解码实现剖析
解码是编码的精确逆操作。编码规则已经隐含了解码所需的全部信息:
- 用
,切分序列得到一个个 token; - token 为
X表示空节点,其余表示真实节点值; - 按照前序顺序递归重建:先建根,再建左子树,最后建右子树。
利用"数组即栈"的优化
公有decode方法(EncodeAndDecodeTree.swift):
public func decode<T>(from string: String) -> BinaryNode<T>? { var components = string.split(separator: separator).reversed().map(String.init) return decode(from: &components) }这里有一个精心设计的性能优化:
split(separator:)按,切分,自动过滤空 token;- 切分结果先
reversed()再交给递归函数; - 递归函数内部使用
removeLast()而非removeFirst()取元素。
为什么要反转?因为 Swift 数组的removeFirst()是O(n)操作(需要整体前移元素),而removeLast()是O(1)。先把数组反转,就能用"数组当作栈"的方式从尾部弹出元素,把解码的取元素成本从 O(n²) 降为 O(n)。readme 明确指出:"Thereversestep is an optimization for the next function, allowing us to usearray.removeLast()instead ofarray.removeFirst()."
递归重建的核心逻辑
私有递归方法(EncodeAndDecodeTree.swift):
private func decode<T>(from array: inout [String]) -> BinaryNode<T>? { guard !array.isEmpty else { return nil } let value = array.removeLast() guard value != nilNode, let val = value as? T else { return nil } let node = BinaryNode<T>(val) node.left = decode(from: &array) node.right = decode(from: &array) return node }递归逻辑拆解:
- 数组为空:序列耗尽,返回
nil(对应编码时空哨兵之后不再有节点的情况); - 弹出栈顶 token:如果是
X,说明这里是空节点,直接返回nil;否则尝试用value as? T把字符串转回泛型类型,转换失败同样视为空节点; - 先建根、再递归左右:
left = decode(...)会消耗掉左子树对应的全部 token,之后right = decode(...)继续消耗右子树的 token。由于前序序列中"值—左子树—右子树"天然有序,递归顺序与编码顺序完全对称,树结构得以忠实还原。
完整可运行示例
项目在 EncodeAndDecodeTree.playground/Contents.swift 中提供了可直接运行的演示代码。构造一棵五节点二叉树:
let coder = BinaryNodeCoder<String>() let node1 = BinaryNode("a") let node2 = BinaryNode("b") let node3 = BinaryNode("c") let node4 = BinaryNode("d") let node5 = BinaryNode("e") node1.left = node2 node1.right = node3 node3.left = node4 node3.right = node5 let encodeStr = try coder.encode(node1) print(encodeStr)这棵树的形状是:
a / \ b c / \ d e编码结果为a,b,X,X,c,d,X,X,e,X,X,——逐段解读:根a;b的左孩子、右孩子都是空(两个X);c的左孩子d和右孩子e,它们各自的左右孩子又都是空(各两个X)。
接着解码并打印还原后的树:
let root: BinaryNode<String> = coder.decode(from: encodeStr)! printTree(root)Playground 中的printTree以"值 + 左右孩子"的缩进形式输出,运行结果确认了解码后的树与原始树完全一致:
val: a left: b right: c val: b left: nil right: nil val: c left: d right: e val: d left: nil right: nil val: e left: nil right: nilreadme 中还用一棵 8 节点树展示了更完整的过程:原始树、编码字符串830,202,169,X,X,701,X,X,7838,3924,2506,X,X,4936,X,X,8391,X,8423,X,X,与解码后的树三者一一对应,验证了方案的"往返一致性"(round-trip 完整性)。
边界情况与使用约束
基于对 EncodeAndDecodeTree.swift 实现的逐行分析,实际使用中需要留意以下边界:
1. 节点值不能包含分隔符,。编码时直接用,连接所有 token,解码时也按,切分。若节点值本身含有逗号(如字符串"a,b"),会被错误拆成两个 token,导致解码结果错乱。这是该类简单序列化方案的固有局限,可以通过对值做转义或改用长度前缀等方案规避,但会显著增加复杂度。
2. 节点值不能与空哨兵X冲突。若真实节点值恰好就是字符串"X",解码时会把它误判为空节点。选择X正是因为它极少出现在真实业务数据中,但并非绝对安全。
3. 空树(nil根节点)的编码。encode中对node?使用了可选链调用,根节点为nil时不会触发任何访问,返回空字符串"";而decode对空字符串执行split得到空数组,递归入口直接返回nil。因此空树可以正确往返。
4. 类型转换的静默失败。解码时value as? T失败会返回nil而不是抛出错误。若调用方用错误的泛型类型解码(例如把数值树当成字符串树解码),得到的结果可能不完整且没有显式报错,生产环境中建议对解码结果做非空校验。
5. 非平衡树的递归深度。解码依赖递归调用,递归深度等于树高。对于极端退化的链状树(如只有右孩子的树),深递归可能造成栈溢出风险,可推断这是本实现针对普通二叉树场景的设计取舍。
复杂度分析
| 阶段 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 编码 | O(n):前序遍历每个节点恰好访问一次 | O(n):字符串存储 + 递归调用栈(栈深为树高) |
| 解码 | O(n):split线性切分、反转 O(n)、removeLast()每次 O(1) | O(n):token 数组 + 重建出的树 + 递归栈 |
这里的 n 是树的节点总数。编码与解码的复杂度都控制在O(n),且得益于removeLast()的栈式取元素技巧,避免了数组头部移除带来的二次方开销——这正是前文"反转"步骤的意义所在。
相关资源
- 编码与解码的完整实现:EncodeAndDecodeTree.swift
- 可直接运行的 Playground 示例:EncodeAndDecodeTree.playground/Contents.swift
- 二叉树基础概念与递归结构:Binary Tree/README.markdown
- 若要深入学习节点有序的二叉搜索树,可参考仓库中的 Binary Search Tree 与 AVL Tree 专题
小结
通过本文,我们从"为什么树需要编码"出发,完整走通了 Swift Algorithm Club 中二叉树序列化的全链路:前序遍历保证顺序可重建,分隔符消除节点边界歧义,空哨兵显式记录缺失孩子,三者共同构成一套自洽的编码规则;解码侧则利用"数组作栈 + 反转 + removeLast"的技巧,以 O(n) 复杂度精确逆操作。这套方案同时是该主题在经典算法题库(LeetCode 上的 Serialize and Deserialize Binary Tree)中的标准解法思路,理解了它,也就掌握了绝大多数树结构序列化问题的方法论。
本文基于 Swift Algorithm Club 的 Encode and Decode Tree 专题整理,原文由 Kai Chen 与 Kelvin Lau 编写。
【免费下载链接】swift-algorithm-clubAlgorithms and data structures in Swift, with explanations!项目地址: https://gitcode.com/gh_mirrors/sw/swift-algorithm-club
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考