Java实现九大内部排序

前言

排序,其实是一个我们从小就接触的东西,上小学的时候,课后习题就有过这样的题目,只是当时我们运用的自然排序,眼睛大概扫描一番,心里就出现答案。但是随着需要排序的数据越来越多,这种方式就显得不太适用。与此同时,计算机并不能像人一样,可以使用自然排序,为此科学家们设计了纷繁多的排序算法,这些算法主要利用了数据比较、数据特性以及数据结构等方式来实现,它们几乎都能在九大内部排序中有所涉及。这些算法都有它们自身的优势以及劣势,不管是复杂性、时空性、稳定性等。
如果不是为了学习数据结构和算法,大多数情况,我们根本都不需要自己编写排序算法,就拿Java来说,大多数涉及排序的问题,如果是数组形式,我都是交给Arrays里面的普通排序和并行排序,如果是列表形式,我也是交给Collections的排序方法来解决。其实这些库函数里面的排序方法,核心思想也是这几种内部排序,只是它们又做了很多优化措施,比如DualPivotQuicksort就是快排的一种优化,TimSort也是归并排序的一种优化。
不过在真正开始学习排序算法时,还是被它吸引了,虽然编写时,出现了很多问题,但最终解决的感觉还真的是爽的不行。所以我才萌发了写篇博客的意向,希望和大家多交流一番。
对于基本的内部排序算法,网上的资料汗牛充栋。本文不会过多介绍算法的原理,侧重点在排序算法的Java实现,实现过程中所需要注意的问题以及解决的方法。算法主要涉及冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、桶排序、基数排序以及堆排序。

冒泡排序

冒泡排序可以说是最基础的一种排序算法,被大家广为熟知。虽然经典,但其执行效率极低,实用性也较差。有兴趣的可以去知乎看看大家对它的看法,冒泡排序为什么会被看做经典,写入所有C语言的教科书?。它的代码如下:

public static void bubbleSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    for(int i = 0, limit = len - 1; i < limit; i++){
        for(int j = i + 1; j < len; j++){
            if(array[i] > array[j]){
                swap(array, i, j);
            }
        }
    }
}

对于swap函数,这里我稍微提一句,因为Java没有指针,所以当时学习的时候,还真的对这个swap函数费解半天,虽然写法都是和下面一般

public void swap(int[] array, int i, int j){
    int temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}

但总觉得它和C++的写法别扭,可能是因为C++用的指针,看起来简洁点吧。Java Puzzles这本书的谜题7,介绍了异或版本的swap,作者也分享了他自己对花哨代码的一点看法,还是挺有道理的。
虽然冒泡排序实现起来比较简单,但是我们也需要注意对参数的检验,再稍微注意一下两个for循环上下标的边界问题。

选择排序

选择排序算是冒泡排序的升级版,冒泡排序每次比较都需要swap,而选择排序是冒泡完一次后,才进行swap操作,稍微提升了点效率。代码如下:

public static void selectionSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    for(int i = 0, limit = len - 1; i < limit; i++){
        int min = i;
        for(int j = i + 1; j < len; j++){
            if(array[min] > array[j]){
                min = j;
            }
        }
        if(min != i){
            swap(array, min, i);
        }
    }
}

选择排序和冒泡排序原理差不多,也没有太多需要说的。

插入排序

插入排序是人类最自然的排序方法,就像斗地主一般,每次拿牌都是比较后再插牌,下次叫它斗地主排序,嘿嘿。它的代码如下:

public static void insertionSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    insertionSort(array, 0, len - 1);
}
private static void insertionSort(int[] array, int begin, int end){
    int len = array.length;
    if(begin < 0 || begin > end || end >= len){
        throw new IndexOutOfBoundsException();
    }
    for(int i = begin; i <= end; i++){
        int index = i;
        int target = array[i];
        while(--index >= 0 && array[index] > target){
            array[index + 1] = array[index];
        }
        array[index + 1] = target;
    }
}

这次分开写只是为了方便后面快排小数据时调用插排,在小数据排序算法中,插排的效率很高。因为后面的插排方法是在内部调用,其实可以省略参数的检验,但是以前看String源码时,里面对参数的检验十分严格,这里我也试试,也算是一次学习。并且String源码在处理数组时各种while、++、–,也是fashion的不行,我也算偷学了点。
其实通过代码我们可以看出,我们找插入点,是从后向前,一个值一个值找,并且我们应该了解到,前面的数据都是已经排序好了的,在排序好的数组中找东西(或位置),很自然的就会想到二分查找,因此上面的代码也可以优化为二分插入排序。代码如下:

public static void binaryInsertionSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    for(int i = 1; i < len; i++){
        int left = 0;
        int right = i - 1;
        int target = array[i];
        while(left <= right){
            int mid = (left + right) >>> 1;
            int midValue = array[mid];
            if(midValue > target){
                right = mid - 1;
            }else if(midValue == target){
                left = mid + 1;
                break;
            }else{
                left = mid + 1;
            }
        }
       for(int j = i - 1; j >= left; j--){
           array[j + 1] = array[j];
       }
       array[left] = target;
    }
}

对于二分那部分,我们一定要保证最终返回的插入索引处的值要大于等于目标值,这样才能保证后面的插排顺利完成。这里我们需要注意一下溢出的情况,有些时候我们取中间值喜欢如下操作:

int mid = (left + right) / 2;

当left和right比较小时,我们这样操作是没问题,但是我们int范围为-2147483648~2147483647,超出范围的会被截断,造成整型溢出。我们操作整型的加、减和乘时,一定要慎之又慎,时刻注意溢出的情况。如果能保证left和right都为正数,或者说保证right - left不溢出,其实取中间值,如下操作也是合理的。

int mid = left + ((right - left) >> 1);

Java算数运算符的优先级高于移位运算符,因此上面代码的括号不能省。我们在同时操作多个运算符时,一定要注意各运算符的优先级问题,不然出问题了,很难排错。

希尔排序

希尔排序是插排的优化版。插排在数据基本有序的情况下,执行效率非常高,如果是逆序的情况,他甚至退化成冒泡排序。希尔排序的改进点就是在执行直接插入排序操作之前,尽可能保证待排数组的有序。它通过选取合适的步长间隔,将待排序数组分成若干子序列,再对所有子序列执行插入排序,因为该排序算法的效率主要取决于选取的步长间隔,因此被称为最难分析执行效率的排序算法。具体步长间隔选取细节,可以参考维基百科的希尔排序。我一般选取3n + 1作为步长间隔,代码如下:
第一种

public static void shellSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int gap = 1;
    int gapLimit = len / 3;
    while(gap < gapLimit){
        gap = 3 * gap + 1;
    }
    while(gap >= 1){
        for(int i = gap; i < len; i++){
            int index = i - gap;
            int target = array[i];
            while(index >= 0 && array[index] > target){
                array[index + gap] = array[index];
                index -= gap;
            }
            array[index + gap] = target;
        }
        gap = (gap - 1) / 3;
    }
}

第二种

public static void shellSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int gap = 1;
    int gapLimit = len / 3;
    while(gap < gapLimit){
        gap = 3 * gap + 1;
    }
    while(gap >= 1){
        for(int i = 0; i < len; i++){
            for(int j = i + gap; len / gap >= 1; j += gap){
                int index = j - gap;
                int target = array[j];
                while(index >= 0 && array[index] > target){
                   array[index + gap] = array[index];
                   index -= gap;
                }
                array[index + gap] = target;
            }
        }
        gap = (gap - 1) / 3;
    }
}

第一种方法是从gap索引开始,对整个数据执行步长为gap的插排;第二种方法是从零开始对步长间隔gap的数组进行插排。
在我的测试环境里跑:

$ java -version
java version "1.8.0_111"  
Java(TM) SE Runtime Environment (build 1.8.0_111-b14)  
Java HotSpot(TM) 64-Bit Server VM (build 25.111-b14, mixed mode) 
$ 10000 random numbers (The data range is from 0 to 9999.)
use the first shellSort function to sort 10000 random numbers,cost 3
use the second shellSort function to sort 10000 random numbers,cost 140
$ 100000 random numbers  (The data range is from 0 to 99999.)
use the first shellSort function to sort 100000 random numbers,cost 17
use the second shellSort function to sort 100000 random numbers,cost 14398

如果我们在两个函数的while循环里面添加一个计数器,会发现两个函数移动数据的次数是相同,按理说两者执行时间应该相差不大。可是在实际测试中,随着数据量的增大,两者执行效率的差距越来越大。
当时这个问题还真让我束手无策,因为水平有限,我也就放在一边没有管它。等到我学习Java内存模型的CPU缓存部分时,我突然想到一套可以解释上面问题的理论。
因为while的次数相同,所以我将问题归结于两者对数组的取值上。众所周知CPU运行处理速度远远大于内存读写速度,为了加快读取速度,一般的CPU都会设有一级到三级不等的缓存,在缓存中的数据是内存中的一小部分,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先在缓存中调用。处理器从缓存中读取操作数,而不是从内存中读取,称为缓存命中。
此时,我们再观察两者算法,可以看到方法一执行插排的数据都比较紧密,数据都是在一个步长间隔之间,这些数据有很大概率能被缓存命中,而方法二中的第二个for循环,是对整个数组在步长间隔进行插排,数据的跨度比较大,而缓存的存储量本身就比较小,所以随着数组长度越大,数据的缓存命中率会越来越低,两者读取数据的效率也会随之出现较大差距。(这只是我个人理解,希望后续有人能提出其他的解释,我也学习学习!)
第一种方法我是修改的维基百科中希尔排序的伪代码,第二种方法我是参考的百度百科中希尔排序的Java版本,让我费解的是,百度百科关于希尔排序的伪代码和维基百科伪代码的原理一样,为什么后面的代码实现,却都是第二种情况。虽然两个函数执行的方式相同,运行效率却大大不同,所以以后在编写算法时还是应该考虑周全,可能这就是算法研究的乐趣所在吧。

归并排序

归并排序利用“归并”和“分治”思想对数组进行排序。根据具体实现,归并排序分为“从上往下”和“从下往上”两种方式。这种排序经常用来和快排比较,并且大多时候也是被快排吊打,但是在外部排序中,归并排序却是大显身手。2016年看过腾讯的一篇新闻:腾讯云数智98.8秒完成100TB数据排序的架构和算法,抛开硬件和分布式系统软件架构不谈,单纯讨论排序算法部分,就可以发现归并排序的身影。Java对象排序使用的TimSort(JDK1.7开始使用ComparableTimSort),也是归并排序和插入排序的混合排序算法,因此归并排序还是很重要的。
首先写个归并两个有序数组的函数,练练手,代码如下:

public static int[] mergeArrays(int[] array1, int[] array2){
    int len1 = 0, len2 = 0;
    if(array1 == null || (len1 = array1.length) == 0){
        return array2 == null ? null : Arrays.copyOf(array2, array2.length);
    }
    if(array2 == null || (len2 = array2.length) == 0){
        return Arrays.copyOf(array1, len1);
    }
    int[] mergeArray = new int[len1 + len2]; // May throw OutOfMemoryError or NegativeArraySizeException
    int index1 = 0, index2 = 0, index = 0;
    while(true){
        if(array1[index1] > array2[index2]){
            mergeArray[index++] = array2[index2++];
        }else {
            mergeArray[index++] = array1[index1++];
        }
        if(index1 == len1){
            System.arraycopy(array2, index2, mergeArray, index, len2 - index2);
            break;
        }
        if(index2 == len2){
            System.arraycopy(array1, index1, mergeArray, index, len1 - index1);
            break;
        }
    }
    return mergeArray;
} 

这段代码写起来可能比较简单,两个数组逐个比较,然后添加到新的数组中。但是一定要注意变量的检测,我看网上这部分的代码好像都不喜欢对传入的变量进行检验,还有合并后的数组,可能会出现一些异常,因为无法解决,所以就不捕获了。
接着就是“从上向下”,分治版本的归并排序,代码如下:

public static void mergeSortFromTopToBottom(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    splitGroups(array, 0, len - 1);
}
private static void splitGroups(int[] array, int begin, int end){
    int mid = (begin + end) >>> 1;
    if(begin == mid){
        if(array[begin] > array[end]){
            swap(array, begin, end);
        }
        return;
    }
    splitGroups(array, begin, mid);
    splitGroups(array, mid + 1, end);
    merge(array, begin, mid, end);
}
private static void merge(int[] array, int begin, int mid, int end){
    int len = end - begin + 1;
    int left = begin;
    int leftLimit = mid + 1;
    int right = leftLimit;
    int rightLimit = end + 1;
    int[] mergeArray = new int[len];
    int index = 0;
    while(true){
        if(array[left] > array[right]){
            mergeArray[index++] = array[right++];
        }else{
            mergeArray [index++] = array[left++];
        }
        if(left == leftLimit){
            System.arraycopy(array, right, mergeArray, index, rightLimit - right);
            break;
        }
        if(right== rightLimit){
            System.arraycopy(array, left, mergeArray, index, leftLimit - left);
            break;
        }
    }
    System.arraycopy(mergeArray, 0, array, begin, len);
}

直接看代码,其实算法的思路很清晰,就是分治数组最后合并排序好的数组块。需要注意的是索引下标,如果不注意很容易越界。
最后就是“从下向上”,归并版本的归并排序,代码如下:

public static void mergeSortFromBottomToTop(int[] array){
     int len = 0;
     if(array == null || (len = array.length) < 2){
         return;
     }
     for(int gap = 1; len / gap >= 1; gap <<= 1){
         int twoGaps = gap << 1;
         int index = 0;
         for(; index + twoGaps - 1 < len; index += twoGaps){
             merge(array, index, index + gap - 1, index + twoGaps - 1);
         }
         if(index < len - gap){
             merge(array, index, index + gap - 1, len - 1);
         }
     }
}

上面的代码主要是实现步长间隔的合并操作,因为数组长度不都是等于2的幂次方,所以剩余部分也要进行合并操作,算法也没有太多技巧。但这段代码有一个隐藏很深的陷阱,网上很多合并排序代码的循环操作如下所示:

