前端-计算机基础
一、網絡
#1 UDP
1.1 面向報文
UDP?是一個面向報文(報文可以理解為一段段的數據)的協議。意思就是?UDP?只是報文的搬運工,不會對報文進行任何拆分和拼接操作
具體來說
- 在發送端,應用層將數據傳遞給傳輸層的?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=1,ACK=0時,表示當前報文段是一個連接請求報文。當SYN=1,ACK=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.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 falsereturn true };#2.2 隊列
概念
隊列一個線性結構,特點是在某一端添加數據,在另一端刪除數據,遵循先進先出的原則
實現
這里會講解兩種實現隊列的方式,分別是單鏈隊列和循環隊列
- 單鏈隊列
因為單鏈隊列在出隊操作的時候需要?O(n)?的時間復雜度,所以引入了循環隊列。循環隊列的出隊操作平均是?O(1)?的時間復雜度
- 循環隊列
#2.3 鏈表
概念
鏈表是一個線性結構,同時也是一個天然的遞歸結構。鏈表結構可以充分利用計算機內存空間,實現靈活的內存動態管理。但是鏈表失去了數組隨機讀取的優點,同時鏈表由于增加了結點的指針域,空間開銷比較大
實現
- 單向鏈表
#2.4 樹
二叉樹
- 樹擁有很多種結構,二叉樹是樹中最常用的結構,同時也是一個天然的遞歸結構。
- 二叉樹擁有一個根節點,每個節點至多擁有兩個子節點,分別為:左節點和右節點。樹的最底部節點稱之為葉節點,當一顆樹的葉數量數量為滿時,該樹可以稱之為滿二叉樹
二分搜索樹
- 二分搜索樹也是二叉樹,擁有二叉樹的特性。但是區別在于二分搜索樹每個節點的值都比他的左子樹的值大,比右子樹的值小
- 這種存儲方式很適合于數據搜索。如下圖所示,當需要查找 6 的時候,因為需要查找的值比根節點的值大,所以只需要在根節點的右子樹上尋找,大大提高了搜索效率
- 實現
- 以上是最基本的二分搜索樹實現,接下來實現樹的遍歷。
對于樹的遍歷來說,有三種遍歷方法,分別是先序遍歷、中序遍歷、后序遍歷。三種遍歷的區別在于何時訪問節點。在遍歷樹的過程中,每個節點都會遍歷三次,分別是遍歷到自己,遍歷左子樹和遍歷右子樹。如果需要實現先序遍歷,那么只需要第一次遍歷到節點時進行操作即可
// 先序遍歷可用于打印樹的結構 // 先序遍歷先訪問根節點,然后訪問左節點,最后訪問右節點。 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 nulllet 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 nodereturn this._getMin(node.left) } getMax() {return this._getMax(this.root).value } _getMax(node) {if (!node.right) return nodereturn this._getMin(node.right) }向上取整和向下取整,這兩個操作是相反的,所以代碼也是類似的,這里只介紹如何向下取整。既然是向下取整,那么根據二分搜索樹的特性,值一定在根節點的左側。只需要一直遍歷左子樹直到當前節點的值不再大于等于需要的值,然后判斷節點是否還擁有右子樹。如果有的話,繼續上面的遞歸判斷
floor(v) {let node = this._floor(this.root, v)return node ? node.value : null } _floor(node, v) {if (!node) return nullif (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 rightreturn node }排名,這是用于獲取給定值的排名或者排名第幾的節點的值,這兩個操作也是相反的,所以這個只介紹如何獲取排名第幾的節點的值。對于這個操作而言,我們需要略微的改造點代碼,讓每個節點擁有一個 size 屬性。該屬性表示該節點下有多少子節點(包含自身)
class Node {constructor(value) {this.value = valuethis.left = nullthis.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 }接下來講解的是二分搜索樹中最難實現的部分:刪除節點。因為對于刪除節點來說,會存在以下幾種情況
- 需要刪除的節點沒有子樹
- 需要刪除的節點只有一條子樹
- 需要刪除的節點有左右兩條樹
- 對于前兩種情況很好解決,但是第三種情況就有難度了,所以先來實現相對簡單的操作:刪除最小節點,對于刪除最小節點來說,是不存在第三種情況的,刪除最大節點操作是和刪除最小節點相反的,所以這里也就不再贅述
- 最后講解的就是如何刪除任意節點了。對于這個操作,T.Hibbard?在?1962年提出了解決這個難題的辦法,也就是如何解決第三種情況。
- 當遇到這種情況時,需要取出當前節點的后繼節點(也就是當前節點右子樹的最小節點)來替換需要刪除的節點。然后將需要刪除節點的左子樹賦值給后繼結點,右子樹刪除后繼結點后賦值給他。
- 你如果對于這個解決辦法有疑問的話,可以這樣考慮。因為二分搜索樹的特性,父節點一定比所有左子節點大,比所有右子節點小。那么當需要刪除父節點時,勢必需要拿出一個比父節點大的節點來替換父節點。這個節點肯定不存在于左子樹,必然存在于右子樹。然后又需要保持父節點都是比右子節點小的,那么就可以取出右子樹中最小的那個節點來替換父節點
#2.5 堆
概念
- 堆通常是一個可以被看做一棵樹的數組對象。
- 堆的實現通過構造二叉堆,實為二叉樹的一種。這種數據結構具有以下性質。
- 任意節點小于(或大于)它的所有子節點 堆總是一棵完全樹。即除了最底層,其他層的節點都被元素填滿,且最底層從左到右填入。
- 將根節點最大的堆叫做最大堆或大根堆,根節點最小的堆叫做最小堆或小根堆。
- 優先隊列也完全可以用堆來實現,操作是一模一樣的。
實現大根堆
堆的每個節點的左邊子節點索引是?i * 2 + 1,右邊是?i * 2 + 2,父節點是?(i - 1) /2。
- 堆有兩個核心的操作,分別是?shiftUp?和?shiftDown?。前者用于添加元素,后者用于刪除根節點。
- shiftUp?的核心思路是一路將節點與父節點對比大小,如果比父節點大,就和父節點交換位置。
- shiftDown?的核心思路是先將根節點和末尾交換位置,然后移除末尾元素。接下來循環判斷父節點和兩個子節點的大小,如果子節點大,就把最大的子節點和父節點交換
#三、算法
#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)
- 右移很好用,比如可以用在二分算法中取中間值
按位操作
- 按位與
每一位都為 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 bif (b == 0) return alet newA = a ^ blet newB = (a & b) << 1return 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 = 3mergeSort(data, 0, 3) // mid = 1mergeSort(data, 0, 1) // mid = 0mergeSort(data, 0, 0) // 遇到終止,回退到上一步mergeSort(data, 1, 1) // 遇到終止,回退到上一步// 排序 p1 = 0, p2 = mid + 1 = 1// 回退到 `mergeSort(data, 0, 3)` 執行下一個遞歸mergeSort(2, 3) // mid = 2mergeSort(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// 初始設置為空,因為第一個節點反轉后就是尾部,尾部節點指向 nulllet pre = nulllet current = headlet next// 判斷當前節點是否為空// 不為空就先獲取當前節點的下一節點// 然后把當前節點的 next 設為上一個節點// 然后把 current 設為下一個節點,pre 設為當前節點while(current) {next = current.nextcurrent.next = prepre = currentcurrent = 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 = [];// 先將根節點 pushstack.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// 結論 1if (node.left) {return getRight(node.left)} else {let parent = node.parent// 結論 2 3 的判斷while(parent && parent.right === node) {node = parentparent = node.parent}return parent} } function getRight(node) {if (!node) returnnode = node.rightwhile(node) node = node.rightreturn node }后繼節點
對于節點 2 來說,他的后繼節點就是 5 ,按照中序遍歷原則,可以得出以下結論
- 如果有右節點,就找到該右節點的最左節點。對于節點 1 來說,他有右節點 3 ,那么節點 3 的最左節點就是 6
- 如果沒有右節點,就向上遍歷直到找到一個節點是父節點的左節點。對于節點 5 來說,沒有右節點,就向上尋找到節點 2 ,該節點是父節點 1 的左節點,所以節點 1 是后繼節點 以下是算法實現
樹的深度
樹的最大深度:該題目來自 Leetcode,題目需要求出一顆二叉樹的最大深度
以下是算法實現
var maxDepth = function(root) {if (!root) return 0return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1 };對于該遞歸函數可以這樣理解:一旦沒有找到節點就會返回 0,每彈出一次遞歸函數就會加一,樹有三層就會得到3
總結