工作之余b站充電
課程介紹
1.1課程目標
數據結構和算法這門課程無論在哪個學校的計算機專業,都是一門必修課,因為這門課程非常重要的,是編程必備
的基礎,但是這門課程是一門不太好學習的課程,因為它學習起來是非常的枯燥乏味的。但是如果你想讓自己的編
程能力有質的飛躍,不再停留於調用現成的API,而是追求更完美的實現,那么這門課程就是你的必修課,因為程
序設計=數據結構+算法。
通過對基礎數據結構和算法的學習,能更深層次的理解程序,提升編寫代碼的能力,讓程序的代碼更優雅,性能更
高。
1.2 課程內容
1.數據結構和算法概述
2.算法分析
3.排序
4.線性表
5.符號表
6.樹
7.堆
8.優先隊列
9.並查集
10.圖l
一、數據結構和算法概述
1.1什么是數據結構?
官方解釋:
數據結構是一門研究非數值計算的程序設計問題中的操作對象,以及他們之間的關系和操作等相關問題的學科。
大白話:
數據結構就是把數據元素按照一定的關系組織起來的集合,用來組織和存儲數據
1.2數據結構分類
傳統上,我們可以把數據結構分為邏輯結構和物理結構兩大類。
邏輯結構分類:
邏輯結構是從具體問題中抽象出來的模型,是抽象意義上的結構,按照對象中數據元素之間的相互關系分類,也是
我們后面課題中需要關注和討論的問題。
a.集合結構:集合結構中數據元素除了屬於同一個集合外,他們之間沒有任何其他的關系。

b.線性結構:線性結構中的數據元素之間存在一對一的關系

c.樹形結構:樹形結構中的數據元素之間存在一對多的層次關系

d.圖形結構:圖形結構的數據元素是多對多的關系

物理結構分類:
邏輯結構在計算機中真正的表示方式(又稱為映像)稱為物理結構,也可以叫做存儲結構。常見的物理結構有順序
存儲結構、鏈式存儲結構。
順序存儲結構:
把數據元素放到地址連續的存儲單元里面,其數據間的邏輯關系和物理關系是一致的 ,比如我們常用的數組就是
順序存儲結構。

順序存儲結構存在一定的弊端,就像生活中排時也會有人插隊也可能有人有特殊情況突然離開,這時候整個結構都
處於變化中,此時就需要鏈式存儲結構。
鏈式存儲結構:
是把數據元素存放在任意的存儲單元里面,這組存儲單元可以是連續的也可以是不連續的。此時,數據元素之間並
不能反映元素間的邏輯關系,因此在鏈式存儲結構中引進了一個指針存放數據元素的地址,這樣通過地址就可以找
到相關聯數據元素的位置

1.3什么是算法?
官方解釋:
算法是指解題方案的准確而完整的描述,是一系列解決問題的清晰指令,算法代表着用系統的方法解決問題的策略
機制。也就是說,能夠對一定規范的輸入,在有限時間內獲得所要求的輸出。
大白話:
根據一定的條件,對一些數據進行計算,得到需要的結果。
1.4算法初體驗
在生活中,我們如果遇到某個問題,常常解決方案不是唯一的。
例如從西安到北京,如何去?會有不同的解決方案,我們可以坐飛機,可以坐火車,可以坐汽車,甚至可以步行,
不同的解決方案帶來的時間成本和金錢成本是不一樣的,比如坐飛機用的時間最少,但是費用最高,步行費用最
低,但時間最長。
再例如在北京二環內買一套四合院,如何付款?也會有不同的解決方案,可以一次性現金付清,也可以通過銀行做
按揭。這兩種解決方案帶來的成本也不一樣,一次性付清,雖然當時出的錢多,壓力大,但是沒有利息,按揭雖然
當時出的錢少,壓力比較小,但是會有利息,而且30年的總利息幾乎是貸款額度的一倍,需要多付錢。
在程序中,我們也可以用不同的算法解決相同的問題,而不同的算法的成本也是不相同的。總體上,一個優秀的算
法追求以下兩個目標:
1.花最少的時間完成需求;
2.占用最少的內存空間完成需求;
下面我們用一些實際案例體驗一些算法。
需求1:
計算1到100的和。
第一種解法:
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);
}
第二種解法:
public static void main(String[] args) {
int sum = 0;
int n=100;
sum = (n+1)*n/2;
System.out.println("sum="+sum);
}
第一種解法要完成需求,要完成以下幾個動作:
1.定義兩個整型變量;
2.執行100次加法運算;
3.打印結果到控制台;
第二種解法要完成需求,要完成以下幾個動作:
1.定義兩個整型變量;
2.執行1次加法運算,1次乘法運算,一次除法運算,總共3次運算;
3.打印結果到控制台;
很明顯,第二種算法完成需求,花費的時間更少一些。
需求2:
計算10的階乘
第一種解法:
public class Test {
public static void main(String[] args) {
//測試,計算10的階乘
long result = fun1(10);
System.out.println(result);
}
//計算n的階乘
public static long fun1(long n){
if (n==1){
return 1;
}
return n*fun1(n-1);
}
}
第二種解法:
public class Test {
public static void main(String[] args) {
//測試,計算10的階乘
long result = fun2(10);
System.out.println(result);
}
//計算n的階乘
public static long fun2(long n){
int result=1;
for (long i = 1; i <= n; i++) {
result*=i;
}
return result;
}
}
第一種解法,使用遞歸完成需求,fun1方法會執行10次,並且第一次執行未完畢,調用第二次執行,第二次執行
未完畢,調用第三次執行...最終,最多的時候,需要在棧內存同時開辟10塊內存分別執行10個fun1方法。
第二種解法,使用for循環完成需求,fun2方法只會執行一次,最終,只需要在棧內存開辟一塊內存執行fun2方法
即可。
很明顯,第二種算法完成需求,占用的內存空間更小。
一、算法分析
前面我們已經介紹了,研究算法的最終目的就是如何花更少的時間,如何占用更少的內存去完成相同的需求,並且
也通過案例演示了不同算法之間時間耗費和空間耗費上的差異,但我們並不能將時間占用和空間占用量化,因此,
接下來我們要學習有關算法時間耗費和算法空間耗費的描述和分析。有關算法時間耗費分析,我們稱之為算法的時
間復雜度分析,有關算法的空間耗費分析,我們稱之為算法的空間復雜度分析。
1.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.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:
1
那么上述算法,哪個效率更高呢?

