3.12
栈与队列
队列是先进先出,栈是先进后出
栈 (Stack)
栈是一种后进先出(Last In First Out, LIFO)的数据结构,这意味着最后添加进栈的元素会被最先移除。栈通常用于处理具有嵌套结构的数据,例如计算机程序中的函数调用。
在 Java 中,Stack
类是一个继承自 Vector
的旧类,提供了栈的基本操作。然而,由于 Stack
类的设计并不理想(它允许进行索引访问等操作,这与栈的抽象概念相违背),建议使用 Deque
接口及其实现(如 ArrayDeque
)来作为栈的实现。
Deque<Integer> stack = new ArrayDeque<Integer>();
// 入栈
stack.push(1);
stack.push(2);
// 查看栈顶元素(不移除)
int top = stack.peek();
// 出栈
int popped = stack.pop();
队列 (Queue)
队列是一种先进先出(First In First Out, FIFO)的数据结构,这意味着最先添加进队列的元素会被最先移除。队列经常用于任务调度,处理需要按顺序处理的数据。
Java 提供了 Queue
接口来实现队列数据结构,其中包括多种实现,如 LinkedList
、PriorityQueue
等。Queue
接口定义了入队、出队等基本操作。
Queue<Integer> queue = new LinkedList<>();
// 入队
queue.offer(1);
queue.offer(2);
// 查看队首元素(不移除)
int head = queue.peek();
// 出队
int removed = queue.poll();
特别提及:双端队列 (Deque)
双端队列(Double Ended Queue, Deque)是一种允许我们从两端添加或移除元素的数据结构,结合了栈和队列的特性。Java 中的 Deque
接口及其实现(如 ArrayDeque
、LinkedList
)提供了双端队列的功能。
双端队列既可以作为严格的先进先出队列使用,也可以作为后进先出的栈使用,这使得双端队列非常灵活。
Deque<Integer> deque = new ArrayDeque<>();
// 作为队列使用
deque.offerLast(1); // 入队
int head = deque.peekFirst(); // 查看队首
int removed = deque.pollFirst(); // 出队
// 作为栈使用
deque.push(2); // 入栈
int top = deque.peek(); // 查看栈顶
int popped = deque.pop(); // 出栈
用栈实现队列
题目:
使用栈实现队列的下列操作:
push(x) -- 将一个元素放入队列的尾部。 pop() -- 从队列首部移除元素。 peek() -- 返回队列首部的元素。 empty() -- 返回队列是否为空。
使用栈来模式队列的行为,如果仅仅用一个栈,是一定不行的,所以需要两个栈一个输入栈,一个输出栈,这里要注意输入栈和输出栈的关系。
在push数据的时候,只要数据放进输入栈就好,但在pop的时候,操作就复杂一些,输出栈如果为空,就把进栈数据全部导入进来(注意是全部导入),再从出栈弹出数据,如果输出栈不为空,则直接从出栈弹出数据就可以了。最后如何判断队列为空呢?如果进栈和出栈都为空的话,说明模拟的队列为空了。
// 使用两个栈来模拟队列的行为
class MyQueue {
// stackIn 用于处理入队操作
Stack<Integer> stackIn;
// stackOut 用于处理出队和查看队首元素的操作
Stack<Integer> stackOut;
// 构造函数,初始化两个栈
public MyQueue() {
stackIn = new Stack<>();
stackOut = new Stack<>();
}
// 入队操作:将元素x压入stackIn
public void push(int x) {
stackIn.push(x);
}
// 出队操作:如果必要,首先将stackIn中的所有元素倒入stackOut以保证队列的先进先出顺序,
// 然后从stackOut中弹出顶部元素
public int pop() {
dumpStackIn(); // 确保stackOut中有元素
return stackOut.pop(); // 弹出并返回stackOut的顶部元素
}
// 获取队首元素操作:操作与pop类似,但不弹出元素,只返回顶部元素的值
public int peek() {
dumpStackIn(); // 确保stackOut中有元素
return stackOut.peek(); // 返回stackOut顶部元素的值,但不移除它
}
// 检查队列是否为空:如果两个栈都为空,则队列为空
public boolean empty() {
return stackIn.isEmpty() && stackOut.isEmpty();
}
// 将stackIn中的所有元素倒入stackOut中,但仅当stackOut为空时执行此操作。
// 这确保了之前在stackOut中的顺序(即队列顺序)被保持,
// 同时新的元素(在stackIn中)在stackOut被清空后才被移动过来。
private void dumpStackIn(){
if(!stackOut.isEmpty()) return; // 如果stackOut非空,则无需倒入元素
while(!stackIn.isEmpty()){ // 将stackIn中的元素逐个移动到stackOut,实现顺序反转
stackOut.push(stackIn.pop());
}
}
}
注意细节
-
两个栈,一个输入栈,一个输出栈,来模拟队列
-
在pop的时候,操作就复杂一些,输出栈如果为空,就把进栈数据全部导入进来
-
if (!stackOut.isEmpty()) return;
-
如果
stackOut
非空,说明之前已经有一些元素从stackIn
转移到了stackOut
,并且stackOut
的顶部元素就是队列的“前端”元素。此时,直接从stackOut
取(或移除)元素即可满足队列的操作要求。 -
如果
stackOut
为空,说明之前转移的元素都已经被处理完毕,或者根本就没有进行过转移。这种情况下,需要将stackIn
中的所有元素倒入stackOut
中。这样,最早进入stackIn
的元素就会位于stackOut
的顶部,满足队列的先进先出特性。
简而言之,
if (!stackOut.isEmpty()) return;
这行代码的含义是:如果stackOut
中已经有元素了,就不需要再从stackIn
中倒腾元素过来了。因为stackOut
中的元素已经是按照队列的出队顺序排列好的。这样做可以减少不必要的操作,提高效率,同时保证队列操作的正确性。 -
用队列实现栈
题目:
使用队列实现栈的下列操作:
push(x) -- 元素 x 入栈
pop() -- 移除栈顶元素
top() -- 获取栈顶元素
empty() -- 返回栈是否为空
队列模拟栈,其实一个队列就够了,那么我们先说一说两个队列来实现栈的思路。
队列是先进先出的规则,把一个队列中的数据导入另一个队列中,数据的顺序并没有变,并没有变成先进后出的顺序。
所以用栈实现队列, 和用队列实现栈的思路还是不一样的,这取决于这两个数据结构的性质。
但是依然还是要用两个队列来模拟栈,只不过没有输入和输出的关系,而是另一个队列完全用来备份的!
如下面动画所示,用两个队列que1和que2实现队列的功能,que2其实完全就是一个备份的作用,把que1最后面的元素以外的元素都备份到que2,然后弹出最后面的元素,再把其他元素从que2导回que1。
其实这道题目就是用一个队列就够了。一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时再去弹出元素就是栈的顺序了。
// 使用队列实现栈的自定义类
class MyStack {
// 声明一个队列来存储栈中的元素
Queue<Integer> queue;
// 构造函数,初始化队列
public MyStack() {
queue = new LinkedList<Integer>();
}
// 将一个元素推入栈顶
public void push(int x) {
// 先记录下当前队列(栈)的大小
int n = queue.size();
// 先将新元素加入队列
queue.offer(x);
// 然后将队列(栈)中已有的所有元素依次出队并重新入队
// 这样做是为了确保最新加入的元素位于队列的前端,模拟栈的后进先出特性
for(int i = 0; i < n ; i++){
queue.offer(queue.poll());
}
}
// 移除栈顶元素并返回该元素的值
public int pop() {
// 直接从队列中出队元素,由于之前的操作,队列的前端元素即为栈顶元素
return queue.poll();
}
// 获取栈顶元素的值
public int top() {
// 查看队列的前端元素,即栈顶元素,但不移除它
return queue.peek();
}
// 检查栈是否为空
public boolean empty() {
// 直接检查队列是否为空
return queue.isEmpty();
}
}
注意细节
-
栈顶是先出的元素:栈是一种后进先出(LIFO)的数据结构,这意味着最后加入栈的元素会第一个被移除。在栈的物理表示中(假设从上向下看),"栈顶"是指新加入元素的位置,而"栈底"是指最先加入元素的位置。因此,"栈顶"实际上是指栈的"末端",而不是字面意义上的顶部。
-
Queue
声明为成员变量第一个版本:
javaCopy codeQueue<Integer> queue; public MyStack() { queue = new LinkedList<Integer>(); }
在这个版本中,
queue
被声明为类的成员变量。这意味着它在整个类中都是可见的,包括该类的所有方法中。在构造器MyStack
中使用queue = new LinkedList<Integer>();
时,实际上是在初始化这个类级别的成员变量。这使得queue
可以在类的其他方法(如push
、pop
等)中被访问和使用。第二个版本:
javaCopy codepublic MyStack() { Queue<Integer> queue = new LinkedList<Integer>(); }
在这个版本中,
queue
被声明和初始化在构造器MyStack
的局部作用域内。这意味着一旦构造器的执行完成,局部变量queue
就会从内存中消失,其他方法(如push
、pop
)就无法访问queue
,因为它只在构造器的作用域内存在。因此,第一个版本是正确的做法,因为通常需要在类的多个方法中访问类的成员变量。将
queue
声明为类的成员变量,然后在构造器或任何其他方法中初始化,可以确保它在整个类的上下文中都是可访问的。而第二个版本中的queue
仅在构造器内部是可见的,这显然不符合我们使用这个队列来模拟栈的需求。
有效的括号
题目:
给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
注意空字符串可被认为是有效字符串。
第一种情况:已经遍历完了字符串,但是栈不为空,说明有相应的左括号没有右括号来匹配,所以return false
第二种情况:遍历字符串匹配的过程中,发现栈里没有要匹配的字符。所以return false
第三种情况:遍历字符串匹配的过程中,栈已经为空了,没有匹配的字符了,说明右括号没有找到对应的左括号return false
那么什么时候说明左括号和右括号全都匹配了呢,就是字符串遍历完之后,栈是空的,就说明全都匹配了。
但还有一些技巧,在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了,比左括号先入栈代码实现要简单的多了!
-
使用双端队列作为栈使用,用于存储待匹配的右括号
-
从左向右,遇到左括号,就添加对应的右括号
-
如果栈为空或者栈顶元素与当前字符不匹配,说明括号无法正确匹配,返回false
-
判断如果 右括号,判断右括号和栈顶peek是否相等,如果是对应右括号那就弹出该正确右括号pop
class Solution {
public boolean isValid(String s) {
// 使用双端队列作为栈使用,用于存储待匹配的右括号
Deque<Character> deque = new LinkedList<>();
char ch;
// 遍历输入字符串的每个字符
for (int i = 0; i < s.length(); i++) {
ch = s.charAt(i);
// 如果是左括号,将对应的右括号入栈
// 这样做是为了后面遇到右括号时,可以直接与栈顶元素进行比较
if (ch == '(') {
deque.push(')');
} else if (ch == '{') {
deque.push('}');
} else if (ch == '[') {
deque.push(']');
} else if (deque.isEmpty() || deque.peek() != ch) {
// 如果栈为空或者栈顶元素与当前字符不匹配
// 说明括号无法正确匹配,返回false
return false;
} else {
// 如果是右括号且与栈顶元素匹配,出栈栈顶元素
deque.pop();
}
}
// 遍历完所有字符后,检查栈是否为空
// 如果栈为空,说明所有括号都被正确匹配,返回true
// 如果栈不为空,说明有未匹配的括号,返回false
return deque.isEmpty();
}
}
注意细节
-
利用双端队列作为栈使用
Deque<Character> deque = new LinkedList<>();
push,peek,pop-
Deque<Character> deque = new LinkedList<>();
声明了一个双端队列(Deque),这里用LinkedList
类的实例来实现。双端队列是一种具有队列和栈性质的数据结构,允许从两端添加或移除元素。在 Java 中,Deque
接口支持插入、移除和检查元素的方法,既可以当作队列使用,也可以当作栈使用。当
Deque
作为栈使用时,主要利用以下几个方法模拟栈操作:-
push(E e):将元素
e
压入栈顶。在Deque
中对应于在队列的前端插入元素。 -
pop():移除并返回栈顶元素。在
Deque
中对应于移除队列的前端元素。 -
peek():返回栈顶元素但不移除它。在
Deque
中对应于查看队列的前端元素但不移除。
使用
Deque
作为栈的优点是,它不仅支持标准的栈操作,还提供了额外的方法和灵活性,比如从栈底(队列的另一端)添加或移除元素。 -
-
-
在遍历栈后,要判断是否为空(匹配完全)
-
deque.isEmpty();
的()
不要忘记 -
-
Deque<Character> deque = new LinkedList<>();
使用了所谓的"钻石操作符" (<>
)。自 Java 7 起,Java 加入了类型推断的特性,使得在实例化对象时不必重复声明泛型类型。编译器可以根据变量的声明类型推断出实例化对象的泛型类型。这种方式使代码更简洁,提高了可读性。 -
Deque<Character> deque = new LinkedList<Character>();
明确指定了LinkedList
的泛型类型为Character
。这种写法在 Java 7 之前是常规的做法,因为那时候 Java 还没有类型推断的特性。尽管这种写法在当前版本的 Java 中依然有效,但它增加了代码的冗余。
-
删除字符串中的所有相邻重复项
题目:
给出由小写字母组成的字符串
S
,重复项删除操作会选择两个相邻且相同的字母,并删除它们。在 S 上反复执行重复项删除操作,直到无法继续删除。
在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。
栈
class Solution {
public String removeDuplicates(String s) {
// 使用双端队列来存储最终结果中的字符,同时用于检测重复
Deque<Character> deque = new LinkedList<>();
Character ch;
// 遍历输入字符串的每个字符
for(int i = 0; i < s.length(); i++){
ch = s.charAt(i);
// 如果队列为空,或当前字符与队列顶部字符不相等,则将当前字符压入队列
if(deque.isEmpty() || ch != deque.peek()){
deque.push(ch);
}else{
// 如果当前字符与队列顶部字符相等(即发现了相邻的重复字符),
// 则从队列中移除顶部字符(消除重复)
deque.pop();
}
}
// 初始化一个空字符串,用于存储和返回最终结果
String str = "";
// 从队列中依次弹出字符,拼接成最终的字符串
// 注意,由于双端队列的pop操作是从队列的“前端”弹出元素,
// 为了保证字符串顺序正确,需要在每次弹出字符时将其添加到结果字符串的前面
while(!deque.isEmpty()){
str = deque.pop() + str;
}
// 返回处理后的字符串
return str;
}
}
双指针
想象一下,你在编辑一篇文章时,需要删除所有连续重复的字母,但你只能看到当前和前一个字母。这里的
fast
和slow
指针就像是你的两只手:fast
手指向当前正在检查的字母,而slow
手负责在纸上写下字母。开始时,两只手都在文章的起点。当你(
fast
手)发现一个新字母时,你就把它写下来(slow
手移动一位)。如果这个新字母和你刚写下的(slow
手前一个位置的字母)一样,你就意识到需要删除它们(所以你用橡皮擦抹去slow
手刚写的那个字母,然后slow
手回退一位)。如果不一样,你就继续写。fast
手始终在移动,检查下一个字母。当你检查完所有字母后,
slow
手的位置告诉你有效的(未重复)字母数目。这时,你只需从文章开头到slow
手的位置,就是你需要的、删除了所有连续重复字母的文章部分。最后,
return new String(ch, 0, slow);
这行代码就是把你纸上写下的、有效的文章部分转换成字符串。这里的ch
就是整篇文章的字符数组,0
表示从文章的第一个字符开始,slow
表示到哪里结束(即你slow
手最后的位置,也就是有效的字符长度)。
class Solution {
public String removeDuplicates(String s) {
// 将输入字符串转换成字符数组,便于直接修改字符
char[] ch = s.toCharArray();
// 初始化快慢指针
int fast = 0, slow = 0;
// 遍历字符数组
while(fast < s.length()){
// 使用快指针的字符覆盖慢指针的字符
// 这意味着,如果没有遇到重复字符,慢指针位置的字符会被更新为快指针位置的字符
ch[slow] = ch[fast];
// 如果慢指针不是在起始位置,并且当前慢指针位置的字符等于其前一个字符
// 说明遇到了重复字符,需要将慢指针回退一位,以消除重复
if(slow > 0 && ch[slow] == ch[slow - 1]){
slow--;
} else {
// 如果没有遇到重复字符,或者是字符串的起始位置,慢指针前进
slow++;
}
// 快指针始终前进
fast++;
}
// 由于慢指针记录的是去除重复字符后的新字符串长度,使用慢指针的当前位置作为新字符串的长度
// 从字符数组的起始位置到慢指针当前位置的子数组构造新字符串并返回
return new String(ch, 0, slow);
}
}
想象你是一名艺术家,手里拿着一串由珠宝组成的项链(这里的项链就是字符串
s
),你的任务是重新排列这串项链,去除所有相邻的重复珠宝,使得每种珠宝都只出现一次。这里的
char[] ch = s.toCharArray();
就像是你将项链上的珠宝一个个摘下来,放到桌子上,以便于操作。你有两只手:一只手(
fast
)用来检查下一个珠宝,另一只手(slow
)负责将选好的珠宝重新串回项链。开始时,两只手都在项链的开头。javaCopy codewhile(fast < s.length()){ ch[slow] = ch[fast]; if(slow > 0 && ch[slow] == ch[slow - 1]){ slow--; } else { slow++; } fast++; }这段循环的过程中,
fast
手逐个检查桌子上的珠宝。每当发现一个新珠宝,你都尝试将它串到slow
手持的项链上。
如果
slow
手上最后一个珠宝与fast
手中的这个珠宝一样(意味着相邻的重复珠宝),你就决定将slow
手上的这个珠宝取下,也就是slow--
,撤销上一步的操作。如果不一样,或者这是第一个珠宝,你就将它加入到
slow
手中的项链里,即slow++
。在整个过程中,
fast
手始终在向前移动,直到检查完桌子上的所有珠宝。最后,
return new String(ch, 0, slow);
这行代码就好比是你根据slow
手中重新串好的项链,创建了一个新的、没有相邻重复珠宝的项链。这里的0
到slow
表示的是项链的起始位置和当前的长度,因为在去除重复珠宝后,slow
手的位置就代表了新项链的实际长度。这样,你就得到了一个每种珠宝只出现一次的美丽项链。
注意细节
-
如果栈里面是
【a,c
,那么出来的字符串应该是str = deque.pop() + str;
,保证先出栈的在字符串的后面 -
栈的是
push peek pop
; -
双指针法也可以用
在双指针法中;一个slow用来保留有效值(不重复),不断接受fast中的来判断。如果重复,slow左移,然后接受下一个
-
如果当前
fast
指向的字符与slow
指针的前一个字符不重复,则该字符被认为是“有效的”,应该被保留。此时,slow
指针向右移动一位,以便为下一个“有效值”预留位置。 -
如果当前
fast
指向的字符与slow
指针的前一个字符重复,说明遇到了需要去除的相邻重复字符。此时,通过将slow
指针向左移动一位,实际上是“撤销”了之前认为有效的字符的保存操作,即放弃了slow
指针最近一次向右移动时保留的字符。接下来,slow
将处于一个新的位置,准备接受下一个“有效值”。
return new String(ch, 0, slow);
利用了 Java 中String
类的一个构造函数来创建并返回一个新的字符串。这个构造函数的原型是String(char[] value, int offset, int count)
,它的参数意义如下:-
char[] value
:字符数组,是新字符串的数据源。 -
int offset
:起始偏移量,指定从value
数据源的哪个位置开始取字符构成新字符串。 -
int count
:长度,指定从数据源取多少个字符来构成新字符串。
-
-
ArrayDeque<Character> deque = new ArrayDeque<>();
-
性能:
ArrayDeque
通常比LinkedList
有更好的性能。ArrayDeque
是基于动态数组实现的,因此在没有频繁的随机访问操作时,它比LinkedList
更高效。LinkedList
是基于链表实现的,每次插入或删除操作都需要额外的指针跳转。尽管两者作为栈(或队列)使用时,插入(push
/offer
)和删除(pop
/poll
)操作的时间复杂度都是 O(1),但ArrayDeque
的常数时间可能更小,因为它更好地利用了缓存,而链表的节点可能在内存中分布更散乱。 -
内存使用:
ArrayDeque
通常使用更少的内存。因为LinkedList
对于每个元素都有额外的内存开销(每个节点需要额外的指针空间来存储前后节点的引用),而ArrayDeque
使用连续的内存空间存储元素。 -
功能需求:在这个特定的去除字符串中重复字符的问题中,我们只需要栈的基本操作,即添加、移除和访问最后一个元素。
ArrayDeque
完全能够满足这些需求,并且提供了更好的性能。除非你需要LinkedList
特有的功能(如从列表中间插入或删除元素),否则没有理由选择它。
-
逆波兰表达式求值
题目:
给你一个字符串数组
tokens
,表示一个根据 逆波兰表示法 表示的算术表达式。请你计算该表达式。返回一个表示表达式值的整数。
注意:
有效的算符为
'+'
、'-'
、'*'
和'/'
。每个操作数(运算对象)都可以是一个整数或者另一个表达式。
两个整数之间的除法总是 向零截断 。
表达式中不含除零运算。
输入是一个根据逆波兰表示法表示的算术表达式。
答案及所有中间计算结果可以用 32 位 整数表示。
波兰表达式
如:我们平时写a+b,这是中缀表达式,写成后缀表达式就是:ab+
(a+b)*c-(a+b)/e的后缀表达式为:
(a+b)*c-(a+b)/e
→((a+b)*c)((a+b)/e)-
→((a+b)c*)((a+b)e/)-
→(ab+c*)(ab+e/)-
→ab+c*ab+e/-
表达式 | 描述 |
---|---|
前缀表达式 | 不含括号的的算数表达式,将运算符写在前面,操作数写在后面 |
中缀表达式 | 必须含括号,操作符处于操作数的中间 |
后缀表达式 | 不含括号,运算符放在两个运算对象的后面。 |
波兰表达式(前缀表达式)
波兰表达式是一种前缀表示法,意味着操作符位于操作数之前。例如,表达式
(3 + 4) * 5
在波兰表达式中表示为* + 3 4 5
。这里,加法操作(+ 3 4
)发生在乘法操作(*
)之前。计算波兰表达式通常从右向左进行,首先找到最右侧的操作符,然后对其直接后面的操作数应用这个操作。
逆波兰表达式(后缀表达式)
逆波兰表达式是一种后缀表示法,意味着操作符位于操作数之后。例如,表达式
(3 + 4) * 5
在逆波兰表达式中表示为3 4 + 5 *
。与波兰表达式相反,这种格式的计算通常从左向右进行。计算逆波兰表达式时,遍历表达式,遇到操作数就将其推入栈中,遇到操作符则从栈中弹出所需数量的操作数(对于二元操作符是两个,对于一元操作符是一个),执行操作,并将结果推回栈中。最后,栈顶的元素就是表达式的结果。
优点
无需括号:由于操作符的位置唯一确定了操作的顺序,因此这两种表达式不需要括号来指示优先级,这简化了计算过程。
易于计算机解析:尤其是逆波兰表达式,非常适合计算机处理和计算,这就是为什么它广泛用于编译器和计算器内部。
栈与递归之间在某种程度上是可以转换的
其实逆波兰表达式相当于是二叉树中的后序遍历。 大家可以把运算符作为中间节点,按照后序遍历的规则画出一个二叉树。
但我们没有必要从二叉树的角度去解决这个问题,只要知道逆波兰表达式是用后序遍历的方式把二叉树序列化了,就可以了。
后缀表达式对计算机来说是非常友好的,在1970年代和1980年代,惠普在其所有台式和手持式计算器中都使用了RPN(后缀表达式),直到2020年代仍在某些模型中使用了RPN。
写法1
class Solution {
public int evalRPN(String[] tokens) {
// 使用一个双端队列作为栈来存储整数
Deque<Integer> stack = new LinkedList<>();
// 遍历每个字符串元素
for (String s : tokens) {
// 如果元素是加号
if ("+".equals(s)) {
// 弹出栈顶的两个元素,将它们相加,并将结果压回栈中
stack.push(stack.pop() + stack.pop());
}
// 如果元素是减号
else if ("-".equals(s)) {
// 弹出栈顶的两个元素,注意减法的顺序,先弹出的是被减数
int subtractor = stack.pop();
int minuend = stack.pop();
stack.push(minuend - subtractor);
}
// 如果元素是乘号
else if ("*".equals(s)) {
// 弹出栈顶的两个元素,将它们相乘,并将结果压回栈中
stack.push(stack.pop() * stack.pop());
}
// 如果元素是除号
else if ("/".equals(s)) {
// 弹出栈顶的两个元素,注意除法的顺序,先弹出的是除数
int divisor = stack.pop();
int dividend = stack.pop();
// 进行除法运算,并将结果压回栈中
stack.push(dividend / divisor);
}
// 如果元素是数字
else {
// 将字符串转换为整数并压入栈中
stack.push(Integer.valueOf(s));
}
}
// 返回栈顶元素,即整个表达式的计算结果
return stack.pop();
}
}
写法2
class Solution {
public int evalRPN(String[] tokens) {
Deque<Integer> deque = new LinkedList<>();
for (int i = 0; i < tokens.length; i++) {
String token = tokens[i];
switch (token) {
case "+": {
int b = deque.pop(); // 先出栈的是第二个操作数
int a = deque.pop(); // 后出栈的是第一个操作数
deque.push(a + b);
break;
}
case "-": {
int b = deque.pop();
int a = deque.pop();
deque.push(a - b);
break;
}
case "*": {
int b = deque.pop();
int a = deque.pop();
deque.push(a * b);
break;
}
case "/": {
int b = deque.pop();
int a = deque.pop();
deque.push(a / b);
break;
}
default:
deque.push(Integer.parseInt(token)); // 将字符串转换为整数再压入栈中
break;
}
}
return deque.pop();
}
}
注意细节
-
注意先出栈的要放在运算符的后面
-
字符串比较要用
.equals()
-
字符串数组 :
public int evalRPN(String[] tokens) {
接受一个字符串数组
String[] tokens
作为参数,这个数组包含了组成逆波兰表达式的元素,这些元素既可以是整数也可以是运算符(+
,-
,*
,/
)。 -
for (String s : tokens)
:是一个增强型for
循环,用于遍历tokens
数组中的每个元素。 -
stack.push(Integer.valueOf(s));
Integer.valueOf(s)
方法是 Java 的一个包装类Integer
的静态方法,用于将字符串转换为对应的Integer
对象。由于stack
是Deque<Integer>
类型的,它可以存储Integer
类型的对象,因此需要进行这种转换。 -
switch (token) { case "+": {
滑动窗口最大值
题目:
给你一个整数数组
nums
,有一个大小为k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的k
个数字。滑动窗口每次只向右移动一位。返回 滑动窗口中的最大值 。
错误的方法:栈
思路:
-
设立一个栈,存储结果
-
每个窗口内,先将第一个值push进栈,然后后面的元素与peek比较,如果小于等于,则进行下一个;如果大于,则将pop,将新的push进去
-
遍历整个数组
这是错误的 class Solution { public int[] maxSlidingWindow(int[] nums, int k) { // 1. 设立一个栈,存储结果 Deque<Integer> deque = new LinkedList<>(); // 左右指针设立窗口 // 2. 遍历整个数组 for(int right = 0; right<nums.length;right++){ int left = right - k + 1; while (left<right && (right-left)<(k+1)){ // 3. 每个窗口内,先将第一个值push进栈,然后后面的元素与peek比较,如果小于等于,则进行下一个;如果大于,则将pop,将新的push进去 deque.push(nums[left]); left++; if(nums[left] > deque.peek()){ deque.pop(); deque.push(left); // 此时已经是left+1 } } } int[] res = new int[nums.length - k + 1]; for (int i = 0;i < nums.length - k + 1 ; i++){ res[nums.length - k + 1 - i] = deque.pop(); } return res; } } 这是错误的
错误的数据结构使用:
代码试图使用
Deque
作为栈使用(通过push
和pop
操作),这与问题的需求不符。在滑动窗口问题中,需要的是能够从两端进行操作的队列,以便于同时管理窗口的新元素(从一端加入)和移除窗口外的元素(从另一端移除),而不是仅仅作为栈(后进先出)使用。逻辑错误:
在处理每个窗口的最大值时,代码的逻辑不能正确地保证找到的是最大值。特别是,通过比较
nums[left] > deque.peek()
并随后的pop
和push
操作,这个逻辑不能保证队列的头部是当前窗口的最大值,因为它只比较了当前和下一个元素,没有与窗口中的所有其他元素进行比较。
遍历逻辑:在你的代码中,对于每个新元素,你尝试通过
while (left<right && (right-left)<(k+1)){...}
循环处理每个窗口内的所有元素。这个循环逻辑试图通过从左到右遍历窗口内的每个元素来更新队列,但实际上,它并没有有效地对每个窗口重新计算最大值,因为它错误地使用了left
和right
变量,且处理方式不能保证窗口的其余元素比较正确。队列使用方式:理想情况下,队列应该用来维护当前窗口内的元素索引,保持这些索引对应的值的降序排列。然而,你的实现中通过比较并直接推入值(或在某些情况下,错误地推入了
left
索引),这不符合双端队列维护窗口最大值索引的正确使用方法。窗口更新:正确的逻辑是,每次窗口向右移动时,需要从队列头部检查当前最大值是否离开了窗口范围,如果是,从队列中移除。然后从队尾添加新元素(如果新元素大于队尾元素,则需要先移除较小的队尾元素)。这样可以确保队列的头部始终是当前窗口的最大值。你的实现没有正确处理这种窗口更新逻辑。
索引处理不当:
在向结果数组
res
填充值时,使用的是deque.pop()
,这将会返回并移除队列的头部元素,这意味着它试图使用被移除的元素值作为结果,这通常不是你想要的,特别是当队列被用作索引存储时。结果数组填充错误:
结果数组填充的逻辑是逆向的,即
res[nums.length - k + 1 - i] = deque.pop();
。这不仅逻辑上不正确,因为它尝试在数组的末尾开始填充,而且它使用了错误的索引计算方式,可能导致ArrayIndexOutOfBoundsException
。不恰当的元素移动和比较:
代码片段尝试通过逐一比较来维护队列,但这种方法既不高效也不正确,因为它没有正确地管理窗口内元素的最大值。
正确的双端队列
双端队列
双端队列(Double-Ended Queue,缩写为Deque)是一种具有队列和栈性质的抽象数据类型。它允许在队列的两端进行插入(enqueue)和删除(dequeue)操作。这种灵活性使得双端队列在某些场景下比传统的队列(只允许在一端插入和另一端删除)或栈(只允许在顶端插入和删除)更为有效和适用。
特点
两端操作:在双端队列的前端和后端都可以进行添加元素和移除元素的操作。
先进先出与后进先出:双端队列结合了队列和栈的特性。从一端插入并从另一端删除时,它表现得像一个队列;从同一端插入和删除时,它表现得像一个栈。
无元素限制:双端队列没有固定的大小限制(除非特别实现),可以根据需要动态地扩展或缩减。
操作
addFirst(e):在双端队列的前端添加一个元素。
addLast(e):在双端队列的后端添加一个元素。
offerFirst(e):在双端队列的前端插入一个元素,返回是否成功。
offerLast(e):在双端队列的后端插入一个元素,返回是否成功。
removeFirst():移除并返回双端队列前端的元素。
removeLast():移除并返回双端队列后端的元素。
pollFirst():移除并返回双端队列前端的元素,如果队列为空,则返回
null
。pollLast():移除并返回双端队列后端的元素,如果队列为空,则返回
null
。getFirst():返回但不移除双端队列前端的元素。
getLast():返回但不移除双端队列后端的元素。
peekFirst():返回但不移除双端队列前端的元素;如果队列为空,则返回
null
。peekLast():返回但不移除双端队列后端的元素;如果队列为空,则返回
null
。
应用场景
滑动窗口问题:在需要维护窗口内部元素顺序的情况下,双端队列可以有效地添加新元素和移除旧元素,同时快速访问窗口内的最大或最小元素。
撤销操作:在支持撤销操作的编辑器中,双端队列可以用来存储命令的历史,从而允许用户向前或向后遍历历史命令。
页替换算法:在计算机操作系统的内存管理中,双端队列可用于实现某些页替换算法,如LRU(最近最少使用)算法,以维护最近使用的页面。
双端队列的这些特性和操作使其成为解决特定类型问题的强大工具。在Java中,java.util.Deque
接口及其实现类(如ArrayDeque
和LinkedList
)提供了双端队列的功能。
双端队列取窗口最大
-
遍历数组,滑动向后
-
队列保证 从前向后由大到小;且大值的索引不能超出窗口范围
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
// 使用ArrayDeque因为需要高效的从两端进行插入和删除操作
ArrayDeque<Integer> deque = new ArrayDeque<>();
int n = nums.length;
// 结果数组的大小等于滑动窗口能够滑过的次数,即n - k + 1
int[] res = new int[n - k + 1];
int idx = 0; // res数组的索引
// 遍历每一个元素,以便处理每个滑动窗口
for(int i = 0; i < n; i++) {
// 1.确保队列的头部元素索引在当前滑动窗口内
// 如果队列头部的索引小于i - k + 1,说明它已经不在滑动窗口的范围内了,需要被移除
while(!deque.isEmpty() && deque.peek() < i - k + 1){
deque.poll(); // 从队列头部移除不在窗口内的元素的索引
}
// 2.维护队列的单调递减性
// 如果当前元素大于队尾元素,就一直从队尾移除元素,直到当前元素小于等于队尾元素
// 这样做的目的是保持队列的单调递减,以便队列的头部始终是当前窗口的最大值
while(!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast(); // 从队列尾部移除小于当前元素的索引
}
// 将当前元素的索引加入到队列尾部
deque.offer(i);
// 当窗口的大小达到k时,开始将窗口的最大值(队列的头部元素)加入到结果数组中
if(i >= k - 1){
res[idx++] = nums[deque.peek()]; // 队列头部元素是当前窗口的最大值
}
}
return res; // 返回结果数组
}
}
注意细节
-
双端队列取窗口最大
-
遍历数组,滑动向后
-
队列保证 从前向后由大到小;且大值的索引不能超出窗口范围
-
-
使用栈难以完成这个问题
-
注意排序队列中用的是原数组的索引
-
注意
poll
peek
和pollLast
peekLast
前 K 个高频元素
题目:给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
-
要统计元素出现频率
-
对频率排序
-
找出前K个高频元素
首先统计元素出现的频率,这一类的问题可以使用map来进行统计。
然后是对频率进行排序,这里我们可以使用一种 容器适配器就是优先级队列。
大顶堆小顶堆
大顶堆(Max Heap)
在大顶堆中,任何一个父节点的值都大于或等于它的子节点的值。这意味着,堆的根节点(即堆顶元素)是所有元素中的最大值。因此,大顶堆常被用于实现优先队列,快速访问并移除最大元素。
性质:
A[parent(i)] >= A[i]
,对于所有的节点i
,除了根节点。用途:最大值查询,优先队列管理等。
操作:插入(
insert
)、删除最大元素(extractMax
)、查找最大元素(peek
或findMax
)。小顶堆(Min Heap)
与大顶堆相反,在小顶堆中,任何一个父节点的值都小于或等于它的子节点的值。这意味着,堆的根节点是所有元素中的最小值。小顶堆使得访问和移除最小元素变得非常快速。
性质:
A[parent(i)] <= A[i]
,对于所有的节点i
,除了根节点。用途:最小值查询,实现优先队列等。
操作:插入(
insert
)、删除最小元素(extractMin
)、查找最小元素(peek
或findMin
)。实现和应用
二叉堆通常通过数组实现。给定一个节点的索引
i
(假设索引从0
开始):
父节点的索引为
(i - 1) / 2
。左子节点的索引为
2 * i + 1
。右子节点的索引为
2 * i + 2
。在算法设计中,大顶堆和小顶堆有广泛的应用,如堆排序(Heap Sort)、优先队列(Priority Queue)、Dijkstra的最短路径算法中的优先队列实现、Huffman编码树的构造等。它们提供了一种高效的方式来处理需要频繁查询最大或最小元素的场景。
优先级队列
一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。
而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?
缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。
什么是堆呢?
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。
大顶堆构建排序
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()}); }
PriorityQueue<int[]> pq = new PriorityQueue<>((pair1, pair2) -> pair2[1] - pair1[1]);
这里创建了一个PriorityQueue
,其中存储的元素是int[]
数组,每个数组有两个元素,可以认为是一对值(pair),比如可以表示一个映射的键和值。构造器中提供的Lambda表达式(pair1, pair2) -> pair2[1] - pair1[1]
定义了一个自定义的比较器,用来决定队列中元素的排序方式。
-
比较器的工作方式:这个比较器通过比较两个元素(数组)的第二个值(
pair1[1]
和pair2[1]
)来决定它们的排序顺序。具体来说,由于是pair2[1] - pair1[1]
,如果pair2
的第二个元素大于pair1
的第二个元素,则返回正数,意味着pair2
应该排在pair1
之前,这实际上创建了一个大顶堆(最大元素在队头)。
for(Map.Entry<Integer,Integer> entry: map.entrySet()) { pq.add(new int[]{entry.getKey(), entry.getValue()}); }
这段代码遍历了一个映射(Map
),其每个条目(Entry
)包含一个键(key
)和一个值(value
)。对于映射中的每一个条目,它创建了一个包含两个元素的数组,第一个元素是键,第二个元素是值,并将这个数组添加到优先队列pq
中。由于优先队列pq
使用了上面定义的比较器,这保证了队列中的元素根据它们的值(数组的第二个元素)以降序排列,从而实现了一个大顶堆。
在这个大顶堆中,任何时候访问或移除队列头部的元素(通过peek
、poll
等操作),你都会得到当前所有元素中值最大的那个。这种结构特别适合于需要快速访问最大元素的场景,比如在一些算法问题中找到最大的N个元素等。
小顶堆构建排序
PriorityQueue<intp[]> minHeap = new PriorityQueue<>((a,b) -> a[1] - b[1])
在Java中,
PriorityQueue
是默认实现的最小堆结构,即队列头部(或堆顶)是按照自然顺序或自定义比较器(Comparator)中定义的顺序中最小的元素。当你使用PriorityQueue
并提供一个比较器时,这个比较器定义了元素间的优先级。对于这个比较器
(a, b) -> a[1] - b[1]
,它是一个Lambda表达式,用于定义两个数组a
和b
之间比较的方式。这里,数组的第二个元素(即a[1]
和b[1]
)用于比较它们的大小。当a[1] - b[1]
返回负数时,意味着a[1]
小于b[1]
,根据PriorityQueue
的工作原理,此时a
会被认为优先级更高(即更小),因此会被放在堆的更靠前的位置。详细来说:
如果
a[1] < b[1]
,那么a[1] - b[1]
的结果是负数,这意味着a
会排在b
之前。如果
a[1] == b[1]
,那么a[1] - b[1]
的结果是0
,这意味着a
和b
的优先级相同。如果
a[1] > b[1]
,那么a[1] - b[1]
的结果是正数,这意味着b
会排在a
之前。因此,这个比较器确保了
PriorityQueue
中的元素是根据数组的第二个元素的大小从小到大排列的。队列头部(或堆顶)是所有元素中第二个元素最小的,符合最小堆的性质。这就是为什么说PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> a[1] - b[1]);
实现了一个元素按照数组第二个值从小到大排列的优先级队列
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 1. 统计每个元素出现的频率
Map<Integer, Integer> map = new HashMap<>();
for(int num: nums){
map.put(num, map.getOrDefault(num, 0) + 1);
}
// 2. 使用优先队列(小顶堆)按元素出现的频率排序
PriorityQueue<int[]> minHeap = new PriorityQueue<>((a,b) -> a[1] - b[1]);
// 3. 遍历频率统计结果
for(Map.Entry<Integer,Integer> entry:map.entrySet()){
int num = entry.getKey(); // 数组元素
int count = entry.getValue(); // 元素出现次数(频率)
// 如果堆的大小小于k,则直接将当前元素(及其频率)添加到堆中
if(minHeap.size()<k){
minHeap.offer(new int[]{num,count});
} else if (minHeap.peek()[1] < count) {
// 如果堆已满,且当前元素的频率大于堆顶元素的频率
// 则移除堆顶元素(频率最小的元素),并将当前元素加入堆中
minHeap.poll(); // 移除堆顶元素
minHeap.offer(new int[]{num,count}); // 加入当前元素
}
}
// 4. 从堆中取出所有元素并构建结果数组
// 此时堆中保存的是频率最高的k个元素
int[] topK = new int[k];
for(int i = 0; i < k; i++){
topK[i] = minHeap.poll()[0]; // 提取元素(堆顶元素是频率最低的,但整个堆表示频率最高的k个元素)
}
return topK; // 返回结果
}
}
注意细节
-
如何保证是按顺序拍的前k个呢?因为你添加到队列中,不是就只与队列末尾比较了吗?
优先队列并不是简单地将新元素与队列末尾的元素进行比较,而是根据提供的比较器(Comparator),在内部维护一个堆结构来自动调整元素的位置,确保队列(堆)的顺序性。在这个例子中,比较器是基于元素频率的,所以优先队列确保了频率最小的元素总是位于堆的根部(即优先队列的头部)。
当新元素被添加到优先队列中时,优先队列会根据比较器的逻辑,通过堆的上浮(上滤)或下沉(下滤)操作,找到新元素的合适位置,以维护堆的性质。这意味着整个堆结构会自动调整,以确保顶部(队头)始终是频率最小的元素。
如何确保是前k个最频繁的元素?
这个解决方案之所以有效,关键在于两个操作:
-
当堆的大小小于k时直接添加元素:这保证了堆中始终至少有k个元素(直到遍历完所有元素)。因为堆的大小是限制为k,所以当堆满了之后,我们只需要比较新元素是否应该加入堆来代替堆顶元素。
-
当堆已满(即堆的大小达到k)时,检查当前元素的频率是否大于堆顶元素的频率:
-
如果是,则移除堆顶元素(频率最小的元素),并将新元素加入堆中。这一步是关键,因为它确保了堆中始终保存频率最高的k个元素。堆顶元素的移除和新元素的添加都会触发堆的自我调整,以维持其性质。
-
如果否,则不需要将当前元素加入堆,因为它的频率没有足够高,无法成为频率最高的k个元素之一。
-
通过上述逻辑,优先队列(小顶堆)在整个过程中维护了频率最高的k个元素。当遍历完成后,堆中的元素即为频率最高的k个元素,且堆顶元素是这些元素中频率最小的。最后,通过依次取出这些元素构建结果数组,我们得到的是按频率从小到大的前k个最频繁的元素。如果需要按频率的降序排列,可以在取出元素时进行逆序处理或使用大顶堆的逻辑进行相应调整。
-
-
小顶堆构建优先级队列,直接小顶堆里面填充频率值,然后比小顶堆里,数值最小的如果不大于接下来的值,就移除更换
栈与队列阶段小结
-
栈;队列,双端队列;构建单调队列;优先级队列(一个披着队列外衣的堆);构建小(大)顶堆
-
栈里面的元素在内存中是连续分布的么?
这个问题有两个陷阱:
-
陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中不一定是连续分布的。
-
陷阱2:缺省情况下,默认底层容器是deque,那么deque在内存中的数据分布是什么样的呢? 答案是:不连续的
-
-
一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时在去弹出元素就是栈的顺序了。
-
递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
-
队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
-
不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。
设计单调队列的时候,pop,和push操作要保持如下规则:
-
pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
-
push(value):如果push的元素value大于入口元素的数值,那么就将队列出口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。
单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。
-