資料結構與演算法知識點總結(3)樹、圖與並查集

LyAsano發表於2022-04-12

1. 二叉樹

  一般地二叉樹多用鏈式儲存結構來描述元素的邏輯關係。通常情況下二叉樹中的結點定義如下:

typedef struct btree_node {
    void *item;
    struct btree_node *left;
    struct btree_node *right;
} btree_node_t;

  在一些不同的實際應用中,還可以增加某些指標域或者線索化標誌,例如增加指向父結點的指標、左右線索化的標誌。

  另外如果你想區分二叉樹結點和二叉樹這種結構,不妨定義如下的二叉樹結構(類似於STL中分離定義資料結構和元素結點的方法):

typedef struct  {
    btree_node_t *root;
    int n;
    int (*comp)(const void *,const void *);
} btree_t;

typedef void (*cb)(btree_node_t *);//定義訪問結點的函式指標

  其中n表示二叉樹結點的個數,comp表示函式指標。使用函式指標comp因為資料型別使用的是通用指標,在進行查詢等比較資料大小的操作時需要定義一個比較函式,在泛型資料結構的C實現中應用非常廣泛。

1.1 二叉樹的遍歷

  二叉樹常見的遍歷次序有先序、中序、後序三種,其中序表示根結點在何時被訪問。每種遍歷演算法都有對應的遞迴解法和非遞迴解法。它的非遞迴解法中使用了棧這種資料結構。每個遍歷都有自身的特點:

  • 先序遍歷序列的第一個結點和後序遍歷的最後一個結點一定是根結點
  • 在中序遍歷序列中,根結點將序列分成兩個子序列: 根結點左子樹的中序序列和根結點右子樹的中序序列
  • 先序序列或者後序序列或者層次序序列結合中序序列可以唯一確定一棵二叉樹

  二叉樹還有一種層次序遍歷,它是按自頂向下、自左向右的訪問順序來訪問每個結點,它的實現使用了佇列這種資料結構。

  此外二叉樹還有一種Morris遍歷方法,和上面使用O(n)空間複雜度的方法不同,它只需要O(1)的空間複雜度。這個演算法跟線索化二叉樹很像,不過Morris遍歷是一邊建立線索一邊訪問資料,訪問完後直接銷燬線索,保持二叉樹的不變。Morris演算法的原則比較簡單:藉助所有葉結點的右指標(空指標)指向其後繼節點,組成一個環,但由於第二次遍歷到這個結點時,由於左子樹已經遍歷完了,就訪問該結點。

  總結下來,遍歷是二叉樹各種操作的基礎,可以在遍歷的過程中對結點進行各種操作,例如求二叉樹的深度(高度)、二叉樹的葉子結點個數、求某層結點個數(或者樹的最大寬度、分層輸出結點)、判斷二叉樹是否相同或者是否為完全二叉樹或者二叉排序樹、求二叉樹中結點的最大距離、由遍歷序列重建二叉樹等。

1.2遍歷的遞迴解法

  遞迴程式碼比較簡單,就不具體解釋了,實現如下:

/*先序遍歷,遞迴*/
 void bt_preorder(btree_t *t, cb visit){
     bt_preorder_rec(t->root,visit);
 }

void bt_preorder_rec(btree_node_t *cur, cb visit) {
    if(cur==NULL) return ;
    visit(cur);
    bt_preorder_rec(cur->left,visit);
    bt_preorder_rec(cur->right,visit);
}

/*中序遍歷,遞迴*/
void bt_inorder(btree_t *t, cb visit) {
    bt_inorder_rec(t->root,visit);
}

void bt_inorder_rec(btree_node_t *cur, cb visit) {
    if(cur==NULL) return ;
    bt_inorder_rec(cur->left,visit);
    visit(cur);    
    bt_inorder_rec(cur->right,visit);
}

/*後序遍歷,遞迴*/
void bt_postorder(btree_t *t, cb visit) {
    bt_postorder_rec(t->root,visit);
}
void bt_postorder_rec(btree_node_t *cur, cb visit) {
    if(cur==NULL) return ;
    bt_postorder_rec(cur->left,visit);
    bt_postorder_rec(cur->right,visit);
    visit(cur);
}

