转自:http://janfan.cn/chinese/2015/01/21/dynamic-programming.html

动态规划(Dynamic Programming,以下简称dp)是算法设计学习中的一道槛,适用范围广,但不易掌握。

笔者也是一直不能很好地掌握dp的法门,于是这个寒假我系统地按着LRJ的《算法竞赛入门经典》来学习算法,对dp有了一个比过往都更系统\更深入的理解,并在这里写出来与大家分享。

笔者着重描述的是从穷举到dp的算法演进,并从中获取dp解法的思路,并给出多种思考的角度,务求解决的是LRJ提出的一种现象:

“每次碰到新题自己都想不出来,但一看题解就懂”的尴尬情况。

DP与穷举

大多讲dp的文章都是以0-1背包为基础来讲解的,笔者想换个花样,以另一道题“划分硬币”(UVa-562 Dividing coins)来讲述。

现在有一袋硬币,里面最多有100个硬币,面值区间为[1, 500],要分给两个人,并使得他们所获得的金钱总额之差最小,并给出这个最小差值。

这种问题笔者称之为二叉树选择问题。 假设袋中有N个硬币,我们从最原始的枚举法来一步步优化。

我们让其中一个人先挑硬币,挑剩的就是给另外一个人的。 第一个人对于每一个硬币,都有“选”和“不选”两种选择。 我们很容易穷举他全部的情况——全部硬币的任意大小的子集,共2N

种,并选取其中两人差值最小的解。

具体作法可以用递归法,二进制法等。 在这里给出笔者的递归解法的代码,因为它与后面的优化紧密关联。

int solve_by_brute_force(vector<int> &v, int cur, int limit, int sofar, int sum) {
    if (cur == limit) { // the border of recursion
        int other = sum - sofar;
        return sofar>other? sofar-other : other-sofar;
    }

// choose or not the current coin
    int ans1 = solve_by_brute_force(v, cur+1, limit, sofar+v[cur], sum);
    int ans2 = solve_by_brute_force(v, cur+1, limit, sofar, sum);
    return ans1 < ans2? ans1 : ans2;
}

int main(){
    int t;
    cin >> t;
    for (int nc= 0; nc<t; nc++) {
        int m;
        cin >> m;
        vector<int> v(m);
        int tot = 0;
        for (int i= 0; i<m; i++) {
            cin >> v[i];
            tot += v[i];
        }
        int res = solve_by_brute_force(v, 0, m, 0, tot);
        cout << res << endl;
    }
    return 0;
}

O(2N)

的指数复杂度始终是撑不过去的,我们需要优化,但从哪里开始优化呢?

我们以测试样例“2 3 5”来看看,下图是样例的穷举解答树,左树为不选当前硬币,右树为选入,叶结点为第一个人的硬币总额。

root
          /    \
 2       /      \
        /\      /\
 3     /  \    /  \
      /\  /\  /\  /\
 5   /  \/  \/  \/  \
    0   5        5  2+3+5

可以看到,叶结点中有两个相同的值,思考一下,如果再多加一个硬币(解答树变成4层),相同的叶结点会引起无谓的重复计算,造成浪费。

我们从1到N逐个做硬币的选择决策

以树的结构来记录当前硬币的累计金钱(中间结果)是低效的,因为它允许存在值相同的结点。 我们思考,那么我们何不换一种方式来记录中间结果呢?我们何不采用数组的方式?假设一袋硬币存在int Coins[]数组中,中间结果的范围是可求的,区间为[0, Coins[0]+Coins[1]+..+Coins[cur]],或者干脆是全部硬币的总和[0,SUM]

以树分叉的方式来做决策也是可以改变的。改变中间结果的记录方式后,我们可以这样来递推: 假设有一个二维数组bool mat[N+1][SUM+1],第一维表示当前已选择到第几个硬币,第二维表示中间结果,比如mat[3][10]表示在“2, 3, 5”样例中,我们选择到第3个硬币(最后一个),并且中间结果(正好是最后的叶结点)为10的情况,如果为true,则这种情况成立,是存在的,否则不存在,比如mat[3][9],你是拼不出和为9的情况的。 这时递推公式变为mat[cur][i] |= mat[cur-1][i-Coins[cur]],我们只要在每个硬币(每层)把i在区间[0,SUM]

枚举一遍就可以了。

这个方法的完整代码:

const int MAX_SUM = 100000;
bool arr[MAX_SUM];

