堆(Heap)与栈(Stack)是开发人员必须面对的两个概念,在理解这两个概念时,须要放到具体的场景下,由于不一样场景下,堆与栈表明不一样的含义。通常状况下,有两层含义:
(1)程序内存布局场景下,堆与栈表示两种内存管理方式;
(2)数据结构场景下,堆与栈表示两种经常使用的数据结构。c++
栈由操做系统自动分配释放 ,用于存放函数的参数值、局部变量等,其操做方式相似于数据结构中的栈。参考以下代码:程序员
int main() { int b; //栈 char s[] = "abc"; //栈 char *p2; //栈 }
其中函数中定义的局部变量按照前后定义的顺序依次压入栈中,也就是说相邻变量的地址之间不会存在其它变量。栈的内存地址生长方向与堆相反,由高到底,因此后定义的变量地址低于先定义的变量,好比上面代码中变量 s 的地址小于变量 b 的地址,p2 地址小于 s 的地址。栈中存储的数据的生命周期随着函数的执行完成而结束。golang
堆由开发人员分配和释放, 若开发人员不释放,程序结束时由 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)管理方式不一样。栈由操做系统自动分配释放,无需咱们手动控制;堆的申请和释放工做由程序员控制,容易产生内存泄漏;服务器
(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等)
数据结构中,堆与栈是两个常见的数据结构,理解两者的定义、用法与区别,可以利用堆与栈解决不少实际问题。
栈是一种运算受限的线性表,其限制是指只仅容许在表的一端进行插入和删除操做,这一端被称为栈顶(Top),相对地,把另外一端称为栈底(Bottom)。把新元素放到栈顶元素的上面,使之成为新的栈顶元素称做进栈、入栈或压栈(Push);把栈顶元素删除,使其相邻的元素成为新的栈顶元素称做出栈或退栈(Pop)。这种受限的运算使栈拥有“先进后出”的特性(First In Last Out),简称FILO。
栈分顺序栈和链式栈两种。栈是一种线性结构,因此可使用数组或链表(单向链表、双向链表或循环链表)做为底层数据结构。使用数组实现的栈叫作顺序栈,使用链表实现的栈叫作链式栈,两者的区别是顺序栈中的元素地址连续,链式栈中的元素地址不连续。
栈的结构以下图所示:
栈的基本操做包括初始化、判断栈是否为空、入栈、出栈以及获取栈顶元素等。下面以顺序栈为例,使用 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
堆是一种经常使用的树形结构,是一种特殊的彻底二叉树,当且仅当知足全部节点的值老是不大于或不小于其父节点的值的彻底二叉树被称之为堆。堆的这一特性称之为堆序性。所以,在一个堆中,根节点是最大(或最小)节点。若是根节点最小,称之为小顶堆(或小根堆),若是根节点最大,称之为大顶堆(或大根堆)。堆的左右孩子没有大小的顺序。下面是一个小顶堆示例:
堆的存储通常都用数组来存储堆,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。
(1)创建
以最小堆为例,若是以数组存储元素时,一个数组具备对应的树表示形式,但树并不知足堆的条件,须要从新排列元素,能够创建“堆化”的树。
(2)插入
将一个新元素插入到表尾,即数组末尾时,若是新构成的二叉树不知足堆的性质,须要从新排列元素,下图演示了插入15时,堆的调整。
(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)建堆
有了堆的插入和删除后,再考虑下如何对一个数据进行堆化操做。要一个一个的从数组中取出数据来创建堆吧,不用!先看一个数组,以下图:
很明显,对叶子节点来讲,能够认为它已是一个合法的堆了即20,60, 65, 4, 49都分别是一个合法的堆。只要从A[4]=50开始向下调整就能够了。而后再取A[3]=30,A[2] = 17,A[1] = 12,A[0] = 9分别做一次向下调整操做就能够了。下图展现了这些步骤:
写出堆化数组的代码:
//创建最小堆 //a:数组,n:数组长度 void makeMinHeap(int a[], int n) { for (int i = n/2-1; i >= 0; i--) minHeapFixDown(a, i, n); }
堆排序(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)。
所以,堆排序和快速排序在效率上是差很少的,可是堆排序通常优于快速排序的重要一点是数据的初始分布状况对堆排序的效率没有大的影响。