学前端算法的数据结构基础?只要4999!

1,046 阅读16分钟

前言

正好前段时间屯了修言大佬的《前端算法与数据结构面试:底层逻辑解读与大厂真题训练 》的小册,所以这几天放弃摸鱼来学习一下,因为想着也是做一下自己的学习记录,也可以方便一些同学简单的了解这方面的知识,所以写了这么一篇学习笔记,主要结构是根据小册来的,内容是修言大佬的内容结合我的一些自己理解,里面的例子有的是小册中的,有的是我自己想的,我也是初次学习,可能多有不妥之处,多包涵,话不多说,就进正题吧!(偷偷的告诉你,文章一共4999个字,不算这句悄悄话!)

数据结构层面

数组

创建方式:

const arr = [1, 2, 3, 4]
const arr = new Array() 

推荐在不知道内部元素的情况下使用第二种,并且知道有多少元素,指定Array(4),这样的情况下,假如元素相同,我们就可以避免写一个重复的数组,如 let arr = [1,1,1,1] ,而是使用 let arr = (new Array(4)).fill(1) 来创建。

  • 注意:但是这里需要明确一点,就是如果你的数组是一个二维数组(矩阵),那么请不要用fill这个方法先去填充一个空数组,比如let arr = (new Array(4)).fill([]),没错你确实能够得到七个空数组,但当你出现arr[0][0] = 1这样的操作时,你就会得到七个数组中的元素都变成了1,这是因为fill方法的参数如果是一个引用类型(数组,对象等),那么fill在填充的时候就是对这个参数的引用,因此我们可以选择用for循环来创建这个二维数组,同理,多维数组也是一样

遍历方式:

  • for循环 (性能上最快)
  • arr.forEach
  • arr.map(统一再加工)

增删元素:

  • unshift(将元素添加到数组头部):
const arr = [1,2,3]
arr.unshift(0) // [0,1,2,3]
  • push(将元素添加到数组尾部):
arr.push(4) // [0,1,2,3,4]
  • splice(将元素添加到数组任意位置,第一个参数是下标index,第二个参数是需要删除的元素个数,第三个参数是你要放入的元素(可选)):
arr.splice(2,0,5) // [0,1,5,2,3,4]
arr.splice(2,1) // [0,1,2,3,4]
  • shift(将数组头部元素删除):
arr.shift() // [1,2,3,4]
  • pop(将数组尾部元素删除):
arr.pop() // [1,2,3]

栈(后进先出--Last In First Out)

我们可以将他理解为只使用pop和push方法的数组,因为他后进先出的特性,也就是我们只能在尾部添加和删除元素。

比如我们有一个瓶子,只有一个球的宽度,我们要往里面放5种颜色的球

const stack = []
// 入栈过程
stack.push('红球')
stack.push('黄球')
stack.push('蓝球')
stack.push('绿球')
stack.push('黑球') 
// ['红球','黄球','蓝球','绿球','黑球']

// 出栈过程
while(stack.length) {
    console.log('现在取出的是' + stack.pop())
} 
// 现在取出的是黑球
// 现在取出的是绿球
// 现在取出的是蓝球
// 现在取出的是黄球
// 现在取出的是红球

队列(先进先出--First In First Out)

我们可以将他理解为只使用push和shift方法的数组,因为他先进先出的特性,也就是我们只能在尾部添加元素和在头部删除元素。

还是上面那个例子:

const queue = []
// 入队过程
queue.push('红球')
queue.push('黄球')
queue.push('蓝球')
queue.push('绿球')
queue.push('黑球') 
// ['红球','黄球','蓝球','绿球','黑球']

// 出队过程
while(queue.length) {
    console.log('现在取出的是' + queue.shift())
} 
// 现在取出的是红球
// 现在取出的是黄球
// 现在取出的是蓝球
// 现在取出的是绿球
// 现在取出的是黑球

链表

链表与数组的最大的区别,我们可以理解为链表是不连续的,而数组是连续的,怎么理解呢,即链表在内存空间内,我们的结点并不会并排并的站在一起,就好比一个操场,100米接力跑的同学会站在400米跑道的各个100米位置,是不连续的,但他们知道自己的下一棒是谁,而数组就好比1~7个跑道是连续的。

链表的创建:

我们首先定义一个链表的构造函数,然后通过指定val和next来创建

function createNode(val) {
    this.val = val // 当前结点的值
    this.next = null // 下一个结点指向
}

const node1 = new createNode(1) // 创建当前结点值为1
node1.next = new createNode(2) // 指向了下一个结点值为2

