洛谷P3741题解:贪心与枚举结合,高效求解字符串VK子串最大化问题

📅 2026/7/21 5:10:04
洛谷P3741题解:贪心与枚举结合,高效求解字符串VK子串最大化问题
1. 项目概述从一道题看字符串处理的巧思最近在洛谷上刷题又碰到了那道经典的P3741。题目名字听起来就挺有意思的——“VK”乍一看还以为是某个社交平台其实不然。这道题的核心是给定一个仅由大写字母V和K组成的字符串允许你进行一次操作将字符串中任意一个字符修改成V或K。你的目标是通过这次修改让最终字符串中“VK”这个子串出现的次数最大化。这可不是一个简单的暴力枚举就能轻松解决的问题。字符串长度最大能到100如果直接枚举每个位置改成V或K再从头到尾扫描统计“VK”的出现次数理论复杂度是O(n²)对于100的数据量虽然能过但思路显得笨拙缺乏算法美感。更重要的是它没有触及问题的本质。这道题真正的价值在于它逼迫我们去深入思考字符串的局部结构与整体统计量之间的关系如何通过一次微小的扰动来撬动整个计数结果的最大化。这背后蕴含的是一种典型的“贪心”与“枚举”结合的思维也是很多字符串优化问题的缩影。无论是准备信息学竞赛的新手还是想巩固基础算法的开发者这道题都能给你带来不少启发。2. 核心思路拆解为什么不能直接暴力我们先来想想最直观的做法。给定一个字符串比如VKKVK。我们允许改变其中一个字符那么就有长度 * 2种可能的修改方案每个位置可以改成V或K。对于每一种修改后的新字符串我们写一个循环去统计其中“VK”子串的数量最后取最大值。这个思路直接、清晰对于学习编程不久的朋友来说是很好的练习。但是如果我们停下来分析一下就会发现其中有大量的重复计算和无效枚举。比如一个字符串S我们修改了位置i新的字符串S和原字符串S可能只差一个字符。然而我们的统计函数却要重新遍历整个S‘来数“VK”。这相当于每次枚举都做了一次O(n)的扫描。其次并不是所有的修改都是有效的。把一个字符改成和它本身一样的字母这次操作就浪费了但我们的枚举仍然会覆盖这种情况。最重要的是这种暴力法没有利用“VK”子串的结构特性。“VK”的出现依赖于相邻两个字符的配对修改一个字符最多只会影响它自身参与的两个配对即S[i-1]和S[i]组成的配对以及S[i]和S[i1]组成的配对。而暴力法却检查了整个字符串这无疑是“杀鸡用牛刀”。所以我们需要一个更聪明的算法。核心思路应该围绕这一点展开一次修改其影响范围是局部的我们只需要计算这次修改带来的“VK”数量的增量变化而不是重新计算全局总数。这就是优化算法的关键突破口。2.1 算法设计预处理与增量计算基于上述分析我们可以设计一个O(n)时间复杂度的算法。步骤如下预处理原始数量首先在不做任何修改的情况下遍历一遍原字符串统计出原始“VK”子串的数量记为base_count。这是我们的基准值。枚举修改位置接着我们枚举每一个可以修改的位置i(从0到n-1)。分析影响范围对于位置i它的修改会影响哪些“VK”的计数呢只可能影响以i-1为开头、i为结尾的配对即子串S[i-1]S[i]以及以i为开头、i1为结尾的配对即子串S[i]S[i1]。更远的配对比如S[i-2]S[i-1]因为不涉及字符S[i]所以不会受到影响。计算增量我们先计算修改前这两个受影响的配对贡献了多少个“VK”。即检查S[i-1]S[i]是否为 “VK”以及S[i]S[i1]是否为 “VK”。然后我们模拟将S[i]修改成另一个字符因为改成相同字符无意义我们只考虑改成V或K中与原来不同的那个。得到一个新的临时字符new_char。再计算修改后新的配对S[i-1] new_char和new_char S[i1]是否为 “VK”。增量delta (修改后两个配对产生的“VK”数) - (修改前两个配对产生的“VK”数)。更新答案对于每个位置i可能的答案就是base_count delta。我们遍历所有i取最大值。但这里有一个极其关键的陷阱我们能否通过一次修改让base_count增加超过1思考一下修改一个字符最多能让几个新的“VK”产生又可能让几个原有的“VK”消失这是本题最精妙的地方也是下面要重点讨论的。2.2 贪心策略与边界情况根据上面的增量计算我们会发现一个有趣的现象。对于大多数位置delta的值只能是 -1, 0, 或者 1。delta 1: 意味着我们通过修改净增加了一个“VK”。例如原串是VV将第二个V改成K得到VK增加了一个。delta 0: 修改后不增不减。例如原串是VK把K改成V得到VV失去了一个原有的“VK”但没能产生新的。delta -1: 修改后反而减少了一个。例如原串是VK把V改成K得到KK失去了一个。那么是否存在delta 2的情况呢这意味着一次修改创造了两个新的“VK”。这需要满足什么条件修改位置i后要同时让S[i-1]S[i]和S[i]S[i1]都变成 “VK”。即修改前S[i-1]是VS[i1]是K而无论S[i]原来是什么我们把它改成K就能让左边配对成“VK”把它改成V就能让右边配对成“VK”。我们无法同时满足两边。所以一次修改不可能直接增加2个“VK”。但是有一种间接情况考虑字符串VVK。原始的“VK”数量是1位于最后两个字符。如果我们把中间的V改成K字符串变为VKK。此时原有的“VK”KK不是VK消失了但新的“VK”也没有产生。delta -1。这显然不是最优。最优解是修改第一个字符吗也不是。让我们跳出“单个位置增量”的思维。题目只要求最终的“VK”数最多并不关心修改过程。我们看这个例子VVK。如果我们把第三个字符K改成V得到VVV一个“VK”都没有了更差。似乎无解等等我们再看一个例子VKK。原始“VK”数为1第一个配对。如果我们把中间的K改成V得到VVK这时“VK”数还是1最后一个配对。增量是0。有没有办法让VVK或VKK的“VK”数变成2如果我们能通过一次修改创造出两个不相邻的“VK”呢这在上述局部增量分析中是无法捕捉的因为我们的分析只考虑了相邻配对。但事实上一次修改一个字符确实可能通过“连锁反应”影响更多。不仔细想想“VK”必须是相邻的字符对。修改一个字符不可能同时创造出两个不相邻的“VK”因为这两个“VK”会共享被修改的字符吗不会它们不相邻。所以不可能。那么真正的突破口在哪里在于修改可能消除一个阻碍从而允许一个早已存在的“VK”被统计或者为后续的配对创造条件吗不我们的统计是全局的、一次性的。不存在“早已存在但不被统计”的VK。让我们回归本质。我们最终要求的是max(base_count delta_i)。如果所有delta_i都小于等于0那答案就是base_count吗不一定因为base_count可能本身就有提升空间但我们的delta计算是“净增量”。如果原串是VVVbase_count0。把第二个字符改成K得到VKV产生了两个配对VK和KV。其中VK是一个有效的子串。所以delta1。答案就是1。但是是否存在一种情况使得base_count delta_i能够大于base_count 1即是否存在通过一次修改让最终结果比原始数量多2个或以上从局部增量看delta最大为1。所以答案的理论上限似乎是base_count 1。然而这就是本题最大的思维陷阱也是贪心策略需要验证的地方。考虑这个例子原串KVK。我们来手动分析base_count: 检查KV不是VK是。所以base_count 1。枚举修改改位置0 (K): 可以改成V。新串VVK。配对VV不是VK是。数量为1。delta 0。改位置1 (V): 可以改成K。新串KKK。数量为0。delta -1。改位置2 (K): 可以改成V。新串KVV。配对KV不是VV不是。数量为0。delta -1。最大结果是base_count 0 1。似乎确实无法突破base_count 1。但让我们找一个更特殊的例子VKV。base_count:VK是KV不是。所以base_count 1。枚举修改改位置0 (V): 改成K新串KKV。KK不是KV不是。数量0。delta -1。改位置1 (K): 改成V新串VVV。数量0。delta -1。改位置2 (V): 改成K新串VKK。VK是KK不是。数量1。delta 0。最大结果还是1。到目前为止所有例子都支持ans base_count或ans base_count 1。那么是不是答案就是base_count和base_count1中的最大值呢我们只需要判断是否存在一个位置修改后能使得delta 1。如果存在答案就是base_count1否则就是base_count。这个贪心策略正确吗我们需要考虑一种边界情况原字符串中是否已经存在连续的“VK”比如VKVKbase_count2。我们能否通过一次修改得到3个“VK”把中间的V改成K得到VKKK数量为1。不行。把某个K改成V似乎也不行。看起来无法突破。但是还有一种更隐蔽的情况原字符串中是否存在“VV”或者“KK”这样的相邻对通过修改其中一个字符可以产生一个“VK”。例如VV修改第二个字符为K得到VKdelta1。KK修改第一个字符为V得到VKdelta1。这都在我们的delta计算范围内。那么有没有可能delta0但通过修改我们重新排列了“VK”的位置从而使得总数不变但为后续计算提供了便利不题目只统计最终状态没有后续。所以基于以上分析一个正确的算法应该是计算原始base_count。尝试贪心遍历字符串寻找是否存在一个位置i使得通过修改S[i]能净增加一个“VK”。如果找到答案就是base_count 1。但是这里必须考虑一个特殊情况如果base_count已经最大即字符串中所有可能的相邻对都已经是“VK”或者任何修改都只会破坏现有的“VK”而不能创造新的那么答案就是base_count。然而我们之前的delta计算已经涵盖了“净增加”的判断。我们只需要遍历所有位置计算每个位置修改后的delta然后取base_count max_delta即可其中max_delta是所有delta中的最大值。由于delta最大为1所以答案就是base_count或base_count1。但这里还有一个终极陷阱让我们看这个例子VKK。base_count1第一个配对VK。计算每个位置的deltai0: 原V改K。新串KKK。影响配对原VK(是)消失新KK(否)产生。delta 0 - 1 -1。i1: 原K改V。新串VVK。影响配对原左VK(是)消失原右KK(否)不变新左VV(否)产生新右VK(是)产生。delta (01) - (10) 0。i2: 原K改V。新串VKV。影响配对原左KK(否)不变新左KV(否)产生。delta 0。max_delta 0。所以按算法ans base_count 0 1。但是有没有可能答案是2呢如果我们把VKK改成VKV呢这需要修改两个字符不符合题意。所以不行。那么是否存在一个例子使得base_count max_delta这个公式失效考虑字符串长度仅为1的情况比如V。base_count0没有相邻对。任何修改都无法产生“VK”因为至少需要两个字符。所以max_delta0ans0。正确。考虑全V或全K的字符串长度n2。例如VVV。base_count0。修改中间字符为K得到VKV。此时配对VK出现一次。delta1。ans1。正确。看起来万无一失。但我们必须警惕一种情况修改操作可能同时破坏一个现有的“VK”并创造一个新的“VK”但创造的位置和破坏的位置不同导致我们的局部增量计算delta仍然为0但实际上全局来看VK的总数可能增加了这听起来矛盾。让我们构造一个场景假设原串有一个“VK”在位置(2,3)。我们修改位置1的字符这个修改破坏了位置(1,2)的一个潜在“VK”吗不位置(1,2)原来可能不是“VK”。修改后可能在位置(1,2)创造了一个新的“VK”同时因为位置1字符变了它影响了位置(0,1)的配对但位置(0,1)原来也不是“VK”。所以这仍然只影响两个配对。如果新创造了一个“VK”而破坏的配对原来不是“VK”那么delta就是1。如果破坏了一个原有的“VK”同时创造了一个新的“VK”那么delta就是0。我们的计算是准确的。因此最终的算法可以简化为统计原串中“VK”的数量记为cnt。将原串转换为字符数组方便修改。初始化一个布尔变量can_increase false。遍历每个位置i(0到n-1)保存原字符original_char。对于两种可能的修改改成V或K不包括改成自己修改字符。统计新字符串中“VK”的数量。注意这里必须全局统计而不是只计算局部增量。为什么因为虽然我们分析了局部增量在理论上是完备的但为了代码的清晰和避免复杂的边界条件判断例如字符串开头和结尾直接全局统计更为稳妥。由于n100全局统计的代价O(n)在可接受范围内且代码更易写、易读。如果新的数量大于cnt则更新can_increase true。恢复i位置的字符为original_char。如果can_increase为真最终答案就是cnt 1否则就是cnt。这个算法的时间复杂度是 O(n²)因为对于每个位置n个我们进行两次修改每次修改后需要O(n)的时间来统计“VK”数量。对于n100计算量是100 * 2 * 100 20000次操作完全在合理范围内。它比纯粹的O(n³)暴力枚举枚举位置、枚举修改值、枚举统计更优也避免了复杂且容易出错的局部增量推导。注意虽然我们进行了大量的理论分析来推导贪心策略答案最多是cnt或cnt1但在实际编码竞赛中采用这种O(n²)的“模拟全局统计”方法更为保险和直观。它减少了思维难度降低了出错概率是一种典型的“以空间换时间思维时间”的策略。3. 代码实现与逐行解析理论分析完毕接下来我们动手实现。我们将使用C代码会力求清晰、健壮并包含详细的注释。#include iostream #include string #include algorithm using namespace std; int countVK(const string s) { int cnt 0; // 注意循环范围是 i 从 0 到 s.length()-2 // 因为我们要检查 s[i] 和 s[i1] for (int i 0; i 1 s.length(); i) { if (s[i] V s[i1] K) { cnt; } } return cnt; } int main() { int n; string s; cin n s; // 读取字符串长度和字符串本身 // 1. 计算原始字符串中VK的数量 int original_cnt countVK(s); int max_cnt original_cnt; // 初始化最大值为原始数量 // 2. 枚举每个位置进行修改尝试 for (int i 0; i n; i) { char original_char s[i]; // 保存原字符以便后续恢复 // 尝试修改为V如果原字符不是V if (original_char ! V) { s[i] V; max_cnt max(max_cnt, countVK(s)); s[i] original_char; // 恢复 } // 尝试修改为K如果原字符不是K if (original_char ! K) { s[i] K; max_cnt max(max_cnt, countVK(s)); s[i] original_char; // 恢复 } } // 3. 输出结果 cout max_cnt endl; return 0; }代码解析与关键点countVK函数这个函数负责统计给定字符串中“VK”子串的数量。注意循环条件i 1 s.length()这确保了s[i1]是有效的下标防止数组越界。这是处理字符串时常见的边界检查。输入处理直接使用cin n s;读取。题目保证输入格式正确。核心枚举逻辑original_cnt存储了不修改任何字符时的基础数量。max_cnt初始化为original_cnt代表当前找到的最大值。遍历每个位置i。char original_char s[i];保存当前位置的原始字符。这是非常关键的一步因为我们在尝试修改后需要将字符串恢复原状才能进行下一次独立的尝试。如果不恢复修改会累积导致后续计算错误。尝试修改成V只有当原字符不是V时才需要尝试因为改成相同的字符没有意义。修改后调用countVK计算新数量并更新max_cnt。然后立即恢复原字符。尝试修改成K逻辑同上。为什么分别尝试V和K因为题目允许修改成任意大写字母但字符串只由V和K组成所以有效的修改就是改成另一种字符。如果原字符是V就只尝试改成K如果是K就只尝试改成V。我们的if条件正好实现了这一点。时间复杂度外层循环 O(n)内层每次修改后调用countVK是 O(n)。所以总复杂度 O(n²)。对于 n ≤ 100非常高效。空间复杂度只使用了输入字符串和一些变量是 O(n)。这个实现直接、暴力在n很小的情况下但正确性显而易见避免了复杂逻辑推导可能带来的错误。3.1 优化版本基于贪心理论的实现虽然上面的模拟法已经足够好但我们也可以实现之前推导出的贪心理论版本即答案最多是original_cnt或original_cnt1。我们只需要判断是否存在一个位置修改后能净增加一个“VK”。这个版本的代码更简洁常数更小。#include iostream #include string using namespace std; int main() { int n; string s; cin n s; int cnt 0; // 统计原始VK数量 for (int i 0; i 1 n; i) { if (s[i] V s[i1] K) { cnt; } } // 关键判断能否通过一次修改增加一个VK // 情况1存在VV或KK可以通过修改中间来产生一个VK // 情况2存在VK但可以通过修改其旁边字符来“挪动”位置并净增不这通常会导致delta0或-1。 // 更通用的判断方法是遍历字符串检查是否存在一个位置i修改s[i]后全局VK数量大于cnt。 // 但根据理论我们只需要检查是否能达到cnt1。 // 一个充分条件是存在一个位置i使得修改s[i]后新字符串中VK数量 cnt。 // 我们可以简化这个检查因为n很小可以直接用模拟法中的逻辑但提前退出。 // 这里我们采用一个更直接的贪心检查 bool can_improve false; // 复制一份字符串用于尝试修改 string t s; for (int i 0; i n; i) { char backup t[i]; // 尝试改成V if (backup ! V) { t[i] V; int new_cnt 0; for (int j 0; j 1 n; j) { if (t[j] V t[j1] K) new_cnt; } if (new_cnt cnt) { can_improve true; break; // 找到一种改进方案即可退出 } t[i] backup; // 恢复 } // 尝试改成K if (backup ! K) { t[i] K; int new_cnt 0; for (int j 0; j 1 n; j) { if (t[j] V t[j1] K) new_cnt; } if (new_cnt cnt) { can_improve true; break; } t[i] backup; // 恢复 } } if (can_improve) { cout cnt 1 endl; } else { cout cnt endl; } return 0; }这个版本在逻辑上更贴近我们最初的贪心分析。它先计算原始数量cnt然后尝试寻找一个能增加数量的修改。一旦找到就标记can_improve为真并跳出循环。最坏情况下仍然需要遍历所有位置但平均来看可能更早结束。对于本题的规模两种实现方式在时间上差异微乎其微。第一种“模拟取最大值”的写法更为常见和通用。4. 常见问题与调试技巧即使有了清晰的思路和代码在实际实现和调试中还是会遇到一些典型问题。下面我总结几个自己踩过的坑和解决方法。4.1 数组越界访问这是最常犯的错误之一尤其是在countVK函数中。循环统计“VK”时必须确保访问s[i1]是合法的。错误示例for (int i 0; i s.length(); i) { // 当i是最后一个字符时s[i1]越界 if (s[i] V s[i1] K) cnt; }正确做法for (int i 0; i 1 s.length(); i) { // 确保 i1 在范围内 if (s[i] V s[i1] K) cnt; }或者for (int i 0; i s.length() - 1; i) { // 效果相同 if (s[i] V s[i1] K) cnt; }注意s.length()返回的是size_t类型无符号整数当字符串为空时s.length()-1会下溢变成一个很大的正数导致循环出错。虽然本题保证n1但养成好习惯使用i1 s.length()更为安全。4.2 修改后未恢复原状在枚举每个位置的修改时我们必须保证每次尝试都是独立的。如果在尝试修改位置i为V后没有把字符改回去就直接尝试修改为K或者去尝试下一个位置i1那么字符串的状态就被污染了后续计算都是基于一个被多次修改的、错误的状态。错误示例for (int i 0; i n; i) { s[i] V; // 修改了 // ... 计算 // 没有恢复 s[i]接着可能又修改 s[i] 为 K或者进入下一轮循环修改 s[i1] }正确做法如参考代码所示在每次尝试修改前保存原字符并在本次尝试计算完成后立即恢复。char original_char s[i]; s[i] V; // ... 计算新数量 s[i] original_char; // 恢复4.3 对“一次操作”的理解偏差题目明确说“可以进行一次操作即把其中的一个字母修改为另一个字母”。这意味着你必须进行恰好一次修改。不能不改也不能修改多次。但在我们的算法中枚举所有可能的单次修改并取最大值自然涵盖了“必须修改一次”的要求。因为即使不改即修改成相同字符可能结果更优但那种情况下的结果就是original_cnt它已经被包含在初始的max_cnt中了。修改的字母可以变成V或K。我们的代码中通过if (original_char ! V)和if (original_char ! K)来避免无意义的相同修改是符合题意的。4.4 贪心策略的验证不充分如果你选择实现贪心版本判断是否能1必须用多种测试用例验证。以下是一些关键的测试用例可以用来检验你的算法基础用例1VK- 原始cnt1。无法增加任何修改都会破坏现有的VK。答案应为1。基础用例2VV- 原始cnt0。修改第二个字符为K得到VKcnt1。答案应为1。基础用例3KK- 原始cnt0。修改第一个字符为V得到VKcnt1。答案应为1。基础用例4V- 原始cnt0。无法产生VK。答案应为0。基础用例5KV- 原始cnt0。修改第一个字符为V得VVcnt0修改第二个为K得KKcnt0。答案应为0。稍复杂用例VKKV- 原始cnt1第一个VK。尝试修改改pos1(K-V):VVKV- cnt1 (第二个VK)改pos2(K-V):VKVV- cnt1 (第一个VK)改pos3(V-K):VKKK- cnt1 (第一个VK)似乎无法增加。答案应为1。特殊用例VKVK- 原始cnt2。任何修改似乎都会破坏一个VK而无法同时创造一个新的。答案应为2。长串用例VVVVVVVVVV(10个V) - 原始cnt0。修改任意一个非首尾的V为K例如改第5个得到VVVVKVVVVV会产生一个VK。答案应为1。将这些用例输入你的程序检查输出是否符合预期。这是调试和验证算法正确性的最有效方法。4.5 性能与可读性的权衡对于这道题n最大为100O(n²)的算法绰绰有余。在竞赛中代码的正确性和可读性往往比微小的性能优化更重要。因此我强烈推荐使用第一种“模拟全局统计”的代码。它逻辑直白不易出错即使是不熟悉贪心证明的读者也能看懂。如果你追求极致的代码简短也可以写成这样但可读性会下降#include iostream #include string #include algorithm using namespace std; int main(){ int n, ans0; string s; cinns; for(int i0;in;i) for(char c:{V,K}) if(s[i]!c){ string ts; t[i]c; int cnt0; for(int j0;j1n;j) cntt[j]Vt[j1]K; ansmax(ans,cnt); } coutansendl; }这段代码将枚举和统计压缩到了极简但对于初学者来说理解起来需要花费更多时间。在团队协作或个人练习中清晰的代码风格更有价值。5. 算法扩展与思维提升解决P3741这道题不仅仅是AC一道题目更是锻炼了一种重要的算法思维如何通过分析操作的影响范围将全局问题转化为局部问题从而设计出高效的算法。影响范围分析这是优化算法的核心。很多题目中一次操作修改、交换、删除等只影响整个数据的局部。识别出这个局部就能避免不必要的重复计算。在这道题中修改一个字符只影响包含该字符的两个相邻配对。贪心与枚举的结合我们通过理论分析得出了答案最多是cnt或cnt1的结论这本质上是一个贪心性质最优解不会比基础值多出超过1。但为了验证这一性质或者在不确信的情况下我们采用了枚举所有可能操作并评估结果的方法。这是一种“暴力枚举验证贪心”的常用技巧。模拟法的普适性当数据规模允许时比如n≤1000甚至n≤5000O(n²)的模拟法往往是竞赛中最保险的选择。它减少了复杂的推导降低了思维难度和出错率。在时间限制内清晰的O(n²)算法远胜于一个可能有bug的O(n)算法。字符串处理的技巧本题巩固了字符串遍历、字符修改、子串统计等基本操作。这些是处理更复杂字符串问题如动态规划、字符串匹配的基础。你可以尝试用类似的思路去解决其他问题例如变形1如果允许修改最多k个字符如何最大化“VK”的数量提示动态规划状态可以设计为dp[i][j][c]表示处理到前i个字符修改了j次且第i个字符是c时的最大VK数。变形2如果不是“VK”而是任意给定的长度为2的模式串例如“AB”算法需要改变吗基本不需要只需修改判断条件。变形3如果要求的是不相邻的“V”和“K”的对数即“V”在“K”前面即可不要求相邻一次修改一个字符如何最大化这会影响局部性可能需要重新分析。通过这道题希望你能体会到算法竞赛中的很多题目其优美之处不在于使用了多么高深的数据结构而在于对问题本质的深刻洞察和简洁高效的建模。从暴力枚举出发思考如何优化正是算法能力提升的必经之路。