最近正在学习数据结构与算法,边学习边记录一下。
1. 基本概念
(1)时间复杂度:算法中基本操作重复执行的次数是问题规模n的某个函数,其时间量度记作 T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n)) 。
(2)空间复杂度:在算法运行时所需的辅助空间大小的度量。
(3)排序稳定:具有相同关键字的元素,经过排序后相对次序仍保持一致。
(4)排序不稳定:具有相同关键字的元素,经过排序后相对次序不能保持一致。
(5)内部排序:待排序内容在内存中就可以完成。
(6)外部排序:待排序内容无法一次装入内存,待排序的记录存储在外存储器上,需要在内存与外存储器之间进行多次数据交换。
2. 插入排序
插入排序由N-1趟排序组成,每趟插入P都保证位置0到位置P-1的上的元素都已经是排序好的。举个例子,假设关键字为:7,4,-2,19,13,6,插入排序过程如下。
void InsertionSort(ElementType A[], int N)
{
int j,P;
ElementType Tmp;
for(P=1; P<N; P++)
{
Tmp=A[P];
for(j=P; j>0&&A[j-1]>Tmp; j--)
A[j] = A[j-1]; //若该值大于插入值则后移该值
A[j] = Tmp; //跳出循环,把插入元素插入到适当位置
}
}
由于嵌套循环每一次都花费N次迭代,因此插入排序的平均时间复杂度为 O ( N 2 ) O(N^2) O(N2),如果数据已经预先排序,则运行时间为 O ( N ) O(N) O(N)。对于几乎被排序的数据,使用插入排序运行非常快。
3. 希尔排序
希尔排序也属于插入排序法,它是一种分组的插入排序,通过比较具有相同间距的子序列进行插入排序,各趟比较的间距越来越小,直到间距为1为止。这些间隔组成的序列叫做增量序列。例如待排列关键字为:9,13,8,2,5,13,7,1,15,11,增量序列是5,3,1,则排序过程如下:
void ShellSort(ElementType A[], int N)
{
int i, j, Increment;
ElementType Tmp;
//递增序列,逐渐2倍衰减
for(Increment=N/2; Increment>0; Increment/=2)
for(i=Increment; i<N; i++)
{
Tmp = A[i];
//开始插入排序
for(j=i; j>=Increment; j-=Increment)
if(Tmp < A[j-Increment]) //逆序则修改
A[j] = A[j-Increment]; //大的值放后面
else
break; //若位置合适则停止
A[j] = Tmp; //将Tmp放到适合位置处
}
}
希尔排序的时间复杂度比较复杂,因为它跟增量序列有关。增量序列有很多种选法,上面代码使用的是 1 / 2 1/2 1/2递减的序列,还有其他更好的方法,例如Sedgewick增量序列最坏运行时间可以达到 O ( N 4 / 3 ) O(N^{4/3}) O(N4/3),但值得注意的是:应使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须是1。
4. 堆排序
堆排序是基于二叉堆的方法,堆中每个结点的值均不大于(或不小于)其左、右孩子结点的值,因此堆顶元素为序列中的最小值(或最大值)。以最大堆为例子,先根据序列建立一个堆,然后将堆顶元素和与堆序列中的最后一个元素互换,缩减堆大小进行下滤,如此循环直到堆大小为1为止。其实这相当于执行N-1次DeleteMax操作。例如,输入序列为:31,41,59,26,53,58,97,执行一次DeleteMax如下:
经过6次DeleteMax后得到的堆数组就是所需的排序数组。下面是堆排序的代码,注意不同于二叉堆,数据是从数组下标0处开始。
#define LeftChild(i) (2*i+1)
//下滤函数
void PercDown(ElementType A[], int i, int N)
{
int Child;
ElementType Tmp;
for(Tmp=A[i];LeftChild(i)<N; i=Child)
{
Child = LeftChild(i); //结点左孩子的索引
if(Child!=N-1 && A[Child+1]>A[Child]) //找更大的子结点
Child++; //如果右孩子大于左孩子,则取右
if(Tmp<A[Child]) //下滤
A[i]=A[Child];
else
break;
}
A[i] = Tmp;
}
void HeapSort(ElementType A[], int N)
{
int i;
for(i=N/2; i>=0; i--) //建立堆
PercDown(A, i, N);
for(i=N-1; i>0; i--)
{
Swap(&A[0], &A[i]); //交换堆顶元素和堆最后元素
PercDown(A, 0, i); //下滤调整,堆大小减1
}
}
堆排序不是一种稳定的算法,根据完全二叉树的性质,其在最坏情况下的时间复杂度为 O ( N l o g N ) O(NlogN) O(NlogN)。
5. 归并排序
归并排序的核心思想是合并两个已排序的数组,分别从两个数组的起始端开始比较,将较小的元素存入到输出数组,然后继续比较使这两数组合并为一个数组,这样递归的合并显然是容易的。例如,输入关键字分别为:23,38,22,45,23,67,31,15,41,则递归过程如下:
//合并算法
/*Lpos=左半部的开始, Rpos=右半部的开始*/
void Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd)
{
int i, LeftEnd, NumElements, TmpPos;
LeftEnd = Rpos - 1; //定位
TmpPos = Lpos; //从左半部开始
NumElememts = RightEnd - Lops + 1; //元素个数
while(Lops <= LeftEnd && Rpos <= RightEnd)
//开始扫描左右数组,依次从小到大开始存储
if(A[Lops]<=A[Rpos])
TmpArray[TmpPos++] = A[Lpos++];
else
TmpArray[TmpPos++] = A[Rpos++];
while(Lpos <= LeftEnd) //左半部多于右半部则copy剩余部分
TmpArray[TmpPos++] = A[Lpos++];
while(Rpos <= RightEnd) //右半部多于左半部则copy剩余部分
TmpArray[TmpPos++] = A[Rpos++];
//copy排序好的TmpArray给A
for(i=0; i<NumElements; i++, RightEnd)
A[RightEnd] = TmpArray[RightEnd];
}
//递归算法
void MSort(ElementType A[], ElementType TmpArray[], int Left, int Right)
{
int Center;
if(Left<Right)
{
Center = ( Left + Right ) / 2;
MSort(A, TmpArray, Left, Center); //递归左半部
MSort(A, TmpArray, Center+1, Right); //递归右半部
Merge(A, TmpArray, Left, Center+1, Right); //合并
}
}
void MergeSort(ElementType A[], int N)
{
ElementType *TmpArray;
TmpArray = malloc(N * sizeof(ElementType)); //申请内存
if(TmpArray != NULL)
{
MSort(A, TmpArray, 0, N-1);
free(TmpArray);
}
else
FatalError("No space for tmp array!!!");
}
归并排序排序的时间分析需要一些计算,对N个数归并排序的用时等于完成两个大小为N/2的递归排序所用时间再加上合并的时间:
T
(
1
)
=
1
T(1)=1
T(1)=1
T
(
N
)
=
2
T
(
N
/
2
)
+
N
T(N)=2T(N/2)+N
T(N)=2T(N/2)+N
可以变换为:
T
(
N
)
N
=
T
(
N
/
2
)
N
/
2
+
1
\frac{T(N)}{N}=\frac{T(N/2)}{N/2}+1
NT(N)=N/2T(N/2)+1
该方程对2的幂的任意的N是成立的,还可以写成:
T
(
N
/
2
)
N
/
2
=
T
(
N
/
4
)
N
/
4
+
1
\frac{T(N/2)}{N/2}=\frac{T(N/4)}{N/4}+1
N/2T(N/2)=N/4T(N/4)+1
T
(
N
/
4
)
N
/
4
=
T
(
N
/
8
)
N
/
8
+
1
\frac{T(N/4)}{N/4}=\frac{T(N/8)}{N/8}+1
N/4T(N/4)=N/8T(N/8)+1
.
.
.
...
...
T
(
2
)
2
=
T
(
1
)
1
+
1
\frac{T(2)}{2}=\frac{T(1)}{1}+1
2T(2)=1T(1)+1
将这些式子等号左右加和:
T
(
N
)
N
=
T
(
1
)
1
+
k
,
N
=
2
k
\frac{T(N)}{N}=\frac{T(1)}{1}+k,N=2^k
NT(N)=1T(1)+k,N=2k
T
(
N
)
N
=
T
(
1
)
1
+
l
o
g
N
\frac{T(N)}{N}=\frac{T(1)}{1}+logN
NT(N)=1T(1)+logN
因此,我们可以得到:
T
(
N
)
=
N
+
N
l
o
g
N
=
O
(
N
l
o
g
N
)
T(N)=N+NlogN=O(NlogN)
T(N)=N+NlogN=O(NlogN)
可以看到归并排序的时间复杂度为
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN),虽然低于插入排序等,但它很难用于主存排序,由于合并两个序列需要线性附加内存,因此对于重要的内部排序应用而言,大多还是选择快速排序,不过它合并思想可以在外部排序中广泛应用。
6. 快速排序
快速排序是已知的实践中最快的算法,其思想也是分治的递归算法,选中数组中的一个枢纽元p,将数组分为大于p和小于p的两组,然后再递归的处理这两个数组,具体步骤如下图所示:
对于枢纽元的选择,比较简单的方法是选择数组最左端、最右端、中间3个位置上的数的中值作为枢纽元,这种方法叫做三数中值分割法,能够避免预排序。例如输入为:8,1,4,9,6,3,5,2,7,0,则枢纽元选择为median(8,6,0)=6,下图为将数组变为左侧小于枢纽值,右侧大于枢纽值的过程:
//三数中值分割法
ElementType Median3(ElementType A[], int Left, int Right)
{
int Center = ( Left + Right ) / 2;
if(A[Left]>A[Center])
Swap(&A[Left], &A[Center]);
if(A[Left]>A[Right])
Swap(&A[Left], &A[Right]);
if(A[Center]>A[Right])
Swap(&A[Center], &A[Right]);
//调整为为A[Left]<=A[Center]<=A[Right]
Swap(&A[Center], &A[Right-1]);
//将枢纽元存储在A[Right-1]处,A[Right]已经大于枢纽元了,不需要进行操作
return A[Right-1]; //返回枢纽元
}
//递归程序
#define Cutoff (3)
void Qsort(ElementType A[], int Left, int Right)
{
int i, j;
ElementType Pivot; //枢纽元
if(Left + Cutoff <= Right)
{
Pivot = Median3(A, Left, Right); //返回枢纽元素
//初始化i,j的位置,比正确位置多1,不需要考虑特殊情况
i = Left; j = Right - 1;
for( ; ; )
{
while(A[++i]<Pivot) {} //满足小于枢纽元则右移
while(A[--j]>Pivot) {} //满足大于枢纽元则左移
if(i<j)
Swap(&A[i], &A[j]); //不满足且i<j则进行交换
else
break; //i>=j则跳出循环,找到枢纽元
}
Swap(&A[i], &A[Right-1]); //将枢纽元与A[i]交换
Qsort(A, Left, i-1); //递归
Qsort(A, i+1, Right);
}
else
InsertionSort(A+Left, Right-Left+1);
//若不满足Left + 3 <= Right执行插入排序
}
//快速排序的驱动程序
void QuickSort(ElementType A[], int N)
{
Qsort(A, 0, N-1);
}
可以证明的快速排序的平均时间复杂度同样是 O ( N l o g N ) O(NlogN) O(NlogN),在最坏情况下也为 O ( N 2 ) O(N^2) O(N2),但这种最坏情况的出现也是微不足道,因此快速排序仍然是大量数据排序的优先选择。
7. 桶式排序
桶排序是使用一个大小为M的Count数组,Count数组中每一个元素就是一个桶。将桶数组初始化为空,依次读入排序数据,例如入读
A
i
A_i
Ai时
C
o
u
n
t
[
A
i
]
Count[A_i]
Count[Ai]增1,在读入所有数据后,扫描Count数组即可得到排序好的数组。例子,输入数组为5,2,2,7,9,1,3,8,4,10,3,桶排序过程如下:
void BucketSort(int A[], int N, int Max)
{
int i, j;
int *buckets;
if(a==NULL || N<1 || Max<1)
return;
if((buckets==(int *)malloc(Max*sizeof(int)))==NULL)
return;
memset(buckets, 0, Max*sizeof(int));
//桶计数
for(i=0; i<N; i++)
buckets[A[i]]++;
//桶输出
for(i=0,j=0; i<Max; i++)
while((buckets[i]--)>0)
A[j++] = i;
free(buckets;)
}
桶排序的时间复杂度是 O ( N ) O(N) O(N),相比于其他算法的比较机制,桶排序似乎能够提供最少的时间消耗,但是随着桶的增值,这种优势显然会下降。虽然桶排序看起来平凡且用处不大,但对于那些输入只是小的整数情况使用桶排序会比快排更适合。
8. 基数排序
基数排序是也不同于之前的使用比较移动的算法,它是基于多关键字排序的思想来对单关键字进行排序,它其实算是桶排序的升级版。其首先找到数组最大值,然后按照最大值统一数组元素的位数,最后从低到高按照个、十、百、千等位的值大小依次进行排序。例如,输入数组为21,475,32,67,178,386,72,9,49,217,156,基数排序过程如下:
//获取桶个数
int GetMax(int A[], int N)
{
int i,max;
max = A[0];
for(i=1; i<N; i++)
if(A[i]>max)
max = A[i];
return max;
}
//
void CountSort(int A[], int N, int exp)
{
int output[N];
int i;
int buckets[10] = {0}; //阿拉伯数字桶
for(i=0; i<N; i++)
buckets[(A[i]/exp)%10]++; //桶排序,记录出现的次数
for(i=1; i<10; i++)
buckets[i] += buckets[i-1]; //记录总计数
//A[i]对应的位置及总计数减1
for(i=N-1; i>=0; i--)
{
output[buckets[(A[i]/exp)%10]-1] = A[i];
buckets[(A[i]/exp)%10]--;
}
for(i=0; i<N; i++)
A[i] = output[i];
}
//基数排序驱动程序
void radix_sort(int A[], int N)
{
int exp;
int max = GetMax(A, N);
for(exp=1; max/exp>0; exp*=10)
CountSort(A, N, exp);
}
基数排序的时间复杂度可以达到 O ( N ) O(N) O(N),设关键字位数为d,每位有r种取值,那么时间复杂度为 O ( d ( N + r ) ) O(d(N+r)) O(d(N+r)),空间复杂度为 O ( N + r ) O(N+r) O(N+r)。在大数据情况下相当于用空间换时间。
9. 总结
排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 应用范围 |
---|---|---|---|---|---|
插入排序 | O ( N 2 ) O(N^2) O(N2) | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 稳定 | N较小,基本有序 |
希尔排序 | O ( N 4 / 3 ) O(N^{4/3}) O(N4/3) | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | 不稳定 | N较大 |
堆排序 | O ( N l o g N ) O(NlogN) O(NlogN) | O ( N l o g N ) O(NlogN) O(NlogN) | O ( 1 ) O(1) O(1) | 不稳定 | N较大 |
归并排序 | O ( N l o g N ) O(NlogN) O(NlogN) | O ( N l o g N ) O(NlogN) O(NlogN) | O ( N ) O(N) O(N) | 稳定 | N较大,外部排序 |
快速排序 | O ( N l o g N ) O(NlogN) O(NlogN) | O ( N 2 ) O(N^2) O(N2) | O ( l o g N ) O(logN) O(logN) | 不稳定 | 基本都适用 |
桶式排序 | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | O ( M ) O(M) O(M) | 稳定 | 较小的正整数 |
基数排序 | O ( d ( N + r ) ) O(d(N+r)) O(d(N+r)) | O ( d ( N + r ) ) O(d(N+r)) O(d(N+r)) | O ( N + r ) O(N+r) O(N+r) | 稳定 | 长度较短的数 |
本文代码基于数据结构与算法分析(C语言描述)以及skywang12345的数据结构与算法系列博客(https://www.cnblogs.com/skywang12345/p/3603935.html )。如有错误及时更新。