写在前面
最近用到的异或运算越来越多了, 而其中又以只出现一次的数字为经典题型, 下面总结总结一下只出现一次的数字系列. 代码均为C++.
前置知识
逻辑表达式
下面这些结论都可以自己写一个真值表推导得出.
符号 | 运算 | 性质 |
---|---|---|
$\bar\ $ | 逻辑非 | - |
$\cdot$ (也可省略不写) | 逻辑与 | $xy=yx$, $x\cdot1=x$ $x\cdot x=x$, $x\cdot\bar x=0$, |
$+$ | 逻辑或 | $x+y=y+x$, $x+\bar x=1$, $x+x=x$, $x+1=1$, |
$\oplus$ | 逻辑异或 | $x\oplus y=x\bar y+\bar xy$, |
一些之后会用到的二级结论有:
- $\overline{x\oplus y}=xy+\bar x\bar y$;
- $x\cdot(x\oplus y)=x\bar y$, $y\cdot(x\oplus y)=\bar xy$;
- $x\cdot(\overline{x\oplus y})=y\cdot(\overline{x\oplus y})=xy$, $\bar x\cdot(\overline{x\oplus y})=\bar y\cdot(\overline{x\oplus y})=\bar x\bar y$;
- 德摩根律: $\overline{xy}=\bar x+\bar y$, $\overline {x+y}=\bar x\bar y$;
- 德摩根律(三元): $\overline{xyz}=\bar x+\bar y+\bar z$, $\overline{x+y+z}=\bar x\bar y\bar z$.
真值表转化为逻辑表达式, 就是找使结果值为1的行, 然后将自变量为0的记为非, 为1的不变, 用与运算连接成组后, 将上述的每组(代表每行)用或运算连接. (没学过数电, 纯属个人语言表达)
基本题型
利用异或的同值消除性质, 直接异或即可, 最快的方法.
class Solution {
public:
int singleNumber(vector<int>& nums) {
int ans{};
for (int num : nums) ans ^= num;
return ans;
}
};
异或的高级用法
异或分组
这两题是一样的, 都是通过位运算分组的方法来做.
class Solution {
public:
vector<int> singleNumber(vector<int>& nums) {
int xorsum{};
for (auto num : nums) xorsum ^= num;
int lsb = xorsum == INT_MIN ? xorsum : xorsum & (-xorsum);
int type1{};
for (int num : nums)
if (lsb & num) type1 ^= num;
return {type1, xorsum ^ type1};
}
};
面试题 17.19. 消失的两个数字 - 力扣(LeetCode);
这部分内容可以看我之前的总结: 力扣面试题寻找消失的两个数字多解法总结_zorchp的博客-CSDN博客;
位运算的混合用法:逻辑表达式
137. 只出现一次的数字 II - 力扣(LeetCode);
下面是接近O(n)
的做法, 原因是采用了一个32位的逐位遍历.
class Solution {
public:
int singleNumber(vector<int>& nums) {
int ans{};
// 32个位
for (int i{}; i < 32; ++i) {
int total{};
for (int num : nums) total += (num >> i) & 1;
if (total % 3) ans |= (1 << i);
}
return ans;
}
};
O(n)
做法需要一定的逻辑表达式知识(数字电路), 通过真值表得到逻辑表达式, 这里还应用了一个技巧, 就是直接通过现有的Y值更新X, 参考了题解[^2].
下面我用题解中的思路重新走一遍推导过程, 加深一下理解.
第一时间应该想到的是找到一种逻辑操作,可以满足 111=01∗1∗1=0 且 01=10=10∗1=1∗0=1 ,其中 *∗ 为这种新逻辑操作符。根据这个,我们可以想到 出现0次为0,出现1次为1,出现2次的值无所谓,出现3次就又回到0,也就是说,我们一共需要记录3种状态:0次,1次,2次,之后次数都是这三种状态的循环。其实这也就是一个模三运算。 记录两个状态需要的是一位二进制0/1,那么记录三种状态需要的是至少两位二进制,可以是00, 01, 10, 11,这里我们只需要选其中任意三个状态即可,例如:00,01,10,分别代表0次1次2次。 用00代表0次,01代表出现1次是因为刚好对应数字原本那位上0代表0次,1代表1次,这样可以方便写程序,不这么选也可以,但最后你自己要有个映射规则。至于2次的编码无所谓,10和11都可以,反正最后答案也不要它,只是个中间状态,辅助我们计算的。 那么对于输入数字的每一位二进制位,都可以用这三种状态表示。如果再输入一个数字,对于每一位上,我们的操作可以化为: 新输入的是0(即00),三种状态都维持不变,$00\rightarrow00,01\rightarrow01,10\rightarrow1000→00,01→01,10→10$ 新输入的是1(即01),$00\rightarrow01,01\rightarrow10,10\rightarrow0000→01,01→10,10→00$
设当前状态为XY,输入为Z,那么我们可以分别为X和Y列出真值表
$XY$ | $Z$ | $X_n$ | $Y_n$ |
---|---|---|---|
00 | 0 | 0 | 0 |
01 | 0 | 0 | 1 |
10 | 0 | 1 | 0 |
00 | 1 | 0 | 1 |
01 | 1 | 1 | 0 |
10 | 1 | 0 | 0 |
很容易得到针对$X_n$和$Y_n$的逻辑表达式为: \(X_n=X\bar Y\bar Z+\bar XYZ\\ Y_n=\bar X\bar YZ+\bar XY\bar Z\) 有了这两个式子, 其实就已经能得到结果了:
class Solution {
public:
int singleNumber(vector<int>& nums) {
int X{}, Y{}, Yn{};
for (int Z : nums) {
Yn = ~X & ~Y & Z | ~X & Y & ~Z;
X = X & ~Y & ~Z | ~X & Y & Z;
Y = Yn;
}
return Y;
}
};
但是这样有点眼花缭乱了, 下面更新一下$Y_n$的表达式: (用到了上面提到的二级结论) \(Y_n=\bar X\bar YZ+\bar XY\bar Z=\bar X(Y\oplus Z)\)
\[\begin{aligned} X_n &=X\bar Y\bar Z+\bar XYZ\\ &=X\bar Y(\overline{Y\oplus Z})+\bar XY(\overline{Y\oplus Z})\\ &=(X\oplus Y)(\overline{Y\oplus Z}) \end{aligned}\]于是代码也可以相应简化:
class Solution {
public:
int singleNumber(vector<int>& nums) {
int X{}, Y{}, Yn{};
for (int Z : nums) {
Yn = ~X & (Y ^ Z);
X = (X ^ Y) & ~(Y ^ Z);
Y = Yn;
}
return Y;
}
};
想想是不是可以用$Y_n$来更新$X_n$呢?
首先可以注意到, 用$Y_n$代替$Y$, 代入真值表, 可以得到:
$X Y_n$ | $Z$ | $X_n$ |
---|---|---|
00 | 0 | 0 |
01 | 0 | 0 |
10 | 0 | 1 |
01 | 1 | 0 |
00 | 1 | 1 |
10 | 1 | 0 |
上面这个表可能还看不出来, 交换一下$X$和$Y_n$, 那么真值表可以写成:
$Y_n X$ | $Z$ | $X_n$ |
---|---|---|
00 | 0 | 0 |
10 | 0 | 0 |
01 | 0 | 1 |
10 | 1 | 0 |
00 | 1 | 1 |
01 | 1 | 0 |
再来看看原始的真值表(忽略了$X_n$)
$XY$ | $Z$ | $Y_n$ |
---|---|---|
00 | 0 | 0 |
01 | 0 | 1 |
10 | 0 | 0 |
00 | 1 | 1 |
01 | 1 | 0 |
10 | 1 | 0 |
发现了什么? 这两个表反映出的对应关系是一样的!
这就意味着通过$Y_n$的计算公式完全可以得到$X_n$的计算公式, 引用题解的说法, 这两个更新公式是同构的. 也就是说, $X_n$的表达式可以记为: (将刚才的交换$X$与$Y_n$操作体现在变量中) \(X_n=\bar Y_n(X\oplus Z).\)
多么优雅的对称形式.
事实上就是函数同一种对应关系的不同字母表示, 即: \(Y_n(X,Y,Z)\cong X_n(Y_n,X,Z).\)
于是代码可以更简洁地写成:
class Solution {
public:
int singleNumber(vector<int>& nums) {
int X{}, Y{};
for(int Z: nums){
Y=~X&(Y^Z);
X=~Y&(X^Z);
}
return Y;
}
};
官方题解中直接将$Y$替换成$Y_n$, 然后将真值表直接转换为逻辑表达式来计算, 也能得到上述结果.
位运算的特殊处理
剑指 Offer II 070. 排序数组中只出现一次的数字 - 力扣(LeetCode);540. 有序数组中的单一元素 - 力扣(LeetCode); 这道题虽然不是主要用位运算做, 但是其中蕴含的一个思想很经典, 就是用异或操作来忽略对数字奇偶性的讨论:
- $x\&1==1$, 则$x\wedge 1=x-1$;
- $x\&1==0$, 则$x\wedge 1=x+1$.
也即, 奇数与1做异或相当于减1, 偶数与1异或相当于加1.
class Solution {
public:
int singleNonDuplicate(vector<int>& nums) {
int l{},r=nums.size()-1;
while (l<r){
int m=l+(r-l)/2;
if(nums[m]==nums[m^1])l=m+1;
else r=m;
}
return nums[l];
}
};
最后的总结
- 如果题目中说了排序数组, 那么大概率去想二分做法;
- 只出现一次的两个数字, 通过异或lsb的方式进行分组;
- 对于给出范围的题目(例如从1到N的正整数), 可以通过数学方法联立方程找出出现一次的数字;
- 原地哈希也是不错的选择, 但是比较难想到.