Kiner算法刷题记(十二):深搜(DFS)与广搜(BFS):初识问题状态空间(手撕算法)

系列文章导引

  • 系列文章导引

开源项目

本系列所有文章都将会收录到GitHub中统一收藏与管理,欢迎ISSUEStar

GitHub传送门:Kiner算法算题记

前言

了解了搜索算法的很核心概念,知道了问题求解树是个什么东西,掌握了深搜广搜的特点以及实现思路之后,下面将分别针对深搜广搜进行刷题巩固。

编程技巧扩展

方向/偏移量数组

// 如下面的二维数组,$所在的位置,他要移动一步有四种可能性:
// 1. 向上移动一步,即y坐标-1,x坐标不变(0,-1)
// 2. 向左移动一步,即x坐标-1,y坐标不变(-1,0)
// 3. 向下移动一步,即y坐标+1,x坐标不变(0,1)
// 4. 向右移动一步,即x坐标+1,y坐标不变(1,0)
const arr = [
  [  *,       (0,-1),       *     ],
	[(-1,0),       $,        (1,0)  ],
  [  *,        (0,1),        *    ],
];

// 根据上面所述,我们可以得到一个方向数组或称之为偏移量数组,这个数组就是当前节点要一步到达下一个节点x和y的偏移量
const direction = [
  [0,-1],
  [-1,0],
  [0,1],
  [1,0]
];
// 有了这个方向数组,我们就可以对任一点移动的行为都能够遍历求解了

深搜

993. 二叉树的堂兄弟节点

解题思路

这道题既可以使用深度优先搜索,也可以使用广度优先搜索,我们先使用深度优先解决这个问题,待会到了广度优先搜索刷题时再使用广度优先搜索实现。

首先,我们要判断两个节点是否是堂兄弟节点,需要满足以下两个条件

  1. 两个节点所处的层级相同,即辈分一样
  2. 两个节点的父节点不同,不能是亲兄弟

明确了目的之后,我们就要开始来设计深度优先搜索函数了:

首先,要先确认我们问题求解树的状态:目标节点所在的层级和目标节点的父级

明确了问题求解树的状态之后,我们只需要实现一个能够让我们明确获取到目标节点层级和父节点的深度优先方法即可,具体的实现逻辑详见代码实现。

代码实现
/*
 * @lc app=leetcode.cn id=993 lang=typescript
 *
 * [993] 二叉树的堂兄弟节点
 *
 * https://leetcode-cn.com/problems/cousins-in-binary-tree/description/
 *
 * algorithms
 * Easy (55.74%)
 * Likes:    222
 * Dislikes: 0
 * Total Accepted:    47.8K
 * Total Submissions: 85.8K
 * Testcase Example:  '[1,2,3,4]\n4\n3'
 *
 * 在二叉树中,根节点位于深度 0 处,每个深度为 k 的节点的子节点位于深度 k+1 处。
 * 
 * 如果二叉树的两个节点深度相同,但 父节点不同 ,则它们是一对堂兄弟节点。
 * 
 * 我们给出了具有唯一值的二叉树的根节点 root ,以及树中两个不同节点的值 x 和 y 。
 * 
 * 只有与值 x 和 y 对应的节点是堂兄弟节点时,才返回 true 。否则,返回 false。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 
 * 输入:root = [1,2,3,4], x = 4, y = 3
 * 输出:false
 * 
 * 
 * 示例 2:
 * 
 * 
 * 
 * 输入:root = [1,2,3,null,4,null,5], x = 5, y = 4
 * 输出:true
 * 
 * 
 * 示例 3:
 * 
 * 
 * 
 * 
 * 输入:root = [1,2,3,null,4], x = 2, y = 3
 * 输出:false
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 二叉树的节点数介于 2 到 100 之间。
 * 每个节点的值都是唯一的、范围为 1 到 100 的整数。
 * 
 * 
 * 
 * 
 */

// @lc code=start
/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */
// 定义一个辅助结构用来存储节点的父节点的引用
type InfoStruct = {
    father: TreeNode | null
};
/**
 * 深度优先搜索
 * @param root 根节点
 * @param x 待查找目标值
 * @param info 辅助对象,用于递归搜索时辅助存储当前节点父节点的引用
 * @returns 
 */
function dfs(root: TreeNode | null, x: number, info: InfoStruct): number {
    // 当根节点为空时,不可能找到目标节点,返回-1
    if(!root) return -1;
    // 当根节点为目标值时,返回0,表示第0层找到了目标节点,后面会加上根节点所在的那层,即加1,所以,此处标记为0即可
    if(root.val === x) return 0;
    // 准备递归左子树,为了始终存储当前节点的父节点的引用,借用info对象存储
    info.father = root;
    // 递归搜索左子树
    const l = dfs(root.left, x, info);
    // 如果在左子树中能够找到目标节点,则返回左子树找到目标节点所在的层数加上根节点层数1
    if(l>=0) return l+1;
    // 左子树搜索完了,准备搜索右子树,进行回溯重置
    info.father = root;
    const r = dfs(root.right, x, info);
    if(r>=0) return r+1;
    // 左右子树都没找到,返回-1
    return -1;

}

function isCousins(root: TreeNode | null, x: number, y: number): boolean {
    // 要判断两个节点是否是堂兄弟的关系,需要确定以下两点:
    // 1. 两个节点的层级相同
    // 2. 两个节点的父节点不同
    // 我们需要使用深度优先搜索,搜索目标节点,并返回目标节点所在的层级
    let d1,d2;
    // 定义两个辅助对象用于存储目标节点的父节点引用
    const info1: InfoStruct = {
        father: null
    }
    const info2: InfoStruct = {
        father: null
    }
    // 分别使用深度优先搜索获取目标节点层级
    d1 = dfs(root, x, info1);
    d2 = dfs(root, y, info2);
    // 如果层级相同,父节点不同则说明是堂兄弟节点
    return d1 === d2 && info1.father !== info2.father;
};
// @lc code=end


