《Alogrithms》算法学习笔记——第六章:动态规划

动态规划

本讲讲的动态规划是一个更加具有普遍意义的算法。

在前几章中我们讲到了分治、图论、贪心,其实都是采用特殊的方法解决的特殊问题。而动态规划与线性规划是在之前的几种方法无法解决问题时,牺牲了一定程度效率而用于更加普遍解决问题的方法。

这里讲一下动态规划名字的意义与由来。

动态规划的英文是Dynamic programming,我一般简称为dp。我们很清楚里面有programming(程序)这个字眼,但为什么我们不叫程序而叫规划呢?因为在过去计算机代表执行人们定义好的步骤,也可以说是“计划”,所以无论是动态规划还是线性规划,规划的字眼都是来自于这里的。

6.1 dag(有向无环图)的最短路径

dag的最短路径,其实在第四章就已经实现了,我们采用的是拓扑排序+贪心实现的。而在本章我们将从另一个角度对这个问题进行描述。

首先,dag的显著特点就来自于他可以线性化,线性化带来的主要优势就是:

  • 在图中所有的箭头都指向该节点的后面。

换言之,我们想要知道一个节点的最短路径,只要知道他前面的节点的最短路径就好了。

仔细思考上面这句话,这个定义是对任何节点都成立的:

  • 第一个节点因为前面没有节点,所以自然为0;
  • 第二个节点如果在与第一个节点有连线的情况下自然就可以求出来,而且不会用到后面节点的数据;
  • 后面的每一个节点都以此类推,只会用到前面已经计算过的节点数据,所以如此向后延伸,一定能求解出每个节点的最短路径。

假设我们现在有这样的一个样例:

我们现在想求解D节点的最短路径,我们可以把他的最短路径的求解公式定义为:
d i s t ( D ) = m i n { d i s t ( B ) + 1 , d i s t ( C ) + 3 } dist(D) = min\{dist(B) + 1,dist(C) + 3\} dist(D)=min{dist(B)+1,dist(C)+3}
正如上面所说,只要知道 d i s t ( B ) dist(B) dist(B) d i s t ( C ) dist(C) dist(C)就可以求解了。所以我们可以把dag线性化对我们的帮助简称为:

  • 线性化的顺序帮助我们在计算节点 u u u时一定会包含节点 u u u所需的所有信息。

在代码中怎么实现呢?我们只需要知道每个节点的入度来自于哪里就行了,所以我们建立一张反图就能实现了。

根据以上dp方法的dag最短路径求解,我先把代码放出来:

/**
 * @brief dag图求解单源最短路径,O(|V|+|E|)
 * 
 * @param s 起点
 */
void dsp_dp(int s) {
    vector<int> tp_order = dag_tp_sort(); // 拓扑序数组
    memset(dist, INF, sizeof dist);
    memset(pre, 0, sizeof pre);

    dist[s] = 0;
    for (int u: tp_order)
        for (auto item: GR[u]) {
            int v = item.v, w = item.w;
            if (dist[v] + w < dist[u]) {
                dist[u] = dist[v] + w;
                pre[u] = v;
            }
        }
}

如果看了第四章笔记的话,会发现代码几乎没有任何差别。只是我们在这里遍历的是反图GR而已,所以和第四章的区别只是来源于思路。

通过dag的例子,我们可以对动态规划做一个基础的定义

  1. 在本例中我们发现每个节点的问题大小来源于子问题的数量,而本题的子问题就是入度节点的数量;
  2. 我们在代码中发现其实这个代码非常通用,根据改变我们的条件,可以变成求解最短路、最长路、乘积最短路等等等;
  3. 所以动态规划的根本思路是通过求解小问题并合并成大问题的解,直到解决全部问题;
  4. 在一般的代码构建中,我们也可以说是,动态规划最重要的就是递推公式,递推公式决定了我们的问题是怎么从小问题的解递推到大问题的解的。

6.2 最长子序列

在6.1中我们说过可以通过改变条件而求解各种问题,而本题就是求解最长路。

我们先对问题进行一个描述:

  • 我们有一个数组,里面有一串数字,我们现在要求解里面递增的最长子序列(子序列的定义是可以不连贯的数组子集)

如图所示,我们有一个数组,并且已经标注了最长子序列:

假设这个时候我们将数组的每个数作为一个节点,我们可以在节点之间建立一条递增的有向边,建立的边的条件为,当 i < j i<j i<j a i < a j a_i < a_j ai<aj。我们就可以把上图转化为这张图:

https://p.ipic.vip/urz3gh.png

很明显,这是dag。所以目标换言之就是寻找dag的最长路。

根据我们在6.1得出的特点,求解任意节点u的最长路的条件都来源于前任节点,所以可以通过相同方法进行求解。并且这里的计算时间于入度成正比,前面的dag中我们已知时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(V+E)。这里因为边都是自己构建的,边数根据图的情况可能很多,所以我们的时间复杂度最多为 O ( n 2 ) O(n^2) O(n2)