1.3基於棧或佇列的非遞迴解法

  A 基於棧的VLR先序遍歷
  整體思路:先入棧根結點,然後再判斷棧是否為空:不為空,出棧當前元素,並按照右左子樹順序分別入棧。該方法可藉助棧的操作,如下的方法採用了類似於棧的實現方式,注意入棧順序: VRL。

  實現如下:

void bt_preorder_iter(btree_t *t, cb visit){
    if(t->root){
        btree_node_t **stack=malloc(sizeof(btree_node_t *)*(t->n));
        stack[0]=t->root;
        int top=1;
        while(top>0){ //只要棧不為空
            btree_node_t *cur=stack[--top];//出棧
            visit(cur);
            if(cur->right)
                stack[top++]=cur->right;
            if(cur->left)
                stack[top++]=cur->left;

        }
        free(stack);
    }
}

  B 基於棧的LVR中序遍歷
  整體思路:判斷條件有兩個:棧是否為空或當前結點cur是否為空。根據中序遍歷順序LVR

  • 如果棧為空,需不斷壓棧當前每個非空結點一直遍歷到第一個沒有左孩子的根結點
  • 此時cur為空,top(棧中的元素大小)只要大於0,開始進行出棧,訪問當前結點,再遍歷右子樹

  實現如下:

void bt_inorder_iter(btree_t *t, cb visit){
    btree_node_t **stack=malloc(sizeof(btree_node_t *)*(t->n));
    btree_node_t *cur=t->root;
    int top=0;
    while(top>0|| cur!=NULL){
        if(cur !=NULL){
            stack[top++]=cur;
            cur=cur->left;
        } else{
            cur=stack[--top];//出棧當前棧頂元素
            visit(cur);
            cur=cur->right;
        }
    }
    free(stack);
}

  C 基於棧的LRV後序遍歷
  整體思路: 用棧儲存結點時,必須分清返回根結點時:是從左子樹返回的還是從右子樹的返回的。這裡用一個pre指標記錄最近剛訪問過的結點。當一直往左直到左孩子為空時,判斷當前結點的右孩子是否為空或者是否已訪問過

  • 若右孩子為空或者已被訪問過(LV或者LRV),則訪問當前結點,並更新最近訪問的結點,並重置當前指標為NULL
  • 否則遍歷右孩子(壓棧),再轉向左

  實現如下:

void bt_postorder_iter(btree_t *t, cb visit){
    btree_node_t **stack=malloc(sizeof(btree_node_t *)*(t->n));
    btree_node_t *cur=t->root;
    btree_node_t *pre=NULL; //指向最近訪問過的結點
    int top=0;
    while(cur!=NULL||top>0){ //當前結點不為空或者棧不為空
        if(cur){ //壓棧,一直往左走
            stack[top++]=cur; 
            cur=cur->left;
        } else {
            cur=stack[top-1];//取棧頂元素
            if(cur->right&&cur->right!=pre){ //如果右子樹存在,且未被訪問過
                cur=cur->right;
                stack[top++]=cur; //轉向右,壓棧
                cur=cur->left;//再走向最左,始終保持LRV的遍歷順序
            } else{ //要麼右孩子為空,要麼右孩子已經被訪問過,彈出當前結點
                cur=stack[--top];
                visit(cur);
                pre=cur; //記錄最近訪問過的結點,結點訪問完重置cur指標
                cur=NULL;
            }
        }
    }
    free(stack);
}

  D 基於佇列的層次序遍歷
  和先序遍歷很類似,區別就是棧換成了佇列。實現如下:

void bt_levelorder(btree_t *t,cb visit){
    if(t->root){
        int maxsize=t->n+1;//使用迴圈佇列浪費1個空間
        btree_node_t **queue=malloc(sizeof(btree_node_t *)*maxsize);
        btree_node_t *cur;
        int front=0;
        int rear=0;
        rear=(rear+1)%maxsize;
        queue[rear]=t->root;
        while(front!=rear){ //判斷佇列是否為空
            front=(front+1)%maxsize;
            cur=queue[front];//出隊
            visit(cur);
            if(cur->left){
                rear=(rear+1)%maxsize;
                queue[rear]=cur->left;    //入隊
            }
            if(cur->right){
                rear=(rear+1)%maxsize;
                queue[rear]=cur->right;//入隊
            }
        }
        free(queue);
    }
}

1.4Morris遍歷

  A Morris中序遍歷
  步驟如下: 初始化當前節點cur為root節點

  1. 若當前cur沒有左孩子,直接訪問當前結點,cur轉向右孩子
  2. 若cur有左孩子,先尋找到cur的前驅節點
  • 如果前驅節點右孩子為空,記錄前驅節點右孩子為當前結點,cur轉向左孩子
  • 如果前驅節點右孩子為當前節點,表明左孩子已被訪問,將前驅節點右孩子重設為空;直接訪問當前結點,cur轉向右孩子

  B Morris先序遍歷
  步驟如下: 初始化當前節點cur為root節點

  1. 若當前cur沒有左孩子,直接訪問當前結點,cur轉向右孩子
  2. 若cur有左孩子,先尋找到cur的前驅節點
  • 如果前驅節點右孩子為空,記錄前驅節點右孩子為當前結點,訪問當前節點,並將當前結點設定為已訪問過的節點,cur轉向左孩子
  • 如果前驅節點右孩子為當前節點,表明左孩子已被訪問,將前驅節點右孩子重設為空,cur轉向右孩子

  C Morris後序遍歷
  Morris後續遍歷稍微麻煩點:它必須保證在訪問某個當前節點時,左右子樹的所有左孩子必須先被訪問;而右孩子的輸出從底部往頂部逆向訪問就行

  步驟如下:設定一個虛擬根結點,記它的左孩子為root,即當前cur為該虛擬根結點

  1. 如果cur的左孩子為空,先記錄會被訪問的當前節點再轉向右孩子分支
  2. 如果cur的左孩子不為空,找到cur的前驅
  • 如果前驅的右孩子為空,建立線索化,記錄會被訪問的當前節點再轉向左孩子分支
  • 如果前驅的右孩子為當前節點,表示已經線索化,因而逆向輸出當前節點左孩子到該前驅節點路徑上的所有節點,轉向當前節點右孩子分支

  實現如下:

void bt_morris_inorder(btree_t *t, cb visit){
    if(t->root){
        btree_node_t *cur=t->root;
        btree_node_t *pre; //前驅線索
        while(cur){
            if(cur->left==NULL){
                visit(cur);
                pre=cur; //記錄已被訪問的前驅
                cur=cur->right;
            } else{
                /*先找到cur的前驅節點*/
                btree_node_t *tmp=cur->left;
                while(tmp->right&&tmp->right!=cur)
                    tmp=tmp->right;

                if(tmp->right==NULL){ //表明左子樹未訪問,先建立線索再訪問左子樹
                    tmp->right=cur; 
                    cur=cur->left;//沒有訪問,無需記錄pre指標
                } else { //左子樹已被訪問,則訪問當前節點,恢復二叉樹,遍歷右子樹
                    visit(cur);
                    tmp->right=NULL;
                    pre=cur;
                    cur=cur->right;
                }
            }
        }
    }
}

void bt_morris_preorder(btree_t *t, cb visit){
    if(t->root){
        btree_node_t *cur=t->root;
        btree_node_t *pre; //前驅線索
        while(cur){
            if(cur->left==NULL){
                visit(cur);
                pre=cur;
                cur=cur->right; //記錄直接前驅,轉向右孩子
            } else{
                btree_node_t *tmp=cur->left;
                while(tmp->right&&tmp->right!=cur)
                    tmp=tmp->right;

                if(tmp->right==NULL){ //表明右子樹未被訪問,訪問當前節點,更新線索,轉向左孩子
                    visit(cur); //僅這一行位置與中序不同
                    tmp->right=cur;
                    pre=cur;//標記當前節點被訪問過(這個與visit函式在同一個程式碼段內)
                    cur=cur->left;
                } else { //表明左子樹已被訪問,重置線索,轉向右孩子
                    tmp->right=NULL;
                    /*pre=cur; 不能有這句,因為cur早早被訪問*/
                    cur=cur->right;
                }
            }
        }
    }
}

