大家好,我是 「前端下饭菜」,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' 填充。
示例
输入: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 的深度尽可能大(一个节点也可以是它自己的祖先)。”
示例
输入: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 一定满足如下条件:
其中 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)。
写在最后,如果大家有疑问可直接留言,一起探讨!感兴趣的可以点一波关注。