Algorithms学习笔记-第二章 排序

#第二章 排序

标签(空格分隔): Algorithms学习笔记


先导:排序算法的程序结构

  • 排序类算法的小模板,(所有例子默认以 由小到大 为目标)
public class Example{
    
    public static void sort(Comparable[] a){ 
        //不同算法的具体代码写在这个方法里
    }
    
    //less()方法用于判断两个值的大小关系
    private static boolean less(Comparable v, Comparable w){
        return v.compareTo(w) < 0; //若compareTo函数小于0,说明v小于w
    }
    
    //交换位置的方法exch()
    private static void exch(Comparable[] a, int i, int j){ 
        Comparable t = a[i]; 
        a[i] = a[j]; 
        a[j] = t; 
    }
    
    public static void main(String[] args){
        //主函数,控制整个程序的运行
    }
}

把每一个算法写成一个类,方法都写成静态方法,以后在使用的时候,就可以在main()函数中,直接使用 example.sort();来应用不同的算法了。

2.1 初级排序算法

选择排序,插入排序,希尔排序

冒泡排序

友情客串

/**
* 冒泡排序
* 比较相邻的元素。如果第一个比第二个大,就交换他们两个。  
* 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。  
* 针对所有的元素重复以上的步骤,除了最后一个。
* 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。 
* @param numbers 需要排序的整型数组
*/
    public static void bubbleSort(int[] numbers)
    {
        int temp = 0;
        int size = numbers.length;
        for(int i = 0 ; i < size-1; i ++){
            for(int j = 0 ;j < size-1-i ; j++){ 
            //对当前无序区间score[0......length-i-1]进行排序(j的范围很关键,这个范围是在逐步缩小的)
                if(numbers[j] > numbers[j+1]){  //交换两数位置
                    temp = numbers[j];
                    numbers[j] = numbers[j+1];
                    numbers[j+1] = temp;
                }
            }
        }
    }

选择排序

矬子里拔大个,一个一个摆好

public class Selection{
    
    public static void sort(Comparable[] a){
        int N = a.length; // array length
        for (int i = 0; i < N; i++){
            int min = i; // index of minimal entr.
            for (int j = i+1; j < N; j++){   
            //j应该从i+1开始,因为j与i相等时,less()的比较就成了一个值和自己相比
                if (less(a[j], a[min])){
                    min = j;
                }
                exch(a, i, min);    //找出最小的值之后,把它排到前面去
            }
        }
    }
    
    /***** 省略辅助函数 *****/
}

插入排序

你打扑克时,整理手中牌的排序方法:
从左到右依次扫描卡牌,看各张牌是否应该插进它左侧的区域内
注意:要确保扫描到某一张牌时,其左侧已经是有序的了,才能正确的插入

public class Insertion{
    public static void sort(Comparable[] a){
        int N = a.length;
        for (int i = 1; i < N; i++){    //给个元素一次机会
            for (int j = i; j > 0 && less(a[j], a[j-1]); j--){
            //每次机会中,这个元素经历多次对比,如果与左侧相邻位置存在逆序,就交换
                exch(a, j, j-1);
            }
    }
    
    /***** 省略辅助函数 *****/
}

**xbb:选择排序和插入排序是两个很基础的排序方法,在写算法时,应该有“两层嵌套循环”的直觉。
第一层是因为,我们的思路使得我们有必要遍历所有的元素来执行某一种
“可以让其跑到正确位置上”**的操作;
第二层是因为,当我们思考这种 “可以让其跑到正确位置上” 的操作的时候,好像总是需要以某种规律不断地进行比较,一次又一次的比较下去。

**xbb: **冒泡排序和插入排序在内层的操作方式上,有一种“方向相反”的感觉(冒泡往后冒,插入往前插)。而由于方向的不同,二者的差别还体现在“卡住”之后的动作上。冒泡排序是无差别的识别相邻两个元素之间的大小的,如果找到了一个较大的值,那么它会一直向后冒泡,冒到它后面的比他大(冒不动了)的情况,而程序将会选择转而携带后面那个大的继续冒泡。而插入排序的方向是向前的,由于外层的“扫描顺序”也是由前向后的,所以插入排序中,只会携带当前位置的元素往前插队,能查到哪算哪,一旦插不动了,因为前面已经是局部有序的了,所以也不会转而继续携带其他元素往前插了。

希尔排序

插入排序的动态进化版本

想象一个非常庞大的数组,一个很小的值 a 在非常靠右的位置,这时候,依靠插入排序一个一个位置地把它挪到左边来就显得有些尴尬了。

这时候,把整个数组看成是一堆间隔为 h 的数组错位叠加组成的数列。
在这些子数组中使用插入排序的话,每次都可以把一个元素挪n个数这么远,这样一来,即便 a 在很靠右的位置,也能大踏步的挪到一个整体看来差不多的位置上去,这就 happy 了。
当然 h 越大,元素移动的步伐越大,但是一次同时,毫无疑问的,精准度也就越低了。

那么,思路就有了,我们逐次减小 h 的值。h很大时,帮助我们大步流星地搬运元素到一个差不多的位置;h慢慢减小时,帮助我们以更为细致的标准整理元素的位置;直到最后 h 减小到“1”时,我们所做的事情就相当于:在一个每一个元素都已经在其差不多的位置上的数组中,执行一次插入排序。(可以想象,这时很多元素可能都已经不再需要调整位置了,多 happy)

在这里h由大到小的变化并不是随意来的,当其遵循不同的序列的时候,会直接影响到希尔排序的性能。然而哪个序列最好并没有绝对的定论。在这里我们使用 y = 3x + 1,x=0,1,2,3,4,5…(性能比较理想)这个序列。

public class Shell{
    public static void sort(Comparable[] a){
        int N = a.length;
        int h = 1;
        while (h < N/3){
            h = 3*h + 1; // 1, 4, 13, 40, 121, 364, 1093, ...
        }
        
        //对这些“子数组”进行插入排序,并逐渐减小h到1
        while (h >= 1){
            for (int i = h; i < N; i++){    //各个子数组的所有元素的集合,等同于大数组的所有元素的集合
                for (int j = i; j >= h && less(a[j], a[j-h]); j -= h){//0<j<h时,循环中代码a[j-h]就越界了
                    exch(a, j, j-h);
                }
            }
            h = h/3;
        }
        
    }
        
/***** 省略辅助函数 *****/
}

**xbb:**上面两种循环代码的不同,是由于思维方式的不同。按照之前的解释,我们一直强调“子数组”这样一个概念,所以首先要区分不同的子数组的起点,再按照不同的起点遍历子数组,再操作每个元素的比较,形成了三层的循环。
现在,让我们尝试淡化“子数组”这一概念。之前之所以划分子数组,是因为想获得元素交换时,更长的移动距离。本着这一目的,不妨把各个子数组的插入排序,理解为:**对大数组进行的,交换距离为h的,插入排序。**事实的确如此,先把大数组分成子数组,再遍历各个子数组,等同于遍历大数组。所谓的“子数组”只是为了引导思维的一个临时说法。
实际的情况是:我们为了获得更大的移动距离和更少的交换次数,进行了一种“可变移动距离的插入排序”,然后,遵循一定的规则,不断的减小这个“移动距离”,一直到“h=1”。
在 h 比较大时,我们享受其 “远距离运输能力” 带来的效率;当 h 较小时,我们享受其 “精确排序能力” 带来的准确性。
由于插入排序在“部分有序”的情况下有很好的性能,而以每个“移动距离”进行插入排序时,都为下一步的插入排序做了铺垫,因而造就了希尔排序的优势。

2.2 归并排序

体育老师要排运动会队型。两个班的孩子都已经知道自己个头在班里的相对顺序,都已经矮个在前站好,等待老师差遣。
老师每次比较两个班排头的孩子,挑最矮的出来,让他站到大队型里。
过了一会,大队型就排好了。
**PS:**听上去好像只用了一步,呵呵,现在告诉你个小秘密:其实老师是从单个孩子开始比较的,然后俩俩一队,然后四个四个一队······然后终于到了整个班一队,最后的最后,才是上面说到的那一步。

先导:原地归并的抽象方法

将两个小的有序数组,合并为一个更大的有序数组(体育老师的最后一步)

有一个数组a[first…mid…last…],子数组 a[first…mid] 与 a[mid…last] 各自有序,现将这两个子数组归并为一个新的有序数组,并放在 a[lo…hi] 中。

    // !!!注意 merge 是【包前包后】的
    public static void merge(Comparable[] a, int lo, int mid, int hi){
        Comparable[] aux = new Comparable[a.length];
        //直接指向同一个数组是不行的,这样会错乱掉的,应该创建一个新的数组
        int i = lo, j = mid+1;
        for (int k = lo; k <= hi; k++){   
            aux[k] = a[k];
        }
        
        for (int k = lo; k <= hi; k++){
            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++]; }
        }
    }

自顶向下的归并排序

首先,我想通过归并将这段数组排序,那么我就要使这个数组的左右两瓣都是有序的。
那么对于每一个小瓣来说,我想通过归并将这段数组排序,那么我就要使这个小瓣的左右两瓣都是有序的。
。。。
如此下去,逐级细分,直到,对于一个长度为 2 的子数组来说,它的左右两瓣各只有一个元素,直接通过归并中的判断就可以将这个长度为 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);
    }
    
    private static void sort(Comparable[] a, int lo, int hi){
        //对于一个递归程序,必须要充分考虑边界条件
        if (hi <= lo) { return; } //这里在首尾相等时就不再执行递归方法,保证了最基础的merge()在两个元素之间进行
        int mid = lo + (hi - lo)/2;
        sort(a, lo, mid); //最后一次(基础操作)时直接return了
        sort(a, mid+1, hi); //最后一次(基础操作)时直接return了
        merge(a, lo, mid, hi); //最后一次是merge两个元素
    }
    
    public static void merge(Comparable[] a, int lo, int mid, int hi){
        int i = lo, j = mid+1;//前半段的位置指示变量 与 后半段的位置指示变量
        for (int k = lo; k <= hi; k++){ aux[k] = a[k]; }//把 a 中的数据复制到 aux
        for (int k = lo; k <= hi; k++){
            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++]; }
        }
    }
}

关于辅助数组的使用:再看整个自顶向下的归并,会发现每一次 merge 都会执行两个操作,首先把范围内的数据复制到辅助数组 b 中,然后在基于 b 中的元素,进行 merge,用原数组 a 来承接结果。

并且辅助数组 b 的声明在方法外,作为成员变量,但是初始化需要 a.length,所以实在方法内。

关于递归

从前有个山,山里有个庙,庙里有个老和尚,他在讲故事,他说:“从前有个山,山里有个庙······”
终于,在某一个故事里的那个“老和尚”忍不住了:“前面这些渣渣,只懂得推脱下去,看老夫来实实在在的给你们讲一个故事”······

从程序的结构上说,所谓递归,就是一个函数,直接或间接的调用了他自己。
从思路上说,在我们讲述上面这个故事时,会发现,在讲到“他说:”之后,当前的状态满足了再一次触发这一操作的起始状态。
从数学的角度来说,可以类比于数学归纳法

**xbb:**递归与数学归纳法的那些事
在高中数学题目中,尤其是解决证明题时,总是遇到数学归纳法。
首先让我们再来回顾一下:当一个问题很庞大,似乎无从下手时,我们首先在一般情况中给出一种允诺,这个事情只要对于n是成立的,那么对于n+1也会是成立的。然后在找出对于一个起始数字(比如0或1)来说,条件确实满足。按照给出的允诺,这个事情,以一个边界上的特例,和一个逐级递推的证明关系为基础,得到了完全的证明
递归的思路大概与这个思路差不多:当一个问题很庞大,需要非常多步的操作,且每一步都是相同的操作,我们对每一步给出一种允诺,这一步执行的过程中,正好可以产生执行下一步所需的条件。这样一来,每一个步骤的执行都引导着下一个步骤的启动,一个拖一个,全都没有落到实处,程序就相当于被架空了。所以我们也需要一个结束的条件:在操作被逐级下放的过程中,必须存在一个步骤,能够不再推脱到下一步,而是切实的做一些事情,终止这种空洞的下放,这样它才能作为基础。而只有存在这样的基础,整个程序的逐级下放得到反溯,递归才具有了实际意义。


xbb:递归与栈
思考一下递归的方式与 “后进先出” 的栈,是否有异曲同工的作用呢?(事实上,程序在运行的时候是确实有“调用栈”这种东西存在的)实际上递归代码定义了一个顺序,在每一层中,递归代码之前的代码,可以看作是 “先到先运行” ,而 递归代码之后的代码 则是 “后到达的反而先运行”,可以实现类似于栈的功能。
但是,递归需要系统缓存函数的调用信息,这将会受到资源占用的限制,一般情况来讲,使用栈来存储一下数据,更为稳妥。


