返回首页返回书籍《Hello 算法》
目录

目录 · CONTENTS

  1. 01序
  2. 02前言
  3. 03关于本书
  4. 04如何使用本书
  5. 05小结
  6. 06初识算法
  7. 07算法无处不在
  8. 08算法是什么
  9. 09小结
  10. 10复杂度分析
  11. 11算法效率评估
  12. 12迭代与递归
  13. 13时间复杂度
  14. 14空间复杂度
  15. 15小结
  16. 16练习
  17. 17数据结构
  18. 18数据结构分类
  19. 19基本数据类型
  20. 20数字编码
  21. 21字符编码
  22. 22小结
  23. 23练习
  24. 24数组与链表
  25. 25数组
  26. 26链表
  27. 27列表
  28. 28内存与缓存
  29. 29小结
  30. 30练习
  31. 31栈与队列
  32. 32栈
  33. 33队列
  34. 34双向队列
  35. 35小结
  36. 36练习
  37. 37哈希表
  38. 38哈希表
  39. 39哈希冲突
  40. 40哈希算法
  41. 41小结
  42. 42练习
  43. 43树
  44. 44二叉树
  45. 45二叉树遍历
  46. 46二叉树数组表示
  47. 47二叉搜索树
  48. 48AVL 树
  49. 49小结
  50. 50练习
  51. 51堆
  52. 52堆
  53. 53建堆操作
  54. 54Top-k 问题
  55. 55小结
  56. 56练习
  57. 57图
  58. 58图
  59. 59图的基础操作
  60. 60图的遍历
  61. 61小结
  62. 62练习
  63. 63搜索
  64. 64二分查找
  65. 65二分查找插入点
  66. 66二分查找边界
  67. 67哈希优化策略
  68. 68重识搜索算法
  69. 69小结
  70. 70练习
  71. 71排序
  72. 72排序算法
  73. 73选择排序
  74. 74冒泡排序
  75. 75插入排序
  76. 76快速排序
  77. 77归并排序
  78. 78堆排序
  79. 79桶排序
  80. 80计数排序
  81. 81基数排序
  82. 82小结
  83. 83练习
  84. 84分治
  85. 85分治算法
  86. 86分治搜索策略
  87. 87构建二叉树问题
  88. 88汉诺塔问题
  89. 89小结
  90. 90练习
  91. 91回溯
  92. 92回溯算法
  93. 93全排列问题
  94. 94子集和问题
  95. 95n 皇后问题
  96. 96小结
  97. 97练习
  98. 98动态规划
  99. 99初探动态规划
  100. 100动态规划问题特性
  101. 101动态规划解题思路
  102. 1020-1 背包问题
  103. 103完全背包问题
  104. 104编辑距离问题
  105. 105小结
  106. 106练习
  107. 107贪心
  108. 108贪心算法
  109. 109分数背包问题
  110. 110最大容量问题
  111. 111最大切分乘积问题
  112. 112小结
  113. 113练习
  114. 114附录
  115. 115编程环境安装
  116. 116一起参与创作
  117. 117术语表
  118. 118参考文献
  119. 119纸质书
第 29 / 119 节

第 29 节 · SECTION 29

小结

重点回顾

  • 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和分散空间存储。两者的特点呈现出互补的特性。
  • 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
  • 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、环形链表、双向链表。
  • 列表是一种支持增删查改的元素有序集合,通常基于动态数组实现。它保留了数组的优势,同时可以灵活调整长度。
  • 列表的出现大幅提高了数组的实用性,但可能导致部分内存空间浪费。
  • 程序运行时,数据主要存储在内存中。数组可提供更高的内存空间效率,而链表则在内存使用上更加灵活。
  • 缓存通过缓存行、预取机制以及空间局部性和时间局部性等数据加载机制,为 CPU 提供快速数据访问,显著提升程序的执行效率。
  • 由于数组具有更高的缓存命中率,因此它通常比链表更高效。在选择数据结构时,应根据具体需求和场景做出恰当选择。

Q & A

Q:数组存储在栈上和存储在堆上,对时间效率和空间效率是否有影响?

存储在栈上和堆上的数组都被存储在连续内存空间内,数据操作效率基本一致。然而,栈和堆具有各自的特点,从而导致以下不同点。

  1. 分配和释放效率:栈是一块较小的内存,分配由编译器自动完成;而堆内存相对更大,可以在代码中动态分配,更容易碎片化。因此,堆上的分配和释放操作通常比栈上的慢。
  2. 大小限制:栈内存相对较小,堆的大小一般受限于可用内存。因此堆更加适合存储大型数组。
  3. 灵活性:栈上的数组的大小需要在编译时确定,而堆上的数组的大小可以在运行时动态确定。

Q:为什么数组要求相同类型的元素,而在链表中却没有强调相同类型呢?

链表由节点组成,节点之间通过引用(指针)连接,各个节点可以存储不同类型的数据,例如 int、double、string、object 等。

相对地,数组元素则必须是相同类型的,这样才能通过计算偏移量来获取对应元素位置。例如,数组同时包含 int 和 long 两种类型,单个元素分别占用 4 字节和 8 字节 ,此时就不能用以下公式计算偏移量了,因为数组中包含了两种“元素长度”。

