c/c++後臺開發必知堆與棧的區別

堆(Heap)與棧(Stack)是開發人員必須面對的兩個概念,在理解這兩個概念時,須要放到具體的場景下,由於不一樣場景下,堆與棧表明不一樣的含義。通常狀況下,有兩層含義:
(1)程序內存佈局場景下,堆與棧表示兩種內存管理方式;
(2)數據結構場景下,堆與棧表示兩種經常使用的數據結構。c++

1.程序內存分區中的堆與棧

1.1 棧簡介

棧由操做系統自動分配釋放 ,用於存放函數的參數值、局部變量等,其操做方式相似於數據結構中的棧。參考以下代碼:程序員

int main() {
    int b;                //棧
    char s[] = "abc";     //棧
    char *p2;            //棧
}

其中函數中定義的局部變量按照前後定義的順序依次壓入棧中,也就是說相鄰變量的地址之間不會存在其它變量。棧的內存地址生長方向與堆相反,由高到底,因此後定義的變量地址低於先定義的變量,好比上面代碼中變量 s 的地址小於變量 b 的地址,p2 地址小於 s 的地址。棧中存儲的數據的生命週期隨着函數的執行完成而結束。golang

1.2 堆簡介

堆由開發人員分配和釋放, 若開發人員不釋放,程序結束時由 OS 回收,分配方式相似於鏈表。參考以下代碼:算法

int main() {
    // C 中用 malloc() 函數申請
    char* p1 = (char *)malloc(10);
    cout<<(int*)p1<<endl;        //輸出:00000000003BA0C0
    
    // 用 free() 函數釋放
    free(p1);
   
    // C++ 中用 new 運算符申請
    char* p2 = new char[10];
    cout << (int*)p2 << endl;        //輸出:00000000003BA0C0
    
    // 用 delete 運算符釋放
    delete[] p2;
}

其中 p1 所指的 10 字節的內存空間與 p2 所指的 10 字節內存空間都是存在於堆。堆的內存地址生長方向與棧相反,由低到高,但須要注意的是,後申請的內存空間並不必定在先申請的內存空間的後面,即 p2 指向的地址並不必定大於 p1 所指向的內存地址,緣由是先申請的內存空間一旦被釋放,後申請的內存空間則會利用先前被釋放的內存,從而致使前後分配的內存空間在地址上不存在前後關係。堆中存儲的數據若未釋放,則其生命週期等同於程序的生命週期。編程

關於堆上內存空間的分配過程,首先應該知道操做系統有一個記錄空閒內存地址的鏈表,當系統收到程序的申請時,會遍歷該鏈表,尋找第一個空間大於所申請空間的堆節點,而後將該節點從空閒節點鏈表中刪除,並將該節點的空間分配給程序。另外,對於大多數系統,會在這塊內存空間中的首地址處記錄本次分配的大小,這樣,代碼中的delete語句才能正確地釋放本內存空間。因爲找到的堆節點的大小不必定正好等於申請的大小,系統會自動地將多餘的那部分從新放入空閒鏈表。數組

1.3 堆與棧區別

堆與棧其實是操做系統對進程佔用的內存空間的兩種管理方式,主要有以下幾種區別:
(1)管理方式不一樣。棧由操做系統自動分配釋放,無需咱們手動控制;堆的申請和釋放工做由程序員控制,容易產生內存泄漏;服務器

(2)空間大小不一樣。每一個進程擁有的棧的大小要遠遠小於堆的大小。理論上,程序員可申請的堆大小爲虛擬內存的大小,進程棧的大小 64bits 的 Windows 默認 1MB,64bits 的 Linux 默認 10MB;數據結構

(3)生長方向不一樣。堆的生長方向向上,內存地址由低到高;棧的生長方向向下,內存地址由高到低。架構

(4)分配方式不一樣。堆都是動態分配的,沒有靜態分配的堆。棧有2種分配方式:靜態分配和動態分配。靜態分配是由操做系統完成的,好比局部變量的分配。動態分配由alloca函數進行分配,可是棧的動態分配和堆是不一樣的,他的動態分配是由操做系統進行釋放,無需咱們手工實現。函數

(5)分配效率不一樣。棧由操做系統自動分配,會在硬件層級對棧提供支持:分配專門的寄存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高。堆則是由C/C++提供的庫函數或運算符來完成申請與管理,實現機制較爲複雜,頻繁的內存申請容易產生內存碎片。顯然,堆的效率比棧要低得多。

