算法分析 - Algorithms, Part I, week 1 ANALYSIS OF ALGORITHMS

前言

上一篇:並查集
下一篇:棧和隊列java

在算法性能上咱們經常面臨的挑戰是咱們的程序可否求解實際中的大型輸入:
--爲何程序運行的慢?
--爲何程序耗盡了內存?程序員

沒有理解算法的性能特徵會致使客戶端的性能不好,爲了不這種狀況的出線,須要具有算法分析的一些知識。
此篇主要涉及一些基礎數學知識和科學方法,以及如何在實踐應用中使用這些方法理解算法的性能。咱們的重點放在得到性能的預測上。
主要分爲5部分:面試

  • 觀察特色 (observations)
  • 數學模型 (mathematical models)
  • 增加階數分類 (order-of-growth classifications)
  • 算法理論 (theory of algorithms)
  • 內存使用 (memory)

注:下文我所的增加量級和增加階數是一個東西其實...算法

咱們將從多種不一樣的角色思考這些問題:spring

  • 程序員:解決一個問題,讓算法可以工做,並部署它
  • 用戶:完成某項工做,但不關心程序作了什麼
  • 理論家:想要理解發生的事情
  • 團隊:可能須要完成以上角色的全部工做

關於算法分析須要集中考慮的關鍵是運行時間。運行時間也能夠理解爲完成一項計算咱們須要進行多少次操做。
這裏主要關心:apache

  • 預測算法的性能
  • 比較完成同一任務不一樣算法的性能
  • 在最壞狀況下算法性能的底線
  • 理解算法如何運行的一些理論基礎

算法分析的科學方法概述:segmentfault

  • 從天然界中觀察某些特徵(程序在計算機上的運行時間)
  • 提出假設模型(與觀察到的現象相一致的模型)
  • 預測(利用上邊的假設作出合理的預測,通常用來預測更大問題規模,或者另外一臺計算機上的運行時間)
  • 驗證(做更多的觀察來驗證咱們的預測)
  • 證明(重複地驗證直到證明咱們的模型和觀察的特徵吻合,證明咱們的模型假設是正確的)

使用科學方法有一些基本原則:數組

  • 別人作一樣的實驗也會獲得相同的結果
  • 假設必須具有某個特殊性質:可證僞性

(可證僞性:指從一個理論推導出來的結論(解釋、預見)在邏輯上或原則上要有與一個或一組觀察陳述與之發生衝突或抵觸的可能。
可證僞,不等於已經被證僞;可證僞,不等因而錯的。)緩存

觀察

第一步是要觀察算法的性能特色,這裏就是要觀察程序的運行時間。
給程序計時的方法:網絡

  • 看錶(你沒看錯,簡單粗暴)
  • 利用API:不少第三方或者Java標準庫中有一個秒錶類,能夠計算用掉的時間
    (如apache commons lang,springframework的工具包都有,這裏使用stdlib庫中的Stopwatch API 進行時間監控)

圖片描述

咱們將使用 3-SUM 問題做爲觀察的例子。

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();
        }
    }

實證分析

測試數據能夠用愈來愈大的輸入來運行。每次將輸入的大小翻倍,程序會運行得更久。經過相似的測試,有時能至關方便和快速地評估程序何時結束。

圖片描述

數據分析

經過實證得出的數據,能夠創建圖像,使觀察更直觀:

  • 標準座標 :Y軸:運行時間;X軸:輸入大小

圖片描述

  • 雙對數座標:Y軸:取運行時間的對數;X軸:取問題輸入大小的對數

