题目大意

题目链接

给定一个长度为 \(n\) 的字符串和 \(m\) 个区间询问,每次询问给出一个区间 \([l, r]\),询问 \([l, r]\) 中有多少个区间 \([x, y]\) 满足 \(l \leq x \leq y \leq r\) 并且 \([x, y]\) 中的字符可以 经过重排 变成一个 回文串。换言之,每次询问给出一个区间并询问该区间有多少个子区间满足子区间中的字符可以经过重排变成回文串。对于每次询问,输出子区间的个数。

\(1 \leq n, m \leq 6 \times 10^4\)

解题思路

观察题目给出的条件,发现是区间不带修查询且可以离线,联系一下题目给出的要求,发现很难用线段树一类的数据结构维护,所以优先考虑使用 莫队 维护。总体来说,这道题主要使用 莫队 维护,通过推性质来确定维护方法。

我们发现题目给出的要求是求满足要求的子区间个数,不难联想到几道用前缀和的思想来维护的题目,例如 CF617E 或者 CF877F。回到题目,我们发现并没有什么可以直接用类似方法维护的信息。由于题目给出的回文串不好维护,可能会涉及到字符串算法,所以我们考虑将题目给出的条件转化成更容易维护的条件。

我们联系回文串的性质。可以经过重排变成回文串的字符串,一定满足出现次数为奇数的字符至多只有 \(1\) 个。换言之,我们关心每一个字符在区间 \([l, r]\) 内出现次数的奇偶性。我们可以考虑将这个性质表示出来,构造成一些信息然后用莫队维护。

观察数据范围,发现字符的取值范围在 a 到 z 之间,不难想到用 状压 来表示。对于一个区间 \([l, r]\),我们用一个 \(26\) 位的二进制数来表示每一个字符在区间 \([l, r]\) 中出现次数的奇偶性。对于字符 a,我们令最低位表示字符 a 在区间 \([l, r]\) 中的出现次数模 \(2\) 的余数,次低位表示字符 b 在区间 \([l, r]\) 中的出现次数模 \(2\) 的余数……最高位表示字符 z 在区间 \([l, r]\) 中出现次数模 \(2\) 的余数。

显然,如果一个区间内的字符可以重排得到回文串,那么与这个区间对应的二进制数相等的十进制数一定是 \(0\) 或者 \(2^x\),其中 \(0 \leq x < 26\)。具体原因是因为如果 a 到 z 在区间 \([l, r]\) 内都出现了偶数次,那么这个二进制数的每一位应该都是 \(0\)。反之,这个数一定包含且仅包含一个 \(1\),二进制表示只含一个 \(1\) 的数一定是 \(2\) 的整次幂。

于是我们可以快速地判断出一个区间是否可以经过重排得到回文串了。但是,对于每一个区间都存储一个 \(26\) 位的二进制数是不现实的。我们必须在有限的空间内表示出任意一段区间对应的取值,如果给出的特定运算满足 可加性和可减性,那么我们就可以用 前缀和 的思想来维护。沿着这个思路来考虑,我们只需要计算出每一个前缀区间对应的二进制数就可以了。换句话说,我们只需要用二进制数表示出满足 \(1 \leq i \leq n\) 的区间 \([1, i]\) 即可。

至于满足可加性和可减性的运算,因为有一道类似的题目 CF617E,所以我们可以考虑直接用 异或。不妨对于状压数组 \(k\)(\(k_i\) 表示区间 \([i, i]\) 的二进制表示)求一遍前缀异或和 \(p\),此时区间 \([l, r]\) 对应的二进制数为 \(p_r\) 与 \(p_{l - 1}\) 的异或和。

如果需要更严谨的思路,假设区间 \([1, l - 1]\) 对应的二进制表示为 \(a_1a_2a_3a_4...a_{26}\),区间 \([1, r]\) 对应的二进制表示为 \(b_1b_2b_3b_4...b_{26}\)。对于这两个二进制表示的第 \(i\) 位,如果第 \(i\) 位上的数字相同,说明两个区间内第 \(i\) 位对应的字符出现次数的奇偶性一致。因为第 \(i\) 个数的出现次数只有在增加偶数次时奇偶性才会不变,所以区间 \([l, r]\) 内第 \(i\) 位对应的字符出现次数一定是偶数。此时两个相同的数字异或得 \(0\),偶数模 \(2\) 的余数也为 \(0\),得到的表示还是合法。第 \(i\) 位上的数字不同的情况同理,这里不再赘述。

由此,我们可以快速地得到区间 \([l, r]\) 的二进制表示,问题就转化成了每次给定一个区间 \([l, r]\),问区间 \([l, r]\) 中有多少个区间满足异或和等于 \(0\) 或者 \(2^x\)。考虑直接用普通莫队维护。对于左端点移动的情况,设左端点为 \(l\),那么对于某个满足 \(l \leq i \leq r\) 的位置,如果 \(p_i\) 与 \(p_{l - 1}\) 的异或和为 \(0\) 或者 \(2^x\),那么区间 \([l, i]\) 一定是合法的。所以对于异或和为 \(0\) 的情况,端点 \(l\) 对于合法区间个数的贡献为满足条件的 \(i\) 的个数,也就是区间 \([l, r]\) 中与 \(p_{l - 1}\) 相等的元素个数,用桶来维护即可。

