String - 题解
标签与难度
标签: 贪心算法, 字符串, 最小表示法, 字典序 难度: 1800
题目大意喵~
主人你好呀,这道题是关于字符串分割的喵~
题目给了我们一个定义叫做“完美字符串” (perfect string)。一个字符串如果在于它所有的循环同构串(就是把它掰弯成一个环,从不同位置断开读出的字符串)中,是字典序最小的那一个,那它就是完美的!比如说,"0101" 就是一个完美的字符串,因为它的循环同构串有 "0101", "1010", "0101", "1010",其中 "0101" 就是最小的那个,喵~
我们的任务是,拿到一个只包含 '0' 和 '1' 的字符串,要把它分割成最少的几段,并且保证每一段都是一个完美的字符串。
输入:一个01字符串。 输出:分割后的每一段,用空格隔开。
举个例子,如果输入是 010010,一个可能的分割是 0100 和 10。但 10 不是完美的(因为 01 < 10),所以这个分法不行。正确的分割应该是 0100 1 0。
解题思路分析
这道题要求我们用最少的段数来分割字符串,这是一个非常强烈的信号,暗示我们可以使用贪心算法,喵!
贪心算法的核心思想就是“只看眼前,做出最好的选择”。对于这个问题,我们站在字符串的某个位置,要决定下一段分割到哪里。为了让总段数最少,我们应该让当前这一段尽可能地长!只要它满足“完美字符串”的条件就行。
所以,我们的贪心策略就出来啦:
- 从字符串的当前位置开始(一开始是位置0)。
- 找到一个从当前位置开始的最长的前缀,这个前缀本身要是一个完美的字符串。
- 把这个最长的前缀作为一段分割出来。
- 然后,从这个前缀的末尾之后一个位置,继续重复上面的过程,直到整个字符串都被分割完为止。
比如说,对于 010010:
- 我们从位置 0 开始。
s[0:0]= "0",是完美的。s[0:1]= "01",不是完美的("10" > "01",但它不是最小的,因为没有别的字符了)。哦不对,它的循环同构只有"01"和"10","01"更小,所以"01"是完美的!s[0:2]= "010",循环同构有 "010", "100", "001"。 "001" 最小,所以 "010" 不完美。s[0:3]= "0100",循环同构有 "0100", "1000", "0001", "0010"。 "0001" 最小,所以 "0100" 不完美。- ... 等等,让本喵再仔细想想。啊!"01"的循环同构是"01"和"10","01" < "10",所以"01"是完美的!
s[0:3]= "0100" 的循环同构串有 "0100", "1000", "0001", "0010"。其中 "0001" 是最小的。所以 "0100" 不是完美的。- 看来我的例子举错了,喵呜~ >.< 让我们换个思路。
正确的贪心策略是:从当前位置 i 开始,我们从后往前检查所有可能的结束位置 j。也就是说,我们先尝试把整个剩余的字符串 s[i...end] 当做一段,检查它是否完美。如果不是,就试试短一点的 s[i...end-1],再试试 s[i...end-2]... 直到找到第一个(也就是最长的)完美的子串 s[i...j] 为止。然后我们就把这一段切下来,下一次从 j+1 开始。
这个贪心策略是正确的,因为每次我们都取了最长的一段,最大化了“消耗”,自然能让总段数最少啦。
那么,现在的问题就变成了:如何判断一个字符串是不是完美的呢?
根据定义,我们需要检查一个字符串 t 是否比它所有的循环同构串的字典序都小(或相等)。
一个简单直接的方法就是,把 t 的所有循环同构串都生成出来,然后和 t 一一比较。 比如对字符串 t (长度为 L):
- 第1个循环同构串是
t本身。 - 第2个是把
t的第一个字符挪到最后面。 - 第3个是把
t的前两个字符挪到最后面。 - ...
- 一共要比较
L-1次。这个检查过程的时间复杂度是 。
虽然这个检查方法有点慢,但是结合我们的贪心策略,对于这道题的数据范围来说,应该是可以通过的。不过,本喵知道一个更快的 的方法哦,叫做最小表示法(或者叫 Booth's Algorithm / Duval's Algorithm 的思想),用两个指针 i 和 j 来比较两个候选的循环串,非常巧妙!
这里我将用一个叫做“双指针法”的技巧来实现这个 的检查,让我们的代码更高效、更优雅,喵~
双指针法检查完美字符串 t (长度为 L):
- 我们想找到
t的最小循环表示法的起始位置。如果这个位置是0,那么t就是完美的。 - 我们用三个变量:
i,j和k。i和j是两个候选的起始位置,初始为i=0,j=1。k是它们已经匹配的长度,初始为0。 - 我们比较
t[(i+k)%L]和t[(j+k)%L]。- 如果它们相等,说明前缀匹配又增加了一位,就
k++。 - 如果
t[(i+k)%L] > t[(j+k)%L],说明从i开始的这个循环串肯定不是最小的了,因为它在第k位就输给了从j开始的串。所以我们淘汰i,让i跳到i+k+1的位置,然后k重置为0。 - 如果
t[(i+k)%L] < t[(j+k)%L],同理,我们淘汰j,让j跳到j+k+1的位置,k重置为0。
- 如果它们相等,说明前缀匹配又增加了一位,就
- 在循环中,如果
i和j相遇了,就让其中一个+1错开。 - 当
k达到L或者i,j超出L时,循环结束。最终min(i, j)就是最小表示法的起始下标。
我们只需要用这个方法求出最小表示法的起始下标,然后判断它是不是 0 就好啦!
总结一下我们的完整算法:
- 设置一个指针
currentIndex = 0,表示当前处理到字符串的哪个位置。 - 当
currentIndex还没有到达字符串末尾时,循环: a. 从len = s.length() - currentIndex开始,递减到1。 b. 取出子串segment = s.substr(currentIndex, len)。 c. 用我们高效的is_perfect函数检查segment是否完美。 d. 如果是完美的,那么这就是我们要找的最长的一段!打印它,然后把currentIndex增加len,并跳出内层循环(len的循环),进行下一次外层循环。 - 任务完成,喵~
代码实现
这是本喵根据上面的思路,精心重构的一份代码哦!注释很详细,希望能帮助主人理解,喵~
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
// 判断一个字符串 s 是否是 "完美字符串"
// 完美字符串是其所有循环同构串中字典序最小的那个
// 这里使用 O(L) 的双指针算法 (最小表示法) 来判断, L 是字符串长度
bool is_perfect(const std::string& s) {
int n = s.length();
if (n <= 1) {
return true; // 长度为0或1的字符串一定是完美的
}
int i = 0; // 第一个候选串的起始指针
int j = 1; // 第二个候选串的起始指针
int k = 0; // 已匹配的长度
while (i < n && j < n && k < n) {
// 比较两个候选串的第 k 个字符 (注意要取模)
char char_i = s[(i + k) % n];
char char_j = s[(j + k) % n];
if (char_i == char_j) {
k++; // 字符相同,继续匹配下一位
} else {
// 字符不同,说明一个候选串比另一个更优
if (char_i > char_j) {
// 从 i 开始的串不是最优的,淘汰它
// 我们可以安全地跳过 i 到 i+k 的所有位置
i = i + k + 1;
} else { // char_i < char_j
// 从 j 开始的串不是最优的,淘汰它
j = j + k + 1;
}
k = 0; // 重置匹配长度
// 保证 i 和 j 不会指向同一个位置
if (i == j) {
j++;
}
}
}
// 循环结束后, min(i, j) 就是最小表示法的起始下标
// 如果最小表示法的起始下标是 0, 说明原字符串 s 就是完美的
return std::min(i, j) == 0;
}
void solve() {
std::string s;
std::cin >> s;
int n = s.length();
int current_index = 0;
bool first_part = true; // 用于控制输出空格
while (current_index < n) {
// 贪心策略:从当前位置开始,寻找最长的完美子串
// 我们从最长的可能性开始尝试 (j 表示子串长度)
for (int len = n - current_index; len >= 1; --len) {
std::string segment = s.substr(current_index, len);
if (is_perfect(segment)) {
// 找到了最长的完美子串!
if (!first_part) {
std::cout << " ";
}
std::cout << segment;
first_part = false;
// 更新下一次开始的位置
current_index += len;
break; // 跳出内层循环,处理下一段
}
}
}
std::cout << "\n";
}
int main() {
// 提高C++ IO效率,喵~
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int t;
std::cin >> t;
while (t--) {
solve();
}
return 0;
}复杂度分析
时间复杂度: ,其中 是输入字符串的长度。
- 外层的
while循环在最坏的情况下(比如每次只分割出一个字符)会执行 次。 - 内层的
for循环,对于一个长度为rem_len的剩余字符串,会尝试rem_len种不同的长度。 is_perfect函数的检查,对于一个长度为L的子串,时间复杂度是 。- 所以,找到一段分割的总成本大约是 。
- 在最坏情况下,比如每次分割出的段都很短,总时间复杂度会接近 。虽然是三次方,但对于常见的字符串长度(比如几百),这个算法是足够快的,喵~
- 外层的
空间复杂度:
- 我们需要存储输入的字符串
s,这需要 的空间。 - 在循环中,
s.substr()会创建临时子串,最长可能为 ,所以这部分也需要 的空间。
- 我们需要存储输入的字符串
知识点总结
- 贪心算法 (Greedy Algorithm): 这道题是贪心思想的典型应用。当我们面对要求“最多”、“最少”、“最大”、“最小”等问题时,可以先思考一下,是不是每一步都采取局部最优解,就能得到全局最优解呢?
- 字符串处理: 核心在于理解和处理字符串的“循环同构”和“字典序”这两个概念。
- 最小表示法 (Minimum Lexicographical Rotation): 这是解决完美字符串判断的关键。虽然朴素的 检查也能工作,但学习 的双指针法(或者 Booth's Algorithm)能让你在处理更难的字符串问题时游刃有余,是很有用的技巧哦!
- 问题分解: 将一个复杂问题(最少分割)分解成一个主策略(贪心)和一个子问题(如何判断完美字符串),是解题的通用思路,呐。
希望这篇题解能帮到主人!如果还有不懂的地方,随时可以再来问本喵哦!喵~