全排列以及组合问题解析

1 全排列问题

1.1 不重复元素的排列组合

给定一个没有重复数字的序列,返回其所有可能的全排列。

1.1.1 康托展开

首先,康托展开的定义如下:
康托展开是一个全排列到一个自然数的双射,常用于构建哈希表时的空间压缩。 康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,因此是可逆的。
以下称第x个全排列是都是指由小到大的顺序。
康托展开的公式如下:
X = a n ( n 1 ) ! + a n 1 ( n 2 ) ! + + a 1 0 ! {\displaystyle X=a_{n}(n-1)!+a_{n-1}(n-2)!+\cdots +a_{1}\cdot 0!}
其中, a i {\displaystyle a_{i}} 为整数,并且 0 a i < i , 1 i n {\displaystyle 0\leq a_{i}<i,1\leq i\leq n}
a i {\displaystyle a_{i}} 的意义参见举例中的解释部分
举例
例如,3 5 7 4 1 2 9 6 8 展开为 98884。因为X=2 * 8!+3 * 7!+4 * 6!+2 * 5!+0 * 4!+0 * 3!+2 * 2!+0 * 1!+0 * 0!=98884.
解释:
排列的第一位是3,比3小的数有两个,以这样的数开始的排列有8!个,因此第一项为2*8!

排列的第二位是5,比5小的数有1、2、3、4,由于3已经出现,因此共有3个比5小的数,这样的排列有7!个,因此第二项为3*7!

以此类推,直至0*0!

1.1.2 逆康托展开

既然康托展开是一个双射,那么一定可以通过康托展开值求出原排列,即可以求出n的全排列中第x大排列。
如n=5,x=96时:
首先用96-1得到95,说明x之前有95个排列.(将此数本身减去1)
用95去除4! 得到3余23,说明有3个数比第1位小,所以第一位是4.
用23去除3! 得到3余5,说明有3个数比第2位小,所以是4,但是4已出现过,因此是5.
用5去除2!得到2余1,类似地,这一位是3.
用1去除1!得到1余0,这一位是2.
最后一位只能是1.
所以这个数是45321.
按以上方法可以得出通用的算法。

1.1.3 用逆康托展开求解不带重复元素的全排列问题

import math
import copy
class Solution:
    def permute(self, nums: list):
        factorial = {}
        for number in range(len(nums)+1):
            factorial[number] = math.factorial(number)
        for position in range(0,math.factorial(len(nums))):
            start = len(nums) - 1
            residue = position
            combination = []
            copy_nums = copy.deepcopy(nums)
            while start:
                number = residue // factorial[start]
                residue = residue % factorial[start]
                combination.append(copy_nums[number])
                copy_nums.remove(copy_nums[number])
                start -= 1
            combination = combination + copy_nums
            print(combination)

if __name__ == '__main__':
    Solution().permute([1,2,3,4,5,6,7,8,9])

1.1.4 穷举法求全排列问题

除了用康托展开,其实还有一种简单粗暴的方式,那就是使用回溯法,来将所有的情况都列举出来。

class Solution:
    def permute(self, nums: list):
        answer = []
        def backtrack(combination=[]):
            if combination.__len__() == nums.__len__():
                answer.append(combination)
                return
            for num in nums:
                if num not in combination:
                    backtrack(combination + [num])
        backtrack()
        return answer

1.2 带重复元素的全排列问题

如果将问题升级为带重复元素的全排列问题,那么怎么办呢?
首先,先思考一个问题,那就是如果我们带上了重复的元素,相比较于不带重复元素的全排列问题,我们少了一些什么?
假设我们这时候有3个1,2个2,1个3个数列[1,1,1,2,2,3]。
如果说不当这个序列是重复的序列,那么它的全排列组合总数是 A 6 6 {A^6_6} ,但是,其中有一部分是重复的,它的任何一个排列中的1与1交换位置,以及2与2交换位置,对结果都没有影响,因此去掉这些重复后,最终的结果是: A 6 6 A 3 3 A 2 2 {\frac{A^6_6}{A^3_3A^2_2}}

因此,我们可以用回溯+剪枝来解决这个问题,剪枝的条件就是用一个hash表来记录所有的数字以及数字的个数,然后每一层只使用hash_key,使用一个数字后,进入下一层时,在hash表中的对应数字个数减1。

class Solution:    

