栈与队列系列
一、栈与队列理论基础
1.栈
(1)概述
如图所示,栈(stack)是一种 先进后出 的数据结构。
- 主要方法: 入栈(push) 和出栈 (pop)
- 允许push和pop的一端,称为栈顶 (Top),固定的一端称为栈底(Bottom)
(3)Stack<E>类和创建方式
- Java中提供了 Stack<E>类 ,有5个方法:
因为Stack继承自AbstractCollection,所以判空也可以用isEmpty()。
-
创建一个栈对象类似创建一个集合:
Stack<Integer> stack=new Stack<>();
(4)Deque实现栈
Deque是可以用作双向队列,也可以用作栈,同时应该优先与Stack的使用,当Deque作为栈时,方法正好等同于Stack的方法。
创建方法:Deque<E> deque=new ArrayDeque<>();
2.队列
(1)概述
如图所示,队列(queue)是一种 先进先出 的数据结构。
(2)实现原理
(3)Queue接口及其子接口和实现类
-
Queue<E>类:Queue是java中实现队列的接口,共有6个方法:
入队:add()、offer()
相同:队列未满时,入队并返回入队元素。
区别:在超出容量时,add()方法会对抛出异常,offer()返回false
出队:remove()、poll()
相同:队列不为空时,出队并返回出队元素。
区别:队列为空时,remove()会抛出异常,poll()返回false
获取队头元素(不删除):element()、peek()
相同:队列不为空时,返回队头元素,但是不删除。
区别:队列为空时,element()会抛出异常,peek()返回null。
-
Deque:是Queue的子接口,是一种双向队列,支持在两端插入和移除元素。此接口既支持有容量限制的双向队列,也支持没有固定大小限制的双向队列。
既然两端(First和Last)都能进行出入队操作,所以方法与Queue相比共有12个方法(一端6个)。同时,也有抛出异常和返回特殊值两种操作。
【注意】Deque抛出异常的检查方法是 getXxx() 而不是elementXxx()。 -
LinkedList:Queue的实现类有 LinkedList 和 PriorityQueue,其中最常用的实现类是 LinkedList。
LinkedList(双向链表) 是List接口的实现类,同时也是 Deque 接口的实现类,所以 LinkedList 既可以当做 双向队列 来使用,也可以当做 栈 来使用。
-
ArrayDeque:ArrayDeque 是 Deque接口 的实现类。
用作栈时,性能优于Stack;用于队列时,性能优于LinkedList。
两端都可以操作,不能存储null。
(4)优先级队列(PriorityQueue)
优先级队列是一个有序队列,可以用来实现大顶堆和小顶堆,从队头到队尾是由小到大排的就是小顶堆,由大到小排的就是大顶堆(即队头是堆顶)。
二、相关题目推荐
1. 232.用栈实现队列
-
思路:由于栈只能从一端进出,所以需要用双栈来模拟队列:一个入队栈,一个出队栈,入队往入队栈stackIn中入,出队从出队栈stackOut中出。
下面动画模拟以下队列的执行过程: 执行语句:
queue.push(1);
queue.push(2);
queue.pop();注意此时的输出栈的操作
queue.push(3);
queue.push(4);
queue.pop();
queue.pop();注意此时的输出栈的操作
queue.pop();
queue.empty();
要注意出队时,★需要先从出栈队出元素,出栈队空了再把入队栈中所有元素按原有顺序(即按入队栈的出栈顺序push进出队栈)移动到出队栈中,此时出队栈的出栈操作就等价于队列的出队。
class MyQueue { //用栈实现队列需要两个栈 Stack<Integer> stackIn; Stack<Integer> stackOut; //初始化队列——即用两个栈实现一个队列 public MyQueue() { //入队往入队栈stackIn中入,出队从出队栈stackOut中出 stackIn=new Stack<>(); stackOut=new Stack<>(); } public void push(int x) { stackIn.push(x); } //在出队栈中出栈实现出队列 public int pop() { //出队列时,需要先从出栈队出元素,出栈队空了再把入队栈中【所有元素】按原有顺序(即按入队栈的出栈顺序push进出队栈)【移动】到出队栈中 inToOut(); return stackOut.pop(); } public int peek() { //获取队列开头的元素等价于获取出队栈栈顶元素 inToOut(); return stackOut.peek(); } public boolean empty() { //入队栈和出队栈都为空,则队列为空 return stackIn.empty()&&stackOut.empty(); } //★ public void inToOut(){ //为保证inToOut时元素的相对顺序,出队栈为空时才能往里放元素 if(!stackOut.empty()){ return; } //,同时要将此时入队栈中所有元素都放到出队栈中 while(!stackIn.empty()){ stackOut.push(stackIn.pop()); } } }
2. 225. 用队列实现栈
-
思路一:一个双向队列(Deque)模拟栈,First端是栈顶,Last端是栈底。
class MyStack { //一个双向队列模拟栈,First端是栈顶,Last端是栈底 Deque<Integer> queue; public MyStack() { queue=new ArrayDeque<>(); } public void push(int x) { queue.offerFirst(x); } public int pop() { return queue.pollFirst(); } public int top() { return queue.getFirst(); } public boolean empty() { return queue.isEmpty(); } }
-
思路二:一个普通队列(Queue)模拟栈。每入栈一个元素x,就将队列中的元素重新排列,将x放到队头。这样方便出栈时直接出队以及获取栈顶元素直接获取队头元素。
class MyStack { Queue<Integer> queue; public MyStack() { queue=new LinkedList<>(); } public void push(int x) { //每入栈一个元素x,就将队列中元素重新排列,将x放到队头(x原来在队尾,这样方便出栈时直接出队以及获取栈顶元素直接获取队头元素) queue.offer(x); int len=queue.size(); while(len>1){ queue.offer(queue.poll()); len--; } } public int pop() { return queue.poll(); } public int top() { return queue.peek(); } public boolean empty() { return queue.isEmpty(); } }
3. 20. 有效的括号
-
思路:这是一道典型的对称匹配问题,像这一类问题可以用 栈 来解决。在遍历字符串时,遇到左括号,将与之匹配的右括号入栈;遇到右括号,如果与栈顶元素相同,则出栈。在写代码前我们首先要理清楚无效的几种情况:
情况①:左括号多了,此时一定会有一个多余的右括号入栈了,因此遍历完字符串栈里还剩这个多余的右括号;
情况②:右括号多了,此时不等遍历完字符串,栈内就空了。比如" ( { } ) ) ";
情况③:遍历时遇到右括号,但是与栈顶元素不同,这说明左右括号不匹配了。比如" ( { } ]",遇到' ] '时栈顶元素是遍历' ( '时入栈的' ) ',这说明 ( ] 不匹配。public boolean isValid(String s) { char[] str=s.toCharArray(); Stack<Character> stack=new Stack<>(); //遍历字符串 for(int i=0;i<str.length;i++){ //如果是左括号,就将对应右括号入栈 if(str[i]=='('){ stack.push(')'); }else if(str[i]=='{'){ stack.push('}'); }else if(str[i]=='['){ stack.push(']'); //情况③:如果遍历字符串时栈为空了,说明右括号多了,返回false //情况②:如果遍历得到的右括号与栈顶元素不同,说明左右括号不匹配,返回false }else if(stack.empty() || str[i]!=stack.peek()){ return false; }else{//如果是右括号,且左右匹配,就将栈顶元素出栈 stack.pop(); } } //情况①:遍历完字符串如果栈不为空,说明左括号多了,返回false;为空,返回true return stack.empty(); }
4. 1047. 删除字符串中的所有相邻重复项
-
思路一:删除相邻重复的字符,其实也是对称匹配问题。我们遍历字符数组,如果栈不空且当前字符与栈顶字符相同,则出栈;不同则进栈。遍历完后栈内剩的字符组成了结果字符串。
public String removeDuplicates(String s) { char[] arr=s.toCharArray(); Deque<Character> deque=new ArrayDeque<>(); //遍历字符数组,如果字符与栈顶字符相同,则出栈;不同则进栈。 for(int i=0;i<arr.length;i++){ if(!deque.isEmpty()&&arr[i]==deque.peek()){ //删除相邻重复项 deque.pop(); }else{ deque.push(arr[i]); } } StringBuilder sb=new StringBuilder(); //遍历完最后栈内元素就是删除相邻重复项后的字符串。 while(!deque.isEmpty()){ sb.append(deque.pop()); } return sb.reverse().toString(); }
★优化:使用结果串模拟栈。public String removeDuplicates(String s) { char[] arr=s.toCharArray(); //★用结果字符串模拟栈,字符串左边是栈底,右边是栈口,即字符串最后一个元素是栈顶元素 StringBuilder rslt=new StringBuilder(); //top一直指向栈顶元素(字符串最后一个元素),规定栈底下标从0开始(即字符串开头),因为栈初始为空,所top要初始化为-1表示栈(字符串)空 int top=-1; //遍历字符数组 for(int i=0;i<arr.length;i++){ //如果栈不为空且字符与栈顶字符rslt.charAt(top)相同,则出栈(即从rslt中删除top所指元素) if(top>=0&&arr[i]==rslt.charAt(top)){ rslt.deleteCharAt(top); top--; }else{//栈为空或字符与栈顶字符不同,则进栈。 rslt.append(arr[i]); top++; } } //遍历完后栈内剩的就是结果字符串。 return rslt.toString(); }
-
★思路二:双指针法(多画画模拟一下就懂了,光看代码很抽象- -)。遇到前后相同值的,slow指针后退一步,下次循环就可以直接被覆盖掉了,代替了删除重复项的操作,也就是说这一次循环的后退操作+下一次循环开始的赋值动作,实现了删除相邻重复项。
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); }
5. 150. 逆波兰表达式求值
-
思路:前面我们提到 栈很适合解决对称匹配问题。前面遇到的两道题“对称匹配”可以说很明显:一道是左右括号匹配,另一道是重复元素匹配。而这道题乍一看没有对称匹配,其实这道题是三个元素(两个数+一个运算符构成匹配)。然后实现的话根据题目提示的"遇到数字则入栈;遇到运算符则取出栈顶两个数字进行计算,并将结果压入栈中"做好了,注意取出栈顶两个数字的顺序,在做减法和除法时,要把取出的第二个数字放在运算符前面。
public int evalRPN(String[] tokens) { //按从左到右的顺序压入栈中,并且按照遇到运算符就把栈顶两个元素出栈,执行运算,得到的结果再入栈的原则来进行处理 Deque<Integer> deque=new ArrayDeque<>(); for(int i=0;i<tokens.length;i++){ if(tokens[i].equals("+")){ deque.push(deque.pop()+deque.pop()); }else if(tokens[i].equals("-")){ int n1=deque.pop(); int n2=deque.pop(); deque.push(n2-n1);//减法的特殊处理 }else if(tokens[i].equals("*")){ deque.push(deque.pop()*deque.pop()); }else if(tokens[i].equals("/")){ int n1=deque.pop(); int n2=deque.pop(); deque.push(n2/n1);//除法的特殊处理 }else{ deque.push(Integer.valueOf(tokens[i])); } } //遍历完字符串后栈顶元素(其实也只有栈顶一个元素)就是最终结果 return deque.pop(); }
6. ★239. 滑动窗口最大值
-
思路:解决这道题我们需要一个队列,随着窗口的后移一位,队列一进一出,每次移动之后,由队列告诉我们此时窗口的最大值是什么。那么这个队列应该有三个方法:每次窗口移动,调用pop方法移除窗口第一个元素,调用push方法将新元素加到窗口,调用front方法获取当前窗口最大值。
可惜的是没有现成的这种数据结构供我们使用,那么我们就需要自己来实现这种队列。
我们先来分析一下这个队列,因为要获取窗口的最大值,那么我们可以让这个队列始终保持一个单调性,让最大值一直在队头。维护这个队列的单调性不是将所有的元素进行一个排序操作,而是应该在一进一出时来维护单调性。这种队列我们称之为单调队列。那么该如何在一进一出时维护其单调性呢?
★在自己设计单调队列时,pop和push操作要保持如下规则:
· pop(value):★如果窗口要移除的元素value等于单调队列的队头元素,那么队列就弹出元素;否则不用任何操作(其实是在push时已经移除了)。
· push(value):如果要加入的元素value大于队尾入口的元素,那么就将队尾入口的元素弹出,直到value小于等于队尾入口元素为止。(即★向队列中加入元素x时,将x前面比x小的所有元素移除后再把x加进去)
保持以上规则(动画演示如下图),每次窗口移动的时候,只要调用front()就可以返回当前窗口的最大值。
我们再以题目示例为例演示一下单调队列的规则,输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3,动画如下:
那么我们该用什么数据结构来实现单调队列呢?
显然单调队列在队尾既要加入元素,又要移除元素,所以用双向队列Deque实现最合适。
class Solution { public int[] maxSlidingWindow(int[] nums, int k) { //用来存放每次滑动窗口的最大值 int[] rslt=new int[nums.length-k+1]; //初始化单调队列 MyQueue myQueue=new MyQueue(); //先将第一个窗口的数字都放入单调队列 for(int i=0;i<k;i++){ myQueue.push(nums[i]); } int rsltIdx=0; //把第一个窗口的最大值放入rslt中 rslt[rsltIdx++]=myQueue.getMax(); //遍历nums,后移窗口 for(int i=k;i<nums.length;i++){ //把窗口第一个数字移除 myQueue.poll(nums[i-k]); //把当前数字加入窗口 myQueue.push(nums[i]); //获取当前窗口最大值 rslt[rsltIdx++]=myQueue.getMax(); } return rslt; } } //使用Deque模拟单调队列MyQueue class MyQueue{ Deque<Integer> deque=new ArrayDeque<>(); //在单调队列中始终让当前窗口的最大值放在队头(这里以First作为队头) //★将窗口第一个数字x移除 public void poll(int x){ //★当要溢出的第一个数字x等于单调队列的队头数字时,说明要进行真正的移除操作 if(!deque.isEmpty() && x==deque.peekFirst()){ deque.pollFirst(); } //▲当x不等于队头数字时,在往队列中加数字时已经移除了(因为要始终维护队头元素最大) } //将当前遍历到的数字x加入队列(注意始终保持队列的单调性) public void push(int x){ //因为要始终维护队头元素最大,所以如果要加入队列的x比队尾数字大,就将队尾数字移除 //★这里不需要把移除了的队尾数字重新入队了,因为题目要求找窗口的最大值,被移除了说明一定不是最大值了,直接丢弃即可(其实这里做了上面的▲的操作) while(!deque.isEmpty() && x>deque.peekLast()){ deque.pollLast(); } //将x加入队列 deque.offerLast(x); } public int getMax(){ //队头数字始终是窗口的最大值 return deque.peekFirst(); } }
7. ★347. 前 K 个高频元素
-
思路一:小顶堆。这道题目主要涉及到三点:
①统计元素出现频率
②对频率排序
③找出前k个高频元素
统计元素出现的频率,可以用map来实现。 然后是对频率进行排序,这里我们可以使用一种优先级队列(PriorityQueue)。
对频率进行排序为什么不用快排呢?
——因为使用快排要将对整个map的值进行排序,而这道题只是让我们找到前k个高频元素,所以其实只需要维护k个有序的序列就可以了,所以使用优先级队列是最优的。
★使用大顶堆还是小顶堆?
——因为是找出前k个高频元素,所以我们很容易选择大顶堆,但实际上应该选择小顶堆。因为我们希望只维护k个元素,如果使用大顶堆要始终保持只有k个元素的话,需要在进来一个更大的元素时把最小的元素弹出去,但是此时最小的元素在优先级队列的队尾,要弹出去的话就把队头最大的元素弹出去了,这怎么能保留下前K个高频元素呢。因此我们选择使用小顶堆,要始终保持只有k个元素的话只需要在进来一个更大的元素时把队头的最小元素弹出去再把这个更大的元素加进去即可。
★如何保持优先级队列中只有k个元素?
——如果队列长度小于k,直接加进去;如果队列长度大于等于k,判断要加进去的元素x和队头元素(最小元素)的大小,x大时才把队头元素弹出,往里放x。
具体实现:
使用map对数组中的元素进行频率统计;定义一个优先级队列实现小顶堆,因为我们要根据频率(value)输出相应的数字(key),而map中没有根据value获取key的方法(因为value不是唯一的),所以我们定义一个二元数组(只有两个元素的数组)来存放数字和对应的频率,每次把二元数组放进优先级队列,所以优先级队列中的元素类型就是int[]。最后优先级队列中的k个元素,就是题目数组中前k个高频元素及其频率,然后将其输出到结果数组中即可。
public int[] topKFrequent(int[] nums, int k) { int[] rslt=new int[k]; //key-数组中的数字,value-数字出现的次数 Map<Integer,Integer> map=new HashMap<>(); for(int i:nums){ //★getOrDefault(Object key, V defaultValue):获取key对应的值,如果key不存在,则返回defaultValue //使用getOrDefault就不用判断key是否在map里了:如果i不在map里,返回默认给的0,然后+1,即map.put(i,1); map.put(i,map.getOrDefault(i,0)+1); } //使用优先队列实现小顶堆(小的靠近队头) //在优先队列中存储二元数组(key,value):key-数组中的数字,value-数字出现的次数 PriorityQueue<int[]> pq=new PriorityQueue<>((nums1,nums2)->nums1[1]-nums2[1]); for(Map.Entry<Integer,Integer> entry:map.entrySet()){ int key=entry.getKey(); int value=entry.getValue(); //因为小顶堆只需要维持k个元素有序,所以当堆内元素个数小于k时,直接加进去 if(pq.size()<k){ pq.add(new int[]{key,value}); }else{ //维持k个元素有序 //当前数字出现的次数大于小顶堆堆顶数字出现的次数 if(value>pq.peek()[1]){ //将堆顶二元数组弹出,将当前数字和次数加进来 pq.poll(); pq.add(new int[]{key,value}); } } } //依次弹出小顶堆 for(int i=0;i<k;i++){ rslt[i] = pq.poll()[0]; } return rslt; }
【注意】①注意map.getOrDefault方法的使用;
②优先级队列的定义方法和Compare()的使用;
③理解好二元数组是干嘛的,以及如何定义一个二元数组;
④Map.Entry<>的用法以及getKey/Value()方法使用。
-
思路二:基于大顶堆。时间复杂度上不如小顶堆快,因为使用大顶堆需要将所有元素进行排序。
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); } //出现次数按从队头到队尾的顺序是从大到小排,出现次数最多的在队头(相当于大顶堆) 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; }