JS核心理论之《位运算与数据结构》

212 阅读13分钟

位运算

  1. 与( AND), a & b ,对于每一个比特位,只有两个操作数相应的比特位都是1时,结果才为1,否则为0。
  2. 或(OR), a | b ,对于每一个比特位,当两个操作数相应的比特位至少有一个1时,结果为1,否则为0。
  3. 异或(XOR), a ^ b ,对于每一个比特位,当两个操作数相应的比特位有且只有一个1时,结果为1,否则为0。
  4. 非(NOT), ~ a,反转操作数的比特位,即0变成1,1变成0。
  5. 左移, a << b,将 a 的二进制形式向左移 b (< 32) 比特位,右边用0填充
  6. 有符号右移, a >> b, 将 a 的二进制表示向右移 b (< 32) 位,丢弃被移出的位,并且使用复制最左侧的一位填充
  7. 无符号右移, a >>> b, 将 a 的二进制表示向右移 b (< 32) 位,丢弃被移出的位,并使用 0 在左侧填充,所以结果总是非负。

示例:

9 << 2 // 36
// 00000000 00000000 00000000 00001001 ->  00000000 00000000 00000000 00100100 -> 36

9 >> 2 // 2
// 00000000 00000000 00000000 00001001 ->  00000000 00000000 00000000 00000010 -> 2

-9 >> 2 // -3
// 11111111 11111111 11111111 11110111 ->  11111111 11111111 11111111 11111101 -> -3

-9 >>> 2 // 1073741821
// 11111111 11111111 11111111 11110111 ->  00111111 11111111 11111111 11111101 -> 1073741821

8 & 7   //相当于二进制1000 & 0111 -> 0000 ->  0
8 | 7   //相当于二进制1000 | 0111 -> 1111 -> 15
8 ^ 7   //相当于二进制1000 ^ 0111 -> 1111 -> 15

注意:

  1. 异或就是不进位的加法。
  2. 负数的二进制表示法,先取反,再加1。

比如-9的二进制表示,求解过程如下:

  1. 先求出原码: 00000000 00000000 00000000 00001001
  2. 求出反码: 11111111 11111111 11111111 11110110
  3. 再加1得补码:11111111 11111111 11111111 11110111

时间空间复杂度

常见的时间复杂度按照从小到大的顺序排列,有以下几种:

常数 对数 线性 线性对数 二次方 三次方 指数
O(1) O(logn) O(n) O(nlogn) O(n^2) O(n^3) O(2^n)

常见的空间复杂度有 O(1)、O(n) 和 O(n^2)。

数据结构

数据结构分类:

  • 逻辑结构:反映数据之间的逻辑关系;
    1. 集合:结构中的数据元素除了同属于一种类型外,别无其它关系。(无逻辑关系), 如一维数组MapSet
    2. 线性结构 :数据元素之间一对一的关系(线性表),如一维数组队列链表线性表
    3. 树形结构 :数据元素之间一对多的关系(非线性),如二叉树
    4. 图状结构或网状结构: 结构中的数据元素之间存在多对多的关系(非线性),如二维数组
  • 存储结构:数据结构在计算机中的表示;
    1. 顺序存储数据结构
    2. 链式存储数据结构
    3. 索引存储数据结构
    4. 散列存储数据结构

大家需要掌握以下几种:

  • 数组
  • Map、Set
  • 队列
  • 链表
  • 树(这里我们着重讲二叉树)

数组

数组的创建: 有两种方式,const arr = [1, 2, 3, 4]const arr = new Array()

数组的访问与遍历: 访问通过访问索引下标arr[0],遍历有for、forEach、map等方法,从效率上讲,for是最快的。

二维数组,又叫矩阵。二维数组的初始化,不要用fill完成const arr =(new Array(7)).fill([]),否则填充的是同一个数组的引用。而应该使用for来初始化。

