贪心 分治 动态规划

一、贪心算法

参考
算法(一):贪心

我们今天介绍的主题是贪心算法。这是相对比较容易的一种算法。我这里不敢给出严格定义,因为我当年领悟贪心和动态规划的无后效性用了三年时间。从我第一次听说无后效性到终有一天恍然大悟,总共用去了一千天。所以,我这里就不讲定义,咱们直接看例子。

贪心算法,如果不用手动证明一个问题的数学性质的话,其实是比较简单的。

1.例1

有一个背包,其最大容量是50KG,现在有各种不同价值的液体,比方说,有水,其价值是10 每千克,共100KG;酒精,其价值是20 每千克,共30KG;有油,价值25每千克,共30KG。问什么样的方案才能使得背包里装的液体的总价值最大?显然,这是一道小学生都会的题目,拣贵的装呗。这谁不会。那就是把30KG油先装了,因为油最贵嘛,剩下20KG的容量装酒精,因为酒精比水贵。

好,那么,这个思维过程就是贪心:每一阶段做决策都找出当前条件下的最优解。当然这个问题,这种解法的正确性显而易见,贪心类的问题最难的地方在于正确性无法证明。这个我们先不管。毕竟我们不是在做算法研究,我们只要能用贪心去解决具体的编程问题就行了。

2.例2

再看一个简单的例子。在商店找零,比如说,售货员要找给你37块钱,那什么样的方案才是找钱的张数最少的方案呢?肯定是找回的零钱面值越大,最后的张数越少。所以我们可以使用贪心的方法来解决这个问题。先找一张20的,如果再找一张20的,那就超过37了,这不行,所以就再找一张10块的,然后是5块的和两张一块的。最终的方案就得到了,这也是一次典型的贪心计算方案。

3.例3

在贪心的思想的指导下,也设计了一种排序算法,这种算法被称为选择排序。排序的思想很简单。既然我的目标是把一堆数字从小到大排,那么我按阶段解决这个问题不就好了么?第一步找到最小的那个数把它放到第一位,第二步在剩下的所有数里再找最小的,把它放到第二位,依次类推。

二、分治算法 (divide and conquer,D&C)

参考
算法设计(二):分治
一个小姑娘在图书馆借了一大摞书,抱着出门,警报响了,小姑娘正准备一本本地去试看是哪一本还没有消磁,管理员大妈惊呼:“这得试到什么时候了." 然后大妈就把书分了两半,去试一下没有响,再把剩下的那一半书再分两半。大妈的这种解法就叫分治。这也是算法设计的一个例子。小姑娘的解法的时间复杂度是O(n),大妈解法的时间复杂度是O(log n)。当然,段子下面早就有人回复:如果不止一本书没有消磁,大妈解法就挂了。

分治算法,通过把问题分解为更小的子问题,先解决小问题,再把小问题的解合并起来的一种方法。通常,如果子问题的规模仍然很大,可以继续拆解成更小的任务,更进一步,实践中,大任务往往与子任务有相同的结构,以便于递归地进行任务拆解。

1.例1

以具体的例子来说明。现在一共9枚硬币,已知其中有一枚是假的,它比其他8枚都要轻。现在有一个天平,只称两次,能找到这枚硬币吗?

我们倒着想,假如现在有两枚,称一次,这很简单,天平高的那一边的是假币,如果有三枚呢?那就有两种情况,如果天平平衡,那么未称的那枚是假币,如果天平不平衡,高的那边是假币。

再往上一层,如果有9枚硬币,我们可以把它分为三堆,一堆3个,然后任选两堆放在天平上称,如果天平平衡,那么假币一定在没称的那一堆里,如果天平不平衡,那么假币就在天平高起的那一堆里。

这个例子就是一个很好的分治的例子,我们先把规模为9的大问题,分拆为规模为3的小问题,而且这个小问题与大问题保持了相同的性质。然后再去解决规模为3的小问题,就得到最终的答案了。

2.解题步骤

分治法有三个解题步骤,无论是简单的分治还是复杂的,都逃不开这个标准步骤。它们是:

  • 拆解子任务
  • 解决子任务
  • 合并子任务的解

