《算法》2.1~2算法进阶之路-排序

二、排序

排序算法类的模板

  • 大多数情况下,我们所实现的排序算法只会通过两个方法操作数据:**less()方法对元素进行比较,exch()方法将元素交换位置。exch()方法的实现很简单,通过Comparable接口实现less()方法也不困难。sort()方法**非常重要,它决定着算法执行所需要的时间,不同的算法对sort()有不同的实现,sort()方法将是我们本节讨论的重点。
  • **注意:相信Comparable接口对大部分学习Java**的读者并不陌生。如果你从没有学习过Java,那么在学习这一章之前,你应该对Comparable接口有一定的学习与了解并且能使用Comparable接口构建类。在排序算法类的模板代码后面我会列出一个Comparable接口的使用示例。

示例代码:

public class Example {
	public static void sort(Comparable[] a) 
	{ /*具体算法具体实现*/ }
	public static boolean less(Comparable v, Comparable w) 
	{ return v.compareTo(w) < 0; }
	public static void exch(Comparable[] a, int i, int j) 
	{ Comparable t = a[i]; a[i] = a[j]; a[j] = t;}
	private static void show(Comparable[] a) 
	{ //在单行中打印数组
		for(int i=0; i<a.length; i++) 
			System.out.println(a[i] + " ");
		System.out.println();
	}
	public static boolean isSorted(Comparable[] a)
	{ //测试数组元素是否有序
		for(int i=1; i<a.length; i++)
			if(less(a[i], a[i-1])) return false;
		
		return true;
	}
	
	public static void main(String[] args) 
	{ //从标准输入读取字符串,将他们排序并输出
		String[] a = {"b","a","hello","world","you"};
		sort(a);
		assert isSorted(a);
		show(a);
	}
}

Comparable接口使用示例:

** Comparable: **
// 实现Comparable接口要覆盖compareTo方法, 在compareTo方法里面实现比较:
public class Person implements Comparable {
     private int age;
     public int compareTo(Person that) {
          if(this.age > that.age) return +1;
          if(this.age < that.age) return -1;
          return 0;                     // 返回age的比较结果.
     }
}
// 这时我们可以直接用 Collections.sort( personList ) 对其排序了.
  • 我们由示例可以知道实现**Comparable接口必须要覆盖compareTo方法,对对象进行排序的工作也基本是调用compareTo方法来完成的。我们接下来用v>w来表示v.compareTo(w)>0**这样的代码。一般来说,如果Vv无法比较或者两者其中一个是null,v.compareTo(w)将会抛出一个异常。此外,compareTo()必须实现一个全序关系,即: 自反性、反对称性、传递性

1.选择排序

  • **选择排序**的原理非常简单: 首先找到数组中那个最小的元素,其次,将它和数组第一个元素交换位置(如果第一个元素就是最小的元素那么它就和自己交换)。再次,在剩下的元素中找出最小的元素,与数组第二个元素交换位置,以此类推,知道将整个数组排序。

示例代码:

public class Selection {
	public static void sort(Comparable[] a) {
		//将a[]按升序排序
		int N = a.length;
		for(int i=0; i<N; i++) {
			//将a[i]和a[i+1]中最小的元素进行交换
			int min = i;
			for(int j=i+1; j<N; j++)
				if(less(a[j], a[min])) min = j;
			exch(a, i ,min);
		}
	}
	//less()、exch()、isSorted() 和 main()方法见“排序算法模板”
}

例图(初始序列:E A S Y Q U E S T I O N):
官网例图

  • 选择排序算法将是我们要了解的几个排序算法中效率最低的一个,因为它所需要消耗的时间复杂度总是平方级别的。
  • 选择排序有两个特点:
    1.运行时间与输入无关:因为无论你输入的序列是否有序,选择排序算法都会使用相同的遍历路径,所用的排序时间也是相同的。
    2.数据移动是最少的:每次交换都只会改变两个元素的值,不涉及元素移动。因此选择排序用了N次交换——交换次数和数组的大小是线性关系。我们将学习的其他算法都不具备这个特征(大部分的增长数量级都是线性对数或平方级别)

2.插入排序

  • 将元素插入到已经有序的元素中的适当位置。在计算机实现中,为了给要插入的数据腾出空间,我们需要将其余所有元素在插入之前都向后移动一位,这种算法叫做**插入排序**

代码示例:

/* 实现方法一 */
public class Insertion {	
	public static void sort(Comparable[] a) {
		//将a[]升序排列
		int N = a.length;
		for(int i=1; i<N; i++) {
			//将a[i]插入到a[i-1]、a[i-2]、a[i-3]...之中
			for(int j=i; j>0 && less(a[j],a[j-1]); j--) {
				exch(a, j, j-1);
			}
		}
	}
    //less()、exch()、isSorted() 和 main()方法见“排序算法模板”
}


/* 实现方法二 */
//实现较大元素右移一位只需要访问一次数组
//相当于把比它大的右移一位,然后空出来的那块就等于它,就是不使用less
    public static void sort(Comparable [] a) {
		for (int i = 1; i < a.length; i++) {
			Comparable temp=a[i];
			int j;
			for (j = i; j>0 && Example.less(temp, a[j-1]) ; j--) 
				a[j]=a[j-1];
			a[j]=temp;
		}
	}
  • 对于1到N-1之间的每一个i,将a[i]与a[0]到a[i-1]中比它小的所有元素依次有序地交换。在索引i由左向右变化的过程中,它左侧的元素总是有序的,所以当i到达数组的右端时排序就完成了。其中得到的数组都是部分有序数组。下面是几种典型的部分有序的数组:
    1.数组中每个元素距离它的最终位置都不远;
    2.一个有序的大数组接一个小数组;
    3.数组中只有几个元素的位置不正确;
  • 插入排序对这样的数组很有效,选择排序则不然。当倒置的数量很少时,插入排序很可能比本章中的其他任何算法都要快。

算法比较

  • 可以通过**SortCompare类来检测(在下面给出)。他会使用由命令行参数指定的排序算法名称所对应的sort()方法**进行指定次数的实验(将指定数组的大小排序),并打印出所观察到的各种算法的运行时间比例。
import java.util.Random;

public class SortCompare {
	public static double time(String alg, Double[] a) {
		Stopwatch timer = new Stopwatch();
		if(alg.equals("Insertion")) Insertion.sort(a);
		if(alg.equals("Selection")) Selection.sort(a);
		if(alg.equals("Shell")) Shell.sort(a);
		if(alg.equals("Merge")) Merge.sort(a);
		if(alg.equals("Quick")) Quick.sort(a);
		if(alg.equals("Help")) Help.sort(a);
		return timer.elapsedTime();
	}
	
	public static double timeRandomInput(String alg, int N, int T) {
		//使用算法alg将T个长度为N的数组排序
		double total = 0.0;
		Double[] a = new Double[N];
		for(int t=0; t<T; t++) {
			//进行一次测试(生成一个数组并排序)
			for(int i=0; i<N; i++) 
				a[i] = new Random().nextDouble();
			total += time(alg, a);
		}
		return total;
	}
	
	public static void main(String[] args) {
		String alg1 = args[0];
		String alg2 = args[1];
		int N = Integer.parseInt(args[2]);
		int T = Integer.parseInt(args[3]);
		double t1 = timeRandomInput(alg1, N, T);  //算法1的总时间
		double t2 = timeRandomInput(alg2, N, T);  //算法2的总时间
		System.out.printf("For %d random Doubles\n     %s is", N, alg1);
		System.out.printf(" %.1f times faster than %s\n", t2/t1, alg2);
	}
}
  • 这个用例会运行由前两个命令行参数指定的排序算法,对长度为N(由第三个参数指定)的Double型随机数组进行排序,重复T次(由第四个参数指定),然后输出总运行时间的比例。

3.希尔排序

  • 希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。
  • 希尔排序的思想是使数组中任意间隔为**h的元素都是有序的。这样的数组被称为h有序数组**。一个h有序数组相当于h个互相独立的有序数组交叉编制在一起组成的一个数组。
  • 想了解更多?由于文本与精力有限,敬请读者自行百度查找。

实现:

  • 实现希尔排序的一种方法是对于每个h,用插入排序将h个子数组独立地排序。但因为子数组是相互独立的,一个更简单的方法是在h个子数组中将每个元素交换到比它大的元素之前去(将比它大的元素向右移动一格),这一步其实很像是插入排序。
  • 只需要在插入排序的代码中将移动元素的距离由1改成h即可。这样希尔排序的实现就转化为了一个类似于插入排序但使用不同增量的过程。

示例代码:

public class Shell {
	public static void sort(Comparable[] a) {
		//将a[]按升序排序
		int N = a.length;
		int h = 1;
		while(h < N/3) h = 3*h + 1;  //1,4,13,40,121,364,1093,...
		while(h >= 1) {
			//将数组变为h有序
			for(int i=h; i<N; i++) {
				//将a[i]插入到a[i-h],a[i-2*h],a[i-3*h]...之中
				for(int j=i; j>=h && less(a[j], a[j-h]); j-=h) {
					exch(a, j, j-h);
				}
				h = h/3;
			}
		}
	}
	//less()、exch()、isSorted() 和 main()方法见“排序算法模板”
}
  • 可以看到,我们在插入排序中加入一个外循环来将h按照递增序列递减,我们就能得到这个简洁的希尔排序。

例图(初始序列:S H E L L S O R T E X A M P L E):
官网例图

例题2.1.14

出列排序。说说你会如何将一副扑克牌排序,限制条件是只能查看最上面的两张牌,交换最上面的两张牌,或是将最上面的一张牌放到这摞牌的最底下。

代码示例(仅供参考):

/*
* Author: wss
* Time: 2019/5/29 17:42
* IDE: MyEclipse
*/

public class a2_1_14_Test {
	
	public static void main(String[] args) {
		/*
		 * 第一步:执行n-1次a[n-1]与a[n-2]的比较之后,数组中最大的数就会保存在a[n-1]中。将a[n-1]放入最底部
		 * 第二步:执行n-2次a[n-1]与a[n-2]的比较之后,数组中第二大的数就会保存在a[n-1]中,此时最大的数保存在a[n-2]中。将顶部的两个数据依次放入最底部
		 * ... :以此类推,知道执行n-1次以上类似操作之后
		 */
		
		a2_1_14_Test test = new a2_1_14_Test();
		int[] a = {3,9,1,7,6,4,0,8,2,5};    //假设下标8和9是最上面两层,0是最下面一层
		a = test.sort(a);
		for(int i=0; i<a.length; i++) {
			System.out.println(a[i]);
		}

	}
	