130. 被围绕的区域

解题思路

这道题我们需要一定的逆向思维,题目要我们找到所有被X包围的O并标记为X,那么,我们可以反过来想,怎样的O是不被X包围的呢?是不是就是我们四个边上的O以及跟它相连的O呢?我们其实可以先找出这些不被X包围的O,将他们先标记为o,那么,剩下的所有的O就是被X包围的了。那么,接下来,我们来看看要如何使用深搜来解决这个问题。

代码实现
/*
 * @lc app=leetcode.cn id=130 lang=typescript
 *
 * [130] 被围绕的区域
 *
 * https://leetcode-cn.com/problems/surrounded-regions/description/
 *
 * algorithms
 * Medium (43.50%)
 * Likes:    554
 * Dislikes: 0
 * Total Accepted:    107.5K
 * Total Submissions: 247.1K
 * Testcase Example:  '[["X","X","X","X"],["X","O","O","X"],["X","X","O","X"],["X","O","X","X"]]'
 *
 * 给你一个 m x n 的矩阵 board ,由若干字符 'X' 和 'O' ,找到所有被 'X' 围绕的区域,并将这些区域里所有的 'O' 用 'X'
 * 填充。
 * 
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入: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'。如果两个元素在水平或垂直方向相邻,则称它们是“相连”的。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:board = [["X"]]
 * 输出:[["X"]]
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * m == board.length
 * n == board[i].length
 * 1 
 * board[i][j] 为 'X' 或 'O'
 * 
 * 
 * 
 * 
 */

// @lc code=start
/**
 Do not return anything, modify board in-place instead.
 */
// 方向数组,用于处理任意坐标走一步的可能性求解,也就是任意一个坐标走一步的偏移量可能性
const direction: number[][] = [
    [0, 1],// 向下走一步
    [1, 0],// 向右走一步
    [-1, 0],// 向左走一步
    [0, -1]// 向上走一步
];
// 全局记录矩阵的行数和列数
let row: number, col: number;
function dfs(i: number, j: number, board: string[][]) {
    // 能够进入这个方法,说明是四边的O或者与四边相连的O,直接将这个O标记为o
    board[i][j] = 'o';
    // 以当前节点向上下左右辐射,找到所有与四条边的O相连的O并标记为o
    for (let k = 0; k < direction.length; k++) {
        // 计算下一个状态的坐标
        const x = i + direction[k][0];
        const y = j + direction[k][1];
        // 下一个状态坐标合法性判断
        if (x < 0 || x >= row) continue;
        if (y < 0 || y >= col) continue;
        if (board[x][y] !== 'O') continue;
        // 递归处理下一个状态
        dfs(x, y, board);
    }
}

function solve(board: string[][]): void {
    // 初始化行数和列数
    row = board.length, col = board[0].length;
    // 接下来就算是搜索四条边上的O
    // 遍历每行的第一列和最后一列,找出为O的坐标,然后使用dfs继续深搜与其相连的O
    for (let i = 0; i < row; i++) {
        if (board[i][0] === 'O') dfs(i, 0, board);
        if (board[i][col - 1] === 'O') dfs(i, col - 1, board);
    }
    // 遍历每一列的第1行和最后一行,找出为O的坐标,然后使用dfs继续深搜与其相连的O
    for (let i = 0; i < col; i++) {
        if (board[0][i] === 'O') dfs(0, i, board);
        if (board[row - 1][i] === 'O') dfs(row - 1, i, board);
    }
    // 上面我们已经四条边上为O何与边上O相连的点都标记为o了,然后再暴力扫描一遍整个矩阵,将所有标记为O的节点
    // 标记为X,然后再把所有标记为o的节点标记为O即可
    for (let i = 0; i < row; i++) {
        for (let j = 0; j < col; j++) {
            if (board[i][j] === 'O') board[i][j] = 'X';
            else if (board[i][j] === 'o') board[i][j] = 'O';
        }
    }
};
// @lc code=end


494. 目标和

解题思路

因为题目允许使用+和-两种运算,那么我们要求目标和的一个关键点就是如何从第i个状态计算出i+1的状态,以及我们何时终止程序。终止程序很简单,之前有做过两数之和的小伙伴应该都有思路,如果进行加法,我们每次需要从目标值target中剔除当前的数字,如果是减法,我们每次需要将当前数字加回目标值target,当数组遍历结束并且target为0时,说明我们找到了一组解,否则当前的解法不正确。

代码演示
/*
 * @lc app=leetcode.cn id=494 lang=typescript
 *
 * [494] 目标和
 *
 * https://leetcode-cn.com/problems/target-sum/description/
 *
 * algorithms
 * Medium (49.62%)
 * Likes:    809
 * Dislikes: 0
 * Total Accepted:    121.5K
 * Total Submissions: 244.8K
 * Testcase Example:  '[1,1,1,1,1]\n3'
 *
 * 给你一个整数数组 nums 和一个整数 target 。
 * 
 * 向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :
 * 
 * 
 * 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
 * 
 * 
 * 返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:nums = [1,1,1,1,1], target = 3
 * 输出:5
 * 解释:一共有 5 种方法让最终目标和为 3 。
 * -1 + 1 + 1 + 1 + 1 = 3
 * +1 - 1 + 1 + 1 + 1 = 3
 * +1 + 1 - 1 + 1 + 1 = 3
 * +1 + 1 + 1 - 1 + 1 = 3
 * +1 + 1 + 1 + 1 - 1 = 3
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:nums = [1], target = 1
 * 输出:1
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 1 
 * 0 
 * 0 
 * -1000 
 * 
 * 
 */

