數據結構和算法筆記(一):二叉樹、堆、鏈表、雙指針

時間複雜度

常見數據結構的查找、插入、刪除時間複雜度

二叉樹(Binary Tree)

存儲結構

二叉樹的存儲結構有兩種,順序存儲結構和鏈式存儲結構。
PS:鏈式存儲結構的二叉樹極端情況下會退化成單鏈表。

基本概念

二叉樹基本概念一覽 -> 結點的度,結點的種類,遍歷方式…
樹的高度和深度的區別:某結點的深度是指從根結點到該結點的最長簡單路徑邊的條數,而高度是指從該結點到葉子結點的最長簡單路徑邊的條數。(這裏規定根結點的深度和葉子結點的高度爲0)因此,樹的高度和深度是一樣的,但是對於某個結點的高度和深度是不一定相等。

二叉樹的深度 = max(左子樹深度,右子數深度) + 1,可用遞歸的方式實現(“左右根”,後序遍歷)。

二叉樹分類

前提:樹的高度h從1開始,根結點下標爲1。

滿二叉樹(perfect binary tree):每層結點個數都是最大值的二叉樹。如果二叉樹的結點個數爲2h12^{h-1}個,則可以判斷爲滿二叉樹。(遍歷所有節點,計算節點個數,O(n))

完全二叉樹(complete binary tree):在完全二叉樹中,除了最底層結點可能沒填滿外,其餘每層結點數都達到最大值,並且最下面一層的結點都集中在該層最左邊的若干位置。若最底層爲第 h 層,則該層包含12h11~2^{h-1}個結點。
完全二叉樹的節點個數 -> 利用完全二叉樹的性質,即左右子樹中必定有滿二叉樹,另一個子樹爲完全二叉樹,可以遞歸進行。滿二叉樹的節點個數可以通過樹的高度h直接計算得到。時間複雜度O((logn)^2),每層遞歸需要計算一次左右子樹的高度,2×(h1+h2+h3+...+1)2\times(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(logn)O(\log {n}),但平衡樹結構的代價較大。什麼是平衡二叉樹(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)。(若隊列中根結點下標爲 iiii 從1開始,則它的左孩子下標爲2i2i,右孩子下標爲2i+12i+1)

堆分爲大頂堆和小頂堆。堆頂爲隊列的頭部,在堆頂取出元素,一般爲最大或者最小的元素;堆底爲隊列的尾部,在堆底插入元素。大頂堆要求根結點的值大於等於左右孩子節點的值,小頂堆要求根結點的值小於等於左右孩子節點。

建堆

自底向上建堆:從下標最大的非葉子結點開始,從右向左,從底至上調整堆,每次調整爲一次下沉操作。調整下標爲 ii 的結點的子樹最多需要交換hlog2i1h-\lfloor log_2i \rfloor-1次,hh 爲樹的高度,log2i+1\lfloor log_2i \rfloor+1爲結點 ii 所處二叉樹中的層數(層數從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;
    }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章