堆(Heap)与栈(Stack)是开发人员必须面对的两个概念,在理解这两个概念时,需要放到具体的场景下,因为不同场景下,堆与栈代表不同的含义。一般情况下,有两层含义:
(1)程序内存布局场景下,堆与栈表示两种内存管理方式;
(2)数据结构场景下,堆与栈表示两种常用的数据结构。
1.程序内存分区中的堆与栈
1.1 栈简介
栈由操作系统自动分配释放 ,用于存放函数的参数值、局部变量等,其操作方式类似于数据结构中的栈。参考如下代码:
int main() {
int b; //栈
char s[] = "abc"; //栈
char *p2; //栈
}
其中函数中定义的局部变量按照先后定义的顺序依次压入栈中,也就是说相邻变量的地址之间不会存在其它变量。栈的内存地址生长方向与堆相反,由高到底,所以后定义的变量地址低于先定义的变量,比如上面代码中变量 s 的地址小于变量 b 的地址,p2 地址小于 s 的地址。栈中存储的数据的生命周期随着函数的执行完成而结束。
栈为什么要由高地址向低地址扩展?
.
计算机内存分了代码段(.text段)、初始化的数据段(.data段)、未初始化的数据段(.bss段)、堆空间(heap)、栈空间(stack)和命令行参数和环境变量区域。
程序计数器(Program Counter,简称PC)的缺省指向0地址,计算机开机后从程序计数器指向的地址开始执行程序,每执行完一条指令后, 程序计数器自动加1。
这是来自apue里一张经典的c程序内存分布图,着重看一下heap和stack的内存分布。
因此很自然的,代码段从低地址区间开始加载,向高地址区间扩展;
heap从低地址向高地址扩展,做内存管理相对要简单些,为了避免栈空间和代码段冲突,最大利用地址空间,很自然的,我们会选择把栈底设置在高地址区间,然后让栈向下增长。
栈由高地址向低地址扩展的优点:
.
stack从高地址向低地址扩展,这样栈空间的起始位置就能确定下来,动态的调整栈空间大小也不需要移动栈内的数据。如果是从低地址到高地址的扩展,结尾的地址是固定的,如果要扩大或缩小,则需要移动整个栈的数据。
并且这样设计可以使得堆和栈能够充分利用空闲的地址空间。如果栈向上涨的话,我们就必须得指定栈和堆的一个严格分界线,但这个分界线怎么确定呢?平均分?但是有的程序使用的堆空间比较多,而有的程序使用的栈空间比较多。
所以就可能出现这种情况:一个程序因为栈溢出而崩溃的时候,其实它还有大量闲置的堆空间呢,但是我们却无法使用这些闲置的堆空间。所以呢,最好的办法就是让堆和栈一个向上涨,一个向下涨,这样它们就可以最大程度地共用这块剩余的地址空间,达到利用率的最大化!
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()函数(C语言)分配,但是栈的动态分配和堆是不同的,它的动态分配是由操作系统进行释放,无需我们手工实现。
栈动态分配,主调函数返回时由编译器(生成的类析构程序)来给你擦屁股。但alloca不具可移植性, 而且在没有传统堆栈的机器上很难实现。当它的返回值直接传入另一个函数时会带来问题,因为他分配在栈上,由于这些原因, alloca不宜使用在必须广泛移植的程序中, 一般使用的很少,不管它可能多么有用
严格来说栈是由类似c++中的析构函数来释放,而这个析构程序不是你写的,是编译器在编译期间自动生成的。程序运行时,就会在对象生命周期结束时执行。
栈一般是存储临时变量的地方,比如在函数中定义了
char a[5] = "";
那数组 a 就存储在了栈中,且当函数调用结束后,栈中的 a 也被自动释放了。但你不能这样子定义
int length = 5;
char str[lenght] = "";
一般的解决方法是使用malloc在堆中分配,但你需要手动free,
int length = 5;
char* str = (char*)malloc(lenght*sizeof(char)); //c
char* str = new char[lenght]; //c++
C提供了在栈中动态分配内存的函数alloca,用法和malloc一样,但不用free,因为他是在栈中分配空间,超出定义域后自动释放:
int lenght = 10;
char* str = (char*)alloca(length * sizeof(char)); //栈动态分配
(5)分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。
(6)存放内容不同。栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等。当主函数调用另外一个函数的时候,要对当前函数执行断点进行保存,需要使用栈来实现,首先入栈的是主函数下一条语句的地址,即扩展指针寄存器的内容(EIP),然后是当前栈帧的底部地址,即扩展基址指针寄存器内容(EBP),再然后是被调函数的实参等,一般情况下是按照从右向左的顺序入栈,之后是被调函数的局部变量,注意静态变量是存放在数据段或者BSS段,是不入栈的。出栈的顺序正好相反,最终栈顶指向主函数下一条语句的地址,主程序又从该地址开始执行。堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的。
从以上可以看到,堆和栈相比,由于大量malloc()/free()或new/delete的使用,容易造成大量的内存碎片,并且可能引发用户态和核心态的切换,效率较低。栈相比于堆,在程序中应用较为广泛,最常见的是函数的调用过程由栈来实现,函数返回地址、EBP、实参和局部变量都采用栈的方式存放。虽然栈有众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,主要还是用堆。
无论是堆还是栈,在内存使用时都要防止非法越界,越界导致的非法内存访问可能会摧毁程序的堆、栈数据,轻则导致程序运行处于不确定状态,获取不到预期结果,重则导致程序异常崩溃,这些都是我们编程时与内存打交道时应该注意的问题。
2.数据结构中的堆与栈
数据结构中,堆与栈是两个常见的数据结构,理解二者的定义、用法与区别,能够利用堆与栈解决很多实际问题。
2.1 栈简介
栈是一种运算受限的线性表,其限制是指只仅允许在表的一端进行插入和删除操作,这一端被称为栈顶(Top),相对地,把另一端称为栈底(Bottom)。把新元素放到栈顶元素的上面,使之成为新的栈顶元素称作进栈、入栈或压栈(Push);把栈顶元素删除,使其相邻的元素成为新的栈顶元素称作出栈或退栈(Pop)。这种受限的运算使栈拥有“先进后出”的特性(First In Last Out),简称 FILO。
栈分顺序栈和链式栈两种。栈是一种线性结构,所以可以使用数组或链表(单向链表、双向链表或循环链表)作为底层数据结构。使用数组实现的栈叫做顺序栈,使用链表实现的栈叫做链式栈,二者的区别是顺序栈中的元素地址连续,链式栈中的元素地址不连续。
(1)基于数组的栈——以数组为底层数据结构时,通常以数组头为栈底,数组头到数组尾为栈顶的生长方向
(2)基于单链表的栈——以链表为底层的数据结构时,以链表头为栈顶,便于节点的插入与删除,压栈产生的新节点将一直出现在链表的头部
栈的结构如下图所示:
栈的基本操作包括初始化、入栈(push)、出栈(pop)、判断栈是否为空、求栈的大小以及获取栈顶元素等。
下面以顺序栈为例,使用 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) {
//将4、5、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 %d\n",x);
}
//将栈顶元素出栈
ret=popSeqStack(seqStack,&x);
if(0==ret) {
printf("pop top element is %d\n",x);
}
}
return 0;
}
运行上面的程序,输出结果:
当前栈中的元素:
7 5 4
top element is 7
pop top element is 7
为什么栈的初始化时候栈顶指针要指向-1 ?
.
这种初始化是用于线性栈的,也就是用一个数组来组成栈,栈顶指针值为当前栈顶元素的下标值。规定空栈的时候指向-1,这样在有第一个元素入栈的时候栈顶指针就可以通过自加指向0元素,从而避免其它判断。
C++使用标准库的栈时, 应包含相关头文件,在栈中应包含头文件:#include< stack >
。定义:stack< int > s;
s.empty(); //如果栈为空则返回true, 否则返回false;
s.size(); //返回栈中元素的个数
s.top(); //返回栈顶元素, 但不删除该元素
s.pop(); //弹出栈顶元素, 但不返回其值
s.push(); //将元素压入栈顶
(1)基于数组的栈
#include <stack>
#include <iostream>
using namespace std;
int main()
{
stack<int> mystack;
int sum = 0;
for (int i = 0; i <= 10; i++){
mystack.push(i);
}
cout << "size is " << mystack.size() << endl;
while (!mystack.empty()){
cout << " " << mystack.top();
mystack.pop();
}
cout << endl;
system("pause");
return 0;
}
//size is 11
// 10 9 8 7 6 5 4 3 2 1 0
接下来我们自己写栈,这时就需要用到c++中的模板类(template)
#include<iostream>
#include<stdlib.h>
using namespace std;
#define MAXSIZE 0xffff
template<class type>
class my_stack
{
int top;
type* my_s;
int maxsize;
public:
my_stack():top(-1),maxsize(MAXSIZE)
{
my_s=new type[maxsize];
if(my_s==NULL)
{
cerr<<"动态存储分配失败!"<<endl;
exit(1);
}
}
my_stack(int size):top(-1),maxsize(size)
{
my_s=new type[maxsize];
if(my_s==NULL)
{
cerr<<"动态存储分配失败!"<<endl;
exit(1);
}
}
~my_stack()
{
delete[] my_s;
}
//是否为空
bool Empty();
//压栈
void Push(type tp);
//返回栈顶元素
type Top();
//出栈
void Pop();
//栈大小
int Size();
};
template<class type>
bool my_stack<type>::Empty()
{
if(top==-1){
return true;
}
else
return false;
}
template<class type>
type my_stack<type>::Top()
{
if(top!=-1)
{
return my_s[top];
}
else
{
cout<<"栈空\n";
exit(1);
}
}
template<class type>
void my_stack<type>::Push(type tp)
{
if(top+1<maxsize)
{
my_s[++top]=tp;
}
else
{
cout<<"栈满\n";
exit(1);
}
}
template<class type>
void my_stack<type>::Pop()
{
if(top>=0)
{
top--;
}
else
{
cout<<"栈空\n";
exit(1);
}
}
template<class type>
int my_stack<type>::Size()
{
return top+1;
}
然后就可以在另一个cpp文件中使用它了(记得include):
#include<iostream>
#include "my_stack.cpp"
using namespace std;
int main()
{
my_stack<int> stk;
for(int i=0;i<50;i++){
stk.Push(i);
}
cout<<"栈的大小:"<<stk.Size()<<endl;
while(!stk.Empty())
{
cout<<stk.Top()<<endl;
stk.Pop();
}
cout<<"栈的大小:"<<sizeof(stk)<<endl;
return 0;
}
(2)基于单链表的栈
#include <iostream>
#include <time.h>
#include <stack>
using namespace std;
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next() {}
};
class MyStack {
private:
ListNode *head;
int _size;
public:
MyStack() {
head = NULL;
_size = 0;
}
bool isEmpty() {
return _size == 0;
}
int size() {
return _size;
}
void push(int value) {
ListNode *cur = new ListNode(value);
if (head == NULL) {
head = cur;
} else {
cur->next = head;
head = cur;
}
_size++;
}
int pop() {
int ans;
if (head != NULL) {
ans = head->val;
ListNode *node = head;
head = head->next;
delete(node);
_size--;
}
return ans;
}
int top() {
return head != NULL ? head->val : -1;
}
};
void testStack() {
MyStack myStack;
stack<int> sta;
srand(time(0));
int testTime = 5000000;
int maxValue = 200000000;
cout << "测试开始:" << endl;
for (int i = 0; i < testTime; i++) {
if (myStack.isEmpty() != sta.empty()) {
cout << "isEmpty() function is wrong!" << endl;
break;
}
if (myStack.size() != sta.size()) {
cout << "size() function is wrong!" << endl;
}
double decide = (rand() % 101) / (double)101;
if (decide < 0.33) {
int num = rand()% maxValue;
myStack.push(num);
sta.push(num);
} else if (decide < 0.66) {
if (!myStack.isEmpty()) {
int num1 = myStack.pop();
int num2 = sta.top();
sta.pop();
if (num1 != num2) {
cout << "pop() function is wrong!" << endl;
break;
}
}
} else {
if (!myStack.isEmpty()) {
int num1 = myStack.top();
int num2 = sta.top();
if (num1 != num2) {
cout << "top() function is wrong!" << endl;
break;
}
}
}
}
if (myStack.size() != sta.size()) {
cout << "Oops! size() function is wrong!" << endl;
}
while (!myStack.isEmpty()) {
int num1 = myStack.pop();
int num2 = sta.top();
sta.pop();
if (num1 != num2) {
cout << "Oops! pop() function is wrong!" << endl;
break;
}
}
cout << "测试结束!" << endl;
}
int main() {
testStack();
return 0;
}
2.2 堆简介
2.2.1 堆的性质
堆是一种常用的树形结构,是一种特殊的完全二叉树,当且仅当满足所有节点的值总是不大于或不小于其父节点的值的完全二叉树被称之为堆。堆的这一特性称之为堆序性。因此,在一个堆中,根节点是最大(或最小)节点。如果根节点最小,称之为小顶堆(或小根堆),如果根节点最大,称之为大顶堆(或大根堆)。堆的左右孩子没有大小的顺序。下面是一个小顶堆示例:
堆的存储一般都用数组来存储堆,i节点的父节点下标就为( i – 1 ) / 2 。它的左右子节点下标分别为 2 ∗ i + 1 和 2 ∗ i + 2 。如第0个节点左右子节点下标分别为1和2。
2.2.2 堆的基本操作
(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);
}
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)。
因此,堆排序和快速排序在效率上是差不多的,但是堆排序一般优于快速排序的重要一点是数据的初始分布情况对堆排序的效率没有大的影响。