[PAT]2. 演算法初步

magic_jiayu發表於2020-12-22

[PAT]2. 演算法初步

1. 排序

1.1 氣泡排序

冒泡的本質在於交換,即每次通過交換的方式把當前剩餘元素的最大值移動到一端

1.2 選擇排序

簡單選擇排序:對一個序列A中的元素A[1]~A[n],令i從1~n列舉,進行n趟操作,每趟從待排序部分[1,n]中選擇最小的元素,令其與待排序部分的第一個元素A[i]交換。時間複雜度為 O ( N 2 ) O(N^2) O(N2)

1.3 插入排序

直接插入排序:對序列A的n個元素A[1]~A[n],令i從2~n列舉,進行n-1趟操作,假設某一趟時,序列A的前i-1個元素A[i]~A[i-1]已經有序,而範圍[i,n]還未有序,那麼該趟從範圍[1,i-1]中尋找某個位置j,使得A[i]插入位置j後(此時A[j]~A[i-1]會後移一位至A[j+1]~A[i]),範圍A[1,i]有序。具體做法一般時從後往前列舉已有序部分來確定插入位置。

int A[maxn], n;							//n為元素個數,陣列下標為0~n-1
void insertSort(){
	for (int i = 1; i < n; ++i){		//進行n-1趟排序
		int temp = A[i], j = i;			//temp臨時存放A[i],j從i開始往前列舉
		while(j > 0&& temp < A[j-1]){	//只要temp小於前一個元素A[j-1]
			A[j] = A[j-1];				//把A[j-1]後移一位至A[j]
			j--;
		}
		A[j] = temp;					//插入位置為j
	}
}

1.4 排序題型常用解題步驟(利用C++sort函式)

  1. 定義相關結構體

    struct Student{
    	char name[10];	//姓名
    	char id[10];	//學號
    	ubt score;	//分數
    	int r;	//排名
    }stu[100010];
    
  2. cmp函式的編寫:使用sort進行排序時,需要提供cmp函式實現的排序規則,比如題目要求:對所有學生先按分數從高到低排序,分數相同的按姓名的字典序從小到大排序。

    bool cmp(Student a, Student b){
    	if(a.score != b.score) return a.score > b.score	//分數不相同,那麼高的在前面
    	else return strcmp(a.name, b.name) < 0	//否則將姓名字典序小的排在前面
    }
    
  3. 排名的實現:一般規則是:分數不同的排名不同,分數相同的排名相同但佔用一個排位。例如五個學生分數分別為90、88、88、88、86,那麼排名分別為1、2、2、2、5。一般需要在結構體型別定義時把排名這一項加到結構體中,在陣列排序完成後有兩種方法來實現排名的計算。

    ①先將陣列第一個個體(假設陣列下標從0開始)的排名記為1,然後遍歷剩餘個體:如果當前個體分數等於上一個個體的分數,那麼當前個體的排名等於上一個個體的排名;否則,當前個體的排名等於陣列下標加1.

    stu[0].r = 1;
    for (int i = 1; i < n; ++i)
    {
    	if (stu[i].score == stu[i-1].score){
    		stu[i].r = stu[i-1].r;
    	}else{
    		stu[i].r = i + 1;
    	}
    }
    

    ②不需要定義排名這一項,直接令int型變數r初值為1,然後遍歷所有個體:如果當前個體不是第一個個體且當前個體的分數不等於上一個個體的分數,那麼令r等於陣列下標加1,這時r就是當前個體的排名,直接輸出即可。

    int r = 1;
    for (int i = 1; i < n; ++i)
    {
    	if (i > 0 && stu[i].score != stu[i-1].score)
    	{
    		r = i + 1;
    	}
    	//輸出當前個體資訊,或者令stu[i].r = r
    }
    

2. 雜湊

2.1 整數雜湊

