LeetCode之路:448. Find All Numbers Disappeared in an Array

一、引言

这是一道我做了很久很久很久的题目,做题的过程真的非常非常有趣,有趣到做的过程痛不欲生,明明看似那么简单的问题,却总是做不出来,再到做出了第 1 个解法后的平静,到试探了第 2 个解法后的欣喜若狂,再到后面拼命减少 runtime 减少 extra space 的绞尽脑汁。

这真的是一件非常非常快乐的事情!我尽量一步一步的把自己的思维过程写出来,能够给各位有些启发。

这道标签为 easy 的题目如下:

Given an array of integers where 1 ≤ a[i] ≤ n (n = size of array), some elements appear twice and others appear once.

Find all the elements of [1, n] inclusive that do not appear in this array.

Could you do it without extra space and in O(n) runtime? You may assume the returned list does not count as extra space.

Example:
Input:
[4,3,2,7,8,2,3,1]

Output:
[5,6]

题意简单说一下:现在给你一个内容只能在 1 到 n 的长度为 n 的整型数组,这里 n 为这个数组的长度;在这个数组中,每个数字(1 到 n 的整数)只能出现 0 次、1 次或者 2 次。需要你输出在 1 到 n 的集合中,未在这个数组中的数字集合。

并且,要求不使用额外的存储空间(参数数组本数可以除外),并要求时间复杂度为 O(n)。

二、让我们着手这个问题

首先,看到这个问题,浮现在我的脑海里的,是这个时间复杂度 O(n)。

什么是 O(n) 的时间复杂度?那就是单位操作执行了 n 次,比如循环了一个数量为 n 的数组,并做了一些单位操作。这里,我猜测时间复杂度出在了数组的遍历上。

那么,数组遍历的过程中如何处理元素能够得到最后的结果呢?

如果你跟我一样都是小白的话,我们可以忽略掉这个时间和空间复杂度的要求,仅仅以一道简单的题目去看待它,去实现它,然后再进行时间和空间复杂度的优化。

三、方法一:使用 std::set

为什么会想到 std::set

因为有查询是否存在的操作呀。我们只需要建立一个装载了参数数组的 std::set 容器,那么在遍历这个参数数组的时候,只需要检查每个参数元素是否存在于这个 std::set 容器中,则可以找到没有的数组元素。

代码如下:

// my solution , runtime = 222 ms
class Solution1 {
public:
    vector<int> findDisappearedNumbers(vector<int>& nums) {
        vector<int> result;
        set<int> nums_set(nums.begin(), nums.end());
        for (int i = 1; i <= nums.size(); ++i) {
            if (nums_set.find(i) == nums_set.end()) {
                result.push_back(i);
            }
        }
        return result;
    }
};

这里注意,我 LeetCode 上面提交后, runtime 为 222 ms。那么怎么样可以快捷的减少 runtime 呢?很简单,将 std::set 替换为 std::unordered_set,无序容器比有序容器而言,前者基于哈希表, 后者基于 红黑树,在查询效率上,明显哈希表更胜一筹。

替换 std::unordered_set如下:

// replace set to unordered_set , runtime = 162 ms
class Solution2 {
public:
    vector<int> findDisappearedNumbers(vector<int>& nums) {
        vector<int> result;
        unordered_set<int> nums_set(nums.begin(), nums.end());
        for (int i = 1; i <= nums.size(); ++i) {
            if (nums_set.find(i) == nums_set.end()) {
                result.push_back(i);
            }
        }
        return result;
    }
};

看来我们的猜测没错,这里的 runtime 变成了 162 ms,成功减少了运行时间。

那么,我们的问题解决了吗?

并没有,题目要求不使用额外的存储空间,这里使用了额外的std::setstd::unordered_set ,明显不符合题意,我们需要另寻一个方法解决这个问题。

三、方法二:天无绝人之路的数组下标

这里,要明确我们到底需要干什么?

我们需要遍历这个数组,然后标记这个数组中已经有了的元素。或许你会说,这不是废话吗?这个数组中的所有元素都应该被标记,因为这里面的所有的元素都出现了啊,我们要的是 1 到 n 里没有出现的元素:

你怎么能在这个数组里面表示出没有 1 到 n 里没有出现的元素呢?

这才是问题的关键,关键是,我们能够做到吗,在没有额外的存储空间的情况下?

答案是,可以的,我们或许忘了一个关键的条件,那就是这个参数数组的长度为 n。是啊,这个参数数组的长度就是 1 到 n 的取值情况的 n 啊。我们可以使用参数数组的下标,来标识这个数字是否被标识。

到这里豁然开朗了吧!

让我举个例子说明下:

当前数组 : nums = [ 4, 3, 2, 7, 8, 2, 3, 1 ]
第1个数字: nums[0] = 4 -> 标记 nums[4] 出现
第2个数字: nums[1] = 3 -> 标记 nums[3] 出现
第3个数字: nums[2] = 2 -> 标记 nums[2] 出现

依次类推