int solve_by_dp(vector<int> &v, int sum) {
    fill(arr, arr+MAX_SUM, false);
    arr[0] = true;
    for (int i= 0; i<v.size(); i++) {
        for (int j= v[i]; j<=sum; j++) {
            if (arr[j-v[i]])
                arr[j] = true;
        }
    }
    int mid = sum/2;
    for (int i=0; i<=mid; i++) {
        int one = mid-i;
        if (arr[one]) {
            int other = sum - one;
            return other>one? other-one : one-other;
        }
    }
    return -1;  // never been here
}

这样复杂度变为\(O(NSUM)\),即\(O(500N^2)\),从指数降到多项式了。 而空间复杂度,考虑到可以当前硬币的中间结果,只与上一层有关,我们使用滚动数组的方法就可以避免开一个二维数组的耗费了。

其实中间数组的每个元素,就是dp中的“状态”。 对dp的理解首先要从枚举开始,发现穷举过程中做无用功的地方,改变记录和递推的方式去优化它。 关于格举的解答树和dp的状态之间的关系,LRJ有一个很好的总结。

状态及其转移类似于回溯法听解答树。解答树中的“层数”,也就是递归函数中的“当前填充位置”cur,描述的是即将完成的决策序号,在动态规划中被称为“阶段”。

如何寻找DP的思路

在做题的时候,我有意识地注意对自己dp的思路寻找过程,而不满足于单纯地套模板。

我往往先考虑最朴素的穷举思路,并努力发现隐藏其中的“最优子结构”,寻找子问题的递推关系。 我相信这也是LRJ在书中按“穷举-分治-dp”的顺序编排章节的原因。
LCS问题(Longest Common Sequence)

比如一个典型的dp问题——LCS(Longest Common Sequence),求两个字符串的最长公共子串(不要求连续),比如这道题UVA - 10405。

假设两个字符串为a, b,最后结果为子串s,按枚举的思路,对a的每一个字符进行枚举,假设它作为公共子串的首字符,并在b中从左往右寻找与它相同的字符,假设a[i]==b[j],这样就得出子问题——现在问题变成了求a, b匹配字符的后缀的LCS,即lcs(a+i+1, b+j+1)。 文字描述不仔细,请看看代码。

// example
// string a = “a1b2c3d4e”, b = “zz1yy2xx3ww4vv”;
// cout << lcs(a, b, 0, 0, 0);
int lcs(string &a, string &b, int ia, int ib, int cur) {
    int max = cur;
    for (int i= ia; i<a.length(); i++) {
        char c = a[i];
        int j;
        for (j= ib; j<b.length(); j++) {
            if (c == b[j])
                break;
        }
        if (j == b.length()) continue;
        int ans = lcs(a, b, i+1, j+1, cur+1);
        if (ans > max) max = ans;
    }
    return max;
}

上述的代码复杂度非常大,原因在于相同的lcs(a, b, i, j, cur)可能会被多次调用,比如lcs(a, b, 3, 4, 1)和lcs(a, b, 3, 4, 2)(数据乱编的),它们的目的都是相同的——想求得lcs(a+i, b+j)。 发现穷举过程中隐藏的子模式\子问题之后,可以把中间结果先算出来,并且它们正好可以存在一个二维表中,代码如下。 这就是LCS问题的思考过程了。

const int N = 100;
int mat[N][N];
int lcs_dp(string &a, string &b) {
    fill(&mat[0][0], &mat[0][0]+N*N, 0);
    for (int i= a.length()-1; i>=0; i--) {
        for (int j= b.length()-1; j>=0; j--) {
            int &cur = mat[i][j];
            if (a[i] == b[j])
                cur = 1 + mat[i+1][j+1];
            else
                cur = mat[i][j+1]>mat[i+1][j] ? mat[i][j+1] : mat[i+1][j];
        }
    }
    return mat[0][0];
}

有些子问题也穷举思路中也许不明显,需要读者自己去寻找一个划分点,然后创造重叠子问题。 本节问题的划分点是公共子串的第一个字符,再如“表达式链”问题(如UVA - 348)的划分点则是“最后一次运算”,都可以从穷举的思路中发现问题子模式。

经典的0-1背包问题也是如此,本来每个物品均是两种选择——放和不放,穷举法是用一种二叉树来表示,可以用本文第一节所说的方法来解决——记录并更新全部可能重量的数组。 也可以用另一种递归思路,即从第1个物品开始,放和不放,影响的是背包的剩余容积,很容易可以构造出重叠子问题——在特定容积下,余下的物品可以放入的最大重量。
多阶段决策问题

还有一类dp问题,是多阶段决策中需要“分层”或者“分阶段”的dp问题。