// @lc code=start

function dfs1(i: number, target: number, nums: number[]): number {
    // 当遍历到最后,如果target===0,则说明找到了一组解,返回1,否则返回0
    if(i === nums.length) return +(target===0);
    // 累加方案
    let res = 0;
    // 处理+号时,应该让目标值减去这个数
    res += dfs1(i+1, target - nums[i], nums);
    // 处理-号时,应该让目标值加上这个数
    res += dfs1(i+1, target + nums[i], nums);
    return res;
}

function findTargetSumWays(nums: number[], target: number): number {
    return dfs1(0, target, nums);
};
// @lc code=end

不过大家可以发现,上面的方法虽然好理解,但是效率比较低。不知大家还是否记得,我们在讲二分算法时,有讲过函数是压缩的数组,数组是展开的函数这个概念,不了解的同学可以去看一下10.1-二分查找,那么,我们现在就利用这个概念来优化一下我们的程序。

我们来想一下,我们上面的方法求解目标值的函数为res = f(i,target),函数的nums其实只是为了用来取数据,与我们问题求解树无关。那么,根据数组是压缩的函数这样一个概念,我们是否能够有这样的一个数组,使:res = arr[i,target],即我传入i和target的值,如果数组中有值,我们直接返回。这样会少了很多不必要的分支操作,也就是我们之前说的剪枝。下面代码中使用一个Map(我们之前有说过,map底层是哈希表,而哈希表就是再将一个任意类型的数据往数组下标进行映射,实际存储数据还是用数组。)这样一个哈希表来缓存我们的计算结果,如果通过i和target能够找到,则直接返回。没有的话,计算之后,我们就把结果存入进Map中。总结一下:我们可以利用函数与数组之前的微妙关系,通过记忆化的形式来空间换时间,提升程序运行效率,在这道题中,记忆化帮助我们剪去了之前已经计算过结果的重复分支

/*
 * @lc app=leetcode.cn id=494 lang=typescript
 *
 * [494] 目标和
 *
 * https://leetcode-cn.com/problems/target-sum/description/
 *
 * algorithms
 * Medium (49.62%)
 * Likes:    809
 * Dislikes: 0
 * Total Accepted:    121.5K
 * Total Submissions: 244.8K
 * Testcase Example:  '[1,1,1,1,1]\n3'
 *
 * 给你一个整数数组 nums 和一个整数 target 。
 * 
 * 向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :
 * 
 * 
 * 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
 * 
 * 
 * 返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:nums = [1,1,1,1,1], target = 3
 * 输出:5
 * 解释:一共有 5 种方法让最终目标和为 3 。
 * -1 + 1 + 1 + 1 + 1 = 3
 * +1 - 1 + 1 + 1 + 1 = 3
 * +1 + 1 - 1 + 1 + 1 = 3
 * +1 + 1 + 1 - 1 + 1 = 3
 * +1 + 1 + 1 + 1 - 1 = 3
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:nums = [1], target = 1
 * 输出:1
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 1 
 * 0 
 * 0 
 * -1000 
 * 
 * 
 */

// @lc code=start

function dfs1(i: number, target: number, nums: number[]): number {
    // 当遍历到最后,如果target===0,则说明找到了一组解,返回1,否则返回0
    if(i === nums.length) return +(target===0);
    // 累加方案
    let res = 0;
    // 处理+号时,应该让目标值减去这个数
    res += dfs1(i+1, target - nums[i], nums);
    // 处理-号时,应该让目标值加上这个数
    res += dfs1(i+1, target + nums[i], nums);
    return res;
}

const map: Map<number, number> = new Map<number, number>();
function dfs2(i: number, target: number, nums: number[]): number {
    // 当遍历到最后,如果target===0,则说明找到了一组解,返回1,否则返回0
    if(i === nums.length) return +(target===0);
    // 从哈希表中获取结果,如果能够获取得到结果,无需计算,直接返回
    // 我们之前有说过,js中的map起始底层就是一个哈希表,现在我们需要根据
    // 我们的状态i和target生成一个唯一值作为key用于提供给哈希函数生成索引映射
    // 此处我们直接使用位运算:按位与对两个数字类型进行运算得到的结果作为键值
    const ans = map.get(i ^ target);
    if(ans) {
        return ans;
    }

    // 累加方案
    let res = 0;
    // 处理+号时,应该让目标值减去这个数
    res += dfs1(i+1, target - nums[i], nums);
    // 处理-号时,应该让目标值加上这个数
    res += dfs1(i+1, target + nums[i], nums);
    // 存储结果,方便后续记忆化检索以前已经有的答案。这种方法类似于使用缓存法计算斐波那契数
    map.set(i ^ target, res);
    return res;
}

function findTargetSumWays(nums: number[], target: number): number {
    map.clear();
    return dfs2(0, target, nums);
};
// @lc code=end


473. 火柴拼正方形

解题思路

