堆与栈的区别

OVERVIEW

  • 栈与堆的区别
      • 一、程序内存分区中的堆与栈
        • 1.栈
        • 2.堆
        • 3.堆&栈
      • 二、数据结构中的堆与栈
        • 1.栈
        • 2.堆
      • 三、堆的深入
        • 1.堆插入
        • 2.堆删除:
        • 3.堆建立:
        • 4.堆排序:
        • 5.堆实现优先队列:
        • 6.堆与栈的相关练习

栈与堆的区别


自整理,以下为主要参考文章:

  • 堆与栈的对比:一文读懂堆与栈的区别_堆和栈的区别_恋喵大鲤鱼的博客-CSDN博客
  • 堆与栈的理解:什么是堆? 什么是栈? - 知乎 (zhihu.com)
  • 用堆实现优先队列:用堆实现优先级队列(Priority Queue)_优先队列用堆实现_t_wu的博客-CSDN博客
  • 用堆实现优先队列:什么是优先队列 - 知乎 (zhihu.com)
  • 深入底层:内存中的堆和栈到底是什么 | 洛斯里克的大书库 (kimihe.com)
  • Python版本:Codify (wzy-codify.com)

在理解堆与栈概念时需要放到具体的场景下,因为不同场景下堆与栈代表不同的含义:

  1. 场景1:程序内存布局场景下,堆与栈表示两种内存管理方式。
  2. 场景2:数据结构场景下,堆与栈表示两种常用的数据结构。

一、程序内存分区中的堆与栈

堆与栈的空间都是分配在内存上,

堆与栈的区别_第1张图片

1.栈

  1. 其中函数中定义的局部变量按照先后定义的顺序依次压入栈中,也就是说相邻变量的地址之间不会存在其它变量。
  2. 栈的内存地址生长方向与堆相反,由高到低,所以后定义的变量地址低于先定义的变量。
  3. 栈中存储的数据的生命周期随着函数的执行完成而结束。

2.堆

  1. 但需要注意的是,后申请的内存空间并不一定在先申请的内存空间的后面,原因是先申请的内存空间一旦被释放,后申请的内存空间则会利用先前被释放的内存,从而导致先后分配的内存空间在地址上不存在先后关系。
  2. 堆的内存地址生长方向与栈相反,由低到高
  3. 堆中存储的数据若未释放,则其生命周期等同于程序的生命周期。

注:关于堆上内存空间的分配过程,首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆节点,然后将该节点从空闲节点链表中删除,并将该节点的空间分配给程序。另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确地释放本内存空间。由于找到的堆节点的大小不一定正好等于申请的大小,系统会自动地将多余的那部分重新放入空闲链表。

3.堆&栈

堆与栈实际上是操作系统对进程占用的内存空间的两种管理方式,主要有如下几种区别:

不同点
1.管理方式不同 栈由操作系统自动分配释放,无需手动控制; 堆的申请和释放工作由开发者控制,容易产生内存泄漏;
2.空间大小不同 每个进程拥有的栈大小要远远小于堆大小 理论上进程可申请的堆大小为虚拟内存大小,进程栈的大小 64bits 的 Windows 默认 1MB,64bits 的 Linux 默认 10MB;
3.生长方向不同 堆的生长方向向上,内存地址由低到高; 栈的生长方向向下,内存地址由高到低
4.分配方式不同 堆都是动态分配的,没有静态分配的堆 栈有 2 种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由alloca()函数分配,但是栈的动态分配和堆是不同的,它的动态分配是由操作系统进行释放,无需手动实现。
5.分配效率不同 栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高 堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多
6.存放内容不同 栈存放的内容有,函数返回地址、相关参数、局部变量和寄存器内容等。当主函数调用另外一个函数的时候,要对当前函数执行断点进行保存,这需要使用栈来实现。
首先入栈的是主函数下一条语句的地址,即扩展指针寄存器的内容(EIP),然后是当前栈帧的底部地址,即扩展基址指针寄存器内容(EBP),再然后是被调函数的实参等,一般情况下是按照从右向左的顺序入栈,之后是被调函数的局部变量,注意静态变量是存放在数据段或者BSS段,是不入栈的。
出栈的顺序正好相反,最终栈顶指向主函数下一条语句的地址,主程序又从该地址开始执行。
堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的。
7.产生内存碎片 栈与数据结构中的栈原理相同,在弹出一个元素之前上个元素已经弹出了,不会产生内存碎片 而不停的调用malloc/new、free/delete则会产生很多的内存碎片。
8.线程安全 栈内存是为线程留出的临时空间,每一个线程都有其固定的栈空间,栈空间存储的数据只能被当前线程访问(线程安全) 堆内存大小不固定、空间由开发者动态分配可以动态扩容,堆内存可以被一个进程内的所有线程访问(线程不安全),
9.访问权限 不同函数之间的栈数据不能够共享,在启动线程的时候实际上是启动函数,其具有自己的栈,线程之间的栈数据时不能够共享的 堆属于在进程上的堆,只要在进程上application内,所有的线程都可以访问堆上的数据。堆在不同的语言下管理释放的方式不同:垃圾回收机制、free、

