数组刷题总结
按照 「纸面拆解→分块编码→可视化建模→刻意复盘→同类题梯度训练」 的全套流程,来解 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。
练习注意事项
- 流程优先于结果:哪怕你能直接写出摩尔投票法的代码,也要强迫自己走“纸面拆解→分块验证→可视化”的流程——目的是训练“结构化思考”,而非“背答案”;
- 重点理解“抵消”思想:摩尔投票法的核心不是“计数”,而是“多数元素的投票数足够抵消所有其他元素的投票数”,理解这一点才能迁移到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号