数据结构和算法笔记(一):二叉树、堆、链表、双指针

时间复杂度

常见数据结构的查找、插入、删除时间复杂度

二叉树(Binary Tree)

存储结构

二叉树的存储结构有两种,顺序存储结构和链式存储结构。
PS:链式存储结构的二叉树极端情况下会退化成单链表。

基本概念

二叉树基本概念一览 -> 结点的度,结点的种类,遍历方式…
树的高度和深度的区别:某结点的深度是指从根结点到该结点的最长简单路径边的条数,而高度是指从该结点到叶子结点的最长简单路径边的条数。(这里规定根结点的深度和叶子结点的高度为0)因此,树的高度和深度是一样的,但是对于某个结点的高度和深度是不一定相等。

二叉树的深度 = max(左子树深度,右子数深度) + 1,可用递归的方式实现(“左右根”,后序遍历)。

二叉树分类

前提:树的高度h从1开始,根结点下标为1。

满二叉树(perfect binary tree):每层结点个数都是最大值的二叉树。如果二叉树的结点个数为 2 h − 1 2^{h-1} 2h1个,则可以判断为满二叉树。(遍历所有节点,计算节点个数,O(n))

完全二叉树(complete binary tree):在完全二叉树中,除了最底层结点可能没填满外,其余每层结点数都达到最大值,并且最下面一层的结点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1 ~ 2 h − 1 1~2^{h-1} 12h1个结点。
完全二叉树的节点个数 -> 利用完全二叉树的性质,即左右子树中必定有满二叉树,另一个子树为完全二叉树,可以递归进行。满二叉树的节点个数可以通过树的高度h直接计算得到。时间复杂度O((logn)^2),每层递归需要计算一次左右子树的高度, 2 × ( h − 1 + h − 2 + h − 3 + . . . + 1 ) 2\times(h-1+h-2+h-3+...+1) 2×(h1+h2+h3+...+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),但插入数据是无序的,为顺序存储结构)

相关练习

[算法总结] 20 道题搞定 BAT 面试——二叉树

堆(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 hlog2i1次, 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)。

相关练习

  1. 排序数组 -> 手写堆排序,不用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;
            }
        }
    }
  1. 最小的k个数 -> 建立k个元素的大顶堆

链表(list)

链表是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个结点里存储一个指向下一个结点的指针。
PS:链表list是离散存储,数组vector是连续存储,双端队列deque是vector和list的折中实现,是多个内存块组成的,每个内存块存放的元素是连续存储的,而内存块之间像链表一样连接起来。

参考:一文搞定常见的链表问题
链表的问题一般都可以灵活的应用双指针来解决!

相关练习

  1. 删除链表中间某个结点 -> 传入指向待删除结点的指针P。没有指向P的前驱结点的指针,不能删除P指针指向的结点,但可以将待删除结点的下一个结点的值给当前结点,删除下一个结点
  2. 获取链表中倒数第k个结点 -> 双指针p和q,p先移动k个结点,然后p和q再一起移动,当p指向null时,q指向倒数第k个结点。
  3. 获取链表的中间结点 -> 快慢指针fast和slow,fast移动两步,slow移动一步,循环条件fast!=nullptr && fast->next!=nullptr。当链表结点为奇数,循环退出时fast->next为null,slow指向中间结点;当链表结点为偶数,循环退出时fast为null,slow指向中间靠右结点(第二个中间结点)。
    PS:当结点为偶数,获得中间靠左结点,可以预先定义一个pre保存slow的前一步结果。
  4. 判断链表是否存在环 -> 快慢指针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;
    }
  1. 求链表环的长度 -> fast和slow指针相遇后,继续移动,并从0开始记录移动次数k,当fast和slow指针再次相遇时的经过的移动次数k为环的长度。
  2. 求链表环的入口结点 -> 在获得环的长度k后,利用双指针p和q,p先移动k个结点,然后p和q一起移动,当p和q相遇时指向的结点就是入口结点。(假设头结点到入口结点需要移动L步,环长k,因此第二次到入口结点需要移动L+k步。q移动L步到入口结点,所以p要先比q多移动k步,p第二次经过入口才会和第一次经过入口的q相遇)
    PS:还有更快的方式,见下图
    链表环入口
  3. 反转链表,不能用中间数组 -> 【反转链表】:双指针,递归,妖魔化的双指针
    双指针思路:指针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;
    }
  1. 两个链表的第一个公共节点 -> 先分别计算两个链表的长度,然后计算链表之间的长度差k。然后,双指针分别指向两个链表的头结点,长的链表的指针先走k步,然后再一起走,相遇的时候就是在第一个公共的结点。这样没有利用额外的空间。
    PS:更简洁牛皮的解法见->双指针法,浪漫相遇
  2. 分隔链表
    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;
}

猜你喜欢

转载自blog.csdn.net/XindaBlack/article/details/105642883