动态规划&&状压

北城余情 提交于 2019-12-06 14:07:21

一、动态规划

动态规划,无非就是利用历史记录,来避免我们的重复计算。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。下面我们先来讲下做动态规划题很重要的三个步骤,

如果你听不懂,也没关系,下面会有很多例题讲解,估计你就懂了。之所以不配合例题来讲这些步骤,也是为了怕你们脑袋乱了

第一步骤:定义数组元素的含义,上面说了,我们会用一个数组,来保存历史数组,假设用一维数组 dp[] 吧。这个时候有一个非常非常重要的点,就是规定你这个数组元素的含义,例如你的 dp[i] 是代表什么意思?

第二步骤:找出数组元素之间的关系式,我觉得动态规划,还是有一点类似于我们高中学习时的归纳法的,当我们要计算 dp[n] 时,是可以利用 dp[n-1],dp[n-2].....dp[1],来推出 dp[n] 的,也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],这个就是他们的关系式了。而这一步,也是最难的一步,后面我会讲几种类型的题来说。

学过动态规划的可能都经常听到最优子结构,把大的问题拆分成小的问题,说时候,最开始的时候,我是对最优子结构一梦懵逼的。估计你们也听多了,所以这一次,我将换一种形式来讲,不再是各种子问题,各种最优子结构。所以大佬可别喷我再乱讲,因为我说了,这是我自己平时做题的套路。

第三步骤:找出初始值。学过数学归纳法的都知道,虽然我们知道了数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],我们可以通过 dp[n-1] 和 dp[n-2] 来计算 dp[n],但是,我们得知道初始值啊,例如一直推下去的话,会由 dp[3] = dp[2] + dp[1]。而 dp[2] 和 dp[1] 是不能再分解的了,所以我们必须要能够直接获得 dp[2] 和 dp[1] 的值

案例一、简单的一维 DP

问题描述:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

(1)、定义数组元素的含义

按我上面的步骤说的,首先我们来定义 dp[i] 的含义,我们的问题是要求青蛙跳上 n 级的台阶总共由多少种跳法,那我们就定义 dp[i] 的含义为:跳上一个 i 级的台阶总共有 dp[i] 种跳法。这样,如果我们能够算出 dp[n],不就是我们要求的答案吗?所以第一步定义完成。

(2)、找出数组元素间的关系式

我们的目的是要求 dp[n],动态规划的题,如你们经常听说的那样,就是把一个规模比较大的问题分成几个规模比较小的问题,然后由小的问题推导出大的问题。也就是说,dp[n] 的规模为 n,比它规模小的是 n-1, n-2, n-3.... 也就是说,dp[n] 一定会和 dp[n-1], dp[n-2]....存在某种关系的。我们要找出他们的关系。

那么问题来了,怎么找?

这个怎么找,是最核心最难的一个,我们必须回到问题本身来了,来寻找他们的关系式,dp[n] 究竟会等于什么呢?

对于这道题,由于情况可以选择跳一级,也可以选择跳两级,所以青蛙到达第 n 级的台阶有两种方式

一种是从第 n-1 级跳上来

一种是从第 n-2 级跳上来

由于我们是要算所有可能的跳法的,所以有 dp[n] = dp[n-1] + dp[n-2]。

(3)、找出初始条件

当 n = 1 时,dp[1] = dp[0] + dp[-1],而我们是数组是不允许下标为负数的,所以对于 dp[1],我们必须要直接给出它的数值,相当于初始值,显然,dp[1] = 1。一样,dp[0] = 0.(因为 0 个台阶,那肯定是 0 种跳法了)。于是得出初始值:

dp[0] = 0. dp[1] = 1. 即 n <= 1 时,dp[n] = n.

三个步骤都做出来了,那么我们就来写代码吧,代码会详细注释滴。

int f( int n ){
    if(n <= 1)
    return n;
    // 先创建一个数组来保存历史数据
    int[] dp = new int[n+1];
    // 给出初始值
    dp[0] = 0;
    dp[1] = 1;
    // 通过关系式来计算出 dp[n]
    for(int i = 2; i <= n; i++){
        dp[i] = dp[i-1] + dp[-2];
    }
    // 把最终结果返回
    return dp[n];
}

(4)、再说初始化

大家先想以下,你觉得,上面的代码有没有问题?

答是有问题的,还是错的,错在对初始值的寻找不够严谨,这也是我故意这样弄的,意在告诉你们,关于初始值的严谨性。例如对于上面的题,当 n = 2 时,dp[2] = dp[1] + dp[0] = 1。这显然是错误的,你可以模拟一下,应该是 dp[2] = 2。

也就是说,在寻找初始值的时候,一定要注意不要找漏了,dp[2] 也算是一个初始值,不能通过公式计算得出。有人可能会说,我想不到怎么办?这个很好办,多做几道题就可以了。

下面我再列举三道不同的例题,并且,再在未来的文章中,我也会持续按照这个步骤,给大家找几道有难度且类型不同的题。下面这几道例题,不会讲的特性详细哈。实际上 ,上面的一维数组是可以把空间优化成更小的,不过我们现在先不讲优化的事,下面的题也是,不讲优化版本。

摘自

二、状态压缩

状态压缩动态规划,就是我们俗称的状压DP,是利用计算机二进制的性质来描述状态的一种DP方式

在求解背包问题时,我们的状态通常定义为n件物品分别放与不放。 最容易想到的是开个n维数组,但是这样控件浪费且难以实现,我们仔细观察就会发现,每件物品有放与不放两种选择;假设我们有5件物品的时候,用1和0代表放和不放。 如果这5件物品都不放的话,那就是00000; 如果这5件物品都放的话,那就是11111;看到这,我们知道可以用二进制表示所有物品的放与不放的情况;如果这些二进制用十进制表示的话就只有一个维度了。而且这一个维度能表示所有物品放与不放的情况;