xbb:如何编写一个递归程序
思考递归程序的运行顺序是一个奇妙的过程。
其实单纯讲程序的顺序是很好追溯的,因为电脑无非就是无脑扫描每一行代码。
单纯理解代码的意图似乎也还可以,毕竟可以忽略跳入下一个步骤,直接以某一个步骤为基础来看代码的话,也比较容易明白它在说什么。
难的是,如何把人的意图准确的转化成机器的执行顺序。机器和人的思维是有区别的,一开始就试着以“人肉编译器”的角度来思考绝对会陷入不知所云的灾难。
这里给出一种尝试的方法:
由于递归在每一步的操作都相同,不妨**(1)先考虑 一般情况,编写“中间某一步的操作”
然后
(2)考最后的“根基步骤” 究竟是在哪里需要限制,添加限制代码,使得程序能够在必要的时候,由一般步骤,转化为根基步骤。**


xbb:递归和循环的区别
递归和循环都具有**“重复操作”,而且都具有“结束条件”**,似乎有些共通之处。循环操作的两次循环之间,在时间上是相互独立的;但是递归操作中,却不一定存在这种独立性,而很可能存在更为复杂的时间关系(如上述代码,两行递归代码同时出现时,形成一种“等待”)


xbb:递归的优势与陷阱
递归可以做很多事情不假,但是递归是否是百无禁忌?那也不然,函数的每一次调用都会占用一定的资源,用来处理数据量不大的程序还好(二叉树、红黑树什么的,虽然数据量大,但是人家递归的次数是和层数挂钩的对不,对于一个平衡二叉树来说,层数可是2的指数,不用多少层就能容纳很多节点),但是如果想用递归来实现一个需要进行非常多次反复的操作,呵呵,等死吧,会出现 “调用栈溢出” 的情况的,这种时候,老老实实考虑循环。

好吧,如果实在还是怕搞错,你只当这个函数调用了另一个函数,而这两个函数的代码恰好摆在了同一个位置。

自底向上的归并排序

既然递归程序需要根基,何不直接从根基做起?

public class MergeBU{
    
    private static Comparable[] aux;
    
    public static void sort(Comparable[] a){
        int N = a.length;
        aux = new Comparable[N];
        //用动态定义merge的范围的方法,逐步扩大每次merge的规模
        for (int sz = 1; sz < N; sz = sz+sz){ // sz:子数组大小
            //每一轮新的归并时,size都变大一倍,来归并上一轮已经排好序的小块
            for (int lo = 0; lo < N-sz; lo += sz+sz){// lo:子数组下标
                merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));//最后的尾巴可能不够凑齐一个小组了
            }
        }
    }
    
    //merge() 还是原来的 merge()
    public static void merge(Comparable[] a, int lo, int mid, int hi){
        int i = lo, j = mid+1;//前半段的位置指示变量 与 后半段的位置指示变量
        for (int k = lo; k <= hi; k++){ aux[k] = a[k]; }//把 a 中的数据复制到 aux
        for (int k = lo; k <= hi; k++){
            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++]; }
        }
    }
    
}

自底向上的过程中,merge涉及到的“子数组”都已经有序,merge可以无需等待,直截了当的进行归并,所以不采用递归的方式来设计程序,而是采用循环的方式。
自顶向下的归并排序中,重点在于:通过一层层递归的sort,来安排merge的顺序;
自底向上的归并排序中,重点在与:思考每次以不同的长度来划分小组,进行归并。

2.3 快速排序

还是体育老师排队形的故事。这次体育老师找一个“标准个头”的孩子。让其他孩子根据自己的个头,比标兵矮的,站一堆;比标兵高的,站另一堆。然后孩子们按照:矮堆+标兵+高堆站好。
对“矮堆”和“高堆”也按照同样的方法,先找标兵,然后分堆站。。。
逐级递推下去,很快,队形就排好了。

快速排序的基本结构

public class Quick{
    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 j = partition(a, lo, hi); //切分函数,调整数据顺序,并得出切分点
        sort(a, lo, j-1); // Sort left part a[lo .. j-1].
        sort(a, j+1, hi); // Sort right part a[j+1 .. hi].
    }
}

