1. 程式人生 > >【動態規劃】從子集和問題到揹包問題

【動態規劃】從子集和問題到揹包問題

一、問題定義

有一個包含n個元素{e1, e2, …, en}的集合S,每個元素ei都有一個對應的權值wi。現在有一個界限W,我們希望從S中選擇出部分元素,使得這些元素的權值之和在不超過W的情況下達到最大,這個便是子集合問題(事實上還有其他型別的子集和問題,本文暫不討論)。舉個更具體一點的例子,某農民今年收成小番茄總重量為W萬斤,有n個採購商想要向這位農民收購小番茄,他們想要採購的數目都有所不同,採購商i想要收購wi萬斤小番茄(1 <= i <= n)。現在農民需要從採購商中挑選部分買家,將小番茄賣給他們,使得自己被收購的小番茄數目達到最大,從而賺取最大的收入(假設所有采購商給出的單位收購價都是一樣的;所有采購商的收購量總和超過農民的收成量,即農民無法滿足所有采購商;收購商i想要收購wi萬斤小番茄,他不會只收購一半或者更多)。

二、解決思路

根據動態規劃的思路,我們來分析一下這個問題的子問題。假設農民已經從前面n-1個收購商中選出了一組最優組合使得收購量最大,現在他要考慮是否要賣給最後一個收購商n。假如賣給收購商n,那麼他能夠賣給前面n-1個收購商的番茄就只有(W-wn)萬斤。如果他不賣給收購商n,那麼他能夠賣給前面n-1個收購商的番茄就是W萬斤了。於是,假設在只有(W-wn)萬斤的情況下,從前面n-1個收購商中選出最優組合所收購的總重量加上賣給收購商n的重量為(O1+wn)。若W萬斤都賣給前面n-1個收購商,他們之中選出的最優組合所收購的總重量為O2。農民需要考慮的問題就變成了比較(O1+wn)和O2的大小了。

我們更形式化一點地進行描述,假設O(i, w)表示將w萬斤小番茄提供給收購商{1, 2, …, i}收購的時候,從這i個收購商中選出最優組合所收購的總重量。那麼O1 = O(n-1, W-wn),O2 = (n-1, W)。當農民考慮收購商n的時候,他需要判定O1和O2的大小。另外一種特殊情況,當收購商收購的數量wn超過農民擁有的所有小番茄的時候,即W < wn,那麼農民自然只能考慮前面n-1個收購商中的最優組合了。

更進一步考慮,當我們考慮O(i, w)的時候,如果w能夠容納wi,那麼我們需要考慮O(i-1, w)和(O(i-1, w-wi) + wi)的大小。如果w無法容納wi,即w < wi,那麼無需考慮i,O(i, w) = O(i-1, w)。因此,我們可以得到遞推公式如下所示:

if w < wi, O(i, w) = O(i-1, w)
else O(i, w) = max(O(i-1, w), wi + O(i-1, w-wi))

得到了遞推公式,我們自然就可以得到一個演算法來算出最優解。演算法的虛擬碼如下所示:

陣列O[0...n, 0...W]
for w = 0, 1, ..., W
    初始化O[0, w] = 0
endFor
for i = 1, 2, ..., n
    for w = 0, ..., W
        if w < wi
            O[i, w] = O[i-1, w]
        else
            O[i, w] = max(O[i-1, w], wi + O[i-1, w-wi])
    endFor
endFor
O[n, W]即為最優解

演算法實際上是實現了一個填表的過程,填了一張n*W的二維表格。整個演算法的時間複雜度為O(nW),顯而易見,當W的值變得很大的時候,這個演算法的效率堪憂。

這裡寫圖片描述

現在暫時拋開效率問題,我們發現,上面給出的演算法只能算出最優解的值,但是並沒有給出所選擇的子集合。即農民用這個演算法之後只知道他最多能賣多少小番茄,但還是不知道要賣給哪些收購商。為了解決這個問題,我們只需要反向搜尋一下陣列O即可在O(n)的時間內找出最優解的元素組合情況。反向搜尋的時候,如果O(i, w)等於O(i-1, w),說明i沒有被選擇,繼續對前面i-1個元素考慮重量為w時候的情況。如果O(i, w)不等於O(i-1, w)的話,說明選擇了i,然後接著就應該繼續對前面i-1個元素考慮重量為(w-wi)時候的情況,具體的虛擬碼如下所示:

