[位运算 哈希表] 136. 只出现一次的数字(哈希表法 → 位运算)137.只出现一次的数字 II (位运算+遍历统计、逻辑电路法)260. 只出现一次的数字 III(位运算+分组异或)

136. 只出现一次的数字(其他元素出现2次,一个元素出现1次)

题目链接:https://leetcode-cn.com/problems/single-number/


分类:

  • 哈希表(记录每个元素的出现次数)
  • 位运算(利用异或找出 只出现一次的数字)

在这里插入图片描述

题目分析

本题是“只出现一次数字”系列的基础题,可以帮助我们初步了解异或操作的妙用。

本题中,数组内其他元素都出现2次,只有一个元素出现一次,哈希表是很简单的一个解法,空间复杂度为O(N),这里就不再赘述了。

思路:位运算

对所有元素执行异或操作,得到的最终结果就是只出现一次的那个元素。

这是基于异或运算的下列性质推导而来的结论:

1、a ⊕ 0 = a
2、a ⊕ a = 0
3、满足交换律、结合律

设数组中a1,…,am是重复两次的元素。am+1是只出现一次的元素,运用结合律和交换律可得下式:

a1 ⊕ a1 ⊕⋯⊕ am ⊕ am ⊕ am+1 = am+1

化简得:

0 ⊕ 0 ⊕⋯⊕ 0 ⊕ am+1 = am+1

实现代码:

class Solution {
    public int singleNumber(int[] nums) {
        //特殊用例
        if(nums.length == 1) return nums[0];
        //位运算
        int res = 0;//存放计算过程中的临时结果和最终结果
        for(int num : nums){
            res ^= num;
        }
        return res;
    }
}

137.只出现一次的数字 II(其他元素出现3次,一个元素出现1次)

题目链接:https://leetcode-cn.com/problems/single-number-ii/


分类:

  • 位运算

在这里插入图片描述

题目分析

本题和136题类似,但题目条件发生了变化:数组内其它元素均出现了3次,只有一个元素出现1次。

我们仍然可以沿用136的位运算方法,但要做一定的扩展。

思路1:位运算 + 遍历统计

算法分析:

出现3次的数字的二进制形式中,统计所有这些元素的每一个二进制位1的出现次数可以发现1的出现次数都是3的倍数。

再把只出现一次的数字考虑进去,统计所有元素每一个二进制位1的出现次数,每一位统计结果%3=只出现一次的数字的相应二进制位。

例如:[3,3,5,3],3个3的二进制位为:

	0011
	0011
	0011
统计0033

统计的是每一个二进制位中1出现的次数,可以发现都是3的倍数。

再把只出现一次的5考虑进去:

	0011
	0011
	0011
	0101
统计0134

可以发现每一位的统计结果%3就能得到5的二进制表达式,即0%3=0,1%3=1,3%3=0,4%3=1,可以得到0101即5。

算法流程:

1、int型范围是32位,所以开辟一个int[32]的数组count来存放数组所有元素0~31位的1的出现次数之和。
因为辅助数组的大小是常数,所以空间复杂度O(32)=O(1)

2、利用位运算技巧,统计一个数组中每个元素的32个位出现1的次数,统计结果加到count数组对应位置中,
每个元素要做32次循环,因为循环次数为常数,所以时间复杂度O(32)=O(1)

3、将count数组的每一位数值对3求余,得到的就是只出现一次的数字的每一个二进制位,再将这些位组合起来构成原数字。

实现遇到的问题

1、如何用count统计所有数字的各个二进制位出现1的次数之和?
方法有很多,这里使用的是:开辟的count数组从下标0-31分别对应数字二进制的最高位-最低位,要统计所有数字某一位出现1的次数,等价于求出所有数字的这一位的二进制值之和,所以我们要做的就是:

(1)取出当前数字每个二进制位:
	可以使用num&1取出num的最低位,再将num向右移位,重新执行num&1就能取出num的次低位,
	以此类推。
(2)每个二进制位都累加到count的对应位置中:要分清count数组的高低位和num高低位的对应关系。
	这里设置的是count下标 0 存放二进制最高位,下标31存放二进制最低位。
for(int i = count.length - 1; i >= 0; i--){
    count[i] += num & 1;
    num >>>= 1; 
}

2、如何将二进制位组合成一个int型数字?
设组合成的最终数字是res,使用移位运算符来生成res时,要注意区分count[i]代表的高低位和res的高低位的区别。

例如:count[31]代表二进制表达式的最后一位,但在组合成res时,我们的思路是先对res的最低位赋值(位赋值采用 |= ),然后res向左移一位(res<<=1),再向补位上来的最低位继续赋值,以此类推。这样一来,在构造res时先赋值的二进制位在移位过程结束后反而移到了高位,所以赋值时需要先拿count[0],也就是要从count的高位值开始对res赋值。

       for(int i = 0; i < count.length; i++){
            res <<= 1;
            res = res | (count[i] % 3);
        }

