数据结构和算法面试题系列—数字题总结

5,394 阅读21分钟

这个系列是我多年前找工作时对数据结构和算法总结,其中有基础部分,也有各大公司的经典的面试题,最早发布在CSDN。现整理为一个系列给需要的朋友参考,如有错误,欢迎指正。本系列完整代码地址在 这里

0 概述

数学是科学之基础,数字题往往也是被面试玩出花来。数学本身是有趣味的一门学科,前段时间有点不务正业,对数学产生了浓厚的兴趣,于是看了几本数学史论的书,也买了《几何原本》和《陶哲轩的实分析》,看了部分章节,受益良多,有兴趣的朋友可以看看。特别是几何原本,欧几里得上千年前的著作,里面的思考和证明方式真的富有启发性,老少咸宜。本文先总结下面试题中的数字题,我尽量增加了一些数学方面的证明,如有错误,也请指正。本文代码都在 这里

1 找质数问题

题: 写一个程序,找出前N个质数。比如N为100,则找出前100个质数。

分析: 质数(或者叫素数)指在大于1的自然数中,除了1和该数自身外,无法被其他自然数整除的数,如 2,3,5...。最基本的想法就是对 1 到 N 的每个数进行判断,如果是质数则输出。一种改进的方法是不需要对 1 到 N 所有的数都进行判断,因为除了 2 外的偶数肯定不是质数,而奇数可能是质数,可能不是。然后我们可以跳过2与3的倍数,即对于 6n,6n+1, 6n+2, 6n+3, 6n+4, 6n+5,我们只需要判断 6n+16n+5 是否是质数即可。

判断某个数m是否是质数,最基本的方法就是对 2 到 m-1 之间的数整除 m,如果有一个数能够整除 m,则 m 就不是质数。判断 m 是否是质数还可以进一步改进,不需要对 2 到 m-1 之间的数全部整除 m,只需要对 2 到 根号m 之间的数整除m就可以。如用 2,3,4,5...根号m 整除 m。其实这还是有浪费,因为如果2不能整除,则2的倍数也不能整除,同理3不能整除则3的倍数也不能整除,因此可以只用2到根号m之间小于根号m的质数去除即可。

解: 预先可得2,3,5为质数,然后跳过2与3的倍数,从7开始,然后判断11,然后判断13,再是17...规律就是从5加2,然后加4,然后加2,然后再加4。如此反复即可,如下图所示,只需要判断 7,11,13,17,19,23,25,29... 这些数字。

判断是否是质数采用改进后的方案,即对2到根号m之间的数整除m来进行判断。需要注意一点,不能直接用根号m判断,因为对于某些数字,比如 121 开根号可能是 10.999999,所以最好使用乘法判断,如代码中所示。

/**
 * 找出前N个质数, N > 3
 */
int primeGeneration(int n)
{
    int *prime = (int *)malloc(sizeof(int) * n);
    int gap = 2;            
    int count = 3;
    int maybePrime = 5;
    int i, isPrime;

    /* 注意:2, 3, 5 是质数 */
    prime[0] = 2;
    prime[1] = 3;
    prime[2] = 5;

    while (count < n)  {
         maybePrime += gap;
         gap = 6 - gap;
         isPrime = 1; 
         for (i = 2; prime[i]*prime[i] <= maybePrime && isPrime; i++)
              if (maybePrime % prime[i] == 0)
                   isPrime = 0;

         if (isPrime)
              prime[count++] = maybePrime;
    }

    printf("\nFirst %d Prime Numbers are :\n", count);

    for (i = 0; i < count; i++) {
         if (i % 10 == 0) printf("\n");
         printf("%5d", prime[i]);
    }
    printf("\n");
    return 0;
}

2 阶乘末尾含0问题

题: 给定一个整数N,那么N的阶乘N!末尾有多少个0呢?(该题取自《编程之美》)

