注意事项:
Java堆栈Stack类已经过时,Java官方推荐使用Deque替代Stack使用。Deque堆栈操作方法:push()、pop()、peek()。
Deque是一个双端队列接口,继承自Queue接口,Deque的实现类是LinkedList、ArrayDeque、LinkedBlockingDeque,其中LinkedList是最常用的。
Deque有三种用途:
- 普通队列(一端进另一端出):
Queue queue = new LinkedList()或Deque deque = new LinkedList() - 双端队列(两端都可进出)
Deque deque = new LinkedList() - 堆栈
Deque deque = new LinkedList()
Deque是一个线性collection,支持在两端插入和移除元素。名称 deque 是“double ended queue(双端队列)”的缩写,通常读为“deck”。大多数 Deque 实现对于它们能够包含的元素数没有固定限制,但此接口既支持有容量限制的双端队列,也支持没有固定大小限制的双端队列。
此接口定义在双端队列两端访问元素的方法。提供插入、移除和检查元素的方法。每种方法都存在两种形式:一种形式在操作失败时抛出异常,另一种形式返回一个特殊值(null 或 false,具体取决于操作)。插入操作的后一种形式是专为使用有容量限制的 Deque 实现设计的;在大多数实现中,插入操作不能失败。
下表总结了上述 12 种方法:
Deque接口扩展(继承)了 Queue 接口。在将双端队列用作队列时,将得到 FIFO(先进先出)行为。将元素添加到双端队列的末尾,从双端队列的开头移除元素。从 Queue 接口继承的方法完全等效于 Deque 方法,如下表所示:
双端队列也可用作 LIFO(后进先出)堆栈。应优先使用此接口而不是遗留 Stack 类。在将双端队列用作堆栈时,元素被推入双端队列的开头并从双端队列开头弹出。堆栈方法完全等效于 Deque 方法,如下表所示:
原文链接:https://blog.csdn.net/devnn/article/details/82716447
题目索引
设计栈
LeetCode 155. 最小栈(同剑指 Offer 30. 包含min函数的栈)
2023.06.01 一刷
思路:
设计栈结构,先进后出,但是又要可以O(1)时间检索到最小元素。
1.辅助栈解法:
常规的栈要找到最小元素,需要弹出所有元素,找最小值,如果想要O(1)时间内找到栈内最小元素,需要用一个结构存储好当前栈的最小元素,并且随着pop和push更新里面的值.
在每个元素 a 入栈时把当前栈的最小值 m 存储起来。在这之后无论何时,如果栈顶元素是 a,我们就可以直接返回存储的最小值 m。可以使用一个辅助栈,与元素栈同步插入与删除,用于存储与每个元素对应的最小值。
代码如下:
// 1.使用辅助栈 时间:O(1) 空间:O(n)
class MinStack {
Deque<Integer> stk;//普通栈,正常存元素
Deque<Integer> minStk;//记录当前普通栈对应位置下方的最小值
public MinStack() {
stk=new LinkedList<>();
minStk=new LinkedList<>();
// 先存储最大值,因为后续入栈的时候,栈顶元素和入栈元素取较小的入栈
minStk.push(Integer.MAX_VALUE);
}
public void push(int val) {
stk.push(val);
// 与普通栈同步入栈,如果栈顶元素更小就再将栈顶元素入栈,val更小就将val入栈
minStk.push(Math.min(minStk.peek(),val));
}
public void pop() {
stk.pop();
minStk.pop();
}
public int top() {
return stk.peek();
}
public int getMin() {
return minStk.peek();
}
}
2.不用辅助栈解法(来自【路漫远】)
栈中每个元素代表的是要压入元素与当前栈中最小值的差值
在弹出时如何维护min?
因为每次压入新的元素时,压入的都是【val】与【当前栈中最小值】的差值,故在弹出元素时,若弹出了当前最小值,因为栈中记录了当前元素与【之前】最小值的差值,故根据这个记录可以更新弹出元素后的最小值
- stack用来存储每个元素和min的差值,min存储最小值,每次出栈的时候通过差值与当前min计算要出栈的值 和 之前的min
- 如果差值diff大于等于0,说明要出栈的值大于等于当前min,那么要出栈的值在入栈的时候没有更新min,返回min+diff;
- 如果插值diff小于0,说明当前要出栈的值就是min(因为入栈的时候我们选择的就是min和入栈元素的最小值),同时,通过min-diff(其实相当于val-diff)计算出之前min
- 要注意的是diff可能会超出int范围,类似于 Integer.MAX_VALUE - 1 这种,所以diff要用long存
代码如下:
//2.不用辅助栈解法,时间:O(1) 空间:O(1)
class MinStack {
private long min;// 当前【已压入】栈中元素的最小值
Deque<Long> stk;// 记录每个元素与【未压入】该元素时栈中最小元素的差值
public MinStack() {
stk=new LinkedList<>();
}
public void push(int val) {
// 栈空,表示压入第一个元素,栈中最小值就是val,压入元素与最小元素差值为0
if (stk.isEmpty()) {
min = val;
stk.push(0L);
} else {//不为空,栈内已有元素,将val与min的差值压入
stk.push((long)val - min);//如果val是最小的数,这里int可能越界,所以用long来保存
min = Math.min((long)val, min);
//上面两个语句是不能颠倒的!一定是先压入,再更新,因为min一定是当前栈中的最小值(顺序倒了,用于计算压入栈的min可能改变)
}
}
public void pop() {
long diff = stk.pop();
// 若大于等于0,说明该元素diff=val-min>=0,val>=min,即当前弹出的元素不是最小值min,不用特殊处理,直接弹出即可
// 当弹出元素小于0时,说明弹出的元素val<min,弹出元素是当前栈中的最小值,要更新最小值
if(diff<0)min = (int) (min - diff);
}
public int top() {
Long diff = stk.peek();
if (diff >= 0) {
return (int) (min + diff);
} else {// 若当前栈顶小于0,说明最小值就是栈顶元素
return (int)min;
}
}
public int getMin() {
return (int)min;
}
}
设计队列
LeetCode 239. 滑动窗口最大值(单调队列)
2023.06.01 三刷
思路:
这题无法用简单的滑动窗口得出题目要求的结果,因为针对每一个长度为k的窗口,都需要记录当前该窗口中的最大值,随着窗口的滑动,之前的最大值可能会掉出这个窗口,导致需要重新在这个窗口内重新寻找最大值。所以需要一个数据结构来有序存储窗口内的元素。
可能会想到优先级队列(二叉堆),但是普通的优先级队列在这题里行不通,因为优先级队列出队只按照元素大小,无法根据元素先进先出的规则进行出队(滑动窗口内元素遵循先进先出),想要在这题里用优先级队列还需要一点特殊的处理(见解法二)。
所以,现在需要一种新的队列结构,既能够维护队列元素「先进先出」的时间顺序,又能够正确维护队列中所有元素的最值,这就是「单调队列」结构。
总结一下这个滑动窗口里单调队列需要实现的功能:
- void push(int num):
单调队列中存的是窗口内的元素,要保证队头元素总是当前队列中最大的,这就需要可以在队尾插入元素(offerLast())的时候,总是将队尾中小于要插入的num的元素删除(removeLast()),这样就可以保证队列从队头到队尾保持从大到小的状态。 - void pop(int num):
需要注意的是,为了保证逆序队列,在入队的时候已经删了一些元素(窗口所有元素并不需要都在队列中,队列只要留存有机会成为最大值的元素即可),因此在后续需要将窗口左边界元素弹出队列(removeLast())时,需要判断这个元素是不是队头元素(peek()),是的话才弹出,否则不操作(不操作是因为这个元素在之前已经弹出了)。 - int peek():
最后还需要一个函数用于返回队头元素(return deque.peek());
代码如下:
//1.单调队列--时间O(n),空间O(k)
class Solution {
//自己实现针对此题的单调队列
class Monotonic{
//用双端队列来实现单调队列(因为队头队尾元素都需要插入删除操作)
Deque<Integer> deque=new LinkedList<>();
int peek(){
return deque.peek();
}
void push(int num){
// 所有队尾小于val的都删除(注意队列非空,否则可能会报错)
while(!deque.isEmpty()&&deque.getLast()<num)deque.pollLast();
deque.addLast(num);
}
//队列中元素逆序,要删除的左边界元素可能在push的时候就已经被删除了
//队头元素一定这一批元素中最早进入的
//要弹出的左边界元素,如果等于队头元素,说明要删的就是队头
// 如果和队头不同,说明可能之前push的时候就已经被删除了,就不需要操作
void pop(int num){
// 注意需要队列非空
if(!deque.isEmpty()&&num==deque.peek())deque.pollFirst();
}
}
public int[] maxSlidingWindow(int[] nums, int k) {
int n=nums.length;
int[] res=new int[n-k+1];
Monotonic dq=new Monotonic();
//存入k个(可能实际不到k个,因为被后来的给删除了)
for(int i=0;i<k;i++)dq.push(nums[i]);
res[0]=dq.peek();
//遍历剩下的n-k个
for(int i=k;i<n;i++){
//要先把左边界弹出再push,如果左边界是上个窗口最大值,很大
//不先弹出去直接push,队头里留存的还是上一个窗口的最大值
dq.pop(nums[i-k]);
dq.push(nums[i]);//不能先push
res[i-k+1]=dq.peek();
}
return res;
}
}
解法2:优先级队列
将最大堆–二元组(num,index)排序规则设置为按num降序,num相同则按下标升序(但是优先队列的默认排序规则就是升序,所以num相同时下标会默认按升序来,不用特别写)
何时弹出二叉堆元素?
正常思维肯定是当堆元素个数超过k的时候弹出,但是这题当堆元素个数超过k,弹出的堆顶元素可能不是最左边窗口边界的元素,而可能是在窗口左边界之外的元素。用i遍历剩下的元素,i-k+1就是窗口左边界,只要看堆顶元素的下标是不是小于这时候的左边界,如果小于,说明堆顶元素不在窗口内,可以弹出,这样一直判断直到堆顶元素是窗口内的,就可以给res赋值。
代码如下:
//解法二-最大堆,时间O(nlogn),空间O(n)
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
//设置优先级队列排序规则
PriorityQueue<int[]> pq=new PriorityQueue<>(new Comparator<int[]>(){
// 重写排序规则,优先按nums[i]降序排列(最大堆),一样时按下标升序
public int compare(int[] o1,int[] o2){
return o1[0]!=o2[0] ? o2[0]-o1[0] : o2[1]-o1[1];
}
});
//初始化二叉堆(存入前k个元素),堆内元素形式为{}nums[i],i}
for(int i=0;i<k;i++){
pq.offer(new int[]{nums[i],i});
}
int n=nums.length;
int[] res=new int[n-k+1];//最后会有n-k+1个窗口
res[0]=pq.peek()[0];//先把最开始堆顶元素填入
//遍历剩下的数组元素
for(int i=k;i<n;i++){
// 一开始先向堆内添加元素,使窗口大小超过k
// 这次循环内res添加的堆顶元素一定要保证是以i为右边界,大小为k的窗口内的
pq.offer(new int[]{nums[i],i});
//当堆顶元素(最大的)不在当前窗口内,就弹出
// 保证后面res添加的堆顶元素一定是在窗口内的
while(pq.peek()[1]<=i-k){
pq.poll();
}//出while能保证当前堆顶元素一定在窗口内,就是最大值
res[i-k+1]=pq.peek()[0];
}
return res;
}
}
用栈解决问题
LeetCode 394. 字符串解码
2024.03.16 一刷
思路:
看到括号首先想到的就是双栈(操作数+操作码)模式,数字存放在数字栈,母串存放在字母串栈;
需要注意的是可能出现’[]'嵌套的情况,而且要保证输出字符串的顺序。
遍历字符串s,用一个res去存储当前’[]‘内的字母串,num存储当前遍历的数字大小
1.遇到字母,就拼接到res中;
2.遇到数字,就将其完整存入数字栈;
3.遇到’]‘,就说明遍历完了一层的’[]‘,
需要将’[]‘前的数字x从数字栈中弹出来,并且字母栈顶也要弹出,用于和’[]‘内拼接
并且将’[]‘中的res复制x遍,与字母栈栈顶元素拼接一起,再存入res中;
4.遇到’[‘,需要将之前拼好的数字入数字栈,并且res也需要入栈(这里是为了解决嵌套’[]',需要好好体会),
并且将res和num都置为初始状态(null,0).
代码如下:
class Solution {
int index=0;
public String decodeString(String s) {
int sLen = s.length();
int num=0;
Deque<Integer> numStk = new LinkedList<>();
Deque<String> letterStk = new LinkedList<>();
// res用于存储遍历过程中遇到的字母
StringBuilder res = new StringBuilder();
while(index<sLen){
char c = s.charAt(index);
if(c >= '0' && c<= '9'){
num = num *10 + (c-'0');
}else if(c=='['){
// '['之前一定是数字,需要先将数字压入数字栈,然后num置0为下一次计算做准备
numStk.push(num);
num = 0;
// 要把res存储下来的临时结果也存入字母栈
letterStk.push(res.toString());
res = new StringBuilder();
}else if(c == ']'){
// 遇到']'就要将最近的数字出栈,同时将最近的临时字符串出栈
int count = numStk.pop();
// 当前res存储的是'[]'内的字母串
// 字母栈顶元素是当前'[]'之前的字母串,要先拼接当前res的内容直到count次
StringBuilder tmp = new StringBuilder();
while(count-->0)tmp.append(new StringBuilder(res));
// 再拼接上'[]'前的字母片段,这样
res = new StringBuilder(letterStk.pop()+tmp.toString());
}else{
// 最后只剩下字母情况。直接拼接进res
res.append(c);
}
// 判断完需要将索引前进
++index;
}
// 最终res内的一定是顺序存储的结果
return res.toString();
}
}
单调栈
单调栈是指栈里的元素保持升序或者降序。
判别是否需要使用单调栈:通常是一维数组里面,需要寻找一个元素左边或者右边第一个比自己大或者小的元素的位置,则可以考虑使用单调栈;这样的时间复杂度一般为O(n)。
力扣 739. 每日温度(2022.9.16华为)
原题链接
2024.06.12 三刷
要找出第一次比第i天温度高的那一天,是在第i天的res[i]天后,也就是对应每一个temperatures[i],找到几天后出现比它高的温度,如果没有,那一位就是0;
这就是很明显的找出元素右边第一个比自己大的元素,是单调栈题目。
对于单调栈题目,必须要要先明确三点:
- 单调栈内存的是什么元素?
- 单调栈内元素的是递增还是递减(从栈顶到栈底)?
- 当前遍历到的元素和栈顶元素不同大小关系时进行什么操作?
这三点都知道了,代码逻辑也就明确了。
-
单调栈内存的是什么元素?
注意题目要求的是升温的天数(也就是下标的差值),而不是升温后的温度,因此栈中应该存储下标,而非温度。 -
单调栈内元素的是递增还是递减(从栈顶到栈底)?
-
- 如果是递减,那么想要保持递减关系,碰到比栈顶元素代表的温度更高的元素时,执行入栈操作,那就无法找出第一个比栈顶元素温度更高的那一天了;
-
- 如果是递增,那么想要保持递增关系,碰到比栈顶元素温度更高的元素时,栈顶元素要出栈,并且后面的栈顶元素若是还是小于这个元素,还要继续出栈,这就符合“找出第一个比前面遍历过的元素更大的元素”,因为当前遍历到的元素就是第一个比栈内元素都大的元素。
- 当前遍历到的元素和栈顶元素不同大小关系时进行什么操作?
因为找出符合题目要求的元素最重要的就是温度的比较,所以是用下标所代表的温度进行比较的:
-
- ①temperatures[i]<temperatures[stack.peek()] (当前元素小于栈顶元素)
说明不是第一个比栈顶元素指代的温度高的那一天的温度,入栈,栈内元素保持递增关系(自栈顶到栈底);即stack.push(i)
- ①temperatures[i]<temperatures[stack.peek()] (当前元素小于栈顶元素)
-
- ②temperatures[i]=temperatures[stack.peek()] (当前元素等于栈顶元素)
同理,入栈;即stack.push(i)
- ②temperatures[i]=temperatures[stack.peek()] (当前元素等于栈顶元素)
-
- ③temperatures[i]>temperatures[stack.peek()] (当前元素大于栈顶元素)
遇到了第一个比栈顶元素温度更高的温度,栈顶元素出栈,res数组记录“当前遍历到的这一天减去栈顶元素那一天(过了多少天)”,即res[stack.pop()]=i-stack.pop();
也就是:若当日温度大于栈顶温度,说明栈顶元素的升温日已经找到了,则将栈顶元素出栈,计算其与当日相差的天数即可。(但是要注意,新的栈顶元素指代温度如果还是小于当前元素指代温度,要继续出栈)
- ③temperatures[i]>temperatures[stack.peek()] (当前元素大于栈顶元素)
如果想看具体的每种情况的图解举例,可以移步力扣官解:链接在此
代码如下:
//单调栈
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int len=temperatures.length;
Deque<Integer> stack=new LinkedList<>();//Java建议使用Deque替换Stack
int[] res=new int[len];
stack.push(0);
for(int i=1;i<len;i++){
if(temperatures[i]<=temperatures[stack.peek()])
stack.push(i);
else{
while(!stack.isEmpty()&&temperatures[i]>temperatures[stack.peek()]){
res[stack.peek()]=i-stack.peek();
stack.pop();
}
stack.push(i);
}
}
return res;
}
}
关于为什么使用Deque而不使用Stack,理由在此:CSDN文章解释
补充:新发现了一篇文章对这个问题的讲解更为全面:Java 程序员,别用 Stack?!
代码其实可以更简洁一些,因为每种情况最后都需要push(i),只有当栈非空,且当日温度大于栈顶温度才会出栈记录天数,所以情况可以合在一起:
//单调栈--简洁
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int len=temperatures.length;
Deque<Integer> stack=new LinkedList<>();//Java建议使用Deque替换Stack
int[] res=new int[len];
for(int i=0;i<len;i++){
while(!stack.isEmpty()&&temperatures[i]>temperatures[stack.peek()]){
res[stack.peek()]=i-stack.peek();
stack.pop();
}
stack.push(i);
}
return res;
}
}
力扣 496. 下一个更大元素 I
这比起739. 每日温度,多了nums1数组,可以直接把nums2数组当成739里的temperatures数组:
在nums2数组里找到右边第一个更大的数,当发生nusm2[i]>nums2[stack.peek()]时(即当前元素比栈顶元素大时),本来正常操作是要让栈顶元素出栈,并且继续比较下一个栈顶元素和当前元素关系,直到当前元素符合入栈条件为止。
但是在这题里,我们需要找到的是nums1出现在nums2数组中的元素的第一个比自己大的元素的值,所以在这个操作里面,需要判断当前要出栈的栈顶元素,是不是在nums1里的元素。是的时候,就把res数组赋值;不是的时候,直接出栈就行了;
还需要解决一个问题,就是怎么快速判断栈顶元素在不在nums1里出现,可以用HashMap来存储nums1的元素值和下标,key-元素值,value-下标(因为hashMap.containsKey()里面的是value,stack.peek()中的元素是下标,需要判断这个下标–栈顶元素,对应的nums2元素是否在以nums1元素和下标为键值对建立的HashMap中:hashMap.containsKey(nums2[stack.peek()])
)。
在nums1里,才执行res的赋值操作,然后栈顶元素出栈,继续判断栈顶元素是否符合,重复此操作,直至不符合while循环条件,当前元素入栈;
代码如下:
//单调栈--简洁写法
class Solution {
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
HashMap<Integer,Integer> hashMap=new HashMap<>();//用于存
//单调栈,用于找出nums2中右边第一个比自己大的元素的位置
Deque<Integer> stack=new LinkedList<>();
int[] res=new int[nums1.length];
Arrays.fill(res,-1);//初始化res元素全为-1
//kew-value映射
for(int i=0;i<nums1.length;i++)hashMap.put(nums1[i],i);
for(int i=0;i<nums2.length;i++){
while(!stack.isEmpty()&&nums2[i]>nums2[stack.peek()]){
if(hashMap.containsKey(nums2[stack.peek()]))
res[hashMap.get(nums2[stack.peek()])]=nums2[i];
stack.pop();
}
stack.push(i);
}
return res;
}
}
力扣 503. 下一个更大元素 II
原题链接
这题是在739. 每日温度基础上,把数组改成了循环数组,即最后一个元素后面跟着的是数组第一个元素,这样循环往复,常规想法会把一个新的nums数组拼接在原来的nums数组后,但是我们可以从逻辑上模拟拼接后的数组:
代码如下:
//单调栈(时间O(n),空间O(n))
class Solution {
public int[] nextGreaterElements(int[] nums) {
int len=nums.length;
Deque<Integer> stack=new LinkedList<>();
int[] res=new int[len];
Arrays.fill(res,-1);
//模拟拼接数组(i<len*2)
for(int i=0;i<len*2;i++){
while(!stack.isEmpty()&&nums[i%len]>nums[stack.peek()]){
res[stack.peek()]=nums[i%len];
stack.pop();
}
stack.push(i%len);
}
return res;
}
}
力扣 42. 接雨水
原题链接
双指针法
求解方法来自代码随想录:
可以按列来累加雨水,假设当前位置为4,那么第4列的雨水,按列来看可以这样求:
分别找到列4左右两边的最高柱子,分别是height[2]=3和height[7]=3,列4的柱子高度height[4]=1;列4的雨水高度h=min(height[2],height[7])-height[4]=1;
即若想求取每个位置的雨水高度,找到该位置左右两边各自的最高柱子高度,选取其中较小的高度,再减去当前柱子高度,就是该位置雨水高度。
代码如下:
//双指针法(时间O(n^2),空间O(1))
class Solution {
public int trap(int[] height) {
int len=height.length;
int rainSum=0;
for(int i=0;i<len;i++){
//最左最右无雨水,跳过
if(i==0||i==len-1)continue;
//分别记录左、右边最高柱高度
int lHight=height[i],rHeight=height[i];
//向左走,找最高;向右走,找最高
for(int l=i-1;l>=0;l--)lHight=Math.max(lHight,height[l]);
for(int r=i+1;r<len;r++)rHeight=Math.max(rHeight,height[r]);
//计算当前位置所在列的雨水量
int h=Math.min(lHight,rHeight)-height[i];
if(h>0)rainSum+=h;//只有雨水高度大于0才累加
}
return rainSum;
}
}
动态规划
从前面的双指针法可以知道,我们只要知道了当前位置左右最高的柱子高度就可以求取当前位置的列雨水量,在双指针法里面是针对每个位置都去遍历求取左右最高柱高度,这样每个位置都需要时间O(n),其实可以节省这部分的时间,只要分别设置左右最高柱数组,记录每个位置左右边最高柱高度,这样在总时间复杂度O(n)即可求出宋雨水量,但是空间会上升到O(n);
代码如下:
//动态规划(时间O(n),空间O(n))
class Solution {
public int trap(int[] height) {
int len=height.length;
int[] maxLeft=new int[len];
int[] maxRight=new int[len];
//找每个位置左边最高柱高度
maxLeft[0]=height[0];
for(int i=1;i<len;i++)maxLeft[i]=Math.max(height[i],maxLeft[i-1]);
//找每个位置右边最高柱高度
maxRight[len-1]=height[len-1];
for(int i=len-2;i>=0;i--)maxRight[i]=Math.max(height[i],maxRight[i+1]);
int h,rainSum=0;
for(int i=1;i<len-1;i++){//计算每个位置雨水列高度
h=Math.min(maxLeft[i],maxRight[i])-height[i];
if(h>0)rainSum+=h;
}
return rainSum;
}
}
单调栈
单调栈的解析比较复杂和麻烦,不过代码随想录里的讲解深入浅出,很适合理解:
链接在此建议用这个理解。
代码如下:
//单调栈(时间O(n),空间O(n))
class Solution {
public int trap(int[] height) {
int len=height.length;
Deque<Integer> stack=new LinkedList<>();
int rainSum=0;
stack.push(0);
for(int i=1;i<len;i++){
if(height[i]<height[stack.peek()])
stack.push(i);
else if(height[i]==height[stack.peek()]){
//stack.pop();//可以不加,效果一样,便于理解
stack.push(i);
}else{
while(!stack.isEmpty()&&height[i]>height[stack.peek()]){
int mid=height[stack.pop()];
if(!stack.isEmpty()){
int w=i-stack.peek()-1;
int h=Math.min(height[stack.peek()],height[i])-mid;
rainSum+=h*w;
}
}
stack.push(i);
}
}
return rainSum;
}
}
可以写得简洁,不过为了方便理解,不太推荐这个写法:
//单调栈(时间O(n),空间O(n))--简洁写法
class Solution {
public int trap(int[] height) {
int len=height.length;
Deque<Integer> stack=new LinkedList<>();
int rainSum=0;
stack.push(0);
for(int i=1;i<len;i++){
while(!stack.isEmpty()&&height[i]>height[stack.peek()]){
int mid=height[stack.pop()];
if(!stack.isEmpty()){
int w=i-stack.peek()-1;
int h=Math.min(height[stack.peek()],height[i])-mid;
rainSum+=h*w;
}
}
stack.push(i);
}
return rainSum;
}
}
力扣 84. 柱状图中最大的矩形
为了解决这题,首先提出一种暴力解法,方便后面理解单调栈的解法。通常暴力方法可以枚举矩形的高或者宽:
①枚举宽:第一重for循环定位矩形宽度左边界,第二重for循环定位矩形宽度右边界,同时第二重循环还要找出宽度范围内的高度的最小值作为矩形的高度,不断更新矩形面积最大值:
//官方c++题解,暴力枚举宽度时间O(n^2)
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int n = heights.size();
int ans = 0;
// 枚举左边界
for (int left = 0; left < n; ++left) {
int minHeight = INT_MAX;
// 枚举右边界
for (int right = left; right < n; ++right) {
// 确定高度
minHeight = min(minHeight, heights[right]);
// 计算面积
ans = max(ans, (right - left + 1) * minHeight);
}
}
return ans;
}
};
②枚举高:这个方法是等下改进为单调栈解法的基础,一重for循环定位每个矩形的高, 然后在这重循环里面,以这个高度为中心,向数组两边扩散,找到左右两边的直到遇到高度小于 h 的柱子,就确定了矩形的左右边界。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int n = heights.size();
int ans = 0;
for (int mid = 0; mid < n; ++mid) {
// 枚举高
int height = heights[mid];
int left = mid, right = mid;
// 确定左右边界
while (left - 1 >= 0 && heights[left - 1] >= height) {
--left;
}
while (right + 1 < n && heights[right + 1] >= height) {
++right;
}
// 计算面积
ans = max(ans, (right - left + 1) * height);
}
return ans;
}
};
单调栈解法
提炼一下枚举高度的暴力解法:
- 枚举i号柱子的高度height[i]作为当前矩形的高h;
- 向两侧扩展,使扩展到的柱子高度不小于h,直到左右两端遇到小于高度h 的柱子为止。这也就是说:要找到左右两侧第一个小于高度h的柱子。找到之后,这左右两根柱子之间(不包括这两根柱子)的柱子宽度就是当前矩形的宽度。
看到“找到左右两侧第一个小于高度h的柱子”是不是很熟悉,这和单调栈的适用范围很像,一般“一维数组里面,需要寻找一个元素左边或者右边第一个比自己大或者小的元素的位置”就可以考虑使用单调栈,那么这题就可以用单调栈来试试。
同时这题也可以类比 42. 接雨水,42中是找每个柱子左右两侧第一个大于该柱子高度的柱子,而这题是找柱子左右两侧第一个小于该柱子高度的柱子。
在42中,找每个柱子左右两侧第一个大于该柱子高度的柱子,单调栈采用自顶向下从小到大的递增顺序,那么本题里面,找柱子左右两侧第一个小于该柱子高度的柱子,单调栈就应该采用自顶向下从大到小的递减顺序。
栈内存储的同42,也是数组下标i。
因为只有栈内保持从大到小的顺序,才能保证栈顶元素在遇到比自己小的高度时,可以弹出,并且此时弹出的栈顶元素i,就是作为当前矩形高度的h=height[i]的下标;
遍历到的小于栈顶元素的当前元素,就是第一个小于矩形高度h的柱子的下标right;
栈顶元素弹出后,下一个新的栈顶元素,就是当前高度height[i]左边第一个小于高度h的柱子的下标left(因为栈内从大到小排序);
所以矩形的宽度w=right-left-1;
这样就找到了矩形的宽和高,可以计算矩形面积了。
代码思路和42.接雨水差不多:
代码如下:
//单调栈解法(时间O(n),空间O(n))
class Solution {
public int largestRectangleArea(int[] heights) {
Deque<Integer> stack=new ArrayDeque<>();
int[] nums=new int[heights.length+2];//数组扩容,首尾加一个0
//将heights数组索引为0开始,复制到nums数组下标为1的位置,复制的长度为heights的长度
System.arraycopy(heights, 0, nums, 1, heights.length);
int area=0;
stack.push(0);
for(int i=1;i<nums.length;i++){
if(nums[i]>nums[stack.peek()]){
stack.push(i);
}else if(nums[i]==nums[stack.peek()]){
stack.pop();
stack.push(i);
}else{
while(nums[i]<nums[stack.peek()]){
int mid=stack.peek();
stack.pop();
int left=stack.peek();
int right=i;
int w=right-left-1;
area=Math.max(area,w*nums[mid]);
}
stack.push(i);
}
}
return area;
}
}
首尾加入两个0是起到“哨兵作用”:
有了这两个柱形:
- 左边的柱形(第 1 个柱形)由于它一定比输入数组里任何一个元素小,它肯定不会出栈,因此栈一定不会为空;
- 右边的柱形(第 2 个柱形)也正是因为它一定比输入数组里任何一个元素小,它会让所有输入数组里的元素出栈(第 1 个哨兵元素除外)。
动态规划解法
这题和42一样,也可以先动态规划预处理找到每个元素左右两边第一个小于它高度的元素:
//动态规划-预处理左右第一个小于的元素,时间O(n),空间O(n)
class Solution {
public int largestRectangleArea(int[] heights) {
int length = heights.length;
int[] minLeftIndex = new int [length];
int[] maxRigthIndex = new int [length];
// 记录左边第一个小于该柱子的下标
minLeftIndex[0] = -1 ;
for (int i = 1; i < length; i++) {
int t = i - 1;
// 这里不是用if,而是不断向右寻找的过程
while (t >= 0 && heights[t] >= heights[i]) t = minLeftIndex[t];
minLeftIndex[i] = t;
}
// 记录每个柱子 右边第一个小于该柱子的下标
maxRigthIndex[length - 1] = length;
for (int i = length - 2; i >= 0; i--) {
int t = i + 1;
while(t < length && heights[t] >= heights[i]) t = maxRigthIndex[t];
maxRigthIndex[i] = t;
}
// 求和
int result = 0;
for (int i = 0; i < length; i++) {
int sum = heights[i] * (maxRigthIndex[i] - minLeftIndex[i] - 1);
result = Math.max(sum, result);
}
return result;
}
}
力扣 901. 股票价格跨度
代码如下:
//官方单调栈,时间O(Q),Q是调用next操作的次数
class StockSpanner {
//两个栈同步入栈、出栈
Deque<Integer> prices;//价格栈
Deque<Integer> weights;//跨度栈
public StockSpanner() {
prices = new LinkedList<>(); //这题用链表作为底层实现效率更高
weights = new LinkedList<>(); //因为不断有栈顶的增删操作
}
public int next(int price) {
int w = 1; //跨度包含自身,初始为1
//只有当栈非空,并且当前价格大于等于栈顶价格
while (!prices.isEmpty() && prices.peek() <= price) {
prices.pop(); //把所有小于当前价格的栈顶价格出栈
w += weights.pop(); //再把栈内小于当前价格的那几个价格的跨度天数,累加到当前价格这一天
}
//栈空或者当前价格大于栈顶价格时,当前价格直接入栈
prices.push(price);
weights.push(w); //并且把这一天的累加天数跨度入栈
return w;
}
}
/**
* Your StockSpanner object will be instantiated and called as such:
* StockSpanner obj = new StockSpanner();
* int param_1 = obj.next(price);
*/
如果对官方题解的解释不是很明白,可以再参考这个题解,相比官方题解会更加清晰。
力扣 316. 去除重复字母
这题也是用到单调栈,不过需要在贪心的思想基础上使用单调栈。接下来就介绍一下这题的贪心思想:
贪心思想:
首先明确题目要求:
- ①去除重复的字符
- ②使字符串字典序最小(但是当该种字符只有一个的时候它必须留下)
- ③保留原来字符的相对顺序。
考虑一个字符串字典序什么时候最小?
当然是字典序越小的排越前面,整体的字典序就越小,换句话说,也就是保持升序的时候会更小,那么遍历字符串,如果后一个元素字典序小于当前元素的字典序(也就是出现逆序–s[i+1]<s[i]),就删除当前更大的字符s[i].
这样就可以使用单调栈,栈顶到栈底保持元素递减顺序,遍历字符串:当前元素若大于栈顶元素,入栈;当前元素若小于栈顶元素,栈顶元素就出栈;
但是这样只能保证题目要求③保留原来字符的相对顺序;虽然可以使字典序最小,但是就算该种字符只有一个的时候,后面如果碰到了更小的元素,也会被弹出栈顶删除,不满足要求②;当前元素和栈内已有元素出现重复之时,也不会被删除,不满足要求①;
为了解决这两个问题,可以增设两个数组:
- nums数组:下标nums[0…25]分别表示小写的字母a-z。先遍历一遍字符串,统计每种字符的数量,再遍历字符串的时候,每次遍历当前字符的时候,先把nums中该字符的数量-1,表示剩余的该种字符数量-1;要弹出栈顶元素之前,先判断一下栈顶字符在nums中的剩余数量,只有剩余数量大于等于1(说明栈外还有剩)时,才可以弹出栈顶元素。nums用于解决要求②;
- boolean型的isCharInStack数组:下标nums[0…25]分别表示小写的字母a-z。为true时表示栈内有该元素,为false时表示栈内没有该元素。这个数组用在遍历字符串的时候,如果当前元素在栈内已存在,直接跳过;在当前元素入栈后,对应值置true;出栈后,对应值置false;isCharInStack用于解决要求①;
代码如下:
//单调栈(时间O(n),空间O(m),n为字符串长度,m为字符串中字母种数)
class Solution {
public String removeDuplicateLetters(String s) {
int[] nums=new int[26];
//记录每种字母数量
for(int i=0;i<s.length();i++)++nums[s.charAt(i)-'a'];
//单调栈,最后剩余的元素就是去重后的字符串(自底到顶)
Deque<Character> stack=new ArrayDeque<>();
// 统计字符是否在栈内
boolean[] isCharInStack=new boolean[26];
//遍历字符串
for(int i=0;i<s.length();i++){
char cur=s.charAt(i);//cur表示当前字符
nums[cur-'a']--;//遍历到了,剩余字符串的这种字符数量就-1
//如果栈内已有这个字符,直接跳过(去重)
if(isCharInStack[cur-'a'])continue;
//当栈内非空,且当前字符小于栈顶,并且剩下没遍历的字符中还有和栈顶字符一样的
while(!stack.isEmpty()&&cur<stack.peek()&&nums[stack.peek()-'a']>=1){
//栈顶元素就可以出栈,并且设置该字符不在栈内
isCharInStack[stack.pop()-'a']=false;
}
//当前元素入栈,并设置该字符在栈内
stack.push(cur);
isCharInStack[cur-'a']=true;
}
StringBuilder sb=new StringBuilder();
while(!stack.isEmpty()){
//last是指栈底元素
sb.append(stack.removeLast());
}
return sb.toString();
}
}