1. 数据结构

1. 数据结构

  • 数据存储于内存时,决定了数据顺序和位置关系的便是“数据结构”
  • 将数据存储于内存时,根据使用的目的选择合适的数据结构,可以调高内存的利用率
  • 数据在内存中是呈线性排列的,但是我们 也可以使用指针等道具,构造出类似“树形”的复杂结构1

2. 链表

​ 链表是数据结构之一,其中的数据呈线性排列;在链表中,数据的添加和删除都较为方便, 就是访问比较耗费时间

1. 数据结构
  1. 这就是链表的概念图。Blue、Yellow、Red 这 3 个字符串作为数据被存储于链表中。每个数据都有 1 个 “指针”,它指向下一个数据的内存地址
1. 数据结构
  1. 在链表中,数据一般都是分散存储于内存中 的,无须存储在连续空间内
1. 数据结构
  1. 因为数据都是分散存储的,所以如果想要访问 数据,只能从第1个数据开始,顺着指针的指 向一一往下访问(这便是顺序访问)。比如,想 要找到Red这一数据,就得从Blue开始访问
1. 数据结构
  1. 如果想要添加数据,只需要改变添加位置前后 的指针指向就可以,非常简单。比如,在Blue 和Yellow之间添加Green
1. 数据结构
  1. 数据的删除也一样,只要改变指针的指向就可 以,比如删除Yellow;虽然 Yellow 本身还存储在 内存中,但是不管从哪里都无法访问这个数据,所以也就没有特意去删除它的必要了。今后需要用到 Yellow所在的存储空间时,只要用新数据覆盖掉就可以了

2.1 耗时

  • 我们把链表中的数据量记成 n,访问数据时,我们需要从链表头部开始查找(线性查找),如果目标数据在链表最后 的话,需要的时间就是 O(n)
  • 添加数据只需要更改两个指针的指向,所以耗费的时间与 n 无关。如果已 经到达了添加数据的位置,那么添加操作只需花费 O(1) 的时间。删除数据同样也只需 O(1) 的时间

2.2 循环/双向链表

  • 虽然上文中提到的链表在尾部没有指针,但我们也可以在链表尾部使用指针,并且 让它指向链表头部的数据,将链表变成环形,这便是“循环链表”,循环链表没有头和尾的概念,想要保存数量固定的最新数据时通常会使用这种链表
1. 数据结构
  • 另外,上文链表里的每个数据都只有一个指针,但我们可以把指针设定为两个,并 且让它们分别指向前后数据,这就是“双向链表”。使用这种链表,不仅可以从前往后, 还可以从后往前遍历数据,十分方便

  • 但是,双向链表存在两个缺点:一是指针数的增加会导致存储空间需求增加;二是 添加和删除数据时需要改变更多指针的指向

    1. 数据结构

3. 数组

​ 数组也是数据呈线性排列的一种数据结构,与前一节中的链表不同,在数组中,访问数据十分简单,而添加和删除数据比较耗工夫

1. 数据结构
  1. 这就是数组的概念图。Blue、Yellow、Red作为数据存储在数组中。
1. 数据结构
  1. 数据按顺序存储在内存的连续空间内。
1. 数据结构
  1. 由于数据是存储在连续空间内的,所以每个数 据的内存地址(在内存上的位置)都可以通过 数组下标算出,我们也就可以借此直接访问目 标数据(这叫作“随机访问”)。
1. 数据结构
  1. 比如现在我们想要访问Red。如果使用指针就 只能从头开始查找,但在数组中,只需要指定 a[2],便能直接访问Red。
1. 数据结构
  1. 但是,如果想在任意位置上添加或者删除数 据,数组的操作就要比链表复杂多了。这里我 们尝试将Green添加到第2个位置上。
1. 数据结构
  1. 首先,在数组的末尾确保需要增加的存储空间。
1. 数据结构
  1. 为了给新数据腾出位置,要把已有数据一个个 移开。首先把Red往后移。
1. 数据结构
  1. 然后把Yellow往后移。
1. 数据结构
  1. 最后在空出来的位置上写入Green。添加数据的操作就完成了,反过来,如果想要删除Green……
1. 数据结构
  1. 首先,删掉目标数据(在这里指 Green)。
