动态规划-算法学习之路

这是我开始写博客的第一篇。以此纪念一下。

概述

动态规划(dynamic programming),首先不是一个特定的算法。它是一种思想,大部分的优化问题,都可以使用动态规划来解决。

优化问题是我们经常碰到的一类问题,很多情况我们都不知道如何下手解决,首先我们应该做的,是分解问题,看看能不能将这个问题分解成多个子问题。谈到问题分解,很多人都会联想到分治算法。分治算法是学会分解问题的第一步。而动态规划比分治更加抽象。和动态规划非常像的还有贪心算法,以后有机会再专门聊。

  • 联系:分治动态规划都需要将大问题分解成小问题,然后将小问题的解合并求原问题的解,比如归并排序、矩阵链乘法。
  • 区别:动态规划一般需要枚举所有子问题,但可以避免对同一子问题的重复计算,避免重复就是programming。

很多书籍和课堂老师提到动态规划,就会强调最优子结构。那么到底什么是最优子结构呢?答案很简单,也就是原问题最优解包含子问题的最优解,听起来很绕。意思就是你用一个大书包尽可能多的装东西,那里面一个小隔层也要尽可能多装。这样说的不太形象,主要是强调原问题包含子问题的最优解,而还有一个关键在于,分解的子问题要和原问题具有一样的结构。这样才能递归求解。

因此,我们可以总结。对于一个问题,如果问题可以规约,则分治大概可以解决问题。如果它还包含最优子结构的性质,那动态规划大概可以解决问题。

解决思路

要解决动态规划的问题,需要理解状态以及状态转移

  1. 定义状态
    状态就是将问题抽象成函数,比如0-1背包中,定义 d(i) ,表示前i个物品用背包装,最大能装的价值。体现算法功力的地方就是如何很好的定义状态,有些时候状态定义的好,问题答案直接就明了了。

  2. 状态转移
    定义好了状态,我们需要写出状态转移函数,也就是 d(i)=max{d(i1)...} ,也就是子问题决策或者转移的方法。比如0-1背包中的决定当前第 i 个物品放与不放,然后求最大。

动态规划关键在于如何定义状态 ,以及 状态转移。这个需要读者自己体会,通过做各种不同类型的题目去思考,得到自己的心得。

那前两部都顺利完成了,动态规划算法就可以直接写出来了吗?不对,我们还需要决定采用什么样的编程方法来解决问题。

两种方案:递归递推

  1. 递归(记忆化搜索)
    递归的方法也叫回溯法。这样的编程技巧在算法里面非常重要。而真正能够准确无误的写出递归程序,需要读者不断的练习。使用递归,特别需要注意两点:边界条件 剪枝。由于递归是方法里面调用本身,如果没有停止条件,程序无法终止,造成数组溢出,段溢出等等未知错误。剪枝 是为了提高时间复杂度的手段,没有剪枝,就类似普通的枚举,效率往往十分差,根源就在于子问题重复计算。

  2. 递推
    递推的方法,一开始都很难下手,因为它不贴近我们最优子结构的思考方式。一般的思路是,首先从小到大枚举子问题的长度;然后枚举这样的问题长度下所有的子问题,最后根据状态转移函数写出循环体。这样小的问题解决了,大的问题就可以使用小的问题的结果,也就是直接使用。

关于刷表法填表法

  1. 填表法
    对于每个状态,通过转移函数去计算。这种“正向”计算的方法是填表法。也就是说,我们要计算当前状态,需要找到依赖的所有状态,在某些情况下,这样效率不高,也不方便。
  2. 刷表法
    与“正向”计算相反,意思是对于当前状态,更新当前状态所影响到的状态。但需要特别注意的是,只有当每个状态所依赖的状态对它的影响相互独立才能使用刷表法。

问题结构分类

之前我们已经知道了什么样的情况使用动态规划,以及采用什么样的方法编写动态规划程序。但是遇到一个新的问题,我们常常知道需要用动态规划算法却无从下手,原因在于动态规划问题有很多种类。

接下来,我们需要对一般遇到的动态规划问题进行分类,以便于遇到了新的问题,能够快速分类,找到最快的解决方法。

  1. DAG
    DAG(directed acyclic graph),有向无环图。意思是子问题(状态)对应于节点,状态转移对应于图的边,而图的一条有向路径就对应着原问题的一个可行解。需要注意的两点是:明确起点和终点无环。无环的意思是从一个状态出发,不会再次回到这个状态,也就是状态是包含关系

    将一个原问题转化成DAG问题,最后解往往是求图上的最长路径最短路径、以及路径计数,这个时候我们还需要仔细看清楚最后解需不需要输出路径,是字典序,还是一个可行解就行。

  2. 多阶段决策
    多阶段决策是一类问题的特点。每次做一个决策,就得到了解的一部分,当所有决策做完之后,完整的解自然就出来了。类似于爬山,有很多路都可以通往高1000的山顶,但是不管哪条路,你都需要到达高500的山坡上。所以每次遍历当前决策的所有可行子问题。再继续决策,到了最后,自然你就知道哪条路上山最快。对应于图的话,可以说是BFS。

    在解答树里的层数,也就是递归函数中当前填充的位置,描述的是即将完成的决策序号,在动态规划中称为阶段

  3. 线性结构
    线性结构上的动态规划问题,主要是问题背景一般在字符串、整数序列上,比如两个串的编辑距离,当然还经常出现多边形。LIS(最长上升子序列)以及LCS(最长公共子序列)是两个非常经典的线性结构上的动态规划问题,希望读者有时间多了解了解。


  4. 树是一类非常有趣的结构。从子树来看,天然的将原问题划分成了相互不影响的子问题,非常适合动态规划解决。从问题规模来看,树的高度就决定了时间复杂度,只要能够运用剪枝等一系列技术,树能做到很多你想不到的事。

    树的最大独立集。对于一棵 n 个结点的无根树,选出尽可能多的结点,使得任何两个结点均不相邻。这个集合叫最大独力集。这里的无根树,意思是你可以将任何一个结点作为根,它是树的结构。问题解法就是对于当前结点,两种策略,选与不选。选的话,所有的儿子结点都不能选,子问题是孙子结点的最优解之和。不选的话,子问题是所有儿子结点的最优解之和。

    树的重心。对于一棵 n 个结点的无根树,找到一个点,使得当把该点作为树的根时,最大子树(结点数目最多)的结点树最小。换句话说,删除这个点后的最大连通块结点数目最少。

  5. 复杂结构
    复杂结构指的对于较难的问题,定义了状态,却发现状态无法转移。比如最优配对问题,对于当前点,你决策和之前的点配对,接下来就需要将你配对的点排除以后再求解,这样状态结构就变化了。对于这类问题,常见的就是增加维度,更细致的描述当前状态。比如增加集合 S ,表示在 S 集合里再如何如何。之所以定义 S ,是因为子集枚举用二进制表示非常方便。

