排序的基本概念
1、内部排序
数据元素全部放在内存中的排序
2、外部排序
数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
3、排序的稳定性
假设在待排序的记录序列中,存在着多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,则称该排序是稳定的,否则称为不稳定的。
插入排序
一、直接插入排序
1、基本思想
直接插入排序的基本思想是将待排序的记录按其关键码的大小插入到一个排好序的有序序列中,直到所有的待排序的记录插入到序列中,此时形成一个新的有序序列。例如生活中我们玩扑克牌时,整理牌的过程便可以近似为一个插入排序过程。
以下以用直接插入排序排序一个升序序列为例。
1)首先,我们可以先考虑一个待排序元素的插入过程:记录待排序的元素(后面要挪动覆盖元素,防止找不到),将其与前面的有序序列中的元素对比(从后往前),不断挪动原有序数组的元素直到该待排序元素找到合适位置。
2)接着,对于单个元素我们认为它是有序的,所以我们可以考虑从无序序列的第二个元素开始向前面的有序数组不断插入排序,直到走完整个数组。
2、代码实现
void InsertSort(int* a, int n)
{
assert(a);
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];//[0,end]有序,将end+1的元素往[0,end]里面插入
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
else
{
break;//将在序列中元素前插入和头元素前插入一起考虑
}
}
a[end + 1] = tmp;
}
}
3、特性总结
1)如果元素集合越接近有序的话,则直接插入排序算法的时间效率越高
2)时间复杂度为
考虑最坏的情况,对于一个有着n个元素的集合,则第二个元素需要一次排序,第三个需要两次...第n个元素需要n-1次,利用等差数列求和公式可以得出其时间复杂度。
3)空间复杂度为
4)稳定性:稳定
二、希尔排序(缩小增量排序)
1、基本思想
希尔排序是直接插入排序的一种高效的改进版本,希尔排序是记录按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组,算法便终止。
如下图所示,先选定一个整数gap,将待排序文件中所有记录分为5个组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行直接插入排序,再取新的gap,如图此时数组被分为两组,对这两组数据进行排序。重复上述过程,直到gap为1,此时数组基本有序,这样排序就很快。
在这里我们又涉及到一个问题就是gap的值应该取多少合适呢?其实这是一个很复杂的数学问题 ,gap的取法有很多种,最初Shell提出取gap=[n/2],gap=[gap/2],直到gap=1.后来Knuth提出gap=[gap/3]+1。
下面的代码中的gap就是按照Knuth提出的方式取值的。
2、代码实现
void ShellSort(int* a, int n)
{
assert(a);
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;//加一保证gap能取到1,取到1则保证了插入排序为绝对有序(直接插入排序)
for (int i = 0; i < n - gap; i++)//保证每一个gap下的每组数组都能进行插入排序
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
3、特性总结
1)希尔排序是对直接插入排序的优化
2)当gap>1时进行的排序都是预排序,其目的是让数组更加接近有序。当gap=1时,数组已经接近有序了,这样就很快。对于整体而言,可以达到优化的效果。
3)希尔排序的时间复杂度很难计算,会因为gap的取值不同很难计算。因为我们上述代码实现用的是Knuth提出的方式进行的,所以时间复杂度我们就按照~
来算。
4)空间复杂度:
5)稳定性:不稳定。
选择排序
一、直接选择排序
1、基本思想
每一次从待排序的元素中选出最大的(最小的)元素,存放在序列的起始位置,直到全部的待排序元素排序完成。
初始序列:[5, 3, 8, 4, 2]
1. 第1轮:
- 找到最小值2,与第一个位置5交换
序列变为:[2, 3, 8, 4, 5]
2. 第2轮:
- 在[3, 8, 4, 5]中找到最小值3,由于3已经在正确位置,无需交换
序列保持:[2, 3, 8, 4, 5]
3. 第3轮:
- 在[8, 4, 5]中找到最小值4,与第三个位置的8交换
序列变为:[2, 3, 4, 8, 5]
4. 第4轮:
- 在[8, 5]中找到最小值5,与第四个位置的8交换
序列变为:[2, 3, 4, 5, 8]
排序完成,最终序列:[2, 3, 4, 5, 8]
2、代码实现
这里我们对原来的选择排序稍微做些改动,原来是每一次选出一个数放在序列的起始位置 ,下面的代码原理是利用两个指针(一个序列首指针,一个序列尾指针),每一次遍历数组找出两个数,一个最大一个最小,将最大的放在序列尾,最小的放在序列头。
代码后面需要考虑的是如下情况,在最大的数出现在序列首位时第一遍遍历为如下结果
接着将最小的换到列首如下
再将最大的换到列尾时就出现了问题,此时应该把6放在列尾,但是maxi和begin重合,所以最后还需要一个判断条件防止出现这种情况。
void SelectSort(int* a, int n)
{
assert(a);
int begin = 0;
int end = n - 1;
while (begin <= end)
{
int mini, maxi;
mini = maxi = begin;//找出的是最大数和最小数的坐标
//第一遍遍历找出最小的数
for (int i = begin + 1; i <= end; i++)
{
if (a[mini] > a[i])
{
mini = i;
}
if (a[maxi] < a[i])
{
maxi = i;
}
}
Swap(&a[begin], &a[mini]);
if (begin == maxi)//这里防止begin和maxi重叠的情况
{
maxi = mini;
}
Swap(&a[maxi], &a[end]);
end--;
begin++;
}
}
3、特性总结
1)直接选择排序的效率不是很高,很少用。
2)时间复杂度为
3)空间复杂度
4)稳定性:不稳定
二、堆排序
可以看堆的实现一节
交换排序
基本思想:所谓交换就是根据序列中两个记录值的比较结果来对换这两个记录在序列中的位置,其特点是将键值较大的记录向序列的尾部移动,键值较小的记录向序列的头部移动。
一、 冒泡排序
void BubbleSort(int* a, int n)
{
int i = 0;
int exchange = 0;
int end = n;
while(end>0)
{
for (i = 1; i < end; i++)
{
if (a[i-1] > a[i])
{
Swap(&(a[i-1]), &a[i]);
exchange = 1;
}
}
if (exchange == 0)//如果第一遍冒泡循环没有发生交换,则直接跳出
{
break;
}
end--;//每一个循环冒泡就会把最大的数放在数组最后,然后再对剩余数组进行冒泡
}
}
二、快速排序
1、基本思想
快速排序是Hoare于1962年提出来的一种二叉树结构的交换排序的方法。首先任取待排序元素序列中的某个元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后运用递归左右子序列重复该过程,直到所有的元素都排列在相对应的位置上为止。
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left > right)
return;
int div = PartSort(a, left, right);
QuickSort(a, left, div - 1);//递归
QuickSort(a, div + 1, right);
}
2、不同版本的快速排序
1)hoare版本(左右指针法)
要实现hoare版本的排序,我们首先得完成单趟快速排序,如上图所示,先设置两个指针(左指着和右指针),确定一个基准值(上图选择的是6) ,此时必须让右指针先动。第一次交换后如图
第二次交换后如图
当两指针重叠之后如图
接着将key和重叠处的元素交换,就可以得到两部分,key前的元素全都小于它,key后的元素全都大于它。
注意如果在开始选取最左边元素为key值时,且让左指针先走则会出现下面情况。
int PartSort1(int* a, int begin, int end)//左右指针法
//单趟:1、把比key大的放在右边,比key小的放在左边 2、key的位置就是其排序后的位置不用动
{
int mid = MiddleIndex(a, begin, end);//获取到三位中数
Swap(&a[mid], &a[end]);//将中数和最后一个数进行调换,满足后面的取最后一个数作为key
int key = a[end];//这里注意有个规则,如果选择最右边的数作为key,则一定要让左边的begin先走,这样能保证他们相遇的位置是一个比key大的值,这样和key交换之后才能保证key左边全比其小,右边全比其大
//反之则反过来
int keyindex = end;//记录key的位置,将来要交换
while (begin < end)
{
while (begin<end&&a[begin] <= key)
{
begin++;
}
while (begin<end&&a[end] >= key)
{
end--;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin], &a[keyindex]);//将key换过来
return begin;//返回划分点
}
2)挖坑法
“挖坑法”是指先选择一个数据存放在key中形成一个“坑位”。从序列头开始找,找到的第一个比key大的数就放在之前的“坑位”里,该位置代替原来的“坑位”变成了一个新的坑位,接着从尾部开始找,找到的第一个比key小的数放在之前的“坑位”中,该位置变为新坑。最终begin和end相遇处就是一个坑,把最开始选出的key放在最后这个坑中。
int PartSort2(int* a, int begin, int end)
{
int mid = MiddleIndex(a, begin, end);
Swap(&a[mid], &a[end]);
int key = a[end];//选择最后一个作为坑,且记录坑
while (begin < end)
{
while (begin < end && a[begin] <= key)
{
begin++;
}
a[end] = a[begin];//从begin开始找,找到第一个比key大的数放在之前的坑里,现在位置重新变成坑
while (begin < end && a[end]>=key)
{
end--;
}
a[begin] = a[end];//接着从end找,找到第一个比key小的数放在前坑中
}
a[begin] = key;//最后begin和end相遇处就是一个坑,把key放此坑中。
return begin;
}
3)前后指针法
前后指针法是通过设置两个指针,在待排序序列中选定一个基准值来实现划分的。
初始时,将prev指针指向序列开头,cur指针指向prev指针的后面一个位置,选定8作为基准值。
如果Cur指向的元素小于基准值,并且++Prev后与Cur不重叠,就交换++Prev和Cur处的值。否则只需要Cur++。在上图中,Prev在判断条件里面已经前置加加了且不满足条件,Cur++。
此时Cur指向的值大于基准值,所以Cur++
接着满足判断条件++Prev后与Cur不重叠且Cur指向的元素小于基准值,下将++Prev的值与Cur的值交换后Cur++
接着与之前同样的步骤,不断交换,直到遇到Cur指向的值大于基准点的情况。如下,此时Cur++,遇到结尾
此时再交换++Prev和基准值,这样在基准值左边的值全都小于它,右边的值全都大于它。
int PartSort3(int* a, int begin, int end)
{
int key = a[end];
int prev = begin-1;
int cru = begin;
while (cru<end)
{
if(a[cru] < key&&++prev!=cru)
Swap(&a[prev], &a[cru]);
cru++;
}
Swap(&a[++prev], &a[end]);
return prev;
}
3、快速排序的优化
1)三数取中法选Key
在上面的快速排序选择基准值时,如果key选择最大或者最小其效率会大大降低,下用三位取中来保证选择的key不是最大或最小。
int MiddleIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin>a[mid]])
{
if (a[mid] > a[end])
return mid;
else if (a[end] > a[begin])
return begin;
else
return end;
}
else
{
if (a[end] > a[mid])
return mid;
else if (a[end] < begin)
return begin;
else
return end;
}
}
三数取中法保证了最坏的情况不会出现,所以综合而言快排的复杂度为
2)结合插入排序
当不断递归到小的子区间时,我们可以考虑使用插入排序。例如在数组大小小于10时就可以考虑不用递归了,直接用插入排序更快。
void QuickSort(int* a, int left, int right)
{
assert(a);
if (left > right)
return;
if(right-left+1>10)//快排的优化
{
int div = PartSort3(a, left, right);
QuickSort(a, left, div - 1);//递归
QuickSort(a, div + 1, right);
}
else//当区间小于等于10的时候不再用快排的递归,用插入排序减少整体的递归次数
{
InsertSort(a + left, right - left + 1);
}
}
4、快速排序的非递归实现
1)递归改非递归方法
1、改循环(如斐波那契额列求解),一些简单递归才能改循环
2、利用栈模拟存储数据非递归
2)改非递归意义
1、提高效率(递归建立栈帧还是有消耗的,但对于现代计算机,这个消耗微乎其微可以忽略不计)
2、递归最大的缺点是,如果递归建立的栈帧太深有可能会导致栈溢出,因为系统栈空间一般不大在M级别左右,但是栈存储在堆上,堆是以G级别的容量。
3)实现
递归的本质是不断调用函数,在函数调用的过程中其实就是不断创建栈帧的过程,想要将递归改成非递归也就是模拟这个过程,我们可以利用数据结构中栈的先进后出的特点模拟这个创建栈帧的过程。
- 对原数组进行一次划分,分别将左边的 Record 和 右边的 Record 入栈 stack。
- 判断 stack 是否为空,若是,直接结束;若不是,将栈顶 Record 取出,进行一次划分。
- 判断左边的 Record 长度(这里指 record.right - record.left + 1)大于 1,将左边的 Record 入栈;同理,右边的 Record。
- 循环步骤 2、3。
void QuickSortNonR(int* a, int left, int right)
{
Stack* st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while (!StackEmpty(&st))
{
int begin = StackTop(&st);
StackPop(&st);
int end = StackTop(&st);
StackPop(&st);
int div = PartSort3(a, begin, end);
if (div + 1 < end)
{
StackPush(&st, end);
StackPush(&st, div + 1);
}
if (div - 1 > begin)
{
StackPush(&st, div - 1);
StackPush(&st, begin);
}
}
StackDestroy(&st);
}
4)特性总结
1)快排的综合性能和使用场景都是比较好的。
2)时间复杂度
3)空间复杂度
4)稳定性:不稳定
归并排序
一、递归实现
1、基本思想
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个典型应用。将已有序的子序列合并 ,得到完全有序的序列。即先使每个子序列有序,再使子序列段间有序。
归并排序递归实现,之前的排序可以归类为内排序,下面的排序可以归并为外排序,即对文件数据进行排序
递归思想方法:主要利用合并有序数组的方法,利用递归思想将整个数组不断分成小部分,对小部分不断进行有序数组合并只需要开辟一个数组空间,合并之后把数组再复制上去。时间复杂度O(NlogN)空间复杂度O(N)。
void MergerArr(int *a,int begin1,int end1,int begin2,int end2,int *tmp)
{
int left = begin1;
int right = end2;
int index = begin1;//记录合并数组的下标,方便后面往回拷贝
while (begin1 <= end1 && begin2 <= end2)//两个数组其中一个结束那就结束
{
if (a[begin1] < a[begin2])
tmp[index++] = a[begin1++];
else
tmp[index++] = a[begin2++];
}
//把长的数组的后面接到tmp面
while (begin1 <= end1)
{
tmp[index++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = a[begin2++];
}
//把归并好的数组再拷贝回原来的数组
for (int i = left; i <= right; i++)
{
a[i] = tmp[i];
}
}
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)//递归停止条件
return;
int mid = (left + right) / 2;//取中间的位置
_MergeSort(a, left, mid, tmp);//前半段递归
_MergeSort(a, mid + 1, right, tmp);//后半段递归
MergerArr(a, left, mid, mid + 1, right, tmp);
}
void MergeSort(int* a, int n)
{
assert(a);
//开辟数组
int* tmp = malloc(sizeof(int) * n);
_MergeSort(a, 0, n-1,tmp);//子函数
free(tmp);
}
二、非递归实现
void MergeSortNonR(int* a, int n)
{
assert(a);
int* tmp = malloc(sizeof(int) * n);
int gap = 1;
while(gap<n)
{
for (int i = 0; i < n; i += 2 * gap)//第一次合并
{
int begin1 = i;
int end1 = i + gap - 1;
int begin2=i+gap;
int end2=i+2*gap-1;
//合并时只有第一组有数据
if (begin2 >= n)
{
break;
}
//合并时第二组只有部分数据,对第二组的边界进行修正
if (end2 >= n)
{
end2 = n - 1;
}
MergerArr(a, begin1,end1,begin2,end2, tmp);
}
gap *= 2;
}
free(tmp);
}
三 、特性总结
1、归并的缺点在于需要的空间复杂度,归并排序的思考更多的使解决在磁盘中的外排序问题。
2、时间复杂度
3、空间复杂度
4、稳定性:稳定
非比较排序
一、计数排序
1、思想
统计相同元素出现的次数,根据统计的结果将序列回收到原来的序列中
2、实现
void CountSort(int* a, int n)
{
assert(a);
//需要相对位置先求数组中的最大值和最小值
int min = a[0];
int max = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] > a[0])
max = a[i];
if (a[i] < a[0])
min = a[i];
}
int range = max - min + 1;//记录数组中一共有多少种数字
int* CountArr = malloc(sizeof(int) * range);//开辟空间存放这些数字的个数
memset(CountArr, 0, sizeof(int)*range);//初始化数组全为0
//记录数字的个数
for (int j = 0; j < n; j++)
{
CountArr[a[j]-min]++;//求相对位置
}
//排序
int index = 0;
for (int j = 0; j < range; j++)
{
while (CountArr[j]--)
{
a[index++] = min + j;
}
}
free(CountArr);
}
3、特性总结
1)计数排序在数据范围集中效率很高,但是适用范围及场景有限
2)时间复杂度O(MAX(N,范围))
3)空间复杂度O(范围)