前言
这几天得空研究了一下几种常见的排序算法,深入地去理解研究后才发现之前了解的太过片面与浅显,每次排序后的结果、算法稳点性都太过模糊…… 话不多说,直接开整。一、示例代码公用部分
以下的代码是排序代码外的公用部分,阅读时请结合这里的代码理解。这里仅仅是提出了一些公共方法,便于下面的排序方法逻辑清晰。(这里的案例都是做升序排序的)public static void main(String[] args) {
Integer[] array = {
57,68,59,52,72,28,96,33,24,19};
System.out.println("序列初始数据:"+arrayToString(array));
//冒泡排序
bubblingSort1(array);
bubblingSort2(array);
//选择排序
selectSort1(array);
fakeSelectSort(array);
//插入排序
insertionSort(array);
//希尔排序
shellSort(array);
//堆排序
heapSort(array);
//快速排序
quickSort(array,0,array.length-1,0);
//归并排序
mergerSort(array);
//基数排序
radixSort(array);
}
/**
* 交换元素
* @param arr
* @param a 元素的下标
* @param b 元素的下标
*/
private static void swap(Integer[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
/**
* 数组转集合,便于打印排序结果
* @param array
* @return
*/
private static String arrayToString(Integer[] array) {
return new ArrayList<Integer>(Arrays.asList(array)).toString();
}
二、各种算法
1.冒泡排序
冒泡排序是最简单以及最常见的一种排序算法,我们学习序列排序时冒泡排序是必学的一种算法,其原理也很简单,我们看一下对这个算法的基本原理:
说白了就是比较相邻两个元素的大小,然后决定两个元素是否交换位置。如果我们只是把视角与思维局限这这里,那么这篇博客就非常值得阅读了。
代码如下(示例):
/**
* 冒泡排序(从前往后冒)
* @param array
*/
private static void bubblingSort1(Integer[] array) {
for(int i =0; i<array.length-1 ; i++) {
for(int j=0; j<array.length-1-i ; j++) {
if(array[j] > array[j+1]) {
swap(array, j, j+1);
}
}
System.out.println("第"+(i+1)+"次冒泡排序结果:"+arrayToString(array));
}
}
排序结果:
这里解释一下代码中内层循环的条件语句,因为这个案例是从前往后冒,说明每次循环都是把当前最大的元素排到后面,若第一次循环,那么取当前最大值排到倒数第一;若第二次循环,那么取当前最大值排到倒数第二……,所以这里的需要空出之前循环排序好的数组末尾的元素个数。这里再看结果,看第一次排序结果和原始数据的变化,结果到代码逻辑不难看出,每次取最大值(我们称之为目标元素)是动态的。如何理解这句话呢,我们看序列初始数据,假设我们当前的目标元素是68,当68和59比较时68大,所以我们的目标元素依然是68,只是下标已经到了2,然后继续比较后面的元素,68比52大,我们的目标元素依然是68,只是下标已经到了3,然后继续比较后面的元素,68比72小,这里注意68不会再往后冒了,它的下标在此次排序中就定在了3,我们的目标元素换成了72,接着我们会拿着72继续往后冒……所以说冒泡排序的目标元素是动态的,这也是它和插入排序的最大的区别之一。这样的话再看每次排序结果,除了会把最大元素冒到后面之外,中间的其他元素的位置也会发生比较大的变化。
代码如下(示例):
/**
* 冒泡排序(从后往前冒)
* @param array
*/
private static void bubblingSort2(Integer[] array) {
for(int i=0;i<array.length;i++){
for(int j=array.length-1;j>i;j--){
if(array[j]<array[j-1]){
swap(array, j, j-1);
}
}
System.out.println("第"+(i+1)+"次冒泡排序结果:"+arrayToString(array));
}
}
排序结果:
这个也是属于冒泡排序,不过我们平时所说的冒泡排序一般都是指从前往后冒,这里把它摘出来说是因为它的代码逻辑要比从前往后冒简单些,外层循环代表当前已经排序好的末尾下标,内层循环从数组的末尾下标开始往前倒推,只要确保j>i就能保证目标元素排到了有序序列后边了。
2.直接选择排序
直接选择排序也是很常见也很简单的一种排序算法,我们看一下对这个算法的基本原理:
代码如下(示例):
/**
* 选择排序
* @param array
*/
private static void selectSort1(Integer[] array) {
//外层循环代表已成功排序的子序列的末尾下标
for(int i = 0; i < array.length; i++){
int minIndex = i; //默认第i个就是最小元素
//在剩余的序列中找最小元素
for(int j = i + 1; j < array.length; j++){
if(array[j] < array[minIndex]){
minIndex = j;
}
}
swap(array,i,minIndex);
System.out.println("第"+(i+1)+"次选择排序结果:"+arrayToString(array));
}
}
排序结果:
看到这个代码逻辑非常明了,排序结果也是非常清晰可见的,每次排序都是把选中的目标元素与目标位置的元素做交换,其他元素一概不动,而且这里每次排序都只确定一个目标元素,不会改变。
代码如下(示例):
/**
* 伪选择排序
* @param array
*/
public static void fakeSelectSort(Integer[] array){
for(int i=0;i<array.length;i++){
for(int j=i+1;j<array.length;j++){
if(array[i]>array[j]){
swap(array,i,j);
}
}
System.out.println("第"+(i+1)+"次选择排序结果:"+arrayToString(array));
}
}
排序结果:
注意看这个排序,乍一看排序结果,它也是每次排序都是把最小的元素排到前边,但是注意这个排序改变了其他元素的位置,所以不能算是选择排序。
3.插入排序
插入排序也是很常见也很简单的一种排序算法,我们看一下对这个算法的基本原理:
通俗的说就是我们先默认前几个元素就是已经排序好的,我拿到下一个元素把他插入到前边的某个位置。
代码如下(示例):
/**
* 插入排序
* @param array
*/
private static void insertionSort(Integer[] array) {
//外层循环代表目标元素的下标,内层循环代表已排序的子序列
for(int i=1;i<array.length;i++){
for(int j=i-1;j>=0;j--){
if(array[j+1]<array[j]){
swap(array,j,j+1);
}else{
break;
}
}
System.out.println("第"+i+"次插入排序结果:"+arrayToString(array));
}
}
排序结果:
看插入排序每次排序的结果,第一次排序68本来就比57大,所以68相当于就插在了自己本来的位置,下一次排序取到目标元素59,把它插入到了57和68的中间……不难看出插入排序有它自己的特征:1.每次排序时目标元素是固定的。2.有可能某次排序后序列没有变化。3.序列前边是已经排序好的元素,后边的未排序的元素依然在自己的下标下不动。
4.希尔排序
希尔排序相较于以上的排序算法来说是不太常见的,也是比较复杂的一种算法,它相当于是插入排序的升级版,我们看一下对这个算法的基本原理:
案例中先定义一个增量d1,第一次分组把57和28分为一组,68和96分为一组,59和33分为一组……,分完组后在组内进行插入排序,然后缩小增量使得分组更少,d2把序列文卫3组,然后再次组内进行插入排序,然后再次重复以上操作,直至增量变成1为止。
代码如下(示例):
/**
* 希尔排序
* @param array
*/
private static void shellSort(Integer[] array) {
int count = 0;
//外层循环代表分的组数(每隔gap个为一组)
for(int gap=array.length/2;gap > 0;gap /= 2){
count++;
//从第gap个元素,逐个对其所在组进行直接插入排序操作
for(int i=gap; i<array.length; i++){
for(int j = i; j-gap>=0 && array[j] < array[j-gap]; j-=gap){
swap(array,j,j - gap);
}
}
System.out.println("第"+count+"次希尔排序每隔"+gap+"个元素为一组,排序结果:"+arrayToString(array));
}
}
排序结果:
希尔排序其实就是插入排序的升级版,希尔排序中先把序列分组,先尽可能的多分组,每组元素少点,然后进行插入排序时效率会高,因为越到后面的次数排序时序列越有序。
5.堆排序
堆排序是排序算法中不太常见,也是最难的一种排序算法,它的底层原理中涉及到了小顶堆或大顶堆的概念,不了解小顶堆的宝宝们先恶补一下二叉树和完全二叉树的相关知识。
代码如下(示例):
/**
* 堆排序
* @param array
*/
private static void heapSort(Integer[] array) {
class Heap{
/**
* 调整堆 整个堆排序最关键的地方
* @param array 待组堆
* @param i 起始结点
* @param length 堆的长度
*/
public void adjustHeap(Integer[] array, int i, int length) {
// 先把当前元素取出来,因为当前元素可能要一直移动
int temp = array[i];
//2*i+1为左子树i的左子树(因为i是从0开始的),2*k+1为k的左子树
for (int k = 2 * i + 1; k < length; k = 2 * k + 1) {
// 让k先指向子节点中最大的节点
if (k + 1 < length && array[k] < array[k + 1]) {
//如果有右子树,并且右子树大于左子树
k++;
}
//如果发现结点(左右子结点)大于根结点,则进行值的交换
if (array[k] > temp) {
swap(array, i, k);
// 如果子节点更换了,那么,以子节点为根的子树会受到影响,所以,循环对子节点所在的树继续进行判断
i = k;
} else {
//不用交换,直接终止循环
break;
}
}
}
}
//这里元素的索引是从0开始的,所以最后一个非叶子结点array.length/2 - 1
int count = 0;
for (int i = array.length/2-1; i >= 0; i--) {
new Heap().adjustHeap(array, i, array.length); //调整堆
System.out.println("第"+(++count)+"次初始化大顶堆,结果为:"+arrayToString(array));
}
System.out.println("初始化大顶堆完成,结果为:"+arrayToString(array));
// 上述逻辑,建堆结束
// 下面,开始排序逻辑
count = 0;
for (int j = array.length - 1; j > 0; j--) {
// 元素交换,作用是去掉大顶堆
// 把大顶堆的根元素,放到数组的最后;换句话说,就是每一次的堆调整之后,都会有一个元素到达自己的最终位置
swap(array, 0, j);
// 元素交换之后,毫无疑问,最后一个元素无需再考虑排序问题了。
// 接下来我们需要排序的,就是已经去掉了部分元素的堆了,这也是为什么此方法放在循环里的原因
// 而这里,实质上是自上而下,自左向右进行调整的
new Heap().adjustHeap(array, 0, j);
System.out.println("第"+(++count)+"次堆排序结果:"+arrayToString(array));
}
}
排序结果:
堆排序的核心思想就是先将序列按下标顺序构建成普通的完全二叉树,然后以二叉树的运算方式把普通的完全二叉树转化成小顶堆或大顶堆,然后把小顶堆或大顶堆用去根法把元素依次交换到指定下标处。对与排序结果,我们只要把每次排序后的结果转成普通完全二叉树就可以看出端倪了。
6.快速排序
快速排序是不太常见的算法之一,不过它的基本思想比较简单:
代码如下(示例):
/**
* 快速排序
* @param array
*/
private static Integer[] quickSort(Integer[] array,int start,int end,int count) {
//首先说明这里的count参数不是排序算法的必要参数,这里加入这个参数只是为了我们便于统计数组变化情况
//先定义一个基准
int pivot = array[start];
int i = start, j = end;
while (i < j) {
//如果基准后边的指针所指的元素大于基准就移动指针
while (i < j && array[j] > pivot) {
j--;
}
//同理,如果基准前边的指针所指的元素小于基准就移动指针
while (i < j && array[i] < pivot) {
i++;
}
//经过上面的两个循环,表面上两个指针都分解指向了大于基准和小于基准的元素,
//其实因为我们一开始定义的基准是第一个元素,所以这里两个指针其实必定有一个是指向基准的
//这里直接交换就可以了
swap(array, i, j);
System.out.println("第"+(++count)+"次快速排序结果:"+arrayToString(array));
}
//排序左边子序列
if (i - 1 > start) array = quickSort(array,start,i-1,count);
//排序右边子序列
if (j + 1 < end) array = quickSort(array,j+1,end,count);
return array;
}
排序结果:
这里排序结果中出现两次第9次排序到第13次排序是因为第8次排序后基准元素已经到了中间。从第9次排序开始就同时进行排序左右两边的子序列了。
7.归并排序
归并排序与希尔排序和快速排序有一个共同点就是,它们都想着把复杂的序列拆分成简单的子序列排序。
这里解释一下归并排序是如何排序两组子序列的,我们先定义一个与原始序列等长的数组R用于承接排序后的序列。上图中第一行为原始序列,第二行把原始序列每两个元素分为一组并排序(这里的排序方式与第二行到第三行的方式一样)。重点是第二行到第三行两个子序列的合并,我们定义两个指针i和j,分别指向57和52,比较57和52的大小,52小,就把52先放入到R中并且移动指针j到59的位置,然后比较57和59,57小,就把57放入到R中并且移动指针i到68的位置……
代码如下(示例):
/**
* 归并排序
* @param array
*/
private static void mergerSort(Integer[] array) {
Integer[] temp = new Integer[array.length];
class Sort{
int count;
public void mergerSort(Integer[] array, int first, int last, Integer[] temp) {
if (first < last) {
int mid = (first + last) / 2;
mergerSort(array, first, mid, temp); // 递归归并左边元素
mergerSort(array, mid + 1, last, temp); // 递归归并右边元素
mergerArray(array, first, mid, last, temp); // 再将二个有序数列合并
}
}
public void mergerArray(Integer array[], int first, int mid, int last, Integer[] temp) {
int i = first, j = mid + 1; // i为第一组的起点, j为第二组的起点
int m = mid, n = last; // m为第一组的终点, n为第二组的终点
int k = 0; // k用于指向temp数组当前放到哪个位置
while (i <= m && j <= n) {
// 将两个有序序列循环比较, 填入数组temp
if (array[i] <= array[j])
temp[k++] = array[i++];
else
temp[k++] = array[j++];
}
while (i <= m) {
// 如果比较完毕, 第一组还有数剩下, 则全部填入temp
temp[k++] = array[i++];
}
while (j <= n) {
// 如果比较完毕, 第二组还有数剩下, 则全部填入temp
temp[k++] = array[j++];
}
for (i = 0; i < k; i++) {
// 将排好序的数填回到array数组的对应位置
array[first + i] = temp[i];
}
System.out.println("第"+(++count)+"次归并排序结果:"+arrayToString(array));
}
}
new Sort().mergerSort(array, 0, array.length - 1, temp);
}
排序结果:
8.基数排序
基数排序是这些算法中最不常见的算法,不过其独到的处理方式倒是值得研究,
代码如下(示例):
/**
* 基数排序
* @param array
*/
private static void radixSort(Integer[] array) {
//待排序列最大值
int max = array[0];
//计算最大值
for (Integer anArr : array) {
if (anArr > max) {
max = anArr;
}
}
int k = 0;
int n = 1;
int m = 1; //控制键值排序依据在哪一位
int[][] temp = new int[10][array.length]; //数组的第一维表示可能的余数0-9
int[] order = new int[10]; //数组order[i]用来表示该位是i的数的个数
while(m <= String.valueOf(max).length()) {
for(int i = 0; i < array.length; i++)
{
int lsd = ((array[i] / n) % 10);
temp[lsd][order[lsd]] = array[i];
order[lsd]++;
}
for(int i = 0; i < 10; i++) {
if(order[i] != 0) {
for (int j = 0; j < order[i]; j++) {
array[k] = temp[i][j];
k++;
}
}
order[i] = 0;
}
n *= 10;
k = 0;
System.out.println("第"+m+"次基数排序结果:"+arrayToString(array));
m++;
}
}
排序结果:
基数排序的思想完全不同于之前的算法一样,它是先定义一个二维数组,然后把序列的每个元素都按位数拆分,然后先把元素按个位数值放入二维数组中,其次是十位,其次百位……
三、总结
这里简单解释一下如何判断算法的稳定性,假如有这样一个序列{25,69,45,69,23}。我们看到序列中有两个元素都是69,如果排序完成后,能够确保前边的69在后边的69之前,那么这个算法就是稳定的,如果排序完不能够保证这两个元素的前后位置就是不稳定的算法。