简单的动态规划:背包问题

一.问题描述:

 背包问题是一类离散最优化问题的统称.这样的问题一般给定n个可选"物品" S e l e c t i o n = s 1 , s 2 , . . . , s n Selection={s_1,s_2,...,s_n} Selection=s1,s2,...,sn,以及其中每一个物品的"价值" V a l u e = v 1 , v 2 , . . . , v n Value=v_1,v_2,...,v_n Value=v1,v2,...,vn每一个物品的"代价" C o s t = c 1 , c 2 , . . . , c n Cost=c_1,c_2,...,c_n Cost=c1,c2,...,cn.
 问题是:假如你手中握有一定的代价C(也就是背包大小),需要你求出通过选择 s 1 , s 2 , . . . , s n {s_1,s_2,...,s_n} s1,s2,...,sn中的某一些"物品",所能够达到的最大"价值"总和,而这些物品的 "代价"总和不能超过C.

二.思路与思想:

背包问题的求解思路:

 对于背包问题这样的 “代价” - “价值” 最优化问题,首先考虑能否使用贪心选择的方法来求解.假若不能,那么就要考虑使用动态规划来求解该问题.
 而使用动态规划的思想,就是要维护一个最优值的数组,可以将其看作一个大小逐步递增的背包序列,使得在通过不断增加"子问题"规模以求解"原问题"的过程中,这个最优值数组是当前子问题的最优解.

  • s 1 , s 2 , . . . , s n {s_1,s_2,...,s_n} s1,s2,...,sn中的子序列 S u b S e q = s i , s j , . . . , s k SubSeq={s_i,s_j,...,s_k} SubSeq=si,sj,...,sk视为原问题的一个子问题.其中i,j,k随意选取.
  •  使用数组元素 D P [ c ] DP[c] DP[c]代表当前"子问题" s i , s j , . . . , s k {s_i,s_j,...,s_k} si,sj,...,sk下花费代价 c ∈ [ 0 , C ] c\in[0,C] c[0,C]所能产生的最优解.
  •  将子问题规模增大,将 s p s_p sp插入到 s i , s j , . . . , s k {s_i,s_j,...,s_k} si,sj,...,sk后,形成新的"子问题" s i , s j , . . . , s k , s p {s_i,s_j,...,s_k,s_p} si,sj,...,sk,sp
  • 更新 D P DP DP数组:
     对于 ∀ c ⩾ c p \forall c\geqslant c_p ccp,要更新 D P [ c ] DP[c] DP[c],也就是考虑对于 D P [ c p ] , D P [ c p + 1 ] , . . . , D P [ C ] DP[c_p],DP[c_p+1],...,DP[C] DP[cp],DP[cp+1],...,DP[C]的背包序列中的每一个每一个背包,将 s p s_p sp加入背包中是否会提高该背包的价值.
     对于一般的问题来说,将 s p s_p sp加入背包 D P [ c q ] DP[c_q] DP[cq]中是否会提高它的价值,取决于 D P [ c q − c p ] + v p DP[c_q-c_p]+v_p DP[cqcp]+vp的值是否大于此时的 D P [ c q ] DP[c_q] DP[cq].
     更新数组时要注意顺序,有时可能会在更新时破坏原数组,从而出错.这种情况下一般都要调整更新顺序,使得大容量"背包"先获得更新,小容量"背包"后更新.

背包问题的求解思想:

  • 1.转化思想:
     很多复杂的背包问题可以转化为简单的背包问题.
  • 2.排序与预处理:
     背包问题可以将 s 1 , s 2 , . . . , s n {s_1,s_2,...,s_n} s1,s2,...,sn序列任意排序/预处理,再进行计算求解.
  • 3.扩展子问题:
     一切动态规划算法都需要维护子问题的信息,但是背包问题的求解,将原问题"求容量为C的背包 D P [ C ] DP[C] DP[C]的最大价值"扩展为"分别求容量不大于C的背包序列 D P [ 0 , 1 , . . . , C ] DP[0,1,...,C] DP[0,1,...,C]的最大价值".

三.简单的背包问题与算法:

