你們好,最近因爲剛剛入職要作的事情不少,疏於更新一段時間,從今天開始,我會慢慢恢復更新,與你們分享一些算法方面的經驗。java
很久沒說動態規劃了,通過上次的分析,你們應該已經對動態規劃有了個大致的認識,今天咱們一塊兒來看一個經典的問題--0/1揹包問題。可能有些同窗以爲揹包問題很簡單,無非寫個判斷條件,遞歸執行就能解決。可是想要拿到最優解,咱們仍然有許多須要細細思量的東西。c++
咱們先來看一下題目的定義:給定N種水果的重量跟收益,咱們須要把它們放進一個可容重量爲C的揹包裏,使得包裏的水果在總重量不超過C的同時擁有最高的收益,假設水果數量有限,一種只能選一個
。算法
題目很短,很容易理解,咱們再具體化一點,看一個例子。假設我如今要去賣水果,如今的狀況以下: 水果: { 蘋果, 橙子, 香蕉, 西瓜 }
重量: { 2, 3, 1, 4 }
收益: { 4, 5, 3, 7 }
揹包可容重量: 5
數組
先來試試不一樣的組合的結果: 蘋果 + 橙子 (總重量5) => 9
蘋果 + 香蕉 (總重量 3) => 7
橙子 + 香蕉 (總重量 4) => 8
香蕉 + 西瓜 (總重量 5) => 10
緩存
咱們能夠看到西瓜跟香蕉是絕配,在有限的重量限制下給咱們最大的收益。咱們來嘗試用算法把它描述出來。如我前面所說,最簡單的就是暴力遞歸,每次遇到一種水果,咱們只有兩個選擇,要麼在揹包還放得下它的時候把它放進去,要麼就直接不放它,這樣就能幫咱們列舉出全部的情形,而後咱們只取收益最大的那種。優化
private int knapsackRecursive(int[] profits, int[] weights, int capacity, int currentIndex) {
if (capacity <= 0 || currentIndex >= profits.length)
return 0;
// 在當前元素能夠被放進揹包的狀況下遞歸的處理剩餘元素
int profit1 = 0;
if( weights[currentIndex] <= capacity )
profit1 = profits[currentIndex] + knapsackRecursive(profits, weights,
capacity - weights[currentIndex], currentIndex + 1);
// 跳過當前元素處理剩餘元素
int profit2 = knapsackRecursive(profits, weights, capacity, currentIndex + 1);
return Math.max(profit1, profit2);
}
複製代碼
這樣的解法時間複雜度得在O(2^n),數據量稍微一大就會出現明顯的耗時。spa
咱們能夠畫一下遞歸調用的樹,因爲重量跟收益數組是一成不變的,對咱們的算法設計過程沒有影響,每次可能變化的只有剩餘可用重量跟表明當前處理到哪一個元素的索引,從這張遞歸調用樹更加能夠肯定暴力遞歸數據越大時更耗時,同時也揭露了有些場景被屢次重複計算。哈,這就輪到咱們緩存大法出場了!因爲只有重量跟索引在處理過程當中變化,那咱們能夠用一個二維數組來存儲已經計算的結果。這個過程沒必要詳述,直接上代碼:private int knapsackRecursive(Integer[][] dp, int[] profits, int[] weights, int capacity, int currentIndex) {
if (capacity <= 0 || currentIndex >= profits.length)
return 0;
// 若是已經算得結果,直接返回
if (dp[currentIndex][capacity] != null)
return dp[currentIndex][capacity];
// 在當前元素能夠被放進揹包的狀況下遞歸的處理剩餘元素
int profit1 = 0;
if (weights[currentIndex] <= capacity)
profit1 = profits[currentIndex] + knapsackRecursive(dp, profits, weights,
capacity - weights[currentIndex], currentIndex + 1);
// 跳過當前元素處理剩餘元素
int profit2 = knapsackRecursive(dp, profits, weights, capacity, currentIndex + 1);
dp[currentIndex][capacity] = Math.max(profit1, profit2);
return dp[currentIndex][capacity];
}
複製代碼
好啦,最終全部的結果都存儲在這個二維數組裏面,咱們能夠肯定咱們不會有超過NC個子問題,N是元素的數量,C是揹包可容重量,也就是說,到這兒咱們時間空間複雜度都只有O(NC)了。設計
事情到這裏尚未結束,咱們來嘗試用自下而上的方法來考慮這道題,來看看能不能得到更優解。本質上,咱們想在上面的遞歸過程當中,對於每個索引,每個剩餘的可容重量,咱們都想在這一步得到能夠的最大收益。處理第3個元素時,咱們想得到能拿到的最大收益。處理第4個元素時,咱們仍是想得到能夠拿到的最大收益。(畢竟獲取最大利潤是每一個人的目標嘛)dp[i][c]
就表明從最開始i=0時計算到當前i的最大收益。那每次咱們也只有兩種選擇:3d
dp[i-1][c]
。profit[i] + dp[i-1][c-weight[i]]
。最終咱們想要得到的最大收益就是這倆中的最大值。dp[i][c] = max (dp[i-1][c], profit[i] + dp[i-1][c-weight[i]])
。code
public int solveKnapsack(int[] profits, int[] weights, int capacity) {
if (capacity <= 0 || profits.length == 0 || weights.length != profits.length)
return 0;
int n = profits.length;
int[][] dp = new int[n][capacity + 1];
// 0空間就0收益
for(int i=0; i < n; i++)
dp[i][0] = 0;
// 在處理第一個元素時,只要它重量能夠被揹包容下,那確定放入比不放入收益高
for(int c=0; c <= capacity; c++) {
if(weights[0] <= c)
dp[0][c] = profits[0];
}
// 循環處理全部元素全部重量
for(int i=1; i < n; i++) {
for(int c=1; c <= capacity; c++) {
int profit1= 0, profit2 = 0;
// 包含當前元素
if(weights[i] <= c)
profit1 = profits[i] + dp[i-1][c-weights[i]];
// 不包含當前元素
profit2 = dp[i-1][c];
// 取最大值
dp[i][c] = Math.max(profit1, profit2);
}
}
// dp的最後一個元素就是最大值
return dp[n-1][capacity];
}
複製代碼
這樣時間空間複雜度也都在O(N*C)。
那怎麼找到選擇的元素呢?其實很簡單,咱們以前說過,不選中當前元素的話,當前的最大收益就是處理前一個元素時的最大收益,換言之,只要在dp裏的上下倆元素相同的,那那個索引所表明的元素確定沒被選中,dp裏第一個不一樣的總收益所在的位置就是選中的元素所在的位置。
private void printSelectedElements(int dp[][], int[] weights, int[] profits, int capacity) {
System.out.print("Selected weights:");
int totalProfit = dp[weights.length - 1][capacity];
for (int i = weights.length - 1; i > 0; i--) {
if (totalProfit != dp[i - 1][capacity]) {
System.out.print(" " + weights[i]);
capacity -= weights[i];
totalProfit -= profits[i];
}
}
if (totalProfit != 0)
System.out.print(" " + weights[0]);
System.out.println("");
}
複製代碼
這個算法夠簡單吧?但我以爲仍是不能就這麼結束了,咱們大費周章地換了一種思路來解題,取得一樣的複雜度就結束了嗎,咱們再來觀察下咱們這個算法。咱們發現咱們在處理當前元素時,咱們須要的僅僅是在前一個元素時各個索引最大的收益,再往前的數據咱們根本不關心,那這就是一個優化的點,咱們能夠把dp的size大幅縮減。
static int solveKnapsack(int[] profits, int[] weights, int capacity) {
if (capacity <= 0 || profits.length == 0 || weights.length != profits.length)
return 0;
int n = profits.length;
// 咱們只須要前面一次的結果來得到最優解,所以咱們能夠把數組縮減成兩行
// 咱們用 `i%2` 代替`i` 跟 `(i-1)%2` 代替`i-1`
int[][] dp = new int[2][capacity+1];
// 在處理第一個元素時,只要它重量能夠被揹包容下,那確定放入比不放入收益高
for(int c=0; c <= capacity; c++) {
if(weights[0] <= c)
dp[0][c] = dp[1][c] = profits[0];
}
// 循環處理全部元素全部重量
for(int i=1; i < n; i++) {
for(int c=0; c <= capacity; c++) {
int profit1= 0, profit2 = 0;
// 包含當前元素
if(weights[i] <= c)
profit1 = profits[i] + dp[(i-1)%2][c-weights[i]];
// 不包含當前元素
profit2 = dp[(i-1)%2][c];
// 取最大值
dp[i%2][c] = Math.max(profit1, profit2);
}
}
return dp[(n-1)%2][capacity];
}
複製代碼
這時候空間複雜度就只剩下O(N)了,嘿嘿,這是比較讓人滿意的結果了。不過要是同窗們再喪心病狂一點,再變態一點,再觀察一下咱們的算法,能夠發現其實咱們只須要前面一次結果中的兩個值dp[c]
跟 dp[c-weight[i]]
。那咱們可不能夠把結果都放在一個一維數組裏面,來看看:
dp[c-weight[i]]
的時候,若是weight[i]>0,那麼dp[c-weight[i]]
是有可能已經被覆蓋掉了。這並非什麼難題,只要咱們改變處理順序就行了:c:capacity-->0
。從後往前處理,就能保證咱們在修改dp裏面任何值得時候,這個被修改的值都用不到了,你們想一想,是否是這麼個道理。 思路想明白了,那手寫代碼就很簡單了:
static int solveKnapsack(int[] profits, int[] weights, int capacity) {
if (capacity == 0 || profits.length == 0 || weights.length != profits.length) {
return 0;
}
int n = profits.length;
int[] dp = new int[capacity + 1];
for (int i = 1; i <= capacity; i++) {
if (weights[0] <= i) {
dp[i] = profits[0];
}
}
for (int j = 1; j < n; j++) {
for (int c = capacity; c >= 0; c--) {
int profit1 = 0;
if (weights[j] <= c) {
profit1 = profits[j] + dp[c - weights[j]];
}
int profit2 = dp[c];
dp[c] = Math.max(profit1, profit2);
}
}
return dp[capacity];
}
複製代碼
如今咱們的算法能夠說是最優咯!最後你們再來好好地總結下,其實動態規劃就是想辦法減小沒必要要的內存消耗,跟複用以前問題的結果來解決如今的問題以用最少的時間解決問題。思路就是這麼簡單,可是關於內存優化,這就得靠經驗的積累了,你們多加練習作手熟了就好啦。