查找与排序
算法可视化网站:算法可视化
代码仓库地址:gitee-查找与排序
一、查找
1.1 顺序查找
1.1.1 顺序查找的基本思想
顺序查找是一种最简单的线性查找方法。其基本思想是:从表的一端开始,顺序扫描线性表,依次将扫描到的关键字和给定值k相比较,若当前扫描到的关键字与k相等,则查找成功;若扫描结束后,仍未找到关键字等于k的记录,则查找失败。
为了提高查找速度,可以对上述算法进行改进,改进的顺序查找的基本思想:设置“哨兵”。哨兵就是待查值,将它放在查找方向的尽头处,免去了在查找过程中每一次比较后都要判断查找位置是否越界,从而提高了查找速度。例如如果查找方向是从左到右,则将待查值放在ST[n]位置;如果查找方向是从右到左,则将待查值放在ST[0]位置。当然前提是最后一个或第一个元素的位置不存放关键字。
1.1.2 代码
typedef int ElemType;
//顺序查找
typedef struct {
ElemType *elem;//数据元素存储空间基址,建表时按实际长度分配,0号单元留空
int TableLen;//表的长度
}SSTable;
//顺序查找
int search_Seq(SSTable ST,ElemType key) {
ST.elem[0]=key;//哨兵
int i;
for(i=ST.TableLen;ST.elem[i]!=key;--i);
return i;//若表中不存在关键字等于key的元素,将查找到i=0时退出for循环
}
1.1.3 复杂度
-
时间复杂度:0(n)
-
空间复杂度:0(1)
1.2 折半查找
1.2.1 基本思想
折半查找----又称为二分查找,这种查找方法需要待查的查找表满足两个条件:首先,查找表必须使用顺序存储结构;其次,查找表必须按关键字大小有序排列。
下面通过一个实际的例子来分析折半查找算法的执行步骤。假设有如下经过排序的数据:3 、12 、31 、42 、54 、59 、69 、77 、90 、97 。待查找关键字为42 。在折半查找过程如下:
(1) 取中间数据项mid 与待查找关键字42 对比, mid 项的值大千42 。因此, 42 应该在数据的前半部分。
(2) 取前半部分的中间数据项mid 与待查找关键字42 对比, mid 项的值小于42 。因此,42 应该在数据的后半部分。
(3) 取后半部分的中间数据项mid 与待查找关键字42 对比, mid 项的值小于42 。因此,42 应该在数据的后半部分。
(4) 最后数据仅剩一项,将其作为mid 与待查找关键字42 对比,正好相等,表示查找到该数据。这样,经过4 次比较便查找到42 所在的位置.
1.2.2 代码
//折半查找
int search_Bin(SSTable ST,ElemType key) {
int low=1,high=ST.TableLen,mid;
while(low<=high) {
mid=(low+high)/2;//取中间位置
if(key<ST.elem[mid]) {//若key小于中间值,则在左区间
high=mid-1;
} else if(key>ST.elem[mid]) {//若key大于中间值,则在右区间
low=mid+1;
} else {//key==ST.elem[mid] 查找成功
return mid;
}
}
return 0;
}
1.2.3 复杂度
-
时间复杂度:O(nlog2n)
-
空间复杂度:0(1)
二、排序
2.1 插入排序
插入排序的思想是:将待排序序列分成两个序列,前面的序列保持有序,依次选取后面的序列的元素,在前面的序列中进行插入。
初始时,有序序列的长度为1。
2.1.1 直接插入排序
2.1.1.1 算法步骤
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。
(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
这时我们可以构造一个比原始数组长度多1的数组,然后手动把A[0]位置的元素置为空,这样我们可以让A[0]位置充当哨兵。而输出时我们不让A[0]输出即可。
2.1.1.2 图解
2.1.1.3 代码
//直接插入排序
void InsertSort(int A[], int n) {
int i, j;
for (i = 1; i < n; i++) {//依次将A[2]~A[n]插入到前面已排序序列
if (A[i] < A[i - 1]) {//若A[i]小于其前驱,需将A[i]插入有序表
A[0] = A[i];//复制为哨兵,A[0]不存放元素
for (j = i - 1; A[j] > A[0] && j >= 0; --j) {//从后往前查找待插入位置
A[j + 1] = A[j];//元素后移
}
A[j + 1] = A[0];//复制到插入位置
}
}
}
2.1.1.4 复杂度
-
时间复杂度
最好情况就是全部有序,此时只需遍历一次,最好的时间复杂度为O(n)
最坏情况全部反序,内层每次遍历已排序部分,最坏时间复杂度为O(n^2)综上,因此直接插入排序的平均时间复杂度为O(n^2)
-
空间复杂度
辅助空间是常量
平均的空间复杂度为:O(1)
2.1.1.5 算法稳定性
相同元素的前后顺序是否改变
插入到比它大的数前面,所以直接插入排序是稳定的
2.1.2 折半插入排序
折半插入排序是对直接插入排序的一种改良方式,在直接插入排序中,每次向已排序序列中插入元素时,都要去寻找插入元素的合适位置,但是这个过程是从已排序序列的最后开始逐一去比较大小的,这其实很是浪费,因为每比较一次紧接着就是元素的移动。
折半排序就是通过折半的方式去找到合适的位置,然后一次性进行移动,为插入的元素腾出位置。
什么是折半的方式去找合适的位置呢,那就是折半查找了,因为再已排序的序列中,序列元素都是按照顺序排列的,既然这样,完全不需要逐一去比较大小,而是去比较已排序序列的中位数,这个中间的位置将一排序列分为左右两部分,通过一次比较后,就缩小了比较的范围,重复这样的操作,需要插入的元素就找到了合适的位置了。
2.1.2.1 算法步骤(图解)
二分法插入排序是在插入第i个元素时,对前面的0~i-1元素进行折半,先跟他们中间的那个元素比,如果小,则对前半再进行折半,否则对后半进行折半,直到left>right,然后再把第i个元素前1位与目标位置之间的所有元素后移,再把第i个元素放在目标位置上。
当然这个也可以使用A[0]哨兵
2.1.2.2 代码
//折半插入排序
void BInsertSort(int A[],int n) {
int i,j,low,high,mid;
for(i=2;i<=n;i++) {
A[0]=A[i];
low=1;high=i-1;//设置折半查找的范围
while(low<=high) {//折半查找(默认递增有序)
mid=(low+high)/2;//取中间点
if(A[mid]>A[0]) {//插入点在低半区(左)
high=mid-1;
} else {//插入点在高半区(右)
low=mid+1;
}
}
for(j=i-1;j>=high+1;--j) {
A[j+1]=A[j];//统一后移元素,空出插入位置
}
A[high+1]=A[0];//插入操作
}
}
2.1.1.3 复杂度
-
时间复杂度:时间复杂度为O(n^2)
但由于引用了二分的思想,它的平均性能会比直接插入好,他的平均比较次数是降低了的,它所需的排序码比较次数与待排序对象序列的初始排列无关,仅依赖于对象个数。在插入第i个对象时,需要经过log2i+1次排序码比较,才能确定它应插入的位置。 将n个对象用折半插入排序所进行的排序码比较次数比较次数(KCN):∑n−1(log2i+1)≈nlog2n
-
空间复杂度:O(1)
2.1.1.4 稳定性
折半插入排序也是稳定的
2.1.3 希尔排序
希尔排序(Shell Sort)是插入排序的一种,它是针对直接插入排序算法的改进。
希尔排序缩小增量排序。
它通过比较相距一定间隔的元素来进行,各趟比较所用的距离随着算法的进行而减小,直到只比较相距元素的最后一趟排序轨迹。
希尔排序对插入排序进行改进,希望通过每次交换间隔一定距离的元素,达到排序效率上的提升
基本原理是:
将待排序的一组元素按一定的间隔分为若干个序列,分别进行插入排序。
开始时设置的间隔比较大,在每轮排序中将间隔逐步减小,直到间隔为1,也就是最后一步是简单插入排序
2.1.3.1 算法步骤(图解)
(1)初始增量第一趟间隙 = 长度/2 = 4
(2)趟第二,增量缩小为2
(3)第三趟,增量缩小为1,得到最终排序结果
2.1.3.2 代码
注意:这里A[0]只是暂存单元,不是哨兵
//希尔排序
void ShellSort(int A[],int n) {
//A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
int i,j,d;
d=n/2;
while(d>=1) {//步长变化,最后一步d=1
for(i=d;i<=n;i++) {
if(A[i]<A[i-d]) {//需将A[i]插入有序增量子表
A[0]=A[i];//暂存在A[0]
for(j=i-d;j>=0&&A[j]>A[0];j-=d) {//通过循环,找到要插入的位置
A[j+d]=A[j];//记录后移
}
A[j+d]=A[0];//插入
}
}
d=d/2;
}
}
2.1.3.3 复杂度
- 时间复杂度为O(n^(1.3-2))
- 空间复杂度为常阶O(1)
2.1.3.4 稳定性
和直接插入排序不同的是,希尔排序不是稳定的排序
选取不同增量进行排序时,可能导致数值相同的两个元素发生相对位置上的改变
2.2 交换排序
2.2.1 冒泡排序
2.2.1.1 算法步骤
比较相邻的元素。如果第一个比第二个大,就交换他们两个。
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
2.2.1.2 图解
2.2.1.3 代码
//冒泡排序
void BubbleSort(int A[],int n) {
int i,j;
for(i=0;i<n-1;i++) {
bool flag=false;//表示本趟冒泡是否发生交换的标志
for(j=n-1;j>i;j--) {//一趟冒泡过程
if(A[j-1]>A[j]) {//若为逆序
A[0]=A[j];//交换,这里A[0]只是暂存单元,不是哨兵
A[j]=A[j+1];
A[j+1]=A[0];
flag=true;
}
}
if(flag==false) {
return;//本趟遍历后没有发生交换,说明表已经有序
}
}
}
2.2.1.4 复杂度
- 时间复杂度:
最好的情况是本身就是有序的,比较的次数就是n-1
次,没有数据的交换,时间复杂度为O(N)
最坏的情况是本身是逆序的,比较的次数就是∑(i-1)= n(n-1)/2,i从0到n-1
,时间复杂度为O(N^2)
所以,平均就是O(N^2)
- 空间复杂度:O(1)
2.2.1.5 稳定性
冒泡排序是针对相邻的元素且存在相对大小时才交换元素位置
对于大小相等的相邻元素,不会交换两者位置
因此,可以发现,两个大小相同的元素在排序过程中会相互靠近,一旦这两个大小相同的元素在排序过程中处于相邻位置,那么后续的排序操作就不会对该元素进行任何相对位置的改变。
所以冒泡排序是稳定的。
2.2.2 快速排序
快速是排序通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序
2.2.2.1 算法步骤
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
2.2.2.2 图解
2.2.2.3 代码
//快速排序
int Partition(int A[],int low,int high) {//一趟划分
int pivot=A[low];//将当前表中第一个元素设为枢轴值,对表进行划分
while(low<high) {//循环跳出条件
while(low<high&&A[high]>=pivot) {
--high;//从右向左扫描,找第一个关键字小于pivot的元素
}
A[low]=A[high];//找到这种元素A[high],将A[high]放到左边
while(low<high&&A[low]<=pivot) {
++low;//从左向右扫描,找第一个关键字大于pivot的元素
}
A[high]=A[low];//找到这种元素A[low],将A[low]放到右边
}
A[low]=pivot;//循环跳出时low=high,将枢轴元素放到该位置
return low;//返回存放枢轴的最终位置
}
void QSort(int A[],int low,int high) {//递归实现
int pivot;
if(low<high) {//长度大于1
pivot=Partition(A,low,high);//一趟划分
QSort(A,low,pivot-1);//对左区间递归排序
QSort(A,pivot+1,high);//对右区间递归排序
}
}
void QuickSort(int A[],int n) {
QSort(A,0,n-1);
}
2.2.2.4 复杂度
- 时间复杂度
快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。
但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小
所以,时间复杂度是O(nlogn)
-
空间复杂度:
最优的情况下空间复杂度为:O(logn) ;每一次都平分数组的情况
最差的情况下空间复杂度为:O( n ) ;退化为冒泡排序的情况
所以,平均空间复杂度就是O(logn)
2.2.2.5 稳定性
快速排序是不稳定的
2.3 选择排序
2.3.1 简单选择排序
通过n-i
次关键字之间的比较,从n-i+1
个记录中选出关键字最小的记录,并和第i个记录作交换
2.3.1.1 算法步骤
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
重复第二步,直到所有元素均排序完毕。
2.3.1.2 图解
2.3.1.3 代码
//简单选择排序
void SelectSort(int A[],int n) {
for(int i=0;i<n-1;i++) {//执行n-1趟操作
int min=i;//记录最小元素位置
for(int j=i+1;j<n;j++) {//在A[i+1]~A[n-1]中选择最小的元素
if(A[j]<A[min]) {//若A[j]比当前最小元素小
min=j;//更新最小元素位置
}
}
if(min!=i) {//若min不等于i,说明找到最小元素,交换
A[0]=A[i];//这里A[0]只是暂存单元,不是哨兵
A[i]=A[min];
A[min]=A[0];
}
}
}
2.3.1.4 复杂度
- 时间复杂度
无论最好或最坏的情况下,其比较次数都是一样多的
第i趟需要进行n-i次关键字的比较,总共需要比较∑(n-i)=n(n-1)/2次,i从1到n-1
而对于交换次数来说,当最好的时候,不需要进行数据的交换
而当逆序的时候,交换次数为n-1次
最终的排序时间是比较与交换的次数总和
因此,总的时间复杂度为O(N^2)
- 空间复杂度:O(1)
2.3.1.5 稳定性
相同关键字的记录在经过排序之后相对次序发生改变,因此简单选择排序是不稳定的
2.3.2 堆排序
利用最大堆(对应升序)或者最小堆(对应降序)输出堆顶元素,即最大值(或最小值),
然后将剩下元素重新生成最大堆(或者最小堆),继续输出堆顶元素,重复此过程直到全部元素都已输出即可得到有序序列
2.3.2.1 算法步骤
- 创建一个堆 H[0……n-1];
- 把堆首(最大值)和堆尾互换;
- 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
- 重复步骤 2,直到堆的尺寸为 1。
2.3.2.2 图解
2.3.2.3 代码
//堆排序
void HeapAdjust(int A[],int i,int n) {
//函数HeapAdjust()的功能是:将以A[i]为根结点的子树调整为最大堆
A[0]=A[i];//A[0]暂存子树的根结点
for(int j=2*i;j<=n;j=2*j) {//沿key较大的子结点向下筛选
if(j<n&&A[j]<A[j+1]) {
j++;//j为key较大的子结点的下标
}
if(A[0]>=A[j]) {//筛选结束
break;
}
A[i]=A[j];//将A[j]调整到双亲结点上
i=j;//修改i值,以便继续向下筛选
}
A[i]=A[0];//被筛选结点的值放入最终位置
}
void BildMaxHeap(int A[],int n) {
for(int i=n/2;i>0;i--) {//从i=[i/2]~1,反复调整堆
HeapAdjust(A,i,n);
}
}
void HeapSort(int A[],int n) {
BildMaxHeap(A,n);//初始建堆
for(int i=n;i>1;i--) {//n-1趟的交换和建堆过程
A[0]=A[1];//输出堆顶元素(和堆底元素交换)
A[1]=A[i];
A[i]=A[0];
HeapAdjust(A,1,i-1);//调整,把剩余的i-1个元素整理成堆
}
}
2.3.2.4 复杂度
- 时间复杂度
堆排序的整体主要由两部分组成
1.构建初始堆,时间复杂度为O(N)
2.交换堆顶和末尾元素并对剩余元素重建最大堆,重建堆的时间复杂度为O(NlogN)
它对原始记录的排序状态并不敏感,最好最坏和平均时间复杂度都是O(NlogN)
所以总体来说,堆排序的时间复杂度为O(NlogN)
- 空间复杂度
在空间上,只有一个用来交换的暂存单元,空间复杂度也很好
- 空间复杂度为O(N)的方法
额外开辟一个辅助的数组空间,将堆顶元素逐一放入辅助数组里,最后再把辅助数组的内容赋值回原始的数组
- 空间复杂度为O(1)的方法
这个方法的时间复杂度与前一种相同,都是O(NlogN),但是不需要额外的辅助数组,所以空间复杂度为O(1)
主要的步骤是:
1.先将一个无序的序列生成一个最大堆
2.将堆顶元素与堆的最后一个元素对换位置
3.将剩余的元素重新生成一个最大堆
4.重复2-3步骤,直到堆中只剩一个元素
2.3.2.5 稳定性
由于记录的比较与交换是跳跃式进行,因此堆排序也是不稳定的
2.4 归并排序和基数排序
2.4.1 归并排序
归并排序就是利用归并的思想来实现排序。
原理是:
假设初始序列有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,
然后两两归并,得到n/2(+1)个长度为2(或1)的有序的子序列
再两两归并……
如此重复,知道得到一个长度为n的有序序列为止
这种排序方法称为 2路归并排序
2.4.1.1 算法步骤
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
- 重复步骤 3 直到某一指针达到序列尾;
- 将另一序列剩下的所有元素直接复制到合并序列尾。
2.4.1.2 图解
2.4.1.3 代码
//归并排序
void Merge(int A[],int low,int mid,int high) {
//将有序的A[low~mid]和A[mid+1~high]归并为有序的A[low~high]
int i=low,j=mid+1,k=0;
int *temp=(int *)malloc((high-low+1)*sizeof(int));//申请空间,辅助数组
while(i<=mid&&j<=high) {//两部分中均有元素
if(A[i]<=A[j]) {//将较小的元素复制到temp中
temp[k++]=A[i++];
} else {
temp[k++]=A[j++];
}
}
while(i<=mid) {//若第一个表未检测完,复制
temp[k++]=A[i++];
}
while(j<=high) {//若第二个表未检测完,复制
temp[k++]=A[j++];
}
for(i=low,k=0;i<=high;i++,k++) {//将temp中的元素复制到原数组中
A[i]=temp[k];
}
free(temp);
}
void MSort(int A[],int low,int high) {
int mid;
if(low<high) {
mid=(low+high)/2;//从中间划分两个子序列
MSort(A,low,mid);//对左侧子序列进行递归排序
MSort(A,mid+1,high);//对右侧子序列进行递归排序
Merge(A,low,mid,high);//归并
}
}
void MergeSort(int A[],int n) {
MSort(A,0,n-1);
}
2.4.1.4 复杂度
2路归并排序算法的性能分析如下:
-
空间效率:
Merge()
操作中,辅助空间刚好为n个单元, 所以算法的空间复杂度为0(m). -
时间效率:每趟归并的时间复杂度为O(m), 共需进行⌈logn⌉趟归并,所以算法的时间复杂
为O(nlogn)
2.4.1.5 稳定性
稳定性:由于 Merge()操作不会改变相同关键字记录的相对次序, 所以2路归并排序算法
是一种稳定的排序方法。
2.4.2 基数排序
基数排序:根据键值的每位数字来分配桶
2.4.2.1 算法步骤(次位优先法LSD)
对于给定范围在0-999之间的10个关键字{64,8,216,512,27,729,0,1,343,125}
① 先为最次位关键字建立桶(10个),将关键字按最次位分别放到10个桶中
② 然后将①中得到的序列按十位放到相应的桶里
③ 做一次收集,扫描每一个桶,收集到一个链表中串起来
④ 将③中得到的序列按最主位放到桶中
⑤ 最后做一次收集,这样就得到一个有序的序列了
2.4.2.2 图解
2.4.2.3 代码
//基数排序
int GetDigit(int x,int d) {
int a[]={1,1,10,100};//本实例中的最大数是百位数,所以只要到100就可以了
return (x/a[d])%10;
}
void RadixSort(int A[],int n) {
int C[10];//分配计数器数组
int B[20];//分配临时数组
int i,j,k;//i,j,k为计数器
for(i=0;i<3;i++) {//进行3次排序
for(j=0;j<10;j++) {//每次分配前清空计数器
C[j]=0;//每次分配前清空计数器
}
for(j=0;j<n;j++) {//统计每个桶中的记录数
k=GetDigit(A[j],i);//求出关键码的第i位数字,如:576的第3位是5
C[k]++;//第k个桶右边界索引加一
}
for(j=1;j<10;j++) {//将tmp中的位置依次分配给每个桶
C[j]=C[j]+C[j-1];//C[j]表示第j个桶右边界索引
}
for(j=n-1;j>=0;j--) {//将所有桶中记录依次收集到tmp中
k=GetDigit(A[j],i);//求出关键码的第i位的数字,如:576的第3位是5
B[C[k]-1]=A[j];//放入对应的桶中,C[k]-1是第k个桶的右边界索引
C[k]--;//对应桶的装入数据索引减一
}
for(j=0;j<n;j++) {//将临时数组的内容复制到A中
A[j]=B[j];//将排序好的数据赋值给A
}
}
}
2.4.2.4 复杂度
-
时间复杂度
对N个关键字用R个桶进行基数排序时,其时间复杂度为O(D(N+R ))
其中,D为分配收集的次数,也就是关键字按基数分解后的位数
当记录的个数N与桶的个数R基本是一个数量级时,基数排序可以达到线性复杂度。 -
空间复杂的
基数排序用链表实现的好处:不需要将记录进行物理移动,对于大型记录的排序是有利的
而代价是:需要O(N)额外空间存放指针
所以,空间复杂度为O(N)
2.4.2.5 稳定性
稳定性:对于基数排序算法而言,很重要一点就是按位排序时必须是稳定的。因此,这也保证了基数排序的稳定性。