14 | 框架思维(上):将素数筛算法写成框架算法

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

上一节呢,我们提到了一个词,叫做“算法思维”,就是用算法去解决问题的思维方式,并且说明了算法思维有别于我们通常所说的“算法”。那么如何锻炼算法思维呢?

今天我要说的这个方法,就叫做“照猫画虎”。什么意思呢?如果我们把一个个具体的算法称之为猫,而每个具体算法中所能锻炼的“算法思维”就是那只虎。也就是说,我们可以通过学习一些简单具体的算法,来总结一些重要的算法思维。

接下来的两节中,我先带你锻炼的是算法思维中的“框架思维”,所谓框架思维就是将一个具体的算法学成一个框架,变成一个可以解决多个问题的利器。废话不多说,开始今天的课程吧。

今日任务

在开始今天的学习之前,先让我们来看看今天这 10 分钟的任务吧。这个任务很简单,就是求 1 万以内所有素数的和。

素数,也叫做质数,就是只能被 1 和其本身整除的数字。举例说,30 以内的素数依次是:2、3、5、7、11、13、17、19、23、29,这几个数字相加之和,等于 129。

而与素数相对的概念,就是合数,它指的是除了能被1和其本身整除以外,还可以被其他数字整除的数字。你可以简单理解为合数是由若干个素数相乘得到的数字,也就是说一个合数,一定能被某个素数整除。例如,6 就是合数,能被 2 和 3 这两个素数整除。

这里我多说几句,素数在数论当中(关于什么是数论,感兴趣的同学可以自行搜索了解),是一个很重要的概念,而数论可以说直接奠定了我们当代互联网经济的基础,那就是“信息安全”。试想,如果不能保证信息安全,你敢在网上使用你的手机号,进行某些登录操作么?如果不能保证信息安全,你敢在网络上购物,支付买单么?如果信息不安全,你敢和你的朋友在聊天工具上畅所欲言么?这一切的一切,都与我们今天说的素数有关系,你说素数重不重要?下面让我们正式开始今天的学习吧。

必知必会,查缺补漏

今天我将给你介绍一个算法,就是素数筛算法。这个算法呢,思想很直接,也很简单,相信我,你肯定可以学会的。

1. 素数筛算法介绍

所谓素数筛,是将其产出的信息存储在一个标记数组中,数组的第 i 位,标记的是 i 这个数字是否是合数的信息。如果 i 这个数字是合数,数组下标为 i 的位置就被标记成为 1,如果 i 不是合数,则数组下标为 i 的位置就是 0。素数筛就是通过一套算法流程,产生一个这样的数组。

可以看到,素数筛的作用就是把所有合数标记出来,在知道了这个范围内所有的合数之后,也就很容易找出这个范围内所有的素数了。

沿着这个思路,算法中要解决的第一个问题,就是如何标记合数?这个就要回忆一下合数的特征了,根据前面的解释,我们知道一个合数一定能被某个素数整除,也就是一定是某个素数的整数倍。也就是说,如果 2 是素数,那么 2 的 2 倍、3 倍、4 倍等等,一定不是素数,我们就可以把 4、6、8 这些数字分别标记为合数。

这个做法里面,你会发现好像有一个死结,我们要标记掉所有合数,就需要找到所有素数,这就又回到最开始素数筛要解决的问题,这不就变成了一个先有鸡,还是先有蛋的问题了么?其实不然,下图是我整理的算法流程:

素数筛算法从 2 开始,执行若干轮,每一轮呢,找到第一个没有被标记掉的数字,可以猜想到,这个数字就一定是素数。为什么呢?其实用我们之前说的“数学归纳法”就可以证明。

首先,2 是第一个没有被标记的数字,所以 2 肯定是素数,然后我们可以正确的标记掉所有 2 的倍数。假设在数字 n 之前,我们正确找到了所有素数,并且将这些素数的倍数均标记掉了,那么 n 作为后续第一个没有被标记掉的数字,n 就一定素数,最后,我们可以用 n 标记掉 n 所有的倍数,这也就保证了后续过程的正确性。在这个过程中,其实也证明了整个素数筛算法的正确性。

