算法基础之动态规划法

本文将详细介绍动态规划法的基本原理和适用条件,并通过经典例题辅助读者理解动态规划法的思想、掌握动态规划法的使用。本文给出的例题包括:多段图问题、矩阵连乘问题、最长公共子序列问题。

算法原理

动态规划是一种解决多阶段决策问题的优化方法,把多阶段过程转化为一系列单阶段问题,利用各个阶段之间的关系逐个求解。与分治法类似,动态规划法也是将待求解的问题分解为若干个子问题(阶段),但分治法中各子问题相互独立,而动态规划法适用于子问题重叠的情况,也即各子问题包含公共的子子问题。与贪心法类似,动态规划法可将一个问题的解决方案视为一系列决策的结果,但动态规划可以处理不满足贪心准则的问题。

动态规划法所能解决的问题一般需要满足以下性质:

  • 最优性原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优性原理。
  • 无后效性:某阶段的状态一旦确定,就不受这个状态以后决策的影响。也即,某状态以后的过程不会影响以前的状态,只与当前的状态有关。
  • 有重叠子问题:子问题之间是不独立的,一个子问题在下一个阶段决策中可能被多次使用。该性质是不是动态规划法适用的必要性质,但若无该性质,则动态规划法与其它算法相比不具备优势。

动态规划法的步骤:

  1. 分析最优解的性质,并刻画其结构特征;
  2. 递归定义最优解;
  3. 以自底向上或自顶向下的记忆化方式计算出最优值;
  4. 根据计算最优值得到的信息构造问题的最优解。

多段图问题

题目描述

设图 G = ( V , E ) G=(V,E) G=(V,E) 是一个带权有向图,图中的顶点被划分成 k k k 个互不相交的子集 V i V_i Vi ( 2 ≤ k ≤ n , 1 ≤ i ≤ k ) (2≤k≤n,1≤i≤k) (2kn,1ik),使得 E E E 中任意一条边 < u , v > <u,v> <u,v> 必有 u ∈ V i , v ∈ V j u∈V_i,v∈V_j uVivVj ( 1 ≤ i < k , 1 < j ≤ k ) (1≤i<k,1<j≤k) (1i<k,1<jk),则称图 G G G 为多段图,称 s ∈ V 1 s∈V_1 sV1 为源点, t ∈ V k t∈V_k tVk 为汇点, V 1 V_1 V1 V k V_k Vk 分别只有一个顶点。多段图的最短路径问题即为求从源点到汇点的最小代价路径(顶点编号从 0 0 0 开始,默认顶点 0 0 0 为源点,顶点 ∣ V ∣ − 1 |V|-1 V1 为汇点)。

输入输出

输入:先输入顶点个数 V V V 和边数 E E E,接着 E E E 行输入 E E E 条边及边的权值 ( u , v , w ) (u,v,w) (u,v,w)

输出:第一行输出最短路径(用→连接),第二行输出最短路径的长度。

向后处理

以下图所示带权有向图为例(记为 G G G),解释如何通过“向后处理”求解最短路径以及最短路径的长度。

在这里插入图片描述

n n n 个顶点依次编号为 0 , 1 , . . . , n − 1 0,1,...,n-1 0,1,...,n1,设 d [ i ] d[i] d[i] = 源点 s s s 到顶点 i i i 的最短路径的长度,则有 d [ j ] = m i n { d [ i ] ∣ ∃ < i , j > ∈ E } d[j]=min\{d[i]|∃<i,j>∈E\} d[j]=min{d[i]∣∃<i,j>∈E}。具体做法:

  1. d [ 0.. n − 1 ] d[0..n-1] d[0..n1] 置为 I N F INF INF,设当前段所有点的集合为 V c u r Vcur Vcur,下一段所有点的集合为 V n e x t Vnext Vnext,源点 s s s 所在段的顶点的集合为 V s Vs Vs,汇点t所在段的顶点的集合为 V t Vt Vt V c u r Vcur Vcur 初始置为 V s Vs Vs V n e x t Vnext Vnext V c u r Vcur Vcur 和边相关信息推出;
  2. 根据 V c u r Vcur Vcur 中的每一个顶点 i i i 尝试去更新 V n e x t Vnext Vnext 中的每一个顶点j对应的 d [ j ] d[j] d[j],即,若 d [ j ] > d [ i ] + c o s t [ i ] [ j ] d[j]>d[i]+cost[i][j] d[j]>d[i]+cost[i][j],则 d [ j ] = d [ i ] + c o s t [ i ] [ j ] d[j]=d[i]+cost[i][j] d[j]=d[i]+cost[i][j],并记录j的前驱 p r e [ j ] pre[j] pre[j] i i i,否则,不作处理;
  3. V c u r = V n e x t Vcur=Vnext Vcur=Vnext V n e x t Vnext Vnext 通过 V c u r Vcur Vcur 和边相关信息推出;
  4. V c u r ! = V t Vcur!=Vt Vcur!=Vt,则重复步骤 2 2 2 3 3 3,否则,程序结束, d [ t ] d[t] d[t] 即为源点 s s s 到汇点 t t t 的最短路径的长度, p r e pre pre 记录了最短路径上每个顶点的前驱。
