那些关于1的个数的经典面试题
好长时间没有练算法了,笔试题一做,发现非常吃力,所以近日来找来《编程之美》一书来看看练练。为了激励自己多练,楼楼可能会出个专栏什么的,感兴趣的同学我们可以一起抱团,楼楼也会保证每天都会更新。那今天呢,就是《编程之美》的第一题了,原题叫做“1”的数目,楼楼会把这道题还有相关的一些题都会记录下来,下面要开始了哦,Are you ready?
题目1 给定一个十进制正整数N,写下从1开始,到N的所有整数,然后数一下其中出现的所有“1”的个数
- 解法1 暴力穷举
我相信如果是我们正在处于笔试或者面试的当场,暴力穷举肯定是我们的第一个想法。一个一个算,算出1中出现“1”的个数,再算出2中出现“1”的个数,依次类推,直到N中“1”的个数,然后相加得出最后的结论。我们看代码:
#include <iostream>
using namespace std;
int getOneShowTimes(unsigned int n) ;
int getOneShowSumTimes(unsigned int N) ;
int main()
{
unsigned int N = 123;
cout << "1出现的次数为:" << getOneShowSumTimes(N) << endl;
system("pause");
}
int getOneShowSumTimes(unsigned int N)
{
unsigned int count = 0;
for (unsigned int i = 0; i <= N; i++)
{
count += getOneShowTimes(i);
}
return count;
}
int getOneShowTimes(unsigned int n)
{
unsigned count = 0;
while(0 != n)
{
count += (n % 10) == 1 ? 1 : 0;
n /= 10;
}
return count;
}
我们分析一下复杂度,外层循环要循环N次,内存循环要循环 log10N+1 次,所以总的复杂度为 O(N(log10N+1)) ,可以看出这个复杂度是比较高的。
下面我们想想,有没有更简单的办法呢?比如对于一个三位数123而言,“1”只能在个位出现,或者十位出现或者千位出现。如果是按照这个原理来统计的,那我们可以完全将外层循环降低到 log10N+1 次。那我们来写几个例子来寻找一下规律。
- 解法2 逐位统计法
如123,那么
个位出现1 | 十位出现1 | 百位出现1 |
---|---|---|
1 | 10 | 100 |
11 | 11 | 101 |
21 | 12 | 102 |
31 | 13 | 103 |
41 | 14 | 104 |
51 | 15 | … |
61 | 16 | |
71 | 17 | |
81 | 18 | |
91 | 19 | |
101 | 110 | |
111 | … | |
121 | 119 | 123 |
共计13次 | 共计20次 | 共计24次 |
猜想公式 N/10+1 | 猜想公式 (N/100+1)∗10 | 猜想公式 (N/1000)∗100+N%100+1 |
但是在这里有我们有几个特殊的情况需要特别考虑,如相应的位数为0怎么办?比如51和50结果是完全不一样的。还有相应的位数为1怎么办?12和22的结果也是不一样的。我下面把结果罗列出来,大家也可以试着推导一下。
分情况 | 个位出现1 | 十位出现1 | 百位出现1 |
---|---|---|---|
bit = 0 | (N/10)∗1 | (N/100)∗10 | (N/1000)∗100 |
bit = 1 | (N/10)∗1+N%1+1 | (N/100)∗10+N%10+1 | (N/1000)∗100+N%100+1 |
bit > 1 | (N/10+1)∗1 | (N/100+1)∗10 | (N/1000+1)∗100 |
总结一下,假设每一位对应的权值为quan,如个位quan = 1,十位quan = 10,那么总的公式为
bit=0 | bit=1 | bit>1 |
---|---|---|
(N/(10∗quan))∗quan | (N/(10∗quan))∗quan+N%quan+1 | (N/(10∗quan)+1)∗quan |
下面看代码:
package net.mindview.util;
public class MyThread {
public static void main(String[] args) {
int N = 123;
System.out.println("总共出现" + getOneShowTimes(N) + "次");
}
public static int getOneShowTimes(int N) {
int numPerBit; //存储每一位的数目
int sumTimes = 0; //存储最后的结果
int quan = 1; //每一位的权值,各位为1,十位为10,依次类推
int tempN = N;
if (0 == N) {
return 0;
}
while(0 != tempN) {
numPerBit = tempN % 10;
sumTimes += getOneShowTimesPerBit(N, numPerBit, quan);
tempN /= 10;
quan *= 10;
}
return sumTimes;
}
public static int getOneShowTimesPerBit(int N, int numPerBit, int quan) {
if (0 == numPerBit) {
return N / (quan * 10) * quan;
} else if (1 == numPerBit) {
return (N / (quan * 10)) * quan + N % quan + 1;
} else {
return (N / (quan * 10) + 1 ) * quan;
}
}
}
非常不好意思,这里的代码是Java的,因为原来就写好了,我就懒得再写成c++的了,请见谅哈!复杂度为 O(log10N+1) ,可以看出来效率是提升了很多的。下面我们来看第二个题。
题目二:给定一个十进制正整数N,写下从1开始,到N的所有整数,然后数一下其中二进制中出现的所有“1”的个数
题目一是十进制,题目二是二进制。注意这里的区别。那我们同样写写看,看能不能找出什么规律
假设N=3,那么我们看每一位出现“1”的次数之和都是相等的,都是2次。那么结果就是2 * 2=4总共四次。其次,假设N=7,我们又惊奇地发现,所有小于7的数中每一位出现“1”的次数之和也是相等的,每一位出现“1”的次数之和为4,那么结果就是3 * 4 = 12次。那我们可以猜想,当N=15时,总数为4 * 8 = 32次。这是非常理想的情况,那当N = 13呢?很easy,N=13,我们就先算N = 7。还剩下6个数,观察一下我们可以发现,剩下的6个数中“1”出现的次数=最小的6个数“1”出现的次数+6。那么我们就把问题降下来了,本来是要求左边6个数中1出现的个数,转变成了求右边6个数中1出现的个数。
本来要求的“1”出现的个数 | 简化之后要求的“1”出现的个数 |
---|---|
1000 | 0000 |
1001 | 0001 |
1010 | 0010 |
1011 | 0011 |
1100 | 0100 |
1101 | 0101 |
那么剩下的6个数,又可以重复前面的步骤,先求N=3。
总结一下,假设不大于N的最小的2的次方数为 biggest2Pow
log2(biggest2Pow)∗biggest2Pow2+(N+1)−biggest2Pow
不断递归上述过程,直接看代码吧,感觉说不清楚:
package net.mindview.util;
public class MyThread {
public static void main(String[] args) {
int N = 13;
System.out.println("总共出现" + getOneShowTimes(N + 1) + "次");
}
public static int getOneShowTimes(int N) {
int biggest2Pow = 0;
int left = N;
int result = 0;
while(0 != left) {
biggest2Pow = getBiggest2Pow(left);
left = left - biggest2Pow;
result += biggest2Pow / 2 * getLog2N(biggest2Pow) + left;
}
return result;
}
//求不大于N最大2次幂整数
public static int getLog2N(int N) {
return (int)(Math.log((double)N) / Math.log((double)2));
}
public static int getBiggest2Pow(int N) {
int factor = getLog2N(N);
return (int) Math.pow(2, factor);
}
}
代码比较好理解,很短。有不清楚的可以给我留言哈!
题目三:求一个无符号数N中二进制表示中“1”出现的个数
和题目2的区别是,题目2要求算的是1,2,3….N所有数中“1”出现的个数之和。题目三只要求求一个数N中“1”出现的次数之和。下面的解法转载自http://www.cnblogs.com/graphics/archive/2010/06/21/1752421.html#commentform。
- 解法1:普通法
int BitCount(unsigned int n)
{
unsigned int c =0 ; // 计数器
while (n >0)
{
if((n &1) ==1) // 当前位是1
++c ; // 计数器加1
n >>=1 ; // 移位
}
return c ;
}
- 解法2:快速法
这种方法速度比较快,其运算次数与输入n的大小无关,只与n中1的个数有关。如果n的二进制表示中有k个1,那么这个方法只需要循环k次即可。其原理是不断清除n的二进制表示中最右边的1,同时累加计数器,直至n为0,代码如下
int BitCount2(unsigned int n)
{
unsigned int c =0 ;
for (c =0; n; ++c)
{
n &= (n -1) ; // 清除最低位的1
}
return c ;
}
为什么n &= (n – 1)能清除最右边的1呢?因为从二进制的角度讲,n相当于在n – 1的最低位加上1。举个例子,8(1000)= 7(0111)+ 1(0001),所以8 & 7 = (1000)&(0111)= 0(0000),清除了8最右边的1(其实就是最高位的1,因为8的二进制中只有一个1)。再比如7(0111)= 6(0110)+ 1(0001),所以7 & 6 = (0111)&(0110)= 6(0110),清除了7的二进制表示中最右边的1(也就是最低位的1)。
– 解法3:快速法
由于表示在程序运行时动态创建的,所以速度上肯定会慢一些,把这个版本放在这里,有两个原因
介绍填表的方法,因为这个方法的确很巧妙。
类型转换,这里不能使用传统的强制转换,而是先取地址再转换成对应的指针类型。也是常用的类型转换方法。
int BitCount3(unsigned int n)
{
// 建表
unsigned char BitsSetTable256[256] = {0} ;
// 初始化表
for (int i =0; i <256; i++)
{
BitsSetTable256[i] = (i &1) + BitsSetTable256[i /2];
}
unsigned int c =0 ;
// 查表
unsigned char* p = (unsigned char*) &n ;
c = BitsSetTable256[p[0]] +
BitsSetTable256[p[1]] +
BitsSetTable256[p[2]] +
BitsSetTable256[p[3]];
return c ;
}
先说一下填表的原理,根据奇偶性来分析,对于任意一个正整数n
1.如果它是偶数,那么n的二进制中1的个数与n/2中1的个数是相同的,比如4和2的二进制中都有一个1,6和3的二进制中都有两个1。为啥?因为n是由n/2左移一位而来,而移位并不会增加1的个数。
2.如果n是奇数,那么n的二进制中1的个数是n/2中1的个数+1,比如7的二进制中有三个1,7/2 = 3的二进制中有两个1。为啥?因为当n是奇数时,n相当于n/2左移一位再加1。
再说一下查表的原理
对于任意一个32位无符号整数,将其分割为4部分,每部分8bit,对于这四个部分分别求出1的个数,再累加起来即可。而8bit对应2^8 = 256种01组合方式,这也是为什么表的大小为256的原因。
注意类型转换的时候,先取到n的地址,然后转换为unsigned char*,这样一个unsigned int(4 bytes)对应四个unsigned char(1 bytes),分别取出来计算即可。举个例子吧,以87654321(十六进制)为例,先写成二进制形式-8bit一组,共四组,以不同颜色区分,这四组中1的个数分别为4,4,3,2,所以一共是13个1,如下面所示。
10000111 01100101 01000011 00100001 = 4 + 4 + 3 + 2 = 13
- 解法四:静态表-4bit
原理和8-bit表相同,详见8-bit表的解释
int BitCount4(unsigned int n)
{
unsigned int table[16] =
{
0, 1, 1, 2,
1, 2, 2, 3,
1, 2, 2, 3,
2, 3, 3, 4
} ;
unsigned int count =0 ;
while (n)
{
count += table[n &0xf] ;
n >>=4 ;
}
return count ;
}
- 解法五:静态表-8bit
首先构造一个包含256个元素的表table,table[i]即i中1的个数,这里的i是[0-255]之间任意一个值。然后对于任意一个32bit无符号整数n,我们将其拆分成四个8bit,然后分别求出每个8bit中1的个数,再累加求和即可,这里用移位的方法,每次右移8位,并与0xff相与,取得最低位的8bit,累加后继续移位,如此往复,直到n为0。所以对于任意一个32位整数,需要查表4次。以十进制数2882400018为例,其对应的二进制数为10101011110011011110111100010010,对应的四次查表过程如下:红色表示当前8bit,绿色表示右移后高位补零。
第一次(n & 0xff) 10101011110011011110111100010010
第二次((n >> 8) & 0xff) 00000000101010111100110111101111
第三次((n >> 16) & 0xff)00000000000000001010101111001101
第四次((n >> 24) & 0xff)00000000000000000000000010101011
int BitCount7(unsigned int n)
{
unsigned int table[256] =
{
0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8,
};
return table[n &0xff] +
table[(n >>8) &0xff] +
table[(n >>16) &0xff] +
table[(n >>24) &0xff] ;
}
当然也可以搞一个16bit的表,或者更极端一点32bit的表,速度将会更快。
- 解法六:平行算法
网上都这么叫,我也这么叫吧,不过话说回来,的确有平行的意味在里面,先看代码,稍后解释
int BitCount4(unsigned int n)
{
n = (n &0x55555555) + ((n >>1) &0x55555555) ;
n = (n &0x33333333) + ((n >>2) &0x33333333) ;
n = (n &0x0f0f0f0f) + ((n >>4) &0x0f0f0f0f) ;
n = (n &0x00ff00ff) + ((n >>8) &0x00ff00ff) ;
n = (n &0x0000ffff) + ((n >>16) &0x0000ffff) ;
return n ;
}
速度不一定最快,但是想法绝对巧妙。 说一下其中奥妙,其实很简单,先将n写成二进制形式,然后相邻位相加,重复这个过程,直到只剩下一位。
以217(11011001)为例,有图有真相,下面的图足以说明一切了。217的二进制表示中有5个1
- 完美法
int BitCount5(unsigned int n)
{
unsigned int tmp = n - ((n >>1) &033333333333) - ((n >>2) &011111111111);
return ((tmp + (tmp >>3)) &030707070707) %63;
}
最喜欢这个,代码太简洁啦,只是有个取模运算,可能速度上慢一些。区区两行代码,就能计算出1的个数,到底有何奥妙呢?为了解释的清楚一点,我尽量多说几句。
第一行代码的作用
先说明一点,以0开头的是8进制数,以0x开头的是十六进制数,上面代码中使用了三个8进制数。
将n的二进制表示写出来,然后每3bit分成一组,求出每一组中1的个数,再表示成二进制的形式。比如n = 50,其二进制表示为110010,分组后是110和010,这两组中1的个数本别是2和3。2对应010,3对应011,所以第一行代码结束后,tmp = 010011,具体是怎么实现的呢?由于每组3bit,所以这3bit对应的十进制数都能表示为2^2 * a + 2^1 * b + c的形式,也就是4a + 2b + c的形式,这里a,b,c的值为0或1,如果为0表示对应的二进制位上是0,如果为1表示对应的二进制位上是1,所以a + b + c的值也就是4a + 2b + c的二进制数中1的个数了。举个例子,十进制数6(0110)= 4 * 1 + 2 * 1 + 0,这里a = 1, b = 1, c = 0, a + b + c = 2,所以6的二进制表示中有两个1。现在的问题是,如何得到a + b + c呢?注意位运算中,右移一位相当于除2,就利用这个性质!
4a + 2b + c 右移一位等于2a + b
4a + 2b + c 右移量位等于a
然后做减法
4a + 2b + c –(2a + b) – a = a + b + c,这就是第一行代码所作的事,明白了吧。
第二行代码的作用
在第一行的基础上,将tmp中相邻的两组中1的个数累加,由于累加到过程中有些组被重复加了一次,所以要舍弃这些多加的部分,这就是&030707070707的作用,又由于最终结果可能大于63,所以要取模。
需要注意的是,经过第一行代码后,从右侧起,每相邻的3bit只有四种可能,即000, 001, 010, 011,为啥呢?因为每3bit中1的个数最多为3。所以下面的加法中不存在进位的问题,因为3 + 3 = 6,不足8,不会产生进位。
tmp + (tmp >> 3)-这句就是是相邻组相加,注意会产生重复相加的部分,比如tmp = 659 = 001 010 010 011时,tmp >> 3 = 000 001 010 010,相加得
001 010 010 011
000 001 010 010
001 011 100 101
011 + 101 = 3 + 5 = 8。(感谢网友Di哈指正。)注意,659只是个中间变量,这个结果不代表659这个数的二进制形式中有8个1。
注意我们想要的只是第二组和最后一组(绿色部分),而第一组和第三组(红色部分)属于重复相加的部分,要消除掉,这就是&030707070707所完成的任务(每隔三位删除三位),最后为什么还要%63呢?因为上面相当于每次计算相连的6bit中1的个数,最多是111111 = 77(八进制)= 63(十进制),所以最后要对63取模。