JavaScript算法学习之“1”的个数(下)

相关文章:JavaScript算法学习之“1”的个数(上)

对“拆解”的分析

接着上回继续说。

为了理解为什么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:12345678910111213141516
25:12345678910 111213141516171819202122232425
36:12345678910 11121314151617181920 21222324252627282930313233343536

上面被【】包括起来的部分,就是我们掌控的信息。我们知道这一部分数字串里,有多少个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 -> 106
25 = 20 + 5 -> 205
  1. 对25的计算是这样的,countDigitOne(25) = getScale(20) + getScale(5)。
25:12345678910111213141516171819202122232425
// getScale(20)计算的是【】内的数字串(即1~20)

25: 12345678910111213141516171819202122232425// getScale(5)计算的是《》内的数字串(即21~25)

getScale(5)其实计算的是1~5区间内,包含的“1”字符。而我们实际应该计算的,是21~25区间内包含的“1”字符。只是由于21~25的十位是“2”,所以1~5区间跟21~25区间包含的"1"字符是一样的。

我们只是恰好走了个狗屎运。。

  1. 对16的计算是这样的,countDigitOne(16) = getScale(10) + getScale(6)
16:12345678910111213141516
// getScale(10)计算的是【】内的数字串(即1~10)

16: 12345678910111213141516// 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 -> 106

计算第二部分的时候,只能用getScale(6)计算1~6区间的“1”,额外的“1”此时不见了。这就是信息的丢失,所以不能这么粗暴地拆解了。我们稍微改进一下,

16 -> 1~1011~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或者甚至更少。如果有的话,那么比我的更好,也是有道理的。

最开始也说了,我的解法不一定是最优的。我觉得宝贵的地方不在于能不能解决这个问题、解法是不是最优的解法,而在于在解决这个问题的过程中,能够一步步按照自己的想法和推理,逐渐接近答案,并从中得到一些感悟和思想。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值