很是完整的线性DP及记忆化搜索讲义

基础概念

咱们以前的课程当中接触了最基础的动态规划。
动态规划最重要的就是找到一个状态和状态转移方程。
除此以外,动态规划问题分析中还有一些重要性质,如:重叠子问题、最优子结构、无后效性等。c++

最优子结构 的概念:
1)若是问题的一个最优解包含了子问题的最优解,则该问题具备最优子结构。当一个问题具备最优子结构的时候,咱们就可能要用到动态规划(贪心策略也是有可能适用的)。算法

2)寻找最优子结构时,能够遵循一种共同的模式:数组

  • 问题的一个解能够是一个选择。例如,装配站选择问题。
  • 假设对一个给定的问题,已知的是一个能够致使最优解的选择。没必要关心如何肯定这个选择,假定他是已知的。
  • 在已知这个选择以后,要肯定那些子问题会随之发生,以及如何最好的描述所的获得的子问题空间。
  • 利用一种“剪贴”技术,来证实在问题的一个最优解中,使用的子问题的解自己也必须是最优的。

3)最优子结构在问题域中以两种方式变化:函数

  • 有多少个子问题被使用在原问题的一个最优解中,以及
  • 在决定一个最优解中使用那些子问题时有多少个选择

在装配线调度问题中,一个最优解只使用了一个子问题,可是,为肯定一个最优解,咱们必须考虑两种选择。优化

4)动态规划与贪心算法的区别spa

  • 动态规划以自底向上的方式来利用最优子结构。也就是说,首先找到子问题的最优解,解决的子问题,而后找到问题的一个最优解。寻找问题的一个最优解须要首先在子问题中作出选择,即选择用哪个来求解问题。问题解的代价一般是子问题的代价加上选择自己带来的开销。
  • 在贪心算法中是以自顶向下的方式使用最优子结构。贪心算法会先作选怎,在当时看来是最优的选择,而后在求解一个结果子问题,而不是现寻找子问题的最优解,而后再作选择。

重叠子问题 的概念:
适用于动态规划求解的最优化问题必须具备的第二个要素是子问题的空间要“很小”,也就是用来解原问题的递归算法能够反复的解一样的子问题,而不是总在产生新的子问题。
好比:状态 \(i\) 的求解和状态 \(i-1\) 有关,状态 \(i-1\) 的求解和状态 \(i-2\) 有关,那么当咱们计算获得状态 \(i\) 的时候,咱们就能够用 \(f[i]\) 来表示状态 \(i\) ,那么当我下一次须要用到状态 \(i\) 的时候,我直接返回 \(f[i]\) 便可。code

无后效性 的概念:
某阶段的状态一旦肯定,则此后过程的演变再也不受此前各类状态及决策的影响,简单的说,就是“将来与过去无关”,当前的状态是此前历史的一个完整总结,此前的历史只能经过当前的状态去影响过程将来的演变。具体地说,若是一个问题被划分各个阶段以后,阶段I中的状态只能由阶段I-1中的状态经过状态转移方程得来,与其它状态没有关系,特别是与未发生的状态没有关系。从图论的角度去考虑,若是把这个问题中的状态定义成图中的顶点,两个状态之间的转移定义为边,转移过程当中的权值增量定义为边的权值,则构成一个有向无环加权图,所以,这个图能够进行“拓扑排序”,至少能够按它们拓扑排序的顺序去划分阶段。blog

咱们在这篇讲义中主要讲解最基础的线性DP记忆记忆化解法。
其实咱们能够发现,若是一个搜索解法添加上了记忆化,那么他就解决了“最优子结构”和“重叠子问题”,就变成了一个递归版本的动态规划了。排序

说明:
接下来的例题当中咱们会讲解这些问题的for循环解法和记忆化搜索写法。
虽然for循环写法在咱们这节课当中写起来更方便且很好理解,可是但愿同窗们务必了解并掌握 记忆化搜索 写法,由于咱们接下来的几节课程会与记忆化搜索有很是重要的关系。递归

例题1 最长上升子序列

题目大意:
给你一个长度为 \(n\) 的数列 \(a_1,a_2, \cdots , a_n\) ,请你求出它的最长上升子序列的长度。
最长上升子序列:在不交换顺序的状况从序列 \(a\) 中选出一些元素(子序列不须要连续)使得前一个元素必然比后一个元素小。对应的最长的上升子序列就是最长上升子序列。
咱们通常简称“最长上升子序列”为 LIS(Longest Increasing Subsequence)。

