排序
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
常见的排序算法
插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。
1. 直接插入排序
基本思想: 当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移
代码实现:
//直接插入排序基本思想:
//每次插入之前于前面已经排序好的数组比较,找到自己的位置之后插入
void InsertSort(int a[], int size) //直接插入排序
{
for (int i = 1; i < size; i++) //从第二个元素开始插入
{
if (a[i] < a[i - 1]) { //通过比较找到自己的位置
int j = i;
while (j > 0 && a[j] < a[j - 1]) //若插入的元素比它前面的元素小,交换连个元素,继续向前比较
{
swap(a[j], a[j - 1]);
j--;
}
}
}
}
直接插入排序的特性总结:
元素集合越接近有序,直接插入排序算法的时间效率越高
时间复杂度:O(N^2)
空间复杂度:O(1),它是一种稳定的排序算法
稳定性:稳定
2. 希尔排序(缩小增量排序)
希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能。这样可以让一个元素可以一次性地朝最终位置前进一大步。然后算法再取越来越小的步长进行排序,算法的最后一步就是普通的插入排序,但是到了这步,需排序的数据几乎是已排好的了(此时插入排序较快)。
//实现思想:
//假设给 9,1,2,5,7,4,8,6,3,5排序;
//首先设置增量, 一般起始增量设置为 size/3, 或者 size/2
//假设增量为h = size / 2; 那么起始增量就是5
//第一次排序时每隔5个元素分为1组, 也就是{9,4}{1,8}{2,6}{5,3}{7,5};
//分别对各个组进行插入排序, 要注意的是, 4的坐标 - 9的坐标 = h(增量);
//排序后得到 {4,1,2,3,5,9,8,6,5,7};
//然后缩小增量 h = h / 2 = 2;
//第二次排序每隔2个元素分为1组 {4,2,5,8,5}; {1,3,9,6,7}
//对各个组进行插入排序得到{2, 4, 5, 5, 8} {1, 3, 6, 7, 9} 也就是{2,1,4,3,5,6,5,7,8,9}
//第三次缩小增量 h = h / 2 = 1;
//这时和直接插入没有区别
void ShellSort(int a[], int size) //缩小增量排序, 希尔排序
{
for (int h = size / 2; h >= 1; h /= 2) //设置初始增量, 增量的缩小
{
for (int i = h; i < size; i++) //设置每次插入排序的起始
{
int tmp = a[i]; //用于保存交换值
int j = i - h;
for (; j >= 0 && a[j] > tmp; j -= h) //插入排序
{
a[j + h] = a[j];
}
a[j + h] = tmp;
}
}
}
希尔排序的特性总结:
希尔排序是对直接插入排序的优化。
当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
希尔排序的时间复杂度不好计算,需要进行推导,推导出来平均时间复杂度: O(N1.3—N2)
稳定性:不稳定
选择排序
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
1. 直接选择排序
基本实现思想:
在元素集合array[i]–array[n-1]中选择关键码最大(小)的数据元素,若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换,在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素
实现代码
//实现思想: 每次找出最大的值或者最小的值,放到其应该在的位置
void SelectionSort(int a[], int size) //选择排序
{
//将每次找的的最小值,从 第1个位置 到 第size - 1个位置按顺序排放
//最后一个元素不用比较
for (int i = 0; i < size - 1; i++)
{
int min = i;
for (int j = i + 1; j < size; j++) //每次比较从i个元素开始的后面所有元素, i之前已经排好序
{
if (a[j] < a[min]) { //保存最小元素的下标
min = j;
}
}
swap(a[i], a[min]); //交换元素
}
}
直接选择排序的特性总结:
直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:不稳定
2. 堆排序
基本思想:
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
实现代码:
//找到第一个非叶子节点,从它开始向上建立小堆
void BulidHeap(int a[], int i, int size) //建堆使用向上调整算法
{
//i 为传进来要调整的节点
int lchild = 2 * i + 1; //i的左节点
int rchild = 2 * i + 2; //i的右节点
int min = i;
if (lchild <= size - 1 && a[min] > a[lchild]) { //i的左节点存在并符合条件
min = lchild;
}
if (rchild <= size - 1 && a[min] > a[rchild]) { //i的右节点存在并符合条件
min = rchild;
}
if (min != i) { //如果进行过调整就需要对调整过的节点继续进行判断,避免其破坏已经建立好的堆
swap(a[min], a[i]);
BulidHeap(a, min, size);
}
}
void HeapSort(int a[], int size) //堆排序使用向下调整算法
{
for (int i = (size - 1) / 2; i >= 0; i--) //从第一个非叶子节点开始向上进行调整建堆
{
BulidHeap(a, i, size);
}
//不断的交换堆顶元素和数组最后一个位置的元素
//再通过向下调整算法进行调整
for (int i = size - 1; i >= 0; i--)
{
swap(a[i], a[0]);
BulidHeap(a, 0, i);
}
}
堆排序特性总结:
堆排序使用堆来选数,效率就高了很多。
时间复杂度:O(N*logN)
空间复杂度:O(1)
稳定性:不稳定
交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
1. 冒泡排序
代码实现:
void BubbleSort(int a[], int size) //冒泡排序
{
for (int i = 1; i < size; i++)
{
for (int j = 0; j < size - i; j++) //每次遍历找出最大或者最小的
{
if (a[j] > a[j + 1]) {
swap(a[j], a[j + 1]);
}
}
}
}
冒泡排序的特性总结:
冒泡排序是一种非常容易理解的排序
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定
2. 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
基本思想:
-
从序列中挑出一个元素,作为"基准"(pivot).
-
把所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准的后面(相同的数可以到任一边),这个称为分区(partition)操作。
-
对每个分区递归地进行步骤1~2,递归的结束条件是序列的大小是0或1,这时整体已经被排好序了。
代码实现:
/*
基本思想:
首先设定一个分界值,通过该分界值将数组分成左右两部分
将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边
此时,左边部分中各元素都小于或等于分界值,而右边部分中各元素都大于或等于分界值
然后,左边和右边的数据可以独立排序。对于左侧的数组数据,
又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。
右侧的数组数据也可以做类似处理。
重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。
当左、右两个部分各数据排序完成后,整个数组的排序也就完成了
*/
void QuickSort(int a[], int begin, int end) //快速排序 时间复杂度 O(nlog2n)
{
if (begin >= end) return; //当左边界大于右边界时退出
int left = begin; //左边界
int right = end + 1; //右边界
int key = a[begin]; //基准值
while (1)
{
while (a[++left] < key) //从左边界开始向右找到第一个大于基准的值
{
if (left == end) {
break;
}
}
while (a[--right] > key) //从右边界开始向左找到第一个小于基准的值
{
if (right == begin) {
break;
}
}
if (left >= right) //保证不越界
break;
swap(a[left], a[right]); //交换两值
}
//此时, right 因满足left >= right而退出,
//所以right位置的元素为左右区间的边界, 此时交换基准和right的值,
//从而使基准左边都比它小,右边都比它大
swap(a[begin], a[right]);
//形成 [begin, right - 1] [right] [right + 1, end]
//将左右两个区间再取划分, 最终可得到有序
QuickSort(a, begin, right - 1);
QuickSort(a, right + 1, end);
}
快速排序的特性总结:
快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
时间复杂度:O(N*logN)
空间复杂度:O(logN)
稳定性:不稳定
归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(DivideandConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
代码实现:
void Merge(int A[], int left, int mid, int right)// 合并两个已排好序的数组A[left...mid]和A[mid+1...right]
{
int len = right - left + 1;
int *temp = new int[len]; // 辅助空间O(n)
int index = 0;
int i = left; // 前一数组的起始元素
int j = mid + 1; // 后一数组的起始元素
while (i <= mid && j <= right)
{
temp[index++] = A[i] <= A[j] ? A[i++] : A[j++]; // 带等号保证归并排序的稳定性
}
while (i <= mid)
{
temp[index++] = A[i++];
}
while (j <= right)
{
temp[index++] = A[j++];
}
for (int k = 0; k < len; k++)
{
A[left++] = temp[k];
}
}
void MergeSortIteration(int A[], int len) // 非递归(迭代)实现的归并排序(自底向上)
{
int left, mid, right;// 子数组索引,前一个为A[left...mid],后一个子数组为A[mid+1...right]
for (int i = 1; i < len; i *= 2) // 子数组的大小i初始为1,每轮翻倍
{
left = 0;
while (left + i < len) // 后一个子数组存在(需要归并)
{
mid = left + i - 1;
right = mid + i < len ? mid + i : len - 1;// 后一个子数组大小可能不够
Merge(A, left, mid, right);
left = right + 1; // 前一个子数组索引向后移动
}
}
}
归并排序的特性总结:
归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
时间复杂度:O(N*logN)
空间复杂度:O(N)
稳定性:稳定
计数排序
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
实现步骤:
- 统计相同元素出现次数
- 根据统计的结果将序列回收到原来的序列中
代码实现:
const int k = 100; // 基数为100,排序[0,99]内的整数
int C[k]; // 计数数组
void CountingSort(int A[], int n)
{
for (int i = 0; i < k; i++) // 初始化,将数组C中的元素置0(此步骤可省略,整型数组元素默认值为0)
{
C[i] = 0;
}
for (int i = 0; i < n; i++) // 使C[i]保存着等于i的元素个数
{
C[A[i]]++;
}
for (int i = 1; i < k; i++) // 使C[i]保存着小于等于i的元素个数,排序后元素i就放在第C[i]个输出位置上
{
C[i] = C[i] + C[i - 1];
}
int *B = (int *)malloc((n) * sizeof(int));// 分配临时空间,长度为n,用来暂存中间数据
for (int i = n - 1; i >= 0; i--) // 从后向前扫描保证计数排序的稳定性(重复元素相对次序不变)
{
B[--C[A[i]]] = A[i]; // 把每个元素A[i]放到它在输出数组B中的正确位置上
// 当再遇到重复元素时会被放在当前元素的前一个位置上保证计数排序的稳定性
}
for (int i = 0; i < n; i++) // 把临时空间B中的数据拷贝回A
{
A[i] = B[i];
}
free(B); // 释放临时空间
}
计数排序的特性总结:
计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
时间复杂度:O(MAX(N,范围))
空间复杂度:O(范围)
稳定性:稳定
排序算法总结
排序算法大体可分为两种:
一种是比较排序,时间复杂度O(nlogn) ~ O(n^2),主要有:冒泡排序,选择排序,插入排序,归并排序,堆排序,快速排序等。
另一种是非比较排序,时间复杂度可以达到O(n),主要有:计数排序,基数排序,桶排序等
算法稳定性:
排序算法稳定性的简单形式化定义为:如果Ai = Aj,排序前Ai在Aj之前,排序后Ai还在Aj之前,则称这种排序算法是稳定的。通俗地讲就是保证排序前后两个相等的数的相对顺序不变。
稳定的算法: 冒泡, 直接插入, 计数, 归并
不稳定的算法: 希尔, 选择, 快排, 堆排序