制作不易,感谢浏览。
导语:蓝桥杯省赛在即,你是否还在为算法知识点零散而焦虑?本文系统梳理C++组高频核心考点,助你快速构建算法知识体系。
一、避开那些"送分题"的坑
1.1 数据类型与极值的边界
在编程竞赛中,数据类型的边界问题常常是导致错误的主要原因之一。以下是几个关键点:
-
int型变量范围:
int
类型的取值范围为-2^31
到2^31-1
(约±21亿)。在处理累加问题(如前缀和、动态规划)时,若数据规模较大,务必检查是否需要使用long long
(范围为-2^63
到2^63-1
)。
注意事项:累加溢出是一个常见的陷阱,尤其是在动态规划问题中,状态转移可能涉及多个大数相加。建议在不确定时直接使用long long
,避免因溢出导致错误。 -
浮点数精度:浮点数存储基于IEEE 754标准,存在精度损失问题。因此,比较两个浮点数时,不能直接用
==
判断,而应通过设定一个极小的阈值(如1e-6
)来判断是否相等。例如:if (abs(a - b) < 1e-6) { // 视为相等 }
-
ASCII技巧:字符与数字的转换是竞赛中的常见操作。例如,
ch - '0'
可以将字符'0'
到'9'
转换为对应的数字,而'A' + 3
可以得到字符'D'
。这种技巧常用于密码类题目或字符映射问题。
1.2 STL容器使用速查表
C++ STL提供了丰富的容器,以下是几个常用容器及其典型应用场景:
容器 | 常用方法 | 典型应用场景 |
---|---|---|
vector | push_back(), resize(),迭代器遍历 | 动态数组、邻接表存储图 |
string | substr(), find(), replace() | 字符串匹配、分割操作 |
map | count(key), lower_bound() | 数据映射、离散化处理 |
set | insert(), erase(), find() | 去重、维护有序集合 |
priority_queue | push(), pop(), top() | 哈夫曼编码、求前K大元素 |
详细STL知识点可以跳转这里进行系统性学习。
实战建议:
vector
是动态数组的首选,但在频繁插入和删除时可能需要手动resize()
以优化性能。map
和set
基于红黑树实现,时间复杂度为O(log n),适合需要频繁查找和排序的场景。priority_queue
默认为大根堆,若需小根堆,可自定义比较函数或存储负值。
1.3 C++11/14/17新特性速览 (慎用高级语法)
C++11/14/17引入了许多新特性,蓝桥杯还是老老实实用简单语法吧,不然就会像往年学长一样暴毙。
-
auto关键字:自动类型推导,减少冗长的类型声明。例如:
vector<int> v = { 1, 2, 3}; for(auto x : v) cout << x << " ";
-
lambda表达式:简化函数对象定义,常用于STL算法。例如:
sort(v.begin(), v.end(), [](int a, int b){ return a > b; });
-
智能指针:
unique_ptr
和shared_ptr
管理动态内存,避免内存泄漏。例如:unique_ptr<int> ptr(new int(10));
二、暴力算法的蜕变
蓝桥杯叫暴力杯不是没有原因的,暴力打满就可以哪个很不错的成绩(要相信水货比较多),相信自己,大胆暴力。
2.1 搜索结果与剪枝艺术
在竞赛中,暴力搜索往往无法通过大规模测试用例,因此剪枝优化是必不可少的技巧。以下是几个关键点:
-
DFS模板(以全排列为例):
void dfs(int step) { if(step == n) { // 输出结果 return; } for(int i=1; i<=n; ++i) { if(!vis[i]) { vis[i] = 1; path[step] = i; dfs(step+1); vis[i] = 0; } } }
应用场景:排列组合、状态枚举、路径搜索等问题。
-
BFS关键点:队列维护状态、层次遍历求最短步数(如迷宫最短路)。
优化技巧:使用双端队列(deque)实现双向BFS,可显著降低时间复杂度。 -
剪枝策略:
- 可行性剪枝:当前路径不可能达成目标时终止(如八皇后问题)。
- 最优性剪枝:当前路径已劣于已知最优解(如旅行商问题)。
2.2 动态规划(DP)的使用
动态规划是竞赛中的核心算法之一,其关键在于状态定义和转移方程的设计。以下是解题步骤:
- 定义状态:明确
dp[i][j]
的含义。 - 推导转移方程:如何从前一个状态转移到当前状态。
- 确定边界条件:初始状态如何初始化。
经典问题:
-
背包问题:01背包(逆序枚举)、完全背包(正序枚举)。
实战技巧:通过状态压缩将二维DP优化为一维,节省空间。 -
最长公共子序列:
dp[i][j] = (s1[i] == s2[j]) ? dp[i-1][j-1] + 1 : max(dp[i-1][j], dp[i][j-1]);
应用扩展:可结合二分法优化为O(n log n)复杂度。
-
矩阵链乘法:区间DP的典型应用,需明确状态定义与转移顺序。
动态规划问题一般比较难,能放掉还是放掉吧
2.3 贪心算法的使用
贪心算法的核心思想是局部最优推导全局最优,但需要严格证明其正确性。
贪心其实就是对于某一种情况只考虑对他最优的情况,从而满足对于整个答案的最优解。一般比较考验思维,但是一般不会出很难得贪心题。可以多花点时间在这上面,争取拿满分。
2.4 图论算法模板速记
Dijkstra算法
Dijkstra算法用于求单源最短路径,其核心思想是利用优先队列维护当前最短路径。以下是代码模板:
void dijkstra(int s) {
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
dist[s] = 0;
pq.push({
0, s});
while(!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if(vis[u]) continue;
vis[u] = true;
for(auto &[v, w] : G[u]) {
if(dist[v] > dist[u] + w) {
dist[v] = dist[u] + w;
pq.push({
dist[v], v});
}
}
}
}
关键点:
- 使用优先队列维护当前最短路径,确保每次扩展的节点是最优的。
- 对于负权边,需改用Bellman-Ford或SPFA算法。
Kruskal算法(最小生成树)
Kruskal算法通过按边权排序并使用并查集维护连通性,求解最小生成树。以下是代码模板:
int kruskal() {
sort(edges.begin(), edges.end(), [](Edge a, Edge b) {
return a.w < b.w; });
int res = 0, cnt = 0;
for(auto &e : edges) {
int x = find(e.u), y = find(e.v);
if(x != y) {
fa[x] = y;
res += e.w;
cnt++;
if(cnt == n-1) break;
}
}
return cnt == n-1 ? res : -1;
}
Floyd算法(多源最短路)
Floyd算法通过动态规划的思想,求解所有节点对之间的最短路径。以下是代码模板:
void floyd() {
for(int k=1; k<=n; ++k)
for(int i=1; i<=n; ++i)
for(int j=1; j<=n; ++j)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
图论知识多而难,这里只给出了一部分皮毛,主播也还在学习当中,如果省赛的时候出了那就祈祷大家都不会吧哈哈哈
2.5 分治与归并排序
分治算法的核心思想是将问题分解为子问题,分别解决后合并结果。以下是归并排序的模板:
void merge_sort(int l, int r) {
if(l >= r) return;
int mid = (l + r) >> 1;
merge_sort(l, mid);
merge_sort(mid+1, r);
merge(l, mid, r); // 合并两个有序区间
}
实战应用:逆序对统计、大规模数据排序。
排序一般都是用sort直接写了,但是归并排序里面的思想很好,在求逆序对的时候有所妙用。
三、常用数学思路
3.1 数论必备公式
数论是竞赛中的重要模块,以下是几个关键算法:
-
快速幂算法(求
a^b mod p
):ll qpow(ll a, ll b, ll p) { ll res = 1; while(b) { if(b & 1) res = res * a % p; a = a * a % p; b >>= 1; } return res; }
-
应用场景:大数求幂。
-
欧拉筛法(线性时间复杂度求质数):
vector<int> primes; bool is_prime[N]; void euler() { memset(is_prime, true, sizeof is_prime); for(int i=2; i<N; ++i) { if(is_prime[i]) primes.push_back(i); for(int p : primes) { if(i*p >= N) break; is_prime[i*p] = false; if(i % p == 0) break; } } }
优化点:通过最小质因数标记,避免重复筛除,确保每个合数仅被筛除一次。
3.2 组合数学与概率
组合数学是解决排列组合问题的核心工具:
- 排列数:
P(n, k) = n!/(n-k)!
- 组合数:
C(n, k) = n!/(k!*(n-k)!)
卢卡斯定理:用于大组合数取模(模数为质数)。
ll C(ll n, ll k, ll p) {
if(k > n) return 0;
return fac[n] * invfac[k] % p * invfac[n-k] % p;
}
实战建议:预处理阶乘及其逆元,可快速计算组合数。
组合数学常用作在求组合数的题里面,实际上也是将模板记住就不是很难,难点在于你会不会用。
对于组合数大家可以看看主播的这篇博客
蓝桥杯应该不考(我猜的)
3.3 矩阵快速乘法
矩阵快速乘法是解决线性递推问题的高效方法,例如斐波那契数列的快速计算。以下是代码模板:
// 定义一个2x2矩阵结构体
struct Matrix {
ll m[2][2]; // 矩阵的元素,使用二维数组存储
// 重载乘法运算符,实现矩阵乘法
Matrix operator*(const Matrix &b) const {
Matrix c; // 创建一个新的矩阵c,用于存储乘法结果
// 遍历矩阵c的每个元素
for(int i = 0; i < 2; ++i) {
for(int j = 0; j < 2; ++j) {
// 计算矩阵c的元素c[i][j],使用矩阵乘法公式
// c[i][j] = (当前矩阵的第i行第0列 * b的第0行第j列) + (当前矩阵的第i行第1列 * b的第1行第j列)
c.m[i][j] = (m[i][0] * b.m[0][j] + m[i][1] * b.m[1][j]) % MOD;
}
}
return c; // 返回结果矩阵c
}
};
// 快速幂函数,用于计算矩阵的幂次
Matrix qpow(Matrix a, ll b) {
// 初始化结果矩阵为单位矩阵(对角线为1,其余为0)
Matrix res = {
{
1, 0}, {
0, 1}};
while(b) {
// 如果b的当前二进制位为1,将当前矩阵a乘到结果中
if(b & 1) res = res * a;
// 矩阵a自乘,相当于计算a的平方
a = a * a;
// 右移b的二进制位,处理下一位
b >>= 1;
}
return res; // 返回最终的幂次结果
}
四、竞赛中的技巧
4.1 位运算的奇巧
位运算是竞赛中优化代码的重要工具,以下是几个关键技巧:
-
lowbit操作:
x & -x
快速获取二进制最后一位1。
应用场景:二进制分组、Fenwick树(树状数组)实现。 -
状态压缩:用整数二进制位表示状态(如n皇后问题)。
优化技巧:通过位运算实现高效的状态转移,减少内存占用。 -
快速交换:
a ^= b; b ^= a; a ^= b;
注意事项:仅适用于整数类型,浮点数需谨慎使用。 -
是否为2的n次幂:
(x & (x-1))
原理:2的n次幂的二进制形式为1000000
,减一后为111111
,两者相与结果为0。
4.2 二分法
二分法是解决有序数组查找问题的经典算法,以下是寻找第一个≥target的位置的模板:
int l=0, r=n;
while(l < r) {
int mid = (l+r) >> 1;
if(a[mid] >= target) r = mid;
else l = mid + 1;
}
return l;
大家可以手写二分好好理解一下其中的过程,手写一下还是可以更好理解二分法的妙处的,能够有效的将O(n^2)
时间复杂度降为O(nlogn)
的复杂度。
关键点:
l < r
循环条件确保最终收敛到唯一解。mid = (l + r) >> 1
避免溢出问题。lower_bound() 和 upper_bound()
的使用一定要熟悉,写题中非常好用。
4.3 前缀和与差分数组
前缀和和差分数组是处理区间问题的高效工具:
-
前缀和:快速计算区间和。
int pre_sum[100005]; pre_sum[0] = 0; for(int i=1; i<=n; ++i) pre_sum[i] = pre_sum[i-1] + a[i]; int sum(int l, int r) { return pre_sum[r] - pre_sum[l-1]; }
-
差分数组:高效处理区间更新问题。
int diff[100005]; void add(int l, int r, int val) { diff[l] += val; diff[r+1] -= val; } for(int i=1; i<=n; ++i) diff[i] += diff[i-1]; // 恢复原数组
4.4 线段树与树状数组
没时间就不要看线段树和树状数组了,看来也写不出来。
线段树和树状数组是处理区间查询和更新问题的高效数据结构:
-
线段树:支持区间查询和单点/区间更新,适用于大规模数据。
struct SegmentTree { int l, r; int sum, add; } tree[4*MAXN];
-
树状数组:支持单点更新和前缀和查询,适用于频繁查询和更新的场景。
int c[MAXN]; int lowbit(int x) { return x & -x; } void update(int x, int val) { while(x <= n) { c[x] += val; x += lowbit(x); } } int query(int x) { int res = 0; while(x > 0) { res += c[x]; x -= lowbit(x); } return res; }
五、常考算法补充
6.1 字符串处理
KMP算法(模式匹配)
KMP算法通过预处理模式串的next
数组,避免暴力匹配中的重复比较。以下是代码模板:
void kmp(string s, string p) {
int n = s.size(), m = p.size(); // 获取主字符串和模式串的长度
vector<int> next(m, 0); // 创建 next 数组,用于存储模式串的前缀函数值
// 第一部分:预处理模式串,生成 next 数组
for (int i = 1, j = 0; i < m; ++i) {
// j 表示当前字符之前的最长公共前后缀长度
while (j && p[i] != p[j]) // 如果当前字符不匹配,回退到前一个位置的最长公共前后缀长度
j = next[j - 1];
if (p[i] == p[j]) // 如果当前字符匹配,最长公共前后缀长度加 1
j++;
next[i] = j; // 将当前字符的最长公共前后缀长度存入 next 数组
}
// 第二部分:主循环,匹配主字符串和模式串
for (int i = 0, j = 0; i < n; ++i) {
// j 表示当前匹配到模式串的第几个字符
while (j && s[i] != p[j]) // 如果当前字符不匹配,回退到前一个位置的最长公共前后缀长度
j = next[j - 1];
if (s[i] == p[j]) // 如果当前字符匹配,继续匹配下一个字符
j++;
if (j == m) {
// 如果匹配完成(j 等于模式串长度)
cout << "Pattern found at index " << i - m + 1 << endl; // 输出匹配的起始位置
j = next[j - 1]; // 回退到前一个位置的最长公共前后缀长度,继续匹配
}
}
}
6.2 二分图匹配
匈牙利算法
匈牙利算法用于求解二分图的最大匹配问题。以下是代码模板:
// 邻接表表示二分图的结构
vector<int> g[MAXN]; // MAXN是图中节点的最大数量
// match数组记录每个节点的匹配情况,初始化为-1表示未匹配
// vis数组用于标记在DFS过程中访问过的节点
int match[MAXN], vis[MAXN];
// 深度优先搜索函数,用于寻找增广路径
bool dfs(int u) {
// 遍历所有与u相连的节点v
for(int v : g[u]) {
// 如果v未被访问过
if(!vis[v]) {
vis[v] = 1; // 标记v为已访问
// 如果v未匹配,或者v的匹配节点可以找到新的匹配
if(match[v] == -1 || dfs(match[v])) {
// 将u和v匹配
match[v] = u;
return true; // 找到增广路径,返回true
}
}
}
// 未找到增广路径,返回false
return false;
}
// 匈牙利算法主函数,计算二分图的最大匹配数
int hungarian() {
// 初始化match数组为-1,表示所有节点初始时未匹配
memset(match, -1, sizeof match);
int res = 0; // 初始化匹配数为0
// 遍历所有左侧节点u(假设左侧节点编号从1到n)
for(int u = 1; u <= n; ++u) {
// 每次调用dfs前重置vis数组
memset(vis, 0, sizeof vis);
// 尝试为u找到增广路径
if(dfs(u)) res++; // 如果找到增广路径,匹配数加1
}
// 返回最大匹配数
return res;
}
6.3 最大流与最小割
Dinic算法
Dinic算法是求解最大流问题的高效算法,基于分层图的思想。以下是代码模板:
// 定义边结构体,包含目标节点、反向边索引和容量
struct Edge {
int to, rev, cap; // to: 目标节点,rev: 反向边的索引,cap: 边的容量
};
// 图的邻接表表示,每个节点对应一个边的列表
vector<Edge> g[MAXN]; // MAXN是图中节点的最大数量
// 添加一条边到图中
void add_edge(int from, int to, int cap) {
// 添加正向边
g[from].push_back((Edge){
to, (int)g[to].size(), cap});
// 添加反向边,容量初始化为0
g[to].push_back((Edge){
from, (int)g[from].size()-1, 0});
}
// 用于Dinic算法的层级数组和迭代器数组
int level[MAXN], iter[MAXN];
// BFS函数,用于构建分层图
void bfs(int s) {
// 初始化层级数组为-1
memset(level, -1, sizeof level);
queue<int> q; // 使用队列进行广度优先搜索
level[s] = 0; // 源点的层级设为0
q.push(s);
while(!q.empty()) {
int v = q.front(); q.pop();
// 遍历所有与v相连的边
for(Edge &e : g[v]) {
// 如果边的容量大于0且目标节点未被访问过
if(e.cap > 0 && level[e.to] < 0) {
level[e.to] = level[v] + 1; // 设置目标节点的层级
q.push(e.to); // 将目标节点加入队列
}
}
}
}
// DFS函数,用于在分层图中寻找增广路径
int dfs(int v, int t, int f) {
// 如果到达汇点,返回当前流量
if(v == t) return f;
// 遍历所有与v相连的边(从上次遍历的位置开始)
for(int &i=iter[v]; i<g[v].size(); ++i) {
Edge &e = g[v][i]; // 获取当前边
// 如果边的容量大于0且目标节点的层级高于当前节点
if(e.cap > 0 && level[v] < level[e.to]) {
// 递归寻找增广路径,取最小的剩余容量
int d = dfs(e.to, t, min(f, e.cap));
// 如果找到增广路径
if(d > 0) {
e.cap -= d; // 减少正向边的容量
g[e.to][e.rev].cap += d; // 增加反向边的容量
return d; // 返回增广的流量
}
}
}
return 0; // 未找到增广路径,返回0
}
// 计算最大流的主函数
int max_flow(int s, int t) {
int flow = 0; // 初始化最大流为0
while(true) {
bfs(s); // 构建分层图
// 如果汇点不可达,返回当前最大流
if(level[t] < 0) return flow;
// 重置迭代器数组
memset(iter, 0, sizeof iter);
int f;
// 在分层图中不断寻找增广路径
while((f = dfs(s, t, INF)) > 0) {
flow += f; // 累加增广的流量
}
}
}
六、狂刷真题
用魔法打败魔法,用真题打败真题。打开蓝桥真题库,库库刷起来吧。
这里有很多题,多写写找找题感,将会在赛场上大放异彩。
七、结语
算法学习没有捷径,但正确的备考方向能让你事半功倍。建议将本文提到的每个算法模板手写实现一遍,深入理解其中的细节。刷题过程中遇到问题,欢迎在评论区留言讨论!
立即行动:将本文提到的每个算法模板手写实现一遍,你会发现不一样的细节!