這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在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+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("\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的個數只與 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=23 時,個位上 1 的個數有 1、11、21 共3個,十位上1的個數為 10,11...19 共10個,所以 1 的個數
-
當 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;
}
複製程式碼