为了让你有个更直观的感受,我给你整理了10以内,素数筛算法前三轮的示意图:

如图所示,第一轮的时候,2没有被标记掉,我们就使用2 标记掉所有2的倍数,标记掉的就是 4、6、8、10 这四个数字;第二轮的时候,继续向后找,第一个没有被标记掉的数字是 3,那么我们接着标记掉范围内所有 3 的倍数,就是 6、9 这两个;第三轮,发现 5 没有标记掉,那么就用 5 去标记了 10 这个数字。

2. 素数筛代码框架总结

在认识了基本的素数筛算法以后,让我们看看素数筛的具体代码实现,下面的示例代码呢,演示了如何标记 10000 以内所有合数,以此来找到这个范围内所有的素数。

int prime[10005] = {0};
void init_prime() {
    // 素数筛的标记过程
    for (int i = 2; i * i <= 10000; i++) {
        if (prime[i]) continue;
        // 用 j 枚举所有素数 i 的倍数
        for (int j = 2 * i; j <= 10000; j += i) {
            prime[j] = 1; // 将 j 标记为合数
        }
    }
    return ;
}

如代码所示,init_prime 就是素数筛算法的过程,并把最终生成的信息都存储在了 prime 数组中,如果prime[i] 为 1 ,说明 i 是合数。

这个算法流程中呢,包含了两层循环结构,外层循环结构,从 2 开始遍历到根号 10000,也就是 100。其中这里还用到了一个编程技巧,原本代码应该写成:i <= sqrt(10000) 的这个不等式,而加上了左右平方,就变成了上面的 i * i <= 10000 这样的代码。这种改变是有好处的,会在代码运行速度上做提升,毕竟开方运算是很慢的,远远没有单独做一个乘法操作要快。

第 5 行代码,是判断 i 这个数字是否被标记过的,如果被标记过,就说明是合数,就不执行后续操作。当代码到了第6行的时候,说明此时 i 这个数字,一定是素数,我们就用内部的 j 循环,遍历所有数字 i 的倍数,并且将 prime[j] 标记为 1,也就是将 j 这个数字标记为合数。

执行完 init_prime 函数以后,prime 数组中就是所有合数的标记信息,反向思维就能找到所有素数,就是那些没有被标记掉的数字。

在这份代码中,你需要注意以下两点:一是到了代码的第 6 行,数字 i 有什么特性?二是为什么外层循环 i 只需要遍历到根号 10000 即可?

第一点比较好理解,到了代码第6行,这时候访问到的 i 一定是素数。第二点呢,就要从合数的特点思考了,合数一定可以表示为两个非 1 整数的乘积形式,否则那就是素数了。例如,6可以拆解成 2 * 3,39 可以拆解成 3 * 13 等等。而质数 7 呢,只能表示成 1 * 7,这不是两个非 1 整数。

而用来表示合数 n 的这两个数字,一定是一个小于等于根号 n,一个大于等于根号 n。我们再具体看那个小于等于根号 n 的数字,假设它是数字a ,如果a是素数,那么在素数筛算法中,i 遍历到根号 n,数字 a 一定可以正确的标记掉数字 n;而如果数字a不是素数,而是一个合数,那说明数字 n 可以被一个更小的数字标记掉。这也就说明,外层循环 i 只需要遍历到根号 n,就可以正确的标记掉 n 这个范围内所有的合数。

在你学习这份代码的时候,或者以后自学某些其他算法代码的时候,清晰地知道这份代码到了第几行,某些变量的取值有什么性质,这是理解框架性思维的最重要的一步。只有这样,你才能游刃有余地使用你所会的所有的算法代码。

