目录
时间复杂度
二叉树(Binary Tree)
存储结构
二叉树的存储结构有两种,顺序存储结构和链式存储结构。
PS:链式存储结构的二叉树极端情况下会退化成单链表。
基本概念
二叉树基本概念一览 -> 结点的度,结点的种类,遍历方式…
树的高度和深度的区别:某结点的深度是指从根结点到该结点的最长简单路径边的条数,而高度是指从该结点到叶子结点的最长简单路径边的条数。(这里规定根结点的深度和叶子结点的高度为0)因此,树的高度和深度是一样的,但是对于某个结点的高度和深度是不一定相等。
二叉树的深度 = max(左子树深度,右子数深度) + 1,可用递归的方式实现(“左右根”,后序遍历)。
二叉树分类
前提:树的高度h从1开始,根结点下标为1。
满二叉树(perfect binary tree):每层结点个数都是最大值的二叉树。如果二叉树的结点个数为 2 h − 1 2^{h-1} 2h−1个,则可以判断为满二叉树。(遍历所有节点,计算节点个数,O(n))
完全二叉树(complete binary tree):在完全二叉树中,除了最底层结点可能没填满外,其余每层结点数都达到最大值,并且最下面一层的结点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含
1
~
2
h
−
1
1~2^{h-1}
1~2h−1个结点。
完全二叉树的节点个数 -> 利用完全二叉树的性质,即左右子树中必定有满二叉树,另一个子树为完全二叉树,可以递归进行。满二叉树的节点个数可以通过树的高度h直接计算得到。时间复杂度O((logn)^2),每层递归需要计算一次左右子树的高度,
2
×
(
h
−
1
+
h
−
2
+
h
−
3
+
.
.
.
+
1
)
2\times(h-1+h-2+h-3+...+1)
2×(h−1+h−2+h−3+...+1) -> O(h^2)。
PS:已知是完全二叉树,判断是否为满二叉树,主要判断树最左边和最右边的结点高度是否相等,相等则是满二叉树。
判断是否为完全二叉树:bfs找到第一个不含有孩子或者只含有一个左孩子的结点,那么后续的结点必须是叶子结点才满足完全二叉树性质。
int countNodes(TreeNode* root) {
int h;
if(isFullTree(root, h)){
return (1<<h) -1;
}
return countNodes(root->left)+countNodes(root->right)+1; // ‘+1’是把root自身也算上
}
// 判断完全二叉树是否为满二叉树
bool isFullTree(TreeNode* root, int& h){
if(root==nullptr){
h = 0;
return true;
}
TreeNode* p = root;
int countLeft = 1, countRight = 1;
while(p->left!=nullptr){
p = p->left;
countLeft++;
}
p = root;
while(p->right!=nullptr){
p = p->right;
countRight++;
}
h = countLeft;
return countLeft == countRight;
}
二叉搜索树/二叉排序树(binary search tree):它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于等于它的根结点的值; 它的左、右子树也分别为二叉排序树。查找平均效率O(logn)。
二叉搜索树的第k大节点 -> 利用二叉搜索树性质,中序遍历二叉搜索树输出的按非严格递增或者递减序排列的值。(递增是左根右,递减是右左根)
int count;
// 反向的中序遍历,"右根左",结点的值按降序输出
int kthLargest(TreeNode* root, int k) {
int re;
count = k;
traverse(root,&re);
return re;
}
void traverse(TreeNode* root, int* re){
if(root==nullptr){
return;
}
traverse(root->right,re);
if(count==1){
*re = root->val;
}
if(--count == 0){ // 剪枝
return;
}
traverse(root->left,re);
}
二叉搜索树的最近公共祖先 -> 利用BST的右孩子>=根>左孩子的性质即可。
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root==nullptr || p->val < root->val && q->val >= root->val ||
(p->val >= root->val && q->val < root->val)||
root->val == p->val || root->val == q->val){
return root;
}
TreeNode* l = lowestCommonAncestor(root->left,p,q);
TreeNode* r = lowestCommonAncestor(root->right,p,q);
return l==nullptr ? r:l;
}
PS:二叉树的最近公共祖先 -> 后序遍历,左右孩子其中一个返回p或q指针,则将p或q指针向上传递;若左右孩子分别返回有p和q指针,则根为LCA。(如果是p或q结点是它自己的祖先的情况,最终返回p或者q指针!)
// 后序遍历
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root == nullptr || root == p || root == q){ // 遇到p和q指针或者空指针返回
return root;
}
TreeNode* left, *right;
left = lowestCommonAncestor(root->left,p,q);
right = lowestCommonAncestor(root->right,p,q);
if(left == p && right == q || (left == q && right == p)){ // root为LCA,并将root指针本身向上传递
return root;
}
// left和right为空指针表示以它们为根的子树没有p和q结点,因此返回它们之中的非空指针,传递给root
return left==nullptr? right:left;
}
二叉搜索树的查找效率取决于树的高度,因此保持树的高度最小,即可保证树的查找效率。AVL树和红黑树都是自平衡的二叉搜索树。
平衡二叉树/AVL树:在AVL树中,任一节点对应的左、右子树的最大高度差为1,因此它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下的时间复杂度都是
O
(
log
n
)
O(\log {n})
O(logn),但平衡树结构的代价较大。什么是平衡二叉树(AVL)
判断是否为平衡二叉树 -> 判断树中所有结点的子树的高度差是否都不大于1。
bool isBalanced(TreeNode* root) {
bool flag = true; // 平衡二叉树可以是空树
traverse(root,&flag);
return flag;
}
// 从底向上求结点的高度
int traverse(TreeNode* root, bool* flag){
if(root==nullptr || !flag){ // 当已经判断不是平衡二叉树的时候可以直接剪枝返回了
return 0;
}
int l = traverse(root->left,flag);
int r = traverse(root->right,flag);
if(abs(l-r) > 1){
*flag = false;
}
return max(l,r)+1;
}
红黑树/RBT树:从根节点到叶子节点的最长路径不超过最短路径的两倍。查找效率基本维持在O(logn),但在最差情况下比AVL树要逊色一点,远远好于BST树。
漫画:什么是红黑树?
轻松搞定面试中的红黑树问题
PS:大量数据实践证明,RBT的总体统计性能要好于平衡二叉树。
STL里哪些容器用到二叉树存储?
map、set的底层数据结构是红黑树,插入的数据是有序存储的,默认按key的升序存储,查找效率O(logn)。map和set是关联容器,内部所有元素都是以结点的方式来存储,为链式存储结构。(unordered_map和unorder_set的底层数据结构是哈希表,查找效率O(1),但插入数据是无序的,为顺序存储结构)
相关练习
堆(heap)
堆以完全二叉树的形式表示,用队列(数组)存储,队列中允许的操作是先进先出(FIFO),在队尾插入元素,在队头取出元素。堆也是一样,在堆底插入元素,在堆顶取出元素,但是堆中元素的排列不是按照到来的先后顺序,而是按照一定的优先顺序排列的,因此也称为优先队列(priority queue)。(若队列中根结点下标为 i i i 且 i i i 从1开始,则它的左孩子下标为 2 i 2i 2i,右孩子下标为 2 i + 1 2i+1 2i+1)
堆分为大顶堆和小顶堆。堆顶为队列的头部,在堆顶取出元素,一般为最大或者最小的元素;堆底为队列的尾部,在堆底插入元素。大顶堆要求根结点的值大于等于左右孩子节点的值,小顶堆要求根结点的值小于等于左右孩子节点。
建堆
自底向上建堆:从下标最大的非叶子结点开始,从右向左,从底至上调整堆,每次调整为一次下沉操作。调整下标为 i i i 的结点的子树最多需要交换 h − ⌊ l o g 2 i ⌋ − 1 h-\lfloor log_2i \rfloor-1 h−⌊log2i⌋−1次, h h h 为树的高度, ⌊ l o g 2 i ⌋ + 1 \lfloor log_2i \rfloor+1 ⌊log2i⌋+1为结点 i i i 所处二叉树中的层数(层数从1开始),可推得建堆的时间复杂度O(n)。为什么建立一个二叉堆的时间为O(N)而不是O(Nlog(N))?
自顶向下建堆:从根结点开始,然后一个一个的把结点插入堆中。当把一个新的结点插入堆中时,需要对结点进行调整,以保证插入结点后的堆依然能维持堆的性质。建堆的时间复杂度O(nlogn)。
堆排序
以升序为例,重复从大顶堆取出数值最大的结点,即堆顶(把根结点和最后一个结点交换,把交换后的最后一个结点移出堆),并调整剩余的堆,使之维持大顶堆的性质。堆排序的时间复杂度是O(nlog n)。
堆的插入和删除操作
最小堆 构建、插入、删除的过程图解
插入操作,插入在队列底部k,则它的父结点为k/2,然后至底向上递归调整,即上浮;删除操作,删除是对于堆顶而言,将堆顶与堆底交换,然后将堆底移出堆,对剩余的对进行至顶向下递归调整,即下沉。插入和删除操作时间复杂度都是O(logn)。
相关练习
- 排序数组 -> 手写堆排序,不用priority_queue
// 堆排序
vector<int> sortArray(vector<int>& nums) {
int n = nums.size()-1;
// 自底向上建堆,O(n)
for(int i = (n-1)/2; i>=0; i--){
adjust_heap(nums,i,n);
}
// 堆排序,O(nlogn)
for(int i = n; i > 0; i--){
swap(nums[0],nums[i]); //将堆顶元素与堆尾交换
adjust_heap(nums,0,i-1);
}
return nums;
}
// 下沉(下虑)操作,维护大顶堆,O(logn)
void adjust_heap(vector<int>& nums, int k, int max_index){
for(int i = 2*k+1; i <= max_index; i = 2*i+1){
if(i+1 <= max_index && nums[i] < nums[i+1]){
i = i+1; // 选择左右孩子中大的那一个
}
if(nums[i] > nums[k]){
swap(nums[i],nums[k]);
k = i;
}else{ // 维护之前,节点k的左右子子树满足大顶堆的性质
break;
}
}
}
- 最小的k个数 -> 建立k个元素的大顶堆
链表(list)
链表是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个结点里存储一个指向下一个结点的指针。
PS:链表list是离散存储,数组vector是连续存储,双端队列deque是vector和list的折中实现,是多个内存块组成的,每个内存块存放的元素是连续存储的,而内存块之间像链表一样连接起来。
参考:一文搞定常见的链表问题
链表的问题一般都可以灵活的应用双指针来解决!
相关练习
- 删除链表中间某个结点 -> 传入指向待删除结点的指针P。没有指向P的前驱结点的指针,不能删除P指针指向的结点,但可以将待删除结点的下一个结点的值给当前结点,删除下一个结点。
- 获取链表中倒数第k个结点 -> 双指针p和q,p先移动k个结点,然后p和q再一起移动,当p指向null时,q指向倒数第k个结点。
- 获取链表的中间结点 -> 快慢指针fast和slow,fast移动两步,slow移动一步,循环条件
fast!=nullptr && fast->next!=nullptr
。当链表结点为奇数,循环退出时fast->next为null,slow指向中间结点;当链表结点为偶数,循环退出时fast为null,slow指向中间靠右结点(第二个中间结点)。
PS:当结点为偶数,获得中间靠左结点,可以预先定义一个pre保存slow的前一步结果。 - 判断链表是否存在环 -> 快慢指针fast和slow,fast每次移动两个结点,slow每次移动一个结点,如果slow能追上fast则链表存在环。
bool hasCycle(ListNode *head) {
ListNode *fast=head, *slow=head;
while(fast != nullptr && fast->next!=nullptr){
fast = fast->next->next;
slow = slow -> next;
if(fast == slow){
return true;
}
}
return false;
}
- 求链表环的长度 -> fast和slow指针相遇后,继续移动,并从0开始记录移动次数k,当fast和slow指针再次相遇时的经过的移动次数k为环的长度。
- 求链表环的入口结点 -> 在获得环的长度k后,利用双指针p和q,p先移动k个结点,然后p和q一起移动,当p和q相遇时指向的结点就是入口结点。(假设头结点到入口结点需要移动L步,环长k,因此第二次到入口结点需要移动L+k步。q移动L步到入口结点,所以p要先比q多移动k步,p第二次经过入口才会和第一次经过入口的q相遇)
PS:还有更快的方式,见下图
- 反转链表,不能用中间数组 -> 【反转链表】:双指针,递归,妖魔化的双指针
双指针思路:指针p和q,p初始定义为nullptr,q初始指向头结点,然后p和q都不断向后移,直到q为nullptr,此时p指向反转后链表的头结点。(中间需要temp指向q的后一个结点)
ListNode* reverseList(ListNode* head) {
ListNode *p = nullptr, *q = head, *temp;
while(q!=nullptr){
temp = q->next;
q->next = p;
p = q;
q = temp;
}
return p;
}
- 两个链表的第一个公共节点 -> 先分别计算两个链表的长度,然后计算链表之间的长度差k。然后,双指针分别指向两个链表的头结点,长的链表的指针先走k步,然后再一起走,相遇的时候就是在第一个公共的结点。这样没有利用额外的空间。
PS:更简洁牛皮的解法见->双指针法,浪漫相遇 - 分隔链表
ListNode* partition(ListNode* head, int x) {
ListNode *ph = new ListNode(0); // 链表ph存放小于x的节点
ListNode *qh = new ListNode(0); // 链表qh存放大于等于x的节点
ListNode *h = head, *p = ph, *q = qh;
while(h!=nullptr){
if(h->val < x){
p->next = h;
p = p->next;
}else{
q->next = h;
q = q->next;
}
h = h->next;
}
p->next = qh->next;
q->next = nullptr; // 注意:链表qh末尾指向链表ph中的节点(形成环),会造成堆内存的二次释放,因此需要指向空
return ph->next;
}
双指针
相关练习
面试题21. 调整数组顺序使奇数位于偶数前面 -> 头尾双指针p和q,向中间靠拢,p的下标始终小于q的下标。
vector<int> exchange(vector<int>& nums) {
// 头尾双指针
int i = 0, j = nums.size()-1;
while(i < j){
// 先移动头部的指正,直到遇见偶数
if(nums[i]%2==0){
// 再移动尾部的指针,直到遇见奇数
while(i < j && nums[j]%2==0){
j--;
}
swap(nums[i],nums[j]);
i++;
j--;
}else{
i++;
}
}
return nums;
}
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> re;
set<int> st;
// 排序,可以去重复,并且有序数组可以用双指针
sort(nums.begin(),nums.end());
for(int i = 0; i < nums.size(); i++){
// 去重复
if(i > 0 && nums[i]==nums[i-1]){
continue;
}
int target = -nums[i];
vector<int> v(3);
v[0] = nums[i];
int l = i+1, r = nums.size()-1;
while(l < r){
if(nums[l]+nums[r]==target){
v[1] = nums[l];
v[2] = nums[r];
re.push_back(v);
l++;
r--;
// 如果已经找到三元组,双指针移动过程中需要去重复
while(l < r && nums[l]==nums[l-1]){
l++;
}
while(l < r && nums[r]==nums[r+1]){
r--;
}
}else if(nums[l]+nums[r]<target){
l++;
}else{
r--;
}
}
}
return re;
}
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> re;
int n = nums.size();
// 排序,固定两个数,然后求剩余两数之和用双指针,O(n^3)
sort(nums.begin(),nums.end());
for(int i = 0; i < n; i++){
if(i > 0 && nums[i]==nums[i-1]) continue; // 去除重复
vector<int> v(4);
v[0] = nums[i];
for(int j = i+1; j < n; j++){
if(j > i+1 && nums[j]==nums[j-1]) continue; // 去除重复
int tar = target - nums[i] - nums[j];
v[1] = nums[j];
int l = j+1, r = n-1;
while(l < r){
if(nums[l]+nums[r]==tar){
v[2] = nums[l];
v[3] = nums[r];
re.push_back(v);
l++;
r--;
while(l < r && nums[l] == nums[l-1]) l++; // 去除重复
while(l < r && nums[r] == nums[r+1]) r--; // 去除重复
}else if(nums[l]+nums[r] < tar){
l++;
}else{
r--;
}
}
}
}
return re;
}