Bootstrap

ue5异步加载关卡 搜索会员中心 消息历史创作中心发布独立游戏《星尘异变》UE5 C++程序开发日志8——实现敏感词过滤功能(AC自动机)

         在游戏中经常会有需要玩家输入一些内容的功能,例如聊天,命名等,这款游戏只有在存档时辉用到命名功能,所以这个过滤也只是一个实验性的功能,我们将使用AC自动机来实现,这是在我们把“csdn”这个词设置为屏蔽词后的效果:

目录

一、敏感词词典的处理

二、搭建AC自动机

1.自动机节点的数据机构

2.加载词典

3.建立字典树

4.建立失配指针

三、替换字符串中的敏感词


一、敏感词词典的处理

        我们是从别的地方找的开源词典,所以要做一下筛选,首先我们要去重,然后去除所有的标点符号空格和其他无关字符,然后同时去掉长度为1的字符,因为其会在AC自动机中表现的过于严格

wifstream InputTxt;
wofstream OutputTxt;
//词典的路径,这里是单独开了一个程序,所以和后面项目里相关代码用到的路径不同
InputTxt.open("Dict.txt", ios::out);
//使用宽字符串读入
wstring Word;
map<wstring, bool>Words;	
while (getline(InputTxt,Word))
{
    //去重
	if (Words.find(Word) == Words.end())
	{
        //去掉短字,但这里对中文无效,因为一个中文字长度大概率不为1
		if (Word.size() == 1)
			continue;
		for (auto& It1 : Word)
		{
            //统一成小写
			if (iswupper(It1))
			{
				It1 = towlower(It1);
			}
            //去除字符				
			if (iswpunct(It1)||iswblank(It1)||iswspace(It1))
			{
				Word.erase(It1);
				It1--;
			}				
		}
        //记录这个词处理完毕
		Words[Word] = true;
	}
}
InputTxt.close();
OutputTxt.open("Dict.txt", ios::out);
//将处理完的词重新写入词典
for (auto& It: Words)
{
	OutputTxt << It.first << endl;
}

二、搭建AC自动机

        AC自动机就是在字典树的基础上加入了类似于KMP的失配指针,当匹配串在树上失配时,会回溯到某个上一层的节点,该节点的所有父节点即前缀,和失配节点的所有父节点的后缀,形成最大匹配,使多模匹配的效率达到近似O(匹配串长度)

1.自动机节点的数据机构

        因为我们要将匹配到的敏感词替换成'*',所以相比于一般的自动机节点,要在每个词的末尾记录这个词的长度,同时因为不止26个字母,所以也用红黑树替代了数组

class FSensitiveWordFilterStruct
{
public:
	FSensitiveWordFilterStruct()=default;

	explicit FSensitiveWordFilterStruct(const wchar_t&InputCharacter):Character(InputCharacter){};
	
	//字符
	wchar_t Character{'#'};

	//匹配的字符串的长度
	int Length{0};

	//子节点
	TMap<wchar_t,std::shared_ptr<FSensitiveWordFilterStruct>>ChildNode;

	//失配指针
	FSensitiveWordFilterStruct* FailPointer{this};
};

        然后我们在游戏实例中声明自动机的根节点:

//屏蔽词过滤器树根
std::shared_ptr <FSensitiveWordFilterStruct>SensitiveWordFilterRoot;

        在游戏启动时初始化AC自动机,用到的函数后面一个一个讲:

UAstromutateGameInstance::UAstromutateGameInstance()
{
	//加载词典
	LoadTXTFile("/Movies/Dict.txt");
    //实例化自动机根节点
	SensitiveWordFilterRoot=std::make_shared<FSensitiveWordFilterStruct>(FSensitiveWordFilterStruct());
    //将词典中的词添加到树上
	for(const auto&It:*SensitiveWords)
	{
		AddWordToSensitiveWordTree(It);
	}
    //建立失配指针
	InitializeSensitiveWordTree();
}

2.加载词典

        这里我们把词典作为txt文件放在Movies文件夹下,因为该文件夹中的所有文件都会被原封不动的打包,我们将所有敏感词存到一个TArray中

//声明敏感词词典
TSharedPtr<TArray<FString>> SensitiveWords;
auto UAstromutateGameInstance::LoadTXTFile(const FString& Path)->void
{
    //获取词典路径
	FString Temp{FPaths::ProjectContentDir()+Path};
    //实例化词典数组
	SensitiveWords=MakeShared<TArray<FString>>(TArray<FString>());
    //加载所有词
	FFileHelper::LoadFileToStringArray(*SensitiveWords,*Temp);
	UE_LOG(LogTemp,Warning,TEXT("SensitiveWords loade %d Words"),SensitiveWords->Num());
}

3.建立字典树

        从根节点开始,遍历模式串,如果当前点没有当前字符对应的子节点,就创建之,然后无论有无都移动到该子节点