# 元素内存地址 = 数组内存地址(首元素内存地址) + 元素长度 * 元素索引

Q:删除节点 P 后,是否需要把 P.next 设为 None 呢?

不修改 P.next 也可以。从该链表的角度看,从头节点遍历到尾节点已经不会遇到 P 了。这意味着节点 P 已经从链表中删除了,此时节点 P 指向哪里都不会对该链表产生影响。

从数据结构与算法(做题)的角度看,不断开没有关系,只要保证程序的逻辑是正确的就行。从标准库的角度看,断开更加安全、逻辑更加清晰。如果不断开,假设被删除节点未被正常回收,那么它会影响后继节点的内存回收。

Q:在链表中插入和删除操作的时间复杂度是 $O(1)$ 。但是增删之前都需要 $O(n)$ 的时间查找元素,那为什么时间复杂度不是 $O(n)$ 呢?

如果是先查找元素、再删除元素,时间复杂度确实是 $O(n)$ 。然而,链表的 $O(1)$ 增删的优势可以在其他应用上得到体现。例如,双向队列适合使用链表实现,我们维护一个指针变量始终指向头节点、尾节点,每次插入与删除操作都是 $O(1)$ 。

Q:图“链表定义与存储方式”中,浅蓝色的存储节点指针是占用一块内存地址吗?还是和节点值各占一半呢?

该示意图只是定性表示,定量表示需要根据具体情况进行分析。

  • 不同类型的节点值占用的空间是不同的,比如 int、long、double 和实例对象等。
  • 指针变量占用的内存空间大小根据所使用的操作系统及编译环境而定,大多为 8 字节或 4 字节。

Q:在列表末尾添加元素是否时时刻刻都为 $O(1)$ ?

如果添加元素时超出列表长度,则需要先扩容列表再添加。系统会申请一块新的内存,并将原列表的所有元素搬运过去,这时候时间复杂度就会是 $O(n)$ 。

Q:“列表的出现极大地提高了数组的实用性,但可能导致部分内存空间浪费”,这里的空间浪费是指额外增加的变量如容量、长度、扩容倍数所占的内存吗?

这里的空间浪费主要有两方面含义:一方面,列表都会设定一个初始长度,我们不一定需要用这么多;另一方面,为了防止频繁扩容,扩容一般会乘以一个系数,比如 $\times 1.5$ 。这样一来,也会出现很多空位,我们通常不能完全填满它们。

Q:在 Python 中初始化 n = [1, 2, 3] 后,这 3 个元素的地址是相连的,但是初始化 m = [2, 1, 3] 会发现它们每个元素的 id 并不是连续的,而是分别跟 n 中的相同。这些元素的地址不连续,那么 m 还是数组吗?

假如把列表元素换成链表节点 n = [n1, n2, n3, n4, n5] ,通常情况下这 5 个节点对象也分散存储在内存各处。然而,给定一个列表索引,我们仍然可以在 $O(1)$ 时间内获取节点内存地址,从而访问到对应的节点。这是因为数组中存储的是节点的引用,而非节点本身。

与许多语言不同,Python 中的数字也被包装为对象,列表中存储的不是数字本身,而是对数字的引用。因此,我们会发现两个数组中的相同数字拥有同一个 id ,并且这些数字的内存地址无须连续。

Q:C++ STL 里面的 std::list 已经实现了双向链表,但好像一些算法书上不怎么直接使用它,是不是因为有什么局限性呢?

一方面,我们往往更青睐使用数组实现算法,而只在必要时才使用链表,主要有两个原因。

  • 空间开销:由于每个元素需要两个额外的指针(一个用于前一个元素,一个用于后一个元素),所以 std::list 通常比 std::vector 更占用空间。
  • 缓存不友好:由于数据不是连续存放的,因此 std::list 对缓存的利用率较低。一般情况下,std::vector 的性能会更好。

另一方面,必要使用链表的情况主要是二叉树和图。栈和队列往往会使用编程语言提供的 stack 和 queue ,而非链表。

Q:操作 res = [[0]] * n 生成了一个二维列表,其中每一个 [0] 都是独立的吗?

不是独立的。此二维列表中,所有的 [0] 实际上是同一个对象的引用。如果我们修改其中一个元素,会发现所有的对应元素都会随之改变。

如果希望二维列表中的每个 [0] 都是独立的,可以使用 res = [[0] for _ in range(n)] 来实现。这种方式的原理是初始化了 $n$ 个独立的 [0] 列表对象。

Q:操作 res = [0] * n 生成了一个列表,其中每一个整数 0 都是独立的吗?

在该列表中,所有整数 0 都是同一个对象的引用。这是因为 Python 对小整数(通常是 -5 到 256)采用了缓存池机制,以便最大化对象复用,从而提升性能。

虽然它们指向同一个对象,但我们仍然可以独立修改列表中的每个元素,这是因为 Python 的整数是“不可变对象”。当我们修改某个元素时,实际上是切换为另一个对象的引用,而不是改变原有对象本身。

然而,当列表元素是“可变对象”时(例如列表、字典或类实例等),修改某个元素会直接改变该对象本身,所有引用该对象的元素都会产生相同变化。