草庐IT

【数据结构与算法】前缀树的实现

阿亮joy. 2023-10-28 原文

🌠作者:@阿亮joy.
🎆专栏:《数据结构与算法要啸着学》
🎇座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根


目录

👉前缀树的实现👈

什么是前缀树

Trie(发音类似 “try”),被称为前缀树或字典树,是一种树形的数据结构,可用于高效地存储和检索字符串数据集中的键。这个数据结构有相当多的应用情景,例如自动补完和拼写检查。下图就是经典的前缀树,我们接下来要实现的前缀树的节点存储的数据比较丰富,以达到特定字符串在树中出现几次等类似的功能。

节点的定义

// 前缀树节点的定义
// 假设字符都是小写字母
struct TrieNode
{
	int pass = 0;	// 有几个字符串经过该节点(前缀包含这个字符的字符串数量)
	int end = 0;	// 以该节点为结尾的字符串的数量,如果不允许字符串重复插入,可以改成bool
	// next[0] == nullptr 表示没有走向'a'的路
	// next[0] != nullptr 表示有走向'a'的路
	// ...
	// next[25] != nullptr 表示有走向'z'的路
	TrieNode* next[26] = { nullptr };	// 26个空位,准备挂下一个节点'a'-'z',没有挂节点时为nullptr
	// 如果字符种类个数比较多,可以将数组换成哈希表或者set
};

构造函数

前缀树是用哨兵位头节点来管理整棵前缀树的,所以其构造函数需要 new 上一个哨兵位头节点。

class Trie
{
	typedef TrieNode Node;
public:
	Trie()
	{
		_root = new Node();
	}
private:
	Node* _root = nullptr;	// 哨兵位头节点,可以用来求前缀树中字符串的数量,也可以求空串的数量
};

注:哨兵位头节点的 pass 值可以表示前缀树含有的字符串数量,end 值可以表示前缀树含有空串的数量。因为任何字符串都会以空串作为前缀,都会经过哨兵位头节点。

插入字符串

我们从哨兵位头节点开始,插入字符串。对于当前字符对应的子节点,有以下两种情况:

  • 子节点存在:沿着指针移动到子节点,继续处理下一个字符。
  • 子节点不存在:创建一个新的子节点,记录在 next 指针数组的对应的位置上,然后沿着指针移动到子节点,继续处理下一个字符。
  • 插入字符串的同时,还需要更新沿途节点的 pass 值。

插入字符串图解:

class Trie
{
public:
	void Insert(const string& str)
	{
		Node* cur = _root;
		++cur->pass;	// 任何一个字符串都需要经过哨兵位头节点

		for (size_t i = 0; i < str.size(); ++i)
		{
			size_t index = str[i] - 'a';
			// 如果之前没有字符串经过该节点,则需要建出新节点
			if (cur->next[index] == nullptr)
			{
				cur->next[index] = new Node();
			}
			cur = cur->next[index];
			++cur->pass;
		}
		// cur指向字符串的最后一个节点,++cur->end表示多了一个字符串以该节点结尾
		++cur->end;
	}
}

如果不需要插入重复字符串,可以将函数的返回值改成 bool 类型。

查找字符串和前缀

class Trie
{
public:
	// 查找前缀树中有多少个要查找的字符串
	size_t Search(const string& str) const
	{
		Node* cur = _root;
		for (auto ch : str)
		{
			// 找的过程发现没路了,说明树中不存在要查找的字符串
			if (cur->next[ch - 'a'] == nullptr)
			{
				return 0;
			}
			cur = cur->next[ch - 'a'];
		}
		// cur是str最后一个字符,cur->end表示树中有多少个str
		return cur->end;
	}
	
	// 查找树中有多少个字符串以前缀prefix为前缀
	size_t StartsWith(const std::string& prefix) const
	{
		Node* cur = _root;
		for (auto ch : prefix)
		{
			// 找的过程中发现没有路,则说明没有字符串以prefix为前缀
			if (cur->next[ch - 'a'] == nullptr)
			{
				return 0;
			}
			cur = cur->next[ch - 'a'];
		}
		// cur->pass表示有多少个字符串以prefix为前缀
		return cur->pass;
	}
}

注:查找的过程和插入的过程非常的相似,只是查找时发现没有路,就直接返回 0,表示树中没有该字符串或者树中的字符串不以 prefix 为前缀。注:如果树中有要查找的字符串 str,则 cur->end 表示树中有多少个 str;如果树有字符串以 prefix 为前缀,则 cur->pass 表示多少个字符串以 prefix 为前缀。

析构函数

