本人对浙大数据结构课程的学习过程笔记,非常口语化不专业。
目录
查找算法
动态查找涉及到增删改查,当未查找到时可能需要插入该元素,当查找到时可能需要删除或改变元素。前面已知的几种查找方法:
顺序查找 O(N),二分查找 O(logN),
二叉搜索树 O(h),平衡二叉(搜索)数 O(logN),log均以二为底。
以上内容基本在本人对应的数据结构笔记中有记录,这里不再赘述。
前面两个数组形式的查找不适用于动态查找,移动数据非常麻烦,树虽然适合动态查找,但查找方式只能通过key值比较,没有数字形式的索引,如果key值比较非常麻烦,如字符串需要每个字符去比对,效率也很低。
查找的本质:已知对象找位置,为了提高效率可以是全序,如二分查找的顺序数组,或半序,在一定条件内有序,如平衡二叉树每个结点是左右子树的中位树。
对于字符串关键词数据,尤其是长字符串,可以使用散列表来做查找,散列表是随机存储,无法找最值,排序,找范围,一般字符串也没这些。
顺序查找
静态查找:数据一旦构建,此后仅查找,不再插入或删除。
所谓哨兵就是把一个附加的数据项放到数据结构中(常在一端留空数据位作为哨兵),使得边界条件不需要再进行特殊的处理。
有哨兵:数据结构Tbl->Element[0]留空,建立哨兵
无哨兵:
可见有哨兵可在每次判断时省去如i>0的边界判断分支。
顺序查找的时间复杂度O(n),平均要n/2。
二分查找
前提必须是元素的关键字有序,且连续存放的数据(数组)。
数据有序才能在二分之后判断是在前还是在后。
假设一数组按从小到大排列:
二分查找算法的时间复杂度O(logN),log以2为底。
之所以可以比顺序更快,就是因为多了事先排序,这就是树的意义。
一个有序(左支小右支大)二分查找判定树:
启示:可以通过树的形式存储数据,不依赖数组也能达到二分法logN的效率,以此解决动态查找问题。
散列表(哈希表)
设计一个函数,将key值映射成一个尽量唯一的数字位置,这样在查找时T几乎为常数O(1),把主要工作变成了函数的计算和解决结果冲突,显然函数要尽量计算简单,且地址空间分布均匀以减少冲突。
比如把一个长度TableSize为17的数组作为散列表h,可以将数据存放到下标为0-16的数组中,我们可以用h(key) = key mod TableSize作为散列函数,即把一个数对17求余取绝对值,结果即为数组下标(位置),如23mod17=6,则h(23)=6,指散列表h中23在位置6上。
散列表的操作集
用于管理一个符号表(SymbolTable)
散列表查找性能
装填因子
设散列表空间大小为m,已填入表中元素个数为m,则称α=n/m为散列表的装填因子,即表示装满程度,α越大散列表的查找效率越低,超过0.85是迅速降低,因为装得越满越容易发生位置冲突。
性能指标
成功平均查找长度ASLs:查找表里元素平均需要的次数,很好理解,一般就是表里元素数量+总冲突次数,再除以表里元素数量。
不成功平均查找长度ASLu:判断一类元素不在表里平均需要的次数
该长度13的表哈希函数是对11取余+线性探测,则22,33…都属于0类,因为对11取余都是0,如要判断22,33…不在表里要判断3次,数到2发现是空位才说明不在表里(因为是线性探测有可能往后放了),而18,40…要判断9次。所以ASLu为总空间+每个空间到下一个空位的距离之和(本来就是空位则距离为0,找的那次记在前面总空间数里了),再除以总位置(空间>=长度,使用开放定址法解决冲突一般会留有余量)
主要由三个因素影响:散列函数是否均匀,处理冲突的策略,装填因子的大小。
设计散列函数
数字关键词
即key值本身就是数字的元素。
1.直接定址法:直接根据key值线性的定址,h(key)=a*key+b,如数据为1990年以后的出生人数,h(key)=key-1990
2.除留余数法:直接用key值对地址数求余(取绝对值或保证数据均非负),一般取素数,制表空间不会小于元素种类(长度),取最近的不小于长度,不大于空间的素数即可。
3.数字分析法:每一位上的数字意义、变化情况可能不同,取其中比较随机的作为散列地址,如同一地区的11位手机号前面几位都一样,取后4位作为地址。把key看作一个字符串,有h(key)=atoi(key+7);atoi(const char *nptr)是ascii to integer,把字符串按ascii码转换成整型,传入key+7即传入字符串key第八位的指针,通用可写成(key+keylength-maxd),即key减去key长度加上需要长度。
4.折叠法:把关键词分割成位数相同的几个部分,然后相加,如57146拆成057+146=203,h(57146)=203
5.平方取中法:把key值平方,然后取中间几位数,如57146x57146=3265665316,取最中间的两位66,有点卷积的意思,考虑每一位数字
字符串关键词
1.ASCII码加和法:就是求字符串的ASCII码值之和,然后求余定址,ASCII码只定义了128个字符,由于字符串关键字经常很长,容易冲突。
2.简单移位法:针对上面的改进,如只取前3个字符并移位,将key[0]看作百位数,key[1]看作十位数,key[0]看作个位数,注意这里不是十进制,而选择字母的进制,即27进制(加上空格),故key[0]要乘以27的平方,key[1]乘以27,但是这种效率不高。
3.移位法:考虑所有位数的移位,此时如果使用上面数学的方法计算量大且复杂,故使用32进制,这样直接把每位左移5位即可,注意移位之后还要对地址长度求余,不然太大了空间浪费。
解决冲突
多维数组做散列表
我们可以使用多维数组来解决一维数组遇到的位置冲突问题,比如给字符串分类,创建一个26x2的二维数组Table作为散列表,h(key)=key[0]-‘a’,即按首字母分别放在h[0-25],每行可以容纳两个元素,先来后到,如先来了char放在h[2][0],又来了ceil放在h[2][1]。
如果没有冲突,则增删改查的时间复杂度都为O(1)。
开放定址法
若某个位置发生第i次冲突,则试探的下一个地址将增加di,依然要求余防止超出散列表空间。基本公式为hi(key)=(h(key)+di) mod TableSize。Di决定了不同的探测方案:线性探测di=i,平方探测di=+/-i平方,双散列设计一个备用散列函数di=i*h’(key),比如h’(key)=p-(key mod p)。
线性探测的问题很明显,就是容易造成数据聚集问题,因为占用了下一个地址就自动往后一位放,此后这一段的数据进来都会一直往后延,这样就导致在查找时可能出现找某个值要往后找很多次(冲突次数)才能找到,显然ASLu比较高。
平方探测是第1次增加1,第二次减去1,第三次增加4,第四次减去4,所以准确来说是di=+/-(i/2)平方,相比于线性探测ASLu较低,但线性探测只要表里还有空位就一定能插进去,而平方探测来回跳,可能会出现回路情况导致元素插不进去。定理:当散列表长度为某个4k+3形式的素数时,平方探测法就能探查到全部空间。
分离链接法
散列表数组的每个位置存放的不是单个元素,而是一个单链表的(空)头指针,把相应位置上冲突的所有关键词都存在同一个单链表中,空位置指向NULL,有点像桶排序。当然最后在释放表的时候别忘了先释放每个链表。
结构体改成数据域和指针域(取代Info位),黄框为简单的链表遍历查找。
再散列
当散列表元素太多,即装填因子过大时(我们一般控制在0.5以下,超过0.85是不能忍受的),为防止查找效率过低,可以加倍扩大散列表进行再散列rehashing,不是把原有的复制过来,是全部重新计算。
散列表的实现(以平方探测为例)
表的初始化
首先定义一个哈希表结构体,包括一个元素类型的指针(用于在堆上开辟数组)和一个int型变量记录表的空间大小。表的初始化如下:
输入为数据的种类长度,太小则没必要做散列,NextPrime()是取比TableSize大的最近的素数作为表的空间大小,然后给TheCells数组指针开辟对应大小的空间。
其中TheCells里的元素Cell是个结构体,包含数据域和记号info,info位用于判断该位置当前是否为空位,因为上述各种增删改查规则,我们删除元素时不能真的删除它,否则在查找或插入时会出现问题,有了记号,如果一个元素被删除了,根据info记号,插入时可以直接替代,等于修改操作,查找时又可以跳过,保证能继续往后去找真正的元素,这种用记号来标记而非真的删除叫做“懒惰删除”。
散列表的查找
CNum记录本次查找冲突次数,用以后续平方探测。先通过一个Hash函数求出原本位置CurrnetPos,赋给当前位置NewPos,然后在黄框里判断,如果当前位置的Info不为空,且还不是要找的那个值,就一直循环进行平方探测,根据CNum的奇偶性来判断是+还是-。
散列表的插入/修改
查找Find函数对于没有或被删除的元素,会返回遇到的第一个空位(info为空即视为空),在此插入即可。
先通过Find()找到位置,if判断要插入的元素是不是还没有,确定后改变Info位插入,注意如果key是字符串要用strcpy()赋值。
修改就是改if条件为判断Find()返回的是不是要改的那个元素即可,删除则是将Info位改掉即可。
散列表的应用
常用于文本的单词词频统计
这里的单词只考虑字母、数字和下划线,其他均认为是分隔符,GetAWord()每次开始读取,直到遇到分隔符结束,返回读取到的字符串,检查后InsertAndCount()插入到哈希表中。
串的模式匹配(KMP算法)
串是指线性存储的一组数据,默认是字符(串),作为线性结构串并不需要记录长度,而是通过结束字符标记结尾。串的特殊之处在于自身有一系列特殊的操作集,如求串的长度、比较两串是否相等、两串相接、求子串、插入子串、匹配子串、删除子串等。
本节只介绍相对较难的匹配子串。
如果只是从一小段文本找某个简单的单词字符串不需要太复杂的算法。当文本很大,比如一部小说,要找的字符串也很长,此时简单的字符串匹配算法显然要花费很长时间。模式匹配的目标就是:
简单匹配算法
strstr()函数的实现很简单暴力,在C语言课程有提过,基本思想就是滑动窗口,把string里的每个字符作为起始位置对着pattern扫描一遍,有错则下一个字符重新扫描匹配,整个过程需要不停地移动和重置指针,故若string有n个字符,pattern有m个字符,则时间复杂度为O(n·m),所以只有当n很小或者m非常小时才好用。
KMP算法思想
如上例,匹配到第六个字符发现不对,此时观察到pattern匹配成功的前5个字符串组成的子串有一个特点:即pattern[0]~pattern[1]和pattern[3]~pattern[4]是相同的小子串。那么窗口移动时不必只挪一位,可以直接挪到pattern[3]的位置,即当前string的指针s不必返回,而pattern的指针p只回移到首子串结尾处,直接在当前string的指针从pattern[2]开始匹配,如图所示,其实就是虽然当前不匹配了,但是如果有相同的前缀,而后缀又已经匹配过了,那前缀就可以直接利用上省得再匹配,因为是最大的相同前后缀,所以在这段匹配成功的子串里也不可能再有其他从头匹配成功的了。
这一过程可以抽象成一个函数match(j),j为pattern字符串的下标,i严格小于j,如果i=j则必然有p0…pi = pj-i…pj,自己和自己相等没意义。
对要匹配的模式pattern字符串先进行观察,从下标0开始,如果出现从头开始的某段前缀,等于从当前j下标往前的某段后缀,则记录该前缀的结尾下标i为match(j),不断更新只记录最大的相等子串(这里的match表就是模式串的前缀表,也就是最大相等前后缀的位置或长度,这里记录的是位置(下标),如果是长度则全部+1),这样从j=0直到pattern结尾把每个字符的match(j)都记录下来放进数组match[]。记录图示如下:
j=0,只有一个a,match(0)为-1;
j=1,有ab,没相等的match(1)为-1;
j=2,同理match(2)为-1;
j=3,首尾字符有pattern[0]= pattern[3],前缀结尾下标i为0,match(3)=0;
j=4,有前缀pattern[0]~ pattern[1]和从j往前的后缀pattern[3]~ pattern[4],前缀结尾下标i为1,match(4)=1;
j=5,有前缀pattern[0]~ pattern[2]和从j往前的后缀pattern[3]~ pattern[5],前缀结尾pattern[2]下标i为2,match(5)=2;
j=6,有前缀pattern[0]~ pattern[3]和从j往前的后缀pattern[3]~ pattern[6],前缀结尾pattern[3]下标i为3,match(5)=3;
j=7,前缀子串没有和后缀相等的,match(7)=-1;
j=8,首尾字符有pattern[0]= pattern[8],前缀结尾下标i为0,match(8)=0;
j=9,match(9)=1。
j-match(j)=前缀结尾i到后缀结尾j的距离。
可见match(j)这一函数过程和string文本无关,只和pattern模式有关。
KMP算法实现
第二种情况,已经匹配一段后面不对:
KMP算法调用:
Match(j)思路
有的也叫next或者prefix数组。如果使用简单的两头往里遍历比较,那效率很低,每次是O(m平方),外面再套个j循环,最终需要O(m立方)。实际上我们根据上面的例子可以发现match(j)也是有规律的,具体思路如下。
在求match(j)时可以先判断下j-1的最大匹配子串再往后加一个字符是否还相等,如果相等那match(j)就直接等于match(j-1)+1。这里不可能再往后还能加字符了,因为如果可以那match(j-1)就应该再往后一位,所以match(j)最大不超过match(j-1)+1。如下图所示。
如果首子串+1和尾子串+1不相等呢?那就再找这个match[j-1]的match值,必然有1和2相等(黑线),由于上面的首子串和尾子串相等,则1和4相等(绿线),同样1和3,2和4也是相等,也就是1=2=3=4。我们只需要有1=4就能继续做+1字符比较,如果成功则有match(j)= match[match[j-1]]+1,同理也不可能再往后加字符了,不然1+n=n+2=3+n=n+4,match[match[j-1]]也应该再往后。如此套娃(递归或循环实现)直到找到或到第一个字符都没找到。
Match(j)实现
所以KMP算法的时间复杂度为O(n+m+m)=O(n+m),当m远小于n时,T=O(n)。
本例只是最基础的KMP算法实现,还有很多优化版本。
长度前缀表KMP实现
void getNext(const string& s, int* next){//next前缀表实现
next[0] = 0;//next[0]赋值0,是记录长度的前缀表
int i,j=0;//i为字符串遍历,j为i的next值,也就是前缀结尾字符的下标,初值next[0]=0
for(i=1;i<s.size();i++){//i从1开始遍历字符串求next[i]
while(j>0&&s[i]!=s[j]){//一直回退到匹配或首字符,j不能=0,因为下面有j-1
j=next[j-1];//回退记录i的next值
}
if(s[i]==s[j])//说明是找到了,则next[i]要赋给j+1,j也要自增的,便于下个循环
next[i]=++j;
else next[i]=j;//否则说明是退到j=0了还没找到,则next[i]赋为0
}
}
int strStr(string haystack, string needle) {//kmp算法的模式匹配
int h = haystack.size();//字符串长度
int n = needle.size();//模式串长度
if(n==0) return 0;//模式串长度为0返回0
if(h<n) return -1;//字符串长度小于模式串长度返回-1
int next[n];
getNext(needle, next);
int s=0, p=0;//string的指针和pattern的指针
while(s<h&&p<n){//任意串到头结束循环
if(haystack[s]==needle[p]){//如果匹配就一直走
s++;
p++;
}
else if(p>0) p=next[p-1];//不匹配,但是p已经匹配一段了,回退到p-1的匹配前缀继续看行不行
else s++;//从头就不匹配或p一直退到0都不行,s直接++
}
return (p==n) ? (s-n) : -1;//如果是模式串指针p到头了说明匹配成功,否则说明是s到头了也没找到
}