19XDU校赛现场赛_H

H.QKO吃串

原题传送门

在补题前先根据5种题解恶补了许多的字符串知识

KMP算法

kmp算法主要用来较为高效地处理主串中的关键词定位问题,时间复杂度是O(m+n),会比暴力搜索快很多。
昨天花了不少时间仔细读了许多聚聚的博客,大体上差不多弄明白了kmp算法的思想核心:先对子串进行处理,求出长度为i (i=1,2,…,strlen(B)) 的相同前缀后缀的最大长度,将其存储在next数组中。再设置2个指针 i,j 用来遍历整个串,当发生不配对的情况后,考虑该字符前面的 相同字符串 的相同前缀后缀的最大长度(也就是next记录的值),将B串前进到B串的最大前缀与A串的最大后缀相对应的地方,用指针来进行表示就是 i 保持不变,j 变为 next 数组存储的值(数组编号是从0开始!因而j会指向B最大前缀的后一个字符,随后即可进行下一轮配对)(这里可能会考虑为什么B串可以直接前进到这个位置而不会在之前产生一些可能的配对呢?我们说如果之前还存在一些地方可能会有配对,那么至少这个位置的前后缀得相同,那么这个长度便肯定要大于所求的最大长度next了;言下之意便是next数组存储了最长的长度,也因而唯一确定了B串允许前进而不产生遗漏的最长长度)。下面我们考虑如何求出next数组的取值:当求出了长度为n时的最大长度next[n]后,对于长度为n+1的字串,如果下标为n与下标为next[n]的字符相同,那么next[n + 1] = next[n] + 1是毫无疑问的;如果上述的两个字符不同,就可以考察长为next[n]的前(或者后)缀的最长前后缀长度,然后不断比较新的最长前缀的后一个字符和B[n],当相等时就可以获得next[n + 1]的数值。其中在next的求解中可以有一步优化:如果说不匹配的字符与重新指向next的位置的字符相同,那显然是做了无用功,所以就可以直接跳去这一部分,重新搜索一个尽可能更好next的位置。

聚聚更加详细的kmp算法总结

字符串HASH

字符串HASH算法是将字符串映射到一个整数以达到快速查询的目的,我们自然希望这是一个单射以避免发生冲突,所以如何构造这个映射便显得尤为重要。
字符hash算法
学习了这位聚聚的博客,稍微总结一下:
将26个字母记为idx[i] = i - ‘a’ + 1

HASH方法:

1.自然溢出法:定义unsigned long long 数组,使用p进制表示。

hash[i]=hash[i−1]∗p+idx(s[i])

不断获取该hash数自动(因为%是一个效率很低的运算)溢出后(也就是mod2^64 - 1)的值。

2.单hash方法:

hash[i]=(hash[i−1])∗p+idx(s[i]) % mod

保证p与mod是素数且有p<mod,当p和mod较大的时候冲突几率很小。

3.双hash方法:将同一个串对不同的模数mod两次,分别作为横纵坐标,使用二维数组进行存储,作为hash结果,这种hash办法比较安全。

获取字符串的hash

若已知一个|S|=n的字符串的hash值,hash[i],1≤i≤n,其子串sl…sr,1≤l≤r≤n对应的hash值为:hash=hash[r]−hash[l−1]∗p(r−l+1)
如果有多组数据要处理,可以对p的次方进行预处理以提高效率。

至于大素数的选取,尽可能避开1e9 + 7, 1e9 + 9这种常见的素数,以防止出题人卡你,在原博客有一张素数表,日后用到可以前往查询。

在博客字符串系列(一)——伟大的字符串Hash中提及:

P的话我参考了李煜东前辈的给出的值——131或13331,此时冲突概率极低。

其中还提到了KMP、拓展KMP、最小表示法、Manacher、Trie、后缀数组、后缀自动机、AC自动机(树上KMP) 的字符串算法,以及树状数组、平衡树 等可以维护整个序列的一些结构,这些是我还需要在日后不断全面系统学习的算法知识。

HASH树和字典树

HASH树

学习于博客->HASH树

理论基础: 质数分辨定理(简要地说就是,n个不同的质数可以分辨出的连续的整数的个数与它们的乘积相同,分辨的方式是比较它们模这n个质数的有序数对)
我们即使只构造一颗10层的哈希树,就至多可以表示6 464 693 230 个数字,已经超过2^32-1的范围,从而其效率是相当高的。

