20、排序算法全解析

排序算法全解析

1. 排序的重要性与基本概念

快速搜索算法通常需要数据以有序的形式呈现,这使得排序问题从实际应用的角度来看显得尤为重要。

考虑一个非空有限集合 $A = {a_1, a_2, a_3, …}$,其中定义了一个反对称且传递的关系 $S$,集合中的所有元素两两可比较。从集合 $A$ 的 $N$ 个元素中形成向量 $a = (a_{i1}, a_{i2}, …, a_{iN})$,排序问题就是对向量 $a$ 的分量进行重新排列(即改变顺序),使得其分量最终按如下条件有序:
- 输入数据:$(a_{i1}, a_{i2}, …, a_{iN})$
- 输出数据:$(a_{j1}, a_{j2}, …, a_{jN})$,满足 $(a_{j1} S a_{j2})$ 且 $(a_{j2} S a_{j3})$ 且 … 且 $(a_{jN - 1} S a_{jN})$

输入数据可以表示为数组,也可以是更复杂的形式,如列表,列表中的每个元素可能有多个字段。用于排序的元素字段称为键。有时除了选定的键字段外,还需要考虑其他字段的值来对数据进行排序,这就是二次键排序。

根据直接访问数据的可能性,排序算法可分为内部排序和外部排序:
- 内部排序 :对大小允许放入随机访问内存的数组中的数据进行排序,此时可以任意访问任何内存单元。
- 外部排序 :当数组由于规模太大只能存放在相对较慢的外围设备中时使用。内部排序常作为外部排序的基础,外部排序通常先使用某种内部算法在随机访问内存中处理数据数组的各个部分,然后在外围设备中将它们合并成一个单个数组。

排序算法的行为若满足以下条件则称为自然排序:当元素按所需顺序排列时,排序时间 $T(N)$ 最短;随着有序程度降低,排序时间增加;当被排序数组的元素按逆序排列时,排序时间最长。稳定排序是指不改变具有相同键值的已排序元素的顺序的排序。

排序复杂度的下界定理表明,任何基于比较的排序在最坏情况下的复杂度 $W(N) = \Omega(N \log_2 N)$。证明如下:排序算法对原始的 $N$ 个元素进行排列,可能的结果数量等于元素的排列数,即 $N!$。排序算法的决策树构建方式为:每个顶点代表算法执行的一次比较 $(a?b)$,与给定顶点相关的两个子树分别对应 $a < b$ 和 $a > b$ 的结果(为简单起见,假设所有元素 $a \neq b$)。决策树的边表示状态之间的转换,叶子节点表示算法可能的输出数据。在最坏情况下,比较次数等于树的高度。对于任何高度为 $h$ 且叶子节点数为 $l$ 的二叉树,有不等式 $2^h \geq l$ 或 $h \geq \lceil \log_2 l \rceil$。因此,基于比较的排序的决策树高度满足 $W(N) \geq \lceil \log_2 N! \rceil$,而 $\lceil \log_2 N! \rceil \geq \sum_{i = 1}^{N} \log_2 i > \frac{N}{2} \log_2 \frac{N}{2} = \Omega(N \log_2 N)$,从而证明了下界定理。如果一个算法的最坏情况复杂度估计为 $W(N) = \Theta(N \log_2 N)$,则称该算法为渐近最优算法。

基于元素比较的排序可分为以下几类:
- 插入排序
- 选择排序
- 交换排序

各类排序算法的复杂度如下表所示:
| 排序类型 | 复杂度 |
| ---- | ---- |
| 选择排序 | $\Theta(N^2)$ |
| 插入排序 | $\Theta(N^2)$ |
| 冒泡排序 | $\Theta(N^2)$ |
| 堆排序 | $\Theta(N \log_2 N)$ |
| 希尔排序 | $\Theta(N \log_2 N)$ |
| 快速排序 | $\Theta(N \log_2 N)$ |

2. 插入排序

插入排序在算法执行过程中逐步生成数组的有序部分。在每一步,将一个输入数据元素插入到已排序数组的合适位置,直到输入数据集合处理完毕。

以下是插入排序的代码实现:

const N = 100;
type Mass = array[1..N] of integer;
procedure InsertionSort(var list : Mass);
var i, j, temp: integer;
// list — array of N elements subject to sorting
begin
    for i := 2 to N do
    begin
        temp := list[i];
        j := i - 1;
        while (j >= 1) and (list[j] > temp) do
        // shift all elements greater than the next
        begin
            list[j + 1] := list[j];
            j := j - 1;
        end;
        list[j + 1] := temp;
    end;
end;

