排序算法
排序方法的分类
-
按数据存储介质:内部排序和外部排序
- 内部排序:数据量不大、数据在内存,无序内外存交换数据
- 外部排序:数据量较大、数据在外存(文件排序)
- 外部排序时,要将数据分批调入内存来排序,中间结果还要及时放入外存,显然外部排序要复杂很多。
-
按比较器个数:串行排序和并行排序
- 串行排序:单处理机(同一时刻比较一对元素)
- 并行排序:多处理机(同一时刻比较多对元素)
-
按主要操作:比较排序和基数排序
- 比较排序:用比较的方法
- 插入排序
- 交换排序
- 选择排序
- 归并排序
- 基数排序:不比较元素的大小,仅仅根据元素本身的取值确定其有序位置。
- 比较排序:用比较的方法
-
按辅助空间:原地排序和非原地排序
- 原地排序:辅助空间用量为O(1)的排序方法
- 所占的辅助存储空间与参加排序的数据量大小无关
- 非原地排序:辅助空间用量超过O(1)的排序方法
- 原地排序:辅助空间用量为O(1)的排序方法
-
按稳定性:稳定排序和非稳定排序
-
稳定排序:能够使任何数据相等的元素,排序以后相对次序不变。
-
非稳定排序:不是稳定排序的方法
-
-
按自然性:自然排序和非自然排序
- 自然排序:输入数据越有序,排序的速度越快的排序方法
- 非自然排序:不是自然排序的方法
-
按排序依据原则:
- 插入排序:
- 直接插入排序
- 折半插入排序
- 希尔排序
- 交换排序:
- 冒泡排序
- 快速排序
- 选择排序:
- 简单选择排序
- 堆排序
- 归并排序:
- 2-路归并排序
- 基数排序
- 插入排序:
-
按排序所需工作量
- 简单的排序方法:T(n) = O(n2)
- 先进的排序方法:T(n) = O(nlogn)
- 基数排序:T(n) = O(d.n)
插入排序
基本思想:
每步将一个待排序的对象,按其关键码大小,插入到前面已经排好序的一组对象的适当位置上,直到对象全部插入为止。
- 基本操作:有序插入
- 在有序序列中插入一个元素,保持序列有序,有序长度不断增加
- 起初,a[0]是长度为1的子序列,然后逐一将a[1]至a[n - 1]插入到有序子序列中。
有序插入方法:
- 在插入a[i]前,数组a的前半段(a[0]~a[i - 1])是有序段,后半段(a[i]~a[n - 1])是停留于输入次序的“无序段“。
- 插入a[i]使a[0]~a[i - 1]有序,也就是要为a[i]找到有序位置j (0 <= j <= i),将a[i]插入在a[j]的位置上。
插入排序的种类:
- 顺序法定位插入位置
- 直接插入排序
- 二分法定位插入位置
- 二分插入排序
- 缩小增量多遍插入排序
- 希尔排序
直接插入排序
void sort_c() {
vector<int> v;
int insertVal;
int insertIndex;
for (int i = 1; i < v.size(); i++) {
insertVal = v[i];
insertIndex = i - 1;
while (insertIndex >= 0 && insertVal < v[insertIndex]) {
v[insertIndex + 1] = v[insertIndex];
insertIndex--;
}
if (insertIndex + 1 != i) {
v[insertIndex + 1] = insertVal;
}
}
}
void InsertSort(SqList& L) {
int i,j;
for (i = 2; i <= L.length; ++i) {
if (L.r[i].key < L.r[i - 1].key) {
L.r[0] = L.r[i];
for (j = i - 1; L.r[0].key < L.r[j].key; --j) {
L.r[j + 1] = L.r[j];
}
L.r[j + 1] = L.r[0];
}
}
}
直接插入排序---采用顺序查找法查找插入位置
优化:
实现排序的基本操作有两个:
(1)“比较”序列中两个关键字的大小
(2)“移动”记录
最好的情况(关键字在记录序列中顺序有序):
11 25 32 47 56
最坏的情况(关键字在记录序列中逆序有序):
85 92 96
时间复杂度结论
- 原始数据越接近有序,排序速度越快
- 最坏情况下(输入数据是逆有序的) Tw(n) = O(n2)
- 平均情况下,耗时差不多是最坏情况的一半 Te(n) = O(n2)
- 要提高查找速度
- 减少元素的比较次数
- 减少元素的移动次数
折半插入排序
void sort_er() {
vector<int> v;
int insertVal;
int insertIndex;
int low;
int high;
for (int i = 1; i < v.size(); i++) {
insertVal = v[i];
low = 0;
high = i;
while (low <= high) {
insertIndex = low + (high - low) / 2;
if (v[insertIndex] > insertVal) {
high = insertIndex - 1;
} else {
low = insertIndex + 1;
}
}
//循环结束,high + 1则为插入位置
for (int j = i - 1; j >= high + 1; j--) {
v[j + 1] = v[j];
}
v[high + 1] = insertVal;
}
}
-
查找插入位置时采用折半查找法
-
折半查找比顺序查找快,所以折半插入排序就平均性能来说比直接插入排序要快
-
它所需要的关键码比较次数与待排序对象序列的初始排列无关,仅依赖于对象个数。在插入第i个对象时,需要经过次关键码比较,才能确定它应插入的位置
- 当n较大时,总关键码比较次数比直接插入排序的最坏情况要好得多,但比其最好情况要差
- 在对象的初始排列已经按关键码排好序或接近有序直接插入排序比折半插入排序执行的关键码比较次数要少
-
折半插入排序的对象移动次数与直接插入排序相同,依赖于对象的初始排列
- 减少了比较次数,但没有减少移动次数
- 平均性能优于直接插入排序
-
时间复杂度为O(n2)
-
空间复杂度为O(1)
-
是一种稳定的排序方法
希尔排序
//平移法
void shellSort(int v[], int length) {
int temp = 0;
int count = 0;
for (int gap = length / 2; gap > 0; gap /= 2) {
//之所以 i = gap 是因为数组从下标为0开始,视频中给的是gap + 1是因为下标从1开始
for (int i = gap; i < length; i++) {
int insertValue;
int insertIndex;
insertValue = v[i];
insertIndex = i - gap;
while (insertIndex >= 0 && v[insertIndex] > insertValue) {
v[insertIndex + gap] = v[insertIndex];
insertIndex = insertIndex - gap;
}
if (insertIndex + gap != i) {
v[insertIndex + gap] = insertValue;
}
}
}
for (int i = 0; i < 5; i++) {
cout<<v[i]<<" ";
}
}
//交换法
void shellSort_two(int v[], int length) {
int temp = 0;
int count = 0;
for (int gap = length / 2; gap > 0; gap /= 2) {
//之所以 i = gap 是因为数组从下标为0开始,视频中给的是gap + 1是因为下标从1开始
for (int i = gap; i < length; i++) {
for (int j = i - gap; j >= 0; j -= gap) {
if (v[j] > v[j + gap]) {
temp = v[j];
v[j] = v[j + gap];
v[j + gap] = temp;
}
}
}
}
for (int i = 0; i < 5; i++) {
cout<<v[i]<<" ";
}
}
//视频中给出的
void ShellInsert(int v[], int len, int d) {
int insertValue;
int insertIndex;
for (int i = d; i < len; i++) {
insertValue = v[i];
insertIndex = i - d;
while (insertIndex >= 0 && v[insertIndex] > insertValue) {
v[insertIndex + d] = v[insertIndex];
insertIndex = insertIndex - d;
}
if (insertIndex + d != i) {
v[insertIndex + d] = insertValue;
}
}
}
void ShellSort() {
//按增量序列arr[]进行希尔排序
int t = 2;
int arr[2] = {3, 1};
int v[5] = {5, 4, 3, 2, 1};
for (int k = 0; k < t; k++) {
ShellInsert(v,5,arr[k]); //一趟增量为arr[k]的插入排序
}
for (int i = 0; i < 5; i++) {
cout<<v[i]<<" ";
}
}
- 直接插入排序在什么情况下效率比较高
- 直接插入排序在基本有序时,效率较高
- 在待排序的记录个数较少时,效率较高
基本思想:
先将整个待排记录序列分割成若干子序列,分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行一次直接插入排序。
- 希尔排序算法,特点:
- 缩小增量
- 多遍插入排序
希尔排序思路:
- 定义增量序列Dk:DM > DM-1>....D1 = 1
- 刚才的例子中:D3 = 5, D2 = 3, D1 = 1
- 对每个Dk进行"Dk - 间隔"插入排序(k = M, M - 1, ....1)
希尔排序特点:
- 一次移动,移动位置较大,跳跃式地接近排序后的最终位置
- 最后一次只需要少量移动
- 增量序列必须是递减的,最后一个必须是1
- 增量序列应该是互质的
希尔排序算法分析:
- 时间复杂度是n和d的函数
- O(n1.25) ~O(1.6n1.25) ----经验公式
- 空间复杂度为O(1)
- 是一种不稳定的排序方法
- 如何选择最佳d序列,目前尚未解决
- 但是最后一个增量必须为1,无除了1之外的公因子
- 不宜在链式存储结构上实现
- 如何选择最佳d序列,目前尚未解决
交换排序
基本思想:
两两比较,如果发生逆序则交换,直到所有记录都排好序为止。
常见的交换排序方法:
- 冒泡排序 O(n2)
- 快速排序 O(nlog2n)
冒泡排序
void BobSort(int arr[], int length) {
bool flag = false;
int temp = 0;
for (int i = 0; i < length - 1; i++) {
flag = false;
for (int j = 0; j < length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
flag = true;
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
if (!flag) {
break;
}
}
}
基于简单交换思想
基本思想:每趟不断将记录两两比较,并按 "前小后大" 规则交换
优点:
每趟结束时,不仅能挤出一个最大值到最后面位置,还能同时部分理顺其他元素;
如何提高效率?
一旦某一趟比较时不出现记录交换,说明已排好序了,就可以结束本算法
冒泡排序算法分析
-
时间复杂度
-
最好情况(正序)
- 比较次数:n - 1
- 移动次数:0
-
最坏情况(逆序)
-
冒泡排序的算法评价
- 冒泡排序最好时间复杂度是O(n)
- 冒泡排序最坏时间复杂度为O(n2)
- 冒泡排序平均时间复杂度为O(n2)
- 冒泡排序算法中增加一个辅助空间temp,辅助空间为S(n) = O(1)
- 冒泡排序是稳定的
快速排序
int Partition(int arr[], int length, int low, int high) {
int temp = arr[low];
int pivotKey = arr[low];
while (low < high) {
while (low < high && arr[high] >= pivotKey) {
--high;
}
arr[low] = arr[high];
while (low < high && arr[low] <= pivotKey) {
++low;
}
arr[high] = arr[low];
}
arr[low] = temp;
return low;
}
void QSort(int arr[], int length, int low, int high) {
if (low < high) { //长度大于1
int pivotloc = Partition(arr, length, low, high);
QSort(arr, length, low, pivotloc - 1);
QSort(arr, length, pivotloc + 1, high);
}
}
int main() {
//ShellSort();
int arr[5] = {3, 6, 2, 8, 8};
//BobSort(arr, 5);
QSort(arr, 5, 0, 4); //参数:数组,长度,左下标,右下标
for (int i = 0; i < 5; i++) {
cout<<arr[i]<<" ";
}
// shellSort(arr, 5);
}
-----改进的交换排序
基本思想:
- 任取一个元素(如:第一个)为中心 (pivot: 枢轴、中心点)
- 所有比它小的元素一律前放,比它大的元素一律后放,形成左右两个子表
- 对各子表重新选择中心元素并依次规则调整(递归思想)
- 直到每个子表的元素只剩一个(结束条件)
基本思想:
通过一趟排序,将待排序记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录进行排序,以达到整个序列有序
具体实现:
选定一个中间数作为参考,所有元素与之比较,小的调到其左边,大的调到其右边
(枢轴)中间数:
可以是第一个数、最后一个数、最中间一个数、任选一个数等
一种方法:开辟新空间
第二种方法:
- 每一趟的子表的形成是采用从两头向中间交替式逼近法
- 由于每趟中对各子表的操作相似,可采用递归方法
快速排序算法分析
-
时间复杂度
- 可以证明,平均计算时间是O(nlog2n)
- Osort() : O(log2n)
- Partition() : O(n)
- 实验结果表明:就平均计算时间而言,快速排序是我们所讨论的所有内排序方法中最好的一个
- 可以证明,平均计算时间是O(nlog2n)
-
空间复杂度
快速排序不是原地排序
由于程序中使用了递归,需要递归调用栈的支持,而栈的长度取决于递归调用的深度。(即使不用递归,也需要用用户栈)
- 在平均情况下:需要O(logn)的栈空间
- 最坏情况下:栈空间可达O(n)
-
稳定性
快速排序是一种不稳定的排序方法
快速排序算法分析
- 划分元素的选取是影响时间性能的关键
- 输入数据次序越乱,所选划分元素值的随机性越好,排序速度越快,快速排序不是自然排序方法
- 改变划分元素的选取方法,至多只能改变算法平均情况下的时间性能,无法改变最坏情况下的时间性能。即最坏情况下,快速排序的时间复杂性总是O(n2)
选择排序
简单选择排序
基本思想:在待排序的数据中选出最大(小)的元素放在其最终的位置
基本操作:
- 首先通过n - 1次关键字比较,从n个记录中找出关键字最小的记录,将它与第一个记录交换
- 再通过n - 2次比较,从剩余的n - 1个记录中找出关键字最小的记录,将它与第二个记录交换
- 重复上述操作,共进行n - 1趟排序后,排序结束
简单选择排序算法分析
-
时间复杂度
-
记录移动次数
- 最好情况:0
- 最坏情况:3(n - 1)
-
比较次数:无论待排序列处于什么状态,选择排序所需进行的“比较”次数都相同
-
算法稳定性
- 简单选择排序是不稳定排序
堆排序
堆排序:
若在输出堆顶的最小值(最大值)后,使得剩余n - 1个元素的序列重又建成一个堆,则得到n个元素的次小值(次大值)......如此反复,便能得到一个有序序列,这个过程称之为堆排序。
实现堆排序需解决两个问题:
- 如何由一个无序序列建成一个堆?
- 如何在输出堆顶元素后,调整剩余元素为一个新的堆?
如何在输出堆顶元素后,调整剩余元素为一个新的堆?
小根堆为例:
- 输出堆顶元素之后,以堆中最后一个元素替代之
- 然后将根结点值与左、右子树的根结点值进行比较,并与其中小者进行交换
- 重复上述操作,直至叶子结点,将得到新的堆,称这个从堆顶至叶子的调整过程为“筛选”
显然:
单结点的二叉树是堆
在完全二叉树中所有以叶子结点(序号 i > n / 2)为根的子树是堆。
这样,我们只需依次将以序号为n / 2, n / 2 - 1, .....的结点为根的子树均调整为堆即可。
即:对应由n个元素组成的无序序列,“筛选”只需从第n / 2个元素开始
由以上分析知:
若对一个无序序列建堆,然后输出根;重复该过程就可以由一个无序序列输出有序序列。
实质上,堆排序就是利用完全二叉树中父结点与孩子结点之间的内在关系来排序的。
算法性能分析:
-
初始堆化所需时间不超过O(n)
-
排序阶段(不含初始堆化)
-
一次重新堆化所需时间不超过O(logn)
-
n - 1次循环所需时间不超过O(nlogn)
TW(n) = O(n) + O(nlogn) = O(nlogn)
-
-
堆排序的时间主要耗费在建初始堆和调整建新堆时进行的反复“筛选”上。堆排序在最坏情况下,其时间复杂度也为O(nlog2n),这是堆排序的最大优点。无论待排序列中的记录是正序还是逆序排列,都不会使堆排序处于“最好”或“最坏”的状态
-
另外,堆排序仅需一个记录大小供交换用的辅助存储空间。
-
然而堆排序是一种不稳定的排序方法,它不适用于待排序记录个数n较少的情况,但对于n较大的文件还是很有效的。
归并排序
- 基本思想:将两个或两个以上的有序子序列“归并”为一个有序序列。
- 在内部排序中,通常采用的是2 - 路归并排序
- 即:将两个位置相邻的有序子序列R[l...m]和R[m + 1...n]归并为一个有序序列R[l...n]
归并排序算法分析
-
时间效率:O(nlog2n)
-
空间复杂度:O(n)
因为需要一个与原始序列同样大小的辅助序列(R1)。这正是此算法的缺点。
-
稳定性:稳定
基数排序
基本思想:分配 + 收集
也叫桶排序或箱排序:设置若干个箱子,将关键字为K的记录放入第k个箱子,然后在按序号将非空的连接。
基数排序:数字是有范围的,均由0-9这十个数字组成,则只需设置十个箱子,相继按个、十、百...进行排序。
基数排序算法分析
时间效率:O(k * (n + m))
k : 关键字个数
m: 关键字取值范围为m个值
空间效率:O(n + m)
稳定性:稳定
各种排序方法的综合比较
一、时间性能
- 按平均的时间性能来分,有三类排序方法:
- 时间复杂度为O(nlogn)的方法有:
- 快速排序、堆排序和归并排序,其中以快速排序为最好
- 时间复杂度为O(n2)的有:
- 直接插入排序、冒泡排序和简单选择排序,其中以直接插入为最好,特别是对那些对关键字近似有序的记录序列尤为如此
- 时间复杂度为O(n)的排序方法只有:基数排序
- 时间复杂度为O(nlogn)的方法有:
- 当待排序记录序列关键字顺序有序时,直接插入排序和冒泡排序能达到O(n)的时间复杂度;而对于快速排序而言,这是最不好的情况,此时的时间性能退化为O(n2),因此是应该尽量避免的情况。
- 简单选择排序、堆排序和归并排序的时间性能不随记录序列中关键字的分布而改变。
二、空间性能
指的是排序过程中所需的辅助空间大小
- 所有的简单排序方法(包括:直接插入、冒泡和简单选择)和堆排序的空间复杂度为O(1)
- 快速排序为O(logn),为栈所需的辅助空间
- 归并排序所需辅助空间最多,其空间复杂度为O(n)
- 链式基数排序需附设队列首尾指针,则空间复杂度为O(r*d)
三、排序方法的稳定性能
- 稳定的排序方法指的是,对于两个关键字相等的记录,它们在序列中的相对位置,在排序之前和经过排序之后,没有改变。
- 当对多关键字的记录序列进行LSD方法排序时,必须采用稳定的排序方法。
- 对于不稳定的排序方法,只要能举出一个实例说明即可。
- 快速排序和堆排序是不稳定的排序方法。
四、关于“排序方法的时间复杂度的下限”
-
本章讨论的各种排序方法,除基数排序外,其它方法都是基于“比较关键字”进行排序的排序方法,可以证明,这类排序法可能达到的最快的时间复杂度为O(nlogn)
(基数排序不是基于“比较关键字”的排序方法,所以它不受这个限制)。
-
可以用一棵判定树来描述这类基于“比较关键字”进行排序的排序方法。