LeetCode之路:283. Move Zeroes

来源:互联网 发布:linux sp2 1503 编辑:程序博客网 时间:2024/06/06 13:12

一、引言

一看到这道题,我就想起了曾经在西安一家公司面试的时候,一位面试官问我的一道题:

在一个数组中,零散的分布了一些正整数和 0,你可以写个方法把所有的 0 都移动到数组的最后边吗?

题目要求很简单,完成这道题的思路也有很多,当时脑海里冒出的第一个念头就是:

排序

嘿嘿,就是这么“懒”(使用std::sort方法就可以了),但是面试官不满意,让我再想一个方法,于是就有了今天 LeetCode 这道题的我的第一个思路。

前言说了这么多,这里先看看题目信息吧:

Given an array nums, write a function to move all 0’s to the end of it while maintaining the relative order of the non-zero elements.

For example, given nums = [0, 1, 0, 3, 12], after calling your function, nums should be [1, 3, 12, 0, 0].

Note:

1.You must do this in-place without making a copy of the array.
2.Minimize the total number of operations

题目信息很简单,这里简要概括下:

给定一个数组 nums,编写一个函数,使得所有的 0 元素都移动到数组的最后,并且要保证非 0 元素的相对位置。

注意:
1.你必须在数组内完成这个任务
2.尽量减少单位操作的次数

我做 LeetCode 题目的习惯是,先甩开题目上那么多时间或者空间上的苛求,简简单单让我们一步一步地分析、思考、设计和优化,最后才去考虑如何完成这道题目的要求,这个过程本身就是一种享受。

那么,现在让我们忽略题目的性能要求,来看看这道题怎么做吧。

二、面试时的思路:快速排序

前言里说过,相似的题目曾经在面试中遇到过,我的第一个想法是排序。

为什么想到排序呢?

因为这个数组很明显,只有非负数,那么 0 就是最小的数字,我只需要从大到小排序,那么所有的 0 就自动的都到最后了。

但是面试官说,能不能再想一个办法(很明显对于我这种思维惰性很不满意T_T)?

然后我就顺着排序的思路继续思考,想到了快速排序。

快速排序是什么呢?指定一个范围,然后低位置寻找到第一个大于中间值的值,高位置寻找到第一个小于中间值的值,然后两者交换。这是快速排序的核心思想。而这道题可不可以借鉴呢?

这当然是可以的:

  1. 我可以指定最低位也就是从 0 开始一个标记位 low,最高位也就是数组 nums 的最后一个元素的位置 hign,然后 low 向右边寻找第一个为 0 的元素,high 向左边寻找第一个非 0 的元素,然后进行交换
  2. 交换完成后,需要进行 low 和 high 的位置变更,其中 low 需要向右递增一位,high 需要向左减少一位
  3. 直到 low >= high 了循环停止,否则就继续上述的操作

思路很清晰,代码也就出来了:

// my solution 1 , failed class Solution1 {public:    void moveZeroes(vector<int>& nums) {        int low = 0, high = nums.size() - 1;        while (low < high) {            while (nums[low] != 0) ++low;            while (nums[high] == 0) --high;            swap(nums[low++], nums[high--]);        }    }};

这里值得一说的是:

while (nums[low] != 0) ++low;

是寻找第一个为 0 的元素,而

while (nums[high] == 0) --high;

是寻找第一个非 0 的元素,这种常用的代码,可以作为一种深深的痕迹记在脑海里,需要的时候就拿出来使用。

代码是写出来了,其实并不满足题目要求:

要求保证非 0 元素之间的相对位置

众所周知的,快速排序是不稳定的排序方法,其不稳定性就在于它的交换操作,所以这里我们虽然做出来了一个小小的尝试性函数,却不能满足题意。

那么,怎么样才能保证非 0 元素的相对位置呢?于是我又开始了下一种思路的思考。

三、打破思维惯性:没有人规定两个指针必须一头一尾

同样是来源于快速排序的启发,一个标记位在数组最左边,一个标记位在数组最右边,从而发生的交换,当然无法满足非 0 位的相对位置。

那么,我们就两个标记位都从左边开始,依次使最左边的非 0 数与最左边的 0 元素进行交换,那么一定就可以保证非 0 元素的相对位置了。

那么我们就可以这么设计实现思路:

  1. 设计两个标记位从数组的最左边也就是 0 号位开始,其中一个标记位标记最左边的非 0 数的位置,另一个标记位标记最左边的 0 元素的位置,然后我们进行两个标记位上元素的交换
  2. 交换完成后,我们需要递增两个标记位的位置
  3. 直到非 0 元素的标记位或者 0 元素的标记位到达了数组末尾,退出循环为止,否则一直循环处理

如此这般,我们保证了非 0 元素的相对位置,并且完成了题目要求:

class Solution2 {public:    void moveZeroes(vector<int>& nums) {        int zero = 0, non_zero = 0;        while (zero < nums.size() && non_zero < nums.size()) {            while (nums[zero] != 0) ++zero;            while (nums[non_zero] == 0) ++non_zero;            swap(nums[zero++], nums[non_zero++]);        }    }};

