26.16-26
刷题记录
## 2026-06-16 双指针
128.最长连续序列(二刷复盘)
这次踩坑点:
- `Set<Integer>` 一开始少写了 `new`,说明集合初始化的基本写法还不够稳
- `res` 一开始没初始化成 `0`,导致答案变量的起点没立住
- 我虽然记得“不是起点就跳过”,但还是容易漏掉 `continue`
- `while` 里一开始只写了 `count++`,没写 `start++`,这会让连续段没有真正往后推进,甚至卡成死循环
- 我一开始写了 `res = count`,这说明我又差点把“当前这段长度”和“历史最大值”混掉
正确写法重点:
- 集合初始化:`Set<Integer> set = new HashSet<>();`
- 先把所有数字放进 `set`
- 遍历 `set` 时,若 `set.contains(num - 1)`,直接 `continue`
- 只有起点才继续:`int start = num; int count = 1;`
- 连续段扩展:`while (set.contains(start + 1)) { start++; count++; }`
- 维护历史最大值:`res = Math.max(res, count);`
记住一句话:
- `只从起点出发,边推进边计数,最后用 res 保留历史最大值`
49.字母异位词分组(二刷复盘)
这次踩坑点:
- 我一开始还是容易“先建空桶再判断是否存在”,结果会把同组里前面已经存进去的字符串覆盖掉
- 我虽然知道要按排序后的字符串分组,但还是容易把原字符串 `str` 和分组特征 `key` 混着用
- `List` 和 `ArrayList` 的关系还不够稳,写到“新建列表”时容易直接 `new List<>()`
- `String -> char[] -> 排序 -> String` 这条转换链还没完全熟
- 最后返回结果时,我知道答案在 `value` 里,但 Java 里是 `values()` 方法,不是 `value`
正确写法重点:
- 字符串转字符数组:`char[] chars = str.toCharArray();`
- 排序字符数组:`Arrays.sort(chars);`
- 构造分组 key:`String key = new String(chars);`
- 桶已存在时:`map.get(key).add(str);`
- 桶不存在时:先 `List<String> list = new ArrayList<>();`,再 `list.add(str);`,最后 `map.put(key, list);`
- 返回结果时:`return new ArrayList<>(map.values());`
记住一句话:
- `原字符串负责进桶,排序后的 key 负责找桶`
283.移动零
这次踩坑点:
- 我一开始容易把这题想成“找零再交换”的题,而不是“把非零元素稳定地收紧到前面”
- 我差点把 `slow` 理解成“找下一个零的位置”,但更准确地说,它表示“下一个非零元素应该放到哪里”
- 写交换时,我一开始想交换 `fast` 和 `slow` 这两个指针变量本身,而不是交换 `nums[fast]` 和 `nums[slow]`
- 我差点把 `if(fast != slow)` 放成外层判断,这会漏掉 `fast == slow` 但当前元素是非零时 `slow` 也应该前进的情况
- 这题不是每次遇到非零都必须真的交换,只有 `fast != slow` 时交换才有意义
正确写法重点:
- 初始化双指针:`int slow = 0;`
- 用 `fast` 扫描数组:`for (int fast = 0; fast < nums.length; fast++)`
- 只处理非零元素:`if (nums[fast] != 0)`
- 如果 `fast != slow`,交换 `nums[fast]` 和 `nums[slow]`
- 只要处理了一个非零元素,不管有没有交换,都要 `slow++`
## 2026-06-17
283.移动零(二刷复盘)
为什么 `slow++` 能指到下一个该放非零的位置:
- `slow` 维护的不是“下一个零的位置”,而是“下一个非零元素应该放的位置”
- 每当执行到 `slow++`,说明当前这一轮已经成功处理完了一个非零元素
- 这个非零元素要么本来就在 `slow` 位置上,要么刚刚被交换到了 `slow` 位置上
- 所以当前位置已经被一个合法的非零元素占好,下一次当然应该往后一个位置放新的非零元素
- 换句话说,`slow` 也可以理解成“前面已经整理好的非零区长度”
记住一句话:
- `fast 找非零,slow 指向下一个非零该放的位置`
11.盛最多水的容器
题型信号:
- 输入是数组 `height`,目标是从两端选两条线,让面积最大
- 面积由“较矮高度”和“两指针距离”共同决定:`area = min(height[left], height[right]) * (right - left)`
- 暴力枚举两条线是 `O(n^2)`,优化方向是相向双指针
这次理解重点:
- 面积的高度由较矮的那条线决定,不是由高的那条线决定
- 每次移动指针后,宽度一定变小,所以未来面积想变大,只能希望短板变高
- 因此应该移动较矮的一边,而不是移动较高的一边
正确写法重点:
- 初始化:`int left = 0; int right = height.length - 1; int res = 0;`
- 循环条件:`while (left < right)`,因为两条不同的线才能形成容器
- 当前面积:`int area = Math.min(height[left], height[right]) * (right - left);`
- 更新最大值:`res = Math.max(res, area);`
- 移动短板:如果 `height[left] < height[right]`,就 `left++`,否则 `right--`
易错点:
- `right` 初始化为 `height.length - 1`,不是 `height.length`
- 面积高度要用 `Math.min(...)`,不能用较高的那条线
- 移动指针时要移动较矮的一边,因为短板才是当前面积瓶颈
记住一句话:
- `宽度一定变小,所以移动短板,赌下一个短板更高`
## 端午休息
## 2026-06-22
49.字母异位词分组(三刷复盘)
这次踩坑点:
- 我已经知道要用 `str -> chars -> 排序 -> key` 这条链来构造分组特征,但在新建桶时还是漏掉了“把当前字符串先放进桶里”这一步
- 我一开始写成了只 `map.put(key, list)`,结果第一次遇到某个 `key` 时,桶是空的,当前字符串直接丢了
- 这说明我现在不是判型不会,而是“新桶建立后要立刻装入当前元素”这个动作还不够自动化
- `list.add(str)` 的返回值是 `boolean`
正确写法重点:
- 生成 key:`char[] chars = str.toCharArray(); Arrays.sort(chars); String key = new String(chars);`
- 桶已存在:`map.get(key).add(str);`
- 桶不存在:`List<String> list = new ArrayList(); list.add(str); map.put(key, list);`
- 返回结果:`return new ArrayList<>(map.values());`
记住一句话:
- `先把当前字符串装进新桶,再把桶挂到 key 上`
128.最长连续序列(三刷复盘)
这次踩坑点:
- 我这次主逻辑已经写对了,但提交时超时,暴露出我还没有把“遍历 `set` 而不是遍历 `nums`”这件事彻底自动化
- 如果遍历 `nums`,重复元素会让同样的数字被反复拿出来判断起点,虽然思路接近对,但效率会变差
- 这题真正稳定的写法是:先用 `set` 去重,再围绕 `set` 判断起点和扩展连续段
正确写法重点:
- 先去重:`for (int num : nums) set.add(num);`
- 遍历对象要用 `set`:`for (int num : set)`
- 起点判断:`if (set.contains(num - 1)) continue;`
- 从起点扩展:`while (set.contains(start + 1)) { start++; length++; }`
- 维护最长值:`res = Math.max(res, length);`
记住一句话:
- `先去重,再只从起点出发`
15.三数之和
题型信号:
- 题目要找的是“所有和为 `0` 的三元组”,而且结果不能重复
- 一边固定一个数,一边在剩余区间里继续找两数配合,更适合“排序 + 双指针”
- 这题不是随便设三个指针乱试,而是先排序,把“变大/变小”的方向交给有序数组
这次踩坑点:
- 我一开始还是按“直接凑三个下标”的方向写,`j++`、`k++` 这种写法没有真正利用有序性
- 我差点写成“两数之和找目标值”的变形,算 `0 - nums[i]`,但双指针这里更应该直接算三个数的和`int sum = nums[i]+nums[left]+nums[right]`
- 我一开始把 `i` 去重写成和后一个比较,实际上应该和前一个比,跳过的是“重复开头的第二个、第三个”
- 我写到 `sum == 0` 时,只做了 `left++`、`right--`,但还没立刻意识到后面要继续跳过重复值
- 我还一度把 `nums[left] != nums[right]` 当成是否加入答案的条件,但像 `[-2,1,1]` 这种合法答案会被误杀
正确写法重点:
- 先排序:`Arrays.sort(nums);`
- 固定开头:`for (int i = 0; i < nums.length; i++)`
- `i` 去重:`if (i > 0 && nums[i] == nums[i - 1]) continue;`
- 初始化双指针:`int left = i + 1; int right = nums.length - 1;`
- 在 `while (left < right)` 里直接算:`int sum = nums[i] + nums[left] + nums[right];`
- `sum < 0`:`left++`
- `sum > 0`:`right--`
- `sum == 0`:收集答案,然后 `left++`、`right--`,再分别跳过左右重复值
今天最该记住的分界线:
- `i` 去重:防止同一个开头值重复起手
- `left/right` 去重:防止同一组三元组重复加入结果
- `sum < 0` 移 `left`,`sum > 0` 移 `right`:因为排序后只有这样才有明确的“变大/变小”方向
记住一句话:
- `先排序,固定 i,再用 left/right 夹出两数和;找到答案后,i 和左右两边都要去重`
## 2026-06-23
15.三数之和(二刷复盘)
这次踩坑点:
- 我一开始把 `sum` 放在 `while` 外面,只算了一次,但 `left/right` 在循环里会变,`sum` 也必须每轮重新算
- 我把 `i` 去重写成了和后一个比较,甚至直接 `i++`,这会打乱 `for` 循环自己的节奏;正确思路是和前一个比,重复就 `continue`
- 我在 `sum == 0` 时一开始没有移动 `left/right`,这样会一直卡在同一组数上死循环
- 后来虽然知道要移动指针了,但又写成了“先移动、再记录答案”,结果加进去的不是刚刚命中的那组三元组
- 我还把 `list` 放错了位置,一度想让一个 `list` 承担一个 `i` 的所有结果,但其实每命中一次,都要新建一个三元组列表并立刻加入 `resList`
- 左右去重时,我又混淆了比较方向:`left` 已经右移后,要和 `left - 1` 比;`right` 已经左移后,要和 `right + 1` 比
正确写法重点:
- `sum` 要放在 `while (left < right)` 里每轮重算:`int sum = nums[i] + nums[left] + nums[right];`
- `i` 去重:`if (i > 0 && nums[i] == nums[i - 1]) continue;`
- `sum < 0`:只做 `left++`
- `sum > 0`:只做 `right--`
- `sum == 0`:先新建 `list` 收集 `nums[i]`、`nums[left]`、`nums[right]`,立刻 `resList.add(list)`,再 `left++`、`right--`
- `sum == 0` 后继续去重:
`while (left < right && nums[left] == nums[left - 1]) left++;`
`while (left < right && nums[right] == nums[right + 1]) right--;`
今天最该记住的分界线:
- `sum < 0 / > 0` 时只是移动一边,不急着去重
- 真正要做左右去重的时机,是 `sum == 0` 找到答案之后
- 记录答案的顺序是:`先记录,再移动,再去重`
记住一句话:
- `15 题最怕顺序乱:先算 sum,命中后先收集答案,再移动左右,再跳过重复`
42.接雨水
题型信号:
- 输入是柱状图高度数组,目标不是找区间和,而是算“每个位置上方最多能存多少水”
- 某个位置能接多少水,不取决于自己多高,而取决于它左右两侧的最高挡板里较矮的那个
- 这题可以先想到“预处理 `leftMax/rightMax`”,再进一步优化成“双指针 + 边走边维护最大值”
这次踩坑点:
- 我一开始差点把接水公式写反,正确的是 `min(leftMax, rightMax) - height[i]`
- 在预处理思路里,我一开始把 `leftMax[i]` 跟自己比了,但真正该比的是“前一个最大值”和“当前柱子高度”
- 切到双指针后,我已经知道 `leftMax < rightMax` 处理左边,否则处理右边,但右边分支一开始漏写了 `right--`
- 我最后真正提交出错的关键坑,是把循环条件写成了 `while(left < right)`,结果漏算了 `left == right` 时最后那个还没处理的位置,样例少算了 `1`
正确写法重点:
- 先判空:`if (height == null || height.length == 0) return 0;`
- 初始化:`int left = 0; int right = height.length - 1;`
- 维护两侧最高值:`int leftMax = height[left]; int rightMax = height[right];`
- 循环条件:`while (left <= right)`
- 如果 `leftMax < rightMax`,先处理左边:
- `height[left] < leftMax`:`res += leftMax - height[left];`
- 否则更新:`leftMax = height[left];`
- 然后 `left++`
- 否则处理右边:
- `height[right] < rightMax`:`res += rightMax - height[right];`
- 否则更新:`rightMax = height[right];`
- 然后 `right--`
今天最该记住的分界线:
- `leftMax < rightMax`:左边当前格的水位已经由 `leftMax` 决定,可以直接处理左边
- 否则处理右边,因为右边当前格的水位已经由 `rightMax` 决定
- 如果代码是“先处理当前位置,再移动指针”,那这题要特别警惕:`left == right` 时那一格可能还没处理过
记住一句话:
- `哪边最大值更小,就先结算哪边;这版写法要用 while(left <= right),别漏掉最后一格`
## 2026-06-24
3.无重复字符的最长子串(第一次刷题)
题型信号:
- 题目给的是 `String`,目标是求“最长子串”的长度
- 这里的“子串”说明答案必须是连续区间,不是随便挑字符
- 题目还要求“无重复字符”,说明我们在维护一个合法窗口:窗口内不能出现重复
- 所以这题最典型的判型是:`滑动窗口 + 哈希表`
我这次是怎么一步步判出来的:
- 一开始先抓住了最关键的词:`子串`
- 因为是子串,所以我意识到它本质上是在字符串上维护一段连续区间
- 再加上“无重复”,就说明这个区间会随着新字符加入而变得不合法,需要动态收缩
- 所以这题不像普通哈希统计那样一次把全串数完,而是要一边扩张右边界,一边在必要时移动左边界
窗口里每个变量的角色:
- `left`:当前窗口左边界
- `right`:当前窗口右边界,负责一格一格向右扫描
- `map`:记录 `字符 -> 最近一次出现的位置`
- `res`:到目前为止的最长合法窗口长度
为什么 `map` 里要存“最近一次出现的位置”:
- 因为当 `right` 扫到一个重复字符时,我们关心的不是“它出现过没”,而是“它上次出现在当前窗口里的什么位置”
- 只有拿到这个位置,`left` 才知道要跳到哪里
- 这就是这题和单纯 `HashSet` 判重的区别:这里不仅要知道“重复了”,还要知道“重复点在哪”
这题最核心的更新句:
- 当当前字符 `c` 已经出现过时:
`left = Math.max(left, map.get(c) + 1);`
为什么一定要 `Math.max(...)`:
- 因为 `left` 只能往右走,不能往左退
- 我这次专门用 `abba` 这个例子想明白了:
- 当扫到最后一个 `a` 时,`a` 以前确实出现过
- 但它上一次出现的位置已经在当前窗口左边界外面了
- 如果我直接写 `left = map.get(c) + 1`,就会把 `left` 往回拉,窗口逻辑会乱
- 所以这里不是简单赋值,而是:
`left = Math.max(left, map.get(c) + 1)`
我这次写代码时暴露出来的坑:
- 一开始把 `right` 和 `i` 两套角色混用了,说明滑动窗口里“谁负责扫描”这个角色还不够稳
- 我一开始写了固定的 `right = 1`,但又想用 `for` 去遍历,这会让右边界角色混乱
- 我一开始差点每轮都 `left++`,但这题里 `left` 不是自动前进,而是“只有重复时才跳”
- 我一开始把 `map.put(...)` 和 `res = Math.max(...)` 都放进了 `if` 里,这会导致只有遇到重复时才更新,非重复字符反而没被正常纳入窗口
- 我还暴露出一些 Java 语法基础点不够稳:
- `HashMap<char, Integer>` 应该写成 `HashMap<Character, Integer>`
- `containskey` 应该是 `containsKey`
- `toArrays` / `toCharArray` 方法名容易写乱,而且 `toCharArray()` 后面必须带 `()`
正确写法重点:
- 先准备哈希表:`HashMap<Character, Integer> map = new HashMap<>();`
- 初始化:`int left = 0; int res = 0;`
- 把字符串转成字符数组:`char[] chars = s.toCharArray();`
- 用 `right` 扫描整个字符串:
`for (int right = 0; right < s.length(); right++)`
- 当前字符:`char c = chars[right];`
- 如果 `map` 里已经有这个字符:
`left = Math.max(left, map.get(c) + 1);`
- 不管重不重复,都要更新当前位置:
`map.put(c, right);`
- 再更新当前窗口长度:
`res = Math.max(res, right - left + 1);`
今天最该记住的分界线:
- `子串` -> 优先想到连续区间 -> 滑动窗口
- `无重复` -> 不是固定窗口,而是遇到重复时要动态收缩
- `HashSet` 只能判断“有没有”,`HashMap` 才能告诉我“重复点在哪”
- 这题里 `left` 不是每轮都加 1,而是只在重复时跳
- 跳的时候不能后退,所以一定要:
`left = Math.max(left, map.get(c) + 1)`
记住一句话:
- `right 负责扩张窗口,left 只在重复时跳;map 存最近位置,left 只能右移不能后退`
## 2026-06-25
42.接雨水(二刷复盘)
这次踩坑点:
- 我一开始又把 `leftMax` 和 `rightMax` 放回了 `while` 里面,每轮都重新等于当前位置高度,这样它们就不再是“历史最大值”,而只是“当前这一格”
- 我一开始把左右分支拆成了两套独立判断,导致一轮里可能连续处理两边,指针也会在同一轮里被多次移动
- 我还一度在每个分支里同时写了 `left++` 和 `right--`,但这题每一轮只能结算一边,所以一轮只能移动一个指针
- 右边分支我又把参与计算的柱子写错了,处理右边时应该看 `height[right]`,不是 `height[left]`
- 我还把右边分支的大小判断写反了:应该是“当前柱子更低才加水”,不是“当前柱子更高才加水”
- 这次重新暴露出一个核心问题:我知道了“比较 `leftMax` 和 `rightMax`”,但对“哪边分支该更新最大值、哪边分支该加水”还没有完全自动化
正确写法重点:
- `leftMax` 和 `rightMax` 在循环外初始化一次,进入循环后只按情况更新,不要每轮重置
- 主结构只有一层:
`if (leftMax < rightMax) { 处理 left } else { 处理 right }`
- 左边分支:
- 如果 `height[left] < leftMax`,就 `res += leftMax - height[left]`
- 否则更新 `leftMax = height[left]`
- 最后只做 `left++`
- 右边分支:
- 如果 `height[right] < rightMax`,就 `res += rightMax - height[right]`
- 否则更新 `rightMax = height[right]`
- 最后只做 `right--`
今天最该记住的分界线:
- `leftMax/rightMax` 是“历史最大值”,不是“当前高度”
- 一轮只能处理一边,所以一轮也只能移动一个指针
- 左右两边的逻辑是完全对称的:左边看 `height[left]`,右边看 `height[right]`
记住一句话:
- `42 二刷最容易乱的是角色:最大值别重置,一轮只处理一边,左边看 left,右边看 right`
3.无重复字符的最长子串(二刷复盘)
这次踩坑点:
- 我一开始又把 `left` 和 `right` 的角色写反了:把 `left` 写成了主动遍历的指针,但这题真正负责一格一格向右扫描的是 `right`
- 我一开始在 `for` 里面又手动写了 `right++`,这会让 `right` 每轮跳两格,窗口直接漏扫字符
- 我一开始把重复字符时的更新写成了 `left = right + 1`,这说明我还在用“跳过当前字符”的直觉,而不是“跳过旧重复字符”的位置
- 后来改成了 `left = map.get(c) + 1`,但这还不够稳,因为 `left` 可能会后退,`abba` 这种例子就会出错
- 这次再次说明:我已经知道这题是滑动窗口了,但一写代码还是容易把“谁负责扫描、谁只在必要时跳”这两个角色混掉
正确写法重点:
- `right` 负责遍历整个字符串:
`for (int right = 0; right < s.length(); right++)`
- `left` 不是每轮自动加 1,而是只在遇到重复字符时更新
- 遇到重复字符时,不能写成 `left = right + 1`
- 也不能简单写成 `left = map.get(c) + 1`
- 真正稳定的写法是:
`left = Math.max(left, map.get(c) + 1);`
- 然后正常更新:
`map.put(c, right);`
`res = Math.max(res, right - left + 1);`
今天最该记住的分界线:
- `right`:负责扩张窗口,正常往右扫
- `left`:只在重复时跳,平时不乱动
- `map.get(c) + 1` 给的是“理论上该跳到哪”,`Math.max(...)` 才能保证 `left` 不后退
记住一句话:
- `3 二刷最容易乱的是左右角色:right 负责扫,left 只在重复时跳,而且只能右移不能后退`
## 2026-06-26
438.找到字符串中所有字母异位词(第一次刷题)
题型信号:
- 输入是两个字符串 `s` 和 `p`
- 目标不是只判断一次 `true/false`,而是找出 `s` 中所有满足条件的子串起点
- 条件是:该子串和 `p` 互为字母异位词
- “异位词”本质上还是字符频次比较,所以这一层很像 `242`
- 但这里又多了“子串”,说明比较对象不是任意字符集合,而是 `s` 上一段连续区间
- 并且因为异位词长度必须和 `p` 一样,所以窗口长度是固定的
- 所以这题的第一次判型应该是:`固定长度滑动窗口 + 频次比较`
第一次刷题时我先建立的模型:
- 先统计 `p` 的频次表
- 再在 `s` 上拿一个长度为 `p.length()` 的窗口
- 判断当前窗口频次是否和 `p` 完全一致
- 如果一致,就把当前窗口左端点加入结果
- 这本质上就是:`438 = 242 的频次比较 + 固定窗口`
我一开始为什么会想到朴素写法:
- 因为第一次刷题时,最重要的是先把“窗口长度固定”和“比较频次”这两个核心模型搭起来
- 所以我一开始的自然思路是:
- 每个窗口都单独统计一份 `windowMap`
- 再用 `map.equals(windowMap)` 比较
- 这个思路虽然不够快,但它能帮我先确认题型和逻辑没有跑偏
第一次写代码时暴露出来的坑:
- 我一开始在统计 `p` 的频次时,写了一个局部 `count`,但每轮都会重新归零,说明我还没有完全把“频次累计”模板自动化
- 后来我意识到,这里应该直接用:
`map.put(c, map.getOrDefault(c, 0) + 1);`
- 我一开始拿到固定窗口时,`right` 的表达式写反了,没有真正体现“`right` 随着 `left` 一起往右滑”
- 我一开始最内层统计窗口频次时,一直重复统计 `charStrS[left]`,说明我虽然知道要遍历窗口,但还没真正把“窗口里的每一格都要扫到”落实到代码里
- 我一开始用 `map == windowMap` 比较两张表,这比较的是是不是同一个对象,不是内容是否相等;后来才改成了 `map.equals(windowMap)`
- 我一开始忘了比较初始窗口,结果会漏掉起点 `0`
- 我还暴露出一个边界问题:如果 `s.length() < p.length()`,应该直接返回空列表,而不是继续统计窗口
第一次刷题写通后的朴素解法思路:
- 一张 `map`:统计 `p` 的频次
- 每个窗口都临时建一张 `windowMap`
- 窗口范围是:
`left ... left + p.length() - 1`
- 每个窗口统计完后,用:
`map.equals(windowMap)`
判断是否为异位词
- 这版思路是对的,但复杂度偏高
为什么这版会超出时间限制:
- 因为我后来意识到:我虽然用了“滑动窗口”的窗口边界,但还没有真正利用“滑动”本身
- 我当时做的是:
- 每来到一个新窗口
- 就重新创建一张 `windowMap`
- 再把整个窗口从头到尾数一遍
- 这等于窗口虽然在滑,但频次表每次都重建
- 所以时间复杂度就变成了:
- 窗口个数很多
- 每个窗口又都重新统计整段
- 整体就会慢
优化复杂度时,我重新建立的正确滑窗模型:
- 既然窗口长度固定,窗口每次只向右滑 1 格
- 那么新窗口和旧窗口相比,只会发生两个变化:
- 旧的左端字符滑出窗口
- 新的右端字符滑入窗口
- 所以根本不需要重建整张 `windowMap`
- 只需要在已有频次表上做两次更新:
- `out`:频次 `-1`
- `in`:频次 `+1`
优化版里最关键的两个字符:
- 当新窗口左端点是 `left` 时:
- 滑出窗口的字符是:
`char out = charStrS[left - 1];`
- 滑入窗口的字符是:
`char in = charStrS[right];`
- 其中:
`right = left + p.length() - 1;`
为什么 `out` 要先减 1,再考虑删除:
- 因为一个字符滑出窗口时,不一定就完全消失了
- 它在窗口里可能还有别的副本,所以第一步应该是:
`windowMap.put(out, windowMap.get(out) - 1);`
- 只有当减完次数变成 `0` 时,才执行:
`windowMap.remove(out);`
- 不删掉次数为 `0` 的键,会影响后面 `map.equals(windowMap)` 的比较结果
优化版完整思路骨架:
- 先统计 `p` 的频次表 `map`
- 再统计 `s[0 ... p.length() - 1]` 的初始窗口频次 `windowMap`
- 先比较一次初始窗口,若相等就加入 `0`
- 然后让 `left` 从 `1` 开始滑动到最后一个合法起点
- 每轮:
- 算当前窗口右端点 `right`
- `out` 减 1,必要时删除
- `in` 加 1
- 再比较 `map.equals(windowMap)`
- 若相等,就把当前 `left` 加入结果
这题和之前题目的分界线:
- 和 `242` 的关系:
- `242` 只比较一次两个字符串频次
- `438` 是在 `s` 上不断取定长窗口,反复做 `242`
- 和 `3` 的关系:
- `3` 是可变窗口,窗口长度由“是否重复”动态收缩
- `438` 是固定窗口,窗口长度始终等于 `p.length()`
- 所以 `438` 不能按 `3` 那种“窗口不合法就一直缩”的思路写,而是要固定长度地往右平移
今天最该记住的分界线:
- `异位词` -> 看频次
- `子串` -> 看连续区间
- `和 p 同长度` -> 固定窗口
- `第一次刷题` 可以先每个窗口重建频次表,把题做对
- `超时后优化` 的关键不是换题型,而是让频次表随着窗口滑动做增量更新
记住一句话:
- `438 = 固定窗口版的 242;先把题做对,再把“每个窗口重建整张表”优化成“out--、in++”`
560.和为 K 的子数组(第一次刷题)
题型信号:
- 输入是 `int[] nums`,而且可能有 `负数`
- 目标是找 `连续子数组`
- 要求返回的是 `个数`,不是长度,也不是是否存在
- 只要出现 `连续子数组 + 求个数 + 可能有负数`,就要优先警觉:这题通常不是普通滑动窗口,而是 `前缀和 + HashMap`
我这次是怎么一步步判出来的:
- 我先抓到的是“子数组”,说明答案一定对应数组里的某一段连续区间
- 然后继续看,题目求的是“和等于 `k` 的子数组个数”
- 这时如果去枚举每个区间,复杂度会很高,所以就要想:能不能把“某段区间的和”转成“之前信息 + 当前信息”
- 这一步就会落到前缀和上:如果当前前缀和是 `pre`,想要某段区间和为 `k`,本质上就是找之前有没有前缀和等于 `pre - k`
- 因为题目求的是“个数”,所以 `HashMap` 里不能只存“有没有出现过”,而要存“某个前缀和出现过多少次”
这题最核心的数学关系:
- 设当前前缀和为 `pre`
- 如果某段子数组和为 `k`
- 那就说明:`当前前缀和 - 之前某个前缀和 = k`
- 也就是:`之前某个前缀和 = pre - k`
- 所以当我扫到当前位置时,只要知道 `pre - k` 之前出现过多少次,就知道“以当前位置结尾、和为 `k` 的子数组”有多少个
HashMap 在这题里到底存什么:
- `key`:某个前缀和
- `value`:这个前缀和出现的次数
- 也就是:`map.get(x)` 表示“前面前缀和等于 `x` 的情况出现过几次”
为什么一开始就要写 `map.put(0, 1)`:
- 它表示:在还没遍历任何元素之前,前缀和 `0` 已经出现过 `1` 次
- 这是为了处理“从下标 `0` 开始的那段子数组刚好和为 `k`”的情况
- 如果当前 `pre == k`,那我们就需要去找 `pre - k = 0`
- 这时候如果没有预先放入 `0 -> 1`,这类答案就会被漏掉
正确模板:
- 初始化:
- `int pre = 0;`
- `int res = 0;`
- `HashMap<Integer, Integer> map = new HashMap<>();`
- `map.put(0, 1);`
- 遍历数组中每个 `num`
- 先更新当前前缀和:`pre += num`
- 再查之前有多少个前缀和等于 `pre - k`:
- `res += map.getOrDefault(pre - k, 0);`
- 最后把当前前缀和记进表里:
- `map.put(pre, map.getOrDefault(pre, 0) + 1);`
这次我写代码时暴露出来的坑:
- 我一开始把
- `res += map.getOrDefault(pre - k, 0);`
写成了
- `res = map.getOrDefault(pre - k, 0);`
- 这说明我当时还没完全分清:
- `map.getOrDefault(pre - k, 0)` 表示“当前这一轮新找到几个”
- `res` 表示“到目前为止总共找到几个”
- 所以这里必须是“累加答案”,不能是“覆盖答案”
第二个坑:
- 我一开始把
- `map.put(pre, map.getOrDefault(pre, 0) + 1);`
少写了 `+1`
- 这会导致前缀和次数根本没有增长,相当于 `map` 没有正确记录历史出现次数
- 这题不是只看“这个前缀和存不存在”,而是严格依赖“它之前出现过几次”
第三个坑:
- 我一开始差点把 `pre` 和 `res` 写进循环里重新声明
- 但这两个变量都不是每轮临时值
- `pre` 是一路累计的前缀和
- `res` 是一路累计的总答案
- 所以它们必须在循环外初始化,在循环里只更新
第四个坑:
- 这题的顺序必须是:
- 先 `pre += num`
- 再查 `pre - k`
- 最后更新 `map` 里的 `pre`
- 不能先把当前 `pre` 放进 `map`
- 因为 `map` 里存的应该是“当前位置之前的前缀和统计”
- 先放再查,会把“当前这一位自己”提前算进历史里,语义就乱了
为什么这题不是普通滑动窗口:
- 因为数组里可能有 `负数`
- 一旦有负数,窗口和就不再具有“左缩小一定变小、右扩张一定变大”的单调性
- 所以不能靠滑动窗口那种“和大了就缩、和小了就扩”的方式稳定求解
- 这题真正稳定的思路是:不直接维护窗口和,而是维护“前缀和之间的差”
今天最该记住的分界线:
- `连续子数组 + 求个数 + 可能有负数`
-> 优先想 `前缀和 + HashMap`
- `HashMap` 里不是存下标,也不是存布尔值
-> 存的是 `前缀和 -> 出现次数`
- 这题的答案不是看“当前有没有”
-> 而是看“之前有多少个 `pre - k`”
记住一句话:
- `560 不是滑动窗口题,而是前缀和找差值:当前 pre 想凑出 k,就去 HashMap 里找之前出现过多少次 pre-k`
239.滑动窗口最大值(第一次刷题)
题型信号:
- 输入是数组 `nums`,再给一个固定窗口大小 `k`
- 窗口会从左往右滑动
- 每滑一次,都要立刻知道“当前窗口里的最大值”
- 这题不是普通双指针,也不是固定窗口里暴力找最大值,而是要维护一个“窗口滑动过程中还能竞争最大值的结构”
我这次是怎么判型的:
- 我先抓到的是“滑动窗口”这四个字,所以第一反应是窗口会不断有元素滑出、也不断有元素滑入
- 但这题和 `3`、`438` 不一样,不是维护一个合法性条件,也不是维护频次
- 它要维护的是“最大值”
- 如果每次窗口形成后都重新扫一遍找最大值,虽然思路直接,但复杂度会太高
- 所以这题真正的关键不是“窗口怎么动”,而是“窗口动的时候,怎么让最大值也能被快速维护”
为什么最后会落到单调队列:
- 如果一个新进来的数更大,而且它在更右边
- 那么它左边那些比它小的数,只要它们还和它同时待在窗口里,就永远不可能成为最大值
- 例如队尾是 `3`,新进来的是 `5`
- 只要 `5` 还在窗口中,`3` 就没有任何机会再当最大值
- 所以这些较小元素没有必要继续留在队列里
- 这就说明我们要维护一个“从大到小”的队列,也就是 `单调递减队列`
为什么队列里存的是下标,不是值:
- 因为窗口左边会不断滑出元素
- 我们必须知道队头这个元素“是不是已经不在当前窗口里了”
- 只有存下标,才能判断它是否已经过期
- 所以队列里存的是 `index`
- 但比较大小时,看的是 `nums[index]`
单调队列在这题里的角色:
- 队头:当前窗口最大值的下标
- 队列中下标对应的值:从队头到队尾单调递减
- 这样一来,当前窗口最大值就可以直接用:
`nums[deque.peekFirst()]`
每一轮固定做的 4 件事:
- 1. 先删掉已经滑出窗口左边界的下标
- 条件是:
`deque.peekFirst() < i - k + 1`
- 2. 再删掉队尾那些比当前值小的下标
- 条件是:
`nums[deque.peekLast()] < nums[i]`
- 3. 把当前下标 `i` 放到队尾
- 4. 只有当窗口真正形成后,才开始收集答案
这题最容易错的分界线:窗口“形成之前”和“形成之后”
- 当 `i < k - 1` 时,窗口还没凑够 `k` 个元素
- 这时候队列虽然已经在维护了,但还不能往结果数组里写答案
- 只有当:
`i >= k - 1`
时,当前窗口才第一次完整形成
- 也就是说:
- `i = 0, 1, ... , k-2`:只维护队列,不产出答案
- `i = k-1`:第一个完整窗口出现,开始产出第 1 个答案
为什么答案写在 `res[i - k + 1]`:
- 当遍历到 `i` 时,当前窗口右边界就是 `i`
- 固定长度是 `k`
- 所以当前窗口左边界就是:
`i - k + 1`
- 结果数组里,每个窗口对应一个答案
- 因此当前窗口最大值应该写到:
`res[i - k + 1]`
这次我写代码时暴露出来的坑:
- 我一开始把结果数组写成了:
`new int[k]`
- 但这题窗口一共会产生:
`nums.length - k + 1`
个答案
- 所以结果数组长度必须跟“窗口个数”一致,而不是跟 `k` 一样
第二个坑:
- 我一开始虽然已经知道要用队列,但还不会 Java 里的双端队列写法
- 这题常用写法是:
`Deque<Integer> deque = new LinkedList<>();`
- 重点不是死记 API,而是记住:
- `peekFirst / pollFirst`:看/删队头
- `peekLast / pollLast`:看/删队尾
- `offerLast`:从队尾加入
第三个坑:
- 我一开始把“收集答案”的时机放错了
- 我先写了:
`res[i - k + 1] = nums[deque.peekFirst()]`
- 然后才去删过期元素、删较小队尾、再把当前 `i` 入队
- 这样其实是在“窗口还没更新完”的时候,先拿旧答案
- 正确顺序必须是:
- 先删过期队头
- 再删较小队尾
- 再让当前下标入队
- 最后如果窗口形成了,再取队头作为答案
正确模板骨架:
- 结果数组:
`int[] res = new int[nums.length - k + 1];`
- 双端队列:
`Deque<Integer> deque = new LinkedList<>();`
- 遍历每个下标 `i`
- 先删过期下标:
`if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) deque.pollFirst();`
- 再删掉所有比当前值小的队尾:
`while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) deque.pollLast();`
- 当前下标入队:
`deque.offerLast(i);`
- 如果窗口已经形成:
`if (i >= k - 1) res[i - k + 1] = nums[deque.peekFirst()];`
今天最该记住的分界线:
- `3` 和 `438`:核心是“窗口是否合法”
- `239`:核心不是合法性,而是“窗口里谁还配竞争最大值”
- `239` 里队列不是按进入顺序单纯存元素
- 而是按“未来是否还有资格当最大值”来筛选保留
记住一句话:
- `239 = 用单调递减队列维护窗口候选最大值;窗口没形成时只维护,形成后队头就是答案`
