欢迎来到博主的专栏:算法解析
博主ID代码小豪
文章目录
- 什么是单身狗问题
- leetcode_136——只出现一次的数字I
- 使用位运算解决单身狗问题。
- leetcode_137——只出现一次的数字II
- 统计二进制数解决单身狗问题
- leetcode_260 只出现一次数字III
- 分区域按位异或解决问题。
- 总结
什么是单身狗问题
最近也是度过了5.20和儿童节这两个单身狗受难日,由于这两天学生都出去谈恋爱了,才让我有机会坐在图书馆里沉浸式刷题(也不知是喜是悲)。
在机缘巧合下,我在牛客网和leetcode上都刷到了类似的问题:如何在非空数组当中,找到只出现一次的数字。牛客网对这道题型的起名也很有意思,叫做:单身狗问题,我想这也很符合我的现状(笑)。
leetcode_136——只出现一次的数字I
题目如下:
给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。
这是leetcode上给出的示例
输入:nums = [2,2,1]
输出:1
输入:nums = [4,1,2,1,2]
输出:4
解题思路一:暴力检索法
我们遍历整个数组的元素,每遍历一个元素时,检查数组中的其余元素是否和该元素相等,若相等,就返回该位置的元素。反之检查下一个元素
(博主不会制作动图,放弃了,后面用静图)。
这种方法的时间复杂度为O(N^2),空间复杂度为O(1),但是题目中要求时间复杂度是线性的,显然O(N^2)的算法不符合要求。
方法2:数组映射法。
以示例2为例。先遍历一遍数组,找到差值最大的两个元素。示例2中最小值为1,最大值为4,因此建立一个4个元素的数组。
映射数组中的[0]代表1的个数,[1]代表2的个数,[2]代表3的个数,[3]代表4的个数,然后遍历数组,统计出现数字的个数,映射数组和数组的关系如下,假如当前数组元素num,数组中的最小值为min,映射数组为map。
那么map[num-min]表示的就是数组中num的个数。
通过遍历映射数组,可以发现[3]只有一个数字,那么单身狗数就是[3]+数组的最小值1,即4。数字4是数组中的单身狗。
这种方法的时间复杂度为O(N),空间复杂度为O(N)。符合要求。
但是有没有更好的方法呢?有,上两种算法的难度不高,下面要讲的算法才是重中之中,如果你也是第一次遇到这种题,相信下面的解法会让惊叹。
使用位运算解决单身狗问题。
上面的两种算法的逻辑实际上都是人类的逻辑,也就是通过寻找数字之间的关系找到单身狗,但这些方法都不够高效。
真正高效的方法是利用计算机的机器逻辑,也就是计算机的思考方式,我们站在计算机的角度考虑问题,这些数字其实不是1,2,3,4。而是一个个二进制数字。那么示例1中的数组在计算机眼中应该是这样的。
人类对数字的运算方式是加减乘除,而机器也有对数字的运算方式,分别是按位于,按位或,按位取反、以及按位异或。它们的规则如下:
按位与:符号是&,相同位(bit)上的数字都为1,运算结果为1,反之为0(记作:同一为1,其余为0)。
按位或:符号是|,有1为1,反之为0
按位取反:符号是~,1变为0,0变为1
按位异或:符号是^,相同位(bit)上的数字相同位0,不同为1.记作(相同为0,不同为1)。
解决问题的重点就在于这个按位异或的计算了,我们思考以下两个问题。
(1)一个二进制数与0按位异或的结果是什么?
(2)两个相同的二进制数按位异或的结果是什么?
假如当前有一个二进制数01010101,与0按位异或,其结果为不变,解题如下:
01010101
00000000
——————
01010101
解释:位数相同为0,不同为1,因此任意二进制数与0按位异或时,位上是0的数变为0,位上是1的数任为1。所以任意一个二进制数与0按位异或的结果不变。
假如现在有两个相同的二进制数按位异或,其结果为0。解题如下:
01010101
01010101
——————
00000000
解释:两个相同的二进制数的每个二进制位都相同,按位异或的计算方式是相同为0,不同为1,因此计算结果为0。
从上面两个结论可以推导出下面的结论:
a⊕b⊕b=a⊕0=a。
这个结论可以干什么?没错,这个结论就是解决这个单身狗问题最快的方式,大家仔细想想,单身狗数的数组是怎么样的?除了1个数字出现1次外,其余的数字均出现两次。那么这就好办了,我们让整个数组的数字都进行按位异或计算,那么得出结果是不是就是单身狗数?
计算结果为:
计算结果为:
于是我们得出了这个问题的解决思路:将数组中的全部元素进行异或运算,结果即为数组中只出现一次的数字。
class Solution {
public:
int singleNumber(vector<int>& nums) {
int ret=0;
for(const auto&e:nums)
{
ret^=e;
}
return ret;
}
};
leetcode_137——只出现一次的数字II
这是单身狗问题的plus版,我们还是先看题目:
给你一个整数数组 nums ,除某个元素仅出现 一次 外,其余每个元素都恰出现 三次 。请你找出并返回那个只出现了一次的元素。
示例 1:
输入:nums = [2,2,3,2]
输出:3
示例 2:
输入:nums = [0,1,0,1,0,1,99]
输出:99
这个单身狗问题出现两次的其余数组变成了三次,那么大家思考以下?我们还能不能继续用异或大法?不能。因为只有两个相同的数才会异或的结果为0。但是解决问题的思路还是要放在二进制数上。
统计二进制数解决单身狗问题
这个方法适用所有单个单身狗数的变种问题,也就是除单身狗数外,无论是均有两个、三个、还是四个,都能使用这种方法。
我们先来思考一个问题,如何找到单身狗数的本质是什么?其实就是在众多的数据中找到特定的二进制数。
- 我们假设单身狗数的第一个二进制位是1。其余的数字的第一个二进制位是0,那么我们可以轻易的得出这么一个结论:整个数组第一个二进制位是1的元素有一个
- 如果存在第二个数字的二进制位也是1呢?由于第二个数字会在数组中出现3次,那么整个数组中第一个二进制位的元素有4个。
- 如果单身狗数的第一个二进制位是1,其余的n个数字的第一个二进制位是1。那么整个数组的第一个二进制位的元素有3n+1个。
有没有发现这么一个规律?如果单身狗数的某一位是1,那么整个数组中,和单身狗数一样该位是1的数字会有3n个。算上单身狗数会有3n+1个。于是我们可以得出下面的结论
单身狗数的第 i 个二进制位就是数组中所有元素的第 i 个二进制位之和除以 3 的余数。
那么解题思路就来了,我们统计所有数组中第i位是1的数字个数,让这个结果%3,就可以得出单身狗在第i位是1还是0.
如何统计所有数组中第i位是1的数字个数的方法如下:
假设x的二进制数如下:
00100100 10010011
我们要知道x的第5位是0还是1,我们让1右移(<<)4位。然后让x与1<<4的结果按位与(&),就能知道第5位的结果是1还是0了。
1<<4的结果如下:
00000000 00010000
计算结果如下:
00100100 10010011
00000000 00010000
——————————
00000000 00010000
代码如下:
class Solution {
public:
int singleNumber(vector<int>& nums) {
int ret = 0;
for (int i = 1; i <= 32; i++)
{
int n = 0;//统计次数
for (int x : nums)
{
if ((x & (1 << (i-1))) == 1 <<(i-1))
{
n++;
}
}
if (n % 3 == 1)//次数%3=1则说明第i位为1
ret |= 1 << (i-1);
}
return ret;
}
};
leetcode_260 只出现一次数字III
老规矩,先看题目
给你一个整数数组 nums,其中恰好有两个元素只出现一次,其余所有元素均出现两次。 找出只出现一次的那两个元素。你可以按 任意顺序
返回答案。
示例 1:
输入:nums = [1,2,1,3,2,5]
输出:[3,5]
解释:[5, 3] 也是有效的答案。
示例 2:
输入:nums = [-1,0]
输出:[-1,0]
这次的单身狗问题属于promax版,因为单身狗数从1个变为了2个。但是好在其余元素只出现两次,这就说明我们又可以用异或大法解决问题了(统计大法只能用于单个单身狗数)。
分区域按位异或解决问题。
前面已经推导出了一个结论:
a⊕b⊕b=a⊕0=a。
由这个公式我们还能推导出下一个结论:
a⊕b⊕b⊕c=a⊕0⊕c=a⊕c。
从上式可以得出,如果一个数组存在两个单身狗数,对这个数组的所有元素进行异或计算的结果等于两个单身狗数的异或结果。那么我们该怎么从这个异或的结果分离出两个单身狗数呢?我们先来看看两个数进行异或的结果具有什么性质:
假如a为01010100,c为11001010,a⊕c的结果为
01010100
11001010
——————
10011110
异或结果的具有这么一个意义:如果异或结果上的某一个位是1,就说明a或b中只有一个数,在这个位是1。如果a在这个位是1,那么b在这个位则是0。反之亦然。
那么解题思路就是,找到异或结果当中任意一位的1,然后将整个数组分为两组,一组是在这位是1的数字,另一组是在这位为0的数字,我们神奇的发现,这两个单身狗数竟然被分为了两组。
我们拿示例1为例,整个数组的所有元素异或的结果等于单身狗数3和5的异或结果。
3的二进制数是0000 0011.
5的二进制数是0000 0101
3和5的异或结果为:
0000 0011
0000 0101
——————
0000 0110
我们取异或结果的任意一位1,例如取第二位。我们根据第二位是否为1将数组分为两部分
可以发现一个神奇的现象,那就是数组中的两个单身狗数被分到不同的组当中,我们分别对这两个组进行异或操作,就能得到两个单身狗数。
那么剩下的问题就只剩一个了,那就是如何找到异或结果当中哪一位是1,实际上处理方法也很简单,我们可以让异或结果的每个位都与1进行按位与,就可以知道第几位是1了。
但是这个方法还是有点麻烦,我们有更好的方法。假设异或结果是x,那么x&-x的结果会只留下一个1。很神奇吧,原理在于:正值的补码和原码相同,负值的补码则是正值的反码+1。那么x与x的反码相&的结果为0,如果让x的反码再加上1,就会让按位与的结果只留下一个1
这么说好像有点抽象了,我们试试让x=1.
1的补码为0000 0001。
-1的补码为1的反码+1。1的反码是1111 1110.反码加1为1111 1111.
0000 0001
1111 1111
——————
0000 0001
大家可以多试试几个数,总之结果都是一样。
代码如下:
class Solution {
public:
vector<int> singleNumber(vector<int>& nums) {
int eor = 0;//eor是所有元素的异或结果
for (int num : nums)
{
eor ^= num;
}
int flag = eor & (-eor);//找到一位1.
int single1 = 0;//单身狗数1
int single2 = 0;//单身狗数2
for (int num : nums)
{
if ((num & flag) == flag)
{
single1 ^= num;
}
else
{
single2 ^= num;
}
}
return { single1,single2 };
}
};
这个代码的逻辑没有问题,但是通过不了leetcode的测试,因为存在一个示例是存在问题的。
[1,1,0,-2147483648]。
该数组所有元素的异或结果为:-2147483648。那么这个结果存在什么问题呢?还不记不得我们要进行一个操作,那就是让异或的结果与其负值进行按位于运算。那么-(-2147483648)的值为2147483648,而int类型可以存储的最大值为2147483647.超出了范围,所以编译不通过,解决问题是可以将xor的int类型改为unsigned int。但是不太优雅。更像是走了歪门邪道通过的测试。
最主要的问题还是eor的值是一个特殊值,那么我们就加上一个判断,如果xor等于int类型的最小值,就不将其转换成正数。这才是更优的解决方案。
代码如下:
class Solution {
public:
vector<int> singleNumber(vector<int>& nums) {
int eor = 0;//eor是所有元素的异或结果
for (int num : nums)
{
eor ^= num;
}
int flag =eor==INT_MIN?eor:eor & (-eor);//找到一位1.
int single1 = 0;//单身狗数1
int single2 = 0;//单身狗数2
for (int num : nums)
{
if ((num & flag) == flag)
{
single1 ^= num;
}
else
{
single2 ^= num;
}
}
return { single1,single2 };
}
};
总结
实际上博主并不仅仅是在思考单身狗问题的算法,而是想要抛出一个思想,那就是如果在刷OJ题的时候,人的逻辑难以解决某个问题,那么能不能换个角度,在机器逻辑上寻找突破口呢?当然了博主在这方面还没有太多经验。还要多多努力。