1. 数据结构题目收录系列的第二十二期:这次我们聊点真东西
做数据结构题目收录这个系列做到第二十二期,说实话我自己都有点意外。最开始只是想把平时带人刷题时遇到的一些典型题目整理出来,方便复盘,没想到一路更下来,积累了不少读者的反馈,也让我重新审视了很多以前觉得“理所当然”的知识点。这一期我打算换个思路,不再单纯罗列题目和答案,而是围绕几道有代表性的题目,把背后的设计思路、常见误区、以及实际编码中容易踩的坑掰开揉碎讲清楚。
如果你正在准备技术面试、正在补数据结构的基础、或者单纯想找一些高质量的练习题来打磨编码手感,这一期的内容应该都能给你一些参考。题目本身覆盖了线性表、树、图、排序与查找这几个核心板块,难度从基础到中等偏上都有,我会尽量把每道题的“为什么这么做”讲透,而不是只给一个能跑通的代码就完事。
这一期的关键词我提炼了几个:数据结构题目收录、算法题解析、刷题经验、代码实操、避坑指南。全文会围绕这些关键词展开,但不会为了堆砌而堆砌,每一段都是我觉得值得展开讲的内容。
2. 本期题目整体设计与选题思路
2.1 为什么选这些题而不是那些题
做题目收录到第二十二期,选题其实是最头疼的环节。太简单的题没有讲解价值,太偏的题又脱离实际应用场景。我这一期的选题标准有三个:第一,题目本身要能体现某个数据结构的核心特性,比如栈的“后进先出”、树的“递归结构”、图的“遍历策略”;第二,题目要有一定的变形空间,能让我在讲解时顺带延伸到相关的知识点;第三,题目在实际面试或工程场景中出现频率较高,不是那种为了出题而出题的偏题怪题。
基于这三个标准,本期选了六道题,分别对应不同的数据结构类型。线性表部分选了一道关于链表环检测的经典题,树部分选了一道二叉搜索树验证的题目,图部分选了一道拓扑排序的应用题,排序与查找部分选了两道,一道考察快排的partition思想,另一道考察二分查找的边界处理。最后还有一道综合题,把栈和队列结合起来解决一个实际问题。
提示:选题时不要只看题目难度,更要看题目能否帮你建立知识点之间的联系。一道好题的标准是,做完之后你能顺带复习好几个相关概念。
2.2 题目难度分布与适用人群
本期的六道题,如果按难度划分,大致是这样的:链表环检测和二分查找边界处理属于基础偏上,适合刚学完对应数据结构的人练手;二叉搜索树验证和快排partition属于中等难度,适合有一定刷题经验、想巩固递归和分治思想的人;拓扑排序应用题和栈队列综合题属于中等偏上,适合准备面试、想提升综合运用能力的人。
我特意没有选那种需要复杂数学推导或者高级算法技巧的题目,因为这一期的定位是“夯实基础、打通思路”,而不是“炫技”。如果你能把这几道题背后的原理讲清楚,并且能独立写出没有bug的代码,那说明你对这些基础数据结构的理解已经比较扎实了。
2.3 题目收录系列的长期价值在哪里
有人可能会问,数据结构题目收录做这么多期,到底有什么意义?网上随便一搜就有成千上万的题解,为什么还要看你的?这个问题我自己也想过。后来我想明白了,题目收录的价值不在于题目本身有多新,而在于讲解的角度和深度。同一道题,不同的人写出来的题解质量差别很大。有的人只给代码,有的人会讲思路,但很少有人会把“为什么这么想”“为什么不能那么做”“实际写的时候哪里容易错”讲清楚。
我这个系列一直在尝试做的,就是补上后面这部分。每道题我都会先讲清楚它的核心考点是什么,然后分析几种可能的解法,对比它们的优劣,最后给出我推荐的实现方式,并附上我在实际编码中踩过的坑。这种“从思路到代码到避坑”的完整链条,才是我觉得对读者最有价值的地方。
3. 核心题目细节解析与实操要点
3.1 链表环检测:快慢指针为什么能相遇
链表环检测这道题,题目描述很简单:给定一个链表,判断它是否有环。如果单纯用哈希表记录访问过的节点,那这道题就没什么好讲的了,空间复杂度O(n),思路直白。但面试官通常不会满足于此,他们会追问:能不能用O(1)的空间复杂度解决?这时候快慢指针就登场了。
快慢指针的核心思想是:设置两个指针,一个每次走一步,一个每次走两步。如果链表没有环,快指针会先到达末尾;如果有环,快指针会在环内追上慢指针。这个思路听起来简单,但很多人第一次接触时会有一个疑问:为什么快指针一定会追上慢指针,而不是刚好跳过去?这就涉及到数学上的追及问题了。
假设环的长度为L,当慢指针进入环时,快指针已经在环内走了k步。由于快指针速度是慢指针的两倍,每经过一次移动,快指针相对于慢指针就多走一步。所以快指针追上慢指针所需的时间取决于它们之间的初始距离。因为快指针每次相对慢指针靠近一步,所以无论初始距离是多少,最终一定会追上,不会出现“跳过去”的情况。
注意:快指针每次走两步是经过验证的最优选择。如果快指针每次走三步或更多,虽然也能检测到环,但在某些环长度下可能会增加相遇的时间,而且代码的边界处理会更复杂。
实际编码时,有几个细节需要特别注意。第一,循环条件应该是fast != null && fast.next != null,而不是只判断fast != null,因为快指针每次要走两步,必须确保fast.next也存在。第二,初始时慢指针指向head,快指针指向head,而不是head.next,这样可以统一处理从第一个节点就开始成环的情况。第三,返回结果时,如果快指针遇到null,说明没有环,直接返回false。
public boolean hasCycle(ListNode head) { if (head == null || head.next == null) return false; ListNode slow = head; ListNode fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) return true; } return false; }这段代码看起来很短,但每一行都有讲究。比如开头的边界判断,如果链表为空或只有一个节点,直接返回false,省去了后续的循环开销。再比如slow == fast的判断放在指针移动之后,而不是之前,这是因为初始时两个指针都指向head,如果先判断就会误判为有环。
3.2 二叉搜索树验证:中序遍历与上下界约束
验证二叉搜索树这道题,表面上看很简单:只要每个节点的左子节点小于它,右子节点大于它,不就行了吗?但如果你真的这么写,就会掉进一个经典的陷阱。考虑这样一个树:根节点是10,左子节点是5,右子节点是15,然后5的右子节点是12。按照“左小于根、右大于根”的局部判断,12大于5,看起来没问题,但12实际上大于根节点10,违反了二叉搜索树的全局性质。
所以正确的做法有两种:一种是利用中序遍历的有序性,另一种是递归时传递上下界。中序遍历的思路很直观:二叉搜索树的中序遍历结果一定是严格递增的。所以我们可以中序遍历整棵树,检查每个节点的值是否大于前一个节点的值。如果发现逆序,就说明不是二叉搜索树。
private TreeNode prev = null; public boolean isValidBST(TreeNode root) { if (root == null) return true; if (!isValidBST(root.left)) return false; if (prev != null && prev.val >= root.val) return false; prev = root; return isValidBST(root.right); }这段代码用了一个全局变量prev来记录中序遍历的前一个节点。注意这里用的是>=而不是>,因为二叉搜索树通常不允许重复值。如果题目允许重复值,那就需要根据具体定义来调整。
另一种上下界的方法更通用一些,也更容易理解。递归时给每个节点传递一个允许的取值范围,根节点的范围是负无穷到正无穷,左子节点的范围是父节点的下界到父节点的值,右子节点的范围是父节点的值到父节点的上界。
public boolean isValidBST(TreeNode root) { return validate(root, null, null); } private boolean validate(TreeNode node, Integer low, Integer high) { if (node == null) return true; if (low != null && node.val <= low) return false; if (high != null && node.val >= high) return false; return validate(node.left, low, node.val) && validate(node.right, node.val, high); }提示:用Integer而不是int来传递上下界,是为了处理节点值为Integer.MIN_VALUE或MAX_VALUE的边界情况。如果直接用int,初始的上下界就没法表示“无穷”了。
3.3 拓扑排序:从课程表问题看有向无环图
拓扑排序这道题,我选的是课程表问题的变体:给定一些课程和它们之间的先修关系,判断是否可能完成所有课程。这道题的本质是判断一个有向图是否有环。如果有环,说明存在循环依赖,无法完成;如果没有环,就可以得到一个合法的学习顺序。
拓扑排序有两种经典实现:一种是基于BFS的Kahn算法,另一种是基于DFS的深度优先搜索。Kahn算法的思路是:先统计每个节点的入度,然后把所有入度为0的节点加入队列,依次取出队列中的节点,将它指向的节点的入度减1,如果某个节点的入度变为0,就加入队列。最后如果处理的节点数等于总节点数,说明没有环。
public boolean canFinish(int numCourses, int[][] prerequisites) { List<List<Integer>> graph = new ArrayList<>(); int[] indegree = new int[numCourses]; for (int i = 0; i < numCourses; i++) { graph.add(new ArrayList<>()); } for (int[] pre : prerequisites) { graph.get(pre[1]).add(pre[0]); indegree[pre[0]]++; } Queue<Integer> queue = new LinkedList<>(); for (int i = 0; i < numCourses; i++) { if (indegree[i] == 0) queue.offer(i); } int count = 0; while (!queue.isEmpty()) { int course = queue.poll(); count++; for (int next : graph.get(course)) { indegree[next]--; if (indegree[next] == 0) queue.offer(next); } } return count == numCourses; }这段代码的关键在于图的构建和入度的维护。注意prerequisites数组中,pre[1]是先修课程,pre[0]是后续课程,所以边的方向是从pre[1]指向pre[0]。这个方向不能搞反,否则入度的统计就全错了。
DFS的实现方式稍微复杂一些,需要用三种状态来标记节点:未访问、正在访问、已访问。如果在DFS过程中遇到了“正在访问”的节点,说明存在环。这种实现方式在检测环的同时还能输出具体的环路径,适合需要调试的场景。
3.4 快速排序的partition:边界处理是魔鬼
快速排序的partition操作,可以说是排序算法里最容易写错的几行代码之一。我见过太多人面试时信心满满地写下快排,结果在边界条件上翻车。partition的核心目标是:选定一个基准值,把数组分成两部分,左边都小于等于基准值,右边都大于基准值,然后返回基准值的最终位置。
最经典的Lomuto partition方案是这样的:
private int partition(int[] arr, int low, int high) { int pivot = arr[high]; int i = low; for (int j = low; j < high; j++) { if (arr[j] <= pivot) { swap(arr, i, j); i++; } } swap(arr, i, high); return i; }这段代码的逻辑是:用i来维护“小于等于基准值的区域”的右边界,遍历过程中如果发现小于等于基准值的元素,就把它交换到i的位置,然后i右移。最后把基准值交换到i的位置,此时i就是基准值的最终位置。
注意:
i的初始值是low,而不是low+1。如果写成low+1,当数组中所有元素都大于基准值时,i会停在low+1,导致基准值被交换到错误的位置。
另一个容易出错的地方是循环条件j < high,而不是j <= high。因为arr[high]是基准值,不应该参与比较和交换。如果写成j <= high,基准值会被自己和自己比较,虽然不会导致错误,但逻辑上是不对的。
还有一种Hoare partition方案,用两个指针从两端向中间靠拢,效率略高一些,但边界处理更复杂。对于大多数场景,Lomuto方案已经足够,而且更容易理解和调试。我的建议是,除非有明确的性能要求,否则优先选择Lomuto方案。
3.5 二分查找的边界:left、right和mid的三角关系
二分查找看起来简单,但写出一个完全正确的二分查找并不容易。最大的坑在于边界条件的处理:while循环的条件是left <= right还是left < right?mid的计算是(left + right) / 2还是(left + right + 1) / 2?right的更新是mid - 1还是mid?
这些问题的答案取决于你要解决的是哪种类型的二分查找。如果是标准的“查找一个目标值是否存在”,那么用left <= right和mid - 1、mid + 1是比较直观的。
public int binarySearch(int[] arr, int target) { int left = 0, right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) return mid; else if (arr[mid] < target) left = mid + 1; else right = mid - 1; } return -1; }注意mid的计算用的是left + (right - left) / 2,而不是(left + right) / 2。这是为了防止left + right溢出。虽然Java中int溢出的情况比较少见,但在处理大规模数据时,这个细节很重要。
如果是“查找第一个大于等于目标值的位置”或者“查找最后一个小于等于目标值的位置”,就需要用left < right的循环条件,并且right的更新可能是mid而不是mid - 1。这类二分查找的模板我建议单独记忆,因为在实际面试中出现的频率很高。
提示:二分查找的变体很多,但核心思想只有一个:每次排除一半的搜索空间。只要你能清楚地定义“搜索空间”是什么,以及“排除”的条件是什么,就能推导出正确的边界处理方式,而不需要死记硬背模板。
3.6 栈与队列综合:用两个栈实现队列
最后一道综合题是用两个栈实现一个队列。这道题考察的是对栈和队列特性的理解:栈是后进先出,队列是先进先出。用两个栈来模拟队列,关键在于把一个栈的输出顺序反转过来。
具体做法是:维护两个栈,一个叫inStack,一个叫outStack。入队时,直接把元素压入inStack。出队时,如果outStack不为空,直接从outStack弹出;如果outStack为空,就把inStack中的所有元素依次弹出并压入outStack,然后再从outStack弹出。
class MyQueue { private Stack<Integer> inStack; private Stack<Integer> outStack; public MyQueue() { inStack = new Stack<>(); outStack = new Stack<>(); } public void push(int x) { inStack.push(x); } public int pop() { if (outStack.isEmpty()) { while (!inStack.isEmpty()) { outStack.push(inStack.pop()); } } return outStack.pop(); } public int peek() { if (outStack.isEmpty()) { while (!inStack.isEmpty()) { outStack.push(inStack.pop()); } } return outStack.peek(); } public boolean empty() { return inStack.isEmpty() && outStack.isEmpty(); } }这段代码的关键在于outStack的“懒加载”策略:只有当outStack为空时才把inStack的元素转移过去。这样可以保证每个元素最多被转移一次,均摊时间复杂度是O(1)。如果每次出队都转移,时间复杂度就会退化到O(n)。
注意:
peek操作和pop操作共享了转移逻辑,但peek不需要弹出元素,所以最后用的是outStack.peek()而不是outStack.pop()。这个细节虽然小,但在面试中很容易被忽略。
4. 实操过程中容易踩的坑与排查技巧
4.1 链表题目的空指针陷阱
链表相关的题目,空指针异常是最常见的错误来源。以环检测为例,如果循环条件只写了fast != null,而没有检查fast.next != null,那么当fast指向最后一个节点时,fast.next.next就会抛出空指针异常。这个错误在本地测试时可能不会触发,因为测试用例的链表长度可能刚好避开了这个边界,但在面试的隐藏测试用例中很容易暴露。
排查这类问题的技巧是:在写链表代码时,先画出链表的边界情况,比如空链表、只有一个节点、只有两个节点、有环且环在头部、有环且环在尾部。然后针对每种情况手动模拟代码的执行过程,看看有没有访问null的情况。这个习惯看起来笨,但能帮你省下大量调试时间。
另一个常见的坑是链表的断链问题。在反转链表、删除节点等操作中,如果不小心丢失了后续节点的引用,就会导致链表断裂。解决方法是:在修改指针之前,先用临时变量保存后续节点的引用。这个原则在几乎所有链表操作中都适用。
4.2 递归题目的栈溢出与重复计算
树相关的题目大多可以用递归解决,但递归有两个常见的坑:栈溢出和重复计算。栈溢出通常发生在树的高度很大时,比如一棵退化成链表的树,递归深度可能达到节点数。如果节点数超过几万,就可能触发栈溢出。解决方法是改用迭代方式,或者手动用栈来模拟递归。
重复计算是另一个问题。比如在验证二叉搜索树时,如果对每个节点都重新计算它的上下界,就会导致大量重复工作。正确的做法是在递归过程中传递上下界,而不是在每个节点重新计算。这个思路在动态规划中也很常见,叫做“记忆化搜索”。
提示:写递归代码时,先确定三件事:递归的终止条件是什么、当前层需要做什么、递归的返回值是什么。把这三件事想清楚,代码就不容易写错。
4.3 图论题目的节点编号与边界
图论题目的坑主要集中在节点编号和边界处理上。以拓扑排序为例,如果课程编号是从1开始的,而数组下标是从0开始的,就需要做偏移转换。如果忘记转换,就会导致数组越界或者结果错误。这个错误在面试中很常见,因为面试官往往不会明确告诉你编号是从0还是从1开始。
另一个坑是图的连通性问题。有些图论题目假设图是连通的,但实际上可能存在多个连通分量。如果代码只从一个节点开始遍历,就会漏掉其他连通分量。解决方法是:在外层加一个循环,遍历所有节点,对每个未访问的节点都启动一次遍历。
排查这类问题的技巧是:先手动构造几个边界测试用例,比如空图、只有一个节点的图、有多个连通分量的图、有自环的图。然后观察代码在这些用例下的表现。如果代码能正确处理这些边界情况,那基本就没有大问题了。
4.4 排序与查找的稳定性与溢出
排序算法的稳定性是一个容易被忽略的点。快速排序是不稳定的,因为partition过程中会交换不相邻的元素。如果题目要求稳定排序,就需要用归并排序或者插入排序。这个细节在面试中经常被问到,但很多人只关注时间复杂度,忽略了稳定性。
二分查找的溢出问题前面已经提过了,这里再强调一下:mid = left + (right - left) / 2是标准写法,不要图省事写成(left + right) / 2。虽然Java中int溢出的概率不高,但这是一个体现基本功的细节,面试官很可能会注意到。
另一个容易出错的地方是二分查找的循环终止条件。如果写成left < right,那么当left == right时循环会退出,此时需要额外判断arr[left]是否等于目标值。如果写成left <= right,循环会在left > right时退出,此时不需要额外判断。两种写法都可以,但必须保持一致,不能混用。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 空指针异常 | 链表访问了null节点 | 检查循环条件是否包含next | 增加fast.next != null判断 |
| 栈溢出 | 递归深度过大 | 打印递归深度或改用迭代 | 改用迭代或手动模拟栈 |
| 结果错误 | 节点编号偏移错误 | 检查数组下标与节点编号的对应关系 | 统一编号规则或做偏移转换 |
| 超时 | 重复计算或时间复杂度高 | 分析算法的时间复杂度 | 使用记忆化或优化算法 |
| 死循环 | 循环条件或指针更新错误 | 打印循环变量 | 检查循环条件和指针更新逻辑 |
| 数组越界 | 边界条件处理不当 | 检查数组访问的下标范围 | 增加边界判断或调整下标 |
这张表是我在实际刷题和带人过程中总结出来的,覆盖了大部分常见问题。遇到bug时,可以先对照这张表快速定位问题类型,然后再深入排查。
5. 从题目到能力:如何把刷题转化为真正的数据结构功底
5.1 不要只追求AC,要追求讲清楚
很多人刷题的目标就是“AC”(Accepted),只要代码能通过测试就万事大吉。但如果你真的想提升数据结构功底,就不能只满足于AC。每做完一道题,你应该问自己几个问题:这道题的核心考点是什么?我的解法时间复杂度是多少?有没有更优的解法?如果面试官追问,我能讲清楚每一步的理由吗?
我见过很多人,题目做过了,代码也AC了,但过了一周再让他做,他又不会了。原因就在于他只是记住了代码,而没有理解背后的思路。真正掌握一道题的标准是:你能在不看题解的情况下,从头推导出解法,并且能向别人解释清楚为什么这么做。
5.2 建立自己的题目分类体系
刷题到一定数量后,你会发现很多题目其实是同一类问题的变体。比如链表环检测、寻找链表中间节点、判断两个链表是否相交,这些题目都可以用快慢指针解决。如果你能把这些题目归为一类,总结出它们的共同点和差异点,那你的刷题效率会大幅提升。
我的建议是:每做完一道题,就在笔记里给它打上标签,比如“快慢指针”“递归”“动态规划”“二分查找”。过一段时间后,按标签回顾这些题目,看看自己是否真的掌握了这一类问题的通用解法。这种分类回顾的方式,比盲目刷新题要有效得多。
5.3 从题目延伸到实际工程场景
数据结构的题目虽然看起来是“学术”的,但它们的应用场景其实很广泛。比如拓扑排序在构建系统、任务调度、依赖管理中都有应用;二分查找在数据库索引、日志检索中也很常见;栈和队列更是操作系统、网络协议中的基础数据结构。
如果你能把题目和实际场景联系起来,你对数据结构的理解就会更深一层。比如你在做课程表问题时,可以想一想:实际的项目管理工具是如何处理任务依赖的?如果依赖关系很复杂,拓扑排序的性能瓶颈在哪里?这种思考方式能帮你把“刷题”转化为“解决实际问题”的能力。
5.4 定期复盘比盲目刷题更重要
我自己的经验是,刷题的数量不是关键,关键是刷题的质量和复盘的质量。我每周会花半天时间,把这一周做过的题目重新看一遍,重点看那些当时做错或者做得很吃力的题目。我会重新写一遍代码,然后对比之前的解法,看看有没有改进的空间。
复盘的时候,我还会问自己:这道题如果换一个条件,解法会怎么变?比如把“判断是否有环”改成“找到环的入口”,把“验证二叉搜索树”改成“修复二叉搜索树”,解法需要做哪些调整?这种“一题多变”的练习方式,能帮你真正吃透一个知识点,而不是只会做某一道具体的题。
6. 一些个人体会和后续计划
做数据结构题目收录这个系列,对我自己来说也是一个不断学习的过程。每次整理题目、写解析、补充避坑经验,都会让我重新审视一些以前觉得“已经懂了”的知识点。很多时候,写着写着就发现自己其实并没有完全理解某个细节,然后就得回去查资料、做实验,直到彻底搞明白为止。
这一期的六道题,我觉得最有价值的不是代码本身,而是那些“为什么这么做”和“为什么不能那么做”的分析。代码是可以抄的,但思路和判断力是抄不来的。如果你能把这几道题背后的思路内化成自己的东西,那以后遇到类似的题目,你就能自己推导出解法,而不是依赖题解。
后续我打算在这个系列里增加一些“一题多解”的内容,把同一道题的多种解法放在一起对比,分析它们的适用场景和优劣。另外,也会尝试加入一些实际工程中的数据结构应用案例,让题目和实际场景结合得更紧密。如果你有什么想看的题目类型或者想了解的解题技巧,也欢迎告诉我,我会尽量在后续的内容里安排上。
最后分享一个小技巧:如果你在刷题时卡住了,不要急着看题解。先给自己15分钟,尝试不同的思路,哪怕最后没做出来,这个思考的过程也会让你在看题解时收获更大。因为你知道自己卡在哪里了,题解中的关键点就会格外醒目。这个习惯我坚持了很多年,觉得对提升解题能力特别有帮助。