// 当我们要插入结点时
const node3 = new createNode(3)
node3.next = node1.next // 将node1原来的next指向给node3的next
node1.next = node3 // 将node1的next指向node3

// 当我们要删除结点时
node1.next = node3.next // node3会因为无法抵达就会被垃圾回收系统回收

// 另外我们也可以通过node1来抵达node3
// let target = node1.next
// node1.next = target.next
// 这样也可以达成目的

与数组相比,链表的优势与劣势

  • 优势:我们可以通过上述所说的目标位置来找到对应的结点,这样就可以进行一个高效的增删操作,这里会涉及到时间复杂度的降低,从O(n)->O(1)

  • 劣势:我们如果需要找到一个特定的链表结点时,我们必须从头开始遍历,与优势相反,会把时间复杂度从O(1)->O(n)提升,即访问效率低

这里我们暂时先不管复杂度的问题,后面会再讲到,另外需要提一点的是,JS的数组未必是一个真正的数组,因为通常的数组是一段连续的空间,而当JS数组中的元素不是一种类型时,他就变成了一段非连续的内存,此时他是由对象链表来实现的

关于树,其实就跟现实中的树一样,通过不断散发结点来扩张,有几点我们需要记住:

  • 层级:根结点所在的层级是第一层,往后每下一级结点就层级增加一层
  • 高度:最下面的叶子结点的高度为1,每向上一层,高度增加1,直到根结点得到的最大的高度就是树的高度(因为结点扩散下去的层数可能不一样,因此高度不一定,所以最大的高度才是树的高度)
  • 度:每个结点分散的子结点的个数就是度,比如一个结点有两个子结点,那么他的度就是2,其中因为叶子结点不再向下扩展,因此叶子结点的度为0

详细的大家可以看一下我画的这张示例图👇,包含了上面说的这些内容

树.png

二叉树

二叉树这个概念相信很多同学听的特别多,面试问到的几率也很大,那么我们来仔细的聊一聊,什么是二叉树,他又有什么特点呢?

什么样的被称为二叉树

  • 可以没有根结点,作为一颗空树存在
  • 如果不是一颗空树,则必须具备:1.根结点;2.左子树;3.右子树,而其中左右子树都必须仍然是二叉树,即满足以上两点

其实上面我画的那张关于树的图,这就是一个二叉树形,为什么说是形呢?因为二叉树并不仅仅单纯的是每个结点有两个子结点,他必须是左右子树的位置明确区分,无法交换的,也就是每个子结点,就必须在他所在位置,不能与他的兄弟结点进行互换。然后还要提到一点的是,二叉树的每个结点最多只能有两棵子树,子树可以不存在,也可以存在并其中一个为空树,或者两个都为空树,或者都不为空树,因为即使是空树,他也是二叉树的一种,如果一棵二叉树层数为k,结点总数为(2^k)-1,那么他就是一棵满二叉树。

二叉树的编码实现

二叉树的结构分为三块:

  • 数据域
  • 左侧子结点(左子树根结点)的引用
  • 右侧子结点(右子树根结点)的引用
// 首先我们需要创建一个构造函数,并且设定子树为空,如果我们需要子树,那么就对左右子结点进行新的创建操作
function createTreeNode(val) {
    this.val = val // 定义当前结点的值
    this.left = null // 左子树为空
    this.right = null // 右子树为空
}

// 实例化
const node1 = new createTreeNode(1)

二叉树的遍历

接下来这部分很重要,就是二叉树的遍历,修言大佬说了,只理解不记忆,你就回家种地吧(亏的我是农村人,有地种,城里的同学另谋出路吧~ ^_^!)

