一、二叉树
二叉树是每个结点至多只有两棵子树(度小于等于2)的树,且二叉树的子树有左右之分,次序不能颠倒。
二叉树和度为2的树区别:度为2的树至少有三个节点,而二叉树可以为空
1.满二叉树
满二叉树的每一层都含有最多的结点,且除了叶子结点之外的每个结点度数均为2,对满二叉树编号如下,根据这些号可以求出每一层的最大宽度等信息。
2.完全二叉树
当有n个结点的二叉树每个结点都与满二叉树的编号为1~n的结点一一对应时,就是完全二叉树
看出满二叉树和完全二叉树的区别了吗?其实完全二叉树就是叶子结点未填满的满二叉树。
3.二叉树的存储结构
- 顺序存储结构
按照满二叉树的编号将二叉树每个节点i
存储在数组对应下标为i-1
的位置上,一般二叉树也这样存,即使有空余,因此顺序存储多用于完全二叉树中
- 链式存储结构
用链表表示元素的逻辑关系,一个数据域和两个指针域,分别指向左孩子和右孩子
4.二叉搜索树
二叉搜索树,也叫二叉排序树、二叉查找树,或BST,其特点为:
- 若左子树非空,则左子树上所有结点值均小于根结点
- 若右子树非空,则右子树上所有结点值均大于根结点
- 左、右子树本身也是二叉搜索树
二叉搜索树的最重要性质为:中序遍历序列是递增的,利用这一个性质可以做许多有趣的事情。
二、树的遍历
树的遍历非常重要,分为两类
- 深度优先遍历:前序、中序、后序,遍历方法有两种,递归方法最为直观易懂,但效率不高,可用栈迭代方法提供效率,但代码复杂。
- 广度优先遍历:中序,遍历用队列辅助
递归通常有两种,一种是直接在整个函数中递归(可以直接使用函数的形参),一种是用辅助函数递归(不能直接使用形参,需要处理再调用辅助函数)
// 函数递归
function isTree(root) {
// 终止条件,一般都是判断节点是否为空
if(!root) return true
// 否则,递归
return isTree(root.right) && isTree(root.left)
}
// 辅助函数递归
function Tree(root) {
// 调用辅助函数
return isTree(root)
}
function isTree(root) {
// 终止条件,一般都是判断节点是否为空
if(!root) return true
// 否则,递归
return isTree(root.right) && isTree(root.left)
}
1.前序遍历
// 递归
var preorderTraversal = function(root) {
let res = []
let preorder = function(node) {
if(!node) return
res.push(node.val)
preorder(node.left)
preorder(node.right)
}
preorder(root)
return res
};
用栈模拟递归遍历
// 迭代遍历
var preorderTraversal = function(root) {
if(!root) return []
let res = []
stack = [root]
while(stack.length) {
let node = stack.pop()
res.push(node.val)
if(node.right) stack.push(node.right)
if(node.left) stack.push(node.left)
}
return res
};
2. 中序遍历
// 递归
var inorderTraversal = function(root) {
if(!root) return []
let res = []
let inorder = function(node) {
if(!node) return
inorder(node.left)
res.push(node.val)
inorder(node.right)
}
inorder(root)
return res
};
迭代的方法有很多,这一种是标记访问过节点的,依次加入左节点到栈中,直到没有左节点,再依次弹出左节点加入右节点。
// 迭代
var inorderTraversal = function(root) {
if(!root) return []
let res = []
let stack = [root]
// 用指针指向当前遍历的节点
let cur = root
// 标记是否被访问过,不能一开始就把root当作已访问过
let visited = new Set()
while(stack.length) {
// 只要还有左节点
while(cur.left && !visited.has(cur)) {
visited.add(cur)
// 存入左节点
stack.push(cur.left)
// 指向下一个左节点
cur = cur.left
}
// 弹出最左边节点
cur = stack.pop()
res.push(cur.val)
// 有右节点就压入
if(cur.right) {
stack.push(cur.right)
cur = cur.right
}
}
return res
};
也可以不用标记节点
// 迭代
var inorderTraversal = function(root) {
if(!root) return []
let res = []
let stack = []
// 用指针指向当前遍历的节点
let cur = root
// 栈里还有值或者当前节点不为空就要遍历,
// 当前节点不为空是防止根节点取不到
while(stack.length || cur) {
// 所有不为空的节点都来这里入栈
while(cur) {
stack.push(cur)
// 中序遍历先循环左节点
cur = cur.left
}
// 弹出节点
cur = stack.pop()
res.push(cur.val)
// 不管右节点有没有值都先让指针指向
cur = cur.right
}
return res
};
3.后序遍历
// 递归
var postorderTraversal = function(root) {
let res = []
let traversal = function(node) {
if(!node) return
traversal(node.left)
traversal(node.right)
res.push(node.val)
}
preorder(root)
return res
};
树的后序后序迭代最难想,用层序遍历和栈实现,注意最后要反转数组
// 迭代
var postorderTraversal = function(root) {
// 搞不懂为什么 return root 会报错
if(!root) return []
let res = []
let stack = [root]
while(stack.length) {
// 取出每个要遍历的节点
let cur = stack.pop()
res.push(cur.val)
// 若有左节点、右节点,先后入栈
if(cur.left) stack.push(cur.left)
if(cur.right) stack.push(cur.right)
}
// 记得最后将层序遍历结果反转
return res.reverse()
};
4.通用深度遍历模板
可以利用图的深度遍历思路来进行树的通用深度遍历
- 用颜色标记节点的状态,未访问为白色(0),已访问的节点为灰色(1)。
- 如果遇到的节点为白色(0),则将其标记为灰色(1),然后将其右子节点、自身、左子节点依次入栈(改变入栈顺序就改变了遍历顺序)。
- 如果遇到的节点为灰色(1),加入结果。
var postorderTraversal = function(root) {
if(!root) return []
let res = []
// 0代表未访问过
let stack = [[root, 0]]
while(stack.length) {
let node = stack.pop()
if(!node[0]) continue
// 未访问过
if(node[1] == 0) {
// 先序
stack.push([node[0].right, 0])
stack.push([node[0].left, 0])
// 1代表已访问过,下次不需要再访问
stack.push([node[0], 1])
} else {
// 已访问节点,压入结果栈
console.log(node[0].val)
res.push(node[0].val)
}
}
return res
};
改变节点压入顺序,就改变了遍历顺序
// 中序
stack.push([node[0].right, 0])
stack.push([node[0], 1])
stack.push([node[0].left, 0])
// 后序
stack.push([node[0], 1])
stack.push([node[0].right, 0])
stack.push([node[0].left, 0])
5.层序遍历
借助队列实现,转自力扣题解
var levelOrder = function(root) {
if (!root) return [];
const queue = [root];
const res = []; // 存放遍历结果
let level = 0; // 代表当前层数
while (queue.length) {
res[level] = []; // 第level层的遍历结果
let levelNum = queue.length; // 第level层的节点数量
while (levelNum--) {
const front = queue.shift();
res[level].push(front.val);
if (front.left) queue.push(front.left);
if (front.right) queue.push(front.right);
}
level++;
}
return res;
};
三、树的应用
1.树的递归
// 递归
function isMirror(leftroot, rightroot) {
// 左右子树均为空,说明相等
if(!leftroot && !rightroot) return true
// 左右子树有一个不为空,说明不相等
if(!leftroot) return false
if(!rightroot) return false
// 若两个节点值相同,且左子树的左子树等于右子树的右子树,左子树的右子树等于右子树的左子树
if(leftroot.val == rightroot.val && isMirror(leftroot.left, rightroot.right) && isMirror(leftroot.right, rightroot.left)) return true
else return false
}
var isSymmetric = function(root) {
if(!root) return true
return isMirror(root.left, root.right)
};
很明显可以看出来迭代的原理还是递归,只不过用while和栈模拟了递归
// 迭代
var isSymmetric = function(root) {
if(!root) return true
let stack = [root.left, root.right]
while(stack.length) {
let left = stack.pop()
let right = stack.pop()
if(!left && !right) continue
if(!left) return false
if(!right) return false
if(left.val == right.val) {
stack.push(left.left)
stack.push(right.right)
stack.push(left.right)
stack.push(right.left)
} else {
return false
}
}
return true
};
2. 树的深度
树的最大深度可以用递归或广度优先遍历
// 递归
var maxDepth = function(root) {
if(!root) return 0
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1
};
// 迭代也可以求,用广度优先遍历
树的最小深度
这道题一定要理解清楚题意,求的是根节点到叶子节点的最短距离,所以要判断现在到达的节点是否为叶子节点。
// 第一次见到这么多的递归条件,每个节点都有五种可能情况
var minDepth = function(root) {
// (1)该节点为空,不记数
if(!root) return 0
// (2)该节点的左右节点均为空,说明是叶子节点,结束递归
if(!root.left && !root.right) return 1
// (3)左节点为空,但右节点不为空
if(!root.left) return minDepth(root.right)+1
// (4)右节点为空,但左节点不为空
if(!root.right) return minDepth(root.left)+1
// (5)左右节点均不为空,返回最小深度
return Math.min(minDepth(root.left), minDepth(root.right)) + 1
};
3.回溯在树中的应用
var pathSum = function(root, sum) {
if(!root) return []
let res = []
let path = [root.val]
let lookpath = function(root, sum, path) {
// 到子节点
if(!root.left && !root.right) {
// 判断这条路是否满足条件
if(root.val == sum) {
res.push(path.slice())
}
return
}
// 剪枝条件,注意一定要考虑负数的情况
// if(Math.abs(root.val) >= Math.abs(sum)) return
// 不能剪枝,因为没说全是负数或者全是正数
// 还是考虑问题不全面啊!!!!答错了两次,心痛!
if(root.left) {
path.push(root.left.val)
lookpath(root.left, sum-root.val, path)
path.pop()
}
if(root.right) {
path.push(root.right.val)
lookpath(root.right, sum-root.val, path)
path.pop()
}
}
lookpath(root, sum, path)
return res
};
4.满二叉树的应用
根据满二叉树的层序序号, 求力扣:二叉树的最大宽度
var widthOfBinaryTree = function(root) {
if(!root) return 0
let queue = [root]
let idx = [1]
// 根节点所在的第一层宽度肯定是1
let max = 1
while(queue.length) {
const size = queue.length
for(let i = 0; i < size; i++) {
let node = queue.shift()
let id = idx.shift()
if(node.left) {
queue.push(node.left)
idx.push(2*id)
}
if(node.right) {
queue.push(node.right)
idx.push(2*id+1)
}
}
// 只有一个值活着没有值不用考虑
if(idx.length > 1) max = Math.max(max, idx[idx.length-1] - idx[0] + 1)
}
return max
};
5.由遍历序列构造二叉树
各个遍历序列的特点:
- 先序遍历中,第一个结点是二叉树的根节点,左子序列的第一个结点是左子树的根节点,右子序列的第一个结点是右子树的根节点
- 中序遍历中,根节点将序列分割成两个子序列,前一个序列就是根节点的左子树的中序序列,前一个序列就是根节点的右子树的中序序列
- 后序遍历中,最后一个结点是二叉树的根节点,左子序列的第一个结点是左子树的根节点,右子序列的第一个结点是右子树的根节点
可以由以下几个遍历序列构造二叉树
- 先序和中序
- 后序和中序
- 层序和中序
- 先序和后序
注意:这类题目都要if(!root) return null
,因为返回类型是树的节点
力扣:从前序与中序遍历序列构造二叉树
var buildTree = function(preorder, inorder) {
if(!preorder.length) return null
let root = new TreeNode(preorder[0])
if(preorder.length==1) return root
// 因为树中没有重复的元素,如果有了就不能用这种方法分割中序序列了
let mid = inorder.indexOf(preorder[0])
root.left = buildTree(preorder.slice(1,mid+1),inorder.slice(0,mid))
root.right = buildTree(preorder.slice(mid+1),inorder.slice(mid + 1))
return root
};
力扣:从中序与后序遍历序列构造二叉树
力扣:根据前序和后序遍历构造二叉树
6.二叉搜索树
var sortedArrayToBST = function(nums) {
const len = nums.length
if(!len) return null
const r = Math.floor(len/2)
let root = new TreeNode(nums[r])
root.left = sortedArrayToBST(nums.slice(0, r))
root.right = sortedArrayToBST(nums.slice(r+1, len))
return root
};
力扣:验证二叉搜索树
根据性质二叉搜索树的中序序列是递增序列,在二叉树中序遍历的代码中加一个判断即可。
var isValidBST = function(root) {
let stack = [];
// js中最小值的表示方法
let inorder = -Infinity;
let res = []
while (stack.length || root !== null) {
while (root !== null) {
stack.push(root);
root = root.left;
}
root = stack.pop();
res.push(root.val)
// 如果中序遍历得到的节点的值小于等于前一个 inorder,说明不是二叉搜索树
if (root.val <= inorder) return false;
inorder = root.val;
root = root.right;
}
return true;
};
7.二叉树的祖先
var lowestCommonAncestor = function(root, p, q) {
if(!root || root==p || root==q) return root
let left = lowestCommonAncestor(root.left, p, q)
let right = lowestCommonAncestor(root.right, p, q)
if(left && right) return root
if(!left && !right) return
if(!left) return right
if(!right) return left
};
8. 完全二叉树
力扣:完全二叉树的节点个数,结合遍历普通二叉树的个数和完全二叉树的个数性质。
var countNodes = function(root) {
let left = right = root
let hl = hr = 0
// 左右两边都往最旁边去,因为完全二叉树的性质
while(left) {
hl++
left = left.left
}
while(right) {
hr++
right = right.right
}
// 左右子树高度相等,就是满二叉树,直接返回满二叉树的个数
if(hl == hr) {
return Math.pow(2, hl) - 1
}
// 不相等,不是满二叉树,加1,遍历左右两遍
// 总会遇到某一边为满二叉树
return 1 + countNodes(root.left) + countNodes(root.right)
};
四、红黑树
是数据结构掌握深度的一个考察点。
1. 红黑树的特性
- 从根到叶子的最长可能路径,不会超过最短可能路径的两倍长
- 树是基本平衡的
- 即使没有做到绝对平衡,在保证最坏的情况下,依然高效
2.红黑树规则
- 节点是红色或黑色
- 根节点是黑色
- 每个叶子节点都是黑色的空节点(null节点)
- 每个红色节点的两个子节点都是黑色(从每个叶子到根的所有路径不能有两个连续的红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
以上规则保证了最长路径不超过最短路径的两倍
- 性质4决定了路径不能有两个相连的红色节点
- 最短的可能路径都是黑色节点
- 最长的可能路径是红色和黑色交替
- 性质5所有路径都有相同数目的黑色节点,所以没有路径能多余任何其它路径的两倍长
3.变换规则
首先,要明确插入一个新节点,为了保证规则5,它的颜色一定是红色的
- 变色,插入红色节点可能会导致红色相连的现象,不满足规则5,这是可以通过变换颜色满足要求。
- 左旋转:逆时针旋转不满足颜色条件的两个节点,使父节点被自己的右孩子取代,父节点变成左孩子;同时注意原来右节点的左节点平移到原来父节点的右边。
- 右旋转:顺时针旋转不满足颜色条件的两个节点,使父节点被自己的左孩子取代,父节点变成右孩子;同时注意原来左节点的右节点平移到原来父节点的左边。
4. 插入一个节点
- 向空树插入节点,变换节点为黑即可
- 父节点是黑色,且插入新节点后也满足红黑树规则,直接插入即可
- 父红叔红祖黑 变换成 父黑叔黑祖红