这道题要求我们用火柴拼成一个正方形,那么,我们假设有四个火柴盒,每个火柴盒的容量n = 火柴总长度 m / 4,我们可以依次将每一根火柴尝试放入火柴盒中,如果所有火柴用完时这四个火柴盒刚好都能够放满,则说明可以拼凑成正方形,否则就拼不成。在往火柴盒中放火柴的时候,我们尽可能的先将较长的火柴先放进去,当某个火柴盒中放入火柴后还有余量,那我们还得看一下剩余的空间能不能放得下我们最短的火柴,如果最短的火柴都放不下,那就不需要尝试了,肯定不能拼成正方形了。通过这种方式,我们可以对一些明确无解的搜索分支进行剪枝。这道题的问题求解树的状态其实就是当前火柴的索引以及火柴盒的剩余容量共同组成的。

代码演示
/*
 * @lc app=leetcode.cn id=473 lang=typescript
 *
 * [473] 火柴拼正方形
 *
 * https://leetcode-cn.com/problems/matchsticks-to-square/description/
 *
 * algorithms
 * Medium (41.37%)
 * Likes:    185
 * Dislikes: 0
 * Total Accepted:    17.6K
 * Total Submissions: 42.4K
 * Testcase Example:  '[1,1,2,2,2]'
 *
 * 
 * 还记得童话《卖火柴的小女孩》吗?现在,你知道小女孩有多少根火柴,请找出一种能使用所有火柴拼成一个正方形的方法。不能折断火柴,可以把火柴连接起来,并且每根火柴都要用到。
 * 
 * 输入为小女孩拥有火柴的数目,每根火柴用其长度表示。输出即为是否能用所有的火柴拼成正方形。
 * 
 * 示例 1:
 * 
 * 
 * 输入: [1,1,2,2,2]
 * 输出: true
 * 
 * 解释: 能拼成一个边长为2的正方形,每边两根火柴。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入: [3,3,3,3,4]
 * 输出: false
 * 
 * 解释: 不能用所有火柴拼成一个正方形。
 * 
 * 
 * 注意:
 * 
 * 
 * 给定的火柴长度和在 0 到 10^9之间。
 * 火柴数组的长度不超过15。
 * 
 * 
 */

// @lc code=start
/**
 * 深搜
 * @param idx 当前是第几根火柴
 * @param arr 火柴盒数组
 * @param ms 原始火柴数组
 */
function dfs(idx: number, arr: number[], ms: number[]): boolean {
    // 如果火柴全部那完了,说明我们可以放满所有的火柴盒(因为火柴盒的总容量就等于火柴的总长度)
    // 如果火柴那完了,火柴盒还没放满,就说明火柴盒的总容量不等于火柴的总长度,这是与我们刚开始
    // 设计的情况相悖的,不可能出现
    if(idx < 0) return true;
    // 我们依次往火柴盒中放入火柴
    for(let i=0;i<arr.length;i++) {
        // 如果当前拿的火柴放不进当前的火柴盒,则尝试下一个火柴盒
        if(arr[i] < ms[idx]) continue;
        // 如果当前火柴刚好可以放入当前火柴盒或将当前火柴放入火柴盒后,火柴盒还能至少放得下最短的火柴
        // 那么我们才把火柴放入当前火柴盒。为什么余量至少要大于最短的火柴呢?因为如果火柴盒的余量连最短
        // 的火柴都放不下的话,那我们就不可能把这个火柴盒放满了,就可以把本次尝试舍弃,进行搜索剪枝
        if(arr[i] === ms[idx] || arr[i] >= ms[idx] + ms[0]) {
            // 将火柴放入火柴盒,火柴盒的容量减少火柴的长度
            arr[i] -= ms[idx];
            // 继续下一轮的尝试,如果之后的尝试都可以正常放入火柴,则说明本次尝试正确,直接返回true
            if(dfs(idx - 1, arr, ms)) return true;
            // 如果后面的尝试失败,那我们要把当前的火柴从火柴盒里面拿出来,尝试其他的火柴
            arr[i] += ms[idx];
        }
    }
    // 如果经过上述尝试还无法放满所有的火柴盒,说明无法拼接成正方形
    return false;
}
function makesquare(matchsticks: number[]): boolean {
    // 火柴数量不足4根,无解
    if(matchsticks.length<4) return false;
    // 求出单个火柴盒的容量
    const sum = matchsticks.reduce((a,b) => a+b, 0);
    // 如果火柴的总长度与4求模不为0,由于题目说火柴不能折断,因此肯定不可能拼接成正方形
    if(sum % 4) return false;
    // 初始化四个火柴盒,每个火柴盒中放的是当前火柴盒剩余可放火柴的长度
    const arr: number[] = new Array(4);
    const v = sum / 4;
    // 初始化每个火柴盒的容量
    arr.fill(v);
    // 为了尽可能的先将较长的火柴先放入火柴盒,我们先对原始火柴数组从小到大排序
    // 然后再从后往前那火柴
    matchsticks.sort((a, b) => a-b);
    // 因为我们的火柴是从短到长排序的,所以我们先拿出最长的一根火柴进行尝试
    return dfs(matchsticks.length - 1, arr, matchsticks);
};
// @lc code=end


39. 组合总和

解题思路

这一题,我么依然使用深搜来解决,那么这题的状态什么呢?应该是当前正在使用的数字与当前目标和值共同组成的一个状态。注意,每次选取数字时,存在使用当前数字和不使用当前数字两种选择

