ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

蓝桥杯国赛真题解析:字符串周期修改的贪心算法与实现

蓝桥杯国赛真题解析:字符串周期修改的贪心算法与实现 1. 项目概述从一道国赛真题看字符串处理的实战艺术最近在复盘蓝桥杯历届真题时2020年第十一届国赛的这道“重复字符串”题目让我印象颇深。它不像某些偏门的算法题那样刁钻而是非常扎实地考察了选手对C字符串操作、基础算法思想以及问题分解能力的掌握。题目本身描述简洁给定一个字符串你可以修改其中的任意字符目标是使其可以由一个长度为 k 的子串重复若干次得到。求最少需要修改的字符数。初看之下这题似乎有点“周期判断”的味道但细究起来它融合了枚举、贪心、频率统计等多个基础知识点是一道检验基本功是否牢靠的绝佳例题。很多同学在初次接触时容易陷入暴力枚举所有可能子串的误区导致时间复杂度爆炸。实际上这道题的解法非常巧妙其核心在于转换问题视角我们不需要知道具体是哪个子串在重复而是关注在给定的“重复节拍”k下如何让整个字符串变得“整齐划一”。接下来我将结合我的参赛和教学经验彻底拆解这道题的解题思路、代码实现细节以及那些容易踩坑的地方。2. 核心思路拆解化整为零的贪心策略2.1 问题重述与关键洞察首先我们严格定义一下题目有一个字符串 S长度为 n。我们可以修改 S 中的任意字符为其他字符。目标是找到最小的修改次数使得存在一个正整数 k满足 S 可以由某个长度为 k 的字符串重复若干次构成。换句话说修改后的字符串 S‘ 具有周期性其周期为 k。最直接的暴力想法是枚举所有可能的周期长度 kk必须是 n 的约数对于每个 k再枚举所有可能的长为 k 的“模板”子串计算将 S 修改为该模板重复形式所需的代价取最小值。这个想法在理论上是正确的但实践上不可行。因为枚举所有模板子串的代价是指数级的。这里的关键洞察在于当周期 k 确定后问题被分解为了 k 个独立的子问题。为什么假设周期为 k那么最终字符串中第 1, 1k, 12k, … 这些位置上的字符必须相同同理第 2, 2k, 22k, … 这些位置上的字符也必须相同以此类推直到第 k, 2k, 3k, … 这些位置。注意这里有一个非常重要的前提题目允许我们修改字符而不是重新排列字符。所以对于每个“位置组”即所有模 k 同余的位置我们的目标是把组内所有字符变成同一个字符并且我们希望这个操作的总代价修改次数最小。2.2 贪心策略的推导对于每一个固定的位置组例如所有下标 i 满足 i % k 0 的位置组内可能有 a, b, c 等多种字符。我们要把它们全部变成同一个字符最少需要修改多少次这是一个经典的“少数服从多数”问题。策略是将组内所有字符修改为出现次数最多的那个字符。假设一个组有 m 个字符其中出现次数最多的字符出现了max_count次。那么我们只需要修改剩下的m - max_count个字符即可。这就是处理这个组的最小代价。这个结论是直观的保留最多的改动最少的。因此对于给定的 k总的最小修改代价就是将所有 k 个组的代价相加总代价 Σ (第 i 组的字符总数 - 第 i 组中出现次数最多的字符的频数)其中 i 从 0 遍历到 k-1。2.3 算法步骤梳理基于以上分析我们可以梳理出清晰的算法步骤读入数据获取字符串 S 及其长度 n。枚举周期 kk 必须是 n 的约数因为字符串要恰好被整数个周期覆盖。我们从 1 枚举到 n实际上到 n/2 即可因为周期不可能大于 n/2除非 kn此时字符串无需重复代价为0但我们的算法也能覆盖。对于每个候选 k a. 初始化总代价total_cost 0。 b. 对于每个组group_id(从 0 到 k-1) - 创建一个频次数组或哈希表freq[26]假设只有小写字母用于统计该组中每个字符出现的次数。 - 遍历字符串 S对于所有满足j % k group_id的下标 j更新对应字符的频次。 - 找出该组中最大的频次max_freq。 - 计算该组的代价group_cost (n / k) - max_freq。因为每组恰好有n / k个字符。 - 将group_cost累加到total_cost。 c. 用当前的total_cost更新全局答案min_cost。输出结果输出全局最小的min_cost。这个算法的时间复杂度是 O(n * σ(n))其中 σ(n) 是 n 的约数个数。对于 n 最大为 10^5 的蓝桥杯题目规模这个复杂度是完全可接受的。3. 代码实现与细节剖析理解了算法代码实现就是水到渠成的事情。但魔鬼在细节中下面我用C实现并逐段讲解关键点和易错点。#include iostream #include string #include vector #include algorithm #include climits // 用于INT_MAX using namespace std; int main() { int k; string s; cin k; // 注意题目是先输入k再输入字符串s cin s; int n s.length(); // 特殊情况处理如果字符串长度不是k的整数倍问题无解 // 不题目意思是k是我们要找的“重复单元”长度它必须是n的约数。 // 但输入给了我们一个k我们需要基于这个k来计算。 // 仔细读题题目描述是“使其可以由一个长度为 k 的子串重复若干次得到”。 // 这意味着k是给定的目标周期长度我们不需要枚举kk是输入的一部分 // 这是一个非常重要的审题点很多同学在这里理解错了。 if (n % k ! 0) { // 如果字符串长度根本不是k的整数倍那么无论如何修改也无法由一个长度为k的串重复构成。 // 但题目保证输入合法吗我们看一下原题描述。 // 经过核实蓝桥杯本题的输入格式是第一行输入整数k第二行输入字符串s。 // 题目要求是找出最小修改次数。如果 n % k ! 0那就不可能通过修改字符不能增加或删除来实现。 // 因此这种情况下我们应该考虑什么实际上题目隐含了n是k的倍数吗 // 我重新查阅了真题原文“对于一个字符串 S我们每次可以修改其中一个字符请问最少需要修改多少次可以使得字符串 S 可以由一个长度为 k 的字符串重复多次得到。” // 这里并没有说n一定是k的倍数。如果n不是k的倍数那么“重复多次得到”意味着最终字符串长度必须是k的倍数吗 // 是的“由一个长度为 k 的字符串重复多次得到”得到的字符串长度必然是k的整数倍。 // 而我们的操作只能修改字符不能改变长度n。所以如果n不是k的倍数那么无论如何都不可能达成目标。 // 因此对于这种情况直接输出一个不可能的值比如-1或者根据题意处理。 // 然而在蓝桥杯实际评测数据中n一定是k的倍数。这是一个重要的隐含条件 // 所以在代码中我们可以不处理 n % k ! 0 的情况或者为了健壮性直接输出0因为无法完成但题目数据不会出现。 // 这里我们按照“n是k的倍数”的隐含条件来写代码。 // 但为了代码清晰我们可以先检查如果非倍数则代价无穷大不过由于数据保证我们简单注释掉。 // cout 0 endl; // 或者 return 0; // 实际上真题数据保证n是k的倍数所以我们直接计算。 } int ans 0; int group_size n / k; // 每个“位置组”里有多少个字符 // 遍历每个组组索引从 0 到 k-1 for (int i 0; i k; i) { vectorint freq(26, 0); // 统计该组中26个小写字母的出现次数 // 遍历该组的所有位置 for (int j i; j n; j k) { freq[s[j] - a]; } // 找到该组中出现次数最多的字符的频次 int max_freq *max_element(freq.begin(), freq.end()); // 该组需要修改的次数 组内总字符数 - 最大频次 ans (group_size - max_freq); } cout ans endl; return 0; }关键细节剖析输入顺序与审题这是本题第一个大坑。题目是先输入整数 k再输入字符串 s。很多同学习惯性先读字符串导致后续逻辑全部错乱。务必仔细阅读题目输入格式。n 与 k 的关系这是第二个大坑也是算法正确性的基础。题目要求最终字符串由长度为 k 的子串重复构成。设重复了 m 次则最终字符串长度为k * m。而我们的操作只能修改字符不能改变原字符串长度 n。因此必须有n k * m即n % k 0。题目数据一定会保证这一点但在思考时必须明确这个前提。如果比赛时不确定可以在代码中加入判断若n % k ! 0则输出一个特定值或直接认为无法实现代价为 n即全部修改但根据真题情况直接按倍数处理即可。频率统计的范围我们只统计小写字母因此使用长度为26的数组freq是最高效的方式。使用哈希表如unordered_map也可以但常数更大在竞赛中数组访问更优。max_element的使用max_element是 STL 算法返回指向最大元素的迭代器用*解引用即可得到最大值。自己写循环找最大值也可以。代价计算组内总字符数就是group_size n / k。最小修改次数就是总字符数减去出现最多的那个字符的数量。这个公式是贪心策略的核心体现。4. 算法正确性证明与复杂度分析4.1 贪心策略正确性证明为什么对于每个位置组选择出现次数最多的字符作为“目标字符”是最优的 这是一个局部最优导致全局最优的典型贪心且各组的决策是独立的。形式化证明 对于某个特定的位置组 G包含 m 个字符。设字符 c 出现了freq[c]次。 如果我们决定将该组所有字符最终都改为字符 X那么需要的修改次数为m - freq[X]。 为了使这个值最小我们需要使freq[X]最大。因此选择出现频率最高的字符作为 X 是唯一的最优选择。 由于字符串的周期性结构各个位置组之间没有交叉影响第 i 组的字符不会和第 j 组的字符在最终字符串里要求相等因此每个组独立地做出最优选择最终汇总的代价就是全局最优解。4.2 时间复杂度分析我们设字符串长度为 n周期为 k输入给定。外层循环遍历 k 个组循环 k 次。内层循环对于每个组我们需要遍历该组的所有字符。每个字符在整个算法中只会被访问一次因为它只属于一个特定的组。因此内层循环的总迭代次数是 n。在每次内层循环中操作是 O(1) 的数组索引和加法。在每个外层循环迭代结束时有一个在长度为26的数组中找最大值的操作复杂度 O(26) O(1)。所以总时间复杂度为 O(n k * 26) O(n)是线性的效率非常高。 空间复杂度主要是freq数组为 O(26) O(1)以及存储字符串的 O(n)。5. 常见错误与实战调试技巧即便思路清晰在实战编码和调试中依然会遇到各种问题。下面我总结几个常见的“坑”以及解决方法。5.1 错误类型一理解偏差错误误解题意去枚举所有可能的 kn 的约数而不是使用输入给定的 k。症状计算结果与样例或自己手算的小数据对不上。解决反复阅读题目输入输出描述。本题的 k 是作为输入给出的目标周期长度不是需要我们去寻找的变量。这是一个非常关键的审题点。错误认为可以任意修改字符从而改变字符串长度或者认为可以删除/插入字符。症状思考复杂化可能想到动态规划等复杂方法。解决明确“修改”操作的定义仅改变某个位置的字符不改变字符串的长度和结构。5.2 错误类型二实现细节错误数组越界。在计算s[j] - ‘a’时没有确保 s[j] 是小写字母。如果字符串包含其他字符会导致索引为负或超过25。解决题目通常保证输入为小写字母。如果不放心可以加断言或判断但竞赛题一般会明确说明。freq[s[j] - ‘a’]前提是 s[j] 在 ‘a’ 到 ‘z’ 之间。错误循环变量控制错误。内层循环for (int j i; j n; j k)初学者可能写成j n/k或其他。解决画图理解。i 是起始偏移j 每次增加 k直到超过字符串长度 n。这样就能遍历到该组所有元素。错误代价累加错误。ans (group_size - max_freq);这里group_size必须是整数且是n/k的结果。如果 n 和 k 是整型n/k在C中是整数除法没问题。但要确保group_size计算正确。5.3 调试技巧与测试用例设计当你的代码提交后不能通过所有测试点时如何定位问题设计小规模测试用例边界用例1k 1。这意味着要把整个字符串变成同一个字符。答案应该是n - (出现最多的字符的次数)。例如s“aabbb”, k1, ans 5-32。边界用例2k n。这意味着不能做任何修改字符串必须自己就是重复单元但重复一次。实际上任何字符串都可以看作由自身重复1次得到所以修改次数为0。我们的算法group_size n/n 1每个组只有一个字符max_freq1代价为0正确。常规用例s“abcabc”, k3。字符串已经是周期为3的“abc”的重复所以期望 ans0。我们的算法3个组(a,a), (b,b), (c,c)每组max_freq2group_size2代价均为0。需要修改的用例s“aaabbb”, k3。期望结果周期为3分组为(第1,4位: a,b)(第2,5位: a,b)(第3,6位: a,b)。每组都是 {a, b}max_freq1group_size2每组代价1总代价3。我们可以把所有的 a 改成 b 或者所有的 b 改成 a需要改3次。混合用例s“abacaba”, k2。n7, 但7%2!1。注意这个用例是无效的因为n不是k的倍数。这提醒我们如果题目没有明确说明我们的程序对于非法输入最好有处理比如输出0或-1。但在蓝桥杯本题中数据保证合法。使用调试输出 在计算过程中打印出每个组的频率统计结果和计算的代价与手工计算对比。// 调试用 cout “Group ” i “: “; for (int cnt : freq) if(cnt0) cout cnt ‘ ‘; cout “, max_freq” max_freq “, cost” (group_size - max_freq) endl;对比暴力算法对小数据 对于很小的 n比如n10可以写一个暴力枚举所有可能修改方案的算法指数级复杂度来验证你的贪心算法结果的正确性。这是验证算法正确性的终极手段。6. 举一反三相关题型与扩展思考这道“重复字符串”题目虽然解法和代码都很简洁但其背后蕴含的思想可以扩展到许多其他问题。6.1 题型变种允许插入和删除操作如果操作不仅限于修改还可以插入或删除字符求最小操作次数使得字符串具有周期k。这就变成了一个编辑距离问题的变种难度会大幅上升可能需要用动态规划解决。寻找最优周期k如果题目不给定k而是要求你找出一个k使得最小修改次数最少并输出这个最小次数。这就是我们最初想到的暴力枚举所有kn的约数的情况。算法复杂度为 O(σ(n) * n)对于 n10^5约数个数一般不多仍然是可行的。字符集扩大如果不是小写字母而是所有ASCII字符甚至Unicode。我们的频率统计数组就需要扩大或者改用哈希表。核心算法不变。6.2 核心思想的应用本题的核心思想是“分组独立处理”和“局部贪心多数表决”。分组思想在具有周期性或规则性的问题中将下标按模数分类往往能简化问题。例如在一些数组重排、交替序列的问题中经常用到。多数表决贪心在需要将一组元素统一为某一个值的代价最小化问题时选择频次最高的那个值作为目标总是最优的。这出现在很多最小修改次数的题目中。例如LeetCode上有一道题“1156. 单字符重复子串的最大长度”虽然问题不同但其中也涉及到了统计连续段和频率的思想。还有“2027. 转换字符串的最少操作次数”也是一道基于分组和贪心的字符串修改题。6.3 对竞赛训练的启示从这道国赛真题中我们可以总结出几点对备战蓝桥杯或其他算法竞赛有益的经验扎实的基础知识本题没有用到高深的数据结构或算法纯粹考察字符串处理、循环、数组统计和贪心思想。这说明基础是否牢固至关重要。问题转换能力能否将“使字符串重复”这个模糊的目标转化为对“每个模k同余位置组”的字符统一问题是解题的关键。这种化整为零、寻找问题等价形式的能力需要大量练习。审题与细节输入顺序先k后s、n与k的关系n是k的倍数这些细节直接决定了程序的正确与否。竞赛中仔细阅读题目描述和数据范围永远是第一步。效率估算即使想到枚举所有k的暴力解法也要能估算其复杂度约数个数增长很慢判断是否可行。本题如果误解题意去枚举k对于n10^5其约数个数最多也就一两百个乘以O(n)的检查也是可以接受的大约10^7量级。这要求我们对常见数据规模下的时间复杂度有直觉。这道“重复字符串”就像一面镜子清晰地反映出一个选手的基本功。它不追求奇技淫巧而是考验你是否能冷静地分析问题稳健地实现解决方案。在平时的训练中多找一些这类“思维朴实但实现需谨慎”的题目进行练习对提升比赛时的稳定性和得分率大有裨益。
返回列表