热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

LeetCode31:递归、回溯、八皇后、全排列一篇文章全讲清楚

本文始发于个人公众号:TechFlow,原创不易,求个关注今天我们讲的是LeetCode的31题,这是一道非常经典的问题,经常会在面试当中遇到。在今天的文章当中除了关于题目的分析和

本文始发于个人公众号:TechFlow,原创不易,求个关注


今天我们讲的是LeetCode的31题,这是一道非常经典的问题,经常会在面试当中遇到。在今天的文章当中除了关于题目的分析和解答之外,我们还会详细解读深度优先搜索和回溯算法,感兴趣的同学不容错过。


链接

Next Permutation

难度

Medium


描述


实现C++当中经典的库函数next permutation,即下一个排列。如果把数组当中的元素看成字典序的话,那下一个排列即是字典序比当前增加1的排列。如果已经是字典序最大的情况,则返回字典序最小的情况,即从头开始。

Implement next permutation , which rearranges numbers into the
lexicographically next greater permutation of numbers.

If such arrangement is not possible, it must rearrange it as the lowest
possible order (ie, sorted in ascending order).

The replacement must be in-place and use only constant extra memory.

Here are some examples. Inputs are in the left-hand column and its
corresponding outputs are in the right-hand column.


样例

1,2,3 -> 1,3,2
3,2,1 -> 1,2,3
1,1,5 -> 1,5,1


介绍和分析


如果对C++不够熟悉的同学可能不知道next permutation这个库函数,这个库函数可以生成下一个字典序的排列。

举个例子,比如样例当中1 2 3这三个元素,我们把它所有的排列列举出来,一共是6种:

1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1

这6种排列的字典序依次增加,而next permutation呢则是根据当前的排列返回下一个排列。比如我们输入1 2 3,返回1 3 2,我们输入3 1 2,返回3 2 1。而如果我们输入字典序最大的,也就是3 2 1时,则从头开始,返回字典序最小的1 2 3.


暴力


老规矩,我们第一优先级思考最简单的暴力解法。

最简单粗暴的方法是什么,当然是将所有的排列全部列举出来,然后根据字典顺序排序,最后返回答案。

说起来很简单,但是实现的话一点都不容易。首先,我们怎么获取所有的排列呢?这个问题最简单的方法是通过递归实现深度优先搜索。我单单这么说,对搜索算法不够熟悉的同学可能理解不了。没关系,我们一步一步来推导。我们先把原问题放一放,来看一道经典的搜索问题:八皇后问题。


八皇后问题


八皇后问题是假设我们有一张8*8的国际象棋的棋盘,我们要在棋盘上放置八个皇后,使得这些皇后之间彼此相安无事。

技术图片

众所周知,在国际象棋当中皇后比较牛X,可以横竖以及对角线斜着走。也就是说如果将两个皇后放在同行或者同列以及同对角线,那么就会出现皇后之间互相攻击的情况,也就不满足题目要求了。

这个问题我们显然不考虑无脑的暴力枚举,即使我们知道是8个皇后,如果我们真的用8重循环去查找合法位置的话,那么一定会复杂度爆炸计算到天荒地老的。原因也很简单,我们稍微分析一下复杂度就能知道。对于每重循环,我们都需要遍历64个位置,那么一共的复杂度就是\(64^8\),这是一个非常庞大的数字,几乎是算不完的。而且如果我们变换一下题目,假设我们一开始不知道皇后的数量,变成n皇后问题,那么我们连写几重循环都不知道。

解决这个问题的方法需要我们人为地将问题的规模缩小,其实我们没有必要去遍历所有的位置。因为根据题目条件,皇后不能同行,所以我们每行只能放置一个皇后。而刚好我们是8*8的棋盘,所以我们刚好是每行和每列都只能放置一个皇后。也就是说,我们一行一行地放置皇后。

所以解法就变成了,对于第1行而言,我们有8个位置可以放置皇后。我们随意地选择了一个位置之后,只剩下了7个位置给第二行,然后再放一个,再剩下6个位置给第三行……以此类推,当这样所有皇后放置完了,我们检查完合法性之后,我们接下来需要回到之前的行,选择其他的位置。

举个简单的例子,假设我们一开始的放置方案是[1, 2, 3, 4, 5, 6, 7, 8]。

