基本概念
什么是排序?
- 排序
- 将序列中的记录按照排序码顺序排列起来
- 排序码域的值具有不减(或不增)的顺序
- 内排序
- 整个排序过程在内存中完成
- 给定一个序列 R = { r1, r2, ...,rn }
- 其排序码分别为 k = { k1, k2, ...,kn }
- 排序的目的:将记录按排序码重排
- 形成新的有序序列R’={r’1,r’2,...,r’n}
- 相应排序码为 k’= { k’1, k’2, ...,k’n }
- 排序码的顺序
- 其中 k’1 ≤ k’2 ≤ ... ≤ k’n,称为不减序
- 或 k’1 ≥ k’2 ≥ ... ≥ k’n ,称为不增序
排序的稳定性
- 稳定性
- 存在多个具有相同排序码的记录
- 排序后这些记录的相对次序保持不变
- 排序稳定性的意义
- 保持第一关键字相同的数据排序前后顺序不变,若不稳定的排序要做到这一点,则需要增加第二个关键字。
插入排序
直接插入排序算法
template
void ImprovedInsertSort (Record Array[], int n){ //Array[] 为待排序数组,n 为数组长度
Record TempRecord; // 临时变量
for (int i=1; iwhile ((j>=0) && (TempRecord < Array[j])){
Array[j+1] = Array[j];
j = j - 1; }
//此时 j 后面就是记录 i 的正确位置,回填
Array[j+1] = TempRecord; }
}
复制代码
算法分析
- 稳定
- 空间代价:Θ(1)
- 时间代价:
- 最佳情况:n-1次比较,2(n-1)次移动,Θ(n)
- 最差情况:Θ(n2)
- 比较次数为n(n-1)/2 = O(n2)
- 移动次数为 (n-1)(n-4)/2 = O(n2)
- 平均情况:Θ(n2)
- 性质:
- 序列本身有序的话时间代价为O(N)
- 对于短序列比较有效
Shell排序
算法思想
- 先将序列转化为若干小序列,然后在小序列里面插入排序
- 逐步增加小序列规模,从而减少小序列个数
- 最后对整个序列扫尾直接插入排序
“增量每次除以2”的Shell排序
template
void ShellSort(Record Array[], int n) {
// Shell排序,Array[]为待排序数组,n为数组长度
int i, delta;
// 增量delta每次除以2递减
for (delta = n/2; delta>0; delta /= 2)
for (i = 0; i < delta; i++)
// 分别对delta个子序列进行插入排序 //“&”传 Array[i]的地址,数组总长度为n-i ModInsSort(&Array[i], n-i, delta);
// 如果增量序列不能保证最后一个delta间距为1 // 可以安排下面这个扫尾性质的插入排序
// ModInsSort(Array, n, 1);
}
template // 参数delta表示当前的增量 void ModInsSort(Record Array[], int n, int delta) {
int i, j;
// 对子序列中第i个记录,寻找合适的插入位置 for (i = delta; i < n; i += delta)
// j以dealta为步长向前寻找逆置对进行调整 for(j=i;j>=delta;j-=delta) {
if (Array[j] < Array[j-delta]) // 逆置对 swap(Array, j, j-delta);// 交换
else break; }
}
复制代码
算法分析
- 不稳定
- 空间代价O(1)
- 时间代价O(n2)
- 选取的增量并不互质,实际上上一轮子序列都排序过了,导致效率过低
- 选取合适的增量序列,可以是时间接近于O(n)
Hibbard 增量序列
- Hibbard 增量序列
- {2k -1,2k-1 -1,...,7,3,1}
- Shell(3) 和 Hibbard 增量序列的 Shell 排序的效率可以达到 Θ(n3/2)
Shell最好的代价
- 呈 2p3q 形式的一系列整数: – 1, 2, 3, 4, 6, 8, 9, 12
- Θ(nlog2n)
选择排序
- 直接选择排序
- 堆排序
直接选择排序
每次从剩下的堆中选择最小的
template
void SelectSort(Record Array[], int n) {
// 依次选出第i小的记录,即剩余记录中最小的那个
for (int i=0; ifor (int j=i+1;jif (Array[j] < Array[Smallest])
Smallest = j;
// 将第i小的记录放在数组中第i个位置(从而导致不稳定)
swap(Array, i, Smallest);
} }
复制代码
算法分析
- 不稳定(因为会交换位置)
- 空间代价O(1)
- 时间代价
- 比较次数O(n2)
- 交换次数n-1
- 总时间代价O(n2)
堆排序
- 选择类内排序 由于直接选择排序是从剩余记录中线性地去查找最大记录所以效率较低,我们可以采用最大堆来实现,效率会更高
- 选择类外排序(这一部分后面讨论)
- 置换选择排序
- 赢者树、败方树
template
void sort(Record Array[], int n){
int i;
// 建堆
MaxHeap max_heap
= MaxHeap(Array,n,n); // 算法操作n-1次,最小元素不需要出堆 for (i = 0; i < n-1; i++)
// 依次找出剩余记录中的最大记录,即堆顶
max_heap. RemoveMax(); }
复制代码
算法分析
- 不稳定(Siftdown的过程会有可能导致相同元素出入序列不一致)
- 建堆O(n)
- 删除堆顶O(log n)
- 总时间代价O(nlog n)
- 空间代价O(1)
- 在只需得到最大或最小的一部分元素时有优势(部分排序)
交换排序
- 冒泡排序
- 快速排序
冒泡排序
- 算法思想
- 不停地比较相邻的记录,如果不满足排序要求,就 交换相邻记录,直到所有的记录都已经排好序
- 检查每次冒泡过程中是否发生过交换,如果没 有,则表明整个数组已经排好序了,排序结束
- 避免不必要的比较
template
void BubbleSort(Record Array[], int n) {
bool NoSwap; // 是否发生了交换的标志
int i, j;
for (i = 0; i < n-1; i++) {
NoSwap = true;
for (j = n-1; j > i; j--){
if (Array[j] < Array[j-1]) {
swap(Array, j, j-1);
NoSwap = false
}
if (NoSwap)
return;
}
} }
复制代码
算法分析
- 稳定
- 空间代价O(1)
- 时间代价
- 比较次数
- 最少O(n)
- 最多O(n2)
- 交换次数 平均O(n2)
- 比较次数
- 结论
- 最大平均为O(n2)
- 最小为O(n)
- 冒泡排序和直接选择排序从时间复杂度上来讲是一样的,都是O(n^2)的排序算法,但是因为冒泡排序是每次交换相邻两个,所以在常数上可能要稍微大一点。冒泡排序是稳定的,直接选择排序是不稳定的。
快速排序
- 思想(分治策略)
- 选择轴值(pivot)
- 将序列划分为两个子序列L和R,使得L中所有记录都小于或等于轴值,R中记录都大于轴值
- 对子序列L和R递归进行快速排序
- 轴值选择
- 尽可能使 L, R 长度相等
- 选择策略:
- 选择最左边记录
- 随机选择
- 选择平均值
template
void QuickSort(Record Array[], int left, int right) {
if (right <= left) // 只有0或1个记录,就不需排序
return;
int pivot = SelectPivot(left, right); // 选择轴值
swap(Array, pivot, right); // 轴值放到数组末端
pivot = Partition(Array, left, right); // 分割后轴值正确
QuickSort(Array, left, pivot-1); // 右子序列递归快排
QuickSort(Array, pivot +1, right); // 右子序列递归快排
}
int SelectPivot(int left, int right) {
return (left+right)/2;// 选中间记录作为轴值
}
//分割函数
template
int Partition(Record Array[], int left, int right) { // 分割函数,分割后轴值已到达正确位置
int l = left; // l 为左指针
int r = right; // r 为右指针
Record TempRecord = Array[r]; // 保存轴值
while (l != r) { // l, r 不断向中间移动,直到相遇
// l 指针向右移动,直到找到一个大于轴值的记录
while (Array[l] <= TempRecord && r > l)
l++;
if (l < r) { // 未相遇,将逆置元素换到右边空位
Array[r] = Array[l];
r--; // r指针向左移动一步
}
// r 指针向左移动,直到找到一个大于轴值的记录
while (Array[r] >= TempRecord && r > l)
r--;
if (l < r) { // 未相遇,将逆置元素换到左空位
Array[l] = Array[r];
l++; // l 指针向右移动一步 }
} //end while
Array[l]=TempRecord; //把轴值回填到分界位置l上
return l;
// 返回分界位置l
}
复制代码
时间代价
- 不稳定(轴值的选择会影响到)
- 最差情况:
- 时间代价:Θ(n2)
- 空间代价:Θ(n)
- 最佳情况:
- 时间代价:Θ(nlog n)
- 空间代价:Θ(log n)
- 平均情况:
- 时间代价:Θ(nlog n)
- 空间代价:Θ(log n)
快速排序优化
- 轴值选择 RQS
- 小子串不递归 (比如阈值 28, 采用直接插入排序等)
- 消除递归(用栈,队列)
- 更多参见这篇文章
- 快速排序比大部分排序算法都要快。尽管我们可以在某些特殊的情况下写出比快速排序快的算法,但是就通常情况而言,没有比它更快的了。快速排序是递归的,对于内存非常有限的机器来说,它不是一个好的选择。
归并排序
归并排序思想
- 划分为两个子序列
- 分别对每个子序列归并排序
- 有序子序列合并
两路归并排序
#include
using namespace std;
// 两个有序子序列都从左向右扫描,归并到新数组
template
void Merge(Record Array[], Record TempArray[], int left ,int right,int middle){
int i,j,index1,index2;
for(j=left;j<=right;j++)
TempArray[j] = Array[j]; //放入临时数组
i = left;
index1 = left;
index2 = middle+1;
while(index1<=middle && index2<=right){
if(TempArray[index2] < TempArray[index1])
Array[i++] = TempArray[index2++];
else
Array[i++] = TempArray[index1++];
}
while(index1<=middle) //复制剩下的左序列
Array[i++] = TempArray[index1++];
while(index2<=right) //复制剩下的右序列
Array[i++] = TempArray[index2++];
}
template
void MergeSort(Record Array[], Record TempArray[], int left, int right) {
// Array为待排序数组,left,right两端
int middle;
if (left < right) { // 否则序列中只有0或1个记录,不用排序
middle = (left + right) / 2; // 平分为两个子序列
// 对左边一半进行递归
MergeSort(Array,TempArray,left,middle);
// 对右边一半进行递归
MergeSort(Array, TempArray,middle+1,right);
Merge(Array, TempArray,left,right,middle); // 归并
}
}
int main(){
int arr[] = {1,9,7,10,5,8,4,2,11,6};
int tempArr[10];
MergeSort(arr,tempArr,0,9);
for(int i:arr)
cout<" ";
cout<return 1;
}
复制代码
归并算法优化
- 同优化的快速排序一样,对基本已排序序 列直接插入排序
- R.Sedgewick优化:归并时从两端开始 处理,向中间推进,简化边界判断
#include
using namespace std;
#define THRESHOLD 28
template
void InsertSort(Record *p,int len){
Record TempRecord; // 临时变量
for (int i=1; iwhile ((j>=0) && (TempRecord < *(p+j))){
*(p+j+1) = *(p+j);
j = j - 1;
}
*(p+j+1) = TempRecord;
}
}
template
void ModMerge(Record Array[],Record TempArray[],int left,int right,int middle) {
int index1,index2; // 两个子序列的起始位置
int i,j,k ;
for (i = left; i <= middle; i++)
TempArray[i] = Array[i]; // 复制左边的子序列
for (j = 1; j <= right-middle; j++) // 颠倒复制右序列
TempArray[right-j+1] = Array[j+middle];
for (index1=left, index2=right, k=left; k<=right; k++)
if (TempArray[index1] <= TempArray[index2])
Array[k] = TempArray[index1++];
else
Array[k] = TempArray[index2--];
}
template
void ModMergeSort(Record Array[], Record TempArray[], int left, int right) { // Array为待排序数组,left,right两端
int middle;
if (right-left+1 > THRESHOLD) { // 长序列递归
middle = (left + right) / 2; // 从中间划为两个子序列
ModMergeSort(Array, TempArray ,left,middle); // 左
ModMergeSort(Array, TempArray ,middle+1,right);// 右
// 对相邻的有序序列进行归并
ModMerge(Array, TempArray ,left,right,middle); // 归并
}
else
InsertSort(&Array[left],right-left+1); // 小序列插入排序
}
int main(){
int arr[] = {1,9,7,10,5,8,4,2,11,6,11,12,13,14,15,16,17,18,19,28,17,14,12,13,16,36,27,29,20,4,2,11,6,11,12,13,14,15,16,17,18,19,28};
int tempArr[43];
ModMergeSort(arr,tempArr,0,42);
for(int i:arr)
cout<" ";
cout<return 1;
}
复制代码
算法分析
- 空间代价O(n)
- 最大,最小,平均时间代价O(nlog n)
- 普通归并排序是稳定的,因为合并的时候是按照顺序合并。Sedgewick采用一个正序一个逆序合并所以相同值会颠倒顺序。所以是不稳定的。
- Sedgewick算法需要更多地元素之间的比较,但是普通算法需要判断边界,所以比较次数更多。总体来说Sedgewick 算法更优。
分配排序和基数排序
- 不需要进行纪录之间两两比较
- 需要事先知道记录序列的一些 具体情况
桶排序
- 事先知道序列中的记录都位于某个小区间段 [0, m) 内
- 将具有相同值的记录都分配到同一个桶中,然后依次按照编号从桶中取出记录,组成一个有序序列
template
void BucketSort(Record Array[], int n, int max) {
Record *TempArray = new Record[n]; // 临时数组
int *count = new int[max];
int i;
for (i = 0; i < n; i++)
TempArray[i] = Array[i];
for (i = 0; i < max; i++)
count[i] = 0;
for (i = 0; i < n; i++)
count[Array[i]]++;
for (i = 1; i < max; i++)
count[i] = count[i-1]+count [i]; // c[i]记录i+1的起址
for (i = n-1; i >= 0; i--) // 尾部开始,保证稳定性
Array[--count[TempArray[i]]] = TempArray[i];
}
复制代码
算法分析
- 数组长度为 n, 所有记录区间 [0, m) 上
- 时间代价:
- 统计计数:Θ(n+m) , 输出有序序列时循环 n 次
- 总的时间代价为 Θ(m+n)
- 适用于m相对于n很小的情况,也就是适用于小区间,但m不要超过nlogn比较好,因为桶式排序的算法复杂度为O(n+m),如果m过大则换用其它方法比较好.
- 空间代价:
- m 个计数器,长度为 n 的临时数组,Θ(m+n)
- 稳定
基数排序
- 桶式排序只适合 m 很小的情况
- 基数排序:当m很大时,可以将一个记录的值即排序码拆分为多个部分来进行比较
例子
例如:对 0 到 9999 之间的整数进行排序
- 将四位数看作是由四个排序码决定,即千、百 、十、个位,其中千位为最高排序码,个位为 最低排序码。基数 r=10。
- 可以按千、百、十、个位数字依次进行4次桶 式排序
- 4趟分配排序后,整个序列就排好序了
低位优先法
- LSD,Least Significant Digit first
- 从最低位 k0 开始排序
- 对于排好的序列再用次低位 k1 排序;
- 依次重复,直至对最高位 kd-1 排好序后, 整个序列成为有序的
- 分、收;分、收;...;分、收的过程 – 比较简单,计算机常用
基数排序的实现
- 主要讨论 LSD(低位优先法)
- 基于顺序存储
- 基于链式存储
- 原始输入数组R的长度为n,基数为 r,排序码个数为 d
基于数组的基数排序
template
void RadixSort(Record Array[], int n, int d, int r) {
Record *TempArray = new Record[n];
int *count = new int[r]; int i, j, k;
int Radix = 1; // 模进位,用于取Array[j]的第i位
for(i=1;i<=d;i++) { //对第i个排序码分配
for (j = 0; j < r; j++)
count[j] = 0; // 初始计数器均为0
for(j=0;jfor (j = 1; j < r; j++) // 给桶划分下标界
count[j] = count[j-1] + count[j];
for(j=n-1;j>=0;j--) { //从数组尾部收集
k=(Array[j]/Radix)%r; //取第i位
count[k]--; // 桶剩余量计数器减1
TempArray[count[k]] = Array[j]; // 入桶
}
for (j = 0; j < n; j++) // 内容复制回 Array 中
Array[j] = TempArray[j];
Radix *= r; // 修改模Radix
}
}
复制代码
顺序基数排序代价分析
- 空间代价:
- 临时数组, n
- r 个计数器
- 总空间代价 Θ(n+r)
- 时间代价
- 桶式排序:Θ(n+r)
- d 次桶式排序
- Θ(d·(n+r))
基于静态链的基数排序
- 将分配出来的子序列存放在 r 个 (静 态链组织的) 队列中
- 链式存储避免了空间浪费情况
链式基数排序算法代价分析
- 空间代价
- n 个记录空间
- r 个子序列的头尾指针
- Θ(n + r)
- 时间代价
- 不需要移动记录本身, 只需要修改记录的 next 指针
- Θ(d·(n+r))
template
void RadixSort(Record *Array, int n, int d, int r) {
int i, first = 0; // first指向第一个记录
StaticQueue *queue = new StaticQueue[r];
for (i = 0; i < n-1; i++)
Array[i].next = i + 1; // 初始化静态指针域
Array[n-1].next = -1; // 链尾next为空
// 对第i个排序码进行分配和收集,一共d趟
for(i=0;i
void Distribute(Record *Array, int first, int i, int r, StaticQueue *queue) {
int j, k, a, curr = first;
for (j = 0; j < r; j++)
queue[j].head = -1;
while (curr != -1) { // 对整个静态链进行分配
k = Array[curr].key;
for (a = 0; a < i; a++) // 取第i位排序码数字k
k = k / r;
k = k % r;
if (queue[k].head == -1) // 把数据分配到第k个桶中
queue[k].head = curr;
else
Array[queue[k].tail].next = curr;
queue[k].tail = curr;
curr = Array[curr].next; // curr移动,继续分配
}
}
template
void Collect(Record *Array, int& first, int r, StaticQueue *queue) {
int last, k=0; // 已收集到的最后一个记录
while (queue[k].head == -1)
k++; // 找到第一个非空队
first = queue[k].head;
last = queue[k].tail;
while (k < r-1) { // 继续收集下一个非空队列
k++;
while (k < r-1 && queue[k].head == -1)
k++;
if(queue[k].head!=-1) {//试探下一个队列
Array[last].next = queue[k].head;
last = queue[k].tail;
}
}
Array[last].next = -1;
}
复制代码
线性时间整理静态链表
template
void AddrSort(Record *Array, int n, int first) {
int i, j;
j = first; // j待处理数据下标
Record TempRec;
for(i=0;iwhile (j <= i)
j = Array[j].next;
}
}
复制代码
基数排序效率
- 时间代价为Θ(d·n), 实际上还是 Θ(nlog n)
- 基数排序中应该是d>=logrm,m为排序码中的最大数。实际测试的时候当n>=10亿时,在64bit系统下基数排序还是有优势的。甚至比introsort还要快一些。只有排序码中元素两两不相同时,才有m=n。
索引排序
在排序时,若是数据很复杂,对数据的移动显然是费时的。若把数据移动改为索引(或指针)移动,则减少了操作复杂度。索引排序,也叫地址排序,就是这种排序思想。
索引含义
根据索引的含义不同,索引排序的算法上也主要分为两种。
- index[i]为array[i]最终在有序序列中的位置。
- index[i]为位置i上最终应存放元素的下标。即最终元素按array[index[0]]、array[index[1]]……有序。
索引排序的适用性
- 一般的排序方法都可以
- 那些赋值(或交换)都换成对index 数组的赋值(或交换)
- 举例:插入排序
插入排序的索引地址排序版本
template
void AddrSort(Record Array[], int n) {
//n为数组长度
int *IndexArray = new int[n], TempIndex; int i,j,k;
Record TempRec;
for (i=0; ifor (i=1; ifor (j=i; j>0; j--)
if ( Array[IndexArray[j]] else break;
//此时i前面记录已排序
for(i=0;iwhile (IndexArray[j] != i) {
k=IndexArray[j];
Array[j]=Array[k];
IndexArray[j] = j;
j = k;
}
Array[j] =TempRec;
IndexArray[j] = j;
}
}
复制代码
总结
- 时间复杂度(试验时间)
- 稳定性
- 对于稳定的排序算法,在排序前将数据随机打乱,就可以达到不稳定的效果了。
- 对于不稳定的排序算法,可以将数据扩充出一个域,用于记录初始下标,当数据关键值相同时,比较下标大小,这样排序算法就是稳定的了。