前段时间在刷某大厂的校园每日一题,连续两天遇到了两道让我觉得特别值得反复品的题——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:
- 入队 1:data[0] = 1,rear 变成 1
- 入队 2:data[1] = 2,rear 变成 2
- 入队 3:data[2] = 3,rear 变成 3
此时 (rear + 1) % 4 = 0,等于 front,isFull 返回 true。队列里有 3 个元素,恰好等于容量 k = 3,判断正确。再多入队一个,enQueue 会直接返回 false。
出队一个:front 从 0 变成 1。此时 front = 1,rear = 3,队列元素是 2 和 3。调用 Front() 返回 data[1] = 2,正确;调用 Rear() 返回 data[(3 - 1 + 4) % 4] = data[2] = 3,正确。
再把剩下的元素清空:front 依次变成 2、3。此时 front = rear = 3,isEmpty 返回 true,队列空了。
再次入队 4:先判断 isFull,(rear + 1) % 4 = 0,不等于 front(3),所以未满。写入 data[3] = 4,rear 变成 0。此时 front = 3,rear = 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; } ArrayDeque<Integer> 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(ArrayDeque<Integer> 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 追上 front,isEmpty 和 isFull 同时成立,程序无法正常工作。多开一个格子是最省心的解法。
多个方法里取模时用了不一致的分母。有人在实际代码里,isFull 用 capacity,rear 前进时却用 data.length,两者其实相等所以没出问题,但一旦修改了数组长度就会埋雷。我习惯把 capacity 单独抽成字段,所有取模都统一使用它。
7.2 序列化反序列化高频踩坑点
先 poll 再判断区间。这是 LC449 最经典的错误。必须先 peek 判断,确认队首元素属于当前值域后再弹出。判断失败时,这个值要留在队列里,因为它可能属于上层某个尚未构建的分支。
上下界的开闭搞错。使用开区间,等值情况返回 null。如果错误地使用闭区间,遇到重复值可能把节点挂到不正确的分支上。题目默认值唯一,开区间就够了。
空树序列化后不处理空字符串。空串 split 会得到一个含空串的数组,Integer.parseInt("") 直接抛异常。参数校验和空串过滤都是必要的。
在递归里反复做字符串分割。有些写法是在每个递归层级都对字符串做 split,导致大量重复解析,时间复杂度退化到 O(n²)。正确做法是一次性 split 成队列,之后只消费队列元素。
7.3 刷题复盘的建议节奏
这类"设计题"特别适合用来做状态检测。我的经验是分三遍刷:
第一遍,限时二十分钟自己想,想不出来看题解,但看完必须关掉题解自己重写一遍,确保不是抄写。
第二遍,专门写边界用例。循环队列就把"入满、出空、绕一圈再入"跑一遍;序列化就把"单节点、链状树、空树"跑一遍。边界全过,代码基本稳了。
第三遍,尝试用口述的方式把思路讲清楚。能解释清楚"为什么数组要多开一格""为什么 BST 反序列化不需要 null 标记",才算真正掌握。
我个人强烈建议把这两题存进自己的刷题笔记,两周后再重新写一次。很多细节问题,比如 rear 的回退取值、peek 先于 poll,第一次写的时候可能稀里糊涂就过了,第二遍写的时候才会真正内化成自己的判断。
最后分享一个一直沿用到现在的习惯:做设计题时,我在写代码之前习惯先在注释里列出全部边界状态,哪怕只是简单的几行中文描述。别小看这一步,它能强迫你在动手前把逻辑漏洞想清楚,而不是靠编译器和报错来兜底。这两题的代码,值得你用同样的方法认真对待一遍。