比如找零钱的方案数(UVA - 357)问题。

有$50, $25, $10, $5 and $1不同面值的纸钞,现在给定一个需要找零的金额数$N,问一共有多少种不同的找零方法。

如果按照上节所说的穷举方法,我们很容易得到一个4叉树(有4种面值的纸钞),这棵树一直延伸发散,直到从根结点到叶结点的路径总和等于或大于N才停止,然后统计路径总和为N的路径数。

但这个穷举思路有问题,比如现在N=15,穷举树先伸出5再伸出10,和先伸出10再伸出5,都可以达到路径和为15,并算作两条路径,但其实它们是等价的。 这类问题需要加入一个“先后顺序”的概念,LRJ的原话是:

原来的状态转移太敌了,任何时候都允许使用任何一种物品,难以控制。为了消除这种混乱,需要让状态转移(也就是决策)有序化。

我们可以从每种硬币的个数上进行穷举,代码如下。

int coins[] = {1, 5, 10, 25, 50};

int count_ways(int n) {
    int i1, i5, i10, i25, i50;
    int cnt = 0;
    for(i1= 0; i1<=n; i1++) {
        for(i5= 0; i5*5<=n; i5++) {
            for(i10= 0; i10*10<=n; i10++) {
                for(i25= 0; i25*25<=n; i25++) {
                    for(i50= 0; i50*50<=n; i50++) {
                        int tmp = i1 + i5*5 + i10*10 + i25*25 + i50*50;
                        if (tmp == n) cnt++;
                    }
                }
            }
        }
    }
    return cnt;
}

同样是穷举,不同之处在于第二种方案中有“顺序”\“层”\“阶段”的概念,各种硬币并不允许随时被加入最终的总和。 不像第一种方案,为了得到$15,可以按$5+$10和$10+$5多种顺序得到,第二种只能是$5+$10,不会重复。

反映在dp方法上,我们也得做出改变,我们要依次记录不同阶段的中间结果——用arr[N]表示N有多少种拼溱方法,只用$1硬币计算一遍,再加上$5硬币计算一遍,再加入……直到5种硬币都加入了,最终结果就出来了,而且结果不会重复。

