Data Structure

Data Structure

Computer science in plain English

为了更好的理解数据结构如何工作,我们从头开始推到每个数据结构。
包含如下:

  • Random Access Memory 随机存取存储器
  • Binary Numbers 二进制数字
  • Fixed-Width Interger 固定宽度的整数
  • Arrays 数组
  • Strings 字符串
  • Pointers 指针
  • Dynamic Arrays 动态数组
  • Linked Lists 链表
  • Hash Tables Hash表

Random Access Memory

当计算机运行代码是,它需要跟踪记录这些变量(数字,字符串,数组,等等)
变量是存储在RAM中,我们常常叫工作内存或就叫内存

RAM不是存储mp3和应用的地方,你的计算机还有存储空间(有时称为“持久存储”或“光盘”)。虽然内存是我们记录函数为数据分配的变量。存储空间用于存储文件,比如MP3s,视频,文档,可执行程序或者应用。

可以把RAM想象为一个很高的带书架的书柜,数十亿的书架。
书架上是还有编号,我们称这个编号为地址。
每个书架上存储8bits,1位就是一个微小的电开关,有开和关两种状态;我们通常用1和0替代开和关。
8bits称为一个字节,所以一个书架上存储一个字节。
在这里插入图片描述

当然,我们需要一个处理器,在我们计算机里做真正的工作。
这个处理器和一个内存控制器相连,内存控制器执行真正的从RAM中读取和写入操作。它和RAM中的每个书架直接相连。
在这里,直接相连非常重要,这意味着我们首先范围地址0,然后可以立刻访问地址918,873,并且不需要爬下我们庞大的书架。
这也是为什么我们称它为随机存取存储器,我们可以马上随机的访问存储器中任何地址上的内容。

旋转硬盘驱动器没有随机访问的超级能力,因为它没有和设备上的每个字节直连。相反,它拥有一个读的头指针,通过移动头指针来读取相关区域地址的内容。读取较远区域的内容会花费较长时间,因为你一定要等到头指针移动到相应的区域后才能访问。

即使内存控制器可以快速第在较远内存地址间跳转,程序也会访问附近的地址。因此,当读取彼此接近的内存地址时,计算机会调整以获得额外的速度提升,
工作原理就是处理器有一个缓存,存储着最近从RAM读取的书架的拷贝。
在这里插入图片描述

实际上,它拥有一系列缓存,但我们可以将它们合并视为一整个缓存。

从这个缓存的读取速度比直接从RAM读取要快的多,所以当处理器可以从缓存上获取以替代从RAM获取,可以节省很多时间。
当处理器获取一个地址的内容时,内存控制器也会发送少量附近存储器地址的内容。并且处理将所有内容存放在缓存。
因此,如果处理器要求地址951的内容,然后是952,然后是953,那么954 …它将在第一次读取时输出到RAM一次,随后的读取将直接来自超高速缓存。
但是如果处理器要求读取地址951,然后是地址362,则地址419 …然后缓存将无济于事,并且每次读取都必须一直到RAM。
因此,从存储器地址顺序读取比跳转更快。


Binary numbers

二进制,10组成;
2^0=1
2^1=2
2^2=4
2^3=8
etc.


Fixed-width integers

固定宽度数字;8位,可以表示2^8=256个数字;16位,可以表示2 ^ 16 = 65536个数字;不同的位数可以表示的信息量级不同;像基本类型所占的位数不同,字符型,8位已经足够,所以char类型占用1个字节;依次类推。


Arrays

Ok,我们知道如何存储一个数字,现在让我们来讨论下如何存储一些数字。
没错,事情开始升温。
假设我们想要计算每天喝多少瓶kombucha。
让我们用8位、固定宽度、无符号的整数来存储每天的kombucha的数量,这样是足够的,我们不太可能一天喝超过259瓶。
我们将kombucha数量紧挨着存储在RAM中,从内存地址0开始:
在这里插入图片描述
这是一个数组,RAM 就是一个数组。

