《算法导论》第四部分-动态规划浅析及其Javascript实现

631 阅读11分钟

什么是动态规划

动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。

什么样的问题用动态规划

需要解决的问题具备两个特征(个人总结 非算法导论原文描述):
1. 具有最优子结构
2. 子问题重叠(类似)
通常需要维护一个已经计算过的表

解决步骤(个人总结 非算法导论原文描述)

  1. 刻画最优解结构特征
  2. 自底向上生成表
  3. 递归查表

钢条切割

背景简介




大概的意思是这样的

  1. 给定一根长度为L的钢条 可以把它切割成任意长度
  2. 不同长度的售价是不一样的
  3. 我们要求最好的切割方案,就是切割下来的每一段加起来总价值最高

算法思想


从常规思路 我们第一时间会想到递归



解释一下这段伪代码
1,2行就是 钢条是0的时候返回0
3行 把收益设置为负无穷 (或者-1) 表示没有收益的意思即

4,5行是遍历1到n的情况
max中两个值得意思:

  • q ---> q是之前的最佳收益
  • p[i]+n-i的最优方案
    i=1的时候 取切开1+剩下的长度最佳收益
    i=2的时候 取切开2+剩下的长度最佳收益


i=n的时候 即表示不切的方案

因为cut-rod反复对相同的输入求输出,按照这样的写法 每当n增加1 程序运行时间差不多会增加1倍

上面的方法是自顶向下的,就是先求n-1的递归方案,必然会重复求解相同的输入值,所以我们换个思路。

先假设钢条只有1的长度,那么假设切割最优方案就是p1(只有一种)
如果钢条长度有2,那么切割方案有(p1+p1)或者不切  --->比较后得到一个切割方案p2
如果钢条长度有3,那么切割方案有(p1+p1+p1)(p1+p2)或者不切---> 比较后得到一个最优方案p3
....以此类推

可以观察到 在整个过程中 后面的方案都可以由之前求出来的值相加得到

这也就是真·动态规划 : )

  • 具有最优子结构
  • 子问题重叠(类似)
  • 自底向上,通常需要维护一个已经计算过的表

算法过程


按照上面的思路,我们需要两张一维表
let r = new Array(n + 1).fill(0);: 表示最佳收益
let s = new Array(n + 1).fill(0);: 表示对应的i值 即p(i)中的i
初始化收益 r[0] = 0
我们需要两层循环

  1. 遍历1-n的切割方案
  2. 原来的递归过程由于有了s表的出现 只需要查表就可以了。所以转换成了一个for循环遍历过程

代码实现

/**
 * @param pMap {object}
 * @param n {number}
 */
function ExtendedBottomUpCutRod(pMap, n) {
  let r = new Array(n + 1).fill(0);
  let s = new Array(n + 1).fill(0);
  r[0] = 0;
  for (let j = 1; j < n + 1; j++) {
    q = -1;
    for (let i = 1; i < j + 1; i++) {
      if (q < pMap[i] + r[j - i]) {
        q = pMap[i] + r[j - i];
        s[j] = i;
      }
    }
    r[j] = q;
  }
  return { s, r };
}
/**
 * @param pMap {object}
 * @param n {number}
 */
function PrintCutRodSolution(pMap, n) {
  const { r, s } = ExtendedBottomUpCutRod(pMap, n);
  let solution = [];
  let l = n;
  while (l > 0) {
    solution.push(s[l]);
    l = l - s[l];
  }
  return { solution, money: r[n] };
}
module.exports = PrintCutRodSolution;

矩阵链乘法

背景简介


矩阵链乘法是什么意思呢?举个例子
有3个矩阵相乘

  • A(10*100)
  • B(100*5)
  • C(5*50)


那么我们可以知道
A x B x C = (A x B)x C = A x (B x C)

  1. 我们先计算 A x B 需要10X100X5 = 5000次计算;再与C计算需要 10X5X50=2500次 一共7500次计算
  2. 我们先计算B x C 需要100X5X50=25000次计算; 再与A计算
    需要10X100X50=50000次 一个75000次计算