const int N = 1000;
int arr[N];
int count_ways_dp(int n) {
    fill(arr, arr+N, 0);
    arr[0] = 1; // initialization
    for (int i= 0; i<NCOINS; i++) {
        for (int j = coins[i]; j<=n; j++) {
            arr[j] += arr[j-coins[i\\);
        }
    }
    return arr[n];
}

“阶段”只是帮助我们思考的,在动态规划的状态描述中最好避免“阶段”\“层”这样的术语。

用这种方式来描述“阶段”这种概念,笔者也略感到勉力,希望各位看官能反馈回来更好的见解。
最后的话

笔者的算法学习经历并不多,大一的时候读了半本《算法概论》,选了一门水水的算法课(作用几可忽略==),课余参加过一些Codeforces的在线比赛,最认真的,就是这个大学最后的寒假闭关在家按着LRJ的《算法竞赛入门经典》苦练一番,收获颇丰,而其中以dp为最难,故才有此一文来总结这个寒假的算法学习。

不得不说,整个算法的思考过程对我来说很不容易。以在Quora看来的一句与算法(数学)学习相关的话跟诸君共勉。

Most people simply don’t put in the time to make something intuitive.

参考资料

算法竞赛入门经典

对动态规划(Dynamic Programming)的理解:从穷举开始(转)的更多相关文章

  1. 动态规划(Dynamic Programming)算法与LC实例的理解

    动态规划(Dynamic Programming)算法与LC实例的理解 希望通过写下来自己学习历程的方式帮助自己加深对知识的理解,也帮助其他人更好地学习,少走弯路.也欢迎大家来给我的Github的Le ...

  2. 动态规划Dynamic Programming

    动态规划Dynamic Programming code教你做人:DP其实不算是一种算法,而是一种思想/思路,分阶段决策的思路 理解动态规划: 递归与动态规划的联系与区别 -> 记忆化搜索 -& ...

  3. 6专题总结-动态规划dynamic programming

    专题6--动态规划 1.动态规划基础知识 什么情况下可能是动态规划?满足下面三个条件之一:1. Maximum/Minimum -- 最大最小,最长,最短:写程序一般有max/min.2. Yes/N ...

  4. 动态规划 Dynamic Programming

    March 26, 2013 作者:Hawstein 出处:http://hawstein.com/posts/dp-novice-to-advanced.html 声明:本文采用以下协议进行授权: ...

  5. 最优化问题 Optimization Problems & 动态规划 Dynamic Programming

    2018-01-12 22:50:06 一.优化问题 优化问题用数学的角度来分析就是去求一个函数或者说方程的极大值或者极小值,通常这种优化问题是有约束条件的,所以也被称为约束优化问题. 约束优化问题( ...

  6. 动态规划系列(零)—— 动态规划(Dynamic Programming)总结

    动态规划三要素:重叠⼦问题.最优⼦结构.状态转移⽅程. 动态规划的三个需要明确的点就是「状态」「选择」和「base case」,对应着回溯算法中走过的「路径」,当前的「选择列表」和「结束条件」. 某种 ...

  7. 动态规划 Dynamic Programming 学习笔记

    文章以 CC-BY-SA 方式共享,此说明高于本站内其他说明. 本文尚未完工,但内容足够丰富,故提前发布. 内容包含大量 \(\LaTeX\) 公式,渲染可能需要一些时间,请耐心等待渲染(约 5s). ...

  8. Python算法之动态规划(Dynamic Programming)解析:二维矩阵中的醉汉(魔改版leetcode出界的路径数)

    原文转载自「刘悦的技术博客」https://v3u.cn/a_id_168 现在很多互联网企业学聪明了,知道应聘者有目的性的刷Leetcode原题,用来应付算法题面试,所以开始对这些题进行" ...

  9. [算法]动态规划(Dynamic programming)

    转载请注明原创:http://www.cnblogs.com/StartoverX/p/4603173.html Dynamic Programming的Programming指的不是程序而是一种表格 ...

  10. [算法总结] 动态规划 (Dynamic Programming)

    本文组织结构如下: 前言 最长公共子序列(LCS) 最长不下降子序列(LIS) 最大连续子序列之和 最长回文子串 数塔问题 背包问题(Knapsack-Problem) 矩阵链相乘 总结 前言 在学过 ...

随机推荐

  1. A1012 The Best Rank (25)(25 分)

    A1012 The Best Rank (25)(25 分) To evaluate the performance of our first year CS majored students, we ...

  2. Linux系统软件包之---Apache

    当前互联网主流web服务说明 静态服务: apache 中小型静态web服务的主流,web服务器中的老大哥 nginx 大型新兴网站静态web服务主流,web服务器中的初生牛犊 lighttpd 静态 ...

  3. J.U.C 系列之 Tools

    JDK 5.0 开始,java并发大师Doug lea 为我们带来了更高效更应用的java并发API Java.util.concurrent包,简称J.U.C J.U.C 体系由如下五个知识结构构成 ...

  4. MySQL之架构与历史(二)

    多版本并发控制 MySQL的大多数事务型存储引擎实现的都不是简单的行级锁.基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制(MVCC).不仅是MySQL,包括Oracle.PostgreSQ ...

  5. 第八届蓝桥杯C/C++ B组省赛----分巧克力

    分巧克力 问题描述 儿童节那天有K位小朋友到小明家做客.小明拿出了珍藏的巧克力招待小朋友们. 小明一共有N块巧克力,其中第i块是Hi x Wi的方格组成的长方形. 为了公平起见,小明需要从这 N 块巧 ...

  6. Linux磁盘与文件管理系统

    基本上Linux的正统文件系统为Ext2,该文件系统内的信息主要有: superblock:记录此filesystem的整体信息,包括inode/block的总量,使用量,剩余量,以及文件系统的格式与 ...

  7. ios开发学习笔记002-运算符

    运算符 C语言有34种运算符,常见的有加减乘除. 算术运算符 1.加 10+2 2.减 20-2 3.乘 12*2 4.除 10/2 5.取余 10%3 = 1; 10%-3 = 1; -10%3 = ...

  8. Python+Selenium框架设计篇之-简单介绍unittest单元测试框架

    前面文章已经简单介绍了一些关于自动化测试框架的介绍,知道了什么是自动化测试框架,主要有哪些特点,基本组成部分等.在继续介绍框架设计之前,我们先来学习一个工具,叫unittest.       unit ...

  9. 与Python的初次见面

    一.Python的介绍 python的创始人为吉多.范罗苏姆.1989年的圣诞期间,吉多.范罗苏姆为了在阿姆斯特丹打发时间,决心开发一个新的脚本解释程序,作为ABC语言的一种继承. 二.Python是 ...

  10. sqlserver中top 1 赋值的问题

    看代码 declare @iid intselect @iid=111select top 1 @iid=isnull(IID,0) from YYGL_PCDMX where IID=0print ...