算法修炼之路(二) —— 递归

当你的女朋友(就假装你有吧)想让你带她吃东西时:   

——“亲爱的,人家肚子饿了….”

——“肚子饿啦,那我带你去吃东西吧,你想吃什么呀?“

——“随便。“

——“那去吃火锅吧!“

——“火锅太热气,吃多了会起痘痘。”

——“那去吃披萨吧!“

——“吃披萨容易发胖,最近正在减肥。“

——“要不吃炸鸡? “

——“炸鸡太油了,容易腻“

——“那吃海鲜?“

——“我最近刚来那个,你还让我吃海鲜?“

——“……“

算法修炼之路(二) —— 递归_第1张图片

几个回合下来,你的对象开始抱怨了,于是你们的对话变成这样:

——“你根本就不懂我。“

——“你不说清楚我怎么懂你啊!“

——“真正的懂不需要说。“

——“你说了我不就懂了吗?“

——“说出来有什么意思?我不说你也应该懂!“

——“我又不是算命的我知道你几个意思啊?“

——“我没什么意思。“

——“确实没意思。“

——“对,现在我说话你觉得没意思了是吗?“

——“是我说的没意思吗?“

——“那我说没意思就是没意思吗?“

——“那你倒是告诉我你到底几个意思啊?“

——“我说我肚子饿了!“

——“那你想吃什么?“

——“随便。“

——“……“              。

 

算法修炼之路(二) —— 递归_第2张图片

 

      在对话过程中,男方首先询问女方想吃什么,女方一开始回答的随便使双方陷入第一次争执,迫使男方继续了解女方到底想吃什么。女方第二次回答的随便又会继续使双方陷入争执,迫使男方再继续了解女方想吃什么 …. 这种对同类进程的反复调用,在计算机中有个专业术语,叫做——递归。递归是有终止条件的,而猜你对象的心思是永远没有结果的,文章最后会教你怎样防止在与女朋友的对话过程中猝死,在这之前就跟着我来学习今天关于递归的算法吧。

算法修炼之路(二) —— 递归_第3张图片

 

      递归,是一种程序有限次调用自身的方法。递归运算有许多好处,它能帮我们把一个大问题一层一层的转化为若干个小问题,使我们的代码更精简、可读性更高。

      递归过程中每一次函数调用,都需要在栈内存上分配空间,以保存当前函数参数、临时变量及函数地址,在堆内存中分配函数地址指向的函数内存空间。虽然递归过程造成的堆栈消耗会在当前递归结束后根据垃圾回收机制自动回收,但程序的堆栈运行空间是有限的,一次性递归太深而占用过多的堆栈内存,程序很容易发生堆栈溢出而无法正常运行的情况。因此,递归是一种比较耗时间和空间的运算方法。

      相比较而言,迭代(循环)虽然不如递归代码简洁,但迭代是利用某个变量的原值不断更新为新的值,没有额外的空间开销,其时间消耗只与循环次数有关,因此迭代的运行效率更高。

      递归和迭代密切相关,很多时候程序可以使用递归或迭代返回相同的结果。但有些情况,递归才能更好的解决我们的问题。接下来,我们一起来看看递归的各种使用场景。

1.实现阶乘。
      自然数n的阶乘写作n!,任何大于等于1 的自然数n 的阶乘表示方法为:n!= 1×2×3×…× (n-1) × n,我们可以用递归轻松实现阶乘计算。

算法修炼之路(二) —— 递归_第4张图片

      这段代码在运行的时候,首先会开辟一段空间,调用factorial(5)函数,而5大于1,则继续开辟一段空间调用factorial(4)函数,4大于1,进而再开辟空间,依次调用factorial(3)、factorial(2)、factorial(1)函数。

算法修炼之路(二) —— 递归_第5张图片

      当调用到factorial(1)时,由于n等于1,factorial(1)函数内不再继续递归调用,而是return 1,因此,递归调用到此便终止了。接着,之前递归调用的函数,会一层层顺着往上返回值。

