递归是一种简化运算规模的方法。将每个问题分为一个个子问题,子问题从结构上来看与原问题相同,但是运算规模小于原问题。不断递归下去,直到有一个子问题是已知可解决的。反过来再将每一个完成的子问题作为父问题的输入,直到回到第一层得到最终的结果。
书中把递归分为了归纳法,分治法,和动态规划。由于课程中动态规划占的课时比重较大,所以把它单列一篇博客,本文只介绍归纳法和分治法。
归纳法和分治法的差别就像,归纳法是一个一个的递归,类似于栈的结构。分治法的递归像树的结构,在不断向下的同时也不断分裂成为更小规模。下面就介绍一下归纳法和分治法吧!
一、 归纳法(尾递归):
归纳法源自数学归纳法。当我们已知如何求解一个小一点的数量的时候,想要求一个更大的数量,只需要把小数做一个扩展即可。下面从两个例子出发:
5.2.1选择排序:
在前文中对选择排序,进行了介绍。从第一个数字开始,向后依次选出最小的交换至当前开头。在递归中,如果我们已知如何将从1-n中选择出最小的元素。那么接下来就可以使用递归完成操作。
从递归调用中,元素的比较次数为:
最后求出的时间复杂度是O(n^2)。
5.2.2插入排序:
插入排序是一个从后往前的过程,假如我已知如何对第n个元素进行插入排序,那么依次向前递归就能是数组有序。
最后求出的时间复杂度是O(n^2)
5.4整数幂
对于正数幂的运算通常是使用<math.h>头文件中包含的pow函数。但如果禁止使用该函数,常规一点的想法是将n个x相乘。这要n-1次乘法。(已经证实调用pow函数所花费的时间大于自己编写实现乘方的函数)。相较于输入来说是指数级。于是我们采取以下算法:假设我们已经知道如何计算x的(n/2)次方,这么可以再通过一次平方得到x^2。以此种方法,类推下去就可以求出x的任意次方,伪代码如下:
5.7寻找多数元素:
多数元素的定义:数组A中元素a出现的次数大于n/2向下取整,则认为a是多数元素。
求解多数元素,方法一是使用蛮力法:从第一个元素开始,依次向后扫描,计算每对相同的个数最后得出结论,这样的时间复杂度是O(n^2)。较方法一更好的方法二是对数组进行排序(选用最佳的排序算法,时间复杂度是O(nlogn)),由多数元素的特点知,位于n/2向上取整的元素有可能是多数元素,再花费O(n)的时间进行判断。总体时间复杂度是O(nlogn)。第三种算法是从第一个元素开始,向后扫描,遇到相同的count加1(count初始化为0),遇到不同的count减一,看最终count和零比较,若大于零则是多数元素。并且可以将此过程递归,程序伪代码如下图:
二、分治法
分治的主要思想是把大规模数据划分成小规模,再把小规模数据组合起来。通常这样的做法能够使算法时间复杂度降低一个数量级。在
通常情况下,分治都是使用二分法。下文中很多例子都是基于二分法的思想提出的。下面结合一些具体应用,体会递归中的分治法思想。
1. 寻找最大最小值:
遍历整个数组,寻找最大最小值所花费的比较次数具体是2n-2,可以使用另一种思想:将数组分割成两半,寻找到每部分的最大最小值,再将两部分做比较,较大值作为最大值,较小值作为最小值。依次递归下去,根据递推公式可求得该比较次数为3n/2-2。伪代码如下:
2. 二分搜索:
二分搜索是一种常用于有序数组得搜索方式,通过和中间的数字进行比较确定下一次比较的范围。时间复杂度为O(logn)。具体的伪代码如下:
注意:在本递归算法中,需要O(logn)的空间复杂度,而在迭代算法中只需要O(1)得空间复杂度。我理解为在于递归过程中需要保存每层迭代的mid值。
3. 合并排序:
合并排序是将数组从上方不断分为两部分,直至最小部分后。再反向排序递归向上,时间复杂度为O(nlogn)。合并排序需要O(n)的辅助空间。这也是合并排序的缺点之一。具体的伪代码如下图:
需要注意的是合并排序和自底向上排序不是完全一样的(不然也不会出现两个名字)。合并排序强调相对均分,无论奇数还是偶数。自底向上排序在最底层是两两集合的,如果剩下单个是不会加入到过程中的排序的(这是两者最大的区别)。所以当使用二分法,并且数组大小是2的幂次方时,两者并无区别。
为什么说,归并排序的实现类似于二叉树的后序遍历?
从总体上看,归并排序的结构以二叉树型存在。而且在排序的实现过程中,都是从下往上的(此处理解为先叶子节点再根节点),与后序遍历类似。而且从分层方面,每一层排序(merge排序)的时间复杂度都是o(n)。层数为logn层,也解释了时间复杂度是O(nlogn)
对于分治操作大致可分为三个主要步骤:划分、治理、组合。不同算法对三个步骤的时间复杂度是不同的。在治理步骤中,比较重要是的寻找一个合适的阙值,如果数据规模小于阙值,可以直接采用简单方法,而不会对时间造成较大影响。照目前我掌握的知识来看,组合的算法时间复杂度很大程度上决定了最后递归的好坏。
4. 寻找中项和第k个元素(SELECT方法)
常规做法是将数组排序后,选择对应位置。这样的时间复杂度是O(nlogn)。提出一种选择的思想:将数组划分,选择出每个划分组的中值,组成数组A。再从A中选出中值B。将数组分为三部分:大于B,小于B,等于B。并且出三组内对应的元素个数,和想要寻找位置进行比较。选择合适的组,再进行递归,直到结果。具体的伪代码如下:
可证,这种选择法的时间复杂度是O(n)。
5. 快速排序:
快速排序是一种时间复杂度为O(nlogn)的算法。基本思想利用了分治的思想。治理的过程调用了SPLIT算法,SPLIT算法思想是选定一个标准,将数组中小于标准的数据全部放在标准左侧,大于标准的数据全部放在标准右侧。所以快速排序是原地排序。SPILT的伪代码如下:
对于SPILT来说,要想达到做好的效果,要做到两边数据平衡。若整个数组已按照非降序排列,每次SPILT的时间复杂度就变成了O(n),而SPILT的次数也变成了n,整个快速排序此时处于最坏情况O(n2)。最佳情况选择的标准就恰好使两边平衡,此时可以使用前文提到的SELECT算法,直接选出,最中间的数据。这是SPILT的时间复杂度不变,但次数变为logn。总体时间复杂度变为O(nlogn)。
快速排序虽然使用原地排序,但递归过程中需要保存过程中间的标准,所以总体空间复杂度也是O(n)。
在算法设计中,乘法消耗的时间是比较多的。可以采用一种思想:将乘法转换为加法。这样往往能降低时间复杂度的数量级。例如常规矩阵乘法时间复杂度是O(n3),使用STRASSEN算法,就能把时间复杂度降低至O(n2.81)。
6. 最近点对问题:
最近点对问题要求求出二维平面中哪两个点间距离最小。使用蛮力算法:计算每一个点到其余n-1个点的距离,最后求出最小值。时间复杂度为O(n2)。使用CLOSEPATH算法可将时间复杂度降低至O(nlogn)。CLOSEPATH算法基于分治思想,选择中线将二维平面划分为两部分。这样不断递归下去,能找到最短两点距离。最短有三种情况,两点都在左侧或右侧,两点一左一右。在递归的过程中,就将两点在同侧的计算出来。再选出更小的x1,只需判断x1和两点一左一右的最小距离x2谁大谁小即可。若x1更小,则以x1为边界就足够。以寻找到x1的中线为标准,向左向右画长度x1,同意纵向也画x1长度的表格。形成一个x1*2x1的矩形。利用鸽舍原理可证明,在表格的一段最多有d。对于此过程证明相对繁琐。本处指的d不一定是实际存在的点,指的是最多的情况。这些情况都将发生在边界上。只需对边界及以内符合要求的点进行判断即可。对此判断的方式选择了对Y进行排序。对此问题更为详细的介绍可参照博客:https://blog.csdn.net/lishuhuakai/article/details/9133961。link
CLOSEPATH的伪代码如下:
分治法为什么好?
从时间复杂度的方向解释,下面的话引自一位博主(原文链接:https://blog.csdn.net/code_star_one/article/details/72724555)
link
“一个分治法将规模为 n 的问题分成 k 个规模为 n/m 的子问题去解。设分解阀值 n0 = 1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用 merge 将 k 个子问题的解合并为原问题的解需用 f(n) 个单位时间。用T(n)表示该分治法解规模为 |P| = n的问题所需的计算时间,则有:
T(n)= k T(n/m)+f(n)
通过迭代法求得方程的解:
递归方程及其解只给出n等于m的方幂时 T(n) 的值,但是如果认为 T(n) 足够平滑,那么由 n 等于 m 的方幂时 T(n) 的值可以估计T (n) 的增长速度。通常假定 T(n) 是单调上升的,从而当 mi ≤ n < mi+1时,T(mi) ≤ T(n) < T(mi+1)。“
在很多情况下,分治法的时间复杂度决定于组合算法的选择。选择正确的组合算法有可能使时间复杂度降低一个数量级。如:最小点对问题。
本文作者水平有限,如有不正确之处。请各位下方评论区指正。谢谢!