algorithm
算法难度高,所以只有早上能够进入心流通道,其他时间不适合。
开发相关可以放到其他时间。
hot100
day1
哈希表-两数之和
参考题解:思路图解 programmercarl.com、代码实现 leetcode.cn
重点:Map,key 为数组值,value 为数组索引。
思路:
哈希表-字母异位词分组
思路:排序,哈希表
代码:
代码里有很多不知道的用法。如 Map.getOrDefault
和 ArrayList<List<String>>(map.values())
。
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String str : strs) {
// 排序
char[] charArray = str.toCharArray();
Arrays.sort(charArray);
String sortedString = Arrays.toString(charArray);
// 哈希表
List<String> list = map.getOrDefault(sortedString, new ArrayList<String>());
list.add(str);
map.put(sortedString, list);
}
return new ArrayList<List<String>>(map.values());
}
}
哈希表-最长连续序列
参考题解:代码实现 leetcode.cn
重点:
HashSet
,既能去重,底层实现还内置了HashMap
,查询效率为O(1)
。
思路:
双指针-移动零
参考题解 leetcode.cn(图解清晰易懂)
思路:
双指针-盛最多水的容器
参考题解:思路讲解 bilibili.com、优雅的代码实现 leetcode.cn
重点:
- 左右指针相向移动,每次舍弃二者中更短的水槽版。
- 面积公式:\(S(left, right) = Math.min(h[left], h[right]) * (right - left)\)
代码:
class Solution {
public int maxArea(int[] height) {
int i = 0, j = height.length - 1, res = 0;
while(i < j) {
res = height[i] < height[j] ?
Math.max(res, (j - i) * height[i++]):
Math.max(res, (j - i) * height[j--]);
}
return res;
}
}
day2
双指针-三数之和
暴力解法:
两层循环 + 两数之和思路
a + b + c == 0
for (a)
for (b)
if (map.containKey(0 - a - b))
// 难点在于 a b c 都要去重
正确思路:排序 + 三指针
重点:去重
- 第一个元组的去重
left
right
指针的去重
代码:题解 leetcode.cn(再结合自己 IDEA 中加的去重相关的注释)
理论基础-滑动窗口
思路:尺蠖运动(前后都要一步步来,不能跳)
模板
// 外层循环扩展右边界,内层循环扩展左边界
for (int l = 0, r = 0 ; r < n ; r++) {
// 当前考虑的元素
// 区间[left,right]不符合题意
// (while 循环是重点,左指针慢慢移除元素挪到正确位置,而不是直接跳跃到正确位置)
while (l <= r && check()) {
// 扩展左边界
}
// 区间[left,right]符合题意,统计相关信息
}
不能用滑动窗口的场景:
数组元素不同号(要么全是正数,要么全是负数)。因为不同号的情况下滑动窗口会出现左指针向左移的情况,不好处理。
滑动窗口-无重复字符的最长子串
/**
* 思路
* 维护两个指针充当滑动窗口
* 然后借助 HashMap 寻找判断右指针遍历到的元素是否重复
* HashMap 的键是 s 中的字符,值是索引
*
* @param s
* @return
*/
public int lengthOfLongestSubstring(String s) {
int result = 0;
Map<Character, Integer> map = new HashMap<>();
for(int left = 0, right = 0; right < s.length(); right++) {
// 如果当前字符已经在 map 中,且它的索引在左指针的左边
while (map.containsKey(s.charAt(right))) {
// 将左指针移动到重复字符的下一个位置
map.remove(s.charAt(right));
left++;
}
// 更新当前字符的位置
map.put(s.charAt(right), right);
// 计算当前子串的长度
result = Math.max(result, right - left + 1);
}
return result;
}
滑动窗口-找到字符串中所有字母异位词
灵神给了两种方法,定长窗口和不定长窗口。
自己套模板的话是不定长窗口方法。
代码是让 AI 生成的,不太好理清思路,好好看看代码。
public List<Integer> findAnagrams(String s, String p) {
List<Integer> ans = new ArrayList<>();
// 用 HashMap 来统计 p 中每个字符的出现次数
Map<Character, Integer> map = new HashMap<>();
for (char c : p.toCharArray()) {
map.put(c, map.getOrDefault(c, 0) + 1);
}
// 滑动窗口的左右指针
int left = 0;
// 记录当前窗口中字符的出现次数
Map<Character, Integer> windowMap = new HashMap<>();
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
// 右端点字母进入窗口
windowMap.put(c, windowMap.getOrDefault(c, 0) + 1);
// 如果窗口内的字符数量超过了 p 中字符的数量,收缩左边界
while (windowMap.get(c) > map.getOrDefault(c, 0)) {
char leftChar = s.charAt(left);
windowMap.put(leftChar, windowMap.get(leftChar) - 1);
left++;
}
// 如果当前窗口大小等于 p 的长度,且字符出现次数一致,表示是字母异位词
if (right - left + 1 == p.length()) {
ans.add(left);
}
}
return ans;
}
子串-和为K的子数组
通过前缀和,我们可以把连续子数组的元素和转换成两个前缀和的差
该题解中,从 前缀和方法
到 前缀和 + 哈希表方法
的讲解很不错。
代码
public int subarraySum(int[] nums, int k) {
int result = 0;
// 当前的前缀和
int sum = 0;
// key: 前缀和, value: 前缀和出现的次数
Map<Integer, Integer> map = new HashMap<>();
// 初始化前缀和为 0 出现的次数为 1,表示空子数组的情况
map.put(0, 1);
for (int i = 0; i < nums.length; i++) {
// 更新当前的前缀和
sum += nums[i];
// 检查是否有一个之前的前缀和满足 sum - k
if (map.containsKey(sum - k)) {
// 将出现的次数加到结果中
result += map.get(sum - k);
}
// 更新哈希表,记录当前前缀和出现的次数
map.put(sum, map.getOrDefault(sum, 0) + 1);
}
return result;
}
day3
单调栈-每日温度
单调栈基础题。
单调栈-接雨水
关键:横着累计。
单调栈-柱状图的最大矩形
关键:如何求最大面积?
求出每一个高度对应的最大面积,然后比较出里面最大的。
已知高度,求最大面积,就是求最大的宽。
最大的宽 = 右边第一个比它小的索引 - 左边第一个比它小的索引 - 1。
因此,题就转换为了求每个高度左右第一个比他小的索引
所以使用单调栈。
总结-单调栈
单调栈分为单调递增和单调递减,分别适用于不同的场景。
- 每日温度最基础,很好想。
- 接雨水,栈单调递减。
- 柱状图的最大矩形,栈单调递增。
典型场景:
子串-滑动窗口最大值
- 坐飞机看山的例子,很形象
day4
子串-最小覆盖子串
不行了,看不下去了,中午了,今天就四道题吧。
动态规划
完全背包
完全背包指每个物品都有无数个。
完全背包与01背包的不同:
- 二维数组:递推公式
- 一维数组:遍历顺序
重点:
二维数组解法 - 01背包与完全背包递推公式的区别:
首先,二者的递推关系都是:
最大价值 = Math.max(不放物品 i 的最大价值 + 放物品 i 的最大价值);
01背包每个物品只能添加一次,所以放物品 i 的情况不考虑本层物品,依赖于上一层左侧的值;
dp[j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
完全背包每个物品能添加无数次,所以放物品 i 的情况需要考虑本层物品,依赖于本层左侧的值。
dp[j] = Math.max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
一维数组解法 - 01背包与完全背包遍历顺序的区别:
由上面可知:01背包依赖于上层左侧,完全背包依赖于本层左侧;
所以一维数组解法中,内层遍历背包需要从小到大遍历。
518-零钱兑换II
纯完全背包:装满背包的最大价值
零钱兑换II:装满背包有多少种方法;装满背包的方法还是组合数,无顺序要求
与 494. 目标和 很类似,二者唯一区别是01背包与完全背包的区别。
重点:
- 初始化
- dp[0] = 1; 无明确推导逻辑,仅仅是从样例反推而来,题目描述不严谨。
- 非零下标初始化为0,防止影响递推公式累加。
- 遍历顺序:组合和排列
- 纯完全背包两层 for 循环可上下颠倒
- 物品 -> 背包(组合)
- 背包 -> 物品(排列)
遍历顺序解释
(残存的思考。想不明白,举例子也想不明白,先记结论吧)
示例条件
- 硬币面额:
coins = [1, 2]
- 总金额:
amount = 3
物品 -> 背包(组合)
物品i\背包容量j | 0 | 1 | 2 | 3 |
---|---|---|---|---|
0 | 1 | 0 | 0 | 0 |
1 | 1 | 1{1} | 1{11} | 1{111} |
2 | 1 | 1{1} | 2{11 2} | 2(dp[1][3]+dp[2][1]){111 12} |
背包 -> 物品(排列)
物品i\背包容量j | 0 | 1 | 2 | 3 |
---|---|---|---|---|
0 | 1 | 0 | 0 | 0 |
1 | 1 | 1{1} | 1{11} | 1{111} |
2 | 1 | 1{1} | 2{11 2} |
想了两个小时,根本想不明白 dp[2][3]
是怎么推导出来的,很多遍历中的边界条件,大脑思考谬误太多了,暂时放弃,先记忆结论吧。
复习
之前其实并没有做过标准的01背包与完全背包问题,中间四级考试间隔 20 天,此处知识忘记的太多了,所以借 标准01背包&完全背包-二维&一维数组
四个标准问题复习。
关键
- 动规五部曲,先想好五步思路,再写代码:
- 定义 dp 数组:几乎背会了。
- 递归条件:看笔记。
- 遍历顺序:边界条件其实很好写出来,能够想清楚整个过程,边界条件就很清晰了。
- 初始化:
- 问 AI,似乎背包与物品都从 0 开始遍历(即从背包容量为 0 与不装任何物品开始遍历)更加标准、好理解。
- 这种思路更方便些,默认初始化为 0 即可。
- 代码随想录讲的是:背包容量从 0 开始,物品从放第一个物品开始。
- 这种做法算是第一种的优化,因为只放第一个物品的情况,可以很简单的手动遍历出来,复杂度更小,但是个人觉得这种情况下边界条件需要细心。
- 问 AI,似乎背包与物品都从 0 开始遍历(即从背包容量为 0 与不装任何物品开始遍历)更加标准、好理解。
- 一维数组相比二维数组,除了压缩状态,还将 “剩余背包容量是否能够容纳物品重量的判断” 整合进了遍历顺序当中,观感简洁,但是需要留心理解。
377-组合总和IV
花了6个小时,11:00-5:00,与自己和解了,二维数组-组合&排列本质依旧想不明白,放弃……
总结
494-目标和:01背包,固定物品,装满背包有多少种方法。
- 笔记在平板上,先思考二维数组解法;
- 然后根据二维数组状态压缩,推出一维数组解法;
518-零钱兑换II:完全背包,固定物品,组合,装满背包有多少种方法。
- 二维数组正常思考能做出来;
- 一维数组解法是根据
494-目标和
知道的。
377-组合总和IV:完全背包,固定物品,排列,装满背包有多少种方法。
- 二维数组如何做出排列?根本理解不了,放弃;
- 一维数组解法就是:把
518-零钱兑换II
一维数组解法的遍历顺序互换。
所以,这几道题的思考记忆关系就是:
494-目标和-二维数组
-> 494-目标和-一维数组
-> 518-零钱兑换II-一维数组
-> 377-组合总和IV-一维数组
做法
518-零钱兑换II
交换物品背包遍历顺序即可。
注意
注意初始化,将背包容量为 0 的情况初始化为 1。
进阶
进阶爬楼梯问题,一次可以爬 0~n,爬 m 阶台阶方法数。解法与本题同理,都是 动态规划-完全背包-排列
问题。
322-零钱兑换
难点
定义 dp 数组:装满容量为 j 的背包,最少物品为 dp[j]。
递推公式:dp[j] = Math.min(dp[j - coins[i]] + 1, dp[j]);
。
本题的过程不难想象,先遍历物品,再遍历背包,遍历出只有第一个钱币情况的个数,然后逐个添加,再遍历,通过递推公式取最小值。(如果看不懂,想象一下过程就好了)
注意
初始化。之前的题大多是比较子问题谁更大,所以初始值为 0。这道题比较谁更小,所以初始值为 Integer.MAX_VALUE
。
279-完全平方数
重点是问题的转换:给这些物品(完全平方数),装满容量为n的背包,求装满背包最少使用多少物品。
转换后这道题的思路就与上一题一摸一样了。
139-单词拆分
思路
有无限数量的物品(wordDict),判断是否能够装满背包。
代码随想录是按完全背包讲解的,但是自己思考了下感觉有些牵强,思路更像是双指针,只是代码写出来很像完全背包。
示例
s = "applepen"; wordDict = ["apple", "pen"];
i | s.substring(j, i)(左闭右开) | isAppear? |
---|---|---|
0 | 0~0 | 默认初始化 y |
1 | 0~1 | n |
2 | 0~2 | n |
3 | 0~3 | n |
4 | 0~4 | n |
5 | 0~5 | y |
6 | 5~6 | n |
7 | 5~7 | n |
8 | 5~8 | y |
小重点
String.substring(int beginIndex, int endIndex)
方法,参数区间是左闭右开。
原因:
- 计算长度简单:
length = endIndex - beginIndex
。 - 分段方便:例如提取索引
0~4
和4~8
,endIndex
与下一个beginIndex
连续,不会重复字符。
左闭右开的设计在编程中似乎很常见,例如 Python 的切片、C++ 的区间。
背包总结
198-打家劫舍
正常 DP,拆分为子问题,然后比较偷与不偷的金额大小,钱尽可能偷多。
注意
题目要求的是偷的房间不能相邻,没要求必须隔一个,也可以隔多个。
213-打家劫舍II
在打家劫舍I的基础上,将可偷房间连成环。
重点
- 将环展开,分情况讨论,以两种情况的最大值为结果
- 拆分数组的方法:
- 最好理解的方法是遍历拆分,但是复杂度过高。
- 所以干脆不拆分,以 start end 索引来截取想要的数组片段,缺点是边界条件容易出问题,人脑不好理解。
337-打家劫舍III
递归、二叉树和动态规划的结合(树形 DP)。
与前两个打家劫舍解题思路唯一的相关点:状态转移的思路不变,在偷与不偷中选择最佳情况。
重点
- dp 数组的定义:
- 对于每个节点,dp[0]表示不偷,dp[1]表示偷,值分别为当前节点偷与不偷的最大数量。
- 对于其他节点,栈来保存每一层递归的节点。
- 二叉树
- 上层节点依赖于下层节点,所以是二叉树后序遍历递归方法。
- 递归三部曲。
- 状态转移公式在单层递归逻辑中。这道题递归就相当于遍历顺序了。
- 上层节点依赖于下层节点,所以是二叉树后序遍历递归方法。
121-买卖股票的最佳时机
买卖股票的前两道题其实都可以用更好理解的贪心做出来,但是想要完全做出买卖股票这个系列,还是得用动态规划算法。
关键
-
dp 数组的含义,这个看视频就好。
- 关键是理解“当天持有股票”是什么意思,与“当天买入股票”是两码事,易混淆。
-
递推公式:
-
概念。视频中概念描述得不太好理解,概念这么说会好理解些:
- 持有股票的最大金额 == 为了持有股票所花费的最小金额,由于是花费,所以是负数。
- 不持有股票的最大金额 == 卖出股票所赚得的最大金额。
-
公式:
-
实际推导一下图就很好理解了。
输入:
[7, 1, 5, 3, 6, 4]
dp[i][0]
dp[i][1]
0 -7 0 1 -1 0 2 -1 4 3 -1 4 4 -1 5 5 -1 5
-
-
122-买卖股票的最佳时机II
与 I
的唯一区别:
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
I
只能买卖一次股票,所以这里花费的钱,只需为每张股票所需花费的价格即可。II
能买卖多次股票,所以这里花费的钱,应该为(之前赚的钱 - 买股票花费的钱)
对于这道题,其实我没透彻理解这个状态转移公式……
看不懂,放弃了,刷到后面看情况吧。
或许关键是看懂子问题?感觉自己陷入误区了,妄图去理解动态规划的全貌,导致脑袋爆栈。
[12.24] 五天后回头看这里的疑惑,发现状态转移公式还是很好理解的。
- 今天持有股票所拥有的钱 = Math.max(前一天持有股票所拥有的钱, 前一天不持有股票 - 今天买股票所花费的钱);
- 今天不持有股票所拥有的钱 = Math.max(前一天不持有股票所拥有的钱, 前一天持有股票所拥有的钱 + 今天卖股票所赚得的钱);
做算法果然不能钻牛角尖,适当放下,回头再看,思路就会清晰很多了。
123-买卖股票的最佳时机III
股票至多可买卖 2 次。
上一题的基础上多了几个状态。
188-买卖股票的最佳时机IV
股票至多可买卖 k 次。
需要将上一题抽象为可用变量表示的通式。
309-买卖股票的最佳时机含冷冻期
在 II 的基础上加上了冷冻期,较难。
重点
- 定义 dp 数组
- 未持有股票需划分为 3 种
- 当前卖入股票状态。
- 冷冻期状态。
- 冷冻期后未持有股票状态。
- 未持有股票需划分为 3 种
- 递推公式
- 关键是搞清楚四种状态间的转移关系
714-买卖股票的最佳时机含手续费
在 II 的基础上加上了手续费,较简单。
只需在每次买卖股票时,状态转移公式中金额减去手续费即可。
子序列问题
300-最长递增子序列
定义 dp 数组:dp[i] 表示以 nums[i] 为结尾的最长递增子序列的长度。
重点:手动画图模拟感受一下过程。
10 | 9 | 2 | 5 | 3 | 7 | 101 | 18 |
---|---|---|---|---|---|---|---|
1 | 1 | 1 | 2 | 2 | 3 | 4 | 4 |
分析
初始状态:当前的小范围内,最长子序列的长度为 1(①)
子问题:
- 当范围 +1 时,需要将新加入进来的 nums[i] 逐个与先前范围内的 nums[j](即前面最长公共子序列的最后一个也是最大的一个元素)作比较。(②)
- 如果比最后一个元素还大,那么说明 dp[j]+1 可能是 dp[i] 的值。(②)
- 逐个比较,dp[i] 的最终值为逐个比较后的最大值。(这步也是递推公式③的体现)
返回值为 dp 数组的最大值。(④)
代码与分析的对应
public int lengthOfLIS(int[] nums) {
// 存储结果
int result = 1;
// 定义 dp 数组
int len = nums.length;
int[] dp = new int[len];
// 1 初始化
for (int i = 0; i < len; i++) {
dp[i] = 1;
}
// 遍历
for (int i = 1; i < len; i++) {
for (int j = 0; j < i; j++) {
// 2
if (nums[i] > nums[j]) {
// 3
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
// 4 返回值
result = Math.max(result, dp[i]);
}
return result;
}
674-最长连续递增序列
与上一题的对比
- 题意区别:
- 上一题不要求连续
- 本题要求连续。
- 逻辑区别:
- 上一题由于不要求连续,所以每当向后遍历一位数组后,都要再次遍历前面的数组。逐个对比,判断新值是否能够插入前面任意的一个最长序列。如果能则 +1,否则不变。
- 本题由于要求连续,所以每当向后遍历一位数组后,只与当前位置的前一个元素做对比。
- 代码区别:
- 少了层 for 循环,还有递推公式的判断(对比元素的区别)。
718-最长重复子数组
关键
- 定义 dp 数组:
dp[i][j]
是以 i-1 与 j-1 元素作为结尾的公共子数组的长度。 - 递推公式:
- 如果
dp[i-1] != dp[j-1]
,dp[i][j] = 0
。 - 如果
dp[i-1] == dp[j-1]
,dp[i][j] = dp[i-1][j-1] + 1
。
- 如果
- 初始化:定义 dp 数组时,以 i-1 与 j-1 作为结尾是为了避免初始化。
- 如果 i,j=0,
dp[i][j]
未定义,默认初始化为 0,dp[i][1]
与dp[1][j]
按照dp[i][0]
与dp[0][j]
在递推公式中求出。 - 如果 i,j=1,还需要遍历
dp[i][0]
dp[0][j]
,根据是否相等,判断初始化为 0 或 1,复杂度增加。
- 如果 i,j=0,
- 遍历顺序:就是依次遍历二维数组。
1143-最长公共子序列
上一题要求公共序列元素连续,本题不要求连续。
区别
最大的区别在于递推公式:
if(text1.charAt(i - 1) == text2.charAt(j - 1))
dp[i][j] = dp[i - 1][j - 1] + 1;
if(text1.charAt(i - 1) == text2.charAt(j - 1))
dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
- 解释:
- 例如 abc 与 ace 做对比,c != e。
- 所以对比 abc 与 ac 或 ab 与 ace,二者比较然后取最大值。
定义 dp 数组:dp[i][j]
是 nums1 范围 [0, i-1] 与 nums2 范围 [0, j-1] 公共子序列的长度。
返回值:根据 dp 数组定义,直接返回 dp[len1][len2]
即可。
1035-不相交的线
就是最长公共子序列问题,需要转换问题,看出本质。
53-最大子数组和
两种做法,贪心与动态规划。较简单。
392-判断子序和
编辑距离类入门题目。本题还可以用双指针解决,但是动态规划可以解决后续的编辑距离系列问题。
核心思路与最长公共子序列类似。
-
定义 dp 数组
-
二维 dp 数组表示的是两字符串每一个字符的对应情况。
-
注意:由于初始状态可以由 0 在递归公式中自然推导出,所以
dp[i][j]
表示以 i-1 为结尾的 s 和以 j-1 为结尾的 t 的系统子序列长度,而不是 i 与 j。
-
-
递推公式
- 如果字符相同。
- 长度 + 1。
- 如果字符不同。
- 长度不变。继续比较字符串 t 的下一个字符。
- 如果字符相同。
115-不同的子序列
(这道题的定义是区间,前一道题的定义是字符,还是有规律的吧,总结一下)
重点
- 定义 dp 数组(代码随想录讲的听不懂,感觉评论区大佬的思路很清晰)
dp[i][j]
在s[0] ~ s[i-1]
中,有多少个t[0] ~ t[j-1]
的匹配
-
递推公式(以
bag
与bagg
举例,当遍历到第二个g
时。)- 如果当前比较字符相等
s[0,i]
中t[0,j]
的匹配数(bag
) ==s[0,i-1]
中t[0,j-1]
的匹配数(ba
) +s[0,i-1]
中t[0,j]
的匹配数(除去当字符前后bag
的匹配数)
- 如果当前比较字符不相等
s[0,i]
中t[0,j]
的匹配数 ==s[0,i-1]
中t[0,j]
的匹配数
- 如果当前比较字符相等
-
初始化
dp[i][0]
与dp[0][j]
dp[i][0]
:t 为空,s 中字符都删掉有一种空字符串的情况,初始化为 1。dp[0][j]
:s 为空,t 不为空,s 中有 0 种 t,初始化为 0。dp[0][0]
:s 为空,t 为空,1 种情况,初始化为 1。
联想
这道题的 dp 数组定义利用的是区间,前一道题的 dp 数组定义利用的是字符。
二者的区别?
- 上一题判断是否出现,所以状态转移公式的理解较简单,只需逐个比较字符即可。
- 本题要求出现了几次,较复杂,需要统计出具体个数,涉及一个区间内包含另一个区间个数的判断。
583-两个字符串的删除操作
与最长公共子序列类似,返回值处理一下即可。
72-编辑距离
关键在于递推公式,如何通过递推公式实现字符的增删改。
647-回文子串
定义 dp 数组:dp[i][j]
表示范围为 [i,j]
的子串是否是回文子串,boolean 类型。
516-最长回文子串
上一题要求连续,本题可以不连续。
定义 dp 数组:[i,j]
的回文子序列长度为 dp[i][j]
单调栈
739-每日温度
单调栈的使用场景就是,寻找当前元素左边或右边,第一个比当前元素大或者小的元素。
栈中的元素是递增或递减,该栈即为单调栈。
- 递增:求当前元素左或右第一个比它大的元素
- 递减:求当前元素左或右第一个比它小的元素
处理逻辑:
- 遍历数组元素,按递增顺序放入栈中,如果不递增,则计算结果。
496-下一个更大元素I
503-下一个更大元素II
环形遍历数组,相比于复制数组然后拼接,取模法是更好的方法。
42-接雨水
本题按行方向统计,而非列方向。