leetcode -- 回溯法

回溯法,与递归类似,但又有所区别,回溯法是一种通用的算法思路,也就是一道路走到底,发现走不通了,就需要返回走另一条路,再试试另一条路是否还能走通,当然回溯法是需要返回的,因此还需要做好返回的标记。过程就是一条一条的试探,这种思路放在树中,也就是深度优先搜索,不过回溯法经常会使用递归来实现。这篇文章就整理一下leetcode中做到的四道回溯法的题目。本文所有的代码都放在我的github

思路

回溯法的思路比较选择,可以使用伪代码来表示出来

public void getResult(){
    if(满足条件){
        result.add(路径);
        return ;
    }
    
    for(选择列表){
        做出选择;
        进入下一阶段;
        撤销选择
    }
}

回溯法的不同解法也就是在如何选择标记,下一阶段应该是哪一阶段和如何撤销选择上的差异,我们在做题的时候,往往需要思考的也是这三步的差异。

回溯法多用于解决子集和需要列举全部情况的题目中,在本文中我将之分为:

  1. 顺序回溯,这种情况下多用于解决子集问题。
  2. 全路径回溯,这种情况下多用于解决列举全部组合的问题。

顺序回溯

leetcode, 第78题目,Subsets,

Given an integer array nums of unique elements, return all possible subsets (the power set).

The solution set must not contain duplicate subsets. Return the solution in any order.

Example 1:

Input: nums = [1,2,3]
Output: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

Example 2:

Input: nums = [0]
Output: [[],[0]]

Constraints:

  • 1 <= nums.length <= 10
  • -10 <= nums[i] <= 10
  • All the numbers of nums are unique.

这道题是求子集,可以使用回溯法,思路非常的清晰,题目中所需要的是子集,那么我们只需要遍历所有的情况就可以了,因为这里是按照顺序进行遍历的,因此并不需要判断谁已经被判断了,只需要依次按照顺序进行遍历即可。整张算法的思想就是下图,有点类似于冒泡算法的实现过程了,不过注意的是,这里中间保存结果是list,是引用型数据类型,因此在函数中对之进行的操作会全局影响,算法是需要进行回溯操作,也就是将加入的元素从中间list中删除。

// 回溯法
public List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> final_result = new ArrayList<List<Integer>>();

    List<Integer> temp_list = new ArrayList<Integer>();

    getSubset(final_result,temp_list,0,nums);

    return final_result;

}

private void getSubset(List<List<Integer>> final_result,
                       List<Integer> temp_list,
                       int start,
                       int[] nums) {
    final_result.add(new ArrayList(temp_list));

    for(int i = start; i < nums.length; ++i) {
        // 做出选择
        temp_list.add(nums[i]);
        // 进入下一层
        getSubset(final_result,temp_list,i+1,nums);
        // 撤销
        temp_list.remove(temp_list.size() - 1);
    }

}

当然这道题的话,还可以使用位图法来做,如果我们将选取的数用1标记,不选的数用0标记,那么所有选取的子集都可以用一个数来表示,最低的就是0,表示空集,什么都没选,最高的就是7,表示1,2,3都选择。整体的表示可以看下图

那么我们知道了子集都是可以用一个数表示出来,并且全部数的个数和最大数也能知道,那么只需要进行循环0-7,并且将0-7中每个数拆分成二进制,找出这个数哪些位是1,将位为1的数加入到数组中。

// 位图法,进行迭代输出
public List<List<Integer>> subsets_1(int[] nums) {
    List<List<Integer>> final_result = new ArrayList<List<Integer>>();
    int len = nums.length;

    for(int index = 0; index < (1 << len); ++index) {
        List<Integer> temp_list = new ArrayList<Integer>();
        for(int i = 0; i < nums.length; ++i) {
            if(((index >> i) & 1) == 1) {
                temp_list.add(nums[i]);
            }
        }
        final_result.add(temp_list);
    }

    return final_result;

}

此方法非常的巧妙,使用二进制来求解,不过leetcode中也有很多使用二进制来求解的题目,日后会整理出来。

leetcode,第90题,Subsets II,

Given an integer array nums that may contain duplicates, return all possible subsets (the power set).

The solution set must not contain duplicate subsets. Return the solution in any order.