1. 数据结构
  1. 然 后 把 后 面 的 数 据 一 个 个 往 空 位 移。 先 把 Yellow往前移。
1. 数据结构
  1. 接下来移动Red。
1. 数据结构
  1. 最后再删掉多余的空间。这样一来Green便被删掉了。

3.1 耗时

  • 假设数组中有 n 个数据,由于访问数 据时使用的是随机访问(通过下标可计算出内存地址),所以需要的运行时间仅为恒定的 O(1)。
  • 但另一方面,想要向数组中添加新数据时,必须把目标位置后面的数据一个个移 开。所以,如果在数组头部添加数据,就需要 O(n) 的时间。删除操作同理。

3.2 数组和链表对比

  • 在链表和数组中,数据都是线性地排成一列。在链表中访问数据较为复杂,添加和 删除数据较为简单;而在数组中访问数据比较简单,添加和删除数据却比较复杂。

  • 我们可以根据哪种操作较为频繁来决定使用哪种数据结构。

    1. 数据结构

4. 栈

​ 栈也是一种数据呈线性排列的数据结构,不过在这种结构中,我们只能访问最新添加的数 据

1. 数据结构
  1. 这就是栈的概念图。现在存储在栈中的只有数据Blue。
1. 数据结构
  1. 然后,栈中添加了数据Green。
1. 数据结构
  1. 接下来,数据Red入栈。
1. 数据结构
  1. 从栈中取出数据时,是从最上面,也就是最新 的数据开始取出的。这里取出的是Red。

4.1 解说

  • 像栈这种最后添加的数据最先被取出,即“后进先出”的结构,我们称为 Last In First Out,简称 LIFO
  • 与链表和数组一样,栈的数据也是线性排列,但在栈中,添加和删除数据的操作只 能在一端进行,访问数据也只能访问到顶端的数据。想要访问中间的数据时,就必须通 过出栈操作将目标数据移到栈顶才行

5. 队列

​ 队列中的数据也呈线性排列。虽然与栈有些相似,但队列中 添加和删除数据的操作分别是在两端进行的,处理总是从第一名开始往后进行,而新来的人只能排在队尾

1. 数据结构
  1. 这就是队列的概念图。现在队列中只有数据Blue。
1. 数据结构
  1. 然后,队列中添加了数据Green。
1. 数据结构
  1. 从队列中取出(删除)数据时,是从最下面,也就 是最早入队的数据开始的。这里取出的是Blue。

5.1 解说

  • 像队列这种最先进去的数据最先被取来,即“先进先出”的结构,我们称为 First In First Out,简称 FIFO
  • 与栈类似,队列中可以操作数据的位置也有一定的限制。在栈中,数据的添加和删 除都在同一端进行,而在队列中则分别是在两端进行的。队列也不能直接访问位于中间 的数据,必须通过出队操作将目标数据变成首位后才能访问

6. 哈希表

1. 数据结构
  1. 哈希表存储的是由键(key)和值(value)组 成的数据。例如,我们将每个人的性别作为数 据进行存储,键为人名,值为对应的性别。
1. 数据结构
  1. 为了和哈希表进行对比,我们先将这些数据存 储在数组中
1. 数据结构
  1. 此处准备了 6 个箱子(即长度为 6 的数组)来存 储数据。假设我们需要查询Ally的性别,由于 不知道Ally的数据存储在哪个箱子里,所以只 能从头开始查询。这个操作便叫作“线性查找”
1. 数据结构
  1. 查找到4号箱子的时候,发现其中数据的键为 Ally。把键对应的值取出,我们就知道 Ally 的性 别为女(F)了。数据量越多,线性查找耗费的时间就越长。由 此可知 :由于数据的查询较为耗时,所以此处 并不适合使用数组来存储数据。
1. 数据结构
  1. 但使用哈希表便可以解决这个问题。首先准备好数组,这次我们用5个箱子的数组来存储 数据。
1. 数据结构
  1. 使用哈希函数(Hash)计算 Joe的键,也就是 字符串“Joe”的哈希值。得到的结果为4928
1. 数据结构
  1. 将得到的哈希值除以数组的长度5,求得其余数。这样的求余运算叫作“mod 运算”。此处 mod运算的结果为3。