下面对插入排序算法进行分析,选择第 11 行的比较操作 list[j] > temp 作为基本操作。
- 最坏情况 :当添加的元素比其他所有元素都小时,它总是出现在数组的开头,例如原始数组元素按降序排列时。此时比较次数为 $W(N) = \sum_{i = 2}^{N} (i - 1) = \frac{(N - 1)N}{2} = O(N^2)$。
- 平均情况 :需要对从 $i = 2$ 开始的每个第 $i$ 个元素插入时的比较操作进行求和,即 $A(N) = \sum_{i = 2}^{N} A_i$。确定下一个元素位置的平均比较次数为 $A_i = \frac{1}{i} (\sum_{j = 1}^{i - 1} j + (i - 1)) = \frac{i + 1}{2} - \frac{1}{i}$。则 $A(N) = \sum_{i = 2}^{N} A_i = \sum_{i = 2}^{N} (\frac{i + 1}{2} - \frac{1}{i}) = \frac{1}{4}(N + 4)(N - 1) - (\ln N + \gamma - 1 + O(\frac{1}{N})) = O(N^2)$。

简单插入排序算法具有以下优点:
- 对于小数据集和部分有序的集合,与平均情况相比,操作时间较短。
- 只使用有限的额外内存。
- 具有稳定性。
- 实现简单。

其缺点是时间复杂度较高,为 $\Theta(N^2)$。

3. 冒泡排序

冒泡排序的名称源于排序过程中数组元素 list[1..N] 的移动类似于水中气泡的运动。该算法对数组的两个相邻元素进行比较,并在必要时交换元素。

以下是冒泡排序的代码实现:

const N = 100;
type Mass = array[1..N] of integer;
procedure BubbleSort(var list : Mass);
var i, j: integer;
// list — array of N elements subject to sorting
begin
    for i := 2 to N do
        for j := N downto i do
            if list[j - 1] > list[j] then
                swap(list[j - 1], list[j]);
end;

冒泡排序算法的分析通常包括最佳、最坏和平均情况的评估。但对于这种排序,无论输入数据的有序程度如何,比较次数对于大小为 $N$ 的任何输入数据集都是不变的,即 $B(N) = W(N) = A(N)$。第一次遍历的比较次数为 $N - 1$,然后循环以 $N - 2$ 次比较重复,依此类推。$W(N) = \sum_{i = 2}^{N} \sum_{j = N}^{i} 1 = \sum_{i = 2}^{N} (N - i + 1) = \frac{N^2 - N}{2} = \Theta(N^2)$。

可以对该算法进行以下改进:
- 记录最后一次交换的元素的索引,并在下一次遍历中将这些索引中的最大值作为被排序数组部分的下界。
- 不仅将“轻”元素移到数组开头,还立即将“重”元素移到数组末尾,即沿相反方向对数组进行遍历。实现这些改进将得到摇摆排序。

4. 选择排序

选择排序在每次迭代中找到值最小的元素,并将其与数组的第一个元素交换。然后从剩余的 $N - 1$ 个元素中选择值最小的元素,并将其与第二个元素交换,依此类推,直到交换最后两个元素。

以下是选择排序的代码实现:

const N = 100;
type Mass = array[1..N] of integer;
procedure SelectSort(var list : Mass);
var i, j, k, temp: integer;
begin
    for i := 1 to N - 1 do
    begin
        k := i;
        temp := list[i];
        for j := i + 1 to N do
        // select the element with the least value
            if list[j] < temp then
            begin
                k := j;
                temp := list[j];
            end;
        list[k] := list[i];
        list[i] := temp;
    end;
end;

SelectSort 算法执行的比较次数不依赖于数组的有序程度,为 $W(N) = \sum_{i = 1}^{N - 1} \sum_{j = i + 1}^{N} 1 = \Theta(N^2)$。尽管冒泡排序和选择排序算法执行的比较操作次数相同,但选择排序的交换操作次数不依赖于数组的有序程度,为 $\Theta(N)$。

5. 希尔排序

前面讨论的插入排序在一次循环中只能将一个元素移动到其正确位置。希尔排序提出了对相隔固定距离的元素进行排列的可能性。

定义 $h$ - 有序数组:如果相隔距离为 $h$ 的元素集合形成一个有序数组,则称数组 list 为 $h$ - 有序数组。希尔排序算法对数组进行多次 $h$ - 排序,$h$ 为递减的值,称为增量,最后一个增量 $h = 1$。整个长度为 $N$ 的数组被视为多个交错的子数组的集合,排序过程归结为多次应用插入排序。

希尔排序的分析较为复杂,基于逆序对的概念。逆序对是指顺序错误的元素对 $(a_i, a_j)$,其中 $a_i > a_j$ 且 $1 \leq i < j \leq N$。例如,在大小为 $N$ 且元素按逆序排列的数组中,逆序对的数量为 $\frac{N(N - 1)}{2}$。插入排序中的每次比较最多消除一个逆序对,而希尔排序中元素的交换可以一次性消除多个逆序对。

