难度标识:
⭐:简单,⭐⭐:中等,⭐⭐⭐:困难
。
tips:这里的难度不是根据LeetCode难度定义的,而是根据我解题之后体验到题目的复杂度定义的。
1.全排列 ⭐
思路
解这题的思路是使用回溯算法。
-
回溯函数定义:定义一个回溯函数,该函数接受当前的排列作为参数。
-
结束条件:当当前排列的长度与输入数组的长度相等时,我们找到了一个有效的排列。
-
选择与回溯:遍历输入数组中的每个数字。对于那些还未被选择的数字,选择它并进入下一层递归。返回到当前层后,取消对该数字的选择,然后尝试下一个数字。
通过这种方式,我们会尝试数组中的每个数字在每个位置的所有可能性,直到找到所有的排列。
代码
var permute = function (nums) {
const res = []
function backtrack(path) {
if (path.length === nums.length) {
return res.push([...path])
}
for (let num of nums) {
if (!path.includes(num)) {
path.push(num)
backtrack(path)
path.pop()
}
}
}
backtrack([])
return res
};
2.子集 ⭐
思路
这题也是使用回溯算法解决。
-
子集的构建:每个数字有两种选择:它可以出现在某个子集中,或者不出现。这为我们提供了构建子集的途径。
-
回溯:从数组的第一个数字开始,为每个数字做决策:要么加入当前子集,要么不加入。对于每个决策,递归地处理数组中的下一个数字。
-
收集结果:在遍历过程中,每构建一个子集都将其加入结果集中。这样,当遍历完所有数字后,结果集中就包含了所有可能的子集。
代码
var subsets = function (nums) {
const res = []
function backtrack(path, start) {
res.push([...path])
for (let i = start; i < nums.length; i++) {
path.push(nums[i])
backtrack(path, i + 1)
path.pop()
}
}
backtrack([], 0)
return res
};
3.电话号码的字母组合 ⭐
思路
解这题的思路是使用深度优先搜索遍历所有可能的结果,然后将符合条件的结果加入到结果数组中。
-
数字到字母映射:为每个数字预定义一个与之对应的字母集合。
-
深度优先搜索:逐个处理数字,对于当前数字,遍历其对应的所有字母,为其选择一个,并继续处理下一个数字。
-
收集结果:当得到的字母组合长度等于原始数字串的长度时,记录该组合。
代码
var letterCombinations = function (digits) {
if (!digits.length) return []
const res = []
const phoneObj = {
'2': 'abc',
'3': 'def',
'4': 'ghi',
'5': 'jkl',
'6': 'mno',
'7': 'pqrs',
'8': 'tuv',
'9': 'wxyz',
}
function dfs(path, index) {
if (path.length === digits.length) {
return res.push(path)
}
for (let letter of phoneObj[digits[index]]) {
dfs(path + letter, index + 1)
}
}
dfs('', 0)
return res
};
4.组合总和 ⭐
思路
解这题使用回溯算法。
-
回溯:使用回溯策略,从数组中选择一个数字,然后递归地选择下一个数字,直到达到目标值或超过它。
-
无限选取:由于数组中的数字可以无限次选取,每次选择一个数字后,你仍然可以再次选择它。
-
剪枝:为了提高效率,如果当前组合的和已经超过目标值,那么应该停止进一步的选择。同样,如果当前数字加上当前总和仍然小于目标,那么可以继续;否则,应该跳过这个数字。
-
避免重复组合:在每次递归时,从当前数字或下一个数字开始,而不是从数组的开头开始,以此来避免重复的组合。
-
目标达成:如果在选择过程中,当前组合的和恰好等于目标值,那么这个组合就是一个有效的解,应该将其存储到结果中。
代码
var combinationSum = function (candidates, target) {
const res = []
function backtrack(path, start, sum) {
if (sum === 0) {
return res.push([...path])
}
if (sum < 0) return;
for (let i = start; i < candidates.length; i++) {
path.push(candidates[i])
backtrack(path, i, sum - candidates[i])
path.pop()
}
}
backtrack([], 0, target)
return res
};
5.括号生成 ⭐
思路
解这题可以使用深度优先搜索(DFS)。
-
构建与约束:从空字符串开始,逐步添加括号,但在任何时刻都有规则约束可添加的括号类型。
-
确保开括号的优先性:只有还未达到指定的对数时,才能添加开括号。
-
有效性保证:只有当前的闭括号数量小于开括号数量时,才能添加闭括号,确保每个闭括号都与一个开括号配对。
-
递归搜索:使用深度优先策略,递归地尝试所有可能的组合,直到满足条件的组合被构建完毕。
简而言之,通过深度优先策略递归地添加括号,同时遵循有效性和数量的约束,以生成所有可能的有效组合。
代码
var generateParenthesis = function (n) {
const res = []
function dfs(path, start, end) {
if (path.length === 2 * n) {
return res.push(path)
}
if (start < n) {
dfs(path + '(', start + 1, end)
}
if (end < start) {
dfs(path + ')', start, end + 1)
}
}
dfs('', 0, 0)
return res
};
6.单词搜索 ⭐⭐
思路
解这题是使用回溯算法。
-
开始搜索:从每个单元格开始,检查是否可以从该单元格开始找到匹配的单词。
-
深度优先搜索:
- 对于当前的单元格位置,检查它的字符是否与单词的当前字符匹配。
- 如果匹配,继续向该单元格的所有相邻单元格(上、下、左、右)进行搜索,为了进入下一步,单词的索引加。
- 如果在某一点不匹配或者超出边界,停止在这条路径上的进一步搜索。
- 如果单词的所有字符都在路径中按顺序找到,则返回
true
。
-
回溯:
- 在开始搜索相邻的单元格之前,暂时标记当前的单元格,表示它被使用过。
- 完成对相邻单元格的搜索后,取消标记当前单元格,使其可以在其他路径中重新使用。
-
结束条件:
- 如果找到一个匹配的路径,返回
true
。 - 如果所有的开始点都搜索完毕且没有找到匹配的路径,返回
false
。
- 如果找到一个匹配的路径,返回
简而言之,从每个单元格开始,使用深度优先搜索策略搜索可能的路径,并通过回溯确保同一个单元格不会在同一路径中重复使用。
代码
var exist = function (board, word) {
const m = board.length, n = board[0].length;
const direct = [[-1, 0], [1, 0], [0, -1], [0, 1]]
function backtrack(x, y, index) {
if (x < 0 || y < 0 || x >= m || y >= n || board[x][y] !== word[index]) return false
if (index === word.length - 1) return true
const tmp = board[x][y]
board[x][y] = '/'
for (let [dx, dy] of direct) {
if (backtrack(x + dx, y + dy, index + 1)) {
return true
}
}
board[x][y] = tmp
return false
}
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (backtrack(i, j, 0)) {
return true
}
}
}
return false
};
7.分割回文串 ⭐
思路
解这题也是使用回溯算法。
-
回文判断:首先,定义一个函数来判断一个字符串是否为回文。
-
递归分割与回溯:
- 从字符串的开始位置开始,尝试所有可能的分割点。
- 对于每一个分割点,检查从开始位置到该点的子串是否为回文。
- 如果是回文,则递归地在该分割点之后的部分继续分割。
- 如果不是回文,就继续尝试下一个分割点。
- 在递归调用后,回溯到前一个状态(也就是撤销最后一次的分割选择),然后尝试其他的分割点。
-
记录答案:
- 当到达字符串的末尾时,将当前的分割方式加入答案列表中。
-
结束搜索:
- 完成字符串的所有可能分割后,返回答案列表。
简而言之,从头到尾遍历字符串,尝试每一个可能的分割点,并递归地继续在分割点后的字符串上进行同样的操作。在此过程中,始终确保分割出的子串是回文的,并使用回溯来撤销分割选择,从而尝试其他可能的分割方法。
代码
var partition = function (s) {
const res = []
function backtrack(path, start) {
if (start === s.length) {
return res.push([...path])
}
for (let i = start; i < s.length; i++) {
const subStr = s.slice(start, i + 1)
if (isCheck(subStr)) {
path.push(subStr)
backtrack(path, i + 1)
path.pop()
}
}
}
backtrack([], 0)
return res
};
function isCheck(s) {
if (s.split('').reverse().join('') === s) return true
return false
}
8.N 皇后 ⭐⭐
思路
解这题还是使用回溯的思路。
-
棋盘表示:
- 使用一个二维数组或列表来表示 n x n 的棋盘。初始化所有单元格为'.',表示空位。
-
递归回溯:
- 从第一行开始,在每一行中选择一个单元格放置皇后。
- 在选择下一行的单元格之前,检查当前放置的皇后是否与之前放置的皇后有冲突。
-
皇后冲突检查:
-
列检查:检查当前列是否已有皇后。
-
对角线检查:
- 主对角线(左上到右下)上的任何两个单元格满足行差与列差相等。
- 次对角线(左下到右上)上的任何两个单元格满足行差与列差的和为常数(n-1)。 使用这些属性,检查对角线上是否已有皇后。
-
-
回溯:
- 如果在某一行中的所有单元格都不满足放置皇后的条件,则需要回到上一行并更改皇后的位置。
- 一旦在某行中放置了皇后,继续检查下一行。如果到达棋盘的最后一行并成功放置了皇后,那么就找到了一个有效解。
-
保存找到的解:
- 当成功地在所有行中放置了皇后时,将当前棋盘的配置保存到结果列表中。
-
返回所有解:在尝试完所有可能的放置组合后,返回保存的解决方案列表。
这个问题的关键是如何有效地进行冲突检查和回溯,以确保每个解都是有效的。在实践中,还可以采用其他策略来优化搜索,例如使用一维数组代替二维数组来存储皇后的位置,或使用位操作来加速冲突检查。
代码
var solveNQueens = function (n) {
const res = []
const board = new Array(n).fill(0).map(() => new Array(n).fill('.'))
function isSafe(row, col) {
// row-i = col - j
for (let i = 0; i < row; i++) {
if (board[i][col] === 'Q' || board[i][col - row + i] === 'Q'
|| board[i][col + row - i] === 'Q') {
return false
}
}
return true
}
function backtrack(row) {
if (row === n) {
return res.push(board.map(r => r.join('')))
}
for (let i = 0; i < n; i++) {
if (isSafe(row, i)) {
board[row][i] = 'Q'
backtrack(row + 1)
board[row][i] = '.'
}
}
}
backtrack(0)
return res
};
总体来说,回溯的题目算不上难,你只要理解了递归,然后再递归的基础往回撤就行了。