CF1919E Counting Prefixes 题解

发布时间 2024-01-07 17:39:32作者: cccpchenpi

题目链接:https://codeforces.com/problemset/problem/1919/E

题意

输入一个单调非减序列 \(p\),求问有多少个序列 \(a\),使得:

  • \(|a_i| = 1\)

  • \(s_i = \sum_{j = 1}^i a_j\),则 \(s\) 排序后与 \(p\) 相同。

序列长度不超过 \(5000\)

题解

“排序后相同”这个条件等价于每个数的出现次数相同,也就是只要记录每个数的出现次数。为了方便分析,加入 \(p_0 = 0\)

unordered_map<int, int> cnt;
cnt[0]++;
for (int i = 0; i < n; i++) cin >> p[i], cnt[p[i]]++;

首先显然 \(p\) 的值域是一段包含 \(0\) 的连续序列 \([-a, b]\),可以一开始做完这个检查。

for (auto [k, v] : cnt) {
    if (k < 0) {
        if (!cnt.contains(k + 1)) {
            cout << 0 << "\n";
            return;
        }
    } else if (k > 0) {
        if (!cnt.contains(k - 1)) {
            cout << 0 << "\n";
            return;
        }
    }
}

\(a\) 为正负一,就代表 \(s\) 相邻两项是相邻数。观察 \(s\) 的可能形态,例如 \([0, 1, 2, 1, 2, 1, 0, -1, -2, -1, 0, 1, 2, 1]\)

用 x 轴切开这个序列,则:

  • \(s\) 被分为几段,其中除最后一段外开头和结尾均为 \(0\),而最后一段只需要以 \(0\) 开始。我们把前一种叫做“闭区间”,后一种叫做“开区间”。(注意,当结尾为 \(0\) 时,没有最后一段开区间)

  • 每一段内部要么全部大于 \(0\),要么全部小于 \(0\)

我们枚举开区间和闭区间各有多少段大于 \(0\),有多少段小于 \(0\),则可以按照“每一段内部都必须大于 \(0\)”计算,最终使用组合数合并答案即可。

Z ans = 0;
{
    int closed_cnt = cnt[0] - 1;
    for (int i = 0; i <= closed_cnt; i++) {
        for (int c = 0; c <= 1; c++) {
            ans += comb.get(closed_cnt, i) * calc(1, i, c) *
                    calc(-1, closed_cnt - i, 1 - c);
        }
    }
}

calc 这个子问题,考虑如何继续计算。使用一个扫描线从下往上扫描,则又有如下结论:

  • 一个闭区间如果填入 \(x\) 个数,则会变为 \(x - 1\) 个闭区间。(也就是只填入一个数时,开区间在上一层消失;且至少要填入一个数)

  • 一个开区间如果填入 \(x\) 个数,则会变为 \(x - 1\) 个闭区间和一个开区间。另外,如果填入 \(0\) 个数,这个开区间消失。

例如例子 \([0, 1, 2, 3, 2, 3, 2, 1, 0, 1, 2, 1]\)

image

由于有两个 \(0\),一开始有一个开区间和一个闭区间。

  • \(x = 0\)\(x = 1\) 扫描,第一个闭区间内有两个数,仍为一个闭区间;最后一个开区间内也有两个数,变为一个闭区间和一个开区间。

  • \(x = 1\)\(x = 2\) 扫描,第一个闭区间内有三个数,变为两个闭区间;第二个闭区间内有一个数,消失;最后一个开区间内没有数,消失。

  • \(x = 2\)\(x = 3\) 扫描,两个闭区间内均只有一个数,均消失。

  • 此时已经不存在任何区间,也不存在任何未使用的数,故这是一种可行的方案。

将状态记作 \((i, closed, open)\),我们已经知道了状态的转移,考虑如何计数。记 \(i\) 的出现次数为 \(x\),则:

  • \(open = 0\),也就是所有的数都要填到闭区间中。每个闭区间至少要填入一个数,使用插板法知有 \(\binom{x - 1}{open - 1}\) 种方案。由于每个闭区间都使产生的新闭区间会减少一个,转移至的状态一定为 \((i + 1, x - closed, 0)\),即 $calc(i, closed, 0) = \binom{x - 1}{open - 1} \cdot calc(i + 1, x - closed, 0) $。

  • 否则,\(open = 1\)。若这个区间内不填数,则相当于 \(open = 0\) 的情况。否则,这个开区间被当做闭区间使用,相当于多出了一个闭区间的 \(open = 0\) 的情况,唯一的区别是下一层 \(open\) 也要为 \(1\)

  • 边界情况下 \(x = 0\),此时只有 \(open = 0\)\(closed \le 1\),才是合法方案。

可以使用记忆化搜索实现。

unordered_map<i64, Z> cache;
function<Z(int, int, int)> calc = [&](int i, int closed_cnt, int open_cnt) {
    if (closed_cnt < 0) return Z(0);
    if (cnt[i] == 0) { return Z(closed_cnt == 0 && open_cnt <= 1); }
    i64 key = (i64)i * n * 2 + closed_cnt * 2 + open_cnt;
    if (cache.contains(key)) { return cache[key]; }
    Z res = 0;
    auto f = [&](int x, int closed_cnt, int open_cnt) -> Z {
        return comb.get(x - 1, closed_cnt - 1) *
                calc((i < 0) ? i - 1 : i + 1, x - closed_cnt, open_cnt);
    };
    int x = cnt[i];
    if (open_cnt) {
        res += f(x, closed_cnt + 1, 1);
        res += f(x, closed_cnt, 0);
    } else {
        res += f(x, closed_cnt, 0);
    }
    return cache[key] = res;
};

但需要注意,我们这样的计算中包含了“开区间一个数都没有”的情况,这样的情况下,开区间会被分为正负各计算一次,因此需要减去这样的重复情况修正。修正后统计答案的代码如下:

Z ans = 0;
{
    int closed_cnt = cnt[0] - 1;
    for (int i = 0; i <= closed_cnt; i++) {
        for (int c = 0; c <= 1; c++) {
            ans += comb.get(closed_cnt, i) * calc(1, i, c) *
                    calc(-1, closed_cnt - i, 1 - c);
        }
        ans -= comb.get(closed_cnt, i) * calc(1, i, 0) *
                calc(-1, closed_cnt - i, 0);
    }
}
cout << ans << "\n";

状态数是 \(O(n^2)\) 的,因此时空复杂度均为 \(O(n^2)\)。但我的提交的时空表现都很好,不知道是不是因为数据比较松。