天天看點

【資料結構其實真不難】算法分析

  • 💂 個人首頁:​​陶然同學​​
  • 🤟 版權:本文由【陶然同學】原創、在CSDN首發、需要轉載請聯系部落客
  • 💬 如果文章對你有幫助、歡迎關注、點贊、收藏(一鍵三連)和訂閱專欄哦
  • 💅想尋找共同成長的小夥伴,請點選【​​Java全棧開發社群​​】

前言

前面我們已經介紹了,研究算法的最終目的就是如何花更少的時間,如何占用更少的記憶體去完成相

同的需求,并且

也通過案例示範了不同算法之間時間耗費和空間耗費上的差異,但我們并不能将時間占用和空間占

用量化,是以,

接下來我們要學習有關算法時間耗費和算法空間耗費的描述和分析。有關算法時間耗費分析,我們

稱之為算法的時

間複雜度分析,有關算法的空間耗費分析,我們稱之為算法的空間複雜度分析。

1.算法的時間複雜度分析

我們要計算算法時間耗費情況,首先我們得度量算法的執行時間,那麼如何度量呢?

事後分析估算方法:

比較容易想到的方法就是我們把算法執行若幹次,然後拿個計時器在旁邊計時,這種事後統計的方

法看上去的确不

錯,并且也并非要我們真的拿個電腦在旁邊計算,因為計算機都提供了計時的功能。這種統計方

法主要是通過設

計好的測試程式和測試資料,利用計算機計時器對不同的算法編制的程式的運作時間進行比較,從

而确定算法效率

的高低,但是這種方法有很大的缺陷:必須依據算法實作編制好的測試程式,通常要花費大量時間

和精力,測試完

了如果發現測試的是非常糟糕的算法,那麼之前所做的事情就全部白費了,并且不同的測試環境

(

硬體環境

)

的差别

導緻測試的結果差異也很大。

public static void main(String[] args) { 
    long start = System.currentTimeMillis(); 
    int sum = 0; int n=100; 
    for (int i = 1; i <= n; i++) { 
        sum += i; 
    }
    System.out.println("sum=" + sum); 
    long end = System.currentTimeMillis(); 
    System.out.println(end-start); 
}      

事前分析估算方法:

在計算機程式編寫前,依據統計方法對算法進行估算,經過總結,我們發現一個進階語言編寫的程

序程式在計算機

上運作所消耗的時間取決于下列因素:

1.算法采用的政策和方案;

2.編譯産生的代碼品質;

3.

問題的輸入規模

(

所謂的問題輸入規模就是輸入量的多少

)

4.

機器執行指令的速度;

由此可見,抛開這些與計算機硬體、軟體有關的因素,一個程式的運作時間依賴于算法的好壞和問

題的輸入規模。

如果算法固定,那麼該算法的執行時間就隻和問題的輸入規模有關系了。

我麼再次以之前的求和案例為例,進行分析。

需求:

計算

1

100

的和。

第一種解法:

如果輸入量為n為1,則需要計算1次; 
如果輸入量n為1億,則需要計算1億次; 
public static void main(String[] args) {
    int sum = 0;//執行1次 
    int n=100;//執行1次 
    for (int i = 1; i <= n; i++) {//執行了n+1次 
        sum += i; //執行了n次 
    }
    System.out.println("sum=" + sum); 
}      

第二種解法:

如果輸入量為n為1,則需要計算1次; 
如果輸入量n為1億,則需要計算1次;
public static void main(String[] args) { 
    int sum = 0;//執行1次 
    int n=100;//執行1次 
    sum = (n+1)*n/2;//執行1次 
    System.out.println("sum="+sum);
}      

是以,當輸入規模為

n

時,第一種算法執行了

1+1+(n+1)+n=2n+3

次;第二種算法執行了

1+1+1=3

次。如果我們把

第一種算法的循環體看做是一個整體,忽略結束條件的判斷,那麼其實這兩個算法運作時間的差距

就是

n

1

的差距。

為什麼循環判斷在算法

1

裡執行了

n+1

次,看起來是個不小的數量,但是卻可以忽略呢?我們來看

下一個例子:

需求:

計算

100

1+100

2+100

3+...100

100

的結果

代碼:

public static void main(String[] args) { 
    int sum=0; 
    int n=100; 
    for (int i = 1; i <=n ; i++) { 
        for (int j = 1; j <=n ; j++) { 
            sum+=i; 
        } 
    }
    System.out.println("sum="+sum); 
}      

上面這個例子中,如果我們要精确的研究循環的條件執行了多少次,是一件很麻煩的事情,并且,