auto UAstromutateGameInstance::AddWordToSensitiveWordTree(const FString& InputString) const->void
{
    //获取根节点
	FSensitiveWordFilterStruct* Temp=SensitiveWordFilterRoot.get();
    //遍历模式串中的每一个字符
	for(const auto&It:InputString)
	{
		wchar_t CurrentChar{It};
        //如果当前点没有对应的子节点,就添加之
		if(!Temp->ChildNode.Contains(CurrentChar))
		{
			Temp->ChildNode.Add(CurrentChar,std::make_shared<FSensitiveWordFilterStruct>(FSensitiveWordFilterStruct(CurrentChar)));
		}
		Temp=Temp->ChildNode[CurrentChar].get();
	}
    //将词的长度记录在词尾
	Temp->Length=InputString.Len();
}

4.建立失配指针

        因为失配指针指向的节点一定在当前点的上层,所以我们进行bfs,首先将根节点的所有直连的子节点的失配指针指向根节点,因为这些点的上层节点只有根节点。然后对于一个失配点,如果其父节点的失配指针指向的点的子节点中有和该失配点相同的点,则失配点的失配指针指向该点,否则指向根节点

auto UAstromutateGameInstance::InitializeSensitiveWordTree() const -> void
{
    //bfs队列
	std::queue<std::shared_ptr<FSensitiveWordFilterStruct>>Queue;
    //将深度为1的点的失配指针指向根节点
	for(auto&It:SensitiveWordFilterRoot->ChildNode)
	{
		It.Value->FailPointer=SensitiveWordFilterRoot.get();
		Queue.push(std::make_shared<FSensitiveWordFilterStruct>(*It.Value));
	}
	while(!Queue.empty())
	{
		std::shared_ptr<FSensitiveWordFilterStruct> CurrentNode=Queue.front();
		Queue.pop();
        //遍历所有子节点
		for(auto&It:CurrentNode->ChildNode)
		{
            //父节点的失配指针指向的节点是否含有匹配的子节点
			if(!CurrentNode->FailPointer->ChildNode.Contains(It.Key))
			{
				It.Value->FailPointer=SensitiveWordFilterRoot.get();
			}
			else
			{
				It.Value->FailPointer=CurrentNode->FailPointer->ChildNode[It.Key].get();
			}
			Queue.push(std::make_shared<FSensitiveWordFilterStruct>(*It.Value));
		}
	}
}

三、替换字符串中的敏感词

        首先我们将玩家输入的字符串使用字典中字符串同样的方法进行处理,去除符号和空格,全部转为小写,然后遍历其每一个字符,不匹配就按失配指针移动,匹配就检查是否是词尾,如果是的话根据记录的词的长度算出这个词的区间,将这个居间内的所有字符替换成'*',该操作不会影响到后面的匹配,最后将字符串还原成原来有符号和空格的格式并返回

auto UAstromutateGameInstance::ReplaceSensitiveWords(const FString& RawString)->FString
{
	FString Result{""};
    //对玩家输入的字符串进行处理
	for(const auto&It:RawString)
	{
		if(iswpunct(It)||iswblank(It)||iswspace(It))
			continue;
		if(isupper(It))
			Result+=towlower(It);
		else
			Result+=It;
	}
	FSensitiveWordFilterStruct* Temp{SensitiveWordFilterRoot.get()};
    //遍历匹配串的每一个字符
	for(int i=0;i<Result.Len();i++)
	{
		wchar_t CurrentChar{Result[i]};
        //如果失配就一直回溯,直到根节点
		while(!Temp->ChildNode.Contains(CurrentChar)&&Temp!=SensitiveWordFilterRoot.get())
		{
			Temp=Temp->FailPointer;
		}
        //仍然适配就结束这个字符的搜索
		if(!Temp->ChildNode.Contains(CurrentChar))
		{
			Temp=SensitiveWordFilterRoot.get();
			continue;
		}
        //移动到匹配的节点
		Temp=Temp->ChildNode[CurrentChar].get();
		FSensitiveWordFilterStruct* Temp2{Temp};
        //遍历匹配到的所有词
		while(Temp2!=SensitiveWordFilterRoot.get())
		{
			if(Temp2->Length)
			{
                //根据长度算出该词其实位置
				for(int j=i-Temp2->Length+1;j<=i;j++)
				{
					Result[j]='*';
				}
			}
			Temp2=Temp2->FailPointer;
		}
	}
    //将处理完的字符串还原成输入的格式
	FString TrueResult{RawString};
	int CurrentIndex{0};
	for(auto&It:TrueResult)
	{
		if(iswpunct(It)||iswblank(It)||iswspace(It))
			continue;
		if(iswupper(It)&&iswlower(Result[CurrentIndex]))
		{
			continue;
		}
		It=Result[CurrentIndex++];
	}
	return TrueResult;
}

;