文章目录
插入排序
插入排序的思想是:每次将一个待排序的记录按其关键字大小,插入前面已经排好的子序列,直到全部记录插入完成。
由插入排序的思想可以引申出三个排序算法:
- 直接插入排序
- 折半插入排序
- 希尔排序
直接插入排序
假设在排序过程中,待排序表L[1…n]在某次排序过程中的某一时刻状态如下:
有序序列 L[1…i-1] | L(i) | 无序序列 L[i+1…n] |
---|
要将L(i)插入有序子列表中,需要执行以下操作:
- 查找到L(i)要插入的位置k。
- 将L[k…i-1]中所有元素向后移动一个位置。
- 讲L(i)放在L(k)。
执行上述操作 n-1 次就可以将待排序表排好。
插入排序通常采用就地排序(空间复杂度为O(1)),在从后往前的比较过程中,需要反复把已排元素逐步向后挪位,为新元素提供插入空间。
性能分析:
- 空间复杂度:O(1)
- 时间复杂度:O(n2)
- 稳定性:稳定
- 适用性:直接插入排序适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素。
大部分排序算法都仅适用于顺序存储的线性表。
折半插入排序
在直接插入排序中,我们通常是边移动元素,边比较。
折半插入排序将两个动作分开,先折半找到要插入元素的位置,然后统一地移动待插入位置之后的所有元素。
折半查找仅仅减少了元素的比较次数,约为O(nlog2n),该比较次数与待排序列的初始状态无关,取决于表中元素的个数n;而元素的移动次数并未改变,它依赖于待排序表的初始状态。
因此折半查找的时间复杂度仍为O(n2)。对于数据量不大的排序表,折半插入排序具有很好的性能。
性能分析:
- 空间复杂度:O(1)
- 时间复杂度:O(n2)
- 稳定性:稳定
- 适用性:适合于顺序存储的线性表。
希尔排序
由前面的分析可知,如果待排序列为“正序”时,其时间复杂度可提升至O(n),由此可见它更适合于基本有序的排序表和数据量不大的排序表。
希尔排序正是基于这两点分析对直接插入排序进行改进得来的,又称缩小增量排序。
希尔排序的过程:
- 先取一个小于 n 的步长d1,把表中的全部记录分成 d1组,所有距离为d1的倍数的记录放在同一组。
- 在各组中进行直接插入排序。
- 然后取第二个步长d2<d1,
- 重复上述过程,直到dt = 1,即所有记录已经放在同一组中,再进行直接插入排序,由于此时已经有了较好的局部有序性,故可以很快得到结果。
性能分析:
- 空间复杂度:O(1)
- 时间复杂度:希尔排序的时间复杂度依赖于增量序列的函数。当 n 在某个特定范围时,希尔排序的时间复杂度约为O(n1.3)。最坏情况下希尔排序的时间复杂度为O(n2)。
- 稳定性:不稳定
- 适用性:适用于顺序存储的线性表。
交换排序
所谓交换,是根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
考研主要考察其中的冒泡排序和快速排序。
冒泡排序
冒泡排序的过程:
- 从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换它们,直到序列比较完,这是第一趟冒泡。它将最小(或者最大)元素放在了第一个位置(或者最后一个位置)。
- 下一趟冒泡:第一趟冒泡确定的元素不参与排序,从后往前……
- 若第 i 躺冒泡结束,本轮冒泡没有交换元素,则说明表已经有序,冒泡排序结束。
- 这样最多做 n - 1 躺冒泡就能把所有元素排好序。
性能分析:
- 空间复杂度:O(1)
- 时间复杂度:O(n2)
- 稳定性:稳定
冒泡排序中所产生的有序子序列一定是全局有序的,也就是说每趟排序都会将一个元素放在其最终的位置上。
快速排序
快排的过程:
- 在待排序表L[1…n]中任取一个元素pivot作为基准
- 通过一趟排序将待排序表划分为独立的两部分L[1…k-1]和L[k+1…n],使得L[1…k-1]中的元素都小于pivot,L[k+1…n]中的元素都大于pivot,则pivot放到了其最终位置L(k)上,这个过程称为一趟快排。
- 分别对两个子表进行递归重复上述过程,直到每部分只有一个元素或者为空为止,即所有元素放在了其最终位置上。
考研所考察的划分操作,大都选择当前表的第一个元素作为pivot进行划分。
性能分析:
-
空间复杂度:由于快排是递归的,需要借助递归工作栈。
最好情况下为O(log2n);最坏情况下为O(n2),平均情况下为O(log2n)。
-
时间复杂度:快排的运行时间和pivot的选取有关。如果初始待排序表基本有序或者基本逆序,就得到最坏情况O(n2),但是一般情况下快排的时间复杂度为O(nlog2n)。是基于比较的所有内部排序中平均性能最优的算法。
-
稳定性:不稳定。
快排并不产生有序子序列,但每趟排序后会将基准元素放到其最终的位置上。
选择排序
选择排序的基本思想是:每一趟(如第 i 躺)在后面 n - i + 1 个待排序元素中选取关键字最小的元素,作为有序子序列的第 i 个元素,直到第 n - 1 躺做完,排序完成。
选择排序在考研中主要考察简单选择排序和堆排序。
简单选择排序
排序过程:
- 第 i 趟从 L[i…n] 选择关键字最小的元素与 L(i) 交换,每趟排序可以确定一个元素的最终位置
- 这样重复 n - 1 趟就可以使整个待排序表有序。
性能分析:
- 空间复杂度:O(1)
- 时间复杂度:始终是O(n2)
- 稳定性:不稳定
堆排序
对于一个关键字序列L[1…n],将该一维数组视作一颗完全二叉树,若该二叉树中所有根节点都比子节点大,则该关键字序列为大顶堆;若该二叉树中所有根节点都比子节点小,则该关键字序列为小顶堆。
堆排序的思路:
- 首先将L[1…n]建立成初始堆。
- 输出堆顶元素,将堆底元素送到堆顶,此时已经不满足堆的性质。
- 将堆顶元素向下调整使其继续保持堆的性质。
- 再输出堆顶元素,直到堆中仅剩一个元素为止。
堆排序的关键是构造初始堆和调整堆。
构造初始堆
对于 n 个结点的完全二叉树,最后一个分支结点为⌊n/2⌋。
步骤:
-
将以根节点为⌊n/2⌋的子树建堆(调整)。
以大顶堆为例,找到子节点中最大的结点,将该结点与根节点比较,如果比根节点大,则与根节点交换。
-
之后依次对⌊n/2⌋ - 1 ~ 1 为根的子树进行建堆(调整),方法同第 1 步。
交换结点可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该结点为根的子树完全成堆。
-
重复第 2 步,直到根节点。
输出堆顶元素并调整堆
步骤:
- 输出堆顶元素:将堆的最后一个元素与堆顶元素交换。
- 对以堆顶元素为根的树进行调整。
这两个操作重要的步骤是调整堆。
调整堆的时间与树高有关,为O(h)。
建立含 n 个元素的堆,关键字比较的总次数不超过 4n ,时间复杂度为O(n),也就是说可以在线性时间内将无序数组建成一个堆。
同时堆也支持插入删除操作。
插入操作,将新结点放在堆的末尾,再对这个新节点向上执行调整操作。
删除操作,将堆中最后一个元素与目标元素交换,然后对以被交换后的元素为根的树进行调整。
堆排序适合关键字较多的情况。
性能分析:
-
空间复杂度:O(1)
-
时间复杂度:O(nlog2n)
建堆时间为O(n),之后有 n - 1 次向下调整操作,每次调整的时间为O(h),所以最好、最坏和平均情况下,时间复杂度都为O(nlog2n)
-
稳定性:不稳定
归并排序和基数排序
归并排序
“归并”的含义是将两个或者两个以上的有序表合并成一个新的有序表。
排序思想:
- 假定待排序表中含有 n 个有序的子表,每个子表的长度为1
- 然后两两归并,得到 ⌈n/2⌉ 个长度为 2 或 1 的有序表
- 继续两两归并……
- 直到合并成长度为 n 的有序表为止。
这种排序算法称为 2路归并排序。
Merge()的功能是将前后相邻的两个有序表归并为一个有序表。在归并的过程中需要使用到一个长度为 n 的辅助数组。
归并排序基于分治的,整个过程需要⌈log2n⌉趟排序。
性能分析:
- 空间复杂度:O(n)
- 时间复杂度:O(nlog2n)
- 稳定性:稳定
基数排序
基数排序不是一种基于比较的排序方法,而是一种基于关键字各位大小进行排序的方法。
基数排序通常有两种方法:
- 最高位优先MSD
- 最低位优先LSD
通常采用链式基数排序。需要使用 r 个队列。
具体排序过程看王道书p352的示例。
性能分析:
- 空间复杂度:O®
- 时间复杂度:O(d(n+r))
- 稳定性:稳定
以上所介绍的通常认为是内部排序算法。
外部排序
在许多应用中,经常需要对大文件进行排序,因为文件中的记录很多,所以无法将整个文件放在内存中进行排序。因此需要将待排序的记录存储在外存上,排序时再把数据一部分一部分地调入内存进行排序,再排序过程中需要进行多次内存和外存之间的交换,这种排序方法就是外部排序。
因为读写外部磁盘上数据的时间远远超过内存运算的时间,所以在外部排序过程中的时间代价主要考虑访问磁盘的次数。
外部排序通常采用归并排序。
排序过程包括两个阶段:
- 根据内存缓冲区的大小,将外存中上的文件分为若干长度的子文件,依次读入内存并利用内部排序方法对它们排序,排序后得到部分有序的子文件,将它们重新写回外存,这些有序子文件被称为归并段。
- 对这些归并段进行逐趟排序,使归并段(有序子文件)逐渐由小到大,直到得到整个有序文件为止。
外 部 归 并 排 序 的 时 间 = 内 部 排 序 的 时 间 + 外 存 信 息 读 写 的 时 间 + 内 部 归 并 所 需 时 间 外部归并排序的时间 = 内部排序的时间 + 外存信息读写的时间 + 内部归并所需时间 外部归并排序的时间=内部排序的时间+外存信息读写的时间+内部归并所需时间
其中外存信息读写时间远远大于另外两步的时间,所以应该着力减少I/O次数。
为减少I/O次数,我们可以采取的方法有两个:增大归并路数和减少归并段个数。
多路平衡归并与败者树
增大归并路数能减少I/O次数,但是也会导致内部归并的时间增加。这会削弱减少I/O次数所得到的增益,因此不能使用普通的内部归并排序。
由此,我们引入了败者树。
败者树是树形选择排序的一种变体,可视为一颗完全二叉树。k 个叶节点分别存放 k 个归并段在归并过程中参加当前比较的记录,内部结点用来记忆左右子树的“失败者”,而让盛政继续往上进行比较,一直到根节点。(两数中大的数为失败者,小的数位胜利者,则根节点指向的一定是最小的数。)
使用败者树后,内部归并的比较次数与 k 无关了。因此只要内存空间允许,增大归并路数就可以有效地提升外部排序的时间。
置换-选择排序
为了生成更长的归并段,减少归并段的个数。
最佳归并树
文件经过置换选择排序后,得到的是长度不等的初始归并段。
最佳归并书可以合理地组织长度不等的初始归并段的归并序列,使得I/O次数最少。
最佳归并树类似于 k 叉的哈夫曼树。让记录数少的初始归并段先归并,记录多的晚归并,这样就可以得到最佳归并树。
但是和哈夫曼树不同的是,构造最佳归并树过程中,可能会出现归并段不足以至于无法构造严格 k 叉树的情况,所以需要添加长度为 0 的虚段。
如何判断添加几个虚段呢?
设度为 0 的结点有 n0个,度为 k 的结点有 nk个,则对于严格 k 叉树来说,有 n0 = (k - 1)nk + 1
由此可得 nk = (n0 -1) / (k -1)
- 若(n0 -1) % (k -1) = 0,则说明这n0个结点刚好可以构成 k 叉归并树。此时内部结点有 nk个
- 若(n0 -1) % (k -1) = u != 0,则说明有 u 个结点多余,不能包含在 k 叉归并树中。则需要再增加 k - 1 - u 个空归并段,就可以建立归并树。
添加长度为 0 的虚段。
如何判断添加几个虚段呢?
设度为 0 的结点有 n0个,度为 k 的结点有 nk个,则对于严格 k 叉树来说,有 n0 = (k - 1)nk + 1
由此可得 nk = (n0 -1) / (k -1)
- 若(n0 -1) % (k -1) = 0,则说明这n0个结点刚好可以构成 k 叉归并树。此时内部结点有 nk个
- 若(n0 -1) % (k -1) = u != 0,则说明有 u 个结点多余,不能包含在 k 叉归并树中。则需要再增加 k - 1 - u 个空归并段,就可以建立归并树。