和RAM一样,数组的元素也是有编号的,我们将之称为数组元素的索引,在这个例子中,每个数组元素的索引就和ARM的地址一样。
但是经常不是这样,假设一个应用已经在地址号为2存储了一些信息:
在这里插入图片描述
我们必须在它下面开始我们的数组,比如地址号为3,所以我们数组的第0个索引的地址为3,第1个索引的地址为4,依次类推;在这里插入图片描述
如果我们想访问数组的索引为4的地方存储kombucha的数量,我们怎么计算出我们要访问的地址?非常简单计算方式:
stating address(3) + index(4) = 7;
\text{address of nth item in array} = \text{address of array start} + n
这样工作良好,因为用于存储每天kombucha数量是使用1个字节,所以一个数组的插槽对应一个RAM的插槽。
但是很多情况不是这样的,实际上,我们经常使用64位的整数(8byte);
在这里插入图片描述
\text{address of nth item in array} = \text{address of array start} + (n*\text{size of each item in bytes})
不要担心,增加这个乘法不会使我们变慢,请记住:固定宽度整数的加法、减法、乘法、和除法需要时间;所以我们在这里用来获取数组中第n项的地址的所有数学都需要 时间。
这种计算仅适用满足如下条件的数组:

  1. 数组中的每个项目大小相同(占用相同的字节数)。
  2. 该数组在内存中不间断(连续)。数组中不能有任何间隙… …。

对于数组而言,我们可以预测第n个元素的地址;但是这也限制了可以存储到数组的数据类型,我们必须保证每一项数据拥有相同的大小,并且如果我们的数组要存放很多的内容,我们就需要一整块连续的RAM空间,这样将变得很困难,因为我们的RAM经常被一些其他应用程序占用。
这就需要我们进行权衡,数组可以实现快速查找,但数组要求每一项大小相同,并且您需要一块不间断的可用内存来存储数组。
ps:正因为数组的大小相同,和内存不间断的特性,所以当生命数组时,一定要指定数组的length(初始化);这样才能让应用程序提前准备相应大小的连续地址空间。


String

一系列的字符,称做字符串。
我们已经知道如何存储数组,我们也可以使用字符替换数字;数字与字符的对应的关系,称为编码:
关于编码相关可以参照如下blog:https://blog.csdn.net/uestcyms/article/details/80247255
在这里插入图片描述


Pointers

还记得我们说数组的每一项需要具有相同的大小,让我们再深入的了解一下。
如果我们想存储一堆小孩的名字想法,因为我们已经有一些可爱的名字了。
每个名字都是一个字符串,其实就是一个数组;我们需要存储这些数组到一个数组。哇。
现在,如果我们宝贝的名字拥有不同的长度,那该怎么办?它违反了我们的规则:数组中的所有元素必须具有相同的大小。
我们可以将我们宝贝的名字存放在一个任意大的数组中(比如13个字符),然后用一个特殊的字符表示一个数组的结束。
在这里插入图片描述
但是看下位于Bill后面的浪费的空间,然后如果我们需要存储一个大于13个字符的字符串,那我们就没有这么幸运了。

这里有一个更好的方式;不将我们的字符串存储在数组里,而是将字符串放在内存的任意位置。然后我们将数组中的每个元素用于保存对应字符串的内存中的地址。每一个地址都是整数,所以我们的外部数组实际上只是一个整数数组。我们可以将这些整数的每一个称为指针,因为它指向内存中的另一个位置。在这里插入图片描述

指针在开头标有*

这解决了数组的两个缺点:

  1. 这些项目的长度不必相同 - 每个字符串可以是我们想要的长或短。
  2. 我们不需要足够的不间断空闲内存来将所有字符串彼此相邻存储-我们可以将每个字符串分开放置,只要RAM中有空间。

还记得每次读取时,内存控制器如何将附近内存地址的内容发送到处理器?处理器缓存它们?因此,读取RAM中的顺序地址更快,因为我们可以从缓存中获得大部分读取?
在这里插入图片描述
我们的原始数组非常适合缓存,因为一切都是顺序的。因此,从第0个索引读取,然后是第一个索引,然后是第二个等,从处理器缓存中获得额外的加速。

但是这个数组中的指针使得它不是缓存友好的。因为婴儿的名字随机散布在RAM周围,因此从第0个索引读取,然后从第1个索引等不会从缓存中获得额外的加速。

这种基于指针的数组需要更少的内存不间断,可容纳大小不相同的元素,但它的速度较慢,因为它不是缓存友好。

这种放缓并没有反映在Big O的时间成本中。


Dynamic arrays (List)

让我们构建一个非常简单的文字处理器。在我们的用户编写文本时,我们应该使用什么数据结构来存储文本呢?

字符串存储为数组,对吧?我们应该使用一个数组?

这就是棘手的问题。当我们用C或JAVA等低级语言分配数组时,我们必须预先指定我们希望数组有多少索引。

这是有原因的 - 计算机必须在内存中为数组保留空间,并承诺不让其他任何东西使用该空间。我们不能让其他程序覆盖我们数组中的元素!

计算机无法为单个阵列保留所有内存。所以我们必须告诉它要保留多少。

