每日正能量

智者的梦再美,也不如愚人实干的脚印。

递归

1.什么是递归

递归,就是在运行的过程中调用自己。

构成递归需具备的条件:

  1. 子问题须与原始问题为同样的事,且更为简单;

  2. 不能无限制地调用本身,须有个出口,化简为非递归状况处理。

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;}//这里是递归调用
}

接下来看看如何反转,就是先将起点和终点的内容进行交换,然后对字符串进行缩进。

  1. 起点和终点交换

    temp = s[left]; ​ s[left] = s[right]; ​ s[right] = temp;

  2. 字符串缩进

    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 元素的交换

选定了基准元素以后,我们要做的就是把其他元素中小于基准元素

的都交换到基准元素一边,大于基准元素的都交换到基准元素另一边。

具体如何实现呢?有两种方法。

  1. 双边循环法。

  2. 单边循环法。

何谓双边循环法?下面来看一看详细过程。

给出原始数列如下,要求对其从小到大进行排序。

首先,选定基准元素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) 

Java基础——递归实现归并排序和快速排序相关推荐

  1. java基础----递归实现文件搜索

    package com.henu.io;import java.io.File; /**??????????????????????????????????????????*不使用文件过滤器,则看文件 ...

  2. java基础----递归

    package com.henu.io;import java.util.Iterator;/** •递归:指在当前方法内调用自己的这种现象.* 注意:递归必须要有结束条件*/ public clas ...

  3. 四种常见排序算法的对比和总结 插入排序、归并排序、快速排序、堆排序

    目录 一.排序算法的时间复杂度 二.排序算法是否是原地排序 三.排序算法的额外空间 四.排序算法的稳定性 Stable 五.总结 这里我们要总结的排序算法主要有4个,分别是插入排序Insertion ...

  4. java非递归方式实现快速排序

    Java非递归方式实现快速排序 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 ...

  5. 【零基础学Java】—递归(五十一)

    [零基础学Java]-递归(五十一) 一.递归 递归:指在当前方法内调用自己的这种现象

  6. Java基础篇:什么是递归?如何用递归?

    Java支持递归.递归就是依照自身定义事物的过程.在Java编程中,递归是允许方法调用自身调用的属性.调用自身的方法称为是递归的. 递归的典型例子是数字的阶乘.数字N的阶乘是1到N之间所有整数的乘积. ...

  7. 算法与数据结构全阶班-左程云版(二)基础阶段之3.归并排序和快速排序

    文章目录 前言 1.归并排序 2.快速排序 总结 前言 本文主要介绍了两种排序,归并排序和快速排序,归并排序有递归和非递归2种方式实现,快速排序的升级版为荷兰国旗问题. 1.归并排序 归并排序: 1) ...

  8. 数据结构与算法-基础算法篇-排序(归并排序、快速排序)

    3. 归并排序.快速排序 1. 分治思想 分治,顾明思意,就是分而治之,将一个大问题分解成小的子问题来解决,小的子问题解决了,大问题也就解决了. 分治与递归的区别:分治算法一般都用递归来实现的.分治是 ...

  9. java基础练习复习二:递归字节流字符流二

    本篇是基于java基础练习复习一:递归&字节流&字符流一, 如果对您有帮助 ,请多多支持.多少都是您的心意与支持,一分也是爱,再次感谢!!!打开支付宝首页搜"55672346 ...

最新文章

  1. legnano里的看板成员及权限规则?项目成员及规则?
  2. 每日英语:China's Youth to Employers: I Quit
  3. ubuntu apt-get dpkg应用中的一些问题及解决方法
  4. vue2.0路由之编程式导航
  5. 大数据之-Hadoop之HDFS_hadoop集群中的安全模式_操作案例---大数据之hadoop工作笔记0075
  6. sql语句中 and 与or 的优先级
  7. UVa 1585 - Score
  8. 章节3.4----队列的实现与应用
  9. MATLAB最新官方中文文档
  10. Linux多线程编程实验
  11. 最小二乘法求直线的理解
  12. 学习(四):显示FPS,和自定义显示调试
  13. 手机屏幕常见故障_触屏不灵敏、断触怎么回事?手机触摸屏的基本原理与常见问题排查方法介绍...
  14. 移动硬盘只读属性不能改
  15. go语言中goto的使用
  16. 计算机网络与物联网工程专业大学排名,大学专业“薪酬”排名公布,物联网工程仅排第五,有你的专业吗...
  17. 网页端Skype更新 在桌面/移动平台添加对Safari的支持
  18. Linux操作系统安全加固指导
  19. Tachyon安装:本地安装
  20. 中移在线容器平台入选云原生应用十大优秀案例,成为全球最大客服云案例

热门文章

  1. MySQL数据库学总结很干很有用
  2. Linux文件内容永久显示行号
  3. 计算机内存损坏,电脑内存条损坏的原因
  4. Java线程池详细介绍与使用
  5. Mac 安装mysql8.0
  6. jQuery实现ajax轮询
  7. leetcode刷题 638大礼包
  8. Python操作PDF-文本和图片提取(使用PyPDF2和PyMuPDF)
  9. shell 编程arry数组
  10. 实现LED字幕左右移动函数