目录
[1.1 排序的概念](#1.1 排序的概念)
[1.2 常见的排序算法](#1.2 常见的排序算法)
[2.1 直接插入排序](#2.1 直接插入排序)
[2.1.1 基本思想](#2.1.1 基本思想)
[2.1.2 特性总结](#2.1.2 特性总结)
[2.1.3 代码实现](#2.1.3 代码实现)
[2.2 希尔排序](#2.2 希尔排序)
[2.2.1 基本思想](#2.2.1 基本思想)
[2.2.2 特性总结](#2.2.2 特性总结)
[2.2.3 代码实现](#2.2.3 代码实现)
[3.1 直接选择排序](#3.1 直接选择排序)
[3.1.1 基本思想](#3.1.1 基本思想)
[3.1.2 特性总结](#3.1.2 特性总结)
[3.1.3 代码实现](#3.1.3 代码实现)
[3.2 堆排序](#3.2 堆排序)
[3.2.1 基本思想](#3.2.1 基本思想)
[3.2.2 特性总结](#3.2.2 特性总结)
[3.2.3 代码实现](#3.2.3 代码实现)
[4.1 冒泡排序](#4.1 冒泡排序)
[4.1.1 基本思想](#4.1.1 基本思想)
[4.1.2 特性总结](#4.1.2 特性总结)
[4.1.3 代码实现](#4.1.3 代码实现)
[4.2 快速排序](#4.2 快速排序)
[4.2.1 基本思想](#4.2.1 基本思想)
[4.2.2 特性总结](#4.2.2 特性总结)
[4.2.3 代码实现](#4.2.3 代码实现)
[5.1 归并排序](#5.1 归并排序)
[5.1.1 基本思想](#5.1.1 基本思想)
[5.1.2 特征总结](#5.1.2 特征总结)
[5.1.3 代码实现](#5.1.3 代码实现)
[6.1 计数排序](#6.1 计数排序)
[6.1.1 基本思想](#6.1.1 基本思想)
[6.1.2 特征总结](#6.1.2 特征总结)
[6.1.3 代码实现](#6.1.3 代码实现)
前言
在当今信息爆炸的时代,数据量不断攀升,排序算法作为处理数据的核心技术,其在计算机科学、大数据、人工智能等领域的重要性不言而喻。本文旨在介绍各种排序算法,帮助读者掌握其原理、特点及适用场景,从而提高数据处理效率,提升系统性能。
本文将详细介绍常见的排序算法,包括冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序等,并分析各种排序算法的时间复杂度、空间复杂度和稳定性,让你在实际应用中能够灵活选择合适的排序方法。
一、排序的概念
1.1 排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2 常见的排序算法
二、插入排序
2.1 直接插入排序
2.1.1 基本思想
直接插入排序(Insertion Sort)是一种简单直观的排序算法。它的基本思想是将待排序的序列分为已排序和未排序两部分,在每一趟排序中,从未排序的部分中选择一个元素,然后将其插入到已排序的部分中的正确位置,直到所有元素都被插入到已排序的部分中。
具体的步骤如下:
- 将序列的第一个元素看作已排序的部分,剩余部分看作未排序的部分。
- 从未排序的部分中选取一个元素,依次与已排序的部分中的元素进行比较,找到合适的位置插入。
- 插入元素时,需要将已排序的部分中比插入元素大的元素依次向后移动一个位置,给插入元素腾出位置。
- 重复步骤2和步骤3,直到所有元素都插入到已排序的部分中。
直接插入排序的时间复杂度为O(n^2),其中n为待排序序列的长度。它是稳定的排序算法,适用于小规模的数据或基本有序的数据。在实际应用中,当待排序序列规模较小或已经基本有序时,直接插入排序是一个简单有效的选择。
2.1.2 特性总结
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2.1.3 代码实现
cpp
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
2.2 希尔排序
2.2.1 基本思想
希尔排序是一种排序算法,通过将待排序的数组分割成若干个子序列进行插入排序,从而达到整体有序的目的。它是插入排序的改进版,也被称为缩小增量排序。
希尔排序的基本思想是:对数组进行分组,按一定的间隔(增量)将元素分为若干个子序列,对每个子序列进行插入排序,然后减小增量再次分组并排序,直到增量为1时,进行最后的插入排序。
具体的步骤如下:
- 选择一个增量序列,通常初始增量为数组长度的一半,之后依次减半,直到增量为1。
- 对于每个增量,将数组分成若干个长度为增量的子序列。
- 对每个子序列进行插入排序。
- 缩小增量,重复步骤2和3,直到增量为1。
- 最后进行一次插入排序,完成排序。
希尔排序的时间复杂度和增量序列的选择有关,但最坏时间复杂度为O(n^2),平均时间复杂度为O(n^1.3)。相比于插入排序的O(n^2)时间复杂度,希尔排序有较好的性能。
希尔排序是一种原地排序算法,不需要额外的空间,因此它的空间复杂度为O(1)。但它不稳定,相同元素可能会被交换位置,因此对于有重复元素的数组,可能需要考虑其他的排序算法。
2.2.2 特性总结
-
希尔排序是对直接插入排序的优化。
-
当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
-
希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定:
因为咋们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:到来算。
- 稳定性:不稳定
2.2.3 代码实现
cpp
//平均O(n^1.3)
void ShellSort(int* a, int n)
{
int gap = n;
// gap > 1时是预排序,目的让他接近有序
// gap == 1是直接插入排序,目的是让他有序
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
//优化之前
/*while (gap > 1)
{
gap = gap / 3 + 1;
for (int j = 0; j < gap; ++j)
{
for (int i = j; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}*/
}
三、选择排序
3.1 直接选择排序
3.1.1 基本思想
选择排序是一种简单直观的排序算法,它的基本思想是每次从待排序的数组中选择最小(或最大)的元素,放到已排序部分的末尾,直到整个数组有序为止。
具体的步骤如下:
- 遍历数组,从第一个元素开始,将其标记为最小元素。
- 在剩下的待排序部分中,找到最小的元素,并将其与待排序部分的第一个元素交换位置。
- 指针向后移动一位,进入下一轮遍历,重复步骤2,直到遍历完整个数组。
选择排序的时间复杂度为O(n^2),其中n为数组的长度。无论原始数组是否有序,每次都需要遍历剩余的待排序部分来找到最小元素,因此时间复杂度较高。
选择排序是一种原地排序算法,它只需要常数级的额外空间来存储交换时的临时变量,因此空间复杂度为O(1)。
选择排序是一种不稳定的排序算法,因为交换元素的位置可能会改变相同元素的相对顺序。例如,对于序列[5, 5, 2],第一轮选择排序后,第一个5会与2交换位置,导致相同元素的顺序改变。
尽管选择排序的性能不如快速排序、归并排序等高级排序算法,但它简单易懂,实现起来较为简单,适用于小规模的数据排序或作为其他高级排序算法的辅助排序步骤。
3.1.2 特性总结
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.1.3 代码实现
cpp
void swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//优化了一步,一次选两个,一个最大,一个最小
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while(begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
swap(&a[begin], &a[mini]);
if (maxi == begin)
{
maxi = mini;
}
swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
3.2 堆排序
3.2.1 基本思想
堆排序是一种基于完全二叉堆的排序算法。它的基本思想是通过将待排序的数组看作是一个完全二叉树,并将其转化为一个最大堆或最小堆。然后,每次将堆顶元素与堆的最后一个元素交换位置,然后调整堆,使得剩余元素满足堆的性质。重复这个过程,直到整个数组有序为止。
具体的步骤如下:
- 将待排序的数组构建成一个最大堆或最小堆。
- 交换堆顶元素与堆的最后一个元素,然后将剩余元素调整为一个新的堆。
- 重复步骤2,直到堆的大小为1,则整个数组有序。
堆排序的时间复杂度为O(nlogn),其中n为数组的长度。构建堆的过程需要O(n)的时间复杂度,而每次调整堆的时间复杂度为O(logn),总共需要进行n-1次交换和调整操作。
堆排序是一种原地排序算法,它只需要常数级别的额外空间来存储交换时的临时变量,因此空间复杂度为O(1)。
堆排序是一种不稳定的排序算法,因为交换堆顶元素与最后一个元素可能会改变相同元素的相对顺序。
虽然堆排序的时间复杂度较低,但它的常数项较大,而且在实际应用中不如快速排序或归并排序常用,一般用于特定场景或作为其他排序算法的辅助排序步骤。
3.2.2 特性总结
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.2.3 代码实现
cpp
void Swap(int* p1, int* p2)
{
int t = *p1;
*p1 = *p2;
*p2 = t;
}
void AdjustDown(int* a, int size, int parent) //这里的size表示最后要建成的最终形态的堆的大小
{
int child = parent * 2 + 1;
while (child < size)
{
if (child + 1 < size && a[child + 1] > a[child])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = child * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
for (int i = n - 1; i > 0; i--)
{
Swap(&a[0], &a[i]);
AdjustDown(a, i, 0);
}
}
四、交换排序
4.1 冒泡排序
4.1.1 基本思想
冒泡排序是一种简单的排序算法,它重复地遍历待排序数组,每次比较相邻的两个元素,并将较大(或较小)的元素交换到右侧。通过多次遍历,最大(或最小)的元素会逐渐移动到右侧,实现排序的目的。
具体的步骤如下:
- 从数组的第一个元素开始,依次比较相邻的两个元素,如果前一个元素大于(或小于)后一个元素,则交换它们的位置。
- 继续遍历剩下的元素,进行相同的比较和交换操作,直到遍历完整个数组。
- 重复上述步骤,每次遍历都会将当前最大(或最小)的元素移动到右侧。
- 重复执行n-1次遍历,直到所有元素都有序排列。
冒泡排序的时间复杂度为O(n^2),其中n为数组的长度。每次遍历需要比较n-1次,并且最多需要执行n-1次遍历。
冒泡排序是一种原地排序算法,它只需要常数级别的额外空间来存储交换时的临时变量,因此空间复杂度为O(1)。
冒泡排序是一种稳定的排序算法,因为在相邻元素比较时,只有前一个元素大于(或小于)后一个元素时才会交换它们的位置。
尽管冒泡排序的时间复杂度较高,但它的实现简单,适用于小规模的数据排序。在实际应用中,由于其性能较差,常常被其他高效的排序算法取代。
4.1.2 特性总结
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
4.1.3 代码实现
cpp
void swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n - 1; j++)
{
bool exchange = false;
for (int i = 0; i < n - 1 - j; i++)
{
if (a[i] > a[i + 1])
{
swap(&a[i], &a[i + 1]);
exchange = true;
}
}
if (exchange == false)
{
break;
}
}
}
4.2 快速排序
4.2.1 基本思想
快速排序是一种常用的排序算法,它采用分治的思想将数据分成较小和较大的两部分,然后递归地对两部分进行排序。
具体的步骤如下(hoare版本):
- 选择一个基准元素(pivot),通常选择数组的第一个或最后一个元素。
- 将数组分为两部分,使得左边部分的元素都小于等于基准元素,右边部分的元素都大于等于基准元素。可以使用两个指针分别从数组的左右两端开始遍历,当左指针指向的元素大于基准元素,右指针指向的元素小于基准元素时,交换它们的位置。
- 递归地对左右两部分进行排序,直到每个部分只有一个元素或为空。
- 合并排序后的左右部分,即可得到完整的有序数组。
快速排序的时间复杂度平均为O(nlogn),其中n为数组的长度。每次排序都会将数组分成大致等长的两部分,因此分治的次数为logn,而每次分治需要遍历n个元素,所以时间复杂度为nlogn。最坏情况下的时间复杂度为O(n^2),当数组已经有序或近乎有序时,每次分治只能得到一个元素,导致分治次数为n,时间复杂度变为n的平方。
快速排序是一种原地排序算法,它只需要常数级别的额外空间来存储分治时的临时变量,因此空间复杂度为O(1)。
快速排序是一种不稳定的排序算法,因为在交换元素时可能改变相同元素的相对顺序。
快速排序是一种高效的排序算法,常被用于大规模数据的排序。它的实现相对较复杂,但其时间复杂度的平均性能优势使其成为了主流的排序算法之一。
前后指针版本
4.2.2 特性总结
-
快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
-
时间复杂度:O(N*logN)
-
空间复杂度:O(logN)
-
稳定性:不稳定
4.2.3 代码实现
cpp
void Swap(int* p1, int* p2)
{
int t = *p1;
*p1 = *p2;
*p2 = t;
}
int GetMidi(int* a, int begin, int end)
{
int midi = (begin + end) / 2;
//begin midi end 三个数取中
if (a[begin] < a[midi])
{
if (a[midi] < a[end])
return midi;
else if (a[begin] > a[end])
return begin;
else
return end;
}
else // a[begin] > a[midi]
{
if (a[midi] > a[end])
return midi;
else if (a[begin] < a[end])
return begin;
else
return end;
}
}
//hoare版本
int PartSort1(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int left = begin, right = end;
int keyi = left;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
return left;
}
//挖坑法版本
int PartSort2(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int key = a[begin];
int hole = begin;
while (begin < end)
{
// 右边找小,填到左边的坑
while (begin < end && a[end] >= key)
{
--end;
}
a[hole] = a[end];
hole = end;
// 左边找大,填到右边的坑
while (begin < end && a[begin] <= key)
{
++begin;
}
a[hole] = a[begin];
hole = begin;
}
a[hole] = key;
return hole;
}
//前后指针版本
int PartSort3(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
Swap(&a[midi], &a[begin]);
int keyi = begin;
int prev = begin;
int cur = prev + 1;
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
++cur;
}
Swap(&a[prev], &a[keyi]);
return prev;
}
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSort3(a, begin, end);
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
五、归并排序
5.1 归并排序
5.1.1 基本思想
归并排序是一种分治算法,它将待排序数组分成两个部分,分别对每个部分进行排序,然后将两个排好序的部分合并成一个有序的数组。
具体的步骤如下:
- 将数组不断二分,直到每个部分只有一个元素或为空。
- 递归地对左右两部分进行排序,直到每个部分只有一个元素或为空。
- 合并排序后的左右两部分,即可得到完整的有序数组。合并时,从两个部分的头部开始比较元素大小,较小的元素先放入新数组,然后将指针向后移动,直到一个部分为空,再将另一个部分的剩余元素依次放入新数组。
归并排序的时间复杂度是O(nlogn),其中n为数组的长度。每次排序时,需要将数组分成大致等长的两部分,因此分治的次数为logn。每次分治中合并两个部分的时间复杂度为O(n),因为需要遍历n个元素。所以总的时间复杂度为nlogn。
归并排序是一种稳定的排序算法,因为在合并两部分时,如果遇到相同元素,会先将左半部分的元素放入新数组,保证了相同元素的相对顺序不会改变。
归并排序是一种稳定、可靠的排序算法,它的实现相对简单,但其时间复杂度的稳定性使其成为了主流的排序算法之一。它也适用于大规模数据的排序,特别适合用于链表等数据结构的排序。
5.1.2 特征总结
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
5.1.3 代码实现
cpp
void Swap(int* p1, int* p2)
{
int t = *p1;
*p1 = *p2;
*p2 = t;
}
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
{
return;
}
int mid = (begin + end) / 2;
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
六、计数排序
6.1 计数排序
6.1.1 基本思想
前面介绍的几种的排序都是比较排序算法,而计数排序是一种非比较排序算法,其基本思想是统计待排序数组中每个元素出现的次数,然后按照元素的大小顺序依次将元素放入新的数组中。
具体的步骤如下:
- 找出待排序数组中最大值max和最小值min。
- 创建一个大小为max-min+1的计数数组count,用于记录每个元素出现的次数。初始时,count数组的所有元素都为0。
- 遍历待排序数组,将每个元素的值作为count数组的索引,在对应的索引位置上将计数加1。
- 依次累加count数组中的元素,即将count中的元素与前一个元素相加,得到每个元素在排序后数组中的最后一个位置的索引。
- 创建一个大小与待排序数组相同的临时数组temp。
- 遍历待排序数组,根据元素的值在count数组中查找对应的位置,并将元素放入temp数组中。然后将count数组中对应位置的计数值减1。
- 将temp数组的元素复制回原始数组,即得到了排序后的数组。
计数排序的时间复杂度是O(n+k),其中n为数组的长度,k为待排序数组中最大值与最小值之差。由于需要遍历待排序数组两次,分别用于计数和复制元素,因此实际的时间复杂度可以近似为O(n)。
计数排序是一种稳定的排序算法,因为在将元素放入临时数组时,遇到相同值的元素会按照待排序数组中的顺序依次放入,保持了相同元素的相对顺序不变。计数排序适用于待排序数组中的元素范围相对较小的情况,对于较大范围的数据集合,计数排序的空间复杂度较高,不适合使用。
6.1.2 特征总结
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围))
- 空间复杂度:O(范围)
- 稳定性:稳定(可以做到稳定,但一般不谈稳定性)
6.1.3 代码实现
cpp
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc fail\n");
return;
}
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
}
七、排序比较
总结
通过对各种排序算法的学习,希望读者能够了解排序算法的基本原理和特点,以便根据实际需求选择合适的算法。希望本文能为你在排序算法的学习和实践中提供有益的帮助,让你在未来的工作和学习中能够游刃有余地运用排序算法,提升数据处理效率,创造更多价值。