探索資料結構:解鎖計算世界的密碼

Betty’sSweet發表於2024-03-02


✨✨ 歡迎大家來到貝蒂大講堂✨✨

🎈🎈養成好習慣,先贊後看哦~🎈🎈

所屬專欄:資料結構與演算法
貝蒂的主頁:Betty‘s blog

前言

隨著應用程式變得越來越複雜和資料越來越豐富,幾百萬、幾十億甚至幾百億的資料就會出現,而對這麼大對資料進行搜尋、插入或者排序等的操作就越來越慢,人們為了解決這些問題,提高對資料的管理效率,提出了一門學科即:資料結構與演算法

1. 什麼是資料結構

資料結構(Data Structure)是計算機儲存、組織資料的方式,指相互之間存在一種或多種特定關係的資料元素的集合。

下標是常見的資料結構:

名稱 定義
陣列(Array) 陣列是一種聚合資料型別,它是將具有相同型別的若干變數有序地組織在一起的集合。
連結串列(Linked List) 連結串列是一種資料元素按照鏈式儲存結構進行儲存的資料結構,這種儲存結構具有在物理上存在非連續的特點。
棧(Stack) 棧是一種特殊的線性表,它只能在一個表的一個固定端進行資料結點的插入和刪除操作
佇列(Queue) 佇列和棧類似,也是一種特殊的線性表。和棧不同的是,佇列只允許在表的一端進行插入操作,而在另一端進行刪除操作。
樹(Tree) 樹是典型的非線性結構,它是包括,2 個結點的有窮集合 K
堆(Heap) 堆是一種特殊的樹形資料結構,一般討論的堆都是二叉堆。
圖(Graph) 圖是另一種非線性資料結構。在圖結構中,資料結點一般稱為頂點,而邊是頂點的有序偶對

2. 什麼是演算法

演算法(Algorithm):就是定義良好的計算過程,他取一個或一組的值為輸入,併產生出一個或一組值作為輸出。簡單來說演算法就是一系列的計算步驟,用來將輸入資料轉化成輸出結果。

演算法一般分為:排序,遞迴與分治,回溯,DP,貪心,搜尋演算法

  • 演算法往往數學密切相關,就如數學題一樣,每道數學題都有不同的解法,演算法也是同理。

3. 複雜度分析

3.1 演算法評估

我們在進行演算法分析時,常常需要完成兩個目標。一個是找出問題的解決方法,另一個就是找到問題的最優解。而為了找出最優解,我們就需要從兩個維度分析:

  • 時間效率:演算法執行的快慢
  • 空間效率:演算法所佔空間的大小

3.2 評估方法

評估時間的方法主要分為兩種,一種是實驗分析法,一種是理論分析法

(1) 實驗分析法

實驗分析法簡單來說就是將不同種演算法輸入同一臺電腦,統計時間的快慢。但是這種方法有兩大缺陷:

  1. 無法排查實驗自身條件與環境的條件的影響:比如同一種演算法在不同配置的電腦上的運算速度可能完全不同,甚至結果完全相反。我們很難排查所有情況。
  2. 成本太高:同一種演算法可能在資料少時表現不明顯,在資料多時速率較快

(2) 理論分析法

由於實驗分析法的侷限性,就有人提出了一種理論測評的方法,就是漸近複雜度分析(asymptotic complexity analysis),簡稱複雜度分析

這種方法體現演算法執行所需的時間(空間)資源與輸入資料大小之間的關係,能有效的反應演算法的優劣。

4. 時間複雜度與空間複雜度

4.1 時間複雜度

一個演算法所花費的時間與其中語句的執行次數成正比例,演算法中的基本操作的執行次數,為演算法的時間複雜度。

為了準確的表述一段代表所需時間,我們先假設賦值(=)與加號(+)所需時間為1ns,乘號(×)所需時間為2ns,列印所需為3ns。

讓我們計算如下程式碼所需總時間:

int main()
{
	int i = 1;//1ns
	int n = 0;//1ns
	scanf("%d", &n);
	for (i = 0; i < n; i++)
	{
		printf("%d ", i);//3ns
	}
	return 0;
}

計算時間如下:
$$
T(n)=1+1+3×n=3n+2
$$

但是實際上統計每一項所需時間是不現實的,並且由於是理論分析,當n—>∞時,其餘項皆可忽略,T(n)的數量級由最高階決定。所以我們計算時間複雜度時,可以簡化為兩個步驟:

  1. 忽略除最高階以外的所有項。
  2. 忽略所有係數。

