data structure

wisdom1a發表於2020-12-26

1 資料結構和演算法概述

1.1 什麼是資料結構

官方:資料結構是一門研究非數值計算的程式設計問題中的操作物件,以及他們之間的關係和操作等相關問題的學科。
白話:資料結構就是把資料元素按照一定的關係組織起來的集合,用來組織和儲存資料。

1.2 資料結構分類

傳統上:邏輯結構、物理結構

1.2.1 邏輯結構分類

邏輯結構是從具體問題中抽象出來的模型,是抽象意義上的結構,按照物件中資料元素之間的相互關係分類。
1、集合結構:集合結構中資料元素除了屬於同一個集合外,他們之間沒有任何其他的關係。
2、線性結構:線性結構中的資料元素之間存在一對一的關係。
3、樹形結構:樹形結構中的資料元素之間存在一對多的層次關係。
4、圖形結構:圖形結構的資料元素是多對多的關係。

1.2.3 物理結構分類

邏輯結構在計算機中真正的表示方式(又稱為映像)稱為物理結構,也可以叫做儲存結構。常見的物理結構有順序儲存結構、鏈式儲存結構。
1、順序儲存結構
把資料元素放到地址連續的儲存單元裡面,其資料間的邏輯關係和物理關係是一致的,比如陣列就是順序儲存結構。
2、鏈式儲存結構
是把資料元素存放在任意的儲存單元裡面,這組儲存單元可以是連續的也可以是不連續的。此時,資料元素之間並不能反應元素間的邏輯關係,因此在鏈式儲存結構中引進了一個指標存放資料元素的地址,這樣通過地址就可以找到相關聯資料元素的位置。

1.3 什麼是演算法

官方:演算法是指解題方案的準確而完整的描述,是一系列解決問題的清晰指令,演算法代表著用系統的方法解決問題的策略機制。也就是說,能夠對一定規範的輸入,在有限時間內獲得所要求的輸出。
優秀的演算法追求以下兩個目標:(1)用最少時間完成需求。(2)佔用最少的記憶體空間完成需求。

1.3.1 計算1-10的和

方法一:

package com.jack;

public class TestDataStruction {

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

方法二:

package com.jack;

public class TestDataStruction {

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

方法1完成需求,需要以下動作:
1、定於兩個整型變數
2、執行100次加法運算
3、列印結果到控制檯
方法二完成需求,需要以下動作
1、定義兩個整型變數
2、執行1次加法運算,1次乘法運算,1次除法運算,總共3次運算
3、列印結果到控制檯
很明顯,方法二演算法完成需求,花費的時間更少一些。

1.3.2 計算10的階乘

方法一:

package com.jack;

public class TestDataStruction {

	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;
		} else {
			return n*fun1(n-1);
		}
	}
}

方法二:

package com.jack;

public class TestDataStruction {

	public static void main(String[] args) {
		//計算10的階乘
		long result = fun2(10);
		
		System.out.println(result);
		
	}
    
    //計算n的階乘	
	public static long fun2(long n) {
		long result = 1;
		for(long i=1; i<=n; i++) {
			result *= i;
		}
		return result;
	}
}

方法一:使用遞迴完成需求,fun1方法會執行10次,並且第一次執行未完畢,呼叫第二次執行,第二次執行未完畢,呼叫第三次執行…最終,最多的時候,需要在棧記憶體同時開闢10塊記憶體分別執行10個fun1方法。
方法二:使用for迴圈完成需求,fun2方法只會執行一次,最終,只需要在棧記憶體開闢一塊記憶體執行fun2方法即可。
很明顯,第二種演算法完成需求,佔用的記憶體空間更小。

2 演算法分析

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

2.1 演算法的時間複雜度分析

2.1.1 度量演算法的時間複雜度

1、事後分析估算方法:
這種統計方法主要是通過設計好的測試程式和測試資料,利用計算機計時器對不同的演算法編制的程式的執行時間進行比較,從而確定演算法效率的高低,但是這種方法有很大的缺陷:必須依據演算法實現編制好的測試程式,通常要花費大量時間和精力,測試完了如果發現測試的是非常糟糕的演算法,那麼之前所做的事情就全部白費了,並且不同的測試環境(硬體環境)的差別導致測試的結果差異也很大。
在這裡插入圖片描述
2、事前分析估算方法:
在計算機程式編寫前,依據統計方法對演算法進行估算,經過總結,我們發現一個高階語言編寫的程式在計算機上執行所消耗的時間取決於下列因素:
(1)演算法採用的策略和方案
(2)問題的輸入規模(所謂的問題輸入規模就是輸入量的多少)
(3)編譯產生的程式碼質量
(4)機器執行指令的速度
由此可見,拋開這些與計算機硬體、軟體有關的因素,一個程式的執行時間依賴於演算法的好壞和問題的輸入規模。
如果演算法固定,那麼該演算法的執行時間就只和問題的輸入規模有關係了。

