树是数据结构中很重要的一种结构,在现实生活中常用于表达层次关系和非单一的映射关系,如一个老板下对应有多个员工。通过一段时间的算法练习,本文将树常见的算法收录整理,包括:
求二叉树叶子节点的数量
求节点权重和
求距离根节点最近、最远的叶节点
求每一层节点的数量
遍历二叉树,求给定权值的路径
二叉查找树
而对于红黑树、2-3-4树等较为复杂的树结构,本文暂时不收录,仅收录较为简单容易理解的,本文适用于:编程不太好、但了解基本数据结构的人,你仅仅需要知道深度优先遍历、广度优先遍历、二叉树的根节点、普通节点、叶子节点的区别即可。
一类通常题目:求树的叶子数量,求每一层的节点数量,求树的最大高度,求叶子权重的和,都可以归类为对二叉树的遍历,所以本文更多的是树的遍历算法(深度优先或广度优先)的应用。其次,先序、中序、后序遍历树,对遍历顺序有严格的要求,一般不用此类方法遍历树去求解上述问题,一般都是解决先序中序转后序此类需求,在后续会写出此类问题的求解算法。
警告:尽管文本做了详细的代码注释和讲解,但如果你只是准备靠本文的讲解企图理解代码,恐怕不太行,还需要自己多加练习和多思考。
树 构建 654. 最大二叉树 给定一个不重复的整数数组 nums
。 最大二叉树可以用下面的算法从 nums
递归地构建:
创建一个根节点,其值为 nums
中的最大值。
递归地在最大值 左边 的 子数组前缀上 构建左子树。
递归地在最大值 右边 的 子数组后缀上 构建右子树。
返回 nums
构建的 最大二叉树 。
示例 1:
1 2 3 4 5 6 7 8 9 10 11 12 输入:nums = [3,2,1,6,0,5] 输出:[6,3,5,null,2,0,null,null,1] 解释:递归调用如下所示: - [3,2,1,6,0,5] 中的最大值是 6 ,左边部分是 [3,2,1] ,右边部分是 [0,5] 。 - [3,2,1] 中的最大值是 3 ,左边部分是 [] ,右边部分是 [2,1] 。 - 空数组,无子节点。 - [2,1] 中的最大值是 2 ,左边部分是 [] ,右边部分是 [1] 。 - 空数组,无子节点。 - 只有一个元素,所以子节点是一个值为 1 的节点。 - [0,5] 中的最大值是 5 ,左边部分是 [0] ,右边部分是 [] 。 - 只有一个元素,所以子节点是一个值为 0 的节点。 - 空数组,无子节点。
树的算法主要依赖于递归,假设有这么一个递归函数,返回的是所需要的节点,牢牢记住这一点。如果实在理解不了递归,那么只需要理解在调用这个函数的时候,知道它返回的是所需的节点即可。
递归函数就是自己调用自己,因此需要确定好参数的类型和数量,因此下次还会用到这个函数。我们看一下这个题,根据数组创建树,如果找到了最大值,最大值左侧就是左子树,右侧就是右子树。而左右子树就需要索引来区分,不能越界,也就是说,不能把其他的节点放到当前子树中。因此,我们假定递归需要三个参数,数组 nums
,子树的起点索引 l
和终点索引 r
。
第一次调用递归函数,传入的参数是 nums, 0, nums.size()
。然后找到数组中的最大值,创建节点。假设最大值的索引是 idx
递归调用函数,创建节点的左子树,那么创建左子树的递归就是 nums, 0, idx
递归调用函数,创建节点的右子树,那么创建左子树的递归就是 nums, idx+1, nums.size()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 class Solution {public : TreeNode* constructMaximumBinaryTree (vector<int >& nums) { return build (nums, 0 , nums.size ()); } TreeNode* build (vector<int >& nums, int l, int r) { if (l >= r) { return nullptr ; } int maxv = -1 ; int idx; for (int i = l; i < r; i++) { if (nums[i] > maxv) { maxv = nums[i]; idx = i; } } TreeNode* node = new TreeNode (maxv); node->left = build (nums, l, idx); node->right = build (nums, idx + 1 , r); return node; } };
遍历 剑指 Offer 68 - I. 二叉搜索树的最近公共祖先 给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。百度百科中最近公共祖先的定义为:对于有根树 T
的两个结点 p
、q
,最近公共祖先表示为一个结点 x
,满足 x
是 p
、q
的祖先且 x
的深度尽可能大(一个节点也可以是它自己的祖先)。
例如,给定如下二叉搜索树: root = [6,2,8,0,4,7,9,null,null,3,5]
这个算后续遍历了,因为要找两个节点的公共祖先,起码要先知道子节点在哪里,知道了子节点才能找到父节点。
后序遍历时,如果遇到了要找的子节点,那么计数
当计数满足条件时,当前节点就是深度最大的公共祖先
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class Solution {public : TreeNode* res = nullptr ; TreeNode* lowestCommonAncestor (TreeNode* root, TreeNode* p, TreeNode* q) { postorder (root, p, q); return res; } int postorder (TreeNode* root, TreeNode* p, TreeNode* q) { if (root == nullptr ) return 0 ; int a = 0 ; int r1 = postorder (root->left, p, q); int r2 = postorder (root->right, p, q); a += (r1 + r2); if (root == p) { a += 1 ; } if (root == q) { a += 2 ; } if (a == 3 && res == nullptr ) { res = root; } return a; } };
652. 寻找重复的子树 给定一棵二叉树 root
,返回所有重复的子树。对于同一类的重复子树,你只需要返回其中任意一棵的根结点即可。如果两棵树具有相同的结构和相同的结点值,则它们是重复的。
我们可以用深度优先遍历,去遍历一个节点及其子树,将数值拼接起来得到一个字符串。如果存在相同的字符串,就说明存在相同的子树
根据题意,我们知道需要检查每一个节点对应的子树的结果
也就是说,检查每一个节点需要遍历,而生成节点对应的结果又需要一次遍历,总共需要两次遍历。第一次遍历是为了遍历节点,第二次遍历是为了得到字符串。这两次遍历的任务不同,我们要写两个遍历函数
那么在第一次遍历二叉树的时候,加入第二次遍历去得到字符串即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 class Solution {public : vector<TreeNode*> res; unordered_map<string, int > m; vector<TreeNode*> findDuplicateSubtrees (TreeNode* root) { build (root); return res; } void build (TreeNode* root) { if (root == nullptr ) { return ; } string s = "" ; dfs (root, s); m[s] += 1 ; if (m[s] == 2 ) { res.push_back (root); } build (root->left); build (root->right); } void dfs (TreeNode* root, string& s) { if (root == nullptr ) { s += "+" ; return ; } s += "=" ; s += to_string (root->val); dfs (root->left, s); dfs (root->right, s); } };
递归 116. 填充每个节点的下一个右侧节点指针 给定一个 完美二叉树 ,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树定义如下:填充它的每个 next
指针,让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点,则将 next
指针设置为 NULL
。初始状态下,所有 next
指针都被设置为 NULL
。
根据前文我们知道,递归是自己调用自己,且需要明确参数的数量和内容,这样才能方便的调用。如果理解不了递归的细节,就知道要让递归实现什么,直接调用即可,不要陷入递归的细节。
在这个题中,我们发现需要造作两个指针 a
和 b
,这两个指针是根节点的孩子,而且需要让 a
指向 b
,子树也是如此。也就是说,递归有两个参数。因此,只需要传入两个参数,递归的处理左右子树即可,在递归函数中,完成 a
指向 b
的功能即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class Solution {public : Node* connect (Node* root) { if (root == nullptr ) { return nullptr ; } build (root->left, root->right); return root; } void build (Node* left, Node* right) { if (left == nullptr || right == nullptr ) { return ; } left->next = right; build (left->left, left->right); build (right->left, right->right); build (left->right, right->left); } };
114. 二叉树展开为链表 给你二叉树的根结点 root
,请你将它展开为一个单链表:展开后的单链表应该同样使用 TreeNode
,其中 right
子指针指向链表中下一个结点,而左子指针始终为 null
。展开后的单链表应该与二叉树 先序遍历 顺序相同。
同样,我们只需要定义递归函数,并实现其功能即可。根据题意,这个递归函数的作用就是展平子树,实现这个函数后,递归的实现左右子树的展平,不必陷入递归的细节。而且,是先要展平子树,在展平当前节点,因为只有保证子树是展平的,才能保障当前节点可以展平。
如上图所示,先展平左右子树,直接 flatten(left)
和 flatten(right)
即可
在展平之后,根节点的右指针指向左子树,然后移动到左子树的末尾,在指向右子树即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class Solution {public : void flatten (TreeNode* root) { if (root == nullptr ) { return ; } flatten (root->left); flatten (root->right); TreeNode* l = root->left; TreeNode* r = root->right; root->right = l; root->left = nullptr ; TreeNode* p = root; while (p->right != nullptr ) { p = p->right; } p->right = r; } };
前缀树 208. 实现 Trie (前缀树) Trie
(发音类似 “try”)或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。请你实现 Trie 类:
Trie()
初始化前缀树对象。
void insert(String word)
向前缀树中插入字符串 word
。
boolean search(String word)
如果字符串 word
在前缀树中,返回 true
(即,在检索之前已经插入);否则,返回 false
。
boolean startsWith(String prefix)
如果之前已经插入的字符串 word
的前缀之一为 prefix
,返回 true
;否则,返回 false
。
我想不到什么好的语言来描述前缀树。简单说一下就是,假设用树来查单词,单词字母都是小写。那么树的每个节点都有 26 个孩子,初始化为空。
比如对于 cat
这个单词,根节点有 26 个孩子,c
节点不为空,c
节点有 26 个孩子,a
不为空,a
节点有 26 个孩子,t
不为空,且 t
节点设置一个结束符,表示 cat
这个单词到 t
这里就结束了。
因此我们能在这个树中查到 cat
这个单词。浪费空间?不是的,转念一想,为 cat
这个单词建的树,是不是能容纳所有的长度为 3 的单词?and,but,how
等单词都能存进来,且能实现 $O(1)$ 时间的查找。
说了这么多,看一下这个树如何实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 class Trie {public : vector<Trie*> child; bool end; Trie () { child.resize (26 ); end = false ; } void insert (string word) { Trie* node = this ; for (auto ch : word) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { node->child[a] = new Trie (); } node = node->child[a]; } node->end = true ; } bool search (string word) { Trie* node = this ; for (auto ch : word) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { return false ; } node = node->child[a]; } if (node->end == false ) return false ; return true ; } bool startsWith (string prefix) { Trie* node = this ; for (auto ch : prefix) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { return false ; } node = node->child[a]; } return true ; } };
211. 添加与搜索单词 - 数据结构设计 请你设计一个数据结构,支持 添加新单词 和 查找字符串是否与任何先前添加的字符串匹配 。
实现词典类 WordDictionary
:
WordDictionary()
初始化词典对象
void addWord(word)
将 word
添加到数据结构中,之后可以对它进行匹配
bool search(word)
如果数据结构中存在字符串与 word
匹配,则返回 true
;否则,返回 false
。word
中可能包含一些 ‘.’ ,每个 . 都可以表示任何一个字母。
由于 .
可以代表任何一个字符,因此需要对 .
进行处理,使其可以表示任何一个字母。不过这就需要递归处理来使得程序优美一些:
1 2 3 4 5 6 7 8 9 for (int i = 0 ; i < 26 ; i++) { if (node->child[i] != nullptr ) { if (subsearch (word, idx + 1 , node->child[i]) == true ) { return true ; } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 class WordDictionary {public : vector<WordDictionary*> child; bool end = false ; WordDictionary () { child.resize (26 ); } void addWord (string word) { WordDictionary* node = this ; for (auto ch : word) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { node->child[a] = new WordDictionary (); } node = node->child[a]; } node->end = true ; } bool subsearch (string word, int idx, WordDictionary* node) { if (idx >= word.size ()) { return node->end; } auto ch = word[idx]; if (ch == '.' ) { for (int i = 0 ; i < 26 ; i++) { if (node->child[i] != nullptr ) { if (subsearch (word, idx + 1 , node->child[i]) == true ) { return true ; } } } } else { int a = ch - 'a' ; if (node->child[a] != nullptr ) { if (subsearch (word, idx + 1 , node->child[a]) == true ) { return true ; } } } return false ; } bool search (string word) { return subsearch (word, 0 , this ); } };
648. 单词替换 在英语中,我们有一个叫做 词根(root) 的概念,可以词根后面添加其他一些词组成另一个较长的单词——我们称这个词为 继承词(successor)。例如,词根 an,跟随着单词 other(其他),可以形成新的单词 another (另一个)。
现在,给定一个由许多词根组成的词典 dictionary
和一个用空格分隔单词形成的句子 sentence
。你需要将句子中的所有继承词用词根替换掉。如果继承词有许多可以形成它的词根,则用最短的词根替换它。你需要输出替换之后的句子。
就是前缀树的基础应用了,因为需要寻找最短的前缀,因此遇到结束符就立刻返回;如果查找不到这个单词,那么就返回空。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 class Tree {public : vector<Tree*> child; bool end = false ; Tree () { child.resize (26 ); } void insert (string& word) { Tree* node = this ; for (auto ch : word) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { node->child[a] = new Tree (); } node = node->child[a]; } node->end = true ; } string search (string& word) { Tree* node = this ; string res = "" ; for (auto ch : word) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { return "" ; } else { res += ch; node = node->child[a]; if (node->end == true ) { return res; } } } return "" ; } }; class Solution {public : string replaceWords (vector<string>& dictionary, string sentence) { Tree node; for (auto it : dictionary) { node.insert (it); } string res, tmp; stringstream ss{sentence}; while (ss >> tmp) { res += " " ; auto a = node.search (tmp); if (a != "" ) { res += a; } else { res += tmp; } } return res.substr (1 ); } };
677. 键值映射 设计一个 map
,满足以下几点:
字符串表示键,整数表示值
返回具有前缀等于给定字符串的键的值的总和
实现一个 MapSum
类:
MapSum()
初始化 MapSum
对象
void insert(String key, int val)
插入 key-val
键值对,字符串表示键 key
,整数表示值 val
。如果键 key
已经存在,那么原来的键值对 key-value
将被替代成新的键值对。
int sum(string prefix)
返回所有以该前缀 prefix
开头的键 key
的值的总和。
这个题也比较简单,假设 apple
对应的值为 5,而后插入 app
值为 3
,那么 ap
前缀对应的和就是 8,也就是在 5 的基础上加了 3。如果再次插入 apple
此时值为 4,那么 ap
前缀对应的和就是 7,且 apple
对应的值是 4。
如此我们可以看到,只有插入存在过的单词时,才会修改数值。由于插入不存在的单词,数值的更新方式是 +=
,参考上述例子。为了统一这一点,我们需要设置一个 map
,并记录本次插入的值。
如果这个字符串是第二次插入,那么就需要对值进行修改,比如 apple
第一次是 5,map
存入 5;第二次是 3,那么 +=
的值就应该是 -2,map
存入 3。如果第三次是 6,那么加等于的值就是 3,map
存入 6。也就是,+=
的数值就是本次插入的值减去 map
中存入的值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 class MapSum {public : vector<MapSum*> child; int cnt = 0 ; unordered_map<string, int > m; MapSum () { child.resize (26 ); } void insert (string key, int val) { MapSum* node = this ; int v = val; if (m.count (key)) { v = val - m[key]; } m[key] = val; for (auto ch : key) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { node->child[a] = new MapSum (); } node = node->child[a]; node->cnt += v; } } int sum (string prefix) { MapSum* node = this ; for (auto ch : prefix) { int a = ch - 'a' ; if (node->child[a] == nullptr ) { return 0 ; } node = node->child[a]; } return node->cnt; } };
参考 本文构最初思于 2019 年 6 月通过刷题学习数据结构时期,第一次整理发表于 2020 年 2 月。本文全部参考了柳神 的程序,而非我本人的原创。但是我都花精力研究了程序、重写程序、最后做了归纳整理。
时间流转,2022 年由于找工作的需要,又开始整理一些 leetcode
题目,PAT
题目过于简单也从本文中删去退出了历史舞台,但我依然敬佩柳神,也从她的程序中受益颇多。
如果你初学数据结构或刷题,我强烈建议做一下 PAT
的基础题目掌握数据结构,也建议观摩柳神的程序。