数组刷题总结

按照 「纸面拆解→分块编码→可视化建模→刻意复盘→同类题梯度训练」 的全套流程,来解 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=11+0 < 2i=22+0=2(末尾),返回 true(正确)
6. 外层循环终止 + 最终返回 left = times + 1;<br>``if (limit == times) { break; }<br>``return false; 验证:<br>- 更新 lefttimes+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
  1. 初始化:limit=2(nums[0]),left=1times=2
  2. 第一轮外层循环:
    • 遍历 i=1i=2
      • i=1distance=31+3=4(等于目标索引)→ 直接返回 true
  3. 终止流程,结果正确。

示例2:nums = [3,2,1,0,4](预期返回 false

nums = [3, 2, 1, 0, 4]
索引:    0  1  2  3  4
len=5,目标索引=4
  1. 初始化:limit=3(nums[0]),left=1times=3
  2. 第一轮外层循环:
    • 遍历 i=1i=3
      • i=1distance=21+2=3limit=3,不更新 limit
      • i=2distance=12+1=3limit=3,不更新 limit
      • i=3distance=03+0=3limit=3,不更新 limit
    • 更新 left=4,判断 limit==times(3==3)→ break外层循环
  3. 最终返回 false,结果正确。

关键变量状态变化表(示例2)

执行步骤 limit值 times值 left值 遍历区间 limit是否更新 状态说明
初始化 3 - 1 - - 初始最大可达位置为3
第一轮外层循环 3 3 1 [1,3] 遍历后limit无变化,无法推进
循环终止 3 3 4 - - 无法覆盖目标索引4,返回false

步骤4:刻意复盘 + 抽象模板化(套路复用)

复盘你的代码:优点 + 常见坑点(踩坑点+修正思路)

优点(值得保留)

  1. 核心思路正确:精准抓住贪心算法的核心——“维护最大可达位置,不纠结具体路径”,时间复杂度 O(n)(每个元素仅遍历一次),空间复杂度 O(1),符合最优解要求;
  2. 提前终止优化:一旦发现 i + nums[i] == len-1,直接返回 true,避免无效遍历,提升实战效率;
  3. 避免重复遍历:通过 left标记下一轮遍历起始位置,不重复处理已遍历过的区间,逻辑高效;
  4. 边界处理前置:提前过滤单元素数组和“起始位置无法跳跃”的场景,减少后续逻辑冗余。

常见坑点(你的代码已规避/需优化)

常见错误 错误原因 你的代码处理方式 优化建议
数组索引越界 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:练习注意事项(贴合你的做题流程)

  1. 流程优先于结果:你的代码核心逻辑正确,通过“可视化建模”能更直观理解 limitleft的联动,尤其是“无法推进时终止循环”的逻辑,后续遇到“最少跳跃次数”的变体题,能更快迁移贪心思想;
  2. 重点复盘贪心算法的核心:记住“不纠结路径,只维护极值(最大可达位置)”这一贪心算法的核心思想,这是解决跳跃游戏、买卖股票等问题的关键;
  3. 模板化记忆简化逻辑:你的代码逻辑严谨但稍显复杂,可记住上述简化版贪心模板(单 for循环),刷题时直接套用,提升解题速度,同时理解其与你的代码的逻辑等价性;
  4. 记录变体题的差异点:比如本题(判断可达性)和45题(最少跳跃次数)的核心差异,是“是否需要统计轮次边界”,记录下来方便后续对比,快速适配变体题。

总结

  1. 你的代码成功运用贪心算法解决了本题,严格遵循了“边界前置”“贪心极值维护”的解题原则,是一份高质量的实现;
  2. 按全套流程分析后,清晰地梳理了代码的优势、可优化点,并抽象出通用模板,为同类题的迁移打下了坚实基础;
  3. 本题的贪心思想具有广泛的适用性,后续遇到“求可达性”“求最优解(无需路径)”的问题,均可优先考虑“维护极值”的贪心策略,提升解题效率。

如果需要,我可以帮你整理跳跃游戏系列题的核心差异对照表,把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] 为例,逐步骤绘制 leftrighttotal 的变化轨迹,直观感受“捕捉上坡段”的逻辑:

初始状态

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:刻意复盘 + 抽象模板化(套路复用)

复盘你的代码:优点 + 常见坑点(踩坑点+修正思路)

优点(值得保留)

  1. 核心思路正确:精准抓住“捕捉所有上坡段”的核心,符合本题最优解逻辑(时间复杂度 O(n),空间复杂度 O(1));
  2. 边界处理前置:先处理 len<=1的情况,避免后续遍历越界,符合“边界条件前置”原则;
  3. 过滤无效交易:通过 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:练习注意事项(贴合你的做题流程)

  1. 流程优先于结果:你的代码已经做对了本题,但通过“可视化建模”能更直观理解 leftright的联动,后续遇到带“冷冻期”“手续费”的变体题,能更快迁移思路;
  2. 重点复盘“末尾补全”逻辑:这是本题的高频坑点,你的代码完美规避,要记住“循环结束后,必须验证末尾是否为未结算的上坡段”;
  3. 模板化记忆核心逻辑:记住“上坡延续探测,下坡结算利润,末尾补全遗漏”这三句话,刷题时可直接套用,无需重新推导;
  4. 记录变体题的差异点:比如121题(一次交易)和122题(多次交易)的核心差异,就是“是否允许结算后重新开启新的上坡段”,记录下来方便后续对比。

总结

  1. 你的代码核心逻辑正确,完全符合本题最优解,且严格遵循了“边界前置”“双指针分工”的解题原则;
  2. 按全套流程分析后,能更清晰地看到代码的“优势”和“可优化点”,同时抽象出通用模板,方便迁移到同类题;
  3. 本题的核心是“不贪心于单次最大利润,而是捕捉所有小利润”,这一思想在后续的“多次交易”股票题中均适用,需牢记。

如果需要,我可以帮你整理股票系列题的核心差异对照表,把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。

练习注意事项

  1. 流程优先于结果:哪怕你能直接写出摩尔投票法的代码,也要强迫自己走“纸面拆解→分块验证→可视化”的流程——目的是训练“结构化思考”,而非“背答案”;
  2. 重点理解“抵消”思想:摩尔投票法的核心不是“计数”,而是“多数元素的投票数足够抵消所有其他元素的投票数”,理解这一点才能迁移到n/3、n/k等场景;
  3. 记录卡点:如果可视化时搞混了 count的变化逻辑,或者忘记“先判断count==0再投票”,一定要记录下来——这些是同类题的通用坑点。

我可以帮你整理摩尔投票法的同类题解题清单,把基础/进阶/拔高题的核心差异和迁移要点列出来,方便你集中训练。需要吗?

274. H 指数

H 指数是一个衡量研究者学术产出质量与影响力的指标,算法题里的 H 指数问题,核心是让你根据给定的论文引用次数数组,计算出该研究者的 H 指数。

一、H 指数的定义

给定一个整数数组 citations,其中 citations[i] 表示第 i 篇论文的被引用次数。
H 指数的定义是:找到最大的整数 h,满足该研究者恰好有 h 篇论文被引用至少 h 次,且其余的论文被引用次数不超过 h

简单来说,h 是一个“平衡点”,要同时满足两个条件:

  1. 至少有 h 篇论文,每篇的引用数 ≥ h
  2. 剩下的论文,每篇的引用数 ≤ h
  3. 我们要找的是最大的满足条件的 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 指数。
常见的解法有两种:

  1. 排序法:降序排序后遍历,时间复杂度 O(n log n)
  2. 计数排序法:利用数组长度做计数统计,时间复杂度 O(n),空间复杂度 O(n)

四、关键边界条件

  1. 当所有论文引用数都是 0 → h = 0
  2. 当论文引用数都很大(如 [5,5,5])→ 数组长度是 3,每篇引用 ≥3 → h = 3
  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)

  1. count 数组长度 2(索引 0~1);
  2. 统计:100 ≥1 → count[1] +=1 → count = [0,1];
  3. 反向遍历:
    • h=1:paperCount +=1 → 1 ≥1 ✅ → 返回 h=1。

核心逻辑总结

计数排序法的本质是「用空间换时间」:

  1. 先把无序的引用次数,转换成「按引用次数分类的统计结果」;
  2. 再从最大的可能值反向找,因为要找「最大的 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]
  • 总数: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)

