一、 实验目的
- 深入理解汉语分词的基本概念。
- 掌握并实现前向最大匹配算法、后向最大匹配算法和最少分词法。
- 掌握分词的评价指标,学会计算正确率、召回率和F-测度值。
二、 实验内容
利用人民日报语料库或自己构建的语料库(30词以上)作为词典,任选五个句子,并基于正向最大匹配算法和最短路径法分别对这五个句子进行分词,并分别计算分词结果的正确率,召回率和F-测度值。输出句子,基于两种算法的分词结果和其对应的评价指标值。
三、 实验环境
操作系统:macOS Monterey 12.4
IDE:CLion 语言:C++
中文编码:UTF-8
四、 细节解析
4.1 建立词典
定义unordered_set类型全局变量dictionary,用于判断子串是否是词典中的词语,词典中的词语来自pku_training.txt。m表示词典中最长单词的字数*2,为什么要乘以2呢?因为在UTF-8编码中一个汉字占两个char。首先创建ifstream对象ifs,使用getlion方法按行读入字符串至read,再用read实例化stringstream 对象str,这样就可以根据空格提取该句子的每个词了,储存至dictionary中。
图1 建立词典
前向最大匹配
前向最大匹配算法思路较简单,int L表示现在遍历到的左端点,len表示现在考虑的字符串长度。每次通过L和len,调用substr( )函数得到子串,判断是否在词典中。若不在词典中,则len--;若在词典中,则在该词末尾添加分词标记,并更新L = L+len。直到L 等于句子长度后结束算法。注意,由于在UTF-8编码中一个汉字占两个char,部分操作需要考虑乘2。
图2 前向最大匹配
后向最大匹配
后向最大匹配算法与前向类似,int R表示现在遍历到的右端点len表示现在考虑的字符串长度。每次通过R和len,调用substr( )函数得到子串,判断是否在词典中。若不在词典中,则len--;若在词典中,则在该词末尾添加分词标记,并更新R = R - len。
图3 后向最大匹配
最小分词
最小分词算法可分为两步进行。
第一步是建图,需要建立一个结点数为n+1的有限无环图,其中n为输入字符串的长度。首先在相邻节点之间建立有向边,再依次枚举j和k(这里k从j开始枚举到min(字符串长度,m+j ) 结束,m为最长的词语的长度。这样能有效优化时间复杂度。),如果下标从j到k的子串在词典中,那么就连一条从j到k+1的有向边。
第二步是计算最短路,这里我使用了堆优化的迪杰斯特拉算法,时间复杂度为O(n\ logn)。在计算过程中需要记录最短路径,我定义了pre[]来记录某点是由哪个点转移过来的。
图4 最小分词算法
图5 堆优化Dijkstra求最短路
结果展示与评价指标计算
我使用了pku_training.txt中的词语作为词典,最终的结果如下。
图6 实验结果展示
5.1 最大匹配算法验证
输入:他是研究生命科学的一位科学家。
前向最大匹配分词结果:他/是/研究生/命/科学/的/一/位/科学家/。
后向最大匹配分词结果:他/是/研究/生命科学/的/一/位/科学家/。
结果符合预期。
输入:青年大学习还没完成的同学抓紧时间完成。
前向最大匹配分词结果:青年/大学/习/还/没完/成/的/同学/抓紧/时间/完成/。
后向最大匹配分词结果:青年/大/学习/还/没/完成/的/同学/抓紧/时间/完成/。
结果符合预期。
5.2 最小分词算法验证
输入:他是研究生命科学的一位科学家。
最小分词算法结果:他/是/研究/生命科学/的/一/位/科学家/。(词个数:9)
另一种候选结果:他/是/研究生/命/科学/的/一/位/科学家/。 (词个数:10)
结果符合预期。
5.3 评价指标计算
正确的分词结果应该是:
1 他/是/研究/生命科学/的/一/位/科学家/。
2 青年大学习/还/没/完成/的/同学/抓紧/时间/完成/。
3 今天/是/肯德基/疯狂/星期四/。
4 明日/起/将/全面/恢复/全市/正常/生产/生活/秩序/。
5 在/北京大学/生活区/喝/进口/白酒/。
前向最大匹配分词结果:
1 他/是/研究生/命/科学/的/一/位/科学家/。
2 青年/大学/习/还/没完/成/的/同学/抓紧/时间/完成/。
3 今天/是/肯德基/疯狂/星期四/。
4 明日/起/将/全面/恢复/全市/正常/生产/生活/秩序/。
5 在/北京大学/生活区/喝/进口/白酒/。
后向最大匹配分词结果:
1 他/是/研究/生命科学/的/一/位/科学家/。
2 青年/大/学习/还/没/完成/的/同学/抓紧/时间/完成/。
3 今天/是/肯德基/疯狂/星期四/。
4 明日/起/将/全面/恢复/全市/正常/生产/生活/秩序/。
5 在/北京大学/生活区/喝/进口/白酒/。
最小分词算法结果:
1 他/是/研究/生命科学/的/一/位/科学家/。
2 青年/大/学习/还/没/完成/的/同学/抓紧/时间/完成/。
3 今天/是/肯德基/疯狂/星期四/。
4 明日/起/将/全面/恢复/全市/正常/生产/生活/秩序/。
5 在/北京大学/生活区/喝/进口/白酒/。
对于FMM,正确切分的个数有38个,系统所有输出结果有46个;标准答案个数有43个。BMM和最小分词法输出结果相同,正确切分的个数有42个,系统所有输出结果有45个;标准答案个数有43个。其中F-测度值的\beta=1,计算结果如下表。
表1 评价指标值 %
分词方法 正确率 召回率 F-测度值
FMM 82.61 88.37 85.39
BMM 93.33 97.67 95.45
最小分词法 93.33 97.67 95.45
复杂度分析
建立词典需要遍历文件,C++ 中unordered_set由哈希表实现,每次操作时间复杂度O(1),故建立词典需要O(n)的时间复杂度,n为数据集大小。
最大匹配法在最坏的情况下时间复杂度为O(nm),其中m为词典中最长词语的长度,n为输入的句子长度。
最小分词在建图的过程中,时间复杂度为O(nm),其中m为词典中最长词语的长度,n为输入的句子长度。求最短路用到了堆优化的迪杰斯特拉,时间复杂度为O(m\ logn),n 表示点数,m表示边数。
整个算法的空间复杂度为O(n),n为数据集大小。
代码
#include <iostream>
#include <fstream>
#include <sstream>
#include <vector>
#include <unordered_set>
#include <queue>
using namespace std;
typedef long long LL;
typedef pair<int,int> PII;
const int n=5;
string str[5];
unordered_set<string> dictionary;//词典
int m; //词典中最长单词的字数*2
void creatDic(){
ifstream ifs("/Users/a26012/Desktop/大二下/NLP/实验/实验4/pku_training.txt");
ifs.unsetf(ios_base::skipws);
string read,word;
while(getline(ifs, read))
{
stringstream mystr(read);
while(mystr>>word){
if(!dictionary.count(word)) dictionary.insert(word);
m = max(m,(int)word.size());
}
}
}
void maximumMatching(){
vector<string> ans;
string temp;
cout<<"The result of FMM:\n";
for(int i=0;i<n;i++){
if(!ans.empty()) ans.clear();
int sz=str[i].size(),l=0,len;
while(l<sz){
for(len=min(m,sz-l);len!=0;len-=2){
temp=str[i].substr(l,len);
if(len==2||dictionary.count(temp)){
ans.push_back(temp);
l=l+len;
break;
}
}
}
for(int j=0;j<ans.size();j++) cout<<ans[j]<<(j==ans.size()-1?"\n":"/");
}
cout<<"\nThe result of BMM:\n";
for(int i=0;i<n;i++){
if(!ans.empty()) ans.clear();
int sz=str[i].size(),r=sz-1,len;
while(r>0){
for(len=min(m,r+1);len!=0;len-=2){
temp=str[i].substr(r-len+1,len);
if(len==2||dictionary.count(temp)){
ans.push_back(temp);
r=r-len;
break;
}
}
}
for(int j=ans.size()-1;j>=0;j--) cout<<ans[j]<<(j==0?"\n":"/");
}
}
vector<int> mp[510];
bool st[510];
int pre[510],nextt[510],dis[510];
void Dijkstra(int sz){
priority_queue<PII, vector<PII>, greater<PII>> heap;
memset(st,false,sizeof (st));
memset(dis,0x3f,sizeof (dis));
memset(pre,0x3f,sizeof (pre));
memset(nextt,0,sizeof (nextt));
dis[0]=0;
heap.push({0, 0});
while (!heap.empty())
{
auto t = heap.top();
heap.pop();
int ver = t.second, distance = t.first;
if (st[ver]) continue;
st[ver] = true;
for (int nx:mp[ver])
{
if (dis[nx] > distance + 1)
{
dis[nx] = distance + 1;
pre[nx] = ver;
heap.push({dis[nx], nx});
}
}
}
int nw=sz-1;
while(nw!=0){
int nx=pre[nw];
nextt[nx]=nw;
nw=nx;
}
}
vector<string> res;
void leastWord(){
cout<<"\nThe result of Least word segmentation:\n";
for(int i=0;i<n;i++){
if(!res.empty()) res.clear();
int sz=(int)str[i].size()/2;
//建图
for(int j=0; j <= sz; j++){
if(!mp[j].empty()) mp[j].clear();
if(j!=sz) mp[j].push_back(j+1);
}
for(int j=0;j<=sz;j++){
for(int k=j+1;k<sz && k-j<=m/2;k++){
string temp = str[i].substr(j*2,(k-j+1)*2);
if(dictionary.count(temp)){
mp[j].push_back(k+1);
}
}
}
Dijkstra(sz+1); //调用Dijkstra算法
int nw=0;
while(nw!=sz){
int nx=nextt[nw];
res.push_back(str[i].substr(nw*2,(nx-nw)*2));
nw=nx;
}
for(int j=0;j<res.size();j++){
cout<<res[j]<<(j==res.size()-1?"\n":"/");
}
}
}
int main()
{ cout<<"Please input five sentences:\n";
creatDic(); //建立词典
for(int i=0;i<n;i++){
cin>>str[i];
}
maximumMatching(); //最大匹配算法
leastWord(); //最小分词法
return 0;
}
//他是研究生命科学的一位科学家。
//青年大学习还没完成的同学抓紧时间完成。
//今天是肯德基疯狂星期四。
//明日起将全面恢复全市正常生产生活秩序。
//在北京大学生活区喝进口白酒。