第二章 数据结构基础
2.1 什么是数组
2.1.1 初识数组
数组是由有限个相同类型的变量所组成的有序集合, 数组中的每一个变量被称为元素。它的物理存储方式是顺序存储, 访问方式是随机访问。利用下标查找数组元素的时间复杂度是 O(1), 中间插入、删除数组元素的时间复杂度是 O(n)。
2.1.2 数组的基本概念
1. 创建一个数组
- 数组就是一个
[]
- 在
[]
里面存储着各种各样的数据,按照顺序依次排好
a. 字面量创建一个数组
直接使用 []
的方式创建一个数组
// 创建一个空数组
var arr1 = [];
// 创建一个有内容的数组
var arr2 = [1, 2, 3];
b. 内置构造函数创建数组
- 使用
js
的内置构造函数Array
创建一个数组
// 创建一个空数组
var arr1 = new Array();
// 创建一个长度为 10 的数组
var arr2 = new Array(10);
// 创建一个有内容的数组
var arr3 = new Array(1, 2, 3);
2. 数组的 length 属性
length
: 长度的意思length
就是表示数组的长度,数组里面有多少个成员,length
就是多少
// 创建一个数组
var arr = [1, 2, 3];
console.log(arr.length); // 3
3. 数组的索引
- 索引,也叫做下标,是指一个数据在数组里面排在第几个的位置
注意:在所有的语言里面,索引都是从 0 开始的
- 在
js
里面也一样,数组的索引从 0 开始
// 创建一个数组
var arr = ["hello", "world"];
上面这个数组中,第 0 个 数据就是字符串 hello
,第 1 个 数据就是字符串 world
- 想获取数组中的第几个就使用
数组[索引]
来获取
var arr = ["hello", "world"];
console.log(arr[0]); // hello
console.log(arr[1]); // world
4. 数组在内存中的存储方式
数组中的元素在内存中是连续存储的,且每个元素占用相同大小的内存。而不同类型的数组,每个元素所占的字节个数也不同。 总结: 物理存储方式是顺序存储, 访问方式是随机访问
2.1.3 数组的基本操作
1. 读取元素
由于数组在内存中顺序存储,所以只要给出一个数组下标,就可以读取到对应的数组元素。
注意,输入的下标必须在数组的长度范围之内,否则会出现数组越界
像这种根据下标读取元素的方式叫做随机读取。
let array = [3, 1, 2, 5, 4, 9, 7, 2];
// 输出数组中下标为3的元素
console.log(array[3]);
时间复杂度:O(1)
2. 查找元素
与读取元素类似,由于我们只保存了索引为 0 处的内存地址,因此在查找元素时,只需从数组开头逐步向后查找就可以了。如果数组中的某个元素为目标元素,则停止查找;否则继续搜索直到到达数组的末尾。
let array = [3, 1, 2, 5, 4, 9, 7, 2];
// 给数组元素下标为5的元素赋值
array[5] = 10;
// 输出数组中下标为5的元素
console.log(array[5]);
3. 更新元素
把数组中某个元素的值替换为一个新值,直接利用数组下标,把新值赋值给该元素。
let array = [3, 1, 2, 5, 4, 9, 7, 2];
// 给数组元素下标为5的元素赋值
array[5] = 10;
// 输出数组中下标为5的元素
console.log(array[5]);
时间复杂度:O(1)
4. 插入元素
a. 尾部插入
尾部插入,直接把插入的元素放在数组尾部的空闲位置即可。
b. 中间插入
由于数组的每一个元素都有其固定下标,所以不得不首先把插入位置及后面的元素向后移动,腾出地方,再把要插入的元素放到对应的数组位置上。
var MyArray = function (capacity) {
this.array = new Array(capacity);
this.size = 0; // 数组实际元素的数量
};
/**
* 数组插入元素
* @param element 插入的元素
* @param index 插入的位置
*/
MyArray.prototype.insert = function (element, index) {
//判断访问下标是否超出范围
if (index < 0 || index > this.size) {
throw new Error("超出数组实际元素范围!");
}
//从右向左循环,将元素逐个向右挪1位
for (let i = this.size - 1; i >= index; i--) {
this.array[i + 1] = this.array[i];
}
//腾出的位置放入新元素
this.array[index] = element;
this.size++;
};
/**
* 输出数组
*/
MyArray.prototype.output = function () {
for (let i = 0; i < this.size; i++) {
console.log(this.array[i]);
}
};
let myArray = new MyArray(10);
myArray.insert(3, 0);
myArray.insert(7, 1);
myArray.insert(9, 2);
myArray.insert(5, 3);
myArray.insert(6, 1);
myArray.output();// 3 6 7 9 5
c. 超范围插入
假如现在有一个长度为 6 的数组,已经装满了元素,这时还想插入一个新元素, 这就涉及数组的扩容。此时可以创建一个新数组,长度是旧数组的 2 倍,再把旧数组中的元素统统复制过去,这样就实现了数组的扩容。
var MyArray = function (capacity) {
this.array = new Array(capacity);
this.size = 0;
};
/**
* 数组插入元素
* @param element 插入的元素
* @param index 插入的位置
*/
MyArray.prototype.insert = function (element, index) {
//判断访问下标是否超出范围
if (index < 0 || index > this.size) {
throw new Error("超出数组实际元素范围!");
}
// 如果实际元素达到数组容量上限,则对数组进行扩容
if (this.size >= this.array.length) {
this.resize();
}
//从右向左循环,将元素逐个向右挪1位
for (let i = this.size - 1; i >= index; i--) {
this.array[i + 1] = this.array[i];
}
//腾出的位置放入新元素
this.array[index] = element;
this.size++;
};
/**
* 数组扩容
*/
MyArray.prototype.resize = function () {
let arrayNew = new Array(this.array.length * 2);
// 从旧数组复制到新数组
//遍历array数组,把该数组的元素全部赋值给arrayNew数组
for (let i = 0; i < this.array.length; i++) {
arrayNew[i] = this.array[i];
}
this.array = arrayNew;
};
/**
* 输出数组
*/
MyArray.prototype.output = function () {
for (let i = 0; i < this.size; i++) {
console.log(this.array[i]);
}
};
let myArray = new MyArray(4);
myArray.insert(3, 0);
myArray.insert(7, 1);
myArray.insert(9, 2);
myArray.insert(5, 3);
myArray.insert(6, 1);
myArray.output();
时间复杂度:O(n)
5. 删除元素
数组的删除操作和插入操作的过程相反,当我们删除掉数组中的某个元素后,数组中会留下空缺的位置,而数组中的元素在内存中是连续的,这就使得后面的元素需对该位置进行填补操作。如果删除的元素位于数组中间,其后的元素都需要向前挪动 1 位。
var MyArray = function (capacity) {
this.array = new Array(capacity);
this.size = 0;
};
/**
* 数组插入元素
* @param element 插入的元素
* @param index 插入的位置
*/
MyArray.prototype.insert = function (element, index) {
//判断访问下标是否超出范围
if (index < 0 || index > this.size) {
throw new Error("超出数组实际元素范围!");
}
//从右向左循环,将元素逐个向右挪1位
for (let i = this.size - 1; i >= index; i--) {
this.array[i + 1] = this.array[i];
}
//腾出的位置放入新元素
this.array[index] = element;
this.size++;
};
/**
* 数组删除元素
* @param index 删除的位置
*/
MyArray.prototype.delete = function (index) {
//判断访问下标是否超出范围
if (index < 0 || index >= this.size) {
throw new Error("超出数组实际元素范围!");
}
let deleteElement = this.array[index];
//从左向右循环,将元素逐个向左挪1位
for (let i = index; i < this.size - 1; i++) {
this.array[i] = this.array[i + 1];
}
this.size--;
return deleteElement;
};
/**
* 输出数组
*/
MyArray.prototype.output = function () {
for (let i = 0; i < this.size; i++) {
console.log(this.array[i]);
}
};
let myArray = new MyArray(10);
myArray.insert(3, 0);
myArray.insert(7, 1);
myArray.insert(9, 2);
myArray.insert(5, 3);
myArray.insert(6, 1);
myArray.delete(1);
myArray.output(); // 3 6 7 9 5 => 3 7 9 5
时间复杂度:O(n)
2.1.4 数组的优势和劣势
1. 优势
数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素。有一种高效查找元素的算法叫作二分查找,就是利用了数组的这个优势。
2. 劣势
体现在插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,影响效率。
总的来说,数组所适合的是读操作多、写操作少的场景。
2.1.5 二维数组
二维数组是一种结构较为特殊的数组,只是将数组中的每个元素变成了一维数组。
所以二维数组的本质上仍然是一个一维数组,内部的一维数组仍然从索引 0 开始,我们可以将它看作一个矩阵,并处理矩阵的相关问题。
示例
类似一维数组,对于一个二维数组 A = [[1, 2, 3, 4],[2, 4, 5, 6],[1, 4, 6, 8]],计算机同样会在内存中申请一段 连续 的空间,并记录第一行数组的索引位置,即 A[0][0] 的内存地址,它的索引与内存地址的关系如下图所示。
注意,实际数组中的元素由于类型的不同会占用不同的字节数,因此每个方格地址之间的差值可能不为 1。 实际题目中,往往使用二维数组处理矩阵类相关问题,包括矩阵旋转、对角线遍历,以及对子矩阵的操作等。
2.2 什么是链表
链表是一种链式数据结构, 由若干节点组成, 每个节点包含指向下一节点的指针。链表的物理存储方式是随机存储, 访问方式是顺序访问。查找链表节点的时间复杂度是 O(n), 中间插入、删除节点的时间复杂度是 O(1)。
2.2.1 什么是链表
链表(linked list)是一种在物理上非连续、非顺序的数据结构,由若干节点(node)所组成。
2.2.2 链表的分类
1. 单向链表
单向链表的每一个节点又包含两部分,一部分是存放数据的变量 data,另一部分是指向下一个节点的指针next
。
链表的第 1 个节点被称为头节点,最后 1 个节点被称为尾节点,尾节点的next
指针指向空。
2. 双向链表
双向链表比单向链表稍微复杂一些,它的每一个节点除了拥有 data 和next
指针,还拥有指向前置节点的 prev 指针。
2.2.3 链表在内存中的存储方式
链表采用了见缝插针的方式,链表的每一个节点分布在内存的不同位置,依靠next
指针关联起来。这样可以灵活有效地利用零散的碎片空间。
总结:物理存储方式是随机存储, 访问方式是顺序访问
2.2.4 链表的基本操作
1. 查找节点
在查找元素时,链表不像数组那样可以通过下标快速进行定位,只能从头节点开始向后一个一个节点逐一查找。 链表中的数据只能按顺序进行访问,最坏的时间复杂度是 O(n)。
2. 更新节点
如果不考虑查找节点的过程,链表的更新过程会像数组那样简单,直接把旧数据替换成新数据即可。
3. 插入节点
a. 尾部插入
把最后一个节点的next
指针指向新插入的节点即可。
b. 头部插入
第 1 步:把新节点的next
指针指向原先的头节点。
第 2 步:把新节点变为链表的头节点。
c. 中间插入
第 1 步:新节点的next
指针,指向插入位置的节点。
第 2 步:插入位置前置节点的next
指针,指向新节点。
4. 删除节点
a. 尾部删除
把倒数第 2 个节点的next
指针指向空即可。
b. 头部删除
把链表的头节点设为原先头节点的next
指针即可。
c. 中间删除
把要删除节点的前置节点的next
指针,指向要删除元素的下一个节点即可。
[TODO] blog.csdn.net/mischievous…
2.2.5 数组 VS 链表
查找 | 更新 | 插入 | 删除 | |
---|---|---|---|---|
数组 | O(1) | O(1) | O(n) | O(n) |
链表 | O(n) | O(1) | O(1) | O(1) |
从表格可以看出,数组的优势在于能够快速定位元素,对于读操作多、写操作少的场景来说,用数组更合适一些。 相反地,链表的优势在于能够灵活地进行插入和删除操作,如果需要在尾部频繁插入、删除元素,用链表更合适一些。
2.3 栈和队列
栈是一种线性逻辑结构, 可以用数组实现, 也可以用链表实现。栈包含入栈和出栈操作, 遵循先入后出的原则(FILO)。
2.3.1 物理结构和逻辑结构
如果把物质层面的人体比作数据存储的物理结构,那么精神层面的人格则是数据存储的逻辑结构。逻辑结构是抽象的概念,它依赖于物理结构而存在。
逻辑结构 | 物理结构 |
---|---|
线性结构 举例:顺序表、栈、队列 | 顺序存储结构 举例:数组 |
非线性结构 举例:树、图 | 链式存储结构 举例:链表 |
2.3.2 什么是栈
栈(stack)是一种线性数据结构,栈中的元素只能先入后出(First In Last Out,简称 FILO)。最早进入的元素存放的位置叫作栈底(bottom),最后进入的元素存放的位置叫作栈顶(top)。
2.3.3 栈的实现
栈这种数据结构既可以用数组来实现,也可以用链表来实现。
- 栈的数组实现如下:
- 栈的链表实现如下:
2.3.4 栈的基本操作
- 入栈
入栈操作(push)就是把新元素放入栈中,只允许从栈顶一侧放入元素,新元素的位置将会成为新的栈顶。
时间复杂度 O(1)
- 出栈
出栈操作(pop)就是把元素从栈中弹出,只有栈顶元素才允许出栈,出栈元素的前一个元素将会成为新的栈顶。
[TODO] blog.csdn.net/w626394316/… blog.csdn.net/liuxingyuza… 时间复杂度 O(1)
2.3.5 什么是队列
队列(queue)是一种线性数据结构,队列中的元素只能先入先出(First In First Out,简称 FIFO)。队列的出口端叫作队头(front),队列的入口端叫作队尾(rear)。
2.3.6 队列的实现
队列这种数据结构既可以用数组来实现,也可以用链表来实现。 用数组实现时,为了入队操作的方便,把队尾位置规定为最后入队元素的下一个位置。
队列的数组实现如下:
队列的链表实现如下:
2.3.7 队列的基本操作
- 入队
入队(enqueue)就是把新元素放入队列中,只允许在队尾的位置放入元素,新元素的下一个位置将会成为新的队尾。
- 出队
出队操作(dequeue)就是把元素移出队列,只允许在队头一侧移出元素,出队元素的后一个元素将会成为新的队头。
[TODO]blog.csdn.net/qq_38427709…
2.3.8 循环队列
在数组不做扩容的前提下,如何让新元素入队并确定新的队尾位置呢?我们可以利用已出队元素留下的空间,让队尾指针重新指回数组的首位。
这样一来,整个队列的元素就“循环”起来了。在物理存储上,队尾的位置也可以在队头之前。当再有元素入队时,将其放入数组的首位,队尾指针继续后移即可。
一直到(队尾下标+1)%数组长度 = 队头下标时,代表此队列真的已经满了。需要注意的是,队尾指针指向的位置永远空出 1 位,所以队列最大容量比数组长度小 1。
[TODO]blog.csdn.net/qq_41805715…
2.3.9 双端队列
双端队列这种数据结构,可以说综合了栈和队列的优点,对双端队列来说,从队头一端可以入队或出队,从队尾一端也可以入队或出队。
2.3.10 优先队列
谁的优先级最高,谁先出队。优先队列已经不属于线性数据结构的范畴了,它是基于二叉堆来实现的。
2.3.11 栈和队列的应用
- 栈的应用
栈的输出顺序和输入顺序相反,所以栈通常用于对“历史”的回溯,也就是逆流而上追溯“历史”。 例如实现递归的逻辑,就可以用栈来代替,因为栈可以回溯方法的调用链。 栈还有一个著名的应用场景是面包屑导航,使用户在浏览页面时可以轻松地回溯到上一级或更上一级页面。
- 队列的应用
队列的输出顺序和输入顺序相同,所以队列通常用于对“历史”的回放,也就是按照“历史”顺序,把“历史”重演一遍。 例如在多线程中,争夺公平锁的等待队列,就是按照访问顺序来决定线程在队列中的次序的。 再如网络爬虫实现网站抓取时,也是把待抓取的网站 URL 存入队列中,再按照存入队列的顺序来依次抓取和解析的。