一、查找
1.1 查找表
由同一类型的数据元素构成的集合,称为查找表。查找表中的关键字表示了一个数据项,而关键字的取值称为键值。在查找表中找出确定键值等于给定值的数据元素,称为查找。查找表的结点类型定义为
struct records{
keytype key;
fields other;
};
当查找基于关键字比较时,查找分为顺序查找、折半查找、分块查找、BST、AVL、B-树和B+树;当查找基于关键字储存位置时,为散列法;当查找基于数据集合储存位置时,分为内查找与外查找;当查找基于数据集合的改变时,分为静态查找与动态查找。
面向查找的数据结构称为查找表结构,分为线性表、树表、散列表。查找表的结构决定了查找方法。
查找算法的时间性能由关键字的比较次数度量,其与问题规模与关键字在集合的位置有关。给定值与关键字比较次数的期望值称为平均查找长度【Average Search Length,ASL】。
查找表的操作通常包括:
-
S
e
a
r
c
h
(
k
,
F
)
Search(k, F)
Search(k,F),在数据集合
F
F
F中查找键值为
k
k
k的记录,并在成功时返回记录位置,否则返回特定值;
-
I
n
s
e
r
t
(
R
,
F
)
Insert(R, F)
Insert(R,F),在动态环境下在
F
F
F中查找键值为
R
R
R的记录,如果不存在,则插入;
-
D
e
l
e
t
e
(
k
,
F
)
Delete(k, F)
Delete(k,F),在动态环境下在
F
F
F中查找键值为
k
k
k的记录,如果存在,则删除;
1.2 线性查找
线性查找也称为顺序查找,其在线性表的一端开始,顺序的扫描线性表,依次将扫描到的结点关键字与给定值比较。线性查找既适用于线性表的顺序存储结构,也适用于线性表的链式存储结构,分别适合静态与动态查找。
在顺序表上的查找适用于静态查找,在链表上的查找适用于动态查找,其性能为
A
S
L
=
{
(
n
+
1
)
/
2
,
s
u
c
c
e
s
s
n
+
1
,
f
a
i
l
u
r
e
ASL = \left\{\begin{aligned} &(n + 1)/2, &&success\\ &n + 1, &&failure\\ \end{aligned}\right.
ASL={(n+1)/2,n+1,successfailure故
T
(
n
)
=
O
(
n
)
T(n) = O(n)
T(n)=O(n)。
1.3 折半查找
折半查找也称为二分查找,其要求查找表按照顺序式存储结构,并按关键字有序。折半查找仅适用于静态查找。其基本思想为在有序表中取中间记录比较,并根据比较结果取左或右半区继续查找,直到成功或结束。考虑给定值
k
k
k与数据规模
n
n
n折半查找的非递归算法步骤如下
1.初始化
l
o
w
=
0
low = 0
low=0,
u
p
=
n
up = n
up=n;
2.令
m
i
d
=
(
l
o
w
+
u
p
)
/
2
mid = (low + up) / 2
mid=(low+up)/2;
3.比较
k
k
k与
F
[
m
i
d
]
.
k
e
y
F[mid].key
F[mid].key,有:
3.1.
F
[
m
i
d
]
.
k
e
y
=
k
F[mid].key = k
F[mid].key=k,查找成功;
3.2.
F
[
m
i
d
]
.
k
e
y
>
k
F[mid].key > k
F[mid].key>k,令
u
p
=
m
i
d
−
1
up = mid - 1
up=mid−1;
3.3.
F
[
m
i
d
]
.
k
e
y
<
k
F[mid].key < k
F[mid].key<k,令
l
o
w
=
m
i
d
+
1
low = mid + 1
low=mid+1;
4.迭代2-3,直到查找成功或
l
o
w
>
u
p
low > up
low>up,查找失败。
折半查找的过程可以用二叉树来描述,树的每个结点对应有序表的一个记录,结点的值为键值,则该树称为折半查找判定树。判定树的根为
m
i
d
mid
mid的记录,其左子树为左半区表的判定树,右子树亦然。
根据判定树,折半查找的性能为
A
S
L
=
{
l
o
g
2
(
n
+
1
)
−
1
,
s
u
c
c
e
s
s
l
o
g
2
(
n
+
1
)
,
f
a
i
l
u
r
e
ASL = \left\{\begin{aligned} &log_2(n+1) - 1, &&success\\ &log_2(n + 1), &&failure\\ \end{aligned}\right.
ASL={log2(n+1)−1,log2(n+1),successfailure故
T
(
n
)
=
O
(
l
o
g
2
n
)
T(n) = O(log_2n)
T(n)=O(log2n)。
1.4 分块查找
分块查找要求表中的元素分为若干块,每一块内的元素无序,而块之间的元素有序。建立一个线性表,用以存放每块中最大或最小的关键字,称为索引表。那么分块查找的基本思想为根据索引表查找给定值可能出现的块,并在块内顺序寻找。使用索引表的分块查找置适合静态查找。若将表分为平均长度为
L
L
L的块,则其性能为
A
S
L
=
(
n
/
L
+
L
)
/
2
+
1
ASL = (n / L + L)/2 + 1
ASL=(n/L+L)/2+1故
T
(
n
)
=
O
(
n
/
L
,
L
)
T(n) = O(n/L, L)
T(n)=O(n/L,L)。当
L
=
n
1
/
2
L = n^{1/2}
L=n1/2时取得最小值。
考虑使用索引表保存下标范围,存放在不同的向量中,并组织成链表,就得到了带索引表的链表,实现了动态坏境的分块查找。
1.5 二叉查找树
二叉查找树【Binary Search Tree,BST】是一颗二叉树,其或为空树,或是左子树的所有结点均小于根结点的键值,而右子树的所有结点均大于根结点的键值,并且子树也是二叉查找树。
考虑BST
F
F
F,其结点的值
F
d
Fd
Fd及给定值
k
k
k,BST查找的递归算法步骤如下
1.若
F
=
N
U
L
L
F = NULL
F=NULL,查找失败;
2.若
F
d
=
k
Fd = k
Fd=k,查找成功;
3.若
F
d
>
k
Fd > k
Fd>k,则递归的对
F
F
F的左子树进行BST查找;
4.若
F
d
<
k
Fd < k
Fd<k,则递归的对
F
F
F的右子树进行BST查找;
当进行动态查找插入时,若BST为空,则插入的结点一定是根结点;否则,因为查找失败,与之比较的一定是叶结点,故插入的结点也一定是叶结点。
当进行动态查找删除时,考虑删除结点
p
p
p,其实现步骤如下:
1.若结点
p
p
p是叶结点,则删除;
2.若结点
p
p
p有且仅有一棵子树
p
s
ps
ps,则删除
p
p
p,并令
p
s
ps
ps继承;
3.若结点
p
p
p有两棵子树,则:
3.1.查找结点
p
p
p的右子树的最左下结点
s
s
s及其父结点
s
p
r
spr
spr;
3.2.将
s
s
s的数据域替换到
p
p
p的数据域;
3.3.若
p
r
pr
pr无左子树,则
s
s
s的右子树成为
s
p
r
spr
spr的右子树;否则,
s
s
s的右子树称为
s
p
r
spr
spr的左子树;
3.4.删除结点
s
s
s。
二叉排序树的查找性能取决于二叉排序树的形态,在
O
(
l
o
g
2
n
)
O(log_2n)
O(log2n)与
O
(
n
)
O(n)
O(n)之间。
1.6 AVL树
为了保证BST的高度为
l
o
g
2
n
log_2n
log2n,从而保证BST的基本操作在最坏情况下的时间均为
O
(
l
o
g
2
n
)
O(log_2n)
O(log2n),提出了平衡的BST,即AVL【Adelson-Landis-Velsky】树,其是或为空树,或是具有如下性质的BST:
-根结点的左右子树高度之差的绝对值不超过1;
-根结点的子树依然是ALV树。
一个结点的左右子树的高度之差称为平衡因子【Balanced Factor,BF】,那么AVL树的结点的BF尽可能为-1,0和1。
向AVL树插入节点可能造成不平衡,此时要调整树的结构,采用平衡旋转技术,如下情况可能导致不平衡:
-新结点
Y
Y
Y被插入到
A
A
A的左子树
A
l
Al
Al的左子树上,记为LL,要使
A
l
Al
Al的左右子结点分别是
Y
Y
Y与
A
A
A;
-新结点
Y
Y
Y被插入到
A
A
A的右子树
A
r
Ar
Ar的右子树上,记为RR,要使
A
r
Ar
Ar的左右子结点分别是
A
A
A与
Y
Y
Y;
-新结点
Y
Y
Y被插入到
A
A
A的左子树
A
l
Al
Al的右子树上,记为LR,要使
Y
Y
Y的左右结点分别是
A
l
Al
Al与
A
A
A;
-新结点
Y
Y
Y被插入到
A
A
A的右子树
A
r
Ar
Ar的左子树上,记为RL,要是
Y
Y
Y的左右结点分别是
A
A
A与
A
r
Ar
Ar。
AVL树的插入与删除操作基于二叉查找树,其插入时间的最坏情况为
O
(
l
o
g
2
n
)
O(log_2n)
O(log2n)。
1.7 B树
当查找表的大小超过内存容量时,必须从磁盘等辅助设备读取查找树结构中的节点,每次只能根据需要读取一个结点,故AVL树的性能在这种情况下不高。
一颗m-路查找树或为空树,或是具有如下性质的树:
-根节点至多有m棵子树,且有如下结构
n
,
A
0
,
(
K
1
,
A
1
)
,
(
K
2
,
A
2
)
,
.
.
.
,
(
K
i
,
A
i
)
,
.
.
.
,
(
K
n
,
A
n
)
n, A_0, (K_1, A_1), (K_2, A_2), ..., (K_i, A_i),..., (K_n, A_n)
n,A0,(K1,A1),(K2,A2),...,(Ki,Ai),...,(Kn,An)其中,
A
i
A_i
Ai是指向子树的指针,
K
i
K_i
Ki是关键字值且有
K
i
<
K
i
+
1
K_i < K_{i+1}
Ki<Ki+1。
-子树
A
i
A_i
Ai中所有关键字值有
K
i
<
K
<
K
i
+
1
K_i < K < K_{i+1}
Ki<K<Ki+1;
-子树
A
n
A_n
An中所有关键字值由
K
>
K
n
K > K_n
K>Kn;
-子树
A
0
A_0
A0中所有关键字值由
K
<
K
1
K < K_1
K<K1;
-每颗子树
A
i
A_i
Ai也是一颗m-路查找树。
高为
h
h
h的m-路查找树的结点个数为
∑
i
=
0
h
−
1
m
i
=
(
m
h
−
1
)
/
(
m
−
1
)
\sum_{i=0}^{h-1}m^i = (m^h - 1)/(m-1)
∑i=0h−1mi=(mh−1)/(m−1),而每个结点的最大关键字为
m
−
1
m - 1
m−1个,故m-路查找树的最大关键字个数为
m
h
−
1
m^h - 1
mh−1。
在查找过程中,待查关键字不在树中所能达到的结点称为失败结点,而在树中连接失败结点的称为终端结点。那么一颗B-树或为空树,或是具有如下性质的m-路查找树:
-树的每个结点至多有m棵子树;
-根结点至少有2课子树;
-除了根节点和失败结点,所有结点至少有
⌈
m
/
2
⌉
\lceil m/2 \rceil
⌈m/2⌉棵子树;
-所有失败结点都位于同一层。
高为
h
h
h的m-路B-树除了根节点的结点个数为
2
∑
i
=
0
h
−
2
⌈
m
/
2
⌉
i
=
2
(
⌈
m
/
2
⌉
h
−
1
−
1
)
/
(
m
−
1
)
2\sum_{i=0}^{h-2}\lceil m/2 \rceil^i = 2(\lceil m/2 \rceil^{h-1} - 1)/(m-1)
2∑i=0h−2⌈m/2⌉i=2(⌈m/2⌉h−1−1)/(m−1),那么最大关键字个数为
2
⌈
m
/
2
⌉
h
−
1
−
1
2\lceil m/2 \rceil^{h-1} - 1
2⌈m/2⌉h−1−1,故如果有
N
N
N个关键字,那么
h
−
1
≤
l
o
g
⌈
m
/
2
⌉
(
(
N
+
1
)
/
2
)
h - 1 \le log_{\lceil m/2 \rceil}((N + 1)/2)
h−1≤log⌈m/2⌉((N+1)/2)由于树的第
h
h
h层至少有
2
⌈
m
/
2
⌉
h
−
2
2\lceil m/2 \rceil^{h-2}
2⌈m/2⌉h−2个节点,故失败节点至少有
2
⌈
m
/
2
⌉
h
−
1
2\lceil m/2 \rceil^{h-1}
2⌈m/2⌉h−1个。因此失败节点的个数至少为
N
+
1
N+1
N+1,实际上,在任何B-树中均成立。
提高B-树的阶数m,可以减少树的高度,从而减少读入结点的次数,从而减少读磁盘的次数;然而,m过大而超过工作区容量时,结点不能一次读入内存,反而增加了读盘次数。故B-树的阶数应该找到找到关键字的时间总量达到最小。
B-树的查找操作是顺指针查找结点和结点之间查找交替进行的过程,其查找时间与阶数及高度直接有关,需要加以权衡。
B-树从空树起,逐个插入关键字而成,在m阶B-树中,每个非失败结点的关键字取值在
[
⌈
m
/
2
⌉
−
1
,
m
−
1
]
[\lceil m/2 \rceil-1, m-1]
[⌈m/2⌉−1,m−1]之间,B-的插入操作需要首先执行查找操作以确定插入的新关键字的终端结点。如果在关键字插入后,终端结点的关键字个数超出了上界,需要进行分裂;否则可以直接插入。
结点分裂的原则是当结点的关键字数超过上界时的状态为
m
,
A
0
,
(
K
1
,
A
1
)
,
(
K
2
,
A
2
)
,
.
.
.
,
(
K
m
,
A
m
)
m, A_0, (K_1, A_1), (K_2, A_2),..., (K_m, A_m)
m,A0,(K1,A1),(K2,A2),...,(Km,Am)那么这时,结点需要分裂为如下两个结点
⌈
m
/
2
⌉
−
1
,
A
0
,
(
K
1
,
A
1
)
,
(
K
2
,
A
2
)
,
.
.
.
,
(
K
⌈
m
/
2
⌉
−
1
,
A
⌈
m
/
2
⌉
−
1
)
m
−
⌈
m
/
2
⌉
,
A
⌈
m
/
2
⌉
,
(
K
⌈
m
/
2
⌉
+
1
,
A
⌈
m
/
2
⌉
+
1
)
,
.
.
.
,
(
K
m
−
⌈
m
/
2
⌉
,
A
m
−
⌈
m
/
2
⌉
)
\lceil m/2 \rceil - 1, A_0, (K_1, A_1), (K_2, A_2),..., (K_{\lceil m/2 \rceil - 1}, A_{\lceil m/2 \rceil - 1})\\ m - \lceil m/2 \rceil, A_{\lceil m/2 \rceil}, (K_{\lceil m/2 \rceil+1}, A_{\lceil m/2 \rceil+1}), ..., (K_{m-\lceil m/2 \rceil}, A_{m-\lceil m/2 \rceil})
⌈m/2⌉−1,A0,(K1,A1),(K2,A2),...,(K⌈m/2⌉−1,A⌈m/2⌉−1)m−⌈m/2⌉,A⌈m/2⌉,(K⌈m/2⌉+1,A⌈m/2⌉+1),...,(Km−⌈m/2⌉,Am−⌈m/2⌉)且位于中间的关键字
K
⌈
m
/
2
⌉
K_{\lceil m/2 \rceil}
K⌈m/2⌉与指向新节点的指针形成的二元组需要插入到父结点中。在插入二元组之前,两个新形成的结点要写入磁盘。
B-树的删除操作需要找到关键字所在的结点,从结点删去关键字。若该结点是非叶结点,需要找到大于该关键字的子树中最小的关键字或小于该关键字的子树中最大的关键字来替换该节点,然后再进行叶结点删除。
叶节点的删除包括4种情况:
-被删除关键字所在的叶结点也是根结点,若删除关键字后结点还有至少一个关键字,则直接删除并写磁盘;否则,B-树则为空;
-被删除关键字在被删除后,所在的叶结点至少还有
⌈
m
/
2
⌉
−
1
\lceil m/2 \rceil-1
⌈m/2⌉−1个关键字,则直接删除并写磁盘;
-被删除关键字在被删除后,所在的叶结点还有
⌈
m
/
2
⌉
−
2
\lceil m/2 \rceil-2
⌈m/2⌉−2个关键字,而邻近结点还有至少
⌈
m
/
2
⌉
\lceil m/2 \rceil
⌈m/2⌉个关键字,则在删除后将两个关键字平衡,并随之调整父结点,然后更新并写磁盘;
-被删除关键字在被删除后,所在的叶结点还有
⌈
m
/
2
⌉
−
2
\lceil m/2 \rceil-2
⌈m/2⌉−2个关键字,而邻近结点都只有
⌈
m
/
2
⌉
−
1
\lceil m/2 \rceil - 1
⌈m/2⌉−1个关键字,则在删除后将两个结点合并,并随之调整父结点,然后更新并写磁盘;
B+树可以看作B-树的变形,其在是新文件索引结构方面比B-树使用的更加普遍。B+树与B-树的差异在于:
-有k个子结点的结点必然有k个关键字;
-所有叶节点包含了全部关键字的信息,并包含指向关键字记录的指针,叶结点本身依照关键字大小顺序链接;
-所有非终结结点都是索引部分,结点仅含子树的最大或最小关键字。
因此,B+树上有两个头指针,分别指向根结点与关键字最小的叶结点。
1.8 散列技术
用判定树可以证明,基于关键字比较的查找的性能为
Ω
(
l
o
g
2
n
)
\Omega(log_2n)
Ω(log2n),要突破下界,就不能仅依赖于基于比较来进行查找。在键值与储存位置之间建立一个确定的映射关系,关键字的值在这种映射关系下的像,就是相应记录在表中的存储位置,是散列技术的基本思想。理想情况下,散列技术的查找的期望性能为
O
(
1
)
O(1)
O(1)。
设
U
U
U表示所有可能出现的关键字集合,
K
K
K表示实际储存的关键字集合,即
K
⊆
U
K \subseteq U
K⊆U,
F
F
F是具有
B
=
O
(
∣
K
∣
)
B = O(|K|)
B=O(∣K∣)个元素的数组,则从
U
U
U到表
F
F
F下标集合上的映射
U
↦
{
0
,
1
,
.
.
.
,
B
−
1
}
U \mapsto \{0, 1, ..., B - 1\}
U↦{0,1,...,B−1}称为散列函数,也成为哈希【Hash】函数。数组
F
F
F称为散列表,也成为哈希表,
F
F
F中的单元称为桶。对于
u
∈
U
u \in U
u∈U,函数值
h
(
u
)
h(u)
h(u)称为
u
u
u的散列地址。将结点按关键字的散列地址储存到散列表中的过程称为散列。
不同的关键字具有相同的散列地址,称为散列冲突,发生冲突的两个关键字称为同义词。
散列函数的构造原则包括计算简单,防止计算量降低查找效率;分布均匀,减少冲突,保证储存空间的有效利用。
直接定址法是一种简单而直接的构造方法,其散列函数为
h
(
k
)
=
a
k
+
b
h(k) = ak + b
h(k)=ak+b其适用于已知关键字值,并且关键字的取值集合不大且连续。
质数除余法是一种简单而常用的构造方法,其散列函数为
h
(
k
)
=
k
%
m
h(k) = k \% m
h(k)=k%m其中,
m
m
m一般选择不大于表长的最大质数。其不需要事先直到关键字的分布。
平方取中法取
k
2
k^2
k2的中间的几位数作为散列地址,其结果通常依赖于关键字的所有字符而较少了冲突的可能性。但是要设置比例因子,限制地址的越界。
折叠法适用于位数较多的关键字,其将位分割成位数相同的若干段,然后将各段叠加作为散列地址。
开放定址法可以在一定程度上解决散列的冲突问题,其基本思想为在冲突发生时,使用某种探测技术在散列表中形成一个探测序列,沿着序列逐个单元查找,直到找到给定的关键字,或开放地址即空地址,或既未找到给定关键字又没有碰到开放地址为止。用开放定址法处理冲突的散列表称为闭散列表。
线性探测法是开放寻址法的一种探测技术,其在冲突位置的下一个位置依次寻找开放地址。对于散列函数
h
(
k
)
h(k)
h(k),开放定址法的散列地址为
h
=
(
h
(
k
)
+
d
)
%
B
h = (h(k) + d)\%B
h=(h(k)+d)%B其中,
d
=
1
,
2
,
.
.
.
,
B
−
1
d = 1, 2, ..., B-1
d=1,2,...,B−1且使得
h
h
h为开放地址。考虑长为10的数组
{
47
,
7
,
29
,
11
,
16
,
92
,
22
,
8
,
3
}
\{47, 7, 29, 11, 16, 92, 22, 8, 3\}
{47,7,29,11,16,92,22,8,3}散列函数为
h
(
k
)
=
k
%
11
h(k) = k\%11
h(k)=k%11,那么数组的散列地址分别为
{
3
,
7
,
7
,
0
,
5
,
4
,
0
,
8
,
3
}
\{3, 7, 7, 0, 5, 4, 0, 8, 3\}
{3,7,7,0,5,4,0,8,3}这时会出现一种情况,按照数组的次序,
h
(
7
)
=
7
h(7) = 7
h(7)=7,
h
(
29
)
=
7
h(29) = 7
h(29)=7,而此时散列地址为8的数据为空,故29的散列地址将顺延为8,但随后,
h
(
8
)
=
8
h(8) = 8
h(8)=8,使得非同义词之间对同一个散列地址发生了冲突,称为堆积。
此外,还有线性补偿探测法,
d
=
c
,
2
c
,
.
.
.
d = c, 2c, ...
d=c,2c,...;二次补偿法,
d
=
1
,
−
1
,
4
,
−
4
,
.
.
.
,
q
2
,
−
q
2
,
q
≤
B
/
2
d = 1, -1, 4, -4, ..., q^2, -q^2, q \le B/2
d=1,−1,4,−4,...,q2,−q2,q≤B/2;随机探测法,
d
∈
{
1
,
2
,
.
.
.
,
B
−
1
}
r
a
n
d
o
m
d \in \{1, 2, ..., B - 1\}_{random}
d∈{1,2,...,B−1}random且插入、删除、查找均使用同一个随机序列。
带溢出表的内散列法可以在一定程度上解决散列的冲突问题,其扩充了散列表的桶,每个桶包含主表元素与指向溢出表的指针,但其空间利用率不高。
链地址法将所有散列值地址相同的记录储存在一个单链表中,形成同义词子表,散列表储存了同义词子表的表头。
二、内部排序
内部排序指数据对象全部在内存中的排序,其目的是方便查询与处理。
2.1 气泡排序
气泡排序的基本思想为将待排序的记录看作是竖着排列的气泡,键值较小的记录比较轻,从而要往上浮;在遍历所有关键字一次后,最小的关键字就在最高位置;遍历二次后,次小的关键字就在次高位置;直到排序完成。其算法步骤为
1.按次序依次比较前后元素的大小,并在前一元素大于后一元素时互换元素;
2.在第n次比较后,前n个元素排序完成,从第n个元素重新迭代步骤1,直到排序完成。
当出现两个元素需要对换位置时,需要进行3次移动。那么算法在最好情况下移动了0次,最坏的情况下移动了
3
∑
i
=
1
n
−
1
(
n
−
i
)
=
3
n
(
n
−
1
)
/
2
3\sum_{i=1}^{n-1}(n - i) = 3n(n-1)/2
3∑i=1n−1(n−i)=3n(n−1)/2次,气泡排序的时间复杂度
T
(
n
)
=
O
(
n
2
)
T(n) = O(n^2)
T(n)=O(n2)。
气泡排序仅使用固定的空间,空间复杂度
S
(
n
)
=
O
(
1
)
S(n) = O(1)
S(n)=O(1)。
2.2 快速排序
快速排序采用分治策略,通过一趟排序将要排序的数据分割为独立的两部分,一部分的所有数据比另外一部分所有数据都要小,并递归的快速排序。设需要排序的无序区为
A
[
i
]
,
.
.
.
,
A
[
j
]
A[i], ..., A[j]
A[i],...,A[j],那么算法步骤为
1.选取其中一个记录的关键字
v
v
v作为基准;
2.将无序区划分为两部分,其中左部分的键值都小于
v
v
v,右部分的键值都大于
v
v
v;
3.对每个部分递归快速排序,直到区域内的所有元素一致;
基准元素的选取决定了快速排序的性能。最佳情况下,每次都能将表划分为规模相等的两部分,一般的查找方法为从
A
[
i
]
A[i]
A[i]到
A
[
j
]
A[j]
A[j]依次查找,找到两个不同关键字中的最大者。这样定义的FindPivot()既可以找到基准点,又可以在整个区域的所有元素一致时给出标志。
快速排序的最好情况为每次划分点的左右侧子表长度都相同,且不需要排序,其时间复杂度
T
(
n
)
=
O
(
n
l
o
g
2
n
)
T(n) = O(nlog_2n)
T(n)=O(nlog2n);空间上需要额外的空间储存划分的区域,空间复杂度则为
S
(
n
)
=
O
(
l
o
g
2
n
)
S(n) = O(log_2n)
S(n)=O(log2n)。
最坏的情况下,每次划分都只得到了比上次划分的区域少一个的子序列,其时间复杂度
T
(
n
)
=
O
(
n
2
)
T(n) = O(n^2)
T(n)=O(n2),空间复杂度则为
S
(
n
)
=
O
(
n
)
S(n) = O(n)
S(n)=O(n)。
2.3 选择排序
选择排序的主要操作是选择,其在遍历所有关键字依次后选出键值最小的记录,添加到有序序列中。选择排序与气泡排序的不同在于选择排序不需要立即进行交换操作,而是在每次遍历结束后进行交换。其算法步骤为
1.按次序依次比较元素大小,找到最大的元素,与无续区间的首个元素交换位置;
2.在第n次比较后,前n个元素排序完成,从第n个元素重新迭代步骤1,直到排序完成。
选择排序与冒泡排序相似,时间复杂度
T
(
n
)
=
O
(
n
2
)
T(n) = O(n^2)
T(n)=O(n2),空间复杂度
S
(
n
)
=
O
(
1
)
S(n) = O(1)
S(n)=O(1)。
2.4 堆排序
堆排序是对选择排序的改进,改进的点在于减少关键字之间的比较次数。堆是一种特殊形式的完全二叉树,其任意一个结点都不小于其子结点。堆排序的基本思想为将待排列的序列组织为完全二叉树,并构建一个堆,并在一次将最大或最小的值移走后调整成堆,直到堆里只有一个记录。
堆排序的实现步骤为
1.初始化堆;
2.将堆顶元素,取出最小元素;
3.将剩余的元素整理成堆;
4.迭代2~3,则去除完毕记为不增顺序的有序序列。
其中,整理堆的步骤如下:
1.将根结点与子结点比较,若根结点比子结点大,则与较小者交换,左子结点有限;
2.对所有非叶结点迭代1操作,直到二叉树成为堆。
整理堆的时间复杂度为
T
(
n
)
=
O
(
l
o
g
2
n
)
T(n) = O(log_2n)
T(n)=O(log2n),堆排序的时间复杂度
T
(
n
)
=
O
(
n
l
o
g
2
n
)
T(n) = O(nlog_2n)
T(n)=O(nlog2n),空间复杂度
S
(
n
)
=
O
(
1
)
S(n) = O(1)
S(n)=O(1)。
2.5 插入排序
插入排序的主要操作时插入,其基本思想为将一个待排序的记录按关键字的大小插入到已经有序的序列,直到全部排好位置。其算法步骤为
1.顺序比较要插入的元素与有序序列,直到插入元素使得序列依然有序;
2.迭代1,直到排序完成。
插入排序与冒泡排序相似,时间复杂度
T
(
n
)
=
O
(
n
2
)
T(n) = O(n^2)
T(n)=O(n2),空间复杂度
S
(
n
)
=
O
(
1
)
S(n) = O(1)
S(n)=O(1)。
2.6 希尔排序
希尔【Shell】排序是对插入排序的改进,改进基于:
-待排序记录按键值基本有序时,直接插入排序的效率大大提高;
-由于直接插入排序算法简单,在待排序记录数量n较小时效率很高。
因此,希尔排序的基本思想为将整个序列划分为若干个子序列,在子序列里分别进行直接插入排序,使得整个序列基本有序,再对全体记录进行插入排序。其算法步骤为
1.初始化
d
=
⌈
n
/
2
⌉
d = \lceil n/2 \rceil
d=⌈n/2⌉,其中
n
n
n为序列元素的数量;
2.将序列划分为
d
d
d组,对每组进行插入排序,并使得
d
=
⌈
d
/
2
⌉
d = \lceil d/2 \rceil
d=⌈d/2⌉;
3.迭代2,直到
d
=
1
d = 1
d=1,完成排序。
经研究表明,希尔排序的时间性能在
O
(
n
2
)
O(n^2)
O(n2)到
O
(
n
l
o
g
2
n
)
O(nlog_2n)
O(nlog2n)之间,约为
O
(
n
1.3
)
O(n^{1.3})
O(n1.3)。
2.7 归并排序
归并是将两个或两个以上的有序序列合并成一个有序序列的过程。归并排序的主要操作就是归并,其主要思想为将若干个有序序列逐步归并,最终得到一个有序序列。
二路归并的算法步骤为:
-1.查找两个有序序列的最前一个元素,将最小的元素取出;
-2.迭代1,直到所有的元素取出,得到的序列就是有序序列。
那么归并排序算法的步骤为:
1.将具有n个元素的待排序序列看成n个长度为1的有序序列;
2.对于n个序列,每两个序列进行归并,形成
⌈
n
/
2
⌉
\lceil n/2 \rceil
⌈n/2⌉个有序序列。令
n
=
⌈
n
/
2
⌉
n = \lceil n/2 \rceil
n=⌈n/2⌉;
3.迭代2,直到
n
=
1
n = 1
n=1,完成排序。
归并算法的每一迭代归并操作需要
O
(
n
)
O(n)
O(n)的时间,并需要进行
⌈
l
o
g
2
n
⌉
\lceil log_2n \rceil
⌈log2n⌉次迭代,故其时间性能为
T
(
n
)
=
O
(
n
l
o
g
2
n
)
T(n) = O(nlog_2n)
T(n)=O(nlog2n),而归并排序需要额外的空间记录归并序列,其空间性能为
S
(
n
)
=
O
(
n
)
S(n) = O(n)
S(n)=O(n)。
2.8 基数排序
基数排序是一种线性时间性能的排序,其不比较关键字,而根据构成关键字的分量的值,排列记录顺序。其基本思想为设待排序的键值是位相同的整数,根据从低位到高位的顺序,将每一位低的键值排在序列首部,直到所有的位被排序。其算法步骤为:
1.初始化队列
A
A
A,将全部
d
d
d位的数据装入
A
A
A,每位的最大值即基数为
r
r
r,
i
=
1
i = 1
i=1;
2.设置
r
r
r个队列
Q
[
0
]
,
Q
[
1
]
,
.
.
.
,
Q
[
r
−
1
]
Q[0], Q[1], ..., Q[r - 1]
Q[0],Q[1],...,Q[r−1]为空;
3.数据依次出队,在第
i
i
i次迭代时,将键值第
i
i
i位为
r
r
r的数据入队
Q
[
r
]
Q[r]
Q[r];
4.从
Q
[
0
]
Q[0]
Q[0]到
Q
[
r
−
1
]
Q[r -1]
Q[r−1]依次出队,入队到
A
A
A中,
i
=
i
+
1
i = i + 1
i=i+1;
5.迭代2-4,直到
i
=
d
+
1
i = d + 1
i=d+1。
由于队列Q的长度很难确定,可以设计成链表的形式。
基数排序的每一位排序需要
O
(
n
)
O(n)
O(n)的时间,而根据基数排序需要
O
(
r
)
O(r)
O(r)的时间,并需要
d
d
d次迭代,时间性能为
T
(
n
)
=
O
(
d
(
n
+
r
)
)
T(n) = O(d(n + r))
T(n)=O(d(n+r))。空间上需要队列指针
2
r
2r
2r个与链域空间
n
n
n个,空间性能为
S
(
n
)
=
O
(
n
+
r
)
S(n) = O(n + r)
S(n)=O(n+r)。
三、外部排序
外部排序是指排序的过程中,数据的主要部分储存在外存储器上,借助内存储器调整外存储器的数据位置。
3.1 磁盘文件的归并排序
磁盘是随机存储设备。外部排序主要使用归并排序,分为如下阶段:
-初始归并段形成,将文件中的数据分段输入到内存,在内存中采用内部排序,将有序段写回内存。整个文件经过在内存逐段排序又逐段写回内存,在外存中形成多个初始归并段。
-多路归并,对初始归并段采用归并排序,进行多轮扫描,最后形成整个文件的单一归并段。
考虑含有4500个记录的输入文件,用一台内存至多容纳750个记录的计算机进行外部排序。输入文件放在磁盘中,每个页块容纳250个记录,将记录储存在18个页块中。由于内存可以容纳750个记录,即恰好能存3个页块。那么把18个页块的每3个页块读入内存,形成初始归并段,共有6个。
若把内存划分为3个缓冲区,其中2个为输入缓冲,1个为输出缓冲,就可以在内存中实现二路归并。当输出缓冲区装满250个记录时,就输出到磁盘;当输入缓冲区空了,就装入初始归并段。这样,通过三轮归并,就可以把6个初始归并段排序。
3.2 多路归并
考虑m个初始段的k路归并。那么需要
⌈
l
o
g
k
m
⌉
\lceil log_km \rceil
⌈logkm⌉轮归并。而在k路归并中,查找k个关键字的最小记录需要k-1次比较。若总记录为n,需要查找到n-1个最小记录,那么需要的比较总次数为
(
n
−
1
)
(
k
−
1
)
⌈
l
o
g
k
m
⌉
=
(
n
−
1
)
(
k
−
1
)
⌈
l
o
g
2
m
/
l
o
g
2
k
⌉
(n - 1)(k-1)\lceil log_km \rceil = (n - 1)(k-1)\lceil log_2m/log_2k \rceil
(n−1)(k−1)⌈logkm⌉=(n−1)(k−1)⌈log2m/log2k⌉如果归并的路越多,那么归并的遍数越少。然而,当归并路数过多时,多个元素的归并会造成CPU处理时间的增多,减少多路归并所节省的时间,因此需要选择好的分类方法。
考虑k路平衡选择树,在第一次建立树需要比较的时间为
k
−
1
=
O
(
k
)
k - 1 = O(k)
k−1=O(k),之后重新建立选择树的时间为
O
(
l
o
g
2
k
)
O(log_2k)
O(log2k),对于n个记录,
⌈
l
o
g
k
m
⌉
\lceil log_km \rceil
⌈logkm⌉轮归并下,选择树的时间性能为
T
(
n
)
=
O
(
n
l
o
g
2
k
l
o
g
k
m
)
+
O
(
k
l
o
g
k
m
)
T(n) = O(nlog_2klog_km)+O(klog_km)
T(n)=O(nlog2klogkm)+O(klogkm)
3.4 并行归并
对于k个归并段进行k路归并,至少需要k个输入缓冲区和1个输出缓冲区。为了使输入、输出、CPU处理尽可能重叠,需要2k个缓冲区并行操作。考虑4个输入缓冲区与2个输出缓冲区,归并段分别为
{
1
,
3
,
5
,
7
,
8
,
9
}
\{1, 3, 5, 7, 8, 9\}
{1,3,5,7,8,9}与
{
2
,
4
,
6
,
15
,
20
,
25
}
\{2, 4, 6, 15, 20, 25\}
{2,4,6,15,20,25}。那么二路归并外部排序步骤如下:
-输入缓冲区1装入
{
1
,
3
}
\{1, 3\}
{1,3},输入缓冲区2装入
{
2
,
4
}
\{2, 4\}
{2,4};
-归并,输出缓冲区1装入
{
1
,
2
}
\{1, 2\}
{1,2};输入缓冲区3装入
{
5
,
7
}
\{5, 7\}
{5,7};
-输出缓冲区1写磁盘;归并,输出缓冲区2装入
{
3
,
4
}
\{3, 4\}
{3,4};输入缓冲区4装入
{
6
,
15
}
\{6, 15\}
{6,15};
-输出缓冲区2写磁盘;归并,输出缓冲区1装入
{
5
,
6
}
\{5, 6\}
{5,6};输入缓冲区1装入
{
8
,
9
}
\{8, 9\}
{8,9};
-输出缓冲区1写磁盘;归并,输出缓冲区2装入
{
7
,
8
}
\{7, 8\}
{7,8};输入缓冲区2装入
{
20
,
25
}
\{20, 25\}
{20,25};
-输出缓冲区2写磁盘;归并,输出缓冲区1装入
{
9
,
15
}
\{9, 15\}
{9,15};
-输出缓冲区1写磁盘;归并,输出缓冲区2装入
{
20
,
25
}
\{20, 25\}
{20,25};
-输出缓冲区2写磁盘。
从而使输入、输出、CPU处理并行。
3.5 初始归并段的生成
内部排序都可以用来产生初始归并段。初始归并段的长度依赖于内排序过程所用的缓冲区长度。一般来说,归并段长度与缓冲区相同。如果使用选择树法,则可以产生大于缓冲区长度的初始归并段。
建立选择树后,每当从选择树中输出一个记录,树中相应的叶结点就用下一个输入记录来取代,而输出的记录称为当前初始归并段的一部分。如果新输入的键值小于最后输出的键值,那么该记录需要等待生成下一个归并段时供选择。
选择树法生成初始归并段,平均长度是缓冲区长度的两倍。考虑输入序列
{
15
,
19
,
04
,
83
,
12
,
27
,
11
,
25
,
16
,
34
,
26
,
07
,
10
,
90
,
06
,
.
.
.
}
\{15, 19, 04, 83, 12, 27, 11, 25, 16, 34, 26, 07, 10, 90, 06, ...\}
{15,19,04,83,12,27,11,25,16,34,26,07,10,90,06,...}
设缓存区大小为4,那么缓存区的内容随归并步骤变化为:
-15,19,04,83,取序列前4个记录;
-15,19,12,83,归并取出04,取序列第5个记录12;
-15,19,27,83,归并取出12,取序列第6个记录27;
-11,19,27,83,归并取出15,取序列第7个记录11;
-.11,25,27,83,由于11小于最后取出的键值15,等待下一归并段;归并取出19,取序列第8个记录25;
-.11,16,27,83,归并取出25,取序列第9个记录16;
-.11,.16,34,83,由于16小于最后取出的键值25,等待下一归并段;归并取出27,取序列第10个记录34;
-.11,.16,26,83,归并取出34,取序列第11个记录26;
-.11,.16,.26,07,由于26小于最后取出的键值34,等待下一归并段;归并取出83,取序列第12个记录07;
-.11,.16,.26,.07,由于07小于最后取出的键值83,等待下一归并段。
此时,缓冲区的记录均等待下一归并段,因此本初始归并段生成结束,为
{
04
,
12
,
15
,
19
,
25
,
27
,
34
,
83
}
\{04, 12, 15, 19, 25, 27, 34, 83\}
{04,12,15,19,25,27,34,83},并开始下一初始归并段生成。
由于选择树产生的初始归并段长度可能不等,使得多路平衡归并需要一定的方法才可以得到最少读外存记录次数。使用哈夫曼树来得到读写文件次数最少的归并方案,称为最佳归并树。
考虑初始归并段长度分别为
{
49
,
9
,
35
,
18
,
4
,
12
,
23
,
7
,
21
,
14
,
26
}
\{49, 9, 35, 18, 4, 12, 23, 7, 21, 14, 26\}
{49,9,35,18,4,12,23,7,21,14,26},那么构造4阶哈夫曼树,即为四路最佳归并树,形如
其中:
第一层归并需要读取与写入文件4+7次;
第二层归并需要读取与写入文件9+11+12+14+18+21+23+26次;
第三层归并需要读取与写入文件35+46+49+88次。
即共需读写次数为
2
×
[
(
4
+
7
)
×
3
+
(
9
+
12
+
14
+
18
+
21
+
23
+
26
)
×
2
+
(
35
+
49
)
×
1
]
=
726
2×[(4+7)×3 + (9+12+14+18+21+23+26)×2 + (35+49)×1] = 726
2×[(4+7)×3+(9+12+14+18+21+23+26)×2+(35+49)×1]=726即叶结点加权外通路长度的2倍。