举例

我们使用一个UVA上的题目来实战一下。

  1. 问题描述:(Uva1025)某城市的地铁是线性的,有n(2≤n≤50)个车站,从左到右编号为1~n。 有M1辆列车从第1站开始往右开,还有M2辆列车从第n站开始往左开。 在时刻0,Mario从第1站出发,目的是在时刻T(0≤T≤200)会见车站n的一个间谍。 在车站等车时容易被抓,所以她决定尽量躲在开动的火车上,让在车站等待的总时间尽量短。 列车靠站停车时间忽略不计,且Mario身手敏捷,即使两辆方向不同的列车在同一时间靠站,Mario也能完成换乘。

    《动态规划-算法学习之路》

  2. 思路: 这个问题首先明确一点,这个间谍需要从first station到 last station,得经过所有中间的点。因此我们可以将每个station就作为我们的状态,但是到了最后一个station问题就解决了吗?没有,这个间谍需要的是在T时刻到达last station的。所以我们可以将状态定义为 d(i,j) ,表示第 i 时刻处在第 j 个station,最少还需要等待多长时间。状态定义好了,接下来需要确定状态转移函数。想想当前如何决策。无非就三种(插一句,决策一定要严谨,不能遗漏情况):

    • 等待(根据单位,我们等1分钟)
    • 搭往右开的列车(如果有)
    • 搭往左开的列车(如果有)
  3. 代码(C++):

#define LOCAl
#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

#define INF 1<<30
const int maxn_n = 50 + 5;
const int maxn_t = 200 + 5;

int N, T, M1, M2;
int ans[maxn_n][maxn_t];
int have_train[maxn_n][maxn_t][2];
int t[maxn_n], d[maxn_n], e[maxn_n];

void pre_process();
void solve();

void pre_process() {
    for (int i = 1; i <= M1; i ++) {
        int time = d[i];
        for (int j = 0; j < N; j ++) {
            time += t[j];
            if (time <= T)
                have_train[j+1][time][0] = 1;
            else
                break;
        }
    }

    for (int i = 1; i <= M2; i ++) {
        int time = e[i];
        for (int j = N; j > 0; j --) {
            time += t[j];
            if (time <= T)
                have_train[j][time][1] = 1;
            else
                break;
        }
    }
}

void solve() {
    for (int i = 1; i < N; i ++) {
        ans[i][T] = INF;
    }

    ans[N][T] = 0;

    for (int i = T - 1; i >= 0; i --) {
        for (int j = 1; j <= N; j ++) {
            ans[j][i] = ans[j][i+1] + 1;
            if (j < N && have_train[j][i][0] && t[j] + i <= T) {
                ans[j][i] = min(ans[j][i], ans[j + 1][i + t[j]]);
            }

            if (j > 1 && have_train[j][i][1] && t[j - 1] + i <= T) {
                ans[j][i] = min(ans[j][i], ans[j - 1][i + t[j - 1]]);
            }
        }
    }
}

int main() {
    #ifdef LOCAl
        freopen("input.txt", "r", stdin);
        freopen("output.txt", "w", stdout);
    #endif // LOCAl

    int test_case = 1;
    while (scanf("%d%d", &N, &T) == 2 && N) {
        memset(have_train, 0, sizeof(have_train));
        memset(t, 0, sizeof(t));
        memset(d, 0, sizeof(d));
        memset(e, 0, sizeof(e));

        for (int i = 1; i < N; i ++) scanf("%d", t + i);
        scanf("%d", &M1);
        for (int i = 1; i <= M1; i ++) scanf("%d", d + i);
        scanf("%d", &M2);
        for (int i = 1; i <= M2; i ++) scanf("%d", e + i);

        pre_process();
        solve();
        if (ans[1][0] < INF)
            printf("Case Number %d: %d\n", test_case ++, ans[1][0]);
        else
            printf("Case Number %d: impossible\n", test_case ++);
    }
    return 0;
}

总结

通过以上几点的描述,我相信大家已经对动态规划算法有了初步的了解。但是看和做的效果是不一样的。真正理解,以及灵活运用动态规划求解问题需要实际编程。

刚开始写东西,如有错误,欢迎大家指正,一起进步!谢谢~

参考文献

[ 1 ]. 卜东波老师算法资料
[ 2 ]. 刘汝佳,算法竞赛入门经典(第2版),2014.

    原文作者:动态规划
    原文地址: https://blog.csdn.net/brighlee/article/details/62433275
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