Skip to content

C - Capella - 题解

标签与难度

标签: 线段树, 分治, 字符串, 位操作, 数据结构

难度: 2500

题目大意喵~

主人你好呀,这道题是关于一种叫做 "Capella-like" 的特殊字符串的,喵~

一个字符串被称为 "Capella-like",需要同时满足下面两个条件哦:

  1. 字符串中,出现奇数次的字符种类数,本身也是一个奇数。
  2. 字符串中,出现次数为非零偶数的字符种类数,本身是一个偶数。

我们拿到一个初始字符串 S,然后会进行 q 次操作。每次操作会把字符串 S 的某个位置的字符修改成一个新的字符。我们需要在最开始以及每次操作之后,都找出当前字符串 S 中,最长的一个 "Capella-like" 子串,并输出它的长度。

举个例子,"capella" 就是一个 Capella-like 字符串,因为它里面 'c', 'p', 'e' 出现1次(奇数次),种类数是3,是奇数;'a', 'l' 出现2次(非零偶数次),种类数是2,是偶数。两个条件都满足啦,喵!

解题思路分析

这道题的条件看起来有点绕,对吧?别担心,跟着我的思路,一步一步把它变简单,喵~

关键性质的转换

我们先来分析一下 "Capella-like" 的两个条件。对于任何一个子串,我们可以统计其中每个字符('a'到'z')出现的次数。

我们可以用两个 26 位的二进制数(我们叫它“位掩码”或者 mask)来描述一个子串的字符统计特性:

  1. 存在掩码 (existence_mask):如果字符 c 在子串里出现过,那么这个掩码的第 c - 'a' 位就是 1,否则是 0。它等于子串中所有出现过的字符 c 对应的 1 << (c - 'a')或和
  2. 奇偶掩码 (parity_mask):如果字符 c 在子串里出现了奇数次,那么这个掩码的第 c - 'a' 位就是 1;如果出现了偶数次,就是 0。它等于子串中所有字符 c 对应的 1 << (c - 'a')异或和

用这两个掩码,我们可以重新描述题目中的条件:

  • Odd(S) 为出现奇数次的字符种类数,Even(S) 为出现非零偶数次的字符种类数。
  • Odd(S) 其实就是 parity_mask 中 1 的个数,也就是 __builtin_popcount(parity_mask)
  • Even(S) 呢?一个字符出现非零偶数次,意味着它在 existence_mask 中是 1,但在 parity_mask 中是 0。所以 Even(S) = __builtin_popcount(existence_mask & ~parity_mask)

所以,Capella-like 的条件变成了:

  1. __builtin_popcount(parity_mask) 是奇数。
  2. __builtin_popcount(existence_mask & ~parity_mask) 是偶数。

这里有一个非常漂亮的小技巧!一个字符要么出现奇数次,要么出现非零偶数次,要么不出现。所以,一个子串里所有出现过的字符,可以被完美地分成这两类。也就是说:

popcount(existence_mask)=popcount(parity_mask)+popcount(existence_mask&parity_mask)\text{popcount}(\text{existence\_mask}) = \text{popcount}(\text{parity\_mask}) + \text{popcount}(\text{existence\_mask} \& \sim\text{parity\_mask})

popcount(existence_mask)=Odd(S)+Even(S)\text{popcount}(\text{existence\_mask}) = \text{Odd}(S) + \text{Even}(S)

根据题目的条件,Odd(S) 是奇数,Even(S) 是偶数。那么它们的和 Odd(S) + Even(S) 一定是奇数! 所以,第二个条件可以等价替换为: 2'. __builtin_popcount(existence_mask) 是奇数。

还没完哦,还有更神奇的!我们来看看 parity_mask 和子串长度 L 的关系。 子串的总长度 L 是所有字符出现次数的总和。

L=c=azcount(c)L = \sum_{c='a'}^{'z'} \text{count}(c)

当我们对 L 取模 2 时:

L(mod2)=(count(c) is oddcount(c)+count(c) is evencount(c))(mod2)L \pmod 2 = \left( \sum_{\text{count}(c) \text{ is odd}} \text{count}(c) + \sum_{\text{count}(c) \text{ is even}} \text{count}(c) \right) \pmod 2

