行业资讯

蓝桥杯国赛真题解析:重复字符串问题的贪心算法与矩阵分解

发布时间:2026/8/28 3:26:12
蓝桥杯国赛真题解析:重复字符串问题的贪心算法与矩阵分解 1. 问题引入从一道看似简单的国赛真题说起最近在整理蓝桥杯的历年真题翻到了2020年第十一届国赛的这道“重复字符串”。题目乍一看描述非常简洁甚至有些“人畜无害”。很多同学第一反应可能是“这不就是找规律或者简单模拟吗” 但当你真正动手去实现或者仔细琢磨它的数据范围时才会发现里面藏着不少“坑”和精巧的思维转换。这道题的核心远不止于字符串的基本操作它更像是一个披着字符串外衣的数学与贪心策略问题。今天我们就来彻底拆解这道题不仅给出解法更要讲清楚背后的“为什么”以及在实际编码中如何避开那些容易让人栽跟头的陷阱。简单来说题目要求是给定一个字符串 S你可以修改其中的任意字符目标是使得修改后的字符串可以由某个长度为 k 的子串重复多次构成。我们需要找到最少的修改次数。例如字符串“abcdeabcde”如果 k5那么它本身就已经是由“abcde”重复两次构成修改次数为0。如果字符串是“aaxxaaaaaa”k2我们可能就需要考虑如何调整让它变成类似“aaaaaa”由“aa”重复的形式并且改动最少。这道题在蓝桥杯国赛中出现其定位显然不是送分题。它考察的是选手将复杂问题分解、寻找规律、以及高效计算的能力。下面我们就一步步揭开它的面纱。2. 核心思路拆解为什么不能暴力枚举拿到题目最朴素的想法是什么可能是枚举所有可能的重复单元长度为 k 的所有子串然后计算将原字符串 S 修改为以该单元重复构成的新字符串所需的代价最后取最小值。这个思路方向是对的但直接实施会面临巨大的效率问题。首先长度为 k 的“重复单元”并不是任意的。它必须满足最终字符串的长度是 k 的整数倍。题目虽然没有明确说 S 的长度一定是 k 的倍数但隐含了这层意思因为要“重复构成”整个字符串。我们假设 S 的长度为 n。那么如果 k 不能整除 n问题本身就无解。因此第一个关键点就是如果 n % k ! 0则直接输出 -1。这是一个非常重要的边界条件检查在竞赛中忘记处理会导致白丢分。假设 n % k 0记重复次数为m n / k。那么最终的字符串会被等分成 m 段每段都是相同的长度为 k 的字符串 T即我们寻找的重复单元。现在暴力枚举的瓶颈在哪里如果我们要枚举所有可能的 T那么每个位置有 26 种可能假设只考虑小写字母那么 T 的可能性是 26^k 种。即使 k 很小比如10这也是一个天文数字完全不可行。因此我们必须转换思路。既然最终的 m 段都必须等于 T那么对于原字符串 S我们可以把它想象成一个 m 行 k 列的矩阵S[0] S[1] ... S[k-1] S[k] S[k1] ... S[2k-1] ... S[(m-1)*k] ... S[n-1]列号0, 1, 2, ..., k-1行号0, 1, 2, ..., m-1这个矩阵的每一行理论上都应该是同一个字符串 T。那么对于矩阵的每一列即所有行在相同偏移位置上的字符它们最终都应该被修改成同一个字符因为 T 的每个位置上的字符是固定的。例如第0列包含字符 S[0], S[k], S[2k], ... S[(m-1)*k]。在最终的字符串里这些位置上的字符都必须相同都等于 T[0]。同理第1列的所有字符必须相同都等于 T[1]以此类推。这样一来一个全局的、涉及整个字符串修改的问题就被巧妙地分解成了 k 个相互独立的子问题每个子问题只关注一列上的 m 个字符。我们的总修改次数就是这 k 列各自所需的修改次数之和。那么对于单独的一列如何以最少的修改次数让其中的 m 个字符都变成同一个字符呢答案显而易见找出这一列中出现次数最多的那个字符众数。我们把这一列所有的字符都改成这个众数所需的修改次数最少为m - (该众数出现的次数)。注意这里有一个细节。如果出现次数最多的字符有多个例如一列里有3个‘a‘3个‘b‘1个‘c‘那么选择任意一个众数‘a‘或‘b‘所需的修改次数是一样的都是 m - 3。在算法中我们只需要统计出最大出现次数即可不必关心具体是哪个字符。至此整个问题的算法框架就清晰了检查字符串长度 n 是否能被 k 整除不能则返回 -1。计算重复次数 m n / k。将字符串视为 m 行 k 列的矩阵。对于每一列 j (0 j k) a. 统计该列上所有字符的出现频率。即统计 S[j], S[jk], S[j2k], ..., S[j(m-1)*k]。 b. 找出该列中出现频率最高的次数max_count。 c. 该列所需的最小修改次数为m - max_count。将所有 k 列的修改次数相加即为最终答案。这个算法的时间复杂度是 O(n)。因为我们需要遍历每个字符一次以进行统计k 列每列 m 个字符总计 km n。空间复杂度上对于每一列我们只需要一个大小为26字母表大小的数组来统计频率因此是 O(k26)通常视为 O(k)在 k 远小于 n 时非常高效。3. 算法实现细节与代码剖析理解了核心思路我们来看看如何用代码实现并讨论一些实现上的技巧和易错点。这里以 C 为例进行说明其他语言逻辑相通。3.1 基础版本实现首先我们实现上述最直接的算法逻辑。#include iostream #include string #include vector #include algorithm using namespace std; int main() { int k; string s; cin k s; // 题目输入格式通常是先k后字符串 int n s.length(); // 1. 边界检查 if (n % k ! 0) { cout -1 endl; return 0; } int m n / k; // 重复次数即行数 int total_changes 0; // 2. 遍历每一列 for (int col 0; col k; col) { vectorint freq(26, 0); // 用于统计该列字母频率 int max_freq 0; // 3. 遍历该列的所有行 for (int row 0; row m; row) { int index col row * k; // 计算原字符串中的下标 char c s[index]; freq[c - a]; // 统计频率 // 可以在这里更新max_freq但更清晰的做法是统计完再算 } // 4. 找出该列中出现次数最多的字符的频率 for (int count : freq) { if (count max_freq) { max_freq count; } } // 5. 该列最小修改次数 总行数 - 最大频率 total_changes (m - max_freq); } cout total_changes endl; return 0; }这个版本清晰易懂完全遵循了我们的思路。但是它有一个可以优化的点我们在内层循环中遍历每一行统计频率然后在外层又遍历了一次频率数组来求最大值。对于每一列我们实际上可以只遍历一次。3.2 优化版本一次遍历同时统计和求最大值我们可以在统计频率的过程中实时更新当前列的最大频率值。#include iostream #include string #include vector #include algorithm using namespace std; int main() { int k; string s; cin k s; int n s.length(); if (n % k ! 0) { cout -1 endl; return 0; } int m n / k; int ans 0; for (int col 0; col k; col) { vectorint freq(26, 0); int max_freq_in_this_col 0; // 当前列的最大频率 for (int row 0; row m; row) { int idx col row * k; int char_index s[idx] - a; freq[char_index]; // 关键优化实时更新最大值 // 因为freq[char_index]刚加1它可能成为新的最大值 if (freq[char_index] max_freq_in_this_col) { max_freq_in_this_col freq[char_index]; } } ans (m - max_freq_in_this_col); } cout ans endl; return 0; }这个优化是微小的但在某些追求极致性能的场景下或者 k 很大时是有意义的。它减少了一次对26个元素的遍历。不过对于本题的常规数据范围第一个版本也完全足够。3.3 关键易错点与测试用例分析即使思路正确实现时也可能掉进坑里。下面我们通过几个测试用例来验证和巩固理解。测试用例1基本功能输入 5 abcdeabcde 输出 0分析字符串本身就是由“abcde”重复两次构成每一列上的字符都相同所以每列的最大频率max_freq m 2修改次数为0。通过。测试用例2需要修改输入 2 aaxxaaaaaa 输出 3让我们手动计算一下n10, k2, m5。第0列字符a, x, a, a, a- 频率a出现4次x出现1次。max_freq4修改次数5-41。第1列字符a, x, a, a, a- 频率a出现4次x出现1次。max_freq4修改次数5-41。 总修改次数2等等这里出错了。我们仔细看原字符串“aaxxaaaaaa”按2长度分组应该是aa,xx,aa,aa,aa。所以矩阵是 行0: a, a 行1: x, x 行2: a, a 行3: a, a 行4: a, a 因此第0列a, x, a, a, a-a出现4次x出现1次。修改次数1。第1列a, x, a, a, a-a出现4次x出现1次。修改次数1。 总次数2。但示例输出是3。矛盾点在哪里很可能是我构造的输入字符串索引理解有误或者是题目示例本身需要核对。这个矛盾恰恰是重要的它提醒我们必须严格按照我们定义的矩阵划分方式来访问字符。对于s “aaxxaaaaaa”, k2: 索引: 0:a, 1:a, 2:x, 3:x, 4:a, 5:a, 6:a, 7:a, 8:a, 9:a 列0 (j0): s[0], s[2], s[4], s[6], s[8] - a, x, a, a, a - a:4, x:1 - 修改1 列1 (j1): s[1], s[3], s[5], s[7], s[9] - a, x, a, a, a - a:4, x:1 - 修改1 总修改2。如果答案是3说明可能原题中的字符串或k值不同或者是我的理解有偏差。在实际解题中遇到这种不一致首先要检查自己的索引计算是否正确这是最容易出错的地方。公式index col row * k必须确保不会越界并且能正确遍历所有字符。在本例中计算是正确的。所以如果题目给出的答案是3我们需要怀疑是否是另一个测试用例。例如字符串是“aaxxaaaabb”k2那么 列0: a, x, a, a, a - 修改1 列1: a, x, a, a, b - a:3, x:1, b:1 - 修改2 总修改3。这可能才是原题意图。这一点告诉我们在实现时一定要亲手用纸笔模拟几个小例子确保索引映射关系百分百正确。测试用例3无解情况输入 3 abcdef 输出 -1分析n6, k3, 6%30有解。等等6能被3整除所以应该有解输出不会是-1。无解的情况应该是k4, n6这种。所以这个测试用例应该是k4, s“abcdef”输出-1。务必注意边界条件的判断逻辑。测试用例4全相同字符输入 4 aaaaaaaaaaaa 输出 0分析n12, k4, m3。每一列的三个字符都是‘a‘最大频率为3修改次数为0。测试用例5复杂情况输入 3 abacaba 输出 ?分析n7, 7%31不能整除直接输出-1。这个例子用来测试边界条件非常有效。通过这些测试用例我们应当养成习惯先处理边界条件长度整除再小心计算下标最后用简单例子验证。4. 从解题到举一反三这类问题的通用思考模式“重复字符串”这道题给我们提供了一个非常好的思维训练样本。我们可以从中提炼出解决一类问题的通用思考模式。模式一问题分解与独立子问题当遇到一个全局性的优化问题时如修改整个字符串如果直接处理复杂度太高可以尝试寻找一种方式将全局目标分解为若干个局部目标且这些局部目标之间相互独立或弱相关。在这道题中将字符串按“重复单元”切分后“每一列必须字符相同”这个约束使得各列之间的决策完全独立。一列要改成什么字母不影响其他列。这是分解能够成立的关键。在其它问题中可能需要寻找类似的“正交”或“独立”维度。模式二利用周期性与模运算这道题的核心是“重复”这天然引入了周期性。对于下标 i它在“重复矩阵”中的行和列可以通过除法和模运算得到行 row i / k,列 col i % k。这个i % k的运算是处理所有周期性、循环类问题的利器。例如循环数组、环形缓冲区、字符串的循环移位等问题都会用到模运算来映射索引。模式三贪心策略的识别与证明在每一列中我们选择了“出现次数最多的字符”作为目标字符这是一种贪心策略。为什么它是正确的因为对于一列固定的 m 个字符无论你选择哪个目标字符你需要修改的次数都是m - (目标字符出现的次数)。为了让这个值最小就需要(目标字符出现的次数)最大。这是一个非常直观的“少数服从多数”的贪心并且可以严格证明其最优性任何其他选择都会导致更多的修改。在竞赛中对于这类“每一局部最优能导致全局最优”的贪心题关键是要能清晰地阐述哪怕只是在脑子里其正确性。模式四频率统计桶计数的广泛应用我们使用了一个长度为26的数组freq来统计字母频率。这种技巧常被称为“桶计数”或“哈希计数”是处理有限字符集尤其是小写字母问题的标配。它的时间复杂度是 O(n)空间复杂度是 O(字符集大小)效率极高。在解决“最小字符变换”、“构造回文串”、“字母异位词”等问题时这是首要考虑的技巧。如果我们把这道题稍微变形比如字符集很大Unicode或者允许的修改操作不同如交换字符那么解题思路和数据结构的选择可能就要相应调整。但核心的“按列分解每列独立处理”的思想很可能依然适用。5. 性能分析与进阶思考我们实现的算法时间复杂度是 O(n)空间复杂度是 O(k*26) 或优化后 O(26)如果重复使用一个频率数组。对于蓝桥杯的比赛环境通常 n 在 10^5 量级来说这个效率是绰绰有余的。但是我们可以思考一些更极端的情况或可能的变种如果 k 非常大接近 n 呢此时 m n / k 会很小可能等于1或2。我们的算法仍然高效。当 m1 时意味着重复单元长度就是字符串本身那么任何列都只有1个字符最大频率就是1总修改次数 k * (1-1) 0。这符合直觉不需要修改。当 m2 时算法需要统计每一列两个字符的频率仍然很快。如果字符串长度 n 极大例如 10^7并且有多组测试数据呢O(n) 的算法对于单组 10^7 的数据是可行的但如果是多组就需要考虑更高效的输入输出方式例如使用scanf/printf或关闭流同步。算法本身已经没有优化的余地了因为至少需要读取并遍历每个字符一次。变种求最终可以形成的重复字符串是什么我们的算法只计算了最小修改次数。如果题目要求输出具体的重复单元 T我们只需要在每一列统计频率时不仅记录最大频率同时记录达到该频率的字符。注意可能有多解即一列中有多个字符出现次数相同且都是最大。这时通常按字典序选择最小的字符来构造 T可以保证结果唯一。变种修改操作有不同代价原题中将字符 a 改成 b 和将 a 改成 c 的代价都是1。如果每个修改操作的代价不同例如一个代价矩阵那么问题就变成了一个更复杂的动态规划或最小费用流问题不能再使用简单的贪心策略。每一列需要选择使得总修改代价最小的目标字符。通过这道“重复字符串”真题的深度剖析我们可以看到一个好的算法题是如何将字符串处理、数学思维、贪心策略和编码实现结合在一起的。它提醒我们在解题时不要被题目描述的表象所迷惑而是要深入分析其数学结构和约束条件寻找可以分解和简化的规律。在实现时要特别注意边界条件和下标计算的准确性这是算法竞赛中稳定拿分的基础。最后养成举一反三的习惯思考问题的各种变种能够极大地提升解决未知问题的能力。