解1: 流行的解法是,如果 N!= K10M,且K不能被10整除,则 N!末尾有 M 个0。考虑 N!可以进行质因数分解,N!= (2X) * (3Y) * (5Z)..., 则由于10 = 25,所以0的个数只与 XZ 相关,每一对2和5相乘得到一个 10,所以 0 的个数 M = min(X, Z),显然 2 出现的数目比 5 要多,所以 0 的个数就是 5 出现的个数。由此可以写出如下代码:

/**
 * N!末尾0的个数
 */
int numOfZero(int n)
{
    int cnt = 0, i, j;
    for (i = 1; i <= n; i++) {
        j = i;
        while (j % 5 == 0) {
            cnt++;
            j /= 5;
        }
    }
    return cnt;
}

解2: 继续分析可以改进上面的代码,为求出1到N的因式分解中有多少个5,令 Z=N/5 + N/(52) + N/(53)+... 即 N/5 表示 1 到 N 的数中 5 的倍数贡献一个 5,N/(52) 表示 52 的倍数再贡献一个 5...。举个简单的例子,比如求1到100的数因式分解中有多少个5,可以知道5的倍数有20个,25的倍数有4个,所以一共有24个5。代码如下:

/**
 * N!末尾0的个数-优化版
 */
int numOfZero2(int n)
{
    int cnt = 0;
    while (n) {
        cnt += n/5;
        n /= 5;
    }
    return cnt;
}

总结: 上面的分析乏善可陈,不过需要提到的一点就是其中涉及到的一条算术基本定理,也就是 任意大于1的自然数都可以分解为质数的乘积,而且该分解方式是唯一的。 定理证明分为两个部分,存在性和唯一性。证明如下:

存在性证明

使用反证法来证明,假设存在大于1的自然数不能写成质数的乘积,把最小的那个称为n。自然数可以根据其可除性(是否能表示成两个不是自身的自然数的乘积)分成3类:质数、合数和1。

  • 首先,按照定义,n大于1。
  • 其次,n 不是质数,因为质数p可以写成质数乘积:p=p,这与假设不相符合。因此n只能是合数,但每个合数都可以分解成两个严格小于自身而大于1的自然数的积。设 n = a*b,a 和 b都是大于1小于n的数,由假设可知,a和b都可以分解为质数的乘积,因此n也可以分解为质数的乘积,所以这与假设矛盾。由此证明所有大于1的自然数都能分解为质数的乘积。

唯一性证明

  • 当n=1的时候,确实只有一种分解。
  • 假设对于自然数 n>1,存在两种因式分解: n=p1...pm = q1...qk,p1<=...<=pm, q1<=...<=qk,其中 p 和 q 都是质数,我们要证明 p1=q1,p2=q2...如果不相等,我们可以设 p1 < q1,从而 p1 小于所有的 q。由于 p1 和 q1 是质数,所以它们的最大公约数为1,由欧几里德算法可知存在整数 a 和 b 使得 a * p1 + b * q1 = 1。因此 a * p1 * q2...qk + b * q1 * q2...qk = q2...qk (等式1)。由于 q1...qk = n,因此等式1左边是 p1 的整数倍,从而等式1右边的 q2...qk 也必须是 p1 的整数倍,因此必然有 p1 = qi,i > 1。而这与前面 p1 小于所有的 q 矛盾,因此,由对称性,对 p1 > q1 这种情况可以得到类似结论,故可以证明 p1 = q1,同理可得 p2 = q2...pm=qk,由此完成唯一性的证明。

3 1-N正整数中1的数目

题: 给定一个十进制正整数N,求出从 1 到 N 的所有整数中包含 1 的个数。比如给定 N=23,则包含1的个数为13。其中个位出现1的数字有 1,11,21,共3个,十位出现1的数字有 10,11...19 共10个,所以总共包含 1 的个数为 3 + 10 = 13 个。

分析: 最自然的想法莫过于直接遍历1到N,求出每个数中包含的1的个数,然后将这些个数相加就是总的 1 的个数。需要遍历 N 个数,每次计算 1 的个数需要 O(log10N),该算法复杂度为 O(Nlog10N)。当数字N很大的时候,该算法会耗费很长的时间,应该还有更好的方法。

