✨✨小新课堂开课了,欢迎欢迎~✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:数据结构与算法
小新的主页:编程版小新-CSDN博客
1.快排
1.1算法思想
选择一个基准元素,将数组分为两部分,一部分小于基准元素,一部分大于基准元素。通过不断对划分的子序列进行同样的操作,递归地对小于基准元素和大于基准元素的两个子区间进行排序,从而实现整个数组的排序。
1.2算法实现
下面我们介绍三种方法
1.2.1 Hoare法
具体步骤:
1.先选择一个基准值keyi,我们一般把最左边的元素作为基准值
2.定义两个变量L和R,R先从右往左遍历找比keyi小的值,L从左往右找比keyi大的值,交换找到的两个值,并重复上面的过程,直到L和R相遇,相遇位置的值与keyi交换,以相遇位置mid为基点划分区间。
3.对[left mid-1]和[mid+1 right]这两个区间进行递归排序。left和right指原数组的其实位置和末尾。
动图演示:
这里是单趟的思想。
小新:这个算法保证了相遇位置一定比keyi小?思考一下这是为什么呢?
先说结论:左边作keyi,让右边向先走, 可以保证相遇位置的值一定比keyi小,相反,右边作keyi,让左边先走,可以保证相遇位置的值一定比keyi大。
相遇有两种情况:1.R遇L 2.L遇R
1.R在没遇见L的前一步,因为上一次交换的原因,此时L下标对应的值是比keyi下标对应的值要小的,然后R遇见L,保证了相遇位置一定比keyi下标对应的值要小。
2.R是从右往左找小,也就是说明了R下标对应的值比keyi下标对应的值要小,L遇见R,保证了相遇位置的值一定比keyi下标对应的值要小。
代码实现:
void Swap(int*a,int*b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void QuickSort(int* a, int left, int right)
{
//当只有一个数据或者区间不存在的时候,返回
if (left >= right)
return;
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
//右边找小
while (begin < end && a[end] >= a[keyi])
{
end--;
}
//左边找大
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
Swap(&a[begin], &a[end]);
}
//相遇位置与key交换
Swap(&a[keyi], &a[begin]);
int mid = begin;
//[left mid-1] mid [mid+1 right]
QuickSort(a, left, mid - 1);
QuickSort(a, mid + 1, right);
}
1.2.2挖坑法
具体步骤:
1.将keyi位置的值记录起来,形成一个坑位。
2.R从右往左找比keyi下标对应的值小的数,找到就将该值放在坑位处,此位置形成新的坑,L从左往右找比keyi下标位置对应的值大的树,找到就把该值放在坑位处,此位置形成新的坑,重复以上过程。
3.R与L相遇,把keyi位置的值放在相遇位置处,相遇位置一定是坑,因为在交换过程中R或L一定有一个是坑。以相遇位置hole为基点,划分区间。
4.对[left hole-1]和[hole+1 right]这两个区间进行递归排序。left和right指原数组的其实位置和末尾。
动图演示:
代码实现:
void Swap(int*a,int*b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
int PartSort2(int* a, int left, int right)
{
int begin = left;
int end = right;
int hole = begin;//起始坑位
int keyi = a[left];
while (begin < end)
{
//右边找小
while (begin < end && a[end] >= keyi)
{
end--;
}
//找到就交换
Swap(&a[hole], &a[end]);
//记录新的坑位
hole = end;
while (begin < end && a[begin] <= keyi)
{
begin++;
}
Swap(&a[hole], &a[begin]);
hole = begin;
}
//将keyi位置的值,放进相遇位置(这个相遇位置也是一个坑)
a[hole] = a[left];
return hole;
//[left hole-1] hole [hole+1 right]
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int hole = PartSort2(a, left, right);
QuickSort(a, left, hole - 1);
QuickSort(a, hole + 1, right);
}
1.2.3前后指针法
具体步骤:
1.我们选最左边的值作为基准值keyi。
2.定义两个指针变量prev和cur,初始时,prev指向数组的起始位置,cur指向prev的下一个位置。cur从左往右找比keyi小的值,找的话prev++,交换prev和cur指向的值,重复此过程。直到cur越界。
3.交换prev指向的值与keyi,以此时prev的位置mid为基点,划分区间。
4.对[left mid -1]和[mid+1 right]这两个区间进行递归排序。left和right指原数组的其实位置和末尾。
动图演示:
代码实现 :
void Swap(int*a,int*b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
int PartSort3(int* a, int left, int right)
{
int mid = GetMid(a, left, right);
Swap(&a[left], &a[mid]);
int prev = left;
int cur = prev + 1;
int keyi = left;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
int midd = prev;
Swap(&a[keyi], &a[midd]);
return midd;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int keyi = PartSort3(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
1.3复杂度分析
时间复杂度:一般需要递归logN层,并且每层都需要遍历,因此时间复杂度为O(NlogN)。
空间复杂度:一般需要递归logN层,并且每层都需要遍历,因此时间复杂度为O(NlogN)。
1.4算法优化
我们考虑一下一个比较特殊的情况,假设该数组有序的情况下,该算法就会退化成一个O(N^2)的算法。
为了避免这种情况,我们可以随机选keyi,保证选到的keyi既不是最大值也不是最小值。即将选到的值与最左边的值交换,选到的值作为我们的基准值。
随机选keyi:
//三数取中(中代表的是left mid right代表的三个数中既不是最大也不是最小的那个数)
int GetMid(int* a, int left, int right)
{
int mid = (left + right) / 2;
//left mid right
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])//a[mid]>a[right]
{
return left;
}
else
{
return right;
}
}
else//a[left]>a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])//a[mid]<a[right]
{
return right;
}
else
{
return left;
}
}
}
int PartSort1(int* a, int left, int right)
{
//避免了有序情况下的栈溢出
int mid = GetMid(a, left, right);
Swap(&a[left], &a[mid]);
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
//右边找小
while (begin < end && a[end] >= a[keyi])
{
end--;
}
//左边找大
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
Swap(&a[begin], &a[end]);
}
int midd = begin;
//相遇位置与key交换
Swap(&a[keyi], &a[midd]);
return midd;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int keyi = PartSort1(a, left, right);//单趟排序
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
如果让个数少的一组数有序,采用递归去实现的代价比较大。为了避免递归的深度太深,我们可以对其进行小区间优化 ,当该组的元素较少时,我们可以直接使用其他的排序,比如插入排序使其有序。
如何理解递归的深度太深呢?
我们在使用快排的时候,递归就类似于二叉树的前序遍历,确定"根"(基点),"左子树"(左区间),"右子树"(右区间)。
前面我们学习过二叉树的知识,我们知道越往下递归的次数就越多。最后一层大约占总调用次数的50%,倒数第二层占总调用次数的25%,倒数第三层占总调用次数的12.5%,也就是说后三层占了总调用次数的87.5%,可想而知小区间优化存在的极大意义。
小区间优化:
//三数取中(中代表的是left mid right代表的三个数中既不是最大也不是最小的那个数)
int GetMid(int* a, int left, int right)
{
int mid = (left + right) / 2;
//left mid right
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])//a[mid]>a[right]
{
return left;
}
else
{
return right;
}
}
else//a[left]>a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] > a[right])//a[mid]<a[right]
{
return right;
}
else
{
return left;
}
}
}
int PartSort1(int* a, int left, int right)
{
//避免了有序情况下的栈溢出
int mid = GetMid(a, left, right);
Swap(&a[left], &a[mid]);
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
//右边找小
while (begin < end && a[end] >= a[keyi])
{
end--;
}
//左边找大
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
Swap(&a[begin], &a[end]);
}
int midd = begin;
//相遇位置与key交换
Swap(&a[keyi], &a[midd]);
return midd;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
{
return;
}
//小区间优化,减少递归调用次数,避免深度太深
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
return;
}
int keyi = PartSort1(a, left, right);//单趟排序
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
1.5非递归实现
递归改非递归,一般有两种方式:1.用栈实现 2.用循环实现。
这里我们用栈来实现,应用栈先进后出的特点,划分左右区间。
具体步骤:
我们先创建一个栈并对其初始化。
先将整个数组的区间入栈,利用该区间找到基点,利用基点划分左右区间。
再将左右区间入栈,重复上面的过程,当栈为空的时候,排序结束。
代码实现:
#include"stack.h"
//非递归实现快排
void QuickSortNonR(int* a, int left, int right)
{
ST st;
STInit(&st);
STPush(&st, right);
STPush(&st, left);
while (!STempty(&st))
{
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
int keyi = PartSort3(a, begin, end);
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
STDestroy(&st);
}
1.6稳定性分析
在排序过程中,划分操作会交换元素的位置,当存在相同的元素时,可能会被分到不同的区间,他们的相对位置就发生了变化,因此快排不稳定。
2.归并排序
2.1算法思想
归并排序是一种分治算法,它的步骤主要包含两个部分:分解和合并。
分解:将待排序的数组从中间分成两个子数组,如果数组只有一个元素或为空,则不需要排序(这是递归的基本条件)
合并:将已经排序好的子数组合并成一个有序数组。通常比较两个子数组的前段元素来决定哪个元素应先放在合并后的数组中。
2.2具体步骤
1.我们先开辟一个与待排数组一样大小的数组tmp。
2.然后进行分解操作,找到中间位置,把待排数组分成两个子数组,在对子数组进行相同的分解操作,直到子数组不存在或子数组只有一个元素为止。分解操作完成。此时每个子数组都是有序的。
3.接下开始合并两个子数组,因为每个子数组都是有序的,我们创建两个指针变量begin1,begin2分别指向两个子数组的开头。遍历比较两个子数组,以升序为例,把他们较小的先插入到tmp数组中中。
4.每归并一次,就把tmp数组中的元素拷贝回原数组中。
2.3动图演示
2.4代码实现
//归并排序
void _MergeSort(int* arr, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
_MergeSort(arr, begin, mid, tmp);
_MergeSort(arr, mid + 1, end,tmp);
//归并
int i = begin;
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] > arr[begin2])
{
tmp[i++] = arr[begin2++];
}
else
{
tmp[i++] = arr[begin1++];
}
}
//如果还剩下一些元素,就将剩下的元素直接放进tmp数组里
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
//将归并排序好的数据拷贝回原数组中
memcpy(arr+begin, tmp+begin, sizeof(int)*(end-begin+1));
}
void MergeSort(int* arr, int n)
{
int* tmp = (int*)calloc(10,sizeof(int));
if (tmp == NULL)
{
perror("calloc fail");
return;
}
_MergeSort(arr, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
2.5算法优化
和快排一样,这里也会有递归深度太深的问题,解决方式也是一样的。
void _MergeSort(int* arr, int begin, int end, int* tmp)
{
if (begin >= end)
return;
if (end-begin+1<10)//小区间优化
{
InsertSort(arr+begin, end-begin+1);
return;
}
int mid = (begin + end) / 2;
_MergeSort(arr, begin, mid, tmp);
_MergeSort(arr, mid + 1, end,tmp);
//归并
int i = begin;
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] > arr[begin2])
{
tmp[i++] = arr[begin2++];
}
else
{
tmp[i++] = arr[begin1++];
}
}
//如果还剩下一些元素,就将剩下的元素直接放进tmp数组里
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
//将归并排序好的数据拷贝回原数组中
memcpy(arr+begin, tmp+begin, sizeof(int)*(end-begin+1));
}
void MergeSort(int* arr, int n)
{
int* tmp = (int*)calloc(10,sizeof(int));
if (tmp == NULL)
{
perror("calloc fail");
return;
}
_MergeSort(arr, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
2.6复杂度分析
时间复杂度:一般需要递归logN层,并且每层都需要遍历,因此时间复杂度为O(NlogN)
空间复杂度:这里因为额外开辟了一个数组,因此空间复杂度为O(N)。
2.7稳定性分析
我们知道归并排序是把待排数组每次都划分为两个子数组,直到子区间不存在或只有一个元素,分解完成,开始合并。如果有两个相同的数,他们在归并的过程中相对位置是不会改变的。因此归并排序是稳定的。
2.8非递归实现
我们已经知道递归改非递归一般采用两种方式,栈或者是循环。
用栈先进后出的特点模拟快排的递归实现是比较容易的,但是归并排序有一个往回返的过程,当我们准备开始合并操作时,栈已经为空了,我们不知道之前子数组的区间,就还要用一个栈去存储该信息,比较麻烦,采用循环的话,是比较简单的。
void MergeSortNonR(int* arr, int n)
{
int* tmp = (int*)calloc(n, sizeof(int));
if (tmp == NULL)
{
perror("calloc fail");
return;
}
int gap = 1;//gap代表每组的数据个数
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)//控制每组归并的起始位置
{
//第一组的区间
int begin1 = i, end1 = i + gap - 1;//每组的数据个数为gap个,区间是左闭右闭->起始位置+个数-1
//第二组的区间
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] > arr[begin2])
{
tmp[j++] = arr[begin2++];
}
else
{
tmp[j++] = arr[begin1++];
}
}
//如果还剩下一些元素,就将剩下的元素直接放进tmp数组里
while (begin1 <= end1)
{
tmp[j++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = arr[begin2++];
}
//将归并排序好的数据拷贝回原数组中
memcpy(arr + i, tmp + i, sizeof(int) * (end2-i+1));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
上面这个代码还存在一些问题,它只适用于排大小为2的次方倍的数组,如果换成其他的数组就会出现越界问题。
代码优化:
void MergeSortNonR(int* arr, int n)
{
int* tmp = (int*)calloc(n, sizeof(int));
if (tmp == NULL)
{
perror("calloc fail");
return;
}
int gap = 1;//gap代表每组的数据个数
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)//控制每组归并的起始位置
{
//第一组的区间
int begin1 = i, end1 = i + gap - 1;//每组的数据个数为gap个,区间是左闭右闭->起始位置+个数-1
//第二组的区间
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int j = i;
if ( end1 >= n || begin2 >= n)
break;
//光标到这里的时候就是在处理第一种越界的情况
if (end2 >= n)
end2 = n-1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] > arr[begin2])
{
tmp[j++] = arr[begin2++];
}
else
{
tmp[j++] = arr[begin1++];
}
}
//如果还剩下一些元素,就将剩下的元素直接放进tmp数组里
while (begin1 <= end1)
{
tmp[j++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = arr[begin2++];
}
//将归并排序好的数据拷贝回原数组中
memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2;
}
}
那我们来看一下为什么要这么改。
我们可以看到这里用三种越界方式:
1.end2越界,其余不越界【0 7】【8 15】
2.begin2 end2越界 【10 11】
3.end1 begin2 end2越界【8 11】【12 15】
我们再来看归并,归并其实没有必要保持每组子数组的元素个数相同才能归并,不相同也能归并,这并不会影响最终的排序效果。
理解了这个就好处理我们的问题了,观察一下可以发现,第二种情况和第三种情况已经不需要归并了,我们直接退出归并过程,开始下一轮归并。
只有第一种情况不好处理,只有end2一个越界,其他的都没有。
这里只需要对end2做一下更改即可,end2=n-1,就变成了【0 7】【8 9】,只是改变了第二个子数组中的元素个数,让两个子数组的元素个数不一样,这样也能归并,并且结果也是对的。
3.计数排序
3.1算法思想
计数排序其实就是统计数组中元素出现的个数,并根据统计的数据,对原数组数据直接覆盖即可。
3.2具体步骤
1.找出待排数组的max和min,即最大值和最小值(为了避免不必要的空间浪费)
2.开辟一个空间大小为max-min+1的数组count,遍历待排数组来计数
3.根据计数信息,对待排数组进行重新排序,
4.要注意的是在对待排数组进行更改的时候,要记得加上min才是原本的值。
3.3动图演示
3.4代码实现
//计数排序
//计数排序分为两步:1.计数 2.排序(排序的时候直接在原数组上覆盖)
void CountSort(int* arr, int n)
{
int min = arr[0];
int max = 0;
for (int i = 1; i < n; i++)
{
if (arr[i] > max)
max = arr[i];
if (arr[i] < min)
min = arr[i];
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc fail");
return;
}
//计数
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;
}
//排序
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
arr[j++] = i + min;
}
}
free(count);
count = NULL;
}
3.5复杂度分析
时间复杂度:我们遍历了待排数组和count数组,因此时间复杂度为O(N+range)。
空间复杂度:额外开辟一个数组count,因此空间复杂度为O(N)。
3.6稳定性分析
显而易见,计数排序不会改变相同值的相对位置,因此基数排序是稳定的。