代码演示
/*
 * @lc app=leetcode.cn id=39 lang=typescript
 *
 * [39] 组合总和
 *
 * https://leetcode-cn.com/problems/combination-sum/description/
 *
 * algorithms
 * Medium (72.49%)
 * Likes:    1393
 * Dislikes: 0
 * Total Accepted:    273.3K
 * Total Submissions: 376.9K
 * Testcase Example:  '[2,3,6,7]\n7'
 *
 * 给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
 * 
 * candidates 中的数字可以无限制重复被选取。
 * 
 * 说明:
 * 
 * 
 * 所有数字(包括 target)都是正整数。
 * 解集不能包含重复的组合。 
 * 
 * 
 * 示例 1:
 * 
 * 输入:candidates = [2,3,6,7], target = 7,
 * 所求解集为:
 * [
 * ⁠ [7],
 * ⁠ [2,2,3]
 * ]
 * 
 * 
 * 示例 2:
 * 
 * 输入:candidates = [2,3,5], target = 8,
 * 所求解集为:
 * [
 * [2,2,2,2],
 * [2,3,3],
 * [3,5]
 * ]
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 1 <= candidates.length <= 30
 * 1 <= candidates[i] <= 200
 * candidate 中的每个元素都是独一无二的。
 * 1 <= target <= 500
 * 
 * 
 */

// @lc code=start
function dfs(idx: number, target: number, buff: number[], nums: number[], res: number[][]){
    // 因为所有数字都是正数,因此如果target小于零,说明本轮搜索无解
    if(target < 0) return 
    // 如果target刚好为0则说明找到了一组答案,将答案加入到结果数组
    if(target === 0) {
        // 注意,由于js数组是引用关系,不能直接push buff数组,而是要复制一个新数组出来
        res.push([...buff]);
        return;
    }
    // 如果数组遍历到了最后,target依然不为0,说明无解
    if(idx === nums.length) return;
    // 到了这里,我们就要尝试要么使用当前数字,要么不使用当前数字
    // 不使用当前数字,则直接索引加1,目标值不变
    // 为什么在这里不做任何判断,使用了两次dfs递归调用呢?
    // 因为第一种情况是不使用当前数字,我们直接递归匹配下一个数字,如果匹配有结果的话,我们结果已经
    // 加入到结果数组了,没有结果的话,结果数组也不会有任何变化,因此不会对下面的使用当前数字的递归调用产生影响
    dfs(idx + 1, target, buff, nums, res);
    // 使用当前数字的情况
    buff.push(nums[idx]);
    dfs(idx, target - nums[idx], buff, nums, res);
    // 使用完之后,要把当前数字弹出,继续尝试下一个数字
    buff.pop();
}
function combinationSum(candidates: number[], target: number): number[][] {
    const buff: number[] = [];
    const res: number[][] = [];
    dfs(0, target, buff, candidates, res);
    return res;
};
// @lc code=end


广搜

993. 二叉树的堂兄弟节点

解题思路

上面我们使用了深搜实现了判断两个节点是否为堂兄弟的方法,接下来使用广搜来实现一下。首先,第一步还是需要定义状态:

使用广搜解决时,我们定义一个自定义的数据结构Data作为我们的状态,这个自定一结构中包含了我们想要的节点深度、当前节点、当前节点的父节点等信息。

代码演示
/*
 * @lc app=leetcode.cn id=993 lang=typescript
 *
 * [993] 二叉树的堂兄弟节点
 *
 * https://leetcode-cn.com/problems/cousins-in-binary-tree/description/
 *
 * algorithms
 * Easy (55.74%)
 * Likes:    222
 * Dislikes: 0
 * Total Accepted:    47.8K
 * Total Submissions: 85.8K
 * Testcase Example:  '[1,2,3,4]\n4\n3'
 *
 * 在二叉树中,根节点位于深度 0 处,每个深度为 k 的节点的子节点位于深度 k+1 处。
 * 
 * 如果二叉树的两个节点深度相同,但 父节点不同 ,则它们是一对堂兄弟节点。
 * 
 * 我们给出了具有唯一值的二叉树的根节点 root ,以及树中两个不同节点的值 x 和 y 。
 * 
 * 只有与值 x 和 y 对应的节点是堂兄弟节点时,才返回 true 。否则,返回 false。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 
 * 输入:root = [1,2,3,4], x = 4, y = 3
 * 输出:false
 * 
 * 
 * 示例 2:
 * 
 * 
 * 
 * 输入:root = [1,2,3,null,4,null,5], x = 5, y = 4
 * 输出:true
 * 
 * 
 * 示例 3:
 * 
 * 
 * 
 * 
 * 输入:root = [1,2,3,null,4], x = 2, y = 3
 * 输出:false
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 二叉树的节点数介于 2 到 100 之间。
 * 每个节点的值都是唯一的、范围为 1 到 100 的整数。
 * 
 * 
 * 
 * 
 */

// @lc code=start
/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */
// 定义一个辅助结构用来存储节点的父节点的引用
type InfoStruct = {
    father: TreeNode | null
};
// 定义问题求解树状态
type Data = {
    node: TreeNode | null,// 当前节点
    father: TreeNode | null,// 父节点
    depth: number// 当前节点的层级
};

