前端面试问题答案汇总--通识篇

转载于https://github.com/poetries/FE-Interview-Questions,by poetriesnode

1、网络

#1 UDP

1.1 面向报文git

UDP 是一个面向报文(报文能够理解为一段段的数据)的协议。意思就是 UDP 只是报文的搬运工,不会对报文进行任何拆分和拼接操做github

具体来讲面试

  • 在发送端,应用层将数据传递给传输层的 UDP 协议,UDP 只会给数据增长一个 UDP 头标识下是 UDP 协议,而后就传递给网络层了
  • 在接收端,网络层将数据传递给传输层,UDP 只去除 IP 报文头就传递给应用层,不会任何拼接操做

1.2 不可靠性算法

  • UDP 是无链接的,也就是说通讯不须要创建和断开链接。
  • UDP 也是不可靠的。协议收到什么数据就传递什么数据,而且也不会备份数据,对方能不能收到是不关心的
  • UDP 没有拥塞控制,一直会以恒定的速度发送数据。即便网络条件很差,也不会对发送速率进行调整。这样实现的弊端就是在网络条件很差的状况下可能会致使丢包,可是优势也很明显,在某些实时性要求高的场景(好比电话会议)就须要使用 UDP 而不是 TCP

1.3 高效数组

  • 由于 UDP 没有 TCP 那么复杂,须要保证数据不丢失且有序到达。因此 UDP 的头部开销小,只有八字节,相比 TCP 的至少二十字节要少得多,在传输数据报文时是很高效的

头部包含了如下几个数据浏览器

  • 两个十六位的端口号,分别为源端口(可选字段)和目标端口 整个数据报文的长度
  • 整个数据报文的检验和(IPv4 可选 字段),该字段用于发现头部信息和数据中的错误

1.4 传输方式缓存

UDP 不止支持一对一的传输方式,一样支持一对多,多对多,多对一的方式,也就是说 UDP 提供了单播,多播,广播的功能安全

#2 TCP

2.1 头部服务器

TCP 头部比 UDP 头部复杂的多

对于 TCP 头部来讲,如下几个字段是很重要的

  • Sequence number,这个序号保证了 TCP 传输的报文都是有序的,对端能够经过序号顺序的拼接报文
  • Acknowledgement Number,这个序号表示数据接收端指望接收的下一个字节的编号是多少,同时也表示上一个序号的数据已经收到
  • Window Size,窗口大小,表示还能接收多少字节的数据,用于流量控制

标识符

  • URG=1:该字段为一表示本数据报的数据部分包含紧急信息,是一个高优先级数据报文,此时紧急指针有效。紧急数据必定位于当前数据包数据部分的最前面,紧急指针标明了紧急数据的尾部。
  • ACK=1:该字段为一表示确认号字段有效。此外,TCP 还规定在链接创建后传送的全部报文段都必须把 ACK 置为一 PSH=1:该字段为一表示接收端应该当即将数据 push 给应用层,而不是等到缓冲区满后再提交。
  • RST=1:该字段为一表示当前 TCP 链接出现严重问题,可能须要从新创建 TCP 链接,也能够用于拒绝非法的报文段和拒绝链接请求。
  • SYN=1:当SYN=1ACK=0时,表示当前报文段是一个链接请求报文。当SYN=1ACK=1时,表示当前报文段是一个赞成创建链接的应答报文。
  • FIN=1:该字段为一表示此报文段是一个释放链接的请求报文

2.2 状态机

HTTP 是无链接的,因此做为下层的 TCP 协议也是无链接的,虽然看似 TCP 将两端链接了起来,可是其实只是两端共同维护了一个状态

  • TCP 的状态机是很复杂的,而且与创建断开链接时的握手息息相关,接下来就来详细描述下两种握手。
  • 在这以前须要了解一个重要的性能指标 RTT。该指标表示发送端发送数据到接收到对端数据所需的往返时间

创建链接三次握手

  • 在 TCP 协议中,主动发起请求的一端为客户端,被动链接的一端称为服务端。无论是客户端仍是服务端,TCP链接创建完后都能发送和接收数据,因此 TCP 也是一个全双工的协议。
  • 起初,两端都为 CLOSED 状态。在通讯开始前,双方都会建立 TCB。 服务器建立完 TCB 后遍进入 LISTEN 状态,此时开始等待客户端发送数据

第一次握手

客户端向服务端发送链接请求报文段。该报文段中包含自身的数据通信初始序号。请求发送后,客户端便进入 SYN-SENT 状态,x 表示客户端的数据通讯初始序号。

第二次握手

服务端收到链接请求报文段后,若是赞成链接,则会发送一个应答,该应答中也会包含自身的数据通信初始序号,发送完成后便进入 SYN-RECEIVED 状态。

第三次握手

当客户端收到链接赞成的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED状态,此时链接创建成功。

  • PS:第三次握手能够包含数据,经过 TCP 快速打开(TFO)技术。其实只要涉及到握手的协议,均可以使用相似 TFO 的方式,客户端和服务端存储相同 cookie,下次握手时发出 cookie达到减小 RTT 的目的