分治算法应用在方方面面,一般来说,解决子任务这一步骤相对容易,因为当子任务拆到规模一定小的情况下,解就会显得非常简单。分治算法的应用难点一般都会出现在拆解子任务与合并解的步骤上。

3.例2

给定一堆平面上的坐标,找出平面上的距离最近的点。暴力地一对对地去查找,这个办法的时间复杂度是O(n^2),我们能不能使用分治来加速呢?实际上是可以的。但是这个问题的子问题分拆就没有那么简单了。

我们尝试着拆分一下,把这个平面一分为二,那么距离最小的点可能出现在左边,也可能出现在右边。假如,我们已经把左边最近的两个点找出来了,又把右边最近的两个点找出来了。那么合并这两个子问题的时候,只需要再比较这个子问题的解,看是哪一边的点更近。

但是等等,我们漏了一点东西。


image.png

看这幅图,假如,左边那个平面里,找到的最近的一对点是1和2,右边找到的最近的一对是3和4。解合并的时候,如果是检查1,2和3,4之间的距离谁更小是不行的,因为明显这个平面上,距离最近的一对点是2和3。也就是说在进行平面点的拆分的时候时候,就已经把2和3这一对最近的点拆开了,那么我们在合并的时候就要考虑这个问题。这个子问题的解合并是一个非常难的事情,这节课就不展开讲了。(以后还有没有机会再讲,看情况吧)

总之,使用分治算法解题的时候,如何正确地拆解问题,合并问题是设计算法的核心所在。

4.例3

所有的分治算法中,最常用的,还是二分法。二分通常可以使得问题的规模减半。

我们最常见的一个游戏,猜数字。我选定一个数字,在0到100之间,你来猜,如果猜得比选定数字大,我会提示你猜高了,如果比选定数字小,我会提示你猜低了。那么最多需要几次就一定能猜对呢?

我们常用的策略就是二分,先报50,如果主持人说低了,那我就知道,这个数字一定位于50,100这个区间,那我再报75。这样每次都能把待选区减少一半。所以最多需要7次就可以找到这个数字。如果是1到1000,只需要10次(从这里也可以看到O(log n)是一种时间复杂度很好的算法了,毕竟一百万地规模也只要20次。)

我们上面举的例子就是二分法,在一个已经有序的数组里进行查找,我们通常称之为二分查找(Binary Search)。

5.排序算法中最常用的快速排序和合并排序也是分治算法的经典案例
三、《算法图解》第9章 动态规划
image.png
1.简单算法

最简单的算法是尝试各种商品组合,并找出价值最高的组合。在有3件商品的情况下,需要计算8个不同的集合。在有4件时,需要计算16个集合。算法 复杂度达到了O(2^N)

2.贪婪算法

这个例子出现在《算法图解》第8章,背包只能装35磅,商品有以下3种可以偷:

  • 音响 3000美元 30磅
  • 电脑 2000美元 20磅
  • 吉他 1500美元 15磅

贪心算法的原则就是挑最贵的去偷,装上30磅的音响,价值3000美元,别的东西就装不下了。
实际上,电脑加上吉他正好35磅,总计价值是3500美元。在这里,贪心策略显然不是最优解,但非常接近。

有些情况下,完美是优秀的敌人。如果只需要找到一个大致能解决问题的算法,就可以用贪心策略,它们实现起来非常容易,得到的结果又与正确结果相当接近。

3.动态规划

动态规划先解决子问题,再逐步解决大问题。每个动态规划算法都从一个网格开始,背包问题网格如下:


image.png

网格最初是空的,动态规划就是逐步将网格填满。

吉他行

第一个单元格表示背包的容量为1磅。 吉他的重量也是1磅, 这意味着它能装入背包! 因此这个单元格包含吉他, 价值为1500美元。 来看下一个单元格,这个单元格表示背包的容量为2磅, 完全能够装下吉他!这行的其他的单元格也是如此,因为你目前只能把吉他装入背包,其他两种商品还未出现,所以第一行变成下图:


image.png

