提到排序算法,伙伴们肯定很熟悉,包括在做算法题的时候,很多时候是需要将数组排序,然后求得最终的结果,那么此时我们就会考虑算法实现的时间复杂度以及空间复杂度,接下来我会介绍常见的排序算法。
1 冒泡排序
思想
遍历数组中的元素,每轮拿到数组中最大的值,放在数组后面的位置,直到排序完成。
实现
java
/**
* 冒泡排序
*
* @param arr 无序数组
* @return 有序数组
*/
public static int[] BubbleSort(int[] arr) {
if (arr == null || arr.length < 1) {
return arr;
}
for (int i = 0; i < arr.length; i++) {
//外层控制遍历的次数
for (int j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr,j,j+1);
}
}
}
return arr;
}
通过两个for循环,其中外层的循环用于控制遍历的轮数,内层的循环用于比较大小,从而交换位置,总是把大的数字往后放,当一轮遍历完成之后,数组中最大的数字放在末尾,此时内层的循环将缩小查找范围(由外层循环决定),直到遍历完成。
时间复杂度和空间复杂度
时间复杂度O(n^2)
空间复杂度O(1)
2 选择排序
思想
每次遍历全部的元素,找到数组中最小值的索引,将其放在数组前面的位置,直到遍历完成全部的元素。
实现
java
public static int[] SelectSort(int[] arr){
for (int i = 0;i<arr.length;i++){
int index = i;
for (int j = i;j<arr.length;j++){
if(arr[j] < arr[index]){
index = j;
}
}
//交换位置
if (index !=i){
swap(arr,i,index);
}
}
return arr;
}
通过两个for循环,外层循环控制索引的位置,内层循环用于比较大小,每次默认最小值是外层循环的索引位置,内部循环判断如果有最小的值,那么就更新index的值;
当遍历完成之后,交换位置,有一个小优化,如果后面没有比当前元素更小的值,可以不交换位置,继续下一次遍历。
时间复杂度和空间复杂度
时间复杂度O(n^2)
空间复杂度O(1)
3 插入排序
思想
遍历整个数组,每个元素需要判断与前面元素的大小,如果比前面的元素小,就交换位置。
实现
java
public static int[] InsertSort(int[] arr){
for (int i = 0;i<arr.length;i++){
int j = i;
while (j > 0){
if (arr[j] < arr[j-1]){
swap(arr,j,j-1);
}
j--;
}
}
return arr;
}
时间复杂度和空间复杂度
时间复杂度O(n^2)
空间复杂度O(1)
4 归并排序
思想
归并排序的思想其实就是两个有序的数组,通过两个指针指向两个数组的头部,依次比较数组中每一个元素,最终合并成一个有序数组。归并的思想在很多算法题中也有体现,链表、数组中涉及到排序的问题。
实现
首先对于数组中每个单独的元素来说,就是有序的,在遍历的过程中,拆分最小颗粒度,由小的有序数组合并成大的有序数组。
java
public static int[] MergeSort(int[] arr) {
process(arr, 0, arr.length - 1);
return arr;
}
private static void process(int[] arr, int left, int right) {
if (left == right) {
return;
}
//将数组分成两份
int middle = (right + left) / 2;
process(arr, left, middle);
process(arr, middle+1, right);
merge(arr,left,middle,right);
}
private static void merge(int[] arr,int left,int middle,int right) {
int[] help = new int[right - left + 1];
int p1 = left;
int p2 = middle+1;
int h1 = 0;
while (p1 <= middle && p2 <= right) {
if (arr[p1] < arr[p2]) {
help[h1] = arr[p1];
p1++;
} else {
help[h1] = arr[p2];
p2++;
}
h1++;
}
if (p1 <= middle) {
for (int i = p1; i <= middle; i++) {
help[h1] = arr[p1];
h1++;
}
}
if (p2 <= right) {
for (int i = p2; i <= right; i++) {
help[h1] = arr[p2];
h1++;
}
}
for (int i = 0;i<help.length;i++){
arr[left+i] = help[i];
}
}
5 快速排序
思想
快速排序其实是"🇳🇱国旗"问题的延伸,给定一个基准值,让一个数组根据此基准值划分,左边均小于基准值,右边均大于基准值,从而通过递归完成整个数组的排序。
荷兰国旗问题
给定一个数组和一个基准值,让小于基准值的数字在左边,大于基准值的数字在右边,不要求左右两边是排序的。
思路
定义一个边界区间,定义一个指针指向数组的第一个位置,主要分为以下几个case
- case1:当前数值如果小于基准值,那么就与边界的下一个位置的元素互换位置,同时边界值++,指针++;
- case2:当前数值如果大于基准值,那么指针++。
实现
java
public static int[] Solution(int[] arr, int num) {
int i = 0;
//边界
int less = -1;
while (i < arr.length) {
if (arr[i] <= num) {
//当前值与边界的下一个元素交换
swap(arr, i, ++less);
}
i++;
}
return arr;
}
前面我们提到了,在分组的过程中,我们不要求有序,只要小于基准值的就放在左边,大于基准值的放在右边,此时这个问题的升级版就是,著名的荷兰国旗问题,将数组分为3份,左边小于基准值,中间等于基准值,右边大于基准值。
此时定义两个边界,左边界是小于基准值的区域,右边界是大于基准值的区域,同时两边推,最终中间的区域就是等于基准值的区域,因此会有以下case:
- case1:如果当前值小于基准值,那么当前值与左边界的下个元素做交换;左边界++,i++;
- case2:如果当前值等于基准值,i++;
- case3:如果当前值大于基准值,那么当前值与有边界的前一个元素做交换;有边界--,i不动,为啥不动,因为换过来的值可能命中其中某个case,还需要判断。
java
public static int[] Solution2(int[] arr, int num) {
int i = 0;
//左边界
int less = -1;
//有边界
int more = arr.length;
while (i < more) {
if (arr[i] <= num) {
//当前值与边界的下一个元素交换
swap(arr, i, ++less);
i++;
} else if (arr[i] == num) {
i++;
} else {
//当前值大于基准值
swap(arr, --more, i);
}
}
return arr;
}
快速排序1.0
当你明白了"荷兰国旗"的原理之后,就会很快明白快速排序的真谛。其实快速排序1.0的原理就是如下图所示:
一次partition的过程就是,取数组最后一个元素作为基准值,将数组分为大于基准值和小于基准值的部分,然后将基准值与大于基准值部分的第一个元素交换。此时基准值一定是在有序数组中的位置
随后在大于基准值和小于基准值的部分分别进行partition的操作,最终完成排序。
这个思想与荷兰国旗的初始版本一致,基准值需要做一次替换,从而定位到有序数组的位置。
快速排序2.0
快速排序2.0则是基于荷兰国旗的升级版,在partition的过程中,就完成分组(>num 、=num、<num),此时基准值就已经找到了在有序数组的位置,然后在 > num 和 < num的分组中分别进行partition。
java
public static int[] QuickSort_2(int[] arr) {
return QuickSortInner(arr, 0, arr.length - 1);
}
private static int[] QuickSortInner(int[] arr, int left, int right) {
if (left < right) {
int pos = partition(arr, left, right);
QuickSortInner(arr, left, pos - 1);
QuickSortInner(arr, pos + 1, right);
}
return arr;
}
/**
* 返回基准值的位置
*
* @param arr 整个数组
* @param left 需要排序的左边位置
* @param right 需要排序的右边位置
*/
public static int partition(int[] arr, int left, int right) {
//基准值
int num = arr[right];
int i = left;
//左边界
int less = left - 1;
//有边界
int more = right + 1;
while (i < more) {
if (arr[i] < num) {
swap(arr, ++less, i);
i++;
} else if (arr[i] == num) {
i++;
} else {
swap(arr, --more, i);
}
}
return less + 1;
}
时间复杂度和空间复杂度
快速排序其实还是有3.0版本的,不再以数组的最后一个数字为基准值,而是采用随机数的方式,但是快速排序最差的情况,时间复杂度为O(n^2),平均下来为O(nlogn),空间复杂度为O(logn)。
6 堆排序
堆排序,其实就是用数组实现完全二叉树,什么是完全二叉树?
看上图,如果一棵二叉树的所有节点均存在左右节点,或者从左往右有排满的趋势,那么就认为是完全二叉树,例如倒数第二棵二叉树,虽然第三层没有满,但是从左往右看是连续的,这就是有排满的趋势,所以是完全二叉树;但是倒数第一棵二叉树,第三层不是连续的,因此不是完全二叉树。
而堆则是一种比较特殊的完全二叉树,分为大根堆和小根堆,如下图所示。
大根堆指的是任意一棵子树,头结点都是这棵树的最大值;小根堆指的是任意一棵子树,头结点都是这棵树的最小值。
思路
前面我们主要是讲解了完全二叉树的一些基础知识,因为数组是连续的,所以构建的完全二叉树自然也得是连续的,所以对于如何通过索引找到其左节点、右节点、父节点,记住公式:
所以如果要构建一个大根堆,那么在遍历到某个元素时,与自己的父节点比较,如果比自己的父节点小,那么就继续下个元素的比较;如果比自己的父节点值要大,那么就交换,交换完成之后,如果还有父节点就需要一直比较,一直往上窜。
实现大根堆
首先第一步,遍历数组中的元素,构建完全二叉树,但是要符合大根堆的条件:
当依次遍历数组中的元素时,开始构建完全二叉树(脑海中),当遍历到数字8时,此时二叉树不符合大根堆的条件,此时需要交换与父节点的位置,此时index = 3,其父节点的位置为(index-1)/2,交换完成之后,发现还是不满足大根堆的条件,因此继续往上找,直到符合条件。
交换完成之后,继续遍历,遇到不满足大根堆的条件就进行转换,直到遍历完成,这个步骤叫做HeapInsert.
java
/**
* Heap Insert操作
*
* @param arr 待排序的数组
* @param index 当前遍历的元素
*/
private static void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
//当前元素,比父节点要大
swap(arr, index, (index - 1) / 2);
index = (index-1) / 2;
}
}
此时数组中的第一个节点为最大值,如果要获取这个数组中的最大值,那么直接返回数组中第0个元素即可。
如果删除最大值,还要保证是一个最大堆的条件,那么首先把数组中最后一个元素替换到首位。
在替换完成之后,可能会出现不满足大根堆的条件,此时需要从头结点开始,比较左右孩子,拿到最大的值,与头结点替换;替换完成后,此时头结点到了子节点的位置,还需要继续判断它的左右孩子是否满足大根堆的条件,这是heapify的过程。
java
/**
* Heapif操作
*
* @param arr 待排序的数组
* @param index 头结点的索引
*/
private static void heapify(int[] arr, int heap_size, int index) {
int left = 2 * index + 1;
while (left < heap_size) {
//根节点
int head = arr[index];
//两个孩子的最大节点
int largestChild = left;
//先比较两个孩子
//如果有右孩子,而且右孩子比左孩子大,那么两个孩子的最大节点就是右孩子
//否则就是左孩子
if (left + 1 < heap_size && arr[left + 1] > arr[left]) {
largestChild = left + 1;
}
if (head < arr[largestChild]) {
//交换
swap(arr, index, largestChild);
}
index = largestChild;
left = 2 * index + 1;
}
}
如此操作之后,所有最大堆的根节点都被放在的数组的后面,而且还是排序的,最终拿到的数组就是排序完成的。
java
public static int[] HeapSort(int[] arr) {
int heap_size = 0;
while (heap_size < arr.length) {
heapInsert(arr, heap_size);
heap_size++;
}
while (heap_size > 0) {
heap_size--;
swap(arr, heap_size, 0);
heapify(arr, heap_size, 0);
}
return arr;
}
时间复杂度和空间复杂度
时间复杂度O(nlogn)
空间复杂度O(1)
7 计数排序
前面我们见到的排序算法,均为数字之间的比较,通过比较大小交换顺序从而完成数组的排序,而计数排序和桶排序,则是在空间上的计数,从而完成排序规则的复原。
思路
举个例子,有一组员工的年龄数组,我们知道员工的年龄是在[16-100]这个区间内的,超过或者低于这个区间我们认为是不合理的,因此我们可以创建一个数组长度为100的空数组,遍历一次数组,那么在空数组中便可以得到每个数字的计数。
例如:16出现2次,20出现3次,21出现1次,35出现1次,40出现2次,那么最终的排序就是[16,16,20,20,20,35,40,40].
所以,此类排序使用的场景有限:假如数组中的元素就是随机数,在-10^5 <= num <= 10^5
之间,显然就不能使用这种计数的方式了。
实现
java
public static int[] CountSort(int[] arr) {
int max = getMaxValue(arr);
int[] res = new int[max + 1];
for (int i = 0; i < arr.length; i++) {
res[arr[i]]++;
}
int index = 0;
for (int i = 0; i < res.length; i++) {
while (res[i] > 0) {
arr[index] = i;
index++;
res[i]--;
}
}
return arr;
}
private static int getMaxValue(int[] arr) {
int max = Integer.MIN_VALUE;
for (int i = 0; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
8 基数排序 & 桶排序
桶排序或者基数排序,是基于计数排序的升级版本,因为计数排序存在的弊端在于,必须要直到数组中的最大值,决定映射数组的长度。
未完待续~