本系列所有文章都将会收录到GitHub
中统一收藏与管理,欢迎ISSUE
和Star
。
GitHub传送门:Kiner算法算题记
我们已经了解了字典树和双数组字典树的概念、结构特性、以及作用和应用场景,也一起实现了不同版本的字典树,那么,现在通过几道与字典树相关的题目巩固加深一下对于字典树的印象吧。
这题其实就是字典树封装的裸题,基本实现都跟我们之前讲的第一个版本的字典树一样,唯一的区别就是多了一个startsWith
方法,而这个方法的实现也很简单,逻辑基本跟search
一样,不同的是,search
需要判断最后一个单词独立成词,而startsWith
方法不需要判断是否独立成词,只要循环结束还没退出,就说明存在这样的前缀。
/*
* @lc app=leetcode.cn id=208 lang=typescript
*
* [208] 实现 Trie (前缀树)
*
* https://leetcode-cn.com/problems/implement-trie-prefix-tree/description/
*
* algorithms
* Medium (71.64%)
* Likes: 949
* Dislikes: 0
* Total Accepted: 152.7K
* Total Submissions: 212.9K
* Testcase Example: '["Trie","insert","search","search","startsWith","insert","search"]\n' +
'[[],["apple"],["apple"],["app"],["app"],["app"],["app"]]'
*
* Trie(发音类似 "try")或者说 前缀树
* 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。
*
* 请你实现 Trie 类:
*
*
* Trie() 初始化前缀树对象。
* void insert(String word) 向前缀树中插入字符串 word 。
* boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回
* false 。
* boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true
* ;否则,返回 false 。
*
*
*
*
* 示例:
*
*
* 输入
* ["Trie", "insert", "search", "search", "startsWith", "insert", "search"]
* [[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]
* 输出
* [null, null, true, false, true, null, true]
*
* 解释
* Trie trie = new Trie();
* trie.insert("apple");
* trie.search("apple"); // 返回 True
* trie.search("app"); // 返回 False
* trie.startsWith("app"); // 返回 True
* trie.insert("app");
* trie.search("app"); // 返回 True
*
*
*
*
* 提示:
*
*
* 1
* word 和 prefix 仅由小写英文字母组成
* insert、search 和 startsWith 调用次数 总计 不超过 3 * 10^4 次
*
*
*/
// @lc code=start
class TrieNode {
public flag: boolean;
public next: TrieNode[];
constructor() {
this.flag = false;
this.next = new Array(26);
this.next.fill(null);
}
}
class Trie {
public static charCodeA = 'a'.charCodeAt(0);
private root: TrieNode;
constructor() {
this.root = new TrieNode();
}
insert(word: string): void {
let p: TrieNode = this.root;
for(let c of word) {
let idx = c.charCodeAt(0) - Trie.charCodeA;
if(!p.next[idx]) {
p.next[idx] = new TrieNode();
}
p = p.next[idx];
}
p.flag = true;
}
search(word: string): boolean {
let p = this.root;
for(let c of word) {
let idx = c.charCodeAt(0) - Trie.charCodeA;
p = p.next[idx];
if(!p) return false;
}
return p.flag;
}
/**
* 其实主要逻辑个搜索一模一样,只是最后一步无需判断是否独立成词,只要能走到最后一步,就返回true
* @param prefix
* @returns
*/
startsWith(prefix: string): boolean {
let p = this.root;
for(let c of prefix) {
let idx = c.charCodeAt(0) - Trie.charCodeA;
p = p.next[idx];
if(!p) return false;
}
return true;
}
}
/**
* Your Trie object will be instantiated and called as such:
* var obj = new Trie()
* obj.insert(word)
* var param_2 = obj.search(word)
* var param_3 = obj.startsWith(prefix)
*/
// @lc code=end
依据题意,我们要找到跟目标单词前缀匹配最多的前三个,由于字典树的搜索逻辑刚好是从前缀少到前缀多不断查找的,因此,我们可以借助字典树的结构。之前说过,字典树的节点代表以某个字母为结尾的前缀的单词集合,那么我们就可以在插入单词时,将这个集合的单词收集起来,超过3个时将按照字典序排序的最后一位从集合中剔除。在搜索时,将搜索到的符合前缀的集合压入到结果数组即可。
/*
* @lc app=leetcode.cn id=1268 lang=typescript
*
* [1268] 搜索推荐系统
*
* https://leetcode-cn.com/problems/search-suggestions-system/description/
*
* algorithms
* Medium (58.39%)
* Likes: 94
* Dislikes: 0
* Total Accepted: 8.4K
* Total Submissions: 14.4K
* Testcase Example: '["mobile","mouse","moneypot","monitor","mousepad"]\r\n"mouse"\r'
*
* 给你一个产品数组 products 和一个字符串 searchWord ,products 数组中每个产品都是一个字符串。
*
* 请你设计一个推荐系统,在依次输入单词 searchWord 的每一个字母后,推荐 products 数组中前缀与 searchWord
* 相同的最多三个产品。如果前缀相同的可推荐产品超过三个,请按字典序返回最小的三个。
*
* 请你以二维列表的形式,返回在输入 searchWord 每个字母后相应的推荐产品的列表。
*
*
*
* 示例 1:
*
* 输入:products = ["mobile","mouse","moneypot","monitor","mousepad"], searchWord
* = "mouse"
* 输出:[
* ["mobile","moneypot","monitor"],
* ["mobile","moneypot","monitor"],
* ["mouse","mousepad"],
* ["mouse","mousepad"],
* ["mouse","mousepad"]
* ]
* 解释:按字典序排序后的产品列表是 ["mobile","moneypot","monitor","mouse","mousepad"]
* 输入 m 和 mo,由于所有产品的前缀都相同,所以系统返回字典序最小的三个产品 ["mobile","moneypot","monitor"]
* 输入 mou, mous 和 mouse 后系统都返回 ["mouse","mousepad"]
*
*
* 示例 2:
*
* 输入:products = ["havana"], searchWord = "havana"
* 输出:[["havana"],["havana"],["havana"],["havana"],["havana"],["havana"]]
*
*
* 示例 3:
*
* 输入:products = ["bags","baggage","banner","box","cloths"], searchWord =
* "bags"
*
* 输出:[["baggage","bags","banner"],["baggage","bags","banner"],["baggage","bags"],["bags"]]
*
*
* 示例 4:
*
* 输入:products = ["havana"], searchWord = "tatiana"
* 输出:[[],[],[],[],[],[],[]]
*
*
*
*
* 提示:
*
*
* 1 <= products.length <= 1000
* 1 <= Σ products[i].length <= 2 * 10^4
* products[i] 中所有的字符都是小写英文字母。
* 1 <= searchWord.length <= 1000
* searchWord 中所有字符都是小写英文字母。
*
*
*/
// @lc code=start
const BASE = 26;
const charCodeA = 'a'.charCodeAt(0);
// 字典树的节点结构
class TrieNode {
public flag: boolean;// 代表当前节点是否能够独立成词,即true为红色节点,false为白色节点
public nexts: TrieNode[] = new Array(BASE);// 每一条边代表26个字母中的一个,因此这个节点数组最大长度为26(假设我们的字典树只存储小写字母)
public s: string[] = [];
constructor() {
this.flag = false;
this.nexts.fill(null);
}
}
class Trie {
public root: TrieNode;
constructor() {
this.root = new TrieNode();
}
static clearTrieNode(root: TrieNode): void {
if(!root) return;
for(let i=0;i<BASE;i++) Trie.clearTrieNode(root.nexts[i]);
root = null;
}
/**
* 往字典树中插入一个单词,如果是第一次插入这个单词,则返回true,否则返回false
* @param word
*/
public insert(word: string): boolean {
let p = this.root;
for(let x of word) {
// 当前字母在0~25中的索引
const idx = x.charCodeAt(0) - charCodeA;
// 如果当前字母对应的索引下不存在节点,则新建一个节点
if(!p.nexts[idx]) p.nexts[idx] = new TrieNode();
// 让指针指向当前节点,继续下一个字母的插入
p = p.nexts[idx];
// 我们之前说过,节点代表集合,边代表关系,那么,当我们每找到一个字母时
// 我们就可以找到以这个字母为结尾的前缀的单词集合了,也就是当前匹配的单词
// 就是以当前字母作为结尾的前缀开头的,因此,我们将当前匹配的单词压入到集合中
p.s.push(word);
// 由于题目要求按照字典序输出结果,因此,我们在将单词压入集合中后,还需要将集合
// 按照字典序排序
p.s.sort((a, b) => a.localeCompare(b));
// 由于题目要求最大前三个,因此集合中的元素超过三个时,将集合中按照字典序排序的最后一个单词
// 从集合中删除
if(p.s.length>3) {
const lastIdx = p.s.length-1;
p.s.splice(lastIdx, 1);
}
}
// console.log(p.s);
// 循环结束后,我们的p指针指向插入单词的最后一个字母的位置
// 如果flag为true,说明当前单词不是第一次插入,返回false
if(p.flag) return false;
// 如果flag为false,说明归档前单词是第一次插入,更新当前节点的flag,返回true
return (p.flag = true);
}
/**
* 查找目标单词在字典树中是否存在
* @param word
* @returns
*/
public search(word: string): string[][] {
const res: string[][] = [];
let p = this.root;
for(let x of word) {
// 如果当前节点不存在,则无法找到匹配的单词,往结果集合中压入一个空集合
if(!p) {
res.push([]);
continue;
}
const idx = x.charCodeAt(0) - charCodeA;
p = p.nexts[idx];
// 如果p指向的节点不为空,那么,将该节点下集合中压入到结果数组中
if(p) {
res.push([...p.s]);
} else {
res.push([]);
};
}
return res;
}
}
function suggestedProducts(products: string[], searchWord: string): string[][] {
// 构建一颗字典树
const tree: Trie = new Trie();
// 将产品列表中的每一个产品加入到字典书中
products.forEach(item => tree.insert(item));
// 在字典树的查找过程中找目标产品集合
return tree.search(searchWord);
};
// @lc code=end
首先,我们需要搞清楚异或运算
的本质:异或运算
是一个位运算,两个二进制数字相同位上的数字相同则为0,不同则为1,如:
0b010101
0b101101
# 异或得
0b111000
实际上,异或运算就是在统计二进制数字每一位1的奇偶性,如果相同位上1的数量是偶数则为0,如果1的数量为奇数则为1。
了解了异或运算之后,我们再来看看,什么情况下,才能够确保异或运算的结果尽可能大,是不是从二进制的高位(左边)开始尽可能相同位上的数字不同,就能够尽可能保证异或的结果尽可能大呢?如:
# 以下二进制数与那个数进行异或运算的结果是最大的
0b0100110
0b???????
# 异或
# 按照我们上面说的,要让两个二进制数异或结果尽可能大,则需要让相同位上的数字尽可能不一样,因此,
0b0100110
0b1011001 # 正解
# 异或
0b1111111
我们现在已经知道了如何才能让两个二进制数的异或结果尽可能大了。那么我们要通过什么方式实现上面的推到过程呢?题目会输入一个数字数组,那么,我们是否可以将数字的二进制表示看成是我们要查找的一个个单词,然后插入到一个二叉字典树当中去,然后用每一个数字在二叉字典树中按照一定的规则查找并计算出异或值。此处涉及到较多的二进制位操作的技巧,详细实现请看代码注释。
/**
* 创建一个二叉字典树,将数组中的每个数字的二进制表示插入进去,为了确保异或的结构尽可能大
* 我们需要保证当前位于目标数字的对应位的数字不同,因此在搜索时,尽可能往相反的位置走,如果
* 当前位是0,那么就应该尽可能去1的位置查找,反之亦然
*/
class TrieNode {
public next: TrieNode[];
constructor() {
this.next = new Array(2);
this.next.fill(null);
}
}
class Trie {
private root: TrieNode;
constructor() {
this.root = new TrieNode();
}
insert(x: number): void {
let p: TrieNode = this.root;
// 从最高的二进制表示位开始
for(let i=30;i>=0;--i) {
// 先获取当前位是0还是1
// 1 << i 代表将1按位左移i位,即将二进制表示中的第i位置为1,其他为0
// (x & (1 << i) 则是判断插入数字x的第i位是0还是1,如果是0,则结果为0,如果是1,结果就是第i位为1,其他位为0
// !!(x & (1 << i)),进行归一化,由于上一步非0的情况有可能不是1,而我们二叉字典树中只需要0和1,因此通过!!将所有非0的数字都变成1,而原来是0的话,依然是0
// 在js中!!的结果是一个布尔值,因此,我们还需要进行一次隐士类型转换,即+
let idx = +(!!(x & (1 << i)));
// 如果目标位置没有节点,则新建节点
if(!p.next[idx]) p.next[idx] = new TrieNode();
// 指针后移
p = p.next[idx];
}
}
search(x: number): number {
let p = this.root;
// 用于累计异或结果
let res = 0;
for(let i=30;i>=0;--i) {
// 同样获取当前位的0、1信息
let idx = +(!!(x & (1 << i)));
// 为了确保异或运算尽可能大,因此,我们需要尽可能往相反的方向查找,即当前节点
// 如果是0,则往1的方向走,如果是1,则尽可能往0的方向走
if(p.next[+(!idx)]) {
// 存在相反方向的节点,将当前位的结果加入到异或结果当中
res |= (1 << i);
// 指针往反方向走
p = p.next[+(!idx)];
} else {
// 相反方向上没有节点,迫不得已只能往相同的方向走
p = p.next[idx];
}
}
return res;
}
}
function findMaximumXOR(nums: number[]): number {
const trie = new Trie;
// 将所有的数字插入到二叉字典树中
nums.forEach(item => trie.insert(item));
// 在二叉字典树中查找异或结果并取最大值
let res = 0;
nums.forEach(item => res = Math.max(res, trie.search(item)));
return res;
};