暴力递归-dfs&回溯

刚开始学习算法的时候,看了某大佬讲解回溯算法和 dfs 的区别:

js

// DFS 算法把「做选择」「撤销选择」的逻辑放在 for 循环外面
var dfs = function (root) {
    if (root == null) return
    // 做选择
    console.log('我已经进入节点 ' + root + ' 啦')
    for (var i in root.children) {
        dfs(root.children[i])
    }
    // 撤销选择
    console.log('我将要离开节点 ' + root + ' 啦')
}

// 回溯算法把「做选择」「撤销选择」的逻辑放在 for 循环里面
var backtrack = function (root) {
    if (root == null) return
    for (var i in root.children) {
        // 做选择
        console.log('我站在节点 ' + root + ' 到节点 ' + root.children[i] + ' 的树枝上')
        backtrack(root.children[i])
        // 撤销选择
        console.log('我将要离开节点 ' + root.children[i] + ' 到节点 ' + root + ' 的树枝上')
    }
}

后面在 B 站看了左神的算法课,他说了这么一句话:国外根本就不存在什么回溯的概念,就是暴力递归

纸上得来终觉浅,绝知此事要躬行!

js

/** 就用最简单的二叉树来看看,到底,在外面和在里面做选择与撤销选择有什么区别 */
class Node {
    constructor(val) {
        this.left = null
        this.right = null
        this.val = val
    }
}
/*  构建出简单的一棵树,构建过程简单,就不赘述了
                1
              /   \
            2      3
          /  \   /  \
        4     5 6    7
*/
function dfs(root) {
    if (root === null) return
    console.log(`--->> 入 ${root.val}   ---`)
    for (const branch of [root.left, root.right]) {
        dfs(branch)
    }
    console.log(`<<--- 出 ${root.val}   ---`)
}
dfs(root)

console.log('🔥🔥🔥 ---------------------------  🔥🔥🔥')

function backtrack(root) {
    if (root === null || root.left === null || root.right === null) return
    for (const branch of [root.left, root.right]) {
        console.log(`--->> ${root.val} - ${branch.val} 的树枝上; branch.val: ${branch.val}`)
        backtrack(branch)
        console.log(`<<--- ${root.val} - ${branch.val} 的树枝上; branch.val: ${branch.val}`)
    }
}
backtrack(root)

// --->> 入 1   ---
// --->> 入 2   ---
// --->> 入 4   ---
// <<--- 出 4   ---
// --->> 入 5   ---
// <<--- 出 5   ---
// <<--- 出 2   ---
// --->> 入 3   ---
// --->> 入 6   ---
// <<--- 出 6   ---
// --->> 入 7   ---
// <<--- 出 7   ---
// <<--- 出 3   ---
// <<--- 出 1   ---
//
// 🔥🔥🔥 ---------------------------  🔥🔥🔥
//
// --->> 1 - 2 的树枝上; branch.val: 2
// --->> 2 - 4 的树枝上; branch.val: 4
// <<--- 2 - 4 的树枝上; branch.val: 4
// --->> 2 - 5 的树枝上; branch.val: 5
// <<--- 2 - 5 的树枝上; branch.val: 5
// <<--- 1 - 2 的树枝上; branch.val: 2
// --->> 1 - 3 的树枝上; branch.val: 3
// --->> 3 - 6 的树枝上; branch.val: 6
// <<--- 3 - 6 的树枝上; branch.val: 6
// --->> 3 - 7 的树枝上; branch.val: 7
// <<--- 3 - 7 的树枝上; branch.val: 7
// <<--- 1 - 3 的树枝上; branch.val: 3

观察不难发现,dfs 的 root.val 打印和 backtrack 的 branch.val 打印只是相差了第一个节点的值,这是因为回溯内做选择是直接从 「邻居」开始!

因此,“for 外选择是节点,for 内选择是树枝” — 是前人总结出来的经验,从而抽象出来的概念。学习一定要知其然且知其所以然,不能迷失在各种概念里!

