【数据结构】排序算法(冒泡排序、插入排序、希尔排序、选择排序、堆排序、计数排序)
生命不可能有两次,但许多人连一次也不善于度过。💓💓💓
目录
✨说在前面
亲爱的读者们大家好!💖💖💖,我们又见面了,在之前的阶段我们已经将几种常见数据结构都学习完了,接下来这几章我们学习数据结构的应用——排序算法。排序算法有很多种,我接下来将会一一带着大家了解和学习。
今天我们将要学习的排序有——冒泡排序、插入排序、希尔排序、选择排序、堆排序和计数排序。排序算法是数据结构中非常重要的一块内容,我们今天就揭开排序算法神秘的面纱,详细剖析和体会排序算法独特的魅力吧~
博主主页传送门:愿天垂怜的博客
🍋知识点一:排序的概念和应用
• 🌰1.排序及其概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定再待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即再原序列中,存r[i] = r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍然在r[j]之前,则称这种排序是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序的要求不断地在内外存之间移动数据的排序。
• 🌰2.排序的应用
排序在生活中无处不在。我们再逛淘宝、京东的时候,挑选喜欢的商品,希望它的性价比能够高,常常会将该类商品按照价格、销量、评论等方式进行排列,挑选最好的那一个;这里就用到了排序。
大家在高中毕业后查找心仪的院校,也会观察院校在排名中的位次,这个排名也是按照一定方式进行的排列。
所以说,生活中处处有排序,处处都用到排序。
• 🌰3.常见的排序算法
我们常见的排序算法有以下几种:
🍋知识点二:常见排序算法的实现
• 🌰1.冒泡排序
冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地遍历要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。遍历数列的工作是重复进行的,直到没有再需要交换的元素,这意味着数列已经排序完成。这个算法的名字由来是因为越小(或越大)的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序的基本思路:
1.比较相邻的元素:如果第一个比第二个大(升序排序中),就交换它们两个;
2.对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数;
3.针对所有的元素重复以上的步骤,除了最后一个;
4.持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
代码如下:
void BubbleSort(int* arr, int length)
{
assert(arr);
for (int j = 0; j < length; j++)
{
int flag = 1;
for (int i = 0; i < length - j; i++)
{
//前一个比后一个大就交换
if (arr[i] > arr[i + 1])
{
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
flag = 1;
}
}
//如果没有交换过,flag=0,说明已经全部排好,不用再循环
if(flag == 0)
{
break;
}
}
}
时间复杂度分析:
最坏:在最坏的情况下,即数组完全逆序时,冒泡排序需要进行 n−1 轮比较,每一轮都需要进行 n−j 次比较(其中 j 是当前轮数,从 1 开始计数)。因此,总的比较次数和交换次数与下标成等差数列。这意味着在最坏的情况下,冒泡排序的时间复杂度是 O(n^2)。
最好:在最好的情况下,即数组已经是有序的时,冒泡排序只需要进行一轮比较,就会发现没有元素需要交换,从而立即结束排序。因此,最好的情况下冒泡排序的时间复杂度是 O(n)。
平均:对于平均情况,也就是说数组既不是完全有序也不是完全逆序时,无论数组的初始状态如何,冒泡排序都需要进行大量的比较和可能的交换操作来确保数组有序。尽管在某些情况下,数组可能会更快地变得有序,但从总体上看,平均情况的时间复杂度仍然是 O(n^2)。
稳定性分析:
对于冒泡排序来说,当比较两个相邻的元素时,如果它们的顺序是正确的(即较小的元素在前),则不进行交换;如果顺序错误,则进行交换。这种交换方式保证了相等的元素在排序前后的相对位置永远不会改变,因此冒泡排序是稳定的。
总结:冒泡排序的时间复杂度为O(n^2),是稳定的排序算法。
• 🌰2.直接插入排序
插入排序(Insertion Sort),也被称为直接插入排序,是一种简单直观的排序算法。其基本思想是将一个记录插入到已排好序的有序序列中,直到所有记录插入完成为止。
实际上我们玩扑克牌时,就用到了插入排序的思想
插入排序的基本思路:
1.从第一个元素开始,该元素可以认为已经被排序。
2.取出下一个元素,在已经排序的元素序列中从后向前扫描。
3.如果该元素(已排序)大于新元素,将该元素移到下一位置。
4.重复步骤3,直到找到已排序的元素小于或者等于新元素的位置。
5.将新元素插入到该位置后。
6.重复步骤2~5。
代码如下:
void InsertSort(int* arr, int length)
{
assert(arr);
for (int i = 0; i < length - 1; i++)
{
//记录下一项的值和该项的下标
int temp = arr[i + 1];
int end = i;
while (end >= 0)
{
//前一项大于后一项,将前一项放入后一项的位置
if (arr[end] > temp)
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
//插入的位置前后都是有序的
arr[i + 1] = temp;
}
}
时间复杂度分析:
最坏:在最坏的情况下,也就是如果输入数组是逆序的,每次插入操作都需要将当前元素与已排序序列中的所有元素进行比较,并将已排序序列中的元素向后移动一位以为新元素腾出空间。因此,对于第i个元素(i从2开始,因为第1个元素默认已排序),需要比较和移动的次数大约是i-1次。所以,总的比较和移动次数与1下标成等差数列。也就是说在最坏的情况下,插入排序的时间复杂度是 O(n^2)。
最好:在最好的情况下,也就是输入数组已经是有序的,每次插入操作(从第二个元素开始)都会发现当前元素已经位于它在已排序序列中的正确位置,因此不需要进行任何比较或移动。但是算法仍然需要遍历整个数组来确保每个元素都已经在其正确的位置上。因此,总的时间复杂度是O(n),因为算法需要执行n-1次插入操作(对于数组中的第2到第n个元素),而每次操作都是O(1)的。
平均:大多数情况下,输入数组既不是完全有序的也不是完全逆序的,因此平均情况下的时间复杂度通常认为是O(n^2)。因为即使数组部分有序,也可能存在足够多的逆序对,使得插入排序需要执行大量的比较和移动操作。
稳定性分析:
对于插入排序来说,是从后向前扫描的,并且相等元素不会被移动(只是插入位置的选择会跳过这个相等的元素),因此这两个相等的元素在排序前后的相对位置不会改变,因此插入排序是稳定的。
总结:插入排序的时间复杂度为O(n^2),是稳定的排序算法。
• 🌰3.希尔排序
希尔排序(Shell's Sort),又称“缩小增量排序”(Diminishing Increment Sort),是插入排序的一种高效改进版本。希尔排序的基本思想是将待排序的序列划分成若干个子序列,分别进行插入排序,待整个序列中的记录基本有序时,再对全体记录进行一次直接插入排序。通过引入增量(gap)的概念,使得在排序的初始阶段,元素可以跨越多个位置进行比较和移动,从而减少了总的比较次数和移动次数,提高了排序效率。
希尔排序的基本思想:
1.选择一个增量序列:这个序列通常是递减的,且最后一个增量为1。增量序列的选择对希尔排序的性能有很大影响。常见的增量序列有希尔增量(如n/2、n/4等,但这不是最优的),在下面的代码中使用的是n/3 + 1。
2.分组插入排序:使用当前增量对数组进行分组,使得每组中的元素相隔固定的增量。然后,对每个组内的元素进行插入排序。这里的关键是,由于增量的存在,组内的插入排序并不是完全意义上的全局有序,而是保证在每个增量的“视野”内是有序的。
3.减小增量,重复排序:每次完成一轮分组插入排序后,减小增量,并重复上述分组插入排序的过程。随着增量的逐渐减小,每次分组插入排序所涉及的元素之间的间隔也越来越小,直到增量为1时,整个数组被分成了一个组,此时进行一次完整的插入排序,就可以使得整个数组变得有序。
代码如下:
void ShellSort(int* arr, int length)
{
assert(arr);
//将序列分为gap组
int gap = length;
while (gap > 1)
{
//gap遵循gap/3 + 1的序列
gap = gap / 3 + 1;
for (int i = 0; i < length - gap; i++)
{
//记录每组中下一项和该项的下标
int end = i;
int temp = arr[end + gap];
while (end >= 0)
{
在一组中,前一项比后一项大就交换
if (arr[end] > temp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = temp;
}
}
}
gap对希尔排序的影响:
1.gap越大,即分的组越多,大的可以越快跳到后面,小的可以越快调到前面,但越不接近有序。
2.gap越小,即分的组越少,跳的就慢,但更接近有序,当gap = 1即直接插入排序,就有序了。
时间复杂度分析:
希尔排序的时间复杂度是一个比较复杂的问题,大家可以认为它的时间复杂度是O(n^1.3),我们可以简单分析一下:
我们上面代码设定的希尔序列是gap = length / 3 + 1,为了分析方便,我们将1忽略不计,即gap = length / 3。
最坏情况下,第一趟预排序的消耗:(1 + 2) * length / 3也就是length(其中1 + 2指的是序列被分为length / 3组,那每组就是3个数据,最坏需要比较1 + 2 = 3次,在乘以组数length / 3,就是第一趟整趟的消耗)
那么同理,接下来gap = length / 9,最坏情况下,第二趟预排序的消耗:(1 + 2 + 3 +. .. + 8)* length / 9 = 4 * length。
依照这个方法,我们可以继续往下算,也能算出最坏情况下,第length趟预排序的消耗。但问题是,由于第一趟的预排序,第二趟的预排序不会是最坏的情况,同理第三趟,第四趟,后面的每一趟由于前面的预排序,都不会是最坏的情况,这就是希尔排序复杂度的一个难点。
当到最后一趟,即gap = 1时,此时已经很接近有序了,可以认为是插入排序的最好的情况,此时消耗为length。而前面的情况虽然不能精确算出,但还是可以看出是一个递增的趋势,由此我们猜测消耗随趟数的变化是一个先递增再递减的过程:
当然为什么在unknown的位置消耗大约是length的1.3次方,是大量的实验所表明的。
所以,我们就认为它的时间复杂度是O(n^1.3)吧。
总结:希尔排序的时间复杂度为O(n^1.3),是不稳定的排序算法。
稳定性分析:
希尔排序将原始数组分割成多个子序列,并对这些子序列分别进行插入排序。由于每次排序都是基于不同的增量进行分组,这导致相同的元素可能会在不同的子序列中被移动,从而改变它们之间的相对顺序,因此希尔排序是不稳定的。
• 🌰4.选择排序
选择排序(Selection Sort)是一种简单直观的排序算法,每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的其实位置,直到全部数据元素排完。不过这样显然不够好,我们可以每一次同时找出最大和最小的元素放在开头和末尾,以提高排序的效率。
选择排序的基本思想:
1.初始化:设定begin、end,用于指示当前排序的起始和末尾位置。
2.寻找最小和最大元素:从未排序的元素中(即闭区间[begin, end]),通过遍历找到最小和最大的元素。
3.交换元素:将找到的最小和最大元素与begin、end所指的元素进行交换。这样,最小和最大的元素就被放到了正确的位置上。
4.移动指针:将begin向后移动一位,end向前移动一位,然后重复步骤2和步骤3,直到begin = end,此时整个序列就排序完成了。
代码如下:
//两数互换
void Swap(int* p1, int* p2)
{
int temp = *p1;
*p1 = *p2;
*p2 = temp;
}
//选择排序
void SelectSort(int* arr, int length)
{
assert(arr);
//创建左右指针
int begin = 0, end = length - 1;
//创建最小和最大值的下标
int mini = begin, maxi = begin;
while (begin < end)
{
for (int i = begin + 1; i < end; i++)
{
if (arr[i] < arr[mini])
{
mini = i;
}
if (arr[i] > arr[maxi])
{
maxi = i;
}
}
//交换
Swap(arr + mini, arr + begin);
//如果最大值刚好是begin所在的位置,交换后最大值被换到了mini的位置
if (maxi == begin)
maxi = mini;
Swap(arr + maxi, arr + end);
begin++;
end--;
}
}
时间复杂度分析:
最坏:在最坏的情况下,即输入数组完全逆序,同样需要遍历整个数组来找出最大和最小值,时间复杂度为 O(n^2)。
最好:当输入数组已经是排序好的,但由于选择排序的特性,它仍然需要遍历整个数组来找出最大和最小值,所以最好情况的时间复杂度仍然是 O(n^2)。
平均:和最好情况相同,时间复杂度为 O(n^2)。
稳定性分析:
对于选择排序(包括优化后的同时选择最大和最小值的版本),由于它总是将选定的最大(或最小)元素与序列的起始(或末尾)元素进行交换,这可能会改变相等元素的相对位置。因此,优化后的选择排序也是不稳定的。
举例:
总结:选择排序的时间复杂度为O(n^2),是不稳定的排序算法。
• 🌰5.堆排序
堆排序(Heapsort)是一种基于堆数据结构所设计的排序算法。堆排序和选择排序都是基于选择的排序算法,但堆排序比选择排序快很多。
堆排序的基本思想:
1.堆的构建:
1.将待排序的数组元素构建成一个堆,这通常是从最后一个非叶子节点开始,向上遍历每个节点,对每个节点进行向下调整建堆,以确保每个节点都满足堆的性质。
2.向下调整操作是堆排序中的关键步骤,它通过比较节点与其子节点的值,确保父节点的值大于(对于最大堆)或小于(对于最小堆)其子节点的值。
2.堆的调整与排序:
1.在堆构建完成后,堆的根节点就是序列中的最大(或最小)元素。
2.将堆顶元素(最大或最小)与堆的最后一个元素交换,然后移除最后一个元素(或将其视为已排序部分),此时堆的大小减一。
3.对剩余部分重新进行向下调整操作,以恢复堆的性质。
4.重复以上步骤,直到堆的大小减至1,此时序列已经完全排序。
代码如下:
//两数互换
static void Swap(int* p1, int* p2)
{
int temp = *p1;
*p1 = *p2;
*p2 = temp;
}
//堆的向下调整算法
static void AdjustDown(int* arr, int n, int parent)
{
int child = 2 * parent + 1;
while (child < n)
{
if (child + 1 < n && arr[child] < arr[child + 1])
{
child++;
}
if (arr[parent] < arr[child])
{
Swap(arr + parent, arr + child);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
//堆排序
void HeapSort(int* arr, int length)
{
assert(arr);
//向下调整建堆
for (int i = (length - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, length, i);
}
while (length - 1)
{
Swap(arr, arr + length - 1);
AdjustDown(arr, length - 1, 0);
length--;
}
}
时间复杂度分析:
对于向下调整建堆,它的时间复杂度为O(n),推导如下:
而下一个while循环的时间复杂度是O(nlogn),因为每一次都是将堆顶的数据进行向下调整,并且最后一个不看(已经排序完成),所以第一次向下调整的消耗是log(n+1),第二次是log(n),第三次是log(n-1),...,一直到最后一次log(1)。那么总的消耗就是log(1) + log(2) + log(3) + ... + log(n + 1),我们可以用定积分近似计算这个和的值:
所以堆排序的时间复杂度为O(nlogn)。
稳定性分析:
由于堆排序的过程涉及到元素之间的交换,尤其是堆顶元素与堆的最后一个元素的交换,这会导致原本相同元素之间的相对顺序发生变化。具体来说,如果两个相等的元素a和b,其中a在b前面,但在建堆和排序过程中,a被移动到了堆的顶部并被交换到了序列的末尾,而b则保留在原来的位置或者也被移动了,但不一定在a后面。这样,在排序完成后,a和b的相对顺序就可能发生了变化。因此,堆排序是不稳定的。
总结:堆排序的时间复杂度为O(nlogn),是不稳定的排序算法。
• 🌰6.计数排序
计数排序(Counting Sort)是一种非比较型整数排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,它要求输入的数据必须是有确定范围的整数。
计数排序的基本思想:
1.找出待排序的数组中最大和最小的元素。
2.统计数组中每个值为i的元素出现的次数,存入数组count的第i项。
3.对所有的计数累加(从count中的第一个元素开始,每一项和前一项相加)。此时count[i]包含实际位置为i的元素的个数。
4.反向填充目标数组:将每个元素i放在新数组的第count[i]项,每放一个元素就将count[i]减去1。
注意: 但是我们需要注意,如果这个范围很大,如下面这个例子,就会导致0~99的空间被浪费。所以,在映射的过程中,我们可以化绝对映射为相对映射,即创建的数组的大小为range = max - min + 1,那么每个元素所映射的位置就从count[arr[i]]变为了count[arr[i] - min]。
算法步骤:
1.找出待排序数组中的最大值和最小值。
2.根据最大值和最小值的差值范围,创建一个足够大的计数数组count,并初始化为0。
3.遍历待排序数组,计算每个元素的出现次数,并存储在计数数组count中。
4.对计数数组C进行累加操作,使得count[i]包含的是所有小于等于i的元素个数。
5.反向遍历待排序数组,根据计数数组count的值,将元素放到排序后数组的正确位置上,并更新计数数组count。
代码如下:
void CountSort(int* arr, int length)
{
assert(arr);
//找出最大和最小值
int min = arr[0], max = arr[0];
for (int i = 0; i < length; i++)
{
if (arr[i] > max)
max = arr[i];
if (arr[i] < min)
min = arr[i];
}
//创建range大小的数组
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc operation failed");
return;
}
//统计次数
for (int i = 0; i < length; i++)
{
count[arr[i] - min]++;
}
//排序
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
arr[j++] = i + min;
}
}
}
计数排序时间复杂度分析:
计数排序的总体时间复杂度主要由遍历输入数组和填充输出数组决定,因此是 O(n + range)。然而,由于range是常数(对于给定的数据集),所以通常将计数排序的时间复杂度简化为O(n)。
稳定性分析:
计数排序的排序过程是通过统计每个元素的出现次数,然后根据这些统计信息来重建排序后的序列。在这个过程中,如果两个元素相等,它们原本在序列中的相对位置会被保留下来。具体来说,计数排序在统计每个元素的出现次数后,会根据元素的顺序(即它们在原数组中的位置)来填充排序后的数组,从而确保相等的元素保持原有的相对位置。所以计数排序是稳定的。
计数排序的优缺点分析:
优点:
1.时间复杂度低:在给定数据范围不是很大的情况下,计数排序的时间复杂度为O(n + range),由于range通常是一个相对较小的常数(相对于n),因此计数排序可以看作是线性时间复杂度的排序算法,这在实际应用中非常高效。
2.稳定性:计数排序是一种稳定的排序算法。如果两个元素相等,它们在排序后的序列中将保持原有的相对位置。
3.适用于一定范围内的整数排序:当数据集中的元素是整数且范围不是非常大时,计数排序非常有效。它不需要进行元素之间的比较,而是通过计数来排序,这使得它在处理这类数据集时特别高效。
缺点:
1.空间复杂度高:计数排序需要额外的空间来存储计数数组,这个数组的大小取决于输入数据的范围。如果输入数据的范围很大,那么计数数组将占用大量的内存空间。
2.受限于数据范围:计数排序只能用于一定范围内的整数排序。如果数据集中的元素不是整数,或者整数的范围非常大,那么计数排序将不再适用。
3.对内存敏感:由于计数排序需要额外的内存空间来存储计数数组,因此它对内存的使用比较敏感。在内存资源有限的环境下,使用计数排序可能会导致内存溢出等问题。
4.不适用于大数据集:虽然计数排序在处理小数据集时非常高效,但当数据集变得非常大时,由于内存和空间的限制,计数排序不再是一个好的选择。
总结:计数排序的时间复杂度为O(n + range),是稳定的排序算法。
• ✨SumUp结语
到这里本篇文章的内容就结束了,本节内容讲解了部分排序的算法,希望可以给大家带来帮助,并尝试自己独立写出排序的代码。下一节将会继续讲解排序的相关知识,希望大家继续捧场~
原文地址:https://blog.csdn.net/2302_81580770/article/details/140685146
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!