1. 算法竞赛中的动态规划实战:洛谷P10262亲朋数解析
这道题目来自洛谷的GESP样题六级题库,编号P10262。作为一道典型的动态规划问题,它不仅考察选手对基础算法的掌握程度,更考验将数学思维转化为高效代码的能力。我在准备蓝桥杯竞赛时,这道题曾让我卡壳近两小时,后来才发现关键在于余数性质的应用。
亲朋数问题的核心是统计数字串中所有连续子串对应的数值能被给定整数p整除的数量。以样例"102"为例,其5个有效子串分别是"1"(余数1)、"10"(余数0)、"102"(余数0)、"0"(余数0)、"02"(余数0)。注意单独的数字"2"虽然数值为2,但在原串中作为"102"的结尾部分已经被统计过。
2. 问题重述与数学建模
2.1 问题形式化定义
给定数字串S(长度L≤10^6)和整数p(2≤p≤128),需要统计所有连续子串对应的数值是p的倍数的数量。例如S="12342",p=2时,有效子串包括:
- "12"(12÷2=6)
- "1234"(1234÷2=617)
- "12342"(12342÷2=6171)
- 两个不同位置的"2"(索引2和5)
- "234"(234÷2=117)
- "2342"(2342÷2=1171)
- "34"(34÷2=17)
- "342"(342÷2=171)
- "4"(4÷2=2)
- "42"(42÷2=21)
2.2 暴力解法的时间复杂度
最直观的做法是枚举所有L(L+1)/2个子串,逐个计算数值并检查是否能被p整除。对于L=10^6,子串数量约为5×10^11,即使每个检查只需1μs,也需要约5.8天——显然不可行。
2.3 关键数学性质
利用模运算性质:(a*10 + b) mod p = ((a mod p)*10 + b) mod p。这意味着我们可以动态维护当前数字串的余数,而不需要计算完整的数值。例如:
- "102"的处理过程:
- 读取'1':当前余数=1 mod 2=1
- 读取'0':新余数=(1*10+0) mod 2=0 → 找到有效子串"10"
- 读取'2':新余数=(0*10+2) mod 2=0 → 找到"102"和"2"
3. 动态规划解法详解
3.1 状态定义
定义两个数组f和g(大小均为p):
- f[j]:前i-1位数字中,余数为j的子串数量
- g[j]:处理到第i位时,余数为j的新子串数量
3.2 状态转移方程
对于每个新字符s[i]:
- 清空临时数组g
- 对所有可能的余数j∈[0,p-1]:
- 计算新余数t = (j*10 + (s[i]-'0')) % p
- g[t] += f[j](继承之前的余数状态)
- 单独处理以s[i]结尾的子串:
- g[(s[i]-'0')%p] += 1
- 累加有效解:ans += g[0]
- 将g复制到f,准备下一轮迭代
3.3 代码逐行解析
cpp复制#include <bits/stdc++.h>
using namespace std;
#define int long long // 防止整数溢出
const int N = 150; // p最大128,留有余量
int p, f[N], g[N], ans;
string s;
signed main() {
cin >> p >> s;
int len = s.size();
s = " " + s; // 使索引从1开始
for (int i = 1; i <= len; i++) {
memset(g, 0, sizeof(g)); // 重置临时数组
for (int j = 0; j < p; j++) {
int t = (j * 10 + s[i] - '0') % p;
g[t] += f[j]; // 状态转移
}
g[(s[i] - '0') % p]++; // 单字符子串
ans += g[0]; // 累计有效解
memcpy(f, g, sizeof(g)); // 更新状态
}
cout << ans << endl;
return 0;
}
3.4 复杂度分析
- 时间复杂度:O(L×p),其中L是字符串长度,p是模数。由于p≤128,对于L=1e6,总操作约1.28e8次,完全可行。
- 空间复杂度:O(p),仅需两个固定大小的数组。
4. 常见错误与调试技巧
4.1 典型错误案例
-
整数溢出:未使用long long导致大数计算溢出
- 错误表现:结果异常大或出现负数
- 解决方案:如代码所示使用#define int long long
-
索引错误:直接从0开始处理字符串
- 错误表现:漏算第一个字符或数组越界
- 解决方案:统一使用1-based索引(s = " " + s)
-
余数更新错误:忘记单独处理单字符子串
- 错误表现:样例输出为3(漏计"0"和"2")
- 解决方案:确保执行g[(s[i]-'0')%p]++
4.2 调试技巧
- 打印中间状态:
cpp复制printf("i=%d: ", i);
for(int j=0; j<p; j++)
if(f[j]) printf("f[%d]=%d ",j,f[j]);
cout << "ans=" << ans << endl;
- 小数据测试:
- 输入"2"和"12",正确输出应为3("1","12","2")
- 输入"3"和"123",正确输出应为3("12","123","3")
- 边界测试:
- p=128, S="128"(应输出2:"12","128")
- p=2, S="000"(应输出6:所有子串都符合)
5. 算法优化与变种思考
5.1 空间优化
可以只使用一个数组,通过滚动的方式更新状态:
cpp复制int dp[N] = {0};
for(int i=0; i<len; i++){
int num = s[i]-'0';
int temp[N] = {0};
temp[num%p] = 1;
for(int j=0; j<p; j++)
temp[(j*10+num)%p] += dp[j];
ans += temp[0];
memcpy(dp, temp, sizeof(temp));
}
5.2 变种问题
-
统计不同位置的相同子串:如果要求位置不同但数字相同的子串只计一次,需要哈希去重。
-
限制子串长度:如只统计长度≤K的子串,可以在状态中增加长度维度。
-
多模数查询:如果给定多个p需要查询,可以预处理所有可能p的结果。
6. 竞赛应用与扩展学习
这道题是动态规划与数论结合的典型例题,类似思想还出现在以下场景:
- 统计子序列和满足特定条件(如LeetCode 1074)
- 大数取模问题(如Project Euler 48)
- 字符串周期判断(KMP算法的next数组应用)
建议延伸练习:
- 洛谷P1045 麦森数(大数取模)
- Codeforces 919D - Substring(DP+图论)
- LeetCode 1397 - Find All Good Strings(数位DP)
在实际比赛中遇到此类问题时,我的经验是:
- 先写暴力解法确保理解题意
- 观察数据范围寻找优化方向
- 挖掘数学性质(特别是模运算性质)
- 设计状态转移时考虑时空复杂度平衡