Day9: 栈与队列
栈
:适合处理后进先出
的场景,如函数调用栈
、括号匹配
等。队列
:适合处理先进先出
的场景,如任务调度
、消息队列
等。
用栈实现队列
题目链接:232. 用栈实现队列 - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:栈的基本操作! | LeetCode:232.用栈实现队列_哔哩哔哩_bilibili
题目建议:大家可以先看视频,了解一下模拟的过程,然后写代码会轻松很多。
class MyQueue {
public MyQueue() {
}
public void push(int x) {
}
public int pop() {
}
public int peek() {
}
public boolean empty() {
}
}
题目解析
使用栈来模拟队列的行为,如果仅仅用一个栈,是一定不行的,因为出栈和出队列的顺序相反
,所以需要两个栈一个输入栈,一个输出栈
,这里要注意输入栈
和输出栈
的关系。
完整代码
用队列实现栈
题目链接:225. 用队列实现栈 - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:队列的基本操作! | LeetCode:225. 用队列实现栈_哔哩哔哩_bilibili
题目建议:可能大家惯性思维,以为还要两个队列来模拟栈,其实只用一个队列就可以模拟栈了。建议大家掌握一个队列的方法,更简单一些,可以先看视频讲解
class MyStack {
public MyStack() {
}
public void push(int x) {
}
public int pop() {
}
public int top() {
}
public boolean empty() {
}
}
题目解析
使用一个队列模拟栈,在出栈时,可以把 n-1 个元素先出队列再入队列,此时出队列的元素,就是栈要弹出的元素;
使用两个队列来模拟栈,只不过没有输入和输出的关系,而是其中一个队列用于备份
!
用两个队列 que1 和 que2 实现队列的功能,que2 其实完全就是一个备份的作用
:
- 把
que1
最后面的元素以外的元素
都备份到que2
; - 然后弹出最后面的元素;
- 再把其他元素从
que2
导回 `que1。
完整代码
方法一: 使用单队列循环
方法二:使用双队列来保证队列顺序与栈一致
有效的括号
文章讲解:代码随想录
视频讲解:栈的拿手好戏!| LeetCode:20. 有效的括号_哔哩哔哩_bilibili
题目建议:讲完了栈实现队列,队列实现栈,接下来就是栈的经典应用了。大家先自己思考一下 有哪些不匹配的场景,在看视频 我讲的都有哪些场景,落实到代码其实就容易很多了。
class Solution {
public boolean isValid(String s) {
}
}
题目解析
栈结构适合解决对称匹配问题。在处理括号匹配问题时,首先要分析不匹配的三种情况:
- 左括号多余:字符串中左括号数量多于右括号,导致无法完全匹配。
- 右括号多余:字符串中右括号数量多于左括号,或者右括号出现在没有匹配左括号的情况下。
- 括号顺序错误:左括号和右括号的顺序不正确,例如“)(”这种情况。
在写代码之前,明确这些不匹配情况,可以避免逻辑混乱,提高代码的准确性和可读性。
完整代码
删除字符串中的所有相邻重复项
题目链接:1047. 删除字符串中的所有相邻重复项 - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:栈的好戏还要继续!| LeetCode:1047. 删除字符串中的所有相邻重复项_哔哩哔哩_bilibili
题目建议:栈的经典应用。要知道栈为什么适合做这种类似于爱消除的操作,因为栈帮助我们记录了 遍历数组当前元素时候,前一个元素是什么。
class Solution {
public String removeDuplicates(String s) {
}
}
题目解析
本题是删除相邻重复元素的问题,可以用栈解决。具体思路如下:
栈的作用
:栈用于存放遍历过的元素,目的是判断当前元素是否与栈顶元素相邻且相同
。匹配与消除
:如果当前元素与栈顶元素相同,则弹出栈顶元素,实现消除操作;否则,将当前元素压入栈。生成结果
:遍历结束后,栈中剩余的元素组成字符串,但需反转顺序以恢复正序,最终得到结果。
总结:通过栈存储遍历过的元素,实现相邻重复项的匹配与消除,最后反转栈中剩余元素得到最终结果。
那么,我们是否可以使用快慢双指针,来模拟栈的操作呢?
slow
模拟操作栈指针
,fast
模拟遍历数组指针
s[++slow] = s[fast]
模拟入栈操作
,slow--
模拟出栈操作
完整代码
方法一:栈
方法二:使用快慢双指针模拟栈操作
class Solution {
public String removeDuplicates(String ss) {
Stack<Character> stack = new Stack<>();
char[] s = ss.toCharArray();
stack.push(s[0]);
for (int i = 1; i < s.length; i++) {
if (!stack.isEmpty() && s[i] == stack.peek()) {
stack.pop();
continue;
}
stack.push(s[i]);
}
int size = stack.size();
char[] ret = new char[size];
for (int i = size - 1; i >= 0; i--) {
ret[i] = stack.pop();
}
return new String(ret);
}
}
class Solution {
public String removeDuplicates(String ss) {
int slow = -1; // -1 模拟栈空的情况
char[] s = ss.toCharArray();
s[++slow] = s[0];
for (int fast = 1; fast < s.length; fast++) { // 有循环条件, 无须担心只有一个元素的情况造成的越界
if (slow != -1 && s[fast] == s[slow]) {
// 关键err: 重复, slow--,模拟栈抛出栈顶元素
slow--;
continue;
}
// 关键err: 未重复, slow 模拟入栈元素
s[++slow] = s[fast];
}
return new String(Arrays.copyOf(s, slow + 1)); // err: 此时 slow 模拟的是栈元素个数, slow+1 才能来到需要拷贝数组的地方
}
}
Day10 栈与队列
逆波兰表达式求值
题目链接:150. 逆波兰表达式求值 - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:栈的最后表演! | LeetCode:150. 逆波兰表达式求值_哔哩哔哩_bilibili
题目建议:本题不难,但第一次做的话,会很难想到,所以先看视频,了解思路再去做题
class Solution {
public int evalRPN(String[] tokens) {
}
}
题目解析
1. 栈与递归的关系
核心观点
:递归的本质可以用栈来实现
,因为递归调用的过程就是函数不断入栈和出栈的过程。示例
:二叉树的前序/中序/后序遍历,既可以用递归实现(隐式栈),也可以用栈手动模拟(显式栈)。关键结论
:栈和递归可以相互转换
,递归的深层调用栈就是栈结构的一种应用。
2. 逆波兰表达式(后缀表达式)与二叉树后序遍历
-
核心观点
:逆波兰表达式是二叉树后序遍历的结果
。-
运算符
是中间节点
,操作数
是叶子节点
。 -
例如:
3 4 + 5 ×
对应的二叉树:× / \ + 5 / \ 3 4
-
后序遍历(左右根)的结果就是逆波兰表达式。
-
-
关键结论
:理解逆波兰表达式有助于理解二叉树的后序序列化
,但解题时无需显式构建二叉树。
3. 逆波兰表达式求值 vs. 相邻字符消除(对对碰)
相似点
:相邻运算
:逆波兰表达式每次遇到运算符,就计算最近的两个操作数
,类似于1047.删除相邻重复项
的“对对碰”逻辑。栈的应用
:两者都依赖栈结构处理相邻元素:- 逆波兰表达式:遇到数字入栈,遇到运算符弹出栈顶两个数计算,结果再入栈。
- 删除相邻重复项:遇到相同字符就弹出栈顶,否则入栈。
区别
:- 逆波兰表达式:
计算相邻操作数
,生成新结果。 - 删除相邻重复项:
匹配相邻相同字符
,直接删除。
- 逆波兰表达式:
4. 总结
栈 ≈ 递归
:递归调用本质是栈操作,两者可互相转换(如二叉树遍历)。逆波兰表达式 = 二叉树后序遍历
:运算符是中间节点,但解题时无需显式建树。核心操作 = 栈 + 相邻计算
:- 逆波兰表达式:数字入栈,遇运算符计算栈顶两数,结果回栈。
- 类似题目:
1047.删除相邻重复项
,但匹配逻辑不同(计算 vs. 删除)。
一句话概括:逆波兰表达式求值是通过栈模拟后序遍历的“计算版对对碰
”,核心是栈处理相邻元素
的思想。
完整代码
滑动窗口最大值
题目链接:239. 滑动窗口最大值 - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:单调队列正式登场!| LeetCode:239. 滑动窗口最大值_哔哩哔哩_bilibili
题目建议:
- 有点难度,可能代码写不出来,但一刷至少需要理解思路
- 之前讲的都是栈的应用,这次该是队列的应用了。本题算比较有难度的,需要自己去构造单调队列,建议先看视频来理解。
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
}
}
题目解析
问题分析
题目要求求解滑动窗口中的最大值
。
暴力解法: 更新窗口后重新遍历一次窗口
,时间复杂度为O(n × k)
,效率较低。
优先级队列(大顶堆)
无法有效处理窗口移动时的元素移除问题
。
因此,使用单调队列
是更优的解决方案。
单调队列设计
单调队列是一种特殊的队列,队列中的元素按照某种单调性(如单调递减或单调递增)排列
。
本题中,单调队列需要满足以下规则:
add(value)
:在将元素加入队列之前,先将队列内所有小于该元素的值弹出,以保持队列单调递减
。poll(value)
:如果窗口移除的元素
等于队列出口元素
,则弹出队列出口元素
。peek()
:队列出口元素
即为当前窗口的最大值
。
数据结构选择
使用Deque
(双端队列)实现单调队列,因为它支持在两端
进行高效的插入
和删除操作
:
完整代码
方法一:直接使用 Deque
方法二:模拟符合题目解析的 Queue
- 使用
Deque
实现单调队列,保持队列单调递减
。 - 每次窗口移动时,通过
pop
和push
操作维护单调队列。 队列出口元素
始终是当前窗口的最大值
。- 时间复杂度为
O(n)
,空间复杂度为O(k)
。
前 K 个高频元素
题目链接:347. 前 K 个高频元素 - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:优先级队列正式登场!大顶堆、小顶堆该怎么用?| LeetCode:347.前 K 个高频元素
题目建议:
大/小顶堆
的应用, 在C++中就是优先级队列- 有点难度,可能代码写不出来,一刷至少需要理解思路
- 本题是 大数据中取前k值 的经典思路,了解想法之后,不算难。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
}
}
题目解析
因为题目中说:设计算法的时间复杂度必须优于 O(n log n)
,其中 n
是数组大小
可以发现,使用常规的诸如 冒泡、选择、甚至快速排序都是不满足题目要求,它们的时间复杂度都是大于或者等于 O(nlogn),而题目要求算法的时间复杂度必须优于 O(nlogn)。
对于 Top-K 问题,我们需要找到出现次数最高的前 K 个元素。通过创建小根堆(PriorityQueue
),可以高效地解决这一问题:
具体步骤:
- 借助
哈希表
来建立数字
和其出现次数
的映射,遍历一遍数组统计元素的频率; - 维护一个
元素数目为 k 的最小堆
; - 每次都将
新的元素
与堆顶元素
(堆中频率最小的元素)进行比较; - 如果
新的元素
的频率比堆顶端的元素
大,则弹出堆顶端
的元素,将新的元素添加进堆中; - 最终,
堆中的 k 个元素
即为前 k 个高频元素
;
完整代码
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> map = new HashMap<>();
for (int n : nums) {
map.put(n, map.getOrDefault(n, 0) + 1);
}
PriorityQueue<Map.Entry<Integer, Integer>> minHeap = new PriorityQueue<>(
new Comparator<Map.Entry<Integer, Integer>>() {
@Override
public int compare(Map.Entry<Integer, Integer> o1, Map.Entry<Integer, Integer> o2) {
return o1.getValue().compareTo(o2.getValue());
}
});
for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
if (minHeap.size() < k) {
minHeap.add(entry);
} else {
Map.Entry<Integer, Integer> top = minHeap.peek();
if (top.getValue().compareTo(entry.getValue()) < 0) { // err: 对象的比较不能用 <
minHeap.poll();
minHeap.add(entry);
}
}
}
int[] ret = new int[k];
for (int i = k - 1; i >= 0; i--) {
ret[i] = minHeap.poll().getKey(); // err: 不是 getValue()
}
return ret;
}
}
前 K 个高频单词
题目链接:692. 前K个高频单词 - 力扣(LeetCode)
class Solution {
public List<String> topKFrequent(String[] words, int k) {
}
}
题目解析
所以我们需要对比较规则进行进一步细分;
完整代码
class Solution {
public List<String> topKFrequent(String[] words, int k) {
Map<String, Integer> map = new HashMap<>();
for (String word : words) {
map.put(word, map.getOrDefault(word, 0) + 1);
}
PriorityQueue<Map.Entry<String, Integer>> minHeap = new PriorityQueue<>(
new Comparator<Map.Entry<String, Integer>>() {
@Override
public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
if (o1.getValue().compareTo(o2.getValue()) == 0) {
// 关键err: value 同, 比较 key 的首字母随更小, key 小的优先
return o2.getKey().compareTo(o1.getKey());
}
return o1.getValue().compareTo(o2.getValue());
}
});
for (Map.Entry<String, Integer> word : map.entrySet()) {
// err: 遍历的不是 String[] words, 而是 map.entrySet()
if (minHeap.size() < k) {
minHeap.add(word);
} else {
Map.Entry<String, Integer> top = minHeap.peek();
int tmp = top.getValue().compareTo(word.getValue());
if (tmp == 0) {
if (top.getKey().compareTo(word.getKey()) > 0) {
// 关键err: value 同, 比较 key 的首字母随更小, key 小的优先
minHeap.poll();
minHeap.add(word);
}
} else if (tmp < 0) {
minHeap.poll();
minHeap.add(word);
}
}
}
String[] ret = new String[k];
for (int i = k - 1; i >= 0; i--) {
ret[i] = minHeap.poll().getKey();
}
return Arrays.asList(ret); // err: 注意返回值是 List<String>, 不是数组
}
}
补充知识
Stack API
Java 提供了 java.util.Stack
类来实现栈的功能。栈是一种后进先出(LIFO)的数据结构。
常用方法:
push(E item)
- 功能:将一个元素压入栈顶。
- 示例:
stack.push("Hello");
pop()
- 功能:移除并返回栈顶元素。如果栈为空,会抛出
EmptyStackException
。 - 示例:
String top = stack.pop();
- 功能:移除并返回栈顶元素。如果栈为空,会抛出
peek()
- 功能:返回栈顶元素,但不移除它。如果栈为空,会抛出
EmptyStackException
。 - 示例:
String top = stack.peek();
- 功能:返回栈顶元素,但不移除它。如果栈为空,会抛出
isEmpty()
- 功能:判断栈是否为空。
- 示例:
boolean empty = stack.isEmpty();
size()
- 功能:返回栈中元素的数量。
- 示例:
int size = stack.size();
search(Object element)
- 功能:从栈顶开始查找元素的位置(从1开始计数)。如果未找到,返回
-1
。 - 示例:
int position = stack.search("Hello");
- 功能:从栈顶开始查找元素的位置(从1开始计数)。如果未找到,返回
import java.util.Stack;
public class StackExample {
public static void main(String[] args) {
Stack<String> stack = new Stack<>();
stack.push("A");
stack.push("B");
stack.push("C");
System.out.println("Top element: " + stack.peek()); // 输出 C
System.out.println("Popped element: " + stack.pop()); // 输出 C
System.out.println("Size: " + stack.size()); // 输出 2
}
}
Queue API
Java 提供了 java.util.Queue
接口,以及其实现类(如 LinkedList
、ArrayDeque
等)来实现队列的功能。队列是一种先进先出(FIFO)的数据结构。
常用方法:
add(E e)
- 功能:将一个元素插入队列的尾部。如果队列已满(对于有容量限制的队列),会抛出
IllegalStateException
。 - 示例:
queue.add("Hello");
- 功能:将一个元素插入队列的尾部。如果队列已满(对于有容量限制的队列),会抛出
offer(E e)
- 功能:将一个元素插入队列的尾部。如果插入成功返回
true
,如果队列已满返回false
。 - 示例:
boolean added = queue.offer("Hello");
- 功能:将一个元素插入队列的尾部。如果插入成功返回
remove()
- 功能:移除并返回队列头部的元素。如果队列为空,会抛出
NoSuchElementException
。 - 示例:
String head = queue.remove();
- 功能:移除并返回队列头部的元素。如果队列为空,会抛出
poll()
- 功能:移除并返回队列头部的元素。如果队列为空,返回
null
。 - 示例:
String head = queue.poll();
- 功能:移除并返回队列头部的元素。如果队列为空,返回
element()
- 功能:返回队列头部的元素,但不移除它。如果队列为空,会抛出
NoSuchElementException
。 - 示例:
String head = queue.element();
- 功能:返回队列头部的元素,但不移除它。如果队列为空,会抛出
peek()
- 功能:返回队列头部的元素,但不移除它。如果队列为空,返回
null
。 - 示例:
String head = queue.peek();
- 功能:返回队列头部的元素,但不移除它。如果队列为空,返回
isEmpty()
- 功能:判断队列是否为空。
- 示例:
boolean empty = queue.isEmpty();
size()
- 功能:返回队列中元素的数量。
- 示例:
int size = queue.size();
import java.util.LinkedList;
import java.util.Queue;
public class QueueExample {
public static void main(String[] args) {
Queue<String> queue = new LinkedList<>();
queue.add("A");
queue.add("B");
queue.add("C");
System.out.println("Head element: " + queue.peek()); // 输出 A
System.out.println("Removed element: " + queue.poll()); // 输出 A
System.out.println("Size: " + queue.size()); // 输出 2
}
}
Deque API
在Java中,Deque
(双端队列)是一个接口,它提供了在队列的两端(入口和出口)进行插入和弹出操作的能力。Deque
的名称来源于“Double Ended Queue”,即双端队列。
Deque
的主要特点
Deque
允许在队列的头部和尾部进行以下操作:
- 插入元素
- 移除元素
- 访问元素
常用的 Deque
方法
以下是 Deque
接口提供的一些常用方法,这些方法允许在队列的两端进行操作:
默认情况下:
- 头部(Front):通常被视为队列的“出口”,即元素被移除的一端。
- 尾部(Rear):通常被视为队列的“入口”,即元素被添加的一端。
- 在头部操作
addFirst(E e)
:将元素插入到队列头部。offerFirst(E e)
:将元素插入到队列头部(与addFirst
类似,但失败时返回false
,而不是抛出异常)。peekFirst()
:返回队列头部的元素,但不移除它。pollFirst()
:移除并返回队列头部的元素。如果队列为空,返回null
。removeFirst()
:移除并返回队列头部的元素。如果队列为空,抛出NoSuchElementException
。
- 在尾部操作
addLast(E e)
:将元素插入到队列尾部。offerLast(E e)
:将元素插入到队列尾部(与addLast
类似,但失败时返回false
,而不是抛出异常)。peekLast()
:返回队列尾部的元素,但不移除它。pollLast()
:移除并返回队列尾部的元素。如果队列为空,返回null
。removeLast()
:移除并返回队列尾部的元素。如果队列为空,抛出NoSuchElementException
。
常见实现类
Deque
是一个接口,常用的实现类包括:
LinkedList
:基于链表实现的双端队列,支持高效的插入和删除操作。ArrayDeque
:基于数组实现的双端队列,性能通常优于LinkedList
,并且不支持null
元素。
总结
Deque
提供了在队列的头部和尾部进行插入和移除操作的能力,非常适合实现需要在两端进行操作的场景,例如单调队列、滑动窗口等。
遍历Map
在Java中,HashMap
是一个键值对(key-value
)集合,提供了多种方式来遍历其中的每个元素。以下是几种常见的遍历方法:
方法1:使用entrySet( )遍历
entrySet()
方法返回一个包含所有键值对的集合,可以通过 foreach
循环遍历。
方法2:使用keySet( )遍历
keySet()
方法返回一个包含所有键的集合,可以通过 foreach
循环遍历键,再通过键获取值。
方法3:使用values( )遍历
values()
方法返回一个包含所有值的集合,可以通过 foreach
循环遍历值。注意,这种方式无法直接获取键。
方法4:使用values( )遍历(Java 8↑)
forEach()
方法接受一个 Consumer
函数式接口,可以直接在方法中访问键和值。
方法5:使用迭代器(Iterator)
可以通过 entrySet()
、keySet()
或 values()
获取迭代器(Iterator
),然后使用 while
循环遍历。
方法6:使用 Stream API(Java 8↑)
可以通过 entrySet()
、keySet()
或 values()
获取流(Stream
),然后使用 forEach
方法遍历。
总结
entrySet()
:推荐使用,可以直接获取键和值。keySet()
:适合需要频繁访问键的场景。values()
:适合只需要值的场景。forEach()
:简洁,适合Java 8及以上版本。Iterator
:适合需要手动控制迭代器的场景。Stream
API:适合需要流式操作的场景。
根据你的具体需求选择合适的方法。
PriorityQueue API
1. 添加元素
add(E e)
:将指定元素插入队列。如果队列已满(对于有容量限制的队列),会抛出IllegalStateException
。offer(E e)
:将指定元素插入队列。如果插入成功返回true
,如果队列已满返回false
。
2. 访问元素
peek()
:返回队列头部的元素,但不移除它。如果队列为空,返回null
。element()
:返回队列头部的元素,但不移除它。如果队列为空,会抛出NoSuchElementException
。
3. 移除元素
poll()
:移除并返回队列头部的元素。如果队列为空,返回null
。remove()
:移除并返回队列头部的元素。如果队列为空,会抛出NoSuchElementException
。
4. 其他操作
size()
:返回队列中的元素数量。isEmpty()
:判断队列是否为空。clear()
:清空队列中的所有元素。contains(Object o)
:判断队列是否包含指定元素。toArray()
:将队列中的元素转换为数组。
指定堆的比较规则
通过重写 PriorityQueue
的 compare
方法来指定比较规则。
实际上,PriorityQueue
本身并没有直接提供一个可以重写的方法来改变比较规则,但可以通过以下两种方式实现类似的效果:
方法1:使用自定义比较器(推荐)
在创建 PriorityQueue
时,直接传入一个自定义的 Comparator
。这是最常用且推荐的方式。
示例:使用自定义比较器实现最大堆
方法2:通过匿名内部类重写比较逻辑
虽然不能直接重写 PriorityQueue
的某个方法,但可以通过匿名内部类的方式,在创建 PriorityQueue
时直接定义比较逻辑。
示例:通过匿名内部类实现最大堆
方法3:通过 lambda 表达式(Java 8↑)
在Java 8及以上版本中,可以使用 lambda
表达式来简化比较器的定义。
示例:使用 lambda
表达式实现最大堆
总结
虽然不能直接重写 PriorityQueue
的某个方法来改变比较规则,但可以通过以下方式实现:
- 使用自定义比较器:通过
Comparator
指定比较规则。 - 匿名内部类:在创建
PriorityQueue
时直接定义比较逻辑。 lambda
表达式:在Java 8及以上版本中,使用lambda
表达式简化比较器的定义。
这些方法都可以灵活地实现大小根堆,并指定比较规则。
栈与队列总结
栈与队列做一个总结吧,加油