连续子数组零尾数问题 | 豆包MarsCode AI刷题

103 阅读5分钟

1. 题目解析

连续子数组零尾数问题是困难题的最后一题。先看题目:要求求解给定数组中乘积末尾零的数量大于等于 x 的连续子数组的数量。

不难分析,其基本思路一般为:

  • 定义连续子数组。
  • 求子数组元素的乘积。
  • 求解乘积末尾零的数量是否大于等于x
  • 重复以上步骤,直到处理完所有连续子数组。

2. 思路解析

第一眼看到题目的时候我想到的是之前常用的两种方法:

  1. 整数循环除以10,通过辗转相除求余的方法进行计数:这是最直观的方法,只需要对数字型的乘积进行处理即可。但存在明显的问题:
    • 由于是乘运算,只要子数组的数据稍大一些,或者元素数量稍多一些,很可能会得到一个很大的乘积,导致超出定义的数据类型范围;
    • 整数不能被整除时,强行进行整除会导致精度损失,若辗转相除的次数过多,逐渐累积的精度损失可能会对最终结果产生影响。
  2. 整数乘积转字符串,翻转字符串处理末尾字符:这种方法基于读取思想,利用字符串的特性来进行操作。相较于第一种方法而言,解决了辗转相除精度损失的问题,但由于转字符串之前仍然得进行乘积操作,因此还是无法解决数据溢出的问题。

那么,我们还有什么方法来进行求解呢?

不妨从数学的角度思考一下:乘积末尾的零是怎么得来的?观察并思考以下几个简单的乘法式子:

2X5=102 X 5 = 10

10X2=2010 X 2 = 20

20X5=10020 X 5 = 100

10X10=10010 X 10 = 100

我想你应该已经看出来了。没错,乘积末尾的零来自于乘积因数中的10,每有一个10末尾就多一个零

1010 又是怎么来的呢?当然是 2X52 X 5 啦。

将以上结论结合起来,不难得出:乘积分解出的因数中,每有一对{2, 5},乘积的末尾就会有一个零

于是,对题目的求解思路就变为:求解乘积分解的因数中2和5的数量

3. 代码实现

到这一步,我们就可以开始上代码了:

首先,定义乘积中的指定因数的计数器。这里采用递归的方式来求解:

public static int countNum(int num, int n) { // num为待分解的数,n为指定因数
    if (num == 0 || num % n != 0) // 递归出口
        return 0;
    return 1 + countNum(num / n, n); // 递归本体
}

接下来完成Solution的定义。这里对代码思想进行简要说明:

  • 题目要求求子数组元素的乘积,这就要求子数组非空,因此len初始化为1。
  • 由于处理对象是连续子数组,求解的是元素乘积,因此对于首元素相同的子数组,元素更少的数组必然是元素更多的数组的子数组,对于并集部分的元素,处理结果是可以共享的。
  • 我们要做的是对乘积进行因数分解,本质上其实不需要乘积,因此,可以直接对参与乘积的元素进行分解。

整理思路,实现代码如下:

int subArraysNum = 0; // 最后的返回值,记录符合条件的子数组的数量
for (int i = 0; i < a.length; i++) { // 遍历数组,作为子数组的首元素
    int len = 1; // 定义子数组长度
    int fiveCounter = 0; // 因数中5的个数的计数器
    int twoCounter = 0; // 因数中2的个数的计数器
    while (i + len <= a.length) { // 遍历求解首元素相同的子数组
        fiveCounter += countNum(a[i + len - 1], 5); // 求因数的数量
        twoCounter += countNum(a[i + len - 1], 2);
        if (Math.min(fiveCounter, twoCounter) >= x) // 符合条件则计数
            subArraysNum++;
        len++;
    } 
}

最后,根据题目条件,再对10^9 + 7进行求余,返回结果:

int base = 1;
for (int i = 0; i < 9; i++) {
    base *= 10;
}
return subArraysNum % (base + 7);

完整代码如下:

public static int countNum(int num, int n) {
    if (num == 0 || num % n != 0)
        return 0;
    return 1 + countNum(num / n, n);
}

public static int solution(int[] a, int x) {
    int subArraysNum = 0;
    for (int i = 0; i < a.length; i++) {
        int len = 1;
        int fiveCounter = 0;
        int twoCounter = 0;
        while (i + len <= a.length) {
            fiveCounter += countNum(a[i + len - 1], 5);
            twoCounter += countNum(a[i + len - 1], 2);
            if (Math.min(fiveCounter, twoCounter) >= x)
                subArraysNum++;
            len++;
        }
    }

    int base = 1;
    for (int i = 0; i < 9; i++) {
        base *= 10;
    }
    return subArraysNum % (base + 7);
}

4. 性能分析

4.1 时间复杂度

时间复杂度主要考虑以下部分:

  • countNum():其每次递归求解的规模为 num/nnum/n,设最坏情况为经过k次递归后规模缩小为1,有 num/nk=1num / n^k = 1 ,解得 k=logn(num)k = log_n(num) ,因此其时间复杂度为 O(logn)O(logn)
  • solution():遍历数组需要 O(n)O(n) 的时间,每个固定首元素遍历子数组需要 O(n)O(n) 的时间。

由于各个部分是嵌套的关系,因此总时间复杂度为 O(n2logn)O(n^2logn)

4.2 空间复杂度

countNum()递归调用所需的栈空间为 O(logn)O(logn) ,其余均为常数级变量,因此空间复杂度为 O(logn)O(logn)

5. 总结

其实这道题本身并不算太难,乍一看题目思路也是很经典的整数转字符串处理,但由于有“求零”(即求10)这个特殊条件存在,使得我们可以另辟蹊径,用更简便的思路与代码实现算法。以后若是遇到同类型的问题,可以从题目的特定条件入手,寻找更加适合题目的特殊方法。