这一章我们将讨论数据结构的运作,主要是数组和哈希表。我们会对比他们的不同,以及使用他们解决一些算法问题。
什么是数据结构
数据结构是很多值的集合,由算法来处理它们。
一句老话说得好: 数据结构+算法 = 程序
数据结构的核心是两个事情:构建什么样的数据结构以及如何使用它。当然如何使用它们更重要,因为大多数数据结构都是预先定义好的。 数据结构也有一些不同的类型,比如线性和非线性。当你研究的越深,它就越复杂。但是为了成为一个更好的开发者,你需要深入的理解它们。
我们稍微深入的研究一下数据和哈希表。 开始之前,我们需要知道一些编程语言有一些特殊的数据类型是其他语言中没有的。比如JS中没有栈,我们需要自己去实现。
操作数据结构
一般的数据操作有这些:
- 遍历:
- 插入:
- 删除:
- 搜索:
- 访问:
数组
数组中的元素必须是同一种数据类型,比如都是整数,字符或者其他,但是不能混用。(JS中的数组没有这个限定)。 最简单的数组是一维数组,复杂的会有多维的数组。数组更加流行的原因是,在运行时非常容易获取索引,在迭代或者写入。 让我们看一些操作数组的例子:
const names = ['Victor', 'Alex', 'Polya', 'Ugochi'];
// Adds to the array above
names.push('John')
// [ 'Victor', 'Alex', 'Polya', 'Ugochi', 'John' ]
// Removes from the array above
names.pop()
// [ 'Victor', 'Alex', 'Polya', 'Ugochi' ]
// Adds element to the first index of the array above
names.unshift('Lawrence')
//[ 'Lawrence', 'Victor', 'Alex', 'Polya', 'Ugochi' ]
// Adds element after the second index of the array
names.splice(1, 0, 'Jonah')
// [ 'Lawrence', 'Jonah', 'Victor', 'Alex', 'Polya', 'Ugochi' ]
console.log(names)
我们也可以将数组区分为静态数组和动态数组。
静态数组
静态数组是指在编译阶段就确定了长度的数组,写代码的时候就要声明数组的长度。这种数组一般使用在低级语言中,比如C++:
int a[4];
int b[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}
如果我们想给数组添加新的值,我们就必须创建一个新数组然后重新声明它的长度。人们说,C++之所以快,就是它从不创建无用的内存。
动态数组
当你给动态数据添加值的时候会扩展,比如原来的数组是5个元素,你添加了5个,它的尺寸就变成10个了。
注意,每次添加数据实际都是删除了旧数组,然后创建了一个新的数组,并把旧数组的内容复制过来了。这个过程会有点耗时,所以要注意它。
实现
我们在JS中实现一个数组。首先,我们创建一个类,它的构造函数创建两个变量:数组的长度和数组本身。
class CustomArray {
constructor() {
this.data = {},
this.length = 0;
}
// getting the element of the array using the index
access(index) {
return this.data[index]
}
}
第一个方法是如何访问数组中的元素。
const newArray = new CustomArray()
console.log(newArray.access(0)) // undefined
数组是空的,所以现在的输出是undefined
然后我们添加一个push
方法,给数组加数据
class CustomArray {
constructor() {
this.data = {},
this.length = 0;
}
// getting the element of the array using the index
access(index) {
return this.data[index];
}
// add elements to the array
push(value) {
this.data[this.length] = value;
this.length++;
return this.length;
}
}
我们实现的结果大概是这样:
const newArray = new CustomArray()
newArray.push('first item');
newArray.push('second item');
newArray.push('third item');
console.log(newArray)
// CustomArray {
data: { '0': 'first item', '1': 'second item', '2': 'third item' },
length: 3
}
另一个方法是pop
,它将会删除数组中最后一个元素:
class CustomArray {
constructor() {
this.data = {},
this.length = 0;
}
// getting the element of the array using the index
access(index) {
return this.data[index];
}
// add elements to the array
push(value) {
this.data[this.length] = value;
this.length++;
return this.length;
}
// remove the last item in the array
pop() {
const lastValue = this.data[this.length - 1];
delete this.data[this.length - 1];
this.length--;
return lastValue;
}
}
结果是这样子的:
const newArray = new CustomArray()
newArray.push('first item');
newArray.push('second item');
newArray.push('third item');
newArray.pop();
console.log(newArray)
// CustomArray {
data: { '0': 'first item', '1': 'second item' },
length: 2
}
Hash Tables
哈希表中的数据,具有键和值,每个键映射一个值。 不同的编程语言,具有不同的哈希表。Python中是字典,Ruby中是Hashes,Java中是Maps.这是使用最多的一种数据结构,经常用来做缓存和数据库索引。
There is also Map()
which was introduced in 2015 and is regarded as a Hashmap.
JS在2015年引入了Map()
,它是一个HashMap。哈希表和Hashmap 都提供键/值的功能,只是有一些轻微的不同。Hashmap的键可以是任意数据类型,而哈希表的键只能是数字或者字符串。因此,Hashmaps也不能被JSON化。
我们使用JS实现一下。
Hash 函数
这是一个简单的函数,为每一个输入的值生成一个固定长度的值。它的返回值被称为hash值。简单的说,就是hash函数为我们生成一个哈希表中的索引。我们给哈希函数传递对象和值,然后它会决定将其放置在内存的哪里
Hash集合
但是有的时候,哈希函数在决定存储位置的时候,可能会把不同的值放在同一个位置上,这时候就需要一个集合。但是理论上,一个位置应该只有一个值。
看下图,George 和 Cory 的索引就冲突了:
实现
我们先创建一个类,类的第一个方法就是哈希函数。然后在构造函数中创建一个数组。
class HashTable {
constructor(size) {
this.data = new Array(size);
}
}
然后写一个哈希函数,它将返回一个哈希值,这个哈希值是数据被存储的位置。我们会用一种比较简单的方式实现哈希函数,一个可靠稳定的哈希函数会复杂的多,但是已经超出了我们本章的范围。
class HashTable {
constructor(size) {
this.data = new Array(size);
}
hashFunction(value) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash + value.charCodeAt(i) * i) % this.data.length;
console.log(hash);
}
return hash;
}
}
这个简单的哈希函数做了这些事情:
- 获取传入的值
- 遍历它的所有字符
- 为每一个字符返回一个字符编码
- 让编码值与索引相乘
- 将其与初始化的hash值相加
- 再将最后的值与数组的长度求余,这样就不会访问越界
运行一下试试:
const hashTable = new HashTable(4);
hashTable.hashFunction('Hey')
// 3
接下来看set
方法,它有两个属性:键和值
class HashTable {
constructor(size) {
this.table = new Array(size);
}
hashFunction(value) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash + value.charCodeAt(i) * i) % this.table.length;
}
return hash;
}
// add items to the hash HashTable
set(key, value) {
let memoryLocation = this.hashFunction(key);
if (!this.table[memoryLocation]) {
this.table[memoryLocation] = [];
}
this.table[memoryLocation].push([key, value]);
return this.table;
}
}
我们的键属性被函数函数使用。如果对应的内存位置不存在,我们就再这个位置生产一个新数组,并把键和值都放进去。
const hashTable = new HashTable(4);
hashTable.set('Victor', 24)
// [ <1 empty item>, [ [ 'Victor', 24 ] ], <2 empty items> ]
下一步,我们写一个方法,它能返回我们已经放置好的数据。这个是相对容易的,看一下我们的getItem
方法
class HashTable {
constructor(size) {
this.table = new Array(size);
}
hashFunction(value) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash + value.charCodeAt(i) * i) % this.table.length;
}
return hash;
}
// add items to the hash HashTable
set(key, value) {
let memoryLocation = this.hashFunction(key);
if (!this.table[memoryLocation]) {
this.table[memoryLocation] = [];
}
this.table[memoryLocation].push([key, value]);
return this.table;
}
// get items to the hash HashTable
getItems(key) {
let memoryLocation = this.hashFunction(key);
if (!this.table[memoryLocation]) return null;
return this.table[memoryLocation].find((x) => x[0] === key)[1];
}
}
这只是一个简单的实现,还需要一个异常检查,以防止数据不存在,这个读者自己来实现吧。
哈希表和数组的对比
从上面的描述中,我们应该已经注意到了两者的差异。哈希表在查找的时候更快。如果使用数组,你就必须遍历所有的元素,而哈希表中你可以直接找到值存储的位置。哈希表中插入元素也会更快,因为你只需要哈希一下键,然后插入就可以了。在数组中,在插入之前你需要移动元素。下图给出了这些行为的时间复杂度对比
在为各种任务选择合适的数据结构时要非常小心,尤其是面临对性能有要求的任务。 O(n) 的查找复杂度在实时并且具有大量数据的应用时,会非常的难用。 即使你觉得已经选择了合适的结构,也很有必要去验证它是真的合适。