def permuteUnique(self, nums: list) -> list:
    hash_num = {}
    for item in nums:
        hash_num[item] = hash_num.get(item,0) + 1
    output = []
    def backtrack(combination:list=[]):
        if len(combination) == len(nums):
            output.append(combination)
        else:
            for num_key in list(hash_num.keys()):
                hash_num[num_key] = hash_num.get(num_key,0) - 1
                if hash_num.get(num_key,0) == 0:
                    del hash_num[num_key]
                backtrack(combination + [num_key])
                hash_num[num_key] = hash_num.get(num_key,0) + 1
    backtrack()
    return output

2 第K个排列(逆康托展开的用处)

给定一个序列,求序列的第K个排列
这个问题的比较好的解法就是用我们刚才的康托展开。

import math
class Solution:
    def getPermutation(self, n: int, k: int) -> str:
        nums = [i for i in range(1,n+1)]
        factorial = {}
        for number in range(len(nums)+1):
            factorial[number] = math.factorial(number)
        start = len(nums) - 1
        residue = k-1
        combination = ""
        while start:
            number = residue // factorial[start]
            residue = residue % factorial[start]
            combination += str(nums[number])
            nums.remove(nums[number])
            start -= 1
        combination += str(nums[0])
        return str(combination)

如果用回溯法来解决这个问题,有什么比较好的方式么?
我们可以采用回溯加剪枝的方式,可以理解为DFS形式的逆康托展开。

class Solution:
    def getPermutation(self, n: int, k: int) -> str:
        dit = {0:1 ,1: 1, 2: 1, 3: 2, 4: 6, 5: 24, 6: 120, 7: 720, 8: 5040, 9: 40320}
        number_list = []
        for i in range(n):
            number_list.append(str(i+1))
        answer = []
        def backtrack(combination,n,k):
            if len(combination) == len(number_list):
                answer.append(combination)
            else:
                for number in number_list:
                    if number not in combination:
                        current_possibility = dit[n]
                        if k - current_possibility > 0:
                            k = k - current_possibility
                        else:
                            backtrack(combination + number,n-1,k)
        backtrack("",n,k)
        return answer[0]

3 下一个排列(康托展开与逆康托展开的结合)

给定一个序列中的其中一个排列,求下一个排列。
先用当前的康托展开求序列的位置,在用逆康托展开求出下一个序列。

import math
class Solution:
    def nextPermutation(self, nums: list):
        number_list = [i+1 for i in range(len(nums))]
        next_permute = (self.Cantor(nums) + 1) % math.factorial(len(nums))
        return self.Anti_Cantor(number_list,next_permute)
    @staticmethod
    def Cantor(nums:list):
        factorial = {}
        result = 1
        for number in range(len(nums)+1):
            factorial[number] = math.factorial(number)
        number_list = [i + 1 for i in range(len(nums))]
        for index,number in enumerate(nums):
            result += number_list.index(number)*factorial[len(nums)-index-1]
            number_list.remove(number)
        return result
    @staticmethod
    def Anti_Cantor(nums:list,k:int):
        factorial = {}
        for number in range(len(nums)+1):
            factorial[number] = math.factorial(number)
        start = len(nums) - 1
        residue = k-1
        combination = ""
        while start:
            number = residue // factorial[start]
            residue = residue % factorial[start]
            combination += str(nums[number])
            nums.remove(nums[number])
            start -= 1
        combination += str(nums[0])
        return combination

4 全排列问题的一些变种问题

下面解决了一些全排列问题的变种问题,对排列做了一些限制,其实解法大同小异,大家可以看一下下面的一些问题,感受一下。

4.1 子集

4.1.1 子集I

求一个序列的全部子集(不要求全排列)
序列没有重复的元素。
[1,2,3] -> [],[1],[2],[3].[1,2],[1,3],[2,3],[1,2,3]
思路1:动态规划
从空集开始,长度为1的子集是由空集转换而来的,长度为2的子集是由长度为1的子集转换而来的,但是要保持子集的升序特性。

class Solution:
    def subsets(self, nums: list):
        if not len(nums):
            return [[]]
        dp = {
        	0 : [[]],
        	1 : [[num] for num in nums]
		}
        solution = []
        length_subset = 2
        while length_subset <= len(nums):
            dp[length_subset] = []
            for num in nums:
                for state in dp[length_subset-1]:
                    if state[-1] < num:
                        dp[length_subset].append(state+[num])
            length_subset += 1
        for key,value in dp.items():
            solution += value
        return solution

思路2:DFS
剪枝,子集的条件是满足升序序列,根据这个条件进行剪枝。

