借助Leetcode 热门题目学习C++系列——双指针&&“接雨水问题”
文章目录
问题描述
题目描述
给定 n
个非负整数表示每个宽度为 1
的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1
输入:
height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:
6
解释:
上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1]
表示的高度图,在这种情况下,可以接 6
个单位的雨水(蓝色部分表示雨水)。
图示:
示例 2
输入:
height = [4,2,0,3,2,5]
输出:
9
常见思路
一般情况下,可有以下两种常见思路解决该题。
方法一:动态规划
思路
对于下标 i
,下雨后水能到达的最大高度等于 下标 i
两边的最大高度的最小值,
下标 i
处能接的雨水量等于:
min
(
leftMax
[
i
]
,
rightMax
[
i
]
)
−
height
[
i
]
\text{min}(\text{leftMax}[i], \text{rightMax}[i]) - \text{height}[i]
min(leftMax[i],rightMax[i])−height[i]
朴素的做法:
对 height
中的每个元素,分别向左和向右扫描,记录左边和右边的最大高度,然后计算每个位置能接的雨水量。
- 时间复杂度:O(n²) (每个元素向左右扫描)
- 优化方向:使用动态规划,预处理左/右最大高度,避免重复扫描。
优化:动态规划
-
定义两个数组:
leftMax[i]
:表示i
及其左边位置中,height
的最大高度。rightMax[i]
:表示i
及其右边位置中,height
的最大高度。
-
预处理左/右最大高度:
leftMax[0] = height[0]
rightMax[n-1] = height[n-1]
- 状态转移:
leftMax[i] = max(leftMax[i-1], height[i])
rightMax[i] = max(rightMax[i+1], height[i])
-
计算雨水量:
- 对于
0 ≤ i < n
:
water [ i ] = min ( leftMax [ i ] , rightMax [ i ] ) − height [ i ] \text{water}[i] = \text{min}(\text{leftMax}[i], \text{rightMax}[i]) - \text{height}[i] water[i]=min(leftMax[i],rightMax[i])−height[i]
- 对于
复杂度分析
- 时间复杂度:O(n)(遍历
height
三次) - 空间复杂度:O(n)(存储
leftMax
和rightMax
)
方法二:单调栈
思路
- 维护一个 单调递减栈,栈内存储的是
height
数组的下标。 - 从栈底到栈顶对应的
height
元素是 单调递减 的。
步骤
-
遍历
height
数组,维护单调栈:- 如果当前
height[i]
大于 栈顶height[top]
,则说明形成了一个接水区域。 - 计算该区域的雨水:
water = ( right − left − 1 ) × ( min ( height [ l e f t ] , height [ i ] ) − height [ t o p ] ) \text{water} = (\text{right} - \text{left} - 1) \times (\text{min}(\text{height}[left], \text{height}[i]) - \text{height}[top]) water=(right−left−1)×(min(height[left],height[i])−height[top]) - 不断弹出栈顶,直到栈为空或者
height[i] <= height[top]
。 - 最后将
i
入栈。
- 如果当前
-
遍历结束后,水量累加得到最终结果。
复杂度分析
- 时间复杂度:O(n)(每个下标
i
只会入栈和出栈各一次) - 空间复杂度:O(n)(存储
height
下标的单调栈)
以上两种方法并不是本文重点,因此不展示代码实现。
本文重头戏——双指针
为什么说是重头戏?因为这种奇妙的思路使得解决这个问题仅仅需要常数量级的空间耗费!
思路
利用 leftMax
和 rightMax
,但用两个变量代替数组,优化空间复杂度到 O(1):
- 维护两个指针
left
和right
,分别从两端向中间移动。 - 维护两个变量
leftMax
和rightMax
,记录当前遍历过的最大高度。用每个位置的height
值进行可能的更新。
步骤
-
初始化:
left = 0
,right = n-1
leftMax = 0
,rightMax = 0
-
双指针遍历:
-
如果
height[left] < height[right]
:- 由于
leftMax < rightMax
,左指针left
处的水量为:
water [ l e f t ] = leftMax − height [ l e f t ] \text{water}[left] = \text{leftMax} - \text{height}[left] water[left]=leftMax−height[left] - 计算后
left++
。
- 由于
-
否则(
height[left] >= height[right]
):- 由于
rightMax ≤ leftMax
,右指针right
处的水量为:
water [ r i g h t ] = rightMax − height [ r i g h t ] \text{water}[right] = \text{rightMax} - \text{height}[right] water[right]=rightMax−height[right] - 计算后
right--
。
- 由于
-
-
当
left == right
,遍历结束,总水量累加得到最终结果。
会不会有点乱?
把抽象思路转换成题目具体信息,其实不难理解, leftMax < rightMax
其实就意味着,右边的真正的“水桶隔板”要高于左边的隔板,意味着左指针的位置是有能力存一定的水的,那具体能存多少水呢?就是
water
[
l
e
f
t
]
=
leftMax
−
height
[
l
e
f
t
]
\text{water}[left] = \text{leftMax} - \text{height}[left]
water[left]=leftMax−height[left]。
总结一下就是:左端此处能存水由左端最大值和右端最大值的比较结果决定,此处能存多少水则由此处高度和左端最大高度的差决定。非常巧妙。
复杂度分析
- 时间复杂度:O(n)(每个元素最多访问一次)
- 空间复杂度:O(1)(仅使用常数额外空间)
具体的代码实现:
class Solution {
public:
int trap(vector<int>& height) {
int left = 0, right = height.size() - 1;
int left_max = 0, right_max = 0;
int ans = 0;
while (left < right) {
if (height[left] < height[right]) {
if (height[left] >= left_max) left_max = height[left];
else ans += left_max - height[left];
++left;
} else {
if (height[right] >= right_max) right_max = height[right];
else ans += right_max - height[right];
--right;
}
}
return ans;
}
};
另一种想法
筑波在写这题的时候,想到的一开始其实是另一种办法——
计算面积。
什么意思,同样还是两个指针,从两端向中间移动,定义变量h = 1
,面积s = 0
;
- 当两个指针第一次遇到
height[left] = h
和height[right] = h
的时候,停下,记录这一层的面积:s += right - left + 1
; - h自增,左右指针重新回到两端。
- 重复1、2,直到
h
等于height
中的最大值时终止。
在执行以上算法之前,你需要先求出
height
所有元素的和以及最大值(复杂度为 O ( n ) O(n) O(n))。
这个思想是什么意思呢?就是想用计算出的面积,减去原数组的大小,就可以得到那些能存住水的方块面积(能存住水是由两个指针第一次遇到高度为h
时停下所决定的,在这两个指针中间的区域内,所有height
小于h
的地方都有机会存水(具体需要在做减法的时候得出)。
只可惜,这个方案虽然对绝大多数案例都可行,但是它的时间复杂度,在数组非常长的情况下,每更新一个h,两个指针都会重新遍历一遍数组,实际上将会消耗掉非常多的时间。
(这才是最绝望的死法)
所以这个巧妙的思路死在了最后一关上,不过仍然是一个优秀的思路。
class Solution {
public:
int trap(vector<int>& height) {
int left = 0;
int right = height.size() - 1;
int h = 1;
int s = 0;
int high = 0;
int l = height[left];
int r = height[right];
int max_height = height[left];
for(auto i = height.begin(); i != height.end(); ++i){
high += *i;
if(*i > max_height){
max_height = *i;
}
}
//cout << "max_height: " << max_height << endl;
while(h <= max_height){
//记住两端的位置
left = 0;
right = height.size() - 1;
l = height[left];
r = height[right];
// cout << left << " " << right << endl;
// cout << "l: " << l << " r: " << r << endl;
// cout << " h : " << h << endl;
while(l < h || r < h){
l = height[left];
r = height[right];
if(l < h){
//cout << "l != h" << endl;
left++;
}
if(r < h){
//cout << "r != h" << endl;
right--;
}
if(left >= right){
break;
}
}
// 计算当前的面积
s += (right - left + 1);
h++;
}
return s-high;
}
};
总的来说,双指针是一个相当好的工具,可以在不同遍历方向上记录数据,并且节省遍历时间,特别是当问题解决需要两个主要变量共同决定时。
方法对比总结
方法 | 时间复杂度 | 空间复杂度 | 适用情况 |
---|---|---|---|
暴力法 | O(n²) | O(1) | 适用于小规模数据 |
动态规划 | O(n) | O(n) | 适用于中等规模数据 |
单调栈 | O(n) | O(n) | 适用于需要维护局部信息的情况 |
双指针 | O(n) | O(1) | 最优解,适用于大规模数据 |
最近筑波还在尝试其他双指针题目,各有魅力,后续继续更新~欢迎大家一起讨论!
(精彩继续~)