树和图:单词接龙、被围绕的区域、二叉树最近公共祖先

99 阅读7分钟

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

单词接龙

字典 wordList 中从单词 beginWord和endWord的转换序列是一个按下述规格形成的序列 beginWord -> s1 -> s2 -> ... -> sk:

  • 每一对相邻的单词只差一个字母。
  • 对于 1 <= i <= k 时,每个 si 都在 wordList 中。注意, beginWord 不需要在 wordList 中。
  • sk == endWord

给你两个单词beginWord和endWord和一个字典wordList ,返回从beginWord到endWord的最短转换序列中的 单词数目。如果不存在这样的转换序列,返回 0 。

示例 1:

输入:beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]
输出:5
解释:一个最短转换序列是 "hit" -> "hot" -> "dot" -> "dog" -> "cog", 返回它的长度 5

广度优先搜索 + 优化建图
本题要求的是最短转换序列的长度,看到最短首先想到的就是广度优先搜索。想到广度优先搜索自然而然的就能想到图。

我们可以把每个单词都抽象为一个点,如果两个单词可以只改变一个字母进行转换,那么说明他们之间有一条双向边。因此我们只需要把满足转换条件的点相连,就形成了一张

基于该图,我们以 beginWord 为图的起点,以 endWord 为终点进行广度优先搜索,寻找 beginWord 到 endWord 的最短路径。

实现思路
首先为了方便表示,我们先给每一个单词标号,即给每个单词分配一个 id。创建一个由单词 word 到 id 对应的映射 wordId,并将 beginWord 与 wordList 中所有的单词都加入这个映射中。之后我们检查 endWord 是否在该映射内,若不存在,则输入无解。我们可以使用哈希表实现上面的映射关系。

然后我们需要建图,依据朴素的思路,我们可以枚举每一对单词的组合,判断它们是否恰好相差一个字符,以判断这两个单词对应的节点是否能够相连。但是这样效率太低,我们可以优化建图。

对于单词 hit,我们创建三个虚拟节点 it、ht、hi*,并让 hit 向这三个虚拟节点分别连一条边即可。如果一个单词能够转化为 hit,那么该单词必然会连接到这三个虚拟节点之一。对于每一个单词,我们枚举它连接到的虚拟节点,把该单词对应的 id 与这些虚拟节点对应的 id 相连即可。

最后我们将起点加入队列开始广度优先搜索,当搜索到终点时,我们就找到了最短路径的长度。

/**
 * @param {string} beginWord
 * @param {string} endWord
 * @param {string[]} wordList
 * @return {number}
 */
var ladderLength = function(beginWord, endWord, wordList) {
    // 维护单词id以及边列表
    const idMap = new Map();
    const edges = [];
    let wordNums = 0;
    // 添加虚拟边,单词hit可延伸为*it、h*t、*it,然后建立边线关系
    function  addEdge(w) {
        addWord(w);
        const id = idMap.get(w);
        const charArr = w.split('');
        const length = charArr.length;

        for (let i = 0; i < length; i++ ) {
            const temp = charArr[i];
            charArr[i] = '*';

            const newWord = charArr.join('');
            addWord(newWord);
            const newId = idMap.get(newWord);

            edges[id].push(newId);
            edges[newId].push(id);

            charArr[i] = temp;
        }
    }

    function addWord(w) {
        if (!idMap.has(w)) {
            idMap.set(w, wordNums++);
            edges.push([]);
        }
    }

    // 按照beiginWord、wordList生成图的边
    for (const word of wordList) {
        addEdge(word);
    }
    addEdge(beginWord);
    if (!idMap.has(endWord)) {
        return 0;
    }

    // 按边开始遍历
    const stack = [], dis = new Array(wordNums).fill(Infinity);
    const beginId = idMap.get(beginWord), endId = idMap.get(endWord);
    dis[beginId] = 0;
    stack.push(beginId);
    //BFS广度优先遍历
    while (stack.length) {
        const wordId = stack.shift();

        if (wordId === endId) {
            return dis[wordId] / 2 + 1;
        }

        for (const id of edges[wordId]) {
            if (dis[id] === Infinity) {
                dis[id] = dis[wordId] + 1;
                stack.push(id);
            }
        }
    }

    return 0;
};

复杂度分析

  • 时间复杂度:O(N×C2)。其中 N 为 wordList 的长度,C为列表中单词的长度。
  • 空间复杂度:O(N×C2)。其中 N 为 wordList 的长度,C为列表中单词的长度。哈希表中包含 O(N×C)个节点,每个节点占用空间 O(C),因此总的空间复杂度为 O(N×C2)。

被围绕的区域

给你一个 m x n 的矩阵 board ,由若干字符 'X' 和 'O' ,找到所有被 'X' 围绕的区域,并将这些区域里所有的 'O' 用 'X' 填充。

示例 image.png

