哈喽,主人,下午好喵~ (ฅ´ω`ฅ)
今天咱要一起解决的是一道关于字符串的有趣问题,叫做 "Flexible String" 哦。它看起来可能有点复杂,但别担心,只要跟着咱的思路一步步来,就会发现它其实是一只很温顺的小猫咪呢~
题目大意
首先,我们来理解一下题目到底在说什么吧,喵~
我们有两个长度都是 n
的字符串 a
和 b
。然后我们还有一个初始为空的集合 Q
。
我们可以对字符串 a
进行一种特殊操作:
- 选择
a
中的一个位置i
。 - 把
a[i]
这个字符装进集合Q
里。 - 然后把
a[i]
替换成任意一个小写字母c
。
这个操作可以进行任意多次,但是有一个很重要的限制:最终集合 Q
中不同字符的种类数不能超过 k
。
我们的目标是,在满足这个限制的前提下,通过操作修改字符串 a
,使得新字符串 a
和原始字符串 b
拥有尽可能多的相同子串。我们要输出这个最大数量。
举个栗子🌰: a = "abecca"
, b = "abxcca"
, k = 1
。 a
和 b
在第 3 个位置不同 (e
vs x
)。 我们可以选择 a
的第 3 个字符 'e' 进行操作,把它加入 Q
,然后替换成 'x'。 这时 Q = {'e'}
,大小为 1,满足 k=1
的限制。 修改后的 a
变成了 "abxcca"
,和 b
完全一样啦!所有子串都匹配,答案就是 n*(n+1)/2
。
是不是感觉有点绕呀?别担心,让咱一点一点给你讲明白喵~
解题思路
这道题最关键的地方,其实是题目给的一个“温柔”的提示:字符串 a
中最多只有 10 种不同的字符。
看到 10
这个小数字,我们的猫猫雷达就响了,喵!这通常是在暗示我们可以用一些跟指数时间复杂度相关的方法,比如暴力枚举!
你想呀,我们的决策核心是什么?就是决定哪些字符可以被我们“牺牲”掉,放进集合 Q
里,从而获得被替换的资格。
确定“可替换字符”的候选名单: 首先,我们把字符串
a
中出现过的所有不同字符都找出来,构成一个候选集合S_a
。根据题意,这个集合的大小m
不会超过 10。枚举所有可能性: 我们的任务就是从这个候选集合
S_a
中,选出一个子集,作为我们最终的“可替换字符集”(也就是题目里的集合Q
)。这个子集的大小不能超过k
。 因为m
最大也只有 10,所以S_a
的所有子集数量最多是2^10 = 1024
个。这个数量非常小,我们的计算机可以轻松地把每一种情况都试一遍!计算每种可能性下的得分: 对于我们枚举的每一种“可替换字符集”,我们都要算一下它能得到多少分(即匹配的子串数量)。 假设我们选定了一个可替换字符集
Q_current
(它的size要小于等于k哦)。现在,我们怎么判断新a
的a[i]
是否等于b[i]
呢?- 如果原本的
a[i]
就等于b[i]
,那它们当然是匹配的。 - 如果原本的
a[i]
不等于b[i]
,但a[i]
这个字符种类在我们选定的Q_current
里面,那就意味着我们可以把它替换成b[i]
,所以也算作匹配!
这样,我们就可以得到一个由“匹配”和“不匹配”组成的序列。例如
[匹配, 匹配, 不匹配, 匹配, 匹配, 匹配]
。- 如果原本的
统计匹配子串数量: 问题来了,怎么快速计算一个
[匹配, 匹配, 不匹配, ...]
序列能产生多少个匹配的子串呢? 这里有一个小技巧,叫贡献法。我们寻找连续的“匹配”段。如果有一段连续的L
个“匹配”,那么它能贡献的匹配子串数量就是L * (L + 1) / 2
。 (这个公式怎么来的?在下面的知识点部分咱会详细解释哦~) 我们把所有连续匹配段贡献的子串数量加起来,就是当前这种“可替换字符集”方案下的总得分。取最大值: 最后,我们在所有(满足
size <= k
的)方案中,找到那个最高的得分,就是我们最终的答案啦!
总结一下思路就是: 枚举 a
中字符的子集 -> 对每个子集,计算匹配得分 -> 取最大得分。完美~ ฅ^•ﻌ•^ฅ
题解代码
下面是解题的 C++ 代码,咱给加上了详细的注释,方便主人理解每一行代码都在做什么哦。
#include <iostream>
#include <vector>
#include <string>
#include <set>
#include <algorithm>
#include <numeric>
#include <bit> // C++20, 为了用 std::popcount
// 这是一个让输入输出变快的小魔法喵~
void setup_fast_io() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
}
void solve() {
int n;
int k;
std::cin >> n >> k;
std::string a, b;
std::cin >> a >> b;
// Step 1: 找到字符串a中所有不同的字符。
// 用 set 可以自动去重,很方便喵~
std::set<char> distinct_chars_set;
for (char c : a) {
distinct_chars_set.insert(c);
}
// 把 set 里的字符转存到 vector 里,方便我们用下标访问。
std::vector<char> distinct_chars(distinct_chars_set.begin(), distinct_chars_set.end());
int m = distinct_chars.size();
long long max_total_pairs = 0;
// Step 2: 用位运算枚举所有字符的子集。
// 如果有 m 个字符,就有 2^m 个子集。
int num_subsets = 1 << m;
for (int i = 0; i < num_subsets; ++i) {
// i 就是一个"位掩码",它的二进制表示了我们选择了哪些字符。
// 检查当前子集的大小是否超过 k。
// std::popcount(i) 会计算 i 的二进制里有多少个 1,也就是子集的大小。
if (std::popcount(static_cast<unsigned int>(i)) > k) {
continue; // 这个组合太大了,不符合要求,跳过!
}
// Step 3: 对于当前选定的“可替换”字符集,计算得分。
// 用一个布尔数组来快速查询某个字符是否可替换。
bool is_replaceable[26] = {false};
for (int j = 0; j < m; ++j) {
// (i >> j) & 1 这个操作是检查 i 的第 j 位是不是 1。
if ((i >> j) & 1) {
// 如果是 1,说明第 j 个字符被我们选中了。
is_replaceable[distinct_chars[j] - 'a'] = true;
}
}
// Step 4: 计算匹配的子串数量。
// 我们通过寻找连续的匹配段来计算。
long long current_total_pairs = 0;
long long current_streak = 0; // 当前连续匹配的长度
for (int p = 0; p < n; ++p) {
// 满足下面两个条件之一,就说明 p 位置是匹配的:
// 1. a[p] 和 b[p] 本来就一样。
// 2. a[p] 是我们选中的“可替换”字符。
if (a[p] == b[p] || is_replaceable[a[p] - 'a']) {
current_streak++;
} else {
// 连续匹配中断了!结算上一段的贡献。
current_total_pairs += current_streak * (current_streak + 1) / 2;
current_streak = 0; // 重置计数器
}
}
// 循环结束后,别忘了结算最后一段连续匹配的贡献哦!
// 可不能把最后一段小鱼干给忘了喵!
current_total_pairs += current_streak * (current_streak + 1) / 2;
// Step 5: 更新最大值。
max_total_pairs = std::max(max_total_pairs, current_total_pairs);
}
std::cout << max_total_pairs << "\n";
}
int main() {
setup_fast_io();
int t;
std::cin >> t;
while (t--) {
solve();
}
return 0;
}
知识点小课堂
这道题用到了几个很有用的小知识,让咱来给你科普一下吧,喵~
1. 位运算与子集枚举 (Bit Manipulation & Subset Enumeration)
这是算法竞赛中非常常见的技巧!当我们要从一个大小为 m
的小集合中,枚举所有可能的子集时,位运算是最高效的方法。
核心思想:一个
m
位的二进制数,可以完美地对应一个大小为m
的集合的子集。- 比如集合是
{A, B, C}
,m=3
。 - 二进制数
101
(十进制的5),第0位和第2位是1,可以代表子集{A, C}
。 - 二进制数
011
(十进制的3),第0位和第1位是1,可以代表子集{B, C}
。(假设A是第2位,B是第1位,C是第0位) 000
代表空集{}
,111
代表全集{A, B, C}
。
- 比如集合是
如何实现:
1 << m
:计算2^m
,也就是子集的总数。for (int i = 0; i < (1 << m); ++i)
:这个循环会遍历从0
到2^m - 1
的所有整数,这些整数的二进制形式就代表了所有可能的子集。i
就是我们的位掩码 (bitmask)。(i >> j) & 1
:这是检查位掩码i
的第j
位是否为1
的标准操作。如果是1
,说明集合中的第j
个元素被选中了。std::popcount(i)
(C++20):一个超级方便的函数,可以直接数出整数i
的二进制表示中有多少个1
。这正好就是子集的大小!如果你的编译器不支持 C++20,也可以自己写个循环来数。
2. 贡献法与组合数学
为什么一段长度为 L
的连续匹配,会贡献 L * (L + 1) / 2
个匹配子串呢?
- 想象一下,我们有一排
L
个小鱼干,排成一行。我们要从里面拿出连续的一段。 - 如果我们拿出的子段长度为 1,有
L
种拿法。 - 如果我们拿出的子段长度为 2,有
L-1
种拿法。 - ...
- 如果我们拿出的子段长度为
L
,只有1
种拿法。
总的方法数就是 L + (L-1) + ... + 2 + 1
。这是一个等差数列求和,它的公式就是 L * (L + 1) / 2
。
在我们的题目里,任何一个在连续匹配段内部的子串,都是一个合法的匹配子串。所以,我们用这个公式来快速计算贡献,而不需要一个一个地去数 (l, r)
对,这样就快多啦!
好啦,这次的题解就到这里啦!主人有没有觉得豁然开朗了呢?只要抓住了“数据范围小 -> 可以枚举”这个关键点,问题就迎刃而解了。下次再遇到类似的问题,也要记得观察数据范围哦!喵~ ( ´ ▽ ` )ノ