回溯算法:分割回文串、单词搜索 II

127 阅读5分钟

大家好,我是 「前端下饭菜」,80后大龄程序员。我会在算法系列专栏记录leetcode高频算法题,感兴趣的小伙伴可收藏吃灰!

分割回文串

给你一个字符串 s,请你将 **s **分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

回文串: 是正着读和反着读都一样的字符串。例如:

输入: s = "aab"
输出: [["a","a","b"],["aa","b"]]

回溯+动态规划

回文串相关的题一般都使用回溯+动态规划解法,题中要求列举所有可能得分割方案,因此可以考虑搜索+回溯的方法保存所有的分割结果。

假设当前搜索到字符串的第i个字符,且s[0,..., i-1]位置的所有字符串已经被分割成若干个满足条件的回文串,结果都记录到数组ans中,则可以继续枚举下一个回文串的右边界,使得s[i,...,j]也满足回文串条件。

从位置i开始,往后遍历并且当前位置指向j。对于当前枚举的j值,使用双指针方法判断s[i,...,j]是否为回文串:如果是,那么将其添加到ans数组,并以i+1作为新的i进行下一个回文串搜索。

如果我们已经搜索完了字符串的最后一个字符,那么就找到了一种满足要求的分割方法。

为了避免重复计算,可以将字符串s的每个子串 s[i..j]是否为回文串使用动态规划预处理出来。设 f(i,j)表示 s[i..j]是否为回文串,那么有状态转移方程:

image.png

且仅当其为空串(i>ji>ji>j),其长度为1,或者首尾字符相同且 s[i+1..j−1]为回文串,则s[i,...,j]为回文串.

/**
 * 分割回文串
 * @param {string} s
 * @return {string[][]}
 */
var partition = function(s) {
    function dfs(i) {
        console.log(i, ans)
        if (i === n) {
            res.push(ans.slice());
            return;
        }
        for (let j = i; j < n; j++) {
            if (fn[i][j]) {
                ans.push(s.slice(i, j + 1));
                dfs(j + 1);
                ans.pop();
            }
        }
    }

    const n = s.length, ans = [], res = [];
    const fn = new Array(n).fill(0).map(() => new Array(n).fill(true));
    for (let i = n - 1; i >= 0; i--) {
        for (let j = i + 1; j < n; j++) {
            fn[i][j] = s[i] === s[j] && fn[i + 1][j - 1];
        }
    }
    console.log(fn);

    dfs(0);

    return res;
};

复杂度分析

时间复杂度:O(n⋅2n2^n),长度为 n 的字符串的划分方案数为 2n−1=O(2n2^n),每一种划分方法需要 O(n)的时间求出对应的划分结果并放入答案,因此总时间复杂度为O(n·2n)2^n)

空间复杂度:动态规划需要的空间O(n2n^2)。

单词搜索 II

给定一个 m x n 二维字符网格 board 和一个单词(字符串)列表 words, 返回所有二维网格上的单词 。

单词必须按照字母顺序,通过 相邻的单元格 内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母在一个单词中不允许被重复使用。

例如:

image.png

输入:
board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]], 
words = ["oath","pea","eat","rain"]
输出:["eat","oath"]

回溯 + 字典树

需要使用字典树,而字典树一般使用前缀树存储数据结构。什么是前缀树?前缀树(字典树)是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。前缀树可以用 O(∣S∣)的时间复杂度完成如下操作,其中 ∣S∣是插入字符串或查询前缀的长度:

  • 向前缀树中插入字符串word
  • 查询前缀串 prefix是否为已经插入到前缀树中的任意一个字符串word\的前缀;

逐个遍历二维网格中的每一个单元格;然后搜索从该单元格出发的所有路径,找到其中对应words中的单词的路径。因为这是一个回溯的过程,所以我们的算法可如下实现:

  • 遍历二维网格中的所有单元格。

  • 深度优先搜索所有从当前正在遍历的单元格出发的、由相邻且不重复的单元格组成的路径。

  • 因为题目要求同一个单元格内的字母在一个单词中不能被重复使用;所以我们在深度优先搜索的过程中,每经过一个单元格,都将该单元格的字母临时修改为特殊字符(例如 #),以避免再次经过该单元格。

  • 如果当前路径是words中的单词,则将其添加到结果集中。如果当前路径是words中任意一个单词的前缀,则继续搜索;反之,如果当前路径不是words中任意一个单词的前缀,则剪枝才减掉。我们可以将 words中的所有字符串先添加到前缀树中,而后用 O(∣S∣)的时间复杂度查询当前路径是否为 words中任意一个单词的前缀。

实现细节:

  • 同一个单词可能在多个不同的路径中出现,所以我们需要使用哈希集合对结果集去重。

  • 在回溯的过程中,我们不需要每一步都判断完整的当前路径是否是 words中任意一个单词的前缀;而是可以记录下路径中每个单元格所对应的前缀树结点,每次只需要判断新增单元格的字母是否是上一个单元格对应前缀树结点的子结点即可。

/**
 * 使用单词回溯法,将所有单词words存放到前缀树中,然后遍历board网格和前缀树匹配
 * 同一个单元格内的字母再同一个单词中 不允许重复使用
 * 再遍历board中同一个单词可能存在多次,因此需要用hashSet去重
 * @param {character[][]} board
 * @param {string[]} words
 * @return {string[]}
 */
 var findWords = function(board, words) {
    const hashSet = new Set();
    const root = { children: new Map(), word: '' }
    
    for (const word of words) {
        inertNode(word, root);  
    }
    for (let i = 0; i < board.length; i++) {
        for (let j = 0; j < board[0].length; j++) {
            dfs(board, root, i, j, hashSet);
        }
    }
    const result = [];
    hashSet.forEach(val => result.push(val));

    return result;
};

function inertNode(word, tree) {
    for (let i = 0; i < word.length; i++) {
        if (!tree.children.has(word[i])) {
            tree.children.set(word[i], { children: new Map(), word: '' })
        }
        tree = tree.children.get(word[i]);
    }
    tree.word = word;
}

function dfs(board, tree, i, j, set) {
    if (!tree.children.has(board[i][j])) {
        return;
    }
    // 方向:左、上、右、下
    const dirs = [[0, -1], [-1, 0], [0, 1], [1, 0]];
    // 遍历每个单元格,如果存在单词中的字母则继续往相邻方向延伸
    const word = board[i][j], next = tree.children.get(word);

    if (next) {
        board[i][j] = '#';
        for (const dir of dirs) {
            const i2 = i + dir[0], j2 = j + dir[1];
            if (0 <= i2 && i2 < board.length && 0 <= j2 && j2 < board[0].length) {
                dfs(board, next, i2, j2, set);    
            }
        }
        board[i][j] = word;
    }
    if (next.word) {
        set.add(next.word);
        next.word = '';
    }
}

复杂度分析

  • 时间复杂度: O(m×n×3l−1),其中m是二维网格的高度,n是二维网格的宽度,l是最长单词的长度。我们需要遍历 m×n个单元格,每个单元格最多需要遍历 4×3l−1条路径。

  • 空间复杂度:O(k×l),其中 k 是 words的长度,l 是最长单词的长度。

写在最后,如果大家有疑问可直接留言,一起探讨!感兴趣的可以点一波关注。