    // 核心代码
    public int[] sort(int[] a) {
		int m = 1;              //已经有序的元素数量
		int n = a.length;
		while(m <= n) {
			for(int i=0; i<n-m; i++) {
				if(a[n-1] > a[n-2])
					exch(a, n-1, n-2);
				topToLast(a);   //顶部数据放入底部
			}
			for(int i=0; i<m; i++)
				topToLast(a);   
			m++;
		}
		return a;
	}
    
    public void topToLast(int[] a) {
    	int m = a.length;
    	for(int i=m-1; i>0; i--) {
    		exch(a, i, i-1);
    	}
    }
	
	public void exch(int[] a, int i, int j) {
		int t = a[i];
		a[i] = a[j];
		a[j] = t;
	}

}
 //less()、exch()、isSorted() 和 show()方法见“排序算法模板”

4.归并排序

  • 归并:将两个有序的数组归并成一个更大的有序数组。人们为这个操作发明的一种简单的递归排序算法:归并排序
  • 要将一个数组排序,可以先(递归地)将它分为两半分别排序,然后将结果归并起来。
  • 优点:它能够保证将任意长度为N的数组排序所需的时间和**NlogN**成正比。
  • 主要缺点:它所需的额外空间和N成正比。

归并排序示意图

4.1.原地归并的抽象方法

    public static void merge(Comparable[] a, int lo, int mid, int hi) {
		//将a[lo..mid]和 a[mid+1..hi]归并
		int i = lo,j = mid+1;
		
		for(int k=lo; k<=hi; k++)  //将a[lo..hi]复制到aux[lo..hi]
			aux[k] = a[k];
		
		for(int k=lo; k<=hi; k++)  //归并回到a[lo..hi]
			if     (i > mid)              a[k] = aux[j++];
			else if(j > hi)               a[k] = aux[i++];
			else if(less(aux[j], aux[i])) a[k] = aux[j++];
			else                          a[k] = aux[i++];
	}
  • 该方法先将所有元素复制到**aux[]**中,然后再归并回a[]中,方法在归并时(第二个for循环)进行了四个判断:左半边用尽(取右半边元素)、右半边用尽(取左半边元素)、右半边当前元素小于左半边的当前元素(取右半边元素)、左半边当前元素小于右半边的当前元素(取左半边元素)。

4.2.自顶向下的归并排序

  • 这是一个基于**原地归并的抽象实现的另一种递归归并,这也是应用高效算法设计中分治思想**的最典型的一个例子。这段递归代码是归纳证明算法能够正确地将数组排序的基础:如果它能将两个子数组排序,它就能通过归并两个子数组来将整个数组排序。
public class Merge {
	
	private static Comparable[] aux;  //归并所需的辅助数组
	
	public static void sort(Comparable[] a) {
		aux = new Comparable[a.length];  //一次性分配空间
		sort(a, 0, a.length-1);
	}
	
	public static void sort(Comparable[] a, int lo, int hi) {
		//将数组a[lo..hi]排序
		if(hi <= lo) return;
		int mid = lo + (hi - lo)/2;
		sort(a, lo, mid);      //将左半边排序
		sort(a, mid+1, hi);    //将右半边排序
		merge(a, lo, mid, hi); //归并结果(代码见“原地归并的抽象方法”)
	}
}
  • 要对子数组a[lo…hi]进行排序,先将它分为a[lo…mid]和a[mid+1…hi]两部分,分别通过递归调用将它们单独排序,最后将有序的子数组归并为最终的排序结果。

4.2.自底向上的归并排序

  • 实现递归排序的另一种算法是先归并那些微型数组,然后再成对归并得到的子数组。直至我们将整个数组归并在一起。这种实现方法比标准递归方法所需要的代码量更少。
public class MergeBU {
	private static Comparable[] aux; //归并所需的辅助数组 name()
	
	public static void sort(Comparable[] a){
		//进行lgN次两两归并
		int N = a.length;
		aux = new Comparable[N];       //sz子数组大小
		for(int sz=1; sz<N; sz=sz+sz)  //lo: 子数组索引
			for(int lo=0; lo<N-sz; lo+=sz+sz)
				merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
	}
}
  • 自底向上的归并排序会多次遍历整个数组,根据子数组的大小进行两两归并。子数组的大小sz的初始值为1,每次加倍。最后一个子数组的大小只有在数组大小为sz的偶数倍的时候才会等于sz(否则它会比sz小)。
  • 自底向上的归并排序比较适合用链表组织的数据。
  • 用自顶向下或是自底向上的方式实现任何分治类的算法都很自然。

习题2.2.10

  • **快速归并:实现一个merge()**方法,按降序将a[]的后半部分复制到aux[],然后将其归并回a[]中。这样就可以去掉内循环中检测某半边是否用尽的代码。注意:这样的排序产生的结果是不稳定的。

官方解答:

private static void merge(Comparable[] a, int lo, int mid, int hi) { 
   for (int i = lo; i <= mid; i++)
      aux[i] = a[i]; 
   
   for (int j = mid+1; j <= hi; j++)
      aux[j] = a[hi-j+mid+1];
  
   int i = lo, j = hi;        //初始i、j对应的值分别为左半部分和右半部分最小值
   for (int k = lo; k <= hi; k++) 
      if (less(aux[j], aux[i])) a[k] = aux[j--];
      else                      a[k] = aux[i++];
}

习题2.2.11

  • 改进:
    对自顶向下的归并排序进行三项改进:
    1.加快小数组的排序速度;
    2.检测数组是否已经有序;
    3.通过在递归中交换参数来避免数组复制;
  • 解决方案(仅供参考):
    1.当归并的两个子数组的总长度N<=4时,不使用使用**插入排序代替归并排序。
    2.再一次归并过程中,如果
    a[mid] <= a[mid+1],说明左半部分有序队列中的值均小于有半部分有序队列中的值,即对左半部分和右半部分来说整体有序,不需要再进行排序。
    3.将原来的类静态
    aux[]数组改为sort()方法中的局部函数**,在递归调用时作为参数传递。

示例代码(仅供参考):

/**
 * Author: wss
 * Time: 2019/5/31 16:30
 * IDE: MyEclipse
 * Content: 1、加快小数组排序速度
 *          2、检测数组是否已经有序
 *          3、通过在递归中交换参数来避免数组复制
 * */

public class SuperMerge {
	
	public static void main(String[] args) {
		
		SuperMerge smerge = new SuperMerge();
		String[] s = {"F","B","E","A","G","C","H","D","I"};
		smerge.sort(s);
		smerge.show(s);
		
	}
    
    public static void sort(Comparable[] a) {
    	//3.用于在递归中传递; 归并所需的辅助数组
    	Comparable[] aux = new Comparable[a.length];  //一次性分配空间
        sort(a, 0, a.length-1, aux);
    }

    //******SuperMerge主要变动方法******
    public static void sort(Comparable[] a, int lo, int hi, Comparable[] aux) {
        //将数组a[lo..hi]排序
        if(hi <= lo) return;
        int mid = lo + (hi - lo)/2;
        sort(a, lo, mid,aux);      //将左半边排序
        sort(a, mid+1, hi, aux);    //将右半边排序

        //2.检测数组是否已经有序; 如果已经有序,就不执行归并操作
        if(less(a[mid], a[mid+1])) return;
        else if(hi - lo < 4) {
        	//1.加快小数组排序速度
        	insertSort(a, lo, hi);
        } else {
        	merge(a, lo, mid, hi, aux); //归并结果(代码见“原地归并的抽象方法”)
        }
    }

    // 插入排序
    public static void insertSort(Comparable[] a, int lo, int hi) {
        //将a[]升序排列
        for(int i=lo+1; i<=hi; i++) {
            //将a[i]插入到a[i-1]、a[i-2]、a[i-3]...之中
            for(int j=i; j>0 && less(a[j],a[j-1]); j--) {
                exch(a, j, j-1);
            }
        }
    }
    
    public static void merge(Comparable[] a, int lo, int mid, int hi, Comparable[] aux) {
        //见自顶向下排序算法
    }
 //less()、exch()、isSorted() 和 show()方法见“排序算法模板”

习题2.2.14

  • 归并有序的队列。编写一个静态方法,将两个有序的队列作为参数,返回一个归并后的有序队列。

示例代码(仅供参考):

public static void sort(Comparable[] a, Comparable[] b) {
    int al = a.length, bl = b.length;
    int nl =  al + bl;
    int m = 0, n = 0;
    re = new Comparable[nl];
    for(int i=0; i<nl; i++) {
        if(m >= al) re[m+n] = b[n++];
        else if(n >= bl) re[m+n] = a[m++];
        else if(less(a[m], b[n])) re[m+n] = a[m++];
        else re[m+n] = b[n++];
    }
}

5.快速排序

5.1.快速排序

  • 特点:
    1.它是原地排序(只需要一个很小的辅助栈);
    2.将长度为N的数组排序所需的时间和NlgN成正比;
    我们已经了解过的排序算法都无法将这两个优点结合起来
    3.快速排序的内循环比大多数排序算法都要短小,这意味着它无论是在理论上还是在实际中都要更快;
  • 主要缺点:
    非常脆弱,在实现时要非常小心才能避免低劣的性能;
  • 快速排序:
    快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。**快速排序归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。在第一种情况下,递归调用发生在处理整个数组之前;在第二种情况下,递归调用发生在处理整个数组之后。在归并排序中,一个数组被等分为两半;在快速排序中,切分(partition)**的位置取决于数组的内容。

快速排序示意图
代码示例:

public class Quick {
	
	public static void sort(Comparable[] a) {
		//StdRandom.shuffle(a);  //打乱输入队列; 消除对输入的依赖
		sort(a, 0, a.length - 1);
	}
	