下面具体介绍一下关于哈希树的一些操作:

在此之前,我们先构造一个结点结构:

struct NODE{
	Keytype key;
	Valuetype value;
	bool occupied;
	struct NODE * subNode[];
}

其中,key表示这一层的素数大小,value表示存储的数值,occupied用于记录当前结点的存储状况,subNode[]指向key个儿子结点。

题外话:因为原博客中提到 Hash树是一个“动态结构”,所以我在subNode的操作上纠结了好久。比如说,当前结点已被占据,subnode均为空,但value模key后余2,那么怎么样才能在不创建subnode[0]和subNode[1]的情况下给subNode[2]分配空间呢?当时误以为c++有这种强大的操作,也并没有深入思考,之后忽然想到指针的定义云云…加上大佬们提到“怎么搞都会造出0和1”…我忽然意识到了…所谓的动态结构可能是说:比如我在创建key值为7的结点时,将要把这个数值存入value,与此同时,我也得把这个结点的7个儿子都给同时分配好,这样子虽然不像单链表那样创建一个数值后链表只增长一段,也好过整体把hash树全部提前开出来要好。按照这个思路进行实现,hash树初始化的时候,就应该创建key为2的结点、同时把2个儿子结点分配好,以此作为根结点,在插入数据的时候从两个儿子结点开始存储,根结点的value留空。之后每开辟一个新的位置,就同时把这一层的key个儿子给创建好,这样子就实现了hash树。

题题外话:迫切地需要学会c++了!!hash树算法之所以自己搞了半天也没搞懂就是因为网上基本全部都是c++的代码…c已经可以…功成身退了…

插入:

我们根据 插入值 对给定素数序列的模数构成的新序列,寻找hash树中第一个为空(subNode[] == NULL || subNode[].occupied = 0)的位置,插入并且修改状态量即可。

查找:

通过余数序列一层层往下搜索并进行比较,就可以得知查找值是否存在。

值得注意的是:

在实际应用中,调整了质数的范围,使得比较次数一般不超过5次。也就是说:最多属于O(5)。因此可以根据自身需要在时间和空间上寻求一个平衡点。

删除:

在进行删除操作的时候,不进行物理上的删除,仅仅需要把occupied置为0,value保持不变,就可以在保证hash树结构不变的情况下完成删除。

优点:
  1. hash树结构简单,动态分配空间,不需要过久的初始化过程。另外,hash树是一个单向增加的结构,避免了当结点数减少时进行结构调整而造成额外的浪费。
  2. 查找快速,对于int范围内的整数,最多只需要10次的取余、比较,就可以知道其存在与否。事实上,在实际工作时可以通过选取合适的素数让这个次数更小。
  3. 结构不变。常规树在插入和删除时会进行一定的结构调整,就有可能会退化成效率更低的链表结构,hash的这一特点就有效的保证了查找效率。
缺点:
  1. 非排序性。hash树的排序能力远不如它的查找能力那么优秀,当不改进该结构强行通过遍历进行排序那么效率将低于其他树型结构。
Trie树(字典树、前缀树)