我们是放置完第8行的棋子之后进入的结束状态,由于第八行只剩下了一个位置,所以没有其他可以选择的情况。在这种情况下,我们应该往上回顾,回到第7行的时候,对于第7行来说可以选择的位置有[7, 8],第一次我们选择了7,那么我们可以试试选择8,这样就可以得到新的答案[1, 2, 3, 4, 5, 6, 8, 7]。

这样又结束了之后,我们再回顾的时候发现第7行的情况也都选择完了,那么要继续往上,回顾第6行的情况。如果我们把搜索树画一部分出来的话是这样:

                                        root
                                       /
                                      1
                                     /
                                    2
                                   /
                                  3
                                 /
                                4
                               /
                              5
                             /                             6   7
                           /                           7   8
                         /                             8       7

你会发现我们一行一行放置的过程在树上对应的是一层一层往下的过程,而我们枚举完了之后回顾之前的行的时候,体现在搜索树上是反向的逆流而上的过程。我们把枚举完了后续回到之前的决策的算法称为“回溯法”,想象一下一棵从源头展开的搜索树,我们顺着它一层一层往下遍历,遍历结束完了之后又逆流而上去修改之前的决策,再拓展新的搜索空间。这也有点像是平行时空,根据平行时空理论,世界上的每一个随机决定都会生成一个平行宇宙,如果你有穿梭时间的能力,那么你就可以回到之前产生平行时空的节点,去遍历其他的平行时空。如果你看过经典的悬疑电影蝴蝶效应的话,那么你一定知道我在说什么。

关于回溯这个概念,我也在之前讲解栈和模拟递归的文章当中提到过,感兴趣的同学可以点击下方的链接查看。

数据结构——30行代码实现栈和模拟递归


模板代码


如果是初学者,即使是理解了递归的概念想要自己写出代码来也是一件不容易的事情。因为在我们看来记录所有节点的状态,遍历,再回到之前的状态,再继续搜索,这是一个非常复杂的过程。

好在我们并不需要自己记录这个过程,因为计算机在执行程序的时候会有一个称为方法栈的东西,记录我们执行的所有函数和方法的状态。比如我们在A程序中执行了B程序的代码,like this:

def A():
  do_something()
  B()
  do_something()

我们在A当中的第二行执行了B这个函数,那么系统会为我们记录下我们在执行B的时候的位置是第二行,当B执行结束,还会回到调用的位置,继续往下执行。而递归呢,则是利用这一特性,让A来执行自己:

def A():
  do_something()
  A()
  do_something()

和刚才一样,系统同样会为我们记录执行A的位置。A执行了A,在我们看来是一样的,但是系统会储存两份方法状态,我们可以简单认为后面执行的A是A1,那么当A1执行结束之后,又会回到A执行A1的位置。而用递归来实现搜索呢,只是在此基础上加上了一点循环而已。

def dfs():
    for choice in all_choices():
        record(choice)
        dfs()
        rollback(choice)

这就是一个简单的搜索的代码框架了,我们遍历每个节点的所有决策,然后记录下这个选择,进行往下递归。当我们执行完再次回到这个位置这一行代码的时候,我们已经遍历完了这个选择所有的情况,所以我们要撤销这个选择带来的一些影响,好方便枚举下一个选择。

但是这样还没结束,还有一点小问题,就是我们只需要放置8个皇后,我们需要针对这点做限制,不然就会无穷无尽递归下去了。做限制的方法也很简单,本质上来说我们是要限制递归的深度,所以我们可以用一个变量来记录递归的深度,每次往下递归的时候就加上1,如果递归深度达到了我们的要求,就不再往下递归了。

加上深度判断之后,我们就得到了经典的回溯的代码框架。几乎可以说无论什么样的回溯搜索问题,都脱离不了这个代码框架,只是具体执行和撤销的逻辑有所不同而已:

def dfs(depth):
    if depth >= 8:
        return
    for choice in all_choices():
        record(choice)
        dfs(depth+1)
        rollback(choice)

我们利用这个代码框架来实现一下八皇后问题,代码很短,也很好理解

