給定一個十進制正整數 ,從1開始到的所有整數,計算每個數各個位的數字總和。
例如:
N=4,則1+2+3+4=10。
N=14,則1+2+3+4+5+6+7+8+9+(1+0)+(1+1)+(1+2)+(1+3)+(1+4)=60。
| int sum_digital(int N) { int sum = 0; for (int i = 1; i <= N; i ++) { for (int j = i; j; j /= 10) sum += j%10; } return sum; } |
代碼清單1
這樣暴力的方法讓人提不起任何興趣,我們來點有挑戰性的,假設N和sum都不會超過int整型數值范圍,我們提出兩個擴展問題:
- 設計一個時間復雜度更低,更有效的算法來解決這個問題。
- 假如給定sum的值,能否計算出N的大小?
問題一
解法一
代碼清單1的方法很簡單,一個編程的初學者估計都能寫出那樣的代碼來,我們來分析下它的時間復雜度,N次的循環再乘上每個數的位數為Ο(N*logN ),這樣的計算時間是跟N的大小呈線性增長的。
是否一定需要從1到N循環一遍后再計算呢?如果我們要得到更高效的算法,肯定要拋棄這樣遍歷1到N的方法。
考慮直接統計每個數各個位上的數字出現的次數。假設N=123,設Xi為N的第i位的數字,我們分析下十位上的數字Xi的各種情況。
1.如果X1<2時的情況:
當X1=0時,由於0對總和沒有任何貢獻,所以可以不考慮;當X1=1時,{0~1} 1 {0~9},左邊可選集大小為2,右邊可選集大小為10(因為X1=1,所以右邊任意數字,組合后都不會超過123),那十位數上為1時,組合總數為2*10=20,總和為1*20=20。這種情況的公式為
。
2.如果X1=2時的情況:
{0} 2 {0~9},如果左邊的可選集都小於1,則右邊可選集大小一定為10;{1} 2 {0~3},如果左邊的數字為1,那右邊的可選集只能是0到3的數字,所以十位數上為2時,組合總數為1*10+1*4=14,總和為2*14=28。這種情況的公式為
。
3.如果X1>2時的情況:
當時X1=3,{0} 3 {0~9},左邊可選集大小只有1,右邊可選集大小為10。
對於每個位上的0~9都計算一次它的組合總數,即左邊組合數乘上右邊組合數,最低位和最高位時不用特殊處理,反正左或右的組合數總有一個是為0的。我們把它歸納成公式,則

這樣得到的算法也很簡單。
| int sum_digital_3(int N) { int sum = 0; int tmp = N; for (int i = 0; tmp; tmp /=10, i ++) { int X = tmp%10; int left = tmp / 10; int right = N % pow(10, i); for (int j = 1; j <= 9; j ++) sum += j * left * pow(10, i); for (int j = 1; j < X; j ++) sum += j * pow(10, i); sum += X * (right + 1); } return sum; } |
代碼清單3
這個算法只是簡單的組合數學知識,比起解法一的時間復雜度也只不過多了個常系數而已。從對每個位每數字出發去考慮組合總數,這樣只考慮包含自己的組合即可,不需要擔心是否有數字被忽略或重復計算。
問題一
解法二
我們拿N=14的例子來分析下,1+2+3+4+5+6+7+8+9+(1)+(1+1)+(1+2)+(1+3)+(1+4),我們可以重新排列下,(1+2+3+4+5+6+7+8+9)+(1+1+1+1+1)+(1+2+3+4),啊哈!是否覺得有點靈感了,我們可以用乘法去重新排列,這次我們拿N=20做例子,2*(1+2+3+4+5+6+7+8+9)+10*1+(2)。這個乘法的公式跟代碼1的區別就出來了,在代碼1中全部都是加法。

圖1
簡單總結下發現,計算兩位數時,以個位數總和
作為一個單位,假設有個兩位數整數為X*10,則有公式
。再來算算三位數X*100的情況,我們先把
抽象為更廣義的一個函數F(x),它表示1~(10x-1)各位數的總和,則對公式就是
,歸納一下,我們針對X*10n (0≤X≤9)此類的數據就有了通項公式

