leetcode刷题之回溯

407 阅读5分钟

0. 前言

最近小编在刷 leetcode , 连续好几天都刷到回溯的题目。在这篇文章中,小编就来跟大家分享一下最近刷回溯题目的一些总结和思考。废话不多说,我们切入正题。

1. 回溯模板

回溯就是递归求解。通过递归的手段,将所有的情况遍历一遍,然后在这些结果中寻找我们需要的答案。从这里可以看出来,回溯的本质是暴力求解。暴力嘛,那肯定会花费很多时间了,要想降低算法的时间复杂度,剪枝才是关键。

在分享回溯问题的解题思路之前,我们先来看一看回溯可以解决哪些问题。我觉得这个问题可以用一句话来概括:回溯可以解决所有可以通过递归暴力能够得到答案的所有问题,比如说排列组合、n皇后(这是一道经典的回溯题目)、解数独、电话号码……这些题目。

然后我们来总结一下回溯问题的解题模板:

let combination = [];
let combinations = [];

const backTrack = (idx) => {
    // 满足条件添加到最终结果的终止条件
    if() {
        combinations.push(combination.slice());
        return;
    };
    
    // 直接结束递归,但是不满足最终结果的终止条件。这里可以没有。—— 剪枝
    if() {
        return;
    }
    
    // 一般情况怎么办
    while() {
        // 遍历所有的情况,寻找满足条件的结果
        // 需要注意的是,遍历有两种情况: 1. 递归; 2. 循环
        if() {
            // 满足条件的结果
            combination.push("满足条件的某个值");
            // 递归循环遍历 —— 回溯的过程
            backTrack(idx + 1);
            // 回退结果
            combination.pop();
        }
    }
}

2. leetcode部分相关题目

俗话说:“纸上得来终觉浅,唯有实践出真知”。我们现在就来利用这个模板解决几道leetcode上面的题目。

2.1 电话号码的字母组合

2.1.1 题目

image.png

2.1.2 题解

/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function(digits) {
    const phoneMap = ['', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
    let combination = ''
    let combinations = []

    if(!digits) return []

    function backTrack(index) {
        if(index === digits.length) {
            combinations.push(combination)
        } else {
            let digit = digits[index]
            for(let i = 0; i < phoneMap[digit].length; i++) {
                combination += phoneMap[digit][i]
                backTrack(index + 1)
                combination = combination.substring(0, combination.length - 1)
            }
        }
    }

    backTrack(0)
    
    return combinations
};

2.1.3 分析

首先,拿到题目的第一个想法肯定是:我去遍历输入的数字,然后在数字对应的字母中寻找可能的组合,直到遍历完所有输入的数字,我就拿到一条结果,然后放到最终的结果数组中。然后,继续遍历输入的数字,去找下一条结论。 从这段分析中,我们可以发现在查找所有的结果这个过程中,有两条路径:

  • 遍历输入的数字
  • 遍历数字对应的字符

对于这两条路径,我们一横一纵去遍历它。横的就用循环,即遍历数字对应的字符;纵的就采用递归,即递归遍历输入的数字。在这道题目中,我们只需要遍历就可以了,并没有需要判断是否该使用这个结果的地方。因此,这道题目是一道典型的回溯题目,而且没有特别需要剪枝的地方。

在具体的代码实现中,我们首先定义了一个用来存放数字和字符对应关系的数组;然后定义递归函数。最终调用这个函数返回结果即可。不难发现,这段代码的核心就是这个递归函数。

而这个递归的函数是符合我们前面提到的回溯模板的。在代码的开始,进行了递归终止条件的判断,即index === digits.length。满足条件时,递归终止,将单个结果添加到最终的结果数组中。不满足条件时,进行横向的循环遍历数字对应的字符,依次向单个结果中添加字符,构成单个字母组合结果。最终,我们看到的代码就是题解中的那个样子。

2.2 括号生成

2.2.1 题目

image.png

2.2.2 题解

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function(n) {
    let pairs = []
    let pair = ''

    function backTrack(open, close) {
        if(pair.length === 2*n) {
            pairs.push(pair)
            return 
        } 

        if(open < n) {
            pair += '('
            backTrack(open + 1, close)
            pair = pair.substr(0, pair.length-1)
        }

        if(close < open) {
            pair += ')'
            backTrack(open, close + 1)
            pair = pair.substr(0, pair.length - 1)
        }
    }

    backTrack(0, 0)
    return pairs
};

2.2.3 分析

这个括号生成的题目乍一看,似乎并不是一道典型的回溯题目。但是仔细分析的话,它是可以用回溯来解决的。首先,这道题目是一个排列组合的问题。只不过我们组合的对象不是传统的字符或者数字,而是 '('')' .排列组合的结果,最好的解决办法就是通过回溯找到所有的排列组合结果,然后在这些结果中找到我们需要的答案。 因此,这个题目用回溯来解决是合适的。

利用回溯来解决问题,需要搞清楚两个问题:

  • 递归回溯的终止条件是什么
  • 递归回溯要循环遍历的对象是什么

一般来讲,回溯的终止条件就是单个组合结果的长度,回溯要遍历的对象就是需要排列组合的对象。在这个题目中,回溯的终止条件就是括号的长度,回溯遍历的对象就是两瓣括号。这个题目相对于上一个题目而言,增加了一个剪枝的过程。这个剪枝的条件就是括号必须正确的闭合。也就是说,在这个题目中,我们需要关注的问题就是括号怎样才能正确的闭合。

我们知道括号必须是一对一对的,也就是说左括号和右括号的数量必须相等,而且左括号在前面,右括号在后面。因此,在整个往单个结果中添加括号的过程中,右括号的数量总是比左括号的数量少的。而括号的数量又限制了左括号的数量。话句话说,满足我们要求的条件是用括号的数量限制左括号,用左括号的数量限制右括号。因此,我们最终的条件是:if(open < n) 添加左括号;if(close < open) 添加右括号。最终,我们看到的代码就是题解中的那个样子。

2.3 解数独

2.3.1 题目

image.png

2.3.2 题解

/**
 * @param {character[][]} board
 * @return {void} Do not return anything, modify board in-place instead.
 */
var solveSudoku = function(board) {
    const row = new Array(9).fill(false).map(() => new Array(9).fill(false));
    const column = new Array(9).fill(false).map(() => new Array(9).fill(false));
    const subBoard = new Array(3).fill(false).map(() => new Array(3).fill(false).map(() => new Array(9).fill(false)));
    const space = [];
    let valid = false;

    for(let i = 0; i < 9; ++i){
        for(let j = 0; j < 9; ++j) {
            if(board[i][j] === '.') {
                space.push([i, j]);
            } else {
                const digit = board[i][j] - '0' - 1;
                row[i][digit] = column[j][digit] = subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit] = true;
            }
        }
    }

    const dfs = (pos) => {

        // 递归终止条件
        if(pos === space.length) {
            valid = true;
            return; 
        }

        const [i, j] = space[pos]
        for(let digit = 0; digit < 9 && !valid; ++digit) {
            if(!row[i][digit] && !column[j][digit] && !subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit]) {
                row[i][digit] = column[j][digit] = subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit] = true;
                board[i][j] = (digit + 1).toString();
                dfs(pos + 1);
                row[i][digit] = column[j][digit] = subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit] = false;
            }
        }
    }

    dfs(0);
};