从以上可以看到,

  • 堆由于大量malloc()/free()或new/delete的使用,容易造成大量的内存碎片,并且可能引发用户态和核心态的切换,效率较低。
  • 栈在程序中应用较为广泛,最常见的是函数的调用过程由栈来实现,函数返回地址、EBP、实参和局部变量都采用栈的方式存放。虽然栈有众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,主要还是用堆。
  • 无论是堆还是栈,在内存使用时都要防止非法越界,越界导致的非法内存访问可能会摧毁程序的堆、栈数据,轻则导致程序运行处于不确定状态,获取不到预期结果,重则导致程序异常崩溃,这些都是我们编程时与内存打交道时应该注意的问题。

二、数据结构中的堆与栈

1.栈

栈:栈是一种运算受限的线性表,其限制是指只仅允许在表的一端进行插入和删除操作。具有FILO先进后出的特性。

2.堆

堆:堆是一种常用的树形结构,是一种特殊的完全二叉树,满足所有节点的值不大于或不小于其父节点的值的完全二叉树被称之为堆。

堆这一特性称为堆序性,因此在一个堆中根节点是最大 or 最小的节点。如果根节点最小称之为小根堆,根节点最大称之为大根堆。堆的左右孩子没有大小的顺序。

堆与栈的区别_第2张图片

  • 堆的存储一般都用数组来存储堆,
  • i节点的父节点下标就为(i–1)/2
  • 它的左右子节点下标分别为 2∗i+12∗i+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个数据。为了便于重建堆,实际的操作是将数组最后一个数据与根节点交换,然后再从根节点开始进行一次从上向下的调整。

调整时先在左右儿子节点中找最小的,如果父节点不大于这个最小的子节点说明不需要调整了,反之将最小的子节点换到父节点的位置。此时父节点实际上并不需要换到最小子节点的位置,因为这不是父节点的最终位置。但逻辑上父节点替换了最小的子节点,然后再考虑父节点对后面的节点的影响。堆元素的删除导致的堆调整,其整个过程就是将根节点进行“下沉”处理。

// minHeapFixDown 小顶堆结点下沉操作。
// a 为数组,len 为结点总数;从 index 结点开始调整,index 从 0 开始计算 index 其子结点为 2*index+1, 2*index+2;len/2-1 为最后一个非叶子结点。
void minHeapFixDown(int a[],int len,int index) {
	// index 为叶子节点不用调整。
	if(index>(len/2-1)) 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.堆建立:

注:有了堆的插入和删除后,再考虑下如何对一个数据进行堆化操作。

以数组存储元素时具有对应的树表示形式,但树有可能并不满足堆的性质,需要重新排列元素才能建立堆化的树。

  1. 单结点的二叉树是堆(无需调整树中的叶子结点)
  2. 在完全二叉树中所有以叶子结点为根的子树是堆(无需调整)
  3. 堆的调整只需要从最后一个非叶子结点开始即可
  4. 需要依次将以序号为n/2、n/2-1、…1的结点为根的子树均调整为堆即可(筛选需从第n/2个元素开始

堆与栈的区别_第3张图片

堆与栈的区别_第4张图片

将初始无序序列调整成小根堆(筛选过程),可以利用以算法实现:

// makeMinHeap 建立最小堆。
// a:数组,n:数组长度。
void makeMinHeap(int a[], int n) {
    for (int i = n/2-1; i >= 0; i--) {
        minHeapFixDown(a, i, n);
    }
}

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)
  3. 最坏情况:如果待排序数组是有序的,仍然需要O(NlogN)复杂度的比较操作,只是少了移动的操作;
  4. 最好情况:如果待排序数组是逆序的,不仅需要O(NlogN)复杂度的比较操作,而且需要O(NlogN)复杂度的交换操作,总的时间复杂度还是O(NlogN)。
  5. 因此,堆排序和快速排序在效率上是差不多的,但是堆排序一般优于快速排序的重要一点是数据的初始分布情况对堆排序的效率没有大的影响。

5.堆实现优先队列:

待完善

6.堆与栈的相关练习

  1. Leetcode232.用栈实现队列:https://leetcode.cn/problems/implement-queue-using-stacks/description/
  2. Leetcode225.用队列实现栈:https://leetcode.cn/problems/implement-stack-using-queues/
  3. 剑指offer09.用两个栈实现队列:https://leetcode.cn/problems/yong-liang-ge-zhan-shi-xian-dui-lie-lcof/
  4. Leetcode1441.用栈构建数组:https://leetcode.cn/problems/build-an-array-with-stack-operations/

你可能感兴趣的:(数据结构,数据结构)