这几天耗费了大量的时间去理清排序类算法的实现以及复杂度,对排序也算是略懂一二,下面博主就进行详细的介绍算法的思路与实现过程的代码。
这一类算法类似于,有序的整理扑克牌,将牌不断插入从而形成有序的牌堆。
插入类排序与之类似,其主要分为直接插入排序以及衍生出来的希尔排序。
使用最后一个元素与其数组其他元素通过遍历比较,找到需要插入的位置将其插入即可,这里需要注意的是,当元素插入时,插入位置的后面元素需要后移。
显而易见,要实现有序,则先进行第一个数与第二个数进行比较,得到一组元素大小为二的有序数组再与其第三个进行遍历比较,以此迭代来达到整个数组有序的效果。代码如下:
// 插入排序
void InsertSort(int* a, int n)
{
assert(a);
//假如是n个元素,只需要循环n-1次即可
for (int i = 0; i < n - 1; i++)
{
int end = i;
//相当于保存了图中红杠后面的那个元素的大小
int tmp = a[end + 1];
//从后往前遍历,如果tmp小于所遍历的值
//则将当前值向后移
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
//找到插入位置并将红杠后的那个元素插入此位置
a[end + 1] = tmp;
}
}
它的时间复杂度从代码中可以看出嵌套了两个循环,最外为遍历趟数,里面为遍历次数,最后一趟遍历的次数最坏为n-1,倒数第二趟遍历的次数最坏为n-2,依次类推可以得到时间复杂度为O(N^2)。
它的空间复杂度为常数,即O(1)。
它是一种稳定的排序算法,何为稳定?稳定大概的意思就是他的相对位置不变,举个例子,如下。
{1, 2, 5(1), 3, 5(2), 6, 5(3)};
排序后;
{1, 2, 3, 5(1), 5(2), 5(3), 6};
上述数组有三个相同的数字5(括号为他们的相对位置下标),当排序完成之后,他们的相对位置如果不变即为稳定。所以在直接插入排序算法中,可以做到排序后相对位置不变,即具有稳定性。
另外这个算法适用于接近有序的时候使用,时间复杂度会大大降低,假设数组有序,虽然遍历趟数没变,但是每一趟的遍历次数只有一次,所以它适用于有序或者接近有序的时候使用。
这种排序又被称作为缩小增量排序,其实就是对直接插入排序的优化,使数组达到接近有序再使用直接插入排序而达到减小时间复杂度的算法。经过直接插入排序的分析得知,它是一种没有间距的直接遍历比较,即元素元素之间的间距gap为0。如果能增加间距gap,将数组后面小的数据以最快的速度放到前面,数组前面大的数据以最快的速度放到后面,从而达到数组接近有序的效果,此时再使用直接插入排序即可减小时间复杂度。
运用间距gap是为了达到预排序的目的,例如,gap=5,则将元素位置之间相差5的元素分组拿出来进行排序,如上图所示,94、18、26、53、75被单独拿出来进行插入排序。代码的具体实现如下:
void ShellSort(int* a, int n)
{
assert(a);
//gap > 1为预排序
//gap = 1直接插入排序
int gap = n;
while (gap > 1)
{
//经过前人经验推算gap/3是最优预排序
//+1是为了保证最后一次是1
gap = gap / 3 + 1;
//多组并排同时排序
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
这个代码有个比较巧妙的点就是,多组并排,在循环中同时进行多组排序,这样就不需要单独把元素拿出来形成单独的一组进行单独的插入排序。大家好好意会一下。
时间复杂度不太好算,但是经过推导一般是在O(N^1.3 ~ N^2)之间,空间复杂度为O(1),但是它并不稳定,因为gap预排序很容易调换其相对位置。
如上图所示,对大小为n的数组进行遍历,选出最小(最大)的值放入数组的开头(结尾),遍历n-1趟,第一趟遍历次数为n-1,后续第二次为n-2,依次类推可得时间复杂度为O(N^2),空间复杂度为O(1),虽然思路很好理解,但算是也是不稳定的,如下。
void Swap(int* left, int* right)
{
int tmp = *left;
*left = *right;
*right = tmp;
}
// 选择排序
void SelectSort(int* a, int n)
{
assert(a);
int maxi, mini;
int left = 0;
int right = n - 1;
while (left < right)
{
maxi = mini = left;
for (int i = left; i <= right; i++)
{
//找出最大值
if (a[i] > a[maxi])
{
maxi = i;
}
//找出最小值
if (a[i] < a[mini])
{
mini = i;
}
Swap(&a[maxi], &a[right]);
//maxi与left位置重叠,需要修正位置
if (a[mini] > a[left])
{
mini = left;
}
Swap(&a[mini], &a[left]);
}
left++;
right--;
}
}
这个代码与图中稍许不同,代码将最大值和最小值同时找出来并进行交换,但这里需要修正位置,理由如下:
如果不修正位置的话,再次进行Swap函数,又把两个值换了回来不就没有意义了吗?
堆排序是建立在数组已经为大堆或者小堆的基础上进行排序,升序用大堆,降序用小堆。本排序以大堆为例进行升序排序。首先得先了解大堆的向下调整算法。
大堆向下调整算法并不要求根为最大值,但是要求根的左树和右树必须满足大堆的条件,如上图所示,代码如下:
// 大堆向下调整算法
void AdjustDown(int* a, int n, int root)
{
assert(a);
int father = root;
//默认为左孩子为较大值
int child = 2 * father + 1;
while (child < n)
{
//比较左孩子和右孩子
if (child + 1 != n && a[child] < a[child + 1])
{
child++;
}
//比较孩子和父亲
if (a[child] > a[father])
{
Swap(&a[child], &a[father]);
}
else
{
break;
}
//迭代
father = child;
child = 2 * father + 1;
}
}
了解大堆向下调整法,那么我对于一个杂乱的数组怎么样建立大堆呢?,我们要知道叶子结点的父亲的左孩子和右孩子一定是满足大堆向下调整法的条件的,那么我们可以从最后依次往上对数组进行调整即可,如下:
int a[] = { 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 };
//建堆
for (int i = (sizeof(a) / sizeof(a[0]) - 2) / 2; i >= 0; i--)
{
AdjustDown(a, sizeof(a) / sizeof(a[0]), i);
}
建好堆之后,接下来就是堆排序,我们已知大堆的根结点一定是最大值,那么我把最大值放到数组的最末尾,那么除末尾的元素外,根结点的左孩子与右孩子仍满足大堆向下调整法的条件,那么进行调整之后又可以找到最大值,再将它放到末尾,依次循环即可。
// 堆排序
void HeapSort(int* a, int n)
{
assert(a);
int end = n - 1;
while (end)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
因为堆是二叉树,时间复杂度不难得出为O(N*logN),这里logN指的是以2为底,N的对数。空间复杂度为O(1),这也得算法也是不稳定的,大家可以自行举例验证。
这个就简要带过了,双循环,时间复杂度O(N^2),空间复杂度O(1),稳定的算法,但是循环停止的条件比较严苛,只有当数组有序时才会停止,所以冒泡排序基本上都是一直在遍历遍历遍历。它的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
代码如下:
// 冒泡排序
void BubbleSort(int* a, int n)
{
int i = 0;
int j = 0;
for (i = 0; i < n - 1; i++)
{
//exchange判断数组是否有序
int exchange = 0;
for (j = 0; j < n - 1; j++)
{
if (a[j]>a[j + 1])
{
Swap(&a[j], &a[j + 1]);
exchange = 1;
}
}
if (exchange == 0)
{
break;
}
}
}
分治的意思就是将数组分开进行治理(处理)。
如下:
这样就完成了单趟的排序,接下来只需要对key左边的数组和右边的数组重复上述步骤,直到key左右两端的数组有序即可,什么时候有序呢?当key左右两边的元素个数小于等于1时则有序,使用递归创建栈帧完成代码的递归编写,如下:
//单趟排序
int PartSort1(int* a, int left, int right)
{
assert(a);
//取最右边的下标
int keyIndex = right;
while (left < right)
{
//从左向右找大
while (left < right && a[left] <= a[keyIndex])
{
left++;
}
//从右向左找小
while (left < right && a[right] >= a[keyIndex])
{
right--;
}
//交换所找到的比key大和比key小的值
Swap(&a[left], &a[right]);
}
//交换key与right(left)停止的位置
Swap(&a[left], &a[keyIndex]);
return left;
}
void QuickSort(int* a, int left, int right)
{
assert(a);
//停止条件,key左右元素小于等于1时结束
if (left >= right)
{
return;
}
//如果数组小于10时再用快排,可能显得过于复杂
//所以改用直接插入排序
if (right - left + 1 > 10)
{
//类似二叉树前序递归
int div = PartSort3(a, left, right);
QuickSort(a, left, div - 1);
QuickSort(a, div + 1, right);
}
else
{
InsertSort(a + left, right - left + 1);
}
}
通过观察发现,快排的的单趟排序能够把key值(假设key为数组第n大的数)放在整个数组第n大的位置,试想如果能够将key一直处于中位数,即可以变成类似于二叉树的模样,一共遍历logN趟,每一趟遍历N次,时间复杂度为O(N*logN)。如下:
实际中无法保证选key能刚好为中位数,但是我们也不能将key选到最大key值,假设每次都取到最大key值,则遍历的最优趟数从logN转变到最坏的趟数N,则时间复杂度为O(N^2),用三数取中的办法能够达到这一效果,保证不要选到最小或者最小,让有序时变成最优遍历。代码如下:
//三数取中
int GetMidIndex(int* a, int left, int right)
{
int mid = (right + left) / 2;
if (a[right] > a[left])
{
if (a[left] > a[mid])
{
return mid;
}
else if (a[mid] > a[right])
{
return right;
}
else
{
return left;
}
}
else //a[right] < a[left]
{
if (a[right] > a[mid])
{
return right;
}
else if (a[mid] > a[left])
{
return left;
}
else
{
return mid;
}
}
}
//单趟排序
int PartSort1(int* a, int left, int right)
{
assert(a);
int mid = GetMidIndex(a, left, right);
Swap(&a[mid], &a[right]);
int keyIndex = right;
while (left < right)
{
while (left < right && a[left] <= a[keyIndex])
{
left++;
}
while (left < right && a[right] >= a[keyIndex])
{
right--;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyIndex]);
return left;
}
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
{
InsertSort(a + left, right - left + 1);
}
}
将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
void MergeArray(int* a, int begin1, int end1, int begin2, int end2,int* tmp)
{
assert(a && tmp);
//记录left与right的目的
//方便将临时创建的空间tmp拷贝入原数组里头
int left = begin1;
int right = end2;
int tmpi = begin1;
//左数组元素与右数组元素一一比较放入临时空间
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
tmp[tmpi++] = a[begin2++];
else
tmp[tmpi++] = a[begin1++];
}
//右数组先全部放入了临时空间
//再将剩余的左元素放入临时空间
while (begin1 <= end1)
{
tmp[tmpi++] = a[begin1++];
}
//左数组先全部放入了临时空间
//再将剩余的右元素放入临时空间
while (begin2 <= end2)
{
tmp[tmpi++] = 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);
//归并排序指令
MergeArray(a, left, mid, mid + 1, right, tmp);
}
// 归并排序递归实现
void MergeSort(int* a, int n)
{
assert(a);
int left = 0;
int right = n - 1;
//开辟临时空间
int* tmp = (int*)malloc(sizeof(int)*n);
_MergeSort(a, left, right, tmp);
}
时间复杂度O(N*logN),空间复杂度O(N),是一种稳定的排序算法。
归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。(文件的归并处理)
比较简单,也称为鸽巢原理,利用下标表示该值(要加上最小值min)的大小,下标所指内容表示该值出现的次数,代码如下:
// 计数排序
void CountSort(int* a, int n)
{
assert(a);
int max = a[0];
int min = a[0];
//找出最大最小值
for (int i = 0; i < n; i++)
{
if (max < a[i])
{
max = a[i];
}
if (min > a[i])
{
min = a[i];
}
}
//开辟临时空间
int tmpNum = max - min + 1;
int* tmp = (int*)malloc(sizeof(int)*tmpNum);
memset(tmp, 0, sizeof(int)*tmpNum);
//把出现的次数记录在临时空间里
for (int i = 0; i < n; i++)
{
tmp[a[i] - min]++;
}
//排序到原数组
int ai = 0;
for (int i = 0; i < tmpNum; i++)
{
while (tmp[i]--)
{
a[ai++] = i + min;
}
}
free(tmp);
}
时间复杂度:O(MAX(N,范围)),范围即开辟的空间大小,因为如果原数组假设为100,但是最大值为400,最小值为200,那么需开辟400-200+1(201)个空间大于数组N。空间复杂度O(范围),也是一种稳定的排序类算法。
计数排序在数据范围集中时,效率很高,但是适用范围及场景有限(仅适用于整形数组)。