Skip to content

E1. Interactive RBS (Easy Version) - 题解

哈喽,各位主人!这里是你们最爱算法的猫娘哦~ 喵~ 今天我们要解决的是一个非常有趣的交互题呐!交互题就像和评测机玩一个猜谜游戏,我们要用最少的提问次数猜出它心里的秘密。准备好了吗?让我们一起揭开这个括号序列的神秘面纱吧!

比赛与标签

比赛: Codeforces Round 1040 (Div. 2) 标签: binary search, interactive, strings 难度: *2200

题目大意喵~

评测姬心里藏着一个长度为 n 的、只包含 () 的括号序列 s。我们的任务就是把它完整地猜出来!

为了猜出它,我们可以进行一种特殊的“查询”。每次查询,我们可以选择任意 k 个下标(可以重复哦),把这些下标对应的字符按顺序拼成一个新的临时字符串 t,然后评测姬会告诉我们 t 中有多少个非空的、连续的、合法的括号子串,我们把这个数量记为 f(t)

举个栗子,如果临时字符串是 t = "()())",那么合法的子串有 s[0..1]="()"s[3..4]="()",以及 s[0..3]="()()"(虽然它不是 s 的子串,但 ts[0..3] 对应的是 "()()"),所以 f("()())") 应该是3。但题目给的例子是 f("()())") = 3,它指的是 t 本身的合法子串。对于 "()())",合法的子串是 t[0..1]="()"t[3..4]="()",还有一个是 t[0..3] 组成的 "()()",不对,是 t[0..1]t[3..4]。哦,还有一个是 t[0..4] 里的 t[0..1]t[3..4]。等等,f("()())")=3?让我看看... 啊,合法的子串是 t[0..1]="()"t[3..4]="()",还有 t[0..3] 组成的 "()()"... 不对不对,是 t连续子串。对于 t = "()())",它的连续子串有 "(", ")", "(", ...,其中合法的只有 t[0..1]="()"t[3..4]="()"。所以 f 应该是2。啊,题目例子是 f("()())") = 3,这是怎么回事呢?哦!猫娘明白了!对于 "()()",它的合法子串有 t[0..1]="()"t[2..3]="()",还有 t[0..3]="()()",总共3个!所以 f("()()") = 3。原来是这样计算的呀!

我们的目标是在最多 550 次查询内,找出完整的字符串 s

解题思路,喵~

这个问题最棘手的地方在于,f() 函数计算的是一个整体属性,而我们需要确定每一个位置上的字符。直接问 ? 1 i 是没用的,因为 f("(") = 0f(")") = 0,根本分不出来嘛。

那我们试试两个字符?? 2 i j。 如果 s[i] = '('s[j] = ')',那么查询的字符串就是 "()"f("()") = 1。 其他情况,比如 "((", "))", ")("f 值都是0。 哇!这是一个突破口!如果我们能找到一个 ) 的位置,比如说在 ba,那我们就可以用它来“检验”所有其他的字符了!对于任何一个 i,我们查询 ? 2 i ba,如果结果是1,那 s[i] 就一定是 (;如果是0,那 s[i] 就一定是 )

但是... 这需要 n-1 次查询来确定其他所有字符,对于 n=1000 来说太多了,肯定会超时的说!我们需要更高效的方法!

第一步:找到一个可靠的“锚点”!

我们的策略需要一个基准点,一个我们确定知道是 ) 的位置。怎么找呢? 我们可以利用 f() 函数的特性。f(t) > 0 意味着字符串 t 中至少存在一个 "()" 这样的子串。

考虑字符串 s 的前缀 s[0...i]。如果我们查询这个前缀,即 ? i+1 0 1 ... i,得到的结果 res = f(s[0...i])

  • 如果 res = 0,说明这个前缀里还没有形成任何一个 "()" 对。
  • 如果 res > 0,说明这个前缀里第一次出现了 "()" 对。

这个性质是单调的!也就是说,如果 f(s[0...i]) > 0,那么对于所有 j > if(s[0...j]) 也一定大于0。 看到“单调性”,主人你想到了什么?对啦!就是二分查找

我们可以二分查找最小的 i,使得 f(s[0...i]) > 0。这个 i 就是我们苦苦寻找的“锚点” ba!因为它是第一个让前缀出现 "()" 的位置,所以 s[ba] 必然是 ),并且在它前面一定有一个 ( 与它配对。这个过程只需要 log(n) 次查询,非常快喵~

第二步:最高效的魔法——信息压缩查询!

有了锚点 bas[ba] = ')'),我们虽然可以一个一个地测,但太慢了。能不能一次查询就确定一大批字符呢?答案是可以的!这就要用到一点点编码和数学的魔法了,喵~

我们的想法是:把一小组(比如10个)字符的信息,编码到一个整数里。 假设我们要确定 s[j]j 在某个小组里)是 ( 还是 )。我们可以利用 s[j]s[ba] 这个组合。如果 s[j] = '(',它就是 "()"f=1;如果 s[j] = ')',它就是 "))"f=0

