1. 介紹
動態規划典型的被用於優化遞歸算法,因為它們傾向於以指數的方式進行擴展。動態規划主要思想是將復雜問題(帶有許多遞歸調用)分解為更小的子問題,然后將它們保存到內存中,這樣我們就不必在每次使用它們時重新計算它們。
要理解動態規划的概念,我們需要熟悉一些主題:
- 什么是動態規划?
- 貪心算法
- 簡化的背包問題
- 傳統的背包問題
- Levenshtein Distance
- LCS-最長的共同子序列
- 利用動態規划的其他問題
- 結論
本文所有代碼均為java
代碼實現。
動態規划是一種編程原理,可以通過將非常復雜的問題划分為更小的子問題來解決。這個原則與遞歸很類似,但是與遞歸有一個關鍵點的不同,就是每個不同的子問題只能被解決一次。
為了理解動態規划,我們首先需要理解遞歸關系的問題。每個單獨的復雜問題可以被划分為很小的子問題,這表示我們可以在這些問題之間構造一個遞歸關系。
讓我們來看一個我們所熟悉的例子:斐波拉契數列,斐波拉契數列的定義具有以下的遞歸關系:
注意:遞歸關系是遞歸地定義下一項是先前項的函數的序列的等式。Fibonacci
序列就是一個很好的例子。
所以,如果我們想要找到斐波拉契數列序列中的第n個數,我們必須知道序列中第n個前面的兩個數字。
但是,每次我們想要計算Fibonacci
序列的不同元素時,我們在遞歸調用中都有一些重復調用,如下圖所示,我們計算Fibonacci(5)
:
例如:如果我們想計算F(5)
,明顯的我們需要計算F(3)
和F(4)
作為計算F(5)
的先決條件。然而,為了計算F(4)
,我們需要計算F(3)
和F(2)
,因此我們又需要計算F(2)
和F(1)
來得到F(3)
,其他的求解諸如此類。
這樣的話就會導致很多重復的計算,這些重復計算本質上是冗余的,並且明顯的減慢了算法的效率。為了解決這種問題,我們介紹動態規划。
在這種方法中,我們對解決方案進行建模,就像我們要遞歸地解決它一樣,但我們從頭開始解決它,記憶到達頂部采取的子問題(子步驟)的解決方案。
因此,對於Fibonacci
序列,我們首先求解並記憶F(1)
和F(2)
,然后使用兩個記憶步驟計算F(3)
,依此類推。這意味着序列中每個單獨元素的計算都是O(1)
,因為我們已經知道前兩個元素。
當使用動態規划解決問題的時候,我們一般會采用下面三個步驟:
- 確定適用於所述問題的遞歸關系
- 初始化內存、數組、矩陣的初始值
- 確保當我們進行遞歸調用(可以訪問子問題的答案)的時候它總是被提前解決。
遵循這些規則,讓我們來看一下使用動態規划的算法的例子:
下面來以這個為例子:
Given a rod of length n and an array that contains prices of all pieces of size smaller than n. Determine the maximum value obtainable by cutting up the rod and selling the pieces.
這個問題實際上是為動態規划量身定做的,但是因為這是我們的第一個真實例子,讓我們看看運行這些代碼會遇到多少問題:
public class naiveSolution {
static int getValue(int[] values, int length) {
if (length <= 0)
return 0;
int tmpMax = -1;
for (int i = 0; i < length; i++) {
tmpMax = Math.max(tmpMax, values[i] + getValue(values, length - i - 1));
}
return tmpMax;
}
public static void main(String[] args) {
int[] values = new int[]{3, 7, 1, 3, 9};
int rodLength = values.length;
System.out.println("Max rod value: " + getValue(values, rodLength));
}
}
輸出結果:
Max rod value: 17
該解決方案雖然正確,但效率非常低,遞歸調用的結果沒有保存,所以每次有重疊解決方案時,糟糕的代碼不得不去解決相同的子問題。
利用上面相同的基本原理,添加記憶化並排除遞歸調用,我們得到以下實現:
public class dpSolution {
static int getValue(int[] values, int rodLength) {
int[] subSolutions = new int[rodLength + 1];
for (int i = 1; i <= rodLength; i++) {
int tmpMax = -1;
for (int j = 0; j < i; j++)
tmpMax = Math.max(tmpMax, values[j] + subSolutions[i - j - 1]);
subSolutions[i] = tmpMax;
}
return subSolutions[rodLength];
}
public static void main(String[] args) {
int[] values = new int[]{3, 7, 1, 3, 9};
int rodLength = values.length;
System.out.println("Max rod value: " + getValue(values, rodLength));
}
}
輸出結果:
Max rod value: 17
正如我們所看到的的,輸出結果是一樣的,所不同的是時間和空間復雜度。
通過從頭開始解決子問題,我們消除了遞歸調用的需要,利用已解決給定問題的所有先前子問題的事實。
性能的提升
為了給出動態方法效率更高的觀點的證據,讓我們嘗試使用30個值來運行該算法。 一種算法需要大約5.2秒來執行,而動態解決方法需要大約0.000095秒來執行。
簡化的背包問題是一個優化問題,沒有一個解決方案。這個問題的問題是 - “解決方案是否存在?”:
Given a set of items, each with a weight w1, w2... determine the number of each item to put in a knapsack so that the total weight is less than or equal to a given limit K.
給定一組物品,每個物品的重量為w1,w2 …確定放入背包中的每個物品的數量,以使總重量小於或等於給定的極限K
首先讓我們把元素的所有權重存儲在W數組中。接下來,假設有n
個項目,我們將使用從1到n的數字枚舉它們,因此第i
個項目的權重為W [i]
。我們將形成(n + 1)x(K + 1)
維的矩陣M
。M [x] [y]
對應於背包問題的解決方案,但僅包括起始數組的前x
個項,並且最大容量為y
例如
假設我們有3個元素,權重分別是w1=2kg
,w2=3kg
,w3=4kg
。利用上面的方法,我們可以說M [1] [2]
是一個有效的解決方案。
這意味着我們正在嘗試用重量陣列中的第一個項目(w1
)填充容量為2kg的背包。
在M [3] [5]
中,我們嘗試使用重量陣列的前3項(w1,w2,w3)
填充容量為5kg的背包。
這不是一個有效的解決方案,因為我們過度擬合它。
當初始化矩陣的時候有兩點需要注意:
Does a solution exist for the given subproblem (M[x][y].exists) AND does the given solution include the latest item added to the array (M[x][y].includes).
給定子問題是否存在解(M [x] [y] .exists
)並且給定解包括添加到數組的最新項(M [x] [y] .includes
)。
因此,初始化矩陣是相當容易的,M[0][k].exists
總是false
,如果k>0
,因為我們沒有把任何物品放在帶有k容量的背包里。
另一方面,M[0][0].exists = true
,當k=0
的時候,背包應該是空的,因此我們在里面沒有放任何東西,這個是一個有效的解決方案。
此外,我們可以說M[k][0].exists = true
,但是對於每個k
來說 M[k][0].includes = false
。
注意:僅僅因為對於給定的M [x] [y]
存在解決方案,它並不一定意味着該特定組合是解決方案。
在M [10] [0]
的情況下,存在一種解決方案 - 不包括10個元素中的任何一個。
這就是M [10] [0] .exists = true
但M [10] [0] .includes = false
的原因。
接下來,讓我們使用以下偽代碼構造M [i] [k]
的遞歸關系:
if (M[i-1][k].exists == True):
M[i][k].exists = True
M[i][k].includes = False
elif (k-W[i]>=0):
if(M[i-1][k-W[i]].exists == true):
M[i][k].exists = True
M[i][k].includes = True
else:
M[i][k].exists = False
因此,解決方案的要點是將子問題分為兩種情況:
- 對於容量
k
,當存在第一個i-1
元素的解決方案 - 對於容量
k-W [i]
,當第一個i-1
元素存在解決方案
第一種情況是不言自明的,我們已經有了問題的解決方案。
第二種情況是指了解第一個i-1
元素的解決方案,但是容量只有一個第i個元素不滿,這意味着我們可以添加一個第i
個元素,並且我們有一個新的解決方案!
下面這何種實現方式,使得事情變得更加容易,我們創建了一個類Element
來存儲元素:
public class Element {
private boolean exists;
private boolean includes;
public Element(boolean exists, boolean includes) {
this.exists = exists;
this.includes = includes;
}
public Element(boolean exists) {
this.exists = exists;
this.includes = false;
}
public boolean isExists() {
return exists;
}
public void setExists(boolean exists) {
this.exists = exists;
}
public boolean isIncludes() {
return includes;
}
public void setIncludes(boolean includes) {
this.includes = includes;
}
}
接着,我們可以深入了解主要的類:
public class Knapsack {
public static void main(String[] args) {
Scanner scanner = new Scanner (System.in);
System.out.println("Insert knapsack capacity:");
int k = scanner.nextInt();
System.out.println("Insert number of items:");
int n = scanner.nextInt();
System.out.println("Insert weights: ");
int[] weights = new int[n + 1];
for (int i = 1; i <= n; i++) {
weights[i] = scanner.nextInt();
}
Element[][] elementMatrix = new Element[n + 1][k + 1];
elementMatrix[0][0] = new Element(true);
for (int i = 1; i <= k; i++) {
elementMatrix[0][i] = new Element(false);
}
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= k; j++) {
elementMatrix[i][j] = new Element(false);
if (elementMatrix[i - 1][j].isExists()) {
elementMatrix[i][j].setExists(true);
elementMatrix[i][j].setIncludes(false);
} else if (j >= weights[i]) {
if (elementMatrix[i - 1][j - weights[i]].isExists()) {
elementMatrix[i][j].setExists(true);
elementMatrix[i][j].setIncludes(true);
}
}
}
}
System.out.println(elementMatrix[n][k].isExists());
}
}
唯一剩下的就是解決方案的重建,在上面的類中,我們知道解決方案是存在的,但是我們不知道它是什么。
為了重建,我們使用下面的代碼:
List<Integer> solution = new ArrayList<>(n);
if (elementMatrix[n][k].isExists()) {
int i = n;
int j = k;
while (j > 0 && i > 0) {
if (elementMatrix[i][j].isIncludes()) {
solution.add(i);
j = j - weights[i];
}
i = i - 1;
}
}
System.out.println("The elements with the following indexes are in the solution:\n" + (solution.toString()));
輸出:
Insert knapsack capacity:
12
Insert number of items:
5
Insert weights:
9 7 4 10 3
true
The elements with the following indexes are in the solution:
[5, 1]
背包問題的一個簡單變化是在沒有價值優化的情況下填充背包,但現在每個單獨項目的數量無限。
通過對現有代碼進行簡單調整,可以解決這種變化:
// Old code for simplified knapsack problem
else if (j >= weights[i]) {
if (elementMatrix[i - 1][j - weights[i]].isExists()) {
elementMatrix[i][j].setExists(true);
elementMatrix[i][j].setIncludes(true);
}
}
// New code, note that we're searching for a solution in the same
// row (i-th row), which means we're looking for a solution that
// already has some number of i-th elements (including 0) in it's solution
else if (j >= weights[i]) {
if (elementMatrix[i][j - weights[i]].isExists()) {
elementMatrix[i][j].setExists(true);
elementMatrix[i][j].setIncludes(true);
}
}
利用以前的兩種變體,現在讓我們來看看傳統的背包問題,看看它與簡化版本的不同之處:
Given a set of items, each with a weight w1, w2... and a value v1, v2... determine the number of each item to include in a collection so that the total weight is less than or equal to a given limit k and the total value is as large as possible.
在簡化版中,每個解決方案都同樣出色。但是,現在我們有一個找到最佳解決方案的標准(也就是可能的最大值)。請記住,這次我們每個項目都有無限數量,因此項目可以在解決方案中多次出現。
在實現中,我們將使用舊的類Element
,其中添加了私有字段value
,用於存儲給定子問題的最大可能值:
public class Element {
private boolean exists;
private boolean includes;
private int value;
// appropriate constructors, getters and setters
}
實現非常相似,唯一的區別是現在我們必須根據結果值選擇最佳解決方案:
public static void main(String[] args) {
// Same code as before with the addition of the values[] array
System.out.println("Insert values: ");
int[] values = new int[n + 1];
for (int i=1; i <= n; i++) {
values[i] = scanner.nextInt();
}
Element[][] elementMatrix = new Element[n + 1][k + 1];
// A matrix that indicates how many newest objects are used
// in the optimal solution.
// Example: contains[5][10] indicates how many objects with
// the weight of W[5] are contained in the optimal solution
// for a knapsack of capacity K=10
int[][] contains = new int[n + 1][k + 1];
elementMatrix[0][0] = new Element(0);
for (int i = 1; i <= n; i++) {
elementMatrix[i][0] = new Element(0);
contains[i][0] = 0;
}
for (int i = 1; i <= k; i++) {
elementMatrix[0][i] = new Element(0);
contains[0][i] = 0;
}
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= k; j++) {
elementMatrix[i][j] = new Element(elementMatrix[i - 1][j].getValue());
contains[i][j] = 0;
elementMatrix[i][j].setIncludes(false);
elementMatrix[i][j].setValue(M[i - 1][j].getValue());
if (j >= weights[i]) {
if ((elementMatrix[i][j - weights[i]].getValue() > 0 || j == weights[i])) {
if (elementMatrix[i][j - weights[i]].getValue() + values[i] > M[i][j].getValue()) {
elementMatrix[i][j].setIncludes(true);
elementMatrix[i][j].setValue(M[i][j - weights[i]].getValue() + values[i]);
contains[i][j] = contains[i][j - weights[i]] + 1;
}
}
}
System.out.print(elementMatrix[i][j].getValue() + "/" + contains[i][j] + " ");
}
System.out.println();
}
System.out.println("Value: " + elementMatrix[n][k].getValue());
}
輸出:
Insert knapsack capacity:
12
Insert number of items:
5
Insert weights:
9 7 4 10 3
Insert values:
1 2 3 4 5
0/0 0/0 0/0 0/0 0/0 0/0 0/0 0/0 0/0 1/1 0/0 0/0 0/0
0/0 0/0 0/0 0/0 0/0 0/0 0/0 2/1 0/0 1/0 0/0 0/0 0/0
0/0 0/0 0/0 0/0 3/1 0/0 0/0 2/0 6/2 1/0 0/0 5/1 9/3
0/0 0/0 0/0 0/0 3/0 0/0 0/0 2/0 6/0 1/0 4/1 5/0 9/0
0/0 0/0 0/0 5/1 3/0 0/0 10/2 8/1 6/0 15/3 13/2 11/1 20/4
Value: 20
另一個使用動態規划的非常好的例子是Edit Distance
或Levenshtein Distance
。
Levenshtein Distance
就是兩個字符串A
,B
,我們需要使用原子操作將A
轉換為B
:
- 字符串刪除
- 字符串插入
- 字符替換(從技術上講,它不止一個操作,但為了簡單起見,我們稱之為原子操作)
這個問題是通過有條理地解決起始字符串的子串的問題來處理的,逐漸增加子字符串的大小,直到它們等於起始字符串。
我們用於此問題的遞歸關系如下:
如果a == b
則c(a,b)
為0,如果a = = b
則c(a,b)
為1。
實現:
public class editDistance {
public static void main(String[] args) {
String s1, s2;
Scanner scanner = new Scanner(System.in);
System.out.println("Insert first string:");
s1 = scanner.next();
System.out.println("Insert second string:");
s2 = scanner.next();
int n, m;
n = s1.length();
m = s2.length();
// Matrix of substring edit distances
// example: distance[a][b] is the edit distance
// of the first a letters of s1 and b letters of s2
int[][] distance = new int[n + 1][m + 1];
// Matrix initialization:
// If we want to turn any string into an empty string
// the fastest way no doubt is to just delete
// every letter individually.
// The same principle applies if we have to turn an empty string
// into a non empty string, we just add appropriate letters
// until the strings are equal.
for (int i = 0; i <= n; i++) {
distance[i][0] = i;
}
for (int j = 0; j <= n; j++) {
distance[0][j] = j;
}
// Variables for storing potential values of current edit distance
int e1, e2, e3, min;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
e1 = distance[i - 1][j] + 1;
e2 = distance[i][j - 1] + 1;
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
e3 = distance[i - 1][j - 1];
} else {
e3 = distance[i - 1][j - 1] + 1;
}
min = Math.min(e1, e2);
min = Math.min(min, e3);
distance[i][j] = min;
}
}
System.out.println("Edit distance of s1 and s2 is: " + distance[n][m]);
}
}
輸出:
Insert first string:
man
Insert second string:
machine
Edit distance of s1 and s2 is: 3
如果你想了解更多關於Levenshtein Distance
的解決方案,我們在另外的一篇文章中用python
實現了 Levenshtein Distance and Text Similarity in Python,
使用這個邏輯,我們可以將許多字符串比較算法歸結為簡單的遞歸關系,它使用Levenshtein Distance
的基本公式
這個問題描述如下:
Given two sequences, find the length of the longest subsequence present in both of them. A subsequence is a sequence that appears in the same relative order, but not necessarily contiguous.
給定兩個序列,找到兩個序列中存在的最長子序列的長度。子序列是以相同的相對順序出現的序列,但不一定是連續的.
闡明:
如果我們有兩個字符串s1="MICE"
和s2="MINCE"
,最長的共同子序列是MI
或者CE
。但是,最長的公共子序列將是“MICE”,因為結果子序列的元素不必是連續的順序。
遞歸關系與一般邏輯:
我們可以看到,Levenshtein distance
和LCS
之間只有微小的差別,特別是移動成本。
在LCS
中,我們沒有字符插入和字符刪除的成本,這意味着我們只計算字符替換(對角線移動)的成本,如果兩個當前字符串字符a [i]
和b [j]
是相同的,則成本為1。
LCS
的最終成本是2個字符串的最長子序列的長度,這正是我們所需要的。
Using this logic, we can boil down a lot of string comparison algorithms to simple recurrence relations which utilize the base formula of the Levenshtein distance
使用這個邏輯,我們可以將許多字符串比較算法歸結為簡單的遞歸關系,它使用Levenshtein distance
的基本公式。
實現:
public class LCS {
public static void main(String[] args) {
String s1 = new String("Hillfinger");
String s2 = new String("Hilfiger");
int n = s1.length();
int m = s2.length();
int[][] solutionMatrix = new int[n+1][m+1];
for (int i = 0; i < n; i++) {
solutionMatrix[i][0] = 0;
}
for (int i = 0; i < m; i++) {
solutionMatrix[0][i] = 0;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
int max1, max2, max3;
max1 = solutionMatrix[i - 1][j];
max2 = solutionMatrix[i][j - 1];
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
max3 = solutionMatrix[i - 1][j - 1] + 1;
} else {
max3 = solutionMatrix[i - 1][j - 1];
}
int tmp = Math.max(max1, max2);
solutionMatrix[i][j] = Math.max(tmp, max3);
}
}
System.out.println("Length of longest continuous subsequence: " + solutionMatrix[n][m]);
}
}
輸出:
Length of longest continuous subsequence: 8
利用動態規划可以解決很多問題,下面列舉了一些:
- 分區問題:給定一組整數,找出它是否可以分成兩個具有相等和的子集
- 子集和問題:給你一個正整數的數組及元素還有一個合計值,是否在數組中存在一個子集的的元素之和等於合計值。
- 硬幣變化問題:鑒於給定面額的硬幣無限供應,找到獲得所需變化的不同方式的總數
- k變量線性方程的所有可能的解:給定k個變量的線性方程,計算它的可能解的總數
- 找到醉漢不會從懸崖上掉下來的概率:給定一個線性空間代表距離懸崖的距離,讓你知道酒鬼從懸崖起始的距離,以及他向懸崖p前進並遠離懸崖1-p的傾向,計算出他的生存概率
動態編程是一種工具,可以節省大量的計算時間,以換取更大的空間復雜性,這在很大程度上取決於您正在處理的系統類型,如果CPU時間很寶貴,您選擇耗費內存的解決方案,另一方面,如果您的內存有限,則選擇更耗時的解決方案。
作者: Vladimir Batoćanin
譯者:lee