数据结构和算法面试题系列—数字题总结
Posted 数据结构与算法
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构和算法面试题系列—数字题总结相关的知识,希望对你有一定的参考价值。
作者丨未知
https://juejin.im/post/5bb431f15188255c880ad335
https://github.com/shishujuan/dsalg
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+1 与 6n+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("
First %d Prime Numbers are :
", count);
for (i = 0; i < count; i++) {
if (i % 10 == 0) printf("
");
printf("%5d", prime[i]);
}
printf("
");
return 0;
}
2 阶乘末尾含0问题
题: 给定一个整数N,那么N的阶乘N!末尾有多少个0呢?(该题取自《编程之美》)
解1: 流行的解法是,如果 N!= K10M,且K不能被10整除,则 N!末尾有 M 个0。考虑 N!可以进行质因数分解,N!= (2X) * (3Y) * (5Z)..., 则由于10 = 25,所以0的个数只与 X 和 Z 相关,每一对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("
");
}
解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;
}
推荐↓↓↓
长
按
关
注
以上是关于数据结构和算法面试题系列—数字题总结的主要内容,如果未能解决你的问题,请参考以下文章 深度学习/机器视觉/数字IC/FPGA/算法手撕代码目录总汇