学习于博客->Trie树
Trie树主要用于查找、保存、排序大量的字符串(当然也可以写成11叉存储数字的字典树),常见操作有查找和插入,删除比较少见实现起来也有点复杂(?

Trie树要比hash树困难许多…不过就经典trie树而言,建立一个27叉(除了26个字母还要加上一个终止符号表示叶子)的字典树就可以运作了(虽然其空间浪费巨大),不过trie树的实现还有其他方法,下面有一些粗浅的总结。

List Trie 树

将 经典trie树 固定长度的儿子数组换为可变长度数组,从而减少了冗余空间的浪费。但是与此同时,不能通过映射进行状态转移,必须通过遍历才能找到下一个结点,增加了时间的开销。

Hash Trie 树

用键值对Key-Value代替可变数组,Key指向子字符结点,Value指向该结点的后一个状态。该实现方法是空间和时间的一种折中实现办法,在处理中小规模的词典并且要求实时更新的情况下比较适合。

以上2种实现的具体方法并没有仔细研究与学习,打算学习完c++后或遇到某些特定题目后再来补坑。

Double-array Trie 树

双数组 Trie 树是目前 Trie 树各种实现中性能和存储空间均达到很好效果的实现。

我们为每一个字母赋予代号x[i],之后根据一定的顺序读入需要存储的单词,并且以此构造出Base Array和 Check Array数组,实现双向的定位功能。
当我们构造好双数组后,先不考虑判断终点的问题(假设我们已经处理掉了),如果需要进行查找某个串,先前进到首字母的位置base[0] + x[str[0]],之后读取该位置的base值加上串中第二个字符的代号,前进到该位置,进行比较,以此类推比较到串尾即可。显然当我们查找的是字典中存在的单词时没有任何问题(因为字典——更准切的说,base数组就是根据这些单词构造出来的)但当我们试图查找一个不存在的单词时,因为Base Array的值仅仅决定了单向前进的方向,却不能提供父亲结点的位置,因而哪怕我们成功在base中前进到了想要的位置,但是无法保证该位置“从构造的角度看”确实是由上一个位置前进得到的(有可能该字符是因为别的单词才出现在这个位置)(具体见原博客,此处不赘述)。为了解决这个问题,我们开辟出跟Base数组等长的Check数组记录当前位置的父亲,在查找时,如果比较后无问题,与其同时进行check,保证当前字符是由上一个字符转移得到,那么查找字符串便不会产生问题了。
再考虑判断终点的问题,如果将单词终点直接改成某个统一的负数,那么某些长串的子串构成的单词可能就无法被表示了(否则长串就会断裂);如果将字符串统一改为"xxxx\0"的情况则会增加结点的长度,消耗额外的空间。所以我们考虑到,将base数组的空间充分利用起来,直接在结点位置的base数组取负,这样子就可以既表示结点,又不破坏其原本存储的信息。
最后我们再根据以上的一些要求,构造出符合心意的双数组。值得注意的地方是,选取读入单词的方式会极大的影响Base Array的长度和时间消耗,可以通过手工模拟原博客的样例知,如果按照整个单词整个单词的读,一旦出现某个首字母所在的位置被占用的情况时,需要修改的就是Base[0]的值了,这会导致之前的所有位置指向进行重构,造成巨大的时间浪费,所以我们优先对每一个单词的首字母进行指向,之后处理每一个单词的第二个字母,以此类推。通过这个操作,我们可以使Base Array指向的重构尽可能的少,从而减少无谓的时间消耗。

在博客Trie三兄弟中,比较详细的介绍了经典Trie、压缩Trie、后缀Trie,留待日后学习。

其中,压缩Trie可以有效的提高Trie树的构建效率,在内存和速度方面都比无优化的Trie树有很大的提高。具体实现是另外开辟Tail数组专门存放词尾,并将词尾结点的Base值指向Tail的下标,从而读取后缀。
在存入新串的时候进行的是通过比较,搜索到最长前缀然后进行分割的操作吗?
需要找一段实现好的代码进行参考(日后找到后补坑 || 自己实现)

这里引用2张原博客的图片来说明压缩Trie树的模型及其实现:

采用Tail Array来单独存储尾缀

小小结:
知识点DFS: 通过学习Trie树…了解到了有限状态自动机((Definite Automata, DFA)…还需要更加系统的进行学习。此外还dfs到了这样一串算法字符串模式匹配算法——BM、Horspool、Sunday、KMP、KR、AC算法一网打尽,下一步可能会对此进行更深入的学习。此时回头再看qko吃串的n种解法,emm不过还是有很多知识点的…渺沧海之一粟…(一道题可以补一年),里面还有暴力bitset?AC自动机?SA后缀数组?SAM后缀自动机?线段树?主席树?树状数组?加上之前在 字符串hash 中提及到的算法打算过一阵子也要一并补掉。

之后可能要停止补知识点一段时间,打算去luoguOJ、vj做一些KMP、Hash和Trie树的题目练练手,巩固一下学到的知识点。再之后可能会摸一下qko的小进化公式和选拔赛的counting stars这样一些数论题目… 一直看NLP谁顶得住呀…
临近期中考试…还鸽掉了数模校赛(反正什么都不会…)不过还是忙里偷闲学习acm,甚至还打算立个5月份学完c++的flag…

那么暂时这么多,留个天天天坑过一阵补。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值