由于真正計算和

的代碼是内循環的循環體,是以,在研究算法的效率時,我們隻考慮核心代碼的執行次數,這樣可

以簡化分析。

我們研究算法複雜度,側重的是當輸入規模不斷增大時,算法的增長量的一個抽象

(

規律

)

,而不是

精确地定位需要

執行多少次,因為如果是這樣的話,我們又得考慮回編譯期優化等問題,容易主次跌倒。

我們不關心編寫程式所用的語言是什麼,也不關心這些程式将跑在什麼樣的計算機上,我們隻關心

它所實作的算

法。這樣,不計那些循環索引的遞增和循環終止的條件、變量聲明、列印結果等操作,最終在分析

程式的運作時間

時,最重要的是把程式看做是獨立于程式設計語言的算法或一系列步驟。我們分析一個算法的運作

時間,最重要的

就是把核心操作的次數和輸入規模關聯起來。

【資料結構其實真不難】算法分析

1.1函數漸近增長

概念:

給定兩個函數

f(n)

g(n),

如果存在一個整數

N

,使得對于所有的

n>N,f(n)

總是比

g(n)

大,那麼我們說

f(n)

的增長漸近

快于

g(n)

概念似乎有點艱澀難懂,那接下來我們做幾個測試。

測試一:

假設四個算法的輸入規模都是

n

1.

算法

A1

要做

2n+3

次操作,可以這麼了解:先執行

n

次循環,執行完畢後,再有一個

n

次循環,最後有

3

次運算;

2.算法A2

要做

2n

次操作;

3.

算法

B1

要做

3n+1

次操作,可以這個了解:先執行

n

次循環,再執行一個

n

次循環,再執行一個

n

循環,最後有

1 次運算。

4.算法B2

要做

3n

次操作;

那麼,上述算法,哪一個更快一些呢?

【資料結構其實真不難】算法分析

通過資料表格,比較算法

A1

和算法

B1

當輸入規模

n=1

時,

A1

需要執行

5

次,

B1

需要執行

4

次,是以

A1

的效率比

B1

的效率低;

當輸入規模

n=2

時,

A1

需要執行

7

次,

B1

需要執行

7

次,是以

A1

的效率和

B1

的效率一樣;

當輸入規模

n>2

時,

A1

需要的執行次數一直比

B1

需要執行的次數少,是以

A1

的效率比

B1

的效率高;

是以我們可以得出結論:

當輸入規模

n>2

時,算法

A1

的漸近增長小于算法

B1

的漸近增長

通過觀察折線圖,我們發現,随着輸入規模的增大,算法

A1

和算法

A2

逐漸重疊到一塊,算法

B1

算法

B2

逐漸重疊

到一塊,是以我們得出結論:

随着輸入規模的增大,算法的常數操作可以忽略不計

測試二:

假設四個算法的輸入規模都是

n

1.

算法

C1

需要做

4n+8

次操作

2.

算法

C2

需要做

n

次操作

3.

算法

D1

需要做

2n^2

次操作

4.算法D2

需要做

n^2

次操作

那麼上述算法,哪個更快一些?

【資料結構其實真不難】算法分析

通過資料表格,對比算法

C1

和算法

D1

當輸入規模

n<=3

時,算法

C1

執行次數多于算法

D1

,是以算法

C1

效率低一些;

當輸入規模

n>3

時,算法

C1

執行次數少于算法

D1

,是以,算法

D2

效率低一些,

是以,總體上,算法

C1

要優于算法

D1.

通過折線圖,對比對比算法

C1

C2

随着輸入規模的增大,算法

C1

和算法

C2

幾乎重疊

通過折線圖,對比算法

C

系列和算法

D

系列:

随着輸入規模的增大,即使去除

n^2

前面的常數因子,

D

系列的次數要遠遠高于

C

系列。

是以,可以得出結論:

随着輸入規模的增大,與最高次項相乘的常數可以忽略

測試三:

假設四個算法的輸入規模都是

n

算法

E1:

2n^2+3n+1;

算法

E2

n^2

算法

F1

2n^3+3n+1

算法

F2

n^3

那麼上述算法,哪個更快一些?

【資料結構其實真不難】算法分析
【資料結構其實真不難】算法分析

通過資料表格,對比算法

E1

和算法

F1

n=1

時,算法

E1

和算法

F1

的執行次數一樣;

n>1

時,算法

E1

的執行次數遠遠小于算法

F1

的執行次數;

是以算法

E1

總體上是由于算法

F1

的。

通過折線圖我們會看到,算法