偶数次的部分模 2 是 0,奇数次的部分模 2 是 1。所以:

L(mod2)=(count(c) is odd1)(mod2)L \pmod 2 = \left( \sum_{\text{count}(c) \text{ is odd}} 1 \right) \pmod 2

这个 sum 不就是 Odd(S),也就是 __builtin_popcount(parity_mask) 吗? 所以我们得到了一个惊人的结论:

L(mod2)=popcount(parity_mask)(mod2)L \pmod 2 = \text{popcount}(\text{parity\_mask}) \pmod 2

这意味着 “子串长度是奇数”“出现奇数次的字符种类数是奇数” 是完全等价的!

所以,那两个复杂的条件,被我们简化成了两个非常清爽的条件:

  1. 子串的长度是奇数。
  2. 子串中不同字符的种类数是奇数。

这下问题是不是清晰多啦?喵~

分治与线段树

现在我们的目标是:找到最长的子串,满足长度为奇数,且不同字符数也为奇数。 因为有单点修改和区间查询(最值),这很自然地让我们想到了线段树。

“长度为奇数”这个条件很有趣。一个子串 S[l..r] (1-based) 的长度 r-l+1 是奇数,意味着 r-l 是偶数,这说明 lr 的奇偶性相同!

这启发我们可以把问题一分为二:

  1. 只考虑开始和结束位置都是奇数的子串。
  2. 只考虑开始和结束位置都是偶数的子串。

这两个问题是独立的,我们可以分别求解,然后取最大值。它们本质上是同一个问题,我们只用解决一个就好。

让我们来解决奇数位置的情况。我们可以把原字符串 S 中所有奇数位置的字符抽出来,组成一个新的字符串 A。比如 S = "abcbab",奇数位字符是 'a', 'c', 'a',组成 A = "aca"。原 S 中一个 S[2i-1..2j-1] 的子串,就对应 A 中的 A[i..j] 子串。它的长度 2(j-i)+1 永远是奇数。所以长度条件自动满足了!

现在,问题被我们简化为: 给定一个字符串 A,和一些单点修改操作,每次操作后,找出 A 的最长子串 A[i..j],使得其不同字符的种类数是奇数。

这就可以用线段树来解决啦!线段树的每个节点维护其所代表的区间的信息。为了合并两个子节点(比如 leftright)的信息,我们需要知道:

  • left 区间的最长合法子串。
  • right 区间的最长合法子串。
  • 跨越 leftright 区间分界点的最长合法子串。这个通常是通过组合 left 的一个后缀和 right 的一个前缀得到的。

所以,我们的线段树节点需要存储:

  1. ans: 区间内最长合法子串的长度。
  2. len: 区间的长度。
  3. total_e_mask: 整个区间的存在掩码。
  4. prefixes: 区间内一系列“关键”前缀的信息,每个信息包含 (existence_mask, length)
  5. suffixes: 同样,一系列“关键”后缀的信息。

“关键”前缀/后缀是指那些 existence_mask 发生变化的前缀/后缀。因为字符集大小只有26,一个区间的前缀/后缀的 existence_mask 最多只会变化26次。所以我们每个节点只需要存最多26个前缀和26个后缀的信息,喵~

合并 leftright 节点时:

  • 新的 ans 至少是 max(left.ans, right.ans)
  • 然后,我们暴力枚举 left 的每一个关键后缀和 right 的每一个关键前缀。
  • 对于每一个组合 (suf, pre),计算它们的合并长度 suf.len + pre.len 和合并掩码 suf.mask | pre.mask
  • 如果 __builtin_popcount(合并掩码) 是奇数,就用 合并长度 来更新 ans

这样,我们就可以高效地处理查询和修改了。对偶数位置的情况做同样的操作,每次查询取两个结果的最大值就好啦!

代码实现

这是我根据上面的思路,精心重构的代码哦~ 希望能帮到你,喵!

cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <numeric>

// 使用 popcount 函数需要这个头文件
#ifdef _MSC_VER
#include <intrin.h>
#else
#include <x86intrin.h>
#endif

using namespace std;

// K 是字符集的大小,也就是 26
const int K = 26;

