第6章 查找
6.1 查找的基本概念
-
静态查找表。若一个查找表的操作无须动态地修改查找表,此类查找表称为静态查找表。适合静态查找表的查找方法有顺序查找、折半查找、散列查找等。
-
动态查找表。若一个查找表需要对表中元素进行插入、删除操作,则称之为动态查找表。适合动态查找表的查找方法有二叉排序树查找、散列查找等。
-
关键字。数据元素中唯一标识该元素的某个数据项的值。
-
平均查找长度。所有查找过程中进行关键字的比较次数的平均值
A S L = ∑ i = 1 n P i C i ASL = \sum_{i = 1}^{n}P_iC_i ASL=i=1∑nPiCi
式中,n是查找表的长度,Pi是查找第i个元素的概率,Ci是找到第i个数据元素所需要进行比较的次数。
6.2 顺序查找和折半查找
6.2.1 顺序查找
1.一般线性表的顺序查找
普通的顺序查找
typedef struct{ //查找表的数据结构(顺序表)
ElemType *elem; //动态数组基址
int length; //表的长度
}SSTable;
int searchSeq(SStable ST, ElemType key){
for(int i = 0; i<ST.length && ST.elem[i] != key; ++i) //从前往后找
//查找成功,则返回元素下标;查找失败,则返回-1
return i == ST.length ? -1 : i;
}
带有“哨兵”的顺序查找
typedef struct{
ElemType *elem; //元素存储空间基址,建表时按实际长度分配,0号单元留空
int length;
}SSTable;
int searchSeq(SStable ST, ElemType key){
//在顺序表ST中顺序查找关键字为key的元素。若找到则返回该元素在表中的位置。
ST.elem[0] = key; //哨兵
for(int i = ST.length; ST.elem[i] != key; --i) //从后往前找
return i; //查找成功,则返回元素下表;查找失败,则返回0
}
在上述算法中,将ST.elem[0]称为“哨兵”。引入它的目的是使得searchSeq内的循环不必判断数组是否会越界,因为满足i == 0时,循环一定会跳出。可以在其他算法中巧妙地添加“哨兵”,从而减少很多不必要的判断语句。
若每个元素的查找概率相等,
A
S
L
成功
=
n
+
1
2
A
S
L
不成功
=
n
+
1
ASL_{成功} = \frac{n+1}{2}\quad \quad \quad ASL_{不成功} = n+1
ASL成功=2n+1ASL不成功=n+1
若能预先得知每个记录的查找概率,则应对记录的查找概率排序,是表中记录按查找概率由小到大重新排列。(添加哨兵的顺序查找是从右向左查找)
2.有序表的顺序查找
可以用判定树来描述有序顺序表的查找过程。若每个元素查找概率相等,
A
S
L
成功
=
n
+
1
2
A
S
L
不成功
=
1
+
2
+
⋅
⋅
⋅
+
n
+
n
n
+
1
=
n
2
+
n
n
+
1
ASL_{成功} = \frac{n+1}{2}\quad \quad \quad ASL_{不成功} = \frac{1+2+···+n+n}{n+1}= \frac n2 + \frac n{n+1}
ASL成功=2n+1ASL不成功=n+11+2+⋅⋅⋅+n+n=2n+n+1n
注意,等查找概率情况下,查找不成功时的Pi为1 / (n+1)。
6.2.2 折半查找
又称二分查找,它仅适用于有序的顺序表。
int binarySearch(SqList L, ElemType key){
//有序表L中元素升序存储,在有序表L中查找关键字为key的元素,若存在则返回其位置,否则返回-1
int low = 0, high = L.length - 1, mid;
while(low < high)
{
mid = (low + high) / 2;
if(L.elem[mid] == key)
return mid;
else if(L.elem[mid] > key)
high = mid - 1;
else
low = mid + 1;
}
return -1;
}
折半查找的过程可以用二叉树来描述。在等概率查找时,查找成功的平均查找长度为
A
S
L
=
1
n
(
1
∗
1
+
2
∗
2
+
⋅
⋅
⋅
+
h
∗
2
h
−
1
)
=
n
+
1
n
log
2
(
n
+
1
)
−
1
≈
log
2
(
n
+
1
)
−
1
ASL = \frac 1n (1*1+2*2+···+h*2^{h-1}) = \frac {n+1}n \log_2(n+1) -1 \approx \log_2(n+1) -1
ASL=n1(1∗1+2∗2+⋅⋅⋅+h∗2h−1)=nn+1log2(n+1)−1≈log2(n+1)−1
折半查找的判定树一定是平衡二叉树,因此,当元素个数为n时,树高
h
=
⌈
log
2
(
n
+
1
)
⌉
h = \lceil \log_2(n+1) \rceil
h=⌈log2(n+1)⌉,查找的时间复杂度为O(log2n)。
折半查找的判定树中失败节点的个数为n+1个(等于成功结点的空链域数量,符合平衡二叉树的性质)。
6.2.3 分块查找
又称索引顺序查找,吸取了顺序查找和折半查找各自的优点,既有动态结构,又适合快速查找。
分块查找的基本思想:将查找表分为若干子块。块内的元素可以无序,但块之间是有序的。
分块查找的平均查找长度为索引查找和块内查找的长度之和:ASL = LI + LS
若将长度为n的查找表均匀地分为b块,每块有s个记录,在等概率情况下,若在块内和索引表中均采用顺序查找,则
A
S
L
=
L
I
+
L
S
=
b
+
1
2
+
s
+
1
2
=
s
2
+
2
s
+
n
2
s
ASL = L_I + L_S = \frac {b+1}2 + \frac{s+1}2 = \frac{s^2+2s+n}{2s}
ASL=LI+LS=2b+1+2s+1=2ss2+2s+n
此时,若
s
=
n
s = \sqrt{n}
s=n,则平均查找长度取最小值
n
+
1
\sqrt n +1
n+1;若对索引表采用折半查找时,
A
S
L
=
L
I
+
L
S
=
⌈
log
2
(
b
+
1
)
⌉
+
s
+
1
2
ASL = L_I + L_S = \lceil \log_2(b+1) \rceil + \frac{s+1}2
ASL=LI+LS=⌈log2(b+1)⌉+2s+1
💡如果对索引表采用折半查找的方式进行查找,若索引表中不包含目标关键字,则折半查找索引表最终停在low>high,要在low所指的分块中去查找。
【注意】
- 顺序查找和折半查找各有优劣,在未说明具体场景下,二者的查找速度不能确定。
- 折半查找法在查找不成功时和给定值进行关键字比较的次数做多为树的高度,即 ⌊ log 2 n ⌋ + 1 \lfloor \log_2n \rfloor+1 ⌊log2n⌋+1或 ⌈ log 2 ( n + 1 ) ⌉ \lceil \log_2(n+1) \rceil ⌈log2(n+1)⌉
- 二叉排序树的查找性能与数据的输入顺序有关,最好情况下的平均查找长度与折半查找相同,但最坏情况即形成单支树时,其查找长度为O(n).
- 折半查找判定树实际上是一棵二叉排序树,它的中序序列是一个有序序列。可以在树结点上依次填上相应的元素,符合折半查找规则(向上取整或向下取整的操作统一)的树即为所求。
- 判断给定序列是否构成折半查找种关键字比较的序列,做法是画出路径图判断是否是二叉排序树。
6.3 b树和b+树
6.3.1 b树及其基本操作
b树,又称多路平衡查找树,b树中所有结点的孩子结点数的最大值称为b的阶,通常用m表示,一棵m阶b树或为空树,或为满足如下特性的m叉树:
-
💡树中每个结点至多有m棵子树(即至多含有m-1个关键字)
-
若根结点不是终端结点,则至少有两个子树
-
💡除根结点外的所有非叶结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉(即至少含有 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 ⌈m/2⌉−1个关键字)。根结点至少含1个关键字
-
所有叶结点都出现在同一层次上,并且不带信息(实际上这些结点不存在,指向这些结点的指针为空)
⭐️b树是所有结点的平衡因子均等于0的多路查找树。
1.b树的高度(磁盘存取次数)
log m ( n + 1 ) ≤ h ≤ log ⌈ m / 2 ⌉ ( ( n + 1 ) / 2 ) + 1 \log_m(n+1) \quad \leq \quad h \quad \leq \quad \log_{\lceil m/2 \rceil}((n+1)/2)+1 logm(n+1)≤h≤log⌈m/2⌉((n+1)/2)+1
2.b树的查找
- 在b树中找结点。
- 在结点内找关键字。
查找到叶结点时(对应指针为空指针),则说明树中没有对应的关键字,查找失败。
3.b树的插入
- 定位。利用b树查找算法,找出插入该关键字的最底层中的某个非叶结点。
- 插入。在b树中,每个非失败结点的关键字个数都在区间[ ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil-1 ⌈m/2⌉−1, m-1]内。插入后的结点关键字个数小于m,可以直接插入;插入后检查被插入结点内关键字的个数,当插入后的结点关键字个数大于m-1时,必须对结点进行分裂。
- 分裂。取一个新结点,在插入key后的原结点,从中间位置将其中的关键字分为两部分,左部分包含的关键字放在原结点中,有部分包含的关键字放在新结点中,中间位置( ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉)的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致b树的高度加1。
4.b树的删除
当删除的关键字k不在终端结点(最底层非叶结点)中时,有下列几种情况:
- 若小于k的子树中关键字个数大于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1,则找出k的前驱值k‘,并用k’来取代k,再递归地删除k‘即可。
- 若大于k的子树中关键字个数大于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1,则找出k的后继值k’,并用k’来取代k,在递归地删除k‘即可。
- 若前后两个子树中的关键字个数均为 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1,则直接将两个子结点合并,直接删除k即可。
当删除的关键字在终端结点(最底层非叶结点)中时,有下列几种情况:
- 直接删除关键字。若被删除关键字所在结点的关键字个数大于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1,表明删除该关键字后仍满足b树的定义,则直接删去该关键字。
- 移位平衡(兄弟够借)。若被删除关键字所在结点删除前的关键字个数等于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1,且于此结点相邻的右(左)兄弟结点的关键字个数≥ ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉,则需要调整该结点、右(左)兄弟结点及其双亲结点(父子换位法),以达到新的平衡。
- 合并平衡(兄弟不够借)。若被删除关键字所在结点删除前的关键字个数等于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1,且此时与该结点相邻的右(左)兄弟结点的关键字个数等于 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1,则将关键字删除后与右(左)兄弟结点及双亲结点中的关键字进行合并。
在合并过程中,双亲结点中的关键字个数会减少。若其双亲结点是根结点且关键字个数减少至0(根结点关键字个数为1时,有2棵子树),则直接将根节点删除,合并后的新结点成为根;若双亲结点不是根节点,且关键字个数减少到 ⌈ m / 2 ⌉ − 2 \lceil m/2 \rceil -2 ⌈m/2⌉−2,则又要与它自己的兄弟结点进行调整或合并操作,重复上述步骤,直至符合b树的要求为止。
6.3.2 b+树的基本概念
b+树是b树的一种变形树,一棵m阶的b+树需满足下列条件:
- 每个分支结点最多有m棵子树(子结点)。
- 非叶根结点至少有两棵子树,其他每个分支结点至少有 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉棵子树。
- 结点的子树个数与关键字个数相等。
- 所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。
- 所有分支结点(可视为索引的索引)中仅包含它的各个子结点(即下一级的索引块)中关键字最大值及指向其子结点的指针。
m阶b+树与m阶b树的主要差异如下:
- 在b+树中,具有n个关键字的结点只含有n棵子树,即每个关键字对应一棵子树;而在b树中,具有n个关键字的结点含有n+1棵子树。
- 在b+树中,每个结点(非根内部结点)的关键字个数n的范围是 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉≤n≤m(根结点:1≤n≤m);在b书中每个结点(非根内部结点)的关键个数n的范围是 ⌈ m / 2 ⌉ − 1 \lceil m/2 \rceil -1 ⌈m/2⌉−1≤n≤m(根结点:1≤n≤m-1)
- 在b+树种,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。
- 在b+树中,叶结点包含了全部关键字,即在非叶结点中出现的关键字也会出现在叶结点中;而在b树中,叶结点包含的全部关键字和其他结点包含的关键字是不重复的
b+树分支结点的某个关键字是其子树中最大关键字的副本。通常在b+树中有两个头指针:一个指向根结点,另一个指向关键字最小的叶结点。因此,可以对b+树进行两种查找运算:一种是从最小关键字开始的顺序查找,另一种是从根结点开始的多路查找。
b+树的查找、插入和删除操作和b树的基本类似。只是在查找过程中,非叶结点上的关键字值等于给定值时并不终止,而是继续向下查找,直至叶结点上的该关键字为止。所以,在b+树种查找时,无论查找成功与否,每次查找都是一条从根结点到叶结点的路径。
【注意】
- 对于m阶b树,根结点只有到达m个关键字时才能产生分裂,成为高度为2的b树。
m阶B树 | m阶B+树 | |
---|---|---|
类比 | 二叉查找树的进化=>m叉查找树 | 分块查找的进化=>多级分块查找 |
关键字与分叉 | n个关键字对应n+1个分叉(子树) | n个关键字对应n个分叉 |
结点包含的信息 | 所有节点中都包含记录的信息 | 只有最下层叶子结点才包含记录的信息 (可使树更矮) |
查找方式 | 不支持顺序查找。查找成功时,可能停在任何一层结点,查找速度“不稳定”。 | 支持顺序查找。查找成功或失败都会到达最下一层结点,查找速度“稳定”。 |
相同点:除根结点外,最少 ⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉个分叉(确保结点不要太“空”),任何一个结点的子树都要一样高(确保“绝对平衡”)。
6.4 散列表
6.4.1 散列表的基本概念
- 散列函数:一个把查找表中的关键字映射称该关键字对应的地址的函数。即不需要经过比较找出关键字,而是直接算出关键字的位置。
- 冲突:散列函数可能会把两个或两个以上的不同关键字映射到同一地址,这种情况称为冲突。
- 同义词:发生碰撞的不同关键字称为同义词。
- 散列表:根据关键字而直接进行访问到数据结构。理想情况下,对散列表进行查找的时间复杂度为O(1).
6.4.2 散列函数的构造方法
- 散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
- 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生。
- 散列函数应尽量简单,能够在较短的时间内计算出任意关键字对应的散列地址。
1.直接定址法
直接取关键字的某个线性函数值为散列地址,散列函数为
H
(
k
e
y
)
=
a
∗
k
e
y
+
b
H(key) = a * key + b
H(key)=a∗key+b
这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,这会造成存储空间的浪费。
2.除留取余法
假定散列表表长为m,取一个不大于m但最接近或等于m的质数,利用以下功是把关键字转换成散列地址。散列函数为
H
(
k
e
y
)
=
k
e
y
%
p
H(key) = key \% p
H(key)=key%p
除留取余法的关键是选好p,使得每个关键字通过该函数转换后等概率地映射到散列空间上的任一地址,从而尽可能减少冲突的可能性。
3.数字分析法
设关键字时r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,此时应选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
4.平方取中法
这种方法去关键字的平方值的中间极为作为散列地址,具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字每一位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
5.折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以短一些),然后取这几部分的叠加和作为散列地址。关键字位数很多,而且关键字中每位上数字分布大致均匀时,可以采用折叠法得到散列地址。
6.4.3 处理冲突的方法
假设已经选定散列函数H(key),下面用Hi表示发生冲突后第i次探测的散列地址。
1.开放定址法
所谓开放定址法,是指可存放信标项的空闲地址既向它的同义词表项开放,有向它的非同义词表项开放。其数学递推公式为
H
i
=
(
H
(
k
e
y
)
+
d
i
)
%
m
H_i =(H(key) + d_i)\%m
Hi=(H(key)+di)%m
其中i = 0, 1, 2, …, k(k≤m-1);m表示散列表表长;di为增量序列。
取定某一增量序列后,对应的处理方法就是确定的。
- 线性探测法。当di = 0, 1, 2, …, m-1时,称为线性探测法。这种做法容易使得元素争夺地址,从而造成大量元素在相邻的散列地址上堆积起来,大大降低了查找效率。
- 平方探测法。当di = 02, 12, 22, …, k2时,称为平方探测法,其中k≤m/2,散列表的长度m必须是一个可以表示成4k+3的素数,又称二次探测法。该方法可以避免出现“堆积”问题。它的缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。
- 再散列法。当通过第一个散列函数得到的地址发生冲突时,则利用第二个散列函数计算该关键字的地址增量.
H i = ( H ( k e y ) + i ∗ H a s h 2 ( k e y ) % m ) H_i = (H(key) + i * Hash_2(key)\%m) Hi=(H(key)+i∗Hash2(key)%m)
初始探测位置是H0 = H(key)%m。i是冲突的次数,初始为0。在散列法中,最多经过m-1次探测就会遍历表中所有位置,回到H0位置。
- 伪随机序列法。当di = 伪随机数序列时,称为伪随机序列法。
开放定址法的情形下,不能随便物理删除表中的已有元素,因为若删除元素,则会截断其他具有相同 散列地址的元素的查找地址。因此,要删除一个元素时,可给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
2.拉链法(链接法,chaining)
为了避免非同义词发生冲突,可以把所有同义词存储在一个线性链表中,这个线性链表尤其散列地址唯一标识。假设散列地址为i的同义词链表的头指针存放在散列表的第i个单元中,因而查找、插入和删除操作主要在同义词链中进行。拉链法适用于经常进行插入和删除的情况。
6.4.4 散列查找及性能分析
散列表的查找过程与构造散列表的过程基本一致。对于一个给定的关键字key,根据散列函数可以计算出其散列地址,执行步骤如下:
- 初始化:Addr = Hash(key);
- 检测查找表中地址为Addr的位置上是否有记录,若无记录,返回查找失败;若有记录,比较它与key的值,若相等,则返回查找成功标志,否则执行步骤3
- 用给定的处理冲突方法计算“下一个散列地址”,并把Addr置为此地址,转入步骤2
散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子α
α
=
表中记录数
n
散列表长度
m
\alpha = \frac{表中记录数n}{散列表长度m}
α=散列表长度m表中记录数n
散列表的平均查找长度依赖于散列表的装填因子α,而不直接依赖于n或m。直观地看,α越大,表示装填的记录越“满”,发成冲突的可能性越大,反之发成冲突的可能性越小。
6.5 串
6.5.1 串的定义
串(string)是由零个或多个字符组成的有限序列。其逻辑结构和线性表非常相似,其逻辑结构的区别仅在于串的数据对象限定为字符集。
- 空串:字符的个数为0的串称为空串。
- 子串:串中任意个连续的字符组成的子序列称为该串的子串。
- 主串:包含子串的串称为主串。
- 子串的位置:以子串的第一个字符在主串中的位置来表示子串的位置。
- 空格串:有一个或多个空格(空格是特殊字符)组成的串。(空格串不是空串)
6.5.2 串的存储结构
1.定长顺序存储表示
用一组地址连续的存储单元存储串值得字符序列。在串的定长顺序存储结构中,为每个串变量分配一个固定长度的存储区,即定长数组。
#define MAXLEN 255
typedef struct SString{
char ch[MAXLEN];
int length;
}SString;
串的实际长度只能小于等于MAXLEN,超过预定义长度的串值会被舍去,称为截断。
串长有两种表示方式:一是如上述定义描述的那样,用一个额外的变量len来存放串的长度;二是在串值后面加一个不计入串长的结束标记字符“\0”,此时串长为隐含值。
2.堆分配存储表示
堆分配存储表示仍然以一组地址连续的存储单元存放串值的字符序列,但它们的存储空间是在程序执行过程中动态分配得到的。
typedef struct HString{
char *ch;
int length;
}HString;
在C语言中,存在一个称之为“堆”的自由存储区,并用malloc()和free()函数来完成动态存储管理。利用malloc()为每个新产生的串分配一块实际串长所需的存储空间,若分配成功,则返回一个指向起始地址的指针,作为串的基地址,这个串由ch指针来指示;若分配失败,则返回NULL,已分配的空间可用free()释放掉。
3.块链存储表示
类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性(每个元素只有一个字符),在具体实现时,每个结点既可以存放一个字符,又可以存放多个字符。每个结点称为块,整个链表称为块链结构。
6.5.3 串的基本操作
- ⭐️strAssign(&T, chars):赋值操作。把chars赋值给串T。
- ⭐️strCpy(&T, S):复制操作。由串s赋值得到串T。
- strEmpty(S):判空操作。若S为空串,则返回TRUE,否则,返回FALSE。
- strCmp(S, T):比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<T。
- ⭐️strLen(S):求串长。返回串S中的元素个数。
- ⭐️strStr(&sub, S, pos, len):求子串。用sub返回串S的第pos个字符起长度为len的子串。
- ⭐️strCat(&T, S1, S2):串联接。用T返回由S1和S2联接而成的新串。
- index(S, T, pos):定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第pos个字符后第一次出现的位置;否则函数值为0。
- replace(&S, T, V):替换字串。用V替换主串S中出现的所有与T相等的不重叠的子串。
- strInsert(&S, pos, T):在串S的第pos个字符之前插入串T。
- strDelete(&S, pos, len):删除子串。从串S中删除第pos个字符起长度为len的子串。
- strClear(de&S):清空操作。将S清为空串。
- strDestroy(&S):销毁串。将串S销毁。
int strAssign(HString& str, char* ch){
//将串ch赋值给串str
if(str.ch)
{
free(str.ch); //释放原有内容
}
int len = 0;
char *c = ch;
while(*c) //确定赋值目标串的长度,遇到‘\0’就结束
{
++len;
++c;
}
if(len == 0) //目标字符串没有除‘\0’以外的字符
{
str.ch = NULL;
str.length = 0;
return 1;
}
else
{
str.ch = (char*)malloc(sizeof(char)*(len+1));
if(str.ch == NULL)
{
return 0; //分配失败
}
else
{
c = ch;
for(int i = 0; i <= len; ++i, ++c)
{
str.ch[i] = *c;
}
str.length = len;
return 1;
}
}
}
int index(String S, String T, int pos){
if(pos > 0)
{
int n = strLen(S);
int m = strLen(T);
int i = pos;
while(i <= n-m+1)
{
subStr(sub, S, i, m);
if(strCmp(sub, T) != 0)
++i;
else
return i;
}
}
return 0;
}
6.5.4 串的模式匹配
子串的定位操作通常称为串模式匹配,它求的是子串(模式串)在主串中的位置。
int index(SString S, SString T, int pos){
int i = pos; j = 1;
while(i <= S.length && j <= T.length)
{
if(S.ch[i] == T.ch[j])
{
++i;
++j;
}
else
{
i = i - j + 2;
j = 1;
}
}
if(j > T.length)
return i - T.length;
else
return 0;
}
6.5.5 改进的模式匹配算法——KMP算法
KMP算法利用比较过的信息,i指针不需要回溯,仅将子串向后滑动一个合适的位置,并从这个位置开始和主串进行比较,这个合适的位置仅与子串本身的结构有关,而与主串无关。
- 前缀:指除最后一个字符以外,字符串的所有头部子串。
- 后缀:指除第一个字符以外,字符串的所有尾部字串;
- 部分匹配值:字符串的前缀和后缀的最长相等前后缀长度。
以‘ababa’为例进行说明:
- ‘a’的前缀和后缀都为空集,最长相等前后缀长度为0。
- ‘ab’的前缀为{a},后缀为{b},{a}∩{b} = ∅ \emptyset ∅,最长相等前后缀为0.
- ‘aba’的前缀为{a, ab},后缀为{a, ba},{a, ab}∩{a, ba} = {a},最长相等前后缀长度为1.
- ‘abab’的前缀为{a, ab, aba},后缀为{b, ab, bab},最长相等前后缀长度为2.
- ‘ababa’的前缀为{a, ab, aba, abab},后缀为{a, ba, aba, baba},最长相等前后缀长度为3.
故字符串‘ababa’的部分匹配值为00123
⭐️子串需要向后移动的位数 = 已成功匹配的字符数 - 最后一个匹配字符对应的部分匹配值。
- 若字符串第一个字符的数组下标为0,则直接将部分匹配值右移一位,高位截断低位补-1,从而得到next[];
- 若字符串第一个字符的数组下标为1,则将部分匹配值先右移一位,再给所有部分匹配值加1,从而得到next[].
//本算法适用于字符串第一个字符的数组下标为1的情况
void getNext(String T, int next[]){
int i = 1, j = 0;
next[1] = 0;
while(i < T.length)
{
if(j == 0 || T.ch[i] == T.ch[j])
{
++i;
++j;
next[i] = j;
}
else
{
j = next[j];
}
}//while
}
int KMP(String S, String T, int next[], int pos){
int i = pos, j = 1;
while(i <= S.length && j <= T.length)
{
if(j == 0 || S.ch[i] == T.ch[j])
{
++i; //继续比较后继字符
++j;
}
else
{
j = next[j];
}
}//while
if(j > T.length)
return i - T.length; //匹配成功
else
return 0;
}
尽管普通模式匹配的时间复杂度是O(mn),KMP算法的时间复杂度是O(m+n),但在一般情况下,普通模式匹配的时间执行事件近似为O(m+n)。KMP算法尽在主串与子串有很多“部分匹配”时才显得比普通算法快得多,其主要优点是主串不回溯。
【题型】
数组中有一个数字出现的次数超过了数组长度的一般,请找出这个数字。
答:法一。对所有数字进行排序,位于(n+1)/2的数即为要找的数,此时最小时间复杂度为O(nlog2n)。
法二。出现的次数超过数组长度的一半,表明这个数字出现的次数比其他数字出现的次数的总和还多。所以可以考虑每次删除两个不同的数,则在剩下的数中,待找数字的出现次数仍超过总数的一半。重复这个过程,最终剩下的都为同一个数字,即为要找的数字。此时时间复杂度为O(n)。