最后,我们来说一下素数筛这个代码中最重要的性质吧,其实就是前面提到的“当代码到了第 6 行的时候,i 一定是素数”。这是你理解算法代码的第一步,所以我也不打算给你灌输太多内容,就这一点就够了,在后续的学习中,你会看到这一点所能扩展出来的其他代码形式。

一起动手,搞事情

思考题:因子分解程序正确性证明

今天的思考题呢,和整数的素因子分解有关。所谓的素因子分解,就是把一个整数,表示成为若干个素数相乘的形式,并且我们可以轻松的证明,这种只由素数表示的分解表示法,对于某个特定整数 N 来说,一定是唯一的。例如,67689 这个数字就可以分解为:3 * 3 * 3 * 23 * 109 = 67689,其中3、23、109 都是素数。

下面呢,我给你准备了一段素因子分解的程序:

#include <stdio.h>

// 打印一个素因子,并且在中间输出 * 乘号
void print_num(int num, int *flag) {
    if (*flag == 1) printf(" * ");
    printf("%d", num);
    *flag = 1;
    return ;
}

int main() {
    int n, i = 2, flag = 0, raw_n;
    scanf("%d", &n);
    raw_n = n;
    // 循环终止条件,循环到 n 的平方根结束
    while (i * i <= n) {
        //①:只要 n 可以被 i 整除,就认为 i 是 n 的一个素因子
        while (n % i == 0) {
            print_num(i, &flag);
            n /= i;
        }
        i += 1;
    }
    //②:如果最后 n 不等于 1,就说明 n 是最后一个素数
    if (n != 1) print_num(n, &flag);
    printf(" = %d\n", raw_n);
    return 0;
}

今天的任务呢,就是请你解释 ① 处和 ② 处所写注释的正确性,也就是证明:

  1. 第 18 行代码中,只要 n 可以被 i 整除,i 就一定是素数,为什么?
  2. 第 25 行代码中,为什么只要 n 不等于1,n 就一定是素数呢?

由于程序中用了循环,那么循环程序正确性的证明,你还记得吧?需要用到“数学归纳法”。而今天这两个程序过程中具体的证明,我可以给你一个小提示,尝试用“反证法”证明一下。

计算素数和

准备完了前面这些基础知识以后,最后让我们回到今天的任务:求出 1 万以内所有素数的和。如果你掌握了素数打表相关的算法以后,就很容易整理出解题思路,那就是利用素数打表算法标记掉 1 万以内所有的合数,然后将剩余的所有未被标记的数字相加,即可得到我们想要的结果。代码也不难,如下所示:

#include <stdio.h>
#define MAX_N 10000
int prime[MAX_N + 5];

// 初始化素数表
void init_prime() {
     prime[0] = prime[1] = 1;
     for (int i = 2; i * i <= MAX_N; i++) {
         if (prime[i]) continue;
         for (int j = 2 * i; j <= MAX_N; j += i) {
             prime[j] = 1; // 将 j 标记为合数
         }
     }
     return ;
}

int main() {
    init_prime();
    int sum = 0;
    for (int i = 2; i <= MAX_N; i++) {
        sum += i * (1 - prime[i]); // 素数累加
    }
    printf("%d\n", sum);
    return 0;
}

如上这段程序中,首先调用 init_prime 过程初始化 prime 数组。正如你看到的,init_prime 中,用到的是素数筛法,你可以自行改写成欧拉筛法,关于欧拉筛法,你可以自行查阅相关资料,如果经过你修改的程序,输出结果没有变,说明你的实现是没有问题的。

然后在主程序中,依次将每个素数累加到 sum 变量中,这里用到了一个我们之前讲过的技巧,就是用 1 - prime[i] 计算的结果,充当条件选择器:结果为 1 的时候,说明 i 为素数,就会往 sum 中累加一个 i * 1 ,也就是 i;如果结果为 0,说明 i 不是素数,就会往 sum 中累加一个 i * 0,也就是 0。最后,就是把所有素数全部累加到了 sum 变量中。

