算法学习02: 各种排序算法
排序算法
基于交换的算法
冒泡排序
每一轮遍历数组将最大的数沉到已排序部分最后一位
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int e = arr.length - 1; e > 0; e--) {
for (int i = 0; i < e; i++) {
if (arr[i] > arr[i + 1]) {
swap(arr, i, i + 1);
}
}
}
}
复杂度分析:
冒泡排序时间复杂度O(N2),额外空间复杂度O(1)
选择排序
每一轮遍历将未排序部分最小值取出,放到已排序部分末尾
public static void selectionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
minIndex = arr[j] < arr[minIndex] ? j : minIndex;
}
swap(arr, i, minIndex);
}
}
复杂度分析:
选择排序的时间复杂度O(N2),额外空间复杂度O(1)
插入排序
不同于仅作为教学研究意义的冒泡排序与选择排序,插入排序仍有实际应用.
已排序部分位于数组首部,新元素插入已排序部分时,不断与其前一位进行交换直到找到合适位置.
public static void insertionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 1; i < arr.length; i++) {
for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
// j始终跟着新插入的元素
swap(arr, j, j + 1);
}
}
}
复杂度分析:
时间复杂度:
- 最好情况(数组已经排好序,不需要交换): O(N)
- 最坏情况(数组已经逆序排序,需交换到底): O(N2)
- 平均情况:O(N2)
额外空间复杂度:O(1)
归并排序(分治思想)
将数组分为左右两部分,分别排序完成后,再借助外部数组将已排序的两部分归并.
public static void mergeSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
mergeSort(arr, 0, arr.length - 1);
}
public static void mergeSort(int[] arr, int l, int r) {
if (l == r) {
return;
}
int mid = l + ((r - l) >> 1);
mergeSort(arr, l, mid);
mergeSort(arr, mid + 1, r);
merge(arr, l, mid, r);
}
public static void merge(int[] arr, int l, int m, int r) {
int[] help = new int[r - l + 1];
int i = 0;
int p1 = l;
int p2 = m + 1;
while (p1 <= m && p2 <= r) {
help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
}
while (p1 <= m) {
help[i++] = arr[p1++];
}
while (p2 <= r) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[l + i] = help[i];
}
}
复杂度分析
时间复杂度: 套用递归的时间复杂度分析的master公式:O(N)=2*O(N/2)+O(N),因此总的时间复杂度为O(N*logN).
额外空间复杂度:O(N)
为什么归并算法更快? 因为组内的比较不会被浪费,一个组内之间的所有元素在排好序之后不会再比较,归并过程永远是跨组比较.
注意要点:归并问题在计算mid时,有两种算法
mid = (l+r)/2
mid = l+(l-r)/2
为防止溢出,第二种方法更好
分治思想的应用: 逆序对问题(leetcode 315,leetcode493)
分治思想适合解决一个集合中两两发生关系的问题.,将数据分组,先在组内发生关系,再在组件发生关系.
- 问题描述:逆序对问题: 在一个数组中,左边的数如果比右边的数大,则这两个数构成一个逆序对,请打印所有逆序对
- 解法: 将样本分成两个小组,两小组merge时查找逆序对,这样小组内逆序对不会重算,小组间逆序对小和不会漏算
快速排序
荷兰国旗问题(leetcode75)
引例:给定一个数组arr,和一个数num,请把小于等于num的数放在数
组的左边,大于num的数放在数组的右边。
解法:
- 用指针cur遍历数组,初始阶段小于num和大于num序列长度均为0.
- 若cur指向值大于num,则cur右移
- 若cur指向值小于num,则将这个小于num的数与大于num区间的第一个交换,小于num区间长度加一.
荷兰国旗问题:有很多个相同等的num,要求小于num放左边,大于num放右边
解法:
- 用指针cur遍历数组,用less指向小于num部分的最后一个数,用more指向大于num部分的第一个数(初始时less=-1,more=数组长度).
- 若arr[cur]小于num,则arr[cur]应被放入小于num部分. 因此将arr[cur]与arr[less+1]交换,且less右移一位(小于num部分长度增加),cur右移一位(被换过来的数字一定等于num或为原cur).
- 若arr[cur]大于num,则arr[cur]应被放入大于num部分,因此将arr[cur]与arr[more-1]交换,且more左移一位(大于num部分长度增加),cur不动(被换过来的数字与num大小关系未知).
- 若arr[cur]等于num,则cur右移一位.
- 重复上述操作直到cur与more重合,所有数字已经被归类.
- 考虑边界条件-若有一方不存在: 若大于num不存在,则more没有动过;若小于num不存在,则less没有动过;若等于num部分不存在,则程序结束时,cur=more=less+1.这三种情况下代码都是对的,
快速排序的几种改进
- 经典快排: 每次选取最后一个数做基准,把小于等于部分放在左边,大于部分放右边.再对左右两部分继续划分.
- 改进的快排(荷兰国旗问题): 把小于的部分放在左边,等于部分放中间,大于部分放右边. 这样等于区域在下次划分时不用考虑了.
- 随机快排:每次选取数组中一个随机位置的数为基准进行划分
public static void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int l, int r) {
if (l < r) {
// 选择随机位置的值作为标准值
swap(arr, l + (int) (Math.random() * (r - l + 1)), r);
int[] p = partition(arr, l, r);
quickSort(arr, l, p[0]);
quickSort(arr, p[1], r);
} else {
// 划分过程只有在区间长度大于一时发生
return;
}
}
public static int[] partition(int[] arr, int l, int r) {
// partition 默认以arr[l,r]中最后一个元素做划分
int less = l - 1;
int more = r + 1;
int num = arr[r];
int cur = l;
while (cur < more) {
if (arr[cur] < num) {
swap(arr, ++less, cur++);
} else if (arr[cur] > num) {
swap(arr, --more, cur);
} else {
cur++;
}
}
// 返回无序区间顶点
return new int[] { less, more };
}
复杂度分析
- 经典快排的时间复杂度与数据状况有关
- 数据状况很差时,小于区域和大于区域规模很可能不相等,这样快排就退化成为
选择排序,其时间复杂度为O(N2) - 数据状况较好时,每次分配左右两部分规模相同,则由master公式,有O(N*logN)
- 数据状况很差时,小于区域和大于区域规模很可能不相等,这样快排就退化成为
- 随机快排:长期期望的时间复杂度有O(N*logN)
快排是最常用的排序算法,在三个O(N*logN)排序算法中胜在常数项低.
- 额外空间复杂度O(logN),空间用于记录每次划分的划分点
堆排序
堆:数据结构
- 堆结构本质上是一棵完全二叉树,堆可以由数组实现
a[i]
的左子节点为a[2*i+1]
,右子节点为a[2*i+2]
,父节点为a[(i-1)/2]
.a[0]
的父节点是a[0]
本身.
- 堆的性质:
- 大根堆: 任何一棵子树的最大值为树根节点
- 小根堆: 任何一棵子树的最小值为树根节点
// 将value压入容量为size的堆
public static void push(int[] heap, int size, int value) {
heap[size] = value;
int cur = size;
while (heap[cur] > heap[(cur - 1) / 2]) {
swap(heap, cur, (cur - 1) / 2);
cur = (cur - 1) / 2;
}
}
// 弹出堆顶,并将数组重整为大根堆
public static int pop(int[] heap, int size) {
int ret = heap[0];
swap(heap, 0, --size);
int cur = 0;
while (cur < size) {
int left = cur * 2 + 1;
if (left >= size) {
break;
}
int right = left + 1;
int largest = (right < size && heap[left] < heap[right]) ? right : left;
if (heap[largest] <= heap[cur]) {
break;
} else {
swap(heap, cur, largest);
cur = largest;
}
}
return ret;
}
堆的应用(leetcode295)
- 问题描述:数据流的中位数
有一个流始终吐出一些数,要在任何时刻找到其中位数: - 解法:
- 建立两个堆,一个大根堆和一个小根堆
- 我们想要尽量让大的N/2个数放在小根堆中,小的N/2个数放在大根堆中.这样,小根堆顶存储较大N/2数中最小值,大根堆顶存储较小N/2数中最大值,中位数一定在两个堆顶中产生.
- 若吐出的数大于大根堆顶,说明数属于较大的N/2,放入小根堆.
- 若吐出的数小于小根堆顶,说明数属于较小的N/2,仿佛大根堆.
- 若发现两堆容量差值大于1,则将容量大的堆顶弹出压入较小的堆.
堆排序
思路: 建立一个大根堆,先依次将数组元素全部加入大根堆,再逐个弹出到数组尾部.
注意: 要时刻保证数组不越界
插入元素的时候考虑边界条件: 若插入元素很大,浮到堆顶,那么会发生arr[0]与arr[0-1/2]=arr[0]相比较,也会使插入停止.
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 0; i < arr.length; i++) {
push(arr,i, arr[i]);
}
int size = arr.length;
while (size > 0) {
arr[size-1] = pop(arr,size);
size--;
}
}
时间复杂度
时间复杂度O(N*logN).
额外空间复杂度为O(1).
不基于比较的排序
非基于比较的排序,其性能与数据状况有关, 因此不常用.
桶排序
计数排序
按照数据状况,设计几个容器,记录每个容器中数据个数.最后还原数组.
基数排序
当数据范围很大时,对数字从高到低每一位进行计数排序.
补充问题(leetcode164)
问题描述:给定一个数组,求如果排序之后,相邻两数的最大差值,要求时间复杂度O(N),且要求不能用非基于比较的排序.
解法:
先找出最大最小值,然后准备n+1
个桶,将n
个数据插空放入这n+1
桶中.
其中必有一个桶为空.这保证了我们求出的最小临差必然大于一个空桶的长度,但这不能保证临差一定发生在空桶两侧.
证明:
桶个数的选取有意思,桶的个数可以多于n+1
,但不能少.
这是因为我们要保证最大临差发生在桶间,而非桶内.因此桶的宽度必然要小于我们可以证明的最小临差.
排序算法的稳定性及其汇总
- 稳定性: 原数组中两个相同的值,是否会因为排序算法导致相对顺序被打乱.
- 冒泡排序: 可以实现稳定. 当正在向下沉的元素遇到相同值的元素,不进行交换,转而操作第二个相同元素下沉.
- 插入排序: 可以实现稳定. 将元素向前插入有序部分时,若找到前面有相同值的元素,就不再往前插就行.
- 选择排序: 不能实现稳定. 选择无序部分最小值与无序部分首个值交换,这个交换过程中可能会使无序部分首个值跨过多个相同值的元素到后边(非邻位交换).如:
- 序列:[51, 52, 4, 3, 2, 1], 第一次选择下来将1与51交换,51被交换到了52的后面
- 归并排序: 可以实现稳定. 归并过程中相同元素间先拷贝前边数组元素.
- 快速排序: 不能实现稳定. partition过程随机选择参考值,且more,less部分的扩展过程发生交换类似于选择排序(非邻位交换).
- 堆排序: 不能实现稳定. 堆中元素的上浮下沉操作发生的交换类似于选择排序(非邻位交换).
非邻位交换: 一个我发明的词.
当将数组中某元素移动位置时,若是移动过程可以等效成多次连续与其前一个或后一个位上的数字交换,则这种交换过程称为邻位交换.这种交换过程中可以判断相同值元素的存在并改变交换策略(停止交换 或者 把指针指向相同值的另一元素,操作该元素继续交换过程).
只有排序算法中所有的元素交换都是邻位交换,排序算法才能保证稳定性.
工程中的综合排序
- 如果数组长度很短(小于60),就用插排(插排的时间常数项很低,在小样本情况下极快).
- 如果数组长度很长,再对数组中的数据类型进行分析:
- 如果装的是基础类型,则不涉及稳定性问题,用快排.
- 如果装的是我们自己定义的class,可能考虑到稳定性问题,就是用归并排序.
- 在分治的过程中,若分治部分长度小于60,就应用插排.