刷题笔记7.回溯
回溯
回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。
回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
·找到一个可能存在的正确的答案
·在尝试了所有可能的分步方法后宣告该问题没有答案
在最坏的情况下,回溯法会导致一次复杂度为指数时间的计算。
一般用于得到所有可行解
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
深度优先搜索 算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会 尽可能深 的搜索树的分支。当结点 v 的所在边都己被探寻过,搜索将 回溯 到发现结点 v 的那条边的起始结点。这一过程一直进行到已发现从源结点可达的所有结点为止。如果还存在未被发现的结点,则选择其中一个作为源结点并重复以上过程,整个进程反复进行直到所有结点都被访问为止。
我刚开始学习「回溯算法」的时候觉得很抽象,一直不能理解为什么递归之后需要做和递归之前相同的逆向操作,在做了很多相关的问题以后,我发现其实「回溯算法」与「 深度优先遍历 」有着千丝万缕的联系。
与动态规划的区别
共同点
用于求解多阶段决策问题。多阶段决策问题即:
- 求解一个问题分为很多步骤(阶段);
- 每一个步骤(阶段)可以有多种选择。
不同点
动态规划只需要求我们评估最优解是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果;
回溯算法可以搜索得到所有的方案(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。
一文秒杀所有排列组合子集问题 - 全排列 - 力扣(LeetCode) (leetcode-cn.com)
从全排列问题开始理解回溯算法
46. 全排列
从全排列问题开始理解回溯算法
我们尝试在纸上写 3 个数字、4 个数字、5 个数字的全排列,相信不难找到这样的方法。以数组 [1, 2, 3] 的全排列为例。
先写以 1开头的全排列,它们是:[1, 2, 3], [1, 3, 2],即 1 + [2, 3] 的全排列(注意:递归结构体现在这里);
再写以 2开头的全排列,它们是:[2, 1, 3], [2, 3, 1],即 2 + [1, 3] 的全排列;
最后写以 3开头的全排列,它们是:[3, 1, 2], [3, 2, 1],即 3 + [1, 2] 的全排列。
说明:
- 每一个结点表示了求解全排列问题的不同的阶段,这些阶段通过变量的「不同的值」体现,这些变量的不同的值,称之为「状态」;
- 使用深度优先遍历有「回头」的过程,在「回头」以后, 状态变量需要设置成为和先前一样 ,因此在回到上一层结点的过程中,需要撤销上一次的选择,这个操作称之为「状态重置」;
- 深度优先遍历,借助系统栈空间,保存所需要的状态变量,在编码中只需要注意遍历到相应的结点的时候,状态变量的值是正确的,具体的做法是:往下走一层的时候,path 变量在尾部追加,而往回走的时候,需要撤销上一次的选择,也是在尾部操作,因此 path 变量是一个栈;
- 深度优先遍历通过「回溯」操作,实现了全局使用一份状态变量的效果。
使用编程的方法得到全排列,就是在这样的一个树形结构中完成 遍历,从树的根结点到叶子结点形成的路径就是其中一个全排列。
设计状态变量
-
首先这棵树除了根结点和叶子结点以外,每一个结点做的事情其实是一样的,即:在已经选择了一些数的前提下,在剩下的还没有选择的数中,依次选择一个数,这显然是一个 递归 结构;
-
递归的终止条件是: 一个排列中的数字已经选够了 ,因此我们需要一个变量来表示当前程序递归到第几层,我们把这个变量叫做 depth,或者命名为 index ,表示当前要确定的是某个全排列中下标为 index 的那个数是多少;
-
布尔数组 used,初始化的时候都为 false 表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应位置设置为 true ,这样在考虑下一个位置的时候,就能够以 O(1) 的时间复杂度判断这个数是否被选择过,这是一种「以空间换时间」的思想。
这些变量称为「状态变量」,它们表示了在求解一个问题的时候所处的阶段。需要根据问题的场景设计合适的状态变量。
代码实现
import java.util.ArrayList;
import java.util.List;
public class Solution {
public List<List<Integer>> permute(int[] nums) {
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> path = new ArrayList<>();
dfs(nums, len, 0, path, used, res);
return res;
}
private void dfs(int[] nums, int len, int depth,
List<Integer> path, boolean[] used,
List<List<Integer>> res) {
/**if (depth == len) {
res.add(path);
return;
在 Java 中,参数传递是值传递,对象类型变量在传参的过程中,复制的是变量的地址。这些地址被添加到 res 变量,但实际上指向的是同一块内存地址,因此我们会看到 6 个空的列表对象。解决的方法很简单,在 res.add(path); 这里做一次拷贝即可
**/
if (depth == len) {
res.add(new ArrayList<>(path));
return;
}
// 在非叶子结点处,产生不同的分支,这一操作的语义是:在还未选择的数中依次选择一个元素作为下一个位置的元素,这显然得通过一个循环实现。
for (int i = 0; i < len; i++) {
if (!used[i]) {
path.add(nums[i]);
used[i] = true;
dfs(nums, len, depth + 1, path, used, res);
// 注意:下面这两行代码发生 「回溯」,回溯发生在从 深层结点 回到 浅层结点 的过程,代码在形式上和递归之前是对称的
used[i] = false;
path.remove(path.size() - 1);
}
}
}
public static void main(String[] args) {
int[] nums = {1, 2, 3};
Solution solution = new Solution();
List<List<Integer>> lists = solution.permute(nums);
System.out.println(lists);
}
}
如果递归条件:
if (depth == len) {
res.add(path);
return;
}
执行 main
方法以后输出如下:
[[], [], [], [], [], []]
变量 path
所指向的列表 在深度优先遍历的过程中只有一份 ,深度优先遍历完成以后,回到了根结点,成为空列表。
在 Java 中,参数传递是 值传递,对象类型变量在传参的过程中,复制的是变量的地址。这些地址被添加到res 变量,但实际上指向的是同一块内存地址,因此我们会看到 6个空的列表对象。解决的方法很简单,在 res.add(path); 这里做一次拷贝即可。
修改的部分:
if (depth == len) {
res.add(new ArrayList<>(path));
return;
}
回溯算法由于其遍历的特点,时间复杂度一般都比较高,有些问题分析起来很复杂。一些回溯算法解决的问题,剪枝剪得好的话,复杂度会降得很低,因此分析最坏时间复杂度的意义也不是很大。但还是视情况而定。
理解回溯
从 [1, 2, 3] 到 [1, 3, 2] ,深度优先遍历是这样做的,从 [1, 2, 3] 回到 [1, 2] 的时候,需要撤销刚刚已经选择的数 3,因为在这一层只有一个数 3 我们已经尝试过了,因此程序回到上一层,需要撤销对 2 的选择,好让后面的程序知道,选择 3 了以后还能够选择 2。
执行深度优先遍历,从较深层的结点返回到较浅层结点的时候,需要做「状态重置」,即「回到过去」、「恢复现场」,我们举一个例子。
几点说明帮助理解「回溯算法」
每一次尝试都「复制」,则不需要回溯
如果在每一个 非叶子结点 分支的尝试,都创建 新的变量 表示状态,那么
- 在回到上一层结点的时候不需要「回溯」;
- 在递归终止的时候也不需要做拷贝。
这样的做法虽然可以得到解,但也会创建很多中间变量,这些中间变量很多时候是我们不需要的,会有一定空间和时间上的消耗。为了验证上面的说明,我们写如下代码进行实验:
import java.util.ArrayList;
import java.util.List;
public class Solution {
public List<List<Integer>> permute(int[] nums) {
// 首先是特判
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> path = new ArrayList<>();
dfs(nums, len, 0, path, used, res);
return res;
}
private void dfs(int[] nums, int len, int depth,
List<Integer> path, boolean[] used,
List<List<Integer>> res) {
if (depth == len) {
// 3、不用拷贝,因为每一层传递下来的 path 变量都是新建的
res.add(path);
return;
}
for (int i = 0; i < len; i++) {
if (!used[i]) {
// 1、每一次尝试都创建新的变量表示当前的"状态"
List<Integer> newPath = new ArrayList<>(path);
newPath.add(nums[i]);
boolean[] newUsed = new boolean[len];
System.arraycopy(used, 0, newUsed, 0, len);
newUsed[i] = true;
dfs(nums, len, depth + 1, newPath, newUsed, res);
// 2、无需回溯
}
}
}
}
这就好比我们在实验室里做「对比实验」,每一个步骤的尝试都要保证使用的材料是一样的。我们有两种办法:
- 每做完一种尝试,都把实验材料恢复成做上一个实验之前的样子,只有这样做出的对比才有意义;
- 每一次尝试都使用同样的新的材料做实验。
list 作为全局变量也可以:
class Solution {
List<List<Integer>> ans = new ArrayList<List<Integer>>();
List<Integer> path = new ArrayList<Integer>();
public List<List<Integer>> permute(int[] nums) {
// if(nums.length ==0) return nums;
boolean[] used = new boolean[nums.length];
dfs(0,nums,used);
return ans;
}
public void dfs(int len,int[] nums,boolean[] used){
if(len==nums.length){
ans.add(new ArrayList<>(path));
return;
}
for(int i=0;i<nums.length;i++){
if(!used[i]){
path.add(nums[i]);
used[i]=true;
dfs(len+1,nums,used);
used[i]=false;
path.remove(path.size()-1);
}
}
}
}
为什么不是广度优先遍历
- 首先是正确性,只有遍历状态空间,才能得到所有符合条件的解,这一点 BFS 和 DFS 其实都可以;
- 在深度优先遍历的时候,不同状态之间的切换很容易 ,可以再看一下上面有很多箭头的那张图,每两个状态之间的差别只有 11 处,因此回退非常方便,这样全局才能使用一份状态变量完成搜索;
- 如果使用广度优先遍历,从浅层转到深层,状态的变化就很大,此时我们不得不在每一个状态都新建变量去保存它,从性能来说是不划算的;
- 如果使用广度优先遍历就得使用队列,然后编写结点类。队列中需要存储每一步的状态信息,需要存储的数据很大,真正能用到的很少 。
- 使用深度优先遍历,直接使用了系统栈,系统栈帮助我们保存了每一个结点的状态信息。我们不用编写结点类,不必手动编写栈完成深度优先遍历
不回溯可不可以
可以。搜索问题的状态空间一般很大,如果每一个状态都去创建新的变量,时间复杂度是 O(N)。在候选数比较多的时候,在非叶子结点上创建新的状态变量的性能消耗就很严重。
就本题而言,只需要叶子结点的那个状态,在叶子结点执行拷贝,时间复杂度是 O(N)。路径变量在深度优先遍历的时候,结点之间的转换只需要 O(1)。
最后,由于回溯算法的时间复杂度很高,因此在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,就可以提前结束,这一步操作称为 剪枝。
剪枝
回溯算法会应用「剪枝」技巧达到以加快搜索速度。有些时候,需要做一些预处理工作(例如排序)才能达到剪枝的目的。预处理工作虽然也消耗时间,但能够剪枝节约的时间更多;
总结
做题的时候,建议 先画树形图 ,画图能帮助我们想清楚递归结构,想清楚如何剪枝。拿题目中的示例,想一想人是怎么做的,一般这样下来,这棵递归树都不难画出。
在画图的过程中思考清楚:
- 分支如何产生;
- 题目需要的解在哪里?是在叶子结点、还是在非叶子结点、还是在从跟结点到叶子结点的路径?
- 哪些搜索会产生不需要的解的?例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?
题型
下面提供一些我做过的「回溯」算法的问题,以便大家学习和理解「回溯」算法。
题型一:排列、组合、子集相关问题
提示:这部分练习可以帮助我们熟悉「回溯算法」的一些概念和通用的解题思路。解题的步骤是:先画图,再编码。去思考可以剪枝的条件, 为什么有的时候用 used 数组,有的时候设置搜索起点 begin 变量,理解状态变量设计的想法。
47. 全排列 II(中等):思考为什么造成了重复,如何在搜索之前就判断这一支会产生重复;
90. 子集 II(中等):剪枝技巧同 47 题、39 题、40 题;
60. 第 k 个排列(中等):利用了剪枝的思想,减去了大量枝叶,直接来到需要的叶子结点;
题型二:Flood Fill
提示:Flood 是「洪水」的意思,Flood Fill 直译是「泛洪填充」的意思,体现了洪水能够从一点开始,迅速填满当前位置附近的地势低的区域。类似的应用还有:PS 软件中的「点一下把这一片区域的颜色都替换掉」,扫雷游戏「点一下打开一大片没有雷的区域」。
下面这几个问题,思想不难,但是初学的时候代码很不容易写对,并且也很难调试。我们的建议是多写几遍,忘记了就再写一次,参考规范的编写实现(设置 visited
数组,设置方向数组,抽取私有方法),把代码写对。
说明:以上问题都不建议修改输入数据,设置 visited 数组是标准的做法。可能会遇到参数很多,是不是都可以写成成员变量的问题,面试中拿不准的记得问一下面试官
题型三:字符串中的回溯问题
提示:字符串的问题的特殊之处在于,字符串的拼接生成新对象,因此在这一类问题上没有显示「回溯」的过程,但是如果使用 StringBuilder
拼接字符串就另当别论。
在这里把它们单独作为一个题型,是希望朋友们能够注意到这个非常细节的地方。
-
22. 括号生成(中等) :这道题广度优先遍历也很好写,可以通过这个问题理解一下为什么回溯算法都是深度优先遍历,并且都用递归来写。
题型四:游戏问题
回溯算法是早期简单的人工智能,有些教程把回溯叫做暴力搜索,但回溯没有那么暴力,回溯是有方向地搜索。「力扣」上有一些简单的游戏类问题,解决它们有一定的难度,大家可以尝试一下。
- 51. N 皇后(困难):其实就是全排列问题,注意设计清楚状态变量,在遍历的时候需要记住一些信息,空间换时间;
- 37. 解数独(困难):思路同「N 皇后问题」;
- 488. 祖玛游戏(困难)
- 529. 扫雷游戏(困难)
回溯习题
50. Pow(x, n)
中等
实现 pow(x, n) ,即计算 x 的 n 次幂函数(即,xn)。
1.递归
当指数 n为负数时,我们可以计算 \(x^{-n}\)再取倒数得到结果,因此我们只需要考虑 n 为自然数的情况。
class Solution {
public double myPow(double x, int n) {
long N=n;
return N>=0?fastpow(x,N):1.0/fastpow(x,-N);
}
double fastpow(double x , long n){
if(n==0) return 1.0;
double y=fastpow(x,n/2);
return n%2!=0?y*y*x:y*y;
}
}
169. 多数元素
给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋ 的元素。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
简单
1.哈希表
class Solution {
public int majorityElement(int[] nums) {
int n=nums.length;
// if(n==1) return nums[0];
HashMap<Integer,Integer> map=new HashMap<Integer,Integer>();
for(int i=0;i<n;i++){
map.put(nums[i],map.getOrDefault(nums[i],0)+1);
if(map.get(nums[i])>(n/2)){
return nums[i];
}
}
return 0;
}
}
2.摩尔投票法思路
候选人(cand_num)
初始化为nums[0]
,票数count
初始化为1。
当遇到与cand_num
相同的数,则票数count = count + 1
,否则票数count = count - 1
。
当票数count
为0时,更换候选人,并将票数count
重置为1。
遍历完数组后,cand_num
即为最终答案。
为何这行得通呢?
投票法是遇到相同的则票数 + 1
,遇到不同的则票数 - 1
。
且“多数元素”的个数> ⌊ n/2 ⌋
,其余元素的个数总和<= ⌊ n/2 ⌋
。
因此“多数元素”的个数 - 其余元素的个数总和
的结果 肯定 >= 1。
这就相当于每个“多数元素”和其他元素 两两相互抵消,抵消到最后肯定还剩余至少1个“多数元素”。
无论数组是1 2 1 2 1,亦或是1 2 2 1 1,总能得到正确的候选人。
class Solution {
public int majorityElement(int[] nums) {
int cand_num = nums[0], count = 1;
for (int i = 1; i < nums.length; ++i) {
if (cand_num == nums[i])
++count;
else if (--count == 0) {
cand_num = nums[i];
count = 1;
}
}
return cand_num;
}
}
时间复杂度:O(n)。Boyer-Moore 算法只对数组进行了一次遍历。
空间复杂度:O(1)。Boyer-Moore 算法只需要常数级别的额外空间。
17. 电话号码的字母组合
中等
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
回溯
回溯过程中维护一个字符串,表示已有的字母排列(如果未遍历完电话号码的所有数字,则已有的字母排列是不完整的)。该字符串初始为空。每次取电话号码的一位数字,从哈希表中获得该数字对应的所有可能的字母,并将其中的一个字母插入到已有的字母排列后面,然后继续处理电话号码的后一位数字,直到处理完电话号码中的所有数字,即得到一个完整的字母排列。然后进行回退操作,遍历其余的字母排列。
回溯算法用于寻找所有的可行解,如果发现一个解不可行,则会舍弃不可行的解。在这道题中,由于每个数字对应的每个字母都可能进入字母组合,因此不存在不可行的解,直接穷举所有的解即可。
class Solution {
List<String> ans=new ArrayList<String>();
StringBuffer an = new StringBuffer();
Map<Character, String> phoneMap = new HashMap<Character, String>() {{
put('2', "abc");
put('3', "def");
put('4', "ghi");
put('5', "jkl");
put('6', "mno");
put('7', "pqrs");
put('8', "tuv");
put('9', "wxyz");
}};
public List<String> letterCombinations(String digits) {
if (digits.length() == 0) {
return ans;
}
recur(0,digits);
return ans;
}
public void recur(int index,String digits){
if(index == digits.length()){
ans.add(an.toString());
return;
}
char c=digits.charAt(index);
String s=phoneMap.get(c);
//遍历当前层的字母
for(int i=0;i<s.length();i++){
an.append(s.charAt(i));
//去下一层递归
recur(index+1,digits);
an.deleteCharAt(an.length()-1);//an.deleteCharAt(index);回溯时删除当前index层an中的值
}
}
}
51. N 皇后
难度困难
class Solution {
List<List<String>> results= new ArrayList<List<String>>();
public List<List<String>> solveNQueens(int n) {
char[][] board=new char[n][n];
for(char[] c:board){
Arrays.fill(c,'.');
}
recur(board,0,n);
return results;
}
public void recur(char[][] board,int row,int n){
if(row==n){
results.add(charToList(board));
return;
}
for(int col=0;col<n;col++){
if(!judge(board,row,col,n)){
continue;
}
board[row][col]='Q';
recur(board,row+1,n);
board[row][col]='.';
}
}
public boolean judge(char[][] board, int row,int col,int n){
for(int i=0;i<n;i++){
if(board[i][col]=='Q'){
return false;
}
}
for(int i=row-1,j=col-1;i>=0&&j>=0;i--,j--){
if(board[i][j]=='Q'){
return false;
}
}
for(int i=row-1,j=col+1;i>=0&&j<n;i--,j++){
if(board[i][j]=='Q'){
return false;
}
}
return true;
}
public List charToList(char[][] board){
int n=board.length;
List<String> list= new ArrayList<>();
// for(int i=0;i<n;i++){
// for(int j=0;j<n;j++){
// list.add(String.valueOf(board[i][j]));
// }
// }
for(char[] c:board){
list.add(String.copyValueOf(c));
}
return list;
}
}
47. 全排列 II
难度中等898收藏分享切换为英文接收动态反馈
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
1.HashSet去重
class Solution {
Set<List<Integer>> ans=new HashSet<List<Integer>>();
List<Integer> path= new ArrayList<Integer>();
public List<List<Integer>> permuteUnique(int[] nums) {
boolean [] used=new boolean[nums.length];
dfs(nums,0,used);
return new ArrayList<List<Integer>>(ans);
}
public void dfs(int[] nums,int index,boolean[] used){
if(index==nums.length){
ans.add(new ArrayList<>(path));
return;
}
for(int i=0;i<nums.length;i++){
if(!used[i]){
path.add(nums[i]);
used[i]=true;
dfs(nums,index+1,used);
path.remove(path.size()-1);
used[i]=false;
}
}
}
}
2、剪枝
剪枝的前提 :数组顺序排列
对比图中标注 ① 和 ② 的地方。相同点是:这一次搜索的起点和上一次搜索的起点一样。不同点是:
标注 ① 的地方上一次搜索的相同的数刚刚被撤销;
标注 ② 的地方上一次搜索的相同的数刚刚被使用。
产生重复结点的地方,正是图中标注了「剪刀」,且被绿色框框住的地方。
大家也可以把第 2 个 1 加上 ' ,即 [1, 1', 2] 去想象这个搜索的过程。只要遇到起点一样,就有可能产生重复。这里还有一个很细节的地方:
在图中 ② 处,搜索的数也和上一次一样,但是上一次的 1 还在使用中;
在图中 ① 处,搜索的数也和上一次一样,但是上一次的 1 刚刚被撤销,正是因为刚被撤销,下面的搜索中还会使用到,因此会产生重复,剪掉的就应该是这样的分支。
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
这段代码就能检测到标注为 ① 的两个结点,跳过它们。注意:这里 used[i - 1] 不加 !,测评也能通过。有兴趣的朋友可以想一想这是为什么。建议大家做这样几个对比实验:
干脆就不写 !used[i - 1] 结果是什么样?
写 used[i - 1] 结果是什么,代码又是怎样执行的。这里给出的结论是:!used[i - 1] 这样的剪枝更彻底。
// used[i - 1] == true,说明同⼀树⽀candidates[i - 1]使⽤过
// used[i - 1] == false,说明同⼀树层candidates[i - 1]使⽤过
// 要对同⼀树层使⽤过的元素进⾏跳过
used[i - 1]:
!used[i - 1]:
因此,used[i - 1] 前面加不加感叹号的区别仅在于保留的是相同元素的顺序索引,还是倒序索引。很明显,顺序索引(即使用 !used[i - 1] 作为剪枝判定条件得到)的递归树剪枝更彻底,思路也相对较自然。
public class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
int len = nums.length;
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
// 排序(升序或者降序都可以),排序是剪枝的前提
Arrays.sort(nums);
boolean[] used = new boolean[len];
// 使用 Deque 是 Java 官方 Stack 类的建议
Deque<Integer> path = new ArrayDeque<>(len);
dfs(nums, len, 0, used, path, res);
return res;
}
private void dfs(int[] nums, int len, int depth, boolean[] used, Deque<Integer> path, List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < len; ++i) {
if (used[i]) {
continue;
}
// 剪枝条件:i > 0 是为了保证 nums[i - 1] 有意义
// 写 !used[i - 1] 是因为 nums[i - 1] 在深度优先遍历的过程中刚刚被撤销选择
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
path.addLast(nums[i]);
used[i] = true;
dfs(nums, len, depth + 1, used, path, res);
// 回溯部分的代码,和 dfs 之前的代码是对称的
used[i] = false;
path.removeLast();
}
}
}
93. 复原 IP 地址
难度中等766收藏分享切换为英文接收动态反馈
有效 IP 地址 正好由四个整数(每个整数位于 0
到 255
之间组成,且不能含有前导 0
),整数之间用 '.'
分隔。
- 例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。
给定一个只包含数字的字符串 s
,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s
中插入 '.'
来形成。你不能重新排序或删除 s
中的任何数字。你可以按 任何 顺序返回答案。
示例 1:
输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]
回溯+剪枝
回溯算法事实上就是在一个树形问题上做深度优先遍历,因此 首先需要把问题转换为树形问题。这里请大家一定要拿起纸和笔,模拟一下如何通过指定的字符串 s 生成 IP 地址的过程,把树形图画出来(这一点很重要)。
split:已经分割出多少个 ip 段;
begin:截取 ip 段的起始位置;
path:记录从根结点到叶子结点的一个路径(回溯算法常规变量,是一个栈);
res:记录结果集的变量,常规变量。
class Solution {
// StringBuffer path = new StringBuffer();
public List<String> restoreIpAddresses(String s) {
int len=s.length();
List<String> res=new ArrayList<>();
List<String> path = new ArrayList<>();
if(len<4||len>12){
return res;
}
dfs(s,len,0,0,path,res);
return res;
}
public void dfs(String s, int len, int split, int begin, List<String> path, List<String> res){
if(begin==len){
if(split==4){
res.add(String.join(".",new ArrayList<> (path)));
}
return;
}
if(len-begin<(4-split)||len-begin>(4-split)*3){
return ;
}
for(int i=0;i<3;i++){
if(i+begin>=len){
break;
}
if(judge(s.substring(begin,begin+i+1))){
path.add(s.substring(begin,begin+i+1));
dfs(s,len,split+1,begin+i+1,path,res);
path.remove(path.size()-1);
}
// int ipSegment = judgeIfIpSegment(s, begin, begin + i);
// if (ipSegment != -1) {
// // 在判断是 ip 段的情况下,才去做截取
// path.add(ipSegment + "");
// dfs(s, len, split + 1, begin + i + 1, path, res);
// path.remove(path.size()-1);
// }
}
}
public boolean judge(String s){
int res=0;
int len=s.length();
if(len>1&&s.charAt(0)=='0'){
return false;
}
for(int i=0;i<len;i++){
res = res * 10 + s.charAt(i) - '0';
}
return res>=0&&res<=255;
}
// private int judgeIfIpSegment(String s, int left, int right) {
// int len = right - left + 1;
// // 大于 1 位的时候,不能以 0 开头
// if (len > 1 && s.charAt(left) == '0') {
// return -1;
// }
// // 转成 int 类型
// int res = 0;
// for (int i = left; i <= right; i++) {
// res = res * 10 + s.charAt(i) - '0';
// }
// if (res > 255) {
// return -1;
// }
// return res;
// }
}