LeetCode热题100回溯题解析

74 阅读9分钟

难度标识:⭐:简单,⭐⭐:中等,⭐⭐⭐:困难

tips:这里的难度不是根据LeetCode难度定义的,而是根据我解题之后体验到题目的复杂度定义的。

1.全排列

思路

解这题的思路是使用回溯算法。

  1. 回溯函数定义:定义一个回溯函数,该函数接受当前的排列作为参数。

  2. 结束条件:当当前排列的长度与输入数组的长度相等时,我们找到了一个有效的排列。

  3. 选择与回溯:遍历输入数组中的每个数字。对于那些还未被选择的数字,选择它并进入下一层递归。返回到当前层后,取消对该数字的选择,然后尝试下一个数字。

通过这种方式,我们会尝试数组中的每个数字在每个位置的所有可能性,直到找到所有的排列。

代码

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.子集

思路

这题也是使用回溯算法解决。

  1. 子集的构建:每个数字有两种选择:它可以出现在某个子集中,或者不出现。这为我们提供了构建子集的途径。

  2. 回溯:从数组的第一个数字开始,为每个数字做决策:要么加入当前子集,要么不加入。对于每个决策,递归地处理数组中的下一个数字。

  3. 收集结果:在遍历过程中,每构建一个子集都将其加入结果集中。这样,当遍历完所有数字后,结果集中就包含了所有可能的子集。

代码

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.电话号码的字母组合

思路

解这题的思路是使用深度优先搜索遍历所有可能的结果,然后将符合条件的结果加入到结果数组中。

  1. 数字到字母映射:为每个数字预定义一个与之对应的字母集合。

  2. 深度优先搜索:逐个处理数字,对于当前数字,遍历其对应的所有字母,为其选择一个,并继续处理下一个数字。

  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.组合总和

思路

解这题使用回溯算法。

  1. 回溯:使用回溯策略,从数组中选择一个数字,然后递归地选择下一个数字,直到达到目标值或超过它。

  2. 无限选取:由于数组中的数字可以无限次选取,每次选择一个数字后,你仍然可以再次选择它。

  3. 剪枝:为了提高效率,如果当前组合的和已经超过目标值,那么应该停止进一步的选择。同样,如果当前数字加上当前总和仍然小于目标,那么可以继续;否则,应该跳过这个数字。

  4. 避免重复组合:在每次递归时,从当前数字或下一个数字开始,而不是从数组的开头开始,以此来避免重复的组合。

  5. 目标达成:如果在选择过程中,当前组合的和恰好等于目标值,那么这个组合就是一个有效的解,应该将其存储到结果中。

代码

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)。

  1. 构建与约束:从空字符串开始,逐步添加括号,但在任何时刻都有规则约束可添加的括号类型。

  2. 确保开括号的优先性:只有还未达到指定的对数时,才能添加开括号。

  3. 有效性保证:只有当前的闭括号数量小于开括号数量时,才能添加闭括号,确保每个闭括号都与一个开括号配对。

  4. 递归搜索:使用深度优先策略,递归地尝试所有可能的组合,直到满足条件的组合被构建完毕。

简而言之,通过深度优先策略递归地添加括号,同时遵循有效性和数量的约束,以生成所有可能的有效组合。

代码

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.单词搜索 ⭐⭐

思路

解这题是使用回溯算法。

  1. 开始搜索:从每个单元格开始,检查是否可以从该单元格开始找到匹配的单词。

  2. 深度优先搜索

    • 对于当前的单元格位置,检查它的字符是否与单词的当前字符匹配。
    • 如果匹配,继续向该单元格的所有相邻单元格(上、下、左、右)进行搜索,为了进入下一步,单词的索引加。
    • 如果在某一点不匹配或者超出边界,停止在这条路径上的进一步搜索。
    • 如果单词的所有字符都在路径中按顺序找到,则返回 true
  3. 回溯

    • 在开始搜索相邻的单元格之前,暂时标记当前的单元格,表示它被使用过。
    • 完成对相邻单元格的搜索后,取消标记当前单元格,使其可以在其他路径中重新使用。
  4. 结束条件

    • 如果找到一个匹配的路径,返回 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.分割回文串

思路

解这题也是使用回溯算法。

  1. 回文判断:首先,定义一个函数来判断一个字符串是否为回文。

  2. 递归分割与回溯

    • 从字符串的开始位置开始,尝试所有可能的分割点。
    • 对于每一个分割点,检查从开始位置到该点的子串是否为回文。
    • 如果是回文,则递归地在该分割点之后的部分继续分割。
    • 如果不是回文,就继续尝试下一个分割点。
    • 在递归调用后,回溯到前一个状态(也就是撤销最后一次的分割选择),然后尝试其他的分割点。
  3. 记录答案

    • 当到达字符串的末尾时,将当前的分割方式加入答案列表中。
  4. 结束搜索

    • 完成字符串的所有可能分割后,返回答案列表。

简而言之,从头到尾遍历字符串,尝试每一个可能的分割点,并递归地继续在分割点后的字符串上进行同样的操作。在此过程中,始终确保分割出的子串是回文的,并使用回溯来撤销分割选择,从而尝试其他可能的分割方法。

代码

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 皇后 ⭐⭐

思路

解这题还是使用回溯的思路。

  1. 棋盘表示

    • 使用一个二维数组或列表来表示 n x n 的棋盘。初始化所有单元格为'.',表示空位。
  2. 递归回溯

    • 从第一行开始,在每一行中选择一个单元格放置皇后。
    • 在选择下一行的单元格之前,检查当前放置的皇后是否与之前放置的皇后有冲突。
  3. 皇后冲突检查

    • 列检查:检查当前列是否已有皇后。

    • 对角线检查

      • 主对角线(左上到右下)上的任何两个单元格满足行差与列差相等。
      • 次对角线(左下到右上)上的任何两个单元格满足行差与列差的和为常数(n-1)。 使用这些属性,检查对角线上是否已有皇后。
  4. 回溯

    • 如果在某一行中的所有单元格都不满足放置皇后的条件,则需要回到上一行并更改皇后的位置。
    • 一旦在某行中放置了皇后,继续检查下一行。如果到达棋盘的最后一行并成功放置了皇后,那么就找到了一个有效解。
  5. 保存找到的解

    • 当成功地在所有行中放置了皇后时,将当前棋盘的配置保存到结果列表中。
  6. 返回所有解:在尝试完所有可能的放置组合后,返回保存的解决方案列表。

这个问题的关键是如何有效地进行冲突检查和回溯,以确保每个解都是有效的。在实践中,还可以采用其他策略来优化搜索,例如使用一维数组代替二维数组来存储皇后的位置,或使用位操作来加速冲突检查。

代码

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
};

总体来说,回溯的题目算不上难,你只要理解了递归,然后再递归的基础往回撤就行了。