排序算法

  2007年,当总统候选人Baeack Obama访问Google公司时,Google的CEO Eric Schmidt问了Obama一个问题,对100万32位整数排序的最有效的方式是什么。Obama回答冒泡算法将不是好的选择。他的回答正确吗?我们先来考察各种排序算法,然后看看他是否正确。

  一、插入排序

  插入排序重复地将新的元素插入到一个排序好的子线性表中,直到整个线性表排好序。

  插入排序( Insertion sort)是一种简单直观且稳定的排序算法。

  插入排序的工作方式非常像人们排序一手扑克牌一样。开始时,我们的左手为空并且桌子上的牌面朝下。然后,我们每次从桌子上拿走一张牌并将它插入左手中正确的位置。为了找到一张牌的正确位置,我们从右到左将它与已在手中的每张牌进行比较,如下图所

  下图描述如何用插入排序法对线性表{49 38 65 97 76 13 27}进行排序。

 

  i=1:最开始,排好序的子线性表只包含线性表中第一个元素49。把未排序的第一个元素38插入到该子线性表中

  i=2:38比49小,49往后挪一个位置,将38插入到49前面,排好序的子线性表为{38,49},把未排序的第一个元素65插入到子线性表中

  i=3:排好序的子线性表为{38,49,65},将未排序的第一个元素97插入到子线性表中

  i=4:排好序的子线性表为{38,49,65,97},将未排序的第一个元素76插入到子线性表中

  i=5:排好序的子线性表为{38,49,65,76,97},将未排序的第一个元素13插入到子线性表中

  i=6:排好序的子线性表为{13,38,49,65,76,97},将未排序的第一个元素27插入到子线性表中

  i=7:现在整个线性表已经排好序了  

  这样,有序子线性表逐渐扩大,未排序的逐渐减少,直到整个线性表已经排好序,我们关心的是你怎么采取直接插入排序就将数据给插入进去了呢,所以我们对最后一个元素27具体是怎么一步步实现插入的进行详细的说明。

  首先:27比97要小,我们要插入到这个有序序列中,97就需要向后移动一个位置,向后移动一个位置就会把27给覆盖掉,所以我们需要把27进行暂存(currentElement,我们称为哨兵或者岗哨),然后我把有序序列最后一个元素和哨兵比,如果这个元素比岗哨小,很显然,岗哨就要在它后面,直接写回去就可以了,但是现在27是比97小的,所以j指针所指向的元素向后移动一个位置,接着j指针往前移,直到遇到比它小的元素(如果没有比岗哨小的元素,说明岗哨就在第一个位置)。

 step1:将27保存到一个临时变量currentElement

step2:将list[5]移到list[6]

 

step3:将list[4]移到list[5]

step4:将list[3]移到list[4]

 

step5:将list[2]移到list[3]

 

step6:将list[1]移到list[2]

 

 step7:将currentElement赋值给list[1]

这个算法可以描述如下:

  for(int i = 1; i < list.length;;i++){

    将list[i]插入已排好序的子线性表中,这样list[0...i]也是排好序的

  }

   为了将list[i]插入list[0...i],需要将list[i]存储在一个名为currentElement的临时变量中。如果list[i-1]>currentElement,就将list[i-1]移到list[i];如果list[i-2]>currentElement,就将list[i-2]移到list[i-1],依次类推,直到list[i-k]<=currentElement或者k>i(传递的是排好序的的数列的第一个元素)。将currentElement赋值给list[i-k+1].

 算法可以扩展和执行:

public static void insertioinSort(int[] list) {
  for(int i = 1; i < list.length; i++) {
    int currentElement = list[i];//未排序的第一个元素,称为哨兵
    int j;
    for(j = i - 1; j >= 0 && currentElement < list[j]; j--)
      list[j + 1] = list[j];
    list[j + 1] = currentElement;
  }
}

