排序算法小记
记录一下常用的排序算法
该部分内容主要参考的是 黑马程序员 中对于数据结构的视频
- 冒泡排序
- 选择排序
- 插入排序
- 希尔排序
- 归并排序
- 快速排序
- 桶排序
- 计数排序
评判排序算法好坏的标准
时间复杂度
时间复杂度其实就代表了一个算法执行的效率,我们在分析排序算法的时间复杂度时要分别给出最好情况、最坏情况、平均情况下的时间复杂度。为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
在之前的章节中学习复杂度的分析时我们说过,复杂度反映的是一个算法随着 n 的变化的一个增长趋势,在表示的时候往往会忽略表达式中的系数,低阶,常量,但是实际的软件开发中,我们排序的可能是 50 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
对于排序算法中的不同实现,其中有些排序算法是基于数据比较的排序算法,这些算法在执行过程中会涉及到比较元素大小,然后元素的交换或者移动,所以在分析基于比较的排序算法时要将元素比较/交换/移动的次数也考虑进来。
空间复杂度
空间复杂度在一个层面代表了算法对存储空间的消耗程度,我们可以简单的理解为算法的内存消耗,在这里我们还引入另外一个概念:in-place 和 out-place;其中in-place 可以称为原地排序就是特指空间复杂度为 O(1)的排序算法,算法只占用常数内存,不占用额外内存,而 out-place 的算法需要占用额外内存。
算法稳定性
如果我们只用上面提到的时间复杂度和空间复杂度来度量一个排序算法其实是不够的,针对排序算法,还有一个指标就是:稳定性。所谓排序算法的稳定性指的是:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
举个例子,有一组数据:3 7 2 7 5 8 9,我们按照大小排序之后的数据为:2 3 5 7 7 8 9,在这组数据中有两个 7,如果经过某种排序算法后两个 7 的前后顺序没有发生改变则称该算法是稳定的排序算法,否则称为该算法是不稳定的排序算法。
稳定性的意义:
如果一组数据只需要一次排序,则稳定性一般是没有意义的,如果一组数据需要多次排序,稳定性是有意义的。例如要排序的内容是一组商品对象,第一次排序按照价格由低到高排序,第二次排序按照销量由高到低排序,如果第二次排序使用稳定性算法,就可以使得相同销量的对象依旧保持着价格高低的顺序展现,只有销量不同的对象才需要重新排序。这样既可以保持第一次排序的原有意义,而且可以减少系统开销。
冒泡排序
概述
冒泡排序(Bubble Sort)是一种简单的排序算法,它通过依次比较两个相邻的的元素,看两个元素是否满足大小关系要求,如果不满足则交换两个元素。每一次冒泡会让至少一个元素移动到它应该在的位置上,这样 n 次冒泡就完成了 n 个数据的排序工作。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
接下来对整个算法的过程进行描述:
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上两个步骤,除了最后一个;
- 重复前三步,直到排序完成。
实现
理解了冒泡排序的原理后代码实现如下:
public class BubbleSort {
/**
* 冒泡排序算法
* 冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。
* 走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。
* 这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端
*
* 步骤:
* 1:比较相邻的元素。如果第一个比第二个大,就交换它们两个;
* 2:对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
* 3:针对所有的元素重复以上的步骤,除了最后一个;
* 4:重复步骤 1~3,直到排序完成。
*/
public static void bubbleSort1(int[] array){
int len = array.length;
if(len<=1){
return;
}
// 开始冒泡
for (int i = 0; i < len-1; i++) {
for (int j = 0; j < len-i-1; j++) {
// 判断前后数据是否需要交换 如果前一个数据大于后一个数据则进行交换否则不交换
if(array[j]>array[j+1]){
int tmp = array[j];
array[j] = array[j+1];
array[j+1] = tmp;
}
}
}
}
// 测试冒泡排序
public static void main(String[] args) {
// 准备一个 int 数组
int[] array = new int[]{5, 2, 6, 9, 0, 3};
// 进行排序
bubbleSort1(array);
System.out.println(Arrays.toString(array));
}
}
实际上,这里的冒泡排序算法还可以继续优化:因为当某次冒泡时发现已经没有数据需要进行交换时,说明所有元素都已经达到有序状态了,此时就不用再执行后续的冒泡操作了,接下来对之前的冒泡进行优化的代码实现如下:
public static void bubbleSort2(int[] array){
int len = array.length;
if(len<=1){
return;
}
// 开始冒泡
for (int i = 0; i < len-1; i++) {
// 是否需要提前结束冒泡的标识
boolean flag = true;
for (int j = 0; j < len-i-1; j++) {
// 判断前后数据是否需要交换 如果前一个数据大于后一个数据则进行交换否则不交换
if(array[j]>array[j+1]){
int tmp = array[j];
array[j] = array[j+1];
array[j+1] = tmp;
flag = false;
}
}
// 在当前这次冒泡中如果所有元素都不需要进行交换则证明所有元素都已有序,则无需进行后续的冒泡操作了
if(flag){
break;
}
}
}
总结
对于冒泡排序我们要使用之前学习的三个标准来进行评判:
1:冒泡排序的时间复杂度是多少?
最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行n次冒泡操作,所以最坏情况时间复杂度为O(n^2)。
2:冒泡排序的空间复杂度是多少?
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一种 in-place 排序算法。
3:冒泡排序是稳定的排序算法吗?
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
插入排序
概述
插入排序(Insertion Sort)的原理是:将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤 2~5。
实现
插入排序的算法实现如下:
public class InsertionSort {
/**
* 插入排序算法
* 插入排序(Insertion-Sort)的算法描述是一种简单直观的排序算法。
* 我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
*
* 步骤:
1:从第一个元素开始,该元素可以认为已经被排序;
2:取出下一个元素,在已经排序的元素序列中从后向前扫描;
3:如果该元素(已排序)大于新元素,将该元素移到下一位置;
4:重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置;
5:将新元素插入到该位置后;
6:重复步骤 2~5。
*/
private static void insertionSort1(int[] arr){
int len = arr.length;
if(len <=1) {
return;
}
//开始排序
for (int i = 1; i < len; i++) {
// 取出未排序的下一个元素,及当前参与比较的元素
int current = arr[i];
// 在已经排序的元素序列中从后向前扫描,定义前置索引
int preIndex = i - 1;
//从后向前依次和当前元素进行比较,
while (preIndex >= 0 && (arr[preIndex]>current)){
// 比较过程中如果元素大于当前的元素则将元素后移一位
arr[preIndex+1]=arr[preIndex];
preIndex--;
}
// 比较过程中如果该元素小于等于当前元素,则将当前元素放在该元素后面
arr[preIndex+1] = current;
}
}
public static void main(String[] args) {
// 准备一个 int 数组
int[] array = new int[]{5, 2, 6, 9, 0, 3};
// 进行排序
System.out.println(Arrays.toString(array)); insertionSort1(array);
// 输出排序结果
System.out.println(Arrays.toString(array));
}
}
总结
1:插入排序的时间复杂度是多少?
如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为O(n^2)。还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度为O(n^2)。
2:插入排序的空间复杂度是多少?
从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个 in-place(原地排序)排序算法。
3:插入排序是稳定的排序算法吗?
在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
希尔排序
概述
希尔排序是插入排序的一种,又称“缩小增量排序”,是插入排序算法的一种更高效的改进版本。
前面学习插入排序的时候,我们会发现一个很不友好的事儿,如果已排序的分组元素为{2,5,7,9,10},未排序的分组元素为{1,8},那么下一个待插入元素为1,我们需要拿着1从后往前,依次和10,9,7,5,2进行交换位置,才能完成真正的插入,每次交换只能和相邻的元素交换位置。那如果我们要提高效率,直观的想法就是一次交换,能把1放到更前面的位置,比如一次交换就能把1插到2和5之间,这样一次交换1就向前走了5个位置,可以减少交换的次数,这样的需求如何实现呢?接下来我们来看看希尔排序的原理。
排序原理:
- 选定一个增长量h,按照增长量h作为数据分组的依据,对数据进行分组;
- 对分好组的每一组数据完成插入排序;
- 减小增长量,最小减为1,重复第二步操作。
增长量h的确定:增长量h的值每一固定的规则,我们这里采用以下规则
int h=1
while(h < 数组长度/2){
h = 2h+1;
}
// 循环结束后我们可以确定h的最大值
// h的减小规则为:
h=h/2
实现
希尔排序的算法实现如下:
/**
* 希尔排序算法:https://www.cnblogs.com/chengxiao/p/6104371.html
*/
public class ShellSort {
/**
* 希尔排序 针对有序序列在插入时采用交换法
* @param arr
*/
public static void sortCmp(int []arr){
// 增量gap,并逐步缩小增量
for(int gap = arr.length/2; gap > 0; gap /= 2){
// 从第gap个元素,逐个对其所在组进行直接插入排序操作
for(int i = gap; i < arr.length; i++){
int j = i;
while(j-gap >= 0 && arr[j] < arr[j-gap]){
// 插入排序采用交换法
swap(arr, j, j-gap);
j -= gap;
}
}
}
}
/**
* 希尔排序 针对有序序列在插入时采用移动法。
* @param arr
*/
public static void sortMove(int arr[]){
// 增量gap,并逐步缩小增量
for(int gap = arr.length/2; gap > 0; gap /= 2){
// 从第gap个元素,逐个对其所在组进行直接插入排序操作
for (int i = gap; i < arr.length; i++){
int j = i;
int temp = arr[j];
if(arr[j] < arr[j-gap]){
while (j-gap >= 0 && arr[j-gap] > arr[j]) {
arr[j] = arr[j-gap];
j -= gap;
}
}
arr[j] = temp;
}
}
}
public static void swap(int[] arr, int a, int b){
arr[a] = arr[a] + arr[b];
arr[b] = arr[a] - arr[b];
arr[a] = arr[a] - arr[b];
}
// 测试代码
public static void main(String[] args) {
int[] a = {9,1,2,5,7,4,8,6,3,5};
// sortCmp(a);
sortMove(a);
System.out.println(Arrays.toString(a));
}
}
总结
1:希尔排序的时间复杂度是多少?
希尔排序中对于增量序列的选择十分重要,直接影响到希尔排序的性能,若增量选择上述 n/2, (n/2)/2 ... 1,则最坏的情况下杂度依然为O(n^2);而选择一些优化的增量序列其复杂度还可以更低,不做讨论了。
2:希尔排序的空间复杂度是多少?
从实现过程可以很明显地看出,希尔排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个 in-place(原地排序)排序算法。
3:希尔排序是稳定的排序算法吗?
希尔排序是按照不同步长对元素进行插入排序 ,虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不稳定的。
选择排序
概述
选择排序(Selection Sort)的原理有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从排序区间中找到最小的元素,将其放到已排序区间的末尾。
算法描述如下:
- 初始状态:无序区间为 R[1..n],有序区为空;
- 第 i 趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中选出关键字最小的记录 R[k],将它与无序区的第 1 个记录交换,使R[1..i]和R[i+1..n)分别变为记录个数增加 1 个的新有序区和记录个数减少 1 个的新无序区;
- n-1趟结束,数组有序化了。
实现
代码实现如下:
public class SelectionSort {
/**
* 选择排序算法
* 选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。
* 但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾
*/
public static void selectionSort(int[] arr){
int len = arr.length;
if(len <= 1){
return;
}
for(int i=0; i<len; i++){
// 接下来找到未排序区间的最小值的下标
int minIndex = i;
for (int j = i+1; j < len; j++) {
if(arr[minIndex]>arr[j]){
minIndex = j;
}
}
int current = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = current;
}
}
public static void main(String[] args) {
// 准备一个 int 数组
int[] array = new int[]{5, 2, 6, 9, 0, 3};
// 进行排序
System.out.println(Arrays.toString(array)); selectionSort (array);
// 输出排序结果
System.out.println(Arrays.toString(array));
}
}
总结
1:选择排序的时间复杂度是多少?
结合之前的分析方式分析可知选择排序的最好情况时间复杂度为O(n2),最坏情况时间复杂度为:O(n2),平均情况下的时间复杂度为:O(n^2)。
2:选择排序的空间复杂度是多少?
通过算法的实现我们可以发现,选择排序的空间复杂度为 O(1),是一个in-place排序算法
3:选择排序是一个稳定的排序算法吗?
注意:选择排序不是一个稳定的排序算法,为什么呢?选择排序每次都要找剩余未排序元素中的最小值,并和未排序区间的第一个元素进行交换位置,这样破坏了稳定性,比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,从稳定性上来说选择排序相对于冒泡排序和插入排序就稍微逊色了。
归并排序
概述
归并排序(Merge Sort)的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
归并排序使用的是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。从我刚才的描述,你有没有感觉到,分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。而对于递归就是要找到递推公式及终止条件,所以我们可以先写出归并排序的递推公式
\[mergeSort(m...n) = merge(mergeSort(m...k),mergeSort(k+1..n));当 m=n时终止 \]我们来解释一下这个公式:我们要对 [m...n] 之间的数列进行排序,其实可以拆分成对 [m...k] 之间的数列进行排序,以及对 [k+1...n] 之间的数列排序,然后将连个拍好序的数列进行合并就称为了最终的数列,同样的道理,每一段数列的排序又可以继续往下拆分,形成递归。
算法描述:
- 把长度为 n 的输入序列分成两个长度为n/2 的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
实现
代码实现如下:
public class MergeSort {
/**
* 归并排序算法
* 归并排序的核心思想还是蛮简单的。
* 如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,
* 再将排好序的两部分合并在一起,这样整个数组就都有序了
*/
private static int[] mergeSort(int[] arr){
if(arr.length < 2){
return arr;
}
// 将数组从中间拆分成左右两部分
int mid = arr.length/2;
int[] left = Arrays.copyOfRange(arr, 0, mid);
int[] right = Arrays.copyOfRange(arr, mid, arr.length);
return merge(mergeSort(left), mergeSort(right));
}
// 合并两个有序数组并返回新的数组
private static int[] merge(int[] left, int[] right){
// 创建一个新数组,长度为两个有序数组的长度之和
int[] newArray = new int[left.length+right.length];
// 定义两个指针,分别代表两个数组的下标
int lindex = 0;
int rindex = 0;
for(int i=0; i<newArray.length;i++){
if(lindex >= left.length){
newArray[i] = right[rindex++];
}else if(rindex >= right.length){
newArray[i] = left[lindex++];
}else if(left[lindex] > right[rindex]){
newArray[i] = right[rindex++];
}else{
newArray[i] = left[lindex++];
}
}
return newArray;
}
public static void main(String[] args) {
// 准备一个 int 数组
int[] array = new int[]{5, 2, 6, 9, 0, 3};
// 进行排序
System.out.println(Arrays.toString(array));
array = mergeSort(array);
// 输出排序结果
System.out.println(Arrays.toString(array));
}
}
总结
1:归并排序的时间复杂度是多少?
归并排序涉及递归,递归适用的场景是一个问题 A 可以拆分成多个问题 B,C,问题A的解就等于问题 B 的解+问题 C 的解,同理,问题 B/C 可按照相同方式进行拆分,所以问题 A 的时间复杂度可以表示为:
\[O(A) = O(B)+O(C)+O(k) \]其中O(A)代表 A 的时间复杂度,O(B)代表求解问题 B 的时间复杂度,O(C)代表求解问题 C 的时间复杂度,O(k)代表将问题 B 的解和问题 C 的解合并称为最终的解的时间复杂度。
从刚刚的分析,我们可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
我们假设对 n 个元素进行归并排序需要的时间是 O(n),那分解成两个子数组排序的时间都是 O(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
\[O(1) = C;~~~~~~~~n=1时,只需要常量级的执行时间,所以表示为C \\O(n) = 2×O(n/2) + n; n>1 \]可能这个公式看起来仍然不直观,那我们继续往下分解几步,
\[O(n) = 2×O(n/2) + n \\ =2×(2×O(n/4)+n/2) + n=4×O(n/4)+2×n\\= 4×(2×O(n/8) + n/4) + 2×n = 8×O(n/8) + 3×n\\......\\=2^k × O(n/2^k) + k × n \]所以最终:得到归并排序的时间复杂度为:O(nlogn)
从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)
2:归并排序的空间复杂度是多少?
归并排序的空间复杂度是多少呢?是:O(n),因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。这一点你应该很容易理解,但是如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是 O(nlogn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思路对吗?
实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n),因此归并排序并不是一种 in-place 排序算法而是一种 out-place 排序算法。
3:归并排序是稳定的排序算法吗?
归并排序算法稳定取决于合并函数 merge()。也就是两个有序子数组合并成一个有序数组的那部分代码,通过分析 merge 函数我们发现,归并排序也是一个稳定的排序算法。
快速排序
原理
快速排序(Quick Sort)算法,简称快排,利用的也是分治的思想,初步看起来有点像归并排序,但是其实思路完全不一样,快排的思路是:如果要对 [m...n] 之间的数列进行排序,我们选择 [m...n] 之间的任意一个元素数据作为分区点(Pivot),然后我们遍历 [m...n] 之间的所有元素,将小于 pivot 的元素放到左边,大于 pivot 的元素放到右边,pivot 放到中间,这样整个数列就被分成三部分了,[m...k-1] 之间的元素是小于 pivot 的,中间是 pivot,[k+1...n] 之间的元素是大于 pivot 的。然后再根据分治递归的思想处理两边区间的的元素数列,直到区间缩小为 1,就说明整个数列都已有序了。
算法描述如下:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
如果我们用递推公式来将上面的过程写出来的话,就是这样:
\[递推公式:quickSort(p...r)=quickSort(p...q-1)+quickSort(q+1,r) \]终止条件:
\[p>=r \]接下来将递推公式翻译成伪代码如下:
// 快速排序,A 是数组,n 表示数组的大小
quick_sort(A, n) {
quickSort(A, 0, n-1)
}
// 快速排序递归函数,p,r 为下标
quickSort(A, p, r) {
if p >= r then return
q = partition(A, p, r) // 获取分区点
quickSort(A, p, q-1)
quickSort(A, q+1, r)
}
我们这里有一个 partition()
分区函数。partition()
分区函数实际上我们前面已经讲过了,就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A:[p…r] 分区,函数返回 pivot 的下标。如果我们不考虑空间消耗的话,partition()
分区函数可以写得非常简单。我们申请两个临时数组 X 和Y,遍历 A[p…r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r];
但是,如果按照这种思路实现的话,partition()
函数就需要很多额外的内存空间,快排就不是一种 in-place 排序算法了。如果我们希望快排的空间复杂度得是 O(1),那 partition()
分区函数就不能占用太多额外的内存空间,我们就需要在 A:[p…r] 的原地完成分区操作,那如何去完成呢?其实这里的实现思路非常的巧妙,下面是实现分区的伪代码,一起来分析一下:
partition(A, p, r) {
pivot = A[r]
i = p
for(j=p; j<=r-1; j++){
if A[j] < pivot {
swap A[i] with A[j]
i = i+1
}
}
swap A[i] with A[r]
return i
}
实现
代码实现如下:
public class QuickSort {
/**
* https://visualgo.net
* 快速排序算法
* 1:从数列中挑出一个元素,称为 “基准”(pivot);
* 2:重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
* 3:递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
*/
//快排 借助递归和分区的思想来实现
public static void quickSort(int arr[], int begin, int end){
// 判断递归截止条件
if(arr.length <= 1 || begin>=end){
return;
}
// 进行分区 得到分区下标
int pivotIndex = partition(arr, begin, end);
// 对分区左侧进行快排
quickSort(arr, begin, pivotIndex-1);
// 对分区右侧进行快排
quickSort(arr, pivotIndex+1, end);
}
private static int partition(int[] arr, int begin, int end){
// 默认数组中待分区区间的最后一个是 pivot 元素
// 当然也可以随机指定 pivot 元素
int pivot = arr[end];
// 定义分区后 pivot 元素的下标
int pivotIndex = begin;
for(int i=begin; i<end; i++){
// 判断如果该区间内如果有元素小于 pivot 则将该元素从区间头开始一直向后填充 有点类似选择排序
if(arr[i] < pivot){
if(i>pivotIndex){
swap(arr, i, pivotIndex);
}
pivotIndex++;
}
}
swap(arr, pivotIndex, end);
return pivotIndex;
}
// 交换数组内下标为 i j 的两个元素
private static void swap(int[] arr,int i,int j){
int temp = arr[j];
arr[j] = arr[i];
arr[i] = temp;
}
public static void main(String[] args) {
//准备一个 int 数组
int[] array = new int[]{5, 2, 6, 9, 0, 3, 4};
//进行排序
System.out.println(Arrays.toString(array));
quickSort(array,0,array.length-1);
//输出排序结果
System.out.println(Arrays.toString(array));
}
}
总结
1:快速排序的时间复杂度是多少?
快排的时间复杂度最好以及平均情况下的复杂度都是O(nlogn),只有在极端情况下会变成O(n^2)。
2:快速排序的空间复杂度是多少?
通过快排的代码实现我们发现,快排不需要额外的存储空间,所有的操作都能在既定的空间内完成,因此快排的空间复杂度为O(1),也就是说快排是一种 in-place 的排序算法。
3:快速排序是稳定的排序算法吗?
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 [6_1, 8, 7, 6_2, 3, 5, 9, 4] 在经过第一次分区操作之后,若取最后一个元素作为 pivot ,则一次partition后的结果为:[3, 5, 4, 6_2, 6_1,8, 9, 7],两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。
快排和归并的异同
首先快排和归并都用到了分治递归的思想,在快排中对应的叫分区操作,递推公式和递归代码也非常相似,但是归并排序的处理过程是由下到上的由局部到整体,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下由整体到局部,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是一种 out-place 排序算法。主要原因是合并函数无法在原地(数组内)执行。快速排序通过设计巧妙的原地(数组内)分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
截至目前:冒泡,插入,选择,归并,快排这几种排序,就复杂度而言:冒泡,插入,选择都是 O(n^2),归并和快排是:O(nlogn);
接下来的三种排序算法:桶排序、计数排序、计数排序,其时间复杂度是 O(n) 。因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作线性排序(Linear sort)。之所以能做到线性的时间复杂度,主要原因是这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
桶排序*
概述
桶排序(Bucket Sort)顾名思义,会用到“桶”,桶我们可以将其想象成一个容器,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了,换句话说:桶排序是将待排序集合中处于同一个值域的元素存入同一个桶中,也就是根据元素值特性将集合拆分为多个区域,则拆分后形成的多个桶,从值域上看是处于有序状态的。对每个桶中元素进行排序,则所有桶中元素构成的集合是已排序的。
桶排序过程中存在两个关键环节:
-
元素值域的划分,也就是元素到桶的映射规则。映射规则需要根据待排序集合的元素分布特性进行选择,若规则设计的过于模糊、宽泛,则可能导致待排序集合中所有元素全部映射到一个桶上,若映射规则设计的过于具体、严苛,则可能导致待排序集合中每一个元素值映射到一个桶上。
-
从待排序集合中元素映射到各个桶上的过程,并不存在元素的比较和交换操作,在对各个桶中元素进行排序时,可以自主选择合适的排序算法,每个桶内的排序算法的复杂度和稳定性,决定了最终的算法的复杂度和稳定性。
那么桶排序的时间复杂度是多少呢?我们可以建议分析一下:
如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。假设每个桶内部使用快速排序,时间复杂度为 O(k×logk)。m 个桶排序的时间复杂度就是 O(m×k×logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n×log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。
桶排序看起来是如此的优秀,那是不是可以替代我们之前讲到的排序算法呢?答案是否定的。首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在非内存排序中。所谓的非内存排序就是说数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。此外由桶排序的过程可知,当待排序集合中存在元素值相差较大时,对映射规则的选择是一个挑战,有时可能导致元素集中分布在某一个桶中或者绝大多数桶是空桶的现象,对算法的时间复杂度或空间复杂度有较大影响,所以桶排序适用于元素值分布较为集中的序列,或者说待排序的元素能够均匀分布在某一个范围[MIN, MAX]之间。
举个例子:比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
如何借助桶排序的处理思想来解决这个问题?我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。
理想的情况下,如果订单金额在1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。
不过,你可能也发现了,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。
接下来为了能对该算法做具体的实现,我们对该算法进一步做具体的描述:
- 人为设置一个 BucketSize,作为每个桶所能放置多少个不同数值(例如当 BucketSize=5 时,该桶可以存放{1,2,3,4,5}这几种数字,但是容量不限,即可以存放 100 个 3);
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序,可以使用其它排序方法,也可以递归使用桶排序;
- 从不是空的桶里把排好序的数据拼接起来。
注意,如果递归使用桶排序为各个桶排序,则当桶数量为1时要手动减小BucketSize 增加下一循环桶的数量,否则会陷入死循环,导致内存溢出。
图片演示:
实现
对该算法具体实现如下:
public class BucketSort {
/**
* 桶排序
* @param array 待排序集合
* @param bucketSize 桶中元素类型的个数即每个桶所能放置多少个不同数值(例如当 BucketSize==5 时,该桶可以存放{1,2,3,4,5}这几种数字,但是容量不限,即可以存放 100 个 3)
* @return 排好序后的集合
*/
public static List<Integer> bucketSort(List<Integer> array, int bucketSize){
if(array == null || array.size()<2 || bucketSize < 1){
return array;
}
//找出集合中元素的最大值,最小值
int max = array.get(0);
int min = array.get(0);
for (int i = 0; i < array.size(); i++) {
if(array.get(i) > max){
max = array.get(i);
}
if(array.get(i) < min){
min = array.get(i);
}
}
//计算桶的个数 最大值-最小值代表了集合中元素取值范围区间
int bucketCount = (max - min)/bucketSize + 1;
//按序创建桶,创建一个 List,List 带下标是有序的,List 中的每一个元素是一个桶,也用 List 表示
List<List<Integer>> bucketList = new ArrayList<>();
for (int i = 0; i < bucketCount; i++) {
bucketList.add(new ArrayList<Integer>());
}
//将待排序的集合依次添加到对应的桶中
for (int j = 0; j < array.size(); j++) {
int bucketIndex = (array.get(j)-min)/bucketSize;
bucketList.get(bucketIndex).add(array.get(j));
}
//对每一个桶中的数据进行排序(可以使用别的排序方式),
// 然后再将桶中的数据依次取出存放到一个最终的集合中
// 创建最终的集合
List<Integer> resultList = new ArrayList<>();
for (int i = 0; i < bucketCount; i++) {
List<Integer> everyBucker = bucketList.get(i);
//如果桶内有元素
if(everyBucker.size()>0){
//递归的使用桶排序为每一个桶进行排序--最后都只做到每个桶中只有一个元素
//当某次桶排序待排序集合都分配到一个桶中时,缩小桶的范围以获得更多的桶
if(bucketCount == 1){
bucketSize --;
}
List<Integer> tmp = bucketSort(everyBucker, bucketSize);
for (int j = 0; j < tmp.size(); j++) {
resultList.add(tmp.get(j));
}
}
}
return resultList;
}
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(5);
list.add(2);
list.add(2);
list.add(6);
list.add(9);
list.add(0);
list.add(3);
list.add(4);
System.out.println(list);
List<Integer> bucketSort = bucketSort(list, 4);
System.out.println(bucketSort);
}
}
总结
1:桶排序的时间复杂度是多少?
桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,如果我们将待排序元素映射到某一个桶的映射规则做的很好的话,很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。我们一般对每个桶内的元素进行排序时采用快排也可以采用递归桶排序(上述代码),通过我们刚开始的分析,当我们对每个桶采用快排时如果桶的个数接近数据规模 n 时,复杂度为O(n),如果在极端情况下复杂度退化为 O(n×log n)。
2:桶排序的空间复杂度是多少?
由于需要申请额外的空间来保存元素,并申请额外的空间来存储每个桶,所以空间复杂度为 O(N+M),其中M代表桶的个数。所以桶排序虽然快,但是它是采用了用空间换时间的做法。
3:桶排序是稳定的排序算法吗?
桶排序是否稳定取决于对每一个桶内元素排序的算法的稳定性,如果我们对桶内元素使用快排时桶排序就是一个不稳定的排序算法。
计数排序*
概述
计数排序(Counting Sort) 使用了一个额外的数组 C,其中第 i 个元素是待排序数组 A 中值等于 i 的元素的个数。然后根据数组 C 来将 A 中的元素排到正确的位置。其实计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 m,我们就可以把数据划分成 m 个桶(其实是个数组)。每个桶内的数据值都是相同的,省掉了桶内排序的时间。每个桶内存储的也不是待排序的数据而是待排序数组 A 中值等于某个值的元素个数
接下来我们以一个例子来说明:
我们都经历过高考,我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 100 万考生,如何通过成绩快速排序得出名次呢?
我们都知道高考的满分是750 分,最小是 0 分,这个数据的范围很小,所以我们可以分成751个桶,对应分数从 0 分到 750 分。根据考生的成绩,我们将这 100 万考生划分到这 751 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序,那具体如何来做呢?
在这里为了方便理解和说明,我们假设有 10 考生,大家的分数在 0-7 分之间,这10 个考生的成绩我们存放在一个数组 A[10]中,分别为:1,4,5,1,0,3,4,2,6,3;因为成绩的分布是在 0-7 之间,我们使用一个大小为 8 的数组 C[8] 代表8个桶,数组的下标对应的是考生的分数,数组 C 中存储的并不是考生信息,而是对应下标分数的考生个数,我们只需要编译一遍A[10]这样就可以得到C[8]的值如下:
\[C[0]=1~~C[1]=2~~C[2]=1~~C[3]=2~~C[4]=2~~C[5]=1~~C[6]=1~~C[7]=0 \]从图中我们可以看出:分数为 3 的考生有 2 个,小于 3 分的考生有 4 个,所以,成绩为 3 分的考生在最终排序好的有序数组 R[10]中会保存在下标为:4,5 的位置上
那接下来就是计算出每个分数的考生在最终的有序数组中的存储位置,这个处理方案很是巧妙,下面是处理思路:对数组 C[8]顺序求和,就变成了下面这个样子,C[i]里存储的就是分数小于等于 i 的考生个数
\[C[0]=1~~C[1]=3~~C[2]=4~~C[3]=6~~C[4]=8~~C[5]=9~~C[6]=10~~C[7]=10 \]下面就是计数排序中稍微复杂度一点的地方了,我们从后到前依次扫描待排序数组A。比如,当扫描到元素 3 时,我们可以从数组 C 中取出下标为 3 的值 6,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 6 个,也就是说 3 是最终有序数组 R 中的第 6 个元素(也就是数组 R 中下标为 5 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 5 个了,所以相应的 C[3] 要减 1,变成 5。
以此类推,当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 5 个元素的位置(也就是下标为 4 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
当然了这只是大致的思路,在代码的具体实现中我们还需要根据实际的情况来做出一些调整
实现
计数排序实现如下:
public class CountingSort {
/**
* 计数排序
* 1:找出待排序的数组中最大和最小的元素;
* 2:统计数组中每个值为 i 的元素出现的次数,存入数组C的第i项;
* 3:对所有的计数累加(从 C 中的第一个元素开始,每一项和前一项相加);
* 4:反向填充目标数组:将每个元素 i 放在新数组的第 C(i)项,每放一个元素就将 C(i)减去 1。
*/
public static void countingSort(int[] array){
//求出待排序数组的最大值,最小值,找出取值区间
int max = array[0];
int min = array[0];
for (int i = 0; i < array.length; i++) {
if(array[i] > max){
max = array[i];
}
if(array[i] < min){
min = array[i];
}
}
//定义一个额外的数组 C
int bucketSize = max - min + 1;
int[] bucket = new int[bucketSize];
//统计对应元素的个数,数组的下标不是单纯的值
for (int i = 0; i < array.length; i++) {
int bucketIndex = array[i]-min;
bucket[bucketIndex]++;
}
//对数组 C 内元素进行累加
for (int i = 1; i < bucket.length; i++) {
bucket[i] += bucket[i-1];
}
//创建临时数组 R 存储最终有序的数据列表
int[] temp = new int[array.length];
//逆序扫描待排序数组 可保证元素的稳定性
for(int i=array.length-1; i>=0; i--){
int bucketIndex = array[i] - min;
temp[bucket[bucketIndex]-1]=array[i];
bucket[bucketIndex] -= 1;
}
//将临时数据列表依次放入原始数组
for(int i=0;i<temp.length;i++){
array[i] = temp[i];
}
}
public static void main(String[] args) {
//准备一个 int 数组
int[] array = new int[]{5, 2, 6, 9, 0, 3, 3, 4};
//进行排序
System.out.println(Arrays.toString(array)); countingSort(array);
//输出排序结果
System.out.println(Arrays.toString(array));
}
}
总结
1:计数排序的时间复杂度是多少?
通过代码的实现过程我们发现计数排序不涉及元素的比较,不涉及桶内元素(数组C)的排序,只有对待排序数组和用于计数数组的遍历操作,因此计数排序的时间复杂度是O(n+k),其中k是桶的个数即待排序的数据范围,这是一种线性排序算法。计数排序不是比较排序,排序的速度快于任何比较排序算法。由于用来计数的数组 C 的长度 k 取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。
2:计数排序的空间复杂度是多少?
在计数排序的过程中需要创建额外的桶空间(数组C)来计数,因此我们可以得知计数排序的口空间复杂度为:O(n+K),其中n是数据规模大小,K是计数排序中需要的桶的个数,其实也就是用来计数的数组 C 的长度,之前我们提到过它取决于待排序数组中数据的范围。
3:计数排序是稳定的排序算法吗?
在计数排序中核心操作中我们是逆序的去扫描待排序数组,这样仍然可以使待排序数组中值相同但是位置靠后的元素在最终的已排序数组中保持着相同的位置关系,因此计数排序是一个稳定的排序算法。
计数排序的适用场景?
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
比如,还是拿分数这个例子。如果分数精确到小数后一位,我们就需要将所有的分数都先乘以 10,转化成整数,然后再放到桶内。再比如,如果要排序的数据中有负数,数据的范围是[-100, 100],那我们就需要先对每个数据都加 100,转化成非负整数。
小结
接下来我们以一幅图的形式来总结一下各种排序算法。
排序算法 | avg | best | worst | space | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
插入排序 | O(n^2) | O(n) | O(n^2) | O(1) | 稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快排排序 | O(nlogn) | O(nlogn) | O(n^2) | O(1) | 不稳定 |
桶排序 | O(n) | O(n) | O(nlogn) | O(n+k) | 可以稳定 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(n+k) | 可以稳定 |
注:
- 桶排序的复杂度和稳定性取决于用何种排序算法为每个桶进行排序,例如采用快排时,为不稳定的排序;k为桶的个数。
- 计数排序中n为数据的规模,k为计数排序中需要的桶的个数,取决于待排序数组中数据的范围;当倒序遍历原数组时,为稳定排序。
标签:int,复杂度,元素,算法,数组,排序,小记 来源: https://www.cnblogs.com/zhuchengchao/p/14403781.html
本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享; 2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关; 3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关; 4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除; 5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。