输入:board = [["X","X","X","X"],["X","O","O","X"],["X","X","O","X"],["X","O","X","X"]]
输出:[["X","X","X","X"],["X","X","X","X"],["X","X","X","X"],["X","O","X","X"]]
解释:被围绕的区间不会存在于边界上,换句话说,任何边界上的 'O' 都不会被填充为 'X'。 任何不在边界上,或不与边界上的 'O' 相连的 'O' 最终都会被填充为 'X'。如果两个元素在水平或垂直方向相邻,则称它们是“相连”的。

深度优先遍历 本题给定的矩阵中有三种元素:

  • 字母 X;
  • 被字母 X 包围的字母 O;
  • 没有被字母 X 包围的字母 O。

本题要求将所有被字母 X 包围的字母 O都变为字母 X ,但很难判断哪些O是被包围的,哪些O不是被包围的。

注意到题目解释中提到:任何边界上的 O 都不会被填充为 X。 我们可以想到,所有的不被包围的 O 都直接或间接与边界上的 O 相连。我们可以利用这个性质判断 O 是否在边界上,具体地说:

  • 对于每一个边界上的 O,我们以它为起点,标记所有与它直接或间接相连的字母O;
  • 最后我们遍历这个矩阵,对于每一个字母:
    • 如果该字母被标记过,则该字母为没有被字母 X 包围的字母 O,我们将其还原为字母 O;
    • 如果该字母没有被标记过,则该字母为被字母 X 包围的字母 O,我们将其修改为字母 X。
/**
 * @param {character[][]} board
 * @return {void} Do not return anything, modify board in-place instead.
 */
var solve = function(board) {
    const n = board.length;
    if (n === 0) {
        return;
    }
    const m = board[0].length;

    // 由第一行、最后一行遍历相连的O
    for (let i = 0; i < m; i++) {
        dfs(board, 0, i);
        dfs(board, n - 1, i);
    }
    // 由第一列、最后一列遍历相连的O
    for (let j = 0; j < n; j++) {
        dfs(board, j, 0);
        dfs(board, j, m - 1);
    }

    // 遍历所有元素并更新
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < m; j++) {
            if (board[i][j] === 'A') {
                board[i][j] = 'O';
            } else if (board[i][j] === 'O') {
                board[i][j] = 'X'
            }
        }
    }

    function dfs(board, i, j) {
        if (i < 0 || i > n - 1 || j < 0 || j > m - 1 || board[i][j] !== 'O') {
            return;
        }
        board[i][j] = 'A';
        dfs(board, i - 1, j);
        dfs(board, i + 1, j);
        dfs(board, i, j - 1);
        dfs(board, i, j + 1);
    }
};

复杂度分析 时间复杂度:O(n×m),其中 n 和 m 分别为矩阵的行数和列数。深度优先搜索过程中,每一个点至多只会被标记一次。

空间复杂度:O(n×m),其中 n 和 m 分别为矩阵的行数和列数。主要为深度优先搜索的栈的开销。

二叉树最近公共祖先

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”

示例
image.png

输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出:3
解释:节点 5 和节点 1 的最近公共祖先是节点 3

递归遍历 我们递归遍历整棵二叉树,定义fx表示x节点的子树中是否包含 p 节点或 q 节点,如果包含为 true,否则为 false。那么符合条件的最近公共祖先 xxx 一定满足如下条件:

image.png

其中 lson 和 rson分别代表 x 节点的左孩子和右孩子。flson && frson说明左子树和右子树均包含 p 节点或 q 节点,如果左子树包含的是 p 节点,那么右子树只能包含 q 节点,反之亦然。

再来看第二条判断条件,这个判断条件即是考虑了 恰好是 p 节点或 q 节点且它的左子树或右子树有一个包含了另一个节点的情况,因此如果满足这个判断条件亦可说明 x 就是我们要找的最近公共祖先。

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @param {TreeNode} p
 * @param {TreeNode} q
 * @return {TreeNode}
 */
var lowestCommonAncestor = function(root, p, q) {
    let ans = null;

    const dfs = (root, p, q) => {
        if (!root) {
            return false;
        }
        const lson = dfs(root.left, p, q);
        const rson = dfs(root.right, p, q);

        if ((lson && rson) || ((root.val === p.val || root.val === q.val) && (lson || rson))) {
            ans = root;
        }   

        return lson || rson || root.val === p.val || root.val === q.val;
    }
    dfs(root, p, q);

    return ans;
};

复杂度分析
时间复杂度:O(N),其中 N 是二叉树的节点数。二叉树的所有节点有且只会被访问一次,因此时间复杂度为 O(N)。
空间复杂度:O(N) ,其中 N 是二叉树的节点数。递归调用的栈深度取决于二叉树的高度,二叉树最坏情况下为一条链,此时高度为 N,因此空间复杂度为 O(N)。

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