F

系列随着

n

的增長會變得特塊,算法

E

系列随着

n

的增長相比較算法

F

來說,變得比較

慢,是以可以得出結論:

最高次項的指數大的,随着

n

的增長,結果也會變得增長特别快

測試四:

假設五個算法的輸入規模都是

n

算法

G

n^3;

算法

H:

n^2;

算法

I

n:

算法

J

logn

算法

K:

那麼上述算法,哪個效率更高呢?

【資料結構其實真不難】算法分析
【資料結構其實真不難】算法分析

通過觀察資料表格和折線圖,很容易可以得出結論:

算法函數中

n

最高次幂越小,算法效率越高

總上所述,在我們比較算法随着輸入規模的增長量時,可以有以下規則:

1.

算法函數中的常數可以忽略;

2.

算法函數中最高次幂的常數因子可以忽略;

3.

算法函數中最高次幂越小,算法效率越高。

1.2算法時間複雜度

1.2.1大O記法

定義:

在進行算法分析時,語句總的執行次數

T(n)

是關于問題規模

n

的函數,進而分析

T(n)

随着

n

的變化情

況并确定

T(n)

量級。算法的時間複雜度,就是算法的時間量度,記作

:T(n)=O(f(n))

。它表示随着問題規模

n

的增

大,算法執行時間

的增長率和

f(n)

的增長率相同,稱作算法的漸近時間複雜度,簡稱時間複雜度,其中

f(n)

是問題規模

n

的某個函數。

在這裡,我們需要明确一個事情:

執行次數

=

執行時間

用大寫

O()

來展現算法時間複雜度的記法,我們稱之為大

O

記法。一般情況下,随着輸入規模

n

的增

大,

T(n)

增長最

慢的算法為最優算法。

下面我們使用大

O

表示法來表示一些求和算法的時間複雜度:

算法一:

public static void main(String[] args) { 
    int sum = 0;//執行1次 
    int n=100;//執行1次 
    sum = (n+1)*n/2;//執行1次
    System.out.println("sum="+sum); 
}      

算法二:

public static void main(String[] args) { 
    int sum = 0;//執行1次
    int n=100;//執行1次 
    for (int i = 1; i <= n; i++) { 
        sum += i;//執行了n次 
    }
    System.out.println("sum=" + sum); 
}      

算法三:

public static void main(String[] args) { 
    int sum=0;//執行1次 
    int n=100;//執行1次 
    for (int i = 1; i <=n ; i++) { 
        for (int j = 1; j <=n ; j++) { 
            sum+=i;//執行n^2次 
        } 
    }
    System.out.println("sum="+sum);
 }      

如果忽略判斷條件的執行次數和輸出語句的執行次數,那麼當輸入規模為

n

時,以上算法執行的次

數分别為:

算法一:

3

算法二:

n+3

算法三:

n^2+2

如果用大

O

記法表示上述每個算法的時間複雜度,應該如何表示呢?基于我們對函數漸近增長的分

析,推導大

O

的表示法有以下幾個規則可以使用:

1.

用常數

1

取代運作時間中的所有加法常數;

2.

在修改後的運作次數中,隻保留高階項;

3.

如果最高階項存在,且常數因子不為

1

,則去除與這個項相乘的常數;

是以,上述算法的大

O

記法分别為:

算法一:

O(1)

算法二:

O(n)

算法三:

O(n^2)

1.2.2常見的大O階

1.

線性階

一般含有非嵌套循環涉及線性階,線性階就是随着輸入規模的擴大,對應計算次數呈直線增長,例

如:

public static void main(String[] args) { 
    int sum = 0; 
    int n=100; 
    for (int i = 1; i <= n; i++) { 
        sum += i; 
    }
    System.out.println("sum=" + sum); 
}      

上面這段代碼,它的循環的時間複雜度為

O(n),

因為循環體中的代碼需要執行

n

2.

平方階

一般嵌套循環屬于這種時間複雜度

public static void main(String[] args) { 
    int sum=0,n=100; 
    for (int i = 1; i <=n ; i++) { 
        for (int j = 1; j <=n ; j++) { 
            sum+=i; } 
        }
    System.out.println(sum);
}      

上面這段代碼,

n=100

,也就是說,外層循環每執行一次,内層循環就執行

100

次,那總共程式想

要從這兩個循環

中出來,就需要執行

100*100

次,也就是

n

的平方次,是以這段代碼的時間複雜度是

O(n^2).

3.

立方階

一般三層嵌套循環屬于這種時間複雜度

