基于比较的七大排序算法
这篇博客可以作为排序算法的复习,并不适合学习。
以下基本上只写明了代码,没有层序渐进的一步步写明步骤,也没有对复杂度进行分析,只是一个总结。
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) | 稳定 |