进阶指南 - 动态规划(一)

可以说是典中典题了。代码中也有很多输出方案的常用方法。

线性 DP

“线性 DP” 不是指线性复杂度,而是指动态规划的每个维度的转移都是线性的。解决这类问题的关键是要确定,在当前维度下,每个状态的求解只与之前的最优解有关

Mr Young's Picture Permutations

SPOJ GNYR04H on Luogu

给定一个三角矩阵的形状:有 \(k\) 行,每行左对齐有 \(N_1,N_2,\dots,N_k\) 列,满足 \(N_1\ge N_2\ge\dots\ge N_k\),且 \(\sum N_i=N\)。将 \(1\)\(N\) 填在这个三角矩阵中,使得每行从左到右递增、每列从上到下递增。求方案数。

\(N\le30,k\le5\)

注意到 \(k\) 很小,我们可以设一个五维的状态 \(F(x_1,x_2,x_3,x_4,x_5)\),表示第 \(1\)\(5\) 行分别站了几个人时的方案数。

接下来,我们按 \(1\)\(N\) 的顺序填入。考虑可以填在哪:

  • 如果 \(x_1<N_1\),那么 \(F(x_1+1,x_2,x_3,x_4,x_5)\) 加上 \(F(x_1,x_2,x_3,x_4,x_5)\)
  • 如果 \(x_2<N_2\)\(x_2<x_1\),那么 \(F(x_1,x_2+1,x_3,x_4,x_5)\) 加上 \(F(x_1,x_2,x_3,x_4,x_5)\)
  • ……(同 \(x_2\)

时间 \(O(能过)\)(划掉)。

while (cin >> k, k) {
    memset(a, 0, sizeof a);
    memset(f, 0, sizeof f);
    f(i, 1, k) cin >> a[i];
    f[0][0][0][0][0] = 1;
    f(a1, 0, a[1]) f(a2, 0, a[2]) f(a3, 0, a[3]) f(a4, 0, a[4]) f(a5, 0, a[5]) {
        ll t = f[a1][a2][a3][a4][a5];
        if (a1 < a[1]) f[a1 + 1][a2][a3][a4][a5] += t;
        if (a2 < a[2] && a1 > a2) f[a1][a2 + 1][a3][a4][a5] += t;
        if (a3 < a[3] && a2 > a3) f[a1][a2][a3 + 1][a4][a5] += t;
        if (a4 < a[4] && a3 > a4) f[a1][a2][a3][a4 + 1][a5] += t;
        if (a5 < a[5] && a4 > a5) f[a1][a2][a3][a4][a5 + 1] += t;
    }
    cout << f[a[1]][a[2]][a[3]][a[4]][a[5]] << '\n';
}

然而这道题是有数学背景的:杨氏矩阵和勾长公式。一些简短的定义:

  • (标准)杨氏矩阵(杨表):即为题目中填完数之后形成的矩阵,即形状是 “阶梯形”、每行每列元素递增。
  • 方格 \(v\) 的勾长 \(\operatorname{hook}(v)\):其同行右侧和同列下侧的方格个数的和,再加 \(1\)(该方格本身)。

利用勾长公式可以直接计算出答案:

\[\dim_{\lambda}=\frac{N!}{\prod_v\operatorname{hook}(v)}. \]

while (cin >> k, k) {
    ans = 1; n = 0;
    memset(h, 0, sizeof h);
    f(i, 1, k) cin >> a[i], n += a[i];
    f(i, 1, k) f(j, 1, a[i]) {
        h[i][j] += a[i] - j;
        f(p, 1, i) h[p][j] ++;
    }
    f(i, 1, k) f(j, 1, a[i]) {
        ans *= n--;
        ans /= h[i][j]; //尽量边乘边除, 不然小心爆int/ll
    }
    cout << (ll)round(ans) << '\n';
}

*LCIS

CF10D on Luogu

求两个数列 \(a,b\)(长度分别为 \(n,m\))的最长公共上升子序列。输出任意一种解即可。

\(1\le n,m\le500\)\(0\le a_i,b_i\le10^9\)

回顾求 LIS 和 LCS 的过程:

  • \(f(i,j)\) 表示前 \(i\) 位中以 \(a_j\) 结尾的 LIS 的长度,那么有 \(f(i,j)=\max\limits_{0\le k<j\land a_k<a_j}\{f(i-1,k)\}+1\),答案为 \(\max f(n,i)\)
  • \(f(i,j)\) 表示 \(a\) 的前 \(i\) 位和 \(b\) 的前 \(j\) 位的 LCS 长度,那么有 \(f(i,j)=\max\begin{cases}f(i-1,j),&\\f(i,j-1),&\\f(i-1,j-1)+1,&\mathrm{if\ }a_i=b_j.\end{cases}\)

我们结合一下,设 \(f(i,j)\) 表示 \(a\) 的前 \(i\) 位和 \(b\) 的前 \(j\) 位构成的、且以 \(b_j\) 结尾的 LCIS 长度。

那么有

\[f(i,j)=\begin{cases}f(i-1,j)&\mathrm{if\ }a_i\ne b_j,\\ \max\limits_{0\le k<j\land b_k<b_j}\{f(i-1,k)\}+1&\mathrm{if\ }a_i=b_j. \end{cases} \]

然而再多看一眼就会发现,\(O(n^3)\) 的时间复杂度会爆炸。

发现瓶颈在于求 \(\max\limits_{0\le k<j\land b_k<b_j}\{f(i-1,k)\}\),我们考虑如何优化。

一个较强的限制条件是 \(b_k<b_j\),即 \(b_k<a_i\)。考虑 DP 的过程,外层循环的 \(i\) 不变,此时对于所有 \(j\)能转移的 \(\boldsymbol k\) 的集合都不变,那么接下来要考虑的只有 \(0\le k<j\) 的条件,而这个条件由于内层循环顺序直接自动解决了。因此我们在外层循环中存一个 \(mx\) 表示当前的 \(\max\{f(i-1,k)\}\),每次转移之后,用 \(f(i-1,j)\) 更新 \(mx\)

至于输出方案,我们再开一个数组存一下转移路径,最后递归回去即可。时间 \(O(n^2)\)

vector<int> rec;
void print(int d, int j) {
    if (b[j] < 0) return;
    rec.push_back(b[j]);
    print(d - 1, g[d][j]);
    return;
}
signed main() {
    cin >> n; f(i, 1, n) cin >> a[i];
    cin >> m; f(i, 1, m) cin >> b[i];
    a[0] = b[0] = -1;
    f(i, 1, n) {
        int mx = f[i - 1][0], mxj = 0;
        f(j, 1, m) {
            if (a[i] == b[j]) f[i][j] = mx + 1, g[i][j] = mxj;
            else f[i][j] = f[i - 1][j], g[i][j] = g[i - 1][j];
            if (a[i] > b[j] && mx < f[i - 1][j])
                mx = f[i - 1][j], mxj = j;
        }
    }
    int ans = 0, ansj = 0;
    f(j, 1, m) if (ans < f[n][j])
        ans = f[n][j], ansj = j;
    cout << ans << '\n';
    if (ansj) print(n, ansj);
    reverse(rec.begin(), rec.end());
    for (int i: rec) cout << i << ' ';
    cout << '\n';
    return 0;
}

*Making the Grade

Luogu P2893

给定长度为 \(n\) 的数列 \(a\),构造一个长度同样为 \(n\) 的数列 \(b\),满足:

  • \(b\) 非严格单调;
  • 最小化 \(S=\sum|a_i-b_i|\)

求出最小化的 \(S\)\(1\le n\le2000\)\(1\le|a_1|\le10^9\)

\(b\) 可能为非严格单调递增或非严格单调递减,直接正反跑两次就行了。下面只讨论非严格单调递增的情况。

引理 在满足最小化 \(S\) 的情况下,一定存在一种构造 \(b\) 的方案,使得 \(b\) 中的数值都在 \(a\) 中出现过。

证明 考虑数学归纳法。命题对 \(n=1\) 显然成立。设命题对 \(n\le k-1\)\(k\ge2\))成立。当 \(n=k\) 时,我们分情况讨论:

  • \(b_{k-1}\le a_k\),那么令 \(b_k=a_k\),满足 \(b\) 单调不下降,且 \(S\) 最小,命题成立;
  • \(b_{k-1}>a_k\),则令 \(b_k=b_{k-1}\)。满足 \(b\) 单调不下降。那么 \(S\) 为什么最小呢?设 \(b_j=b_{j+1}=\dots=b_{k-1}=b_k=v\),设 \(a_j,a_{j+1},\dots,a_{k-1},a_k\)中位数\(m\)。若 \(m\ge b_{j-1}\),那么令 \(v=m\),这样差的绝对值的和最小;否则,令 \(v=b_{j-1}\),这样所构造的这段 \(b\) 离对应的 \(a\) 最近。

因此,\(b_k\) 一定在 \(a\) 中出现过。证毕。

仿照 LIS,我们设 \(f(i,j)\) 表示完成前 \(i\) 项的构造、且 \(b_i=a_j\) 时的 \(S\)。那么有

\[f(i,j)=\min_{a_k\le a_j}\{f(i-1,k)\}+|a_i-a_j|. \]

注意到这个转移方程可以拆成两项。前一项与 \(j\) 有关,由于每次改变 \(i\)\(j\) 都是相同的,因此前一项在第一维为 \(i-1\) 时可以提前处理出来。而后一项只与 \(i,j\) 有关。综上,我们可以做到 \(O(1)\) 转移,而不必每次寻找 \(\min\)