按照普通计算我们只会求解出长度,我们还想知道最长子序列的名字怎么办?很简单,和dag一样定义pre数组用于表示每个节点的前驱节点就好了。

补充:用递归嘛?不存在的

其实根据上面的描述,我们是可以通过递归求解的,但是我们并不会采用这种方法,为什么?我画张图就明白了:

在这里插入图片描述

我们会发现里面的重复计算也太多了!这样算下去就是花费指数时间了!所以我们不会用递归的。

但是换个问题,为什么我们在分治的时候就可以用递归而且很快,但这里不能用呢?

实际原因是因为,分治实则是将一个大问题拆分成很多个不相关的小问题并进行合并,但这里的重复率太高,所以不适合用这种思路进行求解。

那我们接下来看一下代码,下面的算法是直接通过数组实现的,看完代码再讲为什么不建图:

const int MAXN = 1e5 + 7;
int dist[MAXN], pre[MAXN]; // dp求解最大值、前导节点位置
/**
 * @brief 最长递增子序列(Longest Increasing Subsequences)求解,O(n^2)
 * 
 * @param nums 序列数组(空出0号位)
 * @return int 最长串终点位置下标
 */
int lis(const vector<int>& nums) {
    // 数组初始化
    memset(dist, 0, sizeof dist);
    memset(pre, 0, sizeof pre);

    // 循环dp求解
    int maxp = 0;
    for (int i = 1; i < nums.size(); i++) {
        dist[i] = 1;
        for (int j = 1; j < i; j++)
            if (nums[j] < nums[i] && dist[i] < dist[j] + 1) {
                dist[i] = dist[j] + 1;
                pre[i] = j;
            }
        if (dist[i] > dist[maxp]) maxp = i;
    }

    return maxp;
}

这里看似对当前节点之前的所有节点进行了遍历,增大了时间花费,因为我们如果有一张反图,可以直接找到所有对应的前驱节点。但是我们这是没有考虑到建图的时间花费,我们在一个空荡荡的数组上建立图形,不只是浪费空间,也需要花费到 O ( n 2 ) O(n^2) O(n2)的时间,所以并不值当。

6.3 编辑距离

编辑距离这个名字比较陌生,如果说完整一点,应该叫:通过编辑的次数求解出距离。

虽然这么说了,但其实可能还是不理解,那我们先对概念进行一个了解:

  1. 有的时候我们拼写单词可能会写错,但是我们到底错到了什么程度呢?这个程度应该这么定义呢?
  2. 这个时候我们就想要给两个不同的单词之间,规定一个“距离”,但是这个距离应该是什么样一个概念?
  3. 所以我们定义为:对两个个单词进行增删改,直到两个单词变得相同的最小次数。

所以编辑路径的名字中,“编辑”来源于增删改,“路径”来源于概念

那两个字符串怎么修改成一样呢?举个例子:

在这里插入图片描述

通常来说,我们采用笨方法,也许要枚举很多很多种可能性。所以这里我们引入动态规划进行解决。动态规划的核心是:子问题的定义、迭代的方法、初始值;接下来我们一个一个解决。


  • 定义子问题

解决一个字符串的问题,他的子问题一般来说,就应该是他的子串,为了演算方便,我们选择这个字符串的所有前缀(一定包含第一个字符的子串,子串一定是连续的,子序列才不连续)。所以我们定义一个数组dp[i][j],用于表示 x x x字符串前 i i i个字符组成的前缀与 y y y字符串前 j j j个字符组成的前缀。

如下表示的是dp[5][7]
[ E   X   P   O   N ]   E   N   T   I   A   L [ P   O   L   Y   N   O   M ]   I   A   L [E \ X \ P \ O \ N] \ E \ N \ T \ I \ A \ L \\ [P \ O \ L \ Y \ N \ O \ M] \ I \ A \ L [E X P O N] E N T I A L[P O L Y N O M] I A L


  • 迭代的方法

如果要判定迭代方案,按照我们定义的子问题,那必然是要确定新加进来的字符的情况。这个字符的匹配情况无非就是三种:
x [ i ] − o r − y [ i ] o r x [ i ] y [ i ] \begin{matrix} x[i] \\ - \end{matrix} \qquad or \qquad \begin{matrix} - \\ y[i] \end{matrix} \qquad or \qquad \begin{matrix} x[i] \\ y[i] \end{matrix} x[i]ory[i]orx[i]y[i]
前两种表示增或删,匹配不到的情况,如果匹配不上,那距离一定加 1 1 1。而第三种表示匹配上了,但这个时候可能匹配正确或错误,如果匹配错误距离就要加 1 1 1,如果匹配正确距离就不变。

