KiRorY
算法分析笔记:动态规划

算法分析笔记:动态规划

动态规划(Dynamic Programming)

1.基本算法思想

同样是由大化小解决,但针对的是分解子问题为非独立的情况。

动态规划算法往往用于解决解的最优化问题。其分解的每一级子结构之间往往有重叠,也就是每次产生的子问题并不完全是全新的问题,问题中可能包含有上一级问题的解。为了提高运算效率,在使用该算法时,一般需要使用表格来记录子问题的解,以方便在向上合并时使用它们。通常来说,其可以按照以下步骤进行设计:

  1. 找出最优解性质,并刻画其结构特征;
  2. 递归定义最优值;
  3. 以自底向上的方式计算出最优值;
  4. 根据计算最优值过程中得到的信息,构造最优解。

动态规划算法的基本要素:

  1. 最优子结构:如果待解决问题的最优解包含了其子问题的最优解,则称该问题具有最优子结构性质。
  2. 重叠子问题:在使用动态规划算法时,每次产生的子问题并不完全是全新的问题,问题中可能包含有上一级问题的解。通过上面所提到的表格记录方法解决,一般可以在多项式时间内完成。
  3. 备忘录方法:这是动态规划算法的一种变形。同样使用表格,备忘录方法使用自顶向下的计算方式,其为每个子问题建立记录项,通过检查记录项确定子问题是否已被计算,如果已经计算则直接使用记录项中的值,否则进行计算并记录。

2.算法应用问题

2.1 矩阵连乘(Matrix Chain Multiplication)

2.1.1 问题描述

给定个矩阵,其中是可乘的()。求这个矩阵的连乘积

矩阵的乘法是满足结合律的,因此可以修改其运算顺序。我们使用加括号的方式来确定矩阵连乘积的运算顺序。

例如,矩阵连乘积,可以有以下5种运算顺序: 对于不同的运算顺序,由矩阵的乘法运算特性可知,其运算次数其实是不同的。使用分别表示矩阵那么计算它们矩阵乘积的标准算法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void matrix_multiply(int **a, int **b, int ra, int ca, int rb, int cb)  //a,b表示表示矩阵
{
if(ca != cb)
{
error("Unmultiplyable matrix!");
}
for(int i = 0; i < ra; i++)
{
for(int j = 0; j < cb; j++)
{
int sum = a[i][0]*b[0][j];
for(int k = 1; k < ca; k++ )
{
sum += a[i][k]*b[k][j];
}
c[i][j] = sum;
}
}
}

2.1.2 算法设计

对于矩阵连乘积,将其记为。那么对于一个加括号方式,其运算次数为的运算次数之积加上它们的运算次数之和。

计算最优值:

设计算所需最少数乘次数为,那么原问题最优解就是的值。对于最优计算顺序是在处断开的情况,可以递归定义为:

其中表示矩阵的列数,且由矩阵乘积结果的性质可以知道:表示矩阵的行数。

对于每一个,都有对应的m[i][j]。我们建立另一个表用于存储的值,这样在获得最优值后,可以递归地由构造出相应的最优解。我们使用递归公式计算矩阵,下面先给出基本代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void matrix_chain(int *p, int n, int **m, int **s)
{
for(int i = 1; i <= n; i++)
{
m[i][i] = 0;
}

for(int r = 2; r <= n; r++)
{
for(int i = 1; i <= n-r+1; i++)
{
int j = i+r-1;
m[i][j] = m[i+1][j]+p[i-1]*p[i]*p[j];
s[i][j] = i;
for(int k = i+1; k < j; k++)
{
int t = m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
if(t < m[i][j])
{
m[i][j] = t;
s[i][j] = k;
}
}
}
}
}

这里给出《计算机算法设计与分析》一书上的样例方便理解。假设需要计算矩阵连乘积。其维数如下:

矩阵 A1 A2 A3 A4 A5 A6
维数 30x35 35x15 15x5 5x10 10x20 20x25