解题思路:
设状态 \(f[i]\) 表示以 \(a_i\) 结尾(而且包含 \(a_i\))的最长上升子序列长度,则:

  • \(f[i]\) 至少为 \(1\)
  • \(f[i] = \max (f[j])\) + 1,其中 \(j\) 知足 \(0 \le j \lt i\)\(a[j] \lt a[i]\)

代码演示

首先咱们定义数组和一些必要的变量:

int n, a[1010], f[1010], ans;

其中:

  • \(n\) 表示数组元素个数;
  • \(a\) 数组用于存值, \(a[i]\) 表示数组第 \(i\) 个元素的值;
  • \(f\) 数组用于存状态, \(f[i]\) 表示以 \(a[i]\) 结尾的LIS长度;
  • \(ans\) 用于存放咱们最终的答案。

而后咱们处理输入:

cin >> n;
for (int i = 1; i <= n; i ++)
    cin >> a[i];

而后,咱们演示一下用for循环的方式实现求解 \(f[1]\)\(f[n]\)

for (int i = 1; i <= n; i ++) {
    f[i] = 1;
    for (int j = 1; j < i; j ++) {
        if (a[j] < a[i]) {
            f[i] = max(f[i], f[j]+1);
        }
    }
}

而后咱们的答案就是 \(f[i]\) 的最大值:

for (int i = 1; i <= n; i ++)
    ans = max(ans, f[i]);
cout << ans << endl;

那么,使用搜索+记忆化的方式怎么实现呢?以下:

int dfs(int i) {
    if (f[i]) return f[i];
    f[i] = 1;
    for (int j = 1; j < i; j ++)
        if (a[j] < a[i])
            f[i] = max(f[i], dfs(j)+1);
    return f[i];
}

记忆化搜索 又被称为 备忘录 ,而咱们这里的备忘录就是咱们的 \(f[i]\)

  • 若是 dfs(i) 是第一次被调用,\(f[i]=0\),会执行一系列的计算;
  • 可是若是 dfs(i) 不是第一次被调用,则必然 \(f[i] \gt 0\),因此 dfs(i) 会直接返回 \(f[i]\) 的值,这样就避免了子问题的重读计算。

因此我在函数的最开始就进行了判断:
若是 \(f[i]\) 不为零,则直接返回 \(f[i]\)
不然再进行计算。

而后,咱们在能够经过以下方式计算答案:

for (int i = 1; i <= n; i ++)
    ans = max(ans, dfs(i));
cout << ans << endl;

通常形式的完整代码:

#include <bits/stdc++.h>
using namespace std;
int n, a[1010], f[1010], ans;
int main() {
    cin >> n;
    for (int i = 1; i <= n; i ++) cin >> a[i];
    for (int i = 1; i <= n; i ++) {
        f[i] = 1;
        for (int j = 1; j < i; j ++) {
            if (a[j] < a[i])
                f[i] = max(f[i], f[j]+1);
        }
    }
    for (int i = 1; i <= n; i ++)
        ans = max(ans, f[i]);
    cout << ans << endl;
    return 0;
}

记忆化搜索形式的完整代码:

#include <bits/stdc++.h>
using namespace std;
int n, a[1010], f[1010], ans;
int dfs(int i) {
    if (f[i]) return f[i];
    f[i] = 1;
    for (int j = 1; j < i; j ++)
        if (a[j] < a[i])
            f[i] = max(f[i], dfs(j)+1);
    return f[i];
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i ++) cin >> a[i];
    for (int i = 1; i <= n; i ++)
        ans = max(ans, dfs(i));
    cout << ans << endl;
    return 0;
}

例题2 最大字段和

题目大意:
咱们能够把“字段”理解为“连续子序列”。
最大字段和问题就是求解全部连续子序列的和当中最大的那个和。

解题思路:
首先咱们定义状态 \(f[i]\) 表示以 \(a[i]\) 结尾(而且包含\(a[i]\))的最大字段和。
那么咱们能够获得状态转移方程:
\(f[i] = \max(f[i-1], 0) + a[i]\)

首先咱们初始化及输入的部分以下(坐标从\(1\)\(n\)):

