从循环队列到BST序列化:两道经典数据结构设计题深度解析
发布时间:2026/10/17 18:25:49 锦皓数字建站

前段时间在刷某大厂的校园每日一题连续两天遇到了两道让我觉得特别值得反复品的题——LC622 设计循环队列和 LC449 序列化和反序列化二叉搜索树。表面上看一个是队列一个是二叉树八竿子打不着。但把它们放在一起刷完我最大的感受是这两道题都在考同一件事——你对你正在处理的数据结构到底理解到了什么程度。LC622 是一个定长容器设计题要求实现循环队列的六个核心操作LC449 是一个编码与解码题要求把一棵二叉搜索树序列化成字符串并保证能完整还原。前者会出现在很多初级岗位的笔试里后者经常作为中高难度的面题出现。用 Java 刷这两题代码不算长但每一行都有可能踩坑。这篇文章会把两题从思路推导、完整实现、边界验证到踩坑经验完整展开。如果你正在准备算法面试或者刷题时想刻意训练数据结构设计类题目这两题很适合用来检验自己的基础扎不扎实。1. 为什么把两道题放在一起刷1.1 表面题目不同底层考点相通LC622 的核心矛盾是数组空间是固定的但队列元素在动态进出。如果每次出队都搬移后面的所有元素时间成本是 O(n)于是才有了循环设计——让 front 和 rear 指针在一个环上转不搬数据只动指针。这种用模运算回绕指针、用牺牲一格空间换取判空判满的清晰的思路在很多工业组件里都能看到影子操作系统的环形缓冲区、消息队列底层、日志系统里的定长缓冲池全是同一个套路。LC449 的核心矛盾则是二叉树的结构信息本质上是一堆谁是谁的孩子的关系序列化时必须把这些关系编码进字符串。普通二叉树序列化通常要输出 null 标记否则前序遍历无法唯一还原。但 BST 有一个普通二叉树没有的强约束——节点值有全局的大小顺序于是谁是左孩子、谁是右孩子可以由值域范围直接推导出来不需要 null 标记。这是利用数据结构的性质简化问题的典型例子。所以两题的本质都是先理解性质再根据性质设计存储结构或读写策略。这也是为什么我建议连着刷而不是单独刷完就忘。1.2 这两道题适合什么阶段的人如果你是刚接触数据结构不久的新手LC622 是理解队列的环形实现最好的入门题之一它强迫你处理空态和满态比单纯背队列 API 有营养得多。LC449 可能偏难可以先看思路再看代码反复模拟几遍递归过程。如果你已经刷了一两百道题准备面试这两题就属于应该闭着眼写对的范畴。它们不复杂但细节密度很高。我自己的体会是能够在十分钟内把这两题无 bug 提交并且能讲清楚每一步为什么这么设计说明基础状态是合格的。2. LC622 设计循环队列思路与方案选型2.1 数组 vs 链表怎么选设计循环队列第一反应可能觉得链表更简单维护一个 head 指向队首tail 指向队尾再维护一个 size。入队在尾部挂节点出队把 head 往后挪不需要考虑环的问题实现起来确实直观。但链表方案在这个题里有两个代价。第一每个节点多一个 next 指针k 越大内存浪费越明显。第二题目明确给了容量 k这是一个定长容器的限定条件链表方案几乎用不上这个 k等于主动放弃了题目信息。数组方案则把 k 变成数组长度入队出队都是 O(1) 的数组读写内存固定。代价是必须精心处理空和满的区分这正是这个题想考察的点。面试时如果选用数组方案并且能说明为什么这么设计通常会给面试官留下更好的印象。2.2 front 和 rear 的定义方式数组实现循环队列最关键的一步是决定 front 和 rear 各指向哪里。不同定义会直接改变后面所有判断逻辑这一步定了代码就完成了一半。我习惯用这套定义front 指向队首元素rear 指向队尾元素的下一个可写位置。初始时 front rear 0。这个定义下的两个核心判断非常对称空的条件是 front rear满的条件是 (rear 1) % capacity front。队列为空时两个指针相遇队列满时 rear 落后 front 一格。有人可能会选择让 rear 直接指向队尾元素这种定义也能写但满判断和取队尾元素时要多绕一层容易绕晕。用一个表格对比两种定义定义方式front 指向rear 指向空条件满条件方案A队首元素队尾下一格front rear(rear 1) % cap front方案B队首元素队尾元素需额外推导需额外推导方案B不是不行但方案A的对称性最好记忆和推导成本最低。刷题阶段能用最不容易出错的定义就不要冒险。2.3 判空判满的三种常见方案除了牺牲一个格子判空判满还有两种常见做法维护 size 变量入队 size出队 size--。size 0 为空size capacity 为满。优点是直白面试时最好讲缺点是每一处增删都要记得维护它。维护标志位用一个 boolean 记录最后一次操作是入队还是出队。这个方案在边界情况下容易混乱不推荐作为首选。三种方案的取舍看场景。如果是生产代码维护 size 的可读性更好如果是 LeetCode 提交牺牲一格是最简洁的经典方案。我建议两种都写过一遍面试时根据要求灵活选择。提示LC622 这类设计数据结构题面试官经常追问你为什么要多开一个格子。答出为了区分空和满只是及格能进一步说清如果不牺牲这格rear 追上 front 时无法判断队列是否已满才算有深度。3. LC622 完整 Java 实现与边界验证3.1 完整代码牺牲一格方案class MyCircularQueue { private int[] data; private int front; private int rear; private int capacity; public MyCircularQueue(int k) { capacity k 1; data new int[capacity]; front 0; rear 0; } public boolean enQueue(int value) { if (isFull()) { return false; } data[rear] value; rear (rear 1) % capacity; return true; } public boolean deQueue() { if (isEmpty()) { return false; } front (front 1) % capacity; return true; } public int Front() { if (isEmpty()) { return -1; } return data[front]; } public int Rear() { if (isEmpty()) { return -1; } return data[(rear - 1 capacity) % capacity]; } public boolean isEmpty() { return front rear; } public boolean isFull() { return (rear 1) % capacity front; } }3.2 几个关键步骤的为什么先看构造方法。用户在外部传入的 k 是队列期望的容量但数组实际长度我写成了 k 1。这是牺牲一格方案的核心数组中永远留出一个空位rear 指向的就是这个空位所以数组实际能容纳的元素数是 k。有人可能会问为什么不能直接把数组长度设成 k我们模拟一下如果数组长度是 k当队列装满 k 个元素后rear 经过 k 次取模会回到 front。此时 isEmpty 的判据 front rear 和 isFull 的判据假设写成 (rear 1) % capacity front会同时成立程序就分不清到底是空还是满了。多开一格后满的时候 rear 一定落后 front 一格两种状态在逻辑上彻底分开了。再看 enQueue。入队时先检查 isFull满了直接返回 false。没满时先在 rear 位置写入再把 rear 往前推进一位取模保证回绕。这里顺序绝对不能反如果先把 rear 移过去再写入写入的位置就落在空位之后了。deQueue 更加简单把 front 前进一步就相当于逻辑上丢弃了队首元素。有人喜欢在出队时把 data[front] 清零这个操作是多余的因为旧的位置会在后续入队时被覆盖不清零不影响任何逻辑只会多一次赋值。3.3 手工跑一组边界用例假设 k 3数组实际长度 4初始 front rear 0。连续入队 1、2、3入队 1data[0] 1rear 变成 1入队 2data[1] 2rear 变成 2入队 3data[2] 3rear 变成 3此时 (rear 1) % 4 0等于 frontisFull 返回 true。队列里有 3 个元素恰好等于容量 k 3判断正确。再多入队一个enQueue 会直接返回 false。出队一个front 从 0 变成 1。此时 front 1rear 3队列元素是 2 和 3。调用 Front() 返回 data[1] 2正确调用 Rear() 返回 data[(3 - 1 4) % 4] data[2] 3正确。再把剩下的元素清空front 依次变成 2、3。此时 front rear 3isEmpty 返回 true队列空了。再次入队 4先判断 isFull(rear 1) % 4 0不等于 front(3)所以未满。写入 data[3] 4rear 变成 0。此时 front 3rear 0队列里只有一个元素 4。Rear() 返回 data[(0 - 1 4) % 4] data[3] 4正确。这一整轮验证下来写入空位、指针回绕、队尾回退取值这几个最容易被写错的地方全部覆盖了。4. LC449 序列化和反序列化 BST核心思路拆解4.1 为什么 BST 可以不用 null 标记先还原一个基础事实一棵普通二叉树光靠前序遍历序列是没法唯一还原的。因为前序遍历只告诉了你节点的访问顺序没有告诉你在某个位置下一个节点到底应该挂成左孩子还是右孩子。以普通二叉树的前序序列 [1, 2] 为例第二个节点可能挂在左边也可能挂在右边没有额外信息就无法区分。BST 打破了这种歧义。BST 的定义是左子树所有节点值都小于根节点右子树所有节点值都大于根节点。由此能推出一个对序列化非常有用的性质给定 BST 的前序遍历序列可以唯一还原这棵树。原因很简单——前序序列的第一个值一定是根节点剩下的值里凡是小于根节点的必然属于左子树大于根节点的必然属于右子树划分是完全确定的。左子树内部又可以用同样的规则继续划分。正是这个性质让 LC449 可以做到紧凑序列化不需要 null 标记只需要按前序遍历输出节点值用分隔符隔开就能在反序列化时完整还原。普通二叉树想都不要想这个方案BST 却天生适合。4.2 序列化选择前序遍历序列化的方案选择直接决定了反序列化的复杂度。我选了前序遍历也就是根 → 左 → 右的顺序用空格作为分隔符拼接成一个字符串。为什么不选后序或层序后序遍历的序列化结果里根节点在最后还原时要先处理完子树才能看到根逻辑绕层序遍历虽然在视觉上直观但要处理 null 占位代码更长。前序遍历第一个值就是根剩余值天然分为小于根的左子树部分和大于根的右子树部分最适合配合值域递归法做反序列化。分隔符方面我选空格而不是逗号。原因在于节点值可能是负数逗号版本的字符串像 1,-2,3虽然也能解析但遇到负数时容易产生视觉混淆空格作为分隔符时负号是数值本身的组成部分不会与分隔符产生任何歧义。4.3 反序列化上下界递归法反序列化的核心是上下界递归法。思路是这样把序列化字符串按分隔符拆成一个整数队列然后递归构建节点。每个递归函数接收两个参数 lower 和 upper表示当前节点允许的取值范围开区间。根节点的范围是整个整数域左子树的取值范围被压缩到 (lower, 当前节点值)右子树被压缩到 (当前节点值, upper)。递归的具体流程是如果队列为空返回 null看队首元素是否落在当前允许的 (lower, upper) 区间内不在则返回 null在区间内弹出这个值创建节点递归构建左子树 build(lower, val)递归构建右子树 build(val, upper)这个方案妙在它不需要手动去数前序序列里前几个属于左子树递归和值域区间自动完成了划分。当左子树递归遇到一个不小于当前节点值的队首元素时函数直接返回 null控制权交回上一层开始构建右子树。每个元素恰好被消费一次整个过程一气呵成。5. LC449 完整 Java 实现与逐步解析5.1 完整代码public class Codec { public String serialize(TreeNode root) { StringBuilder sb new StringBuilder(); preorder(root, sb); return sb.toString(); } private void preorder(TreeNode node, StringBuilder sb) { if (node null) { return; } sb.append(node.val).append( ); preorder(node.left, sb); preorder(node.right, sb); } public TreeNode deserialize(String data) { if (data null || data.isEmpty()) { return null; } ArrayDequeInteger queue new ArrayDeque(); for (String s : data.split( )) { if (!s.isEmpty()) { queue.addLast(Integer.parseInt(s)); } } return build(queue, Integer.MIN_VALUE, Integer.MAX_VALUE); } private TreeNode build(ArrayDequeInteger queue, int lower, int upper) { if (queue.isEmpty()) { return null; } int val queue.peekFirst(); if (val lower || val upper) { return null; } queue.pollFirst(); TreeNode node new TreeNode(val); node.left build(queue, lower, val); node.right build(queue, val, upper); return node; } }5.2 序列化代码细节serialize 里用前序遍历节点为空直接返回不输出任何标记所以序列化结果可能是 5 3 4 8 10 这样的字符串注意末尾带了一个空格。空树的情况下返回的是空字符串这个必须处理否则反序列化时会在解析阶段直接崩溃。一个容易被忽略的细节节点值可能是负数。Integer.parseInt 处理 -5 完全没有问题空格分隔符也不会和负号发生混淆这再次说明选用空格作分隔符是个省心的决定。队列容器我选了 ArrayDeque 而不是 LinkedList。ArrayDeque 底层是环形数组pollFirst 和 addLast 都是 O(1)内存密度也比链表节点高。这个题的访问模式恰好是只从头部取、只从尾部存和 ArrayDeque 的设计完美契合。5.3 反序列化代码细节build 函数里有两个基础但重要的坑。第一个坑是必须先 peek 再 poll。先 peek 再 poll的意思是先读取队首元素判断它是否落在当前值域内确认可以消费后再弹出。如果顺序反了先 poll 再判断一旦判断结果是这个值不属于当前区间这个值已经被消费掉了队列状态全部错乱递归的结果就不可能正确。第二个坑是上下界的开闭性。BST 的节点值互不相同所以值域用开区间 (lower, upper)遇到 val lower 或 val upper 的情况都返回 null。如果错误地用闭区间当队列中存在与边界相等的值时会把节点挂到错误的位置上。题目默认了节点值唯一开区间就是最准确的选择。还有一种间接的坑就是 split 空字符串。Java 里对空字符串调用 .split( ) 会得到一个长度为 1、包含一个空字符串的数组。如果不过滤空串Integer.parseInt() 会直接抛 NumberFormatException。代码里专门加了 !s.isEmpty() 的过滤这个细节虽然小但足以让一段看似正确的代码直接 G 掉。5.4 手工模拟完整还原过程用一棵具体的树验证一下。假设原 BST 是5 / \ 3 8 \ \ 4 10前序遍历得到序列5 3 4 8 10 。反序列化时的递归过程如下build(MIN, MAX) 看到队首 5落在整个整数域内弹出 5创建根节点。 先递归左子树 build(MIN, 5)队首 3 在 (MIN, 5) 内弹出 3创建节点 3。继续递归 3 的左子树 build(MIN, 3)队首 4 不在 (MIN, 3) 内4 大于 3返回 null。再递归 3 的右子树 build(3, 5)队首 4 在 (3, 5) 内弹出 4创建节点 4。4 的左右子树递归时队首 8 都不在对应区间内分别返回 null。回到根节点的右子树 build(5, MAX)队首 8 在 (5, MAX) 内弹出 8创建节点 8。8 的左子树 build(5, 8)队首 10 不在区间内返回 null。8 的右子树 build(8, MAX)队首 10 在区间内弹出 10创建节点 10。最终还原出的树和原树完全一致。关键点在于整个过程中哪个值属于哪个子树完全由值域区间自动判定没有任何手动切分数组、统计数量的操作逻辑非常顺滑。6. 复杂度分析与两题的统一视角6.1 时间复杂度与空间复杂度对比LC622 的六个操作全部是 O(1)每次只涉及一次数组读写和一次取模没有循环没有递归。空间复杂度是 O(k)数组长度为 k 1额外变量是常数级可以忽略。LC449 的 serialize 遍历整棵树每个节点访问一次时间复杂度 O(n)生成的字符串长度也是 O(n)。deserialize 中每个节点从队列弹出一次、创建一次同样是 O(n)。但要注意递归深度如果 BST 退化成链状比如所有节点只有右孩子递归深度会达到 n栈空间是 O(n) 级别。两题都符合每个元素只处理常数次这个最优原则。刷题时这是一个很重要的自查指标——如果实现里出现了对每个元素做额外遍历的嵌套循环就要警惕时间复杂度是不是退化成了 O(n²)。6.2 从两题看数据结构设计题的通用套路这两道题放在一起其实可以提炼出一个做设计题的通用套路第一步把数据结构的核心性质写下来。队列要先进先出所以只需要操作两端BST 的节点值有全局序所以值域能指导结构还原。第二步根据性质选择存储方案。循环队列用数组加取模序列化用前序加值域。第三步也是最容易翻车的一步把所有边界状态列出来。空队列、满队列、单节点树、空树、链状树逐个验证。这三步走完代码的正确性基本就有保障了。很多人在刷题时只盯着过了没有忽略了第一步和第三步结果就是换个数据规模或者换个场景代码就暴露 bug。7. 常见问题与排查技巧实录7.1 循环队列高频踩坑点入队时先移动 rear 再写数据。这是顺序颠倒导致的经典错误。正确顺序是先在 rear 指向的位置写入再让 rear 前进。先移动再写会把数据写到空位之后队列状态直接错乱。Rear() 方法直接返回 data[rear]。rear 指向的是下一个可写位置不是队尾元素。必须回退一位且要处理 rear 为 0 的情况写成 (rear - 1 capacity) % capacity。漏掉加 capacity 会导致负数下标运行时报错。构造方法里数组长度直接写成 k。这会引发满和空条件重叠的问题队列装满后 rear 追上 frontisEmpty 和 isFull 同时成立程序无法正常工作。多开一个格子是最省心的解法。多个方法里取模时用了不一致的分母。有人在实际代码里isFull 用 capacityrear 前进时却用 data.length两者其实相等所以没出问题但一旦修改了数组长度就会埋雷。我习惯把 capacity 单独抽成字段所有取模都统一使用它。7.2 序列化反序列化高频踩坑点先 poll 再判断区间。这是 LC449 最经典的错误。必须先 peek 判断确认队首元素属于当前值域后再弹出。判断失败时这个值要留在队列里因为它可能属于上层某个尚未构建的分支。上下界的开闭搞错。使用开区间等值情况返回 null。如果错误地使用闭区间遇到重复值可能把节点挂到不正确的分支上。题目默认值唯一开区间就够了。空树序列化后不处理空字符串。空串 split 会得到一个含空串的数组Integer.parseInt() 直接抛异常。参数校验和空串过滤都是必要的。在递归里反复做字符串分割。有些写法是在每个递归层级都对字符串做 split导致大量重复解析时间复杂度退化到 O(n²)。正确做法是一次性 split 成队列之后只消费队列元素。7.3 刷题复盘的建议节奏这类设计题特别适合用来做状态检测。我的经验是分三遍刷第一遍限时二十分钟自己想想不出来看题解但看完必须关掉题解自己重写一遍确保不是抄写。第二遍专门写边界用例。循环队列就把入满、出空、绕一圈再入跑一遍序列化就把单节点、链状树、空树跑一遍。边界全过代码基本稳了。第三遍尝试用口述的方式把思路讲清楚。能解释清楚为什么数组要多开一格为什么 BST 反序列化不需要 null 标记才算真正掌握。我个人强烈建议把这两题存进自己的刷题笔记两周后再重新写一次。很多细节问题比如 rear 的回退取值、peek 先于 poll第一次写的时候可能稀里糊涂就过了第二遍写的时候才会真正内化成自己的判断。最后分享一个一直沿用到现在的习惯做设计题时我在写代码之前习惯先在注释里列出全部边界状态哪怕只是简单的几行中文描述。别小看这一步它能强迫你在动手前把逻辑漏洞想清楚而不是靠编译器和报错来兜底。这两题的代码值得你用同样的方法认真对待一遍。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。