欢迎光临
我们一直在努力

算法竞赛入门算法串讲:构建你的“解题工具箱”

引言

初入算法竞赛,你可能会被各类名词淹没:DP、二分、并查集、KMP、线段树……但其实,绝大多数简单到中等难度的题目都建立在不超过十个基础算法之上。它们就像武学中的“太祖长拳”——招式朴实无华,但组合起来威力无穷。

本文不追求某个算法的深度,而是为你绘制一张入门算法全景地图。读完它,你将知道:

  • 每类算法解决什么问题?

  • 时间复杂度如何?

  • 什么时候用它?什么时候换别的?

  • 代码如何快速实现?


前置知识(快速自查)

在开始前,请确保你能熟练完成以下操作(任何一项卡住都请先补基础):

  • 用 C++ 写一个 for 循环,能正确输入输出。

  • 理解数组下标、字符串操作、结构体。

  • 会写递归函数(如求阶乘),明白递归调用栈。

  • 会使用 STL 中的 vector、sort、queue、stack、priority_queue。

  • 能估算一段代码的时间复杂度(大 O 记号)。

若以上没问题,我们开始。


第一章:暴力与枚举 —— “大力出奇迹,但必须用在刀刃上”

核心思想
枚举所有可能的候选答案,逐一验证。它是唯一能保证正确性的“万能算法”,但代价是指数级或多项式级的时间。

