自助建网站,网站 功能建设上 不足,网站seo优化推广教程,做电子请柬的网站1. 从KMP开始#xff1a;理解字符串匹配的“记忆”艺术 很多朋友一听到KMP算法就头疼#xff0c;觉得那些next数组、前缀后缀的概念太抽象。我刚开始学的时候也一样#xff0c;总觉得这玩意儿绕来绕去#xff0c;不如暴力匹配来得直接。但后来在Acwing上刷题#xff0c;尤…1. 从KMP开始理解字符串匹配的“记忆”艺术很多朋友一听到KMP算法就头疼觉得那些next数组、前缀后缀的概念太抽象。我刚开始学的时候也一样总觉得这玩意儿绕来绕去不如暴力匹配来得直接。但后来在Acwing上刷题尤其是做“831. KMP字符串”这道题时被卡了好几次时间限制才逼着自己硬啃下来。现在回头看KMP其实是一个特别能体现算法“巧思”的入门关它教会我们的不是死记硬背代码而是一种“利用已知信息避免重复劳动”的核心思想。你可以把字符串匹配想象成你在一个长长的文本比如一篇文章里找一个特定的单词。最笨的方法就是一个字母一个字母地对不对就从头再来。KMP聪明在哪呢它让这个“单词”自己先照照镜子看看自己有没有什么“内部重复”的结构。比如你要找的单词是ABABC当你在文本里匹配到ABAB的时候发现下一个字符对不上KMP不会让你傻乎乎地回到起点重新开始匹配A。它会告诉你“别急你看我们刚匹配成功的ABAB它的前两个字符AB和后两个字符AB是一样的所以我们可以直接把‘单词’的开头挪到已经匹配成功的那个AB后面继续比较下一个字符就行了。” 这个“挪动”的依据就是next数组。下面这个代码是Acwing算法基础课里最经典的实现我们一行行拆开看#include iostream using namespace std; const int N 10010, M 100010; int n, m; char p[N], s[M]; // p是模式串要找的单词s是主串长文章 int ne[N]; // next数组C里用了ne因为next在某些环境里是关键字 int main() { cin n p 1 m s 1; // 从下标1开始读个人习惯方便理解 // 第一步求模式串p的next数组 for (int i 2, j 0; i n; i) { while (j p[i] ! p[j 1]) j ne[j]; if (p[i] p[j 1]) j; ne[i] j; } // 第二步KMP匹配过程 for (int i 1, j 0; i m; i) { while (j s[i] ! p[j 1]) j ne[j]; if (s[i] p[j 1]) j; if (j n) { // 匹配成功 cout i - n ; j ne[j]; // 继续寻找下一个可能匹配的位置 } } return 0; }核心难点在于理解j ne[j]。这里的j可以理解为“已经匹配成功的长度”同时也是模式串p的指针。当s[i]和p[j1]不相等时我们不是把j重置为0而是利用next数组回退到一个“安全”的位置。这个位置保证了p[1...ne[j]]这个前缀和刚才已经匹配成功的p[j-ne[j]1 ... j]这个后缀是相等的。所以我们可以跳过那些绝不可能匹配的位置让i指针主串指针永不回退从而把时间复杂度从暴力匹配的 O(n*m) 降到 O(nm)。我建议你在纸上画一画比如模式串是ABABC自己推导一遍它的next数组[0, 0, 1, 2, 0]。然后模拟在主串ABABABC中查找的过程。当你看到j在ne数组的指引下“跳跃”时就能真切感受到这种“记忆”带来的效率提升。KMP是很多复杂字符串算法比如AC自动机的基础这块骨头啃下来后面会轻松很多。2. 最短路径不同场景下的“最优解”策略学完KMP我们进入图论的世界。最短路径问题是图论里最实用的问题之一从地图导航到网络路由都在用它。Acwing算法基础课里覆盖了四种经典算法Dijkstra、Bellman-Ford、SPFA和Floyd。很多新手会懵为啥要学这么多我该用哪个其实它们的区别就像你工具箱里不同的螺丝刀各有各的适用场景。朴素Dijkstra算法是你的“基础平头螺丝刀”。它的思想是贪心每次从还没确定最短距离的点里挑一个离起点最近的点用它来更新其他点的距离。它的前提是所有边的权重都是非负数。代码实现上它用邻接矩阵存图所以适合点少边多的“稠密图”。// 朴素Dijkstra核心部分 int dijkstra() { memset(dist, 0x3f, sizeof dist); // 初始化距离为“无穷大” dist[1] 0; // 起点距离为0 for (int i 0; i n; i) { // 要迭代n次每次确定一个点的最短距离 int t -1; // 这个循环就是找当前未确定点中距离最小的点时间复杂度O(n) for (int j 1; j n; j) { if (!st[j] (t -1 || dist[t] dist[j])) { t j; } } st[t] true; // 标记t点的最短距离已确定 // 用t点更新其他点的距离 for (int j 1; j n; j) { dist[j] min(dist[j], dist[t] g[t][j]); } } if (dist[n] 0x3f3f3f3f) return -1; return dist[n]; }你会发现找最小距离点t的那层循环是 O(n) 的所以总复杂度是 O(n²)。当点很多的时候比如上万个这就太慢了。于是就有了堆优化Dijkstra。它用一个小根堆优先队列来高效地获取当前距离最小的点把找最小点的复杂度从 O(n) 降到了 O(log n)。代码里用的是priority_queuePII, vectorPII, greaterPII。它适合用邻接表存储的“稀疏图”。这里有个我踩过的坑堆里可能会存在同一个节点的多个不同距离的旧版本所以取出节点时一定要判断if(st[ver]) continue;否则会做大量无用更新。那么如果图里有负权边呢Dijkstra的贪心策略就失效了因为它假设“当前最短”就是“全局最短”但负权边可能让一条更长的路径在加上负权后反而更短。这时候就需要Bellman-Ford算法。它的思想非常暴力就是进行 n 轮松弛操作每轮都尝试用所有边去更新距离。因为最短路径最多包含 n-1 条边所以 n 轮足够。它还能检测负权环第 n 轮还能松弛说明有环。代码里有个关键点memcpy(backup, dist, sizeof dist)这是为了“串联”更新保证本轮更新只用上一轮的结果避免用本轮刚更新的距离去更新同轮的其他点。SPFA算法可以看作是Bellman-Ford的优化版。它观察到一个事实只有某个点的距离被更新了用它才有可能去更新它的邻居。所以它用一个队列只把距离发生变化的点放进去。在随机图上它的效率接近O(m)非常快所以曾经有“SPFA就是队列优化的Bellman-Ford无脑用就行”的说法。但注意SPFA在最坏情况下会退化成O(nm)比如网格图。有些出题人专门卡SPFA所以现在比赛里如果边权为正大家还是更倾向于用堆优化Dijkstra。SPFA的价值在于它能处理负权边并且判断负环也很方便记录每个点入队次数超过n次就有负环。最后是Floyd算法这是“多源”最短路径算法一口气算出所有点对之间的最短距离。它的核心就是三层循环for(k) for(i) for(j)思想是动态规划考虑从 i 到 j 的路径中间只经过前 k 个点最短距离是多少。代码实现极其简单但复杂度是 O(n³)所以只能用于规模较小n500的图。初始化时记得把d[i][i]设为0其他设为无穷大。算法适用场景核心思想时间复杂度能否处理负权边能否判负环朴素Dijkstra稠密图正权边贪心每次确定一个最短点O(n²)否否堆优化Dijkstra稀疏图正权边贪心 优先队列优化O(m log n)否否Bellman-Ford有边数限制含负权动态规划暴力松弛O(nm)能能SPFA含负权随机图快队列优化Bellman-Ford平均O(m)最坏O(nm)能能Floyd多源小规模图动态规划三重循环O(n³)能不能有负环间接判断3. 背包九讲入门从01背包到动态规划思维如果说最短路径是图论的基石那背包问题就是动态规划DP的“ Hello World ”。Acwing的算法基础课把背包问题讲得非常透彻从最简单的01背包开始层层递进。很多同学学DP觉得状态定义和转移方程抽象其实背包问题提供了最经典的模板。01背包是万物的起点。问题描述很简单有 N 件物品和一个容量为 V 的背包。第 i 件物品体积是 v[i]价值是 w[i]。每件物品只能用一次。问怎么装能让背包总价值最大。我们定义状态f[i][j]表示只考虑前 i 件物品且总体积不超过 j 的情况下能获得的最大价值。它的状态转移只有两种可能不选第 i 件物品那最大价值就是前 i-1 件物品在容量 j 下的最大价值即f[i-1][j]。选第 i 件物品那就要先给第 i 件物品腾出空间v[i]然后加上它的价值w[i]即f[i-1][j-v[i]] w[i]。当然前提是j v[i]。所以方程就是f[i][j] max(f[i-1][j], f[i-1][j-v[i]] w[i])。这是最直观的二维写法。但你会发现f[i][j]只依赖于f[i-1][...]也就是上一行的数据。那我们完全可以用一个一维数组f[j]来滚动更新。但这里有个至关重要的细节内层循环的容量 j 必须从大到小遍历。因为如果从小到大遍历你在计算f[j]时用到的f[j-v[i]]可能已经是本行即已经考虑了第 i 件物品更新过的值这就相当于同一件物品被用了多次违背了01背包“只能用一次”的规则。从大到小遍历保证了f[j-v[i]]还是上一轮没考虑物品 i的值。// 01背包 一维优化终极模板 for (int i 1; i n; i) { // 遍历物品 for (int j m; j v[i]; j--) { // 遍历容量从大到小 f[j] max(f[j], f[j - v[i]] w[i]); } }理解了01背包和它的遍历顺序完全背包就水到渠成了。完全背包和01背包的唯一区别就是每件物品有无限件可用。状态定义一样但转移时因为可以重复选所以当选择物品 i 时状态是从f[i][j-v[i]]转移而来因为同一件物品 i 可能已经选过了而不是f[i-1][j-v[i]]。对应到一维优化代码上区别仅仅是内层循环的容量 j 要从小到大遍历。这个顺序的差异完美体现了两种问题的本质区别多体会几遍你会对DP有更深的理解。// 完全背包 一维优化模板 for (int i 1; i n; i) { // 遍历物品 for (int j v[i]; j m; j) { // 遍历容量从小到大 f[j] max(f[j], f[j - v[i]] w[i]); } }多重背包是01背包和完全背包的中间形态每件物品最多有 s[i] 件。最朴素的思路是把第 i 种物品看成 s[i] 件独立的物品用01背包的方法做。但这样复杂度是 O(V * Σs[i])如果 s[i] 很大就会超时。这里Acwing课程引入了一个非常巧妙的优化二进制拆分。它的思想是任何一个数字都可以用若干个2的幂次之和来表示比如 13 1 2 4 6。我们把 s[i] 件物品拆分成若干组每组物品的“体积”和“价值”是原物品的倍数然后对这些组做01背包。这样我们就把物品数量从 s[i] 降到了 log(s[i]) 级别复杂度优化为 O(V * Σlog s[i])。这个技巧在后续很多问题中都有应用。分组背包则是另一种变体物品被分成若干组每组内的物品互斥最多只能选一个。它的状态转移是在决策“第 i 组选哪个物品或者不选”。代码框架依然是先遍历组再逆序遍历容量因为每组内是01背包关系最后遍历组内的每个物品进行决策。背包问题这一套学下来你对DP的状态定义、转移方程设计和空间优化就有了一个非常扎实的基础。4. 动态规划进阶线性DP与区间DP的实战拆解掌握了背包问题的模型我们就可以挑战更复杂的动态规划了。Acwing算法基础课里线性DP和区间DP是两大重点它们能解决很多看似棘手的问题。线性DP通常指状态转移沿着某个线性方向如序列顺序进行的DP。一个经典的例子是数字三角形。从顶部走到底部求路径上的最大和。状态定义很自然f[i][j]表示从顶点走到第 i 行第 j 列这个点的所有路径中数字和的最大值。那么它只能从左上f[i-1][j-1]或正上f[i-1][j]走过来转移方程就是f[i][j] max(f[i-1][j-1], f[i-1][j]) a[i][j]。这里要注意边界初始化把f数组初始化为负无穷再把f[1][1]设为a[1][1]可以有效处理从边界外“走来”的非法情况。另一个线性DP的经典是最长上升子序列LIS。定义f[i]为以第 i 个数字结尾的上升子序列的最大长度。那么要计算f[i]我们需要看前面所有比a[i]小的数a[j]f[i]可能是f[j] 1。所以需要一层i的循环里面再套一层j的循环复杂度 O(n²)。Acwing上还有一道优化题896. 最长上升子序列 II要求 O(n log n) 的解法。这里就用到了一个非常巧妙的思想我们维护一个数组q[]q[len]表示长度为 len 的上升子序列的末尾元素的最小值。这个数组是单调递增的。遍历原序列时对于每个数a[i]我们在q中二分查找第一个大于等于a[i]的数然后用a[i]去替换它。如果找不到即a[i]比所有数都大就把它追加到q末尾。最后q的长度就是 LIS 的长度。这个思路可能第一次见有点绕多找几个例子模拟一下你会惊叹于它的精妙。区间DP则是另一类常见模型它的状态通常定义在一个区间上比如f[i][j]表示区间[i, j]的某种最优解。最典型的例题是石子合并。有 N 堆石子排成一排每次只能合并相邻的两堆代价是两堆石子的重量之和问把所有石子合并成一堆的最小总代价。我们定义f[l][r]为将第 l 堆到第 r 堆石子合并成一堆的最小代价。考虑最后一次合并它一定是把[l, k]和[k1, r]这两大堆合并起来代价是f[l][k] f[k1][r] sum[l...r]其中sum是区间和可以用前缀和快速计算。所以我们需要枚举这个分界点 k。在代码实现时我们通常先枚举区间长度len再枚举区间左端点l然后枚举分界点k。这种“长度-左端点-分界点”的三重循环是区间DP的经典写法。// 石子合并区间DP核心 for (int len 2; len n; len) { // 枚举区间长度 for (int l 1; l len - 1 n; l) { // 枚举左端点 int r l len - 1; // 计算右端点 f[l][r] 1e8; // 初始化为一个大数 for (int k l; k r; k) { // 枚举分界点 f[l][r] min(f[l][r], f[l][k] f[k 1][r] s[r] - s[l - 1]); } } }动态规划的精髓在于“状态定义”和“转移方程”。很多时候难就难在想不到怎么定义状态。我的经验是先从问题最直观的维度去定义比如在序列问题里定义“以 i 结尾”往往是个好的开始然后思考最后一个步骤是怎么发生的从这个步骤反推需要哪些子状态的信息。多刷题多总结把 Acwing 上这些经典例题的代码自己敲几遍理解每一行代码背后的含义慢慢地你就能培养出所谓的“DP思维”。这玩意儿没有捷径就是靠积累和反复练习直到某一天你看到一个新问题能下意识地想到“哎这好像可以套用那个模型”。