(8) 算法优化技巧
Tip
GESP C++ 八级考试大纲知识点梳理系列文章:
在 GESP 八级考试中,最后一项考点是对 算法优化 的综合考察。这不仅仅是学会某个具体的算法,更是要求我们具备一种 “多想一步” 的思维能力:当暴力解法行不通时,如何通过数学、数据结构或算法策略来提升效率,把 优化到 甚至 。
Tip
考纲要求: (8) 算法优化。理解不同方法求解一个问题在时间复杂度和空间复杂度上的差异,理解使用数学知识辅助求解问题的技巧(如可以用循环求出等差数列的和,也可以用数学公式求出等差数列的和),掌握一般的算法优化技巧。
Warning
本人也是边学、边实验、边总结,且对考纲深度和广度的把握属于个人理解。因此本文更多的不是一个教程,而是个人知识梳理,如有遗漏、疏忽,欢迎指正、交流。
本文将从三个维度来拆解常见的优化技巧:数学优化、数据结构优化 和 算法策略优化。
一、数学优化 (Mathematical Optimization)
数学是算法的灵魂。很多看似复杂的循环,背后往往隐藏着简单的数学公式或性质。
1.1 公式代替循环 ()
这是考纲中直接提到的例子:求等差数列的和。
- 暴力做法:写一个
for循环累加,时间复杂度 。 - 数学优化:利用高斯求和公式 ,时间复杂度 。
这类优化在 几何计算(如多边形面积)、排列组合(如 公式计算)中非常常见。
1.2 前缀和与差分 ()
在 区间查询 和 区间修改 问题中,前缀和与差分是神器。
1.2.1 区间求和问题 (Range Sum Query)
问题描述:给定一个包含 个元素的数组 ,有 次询问,每次询问给定区间 ,求该区间内所有元素的和。
Level 1:暴力解法
- 对于每次询问,使用
for循环从 遍历到 进行累加。 - 复杂度:单次询问 ,总复杂度 。当 都在 级别时,总计算量达到 ,必然 超时 (TLE)。
Level 2:前缀和优化 (Prefix Sum)
- 预处理:构造前缀和数组 ,其中 表示数组 前 个数的和,即 。递推公式为 。
- 查询:区间 的和可以表示为“前 个数的和”减去“前 个数的和”。
- 公式:。
- 复杂度:单次询问 。
1.2.2 区间修改问题 (Range Update)
问题描述:给定数组 ,有 次操作,每次操作将区间 内的所有数都加上 。最后输出修改后的数组。
Level 1:暴力解法
- 对于每次操作,使用
for循环把 一个个加上 。 - 复杂度:单次修改 ,总复杂度 ,同样会 超时。
Level 2:差分数组优化 (Difference Array)
核心原理:
- 定义差分数组 (规定 )。
- 根据定义, 其实就是 数组的前缀和,即 。
优化操作:
当我们需要将区间 内的所有数加上 时,不需要遍历修改,只需修改 数组的两个点:
D[L] += v:- 因为 的计算包含 ,所以 增加 。
- 同时,对于任何 , 的前缀和计算中也都包含 ,所以从 开始及其后面所有的数都会增加 。
D[R+1] -= v:- 我们的目标是只修改到 为止,不希望影响 及其后面的数。
- 在 处减去 ,刚好抵消掉 带来的 影响。
- 这样,从 往后,增加的 和减少的 互相抵消,数值保持不变。
如何应用(还原结果):
- 第一步:初始化差分数组 。
- 第二步:对于 次修改操作,每次只执行
D[L] += v和D[R+1] -= v,单次耗时 。 - 第三步(很重要):所有操作完成后,通过前缀和一次性还原出最终的 数组:
A[i] = A[i-1] + D[i](从 到 遍历一次)。
- 总复杂度:,远优于暴力的 。
1.3 数论性质优化
- 最大公约数 (GCD):
- 暴力:从 往下枚举,最坏 。
- 辗转相除法 (Euclidean):,复杂度 。
- 质数判定:
- 暴力:试除 ,复杂度 。
- 优化:只需试除 ,复杂度 。
二、数据结构优化 (Data Structure Optimization)
选择合适的容器,能让代码事半功倍。
2.1 Map/Set 代替扫描 ()
- 场景:查找某个数是否存在,或者统计某个数出现的次数。
- 暴力:
- 在
vector或数组中遍历查找。 - 单次查找 。
- 在
- 优化:
- 使用
std::set(去重/查找) 或std::map(统计/映射)。 - 基于红黑树实现,单次查找/插入均为 。
- 如果是哈希表
unordered_map,均摊甚至可以到 (但要注意哈希冲突和常数)。
- 使用
2.2 优先队列代替排序 ()
- 场景:在 个数中找出 前 大 的数。
- 暴力:
- 先
sort整个数组,然后取前 个。 - 复杂度:。
- 先
- 优化:
- 核心机理:维护一个固定大小为 的 小根堆 (min-heap)。
- 为什么选小根堆? 因为如果要找 前 大,我们需要知道这 个数里谁最小。如果新来的数比这个最小的大,说明新数有资格进前 大,把原最小的踢出去即可。
- 操作步骤:
- 先让前 个元素入堆。
- 从第 个元素开始遍历数组,设当前元素为
x,堆顶元素为min_val。 - 如果你比全班前 名里最弱的那个人(堆顶)强,你就把他挤下去了(Pop Min, Push New)。
- 否则,说明你连前 名的门槛都没摸到,也就没资格进入“精英榜”。
- 复杂度:遍历 次,每次堆调整耗时 ,总复杂度 。当 时优化明显。
三、算法策略优化 (Algorithmic Strategies)
当数学公式和数据结构都救不了你时,就需要改变算法本身的策略。
3.1 双指针法 (Two Pointers)
双指针通常用于将嵌套循环的 优化为 。
经典案例:2Sum 问题
- 问题:在有序数组中找两个数,使其和为
target。 - 暴力:两层循环枚举所有对,判断是否等于
target。。 - 双指针优化:左指针 ,右指针 。
- 若
A[L] + A[R] < target,说明和小了,。 - 若
A[L] + A[R] > target,说明和大了,。 - 因为 和 最多各走 步,总复杂度 。
- 若
3.2 二分答案 (Binary Search on Answer)
二分不仅能查数,还能查 “答案”。
- 适用场景:答案具有 单调性。即:如果 可行,那么比 小的也都可行(或比 大的都可行)。
- 策略:
- 我们不再试图“直接计算”最优解。
- 而是猜测一个答案
mid,然后写一个check(mid)函数判断这个答案是否合法。 - 如果
check(mid)为真,尝试更好的;否则尝试更保守的。
- 复杂度:时间复杂度从“无法直接求解”转化为 。
3.3 预处理与“空间换时间”
- 场景:需要多次查询某些耗时的计算结果(如组合数、素数、阶乘)。
- 优化:
- 不要每次询问都算一遍。
- 在程序开始时,先算好存在数组里(打表/预处理)。
- 询问时直接查表,复杂度 。
经典案例:最大子段和 (Maximum Subarray Sum)
这是一个教科书级的优化案例,体现了从“暴力”到“数学”再到“DP”的完整进化。
问题:给定数组 A,求一个连续子数组,使得其和最大。
- Level 1: 纯暴力 ()
- 枚举起点 ,枚举终点 ,再用循环计算 。
- Level 2: 前缀和优化 ()
- 预处理前缀和 。枚举起点 ,枚举终点 ,用 算区间和。省去了一层循环。
- Level 3: 分治法 ()
- 策略:将数组一分为二,最大子段只可能出现在三种情况:
- 完全在左半边:递归求左半边的最大子段和。
- 完全在右半边:递归求右半边的最大子段和。
- 跨越中间:从中间向左扫描求最大后缀和,从中间向右扫描求最大前缀和,两者相加。
- 合并:最终结果
max(左边最大, 右边最大, 跨越中间最大)。
- Level 4: 动态规划 / 贪心 () - Kadane 算法
- 策略:遍历数组,维护一个
current_sum。如果current_sum变成负数了,把它置为 0(因为负数只会拖累后面的和)。记录过程中的最大值。 - 一趟遍历即可完成,极致的效率。
- 策略:遍历数组,维护一个
四、总结与实战建议
算法优化没有定式,但有迹可循。在考场上遇到难题(尤其是 TLE 时),请按以下顺序思考优化方向:
- 数学公式? 能不能 算出来?有没有重复计算的部分可以用前缀和?
- 数据结构? 是不是查找太慢了?能不能用 Map/Set?是不是最值找太慢了?能不能用堆?
- 算法策略? 能不能二分答案?能不能用双指针去掉一层循环?
- 预处理? 还能不能再多记点东西,让查询更快?
通过八级的学习,我们不仅仅是在学 Coding,更是在学 Problem Solving。希望大家在 GESP 考试中都能游刃有余,写出既“快”又“省”的满分代码!
本文由coderli.com原创,按照CC BY-NC-SA 4.0 进行授权
所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code
“luogu-”系列题目可在 洛谷题库 在线评测。
“bcqm-”系列题目可在 编程启蒙题库 在线评测。
GESP/CSP认证交流QQ群: 688906745
