Bootstrap

基于比较的七大排序算法

这篇博客可以作为排序算法的复习,并不适合学习。
以下基本上只写明了代码,没有层序渐进的一步步写明步骤,也没有对复杂度进行分析,只是一个总结。

1.时间复杂度O(n^2)级排序算法

1.1 冒泡排序

冒泡排序,入门级算法,通常情况下,有三种写法:

1.首先是基础写法,一边比较一边向后两两交换,将最大值/最小值冒泡到最后一位:

int temp = -1;
for (int i = 0; i < arr.length; i++) {
   for (int j = 0; j < arr.length - 1 - i; j++) {
       if (arr[j] > arr[j + 1]) {
           temp = arr[j];
           arr[j] = arr[j + 1];
           arr[j + 1] = temp;
       }
   }
}

优化

2.第二种,对一种就行一部分优化,使用一个变量,记录当前轮次是否发生了交换,若没有发生,说明,当前数组已经有序,没有必要继续排序:

//用flag表示当前轮次是否发生了交换
boolean flag = false;
int temp = -1;
for (int i = 0; i < arr.length; i++) {
    //每个轮次开始前先把flag置为false
    flag = false;
    for (int j = 0; j < arr.length - 1 - i; j++) {
        if (arr[j] > arr[j + 1]) {
            temp = arr[j];
            arr[j] = arr[j + 1];
            arr[j + 1] = temp;
            //发生交换则把flag置为true
            flag = true;
        }
    }
    //如果每个轮次结束后,flag仍为false,说明,此轮次没有交换,数组已经有序
    if (!flag) {
        break;
    }
}

3.第三种,标识上一轮次最后一次交换的下标,此下标之后的数组都是有序的,不必遍历

//用flag表示当前轮次是否发生了交换
boolean flag = false;
int temp = -1;
//上轮次最后交换的下标
int idx = arr.length - 1;
//每次交换的下标
int swapIdx = arr.length - 1;
for (int i = 0; i < arr.length; i++) {
    //每个轮次开始前先把flag置为false
    flag = false;
    for (int j = 0; j < idx; j++) {
        if (arr[j] > arr[j + 1]) {
            temp = arr[j];
            arr[j] = arr[j + 1];
            arr[j + 1] = temp;
            //发生交换则把flag置为true
            flag = true;
            swapIdx = j;
        }
    }
    //如果每个轮次结束后,flag仍为false,说明,此轮次没有交换,数组已经有序
    if (!flag) {
        break;
    }
    idx = swapIdx;
}

即便经过优化,冒泡排序的性能并没有质的提升,时间复杂度仍旧是O(n^2);此外,纵观整个排序过程,也很容易看出,冒泡排序的空间复杂度为O(1),并且是稳定的排序。

1.2 选择排序

选择排序,每一次从无序区间选出最大(或最小)的一个元素,存放在无序区间的最后(或最前),直到全部待排序的数据元素排完。

1.基本写法

int minIndex = 0;
for (int i = 0; i < arr.length - 1; i ++) {
    minIndex = i;

    for (int j = i + 1; j < arr.length; j ++) {
        if (arr[minIndex] > arr[j]) {
            minIndex = j;
        }
    }

    int temp = arr[minIndex];
    arr[minIndex] = arr[i];
    arr[i] = temp;
}

优化

2.二元选择排序
既然一次能够选出最小值,同样也可以取出最大值,一次选择两个。
代码看着多,但是逻辑还是比较简单的。

int minIndex = 0;
int maxIndex = arr.length - 1;
//每次选最大值和最小值,那么可以少遍历一半
for (int i = 0; i < arr.length / 2; i ++) {
    minIndex = i;
    maxIndex = arr.length - 1 - i;

    for (int j = i; j <= arr.length - 1 - i; j ++) {
        if (arr[minIndex] > arr[j]) {
            minIndex = j;
        }

        if (arr[maxIndex] < arr[j]) {
            maxIndex = j;
        }
    }

    //如果最大值最小值下标一样,说明排序完毕
    if (minIndex == maxIndex) {
        break;
    }
    int temp = arr[minIndex];
    arr[minIndex] = arr[i];
    arr[i] = temp;

    //如果最大值的下标是i,上个交换之后i下标处的值就已经改了,要把值替换成minIndex
    if (maxIndex == i) {
        maxIndex = minIndex;
    }
    temp = arr[maxIndex];
    arr[maxIndex] = arr[arr.length - 1 - i];
    arr[arr.length - 1 - i] = temp;
}

选择排序的时间复杂度仍旧是O(n^2),中间变量仍旧那几个,因此空间复杂度O(1),但是,排序算法是不稳定的,例如一个数组[2,2,1],第一次选最小值的时候,会把下标0和下标1交换,相对位置发生改变,因此是不稳定的。

1.3 插入排序

每次选择无序区间的第一个元素,在有序区间内选择合适的位置插入。

 //升序区间[0, i)
 //无序区间[i, arr.length)
 for (int i = 1; i < arr.length; i ++) {
     int cur = arr[i];
     int j = i - 1;
     for (; j >= 0 && arr[j] > cur; j--) {
         arr[j + 1] = arr[j];
     }
     //插入
     arr[j + 1] = cur;
 }