解: 我们可以从1位数开始分析,慢慢找寻规律。

  • 当 N 为 1 位数时,对于 N>=1,1 的个数 f(N) 为1。

  • 当 N 为 2 位数时,则个位上1的个数不仅与个位数有关,还和十位数字有关。

    • 当 N=23 时,个位上 1 的个数有 1、11、21 共3个,十位上1的个数为 10,11...19 共10个,所以 1 的个数 f(N) = 3+10 = 13。如果 N 的个位数 >=1,则个位出现1的次数为十位数的数字加1;如果 N 的个位数为0,则个位出现 1 的次数等于十位数的数字。
    • 十位数上出现1的次数类似,如果N的十位数字等于1,则十位数上出现1的次数为各位数字加1;如果N的十位数字大于1,则十位数上出现1的次数为10。
  • 当 N 为 3 位数时,同样分析可得1的个数。如 N=123,可得 1出现次数 = 13+20+24 = 57

  • 当 N 为 4,5...K 位数时,我们假设 N=abcde,则要计算百位上出现1的数目,则它受到三个因素影响:百位上的数字,百位以下的数字,百位以上的数字。

    • 如果百位上数字为0,则百位上出现1的次数为更高位数字决定。如 N=12013,则百位出现1的数字有100~199, 1000~1199, 2100~2199...11100~111999 共 1200 个,等于百位的更高位数字(12)*当前位数(100)。
    • 如果百位上数字为1,则百位上出现1的次数不仅受更高位影响,还受低位影响。如12113,则百位出现1的情况共有 1200+114=1314 个,也就是高位影响的 12 * 100 + 低位影响的 113+1 = 114 个。
    • 如果百位上数字为其他数字,则百位上出现1的次数仅由更高位决定。如 12213,则百位出现1的情况为 (12+1)*100=1300。

有以上分析思路,写出下面的代码。其中 low 表示低位数字,curr 表示当前考虑位的数字,high 表示高位数字。一个简单的分析,考虑数字 123,则首先考虑个位,则 curr 为 3,低位为 0,高位为 12;然后考虑十位,此时 curr 为 2,低位为 3,高位为 1。其他的数字可以以此类推,实现代码如下:

/**
 * 1-N正整数中1的个数
 */
int numOfOne(int n)
{
    int factor = 1, cnt = 0;  //factor为乘数因子
    int low = 0, curr = 0, high = 0;
    while (n / factor != 0) {
        low = n - n/factor * factor;  //low为低位数字,curr为当前考虑位的数字,high为高位数字
        curr = n/factor % 10;
        high = n/(factor * 10);

        switch(curr) {
            case 0:   //当前位为0的情况
                cnt += high * factor;
                break;
            case 1:   //当前位为1的情况
                cnt += high * factor + low + 1;
                break;
            default:  //当前位为其他数字的情况
                cnt += (high+1) * factor;
                break;
        }
        factor *= 10;
    }
    return cnt;
}

4 和为N的正整数序列

题: 输入一个正整数数N,输出所有和为N连续正整数序列。例如输入 15,由于 1+2+3+4+5=4+5+6=7+8=15,所以输出 3 个连续序列 1-5、4-6和7-8。

解1:运用数学规律

假定有 k 个连续的正整数和为 N,其中连续序列的第一个数为 x,则有 x+(x+1)+(x+2)+...+(x+k-1) = N。从而可以求得 x = (N - k*(k-1)/2) / k。当 x<=0 时,则说明已经没有正整数序列的和为 N 了,此时循环退出。初始化 k=2,表示2个连续的正整数和为 N,则可以求出 x 的值,并判断从 x 开始是否存在2个连续正整数和为 N,若不存在则 k++,继续循环。

/**
 * 查找和为N的连续序列
 */
int findContinuousSequence(int n) 
{
    int found = 0;
    int k = 2, x, m, i;  // k为连续序列的数字的数目,x为起始的值,m用于判断是否有满足条件的值。
    while (1) { 
        x = (n - k*(k-1)/2) / k;  // 求出k个连续正整数和为n的起始值x
        m = (n - k*(k-1)/2) % k; // m用于判断是否有满足条件的连续正整数值

        if (x <= 0)
            break;    // 退出条件,如果x<=0,则循环退出。

        if (!m) {     // m为0,表示找到了连续子序列和为n。
            found = 1;
            printContinuousSequence(x, k);
        }
        k++;
    }
    return found;
}