此时你可能心存疑惑:原来的问题说的是4磅的背包,为何要考虑容量为1磅、2磅等的背包呢?前面说过,动态规划从小问题着手,逐步解决大问题。这里解决的子问题将帮助你解决大问题。

音响行

现在来到第二行,在每一行,可偷的商品都是当前行的商品和之前各行的商品。因此,当前你已经解锁了音响和吉他,但是笔记本电脑还未解锁。现在来看第一个单元格,它表示容量为1磅的背包。 在此之前, 可装入1磅背包的商品的最大价值为1500美元。 背包的容量为1磅, 能装下音响吗? 音响太重了, 装不下! 由于容量1磅的背包装不下音响, 因此最大价值依然是1500美元。 接下来的两个单元格的情况与此相同。


image.png

现在来到了第四个单元格,也就是说背包容量为4磅,终于能装下音响,由于音响的价值为3000美元,比1500美元的吉他值钱多了,所以还是偷音响吧。


image.png

笔记本电脑行

下面以同样的方式处理笔记本电脑。 笔记本电脑重3磅, 没法将其装入容量为1磅或2磅的背包, 因此前两个单元格的最大价值还是1500美元。


image.png

对于容量为3磅的背包, 原来的最大价值为1500美元, 但现在你可选择盗窃价值2000美元的笔记本电脑而不是吉他, 这样新的最大价值将为2000美元!


image.png

现在来到这个问题最关键的单元格,对于容量为4磅的背包,当前的最大价值为3000美元, 你可不偷音响, 而偷笔记本电脑, 但它只值2000美元。但是笔记本电脑的重量只有3磅, 背包还有1磅的容量没用! 在1磅的容量中, 可装入的商品的最大价值之前计算过。根据之前计算的最大价值可知, 在1磅的容量中可装入吉他, 价值1500美元。于是有了下面的比较:


image.png

于是我们得到了最终的结果:


image.png

现在你明白了为何要求解子问题吧?你可以合并两个子问题的解来得到更大问题的解。
4.背包问题FAQ:
  • 增加第四件商品iphone,2000美元,1磅,要重新执行前面的计算么?
    答案:不需要,再增加一行计算结果即可。

  • 沿着一列往下走时, 最大价值有可能降低吗?
    答案: 不可能。 每次迭代时, 你都存储当前的最大价值。 最大价值不可能比以前低!

  • 行的排列顺序发生变化时结果将如何变化?
    答案:没有变化。 也就是说, 各行的排列顺序无关紧要。

  • 可以逐列而不是逐行填充网格吗?
    答案:就这个问题而言, 这没有任何影响, 但对于其他问题, 可能有影响。

  • 增加一件更小的商品将如何呢?
    答案:单元格的按最小商品的重量划分。

  • 可以偷商品的一部分吗?
    答案:没法处理。 使用动态规划时, 要么考虑拿走整件商品, 要么考虑不拿, 而没法判断该不该拿走商品的一部分。但使用贪婪算法可轻松地处理这种情况! 首先, 尽可能多地拿价值最高的商品; 如果拿光了, 再尽可能多地拿价值次高的商品, 以此类推。

  • 动态规划可以处理相互依赖的情况吗?
    答案: 没办法建模。 动态规划功能强大, 它能够解决子问题并使用这些答案来解决大问题。 但仅当每个子问题都是离散的, 即不依赖于其他子问题时, 动态规划才管用 。
    计算最终的解时会涉及两个以上的子背包吗?
    答案:根据动态规划算法的设计, 最多只需合并两个子背包, 即根本不会涉及两个以上的子背包。 不过这些子背包可能又包含子背包。

  • 最优解可能导致背包没装满吗?
    答案:完全可能。

5.动态规划的启发

动态规划可帮助你在给定约束条件下找到最优解。 在背包问题中,你必须在背包容量给定的情况下, 偷到价值最高的商品。

在问题可分解为彼此独立且离散的子问题时, 就可使用动态规划来解决。要设计出动态规划解决方案可能很难, 这正是本节要介绍的。 下面是一些通用的小贴士。

  • 每种动态规划解决方案都涉及网格。
  • 单元格中的值通常就是你要优化的值。 在前面的背包问题中, 单元格的值为商品的价值。
  • 每个单元格都是一个子问题, 因此你应考虑如何将问题分成子问题, 这有助于你找出网格的坐标轴。