3、移位运算符的使用:
见 👉 移位运算符的介绍

实现代码:

class Solution {
    public int singleNumber(int[] nums) {
        //特殊用例
        if(nums.length == 1) return nums[0];

        //常数大小的辅助数组,存放元素每个二进制位1出现的次数之和
        int[] count = new int[32];
        //统计每个元素每个二进制位1出现的次数之和(两层循环,但内循环只执行常数次)
        for(int num : nums){
            for(int i = count.length - 1; i >= 0; i--){
                count[i] += num & 1;
                num >>>= 1; 
            }
        }
        int res = 0;
        //将count数组各元素对3取余,组合成整型数字
        for(int i = 0; i < count.length; i++){
            res <<= 1;
            res = res | (count[i] % 3);
        }
        return res;

    }
}
  • 时间复杂度:nums数组共N个元素,每个元素都遍历自己的32位二进制数,时间复杂度O(32)=O(1),所以整体的时间复杂度是O(N)
  • 空间复杂度:辅助数组的大小是常数,所以空间复杂度O(32)=O(1)。

思路2:位运算的优化

思路1的位运算使用了一个大小为32的数组来存放数组所有元素二进制形式中各个位上值为1的个数,等32位都统计完毕后,再用统计结果构造最终数字。所以需要辅助空间来存放这32位统计结果。

但实际上可以不必等到32位都统计完毕才开始组合数字,我们可以直接在某一位完成统计时就把这一位的统计结果 % 3 叠加到最终数字的对应位上,可以节省辅助空间。

如何统计所有元素在第i位上1的个数?

拿每个元素num和(1 << i)相与,如果结果 == 0,说明该元素在第i位处 = 0,如果结果 == 1,说明该元素在第i位处=1.

算法流程直接看代码:

class Solution {
    public int singleNumber(int[] nums) {
        //特殊用例
        if(nums.length == 1) return nums[0];
        
        int res = 0;
        //依次处理int型数字的每一位
        for(int i = 0; i < 32; i++){
            int count = 0;
            //统计所有元素在第i位上1的个数
            for(int num : nums){
                if((num & (1 << i)) != 0) count++;
            }
            //如果第i位上1的个数不是3的倍数,就将res的第i位置1
            if(count % 3 != 0){
                res |= (1 << i);
            }
        }
        return res;
    }
}

思路3:逻辑电路法

数组中存在出现3次和出现1次的元素,由思路1可以发现,把所有元素集中在一起讨论时,如果其他元素都出现3次,只有一个元素出现1次,则统计它们二进制位上出现的1的个数,有的位上1的个数是3的倍数,有的则是3的倍数+1。

但思路1是对int的32位逐位分析的,我们完全可以设计出一个bit位上的操作,然后32个位同时进行。可以将时间复杂度从O(32N)降低到O(N)。

因此下面的分析都是针对元素的某一位的处理

所有元素的该bit位上出现1的次数只有两种可能:出现1的次数%3 == 0或出现1的次数%3 == 1, 我们可以设置几个状态变量,标记当前位上是第几次出现1。

1出现的次数 mod 3 的范围就在0~2之间,所以我们最少需要两个bit位才能涵盖所有状态,设这两个bit位为one,two:

one two
 0   0  表示出现0次的状态,
 0   1  表示出现1次的状态,
 1   0  表示出现2次的状态,
 0   0  表示出现3次的状态。

状态之间的转移为:00 -> 01 -> 10 -> 00.

  • 如果当前bit位值为0,则状态不变;
  • 如果当前bit位值为1,则由当前状态(one,two)转移到下一个状态(newOne,newTwo)。

由此可以列出状态转移的真值表:(num表示当前元素的某bit位的值)
在这里插入图片描述

根据真值表画出one和two对应的卡诺图:

newOne的卡诺图:
在这里插入图片描述
其中,X表示这一位的真假我们并不关心。然后在卡诺图上画圈,可以得到上图的结果。

卡诺图画圈规则:
- 区域必须是长方形(正方形)
- 卡诺图上下左右是联通的
- 区域内只能包含1或X
- 区域的大小必须是2的幂(2,4,8……)
- 每个区域尽可能地大
- 画完后不能有1在区域外

newOne卡诺图中上方的圈,表示 one & ~num,下方的圈表示 two & num,将这两个圈进行或操作,可以得到newOne的逻辑转换公式:

newOne = (one & ~num) | (two & num)
同理可得:
newTwo = (~one & ~two & num) | (two & ~num)