function isCousins(root: TreeNode | null, x: number, y: number): boolean {
    // 要判断两个节点是否是堂兄弟的关系,需要确定以下两点:
    // 1. 两个节点的层级相同
    // 2. 两个节点的父节点不同
    let d1,d2;
    // 广度优先搜索的状态就是这个info
    const info: Data = {
        father: null,
        node: root,
        depth: 0
    }
    // 定义两个辅助对象用于存储目标节点的父节点引用
    const info1: InfoStruct = {
        father: null
    }
    const info2: InfoStruct = {
        father: null
    }
    // 定一个队列辅助广度优先搜索
    const queue: Data[] = [];
    // 先将根节点加入队列
    queue.push(info);
    // 只要队列不为空就一直循环
    while(queue.length) {
        // 弹出队首元素
        const cur = queue.shift();
        // 判断当前弹出的节点值是否匹配x或y,如果是,则分别更新x,info1.father和y,info2.father
        if(cur.node.val === x) d1 = cur.depth,info1.father = cur.father;
        if(cur.node.val === y) d2 = cur.depth,info2.father = cur.father;
        // 当循环过程中发现已经找到答案了,就没必要循环下去了,直接结束
        if(d1 === d2 && info1.father !== info2.father) return true;
        // 如果存在左子树,将左子树根节点压入队列
        if(cur.node.left) queue.push({
            node: cur.node.left,
            depth: cur.depth+1,
            father: cur.node
        });
        // 如果存在右子树,将右子树根节点压入队列
        if(cur.node.right) queue.push({
            node: cur.node.right,
            depth: cur.depth+1,
            father: cur.node
        });
    }
    // 判断是否相同层级并且不同父级
    return d1 === d2 && info1.father !== info2.father;
};
// @lc code=end


542. 01 矩阵

解题思路

看到题目中的最近距离,大家应该就能反应过来,我们要求的是最优解,二话不说,咱们用广搜。而题目中说的每个元素到最近的0的距离,我们可以转换一下思想,每个元素最近的0的距离其实跟每个0跟最近的非0元素的距离是一样的。这样,我们就可以把每一个0作为问题求解树的起点不断往下查找。不过在查找的过程中,我们需要进行判重操作,因为有些点我们可能已经访问过了,就不需要重复操作了。在这里,我们用到了一个方向(偏移量)数组的编程技巧,详细讲解可看本文头部编程技巧扩展的内容。

代码演示
/*
 * @lc app=leetcode.cn id=542 lang=typescript
 *
 * [542] 01 矩阵
 *
 * https://leetcode-cn.com/problems/01-matrix/description/
 *
 * algorithms
 * Medium (45.66%)
 * Likes:    439
 * Dislikes: 0
 * Total Accepted:    53.8K
 * Total Submissions: 117.8K
 * Testcase Example:  '[[0,0,0],[0,1,0],[0,0,0]]'
 *
 * 给定一个由 0 和 1 组成的矩阵,找出每个元素到最近的 0 的距离。
 * 
 * 两个相邻元素间的距离为 1 。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:
 * [[0,0,0],
 * ⁠[0,1,0],
 * ⁠[0,0,0]]
 * 
 * 输出:
 * [[0,0,0],
 * [0,1,0],
 * [0,0,0]]
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:
 * [[0,0,0],
 * ⁠[0,1,0],
 * ⁠[1,1,1]]
 * 
 * 输出:
 * [[0,0,0],
 * ⁠[0,1,0],
 * ⁠[1,2,1]]
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 给定矩阵的元素个数不超过 10000。
 * 给定矩阵中至少有一个元素是 0。
 * 矩阵中的元素只在四个方向上相邻: 上、下、左、右。
 * 
 * 
 */

// @lc code=start
type Data = {
    x: number,// 当前元素的x坐标
    y: number,// 当前元素的y坐标
    k: number,// 当前元素距离最近的非0元素的距离
};

function initQueue(queue: Data[], n: number, m: number, visited: number[][], mat: number[][]){
    for(let i=0;i<n;i++) {
        visited.push([]);
        for(let j=0;j<m;j++) {
            // 如果当前位置的元素不为0,将判重数组的每一位初始置为-1
            if(mat[i][j]){
                visited[i][j] = -1;
            } else {
                // 如果当前位置元素为0,则初始化为0
                visited[i][j] = 0;
                // 并将当前位置的元素加入到广搜队列中
                queue.push({
                    x: i,
                    y: j,
                    k: 0
                });
            }
            
        }
    }
}

function updateMatrix(mat: number[][]): number[][] {
    // 定义方向数组,用于求解任一点移动一步的结果
    const direction = [
        [0,-1],
        [-1,0],
        [0,1],
        [1,0]
    ];
    // 因为要求最优解,采用广度搜索,定义一个Data类型的队列
    const queue: Data[] = [];
    // 由于需要进行数组判重,我们借助额外的一个二维数组,当某个元素被访问过时,我们将改为标记为1
    const visited: number[][] = [];

    const n = mat.length, m = mat[0].length;

    // 初始化广搜队列,将所有原本为0的节点加入到广搜队列当中
    initQueue(queue, n, m, visited, mat);

    // 广搜队列不为空时循环广搜队列
    while(queue.length) {
        const cur = queue.shift();
        // 循环方向数组,查找当前节点能够到达的每一个节点
        for(let i=0;i<direction.length;i++) {
            const x = cur.x + direction[i][0];
            const y = cur.y + direction[i][1];
            // 对我们计算出来的当前节点可能一步到达的节点坐标进行合法性判断
            if(x<0||x>=n) continue;
            if(y<0||y>=m) continue;
            if(visited[x][y]!==-1) continue;
            // 上面三种情况都排除之后,就说明我们的x、y是合法非重复访问的坐标了,我们就可以计算出下一个状态
            queue.push({
                x,
                y,
                k: cur.k + 1
            });
            // 记得在visited数组中标记一下当前节点已经访问过了,方便下一次循环判重
            visited[x][y] = cur.k + 1;
        }
    }
    // 当整个广搜队列遍历完毕,其实我们的visited数组里面存放的就是我们想要的每个节点距离最近的0要走的部署了
    return visited;

};
// @lc code=end


1091. 二进制矩阵中的最短路径

解题思路