	public static void sort(Comparable[] a, int lo, int hi) {
		if(hi <= lo) return;
		int j = partition(a, lo, hi); //切分(请见快速排序的切分)
		sort(a, lo, j-1);             //将左半部分a[lo .. j-1]排序
		sort(a, j+1, hi);             //将右半部分a[j+1 .. hi]排序
	}
	
}
  • 快速排序递归地将子数组a[lo…hi]进行排序,先用partition()方法将a[j]放到一个合适的位置,然后再用递归调用将其他位置的元素排序。
  • 该方法的关键在于**切分,这个过程使得数组满足下面三个条件:
    1.对于某个
    ja[j]已经排定;
    2.
    a[lo]a[j-1]中的所有元素都不大于a[j]
    3.
    a[j+1]a[hi]中的所有元素都不小于a[j]**;
    我们就是通过递归调用切分来排序的。
  • 切分策略:
    一般策略是先随意地取a[lo]作为**切分元素**,即那个将会被排定的元素(每一轮切分总是能排定一个元素),然后从数组的左端开始向右扫描直到找到一个小于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[lo]的左子数组最右侧的元素(a[j])交换然后返回j即可

快速排序过程
快速排序的切分:

    public static int partition(Comparable[] a, int lo, int hi) {
		//将数组切分为a[lo..i-1], a[i], a[i+1..hi]
		int i = lo, j = hi + 1;       //左右扫描指针
		Comparable v = a[lo];         //切分元素
		while(true) {
			//扫描左右,检查扫描是否结束并交换元素
			while(less(a[++i], v)) if(i == hi) break;
			while(less(v, a[--j])) if(i == lo) break;
			if(i >= j) break;
			exch(a, i, j);
		}
		exch(a, lo, j);               //将v = a[j]放入正确的位置    
		return j;                     //a[lo..j-1] <= a[j] <= a[j+1..hi]达成
	}
  • 这段代按照**a[lo]**的值v进行切分(a[lo]自始至终都在数组下标为lo的位置)。当指针i和j相遇时主循环退出。在循环中,a[i]小于v时我们增大i,a[j]大于v时我们减小j,然后交换a[i]和a[j]来保证i左侧的元素都不大于v,j右侧的元素都不小于v。当指针相遇时交换a[lo]和a[j],切分结束(这样切分值就留在a[j]中了)。
  • 几点需要注意的方面:
    1.原地切分
    2.别越界
    3.保持随机性
    4.终止循环
    5.处理切分元素值有重复的情况
    6.终止递归
    切分示意图
    切分轨迹
  • **缺点一:**在切分不平衡时这个程序可能会及其低效。例如,如果第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,每次调用只会移除一个元素。这样会导致一个大子数组需要切分很多次。
  • **方案:**快速排序最多需要约N方/2次比较,但随机打乱数组能够预防这种情况。

5.2.快速排序算法改进

5.2.1.切换到插入排序
  • 对于小数组,快速排序比插入排序慢;
  • 因为递归,快速排序的**sort()方法**在小数组中也会调用自己
    因此,在排序小数组时应该切换到插入排序

改进示例:

// 将sort()中的语句:
if(hi <= lo) return;

// 改为:
if(hi <= lo + M) {
    Insertion.sort(a, lo, hi); 
    return; 
}
  • M: 转换参数**M的最佳值是和系统相关的,但是5~15**之间的任意值在大多数情况下都能令人满意。
5.2.2.三取样切分
  • 改进快速排序性能的第二个办法是使用子数组的一部分元素的中位数来切分数组。这样做的切分更好,但代价是需要计算中位数。将取样大小设为**3**并用大小居中的元素切分的效果最好。我们还可以将取样元素放在数组末尾作为“哨兵”来去掉partition()中的数组边界测试。
    使用了三取样切分和插入排序转换的快速排序
5.2.3.熵最优的排序
  • 实际应用中经常会出现含有大量重复元素的数组。这些情况下,我们实现的快速排序性能尚可,但还有巨大的改进空间。例如,一个元素全部重复的子数组就不需要继续排序了。
  • 一个简单的想法是将数组切分为三部分,分别对应小于、等于和大于切分元素的数组元素。这种切分实现起来比我们目前使用的二分法更复杂。
  • Dijkstra的解法如“三向切分的快速排序”中极为简洁的切分代码所示。它从左到右遍历数组一次,维护一个指针**lt使得a[lo..lt-1]中的元素都小于v,一个指针gt使得a[gt+1..hi]中的元素都大于v,一个指针i使得a[lt..i-1]中的元素都等于va[i..gt]中的元素都还未确定,如下图所示。一开始ilo相等,我们使用Comparable接口(而非less())对a[i]**进行三向比较来直接处理以下情况:
    1.a[i]小于v,将a[lt]和a[i]交换,将lt和i加一;
    2.a[i]大于v,将a[gt]和a[i]交换,将gt减一;
    3.a[i]等于v,将i加一。
  • 这些操作都会保证数组元素不变且缩小gt-i的值(这样循环才会结束)。另外,除非和切分元素相等,其他元素都会被**交换**。
    三向切分的示意图

三向切分的快速排序:

public class Quick3way {
	
	public static void sort(Comparable[] a) {
		//StdRandom.shuffle(a);  //打乱输入队列; 消除对输入的依赖
		sort(a, 0, a.length - 1);
	}
	
