Definition
我们熟悉的排序算法,例如:冒泡排序,快速排序等,都是在内存中进行的排序算法。但是当我们需要对于一个非常大的文件进行排序时,这个文件可能包含有数十万条记录,我们无法将这样庞大的文件复制到内存中进行排序。这时,我们需要将排序的记录存储在外存上(例如磁盘和磁带),然后一部分一部分读取进入内存进行排序之后,再存放回外存,这样的排序涉及到了多次的内存和外存之间的数据交换,这样的排序方法就是外部排序。
General Method
通常我们采用**归并排序**来进行外部排序,有两个独立的阶段:
- 内部排序阶段:将 n n n 个记录分为若干个 长度为 h h h 的子文件,依次读入内存中进行内部排序,使得这些子文件内部都是有序的,然后存回内存,这些子文件称为归并段或顺串
- 外部排序阶段:对这些归并段逐趟归并,最终合并成完整的包含 n n n 个记录的有序文件
外部排序阶段 example
例如这里有一个包含 2000 2000 2000 个记录的文件,每个磁盘块包含 250 250 250 个记录,总共 8 8 8 个磁盘块。对这 8 8 8 个磁盘块进行二路归并:每次从磁盘读入两个磁盘块,到内存两个缓冲区中,对这两个磁盘块进行 M e r g e \bold {Merge} Merge 操作:
- 读入两个归并段 R 1 , R 2 R_{1},R_{2} R1,R2 到两个输入缓冲区中
- 进行二路归并,归并出来的对象顺序输出到输出缓冲区中
- 若输出缓冲区对象存满(这是经常发生的,因为我们通常会把内存等分为两个输入缓冲区和一个输出缓冲区,当然第一趟归并段较小,通常不会发生),将其内对象顺序写到输出归并段 R 1 ′ R_{1}' R1′,清空这个输出缓冲区
- 若输入缓冲区对象取空(同样通常不会出现在第一趟中),则从对应的归并段中读取下一块,继续参加归并
- 重复上述过程,直到最后一趟中对两个 1000 1000 1000 记录的归并段归并合成完整的有序文件
整个过程两个阶段的时间复杂度之和为:
t
E
S
=
r
t
I
S
+
d
t
I
O
+
S
(
n
−
1
)
t
m
g
(0)
t_{ES}=rt_{IS}+dt_{IO}+S(n-1)t_{mg}\tag{0}
tES=rtIS+dtIO+S(n−1)tmg(0)
其中:
- r t I S rt_{IS} rtIS:对所有初始归并段进行内部排序的时间, r r r 时初始归并段的个数, t I S t_{IS} tIS 即 Internal Sort Time 内部排序时间
- d t I O dt_{IO} dtIO: d d d 是访问外存的次数, t I O t_{IO} tIO 是每个块的存取时间,即总的 IO 时间
- S ( n − 1 ) t m g S(n-1)t_{mg} S(n−1)tmg:内部归并总时间, S S S 是归并趟数, n n n 是每趟参加 2 路归并的记录个数, t m g t_{mg} tmg 是每做一次内部排序时,取得关键字最小的记录的时间,即进行比较获得输出到输出缓冲区的下一个记录所花费的时间
而显然,磁盘存取时间时显著大于内部排序和内部归并的时间,因此要提高外部排序的速度,需要减少访问外存的次数。外存上信息的读写是以“物理块”为单位的,例如例子中,总共有
8
8
8 个物理块,则一趟需要进行
8
8
8 次读和
8
8
8 次写,总共
16
16
16 次,三趟归并加上对初始归并段进行内部排序需要进行的读写,总共
16
×
4
=
64
16\times4=64
16×4=64 次读写,则例子的2路平衡归并排序的总时间为:
t
E
S
=
8
×
t
I
S
+
64
×
t
I
O
+
3
×
1999
t
m
g
(1)
t_{ES}=8\times t_{IS}+64\times t_{IO}+3\times1999t_{mg}\tag{1}
tES=8×tIS+64×tIO+3×1999tmg(1)
如果对上述例子进行 4 4 4 路归并排序,则只需要两趟即可,这时总共的读写次数就是 ( 2 + 1 ) × 16 = 48 (2+1)\times16=48 (2+1)×16=48 次。也就是说,我们可以通过增大归并路数,减少归并趟数,从而减少外存读取次数,显著改善外部排序时间。同理,减少归并的段数,即增大初始归并段的大小,也可以减少归并趟数,缩短外部排序时间。
Moreover
多路归并和败者树
增加归并路数可以减少归并趟数,但是同时也使得内部归并的时间增加(即每次需要从更多的路数中挑选出关键字最小的记录)。当路数为
m
m
m 时,挑选记录需要的比较次数为
m
−
1
m-1
m−1 次。则每趟
n
n
n 个元素需要进行
(
n
−
1
)
(
m
−
1
)
(n-1)(m-1)
(n−1)(m−1) 次比较,
S
S
S 趟归并总共需要的比较次数为:
S
(
n
−
1
)
(
m
−
1
)
=
[
log
m
r
]
(
n
−
1
)
(
m
−
1
)
=
[
log
r
]
(
n
−
1
)
m
−
1
log
m
(2)
S(n-1)(m-1)=[\log_{m}r](n-1)(m-1)=[\log r](n-1)\frac{m-1}{\log m}\tag{2}
S(n−1)(m−1)=[logmr](n−1)(m−1)=[logr](n−1)logmm−1(2)
其中,
[
log
r
]
(
n
−
1
)
[\log r](n-1)
[logr](n−1) 是常数,而
(
m
−
1
)
/
log
m
(m-1)/\log m
(m−1)/logm 随着
m
m
m 的增大而增长,这将降低减少外存访问次数带来的效益,因此我们需要改进内部排序算法。
败者树
为了使得内部归并不受到
m
m
m 增大的影响,引入败者树。是一种树形选择排序的变体,可以看成一棵完全二叉树,结点存储的是每次比较的败者,也就是说,我们保存了上次比较的结果,因为每次我们选出一个记录之后,在新的一组候选记录中,其中
m
−
1
m-1
m−1 个记录是和上次相同的,也就是上一轮的败者,这样当我们用一棵完全二叉树的每个结点记录这些败者,这时新的记录只需要向上进行比较,最终只需经过
log
m
\log m
logm 次比较即可找到胜者,即关键字最小的记录,输出到缓冲区。这时,内部排序的比较时间为:
S
(
n
−
1
)
[
log
m
]
=
[
log
r
]
m
(
n
−
1
)
[
log
m
]
=
[
log
r
]
(
n
−
1
)
(3)
S(n-1)[\log m]=[\log_{r}]m(n-1)[\log m]=[\log r](n-1)\tag{3}
S(n−1)[logm]=[logr]m(n−1)[logm]=[logr](n−1)(3)
这时比较次数和
m
m
m 无关。此时只要内存允许,通过增大路数可以显著改善外部排序的时间。不过,如果内存固定,增大路数自然就会减少每一路对应的输入缓冲区的大小,这样,需要更加频繁的外存读取,反而会增大读写外存的次数