洛谷原文:GESP五级知识点汇总
数论部分
线性筛
线性筛,又称欧拉筛。其时间复杂度为 O(n) ,是一种线性时间复杂度的算法,并因此而得名。
线性筛可以筛出区间 [2,n] 内的所有质数。
核心思想
每个合数有且仅有一次被其最小的质因数标记,从而避免重复操作,达到线性复杂度。
数据结构
1. isPrime[]
一个 bool 类型数组,长度为 n+1 。若 isPrime[i] 的值为 true ,则代表 i 是质数;反之, i 是合数。初始化时所有元素的值都为 true 。
2. primes[]
一般用 vector 存储(即一个动态数组),或者用一个足够大的静态数组。
这个数组内存储已经找到的质数。
算法步骤
1.初始化: 将 isPrime 内的所有元素的值都设置为 true 。(不用把 isPrime[0] 和 isPrime[1] 设置为 false ,因为 0 和 1 都不是质数。但为了严谨性,设置了也未尝不可)
2.遍历每个数 i(从2到n):
-
若
isPrime[i]==true,则将i丢进primes里。 -
用已找到的素数
primes[j]与当前数字i相乘,标记i*primes[j]为合数,即isPrime[i*primes[j]]=false。 -
若
i能被primes[j]整除,停止内层循环。因为后续i与更大素数的乘积,已在之前被筛过。例如i=4,primes[j]=2时,若不停止,4∗3 会在之后被 6∗2 重复筛除。 -
遍历结束后,
primes内存储的值就是区间 [2,n] 内的所有质数。
代码示例
#include<iostream>
#include<algorithm>
#include<vector>
using namespace std;
const int MAXN=1e6+7;
bool isPrime[MAXN];
vector<int> primes;
void get(int n) {
for(int i=2;i<=n;i++)
isPrime[i]=true;
for(int i=2;i<=n;i++) {
if(isPrime[i])
primes.push_back(i);
for(int j=0;j<primes.size()&&i*primes[j]<=n;j++) {
isPrime[i*primes[j]]=false;
if(i%primes[j]==0)
break;
}
}
}
int main() {
int x;
cin>>x;
get(x);
for(int i=0;i<primes.size();i++)
cout<<primes[i]<<" ";
return 0;
}
欧几里得算法(辗转相除法)
欧几里得算法是一个求最大公因数(GCD)的算法。
欧几里得算法,又称“辗转相除法”,(“辗”音 zhan ,三声)。
核心思想
两个整数的最大公约数等于其中较小的数和两数相除余数的最大公约数。
即:(a>b)
gcd(a,b)=gcd(b,amodb)
注释:amodb ,a 除以 b 的余数,即 a%b。
显而易见地,可以用递归实现。
算法证明
步骤 1:设定、符号定义
- 设 d=gcd(a,b),即 d 是 a 和 b 的最大公约数
- 设 r=amodb,根据带余除法,存在整数 q 和 r(0≤r<b)使得:a=b⋅q+r
- 需要证明:gcd(a,b)=gcd(b,r)
步骤 2:证明 d 是 b 和 r 的公约数
- 由 d=gcd(a,b),得 d∣a 且 d∣b
- 由 a=bq+r 可得:r=a−bq
- 因为 d∣a 且 d∣b,所以 d∣(a−bq)
- 即 d∣r,故 d 是 b 和 r 的公约数
步骤 3:证明 d 是最大公约数(反证法)
- 假设存在 d′=gcd(b,r) 且 d′>d
- 由 d′=gcd(b,r),得 d′∣b 且 d′∣r
- 由 a=bq+r,得 d′∣(bq+r),即 d′∣a
- 于是 d′∣a 且 d′∣b,即 d′ 是 a 和 b 的公约数
- 但 d=gcd(a,b) 是最大公约数,d′>d 与之矛盾
- 故假设不成立,d 就是 b 和 r 的最大公约数
步骤 4:算法终止性证明
- 算法迭代过程:gcd(a,b)=gcd(b,r0)=gcd(r0,r1)=⋯
- 余数序列严格递减:b>r0>r1>r2>⋯≥0
- 根据良序原理,余数必然在有限步内达到 0
- 当 rk=0 时:gcd(rk−1,0)=rk−1
- 此时的 rk−1 即为所求的最大公约数
代码示例
1.递归版本
#include<iostream>
using namespace std;
int gcd1(int a,int b) {
if(b==0)
return a;
return gcd1(b,a%b);
}
int main() {
int a,b;
cin>>a>>b;
cout<<gcd1(a,b)<<endl;
return 0;
}
2.迭代版本
#include<iostream>
using namespace std;
int gcd2(int a,int b) {
while(b) {
int temp=b;
b=a%b;
a=temp;
}
return a;
}
int main() {
int a,b;
cin>>a>>b;
cout<<gcd2(a,b)<<endl;
return 0;
}
埃氏筛
埃氏筛法 (Sieve of Eratosthenes),一种古老又有效的素数筛法。用于筛选 [2,n] 之间的素数。
时间复杂度:O(nloglogn)
基本思想
从 2 开始,依次将每个素数的所有倍数标记为合数,剩下的未被标记的数就是素数。
和上文的线性筛有很多相似之处。
初始化
和线性筛相似,直接截取上文(略有修改,但实际意思未发生改变):
isPrime[]:一个
bool类型数组,长度为 n+1 。若isPrime[i]的值为true,则代表i是质数;反之,i是合数。初始化时除去 0 、 1 的所有元素的值都为true(除 0 、 1 都是素数)。
算法过程
从 2 开始,一直遍历到 n :
- 如果当前数字
i是素数(is_prime[i]为true),将从 i2 开始的所有 i 的倍数标记为合数,即设值为false。
遍历结束,所有仍然被标记为 true 的数就是素数。
示例代码
#include<iostream>
#include<vector>
#include<cmath>
using namespace std;
void f(int n) {
vector<bool> is_prime(n+1,true);
vector<int> primes;
is_prime[0]=false,is_prime[1]=false;
for(int i=2;i*i<=n;i++) {
if(is_prime[i])
for(int j=i*i;j<=n;j+=i)
is_prime[j]=false;
}
for(int i=2;i<=n;i++)
if(is_prime[i])
cout<<i<<" ";
}
int main() {
int n;
cin>>n;
f(n);
return 0;
}
最小公倍数
最小公倍数(LCM)也是一个知识点,虽然不常考。
公式
lcm(a,b)=a∗b/gcd(a,b)
这是显而易见的,证明略。
注意点
为了防止 a∗b 溢出,应先计算除法,再算乘法。
即:
lcm(a,b)=a/gcd(a,b)∗b
代码示例
gcd 的代码使用上文递归的做法。
注意下面代码只针对 a>b 的情况,如果题目没有明确表示,还需要添加特判。
#include<iostream>
using namespace std;
int gcd(int a,int b) {
if(b==0)
return a;
return gcd(b,a%b);
}
int lcm(int a,int b) {
return a/gcd(a,b)*b;
}
int main() {
int a,b;
cin>>a>>b;
cout<<lcm(a,b);
return 0;
}
高精度算法
当两个数进行运算,但精度太高,无法用变量储存时,我们通常使用高精度算法进行解决。
高精度计算通常是模拟竖式计算,需要考虑进位、借位等因素。
各位应该都是人类,就不解释竖式计算了。
我们直接通过代码中的随文注释来理解。
高精加
代码及批注:
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;
string add(string num1,string num2) {
string ans;
int k=0; //进位标志,初始化为 0
int i=num1.length()-1,j=num2.length()-1;
while(i>=0||j>=0||k) { //还有未处理数字时继续循环
int n1=i>=0?(num1[i]-'0'):0;
int n2=j>=0?(num2[j]-'0'):0;
//n1、n2 : 当前位置上的数字
int sum=n1+n2+k; //加法竖式计算这一位
ans.push_back((sum%10)+'0');
k=sum/10; //处理进位
--i,--j; //左移到下一位
}
//由于我们从低位向高位加,所以结果需要反转
reverse(ans.begin(),ans.end());
return ans;
}
int main() {
string a,b,ans;
cin>>a>>b;
ans=add(a,b);
cout<<ans<<endl;
return 0;
}
验证:
输入 123 和 456 ,输出 579 ,答案正确。
高精减
代码及批注:
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;
string f(string num1,string num2) {
string ans;
int i=num1.length()-1,j=num2.length()-1;
int k=0; //借位
while(i>=0||j>=0) {
//提取当前位置数字
int n1=(i>=0)?(num1[i]-'0'):0;
int n2=(j>=0)?(num2[j]-'0'):0;
n1-=k; //减去借位
if(n1<n2)
n1+=10,k=1; //借 1 当 10,标记借位
else k=0;
int now=n1-n2;
ans.push_back(now+'0'); //存储当前位置答案
--i,--j; //移动到下一位
}
reverse(ans.begin(),ans.end());
//去除前导 0
size_t temp=ans.find_first_not_of('0');
if(temp!=string::npos)
ans=ans.substr(temp);
else
ans="0"; // 如果全是0,只保留一个0
return ans;
}
int main() {
string a,b;
cin>>a>>b;
string ans=f(a,b);
cout<<ans<<endl;
return 0;
}
验证:
输入 100 和 50 ,输出 50 ,答案正确。
【注意】: 本代码对答案是负数的情况无法处理,需要根据实际情况更改。
高精度乘除法
高精度乘法和高精度除法均不是GESP五级的考点,包括高精加减也很少考,所以此处不再赘述。
读者可以自行在洛谷上找相关题目,通过阅读题解增长知识。
排序算法
基本的冒泡排序不再赘述。
以下是我曾经写过的两张表。
常见比较排序方法
| 排序法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 | 备注 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n2) | O(n2) | O(1) | √ | |
| 选择排序 | O(n2) | O(n2) | O(1) | × | 使用 O(n) 的额外空间,可以做到稳定排序,此时需要使用链表,或采用元素插入而非交换方式 |
| 插入排序 | O(n2) | O(n2) | O(1) | √ | 最坏时间复杂度更准确地表示为 O(n+d) ,其中 d 为原始序列中的逆序数量 |
| 归并排序 | O(nlogn) | O(nlogn) | O(n) | √ | |
| 快速排序 | O(nlogn) | O(n2) | O(logn) | × | 可用原址(in-place)操作减少存储空间占用 |
| 堆排序 | O(nlogn) | O(nlogn) | O(1) | × |
常见的非比较排序方法
| 排序法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 | 备注 |
|---|---|---|---|---|---|
| 基数排序 | O(d(n+k)) | O(d(n+k)) | O(n+k) | √ | k 为关建值的划分个数 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | √ | k 为值域规模 |
| 桶排序 | O(n+k) | O(n2) | O(n+k) | √ | 若在桶内采用归并排序等,最坏时间复杂度为 O(nlogn) |
快速排序
快速排序是一种不稳定的算法,其核心思想为分治。
快速排序的时间复杂度,最优情况 O(nlogn) ,若数组已经有序,则会退化成 O(n2) 。
核心思想
-
选择基准值:从待排序数组中挑选一个元素作为 “基准”(常见选择:数组第一个元素、最后一个元素、中间元素或随机元素)。
-
分区:重新排列数组,将所有比基准值小的元素放在基准值左边,所有比基准值大的元素放在基准值右边(相等元素可放任意一侧),此过程结束后,基准值会处于数组的最终正确位置。
-
递归排序:递归地将基准值左侧的子数组和右侧的子数组重复上述 “选择基准 - 分区” 步骤,直到子数组的长度为 1(此时子数组已天然有序)。
分区实现
以 “选择数组最后一个元素作为基准值” 为例,分区过程如下:
-
初始化一个 “小于基准区” 的边界指针
left(初始值为数组起始索引-1),用于标记 “小于基准值的元素区域” 的最后一个位置。 -
遍历数组中除基准值外的所有元素(从起始索引到倒数第二个索引):
若当前元素 小于等于 基准值,将 left 指针右移,然后交换当前元素与 left 指针指向的元素(此时当前元素被划入 “小于基准区”)。
-
遍历结束后,将基准值与
left+1位置的元素交换——此时基准值左侧全为小于等于它的元素,右侧全为大于它的元素,基准值处于最终正确位置。 -
返回基准值的最终索引(
left+1),作为下一次递归的分区边界。
算法优化
快速排序的时间复杂度高度依赖基准值的选择,若基准值每次都能将数组分成大致相等的两部分,效率最优;若基准值选择不当(如有序数组选第一个元素作基准),则会退化为最坏情况。常见优化方案:
随机选择基准:每次从待排序子数组中随机挑选一个元素作为基准,降低选到 “极端值”(如最大值、最小值)的概率。
三数取中:从子数组的 “第一个元素、中间元素、最后一个元素” 中选择大小居中的元素作为基准,平衡分区效果。
示例代码
不含优化。
旧代码,码风原始。
#include <iostream>
#include <vector>
using namespace std;
int huafen(vector<int>& arr, int low, const int high) {
int pi = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] <= pi) {
++i;
swap(arr[i], arr[j]);
}
}
swap(arr[++i], arr[high]);
return i;
}
void quick(vector<int>& arr, int low, const int high) {
if (low < high) {
int pi = huafen(arr, low, high);
quick(arr, low, pi - 1);
quick(arr, pi + 1, high);
}
}
int main() {
int n;
cin >> n;
vector<int> arr(n);
for (int i = 0; i < n; i++) {
cin >> arr[i];
}
quick(arr, 0, n - 1);
for (int i = 0; i < n; i++) {
cout << arr[i] << " ";
}
return 0;
}
动态规划
这是曾经的文章,直接搬运。原文在这里。
动态规划(简称“DP”)是 1957 年 理查德·贝尔曼(Richard Bellman)在 Dynamic Programming 一书中提出的一种表格处理方法。它将原问题分解为若干子问题,自底向上先求解最小的子问题,并把结果存储在表格中,在求解大的子问题时直接从表格中查询小的子问题的解,以避免重复计算,从而提高效率。
具备的要素
动态规划算法常用来求解最优化问题,尤其是带有多步决策的最优化问题。
能用动态规划解决的问题具备以下 3 个要素:
-
最优子结构: 如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构。也就是说一个问题的最优解只取决于其子问题的最优解。
-
无后效性: 将原问题分解为若干子问题,每个子问题的求解过程作为一个阶段,当前阶段的求解只与之前阶段有关,与之后阶段无关。即某阶段的状态一旦确定,就不受这个状态后续决策的影响。
-
重叠子问题: 求解过程中每次产生的子问题并不总是新问题,会有大量子问题重复。在遇到重复的子问题时,只需在表格中查询,无需再次求解。这个性质不是使用动态规划解决问题的必要条件,但凸显了动态规划的一大优势。
解题步骤
解DP题目的一般模式如下:
-
划分阶段: 按照问题特征,将问题划分为若干阶段。注意每个阶段是无后效性的。
-
状态表示: 将问题发展到各个阶段时所处于的各种情况用不同的阶段进行表示。比如:
dp[i]代表了……。 -
决策与动态转移方程: 在对问题的处理中做出的每种选择性的行动成为“决策”。而根据上一阶段的状态和决策来导出本阶段的状态就是状态转移。
-
边界条件: 需要一个递推的终止条件或边界条件。
-
答案:问题的求解目标。
例子实操
01背包问题
题目地址--P1048 [NOIP 2005 普及组] 采药
题目摘自洛谷 P1048。
1.题目大意
山上有 M 株草药,辰辰有 T 的时间去采药。
每株草药有 Wi 的价值,但采某株草药就会花费 Vi 的时间。
求在规定时间内,最多可以采到的草药最大价值。
2.状态表示(初步)
我们先将题目中的值进行表示。
题目中提到两个重要的值:即 V 和 W 。我们该如何对其进行表示?首先我们先定义一个二维数组来存储状态(即上文中的“表格”)。
设数组 dp[i][j] 为,考虑前 i 株草药,时间不超过 j 时能获得的最大价值。
由此,显而易见地,最终答案为 dp[M][T] ,也就是“考虑前 M 株草药,时间不超过 T ,能获得的最大价值”。
3.决策与动态转移方程
对于数组 dp ,我们不难发现有两种选择方式:
-
选择第 i 个物品;
-
不选第 i 个物品。
其中,“不选第 i 个物品”时,用 dp 数组的第一维表示为 dp[i−1][j] 。也就是根据上一个状态(未选择第 i 个物品)决定,这样的情况便能形成最优解。
先对 dp 数组进行初始化。根据上文定义 (dp[i][j] 为,考虑前 i 株草药,时间不超过 j 时能获得的最大价值),我们就可以把 dp[0][j] 和 dp[i][0] 初始为0,接着进行状态转移。
已知“不选第 i 个物品”的状态转移方程为dp[i][j]=dp[i-1][j],不妨推出“选第 i 个物品”的转移方程:
max(dp[i-1][j],dp[i-1][j-v[i]+w[i])
直接枚举 i 和 j 即可。
4.参考代码
代码仅供参考,AC记录
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
int T, M;
cin >> T >> M;
int v[105], w[105];
for (int i = 1; i <= M; ++i) {
cin >> v[i] >> w[i];
}
int dp[105][1005] = {0};
for (int i = 1; i <= M; ++i) {
for (int j = 0; j <= T; ++j) {
if (j >= v[i]) {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i]);
} else {
dp[i][j] = dp[i-1][j];
}
}
}
cout << dp[M][T] << endl;
return 0;
}
5.考虑优化
考虑将二维的 dp 数组优化成一维。
由于 dp[i][j] 只依赖于 dp[i−1][...] ,我们不妨把第一维优化掉。
j的遍历顺序要是反向,以避免内容的覆盖。
6.优化后代码
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
int T, M;
cin >> T >> M;
int v[105], w[105];
for (int i = 1; i <= M; ++i) {
cin >> v[i] >> w[i];
}
int dp[1005] = {0};
for (int i = 1; i <= M; ++i) {
for (int j = T; j >= v[i]; --j) { //逆序遍历避免覆盖
dp[j] = max(dp[j], dp[j-v[i]] + w[i]);
}
}
cout << dp[T] << endl;
return 0;
}
最长上升子序列
最长上升子序列(LIS)适用一维的动态规划来解决。
“最长上升子序列”是对于一个给定的长度为 n 的序列,求其单调递增的最长自序列的长度。
最长上升子序列是指,从原序列中按顺序取出一些数字排在一起,这些数字是逐渐增大的。
注:“子序列”可以是非连续的,但是顺序要与原序相同
1.状态设计
状态:dp[i] 表示以 a[i] 结尾的最长上升子序列的长度。
2.状态转移
dp[i]=1≤j<ia[j]<a[i]max(dp[j])+1
条件:a[j] < a[i](保证上升)。
操作:在所有满足条件的 j 中,找到最大的 dp[j],然后加 1(因为 a[i] 可以接在 a[j] 后面)。
边界:如果没有任何 j 满足 a[j] < a[i],则 dp[i] = 1(a[i] 自身构成一个长度为 1 的上升子序列)。
3.参考核心代码
这里只展示核心代码。
dp[0]=0;
ans=0;
for(int i=1;i<n;i++) {
dp[i]=1;
for(int j=1;j<=i-1;j++) {
if(a[j]<a[i])
dp[i]=max(dp[i],dp[j]+1);
}
if(dp[i]>ans)
ans=dp[i];
}
4.说明
说明:时间复杂度为 O(n2) ,遇到大的数据可能是会爆掉的。
你可以试着用 贪心、二分查找 等办法进行优化。由于本篇讲述的是 动态规划,所以不展示优化的代码。
或许你可以优化到 O(nlogn) 的级别。
推荐题目
不保证按照难度排序。
P1216 [IOI 1994] 数字三角形 Number Triangles
推荐学习资料
贪心
贪心算法(Greedy Algorithm)是一种局部最优导向全局最优的启发式算法,核心思想是:在每一步决策中,都选择当前状态下 “看起来最好” 的选项(即局部最优解),并希望通过这些局部最优的累积,最终得到整体问题的最优解。
需要注意的是,贪心算法并非对所有问题都有效——只有当问题满足 “贪心选择性质” 和 “最优子结构性质” 时,才能保证得到全局最优解。
推荐题目:(from:洛谷)
P12168 [蓝桥杯 2025 省 C/Python A/Java A] 拼好数
链表
这是一个常考的基础知识,下面提供代码。
构造链表
链表节点:
struct Node {
int val; //值
Node* next;//指向下一个节点的指针
Node(int x) : val(x), next(nullptr) {}
};
串联节点:
//创建头节点
Node* head=new ListNode(1); // 头节点值设为1
Node* cur = head; //用于链接后续节点
//循环创建剩余n-1个节点
for (int i = 2; i <= n; i++) {
Node* newNode=new Node(i); //新建节点
cur->next=newNode; //把新节点链接到当前链表尾部
cur = cur->next; //指向新的尾部
}
删除一个节点
if (k == 1) { //删头节点
ListNode* temp = head;
head = head->next;
delete temp;
} else {
// 找第k-1个节点(prev),因为要通过prev跳过第k个
Node* prev = head;
int count = 1; //计数
while (prev && count < k-1) {
prev = prev->next;
count++;
}
if (prev && prev->next) {
ListNode* temp = prev->next;
prev->next = prev->next->next;
delete temp;
}
}
添加一个节点
Node* dummy = new Node(0);
dummy->next = head;
ListNode* prev = dummy;
for (int i = 1; i < pos; i++) {
prev = prev->next;
}
ListNode* newNode = new ListNode(val);
newNode->next = prev->next;
prev->next = newNode;
head = dummy->next;
delete dummy;
真题解析
链表不具备的特点是 ( )。
A. 可随机访问任何一个元素
B. 插入、删除操作不需要移动元素
C. 无需事先估计存储空间大小
D. 所需存储空间与存储元素个数成正比
答案:A
解析:没这一说,需要挨个遍历。
选自:202503GESP选择题第1题
下面求 gcd(84,60) 的步骤中,第二步计算的是()。
int gcd(int a,int b) { int big=a>b?a:b; int small=a<b?a:b; if(big%small==0) return small; return gcd(small,big%small); }A. 84和60
B. 60和24
C. 24和12
D. 12和0
答案:B
解析:60小于84,84取余60等于24,故答案为B。
选自:202503GESP选择题第4题
1971

被折叠的 条评论
为什么被折叠?