总结:

  • 回溯也是 dfs,只不过是特殊的策略罢了,比如「排列组合」类问题,往往都是往一个「空盒子」里装选择的节点,当空盒子满足 target 时,就是一个可行解。因为是从一个「空盒子」开始,所以也就是在各个树枝上做选择放入盒子里,因此适合在 for 内做选择。

  • 传统的 dfs,就是递归穷尽到叶节点,比如求两个节点之间的距离,自然是节点与节点之间的关系,所以适合在 for 外做选择。


js

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum = function (candidates, target) {
    /**
     * 无重复元素,所以不用剪枝
     * 可以被重复选取,那么就是类似这么个树
     *            1        2       3
     *          1 2 3    1 2 3   1 2 3
     *          .....    .....    ....
     */
    const res = []
    const track = []
    let sum = 0
    /**
     * 仍然不要忘记定义递归
     * 输入:层级 level,输出 void; 在递归过程中把可行解塞入 res
     * 结束条件,这道题比较明显 sum === target
     */
    const backtrack = level => {
        if (sum === target) {
            res.push([...track]) // 注意拷贝一下
            return
        }
        if (sum > target) return // 结束条件不要忘了~
        // level 在这里的含义是1.在 level 层, 2.在候选数据 [level...end] 区间内做选择,通过保证元素的相对顺序,来避免重复的组合
        // 如果每次都从 0 开始,那么 可能会产生 [1,2] [2,1] 这样重复的组合,不过这对排列来说,是有用的
        for (let i = level; i < candidates.length; ++i) {
            track.push(candidates[i])
            sum += candidates[i]
            backtrack(i) // 可重复使用,i + 1 则是在下一层排除了自己
            track.pop()
            sum -= candidates[i]
        }
    }
    backtrack(0)
    return res
}

此题完全弄懂之后,排列组合就都是纸老虎了。

js

/**
 * 与 lc.39 唯二不同,1.有重复元素,2.不能复用元素
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum2 = function (candidates, target) {
    const res = []
    const track = []
    let sum = 0
    candidates.sort((a, b) => a - b)
    const backtrack = level => {
        if (sum === target) {
            res.push([...track])
            return
        }
        if (sum > target) return
        for (let i = level; i < candidates.length; ++i) {
            // 因为元素有重复的,所以需要先进行 「排序」,然后进行剪枝
            if (i > level && candidates[i] === candidates[i - 1]) continue
            track.push(candidates[i])
            sum += candidates[i]
            backtrack(i + 1) // 不能复用元素,下一层 level 不能包扩 i 自身
            sum -= candidates[i]
            track.pop()
        }
    }
    backtrack(0)
    return res
}

js

/**
 * @param {number} k
 * @param {number} n
 * @return {number[][]}
 */
var combinationSum3 = function (k, n) {
    const res = []
    const track = []
    let sum = 0
    const backtrack = level => {
        if (sum === n && track.length === k) {
            res.push([...track])
            return
        }
        if (sum > n) return
        for (let i = level; i < 10; ++i) {
            if (track.length > k) continue
            track.push(i)
            sum += i
            backtrack(i + 1)
            track.pop()
            sum -= i
        }
    }
    backtrack(1)
    return res
}

js

/**
 * @param {number} n
 * @param {number} k
 * @return {number[][]}
 */
var combine = function (n, k) {
    const res = []
    const track = []
    const backtrack = level => {
        if (track.length === k) {
            res.push([...track])
            return
        }
        for (let i = level; i <= n; ++i) {
            track.push(i)
            backtrack(i + 1)
            track.pop()
        }
    }
    backtrack(1)
    return res
}

索然无味的一题~


js

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function (nums) {
    //           root
    //      1      2      3
    //   2   3     3
    // 3
    const res = [[]]
    const track = []
    const backtrack = level => {
        // res.push([...track]) 在这里加入 是另一种无需 res 提前 [[]]
        // 此处是在「节点」操作区
        if (track.length === nums.length) return
        for (let i = level; i < nums.length; ++i) {
            track.push(nums[i])
            // 在这里加入 需要 res 需要提前加一个空 []
            // 此处是 「树枝」操作区
            res.push([...track])
            backtrack(i + 1)
            track.pop()
        }
    }
    backtrack(0)
    return res
}