const len = arr.length
for(let i=0;i<len;i++) {
    // 将数组的每一个坑位初始化为数组
    arr[i] = []
}

数组API: concat、some、join、sort、push(添加到尾部)、pop(删除尾部)、unshift(添加到头部)、shift(删除头部)、slice(返回一个截取的新数组)、splice(修改原有数组)

splice() 方法通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。此方法会改变原数组。 示例:

const months = ['Jan', 'March', 'April', 'June'];

// 在index 1位置插入
months.splice(1, 0, 'Feb');
console.log(months);        //["Jan", "Feb", "March", "April", "June"]

// 在index 4位置替换
months.splice(4, 1, 'May');
console.log(months);        // ["Jan", "Feb", "March", "April", "May"]

// 在index 2位删除一个元素
months.splice(2, 1);
console.log(months);        // ["Jan", "Feb", "April", "May"]

// 在倒数第二2位 删除一个元素
months.splice(-2, 1);
console.log(months);        // ["Jan", "Feb", "May"]

slice() 方法返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。 示例:

const animals = ['ant', 'bison', 'camel', 'duck', 'elephant'];

console.log(animals.slice(2));      // ["camel", "duck", "elephant"]

//从index 2位开始,到index 4结束(不包括4)返回
console.log(animals.slice(2, 4));   // ["camel", "duck"]

console.log(animals.slice(1, 5));   // ["bison", "camel", "duck", "elephant"]

Map

Map对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者原始值) 都可以作为一个键或一个值。 一个Map对象在迭代时会根据对象中元素的插入顺序来进行 — 一个 for...of 循环在每次迭代后会返回一个形式为[key,value]的数组。

Map与Object的区别:

Map Object
意外的键 默认情况不包含任何键。只包含显式插入的键 有一个原型, 原型链上的键名有可能和你自己在对象上的设置的键名冲突
键的类型 可以是任意值,包括函数、对象或任意基本类型 必须是一个 String 或是Symbol
键的顺序 有序的 无序的
Size 通过size 属性获取 只能手动计算
迭代 iterable 的,所以可以直接被迭代 需要以某种方式获取它的键然后才能迭代
性能 在频繁增删键值对的场景下表现更好 在频繁添加和删除键值对的场景下未作出优化

Set

Set对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。 Set对象是值的集合,你可以按照插入的顺序迭代它的元素。 Set中的元素只会出现一次,即 Set 中的元素是唯一的。

let myArray = ["value1", "value2", "value3"];

// 用Set构造器将Array转换为Set
let mySet = new Set(myArray);

mySet.has("value1"); // returns true

// 用...(展开操作符)操作符将Set转换为Array
console.log([...mySet]); // 与myArray完全一致

//数组去重
const numbers = [2,3,4,4,2,3,3,4,4,5,5,6,6,7,5,32,3,4,5]
console.log([...new Set(numbers)]) 
// [2, 3, 4, 5, 6, 7, 32]

栈是一种后进先出(LIFO,Last In First Out)的数据结构。只用 pop 和 push 完成增删的“数组”。

function Stack(){
    // 用let创建一个私有容器,无法用this选择到dataStore;
    let dataStore = [];
    // 模拟进栈的方法 
    this.push = function(element){
        dataStore.push(element);
    };
    // 模拟出栈的方法,返回值是出栈的元素。
    this.pop = function(){
        return dataStore.pop();
    };
    // 返回栈顶元素
    this.peek = function(){
        return dataStore[dataStore.length-1];  
    };
    // 是否为空栈
    this.isEmpty = function(){
        return dataStore.length === 0 
    };
    // 获取栈结构的长度。
    this.size = function(){
        return dataStore.length;
    };
    //  清除栈结构内的所有元素。
    this.clear = function(){
        dataStore = [];
    }
}

一个单独的栈结构生成了,我们来测试一下。

let stack = new Stack();
stack.push(1);
stack.push(2);
stack.push(5);
stack.peek(); // return 5
stack.size(); // return 3
stack.clear();
stack.peek(); // undefined

