简易数据结构
今天我们将要了解并学习数据结构。
"OOooooOOOooh *soo* exciting" right?
没错,它不是最有内容的话题,但它很重要。并非为了向计算机输入奇怪的101,而是让自己成为一名
更好的程序员。
理解数据结构可以让你:
- 管理复杂的系统,并使你的代码更易维护。
- 构建高性能,高效率的应用。
我认为前者更重要一些。使用正确的数据结构可以大幅简化本来很复杂的逻辑。
当然后者同样重要。如果关心性能和内存,应用正确的数据机构比通常的解决方案更重要。
为了了解数据结构,我们准备尝试一起实现它们中的一部分。别怕,我们将保持代码的简洁。
实际上,注释要比代码多。
1 | /** |
什么是数据结构?
实际上,他们是一些不同的存储和组织数据的方法,供若干不同的需求使用。
数据总可以以很多不同的方式表现。然而,基于数据的具体内容与它的用途,总有一种表现方式更优一些。
想要理解为什么,让我们先简单聊一聊算法。
算法
1 | /*** ===================================================================== ***\ |
算法,是一系列操作被一步步执行的美称。
数据结构由算法实现,而算法又基于数据结构。数据结构与算法一直往下,直到你看见一群使用打
孔卡操作计算机的小人儿。(额- Intel奴役这这群小人儿。 醒醒别睡了!!)
任何任务都可以有无数的实现方法。所以对于相同的任务,人们会想出很多不同的算法。
比如,有非常庞大的数量的算法可以用来给乱序的事物排序:
Insertion Sort(插入排序), Selection Sort(选择排序), Merge Sort(归并排序),
Bubble Sort(冒泡排序), Heap Sort(堆排序), Quick Sort(快速排序),
Shell Sort(希尔排序), Timsort, Bucket Sort(桶排序), Radix Sort(基数排序), ...
它们之间有一些明显比其他的速度快。有一些使用更少的内存。有一些更容易实现。
有一些基于数据集的某些前提。
任何一个都比其他的在 某方面 有优势。所以你需要根据你的需求做选择,因此你需要一个考量和
比较算法的方法。
我们使用对算法的平均与最差性能进行粗略测量,来比较算法的性能。它被称为”大O”。
大O 符号
1 | /*** ===================================================================== ***\ |
大O 符号是粗略测量算法性能的一种方法,以至于可以在讨论的时候对两个算法进行比较。
大O 是计算机科学中借来的一个数学符号。根据算法对给予它元素的数目(N)而表现结果,将算法分类。
你主要可以用大O测量两个东西:
时间复杂度 表示对于给予一系列元素(N),算法需要操作的总次数。
空间复杂度 表示对于给予一系列元素(N),算法运行所需要的总内存。
我们在在对比其中一项指标是一定要独立于另一个。因为一个算法可能比另一个需要的操作次数少。
它通常需要更多的内存。依照你的需求,选出更好的那一个。
这是一些常见的 大O:
名称 | 符号 | 当用到它时你的感觉 |
---|---|---|
常数 | O(1) | 爽!! |
对数 | O(log N) | 不错哦! |
线性 | O(N) | 还行. |
对数线性 | O(N log N) | 额… |
平方 | O(N ^ 2) | 不好 |
指数 | O(2 ^ N) | 可怕 |
阶乘 | O(N!) | 艹! |
为了给大家一个具体的印象,具体的需要进行多少次操作。我们看一下具体元素数(N)对应的操作数。
N | 5 | 10 | 20 | 30 |
---|---|---|---|---|
O(1) | 1 | 1 | 1 | 1 |
O(log N) | 1.6094… | 2.3025… | 2.9957… | 3.4011… |
O(N) | 5 | 10 | 20 | 30 |
O(N log N) | 8.0471… | 23.0258… | 59.9146… | 102.0359… |
O(N ^ 2) | 25 | 100 | 400 | 900 |
O(2 ^ N) | 32 | 1024 | 1,048,576 | 1,073,741,824 |
O(N!) | 120 | 3,628,800 | 2,432,902,0… | 265,252,859,812,191,058,636,308,480,000,000 |
正如你看到的,即使对于较小的数据集你也可能做很多额外的工作。
通过数据结构,你可以进行四种主要的操作:
访问,查询,插入,和删除。
需要提醒的是,数据结构可能在某方面表现不错,但在另一方面表现很差。
Accessing | Searching | Inserting | Deleting | |
---|---|---|---|---|
数组 | O(1) | O(N) | O(N) | O(N) |
链表 | O(N) | O(N) | O(1) | O(1) |
二叉搜索树 | O(log N) | O(log N) | O(log N) | O(log N) |
或者…
Accessing | Searching | Inserting | Deleting | |
---|---|---|---|---|
数组 | 爽!! | 还行! | 还行! | 还行! |
链表 | 还行! | 还行! | 爽!! | 爽!! |
二叉搜索树 | 不错哦! | 不错哦! | 不错哦! | 不错哦! |
设置,一些操作可能会有不同的”平均”性能和”最差”性能。
没有最完美的数据结构,你选择某个数据结构应基于你正在做的东西,和你将要做的事情。
这也是为什么了解一系列不同的常见数据结构很重要。你可以从中选择你需要的。
内存
1 | /*** ===================================================================== ***\ |
计算机的内存很无聊,它只是一堆可以存放信息的有序的插槽。根据内存地址就可以找到信息。
让我们把一块内存想象成这个样子:
值: |1001|0110|1000|0100|0101|1010|0010|0001|1101|1011...
地址: 0 1 2 3 4 5 6 7 8 9 ...
如果你曾经有过为什么在编程语言里索引是以0开始的疑问,就是因为内存工作方式的原因。
如果你想读取内存第一块,你需要从0读到1,第二块需要从1读到2。所以你取得的地址分别为0和1。
你的计算机有比这多很多的内存,而且只是上面模式的延续。
内存和狂野的西部有些相像,运行在你电脑上的每个程序都将数据保存在相同的物理的数据结构里。
如果不将它一层层的抽象,它将极其难用。
但是这些抽象服务于两个额外的目的:
- 将数据存在内存中,使得调用时更高效、更快速。
- 将数据存在内存中,使得更易用。
LIST
1 | /*** ===================================================================== ***\ |
为了演示内存和数据结构之间的相互作用。我们首先准备实现一个LIST。
LIST表示一个有序序列,它的值允许重复。
1 | class List { |
LIST的优势在于快速访问,和操作最后一个元素。然而正如我们看到的,它并不擅长处理非结尾位置的
元素,而且我们需要手动维护地址。
所以,让我们来考一下不同的数据结构,以及它们如何处理添加,访问和无须地址删除值。
HASH TABLES(哈希表)
1 | /*** ===================================================================== ***\ |
哈希表是一个无序的数据结构。它拥有”keys” 和 “values”,我们可以根据key计算
内存中的地址。
基本思想是,我们操作的keys是可以”哈希化(hashable)”的 (我们很快会讲到),并且可以非常高效地
在内存中新增,访问,删除。
var hashTable = new HashTable();
hashTable.set('myKey', 'myValue');
hashTable.get('myKey'); // >> 'myValue'
1 | class HashTable { |
1 | /** |
从此往后,我们将不再与内存直接接触,因为剩下的数据结构开始基于其他数据结构而实现。
这些数据结构致力于两件事情:
- 根据使用方式组织数据。
抽象掉实现详情。
这些数据结构致力于建立一个结构用于各式各样的程序。它们增加了一些特定的语法,可以让你讨论更加
复杂的逻辑。它们都将具体的实现逻辑抽象,所以它们可以改变实现逻辑从而变得更快。
STACKS(栈)
1 | /*** ===================================================================== ***\ |
栈与LIST有些像,因为它们都是有序的,但是栈限制你只能在结尾处“push”或“pop”值,正是我们
曾看到的直接映射内存时非常快的操作。
然而,为了给栈增加功能,栈也可以使用其他数据结构实现。
栈最常见的用途是,一个进程向栈中增加元素,另个进程删除最近添加的元素。
1 | class Stack { |
QUEUES(队列)
1 | /*** ===================================================================== ***\ |
接下来,我们准备实现队列,它与栈类似,区别是它从队列头部删除元素而非头部。
删除最早的元素而非最新的元素。
同样,因为有限的功能,队列同样有很多不同的实现方式。使用链表实现或许是一个好方法,之后
会为大家介绍。
1 | class Queue { |
需要强调的是,由于我们的队列是基于LIST实现的。所以也继承了”shift”的性能,
线性复杂度 O(N) “还行.”
之后会为大家介绍链表(linked list),它可以让我们实现一个更快的队列。
1 | /** |
从此往后,我们将开始操作数据结构,一个数据结构的值引用另一个数据结构。
+- Data Structure ---------------------------------------+
| +- Item A ---------------+ +- Item B ---------------+ |
| | Value: 1 | | Value: 2 | |
| | Reference to: (Item B) | | Reference to: (Item A) | |
| +------------------------+ +------------------------+ |
+--------------------------------------------------------+
数据结构的值将会是属于它自己的更小的数据结构,其中包含了一些附加的信息,包括指向大数据结构中
其他元素的引用。
你将很快明白我在说什么。
GRAPHS(图)
1 | /*** ===================================================================== ***\ |
与上面的字符图不同,图并不是某种类型的可视化图表。
而是把它想象成这个样子:
A –→ B ←–––– C → D ↔ E
↑ ↕ ↙ ↑ ↘
F –→ G → H ← I ––––→ J
↓ ↘ ↑
K L
我们有一堆用线彼此相连的“节点(node)” (A, B, C, D, …)。
这些节点看上去是这样的:
1 | Node { |
整个图,看上去是这样的:
1 | Graph { |
1 | class Graph { |
最后你可以这样使用图:
1 | var graph = new Graph(); |
这看起来或许费了很多功夫做了很少的事情,但是它其实是一个很强大的模式,尤其是在复杂
程序中查找想要的东西时。
它们通过优化数据间的联系实现,而非操作数据本身。如果你知道图中有某个节点,那就很容易找到
图中与它有关系的所有元素了。
太多东西都可以用这种方式抽象,好友圈,node_modules文件夹中的传递依赖,因特网本身也是
通过链接将网页连接到一起的图。
LINKED LISTS(链表)
1 | /*** ===================================================================== ***\ |
下面,将为大家介绍类-图结构怎样帮助优化数据列表的。
Linked lists(链表)是非常常见的数据结构,由于它向开头,中间及结尾插入元素都非常的高效,
所以它常常被用与实现其他的数据结构。
链表的基础理念和图非常像。一个节点指向另一个节点,它看上去大概是这样的:
1 -> 2 -> 3 -> 4 -> 5
将其可视化为类JSON结构,看上去像这个样子:1
2
3
4
5
6
7
8
9
10{
value: 1,
next: {
value: 2,
next: {
value: 3,
next: {...}
}
}
}
1 | class LinkedList { |
1 | /** |
我们准备了解的剩下的两个数据结构都属于”tree(树)”家族。
和现实中一样,有很多不同种类的树的数据结构。(译者:恕我无能)
Binary Trees:
AA Tree, AVL Tree, Binary Search Tree, Binary Tree, Cartesian Tree,
left child/right sibling tree, order statistic tree, Pagoda, ...
B Trees:
B Tree, B+ Tree, B* Tree, B Sharp Tree, Dancing Tree, 2-3 Tree, ...
Heaps:
Heap, Binary Heap, Weak Heap, Binomial Heap, Fibonacci Heap, Leonardo
Heap, 2-3 Heap, Soft Heap, Pairing Heap, Leftist Heap, Treap, ...
Trees:
Trie, Radix Tree, Suffix Tree, Suffix Array, FM-index, B-trie, ...
Multi-way Trees:
Ternary Tree, K-ary tree, And-or tree, (a,b)-tree, Link/Cut Tree, ...
Space Partitioning Trees:
Segment Tree, Interval Tree, Range Tree, Bin, Kd Tree, Quadtree,
Octree, Z-Order, UB-Tree, R-Tree, X-Tree, Metric Tree, Cover Tree, ...
Application-Specific Trees:
Abstract Syntax Tree, Parse Tree, Decision Tree, Minimax Tree, ...
你肯定没想到,今天你会在这里学习树木学……而且那还不是所有的树。但是不要害怕,它们中的大多数
并不重要。它们只是很多计算机科学博士需要证明一些东西的时候用到的。
树对于图还有链表在除了它是“单向的”方面,其他方面都很像。也就是说,它不会有引用循环。
1 | 树: 非树: |
如果你在树的节点之间建立了循环的联系,那么它就不再是树了。
树有很多用处,它可以用于优化搜索和排序。它可以让你更好的组织程序。它可以为你提供更易工作的
形式。
TREES(树)
1 | /*** ===================================================================== ***\ |
我们从一个非常简单的树结构起步。它没有任何特殊的规则,看起来是这样的:1
2
3
4
5
6
7
8
9
10
11
12Tree {
root: {
value: 1,
children: [{
value: 2,
children: [...]
}, {
value: 3,
children: [...]
}]
}
}
1 | class Tree { |
这是一个最基础的树,它可能只有在你想代表的数据酷似树的时候才有用。
但是,添加一些额外的规则,树可以为很多不同的需求服务。
BINARY SEARCH TREES(二叉搜索树)
1 | /*** ===================================================================== ***\ |
二叉搜索树是树的一个非常普遍的方式,因为它可以高效的访问,搜索,插入以及删除值,同时
保持它们有序。
假如有这么一串数字:
1 2 3 4 5 6 7
让后将其转换为从中间开始的树。
1 | 4 |
二叉树是这样工作的。每个节点有两个子节点:
- 左节点: 比父节点的值小。
右节点: 比父节点的值大。
提示: 树中的值必须唯一。
这使得遍历查值使非常的有效率。比如我们想要找到树种的5:
1 | (4) <--- 5 > 4, 向右. |
需要强调的是,我们只需要3此判断就找到了5。如果我们将这个树扩充到1000个元素。
我们需要:
500 -> 250 -> 125 -> 62 -> 31 -> 15 -> 7 -> 3 -> 4 -> 5
1000个元素仅需10次判断!
另一个,二叉搜索树在删除或新增值时和链表很像,你只需更新它周围的元素就可以。
1 | class BinarySearchTree { |
YOU REACHED THE END!
1 | /*** ===================================================================== ***\ |
可能内容有些多,但我希望它对你有帮助。如果你喜欢这些内容,欢迎star项目
或者粉我的twitter (@thejameskyle)?
你也可以看一下我的另一篇文章 “The Super Tiny Compiler”
here ——> https://github.com/thejameskyle/the-super-tiny-compiler