回溯算法
回溯算法的思想很简单,但却应用十分广泛。除了经典的深度优先搜索DFS外,还由很多实际软件开发或数学应用的场景中用到了回溯算法的思想。软件开发中如正则表达式的匹配,编译原理中的语法分析;数学应用中如数独,八皇后问题等等都可以用回溯的思想来解决。回溯算法适合于在一组可能的解中找到满足期望的解,通常适合用递归代码实现。而关于回溯算法本身,简单来说,就是当每次我们遇到选择的时候去尝试每一种情况。如先尝试了情况A,然后将状态重置到未选择的时候,再次尝试情况B。
来看LeetCode的两道习题吧。LeetCode传送门。
46 全排列
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
这道题目是和典型的适合用回溯算法来解决的题目。中学学习排列组合的时候就是这么一个思路,先对1开头的所有结果进行排列,结果包括[1,2,3],[1,3,2]。然后再对2开头的结果进行排列,最后是3的。当对1的所有排列都计算完成时其实就发生了“回溯”,因为我们又需要选择排在第一位的数字了。而此时1是已经选择过的了,所以换个数字开头来继续计算下面的结果。
前面有提到回溯算法十分适合使用递归来实现。关于递归,首先要找到递归终止条件,然后需要找到递推公式。结合这道题目来说,递归终止条件是某次的排列的结果的结合中Count等于3.(即所有的数字都包含在这个排列之中了)。递推公式是
排列结果(包含n个数字,1 <= n <= 数组长度) = 排列结果(包含n - 1个数字) + (除排序结果(n-1)中包含的数字外的任意一个数字)
翻译成代码是:
1 public IList<IList<int>> Permute(int[] nums) 2 { 3 IList<IList<int>> result = new List<IList<int>>(); 4 LinkedList<int> temp = new LinkedList<int>(); 5 6 backtrack(nums, temp, result); 7 8 return result; 9 } 10 11 private void backtrack(int[] nums, LinkedList<int> temp, IList<IList<int>> result) 12 { 13 if (temp.Count == nums.Count()) 14 { 15 result.Add(new List<int>(temp)); 16 return; 17 } 18 19 for (int i = 0; i < nums.Length; i++) 20 { 21 if (temp.Contains(nums[i])) 22 continue; 23 24 temp.AddLast(nums[i]); 25 backtrack(nums, temp, result); 26 temp.RemoveLast(); 27 } 28 }
上述代码中需要注意一下 LinkedList<int> temp。这里使用temp来记录这次寻找的结果中有哪些数字已经加如到排列中了。如果temp中的数字个数已经和给定的数字个数相等,说明达到了递归终止条件。如果temp中包含了某个数字,基于题目要求,那么我们就不应该再把它加入到排列中,因此可以直接跳过。而且要注意,在同一层递归内,当我们尝试了向temp中添加某个值之后,还应该把它从temp中移除掉,即“回溯”。因为同层的多种选择之间不应该相互影响。
47. 全排列 II
给定一个可包含重复数字的序列,返回所有不重复的全排列。
示例:
输入: [1,1,2]
输出:
[
[1,1,2],
[1,2,1],
[2,1,1]
]
这道题与第一题很类似,差别在于这次允许了重复数字。这里需要提到回溯算法中一个很重要的技巧,剪枝。所谓剪枝,指的是直接跳过一些在我们完全得到结果之前就可以判断不符合条件的分支。46题我们可以看到3个不同的数字的所有不同顺序的排列组合共有6种。如果我们按照46题的方式来进行计算本题,会得到重复的结果。那么有没有办法在得到完全的结果前就判断哪些分支会重复呢。其实是有的,我们来看一下code:
1 public class Solution { 2 public IList<IList<int>> PermuteUnique(int[] nums) 3 { 4 IList<IList<int>> result = new List<IList<int>>(); 5 LinkedList<int> temp = new LinkedList<int>(); 6 7 nums = nums.OrderBy(x => x).ToArray(); 8 9 bool[] flag = new bool[nums.Length]; 10 11 dfs2(0, nums.Length, flag.ToList(), nums, temp, result); 12 13 return result; 14 } 15 16 private void dfs2(int depth, int length, List<bool> flag, int[] nums, LinkedList<int> temp, IList<IList<int>> result) 17 { 18 if (depth == length) 19 { 20 result.Add(new List<int>(temp)); 21 return; 22 } 23 24 for (int i = 0; i < nums.Length; i++) 25 { 26 if (flag[i]) 27 continue; 28 29 if (i > 0 && nums[i] == nums[i - 1] && flag[i - 1] == false) 30 { 31 continue; 32 } 33 34 flag[i] = true; 35 temp.AddLast(nums[i]); 36 dfs2(depth + 1, length, flag, nums, temp, result); 37 38 temp.RemoveLast(); 39 flag[i] = false; 40 } 41 } 42 }
相较于上一题,这里有两个改变。首先是对数组进行了排序,然后多出了一个flag数组来标识某个位置上的数字是否已经被使用过了。对数组排序在这接下来的处理种很重要,因为它能保证值相同的数组索引是相邻的。而关于剪枝的部分,笔者感觉不能依靠文字很清楚的描述出来,还请各位看官移步这里,大佬的高赞的解答,传送门。
嗯,题目还是要多练的,稍微改一点感觉差别就很大了。