拼写检查编程题详解-BK树算法

2023-10-09 21:20

本文主要是介绍拼写检查编程题详解-BK树算法,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

目录

问题:1035:拼写检查

分析:

编辑距离:

BK树:

构造BK树:

查询相似词:

推论:

C++AC代码:


问题:1035:拼写检查

查看    提交   统计     提示        提问

总时间限制:2000ms           内存限制:65536kB

描述

现在有一些英语单词需要做拼写检查,你的工具是一本词典。需要检查的单词,有的是词典中的单词,有的与词典中的单词相似,你的任务是发现这两种情况。单词A与单词B相似的情况有三种:

1、删除单词A的一个字母后得到单词B;

2、用任意一个字母替换单词A的一个字母后得到单词B;

3、在单词A的任意位置增加一个字母后得到单词B。

你的任务是发现词典中与给定单词相同或相似的单词。

 

输入

第一部分是词典中的单词,从第一行开始每行一个单词,以"#"结束。词典中的单词保证不重复,最多有10000个。
第二部分是需要查询的单词,每行一个,以"#"结束。最多有50个需要查询的单词。
词典中的单词和需要查询的单词均由小写字母组成,最多包含15个字符。

输出

按照输入的顺序,为每个需要检查的单词输出一行。如果需要检查的单词出现在词典中,输出“?x is correct",?x代表需要检查的单词。如果需要检查的单词没有出现在词典中,则输出"?x: ?x1 ?x2 ...?xn",其中?x代表需要检查的单词,?x1...?xn代表词典中与需要检查的单词相似的单词,这些单词中间以空格隔开。如果没有相似的单词,输出"?x:"即可。

样例输入

i
is
has
have
be
my
more
contest
me
too
if
award
#
me
aware
m
contest
hav
oo
or
i
fi
mre
#

样例输出

me is correct
aware: award
m: i my me
contest is correct
hav: has have
oo: too
or:
i is correct
fi: i
mre: more me

查看         提交        统计   提示          提问

分析:

之前写了这道题普通的解法——分情况讨论,详见拼写检查编程练习题。如果我们想要获取和字典中单词有至多2个字母之差的单词,那么该如何处理,如果还是分情况讨论的话会非常复杂。这个问题难就难在,根据定义操作可以是单词任意位置上的,似乎不遍历字典是不可能完成的。那么该怎么解决呢?接下来慢慢进行分析。另外我们在使用搜索引擎时,有没有发现即使输错几个字母,搜索引擎依然能很快给我们推荐出想要的单词,非常智能。

编辑距离:

这里,我们只需通过修改一个字母即可将单词A转换为单词B。在更一般的情况下,任何两个单词都可以经过有限次的增加、删除、替换某个字母相互转换。这时我们就可以使用最少多少步的增加、删除、修改操作将两个单词(字符串)互相转换,来度量两个单词(字符串)有多像,换句话说就是两个字符串的相似度是多少。1965年,俄国科学家Vladimir Levenshtein给字符串相似度做出了一个明确的定义叫做Levenshtein距离,我们通常叫它“编辑距离”。字符串A到B的编辑距离是指,只用插入、删除和替换三种操作,最少需要多少步可以把A变成B。Levenshtein给出了编辑距离的一般求法,就是大家都非常熟悉的经典动态规划问题。求编辑距离问题请见编辑距离算法详解:Levenshtein Distance算法——动态规划问题。

BK树:

在自然语言处理中,这个概念非常重要,例如我们可以根据这个定义开发出一套半自动的校对系统:查找出一篇文章里所有不在字典里的单词,然后对于每个单词,列出字典里与它的Levenshtein距离小于某个数n的单词,让用户选择正确的那一个。n通常取到2或者3,或者更好地,取该单词长度的1/4等等。这个想法倒不错,但算法的效率成了新的难题:查字典好办,建一个Trie树即可;但怎样才能快速在字典里找出最相近的单词呢?这个问题难就难在,Levenshtein的定义可以是单词任意位置上的操作,似乎不遍历字典是不可能完成的。现在很多软件都有拼写检查的功能,提出更正建议的速度是很快的。它们到底是怎么做的呢?1973年,Burkhard和Keller提出的BK树有效地解决了这个问题。这个数据结构强就强在,它初步解决了一个看似不可能的问题,而其原理非常简单。

BK树或者称为Burkhard-Keller树,是一种基于树的数据结构,被设计于快速查找近似字符串匹配,比方说拼写纠错,或模糊查找,当搜索”aeek”时能返回”seek”和”peek”。BK树在1973年由Burkhard和Keller第一次提出,论文在这《Some approaches to best match file searching》。这是网上唯一的ACM存档,需要订阅。更细节的内容,可以阅读这篇论文《Fast Approximate String Matching in a Dictionary》。