class Trie
{
	typedef TrieNode Node;
public:
	~Trie()
	{
		Destroy(_root);
	}
private:
	void Destroy(Node* root)
	{
		// 先销毁孩子节点,才能够销毁自己
		for (int i = 0; i < 26; ++i)
		{
			// root->next[i]不为空,则说明有节点,需要递归释放节点
			if (root->next[i] != nullptr)
			{
				Destroy(root->next[i]);
			}
		}
		delete root;
	}
}

前缀树析构时,需要先释放孩子节点,才能够释放哨兵位头节点。而孩子节点有可能会有孩子节点,所以我们可以采用递归去释放节点。

删除字符串

class Trie
{
	typedef TrieNode Node;
public:
	// 从树中删除字符串str,注:如果有多个str,只会删除一次
	void Erase(const string& str)
	{
		// 树中没有str,无法删除
		if (Search(str) == 0)
			return;

		Node* cur = _root;
		--cur->pass;

		for (size_t i = 0; i < str.size(); ++i)
		{
			size_t index = str[i] - 'a';
			// 如果发现str是唯一经过该节点的字符串
			// 那么就需要递归去释放当前节点及后续路径的节点
			if (--cur->next[index]->pass == 0)
			{
				Destroy(cur->next[index]);	// 递归释放节点
				cur->next[index] = nullptr;	// next需要置为nullptr
				return;
			}
			cur = cur->next[index];
		}
		// 如果字符串的所有字符都删除了一遍,还有该路径,那么最后要
		// --cur->end,表明树中str的个数减少了一个
		--cur->end;
	}
}

删除字符串时,需要看树中是否有需要删除的字符串。如果没有,直接 return 即可。如果有,才进行删除。进行删除时,如果发现 str 是唯一经过该节点的字符串,那么就需要递归去释放当前节点及后续路径的节点。

打印前缀树

class Trie
{
	typedef TrieNode Node;
public:
	void Print() const
	{
		cout << "根节点:[" << "pass: " << _root->pass << " end: " << _root->end << "]" << endl;
		_Print(_root);
	}
private:
	void _Print(Node* root) const
	{
		if (root == nullptr)
			return;
		for (int i = 0; i < 26; ++i)
		{
			if (root->next[i] == nullptr)
				continue;
			else
			{
				cout << "节点" << (char)('a' + i) << ":[pass: " << root->next[i]->pass << " end: " << root->next[i]->end << "]" << endl;
				_Print(root->next[i]);
			}
		}
	}
}

完整代码

#pragma once

#include <vector>
#include <string>
#include <iostream>
using namespace std;

// 前缀树节点的定义
// 假设字符都是小写字母
struct TrieNode
{
	int pass = 0;	// 有几个字符串经过该节点(前缀包含这个字符的字符串数量)
	int end = 0;	// 以该节点为结尾的字符串的数量,如果不允许重复插入,可以改成bool
	// next[0] == nullptr 表示没有走向'a'的路
	// next[0] != nullptr 表示有走向'a'的路
	// ...
	// next[25] != nullptr 表示有走向'z'的路
	TrieNode* next[26] = { nullptr };	// 26个空位,准备挂下一个节点'a'-'z',没有挂节点时为nullptr
	// 如果字符种类个数比较多,可以将数组换成哈希表或者set
};

class Trie
{
	typedef TrieNode Node;
public:
	Trie()
	{
		_root = new Node();
	}

	~Trie()
	{
		Destroy(_root);
	}

	// 查找前缀树中有多少个要查找的字符串
	size_t Search(const string& str) const
	{
		Node* cur = _root;
		for (auto ch : str)
		{
			// 找的过程发现没路了,说明树中不存在要查找的字符串
			if (cur->next[ch - 'a'] == nullptr)
			{
				return 0;
			}
			cur = cur->next[ch - 'a'];
		}
		// cur是str最后一个字符,cur->end表示树中有多少个str
		return cur->end;
	}

	// 查找树中有多少个字符串以前缀prefix为前缀
	size_t StartsWith(const std::string& prefix) const
	{
		Node* cur = _root;
		for (auto ch : prefix)
		{
			// 找的过程中发现没有路,则说明没有字符串以prefix为前缀
			if (cur->next[ch - 'a'] == nullptr)
			{
				return 0;
			}
			cur = cur->next[ch - 'a'];
		}
		// cur->pass表示有多少个字符串以prefix为前缀
		return cur->pass;
	}

