【背景说明】本文的作者是一名算法竞赛小白,在第一次参加蓝桥杯之前希望整理一下自己会了哪些算法,于是有了本文的诞生。分享在这里也希望与众多学子共勉。如果时间允许的话,这一系列会分为上中下三部分和大家见面,祝大家竞赛顺利!
【文风说明】本文主要会用代码+注释的方式来解释内容。相信学过编程的人都会发现程序比长篇大论更易理解!
目录
三、搜索算法
搜索算法一般分为深度优先搜索(DFS)和广度优先搜索(BFS),但实际上,一般的题目都可以用深度优先搜索来解决。因此,为了学习和记忆的便利性,我们在这里只介绍深度优先搜索(DFS),又称回溯法。
上图中的内容就是DFS和BFS在同一棵树中搜索的过程,有差异,但是都和名字比较一致。深度优先,即从起点开始,沿着一条路一直走,直到没有下一个节点可走为止;接着返回到前一个结点,继续探索其它分支。常用的DFS写法使用了递归,这里给出一个模板,用来求1~n的全排列。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
int a[N];
bool vis[N];
void DFS(int dep, int n) {
// 递归出口
if (dep == n + 1) {
for (int i = 1; i <= n; ++i) cout << a[i] << " ";
cout << endl;
return;
}
for (int i = 1; i <= n; ++i) {
// 排除已经遍历过的数
if (vis[i]) continue;
// 修改状态
vis[i] = true;
a[dep] = i;
// 下一层
DFS(dep + 1, n);
// 恢复现状
vis[i] = false;
}
}
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
int n; cin >> n;
// 调用DFS算法
DFS(1, n);
return 0;
}
我们需要注意的是回溯法的最后是需要恢复状态的,这一点很容易遗忘,导致结果不正确。在上面的程序中我们的搜索是在一个数组中,当然也可以在树和图中进行类似的搜索。但有的时候,搜索中会频繁地搜索到一些不必要的部分,这导致时间消耗增大,所以我们要想办法减少这些不必要部分的搜索,也就是所谓的剪枝操作。
这个过程往往是先写一个暴力搜索,然后通过寻找某些特殊的数学关系或者逻辑关系,约束整个查找过程,让搜索树尽可能地浅和小,从而达到降低时间复杂度的目的。
下面我们讲一个例题来学习一下这个概念:
【解题思路】
这道题我们规定构造出的三元组是递增的,在搜索过程中通过计算当前这个位置的上限(剪枝的关键)。DFS过程中记录乘积,当乘积大于1e6时直接返回,并且要注意三角形的最后一条边边长还需要小于另外两条边长之和。最后,我们使用上次讲到的前缀和快速求和查询。
#include <bits/stdc++.h> using namespace std; using ll = long long; const int N = 1e6 + 10; int cnt[N], prefix[N]; // cnt[i]表示边乘积为i的三角形的个数 //dep表示层数,st表示上一位元素的值,mul表示前面边长的乘积,sum表示前面边长的和 void dfs(int dep, int st, int mul, int sum) { //剪枝1 if (mul > 1e6) return; if (dep == 4) { cnt[mul]++; return; } //剪枝2 //由于三元组的元素依次递增,则假设之前乘积mul,当前边为x //则mul * x^(4-dep) <= 1e6,即x <= pow(1e6 / mul, 1.0 / (4 - dep)) int up = pow(1e6 / mul, 1.0 / (4 - dep)) + 3; //最后一位还需满足三角形两边之和大于第三边 for (int i = st + 1; i < (dep == 3 ? sum : up); i++) { dfs(dep + 1, i, mul * i, sum + i); } } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); dfs(1, 0, 1, 0); //从第1层开始,乘积从1开始,前面边长的和是0 for (int i = 1; i <= 1e6; i++) { prefix[i] = prefix[i - 1] + cnt[i]; } int q; cin >> q; // 前缀和多次查找 while (q--) { int l, r; cin >> l >> r; cout << prefix[r] - prefix[l - 1] << endl; } return 0; }
除了剪枝,我们还可以将搜索过程中会重复计算且结果相同的部分保存下来,作为一个状态,下次可以直接将这个子搜索的结果返回,不必重新计算。但要注意,这里一定要保证每一次重复计算得到的结果是相同的!
记忆化搜索的主要目的是为了防止递归层数过多,导致时间消耗太长。最典型的案例就是斐波那契数列。我们在搜索 F(n) 时用到了 F(n-2),在搜索 F(n-1) 时也用到了 F(n-2),这里就需要两遍相同的递归,显得是不必要的!
四、字符串算法
字符串问题千变万化,我们肯定无法讨论所有可能出现的题目类型,但我们可以寻找一些经典的算法来进行讲解。
4.1 Manacher算法
在介绍算法之前,我们先告诉大家这个算法的用处:一个字符串的子串可能是回文串,现在希望求出子串中是回文串的最长的长度是多少。Manacher 算法可以大大减少时间消耗。当然,你也可以看出这个算法的适用面比较窄,不过它的思想可能对我们解答许多字符串查找问题有很大的启示。
接下来,我们给出 Manacher 算法的模板,在注释中我们会对代码进行解析:
#include <bits/stdc++.h>
using namespace std;
// 修改字符串成一定可以查找的字符串
// 对于奇数长度的回文串,我们可以找到其回文中心i,回文半径为r,使[i-r+1, i+r-1]是回文字符串
// 但是对于偶数长度的回文串,我们无法找到其回文中心(或者说虽然可以定义,但没有什么意义)
// 因此,我们通过在原字符串中插入字符#,将所有回文串都变成奇数长度,头尾插入不同字符
string ManacherString(string s) {
string res = "^#";
for (int i = 0; i < s.size(); i++) {
res += s[i];
res += "#";
}
res += "$";
return res;
}
// s是将要查找的字符串,n是字符串长度,返回值是字符串中最长回文子串的长度
int Manacher(string s, int n) {
// R表示的是已经查找的字符中,回文串右边界的最大下标
// C表示的是右边界是R对应的回文串的回文中心的下标
// p[i]表示的是以i为中心的回文串的半径
int C = 0, R = 0;
vector<int> p(n, 1); // 回文半径至少为1
// 遍历字符串
for (int i = 1; i < n; i++) {
// i_mirror对应的是i关于C的对称点
int i_mirror = 2 * C - i;
// 如果此时i在R的左边,则p[i]的初始值至少为R - i和p[i_mirror]中的最小值
// 这是因为回文串有一个递归性质:
// 某个点(假设该点在回文半径右侧)的回文半径和其关于回文中心对称的点的回文半径相比一定相等或更大
// 如果此时i在R的右边,则p[i]的初始值至少为1
if (i < R) {
p[i] = min(R - i, p[i_mirror]);
} else {
p[i] = 1;
}
// 此时p[i]的初始值已经确定,接下来开始查找以i为中心的回文串的最大长度
while (s[i + p[i]] == s[i - p[i]]) {
p[i]++;
}
// 如果此时i + p[i] > R,则更新R和C,得到右边界下标更大的回文串
if (i + p[i] > R) {
C = i;
R = i + p[i];
}
}
// 返回最长回文串的长度,也就是转换后所得回文半径的最大值减1
// 这是因为转换后的字符串中回文半径为p[i],说明在转换前相应的回文串长度为:
// floor((p[i] + p[i] - 1) / 2) = floor(p[i] - 1/2) = p[i] - 1
// floor()表示向下取整
int res = 0;
for (auto &it : p) {
res = max(it, res);
}
return res - 1;
}
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
string s = "ABBCCCBABCCBABB";
string re_s = ManacherString(s);
cout << Manacher(re_s, re_s.size()) << endl; // print 8
return 0;
}
虽然看起来很长,但实际上模板并不长,只是注释写的稍微有点多,但也是希望大家可以理解这个查找过程的一些细节。
4.2 KMP算法
这个算法可以说是所有初学算法的同学心中很难过去的一道坎,因此,建议大家先记住模板,如有时间慢慢理解。这里也给出一些个人认为写的比较好的文章链接:
【经典算法】图解Kmp算法——配图详解(超级详细)-CSDN博客
KMP算法是一种字符匹配算法,用于匹配字符串p在文本串S中出现的位置。例如:S = "ababc",p = "aba",则出现的位置是1(下标从1开始计数)。
下面我们也同样给出KMP算法的模板,并配上注释:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
// KMP算法,找出字符串中第一个符合模式串的位置
int* build_next(string strr) {
int next[N] = {0}; // 返回的next数组
int prefix_len = 0; // 当前相同前后缀的长度
int i = 1;
while (i < strr.size()) {
if (strr[prefix_len] == strr[i]) {
// 下个字符依旧相同,长度加1
prefix_len += 1;
next[i] = prefix_len;
}
else {
if (prefix_len == 0) {
next[i] = 0;
i += 1;
}
else {
prefix_len = next[prefix_len - 1];
}
}
}
return next;
}
int KMP(string str, string strr) {
int* next = build_next(strr); // 构建next数组
int i = 0, j = 0;
while (i < str.size()) {
if (str[i] == strr[j]) { // 如果下一个字符相同,继续匹配下一个
i += 1;
j += 1;
}
else if (j > 0) { // 如果不相同,且已经经过匹配,此时需要找到最近的重新开始处
j = next[j - 1];
}
else { // 如果不相同,未经过匹配,此时主串继续寻找下一个
i += 1;
}
if (j == strr.size()) return i - j;
}
}
int main() {
string str, strr;
cin >> str;
cin >> strr;
cout << KMP(str, strr);
return 0;
}
4.3 字典树
字典树,是一种特殊的树状数据结构,对于解决字符串相关问题非常有效。一般而言,我们认为字典树是一种前缀树,但有时它也可以是后缀树(具体见下图)。字典树在统计、保存大量字符串中有着极大的优势:它利用字符串的公共前缀或后缀来减少查询时间,最大限度地减少不必要的字符串比较,使得查询的效率比一般算法高得多。
如上图所示,常见的字典树的每一个节点是由一个数据域(用来标记是否在此处有字符串终止)与一个长度为26的指针域(表示26个小写字母)组成。一般我们在根结点不存储任何数据,这样是为了可以存储所有的字符串,从根结点到某一个节点,路过的字符连起来就是该节点对应的字符串。由于每个节点的子节点字符不同,也就是说明找到对应单词、字符是唯一的。
这里我们整理字典树的定义、插入和查找的相应算法的写法。
4.3.1 字典树的定义
const int size = 26;
struct Node {
bool k; // true表示有字符串在此结尾,false表示无字符串在此结尾
Node* next[size];
Node() :k(false) { // 给成员变量赋值false
for (int i = 0; i < size; ++i) {
next[i] = nullptr; // 初始化都是空指针
}
}
};
4.3.2 字典树的插入
void insert_ch(char *ch) {
Node *p = head;
for (int i = 0; ch[i]; ++i) {
if (p->next[ch[i] - 'a'] == nullptr) // 判断下层节点是否存在
p->next[ch[i] - 'a'] = new Node; // 开辟新空间
p = p->next[ch[i] - 'a'];
}
p->k = true; // 进行字符串结尾标记
}
每次从根节点进行插入,如果向下的节点已经存在,就直接读取,否则拓展一个新节点。之后将最后一个节点的k标记为true表示该位置有一个字符串结尾。
4.3.3 字典树的查找
bool find_ch(char *ch) {
Node *p = head;
for (int i = 0; ch[i]; ++i) {
if (p->next[ch[i] - 'a'] == nullptr) { // 判断下层节点是否存在
return false; // 不存在即判否
}
p = p->next[ch[i] - 'a'];
}
return p->k; // 最终判断
}
基本过程与插入相同,向下查找,入过该节点不存在,直接返回false,如果存在一直向下查找,最终返回末尾标记的k。
下面整理一些关于字典树的常见问题:
4.3.4 依依的瓶中信
//本题考察字典树的扩展应用
//其具体算法仍是字典树的插入与查询
//需要注意的是当前字符串不能与自己匹配,
//解决的方法是写一个删除函数,先将当前字符串删除再查询,查询后再恢复
//由于插入与删除的本质相同,只是cnt数组对应位置的增加或减小,故只需改写插入函数即可
#include <bits/stdc++.h>
using namespace std;
const int maxn=1e5+100;
string str[maxn];//存储原始字符串组
int nex[maxn][27];//nex[x][0]表示从第x个结点出发,边为'a'的下一个结点地址
int cnt[maxn];//cnt[i]表示以第i个结点结尾的前缀的数量
int idx=2;//用于动态开点
void Insert(string s,int tag)//将字符串s插入字典树中,或将其从字典树中删除
//若传入tag=1,则为插入;若传入tag=-1,则为删除
//插入与删除的本质是令对应的cnt[x]+1或-1
{
int x=1;//初始从根结点(1号)开始
for(int i=0;i<s.size();i++)//遍历字符串s
{
cnt[x]+=tag;//对每个字符,以该字符结尾的前缀数量均+1/-1
if(nex[x][s[i]-'a']==0)//若该字符(存储该字符的边)未被记录
{
nex[x][s[i]-'a']=idx++;//则动态开点并记录之
}
x=nex[x][s[i]-'a'];//继续向下追溯
}
cnt[x]+=tag;//结尾字符对应的前缀数量+1/-1
}
int Search(string s)//在字典树中查找与s最接近的字符串,并返回匹配的最长前缀的长度
{
int x=1;//初始从根结点(1号)开始
int ans=0;//记录匹配的最长前缀的长度
for(int i=0;i<s.size();i++)//遍历字符串
{
if(nex[x][s[i]-'a']==0)//已经无法再匹配(不存在记录当前字符的边)
{
return ans;//返回之前累计的长度
}
x=nex[x][s[i]-'a'];//若能继续匹配,则继续向下追溯
if(cnt[x]==0)return ans;//已经不存在以x结点结尾的前缀,返回之前累计的长度
//注意以上这句不可省略,因为在删除操作中只是减少了字符串出现的次数,并没有删除之前记录的字符
ans++;//计数值加1,重复上述操作
}
return ans;//最终返回ans
}
int main()
{
int N;
cin>>N;
for(int i=0;i<N;i++)//输入N个字符串
{
cin>>str[i];
Insert(str[i],1);//插入
}
for(int i=0;i<N;i++)//N组查询
{
Insert(str[i],-1);//先将当前字符串删除
cout<<Search(str[i])<<endl;//查询匹配的最长前缀的长度并输出
Insert(str[i],1);//将当前字符串重新插入以恢复字典树
}
return 0;
}
4.3.5 XOR的最大值
这道题使用了字典树的特例——01树,这里简单做个介绍:
// 本题考察的是字典树的一个特例——01树
// 01树的一个常见用处就是用来查找异或最大值
// 由于查找过程类似二分,大大减少了所用的时间
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e5 + 10;
int son[32 * N][2], tot = 1; // 表示每个节点的子节点,节点编号
// 在01树中插入新的数
void insert(int num) {
int o = 1; // 从第一层开始插入,深度较小的节点存储数的高位
for (int i = 30; i >= 0; --i) { // 这里的最高层数是一个估计值,大约30位
int bit = num >> i & 1; // num的第i位
if (!son[o][bit]) { // 如果这个子节点之前未被创建
son[o][bit] = ++tot; // 那么就给这个新的子节点编上序号
} // 这里节点覆盖不影响之后的查找,因为查找过程中只需要确定这个数存在即可,
// 并不关注这个数的编号
o = son[o][bit]; // 之后就从这个子节点所在序号处继续插入
}
}
// 在01树中查找异或最大值
int query(int num) {
int o = 1, res = 0;
for (int i = 30; i >= 0; --i) {
int bit = num >> i & 1;
if (son[o][!bit]) { // 如果该位的不同数节点存在
o = son[o][!bit];
res |= 1ll << i; // 那么就证明存在一个数的该位可以通过异或产生有效位
} // 这里利用了二进制的一个特殊之处在于第i位为1形成的数大于从第0位到第i-1位均为1形成的数
else {
o = son[o][bit];
}
}
return res;
}
int main()
{
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
int n; cin >> n;
for (int i = 1; i <= n; ++i) {
int x; cin >> x;
insert(x);
}
int q; cin >> q;
while (q--) {
int x; cin >> x;
cout << query(x) << "\n";
}
}
五、数学相关算法
5.1 素数判定法
素数是指那些除了 1 和它本身之外,没有其他因子的数字,想必这一点大家都是知道的。那么如何判断素数,这里我们先给一个最朴素的做法:
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
int n; cin >> n; // 判断n是否为素数
if (n < 2) { // 如果小于2,则肯定不是
cout << "NO" << endl;
return 0;
}
// 按照定义,查找这个数是否有其它因子,如果有,则不是素数
// 注意,只需要查找到sqrt(n)即可,因为如果n有大于sqrt(n)的因子,那么一定有小于sqrt(n)的因子
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) {
cout << "NO" << endl;
return 0;
}
}
cout << "YES" << endl;
return 0;
}
上面的算法中时间复杂度为 O(sqrt(n))。但如果我希望对一个区间中所有的整数判断是否是素数,使用上面的判断算法就会导致时间复杂度变为 O(nsqrt(n)),这并不是一个非常好的结果,所以我们需要优化算法。
优化的第一步就是我们希望利用一个数就能判断出很多别的数是否是素数,这样的想法整理之后就成为了埃氏筛法:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
bool vis[N]; // 全局变量默认是 false
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
int n; cin >> n;
vis[0] = vis[1] = true; // vis[i] = true 表示已经被筛除
for (int i = 2; i <= n; ++i) {
if (!vis[i]) { // 如何 i 还没有被筛除,说明 i 是质数
cout << i << " "; // 输出质数
// 筛除 i 的倍数,因为它们都有 i 这个因子,一定是合数
for (int j = i + i; j <= n; j += i) {
vis[j] = true;
}
}
}
return 0;
}
尽管上面的埃氏筛法也很不错,但是我们在枚举每个素数的过程中仍然存在一些冗余的计算。例如:数字 30 会被 2 筛除,也会被 3 筛除。如果有一种算法可以保证每个合数都只会被筛除一次,那么就可以极大地降低时间复杂度。欧拉筛法就是利用的这一原理,将素数筛法的时间复杂度降到了 O(n)。
欧拉筛的核心思想用一句话概括就是保证每个合数都只被最小的质因子筛除。对于每个数 i,我们检查它是否为素数,如果是,则将其加入素数列表。然后,我们通过枚举素数列表 primes,用 i 去筛掉 i 的所有倍数 i * primes[j]。但是只有当 primes[j] 是这些倍数的最小质因子时,我们才进行筛除操作,一旦 primes[j] 不是某个倍数的最小质因子时,就直接结束,不再枚举 primes,去枚举下一个 i。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
bool vis[N]; // 全局变量默认是 false
void euler(int n) {
vector<int> primes;
vis[0] = vis[1] = true;
for (int i = 2; i <= n; i++) {
// 如果 i 没有被筛除,说明 i 是质因子,存入 vector 中
if (!vis[i]) primes.push_back(i);
// 注意枚举条件, primes[j] * i 表示要被筛除的数字(一定不是质数)
for (int j = 0; j < primes.size() && primes[j] * i <= n; j++) {
vis[primes[j] * i] = true;
if (i % primes[j] == 0) { // 说明此时primes[j] 已经不再是 i * primes[j] 的最小质因子了
break;
}
}
}
}
5.2 唯一分解定理
唯一分解定理指的是:对于任意一个 > 1 的正整数,都可以用唯一的一种方式分解为若干个质因数的乘积。这里展示一个示例代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
// 存储分解得到的质因子及其指数
vector<pair<int, int>> v;
int main() {
int x; cin >> x;
for (int i = 2; i <= x/i; i++) {
// 如果不能整除,直接进入下一次循环
if (x % i) continue;
// 如果能整除,那么一定是一个质因子,因为我们是从小到大查找因子的
// cnt 是这个质因子在 x 中的指数
int cnt = 1;
while (x % i == 0) {
cnt++;
x /= i;
}
v.push_back({i, cnt});
}
// 如果此时 x > 1, 那么只有可能 x 是一个质数
if (x > 1) v.push_back({x, 1});
// 输出检验
for (auto i : v) {
cout << i.first << " " << i.second << endl;
}
return 0;
}
5.2.1 约数的个数
之前我们已经得到了将一个数进行唯一分解后的结果,本节我们将解决求出一个数约数个数的问题。如果了解过这个问题的同学或许听过,这个问题是有公式的:。这里 d(x) 指的是数 x 的约数个数,cnt_i 指的是 x 中对于质因子 i 的指数大小。
在上节程序的基础上,我们稍加改变就可以变为求约数个数的程序:
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
// 存储分解得到的质因子及其指数
vector<pair<int, int>> v;
int main() {
int x; cin >> x;
for (int i = 2; i <= x/i; i++) {
// 如果不能整除,直接进入下一次循环
if (x % i) continue;
// 如果能整除,那么一定是一个质因子,因为我们是从小到大查找因子的
// cnt 是这个质因子在 x 中的指数
int cnt = 1;
while (x % i == 0) {
cnt++;
x /= i;
}
v.push_back({i, cnt});
}
// 如果此时 x > 1, 那么只有可能 x 是一个质数
if (x > 1) v.push_back({x, 1});
// 求出约数个数
int ans = 1;
for (auto i : v) {
ans *= i.second + 1;
}
cout << ans;
return 0;
}
5.2.2 计算约数和
在上一节的基础上进一步,我们还可以求出一个数 x 的约数和大小。当然,了解过的同学应该也知道这是有公式的:。这里
是一个质因子,
是这个质因子在数 x 中的指数。
用上面的公式,我们很轻松地就可以得到下面求约数和的程序:
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
// 存储分解得到的质因子及其指数
vector<pair<int, int>> v;
int main() {
int x; cin >> x;
for (int i = 2; i <= x/i; i++) {
// 如果不能整除,直接进入下一次循环
if (x % i) continue;
// 如果能整除,那么一定是一个质因子,因为我们是从小到大查找因子的
// cnt 是这个质因子在 x 中的指数
int cnt = 1;
while (x % i == 0) {
cnt++;
x /= i;
}
v.push_back({i, cnt});
}
// 如果此时 x > 1, 那么只有可能 x 是一个质数
if (x > 1) v.push_back({x, 1});
// 求出约数和
int ans = 1;
for (auto i : v) {
int base = 1, mulbase = 0;
for (int j = 0; j <= i.second; j++) {
mulbase += base;
base *= i.first; // base 表示 i.first 的 j 次方
}
ans *= mulbase;
}
cout << ans;
return 0;
}
5.3 快速幂
在上面我们为了求出一个数的 k 次方,用的是循环的方法。但事实上,这样一个时间复杂度是 O(N) 的方法并不优秀。使用快速幂算法,我们可以将这个步骤的时间复杂度变为 O(logN)。
简单来说,快速幂使用的想法是倍增。
假如求 a^b,其中 b 是奇数,那么 a^b = a * a^(b / 2) * a^(b / 2) = a * (a ^ 2)^(b / 2);
而若 b 是偶数,那么 a^b = a^(b / 2) * a^(b / 2) = (a ^ 2)^(b / 2)。
不断迭代下去,最终会出现指数为 0 的时候。这就是倍增思想。或许上面的思路听上去还有一点复杂,下面我们直接来看代码,马上就会发现这并不困难:
long long fast_pow(long long base, long long exponent, long long mod = 0) {
// 这里的 mod 做一个解释,因为往往所求的结果会有一个范围限制
// mod 就是起到这个作用,使结果不会溢出
long long result = 1;
while (exponent > 0) {
if (exponent & 1) {
result = result * base;
if (mod) result %= mod;
}
base = base * base;
if (mod) base %= mod;
exponent >>= 1;
}
return result;
}
5.4 费马小定理
这一部分涉及一点数论的知识,不过我们可以只了解结论,忽略掉中间的一些证明过程。首先,我们要介绍一下什么是逆元!如果 a * c = 1(mod p) 【注意:按道理这里应该是三等于号,但 CSDN 打印不出来,所以我们统一用双等于号代替】,则称 c 是 a 的逆元,也记作 a^(-1),1 / a。【注意:逆元是一个整数,这和一般的 1/a 并不相同】
知道了什么是逆元,我们再介绍什么是费马小定理:若 p 是质数,且 a , p 互质,则有 a^(p-1) = 1(mod p)。而因为 p 是一个质数,所以对于任意小于 p 的正整数 a,都有费马小定理成立。
至于我们为什么要讲费马小定理,那是因为,有些题目需要我们求出一个数的逆元,而费马小定理恰好可以求数 a 的逆元。我们只需要对费马小定理的式子两边同时除以一个 a,就可以得到 a^(p-2) = 1/a。于是我们就得到了 a 的逆元,即 1/a 在 mod p 意义下的整数形式,也就是 a 的 p-2 次方。
这个很简单,只需要用快速幂算法求出来即可,这里展示一下基本的算法:
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll pmi(ll a, ll p, ll mod) {
ll cnt = 1;
while (p != 0) {
if (p % 2 == 1) {
cnt = cnt * a % mod;
}
p = p / 2;
a = a * a % mod;
}
return cnt % mod;
}
int main() {
ll a, p; cin >> a >> p;
if (a % p) {
cout << pmi(a, p-2, p);
}
else {
cout << "Inverse doesn't exist";
}
return 0;
}
下面和大家一起看一个用到这个知识点的问题:
【问题展示】
【问题分析】
这里我们可以看到在输出格式中,题目要求我们输出的就是逆元的形式。怎么求逆元就是用上面讲过的费马小定理和快速幂的方法。
至于这道题目本身比较简单,我们可以对 k 进行讨论:
如果 k = 0,那么 1 号获胜,其余都是 0.
如果 k 是奇数,那么所有偶数号的选手平摊获胜概率。
如果 k 是偶数,那么所有奇数号的选手平摊获胜概率。
这是简单的概率问题,相信大家都可以想明白这个过程。
5.5 欧拉函数和欧拉降幂
这一节和上面类似,也涉及一点数论知识,不过我们都可以“不求甚解”。因此,我们不做铺垫直接进入主题,什么是欧拉函数:欧拉函数 phi(n) 表示小于等于 n 且与 n 互质的数的个数。按照定义 phi(1) = 1。
当然,这个函数也有固定的计算公式:。其中 p1~pm 是对 n 进行质因数唯一分解后得到的质因子。
假如 n 的范围在 1~1e9 之间,我们利用类似唯一分解的框架去枚举所有质因子按照之前的计算公式去计算即可。
long long phi(long long n) {
long long phi = n;
for (int i = 2; i <= n / i; i++) {
if (n % i) continue;
phi = phi / i * (i - 1);
while (n % i == 0) n /= i;
}
if (n > 1) phi = phi / n * (n-1);
return phi;
}
了解欧拉函数后,我们再认识一个欧拉定理:当 a,c 互质时,有 。不难意识到费马小定理其实是欧拉定理的一个特例。在费马小定理中,c 一定要是素数。
利用欧拉定理,我们可以得到如下等式:当 a,c 互质时,。往往这里的 b 是一个很大的数,为了简化计算过程,我们就可以使用这个式子来降幂,因此这个式子也被称为欧拉降幂公式。
5.6 裴蜀定理
最后让我们来讨论一个数论中不得不提的著名定理——裴蜀定理。其实这个定理很简单,它说的是:对于不定方程 ax + by = c,当且仅当 gcd(a,b) | c(即 a, b 的最大公约数可以整除 c)的时候,x, y 有整数解,其余情况都一定没有整数解。
一般而言,我们会对 c = 1 的特殊情况单独讨论,因为这时不定方程变为 ax + by = 1,若有整数解,则必须要求 gcd(a, b) = 1,即 a,b 互质。
这个定理比较简单易懂,它的证明过程,我们也可以抽空了解一下,主要使用的是辗转相除法的思路。裴蜀定理的证明过程https://zhuanlan.zhihu.com/p/15071520530 针对裴蜀定理,我们还有一个常用的推论:当 a,b 互质时且 x, y 均为非负整数时,ax + by 最大的不能表示的整数为 ab - a - b。
OK,朋友们,走到这里,我们已经讲完了一半左右的内容了,希望大家都还有毅力继续学下去。之后剩余的部分主要是数据结构,图论还有一些几何相关,可能比较繁杂,但并不困难。
算法知识点整理 · 考前突击(中)就到此为止了,我也会尽快整理好之后的内容哒。完成后链接会放在这里:(请等等哦,马上来)。谢谢大家的阅读!