对于某些增量序列的复杂度有已知的估计,例如对于增量序列 $1, 3, 7, 15, 31, 63, …$,最坏情况下的渐近复杂度为 $O(N^{3/2})$。普拉特提出的 $h$ 值序列使得算法的复杂度为 $W(N) = \Theta(N(\log_2 N)^2)$,但只有当数组 $N$ 的大小足够大时,使用该序列的优势才会显现出来。

6. 快速排序

快速排序是研究最深入且在实践中应用最广泛的排序算法之一。该算法由霍尔提出,其主要特点是信息处理速度快,这也体现在其名称中。

选择一个枢轴元素 list[v] (有些作者称枢轴元素为分区元素),将数组 list[left..right] 分为两部分:第一部分包含小于或等于所选枢轴元素的元素,第二部分包含大于或等于枢轴元素的元素。即数组元素满足以下性质:
- 对于某个 $v$, list[v] 占据最终位置。
- 对于所有 $k = left, left + 1, …, v$,有 list[k] <= list[v]
- 对于所有 $k = v, v + 1, …, right$,有 list[k] >= list[v]

通过递归应用分区过程,将所有元素放置到最终位置。元素的排列过程如下:引入索引 $i$ 和 $j$,初始值 $i = left$,$j = right$。从左到右扫描数组,增加索引 $i$ 直到遇到 list[i] >= v ,然后从右到左扫描数组,减小索引 $j$ 直到遇到 list[j] <= v 。必要时交换 list[i] list[j] ,直到所有索引相交,即 $i \geq j$。在过程的最后阶段,将 list[j] 与枢轴元素交换。可以通过引入一个屏障 list[right + 1] 来防止索引 $i$ 超出范围,该屏障的值大于或等于 max(list[k])

以下是快速排序的代码实现:

const N = 100;
type Mass = array[1..N + 1] of integer;
function Partition(var list : Mass; left, right : integer): integer;
var i, j, v: integer;
begin
    v := list[left];
    // select the first element
    // as the pivot
    i := left;
    j := right + 1;
    repeat
        repeat j := j - 1 until list[j] <= v;
        repeat i := i + 1 until list[i] >= v;
        swap(list[i], list[j]);
        // elimination
        // of inversion
    until (i >= j);
    swap(list[i], list[j]);
    // cancellation of
    // the last exchange
    swap(list[left], list[j]);
    // placement
    // of the pivot element
    result := j;
end;

procedure QuickSort(var list : Mass; left, right : integer);
var v: integer;
begin
    list[N + 1] := MaxInt;
    // placing a barrier
    if left < right then
    begin
        v := Partition(list, left, right);
        QuickSort(list, left, v - 1);
        QuickSort(list, v + 1, right);
    end;
end;

下面分析快速排序算法的渐近复杂度:
- 最坏情况 :当枢轴元素大于或小于数组的所有元素时,分区将数组分为极不均匀的两部分,分别包含 1 和 $N - 1$ 个元素。每次递归调用排除一个元素,最坏情况下的复杂度为 $W(N) = (N + 1) + N + … + 3 = O(N^2)$,这种情况在数组按所需顺序排序时出现。
- 平均情况 :数组分区过程对 $N$ 个元素的数组进行 $N + 1$ 次比较,对由成对相等元素组成的数组进行 $2 \lfloor \frac{N}{2} \rfloor$ 次比较。如果枢轴元素的索引为 $V$,则递归调用将对长度为 $V - 1$ 和 $N - V$ 的数组进行。相应的递归关系为:
[
\begin{cases}
A(N) = N + 1 + \frac{1}{N} \sum_{i = 1}^{N} (A(i - 1) + A(N - i)) & \text{for } N \geq 2 \
A(0) = A(1) = 0
\end{cases}
]
重写该关系为 $A(N) = N + 1 + \frac{1}{N} \sum_{i = 0}^{N - 1} (A(i) + A(N - i - 1))$,并交换求和顺序可得 $A(N) = N + 1 + \frac{2}{N} \sum_{i = 0}^{N - 1} A(i - 1)$,即 $N A(N) = N(N + 1) + 2 \sum_{i = 0}^{N - 1} A(i)$。

综上所述,不同的排序算法各有优缺点,在实际应用中需要根据数据的特点和需求选择合适的排序算法。例如,对于小规模数据或部分有序的数据,插入排序和冒泡排序可能是不错的选择;而对于大规模数据,快速排序、希尔排序等具有更好的性能。

排序算法全解析(续)

7. 排序算法的复杂度对比与选择建议