可以看到不同的顺序计算会带来计算量的巨大差异

所以需要我们规划出最优的计算顺序

算法思路


采用动态规划的思路



我们以上面图片的输入作为例子来说明 输入p = [30, 35, 15, 5, 10, 20, 25]

假设k位置分割是最佳方案 那么这个方案的消耗就是 下面1,2,3之和

  1. A1~Ak的最佳方案 假定称为m[1][k]
  2. Ak+1~A6的最佳方案 假定称为m[k+1][6]
  3. Ak _ p0 _ p6


我们可以得出公式 m[1][6] = m[1][k] + m[k+1][6] + Ak * p0 * p6

那么我们对A1A6作同样的假设。
...
如此类推

扩展一下 m[i][j] = m[i][k] + m[k+1][j] + Ak * pi-1 * pj

假设只有A1A2 那么方案就是k=1 计算消耗记为m[1][2] 最佳分割点记为s[1][2]
假设只有A1A2A3 那么方案就是k=1或者k=2 最佳方案计算消耗m[1][3] 最佳分割点记为s[1][3]
...
如此类推

我们得到两张表之后就只需要简单的递归查表就可以知道对应的最优方案了

算法过程


从上述过程中 我们需要 一张记录最佳分割点的表s 一张记录最佳消耗的表m
n p.length-1 就是6个矩阵

let m = newTable(n + 1, n + 1)
let s = newTable(n + 1, n + 1)

我们需要从2矩阵个开始构建这个表,所以最外层是这样一个循环

 for (let l = 2; l < n + 1; l++) {
 }


2个矩阵的情况有 12 23 34 45 56  所以i <= n - l + 1
然后我们需要求解的m[i][j]那么就是从i到最后一位j = i + l - 1

    for (let i = 1; i < n - l + 2; i++) {
        j = i + l - 1
        // xxxxx
    }


最后求解i,j区间最佳消耗m[i][j] 就是从i,j区域内计算k的每一个位置 取最小值
m[i][j] = Infinity:初始化无穷大

    m[i][j] = Infinity
    for (k = i; k < j; k++) {
        q = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j]
        if (q < m[i][j]) {
            m[i][j] = q
            s[i][j] = k
        }
    }


最后得到s,m表之后递归查表
举个例子 比如s[1][6] = 3 那么k的值是3
再去找 s[1,3] s[4][6]
以此类推

代码实现

//生成二维数组
function newTable(m, n) {
    let arr = [];
    for (let i = 0; i < m; i++) {
        let arrInside = [];
        for (let j = 0; j < n; j++) {
            arrInside.push(0);
        }
        arr.push(arrInside);
    }
    return arr;
}


function MatrixChainOrder(p) {
    let n = p.length - 1
    let m = newTable(n + 1, n + 1)
    let s = newTable(n + 1, n + 1)
    for (let l = 2; l < n + 1; l++) {
        for (let i = 1; i < n - l + 2; i++) {
            j = i + l - 1
            m[i][j] = Infinity
            for (k = i; k < j; k++) {
                q = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j]
                if (q < m[i][j]) {
                    m[i][j] = q
                    s[i][j] = k
                }
            }
        }
    }
    return { m, s }
}


function PrintOptimalParens(input) {
    const r = MatrixChainOrder(input)
    var plan = ''
    let print = (s, i, j) => {
        if (i == j) {
            plan = plan + 'A' + i
        } else {
            plan = plan + '('
            print(s, i, s[i][j])
            print(s, s[i][j] + 1, j)
            plan = plan + ')'
        }
    }
    print(r.s, 1, input.length - 1)
    return plan
}

module.exports = PrintOptimalParens


最长公共子序列

背景简介


什么是子序列?

某个序列的子序列是从最初序列通过去除某些元素但不破坏余下元素的相对位置(在前或在后)而形成的新序列