/**
 * 打印连续子序列
 */
void printContinuousSequence(int x, int k)
{
    int i;
    for (i = 0; i < k; i++) {
        printf("%d ", x++);
    }
    printf("\n");
}

解2:序列结尾位置法

因为序列至少有两个数,可以先判定以数字2结束的连续序列和是否有等于 n 的,然后是以3结束的连续序列和是否有等于 n 的,...以此类推。此解法思路参考的何海涛先生的博文,代码如下:

/**
 * 查找和为N的连续序列-序列结尾位置法
 */
int findContinuousSequenceEndIndex(int n) 
{
    if (n < 3) return 0;

    int found = 0;
    int begin = 1, end = 2;
    int mid = (1 + n) / 2;
    int sum = begin + end;

    while (begin < mid) {
        if (sum > n) {
            sum -= begin;
            begin++;
        } else {
            if (sum == n) {
                found = 1;
                printContinuousSequence(begin, end-begin+1);
            }

            end++;
            sum += end;
        }
    }

    return found;
}

扩展: 是不是所有的正整数都能分解为连续正整数序列呢?

答案: 不是。并不是所有的正整数都能分解为连续的正整数和,如 32 就不能分解为连续正整数和。对于奇数,我们总是能写成 2k+1 的形式,因此可以分解为 [k,k+1],所以总是能分解成连续正整数序列。对于每一个偶数,均可以分解为质因数之积,即 n = 2i * 3j * 5k...,如果除了i之外,j,k...均为0,那么 n = 2i,对于这种数,其所有的因数均为偶数,是不存在连续子序列和为 n 的,因此除了2的幂之外的所有 n>=3 的正整数均可以写成一个连续的自然数之和。

5 最大连续子序列和

题: 求取数组中最大连续子序列和,例如给定数组为 A = {1, 3, -2, 4, -5}, 则最大连续子序列和为 6,即 1 + 3 +(-2)+ 4 = 6

分析: 最大连续子序列和问题是个很老的面试题了,最佳的解法是 O(N) 复杂度,当然有些值得注意的地方。这里总结三种常见的解法,重点关注最后一种 O(N) 的解法即可。需要注意的是有些题目中的最大连续子序列和如果为负,则返回0;而本题如果是全为负数,则返回最大的负数即可。

解1: 因为最大连续子序列和只可能从数组 0 到 n-1 中某个位置开始,我们可以遍历 0 到 n-1 个位置,计算由这个位置开始的所有连续子序列和中的最大值。最终求出最大值即可。

/**
 * 最大连续子序列和
 */
int maxSumOfContinuousSequence(int a[], int n)
{
    int max = a[0], i, j, sum; // 初始化最大值为第一个元素
    for (i = 0; i < n; i++) {
        sum = 0; // sum必须清零
        for (j = i; j < n; j++) { //从位置i开始计算从i开始的最大连续子序列和的大小,如果大于max,则更新max。
            sum += a[j];
            if (sum > max)
                max = sum;
        }
    }
    return max;
}

解2: 该问题还可以通过分治法来求解,最大连续子序列和要么出现在数组左半部分,要么出现在数组右半部分,要么横跨左右两半部分。因此求出这三种情况下的最大值就可以得到最大连续子序列和。

/**
 * 最大连续子序列和-分治法
 */
int maxSumOfContinuousSequenceSub(int a[], int l, int u)
{
    if (l > u) return 0;
    if (l == u) return a[l];
    int m = (l + u) / 2;

    /*求横跨左右的最大连续子序列左半部分*/
    int lmax = a[m], lsum = 0;
    int i;

    for (i = m; i >= l; i--) {
        lsum += a[i];
        if (lsum > lmax)
            lmax = lsum;
    }

    /*求横跨左右的最大连续子序列右半部分*/
    int rmax=a[m+1], rsum = 0;
    for (i = m+1; i <= u; i++) {
        rsum += a[i];
        if (rsum > rmax)
            rmax = rsum;
    }

    return max3(lmax+rmax, maxSumOfContinuousSequenceSub(a, l, m),
        maxSumOfContinuousSequenceSub(a, m+1, u)); //返回三者最大值
}

