一、基本概念
1.1增排序和减排序
按关键字从大到小或从小到大划分。
1.2内部排序和外部排序
数据元素均在内存中即内部排序,否则则包含外部排序。
1.3稳定排序和不稳定排序
关键字相同的两个元素,排序后相对位置发生变化即不稳定,否则即稳定。
1.4排序算法的评价指标
时间复杂度 和 空间复杂度。
二、插入排序
2.1基本思想
将待排序表看作左右两个部分,左边为有序区,右边为无序区,整个排序过程就是将右边无序区的元素逐个插入到有序区中,以构成有序区。主要介绍 直接插入排序和希尔排序。
2.2直接插入排序
现直接给出代码和注释:
void insertSort(elementType A[n+1]) {
for(int i = 2; i <= n; i++){ //I表示待插入元素的下标
A[0] = A[I]; //设置监视哨保存待插入元素,以腾出A[i]的空间
j = I - 1; //j表示当前空位置的前一个
while(A[j].key > A[0].key){ //搜索插入位置并腾出空位
A[j+1] = A[j];
j = j - 1;
}
A[j+1] = A[0]; //插入元素
}
}
算法分析:
1.稳定性:该算法为稳定算法。
2.空间性能:该算法仅需要一个记录的监视哨辅助空间。
3.时间性能:整个算法循环n-1次,每次循环中的基本操作为比较和移动元素,一般情况下为O(N^2).
2.3希尔排序(Shell Sort)
基本思想:将待排序的序列划分为若干组别,在每组内进行直接选择插入排序,以使得整个序列基本有序,然后再对整个序列进行直接插入排序。
这种排序的关键在于选组。而我们所决定的选择是将整个序列的长度的1/2在初始选择为步长。后面依次递减1/2。
伪代码:
void ShellSort(elementType A[n+1], int dh) { //dh means the ORIGINAL FOOTSTEP
while(dh>=1) {
for(I = dh + 1; I <= n; I++){
temp = A[I];
j = I;
while(j > d && temp.key<A[j-dh].key){
A[j] = A[j-dh];
j = j - dh;
}
A[j] = temp;
}
dh = dh/2;
}
}
算法分析:
希尔排序是分组插入排序,先按照规定将元素分组,同一组内采用直接插入排序。
对比希尔排序和直接插入排序,希尔排序除了分组循环外,其余同插入排序几乎完全一致,只是步长从1变为了dh
1.该算法为不稳定算法。
2.空间复杂度为O(1)
3.时间复杂度为O(nlog2N)
与分区方法有很大关系
性能优于直接插入排序,时间复杂度介于O(n)和O(n^2)之间,大致为O(1.3)或O(1.5)
三、交换排序
两两比较待排序元素,发现倒序则交换。
3.1冒泡排序
逐个比较两相邻元素,发现倒序则交换。
典型做法是从后往前(从下往上)逐个比较相邻2个元素,发现倒序则交换。
每次扫描一定能将当前最小/大的元素交换到最终位置,如同水泡冒出水面
伪代码:
void bubbleSort(int A[]){
for(int I = 1; I < n; I++){
for(int j = n; j >= I + 1; j--){
if(A[j].key<A[j-1].key){
swap(A[j],A[j-1]; //SWAP in IOSTREAM
}
}
}
}
改进的冒泡排序
接下来考虑一种极端情况:序列本身就是有序。
此种情况下,依然将进行O(n^2)级别的扫描。
很明显不够划算。
因此,我们可以设置一种含有标志是否已经交换完成的标志。这样作为每次冒泡排序完成后是否还需要继续的标志。
因此得到的改进算法如下:
void bubbleSort(int A[n+1]) {
I = 1;
do{
exchanged = FALSE; // As a sign of Exchanged or not
for(j = n; j >= I + 1; j--) {
if(A[j].key < A[j-1].key){
swap(A[j],A[j-1]);
exchanged = TRUE;
}
}
I++;
}while(I<=n-1&&exchanged == TRUE);
}
算法分析:
稳定性:稳定排序
空间复杂度:O(1)的辅助空间
时间复杂度:
受到数据表初始状态影响大。
最好情况:正序 比较n-1次,交换 0 次, 时间复杂度O(n)
最坏情况:全部逆序 比较与交换 均为 n*(n-1)/2;
一般:O(n^2)
3.2快速排序
3.2.1基本思想:分治法。
选定一个元素作为中间元素,然后将表中所有元素与之比较:
比其小的放在表的前面;
比其大的放在表的后面;
该元素放在两部分中间做划分,这就是其最终位置。
这样就可以得到一个划分(二分)
然后对左右子表再分别进行划分。
快速排序通过一趟排序将排序序列分成左右两部分,使得左边任意元素均不大于/小于右边任意元素,并将中间元素放到最终位置。
3.2.2操作方法
选择第一个元素作为中间元素
1.先保存该元素到其他位置,腾出该位置。
2.从后往前扫描一个比中间数小的元素,并将其放置到(1)中的空位置上,此时后面空出一个位置。
3.从前往后扫描一个比中间数大的元素,并将其放置到(2)中的空位置上,此时前面空出一个位置。
重复2、3直到两边扫描到的空位重合,此时将中间元素放在空位中。
3.3.3算法设计
分区算法
1.保存中间元素的值到临时变量x以腾出空间,并且用low指向该元素,即x = A[low];
2.从后往前搜索比这个数字小的元素,并将其放在空位上,从而在后面腾出一个位置(high指向)
3.从前往后扫描到比这个数字大的元素,将其放置在(2)中的high上,从而使得前面空出一个位置(low指向)
重复2、3直到两边扫描的位置重合(low==high,即在该空位前没有更大的元素,此后没有更小的元素)因而可以将中间元素放在此位置,该元素归位。
void Partition(int A[], int low,int high, int &mid) {
//low 分区的第一个元素下标,high 作为最后一个元素下标
//mid为中间元素
A[0] = A[low];
while(low < high) {
//A[high] >= mid元素则不交换,high左移
while(low < high && A[high].key >= A[0].key) high--;
//右区间遇到第一个小于mid的元素,移动到 A[low]
//此时A[low]的元素已经取到A[0]
//同时A[high]已经移动,其为空位置,可以存放其他数据
A[low] = A[high];
//A[low]<= mid 元素,则不交换,low右边移动
while(low < high && A[low].key <= A[0].key) low++;
//左区间遇到第一个大于此中间元素的值,移动到 A[high]
//此时A[high]空
A[high] = A[low];
}
//此时low == high 为目标的空位置
A[low] = A[0];//将中间元素移动到目标位置
mid = low; //返回本次中间值的最终位置
}
快速排序即用到上述的分区算法
void QuickSort(int A[n], int low, int high){
int mid; // mid 由Partition函数给出
if(low <high){
Partition(A,low,high,mid);
QuickSort(A,low,mid-1);
QuickSort(A,mid+1,high);
}
}
算法分析:
1.稳定性:不稳定排序
2.空间复杂度:需要一个辅助空间
3.时间复杂度:
理想情况:每次选择元素正好两等份子表。整个算法复杂度为O(nlog2N)
最坏情况:每次选择的元素恰为最大/最小。即需要(n-1)次划分,扫描(n-i+1)次。整个复杂度为O(n^2)
一般情况:O(K*nlog2^N)
分析可得:划分中中间元素的选择非常重要,因此改进选择为:比较子表第一个、最后一个、中间元素。选取中值作为枢纽元素。
而快排目前也被认为是内部排序最优解之一。
四、选择排序
基本思想:在每次排序中选出关键字最小/最大的元素放在最终位置。
4.1简单(直接)选择排序
通过在待排序子表中完整的比较一遍以确定最值元素,并将该元素放在子表的最前/后面。
void SelectSort(int A[],int n) {
//1~n
for(int i = 1; i < n; i++) {
int min = i;
for(int j = i + 1; j < n; j++) {
if(A[j] < A[min])
min = j;
if(min!=i) {
swap(A[min],A[i]);
}
}
}
}
算法分析:
稳定性:不稳定排序。
空间复杂度:需要一个额外空间。O(1)
时间复杂度:
共比较n*(n-1)/2次
最多交换n-1次,一趟最多交换1次
O(n^2)
4.2堆排序
4.2.1堆及其基本概念
堆实际上是一棵完全二叉树
·若其每个结点均不大于其左右孩子的值,称为小根堆(根结点的值最小)
·若其每个结点均不小于其左右孩子的值,称为大根堆(根结点的值最大)
可见,若某序列为堆,其堆顶必为序列中的最大值或最小值。
堆排序的基本思想:
假设要求递增排序且已有一个大根堆
1.输出根
2.用二叉树的最后一个结点替代根,重新调整堆(待排序元素-1)
3.重复上述直到输出全部结点。
可见,要解决两个问题:
一是如何建立初始堆、二是输出根后如何调整堆。
4.2.2堆的筛选(调整)
1.输出根,用二叉树最后一个结点代替新的根。
2.调整堆,此时,除了跟结点和其左右孩子违反条件外,其余左右子树仍然满足条件。即整个序列不是堆,但其左右子树仍然是堆。
如何调整:
1.由于其左右子树是堆,此时左右孩子结点的值分别是两个子树中的最大值。因此,新的堆顶只可能从当前根点、其左右孩子中产生,故可以比较这三者得到。
2.如果当前根结点已经是最大值,即已经是堆,则无需调整;否则将左右孩子中的最大值与根对换。
但是调整之后可能违反子树中堆的大小,因此需要在执行调换的子树中继续进行。
算法设计:
1.保存临时根的值到一个变量(设为x)用i标记该结点。
2.比较i结点的左右孩子和x的最大值:
2.1 i结点没有左右孩子,即已经到达叶子结点。将x填到i结点中。
2.2 i结点的左右孩子的值小于x的值,表示搜索到了填充位置,将x填入i结点中。
2.3 否则将左右孩子中的最大填充在i结点中,从而出现新的空位,因此,同样用i指示,并且转2.2继续执行。
整理可得 所需参数:
调整中,堆顶的下标不一定为1,因此需要将堆顶的下标作为参数---K,输出根之后,参与运算的元素个数减一,因此,需要将当前序列的元素个数作为参数---M,加上数组参数A[].
void sift(int A[], int k, int m) {
//调整以K为根的子树序列为堆
//其中K为子树根,M为最大元素编号
//假设以2K和2K+1为根的左右子树均为堆
int x = A[k]; //临时保存当前根值,空出位置
bool finished = false;//设置未结束标志
int i = k; //i指示空位,子树根
int j = 2*i; //j指向k的左孩子结点
while(j<=m && !finished) {
//确定i结点不是叶子且未搜索结束
if(j < m && A[j] < A[j + 1])
j = j +1;//找出i左右孩子中的最大者,用j指向
if(x>=A[j])
finished = true;
//根值最大,无需再调整,结束标志置真
else {
A[i] = A[j]; //最大值A[j]上升为树根
i = j; //跟新子树根i为j继续调整j以下的子树为堆
j = 2 * j; //继续下筛,i仍为子树树根,j指向其左孩子结点
}
}
A[i] = x; //循环结束i即为x的最终位置,使得K为根的子树为大根堆
}
从N/2开始从右往左、自下而上逐棵子树调整。
建立初堆:
for(int I = n/2; I>=1;i--){
sift(A,i,n);
}
堆排序:
void HeapSort(int A[],int n){
int i;
//初建堆--由初始序列产生堆(此处为大根堆)
//从第n/2结点开始往上筛,
//直到1号结点(根、堆顶)
for(i = n/2; i>=1;i--) {
sift(A,i,n);
//每次调用此函数,
//都将以i为根结点的子树调整为堆。
}//由堆序列产生排序序列,
//此时整棵树(完全二叉树)为堆(此处为大根堆)
for(i=n;i>=2;i--)
{
A[0]=A[i]; //完全二叉树最后一个结点保存到A[0],
//空出位置i输出根A[1],即当前子树的根(堆顶)
A[i]=A[1]; //输出根,即A[1]保存到排序后的最终位置i
A[1]=A[0]; //原第i元素暂作为“根”。
//又A[1]=A[0]后可能破坏了当前树的堆属性,
//需要从根结点1开始重新调整为堆
//因为输出根,此时树的结点数为i-1。
sift(A,1,i-1);
}
}
算法分析:
稳定性:不稳定。
空间复杂度:需要一个辅助空间,O(1)
时间复杂度:
主要花费在建立初堆和调整堆上。
高度为h的堆,筛选算法中所进行的关键比较次数最多为2(h-1)次。
h=floor(log2n)+1;
即最多为log2N次
堆排序共调用筛选n-1次;建立初堆共调用筛选n/2次。
总复杂度为O(nlog2n)
五、归并排序
归并排序先设法将原序列划分为只含有1个元素的子表(视为有序)
然后反复选择两个有序子表进行合并直到合并后的序列长度为n
归并算法基于两个基本操作:划分和合并
划分操作将1个未排序序列划分成2个更短的子序列。
归并操作将2个或者多个有序子序列合并成1个更长的有序序列。
归并排序可以分为:
·自顶向下的
·自底向上的
归并排序同快速排序一样,都是分治法的典型应用。
5.1归并
(同线性表一样的三情况分情况讨论)
void merge(int A[],int B[],int C[],int la, int lb, int lc) {
//非降序数组A,B前la,lb个元素合并到C 并且保持其次序
int ia = 1, ib = 1, ic = 1;
while(ia <= la && ib <= lb)
if(A[ia]<=B[ib])
C[ic++] = A[ia++];
else
C[ic++] = B[ib++];
while(ia <= la)
C[ic++] = A[ia++];
while(ib<=lb)
C[ic++] = B[ib++];
}
算法分析:
对于A和B均是一遍扫描,整个时间复杂度为O(|A| + |B|)
而归并排序中归并的两个字序列要放在同一个表A中,因此要通过元素下标参数对两个字表进行定界。
通过三个参数low、mid、high来确定2个有序子序列。
第一个子序列放在A[low~mid]
第二个子序列放在A[mid+1~high]
此外,归并中要把归并后的元素放在一个临时表T中,T的大小与A相同归并完成后,再将T中的元素复制到A中。
Merge函数需要4个参数:
A[]存放元素序列 low序列第一个元素下标 high序列最后一个元素下标 mid划分点下标
改造后的归并序列:
void Merge(int A[], int low, int mid, int high) {
int T[10005];
int i, j, k;
//i作为low~mid的下标 j作为mid+1~high的下标 k作为T的下标
i = low;
k = low;
j = mid + 1;
while(i<=mid && j<=high) {
//A两个子表都有元素
if(A[i] <= A[j]) {//A[i]较小
T[k] = A[i];
i++;
}else {
T[k] = A[j];
j++;
}
k++;
}
//处理一个表结束,另一个尚未结束的场景
while(i<=mid) {
T[k] = A[i];
i++;
k++;
}
while(j<=high) {
T[k] = A[j];
j++;
k++;
}
//复制回原表
memcpy(A,T, sizeof(T));
}
自底向上
·将原序列视为(划分为)n个有序子序列,子序列长度为1,每个子表只有一个元素;
·当子序列长度小于N的时候,循环选择2个相邻有序子序列,归并