你是否有疑惑明明两次握手就能够创建起链接,为何还须要第三次应答?

  • 由于这是为了防止失效的链接请求报文段被服务端接收,从而产生错误

能够想象以下场景。客户端发送了一个链接请求 A,可是由于网络缘由形成了超时,这时 TCP 会启动超时重传的机制再次发送一个链接请求 B。此时请求顺利到达服务端,服务端应答完就创建了请求。若是链接请求 A 在两端关闭后终于抵达了服务端,那么这时服务端会认为客户端又须要创建 TCP 链接,从而应答了该请求并进入 ESTABLISHED 状态。此时客户端实际上是 CLOSED 状态,那么就会致使服务端一直等待,形成资源的浪费

PS:在创建链接中,任意一端掉线,TCP 都会重发 SYN 包,通常会重试五次,在创建链接中可能会遇到 SYN FLOOD 攻击。遇到这种状况你能够选择调低重试次数或者干脆在不能处理的状况下拒绝请求

断开连接四次握手

TCP 是全双工的,在断开链接时两端都须要发送 FIN 和 ACK

第一次握手

若客户端 A 认为数据发送完成,则它须要向服务端 B 发送链接释放请求。

第二次握手

B 收到链接释放请求后,会告诉应用层要释放 TCP 连接。而后会发送 ACK 包,并进入 CLOSE_WAIT 状态,表示 A 到 B 的链接已经释放,不接收 A 发的数据了。可是由于 TCP 链接时双向的,因此 B 仍旧能够发送数据给 A。

第三次握手

B 若是此时还有没发完的数据会继续发送,完毕后会向 A 发送链接释放请求,而后 B 便进入 LAST-ACK 状态。

PS:经过延迟确认的技术(一般有时间限制,不然对方会误认为须要重传),能够将第二次和第三次握手合并,延迟 ACK 包的发送。

第四次握手

  • A 收到释放请求后,向 B 发送确认应答,此时 A 进入 TIME-WAIT 状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有 B 的重发请求的话,就进入 CLOSED 状态。当 B 收到确认应答后,也便进入 CLOSED 状态。

为何 A 要进入 TIME-WAIT 状态,等待 2MSL 时间后才进入 CLOSED 状态?

  • 为了保证 B 能收到 A 的确认应答。若 A 发完确认应答后直接进入 CLOSED 状态,若是确认应答由于网络问题一直没有到达,那么会形成 B 不能正常关闭

#3 HTTP

HTTP 协议是个无状态协议,不会保存状态

3.1 Post 和 Get 的区别

  • Get请求能缓存,Post 不能
  • Post 相对 Get安全一点点,由于Get 请求都包含在 URL 里,且会被浏览器保存历史纪录,Post 不会,可是在抓包的状况下都是同样的。
  • Post 能够经过 request body来传输比 Get 更多的数据,Get没有这个技术
  • URL有长度限制,会影响 Get请求,可是这个长度限制是浏览器规定的,不是 RFC 规定的
  • Post 支持更多的编码类型且不对数据类型限制

3.2 常见状态码

2XX 成功

  • 200 OK,表示从客户端发来的请求在服务器端被正确处理
  • 204 No content,表示请求成功,但响应报文不含实体的主体部分
  • 205 Reset Content,表示请求成功,但响应报文不含实体的主体部分,可是与 204 响应不一样在于要求请求方重置内容
  • 206 Partial Content,进行范围请求

3XX 重定向

  • 301 moved permanently,永久性重定向,表示资源已被分配了新的 URL
  • 302 found,临时性重定向,表示资源临时被分配了新的 URL
  • 303 see other,表示资源存在着另外一个 URL,应使用 GET 方法丁香获取资源
  • 304 not modified,表示服务器容许访问资源,但因发生请求未知足条件的状况
  • 307 temporary redirect,临时重定向,和302含义相似,可是指望客户端保持请求方法不变向新的地址发出请求

4XX 客户端错误

  • 400 bad request,请求报文存在语法错误
  • 401 unauthorized,表示发送的请求须要有经过 HTTP认证的认证信息
  • 403 forbidden,表示对请求资源的访问被服务器拒绝
  • 404 not found,表示在服务器上没有找到请求的资源

5XX 服务器错误

  • 500 internal sever error,表示服务器端在执行请求时发生了错误
  • 501 Not Implemented,表示服务器不支持当前请求所须要的某个功能
  • 503 service unavailable,代表服务器暂时处于超负载或正在停机维护,没法处理请求

3.3 HTTP 首部

