++在上一篇文章中我们了解到了递归算法👉 【递归算法】,现在我们来学习排序算法中的直接插入排序和希尔排序。++
目录
[💯希尔排序(Shell sort)](#💯希尔排序(Shell sort))
[😝步骤四:最后一次增量为 1,进行直接插入排序](#😝步骤四:最后一次增量为 1,进行直接插入排序)
💯引言
直接插入排序和希尔排序作为经典的排序算法,各自具有独特的特点和适用场景。深入理解这两种算法对于掌握算法设计和优化的基本原理至关重要。
本文将详细解析直接插入排序和希尔排序的原理、实现、性能分析,并通过 C 语言代码示例进行深入探讨。
💯直接插入排序
⭐原理详解
直接插入排序的基本思想是将一个未排序的元素插入到一个已排序的序列中合适的位置,使得插入后序列仍然保持有序。
它就像我们在整理手中的扑克牌时,每次拿到一张新牌,将其插入到已排好序的牌堆中的正确位置。
😁具体操作步骤如下:
- 从数组的第二个元素开始,将其视为当前要插入的元素。
- 与前面已排好序的元素逐个进行比较,从后往前扫描已排序部分。
- 如果当前要插入的元素小于已排序元素,则将已排序元素向后移动一位,为要插入的元素腾出位置。
- 继续向前比较,直到找到一个合适的位置,使得当前要插入的元素大于等于前面的元素且小于等于后面的元素(或者已到达数组的起始位置)。
- 将当前要插入的元素插入到该位置,此时前面的部分序列仍然保持有序。
- 重复上述步骤,直到整个数组都被排序。
😏图解如下:
😃例如:
对于数组,初始时 被视为已排序部分,然后处理,将 与 比较,因为 ,所以将向后移动一位,得到,再将 插入到合适位置,此时数组变为。接着处理 ,因为 大于已排序部分的最后一个元素 ,所以直接将 插入到已排序部分的后面,数组变为。再处理 ,将 与 ,依次比较,移动元素后插入,数组变为。最后处理 ,经过比较和移动,得到最终排序后的数组 。
⭐代码实现
cpp
void insertionSort(int arr[], int n) {
for (int i = 1; i < n; i++) {
int j = i - 1;
int key = arr[i];
// 将arr[i]插入到已排序的子序列arr[0..i - 1]中
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
🌷在这段代码中:
- i用于遍历数组中的元素,从第二个元素开始,因为第一个元素默认已排好序。
- key 变量用于存储当前要插入的元素,它的值在每次循环中会更新为arr[i]。
- j变量用于标记已排序子序列的最后一个元素的位置,初始值为i - 1。
- 内层的
while
循环用于在已排序子序列中找到合适的插入位置。如果 arr[j]大于key,说明 key应该插入到 arr[j] 的前面,所以将 arr[j] 向后移动一位,即 arr[j + 1] = arr[j],然后 j 减 1,继续向前比较。当找到合适位置(j >= 0 && arr[j] > key条件不满足)时,将key 插入到 j + 1 的位置,即 arr[j + 1] = key。
⭐性能分析
(一)时间复杂度
- 最好情况 :当输入的数组已经是有序时,直接插入排序的效率最高。 在这种情况下,每次插入操作只需要比较一次,不需要移动元素。对于一个长度为 的数组,外层循环需要执行 次,但内层循环每次只执行一次比较操作。因此,最好情况下的时间复杂度为
- 最坏情况 :当输入的数组是逆序时,每次插入操作都需要将当前元素与前面已排序的所有元素进行比较和移动。对于第 个元素,需要进行 次比较和移动操作。那么总的比较和移动次数为 ,根据等差数列求和公式 ,这里的和为 ,所以时间复杂度为
- 平均情况 :在平均情况下,假设数组中元素的排列是随机的。对于第 个元素,平均需要比较和移动大约 次。那么总的平均比较和移动次数为 ,通过数学推导可得平均时间复杂度也为
(二)空间复杂度
直接插入排序是一种原地排序算法,它只需要常数级别的额外空间,用于存储临时变量。在排序过程中,不需要额外的数组或数据结构来存储数据。因此,空间复杂度为
(三)稳定性
直接插入排序是稳定的排序算法。这是因为在比较和移动元素的过程中,如果两个元素相等,不会交换它们的位置。只有当待插入元素小于已排序元素时,才会进行移动操作。所以,相等元素的相对顺序在排序前后不会改变,保证了算法的稳定性。
💯希尔排序(Shell sort)
++希尔排序是一种对直接插入排序进行改进的高效排序算法。++
希尔(shell) 做了俩个步骤,优化了直接插入排序:
- 预排序,目的在于:让序列接近有序,避免逆序直接插入时更高的时间复杂度
- 插入排序
⭐原理剖析
希尔排序的基本思想是👉++先将整个待排序的记录序列分割成若干子序列分别进行直接插入排序,待整个序列中的记录 "基本有序" 时,再对全体记录进行一次直接插入排序。++
😛具体操作过程如下:
假设我们有一个数组 [9, 5, 1, 8, 3, 7, 4, 6, 2]
,我们将使用希尔排序对其进行排序。
😌步骤一:确定增量序列
通常,我们可以选择增量序列为 n/2
,n/4
,n/8
,...,1
,其中 n
是数组的长度。在这个例子中,数组长度为 9
,所以初始增量 gap = 9 / 2 = 4
。
😛步骤二:按增量分组并进行插入排序
-
对于增量
gap = 4
,我们将数组分成以下子序列:- 子序列 1:
[9, 3]
- 子序列 2:
[5, 7]
- 子序列 3:
[1, 4]
- 子序列 4:
[8, 6]
- 子序列 5:
[3, 2]
(这里最后一个子序列元素个数可能较少,这是正常的情况)(每种颜色代表不同的子序列)
- 子序列 1:
-
对每个子序列进行插入排序:
- 子序列 1
[9, 3]
:- 初始时,
9
被认为是已排序部分,3
是待插入元素。 - 因为
3 < 9
,所以将9
向后移动一位,得到[9, 9]
。 - 然后将
3
插入到正确位置,此时子序列变为[3, 9]
。
- 初始时,
- 子序列 2
[5, 7]
:- 这里
5
和7
已经是相对有序的(因为5 < 7
),所以子序列不变,仍为[5, 7]
。
- 这里
- 子序列 3
[1, 4]
:- 初始时,
1
被认为是已排序部分,4
是待插入元素。 - 因为
1 < 4
,所以4
直接插入到已排序部分后面,子序列变为[1, 4]。
- 初始时,
- 子序列 4
[8, 6]
:- 初始时,
8
被认为是已排序部分,6
是待插入元素。 - 因为
6 < 8
,所以将8
向后移动一位,得到[8, 8]
。 - 然后将
6
插入到正确位置,此时子序列变为[6, 8]
。
- 初始时,
- 子序列 5
[3, 2]
:- 初始时,
3
被认为是已排序部分,2
是待插入元素。 - 因为
2 < 3
,所以将3
向后移动一位,得到[3, 3]
。 - 然后将
2
插入到正确位置,此时子序列变为[2, 3]
。
- 初始时,
- 子序列 1
经过这一轮对每个子序列的插入排序后,数组变为 [3, 5, 1, 6, 2, 7, 4, 8, 9]
。
😜步骤三:减小增量并重复步骤二
此时,我们将增量 gap
更新为 gap = 4 / 2 = 2
。
-
对于增量
gap = 2
,我们将数组分成以下子序列:- 子序列 1:
[3, 1, 2, 4]
- 子序列 2:
[5, 6, 7, 8]
- 子序列 3:
[9]
(当增量为2
时,最后一个元素单独构成一个子序列)(每种颜色代表不同的子序列)
- 子序列 1:
-
对每个子序列进行插入排序:
- 子序列 1
[3, 1, 2, 4]
:- 初始时,
3
被认为是已排序部分,1
是待插入元素。 - 因为
1 < 3
,所以将3
向后移动一位,得到[3, 3]
。 - 再将
1
插入到正确位置,此时子序列变为[1, 3, 2, 4]
。 - 接着,对于
2
,它与3
比较,因为2 < 3
,将3
向后移动一位,得到[1, 3, 3]
。 - 再将
2
与1
比较,因为1 < 2
,所以2
插入到1
后面,此时子序列变为[1, 2, 3, 4]
。
- 初始时,
- 子序列 2
[5, 6, 7, 8]
:- 这里
5
,6
,7
,8
已经是相对有序的(在前面的步骤中已经有一定的有序性),所以子序列不变,仍为[5, 6, 7, 8]
。
- 这里
- 子序列 3
[9]
:- 只有一个元素,无需排序。
- 子序列 1
经过这一轮对每个子序列的插入排序后,数组变为 [1, 2, 3, 4, 5, 6, 7, 8, 9]
。
😝步骤四:最后一次增量为 1,进行直接插入排序
此时,gap = 2 / 2 = 1
,这实际上就是对整个数组进行直接插入排序。但由于前面的步骤已经使数组基本有序,所以这一步的比较和移动次数会相对较少。
- 从第二个元素开始,将其与前面已排序的元素进行比较和插入:
- 初始时,
2
与1
比较,因为1 < 2
,所以2
已经在正确位置,数组不变。 - 接着,
3
与2
和1
比较,因为1 < 3
且2 < 3
,所以3
也在正确位置,数组不变。 - 以此类推,对后面的元素进行比较和插入,最终得到完全有序的数组
[1, 2, 3, 4, 5, 6, 7, 8, 9]
。
- 初始时,
**这种分阶段使用不同增量进行排序的方式,**使得在早期能够让元素快速地移动到大致正确的位置,减少了后期直接插入排序的工作量。
⭐代码实现
cpp
void shellSort(int arr[], int n) {
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
}
在这段代码中:
- gap用于控制增量的变化,从数组长度的一半开始,每次减半,直到gap变为 1。
- 外层循环每次确定一个增量值,中层循环用于遍历每个子数组中的元素,从gap位置开始。
- temp变量用于存储当前待插入的元素,j变量用于标记已排序子序列的最后一个元素的位置。
- 内层循环通过不断比较temp与已排序元素arr[j - gap],并将较大的元素向后移动gap位,直到找到合适的插入位置。最后,将temp插入到正确的位置
j
。
⭐性能分析
(一)时间复杂度
- 最好情况 :
- 在最好情况下,希尔排序的时间复杂度取决于增量序列的选择。当增量序列选择得当,且数组接近有序时,时间复杂度可以接近。例如,对于一些特殊的增量序列和特定的数据分布,可能会有较好的性能表现。然而,这种最好情况在实际应用中相对较少出现,并且对于不同的增量序列,最好情况的时间复杂度也会有所不同。
- 最坏情况 :
- 最坏情况下,希尔排序的时间复杂度为。与直接插入排序的最坏情况相同,但在实际运行中,希尔排序通常会比直接插入排序在最坏情况下表现更好。这是因为希尔排序通过前期的分组和局部排序,使得数据在早期就能够得到一定程度的有序化,减少了后期直接插入排序的比较和移动次数。
- 平均情况 :
- 希尔排序的平均时间复杂度通常在到之间,具体取决于增量序列的选择和数据的分布情况。**不同的增量序列会对平均性能产生影响,**一些经过优化的增量序列可以在一定程度上提高希尔排序的平均性能。例如,Hibbard 增量序列在实践中表现相对较好,但仍然没有一个精确的数学表达式来准确描述其平均时间复杂度。
(二)空间复杂度
希尔排序也是原地排序算法,只需要常数级别的额外空间用于存储临时变量(如temp
)。在排序过程中,不需要额外的数组或数据结构来存储数据。因此,空间复杂度为,与直接插入排序相同。
(三)稳定性分析
**希尔排序一般情况下是不稳定的排序算法。**这是因为在不同的子数组中进行插入排序时,相同元素可能会因为所在子数组的不同而被移动到不同的位置,从而导致相对顺序发生改变。然而,对于某些特殊的增量序列和数据分布,希尔排序也可能是稳定的,但这种情况相对较少见,并且需要具体分析。
💯总结
**直接插入排序和希尔排序都是重要的排序算法。**👀
直接插入排序原理简单、代码实现容易、是稳定的排序算法,在数据规模较小或数据基本有序时性能较好。
希尔排序是对直接插入排序的改进,通过采用不同的增量策略,在处理大规模数据时能显著提高排序效率。然而,希尔排序一般情况下是不稳定的。在实际应用中,需要根据数据的规模、特点以及对性能和稳定性的要求来选择合适的排序算法。
通过深入理解这两种排序算法,我们可以更好地应对各种排序问题,提高算法的效率和性能。 👌
💝💝💝感谢你看到最后,点个赞再走吧!💝💝💝我的主页👉 【A Charmer】
😇亲爱的读者,你在学习和使用排序算法的过程中,对于直接插入排序和希尔排序有怎样的看法呢? 欢迎参与投票: