归并排序和快速排序的算法思想-分治算法

Merge Sort 和 Quick Sort两种都是O(nlogn)级别的排序算法,两种排序算法不仅更加高效的解决了排序问题,而这两种算法本身背后也隐藏着非常深刻的算法设计思想。下面我们来介绍相关的问题。

Merge Sort 和 Quick Sort都使用了分治算法的基本思想。

分治算法:顾名思义,分而治之。就是将原问题,分割成同等结构的子问题,之后将子问题逐一解决后,原问题也就得到了解决。

Merge Sort 和 Quick Sort都是将原问题分隔成两个子问题,与此同时Merge Sort 和 Quick Sort也代表了分治算法的两类基本思想。我们可以回忆一下Merge Sort和Quick Sort的实现。

Merge Sort在分这个问题上,没有做太多的考虑,就是一刀切的把整个数组分成两部分,然后递归的进行归并排序。但问题的关键是这样分完之后,如何把它们归并起来,这就是Merge归并的过程。而Quick Sort则是费了很大的功夫,放在如何分这个问题上。我们选定了一个标定点,然后使用Partition过程,将标定点移到了合适的位置。当它移动到合适的位置之后,我们才将整个数组分隔成了两部分,这样分完之后,我们在合的时候就不用做过多的考虑了,只需要一步一步的递归下去。

总结上面提到的分治算法的两种基本思想:

1.分割操作简单,合并操作复杂。归并排序

2.分割操作复杂,合并操作简单。插入排序

我们不应该把经典的算法实现和算法的设计思想拆开来看,对于经典的算法实现也不应该是仅仅的记忆或者是背诵,而应该思考前人是如何想到和设计出这种算法的。如果我们在学习每一个经典算法之后,多思考一下这个问题,相信无论是对于这个算法本身,还是对于算法的设计,都会有更深刻的理解。

下面我们来讨论两个直接从Merge Sort和Quick Sort所延伸出来的具体问题。

第一个问题:求一个数组中逆序对的数量。

下面我们来看一下,什么是逆序对呢?

对于这样一个数组,我们可以从中抽出一个一个的数字对,比如说我从中抽出了"2"和"3"这个数字对。我们可以看到"2"排在"3"的前面,并且"2"这个数字比"3"要小,这样的数字对就叫顺序对。相应的在数组中还有"2"和"1"这样的数字对,"2"这个数字排在"1"的前面,但是"2"这个数字比"1"要大,这样的数字对就叫逆序对。

数组中逆序对的数量,一个最典型的应用就是衡量这个数组的有序程度。

我们可以观察下图的两个数组。对于第一个数组它是完全顺序的,可以看出他的逆序对的数量为0。对于第二个数组它是完全逆序的,随意抽出一个数字对都是逆序对,此时这个数组中的逆序数量达到了最大值。

因此给定一个数组,可以通过计算数组中逆序对的数量,来衡量数组的有序程度。

下面来分析这个问题的解题思路:

1.暴力解法:考察数组中每一个数对,使用一个双重循环来比较每一个数对的顺序情况,如果是逆序对计数器就加1。

时间复杂度:O(n^2)

2.用Merge Sort的思路求逆序对的个数。

时间复杂度:O(nlogn)

解决这个问题关键在于归并的过程,归并的过程每一次考察两部分中相应的元素。比如说在这个例子中,我们同时看"2"和"1"的大小。

"1"比"2"小,就把"1"放到开始的位置。这里需要注意,由于两个子部分都已经排好序了,我们比较出"1"比"2"小,于此同时也就意味着,这个"1"比前面子数组中"2"以及"2"之后所有的元素都要小。换句话说"1"和前面子数组中"2"以及"2"后面所有的元素,都构成了一个逆序对。在这种情况下计数器加4。下标往后移看下一组元素。

下面我们就来考虑"2"和"4","2"比"4"要小,直接把“2”放到第二个位置。这里就隐含着"2"比"4"以及"4"之后所有的元素都要小,所以"2"和"4"以及"4"之后所有的元素,都组成顺序对,计数器值不变。下标继续往后移看下一组元素。

下面我们来看"3"和"4","3"比"4"小,"3"放上去。然后下标继续往后移,比较下一组元素,依次类推,直到比较完所有的元素。

我们可以看到,这样在归并排序的过程中,可以不一对一对的考虑问题,而能够直接考虑一组数据对,这样提高了算法的性能。这个算法过程和归并排序的过程是完全一样的,只是按照刚才的逻辑,再添加一个计数器。对于后半部分数组中的元素,归并过来的时候,相应的隐含着逆序对的个数进行叠加。这样就是最终求出逆序对的数量。

具体代码实现如下:

package com.zeng.sort;

import java.util.Arrays;

//求一个数组中逆序对的数量
public class ReversePairNum {

	/**
	 * @param args
	 */
	public static void main(String[] args) {
		// TODO Auto-generated method stub
           //测试
		ReversePairNum reverseParNum = new ReversePairNum();
		int[] arr = {4, 3, 2, 1};
		System.out.println("ReversePairNum: " + reverseParNum.reversePairNum(arr));
	}
	
	private int mPairNum = 0; //逆序对的数量

	/**
	 * 求数组中逆袭对的个数
	 * @param arr
	 * @return
	 */
	public int reversePairNum(int[] arr){
		mPairNum = 0;
		
		reversePairNum(arr, 0, arr.length - 1);
		
		return mPairNum;
	}
	
	/**
	 * 利用归并排序的思想,递归的实现查找逆序对的个数。
	 * 算法过程跟归并排序算法的过程一样,只是增加了一个逆序对的计数器
	 * 对数组arr[left...right]进行归并排序。
	 * @param arr
	 * @param left
	 * @param right
	 * @return
	 */
	private void reversePairNum(int[] arr, int left, int right){
		//递归到底的退出条件,子数组只有一个元素或为空
		if(left >= right){
			return;
		}
		
		int mid = (left + right) / 2;
		reversePairNum(arr, left, mid); //对前面部分的子数组做归并排序
		reversePairNum(arr, mid + 1, right); //对后面部分的子数组做归并排序
		merge(arr, left, mid, right);
	}
	
	/**
	 * 归并的过程
	 * @param arr
	 * @param left
	 * @param mid
	 * @param right
	 */
	private void merge(int[] arr, int left, int mid, int right){
		int[] tempArr = Arrays.copyOfRange(arr, left, right + 1);
		int i = 0, j = mid - left + 1, index = left;
		while(true){
			if(i <= mid - left && j <= right - left){
				if(tempArr[i] <= tempArr[j]){
					arr[index] = tempArr[i];
					index++;
					i++;
				}else{
					arr[index] = tempArr[j];
					index++;
					j++;
					
					//这时候需要逆序对计数器增长
					mPairNum += mid - left - i + 1;
				}
			}else if(i <= mid - left){
				arr[index] = tempArr[i];
				index++;
				i++;
			}else if(j <= right - left){
				arr[index] = tempArr[j];
				index++;
				j++;
			}else{
				break;
			}
			
		}
	}
}

测试用例和结果:

{1, 2, 3, 4} ---> ReversePairNum: 0

{1, 2, 4, 3} ---> ReversePairNum: 1

{1, 4, 3, 2} ---> ReversePairNum: 3

{4, 3, 2, 1} ---> ReversePairNum: 6

 

第二个问题:取数组中第n大的元素

比如说有一百万个元素的随机数组,数组没有排好序,此时的问题是数组中排名第一千位的元素是多少。

对于这个问题,我们再简化一下,最简单的情况就是取数组中的最大值或最小值。只要从头到尾遍历一遍数组,找出其中最大值或者最小值。时间复杂度:O(n)。

但是现在不是取最大值或最小值,而是取任意大小的值。比如一百万个元素的随机数组里,取第一千大的那个元素。

解法一:

先对整个数组进行排序,排好序之后,直接通过数组索引下标去取第n大的值。

时间复杂度:O(nlogn)

解法二:

使用Quick Sort的思路求数组中第n大的元素

时间复杂度:O(n)

求解思路:

先来看看Quick Sort的过程,每次都是找到一个标定点,然后将这个标定点移动到数组中合适的位置。所谓合适的位置,就是数组在排好序之后,该元素所在的位置。

比如说在下图的例子中,我们把"4"移动到了合适的位置,这个位置也是数组中第4个位置,那么整个数组第4大的元素就是4,第4大的元素前面部分都小于等于4,第4大的元素后面部分都大于等于4。

这时候如果我们要找第6大的元素,我们就完全不用管第4大元素前面部分,因为第6大元素一定在第4大元素后面部分。我们只需要递归的去求解后面部分的第2大元素。如果我们是要找第2大元素,只需要递归的去求解前面部分的第2大元素。

具体代码实现如下

package com.zeng.sort;

import java.util.Arrays;

public class FindSpecifyNumber {

	/**
	 * @param args
	 */
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		final FindSpecifyNumber findSpecifyNumber = new FindSpecifyNumber();
		int[] arr = {4, 6, 2, 3, 1, 5, 7, 8};
		System.out.println(findSpecifyNumber.findSpecifyNumber(arr, 5) + " ");
	}
	
	/**
	 * 查找数组中第n大的元素
	 * @param arr
	 * @param n
	 */
	public int findSpecifyNumber(int[] arr, int n){
		return findSpecifyNumber(arr, n, 0, arr.length - 1);
	}

	/**
	 * 递归查找第n大的元素
	 * @param arr
	 * @param n
	 * @param left
	 * @param right
	 */
	private int findSpecifyNumber(int[] arr, int n, int left, int right){
		//递归退出条件
		if(left >= right){
			return -1;
		}
		
		//partition的过程
		int p = partition(arr, left, right);
		if(p == n - 1){
			return arr[p];
		}else if(n - 1 < p){
			return findSpecifyNumber(arr, n, left, p - 1);
		}else{
			return findSpecifyNumber(arr, n, p + 1, right);
		}
	}
	
	private int partition(int[] arr, int left, int right){
		//随机取一个元素,作为标定点
		swap(arr, left, (int)(Math.random() * (right - left + 1)) + left);
		
		int v = arr[left];
		
		//定义两个区间,[left + 1, p]<=v,[p + 1, i)>v
		int p = left, i = left + 1; 
		while(i <= right){
			if(arr[i] > v){
				i++;
			}else{
				swap(arr, i, p + 1);
				p++;
				i++;
			}
		}
		swap(arr, left, p);
		
		return p;
	}
	
	/**
	 * 交换数组中的两个元素
	 * @param arr
	 * @param x
	 * @param y
	 */
	private void swap(int[] arr, int x, int y){
		int temp = arr[x];
		arr[x] = arr[y];
		arr[y] = temp;
	}
}

测试数组:{4, 6, 2, 3, 1, 5, 7, 8}

求第5大的数:5

求第1大的数:1

求第7大的数:7

 

这个算法的时间复杂度为什么是O(n)呢?这里简单的介绍一下。

我们可以设想一下,此时对于这个算法的时间复杂度来说,我们先用了n步partition操作,把整个数组一分为二,之后我们只需要在这两部分中选一部分继续操作就好了,所以第二部分是n/2步partition操作,以此类推第三部分是n/4,第四部分是n/8,直到最后一部分为1。

大家要注意,快速排序的过程,每一次并不保证是严格平分的,但是当我们采用了随机化的思路之后,可以保证期望值是平分的。下面时间复杂度的写法并不严格,但大体意思是这样的。对于下面的这个式子,它其实相当于一个等比数列求和,大家可以利用等比数列的求和公式来计算一下,这个求和当n趋近于无穷大的时候,求和为2n。

时间复杂度 = n + n/2 + n/4 + n/8 + 1 = O(2n) = O(n)

 

本篇我们由Merge Sort 和 Quick Sort,引出了 分治算法,并且求解了两个问题。希望大家对分治理算法有更好的理解。

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值