	private static void sort(Comparable[] a, int lo, int hi) {
		if(hi <= lo) return;
		int lt = lo, i = lo+1, gt = hi;
		Comparable v = a[lo];
		while(i <= gt) {
			int cmp = a[i].compareTo(v);
			if      (cmp < 0) exch(a, lt++, i++);
			else if (cmp > 0) exch(a, i, gt--);
			else              i++;
		} //现在a[lo..lt-1] < v = a[lt..gt] < a[gt+1..hi]成立
		sort(a, lo, lt-1);
		sort(a, gt+1, hi);
	}
	
}
  • 这段排序代码的切分能够将和切分元素相等的元素归位,这样它们就不会被包含在递归调用处理的子数组之中了。对于存在大量重复元素的数组,这种方法比标准的快速排序的效率高得多。
  • 三向切分的快速排序的可视轨迹:

三向切分的轨迹(每次迭代循环之后的数组内容)
三向切分的快速排序的可视轨迹

  • 对于标准的快速排序,随着数组规模的增大其运行时间会趋于平均运行时间,大幅偏离的情况非常罕见,因此可以肯定三向切分的快速排序的运行时间和输入的信息量的N倍是成正比的。
  • 对于包含大量重复元素的数组,它将排序时间从线性对数级降到了线性级别。
  • 这种对重复元素的适应性使得三分切向的快速排序成为**排序库函数**的最佳算法选择——需要将包含大量重复元素的数组排序的用例很常见。
  • 但这并不是快速排序发展的终点,因为有人研究出了完全不需要比较的排序算法!但快速排序的另一个版本在那个环境下仍然是最棒的,和这里一样。

6.优先队列

  • 许多应用程序都需要处理有序的元素,但不一定要求他们全部有序,或是不一定要一次就将它们排序。很多情况下我们会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,在处理当前键值最大的元素。
  • 在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素插入元素。这种数据类型叫做**优先队列**。
  • 我们会学习基于**二叉堆数据结构的一种优先队列的经典实现方法,用数组保存元素并按照一定条件排序,以实现高效地(对数级别的)删除最大元素插入元素操作**。
  • 通过插入一列元素然后一个个地删掉其中最小的元素,我们可以用优先队列实现排序算法。一种名为**堆排序**的重要排序算法也来自基于堆的优先队列的实现。
方法 功能
MaxPQ() 创建一个优先队列
MaxPQ(int max) 创建一个初始容量为max的优先队列
MaxPQ(Key[] a) 用a[]中的元素创建一个优先队列
void insert(Key v) 向优先队列中插入一个元素
Key max() 返回最大的元素
Key delMax() 删除并返回最大的元素
boolean isEmpty() 返回队列是否为空
int size() 返回优先队列中元素的个数

优先队列的调用示例

  • 现在我们来考虑一个问题:输入N个字符串,每个字符串都对应着一个整数,你的任务就是从中找出最大的(或是最小的)M个整数(及其关联的字符串)。
  • 要处理这个问题,只要我们能够高效地实现**insert()delMin(),下面的优先队列用例中调用了MinPQTopM**就能使用优先队列解决这个问题,这就是我们的目标。

一个优先队列的示例:

public class TopM {
	
	public static void main(String[] args) {
		//打印输入流中最大的M行
		int M = Integer.parseInt(args[0]);
		MinPQ<Transaction> pq = new MinPQ<Transaction>(M+1);
		while(StdIn.hasNextLine()) {
			//为下一行输入创建一个元素并放入优先队列中
			pq.insert(new Transection(StdIn.readLine()));
			if(pq.size() > M) 
				pq.delMin();  //如果优先队列中存在M+1个元素则删除其中最小的元素
		} //最大的M个元素都在优先队列中
	}
	Stack<Transection> stack = new Stack<Transection>();
	while(!pq.isEmpty()) stack.push(pq.delMin());
	for(Transection t : stack) StdOut.println(t);
}
  • 从命令行输入一个整数**M,从输入流中获得一系列字符串,输入流的每一行代表一个交易。这段代码调用了MinPQ()并会打印数字最大的M行。它用到了Transection类**构造了一个用数字作为键的优先队列。当优先队列的大小超过M时就删掉其中最小的元素。处理完所有交易,优先队列中存放者以降序排列的最大的M个交易,然后这段代码将它们放入到一个栈中,遍历这个栈以颠倒它们的顺序,从而将它们按降序打印出来。

6.1.初级实现

  • **数组实现(无序):**基于下压栈实现。insert()方法和栈的push()方法完全一样。删除元素类似于选择排序的内循环代码,将最大的元素和边界元素交换然后删除它。…
  • **数组实现(有序):**insert()方法和插入排序一样,删除操作与栈的pop()操作一样。
  • **链表表示法:**后续实现…
  • 使用无序序列是解决这个问题的惰性方法,我们仅在必要的时候才会采取行动(找出最大元素);使用有序序列则是解决问题的积极方法,因为我们会尽可能未雨绸缪(在插入元素时就保持列表有序),使后续操作更高效。
  • 实现栈或是队列与实现优先队列的最大不同在于对性能的要求。对于栈和队列,我们的实现能够在常数时间内完成操作;而对于优先队列,我们刚刚讨论的所有初级实现中,插入元素删除最大元素这两个操作之一在最坏的情况下需要线性时间内来完成。下面我们将讨论的是基于数据结构****的实现能够保证这两种操作都能更快地执行。

表:优先队列的各种实现在最坏情况下运行时间的增长级

数据结构 插入元素 删除最大元素
有序数组 N 1
无序数组 1 N
logN logN
理想情况 1 1

6.2.堆的定义

