「一本通 5.4 练习 3」动物园

GitHub跳转原题关系图返回列表

把每个小朋友只依赖的连续 5 个围栏压成 32 种窗口状态,滑动时右移补位并在窗口起点结算得分,最后枚举初窗口的 32 种取值破环取最大。

OJ: roj

题目 ID: 1596

难度:提高

标签:动态规划状态压缩环形DPpython

创建: 2026-09-30 20:56

更新: 2026-10-07 13:50

形式化题目

NN 个围栏编号 1,2,…,N1, 2, \ldots, N 顺时针围成一圈,每个围栏里的动物可以保留或移走。 把「保留」记作 11、「移走」记作 00,就得到一个环形 0/1 序列 b1,b2,…,bNb_1, b_2, \ldots, b_N。

有 CC 个小朋友,第 kk 个小朋友能看到从 EkE_k 起顺时针连续的 5 个围栏(越过 NN 则从 11 续), 并给出两个集合:害怕集合 AkA_k 与喜欢集合 LkL_k,两者都是这 5 个围栏编号的子集且互不相交。 第 kk 个小朋友高兴当且仅当

(∃ i∈Ak: bi=0)∨(∃ j∈Lk: bj=1),\bigl(\exists\, i \in A_k:\ b_i = 0\bigr) \quad \lor \quad \bigl(\exists\, j \in L_k:\ b_j = 1\bigr),

即「至少一个害怕的动物被移走」或「至少一个喜欢的动物被保留」。求高兴人数最大可能的取值。

其中 10⩽N⩽10410 \leqslant N \leqslant 10^4,1⩽C⩽5×1041 \leqslant C \leqslant 5 \times 10^4。

以样例 1 的第三个小朋友(E=6E = 6)为例:他害怕围栏 99、喜欢围栏 66 与 88。只要 b9=0b_9 = 0, 或 b6=1b_6 = 1,或 b8=1b_8 = 1,他就高兴;唯一让他不高兴的取值是 b9=1b_9 = 1、b6=b8=0b_6 = b_8 = 0。

正解

思路

朴素做法及其瓶颈。 最直接的想法是枚举 2N2^N 种留/移方案,对每种方案扫一遍全部 CC 个 小朋友统计高兴人数,复杂度 O(2NC)O(2^N C)。NN 最大 10410^4,完全不可行。瓶颈在于它把 NN 个围栏 当成彼此独立的决策,而题面其实给了一个很强的结构性事实:每个约束只依赖连续 5 个围栏。 这意味着每个围栏的影响半径是有限的——当我们的视线沿着环向前推进,一个围栏离开「最近 5 个 围栏」的窗口后,它就再也不会出现在任何尚未结算的约束里了。

把连续 5 个围栏压成状态。 既然任何时刻只有 5 个围栏的取值会影响未来,那就只记住它们。 定义窗口状态 ss 为一个 5 位掩码,第 jj 位(j=0,…,4j = 0, \ldots, 4)表示窗口起点后第 jj 个围栏 是否保留动物。共有 25=322^5 = 32 种状态。

窗口沿环前进一步时,起点处的那个围栏离开窗口,新围栏进入窗口末尾。用位运算描述:

s  ⟶  s≫1或s≫1∣16.s \;\longrightarrow\; s \gg 1 \quad\text{或}\quad s \gg 1 \mid 16.

旧位 jj 对应的围栏在新窗口中排在位置 j−1j - 1,所以整体右移一位;空出的第 4 位交给新围栏, 两个分支分别对应把新围栏的动物移走(s >> 1)和保留(s >> 1 | 16,16 = 1 << 4)。

把得分挂在窗口起点上。 一个小朋友的 5 个围栏,在窗口起点恰好等于他的 EE 时被完整覆盖, 这是他第一次也是唯一一次被完整覆盖。所以得分只需在那一刻结算。把他的两个集合也编码成 对齐的 5 位掩码(位 jj = 围栏 E+jE + j),高兴判定就退化成两个按位运算:

高兴  ⟺  (afraid  &  ∼s)≠0  ∨  (fond  &  s)≠0.\text{高兴} \iff (\texttt{afraid} \;\&\; \sim s) \neq 0 \;\lor\; (\texttt{fond} \;\&\; s) \neq 0.

左边是「有害怕的动物被移走」(∼s\sim s 是补集),右边是「有喜欢的动物被保留」。于是预计算