但是对于我们的文字处理器,我们提前知道用户的文档将持续多长时间!所以,我们能做些什么?

只需创建一个数组并对其进行编程,以便在空间不足时自行调整大小!这称为动态数组,它建立在普通数组之上。

Python,Ruby和JavaScript使用动态数组作为其默认的类数据结构。 在Python中,它们被称为“列表”。其他语言都有。例如,在Java中,数组是一个静态数组(我们必须提前定义其大小),而ArrayList是一个动态数组。

分配动态数组时,动态数组实现会生成基础静态数组。起始大小取决于实现(android一般list初始大小为16)- 假设我们的实现使用10个索引:

在这里插入图片描述
假设您将4个项目附加到动态数组中:
在这里插入图片描述

此时,我们的动态数组包含4项内容,它的长度为4,但是底层数组的长度为10.

我们说这个动态数组的大小为4,它的容量为10.
在这里插入图片描述
动态数组会存储一个end_index来记录动态数组的真正内容的结束和额外容量的开始
在这里插入图片描述
如果继续增加,某个时刻一会将底层数组填满
在这里插入图片描述
下次你再添加元素时,动态数组将会做下面几件事,使得它可以继续工作。

  1. 创建一个新的,更大的数组,通常是两倍于之前。
    为什么不扩展已经存在的数组呢?因为已存在的数组邻下的内存可能已经被占用,我们必须跳过这些被占用的内存,选择一块大小为20的连续的内存空间,用来存储我们新的数组。
    在这里插入图片描述
  2. 将旧数组的每个元素拷贝至新数组。
    在这里插入图片描述
  3. 释放旧的数组;这么做就是告诉操作系统,你可以用这块内存做其他事了(内存释放,回收)
    在这里插入图片描述
  4. 添加新元素
    在这里插入图片描述
    将项添加到数组通常是一个 时间操作,但单个加倍附加是一个时间操作,因为我们必须从数组中复制所有n个项目。
    动态数组优于数组的优点是您不必提前指定大小,但缺点是某些附加可能很昂。
    但是如果我们想要两全其美呢… …

Linked lists

我们的文本处理器肯定需要快速添加,文本添加对于文本处理器是最重要的一件事。

我们可以创建这样一个数据结构吗?可以存储一个字符串、可以快速添加、不需要你预先设定这个字符串的长度。

让我们首先关注如何不需要提前设定一个字符串的长度,还记得我们怎么使用指针存储宝贝的名字吗?

我们的字符串中每一个字符都是two-index array:

  1. 字符自己
  2. 指向下一个字符的指针
    在这里插入图片描述
    我们称每个包含两个元素的数组为节点,这一系列的节点组成了链表
    下面是在内存中的分配:
    在这里插入图片描述

注意,只要我们能找到内存中相邻的两个free的内存地址,就可以存放我们的节点,节点间没必要相邻,甚至在也没有顺序(地址排列上)。
在这里插入图片描述

但是它和指针一样,是缓存不友好的。

链表的第一个节点我们称为head,最后一个节点称为tail。

对于链表来说,拥有一个指向头节点的指针是很重要的一件事,,否则我们无法返回链表的开始的地方。

我们有时也拥有一个指向末节点的指针,这对于在链表末尾添加元素来说,非常的有帮助。
比如我们在链表中存放了“LOG”字符串:

在这里插入图片描述
然后我们要在末尾添加一个“S”,使之成为“LOGS”,我们该怎么做?

在这里插入图片描述

  1. 创建新的节点:“S”
  2. 获取末尾节点:“G”
  3. 将G的指针指向新的节点S
  4. 再将末尾节点指向节点S

linked lists have faster prepends ( time) than dynamic arrays ( time).
这些链接列表的快速附加和前缀来自链接列表节点可以在内存中的任何位置。它们不必像数组中的项一样紧挨着坐在一起。
因此,如果链接列表如此之大,为什么我们通常将字符串存储在数组中呢?因为数组有时间查找。那些恒定时间查找来自于所有数组元素在内存中彼此相邻排列的事实。
具有链表的查找更多是一个过程,因为我们无法知道第i个节点在内存中的位置。所以我们必须逐个节点地遍历链表,按照我们的方式计算,直到我们点击第i个项目。

链表也不是缓存友好的。

链表在,插入,追加,删除方便比动态数组来的更快,更合理;但它的查找速度较慢。

Hash tables

快速查找经常是非常重要的,因为这个原因,相交于链表来说,我们常常更倾向于使用数组。