js

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsetsWithDup = function (nums) {
    const res = []
    const track = []
    nums.sort((a, b) => a - b)
    const backtrack = level => {
        res.push([...track])
        if (level === nums.length) return
        for (let i = level; i < nums.length; ++i) {
            if (i > level && nums[i] === nums[i - 1]) continue
            track.push(nums[i])
            backtrack(i + 1)
            track.pop()
        }
    }
    backtrack(0)
    return res
}

排列和组合最大的区别是:

  • 组合无序,[1,2]和 [2,1] 是同一个组合,所以需要 level 来控制
  • 排序有序,所以每次都是从 level-0 开始,但是不能重复使用元素,就需要一个 「used」(可以为一个简单的 boolean[]数组,也可以为一个栈) 来进行剪枝操作

js

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function (nums) {
    const res = []
    const track = []
    const used = []
    const backtrack = () => {
        if (track.length === nums.length) {
            res.push([...track])
            return
        }
        for (let i = 0; i < nums.length; ++i) {
            if (used[i]) continue // 剪枝
            track.push(nums[i])
            used[i] = true
            backtrack()
            track.pop()
            used[i] = false
        }
    }
    backtrack()
    return res
}

js

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permuteUnique = function (nums) {
    const res = []
    const track = []
    const used = []
    nums.sort((a, b) => a - b)
    const backtrack = () => {
        if (track.length === nums.length) {
            res.push([...track])
            return
        }
        for (let i = 0; i < nums.length; ++i) {
            if (used[i]) continue // 去除自身剪枝
            if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue // 关键
            track.push(nums[i])
            used[i] = true
            backtrack()
            track.pop()
            used[i] = false
        }
    }
    backtrack()
    return res
}