我们想设计一个查询,它的结果 R 的二进制表示能告诉我们这一小组里哪些是 (。比如,如果 R 的第 k 位是1,就表示小组里第 k 个字符是 (

为了实现这个,我们需要让 s[j] 的贡献(如果它是 ( 的话)是 2^k。但我们只能通过构造 "()" 对来增加 f 的值。一个好消息是:一个由 v"()" 拼接成的字符串 t = "()()...()"f(t) 的值恰好是 1+2+...+v = v*(v+1)/2!这是一个三角数。

所以,我们的任务变成:对于每个要贡献 2^kj,我们需要构造一个查询子串,让它在 s[j] = '(' 时,能给总的 f 值增加 2^k。我们可以通过组合不同数量的 "()" 对来实现。任何一个正整数 X 都可以被贪心地表示成若干个三角数的和。

具体操作如下:

  1. 我们将 n 个下标分成若干组,比如每组10个。
  2. 对每一组,我们构造一个超长的查询。
  3. 对于组内的第 k 个字符(下标为 j),我们希望如果 s[j] = '(',它能对总的 f 值贡献 X = 2^k
  4. 我们用贪心法把 X 分解成三角数之和。例如,X=13,最大的不超过13的三角数是 10 = 4*(4+1)/2。所以我们先构造一个4对 "()" 的块。X 剩下 3。最大的不超过3的三角数是 3 = 2*(2+1)/2。我们再构造一个2对 "()" 的块。这样 13 就被分解完了。
  5. 我们将为组内所有 j 构造的这些小块全部拼接起来,形成一个大的查询字符串。因为我们的锚点 ba),它能有效地把不同 j 对应的块隔开,让它们的 f 值可以简单相加,互不干扰!
  6. 发起这个唯一的、精心设计的查询,得到结果 R
  7. 检查 R 的二进制位。如果 R 的第 k 位是1,就说明我们小组的第 k 个字符 s[j](

这样,我们用 1 次查询就能确定 10 个字符的身份!总查询次数大约是 log(n) + n/10,对于 n=1000,大概是 10 + 100 = 110 次,远远小于550次的限制!是不是很神奇的说?

代码实现喵

cpp
// 完整的AC代码,添加详细注释解释关键逻辑
#include <iostream>
#include <vector>
#include <algorithm>
#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;

        // 一个方便的 lambda 函数,用来向评测姬提问
        auto ask = [](const vector<int>& indices) -> int {
            if (indices.empty()) {
                return 0; // 空查询返回0
            }
            cout << "? " << indices.size();
            for (int idx : indices) {
                cout << " " << idx + 1; // 题目要求下标从1开始
            }
            cout << endl;
            cout.flush(); // 交互题一定要刷新缓冲区!
            int res;
            cin >> res;
            return res;
        };

        // --- 第一步:二分查找找到我们的锚点 'ba' ---
        int ba = -1; // ba 是我们找到的第一个 ')' 的位置
        int lo = 0, hi = n - 1;
        while (lo <= hi) {
            int mid = lo + (hi - lo) / 2;
            vector<int> prefixIndices;
            for (int i = 0; i <= mid; i++) {
                prefixIndices.push_back(i);
            }
            // 查询 s[0...mid] 这个前缀
            if (ask(prefixIndices) > 0) {
                ba = mid; // 找到了一个可能的答案,尝试找更靠前的
                hi = mid - 1;
            } else {
                lo = mid + 1; // 这个前缀还不够长
            }
        }
        
        // 如果二分没找到,说明第一个 '()' 对就是 s[0]s[1],那么 ba=1。
        // 不过上面的二分逻辑已经能正确处理这种情况了。
        // 这里为了保险,如果一个都没找到,说明整个串都是 `((...))` 或者 `))((` 这种,`s[0]` 肯定是 `(`,第一个 `)` 在后面。
        // 但题目保证了有 `(` 和 `)`,所以 `ba` 一定能找到。
        if (ba == -1) { 
            // 理论上不会到这里,但为了代码健壮性
            ba = 0; // 随便给个值,实际上二分肯定能找到
        }

        // --- 第二步:分组查询,解码出整个字符串 ---
        string ans(n, ')'); // 先假设所有字符都是 ')'
        const int GROUP_SIZE = 10; // 每次处理10个字符

        for (int start = 0; start < n; start += GROUP_SIZE) {
            int end = min(start + GROUP_SIZE - 1, n - 1);
            
            vector<int> queryIndices; // 构建这次查询用的下标列表
            
            // 为组内的每个字符构建查询块
            for (int j = start; j <= end; j++) {
                if (j == ba) continue; // 锚点自己不用查啦

                // 我们希望 s[j] = '(' 时,能对 f() 贡献 2^(j-start)
                int X = 1 << (j - start);

                // 贪心地将 X 分解成三角数之和
                while (X > 0) {
                    // 二分查找最大的 v,使得 v*(v+1)/2 <= X
                    int lowv = 1, highv = 100, v = 1; // v 不会很大
                    while (lowv <= highv) {
                        int midv = lowv + (highv - lowv) / 2;
                        long long T = static_cast<long long>(midv) * (midv + 1) / 2;
                        if (T <= X) {
                            v = midv;
                            lowv = midv + 1;
                        } else {
                            highv = midv - 1;
                        }
                    }

                    X -= v * (v + 1) / 2; // 从 X 中减去这个三角数

                    // 添加 v 对 (s[j], s[ba]) 到查询中
                    for (int rep = 0; rep < v; rep++) {
                        queryIndices.push_back(j);
                        queryIndices.push_back(ba);
                    }
                    // 添加一个 s[ba]作为分隔符,防止不同 j 的块互相影响
                    queryIndices.push_back(ba);
                }
            }
            
            // 移除最后一个多余的分隔符
            if (!queryIndices.empty()) {
                queryIndices.pop_back();
            }

            int res_query = 0;
            if (!queryIndices.empty()) {
                res_query = ask(queryIndices);
            }
            
            // --- 第三步:解码查询结果 ---
            for (int j = start; j <= end; j++) {
                if (j == ba) continue;
                int bit = j - start;
                // 如果结果的第 bit 位是 1,说明 s[j] 的贡献被计算了,所以 s[j] = '('
                if (res_query & (1 << bit)) {
                    ans[j] = '(';
                }
            }
        }
        
        // 锚点 ba 肯定是 ')',但它前面的那个 '(' 我们还没确定呢
        // 我们可以单独查询一次来确定 s[ba-1]
        // 但其实,我们已经把所有非 ba 的位置都确定了,s[ba] 我们也知道是 ')'
        // 剩下的那个没确定的 s[ba-1] 肯定是 '('
        // 咦,等一下,我的思路好像有点小问题
        // s[ba] 是第一个让前缀合法的 ')',和它配对的 '(' 可能在 ba-1,也可能在更前面
        // 啊,猫娘想明白了!我们已经确定了除了 ba 之外的所有字符。
        // 整个字符串中 `(` 和 `)` 的数量是相等的,都是 n/2。
        // 我们可以数一下已经确定的 `(` 的数量,如果不足 n/2,那剩下的未定位置(除了 ba)就都是 `(` 了。
        // 哦,代码的逻辑更简单!它直接把 ba 前面的那个字符也用分组查询的方式确定了!
        // 如果 `j == ba`,就跳过。啊,最后 `ans[ba]` 默认就是 `)`,这是对的。
        // 那和 `ba` 配对的那个 `(` 呢?
        // 哦!代码把所有非 `ba` 的位置都测了一遍,所以那个 `(` 也被测出来了!
        // 完美~

        cout << "! " << ans << endl;
        cout.flush();
    }
    return 0;
}

复杂度分析

  • 时间复杂度: O(n log n) 的说。

    • 找锚点 ba 的二分查找需要 log(n) 次查询,每次查询的长度最多为 n,所以这部分交互的耗时是 O(n log n)
    • 后面分组查询部分,共有 n / GROUP_SIZE 次查询。每次查询的准备过程,对于组内每个字符,都需要分解一个数,这个分解过程包含一个二分,所以构造查询的时间是 O(GROUP_SIZE * log(X) * log(v_limit))。总的 CPU 时间是 O(n * log(X) * log(v_limit)),近似 O(n)。所以总时间复杂度由交互时间主导,是 O(n log n)
  • 空间复杂度: O(n) 的说。

    • 我们需要 O(n) 的空间来存储最终的答案字符串 ans
    • 查询向量 queryIndices 的大小在每次循环中被重用。它的大小由 GROUP_SIZE 决定,我们计算过最大长度是几百,是一个和 n 无关的常量。所以空间复杂度是 O(n)

知识点与总结喵~

这真是一道设计精妙的交互题,它教会了我们很多东西呐!

  1. 交互题的通用思路: 核心是找到一种方式,用有限的、功能特殊的查询来逐步缩小未知信息的范围。
  2. 二分查找的应用: 当问题中出现单调性时,一定要优先考虑二分查找!它可以极大地降低寻找“临界点”的成本。
  3. 信息编码与压缩: 这是本题最亮眼的部分!将多个离散的是/否信息(字符是(还是))压缩到一个整数查询结果中,是解决查询次数限制问题的神来之笔。
  4. 巧用问题特性: f("()()...()") = v*(v+1)/2 这个三角数性质是编码的基石。发现并利用好题目给定的函数/操作的数学或组合特性,是解决难题的关键。
  5. 分治思想: 将整个大问题分解成一个个独立的小组来处理,每个小组内部用统一的方法解决,最后合并结果。这里的锚点 ba 保证了小组间的独立性,非常巧妙。

希望这篇题解能帮助到各位主人!遇到难题不要怕,像猫娘一样保持好奇心和热情,一步一步分析,总能找到线索的!加油喵~

Released under the MIT License.