目录
本系列文章仅是 GitHub 大神 @halfrost 的刷题笔记 《LeetCode Cookbook》的提纲以及示例、题集的 C++转化。原书请自行下载学习。
本篇文章涉及新手应该优先刷的几道经典平衡二叉树算法题。
平衡二叉树基础
- 平衡二叉树,是一种二叉排序树,其中对于每个节点的左右子树高度相差小于等于 1,即“高度平衡”。
- 我们通过平衡因子(BF,balance factor,其值为左右子树高度之差)来衡量一棵树是否平衡。
- 一棵平衡二叉树,必须满足:
-
- 是一棵二叉搜索树
-
- 每个节点 BF 的绝对值小于 1
- 对于一棵有 n n n 个节点的平衡二叉树来说,它每一个操作的时间复杂度都可以维持在 O ( log n ) O(\log n) O(logn) 。
- 对于一棵平衡二叉树,插入节点可能会使其「失衡」,我们将“距离插入节点最近的,且以 BF 绝对值大于 1 的节点为根的子树”称为「最小不平衡子树」。那么,失衡的调整主要通过调整最小不平衡子树来实现重新平衡,我们具体以「旋转」的方式实现:
-
- 左旋:把最小不平衡子树的右孩子作为根进行旋转操作。
- “根节点的右孩子”作为“新的根节点”
- “根节点的右孩子的左子树”(如果有)变为“根节点的右子树”
- “根节点”变为“根节点的右孩子”的左子树
- 左旋:把最小不平衡子树的右孩子作为根进行旋转操作。
-
- 右旋:把最小不平衡子树的左孩子作为根进行旋转操作。
- “根节点的左孩子”作为“新的根节点”
- “根节点的左孩子的右子树”(如果有)变为“根节点的左子树”
- “根节点”变为“根节点的左孩子”的右子树
- 右旋:把最小不平衡子树的左孩子作为根进行旋转操作。
不同插入节点方式的不同旋转
对于平衡二叉树的节点 node
来说,有 4 种插入方式:
- 插入左孩子的左子树(LL 型)
- 插入右孩子的右子树(RR 型)
- 插入左孩子的右子树(LR 型)
- 插入右孩子的左子树(RL 型)
这些插入方式可能会造成 node
的 BF 绝对值大于 1,从而打破了原平衡二叉树的平衡。
LL 型失衡
针对插入左孩子的左子树失衡,只需执行一次「右旋」即可。
插入节点 1,造成失衡,这时候最小不平衡子树的根节点为 3,直接进行一次右旋操作:
RR 型失衡
对于针对插入右孩子的右子树失衡,只需执行一次「左旋」即可。
插入节点 7,造成失衡,这个时候最小不平衡子树的根节点为 5,直接进行一次左旋操作:
LR 型失衡
插入左孩子的右子树失衡,需要 2 步解决:
- 对最小不平衡子树的根节点的左孩子左旋,转变为 LL 型失衡
- 最小不平衡子树右旋
插入节点 2,造成失衡,最小不平衡子树的根节点为 3 。此时最小不平衡子树根节点的左孩子为节点 1,将以节点 1 为根节点的子树左旋:
将最小不平衡子树右旋:
RL 型失衡
插入右孩子的左子树失衡,也需要 2 步:
- 对最小不平衡子树根节点的右孩子右旋,转变为 RR 型失衡
- 最小不平衡子树左旋
插入节点 6,造成失衡,最小不平衡子树的根节点为 5 。此时最小不平衡子树根节点的右孩子为节点 7,将以节点 7 为根节点的子树右旋:
将最小不平衡子树左旋:
删除操作
同二叉搜索树一致,平衡二叉树也分为 3 种情况,但是删除完节点后,需要重新判断一下是否依然平衡并调整之;删除一个节点后可能会有多个不平衡的节点。
删除节点为二叉树的叶子节点
通过平衡二叉树的查找操作找到节点,直接删除,随后,从删除节点的父节点开始判断是否失衡,如果是,则判断为 LL、RR、LR、RL 中的哪种类型的失衡,根据上述调整方式进行旋转;如果不是,就继续向父节点的父节点继续判断,以此类推。
删除的节点只有左子树或者右子树
直接用删除节点的父节点指向它的子树即可,随后,从删除节点的父节点开始判断为哪种类型的失衡,并进行旋转;如果不失衡,就继续向父节点的父节点继续判断,以此类推。
删除的节点既有左子树又有右子树
将要删除节点 node
位置上替换成左子树最右边的节点或者右子树最左边的节点,即左子树的最大值或者右子树的最小值;删除以后的调整操作,还是与前两种情况的旋转操作相同。
LeetCode #110:Balanced Binary Tree 平衡二叉树
给定一个二叉树,判断它是否是高度平衡的二叉树。
针对每一个节点,大致分为两步:
- 分别求该节点左右子树的最大高度
- 求该节点的 BF,若|BF| ≤ \leq ≤ 1 ,则是平衡的,继续判断下一个节点;若|BF|> 1,则直接不是平衡二叉树
因此,关键在于如何求解出每个节点的最大高度,由于二叉树的深度和层次是完全对应的,最大深度为最大层次数,而二叉树的深度和高度正好相反,求二叉树的最大高度共有 3 种方法——自底向上的递归(后序遍历)、自顶向上的递归(前序遍历),以及自左向右的层次遍历。
递归法(自底向上)
对于后序遍历,分而治之,递归左子树的最大高度,递归右子树的最大高度,求根的 BF,判断|BF|。
class Solution {
private:
int judgeHeight(TreeNode* root) {
if (root == nullptr) return 0; //节点为空,高度为 0
//递归计算左子树的最大高度
int leftHeight = judgeHeight(root->left);
//如果左子树不是平衡二叉树,肯定不是平衡二叉树
if (leftHeight == -1) return -1;
//递归计算右子树的最大高度
int rightHeight = judgeHeight(root->right);
//如果右子树不是平衡二叉树,肯定不是平衡二叉树
if (rightHeight == -1) return -1;
//若为平衡二叉树,平衡因子为 1、0、-1
if (abs(leftHeight - rightHeight) > 1) return -1;
else return max(leftHeight, rightHeight) + 1; //二叉树的最大高度 = 子树的最大高度 + 1
}
public:
bool isBalanced(TreeNode* root) {
if (judgeHeight(root) != -1) return true;
return false;
}
};
该算法时间复杂度为 O ( n ) O(n) O(n),空间复杂度为 O ( n ) O(n) O(n)。
递归法(自顶向下)
类似于二叉树的前序遍历,对于当前遍历到的节点,首先计算左右子树的高度,判断左右子树的高度差是否不超过 1,再分别递归地遍历左右子节点,并判断左子树和右子树是否平衡。
class Solution {
private:
int height(TreeNode* root) {
if (root == nullptr) return 0;
else return max(height(root->left), height(root->right)) + 1;
}
public:
bool isBalanced(TreeNode* root) {
if (root == nullptr) return true;
else return abs(height(root->left) - height(root->right)) <= 1 && isBalanced(root->left) && isBalanced(root->right);
}
};
最坏情况下,二叉树是满二叉树,需要遍历二叉树中的所有节点,时间复杂度是
O
(
n
)
O(n)
O(n);对于节点 node
,如果它的高度是 d
,则 height(node)
最多会被调用 d
次(即遍历到它的每一个祖先节点时)。对于平均的情况,一棵树的高度 h
满足
O
(
h
)
=
O
(
log
n
)
O(h)=O(\log n)
O(h)=O(logn),因为
d
≤
h
d≤h
d≤h ,所以总时间复杂度为
O
(
n
log
n
)
O(n\log n)
O(nlogn);对于最坏的情况,二叉树形成链式结构,高度为
O
(
n
)
O(n)
O(n),此时总时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。
空间复杂度为 O ( n ) O(n) O(n)。
迭代法
使用队列保存每一层的所有节点,把队列里的所有节点弹出队列,然后把这些被弹出节点各自的子节点入队列。用 depth
维护每一层,求出最大高度。
class Solution {
private:
int maxDepth(TreeNode* root) {
if (root == nullptr) return 0;
//初始化队列和层次
queue<TreeNode*> q;
q.push(root);
int depth = 0;
while (!q.empty()) {
//当前层的节点数
int n = q.size();
//弹出当前层的所有节点,并将所有子节点入队列
for (int i = 0; i < n; i++) {
TreeNode* node = q.front();
q.pop();
if (node->left != nullptr) q.push(node->left);
if (node->right != nullptr) q.push(node->right);
}
depth++;
}
//二叉树最大层次即为二叉树最大高度
return depth;
}
public:
bool isBalanced(TreeNode* root) {
if (root == nullptr) return true;
//判断以二叉树的每个节点为根的二叉树是否符合平衡二叉树的条件
//这里遍历二叉树的每个节点使用的是非递归的中序遍历,用栈来模拟
stack<TreeNode*> stk;
while (root != nullptr || !stk.empty()) {
if (root != nullptr) {
stk.push(root);
root = root->left;
} else {
TreeNode* cur = stk.top();
stk.pop();
if (abs(maxDepth(cur->left) - maxDepth(cur->right)) > 1) return false;
root = cur->right;
}
}
return true;
}
};
该算法遍历整棵二叉树的节点消耗 O ( n ) O(n) O(n),而对每个节点来说,求节点的最大高度又消耗了 O ( n ) O(n) O(n),所以时间复杂度为 O ( n 2 ) O(n^2) O(n2);空间复杂度依然为 O ( n ) O(n) O(n)。
LeetCode #1382:Balance a Binary Search Tree 将二叉搜索树变平衡
给你一棵二叉搜索树,请你返回一棵平衡后的二叉搜索树,新生成的树应该与原来的树有着相同的节点值。如有多种构造方法,请你返回任意一种。
我们可以对二叉搜索树进行中序遍历,得到一个有序数组,针对这个数组来构造平衡二叉树,需要注意满足三个条件:
- 有序数组中间节点为根节点
- 根节点左侧区间为左子树
- 根节点右侧区间为右子树
这些条件是基于贪心策略的,当左右子树的大小越「平均」,这棵树就越平衡1。
对于我们之前遇到的数组元素个数的奇偶问题,若数组有偶数个元素,中间节点任取左右两个边的一个均可。
递归法
对于中序遍历,先遍历左子树,再取根节点,最后遍历右子树:
inOrder(root->left, nums);
nums.push_back(root->val);
inOrder(root->right, nums);
对于构造平衡二叉树,重复的子问题就是找到根节点,递归构造左子树,递归构造右子树:
int mid = left + ((right - left) >> 1);
//根节点
TreeNode* midNode = new TreeNode(nums[mid]);
//递归构造左子树
midNode->left = process(nums, left, mid - 1);
//递归构造右子树
midNode->right = process(nums, mid + 1, right);
对于有序序列构造平衡二叉树的终止条件 base case
,当 left > right
时,就终止,返回 nullptr
,因为此时就是空节点。
具体代码实现如下:
class Solution {
private:
//中序遍历,将二叉搜索树转为有序数组
void inOrder(TreeNode* root, vector<int>& nums) {
if (root == nullptr) return;
inOrder(root->left, nums);
nums.push_back(root->val);
inOrder(root->right, nums);
}
//将有序数组转为平衡二叉树
TreeNode* process(const vector<int>& nums, int left, int right) {
if (left > right) return nullptr;
//找数组中间元素
int mid = left + ((right - left) >> 1);
//根节点
TreeNode* midNode = new TreeNode(nums[mid]);
//递归构造左子树
midNode->left = process(nums, left, mid - 1);
//递归构造右子树
midNode->right = process(nums, mid + 1, right);
return midNode;
}
public:
TreeNode* balanceBST(TreeNode* root) {
vector<int> nums;
inOrder(root, nums);
TreeNode* rootNew = process(nums, 0, nums.size() - 1);
return rootNew;
}
};
该算法使用中序遍历将二叉搜索树转化为有序序列消耗 O ( n ) O(n) O(n),而由于有序序列构造平衡二叉树时每个元素都要访问且构造单个节点消耗 O ( 1 ) O(1) O(1),即有序序列构造平衡二叉树也消耗 O ( n ) O(n) O(n),所以总体的时间复杂度为 O ( n ) O(n) O(n)。
无论是中序遍历,还是有序序列构造平衡二叉树,调用的栈大小都是
O
(
log
n
)
O(\log n)
O(logn),此外还使用了一个数组 nums
存储中序遍历之后的有序序列,消耗
O
(
n
)
O(n)
O(n),所以总体的空间复杂度为
O
(
n
)
O(n)
O(n)。
迭代法
我们依然还是有序序列构造平衡二叉树,需要用到 3 个队列:
rootQue
存放遍历的节点leftQue
存放左区间的下标rightQue
存放右区间的下标
之后不断地模拟寻找根节点,构造左子树和构造右子树。
class Solution {
private:
vector<int> inOrder(TreeNode* root) {
if (root == nullptr) return {};
vector<int> nums;
stack<TreeNode*> stk;
while (!stk.empty() || root != nullptr) {
if (root != nullptr) {
stk.push(root);
root = root->left;
} else {
TreeNode* cur = stk.top();
stk.pop();
nums.push_back(cur->val);
root = cur->right;
}
}
return nums;
}
public:
TreeNode* balanceBST(TreeNode* root) {
vector<int> nums = inOrder(root);
if (nums.size() == 0) return nullptr;
//初始化根节点
TreeNode* rootNew = new TreeNode(0);
//队列存放遍历的节点
queue<TreeNode*> rootQue;
//队列存放左区间下标
queue<int> leftQue;
//队列存放右区间下标
queue<int> rightQue;
//初始化 3 个队列
rootQue.push(rootNew);
leftQue.push(0);
rightQue.push(nums.size() - 1);
while (!rootQue.empty()) {
TreeNode* cur = rootQue.front();
rootQue.pop();
int left = leftQue.front();
leftQue.pop();
int right = rightQue.front();
rightQue.pop();
//找数组中间元素
int mid = left + ((right - left) >> 1);
//将中间元素值赋值给节点
cur->val = nums[mid];
//处理左区间
if (left < mid) {
cur->left = new TreeNode(0);
rootQue.push(cur->left);
leftQue.push(left);
rightQue.push(mid - 1);
}
//处理右区间
if (right > mid) {
cur->right = new TreeNode(0);
rootQue.push(cur->right);
leftQue.push(mid + 1);
rightQue.push(right);
}
}
return rootNew;
}
};
该贪心算法的正确性证明:
要证明基于这三个条件构造的这棵树是平衡的,就要证明这棵树的根节点为空或者左右两个子树的高度差的绝对值不超过 1。观察这个过程,我们不难发现它和二分查找非常相似——对于一个长度为 x x x 的区间,由它构建出的二叉树的最大高度应该等于对长度为 x x x 的有序序列进行二分查找「查找成功」时的「最大」比较次数,为 ⌊ log 2 x ⌋ + 1 \lfloor \log_2 x\rfloor + 1 ⌊log2x⌋+1,记为 h ( x ) h(x) h(x)。
「引理 A」 长度为 k k k 的区间与长度为 k + 1 k+1 k+1 的区间(其中 k ≥ 1 k≥1 k≥1)按照以上方法构造出的二叉树的最大高度差不超过 1 。
要证明「引理 A」,我们要证明:
h ( k + 1 ) − h ( k ) = [ ⌊ log 2 ( k + 1 ) ⌋ + 1 ] − [ ⌊ log 2 k ⌋ + 1 ] = ⌊ log 2 ( k + 1 ) ⌋ − ⌊ log 2 k ⌋ ≤ 1 \begin{aligned} h(k+1)-h(k) &= \left[ \lfloor \log_2(k+1)\rfloor+1 \right] - \left[ \lfloor \log_2k\rfloor+1 \right] \\ &= \lfloor \log_2(k+1)\rfloor - \lfloor \log_2 k \rfloor \leq 1 \end{aligned} h(k+1)−h(k)=[⌊log2(k+1)⌋+1]−[⌊log2k⌋+1]=⌊log2(k+1)⌋−⌊log2k⌋≤1
由此我们可以证明不等式: ⌊ log 2 ( k + 1 ) ⌋ ≤ ⌊ log 2 k ⌋ + 1. \lfloor\log_2 (k+1)\rfloor \leq \lfloor\log_2k\rfloor + 1. ⌊log2(k+1)⌋≤⌊log2k⌋+1.
设 k = 2 r + b k=2^r+b k=2r+b,其中 0 ≤ b ≤ 2 r 0\leq b \leq 2^r 0≤b≤2r,那么 k ∈ [ 2 r , 2 r + 1 ) k\in [2^r,2^{r+1}) k∈[2r,2r+1), ⌊ log 2 k ⌋ = r \lfloor\log_2k\rfloor=r ⌊log2k⌋=r,不等式右边等于 r + 1 r+1 r+1。由于 k ∈ [ 2 r , 2 r + 1 ) k\in [2^r,2^{r+1}) k∈[2r,2r+1),则 k + 1 ∈ ( 2 r , 2 r + 1 ] k+1\in (2^r,2^{r+1}] k+1∈(2r,2r+1],故 ⌊ log 2 ( k + 1 ) ⌋ = r + 1 \lfloor\log_2 (k+1)\rfloor=r+1 ⌊log2(k+1)⌋=r+1,即不等式右边等于 ⌊ log 2 ( k + 1 ) ⌋ \lfloor\log_2 (k+1)\rfloor ⌊log2(k+1)⌋,因此我们需要证明
⌊ log 2 ( k + 1 ) ⌋ ≤ ⌊ log 2 ( k + 1 ) ⌋ \lfloor\log_2 (k+1)\rfloor \leq \lfloor\log_2 (k+1)\rfloor ⌊log2(k+1)⌋≤⌊log2(k+1)⌋
显然成立。由此逆推可得,引理 A 成立。
下面我们利用「引理 A」来证明这一贪心算法的正确性:
假设我们要讨论的区间长度为 k k k,我们用数学归纳法来证明:- k = 1 k=1 k=1, k = 2 k=2 k=2 时显然成立;
- 假设 k = m k=m k=m 和 k = m + 1 k=m+1 k=m+1 时正确性成立:
-
- 那么根据「引理 A」,长度为 m m m 和 m + 1 m+1 m+1 的区间构造出的子树都是平衡的,并且它们的高度差不超过 1;
-
- 当 k = 2 ( m + 1 ) − 1 k=2(m+1)−1 k=2(m+1)−1 时,创建出的节点的值等于第 m + 1 m+1 m+1 个元素的值,它的左边和右边各有长度为 m m m 的区间,根据「假设推论」, k = 2 ( m + 1 ) − 1 k=2(m+1)−1 k=2(m+1)−1 时构造出的左右子树都是平衡树,且树形完全相同,故高度差为 0,所以 k = 2 ( m + 1 ) − 1 k=2(m+1)−1 k=2(m+1)−1 时,正确性成立;
-
- 当 k = 2 ( m + 1 ) k=2(m+1) k=2(m+1) 时,创建出的节点的值等于第 m + 1 m+1 m+1 个元素的值,它的左边的区间长度为 m m m,右边区间的长度为 m + 1 m+1 m+1,那么 k = 2 ( m + 1 ) k=2(m+1) k=2(m+1) 时构造出的左右子树都是平衡树,且高度差不超过 1,所以 k = 2 ( m + 1 ) k=2(m+1) k=2(m+1) 时,正确性成立.
- 通过这种归纳方法,可以覆盖所有的 k ≥ 1 k≥1 k≥1。故在 k ≥ 1 k≥1 k≥1 时,正确性成立,证毕。