递归与循环是程序设计中基础的环节,相对于循环,递归总是更神秘一些。
一、递归的实质
在程序中,递归的实现往往是通过程序压栈来实现的,执行方法时,如果仍然存在子递归,则将父级方法中的全部信息压入栈中,保存这部分数据;当子递归执行完毕后,从栈中取出父递归,并将子递归作为参数继续执行。
因此递归执行的过程其实就是压栈、出栈的过程
二、举例
从一个数组中找到它的最大值
参考牛客网题目:https://www.nowcoder.com/questionTerminal/10f59d86339041389c0b377d0af72300
1、遍历实现
遍历整个数组,依次比较每个数,从而找到最大值
fun getMaxNumber1(a: IntArray): Int {
if (a.isEmpty()) {
return -1
}
var max = a[0]
a.forEach {
if (it > max) {
max = it
}
}
return max
}
2、递归实现
从中间分割数组为左侧区间和右侧区间,然后左侧区间再次从中间分割,右侧区间也是如此
/**
* 通过分治递归的方式找到最大值
*/
fun getMaxNumber2(a: IntArray, left: Int, right: Int): Int {
if (left == right) {
return a[left]
}
val mid = (left + right) / 2
val leftMax = getMaxNumber2(a, left, mid)
val rightMax = getMaxNumber2(a, mid + 1, right)
return leftMax.coerceAtLeast(rightMax)
}
三、理解递归的实质
以上面的递归获取最大值来分析,假设数组为[1,3,6,2,8]
1、首先比较left==right条件,不满足,则计算mid = (left+right)/2 = (0+4)/2 = 2,即从下标为2的元素作为中间点,中间元素可以归到左侧数组,也可以归到右侧数组,这里以归属数组为例。此时左侧为[1,3,6],右侧[2,8]
2、执行val leftMax = getMaxNumber2(a, left, mid),这里leftMax的值是通过递归实现的,因此接下来需要执行leftMax等式右侧的递归,那么首先需要将当前方法中的信息保存到栈中(行数,方法中的传入的参数)。
这里 执行到第9行,且传入的left = 0 ,right = 4,因此压栈信息如下
3、执行递归getMaxNumber(a,0,2),比较left == right,不满足,则计算mid = (left+right)/2 = 1,即从下标为1的元素作为中间点,左侧为[1,3],右侧为[2]。
4、执行val leftMax = getMaxNumber2(a, left, mid),left = 0,right = 1,而这里又是一个递归,那么在执行这个递归之前,需要将当前函数压栈。
5、压栈依次类推,当递归执行中getMaxNumber2执行了return返回值时,假设getMaxNumber2(a,0,1)返回了对应的值,getMaxNumber2(a,2,2)也返回了对应值,此时就需要将入栈的方法信息执行出栈,恢复方法之前的状态,然后执行return leftMax.coerceAtLeast(rightMax)比较最大值
此时栈为:
6、出栈依次类推,当所有的方法栈信息都出栈完成时,那么递归也就执行完毕了
四、递归复杂度
递归过程的复杂度是一个很常见的问题,相比较循环的复杂度分析,递归的复杂度分析要相对麻烦一点。
1、对于等分递归的分析
等分,即使把一个数组按相同的比例等分为几个子数组,那么就符合如下master公式:
T(N) = a*T(N/b)+O(N^d)
其中N为数组大小,a为常数倍数,b为等分的比例,O(N^d)表示在递归后执行的操作
以上面的求最大值递归为例:
对于左侧数组,需要执行N/2次遍历,而这样的操作需要执行两次。而在每次遍历的过程中,需要进行一次比较,这是常数操作,因此O(N^d) = O(N^0)即 = 1
则上面例子对应的公式为:
T(N) = 2T(N/2)+O(1)
符合master公式的前提下,可以通过下面来获取对应的时间复杂度
满足条件 | 时间复杂度 |
log(b,a)>d | O(N^log(b,a)) |
log(b,a)=d | O((N^d)*logN) |
log(b,a)<d | O(N^d) |
套入到上面的例子中,T(N) = 2T(N/2)+O(1)。
其中a =2,b = 2,d = 0,那么log(2,2) = 1,即满足log(b,a)>d,那么对应的时间复杂度为O(N^log(b,a)),即复杂度为O(N)
2、非等分递归
对于非等分递归,我们不能再使用master公式来进行解决,而这种场景后面的算法中我们会分析到
五、归并排序的递归实现
归并排序其实就是递归实现的一种很好的应用。
归并排序算法的核心就是,将数组等分为两部分,先将左侧部分排好序,再将右侧部分排好序,然后将两部分有序的子数组进行合并。合并的时候,我们需要遍历整个数组进行比较。而核心就在于这里的合并过程
1、具体实现
@JvmStatic
fun mergeSort(a: IntArray, left: Int, right: Int) {
// 注意,递归需要特别注意边界值,否则会导致堆栈移除
if (left == right) {
return
}
// 求中间数,可以使用如下方式
val mid = left + ((right - left) shr 1)
// val mid = (left + right) / 2
mergeSort(a, left, mid)
mergeSort(a, mid + 1, right)
merge(a, left, mid, right)
}
/**
* 执行merge操作
*/
fun merge(a: IntArray, left: Int, mid: Int, right: Int) {
// 定义临时数组
val temp = IntArray(right - left + 1)
// 定义i,用于将排序后的值填入temp数组中
var i = 0
// 代表左侧数组下标
var p1 = left
// 代表右侧数组下标
var p2 = mid + 1
// 同时比较p1与p2对应的值的大小,如果a[p1]<= a[p2],则将a[p1]放入temp中,同时i的值+1,p1的值+1,p2不变,进入下一轮比较;
// 如果a[p1] > a[p2],则将a[p2]放入temp中,同时i的值+1,p2的值+1,p1不变,进入下一轮比较;
// 直到左右两个个数组达到边界,则停止比较
while (p1 <= mid && p2 <= right) {
temp[i++] = if (a[p1] <= a[p2]) a[p1++] else a[p2++]
}
// 表示右侧数组已达到边界值,此时将左侧数组剩余数值直接copy到temp即可
while (p1 <= mid) {
temp[i++] = a[p1++]
}
// 表示左侧数组已达到边界值,此时将右侧数组剩余数值直接copy到temp即可
while (p2 <= right) {
temp[i++] = a[p2++]
}
// 将临时temp的值,拷贝到原数组中
temp.forEachIndexed { index, i ->
a[left + index] = i
}
}
2、对应的master公式
T(N) = 2T(T/2)+O(N)
因此对应的master公式为:
T(N) = 2T(T/2)+O(N)
3、时间复杂度
对应到master公式中,则时间复杂度为:
O(N) = O((N^d)*logN) = O(N*logN)
4、空间复杂度
在实现中我们是临时生成不同区间的数组,其实可以等同于我们生成了一个全局的长度为N的数组,然后重复使用
因此额外空间复杂度为O(N)