for(int gap = 1; gap < len; gap <<= 1){
    do something...
}

如果这个数组的长度范围在230 + 1 ~ 231 - 1之间时,这段代码就会陷入死循环,因为gap <<= 1这句话很容易造成数值溢出。平时我们直接写gap < len,前提是gap的增量为1,它的溢出被len死死限制住了,但是gap << 1很容易跨越len,直接溢出。此时我们必须保证gap = 231(第一次溢出)时能正常退出,所以我使用len / gap >= 1来限制它的溢出。可能这里有人注意到,twoGaps比gap要更快的溢出,当gap = 230时,twoGaps = 231,为什么我不对twoGaps进行溢出处理呢?这是因为下一个for循环里面的判断语句是index + twoGaps - 1 < len,index起始值为0,0 + 231 - 1 = 230,这个值刚好为整型的最大值,所以这个值绝对会大于等于整型的len,并不会我们污染我们后续的操作,所以才没对它进行处理。
我们平时在写循环代码时,喜欢按照惯性思维,上来就小于或小于等于限制值,以后一定要充分考虑增量问题,如果增量的结果可能跨越限制值而发生溢出,一定要使用其他的限制条件来约束它的溢出。并且不是大多情况都是len / gap >= 1来防止,只是我的这种情况很巧,刚好利用它能规避2倍的溢出,如果增量是3倍或者其他的,这种也是不行的,一切根据实际情况而定。
最后提一点,不管是“从上向下”还是“从下向上”版本的归并排序,都可以在小数据时使用插排处理,因为小数据就使用merge函数,启动的代价比较大,杀鸡焉用牛刀。

快速排序

终于写到这种被世人称赞的快排了,嘿嘿。快速排序其实也是使用了分治策略。该方法首先从待排数组中选取一个基准值,通过它将数组分割成两部分,其中一部分的所有数据都比另外一部分的任意数据都要小。然后,再按照此方法对这两部分进行快速排序,递归结束后,整个数组也将变成有序数组。这里我先从最原始的快排入手,然后逐步优化,多介绍几种快排版本。
首先就是原始版本的快排(Python代码一行即可),代码如下:

public static void originalQuickSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    originalQuickSort(array, 0, len - 1);
}
private static void originalQuickSort(int[] array, int begin, int end){
    if(begin >= end){
        return;
    }
    int randIndex = begin + new Random().nextInt(end - begin + 1);
    swap(array, begin, randIndex);
    int pivot = array[begin];
    int left = begin;
    for(int index = begin + 1; index <= end; index++){
        if(array[index] < pivot){
            swap(array, ++left, index);
        }
    }
    swap(array, begin, left);
    originalQuickSort(array, begin, left - 1);
    originalQuickSort(array, left + 1, end);
}

上面其实我取了巧,并不是如传统那般,直接使用最左边的值作为枢纽值,而是使用待排数组块中任意值作为枢纽值,有些时候使用随机方法去解决随机问题,反而会有奇效,这还真是个神奇的事。
原始快排原理比较简单,代码量较少,但是很多人写起来,很容易出现各种问题,我觉得他们是只知道原理,却忽略了两点比较重要的东西。第一是要保证不能出现重复的begin和end,如果出现了,很容易出现爆栈或者死循环,所以originalQuickSort(array, left + 1, end);这句代码,不管中间的是left + 1,还是什么值,一定要确保该值大于begin。第二点是要保证分割出来的左右两个数组块,左边的所有值都要小于等于右边的任意一个值,这也是大多快排算法执行完了,却排错的原因。我相信只要谨记这两点,快排算法写起来会又快又稳。

前面说过对于小数据可以使用插入排序来提升效率,快排也不例外,代码如下:

private static final int INSERTION_SORT_THRESHOLD = 47; // Get a threshold for insertion, prevent code from the magic numbers
if((end - begin) <= INSERTION_SORT_THRESHOLD){
    if(end > begin){
        insertionSort(array, begin, end);
    }
    return;
}

前面原始快排部分,我使用的是随机法确定的枢纽值,不过这玩意总觉得有点玄幻,每次用它的时候都是忐忑不安,因此使用三点取中法,顾名思义,该方法就是选取首、中和尾数据里面第二大数据,作为枢纽值,不过命名为三点取中法,瞬间高端大气起来,哈哈哈。代码如下:

private static int getPivot(int[] array, int begin, int end){
    int mid = (begin + end) >>> 1;
    if(array[mid] > array[end]){
        swap(array, mid, end);
    }
    if(array[begin] > array[end]){
        swap(array, begin, end);
    }
    if(array[begin] < array[mid]){
        swap(array, begin, mid);
    }
    return array[begin];
}

