在算法性能上咱們經常面臨的挑戰是咱們的程序可否求解實際中的大型輸入:
--爲何程序運行的慢?
--爲何程序耗盡了內存?程序員
沒有理解算法的性能特徵會致使客戶端的性能不好,爲了不這種狀況的出線,須要具有算法分析的一些知識。
此篇主要涉及一些基礎數學知識和科學方法,以及如何在實踐應用中使用這些方法理解算法的性能。咱們的重點放在得到性能的預測上。
主要分爲5部分:面試
注:下文我所的增加量級和增加階數是一個東西其實...算法
咱們將從多種不一樣的角色思考這些問題:spring
關於算法分析須要集中考慮的關鍵是運行時間。運行時間也能夠理解爲完成一項計算咱們須要進行多少次操做。
這裏主要關心:apache
算法分析的科學方法概述:segmentfault
使用科學方法有一些基本原則:數組
(可證僞性:指從一個理論推導出來的結論(解釋、預見)在邏輯上或原則上要有與一個或一組觀察陳述與之發生衝突或抵觸的可能。
可證僞,不等於已經被證僞;可證僞,不等因而錯的。)緩存
第一步是要觀察算法的性能特色,這裏就是要觀察程序的運行時間。
給程序計時的方法:網絡
咱們將使用 3-SUM 問題做爲觀察的例子。
三數之和。若是有N個不一樣的整數,以3個整數劃爲一組,有多少組整數只和爲0.
以下圖,8ints.txt 有8個整數,有四組整數和爲0
目標是編寫一個程序,能對任意輸入計算出3-SUM整數和爲0有多少組。
這個程序實現的算法也很簡單,首先是第一種,「暴力算法」
EN:brute-force algorithm
這裏使用第三方API的方法測量程序運行的時間。
import edu.princeton.cs.algs4.StdIn; import edu.princeton.cs.algs4.StdOut; import edu.princeton.cs.algs4.Stopwatch; public class ThreeSum { public static int count(int[] a) { int N = a.length; int count = 0; //三重的for循環,檢查每三個整數組合 for (int i = 0; i < N; i++) for (int j = i + 1; j < N; j++) for (int k = j + 1; k < N; k++) //爲了方便觀察算法的性能問題,這裏忽略了整型溢出問題的處理 if (a[i] + a[j] + a[k] == 0) count++; return count; } /** * 讀入全部整數,輸出count的值 * 利用StopWatch執行時間監控 * @param args */ public static void main(String[] args) { int[] a = StdIn.readAllInts(); Stopwatch stopwatch = new Stopwatch(); StdOut.println(ThreeSum.count(a)); double time = stopwatch.elapsedTime(); } }
測試數據能夠用愈來愈大的輸入來運行。每次將輸入的大小翻倍,程序會運行得更久。經過相似的測試,有時能至關方便和快速地評估程序何時結束。
經過實證得出的數據,能夠創建圖像,使觀察更直觀:
(lg以2爲底)
使用雙對數座標一般狀況下是獲得一條直線,這條直線的斜率就是問題的關鍵。
這個例子(3-SUM 暴力算法)的斜率是3
--經過對數座標的方法得出公式:lg(T(N)) = blgN + c (可看作 y = b*x + c,其中 y = lg(T(N)),x = lgN)
--經過圖中兩點可求出b,c值,若是等式兩邊同時取2的冪,就獲得 T(N) = 2^c*N^b, 其中 2^c 爲一個常數,可記做 a
由此,從這個模型的觀察中咱們就獲得了程序的運行時間,經過一些數學計算(在這裏是迴歸運算),咱們就知道得出了運行時間:
T(N) = a*N^b (b爲雙對數座標中直線的斜率,同時 b 也是這個算法的增加量級,第三點會講到)
假設
經過上述的數據分析,咱們得出假設:
運行時間看起來大約是 1.006 × 10^–10 × N^2.999 (秒)
預測
能夠運用這個假設繼續作預測,只要帶入不一樣的N值,就能計算出須要的大體時間。
・51.0 seconds for N = 8,000.
・408.1 seconds for N = 16,000.
驗證
經過對比 程序實際運行時間(下圖) 和 經過咱們的假設模型預測的時間上一步) 能夠看出結果很是相近 (51.0 vs ~51.0/408.1 vs ~410.8)
這個模型幫助咱們在不須要花時間運行試驗的前提下作一些預測。實際上這個情形中存在冪定律(a*N^b).實際上絕大多數的計算機算法的運行時間知足冪定律。
下邊介紹一種求解符合冪定律運行時間中的增加量級值(b)的方法
這裏能夠經過 Doubling hypothesis 的方法能夠快速地估算出冪定律關係中的 b 值:
運行程序,將每次輸入的大小翻倍(doubling size of the input),而後計算出N和2N運行時間的比率。主要看下圖的後幾行運算時間比率,前幾行的輸入值小,以如今的計算機運算能力處理起來,立方級別的增量級運算速度快也相差無幾。
ratio ≈ T(2N)/T(N)
至於爲何 0.8/0.1≈7.7 或其餘看起來 "運算錯誤" 相似的狀況,是由於圖上的運行時間的記錄是簡化精確到了小數點後一位,實際運算比率值是使用了實際運行時間(精確到小數點後幾位)去計算的,因此會出現0.8/0.1≈7.7。
經過不斷地進行雙倍輸入實驗,能夠看到比率會收斂到一個常數(這裏爲8),而實際上比率的對數會收斂到N的指數,也就是 b 的值,這裏粗暴算法的 b 值就等於3
經過Doubling hypothesis方法咱們又能提出假設:
此算法的運行時間大約是 a*N^b, 其中 b = lg ratio
注意:Doubling hypothesis 不適用於識別對數因子
得出 b 的值後,在某個大的輸入值上運行程序,就能求出 a 值。
由此得出假設:運行時間 ≈ 0.998 × 10^–10 × N^3 (秒)
咱們經過做圖得出的模型( ≈ 1.006 × 10^–10 × N^2.999 )和咱們經過Doubling hypothesis方法得出的模型是很接近的。
計算機中有不少的因素也會影響運行時間,可是關鍵因素通常和計算機的型號無關。
關鍵的因素即爲你使用的算法和數據. 決定冪定律中的 b 值
還有不少與系統相關的因素:
以上全部因素,包括關鍵因素,都決定了冪定律中的 a 值
現代計算機系統中硬件和軟件是很是複雜的,有時很難得到很是精確的測量,可是另外一方面咱們不須要像其餘科學中須要犧牲動物或者向一顆行星發射探測器這些複雜地方法,咱們只須要進行大量的實驗,就能理解和獲得咱們想要知道的影響因子(的值)。
經過觀察發生了什麼可以讓咱們對性能做出預測,可是並不能幫助咱們理解算法具體作了什麼。經過數學模型更有利於咱們理解算法的行爲。
咱們能夠經過識別全部的基本操做計算出程序的總運行時間。
致敬一下,Don Knuth 在二十世紀60年代末便提出和推廣了運行時間的數學模型:sum(操做的開銷 * 操做執行的頻率)
基於 knuth 研究得知,原則上咱們可以得到算法,程序或者操做的性能的精確數學模型。
基本操做的開銷通常都是一個取決於計算機及系統的常量,若是想要知道這個常量是多少,能夠對一個基本操做運行成千上萬的實驗的方式算出。好比能夠進行十億次的加法,而後得出在你運行的計算機系統上進行 a + b 的基本操做花費大概 2.1 納秒
爲了方便創建數學模型,絕大多數的狀況下咱們只要 假定它是某個常數 cn (n:1,2,3...) 就能夠。
下圖羅列了一下基本操做和其開銷
關於N:當咱們在處理一組對象時,假設有N個對象,有一些操做須要的時間和N成正比。好比第六行,分配一個大小爲N的數組是,須要正比於N的時間,由於在Java中默認吧數組中的每一個元素初始化爲0.
還有些運行時間去決定系統的實現,好比鏈接兩個字符串須要的運行時間與字符串的長度(N)成正比,鏈接字符串並不等同於加法運算
數組中有多少個元素等於0
public class OneSum { public static int count(int[] a) { int N = a.length; int count = 0; for (int i = 0; i < N; i++) if(a[i] == 0) count++; return count; } }
其中幾項操做的頻率取決於N的輸入
數組中有多少對元素等於0
public class TwoSum { public static int count(int[] a) { int N = a.length; int count = 0; for (int i = 0; i < N; i++) for (int j = i + 1; j < N; j++) if (a[i] + a[j] == 0) count++; return count; } }
額外稍微解釋下數據怎麼算來的,若是已經瞭解能夠略過如下細緻的解釋。
j 每次迭代的增量都取決於 i 的值,由於 j 被初始化爲 i + 1
便於理解能夠用具體數值帶入:
假設 N = 5
當 i == 0 時,i 遞增到 1,遞增了 1 次;j 從 1 遞增到 5,遞增了4次;i 和 j 一塊兒遞增了 5 次
當 i == 0 時,i 進行了 1 次 i < N 的比較,j 進行了 5 次 j < 5 的比較,i 和 j 一塊兒進行了 6 次比較
將具體泛化:
a) < 比較 : 離散求和公式:0 + 1 + 2 +...+ N + (N+1) = ½(N+1)(N+2)
即當 i == 0 時,j < N 的比較會進行 N 次,所以總的來講,i 的第一次迭代中**i和j**一塊兒有 N + 1 次比較操做 然後 i 遞增,對於 i == 1 的下一次迭代,j < N 進行了 N - 1 次,在i的第二次迭代中,**i和j一塊兒**有N次比較操做 即 i 每加 1,j 都會在上一層比較的基礎上少比較一次 直到 i == N, j 再也不進行比較操做,i 和 j 一共有 1 次比較操做 i + j 總共進行 < N 比較操做的頻率利用離散求和就是½(N+1)(N+2)
b) == 比較 : 離散求和:0 + 1 + 2 +...+ (N-2) + (N-1) = ½ N (N − 1)
即當 i == 0 時,j 將會迭代 N-1 (從1到N-1) 次 然後 i == 1 時,j 將會迭代 N-2 (從2到N-1) 次 當 i == N 時,j 將不會再迭代,即 0 次結束 即 i 每加 1,j 都會在上一層迭代的基礎上少迭代一次 利用離散求和得出 j 的迭代次數爲 ½ N (N − 1) j 的 迭代頻率與進行「==」比較的操做頻率是同樣的,所判斷相等的操做頻率就等於½ N (N − 1)
c) 數組訪問 : 假設咱們假設編譯器/JVM沒有優化數組訪問的狀況下
每次進行相等比較都會有兩次數組訪問的操做,因此是½ N (N − 1) * 2 = N (N − 1)
d) 增量{++} : ½ N(N+1) to N^2.,coursera上ppt的½ N (N − 1) to N (N − 1)是錯的
Mathematical Models, slide 28, 30, 32. Number of increments should be ½ N(N+1) to N^2.
(參見coursera 課程勘誤表Resources--Errata)
當 i == 0 時,i 先進行遞增,j 也遞增了 N-1 次,所以總的來講,i 的第一次迭代中**i和j**一塊兒有 N 個遞增 而後i遞增,對於 i == 1 的下一次迭代,j 將遞增 N-2 次,在i的第二次迭代中,**i和j一塊兒**給出N-1個增量。 一直到 i == N,**i和j一共**只有一次遞增 (j 再也不遞增) 一樣利用離散求和:N +(N-1)+ ... + 2 + 1,**i和j一塊兒給出** ½N(N+1)個增量 下限 : ½ N(N+1)(假設計數徹底沒有增長,即count沒有增長,只有上訴 i 和 j 進行了增量)。 上限 : 咱們假設計數器count在每次循環都增長,count++執行的次數與「等於比較」的次數相同,所以咱們獲得 ½ N(N+1) + ½ N(N-1) = N^2
原則上咱們是能夠算出這些精確的次數,但是這樣太繁瑣。圖靈大佬1947年就提出了,其實咱們測量計算過程當中的工做量時不用列出全部細節,粗略的估計一樣有用。其實咱們只須要對開銷最大的操做計數就OK了。因此如今咱們也這麼幹。咱們選出開銷最大的基本操做,或者是執行次數最多的、開銷最大的、頻率最高的操做來表明執行時間。
咱們假設運行時間等於 常數*操做的執行時間,在 2-SUM 例子中,
咱們選擇訪問數組的時間 (c*N(N − 1)) 表明這個以上算法的運行時間。
-- 估算輸入大小爲 N 的函數的運行時間(或內存)
-- 忽略推導式子中的低階項。使用 tilde notation (~ 號)表示:
a) 當 N 很大時,咱們只須要關注高階項的開銷
b) 當 N 很小時,雖然低階項不能忽略,可是咱們更無需擔憂,由於小 N 的運行時間原本就不長,咱們更想要對大 N 估計運算時間
如圖,當 N 很大時,N^3 遠比後邊的 N 的低階項要大得多,大到基本不用關注低階項,因此這些式子都近似爲 (1/6)N^3
經過圖形能夠看出低階項真的沒太多影響
波浪號的含義:f(n) 近似於 g(n) 意味着 f(n)/g(n)的極限等於 1
簡化統計頻率後,咱們能夠這麼樣的表示:
是否是看起來更微妙,更清爽~
結合兩種簡化,咱們就能夠說 2-SUM 須要近似 N^2 次數組訪問,並暗示了運行時間爲 ~c*N^2 (c 爲常數)
利用開銷模型和 ~ 嘗試對 3-SUM 問題進行分析
public class ThreeSum { public static int count(int[] a) { int N = a.length; int count = 0; for (int i = 0; i < N; i++) for (int j = i + 1; j < N; j++) for (int k = j+1; k < N; k++) if (a[i] + a[j] + a[k] == 0) count++; return count; } }
開銷最大的就是這句了:if (a[i] + a[j] + a[k] == 0),咱們能夠說 3-SUM 問題須要近似 ~ ½ n3 次數組訪問,並暗示了運行時間 ~½ c*n3 (c 爲常數)
爲了不部分蒙圈現象,解釋下爲何是1/6 N^3 和 1/2 N^3
a) 1/6 N^3 這個值仍是離散求和得出的,能夠參考 2-SUM. 就是又多了一層loop. 建議利用計算器或者工具去計算
Maple 或者 Wolfram Alpha
b) 由於 1/6 N^3 是 equal to compare 的次數,不是數組訪問的次數。
每次在執行 equal to compare 都有 3 次數組訪問,因此是 1/6 N^3 * 3 = 1/2 N^3
精確的模型最好仍是讓專家幫搞定,簡化模型也是有價值的。有時會給出一些數學證實,可是有時候引用專家的研究成果,利用數學工具就能夠了。簡化後咱們就不用去計算全部操做的開銷,咱們選出開銷最大的操做乘上頻率,得出適合的近似模型來描述運行時間。精確一點的數學模型以下:
costs:基本操做的開銷,常量,取決於計算機,編譯器
frequencies:操做頻率,取決於算法,輸入大小(即 N 的大小)
如下增加量級同增加階數一個意思。
增加量級能夠看作是函數類型,如是常量,線性函數,指數函數,平方,立方,冪函數等。
通常分析算法時咱們不會遇到太多不一樣的函數,這樣咱們能夠將算法按照性能隨問題的大小變化分類。
通常算法咱們都能用這幾個函數描述:
當咱們關注增加量級時,咱們會忽略掉函數前面的常數。好比當咱們說這個算法的運行時間和 NlogN 成正比,等同於咱們假設運行時間近似 cNlogN (c 爲常數).
上圖爲雙對數座標圖,從圖中能夠看出若是:
以上兩種算法都是咱們想要設計的算法,它們可以成比例適應問題的規模。
綜上所訴,咱們研究算法是,首先要保證這些算法不是平方或者立方階的。
增加階數類型實際上就源於咱們寫的代碼中的某些簡單模式。下圖使用翻倍測試(參考上邊 Doubling hypothesis 內容)得出算法運行時間隨問題大小翻倍後增加的翻倍狀況。某些增加量級對應的代碼模式以下:
若是有某種循環:
經過上述分析,咱們在設計處理巨大規模輸入的算法的時候,通常都儘可能把算法設計成線性階數和線性對數階數。
爲了展現描述算法性能的數學模型的創建過程,下邊以 binary search 二分查找爲例
目標:給定一個有序整數數組,給定一個值,判斷這個值在這個數組中是否存在,若是存在,它在什麼位置
二分查找:將給定值與位於數組中間的值進行比較
以下圖,查找 33,首先和 53比較,33<53, 因此若是33存在,那麼就會在數組的左半邊,而後遞歸地使用一樣的算法,直到找到,或確認要查找的值不在給定數組中。下圖展現二分查找的過程(使用了3個指針 lo, hi, mid)
初始化 lo 指針指向 id[0], hi 指針指向 id[n-1], mid 指針指向 id[mid]
33<53, hi指針向左移動到mid的前一位
33>53, lo 指針向右移動到mid的後一位
33<43, hi 指針移動到 43 以前,也就是數組中 33 的位置,此時只剩下一個元素查看,若是等於 33,則返回 index 4, 若是不等於 33,則返回 -1,或者別的形式說明要查找的定值不在數組中
此算法的不變式:若是數組 a[] 中存在要尋找的關鍵字,則它在 lo 和 hi 之間的子數組中, a[lo] ≤ key ≤ a[hi].
public static int binarySearch(int[] a, int key) { int lo = 0, hi = a.length - 1; while (lo <= hi) { //why not mid = (lo + hi) / 2 ? int mid = lo + (hi - lo) / 2; //關鍵值與中間值是三項比較(<,>, ==) if (key < a[mid]) hi = mid - 1; else if (key > a[mid]) lo = mid + 1; else return mid; } return -1; }
定理:在大小爲 N 的有序數組中完成一次二分查找最多隻須要 1 + lgN 次的比較
定義:定義變量 T(N) 表示對長度爲 N 的有序數組的子數組(長度<=N)進行二分查找所須要的比較次數
遞推公式(根據代碼):T(n) ≤ T(n / 2) + 1 for n > 1, with T(1) = 1.
程序將問題一分爲二,因此T(n) ≤ T(n / 2) 加上一個數值,這個數值取決於你怎麼對比較計數。這裏看作二向比較,分紅兩半須要進行一次比較,因此只要 N>1, 這個遞推關係成立。當 N 爲 1 時,只比較了 1 次。
裂項求和
咱們將遞推關係帶入下面公式右邊(即 <= 號右邊)求解,
若是T (n) ≤ T (n / 2) + 1 成立,則 T (n / 2) ≤ T (n / 4) + 1 成立...
這個證實雖然是證實在 N 是 2 的冪的時候成立,由於並無在遞推關係中明確 N 是奇數的狀況,可是若是把奇數狀況考慮進來,也可以證實二分查找的運行時間也老是對數階的。
基於這個事實,咱們可以對 3-SUM 問題設計一個更快的算法:
(基於增加量級與二分查找應用)
Java 實現:
import java.util.Arrays; public class ThreeSumFast { // Do not instantiate. private ThreeSumFast() { } // returns true if the sorted array a[] contains any duplicated integers private static boolean containsDuplicates(int[] a) { for (int i = 1; i < a.length; i++) if (a[i] == a[i-1]) return true; return false; } /** * Prints to standard output the (i, j, k) with {@code i < j < k} * such that {@code a[i] + a[j] + a[k] == 0}. * * @param a the array of integers * @throws IllegalArgumentException if the array contains duplicate integers */ public static void printAll(int[] a) { int n = a.length; Arrays.sort(a); if (containsDuplicates(a)) throw new IllegalArgumentException("array contains duplicate integers"); for (int i = 0; i < n; i++) { for (int j = i+1; j < n; j++) { int k = Arrays.binarySearch(a, -(a[i] + a[j])); if (k > j) StdOut.println(a[i] + " " + a[j] + " " + a[k]); } } } /** * Returns the number of triples (i, j, k) with {@code i < j < k} * such that {@code a[i] + a[j] + a[k] == 0}. * * @param a the array of integers * @return the number of triples (i, j, k) with {@code i < j < k} * such that {@code a[i] + a[j] + a[k] == 0} */ public static int count(int[] a) { int n = a.length; Arrays.sort(a); if (containsDuplicates(a)) throw new IllegalArgumentException("array contains duplicate integers"); int count = 0; for (int i = 0; i < n; i++) { for (int j = i+1; j < n; j++) { int k = Arrays.binarySearch(a, -(a[i] + a[j])); if (k > j) count++; } } return count; } /** * Reads in a sequence of distinct integers from a file, specified as a command-line argument; * counts the number of triples sum to exactly zero; prints out the time to perform * the computation. * * @param args the command-line arguments */ public static void main(String[] args) { In in = new In(args[0]); int[] a = in.readAllInts(); int count = count(a); StdOut.println(count); } }
基於搜索的算法:
若是找到- (a[i] + a[j]),那麼就有 a[i], a[j] 和 - (a[i] + a[j]) 三個整數和爲 0
運行時間的增加階數: N^2 log N.
第二步: 二分查找使用 N^2 log N
for (int i = 0; i < n; i++) { for (int j = i+1; j < n; j++) { int k = Arrays.binarySearch(a, -(a[i] + a[j])); if (k > j) count++; } }
第2步進行屢次二分搜索。多少次? N ^ 2次。二分查找須要log(n)時間 (請參考概述中最後一個表和回顧二分查找的內容)。 所以,循環須要(N ^ 2 * log(N))時間。 應該注意循環在排序後發生。不在排序過程當中發生。因爲操做一個接一個地發生,咱們添加了運行時間。不是成倍增長。 **總運行時間是這樣的**: (N ^ 2)+(N ^ 2 * log(N)) **因爲忽略了較低階項**,所以算法只有最重要的項的增加順序: (N ^ 2 * log(N))
一般,更好的增加階數意味着程序在實際運行中更快。
爲了更有說服力,通常狀況下不考慮上下限問題,運行時間爲最壞狀況下的時間複雜度 (算法理論內容)
增加量級在實際運用在是很是重要的,它直接反映了算法的效率,近年來人們針對增加量級也作了不少研究。
一個不一樣的輸入可能會讓算法的性能發生巨大變化。咱們須要從不一樣的角度針對輸入的大小分析算法。運行時間介於最好狀況與最壞狀況之間。
Best case:最好狀況,算法代價的下限(lower bound on cost), 運行時間老是大於或等於下限。
Worst case:最糟糕的狀況,算法代價的上限(Upper bound on cost), 運行時間不會長於上限。
Average case:平均隨機狀況,將輸入認爲是隨機的
通常的,即便輸入變化很是大,咱們也可以各類狀況進行建模和預測性能
Ex 1. 如上邊的 3-SUM 問題:
經過「暴力算法」,數組的訪問次數爲
Best: ~ ½ N^3
Average: ~ ½ N^3
Worst: ~ ½ N^3
其實各類狀況的低階項是不同的,可是由於咱們利用了簡化方法忽略了低階項(回顧數學表示的簡化內容),因此3種狀況下的數組訪問幾乎是同樣的。使用近似表達時,算法中惟一的變化就是計數器 count 增長的次數。
Ex 2. 二分查找中的比較次數
Best: ~ 1 常數時間,第一次比較結束後就找到了關鍵字
Average: ~ lg N
Worst: ~ lg N
應對不一樣的輸入,咱們有不一樣的類型分析,可是關鍵是客戶要解決的實際問題是什麼。爲了瞭解算法的性能,咱們也要了解這個問題。
實際數據可能與輸入模型不匹配怎麼辦?
方法1:取決於最壞狀況下的性能保證,保證你的算法在最壞狀況下運行也能很快
若是不能保證最壞狀況,那麼就考慮隨機狀況,依靠某種機率條件下成立的保證
方法2:隨機化,取決於機率保證。
(排序在後幾個星期有談論到)
對於增加量級的討論引出了對算法理論的討論
新目標
肯定問題的「困難性」
方法
用增加量級對最壞狀況進行描述
分析的目標是找出「最優」算法
最優算法
如何使用這三個符號對算法按照性能分類?
目標:肯定問題的「難度」並開發「最優」算法。
上限:O(g(N)) 問題難度的上限取決於某個特定的算法
1-SUM 問題未知的最優算法的運行時間是 O(N):
下限:Ω(h(N)) 證實沒有算法能夠作得比 Θ(h(N)) 更好了
1-SUM 的未知最優算法的運行時間是 Ω(N)
最優算法:
對於簡單問題,找到最優算法仍是比較簡單的,但對於很複雜的問題,肯定上下限就很困難,肯定上下界吻合就更加困難。
目標
暴力算法分析
上限: 問題難度的上限取決於某個特定的算法
3-SUM 的最優算法的運行時間爲 O(N^3)
但若是咱們找到了更好的算法
上限: 一種特定的改進算法
下限: 證實沒有別的算法能夠作得更好
可能你們仍是對Omega Ω 符號有點困惑。 Omega只顯示算法複雜度的下限。 3-SUM 算法須要檢查來自某個數組的全部元素,所以咱們能夠說,該算法具備 Ω(N) 複雜度,由於它至少執行線性數量的操做。事實上,操做總數是更大的,所以實際最優算法確定是 ≥ Θ(N) 的,記做 Ω(N)
對於 3-SUM 問題沒有人知道更高的下界,其實咱們如今就能看出,處理 3-SUM 問題確定是要用超過 Θ(N) 的時間的,可是咱們卻不能肯定多出多少,就是不知道比 Θ(N) 更高的下界是多少。
當有人證實更高的下限時,也是贊成沒有算法能夠作得比前一個下限更好的前提下提出新的下界。可是他們會作出了更強有力的陳述,特別是證實沒有算法能夠實現比他們剛纔證實的新下界更好,以此來提升原來的下界,定義一個新的下界。
新的下限可能僅略高於先前的下限,或者可能顯着更高。提升下界每每都不是很容易。談論如何提升下界這也不是本文的重點。
算法理論中的一個開放問題:
·3-SUM 有最優算法嗎?咱們不知道
·3-SUM 問題是否存在一個運行時間小於 O(N^2) 的算法?咱們沒法肯定
·3-SUM 比現行的下界更高的下界是什麼,上面已經談論過了,咱們也還不知道
咱們不知道求解 3-SUM 問題的難度
因此人們更傾向於研究持續降低上界,也就是設法提升算法在最壞狀況下的運行時間來了解問題的難度,並獲得了不少最壞狀況下的最優算法。
值得注意的是:有不少人錯把 big-Oh 分析結果當作了運行時間的近似模型,其實 big-Oh 應該是這個問題運行時間的上界,不是運行時間的近似模型。
咱們使用 ~ 來表示算法運行時間的近似模型。當咱們談論到運行時間的上界就使用 big-Oh.
運行時間和程序的內存需求都會對算法的性能有所影響,下邊是對內存需求的簡單討論。
從根本上講咱們就是想知道程序學要多少比特(bit),或者多少字節(byte)
Bit: 0 or 1 Byte: 8 bites Megabyte (MB) 2^20 bytes Gigabyte (GB) 2^30 bytes. 32-bit machine: 32 位系統,指針是 4 個字節, 64-bit machine: 64 位系統,指針是 8 個字節,這使得咱們可以對很大的內存尋址,可是指針指針也使用了更大的空間。有些 JVM 把指針壓縮到 4 bytes 來節省開支。
內存使用和機器還有硬件實現有很大的關係,可是通常狀況都是如圖所示
Boolean 雖然只用了 1 bit,但系統仍是分配了 1 byte 給它
數組須要額外空間 + 基本類型空間開支(參考左表) * 元素個數(N)
二維數組須要的空間下圖用近似值表示, ~ 2MN 能夠理解爲 char 基本類型開銷是 2 bytes,char [M] [N] 近似用了 2MN bytes 的內存
Object overhead 對象須要的額外空間. 16 bytes. Reference 引用. 8 bytes. Padding 內置用來對齊的空間. 對齊空間能夠是 4 bytes 或者是其它,對齊空間的分配目的是使得每一個對象使用的空間都是 8 bytes 的倍數
下圖是一個日期對象的內存佔用量例子
數據類型值的總內存使用量:
例子:用了多少字節?
使用上邊的基本知識能夠算出 B
總共 8N + 88 ~ 8 N bytes.
Version 0: Try each flow from the bottom. The first floor that the egg breaks on is the value of T. Version 1: Using the binary search.Firstly, try floor T/2. If the egg breaks, T must be equal to T/2 or smaller. If the egg does not break, T must be greater than T/2. Continue testing the mid-point of the subset of floors until T is determined. Version 2: Start test at floor 1 and exponentially grow (2^t) floor number (1, 2, 4, 8 ... 2^t)until first egg breaks. The value of T must be in [2^(t-1), 2^t). This step costs lgT tosses. Then in the range got from last step can be searched in ~lgT tosses using the binary search. Two step will cost ~2lgT tosses. Version 3: Test floors in increments of sqrt(N) starting from the first floor. {e.g: {1, sqrt(N), 2*sqrt(N), 3*sqrt(N)...t*sqrt(N)...}. When the egg breaks on t, test floor from (t-1)*sqrt(N) and increment by each floor. The remaining sqrt(N){e.g [(t-1)*sqrt(N), t*sqrt(N))} tests will be enough to check each floor between floor t-1 and t. The floor that breaks will be the value of T. Version 4: Start test at floor 1 in increments of t^2 (e.g {1,4,9...t^2..N}), When the egg breaks on t, test floor from (t-1)^2+1 and increment by each floor.