算法修炼之路(二) —— 递归_第6张图片

      返回到最后,输出的便是120。

      当然,这里我们也可以用迭代的方法,实现同样的功能。

算法修炼之路(二) —— 递归_第7张图片

      在这种情况下,递归和迭代的时间复杂度都为O(n)。 但递归由于每一次调用函数都要开辟一段内存空间,所以它的空间复杂度为O(n)。而迭代每次都是在同一个num值上进行数值更新,并没有开辟更多的内存空间,所以此处迭代的空间复杂度为O(1)。显然这种情况下使用迭代比使用递归更合适些。

2.求斐波那契数列第n个数字的值。

      斐波那契数列,又称黄金分割数列,指的是这样一个数列:1、1、2、3、5、8、13、21、34、…… 。该数列从第三项开始,每一项都等于前两项之和。要求出该数列某一位置的值,我们可以用递归来实现。

 

算法修炼之路(二) —— 递归_第8张图片

     

      我们以求第五项为例,但n等于5时,该函数进行了这样的调用:

算法修炼之路(二) —— 递归_第9张图片

      该递归过程刚好满足于一颗二叉树的排列方式,对于一颗二叉树来说,它的第i层最多可以有2^i − 1个节点,深度为k的二叉树最多可以有2^k − 1个节点。

算法修炼之路(二) —— 递归_第10张图片

      我们忽略掉底下缺失的常数量节点(因为不管是多少层,最底下缺失的常数量节点都是一样的,在求算法时间复杂度的过程中可以将这些常数影响量忽略),我们可以得出该递归算法的时间复杂度为O(2^n –1) = O(2^n )。显然,使用递归计算斐波那契数列的时间复杂度是相当高,假设我们要求第40项数字的值,该算法需要计算1099511627776次函数再依次返回它们的计算结果。

      我们试试看用迭代的方式计算斐波那契数列:

 

算法修炼之路(二) —— 递归_第11张图片

 

      该算法我们先将a和b初始化为数列第一项和第二项的值(都为1),然后每次循环让a等于它的下一项b,让b保存原来的a+b之和,最后将a返回,就得到了数列某一位上的值。该算法的时间复杂度为O(n),空间复杂度为O(1),相比第一种要好很多。

      我们还有一种方式可以计算斐波那契数列,即结合循环和动态数组的方式,这种方式的原理其实和第二种差不多,虽然空间复杂度达到了O(n),却可以把某一位前面所有计算过的项都保存到一个数组中,如果需要得出数列中前n项的值,就可以直接将整个数组返回。

 

算法修炼之路(二) —— 递归_第12张图片

 

(在强类型语言中,静态数组保存在栈内存中,创建静态数组会在栈上给该数组分配大小固定的空间,并且在运行时这个大小不能改变。动态数组保存在堆内存中,在栈内存中保存指向堆内存的指针。当需要给动态数组动态添加元素时,系统首先会在堆内存中开辟一段新的空间,大小为动态添加元素后所需要的数组的大小。接着先将原来堆内存中的数组元素拷贝到新的堆内存中,然后在新的堆内存空间中添加剩余所指定的元素,再把栈内存中保存的指针指向新的堆内存空间,原来的堆内存空间就等待垃圾回收机制清除。在这里由于javaStript是一门弱类型语言,它的数组长度本身就是不固定的,不需要运用其它语法来定义动态数组)

3.尺子刻度算法:给出一个中间数,根据中间的数字打印出如下格式的数字串:

1

1  2  1

1  2  1  3  1  2  1

1  2  1  3  1  2  1  4  1  2  1  3  1  2  1

……    

     

      这种格式的数字串,每一段中间的数左右两边的数字,都是上一列的运算结果,我们可以根据递归来实现这样的算法:

 

算法修炼之路(二) —— 递归_第13张图片

 

      这种方式跟我们之前就斐波那契数列类似,在递归调用过程中都形成了二叉树,因此这种算法的时间复杂度也为O(2^n)。即然左右两边求的都是同一组数,那我们是否可以将其进一步优化呢?

      答案是肯定的,即然中间数左右两边的数字串都是一样的,我们可以提前将一边的字符串保存下来,然后在最后直接在左右两边拼接所保存的字符串,这样就只是对单边进行递归计算而不是两边重复递归计算,时间复杂度也由原来的O(2^n)降为O(n)

 