举个例子,我们要统计Romeo and Juliet中每个字符出现的次数,我们该怎么存储这些计数?

我们可以使用数组,记住,字符也是数字,在ASCII中,‘A’是65,‘B’是66,依次类推。

所以我们可以用字符作为数组的索引,字符的数量作为数组对于索引的值。
在这里插入图片描述
拥有这样的数组,我们可以在恒定的时间内查找任何字符的数量,因为我们可以在恒定的时间内访问数组的任意一个索引。
一些有趣的事情发生了-这个数组不仅仅是个列表,这个数组存储了两个东西:字符和字符数量。索引代表了字符。

所以我们想象一个数组:拥有两列,其中一列始终为0,1,2,3,等等。
如果我们想在该列中添加任何值并可以快速查找,该怎么办?
将字符转换为数组索引很容易。但是我们必须做一些更聪明的事情来将一个单词(一个字符串)翻译成一个数组索引…
在这里插入图片描述
这是我们可以做到的一种方式:
获取每个字符的数字值并添加他们。
在这里插入图片描述
结果是429.但是如果我们的数组中只有30个插槽呢?我们将使用一种常用技巧将数字强制转换为特定范围:模数运算符(%)。将总和修改为30可确保得到小于30(且至少为0)的整数:

429 \:\%\:30 = 9
这将使我们从一个单词到数组索引。

此数据结构称为哈希表或哈希映射,在我们的哈希表中,计数是,而单词是(类似于数组中的索引),我们勇于将健转为为数组索引的过程称为散列函数
在这里插入图片描述

现代系统中使用 的散列函数变得相当复杂 - 我们在这里使用的是一个简化的例子。

请注意,我们的快速查找仅在一个方向上 - 我们可以快速获取给定键的值,但获取给定值的键的唯一方法是遍历所有值和键。
与数组相同 - 我们可以快速查找给定索引处的值,但是找出给定值的索引的唯一方法是遍历整个数组。

一个问题 - 如果两个键散列到我们数组中的同一个索引会怎样?看看“谎言”和“敌人”:

在这里插入图片描述它们总计达到429!所以当我们修改30时,他们当然会得到相同的答案:

429 \:\%\:30 = 9
因此,我们的哈希函数为“谎言”和“敌人”提供了相同的答案。这称为哈希冲突。处理它们有几种不同的策略。
这是一个常见的:不是将实际值存储在我们的数组中,而是让每个数组槽都保存一个指向链表的指针,该链表包含散列到该索引的所有单词的计数:

在这里插入图片描述
一个问题 - 我们如何知道哪些是“谎言”,哪个是“敌人”?要解决这个问题,我们将在每个链表节点中存储单词和计数

在这里插入图片描述
“可是等等!” 你可能会想,“现在在我们的哈希表中查找在最糟糕的情况下,因为我们必须走下链表。“这是真的!你甚至可以说在最坏的情况下,每个键都会产生一个哈希冲突,所以我们的整个哈希表会降级为一个链表。然而,在工业领域,我们通常会挥手并说冲突很少,平均在哈希表中进行查找时间。并且有一些花哨的算法可以保持较低的冲突次数并使链接列表的长度保持良好和简短。

但这就是哈希表的权衡。您可以通过密钥快速查找…除了一些查找可能很慢。当然,您只能在一个方向上获得快速查找 - 查找仍然需要的给定值的密钥 时间。

Summary

数组可以快速查找,但是你需要足够的连续的空闲内存空间,并且数组中的每个元素大小相等。

但是如果你的数组存储指向实际数组项的指针(就像我们对我们的婴儿名字列表所做的那样),你可以解决这两个缺点。您可以将每个数组项存储在RAM中的任何空间,并且数组项可以是不同的大小。权衡的是,现在你的数组速度较慢,因为它不是缓存友好的。

数组的另一个问题是你必须提前指定它们的大小。有两种方法可以解决这个问题:动态数组和链表。链接列表比动态数组具有更快的附加和预先添加,但动态数组具有更快的查找。

快速查找非常有用,特别是如果您不仅可以通过索引(0,1,2,3等)查找内容,还可以查看任意键(“谎言”,“敌人”…任何字符串)。这就是哈希表的用途。散列表的唯一问题是它们必须处理散列冲突,这意味着某些查找可能有点慢。

每个数据结构都有权衡。你无法拥有一切。

所以你必须知道你正在处理的问题中有什么重要。您的数据结构需要快速完成什么?它是按索引查找的吗?是附加还是预先?

一旦你知道什么是重要的,你就可以选择最好的数据结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值