时间复杂度O(n^2),空间复杂度O(1),并且每轮次移动时,遇到相等元素就停止了,相对位置没有发生改变,是稳定的排序。

2.时间复杂度O(nlogn)级排序算法

2.1 希尔排序

希尔排序本质上是对插入排序的一种优化,它利用了插入排序的简单,又克服了插入排序每次只交换相邻两个元素的缺点。基本思想如下:

  • 将待排序数组按照一定的间隔分为多个子数组,每组分别进行插入排序。这里按照间隔分组指的不是取连续的一段数组,而是每跳跃一定的间隔取一个值组成一组
  • 逐渐缩小间隔进行下一轮排序
  • 最后一轮时,取间隔为1,也就相当于直接使用插入排序。但这时经过前面的调控,数组已经基本有序,所以此时的插入排序只需进行少量交换便可完成。

希尔排序的基本写法:

//定义间隔大小,也就是增量
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
    //按照增量进行分组,类似于选择排序
    for (int i = gap; i < arr.length; i++) {
        int tmp = arr[i];
        int prevIdx = i - gap;
        for (; prevIdx >= 0 && arr[prevIdx] > tmp; prevIdx -= gap) {
            arr[prevIdx + gap] = arr[prevIdx];
        }
        arr[prevIdx + gap] = tmp;
    }
}

另外,希尔排序也是可以再优化一下的,主要就是增量序列的选择,这会很大程度的影响排序的效率。
增量元素不互质,则小增量可能根本不起作用。那么如何选择呢,很多大牛做过这方面的研究,
下面是从力扣《排序算法全解析》中截的图。

在这里插入图片描述
同样贴上力扣上的代码:

public static void shellSortByKnuth(int[] arr) {
    // 找到当前数组需要用到的 Knuth 序列中的最大值
    int maxKnuthNumber = 1;
    while (maxKnuthNumber <= arr.length / 3) {
        maxKnuthNumber = maxKnuthNumber * 3 + 1;
    }
    // 增量按照 Knuth 序列规则依次递减
    for (int gap = maxKnuthNumber; gap > 0; gap = (gap - 1) / 3) {
        // 从 gap 开始,按照顺序将每个元素依次向前插入自己所在的组
        for (int i = gap; i < arr.length; i++) {
            // currentNumber 站起来,开始找位置
            int currentNumber = arr[i];
            // 该组前一个数字的索引
            int preIndex = i - gap;
            while (preIndex >= 0 && currentNumber < arr[preIndex]) {
                // 向后挪位置
                arr[preIndex + gap] = arr[preIndex];
                preIndex -= gap;
            }
            // currentNumber 找到了自己的位置,坐下
            arr[preIndex + gap] = currentNumber;
        }
    }
}

作者:力扣 (LeetCode)
链接:https://leetcode-cn.com/leetbook/read/sort-algorithms/eu039h/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

排序逻辑没啥变化,只是增量序列有所改变。

希尔排序是不稳定的。
另外,空间复杂度很容易得知是O(1),但是时间复杂度非常难以计算,严格上说并不是对数级别,一般介于O(n)和O(n^2) 之间,普遍认为最好的时间复杂度为O(n^1.3)。不过平时也把它归于O(nlogn)级别的排序算法。

2.2 堆排序

先区分一下大堆小堆:

  • 根节点的值 >= 子节点的值,这样的堆被称之为最大堆,或大顶堆
  • 根节点的值 <= 子节点的值,这样的堆被称之为最小堆,或小顶堆

这个排序的代码就比较多了:

//进行堆排序
private static void heapSort(int[] arr) {
    //构建初始大顶堆
    buildHeap(arr);

	//通过大顶堆对数组进行升序排序
    for (int i = arr.length - 1; i > 0; i--) {
        exchange(arr, 0, i);
        maxHeapify(arr, 0, i);
    }
}

//构建大顶堆
private static void buildHeap(int[] arr) {
    //从最后一个非叶子节点开始调整大顶堆,最后一个非叶子节点的下标就是arr.length / 2 - 1
    for (int i = arr.length / 2 - 1; i >= 0; i--) {
        maxHeapify(arr, i, arr.length);
    }
}

//调整根节点与子节点的位置,是一个下沉过程
private static void maxHeapify(int[] arr, int i, int heapSize) {
    //左子节点下标
    int left = 2 * i + 1;
    //右子节点下标
    int right = left + 1;
    //记录根节点,左子树节点,右子树节点三者中的最大值下标
    int largest = i;
    //与左子树节点比较
    if (left < heapSize && arr[left] > arr[largest]) {
        largest = left;
    }

    //与右子树节点比较
    if (right < heapSize && arr[right] > arr[largest]) {
        largest = right;
    }

    //如果发生了变化,说明需要进行交换
    if (largest != i) {
        exchange(arr, i, largest);
        maxHeapify(arr, largest, heapSize);
    }
}