算法修炼之路(二) —— 递归_第14张图片

 

      当然,我们也可以直接用迭代的方式,它的时间复杂度也为O(n):

算法修炼之路(二) —— 递归_第15张图片

      之所以说这种算法是尺子刻度算法,是因为我们可以根据它来模拟出尺子刻度,如:

中间刻度为1-2-1的尺子:

中间刻度为1-2-1-3-1-2-1的尺子:

      如果根据需要循环复制中间刻度线再在左右两边加上数字,就能形成一把尺子了:

中间刻度为1-2-1的尺子:

算法修炼之路(二) —— 递归_第16张图片

4.求最短数学表达式:输入a,b两个数,b大于a, a只能通过×2或者+1等于b,并且a在式中只能出现一次(排除a刚好是1或者2的情况),求出a运算后等于b的最短表达式。

      想象一下,这道题如果求2与5的数学表达式,可以有两种写法:5=2×2+1和5=2+1+1+1,率先用乘法肯定比加法更短。因此,这道题,我们可以首先根据b和2a的关系进行判断:当b>=2a时,我们给表达式后面添上×2,然后让乘2后的a与b再进行比较;当b<2a时,我们给表达式后面添上+1,然后让加1后的a后再与b进行比较;到最后a通过乘2或加1与b相等时,将a返回结束递归。如果直接按照这种思路,我们可能会写出这样的算法:

算法修炼之路(二) —— 递归_第17张图片

     

      表面上看,我们这个算法似乎没有什么问题,求出来的结果也符合预期,那如果我们是求5与12的表达式呢?

算法修炼之路(二) —— 递归_第18张图片

 

      问题很快就显露出来了,这个算法求出来的结果((a+1)+1)* 2 ,代入a=5再求一遍,得出来的结果是14而不是12,答案显然是错误的。这究竟是为什么呢?因为这道题我们运用到了递归,递归的结果是依次返回的。一开始intSeq(5,12)返回intSeq(10,12)* 2 ,intSeq(10,12)又返回(intSeq(11,12)+1),intSeq(11,12)又返回(intSeq(12,12)+1),intSeq(12,12)碰到a=b的情况,返回a停止递归,然后返回的结果依次拼凑起来,从a到(a+1)到 ((a+1)+1)再到((a+1)+1)*2。

      我们这种算法是对a操作来进行递归的,我们换种方法,对b操作进行递归来对比一下:

算法修炼之路(二) —— 递归_第19张图片

 

      在输出的结果中我们惊讶的发现,表达式的答案是正确的。为什么对a进行放大操作结果会溢出,对b进行缩小操作结果就刚刚好呢?我们再对这种算法进行递归解析:一开始intSeq(5,12)返回intSeq(5,6)* 2,intSeq(5,6)又返回 ( intSeq(5,5)+1),intSeq(5,5)碰到a=b的情况停止递归返回a,返回的结果依次拼凑,从a到(a+1)再到(a+1)*2,刚好是我们想要的结果。

      之所以是这样的结果,是因为表达式最右边的部分从一开始就是写好的,而我们递归到最深处的表达式是写在左边的。对于求5与12的最短表达式,*2是最开始就写好的返回值,只不过得等到最后再拼凑。接下来左边求出来的表达式要记住最后还会乘以2,所以左边表达式的计算结果就必须基于等于b/2的情况。

      我们对b操作,把b除以2,让a继续与b/2进行递归运算,则接下来表达式左边的整体运算结果就绝对不会超出b/2的界限。如果我们是对a进行操作让a*2与b进行递归运算求左边的表达式,递归会接着让a+1与b进行递归运算求再左边的表达式,表达式a*2或接下来的a+1就始终是基于改变过后的a与最终值b进行比较,它的运算结果不会将上一步的运算结果作用前提。因此到最后运算到a=b之前拼凑出来的表达式整体上可能已经超出了b的界限。

      那算法写到这里就天衣无缝了吗?当然不是,如图所示:

算法修炼之路(二) —— 递归_第20张图片

     

      当我们对大于2a的奇数b进行运算,程序都会报出一个栈溢出错误。我们拿5和11举例,第一次进行intSeq(5,11)会继续对intSeq(5,5.5)进行递归,intSeq(5,5.5)继续对intSeq(5,4.5)进行递归,接下来的递归,无论递归几次,从b=4.5开始永远会进入b

      或许我们对b

算法修炼之路(二) —— 递归_第21张图片

 

      b对2取模等于1,说明b是奇数。当遇到b为奇数的情况,我们给当前表达式右边添上“+1”,然后让b减1变成偶数继续进行递归。这样不管b是奇数还是偶数,递归都能输出正确的结果,而且也不会再出现在递归过程中b小于a的情况了。   

5.汉诺塔算法:有三根柱子,在一根柱子上从下往上根据大小顺序依次摆放n个圆盘(底部圆盘最大)。现在对圆盘进行移动,要求把这堆圆盘按照同样排列顺序转移到另一根柱子上。规定在移动的过程中,三根柱子之间每次只能移动一个圆盘,并且小圆盘上不能放在大圆盘上面,请写一套算法,给出对于n个圆盘的移动过程。

算法修炼之路(二) —— 递归_第22张图片

 

      汉诺塔是一道典型的递归算法题。递归的数学模型其实就是数学归纳法,将需要解决的原问题转化为解决它的子问题,而它的子问题又变成解决子问题的子问题,而这些问题其实都是一个模型,到最后总是存在相同的逻辑归纳处理项。对于这道题,我们假设只有一个圆盘,则圆盘要移动到另一根柱子,只需要1步操作,即从A到C。

算法修炼之路(二) —— 递归_第23张图片

      倘若有两个圆盘,我们需要走三步:第一步将小圆盘从A挪到B,第二步将大圆盘从A挪到C,第三步将小圆盘从B挪到C。

算法修炼之路(二) —— 递归_第24张图片

算法修炼之路(二) —— 递归_第25张图片

算法修炼之路(二) —— 递归_第26张图片

      我们将圆盘一开始在的柱子叫做起始柱子From,最后要放的柱子叫做目标柱子To,而中间暂时过渡的柱子叫做过渡柱子By。对于只有一个圆盘的情况来说,圆盘只需要从起始柱子直接移动到目标柱子,但对于两个或两个圆盘以上来说,上面的圆盘不能直接移动到目标柱子,它得先移动到过渡柱子,等到底下的圆盘移动到目标柱子后,它才能从过渡柱子移动到目标柱子。

      我们再假设有三个圆盘,则要分成7步:

  • 1号盘子从A移动到C
  • 2号盘子从A移动到B
  • 1号盘子从C移动到B

算法修炼之路(二) —— 递归_第27张图片


      此时我们对1号盘子和2号盘子,进行了与前面只有两个盘子的情况时基本相同的操作,只不过是目标柱子To由C变为了B,过渡柱子由B变为了C。接下来3号盘子要进行移动。

  • 3号盘子从A移动到C
  • 1号盘子从B移动到A
  • 2号盘子从B移动到C
  • 1号盘子从A移动到C

      对于第四步3号盘子移动的之前,我们可以将1号盘子和2号盘子看作一个整体,将它们在第一步到第三步之间进行的移动视为一步,即视为从A整体移动到B。

算法修炼之路(二) —— 递归_第28张图片

      此时3号盘子从A移动到C,移动一步到位。我们暂时假设B柱子上的两个盘子是不存在的,则3号盘子的移动满足只有一个盘子移动的情况。

算法修炼之路(二) —— 递归_第29张图片

      当然B柱子上的盘子还是存在的,我们此时要对它们进行移动。因为对于B柱子上的盘子来说,C柱子上最大的盘子已经是最大了,所以它们作为两个盘子移动过程中不用担心移动到比它们还小的盘子上的情况,因此此时C柱子上的3号盘子可以视为是不存在的,则此时又进行了关于只有两个盘子的移动操作。

