283. 移动零
这道题目是 “移动零” 。它在面试中主要考察对 “原地(In-place)修改” 和 “双指针” 技巧的掌握。
题目要求把所有的 0 挪到最后,同时不能破坏非零数字原本的先后顺序,最关键的是:不能开辟新数组。
🏠 生活案例:排队剔除空位
想象一排座次,里面坐着一些人(非零数字)和一些空椅子(数字 0)。老师要求把所有人都往前挪,把空椅子都留到最后。
你的代码逻辑(快慢指针法):
- 慢指针 (
slow) :像是一个“管理员”,他站在队列的最前方,负责盯着“下一个有用的人应该坐在哪”。 - 快指针 (
fast) :像是一个“侦查员”,他跑在前面,专门寻找那些“不是空椅子的人”。
动作描述:
- 当侦查员
fast发现一个人时,就赶紧叫他坐到管理员slow指着的位置上。 - 如果此时管理员和侦查员不在同一个位置,说明中间有“空椅子”,于是两人交换位置:人坐到前面,空椅子被换到后面。
💻 代码实现与生活化注释
JavaScript
/**
* @param {number[]} nums
* @return {void} Do not return anything, modify nums in-place instead.
*/
var moveZeroes = function(nums) {
// 1. 管理员 slow:记录下一个非零元素该去的位置
let slow = 0;
// 2. 侦查员 fast:负责遍历整个数组寻找非零元素
for(let fast = 0; fast < nums.length; fast++){
/**
* 3. 发现目标!
* 只要侦查员发现当前的数不是 0
*/
if(nums[fast] !== 0){
/**
* 4. 只有当快慢指针不在同一位置时,才交换
* (如果一开始全是正数,fast 和 slow 会一直重合,没必要自己跟自己换)
*/
if(fast !== slow){
// 经典的交换动作:把非零数换到前面,把 0 换到后面
let temp = nums[fast];
nums[fast] = nums[slow];
nums[slow] = temp;
}
/**
* 5. 管理员挪动位置:
* 因为刚才那个坑位已经坐了一个“非零人”,管理员要指下一个坑位了。
*/
slow++;
}
}
// 因为是原地修改,不需要返回新数组
return nums;
};
🔍 深度拆解:为什么这样做很“妙”?
1. 相对顺序不变
因为 fast 是从左往右按顺序找人的,找到一个换一个,所以 1, 3, 12 的先后顺序被完美保留了下来。
2. 空间复杂度
我们没有用 new Array(),只是用了两个变量(指针)和一点点交换用的临时变量,极大地节省了内存。
3. 时间复杂度
不管数组多长,fast 只需要走一遍,效率非常高。
💡 进阶思考
如果你直接把非零的数填到前面,最后再手动补零,也是一种解法。但你代码里的这种“交换”写法更进一步:它在移动非零数的同时,自动就把 0 甩到了后面,不需要第二次循环去补零,操作次数更少!
11. 盛最多水的容器
🏠 生活案例:搬家抬水槽
想象你和朋友在搬一个巨大的简易水槽(由两块挡板组成)。
- 目标:两块挡板之间的面积越大,能装的水就越多。
- 物理规律:水槽能装多少水,取决于矮的那块挡板(短板效应)。
你的策略(双指针法):
-
你站在最左边 (
left),朋友站在最右边 (right)。这是初始状态,虽然宽度最大,但挡板可能很矮。 -
关键决定:接下来谁往中间挪?
- 如果挪动高的那块板,宽度变窄了,而高度最高也只能是刚才那块矮板的高度,水量只会变少。
- 如果挪动矮的那块板,虽然宽度变窄了,但我们有机会遇到一块更高的板,从而弥补宽度损失,让水量变多。
-
结论:谁矮谁就往中间挪,直到两人碰头。
💻 代码实现与生活化注释
JavaScript
/**
* @param {number[]} height
* @return {number}
*/
var maxArea = function(height) {
// 1. 初始化:我站开头,朋友站末尾
let left = 0;
let right = height.length - 1;
let maxsquare = 0; // 记录历史上出现过的最大盛水量
// 2. 只要我们还没碰头,就继续尝试
while(left < right){
// 计算当前的宽度(底边)
let currentwidth = right - left;
/**
* 3. 核心物理规则:木桶效应
* 水的高度取决于左右两块挡板中较短的那一块
*/
let currentheight = Math.min(height[right], height[left]);
// 计算当前盛水量:底 * 高
let currentquare = currentheight * currentwidth;
// 更新最高纪录
maxsquare = Math.max(currentquare, maxsquare);
/**
* 4. 贪心策略:谁矮谁往中间挪
* 我们保留高的,抛弃矮的,试图在后面找到更高的挡板
*/
if(height[left] < height[right]){
left++; // 左边矮,左边向右挪
} else {
right--; // 右边矮(或一样高),右边向左挪
}
}
return maxsquare;
};
🔍 深度拆解:为什么不需要尝试所有组合?
如果用暴力法,需要对比 次组合。而双指针法只需要 次,因为它排除了无效的搜索方向。
- 当我们确定
height[left]比height[right]矮时,以left为左边界的所有其他组合(比如left与right-1,left与right-2等)的盛水量一定都比当前这个组合少。 - 因为宽度在不断减小,而高度受限于
left这块“短板”,再怎么换右边的挡板,高度也不会超过height[left]。 - 所以,我们可以理直气壮地“扔掉”
left这块板,让它往中间移动,去寻找转机。
总结
这就是算法中的贪心思想结合对撞指针。它告诉我们:在资源(宽度)不断缩减的情况下,必须通过提升核心竞争力(高度)才有可能获得更大的收益。
15. 三数之和
15. 三数之和
🏠 生活案例:三人凑单免单
想象你在逛街,商家有个活动: “三人组团,消费总额刚好为 0 元(假设有抵扣券负值)即可免单” 。
-
规则:不能找重复的组合(比如“小明、小红、小刚”和“小红、小刚、小明”是一回事)。
-
你的策略:
- 先排队:让所有人在路边按身高(数值)从矮到高排好队。这样如果你现在的总和太小,你就往右找“高个子”;太大就往左找“矮个子”。
- 定一个,找两个:固定一个人 A,然后在 A 右边的所有人里,用“两头堵”的方法找 B 和 C。
💻 代码实现与生活化注释
JavaScript
var threeSum = function(nums) {
let len = nums.length;
let res = [];
if(len < 3) return res;
// 1. 排队:从小到大排序,这是双指针的前提
nums.sort((a, b) => a - b);
for(let i = 0; i < len; i++){
// 优化:如果第一个数就大于0,后面全是正数,不可能凑成0了
if(nums[i] > 0) break;
// 2. 去重:如果这个人和上一个人一样,直接跳过(避免重复组合)
if(i > 0 && nums[i] == nums[i-1]) continue;
let L = i + 1; // 左指针(剩下人里最矮的)
let R = len - 1; // 右指针(剩下人里最高的)
while(L < R){
let total = nums[i] + nums[L] + nums[R];
if(total == 0){
// 3. 恭喜凑单成功!
res.push([nums[i], nums[L], nums[R]]);
// 继续去重:如果旁边的人长得一样,直接跳过
while(L < R && nums[L] == nums[L+1]) L++;
while(L < R && nums[R] == nums[R-1]) R--;
// 缩小范围继续找
L++;
R--;
} else if(total < 0){
// 钱不够?左边的矮个子往右挪,换个高点的
L++;
} else {
// 钱多了?右边的高个子往左挪,换个矮点的
R--;
}
}
}
return res;
};
📝 综合复习表
为了帮你把这几张图的代码串起来,我总结了这个对比表:
| 题目 | 核心算法 | 生活比喻 | 关键点 |
|---|---|---|---|
| 53. 最大子数组和 | 动态规划 (Kadane) | 心情存钱罐 | 如果之前的累积是负数,果断“断舍离”从零开始。 |
| 56. 合并区间 | 排序 + 贪心 | 会议室预定 | 必须先排序!然后看当前开始时间是否撞了上一个的结束时间。 |
| 15. 三数之和 | 排序 + 双指针 | 三人凑单免单 | 定一个,移两个。通过排序和跳过相同值来去重。 |
💡 学习小贴士
-
为什么总要排序?
在 56 题(合并区间)和 15 题(三数之和)里,排序是为了变无序为有序,让我们能预测“下一步该往哪走”,从而把复杂度从 或 降下来。
-
为什么三数之和不用哈希表(Map)?
虽然可以用,但双指针在处理**“不去重”**的要求时,比哈希表更直观且省内存。
42. 接雨水
看来你已经挑战到了 LeetCode 热题中的“大 Boss”之一:接雨水。这道题之所以被标为“困难”,是因为它需要你不仅能理解高度的变化,还要能洞察水的深度是由谁决定的。
🏠 生活案例:屋顶的凹槽
想象你在一个不平整的屋顶上排了一排柱子,现在下了一场大雨。
- 核心物理原理:任何一个位置能接多少水,取决于它左边最高的柱子和右边最高的柱子中较短的那一个。这就像木桶原理,最短的那块板决定了水位。
- 计算公式:该位置的水量 = 。
你的代码逻辑(双指针法):
你和朋友分别从屋顶的最左端 (left) 和最右端 (right) 往中间走。
- 你们每走一步,都记下目前为止看到的最高柱子(
leftMax和rightMax)。 - 如果你这边的最高柱子比朋友那边的矮,那你这边的水位就由你这边的
leftMax说了算,你就算出你脚下能接多少水,然后往前走一步。 - 反之,如果你朋友那边的柱子更矮,就处理他那边。
💻 代码实现与生活化注释
JavaScript
/**
* @param {number[]} height
* @return {number}
*/
var trap = function(height) {
let left = 0; // 左指针:从头开始
let right = height.length - 1; // 右指针:从尾开始
let leftMax = 0; // 左侧目前见过的最高海拔
let rightMax = 0; // 右侧目前见过的最高海拔
let total = 0; // 总接水量
while (left < right) {
// 1. 更新两侧的“最高挡板”记录
leftMax = Math.max(leftMax, height[left]);
rightMax = Math.max(rightMax, height[right]);
/**
* 2. 关键决策:哪边矮,就处理哪边
* 因为水位是由“短板”决定的。如果你左边的墙 (leftMax) 比右边的墙 (rightMax) 矮,
* 那么中间不管有什么更高的墙,左边这个位置的水位最高也只能到 leftMax。
*/
if (leftMax < rightMax) {
// 当前位置能接的水 = 左侧最高墙 - 当前地板高度
total = total + leftMax - height[left];
left++; // 处理完,左边向中间靠拢
} else {
/**
* 反之,右边的墙更矮,水位由右边决定
*/
total = total + rightMax - height[right];
right--; // 处理完,右边向中间靠拢
}
}
return total;
};
🔍 深度拆解:为什么双指针可行?
很多人的第一反应是:算某个位置的水量,不是得知道所有左边和右边最高的墙吗?
双指针的巧妙之处在于:
- 当
leftMax < rightMax时,我们其实并不需要知道右边真正最高的海拔是多少。我们只需要知道右边已经存在一个比leftMax更高的屏障(即rightMax),这就足够保证左边这个位置的水不会从右边流走。 - 于是,该位置的水位就完全由
leftMax锁定了。
总结
这道题是“盛最多水的容器”的进阶版:
- 11. 盛最多水的容器:只看两端,算一个大坑。
- 42. 接雨水:看每一个位置,算所有小坑的总和。
这种从两侧向中间收缩的思路,完美避开了 的重复计算,只用一次遍历( 时间)和几个变量( 空间)就解决了困难题。