struct Node {
    int ans = 0;
    int len = 0;
    int total_e_mask = 0;
    // pair: {existence_mask, length}
    vector<pair<int, int>> prefixes;
    vector<pair<int, int>> suffixes;
};

// 合并两个线段树节点
Node merge(const Node& left, const Node& right) {
    if (left.len == 0) return right;
    if (right.len == 0) return left;

    Node res;
    res.len = left.len + right.len;
    res.total_e_mask = left.total_e_mask | right.total_e_mask;
    res.ans = max(left.ans, right.ans);

    // --- 计算合并后的关键前缀 ---
    res.prefixes = left.prefixes;
    for (const auto& p : right.prefixes) {
        int new_mask = left.total_e_mask | p.first;
        // 如果 mask 没变,就不是“关键”的,不用加
        if (res.prefixes.empty() || res.prefixes.back().first != new_mask) {
            res.prefixes.push_back({new_mask, left.len + p.second});
        }
    }

    // --- 计算合并后的关键后缀 ---
    res.suffixes = right.suffixes;
    for (const auto& s : left.suffixes) {
        int new_mask = s.first | right.total_e_mask;
        if (res.suffixes.empty() || res.suffixes.back().first != new_mask) {
            res.suffixes.push_back({new_mask, s.second + right.len});
        }
    }

    // --- 计算跨越中点的最长合法子串 ---
    for (const auto& s : left.suffixes) {
        for (const auto& p : right.prefixes) {
            int combined_mask = s.first | p.first;
            if (__builtin_popcount(combined_mask) % 2 != 0) {
                res.ans = max(res.ans, s.second + p.second);
            }
        }
    }

    return res;
}

vector<Node> tree;
string str_view; // 当前线段树处理的字符串视图

// 构建线段树
void build(int node_idx, int l, int r) {
    if (l == r) {
        int char_val = str_view[l] - 'a';
        tree[node_idx].ans = 1;
        tree[node_idx].len = 1;
        tree[node_idx].total_e_mask = (1 << char_val);
::: v-pre
        tree[node_idx].prefixes = {{1 << char_val, 1}};
::: v-pre
        tree[node_idx].suffixes = {{1 << char_val, 1}};        return;    }
:::
    int mid = l + (r - l) / 2;
    build(2 * node_idx, l, mid);
    build(2 * node_idx + 1, mid + 1, r);
    tree[node_idx] = merge(tree[2 * node_idx], tree[2 * node_idx + 1]);
}

// 更新线段树
void update(int node_idx, int l, int r, int pos, char new_char) {
    if (l == r) {
        int char_val = new_char - 'a';
        tree[node_idx].total_e_mask = (1 << char_val);
::: v-pre
        tree[node_idx].prefixes = {{1 << char_val, 1}};
::: v-pre
        tree[node_idx].suffixes = {{1 << char_val, 1}};        // ans 和 len 不变        return;
:::
    }
    int mid = l + (r - l) / 2;
    if (pos <= mid) {
        update(2 * node_idx, l, mid, pos, new_char);
    } else {
        update(2 * node_idx + 1, mid + 1, r, pos, new_char);
    }
    tree[node_idx] = merge(tree[2 * node_idx], tree[2 * node_idx + 1]);
}