public static void main(String[] args) { 
    int x=0,n=100; 
    for (int i = 1; i <=n ; i++) { 
        for (int j = i; j <=n ; j++) { 
            for (int j = i; j <=n ; j++) { 
                x++; 
            } 
        }
     }
    System.out.println(x); 
}      

上面這段代碼,

n=100

,也就是說,外層循環每執行一次,中間循環循環就執行

100

次,中間循環

每執行一次,最

内層循環需要執行

100

次,那總共程式想要從這三個循環中出來,就需要執行

100

100

100

次,也就

n

的立方,所

以這段代碼的時間複雜度是

O(n^3).

4.

對數階

對數,屬于高中數學的内容,我們分析程式以程式為主,數學為輔,是以不用過分擔心。

int i=1,n=100; 
while(i<n){ 
    i = i*2; 
}      

由于每次

i*2

之後,就距離

n

更近一步,假設有

x

2

相乘後大于

n

,則會退出循環。由于是

2^x=n,

x=log(2)n,

以這個循環的時間複雜度為

O(logn);

對于對數階,由于随着輸入規模

n

的增大,不管底數為多少,他們的增長趨勢是一樣的,是以我們

會忽略底數。

【資料結構其實真不難】算法分析
【資料結構其實真不難】算法分析

5.

常數階

一般不涉及循環操作的都是常數階,因為它不會随着

n

的增長而增加操作次數。例如:

public static void main(String[] args) { 
    int n=100; 
    int i=n+2; 
    System.out.println(i); 
}      

上述代碼,不管輸入規模

n

是多少,都執行

2

次,根據大

O

推導法則,常數用

1

來替換,是以上述代

碼的時間複雜度 為O(1)

下面是對常見時間複雜度的一個總結:

1.2.3函數調用的時間複雜度分析

之前,我們分析的都是單個函數内,算法代碼的時間複雜度,接下來我們分析函數調用過程中時間

複雜度。

案例一:

public static void main(String[] args) { 
    int n=100; 
    for (int i = 0; i < n; i++) { 
        show(i); 
    } 
    }private static void show(int i) { 
        System.out.println(i); 
    }
}      

main

方法中,有一個

for

循環,循環體調用了

show

方法,由于

show

方法内部隻執行了一行代碼,

是以

show

方法

的時間複雜度為

O(1),

main

方法的時間複雜度就是

O(n)

案例二:

public static void main(String[] args) { 
    int n=100; 
    for (int i = 0; i < n; i++) { 
        show(i); 
    } 
}
    private static void show(int i) { 
        for (int j = 0; j < i; i++) { 
        System.out.println(i); 
    }
 }      

main

方法中,有一個

for

循環,循環體調用了

show

方法,由于

show

方法内部也有一個

for

循環,

是以

show

方法

的時間複雜度為

O(n),

main

方法的時間複雜度為

O(n^2)

案例三:

public static void main(String[] args) { 
    int n=100; 
    show(n);
     for (int i = 0; i < n; i++) { 
        show(i); 
     }
    for (int i = 0; i < n; i++) { 
        for (int j = 0; j < n; j++) {
             System.out.println(j); 
        } 
    } 
}
private static void show(int i) { 
    for (int j = 0; j < i; i++) { 
        System.out.println(i); 
    }
}      

show

方法中,有一個

for

循環,是以

show

方法的時間複雜度為

O(n),

main

方法中,

show(n)

這行

代碼内部執行

的次數為

n

,第一個

for

循環内調用了

show

方法,是以其執行次數為

n^2,

第二個嵌套

for

循環内隻執行

了一行代碼,

是以其執行次數為

n^2,

那麼

main

方法總執行次數為

n+n^2+n^2=2n^2+n

。根據大

O

推導規則,去掉

n

保留最高階

項,并去掉最高階項的常數因子

2

,是以最終

main

方法的時間複雜度為

O(n^2)

1.2.4最壞情況

從心理學角度講,每個人對發生的事情都會有一個預期,比如看到半杯水,有人會說:哇哦,還有

半杯水哦!但也

有人會說:天哪,隻有半杯水了。一般人處于一種對未來失敗的擔憂,而在預期的時候趨向做最壞

的打算,這樣即

使最糟糕的結果出現,當事人也有了心理準備,比較容易接受結果。假如最糟糕的結果并沒有出

現,當事人會很快 樂。

算法分析也是類似,假如有一個需求:

有一個存儲了

n

個随機數字的數組,請從中查找出指定的數字。

