一个简单的算法题引发的思考

238 阅读5分钟

问题一:斐波那契数列

斐波那契数列,其最开始的几项是1、1、2、3、5、8、13、21、34…… ,后面的每一项是前两项之和,事实上,斐波那契在数学上有自己的严格递归定义。

f0 = 1 f1 = 1 f(n) = f(n-1) + f(n-2)

方法一:递归求解

function fib(n) {
  if (n == 1 || n == 2) {
    return 1;
  }
  return fib(n - 1) + fib(n - 2);
}

console.log(fib(10))

这个方式很容易想到,只要我们用代码实现数学上的递推公式就可以了。

产生问题

运行如下结果:

function fib(n) {
  if (n == 1 || n == 2) {
    return 1;
  }
  return fib(n - 1) + fib(n - 2);
}
console.time()
console.log(fib(10)) // 55 耗时:9.102ms
console.timeEnd()

console.time()
console.log(fib(44)) // 701408733 耗时:6.494s
console.timeEnd()

我们发现在计算第44项的时候,耗时竟然惊人的到达了6.5s中,那我们计算第100项,估计宇宙毁灭了都算不出来,那怎么办呢?

哪里影响的耗时

image.png 通过树形分解,我们发现我们进行了重复计算,计算了2次fib(3),3次fib(2)。 哦豁,原来如此,是重复计算导致的耗时,我们知道哪里引起的问题,我们就可以进行优化。

方法二:增加缓存

// 性能差
function fib1(n) {
    const cache = [];
    var res = helper(cache, n);
    return res;
}

function helper(cache, n) {
    if (n == 1 || n == 2) {
        cache[n] = 1;
        return 1;
    }
    if (cache[n]) return cache[n];
    var res = helper(cache, n - 1) + helper(cache, n - 2);
    cache[n] = res;
    return res;
}

我们使用cache数组进行缓存,减少重复计算。

运行结果:

console.time()
console.log(fib1(10)) // 55 耗时:9.102ms
console.timeEnd()

console.time()
console.log(fib1(44)) // 701408733 耗时:9.599ms
console.timeEnd()

时间复杂度降下来了,完美。

继续测试

console.time()
console.log(fib1(144)) //5.5556540422429276e+29 耗时:9.229ms
console.timeEnd()


console.time()
console.log(fib1(14400))  // ????
console.timeEnd()

当计算第14400项是出现了如下情况 image.png 这是什么情况?我没有写死循环,竟然出现了死循环的报错???? 分析原因,发现是因为js开辟函数是有限制的,不允许创建过多的函数,函数达到一定的量级,便会认为是死循环。

那没办法,只能继续优化

方法三:使用循环,放弃递归

function fib2(n){
  let dp = []
  dp[1] = 1;
  dp[2] = 2;
  for(var i=3;i<=n;i++){
    dp[i] = dp[i-1] + dp[i-2]
  }
  return dp[n];
}

改成如上的代码,我们算数列的第一百万,都是轻轻松松。 ​

总结; 在做量级很大的计算时,需要放弃递归,使用for或者while循环进行,不然会出现死循环的警告。

问题二:找零问题

对于现实生活中的找零问题,假设有数目不限,面值为20,10,5,1的硬币。 求出找零方案, 要求:使用数目最少的硬币。 对于此类问题,贪心算法采取的方式是找钱时,总是选取可供找钱的硬币的最大值。比如,需要找钱数为25时,找钱方式为20+5,而不是10+10+5。 ​

这个思路也比较好想:我们拿 整钱 去面值中,从最大的开始找,如果不够找了,就找下一个面值,以此思路就可以找到解决方法了,这种思考在算法中叫贪心算法。

class Change1 {
  constructor(changeType) {
    // 倒序,大的数字在前面
    this.changeType = changeType.sort((r1, r2) => r2 - r1);
    this.cache = {};
  }
  mkChange(amount) {
    const arr = [];

    for (let i = 0; i < this.changeType.length; i++) {
      let tem = this.changeType[i];
      while (amount - tem >= 0) {
        arr.push(tem);
        amount -= tem;
      }
    }
    return arr;
  }
}
var c = new Change1([20, 10, 5, 1])

console.log(c.mkChange(126))

// 答案
[
  20, 20, 20, 20,
  20, 20,  5,  1
]

嗯,我们得出结果,找5个20的,一个5块,一个1一块,完成。

产生问题

当我们面值和找零发生变化是结果不一定对 比如说:如果提供找零的面值是11,5,1,找零15。

var c = new Change1([11,5,1])
console.log(c.mkChange(15)) [ 11, 1, 1, 1, 1 ] 

但是我们通过正常分析,我们知道应该找3个五块才是正确答案。所以在这种情况下,贪心算法就没办法找到最优解了。