def dfs(n, queens, record):
    # 记录答案与控制递归深度
    if n >= 8:
        # 由于Python当中传递的是引用,所以这里需要copy
        # 否则当后面queens pop的时候,会影响record当中记录的值
        record.append(queens.copy())
        return

    for i in range(8):
        # 判断是否同列
        if i in queens:
            continue
        # 判断是否同对角线
        flag = False
        for j in range(len(queens)):
            if abs(n - j) == abs(i - queens[j]):
                flag = True
                break
        if flag:
            continue
        # 递归与回溯
        queens.append(i)
        dfs(n+1, queens, record)
        queens.pop()

如果我们把八皇后问题当中的不能同列同对角线的判断去掉,也就是说皇后不能同行,一行只能放一个皇后,我们把数组当中的元素看成是皇后,那么去掉限制之后的八皇后问题就变成了全排列问题。而这个问题是找出所有的排列当中大于当前这个排列的最小的排列,也就是说我们不需要记录所有的排列结果,只需要记录满足条件的那一个。

那么套用上面的代码框架,这个问题立刻迎刃而解。

def dfs(permutation, array, origin):
    """
    origin记录原排列
    mini  记录答案,即大于原排列的最小排列
    """
    # 由于Python引用机制,在函数内部修改,外部不会生效
    # 所以需要用全局变量来记录答案
    global mini
    # 当所有元素都已经放入permutation
    if len(array) == 0:
        # 判断是否合法,并且是最小的
        if permutation > origin and permutation 

我们之所以费这么大劲绕过去讲解八皇后问题,就是为了让大家理解全排列和回溯算法之间的关系。但是如果你真的这么去解题,你会发现是无法通过的,原因也很简单,因为全排列是一个n!的算法,它的复杂度非常大。对于一个稍微大一点的n,我们就得不到结果。


贪心


费了这么大劲获得了一个不能用的答案,看起来有些可惜,但是其实一点也不浪费。我们是为了学习算法和提升自己来做题的,而不仅仅是为了通过。就好像本文的产生并不仅仅是作为题解产生的,而是希望实实在在地传递一些知识和技能,这也是我的初心。

言归正传,我们不用分析也知道,超时的原因很简单,因为我们做了许多无用功。因为我们不论大小,把所有的全排列都求出来了。那么我们能不能有什么办法不用求出所有的全排列就获得答案呢?

当然是有的,但是要想能够想到这个答案,需要我们对这个问题有更深一点的理解。

我们可以很容易想到,最小的全排列是升序的,最大的是降序的。那么中间大小的呢,大概是什么样子的?显然是有升有降,参差不齐的。我们观察一下相邻排列的规律,看看能发现什么。

举个例子,1 2 3的下一个排列是1 3 2,是交换2和3得到的。我们再来看一个,1 3 2 4的下一个排列是1 3 4 2,也是交换了两个元素得到的。如果你列举更多会发现,不论当前的全排列长什么样,我们都可以交换其中的两个元素得到下一个排列。也就是说我们并不需要遍历所有的全排列,只需要找到两个合适的元素,将它们交换即可。

根据排列顺序递增的条件,我们可以想明白,我们应该找的这两个元素应该是小的在前,大的在后,这样我们将它们交换之后,它们的字典序才会增加。所以到这里,整个问题就变成了我们该怎么样找到这样的元素呢?

我们可以继续想出一些条件,比如我们可以想到我们选择的这两个元素当中较大的那个应该尽量小,其中小的那个要尽量大,并且这两个元素的位置要尽量靠后,不然的话我们一交换元素,可能带来太多的提升。

虽然这些条件看起来比较直观,但其实很有用,我们只需要将这些想法想办法加以量化,就可以得到答案了。在此之前,我们先来观察一下一个可能的通用例子,来寻找一下通用的解法。

技术图片

这是一个典型的排列的展示图,横坐标是元素放置的位置,纵坐标是元素的大小。这个图很经典是在于如果我们抽象一些理解,它可以代表所有的排列。不论什么样的排列我们都可以认为它由三部分组成,第一部分是序列前端的递增的部分,第二个部分是中间参差不齐的部分,和最后递减的部分。对于不同的排列而言,模式都是一样的,差别只在于这三个部分的长度。

