算法导论知识梳理(四):高级设计和分析技术

537 阅读8分钟

本章主要介绍了动态规划和贪心算法,也是平时较为常见的高级设计算法,同时也提供了摊还分析的链接,个人认为相对前两个来说也是比较好理解了,所以就直接贴了个链接,内容和书本上也是一样的。

动态规划

动态规划与分治算相似,都是通过组合子问题的解来求解原问题。分治法将问题划分为互不相交的子问题,递归地求解子问题,再将它们的解组合起来,求出原问题的解。与之相反,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题。在这种情况下,分治法会做许多不必要的工作,它会反复地求解那些公共子子问题。而动态规划算法对每个子子问题只求解一次,将其保存在一个表格中,从而无须每次求解一个子子问题都重新计算,避免了这种不必要的计算工作。

动态规划方法通常用来求解最优化问题。通常会通过如下4个步骤设计一个动态规划算法:

  1. 刻画一个最优解的结构特性
  2. 递归地定义最优解的值
  3. 计算最优解的值,通常采用自底向上的方法
  4. 利用计算出的信息构造一个最优解

就光是上述内容看下来,估计大家都是一脸懵逼的,下面就将拿书上的钢条切割问题为例进行详细说明,后面的分析也会围绕这个例子展开: 给定一段长度为n英寸的钢条和一个价格表pi(i=1,2,...,n),将其切割为短钢条出售,求切割钢条方案,使得销售收益rn最大(不考虑切割工序成本,钢条的长度均为整英寸)。下面给出的是一个价格表的样例:

长度i 1 2 3 4 5 6 7 8 9 10
价格pi 1 5 8 9 10 17 17 20 24 30

朴素递归算法

解决这个问题,我们可以很容易地想到使用之前的分治法自顶向下递归求解,分解问题为长度为i的部分不切割,剩余部分切割(子问题)。那么其代码就应该是这样的(为了简单起见,求解过程只求出了最大收益值,不返回具体切割方案):

const priceArr = [0,1,5,8,9,10,17,17,20,24,30];
function CutRod(priceArr, n) {
  if(n === 0) return priceArr[0];
  // 初始化最大收益
  let profit = 0;
  for(let i = 1; i <= n; i++) {
    // 归纳最大收益
    profit = Math.max(profit, priceArr[i] + CutRod(priceArr, n - i));
  }
  return profit;
}
console.log(CutRod(priceArr, 10)); // 30

但是,其实这段代码问题很大,仔细分析一下这段代码,不难发现,它对反复地求解相同的子问题。上图说话,当n=4时,求解过程

我们可以明显地看到,这一过程其实一直在求解重复的子问题,并且,随着n的增大,运行时间会爆炸式地增长。

动态规划方案

通过上述分析,可以知道,朴素递归算反之所以效率低,是因为它反复求解相同的子问题。因此,动态规划方法仔细安排求解顺序,对每个子问题只求解一次,并将结果保存下来。如果随后再次需要此子问题的解,只需查找保存的结构,而不必重新计算。这种解决方案,是典型的时空权衡的列子。

动态规划有两种等价的实现方法:带备忘的自定向下法和自底向上法。下面就分别看下具体实现:

  1. 带备忘的自定向下法
function MemoizedCutRod(priceArr, n) {
  // 通过arr将最大收益结果保存起来,用-1代表无值状态
  const arr = new Array(n).fill(-1);
  MemoizedCutRodAux(priceArr, n, arr);
  return arr;
}
function MemoizedCutRodAux(priceArr, n, arr) {
  // 当有值时,直接返回对应的值即可
  if(arr[n] >= 0) return arr[n];
  let profit = 0;
  if(n === 0) profit = priceArr[0];
  else {
    for(let i = 1; i <= n; i++) {
      profit = Math.max(profit, priceArr[i] + MemoizedCutRodAux(priceArr, n - i, arr))
    }
  }
  arr[n] = profit;
  return profit;
}

总结思路和朴素递归算法是一样的,只是多了一个用来保存n在不同值时的最大收益的数组

  1. 自底向上法
