排序算法
本文总结内部排序(数据在内存中而不是存储在文件中)的一些经典算法,对《数据结构》中相关知识给予总结和复习,同时方便查阅。
约定:为了方便下文把“内部排序”简称为“排序”。
按照排序过程中依据的不同原则对排序方法分类,大致可分为:插入排序、交换排序、选择排序、归并排序和基数排序等。
约定:为了简洁这里只研究如何对“关键字”排序,将“关键字”和“记录”视为一体,相信任何有编程知识的人都知道怎么推广到具体程序中对记录排序。同样为了简便,假设所有的排序算法都将无序序列排列为“非递减”顺序。
一、 插入排序
1.1 直接插入排序(Straight Insertion Sort)
它的基本操作是将一个记录插入到已经排好序的有N个记录的有序序列中,从而形成一个新的、记录数为N+1的有序序列。(此处的“序列”可以实作为数组或链表等,本文讨论的都是基于数组的实作,链表对于某些操作会更加方便)
对于一个有N个记录的待排序的序列,整个排序过程为进行N -1趟插入:先将表中的第一个记录看成一个有序的子序列,然后从第二个记录起逐个查找合适的位置进行插入,直至整个序列变成有序序列为止。
算法代码:
template <typename T>
void InsertSort( T *L, int len )
{
T temp;
int j;
for (int i = 1; i < len; i++)
{
// 如果当前的数据比的已序序列中最大的数据小,
// 则需要插入到前方某个位置上
if (L[i] < L[i-1])
{
temp = L[i];
// 后移数据
L[i] = L[i-1]; // 减少一次没必要的比较
for (j = i-2; (j >= 0) && (temp < L[j]); j--)
L[j+1] = L[j];
// 插入到正确的位置
L[j+1] = temp;
}
}
}
小改进:
(1) 折半插入排序:把“直接插入排序”中“查找合适的位置”的查找算法从“逐个比较”替换为“折半查找”。
(2) 2-路插入排序:这是在(1)的改进基础上,针对插入新记录而导致大块记录需要后移而进行的改进,具体地说:另设一个与原序列L同类型大小相等的序列D,并设置D[0] = L[0];从L中的二记录开始依次插入到D[0]之前或之后的有序子序列。在实现算法时,D被看成了一个循环序列,并设置两个指针first和final分别指示排序过程中得到的有序序列的第一个记录和最后一个记录在D中的位置。
(3) 表插入排序:用静态链表作为存储记录的数据结构,这样只需修改next指示量而无需移动记录(很怀疑为什么不直接用链表,搞得这么麻烦,这个算法就不多说了)。
上述几种算法的时间复杂度均为O(n2)。
1.2 希尔排序(Shell’s Sort)
此算法又称为“缩小增量排序”(Diminshing Increment Sort),基本思想是:先将整个待排记录序列分割成若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”(即减少“当前记录比已序序列中最大的记录要大”的情形)时,再对全体记录进行一次直接插入排序。子序列的构成不是简单地“逐段分割”,而是将向各某个“增量”的记录组成一个子序列,且增量本身在逐渐的缩小(例如:依次以增量5、3、1对序列排序)。“增量”序列的选择可以有各种取法,且各种取法的时间复杂度不一,但必须满足:增量序列中的值没有除了1之外的公因子,并且最后一个增量值必须为1。
算法代码:
//------------------------------------------------------------------------
// 功能: 一趟希尔排序
// 参数: L ---- 待排序序列; len ---- 序列长度;dk ---- 子序列分割增量
//------------------------------------------------------------------------
template <typename T>
void ShellInsert( T *L, int len, int dk )
{
T temp;
int j;
for (int i = dk; i < len; i++)
{
// 如果当前的数据比的已序序列中最大的数据小,
// 则需要插入到前方某个位置上
if (L[i] < L[i - dk])
{
temp = L[i];
// 后移数据
L[i] = L[i - dk]; // 减少一次没必要的比较
for (j = i - 2 * dk; (j >= 0) && (temp < L[j]); j -= dk)
L[j + dk] = L[j];
// 插入到正确的位置
L[j + dk] = temp;
}
}
}
//----------------------------------------------------------------------------
// 功能: 希尔排序
// 参数: L ---- 待排序序列; tlen ---- 序列长度;
// dlta ---- 子序列分割增量数组; dlen ---- 子序列分割增量数组的长度
//----------------------------------------------------------------------------
template <typename T>
void ShellSort(T *L, int tlen, int dlta[], int dlen)
{
for (int k = 0; k < dlen; k++)
ShellInsert(L, tlen, dlta[k]);
}
二、 交换排序
2.1 起(冒)泡排序(Bubble Sort)
n个记录的序列排序过程:第i(i =1,2...n-1)趟排序过程:从第0个记录开始,依次比较相邻两个记录,如果逆序则交换之,直到比较第n – i – 1 和n – i 个记录;重复上述排序过程直到“某趟排序没有发生交换”或排序到第n-1趟。
算法伪代码:
//-----------------------------------------------------------------------------
// 功能: 冒泡排序
// 参数: L ---- 待排序序列 len ---- 序列长度
//-----------------------------------------------------------------------------
template <typename T>
void BubbleSort(T *L, int len)
{
for (int i = 1; i < len; i++)
{
bSwap = false;
for (int j = 0; j < len - i; j++)
{
if (L[j] > L[j + 1])
{
// 交换记录
L[j] ←→ L[j + 1];
bSwap = true;
}
}
// 如果此趟没有发生交换,说明已序
if (!bSwap)
return;
}
}
复杂度为O(n2)
2.2 快速排序(Quick Sort)
基本思想:通过一趟排序将待排记录分割成对立的两部分,其中一部分记录均比另一部分记录小,再分别对这两部分排序,已达到这个序列有序。
一趟排序具体步骤为:任选一个记录(一般选第一个记录)为枢轴pivot,设两个指针low和high指向当前序列的第一和最后一个记录,先从high所指位置向前搜索第一个小于pivot的记录并互换,然后从low所指位置向后搜索第一个大于pivot的记录并互换,重复这两步直到low = high。
算法代码:
//-------------------------------------------------------------------
// 功能: 一趟快速排序,将序列分成两部分,一部分均大于另一部分
// 参数: L ---- 待排序序列;low ---- 待排序列的开始记录的索引
// high ---- 待排序列的最后一个记录的索引
//-------------------------------------------------------------------
template <typename T>
int Partition(T *L, int low, int high)
{
T pivot(L[low]);
while (low < high)
{
while ((low < high) && (L[high] >= pivot)) high--;
L[low] = L[high];
while ((low < high) && (L[low] <= pivot)) low++;
L[high] = L[low];
}
L[low] = pivot;
return low;
}
//-------------------------------------------------------------------
// 功能: 快速排序
// 参数: L ---- 待排序序列;low ---- 待排序列的开始记录的索引
// high ---- 待排序列的最后一个记录的索引
//-------------------------------------------------------------------
template <typename T>
void QSort(T *L, int low, int high)
{
if (low < high)
{
int pviotloc = Partition(L, low, high);
QSort(L, low, pviotloc - 1);
QSort(L, pviotloc + 1, high);
}
}
//-------------------------------------------------------------------
// 功能: 快速排序的参数简化写法
// 参数: L ---- 待排序序列;
// len ---- 待排序列的记录数目
//-------------------------------------------------------------------
template <typename T>
void QuickSort(T* L, int len)
{
QSort(L, 0, len - 1);
}
复杂度O(nlog n),在同数量级算法中,其平均性能好。
三、 选择排序(Selection Sort)
基本思想:每一趟在n – i + 1个记录中(i=1,2 ... n-1)个记录中选取最小的那个作为第i个记录。
3.1 简单选择排序 (略,参考上面“基本思想”)
3.2 树形选择排序
又称“锦标赛排序”,具体步骤:首先对n个记录两两比较,然后再在其中的ceil( n/2 )个较小者之间再两两比较,如此重复。
分析:复杂度O(nlog2 n),缺点:存储空间较多、和“最大值”进行多余的比较等缺点。
3.3 堆排序(Heap Sort)
堆的定义:n个元素序列{K1,K2, ... Kn }满足以下两者之一:
(1) Ki <= K2i 且Ki <= K2i+1;
(2) Ki >= K2i 且Ki >= K2i+1
( 其中i = 1,2... floor(n/2) )。
其实就是一棵完全二叉树的一维数组表示, 第i个记录(i从1开始)的左右子树根节点分别为2i 和 2i+1。(PS: 我们实际编程时更喜欢i以0开始, 那么就是2i +1 和 2i + 2)
基本步骤:(1)构造一个“大顶堆”(也就是根的记录比左右子树都大);(2)将堆顶记录和最后一个未排序的记录(假设第i个)交换,将前n-i个记录再排为“大顶堆”;重复(2)直到所有记录已序。
算法伪代码:
//-------------------------------------------------------------------
// 功能: 已知L[first .. last] 中除了L[first]之外均满足堆的定义(大顶堆)
// 重新将L[first .. last] 调整为"大顶堆".
//-------------------------------------------------------------------
template <typename T>
void HeapAdjust(T *L, int first, int last)
{
T temp(L[first]);
// 沿着记录较大的孩子结点向下筛选
for (int j = 2 * first + 1; j <= last; j = 2 * j + 1)
{
// j为较大的孩子结点的下标
if ((j < last) && (L[j] < L[j+1]))
j++;
if (temp >= L[j])
break;
L[first] = L[j];
first = j;
}
L[first] = temp;
}
//-------------------------------------------------------------------
// 功能: 堆排序
// 参数: L ---- 待排序序列; len ---- 待排序列的记录数目
//-------------------------------------------------------------------
template <typename T>
void HeapSort(T* L, int len)
{
// 将L[0..len-1]建为大顶堆:
// 从最后一个非叶子节点i起将L[i..len-1]建为大顶堆
// 重复该过程i-1,i-2直到第个节点
for (int i = len / 2 - 1; i >= 0; --i)
HeapAdjust(L, i, len - 1);
for (int i = len - 1; i >= 0; --i)
{
// 先将堆顶记录和当前未排序的子序列L[0..i]中最后一个记录互换
L[0] ←→ L[i];
// 再将L[0..i-1]重新调整为大顶堆
HeapAdjust(L, 0, i - 1);
}
}
复杂度O(nlog n)。
四、 归并排序(Merge Sort)
2-路归并排序基本思想:将初始的n个记录看成n个有序的子序列,每个子序列长度为1,然后两两归并,得到ceil(n/2)个长度为2或1的有序子序列;在两两归并,...... 如此重复,直到得到一个长度为n的有序序列为止。
算法代码:
//-------------------------------------------------------------------
// 将有序的L[i..m]和L[m + 1..n]归并为有序的L[i..n]
//-------------------------------------------------------------------
template <typename T>
void Merge(T *L, int i, int m, int n)
{
T *pTemp = new T[n - i + 1];
int j = m + 1;
int k = 0;
int l = i;
for ( ; l <= m && j <= n; ++k)
{
if (L[l] <= L[j])
pTemp[k] = L[l++];
else
pTemp[k] = L[j++];
}
while (l <= m) pTemp[k++] = L[l++];
while (j <= n) pTemp[k++] = L[j++];
for (k = 0; i <= n; i++)
L[i] = pTemp[k++];
delete[] pTemp;
}
//-------------------------------------------------------------------
// 将L[s..t]轨并排序
//-------------------------------------------------------------------
template <typename T>
void MSort(T* L, int s, int t)
{
if (s == t)
return;
int m = (s + t) / 2;
MSort(L, s, m);
MSort(L, m + 1, t);
Merge(L, s, m, t);
}
template <typename T>
void MergeSort(T* L, int len)
{
MSort(L, 0, len - 1);
}
复杂度O(nlog n)。
五、 基数排序(Radix Sort)
前面几种排序中我尽量不去区别“关键字”和“记录”的概念,这里不得不区分了,因为这是一类基于多个关键字对应于一个记录的排序方法。
5.1 多关键字排序
假设有n的记录 { R1, R2, ... Rn},其中每个记录又含有d个关键字 (K0, K1, ... Kd-1),其中关键字优先级逐次降低:
第一种方法:先对主关键字K0排序,将序列划分成若干子序列,每个子序列中都有相同的K0值,然后对每个子序列对关键字K1排序,分成更小的若干子序列,依次重复至对每个子序列对关键字K d-1排序, 然后将所有子序列联接成一个有序序列。
第二种方法:与上相反, 依次以K d-1,K d-2 ... K 0排序.
5.1 链式基数排序
基本思想:有的逻辑关键字可以看成有若干个关键字复合而成,这是一种借助“分配”和“收集”两种操作对单逻辑关键字进行的一种内部排序。
举例:对一组数字(最大数为3位数, 不满3位的前面置0)排序,首先以静态链表存储n个待排记录,并令表头指针指向第一个记录;第一趟分配以个位数进行分成10个链队列(0,1...9),第一趟收集是改变所有的非空队列的队尾记录的指针域,令其指向下一个非空队列的队头记录,重新将10个队列中记录链成一个链表;接着第二趟分配和收集针对十位数....直到第三趟的分配和收集结束。
总结:
排序方法 | 平均时间 | 最坏情况 | 辅助存储 |
简单排序 | O(n2) | O(n2) | O(1) |
快速排序 | O(n logn) | O(n2) | O(logn) |
堆排序 | O(n logn) | O(n logn) | O(1) |
归并排序 | O(n logn) | O(n logn) | O(n) |
基数排序 | O(d(n + rd)) | O(d(n + rd) | O(rd) |