文章目录
1.概述
- 排序是计算结程序设计中的一种重要操作,它的功能是将一个数据元素的任意序列,重新排列一个换关键字有序的序列。
- 如果排序中存在着两个和两个以上的关键字相等的记录,那么如果排序后的序列是惟一的,则称用的排序方法是稳定的;反之,若可能使排序后的序列不唯一,则称用的排序方法是不稳定的。
- 排序分为内部排序和外部排序:
- 内部排序:指的是待排序记录存放在计算机随机存储器中进行的排序过程。
- 外部排序:指待排序记录的数量很大,以致内存一次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程
- 大部分算法中,待排记录的数据类型设为:
#define MAXSIZE 20 //一个用作示例的小顺序表的最大长度
typedef int KeyType; //定义关键字类型为整数类型
typedef struct
{
KeyType key;
Info otherinfo; //其他数据项
}RedType;
typedef struct
{
RedType r[MAXSIZE+1]; //r[0]闲置或用作哨兵单元
int length;
}SqList; //顺序表类型
2.插入排序
(1)直接插入排序O(n2)
- 直接插入排序是一种最简单的排序方法,它的基本操作时将一个记录插入到已排好序的有序表中。对于一个n个结点的记录,需要插入n-1次。每次插入在从后往前搜索的过程中,可以同时后移记录。时间复杂度比冒泡稍微低(虽然还是O(n2)),但是在基本有序的情况下复杂度可达到O(n2),比冒泡常用。
- 实际示例:
- 实现代码
void InsertSort(SqList& L)
{
int i, j;
RedType tmp;
for (i = 1; i < L.length; ++i)
{
tmp = L.r[i];
for (j = i - 1; j >= 0; --j)
{
if (tmp.key >= L.r[j].key)
break;
else
{
L.r[j + 1] = L.r[j];
L.r[j] = tmp;
}
}
}
}
(2)希尔排序(小于O(n2))
- 希尔排序,又称缩小增量排序,它也是一种属插入排序类的方法,但在时间效率上较前排序方法有较大的改进
- 通过上述直接插入排序知在基本有序的情况下下时间复杂度为O(n2),所以希尔排序对其作出优化,即:先将整个待排记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序、
- 希尔排序示例
- 从示例可见,希尔排序的一个特点是:子序列的构成不是简单地“逐段分隔”,而是将相隔某个“增量”的记录组成一个子序列。如上例中,第一趟排序时的增量为5,第二趟排序时的增量为3,由于在前两趟的插入排序中记录的关键字是和同一子序列中的前一个记录的关键字进行比较,因此关键字较小的记录就不是一步一步地往前挪动,而是跳跃式地往前移,从而使得最后一趟增量为1的插入排序时,序列已基本有序。
void ShellInsert(SqList& L, int dk)
{
int i, j;
RedType tmp;
for (i = dk; i < L.length; ++i)
{
tmp = L.r[i];
for (j = i - dk; j >= 0; j-=dk)
{
if (tmp.key >= L.r[j].key)
break;
else
{
L.r[j + dk] = L.r[j];
L.r[j] = tmp;
}
}
}
}
void ShellSort(SqList& L)
{
for (int i = 5; i > 0; i -= 2)
{
ShellInsert(L, i);
}
}
- 增量序列可以有各种取法,但需注意:应使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须为1。
3.快速排序
(1)冒泡排序O(n2)
- 取第一个元素与后一个比较,如果大于后者,就与后者互换位置,不大于,就保持位置不变。再拿第二个元素与后者比较,如果大于后者,就与后者互换位置。一轮比较之后,最大的元素就移动到末尾。相当于最大的就冒出来了。再进行第二轮,第三轮,直到排序完毕。
- 实际示例:
- 实现代码:
void BubblingSort(SqList& L)
{
int num = L.length - 1;
for (int num = L.length - 1;num > 0; --num)
{
for (int i = 0; i < num; ++i)
{
if (L.r[i].key > L.r[i + 1].key)
{
RedType tmp = L.r[i];
L.r[i] = L.r[i + 1];
L.r[i + 1] = tmp;
}
}
}
}
(2)快速排序(two pointers)O(nlogn)
- 快速排序时对起泡排序的一种改进。它的基本思想是, 通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序
- 快速排序涉及到了two pointer思想,那么什么是two pointer思想呢?让我们来举个例子:
给定一个递增的正整数序列和一个正整数M,求序列中的两个不同位置的数a和b,使得他们的和恰好为M,输出所有满足条件的方案。
普通的想法肯定是一个二重循环,查找所有满足条件的方法
int i,j
for(i=0;i<length-1;++i)
for(j=i+1;j<length;++j)
{
if(a[i]+a[j] ==M)
cout<<a[i]<<a[j];
}
这样的算法时间复杂度达到了O(nn),我们尝试另一种思路,利用两个指针,一个指向首部记为i,一个指向尾部记为j,如果它们指向的和小于M,则i++,大于M则j–,否则输出并且i++,j–,直到i==j,代码如下:
while(i<j)
{
if(a[i]+a[j] == m)
{
cout<<a[i]<<a[j];
++i;
--j;
}
else if(a[i]+a[j] < m)
++i;
else
--j;
}
上述算法的时间复杂度为O(n),可以发现two pointers思想充分利用了序列递增的性质,面对一些有序序列的遍历操作很有用。还比如两个有序序列的合并,归并排序,和现在需要解决的快速排序。
- 回到快速排序,依旧是定义双指针指向头尾,每次将第一个元素作为枢轴,作为划分界限,先从后往前遍历,如果遇到比枢轴小的,就将a[low] =a[high],再从前往后遍历,如果遇到比枢轴大的,就将a[high] = a[low] 。最终枢轴左边都是比枢轴小的,枢轴大的都是比枢轴大的,第一次划分完毕。再往下递归
int Partition(SqList& L, int low, int high)
{
//定义枢轴
RedType zhou = L.r[low];
while (low < high)
{
while (low < high && L.r[high].key >= zhou.key) --high;
L.r[low] = L.r[high];
while (low < high && L.r[low].key < zhou.key) ++low;
L.r[high] = L.r[low];
}
L.r[low].key = zhou.key;
return low;
}
void QSort(SqList& L,int low,int high)
{
if (low < high)
{
int pivotloc = Partition(L, low, high);
QSort(L, low, pivotloc - 1);
QSort(L, pivotloc + 1, high);
}
}
void QuickSort(SqList& L)
{
QSort(L, 0, L.length - 1);
}
快速排序的平局时间为T = knlnn,其中n为待排序序列记录的个数,k为某个常数,时间复杂度为O(nlogn),就平均时间而言,快速排序是目前被认为是最好的一种内部排序方法
4.选择排序
- 选择排序的基本思想是:每一趟在n-i+1个记录中选取关键字最小的记录作为有序序列的第i个记录
(1)简单选择排序O(n2)
- 一趟简单选择排序的操作为:通过n-i次关键字的比较,从n-i+1个记录中选择出关键字最小的记录作为有序序列的第i个记录
void SelectSort(SqList& L)
{
int i, j;
for (i = 0; i < L.length - 1; ++i)
{
int min = INT_MAX;
int index = -1;
for (j = i + 1; j < L.length; ++j)
{
if (L.r[j].key < min)
{
min = L.r[j].key;
index = j;
}
}
RedType tmp = L.r[i];
L.r[i] = L.r[index];
L.r[index] = tmp;
}
}
(2)树形选择排序O(nlogn)
- 树形选择排序,又称锦标赛排序,是一种按照锦标赛的思想进行选择排序的方法。
- 它首先对n个记录的关键字进行两两比较,然后在其中⌈n/2⌉个较小者之间在进行两两比较,如此重复,知道选出最小关键字的记录为止。这个过程可用一棵有n个叶子结点的完全二叉树表示。
第一轮从下而上构造出完全二叉树,根结点即最小结点,输出后,将对应叶子结点置为最大值,再与兄弟结点比较后顺着树修改到根结点,得到第二个最小结点,由此得到所有最小结点。 - 时间复杂度为O(nlogn),但是这种排序方法由于辅助存储空间较多,和“最大值”进行多余的比较等缺点,很少使用,更常用的是下面要说的堆排序。
(3)堆排序O(nlogn)
- 堆排序只需要一个记录大小的辅助空间,每个待排序的记录仅占有一个存储空间。
- 堆的定义:
若将次序列对应的一维数组看成是一个完全二叉树,则堆的含义表明,完全二叉树中所有非终端结点的值均不大于(或不小于)其左、右孩子结点的值。由此,若序列是堆,则堆顶元素必须为序列中n个元素的最小值(或最大值)。若在输出堆顶的最小值之后,使得剩余n-1个元素的序列重又建成一个堆,则得到n个元素的次小值,如此反复执行,便能得到一个有序序列,这个过程称之为堆排序。 - 由此,实现堆排序需要解决两个问题:
- 如何由一个无序序列建成一个堆?
- 如何在输出栈顶元素之后,调整剩余元素称为一个新的堆?
- 首先解决第二个问题:通过筛选解决,筛选就是自堆顶至叶子的调整过程。
- 再看第一个问题:从无序序列建堆的过程就是一个反复“筛选”的过程。若将此序列看成是一个完全二叉树,则最后一个非终端结点是⌊n/2⌋个元素,由此“筛选”只需从第⌊n/2⌋个元素开始。
- 堆排序算法:
【注意:堆排序下标要从1开始,因为如果从0开始的话得不到根结点的子节点】
typedef SqList HeapType;
void HeapAdjust(HeapType& H, int s, int m)
{
RedType rc = H.r[s];
for (int i = 2 * s; i <= m; i *= 2)
{
//如果右结点更小则交换右结点
if ( i< m && H.r[i].key<H.r[i + 1].key) ++i;
if (rc.key > H.r[i].key) break;
H.r[s] = H.r[i];
s = i;
}
H.r[s] = rc;
}
void HeapSort(HeapType& H)
{
for (int i = H.length / 2; i > 0; --i)
HeapAdjust(H, i, H.length);
for (int i = H.length;i > 1; --i)
{
RedType tmp = H.r[1];
H.r[1] = H.r[i];
H.r[i] = tmp;
HeapAdjust(H, 1, i - 1);
}
}
- 堆排序方法对记录树较少的文件并不值得提倡,但对n较大的文件还是很有效的。堆排序在最坏的情况下,其时间复杂度也为O(nlogn)。相对快速排序来说,这是堆排序最大的优点。
5.归并排序O(nlogn)
- 归并排序是又一类不同的排序方法。“归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。无论是顺序存储结构还是链表存储结构,都可在O(m+n)的时间量级上实现。
- 2-路归并排序
- 与快速排序和堆排序相比,归并排序最大的特点是,它是一种稳定的排序方法。但在一般情况下,很少利用2-路归并排序法进行内部排序。
- 归并排序算法实现:
void Merge(const RedType _TR[],RedType TR[],int s,int mid,int t)
{
int i = s, j = mid + 1, k = s;
while (i <= mid && j <= t)
{
if (_TR[i].key < _TR[j].key)
TR[k++] = _TR[i++];
else
TR[k++] = _TR[j++];
}
while(i<=mid)
TR[k++] = _TR[i++];
while(j<=t)
TR[k++] = _TR[j++];
}
void MSort(const RedType SR[], RedType TR[], int s, int t)
{
if (s == t)
TR[s] = SR[s];
else
{
RedType _TR[20];
int mid = (s + t) / 2;
MSort(SR, _TR, s, mid);
MSort(SR, _TR,mid + 1,t);
Merge(_TR, TR, s, mid, t);
}
}
void MergeSort(SqList& L)
{
MSort(L.r, L.r, 0, L.length-1);
}
6.桶排序
- 基本思路:将待排序元素划分到不同的桶。先扫描一遍序列求出最大值 maxV 和最小值 minV ,设桶的个数为 k ,则把区间 [minV, maxV] 均匀划分成 k 个区间,每个区间就是一个桶。将序列中的元素分配到各自的桶。
- 对每个桶内的元素进行排序。可以选择任意一种排序算法,将各个桶中的元素合并成一个大的有序序列。
- 假设数据是均匀分布的,则每个桶的元素平均个数为 n/k 。假设选择用快速排序对每个桶内的元素进行排序,那么每次排序的时间复杂度为 O(n/klog(n/k)) 。总的时间复杂度为 O(n)+O(m)O(n/klog(n/k)) = O(n+nlog(n/k)) = O(n+nlogn-nlogk 。当 k 接近于 n 时,桶排序的时间复杂度就可以金斯认为是 O(n) 的。即桶越多,时间效率就越高,而桶越多,空间就越大。
(1)计数排序O(n)
- 是一种O(n)的排序算法,其思路是开一个长度为 maxValue-minValue+1 的数组,然后
- 分配:扫描一遍原始数组,以当前值- minValue 作为下标,将该下标的计数器增1。
- 收集:扫描一遍计数器数组,按顺序把值收集起来。
- 举个例子, nums=[2, 1, 3, 1, 5] , 首先扫描一遍获取最小值和最大值, maxValue=5 , minValue=1 ,于是开一个长度为5的计数器数组 counter ,
- 分配。统计每个元素出现的频率,得到 counter=[2, 1, 1, 0, 1] ,例如 counter[0] 表示值 0+minValue=1 出现了2次。
- 收集。 counter[0]=2 表示 1 出现了两次,那就向原始数组写入两个1, counter[1]=1 表示 2 出现了1次,那就向原始数组写入一个2,依次类推,最终原始数组变为 [1,1,2,3,5] ,排序好了。
- 适用于分布较密集的整数排序,是时间复杂度最小的算法
void CountSort(SqList& L)
{
int min=INT_MAX,max = 0;
for (int i = 0; i < L.length; ++i)
{
if (L.r[i].key < min)
min = L.r[i].key;
if(L.r[i].key > max)
max = L.r[i].key;
}
int size = max - min + 1;
int* tmp = (int*)malloc(sizeof(int) * size);
memset(tmp, 0, size*sizeof(int));
for (int i = 0; i < L.length; ++i)
{
++tmp[L.r[i].key - min];
}
//输出
for (int i = 0; i < size; ++i)
{
for (int j = tmp[i]; j > 0; --j)
cout << (i + min)<<" ";
}
delete tmp;
}
- 看完上述代码后,你可能就会发现,该计数算法并不是真正的对原来的序列进行排列,而是按一定的顺序将该序列顺序输出,所以只能对单纯的整数排序有较好的效果,是不稳定的排序,那我们能不能对其作出改进,使其能够对序列进行排列,成为一个稳定的排序方法呢,答案是可以的。
- 上述例子中我们得到counter=[2, 1, 1, 0, 1],现在我们将其修改为每一位是前一位加自身,得到newcounter = [2,3,4,4,5](即原序列元素对应的真实位置),然后我们创建输出序列SortedArray,序列长度和原有序列一致,并且从后往前遍历原有的序列,将每个元素对应newcounter里面对应的插入位置记录,然后插入到输出序列中,并且将对应newcount对应的插入位置减1(下一个元素值相同的元素应该插入该元素前面一位)。
- 优化算法如下:
void better_CountSort(SqList& L)
{
int i,min=INT_MAX,max = 0;
for (i = 0; i < L.length; ++i)
{
if (L.r[i].key < min)
min = L.r[i].key;
if(L.r[i].key > max)
max = L.r[i].key;
}
int size = max - min + 1;
int* tmp = (int*)malloc(sizeof(int) * size);
memset(tmp, 0, size*sizeof(int));
for (i = 0; i < L.length; ++i)
++tmp[L.r[i].key - min];
//求新的tmp
for (i = 0; i < size-1; ++i)
tmp[i + 1] += tmp[i];
RedType* sortedArray = (RedType*)malloc(sizeof(RedType) * L.length);
for (i = L.length - 1; i >= 0; --i)
{
sortedArray[tmp[L.r[i].key - min] - 1] = L.r[i];
--tmp[L.r[i].key - min];
}
//回赋值给原序列
for (i = 0; i < L.length; ++i)
L.r[i] = sortedArray[i];
delete tmp;
}
(2)基数排序O(n)
- 基数排序是计数排序的一个扩展,因为当数字较大或者是大小相差较大时,计数排序的空间复杂度过高,导致时间复杂度的常数也很高,所以引出了基数排序,即分别对数字的个位、百位、千位…进行计数排序,最终可得到有序序列,时间复杂度也是O(n)。
void better_CountSort(SqList& L,int exp)
{
int i;
int size = 10;
int* tmp = (int*)malloc(sizeof(int) * size);
memset(tmp, 0, size*sizeof(int));
for (i = 0; i < L.length; ++i)
++tmp[(L.r[i].key/exp)%10 ];
//求新的tmp
for (i = 0; i < size-1; ++i)
tmp[i + 1] += tmp[i];
RedType* sortedArray = (RedType*)malloc(sizeof(RedType) * L.length);
for (i = L.length - 1; i >= 0; --i)
{
sortedArray[tmp[(L.r[i].key/exp)%10] - 1] = L.r[i];
--tmp[(L.r[i].key / exp) % 10];
}
//回赋值给原序列
for (i = 0; i < L.length; ++i)
L.r[i] = sortedArray[i];
delete tmp;
}
void radixSort(SqList& L)
{
int exp; // 指数。当对数组按各位进行排序时,exp=1;按十位进行排序时,exp=10;...
int max = getMax(L); // 数组a中的最大值
// 从个位开始,对数组a按"指数"进行排序
for (exp = 1; max / exp > 0; exp *= 10)
better_CountSort(L, exp);
}
7.各个排序算法比较
- 从平均时间性能而言,快速排序最佳,其所需时间最省,但快速排序在最坏的情况下的时间性能不如堆排序和归并排序
- 简单排序和基数排序是稳定的,快速排序、堆排序和希尔排序是不稳定的。