void bt_morris_postorder(btree_t *t, cb visit){
    if(t->root){
        btree_node_t *rec=malloc(sizeof(btree_node_t));
        rec->left=t->root; //建立一個dummy結點,它的左孩子指向根結點
        btree_node_t *cur=rec;//從虛擬根結點開始遍歷
        btree_node_t *pre;
        while(cur){
            if(cur->left==NULL){
                pre=cur;//和前兩個morris遍歷不同,這種方法是先線索化後保證一側子樹都被訪問完後直接逆向輸出
                cur=cur->right;//一般是先訪問後再記錄被訪問的節點,這次相反先記錄將被訪問的節點後再訪問
            } else {
                btree_node_t *tmp=cur->left;
                while(tmp->right&&tmp->right!=cur)
                    tmp=tmp->right;

                if(tmp->right==NULL){ //還未線索化,未被訪問,先建立線索
                    tmp->right=cur;//保證下一次迴圈時回到後繼節點,此時已被線索化
                    pre=cur;//必須要有,先記錄
                    cur=cur->left;
                } else{ //已建立線索
                    reverse_branch(cur->left,tmp,visit);
                    pre->right=NULL;
                    pre=cur; //必須要有
                    cur=cur->right;
                }

            }
        }
        free(rec);
    }
}

2. 位示圖

