1. 2022ICPC杭州站五道算法题深度解析
作为参加过多次ICPC竞赛的老队员,我深知区域赛题目对算法和数据结构的考察既全面又深入。本文将详细拆解2022ICPC杭州站的K、A、C、G、M五道题目,从问题分析到代码实现,分享我的解题思路和实战经验。
2. K - Master of Both:Trie树与逆序对统计
2.1 问题重述
给定n个小写字母字符串和q个不同的字母表顺序,要求计算在每个字母表顺序下,字符串序列的逆序对数量。逆序对定义为满足i < j且字符串j字典序小于字符串i的对(i,j)。
2.2 核心思路
这道题的难点在于需要处理不同字典序下的逆序对统计。直接对每个查询重新计算显然不可行,时间复杂度会达到O(q*n²)。我们需要找到更高效的方法。
关键观察:
- 逆序对的数量只取决于字符之间的相对顺序
- 可以预先统计所有可能的字符对(c1,c2)的出现次数
- 对于每个查询,只需要根据给定的字母表顺序,组合这些预计算的字符对信息
2.3 实现细节
使用Trie树来高效统计字符对出现次数:
c++复制int mp[27][27]; // 记录字符对<c1,c2>的出现次数
const int N = 1e6 + 5;
int ch[N][27], cnt[N], idx, del;
void insert(string& s) {
int len = s.length() - 1, p = 0;
rep(i, 0, len) {
int j = getnum(s[i]);
if (!ch[p][j]) ch[p][j] = ++idx;
rep(k, 0, 25) {
if (k == j) continue;
mp[j][k] += cnt[ch[p][k]]; // 统计逆序对
}
p = ch[p][j];
cnt[p]++;
}
// 处理前缀情况
rep(k, 0, 25) {
del += cnt[ch[p][k]];
}
}
2.4 查询处理
对于每个查询,根据给定的字母表顺序计算逆序对总数:
c++复制rep(i, 1, q) {
string s; cin >> s;
int ans = 0;
vector<int> id(27);
rep(i, 0, 25) {
id[s[i] - 'a'] = i;
}
rep(x, 0, 25) {
rep(y, 0, 25) {
if (x == y) continue;
if (id[x] < id[y]) ans += mp[x][y];
}
}
cout << ans + del << '\n';
}
2.5 复杂度分析
- 预处理:O(nL26),L为字符串平均长度
- 查询:O(q*26²)
- 空间:O(n*26)
3. A - Modulo Ruins the Legend:数论与扩展欧几里得
3.1 问题描述
给定整数序列a1...an,选择两个整数s和d,对每个ak加上s+k*d,使得总和模m最小。
3.2 数学建模
我们需要最小化:(sum + Bs + Cd) mod m,其中B=n,C=n(n+1)/2。
设x为最小结果,则有:
(sum + Bs + Cd) ≡ x (mod m)
即存在k1使得:
Bs + Cd = k1*m + x - sum
3.3 解法思路
使用扩展欧几里得算法求解不定方程。设g1=gcd(B,C),方程有解的条件是:
x-sum ≡ 0 (mod gcd(g1,m))
因此x的最小值为sum mod gcd(g1,m)。
3.4 代码实现
c++复制void exgcd(int a, int b, int& x, int& y) {
if (b == 0) { x = 1, y = 0; return; }
exgcd(b, a % b, y, x);
y -= a / b * x;
}
void solve() {
int n, m; cin >> n >> m;
int sum = 0;
rep(i, 1, n) { int x; cin >> x; sum += x; }
int A = sum % m, B = n % m, C = (n*(n+1)/2) % m;
int g1 = __gcd(B, C), g2 = __gcd(m, g1);
int x = (A / g2) * g2; // 最小非负解
if (x > A) x -= g2;
int k0 = (x - A) / g2;
int x0, y0;
exgcd(m, g1, x0, y0);
int k1 = k0 * x0, k2 = k0 * y0;
exgcd(B, C, x0, y0);
int s = k2 * x0, d = k2 * y0;
s = (s % m + m) % m;
d = (d % m + m) % m;
cout << x << '\n' << s << " " << d << '\n';
}
3.5 注意事项
- 解可能为负数,需要调整到[0,m-1]范围
- 多个解时取使x最小的解
- 所有运算在模m意义下进行
4. C - No Bug No Game:动态规划与状态设计
4.1 问题描述
有n个物品,每个物品有基础值pi和强化值wi,j。可以选择强化总计不超过k的基础值。物品按顺序处理,根据已强化的总量决定当前物品的强化值。
4.2 动态规划设计
状态设计:
dp[sum][0/1]表示已强化sum点,是否已经出现过转折点(即sum≥k的情况)
状态转移:
- 不选当前物品
- 完全强化当前物品
- 部分强化当前物品(作为转折点)
4.3 代码实现
c++复制void solve() {
int n, k; cin >> n >> k;
vector<vector<int>> w(n + 1);
vector<int> p(n + 1);
rep(i, 1, n) {
cin >> p[i];
w[i].resize(p[i] + 1);
rep(j, 1, p[i]) cin >> w[i][j];
}
vector<array<int, 2>> dp(k + 1, {-inf, -inf});
dp[0][0] = 0;
rep(i, 1, n) {
auto ndp = dp;
rep(V, 0, k) {
// 作为转折点
rep(add, 1, p[i]) {
if (V + add > k) break;
chmax(ndp[V + add][1], dp[V][0] + w[i][add]);
}
// 完全强化
if (V + p[i] <= k) {
chmax(ndp[V + p[i]][0], dp[V][0] + w[i][p[i]]);
chmax(ndp[V + p[i]][1], dp[V][1] + w[i][p[i]]);
}
}
dp = ndp;
}
int ans = max(*max_element(dp[0].begin(), dp[0].end()),
*max_element(dp[k].begin(), dp[k].end()));
cout << ans << '\n';
}
4.4 优化技巧
- 使用滚动数组优化空间
- 及时剪枝,避免无效状态转移
- 最终答案可能在sum=0或sum=k处取得
5. G - Subgraph Isomorphism:树同构与哈希
5.1 问题描述
判断给定的连通图中,所有生成树是否同构。
5.2 解题思路
- 如果m=n-1,本身就是树,输出YES
- 如果m>n,存在多个环,输出NO
- 对于基环树,检查环上的子树结构是否对称:
- 所有子树相同
- 或者AB交替且环长为偶数
5.3 树哈希实现
c++复制ull shift(ull x) {
x ^= P;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x ^= P;
return x;
}
void dfs(int u, int fa) {
h[u] = 1;
for (auto& son : e[u]) {
if (son == fa || !vis[son] || vis[son] == 2) continue;
dfs(son, u);
h[u] += shift(h[son]);
}
}
5.4 对称性检查
c++复制bool flag1 = true, flag2 = true;
rep(i, 2, id.size() - 1) {
if (h[id[i]] != h[id[i-1]]) flag1 = false;
}
rep(i, 3, id.size() - 1) {
if (h[id[i]] != h[id[i-2]]) flag2 = false;
}
if ((id.size()-1) % 2) flag2 = false;
6. M - Please Save Pigeland:树形DP与换根技巧
6.1 问题描述
在树中选择一个根节点r和参数d,使得所有特殊节点到r的路径长度之和除以d最小。
6.2 关键观察
- d必须是所有特殊节点到r距离的gcd
- 问题转化为找到r使得sum/gcd最小
- 使用换根DP高效计算所有可能r的结果
6.3 换根DP实现
c++复制void dfs(int u, int fa) {
if (tag[u]) cnt[u] = 1;
for (auto [w, son] : e[u]) {
if (son == fa) continue;
dfs(son, u);
cnt[u] += cnt[son];
sum[u] += sum[son] + cnt[son] * w;
}
}
void dfs2(int u, int fa) {
for (auto [w, son] : e[u]) {
if (son == fa) continue;
sum[son] = sum[u] - cnt[son] * w + (k - cnt[son]) * w;
// 更新gcd信息
dfs2(son, u);
}
}
6.4 线段树维护GCD
c++复制void build(int p, int l, int r) {
if (l == r) { G[p] = abs(b[l]); return; }
build(ls, l, mid);
build(rs, mid+1, r);
pushup(p);
}
void update(int p, int l, int r, int pos, int val) {
if (l == r) { b[l] += val; G[p] = abs(b[l]); return; }
if (pos <= mid) update(ls, l, mid, pos, val);
else update(rs, mid+1, r, pos, val);
pushup(p);
}
7. 参赛经验与技巧分享
-
代码模板准备:提前准备好常用算法模板,如快速幂、并查集、线段树等,可以节省大量编码时间。
-
调试技巧:
- 使用assert验证关键假设
- 对边界情况单独测试
- 输出中间结果辅助调试
-
时间分配策略:
- 先解决思路清晰的题目
- 难题可以先写暴力解法保底
- 留出足够时间检查边界条件
-
团队协作:
- 明确分工,避免重复劳动
- 及时交流思路进展
- 共享调试信息
在实际比赛中,我通常会先花10-15分钟快速浏览所有题目,评估难度和解题思路,然后按照从易到难的顺序解决。对于这类综合性强的题目,关键在于快速识别问题本质并选择合适的数据结构和算法。