上方 !used[i-1] 是为了去除重复的排列,当同一层前后两个元素相同时,如果前一个元素没有使用,那么就 continue,这样做的结果就是会让 [2,2',2''] 这样的数组保持固定的顺序,即 2 一定在 2’ 前,2’ 一定在 2’’ 前。如果改为 used[i-1] 也能得到去重的效果,就是固定顺序为 2’’ -> 2’ -> 2,但是剪枝的效率会大大折扣,可以参考 labuladong 大佬的示意图理解。

js

/**
 * @param {number} n
 * @return {string[][]}
 */
var solveNQueens = function (n) {
    // 这道题是二维的 track,所以得先初始化一个二维棋盘再说
    // 同时类似组合,需要 level 来控制深度;又似排列,需要每次都遍历每一行的每一个
    const res = []
    const board = Array.from(Array(n), () => Array(n).fill('.'))
    const backtrack = level => {
        if (level === n) {
            res.push([...board.map(item => item.join(''))])
            return
        }
        for (let i = 0; i < n; ++i) {
            //剪枝操作
            if (!isValid(board, level, i)) continue
            board[level][i] = 'Q'
            backtrack(level + 1)
            board[level][i] = '.'
        }
    }
    backtrack(0)
    return res
}
/**
 * 判断是否合格,因为是从上往下铺的,所以只判断左上,上,右上即可
 */
function isValid(board, row, col) {
    for (let i = 0; i < row; ++i) {
        if (board[i][col] === 'Q') return false
    }
    for (let i = row - 1, j = col - 1; i >= 0; --i, --j) {
        if (board[i][j] === 'Q') return false
    }
    for (let i = row - 1, j = col + 1; i >= 0; --i, ++j) {
        if (board[i][j] === 'Q') return false
    }
    return true
}

给你输入一个数组 nums 和一个正整数 k,请你判断 nums 是否能够被平分为元素和相同的 k 个子集。

典中典,也是深入理解回溯的绝佳好题,必会必懂。

js

// lc.416 分割两个等和子集,可以用动态规划去做,本文是 k 个子集,得上 dfs
// lc.78 是求出所有子集,这道题是固定了子集的数量,去分配
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {boolean}
 */
var canPartitionKSubsets = function (nums, k) {
    if (k > nums.length) return false
    const sum = nums.reduce((a, b) => a + b)
    if (sum % k !== 0) return false

    const bucketTarget = sum / k
    const used = []
    // 定义递归:输入 k 号桶, 每个数字不能重复使用,所以从 level 层开始选择
    // 输出:k 号桶,是否应该把 nums[level] 加入进来
    const backtrack = (k, level, bucketSum) => {
        if (k === 0) return true // 所有桶装满了
        // 一个桶装满了,继续下一个桶
        if (bucketSum === bucketTarget) {
            return backtrack(k - 1, 0, 0) // 从 0 层 0 sum 重新开始累加和
        }
        for (let i = level; i < nums.length; ++i) {
            if (used[i]) continue // 剪枝,被用过啦
            if (nums[i] + bucketSum > bucketTarget) continue // 这个数装不得,装了就超载~
            used[i] = true
            bucketSum += nums[i]
            if (backtrack(k, level + 1, bucketSum)) return true // 递归下一个数字是否加入桶
            used[i] = false
            bucketSum -= nums[i]
        }
        return false
    }
    return backtrack(k, 0, 0)
}
/**
 * 上方代码,逻辑上是没有问题的,但是效率低下,跑力扣的测试用例会超时
 *
 * 优化自然是可以想到 memo 缓存,再就是我没想到的 used 改为位运算[捂脸]。。。
 */
var canPartitionKSubsets = function (nums, k) {
    if (k > nums.length) return false
    const sum = nums.reduce((a, b) => a + b)
    if (sum % k !== 0) return false

    const bucketTarget = sum / k
    let used = 0 // 使用位图技巧
    const backtrack = (k, level, bucketSum, memo) => {
        if (k === 0) return true
        if (bucketSum === bucketTarget) {
            const nextBucket = backtrack(k - 1, 0, 0, memo)
            memo.set(used, nextBucket)
            return nextBucket
        }
        if (memo.has(used)) {
            // 避免冗余计算
            return memo.get(used)
        }
        for (let i = level; i < nums.length; ++i) {
            // 判断第 i 位是否是 1
            if (((used >> i) & 1) === 1) {
                continue // nums[i] 已经被装入别的桶中
            }
            if (nums[i] + bucketSum > bucketTarget) continue
            used |= 1 << i // 将第 i 位置为 1
            bucketSum += nums[i]
            if (backtrack(k, level + 1, bucketSum, memo)) return true
            used ^= 1 << i // 使用异或运算将第 i 位恢复 0
            bucketSum -= nums[i]
        }
        return false
    }
    return backtrack(k, 0, 0, new Map())
}

这道题,我是觉得挺有难度的。。。位运算俺着实没想到啊 😭

推荐阅读:「labuladong 球盒模型」

js

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function (n) {
    // 当出现右括号数量 > 左括号数量时,无效
    const res = []
    const track = []
    const backtrack = (l, r) => {
        if (l > r) return
        if (l < 0 || r < 0) return
        if (l === 0 && r === 0) {
            res.push([...track].join(''))
            return
        }
        /** 之前可选择的是很多个,这里就两个直接摊开写方便 */
        track.push('(')
        backtrack(l - 1, r)
        track.pop()
        track.push(')')
        backtrack(l, r - 1)
        track.pop()
    }
    backtrack(n, n)
    return res
}

建议先做岛屿类问题,再做此题。

js

/**
 * 根据题意,一开始很容易写出这样子的代码,但是此刻 dfs 是什么意思呢?应该是一个探测的过程
 * 也就是说 -- 此处有回溯!与岛屿类问题 flood fill 算法不同的是:flood fill 它直接就 flush 掉了找到的陆地
 * 而此处的 dfs 是在不断探测的,是要走回头路的,所以这两个 for 循环是在 dfs 之内的,即:
 * dfs() {for(for(选择 dfs 撤销选择))}
 */
var solveSudoku = function (board) {
    for (let i = 0; i < m; ++i) {
        for (let j = 0; j < n; ++j) {
            if (board[i][j] === '.') {
                for (let k = 1; k <= 9; ++k) {
                    dfs(grid, i, j, k.toString())
                }
            }
        }
    }
}
/** 开奖 */
/**
 * @param {character[][]} board
 * @return {void} Do not return anything, modify board in-place instead.
 */
var solveSudoku = function (board) {
    dfs(board)
}
function dfs(grid) {
    for (let i = 0; i < 9; ++i) {
        for (let j = 0; j < 9; ++j) {
            if (grid[i][j] === '.') {
                for (let k = 1; k <= 9; ++k) {
                    // 重点是剪枝
                    if (!isValid(grid, i, j, k.toString())) continue
                    grid[i][j] = k.toString()
                    if (dfs(grid)) return true // 找到一种可行解 直接结束
                    grid[i][j] = '.'
                }
                return false // 9 个数字都不行
            }
        }
    }
    return true // 遍历完没有返回 false,说明找到了一组合适棋盘位置了
}
function isValid(grid, row, col, tryVal) {
    for (let i = 0; i < 9; ++i) {
        if (grid[row][i] === tryVal) return false
        if (grid[i][col] === tryVal) return false
    }

    const startRow = Math.floor(row / 3) * 3
    const startCol = Math.floor(col / 3) * 3
    for (let i = startRow; i < startRow + 3; ++i) {
        for (let j = startCol; j < startCol + 3; ++j) {
            if (grid[i][j] === tryVal) return false
        }
    }

    return true
}

首先考验的是 dfs 遍历二维数组的能力。

js

/**
 * 在二维矩阵中的 dfs --- for{for{dfs}}
 */
function dfs(matrix, i, j, visited) {
    if (i < 0 || j < 0 || i >= m || j >= n) return
    /** 进节点 */
    if (visited[i][j]) return
    visited[i][j] = true
    /** 上下左右遍历 */
    dfs(matrix, i - 1, j)
    dfs(matrix, i + 1, j)
    dfs(matrix, i, j - 1)
    dfs(matrix, i, j + 1)
    /** 出节点 */
}

/** 此外有方向数组的技巧 */
const dirs = [
    [-1, 0],
    [1, 0],
    [0, -1],
    [0, 1]
]
// 把上方的四个 dfs 配合 dirs 改为 for 循环即可
for (var d of dirs) {
    const next_i = i + d[0]
    const next_j = j + d[1]
    dfs(matrix, next_i, next_j, visited)
}

js

/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = function (grid) {
    let res = 0
    const m = grid.length
    const n = grid[0].length
    const visited = Array.from(Array(m), () => Array(n).fill(false))
    for (let i = 0; i < m; ++i) {
        for (let j = 0; j < n; ++j) {
            // 从某个陆地节点开始 detect
            if (grid[i][j] === '1') {
                res++
                dfs(grid, i, j, visited)
            }
        }
    }
    return res
}
function dfs(grid, i, j, visited) {
    const m = grid.length
    const n = grid[0].length
    if (i < 0 || j < 0 || i >= m || j >= n) return
    if (grid[i][j] === '0') return
    grid[i][j] = '0' // 淹没土地
    dfs(grid, i - 1, j, visited)
    dfs(grid, i + 1, j, visited)
    dfs(grid, i, j - 1, visited)
    dfs(grid, i, j + 1, visited)
}

这种“淹掉岛屿”的 dfs 算法有自己的名字 — 「经典的 Flood fill 算法」,这样可以不用维护 visited 数组。如果题目要求不能修改原数组,那么还是用 visited 去做,就此题而言具体就是多两步操作,一个是在 res++ 前判断是否 visit 过,另一个就是在每次 dfs 前判断是否 visit 过。

另外此题还可以使用 BFS 和 并查集 解决

js

/**
 * @param {number[][]} grid
 * @return {number}
 */
var maxAreaOfIsland = function (grid) {
    let maxArea = 0
    const m = grid.length
    const n = grid[0].length
    for (let i = 0; i < m; ++i) {
        for (let j = 0; j < n; ++j) {
            if (grid[i][j] === 1) {
                maxArea = Math.max(maxArea, dfs(grid, i, j))
            }
        }
    }
    return maxArea
}
// 继续 flood fill
function dfs(grid, i, j) {
    const m = grid.length
    const n = grid[0].length
    if (i < 0 || j < 0 || i >= m || j >= n) return 0
    if (grid[i][j] === 0) return 0
    grid[i][j] = 0
    return dfs(grid, i - 1, j) + dfs(grid, i + 1, j) + dfs(grid, i, j - 1) + dfs(grid, i, j + 1) + 1
}

js

/**
 * @param {number[][]} grid
 * @return {number}
 */
var numEnclaves = function (grid) {
    let res = 0
    const m = grid.length
    const n = grid[0].length
    // 先把四个边界的淹没掉
    for (let i = 0; i < m; ++i) {
        dfs(grid, i, 0)
        dfs(grid, i, n - 1)
    }
    for (let i = 0; i < n; ++i) {
        dfs(grid, 0, i)
        dfs(grid, m - 1, i)
    }
    for (let i = 0; i < m; ++i) {
        for (let j = 0; j < n; ++j) {
            if (grid[i][j] === 1) {
                res++
            }
        }
    }
    return res
}
function dfs(grid, i, j) {
    const m = grid.length
    const n = grid[0].length
    if (i < 0 || j < 0 || i >= m || j >= n) return
    if (grid[i][j] === 0) return
    grid[i][j] = 0
    dfs(grid, i - 1, j)
    dfs(grid, i + 1, j)
    dfs(grid, i, j - 1)
    dfs(grid, i, j + 1)
}

js

/**
 * @param {number[][]} grid
 * @return {number}
 */
var closedIsland = function (grid) {
    let res = 0
    const m = grid.length
    const n = grid[0].length
    for (let i = 0; i < m; ++i) {
        dfs(grid, i, 0)
        dfs(grid, i, n - 1)
    }
    for (let j = 0; j < n; ++j) {
        dfs(grid, 0, j)
        dfs(grid, m - 1, j)
    }
    for (let i = 0; i < m; ++i) {
        for (let j = 0; j < n; ++j) {
            if (grid[i][j] === 0) {
                res++
                dfs(grid, i, j)
            }
        }
    }
    return res
}
function dfs(grid, i, j) {
    const m = grid.length
    const n = grid[0].length
    if (i < 0 || j < 0 || i >= m || j >= n) return
    if (grid[i][j] === 1) return
    grid[i][j] = 1
    dfs(grid, i - 1, j)
    dfs(grid, i + 1, j)
    dfs(grid, i, j - 1)
    dfs(grid, i, j + 1)
}

这道题需要稍微思考一下,同样大小的 grid,统计 grid2 中的子岛屿,直接遍历是不太好操作的,得先排除掉非子岛屿,然后再统计。什么是非子岛屿,那就是在 grid 中是陆地,但是在 grid1 中是海洋,直接淹掉,这样最后再遍历的时候剩下的就都是子岛屿啦。

js

/**
 * @param {number[][]} grid1
 * @param {number[][]} grid2
 * @return {number}
 */
var countSubIslands = function (grid1, grid2) {
    let res = 0
    const m = grid2.length
    const n = grid2[0].length
    for (let i = 0; i < m; ++i) {
        for (let j = 0; j < n; ++j) {
            if (grid2[i][j] === 1 && grid2[i][j] !== grid1[i][j]) {
                dfs(grid2, i, j)
            }
        }
    }
    for (let i = 0; i < m; ++i) {
        for (let j = 0; j < n; ++j) {
            if (grid2[i][j] === 1) {
                res++
                dfs(grid2, i, j)
            }
        }
    }
    return res
}
function dfs(grid, i, j) {
    const m = grid.length
    const n = grid[0].length
    if (i < 0 || j < 0 || i >= m || j >= n) return
    if (grid[i][j] === 0) return
    grid[i][j] = 0
    dfs(grid, i - 1, j)
    dfs(grid, i + 1, j)
    dfs(grid, i, j - 1)
    dfs(grid, i, j + 1)
}

这道题自然也是可以使用 「并查集」来解决,后序并查集章节会详细使用。


请注意,本文所有的回溯函数,都使用了闭包的特性,如果不使用闭包,把需要的参数变为回溯函数的入参即可。

我亦无他,唯手熟尔!peace~