清华邓俊辉数据结构学习笔记(5) -散列、优先级队列、串

第九章 词典

(a)散列:原理

:直接存放或间接指向一个词表。
桶数组/散列表:容量为M,N<M<<R
空间 = O( N + M )=O(N)
定址/杂凑/散列:根据词条的key(未必可比较)直接确定散列表入口。
散列函数:hash() : key -> &entry,如 hash(key) = key % M
直接 = expected - O(1) ≠ O(1)
冲突:key1≠key2,但hash( key1 ) = hash( key2 ),词条空间到地址空间不可能是单射。

(b)散列:散列函数

两项基本任务:(1)精心设计散列表及散列函数,以尽可能降低冲突概率;(2)制定可行的预案,以便在发生冲突时尽快予以排解。
散列函数hash()评价标准与设计原则:(1)确定。同一关键码总是被映射至同一地址;(2)快速。expected-O(1);(3)满射:尽可能充分地覆盖整个散列空间;(4)均匀:关键码映射到散列表各位置的概率尽量接近。(可有效避免聚集现象)

散列函数举例 - 越是随机,越是没有规律越好
1、除余法 hash(key) = key % M,为何取M=90001? 若取M = 2^k,效果相对于截取key的最后k位,前面的n-k位对地址没有影响,发生冲突概率大。当M为素数时,数据对散列表的覆盖最充分,分布最均匀。
2、MAD法 除余法的缺陷:(1)不动点。无论表长M取值如何,总有hash(0)=0;(2)零阶均匀。[0, R)的关键码平均分配至M个桶,但相邻关键码的散列地址未必相邻。
一阶均匀:邻近的关键码,散列地址不再邻近?
取M为素数,a>0,b>0,a % M ≠ 0,hash( key ) = (a × key + b ) % M,当然特定场合下未必需要高阶的均匀性。
3、数字分析 抽取key中的某几位,构成地址,如取十进制表示的奇数位。
4、平方取中 取key²的中间若干位构成地址。
5、折叠法 将key分割成等宽的若干段,取其总和作为地址。如自左向右、往复折返。
6、位异或法XOR 将key分割成等宽的二进制段,经异或运算得到地址。
7、(伪)随机数法 (伪)随机数发生器的实现,因具体平台、不同历史版本而异,创建的散列表可移植性差,故慎用。
(伪)随机数发生器 循环:rand( x+1 ) = [ a×rand(x) ] % M
(伪)随机数法 取 hash(key) = rand(key) = [rand(0) × a^key] % M,需确定种子rand(0) = ?
8、多项式法
hash( s = x0 x1 … xn-1 ) = x0.a^(n-1) + x1.a^(n-2)+…+ xn-2.a1 + xn-1

static size_t hashCode( char s[] ){//近似多项式,但更快捷
	int h = 0;
	for ( size_t n = strlen(s),i=0; i<n; i++)
		{h=(h<<5)|(h>>27);h+=(int)s[i];}
	return (size_t) h;
}

(c1)散列:排解冲突

多槽位:桶单元细分成若干槽位slot存放与同一单元冲突的词条。只要槽位数目不多,依然可以保证O(1)的时间效率,但难以预测为每个桶配备多少个槽,预留过多空间浪费,无论预留多少极端情况下仍可能不够。
独立链:每个桶存放一个指针,冲突的词条组织成列表。优点:(1)无需预留多个槽位;(2)任意多次冲突都可解决;(3)删除操作实现简单统一。缺点:(1)指针需要额外空间,节点需要动态申请;(2)空间未必连续分布,系统缓存几乎失效。
开放定址:为每个桶事先约定若干备用桶,它们构成一个查找链。查找时沿查找链逐个转向下一桶单元,直到命中(成功)或者抵达一个空桶(已遍历所有冲突的词条,失败)。
线性试探:一旦冲突,则试探后一紧邻桶单元,直到命中成功,或抵达空桶失败。
[ hash(key) + 1 ] % M
[ hash(key) + 2 ] % M
[ hash(key) + 3 ] % M
优点:无需附加的(指针、链表或溢出区等)空间,查找链具有局部性,可充分利用系统缓存,有效减少I/0。缺点:操作时间>O(1),冲突增多(以往的冲突会导致后续的冲突)。