这只是所有元素一个bit位的状态,如果把one,two修改为int型,一次就能同时处理整个int型32位。因为数组中不存在出现2次的元素,所以1 0只是过渡状态,最终一个bit位上只可能有两个状态00和01,00表示所有元素在这一位上1的个数%3=0,01表示所有元素在这一位上1的个数%3==1。

最终所有元素处理完毕后,返回的是two,因为two上存放的就是出现1次的元素:

例如:[2,2,3,2] 

提取出two:0011,就是只出现1次的3.

实现代码:

//思路3
class Solution {
    public int singleNumber(int[] nums) {
        //特殊用例
        if(nums.length == 1) return nums[0];

        int one = 0, two = 0;
        for(int num : nums){
            int newOne = (one & ~num) | (two & num);
            two = (~one & ~two & num) | (two & ~num);
            one = newOne;
        }
        return two;
    }
}

260. 只出现一次的数字 III(其他元素出现2次,两个元素出现1次)

题目链接:https://leetcode-cn.com/problems/single-number-iii/


分类

  • 哈希表(常规方法:统计每个元素的出现次数,空间复杂度O(N));
  • 位运算(将两个出现1次的元素分到两个不同的组,各分组内做异或寻找只出现一次的元素(同136),空间复杂度O(1))。

在这里插入图片描述

思路1:哈希表

136,137,260都可以用哈希表解决,属于基本解法,空间复杂度为O(N)。这里不再赘述。

  • 时间复杂度:O(N)
  • 空间复杂度:O(N)

思路2:位运算-分组异或(空间O(1),推荐)

在leetcode136中,对所有元素做异或找到了只出现一次的元素。这题只出现一次的元素的有两个,考虑把这两个元素分到两个不同的组中,再对每个组做与leetcode136相同的异或,就能从每个组中各找到只出现一次的那个元素。

分组的要求:

  1. 两个只出现一次的元素必须分在不同组;

  2. 出现两次的元素两个元素都要分在同一组,这样才能在异或时抵消为0;

    两个分组的大小不要求一致,只要满足前面两个条件就行(这也是我看题解时的最大误区,一直想着分组时要如何保证两个分组大小相同)

分组的方法:

基于分组的要求,再结合位运算的思想和leetcode136的解法,可以设两个只出现一次的数字为a,b,对nums数组所有元素做异或,最终得到a ^ b 的结果x。

x的二进制形式中,值为1的位就是a,b之间值不相同的位,不同的位可能有很多,但不可能没有不同位,所以分组可以从这些不同位中选出任意一个位作为分组依据,对所有元素都做分组处理。

判断该位=1分为一组,该位=0分为另一组,这样a,b一定会被分到不同的组中,满足了要求1;出现两次的元素在这个位上的值也一定是相同的,所以会被分到同一组,这样要求2也满足了。

为了简化处理,就采用a^b中的最右边第一个1的位作为分组依据位。

算法流程:

  1. 对数组所有元素做异或,得到a^b的结果x
  2. 找到x的二进制表达式中值=1的最低位是哪一位,作为分组依据位
  3. 开始分组,对所有元素都判断依据位的值,值=1分为一组;值=0分为另一组
  4. 对两个组分别再做异或,就能得到两个只出现一次的数字,组合成数组,返回。
实现遇到的问题

1、分组过程如何保证空间复杂度为O(1)?
两个分组都不需要开辟空间来存放,直接在分组过程中迭代计算各个分组的异或结果,在分组结束异或操作也结束了。

所以可以开辟两个变量用于存放分组异或过程的中间结果。

2、如何求出x的最右边的值为1的位?(要保留该位所在的位置)
方法1:该方法不能保留最右端的1所在的位置。

    int mask = 0;
    while(mask == 0){
        mask |= x & 1;
        x >>>= 1;
    }

方法2:(推荐)

    int mask = 1;
    while((x & mask) == 0){
        mask <<= 1;
    }

方法3:(推荐)

    int mask = x ^ (-x);

3、如何在数组初始化时设置初始元素?

return new int[]{元素,....};

实现代码:

class Solution {
    public int[] singleNumber(int[] nums) {
        //特殊用例
        if(nums.length == 2) return nums;

        //求出a^b的结果x
        int x = 0;
        for(int  num : nums){
            x ^= num;
        }
        //找出x的二进制形式中最右边的1的所在位置mask
        int mask = 1;
        while((x & mask) == 0){
            mask <<= 1;
        }
        //两个变量分别存放两个分组异或的中间结果
        int a = 0, b = 0;

        //nums数组按mask的值进行分组,同时做异或运算
        for(int num : nums){
            if((num & mask) == 0){
                a ^= num;
            }
            else{
                b ^= num;
            }
        }
        //最后a,b存放的就是两个分组的异或结果,就是两个只出现一次的元素
        return new int[]{a,b};
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值