Similarity search
任务
实现近似串查询,近似串有两种度量方式:Levenshtein edit
distance和Jaccard
index。需要实现SimSeacher类的createIndex、searchED、searchJaccard三个方法。
代码中实现了brute-force、tournament sort、MergeSkip@LLL08和DivideSkip@LLL08四种查询算法。
BruteForce
枚举所有字符串,和查询字符串一一进行Levenshtein edit distance或Jaccard index的计算, 若满足阈值条件则添加到存放结果的向量。
Tournament sort
采用filter-and-verification framework,代码中实现了索引、Filter和Verification三个部分。
索引
索引的数据结构是若干倒排索引,另外有一个hash table为Q-gram到倒排索引的映射。
执行createIndex构建索引时,
对于每个输入的字串,使用类似Rabin-Karp string
search算法的方式,获取当前长为
输入文件读入完毕后,对于hash table中的每个倒排索引(必然是非空的),在末端加入作为哨兵元素的无穷大。
Filter
对于一个查询,使用类似构建索引时的Rabin-Karp string search
algorithm,对于每个长为
建立binary heap
列表
Tournament sort进行N-way merge
堆顶指针指向的值为
,若old为无穷大则返回 计算堆顶元素指向的值
的出现次数,若大于等于阈值 则添加到候选集中 把堆顶元素指针向前移动一格(即指向对应的倒排索引的下一项),若下一项仍等于原来指向的值则继续移动
上述操作后堆顶指针指向的值变大了,对它进行下滤操作
若新的堆顶指向的值等于
则跳转到步骤3
之后对候选集的每个元素进行检验,是否满足Jaccard index或Levenshtein编辑距离的阈值要求, 输出筛选后的。
Verification
Levenshtein edit distance
对于searchED操作,需要对每个候选字串和查询字串计算编辑距离。
两个字符串的Levenshtein edit distance可以使用
注意到代码中使用到编辑距离的地方都有阈值
另外当其中某个字符串的长度小于等于128时,还可以采用bit
vector的算法@edit03加速到
Jaccard index
采用scan count的方式。 先用rolling hash计算查询串所有Q-gram的标号,在计数容器中增加一。然后对于候选串的所有Q-gram,若计数容器中的值大于零则减去零并加到答案中。再便利候选串的所有Q-gram,把减去的值再加回来。
MergeSkip
和tournament sort基本结构相同,对Filter部分做了优化,即优化了统计每个字串的出现次数,过程如下:
堆顶指针指向的值为
,若old为无穷大则返回 计算堆顶元素指向的值
的出现次数,若大于等于阈值 则添加到候选集中 把堆顶元素指针向前移动一格(即指向对应的倒排索引的下一项),若下一项仍等于原来指向的值则继续移动
上述操作后堆顶指针指向的值变大了,对它进行下滤操作
若新的堆顶指向的值等于
则跳转到步骤3 从堆中弹出
个元素,使用二分检索把这些倒排索引指针移动至大于当前堆顶指向的值,再重新插入堆中
DivideSkip
结合了MergeSkip和MergeOut。
使用启发函数把倒排索引划分为长索引和短索引两类,长索引有
个,其中 为索引个数 对短索引采用MergeSkip算法,对于短索引中所有出现次数不少于
的元素,在所有长索引中二分检索 将出现次数不少于
的元素添加到候选集中 把堆顶元素指针向前移动一格(即指向对应的倒排索引的下一项),若下一项仍等于原来指向的值则继续移动
上述操作后堆顶指针指向的值变大了,对它进行下滤操作
若新的堆顶指向的值等于
则跳转到步骤4 从堆中弹出
个元素,使用二分检索把这些倒排索引指针移动至大于当前堆顶指向的值,再重新插入堆中
其他
Tournament类使用了curiously recurring template pattern。
Similarity join
任务
实现similarity join,近似串有两种度量方式:Levenshtein edit
distance和Jaccard
index。需要实现SimJoiner类的joinED、joinJaccard两个方法。
BruteForce
枚举
DivideSkip
执行
索引
索引的数据结构是若干倒排索引,另外有一个hash table为Q-gram到倒排索引的映射。
执行createIndex构建索引时,
对于每个输入的字串,使用类似Rabin-Karp string
search算法的方式,获取当前长为
输入文件读入完毕后,对于hash table中的每个倒排索引(必然是非空的),在末端加入作为哨兵元素的无穷大。
Filter
对于一个查询,使用类似构建索引时的Rabin-Karp string search
algorithm,对于每个长为
建立binary heap
列表
Tournament sort进行N-way merge
堆顶指针指向的值为
,若old为无穷大则返回 计算堆顶元素指向的值
的出现次数,若大于等于阈值 则添加到候选集中 把堆顶元素指针向前移动一格(即指向对应的倒排索引的下一项),若下一项仍等于原来指向的值则继续移动
上述操作后堆顶指针指向的值变大了,对它进行下滤操作
若新的堆顶指向的值等于
则跳转到步骤3
之后对候选集的每个元素进行检验,是否满足Jaccard index或Levenshtein编辑距离的阈值要求, 输出筛选后的。
使用启发函数把倒排索引划分为长索引和短索引两类,长索引有
个,其中 为索引个数 对短索引采用MergeSkip算法,对于短索引中所有出现次数不少于
的元素,在所有长索引中二分检索 将出现次数不少于
的元素添加到候选集中 把堆顶元素指针向前移动一格(即指向对应的倒排索引的下一项),若下一项仍等于原来指向的值则继续移动
上述操作后堆顶指针指向的值变大了,对它进行下滤操作
若新的堆顶指向的值等于
则跳转到步骤4 从堆中弹出
个元素,使用二分检索把这些倒排索引指针移动至大于当前堆顶指向的值,再重新插入堆中
Verification
Levenshtein edit distance
需要对每个候选字串和查询字串计算编辑距离。
两个字符串的Levenshtein edit distance可以使用
注意到代码中使用到编辑距离的地方都有阈值
另外当其中某个字符串的长度小于等于128时,还可以采用bit
vector的算法@edit03加速到
两个字符串的Levenshtein edit
distance的下界是字符集所有字符出现频数差的绝对值的和的一半,当该下界小于等于阈值时再进行实际计算。可以用_mm_sad_epu8来估算频数差的绝对值的和。当某个字符频数差大于等于256时会低估实际值,但对结果没有影响。
Jaccard index
采用scan count的方式。 先用rolling hash计算查询串所有Q-gram的标号,在计数容器中增加一。然后对于候选串的所有Q-gram,若计数容器中的值大于零则减去零并加到答案中。再便利候选串的所有Q-gram,把减去的值再加回来。
ppjoin@ppjoin
之前一个疑惑是没理解similarity join和执行若干次similarity search有什么区别。 原来是similarity search构建的索引能支持阈值不同的查询,而similarity join直接指定了固定阈值, 因此构建的索引可以利用这个性质。
Prefix filtering
按照一个全序排序entity包含的gram,那么若两个串的overlap满足:
Jaccard index转化为overlap
由
另由$ J(x,y) t
如果需要能所探寻到所有overlap超过
论文中没有提及某gram被同一个entity包含多次的情况。这时
ppjoin
对于查询串
需要一种类似数组的数据结构支持三种操作:清零、访问某一项、列出上次清零以来所有访问过的项。可以用三个数组和一个计数器实现。
Positional filtering
根据overlap计算hamming distance上界后根据
当gram有重复时比较麻烦,SuffixFilterWeighted中不能直接用mid作为mid相同的字符。
对于可能重复的gram,另一种处理方式是让各gram取不同的值。
Approximate entity extraction
任务
实现近似抽取,近似串有两种度量方式:Levenshtein edit
distance和Jaccard index。
需要实现AEE类的createIndex、aeeED、aeeJaccard三个方法。
Faerie algorithm
实现采用@faerie中提到的Faerie algorithm。
索引
读入描述entity的文件采用mmap:
1 | struct stat s; |
其中的MAP_POPULATE是为了让产生read-ahead的效果,加速文件读入。
索引的数据结构包含:
entity向量
e把Q-gram映射为entity编号的若干倒排索引
s2gid。若干倒排索引
gid2e,为每个entity中出现的Q-gram保存包含该Q-gram的entity向量。
对于每个输入的字串,使用类似Rabin-Karp string
search算法的方式,获取当前长为
s2gid的每个倒排索引末尾添加哨兵元素INT_MAX。
近似抽取
Levenshtein edit distance和Jaccard index均可用Faerie算法处理。对于每个文档执行一次single-heap based algorithm。
建立binary heap
首先把长度为doc划分为s2gid映射后得到L。
Tournament sort进行N-way merge得到候选子串
令
如果是Levenshtein edit distance查询,则设置
记堆顶元素为
eeid,弹出所有等于eeid的元素得到@faerie中的。 由于位置为第二关键字,得到的
以及有序了。 使用尺取法寻找包含
中至少 个元素,且包含的Q-gram数在区间 内的所有窗口。 每个窗口可以产生若干候选子串。
代码使用了batch-count pruning,使用尺取法时可以用@faerie中提到的二分检索方法,一次把指针移动若干步。
Verification
之后对每个候选子串进行检验,是否满足Jaccard index或Levenshtein编辑距离的阈值要求。
Levenshtein edit distance
两个字符串的Levenshtein edit distance可以使用
注意到代码中使用到编辑距离的地方都有阈值
另外当其中某个字符串的长度小于等于128时,可以使用内置类型__int128采用bit
vector的算法@edit03加速到
两个字符串的Levenshtein edit
distance的下界是字符集所有字符出现频数差的绝对值的和的一半,当该下界小于等于阈值时再进行实际计算。可以用_mm_sad_epu8来估算频数差的绝对值的和。当某个字符频数差大于等于256时会低估实际值,但对结果没有影响。
Jaccard index
计算两个串
采用scan count的方式计算s2gid计算查询串所有Q-gram的编号,在计数容器中增加一。然后对于候选串的所有Q-gram,若计数容器中的值大于零则减去零并加到答案中。再遍历候选串的所有Q-gram,把减去的值再加回来。
Trie-based@taste
令Levenshtein编辑距离阈值为
若文档
当探测到文档的某子串
即需要把
多个串共同包含 时复用计算结果
多个串共同包含
把trie节点中检索到的entity按
构建下层trie比较耗空间,可以借鉴suffix
array的思路,把索引到的entity的右侧部分
另外如果发现计算到
生成结果
把匹配某个
文档的某个子串和某个
Aho-Corasick automaton
目前的方式是对于文档的每个位置都对上层trie探测得到候选entity,可以Aho-Corasick automaton改进。 注意Aho-Corasick automaton和Aho-Corasick algorithm的区别,前者会把trie改造为图(一些网络资料称之为trie图)。探测的文档子串的右边界在不断增加,左边界要根据Aho-Corasick状态的转移选择性变化。
1 | TrieNode *p = trie_root, *q; for (int i, j = 0; j < m; ) { |
对于每个匹配的p都要沿着p->pi->pi->…炼上溯到根。可以对于每个trie节点记录其最长的表示一个pi的若干次幂)。这样可以被多串匹配部分的复杂度从
演化
Faerie 7.263 Faerie+ED共用 4.197 Trie-based初版 2.4s 使用Aho-Corasick automaton后2.264s 利用公共前缀后 2.133s
实现空间数据库的即敲即得式检索系统
实验要求
实现空间数据库的即敲即得式检索系统
给定100w个POI,以及一个查询,找到所有的相关结果
基于位置的检索和即敲即得式检索
运行方式
项目由两部分组成,实现检索算法的部分为后端,由C++11编写; Web展示部分由网页前端和Ruby Sinatra写的web服务器组成。
运行后端:
1 | g++ -std=c++11 -O3 src/a.cc -ljsoncpp -o a |
将会读取当前目录下的zipcode.json并监听TCP 1337端口。
运行web服务器:
1 | ruby web/web.rb |
将会监听0.0.0.0:4567。
使用说明
浏览器打开,在输入框中填写字串name
字段包含字串
点击marker或者代表候选地点的右侧按钮, 即可显示起点到该地点的路径。
项目实现
JSON输入文件处理
输入文件为JSON格式的zipcode-address.json,后端程序使用C++库JsonCpp进行JSON的解析和渲染。
位置检索
根据实验要求,location-aware instant search的方法直观上是最适合的。但考虑到数据规模和prefix-region tree的描述,及想要实现任意子串而非单单前缀的查询,我在项目中分别实现了位置检索和地名检索。
位置检索属于multidimensional spatial data structure的应用。考虑到我们需要处理的询问:求出矩形区域内的地点和与给定点最近的k个地点,解决这类问题最常用的数据结构是R-tree家族。
R-tree
R-tree是派生于B+-tree的层次化数据结构,存储d维点或矩形,一般的物体可以转化为最小包围盒后用矩形的方式存储。
R-tree除根以外的节点度限制为
R-tree的操作通常用自顶向下遍历,根据查询和包围盒的相关性来剪枝。 R-tree的缺点是分解是数据相关的,很难进行集合论操作比如交集。
Bulk loading
很多应用数据是静态的,或者修改不频繁,即使是动态的也很有可能很多数据可以提前知道。这时一次插入一项会带来较大的加载代价,空间利用也不充分,结构不够好。此时进行根据输入数据特点进行bulk loading即建立初始数据结构是比较好的选择。
R-tree bulk loading的通用的算法是:
把矩形划分为
个分组,每个分组为一个叶节点。最后一个分组分配到的矩形可能不到 个。 递归地把若干个节点合并为一个上一层的大节点,直到根节点被创建。
一种比较好的启发式合并算法是Sort-Tile-Recursive。
Priority R-tree
@prtree是一种R-tree变体,window
query的时间复杂度为
Priority R-tree算法实际上指的是R-tree的一种bulk loading算法。 Priority R-tree的构建为若干次pseudo priority R-tree构建过程。
Pseudo priority R-tree的构建方式如下:
Extract B left-extreme points. Store them in a leaf-node.
Extract B bottom-extreme points. Store them in a leaf-node.
Extract B right-extreme points. Store them in a leaf-node.
Extract B top-extreme points. Store them in a leaf-node.
Partition the remaining n - 4B nodes into two halves based on our current tree depth, using the same scheme as found in the kd-tree data structure:
If ((depth % 4) == 0) partition by the x-coordinate (left-extreme).
If ((depth % 4) == 1) partition by the y-coordinate (bottom-extreme).
If ((depth % 4) == 2) partition by the x-coordinate in reverse (right-extreme).
If ((depth % 4) == 3) partition by the y-coordinate in reverse (top-extreme).
Recursivly apply this algorithm to create two sub-trees on the partitioned halves.
Stop when no points remain to be indexed (e.g. stop when n - 4B ≤ 0).
去除pseudo priority R-tree的内部节点,把叶节点看作物体继续调用pseudo
priority
R-tree进行下一轮构建,得到的内部节点再进行构建,如此反复直到叶节点数
我的实现参考了一个Java版的priority R-tree实现:https://github.com/dshanley/prtree。
K-nearest neighbors
采用best-first search实现k-nearest neighbors问题,以距离的下界作为估价函数。
地名检索
C++后端实现了给出一个字串,输出所有包含该字串的地点的功能。这个需求可以转化为string search问题。
Suffix array构建
Suffix array是解决string search问题的一个工具,它是一个字串所有后缀的排序。
线性时间的构建算法主要有Kärkkäinen-Sanders(@dc3)、Ko-Aluru(@ka)、KSP三种,其中KSP算法使用了类似suffix
tree的构建方式, Kärkkäinen-Sanders算法时间复杂度的递推式为
项目中我实现了Ko-Aluru算法,并按照@yangzhe的注记,借鉴@nzc对LMS substring递归排序使用的方法,对原始的Ko-Aluru算法进行改动以简化内存使用和提升性能。我的实现在字串和suffix array外仅使用两个数组:计数排序使用的桶和一个表示L/S类型的数组,反映了Ko-Aluru算法能达到和@nzc同等的精简内存占用。
Ka-Aluru suffix array construction algorithm
规定
令字符集为
并结合以上两种分类定义:
对于
Suffix array即为
根据@ka的induced sorting,只要确定了
下面考虑如何递归求出
对于两个长度不同的--substring
根据以上比较方式可以发现如果取出
先用桶排序求出所有-类型字符的大小,使用一次induced
sorting根据-计算 +,这样便得到了所有形如
对--substring计算新字符集大小,若小于--substring数目则说明所有
--substring的顺序已经确定,否则取出-字符递归计算suffix
array。得到这些--substring的顺序即
实现技巧
整个实现分为几个阶段,仍然设
计算
数组,需要使用 使用两次induced sorting计算--substring的大小,需要使用
根据--substring构建新字串递归计算子suffix array
根据子suffix array计算
部分的suffix array 使用induced sorting根据
部分的suffix array得到整体suffix array
其中第2、3步需要特别使用了很多技巧节省空间占用。
注意到
递归也需要使用
多数文献都在
为-,因为把哨兵字符看作字典序最小。 根据+-substring计算--substring的induced sorting过程提前把
放入桶中。 否则因为它是最后一个字符,无法被放入桶中,当该字符的桶包含两个或更多元素时可能会导致它的位置被覆盖而出错。 计算新字符集时,需要注意比较过程中到达边界的情况。
使用suffix array检索字串
把所有地点的名字拼接为一个字串后检索字串的问题就转化为找到所有包含该字串的位置。在suffix
array中检索所有长为
1 | def search(P): |
String search可以用suffix tree的top-down traversal轻易实现。 @sa04提出了使用lcp表增强suffix
array的功能,从而实现
childtab
其性质是后缀
满足
根据这些
\begin{aligned}
\text{up}[i] &=& \min{\{ q \in [0,i) | \text{lcp}[q] > \text{lcp}[i] \text{ and } \forall k\in [q+1,i): \text{lcp}[k]\geq \text{lcp}[q]\}} \\
\text{down}[i] &=& \min{\{ q \in [i+1,n) | \text{lcp}[q] > \text{lcp}[i] \text{ and } \forall k\in [i+1,q): \text{lcp}[k] > \text{lcp}[q]\}} \\
\text{next}[i] &=& \min{\{ q \in [i+1,n) | \text{lcp}[q] = \text{lcp}[i] \text{ and } \forall k\in [i+1,q): \text{lcp}[k] > \text{lcp}[i]\}} \\\end{aligned}
last = -1
push(0)
for i = [1,n)
while lcp[i] < lcp[top]
last = pop
if lcp[i] <= lcp[top] && lcp[st] != lcp[last]
down[top] = last
if last != -1
up[i] = last
last = -1
push(i)
while 0 < lcp[top]
last = pop
if lcp[top] != lcp[last]
down[top] = last
if last != -1
up[n] = last
push(0)
for i = [1,n)
while lcp[i] < lcp[top]
pop
if lcp[i] == lcp[top]
next[top] = i
pop
push(i)
有些项没有值,不妨设为无效值
用一个数组child表示
如果
对于一个可分割的
进一步,有多个区间终点相同,它们计算子区间要用到
其中、
表示
: -1 < child[i] && child[i] <= i表示
: child[i] > i && lcp[child[i]] > lcp[i]表示
: child[i] > i && lcp[child[i]] == lcp[i]
比如要在suffix array中搜索needle,@sa04中的伪代码没有考虑越界,修正如下:
found = true
(l, h) = get_interval(0, n, s[0])
i = 1
while found && i < m
if l < h-1
j = min(get_lcp(l, h), m)
found = a[sa[l]+i...sa[l]+j] == needle[i...j]
i = j
if i < m
(l,h) = get_interval(l, h, s[i])
else
found = a[sa[l]+i...sa[l]+m] == needle[i...m]
i = m
return found ? (l, h) : (-1, -1)
如果要输出方案,那么复杂度变为
得到包含needle的区间后,根据rank得到原字符串中的位置,即可知道哪些地点包含该字串。
结果呈现
前端
前端使用Slim作为HTML的模板引擎,Sass为CSS预处理器,CoffeeScript为JavaScript预处理器,使用Semantic-UI CSS框架。Model和view的绑定采用了Model View ViewModel设计模式的 knockout.js。
Web服务器
项目中实现了基于Ruby的Sinatra实现了一个web
server:web/web.rb。
对于地图点击,前端将用ajax向web服务器提交请求,web/web.rb收到查询请求后会使用TCP
socket向后端的C++服务器发送请求,
C++服务器进行位置检索,web服务器得到应答后以JSON格式发送给前端,前端再进行处理。
自动补全的实现方式类似。前端的jquery-autocomplete发现输入框内容改变后向web服务器提交ajax请求,web/web.rb收到查询请求后使用TCP
socket向后端的C++服务器发送请求,C++服务器使用suffix
array进行地名检索。
结果展示