(6)存放內容不一樣。棧存放的內容,函數返回地址、相關參數、局部變量和寄存器內容等。當主函數調用另一個函數的時候,要對當前函數執行斷點進行保存,須要使用棧來實現,首先入棧的是主函數下一條語句的地址,即擴展指針寄存器的內容(EIP),而後是當前棧幀的底部地址,即擴展基址指針寄存器內容(EBP),再而後是被調函數的實參等,通常狀況下是按照從右向左的順序入棧,以後是被調函數的局部變量,注意靜態變量是存放在數據段或者BSS段,是不入棧的。出棧的順序正好相反,最終棧頂指向主函數下一條語句的地址,主程序又從該地址開始執行。堆,通常狀況堆頂使用一個字節的空間來存放堆的大小,而堆中具體存放內容是由程序員來填充的。

從以上能夠看到,堆和棧相比,因爲大量malloc()/free()或new/delete的使用,容易形成大量的內存碎片,而且可能引起用戶態和核心態的切換,效率較低。棧相比於堆,在程序中應用較爲普遍,最多見的是函數的調用過程由棧來實現,函數返回地址、EBP、實參和局部變量都採用棧的方式存放。雖然棧有衆多的好處,可是因爲和堆相比不是那麼靈活,有時候分配大量的內存空間,主要仍是用堆。

不管是堆仍是棧,在內存使用時都要防止非法越界,越界致使的非法內存訪問可能會摧毀程序的堆、棧數據,輕則致使程序運行處於不肯定狀態,獲取不到預期結果,重則致使程序異常崩潰,這些都是咱們編程時與內存打交道時應該注意的問題。

須要C/C++ Linux高級服務器架構師學習資料後臺加羣812855908(包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等)

c/c++後臺開發必知堆與棧的區別

2.數據結構中的堆與棧

數據結構中,堆與棧是兩個常見的數據結構,理解兩者的定義、用法與區別,可以利用堆與棧解決不少實際問題。

2.1 棧簡介

棧是一種運算受限的線性表,其限制是指只僅容許在表的一端進行插入和刪除操做,這一端被稱爲棧頂(Top),相對地,把另外一端稱爲棧底(Bottom)。把新元素放到棧頂元素的上面,使之成爲新的棧頂元素稱做進棧、入棧或壓棧(Push);把棧頂元素刪除,使其相鄰的元素成爲新的棧頂元素稱做出棧或退棧(Pop)。這種受限的運算使棧擁有「先進後出」的特性(First In Last Out),簡稱FILO。

棧分順序棧和鏈式棧兩種。棧是一種線性結構,因此可使用數組或鏈表(單向鏈表、雙向鏈表或循環鏈表)做爲底層數據結構。使用數組實現的棧叫作順序棧,使用鏈表實現的棧叫作鏈式棧,兩者的區別是順序棧中的元素地址連續,鏈式棧中的元素地址不連續。

棧的結構以下圖所示:

c/c++後臺開發必知堆與棧的區別

棧的基本操做包括初始化、判斷棧是否爲空、入棧、出棧以及獲取棧頂元素等。下面以順序棧爲例,使用 C++ 給出一個簡單的實現。

#include<stdio.h>
#include<malloc.h>

#define DataType int
#define MAXSIZE 1024
struct SeqStack {
    DataType data[MAXSIZE];
    int top;
};

//棧初始化,成功返回棧對象指針,失敗返回空指針NULL
SeqStack* initSeqStack() {
    SeqStack* s=(SeqStack*)malloc(sizeof(SeqStack));
    if(!s) {
        printf("空間不足n");
        return NULL;
    } else {
        s->top = -1;
        return s;
    }
}

//判斷棧是否爲空
bool isEmptySeqStack(SeqStack* s) {
    if (s->top == -1)
        return true;
    else
        return false;
}

//入棧,返回-1失敗,0成功
int pushSeqStack(SeqStack* s, DataType x) {
    if(s->top == MAXSIZE-1)
    {
        return -1;//棧滿不能入棧
    } else {
        s->top++;
        s->data[s->top] = x;
        return 0;
    }
}

//出棧,返回-1失敗,0成功
int popSeqStack(SeqStack* s, DataType* x) {
    if(isEmptySeqStack(s)) {
        return -1;//棧空不能出棧
    } else {
        *x = s->data[s->top];
        s->top--;
        return 0;
    }
}

//取棧頂元素,返回-1失敗,0成功
int topSeqStack(SeqStack* s,DataType* x) {
    if (isEmptySeqStack(s))
        return -1;    //棧空
    else {
        *x=s->data[s->top];
        return 0;
    }
}

//打印棧中元素
int printSeqStack(SeqStack* s) {
    int i;
    printf("當前棧中的元素:n");
    for (i = s->top; i >= 0; i--)
        printf("%4d",s->data[i]);
    printf("n");
    return 0;
}