如果我们想要找最优解,需要使用动态规划。

分析

凑0块钱:因为0块钱根本不需要硬币,因此结果是0:f(0) = 0; 凑1块钱:因为有1块钱面值的硬币可以使用,所以可以先用一个1块钱硬币,然后再凑够0块钱即可,而f(0)的值是我们已经算出来了的,所以:f(1) = 1 + f(0) = 1 + 0 = 1,这里f(1) = 1 + f(0) 中的1表示用一枚1块钱的硬币; ​

凑2块钱:此时四种面额的硬币中只有1块钱比2块钱小,所以只能先用一个1块钱硬币,然后再凑够1块钱即可,而f(1)的值我们也已经算出来了,所以:f(2) = 1 + f(1) = 1 + 1 = 2,这里f(2) = 1 + f(1) 中的1表示用一枚1块钱的硬币; ​

凑3块钱:和上一步同样的道理,f(3) = 1 + f(2) = 1 + 2 = 3; ​

凑4块钱:和上一步同样的道理,f(4) = 1 + f(3) = 1 + 3 = 4; ​

凑5块钱:这时就出现了不止一种选择了,因为有5块钱面值的硬币。方案一:使用一个5块钱的硬币,再凑够0块钱即可,这时:f(5) = 1 + f(0) = 1 + 0 = 1,这里f(5) = 1 + f(0) 中的1表示用一枚5块钱的硬币;方案二:使用1个1块钱的硬币,然后再凑够4块钱,此时:f(5) = 1 + f(4) = 1 + 4 = 5。综合方案一和方案二,可得:f(5) = min{1 + f(0),1 + f(4)} = 1; ​

凑6块钱:此时也有两种方案可选,方案一:先用一个1块钱,然后再凑够5块钱即可,即:f(6) = 1 + f(5) = 1 + 1 = 2;方案二:先用一个5块钱,然后再凑够1块钱即可,即:f(6) = 1 + f(1) = 1 + 1 = 2。综合两种方案,有:f(6) = min{1 + f(5), 1 + f(1)} = 2; ​

…(省略) ​

从上面的分析过程可以看出,要凑够i块钱,就要考虑如下各种方案的最小值: 1 + f(i - value[j]),其中value[j]表示第j种(j从0开始,0 <= j < 4)面值且value[j] <= i​ 那么现在就可以写出状态转移方程了: ​

f(i) = 0, i = 0 ​

f(i) = 1, i = 1 ​

f(i) = min{1 + f(i - value[j])}, i > 1,value[j] <= i

代码

// 动态规划
class Change {
  constructor(changeType) {
    this.changeType = changeType;
    this.cache = {};
    this.typeCount = {};
    this.fn = {};
  }
  // 找的  amount 块钱的最优解,并发他放在 cache 缓存住
  mkChange(amount) {
    let min = []; // 最后方案
    if (!amount) {
      return [];
    }
    if (this.cache[amount]) {
      return JSON.parse(this.cache[amount]);
    }
    for (let i = 0; i < this.changeType.length; i++) {
      // 先找一张试试
      const hasAmount = amount - this.changeType[i];
      let newMin; // 临时方案
      if (hasAmount >= 0) {
        //继续找钱
        newMin = this.mkChange(hasAmount);
      }
      if (hasAmount >= 0) {
        if (newMin.length < min.length) {
          min = [this.changeType[i]].concat(newMin);
        }
        if (min.length === 0) {
          // 如果最优解不存在,那么他本上就是最优解
          min = [this.changeType[i]].concat(newMin);
        }
      }
    }
    this.cache[amount] = JSON.stringify(min);
    return min;
  }
  mkChangefn(amount, limit = 100) {
    let calc = 0;
    while (amount > calc) {
      calc += 1;
      this.mkChange(calc);
    }
    return this.mkChange(amount).join(",");
  }
  
  mk(amount) { // 使用自底向上计算
    // let max = amount + 1;
    let dp = new Array(amount + 1).fill(null);
    dp[0] = []
    for (let i = 1; i <= amount; i++) {
      for (let j = 0; j < this.coins.length; j++) {
        if (this.coins[j] <= i && dp[i - this.coins[j]]) {
          if (dp[i]) {
            var tem = [this.coins[j], ...(dp[i - this.coins[j]])]
            if (dp[i].length > tem.length) {
              dp[i] = tem
            }
          } else {
            dp[i] = [this.coins[j], ...dp[i - this.coins[j]]]
          }

        }
      }
    }
    // console.log(dp[amount])
    return dp[amount] ? dp[amount].length : -1;
  }
}
var c = new Change([11, 5, 1])
console.log(c.mkChangefn(15)) // 5,5,5