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,引出了 分治算法,并且求解了两个问题。希望大家对分治理算法有更好的理解。