常见数据结构的查找、插入、删除时间复杂度
二叉树的存储结构有两种,顺序存储结构和链式存储结构。
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的总体统计性能要好于平衡二叉树。
map、set的底层数据结构是红黑树,插入的数据是有序存储的,默认按key的升序存储,查找效率O(logn)。map和set是关联容器,内部所有元素都是以结点的方式来存储,为链式存储结构。(unordered_map和unorder_set的底层数据结构是哈希表,查找效率O(1),但插入数据是无序的,为顺序存储结构)
[算法总结] 20 道题搞定 BAT 面试——二叉树
堆以完全二叉树的形式表示,用队列(数组)存储,队列中允许的操作是先进先出(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)。
// 堆排序
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;
}
}
}
链表是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个结点里存储一个指向下一个结点的指针。
PS:链表list是离散存储,数组vector是连续存储,双端队列deque是vector和list的折中实现,是多个内存块组成的,每个内存块存放的元素是连续存储的,而内存块之间像链表一样连接起来。
参考:一文搞定常见的链表问题
链表的问题一般都可以灵活的应用双指针来解决!
fast!=nullptr && fast->next!=nullptr
。当链表结点为奇数,循环退出时fast->next为null,slow指向中间结点;当链表结点为偶数,循环退出时fast为null,slow指向中间靠右结点(第二个中间结点)。 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;
}
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;
}
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;
}
15. 三数之和
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;
}
18. 四数之和
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;
}