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 的因数。
121243612=22×31=20×30=21×30=22×30=20×31=21×31=22×31
正如你所看到的,在构造 12 的因数的时候,2 的幂次从 0~2 有 3 种取值,3 的幂次从 0~1 有 2 种取值,总共的组合数就是 3 * 2 = 6 个,也就是说,12 一共有 6 个因数。
最后,就让我们来总结一下,如何计算一个数字的因数数量。对于一个数字 N,假设数字 N 的素因子分解式可以表示为:
N=p1a1×p2a2×p3a3×…×pmam
其中,pi,就是数字 N 中的第 i 种素因子,ai 就是第 i 种素因子的幂次。根据上面我们对于 12 这个数字因数数量的分析,就可以得到数字 N 的因数数量函数 g(N) 的公式表示:
g(N)=(a1+1)×(a2+1)×(a3+1)×…×(am+1)
正如你所见,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;
}
}
return ;
}
正如代码所示,init_prime 函数是初始化 prime 数组信息的方法,只不过是 prime 数组中记录的信息与之前的素数筛程序不同了。这个程序中,prime[i] 中记录的是数字 i 中最小的素因子,例如 prime[8] 中记录的是 2,prime[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 的素因子分解式的观察和思考,得到相关的推导公式。并且,我这里可以预先给你一个确定性的结论,那就是因数和公式,本身也是数论积性函数。
说到这里,你可能就明白了,今天这堂课的作业,其实就是让你参照本节求解“因数数量”的过程,完成求解“因数和”的任务。你需要自行搜索的内容就是约数和公式,或者可以搜索任意一篇相关数论积性函数的文章,里面大概率也都会讲到这部分知识,然后找到解题方法。
课程小结
最后,我们来做一下今天的课程总结。我就希望你记住一点:所谓代码框架,就是要活学活用。
因为在真正的工作中,你所做的事情,大多是在多种代码框架之间做选择及组合拼装,每个算法代码只会解决遇到的一部分问题。而你在使用这些算法代码的时候,往往不能照搬照用,反而要做一些适应性的改变,这些都是“框架思维”中所重视的。
好了,今天就到这里了,我是胡光,我们下期见。
文章作者 anonymous
上次更新 2024-05-18