。再來看看函數F(x),明顯得,
我們能很輕松的得出它的遞推公式

,聰明的你一定看出了,其實F(x)就是X*10n通項公式當X=10的時候的特例。呼,世界大同了~
一切似乎都顯得很順利,但是我們的問題是當
時的算法。相信你應該已經有想法了。
參考的通項公式,直接代入進去有問題嗎?回答是當然有問題,問題在於還有一些數字沒有被計算到。

圖2是N=121的圖例,我們用X*10n的通項公式來分析。淺色區00~99和0~9由Xn*F(n)計算得到,深色區由
計算所得,但剩下的無色區並不只有Xn這么簡單,比如十位的2,它可以是20,21,所以這里應該把Xn改成Xn*(N%10n+1)。代碼清單2就是這個算法。
| int sum_digital_2(int N) { int F[11] = {0}; for (int i = 1; i < 10; i ++) F[i] = 10 * F[i-1] + 45 * pow(10, i-1);
int sum = 0; int tmp = N; for (int i = 0; tmp; tmp /= 10, i ++) { int X = tmp%10; if (X == 0) continue; sum += X * F[i]; sum += (X-1) * X / 2 * pow(10,i); sum += X * (N%pow(10,i) + 1); } return sum; } |
代碼清單2
在代碼中,F(x)被預先計算出來,當然使用pow求10的冪次也可以先預處理,這里只是為了做范例。這個代碼中的循環次數只和的位數有關,它的時間復雜度為O(logN),這樣的復雜度,即使N超出了32位整數的數值范圍,也能迅速計算完成,但那時你得注意sum的溢出問題。
雖然這個高效算法實現了,但我們仍然有疑惑,比如,為何把Xn改成Xn*(N%10n+1)代入后,各個位計算求和就是正確答案了?這個過程似乎太自然,我們回頭來驗證下。
我先整理下我們的思考過程,這里體現了分治思想。將一個難以直接解決的大問題,分割成一些規模較小的相同問題,以便各個擊破,分而治之。問題的規模越小,越容易直接求解,就如遞推函數F(x),以它為基礎再解決這樣X*10n特殊的子問題。
從下而上分析,因為我們發現代碼清單1的計算過程中存在大量的重復,如
,周期性不停地循環着,而第n位的數字就是第n-1位的周期數,由0~9的循環我們能得出00~99的循環,進而得到000~999的循環。
從上而下分析,考慮可以把高位的數字統計完,再計算低位的數字,按位分段計算,我們肯定能得到形如
類似這樣的公式。
問題二
解法一
問題二是問題一的逆命題,對於逆命題,我們可以按着原命題的算法逐個計算再測試計算結果和給定sum的值是否相同,這樣的算法只要一個for循環不停的枚舉N,並調用sum_digital_2函數,當計算的值超過sum時就可以停止了,因為不可能有比正確答案更大的N而它的sum卻小於或等於給定的sum,簡單的說,sum是隨着N增大而增大的,它是一個單調遞增函數。
枚舉法是沒有效率可言的,乘上sum_digital_2復雜度,它的時間復雜度為O(N logN),為了得到更高效的算法,我們使用二分法來替換枚舉法,知道二分查找的同學肯定不會陌生。
之所以二分法在這里能派上用場,就是因為sum值有單調遞增的特點。設置left和right變量,每次取它們的中間值mid來測試,如果小了,按照sum值單調性我們可知肯定大於mid,則left往mid+1移動,否則right往mid移動。應用二分法后的時間復雜度為O(logN logN)。
| int find_n(int sum) { int l = 0; int r = sum; while (l < r) { int mid = (l+r) >> 1; int tmp = sum_digital_2(mid); if (tmp < sum) l = mid + 1; else r = mid; } return l; } |
代碼清單4
這里還需要注意的是溢出問題,代碼清單4中直接把sum當作right的值,這很容易使得sum_digital_2(mid)計算的結果溢出,有個簡單的解決辦法,可以拿sum和代碼清單2中的F[i]數組去比較,就可以知道N是屬於哪個X*10n范圍之內,再拿X*10n作為right的值。