int n, a[1010], f[1010], ans;
cin >> n;
for (int i = 1; i <= n; i ++) 
    cin >> a[i];

而后以通常方式求解的方式以下:

for (int i = 1; i <= n; i ++)
    f[i] = max(f[i-1], 0) + a[i];

而后咱们的答案就是全部 \(f[i]\) 的最大值:

for (int i = 1; i <= n; i ++)
    ans = max(ans, f[i]);
cout << ans << endl;

递归形式,咱们一样开一个函数 dfs(i) 用于返回 \(f[i]\) 的值。
可是这里咱们没法经过 \(f[i]\) 的值肯定 \(f[i]\) 是否已经求出来,因此我再开一个bool类型的 \(vis\) 数组,经过 \(vis[i]\) 来判断 \(f[i]\) 是否已经求过了。

bool vis[1010];

记忆化搜索实现以下:

int dfs(int i) {
    if (i == 0) return 0;   // 边界条件
    if (vis[i]) return f[i];
    vis[i] = true;
    return f[i] = max(dfs(i-1), 0) + a[i];
}

注意:搜索/递归必定要注意边界条件。

而后,答案的求解方式1以下:

ans = dfs(1);
for (int i = 2; i <= n; i ++)
    ans = max(ans, dfs(i));
cout << ans << endl;

答案的另外一种求解方式以下:

dfs(n);
ans = f[1];
for (int i = 2; i <= n; i ++)
    ans = max(ans, f[i]);
cout << ans << endl;

有没有发现,在这里我就调用了一次 \(dfs(n)\) ,全部的 \(f[i](1 \le i \le n)\) 的值就都求出来了呢。
由于我在第一次求 \(dfs(n)\) 的时候,会调用 \(dfs(n-1)\) ,而第一次 \(dfs(n-1)\) 会调用 \(dfs(n-2)\) ,……,第一次 \(dfs(2)\) 会调用 \(dfs(1)\)

因此调用一下 \(dfs(n)\) ,我就把全部的 \(f[i]\) 都求出来了。

通常形式的完整实现代码以下:

#include <bits/stdc++.h>
using namespace std;
int n, a[1010], f[1010], ans;
int main() {
    cin >> n;
    for (int i = 1; i <= n; i ++) cin >> a[i];
    for (int i = 1; i <= n; i ++)
        f[i] = max(f[i-1], 0) + a[i];
    for (int i = 1; i <= n; i ++)
        ans = max(ans, f[i]);
    cout << ans << endl;
    return 0;
}

记忆化搜索的完整实现代码以下:

#include <bits/stdc++.h>
using namespace std;
int n, a[1010], f[1010], ans;
bool vis[1010];
int dfs(int i) {
    if (i == 0) return 0;   // 边界条件
    if (vis[i]) return f[i];
    vis[i] = true;
    return f[i] = max(dfs(i-1), 0) + a[i];
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i ++) cin >> a[i];
    // ans = dfs(1);
    // for (int i = 2; i <= n; i ++)
    //     ans = max(ans, dfs(i));
    dfs(n);
    ans = f[1];
    for (int i = 1; i <= n; i ++)
        ans = max(ans, f[i]);
    cout << ans << endl;
    return 0;
}

例题3 数塔问题

题目大意:
有以下所示的数塔,要求从顶层走到底层,若每一步只能走到相邻的结点,则通过的结点的数字之和最大是多少?

解题思路:

首先咱们作一些假设:总共有 \(n\) 行,最上面的那行是第 \(1\) 行,最下面的那行是第 \(n\) 行,咱们用 \((i,j)\) 来表示第 \(i\) 行第 \(j\) 个格子,用 \(a[i][j]\) 表示 \((i,j)\) 格子存放的值。

咱们能够发现,从 \((i,j)\) 往下走走到最底层的最大数字和与从最下面的格子往上走走到 \((i,j)\) 的最大数字和是相等的。
因此咱们能够把问题变成:求从最底下任意一个格子往上走走到 (1,1) 的最大数字和。
能够发现,通过这样一下思惟的转换,咱们就把一个“自顶向上”的问题转换成了一个“自底向上”的问题。
(请好好体会 “自顶向下”“自底向上” 这两个概念,由于咱们这道题接下来还会在另外一个情景中讨论这两个概念)

