leetcode刷题(javaScript)——回溯、递归、dfs相关场景题总结

回溯算法是对树形或者图形结构执行一次深度优先遍历,实际上类似枚举的搜索尝试过程,在遍历的过程中寻找问题的解。深度优先遍历有个特点:当发现已不满足求解条件时,就返回,尝试别的路径。此时对象类型变量就需要重置成为和之前一样,称为「状态重置」。

许多复杂的,规模较大的问题都可以使用回溯法,有「通用解题方法」的美称。实际上,回溯算法就是暴力搜索算法

当涉及到回溯、递归、深度优先搜索(DFS)相关的场景题时,通常可以将它们放在一起综合考虑,因为它们之间有一定的联系和相互影响。以下是一些相关的LeetCode场景题:

  • 组合、排列问题:组合总和、全排列。通常使用回溯算法来解决组合、排列问题。在回溯过程中,需要注意剪枝优化,避免重复计算。
  • 括号生成:解题技巧:使用递归和回溯算法生成有效的括号组合。在递归的过程中,需要考虑有效性条件,及时剪枝。
  • 岛屿问题:岛屿数量、岛屿的最大面积等。在DFS中,需要遍历地图上的每个点,将连通的岛屿标记并计数。
  • 子集、子集和:使用递归和回溯算法生成所有可能的子集,或者找到满足条件的子集和。在递归过程中,需要考虑剪枝和递归终止条件。
  • N皇后问题:使用回溯算法解决N皇后问题,通过递归尝试每一行的皇后位置,并检查是否与之前的皇后位置冲突。

回溯和递归

递归和回溯是两个相关但不完全相同的概念。但这里我们只要认为函数调用了函数本身,那么就是递归就行了。

1. 递归(Recursion)是指在函数的定义中调用函数自身的过程。递归通常用于解决可以被分解为相同问题的子问题的情况,通过不断调用自身来解决问题。递归函数在每一层调用中都会创建新的函数调用栈,直到达到递归的终止条件。

2. 回溯(Backtracking)是一种通过不断尝试可能的解决方案来解决问题的算法。在回溯算法中,系统尝试每一种可能的解决方案,并在达到某种条件时进行回退(backtrack),尝试其他的解决方案。回溯算法通常与递归结合使用,通过递归调用来实现回溯过程。

函数调用栈

我们思考一个问题,为什么当前的问题解决不了,需要进行递归,然后递归到某个结束点解决了一个场景,之后为什么可以回退并处理上一个未完成的调用?并且回退的时候同个函数为什么还能够记住自己之前的参数和局部变量?

这里需要了解一下函数调用栈

在递归函数中,每次递归调用都会都会创建一个新的函数调用栈,将当前函数的参数、局部变量以及执行位置等信息保存在函数调用栈中,直到达到递归的终止条件。当递归结束后,系统会依次弹出函数调用栈中的函数,恢复之前保存的参数和局部变量,继续执行相应的函数代码。栈有什么特性?后入的先出,记住这个,后面理解递归回退有帮助。

注意:如果函数的参数中包含引用类型的数据,而这些数据是在之前的递归调用中生成的,那么在回退的过程中,当前函数仍然可以继续使用这些引用类型的数据。通过这种方式(入参是引用类型),递归函数可以在每一步的结果基础上继续向下执行,将每一步的结果串起来,最终得到最终的结果。这种递归回退的机制使得递归函数能够有效地处理复杂的问题,并保持数据的连续性和正确性。

回溯问题的解题技巧

1.画决策树

回溯算法通常涉及到对多个选择进行尝试,可以抽象为一个N叉树。树的纵向表示递归,横向表示可以选择的集合。在解决问题时,画决策树是一种非常有用的方法!可以帮助理清思路、找出所有可能的路径,并更好地理解问题的解空间。不要盯着屏幕硬想,小手拿出来,拿个笔,开始画决策树了。

在使用回溯算法解决问题时,可以按照以下步骤画决策树:

  1. 确定问题的决策点:找出每一步需要做出的选择或决策;
  2. 绘制根节点:表示问题的初始状态;
  3. 根据决策点画出分支:每个决策点对应一个分支,表示一个可能的选择;
  4. 沿着每个分支继续画出后续的决策点和分支,直到达到问题的结束状态;
  5. 在决策树上标记出符合条件的解;
  6. 根据决策树找出所有可能的解,或者根据条件剪枝,找出符合条件的解。

