刷题刷到一定阶段的朋友,多少会有这种感觉:今天看某道栈的题解法明白得不得了,过三天再看到同一道题,脑子里一片空白。栈、队列、优先级队列这三块尤其如此——概念都懂,一到手撕就卡壳。但说实话,这三类结构恰恰是面试题里出镜率最高的,而且经常是连环追问的素材:先让你写个最小栈,再问你时间复杂度为什么是 O(1),接着又让你换个思路不用辅助栈,一环接一环。
今天这篇我打算换个讲法,不从头到尾讲一遍教科书,而是把栈、队列、优先级队列里的高频考题拎出来一道一道拆。每道题都按面试现场的节奏走:先读题,再讲思路,再给可直接跑的代码,最后复盘易错点和面试官的追问方向。这样一套走下来,你收获的不只是代码模板,更是一套“看到题就知道它在考什么”的题感。
这篇内容适合正在准备算法面试的开发者,也适合想把这几个数据结构真正用明白的人。看完之后你不需要再翻几十篇零散题解,直接照着这篇练就行。
1. 先把三种结构的关系捋清楚——为什么它们总是一起考
1.1 概念之间有一个容易被忽略的共同点
栈、队列、优先级队列,本质上都是“受限的线性表”。这个说法听起来学术,翻译成人话就是:它们底层的存储方式其实和你熟悉的数组、链表差不多,关键是规定了数据进出的规则。
栈是后进先出(LIFO),就像一摞盘子,你只能从最上面拿,最后放上去的永远最先被拿走。队列是先进先出(FIFO),就像排队买奶茶,先来的先服务。优先级队列则是“按优先级出队”,每次弹出的不是最早进来的,而是当前优先级最高的那个——它的底层一般是堆,这个后面细说。
那为什么面试喜欢把这三个放在一起考?因为它们在“进出规则”上做了不同的约束,能考出一个人对数据结构本质的理解。比如“用栈实现队列”这种题,表面上是两个结构的互换,实际上考的是:你知道每种结构的进出规则,能不能在操作层面把一种规则翻译成另一种规则。这种能力在工程里非常实用,消息队列、调用栈、任务调度、撤销重做,全是这类结构的影子。
1.2 工程实现怎么选:数组、链表、还是二叉堆?
做算法题的时候,大多数人直接用语言自带的数据结构,比如 Java 的 ArrayDeque、Python 的 list、C++ 的 stack,基本不会去纠结底层。但面试官偶尔会追问一句“你用的这个,底层是什么”,所以这里还是要把底层说清楚。
栈和队列的底层实现主要有两种:数组和链表。
数组实现的好处是内存连续、缓存友好,访问快,但扩容的时候要搬数据。链表实现的好处是插入删除都只需要改指针,不会有扩容问题,但每个节点要额外存指针,内存占用稍大。在算法题里,两种实现的时间复杂度都是 O(1),所以怎么写都对。工程里其实更常见的是数组实现,尤其是循环数组,也就是循环队列,后面我会专门讲。
优先级队列就不一样了。它底层几乎都是二叉堆,Java 的 PriorityQueue、Python 的 heapq、C++ 的 priority_queue,全是基于堆实现的。堆本质上是一棵完全二叉树,用数组存,父节点和子节点的下标有固定的数学关系:下标 i 的父亲是 (i-1)/2,左孩子是 2i+1,右孩子是 2i+2,这也是很多手撕堆排序题的基础。
提示:如果面试时让你实现一个优先队列,不要想着直接用现成的 API 交差。你需要掌握“上浮”和“下沉”两个核心操作:上浮用于插入元素时调整堆结构,下沉用于删除堆顶时调整堆结构,两者的时间复杂度都是 O(log n)。
1.3 手撕题的标准套路:先定容器,再想边界
我做题的习惯是:看到一道题,先判断它属于哪种结构,然后在草稿纸上把容器画出来,模拟一遍整个流程。画出来之后,边界条件基本就能看出来了:栈空不空、队列满不满、堆要不要调整。
很多新手做题容易一上来就写代码,写到一半发现栈可能弹出空元素,然后改来改去。正确顺序应该是:先确定用哪种容器,再确定存储的内容(元素本身还是下标),最后才是写代码。存储内容是很容易被忽略的点——比如单调栈里存的是下标而不是值,滑动窗口最大值里双端队列存的是下标,这些细节直接决定代码能不能写对。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈题实战:括号、最小栈、单调栈,高频中的高频
2.1 有效括号:最经典的入门,也是最容易出细节问题的题
题目要求很简单:给定一个只包含 '('、')'、'{'、'}'、'['、']' 的字符串,判断括号是否有效。有效需要满足:左括号必须用同类型右括号闭合,并且按正确顺序闭合。
思路非常直接:遍历字符串,遇到左括号就压栈,遇到右括号就弹出栈顶元素检查是否匹配。匹配就继续,不匹配直接返回 false。遍历结束后,如果栈是空的,说明所有左括号都有对应的右括号,返回 true;如果栈里还剩左括号,说明有没闭合的,返回 false。
java复制public boolean isValid(String s) {
if (s.length() % 2 == 1) return false;
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '{' || c == '[') {
stack.push(c);
} else {
if (stack.isEmpty()) return false;
char top = stack.pop();
if (c == ')' && top != '(') return false;
if (c == '}' && top != '{') return false;
if (c == ']' && top != '[') return false;
}
}
return stack.isEmpty();
}
时间复杂度 O(n),空间复杂度 O(n)。这里有个细节值得说:先判断字符串长度为奇数,直接返回 false。这是我第一次刷这道题时踩过的坑——不判断的话,代码逻辑也能跑,但会浪费一次完整的遍历。别小看这种剪枝,面试的时候写出来,面试官会觉得你考虑问题很全面。
这道题的变体有“最长有效括号”“删除无效的括号”等。前者要从“找一段合法括号子串”的角度去考虑,可以用栈配合起点记录,也可以用动态规划;后者则是回溯的思路。作为开场题,把基础版本写熟就够了,但它的变体很适合作为延伸练习。
2.2 最小栈:O(1) 取最小值的常见套路
题目要求设计一个栈,支持 push、pop、top、getMin 四个操作,并且 getMin 要在常数时间返回栈中最小值。
我第一次做这道题时,第一反应是“用一个变量记录最小值不就行了?”但马上发现问题:如果你把这个最小值 pop 出去了,下一个最小值是什么?你根本不知道。所以需要一个辅助结构来记录历史最小值。
标准做法是维护两个栈:一个正常存数据,一个存当前栈内的最小值。每次 push 的时候,新值与辅助栈栈顶比较,把较小值压入辅助栈;每次 pop 的时候,两个栈一起弹出。这样辅助栈的栈顶永远是当前栈的最小值。
java复制class MinStack {
private Deque<Integer> stack;
private Deque<Integer> minStack;
public MinStack() {
stack = new ArrayDeque<>();
minStack = new ArrayDeque<>();
minStack.push(Integer.MAX_VALUE);
}
public void push(int val) {
stack.push(val);
minStack.push(Math.min(val, minStack.peek()));
}
public void pop() {
stack.pop();
minStack.pop();
}
public int top() {
return stack.peek();
}
public int getMin() {
return minStack.peek();
}
}
这里有两个容易写错的点。第一,minStack 要先放一个 Integer.MAX_VALUE 做底,不然第一次 push 的时候 minStack.peek() 会报空指针。第二,pop 的时候两个栈要同步弹,很多人只弹了数据栈,结果辅助栈的最小值状态就错乱了。
注意:面试官大概率会追问“能不能不用辅助栈实现?”答案是能,用的是差值法:栈里存
val - min,用一个变量维护当前最小值。这个方法的复杂度不变,但对数值范围有要求,容易溢出。如果被追问,可以先说思路,再指出它的局限性,反而显得你考虑周全。
2.3 单调栈:真正拉开差距的一类题
如果说有效括号是热身,最小栈是基础,那单调栈就是栈题里真正能拉开差距的部分。最典型的题目是“每日温度”:给一个数组,返回一个数组,每个位置表示“要等几天才能等到更高温度”,如果之后没有更高温度,填 0。
暴力做法是双重循环,对每个位置往后扫描,时间复杂度 O(n²)。数据量一大就废了。单调栈的思路是:维护一个栈,栈里存下标,但栈内下标对应的温度是递减的。遍历数组时,如果当前温度大于栈顶下标对应的温度,说明栈顶的下一个更高温度找到了,弹出并计算结果;否则当前下标入栈。每个元素最多入栈一次、出栈一次,时间复杂度 O(n)。
java复制public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] ans = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
int idx = stack.pop();
ans[idx] = i - idx;
}
stack.push(i);
}
return ans;
}
这段代码量不大,但信息密度很高。我重点说两个细节。第一,栈里存的是下标,不是温度值——因为只有存下标才能算出“等了多少天”。第二,while 循环弹栈的时候,可能一次性弹出多个元素,每个被弹出的元素都找到了自己右侧第一个更大的温度。这就是单调栈的核心:它帮你把“当前元素向右找第一个比自己大的元素”这件事,变成了 O(n)。
同样的套路还能解“下一个更大元素”“柱状图中最大的矩形”“接雨水”等问题。面试时如果考栈,出题人通常不会只满足于让你写一个括号匹配,而是会拿这类进阶题来试探你的抽象能力。我的建议是:把单调栈的模板吃透,然后找 2-3 道同类题练手,直到你不需要思考就能把模板写出来为止。
3. 队列题实战:双栈模拟、滑动窗口、循环队列与 BFS
3.1 用栈实现队列:O(1) 均摊的经典设计题
“用两个栈实现队列”是队列题里最经典的一道设计题,它在力扣上的编号是 232。核心思路一句话就能讲清楚:用两个栈,一个专门负责入队,一个专门负责出队,出队时先把入队栈的元素全部倒进出队栈,顺序就反过来了。
比如你用栈依次压入 1、2、3,栈顶是 3;如果现在要出队,按队列的规则应该出 1。你先把 1、2、3 从入队栈弹出,依次压入出队栈,此时出队栈里从栈顶到栈底依次是 1、2、3,弹出栈顶就是 1。逻辑上完全等价于队列的 FIFO。
java复制class MyQueue {
private Deque<Integer> inStack;
private Deque<Integer> outStack;
public MyQueue() {
inStack = new ArrayDeque<>();
outStack = new ArrayDeque<>();
}
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();
}
}
很多第一次见到这道题的人会困惑:pop 的时候把元素倒来倒去,时间复杂度不是 O(n) 吗?关键在于均摊复杂度:每个元素最多只会被从 inStack 搬到 outStack 一次,之后出队都是 O(1)。所以虽然单次 pop 在极端情况下是 O(n),但整体均摊下来还是 O(1)。这就是面试官想听的答案。
对应的“用队列实现栈”也是一道经典题。思路可以是:入队时先把元素放进队列,然后把队列前面的元素全部出队再入队,这样新元素就排到了队首,出队时就能模拟出栈的顺序了。也可以用两个队列互相倒,原理类似。这两道题建议一起刷,做完你会对 FIFO 和 LIFO 的差别有刻骨铭心的理解。
3.2 滑动窗口最大值:为什么必须是双端队列
给你一个数组和一个整数 k,有一个长度为 k 的窗口从数组最左边滑到最右边,每次移动一格,请返回每个窗口中的最大值。暴力解法是每个窗口扫一遍,时间复杂度 O(n*k),k 大的时候直接爆炸。
高效解法需要用到双端队列(deque),而且队列里存的是下标。核心思路是:队列里的下标对应的元素,按从队首到队尾的顺序是递减的。队首永远是当前窗口的最大值下标。每当新元素要入队时,从队尾开始,把所有比它小的元素都弹出,再把它的下标入队;同时还要检查队首下标是否已经滑出窗口,如果滑出了就弹出队首。
java复制public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] ans = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
if (deque.peekFirst() <= i - k) {
deque.pollFirst();
}
if (i >= k - 1) {
ans[i - k + 1] = nums[deque.peekFirst()];
}
}
return ans;
}
这段代码需要仔细看三遍。为什么是双端队列而不是普通队列? 因为普通的队列只能从队尾进、队头出,而滑动窗口的场景里,新元素要从队尾进,但淘汰过时元素要从队头出,同时为了维持递减还需要从队尾弹出“没用”的旧元素——两头都要操作,所以必须是双端队列。
时间复杂度是 O(n),因为每个元素最多入队一次、出队一次。这道题在面试里出现频率很高,因为它同时考察了队列、双指针、滑动窗口三个知识点,能完整写出来的人不多。我第一次刷的时候在“从队尾弹出比新元素小的元素”这一步想了很久,后来才意识到:窗口最大值只关心“谁能当老大”,既然新元素更大,那队尾那些小的在窗口里永远没机会当最大值了,留它们只会增加无用操作。
3.3 循环队列设计题:工程里环形缓冲区的真相
设计一个循环队列,支持 enQueue、deQueue、Front、Rear、isEmpty、isFull 六个操作。这道题看起来是设计题,实际考察的是对数组下标的控制能力。
普通队列用数组实现的话,出队后队头前面的空间就浪费了。循环队列的思路是:让队尾指针在到达数组末尾时重新绕回开头,实现空间的循环利用。判断队列是否为空和是否为满,有两种常见做法:一种是用一个 size 变量记录当前元素数量,另一种是额外空一个位置来区分空和满。算法题里用 size 更直观,不容易出错。
java复制class MyCircularQueue {
private int[] data;
private int head;
private int tail;
private int size;
private int capacity;
public MyCircularQueue(int k) {
this.capacity = k;
this.data = new int[k];
this.head = 0;
this.tail = 0;
this.size = 0;
}
public boolean enQueue(int value) {
if (isFull()) return false;
data[tail] = value;
tail = (tail + 1) % capacity;
size++;
return true;
}
public boolean deQueue() {
if (isEmpty()) return false;
head = (head + 1) % capacity;
size--;
return true;
}
public int Front() {
return isEmpty() ? -1 : data[head];
}
public int Rear() {
return isEmpty() ? -1 : data[(tail - 1 + capacity) % capacity];
}
public boolean isEmpty() {
return size == 0;
}
public boolean isFull() {
return size == capacity;
}
}
这里最关键的运算就是 (tail + 1) % capacity。取模是实现循环的核心,但也容易在取 Rear 的时候踩坑:当 tail 等于 0 时,tail - 1 是负数,所以需要 (tail - 1 + capacity) % capacity,多加上一个 capacity 保证结果是正数。
提示:循环队列在工程里非常常见,比如操作系统里的环形缓冲区、消息队列的底层存储、音视频播放的缓冲队列。能在面试时提到这些应用场景,会加分不少。
3.4 BFS 中的队列:一种隐形的应用
有些题不会直接说“用队列”,但解法里离不开队列,最典型的就是树的层序遍历和图的广度优先搜索。层序遍历要求按层输出二叉树节点,做法就是把根节点入队,然后循环:弹出当前队首,把它的左右孩子入队。这样天然就是按层推进的。
这类题的题目长这样:二叉树层序遍历、二叉树的最小深度、腐烂的橘子、岛屿数量。它们考的不是队列本身的 API,而是**“先进先出”这个特性如何保证按层级扩展**。我在“腐烂的橘子”里体会最深:每一轮要记录当前队列的大小,把这一轮的所有橘子处理完,再进入下一轮,这样就能统计出经过了几分钟。
刷这类题的时候,建议不要只背代码,要多想想“为什么队列能做到一层一层推进”。想明白了,你会发现 BFS 的本质就是把“先发现的先扩展”翻译成代码,而队列就是那个“先来先服务”的调度器。
4. 优先级队列实战:TopK、合并链表、数据流中位数
4.1 最小的 K 个数:先想清楚用大根堆还是小根堆
面试里 TopK 问题出现频率极高,最基础的版本是“输入一个数组,找出其中最小的 k 个数”。很多人的第一反应是“排序,然后取前 k 个”,这当然可以,但面试官下一个问题一定是“时间复杂度多少?能不能优化?”这时就该堆出场了。
这里的核心决策是:求最小的 k 个数,用一个大小为 k 的大根堆。这个选择反直觉,但逻辑很清晰:大根堆的堆顶是堆中最大的元素,也就是当前“备选答案”里最差的那个。当新元素比堆顶小时,说明堆顶不可能是最终答案,弹出堆顶,把新元素放进去。遍历完后,堆里留下的就是最小的 k 个数。
java复制public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0) return new int[0];
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
for (int num : arr) {
if (maxHeap.size() < k) {
maxHeap.offer(num);
} else if (num < maxHeap.peek()) {
maxHeap.poll();
maxHeap.offer(num);
}
}
int[] ans = new int[k];
int index = 0;
for (int num : maxHeap) {
ans[index++] = num;
}
return ans;
}
这么做的复杂度是 O(n log k),空间 O(k)。当 n 很大、k 很小的时候,这个方案比全排序 O(n log n) 明显更优。如果面试官继续追问,还可以提快速选择算法,平均时间复杂度可以降到 O(n),但缺点是必须一次性拿到全部数据,无法处理数据流场景。对比一下就能看出:堆方案的优势在于它天然适合流式数据,因为每次只需要维护 k 个元素。
4.2 合并 K 个有序链表:堆在链表场景的常见用法
“合并 K 个升序链表”是一道很经典的困难题,考察点集中在堆和链表操作的结合。暴力做法是把所有链表的节点值取出来排序,再重新构造链表,时间复杂度 O(N log N),其中 N 是总节点数。用堆可以把复杂度降到 O(N log K)。
思路是:把 K 个链表的头节点放进一个小根堆,每次弹出堆顶(当前最小的节点),接到结果链表后面,然后如果这个节点还有下一个节点,就把下一个节点入堆。重复这个过程直到堆为空。
java复制public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> a.val - b.val);
for (ListNode node : lists) {
if (node != null) {
pq.offer(node);
}
}
ListNode dummy = new ListNode(-1);
ListNode cur = dummy;
while (!pq.isEmpty()) {
ListNode node = pq.poll();
cur.next = node;
cur = cur.next;
if (node.next != null) {
pq.offer(node.next);
}
}
return dummy.next;
}
这道题我觉得最值得学习的是 dummy 节点的用法。链表题里经常要处理头节点变化的场景,用 dummy 可以避免大量“头节点是否为空”的判断。堆里存的是链表节点,比较器按节点值排序,这样每次弹出的都是当前所有链表头节点里的最小值。
注意:写比较器的时候一定要小心方向。Java 的
PriorityQueue默认是小根堆,(a, b) -> a.val - b.val就是小根堆。如果写反,变成b.val - a.val,就变成大根堆了,代码结果直接错误。这个错误几乎每个人都会犯至少一次。
4.3 数据流中的中位数:两个堆互相配合
“数据流中的中位数”是我觉得优先级队列题目里最巧妙的一道。设计一个类,支持两个操作:addNum 往数据流里加一个数,findMedian 返回当前所有数的中位数。数据流是持续增加的,你不可能每次求中位数都重新排序。
做法是维护两个堆:一个大根堆存较小的一半,一个小根堆存较大的一半。这样堆顶一个是较小部分的最大值,一个是较大部分的最小值,中位数就是这两个堆顶的平均数(偶数个时)或其中一个堆顶(奇数个时)。
java复制class MedianFinder {
private PriorityQueue<Integer> maxHeap;
private PriorityQueue<Integer> minHeap;
public MedianFinder() {
maxHeap = new PriorityQueue<>((a, b) -> b - a);
minHeap = new PriorityQueue<>();
}
public void addNum(int num) {
if (maxHeap.isEmpty() || num <= maxHeap.peek()) {
maxHeap.offer(num);
} else {
minHeap.offer(num);
}
if (maxHeap.size() > minHeap.size() + 1) {
minHeap.offer(maxHeap.poll());
} else if (minHeap.size() > maxHeap.size()) {
maxHeap.offer(minHeap.poll());
}
}
public double findMedian() {
if (maxHeap.size() > minHeap.size()) {
return maxHeap.peek();
}
return (maxHeap.peek() + minHeap.peek()) / 2.0;
}
}
我第一次看这道题时特别好奇:为什么要在 addNum 里做两次判断而不是一次?后来才明白,两个堆需要时刻保持“大根堆比小根堆多 0 或 1 个元素”的平衡,但如果只判断一边,可能另一边的平衡被破坏。比如连续插入很大的数,所有数都进小根堆了,这时小根堆就会越来越大,必须把堆顶(较大一半的最小值)搬回大根堆。
这种“两个堆互相配合”的思路在面试中是加分项,因为它展示了你对堆的性质有深入理解,而不是只会调用 API。时间复杂度方面,插入是 O(log n),取中位数是 O(1)。
4.4 贪心配合堆:面试加分题方向
优先级队列还有一个经典用法是配合贪心算法,这里也提一下方向。最典型的题目是“任务调度器”和“IPO 找最大资本收益”。
以任务调度器为例:给你一堆任务,每个任务有冷却时间,要求最短执行时间。暴力的思路难以处理,但用堆就可以这样设计:统计每种任务的数量,把数量大于 0 的任务放进大根堆,每个时间单位从堆里取数量最多的任务执行,执行完后数量减一,暂时放到一个等待队列里,等到冷却时间过了再重新入堆。这个过程中,堆保证了每一轮都优先执行剩余数量最多的任务,让调度结果尽可能紧凑。
这类题目不一定天天考,但一旦考到,会做的和不会做的差距就很明显。我的建议是:在掌握基础题后,找 1-2 道这种“堆+贪心”的组合题练手,重点体会“为什么这里用堆而不是排序”。
5. 常见问题与排查技巧实录
5.1 手撕现场最容易翻车的 4 个细节
先说第一个:在栈空的时候调用 peek() 或 pop()。不管用 ArrayDeque 还是 LinkedList,空栈操作都会抛异常或返回 null,导致程序崩溃。写完代码后,一定要养成“先用 isEmpty() 判断再操作”的习惯,或者想清楚某个操作的调用时机是否绝对安全。
第二个:队列的 offer 和 add 混用。在 Java 里,ArrayDeque 的 add 满了会抛异常,offer 满了会返回 false。虽然算法题里很少真的装满,但一旦队列大小有限制(比如循环队列),就会出问题。保持一致,统一用 offer 和 poll。
第三个:堆的比较器写反。前面提过一次,但值得再强调一遍:Java 的 PriorityQueue 默认是小根堆。想用大根堆,比较器要写 (a, b) -> b - a。更稳妥的写法是用 Comparator.reverseOrder(),避免手写比较器时方向搞错。
第四个:单调栈/滑动窗口里存下标还是存值,搞混。这个细节很容易被忽视。你存下标是为了能算出位置差(比如每日温度的结果是下标差),但比较大小的时候要从原数组里取元素。如果不小心把值和下标混在一起,代码会很怪,而且不容易调试。
我把这些常见错误整理成一张速查表,方便你刷题时对照检查:
| 场景 | 常见错误 | 正确做法 |
|---|---|---|
| 栈操作 | 空栈时弹栈/取栈顶 | 先判空再操作 |
| 队列 | 只从队头出队,忽略队尾淘汰 | 双端队列两头操作都要考虑 |
| 堆 | 大根堆小根堆比较器写反 | 求最大用大根堆 b - a,求最小用小根堆 a - b |
| 存内容 | 需要下标却存了值 | 想清楚结果需要下标差还是值本身 |
| 循环队列 | tail 为 0 时取队尾越界 | 用 (tail - 1 + capacity) % capacity |
5.2 面试官追问方向与高效应对
面试官在听完你写完一道题之后,通常不会直接放你走。针对栈、队列、优先级队列这三类题,追问的方向其实挺集中,提前准备,面试会稳很多。
第一问:“你这个算法的时间复杂度和空间复杂度是多少?”这是送分题,但很多人紧张的时候会把 O(n) 说成 O(log n)。我的建议是:写完代码主动把复杂度说出来,显得更专业,也省得被追问时紧张。
第二问:“能不能优化一下?”比如有效括号,能不能用 O(1) 空间?答案是用计数器(但只适用于只有一种括号的情况);滑动窗口最大值,能不能用 O(1) 空间?答案是不能,因为你必须记住窗口内最大值的位置信息。这个追问考的就是你能不能理性分析出优化极限。
第三问:“如果数据量特别大,你的方案还能用吗?”这是堆题常见的追问场景。比如最小的 k 个数,面试官会问“如果数组大到不能一次性加载进内存怎么办?”答案就是:维护一个大小为 k 的大根堆,流式处理。所以我说堆方案天然适合大数据场景,不是口头说说,是真的有用。
应对追问的核心原则是:不要慌,不要急着答,先理清思路再开口。如果真的不会,诚实地说明“目前掌握到这里,但我知道大方向是什么”,也比硬编一个错误答案好。
5.3 刷题顺序与举一反三的思路
最后说说刷题策略。我发现很多朋友刷题有个问题:今天刷栈,明天刷链表,后天刷二叉树,每道题都是独立的,没有形成知识网络。但面试题是成体系的,一个知识点会串着另一个知识点考。
我的建议顺序是:先把每种结构最经典的 3-4 道题刷熟,然后做结合题。栈和队列的经典题刷完后,做“用栈实现队列”“用队列实现栈”这两道设计题,把两种结构的关系打通。树结构题目做 BFS 时,顺便复习队列的层序遍历。这样知识就是连成片的,而不是一个一个孤立的小点。
另外,不建议死记代码模板。我的做法是:每道题刷完,用自己的话把思路写在注释里,比如“这里维护递减栈,是因为要找右边第一个更大的元素”。下次看到类似的题,即使代码细节忘了,思路还在,就能快速恢复。
关于举一反三,我给一个具体操作建议:每刷完一道题,去力扣看这道题在题解区有没有“相似题目”标签,顺着刷 1-2 道。比如刷完“有效括号”,就去刷“最长有效括号”;刷完“每日温度”,就去刷“下一个更大元素”。这样不动脑子就能找到同类题,效率很高。
我在实际带学弟学妹刷题的过程中发现,真正能把这三类结构吃透的人,都有一个共同特点:他们不追求刷题数量,而是每道题都愿意花时间去模拟一遍执行过程。比如最小栈,自己拿个小本子把 push 1、push 2、getMin、pop、getMin 的过程走一遍,很快就能理解辅助栈的作用。
这也是我写这篇内容的一个小心愿:不要被“题目数量”绑架。栈、队列、优先级队列的题目再多,核心套路就这些。把它们的根挖透了,面试时碰到新题,你也能一眼看穿它到底在考什么。