[a.] 0-1背包问题1:

  • 1.问题描述:

 电子科大本部食堂的饭卡有一种很诡异的设计,即在购买之前判断余额。如果购买一个商品之前,卡上的剩余金额大于或等于5元,就一定可以购买成功(即使购买后卡上余额为负),否则无法购买(即使金额足够)。所以大家都希望尽量使卡上的余额最少。
 某天,食堂中有n种菜出售,每种菜可购买一次。已知每种菜的价格以及卡上的余额,问最少可使卡上的余额为多少。

  • 2.问题分析:

 显然这是一个类似于0-1背包的问题.给定一个大小为m的背包(饭卡),一个有着价值序列 v 1 , v 2 , . . . , v n v_1,v_2,...,v_n v1,v2,...,vn的菜品序列 s 1 , s 2 , . . . , s n {s_1,s_2,...,s_n} s1,s2,...,sn.想要从中得到一个序列使得"卡上的余额最少",也就是序列价值和最大.
 然而该问题与0-1背包有一个明显的不同之处,那就是在针对"物品" s p s_p sp来更新背包序列 D P [ 0 , 1 , . . . , m ] DP[0,1,...,m] DP[0,1,...,m]时,只要背包的容量 c ⩾ 5 c\geqslant5 c5就可以进行更新,不需要满足 c ⩾ c p c\geqslant c_p ccp的条件.
 而显然通过这种"超额"方式更新后的容量 c ′ < 5 c'<5 c<5的背包以及原本容量 c < 5 c<5 c<5的背包,就不能再次以任何方式进行更新,但是它们的值显然可能是最小值.

  • 3.算法与改进:

 如果使用一个变量存储"超额"更新的最大值:
 因为"超额"之后的背包就不能再次获得更新,那么假若每次更新"子问题"时选取的 s p s_p sp的价值不是严格递增的,即如果 v i > v j v_i>v_j vi>vj而有 s i , . . . , s j s_i,...,s_j si,...,sj是"子问题"序列"增长"的方式.
 则可能存在一个背包 D P [ c x ] DP[c_x] DP[cx],使得它的价值最高是 D P [ c x − v j ] + v j + v i DP[c_x-v_j]+v_j+v_i DP[cxvj]+vj+vi(加入 v j v_j vj不超额,加入 v i v_i vi超额).那么显然先使用 v i v_i vi更新这个背包后, v j v_j vj会因为背包"超额"而无法被更新,从而丢掉最优解.
 这样一来,就必须要事先对"物品"(菜品)进行排序,然后按菜品价值递增的顺序对"子问题"进行更新.
改进(1)一定要使用临时变量,一定要排序吗?
 为什么要使用一个变量代表使用菜品 s p s_p sp更新后所有"超额"背包价值和的最大值,而不是去真正"超额"更新这些背包呢?因为整个算法是按照菜品价值递增顺序进行更新的,因而对于任意非最大价值菜品 s p s_p sp,它所"超额"更新的背包必定比其后续更大价值的菜品"超额"更新的背包小.所以不能真正"超额"更新这些背包,要留给后续菜品去更新.
 那么既然这样,我们甚至也不需要用临时变量储存这个"超额"更新的最大值了,因为已经知到它肯定小于后续菜品的"超额"更新值.而这个值又不参与更新迭代,那么只需要计算一下最后一道菜品(价值最大的菜品)超额更新值即可.
 那么我们甚至可以不需要进行排序了,因为"超额"更新只能发生一次,那么它肯定发生在最大价值菜品处,而且只要保证这个最大价值菜品在最后进行处理即可.
 这样一来,这个问题整个就转化为了一个0-1背包问题,只不过我们一是要保证更新后的背包容量不能小于5否则不更新,二是要首先找到所有"物品"中价值最大者,并保证最后更新它.

    	int n,m,dishes[1005];
    	vector<int> dp(1005); // 背包序列
    	
		memset(&dp.front(), 0, 1005*sizeof(int));
        // 找到最大值,并且将其置为0,最后处理
        int max=-1,max_index=0; 
        for(int i=0;i<n;i++)
        {
            if(dishes[i]>max)
            {
                max=dishes[i];
                max_index=i;
            }
        }
        dishes[max_index]=0; // 置为0
        
        int take_i;
        for (int i = 0; i < n; i++)
        {	
        	// 按照背包容量从大到小更新,使原有数据不被破坏
            for (int j = m; j >= dishes[i] + 5; j--)
            {

                take_i = dp[j - dishes[i]] + dishes[i];
                // 只有更新后背包容量仍然不小于5,才更新
                if (j - take_i >= 5 && take_i > dp[j])
                    dp[j] = take_i;
            }
        }
		// dishes[max_index]=max // 还原(可选操作)
		
       	// 最后考虑所有"物品"中的最大价值者,它可以"超额"更新:
       	if(m>=5)
        	return m-(dp[m]+max);
        else
        	return m;

