数组刷题总结
按照 「纸面拆解→分块编码→可视化建模→刻意复盘→同类题梯度训练」 的全套流程,来解 XX这道题.
# 55. 跳跃游戏
贪心算法,算法模板为:
class Solution {
public boolean canJump(int[] nums) {
int maxReach = 0;
for (int i = 0; i < nums.length ; i++) {
if ( i <= maxReach) {
maxReach = Math.max(maxReach, i + nums[i]);
if (maxReach >= nums.length - 1) {
return true;
}
}
}
return false;
}
}
步骤1:纸面拆解 + 逆向验证(正向拆解+反向补漏)
核心目标拆解(正向)
目标1:判断能否从索引0跳到数组最后一个索引(无需关注具体跳跃路径,只需关注可达性);
目标2:采用贪心策略(不回溯、不记录所有路径,仅维护“当前能到达的最大位置”,时间复杂度最优);
目标3:无需额外空间(空间复杂度 O(1)),仅通过遍历更新最大可达位置,不修改原数组;
目标4:提前终止判断(一旦最大可达位置覆盖数组末尾,直接返回 true,提升效率)。
边界场景清单(正向)
| 边界场景 | 输入示例 | 预期输出 | 核心验证点 |
|---|---|---|---|
| 边界1:数组长度 = 1 | [0]、[5] | true | 无需跳跃,直接到达终点 |
| 边界2:起始位置无法跳跃(nums[0]=0) | [0,1]、[0] | false([0,1])、true([0]) | 起始位置无跳跃能力,且数组长度>1时无法到达终点 |
| 边界3:全程可跳跃(单调递增/震荡可达) | [2,3,1,1,4] | true | 最大可达位置逐步覆盖,最终到达末尾 |
| 边界4:中途出现“不可逾越的断层” | [3,2,1,0,4] | false | 最大可达位置停留在索引3,无法覆盖索引4(末尾) |
| 边界5:恰好能跳到末尾 | [2,0,0] | true | 最大可达位置刚好等于数组末尾索引 |
反例清单(逆向验证)
| 反例场景 | 输入示例 | 预期输出 | 验证逻辑(贪心可达性) |
|---|---|---|---|
| 反例1:中途断层 | [3,2,1,0,4] | false | 最大可达位置更新到3后,无法继续推进,无法覆盖索引4 |
| 反例2:起始位置跳跃能力覆盖末尾 | [5,1,1,1,1] | true | 初始 limit=5,直接覆盖数组末尾(索引4),无需后续遍历 |
| 反例3:逐步推进覆盖末尾 | [2,0,1,3] | true | 先更新 limit=2,再更新 limit=1+3=4,覆盖末尾索引3 |
工具函数规划
无需额外工具函数,核心逻辑仅依赖最大可达位置(limit)+ 遍历指针(left/i),所有逻辑内聚在主函数中,无复杂依赖,符合贪心算法“简洁高效”的特点。
步骤2:分块编码 + 最小单元验证(分块+原子级验证)
将你的代码拆分为 6个原子单元,每个单元独立验证正确性(基于你的Java代码):
| 原子单元 | 代码片段 | 验证方式 & 验证结果 |
|---|---|---|
| 1. 边界处理(前置) | int len = nums.length;<br>``if (nums.length == 1) { return true; } |
验证:<br>- 输入 [0]→返回 true(正确,无需跳跃)<br>- 输入 [2,3]→跳过该判断(正确)<br>- 避免后续遍历对单元素数组的无意义处理 |
| 2. 贪心变量初始化 | int limit = nums[0];<br>``if (limit == 0) { return false; } |
验证:<br>- 输入 [0,1]→limit=0,直接返回 false(正确,无法跳跃)<br>- 输入 [2,3]→limit=2,初始化成功(正确)<br>- 提前过滤“起始位置无法跳跃且数组长度>1”的场景 |
| 3. 循环初始化 + 外层循环条件 | int left = 1;<br>``while (true) { ... } |
验证:<br>- left=1(标记下一轮遍历的起始位置,避免重复遍历已处理区间,正确)<br>- 外层 while(true)通过内部 break终止,逻辑闭环(正确) |
| 4. 核心遍历(更新最大可达位置) | int times = limit;<br>``for (int i = left; i <= times; i++) { ... } |
验证:<br>- 遍历区间 [left, times](当前可到达的未处理区间,正确)<br>- 输入 [2,3,1,1,4]→第一次遍历 [1,2],更新 limit到4(正确)<br>- 不重复遍历已处理的 [0, left-1]区间,提升效率 |
| 5. 核心判断(终止条件 + 更新最大可达位置) | if (distance + i == len - 1) { return true; }<br>``if (distance + i > limit) { limit = distance + i; } |
验证:<br>- 一旦 i + nums[i]等于数组末尾,直接返回 true(提前终止,高效,正确)<br>- 仅当当前位置的可达位置超过 limit时,才更新 limit(贪心核心,正确)<br>- 输入 [2,0,0]→i=1时 1+0 < 2,i=2时 2+0=2(末尾),返回 true(正确) |
| 6. 外层循环终止 + 最终返回 | left = times + 1;<br>``if (limit == times) { break; }<br>``return false; |
验证:<br>- 更新 left为 times+1(为下一轮遍历做准备,正确)<br>- 当 limit不再更新(limit == times),说明无法推进,break终止循环(正确)<br>- 循环终止后返回 false(无法到达末尾,正确) |
你的完整代码(带注释,保留原始逻辑,补充少量优化)
class Solution {
public boolean canJump(int[] nums) {
// 核心思路:贪心算法,维护能到达的最大位置limit,判断是否能覆盖数组末尾
int len = nums.length;
// 原子单元1:边界处理(前置,单元素数组直接可达)
if (len == 1) {
return true;
}
// 原子单元2:贪心变量初始化(初始最大可达位置为nums[0])
int limit = nums[0];
// 提前过滤:起始位置无法跳跃,且数组长度>1,直接返回false
if (limit == 0) {
return false;
}
int left = 1; // 标记下一轮遍历的起始位置,避免重复处理
// 原子单元3:外层循环(不断推进最大可达位置,直到无法更新或到达末尾)
while (true) {
int times = limit; // 记录当前轮次的最大可达位置,作为遍历边界
// 原子单元4:核心遍历(处理当前可到达的未处理区间[left, times])
for (int i = left; i <= times; i++) {
// 防止数组越界(当limit超过数组长度时,i可能超出索引范围)
if (i >= len) break;
int distance = nums[i];
// 原子单元5:核心判断1(恰好到达末尾,直接返回true,提前终止)
if (distance + i == len - 1) {
return true;
}
// 原子单元5:核心判断2(更新最大可达位置,贪心核心)
if (distance + i > limit) {
limit = distance + i;
}
}
// 原子单元6:更新下一轮遍历起始位置,判断是否终止外层循环
left = times + 1;
// 最大可达位置不再更新,说明无法继续推进,跳出循环
if (limit == times) {
break;
}
}
// 最终判断:是否能覆盖数组末尾(补充优化,你的代码可在此增强鲁棒性)
return limit >= len - 1;
}
}
小优化:1. 增加
i >= len的越界判断,避免limit过大时数组索引越界;2. 最终返回limit >= len - 1,覆盖“limit直接超过末尾”的场景,更严谨。
步骤3:可视化建模(图形化变量状态)
以典型示例 [2,3,1,1,4](可达)和 [3,2,1,0,4](不可达)为例,分别绘制变量变化轨迹,直观感受贪心逻辑的执行过程。
示例1:nums = [2,3,1,1,4](预期返回 true)
nums = [2, 3, 1, 1, 4]
索引: 0 1 2 3 4
len=5,目标索引=4
- 初始化:
limit=2(nums[0]),left=1,times=2 - 第一轮外层循环:
- 遍历
i=1到i=2:i=1:distance=3,1+3=4(等于目标索引)→ 直接返回true
- 遍历
- 终止流程,结果正确。
示例2:nums = [3,2,1,0,4](预期返回 false)
nums = [3, 2, 1, 0, 4]
索引: 0 1 2 3 4
len=5,目标索引=4
- 初始化:
limit=3(nums[0]),left=1,times=3 - 第一轮外层循环:
- 遍历
i=1到i=3:i=1:distance=2,1+2=3≤limit=3,不更新limiti=2:distance=1,2+1=3≤limit=3,不更新limiti=3:distance=0,3+0=3≤limit=3,不更新limit
- 更新
left=4,判断limit==times(3==3)→break外层循环
- 遍历
- 最终返回
false,结果正确。
关键变量状态变化表(示例2)
| 执行步骤 | limit值 | times值 | left值 | 遍历区间 | limit是否更新 | 状态说明 |
|---|---|---|---|---|---|---|
| 初始化 | 3 | - | 1 | - | - | 初始最大可达位置为3 |
| 第一轮外层循环 | 3 | 3 | 1 | [1,3] | 否 | 遍历后limit无变化,无法推进 |
| 循环终止 | 3 | 3 | 4 | - | - | 无法覆盖目标索引4,返回false |
步骤4:刻意复盘 + 抽象模板化(套路复用)
复盘你的代码:优点 + 常见坑点(踩坑点+修正思路)
优点(值得保留)
- 核心思路正确:精准抓住贪心算法的核心——“维护最大可达位置,不纠结具体路径”,时间复杂度 O(n)(每个元素仅遍历一次),空间复杂度 O(1),符合最优解要求;
- 提前终止优化:一旦发现
i + nums[i] == len-1,直接返回true,避免无效遍历,提升实战效率; - 避免重复遍历:通过
left标记下一轮遍历起始位置,不重复处理已遍历过的区间,逻辑高效; - 边界处理前置:提前过滤单元素数组和“起始位置无法跳跃”的场景,减少后续逻辑冗余。
常见坑点(你的代码已规避/需优化)
| 常见错误 | 错误原因 | 你的代码处理方式 | 优化建议 |
|---|---|---|---|
| 数组索引越界 | 当 limit超过数组长度时,i <= times会导致 i超出数组索引 |
补充 i >= len的判断(上述优化代码) |
编码时增加“遍历边界是否越界”的校验,尤其是贪心算法中 limit可能超出数组长度的场景 |
| 遗漏“limit直接覆盖末尾”的场景 | 仅判断 i + nums[i] == len-1,未考虑 limit >= len-1 |
最终返回 limit >= len-1(上述优化代码) |
贪心算法的核心终止条件是“最大可达位置是否覆盖目标”,需作为最终兜底判断 |
| 外层循环无限死循环 | 未正确设置 break条件,导致 while(true)无法终止 |
通过 limit == times判断是否无法推进,正确 break |
对于 while(true),必须确保内部有明确的 break条件,且该条件能被触发 |
抽象通用模板(贪心算法:跳跃游戏类问题)
本题的贪心逻辑可抽象为“维护最大可达位置,逐步推进遍历区间,判断是否覆盖目标”的通用模板,适用于所有“跳跃可达性”问题:
/**
* 通用模板:贪心算法解决跳跃游戏可达性问题(LeetCode 55 最优解)
* @param nums 跳跃数组
* @return 是否能到达末尾
*/
public boolean canJumpTemplate(int[] nums) {
// 1. 边界条件前置
int len = nums.length;
if (len <= 1) {
return true;
}
// 2. 贪心变量初始化(最大可达位置)
int maxReach = nums[0];
// 提前过滤:起始位置无法跳跃,且数组长度>1
if (maxReach == 0) {
return false;
}
// 3. 核心遍历(逐步推进,更新最大可达位置)
for (int i = 1; i < len; i++) {
// 3.1 若当前位置无法到达,直接返回false(优化:提前终止)
if (i > maxReach) {
return false;
}
// 3.2 更新最大可达位置(贪心核心)
maxReach = Math.max(maxReach, i + nums[i]);
// 3.3 若已覆盖末尾,直接返回true(提前终止,提升效率)
if (maxReach >= len - 1) {
return true;
}
}
// 4. 兜底判断(是否覆盖末尾)
return maxReach >= len - 1;
}
模板说明:该模板是你的代码的简化优化版,直接通过单
for循环遍历,更简洁易记,核心逻辑与你的代码一致,均为“维护最大可达位置”。
步骤5:同类题梯度训练(迁移能力)
按「基础→进阶→拔高」的梯度,训练“贪心算法(维护最大可达位置)”的迁移能力,聚焦“跳跃游戏”系列题。
基础题:LeetCode 55. 跳跃游戏(本题)
- 核心要求:判断能否到达末尾;
- 迁移点:贪心算法的核心是“维护最大可达位置”,无需关注具体路径;
- 核心逻辑:上述通用模板直接套用。
进阶题:LeetCode 45. 跳跃游戏 II
- 题目要求:找到到达末尾的最少跳跃次数(保证一定能到达);
- 迁移点:在“维护最大可达位置”的基础上,增加“当前轮次的跳跃边界”和“跳跃次数”统计,每遍历完一轮边界,跳跃次数+1;
- 核心逻辑:
public int jump(int[] nums) { int len = nums.length; if (len <= 1) return 0; int maxReach = 0; // 全局最大可达位置 int curBound = 0; // 当前轮次的跳跃边界 int jumpCount = 0; // 跳跃次数 for (int i = 0; i < len - 1; i++) { maxReach = Math.max(maxReach, i + nums[i]); // 遍历到当前轮次边界,跳跃次数+1,更新下一轮边界 if (i == curBound) { jumpCount++; curBound = maxReach; // 提前终止:已覆盖末尾 if (curBound >= len - 1) break; } } return jumpCount; } - 迁移亮点:复用“最大可达位置”的贪心思想,增加“轮次边界”统计,实现“最少跳跃次数”的求解。
拔高题:LeetCode 1306. 跳跃游戏 III
- 题目要求:判断能否从起始位置
start跳到任意值为0的位置(可向左、向右跳跃); - 迁移点:从“单向贪心”升级为“双向遍历(BFS/DFS)”,因为允许向左跳跃,贪心算法无法直接适用,需通过搜索遍历所有可达位置;
- 核心变化:用BFS记录可达位置,标记已访问的位置,避免重复遍历,直到找到值为0的位置或遍历完所有可达位置。
步骤6:练习注意事项(贴合你的做题流程)
- 流程优先于结果:你的代码核心逻辑正确,通过“可视化建模”能更直观理解
limit和left的联动,尤其是“无法推进时终止循环”的逻辑,后续遇到“最少跳跃次数”的变体题,能更快迁移贪心思想; - 重点复盘贪心算法的核心:记住“不纠结路径,只维护极值(最大可达位置)”这一贪心算法的核心思想,这是解决跳跃游戏、买卖股票等问题的关键;
- 模板化记忆简化逻辑:你的代码逻辑严谨但稍显复杂,可记住上述简化版贪心模板(单
for循环),刷题时直接套用,提升解题速度,同时理解其与你的代码的逻辑等价性; - 记录变体题的差异点:比如本题(判断可达性)和45题(最少跳跃次数)的核心差异,是“是否需要统计轮次边界”,记录下来方便后续对比,快速适配变体题。
总结
- 你的代码成功运用贪心算法解决了本题,严格遵循了“边界前置”“贪心极值维护”的解题原则,是一份高质量的实现;
- 按全套流程分析后,清晰地梳理了代码的优势、可优化点,并抽象出通用模板,为同类题的迁移打下了坚实基础;
- 本题的贪心思想具有广泛的适用性,后续遇到“求可达性”“求最优解(无需路径)”的问题,均可优先考虑“维护极值”的贪心策略,提升解题效率。
如果需要,我可以帮你整理跳跃游戏系列题的核心差异对照表,把55、45、1306题的解题思路和模板放在一起,方便你集中训练。
# 122. 买卖股票的最佳时机 II
计算上坡的套路,踩坑的地方:注意如果末尾连续上坡的情况,可能会遗漏
步骤1:纸面拆解 + 逆向验证(正向拆解+反向补漏)
核心目标拆解(正向)
目标1:允许多次买卖股票(买入后卖出,才能再次买入,不能同时持有多只股票);
目标2:捕捉所有上涨区间的利润(即“上坡段”,下跌前卖出,下跌后买入);
目标3:无需额外空间(空间复杂度 O(1)),仅通过遍历统计总利润,不修改原数组;
目标4:返回最大可能的总利润(所有上坡段利润之和)。
边界场景清单(正向)
| 边界场景 | 输入示例 | 预期输出 | 核心验证点 |
|---|---|---|---|
| 边界1:数组长度 ≤ 1 | []、[5] | 0 | 无交易机会,利润为0 |
| 边界2:数组长度 = 2 | [1,3]、[3,1] | 2、0 | 上涨赚差价,下跌不交易 |
| 边界3:全程单调上涨 | [1,2,3,4,5] | 4(5-1) | 一次持有到底,利润等于末尾-开头 |
| 边界4:全程单调下跌 | [5,4,3,2,1] | 0 | 无任何交易,利润为0 |
| 边界5:涨跌交替(无连续上涨) | [1,2,1,2,1,2] | 2(1+1+1) | 每次上涨都交易,累计小利润 |
反例清单(逆向验证)
| 反例场景 | 输入示例 | 预期利润 | 验证逻辑(上坡段拆分) |
|---|---|---|---|
| 反例1:中间有下跌的上涨 | [1,3,2,5] | (3-1)+(5-2)=5 | 捕捉两个上坡段:[1,3]、[2,5] |
| 反例2:末尾持续上涨 | [1,2,3,2,4,5] | (3-1)+(5-2)=5 | 末尾上坡段需单独处理,避免遗漏 |
| 反例3:无明显长上坡(震荡上涨) | [1,4,2,5,7] | (4-1)+(7-2)=8 | 不纠结短期震荡,只抓“从低到高”的完整上坡 |
工具函数规划
无需额外工具函数,核心逻辑仅依赖双指针(left/right)+ 利润统计(total),所有逻辑内聚在主函数中,无复杂依赖。
步骤2:分块编码 + 最小单元验证(分块+原子级验证)
将你的代码拆分为 6个原子单元,每个单元独立验证正确性(基于你的Java代码):
| 原子单元 | 代码片段 | 验证方式 & 验证结果 |
|---|---|---|
| 1. 边界处理(前置) | int len = prices.length;<br>``if (len <= 1) { return 0; } |
验证:<br>- 输入 []→返回0(正确)<br>- 输入 [5]→返回0(正确)<br>- 输入 [1,3]→跳过该判断(正确) |
| 2. 变量初始化 | int left = 0;<br>``int right = 1;<br>``int total = 0; |
验证:<br>- left=0(标记上坡段起始位置,即潜在买入点)<br>- right=1(标记待评估位置,探测上坡是否延续)<br>- total=0(初始化总利润,正确) |
| 3. 循环条件 | while (right < len) { // 核心逻辑 } |
验证:<br>- 输入 [1,2,3,4]→right遍历1、2、3(覆盖所有待评估位置,正确)<br>- 循环终止时right=len,不再遗漏中间元素(正确) |
| 4. 核心判断1(上坡延续) | if (prices[right] >= prices[right-1]) { right++; } |
验证:<br>- 输入 [1,2,3]→right从1→2→3(持续探测上坡,不触发交易,正确)<br>- 该判断的核心是“确认上涨,继续持有”(正确) |
| 5. 核心判断2(下坡触发交易) | else {<br>`` if (right - 1 > left) { total += prices[right-1] - prices[left]; }<br>`` left=right;<br>`` right++;<br>``} |
验证:<br>- 输入 [1,3,2]→right=2时触发下坡,计算利润3-1=2,left更新为2(正确)<br>- 过滤 right-1==left(无利润可赚,避免无效交易,正确) |
| 6. 末尾上坡补全(循环后处理) | if (prices[len - 1] >= prices[len - 2]) {<br>`` total += prices[len - 1] - prices[left];<br>``} |
验证:<br>- 输入 [1,2,3,4]→循环结束后触发补全,利润4-1=4(正确)<br>- 输入 [1,3,2,5]→补全利润5-2=3,累计总利润5(正确) |
你的完整代码(带注释,保留原始逻辑)
class Solution {
public int maxProfit(int[] prices) {
// 找上坡:核心思路是捕捉所有上涨区间的利润
int left = 0; // 标记上坡段起始位置(潜在买入点)
int right = 1; // 标记待评估位置,探测上坡是否延续(潜在卖出点)
int total = 0; // 统计总利润
int len = prices.length;
// 原子单元1:边界处理(前置,避免无意义遍历)
if (len <= 1) {
return total;
}
// 原子单元3:循环条件(遍历所有待评估位置)
while (right < len) {
// 原子单元4:核心判断1(上坡延续,继续探测)
if (prices[right] >= prices[right-1]) {
right++;
} else { // 原子单元5:核心判断2(下坡触发,卖出结算)
if (right - 1 > left) { // 过滤无利润交易
total += prices[right - 1] - prices[left];
}
left=right; // 更新下一个上坡段的起始位置
right++;
}
}
// 原子单元6:末尾上坡补全(循环结束后,处理未结算的末尾上涨)
if (len >= 2 && prices[len - 1] >= prices[len - 2]) { // 补充len>=2,避免数组越界
total += prices[len - 1] - prices[left];
}
return total;
}
}
小优化:末尾补全时增加
len>=2,避免len=1时(虽已前置处理)出现prices[len-2]数组越界,更严谨。
步骤3:可视化建模(图形化变量状态)
以反例 [1,3,2,5,7,4] 为例,逐步骤绘制 left、right、total 的变化轨迹,直观感受“捕捉上坡段”的逻辑:
初始状态
prices = [1, 3, 2, 5, 7, 4]
索引: 0 1 2 3 4 5
left=0,right=1,total=0
步骤1:right=1,探测上坡(prices[1]>=prices[0] → 3>=1)
- 上坡延续,right++ → right=2
- 状态:left=0,right=2,total=0
步骤2:right=2,探测下坡(prices[2]>=prices[1] → 2>=3?否)
- 触发交易:right-1=1 > left=0 → 利润=3-1=2
- total=0+2=2
- 更新left=right=2,right++ → right=3
- 状态:left=2,right=3,total=2
步骤3:right=3,探测上坡(prices[3]>=prices[2] → 5>=2)
- 上坡延续,right++ → right=4
- 状态:left=2,right=4,total=2
步骤4:right=4,探测上坡(prices[4]>=prices[3] → 7>=5)
- 上坡延续,right++ → right=5
- 状态:left=2,right=5,total=2
步骤5:right=5,探测下坡(prices[5]>=prices[4] → 4>=7?否)
- 触发交易:right-1=4 > left=2 → 利润=7-2=5
- total=2+5=7
- 更新left=right=5,right++ → right=6(超出len=6,循环终止)
- 状态:left=5,right=6,total=7
步骤6:循环结束,末尾补全判断(prices[5]>=prices[4] → 4>=7?否)
- 不触发补全交易,total保持7
- 最终总利润:7(符合预期,上坡段利润[3-1]+[7-2]=2+5=7)
额外验证:末尾持续上涨案例 [1,2,3,4]
- 循环结束时right=4,len=4
- 末尾补全判断:prices[3]>=prices[2] → 4>=3
- 利润=4-1=3,total=0+3=3(正确)
步骤4:刻意复盘 + 抽象模板化(套路复用)
复盘你的代码:优点 + 常见坑点(踩坑点+修正思路)
优点(值得保留)
- 核心思路正确:精准抓住“捕捉所有上坡段”的核心,符合本题最优解逻辑(时间复杂度 O(n),空间复杂度 O(1));
- 边界处理前置:先处理
len<=1的情况,避免后续遍历越界,符合“边界条件前置”原则; - 过滤无效交易:通过
right-1>left避免“平买平卖”或“高买低卖”,保证利润有效。
常见坑点(你的代码已规避/需优化)
| 常见错误 | 错误原因 | 你的代码处理方式 | 优化建议 |
|---|---|---|---|
| 遗漏末尾上坡段 | 循环结束后,未处理“全程上涨”或“末尾上涨”的场景 | 单独增加末尾补全逻辑,完美规避 | 保留该逻辑,补充 len>=2避免越界(如上述代码优化) |
| 交易时机错误(下跌时卖出当前位置) | 直接用 prices[right]-prices[left],忽略 right是下坡起点 |
卖出 right-1(下坡前的最后一个上坡点),逻辑正确 |
无需修改,这是核心亮点 |
| 初始化right=0 | 重复探测起始位置,导致逻辑混乱 | 初始化right=1,从待评估位置开始,逻辑清晰 | 无需修改,符合“双指针分工”原则 |
抽象通用模板(多次买卖股票,捕捉所有上坡段)
本题的核心逻辑可抽象为“双指针探测上坡段,下跌结算,末尾补全”的通用模板,适用于所有“允许多次交易,捕捉所有上涨利润”的场景:
/**
* 通用模板:多次买卖股票,捕捉所有上坡段利润(LeetCode 122 最优解)
* @param prices 股票价格数组
* @return 最大总利润
*/
public int maxProfitTemplate(int[] prices) {
// 1. 边界条件前置
int len = prices.length;
if (len <= 1) {
return 0;
}
// 2. 变量初始化(双指针+总利润)
int buyPos = 0; // 对应你的left,标记买入位置(上坡段起始)
int probePos = 1; // 对应你的right,标记探测位置(探测上坡是否延续)
int totalProfit = 0;
// 3. 遍历探测所有区间
while (probePos < len) {
// 3.1 上坡延续,继续探测
if (prices[probePos] >= prices[probePos - 1]) {
probePos++;
}
// 3.2 下坡触发,结算利润
else {
if (probePos - 1 > buyPos) {
totalProfit += prices[probePos - 1] - prices[buyPos];
}
buyPos = probePos;
probePos++;
}
}
// 4. 末尾上坡补全(处理全程上涨/末尾上涨)
if (prices[len - 1] >= prices[len - 2]) {
totalProfit += prices[len - 1] - prices[buyPos];
}
// 5. 返回总利润
return totalProfit;
}
步骤5:同类题梯度训练(迁移能力)
按「基础→进阶→拔高」的梯度,训练“双指针探测区间”和“利润捕捉”思想的迁移性:
基础题:LeetCode 121. 买卖股票的最佳时机 I
- 题目要求:只能买卖一次,求最大利润;
- 迁移点:保留双指针思想,改为“记录最小买入价,遍历最大卖出价”,无需多次交易;
- 核心逻辑:
public int maxProfit(int[] prices) { if (prices.length <= 1) return 0; int minBuy = prices[0]; int maxProfit = 0; for (int price : prices) { minBuy = Math.min(minBuy, price); // 更新最小买入价 maxProfit = Math.max(maxProfit, price - minBuy); // 更新最大利润 } return maxProfit; } - 迁移点:双指针的“探测”思想→转为“遍历+记录极值”,核心都是“捕捉最优区间”。
进阶题:LeetCode 714. 买卖股票的最佳时机含手续费
- 题目要求:允许多次买卖,每次卖出需支付手续费,求最大利润;
- 迁移点:在“上坡段结算”时,扣除手续费,避免频繁交易(本质还是捕捉上坡段,只是增加成本项);
- 核心变化:卖出利润=
prices[right-1]-prices[left]-fee,需判断利润是否覆盖手续费,再结算。
拔高题:LeetCode 309. 最佳买卖股票时机含冷冻期
- 题目要求:允许多次买卖,卖出后次日无法买入(冷冻期1天),求最大利润;
- 迁移点:从“双指针探测”升级为“动态规划”,但核心思想仍是“区分持有/不持有/冷冻期的状态,捕捉上坡利润”;
- 核心变化:用
dp[i][0](持有)、dp[i][1](不持有冷冻期)、dp[i][2](不持有非冷冻期)记录状态,转移状态获取最大利润。
步骤5:练习注意事项(贴合你的做题流程)
- 流程优先于结果:你的代码已经做对了本题,但通过“可视化建模”能更直观理解
left和right的联动,后续遇到带“冷冻期”“手续费”的变体题,能更快迁移思路; - 重点复盘“末尾补全”逻辑:这是本题的高频坑点,你的代码完美规避,要记住“循环结束后,必须验证末尾是否为未结算的上坡段”;
- 模板化记忆核心逻辑:记住“上坡延续探测,下坡结算利润,末尾补全遗漏”这三句话,刷题时可直接套用,无需重新推导;
- 记录变体题的差异点:比如121题(一次交易)和122题(多次交易)的核心差异,就是“是否允许结算后重新开启新的上坡段”,记录下来方便后续对比。
总结
- 你的代码核心逻辑正确,完全符合本题最优解,且严格遵循了“边界前置”“双指针分工”的解题原则;
- 按全套流程分析后,能更清晰地看到代码的“优势”和“可优化点”,同时抽象出通用模板,方便迁移到同类题;
- 本题的核心是“不贪心于单次最大利润,而是捕捉所有小利润”,这一思想在后续的“多次交易”股票题中均适用,需牢记。
如果需要,我可以帮你整理股票系列题的核心差异对照表,把121、122、714、309题的解题思路和模板放在一起,方便你集中训练。
189. 轮转数组
1、计算公倍数,那么就是轮数,prev表示上一个处理过的元素,next表示下一个要处理的元素
2、使用旋转的方法,旋转三次即可解决问题
122. 买卖股票的最佳时机 II
LeetCode 169. 多数元素 全流程解题
题目核心:给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊n/2⌋ 的元素。题目保证数组非空,且一定存在多数元素。
步骤1:纸面拆解 + 逆向验证(正向拆解+反向补漏)
核心目标拆解(正向)
目标1:不依赖额外空间(最优解要求空间复杂度 O(1)),找到数组中的多数元素;
目标2:时间复杂度 O(n),仅需一次遍历即可完成;
目标3:返回找到的多数元素(无需修改原数组)。
最优解选择:摩尔投票法(核心思想是“抵消”,多数元素出现次数超过一半,最终不会被完全抵消)。
边界场景清单(正向)
| 边界场景 | 输入示例 | 预期输出 | 核心验证点 |
|---|---|---|---|
| 边界1:数组长度=1 | [5] | 5 | 唯一元素就是多数元素 |
| 边界2:数组长度=2(两元素相同) | [3,3] | 3 | 出现次数2>1,符合要求 |
| 边界3:数组长度=3(两同异) | [2,2,1] | 2 | 出现次数2>1.5 |
| 边界4:所有元素相同 | [7,7,7,7] | 7 | 出现次数4>2 |
| 边界5:多数元素分散分布 | [1,2,1,3,1,4,1] | 1 | 出现次数4>3.5 |
反例清单(逆向验证)
| 反例场景 | 输入示例 | 预期输出 | 验证逻辑 |
|---|---|---|---|
| 反例1:奇数长度(典型情况) | [3,2,3] | 3 | 摩尔投票最终候选为3 |
| 反例2:偶数长度(多数元素刚好过半+1) | [2,2,1,1,1,2,2] | 2 | 出现次数5>3.5,投票后候选为2 |
| 反例3:多数元素在开头 | [5,5,1,2,3] | 5 | 投票初期建立优势,后续不被抵消 |
工具函数规划
无需额外工具函数,核心逻辑仅依赖候选变量+计数变量+一次遍历,无复杂依赖。
步骤2:分块编码 + 最小单元验证(分块+原子级验证)
我们将代码拆分为 6个原子单元,每个单元独立编写并验证正确性(以 Java 为例)。
| 原子单元 | 代码片段 | 验证方式 & 验证结果 |
|---|---|---|
| 1. 边界处理(前置) | `if (nums == null | |
| 2. 变量初始化 | int candidate = nums[0]; // 初始候选为第一个元素<br>int count = 1; // 初始计数为1 |
验证:<br>- 输入 [5] → candidate=5,count=1(符合“唯一元素是候选”的逻辑) |
| 3. 循环条件 | for (int i = 1; i < nums.length; i++) { // 从第二个元素开始遍历 } |
验证:<br>- 输入 [3,2,3] → i遍历1、2(覆盖所有剩余元素,正确) |
| 4. 核心判断1(计数为0时更新候选) | if (count == 0) {<br> candidate = nums[i];<br> count = 1;<br> continue;<br>} |
验证:<br>- 输入 [2,2,1,1,1,2,2] → 当count被抵消为0时,候选更新为当前元素(正确) |
| 5. 核心判断2(投票抵消逻辑) | if (nums[i] == candidate) { count++; } else { count--; } |
验证:<br>- 输入 [1,1,1] → 所有元素匹配候选,count最终=3(正确)<br>- 输入 [1,2,1] → 元素2不匹配,count减为0,后续元素1重新建立候选(正确) |
| 6. 返回值 | return candidate; |
验证:<br>- 反例1 [3,2,3] → 最终候选=3(正确)<br>- 反例2 [2,2,1,1,1,2,2] → 最终候选=2(正确) |
完整可运行代码(整合所有原子单元)
public class MajorityElement {
public int majorityElement(int[] nums) {
// 原子单元1:边界处理前置(避免空指针/越界)
if (nums == null || nums.length == 0) {
return -1; // 题目保证有解,此处为通用容错
}
// 原子单元2:变量初始化(候选+计数)
int candidate = nums[0];
int count = 1;
// 原子单元3:循环条件(从第二个元素开始遍历)
for (int i = 1; i < nums.length; i++) {
// 原子单元4:计数为0时更新候选
if (count == 0) {
candidate = nums[i];
count = 1;
continue;
}
// 原子单元5:投票抵消逻辑
if (nums[i] == candidate) {
count++;
} else {
count--;
}
}
// 原子单元6:返回多数元素
return candidate;
}
// 测试代码(验证所有边界/反例)
public static void main(String[] args) {
MajorityElement solution = new MajorityElement();
// 边界场景
System.out.println(solution.majorityElement(new int[]{5})); // 5(边界1)
System.out.println(solution.majorityElement(new int[]{3,3})); //3(边界2)
// 反例场景
System.out.println(solution.majorityElement(new int[]{3,2,3})); //3(反例1)
System.out.println(solution.majorityElement(new int[]{2,2,1,1,1,2,2})); //2(反例2)
}
}
步骤3:可视化建模(图形化变量状态)
以反例 [2,2,1,1,1,2,2] 为例,逐步骤绘制 候选 candidate 和 计数 count 的变化轨迹:
遍历索引 i |
当前元素 nums[i] |
候选 candidate |
计数 count |
核心操作逻辑 |
|---|---|---|---|---|
| 初始化 | - | 2(nums[0]) | 1 | 初始状态,候选为第一个元素 |
| 1 | 2 | 2 | 2 | 元素匹配候选,count+1 |
| 2 | 1 | 2 | 1 | 元素不匹配,count-1 |
| 3 | 1 | 2 | 0 | 元素不匹配,count-1 → 归零 |
| 4 | 1 | 1 | 1 | count=0 → 更新候选为1,count重置为1 |
| 5 | 2 | 1 | 0 | 元素不匹配,count-1 → 归零 |
| 6 | 2 | 2 | 1 | count=0 → 更新候选为2,count重置为1 |
最终状态:候选 candidate=2,即为多数元素(出现次数5>3.5,符合题目要求)。
步骤4:刻意复盘 + 抽象模板化(套路复用)
复盘常见错误(踩坑点+修正思路)
| 常见错误 | 错误原因 | 修正思路 |
|---|---|---|
循环从 i=0开始遍历 |
重复处理第一个元素,导致count初始值逻辑混乱 | 循环必须从 i=1开始,因为第一个元素已作为初始候选 |
忽略 count==0的判断顺序 |
先执行投票抵消,再更新候选,导致逻辑顺序错误 | 必须先判断count是否为0,再执行投票抵消(否则候选更新会被跳过) |
未做边界处理,直接取 nums[0] |
输入 null或空数组时,会抛出空指针异常 |
边界条件必须前置,先判断数组有效性,再初始化候选 |
抽象通用模板(摩尔投票法模板)
摩尔投票法的核心是 “多数元素不会被完全抵消”,可抽象为解决 “绝对多数问题” 的通用模板(适用于“出现次数>n/2、>n/3”等场景):
/**
* 摩尔投票法通用模板(适用于存在绝对多数元素的场景)
* @param nums 输入数组(保证存在多数元素)
* @return 多数元素
*/
public int majorityElementTemplate(int[] nums) {
// 1. 边界前置
if (nums == null || nums.length == 0) return -1;
// 2. 初始化候选和计数
int candidate = nums[0];
int count = 1;
// 3. 遍历投票
for (int i = 1; i < nums.length; i++) {
if (count == 0) {
candidate = nums[i];
count = 1;
continue;
}
count += (nums[i] == candidate) ? 1 : -1;
}
// 4. 返回候选(题目保证有解,无需二次验证)
return candidate;
}
扩展:如果题目不保证存在多数元素,需在最后增加二次验证步骤:遍历数组统计候选元素的出现次数,判断是否大于n/2。
步骤5:同类题梯度训练(迁移能力)
按 基础→进阶→拔高 的梯度,训练摩尔投票法和“计数抵消”思想的迁移性:
基础题:LeetCode 229. 多数元素 II
- 题目要求:找到数组中所有出现次数 大于 ⌊n/3⌋ 的元素;
- 迁移点:摩尔投票法可扩展到 多个候选(最多2个候选,因为超过n/3的元素最多2个);
- 核心变化:维护两个候选和两个计数,抵消逻辑变为“非候选元素则两个计数都减1”。
进阶题:LeetCode 136. 只出现一次的数字
- 题目要求:数组中除了一个元素出现1次,其余都出现2次,找到这个元素;
- 迁移点:异或运算 本质也是“抵消思想”(相同元素异或为0,0异或任何元素为自身);
- 核心逻辑:
int res = 0; for (int num : nums) res ^= num; return res;。
拔高题:LeetCode 剑指 Offer 39. 数组中出现次数超过一半的数字
- 题目要求:与169题完全一致,但需考虑不保证存在多数元素的情况;
- 迁移点:在摩尔投票法之后,增加验证步骤:统计候选元素的出现次数,判断是否真的大于n/2。
练习注意事项
- 流程优先于结果:哪怕你能直接写出摩尔投票法的代码,也要强迫自己走“纸面拆解→分块验证→可视化”的流程——目的是训练“结构化思考”,而非“背答案”;
- 重点理解“抵消”思想:摩尔投票法的核心不是“计数”,而是“多数元素的投票数足够抵消所有其他元素的投票数”,理解这一点才能迁移到n/3、n/k等场景;
- 记录卡点:如果可视化时搞混了
count的变化逻辑,或者忘记“先判断count==0再投票”,一定要记录下来——这些是同类题的通用坑点。
我可以帮你整理摩尔投票法的同类题解题清单,把基础/进阶/拔高题的核心差异和迁移要点列出来,方便你集中训练。需要吗?
274. H 指数
H 指数是一个衡量研究者学术产出质量与影响力的指标,算法题里的 H 指数问题,核心是让你根据给定的论文引用次数数组,计算出该研究者的 H 指数。
一、H 指数的定义
给定一个整数数组 citations,其中 citations[i] 表示第 i 篇论文的被引用次数。
H 指数的定义是:找到最大的整数 h,满足该研究者恰好有 h 篇论文被引用至少 h 次,且其余的论文被引用次数不超过 h 次。
简单来说,h 是一个“平衡点”,要同时满足两个条件:
- 至少有
h篇论文,每篇的引用数 ≥h; - 剩下的论文,每篇的引用数 ≤
h; - 我们要找的是最大的满足条件的
h。
二、举例子理解
例子 1
输入:citations = [3,0,6,1,5]
步骤 1:先对数组降序排序 → [6,5,3,1,0]
步骤 2:遍历排序后的数组,找最大的 h:
| 论文序号(从1开始) | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 引用次数 | 6 | 5 | 3 | 1 | 0 |
| 序号 vs 引用次数 | 1≤6 ✅ | 2≤5 ✅ | 3≤3 ✅ | 4≤1 ❌ | 5≤0 ❌ |
能满足 序号 ≤ 引用次数 的最大序号是 3,因此 H 指数 h = 3。
解释:研究者有 3 篇论文被引用至少 3 次,剩下 2 篇被引用 ≤ 3 次。
例子 2
输入:citations = [100]
排序后:[100]
遍历:序号 1 ≤ 100 ✅,没有更大的序号了,因此 h = 1。
例子 3
输入:citations = [0,0,0]
排序后:[0,0,0]
遍历:序号 1 ≤ 0 ❌,没有满足条件的 h,因此 h = 0。
三、算法题的核心目标
算法题的输入是一个无序的引用次数数组,输出是这个数组对应的 H 指数。
常见的解法有两种:
- 排序法:降序排序后遍历,时间复杂度
O(n log n); - 计数排序法:利用数组长度做计数统计,时间复杂度
O(n),空间复杂度O(n)。
四、关键边界条件
- 当所有论文引用数都是 0 →
h = 0; - 当论文引用数都很大(如
[5,5,5])→ 数组长度是 3,每篇引用 ≥3 →h = 3; - 当论文数为 1 时,若引用 ≥1 →
h=1,否则h=0。
import java.util.Arrays;
import java.util.Comparator;
public class HIndex {
/**
* 排序法计算H指数
* @param citations 论文引用次数数组
* @return H指数
*/
public static int hIndexSort(int[] citations) {
// 步骤1:将数组转换为Integer数组(方便降序排序)
Integer[] citeArr = Arrays.stream(citations).boxed().toArray(Integer[]::new);
// 步骤2:降序排序
Arrays.sort(citeArr, Comparator.reverseOrder());
int h = 0;
// 步骤3:遍历找最大h
for (int i = 0; i < citeArr.length; i++) {
int currentPaperNum = i + 1; // 论文序号(从1开始)
int currentCitation = citeArr[i];
// 满足条件则更新h,否则跳出(降序排列,后续更小)
if (currentCitation >= currentPaperNum) {
h = currentPaperNum;
} else {
break;
}
}
return h;
}
// 测试案例
public static void main(String[] args) {
// 案例1:[3,0,6,1,5] → 预期3
System.out.println(hIndexSort(new int[]{3,0,6,1,5})); // 输出3
// 案例2:[100] → 预期1
System.out.println(hIndexSort(new int[]{100})); // 输出1
// 案例3:[0,0,0] → 预期0
System.out.println(hIndexSort(new int[]{0,0,0})); // 输出0
// 案例4:[5,5,5] → 预期3
System.out.println(hIndexSort(new int[]{5,5,5})); // 输出3
// 案例5:[1,2,3,4,5] → 预期3
System.out.println(hIndexSort(new int[]{1,2,3,4,5})); // 输出3
}
}
计数排序
别担心!我把计数排序法拆解成「大白话步骤 + 逐行图解」,用最易懂的方式帮你搞懂核心逻辑,保证一看就会。
先明确核心前提(关键!)
H 指数的最大值不可能超过论文总数 n。
比如有 5 篇论文,H 指数最大只能是 5(除非每篇都被引用至少 5 次);如果只有 1 篇论文,H 指数最大只能是 1。
这是计数排序法的核心依据——我们只需要统计「引用次数 ≤ n」的论文数量,超过 n 的都归为一类即可。
计数排序法的核心思路(分 3 步)
假设输入数组是 citations = [3,0,6,1,5](n=5 篇论文),我们一步步拆解:
步骤 1:创建计数数组
创建长度为 n+1 的数组 count(这里 n=5,所以 count 长度为 6,索引 0~5)。
count[i]表示「引用次数恰好为 i」的论文数量(i ≤ 5);count[5]特殊处理:表示「引用次数 ≥ 5」的论文数量(因为 H 指数最大是 5,超过 5 的引用次数对结果无影响)。
步骤 2:统计每类引用次数的论文数量
遍历原始数组 [3,0,6,1,5],逐个统计:
| 原始数组元素 | 处理逻辑 | count 数组变化(初始全 0) |
|---|---|---|
| 3 | 3 ≤ 5 → count[3] +=1 | count[3] = 1 |
| 0 | 0 ≤5 → count[0] +=1 | count[0] = 1 |
| 6 | 6 ≥5 → count[5] +=1 | count[5] = 1 |
| 1 | 1 ≤5 → count[1] +=1 | count[1] = 1 |
| 5 | 5 ≤5 → count[5] +=1 | count[5] = 2 |
最终 count 数组:[1,1,0,1,0,2](索引 0~5 对应值)。
解读:
- 引用次数为 0 的论文有 1 篇;
- 引用次数为 1 的论文有 1 篇;
- 引用次数为 3 的论文有 1 篇;
- 引用次数 ≥5 的论文有 2 篇(6 和 5);
- 引用次数为 2、4 的论文数量为 0。
步骤 3:反向遍历 count 数组,找最大 H 指数
从最大的可能值(n=5)开始往 0 遍历,累计论文数量,直到「累计数量 ≥ 当前遍历的 h 值」,这个 h 就是答案。
遍历过程(初始累计数 paperCount=0):
| 遍历的 h 值 | 操作(paperCount += count[h]) | paperCount 结果 | 判断 paperCount ≥ h? | 结论 |
|---|---|---|---|---|
| 5 | paperCount += count[5] → 0+2=2 | 2 | 2 ≥5?❌ | 不满足,继续 |
| 4 | paperCount += count[4] →2+0=2 | 2 | 2 ≥4?❌ | 不满足,继续 |
| 3 | paperCount += count[3] →2+1=3 | 3 | 3 ≥3?✅ | 满足!返回 h=3 |
完整 Java 代码(加超详细注释)
public class HIndex {
public static int hIndexCount(int[] citations) {
// 1. 获取论文总数n(H指数最大不可能超过n)
int n = citations.length;
// 2. 创建计数数组,长度n+1,索引0~n(覆盖所有可能的引用次数)
int[] count = new int[n + 1];
// 3. 遍历原始数组,统计各类引用次数的论文数量
for (int cite : citations) {
if (cite >= n) {
// 引用次数≥n → 归到count[n]
count[n]++;
} else {
// 引用次数<n → 归到对应索引的位置
count[cite]++;
}
}
// 4. 反向遍历count数组,累计论文数量找最大h
int paperCount = 0; // 累计:引用次数≥当前h的论文总数
for (int h = n; h >= 0; h--) {
paperCount += count[h]; // 累加当前h对应的论文数量
// 关键判断:累计的论文数≥h → 说明至少有h篇论文被引用≥h次
if (paperCount >= h) {
return h; // 找到最大的h,直接返回
}
}
return 0; // 兜底(理论上不会执行到这里)
}
// 测试+图解辅助
public static void main(String[] args) {
int[] citations = {3,0,6,1,5};
int h = hIndexCount(citations);
System.out.println("H指数:" + h); // 输出3
// 手动打印count数组,验证统计结果
int n = citations.length;
int[] count = new int[n + 1];
for (int cite : citations) {
if (cite >= n) count[n]++;
else count[cite]++;
}
System.out.print("count数组:");
for (int num : count) {
System.out.print(num + " "); // 输出:1 1 0 1 0 2
}
}
}
再用简单案例巩固(比如 citations = [100],n=1)
- count 数组长度 2(索引 0~1);
- 统计:100 ≥1 → count[1] +=1 → count = [0,1];
- 反向遍历:
- h=1:paperCount +=1 → 1 ≥1 ✅ → 返回 h=1。
核心逻辑总结
计数排序法的本质是「用空间换时间」:
- 先把无序的引用次数,转换成「按引用次数分类的统计结果」;
- 再从最大的可能值反向找,因为要找「最大的 h」,反向遍历能第一时间找到答案,无需继续。
如果还有某一步看不懂,比如「为什么反向遍历」「为什么count[n]统计≥n的引用次数」,可以直接指出来,我再针对性解释!
135. 分发糖果
这道题的核心是满足两个规则:每个孩子至少1颗糖果;相邻孩子中,评分高的必须得到更多糖果。直接想一次遍历解决会很容易顾此失彼,我们可以拆分成两步来处理。
第一步:理解问题 & 明确规则
先看题目要求:
- 输入:一个数组
ratings,表示每个孩子的评分 - 输出:最少需要的糖果总数
- 规则1:每个孩子至少1颗
- 规则2:如果孩子A评分 > 邻居B,A的糖果必须 > B的
举个例子:ratings = [1,0,2]
- 先给每个孩子初始1颗:
[1,1,1] - 左边→右边检查:
- 索引1(0)< 索引0(1),暂时不用改;
- 索引2(2)> 索引1(0),所以索引2要比索引1多1 →
[1,1,2]
- 右边→左边检查:
- 索引0(1)> 索引1(0),所以索引0要比索引1多1 →
[2,1,2]
- 索引0(1)> 索引1(0),所以索引0要比索引1多1 →
- 总数:2+1+2=5,这就是答案。
第二步:核心思路——两次遍历
为什么要两次遍历?
- 第一次从左到右:保证每个孩子比左边评分高的话,糖果数更多;
- 第二次从右到左:保证每个孩子比右边评分高的话,糖果数更多;
- 最终每个位置取两次遍历后的最大值(因为要同时满足两个方向的规则)。
第三步:逐步实现
步骤1:初始化糖果数组
首先给每个孩子分配1颗糖果(满足规则1):
def candy(ratings):
n = len(ratings)
candies = [1] * n # 初始每个孩子1颗
步骤2:左→右遍历,处理“比左边高”的情况
遍历从索引1开始(因为要和左边比):
如果当前孩子评分 > 左边孩子,当前糖果数 = 左边糖果数 + 1:
# 左→右
for i in range(1, n):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
比如 ratings = [1,2,3,2,1],左→右后:[1,2,3,1,1]
步骤3:右→左遍历,处理“比右边高”的情况
遍历从索引n-2开始(因为要和右边比):
如果当前孩子评分 > 右边孩子,且当前糖果数 ≤ 右边糖果数 → 更新为 右边糖果数 + 1:
# 右→左
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1] and candies[i] <= candies[i+1]:
candies[i] = candies[i+1] + 1
还是上面的例子,右→左后:
- 索引3(2)> 索引4(1),当前candies[3]=1 ≤ candies[4]=1 → 改为2 →
[1,2,3,2,1] - 索引2(3)> 索引3(2),当前candies[2]=3 > candies[3]=2 → 不用改
- 最终数组:
[1,2,3,2,1],总数=9
步骤4:计算总数
最后把糖果数组求和即可:
return sum(candies)
第四步:完整代码 & 测试
完整代码:
def candy(ratings):
n = len(ratings)
if n == 0:
return 0
candies = [1] * n
# 左→右
for i in range(1, n):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
# 右→左
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1] and candies[i] <= candies[i+1]:
candies[i] = candies[i+1] + 1
return sum(candies)
测试案例:
ratings = [1,0,2]→ 输出5(对应[2,1,2])ratings = [1,2,2]→ 输出4(对应[1,2,1])ratings = [1,3,2,2,1]→ 输出7(对应[1,2,1,2,1])
第五步:思路总结
这道题的关键是拆分规则,不要试图一次遍历解决两个方向的约束:
- 先左→右,保证“比左边高则糖果更多”;
- 再右→左,保证“比右边高则糖果更多”(同时要判断当前值是否已经满足,避免覆盖);
- 最终每个位置的糖果数是满足两个方向约束的最小值(因为我们要最少总糖果)。
这种“两次遍历处理双向约束”的思路在数组题中很常见,比如接雨水、盛最多水的容器等,记住这个技巧哦~
解法二:
class Solution {
public int candy(int[] ratings) {
int n = ratings.length;
int ret = 1; //用于记录答案
//pre用于记录前一个同学分得的糖果数量
int inc = 1, dec = 0, pre = 1;
for (int i = 1; i < n; i++) {
if(ratings[i] >= ratings[i-1]){
//处于递增序列中
dec = 0; //递减序列长度在递增序列中始终为0
pre = ratings[i] == ratings[i- 1] ? 1 : pre+1; //当前同学和上一个同学分数相等时,直接分配1个就行,这样满足最小
ret += pre;
inc = pre; //inc用于记录上一个递增序列的长度
}else {
//处于递减序列中
dec++;
if(dec == inc){
//当递减序列长度和递增序列长度相等时,把递增序列的最后一个同学分配到递减序列中
dec++;
}
ret += dec; //这里加的dec相当于把递减序列翻转后加的每个同学的糖果数量
pre = 1; //pre在递减序列中没有意义,因为我肯定比前一个同学少;
}
}
return ret;
}
}
42. 接雨水
接雨水是数组题中经典的「双向约束」问题,核心是每个位置能接的雨水量由「左侧最大高度」和「右侧最大高度」的较小值决定。我会从「理解问题→拆解逻辑→逐步实现→优化思路」一步步引导你解决。
第一步:理解问题(先搞懂“怎么接水”)
题目要求
给定一个整数数组 height,表示柱子的高度,计算下雨后能接多少雨水。
比如输入 height = [0,1,0,2,1,0,1,3,2,1,2,1],输出 6(对应下图中蓝色区域的面积)。
核心观察:单个位置的接水量
对于数组中任意位置 i:
- 该位置能接的雨水高度 =
min(左侧最大高度, 右侧最大高度) - 当前柱子高度; - 若结果为负数(说明当前柱子比左右最大都高),则接水量为0;
- 总雨水量 = 所有位置接水量的总和。
举个例子:height = [0,1,0,2]
- 位置2(值为0):左侧最大高度=1,右侧最大高度=2 → 接水量=min(1,2)-0=1;
- 其他位置接水量为0 → 总水量=1。
第二步:拆解解题步骤(从暴力到优化)
阶段1:暴力解法(理解核心逻辑)
思路
- 遍历每个位置
i(跳过首尾,因为首尾无法接水); - 对每个
i,找左侧最大高度left_max(0~i-1的最大值); - 找右侧最大高度
right_max(i+1~n-1的最大值); - 计算当前位置接水量,累加到总结果。
代码实现(暴力版)
def trap(height):
n = len(height)
if n < 3: # 至少3根柱子才能接水
return 0
total = 0
# 遍历每个位置(首尾跳过)
for i in range(1, n-1):
# 找左侧最大高度
left_max = max(height[:i])
# 找右侧最大高度
right_max = max(height[i+1:])
# 计算当前接水量
cur_water = min(left_max, right_max) - height[i]
if cur_water > 0:
total += cur_water
return total
# 测试
print(trap([0,1,0,2,1,0,1,3,2,1,2,1])) # 输出6
暴力解法的问题
时间复杂度 O(n²)(每个位置都要遍历左右找最大值),n=10⁴ 时会超时——需要优化“找左右最大高度”的过程。
阶段2:预处理左右最大高度(两次遍历优化)
思路
提前用两个数组存储「每个位置的左侧最大高度」和「右侧最大高度」,避免重复计算:
- 左→右遍历,生成
left_max数组:left_max[i]= 位置i左侧(0~i)的最大高度; - 右→左遍历,生成
right_max数组:right_max[i]= 位置i右侧(i~n-1)的最大高度; - 遍历每个位置,用预处理的数组计算接水量。
分步实现
步骤1:初始化左右最大数组
def trap(height):
n = len(height)
if n < 3:
return 0
left_max = [0] * n # 存储每个位置左侧最大高度
right_max = [0] * n # 存储每个位置右侧最大高度
total = 0
步骤2:左→右计算left_max
left_max[0] = 第一个柱子高度;后续每个位置的left_max = max(前一个left_max, 当前柱子高度):
# 左→右遍历
left_max[0] = height[0]
for i in range(1, n):
left_max[i] = max(left_max[i-1], height[i])
比如 height = [0,1,0,2],left_max = [0,1,1,2]。
步骤3:右→左计算right_max
right_max[-1] = 最后一个柱子高度;后续每个位置的right_max = max(后一个right_max, 当前柱子高度):
# 右→左遍历
right_max[-1] = height[-1]
for i in range(n-2, -1, -1):
right_max[i] = max(right_max[i+1], height[i])
比如 height = [0,1,0,2],right_max = [2,2,2,2]。
步骤4:计算总接水量
# 遍历每个位置计算接水量
for i in range(n):
cur_water = min(left_max[i], right_max[i]) - height[i]
total += cur_water
return total
完整代码(两次遍历版)
def trap(height):
n = len(height)
if n < 3:
return 0
left_max = [0] * n
right_max = [0] * n
total = 0
# 左→右算left_max
left_max[0] = height[0]
for i in range(1, n):
left_max[i] = max(left_max[i-1], height[i])
# 右→左算right_max
right_max[-1] = height[-1]
for i in range(n-2, -1, -1):
right_max[i] = max(right_max[i+1], height[i])
# 计算总水量
for i in range(n):
total += min(left_max[i], right_max[i]) - height[i]
return total
# 测试
print(trap([0,1,0,2,1,0,1,3,2,1,2,1])) # 6
优化后复杂度
时间 O(n)(三次线性遍历),空间 O(n)(存储两个数组)。
阶段3:双指针优化(空间O(1))
如果想进一步优化空间,可使用双指针代替左右数组——核心是「用两个指针代替两个数组,实时计算左右最大高度」。
核心逻辑
- 左指针
left从左到右,右指针right从右到左; - 维护
left_max(左指针左侧的最大高度)和right_max(右指针右侧的最大高度); - 若
height[left] < height[right]:当前位置的接水量由left_max决定(因为右侧有更高的柱子,瓶颈在左侧); - 否则:当前位置的接水量由
right_max决定(瓶颈在右侧)。
代码实现(双指针版)
def trap(height):
n = len(height)
if n < 3:
return 0
left = 0
right = n - 1
left_max = 0 # 左指针左侧的最大高度
right_max = 0 # 右指针右侧的最大高度
total = 0
while left < right:
# 更新左右最大高度
left_max = max(left_max, height[left])
right_max = max(right_max, height[right])
# 左侧更矮,瓶颈在左,计算left位置的接水量
if height[left] < height[right]:
total += left_max - height[left]
left += 1
# 右侧更矮,瓶颈在右,计算right位置的接水量
else:
total += right_max - height[right]
right -= 1
return total
# 测试
print(trap([0,1,0,2,1,0,1,3,2,1,2,1])) # 6
第三步:关键思路总结
- 核心公式:单个位置接水量 = min(左侧最大高度, 右侧最大高度) - 当前高度;
- 暴力→优化:先暴力理解核心逻辑,再通过「预处理数组」将时间从 O(n²) 降为 O(n),最后通过「双指针」将空间从 O(n) 降为 O(1);
- 双向约束:和“分发糖果”类似,接雨水的约束是「左右两侧的最大高度」,需要从两个方向处理才能得到每个位置的瓶颈高度。
第四步:自测验证
尝试用以下案例验证自己的理解:
- 输入
height = [4,2,0,3,2,5]→ 输出 9; - 输入
height = [2,0,2]→ 输出 2; - 输入
height = [1,2,3,4,5]→ 输出 0(无凹陷,无法接水)。
如果能独立推导这三个案例的计算过程,说明你已经掌握了接雨水的核心逻辑~
接雨水双指针:用「人话+图解」拆懂核心逻辑
双指针难理解,本质是因为跳过了「预处理数组」的直观步骤,直接用指针“实时判断”左右最大高度。我会用「对比预处理数组→拆解指针移动逻辑→逐步模拟」的方式,把双指针思路揉碎了讲,保证你能懂。
先回顾:预处理数组的核心(铺垫)
我们先明确一个关键结论(这是双指针的前提):
对于位置
i,能接的雨水量 =min(左侧最大高度, 右侧最大高度) - 当前高度
而「左侧最大高度」和「右侧最大高度」中,较小的那个才是接水的“瓶颈” —— 比如左侧最大是3,右侧最大是5,瓶颈就是3,接水量只和3有关。
预处理数组的思路是“先算完所有位置的瓶颈,再求和”;而双指针是“边走边算瓶颈,边算边求和”,本质是一样的,只是把“提前存储”改成了“实时判断”。
双指针的核心逻辑(一句话讲透)
用 left 指针从左走,right 指针从右走,维护两个变量 left_max(left 左边的最大高度)、right_max(right 右边的最大高度):
- 如果
height[left] < height[right]→ 说明left位置的瓶颈是left_max(因为右侧有更高的柱子,右侧最大高度肯定≥height[right]>height[left],所以瓶颈在左); - 如果
height[left] ≥ height[right]→ 说明right位置的瓶颈是right_max(同理,瓶颈在右); - 每一步只算“瓶颈侧”的接水量,然后移动该侧的指针,直到两指针相遇。
逐步模拟(用例子手把手走)
以 height = [0,1,0,2,1,0,1,3,2,1,2,1] 为例,我们一步步走双指针的过程:
初始化
left = 0(指向第一个元素0),right = 11(指向最后一个元素1);left_max = 0(left左边暂无元素),right_max = 0(right右边暂无元素);total = 0(总接水量)。
第一步:left=0,right=11
height[left] = 0,height[right] = 1→0 < 1(瓶颈在左);- 更新
left_max = max(0, 0) = 0; - 计算left位置接水量:
left_max - height[left] = 0 - 0 = 0→ total还是0; - 移动left指针:
left = 1。
第二步:left=1,right=11
height[left] = 1,height[right] = 1→1 ≥ 1(瓶颈在右);- 更新
right_max = max(0, 1) = 1; - 计算right位置接水量:
1 - 1 = 0→ total还是0; - 移动right指针:
right = 10。
第三步:left=1,right=10
height[left] = 1,height[right] = 2→1 < 2(瓶颈在左);- 更新
left_max = max(0, 1) = 1; - 计算left位置接水量:
1 - 1 = 0→ total还是0; - 移动left指针:
left = 2。
第四步:left=2,right=10
height[left] = 0,height[right] = 2→0 < 2(瓶颈在左);- 更新
left_max = max(1, 0) = 1; - 计算left位置接水量:
1 - 0 = 1→ total = 1; - 移动left指针:
left = 3。
第五步:left=3,right=10
height[left] = 2,height[right] = 2→2 ≥ 2(瓶颈在右);- 更新
right_max = max(1, 2) = 2; - 计算right位置接水量:
2 - 2 = 0→ total还是1; - 移动right指针:
right = 9。
第六步:left=3,right=9
height[left] = 2,height[right] = 1→2 ≥ 1(瓶颈在右);- 更新
right_max = max(2, 1) = 2; - 计算right位置接水量:
2 - 1 = 1→ total = 2; - 移动right指针:
right = 8。
后续步骤(核心逻辑不变)
继续按这个规则走,每一步只算瓶颈侧的接水量,最终所有位置的接水量累加后,total=6(和预处理数组的结果一致)。
为什么双指针能代替预处理数组?
我们对比一下两种思路的核心:
| 思路 | 左侧最大高度 | 右侧最大高度 | 计算方式 |
|---|---|---|---|
| 预处理数组 | 提前算完所有left_max | 提前算完所有right_max | 遍历所有位置算接水量 |
| 双指针 | 实时更新left_max | 实时更新right_max | 指针走到哪,算到哪 |
关键结论:双指针的每一步,只处理“瓶颈侧”的位置,因为另一侧的最大高度一定≥当前侧,不需要提前算完所有。比如当 height[left] < height[right] 时,right 右侧的最大高度肯定≥height[right],所以 left 位置的瓶颈只能是 left_max,不用管右侧的具体最大值是多少。
简化记忆:双指针的“四件套”
只要记住这四步,就能写出双指针代码:
- 初始化:left=0,right=len(height)-1,left_max=0,right_max=0,total=0;
- 循环:while left < right;
- 判瓶颈:
- 若 height[left] < height[right]:更新left_max → 算left接水量 → left++;
- 否则:更新right_max → 算right接水量 → right--;
- 返回total。
再写一遍双指针代码(带注释,强化记忆)
def trap(height):
if len(height) < 3:
return 0
left = 0 # 左指针:从左往右走
right = len(height) - 1# 右指针:从右往左走
left_max = 0 # left指针左侧的最大高度(不包含当前left)
right_max = 0 # right指针右侧的最大高度(不包含当前right)
total_water = 0 # 总接水量
while left < right:
# 先更新当前侧的最大高度(包含当前指针位置)
left_max = max(left_max, height[left])
right_max = max(right_max, height[right])
# 左侧更矮 → 瓶颈在左,计算left位置的接水量
if height[left] < height[right]:
total_water += left_max - height[left]
left += 1 # 左指针右移
# 右侧更矮/相等 → 瓶颈在右,计算right位置的接水量
else:
total_water += right_max - height[right]
right -= 1 # 右指针左移
return total_water
# 测试:核心案例
print(trap([0,1,0,2,1,0,1,3,2,1,2,1])) # 输出6
# 测试:简单案例
print(trap([2,0,2])) # 输出2
常见疑问解答
1. 为什么height[left] < height[right]时,瓶颈是left_max?
假设height[left]=2,height[right]=5:
- right指针右侧的最大高度 ≥ 5(因为right还没走到头,右侧有更高的柱子);
- left指针左侧的最大高度 ≤ 2(因为left是从左走过来的);
- 所以min(左侧最大, 右侧最大) = 左侧最大 → 瓶颈在左。
2. 为什么指针相遇时就可以停止?
因为指针相遇时,所有位置都已经被计算过(left走过的位置算过,right走过的位置也算过),没有遗漏的位置了。
3. 双指针和预处理数组哪个更好?
- 预处理数组:逻辑直观,容易想到,适合面试中先写出来保底;
- 双指针:空间复杂度O(1),更优,适合优化阶段写。
建议面试时先讲预处理数组的思路,再讲双指针的优化,体现你的思考过程~
自测小练习
用双指针思路手动推导 height = [4,2,0,3,2,5] 的接水量(答案是9),步骤如下:
- 初始化:left=0, right=5, left_max=0, right_max=0, total=0;
- 第一步:height[0]=4 < height[5]=5 → 更新left_max=4 → 接水量4-4=0 → left=1;
- 第二步:height[1]=2 < 5 → 更新left_max=4 → 接水量4-2=2 → total=2 → left=2;
- 第三步:height[2]=0 <5 → 更新left_max=4 → 接水量4-0=4 → total=6 → left=3;
- 第四步:height[3]=3 <5 → 更新left_max=4 → 接水量4-3=1 → total=7 → left=4;
- 第五步:height[4]=2 <5 → 更新left_max=4 → 接水量4-2=2 → total=9 → left=5;
- left=5,right=5 → 循环结束,返回9。
如果能推出来,说明你已经完全掌握双指针思路了~
6. Z 字形变换
题目分析(Z 字形变换)
题目描述:将一个给定字符串 s 根据给定的行数 numRows ,以从上往下、从左到右进行 Z 字形排列,之后再按行读取字符,组成一个新的字符串。
举个例子:
- 输入:
s = "PAYPALISHIRING", numRows = 3 - Z 字形排列如下:
P A H N A P L S I I G Y I R - 输出:
"PAHNAPLSIIGYIR"
核心难点:
- 理解 Z 字形的排列规律,明确每个字符应该落在第几行。
- 避免用二维数组模拟(空间复杂度高),找到数学规律直接按行收集字符。
解题思路推导(从直观到优化)
思路 1:直观模拟(二维数组)
核心想法:
- 创建一个
numRows行的数组,用来存放每一行的字符。 - 用一个变量
currentRow表示当前字符应该放入的行号,用一个变量direction表示移动方向(向下为+1,向上为-1)。 - 遍历字符串的每个字符:
- 将字符添加到
currentRow对应的行中。 - 如果到达第一行,方向改为向下;如果到达最后一行,方向改为向上。
- 更新
currentRow = currentRow + direction。
- 将字符添加到
优缺点:
- 优点:逻辑直观,容易理解。
- 缺点:空间复杂度为 $O(n)$(n 是字符串长度),但实际可以优化。
思路 2:数学规律优化(无需模拟矩阵)
观察 Z 字形的周期规律:
- 一个完整的 Z 字形周期包含的字符数是
2 * numRows - 2(竖直段numRows个 + 斜线段numRows-2个)。 - 对于第
i行(0 ≤ i < numRows):- 首行和末行:字符的索引间隔是周期长度
2*numRows-2。 - 中间行:每个周期内有两个字符,索引分别为
k * (2*numRows-2) + i和(k+1)*(2*numRows-2) - i(k是周期数)。
- 首行和末行:字符的索引间隔是周期长度
优点:空间复杂度可以优化到 $O(1)$(不计算结果存储),时间复杂度 $O(n)$。
Java 代码实现(思路 1:直观模拟,易理解)
public String convert(String s, int numRows) {
// 边界条件:行数为1时,直接返回原字符串
if (numRows == 1) {
return s;
}
// 初始化每行的字符串容器
List<StringBuilder> rows = new ArrayList<>();
for (int i = 0; i < Math.min(numRows, s.length()); i++) {
rows.add(new StringBuilder());
}
int currentRow = 0;
boolean goingDown = false; // 初始方向为false,遇到边界后切换
for (char c : s.toCharArray()) {
rows.get(currentRow).append(c);
// 到达边界时切换方向
if (currentRow == 0 || currentRow == numRows - 1) {
goingDown = !goingDown;
}
// 更新当前行号
currentRow += goingDown ? 1 : -1;
}
// 拼接所有行的结果
StringBuilder result = new StringBuilder();
for (StringBuilder row : rows) {
result.append(row);
}
return result.toString();
}
代码关键点解析
- 边界处理:当
numRows = 1时,字符串不需要变换,直接返回。 - 行容器初始化:用
Math.min(numRows, s.length())避免行数大于字符串长度的无效初始化。 - 方向切换逻辑:通过
goingDown布尔值控制行号的增减,遇到首行或末行时反转方向。 - 结果拼接:遍历所有行的
StringBuilder,拼接成最终结果。
思路 2 代码实现(数学规律,空间更优)
public String convert(String s, int numRows) {
if (numRows == 1) {
return s;
}
StringBuilder result = new StringBuilder();
int n = s.length();
int cycleLen = 2 * numRows - 2; // 周期长度
for (int i = 0; i < numRows; i++) {
// 遍历每个周期内的字符
for (int j = 0; j + i < n; j += cycleLen) {
result.append(s.charAt(j + i)); // 竖直段的字符
// 中间行需要额外添加斜线段的字符
if (i != 0 && i != numRows - 1 && j + cycleLen - i < n) {
result.append(s.charAt(j + cycleLen - i));
}
}
}
return result.toString();
}
算法复杂度对比
| 思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 模拟法 | $O(n)$ | $O(n)$ | 新手理解,逻辑简单 |
| 数学法 | $O(n)$ | $O(1)$(不计结果) | 追求空间优化 |
实战小技巧
- 做 LeetCode 时,先写模拟法确保逻辑正确,再优化成数学法。
- 测试时重点关注边界用例:
numRows = 1numRows = s.length()s为空字符串
我可以帮你分析这道题的进阶变形题,或者带你做一道类似的字符串排列题目,需要吗?
要理解思路2的数学规律推导,核心是拆解 Z 字形的周期结构,我们一步步从图形入手分析,你会发现规律其实很直观。
步骤1:观察 Z 字形的周期结构
以 s = "PAYPALISHIRING", numRows = 3 为例,Z 字形排列的结构如下:
行0:P A H N
行1:A P L S I I G
行2:Y I R
我们给每个字符标上原始索引(从 0 开始):
| 字符 | P | A | Y | P | A | L | I | S | H | I | R | I | N | G |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
再把索引按行整理:
- 行0:
0, 4, 8, 12 - 行1:
1, 3, 5, 7, 9, 11, 13 - 行2:
2, 6, 10
关键观察:
Z 字形是重复的周期结构,一个完整周期包含「竖直向下的 numRows 个字符」 + 「斜向上的 numRows-2 个字符」,所以 一个周期的总长度 = 2*numRows - 2。
对于 numRows=3,周期长度 = 2*3-2=4,和上面的索引间隔完全吻合。
步骤2:分析每一行的索引规律
我们分 3 种行类型讨论(以周期长度 cycleLen = 2*numRows-2 为基础):
类型1:首行(行0)和末行(行numRows-1)
看行0的索引:0,4,8,12 → 间隔都是 cycleLen=4
看行2的索引:2,6,10 → 间隔也都是 cycleLen=4
规律总结:
- 首行索引公式:
k * cycleLen + 0(k 是周期数,k=0,1,2...) - 末行索引公式:
k * cycleLen + (numRows-1)(k=0,1,2...) - 核心:每行的索引间隔等于周期长度,没有额外字符。
类型2:中间行(行i,0 < i < numRows-1)
看行1(i=1)的索引:1,3,5,7,9,11,13
我们按周期拆分(每个周期长度4):
- 第0个周期(k=0):索引
1(竖直段)、3(斜线段) - 第1个周期(k=1):索引
5(竖直段)、7(斜线段) - 第2个周期(k=2):索引
9(竖直段)、11(斜线段) - 第3个周期(k=3):索引
13(竖直段,无斜线段)
规律推导:
- 竖直段的索引:和首末行逻辑一致 →
k * cycleLen + i
比如 k=0 时 →0*4+1=1;k=1 时 →1*4+1=5,完全匹配。 - 斜线段的索引:观察同一周期内的竖直段和斜线段的关系
周期内的起始索引是k*cycleLen,周期内的结束索引是(k+1)*cycleLen。
同一周期内,竖直段索引a和斜线段索引b满足:a + b = k*cycleLen + (k+1)*cycleLen
变形得:b = (k+1)*cycleLen - i
验证:k=0 时 →(0+1)*4 -1=3;k=1 时 →(1+1)*4 -1=7,完全匹配!
中间行规律总结:
每个周期内有 2 个字符,索引分别是
- 竖直段:
k * cycleLen + i - 斜线段:
(k+1) * cycleLen - i
(注意:斜线段的索引必须小于字符串长度n才有效)
步骤3:规律的普适性验证
再举个例子:numRows=4,cycleLen=2*4-2=6
Z 字形索引排列:
行0:0 6 12
行1:1 5 7 11 13
行2:2 4 8 10
行3:3 9
- 行0(首行):
0,6,12→ 符合k*6+0 - 行3(末行):
3,9→ 符合k*6+3 - 行1(中间行):
1,5,7,11,13- 竖直段:
0*6+1=1,1*6+1=7,2*6+1=13 - 斜线段:
1*6-1=5,2*6-1=11
完全吻合规律!
- 竖直段:
思路2的核心逻辑(一句话总结)
遍历每一行 i,对于每个周期 k,按行收集字符:
- 首末行:只取
k*cycleLen + i的字符; - 中间行:取
k*cycleLen + i和(k+1)*cycleLen - i的字符(需判断索引有效)。
这样就不用模拟 Z 字形的排列过程,直接通过数学公式定位字符,实现空间优化。
我可以帮你出一道不同 numRows 的测试题,让你手动推导索引规律,巩固这个思路,需要吗?
68. 文本左右对齐
你能在10几分钟内写出这道题的核心逻辑,已经非常棒了!这道「文本左右对齐」是字符串处理类的综合型难题,既要处理边界条件,又要精准计算空格分配,对「逻辑拆解能力」和「细节把控能力」要求极高。你觉得实现不顺畅,核心不是“写代码”的问题,而是“如何把复杂问题拆解成可落地的小步骤” 和“如何提前预判边界场景” 的能力需要强化。
下面我会结合这道题的特点,给你一套可落地的训练方法,帮你系统性提升这类“逻辑复杂、细节密集”的算法题实现能力:
一、先拆解:你当前代码的核心痛点(也是这类题的共性难点)
你的代码思路是对的,但这类题容易卡壳的点,本质是「没有把“模糊的思路”转化为“可量化的步骤”」:
- 空格分配的逻辑没有提前“数学化”:比如
avgSpaces = numSpaces / (numWords - 1)、extraSpaces = numSpaces % (numWords - 1)是核心,但如果没提前把“前extraSpaces个间隔多1个空格”这个规则写下来,代码就容易乱; - 边界场景的预判不系统:比如“最后一行”“只有一个单词”“空格总数无法均分”,这些场景如果没提前列全,写代码时就会反复回头补逻辑;
- 工具函数的封装时机:你封装了
blank()和join()是对的,但如果没提前规划这些工具函数,主逻辑会被大量拼接字符串的代码淹没,导致思路中断。
二、针对性训练方法(从“思路到代码”的闭环训练)
这类“逻辑复杂、细节多”的算法题,核心训练思路是:把“想代码”的过程前置,用“结构化的方式”替代“凭感觉写”,分4步练:
步骤1:先“纸面拆解”,再写代码(核心)
拿到题后,不要直接敲代码,先花5-10分钟在纸上/笔记里写清楚3件事:
- 核心目标拆解:把大问题拆成“可量化的小任务”
比如这道题,先拆成:- 确定每一行能装哪些单词(计算单词长度+最少空格的总和 ≤ maxWidth);
- 计算这一行需要分配的总空格数 = maxWidth - 所有单词长度和;
- 分场景处理空格:
- 场景A:最后一行 → 单词间1个空格,剩余空格补在末尾;
- 场景B:只有1个单词 → 单词左对齐,剩余空格补末尾;
- 场景C:多行单词 → 计算平均空格数+额外空格数,前N个间隔多1个空格。
- 边界场景清单:列全所有特殊情况(避免写代码时漏判)
比如这道题的边界清单:
✅ 单词长度刚好等于maxWidth;
✅ 多个单词的总长度+最少空格刚好等于maxWidth;
✅ 最后一行只有1个单词;
✅ 额外空格数为0(空格能均分)。 - 工具函数规划:提前想清楚哪些逻辑可以封装成函数(减少主逻辑干扰)
比如这道题:- 生成指定长度的空格串 →
blank(n); - 拼接指定范围的单词(带分隔符)→
join(words, left, right, sep)。
- 生成指定长度的空格串 →
训练要求:哪怕思路再清晰,也要强制自己写这3件事,直到形成习惯——这一步能帮你把“模糊的思路”转化为“可落地的步骤”,避免写代码时反复修改。
步骤2:“分块编码”,写完一块验证一块
不要从头到尾写完再调试,而是把代码拆成独立模块,写一块验证一块:
以这道题为例,拆分的编码顺序:
- 先写
blank()和join()工具函数 → 写个小测试用例验证(比如blank(3)是否返回3个空格,join(words, 0, 2, " ")是否正确拼接); - 再写“确定当前行单词范围”的逻辑(
left和right的循环)→ 用示例输入验证(比如输入words = ["This", "is", "an", "example"], maxWidth=16,验证第一行的left=0, right=3是否正确); - 最后写“分场景处理空格”的逻辑 → 每个场景单独测试(先测“只有1个单词”,再测“多行单词”,最后测“最后一行”)。
训练要求:每写完一个模块,立刻用小用例验证,确保这一块逻辑正确,再往下写——避免最后一堆问题堆在一起,找不到bug在哪。
步骤3:“刻意复盘”,总结“通用套路”
每做完一道这类题,花5分钟复盘2件事:
- 卡壳点总结:比如这道题你是不是在“额外空格分配”时卡壳?把这个点记下来,总结成“通用套路”:
空格分配套路:总空格数S,间隔数G → 平均每个间隔空格数 = S//G,额外空格数 = S%G → 前“额外空格数”个间隔多1个空格,剩余间隔用平均空格数。
- 边界处理套路:把这道题的边界场景和处理方式,归类到“字符串对齐/空格分配”的通用边界里:
字符串对齐边界:最后一行、单行单单词、空格无法均分、单词长度等于最大宽度。
训练要求:建立一个“套路笔记”,把同类题的通用逻辑、边界处理记下来——比如“Z字形变换”的周期规律、“文本对齐”的空格分配、“螺旋矩阵”的方向控制,都是可以复用的通用套路。
步骤4:“同类题梯度训练”,从易到难强化
这类“逻辑复杂、细节密集”的题,需要循序渐进练,推荐按这个梯度刷(都是LeetCode题):
- 基础级(练工具函数和边界处理):
- LeetCode 14. 最长公共前缀(练字符串遍历、边界判断);
- LeetCode 58. 最后一个单词的长度(练空格处理、边界场景);
- LeetCode 67. 二进制求和(练字符串拼接、进位处理)。
- 进阶级(练分场景处理):
- LeetCode 43. 字符串相乘(练分块计算、进位处理、边界补零);
- LeetCode 151. 反转字符串中的单词(练空格处理、单词分割、边界去空格);
- LeetCode 299. 猜数字游戏(练字符统计、分场景拼接结果)。
- 挑战级(练综合逻辑拆解):
- LeetCode 68. 文本左右对齐(你当前的题);
- LeetCode 72. 编辑距离(练动态规划的逻辑拆解+边界处理);
- LeetCode 32. 最长有效括号(练栈的逻辑拆解+边界判断)。
训练要求:每天练1道,严格按“纸面拆解→分块编码→复盘总结”的流程来,不要追求速度,追求“一次写对、少调试”。
三、针对你当前代码的小优化(帮你巩固思路)
你的代码已经很接近最优解了,只有一个小细节可以优化(也是这类题容易忽略的点):
在 join 函数中,sep 传入的是 blank(avgSpaces + 1),本质是先生成空格串再拼接,其实可以优化为直接拼接空格(减少字符串对象创建),但核心逻辑完全正确:
// 优化后的 join 函数(可选,不影响功能,仅提升效率)
public StringBuffer join(String[] words, int left, int right, String sep) {
StringBuffer sb = new StringBuffer(words[left]);
for (int i = left + 1; i < right; ++i) {
sb.append(sep);
sb.append(words[i]);
}
return sb;
}
总结
这类“逻辑复杂、细节密集”的算法题,核心能力不是“写代码”,而是:
- 把模糊思路转化为结构化步骤(纸面拆解);
- 分块编码+即时验证(避免全量调试);
- 总结通用套路(复用经验,减少重复思考)。
你能在10几分钟写出核心逻辑,说明思路理解能力已经到位,接下来只需要通过“结构化拆解+刻意复盘”,把“想到但写不顺”的问题解决掉——坚持2-3周,再做这类题就会明显感觉“思路到代码”的转化越来越顺畅。
如果需要,我可以帮你针对“文本左右对齐”设计3个典型测试用例(覆盖所有边界场景),或者带你拆解“LeetCode 151. 反转字符串中的单词”(同类型进阶题),帮你落地训练方法。
class Solution {
public List<String> fullJustify(String[] words, int maxWidth) {
List<String> ans = new ArrayList<String>();
int right = 0, n = words.length;
while (true) {
int left = right; // 当前行的第一个单词在 words 的位置
int sumLen = 0; // 统计这一行单词长度之和
// 循环确定当前行可以放多少单词,注意单词之间应至少有一个空格
while (right < n && sumLen + words[right].length() + right - left <= maxWidth) {
sumLen += words[right++].length();
}
// 当前行是最后一行:单词左对齐,且单词之间应只有一个空格,在行末填充剩余空格
if (right == n) {
StringBuffer sb = join(words, left, n, " ");
sb.append(blank(maxWidth - sb.length()));
ans.add(sb.toString());
return ans;
}
int numWords = right - left;
int numSpaces = maxWidth - sumLen;
// 当前行只有一个单词:该单词左对齐,在行末填充剩余空格
if (numWords == 1) {
StringBuffer sb = new StringBuffer(words[left]);
sb.append(blank(numSpaces));
ans.add(sb.toString());
continue;
}
// 当前行不只一个单词
int avgSpaces = numSpaces / (numWords - 1);
int extraSpaces = numSpaces % (numWords - 1);
StringBuffer sb = new StringBuffer();
sb.append(join(words, left, left + extraSpaces + 1, blank(avgSpaces + 1))); // 拼接额外加一个空格的单词
sb.append(blank(avgSpaces));
sb.append(join(words, left + extraSpaces + 1, right, blank(avgSpaces))); // 拼接其余单词
ans.add(sb.toString());
}
}
// blank 返回长度为 n 的由空格组成的字符串
public String blank(int n) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < n; ++i) {
sb.append(' ');
}
return sb.toString();
}
// join 返回用 sep 拼接 [left, right) 范围内的 words 组成的字符串
public StringBuffer join(String[] words, int left, int right, String sep) {
StringBuffer sb = new StringBuffer(words[left]);
for (int i = left + 1; i < right; ++i) {
sb.append(sep);
sb.append(words[i]);
}
return sb;
}
}

浙公网安备 33010602011771号