6.小结
  • 需要在给定约束条件下优化某种指标时, 动态规划很有用。
  • 问题可分解为离散子问题时, 可使用动态规划来解决。
  • 每种动态规划解决方案都涉及网格。
  • 单元格中的值通常就是你要优化的值。
  • 每个单元格都是一个子问题, 因此你需要考虑如何将问题分解为子问题。
  • 没有放之四海皆准的计算动态规划解决方案的公式。
四、参考知乎 如何理解动态规划?

入门动态规划第一条:切忌望文生义,切忌用名字反推算法!如果我来命名,可能会取:分步规划,分步存储法, 递推存储法,数列递推法,状态转移法.....但我就是想不出动态规划啊。

1.能用动态规划解决的问题

如果一个问题满足以下两点,那么它就能用动态规划解决。

(1).问题的答案依赖于问题的规模,也就是问题的所有答案构成了一个数列。举个简单的例子,1个人有2条腿,2个人有4条腿,..., n 个人有多少条腿?答案是 2n 条腿。这里的 2n 是问题的答案, n 则是问题的规模,显然问题的答案是依赖于问题的规模的。答案是因变量,问题规模是自变量。因此,问题在所有规模下的答案可以构成一个数列 (f(1),f(2),...,f(n)) ,比如刚刚“数腿”的例子就构成了间隔为2的等差数列 (0,2,4,...,2n) 。