xbb:快速排序与归并排序的比较
1、**首先,喜闻乐见,又是一个递归的程序。**来回想一下归并的时候:自底向上时,是在用不同的长度操作分组,所以用循环更合适;而自顶向下的时候,需要不断的对两个子数组进行排序,所以使用了递归。快排的递归和自顶向下归并的递归几乎是一样的出发点,都是对与子数组进行排序的操作。那快排可不可以不用递归呢?这就要思考切分函数,其实切分点不是固定的,是由切分函数在运行之后,找到的。这样一来意味着,切分函数需要一段数据作为输入,才能找到切分点,进行下一步。所以,自底向上的方式,似乎就走不通了;而自顶向下的方式,如果每一步结束之后,只有一个后续操作,也有转成循环的可能型,但是,偏偏排序的的操作对于两个子数组都是必须的,这样一来,好像是必须要使用递归了。
2、**虽有相似,但区别甚大。**从每一步的动作来看,两者大有相似之处,都是把这一步的数组分为两个子数组,然后调用子数组的排序。但是从思路上来看,归并排序和快排完全不同,似乎有一种“互补”的感觉。由于二者的排序理念不同,使得对于“子数组有序”这个条件的运用也相去甚远。
归并 要求两个子数组有序,是为了对二者进行归并的操作;
但是 快排 要求两个子数组有序,是因为这一步分割点已经找到,一旦子数组有序,这一步的排序就完成了。

一般的快排切分方法

两辆小火车相向而行,只有车头,不挂车厢,遇到一对逆序就交换它们,一直到两个车头相遇。

这个程序的结构,上面已经给出了,这里重点是给出切分方法的代码

public class Quick{
    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 j = partition(a, lo, hi); //切分函数,调整数据顺序,并得出切分点
        sort(a, lo, j-1); // Sort left part a[lo .. j-1].
        sort(a, j+1, hi); // Sort right part a[j+1 .. hi].
    }
    
    private static int partition(Comparable[] a, int lo, int hi){
        int i = lo, j = hi+1; // left and right scan indices
        Comparable v = a[lo]; //人为的挑选排头作为“标兵”
        while (true){
            while (less(a[++i], v)){
                if (i == hi){ break; }
                //从矮堆左边开始扫描,直到遇见比标兵高的孩子
            }
            while (less(v, a[--j])){
                if (j == lo){ break; }
                //从高堆右边考试扫描,直到遇见比标兵矮的孩子
            }
            if (i >= j) break;
            exch(a, i, j);//帮助这俩孩子站到该站的堆里,然后继续开始扫描的操作
        }
        exch(a, lo, j); //把排头的标兵插到两堆中间
        //(注意一定是j,因为上面的break条件是i>=j,也就是说j更小,所以换到排头也没问题)
        return j; //输出标兵的站位
    }
}

我原以为使用for循环就可以满足扫描的需求,但是没想到书上是用两个while循环分别扫描首尾。这样一来,两个扫描指针的动作就完全隔离开了,这样才能真正找到一对逆序(虽说是一对,但是也不一定就是对称位置的,所以指针相互隔离才是上策)也就解决了之前我遇到的,切分点不能动态生成的问题。
另外,先指定标兵,然后与标兵比较,最后,再将标兵交换到指定位置(而不是像我写的那个一样,比较两个指针位置元素的值)

三向切分的快速排序

从前有一个收割拖拉机,他开过一片玉米地的时候:玉米留在车里,玉米梗沿车码在后面,卷起的土甩在前面。

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;//( lowerT , greaterT , 二者最终融入等段)
        Comparable v = a[lo];//标兵
        while (i <= gt){
            int cmp = a[i].compareTo(v);
            if (cmp < 0) exch(a, lt++, i++);
            //因为标兵和lt是从同一个起点起步的,而且整个过程中都没有在前段引入“杂质”,
            //所以,a[0...lt-1]为小段,a[lt...i-1]为等段,这两段是无缝衔接的
            else if (cmp > 0) exch(a, i, gt--);
            else i++;
        }
        //标兵点自动融入 “等段”,所以最后无需 exch()
        
        //对子段继续排序
        sort(a, lo, lt - 1);
        sort(a, gt + 1, hi);
    }
}

