1 1 1 质数
1.1 1.1 1.1 定义
对于所有严格大于 1 1 1 的整数,如果它只包含 1 1 1 和其本身两个因数(约数),则称其为质数(素数),反之则为合数。
1.2 1.2 1.2 判断
从定义出发。一个数 n n n 为质数当且仅当它只有 1 1 1 和其本身两个因数。所以我们不妨枚举 [ 2 , n − 1 ] [2,n-1] [2,n−1] 中的所有整数,判断其是否可以整除我们要判断的数,如果在循环中发现了可以整除 n n n 的数,则 n n n 就不可能为质数。如果循环完毕后没有出现任何一个可以整除 n n n 的数,那么 n n n 就是质数:
bool is_prime(int n) {
if (n < 2) return false;
for (int i = 2; i < n; i++) {
if (n % i == 0) return false;
}
return true;
}
上述代码的时间复杂度很明显是
O
(
n
)
\mathcal{O} (n)
O(n),在对于比较大的数时无法快速判断。考虑对其进行优化:我们知道,因数是成对出现的,即对于
n
n
n 的一个因数
i
i
i,$\frac{n}{i} $ 也必为其一个因数,换作数学语言为:
i
∣
n
↔
n
i
∣
n
i\mid n \leftrightarrow \frac{n}{i} \mid n
i∣n↔in∣n
据此,我们就可以缩小我们的枚举范围,只需要对
[
2
,
n
]
[2,\sqrt{n}\ ]
[2,n ] 中的所有整数枚举即可。优化后的时间复杂度为
O
(
n
)
\mathcal{O} (\sqrt n\ )
O(n ),明显优于优化前的时间复杂度:
bool is_prime(int n) {
if (n < 2) return false;
for (int i = 2; i <= n / i; i++) {
if (n % i == 0) return false;
}
return true;
}
笔者在循环中使用的判断条件是 i <= n / i
,这种写法不太常见,却更为保险。和其他两种常见的写法相比,有两点优势:
- 和
i * i <= n
相比,没有了溢出的风险,使得代码运行更可靠; - 和
i <= sqrt(n)
相比,能够加快程序的运行效率,不需要每次都计算一遍sqrt(n)
。
1.3 1.3 1.3 分解质因数
分解质因数,就是对一个给定的合数 n n n,将其分解为若干个质数乘积的形式,如 4 = 2 × 2 , 35 = 5 × 7 4=2 \times 2,35=5 \times 7 4=2×2,35=5×7 等。
同上一小节一样,分解质因数同样使用的是试除法。当我们发现一个质数 i i i 可以整除要分解的合数 n n n 时,就说明 i i i 是 n n n 的质因子。我们只需要不断执行 n ← n i n \gets \frac{n}{i} n←in,同时记录操作的次数 c n t cnt cnt,直到 i i i 不能整除 n n n 为止。此时 c n t cnt cnt 即为 n n n 中质因子 i i i 出现的次数:
void Divide(int n) {
for (int i = 2; i <= n; x++) {
if (n % i == 0) {
int cnt = 0;
while (n % i == 0) cnt++, n /= i;
printf("%d %d\n", i, cnt);
}
}
}
上述代码的时间复杂度为
O
(
n
)
\mathcal{O}(n)
O(n),可以进一步优化至
O
(
n
)
\mathcal{O}(\sqrt{n}\ )
O(n )。在优化前,需要先了解一个定理:
一个的合数
n
最多只存在一个大于
n
的质因子
一个的合数\ n\ 最多只存在一个大于\ \sqrt{n}\ 的质因子
一个的合数 n 最多只存在一个大于 n 的质因子
考虑使用反证法对其进行证明:假设
n
n
n 有两个大于
n
\sqrt{n}
n 的质因子,分别记为
x
,
y
x,y
x,y,则有
x
×
y
>
n
x \times y > n
x×y>n,显然假设不成立,可以得出上面的结论。
基于此,我们就可以将枚举的范围缩小至 [ 2 , n ] [2,\sqrt{n}\ ] [2,n ],如果最后枚举完毕后 n n n 仍然大于 1 1 1,那么此时的 n n n 就是最开始大于 n \sqrt{n} n 的那个质因子了,只需要将其单独输出即可,时间复杂度降为 O ( n ) \mathcal{O}(\sqrt{n}\ ) O(n ):
void Divide(int n) {
for (int i = 2; i <= n / i; i++) {
if (n % i == 0) {
int cnt = 0;
while (n % i == 0) cnt++, n /= i;
printf("%d %d\n", i, cnt);
}
}
if (n > 1) printf("%d %d\n", n, 1);
}
1.4 1.4 1.4 筛素法
光知道如何判断质数肯定不够,在题目中对于质数的考察很大一部分都集中在质数的筛法上。筛法分为很多种,我们先从朴素的开始。
朴素版筛法主要的思想是用质数的倍数去标记合数。假设题目要求筛出 [ 1 , n ] [1,n] [1,n] 中的所有质数,由于所有大于 1 1 1 的数都是 1 1 1 的倍数,所以我们不妨循环枚举 [ 2 , n ] [2,n] [2,n] 中的每个数。如果当前数 i i i 还没有被标记,就说明 i i i 为质数。将 i i i 加入表示质数的数组中后,循环执行 v i s i × k ← True ( k > 1 且 i × k ≤ n ) vis_{i \times k} \gets \text{True}\ (k > 1 \ 且\ i \times k \le n) visi×k←True (k>1 且 i×k≤n) 即可:
const int N = 110;
int prime[N], cnt;
bool vis[N];
void get_prime(int n) {
for (int i = 2; i <= n; x++) {
if (!vis[i]) cnt++, prime[cnt] = i;
for (int k = 2; k * i <= n; k++) vis[i * k] = true;
}
}
分析一下上面代码的时间复杂度。标记
2
2
2 的倍数时循环了
n
2
\frac{n}{2}
2n 次,标记
3
3
3 的倍数时循环了
n
3
\frac{n}{3}
3n 次,以此类推,标记完成时共计循环了 $ {\textstyle \sum_{i = 2}^{n} \frac{n}{i} } $ 次。根据调和级数知识:
lim
n
→
∞
(
1
+
1
2
+
⋯
+
1
n
)
=
ln
n
+
c
\lim_{n \to \infty} (1+\frac{1}{2} +\cdots +\frac{1}{n} )= \ln n+c
n→∞lim(1+21+⋯+n1)=lnn+c
我们不妨将循环总次数看为
n
ln
n
n \ln n
nlnn。由于
n
ln
n
<
n
log
2
n
n \ln n < n \log_2n
nlnn<nlog2n,我们就可以将上述算法的时间复杂度粗略地记为
O
(
n
log
2
n
)
\mathcal{O}(n \log_2n)
O(nlog2n)。
考虑优化一下上面的算法。当一个数已经被标记时,它的所有倍数肯定也已经被标记了,这是由我们枚举的顺序所决定的。由此,我们就可以将循环标记倍数的代码放进判断中即可,这也就是我们所说的埃氏筛:
const int N = 110;
int prime[N], cnt;
bool vis[N];
void get_prime(int n) {
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
cnt++, prime[cnt] = i;
for (int k = 2; k * i <= n; k++) vis[i * k] = true;
}
}
}
埃氏筛的时间复杂度可以记为 O ( n log log n ) \mathcal{O}(n \log \log n) O(nloglogn),比朴素版筛法快了不少,但还不是最优秀的。寻找一下埃氏筛的效率瓶颈,不难发现,每一个合数都被它的所有质因子标记了一遍,这就造成了时间的浪费。不妨考虑一种新的筛法,只用一个合数的最小质因子标记这个合数,这样子的话就可以很好地控制循环的次数,提高代码运行的效率:
const int N = 110;
int prime[N], cnt;
bool vis[N];
void get_prime(int n) {
for (int i = 2; i <= n; i++) {
if (!vis[i]) cnt++, prime[cnt] = i;
for (int j = 0; prime[j] <= n / i; j++) {
vis[prime[j] * i] = true;
if (i % prime[j] == 0) break;
}
}
}
这个筛法每个数都只被标记了一次,所以它是线性的,具体证明比较复杂,在此不过多阐述,感兴趣的读者可以自行查阅相关资料。我们将这种筛法称之为线性筛。线性筛的效率较高,在 OI 中使用广泛。但必须注意的是,对于一些常数比较大的题目,我们可以使用打表的方式巧妙地避开在程序中筛素数的过程,让程序效率更进一步。此时三种筛法就没有太大的优劣之分了,但这种思想却可以转移到其他的题目中。所以说,只有基础牢固,才能有所成就。
2 2 2 约数
2.1 2.1 2.1 分解约数
求一个合数 n n n 的约数的思想仍然是试除法。枚举所有的 x ∈ [ 1 , n ] x \in[1,n] x∈[1,n],如果 x x x 可以整除 n n n,则 x x x 为 n n n 的一个约数。在这里同样可以使用在判断质数时所加的优化,只枚举 [ 1 , n ] [1,\sqrt{n}\ ] [1,n ] 中的数即可,但要注意边界情况:
vector<int> get_divisor(int n) {
vector<int> ans;
for (int i = 1; i <= n / i; i++) {
if (n % i == 0) {
ans.push_back(i);
if (i != n / i) ans.push_back(n / i);
}
}
sort(ans.begin(), ans.end());
return ans;
}
2.2 2.2 2.2 约数个数
谈及一个数的约数个数问题,这其实考察的并不是枚举约数,而是一个特殊的数学式子。算数基本定理告诉我们,任何一个大于
1
1
1 的自然数
n
n
n,如果
n
n
n 不为质数,那么
n
n
n 可以唯一分解成有限个质数的乘积,即:
n
=
P
1
α
1
×
P
2
α
2
×
⋯
P
k
α
k
(
P
1
<
P
2
<
⋯
<
P
k
P
1
,
P
2
,
⋯
P
k
为质数
)
n = P_{1}^{\alpha _1} \times P_{2}^{\alpha _2} \times \cdots P_{k}^{\alpha _k}\ (P_1 < P_2 < \cdots <P_k P_1,P_2,\cdots P_k为质数)
n=P1α1×P2α2×⋯Pkαk (P1<P2<⋯<PkP1,P2,⋯Pk为质数)
根据这个定理,我们就可以将一个合数的任意一个约数
d
d
d 表示为:
d
=
P
1
β
1
×
P
2
β
2
×
⋯
P
k
β
k
(
P
1
<
P
2
<
⋯
<
P
k
P
1
,
P
2
,
⋯
P
k
为质数
)
d = P_{1}^{\beta _1} \times P_{2}^{\beta _2} \times \cdots P_{k}^{\beta _k}\ (P_1 < P_2 < \cdots <P_k P_1,P_2,\cdots P_k为质数)
d=P1β1×P2β2×⋯Pkβk (P1<P2<⋯<PkP1,P2,⋯Pk为质数)
观察每个
β
\beta
β 的取值范围,发现对于每个
β
k
\beta_k
βk,
β
k
\beta_k
βk 可以取到
[
0
,
α
k
]
[0,\alpha_k]
[0,αk] 范围内的所有整数,共计有
α
1
+
1
\alpha_1+1
α1+1 种取法,根据组合数学,分布操作相乘,可得出总数
N
N
N 为:
N
=
(
α
1
+
1
)
(
α
2
+
2
)
⋯
(
α
k
+
1
)
N = (\alpha_1+1)(\alpha_2+2)\cdots(\alpha_k+1)
N=(α1+1)(α2+2)⋯(αk+1)
由于这个方法的主要代码已经在之前写过了,这里不过多阐述,请读者自行实现。
2.3 2.3 2.3 约数之和
同样基于算数基本定理,我们可以推导出一个合数的约数之和
s
u
m
sum
sum 为:
s
u
m
=
(
P
1
0
+
P
1
1
+
⋯
+
P
1
α
1
)
×
⋯
×
(
P
k
0
+
P
k
1
+
⋯
+
P
k
α
k
)
sum = (P_{1}^{0} + P_{1}^{1} + \cdots +P_{1}^{\alpha_1})\times \cdots \times (P_{k}^{0} + P_{k}^{1} + \cdots +P_{k}^{\alpha_k})
sum=(P10+P11+⋯+P1α1)×⋯×(Pk0+Pk1+⋯+Pkαk)
上述公式的证明只需要在约数个数的公式上再加以组合数学的相关推导即可,在此不多阐述,感兴趣的读者可以自行查阅相关资料。
2.4 2.4 2.4 欧几里得算法(辗转相除法)
欧几里得算法基于算数中的一个基本性质:
d
∣
a
,
d
∣
b
⇒
d
∣
a
x
+
b
y
d \mid a,d \mid b \Rightarrow d \mid ax+by
d∣a,d∣b⇒d∣ax+by
据此可以推出:
gcd
(
a
,
b
)
=
gcd
(
b
,
a
m
o
d
b
)
\gcd(a,b)=\gcd(b,a \bmod b)
gcd(a,b)=gcd(b,amodb)
下面对上式进行证明。我们知道,取余运算可以写成下面的形式:
a
m
o
d
b
=
a
−
⌊
a
b
⌋
×
b
a \bmod b = a - \left \lfloor \frac{a}{b} \right \rfloor \times b
amodb=a−⌊ba⌋×b
令
⌊
a
b
⌋
=
c
\left \lfloor \frac{a}{b} \right \rfloor=c
⌊ba⌋=c,则:
gcd
(
a
,
b
)
=
gcd
(
b
,
a
−
c
×
b
)
\gcd(a,b)=\gcd(b,a-c\times b)
gcd(a,b)=gcd(b,a−c×b)
令
d
∣
a
,
d
∣
b
d \mid a,d\mid b
d∣a,d∣b,则根据基本性质可证得上式左右两边相等。该算法的代码如下,时间复杂度为
O
(
log
n
)
\mathcal{O}(\log n)
O(logn):
int gcd(int a, int b) {
return b ? gcd(b, a % b) : a;
}
当然,不能忘了我们的老朋友:STL 算法库。在 STL 中,也有求最大公因数的函数 __gcd
,当我们忘记代码的时候可以帮助我们快速求出最大公因数。所以说,STL 大法好!