编程之美2.18 : 有一个无序、元素个数为n的正整数数组,要求:如何能把这个数组平均分成两个子数组,并使两个子数组之和最接近。

注: n不一定是偶数,也可能是奇数

如题所述,这是一个经典的01背包问题,但是在实现过程中遇到了很多的麻烦,花了三个小时才搞完,特此记录下。主要是分享一下解题过程中出现的错误以及相应的解决办法,希望下回不会再犯吧。

首先,先来一个简化版问题,把约束(平均分割)去掉,题目变成  元素个数为n的正整数数组,如何能把这个数组分成两个子数组,并使两个子数组之和最接近,这里没有平均分割要求,那么就是一个简单的01背包问题,即n件物品凑不超过sum/2的值, 

定义 dp[i][j]  为前i个数凑数字j 是否可能 ,其中j的范围从1 到 sum/2, 显然 d[i][0]=1; 原数组为v,为了之后的谈论方便这里 v 的元素从1开始,但程序中仍从0开始 

状态转移方程:        dp[i][j] = dp[i-1][j-v[i]] || dp[i-1][j]         j>=v[i] 时

                                         =dp[i-1][j]                                j

程序大概是这样的,我没测试不知道正确与否

for(int i=1;i<=v.size();i++)
    for(int j=1;j<=sum/2;j++)
         {
              if(j>=v[i-1]) 
                   dp[i][j] = dp[i-1][j-v[i-1]]||dp[i-1][j];
             else  dp[i][j]=dp[i-1][j];
         }


现在回到原问题,先简化一点,设n是偶数。 那么,现在变成从n个里选 n/2个数,使他们之和接近 sum/2,实际上这就是多了一维的限定条件, 变成了二维的背包,定义 d[n][i][j] 为从 前 n 个数里选 i 个数使他们和为 j是否可能 

状态转移方程:        dp[n][i][j] = dp[n-1][i-1][j-v[n]] || dp[n-1][i-1][j]         j>=v[n] 时

                                                 =dp[n-1][i-1][j]                                j

写成程序也很好写,但是这里是o(n^3)的空间复杂度(由于第三维j与sum有关,所以实际上会高的多),可以利用一些技巧(有些地方称这种方法为滚动数组)来简化空间复杂度,关于这一部分可以去研究背包九讲。下面这个函数返回两个子数组的最小差值