通用字段 做用
Cache-Control 控制缓存的行为
Connection 浏览器想要优先使用的链接类型,好比 keep-alive
Date 建立报文时间
Pragma 报文指令
Via 代理服务器相关信息
Transfer-Encoding 传输编码方式
Upgrade 要求客户端升级协议
Warning 在内容中可能存在错误
请求字段 做用
Accept 能正确接收的媒体类型
Accept-Charset 能正确接收的字符集
Accept-Encoding 能正确接收的编码格式列表
Accept-Language 能正确接收的语言列表
Expect 期待服务端的指定行为
From 请求方邮箱地址
Host 服务器的域名
If-Match 两端资源标记比较
If-Modified-Since 本地资源未修改返回 304(比较时间)
If-None-Match 本地资源未修改返回 304(比较标记)
User-Agent 客户端信息
Max-Forwards 限制可被代理及网关转发的次数
Proxy-Authorization 向代理服务器发送验证信息
Range 请求某个内容的一部分
Referer 表示浏览器所访问的前一个页面
TE 传输编码方式
响应字段 做用
Accept-Ranges 是否支持某些种类的范围
Age 资源在代理缓存中存在的时间
ETag 资源标识
Location 客户端重定向到某个 URL
Proxy-Authenticate 向代理服务器发送验证信息
Server 服务器名字
WWW-Authenticate 获取资源须要的验证信息
实体字段 做用
Allow 资源的正确请求方式
Content-Encoding 内容的编码格式
Content-Language 内容使用的语言
Content-Length request body 长度
Content-Location 返回数据的备用地址
Content-MD5 Base64加密格式的内容MD5检验值
Content-Range 内容的位置范围
Content-Type 内容的媒体类型
Expires 内容的过时时间
Last_modified 内容的最后修改时间

#4 DNS

DNS 的做用就是经过域名查询到具体的 IP。

  • 由于 IP 存在数字和英文的组合(IPv6),很不利于人类记忆,因此就出现了域名。你能够把域名当作是某个 IP 的别名,DNS 就是去查询这个别名的真正名称是什么

在 TCP 握手以前就已经进行了 DNS 查询,这个查询是操做系统本身作的。当你在浏览器中想访问 www.google.com 时,会进行一下操做

  • 操做系统会首先在本地缓存中查询
  • 没有的话会去系统配置的 DNS 服务器中查询
  • 若是这时候还没得话,会直接去 DNS 根服务器查询,这一步查询会找出负责 com 这个一级域名的服务器
  • 而后去该服务器查询 google 这个二级域名
  • 接下来三级域名的查询实际上是咱们配置的,你能够给 www 这个域名配置一个 IP,而后还能够给别的三级域名配置一个 IP

以上介绍的是 DNS 迭代查询,还有种是递归查询,区别就是前者是由客户端去作请求,后者是由系统配置的 DNS 服务器作请求,获得结果后将数据返回给客户端。

#2、数据结构

#2.1 栈

概念

  • 栈是一个线性结构,在计算机中是一个至关常见的数据结构。
  • 栈的特色是只能在某一端添加或删除数据,遵循先进后出的原则

实现

每种数据结构均可以用不少种方式来实现,其实能够把栈当作是数组的一个子集,因此这里使用数组来实现

class Stack { constructor() { this.stack = [] } push(item) { this.stack.push(item) } pop() { this.stack.pop() } peek() { return this.stack[this.getCount() - 1] } getCount() { return this.stack.length } isEmpty() { return this.getCount() === 0 } } 

应用

匹配括号,能够经过栈的特性来完成

var isValid = function (s) { let map = { '(': -1, ')': 1, '[': -2, ']': 2, '{': -3, '}': 3 } let stack = [] for (let i = 0; i < s.length; i++) { if (map[s[i]] < 0) { stack.push(s[i]) } else { let last = stack.pop() if (map[last] + map[s[i]] != 0) return false } } if (stack.length > 0) return false return true }; 

#2.2 队列

概念

队列一个线性结构,特色是在某一端添加数据,在另外一端删除数据,遵循先进先出的原则

实现

这里会讲解两种实现队列的方式,分别是单链队列和循环队列

  • 单链队列
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 } } 

由于单链队列在出队操做的时候须要 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 } } 

#2.3 链表

概念

链表是一个线性结构,同时也是一个自然的递归结构。链表结构能够充分利用计算机内存空间,实现灵活的内存动态管理。可是链表失去了数组随机读取的优势,同时链表因为增长告终点的指针域,空间开销比较大

实现

  • 单向链表
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 } } 

#2.4 树

二叉树

  • 树拥有不少种结构,二叉树是树中最经常使用的结构,同时也是一个自然的递归结构。
  • 二叉树拥有一个根节点,每一个节点至多拥有两个子节点,分别为:左节点和右节点。树的最底部节点称之为叶节点,当一颗树的叶数量数量为满时,该树能够称之为满二叉树

二分搜索树

  • 二分搜索树也是二叉树,拥有二叉树的特性。可是区别在于二分搜索树每一个节点的值都比他的左子树的值大,比右子树的值小
  • 这种存储方式很适合于数据搜索。以下图所示,当须要查找 6 的时候,由于须要查找的值比根节点的值大,因此只须要在根节点的右子树上寻找,大大提升了搜索效率

  • 实现
class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
  }
}
class BST {
  constructor() {
    this.root = null
    this.size = 0
  }
  getSize() {
    return this.size
  }
  isEmpty() {
    return this.size === 0
  }
  addNode(v) {
    this.root = this._addChild(this.root, v)
  }
  // 添加节点时,须要比较添加的节点值和当前
  // 节点值的大小
  _addChild(node, v) {
    if (!node) {
      this.size++
      return new Node(v)
    }
    if (node.value > v) {
      node.left = this._addChild(node.left, v)
    } else if (node.value < v) {
      node.right = this._addChild(node.right, v)
    }
    return node
  }
}
  • 以上是最基本的二分搜索树实现,接下来实现树的遍历。