//test
int main() {
    SeqStack* seqStack=initSeqStack();
    if(seqStack) {
        //將四、五、7分別入棧
        pushSeqStack(seqStack,4);
        pushSeqStack(seqStack,5);
        pushSeqStack(seqStack,7);
        
        //打印棧內全部元素
        printSeqStack(seqStack);
        
        //獲取棧頂元素
        DataType x=0;
        int ret=topSeqStack(seqStack,&x);
        if(0==ret) {
            printf("top element is %dn",x);
        }
        
        //將棧頂元素出棧
        ret=popSeqStack(seqStack,&x);
        if(0==ret) {
            printf("pop top element is %dn",x);
        }
    }
    return 0;
}

運行上面的程序,輸出結果:

當前棧中的元素:
   7   5   4
top element is 7
pop top element is 7

2.2 堆簡介

2.2.1 堆的性質

堆是一種經常使用的樹形結構,是一種特殊的徹底二叉樹,當且僅當知足全部節點的值老是不大於或不小於其父節點的值的徹底二叉樹被稱之爲堆。堆的這一特性稱之爲堆序性。所以,在一個堆中,根節點是最大(或最小)節點。若是根節點最小,稱之爲小頂堆(或小根堆),若是根節點最大,稱之爲大頂堆(或大根堆)。堆的左右孩子沒有大小的順序。下面是一個小頂堆示例:

c/c++後臺開發必知堆與棧的區別

堆的存儲通常都用數組來存儲堆,i節點的父節點下標就爲( i – 1 ) / 2 (i – 1) / 2(_i_–1)/2。它的左右子節點下標分別爲 2 ∗ i + 1 2 i + 12∗_i_+1 和 2 ∗ i + 2 2 i + 22∗_i_+2。如第0個節點左右子節點下標分別爲1和2。

c/c++後臺開發必知堆與棧的區別

2.2.2 堆的基本操做

(1)創建
以最小堆爲例,若是以數組存儲元素時,一個數組具備對應的樹表示形式,但樹並不知足堆的條件,須要從新排列元素,能夠創建「堆化」的樹。

c/c++後臺開發必知堆與棧的區別

(2)插入
將一個新元素插入到表尾,即數組末尾時,若是新構成的二叉樹不知足堆的性質,須要從新排列元素,下圖演示了插入15時,堆的調整。

c/c++後臺開發必知堆與棧的區別

(3)刪除。
堆排序中,刪除一個元素老是發生在堆頂,由於堆頂的元素是最小的(小頂堆中)。表中最後一個元素用來填補空缺位置,結果樹被更新以知足堆條件。

c/c++後臺開發必知堆與棧的區別

2.2.3 堆操做實現

(1)插入代碼實現
每次插入都是將新數據放在數組最後。能夠發現從這個新數據的父節點到根節點必然爲一個有序的數列,如今的任務是將這個新數據插入到這個有序數據中,這就相似於直接插入排序中將一個數據併入到有序區間中,這是節點「上浮」調整。不難寫出插入一個新數據時堆的調整代碼:

//新加入i節點,其父節點爲(i-1)/2
//參數:a:數組,i:新插入元素在數組中的下標 
void minHeapFixUp(int a[], int i) {  
    int j, temp;  
    temp = a[i];  
    j = (i-1)/2;      //父節點 
    while (j >= 0 && i != 0) {  
        if (a[j] <= temp)//若是父節點不大於新插入的元素,中止尋找 
            break;  
        a[i]=a[j];            //把較大的子節點往下移動,替換它的子節點 
        i = j;  
        j = (i-1)/2;  
    }  
    a[i] = temp;  
}

所以,插入數據到最小堆時:

//在最小堆中加入新的數據data 
//a:數組,index:插入的下標,
void minHeapAddNumber(int a[], int index, int data) {  
    a[index] = data;  
    minHeapFixUp(a, index);  
}

(2)刪除代碼實現
按照堆刪除的說明,堆中每次都只能刪除第0個數據。爲了便於重建堆,實際的操做是將數組最後一個數據與根節點交換,而後再從根節點開始進行一次從上向下的調整。

調整時先在左右兒子節點中找最小的,若是父節點不大於這個最小的子節點說明不須要調整了,反之將最小的子節點換到父節點的位置。此時父節點實際上並不須要換到最小子節點的位置,由於這不是父節點的最終位置。但邏輯上父節點替換了最小的子節點,而後再考慮父節點對後面的節點的影響。堆元素的刪除致使的堆調整,其整個過程就是將根節點進行「下沉」處理。下面給出代碼:

//a爲數組,len爲節點總數;從index節點開始調整,index從0開始計算index其子節點爲 2*index+1, 2*index+2;len/2-1爲最後一個非葉子節點 
void minHeapFixDown(int a[],int len,int index) {
    if(index>(len/2-1))//index爲葉子節點不用調整
        return;
    int tmp=a[index];
    lastIndex=index;
    while(index<=len/2-1)        //當下沉到葉子節點時,就不用調整了
    { 
        // 若是左子節點小於待調整節點
        if(a[2*index+1]<tmp) {
            lastIndex = 2*index+1;
        }
        //若是存在右子節點且小於左子節點和待調整節點
        if(2*index+2<len && a[2*index+2]<a[2*index+1]&& a[2*index+2]<tmp) {
            lastIndex=2*index+2;
        }
        //若是左右子節點有一個小於待調整節點,選擇最小子節點進行上浮
        if(lastIndex!=index) {
            a[index]=a[lastIndex];
            index=lastIndex;
        } else break;             //不然待調整節點不用下沉調整
    }
    a[lastIndex]=tmp;           //將待調整節點放到最後的位置
}

根據堆刪除的下沉思想,能夠有不一樣版本的代碼實現,以上是和孫凜同窗一塊兒討論出的一個版本,在這裏感謝他的參與,讀者可另行給出。我的體會,這裏建議你們根據對堆調整過程的理解,寫出本身的代碼,切勿看示例代碼去理解算法,而是理解算法思想寫出代碼,不然很快就會忘記。

(3)建堆
有了堆的插入和刪除後,再考慮下如何對一個數據進行堆化操做。要一個一個的從數組中取出數據來創建堆吧,不用!先看一個數組,以下圖:

c/c++後臺開發必知堆與棧的區別

很明顯,對葉子節點來講,能夠認爲它已是一個合法的堆了即20,60, 65, 4, 49都分別是一個合法的堆。只要從A[4]=50開始向下調整就能夠了。而後再取A[3]=30,A[2] = 17,A[1] = 12,A[0] = 9分別做一次向下調整操做就能夠了。下圖展現了這些步驟:

c/c++後臺開發必知堆與棧的區別

寫出堆化數組的代碼:

//創建最小堆
//a:數組,n:數組長度
void makeMinHeap(int a[], int n) {  
    for (int i = n/2-1; i >= 0; i--)  
        minHeapFixDown(a, i, n);  
}

2.2.4 堆的具體應用——堆排序

堆排序(Heapsort)是堆的一個經典應用,有了上面對堆的瞭解,不難實現堆排序。因爲堆也是用數組來存儲的,故對數組進行堆化後,第一次將A[0]與A[n - 1]交換,再對A[0…n-2]從新恢復堆。第二次將A[0]與A[n – 2]交換,再對A[0…n - 3]從新恢復堆,重複這樣的操做直到A[0]與A[1]交換。因爲每次都是將最小的數據併入到後面的有序區間,故操做完成後整個數組就有序了。有點相似於直接選擇排序。

所以,完成堆排序並無用到前面說明的插入操做,只用到了建堆和節點向下調整的操做,堆排序的操做以下:

//array:待排序數組,len:數組長度
void heapSort(int array[],int len) {
    //建堆
    makeMinHeap(array,len); 
    
    //最後一個葉子節點和根節點交換,並進行堆調整,交換次數爲len-1次
    for(int i=len-1;i>0;--i) {
        //最後一個葉子節點交換
        array[i]=array[i]+array[0];
        array[0]=array[i]-array[0];
        array[i]=array[i]-array[0];
        
        //堆調整
        minHeapFixDown(array, 0, len-i-1);  
    }
}

(1)穩定性。堆排序是不穩定排序。

(2)堆排序性能分析。因爲每次從新恢復堆的時間複雜度爲O(logN),共N-1次堆調整操做,再加上前面創建堆時N/2次向下調整,每次調整時間複雜度也爲O(logN)。兩次操做時間複雜度相加仍是O(NlogN),故堆排序的時間複雜度爲O(NlogN)。

最壞狀況:若是待排序數組是有序的,仍然須要O(NlogN)複雜度的比較操做,只是少了移動的操做;

最好狀況:若是待排序數組是逆序的,不只須要O(NlogN)複雜度的比較操做,並且須要O(NlogN)複雜度的交換操做,總的時間複雜度仍是O(NlogN)。

所以,堆排序和快速排序在效率上是差很少的,可是堆排序通常優於快速排序的重要一點是數據的初始分佈狀況對堆排序的效率沒有大的影響。

相關文章
相關標籤/搜索