[从零开始面试算法] (02/100) LeetCode 283. 移动零:快慢指针的艺术标题】

引言:

欢迎回到我的[从零开始面试算法]系列!今天,我们将转向另一类同样至关重要的问题——序列处理,并迎接一种优雅而高效的算法思想——双指针

作为本系列的第二篇,我们将以 LeetCode 283. 移动零 为起点。这道看似简单的题目,是理解双指针,特别是“快慢指针”模型的绝佳入口。本文将带你一起:

  1. 分析并实现 O(n²) 的暴力解法,理解其瓶颈所在。

  2. 深入理解“快慢指针”这一核心模型的工作原理。

  3. 探讨不同最优解法之间的细微性能差异,并解答关于双指针的一些常见困惑。

无论你是算法新手,还是希望巩固基础知识的同学,相信这篇文章都能让你有所收获。让我们开始吧!

一、题目呈现

  • 题目编号:283. 移动零

  • 题目难度:简单

  • 题目链接283. 移动零 - 力扣 (LeetCode)

  • 题目描述

    给定一个数组 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 指针勇往直前地遍历整个房间。

  1. 如果 fast 发现了“垃圾”(值为 0),他直接跳过,继续前进。

  2. 如果 fast 发现了“宝贝”(非零值),他就把这个“宝贝”交给门口的 slow。slow 接收到“宝贝”后,把它安置在自己当前的位置,然后自己往里走一步,扩大“整洁区域”的范围。

让我们用 nums = [0, 1, 0, 3, 12] 来进行一次完整的演练:

fastnums[fast]操作 (Action)数组状态 (nums)slow
初始[0, 1, 0, 3, 12]0
00遇到“垃圾”,fast 前进[0, 1, 0, 3, 12]0
11找到“宝贝”,交给 slow (nums[0] = 1),slow 前进[1, 1, 0, 3, 12]1
20遇到“垃圾”,fast 前进[1, 1, 0, 3, 12]1
33找到“宝贝”,交给 slow (nums[1] = 3),slow 前进[1, 3, 0, 3, 12]2
412找到“宝贝”,交给 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 指针作为“已处理”和“未处理”区域的分界线,是原地算法中一个极其常用且强大的技巧。

希望这篇文章能帮你彻底掌握快慢指针。在下一篇文章中,我们将继续探索双指针的另一种形态——对撞指针。敬请期待!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值