写在前面:本文为个人读书笔记,其间难免有一些个人不成熟观点,也难免有一些错误,慎之

前文链接:
算法笔记 -【复杂度分析】
算法笔记 -【复杂度分析<续>】

何为线性表?
线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前、后两个方向
常见的线性表结构的数据结构有:

  • 数组
  • 链表
  • 队列

下面一一做一下简单的总结

数组

概念

数组是一种线性表数据结构,使用内存中一组连续的空间存储相同数据类型的数据
注意其概念中线性表、连续、相同数据类型
盗个图:
timg.jpg
鉴于数组的特性,数组适合哪些操作或者适合哪些场景呢?

数组的优势

随机访问

数组在内存中就是一段连续的空间,而且每一项的数据类型相同也就意味着每一项所占用的内存空间一样,这样要访问数组的某一项i就可以很容易得通过公式 base_address + data_type_size * i 计算出内存地址,直接读取,通过复杂度分析的方法对整个访问过程进行复杂度分析可知:数组访问的复杂度为O(1)
注:这里的访问是类似a[i]这样对数组项的访问取值,并非从数组中查找某一项

数组的劣势

插入操作

设想我们在数组a的第k个位置插入一项x,来分析一下
最好情况:k为数组末尾,则直接插入即可,复杂度为O(1)
最坏情况:k为数组首位,则需要将数组原有的n个元素向后挪动一位,然后将x插入,复杂度为O(n)
一般情况:根据期望平均复杂度的分析方法,很容易得出复杂度为:O(n)