那么其计算顺序以及对应的矩阵如下:

例如在计算时,递归式如下:

计算过程中我们可以知道在时取到较小值,因此。对矩阵计算完成后,即可获得最优值

获得最优解:

上面的方法我们仅计算了最少的运算次数,但是还没有得到具体的运算顺序。实际上矩阵所获得的数据就是对应每个计算的顺序信息。

例如记录的信息可以知道计算的最优方式是:。而内部的最优加括号方式又是:。由此递推,最终确定的最优计算顺序。以下是该递推的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
void Trace_back_matrix(int i, int j, int **s)
{
if (i == j)
{
return;
}

Trace_back_matrix(i, s[i][j], s);
Trace_back_matrix(s[i][j]+1, j, s);
cout << "Multiply A" << i <<"," <<s[i][j];
cout << " and A" << s[i][j]+1 << "," << j << endl;
}
通过调用 Trace_back_matrix(1, n, s) 即可获得最优计算顺序。

2.2 最长公共子序列问题(Longest Common Subsequence)

2.2.1 问题描述

给定两个序列,求的最长公共子序列。

对于这类问题,我们很明显可以知道其具有最优子结构性质。设序列的最长公子序列为,则:

  1. ,则,且的最长公共子序列;
  2. ,且,则的最长公子序列;
  3. ,且,则的最长公子序列。

简单理解上述规则,如果在的情况下存在长度大于的公共子序列,则的长度必然大于也就成为了的一个长度大于的公共子序列。这很显然和的最长公子序列矛盾,因此的最长公子序列。同理可证其他情况。

2.2.2 算法设计

在上面提到的三条规则中,可以知道这个问题带有最优子结构性质和子问题重叠性质。其递归方式如下: 1. 如果时,开始找的最长公共子序列,然后将加入到该子序列尾部; 2. 如果,那就要分别找的一个最长公子序列,以及的一个最长公子序列,然后比较两个最长公子序列的长度,取较长的一个即为的最长公子序列。

我们建立表记录公子序列长度。假如要判断序列;。当时,空序列就是它们的最长公子序列,此时。上面总结的递归关系式如下:

使用表来记录对应的值是由哪个子问题得到的。我们根据递归关系式获得如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void LCS_length(int m, int n, char *x, char *y, int **c, int **b)
{
int i,j;
//初始化
for(i = 1; i <= m; i++)
{
c[i][0] = 0;
}
for(i = 1; i <= n; i++)
{
c[0][i] = 0;
}

//构造c和b表
for(i = 1; i <= m; i++)
{
for(j = 1; j <= n; j++)
{
if(x[i] == y[j])
{
c[i][j] = c[i-1][j-1]+1;
b[i][j] = 1;
}
else if(c[i-1][j] >= c[i][j-1])
{
c[i][j] = c[i-1][j];
b[i][j] = 2;
}
else
{
c[i][j] = c[i][j-1];
b[i][j] = 3;
}
}
}

就是最长公子序列的长度。其函数复杂度为,然后我们根据表递归构造最长公子序列,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void LCS(int i, int j, char *x, int **b)
{
if(i == 0 || j == 0)
{
return
}

if(b[i][j] == 1)
{
LCS(i-1, j-1, x, b);
cout << x[i];
}
else if(b[i][j] == 2)
{
LCS(i-1, j, x, b);
}
else
{
LCS(i, j-1, x, b);
}
}

该算法的时间复杂度为

实际上,算法中可以省略数据b,因为在表中,元素c[i][j]往往仅由三个元素决定。因此即使不借助,我们依旧可以知道某个元素是由上面提到的三个元素的哪一个确定的。

2.3 最大字段和(Maximum Subarray Sum)

2.3.1 问题描述

给定由个整数组成的序列,求形如的字段和最大值。

在这个问题中,整数序列允许出现负数,因此如果序列所有整数均为负数时,定义其最大子段和为。例如,当时,最大子段和为

2.3.2 分治算法解决方法

对于这个问题,我们可以使用分治算法来解决。我们将序列分成长度相等的两段并分别求出它们的最大字段和。其有三种情况:

  1. 的最大字段和与的最大字段和相同;
  2. 的最大字段和与的最大字段和相同;
  3. 的最大字段和为,且

对于1、2两种情况,直接进行递归即可。而对于3,我们知道分割点中间两个数一定在最优的序列之中,因此我们可以从分割点向两边扩展,直到序列的两端。这样就可以得到最大字段和。即在中计算出,在中计算出,然后就是的最大字段和。

这样的算法复杂度为。下面给出代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int Max_sub_sum(int *a, int left, int right)
{
int sum = 0;
if (left == right)
{
sum = a[left] > 0 ? a[left]:0;
}
else
{
int center = (left+right)/2;
int left_sum = Max_sub_sum(a, left, center);
int right_sum = Max_sub_sum(a, center+1, right);
int s1 = 0;
int lefts = 0;
for(int i = center; i >= left; i--)
{
lefts += a[i];
if(lefts > s1)
{
s1 = lefts;
}
}
int s2 = 0;
int rights = 0;
for(int i = center+1; i <= right; i++)
{
rights += a[i];
if(rights > s2)
{
s2 = rights;
}
}
sum = s1+s2;
if(sum < left_sum)
{
sum = left_sum;
}
if(sum < right_sum)
{
sum = right_sum;
}
}
return sum;
}

int Max_sum(int *a, int n)
{
return Max_sub_sum(a, 1, n);
}

2.3.3 动态规划解决方法

如果,那么所求的最大字段和就是

由此可知,当时,,否则。可以得到其递归式:

该算法时间和空间复杂度都为,得到代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int Max_sum(int *a, int n)
{
int sum = 0;
int b = 0;
for(int i = 1; i <= n; i++)
{
if(b > 0)
{
b += a[i];
}
else
{
b = a[i];
}
if(b > sum)
{
sum = b;
}
}
return sum;
}

2.4 0-1背包(0-1 Knapsack)

2.4.1 问题描述

个物品和一个容量为的背包,物品的重量是,其价值为。求解将如何选择物品装入背包可使价值总和最大。

2.4.2 算法设计

是所给0-1背包问题的一个最优解,则是下面相应子问题的一个最优解:

很显然,该问题具有最有子结构性质。我们设0-1背包问题的最优值为,那么其递归式为:

边界条件如下:

我们使用二维表,基础代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
template <class Type>
void knapsack(int n, Type *w, Type *v, Type c, Type **m) //动态规划算法
{
int jMax = min(w[n]-1, c);
for(int j = 0; j <= jMax; j++)
{
m[n][j] = 0;
}
for(int j = w[n]; j <= c; j++)
{
m[n][j] = v[n];
}
for(int i = n-1; i > 1; i--)
{
jMax = min(w[i]-1, c);
for(int j = 0; j <= jMax; j++)
{
m[i][j] = m[i+1][j];
}
for(int j = w[i]; j <= c; j++)
{
m[i][j] = max(m[i+1][j], m[i+1][j-w[i]]+v[i]);
}
}
m[1][c] = m[2][c];
if(c >= w[1])
{
m[1][c] = max(m[1][c], m[2][c-w[1]]+v[1]);
}
}

template <class Type>
void traceback(Type **m, int w, int w, int c, int n, int x)
{
for(int i = 1; i < n; i++)
{
if(m[i][c] == m[i+1][c])
{
x[i] = 0;
}
else
{
x[i] = 1;
c -= w[i];
}
}
x[n] = (m[n][c]) ? 1:0;
}

上述代码中,构造出的m[1][c]给出所要求的问题最优值。很显然,算法Knapsack的时间复杂度为,而Traceback的时间复杂度为

Dynamic Programming

1. Basic Algorithm Idea

It also solves problems by breaking them down from large to small, but it addresses situations where decomposed subproblems are not independent.

Dynamic programming algorithms are often used to solve optimization problems. The substructures at each level of decomposition often overlap, meaning that the subproblems generated each time are not entirely new problems; they may contain solutions to previous level problems. To improve computational efficiency, when using this algorithm, it is generally necessary to use a table to record the solutions to subproblems, making them convenient to use when merging upwards. Generally, it can be designed according to the following steps:

  1. Identify the properties of an optimal solution and characterize its structural features;
  2. Recursively define the optimal value;
  3. Compute the optimal value in a bottom-up manner;
  4. Construct an optimal solution based on the information obtained during the computation of the optimal value.

Basic elements of dynamic programming algorithms:

  1. Optimal Substructure: If the optimal solution to the problem to be solved contains the optimal solutions to its subproblems, then the problem is said to possess the optimal substructure property.
  2. Overlapping Subproblems: When using the dynamic programming algorithm, the subproblems generated each time are not entirely new problems; they may contain solutions to previous level problems. This is typically solved using the table-recording method mentioned above and can generally be completed in polynomial time.
  3. Memoization: This is a variation of the dynamic programming algorithm. Also using a table, the memoization method uses a top-down computational approach. It creates a record entry for each subproblem, checks the record entry to determine if the subproblem has already been computed, and if so, directly uses the value in the record entry; otherwise, it computes and records it.

2. Algorithm Application Problems

2.1 Matrix Chain Multiplication

2.1.1 Problem Description

Given \(n\) matrices \(\{A_1,A_2,\cdots,A_n\}\), where \(A_i\) and \(A_{i+1}\) are compatible for multiplication (\(i=1,2,\cdots,n-1\)). Find the product \(A_1A_2\cdots A_n\) of these \(n\) matrices.

Matrix multiplication satisfies the associative law, so their order of operations can be changed. We use parenthesizing to determine the order of operations for the matrix chain product.

For example, the matrix chain product \(A_1A_2A_3A_4\) can have the following 5 operation orders: \[ (A_1(A_2(A_3A_4))),(A_1((A_2A_3)A_4)),((A_1A_2)(A_3A_4)),\\ ((A_1(A_2A_3))A_4),(((A_1A_2)A_3)A_4) \] For different operation orders, it is known from the properties of matrix multiplication that the number of operations actually differs. Let \(ra,ca\) and \(rb,cb\) respectively denote matrices \(A,B\). The standard algorithm code for computing their matrix product is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void matrix_multiply(int **a, int **b, int ra, int ca, int rb, int cb)  //a,b表示表示矩阵
{
if(ca != cb)
{
error("Unmultiplyable matrix!");
}
for(int i = 0; i < ra; i++)
{
for(int j = 0; j < cb; j++)
{
int sum = a[i][0]*b[0][j];
for(int k = 1; k < ca; k++ )
{
sum += a[i][k]*b[k][j];
}
c[i][j] = sum;
}
}
}

2.1.2 Algorithm Design

For the matrix chain product \(A_iA_{i+1}\cdots A_j\), let's denote it as \(A[i:j]\). Then for a parenthesizing method \(((A_1\cdots A_k)(A_{k+1}\cdots A_n))\), its number of operations is the product of the operation counts of \(A[1:k]\) and \(A[k+1:n]\) plus their sum.

Calculating the Optimal Value:

Let \(m[i][j]\) be the minimum number of scalar multiplications required to compute \(A[i:j](1\leqslant i\leqslant j \leqslant n)\). Then the optimal solution to the original problem is the value of \(m[1][n]\). For the case where the optimal computation order breaks at \(k(i\leqslant k<j)\), \(m[i][j]\) can be recursively defined as:

\[ m[i][j]= \left\{ \begin{aligned} &0 &i=j \\ &\lim_{i\leqslant k < j} \left \{ m[i][k]+m[k+1][j]+p_{i-1}p_{k}p_{j}\right\} &i<j \end{aligned} \right. \]

Where \(p_i\) represents the number of columns of matrix \(A_i\), and from the properties of matrix product results, it can be known that \(p_{i-1}\) represents the number of rows of matrix \(A_i\).

For each \(k\), there is a corresponding \(m[i][j]\). We create another table \(s[i][j]\) to store the value of \(k\). This way, after obtaining the optimal value \(m[i][j]\), the corresponding optimal solution can be recursively constructed from \(s[i][j]\). We use the recursive formula to compute the \(m\) matrix. Below is the basic code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void matrix_chain(int *p, int n, int **m, int **s)
{
for(int i = 1; i <= n; i++)
{
m[i][i] = 0;
}

for(int r = 2; r <= n; r++)
{
for(int i = 1; i <= n-r+1; i++)
{
int j = i+r-1;
m[i][j] = m[i+1][j]+p[i-1]*p[i]*p[j];
s[i][j] = i;
for(int k = i+1; k < j; k++)
{
int t = m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
if(t < m[i][j])
{
m[i][j] = t;
s[i][j] = k;
}
}
}
}
}

Here's an example from the book "Computer Algorithms: Design and Analysis" for easier understanding. Suppose we need to compute the matrix chain product \(A_1A_2A_3A_4A_5A_6\). Its dimensions are as follows:

Matrix A1 A2 A3 A4 A5 A6
Dimensions 30x35 35x15 15x5 5x10 10x20 20x25

Then its computation order and corresponding \(m\) and \(s\) matrices are as follows:

For example, when calculating \(m[1][3]\), the recursive formula is as follows:

\[\begin{aligned} m[1][3] &= \min \left \{ \begin{aligned} &m[1][1]+m[2][3]+p_0p_1p_3 &=& 0+2625+30\times35\times5=7875 \\ &m[1][2]+m[3][3]+p_0p_2p_3 &=& 15750+0+30\times15\times5=18000 \\ \end{aligned} \right. \\ &=7875 \end{aligned}\]

During the calculation, we can see that the smaller value is obtained when \(k=1\), so \(s[1][3]=1\). After the \(m\) matrix is computed, the optimal value \(m[1][n]\) can be obtained.

Obtaining the Optimal Solution:

The method above only calculated the minimum number of operations, but has not yet yielded the specific order of operations. In fact, the data obtained from the \(s\) matrix provides the order information for each calculation.

For example, the information recorded in \(s[1][n]\) indicates that the optimal way to compute \(A[i:n]\) is: \((A[1:s[1][n]])(A[s[1][n]+1:n])\). And the optimal parenthesizing for \(A[1:s[1][n]]\) is: \((A[1:s[1][s[1][n]]])(A[s[1][s[1][n]]+1:s[1][s[1][n]]])\). By iterating this way, the optimal computation order for \(A[1:n]\) is finally determined. Below is the code implementation for this iteration:
1
2
3
4
5
6
7
8
9
10
11
12
void Trace_back_matrix(int i, int j, int **s)
{
if (i == j)
{
return;
}

Trace_back_matrix(i, s[i][j], s);
Trace_back_matrix(s[i][j]+1, j, s);
cout << "Multiply A" << i <<"," <<s[i][j];
cout << " and A" << s[i][j]+1 << "," << j << endl;
}

By calling Trace_back_matrix(1, n, s), the optimal computation order can be obtained.

2.2 Longest Common Subsequence

2.2.1 Problem Description

Given two sequences \(X=\{x_1,x_2,\cdots,x_m\}\) and \(Y=\{y_1,y_2,\cdots,y_n\}\), find the longest common subsequence of \(X\) and \(Y\).

For this type of problem, it is clear that it possesses the optimal substructure property. Let \(Z=\left\{z_1,z_2,\cdots,z_k\right\}\) be a longest common subsequence of sequences \(X\) and \(Y\). Then:

  1. If \(x_m=y_n\), then \(z_k=x_m=y_n\), and \(Z_{k-1}\) is a longest common subsequence of \(X_{m-1}\) and \(Y_{n-1}\);
  2. If \(x_m\neq y_n\) and \(z_k\neq x_m\), then \(Z\) is a longest common subsequence of \(X_{m-1}\) and \(Y\);
  3. If \(x_m\neq y_n\) and \(z_k\neq y_n\), then \(Z\) is a longest common subsequence of \(X\) and \(Y_{n-1}\).

To simply understand the above rules, if under the condition \(z_k\neq x_m\) there exists a common subsequence \(W\) with length greater than \(k\), then the length of \(W\) must be greater than \(Z\), and \(W\) would become a common subsequence of \(X\) and \(Y\) with length greater than \(k\). This clearly contradicts \(Z\) being the longest common subsequence of \(X\) and \(Y\). Therefore, \(Z\) is a longest common subsequence of \(X_{m-1}\) and \(Y\). Other cases can be proved by analogy.

2.2.2 Algorithm Design

From the three rules mentioned above, it can be seen that this problem possesses optimal substructure and overlapping subproblems properties. Its recursive approach is as follows: 1. If \(x_m=y_n\), start finding the longest common subsequence of \(X_{m-1}\) and \(Y_{n-1}\), then add \(x_m(x_m=y_n)\) to the end of this subsequence; 2. If \(x_m\neq y_n\), then find a longest common subsequence of \(X_{m-1}\) and \(Y\), and a longest common subsequence of \(X\) and \(Y_{n-1}\) separately. Then compare the lengths of the two longest common subsequences and take the longer one as the longest common subsequence of \(X\) and \(Y\).

We build table \(c\) to record the length of the common subsequence. Suppose we want to determine for sequences \(X_i=\left\{x_1,x_2,\dots,x_i\right\}\) and \(Y_j=\left\{y_1,y_2,\dots,y_j\right\}\). When \(i=0\) or \(j=0\), the empty sequence is their longest common subsequence, so \(c[i][j]=0\). The recursive relation summarized above is as follows:

\[ c[i][j]= \left\{ \begin{aligned} &0 &i>0;j=0 \\ &c[i-1][j-1]+1 &i,j>0;x_i=y_j \\ &\max \left \{ c[i][j-1],c[i-1][j] \right \} &i,j>0;x_i\neq y_j \end{aligned} \right. \]

Use table \(b\) to record which subproblem yielded the corresponding value in \(c\). We obtain the following code based on the recursive relation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void LCS_length(int m, int n, char *x, char *y, int **c, int **b)
{
int i,j;
//初始化
for(i = 1; i <= m; i++)
{
c[i][0] = 0;
}
for(i = 1; i <= n; i++)
{
c[0][i] = 0;
}

//构造c和b表
for(i = 1; i <= m; i++)
{
for(j = 1; j <= n; j++)
{
if(x[i] == y[j])
{
c[i][j] = c[i-1][j-1]+1;
b[i][j] = 1;
}
else if(c[i-1][j] >= c[i][j-1])
{
c[i][j] = c[i-1][j];
b[i][j] = 2;
}
else
{
c[i][j] = c[i][j-1];
b[i][j] = 3;
}
}
}

\(c[m][n]\) is the length of the longest common subsequence. Its function complexity is \(O(mn)\). Then we recursively construct the longest common subsequence based on table \(b\). The code is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void LCS(int i, int j, char *x, int **b)
{
if(i == 0 || j == 0)
{
return
}

if(b[i][j] == 1)
{
LCS(i-1, j-1, x, b);
cout << x[i];
}
else if(b[i][j] == 2)
{
LCS(i-1, j, x, b);
}
else
{
LCS(i, j-1, x, b);
}
}

The time complexity of this algorithm is \(O(m+n)\).

In fact, data \(b\) can be omitted in the algorithm, because in table \(c\), element \(c[i][j]\) is often determined by only three elements: \(c[i-1][j-1], c[i-1][j]\) and \(c[i][j-1]\). Therefore, even without table \(b\), we can still know which of the three elements mentioned above determined a certain element.

2.3 Maximum Subarray Sum

2.3.1 Problem Description

Given a sequence \(A=\{a_1,a_2,\cdots,a_n\}\) consisting of \(n\) integers, find the maximum value of a subarray sum of the form \(\sum\limits_{k=i}^{j}a_k\).

In this problem, the sequence of integers is allowed to contain negative numbers. If all integers in the sequence are negative, its maximum subarray sum is defined as \(0\). For example, when \((a_1,a_2,a_3,a_4,a_5,a_6)=(-2,11,-4,13,-5,-2)\), the maximum subarray sum is \(\sum\limits_{k=2}^4a_k=20\).

2.3.2 Divide-and-Conquer Algorithm Solution

For this problem, we can use a divide-and-conquer algorithm to solve it. We divide the sequence \(a(1:n)\) into two equal-length segments and find their maximum subarray sums separately. There are three cases:

  1. The maximum subarray sum of \(a[1:n]\) is the same as that of \(a[1:\frac{n}{2}]\);
  2. The maximum subarray sum of \(a[1:n]\) is the same as that of \(a[\frac{n}{2}+1:n]\);
  3. The maximum subarray sum of \(a[1:n]\) is \(\sum\limits_{k=i}^ja_k\), where \(1\leqslant i\leqslant \frac{n}{2}\) and \(\frac{n}{2}+1\leqslant j\leqslant n\).

For cases 1 and 2, direct recursion is sufficient. For case 3, we know that the two numbers in the middle of the split point must be within the optimal sequence. Therefore, we can extend from the split point outwards to both ends of the sequence. This way, the maximum subarray sum can be obtained. That is, calculate \(s_1=\max\limits_{1\leqslant i\leqslant \frac{n}{2}}\sum\limits_{k=i}^{n/2}a[k]\) in \(a[1:\frac{n}{2}]\), and \(s_2=\max\limits_{\frac{n}{2}+1\leqslant j\leqslant n}\sum\limits_{k=\frac{n}{2}+1}^{j}a[k]\) in \(a[\frac{n}{2}+1:n]\). Then \(s_1+s_2\) is the maximum subarray sum of \(a[1:n]\).

The time complexity of such an algorithm is \(O(n\log n)\). Below is the code implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int Max_sub_sum(int *a, int left, int right)
{
int sum = 0;
if (left == right)
{
sum = a[left] > 0 ? a[left]:0;
}
else
{
int center = (left+right)/2;
int left_sum = Max_sub_sum(a, left, center);
int right_sum = Max_sub_sum(a, center+1, right);
int s1 = 0;
int lefts = 0;
for(int i = center; i >= left; i--)
{
lefts += a[i];
if(lefts > s1)
{
s1 = lefts;
}
}
int s2 = 0;
int rights = 0;
for(int i = center+1; i <= right; i++)
{
rights += a[i];
if(rights > s2)
{
s2 = rights;
}
}
sum = s1+s2;
if(sum < left_sum)
{
sum = left_sum;
}
if(sum < right_sum)
{
sum = right_sum;
}
}
return sum;
}

int Max_sum(int *a, int n)
{
return Max_sub_sum(a, 1, n);
}

2.3.3 Dynamic Programming Solution

If \(b[j]=\max\limits_{1\leqslant i \leqslant j} \{ \sum\limits_{k=i}^ja[k]\}(1\leqslant j \leqslant n)\), then the maximum subarray sum sought is \[ \max\limits_{1\leqslant \leqslant i \leqslant j \leqslant n}\sum\limits_{k=i}^j a[k]=\max\limits_{1\leqslant i \leqslant n } \] \[ \max\limits_{1\leqslant i \leqslant j} \sum\limits_{k=i}^ja[k] =\max\limits_{1\leqslant j \leqslant n}b[j] \]

From this, it can be seen that when \(b[j-1]>0\), \(b[j]=b[j-1]+a[j]\); otherwise, \(b[j]=a[j]\). Its recursive formula can be derived as: \[ b[j] = \max\{b[j-1]+a[j],a[j]\} \qquad 1\leqslant j \leqslant n \]

The time and space complexity of this algorithm are both \(O(n)\). The code is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int Max_sum(int *a, int n)
{
int sum = 0;
int b = 0;
for(int i = 1; i <= n; i++)
{
if(b > 0)
{
b += a[i];
}
else
{
b = a[i];
}
if(b > sum)
{
sum = b;
}
}
return sum;
}

2.4 0-1 Knapsack

2.4.1 Problem Description

There are \(n\) items and a knapsack with capacity \(c\). Item \(i\) has weight \(w_i\) and value \(v_i\). Determine how to choose items to put into the knapsack to maximize the total value.

2.4.2 Algorithm Design

Let \((y_1,y_2, \cdots ,y_n)\) be an optimal solution to the given 0-1 knapsack problem. Then \((y_2,y_3,\cdots,y_n)\) is an optimal solution to the following corresponding subproblem:

\[ \max{\sum\limits_{i=2}^n v_iy_i} \qquad \left\{ \begin{aligned} &\sum\limits_{i=2}^n w_iy_i \leqslant c-w_1y_1 \\ &x_i \in \{0,1\} \qquad 2\leqslant i \leqslant n \end{aligned} \right. \]

Clearly, this problem has the optimal substructure property. Let \(m(i,j)\) be the optimal value for the 0-1 knapsack problem. Then its recursive formula is: \[ m(i,j)= \left\{ \begin{aligned} &m(i+1,j) &0\leqslant j<w_i \\ &\max \{m(i+1,j),m(i+1,j-w_i)+v_i\} &j\geqslant w_i \end{aligned} \right. \]

The boundary conditions are as follows: \[ m(n,j)= \left\{ \begin{aligned} &0 &0\leqslant j<w_n \\ &v_n &j\geqslant w_n \end{aligned} \right. \]

We use a 2D table \(m\). The basic code is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
template <class Type>
void knapsack(int n, Type *w, Type *v, Type c, Type **m) //动态规划算法
{
int jMax = min(w[n]-1, c);
for(int j = 0; j <= jMax; j++)
{
m[n][j] = 0;
}
for(int j = w[n]; j <= c; j++)
{
m[n][j] = v[n];
}
for(int i = n-1; i > 1; i--)
{
jMax = min(w[i]-1, c);
for(int j = 0; j <= jMax; j++)
{
m[i][j] = m[i+1][j];
}
for(int j = w[i]; j <= c; j++)
{
m[i][j] = max(m[i+1][j], m[i+1][j-w[i]]+v[i]);
}
}
m[1][c] = m[2][c];
if(c >= w[1])
{
m[1][c] = max(m[1][c], m[2][c-w[1]]+v[1]);
}
}

template <class Type>
void traceback(Type **m, int w, int w, int c, int n, int x)
{
for(int i = 1; i < n; i++)
{
if(m[i][c] == m[i+1][c])
{
x[i] = 0;
}
else
{
x[i] = 1;
c -= w[i];
}
}
x[n] = (m[n][c]) ? 1:0;
}

In the code above, the constructed \(m[1][c]\) gives the optimal value for the required problem. Clearly, the time complexity of the Knapsack algorithm is \(O(nc)\), and that of Traceback is \(O(n)\).

本文作者AuthorKiRorY
本文链接Permalinkhttps://kirory.xyz/2023/08/18/算法分析笔记-动态规划/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可License:This work is licensed under CC BY-NC-SA 3.0 CN