在自己写实现的过程中,存在一个不知所措的问题:
判断判断扫描点的大小之后,交换是肯定要换了,那么交换之后,是否还要在一次扫描该点?如果是,好像就可以陷入无限循环;如果直接向下扫描,怎么确定换回来的值一定符合在这一位置的条件呢?
answer:
观察书中的实现,好像和自己的差别不大。其实有一个很重要的概念,我并没有弄清。
很容易设想,在扫描时,每次遇到“小值”,都应该把它换到“小段”,并且换回“小段边界”位置上的值,再把“小段边界”右移。
此时,如果我们让“小段边界”上的值,始终就是“标兵值”,那么向“小段”交换之后,扫描点位置的值,就会是“标兵值”,扫描点也就可以放心的右移了。
那怎么做到呢?在整个排序的过程开始时,“标兵”和“小边界”都是在最左端的位置的。所以如果我们让“小段”和“等段”直接衔接,即小段为[0…lt-1],等段为a[lt…i-1],是不是就可以了?
“等段”里的所有值都是“标兵值”,当然“小段边界a[lb]”作为“等段”的第一个值,也等于“标兵值”。
lb与标兵在初始时就重合,当时“扫描点”紧贴lb,中间没有“杂质”。而整个扫描执行过程中,“等段吞入”不会对左边的结构引入杂质,“小段交换”也可以看成是“等段+扫描点处的小值(即:a[lt…i-1,i])”做了一次180°的翻转,同样不会引入杂质。所以整个左段都是密闭与安全的。
自此,对于“小段”来说:交换与扫描点的右移,在一步之内,圆满解决。
(但是对于“大段”来说,就没有这么精密了。因为“大段”与“扫描点”之间是“待扫描区”,所以任何换回来的元素,都没有办法确认身份,必须要再次扫描。但是不必担心死循环,因为hb每次都会左移的,如果每次换回来的值都是“大值”,那就等待hb慢慢靠拢过来吧。)


三项切分交换次数多于普通的切分方式。
在含有多数重复元素的场合能够发挥更好的效能;
但是,在一般场合下,没有性能优势。

2.4 优先队列

运动会旗手需要五个高个的男同学,体育老师搬了桌椅板凳搞了个报名处。他要从陆续来报名的同学中,找五个最高的。

泛型优先队列的API

public class MaxPQ<Key extends Comparable<Key>>{
    MaxPQ()//构造函数
    MaxPQ(int max);//构造指定长度的优先队列
    MaxPQ(Key[] a)//用a[]中的元素创建一个优先队列
    void insert(Key v)//插入一个元素
    Key max()//返回最大的元素
    Key delMax()//删除并返回最大的元素
    boolean isEmpty()
    int size()
}

初级实现

从已知的知识中来寻找实现优先队列的办法好像并不算难,毕竟我们已经由好几种算法了。只要把盛装数据的数组排个序,好像已经不是什么特别难的事情了。你可以:
1、在插入元素的时候不排序,等到需要输出最大的时候再排序(惰性方法)
2、在插入元素的时候,就将数组排好序(想象一下,插入排序在向你微笑),要用的时候直接输出(积极方法)

先导:二叉堆

少年,故事要从很久很久之前说起。

1、一棵深度为 k 且有 2 k − 1 2^k-1 2k1 个节点的二叉树称为 满二叉树

多好理解,我的哥,满二叉树,就是“满了”的二叉树嘛

2、我们可以试着对一个满二叉树进行编号,就像写字的顺序一样,从上到下,每一行从左到右。这样拍好之后,每个节点的位置就和一个编号,一一对应了,你说好不好啊?
吼啊!那不如这样吧,不管这个二叉树满不满,只要它也是按照这个规则编号,我都支持,不妨叫做 完全二叉树 吧。

完全二叉树,它不一定是“满”的,但是就他已有的这些点来说,它是一棵高尚的树,一棵“完全”的树,一颗占满了靠前位置的树

3、现在找一棵树,我们让每一个节点,都大于等于它的两个子节点,我们就叫它是“ 堆有序 的树”。

堆,有序;大的一定在上面爽着,小的一定在下面压着。

可以想象,按照完全二叉树固定的排序规则,可以把一个堆有序的树存储到一个数组中。
二叉树每一层节点数都是上一层的二倍,对于一个编号为 k 的变量来说,它的上级就是 k/2 ,它的下级就是 2k2k+1,不信你画个二叉树试试。
ok,骚年,有了这个数组,后面就有的聊了。

先导:堆中使用的辅助方法

  • 比较与交换
    因为打算不需要辅助空间,所系不再输入一整个数组来作参数,而是只需要相关的位置参数就可以
