gitbook/人人都能学会的编程入门课/docs/199921.md
2022-09-03 22:05:03 +08:00

13 KiB
Raw Blame History

15 | 框架思维(下):用筛法求解其他积性函数

你好,我是胡光,咱们又见面了。

上一节,我们讲了素数筛这个算法,并且强调了,要按照框架思维去学习算法代码,因为当你学会这么做的时候,它就可以变成解决多个问题的利器了。

本节我将带你具体使用素数筛算法框架,去解决一些其他简单的数论问题。通过解决这几个具体问题的过程,我希望你能找到“框架思维”的感觉。

今日任务

今天这个任务,需要你依靠自己的力量来完成。不过你也不用担心,我会把需要做的准备工作都讲给你。

这个任务和因数和有关什么叫做因数和呢就是一个数字所有因数的和。那么什么是一个数字的因数呢因数就是小于等于这个数字中能整除当前数字的数。例如28 这个数字的因数有 1、2、4、7、14、28 ,因数和就是各因数相加,即 56。

所以今天我们要做的,就是求出 10000 以内所有数字的因数和。你明白了要算的结果后,可能已经想出采用如下方法来解决:

#include <stdio.h>
int sum[10005] = {0};

void init_sum() {
    // 循环遍历 1 到 10000 的所有数字
    for (int i = 1; i <= 10000; i++) {
        // 用 j 循环枚举数字 i 可能的因数
        for (int j = 1; j <= i; j++) {
            // 当 i%j 不等于 0 时,说明 j 不是 i 的因数
            if (i % j) continue;
            sum[i] += j;
        }
    }
    return ;
}

int main() {
    init_sum();
    printf("hello world\n");
    return 0;
}

我们具体来看一下上面这个方法是怎么做的在代码中init_sum 函数内部就是初始化 sum 数组信息的方法sum[i] 存储的就是 i 这个数字所有的因数和。在 init_sum 方法内部,使用了双重循环来进行初始化,外层循环 i 遍历 1 到 10000 所有的数字,内层循环遍历 1 到 i 所有的数字,然后找出其中是数字 i 因数的数字,累加到 sum[i] 里面,以此来计算得到数字 i 所有的因数和。

这个方法呢,诚然是正确的,可如果你真的运行上述代码,你会发现它会运行一段时间,即使你的电脑配置再好,也会感到它好像卡顿一下,然后才在屏幕上输出了 hello world 这一行信息。什么意思呢?,这表示这种程序方法运行速度较慢。

程序就像一个百米赛跑运动员,衡量一个百米赛跑运动员成绩的指标,除了看他能否到达终点,还有更重要的,就是完成比赛的时间。因此,你不仅要关注程序设计的正确性,还要关注程序的运行效率。

好了,了解完今天的任务以后,下面就让我们来看看,想要设计一个更好更快的程序,都需要准备哪些基础知识吧。

必知必会,查缺补漏

为了解决今天这个问题,你需要一点儿数论基础知识的储备。下面呢,我将分成三部分来给你讲解准备工作:

  • 第一部分是掌握数论积性函数基础知识。有道是工欲善其事,必先利其器,数论是完成今日任务的重要利器。
  • 第二部分,我会举一个具体数论积性函数的例子,就是求一个数字的因数的数量。
  • 最后,我们会把因数数量的求解问题,套在我们之前所学的素数筛算法框架中,以此来说明素数筛的算法框架,基本上可以求解所有的数论积性函数。通过这个过程,彻底让你感受到框架思维的威力。

好了,废话不多说,让我们正式开始今天的学习吧。

1. 数论积性函数

首先我们来看一个知识点,就是关于“数论积性函数”的知识。所谓数论积性函数,首先,是作用在正整数范围的函数,也就是说函数 f(x) = y中的 x 均是正整数。其次,是数论积性函数的一个最重要的性质,就是如果 n 和 m 互质,那么 f(n*m) = f(n) * f(m) 。

什么是互质呢?就是两个数字的最大公约数为 1关于最大公约数的相关内容的话是小学的基本内容如果你实在是忘记了就自行上网搜一下吧我就不再赘述了。总地来说只要一个函数满足以上两点我们就可以称这个函数为数论积性函数。

这里我给出一个具体示例,帮助你理解:

其实我给你讲述这个数论积性函数这个定义的时候呢,并不希望你对它是死记硬背,而是希望你在理解这个定义的时候,可以凭借敏锐的嗅觉,或者说培养自己这方面的意识,能在这里面想到更多。