  • 数据结构**二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素。相应的,这些位置的元素又至少要大于等于数组中的另两个元素,以此类推。如果我们将所有元素画成一棵二叉树**,将每个较大元素和两个较小的元素用边连接就可以很容易看出这种结构。
  • 当一颗二叉树的每个结点都大于等于它的两个子结点时,它被称为**堆有序**。
  • 根节点是堆有序的二叉树中的最大结点。

二叉堆表示法:

  • 如果我们用指针来表示堆有序的二叉树,那么每个元素都需要三个指针来找到它的上下节点(父节点和两个子节点各需要一个)。
  • 如果我们使用完全二叉树,表达就会变得特别方便。要画出这样一颗完全二叉树,可以先定义下根节点,然后一层一层地由上向下、由左向右,在每个结点的下方连接两个更小的结点,直至将N个结点全部连接完毕。
  • 完全二叉树只用数组而不需要指针就可以表示。具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点的位置1,它的子结点的位置2和3,而子结点的子结点则分别在位置1、5、6和7,以此类推。
  • 二叉堆是一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级存储(不使用数组的第一个位置

一棵堆有序的完全二叉树

堆的表示

6.3.堆的算法

  • 我们用长度为N+1的私有数组**pq[]来表示一个大小为N的堆,我们不会使用pq[0],堆元素放在pq[1]至pq[N]中。在排序算法中,我们只通过私有辅助函数less()exch()**来访问元素,但因为所有的元素都在数组pq[]中,我们将会在后面使用更加紧凑的实现方式,不再将数组作为参数传递。
  • 堆的操作会首先进行一些简单的改动,打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复,我们称这个过程叫做**堆的有序化**。

比较与交换方法:

private boolean less(int i, int j) {
    return pq[i].compareTo(pq[j]) < 0;
}

private void exch(int i, int j) {
    Key t = pq[i];
    pq[i] = pq[j];
    pq[j] = t;
}
  • 当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。
  • 当某个结点的优先级下降(例如:将根节点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序。
  • 首先我们会学习如何实现这两种辅助操作,然后再用它们实现插入元素删除最大元素的操作。
6.3.1.由下至上的堆有序化(上浮)
private void swim(int k) {
    while(k > 1 && less(k/2, k)) {
        exch(k/2, k);
        k = k/2;
    }
}

由下至上的堆有序化(上浮)

6.3.2.由上至下的堆有序化(下沉)
private void sink(int k) {
    while(2*k <= N) {
        int j = 2 * k;
        if(j < N && less(j, j+1)) j++;
        if(!less(k, j)) break;
        exch(k, j);
        k = j;
    }
}

由上至下的堆有序化(下沉)

插入元素:

  • 我们将新元素加到数组末尾,增加堆的大小并让这个新元素上浮到合适的位置

删除最大元素:

  • 我们从数组顶端删去最大的元素并将数组的最后一个元素放到顶端,减小堆的大小并让这个元素下沉到合适的位置。

堆的操作

  • 下面的算法解决了我们在本节开始时提出的一个基本问题:它对优先队列API的实现能够保证插入元素删除最大元素这两个操作的用时和队列的大小仅成对数关系。
public class MaxPQ {
	private Key[] pq;    //基于堆的完全二叉树
	private int N = 0;   //存储于pq[1..N]中,pq[0]没有使用
	
	public MaxPQ(int maxN) 
	{ pq = (Key[]) new Comparable[maxN+1]; }
	
	public boolean issEmpty()
	{ return N == 0; }
	
	public int size()
	{ return N; }
	
	public void insert(Key v) 
	{
		pq[++N] = v;
		swim(N);
	}
	
	public Key delMax()
	{
		Key max = pq[1];
		exch(1, N--);
		pq[N+1] = null;
		sink(1);
		return max;
	}
	
	//辅助方法的实现请见本节前面的代码框
	private boolean less(int i, int j);
	private void exch(int i, int j);
	private void swim(int k);
	private void sink(int k);
}
  • 对于一个含有N个元素的基于堆的优先队列,插入元素操作只需不超过(lgN+1)次比较,删除最大元素的操作需要不超过2lgN次比较。
6.3.3.多叉堆
6.3.4.调整数组大小
6.3.5.元素的不可变性
6.3.6.索引优先队列
  • 在很多应用中,允许用例引用已经进入优先队列的元素是有必要的。做到这一点的一种简单方法是给每一个元素添加一个**索引**。另外,一种常见的情况是用例已经有了总量为N的多个元素,而且可能还同时使用了多个(平行)数组来存储这些元素的信息。此时,其他无关的用例代码可能已经在使用一个整数索引来引用这些元素了。

关联索引的泛型优先队列的API:

返回类型 函数名称 功能
IndexMinPQ(int maxN) 创建一个最大容量为maxN的优先队列,索引的取值范围为0至maxN-1
void insert(int k, Item item) 插入一个元素,将它和索引k相关联
void change(int k, Item item) 将索引k的元素设为item
boolean contains(int k) 是否存在索引为k的元素
void delete(int k) 删去索引k及其相关联的元素
Item min() 返回最小元素
int minIndex() 返回最小元素的索引
int delMin() 删除最小元素并返回它的索引
boolean isEmpty() 优先队列是否为空
int size() 优先队列中元素数量
  • 在一个大小为N的索引优先队列中,插入元素(insert)、改变优先级(change)、删除(delete)和删除最小元素(remove the minimum)操作所需的比较次数和logN成正比
  • 这段讨论中针对的是找出最小元素的队列。
6.3.7.索引优先队列用例
  • 下面调用了IndexMinPQ的代码Multiway解决了多向归并问题:它将多个有序的输入流归并成一个有序的输入流。如果有足够的空间,你可以把它们简单地读入一个数组并排序,但如果使用了优先队列,无论输入有多长你都可以把它们全部读入并排序。

使用优先队列的多向归并:

public class Multiway {
	
	public static void merge(In[] streams) {
		int N = streams.length;
		IndexMinPQ<String> pq = new IndexMinPQ<String>(N);
		
		for(int i=0; i<N; i++)
			if(!streams[i].isEmpty())
				pq.insert(i, streams[i].readString());
		
		while(!pq.isEmpty()) {
			System.out.println(pq.min());
			int i = pq.delMin();
			
			if(!streams[i].isEmpty()) 
				pq.insert(i, streams[i].readString());
		}
	}

	public static void main(String[] args) {
		int N = args.length;
		In[] streams = new In[N];
		for(int i=0; i<N; i++)
			streams[i] = new In(args[i]);
		merge(streams);
	}

}
  • 每个输入流的索引都关联着一个元素(输入中的下个字符串)。
  • 初始化之后,代码进入一个循环,删除并打印出队列中最小的字符串,然后将该输入的下一个字符串添加为一个元素。

6.4.堆排序

  • 我们可以把任意优先队列变成一种排序方法。将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将它们按顺序删去。
  • 堆排序可以分为两个阶段。在堆的构造阶段中,我们将原始数组重新阻止安排进一个堆中,这样会得到一个堆有序的完全二叉树;然后再下沉排序阶段,我们从堆中按递减顺序取出所有元素并得到排序结果。我们将使用一个面向最大元素的优先队列并重复删除最大元素。
6.4.1.堆的构造
  • 从右至左用**sink()**函数构造子堆。数组的每个位置都已经是一个堆的根节点了,sink()对于这些子堆也适用。如果一个结点的两个子结点都已经是堆了,那么在该结点上调用sink()函数可以将它们变成一个堆。这个过程会递归地建立起堆的秩序。开始时我们只需扫描数组中的一半元素,因为我们可以跳过大小为1的子堆(也就是叶子结点)。最后我们在位置1上调用sink()方法,扫描结束。
  • 用下沉操作由N个元素构造堆只需少于2N次比较以及少于N次交换。

堆排序

public static void sort(Comparable[] a) {
    int N = a.length;
    for(int k=N/1; k>=1; k--) 
        sink(a, k, N);
    while(N > 1) {
        exch(a, 1, N--);
        sink(a, N, N);
    }
}

堆排序的轨迹
堆排序:堆的构造(左和下沉排序(右))

6.4.2.下沉排序
  • 堆排序的主要工作都是在第二阶段完成的。这里我们将堆中最大元素删除,然后放入堆缩小后数组空出的位置。
  • 将N个元素排序,堆排序只需少于(2NlgN+2N)次比较(以及一般次数的交换)。

对排序的可视轨迹

6.4.3.先下沉后上浮

大多数在下沉排序期间重新插入堆的元素会被直接加入到堆底。Floyd在1964年观察发现,我们正好可以通过免去检查元素是否到达正确位置来节省时间。在下沉中总是直接提升较大的子结点直至到达堆底,然后使元素上浮到正确的位置。这种想法几乎可以将比较次数减少一半——接近了归并排序所需的比较次数(随机数组),但是这种方法需要额外的空间。

  • 堆排序在排序复杂性的研究中有着重要的地位,因为它是我们所知的唯一能够同时最优的利用空间和时间的方法——在最坏的情况下也能保证用~2NlgN次比较和恒定的额外空间。
  • 堆排序无法利用缓存。数组元素很少和相邻的其他元素进行比较,因此缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至是希尔排序。

至此,算法基础进阶就基本结束了,由于博主精力有限,后续的查找字符串章节就不在做记录,如果读者对这些算法感兴趣,可以去书店或网上商城购买《算法》这本书,真的很不错。谢谢大家阅读此文,后续我有可能会在学习后面的章节时做笔记来纪录知识,看精力吧,毕竟博主不是专门学习算法的啊。

声明:发表此文是出于传递更多信息之目的,并且做一些学习笔录是为了日后学习之用。文章大部分代码示例与文字内容均摘自《算法》一书。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与本我们(QQ:981086665;邮箱:[email protected])联系联系,我们将及时更正、删除,谢谢。

猜你喜欢

转载自blog.csdn.net/affluent6/article/details/91525982