首先遍历分为两类:

  • 按照顺序规则不同,分为四种:

    • 先序遍历

      根结点 -> 左子树 -> 右子树 (假定左子树先于右子树)

      这里我们就来仔细的说一下什么是先序遍历,大家也都看到了,所谓的先中后其实是对根结点遍历的时间节点的定义,就是什么时候去遍历根结点。那我们来说到先序遍历,即我们先遍历根结点,在遍历左子树,最后遍历右子树,然后这个遍历顺序呢,需要一直贯穿到每一个子树当中,我们以下图👇为例,图中我们可以看到整体的遍历顺序就是先从根结点A遍历后,到左子树的根结点B,然后再到B的左子树D,假如此处D还有子树,按照同样的顺序遍历,D结束之后再遍历E,然后再到A的右子树C,同样是先根结点,然后左子树,然后右子树的遍历方式,最后全部走完到G也就是完成了整个先序遍历:1 -> 2 -> 3 -> 4 -> 5 -> 6 树 (3).png

      接下来我们来看一下代码的实现:

      // 遍历对象
      const treeList = {
          val: 'A',
          left: {
              val: 'B',
              left: {
                  val: 'C',
                  left: {
                      val: 'D'
                  },
                  right: {
                      val: 'E'
                  }
              },
              right: {
                  val: 'F'
              }
          },
          right: {
              val: 'G',
              left: {
                  val: 'H',
                  left: {
                      val: 'I'
                  },
                  right: {
                      val: 'J'
                  }
              }
          }
      }
      
      /* 先序遍历函数: 这里说一下整个思路,就是我们首先判断是不是一个
      空树,如果不是我们就先遍历根结点,然后去遍历左右子树,而左右子树同
      样是执行这个操作,一个结点遍历结束的标志就是,当前结点是一个空树,
      那么我们对当前结点的遍历就结束了,整个的递归过程就是一个先序遍历 */
      function treeTraverse(root) {
          if(!root) {
              return // 如果当前结点为空,那就返回
          }
          console.log(root.val); // 打印当前结点的值
      
          // 接着遍历左子树
          treeTraverse(root.left)
          // 然后遍历右子树
          treeTraverse(root.right)
      }
      
      treeTraverse(treeList)
      

      结果如下图:

      result.jpg

    • 中序遍历

      左子树 -> 根结点 -> 右子树 (假定左子树先于右子树)

      在了解先序遍历之后,我们在去了解中序遍历就不是很困难的事情了,这里就不在说具体的思路了,直接上代码:

      // 中序遍历函数
      function treeTraverse(root) {
          if(!root) {
              return // 如果当前结点为空,那就返回
          }
      
          // 先遍历左子树
          treeTraverse(root.left)
      
          // 再遍历根结点
          console.log(root.val);
      
          // 然后遍历右子树
          treeTraverse(root.right)
      }
      
      treeTraverse(treeList)
      
      // D
      // C
      // E
      // B
      // F
      // A
      // I
      // H
      // J
      // G
      
    • 后序遍历

      左子树 -> 右子树 -> 根结点 (假定左子树先于右子树)

      后序遍历也是一样的:

      // 中序遍历函数
      function treeTraverse(root) {
          if(!root) {
              return // 如果当前结点为空,那就返回
          }
      
          // 先遍历左子树
          treeTraverse(root.left)
          
          // 然后遍历右子树
          treeTraverse(root.right)
      
          // 再遍历根结点
          console.log(root.val);
      }
      
      treeTraverse(treeList)
      
      // D
      // E
      // C
      // F
      // B
      // I
      // J
      // H
      // G
      // A
      
    • 层次遍历

      关于层次遍历,其实也是比较好理解的,层次二字我的理解是跟树的层级有关,最开始的时候,我们是不是提到过层级的定义,从层级面上来说,就是我们先遍历第一层级,然后第二层级,因为左子树优先于右子树,所以整个的遍历顺序也很明确,另外我查阅的解释是用队列的知识来实现:每次出队一个元素,就将该元素的孩子节点加入队列中,直至队列中元素个数为0时,出队的顺序就是该二叉树的层次遍历结果。

      怎么理解这句话呢,通过前面的学习,我们知道了队列是先进先出的,当我们把一个根结点放入队列后,然后再取出,此时是不是就将他的左右子树的根结点放入了队列,然后我们再对左右子树的根结点进行这个操作,整个出队的顺序就是层次遍历,来看一张示意图,我相信你就明白了:

层次遍历.png

看完了顺序,我们用代码来实现一下

 // 层次遍历函数
function treeTraverse(root) {
    // 先定义一个队列
    const queue = []
    // 先将根结点放入队列
    queue.push(root)
    // 然后进行取出根元素放入子元素的循环
    while(queue.length) {
        // 进入循环代表现在还有元素没有遍历,当数组长度为0,就完整了整个层次遍历
        const first = queue[0] //获取列表第一个元素

        console.log(first.val); // 对根元素进行遍历
        // 把第一个元素取出
        queue.shift(first)
        // 取出根元素后,需要将左右子树放入
        if(first.left) {
            queue.push(first.left)
        }
        if(first.right) {
            queue.push(first.right)
        }
    }
}

treeTraverse(treeList)

// A
// B
// G
// C
// F
// H
// D
// E
// I
// J
  • 按照实现方式不同,分为两种:

    • 递归遍历(先、中、后序遍历)
    • 迭代遍历(层次遍历)

复杂度