按这个道理,我们只要在三种情况中,找选择后编辑距离最短的可能就行了,所以迭代公式可以写为:
d p [ i ] [ j ] = m i n { d p [ i − 1 ] [ j ] + 1 , d p [ i ] [ j − 1 ] + 1 , d p [ i − 1 ] [ j − 1 ] + ! e q u a l ( x [ i ] , y [ j ] ) } dp[i][j] = min\{dp[i - 1][j] + 1, \quad dp[i][j - 1] + 1, \quad dp[i - 1][j - 1] + !equal(x[i], y[j])\} dp[i][j]=min{dp[i1][j]+1,dp[i][j1]+1,dp[i1][j1]+!equal(x[i],y[j])}
迭代完了当然要考虑运算顺序,其实运算顺序没太大所谓,只要保证在公式左边的数据计算之前,把右边需要的条件都算出来就好了。所以双层循环的顺序并无所谓:


  • 初始值应该是什么

其实根据上面的图也能看出来,初始值那肯定是 i i i j j j 0 0 0的时候。其实很好理解,其中一方为 0 0 0的时候,不就全都不匹配了吗?所以dp[i][0] = i & dp[0][j] = j,因为肯定就是另一方的长度咯。


展示一下程序:

const int MAXN = 1e3 + 7;
int dp[MAXN][MAXN]; // 任意前缀字符串的编辑距离
/**
 * @brief 编辑距离
 * 
 * @param p 字符串1
 * @param q 字符串2
 * @return int p和q之间的编辑距离
 */
int edit_distance(string p, string q) {
    // 条件初始化
    for (int i = 0; i <= p.length(); i++) dp[0][i] = i;
    for (int i = 1; i <= q.length(); i++) dp[i][0] = i;

    // 递推(先列后行)
    for (int i = 1; i <= p.length(); i++)
        for (int j = 1; j <= q.length(); j++)
            dp[i][j] = min(min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + (p[i - 1] != q[j - 1] ? 1 : 0));
    
    return dp[p.length()][q.length()];
}

很简单吧,然后我们来解释一下底层的原理,其实任何的dp都是有dag的底层理解的,在本题中:

  • 节点:前缀表示的子问题
  • 有向边:迭代的条件

如果把dag画出来就是:

我们将图中的边的权值设计为 0 0 0 1 1 1,这样只要求出左上到右下的最短路就好啦。这张图我们给边冠以含义的话就是,向下的边表示删除,向右表示插入,对角线表示的就是替换 ( 1 ) (1) (1)或匹配成功 ( 0 ) (0) (0)

- 子问题的计算时间

我们常用的子问题复杂度多大呢?其实很简单:

  1. 如果是一维数组,且选取前缀子串: O ( n ) O(n) O(n)
  2. 如果是两个一位数组,且就选取两个数组的前缀子串: O ( n m ) O(nm) O(nm)
  3. 如果是选取子串但不是前缀: O ( n 2 ) O(n^2) O(n2);
  4. 以此类推

- 编辑距离的用处

在计算基因组学中,我们会对基因序列进行计算,我们可以通过编辑距离为基因之间的相似情况进行计算。

6.4 Knapsack背包问题

我们对背包问题先举个例子进行一个简单的描述:

  1. 现在有一个人要去进货,带了一个负重10kg的包,并且地上有一堆货物;

  2. 这堆货物分别为:
    KaTeX parse error: Can't use function '$' in math mode at position 59: …\ 1 & 6kg & $̲30 \\ 2 & 3k…

  3. 我们要想代码带上价值最多的东西走。

这个问题分为两种情况:货物可以重复、货物不能重复;但任何一种通过dp解决的时间复杂度都是 O ( n W ) O(nW) O(nW)。下面我们分两种情况进行解决。

6.4.1 完全背包(货物可以重复)

根据上面的问题,如果我们想要找到子问题可以从两方面下手:

  1. 因为背包的重量确定,我们将子问题拆分为不同大小的背包负重;
  2. 因为已知货物,将子问题拆分为拿物品的情况。

由于本题要求是货物重复,此时物品拿取情况无穷多,所以放弃2。

按照1的要求,我们定义一个数组dp[i]用于表示当背包重量为 i i i时,最高的价值。那 d p [ i ] dp[i] dp[i]怎么用更小的子问题去解决呢?所以我们给出递推公式:
d p [ w ] = max ⁡ i : w i ≤ w { d p [ w − w i ] + v i } dp[w] = \max_{i:w_i \leq w} \{dp[w - w_i] + v_i\} dp[w]=i:wiwmax{dp[wwi]+vi}
此时的dp[w]表示当背包大小为 w w w时的最大价值,右边表示所有比 w w w小的物品在背包里时的价值(也可以说是与 w w w相差 w i w_i wi d p dp dp值,加上某个物品时的价值)。

初始条件就很简单了,背包大小为0的时候,当然放不进东西,就是0咯。

看一下代码哈:

/**
 * @brief 完全背包问题
 * 
 * @param objs 物品数组:first为重量、second为价值
 * @param size 背包大小
 * @return int 背包可承载最大价值
 */
int knapspack_repetition(vector<pair<int, int>> objs, int size) {
    int dp[MAXSIZE]; // 当背包为i时的最大价值
    memset(dp, 0, sizeof dp);

    for (int w = 1; w <= size; w++)
        for (auto item: objs)
            if (item.first <= w)
                dp[w] = max(dp[w], dp[w - item.first] + item.second); // 加上当前这个物品

    return dp[size];
}

然后用dag对这道题进行底层理解的话,可以参考我画的这张图:

通过0和1的有向边可以看出来,我们将背包大小作为节点,货物重量作为有向边,货物价值作为权值,这样只要求解最短路径就好啦。

6.4.2 01背包(货物不能重复)

因为这里说明了货物不能重复,自然就不能完全采用上面的方法,因为我们在统计的时候没法知道前面的货物有没有装载。这个时候就要采用上面讲到的第二个子问题方案:因为已知货物,将子问题拆分为拿物品的情况(此时每个物品为1个)。

我们可以定义子问题为dp[i][j],用于表示在背包中装了i重量的货物,且只选择了前j项物品时的最大价值。

所以此时除了考虑完全背包的重量问题,还应该考虑到新加入的货物。我们将递推公式写为:
d p [ w ] [ j ] = m a x { d p [ w ] [ j − 1 ] , d p [ w − w j ] [ j − 1 ] + v j } dp[w][j] = max\{dp[w][j - 1], \quad dp[w - w_j][j - 1] + v_j\} dp[w][j]=max{dp[w][j1],dp[wwj][j1]+vj}
当然,条件是数组不越界,如果 w < w j w<w_j w<wj的时候,就是前面那一项了。其中 d p [ w − w j ] [ j − 1 ] dp[w - w_j][j - 1] dp[wwj][j1]表示当容量减少了 w j w_j wj,同时保证对第 j j j个元素进行操作的时候。

初始条件一样很简单,无论是背包大小为0,还是货物数量为0,毋庸置疑都带不走任何东西,所以价值为0。

在看代码之前,我画了一张图用于展示一下样例的过程:

可以发现,在二维表中,我们是一个一个对背包中的物品进行分配的。接下来再看代码:

/**
 * @brief 01背包问题
 * 
 * @param objs 物品数组:first为重量、second为价值
 * @param size 背包大小
 * @return int 背包可承载最大价值
 */
int knapspack_repetition(vector<pair<int, int>> objs, int size) {
    int dp[MAXSIZE][MAXN]; // 当背包为i、物品选择前j项时的最大价值
    memset(dp, 0, sizeof dp);

    for (int pos = 1; pos <= objs.size(); pos++)
        for (int w = 1; w <= size; w++) {
            int wpos = objs[pos - 1].first, vpos = objs[pos - 1].second;
            dp[w][pos] = wpos > w ? dp[w][pos - 1] : max(dp[w][pos - 1], dp[w - wpos][pos - 1] + vpos); // 加上当前这个物品
        }

    return dp[size][objs.size()];
}

补充:记忆化

其实在递归的过程中,有时会进行大量重复的计算,其实这些重复计算是可以避免的。我相信很简单就可以想到方案,其实就是把结果存下来。

这个时候把结果存到一张hash表中,就可以大幅加快计算速度。但是由于递归本身的时间消耗,还是会导致时间有所浪费。不过这也是一个很好的思路。

6.5 链式矩阵乘法

在第二章中我们曾今学过矩阵的乘法,有一说一,矩阵的乘法是真的非常的耗费时间,那链式矩阵乘法其实就是很多个矩阵相乘,我们希望减小整体的运算时间开销,这就是本节要解决的问题。

首先对基本条件进行一个定义:

  1. 我们将矩阵之间的乘法开销定义为进行了多少次加法运算,所以 a × b a \times b a×b的矩阵和 b × c b \times c b×c的矩阵相乘,矩阵乘法的开销记为 a ∗ b ∗ c a * b * c abc(因为 a ∗ c a*c ac表示最终矩阵的大小, b b b表示每个位置需要进行多少次加法运算);

  2. 矩阵乘法不满足交换律,但是满足结合律,我们可以在结合律上下手:
    P a r e n t h e s i z a t i o n C o s t c o m p u t a t i o n C o s t A × ( ( B × C ) × D ) 20 ∗ 1 ∗ 10 + 20 ∗ 10 ∗ 100 + 50 ∗ 20 ∗ 100 120200 ( A × ( B × C ) ) × D 20 ∗ 1 ∗ 10 + 50 ∗ 20 ∗ 10 + 50 ∗ 10 ∗ 100 60200 ( A × B ) × ( C × D ) 50 ∗ 20 ∗ 1 + 1 ∗ 10 ∗ 100 + 50 ∗ 1 ∗ 100 7000 \begin{matrix} Parenthesization & Cost computation & Cost \\ A \times ((B \times C) \times D) & 20*1*10+20*10*100+50*20*100 & 120200 \\ (A \times (B \times C)) \times D & 20*1*10+50*20*10+50*10*100 & 60200 \\ (A \times B) \times (C \times D) & 50*20*1+1*10*100+50*1*100 & 7000 \end{matrix} ParenthesizationA×((B×C)×D)(A×(B×C))×D(A×B)×(C×D)Costcomputation20110+2010100+502010020110+502010+501010050201+110100+501100Cost120200602007000
    由此可见,通过改变结合律的括号位置,确实可以有效改善运算效率;

  3. 同时补充一下:通过第二个选择 ( A × ( B × C ) ) × D (A \times (B \times C)) \times D (A×(B×C))×D中,我们可以看出来,通过朴素的选取最小花费的方式是无法求解此问题的。

