写在前面
书接上文:【数据结构与算法】排序算法(上)——插入排序与选择排序
文章主要讲解交换排序与归并排序,其中交换排序中包含快排的三种实现方法,与非递归快排的实现逻辑与源码分析。归并排序中讲解了归并排序的思想与非递归排序的设计思路与实现逻辑,深入浅出的学习两大排序算法的思想。
文章目录
一、交换排序
基本思想:
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
1.2、冒泡排序
冒泡排序适于教学中,因为冒泡排序对于初学着来说较为温和。
代码实现:
void bubbleSort(int* arr, int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int num = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = num;
}
}
}
}
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
1.3、快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素
作为基准值
,按照该排序码将待排序集合分割成两子序列
,左子序列
中所有元素均小于基准值
,右子序列
中所有元素均大于基准值
,然后左右子序列重复该过程
,直到所有元素都排列在相应位置上为止。
将区间按照基准值划分为左右两半部分的常见方式有:
- hoare版本
- 挖坑法
- 前后指针版本
3.1、hoare版快速排序
上示动图中就是hoare版的快速排序的一次运行。其主要思想是把key
放到排序完成后的对应位置。并且运行中把大于key
的值放在数组key
值下标的右边,把小于key
的值放在数组key
值下标的左边。所以右边指针找小于key
的值。左边指针找大于key
的值。
之后再分别对左边数组和右边数组进行递归排序,每一次递归排序都可以把一个key
值放到自己所对应的位置中。
不断递归就可以把数组中的每一个数都能正确的回到自己对应的地方中。这样我们的排序就完成了。
需要注意:如果选择左边节点作为key
值,则右指针一定要先走。如果选择右边节点做key
值,则左边指针一定要先走。这样才能确保左右指针相交时左指针左边的值小于key
值,右边指针的值大于key
值。
代码的实现:(默认key
是最左边的值)
void Swap(int* n1, int* n2) {
int tmp = *n1;
*n1 = *n2;
*n2 = tmp;
}
void quickSort1(int* arr, int left,int right) {
if (!(left < right)) {
return;
}
int le = left;
int ri = right;//必须保存左右起始值,因为递归要用
int key = left;
while (left < right) {
//右边找小
while (left < right && (arr[key] <= arr[right]) ) {// 左右指针只要改变,就必须判断指针关系是否满足。
--right;
}
//左边找大
while (left < right && (arr[key] >= arr[left])) {// 左右指针只要改变,就必须判断指针关系是否满足。
++left;
}
Swap(&arr[left], &arr[right]);//之后交换
}
//结束循环代表左右指针相交,再与key交换。
Swap(arr + left,arr+ key);
key = left;
//此时左右数组与key的关系是 [left,key-1] key [key+1,right]
quickSort1(arr, le, key - 1);//左边,只有使用原左值作为左节点起点才不会多次返回数组的起始点初进行递归,可以提高效率
quickSort1(arr, key + 1, ri);//右边
}
- 左右指针只要改变,就必须判断右指针是否已经满足大于左指针。所以在每次左右指针进行循环找值时,我们都需要判断左右指针的关系。
- 在每次递归中,我们可以画出逻辑草图来方便完善我们代码。
- 在上面逻辑草图中我们可以轻易看出当进入函数后左右指针已经相交时,说明本次递归只剩下一个元素,即说明不用继续递归,返回即可。而且左右指针都必须指向左数组的开始与结尾和右数组的开始和结尾。
- 所以我们在每次递归传值中需要格外注意。其实起始地址与结尾的地址的指针传递位置。
为什么左右指针相交时的值比key小?
当左右指针相交时,意味着所有左侧的元素都已经小于或等于key,而右侧的元素都大于或等于key。此时,左指针指向的位置就是key应该放置的位置。
关键的原因在于指针的比较和交换规则:
- 在移动左指针时,它总是停在比key大的位置(arr[left] > arr[key])。
- 在移动右指针时,它总是停在比key小的位置(arr[right] < arr[key])。
因此,交换后,每次left指针都会前进到一个比key大的位置,而right指针会后退到一个比key小的位置。
3.2、挖坑法快速排序
其实挖坑法并没有本质与Hoare法有差别,知识挖坑法的创作者认为Hoare法比较难于理解,然后他就设计了挖坑法。
与Hoare法的同与异:
- 相同点:
- 一开始把key的值保存起来之后。把key设为第一个坑位,还是先走右指针只要找到
key
值小的数则直接放在坑位,并把坑位重新定义为该下标位置 - 也是与Hoare法相同之后再走左指针。左指针找到比
key
的值大之后把其下标的值存放入坑位,之后再进入循环。
- 一开始把key的值保存起来之后。把key设为第一个坑位,还是先走右指针只要找到
- 不同点:
- Hoare法需要先找出两个对应的值后再进行交换,但是挖坑法只需要每次找到后放入坑位并重新定义坑位即可。
代码的实现
void quickSort2(int* arr, int left, int right) { //挖坑法
if (!(left < right)) {
return;
}
int le = left;
int ri = right;//必须保存左右起始值
int key = left;
int tmp = arr[key];
int ken = key;
while (left < right) {
while (left < right && (tmp <= arr[right])) {
--right;
}
arr[ken] = arr[right];
ken = right;
while (left < right && (tmp >= arr[left])) {
++left;
}
arr[ken] = arr[left];
ken = left;
//Swap(&arr[left], &arr[right]);
}
arr[ken] = tmp;
//Swap(arr[ken], tmp);
//[left - ken -1] ken [ken +1 right]
quickSort2(arr, le, ken - 1);//左边,只有使用原左值作为左节点起点才不会多次返回数组的起始点初进行递归,可以提高效率
quickSort2(arr, ken + 1, ri);//右边
}
- 其递归的逻辑与Hoare法相同。
3.3、前后指针法快速排序
前后指针法是我们程序员手搓中最常用的快速排序的实现方法。因为其代码逻辑简单,所以便于实现。
前后指针排序法规则:
- 前后指针法每进行一次循环可以完成一个
key
的复位。 - 在进入前后这阵法的循环中无论如何。
cur
都会向前移动,但是prev
必须要在cur
的值小于key
值时候才可以向前。 - 在
cur
的值小于key
值时候,prev
必须要cur
移动前移动 - 在
prev
移动后如果不等于cur
则需要与cur
的值进行交换,如果cur
已经越界,则prev
的值就需要与key
值进行交换。
代码的实现
void quickSort3(int* arr, int left, int right) { //前后指针法
if (left > right) {
return;
}
int prev = left;
int cur = prev + 1;
int keyi = left;
while (cur <= right) {
if (arr[cur] < arr[keyi] && ++prev != cur) {
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(arr + prev, &arr[keyi]);
keyi = prev;
quickSort3(arr, left, keyi - 1);
quickSort3(arr, keyi + 1, cur - 1);
}
代码解析:
if (arr[cur] < arr[keyi] && ++prev != cur)
。在这句代码中可以完成上述规则中描述有关cur
与prev
的所有内容。- 首先对
cur
值与key
值进行比较,此时cur
的值比key
值小,则条件为真,prev
就需要进行++
,在++
后需要与cur
位置进行校准,因为prev
的位置与cur
的位置相同时不需要进行交换,条件表达式为假,不进入交换语句。但是位置不相同时候条件表达式为真,进入交换语句,这样就完美解决问题。 - 如果
cur
值与key
值进行比较,此时cur
的值比key
值大,则条件表达式为假,直接不进行后续处理,那么prev
也不会进行++
。
- 首先对
cur++;
。不管条件判断结果如何cur
都需要++
。只有这样才能把大于key
的值放在key
的右边,把小于key
的值放在key
的左边- 在
cur
越界后,则prev
的值就需要与key
值进行交换。 - 在完成一次
key
的排序后,也是和Hoare排序一样,分为了左边小于key
数组与右边大于key
的数组
之后再分别对左边数组和右边数组进行递归排序,每一次递归排序都可以把一个key
值放到自己所对应的位置中。
1.4、快速排序的优化
我们知道在快速排序中,如果每次能把key
控制在中间,我们数组排序与二叉树相似,这样我们排序的效率才是最快的,如果每次key
值都在数组两端,那每次排序我们都需要对整个数组进行遍历,效率上就比不上每次都对半的效率高。
所以隆重推出了三数取中
4.1、三数取中
所谓三数取中,是指数组最左边的值、数组最右边的值与数组中间的值,取出三个值中的中间的元素后与数组
left
的值进行交换,使得中间值作为key
。
三数取中优化效果的具体说明:
-
避免最坏情况:
- 最坏情况: 如果我们总是选择数组的第一个元素或最后一个元素作为基准,那么当数组是已经有序或反向有序时,快速排序的时间复杂度会退化到
O(n^2)
,因为每次分割的子数组非常不均匀。 - 三值取中优化: 通过选择三者中位数作为基准,可以有效地减少出现最坏情况的概率,确保基准的选择更加随机,从而提高快速排序的性能。在平均情况下,分割出来的子数组会更均匀,时间复杂度接近O(n*logn)。
- 最坏情况: 如果我们总是选择数组的第一个元素或最后一个元素作为基准,那么当数组是已经有序或反向有序时,快速排序的时间复杂度会退化到
-
优化的实现:
- 在三值取中优化中,我们选择三个值:
arr[left]
、arr[mid]
和arr[right]
。这三个值的中位数作为基准,可以避免选择最小值或最大值作为基准,减少了在排序过程中不必要的极端情况。 - 例如,假设数组是反向有序的,如果我们总是选择
arr[0]
作为基准,排序时每次都将一个非常小的部分分到左侧,大部分数据分到右侧,导致分割极不均匀。而通过选择三值中的中位数,key
值相对较平衡,从而更有可能将数据分割为较均匀的两部分。
- 在三值取中优化中,我们选择三个值:
-
三值取中方法的优化效果表现在:
- 通过避免最坏情况的发生,保证快速排序大多数情况下能表现出O(n*logn)的时间复杂度。
- 对于大量已经部分排序的数组,三值取中优化比简单地选择数组的第一个或最后一个元素作为基准要好得多。
-
优化效果的局限性:
- 尽管三值取中优化能有效提高快速排序的平均性能,但它并不能保证在所有情况下都优于其他基准选择方法。例如,对于一些特殊的分布,其他选择基准的方法可能更有效。
- 此外,三值取中虽然有助于减少最坏情况的概率,但它并不能完全消除最坏情况。例如,在已经基本有序的数组中,即使使用三值取中,仍然可能出现退化情况,只是概率降低了。
代码的实现
int getMidNumi(int* arr, int left, int right) {
// 如果只有一个元素,直接返回
if (left == right) return left;
int mid = (left + right) / 2; // 计算中间位置
// 如果左端元素大于右端元素,交换它们的位置,使得arr[left] <= arr[right]
if (arr[left] > arr[right]) {
// 如果右端元素更小,则返回右端的索引
if (arr[right] > arr[mid]) {
return right;
}
// 如果中间元素更小,则返回中间元素的索引
else if (arr[left] > arr[mid]) {
return left;
}
// 如果左端元素更小,则返回中间元素的索引
else {
return mid;
}
} else {//说明arr[left] < arr[right]
// 如果左端元素更大,则返回左端的索引
if (arr[left] > arr[mid]) {
return left;
}
// 如果右端元素更大,则返回右端的索引
else if (arr[right] > arr[mid]) {
return right;
}
// 如果中间元素更大,则返回中间元素的索引
else {
return mid;
}
}
}
三值取中优化的核心目标:
是通过选择一个更合适的基准值来避免快速排序在一些特殊情况下退化为O(n^2)
的最坏时间复杂度。它通过增加分割的随机性来优化排序性能,使得快速排序在平均情况下更加稳定,通常能达到O(n log n)
的时间复杂度。但它的优化效果是相对的,不能保证在所有情况下都比其他方法更好。
4.2、递归的优化
在我们快排中,最理想的状态就是每次都二分,如同二叉树一样。
我们在二叉树的那个章节中已经熟知在最后一层的结点是之前节点的和(h-1层的节点是(1+2+…+h-2)层节点的和。而且在快拍中最后一层递归,它只是一个数字,没有进行排序的必要。相关笔记:【数据结构】树——顺序存储二叉树
在越接近最后一层递归中数组的元素就更接近有序。这时我们就可以在递归到一定程度之后,结束递归的快速排序,转而使用采取插入排序完成最后的排序。
那我们怎么确认它递归到了一定程度呢?
这时候我们只需要判断一下递归的数组中的元素个数,如果元素个数已经小于一定范围之后我们直接采取直接插入排序。
在目前的官方文档中并没有明确的给出小于多少个元素后,我们直接采取直接插入排序,但是不才查看了一些资料,它们的取值范围都在8
到15
之间。所以不才这里选择10
个元素。
代码的实现
void quickSort3(int* arr, int left, int right) { //前后指针法
if (left > right) {
return;
}
int prev = left;
int cur = prev + 1;
int mid = getMidNumi(arr, left, right);//三数取中
Swap(arr + left, arr + mid);
int keyi = left;
while (cur <= right) {
if (arr[cur] < arr[keyi] && ++prev != cur) {
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(arr + prev, &arr[keyi]);
keyi = prev;
if ((right - left) > 10) {//元素个数大于10个,就进入递归排序
quickSort3(arr, left, keyi - 1);
quickSort3(arr, keyi + 1, cur - 1);
}
else {//元素个数小于10个,就进入直接插入排序
int len = right - left + 1;
InsertSrot(arr + left, len);
}
}
- 需要注意的细节是,在直接插入排序中起始地点不再是数组首元素地址,是
left
作为起始下标,所以,在插入排序中,我们需要把arr + left
,只有这样才可以访问到正确的节点。
1.5、快速排序的非递归排序
我们需要把递归改为非递归
递归改为非递归有两种办法:
- 直接改循环。如:斐波那契数列等等
- 使用栈辅助来改循环。栈与队列是不分家的。相关笔记【数据结构】线性表——栈与队列
在快速排序的递归改非递归中,我们需要使用栈来辅助改递归,根据我们上面画的递归草图中可以看出。每一次需要对数组更改的无非只是左边数组与右边数组。这其中也只是数组下标范围的变化。(如下图)
在每一次递归中,都是依靠左右指针来确定我们排序的范围。那么我们就利用栈来保存key
的左右数组范围,这样我们就把递归转变为循环了。
现只需要循环入栈循环出栈。保证每一次入栈与出栈都是一次完整的数组范围即可。
我们画个草图来方便我们实现代码。
这样我们就通过栈来实现了递归的逻辑。
代码的实现 (不才这里使用前后指针法为例)
void quickSort4(int* arr, int left, int right, stack* s1) { //前后指针法的非递归
//一次push2个值,一次pop2个值这样就可以获取到区间
SKpush(s1, right);
SKpush(s1, left);//letf后push,就left先Pop
while (!SKEmpty(s1)) {
int on = SKPop(s1);
int end = SKPop(s1);
int prev = on;
int cur = prev + 1;
int mid = getMidNumi(arr, on, end);//三数取中
Swap(arr + on, arr + mid);
int keyi = on;
while (cur <= end) {
if (arr[cur] < arr[keyi] && ++prev != cur) {
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(arr + prev, &arr[keyi]);
keyi = prev;
//一次push2个值,一次pop2个值这样就可以获取到区间
if (end > (keyi + 1)) {
SKpush(s1, end);
SKpush(s1, keyi + 1);
}
//一次push2个值,一次pop2个值这样就可以获取到区间
if ((keyi - 1) > on) {
SKpush(s1, keyi - 1);
SKpush(s1, on);
}
}
}
- 需要注意的是,我们每次
push
与pop
都需要成双的出现,因为这样才可以确保数组的范围是正确的。 - 我们判断循入栈条件,需要左数组或右数组判断剩下多少个元素个数。如果元素个数少于两个,就不需要再进行排序,所以不再需要入栈。
- 当栈为空的时候,说明数组中所有元素都已经完成了排序。
- 单趟排序的逻辑与之递归是一致的。
二、归并排序
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。先使用
分治法
使得每个子序列
有序,将已有序的子序列两两归并
,得到完全有序的序列。若将两个有序表合并成一个有序表,称为二路归并。
1.1、 分治法的思想
归并排序的基本思想先使用分治法将一个数组分解成两个子数组,并且不断递归地进行,直到子数组分解到每个子数组都只包含一个元素时,将子数组两两归并,因为只有一个元素的数组本身是有序的。
1.2、 归并排序的递归实现
递归逻辑过程:
假设我们有一个数组 arr = [38, 27, 43, 3, 9, 82, 10]
,我们希望对它进行排序。
步骤 1:分解
首先将数组分成两部分:
arr1 = {10, 6, 7, 1}
arr2 = {3, 9, 4, 2}
接着继续递归地将这两部分分割,直到每个子数组只有一个元素。
arr1
分成arr1_1 = {10, 6}
和arr1_2 = {7, 1}
arr2
分成arr2_1 = {3, 9}
和arr2_2 = {4, 2}
继续分解,直到每个子数组只包含一个元素:
arr1_1
分成arr1_1_1 = {10}
和arr1_1_2 = {6}
arr1_2
分成arr1_2_1 = {7}
和arr1_2_2 = {1}
arr2_1
分成arr2_1_1 = {3}
和arr2_1_2 = {9}
arr2_2
分成arr2_2_1 = {4}
和arr2_2_2 = {2}
步骤 2:合并排序
然后开始将这些小的已排序的数组合并:
- 合并
arr1_1_1 = {10}
和arr1_1_2 = {6}
:我们比较10
和6
,较小的6
先放入新数组,得到{6, 10}
。 - 合并
arr1_2_1 = {7}
和arr1_2_2 = {1}
:我们比较7
和1
,较小的1
先放入新数组,得到{1, 7}
。 - 合并
arr2_1_1 = {3}
和arr2_1_2 = {9}
:我们比较3
和9
,较小的3
先放入新数组,得到{3, 9}
。 - 合并
arr2_2_1 = {4}
和arr2_2_2 = {2}
:我们比较4
和2
,较小的2
先放入新数组,得到{2, 4}
。
接下来,我们继续合并更大的子数组:
- 合并
arr1_1 = {6, 10}
和arr1_2 = {1, 7}
:比较元素时,得到{1, 6, 7, 10}
。 - 合并
arr2_1 = {3, 9}
和arr2_2 = {2, 4}
:比较元素时,得到{2, 3, 4, 9}
。
最终,合并整个数组:
- 合并
{1, 6, 7, 10}
和{2, 3, 4, 9}
:通过比较每个元素,得到最终排序的数组{1, 2, 3, 4, 6, 7, 9, 10}
。
即下图的逻辑:
代码的实现
void _mergeSort(int* arr, int left, int right, int* n) {
if (left >= right) {
return;
}
int mid = (left + right) / 2;
_mergeSort(arr, left, mid,n);
_mergeSort(arr, mid + 1, right,n);
int i = left;
int on1 = left, end1 = mid;
int on2 = mid + 1, end2 = right;
while (on1 <= end1 && on2 <= end2) {
if (arr[on1] < arr[on2]) {
n[i++] = arr[on1++];
}
else {
n[i++] = arr[on2++];
}
}
while (on1 <= end1) {
n[i++] = arr[on1++];
}
while (on2 <= end2) {
n[i++] = arr[on2++];
}
memcpy(arr + left, n + left, (right - left + 1) * sizeof(int));
}
void mergeSort(int* arr, int n) {
int* arr1 = (int*)malloc(sizeof(int) * n);
if (arr1 == NULL) {
perror("mergeSort malloc::>");
return;
}
int left = 0;
int right = n - 1;
_mergeSort(arr,left,right, arr1);//归并排序
free(arr1);
}
- 上面GIF中,我们也可以很清晰看到我们归并排序的过程。在代码实现上我们还是使用
微元法
来帮助我们设计归并排序的递归 - 归并法主要思想还是把两个有序的数组依此比较后存入到另一个数组中,再把另一个数组的值拷贝回原数组中返回成为一个有序的数组。为了达到有序,我们使用
分治法
来实现子数组元素个数是1
个,在数组元素个数是1
个时,那必然时有序。 - 在成为有序数组后,不再使用
分治法
进行递归,开始归并这两个有序数组。虽然在上逻辑草图中不才是拆分为了不同的数组,但是在程序执行中,只是改变边界范围,并不是的开辟数组。 - 所以我们需要在排序之前先开辟一个与原数组相同大小的数组,用于排序,这样在排序过程中就不会对数据进行覆盖。
- 之后使用归并法进行排序(如下图)
- 之后再把数组的值拷贝到原数组中,需要注意的是,边界回随着递归改变,这时候原数组的起始位就不一定是
0
,是需要+left
才可以到正确的起始边界位。
归并排序的特性总结:
- 归并的缺点在于需要
O(N)
的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。 - 时间复杂度:
O(N*logN)
- 空间复杂度:
O(N)
- 稳定性:稳定
1.3、 归并排序的非递归实现
我们需要把递归改为非递归
递归改为非递归有两种办法:
- 直接改循环。如:斐波那契数列等等
- 使用栈辅助来改循环。栈与队列是不分家的。相关笔记【数据结构】线性表——栈与队列
在快排中我们是需要使用栈来辅助改循环,但是归并排序并不需要使用栈来改循,我们定义一个gap
控制两个子数组的区间范围,通过循环就可以实现归并排序。
我们还是画个草图查看一下如何控制数组的区间范围。
代码的实现
void mergeSort(int* arr, int n) { //非递归的归并排序
int* num = (int*)malloc(sizeof(int) * n);
if (num == NULL) {
perror("mergeSort malloc::>");
return;
}
int left = 0;
int right = n - 1;
int gap = 1;
while (gap < n) {
int i = 0;// 注意i
for (int j = 0; j < n; j += gap * 2) {
int on1 = j, end1 = on1 + gap - 1;
int on2 = on1 + gap, end2 = on2 + gap - 1;
if (on2 > right || end1 > right) {
break;
}
if (end2 > right) {
end2 = right;
}
//printf("[%d %d][%d %d] j=%d ", on1, end1, on2, end2, j);
while (on1 <= end1 && on2 <= end2) {
if (arr[on1] < arr[on2]) {
num[i++] = arr[on1++];
}
else {
num[i++] = arr[on2++];
}
}
while (on1 <= end1) {
num[i++] = arr[on1++];
}
while (on2 <= end2) {
num[i++] = arr[on2++];
}
memcpy(arr + j, num + j, sizeof(int) * (end2 - j + 1));
}
//printf("\n");
gap *= 2;
}
free(num);
}
代码实现过程逻辑:
我们还是由内到外的设计程序,首先先完成交换逻辑,交换逻辑与递归逻辑是一致的,但是我们需要使用gap
来调整我们区间的范围。
所以首先设计一个循环,用来确定一趟排序要移动的范围:
void mergeSort(int* arr, int n) {//arr是需要排序的数组,n是数组的长度
int* num = (int*)malloc(sizeof(int) * n);
if (num == NULL) {
perror("mergeSort malloc::>");
return;
}
int gap = 1;
int i = 0;
for (int j = 0; j < n; j += gap * 2) {
int on1 = j, end1 = on1 + gap - 1;
int on2 = on1 + gap, end2 = on2 + gap - 1;
while (on1 <= end1 && on2 <= end2) {
if (arr[on1] < arr[on2]) {
num[i++] = arr[on1++];
}
else {
num[i++] = arr[on2++];
}
}
while (on1 <= end1) {
num[i++] = arr[on1++];
}
while (on2 <= end2) {
num[i++] = arr[on2++];
}
memcpy(arr + j, num + j, sizeof(int) * (end2 - j + 1));
}
}
- 我们先画出分析草图,首先判断每次循环判断的起始点一次需要跳过多少个元素(入下图)在上图中,可以看出
gap
代表的是每次比较的元素个数,我们每次要进行判断的起始位都需要跳过gap*2
这个距离。所以我们把控制循环的j
设置为j+=gap*2
,这样,j
就代表了每次比较的起始位置。 - 根据上图,我们也可以轻松找出
end1
、on2
、end2
与on1
的关系。 - 之后,我们把每一次比较完的结果都直接返回到元素组中。
这样我们就完成了一次gap=1
归并排序。但是我们需要gap值不断增大来完成更多的元素排序。所以我们需要在上面的基础上增加一层循环来控制gap
。
void mergeSort(int* arr, int n) {//arr是需要排序的数组,n是数组的长度
int* num = (int*)malloc(sizeof(int) * n);
if (num == NULL) {
perror("mergeSort malloc::>");
return;
}
int gap = 1;
while (gap < n) {
int i = 0;
for (int j = 0; j < n; j += gap * 2) {
int on1 = j, end1 = on1 + gap - 1;
int on2 = on1 + gap, end2 = on2 + gap - 1;
if (on2 > right || end1 > right) {
break;
}
if (end2 > right) {
end2 = right;
}
while (on1 <= end1 && on2 <= end2) {
if (arr[on1] < arr[on2]) {
num[i++] = arr[on1++];
}
else {
num[i++] = arr[on2++];
}
}
while (on1 <= end1) {
num[i++] = arr[on1++];
}
while (on2 <= end2) {
num[i++] = arr[on2++];
}
memcpy(arr + j, num + j, sizeof(int) * (end2 - j + 1));
}
gap *= 2;
}
}
gap < n
:我们gap
控制必须要小于元素的长度。只有这样,我们来才能确保不会因为gap
的增大导数组越界访问。
在上述代码完成后,我们可以发现运行结果是数组越界访问。为了更快锁定是哪个变量导致子数组越界访问。我们可以设计以下代码。
printf("[%d %d][%d %d] j=%d ", on1, end1, on2, end2, j);
把每次排序运行时的边界节点都进行打印,这样我们就可以快速掌握是哪个边界没控制好导致子数组越界访问。
我们输入一个测试函数int arr[] = { 1,2,7,5,9,8,6,4,10,3};
- 在上图中我们可以看到边界值
on2
、end2
、end1
都会造成子数组越界。 - 为了更好的控制边界,所以我们。判断一下当什么情况时,数组不需要进行排序。什么时候我们需要把节点调整后,进行排序。
if (on2 > right || end1 > right) {
break;
}
if (end2 > right) {
end2 = right;
}
- 这时候我们把边界值管理设为上图代码。这样就可以把任何情况下元素都进行归并排序。(如下图)虽然在第一次归并排序中,并未最后几个元素排序,但是在下一次排序时,最后的元素就包含进来了。
最终我们就的到完整的归并排序
void mergeSort(int* arr, int n) { //非递归的归并排序
int* num = (int*)malloc(sizeof(int) * n);
if (num == NULL) {
perror("mergeSort malloc::>");
return;
}
int left = 0;
int right = n - 1;
int gap = 1;
while (gap < n) {
int i = 0;// 注意i
for (int j = 0; j < n; j += gap * 2) {
int on1 = j, end1 = on1 + gap - 1;
int on2 = on1 + gap, end2 = on2 + gap - 1;
if (on2 > right || end1 > right) {
break;
}
if (end2 > right) {
end2 = right;
}
//printf("[%d %d][%d %d] j=%d ", on1, end1, on2, end2, j);
while (on1 <= end1 && on2 <= end2) {
if (arr[on1] < arr[on2]) {
num[i++] = arr[on1++];
}
else {
num[i++] = arr[on2++];
}
}
while (on1 <= end1) {
num[i++] = arr[on1++];
}
while (on2 <= end2) {
num[i++] = arr[on2++];
}
memcpy(arr + j, num + j, sizeof(int) * (end2 - j + 1));
}
//printf("\n");
gap *= 2;
}
free(num);
}
memcpy(arr + j, num + j, sizeof(int) * (end2 - j + 1));
:这里拷贝必须要使用(end2 - j + 1)
的形式来进行元素的控制,因为on1
是会随着归并排序的进程而改变的。- 而且边界范围会随时调整所以不能使用
gap*2
来确定拷贝范围。 - 这里虽然
left
与right
的用处不大,但是不才个人习惯用left
与right
确认原数组的边界范围。
以上就是本章所有内容。若有勘误请私信不才。万分感激💖💖 如果对大家有帮助的话,就请多多为我点赞收藏吧~~~💖💖
ps:表情包来自网络,侵删🌹