【蓝桥杯 算法提高 ADV - 299】宰羊( 区间 dp )

题目链接:http://lx.lanqiao.cn/problem.page?gpid=T576

1 题目

题目描述

炫炫回了内蒙,肯定要吃羊肉啦,所有他家要宰羊吃。
炫炫家有 N 只羊,羊圈排成一排,标号 1 ~ N。炫炫每天吃掉一只羊(这食量!其实是放生啦),吃掉的羊的邻居会以为它被放生了,然后又会告诉他们的邻居,这样一直传播下去,除非某个邻居已经被“放生”了。每一天,所有知道某羊被“放生”了这个消息的羊都会很不满,如果不给他们巧克力的话,他们就会很造反,炫炫已经知道他要吃掉哪些羊,他可以任意安排吃的顺序,然后使巧克力的用量最小,请求出这个最小值。

输入格式

本题有多组数据,第一行为数据组数 T。
对于每组数据:
第一行:两个用空格隔开的整数:N 和 M,表示羊的数量和需要吃掉的数量;
第二行:有 M 个数,表示要吃那些羊。

输出格式

T 行,为每组数据的答案。

样例输入

2
8 1
3
20 3
3 6 14

样例输出

T = 10
N <= 10000
M <= 100

2 分析

这题可用 区间dp 解决。

1. 首先我们来看(重温)dp的三个性质:

  • 最优子结构

    • 当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。问题的最优子结构性质提供了该问题可用动态规划算法求解的重要线索。
    • 在动态规划算法中,利用问题的最优子结构性质,以自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解
  • 重叠子问题

    • 子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用。对每个子问题只解一次,而后将其保存在一个表格中,当再次需要的时候,只是简单的用常数时间查看一下结果。
  • 无后效性

    • 即某阶段状态一旦确定,就不受这个状态以后决策的影响。即某状态以后的过程不会影响以前的状态,只与当前状态有关

2. 回到题目中:

首先给出状态转移方程

$$ dp[l][r] = min\{ \, dp[l][r], \,\, dp[l][i - 1] + dp[i + 1][r] + (arr[r + 1] - arr[l - 1] - 2) \, \} $$
  • 数组 $arr[i]$ 存储被杀羊的号码,而 $i$ 表示羊在数组 $arr$ 中的编号,例如在第2个样例中,一共有3只羊被杀,羊的号码分别为$3,6,14$ ,则数组存储的规则为:$arr[1]=3,arr[2]=6,arr[3]=14$

  • $dp[l][r]$ 表示杀掉 $arr[l] \sim arr[r]$ 只羊所花费的最小代价。这个表达可能有些拗口,举个栗子:$dp[1][3]$ 表示杀掉数组编号$1,2,3$ 的羊花费的最小代价,即 $arr[1],arr[2],arr[3]$ 这三只羊。

3. 为什么要用 dp 求解:

再来看一下题意,题目有两点关键信息:

  • 羊圈排成一排,标号为 $1\sim N$。(也就是说,羊圈的羊头和羊尾不会再传播下去)
  • 炫炫每吃掉一只羊,这只羊的邻居就会引起恐慌,接着这只羊的邻居们会继续告诉他们的邻居,一直传播下去,什么时候传播恐慌结束?(划重点!),当某个邻居已经被杀掉时

我们可以看出,其实求 $dp[l][r]$ 可以缩小问题规模,变成求 $dp[l][k] + dp[k + 1][r]$ 的最小代价,对吧?如果你认为没问题,好,通过不断地拆分区间求解,问题的规模得到了缩小,这里符合dp基本性质之一“最优子结构”。

而我们知道,在动态规划算法中,利用问题的最优子结构性质,是以自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解

在本题中,从子问题 → 整个问题的求解,可以这样做,区间以每只即将被杀掉的羊为结点划分:

  • 按照区间长度由小到大的规则,计算最小代价,即我们先从长度为 $1$ 的区间开始计算最小代价,再计算区间长度为 $2→3→4→5→…$ 的最小代价。最后当区间长度为 $m$ 时,就是问题的求解结果
  • 当区间长度 > 1时,这时需要逐个遍历区间中每只即将被杀掉的 $arr[i]$ 羊,通过 dp 方程去计算最小代价。

在本题中,从区间的角度,我们要求的最小代价 $dp[1][m]$ ,可以表示成:

$$ dp[1][m] = dp[1] [k - 1] + arr[k] + dp[k + 1] [m] $$

即杀掉所有羊的代价可以变成求杀掉 $arr[1] \sim arr[k - 1]$ 只羊的最小代价与 $arr[k + 1] \sim arr[m]$ 只羊,再加上杀掉 $arr[k]$ 的最小代价。

