学习某一个算法,不要总是去背诵这个东西怎么用,用的时候模版是什么,我要在什么地方套用它,这样我认为是学不会的。以下是我对单调栈的个人思考,并且对发明这种方法的人感到由衷的赞叹和佩服。至少,我是思考不到的。
场景
力扣739.每日温度
假如你和我一样不喜欢高温,那可能和我一样喜欢看天气预报,总是看看今天温度多高,还有没有比今天更高的温度。现在有一个温度的列表,就假如说是[73,74,75,71,69,72,76,73],我想知道对于上面每一天,下一个温度更高的地方在之后几天出现?
直观一点的话怎么看
很直观的想法就是——遍历一下后面看看嘛:
- 73的后面是74,找到了,是1
- 去看74,后面是75,是1
- 再看75,71、69、72跟75比较,都不是,直到找到76,与75比较,大于75,所以答案在后面第4天
- …
没问题吧?一点问题都没有,这个问题很好地解决掉了。
总有人想着优化
人和人写的代码都要被优化…Oh no,不要优化我……
那就想想吧,上面的代码时间复杂度几乎是
O
(
n
2
)
O(n²)
O(n2)。[35,34,33,32,31],如果是这种,那就是妥妥的平方级别。对于这道题,向后遍历是一定的,那就要考虑,遍历的方式是不是不够优秀?
看一下75那个地方的遍历,按照上面暴力算法,接下来的动作是这样的:
- 遍历了71、69、72,找到了75的下一个更高的温度是76,记录到结果中;
- 然后看71,71再次看69、72,好的,71的结果是2,69同理;
- 接着去看72,再次看76,找到72的结果是1
- …
如果我能省去再看的过程,就可以减轻大量的时间复杂度,而困难的地方就在于,我们要怎么省掉它。
单调栈
以上的暴力算法就是一遍遍的遍历。只是,遍历过程中的数字我们没有考虑重复利用的可能性,这是造成时间浪费的一部分原因。有时优化就是对中间结果或重要的中间量以某种形式保存,下一次根据这个值的意义直接以例如
O
(
1
)
O(1)
O(1)的复杂度直接读取,来降低时间复杂度。 比如说动态规划就是这种思路,算过的值下次就不要再搜索一遍了。接下来我们就考虑,中间的值有什么价值。
首先,73、74这两个数只能这样计算了,我遍历到74,实际上就已经知道73的结果了,到此,73这个数已经没有作用了。应该没有意见。那么74同样的道理。
然后再次考虑计算75的过程,这里才是重头戏。我们要的值在76的地方。中间会经过71、69、72。如果我们想优化,也是在这几个地方下功夫,毕竟前几个数的耗时还是
O
(
1
)
O(1)
O(1)。
盯着下面的数字,你是否有什么想法?
-------- 我是分割线 ---------
一些发现
如果你敏锐地注意到,过程中遍历的72虽然不是75的答案,但是已经可以为中间的71、69找到答案时,你对找到优化的方法已经很近了,这确实需要一些思考。
这时候我可能就要重新考虑每个值的意义了。通过重新思考每个值还能有什么意义,或许可以找到突破口。接下来可能有点跳跃,需要一点灵光乍现。当你遍历到72时,69是第一个没找到答案的温度,71是第二个,75是第三个。想到69是第一个没找到答案的值这一点很重要,因为接下来每一次赋值,都是给第一个没找到答案的值赋值。 之前的74和73,依然没有任何作用。而72,正是为第一个没有找到答案的温度找到了答案。有点想法了吗?我们可以继续沿着这条线想下去。 在为69找到答案时,69还有没有作用?已经没有了,左边不会有数字是以它作为答案,因为你已经遍历到了它的右边,已经证明它不是别人的答案。然后,71变为第一个没有找到答案的数字。 可能已经蒙了,我们简单总结一下,大家可以一条一条过,看看是否都认可:
- 为一个值(如75)寻找答案时,中间遍历过的值如果有多个,其中可能就有一部分可以找到答案,如72完全可以将71、69的答案找到,后面不用再为71、69遍历。
- 既然72可以为69和71找到答案,当这么想的时候,我们就不再是单单为每一个值遍历后面的数组。即为遍历到的值的前面的值找答案。
- 在遍历到72时,考虑前面的69是第一个没找到答案的值,71是第二个,依次类推。
- 72可以为第一个没找到答案的值找到答案。
- 71变成第一个没找到答案的值,72还可以为它找到答案。
- 75变成第一个没找到答案的值,72不符合条件。这时就必须继续遍历了。
数据结构
这个思考过程理解了的话,应该自然而然或者并不是很自然地想到栈了。毕竟很明显的后进先出,我们遍历过的元素,不符合条件的就存储起来,遇到可以找到元素的值就弹出。
至于为什么叫单调栈呢?原因是,栈内的值是
[
75
,
71
,
69
[75,71,69
[75,71,69,从栈顶到栈底是单调的,细想一下遍历的过程,一直在找比当前的值大的元素,如果不是则存到栈内,因此它的名字是单调栈。
时间复杂度
直接降为 O ( n ) O(n) O(n),其实最多遍历两次,一次向后搜索,一次根据栈内存储的值为结果集赋值。
单调栈
单调栈的作用,就是存储遍历过的值,通常用于去求按一个方向找到第一个比它大或比它小的值。仔细体会上面的过程,应该能够理解。实现的代码也很简单,就是简单地模拟上面的过程去压栈和弹栈。
每日温度的题解
为了很方便地找到元素,我们在单调栈中通常存入下标,再通过下标去比较当前的值。如果没有接触题目的可以先去简单读一下题力扣739.每日温度,再来尝试理解存储数组下标的妙用。当然,不同的场景可以灵活的应用,对于本文,了解了为什么使用栈即可。
每一次遍历时都去与栈顶的值比较,即是否能为第一个没找到答案的值找到答案。
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int[] ret = new int[temperatures.length];
if (temperatures.length <= 1) return ret;
// 单调栈,存放已经遍历的元素
Deque<Integer> stack = new LinkedList<Integer>();
stack.push(0);
for (int i = 1; i < temperatures.length; i++) {
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
ret[stack.peek()] = i - stack.pop();
}
stack.push(i);
}
return ret;
}
}