测试案例:

  1. ratings = [1,0,2] → 输出5(对应 [2,1,2]
  2. ratings = [1,2,2] → 输出4(对应 [1,2,1]
  3. ratings = [1,3,2,2,1] → 输出7(对应 [1,2,1,2,1]

第五步:思路总结

这道题的关键是拆分规则,不要试图一次遍历解决两个方向的约束:

  1. 先左→右,保证“比左边高则糖果更多”;
  2. 再右→左,保证“比右边高则糖果更多”(同时要判断当前值是否已经满足,避免覆盖);
  3. 最终每个位置的糖果数是满足两个方向约束的最小值(因为我们要最少总糖果)。

这种“两次遍历处理双向约束”的思路在数组题中很常见,比如接雨水、盛最多水的容器等,记住这个技巧哦~

解法二:

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

  1. 该位置能接的雨水高度 = min(左侧最大高度, 右侧最大高度) - 当前柱子高度
  2. 若结果为负数(说明当前柱子比左右最大都高),则接水量为0;
  3. 总雨水量 = 所有位置接水量的总和。

举个例子:height = [0,1,0,2]

  • 位置2(值为0):左侧最大高度=1,右侧最大高度=2 → 接水量=min(1,2)-0=1;
  • 其他位置接水量为0 → 总水量=1。

第二步:拆解解题步骤(从暴力到优化)

阶段1:暴力解法(理解核心逻辑)

思路

  1. 遍历每个位置 i(跳过首尾,因为首尾无法接水);
  2. 对每个 i,找左侧最大高度 left_max(0~i-1的最大值);
  3. 找右侧最大高度 right_max(i+1~n-1的最大值);
  4. 计算当前位置接水量,累加到总结果。

代码实现(暴力版)

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:预处理左右最大高度(两次遍历优化)

思路

提前用两个数组存储「每个位置的左侧最大高度」和「右侧最大高度」,避免重复计算:

  1. 左→右遍历,生成 left_max 数组:left_max[i] = 位置i左侧(0~i)的最大高度;
  2. 右→左遍历,生成 right_max 数组:right_max[i] = 位置i右侧(i~n-1)的最大高度;
  3. 遍历每个位置,用预处理的数组计算接水量。

分步实现

步骤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))

如果想进一步优化空间,可使用双指针代替左右数组——核心是「用两个指针代替两个数组,实时计算左右最大高度」。

核心逻辑

  1. 左指针 left 从左到右,右指针 right 从右到左;
  2. 维护 left_max(左指针左侧的最大高度)和 right_max(右指针右侧的最大高度);
  3. height[left] < height[right]:当前位置的接水量由 left_max 决定(因为右侧有更高的柱子,瓶颈在左侧);
  4. 否则:当前位置的接水量由 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

第三步:关键思路总结

  1. 核心公式:单个位置接水量 = min(左侧最大高度, 右侧最大高度) - 当前高度;
  2. 暴力→优化:先暴力理解核心逻辑,再通过「预处理数组」将时间从 O(n²) 降为 O(n),最后通过「双指针」将空间从 O(n) 降为 O(1);
  3. 双向约束:和“分发糖果”类似,接雨水的约束是「左右两侧的最大高度」,需要从两个方向处理才能得到每个位置的瓶颈高度。

第四步:自测验证

尝试用以下案例验证自己的理解:

  1. 输入 height = [4,2,0,3,2,5] → 输出 9;
  2. 输入 height = [2,0,2] → 输出 2;
  3. 输入 height = [1,2,3,4,5] → 输出 0(无凹陷,无法接水)。

如果能独立推导这三个案例的计算过程,说明你已经掌握了接雨水的核心逻辑~

接雨水双指针:用「人话+图解」拆懂核心逻辑

双指针难理解,本质是因为跳过了「预处理数组」的直观步骤,直接用指针“实时判断”左右最大高度。我会用「对比预处理数组→拆解指针移动逻辑→逐步模拟」的方式,把双指针思路揉碎了讲,保证你能懂。

先回顾:预处理数组的核心(铺垫)

我们先明确一个关键结论(这是双指针的前提):

对于位置 i,能接的雨水量 = min(左侧最大高度, 右侧最大高度) - 当前高度
而「左侧最大高度」和「右侧最大高度」中,较小的那个才是接水的“瓶颈” —— 比如左侧最大是3,右侧最大是5,瓶颈就是3,接水量只和3有关。

预处理数组的思路是“先算完所有位置的瓶颈,再求和”;而双指针是“边走边算瓶颈,边算边求和”,本质是一样的,只是把“提前存储”改成了“实时判断”。

双指针的核心逻辑(一句话讲透)

left 指针从左走,right 指针从右走,维护两个变量 left_maxleft 左边的最大高度)、right_maxright 右边的最大高度):

  • 如果 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] = 0height[right] = 10 < 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] = 1height[right] = 11 ≥ 1(瓶颈在右);
  • 更新 right_max = max(0, 1) = 1
  • 计算right位置接水量:1 - 1 = 0 → total还是0;
  • 移动right指针:right = 10

