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

前言

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

数据结构层面

数组

建立方式:node

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方法的数组,由于他后进先出的特性,也就是咱们只能在尾部添加和删除元素。markdown

好比咱们有一个瓶子,只有一个球的宽度,咱们要往里面放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)

后记

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

相关文章
相关标签/搜索