首先,我们先观察Levenshtein距离的性质。令d(x,y)表示字符串x到y的Levenshtein距离,那么显然:

1. d(x,y) = 0 当且仅当 x=y         (Levenshtein距离为0 <==> 字符串相等)

2. d(x,y) = d(y,x)                        (从x变到y的最少步数就是从y变到x的最少步数)

3. d(x,y) + d(y,z) >= d(x,z)         (从x变到z所需的步数不会超过x先变成y再变成z的步数)   

最后这一个性质叫做三角形不等式(Triangle Inequality)。就好像一个三角形一样,两边之和必然大于第三边。给某个集合内的元素定义一个二元的“距离函数”,如果这个距离函数同时满足上面说的三个性质,我们就称它为“度量空间”。我们的三维空间就是一个典型的度量空间,它的距离函数就是点对的直线距离。度量空间还有很多,比如Manhattan距离,图论中的最短路,当然还有这里提到的Levenshtein距离。就好像并查集对所有等价关系都适用一样,BK树可以用于任何一个度量空间。

构造BK树:

建树的过程有些类似于Trie树。首先我们随便找一个单词作为根(比如game)。以后插入一个单词时首先计算单词与根的Levenshtein距离:如果这个距离值是该节点处第一次出现,建立一个新的儿子节点;否则沿着对应的边递归下去。例如,我们插入单词fame,它与game的距离为1,于是新建一个儿子,连一条标号为1的边;下一次插入gain,算得它与game的距离为2,于是放在编号为2的边下。再下次我们插入gate,它与game距离为1,于是沿着那条编号为1的边递归下去,递归地插入到fame所在子树;gate与fame的距离为2,于是把gate放在fame节点下,边的编号为2。

每个节点有任意个子节点,每条边有个值表示编辑距离。所有子节点到父节点的边上标注n表示编辑距离恰好为n。

查询相似词:

查询操作异常方便。如果我们需要返回与错误单词距离不超过n的单词,这个错误单词与树根所对应的单词距离为d,那么接下来我们只需要递归地考虑编号在d-n到d+n范围内的边所连接的子树。假如被检查的节点与搜索单词的距离d小于n,则返回该节点并继续查询。由于n通常很小,因此每次与某个节点进行比较时都可以排除很多子树。   

BK树是多路查找树,并且是不规则的(但通常是平衡的)。试验表明,一次查询所遍历的节点不会超过所有节点的5%到8%,两次查询则一般不会17-25%,效率远远超过暴力枚举。适当进行缓存,减小Levenshtein距离常数n可以使算法效率更高。需要注意的是,如果要进行精确查找,也可以非常有效地通过简单地将n设置为0进行。

举个例子,假如我们输入一个gaie,程序发现它不在字典中。现在,我们想返回字典中所有与gaie距离为1的单词。我们首先将gaie与树根game进行比较,得到的距离d=1。由于Levenshtein距离满足三角形不等式,因此现在所有离game距离超过2的单词全部可以排除了。比如,以aim为根的子树到game的距离都是3,而game和gaie之间的距离是1,那么aim及其子树到gaie的距离至少都是2。于是,现在程序只需要沿着标号范围在1-1到1+1里的边继续走下去。我们继续计算gaie和fame的距离,发现它为2,于是继续沿标号在1和3之间的边前进。遍历结束后回到game的第二个节点,发现gaie和gain距离为1,输出gain并继续沿编号为1或2的边递归下去(那条编号为4的边连接的子树又被排除掉了)……  

推论:

这里可能有人会有疑问为啥查找的时候只需要递归地考虑编号在d-n到d+n范围内的边就可以了?接下来让我们根据levenshtein距离的性质进行推导:

我们了解了编辑距离所表达的度量的空间之后,再来看下Burkhard和Keller所观察到的关键结论。

如果我们需要返回与错误单词gaie距离不超过n的单词,这个错误单词与树根game(可用任意字符串A代替)所对应的单词距离为d,因为levenshtein距离的性质(3)三角形不等式成立,则满足与gaie距离在n范围内的另一个字符串B,其与树根game的距离最大为d+n,最小为d-n。

推论如下:

d(gaie, B) + d(B, A) >= d(gaie, A),  即 d(gaie, B) + d(A,B) >= d    

                                                     -->  d(A,B) >= d - d(gaie, B) >= d - n

d(A, B) <= d(A,gaie) + d(gaie, B),   即 d(A, B) <= d + d(gaie, B) <= d + n

