NOIP 2024 第一题 编辑字符串 题解

作者: | 更新日期:

根据特殊性质 C 就可以推导出正确算法,并可推广到一般情形。

本文首发于公众号:天空的代码世界,微信号:tiankonguse

零、背景

之前已经分享了历年 CSP-J/S 的比赛题解。

今天开始,分享 NOIP 比赛题解。
为了避免四道题放在一篇导致文章过长,我计划每道题单独写一篇,最后再给出一个总结。

这篇文章分享 NOIP 2024 第一道题「编辑字符串」的题解。

PS:源码已上传网盘,公众号回复 NOIP 获取。

源码截图

一、题目

给两个长度为 n 且字符集为 01 的二元组字符串 S 和 T。
T 的值为 0 时,代表 S 对应位置的字符是不能动的。
T 的值为 1 时,代表 S 对应位置的字符是可以参与交换的。
交换规则:如果相邻两个字符都是可以交换,则可以进行任意次交换。

现在给你两个二元组字符串 <S0, T0><S1, T1>
在分别按照各自的 T 规则进行任意合法交换后,问 S0S1 最多有多少个位置值相同。

等价理解:对每个 T 中连续为 1 的区间 [L, R],S 在该区间内可以任意重排(只看区间内 0/1 的数量即可);T 为 0 的位置则固定不可动。

二、算法分析

先看数据范围与特判情形,分为五类:

1)n <= 10
2)特殊性质 A:S0 的所有字符都相同
3)特殊性质 B:T0 = T1
4)特殊性质 C:T0T1 各自恰好只有一个字符为 0
5)一般情形(随机输入)

数据范围

下面分别说明这些情形的处理方式,并逐步归纳到通用贪心。

假设没有交换限制

如果整个区间都可交换,那么只需要贪心:统计 S0S1 中相同字符的总数即可(本质是按字符计数匹配)。
复杂度:O(n)

数据范围 n <= 10

可以暴力枚举。
2^10 最多是 1024 种,对两个字符串分别枚举所有重排情况,取最大相同字符数。
复杂度:O(2^n * 2^n)

考虑到存在 T=0 的不可交换位置,实际上每段需要独立枚举,复杂度会更低。

特殊性质 A

S0 的所有字符都相同,则不管怎么交换,S0 不会改变;此时 S1 在任意重排后,与 S0 位置相同的字符也不变。
因此直接统计 S1 中与 S0 字符相同的个数即可。
复杂度:O(n)

特殊性质 B

T0 = T1,两者可任意交换的连续区间完全一致。
我们对每个连续区间独立处理,按计数贪心统计该区间内 S0S1 能匹配的相同字符总数。
复杂度:O(n)

特殊性质 C

T0T1 各自恰好只有一个 0 时,可根据两个 0 的位置将字符串划分成若干子串:

数据范围

一种自然的贪心是:用较短的可交换子串去匹配较长子串中的字符集合。
例如,用 T00 之前的子串 A 去匹配 T1ABC,尽可能匹配 A 的字符数;

然后用剩余字符去匹配 T0 的那个不可交换位置;
再用 T1ABC 剩余字符集合去匹配 T0 的后续集合 CDE
这个过程是递归/迭代式的:始终让“短串优先匹配长串”,并在每次匹配后删空已用完的段。

这个思路可以推广到存在任意多个 0 的场景。

一般情形(无特殊性质)

由对特殊性质 C 的分析可知,“按 0 将序列切段 + 短串优先匹配长串”的贪心,适用于任意多个 0 的情况。
因此在一般情形下也可直接采用该贪心。

三、代码实现

根据贪心过程,需要先依据 T0 的位置把字符串切分为若干子串。
对子串我们只关心字符计数(0/1 的数量)。
其中,T=0 的位置视为长度为 1 的不可交换子串;连续 T=1 的区间视为一个可交换子串。

struct Node {
  int type;        // TYPE_CONST 或 TYPE_SWAP
  int v[2] = {0, 0}; // 记录 0/1 计数
};
void Init(vector<Node>& nodes, char* s, char* t) {
  nodes.clear();
  nodes.push_back(Node(TYPE_CONST, 0));  // 哨兵,避免边界判断
  for (int i = 0; i < n; i++) {
    const int type = t[i] - '0';
    const int val = s[i] - '0';
    if (type == TYPE_CONST) {
      nodes.push_back(Node(TYPE_CONST, val));
    } else {
      if (nodes.back().type == TYPE_SWAP) {
        nodes.back().Add(val);
      } else {
        nodes.push_back(Node(TYPE_SWAP, val));
      }
    }
  }
}

之后采用“短串优先匹配长串”的步骤:枚举当前较短的尾部子串,尝试在较长子串中匹配字符。

int p0 = 0, p1 = 1;
if (nodes[p0].back().Size() > nodes[p1].back().Size()) {
  swap(p0, p1);
}
// 大的匹配小的
int v = 0;
if (!nodes[p0].back().TryPop(v)) {
  v = 1 - v;
}
if (nodes[p1].back().TryPop(v)) {
  ans++;
}

每次匹配后都要检查子串是否已空,为空则删除:

// 删除空的区间
for (int i = 0; i < 2; i++) {
  if (nodes[i].back().Size() == 0) {
    nodes[i].pop_back();
  }
}

整体代码量不大,思路也较清晰。

实现小技巧:

  • 不可交换的位置视为长度为 1 的子串;
  • 始终让短子串去匹配长子串;

这样实现过程中不需要额外判断“是否为不可交换字符”以及“谁长谁短”。

如果不进行封装,会出现大量分支。建议封装 MergeConstSwapMergeSwapSwap 两个函数,避免冗长的 if/else

if (nodes1.back().type == TYPE_CONST && nodes2.back().type == TYPE_CONST) {
  if (nodes1.back().val == nodes2.back().val) {
    ans++;
  }
  nodes1.pop_back();
  nodes2.pop_back();
} else if (nodes1.back().type == TYPE_CONST) {  // nodes2 swap
  MergeConstSwap(nodes1, nodes2);
} else if (nodes2.back().type == TYPE_CONST) {  // nodes1 swap
  MergeConstSwap(nodes2, nodes1);
} else {  // swap swap
  if (nodes1.back().Size() > nodes2.back().Size()) {
    MergeSwapSwap(nodes2, nodes1);
  } else {
    MergeSwapSwap(nodes1, nodes2);
  }
}

四、总结

这道题核心在于“按 0 切段 + 在每一步让短子串优先匹配长子串”的贪心。
在分析特殊性质 C 的过程中即可推导出这一思路,且它自然推广到任意多个 0 的一般情形。
实现层面,把 T=0 的位置视为长度 1 的区间,有助于统一处理逻辑,使代码更为简洁。

《完》

-EOF-

本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号 ID:tiankonguse-code

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越