通過觀察數據表格和折線圖,很容易可以得出結論:
算法函數中n最高次冪越小,算法效率越高
總上所述,在我們比較算法隨着輸入規模的增長量時,可以有以下規則:
1.算法函數中的常數可以忽略;
2.算法函數中最高次冪的常數因子可以忽略;
3.算法函數中最高次冪越小,算法效率越高。
1.1.2算法時間復雜度
1.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.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次,那總共程序想要從這三個循環中出來,就需要執行100100100次,也就是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)
下面是對常見時間復雜度的一個總結:

他們的復雜程度從低到高依次為:
O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(n^3)
根據前面的折線圖分析,我們會發現,從平方階開始,隨着輸入規模的增大,時間成本會急劇增大,所以,我們的
算法,盡可能的追求的是O(1),O(logn),O(n),O(nlogn)這幾種時間復雜度,而如果發現算法的時間復雜度為平方階、
立方階或者更復雜的,那我們可以分為這種算法是不可取的,需要優化。
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.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)
最壞情況是一種保證,在應用中,這是一種最基本的保障,即使在最壞情況下,也能夠正常提供服務,所以,除非
特別指定,我們提到的運行時間都指的是最壞情況下的運行時間。
1.2 算法的空間復雜度分析
計算機的軟硬件都經歷了一個比較漫長的演變史,作為為運算提供環境的內存,更是如此,從早些時候的512k,經
歷了1M,2M,4M...等,發展到現在的8G,甚至16G和32G,所以早期,算法在運行過程中對內存的占用情況也是
一個經常需要考慮的問題。我么可以用算法的空間復雜度來描述算法對內存的占用。
1.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個填充字節)再加上保存值所需的內存。
1.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開發的,基本上都是服務器開發,一般不存在這樣
的問題。
一、簡單排序
在我們的程序中,排序是非常常見的一種需求,提供一些數據元素,把這些數據元素按照一定的規則進行排序。比
如查詢一些訂單,按照訂單的日期進行排序;再比如查詢一些商品,按照商品的價格進行排序等等。所以,接下來
我們要學習一些常見的排序算法。
在java的開發工具包jdk中,已經給我們提供了很多數據結構與算法的實現,比如List,Set,Map,Math等等,都
是以API的方式提供,這種方式的好處在於一次編寫,多處使用。我們借鑒jdk的方式,也把算法封裝到某個類中,
那如果是這樣,在我們寫java代碼之前,就需要先進行API的設計,設計好之后,再對這些API進行實現。
就比如我們先設計一套API如下:

然后再使用java代碼去實現它。以后我們講任何數據結構與算法都是以這種方式講解
1.1 Comparable接口介紹
由於我們這里要講排序,所以肯定會在元素之間進行比較,而Java提供了一個接口Comparable就是用來定義排序
規則的,在這里我們以案例的形式對Comparable接口做一個簡單的回顧。
需求:
1.定義一個學生類Student,具有年齡age和姓名username兩個屬性,並通過Comparable接口提供比較規則;
2.定義測試類Test,在測試類Test中定義測試方法Comparable getMax(Comparable c1,Comparable c2)完成測試
package cn.itcast.algorithm.sort; //1.定義一個學生類Student,具有年齡age和姓名username兩個屬性,並通過Comparable接口提供比較規則; public class Student implements Comparable<Student>{ private String username; private int age; public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "Student{" + "username='" + username + '\'' + ", age=" + age + '}'; } @Override public int compareTo(Student o) { return this.getAge()-o.getAge(); } }
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Student; //2.定義測試類Test,在測試類Test中定義測試方法Comparable getMax(Comparable c1,Comparable c2)完成測試 public class TestComparable { public static void main(String[] args) { //創建兩個Student對象,並調用getMax方法,完成測試 Student s1 = new Student(); s1.setUsername("張三"); s1.setAge(18); Student s2 = new Student(); s2.setUsername("李四"); s2.setAge(20); Comparable max = getMax(s1, s2); System.out.println(max); } public static Comparable getMax(Comparable c1,Comparable c2){ int result = c1.compareTo(c2); //如果result<0,則c1比c2小; //如果result>0,則c1比c2大; //如果result==0,則c1和c2一樣大; if (result>=0){ return c1; }else{ return c2; } } }
1.2 冒泡排序
冒泡排序(Bubble Sort),是一種計算機科學領域的較簡單的排序算法。
需求:
排序前:{4,5,6,3,2,1}
排序后:{1,2,3,4,5,6}
排序原理:
1. 比較相鄰的元素。如果前一個元素比后一個元素大,就交換這兩個元素的位置。
2. 對每一對相鄰元素做同樣的工作,從開始第一對元素到結尾的最后一對元素。最終最后位置的元素就是最大值。

