JavaScript数据结构与算法002|数据结构基础

47 阅读13分钟

第二章 数据结构基础

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 二维数组

二维数组是一种结构较为特殊的数组,只是将数组中的每个元素变成了一维数组。

image.png

所以二维数组的本质上仍然是一个一维数组,内部的一维数组仍然从索引 0 开始,我们可以将它看作一个矩阵,并处理矩阵的相关问题。

示例 类似一维数组,对于一个二维数组 A = [[1, 2, 3, 4],[2, 4, 5, 6],[1, 4, 6, 8]],计算机同样会在内存中申请一段 连续 的空间,并记录第一行数组的索引位置,即 A[0][0] 的内存地址,它的索引与内存地址的关系如下图所示。 image.png

注意,实际数组中的元素由于类型的不同会占用不同的字节数,因此每个方格地址之间的差值可能不为 1。 实际题目中,往往使用二维数组处理矩阵类相关问题,包括矩阵旋转、对角线遍历,以及对子矩阵的操作等。

2.2 什么是链表

链表是一种链式数据结构, 由若干节点组成, 每个节点包含指向下一节点的指针。链表的物理存储方式是随机存储, 访问方式是顺序访问。查找链表节点的时间复杂度是 O(n), 中间插入、删除节点的时间复杂度是 O(1)。

2.2.1 什么是链表

链表(linked list)是一种在物理上非连续、非顺序的数据结构,由若干节点(node)所组成。

2.2.2 链表的分类

1. 单向链表

单向链表的每一个节点又包含两部分,一部分是存放数据的变量 data,另一部分是指向下一个节点的指针next。 链表的第 1 个节点被称为头节点,最后 1 个节点被称为尾节点,尾节点的next指针指向空。 image.png

2. 双向链表

双向链表比单向链表稍微复杂一些,它的每一个节点除了拥有 data 和next指针,还拥有指向前置节点的 prev 指针。 image.png

2.2.3 链表在内存中的存储方式

链表采用了见缝插针的方式,链表的每一个节点分布在内存的不同位置,依靠next指针关联起来。这样可以灵活有效地利用零散的碎片空间。 总结:物理存储方式是随机存储, 访问方式是顺序访问

2.2.4 链表的基本操作

1. 查找节点

在查找元素时,链表不像数组那样可以通过下标快速进行定位,只能从头节点开始向后一个一个节点逐一查找。 链表中的数据只能按顺序进行访问,最坏的时间复杂度是 O(n)。

2. 更新节点

如果不考虑查找节点的过程,链表的更新过程会像数组那样简单,直接把旧数据替换成新数据即可。 image.png

3. 插入节点

a. 尾部插入

把最后一个节点的next指针指向新插入的节点即可。 image.png

b. 头部插入

第 1 步:把新节点的next指针指向原先的头节点。 第 2 步:把新节点变为链表的头节点。 image.png

c. 中间插入

第 1 步:新节点的next指针,指向插入位置的节点。 第 2 步:插入位置前置节点的next指针,指向新节点。 image.png

4. 删除节点

a. 尾部删除

把倒数第 2 个节点的next指针指向空即可。 image.png

b. 头部删除

把链表的头节点设为原先头节点的next指针即可。 image.png

c. 中间删除

把要删除节点的前置节点的next指针,指向要删除元素的下一个节点即可。 image.png

[TODO] blog.csdn.net/mischievous…

blog.csdn.net/WEXIA666/ar…

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 栈的实现

栈这种数据结构既可以用数组来实现,也可以用链表来实现。

  1. 栈的数组实现如下:

image.png

  1. 栈的链表实现如下:

image.png

2.3.4 栈的基本操作

  1. 入栈

入栈操作(push)就是把新元素放入栈中,只允许从栈顶一侧放入元素,新元素的位置将会成为新的栈顶。

image.png

时间复杂度 O(1)

  1. 出栈

出栈操作(pop)就是把元素从栈中弹出,只有栈顶元素才允许出栈,出栈元素的前一个元素将会成为新的栈顶。

image.png

[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 队列的实现

队列这种数据结构既可以用数组来实现,也可以用链表来实现。 用数组实现时,为了入队操作的方便,把队尾位置规定为最后入队元素的下一个位置。

队列的数组实现如下:

image.png

队列的链表实现如下:

image.png

2.3.7 队列的基本操作

  1. 入队

入队(enqueue)就是把新元素放入队列中,只允许在队尾的位置放入元素,新元素的下一个位置将会成为新的队尾。

image.png

  1. 出队

出队操作(dequeue)就是把元素移出队列,只允许在队头一侧移出元素,出队元素的后一个元素将会成为新的队头。

image.png

[TODO]blog.csdn.net/qq_38427709…

2.3.8 循环队列

在数组不做扩容的前提下,如何让新元素入队并确定新的队尾位置呢?我们可以利用已出队元素留下的空间,让队尾指针重新指回数组的首位。

image.png

这样一来,整个队列的元素就“循环”起来了。在物理存储上,队尾的位置也可以在队头之前。当再有元素入队时,将其放入数组的首位,队尾指针继续后移即可。

image.png

一直到(队尾下标+1)%数组长度 = 队头下标时,代表此队列真的已经满了。需要注意的是,队尾指针指向的位置永远空出 1 位,所以队列最大容量比数组长度小 1。

image.png

[TODO]blog.csdn.net/qq_41805715…

2.3.9 双端队列

双端队列这种数据结构,可以说综合了栈和队列的优点,对双端队列来说,从队头一端可以入队或出队,从队尾一端也可以入队或出队。

image.png

2.3.10 优先队列

谁的优先级最高,谁先出队。优先队列已经不属于线性数据结构的范畴了,它是基于二叉堆来实现的。

image.png

2.3.11 栈和队列的应用

  1. 栈的应用

栈的输出顺序和输入顺序相反,所以栈通常用于对“历史”的回溯,也就是逆流而上追溯“历史”。 例如实现递归的逻辑,就可以用栈来代替,因为栈可以回溯方法的调用链。 栈还有一个著名的应用场景是面包屑导航,使用户在浏览页面时可以轻松地回溯到上一级或更上一级页面。

  1. 队列的应用

队列的输出顺序和输入顺序相同,所以队列通常用于对“历史”的回放,也就是按照“历史”顺序,把“历史”重演一遍。 例如在多线程中,争夺公平锁的等待队列,就是按照访问顺序来决定线程在队列中的次序的。 再如网络爬虫实现网站抓取时,也是把待抓取的网站 URL 存入队列中,再按照存入队列的顺序来依次抓取和解析的。