本篇文章使用C语言实现了数据结构中常见的八大排序算法,它们分别是插入排序、希尔排序、选择排序、堆排序、冒泡排序、快速排序、归并排序和计数排序。在排序算法的实现过程中,每种算法都有其独特的特点和适用场景。插入排序通过逐步构建有序序列来排序,希尔排序则是对插入排序的一种改进,通过缩小增量的方式提高了效率。选择排序通过不断选择未排序部分的最小元素进行交换,而堆排序则利用了二叉堆的性质来实现高效的排序。冒泡排序通过多次遍历数组,将相邻元素进行比较和交换,逐步将最大元素移动到数组末尾。快速排序采用分治策略,通过选择一个基准元素,将数组分为两部分进行递归排序。归并排序则是将数组分为两个子数组,分别排序后再合并,以此达到整体有序的效果。最后,计数排序通过统计元素出现的次数,然后按顺序输出,实现了线性时间复杂度。下面我们将一一详细介绍它们,并给出了完整的实现代码。
算法思想:直接插入排序是一种简单且直观的排序算法,它的工作原理是逐步构建有序序列。该算法将待排序的数组分为两部分,一部分是已经排好序的,另一部分是待排序的。而在初始时,第一个数据默认有序(一个数据肯定是有序的),然后逐步将待排序部分的数据插入到已排序部分,直至整个数组有序。
实际中我们玩扑克牌时,就用了插入排序的思想。
具体步骤如下:
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
void InsertSort(int* nums, int n)
{
for (int i = 1; i < n ; ++i)
{
int tmp = nums[i];//待插入元素
//开始插入,从待插入区间的最后一个元素开始,从后往前比较
int end = i - 1;
while (end >= 0)
{
if (nums[end] > tmp)
{
nums[end + 1] = nums[end];
--end;
}
else
{
break;
}
}
nums[end + 1] = tmp;
}
}
插入排序是一种稳定的排序算法,它的时间复杂度为O(n^2),其中n是数组的长度。缺点:插入排序在大规模数据上性能相对较差。优点:实现简单且在小规模或部分有序的数据集上表现良好,有时候也可作为其他排序算法的优化步骤。
算法思想:希尔排序是由Donald Shell于1959年提出的一种排序算法,是对插入排序的一种改进,也被称为缩小增量排序。希尔排序解决了插入排序在处理大规模数据时的性能不足的问题。希尔排序的核心思想是通过对原始数组的分组排序,然后对每一组分别进行插入排序,然后逐步减小分组的间隔,直到间隔为1,最终完成整体的排序。
具体步骤如下:
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
void ShellSort(int* nums, int n)
{
int gap = n;
while (gap > 1)
{
gap /= 2;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = nums[end + gap];
while (end >= 0)
{
if (nums[end] > tmp)
{
nums[end + gap] = nums[end];
end -= gap;
}
else
{
break;
}
}
nums[end + gap] = tmp;
}
}
}
希尔排序的性能分析较为复杂,因为它依赖于分组间隔gap选择。在最坏情况下,希尔排序的时间复杂度为O(n^2),但在实际应用中,它的平均时间复杂度约为O(nlogn)。
以下是一些书上对希尔排序时间复杂度的计算:
《数据结构(C语言版)》— 严蔚敏
《数据结构-用面相对象方法与C++描述》— 殷人昆
因为咱们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:O(n^1.25)到 O(1.6 * n^1.25)来算。
希尔排序是一种不稳定的排序算法,即相同元素的相对位置在排序前后可能会改变(因为相同元素可能被分在不同的组,从而导致相对位置发生改变)。尽管有一些更高效的排序算法,希尔排序仍然因其相对简单的实现和在某些特定场景下的性能表现而受到一定的关注。
算法思想:选择排序是一种简单且直观的排序算法,它的基本思想是在未排序的部分中选择最小(或最大)的元素,然后将其与未排序部分的第一个元素进行交换。这个过程不断重复,直到所有元素都有序。
具体步骤如下:
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
void SelectSort(int* nums, int n)
{
//选n - 1次,同时i位置为未排序部分的第一个元素的下标
for (int i = 0; i < n - 1; ++i)
{
int mini = i;
//从未排序部分找最小值与未排序部分第一个元素交换
for (int j = i + 1; j < n; j++)
{
if (nums[j] < nums[mini])
{
mini = j;
}
}
Swap(&nums[mini], &nums[i]);
}
}
选择排序的特点是每次找到未排序部分的最小元素,并将其放到已排序部分的末尾。它的时间复杂度为O(n^2),其中n是数组的长度。选择排序是一种不稳定的排序算法,因为相同大小的元素在排序后可能会改变相对位置。选择排序相对简单,但由于其时间复杂度较高,不适合对大规模数据进行排序。
堆排序是一种基于二叉堆数据结构的排序算法。它利用了堆的性质来实现对元素的高效排序。堆排序分为两个阶段:建堆和排序。之前写的这篇文章,里面详细介绍了堆排序,本文就不做赘述了。链接如下:堆排序讲解
算法思想:冒泡排序是一种基础的排序算法,也是我们最熟悉(第一个接触)的排序算法。它的工作原理是反复遍历待排序的元素序列,比较相邻的两个元素,如果它们的顺序不符合要求(例如,升序排序时前面的元素大于其相邻后面的元素),则交换它们的位置。通过这样的交换操作,每一轮遍历都能将未排序部分的最大元素冒泡到最后,因此得名冒泡排序。
具体步骤如下:
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
void BubbleSort(int* nums, int n)
{
//比较趟数n - 1;
for (int i = 0; i < n - 1; ++i)
{
int flag = 0;
//每趟比较次数 n - 1 - i
for (int j = 0; j < n - 1 - i; ++j)
{
if (nums[j] > nums[j + 1])
{
flag = 1;
Swap(&nums[j], &nums[j + 1]);
}
}
if (0 == flag)
{
break;
}
}
}
冒泡排序的时间复杂度为O(n^2),其中n是数组的长度。它是一种稳定的排序算法,即相同元素的相对位置在排序前后不会改变。尽管冒泡排序的性能相对较差,特别是对于大规模数据集,但由于其实现方式简单易懂,它常被用于教学和理解排序算法的基本原理。
算法思想:快速排序是一种基于分治思想的排序算法,由英国计算机科学家Tony Hoare于1960年提出。它的主要思想是选择一个基准元素,将数组分成两个子数组,使得基准元素左边数组的元素都小于基准元素,使得基准元素右边数组的元素都大于基准元素,然后对这两个数组重复上述操作,直到整个数组有序。
具体步骤如下:
快速排序的关键点在于选择完基准元素以后如何使得基准元素左边数组的元素都小于基准元素,使得基准元素右边数组的元素都大于基准元素,下面给出了三种方法我们来分别介绍一下:
方法一(hoare版本):
具体步骤:
选择基准元素:选择数组中的一个元素作为基准(通常选择第一个元素)。
初始化指针:初始化两个指针,一个指向数组的开头,另一个指向数组的末尾。
交换元素:从两个指针所指位置开始,分别向中间移动,直到找到需要交换的元素对。
具体规则如下:
向左移动右指针,直到找到一个小于基准的元素。
向右移动左指针,直到找到一个大于基准的元素。
如果左指针仍在右指针的左侧,交换这两个元素。
重复步骤3: 继续重复步骤3,直到左指针与右指针相遇。
将基准元素放置到正确位置: 当左指针超过右指针时,将基准元素与右指针所指位置的元素进行交换。
返回基准位置: 返回左指针的位置作为新的基准位置。
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
//hore版本
int part1(int* nums, int left, int right)
{
keyi = left;//第一个元素做key
while (left < right)
{
//右边找小
while (left < right && nums[right] >= nums[keyi])
{
--right;
}
//左边找大
while (left < right && nums[left] <= nums[keyi])
{
++left;
}
Swap(&nums[left], &nums[right]);交换left和right位置的元素
}
Swap(&nums[keyi], &nums[left]);
return left;
}
方法二(挖坑法):
具体步骤:
填坑:将基准元素填入最后一个坑的位置。
返回基准位置:返回hole的位置作为新的基准位置。
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
int part2(int* nums, int left, int right)
{
//保存key并记录hole的位置
int key = nums[left];
int hole = left;
while (left < right)
{
//右边找小
while (left < right && nums[right] >= key)
{
--right;
}
nums[hole] = nums[right];
hole = right;
//左边找大
while (left < right && nums[left] <= key)
{
++left;
}
nums[hole] = nums[left];
hole = left;
}
nums[hole] = key;
return hole;
}
方法三(前后指针法):
具体步骤:
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
int part3(int* nums, int left, int right)
{
keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (nums[cur] < nums[keyi] && ++prev != cur)
{
Swap(&nums[prev], &nums[cur]);
}
++cur;
}
Swap(&nums[keyi], &nums[prev]);
return prev;
}
通过上面三种方法中的任意一种方法,都能使得选取的基准元素被放置在正确的位置上,并划分出左右两个子区间。然后将划分后的左右子区间分别作为新的待排序数组,对它们进行递归排序。这个过程是快速排序的核心思想,通过不断划分和递归,最终完成整个数组的排序。
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
快排递归版本完整代码如下:
//hoare版本
int part1(int* nums, int left, int right)
{
keyi = left;
while (left < right)
{
//右边找小
while (left < right && nums[right] >= nums[keyi])
{
--right;
}
//左边找大
while (left < right && nums[left] <= nums[keyi])
{
++left;
}
Swap(&nums[left], &nums[right]);
}
Swap(&nums[keyi], &nums[left]);
return left;
}
//void QuickSort(int* nums, int left, int right)
{
if (left >= right) return;
int keyi = part1(nums, left, right);
QuickSort(nums, left, keyi - 1);
QuickSort(nums, keyi + 1, right);
}
三路划分快速排序是对传统快速排序的一种改进,主要解决了在数组中存在大量重复元素时性能下降的问题。它通过将数组划分为三个部分,分别存放小于、等于和大于基准元素的元素,以减少重复元素的比较和交换。以下是三路划分快速排序的详细介绍:
画个图理解一下:
假设待排序数组是:{ 6, 3, 6, 7, 6, 5, 2, 4 }。
代码如下:
void QuickSort(int* nums, int begin, int end)
{
if (begin >= end) return;
int key = nums[begin];
int left = begin, right = end, cur = left + 1;
//三路划分
while (cur <= right)
{
if (nums[cur] < key)
{
Swap(&nums[left++], &nums[cur++]);
}
else if (nums[cur] > key)
{
Swap(&nums[right--], &nums[cur]);
}
else
{
cur++;
}
}
//递归处理大于key和小于key的部分。
QuickSort(nums, begin, left - 1);
QuickSort(nums, right + 1, end);
}
快速排序的非递归版本通常使用栈来模拟递归的过程。非递归实现的关键在于手动管理递归调用的栈,并使用迭代来替代递归。由于需要栈的辅助因此,非递归版本使用C++实现。
具体步骤如下:
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
int part1(vector<int>& nums, int left, int right)
{
int keyi = left;
while (left < right)
{
//右边找小
while (left < right && nums[right] >= nums[keyi])
{
--right;
}
//左边找大
while (left < right && nums[left] <= nums[keyi])
{
++left;
}
swap(nums[left], nums[right]);
}
swap(nums[keyi], nums[left]);
return left;
}
vector<int> sortArray(vector<int>& nums)
{
stack<int> st;
//初始时,将整个数组的边界入栈
st.push(0);
st.push(nums.size() - 1);
//迭代地从栈中取出边界,对相应的子数组执行划分操作
while (!st.empty())
{
int right = st.top();
st.pop();
int left = st.top();
st.pop();
int keyi = part1(nums, left, right);
//将划分后的两个子数组的边界入栈
if (keyi + 1 < right)
{
st.push(keyi + 1);
st.push(right);
}
if (keyi - 1 > left)
{
st.push(left);
st.push(keyi - 1);
}
}
return nums;
}
};
采用三数取中法选择中间值作为 key 是为了避免在已经有序的情况下发生最坏情况。
在快速排序中,选择合适的key是影响算法性能的关键之一。当选择待排序区间的最左侧元素作为key时,在数组已经有序或者近乎有序的情况下,可能导致快速排序的性能下降。这是因为在这种情况下,每次划分都可能得到一个极端不平衡的划分,使得一个子数组的大小为0,而另一个子数组的大小为原来的规模减1。
通过使用三数取中法,选择待排序区间的左端、右端和中间的三个元素中的中值作为key,可以有效地减少出现最坏情况的概率。这是因为无论数组的有序性如何,取三个元素的中值可以保证key相对于整个数组是比较中间的位置,从而减小了出现不平衡划分的可能性。
三数取中法的具体步骤如下:
这样,采用三数取中法可以在一定程度上提高快速排序在各种情况下的性能表现。
三数取中代码如下:
//快排优化 三数取中
int GetMidIndex(int* nums, int left, int right)
{
int mid = left + (right - left) / 2;
if (nums[left] < nums[mid])
{
if (nums[mid] < nums[right])
{
return mid;
}
else if (nums[left] < nums[right])
{
return right;
}
else
{
return left;
}
}
else//nums[left] > nums[mid]
{
if (nums[mid] > nums[right])
{
return mid;
}
else if (nums[left] > nums[right])
{
return right;
}
else
{
return left;
}
}
}
优化快速排序的另一种常见策略就是在递归到小的子区间时切换到插入排序。这是因为插入排序在小规模数据上的表现通常比快速排序更好,因为它的常数因子较小。
快速排序的基本思想是对大规模数据进行分治,但在递归到一定程度时,如果子数组足够小,快速排序的递归开销可能会超过插入排序的实际排序时间。因此,切换到插入排序可以在小规模数据上提高性能。
以下是一种在实际实现中可以考虑的快速排序和插入排序结合的策略:
判断递归停止条件:在每次递归之前,先判断子数组的大小是否小于某个阈值(通常是5到20之间)。如果小于阈值,则停止递归。
插入排序:当子数组的大小小于阈值时,切换到插入排序。插入排序对于小规模数组的性能通常较好,因为它的常数因子小且不涉及递归。
继续递归:如果子数组的大小超过阈值,继续进行快速排序的递归步骤。
这种策略被称为“递归深度限制”或“混合排序”策略。通过这种方式,可以在保持快速排序的优越性能的同时,避免了在小规模数据上的不必要的递归开销。这个阈值的选择可以根据具体问题和性能需求进行调整。
void Swap(int* e1, int* e2)
{
int tmp = *e1;
*e1 = *e2;
*e2 = tmp;
}
void InsertSort(int* nums, int n)
{
for (int i = 1; i < n ; ++i)
{
int tmp = nums[i];//待插入元素
//开始插入,从待插入区间的最后一个元素开始,从后往前比较
int end = i - 1;
while (end >= 0)
{
if (nums[end] > tmp)
{
nums[end + 1] = nums[end];
--end;
}
else
{
break;
}
}
nums[end + 1] = tmp;
}
}
//快排优化 三数取中
int GetMidIndex(int* nums, int left, int right)
{
int mid = left + (right - left) / 2;
if (nums[left] < nums[mid])
{
if (nums[mid] < nums[right])
{
return mid;
}
else if (nums[left] < nums[right])
{
return right;
}
else
{
return left;
}
}
else//nums[left] > nums[mid]
{
if (nums[mid] > nums[right])
{
return mid;
}
else if (nums[left] > nums[right])
{
return right;
}
else
{
return left;
}
}
}
//hoare版本
int part1(int* nums, int left, int right)
{
int keyi = GetMidIndex(nums, left, right);
Swap(&nums[left], &nums[keyi]);
keyi = left;
while (left < right)
{
//右边找小
while (left < right && nums[right] >= nums[keyi])
{
--right;
}
//左边找大
while (left < right && nums[left] <= nums[keyi])
{
++left;
}
Swap(&nums[left], &nums[right]);
}
Swap(&nums[keyi], &nums[left]);
return left;
}
void QuickSort(int* nums, int left, int right)
{
if (left >= right) return;
//当子数组的大小小于阈值时,切换到插入排序。
if (right - left < 10)
{
InsertSort(nums, right - left + 1);
}
int keyi = part1(nums, left, right);
QuickSort(nums, left, keyi - 1);
QuickSort(nums, keyi + 1, right);
}
算法思想:归并排序是一种基于分治思想的经典排序算法。它将一个未排序的数组分割成两个子数组,分别对这两个子数组进行排序,然后将排序后的子数组合并为一个有序数组。这个过程递归进行,直到整个数组有序。时间复杂度O(n*longn),空间复杂度O(n)。
具体详细步骤如下:
画个图理解一下:
假设待排序数组是:{ 6, 3, 8, 7, 1, 5, 2, 4 }。
代码如下:
void _MergeSort(int* nums, int* tmp, int left, int right)
{
//递归出口,数组大小为0或1,则认为已经有序,直接return
if (left >= right) return;
//计算中间位置(mid)
int mid = left + (right - left) / 2;
//递归使左右区间有序
_MergeSort(nums, tmp, left, mid);
_MergeSort(nums, tmp, mid + 1, right);
//归并
int begin1 = left, begin2 = mid + 1, begin3 = left;
while (begin1 <= mid && begin2 <= right)
{
if (nums[begin1] < nums[begin2])
{
tmp[begin3++] = nums[begin1++];
}
else
{
tmp[begin3++] = nums[begin2++];
}
}
while (begin1 <= mid)
{
tmp[begin3++] = nums[begin1++];
}
while (begin2 <= right)
{
tmp[begin3++] = nums[begin2++];
}
//拷贝会原数组
memcpy(nums + left, tmp + left, sizeof(int) * (right - left + 1));
}
void MergeSort(int* nums, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(nums, tmp, 0, n - 1);
free(tmp);
}
归并排序的非递归版本通常采用迭代而非递归调用栈的方式。该实现方式使用循环进行数组的划分和合并操作。
具体详细步骤如下:
画个图理解一下:
代码如下:
void MergeSortNoR(int* nums, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("MergeSortNoR->malloc fail");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += gap * 2)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + gap * 2 - 1;
int begin3 = i;
//越界修正
if (end1 >= n)
{
end1 = n - 1;
begin2 = n + 1;
end2 = n;
}
else if (begin2 >= n)
{
begin2 = n + 1;
end2 = n;
}
else if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (nums[begin1] < nums[begin2])
{
tmp[begin3++] = nums[begin1++];
}
else
{
tmp[begin3++] = nums[begin2++];
}
}
while (begin1 <= end1)
{
tmp[begin3++] = nums[begin1++];
}
while (begin2 <= end2)
{
tmp[begin3++] = nums[begin2++];
}
//memcpy(nums, tmp, sizeof(int) * (end2 - i + 1));
}
memcpy(nums, tmp, sizeof(int) * n);
gap *= 2;
}
free(tmp);
}
算法思想:计数排序是一种非比较性的排序算法,适用于一定范围内的整数排序。它通过统计每个元素的出现次数,然后根据统计信息重写原数组,从而达到排序的目的。计数排序的核心思想是将待排序数据的值映射为额外数组的下标,然后根据其作为下表位置的计数信息,重新构造有序序列。
具体步骤如下:
画个图理解一下:
假设待排序数组是:{ 6, 3, 5, 2, 6, 5, 2, 4 }。
在统计数组中每个元素出现次数出现,要先遍历数组,找到数组中元素的最大值和最小值,计算出数组的范围。这是因为使用绝对映射(用数组元素的值直接当作下标),可能会造成空间的浪费。比如这组数据{ 1006, 1003, 1005, 1002, 1006, 1005, 1002,10004},让10000当作下标的,我们需要开辟1006个整型大小的空间,然而0-10001这么多个位置都是浪费的,因此这里使用相对映射,数组每个元素减去数组元素中的最小值作为映射的下标。上面例子使用相对映射,只需要开辟5个空间即可,在很大程度上节省了空间。
代码如下:
void CountSort(int* nums, int n)
{
int min = nums[0], max = nums[0];
//找最大 和 最下的元素
for (int i = 1; i < n; ++i)
{
if (nums[i] < min)
{
min = nums[i];
}
if (nums[i] > max)
{
max = nums[i];
}
}
int range = max - min + 1;
int* tmp = (int*)calloc(range, sizeof(int));
if (tmp == NULL)
{
perror("CountSort->malloc");
exit(-1);
}
//统计每个元素出现的次数
for (int i = 0; i < n; ++i)
{
tmp[nums[i] - min]++;
}
int index = 0;
//写回原数组
for (int i = 0; i < range; i++)
{
while (tmp[i]--)
{
nums[index++] = i + min;
}
}
free(tmp);
}
计数排序的特性总结:
至此,本片文章就结束了,若本篇内容对您有所帮助,请三连点赞,关注,收藏支持下。
创作不易,白嫖不好,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
如果本篇博客有任何错误,请批评指教,不胜感激 !!!