前面原始快排部分,我们只是单纯通过枢纽值划分小数据在左边,大数据在右边,其实我们也可以将其分成三部分,小于pivot的放在左边,等于pivot的放在中间,大于pivot的放在右边,该方法被称为三向切分快排法。代码如下:

public static void threeWayQuickSort(int[] array){
     int len = 0;
     if(array == null || (len = array.length) < 2){
        return;
    }
    threeWayQuickSort(array, 0, len - 1);
}
private static void threeWayQuickSort(int[] array, int begin, int end){
    if((end - begin) <= INSERTION_SORT_THRESHOLD){
        if(end > begin){
            insertionSort(array, begin, end);
        }
        return;
    }
    int pivot = getPivot(array, begin, end);
    int left = begin;
    int right = end;
    int index = begin + 1;
    while(index <= right){
        int value = array[index];
        if(value < pivot){
            swap(array, index++, left++);
        }else if(value == pivot){
            index++;
        }else{
            swap(array, index, right--);
        }
    }
    threeWayQuickSort(array, begin, left - 1);
    threeWayQuickSort(array, right + 1, end);
}

这方法保证在原始排序比较枢纽值的过程中,小的放在左边,相同的放在中间,大的放在右边。此时我们来看看该算法是否满足原始排序中需要注意的两点。第一点,因为left和right索引处的值等于枢纽值,所以begin到left - 1索引之间的数值绝对小于等于任意处于索引值为right + 1到 end的值,满足。第二点,因为index > left,right + 1 = index,所以right + 1 > begin,满足。哈哈哈,这时我就很有信心证明我这算法正确了。

前面原始排序在partition过程中,都是使用的swap进行数据交换,其实也可以通过赋值(或称为移动)达到交换的目的。移动数据有点像小时候玩的智能拼图,只有一个空,通过有限次的移动来拼出完整图像,如果我们将pivot抠出来,当做智能拼图的一个空,这些数据总能在有限次拼出左边小于pivot,右边大于pivot的数组,并且此时的移动都是赋值,比swap交换数据更加高效一点。
这样说可能有点不直观,大家可以参考网上的一些双端扫描填坑快排算法(这个名字是我取的,大多数叫填坑法),他们有图片,可能更加形象点。放个链接:快速排序。代码如下:

public static void fullPitsQuickSort(int[] array){
     int len = 0;
     if(array == null || (len = array.length) < 2){
        return;
    }
    fullPitsQuickSort(array, 0, len - 1);
}
private static void fullPitsQuickSort(int[] array, int begin, int end){
    if((end - begin) <= INSERTION_SORT_THRESHOLD){
        if(end > begin){
             insertionSort(array, begin, end);
        }
        return;
    }
    int pivot = getPivot(array, begin, end);
    int left = begin;
    int right = end;
    while(left < right){
        while(left < right && array[right] > pivot){
            right--;
        }
        if(left < right){
            array[left++] = array[right];
        }
        while(left < right && array[left] < pivot){
            left++;
        }
        if(left < right){
            array[right--] = array[left];
        }
    }
    array[left] = pivot;
    fullPitsQuickSort(array, begin, left - 1);
    fullPitsQuickSort(array, left + 1, end);
}

这个算法需要注意的是如何填坑。相比于三向切分快排法,双端填坑快排法的优势就是排序数据时,使用的是赋值操作,效率比swap要高(填坑扫描需要两次填坑才能实现一次swap,高也高不到哪去),劣势是填坑算法每次partition时,都可能把与枢纽值相等的值分到左边或右边数组块。而三向切分法每次partition时,都会将待排数组分为三部分,而且也只需要再排序小于pivot以及大于pivot的待排数组,效率更高。
最后验证一下程序正确性,第一点,left处是pivot,因此begin到left - 1都是小于等于pivot的数据,left + 1到end的都是大于等于pivot的数据,满足。第二点,left大于等于begin,因此begin + 1恒大于begin,满足。因此该算法正确,嘿嘿!
前面三向切分算法,我们是选取一个枢纽值,将数据分为小于pivot、等于pivot和大于pivot三部分,如果我们选用两个枢纽值,一大(p1)一小(p2),就能将数据分为< p1、p1 =< <= p2、和> p2三部分,这部分可以参考java.util包下的DualPivotQuicksort类,partition部分的码行为343。其中的双端排序代码整理如下:

public static void dualPivotQuickSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    dualPivotQuickSort(array, 0, len - 1);
}
private static void dualPivotQuickSort(int[] array, int begin, int end){
    if((end - begin) <= INSERTION_SORT_THRESHOLD){
        if(end - begin > 0){
              insertionSort(array, begin, end);
        }
        return;
    }
    if(array[begin] > array[end]){
        swap(array, begin, end);
    }
    int smallPivot = array[begin];
    int bigPivot = array[end];
    int less = begin;
    int great = end;
    while(array[++less] < smallPivot);
    while(array[--great] > bigPivot);
    int index = less - 1;
    outer:
    while(++index <= great){
        int value = array[index];
        if(value < smallPivot){
            array[index] = array[less];
            array[less++] = value;
        }else if(value > bigPivot){
            while(array[great] > bigPivot){
                if(great-- == index){
                    break outer;
                }
            }
            // At this time array[great] < bigPivot
            if(array[great] < smallPivot){
                array[index] = array[less];
                array[less++] = array[great];
            }else{
                array[index] = array[great];
            }
            array[great--] = value;
        }
    }
    array[begin] = array[less - 1]; array[less - 1] = smallPivot;
    array[end] = array[great + 1]; array[great + 1] = bigPivot;
    dualPivotQuickSort(array, begin, less - 2);
    dualPivotQuickSort(array, less, great);
    dualPivotQuickSort(array, great + 2, end);
}

当partition执行完时,less是大于等于smallPivot并且小于等于bigPivot的边界索引,因此less - 1处的值小于smallPivot,与此同时begin处的值等于smallPivot,因此交换begin和less - 1,能保证begin到less - 2的值都小于smallPivot。great也是大于等于smallPivot并且小于等于bigPivot的边界索引,因此great + 1处的值大于大枢纽值,与此同时end处的值等于bigPivot,因此交换end和great + 1,能保证great + 2到end的值都大于大枢纽值,并且less到great的值都大于等于smallPivot小于等于bigPivot,满足第一点。因为less - 2和great + 2的关系,less~great不会与上下边界发生冲突,又因为great大于等于begin,所以great + 2 恒大于begin,满足第二点,算法正确。

桶排序

前面讲的这些排序都是通过数据比较来进行排序,而桶排序却是根据数据特性来进行排序。尽管它只适用于对非负整数进行操作,但是其排序效率远远高于常规排序,就算是极尽优化版本的Arrays.sort,在排序正整数时,都不敢直缨其锋。代码如下:

public static void bucketSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int maxValue = array[0];
    for(int i = 1; i < len; i++){
        int value = array[i];
        if(value < 0){
            throw new IllegalArgumentException("The array contains negative numbers.");
        }
        if(value > maxValue){
            maxValue = value;
        }
    }
    int bucketLen = maxValue + 1;
    int[] bucketArray = new int[bucketLen]; // May throw OutOfMemoryError or NegativeArraySizeException
    for(int i = 0; i < len; i++){
        bucketArray[array[i]]++;
    }
    int index = 0;
    for(int i = 0; i < bucketLen ;  i++){
        int count = bucketArray[i];
        while(--count >= 0){
            array[index++] = i;
        }
    }
}

桶排序的限制条件比较多,不仅有数据类型的问题,如果数组中最大值较大,很容易出现内存不足的错误,算法的空间利用率较低。如果数据类型满足要求,且数据的分布比较均匀,最大值也比较小,使用桶排序最合适不过了。

基数排序

基数排序是桶排序的扩展,利用了整数位数特性来实现排序。它将整数按位数切割成不同数字,然后按每个位数分别比较。因为它固定使用十个桶,空间利用率相对于桶排序大大提高,基数排序一般分为两类,一类是从最低位开始排序,即(Least Significant Digit first)。一类是从最高位开始排序,即(Most Significant Digit first)。
首先是大家比较熟悉的,从低位开始排序的代码:

public static void lsdfSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int maxValue = array[0];
    for(int i = 1; i < len; i++){
        int value = array[i];
        if(value < 0){
            throw new IllegalArgumentException("The array contains negative numbers.");
        }
        if(value > maxValue){
            maxValue = value;
        }
    }
    for(int radix = 1; radix < maxValue && radix < 1000000001; radix *= 10){
        int[] bucketArray = new int[10];
        for(int i = 0; i < len; i++){
            bucketArray[array[i] / radix % 10]++;
        }
        for(int i = 1; i < 10; i++){
            bucketArray[i] += bucketArray[i - 1];
        }
        int[] tempArray = new int[len];
        for(int i = len - 1; i >= 0; i--){
            tempArray[--bucketArray[array[i] / radix % 10]] = array[i];
        }
        System.arraycopy(tempArray, 0, array, 0, len);
    }
}

