这篇文章,我们接着来讲剩下的排序算法:冒泡排序,快速排序(递归和非递归)、归并排序(递归和非递归)
3.3 交换排序
中心思想:
交换就是指根据序列中的两个元素的比较结果来对换这两个元素在序列中的位置,特点就是:将值较大的元素向序列尾部移动,将值较小的元素向序列前部移动
3.3.1 冒泡排序
public static void bubbleSort(int[] arr) {
for(int i = 0; i <arr.length; i++) {
boolean flag = false;
for (int j = 0; j < arr.length - 1 - i; j++) {
if(arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
flag = true;
}
}
//优化,可加可不加
if(!flag) {
break;
}
}
}
特点
- 时间复杂度:不加优化O(N2),对数据不敏感;加了优化最好情况会达到O(N),对数据敏感
- 空间复杂度:O(1)
- 稳定
3.3.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
递归
本篇文章是以左边元素作为基准值。
根据基本思想,我们可以列出快排的主框架
// 假设按照升序对array数组中[start, end]区间中的元素进行排序
public static void quickSort(int[] arr) {
quick(arr, 0, arr.length - 1);
}
private static void quick(int[] arr, int start, int end) {
if(start >= end) {
return;
}
// 按照基准值对array数组的 [start, end]区间中的元素进行划分
int pivot = partition(arr, start, end);
// 划分成功后以pivot为边界形成了左右两部分 [start, div - 1] 和 [div + 1, end]
// 递归排[start, div - 1]
quick(arr, start, pivot - 1);
// 递归排[div+1, end]
quick(arr, pivot + 1, end);
}
与二叉树前序遍历规则非常像,我们在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后续只需分析如何按照基准值来对区间中数据进行划分的方式即可。
将区间按照基准值划分为左右两半部分的常见方式有:
- Hoare版
以 升序、基准值在左边为例
- 先从右边开始遍历,遍历到比基准值小的数,停下
- 再遍历左边的数,遇到比基准值大的数,停下
- 交换这两个数
- 循环以上三步,直到left和right相遇停下,此时,left和right相遇的位置就是基准值排好序的位置
- 然后将基准值与left(或者right)下标的值进行交换,完成这个数的排序。
- 然后返回基准值排好序的位置
//基准值是左边的元素
private static int partition(int[] arr, int left, int right) {
int index = left;//记录基准值的位置
int n = arr[left];
while(left < right) {
//最先从右边开始比较
//找出小于基准值的数
//n <= arr[right] 必须取等于
while(left < right && n <= arr[right]) {
right--;
}
//找出大于基准值的数
while(left < right && n >= arr[left]) {
left++;
}
swap(arr, left, right);
}
//循环完毕之后,left和right的左边是小于
swap(arr, index, left);//把基准值放在自己的位置上
return left;
}
//基准值是右边的元素
private static int partition2(int[] arr, int left, int right) {
int index = right;
int n = arr[right];
while(left < right) {
while(left < right && n >= arr[left]) {
left++;
}
while(left < right && n <= arr[right]) {
right--;
}
swap(arr, left, right);
}
swap(arr, index, left);
return left;
}
注意:
如果基准值是左边的元素,那么一定要最先从右边开始比较,从右边开始移动,否则排序会出错;
反之,最先从左边开始比较,从左边开始移动
- 挖坑法
以 升序、基准值在左边为例
- 可以把基准值的位置想象成一个坑
- 先遍历右边的元素,遇到比基准值小的元素,就把他放入left坑里,自己的地方形成个坑
- 在遍历左边的元素,遇到比基准值大的元素,就把他放入right坑里,自己的地方形成个坑
- 直到left和right相遇,然后把基准值放入left(right)坑里
- 完成基准值的排序,返回基准值排好序的位置
private static int partition3(int[] arr, int left, int right) {
int n = arr[left];
while(left < right) {
while(left < right && n <= arr[right]) {
right--;
}
arr[left] = arr[right];//将右边小于n的数放在左边
while(left < right && n >= arr[left]) {
left++;
}
arr[right] = arr[left];//将左边大于n的数放在右边
}
arr[right] = n;//基准值
return left;//返回基准值下标
}
- 前后指针
写法一:
- 初始时,
prev
指针指向序列开头,cur
指针指向prev
指针后一个位置. - 当cur值小于基准值key并且++prev的值不等于cur值的时候,交换cur和prev
- 当cur的值大于等于key时,cur++,prev不动
- 当cur小于基准值key并且++prev的值等于cur值的时候,cur++
- 当cur大于right后结束循环,交换key和prev值
注意prev什么时候动,什么时候不动。
//写法一
private static int partition(int[] array, int left, int right) {
int key = array[left];
int prev = left;
int cur = left+1;
while (cur <= right) {
if(array[cur] < key && array[++prev] != array[cur]) {
swap(array,cur,prev);
}
cur++;
}
swap(array,prev,left);
return prev;
}
//写法二
private static int partition(int[] array, int left, int right) {
int d = left + 1;
int pivot = array[left];
for (int i = left + 1; i <= right; i++) {
if (array[i] < pivot) {
swap(array, i, d);
d++;
}
}
swap(array, d - 1, left);
return d - 1;
}
这三种方法的常用顺序是:
挖坑 > Hoare > 前后指针
当我们用正序的10_0000数据测试时,发生了栈溢出错误,因为正序和逆序都会形成单分支的树,递归调用太多了。而乱序不会。
那我们该如何优化呢?
优化
有两个方法:
-
三数取中法。
找到最左边,中间,最右边这三个数的中间值,将他和最左边或者最右边的数进行交换,尽可能将数据打乱,不让他形成单分支的树。
举个例子:
private static int threeNum(int[] arr, int left, int right) { int mid = (left + right) / 2; if(arr[left] < arr[right]) { if(arr[mid] < arr[left]) { return left; } else if (arr[mid] > arr[right]) { return right; } else { return mid; } } else { if (arr[mid] < arr[right]) { return right; } else if (arr[mid] > arr[left]) { return left; } else { return mid; } } }
当我们用100_0000的数据进行测试的时候,正序和乱序不会发生栈溢出错误了,但是逆序依旧会。因为逆序递归的次数比顺序递归的次数多了一倍。
-
递归到小的子区间时,可以考虑使用插入排序。根据二叉树的特点可知,二叉树的节点大多集中在后面几层,像下面这棵树
它4/5的结点都在最后两层,随着二叉树的层数变多,递归调用的次数就会越来越多,就可能会形成栈溢出,但是数据也会变得越来越有序,所以我们可以试着把后面数据排序变成插入排序,这样不仅可以节省空间,也能减少时间。
private static void insertSort2(int[] array, int left, int right) {
for (int i = left; i <= right; i++) {
int n = array[i];
int j = i - 1;
for (; j >= left; j--) {
if(array[j] > n) {
array[j + 1] = array[j];
} else {
break;
}
}
array[j + 1] = n;
}
}
加上这两种优化后,代码为:
public static void quickSort(int[] arr) {
quick(arr, 0, arr.length - 1);
}
private static void insertSort2(int[] array, int left, int right) {
for (int i = left; i <= right; i++) {
int n = array[i];
int j = i - 1;
for (; j >= left; j--) {
if(array[j] > n) {
array[j + 1] = array[j];
} else {
break;
}
}
array[j + 1] = n;
}
}
private static void quick(int[] arr, int start, int end) {
if(start >= end) {
return;
}
if(end - start + 1 <= 20) {
//直接插入排序
insertSort2(arr, start, end);
return;
}
//三数取中法
int midIndex = threeNum(arr, start, end);
swap(arr, start, midIndex);
int pivot = partition3(arr, start, end);//挖坑法
quick(arr, start, pivot - 1);
quick(arr, pivot + 1, end);
}
private static int partition3(int[] arr, int left, int right) {
int n = arr[left];
while(left < right) {
while(left < right && n <= arr[right]) {
right--;
}
arr[left] = arr[right];
while(left < right && n >= arr[left]) {
left++;
}
arr[right] = arr[left];
}
arr[right] = n;
return left;
}
private static int threeNum(int[] arr, int left, int right) {
int mid = (left + right) / 2;
if(arr[left] < arr[right]) {
if(arr[mid] < arr[left]) {
return left;
} else if (arr[mid] > arr[right]) {
return right;
} else {
return mid;
}
} else {
if (arr[mid] < arr[right]) {
return right;
} else if (arr[mid] > arr[left]) {
return left;
} else {
return mid;
}
}
}
测试100_0000的数据,逆序还是会栈溢出,直到数据到5_0000才没有溢出,为什么逆序递归的次数会比顺序多一倍,我现在也没有搞清楚,读者可以说出你自己的想法,欢迎在评论区留言!
特点
-
时间复杂度:好的情况下(完全二叉树)
O(N*logN)
:一共有log(N)层,每层都会遍历n个数据;最坏情况下(顺序或者逆序,单分支):
O(N^2)
一般统一是
O(N*logN)
-
空间复杂度:好的情况下
O(logN)
;最坏情况下O(N)
-
稳定性:不稳定
非递归
当数据量非常大的时候,递归快排一定会发生栈溢出,那么我们可以用非递归的方法来实现快排,从而减少函数开辟所占用的内存。
那么非递归该如何实现呢?他的中心思想又是什么呢?
先说用栈来实现:
把数队的start和end下标放入栈中,当栈不为空的时候,也就是说数据还没有都排完序,去找基准,将基准的数据放入他自己的位置后,放入 以基准为分界线的 左右两边数队的start和end坐标。
这里要注意的是,当基准的前面或者是后面只有一个数据的时候,就不用放了,因为这个数已经有序了。
然后再弹出一组数队的坐标,开始新一轮的找基准,以此类推,直到栈为空,说明这组数已经有序。
图示:
public static void quickSort2(int[] arr) {
Stack<Integer> stack = new Stack<>();
int start = 0;
int end = arr.length - 1;
int pivot = partition3(arr, start, end);
//pivot的左边和右边的元素个数大于一个再放入栈中,否则没有意义。
if (pivot > start + 1) {
stack.push(start);
stack.push(pivot - 1);
}
if (pivot < end - 1) {
stack.push(pivot + 1);
stack.push(end);
}
while (!stack.empty()) {
//赋值新的
end = stack.pop();
start = stack.pop();
pivot = partition3(arr,start,end);
if (pivot > start + 1) {
stack.push(start);
stack.push(pivot - 1);
}
if (pivot < end - 1) {
stack.push(pivot + 1);
stack.push(end);
}
}
}
100_0000数字测试:
很明显,在100_0000数据下,用非递归的方法不会造成栈溢出,但是耗费的时间还是相当多的。
优化
于是我们对非递归代码进行了优化
public static void quickSort2(int[] arr) {
Stack<Integer> stack = new Stack<>();
int start = 0;
int end = arr.length - 1;
if(end - start + 1 <= 20) {
//直接插入排序
insertSort2(arr, start, end);
return;
}
//三数取中法
int midIndex = threeNum(arr, start, end);
swap(arr, start, midIndex);
int pivot = partition3(arr, start, end);
if (pivot > start + 1) {
stack.push(start);
stack.push(pivot - 1);
}
if (pivot < end - 1) {
stack.push(pivot + 1);
stack.push(end);
}
while (!stack.empty()) {
end = stack.pop();
start = stack.pop();
if(end - start + 1 <= 20) {
//直接插入排序
insertSort2(arr, start, end);
//这里不能向上面一样return,因为如果直接return了的话,会导致其他区间的数值还没有被排序,程序就直接结束了,这样只会使一小区间被排序
} else {
//三数取中法
midIndex = threeNum(arr, start, end);
swap(arr, start, midIndex);
pivot = partition3(arr,start,end);
if (pivot > start + 1) {
stack.push(start);
stack.push(pivot - 1);
}
if (pivot < end - 1) {
stack.push(pivot + 1);
stack.push(end);
}
}
}
}
测试100_0000的数据:
很明显!快了很多!占用的空间也变少了!
3.4 归并排序
归并排序的基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
3.4.1 递归
归并排序核心步骤:
-
分解
每组分成[l,m]和[m+1,r]两部分,然后再递归这两部分。
当l>=r的时候,"递"停止,"归"开始,合并开始。
private static void mergeFunc(int[] arr, int left, int right) { //递归的结束条件 if(left >= right) { return; } int mid = (left + right) / 2; //分组递归 mergeFunc(arr, left, mid); mergeFunc(arr, mid + 1, right); //合并 merge(arr, left, mid, right); }
-
合并
当递归到只有一个数的时候,“递”就结束了,开始“归”,合并是在数的左右两边的数都遍历完了之后才开始的。
以下面的图片为例,大家不要和我上面的大图搞混了,我上面的大图里的l,r,m是递的时候传的参数,而不是当前方法内的left和right和mid的值。
以这个为例,先申请一个大小为l - r + 1的新数组,然后定义s1,e1,s2,e2.
int s1 = left; int e1 = mid; int s2 = mid + 1; int e2 = right; int[] tmpArr = new int[right - left + 1];
怎样往新数组里面按顺序地放数字呢?
我们可以比较arr[s1]
和arr[s2]
的值,把小的值放入数组,如上图,arr[s1] < arr[s2]
,所以我们将arr[s1]
放入数组,s1++
,循环以上步骤,当s1 > e1
或者s2 > e2
时中断循环。
int k = 0;
while(s1 <= e1 && s2 <= e2) {
if(arr[s1] <= arr[s2]) {
tmpArr[k++] = arr[s1++];
} else {
tmpArr[k++] = arr[s2++];
}
}
循环终止后,可能仍然有没有放入新数组的数,这时我们就要判断,哪一组没有放完,继续放。
while(s1 <= e1) {
tmpArr[k++] = arr[s1++];
}
while (s2 <= e2) {
tmpArr[k++] = arr[s2++];
}
最后再将新数组的值赋给旧数组。
注意:
这里坚决不能这样写:arr[i] = tmpArr[i]
因为你要对应原数组的坐标进行赋值:
以上图为例,tmpArr[0]
赋给arr[4]
,我们可以这样写arr[i + left] = tmpArr[i]
,这样就可以复制给对应坐标了
for (int i = 0; i < k; i++) {
arr[i + left] = tmpArr[i];
}
这里是完整代码
private static void merge(int[] arr, int left, int mid, int right) {
int s1 = left;
int e1 = mid;
int s2 = mid + 1;
int e2 = right;
int[] tmpArr = new int[right - left + 1];
int k = 0;
while(s1 <= e1 && s2 <= e2) {
if(arr[s1] <= arr[s2]) {
tmpArr[k++] = arr[s1++];
} else {
tmpArr[k++] = arr[s2++];
}
}
while(s1 <= e1) {
tmpArr[k++] = arr[s1++];
}
while (s2 <= e2) {
tmpArr[k++] = arr[s2++];
}
for (int i = 0; i < k; i++) {
arr[i + left] = tmpArr[i];
}
}
private static void mergeFunc(int[] arr, int left, int right) {
if(left >= right) {
return;
}
int mid = (left + right) / 2;
mergeFunc(arr, left, mid);
mergeFunc(arr, mid + 1, right);
merge(arr, left, mid, right);
}
public static void mergeSort(int[] arr) {
mergeFunc(arr, 0, arr.length - 1);
}
3.4.2 非递归
非递归的思路其实和递归的合并步骤思路一样,我们先假设的是数组已经递归完了,已经分成一个一个的数了,开始合并。
1个合并成2个,2个合并成4个,设当前合并的数字有gap个,定义left,right,mid,然后调用合并方法合并。
注意i的变化,i+=gap*2可以遍历其他组的数据并合并。
private static void merge(int[] arr, int left, int mid, int right) {
int s1 = left;
int e1 = mid;
int s2 = mid + 1;
int e2 = right;
int[] tmpArr = new int[right - left + 1];
int k = 0;
while(s1 <= e1 && s2 <= e2) {
if(arr[s1] <= arr[s2]) {
tmpArr[k++] = arr[s1++];
} else {
tmpArr[k++] = arr[s2++];
}
}
while(s1 <= e1) {
tmpArr[k++] = arr[s1++];
}
while (s2 <= e2) {
tmpArr[k++] = arr[s2++];
}
for (int i = 0; i < k; i++) {
arr[i + left] = tmpArr[i];
}
}
public static void mergeSort1(int[] arr) {
int gap = 1;
while(gap < arr.length) {
for (int i = 0; i < arr.length; i+= gap * 2) {
int left = i;//i不会越界
int mid = left + gap - 1;
int right = mid + gap;
//注意:mid 和 right可能会发生越界,要判断并修正
if (mid >= arr.length) {
mid = arr.length - 1;
}
if(right >= arr.length) {
right = arr.length - 1;
}
merge(arr, left, mid, right);
}
gap *= 2;
}
}
特点
-
归并的缺点在于需要O(N)的空间复杂度。
-
时间复杂度:
O(N*logN)
,对数据不敏感。递归
logN
层,每一层都要对n个数排序,遍历n个数,相乘为O(N*logN)
-
空间复杂度:
O(N)
,开辟了额外的数组 -
稳定性:稳定
3.4.3 海量数据的排序问题
外部排序:排序过程需要在磁盘等外部存储进行的排序
前提:内存只有 1G,需要排序的数据有 100G
因为内存中因为无法把所有数据全部放下,所以需要外部排序,而归并排序是最常用的外部排序
- 先把文件切分成 200 份,每个 512 M,把每个小文件当中的数据读取到内存中进行排序。
- 分别对 512 M 排序,因为内存已经可以放的下,所以任意排序方式都可以,排序完之后再写入文件,此时这 512 M的每个文件就已经有序了。
- 进行2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了。
4. 排序算法复杂度及稳定性分析
排序方法 | 最好 | 平均 | 最坏 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n)(加优化) | O(n^2) | O(n^2) | O(1) | 稳定 |
插入排序 | 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^1.3) | O(n^1.5) | O(1) | 不稳定 |
堆排序 | O(n * log(n)) | O(n * log(n)) | O(n * log(n)) | O(1) | 不稳定 |
快速排序 | O(n * log(n)) | O(n * log(n)) | O(n^2) | O(log(n)) ~ O(n) | 不稳定 |
归并排序 | O(n * log(n)) | O(n * log(n)) | O(n * log(n)) | O(n) | 稳定 |
对于这些复杂度和稳定性,背是最容易忘的,我们要根据算法的原理来理解,并且自己要能推出来,这样是记的最牢的方法了💓
这篇文章就先到这里啦~如果有什么问题可以在评论区留言或者是私信我呦😊
下篇预告:十大排序算法(下):计数排序,基数排序,桶排序