給出N個正整數,再給出M個正整數,求M個欲查詢的數中每個數在N個數中出現的次數,其中N,M與所有正整數都小於100000。

  • 設定一個int型陣列hashTable[100010],在輸入N個數時進行預處理,即當輸入的數為x時,就令hashTable[x]++,時間複雜度為O(N+M)。

  • 特點:直接把輸入的數作為陣列的下標來對這個數的性質進行統計

  • 問題:輸入整數過大(如11111111)或者是字串,就不能直接作為陣列下標。

  • 雜湊(hash)函式H:將元素key通過一個函式轉換為整數H(key),使得該整數可儘量唯一的代表這個元素。

    • 直接定址(H (key)=a*key+b)
    • 平方取中:取key的平方的中間若干位作為hash值
    • 除留餘數 (H (key)=key% mod)(mod 取不大於表長最大素數),不同的 key 的 hash 值相同即衝突
      • 線性探測
      • 平方探測: ( H ( k e y ) + k 2 ) % m o d (H(key)+k^2) \% mod (H(key)+k2)%mod
      • 鏈地址法
  • 可以使用標準庫模板庫中的map來直接使用hash的功能(unordered_map速度更快)

2.2 字串雜湊

將 A-Z 對應 0-25,將 26 個大寫字母對應到了 26 進位制中,再將26進位制轉換成10進位制的思路進行轉換,即將字串轉換成了整數(轉換成的整數最大是 2 6 l e n − 1 26^{len}-1 26len1,len為字串長度)

int hashFunc(char S[],int len){	//hash函式,將字串S轉換為整數
    int id = 0;
    for(int i = 0; i < len; i++){
        id = id * 26 + (S[i] - 'A');	//十進位制轉換為26進位制
    }
    return id;
}

小寫字母可以對應 26-51,然後再將 52 進位制轉換成 10 進位制

int hashFunc(char S[],int len){
    int id = 0;
    for(int i = 0; i < len; i++){
        if(s[i]>='A'&&S[i]<='Z'){
            id = id*52 + (S[i] - 'A');
        }
        else if(S[i]>='a'&&S[i]<='z'){
        	id = id*52 + (S[i]-'a') + 26;
        }
    }
    return id;
}

如果出現數字,有兩種處理的方法

  • 按照小寫字母的處理方法,增大進位制數至62
  • 如果保證是在字串的末尾是確定個數的數字,可以把前面英文字母的部分按上面的思路轉換成整數,再將末尾的數字直接拼接上去。如“BCD4”可以先將BCD轉換為整數731,然後拼接上末位的4變為7314。即在for迴圈後面新增id = id * 10 + (S[len - 1] - '0');

3. 遞迴

3.1 分治

將原問題劃分成 n 個規模較小,並且結構與原問題相似的子問題,遞迴地解決這些子問題,然後再合併其結果,就得到原問題的解。
① 分解:將原問題分解成一系列子問題;
② 解決:遞迴地求解各個子問題,若子問題足夠小,則直接求解;
③ 合併:將子問題的結果合併成原問題;
原問題分解成的子問題可以獨立求解,子問題之間相互獨立、沒有交叉,這個是與動態規劃明顯的區別;

3.2 遞迴

遞迴很適合來實現分治思想,遞迴有兩個關鍵點:遞迴邊界和遞迴式

① 求解n的階乘:遞迴式F(n) = F(n-1) * n,遞迴邊界F(0) = 1

② 求Fibonacci數列的第n項:遞迴式F(n) = F(n-1) + F(n-2),遞迴邊界F(0)=1, F(1)=1

③ 按字典序從小到大的順序輸出1~n的全排列(Full Permutation)

  • 從遞迴的角度考慮,把問題分解為若干個子問題:輸出以1開頭的全排列,輸出以2開頭的全排列 … 輸出以n開頭的全排列,於是不妨設定陣列 P用來存放當前排列,設定雜湊陣列hashTable,其中hashTable[x]當整數 x 已經在陣列 P 中時候值為 true。
  • 現在按順序往 P 的第 1 位到第 n 位中填入數字。假設 P[1] ~ P[index-1]都已經填好了,正準備填 P[index]。顯然需要列舉 1 ~ n,如果當前列舉值 x 不在 P[1] ~ P[index-1] 之中,則將其填入 P[index],同時 hashTable[x] 為 true。然後去處理P的第 index+1 位,即進行遞迴;而當遞迴完成時,將hashTable[x] 還原為false,讓 P[index]填入下一個數字
  • 遞迴邊界是 index = n + 1,此時說明P的第1~n位都已經填好了,輸出陣列P,表示生成了一個排列,然後直接return即可

