算法工程师面试必选项:动态规划
AI编辑:我是小将
对很多问题,动态规划(Dynamic Programming)是个强有力的武器,因为大部分情况下它可以大大降低算法的时间复杂度。动态规划的一个重要的应用是在满足最优性原理的优化问题,所谓最优性原理指的是问题的一个最优解总是包含子问题的最优解,但这并不是说所有子问题的最优解都对最终解做贡献。动态规划与分治法(Divide-and-Conquer)策略比较类似,都是将一个问题分解成子问题,但是分治法一般用于独立子问题的情形,就是说子问题之间没有重叠,而动态规划对子问题重叠的情形特别有效。如同分治法一样,动态规划是一种通用性的方法,所以我们还是从例子出发来深刻理解动态规划。
从斐波那契数列说起
斐波那契数列数列大家再熟悉不过了,其满足以下递推式:
递归式已经给出了,我们很容易使用递归的方式来实现计算斐波那契数列第 项的值:
// 使用递归的方式计算斐波那契数列
int fib(int n)
{
if (n <= 2) return 1;
return fib(n-1) + fib(n-2);
}
代码是那么的简洁,但是当你计算的 非常大时,你发现计算速度会很慢。为什么呢?我们从函数的实现可以看到,每次计算 时,我们其实将其分解成两个子问题:计算 和 。然后合并两个子问题得到原问题的解。仔细一看,这是分治法的思想。但是用到这里就存在了问题,因为两个子问题可能有交叉,比如你要计算 ,你需要计算 和 ,但是计算两者都会需要 ,所以会有很多子问题被重复计算了。这当然会大大降低效率。说点题外话,分治法的一个典型应用是归并排序,但是划分的两个子数组排序是互不影响的,两者也没有任何交集,最后直接合并两个有序数组就可以了。但是对于斐波那契数列问题,分治法会重复计算一些子问题,这是非常低效的。考虑到这点,我们可以使用一个数组表,保存已经计算过的个子问题的解,一旦递归需要子问题,我们首先去表中查找,一旦发现已经计算过,就重复利用这个结果。基于这种思想,我们改进了上面的算法:
// table是大小为n+1的数组,为了利用已经计算的结果
int fib(int n, vector<int>& table)
{
// 重复利用
if (table[n] != 0) return table[n];
// 无,那就要将结果保存至table
if (n <= 2)
{
table[n] = 1;
}
else
{
table[n] = fib(n - 1, table) + fib(n - 2, table);
}
return table[n];
}
利用一个查询表,我们可以避免很多子问题的重复计算。这就是动态规划的思想。但是有一点,我们采用了递归的方式,这意味着我们在设计方案时,采用的是“自上而下”的方式,这是分治法经常使用的方案:从原问题出发,拆分子问题,继续......,直到无法拆分。尽管看起来是自上而下,但是其实计算时还是先从小的实例开始。我们不禁会想,既然我们使用利用子问题的解来求解原问题,可不可以先从最小的实例开始,即直接采用“自下而上”的方案。完全没有问题,其实动态规划大部分都是采用“自下而上”的思路。这样做有两个好处,首先我们避免的代价较高的递归,因为可以采用循环的方式。其次,有时候我们可以减少内存的使用,因为可能不必存储所有子问题,子问题利用后就被丢弃,不会影响会面的计算。基于这种思路,我们给出了最终版本:
int fib(int n)
{
int a = 1, b = 1;
for (int i = 3; i <= n; ++i)
{
int tmp = b;
b += a;
a = tmp;
}
return b;
}
上面我们从最小的实例开始,利用循环的方式来高效地完成整个计算。可以看到动态规划的思路一般是:(1)确立一种递归关系,就是联系原问题与子问题解之间联系;(2)首先从最小实例开始,以自下而上的方式求解原问题。有时候,我们需要记录每个子问题的解,有时候却并不需要。其实采用“自下而上”的方式是优先考虑的方式,但是这并不代表你不可以采用“自上而下”的方案。
最大公共子序列
斐波那契数列毕竟过于简单,这里开始一个复杂一点的例子:最大公共子序列(Longest Common Subsequence,LCS)。其问题是给定两个序列 和 ,其长度分别为 和 ,求解其最大公共子序列的长度,子序列是指从原序列中任意去掉若干元素(不一定连续)而形成的序列。这个问题的一个应用实例是在基因序列匹配。这里我们假定两个序列都是字符串。比如: 为"ABAZDC",而 为"BACBAD"。那么可以得到其LCS为"ABAD",其长度为4。要使用动态规划来解决这个问题,首先我们要构造递归关系。假定 为序列 和 的LCS长度,那么我们是否可以使用更小的实例来求解 呢?更小的实例是指的是什么?我们不妨将序列减少一个长度,可能的子问题是。那么 是否与这些子问题有关呢?问题的关键是 与 的值,我们分两种情况考虑:
-
(1) ,此时相当于去掉 或者 ,其分别对应于求解 和 ,所以 可以取两者的最大值:
-
(3) ,此时两个元素匹配,而且它们两个正好匹配一定是最好的结果,假如你想让 匹配 之前的元素,那么 直接匹配 的结果一定不会差于这个结果。所以,最优情况是让两者匹配,那么 就依赖于 :
可以看到原问题总是可以利用子问题的解,这正好符合动态规划的原则。我们使用数组 保存各个子问题的解,然后采用自下而上的原则,从较小的实例出发,利用递归式不断向前计算,直到求得原问题的解。下面是具体的实现:
int lcs(const string& s, const string& t)
{
// 保存子问题最大公共子序列长度,初始化为0
vector<vector<int>> len(s.size() + 1, vector<int>(t.size() + 1, 0));
for (int i = 1; i <= s.size(); ++i)
{
for (int j = 1; j <= t.size(); ++j)
{
if (s[i - 1] == t[j - 1])
{
len[i][j] = len[i - 1][j - 1] + 1;
}
else
{
len[i][j] = max(len[i][j - 1], len[i - 1][j]);
}
}
}
return len[s.size()][t.size()];
}
可以看到上面的算法复杂度为 。但是有一点,上面只是计算出了LCS的长度,但是假如你想得到实际的LCS,那么该怎么办?其实可以从 入手,找到LCS。不过这次要从这个矩阵的最右下角的元素开始,我们去比较其与左边及上边元素,如果和其中任何一个元素相等,那么任选一个元素移动到那里(如果和两个元素都相等,说明最大公共子序列可能不止一个)。如果都不相等,那么将其移动到左上角位置,此时对应上面的情形2,并将这个位置的对应的元素输出。不断重复这个过程,程序将逆序输出LCS。具体的实现如下:
string lcs(const string& s, const string& t)
{
// 保存子问题最大公共子序列长度,初始化为0
vector<vector<int>> len(s.size() + 1, vector<int>(t.size() + 1, 0));
for (int i = 1; i <= s.size(); ++i)
{
for (int j = 1; j <= t.size(); ++j)
{
if (s[i - 1] == t[j - 1])
{
len[i][j] = len[i - 1][j - 1] + 1;
}
else
{
len[i][j] = max(len[i][j - 1], len[i - 1][j]);
}
}
}
// 计算lcs
string ls = "";
for (int i = s.size(), j = t.size(); i >= 1 && j >= 1; )
{
if (len[i][j] == len[i - 1][j])
{
--i;
}
else if (len[i][j] == len[i][j - 1])
{
--j;
}
else
{
ls += s[i-1];
--i;
--j;
}
}
reverse(ls.begin(), ls.end());
return ls;
}
采用“自下而上”的方式,我们总是从较小的实例开始,然后利用递归关系前进。算法是采用循环的方式来完成的。在循环时,一定要确保较小的实例先被计算出来。但是你同样可以选择“自上而下”的思维,那就是利用递归,因为有了递归关系,写出一个递归函数是那么地自然:
// 效率低下的递归
int recursionLcs(const string& s, int i, const string& t, int j)
{
// 边界条件
if (i == 0 || j == 0) return 0;
// 递归关系
if (s[i - 1] == t[j - 1])
{
return 1 + recursionLcs(s, i - 1, t, j - 1);
}
return max(recursionLcs(s, i, t, j - 1), recursionLcs(s, i - 1, t, j));
}
int lcs(const string& s, const string& t)
{
return recursionLcs(s, s.size(), t, t.size());
}
是不是很简单,但是这个效率很低下,时间复杂度是指数级的。因为会造成重复计算,所以还是要建立一个查询表,一旦较小的实例已经被计算,就放入这个表中,以供下次查询使用。所以,修改如下:
// 动态规划
int recursionLcs(const string& s, int i, const string& t, int j, vector<vector<int>>& table)
{
// 先进行查询 (-1代表没有计算过)
if (table[i][j] != -1) return table[i][j];
// 边界条件
if (i == 0 || j == 0) { table[i][j] = 0; }
// 递归关系
else if (s[i - 1] == t[j - 1])
{
table[i][j] = 1 + recursionLcs(s, i - 1, t, j - 1, table);
}
else
{
table[i][j] = max(recursionLcs(s, i, t, j - 1, table),
recursionLcs(s, i - 1, t, j, table));
}
return table[i][j];
}
int lcs(const string& s, const string& t)
{
vector<vector<int>> table(s.size() + 1,
vector<int>(t.size() + 1, -1));
return recursionLcs(s, s.size(), t, t.size(), table);
}
我们增加了一个查询表,从而避免相同子问题的重复计算,这有利于提升效率。这是“自上而下”式的动态规划,从本质上两者没有任何区别。前面说过,我们优先选择循环式的“自下而上”的设计。其实,这也不尽然。因为“自上而下”的方式有可能更高效。为什么呢?因为递归的过程中只会计算真正需要使用的子问题,但是“自下而上”的方式往往需要把所有子问题计算出来,因为大部分时候我们可能并不知道到底哪些子问题是后面计算需要的。孰优孰劣,很难说。看你自己的选择,不过还是优先推荐“自下而上”的策略。
背包问题
背包问题估计你不会陌生,因为提到它,就想到了动态规划。我们先来定义问题:假设有 件物品,记为 ,其中物品 的重量为 ,其价值为 ,同时假定你的背包的最大容量为 。现在让你做个选择,如何在保证不超过背包容量的前提下,使挑选的物品的总价值最大。一种最直观的方式是采用暴力穷举法,考虑所有的物品组合情况, 共有 个子集,所以暴力穷举法具有指数级时间复杂度。这有时候并不太现实。另外一种思路是采用贪婪策略,从最贵重的物品开始,直到背包容量不够。但是这同样有问题,因为如果贵重物品的重量与价值比很大,此时这种策略就会失效。考虑到这点,你可以先计算每个物品的单位重量价值,然后按此递减排序,应用贪婪策略选择物品。假如有 件物品,其重量分别为 ,对应的价值分别为 ,背包容量是 。按上面的策略,最终选择的物品为物品1和物品3,总价值为 。但是其实最优选择是物品2和物品3,总价值为 。看来贪婪算法要想得到最优解,还是有困难的。
那么我们考虑使用动态规划来解决背包问题,关键是看大问题可以不以利用子问题。假如 是 的最优子集,我们要想拆分问题,就必须要减少问题的维度,我们把最后一个物品 单独拿出来考虑,那么到底 是否含有这个物品?我们可以分情况考虑,假如不含这个物品,那么这个 相当于在 物品集中寻找最优背包 。但是如果含有物品 ,由于物品 的价值已经定了,所以相当于在 物品集中寻找最优背包 。所以,原问题可以重用两个子问题的解(取两种情况最大值)。而且子问题也很清楚了,你需要知道前 个物品的最优背包解,但是背包容量到底需要选择哪些,不清楚,需要根据原问题来推,但是肯定不会超过原有背包的容量 。所有我们可以计算所有的情况 。这样,我们不妨记 为前 个物品的最优背包解,背包容量是 。那么,递推关系为:
上面的递推式要特别注意容量约束。计算出 ,就是原问题的解,所以我们需要计算一个二维数组,大小为 。所以时间复杂度为 。直观看起来,时间复杂度肯定低于指数级。但是事实上有可能比暴力穷举法性能还差,考虑到 可能远远大于 ,那么谁优谁劣,就很难说了。不过,此时我们先采用“自下而上”的方式求解这个问题,就是采用循环的方式计算一个二维数组:
int knapsack(const vector<int>& w, const vector<int>& p, int W)
{
// 定义二维数组
vector<vector<int>> ops(w.size() + 1, vector<int>(W + 1, 0));
// 利用递推关系
for (int i = 1; i <= w.size(); ++i)
{
for (int j = 1; j <= W; ++j)
{
if (w[i - 1] <= j)
{
ops[i][j] = max(ops[i - 1][j], p[i - 1] + ops[i - 1][j - w[i - 1]]);
}
else
{
ops[i][j] = ops[i - 1][j];
}
}
}
return ops[w.size()][W];
}
两层循环,解决问题。但是上面只是计算出了背包最优解的最大价值,但是如果你想求得这个最优解到底包含哪些物品,你可以用与最大公共子序列类似的方法,从这个二维数组最低下开始,逆着前推。这里就不贴代码了,基本和上面是类似的思路。
“自下而上”的方式讲完了,但是就像前面说的,我们必须把所有的子问题都计算出来。但是其实,我们有时候并不需要所有的子例。这里我们以前面的那个例子来从 逆向计算,以判断到底需要计算哪些子问题。我们的原问题是要计算 ,要计算这个值,我们需要计算 以及 。继续推,要计算 ,我们要计算 和 。同样地,要计算 ,我们需要计算 以及 。所以我们实际上仅需要计算 项,而采用“自下而上”的策略,你需要计算 项。可以看到,我们不必要地计算出了很多子项。但是对于“自下而上”策略来说,这是无法避免的,因为你并不知道哪些子问题是原问题所需要的,所以只能采用最盲目的方式。但是我们可以采用“自上而下”的策略,通过递归来完成,此时在递归时,我们只会去计算会对原问题有用的子问题,这样就可以避免这个问题了。那么,我们通过递归方式来计算:
// i代表的是物品序号,W代表的是背包容量
int knapsack(const vector<int>& w, const vector<int>& p, int i, int W)
{
// 没有选择物品
if (i == 0) return 0;
// 递归关系
if (w[i - 1] > W) return knapsack(w, p, i - 1, W);
return max(knapsack(w, p, i - 1, W),
p[i - 1] + knapsack(w, p, i - 1, W - w[i - 1]));
}
有了递归关系,递归函数写起来总是那么简单。但是上面的递归没有利用查询表,这样会重复计算子问题。所以,我们还是要修改一下,但是此时我们不再使用二维数组作为查询表,因为实际上并不需要计算那么多子问题。我们可以使用关联容器来作为查询表:
// i代表的是物品序号,W代表的是背包容量
int knapsack(const vector<int>& w, const vector<int>& p,
int i, int W, map<pair<int, int>, int>& table)
{
pair<int, int> item{ i, W };
// 重复利用
if (table.find(item) != table.end())
{
return table[item];
}
// 没有选择物品
if (i == 0) { table[item] = 0; }
else if (w[i - 1] > W) { table[item] = knapsack(w, p, i - 1, W, table); }
else
{
table[item] = max(knapsack(w, p, i - 1, W, table),
p[i - 1] + knapsack(w, p, i - 1, W - w[i - 1], table));
}
return table[item];
}
我们使用上面的例子测试这个代码,会发现确实只是计算了需要用到的子问题:
int main()
{
map<pair<int,int>, int> table;
vector<int> w{ 5, 10, 20 };
vector<int> p{ 50, 60, 140 };
cout << knapsack(w, p, 3, 30, table) << endl;
for (auto& i : table)
{
cout << i.first.first << ',' << i.first.second << ": " << i.second << endl;
}
return 0;
}
输出:
200
0,0: 0
0,5: 0
0,10: 0
0,15: 0
0,20: 0
0,25: 0
0,30: 0
1,0: 0
1,10: 50
1,20: 50
1,30: 50
2,10: 60
2,30: 110
3,30: 200
此时,“自上而下”的递归实现就显得有优势,但是递归可能需要较深的栈。所以,还是视情况定吧。背包问题就说这么多了。
Floyd最短路径算法
这里我们介绍一个复杂一点的例子,就是最短路径算法。考虑这样的场景,当两个城市之间没有直飞的航班时,如何选择一个最短路线。要解决这个问题,我们首先要把它抽象出来,这就要涉及到图模型了。图(graph)由顶点(vertice)与边(edge)组成,所以我们一般将图模型用G(E,V)表示。这里,每个顶点可以代表一个城市,而边代表两个城市之间的路线,一般边会有权重值,比如代表距离,这时就称为有权图。我们可以用一个矩阵 来表示一个包含 个顶点的加权图,这个矩阵的每个元素为:
这种表示方法为邻接矩阵法。我们的问题是现在要找出每个顶点到其它顶点的最短路径。假如用 来表示从顶点 到顶点 的最短路径的长度,那么我们就是要计算出这个矩阵 。我们先从这个矩阵的一个元素开始,比如我们要求 。我们是否可以采用动态规划来求解 呢?就是我们想求顶点 到顶点 的最短路径,我们怎么能分解这个问题。考虑这样的情况,顶点 到顶点 的路径的所有情况是使用顶点集 ,即使用顶点集 作为顶点 到顶点 的路径的中间顶点。我们如果不断减少这个中间顶点集,那么问题规模会减小。这里我们将使用顶点集为 作为中间顶点时,顶点 到顶点 的最短路径长记为 。那么它与使用顶点集 作为中间顶点的最短路径长有没有关系呢?我们可以这样考虑,如果加了顶点 可作为中间顶点,可能会出现两种情况:
-
情况1:顶点 对顶点 到顶点 的最短路径没有帮助,即至少有一条最短路径(最短路径可能不止一条)可以不使用顶点 ,那么很显然,此时 ; -
情形2:顶点 对顶点 到顶点 的最短路径有帮助,即所有可能的最短路径一定包含顶点 ,也可以说是这个顶点成为了关键中间顶点,由于必须用这个顶点,此时最短路径就要看顶点 到顶点 的最短路径,以及顶点 到顶点 的最短路径,与两个子路径的最优路径相关,并且这些最短路径使用的顶点集为 ,这里要说明一下,这些最优子路径不可能使用顶点 ,为什么呢?因为 是两个子路径的端点。所以最短路径是两个最短子路径之和: 。
就这两种情况,所以可以取两种情况的最小值就是最短路径:
我们得到了递归关系,但是情况有点复杂。因为原来我们仅关注这个顶点 到顶点 的最短路径,递归关系式中明显要使用其他顶点之间的最短路径。但是这不是问题。因为我们可以采用”自下而上“的策略,先计算较小的实例。首先我们的 可以从最小值 开始计算,其实 就是 ,而且对每一个 ,我们先从最小的 和 开始计算。这样需要三层循环,所以算法复杂度为 。而且计算到最后,即 时, 就是顶点 到顶点 的最短路径的长度。具体的实现如下:
// 最短路径
void minPath(const vector<vector<int>>& W, vector<vector<int>>& D)
{
int n = W.size(); // 顶点总数
D = W; // 存储最短路径,此时是k=0的结果
// k = 1开始
for (int k = 1; k <= n; ++k)
{
for (int i = 0; i < n; ++i)
{
for (int j = 0; j < n; ++j)
{
D[i][j] = min(D[i][j], D[i][k - 1] + D[k - 1][j]);
}
}
}
}
一个运行实例为:
int main()
{
const int INFITY = 10000;
vector<vector<int>> W{ {0, 1, INFITY, 1, 5},
{9, 0, 3, 2, INFITY} ,
{INFITY, INFITY, 0, 4, INFITY},
{INFITY, INFITY, 2, 0, 3 },
{ 3, INFITY, INFITY, INFITY, 0 } };
vector<vector<int>> D;
minPath(W, D);
for (auto& i : D)
{
for (auto j : i)
{
cout << j << " ";
}
cout << '\n';
}
return 0;
}
输出:
0 1 3 1 4
8 0 3 2 5
10 11 0 4 7
6 7 2 0 3
3 4 6 4 0
但是如果想具体知道顶点 到顶点 的最短路径包含具体哪些中间顶点,你可以把这些中间顶点保存到一个数组中,然后利用递归的方式按顺序输出中间顶点,具体的实现如下:
// 最短路径
void minPath(const vector<vector<int>>& W, vector<vector<int>>& D,
vector<vector<int>>& P)
{
int n = W.size(); // 顶点总数
D = W; // 存储最短路径,此时是k=0的结果
// 初始化P:保存中间节点
P = vector<vector<int>>(n, vector<int>(n, 0));
// k = 1开始
for (int k = 1; k <= n; ++k)
{
for (int i = 0; i < n; ++i)
{
for (int j = 0; j < n; ++j)
{
if (D[i][j] > D[i][k - 1] + D[k - 1][j]) // 不要使用>=
{
P[i][j] = k;
D[i][j] = D[i][k - 1] + D[k - 1][j];
}
}
}
}
}
// 输出顶点start到顶点end最短路径
void printPath(const vector<vector<int>>& P, int start, int end)
{
if (P[start - 1][end - 1] != 0) // 有中间节点
{
// 先输出中间节点前的顶点
printPath(P, start, P[start - 1][end - 1]);
// 输出中间节点
cout << P[start - 1][end - 1] << "->";
// 输出中间节点后的顶点
printPath(P, P[start - 1][end - 1], end);
}
}
int main()
{
const int INFITY = 10000;
vector<vector<int>> W{ {0, 1, INFITY, 1, 5},
{9, 0, 3, 2, INFITY} ,
{INFITY, INFITY, 0, 4, INFITY},
{INFITY, INFITY, 2, 0, 3 },
{ 3, INFITY, INFITY, INFITY, 0 } };
vector<vector<int>> D;
vector<vector<int>> P;
minPath(W, D, P);
cout << "顶点5到顶点3的最短路径长为: " << D[4][3];
cout << ",最短路径为: 5->";
printPath(P, 5, 3);
cout << "3\n";
return 0;
}
输出:
顶点5到顶点3的最短路径长为: 4,最短路径为: 5->1->4->3
利用递归的方式,我们可以输出两个顶点之间的最短路径。但是,上面有一点要注意,就是在比较D[i][j]
与 D[i][k - 1] + D[k - 1][j]
时,不要使用等号,这不会影响D
的结果,但是会造成P
错误。因为当k-1==i
或者k-1==j
时,会取等号,但是此时没有意义,因为k
不再是中间节点,而是端点。所以根据P
利用递归方式输出路径就会有误。最后,这里我们采用“自下而上”的策略,那么使用"自上而下“的递归方式是否可以呢?大家不妨试试。。。不过我觉得“自下而上”的方式是最合适的,因为我们这里是求得所有顶点之间的最短路径,而且它们之间相关联,那么所有的子问题都是需要计算的。
最长递增子序列
最后一个例子是最长递增子序列(longest increasing subsequence , LIS),我们首先定义问题:给定一个整数序列 ,我们想找到其最长的递增子序列,一个递增子序列 要满足 且 。假如序列为 ,那么其最长递增子序列为 。一个需要的情况是最长递增子序列可能不唯一。如果我们采用暴力穷举法的话,那么时间复杂度为 。如果我们想使用动态规划的方法来解决这个问题,那么必须要将问题拆解。拆解倒是很简单:由序列 的最长递增子序列是否可以求解序列 的最长递增子序列。好像并没有直接关系。但是我们会想一下前面的最短路径问题,一旦我们选择了某个给定的中间顶点,那么最短路径只与两个子路径的最优解有关。这可以借鉴,我们先求出序列 以 结束的最长递增子序列,这里记为 ,那么我们有可能递推出 。因为 对应的最长递增子序列的右端点是 ,其前面的相邻元素可能是 中的一个,对于任何一种选择 ,我们都可以计算出 (这里第二个子序列固定为 ,要满足 ,故其最长递增子序列长度为1),我们不必纠结到底是是哪个,因为直接取所有情况的最大值就好了:
一旦我们计算出包含 个元素的一维数组 ,取其最大值就是原问题的最长递增子序列的长度。基于上面的想法,我们采用“自下而上”的方案实现:
int lis(const vector<int>& s)
{
vector<int> ls(s.size(), 0);
// 对于k=1
ls[0] = 1; // 只有一个元素
for (int i = 1; i < s.size(); ++i)
{
ls[i] = 1; // 仅含有自己
for (int j = 0; j < i; ++j)
{
if (s[j] <= s[i] && ls[i] < 1 + ls[j])
{
ls[i] = 1 + ls[j];
}
}
}
return *max_element(ls.begin(), ls.end());
}
实现包含两层循环,所以算法复杂度为 。如果我们想得到最长递增子序列,可以利用类似的想法,使用一个数组存储中间节点位置:
vector<int> lis(const vector<int>& s)
{
vector<int> ls(s.size(), 0);
vector<int> ps(s.size(), -1);
// 对于k=1
ls[0] = 1; // 只有一个元素
ps[0] = -1; // 无前接紧邻节点
for (int i = 1; i < s.size(); ++i)
{
ls[i] = 1; // 仅含有自己
ps[i] = 0; // 无前接紧邻节点
for (int j = 0; j < i; ++j)
{
if (s[j] <= s[i] && ls[i] < 1 + ls[j])
{
ls[i] = 1 + ls[j];
ps[i] = j;
}
}
}
int pos = max_element(ls.begin(), ls.end()) - ls.begin(); // lis的最后一个元素位置
vector<int> result;
result.push_back(s[pos]);
while (ps[pos] != -1)
{
pos = ps[pos];
result.push_back(s[pos]);
}
reverse(result.begin(), result.end());
return result;
}
上面的算法复杂度为 ,其实我们有比这个效率更高的解法。让我们回顾一下前面的递归关系,当我们计算 时,我们要把所有的情况都要遍历一遍,因为并不知道哪种情况是最优的。假如序列 的LIS长度为 ,那么如果我们知道这个序列中长度分别为 递增子序列末尾元素的最小值,这里递增子序列长度为 的末尾元素最小值记为 。当你加入元素 时,如果 ,那么说 可以直接加入这个LIS为 的序列后面,此时对于序列 其最大LIS为 ,且 。如果 ,你需要遍历 ,直到找到第一个满足 所对应的 ,然后更新 ,这时我们更新只是维持数组 的特性,这样后面继续加入新元素,可以重复前面的过程,但是其实元素 的加入并不会为当前整个序列的LIS做贡献。大家可能会想,这样操作其实也需要两层循环。但是我们要注意一个细节,那就是 其实是非递减序列,即 ,所以内部遍历时,我们可以使用二分搜索法,这样算法的最终复杂度为 。但是要注意,最后的 序列不一定是一个最长递增子序列,准确地说, 序列的每个元素 指的是长度为 的递增子序列的最后一个元素的最小值。但是 序列的长度是等于最大递增子序列的长度。具体的实现如下:
vector<int> lis(const vector<int>& s)
{
vector<int> m;
// 初始化
m.push_back(s[0]);
for (int i = 1; i < s.size(); ++i)
{
// lis增加1
if (s[i] >= m.back())
{
m.push_back(s[i]);
}
else
{
// 利用lower_bound函数找到第一个大于或等于s[i]的位置
*lower_bound(m.begin(), m.end(), s[i]) = s[i];
}
}
return m;
}
这种处理很巧妙,看来动态规划灵活的地方太多了。
后记
动态规划只是一种思想,并无定型,这是我的感受。网上有人说递推关系不是动态规划的本质,说动态规划的本质是状态转移方程。其实感觉还是一回事。递推式也罢,状态转移方程也好。要想使用动态规划,你需要首先学会分解问题,然后想着如何利用分解的问题解来计算当前解,当然最终的目的是提升效率。还有一点,就是两种思维模式:“自下而上”与“自下而上”。还是多多练习,才是王道。
References
[1] Mark Allen Weiss, Data Structures and Algorithm Analysis in C++, fourth edition, 2013.
[2] Richard E. Neapolitan, Foundations of Algorithms, fifth edition, 2016.
[3] Dynamic Programming 笔记.
[4] 渡部有隆(日)著, 支鹏浩 译,挑战程序设计竞赛2:算法和数据结构, 2016.
推荐阅读
机器学习算法工程师