冒泡排序API設計:

冒泡排序的代碼實現:
package cn.itcast.algorithm.sort; public class Bubble { /* 對數組a中的元素進行排序 */ public static void sort(Comparable[] a){ for(int i=a.length-1;i>0;i--){ for(int j=0;j<i;j++){ //{6,5,4,3,2,1} //比較索引j和索引j+1處的值 if (greater(a[j],a[j+1])){ exch(a,j,j+1); } } } } /* 比較v元素是否大於w元素 */ private static boolean greater(Comparable v,Comparable w){ return v.compareTo(w)>0; } /* 數組元素i和j交換位置 */ private static void exch(Comparable[] a,int i,int j){ Comparable temp; temp = a[i]; a[i]=a[j]; a[j]=temp; } }
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Bubble; import java.util.Arrays; public class BubbleTest { public static void main(String[] args) { Integer[] arr = {4,5,6,3,2,1}; Bubble.sort(arr); System.out.println(Arrays.toString(arr));//{1,2,3,4,5,6} } }
我們分析冒泡排序的時間復雜度,主要分析一下內層循環體的執行次數即可。
在最壞情況下,也就是假如要排序的元素為{6,5,4,3,2,1}逆序,那么:
元素比較的次數為:
(N-1)+(N-2)+(N-3)+...+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
元素交換的次數為:
(N-1)+(N-2)+(N-3)+...+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
總執行次數為:
(N^2/2-N/2)+(N^2/2-N/2)=N^2-N;
按照大O推導法則,保留函數中的最高階項那么最終冒泡排序的時間復雜度為O(N^2).
1.3 選擇排序
選擇排序是一種更加簡單直觀的排序方法。
需求:
排序前:{4,6,8,7,9,2,10,1}
排序后:{1,2,4,5,7,8,9,10}
排序原理:
1.每一次遍歷的過程中,都假定第一個索引處的元素是最小值,和其他索引處的值依次進行比較,如果當前索引處
的值大於其他某個索引處的值,則假定其他某個索引出的值為最小值,最后可以找到最小值所在的索引
2.交換第一個索引處和最小值所在的索引處的值

選擇排序API設計:

選擇排序的代碼實現:
package cn.itcast.algorithm.sort; public class Selection { /* 對數組a中的元素進行排序 */ public static void sort(Comparable[] a){ for(int i=0;i<=a.length-2;i++){ //定義一個變量,記錄最小元素所在的索引,默認為參與選擇排序的第一個元素所在的位置 int minIndex = i; for(int j=i+1;j<a.length;j++){ //需要比較最小索引minIndex處的值和j索引處的值; if (greater(a[minIndex],a[j])){ minIndex=j; } } //交換最小元素所在索引minIndex處的值和索引i處的值 exch(a,i,minIndex); } } /* 比較v元素是否大於w元素 */ private static boolean greater(Comparable v,Comparable w){ return v.compareTo(w)>0; } /* 數組元素i和j交換位置 */ private static void exch(Comparable[] a,int i,int j){ Comparable temp; temp = a[i]; a[i]=a[j]; a[j]=temp; } }
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Selection; import java.util.Arrays; public class SelectionTest { public static void main(String[] args) { //原始數據 Integer[] a = {4,6,8,7,9,2,10,1}; Selection.sort(a); System.out.println(Arrays.toString(a));//{1,2,4,5,7,8,9,10} } }
選擇排序的時間復雜度分析:
選擇排序使用了雙層for循環,其中外層循環完成了數據交換,內層循環完成了數據比較,所以我們分別統計數據
交換次數和數據比較次數:
數據比較次數:
(N-1)+(N-2)+(N-3)+...+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
數據交換次數:
N-1
時間復雜度:N^2/2-N/2+(N-1)=N^2/2+N/2-1;
根據大O推導法則,保留最高階項,去除常數因子,時間復雜度為O(N^2);
1.4 插入排序
插入排序(Insertion sort)是一種簡單直觀且穩定的排序算法。
插入排序的工作方式非常像人們排序一手撲克牌一樣。開始時,我們的左手為空並且桌子上的牌面朝下。然后,我
們每次從桌子上拿走一張牌並將它插入左手中正確的位置。為了找到一張牌的正確位置,我們從右到左將它與已在
手中的每張牌進行比較,如下圖所示:

需求:
排序前:{4,3,2,10,12,1,5,6}
排序后:{1,2,3,4,5,6,10,12}
排序原理:
1.把所有的元素分為兩組,已經排序的和未排序的;
2.找到未排序的組中的第一個元素,向已經排序的組中進行插入;
3.倒敘遍歷已經排序的元素,依次和待插入的元素進行比較,直到找到一個元素小於等於待插入元素,那么就把待
插入元素放到這個位置,其他的元素向后移動一位;

插入排序API設計:

插入排序代碼實現:
package cn.itcast.algorithm.sort; public class Insertion { /* 對數組a中的元素進行排序 */ public static void sort(Comparable[] a){ for(int i=1;i<a.length;i++){ for(int j=i;j>0;j--){ //比較索引j處的值和索引j-1處的值,如果索引j-1處的值比索引j處的值大,則交換數據,如果不大,那么就找到合適的位置了,退出循環即可; if (greater(a[j-1],a[j])){ exch(a,j-1,j); }else{ break; } } } } /* 比較v元素是否大於w元素 */ private static boolean greater(Comparable v,Comparable w){ return v.compareTo(w)>0; } /* 數組元素i和j交換位置 */ private static void exch(Comparable[] a,int i,int j){ Comparable temp; temp = a[i]; a[i]=a[j]; a[j]=temp; } }
插入排序的時間復雜度分析
插入排序使用了雙層for循環,其中內層循環的循環體是真正完成排序的代碼,所以,我們分析插入排序的時間復
雜度,主要分析一下內層循環體的執行次數即可。
最壞情況,也就是待排序的數組元素為{12,10,6,5,4,3,2,1},那么:
比較的次數為:
(N-1)+(N-2)+(N-3)+...+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
交換的次數為:
(N-1)+(N-2)+(N-3)+...+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
總執行次數為:
(N^2/2-N/2)+(N^2/2-N/2)=N^2-N;
按照大O推導法則,保留函數中的最高階項那么最終插入排序的時間復雜度為O(N^2).
二、高級排序
之前我們學習過基礎排序,包括冒泡排序,選擇排序還有插入排序,並且對他們在最壞情況下的時間復雜度做了分
析,發現都是O(N^2),而平方階通過我們之前學習算法分析我們知道,隨着輸入規模的增大,時間成本將急劇上
升,所以這些基本排序方法不能處理更大規模的問題,接下來我們學習一些高級的排序算法,爭取降低算法的時間
復雜度最高階次冪。
2.1希爾排序
希爾排序是插入排序的一種,又稱“縮小增量排序”,是插入排序算法的一種更高效的改進版本。
前面學習插入排序的時候,我們會發現一個很不友好的事兒,如果已排序的分組元素為{2,5,7,9,10},未排序的分組
元素為{1,8},那么下一個待插入元素為1,我們需要拿着1從后往前,依次和10,9,7,5,2進行交換位置,才能完成真
正的插入,每次交換只能和相鄰的元素交換位置。那如果我們要提高效率,直觀的想法就是一次交換,能把1放到
更前面的位置,比如一次交換就能把1插到2和5之間,這樣一次交換1就向前走了5個位置,可以減少交換的次數,
這樣的需求如何實現呢?接下來我們來看看希爾排序的原理。
需求:
排序前:{9,1,2,5,7,4,8,6,3,5}
排序后:{1,2,3,4,5,5,6,7,8,9}
排序原理:
1.選定一個增長量h,按照增長量h作為數據分組的依據,對數據進行分組;
2.對分好組的每一組數據完成插入排序;
3.減小增長量,最小減為1,重復第二步操作。

增長量h的確定:增長量h的值每一固定的規則,我們這里采用以下規則:
int h=1 while(h<5){ h=2h+1;//3,7 } //循環結束后我們就可以確定h的最大值; h的減小規則為: h=h/2
希爾排序的API設計:

希爾排序的代碼實現:
package cn.itcast.algorithm.sort; public class Shell { /* 對數組a中的元素進行排序 */ public static void sort(Comparable[] a){ //1.根據數組a的長度,確定增長量h的初始值; int h = 1; while(h<a.length/2){ h=2*h+1; } //2.希爾排序 while(h>=1){ //排序 //2.1.找到待插入的元素 for (int i=h;i<a.length;i++){ //2.2把待插入的元素插入到有序數列中 for (int j=i;j>=h;j-=h){ //待插入的元素是a[j],比較a[j]和a[j-h] if (greater(a[j-h],a[j])){ //交換元素 exch(a,j-h,j); }else{ //待插入元素已經找到了合適的位置,結束循環; break; } } } //減小h的值 h= h/2; } } /* 比較v元素是否大於w元素 */ private static boolean greater(Comparable v,Comparable w){ return v.compareTo(w)>0; } /* 數組元素i和j交換位置 */ private static void exch(Comparable[] a,int i,int j){ Comparable temp; temp = a[i]; a[i]=a[j]; a[j]=temp; } }
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Shell; import java.util.Arrays; public class ShellTest { public static void main(String[] args) { Integer[] a = {9,1,2,5,7,4,8,6,3,5}; Shell.sort(a); System.out.println(Arrays.toString(a));//{1,2,3,4,5,5,6,7,8,9} } }
希爾排序的時間復雜度分析
在希爾排序中,增長量h並沒有固定的規則,有很多論文研究了各種不同的遞增序列,但都無法證明某個序列是最
好的,對於希爾排序的時間復雜度分析,已經超出了我們課程設計的范疇,所以在這里就不做分析了。
我們可以使用事后分析法對希爾排序和插入排序做性能比較。
在資料的測試數據文件夾下有一個reverse_shell_insertion.txt文件,里面存放的是從100000到1的逆向數據,我們
可以根據這個批量數據完成測試。測試的思想:在執行排序前前記錄一個時間,在排序完成后記錄一個時間,兩個
時間的時間差就是排序的耗時。
希爾排序和插入排序性能比較測試代碼:
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Insertion; import cn.itcast.algorithm.sort.Merge; import cn.itcast.algorithm.sort.Shell; import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.ArrayList; public class SortCompare { //調用不同的測試方法,完成測試 public static void main(String[] args) throws Exception{ //1.創建一個ArrayList集合,保存讀取出來的整數 ArrayList<Integer> list = new ArrayList<>(); //2.創建緩存讀取流BufferedReader,讀取數據,並存儲到ArrayList中; BufferedReader reader = new BufferedReader(new InputStreamReader(SortCompare.class.getClassLoader().getResourceAsStream("reverse_arr.txt"))); String line=null; while((line=reader.readLine())!=null){ //line是字符串,把line轉換成Integer,存儲到集合中 int i = Integer.parseInt(line); list.add(i); } reader.close(); //3.把ArrayList集合轉換成數組 Integer[] a = new Integer[list.size()]; list.toArray(a); //4.調用測試代碼完成測試 //testInsertion(a);//37499毫秒 testShell(a);//30毫秒 // testMerge(a);//70毫秒 } //測試希爾排序 public static void testShell(Integer[] a){ //1.獲取執行之前的時間 long start = System.currentTimeMillis(); //2.執行算法代碼 Shell.sort(a); //3.獲取執行之后的時間 long end = System.currentTimeMillis(); //4.算出程序執行的時間並輸出 System.out.println("希爾排序執行的時間為:"+(end-start)+"毫秒"); } //測試插入排序 public static void testInsertion(Integer[] a){ //1.獲取執行之前的時間 long start = System.currentTimeMillis(); //2.執行算法代碼 Insertion.sort(a); //3.獲取執行之后的時間 long end = System.currentTimeMillis(); //4.算出程序執行的時間並輸出 System.out.println("插入排序執行的時間為:"+(end-start)+"毫秒"); } //測試歸並排序 public static void testMerge(Integer[] a){ //1.獲取執行之前的時間 long start = System.currentTimeMillis(); //2.執行算法代碼 Merge.sort(a); //3.獲取執行之后的時間 long end = System.currentTimeMillis(); //4.算出程序執行的時間並輸出 System.out.println("歸並排序執行的時間為:"+(end-start)+"毫秒"); } }
通過測試發現,在處理大批量數據時,希爾排序的性能確實高於插入排序。
2.2 歸並排序
2.2.1 遞歸
正式學習歸並排序之前,我們得先學習一下遞歸算法。
定義:定義方法時,在方法內部調用方法本身,稱之為遞歸.
public void show(){ System.out.println("aaaa"); show(); }
作用:
它通常把一個大型復雜的問題,層層轉換為一個與原問題相似的,規模較小的問題來求解。遞歸策略只需要少量的
程序就可以描述出解題過程所需要的多次重復計算,大大地減少了程序的代碼量。
注意事項:
在遞歸中,不能無限制的調用自己,必須要有邊界條件,能夠讓遞歸結束,因為每一次遞歸調用都會在棧內存開辟
新的空間,重新執行方法,如果遞歸的層級太深,很容易造成棧內存溢出。

需求:
請定義一個方法,使用遞歸完成求N的階乘;
分析:
1!: 1
2!: 2*1=2*1!
3!: 3*2*1=3*2!
4!: 4*3*2*1=4*3!
...
n!: n*(n-1)*(n-2)...*2*1=n*(n-1)!
所以,假設有一個方法factorial(n)用來求n的階乘,那么n的階乘還可以表示為n*factorial(n-1)
代碼實現:
public class Test {
public static void main(String[] args) throws Exception {
int result = factorial(5);
System.out.println(result);
}
public static int factorial(int n){
if (n==1){
return 1;
}
return n*factorial(n-1);
}
}
2.2.2 歸並排序
歸並排序是建立在歸並操作上的一種有效的排序算法,該算法是采用分治法的一個非常典型的應用。將已有序的子
序列合並,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合並成一個有序
表,稱為二路歸並。
需求:
排序前:{8,4,5,7,1,3,6,2}
排序后:{1,2,3,4,5,6,7,8}
排序原理:
1.盡可能的一組數據拆分成兩個元素相等的子組,並對每一個子組繼續拆分,直到拆分后的每個子組的元素個數是
1為止。
2.將相鄰的兩個子組進行合並成一個有序的大組;
3.不斷的重復步驟2,直到最終只有一個組為止。

歸並排序API設計:

歸並原理:

歸並排序代碼實現:
package cn.itcast.algorithm.sort; public class Merge { //歸並所需要的輔助數組 private static Comparable[] assist; /* 比較v元素是否小於w元素 */ private static boolean less(Comparable v, Comparable w) { return v.compareTo(w)<0; } /* 數組元素i和j交換位置 */ private static void exch(Comparable[] a, int i, int j) { Comparable t = a[i]; a[i] = a[j]; a[j] = t; } /* 對數組a中的元素進行排序 */ public static void sort(Comparable[] a) { //1.初始化輔助數組assist; assist = new Comparable[a.length]; //2.定義一個lo變量,和hi變量,分別記錄數組中最小的索引和最大的索引; int lo=0; int hi=a.length-1; //3.調用sort重載方法完成數組a中,從索引lo到索引hi的元素的排序 sort(a,lo,hi); } /* 對數組a中從lo到hi的元素進行排序 */ private static void sort(Comparable[] a, int lo, int hi) { //做安全性校驗; if (hi<=lo){ return; } //對lo到hi之間的數據進行分為兩個組 int mid = lo+(hi-lo)/2;// 5,9 mid=7 //分別對每一組數據進行排序 sort(a,lo,mid); sort(a,mid+1,hi); //再把兩個組中的數據進行歸並 merge(a,lo,mid,hi); } /* 對數組中,從lo到mid為一組,從mid+1到hi為一組,對這兩組數據進行歸並 */ private static void merge(Comparable[] a, int lo, int mid, int hi) { //定義三個指針 int i=lo; int p1=lo; int p2=mid+1; //遍歷,移動p1指針和p2指針,比較對應索引處的值,找出小的那個,放到輔助數組的對應索引處 while(p1<=mid && p2<=hi){ //比較對應索引處的值 if (less(a[p1],a[p2])){ assist[i++] = a[p1++]; }else{ assist[i++]=a[p2++]; } } //遍歷,如果p1的指針沒有走完,那么順序移動p1指針,把對應的元素放到輔助數組的對應索引處 while(p1<=mid){ assist[i++]=a[p1++]; } //遍歷,如果p2的指針沒有走完,那么順序移動p2指針,把對應的元素放到輔助數組的對應索引處 while(p2<=hi){ assist[i++]=a[p2++]; } //把輔助數組中的元素拷貝到原數組中 for(int index=lo;index<=hi;index++){ a[index]=assist[index]; } } }
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Merge; import java.util.Arrays; public class MergeTest { public static void main(String[] args) { Integer[] a = {8,4,5,7,1,3,6,2}; Merge.sort(a); System.out.println(Arrays.toString(a));//{1,2,3,4,5,6,7,8} } }
歸並排序時間復雜度分析:
歸並排序是分治思想的最典型的例子,上面的算法中,對a[lo...hi]進行排序,先將它分為a[lo...mid]和a[mid+1...hi]
兩部分,分別通過遞歸調用將他們單獨排序,最后將有序的子數組歸並為最終的排序結果。該遞歸的出口在於如果
一個數組不能再被分為兩個子數組,那么就會執行merge進行歸並,在歸並的時候判斷元素的大小進行排序。

用樹狀圖來描述歸並,如果一個數組有8個元素,那么它將每次除以2找最小的子數組,共拆log8次,值為3,所以
樹共有3層,那么自頂向下第k層有2^k個子數組,每個數組的長度為2^(3-k),歸並最多需要2^(3-k)次比較。因此每層
的比較次數為 2^k * 2^(3-k)=2^3,那么3層總共為 3*2^3。
假設元素的個數為n,那么使用歸並排序拆分的次數為log2(n),所以共log2(n)層,那么使用log2(n)替換上面3*2^3中
的3這個層數,最終得出的歸並排序的時間復雜度為:log2(n)* 2^(log2(n))=log2(n)*n,根據大O推導法則,忽略底
數,最終歸並排序的時間復雜度為O(nlogn);
歸並排序的缺點:
需要申請額外的數組空間,導致空間復雜度提升,是典型的以空間換時間的操作。
歸並排序與希爾排序性能測試:
之前我們通過測試可以知道希爾排序的性能是由於插入排序的,那現在學習了歸並排序后,歸並排序的效率與希爾
排序的效率哪個高呢?我們使用同樣的測試方式來完成一樣這兩個排序算法之間的性能比較。
在資料的測試數據文件夾下有一個reverse_arr.txt文件,里面存放的是從1000000到1的逆向數據,我們可以根據
這個批量數據完成測試。測試的思想:在執行排序前前記錄一個時間,在排序完成后記錄一個時間,兩個時間的時
間差就是排序的耗時。
希爾排序和插入排序性能比較測試代碼:
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Insertion; import cn.itcast.algorithm.sort.Merge; import cn.itcast.algorithm.sort.Shell; import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.ArrayList; public class SortCompare { //調用不同的測試方法,完成測試 public static void main(String[] args) throws Exception{ //1.創建一個ArrayList集合,保存讀取出來的整數 ArrayList<Integer> list = new ArrayList<>(); //2.創建緩存讀取流BufferedReader,讀取數據,並存儲到ArrayList中; BufferedReader reader = new BufferedReader(new InputStreamReader(SortCompare.class.getClassLoader().getResourceAsStream("reverse_arr.txt"))); String line=null; while((line=reader.readLine())!=null){ //line是字符串,把line轉換成Integer,存儲到集合中 int i = Integer.parseInt(line); list.add(i); } reader.close(); //3.把ArrayList集合轉換成數組 Integer[] a = new Integer[list.size()]; list.toArray(a); //4.調用測試代碼完成測試 //testInsertion(a);//37499毫秒 testShell(a);//30毫秒 // testMerge(a);//70毫秒 } //測試希爾排序 public static void testShell(Integer[] a){ //1.獲取執行之前的時間 long start = System.currentTimeMillis(); //2.執行算法代碼 Shell.sort(a); //3.獲取執行之后的時間 long end = System.currentTimeMillis(); //4.算出程序執行的時間並輸出 System.out.println("希爾排序執行的時間為:"+(end-start)+"毫秒"); } //測試插入排序 public static void testInsertion(Integer[] a){ //1.獲取執行之前的時間 long start = System.currentTimeMillis(); //2.執行算法代碼 Insertion.sort(a); //3.獲取執行之后的時間 long end = System.currentTimeMillis(); //4.算出程序執行的時間並輸出 System.out.println("插入排序執行的時間為:"+(end-start)+"毫秒"); } //測試歸並排序 public static void testMerge(Integer[] a){ //1.獲取執行之前的時間 long start = System.currentTimeMillis(); //2.執行算法代碼 Merge.sort(a); //3.獲取執行之后的時間 long end = System.currentTimeMillis(); //4.算出程序執行的時間並輸出 System.out.println("歸並排序執行的時間為:"+(end-start)+"毫秒"); } }
通過測試,發現希爾排序和歸並排序在處理大批量數據時差別不是很大。
2.3 快速排序
快速排序是對冒泡排序的一種改進。它的基本思想是:通過一趟排序將要排序的數據分割成獨立的兩部分,其中一
部分的所有數據都比另外一部分的所有數據都要小,然后再按此方法對這兩部分數據分別進行快速排序,整個排序
過程可以遞歸進行,以此達到整個數據變成有序序列。
需求:
排序前:{6, 1, 2, 7, 9, 3, 4, 5, 8}
排序后:{1, 2, 3, 4, 5, 6, 7, 8, 9}
排序原理:
1.首先設定一個分界值,通過該分界值將數組分成左右兩部分;
2.將大於或等於分界值的數據放到到數組右邊,小於分界值的數據放到數組的左邊。此時左邊部分中各元素都小於
或等於分界值,而右邊部分中各元素都大於或等於分界值;
3.然后,左邊和右邊的數據可以獨立排序。對於左側的數組數據,又可以取一個分界值,將該部分數據分成左右兩
部分,同樣在左邊放置較小值,右邊放置較大值。右側的數組數據也可以做類似處理。
4.重復上述過程,可以看出,這是一個遞歸定義。通過遞歸將左側部分排好序后,再遞歸排好右側部分的順序。當
左側和右側兩個部分的數據排完序后,整個數組的排序也就完成了。

快速排序API設計:

切分原理:
把一個數組切分成兩個子數組的基本思想:
1.找一個基准值,用兩個指針分別指向數組的頭部和尾部;
2.先從尾部向頭部開始搜索一個比基准值小的元素,搜索到即停止,並記錄指針的位置;
3.再從頭部向尾部開始搜索一個比基准值大的元素,搜索到即停止,並記錄指針的位置;
4.交換當前左邊指針位置和右邊指針位置的元素;
5.重復2,3,4步驟,直到左邊指針的值大於右邊指針的值停止。

快速排序代碼實現:
package cn.itcast.algorithm.sort; public class Quick { /* 比較v元素是否小於w元素 */ private static boolean less(Comparable v, Comparable w) { return v.compareTo(w) < 0; } /* 數組元素i和j交換位置 */ private static void exch(Comparable[] a, int i, int j) { Comparable t = a[i]; a[i] = a[j]; a[j] = t; } //對數組內的元素進行排序 public static void sort(Comparable[] a) { int lo = 0; int hi = a.length-1; sort(a,lo,hi); } //對數組a中從索引lo到索引hi之間的元素進行排序 private static void sort(Comparable[] a, int lo, int hi) { //安全性校驗 if (hi<=lo){ return; } //需要對數組中lo索引到hi索引處的元素進行分組(左子組和右子組); int partition = partition(a, lo, hi);//返回的是分組的分界值所在的索引,分界值位置變換后的索引 //讓左子組有序 sort(a,lo,partition-1); //讓右子組有序 sort(a,partition+1,hi); } //對數組a中,從索引 lo到索引 hi之間的元素進行分組,並返回分組界限對應的索引 public static int partition(Comparable[] a, int lo, int hi) { //確定分界值 Comparable key = a[lo]; //定義兩個指針,分別指向待切分元素的最小索引處和最大索引處的下一個位置 int left=lo; int right=hi+1; //切分 while(true){ //先從右往左掃描,移動right指針,找到一個比分界值小的元素,停止 while(less(key,a[--right])){ if (right==lo){ break; } } //再從左往右掃描,移動left指針,找到一個比分界值大的元素,停止 while(less(a[++left],key)){ if (left==hi){ break; } } //判斷 left>=right,如果是,則證明元素掃描完畢,結束循環,如果不是,則交換元素即可 if (left>=right){ break; }else{ exch(a,left,right); } } //交換分界值 exch(a,lo,right); return right; } }
package cn.itcast.algorithm.test; import cn.itcast.algorithm.sort.Quick; import java.util.Arrays; public class QuickTest { public static void main(String[] args) { Integer[] a= {6, 1, 2, 7, 9, 3, 4, 5, 8}; Quick.sort(a); System.out.println(Arrays.toString(a));//{1, 2, 3, 4, 5, 6, 7, 8, 9} } }
快速排序和歸並排序的區別:
快速排序是另外一種分治的排序算法,它將一個數組分成兩個子數組,將兩部分獨立的排序。快速排序和歸並排序
是互補的:歸並排序將數組分成兩個子數組分別排序,並將有序的子數組歸並從而將整個數組排序,而快速排序的
方式則是當兩個數組都有序時,整個數組自然就有序了。在歸並排序中,一個數組被等分為兩半,歸並調用發生在
處理整個數組之前,在快速排序中,切分數組的位置取決於數組的內容,遞歸調用發生在處理整個數組之后。
快速排序時間復雜度分析:
快速排序的一次切分從兩頭開始交替搜索,直到left和right重合,因此,一次切分算法的時間復雜度為O(n),但整個
快速排序的時間復雜度和切分的次數相關。
最優情況:每一次切分選擇的基准數字剛好將當前序列等分。

如果我們把數組的切分看做是一個樹,那么上圖就是它的最優情況的圖示,共切分了logn次,所以,最優情況下快
速排序的時間復雜度為O(nlogn);
最壞情況:每一次切分選擇的基准數字是當前序列中最大數或者最小數,這使得每次切分都會有一個子組,那么總
共就得切分n次,所以,最壞情況下,快速排序的時間復雜度為O(n^2);

平均情況:每一次切分選擇的基准數字不是最大值和最小值,也不是中值,這種情況我們也可以用數學歸納法證
明,快速排序的時間復雜度為O(nlogn),由於數學歸納法有很多數學相關的知識,容易使我們混亂,所以這里就不對
平均情況的時間復雜度做證明了。
2.4 排序的穩定性
穩定性的定義:
數組arr中有若干元素,其中A元素和B元素相等,並且A元素在B元素前面,如果使用某種排序算法排序后,能夠保
證A元素依然在B元素的前面,可以說這個該算法是穩定的。

穩定性的意義:
如果一組數據只需要一次排序,則穩定性一般是沒有意義的,如果一組數據需要多次排序,穩定性是有意義的。例
如要排序的內容是一組商品對象,第一次排序按照價格由低到高排序,第二次排序按照銷量由高到低排序,如果第
二次排序使用穩定性算法,就可以使得相同銷量的對象依舊保持着價格高低的順序展現,只有銷量不同的對象才需
要重新排序。這樣既可以保持第一次排序的原有意義,而且可以減少系統開銷。
第一次按照價格從低到高排序:

第二次按照銷量進行從高到低排序:

常見排序算法的穩定性:
冒泡排序:
只有當arr[i]>arr[i+1]的時候,才會交換元素的位置,而相等的時候並不交換位置,所以冒泡排序是一種穩定排序
算法。
選擇排序:
選擇排序是給每個位置選擇當前元素最小的,例如有數據{5(1),8 ,5(2), 2, 9 },第一遍選擇到的最小元素為2,
所以5(1)會和2進行交換位置,此時5(1)到了5(2)后面,破壞了穩定性,所以選擇排序是一種不穩定的排序算法。
插入排序:
比較是從有序序列的末尾開始,也就是想要插入的元素和已經有序的最大者開始比起,如果比它大則直接插入在其
后面,否則一直往前找直到找到它該插入的位置。如果碰見一個和插入元素相等的,那么把要插入的元素放在相等
元素的后面。所以,相等元素的前后順序沒有改變,從原無序序列出去的順序就是排好序后的順序,所以插入排序
是穩定的。
希爾排序:
希爾排序是按照不同步長對元素進行插入排序 ,雖然一次插入排序是穩定的,不會改變相同元素的相對順序,但在
不同的插入排序過程中,相同的元素可能在各自的插入排序中移動,最后其穩定性就會被打亂,所以希爾排序是不
穩定的。
歸並排序:
歸並排序在歸並的過程中,只有arr[i]<arr[i+1]的時候才會交換位置,如果兩個元素相等則不會交換位置,所以它
並不會破壞穩定性,歸並排序是穩定的。
快速排序:
快速排序需要一個基准值,在基准值的右側找一個比基准值小的元素,在基准值的左側找一個比基准值大的元素,
然后交換這兩個元素,此時會破壞穩定性,所以快速排序是一種不穩定的算法。