首先需要明确的是,我们说的排序默认就是从小到大的,也即升序
时间复杂度是衡量算法流程的复杂程度,只与数据量有关,与过程之外的优化无关。此外,还有常数时间:如果一个操作不与具体的数据量有关,那就是常数时间,即O(1)。比如说HashMap你可能存了几万个数,但我只查某一个桶上的位置,这个桶上的数据通常就只有有限的几个,查几次也好 几十次也好 通常都认为是O(1)的操作,因此我们说哈希表的时间复杂度是大常数。
纯小白可以参考(并不严谨,但对于快速入门应该能起到一定的帮助),可以粗浅地理解为for(i....N)就是O(N),两个非嵌套的for(i....N)总体还是O(N),但如果是嵌套的两个for(i....N)就是O(N^2)。
空间复杂度的话,有限几个变量就是O(1),如果需要利用额外的数组进行辅助,就是O(N)。注意,可以认为答案所需要返回的数组通常不算在空间复杂度内。(仍然仅供参考)
我认为所谓的最优解大概就是在保证时间复杂度尽可能低的情况下,使用尽量少的空间完成。
接下来就是排序算法的稳定性:这里的稳定性并不是说时间复杂度忽快忽慢,因为时间复杂度都是按最差的情况来算的,没有所谓的时间复杂度忽快忽慢。稳定性简单说来就是:相同的值,你排完后次序是否会发生变化。
举例:经过某次排序后的情况是y1 y2 y3,如果无论怎么排你都是这个相对次序,也即y1在y2之前,y2在y3之前,那么就是稳定的排序算法。
对于基本数据类型来说,算法排序的稳定性没什么意义。本来你基本数据类型也没区分,都是字面量。何谈什么相对次序呢?
对于引用数据类型就很有意义了。比如说你根据价格进行排序,然后你再根据评价排序,那么排在最前面的就是评价最高的,并且评价最好的商品中内部价格也是最便宜的。也即物美价廉
还需要强调的是,排序算法的稳定性是说其算法能够经过代码控制能够做到的,有些无论如何也无法改写成稳定性。如果就是故意乱写,那什么算法也无法做到稳定性的~
-
选择排序——时间复杂度O(N^2),空间复杂度O(1),不稳定
选择排序的基本思想是在未排序序列中选择最小,然后将其放到已排序序列的末尾。 可以代入一种边界的感觉,i之前的都是有序,从i+1往后找一个最小的,和i交换。然后扩大i,直到 i == N
每次从i ~ N-1 中选择最小的数放到i号位置上。比如 0 ~ N选最小放到0号位置(交换),1 ~N选最小放到1号位置(交换)...
选择排序是不稳定的,比如:3 3 2。根据选择排序算法,会将2和第一个3进行交换,那么第一个3就排在了第二个3后面,也即 2 3 3 .无法做到稳定性(再次强调,基本数据类型仍然没有所谓的稳定性,这里只是为了方便举例。可以把例子中的数字看成对象~)
//选择排序
class Solution{
//排序无需返回值,Java引用传递
public void selectionSort(int[] arr) {
//如果发现数组为null,或者没有元素、或者只有一个
if (arr == null || arr.length < 2) {
//没必要排序,直接返回
return;
}
int N = arr.length;
for (int i = 0; i < N; i++) { //最外层的for表示 i ~ N 范围依次变有序
//假设当前i位置就是最小值的索引
int minIndex = i;
//内层for表示:从i+1开始找 i+1 ~ N上的最小值的索引
for (int j = i + 1; j < N; j++) {
//只要发现有元素比我当前最小值还小,minIndex记录下来
minIndex = arr[j] < arr[minIndex] ? j : minIndex;
}
//最后,i和minIndex上的数进行交换,表示0~i上已经变有序
swap(arr, i, minIndex);
}
}
}
-
冒泡排序——时间复杂度O(N^2),空间复杂度O(1),稳定
如果说我们的选择排序是每次挑一个最小的放到前面来,那么冒泡排序就是每次挑一个最大的数放到后面去
冒泡可以做到稳定性,两两相等的时候不让他们交换就行了。【两两相等不往后】
3231 ——》2331(相等,橙3不能再往后)——》2313 ——》 2133——》1233
可以看到橙3始终在绿3之前。值得一提的是,我这里提供的代码也是稳定性的。只有之前的数严格大于我,才能交换~
//冒泡排序
class Solution{
public static void bubbleSort(int[] arr){
//排序操作默认的健壮性判断
if (arr == null|| arr.length < 2){
return;
}
//表示0 ~ N-1排序,0 ~ N-2上排序,0 ~ N-3上排序
//【每次循环都会导致最大的数被交换到最后】
for(int end = arr.length -1 ; end >= 0 ; end--) {
for (int i = 1; i <= end; i++) {
if (arr[i - 1] > arr[i]) {//严格大于才交换,保证稳定性
swap(arr, i, i - 1);
}
}
}
}
}
-
插入排序——时间复杂度O(N^2),空间复杂度O(1),稳定
插入排序的基本思想是将一个元素插入到已经排好序的部分,逐步构建有序序列。
可理解为整理牌,来一个新的牌就往前找合适的位置放。也是稳定的。【两两相等不往前】
//插入排序
class Solution{
public void insertionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
//最外层for就表示将新的卡牌加入我们的队伍
for (int i = 1; i < arr.length; i++) {
//内层for表示它应该往前移动到哪?
for (int j = i - 1; j >= 0; j--) {
//之前的数严格大于我,才能前进。
if(arr[j] > arr[j + 1]){//严格大于才交换,保证稳定性
//swap(arr,j,j+1)
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
/**
关于这里的交换,还有一种装逼的写法。不过需要确保交换的两个位置不能是同一个索引。只要不是相同位置交换,都可以用。也就是说你不能0位置和0位置进行交换。由于这里的插入排序代码,保证了j和j+1不可能是同一个数组索引,因此可以使用这种方式
public void swap(int[] arr, int i, int j) {
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];
}
/*
}
}
-
归并排序——时间复杂度O(N * logN),空间复杂度O(N),稳定
归并排序是一种分治算法,它将一个大的问题分解成小的子问题来解决,然后将子问题的解合并起来,最终得到整个问题的解。归并排序的基本思想是分而治之,主要的步骤是
- 拆分: 将待排序的数组分成两个长度相等的子数组。
- 递归: 递归地对两个子数组进行归并排序。
- 合并: 将已经排序的两个子数组合并成一个有序数组。
归并排序也可以做到稳定。当左组和右组都出现了相同的时候,优先拷贝左边的。保证左边的1不会跑到右边的1。左组:[1 1 2] 右组:[1 1 3] ——》合并:[1 1 1 1 2 3]
//归并排序
class Solution{
public void mergeSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
dfs(arr, 0, arr.length - 1);
}
//将arr[l..r]排序
public void dfs(int[] arr, int L, int R) {
if (L == R) { // base case
return;
}
int mid = L + ((R - L) >> 1);
dfs(arr, L, mid);
dfs(arr, mid + 1, R);
//merge前提:左右都有序了
merge(arr, L, mid, R);
}
//合并过程需要辅助数组,那么就在merge过程(方法)中定义辅助数组
public void merge(int[] arr, int L, int M, int R) {
int[] help = new int[R - L + 1];//L到R上有多少个数,辅助数组就准备多大
int i = 0;//为help数组准备的索引
int p1 = L;//左半部分的第一个数
int p2 = M + 1;//右半部分的第一个数
//开始拷贝咯
//当两边都未越界时
while (p1 <= M && p2 <= R) {
//【相等的时候,仍然要拷贝左边p1,保证稳定性】
help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
}
// 要么p1越界了,要么p2越界了。必然会一个越界,一个没越界(规定相同就拷左边)
// 下面两个while只会发生一个,谁没越界谁把它剩下的拷贝到help去
while (p1 <= M) {
help[i++] = arr[p1++];
}
while (p2 <= R) {
help[i++] = arr[p2++];
}
//最后把help中的数拷贝到原数组中
for (i = 0; i < help.length; i++) {
arr[L + i] = help[i];
}
}
}
-
随机快排——时间复杂度O(N * logN),空间复杂度O(logN),不稳定
快速排序使用分治策略将一个数组分成两个子数组,然后递归地对子数组进行排序。在快速排序中,通常会选择一个基准元素,将小于基准的元素放在左边,大于基准的元素放在右边,然后对左右两个子数组分别进行递归排序。
随机快排是快速排序的一种变体,它在选择基准元素时采用随机化的方式。这样可以降低快速排序在某些特定情况下的最坏情况时间复杂度,并提高算法的平均性能。
随机快排并不是稳定的,因为分区(Partition)过程并不是稳定的。比如当前选的基准元素是3,那么对于4 4 2就会变成 2 4 4
//正宗的随机快排~~~
class Solution{
public void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
dfs(arr, 0, arr.length - 1);
}
public void dfs(int[] arr, int L, int R) {
if (L >= R) {
return;
}
//随便选一个数作为基准元素
swap(arr,
L + (int) (Math.random() * (R - L + 1)),
R);
//进行分区
int[] equalArea = partition(arr, L, R);
//子过程再次进行
dfs(arr, L, equalArea[0] - 1);
dfs(arr, equalArea[1] + 1, R);
}
public int[] partition(int[] arr, int L ,int R){
if (L > R) { // L...R 居然出现了L>R,不是有效范围
return new int[] { -1, -1 };
}
if (L == R) {
return new int[] { L, R };
}
int less = L - 1; // < 区 右边界
int more = R; // > 区 左边界
int index = L;//遍历到的索引位置
while (index < more) { // 当前位置,不能和 >区撞上
//三种情况:
//①如果当前数 == 目标,直接跳下一个
if (arr[index] == arr[R]) {
index++;
//②当前数 小于 目标 ==》和小于区下一个交换,并跳下一个
} else if (arr[index] < arr[R]) {
swap(arr, index++, ++less);
//③当前数 大于 目标 ==》 和大于区上一个交换,但 不动 当前数index!
} else { // >
swap(arr, index, --more);
}
}
//跑完上面的while后,在L~R-1上已经做好划分了。此时 <R =R >R
//还需要把R和大于区的第一个进行交换。因为是根据arr[R]进行划分的!
swap(arr, more, R);
//less是小于区的最后一个 因此less+1就是等于区的第一个
//more位置原来是大于区第一个,但是经过了上面的swap,more位置上的数就变成了目标数。
return new int[] { less + 1, more };
}
}
-
堆排序——时间复杂度O(N * logN),空间复杂度O(1),不稳定
堆排序是一种基于二叉堆数据结构的排序算法。它的基本思想是将待排序的元素构建成一个二叉堆,然后依次取出堆顶的最大元素,将取出的元素放到已排序部分的末尾,再调整堆,直到所有元素都被取出。【大白话:先建堆(大根堆)——》和堆底交换(数组末尾)——》调堆】
在建立大根堆时其实就不稳定了,可能会交换相等元素的相对位置。比如数组是[1, 1, 1,2] ,建堆会2和1交换。那么绿色的1就会跑到蓝色的1后面[1, 2, 1,1] 。具体的过程画一个二叉树就好理解了,作者还不知道怎么画图~~~
class Solution{
public void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
//【建堆过程二选一】
// O(N*logN) ————这个是从上到下的建堆方式。如果是动态数据流(不断加入数组),用这种
/*for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);
}*/
// O(N) ————这个是从下到上的建堆方式。如果是静态数据流(一开始就有所有的数据),用这种
for (int i = arr.length - 1; i >= 0; i--) {
heapify(arr, i, arr.length);
}
//堆的大小是数组的大小
int heapSize = arr.length;
swap(arr, 0, --heapSize);
// 整体时间复杂度 O(N*logN)
while (heapSize > 0) { // O(N)
//先调堆
heapify(arr, 0, heapSize); // O(logN)
//再下一轮交换,直至heapSize == 0
swap(arr, 0, --heapSize); // O(1)
}
}
// arr[index]刚来的数,往上
public void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
// arr[index]位置的数,能否往下移动
public void heapify(int[] arr, int index, int heapSize) {
int left = index * 2 + 1; // 左儿子的下标
while (left < heapSize) { // 下方还有儿子的时候
// 两个儿子中,谁的值大,把下标给largest
// 1)只有左儿子,left -> largest
// 2) 同时有左儿子和右儿子,右儿子的值 <= 左儿子的值,left -> largest
// 3) 同时有左儿子和右儿子并且右儿子的值 > 左儿子的值, right -> largest
int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1 : left;
// 父和较大的儿子之间,谁的值大,把下标给largest
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) {
// 儿子都没有我值大,不再往下
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
public void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
- 计数排序——时间复杂度O(N),空间复杂度O(M),稳定
计数排序是一种线性时间复杂度的稳定排序算法(非比较性),适用于排序一定范围内的整数。它的基本思想是统计每个元素的个数,然后根据统计信息将元素放回原数组的正确位置。
空间复杂度中的 M 表示元素的取值范围,即不同元素的个数。
//计数排序
class Solution{
public void countSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int max = Integer.MIN_VALUE;
for (int i = 0; i < arr.length; i++) {
max = Math.max(max, arr[i]);
}
int[] bucket = new int[max + 1];//bucket相当于是help辅助数组
//桶里每个数的下标 对应 arr中会出现的数
for (int i = 0; i < arr.length; i++) {
//arr[i]的值在bucket桶里的个数再加1
bucket[arr[i]]++;
}
int i = 0;
for (int j = 0; j < bucket.length; j++) {
while (bucket[j]-- > 0) {//如果桶这个位置上有值,证明是arr中出现的次数
arr[i++] = j;
}
}
}
}
- 基数排序——时间复杂度O(N),空间复杂度O(N),稳定
基数排序同样是一种非比较性的排序算法,它根据元素的位数来进行排序。
//基数排序
class Solution{
public void radixSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
radixSort(arr, 0, arr.length - 1, maxbits(arr));
}
//先找出最大的数,然后看他有多少位数
public int maxbits(int[] arr) {
int max = Integer.MIN_VALUE;
for (int i = 0; i < arr.length; i++) {
max = Math.max(max, arr[i]);
}
int res = 0;//记录最大的位数
while (max != 0) {
max /= 10;
res++;
}
return res;
}
// arr[L..R]排序 , 最大值的十进制位数digit
//指定L...R是为了灵活性更好、易扩展。而不是要递归
public void radixSort(int[] arr, int L, int R, int digit) {
int radix = 10;
int[] help = new int[R - L + 1];
int i = 0,j= 0;
for (int d = 1; d <= digit; d++) {
int[] count = new int[radix];
for (i = L; i <=R ; i++) {
j = getDigit(arr[i], d);
count[j]++;
}
for (i = 1; i < count.length ; i++) {
count[i] += count[i-1];
}
for(i = R; i >= L ; i--) {
j = getDigit(arr[i], d);
help[count[j] - 1] = arr[i];
count[j]--;
}
for (i = L,j = 0; i <= R ; i++,j++) {
arr[i] = help[j];
}
}
}
public int getDigit(int x, int d) {
return (x / (int) Math.pow(10, d - 1)) % 10;
}
}
最后,还有一点点小知识~
追求性能优——》快排
追求省空间——》堆排
追求稳定性——》归并