引言:
欢迎回到我的[从零开始面试算法]系列!今天,我们将转向另一类同样至关重要的问题——序列处理,并迎接一种优雅而高效的算法思想——双指针。
作为本系列的第二篇,我们将以 LeetCode 283. 移动零 为起点。这道看似简单的题目,是理解双指针,特别是“快慢指针”模型的绝佳入口。本文将带你一起:
-
分析并实现 O(n²) 的暴力解法,理解其瓶颈所在。
-
深入理解“快慢指针”这一核心模型的工作原理。
-
探讨不同最优解法之间的细微性能差异,并解答关于双指针的一些常见困惑。
无论你是算法新手,还是希望巩固基础知识的同学,相信这篇文章都能让你有所收获。让我们开始吧!
一、题目呈现
-
题目编号:283. 移动零
-
题目难度:简单
-
题目描述:
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
-
示例:
-
输入: nums = [0,1,0,3,12]
-
输出: [1,3,12,0,0]
-
-
进阶:你能尽量减少完成的操作次数吗?
二、我的思考:从 O(n²) 暴力解法开始
在真实面试中,面试官往往希望看到你的完整思考路径,而不是仅仅一个“背诵”出来的最优解。所以,让我们从最直观的想法开始。
看到题目,我的第一反应是:遍历整个数组,每当我找到一个 0,我就再启动一个内层循环,从当前位置向后找到第一个非零元素,然后交换它们。
这个思路很直接,我们很快可以写出对应的代码:
// 暴力解法
class Solution_BruteForce {
public:
void moveZeroes(std::vector<int>& nums) {
for (int i = 0; i < nums.size(); ++i) {
// 如果当前元素是 0
if (nums[i] == 0) {
// 就从它后面开始,找一个非零元素来交换
for (int j = i + 1; j < nums.size(); ++j) {
if (nums[j] != 0) {
std::swap(nums[i], nums[j]);
break; // 交换完一次就跳出内层循环
}
}
}
}
}
};
分析痛点:
这个方法虽然能得到正确答案,但它的效率很低。因为存在嵌套循环,它的时间复杂度达到了 O(n²)。当数组非常大时,性能会急剧下降。
这引出了一个关键问题:我们真的需要那个昂贵的内层循环吗? 内层循环 j 的工作,似乎在做很多重复的搜索。这正是双指针思想可以大展拳脚的地方。
三、豁然开朗:快慢指针的“清理房间”模型
为了避免无效的重复搜索,我们可以引入快慢双指针。这是一种同向双指针模型,非常适合处理需要“分区”的问题。
让我们用一个比喻来理解它:
把数组 nums 想象成一个需要整理的房间,里面混杂着“宝贝”(非零元素)和“垃圾”(零)。我们的目标是把所有“宝贝”都整齐地放到房间的一侧。
-
slow 指针:一个“安置者”。他站在房间门口。他身后的区域(即 slow 指针的左侧),永远是已经清理干净、只放着“宝贝”的整洁区域。
-
fast 指针:一个“探路者”。他负责跑遍房间的每一个角落,去寻找“宝贝”。
工作流程是这样的:
fast 指针勇往直前地遍历整个房间。
-
如果 fast 发现了“垃圾”(值为 0),他直接跳过,继续前进。
-
如果 fast 发现了“宝贝”(非零值),他就把这个“宝贝”交给门口的 slow。slow 接收到“宝贝”后,把它安置在自己当前的位置,然后自己往里走一步,扩大“整洁区域”的范围。
让我们用 nums = [0, 1, 0, 3, 12] 来进行一次完整的演练:
fast | nums[fast] | 操作 (Action) | 数组状态 (nums) | slow |
初始 | [0, 1, 0, 3, 12] | 0 | ||
0 | 0 | 遇到“垃圾”,fast 前进 | [0, 1, 0, 3, 12] | 0 |
1 | 1 | 找到“宝贝”,交给 slow (nums[0] = 1),slow 前进 | [1, 1, 0, 3, 12] | 1 |
2 | 0 | 遇到“垃圾”,fast 前进 | [1, 1, 0, 3, 12] | 1 |
3 | 3 | 找到“宝贝”,交给 slow (nums[1] = 3),slow 前进 | [1, 3, 0, 3, 12] | 2 |
4 | 12 | 找到“宝贝”,交给 slow (nums[2] = 12),slow 前进 | [1, 3, 12, 3, 12] | 3 |
当 fast 指针走完整个数组后,slow 停在了索引 3 的位置。此时,数组的前 slow 个元素 [1, 3, 12] 就是所有按原始顺序排列好的“宝贝”。接下来的任务就很简单了:把 slow 位置之后的所有“垃圾堆”全部清理成 0。
四、C++ 最优代码与深度解析
根据上面的“清理房间”模型,我们可以写出第一版清晰的 O(n) 解法。
解法一:“填充0”版本 (性能优秀)
这版代码完美地体现了算法的两个阶段。
#include <vector>
class Solution {
public:
void moveZeroes(std::vector<int>& nums) {
int slow = 0;
// 步骤一:将所有非零元素(“宝贝”)移动到数组前部
for (int fast = 0; fast < nums.size(); ++fast) {
if (nums[fast] != 0) {
nums[slow] = nums[fast];
slow++;
}
}
// 步骤二:将 slow 之后的所有位置(“垃圾堆”)填充为 0
for (int i = slow; i < nums.size(); ++i) {
nums[i] = 0;
}
}
};
解法二:“交换”版本 (代码简洁)
题目的进阶要求是“尽量减少操作次数”。我们可以思考,能否将上面两个步骤合二为一?答案是可以的,通过交换操作。
#include <vector>
#include <utility> // 为了使用 std::swap
class Solution {
public:
void moveZeroes(std::vector<int>& nums) {
// slow 指针左边的区域(不包括 slow)都是已处理好的非零元素
for (int fast = 0, slow = 0; fast < nums.size(); ++fast) {
// 当 fast 找到一个非零元素
if (nums[fast] != 0) {
// 就把它和 slow 指向的元素交换
std::swap(nums[slow], nums[fast]);
// slow 前进一格,扩大非零元素的区域
slow++;
}
}
}
};
性能对决:“填充” vs “交换”,哪个更好?
这是一个绝佳的面试加分点!代码简洁的“交换”版本,操作次数一定更少吗?
-
“填充0”版本:总的写操作次数 = (非零元素个数) + (零元素个数) = n。
-
“交换”版本:一次 std::swap 包含 3 次写操作。总的写操作次数 = 3 * (非零元素个数)。
结论:
-
当数组中非零元素很多时(例如 [1,2,3,4,0]),“填充0”版本的写操作次数(5次)远少于“交换”版本(3*4=12次)。
-
只有当非零元素数量少于数组长度的 1/3 时,“交换”版本才开始有优势。
因此,“填充0”版本虽然代码看起来更多,但在平均性能上通常是更优的!
五、深度思考与答疑
问:算法思想中的“指针”,是真的指针吗?
答:不是。这是一个算法领域的概念性术语,意思是“指向序列中某个位置的标记”。在 C++ 代码实现中,我们通常使用整型变量作为数组的索引 (Index) 来扮演这个“指针”的角色,这样更安全、更直观。
问:双指针听起来有点像动态规划,都是“延续之前基础”,有何区别?
答:这个观察非常敏锐!它们都是对暴力解的优化,但本质完全不同。
-
动态规划 延续的是 子问题的解。dp[i] 的值依赖于 dp[i-1] 等已经算出的答案。它是在“解空间”上进行递推。
-
双指针 延续的是 搜索的边界。left 指针敢于前进,是因为 right 指针已经探索过并排除了后面的区域,反之亦然。它是在“数据空间”上进行收缩和排除。
六、总结与收获
通过“移动零”这道题,我们不仅掌握了一种 O(n) 的优雅解法,更深入地理解了双指针思想的精髓。
-
复杂度分析:我们成功地将时间复杂度从 O(n²) 优化到了 O(n),并且没有使用任何额外的数据结构,空间复杂度为 O(1)。
-
核心思想:快慢指针通过定义不同职责的指针(“探路者”与“安置者”),协同完成遍历和修改,是处理原地分区问题的利器。
-
关键技巧:slow 指针作为“已处理”和“未处理”区域的分界线,是原地算法中一个极其常用且强大的技巧。
希望这篇文章能帮你彻底掌握快慢指针。在下一篇文章中,我们将继续探索双指针的另一种形态——对撞指针。敬请期待!