对于异或和为 \(2^x\) 的情况,设与 \(p_i\) 异或得到 \(2^x\) 的 \(p_j = y\),那么 \(p_i \oplus p_j = 2^x\)(\(\oplus\) 表示异或),那么因为 \(p_i \oplus (p_i \oplus 2^x) = 2^x\),所以 \(p_i \oplus 2^x = p_j\)。我们枚举每一个 \(0 \leq x < 26\) 的 \(x\),统计每个值 \(p_i \oplus 2^x\) 在桶内的出现次数之和即可。最后在每次左端点移动时增加或者删除贡献。

对于右端点移动的情况,因为 \(p_r\) 与 \(p_{i - 1}\) 的异或和对应区间 \([i, r]\),所以如果区间 \([l, r]\) 是合法的,那么 \(p_r\) 和 \(p_{l - 1}\) 的异或和也一定是 \(0\) 或者 \(2^x\)。因此在异或和为 \(0\) 的时候,端点 \(r\) 合法区间个数的贡献为区间 \([l - 1, r - 1]\) 内值 \(p_r\) 的出现次数。类似地,如果异或和为 \(2^x\),那么端点 \(r\) 的贡献为 \(0 \leq x < 26\) 的 \(p_r \oplus 2^x\) 在区间 \([l - 1, r - 1]\) 内的出现次数之和。

最后需要说的是,这道题有一些 小小的 卡空间。就算使用了前缀和,开一个大小为 \(2^{26}\) 的数组也肯定是会 \(MLE\) 的。因此我们桶的类型要开成 short。还有这道题可能有一些轻微的卡常,不过笔者一次卡过,所以个人认为这道题还不算特别卡常数。

具体见代码注释。

参考代码
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;

const int maxn = 6e4 + 5;
const int maxm = 6e4 + 5;
const int maxv = (1 << 26);

int n, m;
int p[maxn], bel[maxn];
long long cur;
long long ans[maxm];
short cnt[maxv];
char s[maxn];

struct ques
{
	int l, r, id;
	bool operator < (const ques& rhs) const
	{
		if (bel[l] ^ bel[rhs.l])
			return bel[l] < bel[rhs.l];
		// 奇偶性优化 
		return (bel[l] & 1 ? r < rhs.r : r > rhs.r);
	}
} q[maxm];

int main()
{
	int l = 1, r = 0;
	scanf("%d%d", &n, &m);
	scanf("%s", s + 1);
	int block = sqrt(n);
	for (int i = 1; i <= n; i++)
	{
		bel[i] = (i - 1) / block + 1;
		// (1 << (s[i] - 'a')) 表示区间 [i, i] 的二进制表示
		// 这样这句话就相当于直接做前缀和了 
		p[i] = p[i - 1] ^ (1 << (s[i] - 'a'));
	}
	for (int i = 1; i <= m; i++)
	{
		q[i].id = i;
		scanf("%d%d", &q[i].l, &q[i].r);
	}
	sort(q + 1, q + m + 1);
	for (int i = 1; i <= m; i++)
	{
		while (l < q[i].l)
		{
			// 需要删除端点 l 的贡献,因此最后 cnt[p[l]]-- 
			cur -= cnt[p[l - 1]];
			for (int j = 0; j < 26; j++)
				cur -= cnt[p[l - 1] ^ (1 << j)];
			cnt[p[l]]--;
			l++;
		}
		while (l > q[i].l)
		{
			// 需要增加端点 l 的贡献,因此开头 cnt[p[l]]-- 
			l--;
			cnt[p[l]]++;
			cur += cnt[p[l - 1]];
			for (int j = 0; j < 26; j++)
				cur += cnt[p[l - 1] ^ (1 << j)];
		}
		while (r < q[i].r)
		{
			// 需要增加端点 r 的贡献,因此开头 cnt[p[r]]++
			// 因为此时桶内存储的区间为 [l, r]
			// 所以还需要临时把 p[l - 1] 加入桶,最后删除 
			r++;
			cnt[p[l - 1]]++;
			cur += cnt[p[r]];
			for (int j = 0; j < 26; j++)
				cur += cnt[p[r] ^ (1 << j)];
			cnt[p[r]]++;
			cnt[p[l - 1]]--;
		}
		while (r > q[i].r)
		{
			// 此时我们需要的区间为 [l - 1, r - 1],和左端点不同
			// 所以我们删除 r,临时增加 l - 1 
			cnt[p[l - 1]]++;
			cnt[p[r]]--;
			cur -= cnt[p[r]];
			for (int j = 0; j < 26; j++)
				cur -= cnt[p[r] ^ (1 << j)];
			cnt[p[l - 1]]--;
			r--;
		}
		ans[q[i].id] = cur;
	}
	for (int i = 1; i <= m; i++)
		printf("%lld\n", ans[i]);
	return 0;
}