【算法4总结】第一章:基础
- 目錄
- 備份
第一章:基礎
我認為這一章主要介紹的是如何使用工具。
一共五節,前兩節主要是對 Java 語法的回顧,第三節則是三個數據結構,背包,隊列和棧的API講解。
而第四節是講解的是如何分析算法。第五節則是針對具體的例子 union-find 算法,也就是并查集進行了實踐。
算法分為算法思想和實現細節。而實現細節如果采用具體的語言來實現,會使得思想和實現細節混到一起難以剝離難以理解。本書采用的則是 Java 來直接描述算法。
由此也可以明白《算法導論》為什么采用偽碼來描述算法。本書采用的是 Java 的子集,大部分語法和其他編程語言是通用的,很少使用 Java 的特性語言。
此處默認你已經學過 Java 或者其他語言的基本語法。前三節不再敘述。工具而已使用好就行!
算法分析
大多數的教材都是直接拋出模型,而沒有直接提及模型是怎么來的。
從時間方面來看。首先從觀察實際程序的運行時間出發,然后建立數學模型,之后在對模型進行分類。
而空間方面則是對內存進行了分析。
觀察
可以從直接現實出發,利用時間 API 統計程序的運行時間。
根據數據規模和運行所需要的時間二者結合得到一個增長曲線。根據曲線粗略的得到一個增長函數。
例如一個程序的實際運行時間如圖(橫坐標是數據規模,縱坐標是運行時間):
從上圖中并不能看出什么有效的信息,但是對橫坐標和縱坐標同去對數就會得到一個斜率為 3 的直線。這就說明原來的函數為 T(N)=aN3T(N) = aN^3T(N)=aN3 。取對數后就是 lg(T(N))=3lgN+lgalg(T(N)) = 3 lgN + lgalg(T(N))=3lgN+lga
這樣根據公式就可以從輸入數據的規模計算出程序運行的時間了。
由此也可以得出程序的運行時間符合冪次法則 T(N)=aNbT(N) = aN^bT(N)=aNb 。
數學模型
程序運算的總時間和每條語句的耗時以及執行每條語句的頻率有關。
前者和計算機,Java 編譯器和操作系統有關。而后者取決于程序本身和輸入。
對程序進行多次輸入后得出,執行最頻繁的指令決定了程序執行的總時間。這些指令也稱為程序的內循環。
所以許多程序的運行時間都只取決于其中一小部分指令。
這樣就使得程序的運行時間同具體的計算機剝離開來,只需要考慮程序本身即可。換臺計算機對時間的影響是常數級別可以忽略。
增長數量級的分類
主要分為以下 7 種:
注意
- 不能忽略常數比較大的項。
- 計算機對于每條指令的執行時間可能并不是相同的,有些指令因為緩存的緣故很快就執行完畢。但是有些指令需要消耗不少時間。
- 系統不能對算法程序的運行時間產生影響。
- 程序在不同的場景下效果可能不一樣。
- 對輸入數據存在依賴,存在有些數據直接就是想要的結果,而有些則需要遍歷全部也不一定能夠找到想要的結果。
并查集 union-find
這一節講的是 union-find 算法,也就是并查集。同時也是對應課程的第一節。
定義問題
我們需要明白這個算法解決了什么問題?也就是定義問題,然后是怎么解決的,之后就是改進了。
在現實世界中,如何判斷兩個人直接或間接的認識,間接認識表示二人通過第三方甚至更多人的聯系而認識。
除此之外還有判斷兩地之間是否有必要建立通信線路,如果存在間接的聯系則沒必要。等等有很多類似的問題。
將以上的問題抽象起來,從集合的角度來看。要解決的問題則是判斷兩個點是否存在于同一集合之中。此處集合也稱為連通分量。
如圖所示,存在 7 個點,點和點之間的聯系形成了 3 個連通分量。
當新加入一個聯系時,點和點之間的聯系隨之發生改變。如圖,2 和 5 之間建立了聯系,變成了 2 個連通分量。
定義 API
為了解決這類問題,將其中的流程抽象化,自頂向下思考。可以寫成五個函數:
- 首先需要初始化每個點 UF(int N)
- 最后就是建立聯系了 void union(int p, int q)
- 判斷時需要找到根節點 int find (int p)
- 根據根節點是否一致來判斷已經在一個連通分量中 boolean connected (int p, int q)
將 API 組織起來:
public static void main(String[] args) {int N = StdIn.readInt();UF uf = new UF(N);while (!StdIn.isEmpty()) {int p = StdIn.readInt();int q = StdIn.readInt();if (uf.connected(p,q)) {continue;}uf.union();StdOut.println(p + " " + q);}StdOut.println(uf.count() + "components"); }完整代碼如下:
public class UF {private int[] id;private int count;public UF(int N) {count = N;id = new int[N];for (int i = 0; i < N; i++) {id[i] = i;}}public int count() {return count;}public boolean connected(int p, int q) {return find(p) == find(q);}public int find(int p) {}public void union(int p, int q) {}public static void main(String[] args) {int N = StdIn.readInt();UF uf = new UF(N);while (!StdIn.isEmpty()) {int p = StdIn.readInt();int q = StdIn.readInt();if (uf.connected(p,q)) {continue;}uf.union(p,q);StdOut.println(p + " " + q);}StdOut.println(uf.count() + "components");} }下面就是如何實現了!
細節實現
首先需要思考如何表示點與點之間的關系。
可以采用數組來表示,索引代表點本身,而存儲的值代表指向的點。
起初一條聯系都沒有,都是孤立的點,所以將其指向的點都標識為自己,也就是數組中存儲的值都改為其下標。
public UF(int N) {// 表示連通分量的個數,初始為 Ncount = N; id = new int[N];for (int i = 0; i < N; i++) {id[i] = i;} }數組的定義就是父節點,所以直接返回父節點即可。(注意此處的父節點同時也是根節點)
public int find(int p) {return id[p];}首先判斷是否已經連接,也就是父節點是否一致。已經連接的話就不需要在進行后續操作了,反之需要進行后續操作。
public void union(int p, int q) {int pID = find(p);int qID = find(q);if (pID == qID) {return;}for (int i = 0; i < id.length; i++) {if (id[i] == pID) {id[i] = qID;}}count--;}為什么要遍歷?假設存在兩個連通分量,其中一個需要合并成一個,那么就需要將涉及到其中的所有點的父節點都修改為指向另外一個連通分量的父節點。
改進 find
每次進行 union 都需要執行一遍 for 循環,是一個線性增長。
此外 N 個點就有 N 個聯通分量,假設要變成 1 個聯通分量的話最多需要進行 N - 1 次 uoion 操作。
所以這個時間復雜度就是 O(N2)O(N^2)O(N2) 了。
union 的修改如下:
public void union(int p, int q) {int pRoot = find(p);int qRoot = find(q);if (pRoot == qRoot) {return;}id[pRoot] = qRoot;count--;}直接賦值干掉了一個 for 循環,消除了線性增長。
但是 find 也要隨之修改。find 需要拿到根節點,根節點的修改才能代表整個連通分量的合并。
可以修改 find 為如下:
public int find(int p) {while(p != id[p]) {p = id[p];}return p;}通過循環找到根節點。如圖,假設 3 和 5 之間要連接。那么首先找到 3 的根節點 9 ,5 的根節點 6 ,將 9 和 6 的指向修改即可。
修改前:
修改后:
這樣就使得和 3 在同一連通分量下的所有階段都合并到和 5 相關的連通分量中。而之前則是需要一個循環來修改所有與 3 相關的連通分量中的所有值。
注意每次拿到的是根節點,而非父節點。之前雖然得到的也是父節點,但因為只有兩層的關系,所以也是根節點。此處修改 unoin 后,就變為了多層的關系。
這樣操作帶來的壞處就是如果層數過多, find 需要循環多次才能拿到根節點。而且在也與輸入的數據有關,在一些情況下甚至還不如之改進前的速度快。
增加加權判斷
find 操作次數多是因為層數過深,進一步修改就是降低層數。可以將其想象成一顆倒立的樹,每次合并之時都是小樹指向大樹,這樣就降低了樹高。
假設有兩顆樹,樹高分別為 5 和 3 。如果 5 指向 3 ,樹高就會變為 8 。反之 3 指向 5 ,因為 3 沒有 5 大,所以高度還是 5 ,3 則是掛在了 5 上。
加權就是判斷樹高,使得小樹掛在大樹上。
首先增加一個數組,用于存儲樹高。其高度均為 1 。
public WeightedQuickUnion(int N) {count = N;id = new int[N];for (int i = 0; i < N; i++) {id[i] = i;}sz = new int[N];for (int i = 0; i < N; i++) {sz[i] = 1;}}union 修改如下:
public void union(int p, int q) {int i = find(p);int j = find(q);if (i == j) {return;}if (sz[i] < sz[j]) {id[i] = j; sz[j] += sz[i];}else {id[j] = i; sz[i] += sz[j];}count--;}壓縮路徑
最初因為需要修改大量連接而低效。
將其優化后又因為當 find 操作需要循環多次而低效。
加權后的效果已經很好了,但是依舊存在 find 循環問題,最好不要讓其循環,其實也就是壓縮路徑了。
可以在其循環之時順便將父節點直接指向根節點。例如 CPP 版遞歸時進行壓縮路徑的實現。
int find(int x) {if (f[x] == x) {return x;}return f[x] = find(f[x]); }總結
這樣優化后已經是最好的了,但依舊不是常數級別。事實上常數是不可能的,加權加上壓縮路徑已經是最優的了。
總結
以上是生活随笔為你收集整理的【算法4总结】第一章:基础的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 学习 Cesium (五):加载离线高程
- 下一篇: oracle修改默认值语句,Oracle