目录
前几天将STL中的内容进行了讲解,今天我们来讲解一下C++中基础数据结构,包括数组、链表、栈、队列、堆、树、图、哈希。今天我们主要讲其中的数组、链表、栈、队列、堆。觉得我讲的不错可以点个赞,当然评论支持一下就更好啦,下面我们开始讲解。
1.数组
1.1简介
一组相同类型的元素按照一定顺序排列而成的数据结构,可以使用下标访问数组中的元素。
1.2优点
- 效率高:数组在内存中是连续的一段存储空间,可以通过下标直接访问数组元素,因此速度快。
- 可以用于多维数组:数组可以定义为多维的,可以方便地表示和处理多维数据。
1.3缺点
- 内存固定:数组定义时需要指定大小,且大小是固定的,这就意味着在使用数组时,必须保证数组大小足够,否则可能导致内存溢出或者浪费。
- 不支持动态增加:数组的大小是固定的,无法动态增加,如果需要动态增加,需要重新定义一个新数组,并将原数组的元素复制到新数组中。
- 数组名不可修改:数组名是常量指针,不能修改,所以不能通过修改数组名来改变数组的大小或位置。
- 不支持复制和赋值:数组不能直接复制和赋值,需要使用循环语句逐一复制和赋值,这会降低效率。
其中第三点中我们谈到了一个新的知识,接受常量指针,下面我们对常量指针进行一个讲解。
1.3.1常量指针
1.3.1.1简介
常量指针是指指向常量对象的指针,也就是指针本身不能修改,指向的对象也不能通过指针来修改。
1.3.1.2定义
常量指针可以通过在指针类型前加const关键字来定义,例如:const int *p;表示p是一个指向常量整型的指针。
1.3.1.3赋值
常量指针可以被赋值,但是一旦指向了一个常量对象,就不能通过该指针来修改对象的值。
1.3.1.4解引用
常量指针可以通过解引用操作符*来访问指向的对象的值,但是不能通过解引用操作符来修改对象的值。
1.3.1.5作为函数参数
常量指针可以作为函数的参数进行传递,从而实现对常量对象的访问。
1.3.1.6和指向常量的指针相比
常量指针和指向常量的指针都指向常量对象,但是它们的区别在于前者指针本身是常量,后者指向的对象是常量。
既然说到了这里,下面就讲一下指向常量的指针。
1.3.2指向常量的指针
1.3.2.1简介
指向常量的指针是指指向常量对象的指针,也就是指针可以被修改,但是指向的对象不能通过该指针来修改。
1.3.2.2定义
指向常量的指针可以通过在指针类型前加const关键字来定义,例如:int const *p;表示p是一个指向常量整型的指针。
1.3.2.3赋值
指向常量的指针可以被赋值,但是一旦指向了一个常量对象,就不能通过该指针来修改对象的值。
1.3.2.4解引用
指向常量的指针可以通过解引用操作符*来访问指向的对象的值,但是不能通过解引用操作符来修改对象的值。
1.3.2.5作为函数参数
指向常量的指针可以作为函数的参数进行传递,从而实现对常量对象的访问。
1.4底层实现
底层实现通常是使用连续的内存空间来存储元素。具体来说,数组中的每个元素都被存储在相邻的内存地址中,可以通过下标来访问和修改数组中的元素。
在内存中,数组通常被存储在堆栈或堆内存中。如果数组是在函数中定义的,它通常会被存储在堆栈中。当函数调用结束时,数组将被自动销毁。如果数组是使用new操作符动态分配的,它通常会被存储在堆内存中。当不再需要数组时,需要使用delete操作符来手动释放数组所占用的内存空间。
在访问数组元素时,C++会将数组的首地址加上偏移量,得到需要访问的元素的地址。这个过程通常是由编译器自动完成的,因此访问数组元素的时间复杂度是O(1)。
需要注意的是,当数组元素的数量很大时,数组可能会占用过多的内存空间,因此需要考虑内存的限制。此外,当数组需要进行插入、删除等操作时,由于数组元素的存储是连续的,这些操作可能会导致数组的重新分配和元素的移动,因此可能会降低程序的性能。
2.链表
2.1简介
一组通过指针连接在一起的节点组成的数据结构,可以使用指针访问链表中的元素。
2.2优点
- 内存动态分配:链表节点可以在运行时动态分配内存,因此链表可以根据需要动态地增长或缩小。
- 插入和删除操作高效:链表的插入和删除操作非常高效,因为只需要改变指针的指向即可完成操作,而不需要像数组一样移动元素。
- 灵活性:链表可以轻松地实现各种数据结构,如栈、队列和哈希表等。
2.3缺点
- 随机访问低效:链表不支持随机访问,只能从头开始遍历到指定位置,因此访问元素的时间复杂度为O(n)。
- 空间开销较大:链表需要额外的指针来维护节点之间的关系,因此它需要更多的内存空间来存储相同数量的元素。
- 缓存不友好:由于链表节点在内存中不是连续存储的,所以访问链表节点时缓存命中率较低,这会影响程序的性能。
在上面的第三点当中提到了缓存命中率,我下面给大家讲解一下这个东西。
2.3.1缓存命中率
缓存命中率是指CPU在访问内存时,从高速缓存中读取数据的比例。高速缓存是CPU与内存之间的一个缓冲区域,它可以存储最近访问的数据。由于高速缓存的读取速度比内存快得多,因此如果CPU能够从高速缓存中读取数据,程序的性能会得到很大的提升。
然而,由于链表节点在内存中不是连续存储的,每次访问链表节点时,CPU需要从内存中读取一个节点的数据,并且还需要读取下一个节点的地址,这会导致高速缓存中的数据被频繁替换,缓存命中率会降低。相比之下,数组等连续存储的数据结构可以更好地利用高速缓存,因为它们的数据在内存中是连续存储的,CPU可以预取数据,提高数据读取的效率。
因此,当需要频繁访问数据时,链表的性能可能会受到缓存命中率的影响,尤其是当链表非常长时。在这种情况下,可以考虑使用数组等连续存储的数据结构来提高程序的性能。
2.4底层实现
底层实现通常是通过指针来实现的。具体来说,链表由若干个节点组成,每个节点都包含一个数据元素和一个指向下一个节点的指针。通过这种方式,链表中的节点可以在内存中分散存储,不需要像数组一样占用连续的内存空间。
在C++中,链表的节点通常是通过new操作符动态分配的,可以使用delete操作符手动释放节点所占用的内存空间。链表中的头节点通常是一个空节点,它的作用是指向链表中第一个数据元素的节点。
在访问链表节点时,C++需要通过指针来获取下一个节点的地址,因此访问链表元素的时间复杂度是O(n)。在插入、删除等操作时,链表可以通过修改指针来完成,因此这些操作的时间复杂度通常是O(1)。
需要注意的是,链表的底层实现需要手动管理内存,因此需要注意内存泄漏和空指针的问题。此外,由于链表中的节点是分散存储的,它可能会增加缓存不命中的概率,从而降低程序的性能。
3.栈
3.1简介
后进先出(LIFO)的数据结构,只允许在栈顶进行插入和删除操作,可以使用push、pop、top等操作。
3.2优点
- 快速插入和删除:栈采用后进先出(LIFO)的原则,所以插入和删除操作非常高效。
- 不需要动态内存分配:栈的大小在编译时就已经确定,因此不需要动态分配内存,这可以避免内存泄漏和内存碎片问题。
- 代码简洁:使用栈可以使代码更加简洁易懂,因为栈的操作非常直观。
3.3缺点
- 大小固定:栈的大小在编译时就已经确定,因此无法动态调整大小。如果需要存储的元素数量超出了栈的大小,就会出现栈溢出的问题。
- 存储元素类型受限:栈只能存储相同类型的元素,而且通常只能存储较小的数据类型,如整数、字符等。
- 访问元素不方便:由于栈是后进先出的数据结构,因此访问栈中的元素不如数组等数据结构方便,需要先弹出栈顶元素,才能访问下一个元素。
3.4底层实现
底层实现通常是使用堆栈内存来存储数据。堆栈内存是一种后进先出(LIFO)的内存分配方式,它的分配和释放由编译器自动完成。
在C++中,栈可以使用静态数组或动态数组来实现。静态数组是在编译时分配的,它的大小是固定的,不能动态调整。动态数组是使用new操作符在堆内存中动态分配的,它的大小可以根据需要动态调整。
在访问栈元素时,C++使用指针来跟踪栈顶元素的位置。栈顶元素通常是最后一个被添加到栈中的元素,因此访问栈元素的时间复杂度是O(1)。在入栈和出栈操作时,栈可以通过移动指针来实现,因此这些操作的时间复杂度通常是O(1)。
需要注意的是,栈内存通常是有限的,如果栈中存储的数据量过大,可能会导致栈溢出的问题。此外,栈内存中的数据只在当前函数的作用域中有效,当函数返回时,栈内存中的数据将被自动销毁。
3.4.1栈的容量
栈内存的大小是由操作系统和编译器决定的,它通常是有限的。在不同的操作系统和编译器下,栈内存的大小可能会有所不同。
在Windows操作系统下,默认的栈大小是1MB,在Linux和Unix操作系统下,栈大小通常是8MB或更大。需要注意的是,栈的大小限制也取决于系统的物理内存和虚拟内存的大小。如果栈内存超出了系统的物理内存或虚拟内存的大小限制,可能会导致栈溢出或程序崩溃的问题。
4.队列
4.1简介
先进先出(FIFO)的数据结构,只允许在队尾插入元素,在队头删除元素,可以使用push、pop、front、back等操作。
4.2优点
- 快速插入和删除:队列采用先进先出(FIFO)的原则,所以插入和删除操作非常高效。
- 不需要动态内存分配:队列的大小在编译时就已经确定,因此不需要动态分配内存,这可以避免内存泄漏和内存碎片问题。
- 代码简洁:使用队列可以使代码更加简洁易懂,因为队列的操作非常直观。
4.3缺点
- 大小固定:队列的大小在编译时就已经确定,因此无法动态调整大小。如果需要存储的元素数量超出了队列的大小,就会出现队列溢出的问题。
- 存储元素类型受限:队列只能存储相同类型的元素,而且通常只能存储较小的数据类型,如整数、字符等。
- 访问元素不方便:由于队列是先进先出的数据结构,因此访问队列中的元素不如数组等数据结构方便,需要先出队头元素,才能访问下一个元素。
4.4底层实现
底层实现通常是使用数组或链表来存储元素。具体来说,队列通常由一个数组或链表来存储元素,同时还包括一个头指针和一个尾指针。头指针指向队列中的第一个元素,尾指针指向队列中最后一个元素的下一个位置。
在C++中,队列的元素可以使用静态数组、动态数组或链表来实现。静态数组是在编译时分配的,它的大小是固定的,不能动态调整。动态数组是使用new操作符在堆内存中动态分配的,它的大小可以根据需要动态调整。链表是一种动态数据结构,它可以动态添加和删除节点。
在访问队列元素时,C++使用头指针和尾指针来跟踪队列中元素的位置。队列的插入、删除操作通常是在队列的尾部进行的,因此这些操作的时间复杂度是O(1)。访问队列中的元素通常是在队列的头部进行的,因此这些操作的时间复杂度也是O(1)。
需要注意的是,在使用数组实现队列时,当队列中的元素数量过多时,可能会导致数组空间不足的问题。
5.堆
5.1简介
一种特殊的树形数据结构,满足堆序性质,可以用来实现优先队列和排序等算法。
5.2优点
- 动态内存管理:堆可以动态地分配和释放内存,这使得堆可以根据需要动态增长或缩小。
- 存储元素类型不受限:堆可以存储任意类型和大小的数据,包括自定义类型和结构体等。
- 访问元素方便:堆可以通过下标或指针来访问元素,非常方便。
5.3缺点
- 内存管理复杂:由于堆可以动态分配和释放内存,因此它需要进行复杂的内存管理,包括内存分配、内存释放和内存碎片整理等。
- 内存泄漏和越界访问:堆的动态内存管理需要开发者自行管理,如果管理不当,容易出现内存泄漏和越界访问等问题。
- 访问元素速度较慢:堆的访问速度比栈和数组等数据结构要慢,因为它需要通过指针来访问元素,而指针的访问速度比直接访问数组元素要慢。
5.4底层实现
底层实现通常是使用动态内存分配来实现。动态内存分配是指在运行时根据需要动态分配内存空间,它的分配和释放由程序员手动控。
在C++中,使用new操作符可以在堆内存中动态分配内存空间,使用delete操作符可以手动释放内存空间。堆内存中的数据可以被多个函数共享,在函数调用结束后,数据仍然可以保持存在,直到手动释放内存空间。
在访问堆内存中的数据时,C++使用指针来访问堆内存中的数据。在使用堆内存时,需要注意内存泄漏和空指针的问题。内存泄漏是指在动态分配内存后,没有手动释放内存空间,导致内存无法被回收。空指针是指指向NULL地址的指针,它无法访问有效的内存空间,可能会导致程序崩溃或数据损坏的问题。
需要注意的是,在使用堆内存时,需要避免过度分配内存空间,以免浪费内存资源。同时,也需要避免内存碎片的问题,这可能会导致无法分配大块内存空间的问题。
5.4.1内存碎片
5.4.1.1简介
内存碎片是指堆内存中存在一些零散的未使用内存块,这些内存块虽然总和大小可能足够大,但是由于它们分散在堆内存的各个角落,无法满足大块连续内存的分配需求。这就可能导致无法分配大块内存空间的问题。
例如,假设程序先分配了若干个小块内存空间,然后逐渐释放这些内存空间。如果这些小块内存空间分散在堆内存的不同位置,就可能导致堆内存中出现一些较小的未使用内存块,这些未使用内存块可能无法满足大块内存的分配需求,导致分配失败。
5.4.1.2解决方法
- 使用内存池等技术来优化内存分配效率。内存池是指在程序启动时预先分配一定数量的内存空间,然后在程序运行期间重复使用这些内存空间。这样可以避免频繁地进行内存分配和释放,从而减少内存碎片的产生,提高内存分配效率。
- 使用内存合并等技术来解决内存碎片问题。内存合并是指在释放内存时,尝试将相邻的未使用内存块合并成一个大的未使用内存块,从而减少内存碎片的产生。