④ n皇后問題:在一個n*n的國際象棋棋盤上放置n個皇后,使得這n個皇后兩兩均不在同一行、同一列、同一對角線上,求合法的方案數。

如果把n列皇后所在的行號依次寫出,那麼就會是 1 ~ n 的一個排列,於是列舉1 ~ n 的所有排列,檢視每個排列對應的放置方案是否合法,即遍歷每兩個皇后,判斷它們是否在同一條對角線上(因為顯然不在同一行同一列)。

if(index == n + 1){					//遞迴邊界,生成一個排列
	bool flag = true;				//flag==true時表示當前排列是一個合法方案
	for(int i = 1; i <= n; i++){	//遍歷任意兩個皇后  所有的兩兩都要判斷(不用相互重複)
		for(int j = i + 1; j <= n; j++){
			if(abs(i - j) == abs(P[i] - P[j])){	//若在同一條對角線上
				flag = false;		//不合法
			}
		}
	}
	if(flag) count++;
	return;
}

回溯法:當已經放置了一部分皇后時,可能剩餘的皇后無論怎樣防止都不可能合法,此時沒必要往下遞迴了,直接返回上層即可。

4. 貪心

貪心是用來解決一類最優化問題,並希望由區域性最優策略來推的全域性最優結果的演算法思想。貪心演算法適用的問題一定滿足最優子結構的性質。

區間不相交問題:給出 N 個開區間(x,y),從中選擇儘可能多的開區間,使得這些開區間兩兩之間沒有交集
例如:(1,3)、(2,4)、(3,5)、(6,7) 最多選 3 個:(1,3),(3,5)、(6,7)

考慮最簡單的情況,開區間 I 1 I_1 I1被開區間 I 2 I_2 I2包含,顯然選擇 I 1 I_1 I1。接著把所有開區間按左端點 x x x從大到小排序,那麼去除掉區間包含的情況,一定有 y 1 > y 2 > . . . > y n y_1>y_2>...>y_n y1>y2>...>yn成立;然後考慮選取區間。觀察發現左端點最大的區間 I 1 I_1 I1的右邊有一段是一定不會和其他區間重疊的,如果去掉它,那麼 I 1 I_1 I1的左邊剩餘部分就會被 I 2 I_2 I2包含,這就回到了簡單情況,應當要選取 I 1 I_1 I1

所以當按照左端點從大到小排序後,就算是出現了區間包含的情況,我們仍然是選擇左端點最大的那個,所以演算法流程就是:

  1. 按左端點從大到小排序,左端點相同的按右端點從小到大排序
  2. 排好序後,從左端點最大的區間 I 1 I_1 I1出發往前面的區間 I k I_k Ik遍歷,找到第一個右端點值小於等於 I 1 I_1 I1左端點值的區間 I k I_k Ik,並且將 I k I_k Ik作為新選中的區間,同時不相交區間個數加1
  3. 遍歷到左端點最小的區間為終點,即可得到最多的不相交區間個數

區間選點問題:給出 N 個閉區間 [x,y],求最少需要確定多少個點,才能使每個閉區間都至少存在一個點。例如:對閉區間 [1,4]、[2,6]、[5,7] 來說,需要兩個點(例如 3,5)。

還是先考慮開區間 I 1 I_1 I1被開區間 I 2 I_2 I2包含,那麼在 I 1 I_1 I1中取點可以保證這個點一定在 I 2 I_2 I2內;然後把所有區間按左端點從大到小排序,因此對左端點最大的區間來說,顯然取左端點可以使得它儘可能多地覆蓋其他區間。因此策略與區間不相交問題一致,唯一地區別是在第二步中,找到第一個右端點值小於左端點值的區間。

