《漫画算法:小灰的算法之旅》读书笔记

第 1 章 算法概述

算法 (algorithm) 是一系列程序指令,用于处理特定的运算和逻辑问题。衡量算法优劣的主要标准是时间复杂度和空间复杂度。

数据结构 (data structure) 是数据的组织、管理和存储格式,其使用目的是为了高效地访问和修改数据。数据结构都有哪些组成方式呢?

  1. 线性结构:最简单的数据结构,包括数组、链表,以及由它们衍生出来的栈、队列、哈希表。
  2. 树:相对复杂的数据结构,其中比较有代表性的是二叉树,由它又衍生出了二叉堆之类的数据结构。
  3. 图:更为复杂的数据结构,因为在图中会呈现出多对多的关联关系。
  4. 其他数据结构:由基本数据结构变形而来,用于解决某些特定问题,如跳表、哈希链表、位图等。

时间复杂度 是对一个算法运行时间长短的量度,用大 O 表示,记作 T(n) = O(f(n))

空间复杂度 是对一个算法在运行过程中临时占用存储空间大小的量度,用大 O 表示,记作 S(n) = O(f(n))

第 2 章 数据结构基础

1. 数组

数组 (array) 是由有限个相同类型的变量所组成的有序集合。数组是最为简单、最为常用的数据结构。数组的物理存储方式是顺序存储,访问方式是随机访问。

数组的优势:利用下标就可以用常量时间 O(1) 找到对应元素。有一种高效查找元素的算法叫作二分查找,就是利用了数组的这个优势。

数组的劣势:由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,其时间复杂度是 O(n)

2. 链表

链表 (linked list) 是一种链式数据结构,由若干节点组成,每个节点包含指向下一节点的指针。链表的物理存储方式是随机存储,访问方式是顺序访问。查找链表节点的时间复杂度是 O(n),插入、删除节点的时间复杂度是 O(1)

3. 栈

栈 (stack) 是一种线性数据结构,可以用数组实现,也可以用链表实现。栈包含 入栈 (push)出栈 (pop) 操作,遵循 先入后出 (FILO) 的原则,最早进入的元素存放的位置叫作 栈底 (bottom),最后进入的元素存放的位置叫作 栈顶 (top)

栈的应用:栈的输出顺序和输入顺序相反,所以栈通常用于对「历史」的回溯。例如实现递归的逻辑,就可以用栈来代替,因为栈可以回溯方法的调用链。栈还有一个著名的应用场景是面包屑导航,使用户在浏览页面时可以轻松地回溯到上一级或更上一级页面。

4. 队列

队列 (queue) 也是一种线性数据结构,可以用数组实现,也可以用链表实现。队列包含 入队 (enqueue)出队 (dequeue) 操作,遵循 先入先出 (FIFO) 的原则。队列的出口端叫作 队头 (front),队列的入口端叫作 队尾 (rear)

队列的应用:队列的输出顺序和输入顺序相同,所以队列通常用于对「历史」的回放,也就是按照「历史」顺序,把「历史」重演一遍。例如在多线程中,争夺公平锁的等待队列,就是按照访问顺序来决定线程在队列中的次序的。再如网络爬虫实现网站抓取时,也是把待抓取的网站URL存入队列中,再按照存入队列的顺序来依次抓取和解析的。

物理结构:包括顺序存储结构(数组)和链式存储结构(链表)
逻辑结构:包括线性结构(顺序表、栈、队列)和非线性结构(树、图)

5. 散列表

散列表 也叫 哈希表 (hash table),是存储 Key-Value 映射的集合。对于某一个 Key,散列表可以在接近 O(1) 的时间内进行读写操作。散列表本质上是一个数组,通过哈希函数实现 Key 和数组下标的转换,通过开放寻址法和链表法来解决哈希冲突。

开放寻址法:当一个 Key 通过哈希函数获得对应的数组下标已被占用时,我们可以「另谋高就」,寻找下一个空档位置。

链表法:Java 的集合类 HashMap 使用的就是链表法。HashMap 数组的每一个元素不仅是一个 Entry 对象,还是一个链表的头节点。每一个 Entry 对象通过 next 指针指向它的下一个 Entry 节点。当新来的 Entry 映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。

第 3 章 树

1. 二叉树

二叉树 (binary tree) 是树的一种特殊形式,每一个节点最多有 2 个孩子节点。二叉树包含完全二叉树和满二叉树两种特殊形式:

  1. 满二叉树:所有非叶子节点都存在左右孩子,并且所有叶子节点都在同一层级上。

满二叉树

  1. 完全二叉树:如果一个树所有 n 个节点和同样深度的满二叉树的 1 到 n 的节点位置相同,则这个二叉树为完全二叉树。

完全二叉树

完全二叉树的条件没有满二叉树那么苛刻:满二叉树要求所有分支都是满的;而完全二叉树只需保证最后一个节点之前的节点都齐全即可。

二叉树的应用

1. 查找

