算法学习 (门徒计划)3-3 深搜(DFS)与广搜(BFS)及经典问题 学习笔记

前言

(7.9,还差2课,继续努力!)

本篇为开课吧门徒计划第九讲3-3深搜(DFS)与广搜(BFS):初识问题状态空间。

本课学习的目标是:

  • 了解搜索类算法中的两大基础算法(深搜与广搜)
  • 学习搜索算法的核心概念
  • 学习把握搜索状态的方式

学习总结(学完后记录):

  • 搜索算法本质上是对于问题求解树的一种遍历形式,通过规定状态将无限的可能以逻辑的形式进行推演,选择合适的状态表述是一种优化,选择合适的拓展方式也是一种优化
  • 而常用的树遍历方式为前序遍历和层序遍历,这分别对应搜索算法的深度搜索dfs和广度搜索bfs
  • 问题求解树的2个核心因素为:
    • 状态
    • 扩展方式

(本次学习依然挑战最短学习时间,期望3倍耗时以内完成)

深搜与广搜

搜索的核心概念

  • 问题求解树(思维逻辑上的抽象结构)(或称为问题状态树)
  • 深搜与广搜(对于问题求解树不同的遍历方式)
  • 搜索剪枝和优化(对于问题求解树的无限可能性排除一部分不想要的情况)
  • 设计搜索算法的核心关键点(设计问题求解树的状态)

(这些概念将在经典例题阶段通过解题加深理解,下方仅是我学习是有做总结的题目)

  • 问题求解树:题542
  • 深搜与广搜:题993、题130
  • 状态:题993
  • 搜索剪枝和优化:题542
  • 记忆化优化:题494、题473

问题求解树

问题求解树是人对于解决问题时进行层次化思考时思考逻辑节点在逻辑链路上排布形成的抽象树型结构(举例就是先……再……、当……就……)。

不同的问题都有适合自身的问题求解树,这棵树上的节点是影响问题解决的状态,这颗树的度是问题层级切分情况,任意子树的根表示表述当前问题的切入点。
(到这里要有一个抽象的树能够想象出来)

强调问题求解树是思维中的抽象结构!(问题求解树的可能性是无限的,当思考时想象出树时只是选择了一种高概率的出现情况)

总结:

  • 节点:解决问题的状态
  • 度:问题的展开逻辑
  • 根:当前问题的切入点

搜索剪枝和优化

对于问题求解树的无限可能性,在想象或逻辑描述时就已经会选择一些高概率的情况,通常是一些更合理的情况。这种选择性的逻辑就是搜索剪枝和优化的应用。同样的,在已经准备描述的问题求解树进行进一步的删减也是可行的,因此:

搜索剪枝和优化是排除某些问题求解数中的子树的遍历过程。

问题求解树的状态

把握问题解决时决定每个步骤的状态是解决问题的关键,而这些状态就是问题求解树的节点,也是设计搜索算法的核心关键点。

对比深搜与广搜

搜索需要扩展状态,就相当于某一个状态沿着某个度生成新的状态。

