算法Balabala动态规划概述

前言

算法,对于初级程序员(Api Caller) 而言,可能并不怎么重要,因为平时工作中压根用不到算法。但是要进入高级工程师,就要知道,如何用最优的计算方式来完成同一件任务。比如,腾讯面试经常问到的,给你一个亿的用户数据,要你从中找到指定的用户信息,如何达到最快,又或者,手机QQ本地聊天记录中有1W条数据,如何最快找到包含搜索关键字的聊天记录,这些都是直接影响到用户体验的功能,又比如,滴滴打车app,如何进行最优的派单方案,让 所有的注册车辆都有订单等等.诸如此类的问题,Api Caller 的程序员何以解决,拿不到高薪,是有自身的原因的,谁让你对高级算法一无所知。

算法,基于数学,应用于生活中的各种实际问题,它是一个巨大的知识体系,深不可测,仅以一文概论,不现实。本文详解的是,动态规划算法,一种解决问题的通用套路,学会了套路,相当于入了门,遇到任何问题都可以尝试套用框架,唯一不足的就是深度和广度的不足,多训练就会有提升,遇到再难的问题也不至于抓耳挠腮,不知所措。

所谓动态规划算法

以下是来自百度百科的概念,经我整理之后,浓缩如下:

动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法. 提到数学,可能很多人都要头疼了,读大学的时候最怕的就是 高等数学,线性代数,解析几何这种。但是,实际上,动态规划算法,在现实生活中很多方面都已经应用到实践,比如,求地图上两个地点的最短路线,求资源的最合理分配方案,以及最优排序算法等问题上。按照动态规划的套路,基本上可以做到一套思维框架,放之四海皆准,每一种问题的之间的差别,都只是前提条件的不同,以及 最终目标的不同。

能够用动态规划算法解决的问题,往往都会存在一个"状态转移方程式"。也就是,在多个不同的阶段,所采取的当前决策。把所有决策整合起来形成一个统一的数学方程式。

???什么玩意?我们还是说点人话吧

动态规划算法,都有一个共同点,那就是求 "最值" ,像 最"短"路径,最"少"耗时,最"少"次数等。这种算法是一种普适性套路算法,它针对所有求最值得问题,都可以用一个统一的思维方式去解决。

状态转移方程,其实也就是一个所谓的 数学函数公式(能当程序员,数学应该是都不错的,你肯定知道我在说什么 -!),比如下文即将讲到的 斐波拉契数列公式

写出状态转移方程之后,就可以开始用程序代码来解决实际问题。本文采用的编程语言是Kotlin,但是我都有些详尽的注释,就算不懂Kotlin,阅读起来应该也不会有太大障碍。

开始这一切之前,有一些算法的相关概念在此声明。

时间复杂度和空间复杂度

一个大的问题,往往可以分解成若干个子问题,大事化小,小事化了。

  • 时间复杂度 = 子问题的个数 x 解决一个子问题所需的时间

  • 空间复杂度 = 子问题的个数 x 解决一个子问题所需的内存空间

时间复杂度 描述算法的执行效率。常见的时间复杂度O(1),O(log2n),O(n),O(n^2). n表示子问题的个数,分别表示0次方,1/2次方,1次方,和2次方。次数越高,复杂度越大, 解决同样的问题花费的时间就越长,算法的效率也就越低,谁都不希望自己的程序运行效率低。

空间复杂度 描述算法占用内存。越高,说明算法占用的内存空间越大。 它的写法和时间复杂度一样,次数越高,算法占用的空间也就越大,耗费内存就越大,无论是服务器上的程序算法,还是手机上的程序算法,都会考虑内存的问题。

时间和空间复杂度都只是描述算法的复杂程度,一般对比只看大概数字级别,而不是去纠结实际的耗时,比如3ms,6ms 级别上相差不大的基本也就是同一个时间复杂度

递归回调

递归,也就是函数对自身的调用,一般都会设定一个退出递归的条件,防止无限回调。动态规划,大问题分解成小问题,一般都会用到递归调用。