大概意思能在父序列串中按照对应的前后顺序找到的序列 就是子序列
举个例子
X {A,B,C,B,D,A,B}
Y {B,D,C,A,B,A}
DA 是x的子序列 AD不是

算法思路

假定我们称

  • 最长公共子序列是lcs
  • X的长度为m
  • Y的长度为n

当只有1个字符的时候,那么就是判断x1等于y1否 假定最终lcs b1
当有2个字符的时候,从后往前遍历 如果x2=y2 那么只要求之前的lcs再加上x2就是最终lcs,如果x2!=y2那么就是求x1&(y1,y2)和(x1,x2)&y1中大的为最终lcs,假定为b2
当有3个字符的时候,从后往前遍历 如果x3=y3 那么只要求b2+x3就是最终lcs b3
.....
以此类推

再来看下 如果Xm!=Yn 那么就是求Xm-1,Yn  Xm,Yn-1两种组合的最大lcs

假定c[i][j]表示i,j区间内的lcs长度

c[i][j] 如果x[i]=y[j] 那么c[i][j]=c[i-1][j-1]+1
如果x[i]!=y[j] 那么c[i][j]=max(c[i-1][j],c[j-1][i])

image.png
仅仅有这张表是不够的,我们还需要记录对应的查找顺序 也就是上述三种情况

  1. i-1,j-1的方向
  2. i-1,j的方向
  3. i,j-1的方向

算法过程


新建两张表
let b = newTable(m + 1, n + 1);:顺序表
let c = newTable(m + 1, n + 1);:长度表

遍历m,n长度所有的情况 然后判断三种情况就可以了

遍历

for (let i = 1; i < m + 1; i++){
    for (let j = 1; j < n + 1; j++) {
    }
}


3种情况

if (x[i - 1] == y[j - 1]) {
        c[i][j] = c[i - 1][j - 1] + 1;
        b[i][j] = "c";
      } else if (c[i - 1][j] >= c[i][j - 1]) {
        c[i][j] = c[i - 1][j];
        b[i][j] = "b";
      } else {
        c[i][j] = c[i][j - 1];
        b[i][j] = "a";
      }


最后输出最长子序列 依然是递归查表就可以了

代码实现

//生成二维数组
function newTable(m, n) {
  let arr = [];
  for (let i = 0; i < m; i++) {
    let arrInside = [];
    for (let j = 0; j < n; j++) {
      arrInside.push(0);
    }
    arr.push(arrInside);
  }
  return arr;
}
// 生成序列表
function LcsLength(x, y) {
  let m = x.length;
  let n = y.length;
  let b = newTable(m + 1, n + 1);
  let c = newTable(m + 1, n + 1);
  for (let i = 1; i < m + 1; i++) {
    for (let j = 1; j < n + 1; j++) {
      if (x[i - 1] == y[j - 1]) {
        c[i][j] = c[i - 1][j - 1] + 1;
        b[i][j] = "c";
      } else if (c[i - 1][j] >= c[i][j - 1]) {
        c[i][j] = c[i - 1][j];
        b[i][j] = "b";
      } else {
        c[i][j] = c[i][j - 1];
        b[i][j] = "a";
      }
    }
  }
  return { b, c };
}
//最长公共子序列
function getLcs(X, Y) {
  const b = LcsLength(X, Y).b;
  let str = "";
  function select(b, X, i, j) {
    if (i == 0 || j == 0) return;
    if (b[i][j] == "c") {
      select(b, X, i - 1, j - 1);
      str = str + X[i - 1];
    } else if (b[i][j] == "b") {
      select(b, X, i - 1, j);
    } else {
      select(b, X, i, j - 1);
    }
  }
  select(b, X, X.length, Y.length);
  return str;
}

module.exports = getLcs;

最优二叉搜索树

背景简介