(c2)散列:排解冲突(2)

平方试探:以平方数为距离,确定下一试探桶单元。
[ hash(key) + 1² ] % M
[ hash(key) + 2² ] % M
[ hash(key) + 3² ] % M
[ hash(key) + 4² ] % M
优点:数据聚集现象有缓解。查找链上各桶间距线性递增,一旦冲突可聪明地跳离是非之地。缺点:若涉及外存,I/O将激增。
装填因子须足够小 定理:若M是素数,且装填因子≤0.5,就一定能找出,否则不见得。
M若为合数,n² % M可能的取值必然少于[ M/2 ]种,此时只要对应的桶非空就能找到。
eg: { 0, 1, 2, 3, 4, 5, …}² % 12 = { 0, 1, 4, 9}
M若为素数,n² % M可能的取值恰好会有[ M/2 ]种,此时恰由查找链的前[ M/2 ]项取遍。
eg: { 0, 1, 2, 3, 4, 5, …}² % 11 = { 0, 1, 4, 9, 5, 3}
查找链前缀必足够长
反证:假设存在0≤a<b<[M/2],使得沿着查找链,第a项和第b项彼此冲突,于是a²和b²属于M的某一同余类,b²-a²=(b+a).(b-a)=0 (mod M),然而0<b-a<b+a<M,与M为素数矛盾。

双向平方试探:自冲突位置起,依次向后试探。
[ hash(key) + 1² ] % M
[ hash(key) - 1² ] % M
[ hash(key) + 2² ] % M
[ hash(key) - 2² ] % M
[ hash(key) + 3² ] % M
[ hash(key) - 3² ] % M
查找链彼此独立?正向和逆向的子查找链,各包含[M/2]个互异的桶。2×[M/2]-1=M
观察除了0,这两个序列是否还有其他公共的桶?若表长取素数 4k+3,必然可以保证查找链的前M项均互异。
双平方定理:任一素数p可表示为一对整数的平方和,当且仅当 p%4=1。注意(2²+3²).(5²+8²)=(10+24)²+(16-15)²,推知:任一自然数n可表示为一对整数的平方和,当且仅当在其素分解中,形如M=4×k+3的每一素因子均为偶数次方。

(d)桶/计数排序

第十章 优先级队列

(a1)需求与动机

夜间门诊、多任务调度

template <typename T> struct PQ{//优先级队列
	virtual void insert(T) = 0;//按优先级次序插入词条
	virtual T getMax() = 0; //取出优先级最高的词条
	virtual T delMax() = 0; //删除优先级最高的词条
};//与其说PQ是数据结构,不如是ADT;其不同的实现方式,效率及应用场合也不尽相同

栈(Stack)和队列(Queue),都是PQ的特例,优先级完全取决于元素的插入次序。

(a2)基本实现

对于无序向量
getMax() - > traverse() - > O(n)
delMax() - > remove( traverse() ) - > O(n) + O(n) = O(n)
insert() - > insertAsLast(e) - > O(1)
| Vector::insert( r, e ) | = O( n - r )

对于有序向量
getMax() - > [n-1] - > O(1)
delMax() - > remove(n-1) - > O(1)
insert() - > insert( 1 + search(e), e ) - >O(logn) + O(n) = O(n)

对于列表
getMax() - > traverse() - > O(n)
delMax() - > remove( traverse() ) - > O(n) + O(1) = O(n)
insert() - > insertAsFirst(e) - > O(1)

对于有序列表
getMax() - > first() - > O(1)
delMax() - > remove( first() ) - > O(1)
insert() - > insertA( search(e), e ) - > O(n) + O(1) = O(n)

对于BBST
AVL、Splay、Red-black三个接口均只需O(logn)时间,但是BBST的功能远远超出了PQ的需求。
PQ = 1 × insert() + 0.5 × search() + 0.5 × remove() = 2/3 × BBST
若只需查找极值元,则不必维护所有元素之间的全序关系,偏序足矣,因此应有某种更简单、维护成本更低的实现方式,使得各接口时间复杂度依然为O(logn),且实际效率更高。

(b1)完全二叉堆:结构