gain[e][s]=#{起点为 e 的小朋友在取舍 s 下高兴}.\mathrm{gain}[e][s] = \#\{\text{起点为 } e \text{ 的小朋友在取舍 } s \text{ 下高兴}\}.

以样例 1 的前四个窗口起点为例,表中位串从左到右是位 4→4 \to 位 00,即「窗口中最后一个围栏 →\to 第一个围栏」:

窗口起点 ee 该起点小朋友的 afraid / fond 得 1 分的状态数 得 0 分的状态
1 无 0 全部 32 个
2 00100 / 10001 28 00100 00110 01100 01110
3 01000 / 00010 24 01000 01001 01100 01101 11000 11001 11100 11101
4 无 0 全部 32 个

观察重点:得 0 分的状态恰好是「害怕的全留下、喜欢的全移走」的组合。起点 e=2e = 2 的小朋友 害怕围栏 44(位 2)、喜欢围栏 22 和 66(位 0、位 4),所以只有 ??1?0 且位 4 为 0、位 0 为 0 的 4 个状态让他不高兴——afraid & ~s 与 fond & s 同时为 0。起点 1 和 4 没有小朋友, 32 个状态一律得 0 分。

沿环递推。 设 dp[s]dp[s] 是以当前窗口为终点、已结算过的最高得分,则每前进一个窗口:

dp′[t]=max⁡s→t(dp[s])+gain[e][t],dp'[t] = \max_{s \to t} \bigl(dp[s]\bigr) + \mathrm{gain}[e][t],

其中 ee 是新窗口的起点,s→ts \to t 是上面两条转移。这是一个标准的「窗口 + 最大值」递推, 每步只处理 32 个状态、64 条转移。

破环。 环上没有天然的「第一个围栏」。处理办法是枚举第 1 个窗口(围栏 1∼51 \sim 5)的 全部 32 种状态 first,把环拆成 32 条链:

  1. 初值 dp[first]=gain[1][first]dp[\texttt{first}] = \mathrm{gain}[1][\texttt{first}],其余状态为 −∞-\infty;
  2. 窗口起点依次推到 2,3,…,N2, 3, \ldots, N,每步按上面的递推更新;
  3. 再做一次不带得分的平移,窗口回到起点 11(此时覆盖围栏 N+1∼N+5N+1 \sim N+5,模 NN 后 正是 1∼51 \sim 5);
  4. 只有 dp[first]dp[\texttt{first}] 才代表首尾吻合的合法环形安排,取 32 次枚举的最大值即答案。

第 3 步不计分,是因为起点 1 的那批小朋友在第 1 步已经算过;代码里给 gain 末尾追加一行 全 00 来实现这次纯平移。又因为 N⩾10>5N \geqslant 10 > 5,窗口前进 NN 步后不会和自己重叠, 「起点 N+1N+1」确实是一组完好的 5 个围栏 1∼51 \sim 5,第 4 步可以直接比较整个 5 位状态。

样例 1 取 first = 00000(围栏 1∼51 \sim 5 的动物全移走)时,NN 步累计分的最优值序列为

1, 2, 2, 2, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5,1,\ 2,\ 2,\ 2,\ 3,\ 3,\ 4,\ 4,\ 4,\ 4,\ 5,\ 5,\ 5,\ 5,

终值 dp[00000]=5dp[\texttt{00000}] = 5,即 5 个小朋友全部高兴,与样例输出一致。序列的台阶恰好落在 5 个小朋友的 EE 上:分数在窗口起点 2,3,6,8,122, 3, 6, 8, 12 处各涨一次,正是 5 个小朋友的起始围栏, 直观验证了「每个小朋友只在自己的窗口起点被结算一次」。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 21:05
# update_at: 2026-09-30 21:05

import sys