DFS-深度(deep)优先搜索

  • 参考问题求解树的概念,深度优先搜索就是尽可能的推演单向的非分支逻辑(一根筋,不碰壁不回头
  • 回顾二叉树的遍历查询某一个节点时,可以采用深度搜索的方式,此时就是不断递归直到叶子节点,查不到时返回上一级再改换另一分支。(想一下前序遍历二叉树
  • 深度优先搜索通常以递归的方式进行表现,利于处理最终结果问题。利于处理需要讨论清楚逻辑的最终结果的情况

BFS-广度优先搜索

  • 参考问题求解树的概念,广度优先搜索就是每种情况都考虑一层然后再针对每种问题进行(层级分割,步步推进
  • 回顾二叉数的遍历某一个节点时,可以采用广度优先的方式,每一层级进行查找,查不到时对于下一层进行遍历(想一下层级打印二叉树的代码、层序遍历二叉树
  • 广度优先搜索通常以队列的方式进行表现,利于解决最优化问题。利于处理最少到达某种结果的步骤的情况。

经典例题-广搜

993.二叉树的堂兄弟节点

链接:https://leetcode-cn.com/problems/cousins-in-binary-tree

在二叉树中,根节点位于深度 0 处,每个深度为 k 的节点的子节点位于深度 k+1 处。

如果二叉树的两个节点深度相同,但 父节点不同 ,则它们是一对堂兄弟节点。

我们给出了具有唯一值的二叉树的根节点 root ,以及树中两个不同节点的值 x 和 y 。

只有与值 x 和 y 对应的节点是堂兄弟节点时,才返回 true 。否则,返回 false。

解题思路

本题需要先理解堂兄弟节点的概念:同层,但是父节点不同。
另外把握本题的一个特征每个节点值唯一,因此判断是否是同一节点时可以用val来进行。

因此本题的逻辑就是在每一层寻找两个目标节点,当找到这两个节点时并不是在同一个父节点下找到时,就返回true,其余情况false

以这种方式循环时,结束循环的条件为:

  • 找到两个点但发现不符合堂兄弟节点的概念
  • 在某一深度只找到了一个点

(本题就是回顾广度搜索的概念,层级搜索,常用队列存储下一轮次搜索目标)

(此外本题还回顾问题求解树的核心:状态。在本题中状态是否找到目标节点,并且找到时需要不在同一节点下,这是两个核心状态)

(另外本题也可以用深度搜索来解题)
深度搜索是优先目标搜索,因此先用递归函数去搜寻目标点,并准备一组变量存储找到第一个点的深度和父节点。

结束递归的结束条件为:

  • 找到两个点但发现不符合堂兄弟节点的概念

(对比本题的两种解法,可以发现用广搜有更丰富的结束条件,因此在相同的问题表现时,广搜更可能可以更快的完成搜索)
(但是我代码结果显示两种方式性能接近,甚至深搜更快?)

(简单题用于学习,写一下代码)

示例代码

(广度搜索,1ms,36MB)

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */
class Solution {
    public boolean isCousins(TreeNode root, int x, int y) {

        Queue<TreeNode> nodeQueue = new LinkedList<TreeNode>();
        Queue<Integer> depthQueue = new LinkedList<Integer>();

        nodeQueue.offer(root);
        depthQueue.offer(0);

        boolean getPoint = false;
        int nowDepth = 0;

        while(!nodeQueue.isEmpty()){
            TreeNode node  = nodeQueue.poll();
            Integer depth  = depthQueue.poll();

            if(node.left!=null){
                if(node.left.val == x||node.left.val ==y){
                    if(nowDepth == depth+1) return true;
                    nowDepth = depth+1;
                    getPoint = true;
                }
                nodeQueue.offer(node.left);
                depthQueue.offer(depth+1);
            }
            if(node.right !=null){
                if(node.right.val == x||node.right.val ==y){
                    if(getPoint) return false;
                    
                    if(nowDepth == depth+1) return true;
                    nowDepth = depth+1;
                }
                nodeQueue.offer(node.right);
                depthQueue.offer(depth+1);
            }
            getPoint = false; 
        }

        return false;
    }
}

(深度搜索,0ms,36MB)

class Solution {
    private boolean res = false;
    private int nowDepth = 0;
    private boolean bothF = false;

    public void dfs(TreeNode node ,int x,int y,int depth){
        if(node == null) return;
        if(bothF||res) return;
        
        boolean getPoint = false;

        if(node.left!=null){
            if(node.left.val == x||node.left.val ==y){
                if(nowDepth == depth+1) {
                    res = true;
                    return;
                }
                nowDepth = depth+1;
                getPoint = true;
            }
        }
        if(node.right !=null){
            if(node.right.val == x||node.right.val ==y){
                if(getPoint) {
                    bothF = true;
                    return;
                }
                if(nowDepth == depth+1) {
                    res = true;
                    return;
                }
                nowDepth = depth+1;
            }
        }
        if(node.left!=null) dfs(node.left,x,y,depth+1);
        if(node.right!=null) dfs(node.right,x,y,depth+1);
    }

    public boolean isCousins(TreeNode root, int x, int y) {

        dfs(root,x,y,0);
        
        return res;
    }
}

542.01矩阵

给定一个由 0 和 1 组成的矩阵,找出每个元素到最近的 0 的距离。

两个相邻元素间的距离为 1 。

示例 2:

输入:
[[0,0,0],
 [0,1,0],
 [1,1,1]]

输出:
[[0,0,0],
 [0,1,0],
 [1,2,1]]

解题思路

先理解题目的意思,先提供一个矩阵表示0和1的出现情况,然后要求返回一个结果矩阵表示所有点到离自身最近的0所在位置的距离。

此处把握一个思想,如果某一个点已知周围4个点到0号节点的距离,那么这个点自身又不是0号节点的情况下,这个点到到离自身最近的0所在位置的距离为多少呢?

显然是周围4个点中的最小距离加1
(以上的概念就是状态

由此可以得出解题的逻辑为先获得所有点到0节点距离为1的情况,再获得剩下点(非零,又非1)到距离为1的点距离为1的情况(此时为2),再获得剩下点(非零,又非1,又非2)到距离为2的点距离为1的情况(此时为3)。
依次类推

(以上的概念为问题求解树

此时一种方法是循环遍历N次,每一次计算周围4个点,自身是否非零以及,是否经历,再考虑是否存入结果数组,持续循环直到最终没有新值进入结果数组

(但是这种方式,会重复的经过大量已经经过的点,效率不高,因此该用另一种方案,广搜)

这种方案是先将点和自身相邻点存入队列,先存入到0节为0的点,随后出队时,将周围相邻点间距为1的点存入队列,最终一轮结束时队列内只有到0节为1的点和周围相邻点。随后执行下一轮,这一次需要存入的是到0点间距为2的。以此类推。

(这种方案也是一种问题求解树,但是相比起第一种,更快的排除了已经完成载入结果数组的点,这就是搜索剪枝和优化

为了方便的获取相邻点再准备一个概念,方向数组。当自身坐标和方向数组进行叠加时,获得的就是相邻点的坐标。

随后准备结果矩阵时,令矩阵内所有点初始值为-1(任意负数均可)表示未判定状态。

如何确保每一轮入队的元素刚好是距离为1的?令队列中元素出队列时,将结果集中所有这个元素所在坐标相邻的值为-1点进行入队即可,并且将新入队的元素存入结果集合

(想象一下在绘制等高线)

重新总结一下代码逻辑:

  • 设计存储点坐标的队列(广搜)
  • 设计结果矩阵和相邻数组(搜索剪枝和优化)
  • 设计运行逻辑为将相邻的-1点入队(状态)
  • 设计初始队列内容为自身为0的点作为队列起始内容(问题求解树的根)
  • 设计出队和入队执行的业务代码去查询相邻点(树的度)

示例代码

class Solution {

    public int[][] updateMatrix(int[][] mat) {
        int [][] dir = {{0,1},{1,0},{0,-1},{-1,0},};

        int xlen = mat.length;
        int ylen = mat[0].length;

        if(xlen==0||ylen==0)return mat;

        //Pair<Integer,Integer> point ;
        LinkedList <Point> point_Q  = new LinkedList<>();

        for(int i=0;i<xlen;i++)
            for(int j =0;j<ylen;j++){
                if (mat [i][j] == 1){
                    mat [i][j] = -1;
                }else
                    point_Q.offer(new Point(i,j));
            }
    
        while(!point_Q.isEmpty()){
            Point point = point_Q.poll();
            int  x0 = point.x;
            int  y0 = point.y;

            //System.out.println(x0+"+---"+y0+"+"+ mat[x0][y0] );

            for(int k = 0;k<4;k++){
                int x = x0 +dir[k][0];
                int y = y0 +dir[k][1];

                if(x<0||x>=xlen)continue;
                if(y<0||y>=ylen)continue;
                if(mat[x][y]!=-1) continue;

                //System.out.println(x+"+"+y+"+"+ mat[x0][y0] );

                mat[x][y] = mat[x0][y0] +1;
                point_Q.offer(new Point(x,y));
            }
        }

        return mat;
    }
}

class Point{
    int x;
    int y;

    public Point(int x,int y){
        this.x = x;this.y= y;
    }
}

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

链接:https://leetcode-cn.com/problems/shortest-path-in-binary-matrix

给你一个 n x n 的二进制矩阵 grid 中,返回矩阵中最短 畅通路径 的长度。如果不存在这样的路径,返回 -1 。

二进制矩阵中的 畅通路径 是一条从 左上角 单元格(即,(0, 0))到 右下角 单元格(即,(n - 1, n - 1))的路径,该路径同时满足下述要求:

路径途经的所有单元格都的值都是 0 。
路径中所有相邻的单元格应当在 8 个方向之一 上连通(即,相邻两单元之间彼此不同且共享一条边或者一个角)。
畅通路径的长度 是该路径途经的单元格总数。

示例 1:

在这里插入图片描述

输入:grid = [[0,0,0],[1,1,0],[1,1,0]]
输出:4

解题思路

本题和上一题很接近,因此可以先考虑广搜的解题方案。

本题期望从左上到右下,并设计一条最短路径。

(这里有个近似模板化的解题思路)

  • 根:起点
  • 状态:是否经过、此时的距离
  • 度:相邻8个点(准备方向数组)

(这个模板是我总结的,根是放入广搜队列的起始状态组,状态是当前状态表达和新状态的容许条件,度是拓展状态的方向规则)

由此构建解题逻辑:

  • 设计队列将左上角坐标作为初始点存入队列中
  • 设计路过矩阵和8向相邻数组
  • 设计运行逻辑为将相邻结果矩阵中为0的点存入结果集合,并要求该点可经过(初始矩阵中为0),通过后路过状态矩阵将该点置位真
  • 设计结束逻辑:初始点不可行、到达右下角、队列为空

这种方式可行就是因为采用了广搜逻辑,当出现结果时,此时的步数就是最少步数。

(代码略)

LeetCode 752. 打开转盘锁

链接:https://leetcode-cn.com/problems/open-the-lock

你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有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"。

解题思路

本题首先要把握这个转盘锁的形状,是一个圆,

其次本题要考虑这个死亡数字(deadends)如何会影响去转密码盘,先是影响最大的情况,由于密码盘逐个转动的,因此如果密码盘有3位已经到达目标,但是最后一位却被锁死,向上或向下都会经过死亡数字,此时就需要转动其他密码盘来规避锁死情况。

此时运用当前的知识,这些死亡数字在构建通往目标的路径上是必须规避的,并且本题要求最小旋转次数,综上本题用广搜来解,状态为当前的数字,而度为每一个表盘向上或者向下旋转

(再练习一下广搜模板)

  • 根:起点0000
  • 状态:是否经过、此时的数字
  • 度:每一位向上或者向下

搜索需要扩展状态,就相当于某一个状态沿着某个度生成新的状态。那么有几种度呢,有8种,每一位可以选择向下或者向上

然后再考虑一个情况会有可能从A数字转为B数字后若干步操作后再次回到A吗?绝对不应该发生。因此需要一个存储机制存入转过的数字组合。

(这个存储机制可以用哈希表,或者长度10001的布尔数组,都行)

而这个表内,可以首先存入死亡数字,因为这些数字不被允许经过。

至此解题思路完整,重新描述一遍。

  • 准备广搜队列,准备存储元素的格式(字符串)
  • 准备历史数字集合管理方式(哈希表)
  • 准备扩展方式(8种情况)
  • 准备起始条件,终止条件(考虑边际情况)
  • 开始广搜循环(每个循环中试图进行状态扩展,根据历史进行、目标数字进行判断)

(示例代码略)

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

链接:https://leetcode-cn.com/problems/ji-qi-ren-de-yun-dong-fan-wei-lcof

地上有一个m行n列的方格,从坐标 [0,0] 到坐标 [m-1,n-1] 。一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向左、右、上、下移动一格(不能移动到方格外),也不能进入行坐标和列坐标的数位之和大于k的格子。例如,当k为18时,机器人能够进入方格 [35, 37] ,因为3+5+3+7=18。但它不能进入方格 [35, 38],因为3+5+3+8=19。请问该机器人能够到达多少个格子?

示例 1:
输入:m = 2, n = 3, k = 1
输出:3

示例 2:
输入:m = 3, n = 1, k = 0
输出:1

解题思路

本题以当前的熟练度,应该迅速判断出应该用广搜来解

(再练习一下广搜模板)

  • 根:起点坐标
  • 状态:是否经过、此时的坐标
  • 度:相邻4个点方向数组

此外再准备一些工具函数,用于将坐标转为数字进行求和与K判断,用于管理经过的坐标。

最终每拓展一个新状态就认为机器人可以多到达一个点。

(示例代码略)

经典例题-深搜

深度搜索中,状态会被表示为参数(相比起广搜,深搜的状态会多一重概念)

Leetcode 130. 被围绕的区域

链接:https://leetcode-cn.com/problems/surrounded-regions

给你一个 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’。如果两个元素在水平或垂直方向相邻,则称它们是“相连”的。

解题思路

可以用广搜解题,广搜的特征是可以层层分解

本题期望将所有被X围绕的区域,变为X,但要求完全包围,不能和边缘直接接触。

本题如果考虑从内部取寻找不和外部接触的区域会比较难以设计,但是如果玩过电脑的画图软件中的油漆桶,就可以理解到,可以从外部染色的方式来进行数据隔离,也就是先将所有于边沿接触的O存入广搜队列。

然后设定循环去去感染直接相连的的O,当广搜终止时,原始数组将变为3部分,被X包含的O,于外界相连被感染的O,X,根据这个情况再遍历一次输出即可。

(但是这种方式真的好吗?这种方式的核心是反选,反向选择除于边沿接触的的O直接相邻外的元素,这种方式需要多次遍历所有元素,从而完成染色,再进行反选)
那么有没有一种办法只遍历边沿元素呢?没有。

(以下是课上的深搜思路)

深搜和广搜的思路是接近的,都是遍历方式,但是程序实现上差别就很大。

为了让所有被X包围的O变为X,需要采用逆向思维,先考虑什么情况的O不被包围(于边相邻的O),因此先让于边相邻的O暂且的变为小写的O。

当全部处理完毕后剩余的非小写O的都改为X足以;

那么如何把所有边上的O变为小O呢,并且用深搜来实现呢。

于是设定深搜的职能为改变满足条件的O为小写。

  • 需要判断坐标的合法性(包括扩展的坐标)
  • 起点条件为外围的O
  • 需要准备方向数组用于扩展状态
  • 令进一步搜索的动作放在状态扩展的分支中(度)
  • 每搜一遍改变O

(我总结了课上的方案,核心逻辑和我的一样都是反向思维,但是搜索的模式改为了深度搜索,而两种方案的扩展模式都是相同的,不同的仅是对于度生成的新状态的处理)
(换句话说,深搜和广搜所进行搜索的问题求解树是同一颗,仅仅是搜索的方式不同罢了)

(用于学习,写一下代码)
(我不想使用小写O,我使用r代替)

示例代码

(广搜,3ms,40MB)

class Solution {

    LinkedList <Point> point_q ;
    private int xlen;
    private int ylen;

    private final int [][] dir = {{0,1},{1,0},{0,-1},{-1,0},};

    private void bfs(char[][] board,Point p){

        int x0 = p.x;
        int y0 = p.y;

        for(int k = 0;k<4;k++){
            int x = x0 +dir[k][0];
            int y = y0 +dir[k][1];

            if(x<0||x>=xlen)continue;
            if(y<0||y>=ylen)continue;
            if(board[x][y]!='O') continue;

            board[x][y] = 'r';
            point_q.offer(new Point(x,y));
        }

    }

    public void solve(char[][] board) {
        if(board==null)return;
        xlen = board.length;
        ylen = board[0].length;
        if(xlen==0||ylen==0)return;

        point_q = new LinkedList <Point>();

        for(int i=0;i<xlen;i++)
            for(int j =0;j<ylen;j++){
                if((i==0||j==0||i+1==xlen||j+1==ylen))
                    if (board [i][j]== 'O'){
                        point_q.offer(new Point(i,j));
                        board [i][j] = 'r';
                    }
            }

        while(!point_q.isEmpty()){
            bfs(board,point_q.poll());
        }

        for(int i=0;i<xlen;i++)
            for(int j =0;j<ylen;j++){
                board [i][j] = board [i][j] == 'r' ?'O':'X';
            }
        
    }
}

class Point {
    int x,y;
    public Point(int x0,int y0){
        x = x0;
        y = y0;
    }
}

(深搜,2ms,40.7MB)

class Solution {

    //LinkedList <Point> point_q ;
    private int xlen;
    private int ylen;

    private final int [][] dir = {{0,1},{1,0},{0,-1},{-1,0},};

    private void dfs(char[][] board,Point p){

        int x0 = p.x;
        int y0 = p.y;

        for(int k = 0;k<4;k++){
            int x = x0 +dir[k][0];
            int y = y0 +dir[k][1];

            if(x<0||x>=xlen)continue;
            if(y<0||y>=ylen)continue;
            if(board[x][y]!='O') continue;

            board[x][y] = 'r';

            dfs(board,new Point(x,y));
        }

    }

    public void solve(char[][] board) {
        if(board==null)return;
        xlen = board.length;
        ylen = board[0].length;
        if(xlen==0||ylen==0)return;

        //point_q = new LinkedList <Point>();

        for(int i=0;i<xlen;i++)
            for(int j =0;j<ylen;j++){
                if((i==0||j==0||i+1==xlen||j+1==ylen))
                    if (board [i][j]== 'O'){
                        board [i][j] = 'r';
                        dfs(board,new Point(i,j));
                    }
            }

    

        for(int i=0;i<xlen;i++)
            for(int j =0;j<ylen;j++){
                board [i][j] = board [i][j] == 'r' ?'O':'X';
            }
        
    }
}

class Point {
    int x,y;
    public Point(int x0,int y0){
        x = x0;
        y = y0;
    }
}

LeetCode 494. 目标和 (记忆化技巧学习)

解题思路

先跳出广搜和深搜,本题如果用问题求解树考虑,就是要计算这棵树的叶子节点数目(搜索的可行最终结果状态)

那么这种需要到达末端的搜索策略,就应该使用深搜,因为深搜的特征就是会到达末端(但是由于要到达所有的末端,因此广搜的效果也是相同的)

那么如何拓展状态向下递归呢?

显然本题应该从传递一个枚举组合的状态,因此准备递归函数返回剩余情况的有效组合数,向下的递归的内容为本次枚举情况和剩余集合(剩余枚举集合用集合nums的遍历坐标i表示,本次枚举情况用target的剩余需求值决定)。
定义传递的参数为(int i,int target)

再在递归的过程中如果发现最终枚举情况满足要求,则返回1否则返回0表示状态扩展失败。

(但是这种枚举性能很差,能不能优化一下呢)

此处需要一个记忆化的技巧

这部分技巧源于之前课程中提过的思想:

  • 数组时展开的函数
  • 函数是压缩的数组

(但我忘了)

基于这个思想,可以得出数组和函数没有区别的结论,由此优化dfs函数,让这个函数的入参成为数组的维度

为了创建这个数组结构(这个数组是不完全的,因此需要用哈希表来存储)需要设计一个数对(坐标),再将这个数对存入HashMap中,键值为数对。

而这个坐标实际上就是dfs函数的两个入参。

利用这个哈希表,当某一组坐标试图进行递归前,可以查表判断之前是否有判断过这种这种情况并且得到了结果,如果有则不再需要进行进一步递归。

(此处可以总结,这种记忆化搜索就是用数组结构存储函数的情况,实现实现函数状态和数组的转换

(此处应用的hash表如果没有合适的哈希函数需要自行设计一个,因为封装的对可能没有合适的哈希函数)

(但是进一步思考为什么本题可以用记忆化来实现?)
(这是因为本题的问题求解树的部分子树虽然到达路径不同但是在状态的对外表示上相同,因此可以复制一颗现成的状态树的结果来使用)
(换句话说,就是存在殊途同归的现象)

(另外我发现记忆化优化时,用类去保存状态,会导致hash运算过久,因此改用字符串代替,我不知道怎么优化,有知道的吗?)

示例代码

(无记忆化,495ms,35.8MB)

class Solution {

    private int  dfs(int []nums ,int index,int target){
        if(index  == 0){
            if(target!=0){
                return 0;
            }
            return 1;
        }
        index --;

        return dfs(nums,index, target+nums[index])
        +dfs(nums,index, target-nums[index]);
    }

    public int findTargetSumWays(int[] nums, int target) {
        int index = nums.length; 

        return dfs(nums,index,target);
    }
}

(记忆化优化,83ms,40MB)

class Solution {

    //HashMap<PII,Integer > h = new HashMap<PII,Integer>();
    HashMap<String,Integer > h = new HashMap<String,Integer>();

    private int  dfs(int []nums ,int index,int target){
        if(index  == 0){
            if(target!=0){
                return 0;
            }
            return 1;
        }
        index --;

        //PII now = new PII(index,target);
        String now = index+","+target;
        Integer ans = h.get(now);

        if(ans == null){
            ans = dfs(nums,index, target+nums[index])
            +dfs(nums,index, target-nums[index]);
            h.put(now,ans);
        }

        return ans;
    }

    public int findTargetSumWays(int[] nums, int target) {
        int index = nums.length; 

        return dfs(nums,index,target);
    }
}

// class PII{
//     int index,target;
//     public PII(int i,int t){
//         index = i;
//         target =t;
//     }
// }

LeetCode 473. 火柴拼正方形

链接:https://leetcode-cn.com/problems/matchsticks-to-square

还记得童话《卖火柴的小女孩》吗?现在,你知道小女孩有多少根火柴,请找出一种能使用所有火柴拼成一个正方形的方法。不能折断火柴,可以把火柴连接起来,并且每根火柴都要用到。

输入为小女孩拥有火柴的数目,每根火柴用其长度表示。输出即为是否能用所有的火柴拼成正方形。

示例 1:

输入: [1,1,2,2,2]
输出: true

解释: 能拼成一个边长为2的正方形,每边两根火柴。

**
示例 2:**

输入: [3,3,3,3,4]
输出: false

解释: 不能用所有火柴拼成一个正方形。

解题思路

本题中要考虑一种情况,可否出现短的火柴并联的,或者在正方形中拼出十字等其他情况呢。经过读题,可以明确,本题可以转换为,

能否将将数组内的值分割成4个集合,并另每个集合的和相等?

本题的解题初步逻辑为,先统计总数,然后试图组建第一条边,再尝试下一条边,逐步尝试如果4条边都能成功则得出结果。

由此构建问题求解树,可以根据思考路径得出,需要判断到状态无法扩展的情况,因此本题用dfs解。

另外本题需要的递归函数为要求根据剩余数字生成某一固定值是否可行,如果可行则将组合的数置位已经使用,并继续向下递归。否则返回失败。

因此本题先需要计算出一边需要持有的值,然后用这个递归函数去凑,每一组不同的凑集合情况都进行向下递归。

(上题刚讲过记忆化,这题能不能用呢?取决于一点,问题求解树是否存在殊途同归的情况,换句话就是是否存在某个剩余集合已经无法拼凑出目标,完全有可能,比如第一组边和第二组边的拼凑顺序相反,但是剩余的边还未确定的情况,此时剩余集合相同,目标相同,失败的结果也相同)

(以下是课上的思路)
认为本题是将值放入4个桶中,并且这个桶的容量已知为边长。

然后不断将值存入这4个桶中,如果都能存入(没有溢出)则认为可以拼凑为正方形。

因此向下递归,桶,初始值的集合。并且放入桶中时,尽可能的从大的开始放,因为如果存在某几个短的可以替代大的,那么同一次的效果相同,但是短的可以拆分在用于存放的后期有可能会有更好的表现。

再进一步考虑,大的值应该优先存入剩余容量多的还是少的,不知道,只能枚举。由此获得一个枚举条件,能放就放,如果放了不行就取出来换下一个。

(我认为这种方式比我的思路优秀,我改用课上的解法)
(课上的求解树会出现殊途同归吗?可能出现,假设剩余几个桶容量相同,但是我在其中一个失败了,此时我更换剩余的任何一个导致的最终结果会相同吗?会,因此也可以采用记忆化优化)

此处可用的记忆化维度为:

  • 剩余棒子数
  • 当前桶内的剩余容积

记忆化只需要存储失败情况

(但是此处记忆化的性能会好吗,这里就要考虑一个问题记忆化是否值得,取决于问题求解树的重合率,并实际上相当于性能的用空间换时间)
(这种方式的记忆化优化失败了,因为当前的放置逻辑很难定义状态的重叠)

示例代码

(无记忆化,4ms,35.6MB)

class Solution {
    private boolean dfs(int index,int [] arr,int[] ms){
        if(index <= 0) return true;
        index--;
        for(int k=0;k<4;k++){
            if(arr[k]<ms[index]) continue;
            if(arr[k]==ms[index]||arr[k]>=ms[index]+ms[0]){
                arr[k]-= ms[index];
                if(dfs(index,arr,ms)) return true;
                arr[k]+= ms[index];
            }
        }
        return false;
    }

    public boolean makesquare(int[] matchsticks) {
        int sum = 0;
        for(int l: matchsticks){
            sum += l;
        }

        if(sum%4 !=0) return false;

        int h = sum/4; 
        int arr [] = {h,h,h,h};

        Arrays.sort(matchsticks);

        return dfs(matchsticks.length,arr,matchsticks);

    }
}

LeetCode 39. 组合总和

链接:https://leetcode-cn.com/problems/combination-sum

给定一个无重复元素的数组 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]
]

解题思路

本题可以重复选取,但是最终结果集合中不能有重复的。

另外本题的状态考虑是当前使用的数字,和剩余的和值并且本题需要记录成功时的选取数字。

为了不重复,所以本题可选取的数字应该进行排序,先从高的开始选,不可选取比当前更大的数字。

所以设定深搜的规则,每一轮选择递归candidates的下标,搜索的范围从大的向小,向下递归,此时期望的结果和。递归不需要返回值,只需要成功时将结果存入结果集合中即可.

本题的状态由尝试的数字下标index和剩余求和期望target决定

(int index,int target)

(以此思维构建的问题求解树没有重叠子树因此不可用记忆化)

(代码略,本题比上一题简单)

LeetCode 51. N 皇后 (彩蛋题)

链接:https://leetcode-cn.com/problems/n-queens

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。

示例 1:

在这里插入图片描述

输入:n = 4
输出:[[".Q…","…Q",“Q…”,"…Q."],["…Q.",“Q…”,"…Q",".Q…"]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

解题思路

首先根据示例,可以明确本题是不允许旋转的,但允许对称。

本题是期望所有棋子不共线,那么至少每一行都有一个棋子,并且每一列都有一个棋子。

由此设计问题求解树,这棵树的根为第一行棋子所试图放置的位置(其实是一颗子树,总根为没放置任何棋子的情况)
扩展问题的方式是在下一行寻找可以放置的点。
终止条件为过程中任一棋子没有放置的位置。

由于需要以终止条件进行搜索的判定,所以本题采用深搜会更为简单

进一步思考可以发现,结果集合一定是左右对称或者上下对称的,所以初始点遍历只需要考虑左半边,一旦某种情况完成搜索就可以进行对称化获得另一个结果(只能全部进行左右对称或者全部上下对称,因为如果同时进行上下和左右则实际等效于旋转)

(同样的,斜轴线对称也是可以的,相比起左右或者上下更为简易)

再考虑一重优化,能否将某种过程点集合视为绝对不可用集合,也是可行的,因此又生成了一种优化方式。

由此设计代码:

  • 设置初始遍历条件:进行第一条边的从左到中遍历
  • 设计深搜递归函数,向下已用传递点集合,剩余点数或已放置点数
  • 递归函数中拓展限制为不重线,并且不和绝对失败点集合有包含关系(初期这个步骤考虑省略)
  • 通过搜索条件时,输出当前结果和轴对称结果

(对称的考虑失败了,不知道原因,放弃这个猜想)
(绝对失败点集合想要记录也很麻烦,所以也放弃了)

示例代码

(2ms,39.4MB)

class Solution {
    private void dfs(int num ,Point [] pointSet,ArrayList<List<String>>res){
        num++;
        if(num >= pointSet.length) {
            List<String> nowSet = new ArrayList <String> ();
            for(int k =0;k<num;k++){
                StringBuilder sb = new StringBuilder();
                for(int i = 0;i<num;i++) {
                    if(i == pointSet[k].x)
                        sb.append('Q');
                    else
                        sb.append('.');
                }
                nowSet.add(sb.toString());
            }
            
            res.add(nowSet);
            return;
        }
        int len = pointSet.length;
    
        for(int i=0;i<len ;i++){
            Point wantPut = new Point(i,num);
            if(canSet(num,pointSet,wantPut)){
                dfs(num, pointSet,res);
            }
        }
    }

    private boolean canSet(int num ,Point [] pointSet,Point wantPut){
        for(int k = 0;k<num;k++){
            if(
                pointSet[k].x == wantPut.x||
                pointSet[k].y == wantPut.y||
                pointSet[k].x-wantPut.x == pointSet[k].y-wantPut.y||
                wantPut.x + wantPut.y== pointSet[k].x+ pointSet[k].y
            )
                return false;           
        }


        pointSet [num] = wantPut;
        return true;
    }

    public List<List<String>> solveNQueens(int n) {
        ArrayList<List<String>>res = new ArrayList <List<String>> ();
        if(n ==2||n==3) return res;
        List<String> nowSet = new ArrayList <String> ();
        nowSet.add("Q");
        if(n == 1) {
            res.add(nowSet);
            return res;
        }

        int mid = n/2+n%2-1;
        Point [] pointSet = new Point[n];
        for(int i=0;i<n;i++){
            pointSet [0] = new Point(i,0);
            dfs(0, pointSet,res);
        }

        return res;
    }
}

class Point {
    int x,y;
    public Point(int x0,int y0){
        x= x0;y= y0;
    }
}

结语

学习是讲究化劲的,不可用蛮力。

  • 概念用时0.8H,补完笔记0.2H,共1H
    习题用时,0.5+1+1.5+1.5,4.5H
    补完笔记用时 2.5+0.35+2.3H = 2.5+2.6 = 5.1H

考虑到整理发布本次用时约10H,而课时为4.5H。在3倍耗时内,是一次微弱的进步。(*^▽^*)

之所以能做到这一点,在于之前的练习使得脑部思维活跃能适应视频节奏,不需要太多的中断去补充笔记。

此外我感觉学习要严肃的防止为学习而学习,所以本次我进一步强化了自身思考的部分,少做题,多分析,把力气花在该花的地方。

但是本次也有很多不足,在解题阶段,我的思维存在极大的漏洞,缺乏连贯性,需要大量的调试来帮助我思考,这可能无法适应未来高强度的竞争关系,对此期望接下来的学习能减少错误的代码提交次数。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值