public int search(int num){ 
    int[] arr={11,10,8,9,7,22,23,0}; 
    for (int i = 0; i < arr.length; i++) { 
        if (num==arr[i]){ 
            return i; 
        } 
    }
    return -1; 
}      

最好情況:

查找的第一個數字就是期望的數字,那麼算法的時間複雜度為

O(1)

最壞情況:

查找的最後一個數字,才是期望的數字,那麼算法的時間複雜度為

O(n)

平均情況:

任何數字查找的平均成本是

O(n/2)

最壞情況是一種保證,在應用中,這是一種最基本的保障,即使在最壞情況下,也能夠正常提供服

務,是以,除非

特别指定,我們提到的運作時間都指的是最壞情況下的運作時間。

2.算法的空間複雜度分析

計算機的軟硬體都經曆了一個比較漫長的演變史,作為為運算提供環境的記憶體,更是如此,從早些

時候的

512k,

曆了

1M

2M

4M...

等,發展到現在的

8G

,甚至

16G

32G

,是以早期,算法在運作過程中對記憶體

的占用情況也是 一個經常需要考慮的問題。我麼可以用算法的空間複雜度來描述算法對記憶體的占用。

2.1java中常見記憶體占用

1.

基本資料類型記憶體占用情況:

【資料結構其實真不難】算法分析

2.計算機通路記憶體的方式都是一次一個位元組

【資料結構其實真不難】算法分析

3.

一個引用(機器位址)需要

8

個位元組表示:

例如:

Date date = new Date(),

date

這個變量需要占用

8

個位元組來表示

4.

建立一個對象,比如

new Date()

,除了

Date

對象内部存儲的資料

(

例如年月日等資訊

)

占用的内

存,該對象本身也

有記憶體開銷,每個對象的自身開銷是

16

個位元組,用來儲存對象的頭資訊。

5.

一般記憶體的使用,如果不夠

8

個位元組,都會被自動填充為

8

位元組:

【資料結構其實真不難】算法分析

6.java

中數組被被限定為對象,他們一般都會因為記錄長度而需要額外的記憶體,一個原始資料類型

的數組一般需要

24

位元組的頭資訊

(16

個自己的對象開銷,

4

位元組用于儲存長度以及

4

個填充位元組

)

再加上儲存值所需的

記憶體。

2.2算法的空間複雜度

了解了

java

的記憶體最基本的機制,就能夠有效幫助我們估計大量程式的記憶體使用情況。

算法的空間複雜度計算公式記作:

S(n)=O(f(n)),

其中

n

為輸入規模,

f(n)

為語句關于

n

所占存儲空間

的函數。

案例:

對指定的數組元素進行反轉,并傳回反轉的内容。

解法一:

public static int[] reverse1(int[] arr){ 
    int n=arr.length;//申請4個位元組 
    int temp;//申請4個位元組 
    for(int start=0,end=n-1;start<=end;start++,end--){ 
        temp=arr[start];
        arr[start]=arr[end]; 
        arr[end]=temp; 
    }
    return arr; 
}      

解法二:

public static int[] reverse2(int[] arr){
    int n=arr.length;//申請4個位元組 
    int[] temp=new int[n];//申請n*4個位元組+數組自身頭資訊開銷24個位元組 
    for (int i = n-1; i >=0; i--) { 
        temp[n-1-i]=arr[i]; 
    }
    return temp;
}      

忽略判斷條件占用的記憶體,我們得出的記憶體占用情況如下:

算法一:

不管傳入的數組大小為多少,始終額外申請

4+4=8

個位元組;

算法二:

4+4n+24=4n+28;

根據大

O

推導法則,算法一的空間複雜度為

O(1),

算法二的空間複雜度為

O(n),

是以從空間占用的角

度講,算法一要

優于算法二。

由于

java

中有記憶體垃圾回收機制,并且

jvm

對程式的記憶體占用也有優化(例如即時編譯),我們無

法精确的評估一

java

程式的記憶體占用情況,但是了解了

java

的基本記憶體占用,使我們可以對

java

程式的記憶體占用

情況進行估算。

由于現在的計算機裝置記憶體一般都比較大,基本上個人計算機都是

4G

起步,大的可以達到

32G

是以記憶體占用一般

情況下并不是我們算法的瓶頸,普通情況下直接說複雜度,預設為算法的時間複雜度。

但是,如果你做的程式是嵌入式開發,尤其是一些傳感器裝置上的内置程式,由于這些裝置的記憶體

很小,一般為幾

kb

,這個時候對算法的空間複雜度就有要求了,但是一般做

java

開發的,基本上都是伺服器開發,

一般不存在這樣 的問題。

3.最後