常见场景(按难度升序):

  • 单重循环:求数组最大值、查找某个元素。复杂度 O(n)。

  • 双重循环:统计满足条件的数对,如“两数之和”。复杂度 O(n²)。

  • 子集枚举:用二进制掩码枚举集合的所有子集。例如,n ≤ 20 时,枚举 2ⁿ 个子集,做状态压缩DP的前置练习。

  • 排列枚举:用 next_permutation 枚举全排列,适用于 n ≤ 10 的排列问题。

  • DFS 枚举所有路径:在图中搜索所有可能路径(配合剪枝)。

  • 优化技巧(暴力也要有智慧):

    • 剪枝(Pruning):在DFS中,如果当前路径已经不可能产生最优解(如长度已超过已知最优),立即返回。

    • 预排序:先排序,再枚举,可利用单调性提前 break。

    • 折半搜索(Meet in the Middle):将问题分成两半,分别枚举,再合并。典型例题:POJ 2785 4 Values whose Sum is 0(n=4000,四数组各取一个数和为0)。

    复杂度等级:

    • O(n²) —— n ≤ 5000 时可接受(约 2.5×10⁷ 次)。

    • O(2ⁿ) —— n ≤ 25 时可接受(约 3.3×10⁷)。

    • O(n!) —— 只适用于 n ≤ 11。

    何时使用暴力?

    • 当数据范围极小(题目明确给出小范围)。

    • 作为验证其他算法的“对拍器”(生成随机数据,与自己写的正解对比)。

    • 在想不到正解时,先写暴力拿部分分。

    记忆口诀

    数据小,暴力上;剪枝好,跑得快;折半搜,翻倍强。


    第二章:模拟 —— “翻译题意为代码,一次过是本事”

    核心思想
    模拟就是“按题目描述一步一步执行”。它不考验算法思维,而是考验建模能力和细心程度。模拟题的变量往往很多,容易写错。

    典型题型:

    • 时间推进型:如“程序运行时间”、“病毒扩散”、“时钟走动”。通常用 while (cur_time <= limit) 循环。

    • 矩阵操作型:如“旋转矩阵”、“蛇形填数”、“扫雷展开”。注意方向数组 dx[] = {1, -1, 0, 0}。

    • 游戏规则型:如“扑克牌发牌”、“俄罗斯方块”、“生命游戏”。需要清晰定义“状态”和“状态转移函数”。

    • 字符串解析型:如“计算器表达式”、“日期格式化”。常用 sscanf 或手写解析。

    代码技巧:

    • 函数拆分:将“移动”、“判断胜负”、“打印棋盘”等封装成函数,主函数只控制流程。

    • 防御性编程:每次访问数组前检查边界,避免越界。

    • 调试手段:中途输出关键变量,或写一个 print_state() 函数。

    复杂度:通常是 O(总步数) 或 O(n×m),与模拟轮次成正比。

    易错点:

    • 循环终止条件错误(如多跑一轮或少跑一轮)。

    • 读题不仔细,忽略特殊规则(如“若同时发生则按X优先”)。

    • 数组维度搞反(a[i][j] 与 a[j][i])。

    记忆口诀

    题意读透再动笔,函数分离逻辑清;边界条件多检查,样例手动过一遍。


    第三章:贪心 —— “局部最优,步步为营,但须证明”

    核心思想
    每一步都做当前最优的选择,希望最终结果最优。贪心算法不回溯,因此必须用数学方法证明其正确性(交换论证、归纳法、反证法)。

    经典贪心模型:

  • 区间调度:选择最多不重叠区间 → 按结束时间排序。

  • 最小字典序:每次取当前最小的可行字符(如“移除K位数字使剩下最小”)。

  • 部分背包:按性价比(价值/重量)从高到低取。

  • 哈夫曼编码:每次合并两个最小权值节点。

  • 加油问题:每次到加油站,加满能跑到下一个更便宜的站。

  • 判断是否贪心:

    • 问题具有 贪心选择性质(局部最优能推出全局最优)。

    • 问题具有 最优子结构(子问题的最优解能组合成原问题的最优解)。

    如果无法证明,尝试用动态规划(DP)——贪心是DP的特例(每步决策只依赖局部信息)。

    复杂度:通常为排序的 O(n log n) 或 O(n)。

    常见陷阱:

    • 贪心策略看似合理,实则有反例。例如“找零钱”用贪心并不总是正确(如面额 1, 3, 4,要凑 6,贪心取 4+1+1=3枚,但最优是 3+3=2枚)。

    • 因此,贪心必须经过严格证明,不能凭感觉。

    记忆口诀

    贪心先看性质,证明之后才敢用;排序往往是前奏,反例一戳就破功。


    第四章:二分与三分 —— “单调性是天赐的线索”

    核心思想
    二分:在单调函数中,通过不断缩小区间找到目标值或分界点。三分:在单峰/单谷函数中求极值点。

    二分两大家族:

  • 二分查找(有序数组):

    • 查找某个值:lower_bound / upper_bound(STL 已提供)。

    • 查找第一个满足条件的元素(如“第一个 ≥ x 的位置”)。

    • 注意:整数二分的 mid 计算方式,避免死循环。

  • 二分答案(最值优化):

    • 问题可转化为“给定一个猜测值 mid,判断是否可行”。

    • 典型题:“最大值最小化”(如把数组分成 m 段,使最大段和最小),“最小值最大化”(如放置牛的距离)。

    • 判断函数 check(mid) 通常为 O(n) 或 O(n log n),二分本身 O(log R),总复杂度 O(n log R)。

  • 三分法:

    • 用于求解凸函数或凹函数的极值点(如二次函数)。

    • 取 mid1 = l + (r-l)/3,mid2 = r – (r-l)/3,比较 f(mid1) 和 f(mid2) 来缩小范围。

    • 实数三分需设定迭代次数(如 100 次)或精度。

    注意事项:

    • 整数二分的边界:while (l < r) 与 mid = (l+r)/2 搭配,注意当 l 和 r 相邻时是否死循环。常用写法:

      cpp

      int l = 0, r = n; // [l, r)
      while (l < r) {
      int mid = (l + r) >> 1;
      if (check(mid)) r = mid;
      else l = mid + 1;
      }

    • 实数二分:for (int i=0; i<100; ++i) 固定迭代次数,避免精度误差。

    记忆口诀

    单调数据二分查,答案可行二分判;凸函数用三分,极值就在区间里。


    第五章:搜索(DFS / BFS)—— “状态空间遍历术”

    深度优先搜索(DFS)

    • 本质:递归枚举所有可能状态,直到边界或目标。

    • 实现:递归函数,参数为当前状态。

    • 剪枝三大类:

      • 可行性剪枝:当前状态已不可能达到目标(如剩余步数不够)。

      • 最优性剪枝:当前代价已超过已知最优解。

      • 对称性剪枝:避免重复搜索等价状态。

    • 记忆化搜索:将 dfs(state) 的结果存起来(即“递归版DP”),避免重复计算。

    广度优先搜索(BFS)

    • 本质:逐层扩展,保证第一次到达目标时步数最少(在无权图中)。

    • 实现:队列,vis 数组标记已访问。

    • 空间优化:双向BFS(从起点和终点同时扩展,相遇时结束),可将状态数从 b^d 降到 2*b^(d/2)。

    • 状态压缩:当状态可以用整数表示时,BFS 效率更高(如八数码用 Cantor 展开)。

    二者对比与选择:

    场景推荐
    求所有解 / 方案数 DFS(需要回溯)
    求最短步数(无权图) BFS
    树/图的遍历 均可,DFS 更省空间
    状态空间巨大但深度有限 DFS(IDDFS 迭代加深)
    状态空间巨大且目标明确 双向 BFS

    常见变形:

    • 迭代加深 DFS(IDDFS):限制搜索深度,逐步放宽,适用于深度未知且 BFS 空间过大的情况。

    • *A 搜索**:引入估价函数,优先搜索最有希望的节点(需启发式)。

    记忆口诀

    DFS 递归带剪枝,BFS 队列求最短;双向 BFS 大法好,状态压缩省空间。


    第六章:动态规划 —— “记住历史,避免重复劳动”

    核心思想
    将原问题分解为子问题,并存储子问题的解(状态),避免重复计算。DP 是算法竞赛中最灵活也最难的模块,但入门只需掌握几种经典模型。

    五步分析法(硬背下来):

  • 确定状态表示:dp[i] 表示什么?一维还是二维?

  • 写出状态转移方程:dp[i] = f(dp[j])。

  • 初始化:边界条件(如 dp[0] = 0)。

  • 计算顺序:从小到大的递推顺序(或记忆化搜索)。

  • 答案提取:通常是 dp[n] 或 max(dp[i])。

  • 入门必学的四大DP模型:

  • 线性 DP:

    • 最长上升子序列(LIS):O(n²) 或 O(n log n)(维护单调栈)。

    • 最大子段和(Kadane 算法):dp[i] = max(a[i], dp[i-1]+a[i])。

    • 编辑距离(字符串 DP):dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)。

  • 背包 DP:

    • 0/1背包:dp[j] = max(dp[j], dp[j-w[i]]+v[i])(逆序枚举容量)。

    • 完全背包:正序枚举容量。

    • 多重背包:二进制拆分优化为 0/1 背包,或单调队列优化(进阶)。

  • 区间 DP:

    • 典型:石子合并(dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum[i][j]))。

    • 特征:状态为区间 [i,j],枚举分割点。

  • 树形 DP:

    • 在树上做DP,通常用 DFS 后序遍历,如“树的最大独立集”(选或不选)。

    • 状态 dp[u][0/1] 表示 u 点选或不选时的最优值。

  • 优化技巧(入门需了解):

    • 滚动数组:当只依赖上一行时,用一维数组代替二维。

    • 前缀和优化:转移时需枚举 k,可用前缀和将 O(n³) 降为 O(n²)。

    • 单调队列优化:用于带区间限制的 DP(如多重背包)。

    记忆口诀

    状态定义是关键,转移方程靠推导;初始化别漏掉,计算顺序要定好。


    第七章:图论基础 —— “点与线构建的世界”

    图的存储(重点掌握邻接表):

    cpp

    vector<int> G[N]; // 无向图,双向加边
    vector<pair<int,int>> G[N]; // 带权图

    邻接矩阵仅用于稠密图且 n ≤ 500。

    图的遍历:

    • DFS/BFS 均可,求连通块个数(如岛屿数量)。

    最短路算法(三大经典):

    算法适用复杂度特点
    Floyd 多源,n≤500 O(n³) 代码极简,可判负环
    Dijkstra(堆优化) 单源,非负权 O((V+E) log V) 最常用,必须熟练
    Bellman-Ford / SPFA 单源,可有负权 O(VE) 最坏 SPFA 会被卡,慎用

    最小生成树:

    • Kruskal:边排序 + 并查集,O(E log E)。

    • Prim:类似 Dijkstra,适合稠密图。

    并查集(DSU):

    • 核心操作:find(x)(路径压缩)、union(a,b)(按秩合并)。

    • 应用:连通性判断、Kruskal、动态连通性(离线逆序处理)。

    拓扑排序(DAG):

    • Kahn 算法:入度为0的点入队,逐层删除。

    • 用于判断是否有环、任务调度、DP 递推顺序。

    记忆口诀

    建图用邻接表,遍历 DFS/BFS;最短路堆优 Dijkstra,生成树 Kruskal 并查集;拓扑排序入度零,无环才是 DAG。


    第八章:常用数据结构 —— “工欲善其事,必先利其器”

    栈(Stack)

    • LIFO,典型应用:

      • 括号匹配:左括号入栈,右括号弹出匹配。

      • 表达式求值(中缀转后缀)。

      • DFS 的显式栈(防止递归栈溢出)。

    队列(Queue)

    • FIFO,用于 BFS、滑动窗口(配合单调队列)。

    • 双端队列 deque:支持两端插入删除,用于单调队列优化DP。

    优先队列(Priority Queue)

    • 内部为堆,自动维护极值。

    • 应用:Dijkstra、哈夫曼树、多路归并、维护动态数据流的中位数(两个堆)。

    哈希表(unordered_map / unordered_set)

    • 平均 O(1) 查询,但常数较大。

    • 注意:竞赛中可能被故意卡哈希(构造大量冲突),解决办法:自定义哈希函数,或使用 map(红黑树 O(log n))。

    set / map

    • 有序容器,基于红黑树。

    • 应用:动态维护有序集合,求前驱后继,区间查询。

    bitset

    • 位压缩,支持高效的位运算(与、或、异或、移位)。

    • 常用于状压DP加速(如背包可行性)。

    记忆口诀

    栈后进先出,队先进先出;堆顶最值,哈希查找快;set/map 自动序,bitset 位操作强。


    如何选择算法?(决策指南)

    当你拿到一道题,按以下顺序思考:

  • 数据范围:若 n ≤ 20,考虑暴力/状压;n ≤ 5000,O(n²)可接受;n ≤ 10⁵,需要 O(n log n) 或 O(n)。

  • 问题类型:

    • 求最值/最优方案 → 贪心 or DP(若贪心不显然,则DP)。

    • 求可行方案数 → DP 或组合数学。

    • 求最短路径/最小生成树 → 图论。

    • 求是否存在某种结构 → 搜索/并查集/哈希。

  • 是否有单调性? → 二分答案。

  • 是否可离线处理? → 排序后扫描、莫队、离线分治。

  • 是否涉及复杂数据结构? → 线段树、树状数组(本文未覆盖,但需后续学习)。


  • 结语

    以上八大模块,覆盖了算法竞赛入门阶段的所有核心知识点。但知道 ≠ 掌握,唯有通过大量刷题,才能将这些知识内化为条件反射。建议按模块顺序,在洛谷、Codeforces、AtCoder 上分别刷 10~15 道相应标签的题,每道题都要想清楚它属于哪一类、用了什么优化。

    当你能熟练识别题目类型并快速写出模板代码时,你已经迈过了算法竞赛的第一道门槛。接下来,就可以挑战更高级的算法(线段树、网络流、后缀自动机、多项式算法等)。但无论走多远,这些基础算法永远是你最坚实的后盾。

    “算法竞赛的乐趣,不在于掌握多少高阶技巧,而在于能用简单的工具组合出解决复杂问题的方案。祝你在代码的世界里,越战越勇。”                                    

    最后的最后:

    一个AFO的oier希望看到这里的读者都能由此启发开启你的OI/ACM之路,路上的风景真的很美。这条路不会一帆风顺,你会在某个题上卡上几天,会在某场比赛中爆零到怀疑人生,会看着别人的高分代码羡慕不已。但请相信,每一个退役的选手都会告诉你:值得。

    我的OI之路啊,那是一段小有遗憾的幸福时光啊

    暴搜挂着机,打表出省一。骗分过样例,暴力出奇迹。
    数学先打表,DP看运气。穷举TLE,打表UKE。
    模拟MLE,贪心还CE。想要骗到分,就要有办法。
    图论背模板,数论背公式。动规背方程,高精背代码。
    如果都没背,干脆输样例!
    七八个测试点,两三次CE前,旧时题解花样变,听取WA声一片 

    赞(0)
    未经允许不得转载:171主机测评 » 算法竞赛入门算法串讲:构建你的“解题工具箱”
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址