在解决复杂问题时,画决策树可以帮助我们更系统地思考和分析问题,提高解题效率。友友们一定要画决策树!!

2. 代码逻辑

回溯法的参数通常比较多,可以先将必要的参数,比如路径path,参与决策的数组nums,结果数组result等写进参数,需要额外的参数在构建代码的时候再动态加入。

通常一个dfs需要类似于一下的结构,了解代码结构有助于增加信心。

注意在递归的时候,除了result存储最终的结果数组,是所有路径都用到的数据。其他的引用类的数据,如数组,对象这些不能改变。要对数组进行浅拷贝!!!或者使用+、concat、slice等不改变原数组的方法进行值传递。千万不要传递引用类型,因为我们的递归是会回退的,你把人家调用栈里还没弹出来执行的数据给改了人家还玩毛啊。 

从目前刷题来看,简单到中等的题目,用javascript实现,dfs递归的逻辑,代码也就十行左右。如果超出了这个代码量,就要考虑是否陷入递归的陷进里去了。不要试图找下一层的解,只考虑当前层的逻辑是否完成。

function dfs(path,nums,...,result){

	if(终止条件如path.length==k等)	{
		//收集结果 
        result.push(path);//类似这种
		return;
	}
    //nums是每层参与决策的数组信息,随着path递归深度的增加nums的信息也不同
    for(let i=0;i<nums;i++)
	{
		//处理结点
		//递归函数;
        dfs(path.concat(nums[i]),nums,..,result);//场景一,result需要的是二维数组
        dfs(path+nums[i],nums,...,result);//场景二,result需要的是字符串数组
}

2.代码里debugger

哪有什么讨巧的技巧啊,看不懂就去代码里打断点,看程序怎么一步步向下递归,满足终止条件后又是怎么回退的。在vscode里新建一个.js文件,点右上角那个小虫子进行debugger。单步调试走一走。加些console.log看下过程输出了什么内容。

上题上题

77. 组合

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

 思路:从[1,n]里取两个数,也就是数组[1,...n]的子集,子集长度为2。先画决策树

我们发现同层节点,可决策数组的长度从左到右逐个减1。如何让可决策的数组减少?

在js中可以通过slice方法复制一个新的数组,观察数组,可以发现越后选择的节点,可决策的数组越短。通过代码了解具体实现。

路径上节点数量为k时可以结束递归return

/**
 * @param {number} n
 * @param {number} k
 * @return {number[][]}
 */
var combine = function (n, k) {
    let result = [];
    let nums = [];
    //转换从组合问题将n变成从1-n的数组
    for (let i = 1; i <= n; i++) {
        nums.push(i);
    }
    dfs([], nums, k, result);
    return result;
};
//递归方法 path当前路径数组,nums可参与决策的数组,k目标长度,result结果数组
function dfs(path, nums, k, result) {
    if (path.length == k) {//递归终止条件
        result.push(path); // 当当前组合长度等于 k 时,将当前组合加入结果集
        return;
    }
    //循环nums.length-1次
    for (let i = 0; i < nums.length; i++) {
        dfs(path.concat(nums[i]), nums.slice(i + 1), k, result); // 递归调用,将当前元素加入组合中
    }
}

 打印递归过程中path值,可以发现,i=0时,1开头走完,接着递归回退到i=1,2开头走完,并且没有重复的

216. 组合总和 III

找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次 

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

 思路:组合问题,从[1,...9]里选k个数,其和等于sum。这里跟子集不同的时,一是限制了子集的长度,即递归的深度是k。而是限制了路径上节点之和为sum。所以要多一个变量存储路径节点sum和。其次,在优化上,可以考虑,到节点的深度没有到k,但是sum已经超过n了,可以提前返回。

/**
 * @param {number} k
 * @param {number} n
 * @return {number[][]}
 */
var combinationSum3 = function (k, n) {
    let nums = [], result = [];
    for (let i = 1; i <= 9; i++) {
        nums.push(i);
    }
    dfs([], 0, nums, n, k, result);
    return result;

};
function dfs(path, sum, nums, n, k, result) {
    //path长度=k一定return
    if (path.length === k) {
        if (sum === n) {//return之前看sum是否=n
            result.push(path);
        }
        return;
    }
    //length<k但是sum已经超过n了也返回
    if (sum > n) return;
    for (let i = 0; i < nums.length; i++) {
        //引用类型除了result外,其余都不会影响原有的值,比如path,sum,nums
        dfs(path.concat(nums[i]), sum + nums[i], nums.slice(i + 1), n, k, result);
    }
}

17. 电话号码的字母组合

 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

 思路:digits存储的是数字,而参与决策的是数字对应的字符串。因此需要进行数字到字符串的映射。一开始我用的是map,然后看评论,我傻了,映射的key是2-9的数字,可以使用数组的下标充当key,数组的内容就是value。

    let strs = ['', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']

ok,分析题意后,开始画决策树,以示例一为例,决策树很好画

但是层与层直接参与决策的内容都是不一样的,这中间靠什么维系呢?

我们可以通过决策树需要的东西反推到已有的信息。诶,发现第一层for循环是abc,它可以通过strs[digits[0]]得到,那么0又是什么东西呢,0是不是当前树的深度啊,也就是路径的长度。为了方便理解,我们定义当前需要遍历的数组arr=strs[digits[path.length]];

 代码如下,

首先:我们只用考虑第一次是怎么for循环的,然后看结果集需要的是路径什么信息。观察题干,要求的是从根到叶子节点路径拼接的字符串。因此递归第一个参数要拼接当前字符。

然后:当前层的for循环的决策数组是谁?由前面思路分析,是对应层高的strs数组里的值。

将第一次需要的信息除了path,原封不动继续传下去。

然后:递归结束的条件是什么?是不是pah.length==digits.length,这点很好理解。

最后:在for循环里,将下一步要用的path信息传下去,第一个参数传path+arr[i],拼接当前的字符。其余信息原封不动传。

注意,每次递归肯定要改变参数中的某个,path肯定要改,其余的参数可改可不改。

/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function (digits) {
    if(!digits.length) return [];
    let strs = ['', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
    let result = [];
    dfs('', digits, strs, result);
    return result;
};
//dfs path决策路径上字符串
function dfs(path, digits, strs, result) {
    if (path.length == digits.length) {//path叶子节点的长度=digits的长度,递归结束
        result.push(path);
        return;
    }
    //分析每次循环的是谁,是不是strs数组啊,递归的深度可以用path.length拿到,通过digits拿到下标,在通过strs获取到第length层的决策数组
    const arr = strs[digits[path.length]];//第一次递归"abc" 第二次递归def
    for (let i = 0; i < arr.length; i++) {//对决策数组依次进行dfs
        dfs(path + arr[i], digits, strs, result);//使用path+arr[i]拼接当前字符,不会影响原来的path
    }

}

39. 组合总和

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 对于给定的输入,保证和为 target 的不同组合数少于 150 个。

思路:如果这题求从数组中任意取数字,凑和为target,问可能的组合个数 的话,这题很适合用动态规划,完全背包问题。

但这里要把每种情况的组合列出来,动态规划就比较麻烦了。而使用回溯的思想,我们画出决策树,收集路径节点组成的数组,是不是得到组合的解了。

老规矩,画决策树

这题参与决策的nums每层都一样,观察看递归结束条件,以及能否剪枝提前结束

我们发现当path路径节点之和>target时,递归可以结束,或者path路径之和=path也可以结束。

这里题目加了限制,路径中不能有重复的组合。

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum = function (candidates, target) {
    let result = [];
    dfs([], 0, candidates, target, result)
    return result;
};
function dfs(path, sum, nums, target, result) {
    if (sum === target) {
        //判断是否和result里重复
        const sortedPath = path.sort((a, b) => a - b);
        for (let arr of result) {
            if (arr.length === path.length && arr.join() === sortedPath.join()) {
                return;
            }
        }
        result.push(sortedPath);
        return;
    }
    if (sum > target) return;//剪枝结束递归,如果sum已经大于target
    for (let i = 0; i < nums.length; i++) {
        dfs(path.concat(nums[i]), sum + nums[i], nums, target, result)
    }
}

 22. 括号生成

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

思路:已知要生成括号对数n,初始为s=""空串,尝试给s右侧加(左括号,得到s=(,这肯定不满足括号对数n。因此,继续向s的右侧加括号,可以加什么呢?

其实每步决策过程就像二叉树一样,你可以给S的右侧加左括号(也可以加右括号)。

是不是每步都可以加左括号或右括号?

其实不是的,左括号有上限,如果s中左括号的数量已经等于n了,不能继续加左括号。

如果左括号满了,继续加右括号,右括号也不是无限加的;因为我们递归是先加左括号,要保证最后的括号合理,右括号的数量不能超过左括号。即())这种是不存在的

结合思路看下下面n=3时的决策或递归过程

 代码如下:在递归过程中,我们会不断尝试添加左括号和右括号,并通过判断左右括号的数量来保证生成的括号组合是有效的。当生成的字符串长度达到 2 * n 时,即可将该组合添加到结果中。

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function (n) {
    let result = [];
    backtrack("", n, 0, 0, result);
    return result;
};
/**
    left 左括号的数量    right 右括号的数量    n括号对数
    path递归过程生成的字符串    result结果数组
 */
function backtrack(path, n, left, right, result) {
    //s是回溯过程中产生的括号字符串
    if (path.length == 2 * n) {//如果s的长度为2n,循环结束
        result.push(path);//加入到结果数组
        return;
    }
    //左括号的数量
    if (left < n) {
        backtrack(path + '(', n, left + 1, right, result);
    }
    //右括号的数量 右括号加入时,右括号的数量必须小于左括号的数量
    if (right < left) {
        backtrack(path + ')', n, left, right + 1, result)
    }
}

 思考:当n=3时,第一个进result是哪个呢?

 观察代码,只要left<n就会先执行backtrack(,,left+1..)是不是,所以第一个结果是先填满左括号,在填满右括号。所以是((()))

再思考,当n=3时,第二个进result的s是谁呢?

这个就考察你对递归的理解了,在第一个递归结束了,此时s=((())),left=3,right=3。我们知道需要将递归调用栈依次弹出,那么究竟会回退到哪一步呢?

看下debugger过程(下面的动图),

可以发现s是从((()))依次去掉右边一个字符进行回退,回退的同时left和right值也在减少。但是单步调试一致在21行运行,且在console.log中并没有打印到s\left\right变换内容,因此,这个只是调用栈自己的回退结果,一个return会结束一个遍历结果。

看下决策树,当前这个结果最近一次二分叉在哪,我们发现其实是s=((。所以我们知道了,这个return 结束了会一脚将调用栈踢回到最近一次二叉决策,也就是s=((。

你可以理解是两个if条件,第一个if(left<n) left=2时这个条件完成了,return了。在这个过程中调用栈里产生的其他s不需要,一步步回退即可。然后执行下一个if条件,if(right<left)  此时left=2,right=0,将右括号加入进行递归即可。

 将每步结果打印出来,可以看到s产生的过程

 

46. 全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

 思路:这题在括号后面做的,简直了,都不用看题解的,在纸上将决策过程写出来一下就知道怎么写dfs了。(忽略我的丑字)

dfs接收三个参数:当前结果curr,剩余可参与决策的数组nums,总的result数组。

递归结束的条件是可参与决策的数组为空,这个路径就结束了,将curr塞到result里。

怎么递归呢。从头开始,是不是从待选择的数组中选一个,这里就按数组中元素排列的顺序选。对示例:[1,2,3]来说,我先选1,那么剩下的是不是从[2,3]中选,那我就对当前dfs(1,[2,3],[])进行递归。等这个递归的最深处合并了一个长度为3的组合return 了,你的for循环下一个就可以进行了。

 这里需要从nums数组中取一个作为当前排列值,并将剩余的数组参与下次递归。要注意不要破坏nums数组本身。将数组通过slice进行浅拷贝得到copyArr,然后通过splice进行删除指定索引元素。注意splice会返回删除元素,不会返回原数组copyArr,但是copyArr已经被修改了,直接传入dfs中即可

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function (nums) {
    let result = [];
    //递归参数,第一个当前的遍历结果,待选择的数组,结果数组
    dfs([], nums, result);
    return result;//result引用类型全程参与递归过程
};
//递归函数
function dfs(path, nums, result) {
    if (nums.length == 0) {//某个决策完成,递归结束条件:当前待选择的数组为空
        result.push(path);//加入结果数组
        return;//结束
    }
    //由于每步可决策个数很多,所以用for循环,挨个进行dfs
    for (let i = 0; i < nums.length; i++) {
        let copyArr = nums.slice();//这里想从待选择的数组中选一个,并将剩余的数组进行下次遍历,但是由于数组是引用类型
        copyArr.splice(i, 1);//不能改变其他递归函数,因此先用slice复制一份,在进行splice删除第i个
        dfs(path.concat(nums[i]), copyArr, result);//将结果数组path右侧拼接当前取的数,并将剩下的数组进行递归
    }
}

78. 子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

思路:老样子,先画决策树。从根到叶子节点的路径中经过的节点都放入结果数组中,这里在决策的时候,控制参与决策的数组,保证不会有重复的。只有这样才不会超时,如果都参与决策,虽然可以在结果的时候咔嚓掉,但是测试用例会超时通不过。所以在参与决策的时候砍掉。怎么砍?

按需遍历,当nums=[1,2,3]

遍历1的时候,参与决策[2,3]

遍历2的时候,参与决策[3]

遍历3的时候,参与决策[]

        当已经遍历1,nums=[2,3]

                遍历2,参与决策[3]

                遍历3,参与决策[]

可以看到在同层的元素,后面参与的决策的个数比前面少一个。

 跟着决策树,看着代码怎么写吧。

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function (nums) {
  let result = [];
  dfs([], nums, result);
  return result;
};
//递归函数 第一个参数当前遍历结果,第二个参数待决策的数组,第三个参数,累计的结果
function dfs(path, nums, result) {
  result.push(path);//每次将当前决策入结果数组
  if (nums.length === 0) {//递归结束条件,最长的一次遍历结束
    return;
  }
  //决策树剪枝,对第i个元素,能够进行下次决策的是nums数组i+1个之后的元素
  for (let i = 0; i < nums.length; i++) {
    dfs(path.concat(nums[i]), nums.slice(i + 1), result);
  }
}

90. 子集 II

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

 思路:这个题和上面78很像是不是,其实就按78正常来写。在入结果时进行重复判断。那么如何在二维数组里判断一维数组是否存在?

没有直接的方法,因为数组时引用类型,存的都是地址,你去哪比较两个数组相等。这里先假设结果数组存的一维数组都是按从小到大的顺序排列的,那么一个有序一维数组和另一个有序数组怎么判断相等?

将两个一维数组转成字符串不就好了,字符串是基本类型啊。所以就有了下面的方法:

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsetsWithDup = function (nums) {
    let result = [];
    dfs([], nums, result);
    return result;
};
//定义dfs数组
function dfs(path, nums, result) {
    //path在加入result之前进行includes判断
    const sortedPath = path.sort((a, b) => a - b);
    //判断sortedPath是否在结果中存在
    for (let arr of result) {
        if (arr.join() === sortedPath.join()) {
            return;
        }
    }
    //上面判断都通过,将有序的结果加入到结果数组
    result.push(sortedPath);
    if (nums.length === 0) return;
    for (let i = 0; i < nums.length; i++) {
        dfs(path.concat(nums[i]), nums.slice(i+1), result);

    }
}

字符串场景

131. 分割回文串

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

思路:首先需要写一个方法判断拆分的子串是否是回文串。其次,需要利用回溯遍历所有可能的结果,在回退的时候根据非回文串进行减枝。回溯是值传递,利用当前的结果多个子问题,所以要用复制的方式传递值。

/**
 * @param {string} s
 * @return {string[][]}
 */
var partition = function (s) {
  let result = []; //存结果数组
  //回溯求所有可能的组合
  function backTrack(res, str) {
    if (res.length && !isPalString(res[res.length - 1])) return;//回溯剪枝,不用处理非回文字符串
    if (str.length == 0) {//遍历结束,res里所有的元素都经过了回文串的验证
      result.push(res);//收集结果
      return;
    }
    //核心方法,保留上步遍历结果,遍历剩余str字符串,枚举所有组合可能性
    for (let i = 0; i < str.length; i++) {
      backTrack(res.concat(str.slice(0, i + 1)), str.slice(i + 1));
    }
  }
  backTrack([], s);
  return result;
};
//递归求回文字符串
function isPalString(str) {
  if (!str.length || str.length == 1) return true;
  if (str[0] != str[str.length - 1]) return false;
  return isPalString(str.slice(1, str.length - 1));
}

 二叉树——路径节点信息

1022. 从根到叶的二进制数之和

给出一棵二叉树,其上每个结点的值都是 0 或 1 。每一条从根到叶的路径都代表一个从最高有效位开始的二进制数。

  • 例如,如果路径为 0 -> 1 -> 1 -> 0 -> 1,那么它表示二进制数 01101,也就是 13 。

对树上的每一片叶子,我们都要找出从根到该叶子的路径所表示的数字。

返回这些数字之和。题目数据保证答案是一个 32 位 整数。

思路:在二叉树里天然适配回溯,这道题难点在于如何求二进制和。其实我们先获取的是二进制的高位,由高位向低位的过程,其实是高位不断*2的过程。只要将当前节点的路径和在往下走的时候*2就能不断计算十进制的结果。

在回溯的时候还是用path表示当前路径。注意回溯的参数path之后还有用,所以使用path<<1的方法不直接改变path,传表达式的结果给回溯的参数。

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {TreeNode} root
 * @return {number}
 */
var sumRootToLeaf = function (root) {//利用二进制左移的特性求和
    let sum = 0;
    //回溯 path当前路径和 当前节点
    function backTracking(path, root) {
        if (!root) {
            return;//递归结束边界
        }
        //path逻辑
        path += root.val;
        if (!root.left && !root.right) {//叶子节点,path为路径二进制总和
            sum += path;
        }
        backTracking(path << 1, root.left);//回溯的时候传path左移后的值,注意不要修改当前path值
        backTracking(path << 1, root.right);//回溯左右子树
    }
    backTracking(0, root);
    return sum;
};

 矩阵相关

在网格这块,我又给单独列一个标题,网格上的dfs不是那么容易理解,你可以看题思考五分钟,但没做过一上来肯定越写越多。在我写了二十分钟发现陷入了递归自证的陷阱了,就决定去看看题解。一看题解才知道哦,原来我定义了一个visited数组是可以在递归回退的时候让已访问的状态改回去的。

我是先刷了BFS算法,所以知道在二维网格中处理决策时要定义四个方向,每个方向都考虑进去。bfs是用队列存储四个方向的结果在同层挨个处理。但是dfs是这样的,它对四个方向都做dfs,但是它先挑一个dfs一条路走到黑,走到可以判断是true还是false了就回退,接着走下一条路。这两个区别还是很明显的。难的在于回退的时候一些数据的恢复。对与引用类型,要考虑不同路径的数据隔离,对于全局的引用类型,如果你想所有路径都独立使用,就像79题visited数组,使用后拿到结果了,在还原它。对于其他的路径来说是无感的。

我相信网格会了一题后面就有思路了。let's go

79. 单词搜索

给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

思路:老样子,我们尝试在纸上画出决策树,下面是示例一寻找字符串ABCCED的决策过程

首先寻找第一个字符A,从board网格中找到,拿到A的坐标x,y。然后对A的四个方向尝试找它的邻居[newX,newY],这个邻居必须满足几个条件:newX和newY的坐标要合理,在board范围内,board[newX][newY[必须等于要找的字符B,并且,[newX][newY]必须没有被访问。这就很严格了,在决策树遍历过程我们可以看到大多数路径都是长度为1,路径结果是false。

那么如何存储遍历结果呢?

这里建一个二维数组,长度和宽度和board数组一致,存储的值为false。在每个路径上将已遍历的节点设为false,在拿到递归结果,结束该条路径时,将节点还原为初始值false。

 代码如下:中等难度的题,代码量不会太多。如果你写的比我还多,要思考一下是否可以继续优化,当然我的也可以继续优化,但是这个结构比较符合逻辑。

首先获取网格的行宽,一方面建立二维visited数组,一方面进行dfs的初始化,这里不需要遍历所有的节点,只用找到word的第一个字符,进行dfs即可。dfs传哪些参数呢?

第一个原始board,board你把它当成常量二维数组,不要改变它,要用它进行下标校验,字符相等校验。第二参数是word这个用来进行递归终止条件,和遍历过程中取下一个寻找的字符进行比较。第三个参数是visited,这个二维数组,初始化为false,在每个路径遍历中,其节点上的值依次赋值为true,但是在路径回退时,节点上的值依次还原为false。第四、五个参数为当前遍历的节点坐标。最后一个参数是寻找到word里的字符索引。

/**
 * @param {character[][]} board
 * @param {string} word
 * @return {boolean}
 */
var exist = function (board, word) {
    const rows = board.length;
    const cols = board[0].length;
    //构建一个节点访问状态数组
    const visited = new Array(rows).fill(0).map(() => new Array(cols).fill(false));
    for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
            if (board[i][j] === word[0] && dfs(board, word, visited, i, j, 0)) {//递归的起点是找到word首字母
                return true;
            }
        }
    }
    return false;//如果首字母都找不到或者找不到所有的节点,则返回false
};
function dfs(board, word, visited, x, y, curIndex) {
    //校验坐标是否超出了合理坐标区间,是否已被当前路径访问,未访问情况board值是否与word相应位置相等
    if (x < 0 || y < 0 || x >= board.length || y >= board[0].length || visited[x][y] || board[x][y] != word[curIndex]) {
        return false;
    }
    //为true时条件curIndex指向了最后一个字符
    if (curIndex === word.length - 1) {
        return true;
    }
    //将当前节点的访问属性设置为true
    visited[x][y] = true;
    //定义右、左、下、上四个方向,每个方向进行dfs
    const directions = [[1, 0], [-1, 0], [0, 1], [0, -1]];
    const result = [];//存储四个方向返回的结果
    for (let [dx, dy] of directions) {
        result.push(dfs(board, word, visited, x + dx, y + dy, curIndex + 1));
    }
    visited[x][y] = false;//确保在回溯时可以重新访问这个位置
    return result.includes(true);//四个方向的dfs只要有一个为true就返回true。
}


 这里使用result数组,存储每个子路径中获取的结果值,只要结果中有一个为true就返回true

200. 岛屿数量

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

思路:通过深度优先搜索的方式遍历二维网格,将相邻的陆地标记为已访问过,以计算岛屿的数量。 利用grid是引用类型,修改后全局共享。

  1. 使用两层循环遍历整个二维网格,对每个位置进行如下操作:
    • 如果当前位置是陆地(值为'1'),则调用dfs函数进行深度优先搜索,并增加岛屿数量count
  2. dfs函数实现了深度优先搜索的逻辑:
    • 首先判断当前坐标是否合法(在网格范围内且值为'1'),若不合法则返回。
    • 将当前位置标记为已访问过的陆地(值改为'2')。
    • 递归地对当前位置的上、下、左、右四个相邻位置进行深度优先搜索,继续标记相邻的陆地。
  3. 最终返回岛屿数量count,即为二维网格中岛屿的数量。
/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = function (grid) {
    let rows = grid.length;
    let cols = grid[0].length;
    let count = 0;
    for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
            if (grid[i][j] == '1') { // 如果当前位置是陆地 '1'
                dfs([i, j], grid, rows, cols); // 进行广度优先搜索
                count++; // 增加岛屿数量
            }
        }
    }
    return count;
};
//dfs实现
function dfs(position, grid, rows, cols) {
    const [x, y] = position;
    if (x < 0 || x >= rows || y < 0 || y >= cols || grid[x][y] != '1')//非法坐标及非1值
        return;
    if (grid[x][y] === '1') {
        grid[x][y] = '2';
    }
    dfs([x + 1, y], grid, rows, cols);
    dfs([x - 1, y], grid, rows, cols);
    dfs([x, y + 1], grid, rows, cols);
    dfs([x, y - 1], grid, rows, cols);
}

  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

三月的一天

你的鼓励将是我前进的动力。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值