LeetCode热题100的JS解释---全排列和零钱兑换问题

101 阅读5分钟

LeetCode热题100

为什么要写这个

  1. 在做题时,发现一部分复杂的问题,即使看答案也非常难以理解,我认为有更容易理解的方式去做,虽然可能性能上不是最佳解法,但是总比死背答案的好。
  2. 相当一部分题目是没有JS解法的,这对只会JS的前端来说很不友好。

全排列题目

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入: nums = [1,2,3]
输出: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:
输入: nums = [0,1] 输出: [[0,1],[1,0]]

示例 3:**
输入: nums = [1]
输出: [[1]]

解析

这个题目官方的解释是回溯算法,回溯算法名字起的很唬人,但实际上还是一个递归。 官方的实现,实在难以理解。我认为此题目完全没有那么复杂。 以[1,2,3]假如让我们手写一个全排列的结果,我们会怎么做?

  1. 固定[1],然后看[2,3]的排列,有两个结果 [2,3]和[3,2]。那么1的全排列就有[1,2,3]和[1,3,2]
  2. 固定[2],然后看[1,3]的排列,有两个结果 [1,3]和[3,1]。那么1的全排列就有[2,1,3]和[2,3,1]
  3. 固定[3],然后看[1,2]的排列,有两个结果 [1,2]和[2,1]。那么1的全排列就有[3,1,2]和[3,2,1]
  4. 数组更长的时候,以此类推

我们可以看到规律,如果我们按照数组顺序,依次进行固定,把剩下的数字再进行排列,不就可以实现全排列了么?

按照这个逻辑,我们实现一个递归函数:

const FullPermutation =(nums)=>{
  
  // 如果只有两个数字,我们进行手动排列,这就是递归的中断情况1
  if(nums.length === 2) {
    return [[nums[1],nums[0]],[nums[0],nums[1]]]; // 处理边界情况
  }
  // 如果只有一个数字,我们直接返回原数组,这是递归中断情况2
  
  if(nums.length ===1){
    return [nums]
  }
  // 存放结果
  let resList = []
  
  // 遍历,依次固定数组中的元素,作为排列结果的第一个数字
  for(let num of nums){
    const tmpNum = num;
    // 固定第一个数字之后,把剩下的数字拿来做排列
    const res = allOrder(tmpArray.filter(s=>s!==num));
    
    // 排列完之后,再把这个固定的数字,放到排列的第一个位置
    for(let i=0;i<res.length;i++){
      res[i] =[tmpNum,...res[i]]
    }
    // 返回结果
    resList =  resList.concat(res)

  }
  return resList
}

相比官方的答案,这个结果我认为更符合普通人的做题逻辑,并且复杂度和官方的结果也是一样的。且代码量还比官方的少。而且这个实际上也是回溯的思路,只是不像官方答案那么明显的回溯。

类似的一个问题,兑换零钱。

零钱兑换

给不同的硬币金额coins,请兑换出taget的零钱。可以认为硬币每一个面额都是无限个。
示例 1:
输入: coins = [1,2,3] ,targert = 5
输出: [[1,1,1,1,1],[1,1,3],[1,1,1,2],[2,3],[2,2,1]]

解析

这个题目官方也是用回溯算法,实际上的过程就相当于把每一个硬币都递归的去加,一直去加到==target为止。 这个也没问题,但是如果让我去想,我觉得很难想到这种解法。 相反的,我认为这样子去做更容易理解:

  1. 遍历硬币金额,先拿到1,那我再去数组中找5-1
  2. 这样就变成从中[1,2,3]取4
  3. 再遍历这个数组,发现第一个取到1,那就变成从数组找4-1
  4. 此时发现可以取到

这个过程,一看还是个递归。

const findTarget = (coins, target) => {
  const res = [];
  
  // 遍历硬币面额
  for (let num of coins) {
    if (num === target) {  
  // 如果相等,则直接加入到结果中,这也是递归的终止条件
      res.push([num]) 
    } else if (num > target) {
     //如果比target大,那不用往下递归了,直接下一个面额
      continue
    } else {
    // 如果比target小,那不用往下递归了,直接下一个面额
          const sub = findTarget(coins, target - num);
          for (let subItem of sub) {
            res.push([num, ...subItem]);
          }
    }
  }
  return res;
}

我们只用十几行代码就实现了这个功能,但是运行之后发现一个问题。 比如我们cions =[3,4,5],target=9,这种算法就会把[4,5]和[5,4]当成两个结果,但是实际上这两个方案是一样的。 为什么会有这种情况?因为我们在遍历4的所有场景时,已经算了[4,5]。我们等于是把包含4的所有情况都已经算完了,因此当4遍历完的时候,我们应该从coins中把4这个面额去掉。

const findTarget = (nums, target) => {
  const res = [];
  // 注意增加了tmpArray
  const tmpArray = [...nums]
  for (let num of nums) {
    if (num === target) 
      res.push([num]);
      //当前面值已经满足了,可以把它从tmparray中去除,以防被重复计算
      tmpArray.shift()
    } else if (num > target) {
      continue
    } else {
    // 这里使用了tmpArray作为参数,思考一下原因
      const sub = findTarget(tmpArray, target - num);
      for (let subItem of sub) {
        res.push([num, ...subItem]);
      }
      //当我们把当前这个面值的所有情况已经递归完了,那么就从tmpArray中去掉这个面值
      tmpArray.shift()
    }
  }
  return res;
}

最终我们输出的结果也是符合用例的。