先看一道题:对于一个字节的无符号整形变量,求其二进制表示中 “ 1 “的个数,要求算法的执行效率尽可能高。
看到题目,估计很多人的第一反应跟我一样,很简单啊,初始化一个counter 为0 ,循环除2取余,余数为1,则counter 自增操作,很容易写出以下代码:
解法1
int Count(int n)
{
int counter=0;
while(n)
{
if(n%2==1)
{
counter++;
}
n=n/2;
}
return counter;
}
仔细分析一下,上面的算法的时间复杂度为 O(log2(n)) ,看着也不是很高,上面的代码除以2 ,就等于二进制位右移一位,不难写出下面的代码:
解法2
int Count(int n)
{
int counter=0;
while(n)
{
counter+=n&0x01;
n>>=1;
}
return counter;
}
解法2 中利用位操作,比除、余操作效率自然是高了不少,不过分析一下,解法2的时间复杂度仍然是O( log2(n) ),有没有什么方法再降低它的复杂度呢?答案自然是有的。**一个很不错的思想:**加入某个数的二进制位表示只有1位上是1 ,比如 0100 0000 ,我们会如何判断这个数中只有一个位上 是 1 呢?通过判断这个数是不是2的 x 次方就行了,另外,如果只 和这一个 1 进行判断,这波操作该如何设计呢?我们知道,如果要进行这个操作,结果为 0 或者 1 ,就可以得到我们想要的结论。 假如我们希望操作后的结果为 0 ,0100 0000 可以和 0011 1111进行 ” 与 “操作,不难发现 0100 0000 减去1 就等于 0011 1111 ,下面请看解法3:
解法3
int Count(int n)
{
int counter=0;
while(n)
{
//每经过一次与操作,n中的 1 就少一位,注意,是每次都可以
n=n&(n-1);
counter++;
}
return counter;
}
好,现在我们来分析一下解法3的时间复杂度,发现复杂度为 Q(counter)即是n 二进制位中 1 的个数,解到这里,想必大家 应该觉得这个效率应该不错了吧,确实还行,但是,其实还有更快的 ,这里只介绍思路,就不敲出来了:
这里是 8 位嘛,可以表示 0 - 255的数,直接开个大小为 256 的数组,存储这些数字的 二进制位中 1 的个数 ,假设数组为 arr[256] ,我们输入n ,那答案就是 arr[n] 啊 ,时间复杂度为 O (1),达到极致了啊,但是这样做其实是一种空间换时间的做法,而且,写出这个程序太浪费时间了,你得敲 256 个数进数组啊,还得脑中计算每个数的二进制位表示 ,虽然算几个就发现规律能很快写出来了,但是感觉还是太浪费时间了。
下面我们再看到一个题目 :有一组存放 ID 的数据。并且 ID 取值为 0 - (N-1) 之间,其中只有一个 ID 出现的次数为 1,其他的 ID 出现的次数都等于 2,问如何找到这个次数为 1 的 ID ?
当然对于这道题,还是能够一眼看出来直接用数组下标就能求解出来的 ,直接开个数组 arr[N] ,然后遍历 ID 数据 ,
arr[ ID] ++ 即可。然后遍历数组 ,当arr[n]==1时 返回数组下标 n 。不难发现,这种方法 的时间复杂度为 O(n) ,空间复杂度也为 O(n) 。显然,我们要遍历数组,时间复杂度自然时无法降低的了O(n) ,那究竟有没办法让空间复杂度在最坏的情况下也是 O(1) 呢?
答案是有的,就是采用异或运算。异或运算有个特点:相同的两个数异或之后,结果为 0,任何数与0异或运算,其结果不变并且,异或运算支持结合律。所以,我们可以把所有的 ID 进行异或运算,由于那些出现两次的 ID 通过异或运算之后,结果都为 0,而出现一次的 ID 与 0 异或之后不变,又因为异或支持结合律,所以,把所有 ID 进行异或之后,最后的结果便是我们要找的 ID。这个方法的空间复杂度为 O(1),巧妙利用了位运算,而且运算的效率是非常高效的。