2.1.2 執行次數分析

需求:計算1到100的和
方法1:
在這裡插入圖片描述
方法二:
在這裡插入圖片描述
因此,當輸入規模為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的結果
在這裡插入圖片描述
上面這個例子中,如果我們要精確的研究迴圈的條件執行了多少次,是一件很麻煩的事情,並且,由於真正計算和的程式碼是內迴圈的迴圈體,所以,在研究演算法的效率時,我們只考慮核心程式碼的執行次數,這樣可以簡化分析。

我們研究演算法複雜度,側重的是當輸入規模不斷增大時,演算法的增長量的一個抽象(規律),而不是精確地定位需要執行多少次,因為如果是這樣的話,我們又得考慮回編譯期優化等問題,容易主次顛倒。

我們不關心編寫程式所用的語言是什麼,也不關心這些程式將跑在什麼樣的計算機上,我們只關心它所實現的演算法。這樣,不計那些迴圈索引的遞增和迴圈終止的條件、變數宣告、列印結果等操作,最終在分析程式的執行時間時,最重要的是把程式看做是獨立於程式設計語言的演算法或一系列步驟。我們分析一個演算法的執行時間,最重要的就是把核心操作的次數和輸入規模關聯起來。

2.2 函式的漸近增長

給定兩個函式f(n)和g(n),如果存在一個整數N,使得對於所有的n>N,f(n)總是比g(n)大,那麼我們說f(n)的增長漸近快於g(n)。

根據資料得出的結論:
1、隨著輸入規模的增大,演算法的常數操作可以忽略不計
2、隨著輸入規模的增大,與最高次項相乘的常數可以忽略
3、最高次項的指數大的,隨著n的增長,結果也會變得增長特別快
4、演算法函式中n最高次冪越小,演算法效率越高

總結:
1.演算法函式中的常數可以忽略;
2.演算法函式中最高次冪的常數因子可以忽略;
3.演算法函式中最高次冪越小,演算法效率越高。

2.3 演算法時間複雜度

定義:在進行演算法分析時,語句總的執行次數T(n)是關於問題規模n的函式,進而分析T(n)隨著n的變化情況並確定T(n)的量級。演算法的時間複雜度,就是演算法的時間量度,記作:T(n)=O(f(n))。它表示隨著問題規模n的增大,演算法執行時間的增長率和f(n)的增長率相同,稱作演算法的漸近時間複雜度,簡稱時間複雜度,其中f(n)是問題規模n的某個函式。
在這裡,我們需要明確一個事情:執行次數=執行時間

2.3.1 大O記法

用大寫O()來體現演算法時間複雜度的記法,我們稱之為大O記法。一般情況下,隨著輸入規模n的增大,T(n)增長最慢的演算法為最優演算法。
下面我們使用大O表示法來表示求和演算法的時間複雜度
演算法一:
在這裡插入圖片描述
演算法二:
在這裡插入圖片描述

演算法三:
在這裡插入圖片描述
如果忽略判斷條件的執行次數和輸出語句的執行次數,那麼當輸入規模為n時,以上演算法執行的次數分別為:
演算法一:3次
演算法二:n+3次
演算法三:n^2+2次
如果用大O記法表示上述每個演算法的時間複雜度,應該如何表示呢?基於我們對函式漸近增長的分析,推導大O階的表示法有以下幾個規則可以使用:
1、用常數1取代執行時間中的所有加法常數;
2、在修改後的執行次數中,只保留高階項;
3、如果最高階項存在,且常數因子不為1,則去除與這個項相乘的常數;
所以,上述演算法的大O記法分別為:
演算法一:O(1)
演算法二:O(n)
演算法三:O(n^2)

2.3.4 常見的大O階

2.3.4.1 線性階

一般含有非巢狀迴圈涉及線性階,線性階就是隨著輸入規模的擴大,對應計算次數呈直線增長
在這裡插入圖片描述
上面這段程式碼,它的迴圈的時間複雜度為O(n),因為迴圈體中的程式碼需要執行n次

2.3.4.2 平方階

一般巢狀迴圈屬於這種時間複雜度
在這裡插入圖片描述
上面這段程式碼,n=100,也就是說,外層迴圈每執行一次,內層迴圈就執行100次,那總共程式想要從這兩個迴圈中出來,就需要執行100*100次,也就是n的平方次,所以這段程式碼的時間複雜度是O(n^2).

2.3.4.3 立方階