这个时候我们就要换一种方法来对问题进行思考。

首先通过括号的方式,其实我们可以把这些矩阵的乘法看做一颗二叉树树,为什么是看做一棵树呢?因为都是两两结合,最后得到一整个公式嘛,如图所示:

在这里插入图片描述

当然我们不可能把所有可能的树结构都尝试一遍,但这个思路给我们提供了动态规划子问题的方案

  • 我们最后的树的根节点表示最后的结果公式;
  • 如果最终树的结果是最优解,那么树的每一颗子树实际上也是最优解(我也不知道为啥)。

虽然我不知道为啥,但是这样我们就找到了子问题,我们可以将任意一组子节点(因为是矩阵乘法,所以一定连续)作为子问题,我们将最优解定义为dp[i][j]用于表示在 i i i j j j(包含端点)节点之间的所有节点组成的最小开销。

最小子问题自然也就出来了:当只有一个节点的时候就是最小的时候,因为不用乘,所以开销为0。

递推的思路简单来说就是用第一层推出第二层,第二层推出第三层,以此类推。但是怎么推呢?我们从顶点假设,顶点一定是又两部分相乘组成的(从二叉树中也能看出来),那我们只需要将顶点分成两部分,一部分是 [ s t , m i d ] [st, mid] [st,mid],另一部分是 [ m i d , e d ] [mid, ed] [mid,ed]就可以了。其中 [ s t , m i d ] [st, mid] [st,mid]的开销可以通过 d p [ s t ] [ m i d ] dp[st][mid] dp[st][mid]表示, [ m i d , e d ] [mid, ed] [mid,ed]的开销通过 d p [ m i d ] [ e d ] dp[mid][ed] dp[mid][ed]表示,这两部分相乘的开销呢?其实就是: s t st st的行 × \times × m i d mid mid的列 × \times × e d ed ed的列。公式写为(其中mts表示矩阵数组):
d p [ s t ] [ e d ] = min ⁡ i ≤ k < j { d p [ s t ] [ m i d ] + d p [ m i d + 1 ] [ e d ] + m t s [ s t ] . r o w × m t s [ m i d ] . c o l u m n × m t s [ e d ] . c o l u m n } dp[st][ed] = \min_{i \leq k < j} \{ dp[st][mid] + dp[mid + 1][ed] + mts[st].row \times mts[mid].column \times mts[ed].column \} dp[st][ed]=ik<jmin{dp[st][mid]+dp[mid+1][ed]+mts[st].row×mts[mid].column×mts[ed].column}
知道了这三点就很简单了,直接看代码吧:

const int MAXN = 1e3 + 7;
struct matrix {
    int row, column; // 行数、列数
    vector<vector<int>> nums;
};
/**
 * @brief 计算链式矩阵乘法最小花费
 * 
 * @param mxs 矩阵数据
 * @return int 最小花费
 */
int chain_matrix_multiplication_cost(vector<matrix> mxs) {
    int dp[MAXN][MAXN]; // dp[i][j]表示区间[i,j]之间的矩阵乘积最小花费
    memset(dp, 0, sizeof dp);

    for (int len = 1; len < mxs.size(); len++) // 当前计算的区间长度
        for (int st = 0; st < mxs.size() - len; st++) {
            int ed = st + len;
            dp[st][ed] = dp[st][st] + dp[st + 1][ed] + mxs[st].row * mxs[st].column * mxs[ed].column; // 初始化为第一个值
            for (int mid = st; mid < ed; mid++) // 从第二个值开始循环
                dp[st][ed] = min(dp[st][ed], dp[st][mid] + dp[mid + 1][ed] + mxs[st].row * mxs[mid].column * mxs[ed].column);
        }

    return dp[0][mxs.size() - 1];
}

不难看出结果是 O ( n 3 ) O(n^3) O(n3),因为要对起点、终点、中值三个要素进行循环。

6.6 最短路径