改进(2)一定要完全按照背包问题的方法处理它吗?
 这个问题中"代价"==“价值”,实际上不一定需要0-1背包那样用int数组来存储背包序列 D P [ 0 , 1 , . . . , m ] DP[0,1,...,m] DP[0,1,...,m]的最优值和更新时的暂时值.完全可以使用一个bool型数组表示子问题的最优值.
 定义 D P [ i ] , i ∈ [ 0 , m ] DP[i],i\in[0,m] DP[i],i[0,m]代表一个bool值序列,代表当前"子问题"下余额是否能够到达 i i i值,以及一个int型变量least代表当前"子问题"下余额可到达的(包含负数)的最小值.
 根据以上两节的论述,只要保证最大价值的菜品在最后处理(更新),这个算法就可以正确执行,并且省掉一些空间需求.

        int n,m,dishes[1005];
   		vector<bool> dp(1005);    // bool型空间需求更小

		memset(&dp.front(), 0, 1005*sizeof(bool));
		 // 找到最大值,并且将其与数组末尾交换,最后处理
        int max=-1,max_index=0;
        for(int i=0;i<n;i++)
        {
            if(dishes[i]>max)
            {
                max=dishes[i];
                max_index=i;
            }
        }
        // 交换最大价值元素和最后一个元素的位置
        dishes[max_index]=dishes[n-1];
        dishes[n-1]=max;

		// 首先m余额是可以得到的(哪件菜品都不选)
        dp[m]=true; 
        int least=m,nxt_residue;
        for(int i=0;i<n;i++)
        {
            for(int j=5;j<=m;j++)
            {	
            // 代表这个余额可以达到
                if(dp[j]==true)
                {
                    nxt_residue=j-dishes[i];
                    if(nxt_residue>=0)
                    {
                        dp[nxt_residue]=true;
                    }
                    // least存储所有可达余额的最小值
                    if(nxt_residue<least)
                        least=nxt_residue;
                }
            }
        }
        // 已经处理完成,直接返回即可
        return least;
  • 4.题目来源:

hdu-p2546

[b.]0-1背包问题2:

  • 1.问题描述:

在这里插入图片描述

  • 2.问题分析:

 这个问题是一个NP完全问题,所以不存在现有多项式时间解法,但是因为限定了整数是正整数,且大小固定,则可以使用背包模型动态规划解决该问题.
问题转化: 原问题是"是否能够将数组划分为和相等的两个子数组",并不是一个最优化问题.但是可以将其转换为:“给定n个数,从中选择任意多个,且其和不大于n个数的和的一半,要求得这些数的和的最大值”.在求得这个最大值后,判断它是不是正好等于n个数的和的一半即可.

  • 3.算法示例:


int max_dp[10001];
class Solution 
{

public:
    bool canPartition(vector<int>& nums) 
    {
        memset(max_dp, 0, sizeof(max_dp));
        int sum=0;
        int size=nums.size();
        for(int i=0;i<size;i++)
        {
            sum+=nums[i];
        }
        if(sum%2==1)
            return false;
        
        sum=sum/2;

        for(int i=0;i<size;i++)
        {
            for(int j=sum;j>=nums[i];j--)
                max_dp[j]=std::max(max_dp[j],max_dp[j-nums[i]]+nums[i]);
        }
        return max_dp[sum]==sum;
    }
};
  • 4.题目来源:

416. 分割等和子集

[c.] 二维0-1背包问题:

  • 1.问题描述:

 假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。
 你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。
 输入: strs = [“10”, “0001”, “111001”, “1”, “0”], m = 5, n = 3
 输出: 4
 解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 “10”,“0001”,“1”,“0” 。

  • 2.问题分析:

 这就是一个最经典的二维背包的案例,它和普通的0-1背包的区别只在于它的背包序列是二维的.

  • 3.算法示例:

// 一个二维背包序列:一共有101*101种背包大小
int dps[101][101];

class Solution 
{
public:
    int findMaxForm(vector<string>& strs, int m, int n) 
    {   
        memset(dps, 0 , 10201*sizeof(int));
        // 按原顺序遍历每一个字符串,用它更新每一个背包即可
        for(int i=1;i<=strs.size();i++)
        {
            int m_need=count_m(strs[i-1]);
            int n_need=strs[i-1].size()-m_need;
			
			// 背包的更新要从大至小,防止原数据被破坏
            for(int mi=m;mi>=m_need;mi--)
                for(int ni=n;ni>=n_need;ni--)
                    dps[mi][ni]=std::max(dps[mi][ni],dps[mi-m_need][ni-n_need]+1);
        }
        //返回最大背包的最优解即可
        return dps[m][n];
    }

    // 获得一个单一字符串中0的个数的方法:
public:
    inline int count_m(string& s)
    {
        int res=0;
        for(int i=0;i<s.size();i++)
            if(s[i]=='0')
                res++;
        return res;
    }
};
  • 4.题目来源:

LeetCode474:一和零

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值