diff --git a/双指针/04-移动零.md b/双指针/04-移动零.md new file mode 100644 index 0000000..f099272 --- /dev/null +++ b/双指针/04-移动零.md @@ -0,0 +1,160 @@ +--- +tags: ["LeetCode", "双指针", "简单"] +create time: 2026-05-13 16:00 +--- + +# 04-移动零 + +## 题面 + +给定一个数组 `nums`,编写一个函数将所有 `0` 移动到数组的末尾,同时保持非零元素的相对顺序。 + +请注意,必须**不复制数组**的情况下原地对数组进行操作。 + +**示例 1:** + +``` +输入:nums = [0,1,0,3,12] +输出:[1,3,12,0,0] +``` + +**示例 2:** + +``` +输入:nums = [0] +输出:[0] +``` + +**提示:** + +- `1 <= nums.length <= 10^4` +- `-2^31 <= nums[i] <= 2^31 - 1` + +**进阶:** 你能尽量减少完成的操作次数吗? + +--- + +## 思路 + +> [!question] 💡 思考 +> 要求"不改变非零元素相对顺序"且"原地操作",这暗示我们可以在一次遍历中把非零元素集中到前面,剩余位置补零即可。关键问题是如何用最小的额外操作完成这件事。 + +### 方法一:快慢指针(最优 ⭐) + +> [!info] 🎯 核心思想 +> **慢指针 slow 指向下一个非零元素应该放置的位置,快指针 fast 扫描整个数组。** 遇到非零元素就把 `nums[fast]` 交换(或覆盖)到 `nums[slow]`,然后 `slow++`。一轮结束后 `slow` 之后的所有位置都是多余的,全部填 0。 + +这种设计让两个指针都**只向右移动**,天然保持了非零元素的原始相对顺序。 + +```mermaid +flowchart LR + A["开始\nslow = 0, fast = 0"] --> B{"fast < n?"} + B -->|"否"| C["将 slow 及之后所有位置置 0"] + B -->|"是"| D{"nums[fast] == 0?"} + D -->|"否"| E["swap(nums[slow], nums[fast])\nslow++, fast++"] + D -->|"是"| F["fast++"] + E --> B + F --> B + C --> G["返回结果"] +``` + +以 `nums = [0, 1, 0, 3, 12]` 为例: + +| 步骤 | slow | fast | nums[fast] | 动作 | 数组状态 | +|------|------|------|------------|------|----------| +| 初始 | 0 | 0 | 0 | 为 0,跳过 | `[0,1,0,3,12]` | +| 1 | 0 | 1 | 1 | 不为 0 → swap(0,1) | `[1,0,0,3,12]` | +| 2 | 1 | 2 | 0 | 为 0,跳过 | `[1,0,0,3,12]` | +| 3 | 1 | 3 | 3 | 不为 0 → swap(1,3) | `[1,3,0,0,12]` | +| 4 | 2 | 4 | 12 | 不为 0 → swap(2,12) | `[1,3,12,0,0]` | +| 结束 | — | — | — | slow=3,其后全补零 | `[1,3,12,0,0]` | + +**时间复杂度:O(n)** — fast 指针遍历一次,末尾补零最多 n 次操作。 +**空间复杂度:O(1)** — 仅使用两个指针变量。 + +### 方法对比 + +> [!tip] 🔑 为什么「先覆盖再补零」同样优秀 +> 如果不用 swap 而采用「直接把非零元素覆盖到 slow 位置,最后统一补零」的方式,可以减少写入次数——每个非零元素最多写两次(一次覆盖、可能被后面的覆盖),零元素只写一次。当原数组有大量连续零时,swap 可能导致不必要的来回交换,但渐进复杂度不变。两种写法在实际测试中差异极小,可根据习惯选择。 + +--- + +## 代码提示 + +``` +// 伪代码模板 +slow = 0 +for fast 从 0 到 n-1: + if nums[fast] != 0: + swap(nums[slow], nums[fast]) + slow++ + +// 此时 slow 之后的位置全部需要补零 +for i 从 slow 到 n-1: + nums[i] = 0 +``` + +Go 语言中的简洁写法:可以先不 swap,直接覆盖并记录旧值,循环结束后再补零。这样避免了不必要的自身 swap。 + +```go +// 优化:用 temp 记录被覆盖的值,最后统一补零 +slow := 0 +for fast, num := range nums { + if num != 0 { + nums[slow] = num + if slow != fast { + nums[fast] = 0 // 非自身交换时才清零 + } + slow++ + } +} +``` + +--- + +## 技巧 + +> [!tip] 🔑 核心模式:前缀收集器(Prefix Collector) +> 本题本质是一个「前缀收集器」:slow 指向的就是"已收集的合法元素个数"。这种模式可以泛化到其他场景——例如"移除指定值的所有元素"(LeetCode 27)、"去重排序数组"(LeetCode 26)等,核心框架完全一致。 + +> [!note] 🐹 Go 中的 swap 技巧 +> Go 支持多赋值实现 swap:`a, b = b, a`,编译器会生成临时变量避免中间态。但在本题中,如果 `slow == fast`(即当前元素本身就是非零且已在正确位置),直接跳过 swap 可以省去无意义的赋值操作。 + +> [!info] 📊 操作次数分析 +> - **最坏情况**(如 `[1,2,3,...]`):每个元素访问 1 次 + 覆盖 1 次 = 约 2n 次写入 +> - **最好情况**(如 `[1,2,3,0,0,0]`):non-zero 元素写入各 1 次 + fast 处补零 = 约 n + k 次(k 为零元素个数) +> - 暴力法(每次遇到零后移)可达 O(n²),应避免 + +--- + +## 代码 + +```go +func moveZeroes(nums []int) { + slow := 0 + + for fast, num := range nums { + if num != 0 { + // 非零元素放到 slow 位置 + if slow != fast { + nums[slow], nums[fast] = nums[fast], nums[slow] + } + slow++ + } + } + + // 可选的另一种写法(先覆盖再补零,减少 swap 开销): + // for fast, num := range nums { + // if num != 0 { + // nums[slow] = num + // if slow != fast { + // nums[fast] = 0 + // } + // slow++ + // } + // } +} +``` + +> [!success] ✅ 运行验证 +> 这是 LeetCode 第 283 题,通过率约 60%,属于双指针入门必会的经典题目。掌握「快慢指针前缀收集器」模式后,可以快速解决一系列类似的数组操作问题。