稳定的排序算法 | 基数排序、冒泡排序、插入排序、归并排序 |
---|---|
不稳定的排序算法 | 堆排序、快速排序、希尔排序、选择排序 |
1. 冒泡排序
平均时间复杂度 | O(n^2):每次以遍历一次数组为代价使1个元素有序,循环n次。 |
---|---|
最坏时间复杂度 | O(n^2) |
稳定度 | 稳定:相等的元素不会交换位置 |
空间复杂度 | O(1):在原数组中进行排序 |
适用场景 | 数组规模较小时 |
排序十万个随机数 | 耗时10679ms |
排序一千个随机数 | 耗时5ms |
// 从后往前逐个使原数组变得有序
public static int[] bubbleSort(int[] array){
// 外层循环:每次循环都使第i大的元素有序
for (int i=0; i < array.length-1; i++){
// 内层循环:通过比较两个元素,找到第i大的元素,并将其挪到数组末尾
// 内层循环做的事,实际上就是保证array[j]是当前遇到的最大元素。
// 毕竟我们要找倒数第i大嘛,前半段无序数组中的最大元素就是第i大
for (int j=0; j < array.length-1-i; j++){
if (array[j] > array[j+1]){
swap(array, j, j+1);
}
}
}
return array;
}
2. 选择排序
平均时间复杂度 | O(n^2):每次以遍历一次数组为代价使1个元素有序,循环n次。 |
---|---|
最坏时间复杂度 | O(n^2) |
稳定度 | 不稳定:array = [5 8 5 2 9],第一次选择第1个元素5与2交换,原序列中两个5的前后顺序被破坏 |
空间复杂度 | O(1):在原数组中进行排序 |
适用场景 | 数组规模较小时 |
排序十万个随机数 | 耗时2597毫秒 |
排序一千个随机数 | 耗时3ms |
public static void selectSort(int[] array){
int minPos = -1;
// 从前往后逐个使array变得有序
// 每次使第i个位置上的元素是有序的
for (int i=0; i<array.length-1; i++){
minPos = i;
// 每次从后面的无序部分选择最小的元素与第i个元素交换
for (int j=i+1; j<array.length; j++){
if (array[j] < array[minPos]){
minPos = j;
}
}
if (i != minPos){
swap(array, i, minPos);
}
}
}
3. 插入排序
平均时间复杂度 | O(n^2):每次以找到合适的插入位置为代价,循环n次。 |
---|---|
最坏时间复杂度 | O(n^2):每次的合适位置都是0,数组中的每个元素每轮循环都需要往后挪一个位置 |
稳定度 | 稳定 |
空间复杂度 | O(1):在原数组中进行排序 |
适用场景 | 大部分已排好序时 |
排序十万个随机数 | 耗时704毫秒 |
排序一千个随机数 | 耗时3ms |
// 从前向后逐个使整个数组变得有序
public static int[] insertSort(int[] array){
// [0,orderedPos]之间的元素是有序的
for(int orderedPos=0; orderedPos < array.length-1; orderedPos++){
// insertNum是下一个将被加入有序数组的数字
int insertNum = array[orderedPos + 1];
// insertPos指向insertNum将要插入的位置
int insertPos = orderedPos + 1;
// insertPos应该大于等于0,并且在insertNum插入有序数组后
// 它的前一个数组应该小于等于它
while (insertPos > 0 && insertNum < array[insertPos-1]){
array[insertPos] = array[insertPos-1];
insertPos--;
}
array[insertPos] = insertNum;
}
return array;
}
TIP 分析:为什么对于10万条随机数据,排序的效率是插入排序>选择排序>冒泡排序?
猜测可能是因为,插入排序每次都是最坏情况,即无论如何都会进行n轮比较,每轮比较中,无序部分的元素都必须参与,而且还有可能发生交换操作。
选择排序则无论如何都会进行n轮比较,每轮比较中,无序部分的元素都必须参与,但是每一轮只会有一次交换操作。
插入排序也会进行n轮比较,但只有有序部分中的部分元素参与比较,而且原数组原本的有序性越好,每一轮的比较次数就越少。
4. 快速排序
// 快速排序,使得array[st,ed]是有序的
public static void quickSort(int[] array,int st,int ed){
if (st >= ed){
return;
}
// 以array[st]为基准,使用双指针不停交换array[st->ed]之间的元素
// 直到找到位置sortedPos,将基准放在这个位置上
// 其左边的元素都小于基准,其右边的元素都大于基准
int sortedPos = findSortedPosition(array,st,ed);
// 分治,处理sortedPos前后两个子数组
// 由于sortedPos后面的子数组的所有元素大于前一个子数组中的所有元素
// 所以分别使两个子数组变得有序之后,就达到了全局有序
quickSort(array, st, sortedPos-1);
quickSort(array, sortedPos+1, ed);
}
// 寻找一个基准array[pivot],使用双指针不停交换array[st->ed]之间的元素
// 双指针相遇时,将基准与array[left]交换
// 使得基准之前的所有元素小于基准,其后的所有元素大于基准
public static int findSortedPosition(int[] array,int st,int ed){
// 维护[st,left]之间的元素都小于或等于array[pivot]
// 维护[right,ed]之间的元素都大于或等于array[pivot]
int left = st;
int right = ed;
int pivot = st;
while (left != right){
while (left != right && array[right] >= array[pivot]){
right--;
}
while (left != right && array[left] <= array[pivot]){
left++;
}
if (left != right){
swap(array, left, right);
}
}
swap(array, pivot, right);
return left;
}
如何理解findSortedPosition这个函数?我用一个表格来解释这个过程。
标为红色的是基准,标为蓝色的是左指针维护的区域,标为绿色的是右指针维护的区域。
右指针的维护的区域为:区域内的元素都大于基准
左指针维护的区域为:区域内的元素都小于基准
我们不难得出,左指针和右指针移动的规律:由于要维护上述两个区域的性质,左指针遇到非法元素(比基准大的元素)会停下来,右指针遇到非法元素(比基准小的元素)会停下来。而且左、右指针相遇时也会停下来。
我们让右指针先移动,最终右指针遇到左指针时,左右指针都会停止在同一格上。
这时候,他们所在的这一格的数值,究竟比基准大还是比基准小?由于最后一轮中,右指针是先移动的,无论它是遇到了非法元素,还是遇到左指针停了下来,最后一轮右指针所指的一定是非法元素——比基准小的元素!
这时候,我们将基准元素与array[right]交换,就得到了最终结果——基准元素左边的元素都比它小,基准元素右边的元素都比它大。
4 | 9 | 7 | 1 | 5 | 3 | 7 | 2 | 初始时,left=st,right=ed |
---|---|---|---|---|---|---|---|---|
4 | 9 | 7 | 1 | 5 | 3 | 7 | 2 | 先移动右指针,第一个元素就是非法元素(比基准元素小的元素) |
4 | 9 | 7 | 1 | 5 | 3 | 7 | 2 | 再移动左指针,第一个元素就是非法元素(比基准元素大的元素) |
4 | 2 | 7 | 1 | 5 | 3 | 7 | 9 | 交换左右指针所指的元素,保证左、右区域的合法性 |
4 | 2 | 7 | 1 | 5 | 3 | 7 | 9 | 先移动右指针,右指针遇到非法元素3停下来了 |
4 | 2 | 7 | 1 | 5 | 3 | 7 | 9 | 再移动左指针,左指针遇到非法元素7停下来了 |
4 | 2 | 3 | 1 | 5 | 7 | 7 | 9 | 交换左右指针所指的元素,保证左、右区域的合法性 |
4 | 2 | 3 | 1 | 5 | 7 | 7 | 9 | 先移动右指针,右指针遇到非法元素1停下来了 |
4 | 2 | 3 | 1 | 5 | 7 | 7 | 9 | 左右指针相遇,而且右指针指向比基准元素4小的元素 |
1 | 2 | 3 | 4 | 5 | 7 | 7 | 9 | 将基准元素与array[right]交换,最终基准元素左侧的元素都小于基准元素,右侧的元素都大于基准元素。 |
如果让左指针先移动会怎么样?基于上述分析,我们知道左指针一定会向右移动并最终指向非法元素——比基准元素大的元素,这时候将基准与其交换,坏了,array中的第一个元素比基准元素大了。
平均时间复杂度 | O(nlogn) |
---|---|
最坏时间复杂度 | O(n^2) |
稳定度 | 不稳定:举个例子,[5,9,9,2],第一次划分的时候,首先9与2交换, |
就破坏了两个9的相对位置 | |
空间复杂度 | O(nlogn):快排并没有开辟空间,但是使用了递归,递归会开辟栈帧, |
递归算法的空间复杂度 = 每次递归的空间复杂度*递归深度 | |
适用场景 | n大的时候好 |
排序十万个随机数 | 耗时15ms |
排序一千个随机数 | 耗时1ms |
排序一百万个随机数 | 耗时94ms |
快速排序的时间复杂度分析——为什么平均时间复杂度是O(nlogn)?什么情况下退化成O(n^2)?
- 用注释分析一下平均时间复杂度为什么是O(nlogn)
public static void quickSort(int[] array,int st,int ed){
if (st >= ed){
return;
}
// 划分的时间复杂度是O(n),因为需要比较每个元素,可能要交换一些元素
int sortedPos = findSortedPosition(array,st,ed);
// 采用分治的思路,每调用一次quickSort进行分治,实际上数据规模是成倍减小的。
// 拿最好的情况来说,sortedPos正好是array正中间的位置
// 那么这部分最好的时间复杂度是O(log2n)
// 总而言之,治理嵌套了划分,两部分的时间复杂度相乘得到O(nlogn)
quickSort(array, st, sortedPos-1);
quickSort(array, sortedPos+1, ed);
}
- 那什么时候退化成O(n^2)呢?答案是array已经排好序、或者逆序排序的情况下。递归二叉树画出来应该是一棵斜树。
因为在排好序的情况下,由于基准元素是第一个元素,那么经过一轮时间复杂度为O(n)的比较之后,发现基准元素依然只能在原地不动。而且这种划分还会进行n次,为什么要进行n次呢?原本明明是logn啊,这是因为每次划分,左边部分元素数量为0,右边部分的元素数量为n-1,每调用一次quickSort数据规模只减了1,所以"治理"的时间复杂度也退化成O(n)了,两部分相乘,最坏情况下的时间复杂度就是O(n^2)。
逆序的情况跟排好序的情况类似。
如何避免最坏情况的发生?换句话说,怎么优化快速排序?
目前一种比较好的优化方法是三数取中。具体来说,在进行划分的时候,我们取array[st]、array[ed]、array[mid]这三个值的中间元素作为基准,其中mid = st + (ed-st)/2;
这样做有什么好处呢?考虑一下最好情况,我们希望基准正好是array[st->ed]这部分的中间值,取这三个数再取中间值实际上是一种贪心的思路,希望取到的值既不是最大也不是最小,可以避免最坏情况。
增加了三数取中优化的代码如下:
public static void quickSort(int[] array,int st,int ed){
if (st >= ed){
return;
}
int sortedPos = findSortedPosition(array,st,ed);
quickSort(array, st, sortedPos-1);
quickSort(array, sortedPos+1, ed);
}
public static int findSortedPosition(int[] array,int st,int ed){
// 三数取中
makeLowMid(array, st, ed);
int left = st;
int right = ed;
int pivot = st;
while (left != right){
while (left != right && array[right] >= array[pivot]){
right--;
}
while (left != right && array[left] <= array[pivot]){
left++;
}
if (left != right){
swap(array, left, right);
}
}
swap(array, pivot, right);
return left;
}
// 保证array[low]是array[low]、array[mid]、array[high]里的中间值
public static void makeLowMid(int[] array,int low, int high){
int mid = low + ((high-low)>>1);
// 保证array[high]大于array[mid]
if (array[mid] > array[high]){
swap(array, mid, high);
}
// 保证array[high]是三个数中最大的
if (array[low] > array[high]){
swap(array, low, high);
}
// 保证array[mid] < array[low]
if(array[mid] > array[low]){
swap(array, low, mid);
}
// 这样一来,array[high]是最大值,array[low]是中间值,array[mid]是最小值
}
5. 归并排序
public static void sort(int[] array,int st,int ed){
if (st >= ed){
return;
}
int mid = st + ((ed-st)>>1);
sort(array, st, mid);
sort(array, mid+1, ed);
merge(array, st, mid, ed);
}
public static void merge(int[] array,int st,int mid,int ed){
int[] helperArray = new int[ed-st+1];
int helperPos = 0;
int left = st;
int right = mid+1;
while (left <= mid && right <= ed){
// 为了保证稳定性,这里的条件必须是array[left] <= array[right]
// 在前后两个数组有相同元素的情况下,前一个数组的元素优先被排序
if (array[left] <= array[right]){
helperArray[helperPos++] = array[left++];
} else {
helperArray[helperPos++] = array[right++];
}
}
while (left <= mid){
helperArray[helperPos++] = array[left++];
}
while (right <= ed){
helperArray[helperPos++] = array[right++];
}
System.arraycopy(helperArray,0,array,st,helperArray.length);
}
| 平均时间复杂度 | O(nlog2n):分:数据规模每次减少一半,时间复杂度为O(log2n)
治:合并数组时间复杂度为O(n) |
| — | — |
| 最坏时间复杂度 | O(nlog2n) |
| 稳定度 | 稳定:合并两个数组的时候,在前后两个数组有相同元素的情况下,前一个数组的元素优先被排序 |
| 空间复杂度 | O(n):需要创建辅助数组 |
| 适用场景 | n大的时候好 |
| 排序十万个随机数 | 耗时15ms |
| 排序一千个随机数 | 耗时1ms |
| 排序一百万个随机数 | 耗时134ms |
6. 基数排序
对array[053, 542, 003, 063, 014, 214, 154, 748, 616]进行基数排序,先让个位有序、再让个十位有序、再让个十百位有序…直到最终全局有序。具体过程如下:
原始序列 | 053, 542, 003, 063, 014, 214, 154, 748, 616 |
---|---|
按个位数排序 | 542, 053, 003, 063 ,014, 214, 154, 616, 748 |
按十位数排序 | 003, 014, 214, 616, 542, 748, 083, 154, 063 |
按百位数排序 | 003, 014, 053, 063, 154, 214, 542, 616, 748 |
平均时间复杂度 | O(k*N):array中最大的数字是k位数,那么需要收集k次,相当于遍历k次array |
---|---|
最坏时间复杂度 | O(k*N) |
稳定度 | 稳定:对两个相等的元素来说,左边的元素先被加入到桶中 |
空间复杂度 | O(n):需要创建两个桶数组 |
适用场景 | array中的元素都是正整数,且最大元素的数量级比较小 |
排序十万个随机数 | 耗时102ms |
排序一千个随机数 | 耗时3ms |
排序一百万个随机数 | 耗时1187ms |
// 使得array中的元素按照局部有序,最终全局有序
// 先让个位有序、再让个十位有序、再让个十百位有序....直到最终全局有序
public static void bucketSort(int[] array){
// sortedBucket: 已经按某位数收集到各个桶中
// collectorBucket: 按照sortedBucket的每个桶从左至右的顺序,按下一位数收集到每个桶中
LinkedList<Integer>[] sortedBucket = new LinkedList[10];
LinkedList<Integer>[] collectorBucket = new LinkedList[10];
for(int i=0; i<10; i++){
sortedBucket[i] = new LinkedList<Integer>();
collectorBucket[i] = new LinkedList<Integer>();
}
// 初始化,按array的个位存放到sortedBucket的各个桶中,并找出其中最大的元素
// 由于我们要求array只能存放正整数,所以max初始化为-1即可
int max = -1;
for (int num : array){
sortedBucket[getLayer(num,1)].add(num);
max = Math.max(max, num);
}
// layer是max的数量级
int maxLayer = 0;
while (max != 0){
maxLayer++;
max/=10;
}
// layer是目前已经完成排序的数量级数
// 比如layer=2时,说明已经按个位、十位排好序了
int layer = 1;
while (layer++ < maxLayer){
// 从sortedBucket中的第0个桶按下一位数收集到collectorBucket中,直到收集完10个桶
for(int bucketPos=0; bucketPos<10; bucketPos++){
LinkedList<Integer> nums = sortedBucket[bucketPos];
while (!nums.isEmpty()){
Integer num = nums.pollFirst();
collectorBucket[getLayer(num, layer)].add(num);
}
}
// 完成收集之后,现在sortedBucket的桶都空了,collectorBucket完成了收集,所以得交换他们的引用
LinkedList<Integer>[] temp = collectorBucket;
collectorBucket = sortedBucket;
sortedBucket = temp;
}
// 将sortedBucket中排好序的结果放回原数组
int pos = 0;
for(int bucketPos=0; bucketPos<10; bucketPos++){
LinkedList<Integer> nums = sortedBucket[bucketPos];
while (!nums.isEmpty()){
Integer num = nums.pollFirst();
array[pos++] = num;
}
}
}
// 获得num的第layer位数
public static int getLayer(int num, int layer){
num /= Math.pow(10, layer-1);
return num%10;
}
7. 希尔排序
希尔排序的思想:我认为是对插入排序的一种改进,因为如果原数组的有序性很差,那么插入排序会频繁发生元素的移动,特别是当原数组是逆序时,每次都会将元素插入到数组最开头的位置,其余的元素都会往后挪一格,导致最坏情况下性能差。
而希尔排序会按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以每一组参与插入排序的元素个数很少,速度很快。当元素基本有序了,步长很小, 插入排序对于有序的序列效率很高。
平均时间复杂度 | O(n1.3) |
---|---|
最坏时间复杂度 | O(n2) |
稳定度 | 不稳定:元素在某个分组中可能被交换,另一组中与之相等的元素可能不会被交换 |
空间复杂度 | O(1):在原数组上排序 |
适用场景 | 原数组较为有序时 |
排序十万个随机数 | 耗时27ms |
排序一千个随机数 | 耗时0ms |
排序一百万个随机数 | 耗时332ms |
public static void shellSort(int[] array){
// 初始化步长
int step = array.length/2;
while (step != 0){
for(int startPos=0; startPos<step; startPos++){
// 按照步长分组,并对每一组进行插入排序
insertSortByStep(array, startPos, step);
}
// 使步长折半
step/=2;
}
}
// 下标为start+step*0、start+step*1、start+step*2...被分为一组
// 对这一组元素进行插入排序
public static void insertSortByStep(int[] array,int start,int step){
for(int insertPosition = start+step; insertPosition <array.length; insertPosition +=step){
int insertNum = array[insertPosition];
while (insertPosition > start && insertNum < array[insertPosition-step]){
array[insertPosition] = array[insertPosition-step];
insertPosition -= step;
}
array[insertPosition] = insertNum;
}
}
8. 堆排序
(1) 什么是大根堆、小根堆?
最大堆(大根堆):完全二叉树中的任意结点的关键词大于等于它的两个子结点的关键词。把这样的数据结构称为最大堆。
最小堆(小根堆):完全二叉树中的任意结点的关键词小于等于它的两个子结点的关键词。把这样的数据结构称为最小堆。
(2)堆排序为什么快?
堆排序是对简单选择排序方法的改进,简单选择排序算法的时间大部分都浪费在关键词的比较上,而锦标赛排序刚好用树形保存了前面比较的结果,下一次选择时直接利用前面比较的结果大大减少了比较次数。
平均时间复杂度 | O(nlogn) |
---|---|
最坏时间复杂度 | O(nlogn) |
稳定度 | 不稳定 |
空间复杂度 | O(1) |
适用场景 | n较大时 |
排序十万个随机数 | 50毫秒 |
排序一千个随机数 | 2毫秒 |
排序一百万个随机数 | 759毫秒 |
备注 | 堆排序中的删除、上浮、下沉、插入操作的时间复杂度均为O(logn) |
public static void heapSort(int[] array){
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int num : array){
pq.add(num);
}
for(int i=0; i<array.length; i++){
array[i] = pq.poll();
}
}