对于树的遍从来说,有三种遍历方法,分别是先序遍历、中序遍历、后序遍历。三种遍历的区别在于什么时候访问节点。在遍历树的过程当中,每一个节点都会遍历三次,分别是遍历到本身,遍历左子树和遍历右子树。若是须要实现先序遍历,那么只须要第一次遍历到节点时进行操做便可

// 先序遍历可用于打印树的结构 // 先序遍历先访问根节点,而后访问左节点,最后访问右节点。 preTraversal() { this._pre(this.root) } _pre(node) { if (node) { console.log(node.value) this._pre(node.left) this._pre(node.right) } } // 中序遍历可用于排序 // 对于 BST 来讲,中序遍历能够实现一次遍历就 // 获得有序的值 // 中序遍历表示先访问左节点,而后访问根节点,最后访问右节点。 midTraversal() { this._mid(this.root) } _mid(node) { if (node) { this._mid(node.left) console.log(node.value) this._mid(node.right) } } // 后序遍历可用于先操做子节点 // 再操做父节点的场景 // 后序遍历表示先访问左节点,而后访问右节点,最后访问根节点。 backTraversal() { this._back(this.root) } _back(node) { if (node) { this._back(node.left) this._back(node.right) console.log(node.value) } } 

以上的这几种遍历均可以称之为深度遍历,对应的还有种遍历叫作广度遍历,也就是一层层地遍历树。对于广度遍从来说,咱们须要利用以前讲过的队列结构来完成