第三步:left=1,right=10

  • height[left] = 1height[right] = 21 < 2(瓶颈在左);
  • 更新 left_max = max(0, 1) = 1
  • 计算left位置接水量:1 - 1 = 0 → total还是0;
  • 移动left指针:left = 2

第四步:left=2,right=10

  • height[left] = 0height[right] = 20 < 2(瓶颈在左);
  • 更新 left_max = max(1, 0) = 1
  • 计算left位置接水量:1 - 0 = 1 → total = 1;
  • 移动left指针:left = 3

第五步:left=3,right=10

  • height[left] = 2height[right] = 22 ≥ 2(瓶颈在右);
  • 更新 right_max = max(1, 2) = 2
  • 计算right位置接水量:2 - 2 = 0 → total还是1;
  • 移动right指针:right = 9

第六步:left=3,right=9

  • height[left] = 2height[right] = 12 ≥ 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,不用管右侧的具体最大值是多少。

简化记忆:双指针的“四件套”

只要记住这四步,就能写出双指针代码:

  1. 初始化:left=0,right=len(height)-1,left_max=0,right_max=0,total=0;
  2. 循环:while left < right;
  3. 判瓶颈:
    • 若 height[left] < height[right]:更新left_max → 算left接水量 → left++;
    • 否则:更新right_max → 算right接水量 → right--;
  4. 返回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),步骤如下:

  1. 初始化:left=0, right=5, left_max=0, right_max=0, total=0;
  2. 第一步:height[0]=4 < height[5]=5 → 更新left_max=4 → 接水量4-4=0 → left=1;
  3. 第二步:height[1]=2 < 5 → 更新left_max=4 → 接水量4-2=2 → total=2 → left=2;
  4. 第三步:height[2]=0 <5 → 更新left_max=4 → 接水量4-0=4 → total=6 → left=3;
  5. 第四步:height[3]=3 <5 → 更新left_max=4 → 接水量4-3=1 → total=7 → left=4;
  6. 第五步:height[4]=2 <5 → 更新left_max=4 → 接水量4-2=2 → total=9 → left=5;
  7. 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"

核心难点

  1. 理解 Z 字形的排列规律,明确每个字符应该落在第几行。
  2. 避免用二维数组模拟(空间复杂度高),找到数学规律直接按行收集字符。

解题思路推导(从直观到优化)

思路 1:直观模拟(二维数组)

核心想法

  • 创建一个 numRows 行的数组,用来存放每一行的字符。
  • 用一个变量 currentRow 表示当前字符应该放入的行号,用一个变量 direction 表示移动方向(向下为 +1,向上为 -1)。
  • 遍历字符串的每个字符:
    1. 将字符添加到 currentRow 对应的行中。
    2. 如果到达第一行,方向改为向下;如果到达最后一行,方向改为向上。
    3. 更新 currentRow = currentRow + direction

优缺点

  • 优点:逻辑直观,容易理解。
  • 缺点:空间复杂度为 $O(n)$(n 是字符串长度),但实际可以优化。

思路 2:数学规律优化(无需模拟矩阵)