其实,还可以得到 d(gaie, A) + d(A,B) >= d(gaie, B)                   

                        --> d(A,B) >= d(gaie, B) - d(gaie, A) 

                        --> d(A,B) >= 1 - d >= 0 (gaie与B不等) 由于 A与B不是同一个字符串,所以d(A,B)>=1

所以, min{1, d - n} <= d(A,B) <= d + n,这是更为完整的结论。

C++AC代码:

#include <iostream>
#include <string>
#include <map>using namespace std;
struct nodew{             //存储单词信息,因为POJ要求输出相似词时,需按照读入顺序输出,所以得设置单词的顺序int order;string word;
};typedef struct treeNode{                    //结构体,存储树节点,我这里使用map容器,没有使用指针,性质差不多。但map会节省点空间。struct nodew word;map<int, struct treeNode*> bkTreeNode;  //map容器存储孩子节点,map的key对应编辑距离,value值对应结点。
}BKTreeNode;
bool isSame = false;                        //标识两个单词是否相同
map<int,string> result;                     //存储距离为1时的结果集int levenSTDistance(string x, string y);                   //计算字符串x和字符串y的levenshtein距离
int minOfTreeNum(int a, int b, int c);                     //返回a,b,c三个数中最小值
void queryWord(BKTreeNode* tNode, string word);            //查询单词是否在字典,如果不在的话返回距离为1的结果集
void buildTree(BKTreeNode* tNode, struct nodew nodBuild);  //构造BK树
BKTreeNode* newTreeNode();                                 //初始化节点。int main()
{string dic;                        //词典单词string word;                       //待查询单词//vector<string>::iterator vIter;  //一开始没有考虑顺序,所以换为了mapmap<int,string>::iterator mIter;   //map容器的迭代器string temp;   //中间变量int cnt = 1;   //为字典中单词编号,排顺序cin >> dic;BKTreeNode* root = newTreeNode();struct nodew nodetemp;     //单词结构体,初始化完成后再存入词典nodetemp.word = dic;nodetemp.order = 0;        //为树根单词编号为0root->word = nodetemp;while(1){cin >> dic;if(dic == "#"){break;}nodetemp.order = cnt++;    //为单词标记读入顺序nodetemp.word = dic;       //初始化单词buildTree(root,nodetemp);  //构造BK树}while(1){cin >>word;if(word == "#"){break;}isSame = false;        //每查完一个单词之后置为falseresult.clear();        //将结果集清空queryWord(root,word);  //在BK树中查找满足要求的单词if(!isSame)            //如果字典中没有找到,则输出相似单词{cout << word << ":";for(mIter = result.begin(); mIter != result.end(); mIter++) //迭代输出结果集{temp = mIter->second;cout << " " << temp;}}else{cout << word <<" is correct" ;    //在词典中找到的话,直接输出正确。}cout << endl;}return 0;
}//初始化节点
BKTreeNode* newTreeNode()
{BKTreeNode * node = new BKTreeNode;return node;
}void buildTree(BKTreeNode* tNode, struct nodew nodBuild) //构造BK树
{string newNode = nodBuild.word;                   //新进来的结点string dicNode = tNode->word.word;                //BK树中的节点int distance = levenSTDistance(newNode, dicNode); //计算编辑距离map<int,BKTreeNode*>::iterator iter;              //BK树子树迭代器iter = tNode->bkTreeNode.find(distance);          //查找孩子树种是否存在if(iter != tNode->bkTreeNode.end())               //存在的话迭代插入孙子结点{buildTree(iter->second, nodBuild);}else{                                            //不存在的话插入孩子结点BKTreeNode* tempNode = newTreeNode();tempNode->word = nodBuild;tNode->bkTreeNode[distance] = tempNode;}
}void queryWord(BKTreeNode* tNode, string word)
{string dicNode = tNode->word.word;int ord;int distance = levenSTDistance(dicNode, word);int i;map<int, BKTreeNode*>::iterator iter;if(distance == 0){isSame = true;}else{if(distance == 1){ord = tNode->word.order;result[ord] = dicNode;}i = max(1,distance-1);for(; i <= distance+1 ; i++){iter = tNode->bkTreeNode.find(i);if(iter != tNode->bkTreeNode.end()){queryWord(iter->second, word);}}}
}int minOfTreeNum(int a, int b, int c)  //返回a,b,c三个数中最小值
{int minNum = a;if(minNum > b ){minNum = b;}if(minNum > c ){minNum = c;}return minNum;
}int levenSTDistance(string x, string y)  //计算字符串x和字符串y的levenshtein距离
{int lenx = x.length();int leny = y.length();int levenST[lenx+1][leny+1];  //申请一个二维数组存放编辑距离int eq = 0;                   //存放两个字母是否相等int i,j;//初始化二维数组,也就是将最简单情形的levenshtein距离写入for(i=0; i <= lenx; i++){levenST[i][0] = i;}for(j=0; j <= leny; j++){levenST[0][j] = j;}//将串x和串y中的字母两两进行比较,得出相应字串的编辑距离for(i=1; i <= lenx; i++ ){for(j=1; j <= leny; j++){if(x[i-1] == y[j-1]){eq = 0;}else{eq = 1;}levenST[i][j] = minOfTreeNum(levenST[i-1][j] + 1, levenST[i][j-1] + 1, levenST[i-1][j-1] + eq);}}return levenST[lenx][leny];
}