Example 1:

Input: nums = [1,2,2]
Output: [[],[1],[1,2],[1,2,2],[2],[2,2]]

Example 2:

Input: nums = [0]
Output: [[],[0]]

Constraints:

  • 1 <= nums.length <= 10
  • -10 <= nums[i] <= 10

此题目是上面题目的加强版,给出的nums可能会有重复元素,给出所有的子集,当然解集中不能包含重复的子集,也就是子集中的元素可以是重复的,但是子集不能一样。

咋一看,好似与上面的题解好像并没有太大的区别,既然需要子集不一样,那么我们在加入子集的时候,判断一下总集中是否包含这个子集,如果不包含,那么在总集中加入子集即可,这里的判断可以使用contains。但是这个contains中equals对比的是这个list,也就是说如果遇到了[4,4,4,1,4],那么就出现了[4,4,1]和[4,1,4],还有就是[1,4]和[4,1]这些相同。

那如何去除那些一样,但是contains判断不出来的情况呢?

最简单的就是对列表进行排序,那么每个数字就是有序的了,这样出来的子集顺序都是一样的。

// 要注意特殊情况,也就是[4,4,4,1,4]
public List<List<Integer>> subsetsWithDup(int[] nums) {
    List<List<Integer>> final_result = new ArrayList<List<Integer>>();

    List<Integer> temp_list = new ArrayList<Integer>();

    Arrays.sort(nums);

    getSubset(final_result,temp_list,0,nums);

    return final_result;

}

private void getSubset(List<List<Integer>> final_result,
                       List<Integer> temp_list,
                       int start,
                       int[] nums) {
    if(!final_result.contains(temp_list)) {
        final_result.add(new ArrayList(temp_list));
    }

    for(int i = start; i < nums.length; ++i) {
        // 做出选择
        temp_list.add(nums[i]);
        // 进入下一层
        getSubset(final_result,temp_list,i+1,nums);
        // 撤销
        temp_list.remove(temp_list.size() - 1);
    }

}

还有一种写法,与上面的解题思路是一样,只不过写法不同,我们可以先看看只使用排序不使用contains之后还会出现哪些问题,我们举一个简单的例子,就是[4,1,4]经过排序之后是[1,4,4],使用简单的顺序回溯的话,会出现俩个重复的状况。

出现这俩个情况的原因就是不在起点的前后数据是一样的,比如第一个重复的[1,4],这里的4是索引为3的位置上的4,而索引3上的数据4和索引2上的数据4是一样,因此就会出现重复,后面的[4]和[4]也是一样的,因此这里的contains是可以改成判断前后索引的数据是否一样。

// 回溯法
public List<List<Integer>> subsets_1(int[] nums) {

    List<List<Integer>> final_result = new ArrayList<List<Integer>>();

    List<Integer> temp_list = new ArrayList<Integer>();

    Arrays.sort(nums);

    getSubset_1(final_result,temp_list,0,nums);

    return final_result;
}

private void getSubset_1(List<List<Integer>> final_result,
                       List<Integer> temp_list,
                       int start,
                       int[] nums) {
    final_result.add(new ArrayList(temp_list));

    for(int i = start; i < nums.length; ++i) {
        if(i > start && nums[i] == nums[i - 1]) continue;

        // 做出选择
        temp_list.add(nums[i]);
        // 进入下一层
        getSubset(final_result,temp_list,i+1,nums);
        // 撤销
        temp_list.remove(temp_list.size() - 1);
    }
}

全路径回溯

leetcode,第46题,Permutations,

Given an array nums of distinct integers, return all the possible permutations. You can return the answer in any order.

Example 1:

Input: nums = [1,2,3]
Output: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

Example 2:

Input: nums = [0,1]
Output: [[0,1],[1,0]]

Example 3:

Input: nums = [1]
Output: [[1]]

Constraints:

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • All the integers of nums are unique.

这道题与上面的顺序回溯有所不同了,上面那些只要记录遍历开始点,之后遍历即可,这道题是穷举所有的排列组合情况,最直接的想法就是每个位置上都尝试填入一个数,之后需要判断这个数是否已经使用过,当填入的数达到我们需要的长度的时候,我们就加入到总集中去,其实就是nums中的数字都填入到我们的临时集合中了,我们就将临时集合加到总集中。