5. 二分

5.1 二分查詢

二分查詢是基於有序序列的查詢演算法

如何在一個嚴格遞增序列 A 中找到給定的數 x

[left,right]為整個序列的下標區間,然後每次測試當前中間位置mid = (left+right)/2,判斷A[mid]與欲查詢元素x的大小。這裡的迴圈終止條件是 while(left <= right)

注意如果二分上界超過int型資料範圍的一半,那麼當欲查詢元素在序列較靠右的位置時,語句mid = (left+right)/2中的left+right可能超過int而導致溢位。可以使用mid = left + (right - left) / 2這條語句作為代替避免溢位。

如果遞增序列 A 中的元素可能重複,那麼如何對給定的欲查詢元素 x,求出序列中第一個大於等於 x 的元素的位置 L 以及第一個大於 x 的元素的位置 R,這樣元素 x 在序列中的存在區間就是左閉右開區間 [L,R)

如果序列中沒有x,那麼L和R也可以理解成假設序列中存在x,則x應當在的位置。

先求區間[left, right]序列中的第一個大於等於 x 的元素的位置

  1. 如果A[mid] > x,說明第一個大於等於x的元素的位置一定在mid處或mid的左側,即[left, mid]
  2. 如果A[mid] == x,說明第一個大於等於x的元素的位置一定在mid的左側
  3. 如果A[mid] < x,說明第一個大於等於x的元素的位置一定在mid的右側。即[mid+1, right]
  4. 第1,2條可以合併成:如果A[mid] >= x,說明第一個大於等於x的元素的位置一定在mid處或mid的左側
  5. 注意這裡是while(left < right),且迴圈終止條件是left==right,因此最後返回值可以是left或right
  6. 二分的下界是0,上界是n(考慮到欲查詢元素有可能比序列中所有的元素都要大,此時應當返回n,即假設它存在,它該在的位置)。

然後求序列中第一個大於x的元素的位置

  1. 如果A[mid] > x,說明第一個大於x的元素的位置一定在mid處或mid的左側,即[left, mid]
  2. 如果A[mid] <= x,說明第一個大於x的元素的位置一定在mid的右側。即[mid+1, right]

可以總結出一個問題模板:尋找有序序列中第一個滿足條件的元素的位置

//二分割槽間為左閉右閉的[left,right],初值必須能覆蓋解的所有取值
int solve(int left, int right){
    int mid;
    while(left < right){//對[left,right]來說,left==right意味著找到唯一位置
        mid = (left + right) / 2;
        if(條件成立){	//條件成立,第一個滿足條件的元素的位置<=mid
            right = mid;
        }else{			//條件不成立,第一個滿足條件的元素的位置>mid
            left = mid + 1;
        }
    }
    return left;
}

如果想要尋找最後一個滿足"條件C"的元素的位置,則可以先求第一個滿足“條件!C”的元素的位置,然後將該位置減1即可。

如果是左開右閉的區間(left,right],則left的初值是-1,right的初值是n。寫法上有三點不同:

  1. 迴圈條件應該是while(left + 1 < right)。
  2. 語句left = mid + 1要改為left = mid;
  3. 返回的應當是right而不是left
//二分割槽間為左閉右閉的(left,right],初值必須能覆蓋解的所有取值,並且left比最小取值小1
int solve(int left, int right){
    int mid;
    while(left + 1< right){//對(left,right]來說,left+1 == right意味著找到唯一位置
        mid = (left + right) / 2;
        if(條件成立){	//條件成立,第一個滿足條件的元素的位置<=mid
            right = mid;
        }else{			//條件不成立,第一個滿足條件的元素的位置>mid
            left = mid;
        }
    }
    return right;
}

5.2 快速冪

給定三個正整數a、b、m( a < 1 0 9 , b < 1 0 18 , 1 < m < 1 0 9 a<10^9,b<10^{18},1<m<10^9 a<109,b<1018,1<m<109),求 a b % m a^b\%m ab%m