总时间复杂度 \(O(n^2)\)

有一个 \(O(n\log n)\) 的优先队列做法,证明方法似乎是维护下凸壳,然而我根本看不懂 = =||。参见 P4597 序列 sequence 题解区。(@dbxxx 说贪心证法是假的。)

顺便滚动一下数组。

void solve() {
    sort(c + 1, c + n + 1);
    int t = 0;
    f(i, 1, n) {
        t ^= 1;
        memset(minf[t], 0x3f, sizeof minf[t]);
        f(j, 1, n) {
            f[t][j] = minf[t ^ 1][j] + abs(a[i] - c[j]);
            minf[t][j] = min(f[t][j], minf[t][j - 1]);
        }
    }
    f(j, 1, n) ans = min(ans, f[t][j]);
    return;
}
signed main() {
    cin >> n;
    f(i, 1, n) cin >> a[i], c[i] = a[i];
	solve();
    reverse(a + 1, a + n + 1); //反着再跑一遍
    memset(f, 0, sizeof f);
    memset(minf, 0, sizeof minf);
    solve();
    cout << ans << '\n';
    return 0;
}

另外,还有一个套路的 trick:

  • 把一个严格单调递增的数列 \(a\) 映射成非严格单调递增的数列 \(a'\):令 \(a'(i)=a(i)-i\)

Mobile Service

SPOJ SERVICE on Luogu

有一个公司有三个流动员工。每次只有一名员工可以移动,不允许同一位置上同时有两个及以上员工

每次移动需要花费,从位置 \(p\) 移动到位置 \(q\) 需要花费 \(c(p,q)\) 的价钱。不移动不需要花费(即 \(c(i,i)=0\)),但不保证 \(c(p,q)=c(q,p)\)

现在给出 \(N\) 个请求,第 \(i\) 个请求发生在位置 \(p_i\)。公司必须按照顺序,派一名员工到位置 \(p_i\)过程中不能去其他地方,也就是必须直接过去。

三个流动员工的初始位置分别为 \(1,2,3\)。求公司的最小花费。

对于 \(100\%\) 的数据满足 \(3\le L\le200\)\(1\le N\le100\)\(0\le c(i,j)\le2000\)

(输出路径且 256 MB 版:SPOJ SERVICEH - Mobile Service Hard;输出路径且 64 MB 版:P7685 [CEOI2005] Mobile Service。)

员工数量很少,我们可以直接表示他们的状态。而阶段也很好设,就是当前完成了几个请求。

\(f(i,x,y,z)\) 表示刚完成第 \(i\) 个请求,三个员工分别在 \(x,y,z\) 处的最小花费。

然而这样设的话,会有冗余的信息:既然刚完成第 \(i\) 个请求,那么一定有一个员工在 \(p_i\) 处。并且,我们并不关心具体是哪个员工在哪个位置,而是关心三个员工所在位置的无序集合。

\(f(i,x,y)\) 表示刚完成第 \(i\) 个请求,三个员工分别在 \(x,y,p_i\) 处的最小花费。考虑 \(f(i,x,y)\) 三个员工的贡献,写出状态转移方程:

\[\begin{aligned} f(i+1,x,y)\gets\min\{f(i+1,x,y),f(i,x,y)+c(p_i,p_{i+1})\},\\ f(i+1,p_i,y)\gets\min\{f(i+1,p_i,y),f(i,x,y)+c(x,p_{i+1})\},\\ f(i+1,x,p_i)\gets\min\{f(i+1,x,p_i),f(i,x,y)+c(y,p_{i+1})\}. \end{aligned} \]

为了方便,不妨设 \(p_0=3\),那么初值为 \(f(0,1,2)=0\),答案为 \(\min\limits_{x,y}f(n,x,y)\)

顺便滚动一下数组。

cin >> l >> n;
memset(c, 0x3f, sizeof c);
f(i, 1, l) {
    c[i][i] = 0;
    f(j, 1, l) cin >> c[i][j];
}
f(i, 1, n) cin >> p[i];
memset(f, 0x3f, sizeof f);
f[0][1][2] = 0;
p[0] = 3;
int t = 1;
f(i, 0, n - 1) {
    t ^= 1;
    memset(f[t^1], 0x3f, sizeof f[t^1]);
    f(x, 1, l) {
        f(y, 1, l) {
            int z = p[i], u = p[i+1];
            if (x == y || x == z || y == z) continue;
            f[t^1][x][y] = min(f[t^1][x][y], f[t][x][y] + c[z][u]);
            f[t^1][x][z] = min(f[t^1][x][z], f[t][x][y] + c[y][u]);
            f[t^1][z][y] = min(f[t^1][z][y], f[t][x][y] + c[x][u]);
        }
    }
}
t ^= 1;
f(x, 1, l) f(y, 1, l) ans = min(ans, f[t][x][y]);
cout << ans << '\n';

传纸条

Luogu P1006

给定一个 \(N\times M\) 的矩阵 \(A\),每个格子中有一个整数。现在需要找到两条从左上角 \((1,1)\) 到右下角 \((N,M)\) 的路径,路径上的每一步只能向右或向下走。路径经过的格子中的数会被取走,若两条路径同时经过一个格子,只算一次。求取得的数之和最大是多少。

\(N,M\le50\)

首先 DP 状态一定是要包含走的步数 \(i\) 的(设在 \((1,1)\) 时是第 \(1\) 步)。对于这种方格里向右或向下走的题,有一个常用的等式:

\[x+y=i+1. \]

因此设 \(f(i,x_1,x_2)\) 表示步数为 \(i\),第一条路径走到 \((x_1,i+1-x_1)\),第二条路径走到 \((x_2,i+1-x_2)\) 的答案。那么有

\[f(i,x_1,x_2)\gets\max\begin{cases} f(i-1,x_1,x_2),\\ f(i-1,x_1-1,x_2),\\ f(i-1,x_1,x_2-1),\\ f(i-1,x_1-1,x_2-1) \end{cases}+a_{x_1,i+1-x_1}+a_{x_2,i+1-x_2}\times[x_1\ne x_2]. \]

初值 \(f(1,1,1)=0\),答案 \(f(n+m-1,n,n)\)

顺便滚动一下数组。注意,为了满足无后效性,\(x_1,x_2\) 要倒序枚举。

cin >> n >> m;
f(i, 1, n) f(j, 1, m) cin >> a[i][j];
f(i, 2, n + m - 1) {
    int _ = min(i, n);
    g(x1, _, 1) {
        int y1 = i + 1 - x1;
        g(x2, _, 1) {
            int &t = f[x1][x2];
            int y2 = i + 1 - x2;
            if (y1 < 1 || y2 < 1) continue;
            t = max(t, f[x1 - 1][x2]);
            t = max(t, f[x1][x2 - 1]);
            t = max(t, f[x1 - 1][x2 - 1]);
            t += a[x1][y1] + (x1 != x2) * a[x2][y2];
        }
    }
}
cout << f[n][n] << '\n';

*I-country

AcWing 276 | CH 5104 | SGU167 on Codeforces

\(N\times M\) 的矩阵中,每个格子有一个权值,要求寻找一个包含 \(K\) 个格子的凸连通块(连通块中间没有空缺,并且轮廓是凸的,如图所示),使这个连通块中的格子的权值和最大。求出这个最大的权值和,并给出连通块的具体方案。

\(N,M\le15\)\(K\le N\times M\)。每个数字都在 \(0\)\(1000\) 的范围内。

一道状态设计非常神奇的题。

设第 \(i\) 行选中的格子范围为 \([l_i,r_i]\),共有 \(m\) 行,可以发现,一定存在 \(p,q\) 满足

\[\begin{gathered} l_1\ge\dots\ge l_{p-1}\ge l_p\le l_{p+1}\le\dots\le l_m,\\ r_1\le\dots\le r_{q-1}\le r_q\ge r_{q+1}\ge\dots\ge r_m. \end{gathered} \]

即左端点先递减后递增,右端点先递增后递减。(非严格)

我们以行为阶段进行转移。为了确定这一行都可以选哪些格子,我们需要知道:

  • 目前为止共选了几个格子;
  • 上一行选了哪些格子;
  • 左端点目前是递增还是递减;
  • 右端点目前是递增还是递减。

\(f(i,j,l,r,x,y)\) 表示当前在第 \(i\) 行,目前为止一共选了 \(j\) 个格子,上一行选了 \([l,r]\),左右端点目前递增 / 递减状态分别是 \(x,y\) 的答案,其中 \(x\)\(y\) 等于 \(0\) 表示递增,\(1\) 表示递减。