我们对着图简单分析一下,可以发现对于后面降序的部分,我们不论怎么交换都不可能提升字典序,只能降低。对于前面的部分,我们交换元素可以提升字典序,但显然不是最优的结果。那么问题就很明显了,我们应该把关注点放在中间参差不齐的部分。根据我们之前的直观结论,我们选择的元素应该尽可能靠后,所以我们的关注点可以进一步地缩小,可以锁定在第二个部分的末尾,也就是图中的a[i-1]的位置。

我们选择a[i-1]的主要原因很简单,因为它比a[i]要小,而从a[i]开始降序。那么剩下的问题就更简单了,我们要做的就是从第三部分选择一个比a[i-1]大的元素和它交换位置。这个元素怎么选呢?我们可以简单地贪心,选择比它大的最小的那个。由于a[i] > a[i-1],所以可以确定,这样的元素一定存在。从图上来看,这个比a[i-1]但又尽量小的元素就是a[j]。

我们把a[j]和a[i-1]交换了之后得到了一个新的排列,并且这个排列比之前的排列大,也就是说我们提升了字典序。但是这还不是最小的情况。原因也很简单,因为最后一部分的元素还是降序的,如果我们把它变成升序,它的字典序还会进一步降低。

我们来看一个例子:[1,8, 9, 4, 10, 6, 3]

根据我们之前的结论,我们会将4和6交换,得到:[1, 8, 9, 6, 10, 4, 3],这个字典序比之前更大,但并不是最小的,我们还可以将最后的10,4,3进行翻转,变成3,4,10。这样最终的答案是:[1, 8, 9, 4, 3, 6, 10]

我们分析一下可以发现,我们交换了两个元素的位置之后,并没有影响数组第三部分的元素是降序的这个特性。所以我们并不需要额外的处理,直接翻转数组就可以。方法也很简单,我们只需要翻转第三部分的数组即可。因为如果分析一下可以发现,我们交换了两个元素的位置之后,并没有影响第三部分的降序。

那这样,我们只需要寻找两个元素,可以在\(O(n)\)的时间内找到答案。比之前的算法不知道提升了多少,这个算法的本质其实就是贪心,但是需要我们对题目有非常深入的理解才可以做出来。

这些都想明白了之后,代码就很简单了,我们来看下:

class Solution:
    def nextPermutation(self, nums: List[int]) -> None:
        """
        Do not return anything, modify nums in-place instead.
        """
        # 长度
        n = len(nums)
        # 记录图中i-1的位置
        pos = n - 1
        for i in range(n-1, 0, -1):
            # 如果降序破坏,说明找到了i
            if nums[i] > nums[i-1]:
                pos = i-1
                break
                
        for i in range(n-1, pos, -1):
            # 从最后开始找大于pos位置的
            if nums[i] > nums[pos]:
                # 先交换元素,在进行翻转
                nums[i], nums[pos] = nums[pos], nums[i]
                # 翻转[pos+1, n]区间
                nums[pos+1:] = nums[n:pos:-1]
                return
        
        # 如果没有return,说明是最大的字典序,那么翻转整个数组
        nums.reverse()

到这里整个问题就结束了,从代码来看这段代码量并不大,但是想要完美写出来,并不容易。尤其是变量之间的关系需要详细地推导,根据我的经验,结合上图来思考会容易理解很多。

今天的文章就是这些,如果觉得有所收获,请顺手扫码点个关注吧,你们的举手之劳对我来说很重要。

技术图片

LeetCode 31:递归、回溯、八皇后、全排列一篇文章全讲清楚