什么意思呢?当你看到数论积性函数中的 f(n * m) = f(n) * f(m) 的公式的时候这就应该引起警觉这个公式中n*m 是一个要比 n 和 m 都大的值,而 f(n * m) 的函数值却是由 f(n) 和 f(m) 决定的。

这说明什么?说明我们可以利用较小数据 f(n) 和 f(m) 的函数值,计算得到较大数据 f(n * m) 的函数值。再往深的想,这其实就是一个由前向后的递推公式(可以看到递推公式的应用范围其实很广),也就是说,只要函数 f 是数论积性函数,就可以做递推!

这么说的话,你可能还是一脸懵,可以做递推有啥好的?那你就想错了,简单来说,做递推公式可以计算的更快!下面呢,我们就来看一个具体数论积性函数的例子。

2.因数个数函数

在前面我们介绍了因数和的概念,那么因数个数的概念,就不难理解了,它指的是一个数字因数的数量。例如,数字 6有 1、2、3、6 这 4 个因数,因数个数就是 4。

通常情况下,我们如何计算因数个数呢?这个其实比较简单,我们利用反向思维,考虑如何构造一个数字的因数。就拿 12 个数字来说吧12 的因数需要满足什么条件呢?

第一,就是 12 的所有因数中只能包含 2 和 3 两种素因子;第二,就是 12 的所有因数中2 和 3 素因子的幂次,不能超过 12 本身的 2 和 3 素因子的幂次。也就是说12 的因数中最终可以含有 2 的 2 次方,不能含有 2 的 3 次方,因为 12 中最多就只有 2 个素因子 2一个素因子中含有 3 个 2 的数字,不可能是 12 的因数。

综合以上两点,我们其实只要组合 2 和 3 可能取到的所有幂次,就能得到所有 12 的因数。

  
\\begin{aligned}  
12 &= 2^{2}\\times3^{1} \\\\\\  
1 &= 2^0\\times3^0 \\\\\\  
2 &= 2^1\\times3^0 \\\\\\  
4 &= 2^2\\times3^0 \\\\\\  
3 &= 2^0\\times3^1 \\\\\\  
6 &= 2^1\\times3^1 \\\\\\  
12 &= 2^2\\times3^1 \\\\\\  
\\end{aligned}  

正如你所看到的,在构造 12 的因数的时候2 的幂次从 02 有 3 种取值3 的幂次从 01 有2 种取值总共的组合数就是3 * 2 = 6 个也就是说12 一共有 6 个因数。

最后,就让我们来总结一下,如何计算一个数字的因数数量。对于一个数字 N假设数字 N 的素因子分解式可以表示为:

  
\\begin{aligned}  
N = {p\_1}^{a\_1}\\times{p\_2}^{a\_2}\\times{p\_3}^{a\_3}\\times…\\times{p\_m}^{a\_m}  
\\end{aligned}  

其中,$p_i$,就是数字 N 中的第 i 种素因子,a\_i 就是第 i 种素因子的幂次。根据上面我们对于 12 这个数字因数数量的分析,就可以得到数字 N 的因数数量函数 g(N) 的公式表示:

  
\\begin{aligned}  
g(N) = ({a\_1 + 1})\\times({a\_2 + 1})\\times({a\_3 + 1})\\times…\\times({a\_m + 1})  
\\end{aligned}  

正如你所见g 函数计算的就是数字 N 中各种素因子幂次数的一个组合数,就是数字 N 的因数数量。而这个 g 函数呢,就是我们之前所说的数论积性函数。对于数论积性函数来说,关键就是证明第二点,即当 n 和 m 互素g(n * m) = g(n) * g(m)。关于这个证明,首先我们先把 n 和 m 的素因子分解式和因数数量表示出来:

因为 n 和 m 互素,所以 n * m 的素因子分解式和因数数量表示出来,就如下式所示:

这样,我们就证明了,在 n 和 m 互素的情况下g(n * m) = g(n) * g(m),所以 g 函数是数论积性函数。至此,我们完成了所有基础数学知识的准备。

下面呢,我们将从理论向实践迈进,也就是朝代码实现的方向迈进,实现一个求解 10000 以内所有正整数因子个数的程序。

3. 素数筛框架登场