(2).大规模问题的答案可以由小规模问题的答案递推得到,也就是 f(n) 的值可以由 {f(i)|i

2.适合用动态规划解决的问题

能用动态规划解决,不代表适合用。比如刚刚的“数腿”例子,你可以写成 f(n)=2n 的显式表达式形式,那么杀鸡就不必用牛刀了。但是,在许多场景, f(n) 的显式式子是不易得到的,大多数情况下甚至无法得到,动态规划的魅力就出来了。

3.应用动态规划——将动态规划拆分成三个子目标

当要应用动态规划来解决问题时,归根结底就是想办法完成以下三个关键目标。

(1).建立状态转移方程
这一步是最难的,大部分人都被卡在这里。这一步没太多的规律可说,只需抓住一个思维:当做已经知道f(1) ~f(n-1) 的值,然后想办法利用它们求得 f(n) 。在“数腿”的例子中,状态转移方程即为f(n)=f(n-1)+2 。

(2).缓存并复用以往结果
这一步不难,但是很重要。如果没有合适地处理,很有可能就是指数和线性时间复杂度的区别。假设在“数腿”的例子中,我们不能用显式方程,只能用状态转移方程来解。如果现在 f(100) 未知,但是刚刚求解过一次 f(99) 。如果不将其缓存起来,那么求 f(100) 时,我们就必须花100次加法运算重新获取。但是如果刚刚缓存过,只需复用这个子结果,那么将只需一次加法运算即可。

(3).按顺序从小往大算
这里的“小”和“大”对应的是问题的规模,在这里也就是我们要从 f(0) , f(1) , ... 到f(n) 依次顺序计算。这一点在“数腿”的例子来看,似乎显而易见,因为状态方程基本限制了你只能从小到大一步步递推出最终的结果(假设我们仍然不能用显式方程)。然而当问题复杂起来的时候,你有可能乱了套,所以必须记住这也是目标之一。

4.高中数列题的魔改版

看到这里,你可能会觉得怎么跟高中的数列题那么像??其实在我看来这就是高中数列题的魔改版。

高中的题一般需先推导出状态转移方程,再据此推导出显式表达式(在高中时代称为通项公式)。然而,动态规划是要我们在推导出状态转移方程后,根据状态转移方程用计算机暴力求解出来。显式表达式?在动态规划中是不存在的!

就是因为要暴力计算,所以前面说的目标有两个是涉及到代码层面上:

  • 缓存中间结果:也就是搞个数组之类的变量记录中间结果。
  • 按顺序从小往大算:也就是搞个for循环依次计算。
5.原文有3个例子,这里只引用第1个

斐波那契数列(简单)

斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233……
它遵循这样的规律:当前值为前两个值的和。
那么第 n 个值为多少?
首先,我们可以很容易得到状态转移方程: f(n)=f(n-1)+f(n-2) 。接下来我们用两种方法来做:

(1)简单递归(反例)

def fib(n):
    if n < 2:
        return n
    else:
        return fib(n-1) + fib(n-2)
    
if __name__ == '__main__':
    result = fib(100)  
# 你等到天荒地老,它还没有执行完

如上所示,代码简单易懂,然而这代码却极其低效。先不说这种递归的方式造成栈空间的极大浪费,就仅仅是该算法的时间复杂度已经属于 O(2^n) 了。指数级别时间复杂度的算法跟不能用没啥区别!

为什么是指数时间复杂度?图1通过展示求解 f(6) 的过程说明了其原因。如图,随着递归的深入,计算任务不断地翻倍!


图1 简单递归的执行过程

(2).动态规划

def fib(n):
    
    results = list(range(n+1)) # 用于缓存以往结果,以便复用(目标2)
    
    for i in range(n+1):  # 按顺序从小往大算(目标3)
        if i < 2:
            results[i] = i
        else:
            # 使用状态转移方程(目标1),同时复用以往结果(目标2)
            results[i] = results[i-1] + results[i-2] 
     
    return results[-1]
    
if __name__ == '__main__':
    result = fib(100)  
    # 秒算,result为:354224848179261915075

如上代码,针对动态规划的三个子目标,都很好地实现了(参考备注),具体为:

目标1,建立状态转移方程(完成)。也就是前面的 f(n)=f(n-1)+f(n-2) 。
目标2,缓存并复用以往结果(完成)。图1的简单递归存在大量的重复任务。在线性规划解法中,我们把结果缓存在results列表,同时在results[i] = results[i-1] + results[i-2]中进行了复用。这相当于我们只需完成图2中红色部分的计算任务即可,时间复杂度瞬间降为 O(n) 。


图2 线性规划通过缓存与复用机制将计算规模缩小到红色部分

目标3,按顺序从小往大算(完成)。for循环实现了从0到 n 的顺序求解,让问题按着从小规模往大规模求解的顺序走。

5.【算法-动态规划】-最大子段和

动态规划算法与分治法类似,其基本思想也是将待求解问题分解为若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适用与动态规划法求解的问题,经分解得到的子问题往往不是互相独立的。
分治法的基本思想是将一个规模为n的问题分解为k个规模较小的子问题,这些子问题相互独立且与原问题相同。递归的求解这些子问题,然后将各子问题的解合并得到原问题的解。
如果用分治法求解时,有些子问题被重复计算了许多次。如果我们能够保存已解决的子问题的答案,而在需要的时再找出已求得的答案这样就可以避免大量的重复计算,从而得到多项式时间算法。为了达到此目的,可以用一个表来记录所有已经解决的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思想。

问题描述:给定由n个正数(可能为负整数)组成的序列a1,a2,...,an,求该序列形如的子段和的最大值,当所有正数均为负整数时定义其最大字段和为0。
比如-2,11,-4,13,-5.-2,最大字段和是11+(-4)+13=20;6,-1,5,4,-7,其最大子段和为 6+(-1)+5+4

(1)找出最优解的性质,并刻画其结构特征

由b[j]的定义易知,当b[j-1]>0时b[j]=b[j-1]+a[j],否则b[j]=a[j]

(2)递归地定义最优值。

b[j]=max{b[j-1]+a[j],a[j]},1<=j<=n

(3)以自底向上的方式计算出最优值。

public class MaxSumClass3{
    public static int MaxSum(int n,int[] a){
        int sum=0,b=0;
        for(int i=1;i<=n;i++){
            if(b<0)b=a[i];
            else b=b+a[i];
            if(b>sum)
                sum=b;
        }
        return sum;
    }
    public static void main(String[] args){
        int n=6;
        int[] a={0,-2,11,-4,13,-5,-2};
        System.out.println(MaxSum(n,a));
    }
}

你可能感兴趣的:(贪心 分治 动态规划)