算法修炼之路(二) —— 递归_第30张图片

 

算法修炼之路(二) —— 递归_第31张图片

 

算法修炼之路(二) —— 递归_第32张图片

 

算法修炼之路(二) —— 递归_第33张图片

      接着将视为不存在的3号盘子“可视化”,那么1号和2号盘子已经合理的排放在它的上方了。

算法修炼之路(二) —— 递归_第34张图片

 

      到这里圆盘全部按照规则完成了移动,即然我们前面整体化了1号和2号盘子,将它们在第一步到第三步之间的移动过程视为一步,那我们自然可以继续整体化1号和2号盘子,将它们在第五步到第七步在B柱子上两个盘子移动到C的过程也视为一步。

      到现在我们发现,对于3个盘子七个步骤的移动过程,将3个盘子视为两个整体,我们归结为了三步:

第一步,前两个盘子在起始柱子A上的移动过程,只不过目标柱子To是B,过渡柱子By是C。

第二步,最底下一个盘子在起始柱子A上的移动过程,目标柱子To是C ,过渡柱子By是B。

第三步,前两个盘子在起始柱子B上的移动过程,目标柱子是C,过渡柱子By是A。

      而细化为两个盘子的移动过程,我们也可以归结为三步,:

第一步,第一个盘子在起始柱子A上的移动过程,目标柱子To是B,过渡柱子By是C(没有用到过渡柱子)。

第二部,第二个盘子在起始柱子A上的移动过程,目标柱子To是C,过渡柱子By是B(没有用到过渡柱子)。

第三步,第一个盘子在起始柱子B上的移动过程,目标柱子To是C,过渡柱子By是A(没有用到过渡柱子)。

      再细化到一个盘子的移动过程,就是一个步骤,即盘子从起始柱子From上移动到目标柱子To。

      于是乎我们可以发现,对于三个盘子的移动过程,我们只是不断改变起始柱子From和目标柱子To所代表的位置,然后将其前后细化为两个盘子的移动过程。两个盘子的移动过程也是不断改变起始柱子From和目标柱子To所代表的位置,再细化为一个盘子从起始柱子From到目标柱子To的移动过程。

      这就是圆盘移动最基本的情况,哪怕是四个盘子,我们也可以将前三个盘子的移动视为一个移动整体,第四个盘子视为第二个移动整体,再将前三个盘子视为两个移动整体…不断递归调用,直到最后遇到一个盘子的移动情况直接移动结束递归。因此,整个过程可以始终视为三个递归过程:

 

算法修炼之路(二) —— 递归_第35张图片

输出结果:

算法修炼之路(二) —— 递归_第36张图片

 

      1个盘子需要移动1次,2个盘子需要移动3次,3个盘子需要移动7次,4个盘子需要移动15次,5个盘子需要移动31次,依次推断,n个盘子需要移动2^n-1次,即汉诺塔算法的时间复杂度为O(2^n)。

6.求不重复元素集合的所有子集:给定一个数组,里面的元素不重复,请求出该数组的所有子集。

      对于两个非空集合A与B,如果集合A的任何一个元素都是集合B的元素,我们就称集合A是集合B的子集。包含n个元素的集合有2n个子集,假设一个集合为{ a b c },它的子集共有{ a }、{ b }、{ c }、{ ab }、{ ac }、{ bc }、{ abc } 和 空集{ Ø } 八个可能。

      除了三个独立的元素和空集,其余子集都是三个独立元素各自拼凑而来。我们先忽略掉空集,观察它的非空子集,可以划分为三个部分:① { a } ② { b }、{ ab } ③ { c }、{ ac }、{ bc }、{ abc }。

      这三部分每个部分的最开始,都是集合中的独立元素。如果子集是按这个顺序添加,则每一部分独立元素后面的子集,都是与前面已存在的部分的拼凑结果。因此我们可以按照这个思路,写出如下算法:

算法修炼之路(二) —— 递归_第37张图片

     

      除了拼凑法,还有另一种方法可以解这道题,那就是回溯法。

      回溯法也叫试探法,它是一种系统地搜索问题的解的方法。问题的解空间通常是在搜索问题解的过程中动态产生的,这是回溯算法的一个重要特性。它在问题的解空间树中,按深度优先的策略,从根节点出发搜索解空间树,寻找可能实现的所有情况。回溯法和穷举法的思想相近,不同在于穷举法将所有的情况都列举出来以后再一一筛选,回溯法是在列举过程如果发现当前情况根本不可能存在,就停止后续的所有工作,返回上一步进行新的尝试。

      回溯算法搜索至解空间树的任一节点时,先判断该节点是否包含问题的解。如果不包含,则跳过对以该节点为根的子树的搜索,逐层向其祖先节点回溯,否则进入该子树,以深度优先方式继续搜索问题解。

      回溯法求子集节点可以这么理解,对于集合{ ABC },一开始的搜索,根节点会先试探元素A,形成A’状态。A’状态向下搜索,分为只取A,取A的同时取B,取A的同时取C三种操作。但是,这三种操作并不是同时产生的。

 

算法修炼之路(二) —— 递归_第38张图片

 

      首先对A’先执行只取A操作后,发现取得了一个元素A后无法再往下搜索,此时便将元素A作为第一个结果记录进数组中;接着只取A的操作会回溯到上一个节点的A’状态,从A’状态进行取A的同时取B操作,进入AB’状态。

 

算法修炼之路(二) —— 递归_第39张图片

 

      AB’状态会进行两个操作:只取AB和取AB的同时取C。于是搜索树先搜索到AB节点后发现无法继续向下搜索,便将AB作为第二个结果记录,然后回溯到AB’状态执行取AB的同时取C操作,进入ABC’状态。ABC’状态取得ABC结果后无法继续搜索,回溯到ABC’状态;由于已经没有其它元素了,ABC’状态无法再进行其它操作,于是回溯到AB’状态;AB’同样继续回溯到A’状态。这时A’状态发现还有一个取A的同时取C操作还没进行,就进行取A的同时取C操作,进入AC’状态。AC’状态取得AC结果并记录后回溯到A’状态,A’状态再回溯到根节点。

 

算法修炼之路(二) —— 递归_第40张图片

 

      根节点此时结束了对A’状态的所有探索,就对第二个元素B进行试探,进入对B’状态的探索。就这样节点一个个进行试探搜索,再一个个回溯,最后便取得了所有的子集。

 

算法修炼之路(二) —— 递归_第41张图片

     

 

     在回溯的过程中,很关键的一点在于,我们在回溯时要把状态调回上一个状态,如ABC’调回AB’,否则在同一个子集中将出现很多重复的元素。 而细心的朋友可能已经发现,其实在算法探索的过程中所有的状态值,其实就是子集的结果,我们可以把探索树简化成这样:

 

算法修炼之路(二) —— 递归_第42张图片

 

      每个探索到的状态都可以作为结果记录进子集数组,因此我们可以通过如下代码实现回溯求子集算法:

算法修炼之路(二) —— 递归_第43张图片

     

7.求具有重复元素集合的所有子集,规定求得子集的结果不要出现重复。

      这道题是上一道题的扩展,上一道题求的是无重复元素集合的子集,如果集合里面有重复元素,则子集会出现很多重复的结果:

 

算法修炼之路(二) —— 递归_第44张图片

 

      对集合{ abb }求子集,子集[“a”,”b”]和[“b”]都出现了重复,这是因为探索过程中没有对重复元素进行识别,导致探索树生出相同的状态节点。

 

 

    我们只需要在生出重复的状态节点之前进行识别判断,防止生成相同的节点。我们可以先对集合进行排序,然后在生成状态节点之前对元素进行判断,如果与上一个元素相同就跳过此处节点状态更新操作。

 

算法修炼之路(二) —— 递归_第45张图片

 