这里值得一说的是,C++11 实现交换操作的函数 std::swap(),是一个非常方便的函数,习惯 C++11 的编写方式的人,就应该尽量使用已经造好的轮子,而不是自己声明一个 temp 变量去中转,这样效率肯定不如标准库实现的高,想要了解标准库 std::swap 信息的同学可以点击这里 C++在线参考手册之std::swap。

代码终于实现了,让我们来提交代码看看 runtime 吧:

Runtime Error Message:malloc() : memory corruption : 0x0000000000f161b0 * **

咦?!这是什么问题呢?看到这个出错信息,大概就知道应该是下标访问越界了,那么我上面的代码究竟哪里涉及到了下标越界了呢?

找了许久我才发现,原来是这里:

wrong answer

这里我递增了 zero 和 non_zero 变量,并没有进行越界检查,就直接使用了这两个标记变量进行了数组元素的访问,于是出错。

修改了之后,想着这下终于可以通过测试用例了吧:

wrong test case

额?!感觉有点莫名奇妙,这又是发生了什么样的事情?

四、逻辑问题:不是每一次循环都需要交换

这个时候,很无奈的我只能拿出来 IDE 进行调试查看,终于找到了非常隐晦的逻辑错误。

原来是这样的,当我在使用两个标记位标记最左边的 0 元素和非 0 元素的时候,并没有考虑到这两个标记位的相对位置,也就是说,当我找到的第一个非 0 数已经在第一个 0 元素的左边的时候,我就不应该再进行交换操作了,而上述代码不管三七二十一,进行了交换操作,所以导致了这个 case 未通过的结果。

那么我们现在修改逻辑,需要考虑两个标记位的相对位置:

  1. 我们使用两个标记位标记最左边的第一个非 0 元素和 0 元素;如果非 0 元素位于 0 元素的右边,则进行交换操作,否则不进行交换操作
  2. 当我们进行了交换操作,那我们就应该递增两个标记变量,使两个标记变量都从下一个位置开始重复循环操作;而如果我们未进行交换操作,那么只可能是非 0 数的位置位于 0 元素的左边,那么我们只需要递增非 0 数的标记计数即可
  3. 直到两个标记位计数有其一到达了数组末尾,循环处理结束,否则继续循环处理

这里的逻辑较之上一份代码,添加了两个标记位之间的考虑,并且这里要注意的是,如果未进行交换,需要递增的只有非 0 数的标记计数。

这个逻辑的代码如下:

// my solution3 , runtime = 16 msclass Solution3 {public:    void moveZeroes(vector<int>& nums) {        int zero = 0, non_zero = 0;        while (true) {            while (zero < nums.size() && nums[zero] != 0) ++zero;            while (non_zero < nums.size() && nums[non_zero] == 0) ++non_zero;            if (zero == nums.size() || non_zero == nums.size()) break;            if (zero < non_zero) swap(nums[zero++], nums[non_zero++]);            else ++non_zero;        }    }};

尝试点击提交,终于看到了绿色的 Accepted 标记,分析修改了这么多,终于算是 AC 了这道题了!

最后的这一份我做的代码,从逻辑上来说,比较复杂不容易让人理解,从时间复杂度来说,嵌套 while 循环,单位操作交换,性能确实不高。

那么,是时候让我们看看最高票答案的思路了。

五、思维陷阱:为何非要快排

这里直接贴上最高票答案的代码:

// perfect sulution , runtime = 16 msclass Solution4 {public:    void moveZeroes(vector<int>& nums) {        int j = 0;        for (int i = 0; i < nums.size(); ++i) {            if (nums[i] != 0) {                nums[j++] = nums[i];            }        }        for (; j < nums.size(); ++j) {            nums[j] = 0;        }    }};

当看到这份代码的时候,我内心是非常无奈的。

是啊,其实这道题就是这么简单呀,明明就可以使用这么简单的方法,为何要陷入自己的思维陷阱去模仿快排的交换操作完成这道题呢?

不得不说,琢磨自己的解法也是一种成长。

这里简要解析下最高票答案的思路:

  1. 把这个数组中所有的 N 个非 0 元素全部都一一赋值给数组前 N 个元素位
  2. 把数组中 N 个元素位之后的元素全部赋值为 0

就这么简单,不使用交换操作,只需要遍历两次数组,清晰明了的思路,简单易懂的代码,完美代码的诠释!

六、总结

这是一道看似简单,编写代码容易,但是通过全部测试用例却比较困难的题目。

程序设计中,设计思路优先决定了实现的难易程度;其次,在具体实现的时候,也需要考虑到繁多的测试用例。也许,这也是测试这一工种存在的必要性。

最近的工作实在太忙,很久很久都没有空刷题写博客了。尽管工作写的是业务代码,逻辑繁复冗杂,但是不得不说,学会如何去处理繁杂的业务逻辑,也是一种成长,或画图或总结,都是一种提升。

我还是认为,程序员最主要的工作,就是解决问题;
同样的,程序员最重要的能力,也就是解决问题的能力;
能够快速学习一门技术去解决问题是一种能力,能够熟练掌握能使用的工具并且选择更优的实现方式去解决问题也是一种能力。

当然了,作为程序员,追求简洁和优美是本性,解决问题更是家常便饭,而解决问题过后的快乐,相信是我们每个程序员都赖以生存的精神食粮。

最后的最后,以小甲鱼老师的名言结尾:

让编程改变世界!

0 0