解3: 还有一种更好的解法,只需要 O(N) 的时间。因为最大 连续子序列和只可能是以位置 0~n-1 中某个位置结尾。当遍历到第 i 个元素时,判断在它前面的连续子序列和是否大于0,如果大于0,则以位置 i 结尾的最大连续子序列和为元素 i 和前面的连续子序列和相加;否则,则以位置 i 结尾最大连续子序列和为a[i]。

/**
 * 最打连续子序列和-结束位置法
 */
int maxSumOfContinuousSequenceEndIndex(int a[], int n)
{
    int maxSum, maxHere, i;
    maxSum = maxHere = a[0];   // 初始化最大和为a[0]

    for (i = 1; i < n; i++) {
        if (maxHere <= 0)
            maxHere = a[i];  // 如果前面位置最大连续子序列和小于等于0,则以当前位置i结尾的最大连续子序列和为a[i]
        else
            maxHere += a[i]; // 如果前面位置最大连续子序列和大于0,则以当前位置i结尾的最大连续子序列和为它们两者之和

        if (maxHere > maxSum) {
            maxSum = maxHere;  //更新最大连续子序列和
        }
    }
    return maxSum;
}

6 最大连续子序列乘积

题: 给定一个整数序列(可能有正数,0和负数),求它的一个最大连续子序列乘积。比如给定数组a[] = {3, -4, -5, 6, -2},则最大连续子序列乘积为 360,即 3*(-4)*(-5)*6=360

解: 求最大连续子序列乘积与最大连续子序列和问题有所不同,因为其中有正有负还有可能有0,可以直接利用动归来求解,考虑到可能存在负数的情况,我们用 max[i] 来表示以 a[i] 结尾的最大连续子序列的乘积值,用 min[i] 表示以 a[i] 结尾的最小的连续子序列的乘积值,那么状态转移方程为:

max[i] = max{a[i], max[i-1]*a[i], min[i-1]*a[i]};
min[i] = min{a[i], max[i-1]*a[i], min[i-1]*a[i]};

初始状态为 max[0] = min[0] = a[0]。代码如下:

/**
 * 最大连续子序列乘积
 */
int maxMultipleOfContinuousSequence(int a[], int n)
{
    int minSofar, maxSofar, max, i;
    int maxHere, minHere;
    max = minSofar = maxSofar = a[0];

    for(i = 1; i < n; i++){
        int maxHere = max3(maxSofar*a[i], minSofar*a[i], a[i]);
        int minHere = min3(maxSofar*a[i], minSofar*a[i], a[i]);
        maxSofar = maxHere, minSofar = minHere;
        if(max < maxSofar)
            max = maxSofar;
    }
    return max;
}

7 比特位相关

1) 判断一个正整数是否是2的整数次幂

题: 给定一个正整数 n,判断该正整数是否是 2 的整数次幂。

解1: 一个基本的解法是设定 i=1 开始,循环乘以2直到 i>=n,然后判断 i 是否等于 n 即可。

解2: 还有一个更好的方法,那就是观察数字的二进制表示,如 n=101000,则我们可以发现n-1=100111。也就是说 n -> n-1 是将 n 最右边的 1 变成了 0,同时将 n 最右边的 1 右边的所有比特位由0变成了1。因此如果 n & (n-1) == 0 就可以判定正整数 n 为 2的整数次幂。

/**
 * 判断正整数是2的幂次
 */
int powOf2(int n)
{
    assert(n > 0);
    return !(n & (n-1));
}

2) 求一个整数二进制表示中1的个数

题: 求整数二进制表示中1的个数,如给定 N=6,它的二进制表示为 0110,二进制表示中1的个数为2。