(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 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 不適用於識別對數因子

  • 計算a值

得出 b 的值後,在某個大的輸入值上運行程序,就能求出 a 值。

圖片描述

由此得出假設:運行時間 ≈ 0.998 × 10^–10 × N^3 (秒)

咱們經過做圖得出的模型( ≈ 1.006 × 10^–10 × N^2.999 )和咱們經過Doubling hypothesis方法得出的模型是很接近的。

計算機中有不少的因素也會影響運行時間,可是關鍵因素通常和計算機的型號無關。

影響因素

關鍵的因素即爲你使用的算法和數據. 決定冪定律中的 b

還有不少與系統相關的因素:

  • 硬件配置:CPU,內存,緩存...
  • 軟件環境:編譯器,解析器,垃圾回收器...
  • 計算機的系統:操做系統,網絡,其它應用...

以上全部因素,包括關鍵因素,都決定了冪定律中的 a

現代計算機系統中硬件和軟件是很是複雜的,有時很難得到很是精確的測量,可是另外一方面咱們不須要像其餘科學中須要犧牲動物或者向一顆行星發射探測器這些複雜地方法,咱們只須要進行大量的實驗,就能理解和獲得咱們想要知道的影響因子(的值)。

數學模型

經過觀察發生了什麼可以讓咱們對性能做出預測,可是並不能幫助咱們理解算法具體作了什麼。經過數學模型更有利於咱們理解算法的行爲。
咱們能夠經過識別全部的基本操做計算出程序的總運行時間。
致敬一下,Don Knuth 在二十世紀60年代末便提出和推廣了運行時間的數學模型:sum(操做的開銷 * 操做執行的頻率)

  • 須要分析程序以確執行了哪些操做。
  • 計算機以及系統的開銷取決於機器,編譯器。
  • 頻率分析將咱們引向數學方法,它取決於算法,輸入數據。

基於 knuth 研究得知,原則上咱們可以得到算法,程序或者操做的性能的精確數學模型。

基本操做的開銷

基本操做的開銷通常都是一個取決於計算機及系統的常量,若是想要知道這個常量是多少,能夠對一個基本操做運行成千上萬的實驗的方式算出。好比能夠進行十億次的加法,而後得出在你運行的計算機系統上進行 a + b 的基本操做花費大概 2.1 納秒
爲了方便創建數學模型,絕大多數的狀況下咱們只要 假定它是某個常數 cn (n:1,2,3...) 就能夠。
下圖羅列了一下基本操做和其開銷

圖片描述

關於N:當咱們在處理一組對象時,假設有N個對象,有一些操做須要的時間和N成正比。好比第六行,分配一個大小爲N的數組是,須要正比於N的時間,由於在Java中默認吧數組中的每一個元素初始化爲0.
還有些運行時間去決定系統的實現,好比鏈接兩個字符串須要的運行時間與字符串的長度(N)成正比,鏈接字符串並不等同於加法運算

操做執行的頻率

  • 1-SUM 爲例

數組中有多少個元素等於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的輸入

圖片描述

  • 2-SUM 爲例

數組中有多少對元素等於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 問題進行分析

  • 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 爲常數).

上圖爲雙對數座標圖,從圖中能夠看出若是:

  • 增加量級是對數(logarithmic)或者常數(constant),不管問題的規模多大,算法的運行速度都很快
  • 增加量級是線性的(linearithmic/linear), 也就是增加量級與問題大小N成正比,N增加,運行時間也會隨問題規模大小線性增加 (NlogN 就是 linearithmic 類型的增加量級)

以上兩種算法都是咱們想要設計的算法,它們可以成比例適應問題的規模。

  • 增加量級爲平方階(quadratic),運行時間遠快於問題輸入的大小,即 N 的大小。這種算法不適合處理龐大問題的輸入。立方階(cubic)的算法就更糟糕
  • 還有一種指數階算法(exponential),出來龐大輸入也不會用到

綜上所訴,咱們研究算法是,首先要保證這些算法不是平方或者立方階的。
增加階數類型實際上就源於咱們寫的代碼中的某些簡單模式。下圖使用翻倍測試(參考上邊 Doubling hypothesis 內容)得出算法運行時間隨問題大小翻倍後增加的翻倍狀況。某些增加量級對應的代碼模式以下:

圖片描述

  • 若是沒有循環,增加階數是常數(constant),運行時間的增加是常數的;
  • 若是有某種循環:

    • 每次循環被分爲兩半(如二分查找算法 binary search),增加階數就是對數,運行時間的增加幾乎是常數的
    • 若是遍歷了輸入中的全部對象,運行時間是線性的(與 N 成正比),典型的例子是找一個數組裏頭的最大值,或是上邊提到的 1-SUM 問題
    • nlogn 線性對數階算法,這種時間複雜度源於一種特定的算法設計技巧叫分治法,如歸併排序(mergesort),後續幾周內會有介紹
    • 算法中有兩層for循環(如 2-SUM),算法的運行時間是平方階的,和N^2成正比,當輸入翻倍後,運行時間增大4倍
    • 三層loop(如 3-SUM),運行時間就是立方階的,與N^3成正比,當輸入翻倍後,運行時間增大8倍
    • 指數階算法 2^n, 運行時間是指數階,這些算法中的涉及到的 N 都不會很是大

經過上述分析,咱們在設計處理巨大規模輸入的算法的時候,通常都儘可能把算法設計成線性階數和線性對數階數。

爲了展現描述算法性能的數學模型的創建過程,下邊以 binary search 二分查找爲例

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,或者別的形式說明要查找的定值不在數組中

圖片描述

Java 實現

此算法的不變式:若是數組 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 問題設計一個更快的算法:

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]),那麼就有 a[i], a[j] 和 - (a[i] + a[j]) 三個整數和爲 0

運行時間的增加階數: N^2 log N.

  • 第一步: 排序須要正比於 N^2(若是使用插入排序, N^2的數組訪問仍是好理解的,兩層loops).
  • 第二步: 二分查找使用 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:取決於最壞狀況下的性能保證,保證你的算法在最壞狀況下運行也能很快

    • 示例:使用歸併排序 Mergesort 而不是快速排序 Quicksort

若是不能保證最壞狀況,那麼就考慮隨機狀況,依靠某種機率條件下成立的保證

  • 方法2:隨機化,取決於機率保證。

    • 示例:Quicksort

(排序在後幾個星期有談論到)

對於增加量級的討論引出了對算法理論的討論

算法理論 - 探討最壞狀況

新目標

  • 肯定問題的「困難性」

    • 例如 3-SUM 有多難?
  • 開發「最優」算法解決問題

方法
用增加量級對最壞狀況進行描述

  • 在分析中試着去掉儘量多的細節,將分析作到只差一個常數倍數的精度,這就是上邊所說到的利用增加量級分析的方法
  • 經過關注最壞的狀況,徹底忽略輸入模型,消除輸入模型的可變性,把重點放在針對最壞狀況的設計方法上,這樣就能簡便地只利用增加階數來談論算法性能

分析的目標是找出「最優」算法

最優算法

  • 對於任意輸入,咱們能將運行時間的浮動控制在一個常數以內。
  • 由於是針對最壞狀況考慮,因此提出的算法也就證實了除了它之外,沒有其它的算法提供更好的性能保證了,這個算法就是「最優的」

描述性能界限的經常使用記號

圖片描述

如何使用這三個符號對算法按照性能分類?

使用示例

圖片描述

1-SUM 問題

目標:肯定問題的「難度」並開發「最優」算法。

  • EX. 1-SUM 問題:數組中是否有0?

上限:O(g(N)) 問題難度的上限取決於某個特定的算法

  • EX. 1-SUM的暴力算法:查看每一個數組元素
  • 暴力算法的運行時間爲 Θ(N)
  • 1-SUM 問題未知的最優算法的運行時間是 O(N):

    • 這裏的 g(N)=N,重複一次,Θ(N) 表示某個常數*N。暴力算法已經代表解決 1-SUM 問題須要 Θ(N) 的時間,那麼若是存在最優算法,運行時間確定是 ≤ Θ(N), 根據上表,≤ Θ(N)O(N) 表示

下限:Ω(h(N)) 證實沒有算法能夠作得比 Θ(h(N)) 更好了

  • Ex. 必須檢查全部N個數組裏頭的元素(任何未經檢查的條目均可能爲0)
  • 1-SUM 的未知最優算法的運行時間是 Ω(N)

    • 這裏 h(N) = N,由於必須檢查全部項,沒有別的算法能夠作到比暴力算法 Θ(N) 更好了,因此 1-SUM 的未知最優算法的運行時間是確定都是 ≥ Θ(N) 的,記做 Ω(N)

最優算法

  • 下限等於上限:g(N)= h(N)= N
  • Ex. 1-SUM 的暴力算法是最優的:其運行時間是 Θ(N)。

對於簡單問題,找到最優算法仍是比較簡單的,但對於很複雜的問題,肯定上下限就很困難,肯定上下界吻合就更加困難。

3-SUM 問題

目標

  • 肯定問題的「難度」並開發「最優」算法。
  • Ex. 3-SUM: 數組中,三個數和爲 0 出現多少次

暴力算法分析

上限: 問題難度的上限取決於某個特定的算法

  • Ex. 3-SUM 的暴力算法
  • 3-SUM 的最優算法的運行時間爲 O(N^3)

    • 3-SUM 的的暴力算法須要的運行時間是 Θ(N^3),若是存在某種算法比暴力算法更優,那麼運行時間確定 ≤ Θ(N^3), 記做 O(N^3)

但若是咱們找到了更好的算法

上限: 一種特定的改進算法

  • Ex. 改進的 3-SUM 算法
  • 3-SUM 最優算法的運行時間爲 O(N^2*logN) {使用了二分查找}

下限: 證實沒有別的算法能夠作得更好

  • Ex. 必須檢查全部N個條目以解決 3-SUM 問題
  • 求解 3-SUM 的最優算法的運行時間爲 Ω(N)

可能你們仍是對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 的內存

圖片描述

典型的 JAVA 對象內存開銷

Object overhead 對象須要的額外空間. 16 bytes.
Reference 引用. 8 bytes.
Padding 內置用來對齊的空間. 對齊空間能夠是 4 bytes 或者是其它,對齊空間的分配目的是使得每一個對象使用的空間都是 8 bytes 的倍數

下圖是一個日期對象的內存佔用量例子

圖片描述

數據類型值的總內存使用量:

  • 基本數據類型:int 爲 4 字節,double 爲 8 字節,...
  • 對象引用:8個字節,這是指針須要佔用的空間
  • 數組:24 個字節 + 每一項的內存。
  • 對象:16 個字節 + 實例變量的空間 ( + 8個字節,若是有內部類對象)
  • 對齊空間:增長必定量字節,使得上邊的字節加上這裏的字節數和爲 8 個字節的倍數

例子:用了多少字節?

圖片描述

使用上邊的基本知識能夠算出 B

  • 對象的額外空間 16 bytes (參考日期對象用例)
  • int[] id:8 byte + ( 4N + 24 )
  • int[] sz:同上 (引用 + 24 個字節 + int 類型開銷 * N)
  • int count:4 bytes
  • 對齊空間:4 byte

總共 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.
相關文章
相關標籤/搜索