完全二叉树:平衡因子处处非负的AVL,若 bf(v) = 0,则 lc(v)满;若 bf(v) = 1,则 rc(v)满。
结构性:逻辑上等同于完全二叉树,物理上直接借助向量实现(形神兼备),逻辑节点与物理元素依层次遍历次序彼此对应。

#define Parent(i) ( (i-1)>>1 )
#define LChild(i) ( 1 + ((i) << 1)) //奇数
#define LChild(i) ( (1 + (i))<< 1 ) //偶数

完全二叉堆 PQ_ComplHeap = PQ + Vector

template <typename T> class PQ_ComlHeap:public PQ<T>,public Vector<T>{
protected: 
	Rank percolateDown(Rank n, Rank i); //下滤
	Rank percolateUp(Rank i); //上滤
	void heapify(Rank n); //Floyd建堆算法
public:
	PQ_ComplHeap(T* A, Rank n)//批量构造
		{copyFrom(A,0,n);heapify(n);}
	void insert(T); //按照比较器确定的优先级顺序,插入词条
	T getMax() {return _elem[0];}//读取优先级最高的词条
	T delMax(); //删除优先级最高的词条
}

堆序性:数值上,只要 0 < i,必满足 H[ i ] ≤ H[ Parent(i) ],故 H[0] 即为全局最大元素。

(b2)完全二叉堆:插入与上滤

上滤算法:为插入词条e,只需将e作为末元素接入向量,(结构性自然保持),若堆序性也亦未破坏,则完成。否则只能是e与其父节点违反堆序性,e与其父节点换位,若堆序性因此恢复,则完成。否则依然只可能是e与其新的父节点违反堆序性,再换位,如此重复直到e与其父亲满足堆序性,或e到达堆顶。

template <typename T> void PQ_ComplHeap<T>::insert(T e)//插入
	{Vector<T>::insert(e);percolateUp(_size-1);}
template <typename T> //对第i个词条实施上滤,i<_size
Rank PQ_CompHeap<T>::percolateUp(Rank i){
	while (ParentValid(i)){//只要i有父亲
		Rank j = Parent(i);//将i父亲记作j
		if (lt(_elem[i],_elem[j])) break; //一旦父子不再逆序,上滤旋即完成
		swap(_elem[i],_elem[j]);i=j;//否则,交换父子位置,并上升一层
	}//while
	return i; //返回上滤最终抵达的位置
}

时间复杂度
到达堆顶前:每轮3(比较、交换、上升)- > 3.logn

(b3)完全二叉堆:删除

算法:最大元素始终在堆顶,故摘除向量首元素,代之以末元素e,结构性仍保持,若堆序性依然保持则完成。否则e与孩子中的大者换位,若堆序性因此恢复,则完成。否则e再次与孩子中的大者换位。

template <typename T> T PQ_ComplHeap<T>::delMax(){//删除
	T maxElem = _elem[0];_elem[0]=_elem[--_size]; //摘除堆顶,代之以末词条
	percolateDown(_size,0);//对新堆顶实施下滤
	return maxElem;
template <typename T>//对前n个词条中的第i个实施下滤
Rank PQ_ComplHeap<T>::percolateDown(Rank n, Rank i){
	Rank j; //i及其孩子中堪为父者
	while(i!=(j=ProperParent(_elem,n,i)))//只要n非j,则
		{swap(_elem[i],_elem[j]);i=j;}//换位,并继续考察i
	return i;//返回下滤抵达的位置,亦i亦j
}
}//O(logn)

(b4)完全二叉堆:批量建堆

自上而下的上滤

PQ_ComplHeap(T* A,Rank n){copyFrom(A,0,n);heapify(n);}
template <typename T> void PQ_ComplHeap<T>::heapify(Rank n){//蛮力
	for(int i=1;i<n;i++)//按层次遍历次序逐一
		percolateUp(i);//经上滤插入各节点
}

效率:最坏情况下,每个节点都需上滤至根,所需成本线性正比于其深度(∑depth(i)),即便只考虑底层,2/n个叶节点深度均为O(logn),累计耗时O(nlogn),足以全排序。
自下而上的下滤
任意给定堆H0和H1以及节点p,为得到H0 ∪ {p} ∪ H1,只需将r0和r1当作p的孩子,对p下滤。

template <typename T> void PQ_ComplHeap<T>::heapify(Rank n){
	for ( int i = LastInternal(n); i >= 0;i--)
		percolateDown(n,i);//下滤各内部节点
}//理解为子堆的逐层合并,由此堆序性在全局恢复

效率:每个内部节点所需的调整时间正比于其高度(∑height(i) = O(n))而非深度(∑depth(i) = O(nlogn))。

(c)堆排序

选取:在selectionSort()中,将U替换为H。
初始化:heapify(),O(n),建堆
迭代:delMax(),O(logn),取出堆顶并调整复原
不变性:H ≤ S
等效于常规选择排序,正确性满足。
就地算法:在物理上完全二叉堆等效于向量,既然有m=H[0],x=S[-1],不妨随即 swap(m, x) = H.insert(x) + S.insert(m)

template <typename T> //对向量区间[lo,hi)就地堆排序
void Vector<T>::heapSort(Rank lo,Rank hi){
	PQ_ComplHeap<T> H( _elem + lo,hi - lo );//待排序区间建堆,O(n)
	while(!H.empty())//反复摘除最大元并归入已排序的后缀,直至堆空
		_elem[--hi]=H.delMax();//等效于堆顶与末元素对换后下滤
}

(xa1)左式堆:结构

堆合并: H = merge(A,B)将堆A和B合二为一。
方法一:A.insert( B.delMax() ) - > O(m*(logm+log(n+m))) = O(m*log(n+m))
方法二:union( A, B ).heapify(n+m) -> O( m + n )
单侧倾斜:保持堆序性,附加新条件,使得在堆合并过程中只需调整很少部分的节点O(logn)。新条件:节点分布偏向于左侧,合并操作只涉及右侧。(拓扑上不见得是完全二叉树,结构性无法保证,实际上结构性不是堆排序的本质要求)
空节点路径长度:引入所有的外部节点,消除一度节点,转为真二叉树。Null Path Length为节点到外部节点的最近距离,也为以x为根的最大满子树的高度,npl( NULL ) = 0,npl( x ) = 1 + min( npl( lc(x) ), npl( rc(x) ))。
左倾性 & 左式堆:对于任何内节点x都有npl( lc(x) ) ≥ npl( rc(x) )。推论:对于任何内节点x都有npl( x ) = 1 + npl( rc(x) )。性质:左倾性与堆序性相容而不矛盾;左式堆的子堆必是左式堆;左式堆倾向于更多节点分布于左侧分支,但左子堆的规模和高度不一定大于右子堆。
右侧链(rChain(x)):从节点x出发,一直沿右分支前进,rChain(root)的终点必为全堆中最浅的外部节点,npl® = | rChain® | = d,可见存在一棵以r为根、高度为d的满子树。
节点数目:右侧链长为d的左式堆至少包含 2^d - 1个内部节点,2 ^ (d+1) - 1个节点。反之,在包含n个节点的左式堆中,右侧链的长度 d ≤ [ log2(n+1)] - 1 = O(logn)

(xa2)左式堆:合并

LeftHeap

template <typename T> //基于二叉树,以左式堆形式实现的优先级队列
class PQ_LeftHeap : public PQ<T>,public BinTree<T>{
public:
	void insert(T); //(按比较器确定的优先级次序)插入元素
	T getMax() {return _root->data;}//取出优先级最高的元素
	T delMax(); //删除优先级最高的元素
}; //主要接口,均基于同一的合并操作实现
template <typename T> static BinNodePosi(T) merge(BinNodePosi(T),BinNodePosi(T));

左式堆不满足结构结构性,物理结构不再保持紧凑性。
merge实现

template <typename T> static BinNodePosi(T) merge(BinNodePosi(T) a, BinNodePosi(T) b){
	if(!a) return b;
	if(!b) return a;
	if(lt(a->data,b->data)) swap(b,a);//一般情况下首先确保a≥b
	a->rc = merge(a->rc,b);//将a的右子堆与b合并
	a->rc->parent = a;//更新父子关系
	if (! a->lc || a->lc->npl < a->rc->npl)//若有必要
		swap(a->lc,a->rc);//交换a的左右子堆,以确保右子堆的npl不大
	a->npl = a->rc ? a->rc->npl + 1 : 1 ;//更新a的npl
	return a;//返回合并后的堆项
} //|rChain| = O(logn)

(xa3)左式堆:插入与删除

insert()

template <typename T> void PQ_LeftHeap<T>::insert(T e){
	BinNodePosi(T) v = new BinNode<T>(e);//为e创建一个二叉树节点
	_root = merge(_root,v); //通过合并完成新节点的插入
	_root->parent = NULL; //堆非空,则应当设置父子链接
	_size++; //更新规模
}

delMax()

template <typename T> T PQ_LeftHeap<T>::delMax(){
	BinNodePosi(T) lHeap = _root->lc;//左子堆
	BinNodePosi(T) rHeap = _root->rc;//右子堆
	T e = _root->data; //备份堆顶的最大元素
	delete _root; _size--; //删除根节点
	_root = merge(lHeap,rHeap);//原左右子堆合并
	if(_root) _root->parent = NULL; //更新父子链接
	return e; //返回原根节点的数据项
}

第十一章 串

(a)ADT

定义:为来自字母表∑的字符所组成的有限序列。为何不用序列实现串?通常字符的种类不多,而串长=n>>|∑|,比如英文文章、C程序、DNA等。
术语 - 相等:S[0,n) = T[0,m) - 长度相等(n = m)且对应的字符均相等(S[i] = T[i])。
子串:S.substr(i, k) = S[i, i+k), 0 ≤ i ≤n,0≤k,从S[i]起的连续k个字符。
前缀:S.prefix(k) = S.substr(0, k) = S[0, k), 0≤k≤n,S中最靠前的k个字符。
后缀:S.suffix(k) = S.substr(n-k, k) = S[n-k, n), 0≤k≤n,S中最靠后的k个字符。
联系:S.substr(i, k) = S.prefix(i+k).suffix(k)
空串:S[0, n=0),也是任何串的子串、前缀、后缀。
ADT

length()
charAt(i)
substr(i,k)
prefix(k)
suffix(k)
concat(T)
equal(T)
indexOf(P) //模式P在文本T中所对应的秩

(b1)串匹配

功能要求:记 n = | T |,m = | P |,通常 n>>m>>2。讨论:detection(P是否出现?)、location(首次在哪里出现?)、counting(共有几次出现?)、enumeration(各出现在哪里?),更加关注目标串是否出现,安全过滤系统更加关注病毒的特征串是否出现。
算法评测:如何客观评测?
采用随机T+随机P:不妥,以∑={0,1}*为例,|{长度为m的P}| = 2^m,|{长度为m且在T中出现的P}| = n - m + 1 < n,如此匹配成功的概率 = n/2^m ,将无法对算法做充分测试。
采用随机T:对成功、失败的匹配分别测试,成功则在T中随机取出长度为m的子串作为P分析平均复杂度;失败则采用随机的P统计平均复杂度。

(b2)蛮力匹配

构思:自左向右,以字符为单位依次移动模式串,直到在某个位置发现匹配。
版本1

int match(char * P, char * T){
	size_t n = strlen(T),i = 0;
	size_t m = strlen(P),j = 0;
	while (j < m && i < n) //自左向右逐个比对字符
		if (T[i] == P[j]) {i++; j++} //若匹配,则转到下一对字符
		else {i -= j - 1; j = 0; }//否则T回退,P复位
	return i-j;
}

版本2

int match(char * P, char * T){
	size_t n = strlen(T),i = 0; //T[i]与P[0]对齐
	size_t m = strlen(P),j; //T[i+j]与P[j]对齐
	for (i = 0; i < n - m + 1;i++){//T从第i个字符起
		for (j = 0; j < m;j++) //P中对应字符逐个比对
			if(T[i+j]!=P[j]) break;//若失配P整体右移一个字符,重新比对
		if (m <= j) break; //找到匹配字符串
	}
	return i;
}

复杂度:最好情况(只经过一轮比对,即可确定匹配)比对 = m = O(m);最坏情况(每轮都比对至P的末字符,且反复如此)每轮循环 = m次,循环次数 = n - m + 1,一般 m << n,故总体比对O(n×m)。|∑|越小,最坏情况出现的概率越高,m越大,最坏情况的后果更加严重。

(c1)KMP算法:从记忆力到预知力

蛮力低效:在T回退、P复位之后,此前比对过的字符,将再次参加比对。不变性:T[ i - j , i )==P[ 0, j),此时我们已经掌握T[ i - j, i )的全部信息,只要记忆力足够强,在失败之后的下一次比对中T[ i - j, i ]就不必再次接受比对。

(c2)KMP算法:查询表

事先确定t,构造查询表 next [0, m) :在任一位置 P[j] 失败后,将 j 替换为 next[j],与其说借助强大的记忆,不如说做好充分的预案。

int match(char * P, char * T){
	int * next = buildNext(P); //构造next表
	int n = (int)strlen(T),i=0; //文本串指针
	int m = (int)strlen(P),j=0; //模式串指针
	while (j < m && i < n) //自左向右逐个比对字符
		if ( 0 > j || T[i]==P[j]){//若匹配
			i++; j++; //则携手共进
		}else //否则P右移,T不回退
			j = next[j];
	delete [] next; //释放next表
	return i - j;
}

(c3)KMP算法:理解next[]表

自匹配=快速右移 借助必要条件,排除对齐位置。对任意j,考察集合N(p, j) = { 0 ≤ t < j | P[0, t) == P[ j - t, j) },亦即在P[j]的前缀P[0, j)中,所有匹配真前缀和真后缀的长度。因此一旦T[i] ≠ P[j],可从N(P, j)中取某个t,令P[t]对准T[i],并继续比对。
最长自匹配=快速右移+避免回退
Next[0] = -1 ,对应代码段中 if ( 0 > j ) :巧妙地使用哨兵,可以简化代码 / 统一理解。虚拟实验既是物理学的有效研究方法,也是计算机科学的重要技巧。

(c4)KMP算法:构造next[]表

递推:根据已知的 next[0, j],如何高效地计算next[j + 1]?所谓 next(j)即在P[0, j)中最大自匹配的真前缀和真后缀的长度,故 next[j + 1] ≤ next[j] + 1,特别地,当且仅当**P[j] == P[next[j]]**时取等号。
next[ j + 1 ]的候选者依次应该是 1 + next[j],1 + next[ next[j] ],1 + next[ next[ next[j] ] ]…这个序列严格递减且必收敛于1 + next[0] = 0,以上递推过程即是P的自匹配过程,只需对KMP框架略做修改。
实现

int * buildNext( char * P){//构造模式串P的next[]表
	size_t m = strlen(P),j=0;//“主”串指针
	int * N = new int[m]; //next[]表
	int t = N[0] = -1; //模式串指针(P[-1]通配符)
	while(j < m-1)
		if( 0 > t || P[j]==P[t]) //匹配
			N[ ++j ]=++t;
		else //失配
			t = N[t];
	return N;
}//O(n)
## (c5)KMP算法:分摊分析
O(n+m)!:令 k = 2*i - j

```cpp
while (j < m && i < n)//k随迭代单调递增,故是迭代步数的上界
	if ( 0 > j ||T[i]==P[j])
		{i++;j++} //i,j同时加1,故k恰好加1
	else
		j = next[j]; //i不变,j至少减1,故k至少加1

k的初值为0,算法结束时必有 k = 2*i - j ≤ 2(n -1)-(-1) = 2n - 1 = O(n)

(c6)KMP算法:再改进

汲取教训

int * buildNext(char * P){
	size_t m = strlen(P),j=0;//“主”串指针
	int * N = new int[m]; //next表
	int t = N[0]= -1;//模式串指针
	while (j < m - 1)
		if( 0>t ||P[j]==P[t]){//匹配
			j++;t++;N[j]=P[j]!=P[t]?t:N[t];
		}else//失配
			t=N[t];
	return N;
}

蛮力:n - m轮,每轮至多m次比对,O(n.m)
KMP:O(2n)

(d1)BM算法:坏字符

局部:多次成功,一次失败;整体:一次成功,多次失败。
BC算法善待教训:更多地关注教训,使之更早地出现,更大者更早出现。
以终为始:每一轮比对都从末字符开始,自后向前,自右向左。
坏字符Y:某趟扫描中一旦发现T[i+j] = X ≠ Y = P[j],则P 相应地右移,并启动新的一轮扫描比对。
构造bc[]表

int * buildBC(char * P){
	int * bc = new int[256]; //bc[]表,与字母表等长
	for(size_t j=0;j<256;j++) bc[j]=-1;//初始化(统一指向通配符)
	for(size_t m=strlen(P),j=0;j<m;j++)//自左向右扫描
		bc[P[j]]=j; //刷新P[j]的出现位置记录(画家算法:后来覆盖以往)
	return bc;
}//第二个循环:通过引入临时变量m,避免反复调用strlen()

附加空间 = | bc[] | = O( |∑| ) = O(s)
时间 = O(|∑| + m ) = O( s + m )
最好情况:O( n/m ),一般只要P不含T[i+j]即可直接移动m个字符,仅需单次比较即可排除m个对齐位置。单次匹配概率越小,性能优势越明显。
最坏情况:O( m )×n = O( n×m )

GS算法善于利用经验(匹配的后缀),在所有前缀P[0,t)中,取与U的后缀匹配的最长者。shift = gs[j] = j - k,无论如何位移量只取决于j和P本身,可预先计算,并制表待查。

MS[] - > ss[]
MS[j]:P[0, j]的所有后缀中,与P的某一后缀匹配的最长者。
ss[j] = | MS[j] | = max{ 0≤s≤j+1 | P(j-s,j] = P[m-s,m)}

ss[] - > gs[]
(1)若ss[ j ] = j + 1,则对于任一字符 P[ i ]( i < m - j - 1),m - j - 1必是gs[i]的一个候选;
(2)若ss[j] <= j,则对于任一字符P[m - ss[j] - 1],m - j - 1必是 gs[m - ss[j] - 1]的一个候选。

构造ss[]:采用蛮力策略,每个字符都需一趟扫描,累计O(m²)时间;自后向前逆向扫描,只需O(m)时间。

(e3)BM_GS算法:综合性能

性能:空间 | bc[] | + | gs[] | = O( |∑| + m ),预处理O( |∑| + m )
查找:最好O( n/m ),最差O( n+m )

BF: O(n+m) ~ O( nm )
KMP:O(n+m)
BC:O(n/m) ~ O(n
m)
BM = BC + GC :O(n/m) ~ O(n+m)

(f1)Karp-Rabin算法:串即是数

逻辑系统的符号、表达式、公式、命题、定理、公理等均可以不同的自然数标识。
素数序列p(k) = 第k个素数,每个有限维的自然数向量唯一对应于一个自然数 <a1, a2, …, an> ~ p(1)^(1+a1) × p(2)^(1+a2) × … × p(n)^(1+an)。
十进制串可直接视作自然数,类似的,一般地随意对字符编号{0, 1, 2, …, d - 1},每个字符串都对应于一个d进制自然数。

(f2)Karp-Rabin算法:散列

数位溢出:如果 |∑|很大,模式串P较长,其对应的指纹将很大,比如若将P视作 |P| 位的 |∑| 进制自然数,并将其作为指纹。以ASCII字符集为例,|∑|=128=2^7,只要|P|>9,则指纹的长度将至少是7×10 = 70bits,然而目前的字长一般不超过64位,更重要的是指纹的计算和比对将不能再O(1)的时间内完成,准确地说需要O(|P| / 64) = O(m)的时间,总体需要O(n*m)时间(与蛮力相当)。
关键技巧:借助散列,将指纹压缩至存储器支持的范围,比如采用模余函数 hash( key ) = key % M。
散列冲突:hash()值相等并非匹配的充分条件,因此通过hash()筛选后,还须经严格比对,才可最终确定是否匹配。
快速指纹计算:hash()的计算每次均需O(|P|)时间,怎样加速?类似于进制转换算法,相邻的两个指纹之间存在某种相关性,利用上述性质即可在O(1)时间内由上一指纹得到下一指纹。

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值