拼写检查编程题详解-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

相关文章

Go语言使用select监听多个channel的示例详解

《Go语言使用select监听多个channel的示例详解》本文将聚焦Go并发中的一个强力工具,select,这篇文章将通过实际案例学习如何优雅地监听多个Channel,实现多任务处理、超时控制和非阻... 目录一、前言:为什么要使用select二、实战目标三、案例代码:监听两个任务结果和超时四、运行示例五

Linux线程同步/互斥过程详解

《Linux线程同步/互斥过程详解》文章讲解多线程并发访问导致竞态条件,需通过互斥锁、原子操作和条件变量实现线程安全与同步,分析死锁条件及避免方法,并介绍RAII封装技术提升资源管理效率... 目录01. 资源共享问题1.1 多线程并发访问1.2 临界区与临界资源1.3 锁的引入02. 多线程案例2.1 为

Python使用Tenacity一行代码实现自动重试详解

《Python使用Tenacity一行代码实现自动重试详解》tenacity是一个专为Python设计的通用重试库,它的核心理念就是用简单、清晰的方式,为任何可能失败的操作添加重试能力,下面我们就来看... 目录一切始于一个简单的 API 调用Tenacity 入门:一行代码实现优雅重试精细控制:让重试按我

Python标准库之数据压缩和存档的应用详解

《Python标准库之数据压缩和存档的应用详解》在数据处理与存储领域,压缩和存档是提升效率的关键技术,Python标准库提供了一套完整的工具链,下面小编就来和大家简单介绍一下吧... 目录一、核心模块架构与设计哲学二、关键模块深度解析1.tarfile:专业级归档工具2.zipfile:跨平台归档首选3.

idea的终端(Terminal)cmd的命令换成linux的命令详解

《idea的终端(Terminal)cmd的命令换成linux的命令详解》本文介绍IDEA配置Git的步骤:安装Git、修改终端设置并重启IDEA,强调顺序,作为个人经验分享,希望提供参考并支持脚本之... 目录一编程、设置前二、前置条件三、android设置四、设置后总结一、php设置前二、前置条件

python中列表应用和扩展性实用详解

《python中列表应用和扩展性实用详解》文章介绍了Python列表的核心特性:有序数据集合,用[]定义,元素类型可不同,支持迭代、循环、切片,可执行增删改查、排序、推导式及嵌套操作,是常用的数据处理... 目录1、列表定义2、格式3、列表是可迭代对象4、列表的常见操作总结1、列表定义是处理一组有序项目的

python使用try函数详解

《python使用try函数详解》Pythontry语句用于异常处理,支持捕获特定/多种异常、else/final子句确保资源释放,结合with语句自动清理,可自定义异常及嵌套结构,灵活应对错误场景... 目录try 函数的基本语法捕获特定异常捕获多个异常使用 else 子句使用 finally 子句捕获所

C++11范围for初始化列表auto decltype详解

《C++11范围for初始化列表autodecltype详解》C++11引入auto类型推导、decltype类型推断、统一列表初始化、范围for循环及智能指针,提升代码简洁性、类型安全与资源管理效... 目录C++11新特性1. 自动类型推导auto1.1 基本语法2. decltype3. 列表初始化3

SQL Server 中的 WITH (NOLOCK) 示例详解

《SQLServer中的WITH(NOLOCK)示例详解》SQLServer中的WITH(NOLOCK)是一种表提示,等同于READUNCOMMITTED隔离级别,允许查询在不获取共享锁的情... 目录SQL Server 中的 WITH (NOLOCK) 详解一、WITH (NOLOCK) 的本质二、工作

springboot自定义注解RateLimiter限流注解技术文档详解

《springboot自定义注解RateLimiter限流注解技术文档详解》文章介绍了限流技术的概念、作用及实现方式,通过SpringAOP拦截方法、缓存存储计数器,结合注解、枚举、异常类等核心组件,... 目录什么是限流系统架构核心组件详解1. 限流注解 (@RateLimiter)2. 限流类型枚举 (