快速冪(二分冪)的做法基於以下事實:

  1. 如果b是奇數,那麼有 a b = a ∗ a b − 1 a^b= a*a^{b-1} ab=aab1
  2. 如果b是偶數,那麼有 a b = a b / 2 ∗ a b / 2 a^b=a^{b/2}*a^{b/2} ab=ab/2ab/2

顯然b是奇數的情況總是可以在下一步轉換為b是偶數的情況,而b是偶數的情況總可以在下一步轉換為b/2的情況,這樣在log(b)級別次數的轉換後,就可以把b變為0,任何正整數的0次方都是1。

快速冪的遞迴寫法

typedef long long LL;
LL binaryPow(LL a, LL b, LL m){
    if(b == 0){     //如果b為0,則a^0=1
        return 1;
    }
    if(b % 2 == 1){ //b為奇數,轉換為b-1
        return a * binaryPow(a, b - 1, m) % m;
    }
    else{
        LL mul = binaryPow(a, b / 2, m);
        return mul * mul % m;
    }
}

條件if(b % 2 == 1)可以用if(b & 1)代替,b和1進行位與操作,判斷b的末位是否為1,當b為奇數時b & 1會返回1。

當b % 2 == 0時不要返回直接返回binaryPow(a, b / 2, m) * binaryPow(a, b / 2, m),而是應計算出單個binaryPow(a, b / 2, m)之後再乘起來。因為前者每次都會呼叫兩個binaryPow函式,導致複雜度變成 O ( 2 l o g ( b ) ) = O ( b ) O(2^{log(b)})=O(b) O(2log(b))=O(b)

對於不同的題目,有兩個細節需要注意:

  1. 如果初始時a有可能大於等於m,那麼需要再進入函式前就讓a對m取模。
  2. 如果m為1,可以用直接在函式外部特判為0

快速冪的迭代寫法

a b a^b ab來說,如果把b寫成二進位制,則b可以寫成若干二次冪之和,因此 我們可以把任意的 a b a^b ab表示成 a 2 k , . . . , a 8 , a 4 , a 2 , a 1 a^{2k},...,a^8,a^4,a^2,a^1 a2k,...,a8,a4,a2,a1中若干項的乘積。於是可以列舉b的二進位制的每一位,如果當前位為1,則累積 a 2 i a^{2i} a2i,最後更新a為 a 2 a^2 a2,並將b右移一位(也可以理解為將b除以2)

typedef long long LL;
LL binaryPow(LL a, LL b, LL m){
    LL ans = 1;
    while(b > 0){
        if(b & 1){  //如果b的二進位制末尾為1
            ans = ans * a % m;  //令ans累積上a
        }
        a = a * a % m;  //令a平方
        b >>= 1;    //將b的二進位制右移一位
    }
    return ans;
}

6. 雙指標

給定一個遞增的正整數序列和一個正整數M,求序列中的兩個不同位置的數a和b,使它們的和恰好為M。輸出所有滿足條件的方案。

令i、j分別指向序列的第一個元素和最後一個元素,接下來根據a[i]+a[j]與M的大小來進行下面三種選擇,使i不斷向右移動、使j不斷向左移動,直到i>=j成立。時間複雜度為O(n)

  • 如果a[i]+a[j] == M,說明找到了一組方案,剩下的方案只可能在[i + 1, j - 1]的區間內產生。
  • 如果a[i]+a[j] > M,則剩餘的方案只可能在[i, j - 1]區間內產生
  • 如果a[i]+a[j] < M,則剩餘的方案只可能在[i + 1, j ]區間內產生

序列合併問題:假設有兩個遞增序列A與B,要求將它們合併為一個遞增序列C

設定兩個下標i和j,初值均為0,表示分別指向序列A的第一個元素和序列B的第一個元素,然後根據A[i]與B[j]的大小來決定哪一個放入序列C

int merge(int A[], int B[], int C[], int n, int m){
    int i = 0, j = 0, index = 0;
    while (i < n && j < m){
        if(A[i] <= B[j]){
            C[index++] = A[i++];
        }else{
            C[index++] = B[i++];
        }
    }
    while(i < n){
        C[index++] = A[i++];
    }
    while(j < m){
        C[index++] = B[j++];
    }
    return index;
}

