美文网首页前端开发那些事儿算法
算法Balabala动态规划概述

算法Balabala动态规划概述

作者: 波澜步惊 | 来源:发表于2020-05-03 17:56 被阅读0次

前言

算法,对于初级程序员(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

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

总结

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

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

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

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

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

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

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

相关文章

  • 算法Balabala动态规划概述

    前言 算法,对于初级程序员(Api Caller) 而言,可能并不怎么重要,因为平时工作中压根用不到算法。但是要进...

  • 4. 动态规划算法

    1. 动态规划算法总结2. 漫画:什么是动态规划?3.算法之动态规划4. 动态规划-算法

  • Swift 算法实战:动态规划

    Swift 算法实战:动态规划 Swift 算法实战:动态规划

  • 程序员算法基础——动态规划

    程序员算法基础——动态规划 程序员算法基础——动态规划

  • 动态规划

    --tags: 算法,动态规划 动态规划解题 引入:动态规划 和贪心法 都是算法的思想方法 贪心算法——像 第一类...

  • 动态规划 Dynamic Programming

    从运筹学和算法的角度综合介绍动态规划 算法分类总结动态规划与静态规划的关系浅析静态规划和动态规划动态规划解非线性规...

  • 时序差分算法(Temporal-Difference Learn

    概述 时序差分算法是一种无模型的强化学习算法。它继承了动态规划(Dynamic Programming)和蒙特卡罗...

  • 动态规划-js

    动态规划 参考:算法分析与设计-贪心&动归 漫画:什么是动态规划? 【数据结构与算法】 DP 动态规划 介绍 介绍...

  • 动态规划

    动态规划(Dynamic Programming) 本文包括: 动态规划定义 状态转移方程 动态规划算法步骤 最长...

  • 2018-11-14

    今天学了动态规划一些知识,着重看了一下动态规划之背包算法(主要是0-1算法和部分算法),关于动态规划的问题重点是建...

网友评论

    本文标题:算法Balabala动态规划概述

    本文链接:https://www.haomeiwen.com/subject/boksxhtx.html