初始化i = n, w = W
while i != 0 do
    if O[i, w] == O[i-1, w]
        i = i-1
    else
        print i
        i = i-1
        w = w-wi
    endIf
endWhile

三、程式碼例項

下面給出一個簡單的C++程式碼實現,程式碼後面還附帶有簡單的示例。

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

const int MAX_NUM = 100;
const int MAX_WEIGHT = 1000;

class SubSetProblem {
public:
  void init(int n, int W) {
    this->n = n;
    this->W = W;
    memset(optional, 0, sizeof(optional));
    for (int i = 1; i <= n; ++i) {
      cin >> weight[i];
    }
  }
  void process() {
    for (int i = 1; i <= n; ++i) {
      for (int w = 0; w <= W; ++w) {
        if (w < weight[i]) {
          optional[i][w] = optional[i-1][w];
        } else if (optional[i-1][w] < weight[i] + optional[i-1][w-weight[i]]) {
          optional[i][w] = weight[i] + optional[i-1][w-weight[i]];
        } else {
          optional[i][w] = optional[i-1][w];
        }
      }
    }
  }
  void result() {
    cout << "[Select]";
    for (int i = n, w = W; i != 0; --i) {
      if (optional[i][w] == optional[i-1][w]) {
        continue;
      } else {
        cout << ' ' << i;
        w -= weight[i];
      }
    }
    cout << "\n[Optional] " << optional[n][W] << endl;
  }
private:
  int n, W;
  int weight[MAX_NUM + 1];
  int optional[MAX_NUM + 1][MAX_WEIGHT + 1];
};

int main() {
  SubSetProblem ssp;
  int n, W;
  cin >> n >> W;
  if (n > MAX_NUM || W > MAX_WEIGHT) {
    cout << "error" << endl;
    return -1;
  }
  ssp.init(n, W);
  ssp.process();
  ssp.result();
  return 0;
}

輸入輸出示例:

3 6
2 2 3
[Select] 3 1
[Optional] 5

四、揹包問題

把上面的子集和問題拓展一下,就變成了我們常見的揹包問題了。問題定義:有一個包含n個元素{e1, e2, …, en}的集合S,每個元素ei都有一個對應的權值wi和一個對應的價值vi。現在有一個界限W,我們希望從S中選擇出部分元素,使得這些元素的權值之和在不超過W的情況下,所有元素的價值總和達到最大。繼續拿上面的農民買小番茄的例子來講,假設現在每個收購商的出價都有所不同,收購商i打算收購wi萬斤小番茄,出價vi。農民只有W萬斤能夠提供給收購商,他希望合理選擇收購商,使得賣小番茄的收益達到最大。

這個問題看起來跟前面的子集和問題很像,解決思路也是基本一樣。同樣地,農民在考慮收購商n的時候,他需要考慮兩個子問題。第一,如果把W萬斤小番茄全部拿來提供給前面的n-1個收購商,他能拿到的最大收益為O(n-1, W)。第二,如果先出售部分小番茄給收購商n,剩下的再提供給前面n-1個收購商,他能拿到的最大收益為(O(n-1, W-wn) + vn)。於是,只要O(n-1, W)的價值更大,農民自然不會考慮收購商n,否則他肯定要先出售部分小番茄給收購商n。最後,考慮特殊情況,當收購商n的收購量超過農民所能提供的小番茄時,農民也就只能將W萬斤小番茄提供給前面n-1個收購商了。

假設O(i, w)表示農民將w萬斤小番茄提供給前i個收購商所能賺取的最大收益,按照之前講解子集和問題的思路,我們可以得到遞推公式如下:

if w < wi, O(i, w) = O(i-1, w)
else O(i, w) = max(O(i-1, w), vi + O(i-1, w-wi))

可以看到,公式幾乎跟之前的子集和問題一樣。只不過,子集和問題中我們考慮的是重量w,而這裡我們考慮的是價值v。兩者本質上是同樣的,只不過判定的標準變了而已。解決這種型別的揹包問題的演算法跟之前的一樣,只需要將wi換成vi即可,最後反向搜尋求解最優解的元素組合情況的做法也是與之前一樣,程式碼相似度較高,所以下面我就不重複貼程式碼了。這個演算法的時間複雜度也是O(nW),反向搜尋的時間複雜度為O(n)。演算法缺陷也是跟之前一樣,當W的值非常大的時候,演算法效率低下。