分类讨论:

  1. 左递减、右递增,即左、右均扩张:

    需要考虑刚开始的情况,即 \(j=r-l+1\) 的情况,第 \(i\) 行即为开始选的第一行。

    \[f(i,j,l,r,1,0)=\sum_{k=l}^ra_{i,k}+\begin{cases} f(i-1,0,0,0,1,0), & \mathrm{if\ }j=r-l+1,\\ \max\limits_{l\le p\le q\le r}\{f(i-1,j-(r-l+1),p,q,1,0)\} & \mathrm{if\ }j>r-l+1. \end{cases} \]

  2. 左递增、右递增,即左收缩、右扩张:

    讨论上一行的左端点是扩张还是收缩。

    注意,枚举 \(p,q\) 时,由于必须连通,所以要保证 \(l\le q\)

    \[f(i,j,l,r,0,0)=\sum_{k=l}^ra_{i,k}+\max_{1\le p\le l\le q\le r}\left\{\max_{0\le x\le1}f(i-1,j-(r-l+1),p,q,x,0)\right\}. \]

  3. 左递减、右递减,即左扩张、右收缩:

    讨论上一行的右端点是扩张还是收缩。

    同样,要保证 \(r\le p\)

    \[f(i,j,l,r,1,1)=\sum_{k=l}^ra_{i,k}+\max_{l\le p\le r\le q\le n}\left\{\max_{0\le y\le1}f(i-1,j-(r-l+1),p,q,1,y)\right\}. \]

  4. 左递增、右递减,即左、右均收缩:

    讨论上一行的左、右端点是扩张还是收缩。

    \[f(i,j,l,r,0,1)=\sum_{k=l}^ra_{i,k}+\max_{1\le p\le l,r\le q\le n}\left\{\max_{0\le x,y\le 1}f(i-1,j-(r-l+1),p,q,x,y)\right\}. \]

初值:\(f(i,0,0,0,1,0)=0\),答案 \(\max\{f(i,K,l,r,x,y)\}\)

时间 \(O(nm^4k)\),设 \(n,m\) 同阶、\(n^2,m^2,k\) 同阶,则时间复杂度为 \(O(n^7)\)

对于输出方案,再开一个六元组的数组记录每一个状态是从哪个状态转移过来的,最后从答案递归回去即可。

const int N = 16;
int n, m, k, a[N][N], f[N][N * N][N][N][2][2], ans;
struct State {
    int i, j, l, r, x, y;
} g[N][N * N][N][N][2][2], state;
signed main() {
    cin >> n >> m >> k;
    f(i, 1, n) f(j, 1, m) cin >> a[i][j], a[i][j] += a[i][j - 1];
    f(i, 1, n) f(j, 0, k) f(l, 1, m) f(r, l, m) {
        if (j < r - l + 1) continue;
        // 1. 左扩张, 右扩张
        {
            auto &vf = f[i][j][l][r][1][0];
            auto &vg = g[i][j][l][r][1][0];
            f(p, l, r) f(q, p, r) {
                int val = f[i - 1][j - (r - l + 1)][p][q][1][0];
                if (vf < val) {
                    vf = val;
                    vg = {i - 1, j - (r - l + 1), p, q, 1, 0};
                }
            }
            vf += a[i][r] - a[i][l - 1];
        }
        // 2. 左扩张, 右收缩
        {
            auto &vf = f[i][j][l][r][1][1];
            auto &vg = g[i][j][l][r][1][1];
            f(p, l, r) f(q, r, m) f(y, 0, 1) {
                int val = f[i - 1][j - (r - l + 1)][p][q][1][y];
                if (vf < val) {
                    vf = val;
                    vg = {i - 1, j - (r - l + 1), p, q, 1, y};
                }
            }
            vf += a[i][r] - a[i][l - 1];
        }
        // 3. 左收缩, 右扩张
        {
            auto &vf = f[i][j][l][r][0][0];
            auto &vg = g[i][j][l][r][0][0];
            f(p, 1, l) f(q, l, r) f(x, 0, 1) {
                int val = f[i - 1][j - (r - l + 1)][p][q][x][0];
                if (vf < val) {
                    vf = val;
                    vg = {i - 1, j - (r - l + 1), p, q, x, 0};
                }
            }
            vf += a[i][r] - a[i][l - 1];
        }
        // 4. 左收缩, 右收缩
        {
            auto &vf = f[i][j][l][r][0][1];
            auto &vg = g[i][j][l][r][0][1];
            f(p, 1, l) f(q, r, m) f(x, 0, 1) f(y, 0, 1) {
                int val = f[i - 1][j - (r - l + 1)][p][q][x][y];
                if (vf < val) {
                    vf = val;
                    vg = {i - 1, j - (r - l + 1), p, q, x, y};
                }
            }
            vf += a[i][r] - a[i][l - 1];
        }
    }
    f(i, 1, n) f(l, 1, m) f(r, l, m) f(x, 0, 1) f(y, 0, 1) {
        int val = f[i][k][l][r][x][y];
        if (ans < val) {
            ans = val;
            state = {i, k, l, r, x, y};
        }
    }
    cout << "Oil : " << ans << '\n';
    while (state.j) {
        f(i, state.l, state.r) cout << state.i << ' ' << i << '\n';
        state = g[state.i][state.j][state.l][state.r][state.x][state.y];
    }
    return 0;
}

*Cookies

AcWing 277

圣诞老人共有 \(M\) 个饼干,准备全部分给 \(N\) 个孩子。每个孩子有一个贪婪度,第 \(i\) 个孩子的贪婪度为 \(g_i\)。如果有 \(a_i\) 个孩子拿到的饼干数比第 \(i\) 个孩子多,那么第 \(i\) 个孩子会产生 \(g_i\times a_i\) 的怨气。给定 \(N,M\) 和序列 \(g\),圣诞老人请你帮他安排一种分配方式,使得每个孩子至少分到一块饼干,并且所有孩子的怨气总和最小。

\(1\le N\le30\)\(N\le M\le5000\)

首先这个题看起来就很贪心。感性理解一下,我们猜想,每个孩子拿到的饼干数量随贪婪度递减而递减。

考虑用调整法证明:设 \(g_i>g_j\)。若 \(i\)\(j\) 拿的多,那么这一对的代价为 \(g_j\),否则,代价为 \(g_i\)。因此 \(i\)\(j\) 拿的多更优。

然而,每个人究竟该拿多少呢?接下来我们用 DP 解决这个问题。

将孩子按照 \(g\) 从大到小排序。设 \(f(i,j)\) 表示前 \(i\) 个孩子一共分了 \(j\) 块饼干的答案。

设孩子 \(i\) 拿了 \(c_i\) 个饼干。转移有两种情况:

  • \(c_{i+1}<c_i\)\(a_{i+1}=i\)
  • \(c_{i+1}=c_i\)\(a_{i+1}\)\(i\) 减去 \(a_{i+1}\) 前面与 \(a_i\) 相等的连续段的长度。

因此我们需要知道 \(c_i\) 的值才能转移。

画出 \(c\) 的图象是这样的:

神奇的一步来了:观察图中的形状,我们不妨对转移做一个等价转换

  • \(c_i>1\),那么等价于从 \(1\)\(i\)所有人少拿一块饼干,相对大小不变,所以 \(a\) 不变;
  • \(c_i=1\),那么枚举 \(i\) 前面有几个人和 \(i\) 一样拿了 \(1\) 个饼干,然后进行转移。

如下图:

于是,枚举 \(k\) 表示从 \(k+1\)\(i\) 都拿了 \(1\) 个,那么有:

\[f(i,j)=\min\begin{cases}f(i,j-i),\\ \min\limits_{0\le k<i}\left\{f(k,j-(i-k))+k\times\sum\limits_{p=k+1}^ig_p\right\}.\end{cases} \]

初值:\(f(0,0)=0\),答案:\(f(n,m)\)

本题还要求输出 \(c\)。首先 DP 一遍,并且记录如何转移,然后再复现一遍,同时统计 \(c\) 数组。

int n, m, g[33], f[33][5010], ans[33], id[33]; //g: 原g数组的前缀和
pii rec[33][5010];
inline bool cmp1(int const &p, int const &q) { return g[p] > g[q]; }
inline bool cmp2(int const &p, int const &q) { return p > q; }

void get_ans(int i, int j) {
    if (!i) return;
    get_ans(rec[i][j].first, rec[i][j].second);
    if (rec[i][j].first == i)
        f(k, 1, i) ++ans[id[k]];
    else f(k, rec[i][j].first + 1, i) ++ans[id[k]];
    return;
}

signed main() {
    cin >> n >> m;
    f(i, 1, n) cin >> g[i], id[i] = i;
    sort(id + 1, id + n + 1, cmp1);
    sort(g + 1, g + n + 1, cmp2);
    f(i, 2, n) g[i] += g[i - 1];
    memset(f, 0x3f, sizeof f);
    f[0][0] = 0;
    f(i, 1, n) {
        f(j, i, m) {
            f[i][j] = f[i][j - i];
            rec[i][j] = (pii){i, j - i};
            f(k, 0, i - 1) {
                int val = f[k][j - (i - k)] + k * (g[i] - g[k]);
                if (f[i][j] > val) {
                    f[i][j] = val;
                    rec[i][j] = (pii){k, j - (i - k)};
                }
            }
        }
    }
    cout << f[n][m] << '\n';
    get_ans(n, m);
    f(i, 1, n) cout << ans[i] << ' ';
    cout << '\n';
    return 0;
}

这道题启发我们:

  • 可以利用贪心思想或其他算法,来确定 DP 的阶段的顺序。
  • 有时找到一个状态的等效状态,可以大大减少需要转移的状态。

背包

背包是线性 DP 中一类重要而特殊的模型。

0/1 背包模型

给定 \(N\) 个物品,其中第 \(i\) 个物品的体积为 \(V_i\),价值为 \(W_i\)。有一容积为 \(M\) 的背包,要求选择一些物品放入背包,使得物品总体积不超过 \(M\) 的前提下,物品的价值总和最大。

