前端面試問題答案彙總--通識篇

轉載於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

相關文章
相關標籤/搜索