• 拼写检查编程题详解-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;  //一开始没有考虑顺序,所以换为了map
        map<int,string>::iterator mIter;   //map容器的迭代器
        string temp;   //中间变量
        int cnt = 1;   //为字典中单词编号,排顺序
    
        cin >> dic;
        BKTreeNode* root = newTreeNode();
        struct nodew nodetemp;     //单词结构体,初始化完成后再存入词典
        nodetemp.word = dic;
        nodetemp.order = 0;        //为树根单词编号为0
        root->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;        //每查完一个单词之后置为false
            result.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

  • 相关阅读:
    [DB2]删除大数据量数据及57011错误处理
    [DB2]DB2日常维护——REORG TABLE命令优化数据库性能
    [转]解读DIV CSS网页布局中CSS无效十个原因
    [DB2]DB2 sqlstate 57016 原因码 "7"错误
    [翻译]15个最常见的WCF问题
    [DB2]DB2数据库备份与恢复和导出表结构与导入导出表数据
    [转]网站(bs系统)怎样实现即时消息思路总结
    【摘抄】DB2字符集问题
    [转]jQuery必知必熟基础知识
    sql 2005/2008 订阅与发布的几个概念
  • 原文地址:https://www.cnblogs.com/www-helloworld-com/p/10202922.html
Copyright © 2020-2023  润新知