应用场景:JavaScript引擎中的调用栈就是最直接的应用。

  • 当我们写递归时,一定要设定出口,否则就会因为函数调用只进栈不出栈,而导致最终栈溢出。
  • Redux与Koa的中间件洋葱模型也是栈的典型应用。
  • 闭包函数由于要保持内部变量的引用,在执行完之后并不会退栈。

队列

队列是一种先进先出(FIFO,First In First Out)的数据结构。只用 push 和 shift 完成增删的“数组”。

首先来看单链队列。

class Queue {
  constructor() {
    this.queue = []
  }
  enQueue(item) {
    this.queue.push(item)
  }
  deQueue() {
    return this.queue.shift()
  }
  getHeader() {
    return this.queue[0]
  }
  getLength() {
    return this.queue.length
  }
  isEmpty() {
    return this.getLength() === 0
  }
}

循环队列是一种线性数据结构,其操作表现基于 FIFO(先进先出)原则并且队尾被连接在队首之后以形成一个循环。它也被称为“环形缓冲器”。

循环队列的一个好处是我们可以利用这个队列之前用过的空间。在一个普通队列里,一旦一个队列满了,我们就不能插入下一个元素,即使在队列前面仍有空间。 但是使用循环队列,我们能使用这些空间去存储新的值。

因为单链队列在出队操作的时候需要 O(n) 的时间复杂度,所以引入了循环队列。循环队列的出队操作平均是 O(1) 的时间复杂度。

class SqQueue {
  constructor(length) {
    this.queue = new Array(length + 1)
    // 队头
    this.first = 0
    // 队尾
    this.last = 0
    // 当前队列大小
    this.size = 0
  }
  enQueue(item) {
    // 判断队尾 + 1 是否为队头
    // 如果是就代表需要扩容数组
    // % this.queue.length 是为了防止数组越界
    if (this.first === (this.last + 1) % this.queue.length) {
      this.resize(this.getLength() * 2 + 1)
    }
    this.queue[this.last] = item
    this.size++
    this.last = (this.last + 1) % this.queue.length
  }
  deQueue() {
    if (this.isEmpty()) {
      throw Error('Queue is empty')
    }
    let r = this.queue[this.first]
    this.queue[this.first] = null
    this.first = (this.first + 1) % this.queue.length
    this.size--
    // 判断当前队列大小是否过小
    // 为了保证不浪费空间,在队列空间等于总长度四分之一时
    // 且不为 2 时缩小总长度为当前的一半
    if (this.size === this.getLength() / 4 && this.getLength() / 2 !== 0) {
      this.resize(this.getLength() / 2)
    }
    return r
  }
  getHeader() {
    if (this.isEmpty()) {
      throw Error('Queue is empty')
    }
    return this.queue[this.first]
  }
  getLength() {
    return this.queue.length - 1
  }
  isEmpty() {
    return this.first === this.last
  }
  resize(length) {
    let q = new Array(length)
    for (let i = 0; i < length; i++) {
      q[i] = this.queue[(i + this.first) % this.queue.length]
    }
    this.queue = q
    this.first = 0
    this.last = this.size
  }
}

应用场景:JavaScript运行时中的消息队列就是队列的典型应用。

链表

链表和数组相似,它们都是有序的列表、都是线性结构(有且仅有一个前驱、有且仅有一个后继)。不同点在于,链表中,数据单位的名称叫做“结点”,而结点和结点的分布,在内存中可以是离散的。

function ListNode(val) {
    this.val = val;
    this.next = null;
}

const node = new ListNode(1)  
node.next = new ListNode(2)

在链表中间添加或删除元素时,需要变更前驱结点和目标结点的 next 指针指向