\(f(i,j)\) 表示:从前 \(i\) 个物品中选出了总体积为 \(j\) 的物品放入背包,物品的最大价值总和。转移方程:

\[f(i,j)=\max\begin{cases}f(i-1,j),\\ f(i-1,j-V_i)+W_i, & \mathrm{if\ }j\ge V_i. \end{cases} \]

初值 \(f(0,0)=0\),其他为负无穷。答案 \(\max_jf(n,j)\)

滚动数组后,为了满足无后效性,需要倒序枚举 \(j\)

int f[M];
memset(f, 0xc0, f);
f[0] = 0;
f(i, 1, n) g(j, m, v[i])
	f[j] = max(f[j], f[j - v[i]] + w[i]);
int ans = 0;
f(j, 0, m) ans = max(ans, f[j]);

统计装物品的方案数:设 \(f(i,j)\) 表示方案数。转移方程:

\[f(i,j)=f(i-1,j)+f(i-1,j-v_i). \]

初值 \(f(0,0)=1\),其他为 \(0\)。答案为 \(f(n,m)\)。当然也可以滚动数组。

int f[M];
memset(f, 0, sizeof f);
f[0] = 1;
f(i, 1, n) g(j, m, v[i])
	f[j] += f[j - v[i]];
ans = f[m];

*Jury Compromise

AcWing 280 | UVA323 on Luogu

在一个遥远的国家,一名嫌疑犯是否有罪需要由陪审团来决定。陪审团是由法官从公民中挑选的。

法官先随机挑选 \(N\) 个人(编号 \(1,2…,N\))作为陪审团的候选人,然后再从这 \(N\) 个人中按照下列方法选出 \(M\) 人组成陪审团。

首先,参与诉讼的控方和辩方会给所有候选人打分,分值在 \(0\)\(20\) 之间,第 \(i\) 个人的得分分别记为 \(p_i\)\(d_i\)

为了公平起见,法官选出的 \(M\) 个人必须满足:辩方总分 \(D\) 和控方总分 \(P\) 的差的绝对值 \(|D-P|\) 最小。

如果选择方法不唯一,那么再从中选择辨控双方总分之和 \(D+P\) 最大的方案。

求最终的陪审团获得的辩方总分 \(D\)、控方总分 \(P\),以及陪审团人选的编号。

注意:若陪审团的人选方案不唯一,则任意输出一组合法方案即可。

\(1\le N\le200\)\(1\le M\le20\)

每个人有三个 ”体积维度“:

  • 人数,为 \(1\)
  • 辩方打分,为 \(d_i\),范围 \(0\)\(20\)
  • 控方打分,为 \(p_i\),范围 \(0\)\(20\)

直接地,设布尔数组 \(f(i,j,d,p)\) 表示前 \(i\) 人中选了 \(j\) 人,辩方总分为 \(d\),控方总分为 \(p\) 是否可行,即方案是否存在。那么有

\[f(i,j,d,p)=f(i-1,j,d,p)\mathrm{\ or\ }f(i-1,j-1,d-d_i,p-p_i). \]

初值:\(f(0,0,0,0)=\mathrm{true}\),其余为 \(\mathrm{false}\)。目标:找到 \(f(N,M,d,p)\) 满足 \(f(N,M,d,p)=\mathrm{true}\)\(|d-p|\) 最小、\(|d-p|\) 相同的 \(d+p\) 最大。时间复杂度 \(O(N^4\times d\times p)\),显然会超时。

我们可以换个思路。既然题目要求的是 \(|D-P|\) 最小、\(D+P\) 最大,我们不如把 \(d_i-p_i\) 设为除了人数之外的另一维体积,\(d_i+p_i\) 设为价值,最后从小到大枚举背包的这一维的总体积(的绝对值),如果有解则为答案。

具体地,设 \(f(i,j,k)\) 表示已经在前 \(i\) 个候选人中选了 \(j\) 个,且此时辩方总分和控方总分的差为 \(k\) 时,辩方总分和控方总分的和的最大值。那么有:

\[f(i,j,k)=\max\{f(i-1,j,k),f(i-1,j-1,k-(p_i-d_i))+p_i+d_i\}. \]

初值:\(f(0,0)=0\),其他为负无穷。目标:找到 \(f(N,M,k)\),使得 \(|k|\) 最小,\(|k|\) 相同时 \(f(N,M,k)\) 最大。

滚动第一维,对第二维倒序循环,即可满足无后效性(不用所有维都倒序循环)。

另外,\(f\) 的第三维可能为负数,为了避免负数下标,我们将这一维整个平移,平移距离 \(400\) 就够了。

时间 \(O(nmk)\)\(k=800\)

#include <iostream>
#include <cstring>
#include <vector>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
#define g(x, y, z) for (int x = (y); (x) >= (z); --(x))
using namespace std;
const int N = 210;
const int base = 400;
int n, m, a[N], b[N], f[22][810], g[N][22][820], ans, suma, sumb;
vector<int> rec;

void get_ans(int i, int j, int k) {
    if (!j)
        return;
    int lst = g[i][j][k];
    get_ans(lst - 1, j - 1, k - (a[lst] - b[lst]));
    rec.push_back(lst);
    suma += a[lst], sumb += b[lst];
    return;
}

signed main() {
    cin >> n >> m;
    f(i, 1, n) cin >> a[i] >> b[i];
    memset(f, 0xc0, sizeof f);
    f[0][base] = 0;
    f(i, 1, n) {
        memcpy(g[i], g[i - 1], sizeof(g[i - 1]));
        g(j, m, 1) {
            int low = max(a[i] - b[i], 0), high = min(a[i] - b[i] + 800, 800);
            f(k, low, high) {
                int val = f[j - 1][k - (a[i] - b[i])] + a[i] + b[i];
                if (f[j][k] < val) {
                    f[j][k] = val;
                    g[i][j][k] = i;
                }
            }
        }
    }
    f(k, 0, 400) {
        if (f[m][base + k] >= 0 && f[m][base + k] >= f[m][base - k]) {
            ans = base + k;
            break;
        }
        if (f[m][base - k] >= 0) {
            ans = base - k;
            break;
        }
    }
    get_ans(n, m, ans);
    cout << "Best jury has value " << suma << " for prosecution and value " << sumb << " for defence:\n";
    for (int i : rec) cout << i << ' ';
    cout << '\n';
    return 0;
}

完全背包模型

给定 \(N\) 种物品,其中第 \(i\) 种物品的体积为 \(V_i\),价值为 \(W_i\),并且有无数个。有一容积为 \(M\) 的背包,要求选择一些物品放入背包,使得物品总体积不超过 \(M\) 的前提下,物品的价值总和最大。

同样,设 \(f(i,j)\) 表示:从前 \(i\) 个物品中选出了总体积为 \(j\) 的物品放入背包,物品的最大价值总和。

讨论之前是否考虑选过第 \(i\) 种物品。转移方程:

\[f(i,j)=\max\begin{cases}f(i-1,j),\\ f(i,j-V_i)+W_i, & \mathrm{if\ }j\ge V_i. \end{cases} \]

初值 \(f(0,0)=0\),其他为负无穷。答案 \(\max_jf(n,j)\)

由于转移方程中的第二种情况是在同一阶段中转移,即 \(i\) 相同,因此滚动数组后正序枚举 \(j\) 正好可以转移。换句话说,正序枚举 \(j\) 的意义即为物品可以用无数次(只要不超过 \(M\))。

int f[M];
memset(f, 0xc0, sizeof f);
f[0] = 0;
f(i, 1, n) f(j, v[i], m)
    f[j] = max(f[j], f[j - v[i]] + w[i]);
int ans = 0;
f(j, 0, m) ans = max(ans, f[j]);

同样,改造一下就可以变成求方案数的代码:

int f[M];
memset(f, 0, sizeof f);
f[0] = 1;
f(i, 1, n) f(j, v[i], m)
    f[j] += f[j - v[i]] + w[i];
ans = f[m];

多重背包模型

给定 \(N\) 种物品,其中第 \(i\) 种物品的体积为 \(V_i\),价值为 \(W_i\),并且有 \(C_i\) 个。有一容积为 \(M\) 的背包,要求选择一些物品放入背包,使得物品总体积不超过 \(M\) 的前提下,物品的价值总和最大。

直接拆分法

把第 \(i\) 种物品看成独立的 \(C_i\) 个物品,转化为 0/1 背包。物品总数为 \(\sum_{i=1}^NC_i\),时间复杂度 \(O\left(M\times\sum_{i=1}^NC_i\right)\),一般是无法承受的。

二进制拆分法

考虑一个十进制数转成二进制数的过程。设二进制共有 \(k\) 位,那么从 \(2^0,2^1,\dots,2^{k-1}\)\(k\)\(2\) 的整数次幂中选出若干个相加,可以得到 \([0,2^k-1]\) 内的任意一个整数。因此,进一步地,设 \(p\) 为使得 \(2^0+2^1+\dots+2^p\le C_i\) 的最大整数 \(p\),设余数

\[R_i=C_i-(2^0+2^1+\dots+2^p)=C_i-2^{p+1}+1. \]

那么 \(2^0,2^1,\dots,2^p,R_i\) 可以凑成 \([0,C_i]\) 内的任意一个整数。

综上所述,我们可以把数量为 \(C_i\) 的第 \(i\) 种物品拆成 \(p+2\) 个物品,其体积分别为:

\[2^0\times V_i,2^1\times V_i,\dots,2^p\times V_i,R_i\times V_i. \]