WINDOW = 5
STATES = 1 << WINDOW  # 窗口状态数:5 个围栏各「留 / 移」两种,共 32 种
NEG = -1 << 60        # 不可达状态的初值,比任何合法答案都小


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    fence = next(data)
    kids = next(data)

    # 按小朋友看到的起始围栏 E 分桶,桶里存 (害怕集合, 喜欢集合) 的 5 位掩码,
    # 位 j 表示他视野里第 j 个围栏(即编号 E+j),1 表示该围栏出现在对应名单里。
    by_start: list[list[tuple[int, int]]] = [[] for _ in range(fence + 1)]
    for _ in range(kids):
        start = next(data)
        feared = next(data)
        liked = next(data)
        # 围栏编号绕圈回绕:(编号 - E) mod N 就是它在窗口内的下标
        afraid = 0
        for _ in range(feared):
            afraid |= 1 << ((next(data) - start) % fence)
        fond = 0
        for _ in range(liked):
            fond |= 1 << ((next(data) - start) % fence)
        by_start[start].append((afraid, fond))

    # gain[e][s]:起始围栏为 e、5 位保留情况为 s 时,能高兴的小朋友数。
    # 高兴 <=> 有害怕的动物被移走 (afraid & ~s) 或 有喜欢的动物被留下 (fond & s)。
    # 位 j 对齐,故 s 可直接与两个掩码按位运算。
    # 末尾多出一行全 0:窗口滑到起点 N+1(就是起点 1)时只做平移、不重复计分。
    gain = [
        [sum(1 for afraid, fond in by_start[e] if afraid & ~s or fond & s) for s in range(STATES)]
        for e in range(1, fence + 1)
    ] + [[0] * STATES]

    # next_s: 窗口状态从 s 滑动一格后的两个候选(新围栏的动物留 / 移)。
    # 位 j 记录围栏 pos+j,滑动后旧位 j 变成新位 j-1,新位 4 就是刚进视野的围栏。
    next_s = [(s >> 1, s >> 1 | 16) for s in range(STATES)]

    best = 0
    # 圆环没有天然起点,只能枚举第 1 个窗口的 5 位状态;滑 N 格后必须回到同一个状态。
    for first in range(STATES):
        cur = [NEG] * STATES
        cur[first] = gain[0][first]  # gain[0] 对应起始围栏 1
        # 起点 1 已作为初值算过,再滑 N 次:前 N-1 次补齐起点 2..N,最后一次不带分回到起点 1。
        for row in gain[1:]:
            nxt = [NEG] * STATES
            for s, val in enumerate(cur):
                if val == NEG:
                    continue
                for t in next_s[s]:
                    cand = val + row[t]
                    if cand > nxt[t]:
                        nxt[t] = cand
            cur = nxt
        if cur[first] > best:  # 绕回起点,只有状态吻合的方案才是合法的圆环安排
            best = cur[first]

    print(best)


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-05 11:52
 * update_at: 2026-10-05 11:52
 */
// main.cpp:环形状态压缩 DP 求解最多高兴的小朋友数。
#include <iostream>
using namespace std;

typedef long long ll;

const int MAXN = 10005;      // 围栏数上限
const int MAXC = 50005;      // 小朋友数上限
const int STATES = 32;       // 连续 5 个围栏的留/移状态数 2^5
const int NEG = -1000000000; // 不可达状态的初值,比任何合法得分都小

ll fence_n;  // 围栏数 N
ll child_cnt; // 小朋友数 C

int head[MAXN];        // head[e]:起始围栏为 e 的小朋友链表头
int nxt_child[MAXC];   // 小朋友链表的后继
int afraid_mask[MAXC]; // 该小朋友害怕的围栏在窗口内的 5 位掩码,位 j 对应围栏 e+j
int fond_mask[MAXC];   // 该小朋友喜欢的围栏在窗口内的 5 位掩码
int edge_cnt;

int gain[MAXN][STATES]; // gain[e][s]:窗口起点为 e、5 位保留情况为 s 时高兴的小朋友数

int cur[STATES];    // 当前窗口状态的最高得分
int next_dp[STATES]; // 窗口右移一格后的最高得分

// 把小朋友挂到起点 e 的链表上。
void add_child(ll e, int afraid, int fond) {
    edge_cnt++;
    afraid_mask[edge_cnt] = afraid;
    fond_mask[edge_cnt] = fond;
    nxt_child[edge_cnt] = head[e];
    head[e] = edge_cnt;
}

// 预计算 gain 表:只在小朋友视野起点处结算一次得分。
void build_gain() {
    for (ll e = 1; e <= fence_n; e++) {
        for (int s = 0; s < STATES; s++) {
            int cnt = 0;
            for (int i = head[e]; i != 0; i = nxt_child[i]) {
                // 高兴:有害怕的动物被移走(掩码位为 0)或有喜欢的动物被保留(掩码位为 1)。
                if ((afraid_mask[i] & ~s) != 0 || (fond_mask[i] & s) != 0) {
                    cnt++;
                }
            }
            gain[e][s] = cnt;
        }
    }
}