当然这个过程是需要标记一下nums中哪些数是已经使用过的,因此与上面传入开始点的参数不同,这次函数需要的参数是标记数组,其他和回溯法大概思路都是一样,就是做出选择,进入下一层,撤销回溯。

// 回溯,利用标记数组
public List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> one = new ArrayList<>();
    boolean[] isVisited = new boolean[nums.length];
    getPermute(result, one, isVisited, nums);
    return result; 
}

public void getPermute(List<List<Integer>> result, List<Integer> one, boolean[] isVisited, int[] nums) {
    if(one.size() == nums.length) {
        result.add(new ArrayList<>(one));
        return ;
    }

    for(int index = 0; index < nums.length; ++index) {
        if(isVisited[index]) continue;
        isVisited[index] = true;
        one.add(nums[index]);
        getPermute(result, one, isVisited, nums);
        isVisited[index] = false;
        one.remove(one.size() - 1);
    }
}

上面是使用了标记数组来完成回溯,那么可不可以不使用标记数组呢?标记数组的存在增加了程序的空间复杂度。

将思路转到顺序回溯中去,顺序回溯中的操作是使用开始点来进行操作的,但是如果我们也和上面一样,那么就会缺一部分数据,这是因为它的开始点标记的是填入数据,如果开始点标记的是要填入的位置的话,那么就不会发生缺失,这个可以看下图

这个树深度为1的节点表示要填入的位置为1。这样思考的话,再加上将要填入数据的索引和填入点的索引进行交换的话,那么就不会发生缺失现象,具体举例:如果目前要填入第一个数,填入的数据就是2,将2放到第一个数的位置上后,将第一个位置上数据1和数据2进行交换,之后进入下一层,下一层就是表示第2个填入点,完成之后就需要进行撤销操作,就是将数据交换回来,变成原来的顺序,所有的数据都可以如此操作。

// 回溯,交换数据
public List<List<Integer>> permute_1(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> one = new ArrayList<>();
    for(int data:nums) {
        one.add(data);
    }
    getPermute_1(result, one, 0, nums);
    return result; 
}

public void getPermute_1(List<List<Integer>> result, List<Integer> one, int start, int[] nums) {
    if(start == nums.length - 1) {
        result.add(new ArrayList<>(one));
        return ;
    }

    for(int index = start; index < nums.length; ++index) {
        // 交换
        Collections.swap(one, start, index);
        // 这里是开始点+1了,表示填入位置
        getPermute_1(result, one, start + 1, nums);
        // 撤销操作就是交换回来
        Collections.swap(one, index, start);
    }
}

leetcode,第47题,Permutations II,

Given a collection of numbers, nums, that might contain duplicates, return all possible unique permutations in any order.

Example 1:

Input: nums = [1,1,2]
Output:
[[1,1,2],
 [1,2,1],
 [2,1,1]]

Example 2:

Input: nums = [1,2,3]
Output: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

Constraints:

  • 1 <= nums.length <= 8
  • -10 <= nums[i] <= 10

此题和上面的拓展题差不多,都是需要没有重复的,这时的解题思路其实也和上面的类似,就是排序,整理好位置,之后判断总集中的元素是否包含。

// 回溯判断
public List<List<Integer>> permuteUnique(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> temp_list = new ArrayList<>();
    for(int one:nums) {
        temp_list.add(one);
    }

    getResult(result,temp_list,0,nums);

    return result;
}

private void getResult(List<List<Integer>> result, List<Integer> temp_list, int start, int[] nums) {
    if(start == nums.length - 1 && !result.contains(temp_list)) {
        result.add(new ArrayList<>(temp_list));
        return ;
    }

    for(int index = start; index < nums.length; ++index) {
        Collections.swap(temp_list, start, index);
        getResult(result,temp_list,start+1,nums);
        Collections.swap(temp_list, index, start);
    }
}

总结

回溯法的问题基本上较为简单,并且回溯的解法也是递归实现,在我遇到的笔试题目中,很少见到要使用到回溯法的,也有可能是我做的不多。本文所有的代码都放在我的github

posted @ 2021-03-29 16:25  长安不乱  阅读(227)  评论(0编辑  收藏  举报