观察 Z 字形的周期规律:

  • 一个完整的 Z 字形周期包含的字符数是 2 * numRows - 2(竖直段 numRows 个 + 斜线段 numRows-2 个)。
  • 对于第 i 行(0 ≤ i < numRows):
    1. 首行和末行:字符的索引间隔是周期长度 2*numRows-2
    2. 中间行:每个周期内有两个字符,索引分别为 k * (2*numRows-2) + i(k+1)*(2*numRows-2) - ik 是周期数)。

优点:空间复杂度可以优化到 $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();
}

代码关键点解析

  1. 边界处理:当 numRows = 1 时,字符串不需要变换,直接返回。
  2. 行容器初始化:用 Math.min(numRows, s.length()) 避免行数大于字符串长度的无效初始化。
  3. 方向切换逻辑:通过 goingDown 布尔值控制行号的增减,遇到首行或末行时反转方向。
  4. 结果拼接:遍历所有行的 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)$(不计结果) 追求空间优化

实战小技巧

  1. 做 LeetCode 时,先写模拟法确保逻辑正确,再优化成数学法
  2. 测试时重点关注边界用例:
    • numRows = 1
    • numRows = 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(竖直段,无斜线段)

规律推导

  1. 竖直段的索引:和首末行逻辑一致 → k * cycleLen + i
    比如 k=0 时 → 0*4+1=1;k=1 时 → 1*4+1=5,完全匹配。
  2. 斜线段的索引:观察同一周期内的竖直段和斜线段的关系
    周期内的起始索引是 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=4cycleLen=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=11*6+1=72*6+1=13
    • 斜线段:1*6-1=52*6-1=11
      完全吻合规律!

思路2的核心逻辑(一句话总结)

遍历每一行 i,对于每个周期 k按行收集字符

  1. 首末行:只取 k*cycleLen + i 的字符;
  2. 中间行:取 k*cycleLen + i(k+1)*cycleLen - i 的字符(需判断索引有效)。

这样就不用模拟 Z 字形的排列过程,直接通过数学公式定位字符,实现空间优化。

我可以帮你出一道不同 numRows 的测试题,让你手动推导索引规律,巩固这个思路,需要吗?

68. 文本左右对齐

你能在10几分钟内写出这道题的核心逻辑,已经非常棒了!这道「文本左右对齐」是字符串处理类的综合型难题,既要处理边界条件,又要精准计算空格分配,对「逻辑拆解能力」和「细节把控能力」要求极高。你觉得实现不顺畅,核心不是“写代码”的问题,而是“如何把复杂问题拆解成可落地的小步骤”“如何提前预判边界场景” 的能力需要强化。

下面我会结合这道题的特点,给你一套可落地的训练方法,帮你系统性提升这类“逻辑复杂、细节密集”的算法题实现能力:

一、先拆解:你当前代码的核心痛点(也是这类题的共性难点)

你的代码思路是对的,但这类题容易卡壳的点,本质是「没有把“模糊的思路”转化为“可量化的步骤”」:

  1. 空格分配的逻辑没有提前“数学化”:比如 avgSpaces = numSpaces / (numWords - 1)extraSpaces = numSpaces % (numWords - 1) 是核心,但如果没提前把“前extraSpaces个间隔多1个空格”这个规则写下来,代码就容易乱;
  2. 边界场景的预判不系统:比如“最后一行”“只有一个单词”“空格总数无法均分”,这些场景如果没提前列全,写代码时就会反复回头补逻辑;
  3. 工具函数的封装时机:你封装了 blank()join() 是对的,但如果没提前规划这些工具函数,主逻辑会被大量拼接字符串的代码淹没,导致思路中断。

二、针对性训练方法(从“思路到代码”的闭环训练)

这类“逻辑复杂、细节多”的算法题,核心训练思路是:把“想代码”的过程前置,用“结构化的方式”替代“凭感觉写”,分4步练:

步骤1:先“纸面拆解”,再写代码(核心)