// 准备工作
#define INF (INT_MAX/2-1)
typedef vector<int> vi;

int V, E;                   	// 点数 边数
struct edge { int to, w; };  	// 边
vector<vector<edge>> es;    	// es[i] 以i为起始点的边的集合
vi d;                       	// 各点到源点s的最短距离,初始化为INF
vi pre;                     	// pre[i] 最短路径上顶点i的前驱,初始化为-1

int main() {
	// 输入
	cin >> V >> E;
	es.resize(V), d.resize(V, INF), pre.resize(V, -1);
	for (int i = 0, u, v, w; i < E; ++i) {
		cin >> u >> v >> w;
		es[u].push_back({v, w});
	}
	// 求解
	mulSegGraph(0);
	vi path = getPath(V-1);
	// 输出
	printf("%d", path[0]);
	for (int i = 1; i < path.size(); ++i)cout << "->" << path[i];
	printf("\n%d", d[t]);
}
/**
 * 求多段图问题
 * @param s 源点
 */
void mulSegGraph(int s) {
    queue<int> seg;            	// 段
    seg.push(s), d[s] = 0;   	// 源点入队列,起点到起点的距离设为0
    while (!seg.empty()) {
        int i = seg.front();
        seg.pop();
        // 更新所有以i为起始点的边的终点到起点s的最短距离
        for (auto &e: es[i]) {
            if (d[e.to] > d[i] + e.w) {
                d[e.to] = d[i] + e.w;
                pre[e.to] = i;
                seg.push(e.to);
            }
        }
    }
}
/**
 * 求源点s到汇点t的最短路径
 * @param t 汇点
 * @return 最短路径
 */
vi getPath(int t) {
    vector<int> path;
    for (; t != -1; t = pre[t])path.push_back(t);
    reverse(path.begin(), path.end());
    return path;
}

时间复杂度: O ( E + K ) O(E+K) O(E+K),其中, E E E 表示多段图的边数, K K K 表示多段图的段数。

空间复杂度: O ( V + E ) O(V+E) O(V+E),其中, V V V 为多段图的顶点数。

向前处理