这个算法虽然看起来比较简单,但是如果真的要你自己实现,一时间还真有点措手不及。接下来我来说说我自己理解的思路:要根据位数排序,首先就是要取每个位数上的值,这个通过求商再求余,倒是很简单,然后我们根据位数上的值对应到0到9这十个桶中,并且对它们进行计数。此时此刻我们只是知道位数值为0到9各自包含的数据有多少,这时我们应该想到,我们都是按照0123456789进行排序的,如果我们把0桶的值加到1桶中,其值记做m,那么m就是位数值为2开始的索引值,m-1也就是位数为1结束的索引值,同理,如果我们把0、1桶的值加到2桶中,其值记做n,那么n就是位数值为3开始的索引值,n-1也就是位数为2结束的索引值。所以第二个for将个桶值叠加,就是为了确定各位数值在数组中的索引值。此时我们已经知道每个位数在数组的位置了,那么接下来只需要按照位数,把数据放到指定索引处即可将数据按照0123456789排序了。在放数据时,我们并不是按照0到len - 1来取数据,而是按照len - 1到0,这是因为我们的桶提供的索引值是从大到小开始。比如排序25和29,首先个位排序,肯定是25和29,但是十位后,两者相同,如果先取25,那么桶提供索引值将会比给29提供的大,那么十位排序时是29和25。前面说了桶叠加的值是一个位数值索引的开始,也是一个位数值索引的结束,如果强行要让最后的循环从0到len-1遍历也是可以的,只需要将桶叠加数向前一位,使桶值称为开始索引值,再将0桶附为0,即可,前面部分都是一样,从桶叠加结束开始修改,代码如下:

int temp1 = bucketArray[0];
int temp2 = temp1;
bucketArray[0] = 0;
for(int i = 1; i < 10; i++){
    temp1 = bucketArray[i];
    bucketArray[i] = temp2;
    temp2 = temp1;
}
int[] tempArray = new int[len];
for(int i = 0; i < len; i++){
    tempArray[bucketArray[array[i] / radix % 10]++] = array[i];
}
System.arraycopy(tempArray, 0, array, 0, len);

这里桶数据移动其实可以使用数组复制,但是这可能需要另外开辟一个数组,如果数组太大,对空间来说也是个负担,所以这里我使用的swap原理,通过两个临时值进行交换移动。
从这里我们看到,如果我们真正理解这个算法,修改起来其实是很方便,遍历方向从前往后、从后往前都可以。
可能前面我完全通过语言说算法思路,总觉得干涩涩的,不够形象,我也特意从网上找了篇原理讲解比较生动的文章(主要是有图,哈哈哈!),链接如下:算法 排序算法之基数排序
最后还有一个地方,不知道大家注意了没,我的radix迭代时,判断语句好像多加了的东西。在归并排序时,我说过,如果增量的结果可能跨越限制值而发生溢出,就要仔细考虑是否需要加判断语句进行限制。在lsdfSort函数中,radix增量为10倍,第一次发生溢出时,radix = 1410065408,如果还是使用以前的判定条件len / radix >= 1,当len大于1410065408时,就会出现问题。我们仔细观察这个溢出值,发现它大于整型的最大位数1000000000,所以我们只需要判断其radix小于1000000001到1410065407之间任意一个数,或者直接小于等于1000000000,都是可以避免溢出造成的错误。其实还有两种不同的方法来解决,这只是针对lsdfSort函数。
第一种方法如下

int maxValueLen = String.valueOf(maxValue).length();
int radixLimit = (int)Math.pow(10, maxValueLen - 1); 
for(int radix = 1; radix <= radixLimit ; radix *= 10){
    do something...
}

double转int,转换的是整数部分,而10的幂都是整数,所以并不会发生精度缺失,可以放心转换。
有时代码写new Double(value).intValue(),以为可以安全的转换,其实Double类中的intValue方法就是用(int)强转实现的,但是在代码里面直接写(int),总觉得心慌慌的,哎,掩耳盗铃啊!
第二种方法,灵感来自于Integer的stringSize方法,代码如下:

final int[] radixTable = {1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000};
int radixLimit = 1;
for(int i = 9; i >= 0; i--){
    if(maxValue >= radixTable[i]){
        radixLimit = radixTable[i];
        break;
    }
}
for(int radix = 1; radix <= radixLimit ; radix *= 10){
    do something...
}

jdk1.7开始支持数字下划线,用于提高代码可读性,今天一试还是有点方便呢。如果还不太了解下划线的,给个传送门:为什么Java7开始在数字中使用下划线

当时学完从低位开始排序的基数排序,很自然的就会想着学习一下从高位开始排序的基数排序。我也是伸手党,说去百度看看,有没有相关的代码,查了半天,发现都是从低位开始排序的基数排序,最后想了想,决定自己造个轮子,手撕这个算法。
我的思路:首先我也是参考了低位的基数排序,决定从高位开始装桶排序,处理最高位时好像还可以,但是接着处理下一位,又把以前的排序打乱了,这肯定是不行,所以思路得改改。我发现每处理一位时,桶数据的相对位置应该是固定的。比如18 33 32 15 27 22,我们先进行最高位十位处理时,变为 18 15 27 22 33 32,下次再进行个位操作时,18和15这两个数只能在索引0和1之间进行排序,而27和22只能在2和3之间进行排序,同理33和32只能在4和5之间排序。也就是说,十位分了十个桶进行十位排序,这十个桶每个分别再分十个桶来进行个位排序,以此类推。直到radix等于1即个位排序完,整个算法结束,就大功告成了。
下面的代码绝对原创,有更好思路,能交流就更好了。

