数组刷题总结

按照 「纸面拆解→分块编码→可视化建模→刻意复盘→同类题梯度训练」 的全套流程,来解 XX这道题.

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江  阅读(9)  评论(0)    收藏  举报