拿到题后,不要直接敲代码,先花5-10分钟在纸上/笔记里写清楚3件事:

  • 核心目标拆解:把大问题拆成“可量化的小任务”
    比如这道题,先拆成:
    1. 确定每一行能装哪些单词(计算单词长度+最少空格的总和 ≤ maxWidth);
    2. 计算这一行需要分配的总空格数 = maxWidth - 所有单词长度和;
    3. 分场景处理空格:
      • 场景A:最后一行 → 单词间1个空格,剩余空格补在末尾;
      • 场景B:只有1个单词 → 单词左对齐,剩余空格补末尾;
      • 场景C:多行单词 → 计算平均空格数+额外空格数,前N个间隔多1个空格。
  • 边界场景清单:列全所有特殊情况(避免写代码时漏判)
    比如这道题的边界清单:
    ✅ 单词长度刚好等于maxWidth;
    ✅ 多个单词的总长度+最少空格刚好等于maxWidth;
    ✅ 最后一行只有1个单词;
    ✅ 额外空格数为0(空格能均分)。
  • 工具函数规划:提前想清楚哪些逻辑可以封装成函数(减少主逻辑干扰)
    比如这道题:
    • 生成指定长度的空格串 → blank(n)
    • 拼接指定范围的单词(带分隔符)→ join(words, left, right, sep)

训练要求:哪怕思路再清晰,也要强制自己写这3件事,直到形成习惯——这一步能帮你把“模糊的思路”转化为“可落地的步骤”,避免写代码时反复修改。

步骤2:“分块编码”,写完一块验证一块

不要从头到尾写完再调试,而是把代码拆成独立模块,写一块验证一块:
以这道题为例,拆分的编码顺序:

  1. 先写 blank()join() 工具函数 → 写个小测试用例验证(比如 blank(3) 是否返回3个空格,join(words, 0, 2, " ") 是否正确拼接);
  2. 再写“确定当前行单词范围”的逻辑(leftright 的循环)→ 用示例输入验证(比如输入 words = ["This", "is", "an", "example"], maxWidth=16,验证第一行的 left=0, right=3 是否正确);
  3. 最后写“分场景处理空格”的逻辑 → 每个场景单独测试(先测“只有1个单词”,再测“多行单词”,最后测“最后一行”)。

训练要求:每写完一个模块,立刻用小用例验证,确保这一块逻辑正确,再往下写——避免最后一堆问题堆在一起,找不到bug在哪。

步骤3:“刻意复盘”,总结“通用套路”

每做完一道这类题,花5分钟复盘2件事:

  • 卡壳点总结:比如这道题你是不是在“额外空格分配”时卡壳?把这个点记下来,总结成“通用套路”:

    空格分配套路:总空格数S,间隔数G → 平均每个间隔空格数 = S//G,额外空格数 = S%G → 前“额外空格数”个间隔多1个空格,剩余间隔用平均空格数。

  • 边界处理套路:把这道题的边界场景和处理方式,归类到“字符串对齐/空格分配”的通用边界里:

    字符串对齐边界:最后一行、单行单单词、空格无法均分、单词长度等于最大宽度。

训练要求:建立一个“套路笔记”,把同类题的通用逻辑、边界处理记下来——比如“Z字形变换”的周期规律、“文本对齐”的空格分配、“螺旋矩阵”的方向控制,都是可以复用的通用套路。

步骤4:“同类题梯度训练”,从易到难强化

这类“逻辑复杂、细节密集”的题,需要循序渐进练,推荐按这个梯度刷(都是LeetCode题):

  1. 基础级(练工具函数和边界处理)
    • LeetCode 14. 最长公共前缀(练字符串遍历、边界判断);
    • LeetCode 58. 最后一个单词的长度(练空格处理、边界场景);
    • LeetCode 67. 二进制求和(练字符串拼接、进位处理)。
  2. 进阶级(练分场景处理)
    • LeetCode 43. 字符串相乘(练分块计算、进位处理、边界补零);
    • LeetCode 151. 反转字符串中的单词(练空格处理、单词分割、边界去空格);
    • LeetCode 299. 猜数字游戏(练字符统计、分场景拼接结果)。
  3. 挑战级(练综合逻辑拆解)
    • 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;
}

总结

这类“逻辑复杂、细节密集”的算法题,核心能力不是“写代码”,而是:

  1. 把模糊思路转化为结构化步骤(纸面拆解);
  2. 分块编码+即时验证(避免全量调试);
  3. 总结通用套路(复用经验,减少重复思考)。

你能在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;
    }
}


posted @ 2025-12-29 23:56  coder江  阅读(24)  评论(0)    收藏  举报