二叉查找树 (binary search tree) 在二叉树的基础上增加了二个条件:左子树上所有节点的值均小于根节点的值;右子树上所有节点的值均大于根节点的值。

二叉查找树

例如上图中查找值为 4 的节点,步骤如下:

  1. 访问根节点 6,发现 4 < 6。
  2. 访问节点 6 的左孩子节点 3,发现 4 > 3。
  3. 访问节点 3 的右孩子节点 4,发现 4 = 4,这正是要查找的节点。

对于一个节点分布相对均衡的二叉查找树来说,如果节点总数是 n,那么搜索节点的时间复杂度就是 O(logn),和树的深度是一样的。这种依靠比较大小来逐步查找的方式,和二分查找算法非常相似。

2. 排序

二叉排序树 (binary sort tree) 是二叉查找树的另一个名字,新插入的节点,同样要遵循二叉排序树的原则。例如插入新元素 5,由于 5 < 6,5 > 3,5 > 4,所以 5 最终会插入到节点 4 的右孩子位置。

二叉排序树

这一切看起来很顺利,然而却隐藏着一个致命的问题。下面请试着在二叉查找树中依次插入 9、8、7、6、5、4,看看会出现什么结果。

二叉排序树

不只是外观看起来变得怪异了,查询节点的时间复杂度也退化成了 O(n)。怎么解决这个问题呢?这就涉及二叉树的自平衡了。二叉树自平衡的方式有多种,如红黑树、AVL 树、树堆等。由于篇幅有限,本书就不 一一详细讲解了,感兴趣的读者可以查一查相关资料。

二叉树的遍历方式

遍历方式 输出顺序 宏观角度区分
前序遍历 根节点、左子树、右子树 深度优先遍历
中序遍历 左子树、根节点、右子树 深度优先遍历
后序遍历 左子树、右子树、根节点 深度优先遍历
层序遍历 广度优先遍历

深度优先遍历

绝大多数可以用递归解决的问题,其实都可以用另一种数据结构来解决,这种数据结构就是栈。因为递归和栈都有回溯的特性。

深度优先遍历

  1. 首先遍历二叉树的根节点 1,放入栈中。
  2. 遍历根节点 1 的左孩子节点 2,放入栈中。
  3. 遍历节点 2 的左孩子节点 4,放入栈中。
  4. 节点 4 既没有左孩子,也没有右孩子,我们需要回溯到上一个节点 2。栈已经存储了刚才遍历的路径。让旧的栈顶元素 4 出栈,就可以重新访问节点 2,得到节点 2 的右孩子节点 5。此时节点 2 已经没有利用价值,节点 2 出栈,节点 5 入栈。
  5. 节点 5 既没有左孩子,也没有右孩子,我们需要再次回溯,一直回溯到节点 1。所以让节点 5 出栈。根节点 1 的右孩子是节点 3,节点 1 出栈,节点 3 入栈。
  6. 节点 3 的右孩子是节点 6,节点 3 出栈,节点 6 入栈。
  7. 节点 6 既没有左孩子,也没有右孩子,所以节点 6 出栈。此时栈为空,遍历结束。

广度优先遍历

二叉树同一层次的节点之间是没有直接关联的,如何实现这种层序遍历呢?这里同样需要借助一个数据结构来辅助工作,这个数据结构就是队列。

![广度优先遍历](/Users/mayan/Desktop/《漫画算法 - 小灰的算法之旅》读书笔记/006.jpg)

  1. 根节点 1 进入队列。
  2. 节点 1 出队,输出节点 1,并得到节点 1 的左孩子节点 2、右孩子节点 3。让节点 2 和节点 3 入队。
  3. 节点 2 出队,输出节点 2,并得到节点 2 的左孩子节点 4、右孩子节点 5。让节点 4 和节点 5 入队。
  4. 节点 3 出队,输出节点 3,并得到节点 3 的右孩子节点 6。让节点 6 入队。
  5. 节点 4 出队,输出节点 4,由于节点 4 没有孩子节点,所以没有新 节点入队。
  6. 节点 5 出队,输出节点 5,由于节点 5 同样没有孩子节点,所以没 有新节点入队。
  7. 节点 6 出队,输出节点 6,节点 6 没有孩子节点,没有新节点入 队。

二叉堆

二叉堆是一种特殊的完全二叉树,分为最大堆和最小堆。在最大堆中,任何一个父节点的值,都大于或等于它左、右孩子节点的值;在最小堆中,任何一个父节点的值,都小于或等于它左、右孩子节点的值。

二叉堆的根节点叫作堆顶。最大堆和最小堆的特点决定了:最大堆的堆顶是整个堆中的最大元素;最小堆的堆顶是整个堆中的最小元素。

优先队列

优先队列分为最大优先队列和最小优先队列。

在最大优先队列中,无论入队顺序如何,当前最大的元素都会优先出队,这是基于最大堆实现的。

在最小优先队列中,无论入队顺序如何,当前最小的元素都会优先出队,这是基于最小堆实现的。