	// 插入字符串
	void Insert(const string& str)
	{
		Node* cur = _root;
		++cur->pass;	// 任何一个字符串都需要经过哨兵位头节点

		for (size_t i = 0; i < str.size(); ++i)
		{
			size_t index = str[i] - 'a';
			// 如果之前没有字符串经过该节点,则需要建出新节点
			if (cur->next[index] == nullptr)
			{
				cur->next[index] = new Node();
			}
			cur = cur->next[index];
			++cur->pass;
		}
		// cur指向字符串的最后一个节点,++cur->end表示多了一个字符串以该节点结尾
		++cur->end;
	}

	// 从树中删除字符串str,注:如果有多个str,只会删除一次
	void Erase(const string& str)
	{
		// 树中没有str,无法删除
		if (Search(str) == 0)
			return;

		Node* cur = _root;
		--cur->pass;

		for (size_t i = 0; i < str.size(); ++i)
		{
			size_t index = str[i] - 'a';
			// 如果发现str是唯一经过该节点的字符串
			// 那么就需要递归去释放当前节点及后续路径的节点
			if (--cur->next[index]->pass == 0)
			{
				Destroy(cur->next[index]);	// 递归释放节点
				cur->next[index] = nullptr;	// next需要置为nullptr
				return;
			}
			cur = cur->next[index];
		}
		// 如果字符串的所有字符都删除了一遍,还有该路径,那么最后要
		// --cur->end,表明树中str的个数减少了一个
		--cur->end;
	}

	void Print() const
	{
		cout << "根节点:[" << "pass: " << _root->pass << " end: " << _root->end << "]" << endl;
		_Print(_root);
	}

private:
	void Destroy(Node* root)
	{
		// 先销毁孩子节点,才能够销毁自己
		for (int i = 0; i < 26; ++i)
		{
			if (root->next[i] != nullptr)
			{
				Destroy(root->next[i]);
			}
		}
		delete root;
	}

	void _Print(Node* root) const
	{
		if (root == nullptr)
			return;
		for (int i = 0; i < 26; ++i)
		{
			if (root->next[i] == nullptr)
				continue;
			else
			{
				cout << "节点" << (char)('a' + i) << ":[pass: " << root->next[i]->pass << " end: " << root->next[i]->end << "]" << endl;
				_Print(root->next[i]);
			}
		}
	}

private:
	Node* _root = nullptr;	// 哨兵位头节点,可以用来求前缀树中字符串的数量,也可以求空串的数量
};

前缀树的测试

void TrieTest()
{
	Trie t;
	vector<string> v = { "abc","abd", "abe", "abe", "" ,"a" , "bc", "bd", "be" };
	for (string& str : v)
	{
		t.Insert(str);
	}
	// 前缀树的打印
	t.Print();
	cout << "----------------------" << endl;

	// 输出空串的数量
	cout << "空串的数量: " << t.Search("") << endl;
	// 任意字符串均以空串为前缀/树中字符串的数量
	cout << "树中字符串的数量: " << t.StartsWith("") << endl;
	// 以"ab"为前缀的字符串个数
	cout << "以ab为前缀的字符串个数: " << t.StartsWith("ab") << endl;

	cout << "----------------------" << endl;
	// 测试删除
	for (string& str : v)
	{
		t.Erase(str);
	}
	t.Print();
}

OJ题:实现前缀树

LeetCode 上的实现前缀树是比我们实现的前缀树是要难度低的,所以只需要将上面的代码拷贝过去,再将函数名和函数的返回值修改成题目要求的样子就可以通过了。

👉总结👈

本篇博客主要讲解了什么是前缀树以及前缀树的实现等。那么以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家!💖💝❣️