学完数据结构的基础知识后,我们再来学习复杂度的知识。关于复杂度这个事情,稍微了解过一点算法的同学都知道,就是你怎么去衡量你的算法到底好还是不好呢,同样能够解出来一道题目,那么到底谁的更好呢,这就是复杂度存在的意义,光从概念上可能理解起来比较费劲,修言大佬带我们用代码去认识这两个标准---时间复杂度和空间复杂度!

时间复杂度(time complexity)

关于时间复杂度,我们经常会遇到的是类似于O(1),O(n)这样的表达,那么这些表达是怎么来的呢,又还有哪些表达呢?

我们先来看一下一段代码:

function traverse(arr) {
    var len = arr.length
    for(var i = 0; i < len; i++) {
        console.log(arr[i])
    }
}

假如上面这个traverse函数执行,那么我们如果将所有的循环都用代码来写出来,总共需要执行多少次呢?我们来一一计算一下(下面我们将数组长度定义为n):

  • var len = arr.length 执行1次

  • var i=0 执行1次

  • i<len 执行n+1次

  • i++ 执行n次

  • console.log(arr[i]) 执行n次

那么最后我们将这些次数全部加起来就是我们总的执行次数,也就是1+1+(n+1)+n+n = 3n+3次,而像这样的时间复杂度,我们统一将他们认定为时间复杂度为n,即忽略他的常数,只看整体的趋势,而算法的时间复杂度,就是对代码整体执行次数的一个变化趋势的反应,以前读书的时候我们学过,在n趋于无穷大的时候,他前面的系数和常数也就失去了意义,可以忽略,所以我们最终得到的时间复杂度才是n

那么同理,我们来看一下双循环的代码:

function traverse(arr) {
    var len = arr.length
    for(let i = 0; i < len; i++) {
        for(let j = 0; j < len; j++) {
            console.log(arr[i][j])
    }
}

我们来分析一下他执行的总的次数:

  • var len = arr.length执行1次

  • let i = 0执行1次

  • i < len执行n+1次

  • i++ 执行n次

  • let j = 0执行n次

  • j < len执行n*(n+1)次

  • j++ 执行n*n次

  • console.log(arr[i][j])执行n*n次

那么以上总的执行次数就是1+1+n+1+n+n+n*(n+1)+n * n+n * n=3n^2+4n+3,由此我们可以发现次数的最高次来到了2,那根据我们第一次得到结论,首先系数和常数项可以忽略,再者,当n趋于无穷大的时候,低次项也可以被忽略,因此我们最后得到的趋势也就是时间复杂度即为n^2

经过上面两个案例的分析,相信你也明白了即从执行次数得到时间复杂度是如何得来的,然后其实还有好几种比较常见的时间复杂度,按照复杂程度做了排列,请看👇下面这张图:

树 (6).png

空间复杂度(space complexity)

空间复杂度实际上是对算法在运行过程中临时占用存储空间大小的衡量,是内存增长的趋势,我自己的理解的话,可以把时间复杂度和空间复杂度构成一个坐标系,X轴为时间复杂度,那么Y轴相应的为空间复杂度,两个复杂度之间不互相影响,但是共同构成了我们算法好与坏的一个衡量标准。

常见的空间复杂度有下面几种:

  • O(1)
  • O(n)
  • O(n^2)

同样的我们来看一下接来下这个例子,来理解空间复杂度吧!

function traverse(arr) {
    var len = arr.length
    for(var i = 0; i < len; i++) {
        console.log(arr[i])
    }
}

用的还是这个函数,我们来看一下,占用内存的是这几个:arrilen,而我们在整体的执行过程中,并没有新的变量出现,因此也没有开辟新的内存空间,这样我们是不是就可以认为,空间复杂度是恒定不变的,那么就是一个常量了,对于常量的复杂度,我们都统一将其认定为1,那么最后得到的就是O(1)

接下来我们看另外一个例子:

function init(n) {
    var arr = []
    for(var i=0;i<n;i++) {
        arr[i] = i
    }
    return arr
}

这里我们发现占用内存的有narri,而根上个例子不一样的地方大家也发现了,也就是arr的大小会根据n的值的传入不一样而线性改变,像这样会随着变化的空间复杂度,我们将其记为O(n)

后记

上面就是小册中关于算法的数据结构和基础概念的一个简单的梳理,我个人认为还是比较好理解的,后面的真题部分我准备开始看了,碰到觉得需要补充的概念或者常用的解法,我会在单独写一篇文章来跟大家分享,那我写的是经过我消化了的,如有错误,欢迎指正,想全面了解学习的,就快去读修言大佬的这本小册吧!