zoukankan      html  css  js  c++  java
  • LeetCode

    回溯算法的定义:

    在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的系统搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先的策略进行搜索。回溯法在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。

    解空间树:依据待解决问题的特性,用树结构表示问题的解结构、用叶子表示问题的解的一颗树。

    最初接触回溯算法,应该是走迷宫问题用到DFS。对于一些直观的图论问题,回溯是很符合常识的思路,“此路不通,原路返回,另寻他路”,这是非常朴素的回溯思路,以致于当时根本没有意识到这也算是一种算法思想。

    一个错误的认识是,回溯只能用于图论问题,其背后的根源是缺少将一般的问题抽象出解空间树的能力,以及将一般问题分解为若干状态的能力。

    重新认识回溯思想是遇到全排列问题,这个问题里没有明显的图和树,当时甚至没有往DFS上想,事后看了题解,苦笑、默叹,以为妙绝。

    回溯与剪枝:回溯的本质是枚举和暴力,这意味着他的效率不会高到哪里去,常常需要剪枝来优化。

    全排列

    手写全排列并非难事,对于序列[1, 2, 3],大部分人手动写出的全排列大概都是这样的顺序

    [123, 132, 213, 231, 312, 321]

    其内在的逻辑是逐位固定

    首先若固定第一位为1,这样第二位就只剩两种选择(请强行联想树形结构),即2或3(两种选择,两个子节点)。

    若固定第二位为2,则只能固定第三位为3;-> 123

    此时已经得到一个排列结果(123),此时向上回溯,看看是否还有别的解(全排列要求得出所有解),取消固定第三位,发现没有新的解,继续向上回溯,取消固定第二位,发现当时是有两种选择的(2或3),2已经选过,这次可以选3。

    若固定第二位为3,则只能固定第三位为2;-> 132

    ... 递归以上过程 ...

    难点在于抽象出解空间树

    上面这棵树的所有叶节点即为最终解。

    这道题的题解区liweiwei1419大佬给出的图描述了更清晰的回溯过程:

    此外要注意的是,全排列要求一个数只能用一次,可以设置一个数组来标记对应位置上的数是否已经在当前排列中出现过。

    代码

    class Solution {
        List<List<Integer>> res;
        //标记是否已经用过
        boolean[] vis;
        public List<List<Integer>> permute(int[] nums) {
            res = new ArrayList<>();
            vis = new boolean[nums.length];
            dfs(nums, new ArrayDeque<>(), nums.length);
            return res;
        }
        public void dfs(int[] nums, Deque<Integer> path, int length) {
            //递归出口,排列长度 == 序列长度
            if(0 == length) {
                res.add(new ArrayList<>(path));
                return;
            }
            for(int i = 0; i < nums.length; ++i) {
                if(!vis[i]) {
                    path.add(nums[i]);
                    --length;
                    vis[i] = true;
                    dfs(nums, path, length);
                    //撤销之前的操作,即回溯
                    vis[i] = false;
                    ++length;
                    path.removeLast();
                }
            }
        }
    }

     

    全排列 II

    相对上题,唯一的区别是给出的序列中可能包含重复的数字,这意味着按上题方法得出的结果需要去重。

    见到有用set去重的,但更好的方法应该是剪枝。

    首先考察会出现重复结果的原因。

    以序列[1, 2, 1]为例,按照上题逐位固定的思路,当选择固定第一位时,有1,2,1三种选择,这里就可以看到,第一个1和第三个1是相同的,根据全排列的性质,以这两个1为第一位得到的结果一定会是重复的。

    同理,当第一位固定为2,固定第二位时,剩下1,1两个值相同的选择,其结果也必定是重复的。

    红框为需要剪枝剪掉的部分。

    所以问题变成,有没有办法在固定某一位时直接跳过重复的数?

    参考C++ unique函数去重的思路——将有序数组中重复的元素集中到数组末端然后一并删除。

    这题并非是要把重复元素删除,而是要识别出重复元素后跳过,显然,相对于重复值分布在不同位置的无序数组,重复值集中在一起的有序数组要方便得多。

    只要在循环中加入一个判断即可

    if(i > 0 && nums[i] == nums[i - 1] && vis[i - 1]) {
        break;
    }

    (数组排序当然是直接sort...)

    完整代码

    class Solution {
        List<List<Integer>> res;
        boolean[] vis;
        public List<List<Integer>> permuteUnique(int[] nums) {
            res = new ArrayList<>();
            vis = new boolean[nums.length];
            Arrays.sort(nums);
            dfs(nums, nums.length, new ArrayDeque<Integer>());
            return res;
        }
        public void dfs(int[] nums, int length, Deque<Integer> path) {
            if(path.size() == length) {
                res.add(new ArrayList<>(path));
                return;
            }
            for(int i = 0; i < nums.length; ++i) {
                if(vis[i]) {
                    continue;
                }
                if(i > 0 && nums[i] == nums[i - 1] && vis[i - 1]) {
                    break;
                }
                path.add(nums[i]);
                vis[i] = true;
                dfs(nums, length, path);
                vis[i] = false;
                path.removeLast();
            }
        }
    }

     

    子集

    起初是把不同长度的子集分开求解的,其实并没有这个必要。

    原先的代码

    class Solution {
        List<List<Integer>> res;
        public List<List<Integer>> subsets(int[] nums) {
            res = new ArrayList<>();
            //分别求解0~length长度的子集
            for(int i = 0; i <= nums.length; ++i) {
                dfs(nums, i, 0, new ArrayDeque<>());
            }
            return res;
        }
        public void dfs(int[] nums, int length, int begin, Deque<Integer> path) {
            if(0 == length) {
                res.add(new ArrayList<>(path));
                return;
            }
            for(int i = begin; i < nums.length; ++i) {
                path.add(nums[i]);
                --length;
                dfs(nums, length, i + 1, path);
                ++length;
                path.removeLast();
            }
        }
    }

    在全排列中,递归终止的条件是序列长 == 当前排列的长,而在求子集时,并没有对子集长度的要求,直接添加进结果集即可。

    class Solution {
        List<List<Integer>> res;
        public List<List<Integer>> subsets(int[] nums) {
            res = new ArrayList<>();
            dfs(nums, 0, new ArrayDeque<>());
            return res;
        }
        public void dfs(int[] nums, int begin, Deque<Integer> path) {
            res.add(new ArrayList<>(path));
            for(int i = begin; i < nums.length; ++i) {
                path.add(nums[i]);
                dfs(nums, i + 1, path);
                path.removeLast();
            }
        }
    }

     

     

    子集 II

    借鉴全排列||的剪枝去重法,几乎一样的代码

    class Solution {
        List<List<Integer>> res;
        public List<List<Integer>> subsetsWithDup(int[] nums) {
            res = new ArrayList<>();
            if(0 == nums.length) {
                return res;
            }
            Arrays.sort(nums);
            Deque<Integer> path = new ArrayDeque<>();
            dfs(nums, 0, path);
            return res;
        }
    
        public void dfs(int[] nums, int begin, Deque<Integer> path) {
            res.add(new ArrayList<>(path));
            for(int i = begin; i < nums.length; ++i) {
                if(i > begin && nums[i] == nums[i - 1]) {
                    continue;
                }
                path.add(nums[i]);
                dfs(nums, i + 1, path);
                path.removeLast();
            }
        }
    }

     

    组合

    普通解法很容易得出,和上面的题目相比,只是判断递归终止的条件有所变化而已。

    class Solution {
        List<List<Integer>> res;
        public List<List<Integer>> combine(int n, int k) {
            res = new ArrayList<>();
            if(n < k || k < 0) {
                return res;
            }
            Deque<Integer> path = new ArrayDeque<>();
            dfs(n, k, 1, path);
            return res;
        }
        public void dfs(int n, int k, int begin, Deque<Integer> path) {
            if(path.size() == k) {
                res.add(new ArrayList(path));
                return;
            }
            for(int i = begin; i <= n; ++i) {
                path.add(i);
                dfs(n, k, i + 1, path);
                path.removeLast();
            }
        }
    }

    剪枝

    依然考虑一般手写组合的方法,n = 4(序列为[1, 2, 3, 4]),k = 2时,通常会逐个写出[1, 2], [1, 3], [1, 4], [2, 3], [2, 4]...其实还是逐个固定,组合与全排列的区别仅仅是是否在乎顺序而已。按照这个思路,第一位可以分别固定为1,2,3,这里不会去考虑将第一位固定为4,因为第二位还需要一个数,而4后面已经没有可以用来加入组合的数了,此时将4加入path是无意义的。这类情况可以被简单概括为,当按照逐位固定,逐层回溯的思路求解组合时,当需要的组合的上界超过序列的上界,后面的搜索将变成冗余,此时就需要剪枝。

    根据上述的推理,即当 搜索起点 - 1 + 组合长度 > 序列上界 (①)时,搜索可以停止。(-1是因为起点上也有一个数)

    考察搜索过程中几个变量的意义,i表示搜索在序列中的进度,path表示已经固定的部分组合,k表示所需的组合长度,n表示序列上界。

    不难得出下式:

    搜索起点 = i - path.size()

    代入不等式①可得停止搜索的条件为:

    i - path.size() -1 + k > n

    即i > n - k + path.size() + 1

    所以,只需将搜索停止的条件改为 i <= n - k + path.size() + 1

    class Solution {
        List<List<Integer>> res;
        public List<List<Integer>> combine(int n, int k) {
            res = new ArrayList<>();
            if(n < k || k < 0) {
                return res;
            }
            Deque<Integer> path = new ArrayDeque<>();
            dfs(n, k, 1, path);
            return res;
        }
        public void dfs(int n, int k, int begin, Deque<Integer> path) {
            if(path.size() == k) {
                res.add(new ArrayList(path));
                return;
            }
            //剪枝
            for(int i = begin;  i <= n - k + path.size() + 1; ++i) {
                path.add(i);
                dfs(n, k, i + 1, path);
                path.removeLast();
            }
        }
    }

     

    组合总和

    思路很简单,和上题类似,剪枝也很容易想到。

    class Solution {
        List<List<Integer>> res;
        int[] nums;
        public List<List<Integer>> combinationSum(int[] candidates, int target) {
            res = new ArrayList<>();
            nums = candidates;
            dfs(0, target, new ArrayDeque<Integer>());
            return res;
        }
    
        public void dfs(int begin, int target, Deque<Integer> path) {
            //剪枝
            if(0 > target) {
                return;
            }
            if(0 == target) {
                res.add(new ArrayList(path));
                return;
            }
            for(int i = begin; i < nums.length; ++i) {
                path.add(nums[i]);
                target -= nums[i];
                dfs(i, target, path);
                target += nums[i];
                path.removeLast();
            }
        }
    }

     

    组合总和 II

    没有什么新意的剪枝去重。

    此外,相比为了去重的剪枝,在组合之和已经大于目标值时立即停止搜索是更有效的剪枝。

    如果只有剪枝去重会T。

    class Solution {
        List<List<Integer>> res;
        public List<List<Integer>> combinationSum2(int[] candidates, int target) {
            res = new ArrayList<>();
            Arrays.sort(candidates);
            dfs(candidates, 0, target, new ArrayDeque<Integer>());
            return res;
        }
        public void dfs(int[] candidates, int begin, int target, Deque<Integer> path) {
            if(0 == target) {
                res.add(new ArrayList<>(path));
                return;
            }
            for(int i = begin; i < candidates.length; ++i) {
                //组合总和已经 > target,剪枝
                if(0 > target - candidates[i]) {
                    break;
                }
                //剪枝去重
                if(i > begin && candidates[i] == candidates[i - 1]) {
                    continue;
                }
                path.add(candidates[i]);
                target -= candidates[i];
                dfs(candidates, i + 1, target, path);
                target += candidates[i];
                path.removeLast();
            }
        }
    }

     

    组合总和 III

    组合1和组合2的综合,其实到这里已经索然无味了...

    class Solution {
        List<List<Integer>> res;
        public List<List<Integer>> combinationSum3(int k, int n) {
            res = new ArrayList<>();
            if(n > 9 * k || n < k) {
                return res;
            }
            dfs(k, n, 1, new ArrayDeque<Integer>());
            return res;
        }
        public void dfs(int k, int n, int begin, Deque<Integer> path) {
            if(0 == n && path.size() == k) {
                res.add(new ArrayList<>(path));
                return;
            }
            for(int i = begin; i < 10; ++i) {
                path.add(i);
                n -= i;
                dfs(k, n, i + 1, path);
                n += i;
                path.removeLast();
            }
        }
    }

    组合总和 Ⅳ简单回溯会超时,按下不表。

     

    参考资料:liweiwei1419的题解

  • 相关阅读:
    《敏捷开发修炼之道》学习笔记3:交付用户想要的软件
    Photoshop快捷键大集合
    如何制作已编译的HTML帮助文件(即CHM帮助文件)
    根本不存在 DIV + CSS 布局这回事
    可将视频转换成Gif动画的相关软件
    SEO是什么?与spam有什么区别呢?
    视频六大编辑软件大比拼
    陈彤:一个网络编辑的11年
    最近出现的p2psvr.exe恶意程序的解决办法
    使用火狐浏览器Firefox的一些小技巧
  • 原文地址:https://www.cnblogs.com/CofJus/p/13661488.html
Copyright © 2011-2022 走看看