public static void mdsfSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int maxValue = array[0];
    for(int i = 1; i < len; i++){
        int value = array[i];
        if(value < 0){
            throw new IllegalArgumentException("The array contains negative numbers.");
        }
        if(value > maxValue){
            maxValue = value;
        }
    }
    int maxValueLen = String.valueOf(maxValue).length();
    int radix = (int)Math.pow(10, maxValueLen - 1); 
    mdsfSort(array, 0, len - 1, radix);
}
private static void mdsfSort(int[] array, int begin, int end, int radix){
    int[] bucketArray = new int[10];
    int len = end - begin + 1;
    for(int i = begin; i <= end; i++){
        bucketArray[array[i] / radix % 10]++;
    }
    for(int i = 1; i < 10; i++){
        bucketArray[i] += bucketArray[i - 1];
    }
    int[] tempBucketArray = Arrays.copyOf(bucketArray, 10);
    int[] tempArray = new int[len];
    for(int i = begin; i <= end; i++){
        tempArray[--bucketArray[array[i] / radix % 10]] = array[i];
    }
    System.arraycopy(tempArray, 0, array, begin, len);
    if(radix == 1){
        return;
    }
    if(tempBucketArray[0] > 1){
        mdsfSort(array, begin, begin + tempBucketArray[0] - 1, radix / 10);
    }
    for(int i = 1; i < 10; i++){
        if(tempBucketArray[i] - tempBucketArray[i - 1] > 1){
            mdsfSort(array, begin + tempBucketArray[i - 1], begin + tempBucketArray[i] - 1, radix / 10);
        }
    }
}

该算法就是从最高位开始分十个桶,再对最高位的每个位数值分别分十个桶为下一位排序做准备,依次类推,直到个位排序完。
上面的代码在排数据时,我并没有移动桶数据的操作,但是遍历的方向却是0到len-1,好像和前面低位基数排序算法冲突。其实不然,因为我的每个桶数组都是针对每一位的每一个位数值,他们根本不与其他位数进行冲突,就比如,你单纯排0到9之间的数据,你使用低位基数排序时,也是可以不用任何操作,遍历方向为0到len-1,一个道理。

堆排序

堆排序就是利用特殊的数据结构堆来实现对数据的排序。堆分为“最大堆”和“最小堆”,最大堆通常被用来进行"升序"排序,而最小堆通常被用来进行"降序"排序。本文主要分析最大堆的“升序”排序。
我利用数组实现最大堆,最大堆排序代码如下:

public static void maxHeapSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int[] maxHeapArray = new int[len];
    for(int i = 0; i < len; i++){
        addElement(maxHeapArray, i, array[i]);
    }
    for(int i = len - 1; i >= 0; i--){
        array[i] = removeElement(maxHeapArray, i);
    }
}
private static void addElement(int[] maxHeapArray, int index, int value){
    maxHeapArray[index] = value;
    while(index > 0){
        int fatherIndex = (index - 1) >> 1;
        if(value  > maxHeapArray[fatherIndex]){
            swap(maxHeapArray, index, fatherIndex);
            index = fatherIndex;
        }else{
            break;
        }
    }
}
private static int removeElement(int[] maxHeapArray, int index){
    int oldValue = maxHeapArray[0];
    maxHeapArray[0] = maxHeapArray[index];
    int indexLimit = index - 1;
    index = 0;
    while(index <= indexLimit){
        int left = 2 * index + 1;
        left = (left > indexLimit) ? index : left;
        int right = 2 * index + 2;
        right = (right > indexLimit) ? index : right;
        int maxIndex = (maxHeapArray[left] > maxHeapArray[right]) ? left : right;
        if(maxHeapArray[index] < maxHeapArray[maxIndex]){
            swap(maxHeapArray, index, maxIndex);
            index = maxIndex;
        }else{
            break;
        }
    }
    return oldValue;
}

该算法主要利用最大堆的数据结构性质,如果原理不太理解,可以看看数据结构关于堆的知识。

后记

代码部分其实参考了很多优秀的博客和文章,因为时间有点久,很多出处都忘记了,如果有人提醒,我会补上参考链接的。
终于写完了,长舒一口气。第一次写博客,还真的有点忐忑,生怕自己的无知会误导到别人。本来只是想随便写写排序算法,没想到洋洋洒洒写了这么多。所有代码,我都亲自测试过,生怕出现问题。希望在以后的学习中,我能一直保持严谨的态度。

猜你喜欢

转载自blog.csdn.net/haiyoushui123456/article/details/83422028
今日推荐