2.3.3 分析

这个题目就是那种典型的一看到就知道必须用回溯才能做的。原因有二:

  1. 它每个结果都跟前面的有关联
  2. 这是一个排列组合的问题

我们知道,回溯题目的关键是剪枝。这个题目难就难在剪枝的条件判断起来很困难。但如果有前一道题目的铺垫的话,这个剪枝的条件就不难想到。

image.png

因此,我们先来解这个题目。数独成立的条件有三个:

  • 每一行不能有重复
  • 每一列不能有重复
  • 每一个3 * 3的小格子不能有重复

因此,我们用三个数组来对每一行(或者一列或者3 * 3的格子)中某一个具体的数字填写了几次,代码如下:

const row = new Array(9).fill(0).map(() => new Array(9).fill(0));
const column = new Array(9).fill(0).map(() => new Array(9).fill(0));
const subBoard = new Array(3).fill(0).map(() => new Array(3).fill(0).map(() => new Array(9).fill(0)));

其中,const row = new Array(9).fill(0).map(() => new Array(9).fill(0));表示数独表格中第 i 行(i 的范围是[1,9])中有多少个对应的数字;const column = new Array(9).fill(0).map(() => new Array(9).fill(0));表示数独表格中第 j 列(j 的范围是[1,9])中有多少个对应的数字;const subBoard = new Array(3).fill(0).map(() => new Array(3).fill(0).map(() => new Array(9).fill(0)));表示数独表格中在 (i, j) 位置上的 3 * 3 小表格(i,j 的范围是[1,9])中有多少个对应的数字。当遇到对应的数字就加1,最后判断特定位置的特定数字计数是否超过1,如果超过1,则证明是无效数独;如果所有的未超过1,则证明是有效数独。具体代码实现如下:

/**
 * @param {character[][]} board
 * @return {boolean}
 */
var isValidSudoku = function(board) {
    const row = new Array(9).fill(0).map(() => new Array(9).fill(0));
    const column = new Array(9).fill(0).map(() => new Array(9).fill(0));
    const subBoard = new Array(3).fill(0).map(() => new Array(3).fill(0).map(() => new Array(9).fill(0)));

    for(let i = 0; i < 9; i++){
        for(let j = 0; j < 9; j++) {
            if(board[i][j] !== '.') {
                const num = board[i][j] - '0' - 1;
                row[i][num]++;
                column[j][num]++;
                subBoard[Math.floor(i / 3)][Math.floor(j / 3)][num]++;

                if(row[i][num] > 1 || column[j][num] > 1 || subBoard[Math.floor(i / 3)][Math.floor(j / 3)][num] > 1) {
                    return false;
                }
            }
        }
    }

    return true;
};