​ 这个过程就叫做状态压缩;在状态难以表示并且只有决策情况的时候可以用状态压缩。状压其实是一种很暴力的算法,因为他需要遍历每个状态,所以将会出现2^n的情况数量 ,但是求解的规模n一般不会很大。有了状态,我们就需要对状态进行操作或访问

​ 可是问题来了:我们没法对一个十进制下的信息访问其内部存储的二进制信息,怎么办呢?别忘了,操作系统是二进制的,编译器中同样存在一种运算符:位运算 能帮你解决这个问题。

​ .判断一个数字x二进制下第i位是不是等于1。

方法:if(((1<<(i−1))&x)>0)if(((1<<(i−1))&x)>0)

将1左移i-1位,相当于制造了一个只有第i位上是1,其他位上都是0的二进制数。然后与x做与运算,如果结果>0,说明x第i位上是1,反之则是0。

2.将一个数字x二进制下第i位更改成1。

方法:x=x|(1<<(i−1))x=x|(1<<(i−1))

证明方法与1类似,此处不再重复证明。

3.把一个数字二进制下最靠右的第一个1去掉。

方法:x=x&(x−1)

image-20191204190748253

UVA1099

image-20191204182550589

题目:给定x*y大小的巧克力,要求只能横着切和纵着切,而且必须一次切到底,即每次切割能把一块巧克力切成两块矩形巧克力 ,问是否能经过若干次操作,把巧克力切成n块,每块面积分别为a1,a2……an;

思路:每次切一刀(可以横着或者竖着)可以把一切巧克力切成两块小的矩形巧克力,我们可以定义状态:dp[x] [y] [s]为给定长宽为xy的巧克力,能否切出面积集合s。用2进制来表示面积集合,假设切为4块,每块为6,3,2,1,则用1111表示该集合,集合从1-1111一共 2的n+1次方-1,为15个集合。因为s的总面积总是和x*y相等的,不同的话肯定切不了,可以用sum[s]保存每个集合的面积,则y=sum[s]/x,可以规定x为巧克力较短的边,可以把状态dp[x] [y] [s]改为dp[x] [s]。

题目要求dp[x] [s],我们可以跟x平行切

image-20191205115821828

把巧克力切成两块,只要切成的两小块,可以满足切成子集,那么整个大块就也能切,则状态转移为求dp[min(x,sum[s0]/x)] [s0]&&dp[min(x,sum[s1]/x)] [s1]

同时也可以把最短边x切断,

image-20191205122209409

此时状态转移为dp[min(y,sum[s0]/y)] [s0]&&dp[min(y,sum[s1]/y)] [s1]

只要两种切法满足其中一个就可以。转移方程找到了题目就很容易了。

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <stdio.h>
#include <cstring>
#include <algorithm>
#include <queue>
#include <map>
using namespace std;
const int MAXN = 16;
int n, x, y;
int a[MAXN];
int sum[1 << MAXN];
int dp[101][1 << MAXN]; // 1 为可以 0为不可以 -1为未确定

//计算集合中有几个元素
int bitcount(int a)
{
    int rt = 0;
    while (a)
    {
        //依次取最后一位&1,算是否有元素
        if (a & 1) rt++;
        a >>= 1;
    }
    return rt;
}

//求短边x下能否切出集合S
int dfs(int x, int s)
{
    if (dp[x][s] != -1)return dp[x][s]; //若dp中已经计算过,则直接返回结果
    if (bitcount(s) == 1)return dp[x][s] = 1; //集合中只有一个元素,则不需要再切 
    int y = sum[s] / x; //计算另一边
    for (int s0 = (s - 1) & s; s0; s0 = (s0 - 1) & s) //分别给切成的两块不同的集合
    {
        int s1 = s ^ s0;
        if (sum[s0] % x == 0 && dfs(min(x, sum[s0] / x), s0) && dfs(min(x, sum[s1] / x), s1))
            return dp[x][s] = 1;
        if (sum[s0] % y == 0 && dfs(min(y, sum[s0] / y), s0) && dfs(min(y, sum[s1] / y), s1))
            return dp[x][s] = 1;
    }
    return dp[x][s] = 0;
}
int main()
{
    int cs = 1;
    while (scanf("%d", &n) != EOF)
    {
        if (n == 0)break;
        scanf("%d%d", &x, &y);
        int ct = 0;
        for (int i = 0; i < n; i++)
        {
            scanf("%d", &a[i]);
            ct += a[i];
        }
        if (ct != x * y || ct % x != 0)
        {
            printf("Case %d: No\n", cs++); continue;
        }

        //all表示原始的集合 
            //如果n=3  1<<3-1=111
            //如果n=4  1<<4-1=1111
        int ALL = (1 << n) - 1;

        //计算每个集合的面积
            //          1236
            //1 2 3 6 =>1111 sum[15]=12
            //1       =>1000 sum[8]=1
            //23      =>0110 sum[6]=6
        for (int s = 0; s <= ALL; s++)
        {
            sum[s] = 0;
            for (int i = 0; i < n; i++)
            {
                if (s & (1 << i))
                {
                    sum[s] += a[i];
                }
            }
        }

        memset(dp, -1, sizeof dp);
        if (dfs(min(x, y), ALL) == 1)
        {
            printf("Case %d: Yes\n", cs++);
        }
        else {
            printf("Case %d: No\n", cs++);
        }
    }
    return 0;
}
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!