class Solution:
    def subsets(self, nums: list) -> list:
        answer = [[]]
        def backtrack(combination,nums):
            if len(nums) != 0:
                for num in nums:
                    if len(combination) != 0:
                        if combination[-1] < num:
                            answer.append(combination + [num])
                            backtrack(combination+[num],nums)
                    else:
                        answer.append([num])
                        backtrack([num],nums)
        backtrack([],nums)
        return answer

思路3:位运算
实际上就是编码的思想,与数组中的值的大小无关。也即是说,给nums数组中每一个元素赋予一个唯一的标记,同时设想让每一个子集中所有单个元素的组合都用其所含的单个元素的编码的组合表示,这样每一个子集也就唯一对应一个编码,所以我们能通过单个元素的编码和每个子集的编码进行比较而轻易地知道这个元素在不在这个子集中。那所以问题地关键就变成了如何给单个元素编码才能达到上述目的。于是,这时候独热码(one-hot-coding,二进制码,其中只有一位是1,其余都是0)就派上用场了。因为我们用连续的独热码依次标记nums数组的每一个元素,而各种元素形成的各类子集,就可以用各元素的编码的与运算得到,即子集获得了一个唯一的标记(例如子集{1,3},其标记码为101,由1和3的编码001和100与运算得到),所以如果某个子集和某个元素各自的编码进行与运算为真,则该元素一定在这个子集中,否则不在。另外巧妙的是刚好每个子集的编码对应的数值刚好可以从0(对应空集)到2^n-1。

class Solution:
    def subsets(self, nums: list):
        size = len(nums)
        n = 1 << size
        res = []
        for i in range(n):
            cur = []
            for j in range(size):
                if i >> j & 1:
                    cur.append(nums[j])
            res.append(cur)
        return res

4.1.2 子集II

序列包含重复的元素。
[1,2,2] - >[2], [1],[1,2,2],[2,2],[1,2],[]
思路:动态规划
不过相比于上一个问题的动态规划,这个问题的动态规划需要用一个hash表来辅助判断,记录当前子串的重复元素个数,当然序列还是要满足升序。

class Solution:
    def subsetsWithDup(self, nums: list):
        if not len(nums):
            return [[]]
        hash_num = {}
        for num in nums:
            hash_num[num] = hash_num.get(num,0) + 1
        dp = {
        	0 : [[]],
        	1 : [[num] for num in list(hash_num.keys())]
		}
        solution = []
        length_subset = 2
        while length_subset <= len(nums):
            dp[length_subset] = []
            for num in hash_num.keys():
                for state in dp[length_subset-1]:
                    if state[-1] < num:
                        dp[length_subset].append(state+[num])
                    elif state[-1] == num:
                        if state.count(num) < hash_num[num]:
                            dp[length_subset].append(state + [num])
            length_subset += 1
        for key,value in dp.items():
            solution += value
        return solution

4.2 组合总和

4.2.1 组合总和I

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。
说明:
所有数字(包括 target)都是正整数。
解集不能包含重复的组合。
思路:dfs

class Solution:
    def combinationSum(self, candidates: list, target: int) -> list:
        candidates = sorted(candidates)
        answer = []
        def backtrack(current_sum:int=0,current_list:list=[]):
            if current_sum == target:
                if sorted(current_list) not in answer:
                    answer.append(current_list)
            else:
                for number in candidates:
                    if len(current_list) != 0:
                        if current_list[-1] <= number:
                            if current_sum + number > target:
                                break
                            else:
                                backtrack(current_sum+number,current_list+[number])
                    else:
                        if current_sum + number > target:
                            break
                        else:
                            backtrack(current_sum+number,current_list+[number])

        backtrack()
        return answer

4.2.2 组合总和II

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
说明:
所有数字(包括目标数)都是正整数。
解集不能包含重复的组合。
思路:dfs

class Solution:
    def combinationSum2(self, candidates: list, target: int) -> list:
        candidates = sorted(candidates)
        answer = []
        def backtrack(current_sum:int=0,candidates:list=candidates,current_list:list=[]):
            if current_sum == target:
                if sorted(current_list) not in answer:
                    answer.append(current_list)
            else:
                for index,number in enumerate(candidates):
                    if len(current_list) != 0:
                        if current_list[-1] <= number:
                            if current_sum + number > target:
                                break
                            else:
                                candidates.pop(index)
                                backtrack(current_sum+number,candidates,current_list+[number])
                                candidates.insert(index,number)
                    else:
                        if current_sum + number > target:
                            break
                        else:
                            candidates.pop(index)
                            backtrack(current_sum + number, candidates, current_list + [number])
                            candidates.insert(index, number)
                        
        backtrack()
        return answer