function BottomUpCutRod(priceArr, n) {
  const arr = new Array(n).fill(0);
  let profit = 0;
  for(let i = 1; i <= n; i++) {
    profit = 0;
    for(let j = 1; j <= i; j++) {
      // 直接去arr中去最大收益结果,因为是自低向上的过程,所以arr[i-j]必定有值
      profit = Math.max(profit, priceArr[j] + arr[i - j]);
    }
    arr[i] = profit;
  }
  return arr;
}

自底向上法其实更为简单,感觉也没啥好说的==。

那么,何时采用动态规划法呢?适合应用动态规划法求解最优化问题应具备两个要素:最优子结构和子问题重叠。

  1. 如果一个问题的最优解包含其子问题的最优解,那么就称此问题具有最优子结构性质(这种性质也意味着可以采用贪心策略)。
  2. 如果递归算法反复求解相同的子问题,那么就称最优化问题具有重叠子问题性质。

另一个典型的通过动态规划法解决的问题就是求解最长公共子序列问题,考虑到篇(lan)幅(de)原(xie)因,就直接引用一下别人不错的文章,点我跳转

贪心算法

在求解最优化问题时,贪心算法更为简单高效。它在每一步都做出当时看起来最佳的选择。即它总是做出局部最优的选择,寄希望这样的选择能导致全局最优解。

其求解过程可分为以下步骤:

  1. 确定问题的最优子结构
  2. 设计一个递归算法
  3. 证明如果我们做出一个贪心选择,则只剩下一个子问题
  4. 证明贪心选择总是安全的
  5. 设计一个递归算法实现贪心策略
  6. 将递归算法转换为迭代算法

一个典型的问题就是活动选择问题。假设有n个活动的集合S={a1,a2,...,an},这些活动使用同一个资源(例如同一个阶梯教室),而这个资源在某个时刻只能供一个活动使用,每个活动都有一个开始时间si和结束时间fi,其中0<=si<=fi,时间段为半开区间[si,fi)。此问题中,我们希望选出一个最大兼容活动集,即活动个数达到最大。

例如,如下活动集合S:

先写出其递归式,c[i,j]表示集合S[i,j]的最优解的大小,若活动ak在集合c[i,j]的最优解中,递归式就如下:

那么我们应该采取何种贪心策略?一般的可以有以下两种方式:

  1. 优先选取持续时间最短的活动
  2. 优先选择最早结束的活动

策略一的一个选取方案为i={2,4,8,11}; 策略二的一个选取方案为i={1,4,8,11}。 代码就不写了,因为相对来说是比较简单的。

霍夫曼编码

另一个典型的贪心算法场景就是霍夫曼编码:根据每个字符的出现频率,对不同的字母采用变长编码(赋予高频字短编码,赋予低频字长编码)进行编码。这一过程采用的就是贪心策略。一般地,我们只考虑前缀码(没有任何码字是其他码字的前缀)。例如某个文件中值出现了6个不同字符a-f,每个字符的出现频率为:

a b c d e f
频率(千次) 45 13 12 16 9 5
定长编码 000 001 010 011 100 101
变长编码 0 101 100 111 1101 1100

那么变长编码是如何被构建出来的?答案是通过构建霍夫曼树,将低频率的项两两合并,从根节点出发,到某个节点的路径,即为变长编码。如下图:

优缺点

优点:简单,高效,其一般会被用做辅助算法。

缺点:只考虑到了局部最优解,没有回溯处理。但在大多数情况下,局部最优并不表示全局最优(例如0-1背包问题)。

摊还分析

在摊还分析中,我们求数据结构的一个操作序列中所执行的所有操作的平均时间,来评价操作的代价。摊还分析不同于平均情况分析,它并不涉及概率,它可以保证最坏情况下每个操作的平均性能。

可以看出,摊还分析并不是一种算法,而是就是用来评价一系列操作的平均代价,常用的技术有三种:聚合分析、核算法、势能法。这里就贴个链接,有兴趣的可以了解一下,这个文章的内容和书上基本上都是一样的。点我跳转

总结一下,动态规划和贪心算法这两种设计更多的是需要理解其思想,然后就是尝试用这些思想解决一些实际问题,才能真正理解,毕竟实践出真知。