來源于王爭的《數據結構與算法之美》
一、數組
1)為什么很多編程語言中,數組的下標都從0開始編號?
數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據。因為有連續的內存空間和相同類型的數據,數組就可以實現隨機訪問。實現隨機訪問的方式是
a[i]_address = base_address + i * data_type_size
其中 data_type_size 表示數組中每個元素的大小。
如果用 a 來表示數組的首地址,a[0]就是偏移為 0 的位置,也就是首地址,a[k]就表示偏移 k 個 type_size 的位置,所以計算 a[k]的內存地址只需要用這個公式
a[k]_address = base_address + k * type_size
但是,如果數組從 1 開始計數,那我們計算數組元素 a[k]的內存地址就會變為:
a[k]_address = base_address + (k-1)*type_size
對比兩個公式,我們不難發現,從 1 開始編號,每次隨機訪問數組元素都多了一次減法運算,對于 CPU 來說,就是多了一次減法指令。
數組作為非常基礎的數據結構,通過下標隨機訪問數組元素又是其非?;A的編程操作,效率的優化就要盡可能做到極致。所以為了減少一次減法操作,數組選擇了從 0 開始編號,而不是從 1 開始。
2)數組和鏈表的區別
- 鏈表適合插入、刪除,時間復雜度 O(1);數組支持隨機訪問,根據下標隨機訪問的時間復雜度為 O(1)。
- 對內存要求方面:數組對內存的要求更高。因為數組需要一塊連續內存空間來存放數據。(可能出現的問題就是:內存總的剩余空間足夠,但是申請容量較大的數組時申請失敗) 鏈表對內存的要求較低,是因為鏈表不需要連續的內存空間,它通過“指針”將一組零散的內存塊串聯起來使用。 但是要注意:鏈表雖然方便。但是內存開銷比數組大了將近一倍
- 數組的缺點是大小固定,一經聲明就要占用整塊連續內存空間。如果聲明的數組過大,系統可能沒有足夠的連續內存空間分配給它,導致“內存不足(out of memory)”。如果聲明的數組過小,則可能出現不夠用的情況。這時只能再申請一個更大的內存空間,把原數組拷貝進去,非常費時。鏈表本身沒有大小的限制,天然地支持動態擴容,我覺得這也是它與數組最大的區別。
3)容器 vs 數組:java中的ArrayList與數組相比,到底有哪些優勢呢?
-
容器優勢:
1.ArrayList 最大的優勢就是可以將很多數組操作的細節封裝起來。比如數組插入、刪除數據時需要搬移其他數據等。
2.支持動態擴容。 -
數組優勢:
1.Java ArrayList 無法存儲基本類型,比如 int、long,需要封裝為 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的性能消耗,所以如果特別關注性能,或者希望使用基本類型,就可以選用數組。
2.如果數據大小事先已知,并且對數據的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用數組。
3.還有一個是我個人的喜好,當要表示多維數組時,用數組往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList > array。 - 總結:對于業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟性能,完全不會影響到系統整體的性能。但如果你是做一些非常底層的開發,比如開發網絡框架,性能的優化需要做到極致,這個時候數組就會優于容器,成為首選。
4)二維數組尋址
對于 m * n 的數組,a [ i ][ j ] (i < m,j < n)的地址為:address = base_address + ( i * n + j) * type_size
5)Java 中的JVM、聊一下對標記清除垃圾回收算法的理解
- 大多數主流虛擬機采用可達性分析算法來判斷對象是否存活,在標記階段,會遍歷所有 GC ROOTS,將所有 GC ROOTS 可達的對象標記為存活。只有當標記工作完成后,清理工作才會開始。
- 不足:
1.效率問題。標記和清理效率都不高,但是當知道只有少量垃圾產生時會很高效。
2.空間問題。會產生不連續的內存空間碎片。
leetcode
二分查找:https://leetcode-cn.com/problems/binary-search/
測試用例:
1)[-1,0,3,5,9,12],2 返回-1
2)[-1,0,3,5,9,12],9 返回4
- [2,5], 5 返回1
二、鏈表
1)三種最常見的鏈表結構:
- 單鏈表:內存塊稱作“結點”、鏈上的下一個結點的地址稱作“后繼指針”。頭結點用于記錄鏈表的基地址,尾結點的后繼指針指向一個空地址 NULL。單向鏈表的插入和刪除操作,我們只需要考慮相鄰結點的指針改變,所以對應的時間復雜度是 O(1)。
- 雙向鏈表:結點不止有一個后繼指針 next 指向后面的結點,還有一個前驅指針 prev 指向前面的結點。
- 循環鏈表:循環鏈表的尾結點指針是指向鏈表的頭結點??捎糜诮鉀Q約瑟夫問題:人們站在一個等待被處決的圈子里。 計數從圓圈中的指定點開始,并沿指定方向圍繞圓圈進行。 在跳過指定數量的人之后,處刑下一個人。 對剩下的人重復該過程,從下一個人開始,朝同一方向跳過相同數量的人,直到只剩下一個人,并被釋放。
2)鏈表應用場景
- 緩存是一種提高數據讀取性能的技術,在硬件設計、軟件開發中都有著非常廣泛的應用,比如常見的 CPU 緩存、數據庫緩存、瀏覽器緩存等等。緩存的大小有限,當緩存被用滿時,哪些數據應該被清理出去,哪些數據應該被保留?這就需要緩存淘汰策略來決定。常見的策略有三種:先進先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
3)如何實現LRU緩存淘汰算法?
我們維護一個有序單鏈表,越靠近鏈表尾部的結點是越早之前訪問的。當有一個新的數據被訪問時,我們從鏈表頭開始順序遍歷鏈表。1. 如果此數據之前已經被緩存在鏈表中了,我們遍歷得到這個數據對應的結點,并將其從原來的位置刪除,然后再插入到鏈表的頭部。2. 如果此數據沒有在緩存鏈表中,又可以分為兩種情況:如果此時緩存未滿,則將此結點直接插入到鏈表的頭部;如果此時緩存已滿,則鏈表尾結點刪除,將新的數據結點插入鏈表的頭部。
4)寫鏈表的6個技巧
技巧一:理解指針或引用的含義
技巧二:警惕指針丟失和內存泄漏
// 錯誤示范:x->next指錯
p->next = x; // 將p的next指針指向x結點;
x->next = p->next; // 將x的結點的next指針指向b結點;
技巧三:利用哨兵簡化實現難度:單鏈表的插入,第一個節點不一樣,利用哨兵可以簡化操作
技巧四:重點留意邊界條件處理,以下情況代碼是否能夠工作?
- 如果鏈表為空時
- 如果鏈表只包含一個結點時
- 如果鏈表只包含兩個結點時
- 代碼邏輯在處理頭結點和尾結點的時候
技巧五:技巧五:舉例畫圖,輔助思考
技巧六:多寫多練,沒有捷徑
leetcode
- 單鏈表反轉(206):https://leetcode-cn.com/problems/reverse-linked-list/ 測試用例[1,2,3,4,5]、[1,2]、[1]
- 鏈表中環的檢測(141):https://leetcode-cn.com/problems/linked-list-cycle/
- 兩個有序的鏈表合并(21):https://leetcode-cn.com/problems/merge-two-sorted-lists/
- 刪除鏈表倒數第 n 個結點(19):https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/
- 求鏈表的中間結點(876):https://leetcode-cn.com/problems/middle-of-the-linked-list/
三、棧
1)棧的結構是什么?
棧是一種操作受限的數據結構,只支持入棧和出棧操作。后進先出是它最大的特點。棧既可以通過數組實現(稱為順序棧),也可以通過鏈表來實現(鏈式棧)。不管基于數組還是鏈表,入棧、出棧的時間復雜度都為 O(1)。
2)棧如何完成表達式中括號的匹配?
- 背景:假設表達式中只包含三種括號,圓括號 ()、方括號[]和花括號{},并且它們可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都為合法格式,而{[}()]或[({)]為不合法的格式。那我現在給你一個包含三種括號的表達式字符串,如何檢查它是否合法呢?
- 答:當掃描到左括時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號。如果能夠匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,則繼續掃描剩下的字符串。如果掃描的過程中,遇到不能配對的右括號,或者棧中沒有數據,則說明為非法格式。當所有的括號都掃描完成之后,如果棧為空,則說明字符串為合法格式;否則,說明有未匹配的左括號,為非法格式。
3)如何實現瀏覽器的前進和后退功能?
- 背景:當你依次訪問完一串頁面 a-b-c 之后,點擊瀏覽器的后退按鈕,就可以查看之前瀏覽過的頁面 b 和 a。當你后退到頁面 a,點擊前進按鈕,就可以重新查看頁面 b 和 c。但是,如果你后退到頁面 b 后,點擊了新的頁面 d,那就無法再通過前進、后退功能查看頁面 c 了。
- 答:我們使用兩個棧,X 和 Y,我們把首次瀏覽的頁面依次壓入棧 X,當點擊后退按鈕時,再依次從棧 X 中出棧,并將出棧的數據依次放入棧 Y。當我們點擊前進按鈕時,我們依次從棧 Y 中取出數據,放入棧 X 中。當棧 X 中沒有數據時,那就說明沒有頁面可以繼續后退瀏覽了。當棧 Y 中沒有數據,那就說明沒有頁面可以點擊前進按鈕瀏覽了。
leetcode
有效的括號 https://leetcode-cn.com/problems/valid-parentheses/
四、隊列
1)隊列的應用有哪些
比如高性能隊列 Disruptor、Linux 環形緩存,都用到了循環并發隊列;Java concurrent 并發包利用 ArrayBlockingQueue 來實現公平鎖等。
2)當我們向固定大小的線程池中請求一個線程時,如果線程池中沒有空閑資源了,這個時候線程池如何處理這個請求?是拒絕請求還是排隊請求?各種處理策略又是怎么實現的呢?
我們一般有兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閑線程時,取出排隊的請求繼續處理。那如何存儲排隊的請求呢?我們前面說過,隊列有基于鏈表和基于數組這兩種實現方式。這兩種實現方式對于排隊請求又有什么區別呢?基于鏈表的實現方式,可以實現一個支持無限排隊的無界隊列(unbounded queue),但是可能會導致過多的請求排隊等待,請求處理的響應時間過長。所以,針對響應時間比較敏感的系統,基于鏈表實現的無限排隊的線程池是不合適的。而基于數組實現的有界隊列(bounded queue),隊列的大小有限,所以線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設置一個合理的隊列大小,也是非常有講究的。隊列太大導致等待的請求太多,隊列太小會導致無法充分利用系統資源、發揮最大性能。
leetcode
- 用隊列實現棧(不是很適合用于面試) https://leetcode-cn.com/problems/implement-stack-using-queues/
- 用棧實現隊列(不是很適合用于面試)https://leetcode-cn.com/problems/implement-queue-using-stacks/
五、遞歸
1)什么是遞歸?
- 遞歸是一種非常高效、簡潔的編碼技巧,一種應用非常廣泛的算法,比如DFS深度優先搜索、前中后序二叉樹遍歷等都是使用遞歸。
- 方法或函數調用自身的方式稱為遞歸調用,調用稱為遞,返回稱為歸。
- 基本上,所有的遞歸問題都可以用遞推公式來表示,比如
f(n) = f(n-1) + 1;
f(n) = f(n-1) + f(n-2);
f(n)=n*f(n-1);
2)遞歸的優點與缺點
- 優點:遞歸代碼的表達力很強,寫起來非常簡潔;
- 缺點:空間復雜度高、有堆棧溢出的風險、存在重復計算、過多的函數調用會耗時較多等問題。
3)什么樣的問題可以用遞歸來解決呢?遞歸的3個條件
- 一個問題的解可以分解為幾個子問題的解
- 這個問題與分解之后的子問題,除了數據規模不同,求解思路完全一樣
- 存在遞歸終止條件
4)如何實現遞歸
- 遞歸代碼編寫
寫遞歸代碼的關鍵就是找到如何將大問題分解為小問題的規律,并且基于此寫出遞推公式,然后再推敲終止條件,最后將遞推公式和終止條件翻譯成代碼。 - 遞歸代碼理解
對于遞歸代碼,若試圖想清楚整個遞和歸的過程,實際上是進入了一個思維誤區。
那該如何理解遞歸代碼呢?如果一個問題A可以分解為若干個子問題B、C、D,你可以假設子問題B、C、D已經解決。而且,你只需要思考問題A與子問題B、C、D兩層之間的關系即可,不需要一層層往下思考子問題與子子問題,子子問題與子子子問題之間的關系。屏蔽掉遞歸細節,這樣子理解起來就簡單多了。
- 因此,理解遞歸代碼,就把它抽象成一個遞推公式,不用想一層層的調用關系,不要試圖用人腦去分解遞歸的每個步驟。
5)遞歸常見問題及解決方案
1. 堆棧溢出
- 為什么會造成堆棧溢出
函數調用會使用棧來保存臨時變量。每調用一個函數,都會將臨時變量封裝為棧幀壓入內存棧,等函數執行完成返回時,才出棧。系統?;蛘咛摂M機??臻g一般都不大。如果遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險。 - 如何預防堆棧溢出呢?
通過在代碼中限制遞歸調用的最大深度的方式來解決這個問題。遞歸調用超過一定深度(比如 1000)之后,我們就不繼續往下再遞歸了,直接返回報錯。
2. 重復計算
- 通過某種數據結構來保存已經求解過的值,從而避免重復計算。