本篇博文将详细总结一些排序算法。
插入排序
基本思想
将
插入排序:插入即表示将一个新的数据插入到一个有序 数组中,并继续保持有序。例如有一个长度为
插入排序就是每一步都将一个待排数据按其大小插入到已经排序的数据中的适当位置,直到全部插入完毕。
下图演示了对
实现代码
void InsertSort(int* pDataArray, int iDataNum)
{
for (int i = 1; i < iDataNum; i++)
{
int j = i - 1;
int temp = pDataArray[i];//暂存当前要插入的数
while (j>=0 && pDataArray[j]>temp)//从当前temp位置向前寻找小于等于temp数的位置
{
pDataArray[j + 1] = pDataArray[j];//大于temp的数依次向后滑动
j--;
}
if (j != i - 1)//如果j!=i-1表示j向前滑动了
pDataArray[j + 1] = temp;//把要插入的数放在小于等于它前的位置
}
}
时空复杂度分析
平均时间复杂度:
空间复杂度:
稳定性:稳定
归并排序
基本思想
采用分治法(个人认为分治必然用到递归,否则分治无意义),将数组
这是一个递归的过程,递归的将数组分为两子数组,然后在各个子数组再分为两子数组,以此递归下去,当分出来的子数组只有一个数据时,可以认为这个子数组内已经达到了有序,然后再合并相邻的两个子数组就可以了。这样通过先递归的分解数组,再合并数组就完成了归并排序。
代码实现:
int temp[100];//用来暂存排序过程中数组
void Merge(int* a, int low,int mid,int high)//对有序的两个子数组进行归并
{
int i = low;
int j = mid + 1;
int size = 0;
for (; i <= mid &&j <= high; size++)
{
if (a[i] < a[j])//两子数组两两比较
temp[size] = a[i++];
else
temp[size] = a[j++];
}
while (i<=mid)
temp[size++] = a[i++];
while (j <= high)
temp[size++] = a[j++];
for (i = 0; i < size; i++)
a[low++] = temp[i];
}
void MergeSort(int* a, int low, int high)
{
if (low >= high)
return;
int mid = (low + high) / 2;
MergeSort(a, low, mid);
MergeSort(a, mid + 1, high);
Merge(a, low, mid, high);
}
时间复杂度分析
算法的递推关系:
由上面的代码可知,在
由此我们可以得出:
若
若
所以得:
两点改进
- 在数组长度比较短的情况下,不进行递归,而是选择其他排序方案:如插入排序;
- 归并过程中,可以用记录数组下标的方式代替申请新内存空间;从而避免A和辅助数组间的频繁数据移动。
注:基于关键字比较 的排序算法的平均时间复杂度的下界 为
归并排序应用:逆序数问题
问题描述
给定一个数组
如:
算法分析
我们可以这样分析这个问题,把数组
代码实现:
int temp[100];//用来暂存排序过程中数组
void Merge(int* a, int low,int mid,int high,int& count)
{
int i = low;
int j = mid + 1;
int size = 0;
for (; i <= mid &&j <= high; size++)
{
if (a[i] < a[j])
temp[size] = a[i++];
else if (a[i]>a[j])//说明存在逆序数
{
count += mid - i + 1;//和上面的归并排序代码几乎一样,只是这里多了一句
//每遇到这样的(i,j) 都加上 (mid-i+1)或(j-mid)
//count += j - mid;//本句和上一句代码效果完全一样。
temp[size] = a[j++];
}
}
while (i<=mid)
temp[size++] = a[i++];
while (j <= high)
temp[size++] = a[j++];
for (i = 0; i < size; i++)
a[low++] = temp[i];
}
void MergeSort(int* a, int low, int high,int& count)
{
if (low >= high)
return;
int mid = (low + high) / 2;
MergeSort(a, low, mid,count);
MergeSort(a, mid + 1, high,count);
Merge(a, low, mid, high,count);
}
int main()
{
int a[] = { 3, 56, 2, 7 };
int size = sizeof(a) / sizeof(int);
int count = 0;
MergeSort(a, 0, size - 1, count);
cout << count << endl;
return 0;
}
堆排序
基本思想
定义:对于一棵完全二叉树,若树中任一非叶子结点的关键字均不大于(或不小于)其左右孩子 (若存在)结点的关键字,则这棵二叉树,叫做小顶堆(大顶堆)。
完全二叉树可以用数组完美存储,对于长度为
重要结论:大顶堆的堆顶元素是最大的。
- 初始化操作:将
a[0..n−1] 构造为堆(如大顶堆); - 第
i(n>i≥1) 趟排序:将堆顶记录a[0] 和a[n−i] 交换,然后将a[0…n−i−1] 调整为堆(即:重建大顶堆),也即是不断的拿走堆顶元素,然后再把剩余的元素重建大顶堆; - 进行
n−1 趟,完成排序。
堆排序的调整示意图:
注:每第
堆的存储和树型表示
时间复杂度
初始化堆(建堆)的过程:
调整堆的过程:每次弹出堆顶元素,并且调整堆的时间复杂度为
堆的数据结构表示
堆可以用数组完美表示,数组元素的先后顺序以及其元素大小关系表示了这个堆是大顶堆还是小顶堆。我们在编写程序时经常以数组来存储堆。
- 若
k 为左孩子,则k 的父结点为k/2 - 若
k 为右孩子,则k 的父结点为(k/2)−1
对于大小为
实现代码
//调用该函数前,n的左右孩子都是大顶堆,调整以n为顶的堆为大顶堆
void HeadAjust(int* a, int n, int size)
{
int Lchild = 2 * n + 1;//左孩子
int nChild = Lchild;
int t;
while (nChild<size)
{
if ((nChild+1<size) && (a[nChild+1]>a[nChild]))//找出左右孩子大的那个
nChild += 1;
if (a[nChild] < a[n])//如果孩子比父亲小,说明调整完毕
break;
//反正,孩子比父亲大,需要交换调整
t = a[nChild];
a[nChild] = a[n];
a[n] = t;
n = nChild;
nChild = 2 * n + 1;//循环的继续向下调整
}
}
void HeadSort(int* a, int size, int k)//前k大
{
int i;
for (i = size / 2 - 1; i >= 0; i--)//初始建大顶堆
{
HeadAjust(a, i, size);
}
int t;
int s = size - k;
while (size>s)//依次找到最大的并放在数组末尾,然后重新调整建堆
{
t = a[size - 1];
a[size - 1] = a[0];//将当前堆的最大值放在数组末尾
a[0] = t;
size--;
HeadAjust(a, 0, size);//调整堆,也即是将新的a[0]插入到堆的适当位置
}
}
N个数中,选择前k个最大的数
这个问题肯定要对这
这里我们仅以堆排序为例。
解法一:
- 建立一个小顶堆,小顶堆的大小为
k for 每个数:if 这个数比小顶堆的堆顶元素大弹出小顶堆的最小元素
把这个数插入到小顶堆,并且进行堆调整。小顶堆中的k个元素就是所要求的元素
小顶堆的作用:
- 保持始终有
k 个最大元素——利于最后的输出 -
k 个元素中最小的元素在堆顶——利于后续元素的比较
时间复杂度:从大小为
代码实现:
#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
using namespace std;
//调用该函数前,n的左右孩子都是小顶堆,调整以n为顶的堆为小顶堆
void HeadAjust(int* a, int n, int size)
{
int Lchild = 2 * n + 1;//左孩子
int nChild = Lchild;
int t;
while (nChild<size)
{
if ((nChild + 1<size) && (a[nChild+1]<a[nChild]))//找出左右孩子小的那个
nChild += 1;
if (a[nChild] > a[n])//如果父亲比孩子小,说明调整完毕
break;
//反正,孩子比父亲小,需要交换调整
t = a[nChild];
a[nChild] = a[n];
a[n] = t;
n = nChild;
nChild = 2 * n + 1;//循环的继续向下调整
}
}
void Print(int* b, int k)
{
for (int i = 0; i < k; i++)
cout << b[i] << " ";
cout << endl;
}
void HeadSort(int* a,int* b, int size, int k)//前k大
{
int i;
for (i = 0; i < k; i++)
b[i] = a[i];
for (i = k/2; i >= 0; i--)//根据数组b建大小为k的小顶堆
{
HeadAjust(b, i, k);
}
for (i = k; i < size; i++)
{
if (a[i]>b[0])//不断去除堆里面最小的元素,插入比它大的元素
{
b[0] = a[i];
HeadAjust(b, 0, k);//调整堆,也即是将新的a[0]插入堆中适合的位置。
}
}
}
int main()
{
int a[] = { 7, 54,12, 8,53, 13, 32, 45, 19,101,100};
int size = sizeof(a) / sizeof(int);
int k = 4;
int* b = new int[k];
HeadSort(a, b, size, k);
Print(b, k);
}
解法二:
算法描述:
- 建立全部
n 个元素的大顶堆; - 利用堆排序,但得到前
k 个元素后即完成算法。
时间复杂度分析:
- 建堆
O(N) - 选择
1 个元素的时间是O(logN) ,所以,第二步的总时间复杂度为O(klogN) - 该算法时间复杂度为
O(N+klogN)
该方法的实现代码和上面差不多就不写了。
快速排序
基本思想
快速排序是一种基于划分 的排序方法; 划分
快速排序:通过反复地对
以一个数组作为示例:
取区间第一个数
此时
注意此时
此时
注意此时
此时
注意此时
此时
这样第一轮的排序完成。
实现代码:
void quick_sort(int s[], int l, int r)//c++中 数组*a表示可修改原始数组,int& a表示可修改原始a值
{
if (l < r)
{
int i = l, j = r, x = s[l];//x就是privoteKey,也就是当前基准数
while (i<j)
{
while (i<j&&s[j]>x)//j从右向左找第一个小于x的值
j--;
if (i < j)
//下面是先赋值,然后i再加1
s[i++] = s[j];//之前s[i]为x,可以这样理解把s[j]的值赋值给s[i],s[j]里面可以理解为x
//(或者把s[j]理解为下一个需要填的坑)
while (i < j&&s[i] < x)//i从左向右找第一个大于x的值
i++;
if (i < j)
s[j--] = s[i];//填坑
}
s[i] = x;//此时i==j
//分治递归
quick_sort(s, l, i - 1);
quick_sort(s, i + 1, r);
}
}
int main()
{
int a[] = { 23, 54, 12, 4, 7, 1, 3, 54, 13 };
int size = sizeof(a) / sizeof(int);
quick_sort(a, 0, size - 1);
Print(a, size);
}
时间复杂度
最好的情况:
- 在最好的情况,每次运行一次分区,我们会把一个数列分为两个几近相等的片段。然后,递归调用两个一半大小的数列。
- 一次分区中,
i、j 一共遍历了n 个数,即O(n) - 记:快速排序的时间复杂度为
T(n) ,有:T(n)=2∗T(n/2)+cn ,c 是某常数,这个分析过程类似之前的归并排序。 -
T(n)=O(n∗logn)
最坏的情况:
- 在最坏的情况下,两个子数组的长度为
1 和n−1 -
T(n)=T(1)+T(n−1)+cn - 计算得到
T(n)=O(n2)
快速排序与归并排序比较
- 都是分治递归 的思想
- 经过一次划分后,实现了对
A 的调整:其中一个子集合的所有元素均小于等于另外一个子集合的所有元素; - 两者按同样的策略对两个子集合进行分类处理。快速排序当子集合分类完毕后,整个集合的分类也完成了。这一过程避免了子集合的归并操作。
- 因此我们可以想象,归并排序比快速排序要慢,因为多了一次归并过程。
快速排序与堆排序比较
快速排序的最直接竞争者是堆排序。堆排序通常比快速排序稍微慢,但是最坏情况的运行时间总是
O(nlogn) 。快速排序是经常比较快,但仍然有最坏情况性能的机会。堆排序拥有重要的特点:仅使用固定额外的空间,即堆排序是原地排序,而快速排序需要
O(logn) (递归栈)的空间。
桶排序/基数排序
基本思想
将元素分到若干个桶中,每个桶分别排序,然后归并
由于桶之间往往是有序 的(如:洗牌中的
1−13 个点数,整数按照数位0-9基数排序等),所以,它们不是(完全)基于比较的,时间复杂度下限不是O(NlogN)
桶排序应用:最大间隔
请查看最大间隔问题
2-sum问题
问题描述
输入一个数组
问题分析
之前我们分析过
利用排序算法解决
两头扫:
- 如果数组是无序的,先排序
O(NlogN) ,然后用两个指针i,j ,各自指向数组的首尾两端,令i=0,j=n−1 ,然后i++,j−− ,逐次判断a[i]+a[j] 是否等于Sum : - 若
a[i]+a[j]>sum ,则i 不变,j−− ; - 若
a[i]+a[j]<sum ,则i++,j 不变; - 若
a[i]+a[j]==sum ,如果只要求输出一个结果,则退出;否则,输出结果后i++,j−−;
数组无序 的时候,时间复杂度最终为
实现代码
#include<stdio.h>
#include <stdlib.h>
#include<iostream>
#include<vector>
#include<set>
#include<queue>
using namespace std;
void quick_sort(int *s, int l, int r)
{
if (l < r)
{
int i = l, j = r, x = s[l];//x就是privoteKey,也就是当前基准数
while (i<j)
{
while (i<j&&s[j]>x)//j从右向左找第一个小于x的值
j--;
if (i < j)
//下面是先赋值,然后i再加1
s[i++] = s[j];//之前s[i]为x,可以这样理解把s[j]的值赋值给s[i],s[j]里面为x(或者把它理解为下一个需要填的坑)
while (i < j&&s[i] < x)//i从左向右找第一个大于x的值
i++;
if (i < j)
s[j--] = s[i];
}
s[i] = x;//此时i==j
//分治递归
quick_sort(s, l, i-1);
quick_sort(s, i + 1, r);
}
}
void Print(int *a, int size)
{
for (int i = 0; i < size; i++)
cout << a[i] << " ";
cout << endl;
}
void twoSum(int a[], int size,int sum)
{
int i = 0, j = size - 1;
while (i<j)
{
if (a[i] + a[j] > sum)
j--;
else if (a[i] + a[j] < sum)
i++;
else
{
cout << a[i] << " " << a[j] << endl;
i++;
j--;
}
}
}
int main()
{
int a[] = { 8, 14, 12, 4, 7, 1, 3, 11,13 };
int size = sizeof(a) / sizeof(int);
quick_sort(a, 0, size - 1);
Print(a, size);
twoSum(a, size, 20);
return 0;
}
外排序
外排序(
外排序常采用“排序-归并”策略。
排序阶段,读入能放在内存中的数据量,将其排序输出到临时文件,依次进行,将待排序数据组织为多个有序的临时文件。
归并阶段,将这些临时文件组合为大的有序文件。
例如使用
读入
100M 数据至内存,用常规方式(如堆排序)排序。将排序后的数据写入磁盘。
重复前两个步骤,得到
9 个100MB 的块(临时文件)中。将
100M 内存划分为10 份,前9 份中为输入缓冲区,第10 份为输出缓冲区。如前9 份各8 M,第10 份18M ;或10 份大小同时为10M 。
在上面得出的10 个临时文件块中,取其前9 块各8 M,第10 块18 M;或10 份大小同时为10 M。执行九路归并算法(例如在上面获得的
9 个有序的块中,每块选取前8M 有序内容到输入缓冲区,进行归并),将结果输出到输出缓冲区。
若输出缓冲区满,将数据写至目标文件,清空缓冲区。
若输入缓冲区空,读入相应文件的下一份数据。
排序总结
排序通常不是目的,而是手段,是为了方便求解其他问题的一个手段。比如上面说的
各种排序算法的时间复杂度:
稳定性分析:
一般的说,如果排序过程中,只有相邻元素进行比较,是稳定的,如冒泡排序、归并排序;如果间隔元素进行了比较,往往是非稳定的,如堆排序、快速排序。
- 归并排序是指针逐次后移,姑且算相邻元素的比较
- 直接插入排序可以将新增数据放在排序的相等数据的后面,使得直接插入排序是稳定的;但二分插入排序本身不稳定,如果要稳定,需要向后探测
一般的说,如果能够方便整理数据,对于不稳定的排序,可以使用(A[i],i)键对来进行算法,可以使得不稳定排序变成稳定排序。