其实这段代码中,我最想讲的,是那个 MAX_N 宏的定义与使用。你会发现,程序中有三处用到了 MAX_N 宏,试想一下,如果我们现在想要修改程序的求解范围,修改成求解 100 万以内的所有素数累加之和,如果没有 MAX_N 宏的话,程序中我们最少要修改三个地方。

为什么说是最少修改三个地方呢?因为100万以内素数的和,很有可能超过 int 的表示范围,所以可能连 sum 的类型也要改掉。而使用了 MAX_N 宏这个技巧以后呢,我们只需要修改代码的一个地方,就可以确保,程序中所有和范围相关的地方,都被修改掉了。

课程小结

最后我们来做一下今天的课程总结,我希望你记住如下三点:

  1. 想把具体“算法”升华成“算法思维”,首先要习惯性地总结算法的“框架思维”。
  2. 素数筛是用素数去标记掉这个素数所有的倍数。
  3. 清楚地知道素数筛在执行过程中,每一行的性质。

这里,我希望你一定要熟记素数筛的算法框架,下一节我们将使用素数筛这个框架,解决几个其他问题,让你好好体会一下算法代码的“框架思维”。

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

精选留言

  • 🤪HappyJoo

    2020-02-16 10:59:15

    老师请问一下,您觉得有必要把这几个在文中出现的代码“背”下来吗?因为在看代码的时候总觉得好像会写,但是实际上去写不看您的代码的时候,就写不出来了。身为一个合格的程序员,应该要有能力,可以脱离您的教程,独自根据题目要求,写出代码吧?
    作者回复

    你可以把代码的学习看成两部分,第一部分是熟练程度,第二部分是逻辑性,其中想要提升熟练程度,只有多敲多打,我现在还没有找到太好的办法,让人不写一行代码,就学会编程。-_-|||

    2020-02-16 13:45:01

  • 🤪HappyJoo

    2020-03-06 08:03:15

    1,for (int i = 2; i <= MAX_N; i++) { sum += i * (1 - prime[i]); }
    2,for (int i = 2; i <= MAX_N; i++) { if (!prime[n]) sum += i; }

    老师你好,突然想问一下,这里哪个效率更高呢?我猜是第二个嘿嘿嘿~
    作者回复

    这个要是单纯来说的话,我认为,应该是第二个效率高一些。因为第二个使用了 if 分之条件,就会触发 CPU 的分之预测动作,只要有预测成功的情况,对于执行效率来说的话就是正向收益。

    2020-03-06 14:46:30

  • 信念

    2020-06-01 12:04:57

    i * i <= MAX_N
    这个判断条件真是学到了
    作者回复

    d(^_^o)

    2020-06-15 15:46:25

  • 罗耀龙@坐忘

    2020-06-28 15:26:31

    茶艺师学编程

    1、思考题

    (1)反证一下,如果n可以被整除,但i就是合数--这样的情况完全有可能。然而合数是素数的倍数,就是说被选到的这个i是对应的素因子后面,问题是程序是从前往后一个一个地检验过来的,那么这个i怎么就直接跳到后面呢?所以这里就矛盾了。

    因此按照程序规定的从前往后,只要n可以被i整除,i就一定是素数。

    (2)我做不出来……

    2、课文提到的欧拉筛替换挑战

    我也是参考别人的……

    // 欧拉筛素数(带视窗的)
    void Euler() {
    for (int i = 2; i <= MAX_N; i++) {
    if(p[i] == 0)
    p[PrimeNum++] = i;//这里把p数组按顺序(2-10000)“刷”上数字
    for (int j = 2; j <= MAX_N; j++) {
    if(i * p[j] > MAX_N)// 在用i刷数字的时候,用数组p来框定范围:i*p[j]<10000
    break;
    p[i * p[j]] = 1;//在上用 i * p[j]来圈定就算范围,在这里当做P数组的指针来标记合数(指针为合数的话对应的数组值为1)
    if(i % p[j] == 0)//如果i能被p[j]整除(倍数),那这个数就跳过
    break;
    }
    }
    return ;
    }
    int main() {
    Euler();
    int sum = 0;
    for (int i = 0; i <= PrimeNum; i++) {
    printf("%d\n", p[i]);
    sum += p[i];
    }
    printf("\n一万以内的素数和:%d\n", sum);
    return 0;
    }

    这里有个疑问,我这里看到的欧拉筛出来的就是素数,那么合数标记p[i * p[j]] = 1怎么就不见呢?是因为下面的break直接让“1”消失了吗?
    作者回复

    能有这样的思考很不错。d(^_^o)

    关于你的疑问,要理解一点,合数 N = p * M,其中 p 是 N 中最小的素数,也就是程序中的 p[j],M 就是程序中的 i。如果不 break,继续向后枚举 N=i * p[j] 的话, p[j]就不是 N 中最小的素因子了。

    2020-07-11 00:55:00

  • 宋不肥

    2020-02-20 15:09:22

    欧拉法:
    #include <stdio.h>
    #define MAX_N 10000
    int PrimeNum=2;
    int p[MAX_N + 1] = {0};

    // 欧拉筛素数
    void Euler() {
    for (int i = 2; i <= MAX_N; i++) {
    if(p[i] == 0)
    p[PrimeNum++] = i;
    for (int j = 2; j <= MAX_N; j++) {
    if(i * p[j] > MAX_N)
    break;
    p[i * p[j]] = 1;
    if(i % p[j] == 0)
    break;
    }
    }
    return ;
    }

    int main() {
    Euler();
    int sum = 0;
    for (int i = 0; i <= PrimeNum; i++) {
    sum += p[i];
    }
    printf("%d\n", sum);
    return 0;
    }
    作者回复

    可以的!d(^_^o),再试着把因子个数公式加到这个框架中。就更好了!

    2020-02-20 17:31:11

  • 宋jia wen

    2020-02-18 11:41:56

    老师 void print_num(int num , int *flag) 为什么加“*”,直接写flag 不行吗
    作者回复

    不行,输出的过程中需要有一个类似全局变量的东西记录是否输出过,这个值需要记录在flag变量中,如果没有*,就起不到记录的效果。

    2020-02-18 17:42:13

  • 2020-02-15 17:59:32

    在声明素数筛 prime 数组的大小时为什么要加上5呢?这个5是怎么得到的?
    作者回复

    哦哦哦,只是空间稍微开大一点点。个人编码习惯。

    2020-02-16 09:21:06

  • 乘坐Tornado的线程魔法师

    2020-02-15 00:59:56

    内容干货慢慢,理解思想最重要。
    P.S. 小编,你看如果这样设定标题捏? "将素数筛选算法写成框架算法""
    作者回复

    d(^_^o)

    2020-02-15 13:38:41

  • Outsider

    2021-06-18 09:10:27

    老师你好,素数筛代码中:
    // 用 j 枚举所有素数 i 的倍数
    for (int j = 2 * i; j <= 10000; j += i) { prime[j] = 1; // 将 j 标记为合数 }

    内循环中的j初始值设置为 j = i * i会更好吧,2i, 3i,...等值,已经被筛过了
  • Noya

    2020-05-20 01:17:51

    #include <iostream>

    using namespace std;

    const int N = 1e6 + 10;

    int n;
    int st[N];

    void init_prime(int n)
    {
    for (int i = 2; i <= n; i++)
    {
    if (st[i])
    continue;
    for (int j = i * 2; j <= n; j += i)
    {
    if (!st[j])
    st[j] = 1;
    }
    }
    return;
    }

    int main(void)
    {
    scanf("%d", &n);
    init_prime(n);
    for (int i = 2; i <= n; i++)
    {
    if (!st[i])
    {
    if (i > 2)
    printf(" ");
    printf("%d", i);
    }
    }
    return 0;
    }
    作者回复

    d(^_^o)

    2020-06-15 17:46:00

  • 小风

    2020-05-07 15:54:06

    老师,那个flag初始为0,中间也没看到有变化啊,为什么判定条件*flag==1成立呢?
    作者回复

    注意观察代码的第7行,flag 的值是有变化的。

    2020-05-07 20:58:54

  • 1043

    2020-04-10 23:32:00

    找一个起始数开始标记倍数,只要是这个倍数的取值范围内查找数那就都是合数,就全部标记,再找没有标记的数,开始标记倍数……把这个所有范围内的合数取反就是这个取值范围的质数了。
    把取值范围内所有数设置一个让自己分解质因素,能行吗?就是只要是1和本身再没有其他数组成的数的倍数不就是质数了吗?也就是让这个数除了1和本身挨个数去除,只要有整数结果就是合数。这样计算量就大了,是吗?
    作者回复

    对,这么做的话,计算量会大大增加。

    2020-04-11 08:48:02

  • 宋不肥

    2020-02-20 02:21:49

    看了其他同学的留言,在想48为什么会有影响,才发现之所以不用宏变量来定义n的值,是因为每次得到一个因子在外层while循环中就可以缩小n的值了,从而就可以缩小范围,减小计算量了。自己之前都没发现。。。
    作者回复

    d(^_^o)

    2020-02-20 17:31:52

  • Jinlee

    2020-02-17 15:57:48

    ②:如果最后 n 不等于 1,就说明 n 是最后一个素数 一直没想明白这句话的含义;然后把后面的一句代码 if (n != 1) print_num(n, &flag) 注释掉了,但是运行结果并没有影响,老师能解释下吗
    作者回复

    你输入48,你就会看到影响了。

    2020-02-18 00:04:40

  • StevenZeng学堂

    2020-02-16 20:21:47

    尝试用欧拉筛计算素数和
    #include <stdio.h>
    #define MAX_N 100
    int prime[MAX_N + 5], priNum=0;
    int p[MAX_N + 5] = {0};

    // 初始化素数表
    void init_prime() {
    for (int i = 2; i <= MAX_N; i++) {
    if(p[i] == 0)
    prime[priNum++] = i;
    for (int j = 0; j <= MAX_N; j++) {
    if(i * prime[j] > MAX_N)
    break;
    p[i * prime[j]] = 1;
    if(i % prime[j] == 0)
    break;
    }
    }
    return ;
    }

    int main() {
    init_prime();
    int sum = 0;
    for (int i = 0; i <= priNum; i++) {
    sum += prime[i]; // 素数累加
    }
    printf("%d\n", sum);
    return 0;
    }
    作者回复

    可以的,非常漂亮。再想一想,p和prime数组能不能合并成一个数组。

    2020-02-17 09:49:49

  • 学写代码的猪

    2020-02-15 23:28:48

    while (i * i <= n) {
    //①:只要 n 可以被 i 整除,就认为 i 是 n 的一个素因子
    if (n % i == 0) {
    print_num(i, &flag);
    n /= i;
    }else{
    i += 1;
    }
    }
    里面那个 while 这样改成 if 可以?
    作者回复

    对的,从功能上来讲,是一样的。

    2020-02-16 09:20:37

  • 2020-02-15 17:42:53

    有关素数筛算法有个疑问,如果想标记某一区间段的所有素数(不是从2开始的)是不是就不成立了?
    素数筛算法必须从2开始?
    作者回复

    对的,那就不成立了。那就需要换算法了。

    2020-02-16 09:21:35

  • 2020-02-15 10:20:26

    合数是由若干个素数相乘得到的数字,也就是说一个合数,一定能被某个素数整除

    这个虽然是公认的,但是还未完全证明出来啊,哈哈😄
    作者回复

    详见:算数基本定理。

    2020-02-15 13:42:27