算法集训营深度复盘:贪心、前缀和、二分答案与DP实战精讲 1. 项目概述一次算法集训的深度复盘去年冬天我花了一周时间完整地刷完了牛客网举办的“2022牛客寒假算法基础集训营1”的全部题目。这不仅仅是一次简单的解题练习更像是一次对自身算法知识体系的系统性“体检”和“加固”。对于任何有志于提升算法能力尤其是准备参加校招笔试、机试或各类算法竞赛的同学来说这类由知名平台组织的、题目质量有保障的集训营其价值远超零散地刷几百道题。它提供了一个结构化的、有梯度的训练环境能让你清晰地看到自己的薄弱环节在哪里。今天我就以一名“过来人”的身份结合我当时的手记和后续的思考对这套题进行一次全面的、带有个人视角的拆解。我不会仅仅罗列答案而是会重点分享每道题背后的核心考点、解题思路的构建过程、编码实现时容易踩的坑以及如何从一道题延伸到一类题的思考方法。无论你是刚刚入门算法的新手还是希望查漏补缺的进阶者相信这份融合了实战经验的“题解”都能给你带来不一样的启发。2. 整体赛题分析与备赛策略2.1 题目难度分布与核心考点映射这套“寒假集训营1”的题目整体定位在基础到中等难度非常适合算法入门不久、希望巩固基础的同学。它没有设置那种需要复杂数学推导或奇特数据结构的“压轴题”而是扎实地覆盖了算法竞赛和笔试面试中的高频考点。通过对全部题目的梳理我们可以将核心考点归纳为以下几个大类模拟与实现考察将问题描述准确转化为代码的能力是基本功的体现。贪心算法在局部做出最优选择以期达到全局最优。关键在于证明或理解贪心策略的正确性。前缀和与差分处理区间查询和区间更新问题的利器能将对区间的操作优化到O(1)或O(log n)复杂度。二分查找不仅用于在有序数组中查找元素更广泛应用于“二分答案”这种解决最值问题的强大技巧。双指针用于维护序列中某个满足条件的区间或高效地处理有序数组的合并、去重等问题。简单动态规划(DP)通常是线性DP或背包问题的变种考察状态定义和转移方程的设计。位运算直接操作二进制位常用于处理与、或、异或、位计数等相关问题代码简洁高效。数学与数论涉及最大公约数(GCD)、最小公倍数(LCM)、质数判断、简单组合数学等。注意很多题目是多个知识点的结合。例如一道题可能外层是“二分答案”内层需要用“贪心”来检验答案的可行性。识别这种“套娃”结构是解题的关键一步。2.2 高效的刷题与复盘方法论在深入具体题目之前我想先分享我个人坚持的刷题流程这比单纯看答案重要得多独立审题与思考15-30分钟不看任何提示自己分析题目尝试抽象出模型思考可能用到的算法。即使没思路这个过程也能极大提升问题分析能力。动手实现与调试有了思路后立刻动手编码。在本地或OJ上运行样例确保基础逻辑正确。这是最容易暴露编码细节问题的环节比如数组越界、初始化错误、边界条件处理不当。对比与复盘最关键提交通过后或实在无法解出时去查看官方或其他优秀的题解。重点对比思路的异同、代码的简洁性、时间/空间复杂度的优化。问自己为什么他的方法更好我的方法卡在哪里归纳与拓展将这道题归类到某个知识点下并思考其变种。例如做完一道前缀和题可以想想如果问题变成“区间修改单点查询”或“区间修改区间查询”该怎么办从而引出差分和线段树/树状数组的概念。这套集训营的题目就是实践这个方法的绝佳材料。接下来我将挑选其中最具代表性、最易错或最能体现思维过程的几道题进行详解。3. 核心题目精讲与思维拆解3.1 贪心策略的典型应用日程安排问题这类问题通常描述为给定若干个活动的开始和结束时间问如何安排能参加最多的活动。这是贪心算法的经典例题其正确策略是每次都选择结束时间最早的活动。题目场景还原假设题目给出N个讲座的时间段求最多能听几场。解题思路拆解数据建模将每个讲座视为一个结构体{start, end}。排序按照讲座的结束时间end进行升序排序。这是贪心策略的核心保证了我们每次都为后续选择留下尽可能多的时间。贪心选择设置一个变量last_end记录上一个选择的讲座的结束时间。遍历排序后的列表如果当前讲座的开始时间start last_end说明它不冲突可以选择它并更新last_end end。代码实现要点与避坑struct Lecture { int start, end; }; bool cmp(const Lecture a, const Lecture b) { // 按结束时间排序结束时间相同时开始时间晚的放前面可选优化 return a.end b.end; } int maxLectures(vectorLecture lectures) { sort(lectures.begin(), lectures.end(), cmp); int count 0, last_end 0; for (auto lec : lectures) { if (lec.start last_end) { count; last_end lec.end; } } return count; }避坑指南排序依据务必按结束时间排序而不是开始时间。按开始时间排序的反例很容易构造。边界初始化last_end初始化为0如果时间从0开始或负无穷确保第一个符合条件的活动能被选中。相等情况如果结束时间相同理论上按开始时间降序或升序排序都不影响最终结果数量但升序排序在记录具体活动选择时可能略有不同。思维延伸如果问题变为“需要多少间教室才能安排所有活动”这就变成了区间分组问题最优解法是使用优先队列最小堆来维护每个教室当前活动的结束时间其核心思想与贪心一脉相承。3.2 前缀和的巧妙运用区间和查询前缀和是处理静态数组多次区间求和查询的最高效工具能将每次查询的复杂度从O(n)降至O(1)。题目场景还原给定一个长度为N的数组进行M次查询每次询问区间[L, R]内所有数字的和。解题思路拆解预处理创建一个前缀和数组prefix其中prefix[i]表示原数组arr[0]到arr[i-1]的和通常让下标从1开始更方便prefix[0]0。即prefix[i] prefix[i-1] arr[i-1]。查询计算对于查询区间[L, R]假设L和R是1-based索引其和等于prefix[R] - prefix[L-1]。这个式子的含义是从开头到R的总和减去从开头到L-1的总和剩下的就是L到R的总和。代码实现与细节int main() { int n, m; cin n m; vectorlong long arr(n1, 0); // 1-based索引 vectorlong long prefix(n1, 0); // prefix[i] 表示前i个元素的和 for (int i 1; i n; i) { cin arr[i]; prefix[i] prefix[i-1] arr[i]; // 递推计算前缀和 } while (m--) { int l, r; cin l r; // 区间和查询 cout prefix[r] - prefix[l-1] endl; } return 0; }实操心得数据类型区间和可能很大远超int范围务必使用long long。下标处理采用1-based索引能避免很多边界判断的麻烦prefix[0]0这个初始化让公式prefix[r] - prefix[l-1]在l1时也成立。不止于求和前缀和思想可以推广到“前缀积”、“前缀异或和”等只要运算满足结合律且有逆运算减法对应加法的逆除法对应乘法的逆需考虑模意义下的逆元。进阶思考——差分如果题目变成了“先进行M次区间加值操作最后再查询每个元素的值”就需要用到差分技巧。差分数组diff[i] arr[i] - arr[i-1]对原数组区间[L, R]加c等价于对差分数组执行diff[L] c和diff[R1] - c。所有更新操作完成后对差分数组求前缀和即可得到更新后的原数组。前缀和与差分是一对互逆的操作。3.3 二分答案的实战最小值最大化问题“二分答案”是一种非常实用的技巧用于求解“最大的最小值”或“最小的最大值”这类问题。当直接求解答案很难但给定一个候选答案mid后我们能够容易地判断这个答案是否可行时就可以使用二分答案。题目场景还原经典问题跳石头在一条长为L的河中有N个石头不含起点和终点给出它们距起点的距离。现在要移走M块石头使得选手在跳跃过程中每一步跳跃距离的最小值尽可能大。求这个最大的最小值。解题思路拆解判定函数设计这是二分答案的核心。假设我们猜测的答案是mid即最小跳跃距离至少为mid。我们需要判断在移走不超过M块石头的前提下能否保证任意两块保留的石头之间的距离以及起点到第一块、最后一块到终点都至少为mid。贪心验证从起点开始用贪心策略模拟跳跃。设当前位于位置current寻找下一块距离current至少为mid的石头。如果找到就跳过去如果找不到即下一块石头距离小于mid那么就需要移走这块石头移走数量1继续看下一块。最后判断移走的总石头数是否 M。二分查找答案答案的范围在[1, L]之间。我们在这个范围内进行二分查找。如果mid可行说明答案可能更大我们搜索右半边[mid1, right]如果mid不可行说明答案必须更小我们搜索左半边[left, mid-1]。代码框架与关键点bool check(long long mid, vectorlong long stones, int L, int M) { int remove_cnt 0; long long current 0; // 当前位置 int idx 0; // 指向下一块待判断的石头 while (current L) { // 找到下一块距离当前点至少为mid的石头 while (idx stones.size() stones[idx] - current mid) { remove_cnt; // 这块石头太近需要移走 idx; } if (idx stones.size()) { // 没有石头了判断最后一段当前位置到终点 if (L - current mid) return false; // 最后一段距离不够mid break; } current stones[idx]; // 跳到这块石头上 idx; } return remove_cnt M; } int main() { // ... 输入 L, N, M 以及石头数组 stones ... sort(stones.begin(), stones.end()); // 确保石头有序 long long left 1, right L; long long ans 0; while (left right) { long long mid left (right - left) / 2; if (check(mid, stones, L, M)) { ans mid; // 记录可行的答案 left mid 1; // 尝试更大的答案 } else { right mid - 1; // 答案必须更小 } } cout ans endl; return 0; }注意事项单调性二分答案的前提是“可行性”关于答案具有单调性。即如果x可行那么所有 x的值都可行对于“最大值”问题或者如果x可行那么所有 x的值都可行对于“最小值”问题。本题属于前者如果最小距离mid可行那么更小的距离一定也可行因为限制更宽松。判定函数复杂度check函数的复杂度必须是O(n)或O(n log n)等较低复杂度因为二分本身是O(log L)相乘后总复杂度需可接受。数据类型距离和答案可能很大用long long。3.4 动态规划入门路径与方案计数动态规划是算法学习的重难点。集训营中通常会包含一道经典的线性DP题比如爬楼梯、最小路径和、或者简单的背包问题。题目场景还原数字三角形给定一个数字三角形从顶部走到底部每次只能走到下一行相邻的两个位置求经过数字之和的最大值。解题思路拆解状态定义这是DP最核心的一步。定义dp[i][j]表示从三角形顶部走到第i行第j列这个位置时所能获得的最大路径和。状态转移方程如何用已知状态推导出dp[i][j]对于(i, j)这个点它只能从上一行的(i-1, j-1)或(i-1, j)走过来。因此dp[i][j] max(dp[i-1][j-1], dp[i-1][j]) triangle[i][j]。这里需要注意边界即最左边一列(j0)只能从(i-1, 0)来最右边一列(ji)只能从(i-1, i-1)来。初始化dp[0][0] triangle[0][0]。计算顺序由于dp[i][j]依赖于dp[i-1][...]所以我们需要按行i从1到n-1依次计算。答案最终答案是最后一行dp[n-1][j]中的最大值。代码实现与空间优化// 基础版本 int maxPathSum(vectorvectorint triangle) { int n triangle.size(); vectorvectorint dp(n, vectorint(n, 0)); dp[0][0] triangle[0][0]; for (int i 1; i n; i) { for (int j 0; j i; j) { if (j 0) { dp[i][j] dp[i-1][j] triangle[i][j]; } else if (j i) { dp[i][j] dp[i-1][j-1] triangle[i][j]; } else { dp[i][j] max(dp[i-1][j-1], dp[i-1][j]) triangle[i][j]; } } } return *max_element(dp[n-1].begin(), dp[n-1].end()); }空间优化技巧滚动数组 观察状态转移方程dp[i]这一行只依赖于dp[i-1]这一行。因此我们可以只用两个一维数组甚至一个数组从右向左更新来节省空间。// 使用两个一维数组 int maxPathSumOptimized(vectorvectorint triangle) { int n triangle.size(); vectorint prev(n, 0), curr(n, 0); prev[0] triangle[0][0]; for (int i 1; i n; i) { for (int j 0; j i; j) { if (j 0) curr[j] prev[j] triangle[i][j]; else if (j i) curr[j] prev[j-1] triangle[i][j]; else curr[j] max(prev[j-1], prev[j]) triangle[i][j]; } swap(prev, curr); // 当前行变为下一轮的前一行 } return *max_element(prev.begin(), prev.end()); // 注意最后结果在prev中 }常见错误数组下标越界在计算dp[i][j]时访问dp[i-1][j]或dp[i-1][j-1]前没有判断j和j-1的合法性。初始化遗漏忘记初始化dp[0][0]。状态转移方程错误错误地理解了“相邻”的含义或者求最大/最小时用了错误的运算符。4. 实战编码技巧与调试心得4.1 输入输出效率与数据类型选择在算法竞赛和在线笔试中输入输出往往是第一个性能瓶颈尤其是C使用cin/cout而题目数据量较大时。加速技巧ios::sync_with_stdio(false); // 解除C标准流与C标准流的同步 cin.tie(nullptr); // 解除cin与cout的绑定减少flush cout.tie(nullptr);使用这两行后cin/cout的效率会接近scanf/printf。但请注意一旦使用了它们就不要混用cin/cout和scanf/printf否则可能导致输入输出顺序错乱。数据类型选择原则明确数据范围如果题目说结果在10^9以内int可能够用int最大值约2.1e9。但一旦涉及乘法或累加很容易溢出。一个安全的习惯是除非确定不会溢出否则默认使用long long。无符号类型在处理位运算或明确非负的计数时unsigned int或unsigned long long有时能避免一些意外。4.2 边界条件与特殊情况的处理很多题目的错误不是算法错了而是边界情况没考虑周全。以下是一些常见的“坑点”检查清单情况检查点示例数组/容器访问下标是否可能为负数或超过size()-1循环中i-1,i1的访问。循环循环变量初值、终值、步长是否正确特别是for(int i0; in; i)与in的区别。遍历n个元素下标通常是[0, n-1]。空输入如果输入N可能为0你的代码会崩溃吗vector为空时调用front(),back()。整数运算中间结果会溢出吗特别是求平均值(ab)/2用a (b-a)/2更安全。(l r) / 2在l和r很大时会溢出。浮点数比较不要用直接比较浮点数使用fabs(a-b) epseps为一个极小值如1e-9。二分答案涉及浮点数时。多组数据是否清空了全局变量、容器使用vector.clear()或重新声明。一个具体案例在二分查找中计算中点mid (left right) / 2在left和right都是大整数时可能溢出。安全的写法是mid left (right - left) / 2。4.3 调试与对拍如何快速定位错误当你的代码提交后得到“Wrong Answer”或“Runtime Error”时不要慌张系统化地排查重新阅读题目确保没有误解题意比如输出格式、数据范围、特殊规定如多组数据、文件尾结束。检查样例在本地用题目给的样例测试是否能通过如果不能进入下一步。小数据测试自己构造一些极小的、手算能知道答案的数据进行测试。例如N0, 1, 2的情况。打印中间变量在代码中关键步骤后打印出重要的变量值如DP数组、循环计数器、判断条件的结果与你的预期进行对比。对拍Data Check这是找到隐蔽错误的大杀器。写一个绝对正确但可能很慢的“暴力算法”比如用DFS枚举所有情况再写一个随机数据生成器。运行你的“高效算法”和“暴力算法”对比同一组随机数据的输出。如果出现不一致就找到了反例然后缩小数据规模进行单步调试。对拍简易脚本思路Python示例import subprocess, random for test_case in range(100): # 跑100组随机测试 # 1. 生成随机输入数据写入 input.txt with open(input.txt, w) as f: n random.randint(1, 10) f.write(f{n}\\n) # ... 生成更多数据 # 2. 运行你的程序得到输出 my_output.txt subprocess.run([./my_program.exe, , input.txt, , my_output.txt], shellTrue) # 3. 运行暴力程序得到输出 brute_output.txt subprocess.run([./brute_program.exe, , input.txt, , brute_output.txt], shellTrue) # 4. 比较两个输出文件 with open(my_output.txt, r) as f1, open(brute_output.txt, r) as f2: if f1.read() ! f2.read(): print(f发现错误测试用例 {test_case}) print(输入数据) with open(input.txt, r) as f: print(f.read()) break else: print(所有随机测试通过)5. 从题目到知识体系的构建刷完一套题真正的收获不在于AC的数量而在于你是否能将散落的题目串联成知识网络。针对这套集训营你可以做如下归纳题目特征/关键词可能关联的算法/数据结构思考方向区间求和、多次查询前缀和一维/二维是否需要处理更新更新频繁则考虑树状数组或线段树。区间加值、最后查询差分一维差分、二维差分。“最大/最小化某个值”二分答案答案是否单调能否设计出高效的判定函数“最多/最少选择”且局部最优能导向全局最优贪心尝试几种排序策略按开始时间、结束时间、权重等并思考其正确性。求最优解最大/最小、问题可分解为子问题动态规划状态如何定义状态如何转移有无后效性序列中找满足条件的两个数/子数组双指针/滑动窗口数组是否有序窗口扩张和收缩的条件是什么操作与二进制位相关位运算与()、或(求最大公约数、质数数论欧几里得算法(GCD)、筛法求素数、快速幂。下一步学习建议专题强化如果你在某个知识点比如DP上感觉薄弱可以找该专题的经典题目集中练习如LeetCode或洛谷的题单。复杂度分析养成习惯在写出算法后分析其时间复杂度和空间复杂度并思考是否有优化空间。一题多解对于一道已经AC的题尝试用另一种思路去解决它。例如有些DP问题可以用记忆化搜索来写有些贪心问题可以思考其DP解法。这能极大地加深你对问题本质的理解。模拟比赛环境定期参加虚拟比赛锻炼在压力下快速读题、构思、编码和调试的综合能力。刷题就像搭积木每一道题都是一块积木。牛客寒假集训营这样的系列赛题提供了一套规格标准、搭配合理的积木套装。通过这次系统的“搭建”我们不仅熟悉了每块积木的用法更学会了如何根据蓝图问题描述选择并组合它们。记住答案本身并不最重要那个从毫无头绪到灵光一现再到调试通过的过程以及过程中对自身思维漏洞的修补才是算法能力增长的真正基石。希望这份结合了题目解析和个人经验的分享能让你在下次打开OJ时多一份从容多一份洞察。

相关新闻

最新新闻

ROS2苹果采摘机器人开发实录:从YOLO检测到MoveIt2运动规划

ROS2苹果采摘机器人开发实录:从YOLO检测到MoveIt2运动规划

简介:机器人操作系统(ROS2)为复杂机器人系统的开发提供了分布式通信与生命周期管理等关键机制,尤其在农业采摘等非结构化场景中,机器人的感知、规划与控制能力需要紧密协同。本文从目标检测与三维定位出发,…

2026/8/29 3:16:06
从Prompt到Agent Skills:固化大模型工作流与自定义技能实战

从Prompt到Agent Skills:固化大模型工作流与自定义技能实战

在实际使用大模型时,很多人会遇到一个尴尬:同一个工作流,每次都要把步骤、格式、约束重新告诉模型。Agent Skills 要解决的问题,正是把这套可复用的能力固化下来,让 Agent 在需要时自动调用。下面从零开始,…

2026/8/29 3:16:06
AIS2DW12超低功耗三轴加速度计实战:从寄存器配置到工业检测应用

AIS2DW12超低功耗三轴加速度计实战:从寄存器配置到工业检测应用

做嵌入式这行久了,手里常备的传感器就那么几颗,加速度计更是老熟人了。但这两年有个明显变化:从消费电子到车载电子,越来越多的项目在选型时只问一个问题——功耗能压到多低?AIS2DW12就是ST针对这个需求出的一张硬牌&a…

2026/8/29 3:16:06
Anthropic开放真实Claude对话数据集:解析与Python分析实践

Anthropic开放真实Claude对话数据集:解析与Python分析实践

Anthropic 把真实用户的 Claude 对话数据,整理成研究数据集开放给外部了。这是 Claude 开发方第一次做这种级别的数据公开。过去各家模型厂商发布的数据集,要么是训练语料,要么是评测基准,要么是人工合成的对话,真正把…

2026/8/29 3:16:06
Kafka面试八股文:从高吞吐原理到可靠性机制与积压排查

Kafka面试八股文:从高吞吐原理到可靠性机制与积压排查

去年面了个候选人,提到Kafka为什么快,对方张口就是“分区、顺序写、零拷贝”,我顺着追问了一句“零拷贝具体省了哪几步拷贝”,他愣了几秒,然后告诉我“就是省了拷贝”。这种回答其实挺常见的,名词都听过&am…

2026/8/29 3:16:06
C++20核心特性实战解析:从概念到模块的现代编程范式

C++20核心特性实战解析:从概念到模块的现代编程范式

1. 项目概述:为什么现在必须关注C20?如果你是一名C开发者,最近几年可能感觉有点“分裂”。一方面,C11/14/17带来的现代特性让开发体验焕然一新;另一方面,看着隔壁语言社区(比如Rust、Go&#xf…

2026/8/29 3:11:06