如果数组是一个有序的集合,那必须按照以上方法进行插入,但是如果数组只是作为一个存储集合,是无序的,则可以按照这样的方法:
a中第k个元素移动到末尾,然后将x放在位置k,这样可以大大降低复杂度(此时复杂度为O(1)

删除操作

设想我们删除数组a的第k个位置的元素
最好情况:k为数组末尾,则直接删除,复杂度为O(1)
最坏情况:k为数组首位,则需要将数组原有的n个元素向前挪动一位,复杂度为O(n)
一般情况:根据期望平均复杂度的分析方法,很容易得出复杂度为:O(n)

如果某些场景不追求数组的连续性,则可以先记录下a中哪些项已被删除,做一个标记,并不真正删除,这样就不需要对数组的元素进行搬移,直到数组的空间不足或者需要使用到被标记删除的空间才进行真正删除,将之前被标记的项删除,并将数组项移动到正确位置,这样就可以将多次删除/搬移数组项的操作合并到一次进行操作,从而可以提升性能

(上面提到的优化过程是不是很像v8引擎的标记清除的策略?)

javascript中的“数组”

前文已对数组做了浅显介绍,但具体到javascript中呢?
javascript中的数组跟其他编程语言的数组是一样的吗?

以下内容多为其他文章参考和测试,因为没有结合引擎源码(而且不同引擎的实现存在差异),所以不甚严谨和权威

javascript数组的概念

数组是值的集合,可以是动态的,可以是稀疏的,是javascript对象的特殊形式,数组元素可以是任意类型(参考《javascript权威指南》)

其实javascript数组除了名字叫数组好像和“真正”的数组(或者说其他语言的数组)没什么关系。

对于javascript开发者来说,了解常规数组有什么意义呢?
其实很好理解,javascript代码最终是要引擎实现的,了解常规数组的优/劣势,对于优化javascript代码有很重要的意义

v8引擎中javascript数组的实现

其实叫这个标题很慌,因为并没有真正详细得阅读过v8源码,不论如何,市面上非常多的参考文章结合实际测试还是很有参考意义的

引擎创建并处理js数组有三种模式

  1. Fast Elements 模式(快速模式)
  2. Fast Holey Elements 模式(快速空洞模式)
  3. Dictionary Elements 模式(字典模式)

引擎默认使用Fast Elements模式构建数组,这种模式最快速,
如果数组中存在空值(稀疏数组),将转为Fast Holey Elements模式,该模式下没有赋值的位置将被存储一个特殊的值,这样在访问该位置时将得到undefined,
如果数组中保存的值为特殊类型的值(如对象等),将转为Dictionary Elements模式
以上三种模式的特点在以下会有介绍

Fast Elements 模式

对于新创建的新数组,引擎会默认使用该模式,该模式由c数组实现,性能最好,该模式下引擎将为数组分配连续的内存空间
固定长度大小数组、存储相同类型值、索引大小不很大(具体与引擎实现有关)时引擎将以此种模式创建数组

Fast Holey Elements 模式

此模式适合于数组中只有某些索引存有 元素,而其他的索引都没有赋值的情况。在 Fast Holey Elements 模式下,没有赋值的数组索引将会存 储一个特殊的值,这样在访问这些位置时就可以得到 undefined。但是 Fast Holey Elements 同样会动态分配连 续的存储空间,分配空间的大小由最大的索引值决定。

Dictionary Elements 模式

该模式下数组实际上就是使用 Hash 方式存储。此方式最适合于存储稀疏数组,它不用开辟大块连续的存储空 间,节省了内存,但是由于需要维护这样一个 Hash-Table,其存储特定值的时间开销一般要比 Fast Elements 模式大很多。
一种由 Fast Elements 转换为 Dictionary Elements 的典型情况是对数组赋值时使用远超当前数组大小的索引值,这时候要对数组分配大量空间则将可能造成存储空间的浪费。在Fast Elements 模式下,如果数组内存占用量过大,数组将直接转化为 Dictionary Elements 模式

测试实例

固定大小数组与非固定大小数组

// 非固定数组长度
function trendArray() {
    var tempArray = new Array();
    var i;
    for(i=0; i<100; i++){
        tempArray[i]= 1;
    }
}

// 固定数组长度
function fixedArray() {
    var tempArray = new Array(100);
    var i;
    for(i=0; i<100; i++){
        tempArray[i]= 1;
    }
}

对两段代码分别做100,0000次循环

console.time('trendArray')
for(let j = 0; j < 1000000; j++) {
    trendArray()
}
console.timeEnd('trendArray')

console.time('fixedArray')
for(let j = 0; j < 1000000; j++) {
    fixedArray()
}
console.timeEnd('fixedArray')
浏览器 非固定长度 固定长度
chrome(76.0.3809.132) 400ms左右 130ms左右
firefox(71.0 ) 930ms左右 690ms左右

测试可以看出固定长度的数组在耗时上是很有优势的,因为指定大小后,引擎不用频繁更新数组的长度,减少了不必要的内存操作
对于非固定长度的数组进行一点改动

function trendArray1() {
    var tempArray = new Array();
    tempArray[99] = 1
    var i;
    for(i=0; i<100; i++){
        tempArray[i]= 1;
    }
}

// 测试
console.time('trendArray1')
for(let j = 0; j < 1000000; j++) {
    trendArray1()
}
console.timeEnd('trendArray1')

对非固定长度的数组在遍历赋值之前做了一步操作:tempArray[99] = 1,最终测试结果:

浏览器 非固定长度 非固定长度,中途赋值 固定长度
chrome(76.0.3809.132) 400ms左右 140ms左右 130ms左右
firefox(71.0 ) 930ms左右 690ms左右 690ms左右

其实从测试中还是能够推测出一些东西的:
固定长度的数组,引擎在创建数组的时候就会为其分配一段连续的内存空间,之后每次只进行赋值操作就行;
非固定长度数组,引擎不清楚将来数组长度,可能避免内存空间浪费,将不对其分配连续内存空间,之后每次赋值,都会进行内存空间的拓展,造成性能的下降
非固定长度数组在期望末尾赋值,引擎一开始不清楚数组长度,然后进行末尾赋值之后会为其分配一段连续的内存空间,之后的赋值操作便不再进行空间拓展,相当于将空间拓展合并到一次完成
其实无论以上结论是否真实准确,从测试中也可以得出结论:最好固定数组长度,这样实打实能提升部分性能,另外不妨用复杂度分析的方法分析一下以上三种情况引擎处理的复杂度

数组or对象
一般情况下数组是不会有负数值索引的,但在js中,你甚至可以为数组添加负数值索引,但并不推荐这么做,因为如果为数组添加赋值索引,引擎会将其按照对象来进行处理,这样引擎就无法利用数组的特点进行线性存储

function positiveArray() {
    var tempArray = new Array(5);
    var i;
    for(i = 0; i < 5; i++) {
        tempArray[i]= 1;
    }
}

function negativeArray() {
    var tempArray = new Array(5);
    var i;
    for(i = 0; i > -5; i--) {
        tempArray[i]= 1;
    }
}

同样循环100,0000次

console.time('positiveArray')
for(let j = 0; j < 1000000; j++) {
    positiveArray()
}
console.timeEnd('positiveArray')

console.time('negativeArray')
for(let j = 0; j < 1000000; j++) {
    negativeArray()
}
console.timeEnd('negativeArray')

测试结果

浏览器 负数值索引 正数值索引
chrome(76.0.3809.132) 1000ms左右 20ms左右
firefox(71.0 ) 1000ms左右 300ms左右

结果显而易见,所以避免负数值索引的使用
稀疏数组的代价

function continusArray() {
    var tempArray = [];
    var index = 0;
    for(var i = 0; i < 5; i++){
        tempArray[index]= 1;
        index +=1;
    }
}

function discontinuousArray() {
    var tempArray = [];
    var index = 0;
    for(var i = 0; i < 5; i++){
        tempArray[index]= 1;
        index +=20;
    }
}

同样循环100,0000次

console.time('continusArray')
for(let j = 0; j < 1000000; j++) {
    continusArray()
}
console.timeEnd('continusArray')

console.time('discontinuousArray')
for(let j = 0; j < 1000000; j++) {
    discontinuousArray()
}
console.timeEnd('discontinuousArray')

测试结果:

浏览器 连续数组 不连续数组
chrome(76.0.3809.132) 40ms左右 170ms左右
firefox(71.0 ) 210ms左右 650ms左右

引擎需要对不连续的数组分配更多的内存空间,而且需要对“空洞”做特殊处理,这些都是额外耗时的操作,也会带来性能损失

总结

引擎对js数组的实现是有很多优化的,总体来说是一个降级的过程:

  1. 固定长度、相同类型项的数组最接近c的实现,性能最好
  2. 稀疏数组会带来一定性能损失
  3. 数组来说引擎会优先为其分配连续的内存空间,相对对象的非连续存储来说带来性能提升

有了以上结论,在代码实现上面就可以针对性得进行优化。

链表

前一小结简单介绍过数组,链表其实是和数组类似得一种线性数据结构,但是它在内存中不是一段连续得存储空间,它通过“指针”将不连续得一块块内存空间串联起来存储数据,上一个链表项会携带一个指针信息指向下一个链表项得内存地址;
链表示意图:

链表的特点

在介绍数组的时候就有总结过数组的特点,从某种意义上来说链表和数组存在互补关系

链表的优点

插入/删除
链表的插入/删除只需要改变一下链表项的指向即可,不会像数组那样涉及到数据的搬运,其复杂度为O(1)

随机访问
因为链表在内存中的保存并不是一段连续的内存空间,而且每一项所占用的空间也不固定,所以无法像数组那样计算出某一项的内存地址去直接访问,所以访问链表的某一项只能从头开始一个个遍历,其复杂度为O(n)

链表在js中的实现

链表元素

class Node {
    constructor(val) {
        this.element = val // 数据
        this.next = null // 指针
    }
}

链表类

class LinkedList {
    constructor() {
        this._length = 0
        this.head = null
    }
}

指定位置后插入

  insert(pos, element) {
    // 检查越界
    if (pos < 0 || pos > this._length) return false
    const node = new Node(element)
    let index = 0, current = this.head, previous
    if (pos === 0) {
      node.next = current
      this.head = node
    }
    else {
      while (index < pos) {
        previous = current
        current = current.next
        index++
      }
      previous.next = node
      node.next = current
    }
    
    this._length += 1
    return true
  }

尾部插入

append(element) {
    return this.insert(this._length, element)
}

头部插入

  prepend(element) {
    const node = new Node(element)
    if (!this.head) {
      this.head = node
    } else {
      node.next = this.head
      this.head = node
    }
    this._length += 1
    return true
  }

删除指定位置项

  delete(pos) {
    // 检查越界
    if (pos < 0 || pos >= this._length) return null
    let index = 0, current = this.head, previous
    // 移除第一项
    if (pos === 0) {
      this.head = current.next
      return current
    }
    while (index < pos) {
      previous = current
      current = current.next
      index += 1
    }
    previous.next = current.next
    this._length -= 1
    return current
  }

删除头部

shift() {
    if (!this.head) return null
    return this.delete(0)
}

删除尾部

pop() {
    return this.delete(this._length)
}

删除指定项

remove(element) {
    const index = this.indexOf(element)
    return this.delete(index)
}

返回链表项索引

indexOf(element) {
    if (!element) return -1
    let index = 0, current = this.head
    while(current) {
        if (current.element === element) return index
        index += 1
        current = current.next
    }
    return -1
}

判断链表是否为空

isEmpty() {
    return this._length === 0
}

返回链表长度

size() {
    return this._length
}

返回链表头部元素

getHead() {
    return this.head
}

完整代码


class Node {
  constructor(val) {
    this.element = val // 数据
    this.next = null // 指针
  }
}

class LinkedList {
  constructor() {
    this._length = 0
    this.head = null
  }
  insert(pos, element) {
    // 检查越界
    if (pos < 0 || pos > this._length) return false
    const node = new Node(element)
    let index = 0, current = this.head, previous
    if (pos === 0) {
      node.next = current
      this.head = node
    }
    else {
      while (index < pos) {
        previous = current
        current = current.next
        index++
      }
      previous.next = node
      node.next = current
    }
    
    this._length += 1
    return true
  }
  append(element) {
    return this.insert(this._length, element)
  }
  prepend(element) {
    const node = new Node(element)
    if (!this.head) {
      this.head = node
    } else {
      node.next = this.head
      this.head = node
    }
    this._length += 1
    return true
  }
  delete(pos) {
    // 检查越界
    if (pos < 0 || pos >= this._length) return null
    let index = 0, current = this.head, previous
    // 移除第一项
    if (pos === 0) {
      this.head = current.next
      return current
    }
    while (index < pos) {
      previous = current
      current = current.next
      index += 1
    }
    previous.next = current.next
    this._length -= 1
    return current
  }
  shift() {
    if (!this.head) return null
    return this.delete(0)
  }
  pop() {
    return this.delete(this._length - 1)
  }
  remove(element) {
    const index = this.indexOf(element)
    return this.delete(index)
  }
  indexOf(element) {
    if (!element) return -1
    let index = 0, current = this.head
    while (current) {
      if (current.element === element) return index
      index += 1
      current = current.next
    }
    return -1
  }
  isEmpty() {
    return this._length === 0
  }
  size() {
    return this._length
  }
  getHead() {
    return this.head
  }
}

以上即为以javascript为基础实现的简单的单向链表结构,当然还有双向链表结构、循环链表结构,顾名思义,就是链表项相对单向多了向前的指向,首位相连。
那在javascript日常项目开发中有哪些应用场景呢?或者说链表结构适合进行哪些算法呢?

链表的一些简单应用场景

在应用中经常会有类似:“历史搜索”,“最近使用”等这样的列表,这些列表一般都会有一个长度限制,那怎么实现这样的列表呢?

缓存算法

  1. FIFO(First In First Out)先进先出算法
  2. LFU(Least Frequently Used)最少使用算法
  3. LRU(Least Recently Used)最近最少使用算法

使用单向链表实现LRU算法

思路

根本完全没有必要记住这些算法的名字,只需要根据字面意思理解其算法思想即可,回到最初的问题,怎么实现“最近使用”这样的功能呢?

  1. 设想有一个链表q维护“最近使用”这样的内容列表,其限制长度为s,越靠近尾部的访问时间越早则优先级越低,
  2. 当有一个新的数据x被访问,此时遍历q,如果x已经在q中保存,则将x移动到q的首部,如果x没有在q中,则插入到q首部
  3. 在进行新数据x插入时检查链表长度是否已经超出限制limit,如果超出限制,则将尾部项删除
实现
class LRUList {
  constructor(limit = 10) {
    this._list = new LinkedList()
    this._limit = limit // 长度限制
  }
  add(element) {
    const size = this._list.size()
    if (this._list.indexOf(element) > -1) {
      this._list.remove(element)
    }
    else if (size === this._limit) { // 如果已经满了
      this._list.pop() // 移除末尾
    }
    this._list.prepend(element) // 将新元素添加至链表首部
  }
  delete(element) {
    return this._list.delete(element)
  }
  find(element) {
    return this._list.indexOf(element)
  }
}

是不是很简单?

思考

其实发现无论限制有没有满,我们访问缓存x都需要遍历链表,所以复杂度为O(n),那能不能优化呢?(答案是肯定的,后面再说)
缓存的操作无非就是访问、插入、删除这些,而这些操作数组也是支持的,所以LRU数组也是完全可以实现的,可能在js中由于引擎的优化以及提供丰富的api可能数组实现起来更加便利,但基于链表的实现也展示了一种选择

总结

主要介绍记录了数组、链表这两种数据结构,同时有数组在js中的独有特点、链表在js中的实现和应用:

  • 数组是在使用一段连续固定长度内存保存的同类型的数据片段
  • 数组优势在于随机访问,劣势是插入/删除操作
  • js的数组不同于其他更底层的编程语言,它可以保存不同类型的数据,不必固定长度,长度随时可扩展
  • js引擎对js数组的实现是一个降级的过程,导致js的数组在内存中可能并不是一段连续的空间
  • js中引擎会对一些特定的数组进行优化,在应用中应尽可能这样使用
  • 链表是非连续、不固定长度、可以存储不同数据类型的一种数据结构,它可以充分利用内存中的“碎片”空间,js中的数组其实更加类似链表
  • 链表优势在于插入/删除操作,劣势是随机访问
  • 在js中实现了简单的链表结构,同时基于这个链表实现LRU策略算法

innocence
11 声望1 粉丝

undefined