时间 \(O\left(M\times\sum_{i=1}^N\log C_i\right)\),效率较高。

单调队列优化

时间 \(O(NM)\),与一般的 0/1 背包和完全背包相同。简神!

*Coins

POJ 1742 | Contest Hunter | AcWing 281

给定 \(N\) 种硬币,其中第 \(i\) 种硬币的面值为 \(A_i\),共有 \(C_i\) 个。

从中选出若干个硬币,把面值相加,若结果为 \(S\),则称 “面值 \(S\) 能被拼成”。

\(1\)\(M\) 之间能被拼成的面值有多少个。

\(1\le N\le100\)\(1\le M\le10^5\)\(1\le A_i\le10^5\)\(1\le C_i\le1000\)

看起来是一个多重背包的模板题。然而注意到数据范围,你发现事情并没有这么简单。当然你可以考虑用二进制优化或者单调队列优化。然而我们还有更巧妙的做法。注意到这道题目特殊的一点是,只关注 ”可行性“(即能否拼成),而不是 ”最优性“。我们考虑从这里入手。

设布尔数组 \(f(j)\) 表示用前 \(i\) 种硬币能否拼成面值 \(j\)(已经滚动数组)。那么如果 \(f(j)\) 变成 \(\mathrm{true}\),即 \(j\) 当前能被拼成,有两种情况:

  1. \(i-1\) 种硬币就能拼成 \(j\),即之前 \(f(j)\) 已经为 \(\mathrm{true}\)
  2. 使用了若干个第 \(i\) 种硬币,从而拼成 \(j\),即发现 \(f(j-A_i)\)\(\mathrm{true}\) 则把 \(f(j)\) 变为 \(\mathrm{true}\)

与完全背包的状态转移很相似,不同的是有使用次数限制。(本来就是嘛。。)

由于我们只关注可行性,由谁拼成的 \(j\) 我们并不关心,因此我们为了尽量减少转移次数,考虑尽量选择第一种情况。

具体地,如果 \(f(j)\) 已经为 \(\mathrm{true}\),则不进行转移,即不用第 \(i\) 种硬币。

接下来考虑如何满足使用次数的限制。我们只需要考虑当前阶段,即用了多少第 \(i\) 种硬币。

\(g(j)\) 表示用前 \(i\) 种硬币拼成面值 \(j\)至少要用多少枚第 \(i\) 种硬币。

分类讨论:

  • 如果在阶段 \(i\) 之前 \(f(j)=\mathrm{true}\),则 \(g(j)=0\)
  • 否则,如果 \(f(j-A_i)=\mathrm{true}\)\(g(j-A_i)<C_i\),则 \(f(j)=\mathrm{true}\)\(g(j)=g(j-A_i)+1\)
#include <iostream>
#include <cstring>
#include <bitset>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
using namespace std;
const int N = 110, M = 1e5 + 10;
bitset<M> f;                      // f[i][j]: 用前i种硬币, 能否拼成面值j
int n, m, a[N], c[N], g[M], ans;  // g[i][j]: 用了几个第i种硬币, 拼成面值j

signed main() {
    while (cin >> n >> m, n && m) {
        ans = 0;
        f.reset();
        f(i, 1, n) cin >> a[i];
        f(i, 1, n) cin >> c[i];
        f[0] = true;
        f(i, 1, n) {  //枚举硬币种类
            f(j, 0, m) g[j] = 0;
            f(j, a[i], m) //枚举钱数
                if (!f[j] && f[j - a[i]] && g[j - a[i]] < c[i])
                    f[j] = true, g[j] = g[j - a[i]] + 1;
        }
        f(i, 1, m) if (f[i])++ ans;
        cout << ans << '\n';
    }
    return 0;
}

分组背包模型

给定 \(N\) 组物品,其中第 \(i\) 组有 \(C_i\) 个物品,第 \(i\) 组第 \(j\) 个物品的体积为 \(V_{ij}\),价值为 \(W_{ij}\)。有一容积为 \(M\) 的背包,要求每组至多选择选择一个物品放入背包,使得物品总体积不超过 \(M\) 的前提下,物品的价值总和最大。

把物品组数作为 DP 的阶段,设 \(f(i,j)\) 表示从前 \(i\) 组中选出总体积为 \(j\) 的物品放入背包,物品的最大价值总和。

不妨设 \(V_{i0}=W_{i0}=0\),那么有:

\[f(i,j)=\max\limits_{0\le k\le C_i}\{f(i-1,j-V_{ik})+W_{ik}\}. \]

\(f\) 的第一维滚动掉,并且第二维倒序循环。代码:

memset(f, 0xc0, sizeof f);
f[0] = 0;
f(i, 1, n) g(j, m, 0) f(k, 1, c[i])
    if (j >= v[i][k]) f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);

注意要将 \(k\) 放在 \(j\) 的内层,即先确定物品总体积(状态),再确定是否选当前组中某个物品(决策)。否则会导致类似多重背包的效果。

树形 DP 中常出现分组背包的模型。

区间 DP

一般以区间长度为阶段(常省略这一维),以区间左右端点为状态,从长度为 \(1\) 的 “元区间” 的状态向上递推到父亲区间的状态(类似线段树)。常用记忆化搜索(动态规划的递归实现)。

石子合并

Luogu P1880

\(N\) 堆石子排成一圈,其中第 \(i\) 堆石子的重量为 \(A_i\)。每次可以选择其中相邻的两堆石子合并成一堆,形成的新石子堆的重量以及消耗的体力都是两堆石子的重量之和。求把全部 \(N\) 堆石子合成一堆最少 / 最多需要消耗多少体力。

\(1\le N<300\)

首先考虑最小值,最大值同理。由于石子是环形排列的,我们需要 “破环成链”,即把 \(1\)\(n\) 复制一遍接在后面。

\(f(l,r)\) 表示把 \([l,r]\) 范围内的石子合并成一堆,需要消耗的最少体力。

\[f(l,r)=\min_{l\le k<r}\{f(l,k)+f(k+1,r)\}+\sum_{i=l}^rA_i. \]

初值:\(f(i,i)=0\),其余为正无穷。答案: \(f(1,N)\)

如何枚举 \(l,r\)?刚才提到,我们以区间长度为阶段。于是先枚举区间长度,再枚举左端点,这样就满足了无后效性。

#include <iostream>
#include <cstring>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
using namespace std;
const int N = 210;
const int INF = 0x3f3f3f3f;
int n, a[N], dp[N][N], ans = INF;
signed main() {
    cin >> n;
    f(i, 1, n) cin >> a[i], a[i + n] = a[i];
    f(i, 1, n << 1) a[i] += a[i - 1];

    f(d, 2, n) {
        for (int l = 1, r = d; r < (n << 1); ++l, ++r) {
            dp[l][r] = INF;
            f(k, l, r - 1)
                dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r]);
            dp[l][r] += a[r] - a[l - 1];
        }
    }
    f(i, 1, n) ans = min(ans, dp[i][i + n - 1]);
    cout << ans << '\n';

    f(d, 2, n) {
        for (int l = 1, r = d; r < (n << 1); ++l, ++r) {
            dp[l][r] = 0;
            f(k, l, r - 1)
                dp[l][r] = max(dp[l][r], dp[l][k] + dp[k + 1][r]);
            dp[l][r] += a[r] - a[l - 1];
        }
    }
    f(i, 1, n) ans = max(ans, dp[i][i + n - 1]);
    cout << ans << '\n';

    return 0;
}

Polygon

洛谷 P4342

“多边形游戏” 是一款单人益智游戏。在游戏开始时,系统给定玩家一个 \(N\) 边形,该 \(N\) 边形由 \(N\) 个顶点和 \(N\) 条边构成,每条边连接两个相邻的顶点。在每个顶点上写有一个整数,可正可负。在每条边上标有一个运算符 “+”(加号)或 “*”(乘号)。

第一步,玩家需要选择一条边,将它删除。接下来再进行 \(N-1\) 步,在每一步中玩家选择一条边,把这条边以及该边连接的两个顶点用一个新的顶点代替,新顶点上的整数值等于删去的两个顶点上的数按照删去的边上标有的符号进行计算得到的结果。如下图所示,就是一盘游戏的过程。

最终,游戏仅剩一个顶点,顶点上的数值就是玩家的得分,上图玩家得 \(0\) 分。

请计算对于给定的 \(N\) 边形,玩家最高能获得多少分,以及第一步有哪些策略可以使玩家获得最高得分。

\(1\le N<50\)。保证玩家无论如何操作,顶点上的数值均在 \([-32768,32767]\) 之内。

首先破环成链,把链复制一次接到后面,然后就变成了一个链上的问题。为了方便,我们将多边形的顶点编号。

\(f(l,r)\) 表示把 \([l,r]\) 范围内的顶点合成一个顶点,这个顶点上的数值最大是多少。

像刚才一样枚举 \(k\),考虑 \(k\)\(k+1\) 之间是加还是乘。

如果是加,直接取最大值的 \(\max\) 即可。

如果是乘,情况就比较复杂了,我们需要考虑负负得正的情况。那么只需要再保存最小值,然后枚举两两乘积更新最大 / 小值即可。容易看出这样做把所有可能出现最大 / 小值的情况都考虑到了,所以这样一定是对的。

时间复杂度 \(O(N^3)\)