从这个题目中,我们得到启发。在本题中,我们也建立类似的三个数组,只不过我们这道题中数组存储的不再是数字,而是布尔值。表示在该位置上是否填写过该数字。对这三个数组进行动态的更新。

回溯的模板 + 上一道题目提供的剪枝思路,构成了我们这个题目的最终解决方案。

2.4 n皇后

2.4.1 题目

image.png

2.4.2 题解

/**
 * @param {number} n
 * @return {string[][]}
 */
var solveNQueens = function(n) {
    const solutions = [];
    const queens = new Array(n).fill(-1);
    const columns = new Set();
    const diagnal1 = new Set();
    const diagnal2 = new Set();

    const generateBoard = () => {
        const borad = [];
        for(let i = 0; i < n; i++) {
            let row = '';
            for(let j = 0; j < n; j++) {
                row = queens[i] === j ? row + 'Q' : row + '.';
            }
            borad.push(row);
        }

        return borad;
    };

    const dfs = row => {
        if(row === n) {
            const board = generateBoard();
            solutions.push(board);
        } else {
            for(let i = 0; i < n; i++) {
                if(columns.has(i)) {
                    continue;
                }

                if(diagnal1.has(row - i)) {
                    continue;
                }

                if(diagnal2.has(row + i)) {
                    continue;
                }

                queens[row] = i;
                columns.add(i);
                diagnal1.add(row - i);
                diagnal2.add(row + i);
                dfs(row + 1);
                queens[row] = -1;
                columns.delete(i);
                diagnal1.delete(row - i);
                diagnal2.delete(row + i);
            }
        }
    };

    dfs(0);
    return solutions;
};

2.4.3 分析

N 皇后是经典的回溯题目,但是我们还是要看一看为什么它需要用回溯来解决。和解数独的那个题目一样它是一个排列组合的问题,并且它也是每个结果都和前面的有关联。因此,用回溯来解决。

和前面的回溯问题一样,它的难点主要在于剪枝。题目中给出了,不能添加Q的条件:

  • 不能在同一行
  • 不能在同一列
  • 不能在同一条斜线(同一条斜线有两个:左上到右下的斜线,我们成为左斜线;右上到左下的斜线,我们称为右斜线)

受前面一个题目的启发,我们定义数组(或者类数组)来存储某些不满足条件的结果,然后跳过。在这个题目中,我们递归遍历的就是行数,因此,一定能保证每一行只有一个皇后。除去这个条件,还需要三个数组来存放。因为我们只需要判断有没有,所以可以利用集合只能有一个元素的特性。最终,我们选择Set()来存放皇后不能在的列。代码如下:

const columns = new Set();
const diagnal1 = new Set();
const diagnal2 = new Set();

然后只要动态的更新这三个集合即可。

回溯模板 + 剪枝条件,构成了这个题目的解。

2.5 组合总和

2.5.1 题目

image.png

2.5.2 题解

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum = function(candidates, target) {
    const ans = [];
    const res = [];
    const dfs = (target, idx) => {
        if(idx === candidates.length) {
            return;
        }

        if(target === 0) {
            ans.push(res.slice());
            return;
        }

        dfs(target, idx + 1);

        if(target - candidates[idx] >= 0) {
            res.push(candidates[idx]);
            dfs(target - candidates[idx], idx);
            res.pop();
        } 
    };

    dfs(target, 0);
    return ans;
};

2.5.3 分析

如果只是回溯模板+剪枝条件的话,这个题目是很普通的一个题目,根本没有什么困难的地方。它属于一眼就能看出来要用回溯来解决的题目,并且只需要套模板就可以做的,剪枝条件也很简单。

这个题目比较新颖的地方是先递归的遍历整个数组的元素,然后再进行条件的判断,即剪枝。

2.6 组合总和II

2.6.1 题目

image.png

2.6.2 题解

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum2 = function(candidates, target) {
    const ans = [];
    const list = [];
    const dfs = (target, idx) => {
        if(target === 0) {
            ans.push(list.slice());  
            return;
        }

        if(idx === candidates.length) {
            return;
        }

        for(let i = idx; i < candidates.length; i++) {
            if(target < candidates[i]) {
                break;
            }

            if(i > idx && candidates[i] === candidates[i - 1]) {
                continue;
            }

            list.push(candidates[i]);
            dfs(target - candidates[i], i + 1);
            list.pop();
        }
    }

    candidates.sort((a, b) => a - b);
    dfs(target, 0);

    return ans;
};

2.6.3 分析

这个题目更没啥好说的了,和上一个题目是同一道题。它需要注意的是去除重复的元素。

3. 总结

做了这么多道题,我们可以总结出回溯模板中的关键点:

  • 终止条件:
    • 需要添加结果的终止条件
    • 剪枝的终止条件
  • 遍历待组合的对象:
    • 递归遍历
    • 循环遍历
  • 根据题目要求确定剪枝条件
  • 回溯三步:
    • 添加结果
    • 递归调用
    • 还原现场