int divide_array(const vector &v)
{
    const int len =v.size();
    if(!len) return 0;
    int sum=0;        //假设sum不会溢出
    for(int i=0;i>1; //即  sum/2
    int  h_len = len>>1;
    vector > dp(h_len+1,vector(h_sum+1,0));
    dp[0][0]=1;
    for(int n=1;n<=len;n++)
        for(int i=std::min(n,h_len);i>=1;i--)  //注意这里i递减
            for(int j=1;j<=h_sum;j++)
            {
               if(j>=v[n-1])
                 dp[i][j]=dp[i-1][j-v[n-1]]||dp[i][j];
               else
                 dp[i][j]=dp[i][j];
            }
    int res;
        for(res=h_sum;!dp[h_len][res];res--);
    return sum-2*res;
}

再次回到原问题,如果 n为奇数怎么办? 第一次出错的原因就是我把这两种情况想简单了,直接把奇数偶数合并了,用上面那个程序处理奇数问题,但会出现一些问题,例如 :1, 1,10,1, 1,按照规则这个数组应该分成 1,1,1  和1,10  

但实际上 由于len=5 ,而h_len=2 ,h_sum=7, 所以程序返回的是  选取2个数使之和最接近7的组合,会被分成 1,1   和 1,1,10 造成错误,那怎么解决呢? 我只要看选取 h_len 和h_len+1个两种情况哪种更好就是了。

无论n奇数偶数,我都计算多计算一个h_len+1,然后在最后判断

int divide_array(const vector &v)
{
    const int len =v.size();
    if(!len) return 0;
    int sum=0;
    for(int i=0;i>1;
    int  h_len = (len>>1)+1; // 这里多算一个
    vector > dp(h_len+1,vector(h_sum+1,0));
    dp[0][0]=1;
    for(int n=1;n<=len;n++)
        for(int i=std::min(n,h_len);i>=1;i--)
            for(int j=1;j<=h_sum;j++)
            {
               if(j>=v[n-1])
                 dp[i][j]=dp[i-1][j-v[n-1]]||dp[i][j];
               else
                 dp[i][j]=dp[i][j];
            }
    int res;
    if(!(len&1))  //如果是偶数,按正常来
        for(res=h_sum;!dp[h_len-1][res];res--);
    else
        for(res=h_sum;!dp[h_len-1][res]&&!dp[h_len][res];res--);//看 h_len 和 h_len-1 哪种好
    return sum-2*res;
}

这里解释一下滚动数组中 i 为什么要递减,图1 分别展示了两种扫描方式的区别, 箭头是本次扫描(n一定时)的方向

编程之美2.18 : 有一个无序、元素个数为n的正整数数组,要求:如何能把这个数组平均分成两个子数组,并使两个子数组之和最接近。_第1张图片

                                                                  图1 两种情况下扫描方式

状态转移方程(3维):        dp[n][i][j] = dp[n-1][i-1][j-v[n]] || dp[n-1][i-1][j]         j>=v[i] 时

                                                 =dp[n-1][i-1][j]                                j

当我们要计算dp[n][i][j]时需要 dp[n-1][i-1][j-v[n]] 和 dp[n-1][i-1][j]     由于 不知道 v[n]具体是多少,可以认为对所有j都有可能,所以求需要的数据是 图一中红色箭头和红色方框中的数据, 对于 i++的方式,本次扫描已经扫描过所需的数据,即:

此时   dp[n-1][i-1][j-v[n]] 此时 已经变成  dp[n][i-1][j-v[n]]  变成了一个完全背包的问题,与预期不符

对于 i-- 方式 ,此时红色框内的数据还没有被更新 仍然是dp[n-1][i-1][j-v[n]],

因此 当 i-- 时是01背包,而i++时是完全背包,同样的可以判断出 j的迭代方向对问题没有影响,下文中将j也反向(图一中箭头方向反向),但没有影响


进阶问题: 如何输出相应的两个数组? 网上的程序大多只输出它们的差值,如果要求输出分割方案怎么办,既然能够找到最优解那就一定能用同样的方案找到最优解决方案。

最开始的反应是令dp数组记录选取哪个数不就完了,dp是一个int数组,记录选取的元素n,其中dp[0][0]=0; 其他初始为-1

构建了一个数组bool index[n],表示第n个元素在哪个数组 

那么当dp[i][j]>=0时,即记录了选取元素的位置,那么就将这个元素放到数组1中,然后继续考察dp[i-1][j-v[n]],就可以把所有元素找到了,抱着这个想法于是就有了下面这个版本:注意:这是一个错误的版本

vector > divide_array_2(const vector &v)
{
    const int len =v.size();
    if(!len) return vector>();
    int sum=0;
    for(int i=0;i>1;
    int  h_len = (len>>1)+1;
    vector > dp(h_len+1,vector(h_sum+1,-1));
    dp[0][0]=0;

    for(int n=1;n<=len;n++)
       {
            for(int i=std::min(n,h_len);i>=1;i--)
                for(int j=h_sum;j>=1;j--)
            {
               if(j>=v[n-1] && dp[i-1][j-v[n-1]]>=0)
                     dp[i][j]= n;   //如果dp[i-1][j-v[n-1]]>=0 就可以选择第n个数
               else
                     dp[i][j]=dp[i][j];
            }
       }
    int res;
        for(res=h_sum;dp[h_len-1][res]<=0;res--); //同样要分奇偶来决定h_len的值
    int res_t=0;
    if(len&1)
        for(res_t=h_sum;dp[h_len][res_t]<=0;res_t--);
    vector index(len,0);    
    int a=res>res_t?res:res_t;
    int b=(res>res_t)?h_len-1:h_len;
    while(a!=0)
    {
        int pos =dp[b][a];
        index[pos-1]=1;
        b--;
        a-=v[pos-1];
    }
    vector a1,a2;
    for(int i=0;i > t={a1,a2};
      return t;
}

这个程序有时是对的,有时会崩溃,为什么呢? 我用单步调试找了一个小时终于发现了问题,还是以上面的1,1,10,1,1为例

考虑 当 n=5时,dp[3][3]的值,此时应考察dp[2][2](第5个元素值为1),dp[2][2]显然>=0;此时将dp[3][3]的值设置为n;即dp[3][3]=6;

继续计算,当迭代到 dp[2][2]时,(注意这个是n=6时的dp[2][2],上面那个是n=5时的dp[2][2]), 考察dp[1][1],因为dp[1][1]也>=0, 因此dp[2][2]也被赋值为6, 

 到这里发现了问题,即每次记录的n都是,能够凑成j时,最后一个1的位置(对于本题),这就导致最后一个1被重复选取;

继续迭代,计算dp[1][1]时,由于d[0][0]初始赋值为0 也满足要求,dp[1][1]也被记录成6,这个程序结束后只有index[6]被置为1,即前面的两个1都被忽略了,也就是说这个程序只能解决没有重复元素的问题,怎样解决这个问题呢?


首先找到出现这样的原因,为此我们使用三维的转移方程:

状态转移方程(3维):        dp[n][i][j]  =    n                                          j>=v[n] 且 dp[n-1][i][j-v[n]]>=0 时

                                                             =   dp[n-1][i-1][j-v[n]]                       其他

乍看下去好像与我们的程序一样啊?? 当dp[n-1][i][j-v[n]]>=0 时记录n,但这里我忽略了一个问题 :

使用滚动数组时,dp[i][j]会被覆盖的,  此时dp[i][j]会被更新为新的n

什么意思,还是那个例子1,1,10,1,1, 当我们开始计算 dp[1][1]时,由于dp[0][0]=0,因此dp[1][1]被赋值为1,这没问题,但是当n=2时 ,计算dp[1][1],由于dp[0][0]仍然>=0,因此这时dp[1][1]被更新成2,以此类推,在n=6时dp[1][1]会被更新为6,知道原因之后就好解决了,我们不让dp[i][j]被覆盖就好了,赋值语句上面程序的第18,19行 更改为

               if(j>=v[n-1] && dp[i-1][j-v[n-1]]>=0)
                     dp[i][j]= d[i][j]>0?d[i][j]:n;  

如果n-1时能够找到i个数和为j,那么,在n时保持原样,不进行覆盖。当 d[i][j]=-1时,即第n个数非选不可的时候,将d[i][j]赋值为n,这样就解决了问题。


  再写博客时发现还有一种解决方式: 不使用index数组,而直接将数值拷贝子数组里,这样即使重复选取,也能得到正确的结果,但是求另一半数组时可能有点麻烦。


后记: 其实做这题之前就明确状态转移方程是什么,但还是花了很久时间,所以说动态规划的题不是知道怎么做就能做得出来的。程序还是得上手编,bug还是要靠单步找。

背包问题也许是一个简单的动态规划,但是要知道程序中每一个变动(i--/i++)造成什么样的影响,在发现问题之后能够找到原因以及解决办法还是蛮不容易的。




你可能感兴趣的:(编程之美2.18 : 有一个无序、元素个数为n的正整数数组,要求:如何能把这个数组平均分成两个子数组,并使两个子数组之和最接近。)