4.2.3 组合总和III

找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
说明:
所有数字都是正整数。
解集不能包含重复的组合。
思路:dfs

class Solution:
    def combinationSum3(self, k: int, n: int) -> list:
        if n == 0:
            return []
        number_list = [i+1 for i in range(9)]
        output = []
        def backtrack(combination:list,res:int,number_list:list):
            if res == 0 and len(combination) == k:
                output.append(combination)
            else:
                for num in number_list:
                    if res - num >= 0:
                        backtrack(combination+[num],res-num,number_list[number_list.index(num)+1:])
        backtrack([],n,number_list)
        return output

4.2.4 组合总和IV

给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。
示例:
nums = [1, 2, 3]
target = 4
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。
因此输出为 7。
思路1:dfs
这个dfs比较简单,因为数字组合可以被无限的选用了,所以就不在这里展开了。
思路2:动态规划
使用dfs计算这个问题的时候,我们会发现有很多的重复计算,所以现在尝试用动态规划来解决这个问题。

class Solution:
    def combinationSum4(self, nums: list, target: int) -> int:
        dp = [0 for i in range(target+1)]
        dp[0] = 1
        nums = sorted(nums)
        for current_target in range(1,target+1):
            for num in nums:
                if current_target - num >= 0:
                    dp[current_target] += dp[current_target-num]
                else:
                    break
        return dp[-1]

4.2.4 组合总和V

如果给定的数组中含有负数,并且组合中出现的数字只能给调用一次。
我们可以给target添加容忍度,允许当前的值大于target,因为有负数存在的关系,同时我们有要计算全部负数排列组合的和是多少,当我们遇到target + abs(负数组合和里的数)时,将这个组合保留。

4.3 回文排列

4.3.1 回文排列I

给定一个字符串,判断该字符串中是否可以通过重新排列组合,形成一个回文字符串。
思路:统计频次
I.字符串中每个元素出现的次数为偶数时,肯定能通过重新排列组合形成一个回文字符串。
II.字符串中如果有奇数时,只允许出现一个奇数,并且此时的字符串长度要为奇数(将那个奇数的字符放在中间,剩下的字符中心对称即可)。

class Solution:
    def canPermutePalindrome(self, s: str) -> bool:
        hash_char = {}
        for char in s:
            hash_char[char] = hash_char.get(char,0) + 1
        odd = 0
        for key,value in hash_char.items():
            if value % 2 == 1:
                odd += 1
                if odd > 1:
                    return False
        return False if odd == 1 and len(s) % 2 == 0 else True

4.3.2 回文排列II

给定一个字符串 s ,返回其通过重新排列组合后所有可能的回文字符串,并去除重复的组合。
思路:
首先,我们可以先判断一下当前字符串能否通过重新排列组合得到回文字符串,如果可以,根据对称性将字符串划分为2部分(如果为奇数,将奇数字符设为中心),求左半边的全排列对称对过去即可(要注意重复性的问题)。

class Solution:
    def generatePalindromes(self, s: str) -> list:
        if not s:
            return []
        hash_char,hash_char_left,mid_char,ans = {},{},'',[]
        for char in s:
            hash_char[char] = hash_char.get(char, 0) + 1
        odd = 0
        for key, value in hash_char.items():
            if value % 2 == 1:
                odd += 1
                if odd > 1:
                    return []
                mid_char = key
                if value > 1:
                    hash_char_left[key] = (value-1)//2
            else:
                hash_char_left[key] = value // 2
        if odd == 1 and len(s) % 2 == 0:
            return []
        ans = self.permuteUnique(hash_char_left,int(len(s)/2))
        for index,item in enumerate(ans):
            ans[index] = item + mid_char +item[::-1]
        return ans

    def permuteUnique(self, hash_char_left,length) -> list:
        output = []
        def backtrack(combination: str = ""):
            if len(combination) == length:
                output.append(combination)
            else:
                for char in list(hash_char_left.keys()):
                    hash_char_left[char] = hash_char_left.get(char, 0) - 1
                    if hash_char_left.get(char, 0) == 0:
                        del hash_char_left[char]
                    backtrack(combination + char)
                    hash_char_left[char] = hash_char_left.get(char, 0) + 1
        backtrack()
        return output
发布了11 篇原创文章 · 获赞 17 · 访问量 3112

猜你喜欢

转载自blog.csdn.net/weixin_43208423/article/details/101628278