如果想利用 g 函数的数论积性特点,我们就必须能够将一个数字 n快速的分解成互素的两部分。如果我们能快速的拆解出一个数字 n 中的某种素数的话,那么这种素数,与剩余的部分,不就是互素的两部分么?

例如,如果我们能从数字 12 中,快速的拆解出只包含素数 2 的部分,就是因子 4那么 4 与剩余的部分,数字 3 之间一定是互素的。想要完成这个子任务,我们可以求助素数筛框架,我对素数筛的代码做了一个小小的改动:

#define MAX_N 10000
int prime[MAX_N + 5] = {0};
void init_prime() {
    for (int i = 2; i * i <= MAX_N; i++) {
        if (prime[i]) continue;
        // 素数中最小的素因子是其本身
        prime[i] = i;  
        for (int j = 2 * i; j <= MAX_N; j += i) {
            if (prime[j]) continue;
            // 如果 j 没有被标记过,就标记成 i
            prime[j] = i;
        }
    }
    for (int i = 2; i <= MAX_N; i++) {
        if (prime[i] == 0) prime[i] = i;
    }
    return ;
}

正如代码所示init_prime 函数是初始化 prime 数组信息的方法,只不过是 prime 数组中记录的信息与之前的素数筛程序不同了。这个程序中prime[i] 中记录的是数字 i 中最小的素因子例如prime[8]中记录的是 2prime[25] 中记录的是 5。当初始化完 prime 数组以后,我们利用 prime 数组中的信息,就可以快速地完成将一个数字拆解成互素的两部分。

下面这份代码,展示的就是我们如何利用 prime 数组,计算因数数量:

int g_cnt[MAX_N + 5];
void init_g_cnt() {
    // 1 的因数数量就是 1 个
    g_cnt[1] = 1;
    for (int i = 2; i <= MAX_N; i++) {
        int n = i, cnt = 0, p = prime[i];
        // 得到数字 n 中,包含 cnt 个最小素因子 p
        while (n % p == 0) {
            cnt += 1;
            n /= p;
        }
        // 此时数字 n 和最小素数 p 部分,就是互素的
        g_cnt[i] = g_cnt[n] * (cnt + 1);   
    }
    return ;
}

这份代码中g_cnt 数组记录的就是因数数量信息。在 init_g_cnt 函数中,一开始将 g_cnt[1] 置为 1由于数字 1 的因数数量只有它自己本身,所以也就是 1 个。然后从 2 到 10000 循环,依次求解每个数字的因数数量。

循环内部,将数字 i 中,除去最小素因子的剩余部分存储到 n 中,将最小素因子的次数存储在 cnt 变量中。由于因数数量函数是积性函数,最终用 g_cnt[n] 乘上最小素因子 p 部分的 g_cnt 的值,也就是 cnt + 1 的值,即可。

这个程序之所以运行效率快的原因呢,我今天不做具体讨论,你只需要知道,这个程序比我们开始说的那个双层循环程序,运行速度快了一个数量级。

实际上,如果你掌握了“欧拉筛”相关内容,这个程序你会实现得更加漂亮,也更加能够体现我们所说的“框架思维”。“欧拉筛”实际上也是一种筛选出素数的方法,比我们之前学的素数筛更高效,同时,我也认为它体现的思想也更优美,你要是有兴趣,可以自行网上搜索了解。

一起动手,搞事情

前面,我给出了完整的求解因数数量的代码,以及相关数学公式的推导过程。其实,在最开始我们所说的因数和的求解任务,和因数数量的求解类似,都是基于对数字 N 的素因子分解式的观察和思考,得到相关的推导公式。并且,我这里可以预先给你一个确定性的结论,那就是因数和公式,本身也是数论积性函数。

说到这里,你可能就明白了,今天这堂课的作业,其实就是让你参照本节求解“因数数量”的过程,完成求解“因数和”的任务。你需要自行搜索的内容就是约数和公式,或者可以搜索任意一篇相关数论积性函数的文章,里面大概率也都会讲到这部分知识,然后找到解题方法。

课程小结

最后,我们来做一下今天的课程总结。我就希望你记住一点:所谓代码框架,就是要活学活用。

因为在真正的工作中,你所做的事情,大多是在多种代码框架之间做选择及组合拼装,每个算法代码只会解决遇到的一部分问题。而你在使用这些算法代码的时候,往往不能照搬照用,反而要做一些适应性的改变,这些都是“框架思维”中所重视的。

好了,今天就到这里了,我是胡光,我们下期见。