对“拆解”的分析
接着上回继续说。
为了理解为什么n的位数包含“1”时,我的改进函数就失效了,决定选取几个简单的数字,把我们实际要计算的东西呈现出来。有了直观的图,就更容易发现问题所在。
8: 12345678
16: 12345678910 111213141516
25: 12345678910 11121314151617181920 2122232425
36: 12345678910 11121314151617181920 21222324252627282930 313233343536
456: 123......440441......450451452453453455456
注:为了看起来更轻松些,每隔10个数字的字符我用空格作了下分隔
当我们把数字罗列得紧密些时,就会发现,对于countDigitOne(n),实际上是对[1, n]这个区间内的所有数字字符进行计算,而不是数字本身。
例如n=8时,我们表面上是对1、2、3、4、5、6、7、8这几个数字计算,但是实际是对“12345678”这个数字字符串进行计算,我们的目的,是要计算这个数字字符串里有多少个“1”字符。
拿一个两位数的数字来举例,就更容易理解了。例如36,我们不是对1、2、3…35、36这几个数字进行计算,而是对“123456789101112131415161718192021222324252627282930313233343536”这一串的数字字符串进行操作。
根据上一篇文中所给出的尺度,我们很敏感地看到,这些字符串中有一部分是我们掌控的:
16: 【12345678910】 111213141516
25: 【12345678910 11121314151617181920】 2122232425
36: 【12345678910 11121314151617181920 21222324252627282930】 313233343536
上面被【】包括起来的部分,就是我们掌控的信息。我们知道这一部分数字串里,有多少个1,因为它们实际就是我们得出的尺度值。
既然如此,那么我就计算剩下的部分就好了吧?拿16举例,
16: 12345678910 111213141516
可以分成“12345678910”、“111213141516”这两部分进行计算。
前者是我们的尺度,用getScale函数即可迅速得出。
后者仔细一看,似乎也能发现一点规律,它与某种结构很类似
111213141516
-> 11 12 13 14 15 16
-> 1 1 1 2 1 3 1 4 1 5 1 6
-> 1 2 3 4 5 6 1 1 1 1 1 1 // 把十位的“1”全部拿出来
-> 123456 111111 // 实际就是“123456” ,多出了6个“1”
123456
-> 1 2 3 4 5 6
11~16这个区间,实际跟1~6这个区间所包含的数字串,是很相似的。区别只在于11~16比1~6多出几个1而已。这个关系是可以被我们利用的,毕竟1~6区间内有多少个“1”,也是我们的尺度值,可以轻易得出。
到后面我会介绍如何利用这一个关系,这里先知道有那么一回事。
到这里,可以总结出几点判断:
1)对于[1, n]内所有数字包含多少个“1”的计算,实际是对这些数字组成的数字字符串,包含多少个“1”字符的计算
2)可以把整个数字串分割成几个部分
3)一部分数字串的计算可以用我们的尺度得出
4)剩下的数字串部分,可以把较大的区间转成较小的区间(且这一较小区间与我们的尺度值也有关系),然后额外计算多出来的“1”
5)只要把这几部分得出的计算结果相加,就是最后的计算结果
这里
对“拆解”操作的反思
为什么数字n包含了“1”,改进函数countDigitOne的计算就不对了呢。我们找两个数字16、25来看,16包含了“1”,25不包含“1”。
16: 12345678910 111213141516
25: 12345678910 11121314151617181920 2122232425
之前通过拆解,会把16拆成10、6,25拆成20、5。
16 = 10 + 6 -> 10、6
25 = 20 + 5 -> 20、5
- 对25的计算是这样的,countDigitOne(25) = getScale(20) + getScale(5)。
25: 【1234567891011121314151617181920】 2122232425
// getScale(20)计算的是【】内的数字串(即1~20)
25: 1234567891011121314151617181920 《2122232425》
// getScale(5)计算的是《》内的数字串(即21~25)
getScale(5)其实计算的是1~5区间内,包含的“1”字符。而我们实际应该计算的,是21~25区间内包含的“1”字符。只是由于21~25的十位是“2”,所以1~5区间跟21~25区间包含的"1"字符是一样的。
我们只是恰好走了个狗屎运。。
- 对16的计算是这样的,countDigitOne(16) = getScale(10) + getScale(6)
16: 【12345678910】 111213141516
// getScale(10)计算的是【】内的数字串(即1~10)
16: 12345678910 《111213141516》
// getScale(6)计算的是《》内的数字串(即11~16)
但是1~6区间跟11~16区间,包含的"1"字符个数是不一样的,因为11、12、13、14、15、16每一个数字都多出了一个“1”(正是前文得出的规律)。这也是我们的countDigitOne函数一旦碰到数字n内包含“1”,就不准确的原因。
countDigitOneBl(121); // 55
countDigitOne(121); // 34
countDigitOneBl(1314); // 782
countDigitOne(1314); // 464
countDigitOneBl(11111); // 5560
countDigitOne(11111); // 4326
可以看出,countDigitOne方法比countDigitOneBl暴力法计算出的“1”的个数要少,这正是因为我们忽略了多出来的那些“1”而出现的现象。
对“拆解”的改进:分组
从上文可以得出一个结论,之所以我们的函数不完善,是因为遇到n等于1111、1314这种自带“1”的数字时,会少计算一些额外的“1”。那么,我们只要把这些额外的“1”计算进去,不就好了吗?
如果要计算这些额外的这些“1”,首先这些信息是不能丢掉的。
拿16举例来说,我们知道可以分成1~10、11~16这两部分来计算,并且在11~16这个区间内,我们就需要计算额外的“1”。但是,如果我们按照之前那么简单粗暴的方式来拆解,
16 = 10 + 6 -> 10、6
计算第二部分的时候,只能用getScale(6)计算1~6区间的“1”,额外的“1”此时不见了。这就是信息的丢失,所以不能这么粗暴地拆解了。我们稍微改进一下,
16 -> 1~10、11~16 -> [1, 10]、[11, 16]
我们把16变成[1, 10]、[11, 16]两个部分、两个区间,相当于划分为了两个组。这样不仅没有丢失信息,而且更直观,也更符合事实。
分组的原则
再举个例子1717,我们可以这么分组,
1717 -> [1, 1000] [1001, 1700] [1701, 1710] [1711, 1717]
其实也很类似按照“千”、“百”、“十”、“个”位这样的方式来划分。我们可以总结出分组的操作过程,
第一个数组:
// 左边界是1,右边界取最高位“千”
[1, 1000]
第二个数组:
// 左边界以上一个数组右边界为基础、加1,右边界除了取最高位“千”,还要取下一位“百”
[1001, 1700]
第三个数组:
// 左边界以上一个数组右边界为基础、加1,右边界还要取下一位“十”
[1701, 1710]
第四个数组:
// 左边界以上一个数组右边界为基础、加1,右边界还要取下一位“个”
[1711, 1717]
总结起来,就是
第一个数组(初始化时):左边界是1,右边界取最高位
其余数组:左边界以上一个数组右边界为基础、加1,右边界接着取下一位
用函数实现,即
function groupNum(n) { // 分组
let nStr = n.toString(), results = [];
let len = nStr.length;
let lb = rb = 1; // [lb, rb]
let p = 0;
// init
lb = 1, rb = Number(nStr[0]) * Math.pow(10, len - 1);
results.push(Array.from([lb, rb]));
while (rb < n) {
lb = rb + 1;
let rbStr = rb.toString().split('');
p++;
for (let i = p; i < len; i++) {
if (nStr[i] != 0) {
rbStr[i] = nStr[i];
p = i;
break;
}
}
rb = Number(rbStr.join(''));
results.push(Array.from([lb, rb]));
}
return results;
}
我多写几个输出例子,可以感受一下(也可以直接复制这个函数去调用试试):
groupNum(12345);
/*[ 1, 10000 ],[ 10001, 12000 ],[ 12001, 12300 ],[ 12301, 12340 ],[ 12341, 12345 ]*/
groupNum(131415);
/* [ 1, 100000 ],[ 100001, 130000 ],[ 130001, 131000 ],[ 131001, 131400 ],[ 131401, 131410 ],[ 131411, 131415 ]*/
groupNum(789);
/* [ 1, 700 ], [ 701, 780 ], [ 781, 789 ]*/
对每个分组计算
还是拿1717进行举例,我们已经知道它可以这么分成4组:
1717 -> [1, 1000] [1001, 1700] [1701, 1710] [1711, 1717]
现在我们就分别对这4个分组进行计算,统计“1”的个数。
第一组 [1, 1000]
我们可以直接使用getScale函数,将其作为尺度进行计算。(getScale函数详见“尺度“这一节)
// 计算[1, 1000] 区间“1”的个数
count1 = getScale(1, 1000);
第二组 [1001, 1700]
如果把这一区间的数字字符全部写出来,就会发现,它实际跟1~700这个区间的全部数字字符很类似,不一样的是它每一次都多出了一个千位上的“1”。
这正是我们在本文第一章得出的结论之一:
4)剩下的数字串部分,可以把较大的区间转成较小的区间(且这一较小区间与我们的尺度值也有关系),然后额外计算多出来的“1”
所以,我们可以把计算1001~1700区间,变成计算1~700区间,然后把额外的“1”加上。这里一共有700个数字,每一个数字都额外多出1个“1”,所以一共有700 * 1个额外的“1”。
count2 = getScale(7, 100) + 700 * 1;
第三组 [1701, 1710]
跟第二组类似,我们把1701~1710区间转换成1~10区间,再计算额外的“1”。这里一共出现了10个数字,每个多出1个额外的“1”,所以一共有10 * 1个额外的“1”。
count3 = getScale(1, 10) + 10 * 1;
第四组 [1711, 1711]
1711~1711区间,实际就是数字1711本身了。。这里直接看个位是不是1即可,然后也别忘了计算额外的“1”。这里只出现了1个数字,并且每个数字多出了2个额外的“1”(千位、十位),所以一共有1 * 2个额外的“1”。
count4 = getScale(1, 1) + 2 * 1;
求和
最后,把我们辛苦得来的4个部分的结果进行求和,就得到最终结果了。
count = count1 + count2 + count3 + count4
为了方便,我将这一部分逻辑也抽象成了函数:
function computeDigitOne(interval) { // 对区间内的1进行计数
if (interval[0] === interval[1]) { // [11, 11] -> 11 直接计数
return interval[0].toString().split('').filter(item => item == 1).length; // 筛掉非1,然后计算长度
}
if (interval[0] == 1) {
let rbStr = interval[1].toString();
let num = Number(rbStr[0]), level = Math.pow(10, rbStr.length - 1);
return getScale(num, level);
} else {
let lbArr = interval[0].toString().split(''), rbArr = interval[1].toString().split('');
let len = lbArr.length, diff, extraOneCount = 0; // extraOneCount 额外的对1计数
for (let i = 0; i < len; i++) {
if (lbArr[i] === rbArr[i]) {
if (lbArr[i] == 1) {
extraOneCount++;
}
lbArr[i] = rbArr[i] = 0;
}
}
diff = [Number(lbArr.join('')), Number(rbArr.join(''))];
return computeDigitOne(diff) + extraOneCount * (interval[1] - interval[0] + 1);
}
}
第二次改进
到这里,我们就了足够的工具对之前的求解函数进行进一步优化了。之前我们是这么做的:
1)简单粗暴地拆解
2)获得尺度
3)对拆解部分计算
4)求和
但是通过进一步分析,我们进行了改进,现在我们这么做:
1)仔细地分组
2)获得尺度
3)对每个分组进行计算(注意要计算额外的“1”)
4)求和
于是,最终的函数便呼之欲出:
var countDigitOne = function(n) {
if (n < 1) return 0;
let count = 0;
groupNum(n).forEach(item => {
count += computeDigitOne(item);
})
return count;
function getScale(num, level) { // 作为一种尺度
if (level == 1) return 1;
if (num === 1) {
let digit = level.toString().length;
return (digit - 1) * Math.pow(10, digit - 2) + 1;
} else {
let base = 10 + num;
return (base + num * (level.toString().length - 2)) * (level / 10);
}
}
function groupNum(n) { // 分组
let nStr = n.toString(), results = [];
let len = nStr.length;
let lb = rb = 1; // [lb, rb]
let p = 0;
// init
lb = 1, rb = Number(nStr[0]) * Math.pow(10, len - 1);
results.push(Array.from([lb, rb]));
while (rb < n) {
lb = rb + 1;
let rbStr = rb.toString().split('');
p++;
for (let i = p; i < len; i++) {
if (nStr[i] != 0) {
rbStr[i] = nStr[i];
p = i;
break;
}
}
rb = Number(rbStr.join(''));
results.push(Array.from([lb, rb]));
}
return results;
}
function computeDigitOne(interval) { // 对区间内的1进行计数
if (interval[0] === interval[1]) { // [11, 11] -> 11 直接计数
return interval[0].toString().split('').filter(item => item == 1).length; // 筛掉非1,然后计算长度
}
if (interval[0] == 1) {
let rbStr = interval[1].toString();
let num = Number(rbStr[0]), level = Math.pow(10, rbStr.length - 1);
return getScale(num, level);
} else {
let lbArr = interval[0].toString().split(''), rbArr = interval[1].toString().split('');
let len = lbArr.length, diff, extraOneCount = 0; // extraOneCount 额外的对1计数
for (let i = 0; i < len; i++) {
if (lbArr[i] === rbArr[i]) {
if (lbArr[i] == 1) {
extraOneCount++;
}
lbArr[i] = rbArr[i] = 0;
}
}
diff = [Number(lbArr.join('')), Number(rbArr.join(''))]; // [80001, 89000] -> [ 00001, 09000 ]
return computeDigitOne(diff) + extraOneCount * (interval[1] - interval[0] + 1);
}
}
};
这里最后用到了一次递归:
diff = [Number(lbArr.join('')), Number(rbArr.join(''))]; // [80001, 89000] -> [ 00001, 09000 ]
return computeDigitOne(diff) + extraOneCount * (interval[1] - interval[0] + 1);
对于卡我们脖子的数字89023,我们能够轻易得出结果:
countDigitOne(89023) // 45713
更大的数字也不在话下,
countDigitOne(987654321) // 891632373
countDigitOne(123456789) // 130589849
提交结果
将我们的最终函数提交上去了,令人欣喜地通过了。
执行用时:88 ms, 在所有 JavaScript 提交中击败了31.88%的用户
内存消耗:37.8 MB, 在所有 JavaScript 提交中击败了13.64%的用户
虽然执行用时靠后,但是通过对大佬的解法函数(也就是官方标准的数学规律归纳法)大佬的解法在这里进行比较,发现用时差的也不是太多,跟我这里一样,都是80多ms左右,估计快个3、4ms。
但是我的逻辑还稍微复杂了一些(代码层面也有可以优化的地方),写法谈不上简洁(比起一个公式解决那类方法),能够得到这么少的用时(尽管不知道所有人的用时),还是感到有些欣慰的。。
大佬的解法和代码已经足够优雅了,不知道是不是还有比他更加优化的答案,用时能达到70ms或者甚至更少。如果有的话,那么比我的更好,也是有道理的。
最开始也说了,我的解法不一定是最优的。我觉得宝贵的地方不在于能不能解决这个问题、解法是不是最优的解法,而在于在解决这个问题的过程中,能够一步步按照自己的想法和推理,逐渐接近答案,并从中得到一些感悟和思想。