class Node {
  constructor(v, next) {
    this.value = v
    this.next = next
  }
}
class LinkList {
  constructor() {
    // 链表长度
    this.size = 0
    // 虚拟头部
    this.dummyNode = new Node(null, null)
  }
  find(header, index, currentIndex) {
    if (index === currentIndex) return header
    return this.find(header.next, index, currentIndex + 1)
  }
  addNode(v, index) {
    this.checkIndex(index)
    // 当往链表末尾插入时,prev.next 为空
    // 其他情况时,因为要插入节点,所以插入的节点
    // 的 next 应该是 prev.next
    // 然后设置 prev.next 为插入的节点
    let prev = this.find(this.dummyNode, index, 0)
    prev.next = new Node(v, prev.next)
    this.size++
    return prev.next
  }
  insertNode(v, index) {
    return this.addNode(v, index)
  }
  addToFirst(v) {
    return this.addNode(v, 0)
  }
  addToLast(v) {
    return this.addNode(v, this.size)
  }
  removeNode(index, isLast) {
    this.checkIndex(index)
    index = isLast ? index - 1 : index
    let prev = this.find(this.dummyNode, index, 0)
    let node = prev.next
    prev.next = node.next
    node.next = null
    this.size--
    return node
  }
  removeFirstNode() {
    return this.removeNode(0)
  }
  removeLastNode() {
    return this.removeNode(this.size, true)
  }
  checkIndex(index) {
    if (index < 0 || index > this.size) throw Error('Index error')
  }
  getNode(index) {
    this.checkIndex(index)
    if (this.isEmpty()) return
    return this.find(this.dummyNode, index, 0).next
  }
  isEmpty() {
    return this.size === 0
  }
  getSize() {
    return this.size
  }
}

注意:

  1. const arr = [1,2,3,4]这样的纯数字数组对应的内存是连续的。而如果const arr = ['haha', 1, {a:1}]对应的内存就是非连续的了。
  2. 链表的内存是非连续的。
  3. 链表的插入/删除效率较高(O(1)),而访问效率较低(O(n));数组的访问效率较高(O(1)),而插入效率较低(O(n))。

二叉树

  • 它可以没有根结点,作为一棵空树存在
  • 如果它不是空树,那么必须由根结点、左子树和右子树组成,且左右子树都是二叉树。

它的结构分为三块:

  1. 数据域
  2. 左侧子结点(左子树根结点)的引用
  3. 右侧子结点(右子树根结点)的引用
// 二叉树结点的构造函数
function TreeNode(val) {
    this.val = val;
    this.left = this.right = null;
}

image

按照顺序规则的不同,遍历方式有以下四种:

  1. 先序遍历: 根结点 -> 左子树 -> 右子树
  2. 中序遍历: 左子树 -> 根结点 -> 右子树
  3. 后序遍历: 左子树 -> 右子树 -> 根结点
  4. 层次遍历: BFS(广度优先搜索)

按照实现方式的不同,遍历方式又可以分为以下两种:

  1. 递归遍历(先、中、后序遍历)
  2. 迭代遍历(层次遍历)

所谓的“先序”、“中序”和“后序”,“先”、“中”、“后”其实就是指根结点的遍历时机。

image

先序遍历 结果:A B D E C F

示例:

function preorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历左子树 
    preorder(root.left)  
    // 递归遍历右子树  
    preorder(root.right)
}

中序遍历 结果:D B E A C F

示例:

// 所有遍历函数的入参都是树的根结点对象
function inorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    inorder(root.left)  
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历右子树  
    inorder(root.right)
}

后序遍历 结果:D E B F C A

示例:

function postorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    postorder(root.left)  
    // 递归遍历右子树  
    postorder(root.right)
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
}

二叉树的一种,满足以下条件:

  1. 任意节点大于或小于它的所有子节点(大根堆、小根堆)
  2. 总是一完全树,即除了最底层,其它层的节点都被元素填满

将根节点最大的堆叫做最大堆大根堆,根节点最小的堆叫做最小堆小根堆