從廣義上來講:數據結構就是
一組數據的存儲結構
, 算法就是操作數據的方法
數據結構是為算法服務的,算法是要作用在特定的數據結構上的。
10個最常用的數據結構:數組、鏈表、棧、隊列、散列表、二叉樹、堆、跳表、圖、Trie樹
10個最常用的算法:遞歸、排序、二分查找、搜索、哈希算法、貪心算法、分治算法、回溯算法、動態規劃、字符串匹配算法
本文總結了20個最常用的數據結構和算法,不管是應付面試還是工作需要,只要集中精力攻克這20個知識點就足夠了。
數據結構和算法(三):二分查找、跳表、散列表、哈希算法的傳送門
數據結構和算法(四):二叉樹、紅黑樹、遞歸樹、堆和堆排序、堆的應用的傳送門
第二十六章 貪心算法
一、什么是貪心算法
- 貪心算法是指在對問題求解時,總是做出在當前看來是最好的選擇,也就是說不從整體考慮,而是從局部看來是最優解,所以貪心算法得到的結果不一定是最優的。
- 貪心算法沒有固定的算法解決框架,算法的關鍵就是貪婪策略的選擇,根據不同問題選擇不同的策略。
- 貪心算法的適用場景比較有限,更多是用來指導設計基礎算法,比如最小生成樹算法、單源最短路徑算法等。
二、使用貪心算法的解決問題的思路
- 當我們看到此類數據時,首先要聯想到貪心算法:針對一組數據,我們定義了限制值和期望值,希望從中選擇幾個數據,在滿足限制值的前提下,期望值最大。(例如:從寶庫中只能拿100kg的物品,從黃金、白銀、純鐵怎么選擇,使得價值最大,這里面限制值就是100kg,期望值就是價值最大。)
- 將問題抽象成限制值、期望值后,就可以嘗試選擇合適的貪婪策略去解決了,在剛才那個例子中,貪婪策略就是盡量多拿單價最高的金屬。
- 選擇不同的貪婪策略后,看下貪心算法產生的結果是否是最優的。從實踐的角度來說,大部分能用貪心算法解決的問題,貪心算法的正確性都是顯而易見的,也不需要嚴格證明。
三、貪心算法實戰分析
-
- 分糖果
(1). 假設我們有m個糖果和n個孩子,要把糖果分給孩子吃,糖果多孩子少,每個糖果的大小不等,每個孩子對糖果的需求也不相同,只要糖果的大小超過了孩子的需求,那么這個孩子就會得到滿足,請問:如何分配糖果,才能滿足最多數量的孩子呢?
(2). 解決這個問題的第一步,我們把問題抽象成:在n個孩子中,選擇一部分孩子分配糖果,使得滿足的孩子最多。這里m個糖果就是限制值,最多滿足的孩子就是期望值。
(3). 解決這個問題的第二步,嘗試用貪心算法解決。對應一個孩子來說,如果小的糖果就可以解決,那么沒必要用大的糖果,所以分配糖果的時候我們可以用需求最小的孩子開始分配。
(4). 我們的分配策略就是:每次從剩下的孩子中選擇需求最小的孩子,分配給能滿足他的最少糖果,這樣的分配方案,就是滿足孩子最多的方案,這也是顯而易見的最優方案。
-
- 區間覆蓋
(1). 假設有n個區間,區間的起始端點和結束端點分別是[a1,a2]、[b1,b2]...,我們從這n個區間內選取一部分不相交的區間,問:怎么選擇,才能使得不相交的區間個數最多呢?
(2). 解決問題第一步,我們把問題抽象化,假設n個區間的最左側是min端點,最右側是max端點,這個問題就相當于,從n個區間中選取幾個不相交的區間,從左到右將[min,max]覆蓋完。
-
(3). 解決問題的第二步,選擇合適的貪婪策略嘗試解決,我們每次選擇的時候,選擇左邊端點不重合,右邊端點盡可能小的區間,使得剩下的區域盡可能大,就可以放置更多的區間。如下圖:
區間覆蓋.png
-
- 如何用貪心算法實現霍夫曼壓縮編碼
- (1). 假設有1000個字符,每個字符占1個字節,一共占1000個字節,也就是8000bit的存儲空間;如果我們統計發現這1000個字符,只有6種字符,分別為a、b、c、d、e、f的話,我們就可以用3個bit來表示他們,如下圖,這樣我們就可以只占用3000bit的空間了,比原來節省了很多,那么我們還有更節省空間的方法嗎?(3個bit其實可以存放8種不同的字符,2 x 2 x 2 = 8)
a(000)、b(001)、c(010)、d(011)、e(100)、f(101)
(2). 霍夫曼編碼就登場了,霍夫曼編碼廣泛應用于數據壓縮中,壓縮率在20%~90%之間,霍夫曼編碼不僅會考察文本中有多少個字符,還會統計字符出現的頻率,根據頻率不同,選擇不同長度的編碼,頻率高的字符選用短編碼,頻率低的字符選用稍長編碼,霍夫曼編碼試圖使用不等長的編碼方式,來進一步增加壓縮效率。
(3). 由于霍夫曼編碼是不等長的,所以解壓縮的時候就比較困難,不知道該讀取1位還是2位還是3位來解壓縮,所以為了避免這種歧義,霍夫曼編碼要求各個字符的編碼之間,不能出現一個字符的編碼是另一個字符編碼的前綴。
-
(4). 在上面那個例子中,我們假設這6個字符出現的頻率從高到低依次是a、b、c、d、e、f,我們采用霍夫曼編碼的方式進行編碼后,就是下圖的樣子,任意一個字符的編碼都不是其他字符編碼的前綴,在解壓縮的時候,就可以以 讀取盡可能長的可解壓二進制串 的方式來解壓,就不會出現解壓歧義了,通過霍夫曼編碼來壓縮,這1000個字符只需要占用2100bit的存儲空間就夠了。
采用霍夫曼編碼對1000個字符進行壓縮 -
(5). 霍夫曼編碼的思想并不難理解,但是如何根據字符出現的頻率,選用不同長度的編碼呢? 這里可以這樣處理,如下圖:把每個字符都看作一個節點,把頻率最低的兩個節點f、c組合在一起生成一個父節點x,x的頻率為f、c頻率之和,再把x節點和頻率次低的d節點組合生成父節點y,一直重復下去,直到把字符處理完;接下來,給每條邊上畫一個權值,指向左節點的邊統一記做0,指向右節點的邊統一記做1,那么從根節點到葉子節點的路徑,就是葉子節點對應字符的霍夫曼編碼了。
霍夫曼編碼如何根據字符頻率選編碼
第二十七章 分治算法
一、什么是分治算法?
- 分治算法的核心思想就是四個字:分而治之,就是將原問題分解成n個小問題,解決這些小問題后,將結果合并,就可以得到原問題的解了。
-
- 想用分治算法解決問題,一般需要滿足以下條件:
- (1). 原問題與分解成的小問題具有相同模式
- (2). 子問題之間沒有關聯性,可以獨立求解(需要跟動態規劃區分開)
- (3). 具有分解終止條件,也就是說,當問題足夠小時,可以直接求解
- (4). 可以將子問題合并成原問題,并且合并操作復雜度不能太高,不然就起不到降低總體算法復雜度的目的了
二、分治思想在海量數據處理中的應用
- 假設要給10G的訂單數據按照金額進行排序,但是我們機器的內存只有2G,無法一次性加載到內存,也就無法單純的利用快排、歸并算法來解決了,就可以使用分治思想來解決
- 面對10G的訂單數據,我們可以先掃描一遍訂單,根據訂單金額劃分出幾個金額區間,例如1到100元的放到一個文件中,100到200元的放到另一個文件中,以此類推,這樣每個小文件都可以加載到內存中,最后將這些小文件合并,就得到有序的10G訂單數據了。
- 如果訂單數據存儲在類似于GFS的分布式系統上,就可以將多個小文件并行加載到多臺機器上并行處理,這樣處理速度就會加快很多,這就是分治思想的一個應用。
第二十八章 回溯算法
第二十九章 初始動態規劃
第三十章 動態規劃實戰
第三十一章 拓撲排序
一、什么是拓撲排序?
- 從局部有序推斷出全局的順序就叫做拓撲排序,例如,我們穿衣服的時候是有一定順序的,你必須先穿襪子才能穿鞋,你必須先穿內褲才能穿秋褲,假設我們有8件衣服,可以按照下面的順序進行穿衣服,就可以滿足局部關系的前提下滿足全局有序。
拓撲排序.png
- 從局部有序推斷出全局的順序就叫做拓撲排序,例如,我們穿衣服的時候是有一定順序的,你必須先穿襪子才能穿鞋,你必須先穿內褲才能穿秋褲,假設我們有8件衣服,可以按照下面的順序進行穿衣服,就可以滿足局部關系的前提下滿足全局有序。
- 我們知道,算法是構建在具體數據結構之上的,我們想進行拓撲排序,就需要先把問題背景抽象成具體的數據結構,我們把衣服之間的依賴關系抽象成一個有向圖,每件衣服對應圖中的一個點,依賴關系就是頂點之間的邊,而且這個圖不僅是有向圖,還得是有向無環圖,因為圖中一旦有了環,就無法進行拓撲排序了,所以拓撲排序是基于有向無環圖的一個算法。抽象成的數據結構如下:
public class Graph {
private int v; // 頂點的個數
private LinkedList<Integer> adj[]; // 鄰接表
public Graph(int v) {
this.v = v;
adj = new LinkedList[v];
for (int i=0; i<v; ++i) {
adj[i] = new LinkedList<>();
}
}
public void addEdge(int s, int t) { // s先于t,邊s->t
adj[s].add(t);
}
}
二、如何在有向無環圖上進行拓撲排序?
-
- Kahn算法
(1). 我們從圖中找到入度為0的頂點,進行輸出(如果一個頂點的入度為0,就意味著沒有頂點先于這個頂點了,所以這個頂點就應該輸出了),并且把這個頂點從圖中刪除,即把這個頂點可達的頂點的入度都減一;
(2). 然后我們重復上述過程,直到輸出所有頂點,這樣輸出的序列就是拓撲排序之后的序列了。(輸出的序列就是滿足局部依賴關系的全局序列)
(3). 具體代碼實現如下
public void topoSortByKahn() {
int[] inDegree = new int[v]; // 統計每個頂點的入度
for (int i = 0; i < v; ++i) {
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inDegree[w]++;
}
}
LinkedList<Integer> queue = new LinkedList<>();
for (int i = 0; i < v; ++i) {
if (inDegree[i] == 0) queue.add(i);
}
while (!queue.isEmpty()) {
int i = queue.remove();
System.out.print("->" + i);
for (int j = 0; j < adj[i].size(); ++j) {
int k = adj[i].get(j);
inDegree[k]--;
if (inDegree[k] == 0) queue.add(k);
}
}
}
-
- DFS算法
- (1). 首先根據鄰接表,構造出逆鄰接表,在逆鄰接表中,邊s->t表示s依賴于t,也就是s后于t執行。
- (2). 然后我們遞歸處理每個頂點,先輸出這個頂點可以到達的所有頂點,然后在輸出它自己。
- (3). 代碼實現如下:
public void topoSortByDFS() {
// 先構建逆鄰接表,邊s->t表示,s依賴于t,t先于s
LinkedList<Integer> inverseAdj[] = new LinkedList[v];
for (int i = 0; i < v; ++i) { // 申請空間
inverseAdj[i] = new LinkedList<>();
}
for (int i = 0; i < v; ++i) { // 通過鄰接表生成逆鄰接表
for (int j = 0; j < adj[i].size(); ++j) {
int w = adj[i].get(j); // i->w
inverseAdj[w].add(i); // w->i
}
}
boolean[] visited = new boolean[v];
for (int i = 0; i < v; ++i) { // 深度優先遍歷圖
if (visited[i] == false) {
visited[i] = true;
dfs(i, inverseAdj, visited);
}
}
}
private void dfs(
int vertex, LinkedList<Integer> inverseAdj[], boolean[] visited) {
for (int i = 0; i < inverseAdj[vertex].size(); ++i) {
int w = inverseAdj[vertex].get(i);
if (visited[w] == true) continue;
visited[w] = true;
dfs(w, inverseAdj, visited);
} // 先把vertex這個頂點可達的所有頂點都打印出來之后,再打印它自己
System.out.print("->" + vertex);
}
三、Kahn算法和DFS算法進行拓撲排序的時間復雜度
- 從Kahn算法的代碼中可以看出,每個頂點和每條邊都被訪問了一次,所以時間復雜度是O(V+E),V是頂點個數,E是邊的個數
- 從DFS算法可以看出,每個頂點被訪問兩次,每條邊被訪問一次,所以時間復雜度也是O(V+E),V是頂點個數,E是邊的個數
- 如果我們想知道數據庫中所有用戶的推薦關系之間,有沒有存在環,就可以使用拓撲排序,把用戶之間的的推薦關系從數據庫加載到內存中,構建成今天所講的這種有向圖數據結構,再利用拓撲排序,就可以很快檢測出是否存在環了。