博客内容
- 简要解释每一种排序算法思想,对每种排序的代码进行解读
- 对比复杂度,稳定性,并列表。
- 典型题目分析
时间复杂度下界
-
逆序对:i<j,如果A[i]>A[j],(i,j)即为逆序对,而交换两个相邻元素正好是消去一个逆序对。
-
定理:任意N个不同元素组成的序列平均有N(N-1)/4个逆序对,所以任何以交换相邻两个元素来排序的算法,平均时间复杂度为 O ( N 2 ) O(N^2) O(N2)
冒泡排序
主要思想:两个for循环,一个元素一个元素进行比较,是稳定的。
void Bubble_Sort(ElementType A[], int N) {
for(int P=N-1;P>=0;P--) {
int flag = 0;
for(int i=0;i<P;i++) {
if(A[i]>A[i+1])
Swap(A[i],A[i+1]); //如果没有被执行,则这个序列本来就有序
flag = 1; //标识发生了交换
}
if(flag==0) break;
}
}
最好:开始即有序
最坏:正好是逆序,
T
=
O
(
N
2
)
T=O(N^2)
T=O(N2)
插入排序
不断插入就不断地排序,即确保子序列为有序的
- 从第二位开始遍历,
- 当前数(第一趟是第二位数)与前面的数依次比较,如果前面的数大于当前数,则将这个数放在当前数的位置上,当前数的下标-1,
- 重复以上步骤,直到当前数不大于前面的某一个数为止,这时,将当前数,放到这个位置,1-3步就是保证当前数的前面的数都是有序的,内层循环的目的就是将当前数插入到前面的有序序列里
- 重复以上3步,直到遍历到最后一位数,并将最后一位数插入到合适的位置,插入排序结束。
- 代码
void Insertion_Sort(ElementType A[], int S, int N) {
for(int P=S; P<N; P++) {
int tmp = A[P]; //摸下一张牌
for(int i=P; i>0 && A[i-1]>tmp; i--)
A[i] = A[i-1];
A[i] = tmp; //新牌
}
}
最好:只需要摸牌,
T
=
O
(
N
)
T=O(N)
T=O(N)
最坏:逆序,
T
=
O
(
N
2
)
T=O(N^2)
T=O(N2)
希尔排序
从大到小的间隔,来进行多次排序。每次排序都不会影响上一次排序的结果。
- 定义增量序列,最后一次间隔都是1
- 原始希尔排序算法:
void Shell_sort(ElementType A[], int N) {
for(int D=N/2;D>0;D/=2) { //定义增量序列
for(int P=D;P<N;P++) { //插入排序
ElementType tmp = A[P];
for(int i=P;i>=D&&A[i-D]>tmp; i-=D)
A[i] = A[i-D];
A[i] = tmp;
}
}
}
- 时间复杂度
最坏: T = O ( N 2 ) T=O(N^2) T=O(N2),
当增量序列元素之间并不互质时,原始希尔排序每一次增量排序都没有进行实质性操作,导致只有最后一次有效。例如:16:8,4,2,1。
-
Hibbard增量序列: D k = 2 k − 1 D_k = 2^k-1 Dk=2k−1,此时最坏情况: O ( N ( 5 / 4 ) ) O(N^(5/4)) O(N(5/4))
-
空间复杂度
选择排序
从A[i]到A[N-1]中寻找最小元,并将其位置献给MinPosition。
再将此未排序部分的最小元换到有序部分的最后位置。
- 原始代码:
void Selection_Sort(ElementType A[], int N) {
for(int i=0;i<N;i++) {
int MinPosition = ScanForMin(A,i,N-1);//寻找最小元
Swap(A[i], A[MinPosition])
}
}
- 时间复杂度
最坏:每一次排序都需要交换
不管原始数组是否有序,时间复杂度都是 O ( n 2 ) O(n^2) O(n2),关键是快速找到最小元。
- 空间复杂度
只定义了两个辅助变量且与n的大小无关,所以空间复杂度为O(1)
堆排序
对选择排序的优化
堆:完全二叉树且具有如下性质
- 大顶堆:每个结点值都大于或等于其左右孩子结点的值
- 小顶堆:每个结点值都小于或等于其左右孩子结点的值
左右孩子的结点下标为2i+1,2i+2
具体过程
- 将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
- 将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
- 重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
- 算法1: O ( n l o g n ) O(nlogn) O(nlogn),需要额外的O(N)空间,复制元素消耗时间
void Heap_Sort(ElementType A[], int N) {
BuildHeap(A); //建立最小堆 O(N)
for(int i=0;i<N;i++)
tmpA[i] = DeleteMin(A);//最小元存储 O(logN)
for(int i=0;i<N;i++)
A[i] = tmpA[i];//又将存储的最小元都导回原数组 O(N)
}
- 合理算法::调整为最大堆(升序)
void Heap_Sort(ElementType A[], int N) {
for(int i=N/2; i>=0; i--)
PercDown(A, i, N); //建立最大堆
for(int i=N-1; i>0; i--) {
Swap(&A[0], &A[i]);
PercDown(A, 0, i);
}
}
- 算法中的子方法:
//调整大顶堆
void PercDown(ElementType A[], int i, int N) {
int temp = A[i];//取出当前元素
for(int k=i*2+1; k<N; k=k*2+1) {
if(k+1<N&& A[k]<A[k+1])
k++;//左子结点小于右结点,k指向右子结点
if(A[k]>temp) {//子结点大于父节点,直接赋值给父节点
A[i] = A[k];
i = k;
} else {
break;
}
}
A[i] = temp;
}
//交换
void Swap(ElementType A[], int a, int b) {
int temp = A[a];
A[a] = A[b];
A[b] = temp;
}
归并排序
对有序子序列的归并
- 具体代码
核心交换
/* L:左边起始位置,R:右边起始位置,RightEnd:右边终点位置*/
void Merge(ElementType A[], ElementType tempA[], int L, int R, int RightEnd) {
int LeftEnd = R - 1;
int temp = L;
int NumElements = RightEnd - L + 1;//元素总个数
//归并过程
while(L<=LeftEnd&&R<=RightEnd) {
if(A[L]<=A[R]) tempA[temp++] = A[L++];
else tempA[temp++] = A[R++];
}
//多余就直接拼接
while(L<=LeftEnd)
tempA[temp++] = A[L++];
while(R<=RightEnd)
tempA[temp++] = A[R++];
//L已经被使用过了
for(int i=0;i<NumElements;i++,RightEnd--) {//RightEnd未被使用
A[RightEnd] = tempA[RightEnd];
}
}
分而治之:递归:
T
(
N
/
2
)
+
T
(
N
/
2
)
+
O
(
N
)
,
T
(
N
)
=
O
(
N
l
o
g
N
)
T(N/2)+T(N/2)+O(N),T(N)=O(NlogN)
T(N/2)+T(N/2)+O(N),T(N)=O(NlogN)
void MSort(ElementType A[], ElementType tempA[], int L, int RightEnd) {
int center;
if(L<RightEnd) {
center = (L+RightEnd) /2;
MSort(A,tempA,L,center);
MSort(A,tempA,center+1,RightEnd);
Merge(A,tempA,L,center+1,RightEnd);
}
}
void Merge_sort(ElementType A[], int N) {
ElementType *tempA;
tempA = malloc(N*sizeof(ElementType));//申请空间
if(tempA!=NULL) {
MSort(A,tempA,0,N-1);
free(tempA);
} else {
ERROR("空间不足");//报错方法
}
}
分而治之:非递归算法
并不需要开很多临时数组,只需要开一个临时数组,临时数组与原数组之间进行数据交换。
void Merge_pass(ElementType A[], ElementType tempA[], int N, int length) {
for(int i=0;i<=N-2*length; i+= 2*length)
Merge1(A, tempA, i, i+length, i+2*length-1);//不需要将tempA中元素导回A中,最后有序也是放在tempA中的
if(i+length<N)
Merge1(A,tempA,i,i+length,N-1);
else //最后只剩下一个子列
for(int j=i;j<N;j++) tempA[j] = A[j];
}
void Merge_sort(ElementType A[], int N) {
ElementType *tempA;
tempA = (ElementType *)malloc(N*sizeof(ElementType));//动态分配,<cstdlib>
if(tempA!=NULL) {
while(length<N) {
Merge_pass(A,tempA,N,length);
length *= 2;
Merge_pass(tempA,A,N,length);//确保最后A是有序的
length *= 2;
}
free(tempA);
}
else ERROR("空间不足");
}
优点:时间复杂度、稳定
缺点:空间不足
快速排序
分而治之:从数组中挑选一个数作为主元,将原来的数组分成两部分:小于和大于。最后将左右两边放在一起
- 选中元:取头、中、尾的中位数
/*选中元:*/
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]);
//得到了三个原始位置的小,中,大
Swap(&A[center],&A[Right]);//这个时候利用三个元素的中间值作为基准数
return A[Right];
}
- 子集划分
当元素=pivot时:停下来交换
- 小规模数据处理:先用递归,但是递归会影响性能,所以设置一个阈值cutoff值,当数据小于阈值时停止递归。
运行1000次测试【java】发现,这种快排的方法运行速度大于原始快排的比例只有不到5%,疑惑…
//递归程序
void Quicksort(ElementType A[], int Left, int Right) {
int Cutoff = 3;//自行设置一个值,当间距小于阈值时进行快速排序
if(Cutoff<=Right-Left) {
int Pivot = Median3(A, Left, Right);
int i = Left; int j = Right - 1;
for(;;) {
while(A[j]>=Pivot&&i<j) j--;
while(A[i]<=Pivot&&i<j) i++;
if(i<j)
Swap(&A[i],&A[j]);
else break;
}
Swap(&A[i],&A[Right]);//基准位置交换
Quicksort(A, Left, i-1);
Quicksort(A, i+1, Right);
} else {
//只剩下cutoff元素,传入对应位置的地址即可
Insertion_Sort(A, Left, Right-Left+1);//因为Java中不支持对数组地址的引用操作,而传入的可以只是数组的部分数据。c可以直接对数组进行+Left操作
}
}
//符合标准接口要求主接口
void Quick_Sort(ElementType A[], int N) {
Quicksort(A, 0, N-1);
}
最好:每次都是正好中分
最坏:每次取到的元素都是数组中最值
-
时间复杂度
平均复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) -
空间复杂度
平均空间复杂度 O ( l o g 2 n ) O(log_2n) O(log2n)
表排序
间接排序:只移动指针而不移动元素
环的思想
物理排序:取出一个元素至temp,空位作为华容道。
当table[i]==i
时环结束
最好:初始即有序
最坏:有N/2个环,每个环只有两个元素,每个环都要操作多次
T = O ( m N ) T=O(mN) T=O(mN)
桶排序
建立桶(相同的值放入对应的桶中)
时间复杂度:
T
(
N
,
M
)
=
O
(
M
+
N
)
T(N,M) = O(M+N)
T(N,M)=O(M+N)
基数排序
处理整数基数/多关键字排序
当桶排序中N<<M时,次位优先,比如数字还可以按照十位数、百位数来放入不同的桶中
时间复杂度: T = O ( P ( N + B ) ) T=O(P(N+B)) T=O(P(N+B)),需要收集数
排序算法比较
排序方法 | 平均时间复杂度 | 最坏情况时间复杂度 | 额外空间复杂度 | 稳定性 |
---|---|---|---|---|
简单选择排序* | 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 2 ) O(N^2) O(N2) | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | Y |
插入排序* | O ( N 2 ) O(N^2) O(N2) | O ( N 2 ) O(N^2) O(N2) | O ( 1 ) O(1) O(1) | Y |
希尔排序 | O ( N d ) O(N^d) O(Nd) | 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 2 ) O(N^2) O(N2) | O ( l o g N ) O(logN) O(logN) | 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) | Y |
基数排序 | O ( P ( N + B ) ) O(P(N+B)) O(P(N+B)) | O ( P ( N + B ) ) O(P(N+B)) O(P(N+B)) | O ( N + B ) O(N+B) O(N+B) | Y |
归并排序稳定快速但是需要空间N
注:*
标表示简单排序
练习题
- 关键字序列T=(21,25,49,25*,16,08),写出快速排序算法的一趟实现过程
解答:|08|16|21|25*|49|25
21| |25|49|25*|16|08
21|08|25|49|25*|16|
21|08| |49|25*|16|25
21|08|16|49|25*| |25
21|08|16| |25*|49|25
|08|16|21|25*|49|25