目录
一、排序算法概述
1、排序的概念
排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
2、排序算法的分类
十种常见排序算法按比较与否可以分为两大类:
- 比较类排序:通过比较来决定元素之间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。
- 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
3、排序算法的性能分析
在分析一个排序算法的性能时,主要有下面一些指标:
- 稳定:如果a=b,且排序之前a在b的前面,排序之后a仍然在b的前面,那么此算法就是稳定的。
- 不稳定:如果a=b,且排序之前a在b的前面,但是排序之后a可能会出现在b的后面,那么此算法就是不稳定的。
- 时间复杂度:对排序数据的总的操作次数反映当n变化时,操作次数呈现什么规律。
- 空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模为n的函数。
- 内排序:所有排序操作都在内存中完成。
- 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;
排序方法 | 平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 内外排序 |
---|---|---|---|---|---|---|
冒泡排序 | O ( n 2 ) O(n^{2}) O(n2) | O(n) | O ( n 2 ) O(n^{2}) O(n2) | O(1) | 稳定 | 内排序 |
快速排序 | O ( n l o g 2 n ) O(nlog_2 n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2 n) O(nlog2n) | O ( n 2 ) O(n^{2}) O(n2) | O ( l o g 2 n ) O(log_2 n) O(log2n) | 不稳定 | 内排序 |
插入排序 | O ( n 2 ) O(n^{2}) O(n2) | O(n) | O ( n 2 ) O(n^{2}) O(n2) | O(1) | 稳定 | 内排序 |
希尔排序 | O ( n 1.3 ) O(n^{1.3}) O(n1.3) | O(n) | O ( n 2 ) O(n^{2}) O(n2) | O(1) | 不稳定 | 内排序 |
选择排序 | O ( n 2 ) O(n^{2}) O(n2) | O ( n 2 ) O(n^{2}) O(n2) | O ( n 2 ) O(n^{2}) O(n2) | O(1) | 不稳定 | 内排序 |
堆排序 | O ( n l o g n ) O(nlog n) O(nlogn) | O ( n l o g n ) O(nlog n) O(nlogn) | O ( n l o g n ) O(nlog n) O(nlogn) | O(1) | 不稳定 | 内排序 |
归并排序 | O ( n l o g 2 n ) O(nlog_2 n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2 n) O(nlog2n) | O ( n l o g 2 n ) O(nlog_2 n) O(nlog2n) | O(n) | 稳定 | 外排序 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 | 外排序 |
桶排序 | O(n+k) | O(n) | O ( n 2 ) O(n^{2}) O(n2) | O(k) | 稳定 | 外排序 |
基数排序 | O(n * k) | O(n * k) | O(n * k) | O(k) | 稳定 | 外排序 |
二、各排序算法的原理及代码实现
1、冒泡排序(Bubble Sort)
(1)算法原理
在无序区间中,通过相邻数的比较,将最大的数冒泡到无序区间的最后,重复这个过程,直到数组整体有序。
(2)算法流程
- 两个for循环,第一个for循环是需要排序的趟数,第二个for循环是每一趟需要做的工作;
- 每趟循环工作都是找出当前待排序区间的最大数,放到待排序区间的最后,下一次循环时待排序区间大小减一;
- 做法就是依次比较相邻的元素。如果前面的比后面的大,就交换它们两个。
- 相等就不交换,保证排序的稳定性。
(3)代码实现
public static void bubbleSort(int[] array) {
//n个数据只需要排n-1次
for (int i = 0; i < array.length - 1; i++) {
//每一趟循环都会排好一个,所以每趟循环后待排序区间大小减一
for (int j = 0; j < array.length - i - 1; j++) {
// 相等不交换,保证稳定性
if (array[j] > array[j + 1]) {
int temp = array[j + 1];
array[j + 1] = array[j];
array[j] = temp;
}
}
}
}
这个代码还可以优化一下,因为如果某一趟循环中没有任何元素进行位置交换,那么实际上整个区间都已经有序了。后续再进行循环就没有必要了。
public static void bubbleSort(int[] array) {
for (int i = 0; i < array.length - 1; i++) {
//每趟循环都设一个标志位,发生元素交换就置为false
boolean isSorted = true;
for (int j = 0; j < array.length - i - 1; j++) {
if (array[j] > array[j + 1]) {
int temp = array[j + 1];
array[j + 1] = array[j];
array[j] = temp;
isSorted = false;
}
}
if (isSorted) {
break;
}
}
}
(4)性能分析
时间复杂度:
- 平均时间复杂度:O(n^2)。
- 最好的情况:O(n),就是元素本来就处于有序状态,这样一趟循环就能解决问题。
- 最坏的情况:O(n^2),待排序区间元素逆序。
空间复杂度:O(1),因为没有使用到额外空间。
稳定性:稳定,因为排序前后,相等元素的相对位置没有发生改变。
最后很明显它是一个内排序算法。
2、快速排序(Quick Sort)
(1)算法思想
主要思想就是分治思想。先选择一个基准值,然后通过一趟排序将待排区间分成独立的两部分,其中一部分元素均比基准值大,另一部分均比基准值小。之后分别对这两部分元素继续重复这种操作,直到整个区间有序。
(2)算法流程
- 先从待排序区间选择一个数,作为基准值(pivot);
- 然后是划分两个半区的操作。遍历整个待排序区间,将比基准值小的(可以包含相等的)放到基准值的左边,将比基准值大的(可以包含相等的)放到基准值的右边;
- 最后采用分治思想,对左右两个小区间按照同样的方式处理,直到小区间的长度等于1,则代表已经有序。小区间长度等于0时,代表没有数据。
(3)代码实现
//假设排序区间是左闭右开的:[left, right)
public static void quickSort(int[] array,int left,int right) {
//小于2个元素就不用排序了
if(right - 1 > left) {
//div为划分区间后基准值的位置
int div = partion(array,0,right);
quickSort(array,0,div);
quickSort(array,div+1,right);
}
}
//划分两个区间的方式
public static int partion(int[] array,int left,int right) {
int begin = left;
int end = right -1;
//每次都选取区间最后一个作为基准值
int key = array[end];
while(begin < end) {
//1.让begin从前往后找,找比基准值大的元素
while(begin < end && array[begin] <= key) {
begin++;
}
//2.让end从后往前找比基准值小的元素
while(begin < end && array[end] >= key) {
end--;
}
//交换寻找到的两个元素
if(begin < end) {
swap(array,begin,end);
}
}
//将基准值移到该属于它的位置,即划分完毕
if(begin != right-1) {
swap(array,begin,right-1);
}
//返回划分后基准值的位置
return begin;
}
public static void swap(int[] array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
(4)性能分析
时间复杂度:
- 平均情况:O(nlogn)
- 最好的情况:O(nlogn)
- 最坏的情况:O(n^2)
空间复杂度:
- 平均情况:O(logn)
- 最好的情况:O(logn)
- 最坏的情况:O(n)
稳定性:不稳定。因为涉及到跨元素进行交换。
另外就是快排也是内排序。
3、插入排序(Insertion Sort)
(1)算法思想
将待排序区间看作:有序区间和无序区间两个。对于无序区间每一个元素,都在已排序区间中从后向前扫描,找到相应位置并插入。
(2)算法流程
- 刚开始整个区间都是无序的,所以可先将第一个元素看作是已排序区间;
- 然后取出已排序区间的下一个元素,也就是未排序区间的第一个元素。在已排序区间中从后向前扫描;
- 如果被扫面元素大于待排序元素,则将被扫面元素向后移一位。直至待排序元素找到合适位置并插入;
- 对无序区间每个元素都重复上述步骤,直至整个大区间有序。
(3)代码实现
private static void insertSort(int[] array) {
for (int i = 1; i < array.length; i++) {
//key为待排序元素,也就是无序空间的第一个元素
int key = array[i];
//end为有序空间的最后一个元素
int end = i - 1;
//不写成大于等于key是因为要保证有序性
while (end >= 0 && array[end] > key) {
array[end+1] = array[end];
end--;
}
//将待排序元素插入
array[end+1] = key;
}
}
(4)性能分析
时间复杂度:
- 平均时间复杂度:O(n^2)
- 最好的情况:O(n),就是元素本来就处于有序状态
- 最坏的情况:O(n^2),待排序区间元素逆序
空间复杂度:O(1),因为没有使用到额外空间。
稳定性:稳定,因为排序前后,相等元素的相对位置没有发生改变。
最后很明显它是一个内排序算法。
4、希尔排序(Shell Sort)
(1)算法思想
希尔排序法又称缩小增量法。它的基本思想是:先选定一个整数,把待排序区间中所有数据按增量进行分组,对每组使用直接插入排序算法排序。然后再将增量逐渐减少,重复上面的工作。当增量减至1时,整个文件恰被分成一组,算法便终止,排序结束。
从上面的描述可以看到,希尔排序实际上是对直接插入排序的优化。因为在分析插入排序的时间复杂度后可以发现,元素越接近有序,插入排序表现越好。但现实中排序时元素通常是无序的,所以使用插入排序并不是很明智的选择。但是如果非要采用插入排序,就需要先对数据进行优化,先让其接近有序,再使用插入排序。使得每个元素再处理后已经接近于它们排序后的最终位置,比插入排序减少了搬移元素的次数。故效率要高很多。
(2)算法流程
- 步骤1:选择一个增量序列t1,t2,…,tk,其中增量大小是一直递减的,直到tk=1;
- 步骤2:按增量序列个数k,对序列进行k 趟排序;
- 步骤3:每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子序列进行直接插入排序。仅增量因子为1 时,整个序列作为普通插入排序来处理。
(3)代码实现
private static void shellSort(int[] array) {
//初始增量。之后每次变为二分之一,这个划分方式是可以改变的。
int gap = array.length/2;
while(gap > 0) {
//下面其实就是一个插入排序
for (int i = gap; i < array.length; i++) {
int key =array[i];
int end = i - gap;
while (end >= 0 && array[end] > key) {
array[end+gap] = array[end];
end -= gap;
}
array[end+gap] = key;
}
gap /= 2;
}
}
(4)性能分析
该算法的正确性在于最后一步gap = 1中,对整个数组进行普通插入排序决定的。但是由于数据是通过前面的步骤,比如gap= 7、3…时进行了预排序,所以最后一步只有很少的插入排序步骤即可。 而上面gap /= 2只是一种取增量的方法,还有很多别的取法,故实际上希尔排序的性能取决于增量到底怎么取。
时间复杂度:O(n^(1.3—2)) ,取决于增量的选取。
空间复杂度:O(1),因为没有使用额外空间。
稳定性:不稳定,因为进行了跨元素交换。
最后它是一个内排序算法。
5、选择排序(Selection Sort)
(1)算法思想
每一次从无序区间选出最大(或最小)的一个元素,存放在无序区间的最后(或最前),直到全部待排序的数据元素排完 。
(2)算法流程
- 两层循环,第一层循环是循环的次数,n个数据只需要n-1次便可排序完成;第二层循环是每次要做的操作;
- 第二层循环中每次找到最大的元素,放到区间末尾。
(3)代码实现
public static void selectSort(int[] array) {
for (int i = 0; i < array.length-1; i++) {
int maxPos = 0;
//从待排序区间找最大值下标
for (int j = 1; j < array.length-i; j++) {
if(array[j] > array[maxPos]) {
maxPos = j;
}
}
//将每次循环找出的最大值放到未排序部分最后
int temp = array[array.length-i-1];
array[array.length-i-1] = array[maxPos];
array[maxPos] = temp;
}
}
(4)性能分析
时间复杂度:O(n^2),选择排序对数据不敏感。
空间复杂度:O(1),因为没有使用到额外空间。
稳定性:不稳定,因为排序前后,相等元素的相对位置发生改变。
最后很明显它是一个内排序算法。
6、堆排序(Heap Sort)
(1)算法思想
基本原理也是选择排序,只是不在使用遍历的方式查找无序区间的最大的数,而是通过堆来选择无序区间的最大的数。
(2)算法流程
- 第一步就是将待排序数据建成一个堆;
- 此处要用到完全二叉树的相关性质,首先找到倒数第一个非叶子节点。
- 若待排序区间大小为size,那么最后一个节点的下标为size-1,那么它的双亲就是(size-1-1)/2;
- 从倒数倒数第一个非叶子节点的位置向下调整,使每个节点都满足堆的性质,一直调整到根节点的位置为止。
- 利用堆删除的思想来进行排序。做法就是用堆顶元素与堆中最后一个元素进行交换,将堆中元素减少一个。之后将根节点重复向下调整过程。
(3)代码实现
public static void heapSort(int[] array) {
//第一步:建堆
int lastLeaf = (array.length-1-1)/2;
//从倒数第一个非叶子节点开始,每一个节点都进行向下调整
for (int root = lastLeaf; root >= 0; root--) {
shiftDown(array,root,array.length);
}
//第二步:利用堆删除的思想进行排序
int end = array.length-1;
while (end >= 0) {
//由于建的是大堆,所以堆顶元素是最大的。直接把这个最大的放到待排序区间最后
swap(array,0,end);
shiftDown(array,0,end);
end--;
}
}
//堆的向下调整
public static void shiftDown(int[] array,int parent,int size) {
int child = parent*2+1;
while (child < size) {
//找左右孩子中比较大的孩子
if(child+1 < size && array[child+1] > array[child]) {
child += 1;
}
//检测双亲是否比孩子大
if(array[child] > array[parent]) {
swap(array,child,parent);
//交换过后可能导致下层不满足需求
parent = child;
child = parent*2 + 1;
}else {
break;
}
}
}
public static void swap(int[] array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
(4)性能分析
时间复杂度:O(nlogn),堆排序对数据不敏感
空间复杂度:O(1),因为没有使用到额外空间。
稳定性:不稳定,因为涉及到跨元素交换。
最后很明显它是一个内排序算法。
7、归并排序(Merge Sort)
(1)算法思想
该算法采用的是分治思想,是建立在归并操作上的一种排序算法。做法就是即先使每个子序列有序,再使子序列段间有序。将已有序的子序列合并,得到完全有序的序列。若将两个有序表合并成一个有序表,称为二路归并。
(2)算法流程
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
(3)代码实现
public static int[] mergeSort(int[] array) {
//
if(array.length < 2) {
return array;
}
int mid = array.length/2;
int[] left = Arrays.copyOfRange(array,0,mid);
int[] right = Arrays.copyOfRange(array,mid,array.length);
//
return merge(mergeSort(left),mergeSort(right));
}
//将两个有序的数组合并成一个有序数组
public static int[] merge(int[] left, int[] right) {
int[] result = new int[left.length+right.length];
for (int index = 0, i = 0, j = 0; index < result.length; index++) {
if(i >= left.length) {
result[index] = right[j++];
}else if(j >= right.length){
result[index] = left[i++];
}else if(left[i] > right[j]){
result[index] = right[j++];
}else {
result[index] = left[i++];
}
}
return result;
}
(4)性能分析
时间复杂度:O(nlogn),归并排序对数据不敏感,没有所谓的最差或最优情况,因为每一次都是均分。
空间复杂度:O(n)
稳定性:稳定,因为排序前后,相等元素的相对位置没有发生改变。
归并排序是一个外部排序。
8、计数排序(Counting Sort)
(1)算法思想
计数排序的核心在于将输入的数据值转化为额外开辟的数组空间中的下标。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
计数排序是一种稳定的排序算法。它使用一个额外的数组B,其中第i个元素是待排序数组A中值与i相关的元素的个数。然后根据数组B来将A中的元素排到正确的位置。
(2)算法流程
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
(3)代码实现
public static int[] 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];
}
}
//创建中间数组保存每个元素的个数
int[] temp = new int[max - min + 1];
for (int i = 0; i < array.length; i++) {
temp[array[i] - min]++;
}
//反向填充原数组
int index = 0;
for (int i = 0; i < temp.length; i++) {
while (temp[i] != 0) {
array[index++] = i + min;
temp[i]--;
}
}
return array;
}
(4)性能分析
当输入的元素是n 个0到k之间的整数时,它的运行时间是 O(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。当ķ不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。
时间复杂度:
- 平均时间复杂度:O(n+k)
- 最好的情况:O(n+k)
- 最坏的情况:O(n+k)
空间复杂度:O(k)。也有可能为了不污染原数组,而新建了一个数组用来存储排序后的结果,那么空间复杂度就为O(n+k)
稳定性:稳定,因为排序前后,相等元素的相对位置没有发生改变。
最后很明显它是一个外排序算法。
9、桶排序(Bucket Sort)
(1)算法思想
把数组 arr 划分为n个大小相同子区间(桶),每个子区间各自排序,最后合并 。计数排序是桶排序的一种特殊情况,可以把计数排序当成每个桶里只有一个元素的情况。
(2)算法流程
- 第一步:找出待排序数组中的最大值max、最小值min;
- 第二步:使用动态数组ArrayList 作为桶,桶里放的元素也用ArrayList存储。桶的数量为(max-min)/arr.length+1;
- 第三步:遍历数组 arr,计算每个元素 arr[i] 放的桶;
- 第四步:每个桶各自排序;
- 第五步:遍历桶数组,把排序好的元素放进输出数组。
(3)代码实现
public static void bucketSort(int[] arr){
//找出待排序数组中的最大值max、最小值min;
int max = arr[0];
int min = arr[0];
for (int i = 0; i < arr.length; i++) {
if(arr[i] > max) {
max = arr[i];
}
if(arr[i] < min) {
min = arr[i];
}
}
//确定桶的个数。
//每个桶的大小设为arr.length
int bucketNum = (max - min) / arr.length + 1;
ArrayList<ArrayList<Integer>> bucketArr = new ArrayList<>(bucketNum);
for(int i = 0; i < bucketNum; i++){
bucketArr.add(new ArrayList<>());
}
//将每个元素放入对应的桶
for(int i = 0; i < arr.length; i++){
int num = (arr[i] - min) / (arr.length);
bucketArr.get(num).add(arr[i]);
}
//对每个桶进行排序
for(int i = 0; i < bucketArr.size(); i++){
Collections.sort(bucketArr.get(i));
}
//遍历桶数组,把排序好的元素放进输出数组。
int index = 0;
for (int i = 0; i < bucketArr.size(); i++) {
while (bucketArr.get(i).size() > 0) {
arr[index++] = bucketArr.get(i).remove(0);
}
}
}
(4)性能分析
桶排序最好情况下时间复杂度为O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。
时间复杂度:
- 平均时间复杂度:O(n+k)
- 最好的情况:O(n)
- 最坏的情况:O(n^2)
空间复杂度:O(k)。也有可能为了不污染原数组,而新建了一个数组用来存储排序后的结果,那么空间复杂度就为O(n+k)
稳定性:稳定,因为排序前后,相等元素的相对位置没有发生改变。
最后很明显它是一个外排序算法。
10、基数排序(Radix Sort)
(1)算法思想
基数排序也是非比较的排序算法,对每一位都进行排序。从最低位开始排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。
(2)算法流程
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点);
(3)代码实现
public static void radixSort(int[] array) {
//找出待排序数组的最大数
int max = array[0];
for (int i = 1; i < array.length; i++) {
if(array[i] > max) {
max = array[i];
}
}
//求出最大数的位数
int maxDigit = 0;
while (max != 0) {
max /= 10;
maxDigit++;
}
ArrayList<ArrayList<Integer>> bucketList = new ArrayList<>();
//0-9十个数,正好十个桶
for (int i = 0; i < 10; i++){
bucketList.add(new ArrayList<>());
}
//用这两个参数,辅助计算出每趟循环每个数字应该在的桶
int mod = 10, div = 1;
for (int i = 0; i < maxDigit; i++) {
//第一次进入这个循环的时候是按个位来决定数字放哪桶的。
//第二次再进来就是按十位来决定的
for (int j = 0; j < array.length; j++) {
int num = (array[j] % mod) / div;
bucketList.get(num).add(array[j]);
}
mod *= 10;
div *= 10;
//反向填充目标数组
int index = 0;
for (int j = 0; j < bucketList.size(); j++) {
while (bucketList.get(j).size() > 0) {
array[index++] = bucketList.get(j).remove(0);
}
}
}
}
(4)性能分析
时间复杂度:
- 平均时间复杂度:O(n * k)
- 最好的情况:O(n * k)
- 最坏的情况:O(n * k)
空间复杂度:O(k)。也有可能为了不污染原数组,而新建了一个数组用来存储排序后的结果,那么空间复杂度就为O(n+k)
稳定性:稳定,因为基数排序基于分别排序,分别收集,所以是稳定的。
最后很明显它是一个外排序算法。
小结:
最后三种排序算法都利用了桶的概念,但对桶的使用方法上有略有差异:
基数排序:根据键值的每位数字来分配桶;
计数排序:每个桶只存储单一键值;
桶排序:每个桶存储一定范围的数值。
上面所有图片转载自:https://blog.csdn.net/weixin_41190227/article/details/86600821