这道题起始跟上一道题解题思路是一样的,只是从原先只能走上下左右四个方向增加了左上、右上、左下、右下四个方向,总共有8个方向可以移动。其他都与上一题大同小异,直接看代码

代码演示
/*
 * @lc app=leetcode.cn id=1091 lang=typescript
 *
 * [1091] 二进制矩阵中的最短路径
 *
 * https://leetcode-cn.com/problems/shortest-path-in-binary-matrix/description/
 *
 * algorithms
 * Medium (37.21%)
 * Likes:    105
 * Dislikes: 0
 * Total Accepted:    20.2K
 * Total Submissions: 54.3K
 * Testcase Example:  '[[0,1],[1,0]]'
 *
 * 给你一个 n x n 的二进制矩阵 grid 中,返回矩阵中最短 畅通路径 的长度。如果不存在这样的路径,返回 -1 。
 * 
 * 二进制矩阵中的 畅通路径 是一条从 左上角 单元格(即,(0, 0))到 右下角 单元格(即,(n - 1, n -
 * 1))的路径,该路径同时满足下述要求:
 * 
 * 
 * 路径途经的所有单元格都的值都是 0 。
 * 路径中所有相邻的单元格应当在 8 个方向之一 上连通(即,相邻两单元之间彼此不同且共享一条边或者一个角)。
 * 
 * 
 * 畅通路径的长度 是该路径途经的单元格总数。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:grid = [[0,1],[1,0]]
 * 输出:2
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:grid = [[0,0,0],[1,1,0],[1,1,0]]
 * 输出:4
 * 
 * 
 * 示例 3:
 * 
 * 
 * 输入:grid = [[1,0,0],[1,1,0],[1,1,0]]
 * 输出:-1
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * n == grid.length
 * n == grid[i].length
 * 1 
 * grid[i][j] 为 0 或 1
 * 
 * 
 */

// @lc code=start
type Data = {
    x:  number,
    y: number,
    k: number// 当前几点到达目标点的最短路径
}

function shortestPathBinaryMatrix(grid: number[][]): number {
    // 偏移量数组
    const direction: number[][] = [
        [0,-1],// 向上
        [-1,0],// 向左
        [0,1],// 向下
        [1,0],// 向右
        [-1,-1],// 左上
        [1,-1],// 右上
        [-1,1],// 左下
        [1,1],// 右下
    ];
    // 定义广搜队列
    const queue: Data[] = [];
    // 矩阵边界
    const n = grid.length,m = grid[0].length;
    // 访问过的元素数组
    const visited: number[][] = [];
    for(let i=0;i<n;i++){
        const tmp = new Array(m);
        tmp.fill(0);
        visited.push(tmp);
    }
    // 初始化广搜矩阵
    // 如果左上角的点本身就是非0的数,说明我们已开始就没办法从左上角出发,肯定无解
    if(grid[0][0]) return -1;
    // 如果(0,0)点合法,则标记一下
    visited[0][0] = 1;
    queue.push({
        x: 0,
        y: 0,
        k: 1,// 由于题目要求的是总共要走几步,因此,初始时应该是1
    });

    // 广搜队列不为空则进入循环
    while(queue.length) {
        const cur = queue.shift();
        // 如果当前节点已经是目标节点了,直接返回他的最短路径即可
        if(cur.x === n - 1 && cur.y === m - 1) return cur.k;
        for(let i=0;i<direction.length;i++) {
            const x = cur.x + direction[i][0];
            const y = cur.y + direction[i][1];
            // 坐标合法性校验
            if(x<0||x>=n) continue;
            if(y<0||y>=m) continue;
            // 有访问过,不合法
            if(visited[x][y]) continue;
            // 当前点不是0,不合法
            if(grid[x][y]) continue;
            // 将当前坐标加入到已访问数组中
            visited[x][y] = 1;
            // 将当前坐标加入到广搜队列中
            queue.push({
                x,
                y,
                k: cur.k + 1
            });
        }
    }

    // 广搜队列遍历完之后都没有叨叨目标点,说明不存在这样的路径
    return -1;
};
// @lc code=end


752. 打开转盘锁

解题思路

像这种开锁的问题,我们要明确我们的状态是什么,状态其实就是密码串,而每一次拨动密码锁,可能会有8中可能,哪八种呢?总共有四个锁盘,你可以任意拨动其中一个锁盘,这就是4种了,但是,别忘了,锁盘是可以上下拨动的,向下拨就变大,向上拨就减小,所以,对于每一个锁盘,操作一次的状态变化有两种,所以总共有8中变化。明确了状态以及变化的可能性之后,我们只需要用广搜的思想,在搜索过程中排除掉重复的和在死亡列表中的密码串,直到找得到目标密码或者是广搜队列为空时结束。