#include <iostream>
#include <vector>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
using namespace std;
const int N = 110;
const int INF = 0x3f3f3f3f;
int n, a[N], o[N], dp[N][N][2]; //o: operator; dp[l][r][0/1]: 把区间[l,r]内的点合并为一个点, 得到的0:最大值/1:最小值
signed main() {
    cin >> n;
    f(i, 1, n) {
        char ch;
        cin >> ch >> a[i];
        if (ch == 'x') o[i] = 1;
        a[i + n] = a[i];
        o[i + n] = o[i];
        dp[i][i][0] = dp[i][i][1] = a[i];
        dp[i + n][i + n][0] = dp[i + n][i + n][1] = a[i];
    }
    f(p, 2, n)
        for (int l = 1, r = p; r < (n << 1); ++l, ++r) {
            dp[l][r][0] = -INF;
            dp[l][r][1] = INF;
            f(k, l, r - 1)
                if (o[k + 1] == 0)
                    dp[l][r][0] = max(dp[l][r][0], dp[l][k][0] + dp[k + 1][r][0]),
                    dp[l][r][1] = min(dp[l][r][1], dp[l][k][1] + dp[k + 1][r][1]);
                else f(x, 0, 1) f(y, 0, 1)
                    dp[l][r][0] = max(dp[l][r][0], dp[l][k][x] * dp[k + 1][r][y]),
                    dp[l][r][1] = min(dp[l][r][1], dp[l][k][x] * dp[k + 1][r][y]);
        }
    int ans = -INF;
    vector<int> pos;
    f(i, 1, n) {
        int val = dp[i][i + n - 1][0];
        if (ans < val) {
            ans = val;
            pos.clear();
            pos.push_back(i);
        } else if (ans == val)
            pos.push_back(i);
    }
    cout << ans << '\n';
    for (int i: pos) cout << i << ' ';
    cout << '\n';
    return 0;
}

*金字塔

Contest Hunter | AcWing 284

一棵有根树,节点有颜色。现在有一个人从根出发,按照深度优先的顺序依次经过所有点并返回,同时记录经过的点的颜色,这样会得到一个颜色序列。

现在给定这个颜色序列 \(S\),求有多少种树结构能生成这个颜色序列。

注意:子树之间是有序的,因此下面两棵树视为不同的树结构。

因为结果可能会非常大,你只需要输出答案对 \(10^9\) 取模之后的值。

\(S\) 的长度 \(N\le300\)

为了在序列与树结构之间建立联系,我们想到将一段序列映射为一棵子树(类似于 DFS 序)。

\(f(l,r)\) 表示 \(S[l..r]\) 对应的树结构的数量。考虑如何从子结构推到父结构。

观察遍历一棵树的过程,我们发现,如果从当前点下到某子树又回来的序列是 \(S[l..r]\)\(S[l+1..r-1]\) 就对应着这棵子树。这样就可以变成子问题。

然而,枚举如何划分的时间复杂度过高。有没有更简便的方法呢?

对于计数类 DP 的状态设计,如何做到不重不漏是极其重要的。如果能变成多个子状态的合并,一种常用的方法是,首先枚举并分离出第一个子状态,从而区分不同的决策

在本题中,如果直接枚举如何分割子树转化为子问题,可能会产生重复计数:给定 \(\texttt{ABABABA}\),如果分为 \(\texttt{A|BAB|A|B|A}\)\(\texttt{A|B|A|BAB|A}\),而递归下去之后 \(\texttt{BAB}\) 又可能分成了 \(\texttt{B|A|B}\),这样就会产生重复。

我们枚举 \(S[l..r]\)第一棵子树是哪一段。设划分点为 \(k\),枚举 \(S[l+1..k-1]\) 作为 \(S[l..r]\) 的第一棵子树,而 \(S[k..r]\)\(S[l..r]\) 的剩余部分,即其他子树。如下图。

这样一来,如果第一棵子树不相同,那么树结构也就不可能相同。枚举之后,递归处理剩余部分,相当于是子问题,从而枚举到所有情况。

注意如果 \(S_l\ne S_r\),那么一定不可能是合法的序列,因此 \(S[l..r]=0\)

另外有只有一棵子树的情况。

根据 决策之间的加法原理子状态之间的乘法原理,有状态转移方程:

\[f(l,r)=\begin{cases} 0, & S_l\ne S_r, \\ f(l+1,r-1)+\sum_{k=l+2}^{r-2}[S_l=S_k]\times f(l+1,k-1)\times f(k,r), & S_l=S_r. \end{cases} \]

初值:\(f(i,i)=1\)。答案:\(f(1,N)\)\(S\) is 1-indexed)。

本题用记忆化搜索实现较为方便。保证每个区间只会被求解一次,所以时间是 \(O(N^3)\)

char s[N];
int f[N][N];
inline int Mul(int const &a, int const &b) { return 1ll * a * b % MOD; }
inline int &AddEq(int &a, int const &b) { return (a += b) >= MOD ? (a -= MOD) : a; }
int solve(int l, int r) {
    if (l > r) return 0;
    if (l == r) return 1;
    if (~f[l][r]) return f[l][r];
    if (s[l] != s[r]) return 0;
    int res = 0;
    f(k, l + 2, r)
        AddEq(res, Mul(solve(l + 1, k - 1), solve(k, r)));
    return f[l][r] = res;
}
signed main() {
    cin >> s + 1;
    memset(f, -1, sizeof f);
    cout << solve(1, strlen(s + 1)) << '\n';
    return 0;
}

树形 DP

一般以子树从小到大的顺序作为 DP 的阶段,在 DFS 回溯过程中进行状态转移。因此第一维通常为节点编号。

没有上司的舞会

洛谷 P1352

Ural 大学有 \(N\) 名职员,编号为 \(1\)\(N\)

他们的关系就像一棵以校长为根的树,父节点就是子节点的直接上司。

每个职员有一个快乐指数,用整数 \(H_i\) 给出,其中 \(1\le i\le N\)

现在要召开一场周年庆宴会,不过,没有职员愿意和直接上司一起参会。

在满足这个条件的前提下,主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。

\(1\le N\le6000\)\(−128\le H_i\le127\)

每个职员有没有可能参会取决于它的父亲是否参会。因此对于一个节点,讨论它是否被选中,然后用子节点来转移。

\(f(i,0/1)\) 表示 \(i\) 子树内的答案,并且保证不选中 / 选中 \(i\)。状态转移方程:

\[\begin{aligned} &f(u,0)=\sum_{v\in son(u)}\max\{f(v,0),f(v,1)\},\\ &f(u,1)=H_u+\sum_{v\in son(u)}f(v,0). \end{aligned} \]

答案 \(\max\{f(root,0),f(root,1)\}\)

时间 \(O(N)\)

int n, h[N], f[N][2], deg[N], rt;
vector<int> e[N];
void dp(int u) {
    f[u][1] = h[u];
    for (int v : e[u]) {
        dp(v);
        f[u][0] += max(f[v][0], f[v][1]);
        f[u][1] += f[v][0];
    }
    return;
}
signed main() {
    cin >> n;
    f(i, 1, n) cin >> h[i];
    f(i, 2, n) {
        int u, v;
        cin >> u >> v;
        e[v].push_back(u);
        ++deg[u];
    }
    f(i, 1, n) if (!deg[i]) rt = i;
    dp(rt);
    cout << max(f[rt][0], f[rt][1]) << '\n';
    return 0;
}

树上背包

*选课

洛谷 P2014

洛谷 U53204 【数据加强版】选课

现在有 \(N\) 门功课,每门课有个学分 \(s_i\),每门课有一门或没有直接先修课(若课程 a 是课程 b 的先修课即只有学完了课程 a,才能学习课程 b)。

一个学生要从这些课程里选择 \(M\) 门课程学习,问他能获得的最大学分是多少?

\(1\le N,M\le300\)

如果看成类似背包的问题,相当于每一门课程有一个 “体积” 为 \(1\),“价值” 为学分 \(s_i\),而背包体积为 \(M\)。根据背包的思想,我们把体积设为状态的一维。

\(f(u,t)\) 表示在 \(u\) 子树中选 \(t\) 门课能获得的最高学分。设 \(u\)\(p\) 棵子树 \(v_1,v_2,\dots,v_p\),枚举每棵子树选择的课程数 \(c_i\)。那么有

\[f(u,t)=\max_{\sum_{i=1}^pc_i=t-1}\left\{\sum_{i=1}^pf(v_i,c_i)\right\}+s_u. \]

该问题可以看作是一个分组背包问题,把每种决策看作一个物品。

对于 \(u\),一共有 \(p\) 组物品,每组物品有 \(t-1\) 个,第 \(i\) 组第 \(j\) 个物品的体积为 \(j\),价值为 \(f(v_i,j)\)。背包总容积为 \(t-1\)

由于每棵子树只能选择一种状态转移,所以每组物品至多选择一个。

注意:在 DP 过程中,实际上很多状态都是无用的,如:

  • \(f(u,i)\)\(i>siz_u\)
  • \(f(v,i)\)\(i>siz_v\)
  • \(f(u,i)\)\(i>m\)

因此我们要设置好循环的上下界。这种优化称为上下界优化,在树上背包问题中十分常见,也十分重要。

这种优化保证了复杂度是 \(O(nm)\)。如果不优化,复杂度将变为 \(O(nm^2)\)

复杂度证明:见 树上背包的上下界优化 - ouuan - 博客园

另外,由于题目中不保证图连通,所以我们建一个超级根节点 \(0\)。答案为 \(f(0,m+1)\)