2. 1位示圖相關操作

  一個unsigned int數能表示32個整數,整數從0開始,針對整數i:

  • i對應的無符號陣列下標索引slot: i/32
  • i對應的索引內容裡面的位元位數: i%32(1<<(i&0x1F)

  因而在位陣列中,必須提供三個重要操作:

  • 清除點陣圖某位: bm->bits[i/32] &= ~(1<<(i%32))
  • 設定點陣圖某位: bm->bits[i/32] |= (1<<(i%32))
  • 測試點陣圖某位: bm->bits[i/32] & (1<<(i%32))

  這裡採用位掩碼運算的方法完成這些操作,避免取模和除數運算,效率更高。

2. 2位示圖的資料結構定義

  位示圖的資料結構定義如下:

/*位示圖資料結構定義*/
typedef struct bitmap{
    unsigned int *bits;
    int size; //整數個數大小
} bitmap_t;

  位示圖函式的功能測試如下:

#include "bitmap.h"
#include <stdio.h>
#include <stdlib.h>

#define MAX 10000
#define RAD_NUM 100

int main(){
    /*接受大小為MAX的引數,建立一個點陣圖*/
    bitmap_t *bm=bitmap_alloc(MAX);

    unsigned int i;
    unsigned int arr[RAD_NUM];
    for(i=0;i< RAD_NUM;i++){
        arr[i]=RAD_NUM-i;
    }
    
    /**
     * 設定某些數在點陣圖的位表示為1
     * 生成RAD_NUM個不相同的隨機資料
     * 插入點陣圖中,對應位則設定為1
     */
    printf("原始無序的資料:\n");
    for(i=0;i< RAD_NUM;i++){
        unsigned int j=i+rand()%(RAD_NUM-i);
        int temp=arr[i];
        arr[i]=arr[j];
        arr[j]=temp;
        printf("%d ",arr[i]);
        if(i%10==0&&i!=0) printf("\n");
        bitmap_set(bm,arr[i]);
    }
    printf("\n");
    
    printf("使用點陣圖排序後的資料:\n");

    /*查詢該數是否在陣列中,很容易保證有序輸出*/
    for(i=0;i< MAX;i++){
        if(bitmap_query(bm,i)){
            printf("%d ",i);
            if(i%10==0&&i!=0) printf("\n");
        }
    }
    printf("\n");

    /*釋放點陣圖的記憶體*/
    bitmap_free(bm);
    return 0;
}

2. 3位示圖的核心實現

  核心的實現還是清除、設定和測試某個位,程式碼如下:

/*分配指定size大小的位示圖記憶體*/
bitmap_t *bitmap_alloc(int size){
    bitmap_t *bm=malloc(sizeof(bitmap_t));
    bm->bits=malloc(sizeof(bm->bits[0])*(size+BITS_LENGTH-1)/BITS_LENGTH); //計算合適的slot個數
    bm->size=size;
    memset(bm->bits,0,sizeof(bm->bits[0])*(size+BITS_LENGTH-1)/BITS_LENGTH);
    return bm;
}

/*釋放點陣圖記憶體*/
void bitmap_free(bitmap_t *bm){
    free(bm->bits);
    free(bm);
}

/**
 * 一個unsigned int數能表示32個整數,整數從0開始,針對整數i:
 * i對應的無符號陣列下標索引slot: i/32
 * i對應的索引內容裡面的位元位數: i%32(1<<(i&MASK)
 * 
 * 清除點陣圖某位: bm->bits[i/32] &= ~(1<<(i%32))
 * 設定點陣圖某位: bm->bits[i/32] |= (1<<(i%32))
 * 測試點陣圖某位: bm->bits[i/32] & (1<<(i%32))
 * 這裡採用位掩碼運算的方法完成這些操作,避免取模和除數運算,效率更高
 */

/**/
void bitmap_clear(bitmap_t *bm,unsigned i){
    if(i>=bm->size){
        printf("Invalid integer\n");
        return ;
    }
    bm->bits[i>>SHIFT] &= ~(1<<(i & MASK));
}

/*設定點陣圖中的某一位*/
void bitmap_set(bitmap_t *bm,unsigned i){
    if(i>=bm->size){
        printf("Invalid integer\n");
        return ;
    }
    bm->bits[i>>SHIFT] |= (1<<(i & MASK));
}

/*查詢點陣圖中的某一位,該位為1,返回true;否則返回false*/
bool bitmap_query(bitmap_t *bm,unsigned i){
    if(i>= bm->size)
        return false;
    if( (bm->bits[i>>SHIFT]) & (1<<(i & MASK)))
        return true;
    return false;
}

3. 並查集

  在某些應用中,要將n個不同的元素分成一組不相交的集合。在這個集合上,有兩個重要的操作: 找出給定的元素所屬的集合和合並兩個集合。再例如處理動態連通性問題,假定從輸入中讀取了一系列整數對,如果已知的資料可說明當前整數對是相連的,則忽略輸出,因而需要設計一個資料結構來儲存足夠的的整數對資訊,並用它們來判斷一對新物件是否相連。

  解決這種問題的資料結構稱為並查集。下面我們將介紹4種不同的演算法實現,它們均以物件標號為索引的id陣列來確定兩物件是否處在同一個集合中

3.1 quick-find和quick-union演算法

  A quick-find演算法策略

  find操作實現很快速,只需返回物件所在的集合標識;union操作即要遍歷整個陣列使得p所在的集合分量值都設定為q所在的集合分量值。

  • find操作:id[p]不等於id[q],所有和id[q]相等的元素的值變為id[p]的值。find操作只需訪問陣列一次.
  • union操作: 對於每一對輸入都要掃描整個陣列

  quick-find演算法的時間複雜度O(N^2),對於最終只能得到少數連通分量的一般應用都是平方級別的。

/*p所在的分量識別符號,0-N-1*/
int find(int p){
    return id[p];
}

public void union(int p,int q){
    int pId=find(p);
    int qId=find(q);
    if(pId==qId) return ; //已經在同一個分量中,無需採取任何行動

    for(int i=0;i<id.length;i++){
        if(id[i]==pId) id[i]=qId;
    }
    count--; //減少有觸點對應的id值是id[p]的分量
}

  B quick-union演算法策略

  union操作很快速,只需將某物件的集合分量標識指向另外一個物件的集合分量,通過父連結的方式表示了一片不相交集合的分量
  find操作則需要通過連結的形式找到一個表示它所在集合的標識(p=id[p])

  • find操作: 通過連結由一個觸點到另外一個觸點,知道有個連結它必定指向自身的觸點即id[x]=x,該觸點必然存在。因而find方法則是通過不斷連結遍歷到id[x]==x的時為止,該觸點為根觸點

  • union操作: 只需把一個根觸點連線到另一個分量的根觸點上,通過一條語句就使一個根結點變成另一個根結點的父結點,快速歸併了兩棵樹

  它是quick-find方法的一種改良(union操作總是線性級別的),但並不能保證在所有情況下都比quick-find演算法快。quick-union演算法的效率取決於樹中結點的深度,最壞情況下動態連通性問題只有一個分量,則quick-union的複雜度也是平方級別的。原因: 最壞情況下樹的深度為N,樹的深度無法得到保證。

public int find(int p){
    while(p!=id[p]) p=id[p];
    return p;
}

public void union(int p,int q){//將p和q所在的集合合併
    int pRoot=find(p);
    int qRoot=find(q);
    if(pRoot==qRoot) return ;
    id[pRoot]=qRoot;
    count--;
}

3.2 加權quick-union演算法和使用路徑壓縮演算法

  對於quick-union中出現的糟糕演算法,我們的改進辦法是: 記錄每一棵樹的大小並總是將較小的樹連線到較大的樹中,它能夠大大改進演算法的效率,這種稱為加權quick-union。

  A 加權quick-union演算法策略
  較小的樹根總是指向較大的樹根,使得它構造的樹高度遠遠小於未加權的所有版本的樹高度。這裡新增的額外陣列可以設計成記錄分量中結點的個數,也可設計成每個分量的高度。建議使用高度,這種被稱為按秩合併(union by rank)的啟發式策略,用秩表示結點高度的一個上界,在union操作中具有較小秩的根要指向具有較大秩的根。 

  這種加權quick-union演算法構造的森林中任意結點的深度最多為lgN,有了它就可以保證能夠在合理的時間範圍內解決實際中的大規模動態連通性問題,這比簡單的演算法快數百倍。

...
private int[] sz; //記錄每個分量的結點個數
...

public int find(int p){
    while(p!=id[p]) p=id[p];
    return p;
}

/*合併操作總是使小樹連線到大樹上*/
public void union(int p,int q){
    int pRoot=find(p);
    int qRoot=find(q);

    if(pRoot==qRoot) return;
    if(sz[pRoot]<sz[qRoot]) { 
        id[pRoot]=qRoot;
        sz[qRoot] +=sz[pRoot];
    } else{
        id[qRoot]=pRoot;
        sz[pRoot] +=sz[qRoot];
    }
    this.count--;
}

  B 帶路徑壓縮的加權quick-union演算法策略

  為find新增第一個迴圈,將查詢路徑上的每個結點都直接指向根結點,最後得到的結果幾乎是完全扁平化的樹。注意路徑壓縮並不改變結點的秩

  按演算法導論中的平攤分析方法,這種帶路徑壓縮的加權quick-union演算法中find、union操作的均攤成本控制在反Ackerman函式的範圍內(增長極慢的函式,結果始終控制在4以內的範圍),樹的高度一直很小,沒有任何昂貴的操作

public int find(int p){ 
        while(p!=id[p]){ 
            id[p]=id[id[p]];
            p=id[p];
        }
        return p;
}

public void union(int p,int q){
        int r1=find(p);
        int r2=find(q);
        
        if(r1==r2) return;
        if(rank[r1]< rank[r2]){
            id[r1]=r2;        
        } else if(rank[r1] > rank[r2]){
            id[r2]=r1;
        } else {
            id[r2]=r1;  //小根指向大根的根結點
            rank[r1]++; //相等時,產生新的高度,根的秩才加1(秩才上升)
        }
        count--;
}

  C 總結各種union-find演算法的效能特點
  一般地,帶路徑壓縮的加權quick-union演算法在解決實際問題時能在常數時間內完成每個操作,效能最好。建議實際應用中使用該演算法,它們的比較如下:

資料結構與演算法知識點總結(3)樹、圖與並查集

  具體完整實現如下:

#include "uf.h"
#include <stdio.h>
#include <stdlib.h>

/*分配並查集的記憶體並初始化,n-並查集陣列的大小*/
uf_t *uf_alloc(int n){
    uf_t *t=malloc(sizeof(uf_t));
    t->count=n;
    t->id=malloc(n*sizeof(int)); //分配一個連續的堆記憶體
    int i;
    for(i=0;i<n;i++){
        t->id[i]=-1;
    }
    return t;

}

/*釋放並查集記憶體*/
void uf_free(uf_t *t){
    free(t->id);
    free(t);

}

/*查詢包含元素p的樹的根-集合標號,帶路徑壓縮,並不改變秩*/
int uf_find(uf_t *t,int p){
    int cur=p;
    while(t->id[p] >=0) p=t->id[p]; //找到根結點
    while(cur !=p){ //遍歷查詢過程的所有結點,將其結點指向根結點
        int temp=t->id[cur];
        t->id[cur]=p;
        cur=temp; 
    }
    return p; 
}

/*合併包含兩元素p和q的樹集合*/
void uf_union(uf_t *t,int p,int q){
    int r1=uf_find(t,p);
    int r2=uf_find(t,q); //返回的是索引下標,而不是id值

    if(r1==r2) return; //已在同一集合內,無需再合併
    /*id值作為負數時,它的相反數表示該樹中結點的個數*/
    if(t->id[r1] > t->id[r2]){ //r2作為根
        t->id[r2] += t->id[r1];
        t->id[r1]=r2;
    } else {
        t->id[r1] += t->id[r2];
        t->id[r2]=r1;
    }
    t->count--;
}

/*返回並查集中不相交集合的分量個數*/
int uf_count(uf_t *t){
    return t->count;

}

/*返回並查集中包含p元素的集合大小*/
int uf_set_size(uf_t *t,int p){
    int root=uf_find(t,p);
    return -t->id[root];
}

3.3 應用舉例

  一般並查集在很多問題中應用廣泛,如:

  • Percolation(物理系統的滲透)
  • Dynamic connectivity(網路中的動態連通性問題)
  • Least Common Ancestors(最近公共祖先,Tarjan離線演算法)
  • Hoshen-Kopelman algorithm in physics
  • Kruskal's 最小生成樹
  • 有限自動機的等價性證明
  • Hinley-Milner polymorphic type inference
  • Morphological attribute openings and closings

3.3.1 最近公共祖先問題-LCA演算法和RMQ演算法

3.3.2 求最多連續數子集

  給一個整數陣列, 找到其中包含最多連續數的子集,比如給:15, 7, 12, 6, 14, 13, 9, 11,則返回: 5:[11, 12, 13, 14, 15] 。最簡單的方法是sort然後scan一遍,但是要o(nlgn). 有什麼O(n)的方法嗎?

4. 海量資料處理

  海量資料,一般是指資料量太大,所以導致要麼是無法在較短時間內迅速解決,要麼是資料太大,無法一次性裝入記憶體,從而導致傳統的操作無法實現。一般處理海量資料通常應用到如下資料結構: hash table、trie樹、堆、敗者樹、bitmap和bloom filter

  1. hash table通常可用作hash_map或者hash_set,它一般可以用來統計某字串出現的次數或者將大檔案中的元素對映到不同的小檔案中
  2. trie樹除了用於判斷字串的字首,它還可以統計或排序大量的字串(不限制於字串)。
  3. 堆一般是用於排序和統計topK的高效資料結構,相比於快速排序的劃分演算法計算topK,它無需一次性將資料讀入記憶體,特別適合於處理海量資料
  4. 敗者樹和二路歸併程式適合將若干有序陣列進行歸併排序,二路歸併比較次數一般為1次,而k路歸併的敗者樹只需要比較k的對數次
  5. Bitmap適合判斷某關鍵字是否在集合中,輸出重複元素,輸出出現幾次的數字,處理的檔案如果有海量的資料一般結合hash_map將大檔案拆分為若干個不同的小檔案,再依次處理
  6. Bloom filter是一種節省空間的隨機化資料結構,是Bitmap的擴充套件。它在能容忍低錯誤率的應用場合下,通過極少的錯誤換取了儲存空間的極大節省,在資料庫和網路應用中應用非常廣泛(具體細節參考後面bitmap的介紹)

相關文章