初识动态规划:斐波拉契数列

著名的斐波拉契数列, f(1) = f(2) = 1,往后的f(n) 都是前面两个数的和,写成数学公式,也就是所谓"状态转移方程",如下:


image-20200422151048373.png

如果我们要得到一个 f(40),斐波拉契数列上位置40的函数值。我们可以完全按照数学公式来写代码。

暴力解法

利用递归算法来编程就是如下写法:

fun fib1(x: Int): Long {
    return if (x == 1 || x == 2) {
        1
    } else
        fib1(x - 1) + fib1(x - 2)
}

如果用上面的算法来计算斐波拉契数列中某个节点的值,我们来算算时间复杂度是多少。

如果是f(40),那么递归树结构就是:

image-20200501155359273.png

时间复杂度

  • 子问题的个数

    每个子问题都会分裂成2个子问题。所以子问题的个数是 2n

  • 一个子问题耗费的时间

    一个子问题只需要一次加法。所以,解决一个子问题的时间是1

合并起来,时间复杂度就是O(2^n) 指数级别的复杂度,效率极低。

空间复杂度(O(1))这里不谈,因为都是临时变量,不存在永久保存的数据。

测试一下执行的效率:

fun main() {
    val start = System.currentTimeMillis()
    fib1(40)
    val end = System.currentTimeMillis()
    println("${(end - start)}ms")
}

结果(受系统的调度影响,多次运行结果可能不同,但是数量级应该是一样的):

311ms

从上面的图可以看出,很多子问题都是重复计算的。比如f(38),f(37)...这就是上面的算法所带来的低效率问题,在做很多不必要的重复性工作。 那么有没有办法避免这种重复的递归计算呢?

一重改进

既然上面的f(37),f(38)等都在重复计算,那么我们用一个备忘录map,记录已经计算出的结果。

比如把上面的f(38)的值用map记录下来,下次需要计算的时候,直接取值,而不是再去计算

程序变成这样:

fun fib2(x: Int): Long {
    if (x < 1) return 0
    val map = HashMap<Int, Long>()
    return helper(map, x)
}

fun helper(map: HashMap<Int, Long>, x: Int): Long {
    if (x == 1 || x == 2) return 1
    if (map[x] != null && map[x] != 0L) //如果已经计算过了,那就是说保存过了,就直接返回
        return map[x]!!
    // 否则,就计算之后再保存
    map[x] = helper(map, x - 1) + helper(map, x - 2) // 这里依然是递归
    return map[x]!!
}

fun main() {
    val start = System.currentTimeMillis()
    fib2(40)
    println("${System.currentTimeMillis() - start}ms")
}

运行结果(受系统的调度影响,多次运行结果可能不同,但是数量级应该是一样的):

3ms

执行耗时从 三位数变成了1位数

这种解法的时间复杂度该如何计算?

  • 子问题的个数

    上面的做法,显然就是f(38)等这种重复性计算不再存在,所以可以理解为:当需要f(38)的时候,会优先从 map中去取。所以,计算出f(40),也就最多有40个子问题。所以 f(n)子问题的个数是 n,

  • 一个子问题耗费的时间

    依然只需要一次加法就能算出一个子问题的结果,所以耗时 1

所以时间复杂度O(n) , 对比 前面一种解法的O(2n)简直就是降维打击。

空间复杂度

由于这里使用了 map集合来保存已经计算出来的值,所以,空间复杂度:

  • 子问题的个数

    f(n)子问题的个数还是n

  • 一个子问题耗费的空间

    每一个子问题的计算结果都会占用1个空间来保存, 所以一个子问题耗费空间1

所以空间复杂度= O(n)

时间复杂度降低了,程序的运行效率提高了,但是,空间复杂度却升高了,这就是所谓的"空间换时间".

二重改进

不知道有没有人跟我有一样的感觉!那就是,这种递归算法, 自上而下的思维方式有点反人类,容易把人绕晕。如果可以的话,我宁愿顺着正常人的思维去思考,比如,自下而上,先得出 f(1),f(2),再得出f(3),f(4)...一直到我们要求的f(40).