数据加强版代码:

int constexpr N = 1e5 + 10;
int n, m, w[N], rt, siz[N];
vector<int> son[N];
inline int &gmx(int &a, int const &b) { return a = (a > b ? a : b); }

int f[100000010];
inline int calc(int x, int y) { return x * (m + 1) + y; }

void dfs(int u) {
    siz[u] = 1;
    f[calc(u, 1)] = w[u];
    for (int v: son[u]) {
        dfs(v);
        g(j, min(m, siz[u] + siz[v]), 1)
            f(k, max(1, j - siz[u]), min(siz[v], j - 1))
                gmx(f[calc(u, j)], f[calc(u, j - k)] + f[calc(v, k)]);
        siz[u] += siz[v];
    }
    return;
}

signed main() {
    cin >> n >> m; ++m; //因为有 0 节点所以 ++m
    int x;
    f(i, 1, n) {
        cin >> x >> w[i];
        son[x].push_back(i);
    }
    dfs(0);
    cout << f[calc(0, m)] << '\n';
    return 0;
}

换根 DP

换根 DP 的特点是,给定一个树形结构,需要以每个节点为根进行一系列统计。

我们一般通过两次 DFS 来求解此类题目:

  1. 第一次 DFS 时,任选一个点为根,在 “有根树” 上执行一次树形 DP,也就是在回溯时发生的、自底向上的状态转移;
  2. 第二次 DFS 时,从刚才选出的根出发,对整棵树执行一次深度优先遍历,在每次递归前进行自顶向下的推导,计算出 “换根” 后的解。

*Accumulation Degree

AcWing 287

有一个树形的水系,由 \(N−1\) 条河道和 \(N\) 个交叉点组成。

我们可以把交叉点看作树中的节点,编号为 \(1\)\(N\),河道则看作树中的无向边。

每条河道都有一个容量,连接 \(x\)\(y\) 的河道的容量记为 \(c(x,y)\)

河道中单位时间流过的水量不能超过河道的容量。

有一个节点是整个水系的发源地,可以源源不断地流出水,我们称之为源点。

除了源点之外,树中所有度数为 \(1\) 的节点都是入海口,可以吸收无限多的水,我们称之为汇点。

也就是说,水系中的水从源点出发,沿着每条河道,最终流向各个汇点。

在整个水系稳定时,每条河道中的水都以单位时间固定的水量流向固定的方向。

除源点和汇点之外,其余各点不贮存水,也就是流入该点的河道水量之和等于从该点流出的河道水量之和。

整个水系的流量就定义为源点单位时间发出的水量。

在流量不超过河道容量的前提下,求哪个点作为源点时,整个水系的流量最大,输出这个最大值。

首先考虑固定根的情况。随便选定一个 \(s\) 为根,设 \(f(u)\) 表示以 \(u\) 为源点,流向它的子树中的最大流量是多少。那么有

\[f(u)=\sum_{v\in son(u)}\begin{cases} \min\{f(v),c(u,v)\}, & \mathrm{if\ }deg_v>1,\\ c(u,v), & \mathrm{if\ }deg_v=1. \end{cases} \]

然而枚举 \(s\) 的话,时间为 \(O(N^2)\),无法通过。我们采用换根 DP。

\(g(x)\) 表示以 \(x\) 为源点的答案。假设已经正确地求出了 \(g(u)\),考虑在根由 \(u\) 变化为 \(v\in son(u)\) 之后,如何由 \(f(u),f(v)\)\(g(u)\)求出 \(g(v)\)

\(g(v)\) 包含两部分:

  1. \(v\) 向下流到子树中,即 \(f(v)\)
  2. \(v\) 向上流到 \(u\),再流到其他地方。

考虑 \(f(u)\) 是怎么来的。由转移方程,\(v\)\(f(u)\) 的贡献显然是

\[\begin{cases} \min\{f(v),c(u,v)\}, & \mathrm{if\ }deg_v>1,\\ c(u,v), & \mathrm{if\ }deg_v=1. \end{cases} \]

\(v\)\(g(u)\) 的贡献也是这个式子。因此 \(g(u)-\min\{f(v),c(u,v)\}\) 即为由 \(v\) 向上流\(u\) 之后的贡献。

还需要计算 \(c(u,v)\) 的贡献。

总的状态转移方程:

\[g(v)=f(v)+\begin{cases} \min\{g(u)-\min\{f(v),c(u,v)\},c(u,v)\}, & \mathrm{if\ }deg_u>1\\ c(u,v), & \mathrm{if\ }deg_u=1. \end{cases} \]

先做一次 DFS 求出 \(f\),再做一次 DFS 求出 \(g\) 即可。时间 \(O(N)\)

总结一下换根 DP 过程

  1. 第一次 DFS 求出 \(f(u)\) 表示确定根后 \(u\) 子树的答案。
  2. 第二次 DFS 求出 \(g(u)\) 表示以 \(u\) 为根时的答案,其中包含向上和向下两部分的贡献。为了计算向上部分,我们常常从 \(g(u)\) 中减去 \(v\) 的贡献。而向下部分一般即为 \(f(u)\)
const int N = 2e5 + 10;
int tt, n, deg[N], d[N], f[N];
struct Edge {
    int to, nxt, val;
} e[N << 1];
int head[N], cnt;
inline void add(int from, int to, int val) {
    e[++cnt].to = to, e[cnt].nxt = head[from], e[cnt].val = val, head[from] = cnt;
    return;
}

void dp(int u, int fa) {
    d[u] = 0;
    for (int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to, w = e[i].val;
        if (v == fa) continue;
        dp(v, u);
        if (deg[v] == 1) d[u] += w;
        else d[u] += min(d[v], w);
    }
    return;
}

void dfs(int u, int fa) {
    for (int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to, w = e[i].val;
        if (v == fa) continue;
        if (deg[u] == 1) f[v] = d[v] + w;
        else f[v] = d[v] + min(f[u] - min(d[v], w), w);
        dfs(v, u);
    }
    return;
}

void solve() {
    ans = 0;
    memset(head, 0, sizeof head);
    memset(e, 0, sizeof e);
    memset(deg, 0, sizeof deg);
    cin >> n;
    f(i, 2, n) {
        cin >> x >> y >> z;
        add(x, y, z), add(y, x, z);
        ++deg[x], ++deg[y];
    }
    dp(1, -1);
    f[1] = d[1];
    dfs(1, -1);
    f(i, 1, n) ans = max(ans, f[i]);
    cout << ans << '\n';
    return;
}

环形与后效性处理

环形结构上的 DP

一般通过破环成链,转化为链上的问题解决。我们的目标是找到如何避免枚举断开环的那个点,从而。

*Naptime

Luogu P6064

在某个星球上,一天由 \(N\) 个小时构成,我们称 \(0\) 点到 \(1\) 点为第 \(1\) 个小时、\(1\) 点到 \(2\) 点为第 \(2\) 个小时,以此类推。

在第 \(i\) 个小时睡觉能够恢复 \(U_i\) 点体力。

在这个星球上住着一头牛,它每天要休息 \(B\) 个小时。

它休息的这 \(B\) 个小时不一定连续,可以分成若干段,但是在每段的第一个小时,它需要从清醒逐渐入睡,不能恢复体力,从下一个小时开始才能睡着。

为了身体健康,这头牛希望遵循生物钟,每天采用相同的睡觉计划。

另外,因为时间是连续的,即每一天的第 \(N\) 个小时和下一天的第 \(1\) 个小时是相连的(\(N\) 点等于 \(0\) 点),这头牛只需要在每 \(N\) 个小时内休息够 \(B\) 个小时就可以了。

请你帮忙给这头牛安排一个睡觉计划,使它每天恢复的体力最多。

\(3\le N\le3830\)\(2\le B<N\)\(0\le U_i\le200000\)

首先我们考虑 \(1\)\(N\) 不相连,即在链上的情况。

\(f(i,j,0/1)\) 表示前 \(i\) 个小时休息了 \(j\) 个小时,并且第 \(i\) 个小时不在 / 在休息,最多恢复多少体力。转移方程:

\[\begin{aligned} &f(i,j,0)=\max\{f(i-1,j,0),f(i-1,j,1)\},\\ &f(i,j,1)=\max\{f(i-1,j-1,0),f(i-1,j-1,1)+U_i\}. \end{aligned} \]

初值:\(f(1,0,0)=f(1,1,1)=0\),其他为负无穷。答案:\(\max\{f(N,B,0),f(N,B,1)\}\)

现在我们考虑 \(1\)\(N\) 相连的情况。刚才与现在的区别在于,刚才睡觉不能跨过前一天和后一天,而现在则可以。

换句话说,现在多出的对答案的贡献在于,可能有一段选了 \(i,i+1,\dots,N,1,\dots,j\)\(j-i+1\le B\))。

于是,我们强制把 \(U_1\) 计算到答案中,并且强制选择 \(N\) 睡觉(注意前后两者的区别)。

我们仍然采用刚才的状态与转移。初值改为:\(f(1,1,1)=U_1\),其他为负无穷。答案为:\(f(N,B,1)\)

把两次 DP 的答案取 \(\max\) 即可。

这道题启发我们,环形结构上 DP 的一个解法是,执行两次 DP

  1. 第一次在任意位置把环断开成链,按照线性问题求解;
  2. 第二次通过适当的条件和赋值,保证计算出的状态等价于把断开的位置强制相连

