每日正能量
智者的梦再美,也不如愚人实干的脚印。
1.什么是递归
递归,就是在运行的过程中调用自己。
构成递归需具备的条件:
子问题须与原始问题为同样的事,且更为简单;
不能无限制地调用本身,须有个出口,化简为非递归状况处理。
2.递归模板
我们知道递归必须具备两个条件,一个是调用自己,一个是有终止条件。这两个条件必
须同时具备,且一个都不能少。并且终止条件必须是在递归最开始的地方,也就是下面
这样
public void recursion(参数0) {
if (终止条件) {
return;
}
recursion(参数1);
}
不能把终止条件写在递归结束的位置,下面这种写法是错误的
public void recursion(参数0) {
recursion(参数1);
if (终止条件) {
return;
}
}
如 果 这 样 的 话 , 递 归 永 远 退 不 出 来 了 , 就 会 出 现 堆 栈 溢 出 异 常
(StackOverflowError)。
3. 实例分析
我对递归的理解是先往下一层层传递,当碰到终止条件的时候会反弹,最终会反弹到调用处。下面我们就以5个最常见的示例来分析下
3.1 阶乘
我们先来看一个最简单的递归调用-阶乘,代码如下
public int recursion(int n) {
if (n == 1)
return 1;
return n * recursion(n - 1);
}
代码分析
第2-3行是终止条件,第4行是调用自己。我们就用n等于5的时候来画个图看一下递归究竟是怎么调用的 。
这种递归还是很简单的,我们求f(5)的时候,只需要求出f(4)即可,如果求f(4)我们要求出f(3)……,一层一层的调用,当n=1的时候,我们直接返回1,然后再一层一层的返回,直到返回f(5)为止。
递归的目的是把一个大的问题细分为更小的子问题,我们只需要知道递归函数的功能即可,不要把递归一层一层的拆开来想,如果同时调用多次的话这样你很可能会陷入循环而出不来。比如上面的题中要求f(5),我们只需要计算f(4)即可,即f(5)=5* f(4);至于f(4)是怎么计算的,我们就不要管了。因为我们知道f(n)中的n可以代表任何正整数,我们只需要传入4就可以计算f(4)。
3.2 斐波那契数列
我们再来看另一道经典的递归题,就是斐波那契数列,数列的前几项如下所示[1,1,2,3,5,8,13……]
我们参照递归的模板来写下,首先终止条件是当n等于1或者2的时候返回1,也就是数列 的前两个值是1,代码如下:
public int fibonacci(int n) {
if (n == 1 || n == 2)
return 1;
这里是递归调用;
}
递归的两个条件,一个是终止条件,我们找到了。还有一个是调用自己,我们知道斐波那契数列当前的值是前两个值的和,也就是 fibonacci(n) =fibonacci(n - 1) + fibonacci(n - 2)。
所以代码很容易就写出来了:
//1,1,2,3,5,8,13……
public int fibonacci(int n) {
if (n == 1 || n == 2)
return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
3.3 反转字符串 (Leetcode344)
通过前面两个示例的分析,我们对递归有一个大概的了解,下面我们再来看另一个示例-反转字符串
题目要求:
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
根据递归模板,分析出当要交换的字符串起点和终点相遇,反转完成。
public void reverse(char[]s, int left, int right) {
if (left>=right) {
return;
}
//这里是递归调用
}
接下来看看如何反转,就是先将起点和终点的内容进行交换,然后对字符串进行缩进。
起点和终点交换
temp = s[left]; s[left] = s[right]; s[right] = temp;
字符串缩进
left+1
right-1
public void reverse(char[]s, int left, int right) {
if (left>=right) {
return;
}
temp = s[left];
s[left] = s[right];
s[right] = temp;
reverse(s, left+1, right-1);
}
通过上面的分析,是不是感觉递归很简单。所以我们写递归的时候完全可以套用上面的模板,先写出终止条件,然后在写递归的逻辑调用。还有一点非常重要,就是一定要明白递归函数中每个参数的含义,这样在逻辑处理和函数调用的时候才能得心应手,函数的调用我们一定不要去一步步拆开去想,这样很有可能你会奔溃的。
3.4 不死神兔
有一对兔子,从出生后第三个月起每个月都生一对兔子,小兔子长到第三个月后每个月又生一对兔子,假如兔子都不死,问第二十个月的兔子对数为多少?
问题分析:
前2个月我们就会发现兔子时没有发生变化的,也就是前两个月均为1只兔子,在第三个月时我们的兔子就会生下另一对兔子,也就是说我们在之后每一次都需要加上上一个月所有的兔子,就是简单可以理解成每一个月即前两个月的兔子之和。
递归终止条件,第一个月和第二个月都是1
递归调用,其余都是前两个月的和
代码如下:
public static int fei(int n) {//在调用方法时输入你需要的月数就可以了比如fei(20);
if (n == 1 || n == 2) {
return 1;
} else {
return fib(n - 1) + fei(n - 2);
}
}
3.5 两个数的最大公约数
辗转相除法 设用户输入的两个整数为n1和n2且n1>n2,余数=n1%n2。当余数不为0时,把除数赋给n1做被除数,把余数赋给n2做除数再求得新余数,若还不为0再重复知道余数为0,此时n2就为最大公约数。
由此可以分析得出:递归结束条件为余数==0
代码示例:
public static int gcd4(int n1, int n1) {
if (n1 % n2 == 0)
return n2;
return gcd4(n2, n1 % n2);
}
4.归并排序(Merge Sort)
和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(n log n)的时间复杂度。代价是需要额外的内存空间。
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
4.1 算法描述
把长度为n的输入序列分成两个长度为n/2的子序列;
对这两个子序列分别采用归并排序;
将两个排序好的子序列合并成一个最终的排序序列。
4.2 动图演示
4.3 代码实现
/**
* 合并数组
* @param array
* @param left
* @param mid
* @param right
*/
public static void merge(int[] array,int left,int mid,int right){
int s1 = left;
int s2 = mid+1;
int [] res = new int[right-left+1];
int i=0;
while(s1 <= mid && right >=s2){
/*if(array[s1] <= array[s2]){
res[i++] = array[s1++];
}else {
res[i++] = array[s2++];
}*/
res[i++]= array[s1] <= array[s2] ? array[s1++] :array[s2++];
}
while(s1 <= mid){
res[i++] = array[s1++];
}
while(s2 <= right){
res[i++] = array[s2++];
}
System.arraycopy(res,0,array,0+left,res.length);
}
/**
* 分解数组
* @param array
* @param left
* @param right
*/
public static void mergeSort(int array[],int left,int right){
if(left>=right){
return;
}
int mid = (left+right)>>>1;
mergeSort(array,left,mid);
mergeSort(array,mid+1,right);
merge(array,left,mid,right);//合并
}
4.4 算法分析
最佳情况:T(n) = O(n) 最差情况:T(n) = O(nlogn) 平均情况:T(n) = O(nlogn)
5.快速排序(Quick Sort)
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
5.1 算法描述
同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。
不同的是,冒泡排序在每一轮中只把1个元素冒泡到数列的一端, 而快速排序则在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成两个部分。
这种思路就叫作分治法。
每次把数列分成两部分,究竟有什么好处呢?
假如给出一个8个元素的数列,一般情况下,使用冒泡排序需要比较7轮,每一轮把1个元素移动到数列的一端,时间复杂度是O(n2)。
而快速排序的流程是什么样子呢?
如图所示,在分治法的思想下,原数列在每一轮都被拆分成两部分,每一部分在下一轮又分别被拆分成两部分,直到不可再分为止。
每一轮的比较和交换,需要把数组全部元素都遍历一遍,时间复杂度是O(n)。这样的遍历一共需要多少轮呢?假如元素个数是n,那么平均情况下需要logn轮,因此快速排序算法总体的平均时间复杂度是O(nlogn)
基准元素的选择,以及元素的交换,都是快速排序的核心问题。让我们先来看看如何选择基准元素。
5.2 基准元素的选择
基准元素,英文是pivot,在分治过程中,以基准元素为中心,把其他元素移动到它的左右两边。
那么如何选择基准元素呢?
最简单的方式是选择数列的第1个元素。
这种选择在绝大多数情况下是没有问题的。但是,假如有一个原本逆序的数列,期望排序成顺序数列,那么会出现什么情况呢?
在这种情况下,数列的第1个元素要么是最小值,要么是最大值,根本无法发挥分治法的优势。
在这种极端情况下,快速排序需要进行n轮,时间复杂度退化成了O(n2)。
那么,该怎么避免这种情况发生呢?
其实很简单,我们可以随机选择一个元素作为基准元素,并且让基准元素和数列首元素交换位置。
这样一来,即使在数列完全逆序的情况下,也可以有效地将数列分成两部分。
当然,即使是随机选择基准元素,也会有极小的几率选到数列的最大值或最小值,同样会影响分治的效果。
所以,虽然快速排序的平均时间复杂度是O(nlogn),但最坏情况下的时间复杂度是O(n2)。
在后文中,为了简化步骤,省去了随机选择基准元素的过程,直接把首元素作为基准元素。
5.3 元素的交换
选定了基准元素以后,我们要做的就是把其他元素中小于基准元素
的都交换到基准元素一边,大于基准元素的都交换到基准元素另一边。
具体如何实现呢?有两种方法。
双边循环法。
单边循环法。
何谓双边循环法?下面来看一看详细过程。
给出原始数列如下,要求对其从小到大进行排序。
首先,选定基准元素pivot,并且设置两个指针left和right,指向数列的最左和最右两个元素。
接下来进行第1次循环,从right指针开始,让指针所指向的元素和基准元素做比较。如果大于或等于pivot,则指针向左移动;如果小于pivot,则right指针停止移动,切换到left指针。
在当前数列中,1<4,所以right直接停止移动,换到left指针,进行下一步行动。
轮到left指针行动,让指针所指向的元素和基准元素做比较。如果小于或等于pivot,则指针向右移动;如果大于pivot,则left指针停止移动。
由于left开始指向的是基准元素,判断肯定相等,所以left右移1位。
由于7>4,left指针在元素7的位置停下。这时,让left和right指针所指向的元素进行交换。
接下来,进入第2次循环,重新切换到right指针,向左移动。right指针先移动到8,8>4,继续左移。由于2<4,停止在2的位置。
按照这个思路,后续步骤如图所示。
5.4 代码实现
我们来看一下用双边循环法实现的快速排序,代码使用了递归的方式。
public static void quickSort(int[] arr, int startIndex, int endIndex) {
// 递归结束条件:startIndex大于或等于endIndex时
if (startIndex >= endIndex) {
return;
}
// 得到基准元素位置
int pivotIndex = partition(arr, startIndex, endIndex);
// 根据基准元素,分成两部分进行递归排序
quickSort(arr, startIndex, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, endIndex);
}
/**
* 分治(双边循环法)
*
* @param arr 待交换的数组
* @param startIndex 起始下标
* @param endIndex 结束下标
*/
private static int partition(int[] arr, int startIndex, int endIndex) {
// 取第1个位置(也可以选择随机位置)的元素作为基准元素
int pivot = arr[startIndex];
int left = startIndex;
int right = endIndex;
while (left != right) {
//控制right 指针比较并左移
while (left < right && arr[right] > pivot) {
right--;
}
//控制left指针比较并右移
while (left < right && arr[left] <= pivot) {
left++;
}
//交换left和right 指针所指向的元素
if (left < right) {
int p = arr[left];
arr[left] = arr[right];
arr[right] = p;
}
}
//pivot 和指针重合点交换
arr[startIndex] = arr[left];
arr[left] = pivot;
return left;
}
在上述代码中,quickSort方法通过递归的方式,实现了分而治之的思想。
partition方法则实现了元素的交换,让数列中的元素依据自身大小,分别交换到基准元素的左右两边。在这里,我们使用的交换方式是双边循环法。
双边循环法的代码确实有些烦琐。除了这种方式,要实现元素的交换也可以利用单边循环法,下一节我们来仔细讲一讲。
5.5 单边循环法
双边循环法从数组的两边交替遍历元素,虽然更加直观,但是代码实现相对烦琐。而单边循环法则简单得多,只从数组的一边对元素进行遍历和交换。我们来看一看详细过程。
给出原始数列如下,要求对其从小到大进行排序。
开始和双边循环法相似,首先选定基准元素pivot。同时,设置一个mark指针指向数列起始位置,这个mark指针代表小于基准元素的区域边界。
接下来,从基准元素的下一个位置开始遍历数组。
如果遍历到的元素大于基准元素,就继续往后遍历。
如果遍历到的元素小于基准元素,则需要做两件事:第一,把mark指针右移1位,因为小于pivot的区域边界增大了1;第二,让最新遍历到的元素和mark指针所在位置的元素交换位置,因为最新遍历的元素归属于小于pivot的区域。
首先遍历到元素7,7>4,所以继续遍历。
接下来遍历到的元素是3,3<4,所以mark指针右移1位。
随后,让元素3和mark指针所在位置的元素交换,因为元素3归属于小于pivot的区域
按照这个思路,继续遍历,后续步骤如图所示。
双边循环法和单边循环法的区别在于partition函数的实现,让我们来看一下代码。
5.6 代码实现
public static void quickSort(int[] array){
subSort(array,0,array.length-1);
}
/**
* 快排单边循环
* @param array
* @param low
* @param high
*/
public static void subSort(int[] array,int low,int high){
if(low >= high){
return;
}
//第一个元素为基准
int privot = array[low];
//指针i和指针j都是从第二个位置出发
int i = low +1;
int j = low +1;
//移动两个指针,使得i左边的都小于pivot,i和j中间的元素的都大于pivot
//j指针跑的快,j到达最后一个
while(j<=high){
//一开始i,j同步一直到第一个比povit大的数出现,i停下
if (array[j] < privot){
swap(array,i,j);
i++;
}
j++;
}
//最后交换pivot和i-1的数
swap(array,low,i-1);
//递归调用左边、右边
subSort(array,low,i-2);
subSort(array,i,high);
}
/**
* 交换数组内两个元素
* @param array
* @param i
* @param j
*/
public static void swap(int[] array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
5.7 算法分析
最佳情况:T(n) = O(nlogn) 最差情况:T(n) = O(n2) 平均情况:T(n) = O(nlogn)