//假使已有数组Key[] pq; (Key是从Comparble继承的,前面有)

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; 
}
  • 上浮(swim)
    对于存储数据的数组 a,a[0]不使用,从a[1]开始存储这个堆
    private void swim(int k){
        //元素很有可能不止一次上浮,swim应该提供一个“一次调用,直接搞定”的办法
        while (k > 1 && less(k/2, k)){
            exch(k/2, k);
            k = k/2;//k跟进到新的位置
        }
    }
  • 下沉(sink)
    private void sink(int k){
        while (2*k <= N){
            int j = 2*k;//较大的子节点(biggerChild)
            if (j < N && less(j, j+1)) { j++ };
            if (!less(k, j)) { break; }            
            exch(k, j);
            k = j;
        }
    }

基于堆的优先队列

所谓“官僚体制”,就是下属的能力绝对不能超过领导。

public class MaxPQ<Key extends Comparable<Key>>{
    private Key[] pq; // heap-ordered complete binary tree
    private int N = 0; //由于a[0]的预留,所以这里N既是编号,又是计数变量。
    public MaxPQ(int maxN){ 
        pq = (Key[]) new Comparable[maxN+1];
    }
   
    public boolean isEmpty(){ 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 void swim(int k){
        //元素很有可能不止一次上浮,swim应该提供一个“一次调用,直接搞定”的办法
        while (k > 1 && less(k/2, k)){
            exch(k/2, k);
            k = k/2;//k跟进到新的位置
        }
    }
    
    private void sink(int k){
        while (2*k <= N){
            int j = 2*k;//较大的子节点(biggerChild)
            if (j < N && less(j, j+1)) { j++ };
            if (!less(k, j)) { break; }            
            exch(k, j);
            k = j;
        }
    }
    
    private boolean less(int i, int j)
    private void exch(int i, int j)
}

堆排序

public class Heap {

    //Q:a[0]由于0的特殊性,不能参与到排序中来,最后还要为了a[0]全部移动数组吗?
    //A:虽然数组肯定是从0开始计数的,但是我们的编号可以从1开始,在最底层函数的实现中注意把编号对应成数组下标就可以
    
    public static void sort(Comparable[] pq) {
        int n = pq.length;//“pq.length”编号所指代的元素是“a.length-1”位置上的元素
        for (int k = n/2; k >= 1; k--){//为啥就到pq.length/2?因为要留一半在最底下垫底呀。。。
            sink(pq, k, n);
        }//堆构造完毕
        
        //开始进行排序整理,思路是找到最大的往后面放
        while (n > 1) {
            exch(pq, 1, n--); // 首先将堆顶的最大值放到最后,并缩小待排序的范围
            sink(pq, 1, n); // 然后找出当前处于待排序范围内的最大值,依次循环
            //“包前且包后”的范围
        }
    }
    
    //sink 允许的下沉范围,是由 n 来限制的
    private static void sink(Comparable[] pq, int k, int n) {
        while (2*k <= n) {
            int j = 2*k;
            if (j < n && less(pq, j, j+1)) j++;
            if (!less(pq, k, j)) break;
            exch(pq, k, j);
            k = j;
        }
    }

    //在比较函数的实现中,要把编号转换成数组下标
    private static boolean less(Comparable[] pq, int i, int j) {
        return pq[i-1].compareTo(pq[j-1]) < 0;
    }

    //在交换函数的实现中,要把编号转换成数组下标
    private static void exch(Object[] pq, int i, int j) {
        Object swap = pq[i-1];
        pq[i-1] = pq[j-1];
        pq[j-1] = swap;
    }
}

XBB:为什么没有在构造的时候就构造成最小堆呢?这是因为虽然对非底层的值都进行了sink,但是这种sink只能保证纵向上的大小关系,横向上的大小关系是没有办法得出的。集横向与纵向之大成者,唯有顶点(纵向sink能保证,横向只有他自己),所以依靠顶点来拿去最大值放入后部,才是正确的路线。

2.5 应用

2.5.1 将各种数据排序

  • 2.5.1.2
    我们使用的排序,本质上并没有改变数据本身的位置,而只是将它们的引用重新的排列。
    (电视机还是摆在那里的那几台电视机,遥控器重新摆了一遍),所以又叫“指针排序”。
  • 2.5.1.8、
    有多条数据,不同客户关联着按时间排序的不同订单。如果针对客户进行排序之后,各个客户的多条数据依然保持着按订单的时间顺序排列,那么就说“这个排序算法是稳定的”。
    实际上,要实现稳定的排序,需要额外消耗时间和空间。

2.5.2 不同排序算法的选择

直接上结论:快排是最快的通用排序算法。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值