本章与前面所学的单源最短路径不同,这张主要讨论的是各种最短靠谱路径的求解。比如本章主要会提到Floyd多源最短路径算法、旅行商问题动态规划解法。

学到这种问题主要有两个理由:

  1. 如果要求解任意两点的最短距离,想要更快的速度就需要提前计算下来;
  2. 现实情况比之前的Dijkstra算法复杂的多,可能不止需要考虑路长,还有别的因素(例如希望不要经过太多的城市)。

所以这里使用贪心算法的Dijkstra,就没办法求得所有的路径做判断。

6.6.1 Floyd多源最短路径算法

如果我们想要求解多源最短路径,主要有两个办法:

  1. 通过之前学过的Bellman—Ford算法进行n次计算(不用Dijkstra是因为可能有负边);
  2. 使用dp构建的Floyd-Warshall算法。

这里主要讲Floyd算法是怎么通过dp求解的,依旧是三要素:

  1. 构建子问题:想要求解任意两点的最短路径,我们就将两点之间的最短路径做为子问题,定义为dist[i][j],用于表示 i i i j j j之间的最短路径;

  2. 最小子问题:一步可到的两个点的距离(也就是初始的邻接矩阵);

  3. 递推公式:我们的问题是求解两个点之间的距离,所以我们能使用的条件也就是两个点之间的距离。我们最小的情况是两个相邻点的距离,次之就是相隔一个点之间的距离。通过已知的两点之间距离的条件,将大的问题分解成两个小问题:当前的最短路径是由哪两条比他小的最短路径构成的:
    d i s t [ l ] [ r ] = m i n { d i s t [ l ] [ m i d ] + d i s t [ m i d ] [ r ] } dist[l][r] = min\{ dist[l][mid] + dist[mid][r] \} dist[l][r]=min{dist[l][mid]+dist[mid][r]}

这其实非常简单所以我也不多讲了:

const int MAXN = 1e3;
const int INF = 0x3f3f3f3f;
int N, M; // 图的节点数量、边数量
int G[MAXN][MAXN]; // 图的邻接矩阵
int dist[MAXN][MAXN]; // 表示两个节点之间的最短距离
/**
 * @brief 多源最短路径
 * 
 */
void floyd(int G[MAXN][MAXN]) {
    for (int i = 1; i <= N; i++)
        for (int j = 1; j <= N; j++)
            dist[i][j] = G[i][j];
    for (int mid = 1; mid <= N; mid++)
        for (int l = 1; l <= N; l++)
            for (int r = 1; r <= N; r++)
                dist[l][r] = dist[r][l] = min(dist[l][r], dist[l][mid] + dist[mid][r]);
}

因为要循环两个顶点与中间点,所以时间复杂度为 O ( n 3 ) O(n^3) O(n3)

6.6.2 旅行商问题

作为经典的臭名昭著的问题之一,旅行商问题是非常滴消耗时间,我们先对问题进行一个描述:

  • 我们给定每两个城市之间的距离,求解只经过每个城市一次,并且在最后回到起点的最短路径。

这个问题假设有 n n n个城市,按照最蠢的方法也需要有 n ! n! n!次的运行时间(用枚举),但是我们在这里可以通过dp将其解决,不过依旧需要 O ( n 2 2 n ) O(n^22^n) O(n22n)的时间。同时很大概率上,这个问题是没有多项式时间上的解的,不过还尚未证明。

dp三要素:

  1. 构建子问题:我们要求解 n n n个点的旅行商最短路径,自然而然会相当求 n − 1 , n − 2 n-1, n-2 n1,n2个点的旅行商最短路径。同时为了知道这条路径的行进情况,我们需要知道起点或终点是谁。
    本书将子问题定义为 d i s t [ V ] [ j ] dist[V][j] dist[V][j],用于表示在已经走过的节点集合为 V V V、其中终点为 j j j时的最短路径。
    但由于不易于构建数据结构所以本文将其定义为 d i s t [ s ] [ V ] dist[s][V] dist[s][V],用于表示当起点为 s s s,且后续节点集合为V时的最短路径。

  2. 最大问题与最小子问题:这里将最大问题是因为这里比较特殊,所以强调一下。
    d i s t [ V ] [ j ] dist[V][j] dist[V][j]的最大问题是 min ⁡ 0 ≤ j < N { d i s t [ a l l ] [ j ] } \min_{0 \leq j < N} \{dist[all][j]\} min0j<N{dist[all][j]},最小问题是 d i s t [ { j } ] [ j ] dist[\{j\}][j] dist[{j}][j](表示只有一个节点时候的情况,其实这种情况大部分不存在,因为只有一个点,起点都不一定在这里面,所以实际上设计的是起点到点 j j j的距离,因为最后要回到起点,所以正好可以加上)。
    d i s t [ s ] [ V ] dist[s][V] dist[s][V]的最大问题是 d i s t [ s ] [ V − { s } ] dist[s][V - \{s\}] dist[s][V{s}],最小问题是 d i s t [ i ] [ 0 ] dist[i][0] dist[i][0](道理同上)。

  3. 递推公式:每一个更大一级的问题自然可以通过比起小一级的子问题推出来,我们只要选择其没有选择其中某个节点时的最小值就可以了。
    如果是书上的就是:
    d i s t [ V ] [ j ] = min ⁡ i ∈ V , i ≠ j { d i s t [ V − { j } ] [ i ] + G [ i ] [ j ] } dist[V][j] = \min_{i \in V, i \neq j} \{dist[V - \{j\}][i] + G[i][j] \} dist[V][j]=iV,i=jmin{dist[V{j}][i]+G[i][j]}
    那如果是本文的就是:
    d i s t [ s ] [ V ] = min ⁡ s 1 ∈ V { G [ s ] [ s 1 ] + d i s t [ s 1 ] [ V − { s 1 } ] } dist[s][V] = \min_{s1 \in V} \{ G[s][s1] + dist[s1][V - \{s1\}] \} dist[s][V]=s1Vmin{G[s][s1]+dist[s1][V{s1}]}