咱们能够发现,除了最底层(第 \(i\) 层)是直接走到的意外,上层的全部 \((i,j)\) 不是从 \((i+1,j)\) 走上来的,就是从 \((i+1, j+1)\) 走上来的。

因此咱们不妨设 \(f[i][j]\) 表示从最底层任意一个位置往上走走到 \((i,j)\) 位置的最大数字和。

能够推导出:

  • \(i=n\) 时,\(f[i][j] = a[i][j]\)
  • \(i \lt n\) 时, \(f[i][j] = \max(f[i+1][j], f[i+1][j+1]) + a[i][j]\)

在推导的过程当中,记得从 \(n\)\(1\) 遍历 \(i\) ,由于高层的状态要先经过低层的状态推导出来。

通常形式的主要实现代码段以下:

for (int i = n; i >= 1; i --) { // 自底向上
    for (int j = 1; j <= n; j ++) {
        if (i == n)
            f[i][j] = a[i][j];
        else
            f[i][j] = max(f[i+1][j], f[i+1][j+1]) + a[i][j];
    }
}

能够发现,咱们采用通常形式的写法,是先求解较低层的转态,而后经过低层的转态推导出高层的状态,因此咱们也说这种实现思想是 自底向上 的。

讲完通常形式的实现方式,咱们再来说解使用 记忆化搜索 的形式进行求解的实现方式。

咱们一样仍是要定义一个状态 \(f[i][j]\) 表示从最底层任何一个位置走到 \((i,j)\) 的最大数字和(和上面的描述同样)。

可是咱们不是以上面的通常形式来求解 \(f[i][j]\) ,而是开一个函数 dfs(int i, int j) 来求解 \(f[i][j]\)

那么,咱们怎么样来进行 记忆化 :即:判断当前的 \(f[i][j]\) 已经访问过呢?

由于一开始 \(f[i][j]\) 均为 \(0\),若是全部的数塔中的元素 \(a[i][j]\)\(\gt 0\) ,那么 \(f[i][j]\) 一旦求过则 \(f[i][j]\) 必然也 \(\gt 0\)

可是若是 \(a[i][j] \ge 0\)(即 \(a[i][j]\) 能够为 \(0\))或者 \(a[i][j]\) 能够是负数的状况下,咱们就不能靠 \(f[i][j]\) 是否为 \(0\) 来判断 \((i,j)\) 这个格子有没有访问过了( 仔细思考一下为何 )。

因此最靠谱,最不容易错的方式就是跟采用跟例2同样的方式,开一个二维 \(vis\) 数组, 用 \(vis[i][j]\) 来标识 \((i, j)\) 是否访问过。

记忆化搜索形式的主要代码片断以下:

int dfs(int i, int j) { // dfs(i,j)用于计算并返回f[i][j]的值
    if (vis[i][j]) return f[i][j];
    vis[i][j] = true;
    if (i == n) // 边界条件——最底层
        return f[i][j] = a[i][j];
    return f[i][j] = max(dfs(i+1, j), dfs(i+1, j+1)) + a[i][j];
}

通常形式的完整代码以下:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 101;
int n, a[maxn][maxn], f[maxn][maxn];
int main() {
    cin >> n;
    for (int i = 1; i <= n; i ++)
        for (int j = 1; j <= i; j ++)
            cin >> a[i][j];
    for (int i = n; i >= 1; i --) { // 自底向上
        for (int j = 1; j <= n; j ++) {
            if (i == n) f[i][j] = a[i][j];
            else f[i][j] = max(f[i+1][j], f[i+1][j+1]) + a[i][j];
        }
    }
    cout << f[1][1] << endl;
    return 0;
}

记忆化搜索的完整代码以下:

#include <bits/stdc++.h>
using namespace std;
const int maxn = 101;
int n, a[maxn][maxn], f[maxn][maxn];
bool vis[maxn][maxn];
int dfs(int i, int j) { // dfs(i,j)用于计算并返回f[i][j]的值
    if (vis[i][j]) return f[i][j];
    vis[i][j] = true;
    if (i == n) // 边界条件——最底层
        return f[i][j] = a[i][j];
    return f[i][j] = max(dfs(i+1, j), dfs(i+1, j+1)) + a[i][j];
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i ++)
        for (int j = 1; j <= i; j ++)
            cin >> a[i][j];
    cout << dfs(1, 1) << endl;
    return 0;
}
相关文章
相关标签/搜索