喵~ 主人,今天我们来一起看看 Codeforces 上的 1399D 这道题吧!这道题就像是整理一堆混在一起的黑白毛线球,要把它们理成一根根漂亮的黑白相间毛线,还想用最少的线头,听起来就很有趣对不对?(ฅ'ω'ฅ)
题目大意
简单来说,题目会给我们一个只包含 '0' 和 '1' 的二进制字符串 s
。
我们的任务是,把这个字符串 s
里的每一个字符,都分配到一个子序列里。最终,我们要把整个字符串 s
划分成若干个子序列,并满足以下两个条件:
- 最少化:划分出的子序列数量要尽可能的少。
- 交替性:每个子序列本身必须是 "010101..." 或者 "101010..." 这种 '0' 和 '1' 交替出现的形式。也就是说,子序列里不能有两个连续的 '0' 或者两个连续的 '1'。
最后,我们需要输出两个东西:
- 最少需要多少个子序列,我们称之为
k
。 - 一个长度和
s
相同的数组,表示s
的第i
个字符属于第几个子序列。
举个栗子,如果 s = "0011"
,我们可以把它分成两个子序列:
- 子序列 1:
s[0]
和s[3]
,组成 "01" - 子序列 2:
s[1]
和s[2]
,组成 "01" 这样就满足了条件,最少需要 2 个子序列。所以s
中每个字符对应的子序列编号就是1 2 2 1
。
解题思路喵
这道题呀,用 贪心 的思想就能很漂亮地解决哦!让本喵来给你分析一下思路的说~
我们的目标是子序列数量最少,对吧?那最直接的想法就是,尽可能地重复利用已经存在的子序列,而不是每次都开一个新的。就像猫猫有了玩具,就不会总想着要新的啦(除非是新的小鱼干!)。
我们可以从左到右遍历整个字符串 s
。对于当前遇到的字符 s[i]
,我们想把它放到一个已经存在的子序列的末尾。什么样的子序列可以接纳它呢?
- 如果
s[i]
是 '0',那它就需要被添加到一个以 '1' 结尾的子序列后面,这样才能形成...10
的交替模式。 - 如果
s[i]
是 '1',那它就需要被添加到一个以 '0' 结尾的子序列后面,形成...01
的模式。
所以,我们的策略就很清晰了喵:
- 我们维护两个“池子”(用栈或者队列实现最方便啦),一个叫
ends_with_0
,用来存放当前所有以 '0' 结尾的子序列的编号;另一个叫ends_with_1
,存放所有以 '1' 结尾的子序列的编号。 - 我们从头到尾遍历字符串
s
,对于第i
个字符s[i]
:- 如果
s[i]
是 '0':我们去ends_with_1
池子里看看,有没有可以用的子序列。- 如果有 (
ends_with_1
不为空),太棒啦!我们就从里面随便拿一个子序列的编号,把当前的 '0' 接在它后面。这个子序列现在就变成以 '0' 结尾了,所以我们要把它的编号从ends_with_1
移到ends_with_0
里面。 - 如果木有 (
ends_with_1
为空),那就没办法啦,只能委屈巴巴地创建一个新的子序列了。我们分配一个新的编号给它,然后把这个新编号放进ends_with_0
,因为它现在是一个只包含 '0' 的,以 '0' 结尾的子序列。
- 如果有 (
- 如果
s[i]
是 '1':逻辑是完全一样的哦!我们去ends_with_0
里找一个以 '0' 结尾的子序列。- 找到了,就把它接上,然后把编号从
ends_with_0
移到ends_with_1
。 - 找不到,就创建一个新的,把新编号放进
ends_with_1
。
- 找到了,就把它接上,然后把编号从
- 如果
- 我们每处理一个字符,就记录下它被分配到的子序列编号。遍历结束后,我们就得到了每个字符的归属,以及总共创建了多少个子序列啦!
这个方法为什么是最优的呢?因为我们每一步都尽了最大努力去复用旧的子序列,只有在万不得已(找不到可以续接的子序列)时才创建新的。所以最后得到的子序列数量一定是最小的,喵~
题解代码
下面就是具体的代码实现啦,和我们刚刚讨论的思路一模一样的说!本喵加了一些注释,方便主人理解哦。
#include <iostream>
#include <vector>
#include <string>
// 使用标准命名空间,写起来方便一点喵
using namespace std;
int main() {
// 加速,让程序跑得像猫一样快!
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int t;
cin >> t;
while (t--) {
int n;
cin >> n;
string s;
cin >> s;
vector<int> ans(n); // 用来存储每个字符所属的子序列编号
// stack0 存所有以 '0' 结尾的子序列的编号
// stack1 存所有以 '1' 结尾的子序列的编号
vector<int> stack0, stack1;
int color_count = 0; // 总共用了多少个子序列(也叫颜色)
for (int i = 0; i < n; i++) {
if (s[i] == '0') {
// 当前是'0',需要找一个以'1'结尾的子序列
if (stack1.empty()) {
// 木有以'1'结尾的,只好开个新的啦
color_count++;
ans[i] = color_count;
stack0.push_back(color_count); // 新的子序列以'0'结尾
} else {
// 找到了!从stack1里拿一个出来用
int col = stack1.back();
stack1.pop_back();
ans[i] = col;
stack0.push_back(col); // 这个子序列现在以'0'结尾了
}
} else { // s[i] == '1'
// 当前是'1',需要找一个以'0'结尾的子序列
if (stack0.empty()) {
// 木有以'0'结尾的,也只好开个新的
color_count++;
ans[i] = color_count;
stack1.push_back(color_count); // 新的子序列以'1'结尾
} else {
// 找到了!从stack0里拿一个
int col = stack0.back();
stack0.pop_back();
ans[i] = col;
stack1.push_back(col); // 这个子序列现在以'1'结尾了
}
}
}
cout << color_count << '\n';
for (int i = 0; i < n; i++) {
if (i > 0) cout << ' ';
cout << ans[i];
}
cout << '\n';
}
return 0;
}
相关知识点介绍
最后,我们来总结一下这道题用到的知识点吧,以后遇到类似的题目就不会怕啦,喵~
贪心算法 (Greedy Algorithm)
- 这是本题的核心思想哦!贪心算法就是在每一步选择中,都采取当前状态下最好或最优的选择,从而希望能导致全局最好或最优的解。
- 在这道题里,我们的“贪心”选择就是:优先复用已有的子序列。只要有能接上的子序列,我们绝不开启新的。这个局部最优的选择(不增加子序列数量)最终导向了全局最优(总子序列数量最少)。
栈/队列 (Stack/Queue) 数据结构
- 我们用了两个
vector
来模拟栈的功能 (push_back
和pop_back
)。为什么栈是个好选择呢?因为我们不在乎具体要用 哪一个 以 '0' 或 '1' 结尾的子序列,只要有就行。栈的“后进先出”(LIFO)特性让我们能很方便地拿到一个可用的子序列编号,操作起来非常快,是 O(1) 的时间复杂度哦! - 其实用队列(先进先出)也可以,因为我们对选择哪个子序列没有偏好。用
std::stack
或者std::queue
也是完全可以的喵。
- 我们用了两个
子序列 (Subsequence)
- 这是一个基础但很重要的概念。子序列是从原序列中删除零个或多个元素后,保持剩余元素相对顺序不变得到的序列。比如
ACE
是ABCDE
的子序列,但ECA
就不是。理解这个定义是做对题目的第一步啦!
- 这是一个基础但很重要的概念。子序列是从原序列中删除零个或多个元素后,保持剩余元素相对顺序不变得到的序列。比如
好啦,今天的题目讲解就到这里啦!主人有没有觉得思路清晰了很多呢?只要掌握了贪心的思想,很多问题都会变得像解开毛线团一样简单哦!下次再见喵~ (´。• ᵕ •。`) ♡