8.求数组中元素的所有组合方式。

      对于一个含有n个元素的数组,数组第一位元素有n个可能性,第二位有n-1个可能性,第三位有n-2个可能性。也就是说,n个元素的组合方式共有n!个。那么,我们该如何求一个数组中元素的所有组合方式呢?

      我们假设求数组[1,2,3]的组合,它共有123,213,231,132,312,321六种结果。我们仔细观察可以发现,所有组合无非都是数字的穿插位置的改变,1可以穿插在23或32前面,也可以穿插在23或32中间,或穿插在23或32后面。那么我们只要求出2对3的所有穿插组合,即23跟32,然后让1在它们之间进行的每个不同的位置进行穿插,就可以求出1、2、3的所有组合方式。

 

算法修炼之路(二) —— 递归_第46张图片

 

      因此,我们可以将数组分为两个部分,要穿插的数和被穿插的组合。我们可以用递归的方式,对被穿插的组合继续细分为要穿插的数和被穿插的组合。细分到最后被穿插的组合只剩一个数时,停止细分,开始进行组合,循环遍历当前被穿插组合,将当前取得的穿插数对被穿插组合各个位置进行穿插,把结果再作为被穿插组合返回以便更左边的元素进行穿插,这样递归到最后,就能得到所有可能的组合方式了。

 

算法修炼之路(二) —— 递归_第47张图片

 

      因为左边元素可以穿插到右边组合的最后面,所以这里第二层循环的判断条件中j可以等于右边组合数组的长度(数组索引从0开始,索引为数组长度代表最后一位的下一位)

      这道题除了用穿插法,其实也可以用回溯法来解决。具体解决方式就先暂时留作思考,留待以后有机会再详细讲解。

9.括号组合:给定指定对数的括号,要求符合正常的括号逻辑(即左括号在左边,右括号在右边),求出n对括号所有可能的组合方式。
     
这道题我们同样可以用回溯法来解决。假设有三对括号,左括号和右括号各有三个。若要符合正常的括号逻辑,则不能出现右括号前面没有与之对应的左括号。因此左括号的使用个数每次都必须大于右括号,我们根据这个思路画出如下搜索树:

 

算法修炼之路(二) —— 递归_第48张图片

 

      我们首先添加左括号,添加完三个左括号后再继续添加右括号,到最后求出第一个结果,回溯返回上一层,由于上一层没有其它搜索路径,继续依次返回到两个左括号的状态。

 

算法修炼之路(二) —— 递归_第49张图片

 

      两个左括号的状态此时进行另一步操作,添加一个右括号,然后继续按左括号优先的原则,先添加左括号。此时三个左括号都添加完毕,后面的两个步骤就只能添加右括号,到最后得出结果记录进数组并继续回溯。

 

算法修炼之路(二) —— 递归_第50张图片

 

      在回溯到已经使用了两个左括号和两个右括号时,由于左括号和右括号的使用数量相等,因此不可以先进行右括号的添加,所以不会走图中虚线道路,而是会继续往上回溯到一个左括号的状态,准备下一步的搜索。

 

算法修炼之路(二) —— 递归_第51张图片

 

      搜索到最后,便能找到括号对的所有可能组合方式了,以下是代码实现方式:

 

算法修炼之路(二) —— 递归_第52张图片

 

      今天还有一些关于递归的算法,比如求格雷码位元改变情况、求指定长度元素排列组合、求具有重复元素集合的不重复排列组合。无奈本人精力和水平有限,写到这里已经写不动了。今天的递归算法题目,难度相对于上一次高出很多,特别是对回溯法的理解和应用,更需要下足功夫才能弄懂。如果你坚持看完了这篇文章,并能很好的理解每一道题,那么,恭喜你,你已经具备一定程度的算法思维了。今天的算法专题就到此结束,下次的算法专题,我将讲解搜索和排序算法,期待的朋友敬请关注。

 

 

      那么,最后一个问题来了,当你的对象想让你带她去吃东西时,究竟要怎样才能结束这场对象的递归调用呢?

——“亲爱的,人家肚子饿了….”

——“分手吧!渣女!”

你可能感兴趣的:(算法修炼之路,递归算法,汉诺塔,阶乘,斐波那契数列,求子集)