一般三層巢狀迴圈屬於這種時間複雜度
在這裡插入圖片描述
上面這段程式碼,n=100,也就是說,外層迴圈每執行一次,中間迴圈就執行100次,中間迴圈每執行一次,最內層迴圈需要執行100次,那總共程式想要從這三個迴圈中出來,就需要執行100100100次,也就是n的立方,所以這段程式碼的時間複雜度是O(n^3).

2.3.4.3 對數階

在這裡插入圖片描述
由於每次i*2之後,就距離n更近一步,假設有x個2相乘後大於n,則會退出迴圈。由於是2^x=n,得到x=log(2)n,所以這個迴圈的時間複雜度為O(logn);
對於對數階,由於隨著輸入規模n的增大,不管底數為多少,他們的增長趨勢是一樣的,所以我們會忽略底數。

2.3.4.4 常數階

一般不涉及迴圈操作的都是常數階,因為它不會隨著n的增長而增加操作次數
在這裡插入圖片描述
上述程式碼,不管輸入規模n是多少,都執行2次,根據大O推導法則,常數用1來替換,所以上述程式碼的時間複雜度為O(1)
在這裡插入圖片描述
他們的複雜程度從低到高依次為:
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)
從平方階開始,隨著輸入規模的增大,時間成本會急劇增大,所以,我們的演算法,儘可能的追求的是O(1),O(logn),O(n),O(nlogn)這幾種時間複雜度,而如果發現演算法的時間複雜度為平方階、立方階或者更復雜的,那我們可以認為這種演算法是不可取的,需要優化。

2.4 函式呼叫的時間複雜度分析

之前,我們分析的都是單個函式內演算法程式碼的時間複雜度,接下來我們分析函式呼叫過程中時間複雜度。

2.4.1 案例一

package com.jack;

public class TestDataStruction {

	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)

2.4.2 案例二

package com.jack;

public class TestDataStruction {

	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)

2.4.3 案例三

package com.jack;

public class TestDataStruction {

	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+n2+n2=2n^2+n。
根據大O推導規則,去掉n保留最高階項,並去掉最高階項的常數因子2,所以最終main方法的時間複雜度為O(n^2)

2.5 最壞情況

有一個儲存了n個隨機數字的陣列,請從中查詢出指定的數字。
在這裡插入圖片描述
最好情況:查詢的第一個數字就是期望的數字,那麼演算法的時間複雜度為O(1)
最壞情況:查詢的最後一個數字,才是期望的數字,那麼演算法的時間複雜度為O(n)
平均情況:任何數字查詢的平均成本是O(n/2)
最壞情況是一種保證,在應用中,這是一種最基本的保障,即使在最壞情況下,也能夠正常提供服務,所以,除非
特別指定,我們提到的執行時間都指的是最壞情況下的執行時間。

2.6 演算法的空間複雜度分析

用演算法的空間複雜度來描述演算法對記憶體的佔用

2.6.1 Java中常見記憶體佔用

1、基本資料型別記憶體佔用情況
資料型別 記憶體佔用位元組數
byte 1
short 2
int 4
long 8
float 4
double 8
boolean 1
char 2
2、計算機訪問記憶體的方式都是一次一個位元組。
3、 一個引用(機器地址)需要8個位元組表示:例如: Date date = new Date(),則date這個變數需要佔用8個位元組來表示
4、建立一個物件,比如new Date(),除了Date物件內部儲存的資料(例如年月日等資訊)佔用的記憶體,該物件本身也有記憶體開銷,每個物件的自身開銷是16個位元組,用來儲存物件的頭資訊。
5.一般記憶體的使用,如果不夠8個位元組,都會被自動填充為8位元組:
public class A {
public int a = 1;
}
通過newA()建立一個物件的記憶體佔用如下:
(1)整型成員變數a佔用4個位元組
(2)物件本身佔用16個位元組。
那麼建立該物件總共需要20個位元組,但由於不是以8位單位,會自動填充為24個位元組。
6、java 中陣列被限定為物件,他們一般都會因為記錄長度而需要額外的記憶體,一個原始資料型別的陣列一般需要24位元組的頭資訊(16個自己的物件開銷,4位元組用於儲存長度以及4個填充位元組)再加上儲存值所需的記憶體。

2.6.2 演算法的空間複雜度

演算法的空間複雜度計算公式記作:S(n)=O(f(n)),其中n為輸入規模,f(n)為語句關於n所佔儲存空間的函式。
案例:對指定的陣列元素進行反轉,並返回反轉的內容。
方法一:

package com.jack;

public class TestDataStruction {

	public static void main(String[] args) {
		
		
	}
	
	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;
	}
}

方法二:

package com.jack;

public class TestDataStruction {

	public static void main(String[] args) {
		
		
	}
	
	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排序
4線性表
5符號表
6樹
7堆
8優先佇列
9並查集
10圖

相關文章