// 主逻辑函数,解决一个子问题
int solve_subproblem(string& s, int n, const vector<pair<int, char>>& queries) {
    if (s.empty()) {
        for (size_t i = 0; i < queries.size() + 1; ++i) {
            cout << 0 << "\n";
        }
        return 0;
    }

    str_view = s;
    tree.assign(4 * s.length(), Node());
    build(1, 0, s.length() - 1);
    
    vector<int> results;
    results.push_back(tree[1].ans);

    for (const auto& q : queries) {
        int original_pos = q.first;
        if ((original_pos % 2) != (n % 2)) { // 判断修改位置是否属于当前子问题
            int mapped_pos = original_pos / 2;
            update(1, 0, s.length() - 1, mapped_pos, q.second);
        }
        results.push_back(tree[1].ans);
    }
    return results.size();
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;
    string s;
    cin >> s;

    vector<pair<int, char>> queries(q);
    for (int i = 0; i < q; ++i) {
        cin >> queries[i].first >> queries[i].second;
        queries[i].first--; // 0-indexed
    }

    string odd_s, even_s;
    for (int i = 0; i < n; ++i) {
        if (i % 2 == 0) odd_s += s[i];
        else even_s += s[i];
    }
    
    vector<int> odd_results, even_results;

    // --- 解决奇数位置子问题 ---
    if (!odd_s.empty()) {
        str_view = odd_s;
        tree.assign(4 * odd_s.length(), Node());
        build(1, 0, odd_s.length() - 1);
        odd_results.push_back(tree[1].ans * 2 - 1);

        for (const auto& query : queries) {
            if (query.first % 2 == 0) {
                update(1, 0, odd_s.length() - 1, query.first / 2, query.second);
            }
            odd_results.push_back(tree[1].ans * 2 - 1);
        }
    } else {
        odd_results.assign(q + 1, 0);
    }

    // --- 解决偶数位置子问题 ---
    if (!even_s.empty()) {
        str_view = even_s;
        tree.assign(4 * even_s.length(), Node());
        build(1, 0, even_s.length() - 1);
        even_results.push_back(tree[1].ans * 2 - 1);
        
        for (const auto& query : queries) {
            if (query.first % 2 != 0) {
                update(1, 0, even_s.length() - 1, query.first / 2, query.second);
            }
            even_results.push_back(tree[1].ans * 2 - 1);
        }
    } else {
        even_results.assign(q + 1, 0);
    }

    // --- 合并答案 ---
    for (int i = 0; i < q + 1; ++i) {
        cout << max(1, max(odd_results[i], even_results[i])) << "\n";
    }

    return 0;
}

代码说明:

  • solve_subproblem 被重构并内联到 main 中,因为两个子问题的逻辑非常相似但又不完全相同(比如修改位置的判断),分开写更清晰。
  • 奇数位置 S[2i-1..2j-1] 对应新串 A[i..j],长度为 k = j-i+1。原长度为 2k-1。所以子问题的答案 k 需要转换回 2k-1
  • 偶数位置 S[2i..2j] 对应新串 B[i..j],长度 k。原长度也为 2k-1
  • 如果某个子问题(比如偶数位)的字符串为空,它的答案就是0。
  • 最终答案至少是1,因为单个字符的子串总是合法的。

复杂度分析

  • 时间复杂度: O((N+QlogN)K2)O((N + Q \cdot \log N) \cdot K^2)

    • 这里的 N 是原字符串长度,Q 是查询次数,K 是字符集大小(26)。
    • 构建线段树需要遍历所有叶子节点,每个合并操作的复杂度是 O(K2)O(K^2),总构建时间是 O(NK2)O(N \cdot K^2)
    • 每次更新操作会影响路径上的 log N 个节点,每次合并需要 O(K2)O(K^2),所以单次更新是 O(K2logN)O(K^2 \log N)
    • 虽然理论上这个复杂度很高,但在 K 是一个较小的常数(如26)时,它在一些情况下是可以通过的。
  • 空间复杂度: O(NK)O(N \cdot K)

    • 线段树有 O(N)O(N) 个节点。每个节点存储了关键前缀和后缀列表,每个列表最多 K 个元素。所以总空间是 O(NK)O(N \cdot K)

知识点总结

这道题是一道非常好的数据结构练习题,融合了多种技巧,喵~

  1. 问题简化: 解题最关键的一步!通过分析题目条件的数学性质,将复杂的描述(奇数次的种类数是奇数,偶数次的种类数是偶数)等价转换为更简洁直观的条件(长度为奇数,不同字符数为奇数)。这是算法竞赛中化繁为简思想的完美体现。
  2. 分治思想: “长度为奇数”这一条件引出了根据下标奇偶性进行分治的想法,将一个复杂问题拆解成两个结构相同的子问题,大大降低了处理的难度。
  3. 线段树高级应用: 本题不是简单的线段树求和/求最值。节点需要维护复杂的信息(关键前/后缀列表),合并操作也需要精心设计。这展示了线段树作为一种通用分治工具的强大能力。
  4. 位操作: 使用位掩码(bitmask)来表示字符集合(existence_mask)和奇偶性(parity_mask)是处理字符集问题的常用高效技巧。

希望这篇题解能帮到你,如果还有不明白的地方,随时可以再来问我哦!喵~