程序写成下面这样:

fun fib3(x: Int): Long {

    val map = HashMap<Int, Long>() // 初始化一个map

    map[1] = 1
    map[2] = 1

    for (i in 3..x) {
        val pre1 = map[i - 1] ?: 0
        val pre2 = map[i - 2] ?: 0
        map[i] = pre1 + pre2
    }
    return map[x] ?: 0
}

fun main() {
    val start = System.currentTimeMillis()
    fib3(40)
    println("${System.currentTimeMillis() - start}ms")
}

时间复杂度:

  • 子问题的个数

    只是思考的方向反了,子问题仍然是n个

  • 一个子问题耗费的空间

    处理一个子问题的,仍然是一次加法,时间依然是1

所以时间复杂度依然是:O(n)

空间复杂度:

  • 子问题的个数

    n

  • 处理一个子问题,需要保存一个数据,所以占空间是1

空间复杂度为:O(n)

这种算法,把自上而下的递归,变成了自下而上的遍历,时间和空间复杂度都没有变化,但是写法更容易理解,执行效率也如预料中一样,并没有变化:

4ms

三重改进

上面一种改进方法,我们用map保存了已经计算出来的值,自上而下递归计算,和自下而上遍历计算,时间和空间复杂度都相同。但是,自下而上的计算依然有优化空间。

思考:是否可以不需要map来保存数据

上一节的改进,f(n)依赖的仅仅是f(n-1)和f(n-2). 而不需要另外的数据一直保存。

/**
 * 最后优化
 */
fun fib4(x: Int): Long {
    if (x == 1 || x == 2) return 1L
    var cur = 1L
    var pre = 1L
    for (i in 3..x) {
        val sum = cur + pre
        pre = cur
        cur = sum
    }
    return cur
}

fun main() {
    val start = System.currentTimeMillis()
    fib4(40)
    println("${System.currentTimeMillis() - start}ms")
}

时间复杂度:

  • 子问题的个数

    仍然是 n

  • 一个子问题耗费的时间

    一个子问题只需要1次加法,所以耗时 1

所以时间复杂度是O(n)

空间复杂度:

  • 子问题的个数是n
  • 一个子问题所占用的空间是1,但是 都是临时占用,函数执行完毕之后就会释放,算法中的临时变量所占空间并不会累加,

所以空间复杂度,是O(1)

运行结果,时间耗费上依然没变:

3ms

至此,斐波拉契数列问题才算圆满解决,时间和空间复杂度都达到了最优。

总结

动态规划算法,解决问题的一般思路为:

  • 写出状态转移方程式
  • 直接按照方程式写出暴力解法程序,可能效率会很低
  • 使用集合保存已经计算出的子问题的结果,优化子问题的个数,得出优化算法优化时间复杂度
  • 使用顺序遍历的思路来替代逆序递归,让程序代码更好理解
  • 尝试能否使用临时变量替代集合,优化空间复杂度

但是,聪明的你们应该已经发现了好像哪里不对。

没错,最前面说过,动态规划算法,是用来解决最值问题的算法, 目的是得出最优解。 显然,斐波拉契数列并不是求最值。从这里可以看出,这种解法思路,不仅仅针对求最值问题,有一些非此类问题,也可以尝试使用动态规划去思考。

本文使用斐波拉契数列,是因为它的状态转移方程是 公开的,大家入门都很容易理解。但是现实生活中的实际问题,它的状态转移方程,要根据实际情况,分情况列出,如果方程列错了,就算算法再精良,结果也不是我们想要的。

所以,使用动态规划解决问题,还是万事开头难。只要列出了正确的状态转移方程,后续,我们才可以按照套路来,一步一步优化算法。 所以千万不要瞧不起 效率最低的暴力解法,它代表的是最原始的也是,最标准的解法。计算结果出现问题,我们首先要审视的就是 状态转移方程是否正确,方程正确,才能去查后面的问题。

下一篇将以一个实际案例(凑零钱问题),来一步步展示动态规划算法的实际应用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352