而上述程式碼時間可以記為O(n),這種方法被稱為大O的漸進表示法。如果計算機結果全是常數,則記為O(1)。

  • 並且計算複雜度時,有時候會出現不同情況的結果,這是應該以最壞的結果考慮。

4.2 空間複雜度

空間複雜度也是一個數學表示式,是對一個演算法在執行過程中臨時佔用儲存空間大小的量度 。空間複雜度的表示也遵循大O的漸進表示法

讓我們計算一下氣泡排序的空間複雜度

// 計算BubbleSort的空間複雜度?
void BubbleSort(int* a, int n)
{
	assert(a);
	for (size_t end = n; end > 0; --end)
	{
		int exchange = 0;
		for (size_t i = 1; i < end; ++i)
		{
			if (a[i - 1] > a[i])
			{
				Swap(&a[i - 1], &a[i]);
				exchange = 1;
			}
		}
		if (exchange == 0)
			break;
	}
}
  • 透過觀察我們可以看出,氣泡排序並沒有開闢多餘的空間,所以空間複雜度為O(1).

5. 複雜度分類

演算法的複雜度有幾個量級,表示如下:
$$
O(1) < O( log N) < O(N) < O(Nlog N) < O(N 2 ) < O(2^𝑛 ) < 𝑂(O!)
$$

  • 從左到右複雜度依次遞增,演算法的缺點也就越明顯

圖示如下:

5.1 常數O(1)階

常數階是一種非常快速的演算法,但是在實際應用中非常難實現

以下是一種時間複雜度與空間複雜度皆為O(1)的演算法:

int main()
{
	int a = 0;
	int b = 1;
	int c = a + b;
	printf("兩數之和為%d\n", c);
	return 9;
}

5.2 對數階O(logN)

對數階是一種比較快的演算法,它一般每次減少一半的資料。我們常用的二分查詢演算法的時間複雜度就為O(logN)

二分查詢如下:

int binary_search(int nums[], int size, int target) //nums是陣列,size是陣列的大小,target是需要查詢的值
{
    int left = 0;
    int right = size - 1;	// 定義了target在左閉右閉的區間內,[left, right]
    while (left <= right) {	//當left == right時,區間[left, right]仍然有效
        int middle = left + ((right - left) / 2);//等同於 (left + right) / 2,防止溢位
        if (nums[middle] > target) {
            right = middle - 1;	//target在左區間,所以[left, middle - 1]
        } else if (nums[middle] < target) {
            left = middle + 1;	//target在右區間,所以[middle + 1, right]
        } else {	//既不在左邊,也不在右邊,那就是找到答案了
            return middle;
        }
    }
    //沒有找到目標值
    return -1;
}

空間複雜度為O(logN)的演算法,一般為分治演算法

比如用遞迴實現二分演算法:

int binary_search(int ar[], int low, int high, int key)
{
    if(low > high)//查詢不到
        return -1;
    int mid = (low+high)/2;
    if(key == ar[mid])//查詢到
        return mid;
    else if(key < ar[mid])
        return Search(ar,low,mid-1,key);
    else
        return Search(ar,mid+1,high,key);
}

每一次執行遞迴都會對應開闢一個空間,也被稱為棧幀

5.3 線性階O(N)

線性階演算法,時間複雜度與空間複雜度隨著數量均勻變化。

遍歷陣列或者連結串列是常見的線性階演算法,以下為時間複雜度為O(N)的演算法:

int main()
{
	int n = 0;
	int count = 0;
	scanf("%d", &n);
	for (int i = 0; i < n; i++)
	{
		count += i;//計算0~9的和
	}
	return 0;
}

以下為空間複雜度為O(N)的演算法

int main()
{
	int n = 0;
	int count = 0;
	scanf("%d", &n);
	int* p = (int*)malloc(sizeof(int) * n);
	//開闢大小為n的空間
	if (p == NULL)
	{
		perror("malloc fail");
		return -1;
          }
       free(p);
       p=NULL;
	return 0;
}

5.4 線性對數階O(NlogN)

無論是時間複雜度還是空間複雜度,線性對數階一般出現在巢狀迴圈中,即一層的複雜度為O(N),另一層為O(logN)

比如說迴圈使用二分查詢列印:

