【算法-LeetCode】46. 全排列(回溯算法初体验)

LeetCode46. 全排列

发布:2021年7月27日15:33:36

问题描述及示例

给定一个不含重复数字的数组 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 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整数 互不相同

我的题解

这算是我在LeetCode上碰到的第一个有关于回溯算法的题目了。当然我一开始也不知道这个题可以用回溯解决。按照我一贯的解法,我首先是想有没有什么暴力穷举的方法来解决,然后再去考虑暴力解法以外的解法。但是很可惜的是,这类题目没有什么通用的暴力解法,一旦输入的数组过长,就会陷入恐怖的多层for循环嵌套中,这就导致了暴力解法不能再施展拳脚了。

没办法,我也只能从递归的角度来想解决办法了,因为递归的思想就是将大事化小,像这一类的题目往往也会有递归的特征。稍加思考了一下,我发现全排列的问题确实可以用递归的思想来解决。

假设输入是[1,2,3],那么我们可以先取出一个元素作为某个全排列结果的第一位,假设是2,那么,那么我们只要将剩下的[1,3]再进行一次全排列(得到[1,3][3,1]),然后再把得到的结果和原先取出的2拼接起来那不得到了所有以2开头的全排列了吗(即:2 + [1,3]==>[2,1,3]2 + [3,1]==>[2,3,1])?

当然[1,3]还能再划分为更小的单位,即:在取出2的前提下,再取出1,那么就得对剩下的3作全排列(此时结果显然只有[3]一种),这样我们也就同时得到了递归终止的条件:当输入的数组长度为1时

于是我开始用这种递归方法尝试解题。下面是我最初的题解,当然,是完全无法通过测试的,提交结果为【解答错误】。o(╥﹏╥)o

// 最开始的思路,提交之后无法通过测试用例
var permute = function(nums) {
  let result = [];
  if(nums.length === 1) {
    return result[nums];
  }
  let copy = Array.from(nums);
  for(let i = 0, length = copy.length; i < length; i++) {
  	// 注意splice函数会对原数组产生影响,所以我在在上面特意弄了个copy数组
  	// 在了解了回溯算法之后,我发现下面的三句似乎其实无形之中就有了回溯的味道
    copy.splice(i,1);
    permute(copy);
    copy.splice(i,0,nums[i]);
  }
  return result;
};

其实上面的错误也非常明显,返回的结果总是会为一个空数组。感觉上面的解法体现出我对于递归的理解的不充分。具体的分析就不展开了,毕竟是个无法通过的算法。

后来我想是不是用递归解决的思路错了?但按理论分析的话,用递归确实是没问题啊。但是没办法,我始终没有想出有效的解决办法。也没有什么比递归更好的思路,于是只能开始看别人的题解思路。这才意识到要用回溯算法。

我以为无论什么题目,总还是能用暴力解法来找到一种可能性能很差但起码能运行出正确结果的题解,直到这两天遇到了动态规划和回溯,尤其是回溯。

我的题解1(回溯算法)

准确来说,这不是“我的题解”,而是参照别人的题解写的。总体思路是先创建一个特定的辅助函数backtracking()来完成回溯搜索(也可以理解为一种遍历吧)的操作。得益于JavaScript的特性,我们可以在主函数permute()内部定义这个函数,这样也更便于backtracking()使用resulttemp两个外部变量(相对于backtracking()来说)。其中result是用来存储最终的返回结果的,而temp是用来动态存储某一个全排列结果的。

backtracking()函数中的参数target就是待处理的数组对象(将要被初始化为nums),还用到了一个数组类型的变量used作为参数,是用于记录target中的数组元素的被使用情况,其元素为布尔类型(true代表该元素被使用过,下一层递归中不能再取用了,false代表该元素没被使用过,在下一层递归中是可用的)。

/**
 * @param {number[]} nums
 * @return {number[][]}
 */

var permute = function(nums) {
  let result = [];
  let temp = [];
  // 调用backtracking对nums进行回溯搜索(遍历)操作,将获得的全排列结果逐个存于result中
  // 其中used初始化为空数组,因为空数组中默认元素值为undefined,转化为布尔值即为false
  backtracking(nums, []);
  return result;

  function backtracking(target, used) {
  	// 递归的终止条件,当特temp的长度和target的长度相等时,说明得到了一个全排列结果
    if(temp.length === target.length) {
      // console.log('path-res:',path);	// 调试语句,可通过开发者工具查看过程
      // 若满足条件,则将temp中存储的那个全排列结果放入result,并用return结束一次递归
      // 注意这里不能简单地写作result.push(temp),否则result中只会存入一个空数组
      // 使用扩展运算符可以对temp进行一次深拷贝,当然用Array.from(temp)也可以
      // [...temp]可以理解为:[] = Array.of(...temp)
      result.push([...temp]);
      // console.log('result:',result);
      return;
    }

	// 这里的for循环可以理解为对要处理的数组的遍历
    for(let i = 0, length = target.length; i < length; i++) {
      // 如果该位元素已经被使用过了则跳过这次循环以防止在一次遍历中重复使用该元素
      // 这里的!!是确保used[i]被转换为布尔值,当然undefined默认就被转为false,
      if(!!used[i]) {
        continue;
      }
      // 如果该元素没被用过,则将其存入temp
      temp.push(target[i]);
      // 同时标志该位元素已被用过
      used[i] = true;
      // console.log('path-gen:',path,' ',used,i);	// 调试语句,可通过开发者工具查看过程
      // 用递归对剩余元素作遍历,其中used数组发挥关键作用,它让下一层递归知道哪些元素可用
      // 也就是告诉下一层递归数组中的哪些元素是所谓的【剩余元素】
      backtracking(target, used);
      // 将刚才存入temp的元素弹出,并更新元素使用状态,以确保能够正确完成“回溯”这一过程
      // 回溯算法的精髓就在就在于此,即:将状态恢复(回溯)到上一步
      temp.pop();
      used[i] = false;
      // console.log('path-bak:',path,' ',used,i);	// 调试语句,可通过开发者工具查看过程
    }
  }
};