private static void exchange(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

堆排序的空间复杂度为O(1),时间复杂度为O(nlogn),是不稳定的

2.3 快速排序

快速排序的基本思想是:

  • 从数组中取出一个数,称之为基数
  • 遍历数组,将比基数大的数字放到它的右边,比基数小的数字放到它的左边。遍历完成后,数组被分成了左右两个取余
  • 将左右两个区域视为两个数组,重复前两个步骤,直到排序完成

代码如下:

 private static void quickSort(int[] arr) {
 	quickSort(arr, 0, arr.length - 1);
}

private static void quickSort(int[] arr, int start, int end) {
    if (start >= end) {
        return;
    }

    int loc = partition(arr, start, end);
    quickSort(arr, start, loc - 1);
    quickSort(arr, loc + 1, end);
}

private static int partition(int[] arr, int start, int end) {
    int value = arr[start];

    while (start < end) {
        while (start < end && arr[end] > value) {
            end--;
        }
        arr[start] = arr[end];
        while (start < end && arr[start] <= value) {
            start++;
        }
        arr[end] = arr[start];
    }

    arr[start] = value;
    return start;
}

快排的效率和基准的选择有很大的关系,如果选择的基准比较好,每次尽可能把数组分为等长的两部分,那么效率就最高,反之,如果每次基准的位置都是数组的边缘,快排就会退化成冒泡排序。
因此,应尽量避免最糟情况,方法之一就是随机选取基准值,因此,可以在进行快排的时候,调用洗牌算法,把数组尽可能打乱。

private static void shuffle(int[] arr) {
    Random random = new Random();
    for (int i = arr.length - 1; i > 0; i--) {
        int idx = random.nextInt(i);
        int temp = arr[idx];
        arr[idx] = arr[i];
        arr[i] = temp;
    }
}

即便打乱也需要一定时间,但是整体效率上仍旧是有提升的,我其中的一次测试结果如下:

private static final int LEN = 100_0000;
private static final int END = 100_0000;

public static void main(String[] args) {
    long start1 = System.currentTimeMillis();
    for (int i = 0; i < 10; i++) {
        //生成一个长度为1000000,取值为[0,1000000)的数组
        int[] arr = ArrUtil.build(LEN, END);
        quickSort(arr);
    }
    System.out.println("不对数组进行随机打乱后10次快排总耗时为:" + 
    				(System.currentTimeMillis() - start1) + "毫秒");

    long start2 = System.currentTimeMillis();
    for (int i = 0; i < 10; i++) {
        int[] arr = ArrUtil.build(LEN, END);
        shuffle(arr);
        quickSort(arr);
    }
    System.out.println("对数组进行随机打乱后10次快排总耗时为:" + 
    				(System.currentTimeMillis() - start2) + "毫秒");
}
不对数组进行随机打乱后10次快排总耗时为:1836毫秒
对数组进行随机打乱后10次快排总耗时为:1397毫秒

快排是不稳定的。时间复杂度为O(logn) - O(n^2),一般记为O(logn),空间复杂度和递归层数有关,O(logn) - O(n),一般记为O(logn)

2.4 归并排序

归并排序的原理就是合并两个有序数组

private static void mergeSort(int[] arr) {
	//一个临时数组,这是为了避免在merge中重复声明临时数组
 	int[] temp = new int[arr.length];
    mergeSort(arr, 0, arr.length - 1, temp);
}

//首先对数组进行划分,分到长度1的数组肯定是有序的,之后就是合并操作
private static void mergeSort(int[] arr, int start, int end, int[] temp) {
    if (start >= end) {
        return;
    }
    int mid = start + (end - start) / 2;
    mergeSort(arr, start, mid, temp);
    mergeSort(arr, mid + 1, end, temp);
    merge(arr, start, mid, end, temp);
}

//对两个有序数组进行合并
private static void merge(int[] arr, int start, int mid, int end, int[] temp) {
    int i = start;
    int j = mid + 1;
    int idx = start;

    while (i <= mid && j <= end) {
        if (arr[i] > arr[j]) {
            temp[idx++] = arr[j++];
        } else {
            temp[idx++] = arr[i++];
        }
    }

    while (i <= mid) {
        temp[idx++] = arr[i++];
    }
    while (j <= end) {
        temp[idx++] = arr[j++];
    }
    System.arraycopy(temp, start, arr, start, end - start + 1);
}

归并排序是稳定的,时间复杂度O(logn),空间复杂度O(n)

3.各排序算法时间、空间、稳定性:

排序算法最好时间复杂度平均最坏空间复杂度稳定性
冒泡排序O(n)O(n^2)O(n^2)O(1)稳定
选择排序O(n^2)O(n^2)O(n^2)O(1)不稳定
插入排序O(n)O(n^2)O(n^2)O(1)稳定
希尔排序O(n)O(n^1.3)O(n^2)O(1)不稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(nlogn)O(1)
快速排序O(nlogn)O(nlogn)O(n^2)O(logn) - O(n)不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
;