既然逻辑分析清楚了,代码还是不好写,因为代码怎么能拿集合放进数组的查找框呢?

我们都知道,假如有n个点,那么自然就会有 2 n 2^n 2n个集合,既然是要用在代码中,我们肯定是想着用数字代替,下面我给出一种通过二进制实现的代替方案(例中为4个点:

索引0123456789101112131415
集合 { ∅ } \{ \emptyset \} {} { 0 } \{ 0 \} {0} { 1 } \{ 1 \} {1} { 0 , 1 } \{ 0, 1 \} {0,1} { 2 } \{ 2 \} {2} { 0 , 2 } \{ 0, 2 \} {0,2} { 1 , 2 } \{ 1, 2 \} {1,2} { 0 , 1 , 2 } \{ 0, 1, 2 \} {0,1,2} { 3 } \{ 3 \} {3} { 0 , 3 } \{ 0, 3 \} {0,3} { 1 , 3 } \{ 1, 3 \} {1,3} { 0 , 1 , 3 } \{ 0, 1, 3 \} {0,1,3} { 2 , 3 } \{ 2, 3 \} {2,3} { 0 , 2 , 3 } \{ 0, 2, 3 \} {0,2,3} { 1 , 2 , 3 } \{ 1, 2, 3 \} {1,2,3} { 0 , 1 , 2 , 3 } \{ 0, 1, 2, 3 \} {0,1,2,3}
s = 0 s=0 s=0
s = 1 s=1 s=1
s = 2 s=2 s=2
s = 3 s=3 s=3

这个时候就要问了,是怎么替代的。

我们用 0 ~ n 0~n 0n每个数字用来替代二进制的一位, 0 0 0表示第 1 1 1位, n n n表示第 n + 1 n+1 n+1位,所以 { 0 , 1 , 2 } \{ 0, 1, 2 \} {0,1,2}可以表示为 11 1 2 111_2 1112 { 1 , 3 } \{ 1, 3 \} {1,3}可以表示为 101 0 2 1010_2 10102

那么又有一个问题来了,为什么要用这种方法或者说这样的集合顺序呢?我不能排列组合的放吗?

直接说结论就是不行。因为我们的集合顺序要保证在求解第 n n n项时,已经在前面的 n − 1 n-1 n1项中获得了所有子问题的解。那为什么这个顺序可以获得子问题的解呢?下面证明:

  • 在上面可以发现所有相同的最大数,一定相邻。这个很好说明,因为二进制在第n项为最大位时,一定会在今晚之前保持为1,此时在增加较小为大值;
  • 也就是说,在第 4 4 4位出来之前,我们已经把前 3 3 3位所有可能全部遍历完了;
  • 回忆我们的方程,求解第 n n n位,要求知道 n − 1 n-1 n1位的所有情况,这不就满足了吗。

接下来就比较简单了:

const int MAXN = 17;
const int INF = 0x3f3f3f3f;
int N, G[MAXN][MAXN]; // 图的大小、邻接矩阵
int dist[MAXN][1 << MAXN]; // 在起点为i,路径为集合j时的旅行商最短路
/**
 * @brief 旅行商问题dp求解
 * 
 * @param s 起点位置
 * @return int 
 */
int travel(int s) {
    memset(dist, INF, sizeof dist);
    for (int i = 0; i < N; i++) // 初始化只有一个节点的情况(V=0)
        dist[i][0] = G[i][s];
        
    int size = 1 << N;
    for (int V = 1; V < size; V++) if (!(V >> s & 1)) // 循环所有集合情况、跳过包含题目起点的集合
        for (int fi = 0; fi < N; fi++) if (!(V >> fi & 1)) // 循环所有节点作为起点
            for (int se = 0; se < N; se++) if (V >> se & 1) // 表示紧接着起点的第二个节点
                dist[fi][V] = min(dist[fi][V], G[fi][se] + dist[se][V ^ (1 << se)]); // 递推公式:其中V^(1 << se)表示在V中删除se

    return dist[s][(size - 1) ^ (1 << s)];
}

就是做三层循环:循环集合 O ( 2 n ) O(2^n) O(2n),循环起点 O ( n ) O(n) O(n),循环次起点 O ( n ) O(n) O(n)。时间主要在循环集合上,因为集合种类太多了,最终时间复杂度为 O ( 2 n n 2 ) O(2^nn^2) O(2nn2)

根据dp的特点其实也可以把路径输出来,因为每个大的问题是通过多个小的子问题求出来的,再算一遍比谁小就行:

/**
 * @brief 生成路径
 * 
 * @param s 起点
 * @return vector<int> 路径数组
 */
vector<int> travel_path(int s) {
    vector<int> ans; ans.push_back(s);

    int V = (1 << N) - 1;
    for (int i = 1; i < N; i++) {
        int now = ans[ans.size() - 1]; // 最后一个放进去的数
        V ^= 1 << now; // 删除已经求解过的节点

        int mpos = 0, mvalue = INF;
        for (int j = 0; j < N; j++) { // 遍历查找在集合中的节点
            if (V >> j & 1) {
                int v = G[now][j] + dist[j][V ^ (1 << j)];
                if (v < mvalue) {
                    mpos = j;
                    mvalue = v;
                }
            }
        }

        ans.push_back(mpos);
    }

    ans.push_back(s);
    return ans;
}

不难看出时间复杂度就是 O ( n 2 ) O(n^2) O(n2)

6.7 树的独立集

首先从树的独立集的定义开始:

  • 假设有一个顶点集合V,他的一个子集S中任意两个节点中间没有连线的话,这个集合S称作一个独立集。

这个定义很简单,其实独立集的概念如果放在图中也是一个很难解决的问题,但如果是一棵树,我们可以通过线性的时间快速求解。

dp三要素:

  1. 子问题:其实树的子问题非常简单明了,就是子树。之前的链式矩阵乘法很好的说明了这个问题,所以我们将树的根作为子问题,定义一个 d p [ i ] dp[i] dp[i]用于表示以 i i i为根节点的子树的最大独立集数量;

  2. 最小子问题:那当然就是叶子节点啦,而且值为1,因为只有自己一个;

  3. 递推公式:递推就是把大问题拆分成小问题,或者说是把小问题合体成大问题。作为一个根节点本身来说只有两种情况,要么是独立集、要么不是独立集。所以我们把情况就分成两种:

    1. 是独立集:就是孙子节点之和(因为相邻,子节点不能是独立集);
    2. 不是独立集:就是子节点之和;
    3. 树能这样做,就是因为子树互不相干,这也是最大的优点。

    于是我们得出的公式可以写作:
    d p [ u ] = max ⁡ { 1 + ∑ g r a n d c h i l d r e n   q   o f   u d p [ q ] , ∑ c h i l d r e n   w   o f   u d p [ w ] } dp[u] = \max \left\{ 1 + \sum_{grandchildren \ q \ of \ u} dp[q], \sum_{children \ w \ of \ u} dp[w] \right\} dp[u]=max 1+grandchildren q of udp[q],children w of udp[w]

应为根节点就是每个节点自身,所以时间线性为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(V+E)

6.8 归纳&寄语

动态规划是我耗费时间最多的一章,本章作为普适性的解法在编码上与理解上都有一点难度。

首先是dp的原理与底层逻辑已经无数次的提到了,就是由最优子结构(子问题)所产生的一张dag图,其中节点就是子问题,连线就是递推关系。求解dag的最短、最长、乘积最长等问题的时候,毋庸置疑都是在利用这三点。

所以可以明白dp的核心就是:

  • 明确dp子问题逻辑与表达方式;
  • 确定小问题与大问题之间的求解关系,从而产生递推公式;
  • 为了用代码计算,明确最底层子问题的值,用于求解大问题。

所以其实dp只要找到了合理的表达方式,在编码上并不难的。

通过这本书的学习其实我对动态规划的理解深刻了不少,动态规划是简单的、便于理解的、方便的,我非常喜欢。但也是困难的、复杂的,因为明确这个结构并确立一个复杂难题的结构逻辑关系并不简单,可能也需要证明、推导。

本书的学习就到此为止了,如果有机会我会阅读后面的几章。通过这六章的学习我学到了许多基础的算法,其中最让我喜欢而且影响深刻的就是dp,我以前就很喜欢,我可以通过寻找逻辑关系去分析问题之间的逻辑关系。但现在对dp的底层理解之后,更让我感受到解决问题的核心步骤无论是从顶向下或是从低向上,都是一种逻辑关系的推导,让人欲罢不能。

感谢我的导师让我通过本书对这些基础算法有了本质上的学习。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值