问题看似已经解决了,其实还有问题,那就是,这里的 nums[2] = 2nums[5] = 2 ,二者都等于 2 , 那么,如果做到两次标识同一个数字,都能保持被标识状态呢?

这里,我选择了 nums[2] += nums.size() 的方法,也就是说,当我们每次取值的时候,我们使用取余来寻找元素,而多次标识这个元素也就是多次加 n 的长度而已,取余后并不影响结果。

这下问题全部解决,代码如下:

// no use array attribute , runtime = 762 ms
class Solution3 {
public:
    vector<int> findDisappearedNumbers(vector<int>& nums) {
        for (auto i : nums) {
            if (i % nums.size() == 0) {
                nums[nums.size() - 1] += nums.size();
            } else {
                nums[i % nums.size() - 1] += nums.size();
            }
        }
        for (auto it = nums.begin(); it != nums.end(); ++it) {
            if (*it <= nums.size()) {
                *it = it - nums.begin() + 1;
            }
        }
        for (auto it = nums.begin(); it != nums.end(); ++it) {
            if (*it > nums.capacity()) {
                it = nums.erase(it);
                --it;
            }
        }
        return nums;
    }
};

这里值得一说的是,我当时阅读题意,以为只能使用参数数组返回结果,所以并没有声明额外的存储空间,相当于我的空间复杂度为 0 ,这里后期为了取出未出现的元素,我使用了 erase 函数,这个函数有一些坑的,尤其是这里:

for (auto it = nums.begin(); it != nums.end(); ++it) {
            if (*it > nums.capacity()) {
                it = nums.erase(it);
                --it;
            }
}

每次调动了 vectorerase 方法后,都会对删除后的元素的迭代器发生影响,这里必须手动接收返回的删除元素的下一个元素的迭代器。

另外,这里为什么要--it,因为循环自动每次循环增加 1 个计数,这里删除后的迭代器指向的是被删除元素的下一个位置,如果再增加 1 个计数,就跳过了下一个元素了,所以这里必须手动减去 1 个计数。

这个问题看似就解决了,时间复杂度仿佛也就是 O(n) 了,空间复杂度还做到了 0。

但是让我们看一看 runtime,我的天,怎么到了 700 多 ms。这不是三个 for 循环而已,应该是 O(n) 的时间复杂度啊!

其实,我们都忘了一点,erase 方法每次删除了一个元素后,会重新复制一下整个数组,这个函数的时间复杂度是 O(n),那么嵌套一层循环,时间复杂度就是 O(n^2) 了。

那么,看来额外的存储空间是必要的了,不然时间复杂度降不下来。

增加额外的存储空间后,代码如下:

// in order to reduce runtime , runtime = 132 ms
class Solution4 {
public:
    vector<int> findDisappearedNumbers(vector<int>& nums) {
        vector<int> result;
        for (auto i : nums) {
            if (i % nums.size() == 0) {
                nums[nums.size() - 1] += nums.size();
            } else {
                nums[i % nums.size() - 1] += nums.size();
            }
        }
        for (auto it = nums.begin(); it != nums.end(); ++it) {
            if (*it <= nums.size()) {
                result.push_back(it - nums.begin() + 1);
            }
        }
        return result;
    }
};

果然,时间复杂度降到了 132 ms。

终于大功告成了,这道 easy 的题目,还真是让我折腾了不少时间呢!

四、方法三:最高票答案的妙招

当然了,以上都是我自己琢磨的方法,根据

自己琢磨的方法永远不会是高票答案

的一致定律,我还是点开了最高票答案看了下。

果然有收获!

来,让我们看看这个思路,这是我仿照最高票答案写的 C++11 的代码:

// perfect solution , runtime = 138 ms
class Solution5 {
public:
    vector<int> findDisappearedNumbers(vector<int>& nums) {
        vector<int> result;
        for (auto i : nums) {
            i = abs(i);
            nums[i - 1] = -abs(nums[i - 1]);
        }
        for (int i = 0; i < nums.size(); ++i) {
            if (nums[i] > 0) {
                result.push_back(i + 1);
            }
        }
        return result;
    }
};

看出了什么区别了吗?

其实思路跟我的方法二都是一样的(小小嘚瑟下),但是标记出现的数字的方法有所不同,作者聪明的使用了取负的方式来标记此数字出现过,非常非常的简单。

不过看了下 runtime,其实效率差的也不多。

另外值得一提的是,这道题目,我使用 C++11 ,runtime 从来没有下过 100ms,但是好像看讨论区中同样的思路,java 能有 2ms 的 runtime,至于为什么同样的思路和写法,两种语言的 runtime 差距那么大,还有待研究。

五、总结

这是一道被虐的过程中各种崩溃,然后做出来后又异常欣喜若狂的题目。

也许因为我本就是一个平凡人,拥有着普通人应该拥有的智商和情商。但是谁又说平凡人不能喜欢刷 LeetCode,谁又能说,平凡人成不了一方大牛。

正因为我是一个平凡人,我懂得自己的界限在哪里,我懂得贸然神离之余还能孤芳自赏。

我爱刷题,我爱 LeetCode,我爱 Coding!

让编程改变世界!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值