关于二叉搜索树
关于最优二叉搜索树,算法导论给了一个生动的例子。以下是描述截图:
Untitled.png
从上述的截图中可以知道搜索一个单词是有搜索成本的,我们将它定义为
Untitled 1.png
Untitled 2.png
dx 代表伪节点(正常节点都未命中时的虚拟节点)
pi 第i个节点命中的概率
qi 第i个伪节点命中的概率
举个例子:
第2个节点的搜索代价 = (K2深度+1)*P2 + (D2深度+1)*Q2 = (0+1)**0.10 + (3+1)**0.02
我们需要找出期望搜索代价最小的二叉搜索树

算法思想

我们假设这棵树(T)是最优二叉搜索树,那么它的子树必定也是最优二叉搜索树。可以用反证法来理解这个问题,假如它的子树不是最优二叉搜索树(假定为a),我们将其替换成最优二叉搜索子树(假定为b)。那么b的搜索代价一定是小于a的,那么原来树T的其他搜索代价假设为t
t+a > t+b 与  假设这棵树(T)是最优二叉搜索树  是相违背的
所以问题就变成了(正向思维):求T的最优二叉搜索树 = T的最优二叉左子树 + T的最优二叉右子树 + 根节点,递归式就有了。

假定求解kikj节点的最优二叉搜索树,其根节点为kr
假定期望搜索代价为e[i,j]
假定ki
kj的期望搜索代价之和为w[i,j]

这里分为两种情况
1. j = i - 1 表示子树没有节点 只有伪节点 所以 e[i,i-1] = qi-1
2. j≥ i-1 的情况比较复杂
当原来的树结构发生变化 在他们上面多了一个r节点的时候 看下图的变化
Untitled 3.pngUntitled 4.png
子树所有节点的深度都+1了,会增加搜索代价
所以可以得到下图所示的公式
Untitled 5.png
整理一下之后可以得到
Untitled 6.png
最终我们可以得到
Untitled 7.png

算法过程

根据上述的过程  我们需要 e ,w,root 三张表避免重复计算

let e = newTable(n + 1, n + 1)
let w = newTable(n + 1, n + 1)
let root = newTable(n, n)

处理j=i-1的情况

for (let i = 1; i <= n + 1; i++) {
  e[i][i - 1] = q[i - 1]
  w[i][i - 1] = q[i - 1]
}

需要三层循环(与矩阵链乘几乎一模一样)

1.遍历1-n各个长度的情况

for (let l = 1; l <= n; l++)

2.可以理解为同样的l长度 滑动窗口  例如 12,23, 34, 45,

for (let i = 1; i <= n - l + 1; i++)

3.遍历每个r的情况取最小值

代码实现

//生成二维数组
function newTable(m, n) {
    let arr = [];
    for (let i = 0; i <= m; i++) {
        let arrInside = [];
        for (let j = 0; j <= n; j++) {
            arrInside.push(0);
        }
        arr.push(arrInside);
    }
    return arr;
}

//生成对照表
function createTable(p, q, n) {
    let e = newTable(n + 1, n + 1)
    let w = newTable(n + 1, n + 1)
    let root = newTable(n, n)
    for (let i = 1; i <= n + 1; i++) {
        e[i][i - 1] = q[i - 1]
        w[i][i - 1] = q[i - 1]
    }
    for (let l = 1; l <= n; l++) {
        for (let i = 1; i <= n - l + 1; i++) {
            j = i + l - 1
            e[i][j] = Infinity
            w[i][j] = w[i][j - 1] + p[j] + q[j]
            for (let r = i; r <= j; r++) {
                let t = e[i][r - 1] + e[r + 1][j] + w[i][j]
                if (t < e[i][j]) {
                    e[i][j] = t
                    root[i][j] = r
                }
            }
        }
    }
    return { e, root }
}

function OptimalBst(p, q, n) {
    const { root } = createTable(p, q, n)
    let tree = []
    const find = (root, i, j) => {
        if (i <= j) {
            let r = root[i][j]
            tree.push('k' + r)
            find(root, i, r - 1)
            find(root, r + 1, j)
        }
    }
    find(root, 1, 5)
    return tree
}

module.exports = OptimalBst