代码演示
/*
 * @lc app=leetcode.cn id=752 lang=typescript
 *
 * [752] 打开转盘锁
 *
 * https://leetcode-cn.com/problems/open-the-lock/description/
 *
 * algorithms
 * Medium (49.55%)
 * Likes:    263
 * Dislikes: 0
 * Total Accepted:    43.9K
 * Total Submissions: 88.5K
 * Testcase Example:  '["0201","0101","0102","1212","2002"]\n"0202"'
 *
 * 你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字: '0', '1', '2', '3', '4', '5', '6', '7', '8',
 * '9' 。每个拨轮可以自由旋转:例如把 '9' 变为  '0','0' 变为 '9' 。每次旋转都只能旋转一个拨轮的一位数字。
 * 
 * 锁的初始数字为 '0000' ,一个代表四个拨轮的数字的字符串。
 * 
 * 列表 deadends 包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再被旋转。
 * 
 * 字符串 target 代表可以解锁的数字,你需要给出最小的旋转次数,如果无论如何不能解锁,返回 -1。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:deadends = ["0201","0101","0102","1212","2002"], target = "0202"
 * 输出:6
 * 解释:
 * 可能的移动序列为 "0000" -> "1000" -> "1100" -> "1200" -> "1201" -> "1202" -> "0202"。
 * 注意 "0000" -> "0001" -> "0002" -> "0102" -> "0202" 这样的序列是不能解锁的,
 * 因为当拨动到 "0102" 时这个锁就会被锁定。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入: deadends = ["8888"], target = "0009"
 * 输出:1
 * 解释:
 * 把最后一位反向旋转一次即可 "0000" -> "0009"。
 * 
 * 
 * 示例 3:
 * 
 * 
 * 输入: deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"],
 * target = "8888"
 * 输出:-1
 * 解释:
 * 无法旋转到目标数字且不被锁定。
 * 
 * 
 * 示例 4:
 * 
 * 
 * 输入: deadends = ["0000"], target = "8888"
 * 输出:-1
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 死亡列表 deadends 的长度范围为 [1, 500]。
 * 目标数字 target 不会在 deadends 之中。
 * 每个 deadends 和 target 中的字符串的数字会在 10,000 个可能的情况 '0000' 到 '9999' 中产生。
 * 
 * 
 */

// @lc code=start
type Data = {
    pass: string,// 密码序列
    k: number// 达到这个密码序列需要几步
};

// 用于计算下一个状态的字符串
function getNextStr(str: string, i: number, j: number): string {
    const res = str.split('').map(str=>parseInt(str));
    // j为0是说明锁盘向上拨变小,j为1时说明锁盘向下拨变大
    switch(j){
        case 0: res[i] -= 1; break;
        case 1: res[i] += 1; break;
    }
    // 处理一下边界情况
    if(res[i] > 9) res[i] = 0;
    if(res[i] < 0) res[i] = 9;
    return res.join('');
}

function openLock(deadends: string[], target: string): number {
    // 定义广搜队列
    const queue: Data[] = [];
    // 定义一个用于判断非法传的set,只要在这个set里面的密码序列,就不能使用
    const set: Set<string> = new Set<string>();
    // 将死亡列表中的密码序列加入到set中
    deadends.forEach(s => set.add(s));
    // 如果0000这个密码序列在死亡列表中,说明永远无法开锁,返回-1
    if(set.has('0000')) return -1;
    // 如果0000不在死亡列表,则将0000加入到广搜队列中
    queue.push({
        pass: '0000',
        k: 0
    });
    // 广搜队列不为空时进入循环
    while(queue.length) {
        const cur = queue.shift();
        if(cur.pass === target) return cur.k;
        // 由于我们的锁盘的每个位置都可以向上调,也可以向下调,总共有4个锁盘,因此有8中情况
        for(let i=0;i<4;i++) {
            for(let j=0;j<2;j++) {
                // 获取下一个状态
                const nextStr = getNextStr(cur.pass, i, j);
                // 如果计算出来的下一个状态在set中,则忽略
                if(set.has(nextStr)) continue;
                // 否则将计算出来的状态加入到set宏
                set.add(nextStr);
                // 并将下一个状态压入队列
                queue.push({
                    pass: nextStr,
                    k: cur.k + 1
                });
                
            }
        }
    }
    return -1;

};
// @lc code=end


剑指 Offer 13. 机器人的运动范围

解题思路

这道题没有什么除了一个数位之和的边界条件之外,其他就是标准的广搜解法,不再赘述,详看代码实现。

代码实现

type Data = {
    x: number,
    y: number
};
// 用于获取行坐标与列坐标数位之和
function numItem(num1: number, num2:number): number{
    const str = `${num1}${num2}`;
    const arr = str.split('');
    return arr.reduce((a, b)=>a + parseInt(b),0)
}
function movingCount(m: number, n: number, k: number): number {
    // 广搜队列
    const queue: Data[] = [];
    // 已访问过的节点
    const visited: number[][] = [];
    for(let i=0;i<m;i++) {
        const tmp = new Array(n);
        tmp.fill(0);
        visited.push(tmp);
    }
    // 方向/偏移量数组
    const direction: number[][] = [
        [-1,0],
        [0,1],
        [1,0],
        [0,-1]
    ];
    // 如果(0,0)这个坐标数位之和就大于k,无需继续,依题意直接返回1
    if(numItem(0,0) > k) return 1;
    // 将(0,0)坐标标记为已访问
    visited[0][0] = 1;
    // 将(0,0)坐标加入到广搜队列中
    queue.push({
        x: 0,
        y: 0
    });
    // 用于统计总格子数
    let res = 0;
    // 广搜队列不为空则进入循环
    while(queue.length) {
        const cur = queue.shift();
        // 没进入一次循环结果数量加1
        res += 1;
        // 循环判断每个方向计算出来的坐标的合法性,如果坐标合法则标记为已访问并加入广搜队列
        for(let i=0;i<direction.length;i++) {
            const x = cur.x + direction[i][0];
            const y = cur.y + direction[i][1];
            if(x<0||x>=m) continue;
            if(y<0||y>=n) continue;
            if(visited[x][y]) continue;
            if(numItem(x, y) > k) continue;
            queue.push({
                x,
                y
            });
            visited[x][y] = 1;
        }
    }

    return res;
};

你可能感兴趣的:(数据结构,前端基础,知识梳理,算法,DFS,BFS,深搜,广搜)