- 分享
数学讲义
- @ 2024-8-15 18:31:43
质数与约数
质数
定义
质数与合数的定义:质数与合数是针对所有大于 的自然数来定义的,也就是说所有小于等于 的数字都不属于质数和合数的范围。 在大于 的整数中,如果只包含 和本身两个约数,就被称为质数(素数);反之为合数。
线性判断质数
很容易可以想到,只需要枚举 的所有数字,依次判断它们能否整除 ,只要有一个可以就是合数;而如果都不能整除,则是质数,由此可以得到代码:
bool is_prime(int n) {
if (n < 2) return 0;
for (int i=2; i<=n-1; i++) {
if (n%i == 0) return 0;
}
return 1;
}
容易发现,上面这个代码的时间复杂度是 级别的,那么在面对 较大或者判断次数比较多时,会有超时的风险。
判断质数
在学习这个方法前,我们需要知道一个性质:如果 可以整除 ,那么 必然也可以整除 。 所以根据这个性质,我们只需要枚举完所有较小的数字即可,可以列出如下公式:
$$i \leq n/i \rightarrow i \times i \leq n \rightarrow i \leq \sqrt n$$所以上面代码可以优化为:
bool is_prime(int n) {
if (n < 2) return 0;
for (int i=2; i<=n/i; i++) { // 写 i<=sqrt(n)也可以,但是还需要算,比较慢。
if (n%i == 0) return 0;
}
return 1;
}
这样代码的时间复杂度就变成了 。
分解质因数
分解质因数应该是很多同学都熟悉的数学题,要把一个合数分解成多个质数相乘的结果。容易想到一个解法,枚举所有可能的质数,然后让这个质数一直去除 ,直到不能整除为止。
这样做确实可以,但其实我们没有必要去枚举质数,直接枚举 然后一直除就可以了,不用管这个数字是否是质数,为什么呢?可以思考一下。
[!NOTE] 证明过程 假设当前枚举的数 是合数,那么前面必然已经枚举过 的质因子了,所以在前面已经通过不断的除使得质因子无法整除 了,既然质因子都无法整除,作为合数的 必然也不可能整除,所以合数 并不会影响程序的结果,也就不需要去判断质数合数。
那么可以得到如下代码:
void divide(int n) {
// 对数字 n 进行分解质因数
for (int i=2; i<=n-1; i++) {
if (n%i == 0) {
int cnt = 0;
while (n%i == 0) {
n /= i;
cnt++;
}
printf("%d %d\n", i, cnt);
}
}
}
同样的,这个代码也是 级别的时间复杂度,能否让算法更快一点呢?很容易能想到,大于 的质因子最多只会有 个,否则如果有两个的话,它俩乘起来就比 大了,这肯定不可能。
所以,我们枚举的范围同样可以只到 ,而如果最后计算完后 的话,那现在它里面的数字一定就是那个最后的质因子,代码成功优化为了 的时间复杂度(最好情况是 )。
void divide(int n) {
// 对数字 n 进行分解质因数
for (int i=2; i<=n/i; i++) {
if (n%i == 0) {
int cnt = 0;
while (n%i == 0) {
n /= i;
cnt++;
}
printf("%d %d\n", i, cnt);
}
}
if (n > 1) printf("%d %d\n", n, 1);
}
质数筛
在需要多次判断质数的场景,比如统计某个区间内的所有质数时, 的速度还是太慢了,能否更快呢?这时候就要用到质数筛法了,常见的筛法有 朴素筛、埃氏筛和欧拉筛。
朴素筛
筛法的思想很简单,数字的倍数一定不是质数。那么在选到一个数字时,把这个数字的所有倍数全部筛掉,这样过一轮之后,所有的质数也就被筛选出来了。
如果要用STL实现,就把 primes 数组替换为 vector<int>
const int N = 1e6+5;
int primes[N], cnt; // primes用来存储质数,cnt是对应数组的下标
bool st[N]; // st[i]标记数字是否被筛掉
void get_primes(int n) {
// 筛选 2~n中的所有质数
for (int i=2; i<=n; i++) {
if (!st[i]) {
primes[++cnt] = i; // 质数存到数组中
}
for (int j=i+i; j<=n; j+=i) st[j] = 1; // 筛掉数字的倍数
}
// 这样过完之后,所有的质数就存到了primes数组中
// 同时 st 中标记了每个数字是否是质数。
}
这段代码的时间复杂度约为 ,略快于 ,空间复杂度为 。
埃氏筛
可以发现,其实没必要把所有的数字的倍数都遍历一遍,因为如果数字是一个合数,它的倍数肯定也被之前的质数筛掉了,所以只需要遍历质数的倍数;这也是筛法的核心思想:质数的倍数一定不是质数。
const int N = 1e6+5;
int primes[N], cnt; // primes用来存储质数,cnt是对应数组的下标
bool st[N]; // st[i]标记数字是否被筛掉
void get_primes(int n) {
// 筛选 2~n中的所有质数
for (int i=2; i<=n; i++) {
if (!st[i]) {
primes[++cnt] = i; // 质数存到数组中
for (int j=i+i; j<=n; j+=i) st[j] = 1; // 筛掉质数的倍数
}
}
// 这样过完之后,所有的质数就存到了primes数组中
// 同时 st 中标记了每个数字是否是质数。
}
这段代码的时间复杂度约为 ,空间复杂度为 。
欧拉筛(线性筛)
在埃氏筛中,为什么代码会耗时呢,是因为在遍历质数的倍数时,会重复标记很多数字,例如数字 ,既是 的倍数,也是 的倍数,那不管我们是扫描 还是扫描 ,一定都会把 去重复筛掉。 有什么办法可以让每个数字都只被筛一次呢?先来看下面这段代码:
void get_primes(int n) {
// 筛选 2~n中的所有质数
for (int i=2; i<=n; i++) {
if (!st[i]) primes[++cnt] = i; // 质数存到数组中
// primes[j] <= n/i 理解为 primes[j]*i <= n,即只筛n范围内的数字
for (int j=1; primes[j]<=n/i; j++) {
st[primes[j]*i] = 1; // 筛掉i的素数倍数
if (i%primes[j] == 0) break; // 线性复杂度的关键
}
}
}
这段代码乍一看很莫名其妙,感觉既不能筛掉所有的合数,也没法保证 的时间复杂度,但是数学就是如此的神奇,让我们往下看。
这个算法称为欧拉筛法,其核心思想是每个合数都可以分解为一个最小质因数乘以另一个数的形式,即 合数 = 最小质因数 * 自然数,在上述代码中,st[primes[j]*i] = 1 做的工作就是把以 primes[j] 为最小质因数的数字给筛掉,来看一段表格:
| i | primes | 筛掉的数字 |
|---|---|---|
| 2 | 4 | |
| 3 | 2,3 | 6,9 |
| 4 | 8 | |
| 5 | 2,3,5 | 10,15,25 |
| 6 | 12 | |
| 7 | 2,3,5,7 | 14,21,35,49 |
| 8 | 16 | |
| 9 | 18,27 | |
| 10 | 20 | |
| 首先可以发现,每个数字都是被自己的最小质因子筛掉的,比如 是被 筛掉的, 是被 筛掉的,如何证明这一点呢? | ||
[!INFO] 被最小质因子筛 证明 这还要基于
if (i%primes[j]==0) break;这段代码来证明,根据这个条件,我们代码中会有两种情况:情况一:
i % primes[j] == 0,那么primes[j]一定是i的最小质因子,否则肯定已经break了;因此primes[j]*i的最小质因子也必然是primes[j]。情况二:
i % primes[j] != 0,那么primes[j]一定小于i的最小质因子,否则在前面肯定已经break了;因此primes[j]*i的最小质因子必然是primes[j]。
因为每个数字都会被最小质因子筛掉,所以每个数字实际都只会被标记依次,那么时间复杂度就接近 了。 可能同学们还会有一个疑问,就是这样写代码不会漏掉一些数字么?当然不会,请看下面:
[!INFO] 欧拉筛正确性证明
假设现在要筛掉 合数 ,并且 的最小质因数为 ,令 ;在遇到 的时候, 必然已经在 primes 数组中了,而 这个数字显然一定在 这个范围内,所以在遇到 之前, 这个数字肯定已经被筛掉了。
以上便是关于欧拉筛的模板与证明,欧拉筛的时间复杂度为 ,当 达到 时,埃氏筛与欧拉筛的性能差一倍左右。在一般情况下,进行质数筛时我们都可以选用欧拉筛法,而埃氏筛的思想也是很重要的。
约数
定义
约数的概念很简单,如果 能够整除 ,那么 是 的约数, 是 的倍数。
线性枚举约数
容易想到,枚举 的数字,然后依次判断并加到数组中即可,代码如下所示:
const int N = 1e5+5;
int res[N], cnt;
void get_divisors(int n) {
for (int i=1; i<=n; i++) {
if (n%i == 0) res[++cnt] = i;
}
}
显然,这样做的时间复杂度是 级别的,能否更快呢?
枚举约数
还是质数时讲过的性质:如果 可以整除 ,那么 必然也可以整除 。所以我们枚举的时候,可以只枚举到 ,然后当 可以整除 时,把 和 都加入到数组中即可,这样代码的时间复杂度就到了
const int N = 1e5+5;
int res[N], cnt;
void get_divisors(int n) {
for (int i=1; i<=n/i; i++) {
if (n%i == 0) {
res[++cnt] = i;
if (i != n/i) res[++cnt] = n/i; // 这个判断是防止加两次 n/i 进去
}
}
sort(res+1, res+1+cnt); // 如果不要求排序,也可以不用。
// 另外,也可以用两个数组,一个存前半部分一个存后半部分,最后合并,比排序更快。
}
例题-约数个数
一个数的约数个数可以通过其质因数分解来确定。具体来说,如果一个数 可以被分解为质因数的乘积,即 $n = p_1^{x_1} \times p_2^{x_2} \times \cdots \times p_k^{x_k}$,那么 的约数个数 可以通过以下公式计算:
$$r = (x_1 + 1) \times (x_2 + 1) \times \cdots \times (x_k + 1)$$例如,数字 可以被分解为 ),因此它的约数个数为:
$$(4 + 1) \times (3 + 1) \times (3 + 1) \times (1 + 1) = 160$$[!NOTE] 定理简证 容易发现, 的约数有:,共 个;同理可得 的约数有 个;一直到 的约数有 个,根据乘法原理可知, 的约数个数就是 。
来看例题:
题目描述
给定 个正整数 ,请你输出这些数的乘积的约数个数,答案对 取模。
输入格式
第一行包含整数 。 接下来 行,每行包含一个整数 。
输出格式
输出一个整数,表示所给正整数的乘积的约数个数,答案需对 取模。
样例输入
3
2
6
8
样例输出
12
题目分析
根据上述公式可得,我们只需要对每个 进行质因数分解,统计所有质因子的指数,然后求和即可,因为数字范围比较大,所以可以使用 unordered_map 来记录每个质因子的出现次数。
示例代码
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9+7;
unordered_map<int, int> mp;
void get_cnt(int n) {
for (int i=2; i<=n/i; i++) { // 分解质因数
while (n%i == 0) {
n /= i;
mp[i]++; // 统计这个质因数的指数
}
}
if (n > 1) mp[n]++; // 单独处理比 n/i 大的质因子
}
int main() {
int n, x;
long long res = 1;
cin >> n;
for (int i=1; i<=n; i++) {
cin >> x;
get_cnt(x);
}
for (auto i:mp) res = res*(i.second+1) % MOD; // 计算结果
cout << res;
return 0;
}
约数之和
题目描述
给定 个正整数 ,请你输出这些数的乘积的约数之和,答案对 取模。
输入格式
第一行包含整数 。
接下来 行,每行包含一个整数 。
输出格式
输出一个整数,表示所给正整数的乘积的约数之和,答案需对 取模。
样例输入
3
2
6
8
样例输出
252
题目分析
还是基于分解质因数的公式来计算。如果 $n = p_1^{x_1} \times p_2^{x_2} \times \cdots \times p_k^{x_k}$,那么来显然 的约数有 ,同理可得, 的约数有 。
我们来用实际的数字进行推导, 分解质因数后是 ,它的约数有 , 那显然可得它的约数之和是 。 那 分解质因数后是 ,它的约数除了上述 的约数之外,还会有 $2^0 \times 3^1, 2^1 \times 3^1, 2^2 \times 3^1, 2^3 \times 3^1, 2^0 \times 3^2, 2^1 \times 3^2, 2^2 \times 3^2, 2^3 \times 3^2$。
假设 ,那么可得 的因数之和为:
$$2^0+2^1+2^2+2^3+2^0 \times 3^1+2^1 \times 3^1+2^2 \times 3^1+2^3 \times 3^1+2^0 \times 3^2+2^1 \times 3^2+2^2 \times 3^2+2^3 \times 3^2$$即:
$$m + m \times 3^1 + m \times 3^2 \rightarrow m \times (3^0+3^1+3^2) \rightarrow (2^0+2^1+2^2+2^3) \times (3^0+3^1+3^2)$$聪明的你再往下推一步,如果是 分解质因数,得到的是 ,那么它的因数之和是多少呢?
揭晓答案,应该是:
$$(2^0+2^1+2^2+2^3) \times (3^0+3^1+3^2) \times (5^0+5^1)$$所以,根据数学归纳法可得,当 $n = p_1^{x_1} \times p_2^{x_2} \times \cdots \times p_k^{x_k}$ 时,其约数和为:
$$s = (p_1^0+p_1^1+ \cdots + p_1^{x_1}) \times (p_2^0+p_2^1+ \cdots + p_2^{x_2}) \times \cdots \times (p_k^0+p_k^1+ \cdots + p_k^{x_k})$$根据上面的公式,来求出这个代码吧!
[!INFO] 提示 如何计算 呢?
...
示例代码
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9+7;
unordered_map<int, int> mp;
void get_cnt(int n) {
// 此处实现同上段代码,不再演示
}
int main() {
int n, x;
long long res = 1;
cin >> n;
for (int i=1; i<=n; i++) { // 求出所有质因子的指数
cin >> x;
get_cnt(x);
}
for (auto i:mp) {
long long sum=1, a=i.first, b=i.second;
while (b--) { // 求解的关键,写法很多,也可以再来个变量,一项一项算然后求和。
sum = (sum*a+1) % MOD;
}
res = res*sum % MOD; // 一定要记得边算边模,否则数据会溢出
}
cout << res;
return 0;
}
最大公约数
定义
最大公约数(GCD),指两个或多个整数共有的约数中最大的一个,例如 和 的公约数由 ,其中最大的一个是 ,所以 是 和 的最大公约数, 和 的最大公约数记为 ,最大公约数在数论问题中是特别重要的概念。
线性找最大公约数
很容易想到,如果要求 和 的最大公约数,我们只需要把 的数字列出来,然后找到第一个能同时整除 和 的数字,就是最大公约数了,代码如下:
int gcd(int a, int b) {
// 为什么是 min(a,b) 呢?因为约数的范围不会超过这个数字。
for (int i=min(a,b); i>=1; i--) {
if (a%i==0 and b%i==0) return i;
}
}
显然,这个代码的时间复杂度是 级别的。
辗转相除法
欧几里得算法又称辗转相除法,原理为 $gcd(a,\; b) = gcd(b,\; a\%b), (a >= b,\; a\%b != 0)$。
[!INFO] 证明过程 令 ,即 ( 均为正整数)。
设 为 的任意一个公约数,而根据上式可得 ,两边同时除以 ,可得 。
因为 必然是一个整数,所以 也是整数,也就意味着 也是 的约数,又因 ,所以 和 的公约数是一样的,所以最大公约数必然也一样,得证 。
辗转相除法便是利用这个原理不断往下计算,直到 为 时,时间复杂度约为 ,尝试自己实现下这个代码吧!
示例代码
int gcd(int a, int b) {
// 可以简写为 return b ? gcd(b, a%b) : a;
if (a%b == 0) return b;
else return gcd(b, a%b);
}
快速幂
什么是幂
幂是一个数自乘若干次的形式,乘方的结果叫做幂。比如 的 次幂意为 个 相乘,写作 ,其中 称为底数, 称为指数。
[!INFO] 幂运算的性质 这些性质都是针对任意实数 以及自然数 所得的结论。
同底数幂的乘法法则
同底数幂的除法法则
幂的幂
积的幂
商的幂
线性求幂
容易想到,求 只需要循环 次,每次乘 即可,时间复杂度是 ,代码如下:
int sum=1;
for (int i=1; i<=n; i++) sum *= a;
那能否更快呢?
快速幂
来看例题:
例题——快速幂
题目描述
给定 组 ,对于每组数据,求出 的值。
输入格式
第一行包含整数 。
接下来 行,每行包含三个整数 。
输出格式
对于每组数据,输出一个结果,表示 的值。
每个结果占一行。
样例输入
2
3 2 5
4 3 9
样例输出
4
1
题目分析
首先我们需要知道幂运算的性质,,又因在位运算章节中,我们学习过数字转二进制,必然可以把数字 转为 的形式,综合起来可得:
举个例子:
这样表示有什么好处呢,请看下面的式子:
$$\begin{align} 5^{2^1} &= 5^{2^0} \times 5^{2^0} \\ 5^{2^2} &= 5^{2^1} \times 5^{2^1} \\ &\cdots \\ 5^{2^{x_i}} &= 5^{2^{x_{i-1}}} \times 5^{2^{x_{i-1}}} \end{align}$$易得:
这意味着什么呢?如果我们把 拆解成 的形式,那么我们就可以利用上面的公式,从 开始,一路往上递推,直到 ,并在这个过程中把所有要的数字都乘起来求出结果。 而把 拆成二进制的时间复杂度为 ,所以只需要这个复杂度就可以求出 。
示例代码
#include<bits/stdc++.h>
using namespace std;
long long qmi(int a, int k, int p) {
long long res = 1;
while (k) {
if (k&1) res = res*a % p; // 如果二进制上这位有数字,那么结果乘起来
k >>= 1; // 去掉最低位
a = (long long)a*a % p; // 递推求出 a^(2^x) 的值
}
return res;
}
int main() {
int a, k, p;
cin >> a >> k >> p;
cin >> a >> k >> p;
cout << qmi(a, k, p) << '\n';
return 0;
}
例题——快速幂求逆元
题目描述
给定 组 ,其中 是质数,求 模 的乘法逆元,若逆元不存在则输出 impossible。
注意:请返回在 之间的逆元。
[!INFO] 乘法逆元的定义
若整数 互质,并且对于任意的整数 ,如果满足 ,则存在一个整数 ,使得 ,则称 为 的模 乘法逆元,记为 。
存在乘法逆元的充要条件是 与模数 互质。当模数 为质数时, 即为 的乘法逆元。
输入格式
第一行包含整数 。
接下来 行,每行包含一个数组 ,数据保证 是质数。
输出格式
输出共 行,每组数据输出一个结果,每个结果占一行。
若 模 的乘法逆元存在,则输出一个整数,表示逆元,否则输出 impossible。
样例输入
3
4 3
8 5
6 3
样例输出
1
2
impossible
题目分析
在模运算中,我们都知道,加法和乘法是比较简单的:
$$\begin{aligned} (A+B)\;\%\; mod\; = \;((A\;\%\;mod)+(B\;\%\;mod))\;\%\;mod \\ (A \times B)\;\%\; mod\; = \;((A\; \% \;mod) \times (B\; \% \;mod))\;\%\;mod \end{aligned}$$减法因为可能由负数,所以需要加上之后再取模:
$$(A-B)\;\%\; mod\; = \;((A\;\%\;mod)-(B\;\%\;mod)+mod)\;\%\;mod$$而除法取模则比较特别,这里我们需要引入逆元的概念。
逆元的概念
首先我们要来了解 单位元 和 逆元 的定义。
单位元
[!NOTE] 单位元的定义 在一个集合中,对于某种运算,如果对于任何的集合元素 ,和元素 运算后还是得到集合元素 本身,则称 为这个运算下的单位元。
例如,在加法运算中,对于任意的实数 ,都有 ,所以加法运算的单位元 。 根据上面的概念,你可以说出乘法运算的单位元是什么么?
没错,就是 !
逆元
[!NOTE] 逆元的定义 在一个集合中,对于某种运算,如果任意两个元素的运算结果等于单位元,则称这两个元素互为逆元。
例如,在普通的加法运算中,任意实数 的逆元为 。
同理,在普通的乘法运算中,任意实非零实数 的逆元为 。
模乘运算下的单位元
对于结果对 取模的乘法,首先所有模 和 同余的数都可以表示成:
假设单位元为 ,我们将 和 进行模乘操作,得到:
$$\begin{align*} a(mod\;n) \times e(mod \; n) &\equiv (k_{1}\ n+a)(k_{2}\ n+e)\\ &\equiv(k_{1}\ k_{2} \ n^{2}+k_{1}\ e\ n+k_{2}\ a\ n+ae)\\ &\equiv(k_{1}\ k_{2} \ n+k_{1}\ e\ n+k_{2}\ a)\ n+ae \end{align*}$$根据单位元的定义,应该有:
将上面的式子带入,则有:
$$(k_{1}\ k_{2} \ n+k_{1}\ e\ n+k_{2}\ a)\ n + ae = k \times n+a$$所以可得:
$$\begin{cases} k_{1}\ k_{2} \ n+k_{1}\ e\ n+k_{2}\ a = k\\ \\ e = 1 \end{cases}$$所以,模乘的单位元就是 。
模乘的逆元
根据逆元的定义,若 互质,当 时, 为逆元,记作 。
例如:,解得 ,一般只考虑比 小的,所以逆元为 。
[!NOTE] 模乘逆元的定义 换个角度说,若 互质,并且 ,则存在一个整数 ,使得 ,则称 为 模 意义下的乘法逆元,记作 ,化简可以写成 。
模乘逆元的一些性质:
[!INFO] 性质
- 模 意义,逆元一般只考虑比 小的,并且在这个范围内逆元是唯一的。
- 逆元存在的条件, 互质,那么 在模 意义下才有逆元。
这个逆元有什么用呢?在取模意义下,如果数字 的逆元为 ,那么在除以 相当于 乘以 ,所以就把除法运算变成了乘法运算。
那么如何求出数字的模乘逆元呢?我们先来了解一下费马小定理:
[!INFO] 费马小定理 若 是一个质数,且整数 不是 的倍数,则 。也就是说,如果 整数 不能被质数 整除,那么 得到的余数是 。
那么根据逆元的定义和费马小定理,我们可以进行如下推导:
假设 互质, 为 的模乘逆元,有:
根据费马小定理,有:
将左边式子提取一个 出来,得:
所以可得,逆元 。
总结:
[!INFO] 费马定理求乘法逆元 若 互质,且模数 是质数时, 即为 的乘法逆元。
做法其实很简单,因为 已经限制是质数了,只需要判断一下 是否互质,是则求 ,否则就是 impossible。
示例代码
int n;
cin >> n;
while (n--) {
int a, p;
cin >> a >> p;
if (a%p == 0) cout << "impossible\n";
else cout << qmi(a, p-2, p) << '\n'; // 快速幂代码和上面一致,不再演示
}