为了更直观地对比各种排序算法的复杂度,我们将前面分析的结果汇总成以下表格:
| 排序算法 | 最坏情况复杂度 | 平均情况复杂度 | 最好情况复杂度 | 空间复杂度 | 稳定性 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| 插入排序 | $O(N^2)$ | $O(N^2)$ | $O(N)$ | $O(1)$ | 稳定 |
| 冒泡排序 | $O(N^2)$ | $O(N^2)$ | $O(N)$ | $O(1)$ | 稳定 |
| 选择排序 | $O(N^2)$ | $O(N^2)$ | $O(N^2)$ | $O(1)$ | 不稳定 |
| 希尔排序 | 取决于增量序列,如 $O(N^{3/2})$ 等 | 较复杂,不同增量序列不同 | 较复杂,不同增量序列不同 | $O(1)$ | 不稳定 |
| 快速排序 | $O(N^2)$ | $O(N \log_2 N)$ | $O(N \log_2 N)$ | $O(\log_2 N)$ | 不稳定 |

根据这些复杂度和特点,我们可以给出以下选择排序算法的建议:
- 数据规模较小 :插入排序和冒泡排序实现简单,对于小规模数据,它们的常数因子较小,在实际运行中可能表现不错。而且插入排序在部分有序的数据上性能更好,操作时间短,还具有稳定性,只使用有限额外内存。
- 数据规模较大 :快速排序平均情况下复杂度为 $O(N \log_2 N)$,在大多数情况下性能优异,但最坏情况复杂度较高。希尔排序对于某些增量序列也能达到较好的复杂度,并且它在处理大规模数据时也有不错的表现。
- 对稳定性有要求 :如果需要保证相同键值元素的相对顺序不变,那么插入排序和冒泡排序是合适的选择,因为它们是稳定排序。

8. 排序算法的应用场景举例

排序算法在计算机科学和实际生活中有广泛的应用,以下是一些具体的例子:
- 数据库查询 :在数据库中,经常需要对查询结果进行排序。例如,在一个学生信息数据库中,按照学生的成绩进行排序,以便找出成绩排名靠前的学生。这时可以使用快速排序或其他高效的排序算法来提高查询效率。
- 搜索引擎 :搜索引擎在返回搜索结果时,通常会根据网页的相关性和重要性对结果进行排序。排序算法可以帮助搜索引擎快速地对大量的网页进行排序,提供更准确的搜索结果。
- 游戏开发 :在游戏中,可能需要对角色的属性、得分等进行排序。例如,在一个排行榜系统中,需要对玩家的得分进行排序,显示排名靠前的玩家。选择合适的排序算法可以确保排行榜的实时更新和高效显示。

9. 排序算法的优化思路

虽然各种排序算法都有其基本的实现方式,但在实际应用中,为了提高性能,我们可以对它们进行优化。以下是一些常见的优化思路:
- 插入排序优化 :可以采用二分查找来确定插入位置,减少比较次数。在基本插入排序中,每次插入一个元素时,需要从后往前依次比较,而二分查找可以在已排序的部分中更快地找到插入位置,将比较次数从 $O(N)$ 降低到 $O(\log N)$。
- 快速排序优化
- 随机选择枢轴元素 :为了避免最坏情况的发生,可以随机选择枢轴元素,而不是总是选择第一个元素。这样可以使分区更加均匀,降低最坏情况出现的概率。
- 三数取中法 :选择数组的第一个元素、中间元素和最后一个元素中的中位数作为枢轴元素,也能提高分区的均匀性。
- 小数组使用插入排序 :当子数组的规模较小时,快速排序的递归开销可能较大,此时可以切换到插入排序,因为插入排序在小规模数据上性能较好。

10. 排序算法的流程图

下面是快速排序算法的 mermaid 流程图:

graph TD;
    A[开始] --> B[选择枢轴元素];
    B --> C[分区操作];
    C --> D{分区是否完成};
    D -- 是 --> E[递归处理左子数组];
    D -- 否 --> C;
    E --> F[递归处理右子数组];
    F --> G[结束];

这个流程图展示了快速排序的基本流程:首先选择枢轴元素,然后进行分区操作,将数组分为两部分。如果分区完成,则递归地对左右子数组进行排序,直到整个数组排序完成。

11. 总结

排序算法是计算机科学中非常重要的基础算法,不同的排序算法具有不同的复杂度和特点。在实际应用中,我们需要根据数据的规模、有序程度、是否需要稳定性等因素来选择合适的排序算法。同时,通过对排序算法进行优化,可以进一步提高其性能。希望通过本文的介绍,读者能够对排序算法有更深入的理解,并在实际编程中能够灵活运用这些算法。

排序算法的研究和应用是一个不断发展的领域,随着计算机技术的不断进步,新的排序算法和优化方法也在不断涌现。未来,我们可以期待更高效、更稳定的排序算法的出现,为计算机科学和各个领域的发展提供更强大的支持。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值