向后处理即从源点 s s s 出发,向汇点 t t t 迈进,向前处理则是从汇点 t t t 倒推回源点 s s s,处理方法与向后处理类似。 n n n 个顶点依次编号为 0 , 1 , . . . n − 1 0,1,...n-1 0,1,...n1,设 d [ i ] d[i] d[i] = 顶点 i i i 到汇点 t t t 的最短路径的长度,则有 d [ i ] = m i n { d [ j ] ∣ ∃ < i , j > ∈ E } d[i]=min\{d[j]|∃<i,j>∈E\} d[i]=min{d[j]∣∃<i,j>∈E}。具体做法:

  1. d [ 0.. n − 1 ] d[0..n-1] d[0..n1] 置为 I N F INF INF,设当前段所有点的集合为 V c u r Vcur Vcur,上一段所有点的集合为 V p r e Vpre Vpre,源点 s s s 所在段的顶点的集合为 V s Vs Vs,汇点 t t t 所在段的顶点的集合为 V t Vt Vt V c u r Vcur Vcur 初始置为 V t Vt Vt V p r e Vpre Vpre V c u r Vcur Vcur 和边相关信息推出;
  2. 根据 V c u r Vcur Vcur 中的每一个顶点 j j j 尝试去更新 V p r e Vpre Vpre 中的每一个顶点 i i i 对应的 d [ i ] d[i] d[i],即,若 d [ i ] > d [ j ] + c o s t [ i ] [ j ] d[i] > d[j] + cost[i][j] d[i]>d[j]+cost[i][j],则 d [ i ] = d [ j ] + c o s t [ i ] [ j ] d[i] = d[j] + cost[i][j] d[i]=d[j]+cost[i][j],并记录 i i i 的后继 n e x [ i ] nex[i] nex[i] j j j,否则,不作处理;
  3. V c u r = V p r e Vcur = Vpre Vcur=Vpre V p r e Vpre Vpre 通过 V c u r Vcur Vcur 和边相关信息推出;
  4. V c u r ! = V s Vcur != Vs Vcur!=Vs,则重复步骤 2 2 2 3 3 3,否则,程序结束, d [ s ] d[s] d[s] 即为源点 s s s 到汇点 t t t 的最短路径的长度, n e x nex nex 记录了最短路径上每个顶点的后继。
// 准备工作
#define INF (INT_MAX/2-1)
typedef vector<int> vi;

int V, E;                     	// 点数 边数
struct edge { int from, w; }; 	// 边
vector<vector<edge>> es;      	// es[i] 以i为终点的边的集合
vi d;							// 各点到汇点t的最短距离,初始化为INF
vi nex;							// nex[i] 最短路径上顶点i的后继,初始化为-1

int main() {
	// 输入
	cin >> V >> E;
	es.resize(V), d.resize(V, INF), nex.resize(V, -1);
	for (int i = 0, u, v, w; i < E; ++i) {
		cin >> u >> v >> w;
		es[v].push_back({u, w});
	}
	// 求解
	mulSegGraph(V-1);
	vi path = getPath(0);
	// 输出
	cout << path[0];
	for (int i = 1; i < path.size(); ++i)cout << "->" << path[i];
	cout << '\n' << d[s];
}
/**
 * 求多段图问题
 * @param t 汇点
 */
void mulSegGraph(int t) {
	queue<int> seg;            // 段
	seg.push(t), d[t] = 0;     // 汇点t入队列,汇点t到汇点t的距离设为0
	while (!seg.empty()) {
		int i = seg.front();
		seg.pop();
		// 更新所有以i为终点的边的起始点到汇点t的最短距离
		for (auto &e: es[i]) {
			if (d[e.from] > d[i] + e.w) {
				d[e.from] = d[i] + e.w;
				nex[e.from] = i;
				seg.push(e.from);
			}
		}
	}
}
/**
 * 求源点s到汇点t的最短路径
 * @param s 源点
 * @return 最短路径
 */
vi getPath(int s) {
	vi path;
	for (; s != -1; s = nex[s])path.push_back(s);
	return path;
}

时间复杂度: O ( E + K ) O(E+K) O(E+K),其中, E E E 表示多段图的边数, K K K 表示多段图的段数。

空间复杂度: O ( V + E ) O(V+E) O(V+E),其中, V V V 为多段图的顶点数。

优化处理

观察向前处理和向后处理两种方案的输出结果发现,对于同一个多段图,二者给出的最短路径并不相同,但最短路径的长度是相同的。这是因为,当前多段图存在多条最短路径,而两个方案中存储最短路径的数组均为一维数组,只能记录一条最短路径。

为此,以向后处理为例,修改记录最短路径的数组 p r e pre pre 为二维数组, p r e [ i ] pre[i] pre[i] 用于记录每一条最短路径上节点 i i i 的前驱,即 p r e [ i ] pre[i] pre[i] 为节点 i i i 的前驱的集合。在向后处理的的基础上做如下修改(与向前处理类似)。

...
vector<set<int>> pre;	// pre[i] 最短路径上顶点i的前驱的集合
...
int main() {
	...
    pre.resize(V), pre[0].insert(-1);
	...
    // 读取最短路径
    vector<vi> paths;
    vi path;
    getPaths(t, paths, path);
    // 输出
    for (const auto &ph: paths) {
        cout << ph[0];
        for (int i = 1; i < ph.size(); ++i)cout << "->" << ph[i];
    }
    ...
}
void mulSegGraph(int s) {
	...
    while (!seg.empty()) {
		...
        for (auto &e: es[i]) {
            if (d[e.to] >= d[i] + e.w) {
                // 记录多条路径
                if (d[e.to] > d[i] + e.w) {
                    // 找到到e.to更短的路径则清除e.to原来记录的前驱
                    pre[e.to].clear();	
                    pre[e.to].insert(i);
                    d[e.to] = d[i] + e.w;
                }
                // 找到与目前到e.to的最短路径长度相同的路径则记录
                else pre[e.to].insert(i);	
                seg.push(e.to);
            }
        }
    }
}
/**
 * 求源点s到汇点t的所有最短路径
 * @param t 汇点
 * @param paths 所有路径
 * @param path 已搜索到的路径
 */
void getPaths(int t, vector<vi> &paths, vi path) {
    if (t == -1) {          // 当前最短路径搜索完毕
        reverse(path.begin(), path.end());
        paths.push_back(path);
        return;
    } else path.push_back(t);
    for (auto &p: pre[t])getPaths(p, paths, path);
}

时间复杂度: O ( E + M K ) O(E+MK) O(E+MK),其中, E E E 表示多段图的边数, M M M 表示最短路径的数目, K K K 表示多段图的段数。

空间复杂度: O ( V + E ) O(V+E) O(V+E),其中, V V V 为多段图的顶点数。

矩阵连乘问题

题目描述

给定 n n n 个矩阵 A 0 , A 1 , … , A n − 1 A_0,A_1,…,A_{n-1} A0,A1,,An1,其中 A i A_i Ai 的维数为 p i × p i + 1 p_i × p_{i+1} pi×pi+1,并且 A i A_i Ai A i + 1 A_{i+1} Ai+1 是可乘的。考察这 n n n 个矩阵的连乘积 A 0 × A 1 × … × A n − 1 A_0 × A_1 ×…× A_{n-1} A0×A1××An1,由于矩阵乘法满足结合律,所以计算矩阵的连乘可有许多不同的计算次序。矩阵连乘问题是确定计算矩阵连乘积的计算次序,使得按照这一次序计算矩阵连乘积,需要的“数乘”次数最少。

输入输出

输入:第一行输入 n n n 的值,第二行输入 n n n 个矩阵的维数 p i p_i pi

输出:最少数乘次数以及对应的计算次序。

基础实现

m [ i ] [ j ] = A i . . . A j m[i][j]=A_i...A_j m[i][j]=Ai...Aj 的最小数乘次数 ( 0 ≤ i ≤ j ≤ n − 1 ) (0≤i≤j≤n-1) (0ijn1) p [ i ] p[i] p[i] = 矩阵 A i A_i Ai 的行数, p [ i + 1 ] p[i+1] p[i+1] = 矩阵 A i A_i Ai 的列数 = 矩阵 A i + 1 A_{i+1} Ai+1 的行数 ( 0 ≤ i ≤ n − 1 ) (0≤i≤n-1) (0in1),则有 m [ i ] [ j ] = m i n { m [ i ] [ k ] + m [ k + 1 ] [ j ] + p [ i ] × p [ k + 1 ] × p [ j + 1 ]   ∣   i < k < j } m[i][j] = min\{m[i][k] + m[k+1][j] + p[i] × p[k+1] × p[j+1]\ |\ i<k<j\} m[i][j]=min{m[i][k]+m[k+1][j]+p[i]×p[k+1]×p[j+1]  i<k<j},当 i = j i=j i=j 时,有 m [ i ] [ j ] = 0 m[i][j]=0 m[i][j]=0,也即 m [ i ] [ i ] = 0 m[i][i]=0 m[i][i]=0。为了后续输出对应的计算次序,增加数组 b b b 用于记录划分点, b [ i ] [ j ] = A i . . . A j b[i][j]=A_i...A_j b[i][j]=Ai...Aj 的划分点,划分点属于左侧。

typedef vector<int> vi;
int N;              	// 矩阵数
vi p;                	// p[i] 矩阵Ai的行数,同时也是举证Ai+1的列数
vector<vi> m;        	// m[i][j] Ai...Aj的最小数乘次数
vector<vi> b;        	// b[i][j] Ai...Aj的划分点,划分点属于左侧