6.1 歸併排序

2-路歸併排序的原理是,將序列兩兩分組,將序列歸併為 ⌈ n 2 ⌉ \left \lceil \frac{n}{2} \right \rceil 2n個組,組內單獨排序;然後將這些組再兩兩歸併,生成 ⌈ n 4 ⌉ \left \lceil \frac{n}{4} \right \rceil 4n個組,組內再單獨排序;以此類推,直到只剩下一個組位置。其核心在於如何將兩個有序序列合併為一個有序序列,即上面的序列合併問題。時間複雜度為 O ( n l o g n ) O(nlogn) O(nlogn)

  1. 遞迴實現:反覆將當前區間[left,right]分為兩半,對兩個子區間[left,mid]與[mid+1,right]分別遞迴進行歸併排序,然後將兩個已經有序的子區間合併為有序序列即可。
  2. 非遞迴實現:考慮到每次分組時組內元素個數上限都是2的冪次。於是令步長step的初值為2,然後將陣列每step個元素作為一組,將其內部進行排序;再令step乘以2,重複上面的操作,直到 step/2 超過元素個數n。

6.2 快速排序

先解決這樣一個問題:對一個序列A[1]、A[2]、…、A[n],調整序列中元素的位置,使得A[1](原序列的A[1],下同)的左側所有元素都不超過A[1]、右側所有元素都大於A[1]。下面給出速度最快的做法:思想就是雙指標

  1. 先將A[1]存至某個臨時變數temp,並令下標left、right分別指向序列首尾(如令left=1、right=n)
  2. 只要right指向的元素A[right]大於temp,就將right不斷左移;當某個時候A[right]<=temp時,將元素A[right]挪到left指向的元素A[left]處
  3. 只要left指向的元素A[left]小於temp,就將left不斷右移;當某個時候A[left]>temp時,將元素A[left]挪到right指向的元素A[right]處
  4. 重複2,3,直到left與right相遇,把temp(即原A[1])放到相遇的地方。

快速排序的思路是:

  1. 調整序列中的元素,使當前序列最左端的元素在調整後滿足左側所有元素均不超過該元素、右側所有元素均大於該元素
  2. 對該元素的左側和右側分別遞迴進行步驟1的調整,直到當前調整區間的長度不超過1。

把用以劃分割槽間的元素A[left] 稱為主元。快速排序演算法當序列中元素的排列比較隨機時效率最高,但是當序列中元素接近有序時,會達到最壞時間複雜度 O ( n 2 ) O(n^2) O(n2),產生這種情況的主要原因在於主元沒有把當前區間劃分為兩個長度接近的子區間。解決辦法是隨機選擇主元,這樣對任意輸入資料的期望時間複雜度都能達到 O ( n l o g n ) O(nlogn) O(nlogn)

6.3 隨機選擇演算法

如何從一個無序的陣列中求出第K大的數

最直接的想法是對陣列排序,然後直接取出第K個元素即可,時間複雜度為(nlogn),而隨機選擇演算法可以做到對任意輸入都達到 O ( n ) O(n) O(n)的期望時間複雜度

當對A[left,right]執行一次randPartition函式之後,主元左側的元素個數就是確定的,且它們都小於主元。假設此時主元是A[p],那麼A[p]就是A[left,right]中第p-left+1大的數。不妨令M表示p-left+1,如果K==M成立,說明第K大的數就是主元A[p];如果K<M成立,說明第K大的數在主元左側,即A[left…(p-1)]中的第K大,往左遞迴即可;如果K>M成立,則說明第K大的數在主元右側,即即A[(p+1)…right]中的第K-M大,往右側遞迴即可。演算法以left==right為遞迴邊界,返回A[left]。

可以證明雖熱隨機選擇演算法的最壞時間複雜度是 O ( n 2 ) O(n^2) O(n2),但其對任輸入的期望時間複雜度確實 O ( n ) O(n) O(n)