编程之美1:那些关于1的个数的经典面试题

那些关于1的个数的经典面试题

好长时间没有练算法了,笔试题一做,发现非常吃力,所以近日来找来《编程之美》一书来看看练练。为了激励自己多练,楼楼可能会出个专栏什么的,感兴趣的同学我们可以一起抱团,楼楼也会保证每天都会更新。那今天呢,就是《编程之美》的第一题了,原题叫做“1”的数目,楼楼会把这道题还有相关的一些题都会记录下来,下面要开始了哦,Are you ready?

题目1 给定一个十进制正整数N,写下从1开始,到N的所有整数,然后数一下其中出现的所有“1”的个数

  • 解法1 暴力穷举

我相信如果是我们正在处于笔试或者面试的当场,暴力穷举肯定是我们的第一个想法。一个一个算,算出1中出现“1”的个数,再算出2中出现“1”的个数,依次类推,直到N中“1”的个数,然后相加得出最后的结论。我们看代码:

#include   

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/(10quan))quan (N/(10quan))quan+N%quan+1 (N/(10quan)+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:快速法
由于表示在程序运行时动态创建的,所以速度上肯定会慢一些,把这个版本放在这里,有两个原因

  1. 介绍填表的方法,因为这个方法的确很巧妙。

  2. 类型转换,这里不能使用传统的强制转换,而是先取地址再转换成对应的指针类型。也是常用的类型转换方法。

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取模。

你可能感兴趣的:(编程之美读书笔记,编程之美答题笔记)