文章目录
🎯引言
在算法设计与实现中,排序算法是最为基础且重要的内容之一。在各种排序算法中,快速排序、归并排序和计数排序各有其独特的优势和应用场景。快速排序以其平均情况下的高效性著称,是很多实际应用中的首选;归并排序则以其稳定性和适用于大规模数据的能力受到广泛关注;而计数排序在特定条件下能够以线性时间完成排序,适用于数据范围较小的整数序列。本文将深入探讨这三种经典排序算法的原理、实现以及它们各自的应用场景,以帮助读者更好地理解并应用这些算法。
👓快速排序、归并排序、计数排序
1.快速排序
1.1快速排序递归实现
快速排序(QuickSort)是一种基于分治法的高效排序算法。其核心思想是通过选择一个基准值(pivot),将待排序的数组分成两个部分,使得基准值左边的元素都小于或等于它,右边的元素都大于或等于它,然后递归地对左右两个部分进行排序,最终达到整个数组有序的效果。
图示:
1.2三种找基准值的函数实现:
1.2.1Hoare版本
Hoare版本的核心思想是通过一对指针从数组的两端向中间移动,逐步将小于基准值的元素移到左侧,大于基准值的元素移到右侧,最后将基准值放置在正确的位置。以下是对这段代码的详细解释:
void Swap(int* a1, int* a2)
{
int temp = *a1;
*a1 = *a2;
*a2 = temp;
}
int _QuickSort1(int* arr, int left, int right)
{
int key = arr[left]; // 基准值设为数组的第一个元素
int start = left + 1; // 左指针从基准值的下一个元素开始
int end = right; // 右指针从数组的最右端开始
while (start <= end)
{
// 从右侧向左扫描,找到第一个小于或等于基准值的元素
while (start <= end && arr[end] > key)
{
end--;
}
// 从左侧向右扫描,找到第一个大于或等于基准值的元素
while (start <= end && arr[start] < key)
{
start++;
}
// 如果左指针未超出右指针,则交换这两个不符合基准值位置的元素
if (start <= end)
{
Swap(&arr[end], &arr[start]);
start++; // 左指针右移,继续扫描
end--; // 右指针左移,继续扫描
}
}
// 最后,将基准值与右指针位置的元素交换
Swap(&arr[left], &arr[end]);
return end; // 返回右指针的位置,这个位置是基准值的最终位置
}
步骤详解
- 选择基准值:
- 基准值 (
key
) 选取为数组的第一个元素arr[left]
。
- 基准值 (
- 初始化指针:
- 左指针
start
初始化为基准值的下一个位置,即left + 1
。 - 右指针
end
初始化为数组的最右端,即right
。
- 左指针
- 扫描并交换元素:
- 通过
while
循环,左右指针同时向中间移动,直到start > end
。在每次循环中:- 右指针向左移动,直到找到一个小于或等于基准值的元素。
- 左指针向右移动,直到找到一个大于或等于基准值的元素。
- 如果左指针未超出右指针,则交换左右指针所在位置的元素,并继续扫描。
- 通过
- 放置基准值:
- 最终,基准值与右指针所在位置的元素交换,这样基准值就被放置在正确的位置。
- 返回分区点:
- 函数返回右指针的位置 (
end
),该位置就是基准值在整个数组中的最终位置。这一位置将用于递归调用快速排序函数,以对左右两部分继续进行排序。
- 函数返回右指针的位置 (
1.2.2挖坑法
挖坑法通过在数组中“挖坑”,依次将基准值两侧的元素填入坑中,直到最终将基准值放入正确的位置。以下是对这段代码的详细解释:
int _QuickSort2(int* arr, int left, int right)
{
int hore = left; // 初始坑的位置,设为数组的第一个元素位置
int key = arr[left]; // 基准值设为数组的第一个元素
while (left < right)
{
// 从右侧向左扫描,寻找第一个小于基准值的元素
while (left < right && arr[right] >= key)
{
right--;
}
// 将找到的元素填入左边的坑中
arr[hore] = arr[right];
hore = right; // 更新坑的位置
// 从左侧向右扫描,寻找第一个大于基准值的元素
while (left < right && arr[left] <= key)
{
left++;
}
// 将找到的元素填入右边的坑中
arr[hore] = arr[left];
hore = left; // 更新坑的位置
}
// 最后将基准值填入最后一个坑中
arr[hore] = key;
return hore; // 返回坑的位置,也就是基准值的最终位置
}
步骤详解
- 初始化:
hore
表示当前“坑”的位置,初始设为left
,即数组的第一个位置。key
为基准值,选取数组的第一个元素arr[left]
。
- 右侧扫描并填坑:
- 通过
while (left < right)
循环不断收缩left
和right
之间的范围。 - 首先,从右向左扫描 (
right--
),寻找第一个小于基准值key
的元素,并将其填入当前“坑” (arr[hore]
) 中,然后将hore
更新为right
。
- 通过
- 左侧扫描并填坑:
- 接下来,从左向右扫描 (
left++
),寻找第一个大于基准值key
的元素,并将其填入当前“坑” (arr[hore]
) 中,然后将hore
更新为left
。
- 接下来,从左向右扫描 (
- 最终基准值归位:
- 当
left
与right
相遇时,最终会形成一个“坑”,这个位置即是基准值的最终位置。将基准值key
放入这个位置。
- 当
- 返回分区点:
- 函数返回
hore
的位置,这个位置就是基准值key
在数组中的正确位置。这个位置将用于递归调用快速排序函数,对左、右两部分继续进行排序。
- 函数返回
关键点分析
- 坑的概念: 挖坑法的核心在于“坑”的概念。通过在数组中找到不满足条件的元素,将它们依次填入当前的坑,最后形成一个新坑,将基准值放入这个坑中。
- 双向扫描: 代码使用双向扫描法,一次从右向左,再一次从左向右,这样能够在每次循环中有效地将不符合条件的元素移到正确的一侧。
- 稳定性: 挖坑法并不保证排序的稳定性,因为相同的元素在分区过程中可能会被移动。
1.2.3Lomuto分区法
Lomuto分区法相较于Hoare分区法更加简洁,通过两个指针的移动和交换,将基准值左边的所有元素移到数组的左侧。以下是对这段代码的详细解释:
int _QuickSort3(int* arr, int left, int right)
{
int prev = left; // 初始化前指针,指向数组的第一个元素
int cur = left + 1; // 初始化当前指针,指向数组的第二个元素
int key = arr[left]; // 基准值设为数组的第一个元素
while (cur <= right)
{
// 如果当前元素小于基准值,将当前元素与前指针指向的元素交换位置
if (arr[cur] < key && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++; // 移动当前指针到下一个位置
}
// 最后将基准值放置在前指针指向的位置上
Swap(&arr[prev], &arr[left]);
return prev; // 返回基准值的最终位置
}
步骤详解
-
初始化:
prev
指向数组的第一个元素 (left
),这是前指针,负责标记已处理区的最后一个位置。cur
指向数组的第二个元素 (left + 1
),这是当前指针,负责扫描数组中剩余的元素。key
为基准值,选取数组的第一个元素arr[left]
。
-
扫描数组:
- 使用
while (cur <= right)
循环遍历数组中从cur
到right
的所有元素。 - 如果
arr[cur] < key
(当前元素小于基准值),则:prev
向右移动一位 (++prev
)。- 如果
prev
不等于cur
,说明当前元素需要交换到前面的部分,则交换arr[cur]
和arr[prev]
的值。
- 如果
arr[cur] >= key
cur++
- 使用
-
交换基准值:
- 遍历结束后,所有小于基准值的元素都被移到数组的左侧。此时,前指针
prev
指向的元素即为最后一个小于基准值的元素。 - 将基准值
arr[left]
与arr[prev]
交换,这样基准值就被放置在正确的位置上。
- 遍历结束后,所有小于基准值的元素都被移到数组的左侧。此时,前指针
-
返回分区点:
- 函数返回
prev
,即基准值的最终位置。这个位置将用于递归调用快速排序函数,对左、右两部分继续进行排序。
- 函数返回
1.3快速排序递归代码
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
{
return;
}
// 找到基准值的位置
int mid = _QuickSort3(arr, left, right);
// 递归排序基准值左侧的子数组
QuickSort(arr, left, mid - 1);
// 递归排序基准值右侧的子数组
QuickSort(arr, mid + 1, right);
}
步骤详解
- 递归结束条件:
- 首先检查
left
是否大于或等于right
。如果是,则表示子数组长度为 1 或无效(left >= right
),此时无需继续排序,直接返回。 - 这个条件也是递归函数的基线条件,确保递归不会无限进行。
- 首先检查
- 分区操作:
- 调用
_QuickSort3(arr, left, right)
,将数组arr
的部分从left
到right
进行分区。这个函数返回基准值在排序后的数组中的最终位置mid
。 - 在
_QuickSort3
中,基准值被放置在了正确的位置上,确保了基准值左边的所有元素都小于等于它,右边的所有元素都大于等于它。
- 调用
- 递归调用:
- 对基准值左侧的子数组(
arr[left...mid-1]
)调用QuickSort
进行递归排序。 - 对基准值右侧的子数组(
arr[mid+1...right]
)调用QuickSort
进行递归排序。
- 对基准值左侧的子数组(
- 递归过程:
- 递归调用继续分解子数组,直到每个子数组的长度为1或0,即
left >= right
。此时递归结束,并逐层返回,最终完成整个数组的排序。
- 递归调用继续分解子数组,直到每个子数组的长度为1或0,即
1.4快速排序非递归实现
实现需要借助栈,可以通过之前我写的实现栈的博客,讲栈的相关代码拷贝过来,其实非递归的本质,是通过栈来模拟递归的实现。
void QuickSortNonR(int* a, int left, int right)
{
// 初始化栈
Stack s1;
StackInit(&s1);
// 首次调用分区函数
int mid = _QuickSort2(a, left, right);
// 将右侧子数组的边界压入栈中
StackPush(&s1, right);
StackPush(&s1, mid + 1);
// 将左侧子数组的边界压入栈中
StackPush(&s1, mid - 1);
StackPush(&s1, left);
// 循环处理栈中的子数组
while (!IsEmpty(&s1))
{
// 从栈中取出子数组的边界
left = StackTop(&s1);
StackPop(&s1);
right = StackTop(&s1);
StackPop(&s1);
// 对当前子数组进行分区
mid = _QuickSort2(a, left, right);
// 如果右侧子数组还有元素,继续处理
if (mid + 1 < right)
{
StackPush(&s1, right);
StackPush(&s1, mid + 1);
}
// 如果左侧子数组还有元素,继续处理
if (mid - 1 > left)
{
StackPush(&s1, mid - 1);
StackPush(&s1, left);
}
}
// 销毁栈,释放资源
StackDestory(&s1);
}
步骤详解
- 初始化栈:
- 首先,使用
StackInit(&s1)
初始化一个栈s1
。这个栈将用来保存子数组的边界,以模拟递归过程。
- 首先,使用
- 初始分区:
- 调用
_QuickSort2(a, left, right)
对整个数组进行初次分区,并得到基准值的位置mid
。
- 调用
- 压栈操作:
- 将初次分区得到的左、右子数组的边界分别压入栈中:
- 首先压入右子数组的边界
[mid + 1, right]
。 - 然后压入左子数组的边界
[left, mid - 1]
。
- 首先压入右子数组的边界
- 栈中保存的顺序是右子数组在栈顶,左子数组在栈底,这样确保在非递归的过程中先处理左子数组。
- 将初次分区得到的左、右子数组的边界分别压入栈中:
- 循环处理栈中的子数组:
- 在
while (!IsEmpty(&s1))
循环中,不断从栈中取出子数组的边界进行处理。 - 每次从栈中取出边界
[left, right]
,然后对该范围内的数组进行分区。
- 在
- 判断并压栈:
- 分区后,如果右子数组
[mid + 1, right]
还有元素,则将它的边界压入栈中,等待后续处理。 - 同样地,如果左子数组
[left, mid - 1]
还有元素,也将它的边界压入栈中。 - 通过这种方式,逐步处理整个数组,最终实现排序。
- 分区后,如果右子数组
- 栈的销毁:
- 当所有子数组都处理完毕后,栈为空,退出循环,最后销毁栈
StackDestory(&s1)
,释放资源。
- 当所有子数组都处理完毕后,栈为空,退出循环,最后销毁栈
关键点分析
- 非递归实现: 通过使用栈来保存待处理的子数组边界,避免了递归调用,从而实现了快速排序的非递归版本。这对于避免递归过深带来的栈溢出问题非常有用。
- 栈的使用: 栈的使用模拟了递归的过程,使得算法仍然遵循“分治法”的思想,逐步分解问题并解决。
2.归并排序
2.1归并排序递归实现
图解:
归并排序(Merge Sort)是一种基于分治法的排序算法,通过将数组分解成更小的部分,对每部分进行排序,然后合并排序结果,最终实现整个数组的排序。下面是对提供的归并排序代码的详细解析:
void _MergeSort(int* a, int* temp, int left, int right)
{
// 递归终止条件
if (left >= right)
{
return;
}
// 计算中间位置
int mid = (left + right) / 2;
// 递归排序左右子数组
_MergeSort(a, temp, left, mid);
_MergeSort(a, temp, mid + 1, right);
// 合并两个排序好的子数组
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int i = begin1;
for (i = begin1; i <= right; i++)
{
if (begin1 > end1 || begin2 > end2)
{
break;
}
if (a[begin1] <= a[begin2])
{
temp[i] = a[begin1];
begin1++;
}
else
{
temp[i] = a[begin2];
begin2++;
}
}
// 将左半部分剩余的元素拷贝到temp数组中
while (begin1 <= end1)
{
temp[i] = a[begin1];
begin1++;
i++;
}
// 将右半部分剩余的元素拷贝到temp数组中
while (begin2 <= end2)
{
temp[i] = a[begin2];
begin2++;
i++;
}
// 将临时数组中的数据拷贝回原数组
for (int j = left; j <= right; j++)
{
a[j] = temp[j];
}
}
void MergeSort(int* a, int n)
{
// 创建临时数组用于合并操作
int* temp = (int*)malloc(sizeof(int) * n);
int left = 0;
int right = n - 1;
_MergeSort(a, temp, left, right);
// 释放临时数组的内存
free(temp);
}
步骤详解
- 初始化和内存分配:
MergeSort
函数初始化临时数组temp
,该数组的大小与原数组a
相同,用于在合并过程中存储中间结果。
- 递归分解:
_MergeSort
函数通过递归将待排序数组分解成越来越小的子数组,直到每个子数组的长度为1(或为空)。这种分解通过计算mid = (left + right) / 2
来确定中间位置,并分别对左半部分[left, mid]
和右半部分[mid + 1, right]
进行递归排序。
- 合并操作:
- 合并操作的目标是将两个已排序的子数组
[left, mid]
和[mid + 1, right]
合并成一个大的已排序子数组。合并的具体步骤如下:- 设置指针
begin1
和begin2
分别指向两个子数组的开始位置,end1
和end2
分别指向两个子数组的结束位置。 - 使用一个循环将两个子数组中的元素比较,并将较小的元素拷贝到
temp
数组中。指针begin1
和begin2
会在循环过程中向后移动。 - 将左半部分和右半部分剩余的元素分别拷贝到
temp
数组中。如果一个子数组已经处理完毕,另一个子数组可能还有剩余元素。 - 最后,将临时数组
temp
中的排序结果拷贝回原数组a
的对应位置。
- 设置指针
- 合并操作的目标是将两个已排序的子数组
- 清理内存:
- 在
MergeSort
函数结束时,释放临时数组temp
的内存以避免内存泄漏。
- 在
2.2归并排序非递归实现
归并排序的非递归实现(也称为迭代实现)通过使用逐步增加的间隔(gap
)来合并已排序的子数组,而不是通过递归来实现。下面是对提供的非递归归并排序代码的详细解析:
void MergeSortNonR(int* a, int n)
{
// 初始化间隔为1
int gap = 1;
// 动态分配内存,用于存储合并后的结果
int* temp = (int*)malloc(sizeof(int) * n);
// 当间隔小于数组长度时进行合并
while (gap < n)
{
// 遍历数组,合并两个相邻的子数组
for (int m = 0; m < n; m += 2 * gap)
{
// 确定区间[m,m+gap-1] [m+gap,m+2*gap-1]
// 确定第一个子数组的起始和结束位置
int begin1 = m, end1 = m + gap - 1;
// 确定第二个子数组的起始和结束位置
int begin2 = m + gap, end2 = m + 2 * gap - 1;
// 如果第一个子数组的结束位置或第二个子数组的开始位置超出数组边界,则停止合并
if (end1 >= n || begin2 >= n)
{
break;
}
// 如果第二个子数组的结束位置超出数组边界,调整结束位置
if (end2 >= n)
{
end2 = n - 1;
}
// 合并两个已排序的子数组
int i = begin1;
for (i = begin1; i <= end2; i++)
{
// 如果第一个子数组或第二个子数组的指针超出范围,停止合并
if (begin1 > end1 || begin2 > end2)
{
break;
}
// 将较小的元素拷贝到临时数组中
if (a[begin1] < a[begin2])
{
temp[i] = a[begin1];
begin1++;
}
else
{
temp[i] = a[begin2];
begin2++;
}
}
// 将第一个子数组剩余的元素拷贝到临时数组中
while (begin1 <= end1)
{
temp[i] = a[begin1];
begin1++;
i++;
}
// 将第二个子数组剩余的元素拷贝到临时数组中
while (begin2 <= end2)
{
temp[i] = a[begin2];
begin2++;
i++;
}
// 将临时数组中的排序结果拷贝回原数组
for (int j = m; j <= end2; j++)
{
a[j] = temp[j];
}
}
// 将间隔加倍,以便在下一轮合并中处理更大的子数组
gap *= 2;
}
步骤详解
- 初始化和内存分配:
gap
变量用于控制每次合并的子数组间隔,初始值为1。- 动态分配内存创建临时数组
temp
,该数组用于存储合并后的结果。
- 逐步合并子数组:
- 使用
while (gap < n)
循环来逐步增加合并的子数组的间隔,直到gap
达到数组长度。 - 在
for (int m = 0; m < n; m += 2 * gap)
循环中,m
指定每次合并的起始位置,并将数组分成两个部分进行合并。
- 使用
- 确定子数组的边界:
- 对于每次合并操作,确定左子数组
[begin1, end1]
和右子数组[begin2, end2]
的边界。 - 如果右子数组的开始位置
begin2
超出了数组边界,则停止合并。
- 对于每次合并操作,确定左子数组
- 合并两个子数组:
- 使用两个指针
begin1
和begin2
分别指向两个子数组的开始位置,将较小的元素拷贝到temp
数组中。 - 将较小元素的指针向后移动,直到其中一个子数组的元素处理完毕。
- 使用两个指针
- 拷贝剩余的元素:
- 如果左子数组
[begin1, end1]
中还有剩余元素,则将它们拷贝到temp
数组中。 - 如果右子数组
[begin2, end2]
中还有剩余元素,也将它们拷贝到temp
数组中。
- 如果左子数组
- 将合并结果拷贝回原数组:
- 将
temp
数组中的排序结果拷贝回原数组a
的相应位置[m, end2]
。
- 将
- 更新间隔:
- 每次合并完成后,将
gap
乘以2,以便进行下一轮合并操作。这样,gap
会逐步增大,直到处理整个数组。
- 每次合并完成后,将
- 释放内存:
- 最后,释放临时数组
temp
的内存,以避免内存泄漏。
- 最后,释放临时数组
3.计数排序
计数排序是一种线性时间复杂度的非比较排序算法,适用于排序值域较小的整数集合。它通过统计每个元素出现的次数,进而直接定位元素的位置,完成排序。这种排序算法特别适合对大量重复的元素进行排序。以下是代码的详细解析:
void CountSort(int* a, int n)
{
int min = a[0];
int max = a[0];
// 找到数组中的最小值和最大值
for (int i = 1; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
// 计算范围 (max - min + 1)
int range = max - min + 1;
// 创建计数数组,并初始化为0
int* arr = (int*)malloc(sizeof(int) * range);
memset(arr, 0, sizeof(int) * range);
// 统计每个元素出现的次数
for (int i = 0; i < n; i++)
{
arr[a[i] - min]++;
}
// 根据计数数组重新排列原数组
int index = 0;
for (int j = 0; j < range; j++)
{
while (arr[j] != 0)
{
a[index++] = j + min;
arr[j]--;
}
}
// 释放动态分配的内存
free(arr);
}
步骤详解
- 找出最小值和最大值:
- 首先遍历整个数组,找到数组中的最小值
min
和最大值max
。 - 这是为了确定待排序数组中元素的值域(
range
),即从min
到max
之间的范围。
- 首先遍历整个数组,找到数组中的最小值
- 初始化计数数组:
- 计算出
range = max - min + 1
,这个范围决定了计数数组arr
的大小。 - 动态分配内存创建计数数组
arr
,并将其初始化为0。计数数组的每个索引位置用于存储对应值出现的次数。
- 计算出
- 统计每个元素的出现次数:
- 再次遍历原数组,对于每个元素
a[i]
,在计数数组arr[a[i] - min]
对应的位置加1。这一步记录了数组中每个值的出现次数。
- 再次遍历原数组,对于每个元素
- 重新排列原数组:
- 遍历计数数组
arr
,根据记录的次数将元素按顺序写回到原数组a
中。 - 例如,如果
arr[j]
的值为3,表示值j + min
在原数组中出现了3次,因此将j + min
写入原数组3次。
- 遍历计数数组
- 释放内存:
- 最后,释放动态分配的计数数组
arr
,避免内存泄漏。
- 最后,释放动态分配的计数数组
🥇结语
通过对快速排序、归并排序和计数排序的分析与比较,我们可以看到,每种算法都有其独特的优势和适用领域。在实际开发中,选择合适的排序算法至关重要,需要综合考虑数据规模、数据类型以及排序的稳定性要求。希望通过本文的介绍,读者能够更好地理解这些算法的特性,并在不同的应用场景中作出最佳选择。无论是在学术研究还是实际工程中,掌握这些经典排序算法,都是提升编程能力的关键一步。