目录
2. ArrayDeque 和 LinkedList(实现类)
栈与队列理论基础
队列是先进先出,栈是先进后出。
问题思考
那么我这里再列出四个关于栈的问题,大家可以思考一下。
- C++中stack 是容器么?
- 我们使用的stack是属于哪个版本的STL(标准模板库)?
- 我们使用的STL中stack是如何实现的?
- stack 提供迭代器来遍历stack空间么?
以下是针对Java语言,对于这几个问题的解释:
1. Java中的 Stack
是集合类吗?
- 是。
java.util.Stack
是 Java 集合框架(Java Collections Framework)的一部分,但它继承自Vector
类(一种线程安全的动态数组)。 - 但注意:官方已不推荐直接使用
Stack
,而是建议用Deque
接口的实现类(如ArrayDeque
)来模拟栈操作。
2. Java 的 Stack
类属于集合框架的哪个版本?
Stack
是 Java 1.0 引入的早期集合类,属于原始集合框架的一部分。- 替代方案:从 Java 1.6 开始,官方推荐使用
Deque
接口的push()
和pop()
方法实现栈(例如new ArrayDeque<>()
),因为Deque
的设计更现代且性能更好。
3. Java 中 Stack
类是如何实现的?推荐使用的栈实现是什么?
-
Stack
的实现:基于Vector
(动态数组),所有操作通过数组的末尾操作(如push()
对应addElement()
)。 -
推荐实现:使用
Deque
接口的ArrayDeque
(基于可扩容数组)或LinkedList
(基于双向链表)。示例:Deque<Integer> stack = new ArrayDeque<>(); // 推荐 Deque<Integer> stack = new LinkedList<>(); // 可选,但性能略低
4. Java 的 Stack
类或推荐的栈实现是否提供迭代器?使用时需要注意什么?
-
Stack
类:由于继承自Vector
,支持迭代器(iterator()
方法),但遍历栈会破坏 LIFO 语义(栈本应仅通过顶部操作)。 -
Deque
实现(如ArrayDeque
):也提供迭代器,但同样不推荐遍历。示例:Deque<Integer> stack = new ArrayDeque<>(); stack.push(1); stack.push(2); // 可以遍历,但违背栈的设计原则 for (int num : stack) { System.out.println(num); // 输出顺序为 2, 1(栈顶到栈底) }
-
关键原则:栈是后进先出(LIFO)结构,应仅通过
push()
和pop()
操作。使用迭代器遍历栈虽然可行,但不符合栈的设计意图。
总结对比(C++ vs Java)
问题 | C++ 答案 | Java 答案 |
---|---|---|
栈是否属于容器/集合类 | 是(容器适配器) | 是(但推荐用 Deque 替代 Stack ) |
实现版本 | STL 的特定版本(如 C++11) | Stack (Java 1.0)→ Deque (Java 1.6+) |
底层实现 | 默认基于deque, 可指定其他容器 | Stack 基于 Vector ,推荐 ArrayDeque 基于数组 |
是否支持迭代器 | 否(栈不允许遍历) | 是(但不推荐使用) |
附加建议
- 避免直接使用
Stack
类:因其同步开销(继承自Vector
)和设计过时。 - 优先选择
ArrayDeque
:非线程安全、高性能,符合现代 Java 编程规范。
1. 栈的实现
-
传统实现 – java.util.Stack
Java 提供的
Stack
类继承自Vector
,这意味着它内部实际上是基于动态数组实现的。Stack
类提供了常用的操作,例如:push(E item)
:将元素压入栈顶pop()
:从栈顶弹出元素peek()
:查看栈顶元素empty()
:判断栈是否为空
不过,由于
Stack
继承自Vector
,它默认就拥有迭代器接口,可以对内部元素进行遍历,这与理论上栈应只暴露 LIFO 操作的思想有所出入。 -
现代替代 – 使用 Deque 接口
为了避免
Stack
类暴露出不必要的遍历功能,Java 推荐使用Deque
接口来模拟栈。常见的实现有ArrayDeque
或LinkedList
。例如:Deque<Integer> stack = new ArrayDeque<>(); stack.push(1); stack.push(2); System.out.println(stack.pop()); // 输出 2
使用
Deque
的好处在于,它既能实现栈的 LIFO 逻辑,又可以根据需要隐藏或暴露迭代功能(取决于你如何设计接口)。
2. 队列的实现
-
Queue 接口及其实现
Java 中的队列通过
Queue
接口来定义,常见的实现有LinkedList
和ArrayDeque
。队列遵循先进先出(FIFO)原则,提供的常用操作包括:offer(E e)
:将元素添加到队列尾部poll()
:从队列头部取出并删除元素peek()
:仅查看队列头部的元素
例如,使用
LinkedList
实现队列:Queue<Integer> queue = new LinkedList<>(); queue.offer(1); queue.offer(2); System.out.println(queue.poll()); // 输出 1
-
底层结构与适配器思想
与 STL 中容器适配器(例如 stack 和 queue 默认使用 deque 实现)类似,Java 的队列实现也依赖于底层容器(如链表或数组),但它们并没有刻意隐藏迭代器接口。实际上,许多队列实现都允许你遍历所有元素,因为它们本身就是 Collection 的一部分。
3. 设计理念对比
-
STL 的容器适配器
在 C++ STL 中,stack 和 queue 被设计为容器适配器,其核心思想是仅提供特定的接口(如 push、pop 等),并通过封装一个底层容器(如 deque、vector、list)来完成实际的数据存储。这样做的目的是严格限制用户对数据结构的访问,只暴露特定操作,而不允许随意遍历容器内容。
-
Java 的实现方式
Java 的 Collections Framework 更多依赖于接口与继承的方式。例如,
Stack
类直接继承自Vector
,暴露了全部的迭代能力;而使用Deque
实现的栈或队列,虽然可以只使用 push/pop 或 offer/poll 操作,但它们依然继承自 Collection 接口,允许遍历。也就是说,在 Java 中,严格隐藏遍历接口的思想没有像 STL 那样明确,但通过选择合适的使用方式(例如只调用栈或队列的相关方法)依然可以达到类似的逻辑效果。
总结
- 栈:
- Java 传统的
Stack
类基于Vector
实现,但由于继承了Vector
,它暴露了遍历等不必要的接口。 - 为了更符合严格的 LIFO 操作,推荐使用
Deque
(如ArrayDeque
)来实现栈,这样既能完成 push/pop 操作,也能根据需要控制是否允许遍历。
- Java 传统的
- 队列:
- Java 通过
Queue
接口及其实现(如LinkedList
、ArrayDeque
)来实现 FIFO 结构。 - 队列同样依赖于底层容器,但通常不会刻意隐藏遍历功能,因为它们本身作为 Collection 的一部分,允许对所有元素进行迭代。
- Java 通过
详解栈与队列的接口与实现
1. Queue 和 Deque(接口层级)
Queue
(队列)和Deque
(双端队列)都是 Java 集合框架(Java Collections Framework, JCF) 的接口。Queue
代表 先进先出(FIFO) 结构,通常用于任务调度、消息队列等。Deque
继承自Queue
,支持 两端插入和删除,可以作为 双端队列(双向 FIFO) 或 栈(LIFO) 使用。
2. ArrayDeque 和 LinkedList(实现类)
ArrayDeque
和LinkedList
都是Deque
接口的实现类。ArrayDeque
使用 动态数组 作为底层存储结构,适用于需要高效的队列和栈操作。LinkedList
使用 双向链表 作为底层存储结构,适用于频繁的插入和删除操作。
3. LinkedList 额外实现了 List
LinkedList
不仅仅是一个队列(Queue/Deque),它还实现了List
接口,因此可以按索引访问元素,适用于链表操作。ArrayDeque
仅实现Deque
,不能像LinkedList
那样通过索引访问元素。
总结关系图
Queue
是Deque
的父接口。Deque
扩展了Queue
,支持双端操作。LinkedList
和ArrayDeque
都实现了Deque
,但它们的底层数据结构不同。LinkedList
额外实现了List
,支持按索引访问。
选择建议
ArrayDeque
- 适用于栈(LIFO) 和 队列(FIFO) 操作,比
LinkedList
更快(避免链表的指针操作)。 - 适合频繁访问两端的情况,如
push()
、pop()
、offerFirst()
、pollLast()
。
- 适用于栈(LIFO) 和 队列(FIFO) 操作,比
LinkedList
- 适用于需要按索引访问的链表操作,但比
ArrayDeque
慢(需要遍历链表)。 - 适合插入和删除频繁但不经常遍历的情况。
- 适用于需要按索引访问的链表操作,但比
Queue
- 适用于标准队列场景,如任务调度、消息队列等。
Deque
- 适用于双端插入和删除的场景,如双端队列、栈等。
👉 如果只是需要队列或栈功能,推荐使用
ArrayDeque
,它通常比LinkedList
更高效!
4. ArrayDeque和LinkedList方法详解
ArrayDeque 和 LinkedList 是 Java 中实现双端队列(Deque)的类,支持从队列的两端(队首和队尾)高效地添加、删除和访问元素。
方法类型 | 队首(头部)方法 | 队尾(尾部)方法 | 关键行为说明 |
---|---|---|---|
添加元素 | addFirst(E e) | addLast(E e) | 直接插入,失败抛异常 无返回值(void ) |
offerFirst(E e) | offerLast(E e) | 建议用此方法,插入成功返回 true, 失败返回 false | |
移除元素 | removeFirst() | removeLast() | 直接删除,队列为空时抛异常 返回被删除的元素 |
pollFirst() | pollLast() | 安全删除,队列为空时返回 null | |
访问元素 | getFirst() | getLast() | 队列为空时抛异常 |
peekFirst() | peekLast() | 队列为空时返回 null |
空队列处理:
peekFirst()
和peekLast()
在队列为空时返回null
。getFirst()
和getLast()
在队列为空时抛出异常。
队列容量:
ArrayDeque
是动态扩容的,没有固定容量限制。addFirst()
和addLast()
在队列已满时会抛出异常,而offerFirst()
和offerLast()
会返回false
。
性能:
ArrayDeque
的所有操作(添加、删除、访问)都是 O(1) 时间复杂度。
但注意!
如果你在
Deque
(如ArrayDeque
或LinkedList
)中直接使用push
、pop
和peek
方法,那么你就是在实现 栈(Stack) 的操作。这是因为Deque
接口支持栈的所有操作,并且push
、pop
和peek
方法默认操作的是 栈顶元素(即双端队列的队首元素)。
栈的操作与 Deque
的对应关系
栈操作 | Deque 对应方法 | 功能描述 |
---|---|---|
push | push(E e) | 将元素压入栈顶(即添加到队首)。 |
pop | pop() | 弹出栈顶元素(即移除并返回队首元素)。 |
peek | peek() | 查看栈顶元素(即返回队首元素,但不移除)。 |
isEmpty | isEmpty() | 检查栈是否为空。 |
size和length方法详解
在 Java 中,集合(Collection)类(如 ArrayList
、LinkedList
)使用 size()
方法获取元素数量,而数组(Array)使用 length
属性获取长度。
类型 | 方法/属性 | 示例 |
---|---|---|
数组 | length 属性 | int len = arr.length; |
集合类 | size() 方法 | int size = list.size(); |
- 数组是固定长度的:
- 数组在创建时确定长度(如
int[] arr = new int[5]
),length
表示其容量(固定不变)。 - 例如:
arr.length
始终是 5,即使数组中只有 3 个元素被赋值。
- 数组在创建时确定长度(如
- 集合是动态扩容的:
- 集合类的元素数量(
size()
)会随着增删操作动态变化。 - 例如:
list.add(10)
后,list.size()
会增加 1,但底层数组可能已自动扩容。
- 集合类的元素数量(
- 语义不同:
length
是静态属性,反映数组的容量(物理长度)。size()
是动态方法,反映集合当前存储的元素数量(逻辑长度)。
- 面向对象特性:
- 数组是 Java 中的基础数据结构,直接通过属性
length
访问。 - 集合是对象,通过方法
size()
提供更灵活的操作(可能涉及内部计算)。
- 数组是 Java 中的基础数据结构,直接通过属性
232.用栈实现队列
代码实现
class MyQueue {
Stack<Integer> stackIn;
Stack<Integer> stackOut;
void move(){
// move 方法只在 stackOut 为空时才需要执行
if (stackOut.isEmpty()) {
while(!stackIn.empty()){
stackOut.push(stackIn.pop());
}
}
}
public MyQueue() {
stackIn = new Stack<>();
stackOut = new Stack<>();
}
public void push(int x) {
stackIn.push(x);
}
public int pop() {
move();
return stackOut.pop();
}
public int peek() {
move();
return stackOut.peek();
}
public boolean empty() {
return stackIn.empty() && stackOut.empty();
}
}
/**
* Your MyQueue object will be instantiated and called as such:
* MyQueue obj = new MyQueue();
* obj.push(x);
* int param_2 = obj.pop();
* int param_3 = obj.peek();
* boolean param_4 = obj.empty();
*/
注意:move 方法只在 stackOut 为空时才需要执行;因为stackOut不为空时,最早进入队列的元素一定在stackOut中!
225. 用队列实现栈
代码实现
class MyStack {
Queue<Integer> queue1;
Queue<Integer> queue2;
public MyStack() {
queue1 = new ArrayDeque<>();
queue2 = new ArrayDeque<>();
}
public void push(int x) {
queue1.offer(x);
}
public int pop() {
keepOne();
return queue1.poll();
}
public int top() {
keepOne();
return queue1.peek();
}
public boolean empty() {
return queue1.isEmpty() && queue2.isEmpty();
}
void keepOne(){
if(queue1.isEmpty()){
while(!queue2.isEmpty()){
queue1.offer(queue2.poll());
}
}
while(queue1.size()>1){
queue2.offer(queue1.poll());
}
}
}
注意语法
void keepOne(){
if(queue1.size() == 0){
queue1 = queue2;
queue2.clear();
}
while(queue1.size()>1){
queue2.offer(queue1.poll());
}
}
我们观察一下以上代码哪里错了?
关键问题出现在keepOne
方法中,当queue1
为空时,错误地将queue2
赋值给queue1
并立即清空queue2
,导致后续操作出现空指针。正确的做法是将queue2
的元素逐个转移到queue1
,而不是直接赋值引用。
也就是根据java语法,对于不同数组之间的赋值,实际上是使其指向相同的指针!!!
20. 有效的括号
思路
思路比较简单,看代码注释即可。(自己想的方法复杂度还是稍微有点高了...)
简单写一下:
1. 碰到左括号,就把相应的右括号入栈;
2. 如果是右括号判断是否和栈顶元素匹配时,一定要先考虑栈里是否有元素!
3. 最后判断栈中元素是否均匹配完成。
代码实现
class Solution {
public boolean isValid(String s) {
Deque<Character> deque = new ArrayDeque<>();
for(int i=0;i<s.length();i++){
char ch = s.charAt(i);
//碰到左括号,就把相应的右括号入栈
if (ch == '(') {
deque.push(')');
}else if (ch == '{') {
deque.push('}');
}else if (ch == '[') {
deque.push(']');
}
//如果是右括号判断是否和栈顶元素匹配
else if (deque.isEmpty()){
return false;
}
else if (ch == deque.peek()){ // 一定要先考虑栈里是否有元素!
deque.pop();
}else{
return false;
}
}
//最后判断栈中元素是否均匹配完成
return deque.isEmpty();
}
}
1047. 删除字符串中的所有相邻重复项
代码实现
class Solution {
public String removeDuplicates(String s) {
Deque<Character> deque = new ArrayDeque<>();
for(int i=0; i<s.length(); i++){
if(!deque.isEmpty() && s.charAt(i) == deque.peek()){
deque.pop();
}else{
deque.push(s.charAt(i));
}
}
// 转化为字符串,且注意顺序!!!
String str = "";
while (!deque.isEmpty()) {
str = deque.pop() + str;
}
return str;
}
}
转化为字符串的时候注意顺序!!!
或者拿字符串直接作为栈,省去了栈还要转为字符串的操作:
class Solution {
public String removeDuplicates(String s) {
// 将 res 当做栈
// 也可以用 StringBuilder 来修改字符串,速度更快
// StringBuilder res = new StringBuilder();
StringBuffer res = new StringBuffer();
// top为 res 的长度
int top = -1;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// 当 top >= 0,即栈中有字符时,当前字符如果和栈中字符相等,弹出栈顶字符,同时 top--
if (top >= 0 && res.charAt(top) == c) {
res.deleteCharAt(top);
top--;
// 否则,将该字符 入栈,同时top++
} else {
res.append(c);
top++;
}
}
return res.toString();
}
}
拓展:双指针
class Solution {
public String removeDuplicates(String s) {
char[] ch = s.toCharArray();
int fast = 0;
int slow = 0;
while(fast < s.length()){
// 直接用fast指针覆盖slow指针的值
ch[slow] = ch[fast];
// 遇到前后相同值的,就跳过,即slow指针后退一步,下次循环就可以直接被覆盖掉了
if(slow > 0 && ch[slow] == ch[slow - 1]){
slow--;
}else{
slow++;
}
fast++;
}
return new String(ch,0,slow);
}
}
注意语法
在 Java 中,StringBuilder
和 StringBuffer
是用于处理可变字符串的两个类。它们的核心功能相似,但关键区别在于线程安全性和性能。以下是详细对比:
1. 线程安全性
StringBuffer | StringBuilder |
---|---|
线程安全:所有公共方法都使用 synchronized 关键字修饰,保证多线程环境下的同步操作。 | 非线程安全:方法没有同步,适用于单线程环境。 |
适合多线程共享数据的场景(如并发修改字符串)。 | 适合单线程或不需要同步的场景(性能更高)。 |
2. 性能
StringBuffer | StringBuilder |
---|---|
由于同步机制(synchronized ),在多线程频繁操作时,性能较低。 | 没有同步开销,性能比 StringBuffer 高约 10%~15%。 |
适用于需要线程安全的场景。 | 适用于单线程或需要高性能的场景。 |
3. 继承关系
两者均继承自 AbstractStringBuilder
,提供相似的方法(如 append()
, insert()
, reverse()
等),但 StringBuffer
通过同步方法实现线程安全。
public final class StringBuffer extends AbstractStringBuilder
implements Serializable, CharSequence {
// 所有方法用 synchronized 修饰
}
public final class StringBuilder extends AbstractStringBuilder
implements Serializable, CharSequence {
// 无同步修饰
}
总结
特性 | StringBuilder | StringBuffer |
---|---|---|
线程安全 | ❌ 不支持 | ✅ 支持 |
性能 | ✅ 更高 | ❌ 较低(因同步开销) |
适用场景 | 单线程环境 | 多线程环境 |
选择建议:优先使用 StringBuilder
(单线程场景下性能更好),仅在需要线程安全时选择 StringBuffer
。
150. 逆波兰表达式求值
代码实现
class Solution {
public int evalRPN(String[] tokens) {
Deque<Integer> deque = new ArrayDeque<>(); // 要放整型
for(int i=0;i<tokens.length;i++){
String s = tokens[i];
if(s.equals("+")){
deque.push(deque.pop() + deque.pop());
}else if(s.equals("-")){
deque.push(- deque.pop() + deque.pop());
}else if(s.equals("*")){
deque.push(deque.pop() * deque.pop());
}else if(s.equals("/")){
int temp1 = deque.pop();
int temp2 = deque.pop();
deque.push(temp2 / temp1);
}else{
deque.push(Integer.valueOf(s));
}
}
return deque.pop();
}
}
注意语法
1. Integer.valueOf(s)
是 Java 中用于将字符串 s
转换为 Integer
对象的方法。它是 Integer
类的一个静态方法,常用于将字符串形式的数字转换为整数对象。
String s = "123";
Integer num = Integer.valueOf(s);
System.out.println(num); // 输出 123
2. 查看以下代码错误原因:
if(s == new String('+'))
错误原因:
new String('+')
试图通过char
类型参数创建字符串,但String
类没有这样的构造函数。Java 中可用的String
构造函数包括:
String(String original)
:接受另一个字符串。String(char[] value)
:接受字符数组。
将 char
转换为 String
:若一定要从 char
生成字符串,可以用以下方法:
// 方法 1: 使用 String.valueOf()
String operator = String.valueOf('+');
// 方法 2: 使用 Character.toString()
String operator = Character.toString('+');
// 方法 3: 通过字符数组构造
String operator = new String(new char[]{'+'});
3. 字符串的比较
如果只是想比较字符串 s
是否为 "+"
,直接使用字符串字面量即可:
if (s.equals("+"))
注意:字符串比较必须用 equals()
Java 中 ==
比较的是对象引用,而非内容。例如:
String s1 = new String("+");
String s2 = "+";
System.out.println(s1 == s2); // false(引用不同)
System.out.println(s1.equals(s2)); // true(内容相同)
4. 同时,一定要注意单双引号!!!
特性 | '+' | "+" |
类型 | char (基本类型) | String (对象) |
比较方式 | == (直接比较值) | equals() (比较内容) |
适用场景 | 单个字符操作(如 ASCII 计算) | 字符串处理(如文本解析、拼接) |
单引号包裹,表示单个字符 | 双引号包裹,表示字符串(可能包含多个字符) |
5. 注意除法逻辑:
deque.push(1 / deque.pop() * deque.pop()); // 会导致逻辑错误。
例如,对于 13
和 5
:
-
先弹出
5
,然后计算1 / 5
,结果是0
(因为1 / 5
是整数除法,结果为0
)。 -
再弹出
13
,计算0 * 13
,结果是0
。
239. 滑动窗口最大值
思路
我先对于暴力求解进行了两步优化:
1. 使用队列结构,更好的实现窗口滑动;
2. 对于被移除的元素不是最大值的情况,判断新加入的元素和原来最大值之间的max,从而减少遍历全部窗口内元素的次数。
该代码如下:
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
Queue<Integer> queue = new LinkedList<>();
int[] res = new int[nums.length-k+1];
for(int i = 0; i<k; i++){
queue.offer(nums[i]);
}
boolean maxIn = false;
for(int i = 0;i<=nums.length-k; i++){
if(maxIn && i<nums.length-k){
res[i] = max(res[i-1],nums[i+k-1]);
}else{
res[i] = findMax(queue);
}
if(queue.poll() != res[i]){
maxIn = true;
}else{
maxIn = false;
}
if(i<nums.length-k){
queue.offer(nums[i+k]);
}
}
return res;
}
public int findMax(Queue<Integer> queue){
int max = queue.peek();
for(int nums : queue){
if(nums > max){
max = nums;
}
}
return max;
}
public int max(int a, int b){
return (a > b) ? a : b;
}
}
但很遗憾的是,我的代码依旧超时了,通过了46/51个测试用例。
实际上,即使这样优化,我的代码的时间复杂度最高情况依旧是O(n * k)。
所以我们不得不学习一下先进的优化算法了!!!
实现一个最大值永远在出口处的单调队列: 也就是使用deque双端队列实现该算法!
- pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
- push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止
代码实现
注意:一定要判断队列是否非空!
(并且 这种情况一定是单调队列,否则去掉第一个值之后需要遍历才能找到max值)
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
Deque<Integer> deque = new ArrayDeque<>();
int[] res = new int[nums.length-k+1];
// 将初始序列读入
for(int i = 0; i<k; i++){
while (!deque.isEmpty() && deque.peekLast()<nums[i]){
deque.pollLast();
}
deque.offerLast(nums[i]);
}
res[0] = deque.peekFirst();
// 滑动窗口
for(int i = 1;i<=nums.length-k; i++){
// 移除元素逻辑
if (!deque.isEmpty() && nums[i-1] == deque.peekFirst()){
deque.pollFirst();
}
// 加入元素逻辑
while (!deque.isEmpty() && deque.peekLast()<nums[i+k-1]){
deque.pollLast();
}
deque.offerLast(nums[i+k-1]);
res[i] = deque.peekFirst();
}
return res;
}
}
347.前 K 个高频元素
优先级队列正式登场!大顶堆、小顶堆该怎么用?| LeetCode:347.前 K 个高频元素_哔哩哔哩_bilibili
思路
- 要统计元素出现频率
- 对频率排序
- 找出前K个高频元素
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
本题我们就要使用优先级队列来对部分频率进行排序。
为什么不用快排呢, 使用快排要将map转换为vector的结构,然后对整个数组进行排序, 而这种场景下,我们其实只需要维护k个有序的序列就可以了,所以使用优先级队列是最优的。
此时要思考一下,是使用小顶堆呢,还是大顶堆?
有的同学一想,题目要求前 K 个高频元素,那么果断用大顶堆啊。
那么问题来了,定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。
而且使用大顶堆就要把所有元素都进行排序(也可行),那能不能只排序k个元素呢?
所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。
代码实现
class Solution {
//解法1:基于大顶堆实现
public int[] topKFrequent1(int[] nums, int k) {
Map<Integer,Integer> map = new HashMap<>(); //key为数组元素值,val为对应出现次数
for (int num : nums) {
map.put(num, map.getOrDefault(num,0) + 1);
}
//在优先队列中存储二元组(num, cnt),cnt表示元素值num在数组中的出现次数
//出现次数按从队头到队尾的顺序是从大到小排,出现次数最多的在队头(相当于大顶堆)
PriorityQueue<int[]> pq = new PriorityQueue<>((pair1, pair2) -> pair2[1] - pair1[1]);
for (Map.Entry<Integer, Integer> entry : map.entrySet()) {//大顶堆需要对所有元素进行排序
pq.add(new int[]{entry.getKey(), entry.getValue()});
}
int[] ans = new int[k];
for (int i = 0; i < k; i++) { //依次从队头弹出k个,就是出现频率前k高的元素
ans[i] = pq.poll()[0];
}
return ans;
}
//解法2:基于小顶堆实现
public int[] topKFrequent2(int[] nums, int k) {
Map<Integer,Integer> map = new HashMap<>(); //key为数组元素值,val为对应出现次数
for (int num : nums) {
map.put(num, map.getOrDefault(num, 0) + 1);
}
//在优先队列中存储二元组(num, cnt),cnt表示元素值num在数组中的出现次数
//出现次数按从队头到队尾的顺序是从小到大排,出现次数最低的在队头(相当于小顶堆)
PriorityQueue<int[]> pq = new PriorityQueue<>((pair1, pair2) -> pair1[1] - pair2[1]);
for (Map.Entry<Integer, Integer> entry : map.entrySet()) { //小顶堆只需要维持k个元素有序
if (pq.size() < k) { //小顶堆元素个数小于k个时直接加
pq.add(new int[]{entry.getKey(), entry.getValue()});
} else {
if (entry.getValue() > pq.peek()[1]) { //当前元素出现次数大于小顶堆的根结点(这k个元素中出现次数最少的那个)
pq.poll(); //弹出队头(小顶堆的根结点),即把堆里出现次数最少的那个删除,留下的就是出现次数多的了
pq.add(new int[]{entry.getKey(), entry.getValue()});
}
}
}
int[] ans = new int[k];
for (int i = k - 1; i >= 0; i--) { //依次弹出小顶堆,先弹出的是堆的根,出现次数少,后面弹出的出现次数多
ans[i] = pq.poll()[0];
}
return ans;
}
}
注意语法
在 Java 中,大小顶堆(最大堆和最小堆)是一种基于完全二叉树的数据结构,常用于实现优先队列。堆的特点是:
- 最大堆:父节点的值大于或等于其子节点的值。
- 最小堆:父节点的值小于或等于其子节点的值。
Java 中的
PriorityQueue
类实现了堆的功能,默认是最小堆;但可以通过自定义比较器实现最大堆。
1. 堆的特性
- 堆的性质:
- 堆是一个完全二叉树。
- 最大堆的根节点是最大值,最小堆的根节点是最小值。
- 时间复杂度:
- 插入元素:
O(log n)
- 删除堆顶元素:
O(log n)
- 获取堆顶元素:
O(1)
- 插入元素:
2. Java 中的 PriorityQueue
PriorityQueue
是 Java 提供的优先队列实现,底层基于堆。默认是最小堆,但可以通过自定义比较器实现最大堆。
默认最小堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
自定义最大堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
// 或者
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
3. 自定义对象的堆
如果堆中存储的是自定义对象,需要实现 Comparable
接口或提供 Comparator
。
示例:自定义对象的最小堆
import java.util.PriorityQueue;
class Person implements Comparable<Person> {
String name;
int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int compareTo(Person other) {
return this.age - other.age; // 按年龄升序(最小堆)
}
@Override
public String toString() {
return name + " (" + age + ")";
}
}
public class CustomMinHeapExample {
public static void main(String[] args) {
// 创建一个最小堆
PriorityQueue<Person> minHeap = new PriorityQueue<>();
// 添加元素
minHeap.offer(new Person("Alice", 25));
minHeap.offer(new Person("Bob", 20));
minHeap.offer(new Person("Charlie", 30));
// 获取堆顶元素(年龄最小)
System.out.println("堆顶元素: " + minHeap.peek()); // 输出: Bob (20)
// 弹出堆顶元素
System.out.println("弹出元素: " + minHeap.poll()); // 输出: Bob (20)
// 剩余元素
System.out.println("剩余元素: " + minHeap); // 输出: [Alice (25), Charlie (30)]
}
}
示例:自定义对象的最大堆
import java.util.PriorityQueue;
import java.util.Comparator;
class Person {
String name;
int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return name + " (" + age + ")";
}
}
public class CustomMaxHeapExample {
public static void main(String[] args) {
// 创建一个最大堆
PriorityQueue<Person> maxHeap = new PriorityQueue<>((a, b) -> b.age - a.age);
// 添加元素
maxHeap.offer(new Person("Alice", 25));
maxHeap.offer(new Person("Bob", 20));
maxHeap.offer(new Person("Charlie", 30));
// 获取堆顶元素(年龄最大)
System.out.println("堆顶元素: " + maxHeap.peek()); // 输出: Charlie (30)
// 弹出堆顶元素
System.out.println("弹出元素: " + maxHeap.poll()); // 输出: Charlie (30)
// 剩余元素
System.out.println("剩余元素: " + maxHeap); // 输出: [Alice (25), Bob (20)]
}
}
4. 常见应用场景
- Top K 问题:使用最小堆或最大堆快速找到前 K 个最大或最小的元素。
- 排序:堆排序。
- 任务调度:优先处理优先级高的任务。
- Dijkstra 算法:优先队列用于选择最短路径。
5. 代码解析
PriorityQueue<int[]> pq = new PriorityQueue<>((pair1, pair2) -> pair1[1] - pair2[1]);
PriorityQueue<int[]>
:- 优先队列中存储的元素是
int[]
类型(整数数组)。 - 例如:
[1, 2]
、[3, 4]
等。
- 优先队列中存储的元素是
(pair1, pair2) -> pair1[1] - pair2[1]
:- 这是一个 Lambda 表达式,用于定义优先队列的排序规则。
pair1
和pair2
是队列中的两个元素(都是int[]
类型)。pair1[1]
和pair2[1]
分别表示这两个数组的第二个元素。pair1[1] - pair2[1]
表示根据第二个元素的值进行升序排序。
6. 哈希表算法:map.entrySet()
map.entrySet()
是 Java 中 Map
接口的一个方法,用于返回一个包含所有键值对(Map.Entry
)的集合(Set
)。每个 Map.Entry
对象表示一个键值对,可以通过它访问键和值。
(1)方法签名:
Set<Map.Entry<K, V>> entrySet();
- 返回值:一个
Set
集合,包含Map
中的所有键值对(Map.Entry
)。 - 泛型:
K
:键的类型。V
:值的类型。
(2)Map.Entry
接口
Map.Entry
是 Map
接口的内部接口,表示一个键值对。它提供了以下常用方法:
方法名 | 功能描述 |
---|---|
getKey() | 返回当前键值对的键。 |
getValue() | 返回当前键值对的值。 |
setValue(V value) | 设置当前键值对的值(注意:会修改原始 Map 中的值)。 |
(3)使用场景
- 遍历
Map
:通过entrySet()
可以方便地遍历Map
中的所有键值对。 - 修改值:通过
Map.Entry
的setValue()
方法可以直接修改Map
中的值。 - 获取键值对集合:将
Map
转换为Set
,便于进一步操作。
(4)代码示例:遍历 Map
import java.util.HashMap
import java.util.Map;
import java.util.Set;
public class EntrySetExample {
public static void main(String[] args) {
// 创建一个 Map
Map<String, Integer> map = new HashMap<>();
map.put("Alice", 25);
map.put("Bob", 30);
map.put("Charlie", 35);
// 获取 entrySet
Set<Map.Entry<String, Integer>> entries = map.entrySet();
// 遍历 entrySet
for (Map.Entry<String, Integer> entry : entries) {
System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
}
}