这样就可以保证考虑到所有情况。

代码中滚动了数组。

#include <iostream>
#include <cstring>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
using namespace std;
const int N = 3840;
int n, b, u[N], f[2][N][2],  ans;

signed main() {
    cin >> n >> b;
    f(i, 1, n) cin >> u[i];

    memset(f, 0xc0, sizeof f);
    f[1][0][0] = f[1][1][1] = 0;
    int t = 1;
    f(i, 2, n) {
        t ^= 1;
        f[t][0][0] = max(f[t ^ 1][0][0], f[t ^ 1][0][1]);
        f(j, 1, min(i, b)) {
            f[t][j][0] = max(f[t ^ 1][j][0], f[t ^ 1][j][1]);
            f[t][j][1] = max(f[t ^ 1][j - 1][0], f[t ^ 1][j - 1][1] + u[i]);
        }
    }
    ans = max(f[t][b][0], f[t][b][1]);

    memset(f, 0xc0, sizeof f);
    f[1][1][1] = u[1];
    t = 1;
    f(i, 2, n) {
        t ^= 1;
        f[t][0][0] = max(f[t ^ 1][0][0], f[t ^ 1][0][1]);
        f(j, 1, min(i, b)) {
            f[t][j][0] = max(f[t ^ 1][j][0], f[t ^ 1][j][1]);
            f[t][j][1] = max(f[t ^ 1][j - 1][0], f[t ^ 1][j - 1][1] + u[i]);
        }
    }
    ans = max(ans, f[t][b][1]);

    cout << ans << '\n';

    return 0;
}

环路运输

在一条环形公路旁均匀地分布着 \(N\) 座仓库,编号为 \(1\)\(N\),编号为 \(i\) 的仓库与编号为 \(j\) 的仓库之间的距离定义为 \(dist(i,j)=\min(|i−j|,N−|i−j|)\),也就是逆时针或顺时针从 \(i\)\(j\) 中较近的一种。

每座仓库都存有货物,其中编号为 \(i\) 的仓库库存量为 \(A_i\)

\(i\)\(j\) 两座仓库之间运送货物需要的代价为 \(w(i,j)=A_i+A_j+dist(i,j)\)

求在哪两座仓库之间运送货物需要的代价最大。

\(2\le N\le10^6\)\(1\le A_i\le10^7\)

AcWing 289

我们考虑破环成链,将链复制一遍接在后面,形成长度为 \(2N\) 的链。

对于 \(w(i,j)\)(不妨设 \(1\le j<i\le2N\)),讨论 \(dist(i,j)\)的两种情况:

  • 如果 \(i-j\le N/2\),那么 \(w(i,j)=A_i+A_j+i-j\)
  • 如果 \(i-j>N/2\),那么可以对应成在 \(i\)\(j+N\) 之间运送货物,由于 \(j+N-i<N/2\),这样就变成了刚才的情况。因此 \(w(i,j)=A_i+A_j+j+N-i=w(j+N,i)\)

因此只要考虑所有 \(i-j\le N/2\) 的情况,所有情况就都考虑到了。

题目转化为:求所有满足 \(i-j\le N/2\)\(i,j\) 中,\(A_i+A_j+i-j\) 的最大值。

用单调队列求出即可,时间复杂度线性。

const int N = 2e6 + 10;
int n, a[N], ans, q[N >> 2], h, t;
signed main() {
    cin >> n;
    f(i, 1, n) cin >> a[i], a[i + n] = a[i];
    h = 1;
    f(i, 1, n << 1) {
        while (h <= t && i - q[h] > (n >> 1)) ++h;
        ans = max(ans, a[i] + i + a[q[h]] - q[h]);
        while (h <= t && a[i] - i >= a[q[t]] - q[t]) --t;
        q[++t] = i;
    }
    cout << ans << '\n';
    return 0;
}

有后效性 DP

在一些题目中,当我们设计出状态和转移方程后,却发现不满足「无后效性」这一基本条件一一部分状态之间互相转移、互相影响,在状态转移的有向图上构成了环形,无法确定出一个合适的「阶段」以进行递推。

事实上,我们可以把动态规划的各状态看作未知量,状态的转移看作若干个方程。如果仅仅是「无后效性」这一条前提不能满足,并且状态转移方程都是一次方程,那么我们可以用高斯消元代替线性递推求出所有状态的解。

在更多的题目中,动态规划的状态转移 “分阶段带环”——我们需要把 DP 和高斯消元相结合,在整体层面采用动态规划框架,而在局部使用高斯消元解出互相影响的状态

我们用一道例题来具体说明这类情况。

*Broken Robot

CF24D on Luogu

有一个 \(n\)\(m\) 列的矩阵,现在你在 \((x,y)\),每次等概率向左,右,下走或原地不动,但不能走出去,问走到最后一行期望的步数。

注意,\((1,1)\) 是木板的左上角,\((n,m)\) 是木板的右下角。

\(1\le n,m\le 10^3\)\(1\le x\le n\)\(1\le y\le m\)

这道题与「传纸条」的移动方式很类似,相同之处在于行数递增,不同之处在于列数不一定增加还是减少。

于是我们以行数为阶段。根据期望 DP 的套路,我们用倒推的方式进行 DP(因为终止状态的期望一定是确定的)。

\(f(i,j)\) 表示从点 \((i,j)\) 走到最后一行的期望步数。那么有

\[\begin{aligned} &f(i,1)=\frac13(f(i,1)+f(i,2)+f(i+1,1))+1,&\\ &f(i,m)=\frac13(f(i,m)+f(i,m-1)+f(i+1,m))+1,&\\ &f(i,j)=\frac14(f(i,j)+f(i,j-1)+f(i,j+1)+f(i+1,j))+1&(j\ne1\mathrm{\ and\ }j\ne m). \end{aligned} \]

初值:\(f(n,i)=0\)。目标:\(f(x,y)\)

我们发现,虽然行数即第一维满足无后效性,但列数即第二维并不满足。

因此我们固定 \(i\),把 \(f(i,j)\) 看作 \(m\) 个变量,列出 \(m\) 个转移方程,采用高斯消元解方程组。\(f(i+1,j)\) 是已知的。

\(m=5\) 的增广矩阵形如:

\[\begin{bmatrix} \frac23 & -\frac13 & 0 & 0 & 0 & \frac13f(i+1,1)+1 \\ -\frac14 & \frac34 & -\frac14 & 0 & 0 & \frac14f(i+1,2)+1 \\ 0 & -\frac14 & \frac34 & -\frac14 & 0 & \frac14f(i+1,3)+1 \\ 0 & 0 & -\frac14 & \frac34 & -\frac14 & \frac14f(i+1,4)+1 \\ 0 & 0 & 0 & \frac23 & -\frac13 & \frac13f(i+1,5)+1 \\ \end{bmatrix}\sim \begin{bmatrix} 2 & -1 & 0 & 0 & 0 & f(i+1,1)+3 \\ -1 & 3 & -1 & 0 & 0 & f(i+1,2)+4 \\ 0 & -1 & 3 & -1 & 0 & f(i+1,3)+4 \\ 0 & 0 & -1 & 3 & -1 & f(i+1,4)+4 \\ 0 & 0 & 0 & 2 & -1 & f(i+1,5)+3 \\ \end{bmatrix} \]

然而单纯的高斯消元时间复杂度是 \(O(m^3)\) 的,无法通过。注意到矩阵中 \(0\) 很多且分布有规律,所以我们不需要去管那些 \(0\),于是可以做到 \(O(m)\)。总体时间复杂度 \(O(nm)\)

注意 \(m=1\) 时转移方程不成立,需要特判。

代码中滚动了数组第一维。

#include <iostream>
#include <cstring>
#include <iomanip>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
#define g(x, y, z) for (int x = (y); (x) >= (z); --(x))
using namespace std;
typedef double db;
const int N = 1e3 + 10;
int n, m, x, y;
db f[N], a[N][N];

void init() {
    memset(a, 0, sizeof a);
    a[1][1] = a[m][m] = 2;
    a[1][2] = a[m][m - 1] = -1;
    a[1][m + 1] = f[1] + 3;
    a[m][m + 1] = f[m] + 3;
    f(j, 2, m - 1) {
        a[j][j] = 3;
        a[j][j - 1] = a[j][j + 1] = -1;
        a[j][m + 1] = f[j] + 4;
    }
    return;
}

void Gauss() {
    db t;
    f(i, 1, m - 1) {
        t = a[i + 1][i] / a[i][i];
        a[i + 1][i] = 0;
        a[i + 1][i + 1] -= a[i][i + 1] * t;
        a[i + 1][m + 1] -= a[i][m + 1] * t;
    }
    g(i, m, 2) {
        f[i] = a[i][m + 1] / a[i][i];
        t = a[i - 1][i] / a[i][i];
        a[i - 1][i] = 0;
        a[i - 1][m + 1] -= a[i][m + 1] * t;
    }
    f[1] = a[1][m + 1] / a[1][1];
    return;
}

signed main() {
    cin >> n >> m >> x >> y;
    cout << fixed << setprecision(4);
    if (m == 1) {
        cout << (n - x) * 2.0 << '\n';
        return 0;
    }
    g(i, n - 1, x) {
        init();
        Gauss();
    }
    cout << f[y] << '\n';
    return 0;
}
posted @ 2023-05-31 17:03  f2021ljh  阅读(105)  评论(1)    收藏  举报