int binary_search(int nums[], int size, int target) //nums是陣列,size是陣列的大小,target是需要查詢的值
{
    int left = 0;
    int right = size - 1;	// 定義了target在左閉右閉的區間內,[left, right]
    while (left <= right) {	//當left == right時,區間[left, right]仍然有效
        int middle = left + ((right - left) / 2);//等同於 (left + right) / 2,防止溢位
        if (nums[middle] > target) {
            right = middle - 1;	//target在左區間,所以[left, middle - 1]
        }
        else if (nums[middle] < target) {
            left = middle + 1;	//target在右區間,所以[middle + 1, right]
        }
        else {	//既不在左邊,也不在右邊,那就是找到答案了
            printf("%d ", nums[middle]);
        }
    }
    //沒有找到目標值
    return -1;
}
void func(int nums[], int size, int target)
{
	for (int i = 0; i < size; i++)
	{
		binary_search(nums, size, target);
	}
}

空間複雜度為O(NlogN)的演算法,最常見的莫非歸併排序

void Merge(int sourceArr[],int tempArr[], int startIndex, int midIndex, int endIndex){
    int i = startIndex, j=midIndex+1, k = startIndex;
    while(i!=midIndex+1 && j!=endIndex+1) {
        if(sourceArr[i] > sourceArr[j])
            tempArr[k++] = sourceArr[j++];
        else
            tempArr[k++] = sourceArr[i++];
    }
    while(i != midIndex+1)
        tempArr[k++] = sourceArr[i++];
    while(j != endIndex+1)
        tempArr[k++] = sourceArr[j++];
    for(i=startIndex; i<=endIndex; i++)
        sourceArr[i] = tempArr[i];
}
 
//內部使用遞迴
void MergeSort(int sourceArr[], int tempArr[], int startIndex, int endIndex) {
    int midIndex;
    if(startIndex < endIndex) {
        midIndex = startIndex + (endIndex-startIndex) / 2;//避免溢位int
        MergeSort(sourceArr, tempArr, startIndex, midIndex);
        MergeSort(sourceArr, tempArr, midIndex+1, endIndex);
        Merge(sourceArr, tempArr, startIndex, midIndex, endIndex);
    }
}

5.5 平方階O(N2)

平方階與線性對數階相似,常見於巢狀迴圈中,每層迴圈的複雜度為O(N)

時間複雜度為O(N2),最常見的就是氣泡排序

void BubbleSort(int* a, int n)
{
	assert(a);
	for (size_t end = n; end > 0; --end)
	{
		int exchange = 0;
		for (size_t i = 1; i < end; ++i)
		{
			if (a[i - 1] > a[i])
			{
				Swap(&a[i - 1], &a[i]);
				exchange = 1;
			}
		}
		if (exchange == 0)
			break;
	}
}

計算過程如下;
$$
T(N)=1+2+3+......+n-1=(n2-n)/2=O(n2)
$$

空間複雜度為O(N2),最簡單的就是動態開闢。

{
	int n = 0;
	int count = 0;
	scanf("%d", &n);
	int* p = (int*)malloc(sizeof(int) * n*n);
	//開闢大小為n的空間
	if (p == NULL)
	{
		perror("malloc fail");
		return -1;
          }
       free(p);
       p=NULL;
	return 0;
}

5.6 指數階O(2N)

指數階的演算法效率低,並不常用。

常見的時間複雜度為O(2N)的演算法就是遞迴實現斐波拉契數列:

int Fib1(int n)
{
	if (n == 1 || n == 2)
	{
		return 1;
	}
	else
	{
		return Fib1(n - 1) + Fib1(n - 2);
	}
}

粗略估計
$$
T(n)=20+21+22+.....+2(n-1)=2n-1=O(2N)
$$

  • 值得一提的是斐波拉契的空間複雜度為O(N),因為在遞迴至最深處後往回歸的過程中,後續空間都在銷燬的空間上建立的,這樣能大大提高空間的利用率。

空間複雜度為O(2N)的演算法一般與樹有關,比如建立滿二叉樹

TreeNode* buildTree(int n) {
	if (n == 0)
		return NULL;
	TreeNode* root = newTreeNode(0);
	root->left = buildTree(n - 1);
	root->right = buildTree(n - 1);
	return root;
}

5.7 階乘階O(N!)

階乘階的演算法複雜度最高,幾乎不會採用該型別的演算法。

這是一個時間複雜度為階乘階O(N!)的演算法

int func(int n)
{
	if (n == 0)
		return 1;
	int count = 0;
	for (int i = 0; i < n; i++) 
	{
		count += func(n - 1);
	}
	return count;
}

示意圖:

  • 空間複雜度為階乘階O(N!)的演算法並不常見,這裡就不在一一列舉。

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章