void read_input() {
    cin >> fence_n >> child_cnt;
    for (ll k = 1; k <= child_cnt; k++) {
        ll e, f, l;
        cin >> e >> f >> l;
        int afraid = 0;
        for (ll j = 1; j <= f; j++) {
            ll x;
            cin >> x;
            ll idx = ((x - e) % fence_n + fence_n) % fence_n; // 围栏编号换算成窗口内下标 0..4
            afraid |= 1 << idx;
        }
        int fond = 0;
        for (ll j = 1; j <= l; j++) {
            ll y;
            cin >> y;
            ll idx = ((y - e) % fence_n + fence_n) % fence_n;
            fond |= 1 << idx;
        }
        add_child(e, afraid, fond);
    }
}

void solve() {
    int best = 0;
    // 圆环没有天然起点,枚举第 1 个窗口的 32 种取值,滑 N 格后要求状态回到同一个。
    for (int first = 0; first < STATES; first++) {
        for (int s = 0; s < STATES; s++) {
            cur[s] = NEG;
        }
        cur[first] = gain[1][first];

        // 窗口起点从 2 推到 N,每步右移一格并结算该起点的小朋友。
        for (ll e = 2; e <= fence_n; e++) {
            for (int s = 0; s < STATES; s++) {
                next_dp[s] = NEG;
            }
            for (int s = 0; s < STATES; s++) {
                if (cur[s] == NEG) {
                    continue;
                }
                int base = cur[s];
                int t = s >> 1; // 新进视野的围栏把动物移走
                if (base + gain[e][t] > next_dp[t]) {
                    next_dp[t] = base + gain[e][t];
                }
                t = (s >> 1) | 16; // 新进视野的围栏保留动物,16 = 1 << 4
                if (base + gain[e][t] > next_dp[t]) {
                    next_dp[t] = base + gain[e][t];
                }
            }
            for (int s = 0; s < STATES; s++) {
                cur[s] = next_dp[s];
            }
        }

        // 再做一次不带得分的平移:窗口回到起点 1(起点 N+1 模 N 后为 1),避免重复计分。
        for (int s = 0; s < STATES; s++) {
            next_dp[s] = NEG;
        }
        for (int s = 0; s < STATES; s++) {
            if (cur[s] == NEG) {
                continue;
            }
            int t = s >> 1;
            if (cur[s] > next_dp[t]) {
                next_dp[t] = cur[s];
            }
            t = (s >> 1) | 16;
            if (cur[s] > next_dp[t]) {
                next_dp[t] = cur[s];
            }
        }
        // 只有首尾状态吻合的方案才对应合法的圆环安排。
        if (next_dp[first] > best) {
            best = next_dp[first];
        }
    }
    cout << best << "\n";
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    read_input();
    build_gain();
    solve();

    return 0;
}

复杂度

  • 时间复杂度:O(32C+2048N)O(32C + 2048N)。预计算 gain 需要对每个窗口起点、每个状态扫该起点的小朋友, 而所有起点的小朋友数之和为 CC,故为 O(32C)O(32C);DP 为 32 次枚举 × N\times\ N 步 × 32\times\ 32 个状态 × 2\times\ 2 条转移。N=104, C=5×104N = 10^4,\ C = 5 \times 10^4 时约 2×1072 \times 10^7 次内层操作,最大数据实测约 0.9 s。
  • 空间复杂度:O(32N+C)O(32N + C)。gain 表为 (N+1)×32(N+1) \times 32 个整数,by_start 共 CC 个二元组, DP 数组只有 32 项;峰值实测约 15 MB,远低于 128 MB。

总结

  • 题面给出的关键结构是每个约束只依赖连续 5 个围栏。这直接把「2N2^N 枚举」压成 「O(N)O(N) 次窗口滑动」,状态数只有 25=322^5 = 32。
  • 局部性的用法是双向的:向前看只用 5 位状态(转移),向后看则可以在窗口滑过时一次性结算 该起点的小朋友(得分),两者合起来才把每个小朋友不重不漏地计入一次。
  • 环形问题的通用套路是枚举初窗口破环:N⩾10>5N \geqslant 10 > 5 时窗口不会自我重叠, 枚举第 1 个窗口的 32 种取值、要求首尾状态吻合,就能在 32 条链上取最大。
  • 掩码编码让「高兴判定」变成 afraid & ~s or fond & s 两个按位运算,避免了在热循环里 逐条遍历小朋友的名单。