好了,到这一步应该没有太难理解的地方,那么应该如何求杀掉 $arr[k]$ 的最小代价?

4. 如何求杀掉 $arr[k]$ 的最小代价:

通过上面列出的 dp 方程中可以看到

$$ arr[k]=arr[r + 1] - arr[l - 1] - 2 $$

为啥?

我们改一下第 1 个样例中的数据,再举个栗子:

羊   圈:1 2 3 4 5 6 7 8
待杀羊号码:2 3 5 $(arr[1]=2, arr[2]=3, arr[3]=5)$
考虑区间:{ $2$ },$2$ 表示待杀羊在数组中的编号

即我们要杀掉号码为 $3$$arr[2]=3$)的这只羊,也就是要求杀掉 $arr[2]$ 的代价,这时候3号羊的邻居得到了消息,它们开始传播消息,什么时候传播停止?遇到了号码为2与号码为5的羊

换句话说,我们在杀死号码为3的这只羊时,它的左右邻居肯定被影响,也就是区间外的羊也会被影响,而传播停止是在遇到被杀死的羊的时候。

再来看公式:

$$ arr[k]=arr[r + 1] - arr[l - 1] - 2 $$

是将区间扩展到左右边界外第一只待杀死的羊,为啥是 $-2$

  • 公式中已经将区间扩展到了 $arr[r + 1]与arr[l - 1]$ ,即{ 2, 3, 4, 5 },就是例子中羊的号码为 $2$ 与号码为 $5$ 的位置,这两只羊是要被杀死的,此时区间中有 $5-2-1$ 只羊。
  • 而号码 $3$(即$arr[2]$)是我们最开始要杀的羊,所以最小代价为 $5 - 2 - 1 - 1$,即$5 - 2 - 2 = 1$,我们需要给一块巧克力去安慰号码为 $4$ 这只羊。

接下来验证一下这个公式是否合理:

  • 考虑区间:{ $2$ },此时区间长度为 $1$,区间左部 $l$ 与区间右部 $r$ 相等,所以 $l=r=2$(前面约定,区间是以每只即将被杀掉的羊为结点划分,在这个例子中,羊的结点编号为 $2$

  • 代入公式:

    $$ arr[2]=arr[2 + 1] - arr[2 - 1] - 2=1 $$
  • 公式结果表明,杀掉 $arr[2]$ 这只羊,最小代价是 $1$

5. 考虑边界值:

  • 题意说到,羊圈是排成一排的,即羊首与羊尾不会再传播给它的邻居,因此为了处理方便,我们设 $arr[0] = 0,arr[m + 1] = n + 1$
  • 当区间长度为1时,此时区间里只有一只待杀的羊,这时不需要再进行区间划分,直接计算杀掉 $arr[k]$ 的代价即可。即:
    $$dp[l][r] = arr[r + 1] - arr[l - 1] - 2$$

3 题解

#include <iostream>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int maxn = 1e4 + 5;
const int INF = 0x3f3f3f;
int arr[maxn] = {0}, dp[maxn][maxn] = {0};

int main (){
    //freopen("input.txt", "r", stdin);
    int t;
    cin >> t;
    for(int x = 0;x < t;x++){
        int n, m;
        cin >> n >> m;
        for(int i = 1;i <= m;i++){
            cin >> arr[i];
        }
        //处理边界值 
        arr[0] = 0, arr[m + 1] = n + 1;
        //区间长度由小到大 
        for(int len = 1;len <= m;len++){
            //考虑区间情况 l → r 
            for(int l = 1;l + len <= m + 1;l++){
                //计算区间的右边界 r
                int r = l + len - 1;
                //区间长度为 1 的特殊情况 
                if(len == 1){
                    dp[l][r] = arr[r + 1] - arr[l - 1] - 2;
                }else{
                    //赋初值 
                    dp[l][r] = INF;
                    //逐个遍历区间内每只待杀的羊 
                    for(int i = l;i <= r;i++){
                        dp[l][r] = min( dp[l][r], dp[l][i - 1] + dp[i + 1][r] + arr[r + 1] - arr[l - 1] - 2 );
                    }
                }
            }
        }
        printf("%d\n", dp[1][m]);
    }
    return 0;
}

如果你还是有一些疑惑,建议在代码中进行调试,输出各数据的运行情况,这可能有利于你理解。
另外如果你想进一步了解区间 dp 原理,可以查看 $References$ 链接中的第一个。

4 References

有问题欢迎指正!