Java基础——递归实现归并排序和快速排序

每日正能量

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

递归

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) 

  • 7
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值