提交记录
26 / 26 个通过测试用例
状态:通过
执行用时: 92 ms,在所有 JavaScript 提交中击败了82%的用户
内存消耗: 40.6 MB,在所有 JavaScript 提交中击败了61%的用户
时间:202172712:53:02

可以在Chrome浏览器中的开发者工具中观察运行过程,重点观察调用栈Call Stack的变化,体会递归过程。同时观察控制台输出,体会回溯过程。
46.全排列-开发者工具观察执行过程

更新:2021年7月30日02:05:52
注意:如果在上面那个调试界面看不到Console控制台,可以在该界面按下Esc键,或者点击按下图所示的菜单项:
在这里插入图片描述

同时也可以通过在线JS编辑器来运行调试,观察控制台输出。JS在线编辑器
jsrun.net

非常感谢博主:【代码随想录】的相关讲解,详细讲解可看下方的参考链接。
同时,博主:【liweiwei1419】的相关讲解也很到位,详细讲解可看下方的参考链接。

根据上面博主的讲解,回溯算法其实就是一种暴力解法,而将回溯算法的过程抽象为对一个N叉树的遍历(搜索)是一个很好贴切的理解方式,其中for循环可以看做是对树的广度遍历,而递归可以看做是对树的深度遍历(同时也解决了数组过长时导致的多层for循环嵌套问题)。虽然普遍认为递归的性能较差,但是递归确实是将复杂问题的逻辑简化的利器。

另外,博主还提到了剪枝操作可以在一定程度上优化算法,但目前暂且不做研究,待日后有更深的理解后再做补充。

官方题解

更新:2021年7月29日18:43:21

因为我考虑到著作权归属问题,所以【官方题解】部分我不再粘贴具体的代码了,可到下方的链接中查看。

更新:2021年7月29日19:53:06

参考:全排列 - 全排列 - 力扣(LeetCode)

【更新结束】

有关参考

更新:2021年7月27日12:57:23
参考:算法图解part3:递归&栈_catkin_ws的博客-CSDN博客
参考:js内部函数的定义及调用_Honeyhanyu的博客-CSDN博客_js函数内部定义函数
参考:「代码随想录」带你学透回溯算法!46. 全排列 - 全排列 - 力扣(LeetCode)
参考:「代码随想录」带你学透回溯算法(理论篇)| 回溯法精讲!_哔哩哔哩_bilibili
参考:「代码随想录」带你学透回溯算法-组合问题(对应力扣题目:77.组合)| 回溯法精讲!_哔哩哔哩_bilibili
更新:2021年7月27日15:10:06
参考:Array.of() - JavaScript | MDN
参考:ES6—数组的扩展(扩展运算符&&Array方法)的讲解_IT_qslong的博客-CSDN博客
更新:2021年7月27日15:16:55
参考:【liweiwei1419】回溯算法入门级详解 + 练习(持续更新) - 全排列 - 力扣(LeetCode)
更新:2021年7月27日15:42:33
参考:JavaScript实现 - LeetCode刷题 -【全排列】- 第 46 题 !!!_帅帅邬同学的博客-CSDN博客
参考:Leetcode 46:全排列(最详细的解法!!!)_coordinate的博客-CSDN博客

【我做的其他回溯相关题目】
更新:2021年10月26日13:48:34
参考:【算法-LeetCode】47. 全排列 II(回溯;有重复元素的全排列)_赖念安的博客-CSDN博客
参考:【算法-LeetCode】93. 复原 IP 地址(回溯;递归)_赖念安的博客-CSDN博客
参考:【算法-LeetCode】22. 括号生成(回溯;有重复元素的全排列)_赖念安的博客-CSDN博客
参考:【算法-LeetCode】78. 子集(回溯)_赖念安的博客-CSDN博客
参考:【算法-LeetCode】236. 二叉树的最近公共祖先(递归;回溯)_赖念安的博客-CSDN博客
参考:【算法-剑指 Offer】38. 字符串的排列(回溯;有重复元素的全排列)_赖念安的博客-CSDN博客
更新:2021年11月16日17:24:16
参考:【算法-LeetCode】39. 组合总和(回溯;递归)_赖念安的博客-CSDN博客
参考:【算法-剑指 Offer】12. 矩阵中的路径(回溯)_赖念安的博客-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值