注意:POJ上面要求数组必须是按照输入的顺序输出。

参考资料:http://www.matrix67.com/blog/?s=bk%E6%A0%91

这篇关于拼写检查编程题详解-BK树算法的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/175654

相关文章

Java Lambda表达式的使用详解

《JavaLambda表达式的使用详解》:本文主要介绍JavaLambda表达式的使用方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、前言二、Lambda表达式概述1. 什么是Lambda表达式?三、Lambda表达式的语法规则1. 无参数的Lambda表

详解如何使用Python构建从数据到文档的自动化工作流

《详解如何使用Python构建从数据到文档的自动化工作流》这篇文章将通过真实工作场景拆解,为大家展示如何用Python构建自动化工作流,让工具代替人力完成这些数字苦力活,感兴趣的小伙伴可以跟随小编一起... 目录一、Excel处理:从数据搬运工到智能分析师二、PDF处理:文档工厂的智能生产线三、邮件自动化:

Spring @RequestMapping 注解及使用技巧详解

《Spring@RequestMapping注解及使用技巧详解》@RequestMapping是SpringMVC中定义请求映射规则的核心注解,用于将HTTP请求映射到Controller处理方法... 目录一、核心作用二、关键参数说明三、快捷组合注解四、动态路径参数(@PathVariable)五、匹配请

git stash命令基本用法详解

《gitstash命令基本用法详解》gitstash是Git中一个非常有用的命令,它可以临时保存当前工作区的修改,让你可以切换到其他分支或者处理其他任务,而不需要提交这些还未完成的修改,这篇文章主要... 目录一、基本用法1. 保存当前修改(包括暂存区和工作区的内容)2. 查看保存了哪些 stash3. 恢

java String.join()方法实例详解

《javaString.join()方法实例详解》String.join()是Java提供的一个实用方法,用于将多个字符串按照指定的分隔符连接成一个字符串,这一方法是Java8中引入的,极大地简化了... 目录bVARxMJava String.join() 方法详解1. 方法定义2. 基本用法2.1 拼接

Java中的record使用详解

《Java中的record使用详解》record是Java14引入的一种新语法(在Java16中成为正式功能),用于定义不可变的数据类,这篇文章给大家介绍Java中的record相关知识,感兴趣的朋友... 目录1. 什么是 record?2. 基本语法3. record 的核心特性4. 使用场景5. 自定

MyBatis编写嵌套子查询的动态SQL实践详解

《MyBatis编写嵌套子查询的动态SQL实践详解》在Java生态中,MyBatis作为一款优秀的ORM框架,广泛应用于数据库操作,本文将深入探讨如何在MyBatis中编写嵌套子查询的动态SQL,并结... 目录一、Myhttp://www.chinasem.cnBATis动态SQL的核心优势1. 灵活性与可

Python struct.unpack() 用法及常见错误详解

《Pythonstruct.unpack()用法及常见错误详解》struct.unpack()是Python中用于将二进制数据(字节序列)解析为Python数据类型的函数,通常与struct.pa... 目录一、函数语法二、格式字符串详解三、使用示例示例 1:解析整数和浮点数示例 2:解析字符串示例 3:解

C/C++ chrono简单使用场景示例详解

《C/C++chrono简单使用场景示例详解》:本文主要介绍C/C++chrono简单使用场景示例详解,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友... 目录chrono使用场景举例1 输出格式化字符串chrono使用场景China编程举例1 输出格式化字符串示

MySQL 表的内外连接案例详解

《MySQL表的内外连接案例详解》本文给大家介绍MySQL表的内外连接,结合实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧... 目录表的内外连接(重点)内连接外连接表的内外连接(重点)内连接内连接实际上就是利用where子句对两种表形成的笛卡儿积进行筛选,我