int main() {
	// 输入
	cin >> N;
	p.resize(N + 1, 0), m.resize(N, vi(N, 0)), b.resize(N, vi(N, -1));
	for (int i = 0; i <= N; ++i)cin >> p[i];
	// 求解
	minNumMul();
	// 输出
	cout << m[0][N - 1] << endl;
	bracket(0, N - 1);
}
/**
 * 计算A0A1...An-1的最少数乘次数
 */
void minNumMul() {
    int i, j, k;
    // 求k个矩阵相乘时的最少数乘次数
    for (k = 2; k <= N; k++)
        // 求每个长度为k子序列(i,j)的子序列的划分点(划分点属于左侧)
        for (i = 0; i < N - k + 1; i++) {
            j = i + k - 1;
            // 以i为划分点赋初值,省略+m[i][i],因为m[i][i]=0
            m[i][j] = m[i + 1][j] + p[i] * p[i + 1] * p[j + 1]; 
            b[i][j] = i;
            // 尝试每个可能的划分
            for (int r = i + 1, t; r < j; r++) {
                t = m[i][r] + m[r + 1][j] + p[i] * p[r + 1] * p[j + 1];
                if (t < m[i][j]) {
                    m[i][j] = t;    // 记录数乘数
                    b[i][j] = r;    // 记录划分点
                }
            }
        }
}
/**
 * 显示Ai...Aj数乘次数最少时对应的计算次序
 */
void bracket(int i, int j) {
    cout << "(";
    if(i == b[i][j])printf("A%d", i);   // 划分点b[i][j]左侧没有划分点
    else bracket(i, b[i][j]);
    if (b[i][j] + 1 == j)    // 划分点b[i][j]右侧没有划分点
        for (int k = b[i][j] + 1; k <= j; ++k) printf("A%d", k);
    else bracket(b[i][j] + 1, j);
    cout << ")";
}

时间复杂度: O ( n 3 ) O(n^3) O(n3)

空间复杂度: O ( n 2 ) O(n^2) O(n2)

优化处理

观察 m i n N u m M u l ( ) minNumMul() minNumMul() 函数发现,二维数组 m m m 只利用了下标 i ≤ j i≤j ij 的空间,二维数组 b b b 只利用了下标 i < j i<j i<j 的空间,而且二者大小均为 N × N N×N N×N 。因此,为了节约空间,可以将两个二维数组合并,具体做法就是用 m [ j m[j m[j][i] 直接替换掉原来的 b [ i ] [ j ] b[i][j] b[i][j],其中 i < j i<j i<j。在基础实现的基础上做如下修改:

void minNumMul() {
	...
    for (k = 2; k <= N; k++)
        for (i = 0; i < N - k + 1; i++) {
		   ...
		   // b[i][j] = i;
            m[j][i] = i;
            for (int r = i + 1, t; r < j; r++) {
                ...
                if (t < m[i][j]) {
                    ...
			   	  // b[i][j] = r; 
                    m[j][i] = r;
                }
            }
        }
}
void bracket(int i, int j) {
    cout << "(";
    if (i == m[j][i])printf("A%d", i);   // 划分点b[i][j]左侧没有划分点
    else bracket(i, m[j][i]);
    if (m[j][i] + 1 == j)    // 划分点b[i][j]右侧没有划分点
        for (int k = m[j][i] + 1; k <= j; ++k) printf("A%d", k);
    else bracket(m[j][i] + 1, j);
    cout << ")";
}

时间复杂度: O ( n 3 ) O(n^3) O(n3)

空间复杂度: O ( n 2 ) O(n^2) O(n2)

最长公共子序列问题

题目描述

给定两个序列 X = { x 1 , x 2 , … , x m } X=\{x_1,x_2,…,x_m\} X={x1,x2,,xm} Y = { y 1 , y 2 , … , y n } Y=\{y_1,y_2,…,y_n\} Y={y1,y2,,yn},找出 X X X Y Y Y 的最长公共子序列。

输入输出

输入:第一行输入序列 X X X,第二行输入序列 Y Y Y

输出: X X X Y Y Y 的最长公共子序列和最长公共子序列的长度

基础实现

L [ i ] [ j ] L[i][j] L[i][j] x 1 . . . x i x_1...x_i x1...xi y 1 . . . y j y_1...y_j y1...yj 的最长公共子序列(设为 Z Z Z )的长度,则有

  • i = 0 i=0 i=0 j = 0 j=0 j=0 时, l [ i ] [ j ] = 0 l[i][j]=0 l[i][j]=0
  • ② 当 Z Z Z 末位不为 x i x_i xi 不为 y j y_j yj 时, L [ i ] [ j ] = L [ i − 1 ] [ j − 1 ] L[i][j]= L[i-1][j-1] L[i][j]=L[i1][j1]
  • ③ 当 Z Z Z 末位不为 x i x_i xi y j y_j yj 时, L [ i ] [ j ] = L [ i − 1 ] [ j ] L[i][j]= L[i-1][j] L[i][j]=L[i1][j]
  • ④ 当 Z Z Z 末位为 x i x_i xi 不为 y j y_j yj 时, L [ i ] [ j ] = L [ i ] [ j − 1 ] L[i][j]= L[i][j-1] L[i][j]=L[i][j1]
  • ⑤ 当 Z Z Z 末位为 x i x_i xi 且为 y j y_j yj 时(此时 x i x_i xi y j y_j yj 必相等), L [ i ] [ j ] = L [ i − 1 ] [ j − 1 ] + 1 L[i][j]= L[i-1][j-1]+1 L[i][j]=L[i1][j1]+1

综上,若 x i = y j x_i=y_j xi=yj L [ i ] [ j ] = L [ i − 1 ] [ j − 1 ] + 1 L[i][j]= L[i-1][j-1]+1 L[i][j]=L[i1][j1]+1,否则 L [ i ] [ j ] = m a x ( L [ i − 1 ] [ j − 1 ] , L [ i − 1 ] [ j ] , L [ i ] [ j − 1 ] ) L[i][j]=max(L[i-1][j-1], L[i-1][j], L[i][j-1]) L[i][j]=max(L[i1][j1],L[i1][j],L[i][j1])

需要注意,③中 L [ i − 1 ] [ j ] L[i-1][j] L[i1][j] 实际表示 Z Z Z 末位不为 x i x_i xi 的情况,比②中前提表示范围要大,但包含了③中前提表示范围,④和⑤中也是类似。虽然如此,但并不影响结果的正确性。①到⑤的前提表示范围可以覆盖所有情况,③④⑤实际表示范围均包含了各自前提表示范围,只不过各自均考虑了一些前提以外的情况,这些情况仍在整体待考虑的范围之内,由于求“最长”,即使重复考虑了某些情况,也并不影响最值。

以字符串 a b a d abad abad b a a d e baade baade 为例,构造二维数组 L L L 如下图所示。

在这里插入图片描述

应用上述规则,通过对二维数组 L L L 反向搜索,便可以得到最长公共子序列。为了在反向搜索时更加方便地直到 L [ i ] [ j ] L[i][j] L[i][j] 究竟来自 L [ i − 1 ] [ j ] L[i-1][j] L[i1][j] L [ i ] [ j − 1 ] L[i][j-1] L[i][j1] L [ i − 1 ] [ j − 1 ] L[i-1][j-1] L[i1][j1] 中的哪一个,可以增设数组 W [ i ] [ j ] W[i][j] W[i][j],在构造二维数组L的同时对 L [ i ] [ j ] L[i][j] L[i][j] 来自哪里进行记录。

// L[i][j]: x1x2...xi与y1y2...yj的最长公共子序列的长度
vector<vector<int>> L;
// W[i][j]: L[i][j]的值是通过l[i-1][j]、L[i][j-1]、L[i-1][j-1]中的哪一个得到的
// 用于还原最长公共子序列
vector<vector<int>> W;
// W[i][j]的三个值,分别对应l[i-1][j-1]、L[i-1][j]、L[i][j-1]
int LU = 1, LEFT = 2, UP = 3;
string X, Y;    // 字符串

int main() {
	// 输入
	cin >> X >> Y;
	int m = X.length(), n = Y.length();
	L.resize(m + 1, vector<int>(n + 1, 0));
	W.resize(m + 1, vector<int>(n + 1, 0));

	// 求解
	lonComSub();
	string s = sub();

	// 输出
	cout << s << '\n' << L[m][n];
}
/**
 * 求最长公共子序列即其长度,也即填充l和w
 */
void lonComSub() {
    int i, j;
    for (i = 0; i < X.length(); ++i) {
        for (j = 0; j < Y.length(); ++j) {
            if (X[i] == Y[j]) {
                L[i + 1][j + 1] = L[i][j] + 1;
                W[i + 1][j + 1] = LU;    // (i,j)是左上方来的
            } else if (L[i + 1][j] > L[i][j + 1]) {
                L[i + 1][j + 1] = L[i + 1][j];
                W[i + 1][j + 1] = LEFT;    // (i,j)是从左边来的
            } else {
                L[i + 1][j + 1] = L[i][j + 1];
                W[i + 1][j + 1] = UP;    // (i,j)是从上边来的
            }
        }
    }
}
/**
 * 以字符串的形式返回最长公共子序列
 */
string sub() {
    string s;
    int i = X.length(), j = Y.length();
    while (i > 0 && j > 0) {
        if (W[i][j] == LU) {                // 从左上来的
            s = X[i - 1] + s;
            i--, j--;
        } else if (W[i][j] == LEFT) j--;    // 从左边来的
        else i--;                           // 从上面来的
    }
    return s;
}

时间复杂度: O ( m n ) O(mn) O(mn)

空间复杂度: O ( m n ) O(mn) O(mn)

优化处理

观察基础实现的测试结果不难发现,当 X X X Y Y Y 存在多个最长公共子序列时,程序只输出了其中一个,为此,需要修改获取最长公共子序列的函数,即 s u b ( ) sub() sub() 函数。在基础实现的基础上做如下修改:

int main() {
	// 初始化
	cin >> X >> Y;
	int m = X.length(), n = Y.length();
	L.resize(m + 1, vector<int>(n + 1, 0));
	W.resize(m + 1, vector<int>(n + 1, 0));
	
	// 求解
	lonComSub();
	vector<string> ss;
	string s;
	sub(m, n, ss, s);
	
	// 输出
	for (auto &it: ss)cout << it << endl;
	cout << L[m][n];
}
/**
 * 获取最长公共子序列
 * @param i,j 坐标
 * @param ss 所有公共子序列
 * @param s 目前已搜索到的最长公共子序列
 */
void sub(int i, int j, vector<string> &ss, string s) {
    if (i < 1 || j < 1) {   // 找到一条路径
        ss.push_back(s);
        return;
    }
    if (X[i - 1] == Y[j - 1])                       // 左上来的
        sub(i - 1, j - 1, ss, X[i - 1] + s);
    else if (L[i][j - 1] > L[i - 1][j])            // 左边来的
        sub(i, j - 1, ss, s);
    else if (L[i][j - 1] < L[i - 1][j])            // 上面来的
        sub(i - 1, j, ss, s);
    else sub(i, j - 1, ss, s), sub(i - 1, j, ss, s); // 左和上均可能
}

此外,用于记录 L [ i ] [ j ] L[i][j] L[i][j] 来自哪的二维数组 W W W 并非是必须的,二维数组 W W W 存储的信息可以从 L L L X X X Y Y Y 中推导出来,如果从节省空间的角度考虑,可以撤去二维数组 W W W(优化后用于获取所有最长公共子序列的函数 s u b ( ) sub() sub() 便没有用到 W W W)。

当不需要输出具体的最长公共子序列,而只需要求最长公共子序列的长度时,可用一滑动数组 v v v 替换掉数组 L L L。主要代码实现如下:

int main() {
	// 输入
	cin >> X >> Y;
	// 求解并输出
	cout << lonComSubSimply();
}
/**
 * 使用滑动数组求最长公共子序列长度
 * @return 最长公共子序列长度
 */
int lonComSubSimply() {
	// 滑动数组v记录当前行,用当前行依次更新下一行的每个单元,当前行初始为v[0...n]=0
	int m = X.length(), n = Y.length();
	vector<int> v(n + 1, 0);
	int lu, left, up;   // 下一行单元j的左上、左边、上面单元
	for (int i = 0; i < m; ++i) {
		lu = left = v[0];   // 下一行单元j的左上和左边初始为v[0],即0
		up = v[1];          // 上边为v[1]
		for (int j = 0; j < n; ++j) {
			// 更新v
			if (X[i] == Y[j])v[j + 1] = lu + 1;
			else if (left > up) v[j + 1] = left;
			else v[j + 1] = up;
			// 更新游标
			lu = up, up = v[j + 2], left = v[j + 1];
		}
	}
	return v[n];
}

时间复杂度: O ( m n ) O(mn) O(mn)

空间复杂度: O ( n ) O(n) O(n)

经验总结

动态规划法适用于子问题重叠的情况,即适合解决冗余,并且可以处理不满足贪心准则的问题,一般而言,采用动态规划法解决问题只需要多项式时间复杂度。但是,动态规划法没有统一的标准模型,需要根据问题具有的性质来进行处理,因此,不同问题得到的模型可能不太一样。并且,动态规划法需要存储在求解过程中得到的各种状态信息,因而需要占用较大的存储空间,空间复杂度较之于其它算法较高。动态规划法实质上是一种以空间换时间的技术。

虽然动态规划没有统一的标准模型,但是仍有一些固定的套路。动态规划问题通常有一个 d p dp dp 数组,用来表示所有状态即对应的值,于是,求解动态规划问题就转为定义 d p dp dp 数组和填充 d p dp dp 数组。

定义 d p dp dp 数组需要考虑维度和值。通常 d p dp dp 数组维度与表示一个状态所需变量个数相同,维度应该尽可能少, d p dp dp 数组的值通常为题目所求,如最大值、最小值、计数。

填充 d p dp dp 数组(假设二维,方便后面描述)需要考虑状态转移方程和边界值。得出状态转移方程前,需要分类讨论,即考虑在一般情况下(此时先不要管特殊情况,如边界值)的某个状态 d p [ i ] [ j ] dp[i][j] dp[i][j],可以通过哪些状态转换而来,分类依据通常可以考虑“包含和不包含”、“包含多少”、“方向”等,分类需要做到不遗漏、不重复(最值问题一般可以重复、计数问题一般不能重复),每种情况均需对应一个 d p [ x ] [ y ] dp[x][y] dp[x][y],最后根据 d p [ i ] [ j ] dp[i][j] dp[i][j] d p [ x ] [ y ] dp[x][y] dp[x][y] 的关系写出状态转移方程。边界值需要视情况而定,但通常用下标 0 0 0 来存放(一维的 d p [ 0 ] dp[0] dp[0]、二维的 d p [ 0 ] [ i ] dp[0][i] dp[0][i] d p [ i ] [ 0 ] dp[i][0] dp[i][0]、…),因而填充 d p dp dp 数组时从下标 1 1 1 开始。

对于 d p dp dp 数组,以二维为例,若计算第 i i i 行时,只用到 d p dp dp 数组的第 i − 1 i-1 i1 行和 i i i 行的值,则该二维数组可去掉第一维,转为一维数组。一般而言,填充 d p dp dp 数组需要用到两层循环,计算 d p [ i ] [ j ] dp[i][j] dp[i][j] i i i 从小到大, j j j 从小到大,转换之后, j j j 可能需要改为从大到小,这从状态转移方程很容易看出需不需要改变方向。

此外,对于不同类型的动态规划问题,也有一些针对性的“经验”。对于线性 D P DP DP,如最长公共子序列问题,划分时通常考虑“前多少”。对于区间 D P DP DP,如矩阵连乘问题,填充 d p dp dp 数组时,第一层循环通常用于枚举长度。对于计数类 D P DP DP,如整数划分问题,分类讨论时,必须做到各情况之间没有交集。对于树形 D P DP DP,如没有上司的舞会问题,通常使用递归。背包问题也是一类经典的DP问题,整数划分问题便可转为完全背包问题求解。

由于动态规划问题没有统一的标准模型,因此求解问题时更依赖于“经验”,所以更需要多看多练多总结,不断地增加“经验”,这样,在处理动态规划问题时才不会无从下手。

END

文章文档:公众号 字节幺零二四 回复关键字可获取本文文档。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值