给定一个十进制正整数 ,从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的值。