1. 数据结构
  1. 因此,我们将Joe的数据存进数组的3号箱子中。重复前面的操作,将其他数据也存进数组中。
1. 数据结构
  1. Nell键的哈希值为6276,mod 5的结果为1。本应将其存进数组的1号箱中,但此时1号箱中已经 存储了 Sue 的数据。这种存储位置重复了的情况便叫作“冲突”。
1. 数据结构
  1. 遇到这种情况,可使用链表在已有数据的后面 继续存储新的数据
1. 数据结构
  1. 想要查询Ally的性别时该怎么做呢?为 了找到它的存储位置,先要算出Ally键的哈希 值,再对其进行mod运算。最终得到的结果为3。
1. 数据结构
  1. 然而3号箱中数据的键是Joe而不是Ally。此时便需要对Joe所在的链表进行线性查找,于是我们找到了键为Ally的数据。取出其对应 的值,便知道了 Ally 的性别为女(F)。

6.1 解说

  • 在哈希表中,我们可以利用哈希函数快速访问到数组中的目标数据。如果发生哈希 冲突,就使用链表进行存储。这样一来,不管数据量为多少,我们都能够灵活应对。
  • 如果数组的空间太小,使用哈希表的时候就容易发生冲突,线性查找的使用频率也 会更高;反过来,如果数组的空间太大,就会出现很多空箱子,造成内存的浪费。因此, 给数组设定合适的空间非常重要。

6.2 补充

  • 在存储数据的过程中,如果发生冲突,可以利用链表在已有数据的后面插入新数据 来解决冲突。这种方法被称为“链地址法”。
  • 除了链地址法以外,还有几种解决冲突的方法。其中,应用较为广泛的是“开放地 址法”。这种方法是指当冲突发生时,立刻计算出一个候补地址(数组上的位置)并将数 据存进去。如果仍然有冲突,便继续计算下一个候补地址,直到有空地址为止。可以通 过多次使用哈希函数或“线性探测法”等方法计算候补地址。

7. 堆

  • 堆是一种图的树形结构,被用于实现“优先队列”(priority queues)
  • 优先队列是一种数据结构,可以*添加数据,但取出数据时要从最小值开始按顺序取出
  • 在堆的树形结构中,各个顶点被称为“结点”(node),数据就存储在这些结点中
1. 数据结构
  1. 这就是堆的示例。结点内的数 字就是存储的数据。堆中的每个结点最多有两个子结点。树 的形状取决于数据的个数。另 外,结点的排列顺序为从上到 下,同一行里则为从左到右。
  2. 在堆中存储数据时必须遵守这 样一条规则 :子结点必定大于父 结点。因此,最小值被存储在顶 端的根结点中。往堆中添加数据 时,为了遵守这条规则,一般会 把新数据放在最下面一行靠左 的位置。当最下面一行里没有多 余空间时,就再往下另起一行, 把数据加在这一行的最左端。
1. 数据结构
  1. 我们试试往堆里添加数 字5。
1. 数据结构
  1. 首先按照02的说明寻找新数据的位置。该图中最 下面一排空着一个位置,所以将数据加在此处。
1. 数据结构
  1. 如果父结点大于子结点,则不符合上文提到的 规则,因此需要交换父子结点的位置。
1. 数据结构
  1. 这里由于父结点的6大于子结点的5,所以交 换了这两个数字。重复这样的操作直到数据都 符合规则,不再需要交换为止。
1. 数据结构
  1. 从堆中取出数据时,取出的是最上面的数据。 这样,堆中就能始终保持最上面的数据最小。
1. 数据结构
  1. 由于最上面的数据被取出,因此堆的结构也需 要重新调整。
1. 数据结构
  1. 按照01中说明的排列顺序,将最后的数据(此 处为6)移动到最顶端,再经过一系列调整
1. 数据结构
  1. 这样,从堆中取出数据的操作便完成了。

7.1 耗时

  • 堆中最顶端的数据始终最小,所以无论数据量有多少,取出最小值的时间复杂度都 为 O(1)。
  • 另外,因为取出数据后需要将最后的数据移到最顶端,然后一边比较它与子结点数据 的大小,一边往下移动,所以取出数据需要的运行时间和树的高度成正比。假设数据量为 n,根据堆的形状特点可知树的高度为 log2n ,那么重构树的时间复杂度便为 O(logn)。
  • 添加数据也一样。在堆的最后添加数据后,数据会一边比较它与父结点数据的大 小,一边往上移动,直到满足堆的条件为止,所以添加数据需要的运行时间与树的高度 成正比,也是 O(logn)。