推荐阅读
  • 本文介绍了C#中数据集DataSet对象的使用及相关方法详解,包括DataSet对象的概述、与数据关系对象的互联、Rows集合和Columns集合的组成,以及DataSet对象常用的方法之一——Merge方法的使用。通过本文的阅读,读者可以了解到DataSet对象在C#中的重要性和使用方法。 ... [详细]
  • 本文介绍了使用Java实现大数乘法的分治算法,包括输入数据的处理、普通大数乘法的结果和Karatsuba大数乘法的结果。通过改变long类型可以适应不同范围的大数乘法计算。 ... [详细]
  • 在说Hibernate映射前,我们先来了解下对象关系映射ORM。ORM的实现思想就是将关系数据库中表的数据映射成对象,以对象的形式展现。这样开发人员就可以把对数据库的操作转化为对 ... [详细]
  • 本文内容为asp.net微信公众平台开发的目录汇总,包括数据库设计、多层架构框架搭建和入口实现、微信消息封装及反射赋值、关注事件、用户记录、回复文本消息、图文消息、服务搭建(接入)、自定义菜单等。同时提供了示例代码和相关的后台管理功能。内容涵盖了多个方面,适合综合运用。 ... [详细]
  • 本文介绍了lua语言中闭包的特性及其在模式匹配、日期处理、编译和模块化等方面的应用。lua中的闭包是严格遵循词法定界的第一类值,函数可以作为变量自由传递,也可以作为参数传递给其他函数。这些特性使得lua语言具有极大的灵活性,为程序开发带来了便利。 ... [详细]
  • 基于layUI的图片上传前预览功能的2种实现方式
    本文介绍了基于layUI的图片上传前预览功能的两种实现方式:一种是使用blob+FileReader,另一种是使用layUI自带的参数。通过选择文件后点击文件名,在页面中间弹窗内预览图片。其中,layUI自带的参数实现了图片预览功能。该功能依赖于layUI的上传模块,并使用了blob和FileReader来读取本地文件并获取图像的base64编码。点击文件名时会执行See()函数。摘要长度为169字。 ... [详细]
  • Webpack5内置处理图片资源的配置方法
    本文介绍了在Webpack5中处理图片资源的配置方法。在Webpack4中,我们需要使用file-loader和url-loader来处理图片资源,但是在Webpack5中,这两个Loader的功能已经被内置到Webpack中,我们只需要简单配置即可实现图片资源的处理。本文还介绍了一些常用的配置方法,如匹配不同类型的图片文件、设置输出路径等。通过本文的学习,读者可以快速掌握Webpack5处理图片资源的方法。 ... [详细]
  • HDU 2372 El Dorado(DP)的最长上升子序列长度求解方法
    本文介绍了解决HDU 2372 El Dorado问题的一种动态规划方法,通过循环k的方式求解最长上升子序列的长度。具体实现过程包括初始化dp数组、读取数列、计算最长上升子序列长度等步骤。 ... [详细]
  • 本文讨论了Alink回归预测的不完善问题,指出目前主要针对Python做案例,对其他语言支持不足。同时介绍了pom.xml文件的基本结构和使用方法,以及Maven的相关知识。最后,对Alink回归预测的未来发展提出了期待。 ... [详细]
  • 本文讨论了如何优化解决hdu 1003 java题目的动态规划方法,通过分析加法规则和最大和的性质,提出了一种优化的思路。具体方法是,当从1加到n为负时,即sum(1,n)sum(n,s),可以继续加法计算。同时,还考虑了两种特殊情况:都是负数的情况和有0的情况。最后,通过使用Scanner类来获取输入数据。 ... [详细]
  • 本文介绍了OC学习笔记中的@property和@synthesize,包括属性的定义和合成的使用方法。通过示例代码详细讲解了@property和@synthesize的作用和用法。 ... [详细]
  • Mac OS 升级到11.2.2 Eclipse打不开了,报错Failed to create the Java Virtual Machine
    本文介绍了在Mac OS升级到11.2.2版本后,使用Eclipse打开时出现报错Failed to create the Java Virtual Machine的问题,并提供了解决方法。 ... [详细]
  • 本文介绍了在SpringBoot中集成thymeleaf前端模版的配置步骤,包括在application.properties配置文件中添加thymeleaf的配置信息,引入thymeleaf的jar包,以及创建PageController并添加index方法。 ... [详细]
  • 知识图谱——机器大脑中的知识库
    本文介绍了知识图谱在机器大脑中的应用,以及搜索引擎在知识图谱方面的发展。以谷歌知识图谱为例,说明了知识图谱的智能化特点。通过搜索引擎用户可以获取更加智能化的答案,如搜索关键词"Marie Curie",会得到居里夫人的详细信息以及与之相关的历史人物。知识图谱的出现引起了搜索引擎行业的变革,不仅美国的微软必应,中国的百度、搜狗等搜索引擎公司也纷纷推出了自己的知识图谱。 ... [详细]
  • 本文讲述了作者通过点火测试男友的性格和承受能力,以考验婚姻问题。作者故意不安慰男友并再次点火,观察他的反应。这个行为是善意的玩人,旨在了解男友的性格和避免婚姻问题。 ... [详细]
author-avatar
xzh
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有