算法评价:

  插入排序重复地将新的元素插入到一个排序好的子线性表中,直到整个线性表排好序。在第k次次迭代中,为了将一个元素插入到一个大小为k的数组中,将进行k次比较来找到插入的位置,还要进行k次的移动来插入元素。使用T(n)表示插入排序的复杂度,c表示诸如每次迭代中的赋值和额外的比较的操作总数,则

    T(n) = (2+c) + (2*2+c)+...+(2*(n-1) +c)

       =2(1+2+...n-1)+c(n-1)

       =2[(n-1)n/2]+cn=n-n+cn-c

       =O(n2)

   因此,插入排序的时间复杂度为:

  思考

  简单插入排序的本质? 

    比较和交换

    序列中逆序的个数 决定交换次数。

    平均逆序数量为C(n,2) / 2 ,所以T(n)=O(n2)

  简单插入排序复杂度由什么决定?逆序个数 

  如何改进简单插入排序复杂度?

    • 分组,比如C(n,2)/2 > 2C((n/2),2)/2

    • 3,2,1有3组逆序对(3,1)(3,2)(2,1)需要交换3次。但相隔较远的 3,1交换一次后1,2,3就没有逆序对了。

    • 基本有序的插入排序算法复杂度接近O(n)

二、希尔排序(缩小增量法)

  希尔排序是简单插入排序算法的一种更高效的改进版本。

  基本思想:分割成若干个较小的子文件,对各个子文件分 别进行直接插入排序,当文件达到基本有序时,再对整个 文件进行一次直接插入排序。

  对待排记录序列先作“宏观”调整,再作“微观”调整。

    “宏观”调整,指的是,“跳跃式”的插入排序。(前面学习插入排序的时候,我们会发现一个很不友好的事儿,如果已排序的分组元素为{2,5,7,9,10} ,未排序的分组元素为{1,8} ,那么下一个待插入元素为1 , 我们需要着1从后往前,依次和10,9,7,5,2进行交换位置,才能完成真正的插入,每次交换只能和相邻的元素交换位置。那如果我们要提高效率,直观的想法就是一次交换 ,能把1放到更前面的位置,比如一次交换就能把1插到2和5之间,这样一次交换1就向前走了5个位置,可以减少交换的次数,这样的需求如何实现呢?接下来我们来看看希尔排序的原理)。

  排序原理:

  1.选定一个增长量d ,按照增长量d作为数据分组的依据,对数据进行分组;

  2.对分好组的每一组数据完成插入排序;

  3.减小增长量,最小减为1 , 重复第二步操作。

 算法示例:

   public static void shellSort(int[] list) {
//第一步:确定希尔增量,采用Hibbard’s增量序列(1,3,7.... 2^k -1)
        int ht = 1;
        //根据数组的长度确定增量值
        while(ht < list.length / 2)ht = ht * 2 + 1;
        //排序
        while(ht >= 1) {
            //完成每组的插入排序(1.找到待插入的元素,即为ht对应的元素)
            for(int i = ht;i < list.length; i++) {
         
int currentElement = list[i];     int j;     //将元素插入到有序序列中     for( j = i - ht; j >= 0 && currentElement < list[j];j -= ht) {       list[j + ht] = list[j];     }     list[j + ht] = currentElement; } //缩减希尔增量 ht /= 2; } }

  希尔排序特点 

  • 子序列的构成不是简单的“逐段分割”,而是将相隔某个增量的记录组成 一个子序列 
  • 希尔排序可提高排序速度,因为
    • 分组后n值减小,n²更小,而T(n)=O(n²),所以T(n)从总体上看是减小了
    • 关键字较小的记录跳跃式前移,在进行最后一趟增量为1的插入排序时, 序列已基本有序 
  • 增量序列取法
    • 无除1以外的公因子
    • 最后一个增量值必须为1 

  希尔排序的复杂度和增量序列是相关的

  【定理】使用希尔增量的最坏时间复杂度为 O( N2 ).

  〖Example〗A bad case: 

  Hibbard’s 增量序列: hk = 2 k  - 1 ---- 持续增量没有公共因子.

  使用Hibbard’s 增量的最坏时间复杂度 O ( N3/2 ).

  Conjectures: Tavg – Hibbard ( N ) = O ( N5/4 ).

  Sedgewick’s best sequence is {1, 5, 19, 41, 109, … } in which the terms are either of the form 9 * 4 i – 9 * 2 i + 1 or 4 i – 3 * 2 i + 1.

    Tavg ( N ) = O ( N7/6 ) and Tworst ( N ) = O ( N4/3 ).

  希尔排序算法本身很简单,但复 杂度分析很复杂. 他适合于中等 数据量大小的排序(成千上万的 数据量).

 

三、简单选择排序

  基本思想:从无序子序列中“选择”关键字最小或最大的记录,并将它加入到有序子序列中,以此方法增加记录的有序子序列的长度。

    假设排序过程中,待排记录序列的状态为:

  

  排序过程

  • 首先通过n-1次关键字比较,从n个记录中找出关键字最小的记录,将它与 第一个记录交换
  • 再通过n-2次比较,从剩余的n-1个记录中找出关键字次小的记录,将它与 第二个记录交换
  • 重复上述操作,共进行n-1趟排序后,排序结束 

 

  算法描述

public static void selectionSort(int[] list) {
        for(int i = 0; i < list.length - 1; i++) {
            int currentMin = list[i];
            int currentIndex = i;
            for(int j = i + 1; j < list.length; j++) {
                if(currentMin > list[j]) {//选择最小的元素
                    currentMin = list[j];
                    currentIndex = j;//
                }
            }
            if(currentIndex != i) {
                list[currentIndex] = list[i];
                list[i] = currentMin;
            }
        }
    }

  简单选择排序性能分析

    对 n 个记录进行简单选择排序,所需进行的 关键字间的比较次数为:

                                        

    移动记录的次数,最小值为 0, 最大值为3(n-1) 

    T(n)=O(n2)

  稳定性分析

  初始数据:3,3,1,4 

  第一趟排序: 1,3,3,4

  1,3,3,4

  1,3,3,4

  所以选择排序是不稳定排序

四、冒泡排序

  冒泡排序算法多层遍历数组,在每次遍历中连续比较相邻的元素,如果元素没有按照顺序排列,则互换他们的值。由于较小的值像“气泡”一样逐渐符向顶部,而较大的值沉向底部,所以称这种技术为冒泡排序(bubble sort)或下沉排序(sinking sort)。第一次遍历后,最后一个元素称为数组中的最大值。在第二次遍历后,倒数第二个元素成为数组中的第二大数。整个过程持续到所有元素都排好序。

  算法描述

  注意到如果在某次遍历中没有发生交换,那么就不必进行下一次遍历,因为所有的元素都已经排好序了。使用该特征可以改变上述算法:

public static void bubbleSort(int[] list) {
        boolean needNextPass = true;
        for(int i = 1; i < list.length && needNextPass; i++) {//如果在某次迭代中没有元素发生交换,不需要进行下一次的比较了
            needNextPass = false;
            for(int j = 0; j < list.length - i; j++) {
                if(list[j] > list[j + 1]) {
                    int temp = list[j];
                    list[j] = list[j + 1];
                    list[j + 1] = temp;
                    needNextPass = true;
                }
            }
        }
    }

  算法分析

  在最佳情况下,冒牌排序算法只需要一次遍历就能确定数组已排好序,不需要下一次遍历。由于第一次遍历的比较次数为n-1,因此在最佳情况下,冒泡排序的时间为O(n).在最差情况下,冒泡排序需要进行n-1次遍历。在第一次遍历需要n-1次比较;第二次遍历需要n-2次比较;依次进行,最后一次遍历需要1次比较。因此,总的比较次数为:,因此,在最差情况下,冒泡排序的时间复杂度为O(n2)。

五、归并排序

  归并排序将数组分为两半,对每部分递归地应用归并排序,在两部分都排好序后,对它们进行合并

算法描述

public static void mergeSort(int[] list) {
		if(list.length > 1) {
			//将数组拆分成两个子数组
			int[] firstList = new int[list.length / 2];
			System.arraycopy(list, 0, firstList, 0, list.length / 2);
			mergeSort(firstList);
			
			int secondSize = list.length - list.length / 2;
			int[] secondList = new int[secondSize];
			System.arraycopy(list, list.length / 2, secondList, 0, secondSize);
			mergeSort(secondList);
			
			//合并两个子数组
			merge(firstList, secondList, list);
		}
	}
	private static void merge(int[] list1,int[] list2,int[] temp) {
		int index1 = 0;
		int index2 = 0;
		int index3 = 0;
		while(index1 < list1.length && index2 < list2.length) {
			if(list1[index1] < list2[index2]) {
				temp[index3++] = list1[index1++];
			}else {
				temp[index3++] = list2[index2++];
			}
		}
		while(index1 < list1.length) {//如果list1中还有没有移动到临时数组中的元素
			temp[index3++] = list1[index1++];
		}
		
		while(index2 < list2.length) {//如果list21中还有没有移动到临时数组中的元素
			temp[index3++] = list2[index2++];
		}
	}

下图演示了归并排序。

   递归调用持续将数组划分为子数组,直到每个子数组只包含一个元素。然后,该算法将这些小的子数组归并为稍大的子数组,直到最后形成一个有序的数组。

算法实现

  合并两个有序数组

  current1和current2指向list1和list2中要考虑的当前元素,重复的比较list1和list2中当前元素。并将较小的元素移动到temp中,如果较小元素在list1中,current1和current3增加1,如果较小元素在list2中,current2和current3增加1。最后,其中一个数组中的所有元素都都被移动到temp中。如果list1中还有未移动的元素,就将它们复制到temp中,同理如果list2中还有未移动的元素,就将它们复制到temp中。如下图所示:

  算法评价

  时间复杂度:每一趟归并的时间复杂度为O(n), 总共需要归并 log2n趟,因而,总的时间复杂度为O(nlog2n)。
  空间复杂度:归并排序过程中,需要一个与表等长的存储单 元数组空间,因此,空间复杂度为O(n)。

归并排序并行版

  归并排序可以使用并行处理高效执行。JDK7引入了新的Fork/Join框架用于并行编程,从而利用多核处理器。它可以实现线程池中任务的自动调度,并且这种调度对用户来说是透明的。为了达到这种效果,必须按照用户指定的方式对任务进行分解,然后再将分解出的小型任务的执行结果合并成原来任务的执行结果。这显然是运用了分治法(divide-and-conquer)的思想。

import java.util.Arrays;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;

/**
* Fork/Join框架高效地自动执行和协调所有任务
*/
public class ParallelMergeSort { public static final int SIZE = 5_000_0000; public static void main(String[] args) { int[] list1 = new int[SIZE]; int[] list2 = new int[SIZE]; for(var i = 0; i < SIZE; i++) { list1[i] = list2[i] = (int)(Math.random() * 1000000000); } var startTime = System.currentTimeMillis(); SelectionSort.mergeSort(list1); System.out.println("归并排序所花时间:" + (System.currentTimeMillis() - startTime)); startTime = System.currentTimeMillis(); parallelMergeSort(list2); System.out.println("并行归并排序所花时间:" + (System.currentTimeMillis() - startTime)); } private static void parallelMergeSort(int[] list) { var action = new SortTask(list);
     //巨量的子任务可以在池中创建和执行 var pool
= new ForkJoinPool();
     //主任务执行完后将返回 pool.invoke(action); }
private static class SortTask extends RecursiveAction{ private static final long serialVersionUID = 1L; private int[] list; public SortTask(int[] list) { this.list = list; } @Override protected void compute() { if(list.length < 500) {//当问题分解到可求解程度时直接计算结果 Arrays.sort(list); }else { int[] firstHalf = new int[list.length / 2]; System.arraycopy(list, 0, firstHalf, 0, firstHalf.length); int[] secondHalf = new int[list.length - list.length / 2]; System.arraycopy(list, list.length / 2, secondHalf, 0, secondHalf.length);
          //执行主任务时,任务分为子任务,通过使用invokeAll调用子任务,该方法在所有子任务都完成后将返回(每个子任务又递归地分为更加小的任务) invokeAll(
new SortTask(firstHalf),new SortTask(secondHalf)); SelectionSort.merge(firstHalf, secondHalf, list); } } } }

快速排序

  实际使用中已知最快的算法,快速排序工作机制如下,该算法在数组中选择一个称为主元(pivot)的元素,将数组分为两部分,使得第一部分中的所有元素都小于或等于主元,而第二部分中的所有元素都大于主元。对第一部分递归地应用快速排序算法,然后对第二部分递归地应用快速排序算法。

算法描述

public static void quickSort(int[] list) {
    if (1ist.1ength > 1) {
        select a pivot;
        partition list into list1 and list2 such that
        all elements in list1 <= pivot and
        all elements in 1ist2 > pivot;
        quickSort(list1);
        quickSort(1ist2);
    }
}

 主元

  该算法的每次划分都将主元放在了恰当的位置。主元的选择会影响算法的性能。在理想情况下,应该选择能平均划分两部分的主元。

  • 错误的方法:  pivot= list[0 ]

    最糟糕的情况:  list[ ]有序或者逆序   quicksort= O( N2)

  • 安全的方法:  pivot = random select from list[ ]

    随机数生成很花时间(expensive)

  • 3者取中法:
      pivot = median ( left, center, right )
    这种方法能够排除序列有序的枢纽是最小或者最大值情况,实际运行时间能够减少约5%.

  为了简单起见,假定将数组中的第一个元素作为主元进行说明

排序过程:

  • 对list[first.....last]中记录进行一趟快速排序 ,附设两个指针low和high,设划分元记录pivot=list[first]
  • 初始时令low=first+1,high=last
  • 首先从high所指位置向前搜索第一个小于pivot的记录,并和pivot交换(主元频繁参与交换时很不划算的,思考如何改进)
  • 再从low所指位置起向后搜索,找到第一 个大于pivot的记录,和pivot交换
  • 重复上述两步,直至low==high为止
  • 再分别对两个子序列进行快速排序,直到每个子序列只含有一个记录为止

算法实现

/**
 * 快速排序
 *
 */
public class QuickSort {
    public static void quickSort(int[] list) {
        quickSort(list,0,list.length - 1);
    }
    public static void quickSort(int[] list,int first,int last) {
        if(last > first) {
            //划分左右子数组,返回主元的索引
            int pivotIndex = patition(list,first,last);
            quickSort(list,first,pivotIndex - 1);
            quickSort(list,pivotIndex + 1,last);
        }
    }
    
    //partition list into list1 and list2 
    private static int patition(int[] list, int first, int last) {
        //选择主元元素pivot,为了便于理解,选择数组中的第一个元素作为主元元素
        int pivot = list[first];
        int low = first + 1;
        int high = last;
        while(high > low) {
            //从左侧查找第一个大于主元的元素
            while(low <= high && list[low] <= pivot)
                low++;
            //从右侧查找第一个小于主元的元素
            while(low <= high && list[high] > pivot)
                high--;
            //交换着两个元素
            if(high > low) {
                int temp = list[high];
                list[high] = list[low];
                list[low] = temp;
            }
        }
        while(high > first && list[high] >= pivot) high--;
        if(pivot > list[high]) {//判定是否交换主元
            list[first] = list[high];
            list[high] = pivot;
            return high;
        }
        return first;
    }
}

算法评价

  • 快速排序算法是不稳定的

     对待排序序列 49 49' 38 65, 快速排序结果为: 38 49' 49 65

  • 快速排序的性能跟初始序列中关键字的排列和选取的枢纽有关
  • 当初始序列按关键字有序(正序或逆序)时,性能最差,蜕化为冒泡 排序,时间复杂度为O(n2 )
  • 常用“三者取中”法来选取划分记录,即取首记录list[first].尾记录 list[last]和中间记录list[(first + last) / 2]三者的中间值为划分记录。
  • 快速排序算法的平均时间复杂度为O(nlogn)

请尝试用三者取中法完成快速排序,并编写程序与取第一个元素为枢纽的快速排序方法进行比较测试。然后仔细研究快排还可以做哪些改进!

桶排序

基数排序

外部排序

posted @ 2020-04-22 10:38  Tiger-Adan  阅读(1228)  评论(0编辑  收藏  举报