8. 二叉查找树

  • 二叉查找树(又叫作二叉搜索树或二叉排序树)是一种数据结构,采用了图的树形结构
  • 数据存储于二叉查找树的各个结点中
1. 数据结构
  1. 这就是二叉查找树的示 例。结点中的数字便是 存储的数据。此处以不 存在相同数字为前提进 行说明。
  2. 二叉查找树有两个性质。第一个是每个结点的 值均大于其左子树上任意一个结点的值。比如 结点9大于其左子树上的3和8。同样,结点15也大于其左子树上任意一个结 点的值。
  3. 第二个是每个结点的值均小于其右子树上任意 一个结点的值。比如结点 15 小于其右子树上的 23、17和28。
  4. 根据这两个性质可以得到以下结论。首先,二 叉查找树的最小结点要从顶端开始,往其左下 的末端寻找。此处最小值为3。
  5. 反过来,二叉查找树的最大结点要从顶端开 始,往其右下的末端寻找。此处最大值为28。
  6. 下面我们来试着往二叉查找树中添加数据。比 如添加数字1。
1. 数据结构
  1. 首先,从二叉查找树的 顶端结点开始寻找添加 数字的位置。将想要添 加的1与该结点中的值 进行比较,小于它则往 左移,大于它则往右移。由于1<9,所以将1往左移。由于1<3,所以继续将1往左移,但前面已经没 有结点了,所以把 1 作为新结点添加到左下方。
1. 数据结构
  1. 这样,1的添加操作便完成了。接下来,我们再试试添加数字4。
1. 数据结构
  1. 于是4的添加操作也完成了。
1. 数据结构
  1. 接下来看看如何在二叉查找树中删除结点。比 如我们来试试删除结点28。如果需要删除的结点没有子结点,直接删掉该 结点即可。
1. 数据结构
  1. 再试试删除结点8。 如果需要删除的结点只有一个子结点,那么先 删掉目标结点……然后把子结点移到被删除结点的位置上即可。
1. 数据结构
  1. 最后来试试删除结点9。如果需要删除的结点有两个子结点,那么先删 掉目标结点……然后在被删除结点的左子树中寻找最大结 点……最后将最大结点移到被删除结点的位置上。这样一来,就能在满足二叉查找树性质的前提下删除结点了。 如果需要移动的结点(此处为4)还有子结点,就递归执行前面的操作(删除9的时候,我们将“左子树中的最大结点”移动到了删除结点的位置上,但是根据 二叉查找树的性质可知,移动“右子树中的最小结点”也没有问题。)
1. 数据结构
  1. 下面来看看如何在二叉查找树中查找结点。比 如我们来试试查找12。从二叉查找树的顶端结点开始往下查找。和添 加数据时一样,把12和结点中的值进行比较, 小于该结点的值则往左移,大于则往右移。

8.1 耗时

  • 我们可以把二叉查找树当作是二分查找算法思想的树形结构体现,因为它具有前面提到的那两个性质,所以在查找数据或寻找适合添加 数据的位置时,只要将其和现有的数据比较大小,就可以根据比较结果得知该往哪边移 动了。
  • 比较的次数取决于树的高度。所以如果结点数为 n,而且树的形状又较为均衡的话, 比较大小和移动的次数最多就是 log2n。因此,时间复杂度为 O(logn)。但是,如果树的 形状朝单侧纵向延伸,树就会变得很高,此时时间复杂度也就变成了 O(n)。

8.2 补充

  • 有很多以二叉查找树为基础扩展的数据结构,比如“平衡二叉查找树”。这种数据 结构可以修正形状不均衡的树,让其始终保持均衡形态,以提高查找效率。
  • 另外,虽然文中介绍的二叉查找树中一个结点最多有两个子结点,但我们可以把子 结点数扩展为 m(m 为预先设定好的常数)。像这种子结点数可以*设定,并且形状均 衡的树便是 B 树。

《我的第一本算法书》-- 宫崎修一 , 石田保辉