解1: 一个自然的方法是将N和1按位与,然后将N除以2,直到N为0为止。该算法代码如下:


int numOfBit1(int n)
{
    int cnt = 0;
    while (n) {
        if (n & 1)
            ++cnt;
        n >>= 1;
    }
    return cnt;
}

上面的代码存在一个问题,如果输入为负数的话,会导致死循环,为了解决负数的问题,如果使用的是JAVA,可以直接使用无符号右移操作符 >>> 来解决,如果是在C/C++里面,为了避免死循环,我们可以不右移输入的数字i。首先 i & 1,判断i的最低位是不是为1。接着把1左移一位得到2,再和i做与运算,就能判断i的次高位是不是1...,这样反复左移,每次都能判断i的其中一位是不是1。

/**
 * 二进制表示中1的个数-解法1
 */
int numOfBit1(int n)
{
    int cnt = 0;
    unsigned int flag = 1;
    while (flag) {
        if(n & flag)
            cnt++;

        flag = flag << 1;
        if (flag > n)
            break;
    }
    return cnt;
}

解2: 一个更好的解法是采用第一个题中类似的思路,每次 n&(n-1)就能把n中最右边的1变为0,所以很容易求的1的总数目。

/**
 * 二进制表示中1的个数-解法2
 */
int numOfBit1WithCheck(int n)
{
    int cnt = 0;
    while (n != 0) {
        n = (n & (n-1));
        cnt++;
    }
    return cnt;
}

3) 反转一个无符号整数的所有比特位

题: 给定一个无符号整数N,反转该整数的所有比特位。例如有一个 8 位的数字 01101001,则反转后变成 10010110,32 位的无符号整数的反转与之类似。

解1: 自然的解法就是参照字符串反转的算法,假设无符号整数有n位,先将第0位和第n位交换,然后是第1位和第n-1位交换...注意一点就是交换两个位是可以通过异或操作 XOR 来实现的,因为 0 XOR 0 = 0, 1 XOR 1 = 0, 0 XOR 1 = 1, 1 XOR 0 = 1,使用 1 异或 0/1 会让其值反转。

/**
 * 反转比特位
 */
uint swapBits(uint x, uint i, uint j)
{
    uint lo = ((x >> i) & 1);  // 取x的第i位的值
    uint hi = ((x >> j) & 1);  // 取x的第j位的值
    if (lo ^ hi) {             
        x ^= ((1U << i) | (1U << j)); // 如果第i位和第j位值不同,则交换i和j这两个位的值,使用异或操作实现
    }
    return x;
}
 
/**
 * 反转整数比特位-仿字符串反转
 */
uint reverseXOR(uint x)
{
    uint n = sizeof(x) * 8;
    uint i;
    for (i = 0; i < n/2; i++) {
        x = swapBits(x, i, n-i-1);
    }
    return x;
}

解2: 采用分治策略,首先交换数字x的相邻位,然后再是 2 个位交换,然后是 4 个位交换...比如给定一个 8 位数 01101010,则首先交换相邻位,变成 10 01 01 01,然后交换相邻的 2 个位,得到 01 10 01 01,然后再是 4 个位交换,得到 0101 0110。总的时间复杂度为 O(lgN),其中 N 为整数的位数。下面给出一个反转32位整数的代码,如果整数是64位的可以以此类推。

/**
 * 反转整数比特位-分治法
 */
uint reverseMask(uint x)
{
    assert(sizeof(x) == 4); // special case: only works for 4 bytes (32 bits).
    x = ((x & 0x55555555) << 1) | ((x & 0xAAAAAAAA) >> 1);
    x = ((x & 0x33333333) << 2) | ((x & 0xCCCCCCCC) >> 2);
    x = ((x & 0x0F0F0F0F) << 4) | ((x & 0xF0F0F0F0) >> 4);
    x = ((x & 0x00FF00FF) << 8) | ((x & 0xFF00FF00) >> 8);
    x = ((x & 0x0000FFFF) << 16) | ((x & 0xFFFF0000) >> 16);
    return x;
}

参考资料