breadthTraversal() { if (!this.root) return null let q = new Queue() // 将根节点入队 q.enQueue(this.root) // 循环判断队列是否为空,为空 // 表明树遍历完毕 while (!q.isEmpty()) { // 将队首出队,判断是否有左右子树 // 有的话,就先左后右入队 let n = q.deQueue() console.log(n.value) if (n.left) q.enQueue(n.left) if (n.right) q.enQueue(n.right) } } 

接下来先介绍如何在树中寻找最小值或最大数。由于二分搜索树的特性,因此最小值必定在根节点的最左边,最大值相反

getMin() { return this._getMin(this.root).value } _getMin(node) { if (!node.left) return node return this._getMin(node.left) } getMax() { return this._getMax(this.root).value } _getMax(node) { if (!node.right) return node return this._getMin(node.right) } 

向上取整和向下取整,这两个操做是相反的,因此代码也是相似的,这里只介绍如何向下取整。既然是向下取整,那么根据二分搜索树的特性,值必定在根节点的左侧。只须要一直遍历左子树直到当前节点的值再也不大于等于须要的值,而后判断节点是否还拥有右子树。若是有的话,继续上面的递归判断

floor(v) { let node = this._floor(this.root, v) return node ? node.value : null } _floor(node, v) { if (!node) return null if (node.value === v) return v // 若是当前节点值还比须要的值大,就继续递归 if (node.value > v) { return this._floor(node.left, v) } // 判断当前节点是否拥有右子树 let right = this._floor(node.right, v) if (right) return right return node } 

排名,这是用于获取给定值的排名或者排名第几的节点的值,这两个操做也是相反的,因此这个只介绍如何获取排名第几的节点的值。对于这个操做而言,咱们须要略微的改造点代码,让每一个节点拥有一个 size 属性。该属性表示该节点下有多少子节点(包含自身)

class Node { constructor(value) { this.value = value this.left = null this.right = null // 修改代码 this.size = 1 } } // 新增代码 _getSize(node) { return node ? node.size : 0 } _addChild(node, v) { if (!node) { return new Node(v) } if (node.value > v) { // 修改代码 node.size++ node.left = this._addChild(node.left, v) } else if (node.value < v) { // 修改代码 node.size++ node.right = this._addChild(node.right, v) } return node } select(k) { let node = this._select(this.root, k) return node ? node.value : null } _select(node, k) { if (!node) return null // 先获取左子树下有几个节点 let size = node.left ? node.left.size : 0 // 判断 size 是否大于 k // 若是大于 k,表明所须要的节点在左节点 if (size > k) return this._select(node.left, k) // 若是小于 k,表明所须要的节点在右节点 // 注意这里须要从新计算 k,减去根节点除了右子树的节点数量 if (size < k) return this._select(node.right, k - size - 1) return node } 

接下来说解的是二分搜索树中最难实现的部分:删除节点。由于对于删除节点来讲,会存在如下几种状况

  • 须要删除的节点没有子树
  • 须要删除的节点只有一条子树
  • 须要删除的节点有左右两条树
  • 对于前两种状况很好解决,可是第三种状况就有难度了,因此先来实现相对简单的操做:删除最小节点,对于删除最小节点来讲,是不存在第三种状况的,删除最大节点操做是和删除最小节点相反的,因此这里也就再也不赘述
delectMin() { this.root = this._delectMin(this.root) console.log(this.root) } _delectMin(node) { // 一直递归左子树 // 若是左子树为空,就判断节点是否拥有右子树 // 有右子树的话就把须要删除的节点替换为右子树 if ((node != null) & !node.left) return node.right node.left = this._delectMin(node.left) // 最后须要从新维护下节点的 `size` node.size = this._getSize(node.left) + this._getSize(node.right) + 1 return node } 
  • 最后讲解的就是如何删除任意节点了。对于这个操做,T.Hibbard 在 1962年提出了解决这个难题的办法,也就是如何解决第三种状况。
  • 当遇到这种状况时,须要取出当前节点的后继节点(也就是当前节点右子树的最小节点)来替换须要删除的节点。而后将须要删除节点的左子树赋值给后继结点,右子树删除后继结点后赋值给他。
  • 你若是对于这个解决办法有疑问的话,能够这样考虑。由于二分搜索树的特性,父节点必定比全部左子节点大,比全部右子节点小。那么当须要删除父节点时,势必须要拿出一个比父节点大的节点来替换父节点。这个节点确定不存在于左子树,必然存在于右子树。而后又须要保持父节点都是比右子节点小的,那么就能够取出右子树中最小的那个节点来替换父节点
delect(v) { this.root = this._delect(this.root, v) } _delect(node, v) { if (!node) return null // 寻找的节点比当前节点小,去左子树找 if (node.value < v) { node.right = this._delect(node.right, v) } else if (node.value > v) { // 寻找的节点比当前节点大,去右子树找 node.left = this._delect(node.left, v) } else { // 进入这个条件说明已经找到节点 // 先判断节点是否拥有拥有左右子树中的一个 // 是的话,将子树返回出去,这里和 `_delectMin` 的操做同样 if (!node.left) return node.right if (!node.right) return node.left // 进入这里,表明节点拥有左右子树 // 先取出当前节点的后继结点,也就是取当前节点右子树的最小值 let min = this._getMin(node.right) // 取出最小值后,删除最小值 // 而后把删除节点后的子树赋值给最小值节点 min.right = this._delectMin(node.right) // 左子树不动 min.left = node.left node = min } // 维护 size node.size = this._getSize(node.left) + this._getSize(node.right) + 1 return node } 

#2.5 堆

概念

  • 堆一般是一个能够被看作一棵树的数组对象。
  • 堆的实现经过构造二叉堆,实为二叉树的一种。这种数据结构具备如下性质。
  • 任意节点小于(或大于)它的全部子节点 堆老是一棵彻底树。即除了最底层,其余层的节点都被元素填满,且最底层从左到右填入。
  • 将根节点最大的堆叫作最大堆或大根堆,根节点最小的堆叫作最小堆或小根堆。
  • 优先队列也彻底能够用堆来实现,操做是如出一辙的。

实现大根堆

堆的每一个节点的左边子节点索引是 i * 2 + 1,右边是 i * 2 + 2,父节点是 (i - 1) /2

  • 堆有两个核心的操做,分别是 shiftUp 和 shiftDown 。前者用于添加元素,后者用于删除根节点。
  • shiftUp 的核心思路是一路将节点与父节点对比大小,若是比父节点大,就和父节点交换位置。
  • shiftDown 的核心思路是先将根节点和末尾交换位置,而后移除末尾元素。接下来循环判断父节点和两个子节点的大小,若是子节点大,就把最大的子节点和父节点交换

class MaxHeap { constructor() { this.heap = [] } size() { return this.heap.length } empty() { return this.size() == 0 } add(item) { this.heap.push(item) this._shiftUp(this.size() - 1) } removeMax() { this._shiftDown(0) } getParentIndex(k) { return parseInt((k - 1) / 2) } getLeftIndex(k) { return k * 2 + 1 } _shiftUp(k) { // 若是当前节点比父节点大,就交换 while (this.heap[k] > this.heap[this.getParentIndex(k)]) { this._swap(k, this.getParentIndex(k)) // 将索引变成父节点 k = this.getParentIndex(k) } } _shiftDown(k) { // 交换首位并删除末尾 this._swap(k, this.size() - 1) this.heap.splice(this.size() - 1, 1) // 判断节点是否有左孩子,由于二叉堆的特性,有右必有左 while (this.getLeftIndex(k) < this.size()) { let j = this.getLeftIndex(k) // 判断是否有右孩子,而且右孩子是否大于左孩子 if (j + 1 < this.size() && this.heap[j + 1] > this.heap[j]) j++ // 判断父节点是否已经比子节点都大 if (this.heap[k] >= this.heap[j]) break this._swap(k, j) k = j } } _swap(left, right) { let rightValue = this.heap[right] this.heap[right] = this.heap[left] this.heap[left] = rightValue } } 

#3、算法

#3.1 时间复杂度

  • 一般使用最差的时间复杂度来衡量一个算法的好坏。
  • 常数时间 O(1) 表明这个操做和数据量不要紧,是一个固定时间的操做,好比说四则运算。
  • 对于一个算法来讲,可能会计算出以下操做次数 aN +1,N 表明数据量。那么该算法的时间复杂度就是 O(N)。由于咱们在计算时间复杂度的时候,数据量一般是很是大的,这时候低阶项和常数项能够忽略不计。
  • 固然可能会出现两个算法都是 O(N) 的时间复杂度,那么对比两个算法的好坏就要经过对比低阶项和常数项了

#3.2 位运算

  • 位运算在算法中颇有用,速度能够比四则运算快不少。
  • 在学习位运算以前应该知道十进制如何转二进制,二进制如何转十进制。这里说明下简单的计算方式
  • 十进制 33 能够当作是 32 + 1 ,而且 33 应该是六位二进制的(由于 33近似 32,而 32 是 2的五次方,因此是六位),那么 十进制 33 就是 100001 ,只要是 2 的次方,那么就是 1不然都为 0 那么二进制 100001 同理,首位是 2^5,末位是 2^0 ,相加得出 33

左移 <<

10 << 1 // -> 20

左移就是将二进制所有往左移动,10在二进制中表示为 1010 ,左移一位后变成 10100 ,转换为十进制也就是 20,因此基本能够把左移当作如下公式 a * (2 ^ b)

算数右移 >>

10 >> 1 // -> 5
  • 算数右移就是将二进制所有往右移动并去除多余的右边,10 在二进制中表示为 1010 ,右移一位后变成 101 ,转换为十进制也就是 5,因此基本能够把右移当作如下公式 int v = a / (2 ^ b)
  • 右移很好用,好比能够用在二分算法中取中间值
13 >> 1 // -> 6

按位操做

  • 按位与

每一位都为 1,结果才为 1

8 & 7 // -> 0
// 1000 & 0111 -> 0000 -> 0
  • 按位或

其中一位为 1,结果就是 1

8 | 7 // -> 15
// 1000 | 0111 -> 1111 -> 15
  • 按位异或

每一位都不一样,结果才为 1

8 ^ 7 // -> 15
8 ^ 8 // -> 0
// 1000 ^ 0111 -> 1111 -> 15
// 1000 ^ 1000 -> 0000 -> 0

面试题:两个数不使用四则运算得出和

这道题中能够按位异或,由于按位异或就是不进位加法,8 ^ 8 = 0 若是进位了,就是 16 了,因此咱们只须要将两个数进行异或操做,而后进位。那么也就是说两个二进制都是 1 的位置,左边应该有一个进位 1,因此能够得出如下公式 a + b = (a ^ b) + ((a & b) << 1) ,而后经过迭代的方式模拟加法

function sum(a, b) { if (a == 0) return b if (b == 0) return a let newA = a ^ b let newB = (a & b) << 1 return sum(newA, newB) } 

#3.3 排序

冒泡排序

冒泡排序的原理以下,从第一个元素开始,把当前元素和下一个索引元素进行比较。若是当前元素大,那么就交换位置,重复操做直到比较到最后一个元素,那么此时最后一个元素就是该数组中最大的数。下一轮重复以上操做,可是此时最后一个元素已是最大数了,因此不须要再比较最后一个元素,只须要比较到 length - 1 的位置

如下是实现该算法的代码

function bubble(array) { checkArray(array); for (let i = array.length - 1; i > 0; i--) { // 从 0 到 `length - 1` 遍历 for (let j = 0; j < i; j++) { if (array[j] > array[j + 1]) swap(array, j, j + 1) } } return array; } 

该算法的操做次数是一个等差数列 n + (n - 1) + (n - 2) + 1 ,去掉常数项之后得出时间复杂度是O(n * n)

插入排序

入排序的原理以下。第一个元素默认是已排序元素,取出下一个元素和当前元素比较,若是当前元素大就交换位置。那么此时第一个元素就是当前的最小数,因此下次取出操做从第三个元素开始,向前对比,重复以前的操做

如下是实现该算法的代码

function insertion(array) { checkArray(array); for (let i = 1; i < array.length; i++) { for (let j = i - 1; j >= 0 && array[j] > array[j + 1]; j--) swap(array, j, j + 1); } return array; } 

该算法的操做次数是一个等差数列 n + (n - 1) + (n - 2) + 1 ,去掉常数项之后得出时间复杂度是 O(n * n)

选择排序

选择排序的原理以下。遍历数组,设置最小值的索引为 0,若是取出的值比当前最小值小,就替换最小值索引,遍历完成后,将第一个元素和最小值索引上的值交换。如上操做后,第一个元素就是数组中的最小值,下次遍历就能够从索引 1 开始重复上述操做

如下是实现该算法的代码

function selection(array) { checkArray(array); for (let i = 0; i < array.length - 1; i++) { let minIndex = i; for (let j = i + 1; j < array.length; j++) { minIndex = array[j] < array[minIndex] ? j : minIndex; } swap(array, i, minIndex); } return array; } 

该算法的操做次数是一个等差数列 n + (n - 1) + (n - 2) + 1 ,去掉常数项之后得出时间复杂度是 O(n * n)

归并排序

归并排序的原理以下。递归的将数组两两分开直到最多包含两个元素,而后将数组排序合并,最终合并为排序好的数组。假设我有一组数组 [3, 1, 2, 8, 9, 7, 6],中间数索引是 3,先排序数组 [3, 1, 2, 8] 。在这个左边数组上,继续拆分直到变成数组包含两个元素(若是数组长度是奇数的话,会有一个拆分数组只包含一个元素)。而后排序数组 [3, 1] 和 [2, 8] ,而后再排序数组 [1, 3, 2, 8] ,这样左边数组就排序完成,而后按照以上思路排序右边数组,最后将数组 [1, 2, 3, 8] 和 [6, 7, 9] 排序

如下是实现该算法的代码

function sort(array) { checkArray(array); mergeSort(array, 0, array.length - 1); return array; } function mergeSort(array, left, right) { // 左右索引相同说明已经只有一个数 if (left === right) return; // 等同于 `left + (right - left) / 2` // 相比 `(left + right) / 2` 来讲更加安全,不会溢出 // 使用位运算是由于位运算比四则运算快 let mid = parseInt(left + ((right - left) >> 1)); mergeSort(array, left, mid); mergeSort(array, mid + 1, right); let help = []; let i = 0; let p1 = left; let p2 = mid + 1; while (p1 <= mid && p2 <= right) { help[i++] = array[p1] < array[p2] ? array[p1++] : array[p2++]; } while (p1 <= mid) { help[i++] = array[p1++]; } while (p2 <= right) { help[i++] = array[p2++]; } for (let i = 0; i < help.length; i++) { array[left + i] = help[i]; } return array; } 

以上算法使用了递归的思想。递归的本质就是压栈,每递归执行一次函数,就将该函数的信息(好比参数,内部的变量,执行到的行数)压栈,直到遇到终止条件,而后出栈并继续执行函数。对于以上递归函数的调用轨迹以下

mergeSort(data, 0, 6) // mid = 3 mergeSort(data, 0, 3) // mid = 1 mergeSort(data, 0, 1) // mid = 0 mergeSort(data, 0, 0) // 遇到终止,回退到上一步 mergeSort(data, 1, 1) // 遇到终止,回退到上一步 // 排序 p1 = 0, p2 = mid + 1 = 1 // 回退到 `mergeSort(data, 0, 3)` 执行下一个递归 mergeSort(2, 3) // mid = 2 mergeSort(3, 3) // 遇到终止,回退到上一步 // 排序 p1 = 2, p2 = mid + 1 = 3 // 回退到 `mergeSort(data, 0, 3)` 执行合并逻辑 // 排序 p1 = 0, p2 = mid + 1 = 2 // 执行完毕回退 // 左边数组排序完毕,右边也是如上轨迹 

该算法的操做次数是能够这样计算:递归了两次,每次数据量是数组的一半,而且最后把整个数组迭代了一次,因此得出表达式 2T(N / 2) + T(N) (T 表明时间,N 表明数据量)。根据该表达式能够套用 该公式 得出时间复杂度为 O(N * logN)

快排

快排的原理以下。随机选取一个数组中的值做为基准值,从左至右取值与基准值对比大小。比基准值小的放数组左边,大的放右边,对比完成后将基准值和第一个比基准值大的值交换位置。而后将数组以基准值的位置分为两部分,继续递归以上操做。

如下是实现该算法的代码

function sort(array) { checkArray(array); quickSort(array, 0, array.length - 1); return array; } function quickSort(array, left, right) { if (left < right) { swap(array, , right) // 随机取值,而后和末尾交换,这样作比固定取一个位置的复杂度略低 let indexs = part(array, parseInt(Math.random() * (right - left + 1)) + left, right); quickSort(array, left, indexs[0]); quickSort(array, indexs[1] + 1, right); } } function part(array, left, right) { let less = left - 1; let more = right; while (left < more) { if (array[left] < array[right]) { // 当前值比基准值小,`less` 和 `left` 都加一 ++less; ++left; } else if (array[left] > array[right]) { // 当前值比基准值大,将当前值和右边的值交换 // 而且不改变 `left`,由于当前换过来的值尚未判断过大小 swap(array, --more, left); } else { // 和基准值相同,只移动下标 left++; } } // 将基准值和比基准值大的第一个值交换位置 // 这样数组就变成 `[比基准值小, 基准值, 比基准值大]` swap(array, right, more); return [less, more]; } 

该算法的复杂度和归并排序是相同的,可是额外空间复杂度比归并排序少,只需 O(logN),而且相比归并排序来讲,所需的常数时间也更少

面试题

Sort Colors:该题目来自 LeetCode,题目须要咱们将 [2,0,2,1,1,0] 排序成 [0,0,1,1,2,2],这个问题就可使用三路快排的思想

var sortColors = function(nums) { let left = -1; let right = nums.length; let i = 0; // 下标若是遇到 right,说明已经排序完成 while (i < right) { if (nums[i] == 0) { swap(nums, i++, ++left); } else if (nums[i] == 1) { i++; } else { swap(nums, i, --right); } } }; 

#3.4 链表

反转单向链表

该题目来自 LeetCode,题目须要将一个单向链表反转。思路很简单,使用三个变量分别表示当前节点和当前节点的先后节点,虽然这题很简单,可是倒是一道面试常考题

var reverseList = function(head) { // 判断下变量边界问题 if (!head || !head.next) return head // 初始设置为空,由于第一个节点反转后就是尾部,尾部节点指向 null let pre = null let current = head let next // 判断当前节点是否为空 // 不为空就先获取当前节点的下一节点 // 而后把当前节点的 next 设为上一个节点 // 而后把 current 设为下一个节点,pre 设为当前节点 while(current) { next = current.next current.next = pre pre = current current = next } return pre }; 

#3.5 树

二叉树的先序,中序,后序遍历

  • 先序遍历表示先访问根节点,而后访问左节点,最后访问右节点。
  • 中序遍历表示先访问左节点,而后访问根节点,最后访问右节点。
  • 后序遍历表示先访问左节点,而后访问右节点,最后访问根节点

递归实现

递归实现至关简单,代码以下

function TreeNode(val) { this.val = val; this.left = this.right = null; } var traversal = function(root) { if (root) { // 先序 console.log(root); traversal(root.left); // 中序 // console.log(root); traversal(root.right); // 后序 // console.log(root); } }; 

对于递归的实现来讲,只须要理解每一个节点都会被访问三次就明白为何这样实现了

非递归实现

非递归实现使用了栈的结构,经过栈的先进后出模拟递归实现。

如下是先序遍历代码实现

function pre(root) { if (root) { let stack = []; // 先将根节点 push stack.push(root); // 判断栈中是否为空 while (stack.length > 0) { // 弹出栈顶元素 root = stack.pop(); console.log(root); // 由于先序遍历是先左后右,栈是先进后出结构 // 因此先 push 右边再 push 左边 if (root.right) { stack.push(root.right); } if (root.left) { stack.push(root.left); } } } } 

如下是中序遍历代码实现

function mid(root) { if (root) { let stack = []; // 中序遍历是先左再根最后右 // 因此首先应该先把最左边节点遍历到底依次 push 进栈 // 当左边没有节点时,就打印栈顶元素,而后寻找右节点 // 对于最左边的叶节点来讲,能够把它当作是两个 null 节点的父节点 // 左边打印不出东西就把父节点拿出来打印,而后再看右节点 while (stack.length > 0 || root) { if (root) { stack.push(root); root = root.left; } else { root = stack.pop(); console.log(root); root = root.right; } } } } 

如下是后序遍历代码实现,该代码使用了两个栈来实现遍历,相比一个栈的遍从来说要容易理解不少

function pos(root) { if (root) { let stack1 = []; let stack2 = []; // 后序遍历是先左再右最后根 // 因此对于一个栈来讲,应该先 push 根节点 // 而后 push 右节点,最后 push 左节点 stack1.push(root); while (stack1.length > 0) { root = stack1.pop(); stack2.push(root); if (root.left) { stack1.push(root.left); } if (root.right) { stack1.push(root.right); } } while (stack2.length > 0) { console.log(s2.pop()); } } } 

中序遍历的前驱后继节点

实现这个算法的前提是节点有一个 parent 的指针指向父节点,根节点指向 null

如图所示,该树的中序遍历结果是 4, 2, 5, 1, 6, 3, 7

前驱节点

对于节点 2 来讲,他的前驱节点就是 4 ,按照中序遍历原则,能够得出如下结论

  • 若是选取的节点的左节点不为空,就找该左节点最右的节点。对于节点 1 来讲,他有左节点 2 ,那么节点 2 的最右节点就是 5
  • 若是左节点为空,且目标节点是父节点的右节点,那么前驱节点为父节点。对于节点 5 来讲,没有左节点,且是节点 2 的右节点,因此节点 2 是前驱节点
  • 若是左节点为空,且目标节点是父节点的左节点,向上寻找到第一个是父节点的右节点的节点。对于节点 6 来讲,没有左节点,且是节点 3 的左节点,因此向上寻找到节点 1 ,发现节点 3 是节点 1 的右节点,因此节点 1 是节点 6 的前驱节点

如下是算法实现

function predecessor(node) { if (!node) return // 结论 1 if (node.left) { return getRight(node.left) } else { let parent = node.parent // 结论 2 3 的判断 while(parent && parent.right === node) { node = parent parent = node.parent } return parent } } function getRight(node) { if (!node) return node = node.right while(node) node = node.right return node } 

后继节点

对于节点 2 来讲,他的后继节点就是 5 ,按照中序遍历原则,能够得出如下结论

  • 若是有右节点,就找到该右节点的最左节点。对于节点 1 来讲,他有右节点 3 ,那么节点 3 的最左节点就是 6
  • 若是没有右节点,就向上遍历直到找到一个节点是父节点的左节点。对于节点 5 来讲,没有右节点,就向上寻找到节点 2 ,该节点是父节点 1 的左节点,因此节点 1 是后继节点 如下是算法实现
function successor(node) { if (!node) return // 结论 1 if (node.right) { return getLeft(node.right) } else { // 结论 2 let parent = node.parent // 判断 parent 为空 while(parent && parent.left === node) { node = parent parent = node.parent } return parent } } function getLeft(node) { if (!node) return node = node.left while(node) node = node.left return node } 

树的深度

树的最大深度:该题目来自 Leetcode,题目须要求出一颗二叉树的最大深度

如下是算法实现

var maxDepth = function(root) { if (!root) return 0 return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1 }; 

对于该递归函数能够这样理解:一旦没有找到节点就会返回 0,每弹出一次递归函数就会加一,树有三层就会获得3

相关文章
相关标签/搜索