前端开发 -- 算法模式(递归和动态规划)

递归

递归是一种解决问题的方法,它解决问题的各个小部分,直到解决最初的大问题,递归通常涉及到函数的自身调用。
递归函数是像下面这样能够直接调用自身的方法或是函数:

function recursiveFunction(someParam){
  recursiveFunction(someParam);
}

当然,像下面这样间接的调用自己的函数也是递归函数:

function recursiveFunction1(someParam){
  recursiveFunction2(someParam);
}

function recursiveFunction2(someParam){
  recursiveFunction1(someParam);
}

当然我们执行一个函数,是为了实现某一个目的,所以我们一定要给该函数一个跳出的条件,也就是说每一个递归函数都必须要有边界条件,即不再递归调用的条件(停止点),防止无限递归下去。
JavaScript调用栈大小的限制
如果忘记停止递归调用的边界条件,递归调用并不无限的执行下去;浏览器会直接抛出错误,也就是我们所谓的栈溢出错误。
每一个浏览器都有自己的上限。

let i = 0;
function recursiveFn(){
  i++;
  recursiveFn(); // {1}
}
try{
  recursiveFn();
}catch(ex){
  alert('i = ' + i + ' error :' +ex);
}

执行的时候会报下面的bug:

栈溢出错误

ES6中有尾调用,如果函数最后一个操作是调用函数,也就是{1}这行代码,会通过"跳转指令",而不是“子程序调用”来控制,也就是说吗,在es6中,这里的代码是可以一直执行下去的,所以,具有停止递归的边界条件是十分重要的。
斐波那契数列
我们需要先了解什么是斐波那契?斐波那契数列的定义如下:

  • 1和2的斐波那契数是1
  • n(n > 2)的斐波那契数是(n - 1)的的斐波那契数加上(n - 2)的斐波那契数。
    现在让我们开始实现斐波那契函数:
function fibonacci(num){
  if(num === 1 || num === 2){
    return 1;
  }
}

我们的边界条件是已知的,1和2的斐波那契是1,现在,让我们完成斐波那契函数的实现:

function fibonacci(num){
  if(num === 1 || num === 2){
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
}

我们已经知道,当n大于2的时候,fibonacci(n)等于fibonacci(num - 1) + fibonacci(num - 2);
现在我们的函数已经编写完成,让我们试着找出6的斐波那契函数,会产生如下的函数调用:

斐波那契数列的递归方式

当然我们也可以通过非递归的方式实现斐波那契函数:

function fib(num){
  let n1 = 1;
  let n2 = 1;
  let n = 1;
  for(let i = 3; i < num; i++){
    n = n1 +n2;
    n1 = n2;
    n2 = n;
  }
  return n; 
}

动态规划

动态规划(Dynamic Programmaing,DP)是一种将赋值问题分解成更小的子问题来解决的优化技术。
ps:我们需要注意动态规划和分而治之是不同的方法,分而治之是将问题分解成独立的子问题,然后组合它们的答案,而动态规划则是将问题分解成互相依赖的子问题。
我们的解决动态规划问题的时候,需要遵循三个重要的步骤:
(1)定义子问题
(2)实现要反复执行来解决子问题的部分(这一步要参考前一节的递归的步骤)
(3)识别并求解出边界条件
我们通过动态规划解决如下一些著名的问题:背包问题、最长公共子序列、矩阵链相乘、硬币找零、图的全源最短路径。

  • 背包问题:给出一组项目,各自有值和容量,目标是找出总值最大的项目的集合,这个问题的限制是,总容量必须小于等于背包的容量。
  • 最长公共子序列:找出一组序列最长的公共子序列(可由另一序列删除元素但是不改变余下元素的顺序而得到)。
  • 矩阵链相乘:给出一系列的矩阵,目标是找到这些矩阵相乘的最高效办法(计算次数尽可能的少),相乘操作不会进行,解决方案是找到这些矩阵各自相乘的顺序。
  • 硬币找零:给出面额为d1...dn的一定数量的硬币和要找零的钱数,找出多少种找零的方法。
    图的全源最短路径:对所有的顶点对(u,v),找出从顶点u到顶点v的最短路径。

最少硬币找零问题
最少硬币找零问题是硬币找零问题的一个变种,硬币找零问题是给出要找零的钱数,以及可以使用的硬币面额d1...dn及其数量,找出多少种找零的方式,最少硬币找零问题是给出要找零的钱数,以及可用的硬币面额d1...dn及其数量,找到所需的最少的硬币的个数。
例题:美国有以下的面额的硬币:d1 = 1,d2 = 5,d3 = 10,d4 = 25
如果要找36美分的零钱,我们可以用一个25美分,一个10美分,和1个便士(1美分)
那我们怎么将这个解答转化为算法呢?
最少的硬币找零的解决方案就是找到n所需的最小硬币数,但是要做到这一步,首选要找到对每个x

function MinCoinChange(coins) {
    let coins1 = coins;
    let cache = {};
    this.makeChange = function(amount) {
        let me = this;          
        if(!amount) {
            return [];
        }
        if(cache[amount]) {
            return cache[amount];
        }
        let min = [],
            newMin, newAmount;
        for(let i = 0; i < coins1.length; i++) {
            let coin = coins1[i];
            newAmount = amount - coin;
            if(newAmount >= 0) {
                newMin = me.makeChange(newAmount);
            }
            if(
              newAmount >= 0 &&
             (newMin.length < min.length - 1 || !min.length) && 
             (newMin.length || !newAmount)
              ) {
                min = [coin].concat(newMin);
                console.log('new Min' + min + ' for ' + amount);
            }
        }
        return(cache[amount] = min);
    }
}
let arr = [1, 3, 4];
let minCoinChange1 = new MinCoinChange(arr);
console.log(minCoinChange1.makeChange(6));

由上面的代码我们看出,我们求最优解的方法,是建立在求出上一个最优解的基础上面的。我们在执行算法的过程中,我们会产生以下的输出:
new Min 1 for 1
new Min 1,1 for 2
new Min 1,1,1 for 3
new Min 3 for 3
new Min 1,3 for 4
new Min 4 for 4
new Min 1,4 for 5
new Min 1,1,4 for 6
new Min 3,3 for 6
[3,3]

背包问题

背包问题的本质就是一个组合优化的问题。它可以描述如下:给定一个固定大小的本报,能够携带W的背包,以及一组有价值和重量的物品,找出一个最佳的解决方案,使得子昂如背包的物品重量不超过W且价值是最大的。

背包问题描述

如果考虑背包呢鞥狗携带的重量只有5,对于这个例子,我们可以说最佳的解决方案是向背包里面装物品1和物品2,这样,总重量为5,总价值为7.(这个问题我们仅仅可以解决将背包里面的东西完整的装进来,不包括对分数问题无法解决,分数的背包问题要我们在贪心算法中进行解决,现在解决的问题是0-1背包问题)。
具体的解决的方案,如下图:

0-1背包的解释

其中i表示的是物品的个数,w表示的是物品的重量,在他们组成的二位的数组的值为,总的价值,我们看一下下面的代码:

function knapSack (capacity,weights,values,n){
    let i,w,a,b,KS =[];
    for(i = 0; i<= n; i++){  //{1}
        KS[i] = [];
    }
    for(i = 0; i <= n; i++){
        for(w = 0; w <= capacity; w++ ){
            if(i == 0 || w == 0){ //{2}
                KS[i][w] = 0;
            }else if(weights[i - 1] <= w){ //{3}
                a = values[i - 1] + KS[i -1][w-weights[i - 1]];
                b = KS[i -1][w];
                KS[i][w] = (a > b) ? a : b; // {4} max(a,b)
            }else{
                KS[i][w] = KS [i-1][w]; //{5}
            }
        }
    }
    return KS[n][capacity]; //{6}
    }
let values = [3,4,5];
let weights = [2,3,4];
let capacity = 5;
let n = values.length;
console.log(knapSack(capacity,weights,values,n)); // 输出为7

我们从代码的逻辑上梳理一下这个算法是如何工作的:
行{1}:首先,初始化将用于寻找解决方案的矩阵KS[n+1][capacity]
行{2}:忽略矩阵的第一列和第一行,只处理索引不为0的行和列
行{3}:物品i的重量必须小于约束(capacity)才有可能称为解决方案的一部分;否则,总重量就会就会超出背包可以携带的重量,这是不可能发生的,发生这种情况的时候,就要直接忽略它,用之前的值就可以了。
行{4}:当找到可以解决方案的物品时,选择价值大的那个。
行{6}:最后,问题的解决方案就在这个二维表格的右下角的最后一个格子里。
我们也看到了上面的代码,只能输出最大的价值,但是却不能知道我们得到该价值的时候,背包中的重量是如何组合的,现在我们针对上面遇到的问题,对代码进行优化:

function findValues (n,capacity,KS,weights,values){
    let i = n, k = capacity;
    console.log('解决方案包含以下的商品:');
    while(i > 0 && k > 0){
        if(KS[i][k] !== KS[i-1][k]){
            console.log('物品' + i + ',重量'+ weights[i-1] + ',价值:' + values[i -1]);
            i--;
            k = k - KS[i][k];
        }else{
            i--;
        }
    }
}

上面的代码在行{6}之前进行调用,就可以得到如下的结果过:

0-1背包问题答案截图

最长公共子序列

另一个经常被当做编程挑战的问题的动态规划问题是最长公共子序列(LCS):找到两个字符串序列的最长子序列的长度。最长子序列是指,在两个字符串序列中以想听的顺序出现,但不要求连续(非字符串子串)的字符串序列。我们看一下下面的问题描述:

最长公共子序列的问题描述

我们看一下算法的具体实现:

// 最长公共子序列
lcs('acbacd', 'abcadf')
function lcs(wordx, wordy){
    let m = wordx.length;
    let n = wordy.length;
    let l = [];
    let solution = [];
    let i ,j, a, b;
    for( i = 0; i<= m; ++i ){
        l[i] = [];
        solution[i] = [];
        for(j = 0; j <= n ;++j ){
            l[i][j] = 0;
            solution[i][j] = '0';
        }
    }
    for(i = 0; i <= m; i++){
        for(j = 0; j <= n; j++){
            if(i == 0 || j== 0){
                l[i][j] = 0;
            }else if(wordx[i-1] === wordy[j-1]){
                l[i][j] = l[i - 1][j - 1] + 1;
                solution[i][j] = 'dialog'
            }else{
                a = l[i-1][j];
                b = l[i][j - 1];
                l[i][j] = (a > b) ? a : b;
                solution[i][j] = (l[i][j] == l[i-1][j]?'top':'left')
            }
        }
    }
    printSolution(solution,l,wordx,wordy,m,n);
    return l[m][n];
}
function printSolution (solution,l,wordx,wordy,m,n) {
    let a = m, b = n, i,j;
    let x = solution[a][b];
    let answer = '';
    while (x!== '0'){
        if (solution[a][b] === 'dialog') {
            answer = wordx[a-1]+ answer;
            a--;
            b--;
        }else if(solution[a][b] === 'left'){
            b--;
        }else if(solution[a][b] === 'top'){
            a--;
        }
        x = solution[a][b];
    }
    console.log('lcs:'+ answer);
}

结果的矩阵是:

l矩阵
solution的矩阵

矩阵链相乘

这个问题的目的是找到一组矩阵相乘的最佳顺序,让我们可以更好的理解这个问题,让我们可以更好的理解这个问题,nm列的矩阵Amp列的矩阵B相乘,结果np列的矩阵C。考虑我们想做A*B*C*D的乘法,因为我们可以让这些矩阵以任意的顺序相乘,因此我们考虑一下如下的情况:

  • A是一个10行100列的矩阵
  • B是一个100行5列的矩阵
  • C是一个5行50列的矩阵
  • D是一个50行1列的矩阵

在这个例子中,我们有5种相乘的方式:
(A(B(CD))):乘法运算的次数是1750次
((AB)(CD)):乘法运算的次数是5300次
(((AB)C)D):乘法运算的次数是8000次
((A(BC))D):乘法运算的次数是75500次
(A((BC)D):乘法运算的次数是31000次
由此我们可以看到,相乘的顺序不一样,要进行的乘法的原酸的总数也有很大的差异,那么,要如何构建一个算法呢,来求出最少的乘法的运算的操作次数?矩阵链相乘的算法如下:

function matrixChainOrder(p,n){
    let i,j,k,l,q,m=[];
    for(i = 1; i<=n; i++){
        m[i] = [];
        m[i][i] = 0;
    }
    for(l = 2; l < n; l++){
        for(i = 1; i<= n-l+1; i++){
            j = i + l - 1;
            m[i][j] = Number.MAX_SAFE_INTEGER;
            for(k = i; k <= j - 1;k++){
                // 用来计算给定括号顺序的乘法运算的次数,并将次数保存在辅助矩阵m中
                q = m[i][k] + m[k + 1][j] + p[i - 1]*p[k]*p[j]; 
                if(q < m[i][j]){
                    m[i][j] = q;
                }
            }
        }
    }
    return m[1][n-1];
}
let p = [10,100,5,50,1];
let len = p.length;
console.log(matrixChainOrder(p,len));

你可能感兴趣的:(前端开发 -- 算法模式(递归和动态规划))