1. 算法竞赛中的数学与动态规划问题解析
最近在算法竞赛集训中遇到几道典型题目,涉及数学推导、动态规划等核心知识点。这些题目看似简单,但想要高效解决需要掌握特定技巧。下面我将逐一分析每道题目的解题思路和优化方法,分享我在解题过程中的思考路径和踩过的坑。
2. 分块算法解决整除求和问题
2.1 问题描述与暴力解法
给定整数n,计算Σ⌊n/i⌋(i从1到n)。直接暴力计算的时间复杂度是O(n),当n较大时(如1e9)必然超时。
我最初写的暴力代码如下:
cpp复制long long sum = 0;
for(int i=1; i<=n; i++) {
sum += n/i;
}
这个解法在n=1e5时还能接受,但题目数据范围通常更大,需要更优的解法。
2.2 分块优化思路
观察⌊n/i⌋的值会发现,随着i增大,结果呈现阶梯状下降。对于相同的⌊n/i⌋值,i的范围可以确定:
- 对于某个k=⌊n/i⌋,最大的i使得⌊n/i⌋=k的是⌊n/k⌋
- 因此可以直接计算每个k对应的区间长度,将时间复杂度降至O(√n)
优化后的核心代码:
cpp复制long long res = 0;
for(int i=1, j; i<=n; i=j+1) {
j = n/(n/i);
res += (j-i+1)*(n/i);
}
2.3 复杂度分析
这种分块方法利用了值域的连续性,将线性复杂度优化为平方根级别。对于n=1e12,循环次数仅约2e6次,完全可以接受。
3. 差的平方和问题的高效计算
3.1 问题重述
给定序列a₁到aₙ,计算所有i<j的(aᵢ-aⱼ)²之和。直接双重循环计算的时间复杂度是O(n²),无法处理大数据。
3.2 数学推导优化
展开平方和公式:
Σ(aᵢ-aⱼ)² = Σaᵢ² + Σaⱼ² - 2Σaᵢaⱼ
= nΣaᵢ² - (Σaᵢ)²
因此只需要预处理:
- 元素平方的前缀和
- 元素和的前缀和
3.3 实现细节
需要注意模运算的处理:
- 每次加法后取模
- 减法后若为负要加MOD
- 乘法可能溢出,需要long long
核心代码段:
cpp复制for(int i=1; i<=n; i++) {
s[i] = (s[i-1] + a[i]) % MOD;
s2[i] = (s2[i-1] + a[i]*a[i]) % MOD;
}
long long ans = (n*s2[n] - s[n]*s[n]) % MOD;
if(ans < 0) ans += MOD;
4. 动态规划解决数变换问题
4.1 问题描述
给定正整数n,每次操作可以:
- n减1
- 若p是n的质因数,可将n变为n/p
求将n变为1的最少操作次数
4.2 DP状态设计
定义dp[i]:将i变为1的最少操作次数
初始条件:dp[1] = 0
转移方程:
dp[i] = min(dp[i-1]+1, min{dp[i/p]+1 | p是i的质因数})
4.3 预处理优化
需要预处理:
- 质数筛(埃氏筛或欧拉筛)
- 每个数的最小质因数
实现代码框架:
cpp复制// 预处理最小质因数
for(int i=2; i<=MAX; i++) {
if(!min_p[i]) {
for(int j=i; j<=MAX; j+=i) {
if(!min_p[j]) min_p[j] = i;
}
}
}
// DP计算
dp[1] = 0;
for(int i=2; i<=MAX; i++) {
dp[i] = dp[i-1] + 1;
for(int p : primes[i]) {
dp[i] = min(dp[i], dp[i/p]+1);
}
}
5. 贪心算法解决课程安排问题
5.1 问题分析
选择不冲突的课程,使总上课时间最长。与经典的活动选择问题不同,这里优化目标是最大化总时长而非课程数量。
5.2 关键思路
- 按结束时间排序
- 对于每节课,选择它后能接的最早结束的后续课程
- 使用二分查找优化查找过程
5.3 实现要点
定义dp[i]:前i节课的最大总时长
转移方程:
dp[i] = max(dp[i-1], dp[j] + duration[i])
其中j是最后一个不与i冲突的课程
核心代码:
cpp复制sort(courses, courses+n, [](auto &a, auto &b){
return a.end < b.end;
});
for(int i=0; i<n; i++) {
int l=0, r=i-1, j=-1;
while(l <= r) {
int mid = (l+r)/2;
if(courses[mid].end <= courses[i].start) {
j = mid;
l = mid+1;
} else {
r = mid-1;
}
}
dp[i] = max((i>0?dp[i-1]:0), (j>=0?dp[j]:0)+courses[i].duration);
}
6. 动态规划统计子数组乘积
6.1 问题描述
统计所有子数组乘积中负数、正数和零的数量。序列元素仅为-1、0、1。
6.2 DP状态设计
定义三个状态:
- dp[i][0]:以i结尾乘积为0的子数组数
- dp[i][1]:以i结尾乘积为1的子数组数
- dp[i][2]:以i结尾乘积为-1的子数组数
转移规则根据当前元素值不同而变化:
- 当前为0:所有以i结尾的子数组乘积都是0
- 当前为1:继承前驱状态并转换
- 当前为-1:正负状态交换
6.3 实现示例
cpp复制for(int i=1; i<=n; i++) {
if(a[i] == 0) {
dp[i][0] = i;
dp[i][1] = dp[i][2] = 0;
} else if(a[i] == 1) {
dp[i][0] = dp[i-1][0];
dp[i][1] = dp[i-1][1] + 1;
dp[i][2] = dp[i-1][2];
} else {
dp[i][0] = dp[i-1][0];
dp[i][1] = dp[i-1][2];
dp[i][2] = dp[i-1][1] + 1;
}
cnt0 += dp[i][0];
cnt1 += dp[i][1];
cnt2 += dp[i][2];
}
7. BFS解决倒牛奶问题
7.1 问题建模
三个桶的容量为v,x,y,初始状态(v,0,0)。通过倒牛奶操作,使任一桶中牛奶量为v/2。
7.2 BFS实现要点
- 状态表示:三元组(a,b,c)
- 状态转移:6种可能的倒法(A→B, A→C, B→A等)
- 使用哈希或三维数组记录访问状态
- 提前终止条件:任一桶达到v/2
7.3 优化技巧
- 对称性剪枝
- 提前检查v是否为偶数
- 使用位运算压缩状态(如果范围允许)
核心转移逻辑:
cpp复制while(!q.empty()) {
auto [a,b,c,step] = q.front(); q.pop();
if(a == target || b == target || c == target)
return step;
// A->B
int pour = min(a, y-b);
if(pour > 0 && !vis[a-pour][b+pour][c]) {
vis[a-pour][b+pour][c] = true;
q.push({a-pour,b+pour,c,step+1});
}
// 其他五种转移类似...
}
8. 算法竞赛经验总结
在实际编码中,有几个常见陷阱需要注意:
-
整数溢出问题:即使最终结果在范围内,中间计算也可能溢出。例如前缀和问题中,平方和可能很大,需要使用long long。
-
边界条件处理:DP问题中i=0或1的初始条件,二分查找的左右边界等都需要仔细考虑。
-
模运算的负值:减法取模后可能为负,需要加MOD再取模。
-
状态转移的正确性:在倒牛奶问题中,每种倒法都要正确处理剩余量和倒入量的关系。
对于算法竞赛训练,建议:
- 熟练掌握基础算法模板
- 培养数学推导能力
- 大量练习提高编码速度和准确性
- 学会使用对拍验证程序正确性