有关【数据结构与算法】前缀树的实现的更多相关文章

  1. ruby - 使用 ruby​​ 将 HTML 转换为纯文本并维护结构/格式 - 2

    我想将html转换为纯文本。不过,我不想只删除标签,我想智能地保留尽可能多的格式。为插入换行符标签,检测段落并格式化它们等。输入非常简单,通常是格式良好的html(不是整个文档,只是一堆内容,通常没有anchor或图像)。我可以将几个正则表达式放在一起,让我达到80%,但我认为可能有一些现有的解决方案更智能。 最佳答案 首先,不要尝试为此使用正则表达式。很有可能你会想出一个脆弱/脆弱的解决方案,它会随着HTML的变化而崩溃,或者很难管理和维护。您可以使用Nokogiri快速解析HTML并提取文本:require'nokogiri'h

  2. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  3. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  4. ruby - Ruby 有 `Pair` 数据类型吗? - 2

    有时我需要处理键/值数据。我不喜欢使用数组,因为它们在大小上没有限制(很容易不小心添加超过2个项目,而且您最终需要稍后验证大小)。此外,0和1的索引变成了魔数(MagicNumber),并且在传达含义方面做得很差(“当我说0时,我的意思是head...”)。散列也不合适,因为可能会不小心添加额外的条目。我写了下面的类来解决这个问题:classPairattr_accessor:head,:taildefinitialize(h,t)@head,@tail=h,tendend它工作得很好并且解决了问题,但我很想知道:Ruby标准库是否已经带有这样一个类? 最佳

  5. ruby - 是否有用于序列化和反序列化各种格式的对象层次结构的模式? - 2

    给定一个复杂的对象层次结构,幸运的是它不包含循环引用,我如何实现支持各种格式的序列化?我不是来讨论实际实现的。相反,我正在寻找可能会派上用场的设计模式提示。更准确地说:我正在使用Ruby,我想解析XML和JSON数据以构建复杂的对象层次结构。此外,应该可以将该层次结构序列化为JSON、XML和可能的HTML。我可以为此使用Builder模式吗?在任何提到的情况下,我都有某种结构化数据-无论是在内存中还是文本中-我想用它来构建其他东西。我认为将序列化逻辑与实际业务逻辑分开会很好,这样我以后就可以轻松支持多种XML格式。 最佳答案 我最

  6. ruby - 我如何添加二进制数据来遏制 POST - 2

    我正在尝试使用Curbgem执行以下POST以解析云curl-XPOST\-H"X-Parse-Application-Id:PARSE_APP_ID"\-H"X-Parse-REST-API-Key:PARSE_API_KEY"\-H"Content-Type:image/jpeg"\--data-binary'@myPicture.jpg'\https://api.parse.com/1/files/pic.jpg用这个:curl=Curl::Easy.new("https://api.parse.com/1/files/lion.jpg")curl.multipart_form_

  7. 世界前沿3D开发引擎HOOPS全面讲解——集3D数据读取、3D图形渲染、3D数据发布于一体的全新3D应用开发工具 - 2

    无论您是想搭建桌面端、WEB端或者移动端APP应用,HOOPSPlatform组件都可以为您提供弹性的3D集成架构,同时,由工业领域3D技术专家组成的HOOPS技术团队也能为您提供技术支持服务。如果您的客户期望有一种在多个平台(桌面/WEB/APP,而且某些客户端是“瘦”客户端)快速、方便地将数据接入到3D应用系统的解决方案,并且当访问数据时,在各个平台上的性能和用户体验保持一致,HOOPSPlatform将帮助您完成。利用HOOPSPlatform,您可以开发在任何环境下的3D基础应用架构。HOOPSPlatform可以帮您打造3D创新型产品,HOOPSSDK包含的技术有:快速且准确的CAD

  8. 区块链之加解密算法&数字证书 - 2

    目录一.加解密算法数字签名对称加密DES(DataEncryptionStandard)3DES(TripleDES)AES(AdvancedEncryptionStandard)RSA加密法DSA(DigitalSignatureAlgorithm)ECC(EllipticCurvesCryptography)非对称加密签名与加密过程非对称加密的应用对称加密与非对称加密的结合二.数字证书图解一.加解密算法加密简单而言就是通过一种算法将明文信息转换成密文信息,信息的的接收方能够通过密钥对密文信息进行解密获得明文信息的过程。根据加解密的密钥是否相同,算法可以分为对称加密、非对称加密、对称加密和非

  9. 华为OD机试用Python实现 -【明明的随机数】 2023Q1A - 2

    华为OD机试题本篇题目:明明的随机数题目输入描述输出描述:示例1输入输出说明代码编写思路最近更新的博客华为od2023|什么是华为od,od薪资待遇,od机试题清单华为OD机试真题大全,用Python解华为机试题|机试宝典【华为OD机试】全流程解析+经验分享,题型分享,防作弊指南华为o

  10. FOHEART H1数据手套驱动Optitrack光学动捕双手运动(Unity3D) - 2

    本教程将在Unity3D中混合Optitrack与数据手套的数据流,在人体运动的基础上,添加双手手指部分的运动。双手手背的角度仍由Optitrack提供,数据手套提供双手手指的角度。 01  客户端软件分别安装MotiveBody与MotionVenus并校准人体与数据手套。MotiveBodyMotionVenus数据手套使用、校准流程参照:https://gitee.com/foheart_1/foheart-h1-data-summary.git02  数据转发打开MotiveBody软件的Streaming,开始向Unity3D广播数据;MotionVenus中设置->选项选择Unit

随机推荐