左神直通BAT演算法筆記(基礎篇)-上

Anwen發表於2019-02-19

個人技術部落格:www.zhenganwen.top

時間複雜度

時間複雜度是衡量演算法好壞的重要指標之一。時間複雜度反映的是不確定性樣本量的增長對於演算法操作所需時間的影響程度,與演算法操作是否涉及到樣本量以及涉及了幾次直接相關,如遍歷陣列時時間複雜度為陣列長度n(對應時間複雜度為O(n)),而對資料的元操作(如加減乘除與或非等)、邏輯操作(如if判斷)等都屬於常數時間內的操作(對應時間複雜度O(1))。

在化簡某演算法時間複雜度表示式時需遵循以下規則:

  • 對於同一樣本量,可省去低階次數項,僅保留高階次數項,如O(n^2)+O(n)可化簡為O(n^2)O(n)+O(1)可化簡為O(n)
  • 可省去樣本量前的常量係數,如O(2n)可化簡為O(n)O(8)可化簡為O(1)
  • 對於不同的不確定性樣本量,不能按照上述兩個規則進行化簡,要根據實際樣本量的大小分析表示式增量。如O(logm)+O(n^2)不能化簡為O(n^2)O(logm)。而要視m、n兩者之間的差距來化簡,比如m>>n時可以化簡為O(logm),因為表示式增量是由樣本量決定的。

額外空間複雜度

演算法額外空間複雜度指的是對於輸入樣本,經過演算法操作需要的額外空間。比如使用氣泡排序對一個陣列排序,期間只需要一個臨時變數temp,那麼該演算法的額外空間複雜度為O(1)。又如歸併排序,在排序過程中需要建立一個與樣本陣列相同大小的輔助陣列,儘管在排序過後該陣列被銷燬,但該演算法的額外空間複雜度為O(n)

經典例題——舉一反三

找出B中不屬於A的數

找出陣列B中不屬於A的數,陣列A有序而陣列B無序。假設陣列A有n個數,陣列B有m個數,寫出演算法並分析時間複雜度。

方法一:遍歷

首先遍歷B,將B中的每個數拿到到A中找,若找到則列印。對應演算法如下:

int A[] = {1, 2, 3, 4, 5};
int B[] = {1, 4, 2, 6, 5, 7};

for (int i = 0; i < 6; ++i) {
  int temp = B[i];
  bool flag = false;
  for (int j = 0; j < 5; ++j) {
    if (A[j] == temp) {
      flag = true;    //找到了
      break;
    }
  }
  if (!flag) {    //沒找到
    printf("%d", temp);
  }
}
複製程式碼

不難看出上述演算法的時間複雜度為O(m*n),因為將兩個陣列都遍歷了一遍

方法二:二分查詢

由於陣列A是有序的,在一個有序序列中查詢一個元素可以使用二分法(也稱折半法)。原理就是將查詢的元素與序列的中位數進行比較,如果小於則去掉中位數及其之後的序列,如果大於則去掉中位數及其之前的序列,如果等於則找到了。如果不等於那麼再將其與剩下的序列繼續比較直到找到或剩下的序列為空為止。

左神直通BAT演算法筆記(基礎篇)-上

利用二分法對應題解的程式碼如下:

for (int i = 0; i < 6; ++i) {		//B的長度為6
  int temp = B[i];
  //二分法查詢
  int left = 0,right = 5-1;			//A的長度為5
  int mid = (left + right) / 2;
  while (left < right && A[mid] != temp) {
    if (A[mid] > temp) {
      right = mid - 1;
    } else {
      left = mid + 1;
    }
    mid = (left + right) / 2;
  }
  if (A[mid] != temp) {
    printf("%d", temp);
  }
}
複製程式碼

for迴圈m次,while迴圈logn次(如果沒有特別說明,log均以2為底),此演算法的時間複雜度為O(mlogn)

方法三:排序+外排

第三種方法就是將陣列B也排序,然後使用逐次比對的方式來查詢A陣列中是否含有B陣列中的某元素。引入a、b兩個指標分別指向陣列A、B的首元素,比較指標指向的元素值,當a<b時,向後移動a指標查詢該元素;當a=b時,說明A中存在該元素,跳過該元素查詢,向後移動b;當a>b時說明A中不存在該元素,列印該元素並跳過該元素的查詢,向後移動b。直到a或b有一個到達陣列末尾為止(若a先到達末尾,那麼b和b之後的數都不屬於A)

左神直通BAT演算法筆記(基礎篇)-上

對應題解的程式碼如下:

void fun3(int A[],int a_length,int B[],int b_length){
    quickSort(B, 0, b_length - 1);	//使用快速排序法對陣列B排序->O(mlogm)
    int* a = A,*b=B;
    while (a <= A + a_length - 1 || b <= B + b_length - 1) {
        if (*a == *b) {
            b++;
            continue;
        }
        if (*a > *b) {
            printf("%d", *b);
            b++;
        } else {
            a++;
        }
    }

    if (a == A + a_length) {	//a先到頭
        while (b < B + b_length) {
            printf("%d", *b);
            b++;
        }
    }
}
複製程式碼

快速排序的程式碼如下:

#include <stdlib.h>
#include <time.h>

//交換兩個int變數的值
void swap(int &a, int &b){
    int temp = a;
    a = b;
    b = temp;
}

//產生一個low~high之間的隨機數
int randomInRange(int low, int high){
    srand((int) time(0));
    return (rand() % (high - low))+low;
}

//快速排序的核心演算法,隨機選擇一個數,將比該數小的移至陣列左邊,比該數大的移至
//陣列右邊,最後返回該數的下標(移動完之後該數的下標可能與移動之前不一樣)
int partition(int arr[],int start,int end){
    if (arr == NULL || start < 0 || end <= 0 || start > end) {
        return -1;
    }

    int index = randomInRange(start, end);//隨機選擇一個數
    swap(arr[index], arr[end]);//將該數暫時放至末尾

    int small = start - 1;
    //遍歷前n-1個數與該數比較並以該數為界限將前n-1個數
    //分為兩組,small指向小於該數的那一組的最後一個元素
    for (index = start; index < end; index++) {
        if (arr[index] < arr[end]) {
            small++;
            if (small != index) {
                swap(arr[small], arr[index]);
            }
        }
    }

    //最後將該數放至數值較小的那一個組的中間
    ++small;
    swap(arr[small], arr[end]);
    return small;
}

void quickSort(int arr[],int start,int end) {
    if (start == end) {
        return;
    }
    int index = partition(arr, start, end);
    if (index > start) {
        quickSort(arr,start, index - 1);
    }
    if (index < end) {
        quickSort(arr, index + 1, end);
    }
}
複製程式碼

此種方法的時間複雜度為:O(mlogm)(先對B排序)+O(m+n)(最壞的情況是指標a和b都到頭)。

三種方法的比較

  1. O(m*n)
  2. O(mlogn)(以2為底)
  3. O(mlogm)+O(m+n)(以2為底)

易知演算法2比1更優,因為增長率logn<n。而2和3的比較取決於樣本量m和n之間的差距,若m>>n那麼2更優,不難理解:陣列B元素較多,那麼對B的排序肯定要花費較長時間,而這一步並不是題解所必需的,不如採用二分法;相反地,若m<<n,那麼3更優。

荷蘭國旗問題

給定一個陣列arr,和一個數num,請把小於num的數放在陣列的左邊,等於num的數放在陣列的中間,大於num的數放在陣列的右邊。

要求額外空間複雜度O(1),時間複雜度O(N)

思路:利用兩個指標LR,將L指向首元素之前,將R指向尾元素之後。從頭遍歷序列,將當前遍歷元素與num比較,若num,則將其與L的右一個元素交換位置並遍歷下一個元素、右移L;若=num則直接遍歷下一個元素;若>num則將其和R的左一個元素交換位置,並重新判斷當前位置元素與num的關係。直到遍歷的元素下標到為R-1為止。

void swap(int &a, int &b){
    int temp = a;
    a = b;
    b = temp;
}
void partition(int arr[],int startIndex,int endIndex,int num){
    int L = startIndex - 1, R = endIndex + 1, i = startIndex;
    while (i <= R - 1) {
        if (arr[i] < num) {
            swap(arr[i++], arr[++L]);
        } else if (arr[i] > num) {
            swap(arr[i], arr[--R]);
        } else {
            i++;
        }
    }
}

int main(){
    int arr[] = {1,2, 1, 5, 4, 7, 2, 3, 9,1};
    travles(arr, 8);
    partition(arr, 0, 7, 2);
    travles(arr, 8);
    return 0;
}
複製程式碼

L代表小於num的數的右界,R代表大於num的左界,partition的過程就是遍歷元素、不斷壯大L、R範圍的過程。這裡比較難理解的地方可能是為什麼arr[i]<num時要右移Larr[i]>num時卻不左移R,這是因為對於當前元素arr[i],如果arr[i]<num進行swap(arr[i],arr[L+1])之後對於當前下標的資料狀況是知曉的(一定有arr[i]=arr[L+1]),因為是從頭遍歷到i的,而L+1<=i。但是如果arr[i]>num進行swap(arr[i],arr[R-1])之後對於當前元素的資料狀況是不清楚的,因為R-1>=iarr[R-1]還沒遍歷到。

矩陣列印問題

轉圈列印方塊矩陣

給定一個4階矩陣如下:

左神直通BAT演算法筆記(基礎篇)-上

列印結果如下(要求額外空間複雜度為O(1)):

1 2 3 4 8 12 16 15 14 13 9 5 6 7 11 10
複製程式碼

思路:這類問題需要將思維開啟,從巨集觀的層面去找出問題存在的共性從而求解。如果你的思維侷限在1是如何變到2的、4是怎麼變到8的、11之後為什麼時10、它們之間有什麼關聯,那麼你就陷入死衚衕了。

從巨集觀的層面找共性,其實轉圈列印的過程就是不斷順時針列印外圍元素的過程,只要給你一個左上角的點(如(0,0))和右下角的點(如(3,3)),你就能夠列印出1 2 3 4 8 12 16 15 14 13 9 5;同樣,給你(1,1)(2,2),你就能列印出6 7 11 10。這個根據兩點列印正方形上元素的過程可以抽取出來,整個問題也就迎刃而解了。

列印一個矩陣某個正方形上的點的邏輯如下:

左神直通BAT演算法筆記(基礎篇)-上

//
// Created by zaw on 2018/10/21.
//
#include <stdio.h>
#define FACTORIAL 4

void printSquare(int leftUp[], int rigthDown[],int matrix[][FACTORIAL]){
    int i = leftUp[0], j = leftUp[1];
    while (j < rigthDown[1]) {
        printf("%d ", matrix[i][j++]);
    }
    while (i < rigthDown[0]) {
        printf("%d ", matrix[i++][j]);
    }
    while (j > leftUp[1]) {
        printf("%d ", matrix[i][j--]);
    }
    while (i > leftUp[0]) {
        printf("%d ", matrix[i--][j]);
    }
}

void printMatrixCircled(int matrix[][FACTORIAL]){
    int leftUp[] = {0, 0}, rightDown[] = {FACTORIAL-1,FACTORIAL-1};
    while (leftUp[0] < rightDown[0] && leftUp[1] < rightDown[1]) {
        printSquare(leftUp, rightDown, matrix);
        ++leftUp[0];
        ++leftUp[1];
        --rightDown[0];
        --rightDown[1];
    }
}

int main(){
    int matrix[4][4] = {
            {1,  2,  3,  4},
            {5,  6,  7,  8},
            {9,  10, 11, 12},
            {13, 14, 15, 16}
    };
    printMatrixCircled(matrix);//1 2 3 4 8 12 16 15 14 13 9 5 6 7 11 10
}
複製程式碼

旋轉方塊矩陣

給定一個方塊矩陣,請把該矩陣調整成順時針旋轉90°之後的樣子,要求額外空間複雜度為O(1)

左神直通BAT演算法筆記(基礎篇)-上

思路:拿上圖舉例,首先選取矩陣四個角上的點1,3,9,7,按順時針的方向13的位置(1->3)、3->99->77->1,這樣對於旋轉後的矩陣而言,這四個點已經調整好了。接下來只需調整2,6,8,4的位置,調整方法是一樣的。只需對矩陣第一行的前n-1個點採用同樣的方法進行調整、對矩陣第二行的前前n-3個點……,那麼調整n階矩陣就容易了。

這也是在巨集觀上觀察資料變動的一般規律,找到以不變應萬變的通解(給定一個點,確定矩陣上以該點為角的正方形,將該正方形旋轉90°),整個問題就不攻自破了。

左神直通BAT演算法筆記(基礎篇)-上

//
// Created by zaw on 2018/10/21.
//
#include <stdio.h>
#define FACTORIAL 4

void circleSquare(int leftUp[],int rightDown[],int matrix[][FACTORIAL]){
    int p1[] = {leftUp[0], leftUp[1]};
    int p2[] = {leftUp[0], rightDown[1]};
    int p3[] = {rightDown[0], rightDown[1]};
    int p4[] = {rightDown[0],leftUp[1]};
    while (p1[1] < rightDown[1]) {
        //swap
        int tmp = matrix[p4[0]][p4[1]];
        matrix[p4[0]][p4[1]] = matrix[p3[0]][p3[1]];
        matrix[p3[0]][p3[1]] = matrix[p2[0]][p2[1]];
        matrix[p2[0]][p2[1]] = matrix[p1[0]][p1[1]];
        matrix[p1[0]][p1[1]] = tmp;

        p1[1]++;
        p2[0]++;
        p3[1]--;
        p4[0]--;
    }
}

void circleMatrix(int matrix[][FACTORIAL]){
    int leftUp[] = {0, 0}, rightDown[] = {FACTORIAL - 1, FACTORIAL - 1};
    while (leftUp[0] < rightDown[0] && leftUp[1] < rightDown[1]) {
        circleSquare(leftUp, rightDown, matrix);
        leftUp[0]++;
        leftUp[1]++;
        --rightDown[0];
        --rightDown[1];
    }
}

void printMatrix(int matrix[][FACTORIAL]){
    for (int i = 0; i < FACTORIAL; ++i) {
        for (int j = 0; j < FACTORIAL; ++j) {
            printf("%2d ", matrix[i][j]);
        }
        printf("\n");
    }
}

int main(){
    int matrix[FACTORIAL][FACTORIAL] = {
            {1,  2,  3,  4},
            {5,  6,  7,  8},
            {9,  10, 11, 12},
            {13, 14, 15, 16}
    };
    printMatrix(matrix);
    circleMatrix(matrix);
    printMatrix(matrix);
}
複製程式碼

之字形列印矩陣

左神直通BAT演算法筆記(基礎篇)-上

對如上矩陣的列印結果如下(要求額外空間複雜度為O(1)):

1 2 7 13 8 3 4 9 14 15 10 5 6 11 16 17 12 18
複製程式碼

此題也是需要從巨集觀上找出一個共性:給你兩個,你能否將該兩點連成的45°斜線上的點按給定的列印方向列印出來。拿上圖舉例,給出(2,0)(0,2)turnUp=true,應該列印出13,8,3。那麼整個問題就變成了兩點的走向問題了,開始時兩點均為(0,0),然後一個點往下走,另一個點往右走(如1->71->2);當往下走的點是邊界點時就往右走(如13->14),當往右走的點到邊界時就往下走(如6->12)。每次兩點走一步,並列印兩點連線上的點。

//
// Created by zaw on 2018/10/22.
//
#include <stdio.h>

const int rows = 3;
const int cols = 6;

void printLine(int leftDown[],int rightUp[], bool turnUp,int matrix[rows][cols]){
    int i,j;
    if (turnUp) {
        i = leftDown[0], j = leftDown[1];
        while (j <= rightUp[1]) {
            printf("%d ", matrix[i--][j++]);
        }
    } else {
        i = rightUp[0], j = rightUp[1];
        while (i <= leftDown[0]) {
            printf("%d ", matrix[i++][j--]);
        }
    }
}

void zigZagPrintMatrix(int matrix[rows][cols]){
    if (matrix==NULL)
        return;
    int leftDown[] = {0, 0}, rightUp[] = {0, 0};
    bool turnUp = true;
    while (leftDown[1] <= cols - 1) {
        printLine(leftDown, rightUp, turnUp, matrix);
        turnUp = !turnUp;
        if (leftDown[0] < rows - 1) {
            leftDown[0]++;
        } else {
            leftDown[1]++;
        }
        if (rightUp[1] < cols - 1) {
            ++rightUp[1];
        } else {
            ++rightUp[0];
        }
    }
}

int main(){
    int matrix[rows][cols] = {
            {1,  2,  3,  4,  5,  6},
            {7,  8,  9,  10, 11, 12},
            {13, 14, 15, 16, 17, 18}
    };
    zigZagPrintMatrix(matrix);//1 2 7 13 8 3 4 9 14 15 10 5 6 11 16 17 12 18
    return 0;
}
複製程式碼

在行和列都排好序的矩陣上找數

如圖:

左神直通BAT演算法筆記(基礎篇)-上

任何一列或一行上的數是有序的,實現一個函式,判斷某個數是否存在於矩陣中。要求時間複雜度為O(M+N),額外空間複雜度為O(1)

從矩陣右上角的點開始取點與該數比較,如果大於該數,那麼說明這個點所在的列都不存在該數,將這個點左移;如果這個點上的數小於該數,那麼說明這個點所在的行不存在該數,將這個點下移。直到找到與該數相等的點為止。最壞的情況是,該數只有一個且在矩陣左下角上,那麼時間複雜度為O(M-1+N-1)=O(M+N)

//
// Created by zaw on 2018/10/22.
//
#include <stdio.h>
const int rows = 4;
const int cols = 4;

bool findNumInSortedMatrix(int num,int matrix[rows][cols]){
    int i = 0, j = cols - 1;
    while (i <= rows - 1 && j <= cols - 1) {
        if (matrix[i][j] > num) {
            --j;
        } else if (matrix[i][j] < num) {
            ++i;
        } else {
            return true;
        }
    }
    return false;
}

int main(){
    int matrix[rows][cols] = {
            {1, 2, 3, 4},
            {2, 4, 5, 8},
            {3, 6, 7, 9},
            {4, 8, 9, 10}
    };
    if (findNumInSortedMatrix(7, matrix)) {
        printf("find!");
    } else {
        printf("not exist!");
    }
    return 0;
}
複製程式碼

島問題

一個矩陣中只有0和1兩種值,每個位置都可以和自己的上、下、左、右四個位置相連,如果有一片1連在一起,這個部分叫做一個島,求一個矩陣中有多少個島?

比如矩陣:

1 0 1
0 1 0
1 1 1

就有3個島。

分析:我們可以遍歷矩陣中的每個位置,如果遇到1就將與其相連的一片1都感染成2,並自增島數量。

public class IslandNum {
	
	public static int getIslandNums(int matrix[][]){
		int res = 0 ;
		for(int i = 0 ; i < matrix.length ; i++){
			for(int j = 0 ; j < matrix[i].length ; j++){
				if(matrix[i][j] == 1){
					res++;
					infect(matrix , i , j);
				}
			}
		}
		return res;
	}

	public static void infect(int matrix[][], int i ,int j){
		if(i < 0 || i >= matrix.length || j < 0 || j >= matrix[i].length || matrix[i][j] != 1){
			return;
		}
		matrix[i][j] = 2;
		infect(matrix , i-1 , j);
		infect(matrix , i+1 , j);
		infect(matrix , i , j-1);
		infect(matrix , i , j+1);
	}

	public static void main(String[] args){
		int matrix[][] = {
			{1,0,0,1,0,1},
			{0,1,1,0,0,0},
			{1,0,0,0,1,1},
			{1,1,1,1,1,1}
		};
		System.out.println(getIslandNums(matrix));
	}
}
複製程式碼

經典結構和演算法

字串

KMP演算法

KMP演算法是由一個問題而引發的:對於一個字串str(長度為N)和另一個字串match(長度為M),如果matchstr的子串,請返回其在str第一次出現時的首字母下標,若match不是str的子串則返回-1

最簡單的方法是將str從頭開始遍歷並與match逐次比較,若碰到了不匹配字母則終止此次遍歷轉而從str的第二個字元開始遍歷並與match逐次比較,直到某一次的遍歷每個字元都與match匹配否則返回-1。易知此種做法的時間複雜度為O(N*M)

KMP演算法則給出求解該問題時間複雜度控制在O(N)的解法。

首先該演算法需要對應match建立一個與match長度相同的輔助陣列help[match.length],該陣列元素表示match某個下標之前的子串的前字尾子串最大匹配長度字首子串表示一個串中以串首字元開頭的不包含串尾字元的任意個連續字元,字尾子串則表示一個串中以串尾字元結尾的不包括串首字元的任意個連續字元。比如abcd的字首子串可以是aababc,但不能是abcd,而abcd的字尾字串可以是dcdbcd,但不能是abcd。再來說一下help陣列,對於char match[]="abc1abc2"來說,有help[7]=3,因為match[7]='2',因此match下標在7之前的子串abc1abc的字首子串和字尾子串相同的情況下,字首子串的最大長度為3(即字首字串和字尾字串都取abc);又如match="aaaab",有help[4]=3(字首子串和字尾子串最大匹配長度當兩者為aaa時取得),相應的有help[3]=2help[2]=1

假設當要尋找的子串matchhelp陣列找到之後(對於一個串的help陣列的求法在介紹完KMP演算法之後再詳細說明)。就可以進行KMP演算法求解此問題了。KMP演算法的邏輯(結論)是,對於stri~(i+k)部分(ii+k均為str的合法下標)和match0~k部分(kmatch的合法下標),如果有str[i]=match[0]str[i+1]=match[1]……str[i+k-1]=match[k-1],但str[i+k]!=[k],那麼str的下標不用從i+k變為i+1重新比較,只需將子串str[0]~str[i+k-1]的最大匹配字首子串的後一個字元cn重新與str[i+k]向後依次比較,後面如果又遇到了不匹配的字元重複此操作即可:

左神直通BAT演算法筆記(基礎篇)-上

當遇到不匹配字元時,常規的做法是將str的遍歷下標sIndex移到i+1的位置並將match的遍歷下標mIndex移到0再依次比較,這種做法並沒有利用上一輪的比較資訊(對下一輪的比較沒有任何優化)。而KMP演算法則不是這樣,當遇到不匹配的字元str[i+k]match[k]時,str的遍歷指標sIndex=i+k不用動,將match右滑並將其遍歷指標mIndex打到子串match[0]~match[k-1]的最大匹配字首子串的後一個下標n的位置。然後sIndexi+k開始,mIndexn開始,依次向後比較,若再遇到不匹配的數則重複此過程。

對應程式碼如下:

void length(char* str){
  if(str==NULL)
    return -1;
  int len=0;
  while(*(str++)!='\0'){
    len++;
  }
  return len;
}

int getIndexOf(char* str,char* m){
    int slen = length(str) , mlen = length(m);
    if(mlen > slen)
        return -1;
    int help[mlen];
    getHelpArr(str,help);
    int i=0,j=0;	//sIndex,mIndex
    while(i < slen && j < mlen){
        if(str[i] == m[j]){
            i++;
            j++;
        }else if(help[j] != -1){
            j = help[j];    //mIndex -> cn's index
        }else{	//the first char is not match,move the sIndex
            i++;    
        }
    }
    return j == mlen ? i - mlen : -1;
}
複製程式碼

可以發現KMP演算法中str的遍歷指標並沒有回溯這個動作(只向後移動),當完成匹配時sIndex的移動次數小於N,否則sIndex移動到串尾也會終止迴圈,所以while對應的匹配過程的時間複雜度為O(N)(if(help[j] != -1){ j = help[j] }的執行次數只會是常數次,因此可以忽略)。

下面只要解決如何求解一個串的help陣列,此問題就解決了。help陣列要從前到後求解,直接求help[n]是很難有所頭緒的。當串match長度mlen=1時,規定help[0]=-1。當mlen=2時,去掉match[1]之後只剩下match[0],最大匹配子串長度為0(因為字首子串不能包含串尾字元,字尾子串不能包含串首字元),即help[1]=0。當mlen>2時,help[n](n>=2)都可以推算出來:

左神直通BAT演算法筆記(基礎篇)-上

如上圖所示,如果我們知道了help[n-1],那麼help[n]的求解有兩種情況:如果match[cn]=match[n-1],那麼由a區域與b區域(a、b為子串match[0~n-2]的最大匹配字首子串和字尾字串)相同可知help[n]=help[n-1]+1;如果match[cn]!=match[n-1],那麼求a區域中下一個能和b區域字尾子串中匹配的較大的一個,即a區域的最大匹配字首字串c區域,將match[n-1]和c區域的後一個位置(cn')上的字元比較,如果相等則help[n]等於c區域的長度+1,而c區域的長度就是help[cn]help陣列的定義如此);如果不等則將cn打到cn'的位置繼續和match[n-1]比較,直到cn被打到0為止(即help[cn]=-1為止),那麼此時help[n]=0

對應程式碼如下:

int* getHelpArr(char* s,int help[]){
    if(s==NULL)
        return NULL;
    int slen = length(s);
    help[0]=-1;
    help[1]=0;
    int index = 2;//help陣列從第三個元素開始的元素值需要依次推算
    int cn = 0;		//推算help[2]時,help[1]=0,即s[1]之前的字元組成的串中不存在最大匹配前後子串,那麼cn作為最大匹配字首子串的後一個下標自然就是0了
    while(index < slen){
        if(s[index-1] == s[cn]){	//if match[n-1] == match[cn]
            help[index] = help[index-1] + 1;
            index++;
            cn++;
        }else if(help[cn] == -1){	//cn reach 0
            help[index]=0;
            index++;
            cn++;
        }else{
            cn = help[cn];	//set cn to cn' and continue calculate help[index]
        }
    }
    return help;
}
複製程式碼

那麼這個求解help陣列的過程的時間複雜度如何計算呢?仔細觀察剋制while迴圈中僅涉及到indexcn這兩個變數的變化:

第一個if分支 第二個if分支 第三個if分支
index 增大 增大 不變
index-cn 不變 不變 增大

可以發現while迴圈執行一次不是index增大就是index-cn增大,而index < slenindex - cn < slen,即index最多自增Mmatch串的長度)次 ,index-cn最多增加M次,如此while最多執行M+M次,即時間複雜為O(2M)=O(M)

綜上所述,使用KMP求解此問題的時間複雜度為O(M)(求解matchhelp陣列的時間複雜度)+O(N)(匹配的時間複雜度)=O(N)(因為N > M)。

KMP演算法的應用

  1. 判斷一個二叉樹是否是另一棵二叉樹的子樹(即某棵樹的結構和資料狀態和另一棵二叉樹的子樹樣)。

    思路:如果這棵樹的序列化串是另一棵樹的序列化串的子串,那麼前者必定是後者的子樹。

字首樹(字典樹)

字首樹的介紹

字首樹是一種儲存字串的高效容器,基於此結構的操作有:

  • insert插入一個字串到容器中

  • search容器中是否存在某字串,返回該字串進入到容器的次數,沒有則返回0

  • delete將某個字串進入到容器的次數減1

  • prefixNumber返回所有插入操作中,以某個串為字首的字串出現的次數

設計思路:該結構的重點實現在於儲存。字首樹以字元為儲存單位,將其儲存在結點之間的樹枝上而非結點上,如插入字串abc之後字首樹如下:

左神直通BAT演算法筆記(基礎篇)-上

每次插入串都要從頭結點開始,遍歷串中的字元依次向下“鋪路”,如上圖中的abc3條路。對於每個結點而言,它可以向下鋪a~z26條不同的路,假如來到某個結點後,它要向下鋪的路(取決於遍歷到哪個字元來了)被之前插入串的過程鋪過了那麼就可以直接走這條路去往下一個結點,否則就要先鋪路再去往下一個結點。如再插入串abdebcd的字首樹將如下所示:

左神直通BAT演算法筆記(基礎篇)-上

根據字首樹的searchprefixNumber兩個操作,我們還需要在每次鋪路後記錄以下每個結點經過的次數(across),以及每次插入操作每個結點作為終點結點的次數(end)。

字首樹的實現

字首樹的實現示例:

public class TrieTree {

  public static class TrieNode {
    public int across;
    public int end;
    public TrieNode[] paths;

    public TrieNode() {
      super();
      across = 0;
      end = 0;
      paths = new TrieNode[26];
    }
  }

  private TrieNode root;

  public TrieTree() {
    super();
    root = new TrieNode();
  }

  //向樹中插入一個字串
  public void insert(String str) {
    if (str == null || str.length() == 0) {
      return;
    }
    char chs[] = str.toCharArray();
    TrieNode cur = root;
    for (char ch : chs) {
      int index = ch - 'a';
      if (cur.paths[index] == null) {
        cur.paths[index] = new TrieNode();
      }
      cur = cur.paths[index];
      cur.across++;
    }
    cur.end++;
  }

  //查詢某個字串插入的次數
  public int search(String str) {
    if (str == null || str.length() == 0) {
      return 0;
    }
    char chs[] = str.toCharArray();
    TrieNode cur = root;
    for (char ch : chs) {
      int index = ch - 'a';
      if (cur.paths[index] == null) {
        return 0;
      }else{
        cur = cur.paths[index];
      }
    }
    return cur.end;
  }

  //刪除一次插入過的某個字串
  public void delete(String str) {
    if (search(str) > 0) {
      char chs[] = str.toCharArray();
      TrieNode cur = root;
      for (char ch : chs) {
        int index = ch - 'a';
        if (--cur.paths[index].across == 0) {
          cur.paths[index] = null;
          return;
        }
        cur = cur.paths[index];
      }
      cur.end--;
    }
  }

	//查詢所有插入的字串中,以prefix為字首的有多少個
  public int prefixNumber(String prefix) {
    if (prefix == null || prefix.length() == 0) {
      return 0;
    }
    char chs[] = prefix.toCharArray();
    TrieNode cur = root;
    for (char ch : chs) {
      int index = ch - 'a';
      if (cur.paths[index] == null) {
        return 0;
      }else{
        cur = cur.paths[index];
      }
    }
    return cur.across;
  }

  public static void main(String[] args) {
    TrieTree tree = new TrieTree();
    tree.insert("abc");
    tree.insert("abde");
    tree.insert("bcd");
    System.out.println(tree.search("abc"));	//1
    System.out.println(tree.prefixNumber("ab"));	//2
  }
}
複製程式碼

字首樹的相關問題

一個字串型別的陣列arr1,另一個字串型別的陣列arr2:

  • arr2中有哪些字元,是arr1中出現的?請列印
  • arr2中有哪些字元,是作為arr1中某個字串字首出現的?請列印
  • arr2中有哪些字元,是作為arr1中某個字串字首出現的?請列印arr2中出現次數最大的字首。

陣列

氣泡排序

氣泡排序的核心是從頭遍歷序列。以升序排列為例:將第一個元素和第二個元素比較,若前者大於後者,則交換兩者的位置,再將第二個元素與第三個元素比較,若前者大於後者則交換兩者位置,以此類推直到倒數第二個元素與最後一個元素比較,若前者大於後者,則交換兩者位置。這樣一輪比較下來將會把序列中最大的元素移至序列末尾,這樣就安排好了最大數的位置,接下來只需對剩下的(n-1)個元素,重複上述操作即可。

左神直通BAT演算法筆記(基礎篇)-上

void swap(int *a, int *b){
  int temp = *a;
  *a = *b;
  *b = temp;
}

void bubbleSort(int arr[], int length) {
  if(arr==NULL || length<=1){
    return;
  }
  for (int i = length-1; i > 0; i--) {	//只需比較(length-1)輪
    for (int j = 0; j < i; ++j) {
      if (arr[j] > arr[j + 1]) {
        swap(&arr[j], &arr[j + 1]);
      }
    }
  }
}
複製程式碼

該演算法的時間複雜度為n+(n-1)+...+1,很明顯是一個等差數列,由(首項+末項)*項數/2求其和為(n+1)n/2,可知時間複雜度為O(n^2)

選擇排序

以升序排序為例:找到最小數的下標minIndex,將其與第一個數交換,接著對子序列(1-n)重複該操作,直到子序列只含一個元素為止。(即選出最小的數放到第一個位置,該數安排好了,再對剩下的數選出最小的放到第二個位置,以此類推)

左神直通BAT演算法筆記(基礎篇)-上

void selectionSort(int arr[], int length) {
  for (int i = 0; i < length-1; ++i) {    //要進行n-1次選擇,選出n-1個數分別放在前n-1個位置上
    if(arr==NULL || length<=1){
      return;
    }
    int minIndex = i;	//記錄較小數的下標
    for (int j = i+1; j < length; ++j) {
      if (arr[minIndex] > arr[j]) {
        minIndex = j;
      }
    }
    if (minIndex != i) {
      swap(&arr[minIndex],&arr[i]);
    }
  }
}
複製程式碼

同樣,不難得出該演算法的時間複雜度(big o)為O(n^2)(n-1+n-2+n-3+…+1)

插入排序

插入排序的過程可以聯想到打撲克時揭一張牌然後將其到手中有序紙牌的合適位置上。比如我現在手上的牌是7、8、9、J、Q、K,這時揭了一張10,我需要將其依次與K、Q、J、9、8、7比較,當比到9時發現大於9,於是將其插入到9之後。對於一個無序序列,可以將其當做一摞待揭的牌,首先將首元素揭起來,因為揭之前手上無牌,因此此次揭牌無需比較,此後每揭一次牌都需要進行上述的插牌過程,當揭完之後,手上的握牌順序就對應著該序列的有序形式。

左神直通BAT演算法筆記(基礎篇)-上

void swap(int *a, int *b){
    int temp = *a;
    *a = *b;
    *b = temp;
}
void insertionSort(int arr[], int length){
    if(arr==NULL || length<=1){
        return;
    }
    for (int i = 1; i < length; ++i) {		//第一張牌無需插入,直接入手,後續揭牌需比較然後插入,因此從第二個元素開始遍歷(插牌)
      	//將新揭的牌與手上的逐次比較,若小於則交換,否則停止,比較完了還沒遇到更小的也停止
        for (int j = i - 1; j >= 0 || arr[j] <= arr[j + 1]; j--) {
            if (arr[j] > arr[j + 1]) {
                swap(&arr[j], &arr[j + 1]);
            }
        }
    }
}
複製程式碼

插入排序的big o該如何計算?可以發現如果序列有序,那麼該演算法的big o為O(n),因為只是遍歷了一次序列(這時最好情況);如果序列降序排列,那麼該演算法的big o為O(n^2)(每次插入前的比較交換加起來要:1+2+…+n-1)(最壞情況)。**一般應用場景中都是按演算法的最壞情況來考量演算法的效率的,因為你做出來的應用要能夠承受最壞情況。**即該演算法的big o為O(n^2)

歸併排序

歸併排序的核心思想是先讓序列的左半部分有序、再讓序列的右半部分有序,最後從兩個子序列(左右兩半)從頭開始逐次比較,往輔助序列中填較小的數。

以序列{2,1,4,3}為例,歸併排序的過程大致如下:

左神直通BAT演算法筆記(基礎篇)-上

演算法程式碼示例:

void merge(int arr[],int helpArr[], int startIndex, int midIndex,int endIndex) {
    int L = startIndex, R = midIndex + 1, i = startIndex;
    while (L <= midIndex && R <= endIndex) { //只要沒有指標沒越界就逐次比較
        helpArr[i++] = arr[L] < arr[R] ? arr[L++] : arr[R++];
    }
    while (L != midIndex + 1) {
        helpArr[i++] = arr[L++];
    }
    while (R != endIndex + 1) {
        helpArr[i++] = arr[R++];
    }
    for (i = startIndex; i <= endIndex; i++) {
        arr[i] = helpArr[i];
    }
}

void mergeSort(int arr[],int helpArr[], int startIndex, int endIndex) {
    int midIndex;
    if (startIndex < endIndex) {  //當子序列只含一個元素時,不再進行此子過程
      	//(endIndex+startIndex)/2可能會導致int溢位,下面求中位數的做法更安全
        midIndex = startIndex + ((endIndex - startIndex) >> 1);
        mergeSort(arr, helpArr, startIndex, midIndex);        //對左半部分排序
        mergeSort(arr, helpArr, midIndex + 1, endIndex);      //對右半部分排序
        merge(arr, helpArr, startIndex, midIndex, endIndex);  //使整體有序
    }
}

int main(){
    int arr[] = {9, 1, 3, 4, 7, 6, 5};
    travels(arr, 7);//遍歷列印
    int helpArr[7];
    mergeSort(arr, helpArr, 0, 7);
    travels(arr, 7);

    return 0;
}
複製程式碼

此演算法的核心就是第24、25、26這三行。第26行應該不難理解,就是使用兩個指標L、R外加一個輔助陣列,將兩個序列有序地併入輔助陣列。但為什麼24、25行執行過後陣列左右兩半部分就分別有序了呢?這就又牽扯到了歸併排序的核心思想:先讓一個序列左右兩半部分有序,然後再併入使整體有序。因此24、25是對左右兩半部分分別遞迴執行歸併排序,直到某次遞迴時左右兩半部分均為一個元素時遞迴終止。當一個序列只含兩個元素時,呼叫mergeSort會發現24、25行是無效操作,直接執行merge。就像上圖所示,兩行遞迴完畢後,左右兩半部分都會變得有序。

當一個遞迴過程比較複雜時(不像遞迴求階乘那樣一幕瞭然),我們可以列舉簡短樣本進行分析。

對於這樣複雜的遞迴行為,千萬不要想著追溯整個遞迴過程,只需分析第一步要做的事(比如此例中第一步要做的是就是mergeSort函式所呈現出來的那樣:對左半部分排序、對右半部分排序、最後併入,你先不管是怎麼排序的,不要被24、25行的mergeSort給帶進去了)和遞迴終止的條件(比如此例中是``startIndex>=endIndex`,即要排序的序列只有一個元素時)。

歸併排序的時間複雜度是O(nlogn),額外空間複雜度是O(n)

根據Master公式(本文 小技巧一節中有講到)可得T(n)=2T(n/2)+O(n),第一個2的含義是子過程(對子序列進行歸併排序)要執行兩次,第二個2的含義是子過程樣本量佔一半(因為分成了左右兩半部分),最後O(n)表示左右有序之後進行的併入操作為O(n+n)=O(n)(L、R指標移動次數總和為n,將輔助陣列覆蓋源陣列為n),符合T(n)=aT(n/b)+O(n^d),經計算該演算法的時間複雜度為O(nlogn)

小和問題

在一個陣列中,每一個數左邊比當前數小的數累加起來,叫做這個陣列的小和。求一個陣列的小和。例如:

對於陣列[1,3,4,2,5]
1左邊比1小的數,沒有;
3左邊比3小的數,1;
4左邊比4小的數,1、3;
2左邊比2小的數,1;
5左邊比5小的數,1、3、4、2;
所以小和為1+1+3+1+1+3+4+2=16
複製程式碼

簡單的做法就是遍歷一遍陣列,將當前遍歷的數與該數之前數比較並記錄小於該數的數。易知其時間複雜度為O(n^2)(0+1+2+……+n-1)。

更優化的做法是利用歸併排序的併入邏輯

左神直通BAT演算法筆記(基礎篇)-上

對應程式碼:

int merge(int arr[],int helpArr[], int startIndex, int midIndex,int endIndex) {
    int L = startIndex, R = midIndex + 1, i = startIndex;
    int res=0;
    while (L <= midIndex && R <= endIndex ) { //只要沒有指標沒越界就逐次比較
        res += arr[L] < arr[R] ? arr[L] * (endIndex - R + 1) : 0;
        helpArr[i++] = arr[L] < arr[R] ? arr[L++] : arr[R++];
    }
    while (L != midIndex + 1) {
        helpArr[i++] = arr[L++];
    }
    while (R != endIndex + 1) {
        helpArr[i++] = arr[R++];
    }
    for (i = startIndex; i <= endIndex; i++) {
        arr[i] = helpArr[i];
    }
    return res;
}

int mergeSort(int arr[],int helpArr[], int startIndex, int endIndex) {
    int midIndex;
    if (startIndex < endIndex) {  //當子序列只含一個元素時,不再進行此子過程
        midIndex = startIndex + ((endIndex - startIndex) >> 1);
        return mergeSort(arr, helpArr, startIndex, midIndex) +        //對左半部分排序
               mergeSort(arr, helpArr, midIndex + 1, endIndex) +     //對右半部分排序
               merge(arr, helpArr, startIndex, midIndex, endIndex);  //使整體有序
    }
    return 0;	//一個元素時不存在小和
}

int main(){
    int arr[] = {1,3,4,2,5};
    int helpArr[5];
    printf("small_sum:%d\n",mergeSort(arr, helpArr, 0, 4)) ;
    return 0;
}
複製程式碼

該演算法在歸併排序的基礎上做了略微改動,即merge中新增了變數res記錄每次併入操作應該累加的小和、mergeSort則將每次併入應該累加的小和彙總。此種做法的複雜度與歸併排序的相同,優於遍歷的做法。可以理解,依次求每個數的小和過程中有很多比較是重複的,而利用歸併排序求小和時利用了併入的兩個序列分別有序的特性省去了不必要的比較,如134併入25時,2>1直接推出2後面的數都>1,因此直接1*(endIndex-indexOf(2)+1)即可。這在樣本量不大的情況下看不出來優化的效果,試想一下如果樣本量為2^32,那麼依照前者求小和O(n^2)可知時間複雜度為O(21億的平方),而歸併排序求小和則只需O(21億*32),足以見得O(n^2)O(nlogn)的優劣。

逆序對問題

在一個陣列中,左邊的數如果比右邊的數大,則這兩個數構成一個逆序對,請列印所有逆序對。

這題的思路也可以利用歸併排序來解決,在併入操作時記錄arr[L]>arr[R]的情況即可。

快速排序

經典快排

經典快排就是將序列中比尾元素小的移動到序列左邊,比尾元素大的移動到序列右邊,對以該元素為界的左右兩個子序列(均不包括該元素)重複此操作。

首先我們要考慮的是對給定的一個數,如何將序列中比該數小的移動到左邊,比該數大的移動到右邊。

思路:利用一個輔助指標small,代表較小數的右邊界(初始指向首元素前一個位置),遍歷序列每次遇到比該數小的數就將其與arr[small+1]交換並右移small,最後將該數與arr[small+1]交換即達到目的。對應演算法如下:

void partition(int arr[], int startIndex, int endIndex){
    int small = startIndex - 1;
    for (int i = startIndex; i < endIndex; ++i) {
        if(arr[i] < arr[endIndex]) {
            if (small + 1 != i) {
                swap(arr[++small], arr[i]);
            } else {
                //如果small、i相鄰則不用交換
                small++;
            }
        }
    }
    swap(arr[++small], arr[endIndex]);
}
int main(){
    int arr[] = {1, 2, 3, 4, 6, 7, 8, 5};
    travles(arr, 8);//1 2 3 4 6 7 8 5
    partition(arr, 0, 7);
    travles(arr, 8);//1 2 3 4 5 7 8 6
    return 0;
}
複製程式碼

接著就是快排的遞迴邏輯:對1 2 3 4 6 7 8 5序列partition之後,去除之前的比較引數5,對剩下的子序列1234786繼續partition,直到子序列為一個元素為止:

int partition(int arr[], int startIndex, int endIndex){
    int small = startIndex - 1;
    for (int i = startIndex; i < endIndex; ++i) {
        if(arr[i] < arr[endIndex]) {
            if (small + 1 != i) {
                swap(arr[++small], arr[i]);
            } else {
                //如果small、i相鄰則不用交換
                small++;
            }
        }
    }
    swap(arr[++small], arr[endIndex]);
    return small;
}

void quickSort(int arr[], int startIndex, int endIndex) {
    if (startIndex > endIndex) {
        return;
    }
    int index = partition(arr, startIndex, endIndex);
    quickSort(arr, startIndex, index - 1);
    quickSort(arr, index + 1, endIndex);
}
int main(){
    int arr[] = {1, 5, 6, 2, 7, 3, 8, 0};
    travles(arr, 8);	//1 5 6 2 7 3 8 0
    quickSort(arr, 0,7);
    travles(arr, 8);	//0 1 2 3 5 6 7 8
    return 0;
}
複製程式碼

經典排序的時間複雜度與資料狀況有關,如果每一次partition時,尾元素都是序列中最大或最小的,那麼去除該元素序列並未如我們劃分為樣本量相同的左右兩個子序列,而是隻安排好了一個元素(就是去掉的那個元素),這樣的話時間複雜度就是O(n-1+n-2+……+1)=O(n^2);但如果每一次partition時,都將序列分成了兩個樣本量相差無幾的左右兩個子序列,那麼時間複雜度就是O(nlogn)(使用Master公式求解)。

由荷蘭國旗問題引發對經典快排的改進

可以發現這裡partition的過程與荷蘭國旗問題中的partition十分相似,能否以後者的partition實現經典快排呢?我們來試一下:

int* partition(int arr[], int startIndex, int endIndex){ ;
    int small = startIndex - 1, great = endIndex + 1, i = startIndex;
    while (i <= great - 1) {
        if (arr[i] < arr[endIndex]) {
            swap(arr[++small], arr[i++]);
        } else if (arr[i] > arr[endIndex]){
            swap(arr[--great], arr[i]);
        } else {
            i++;
        }
    }
    int range[] = {small, great};
    return range;
}

void quickSort(int arr[], int startIndex, int endIndex) {
    if (startIndex > endIndex) {
        return;
    }
    int* range = partition(arr, startIndex, endIndex);
    quickSort(arr, startIndex, range[0]);
    quickSort(arr, range[1], endIndex);
}

int main(){
    int arr[] = {1, 5, 6, 2, 7, 3, 8, 0};
    travles(arr, 8);	//1 5 6 2 7 3 8 0
    quickSort(arr, 0,7);
    travles(arr, 8);	//0 1 2 3 5 6 7 8
    return 0;
}
複製程式碼

比較一下經典排序和使用荷蘭國旗問題改進後的經典排序,不難發現,後者一次partition能去除一個以上的元素(等於arr[endIndex]的區域),而前者每次partition只能去除一個元素,這裡的去除相當於安排(排序)好了對應元素的位置。因此後者比經典排序更優,但是優化不大,只是常數時間內的優化,實質上的效率還是要看資料狀況(最後的情況為O(nlogn),最壞的情況為O(n^2))。

隨機快排——O(nlogn)

上面談到了快排的短板是依賴資料狀況,那麼我們有沒有辦法消除這個依賴,讓他成為真正的O(nlogn)呢?

事實上,為了讓演算法中的操作不依託於資料狀況(如快排中每一次partition取尾元素作為比較,這就沒有規避樣本的資料狀況,如果尾元素是最大或最小值就成了最壞情況)常常有兩種做法:

1、使用隨機取數

2、將樣本資料雜湊打亂

隨機快排就是採用上了上述第一種解決方案,在每一輪的partition中隨機選擇序列中的一個數作為要比較的數:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void swap(int &a, int &b){
    int temp = a;
    a = b;
    b = temp;
}

//產生[startIndex,endIndex]之間的隨機整數
int randomInRange(int startIndex,int endIndex){
    return rand() % (endIndex - startIndex + 1) + startIndex;
}

int* partition(int arr[], int startIndex, int endIndex){ ;
    int small = startIndex - 1, great = endIndex + 1, i = startIndex;
    int randomNum = arr[randomInRange(startIndex, endIndex)];
    while (i <= great - 1) {
        if (arr[i] < randomNum) {
            swap(arr[++small], arr[i++]);
        } else if (arr[i] > randomNum){
            swap(arr[--great], arr[i]);
        } else {
            i++;
        }
    }
    int range[] = {small, great};
    return range;
}

void quickSort(int arr[], int startIndex, int endIndex) {
    if (startIndex > endIndex) {
        return;
    }
    int* range = partition(arr, startIndex, endIndex);
    quickSort(arr, startIndex, range[0]);
    quickSort(arr, range[1], endIndex);
}

void travles(int dataArr[], int length){
    for (int i = 0; i < length; ++i) {
        printf("%d ", dataArr[i]);
    }
    printf("\n");
}

int main(){
    srand(time(NULL));//此後呼叫rand()時將以呼叫時的時間為隨機數種子
    int arr[] = {9,7,1,3,2,6,8,4,5};
    travles(arr, 9);
    quickSort(arr, 0,8);
    travles(arr, 9);
    return 0;
}
複製程式碼

觀察比較程式碼可以發現隨機快排只不過是在partition時隨機選出一個下標上的數作為比較物件,從而避免了每一輪選擇尾元素會受資料狀況的影響的問題。

那麼隨機快排的時間複雜度又為多少呢?

經數學論證,由於每一輪partition選出的作為比較物件的數是隨機的,即序列中的每個數都有1/n的概率被選上,那麼該演算法時間複雜度為概率事件,經數學論證該演算法的數學期望O(nlogn)。雖然說是數學期望,但在實際工程中,常常就把隨機快排的時間複雜度當做O(nlog)

堆排序

什麼是堆

堆結構就是將一顆完全二叉樹對映到陣列中的一種儲存方式:

左神直通BAT演算法筆記(基礎篇)-上

大根堆和小根堆

當堆的每一顆子樹(包括樹本身)的最大值就是其結點時稱為大根堆;相反,當堆的每一顆子樹的最小值就是其根結點時稱為小根堆。其中大根堆的應用較為廣泛,是一種很重要的資料結構。

左神直通BAT演算法筆記(基礎篇)-上

heapInsert和heapify

大根堆最重要的兩個操作就是heapInsertheapify,前者是當一個元素加入到大根堆時應該自底向上與其父結點比較,若大於父結點則交換;後者是當堆中某個結點的數值發生變化時,應不斷向下與其孩子結點中的最大值比較,若小於則交換。下面是對應的程式碼:

//index之前的序列符合大根堆排序,將index位置的元素加入堆結構,但不能破壞大根堆的特性
void heapInsert(int arr[],int index){
    while (arr[index] > arr[(index - 1) / 2]) { //當該結點大於父結點時
        swap(arr[index], arr[(index - 1) / 2]);
        index = (index - 1) / 2;    //繼續向上比較
    }
}

//陣列中下標從0到heapSize符合大根堆排序
//index位置的值發生了變化,重新調整堆結構為大根堆
//heapSize指的是陣列中符合大根堆排序的範圍而不是陣列長度,最大為陣列長度,最小為0 
void heapify(int arr[], int heapSize, int index){
    int leftChild = index * 2 + 1;
    while (leftChild < heapSize) {  //當該結點有左孩子時
        int greatOne = leftChild + 1 < heapSize && arr[leftChild + 1] > arr[leftChild] ?
                leftChild + 1 : leftChild;  //只有當右孩子存在且大於左孩子時,最大值是右孩子,否則是左孩子
        greatOne = arr[greatOne] > arr[index] ? greatOne : index;//將父結點與最大孩子結點比較,確定最大值
        if (greatOne == index) {
            //如果最大值是本身,則不用繼續向下比較
            break;
        }
        swap(arr[index], arr[greatOne]);

        //next turn下一輪
        index = greatOne;
        leftChild = index * 2 + 1;
    }
}
複製程式碼

建立大根堆

void buildBigRootHeap(int arr[],int length){
    if (arr == NULL || length <= 1) {
        return;
    }
    for (int i = 0; i < length; ++i) {
        heapInsert(arr, i);
    }
}
複製程式碼

利用heapify排序

前面做了那麼多鋪墊都是為了建立大根堆,那麼如何利用它來排序呢?

左神直通BAT演算法筆記(基礎篇)-上

對應程式碼實現如下:

void heapSort(int arr[],int length){
    if (arr == NULL || length <= 1) {
        return;
    }
  	//先建立大根堆
    for (int i = 0; i < length; ++i) {
        heapInsert(arr, i);
    }
 	  //迴圈彈出堆頂元素並heapify
    int heapSize = length;
    swap(arr[0], arr[--heapSize]);//相當於彈出堆頂元素
    while (heapSize > 0) {
        heapify(arr, heapSize, 0);
        swap(arr[0], arr[--heapSize]);
    }
}

int main(){
    int arr[] = {9,7,1,3,6,8,4,2,5};
    heapSort(arr, 9);
    travles(arr, 9);
    return 0;
}
複製程式碼

堆排序的優勢在於無論是入堆一個元素heapInsert還是出堆一個元素之後的heapify都不是將整個樣本遍歷一遍(O(n)級別的操作),而是樹層次上的遍歷(O(logn)級別的操作)。

這樣的話堆排序過程中,建立堆的時間複雜度為O(nlogn),迴圈彈出堆頂元素並heapify的時間複雜度為O(nlogn),整個堆排序的時間複雜度為O(nlogn),額外空間複雜度為O(1)

優先順序佇列結構(比如Java中的PriorityQueue)就是堆結構。

排序演算法的穩定性

排序演算法的穩定性指的是排序前後是否維持值相同的元素在序列中的相對次序。如序列271532,在排序過程中如果能維持第一次出現的2在第二次出現的2的前面,那麼該排序演算法能夠保證穩定性。首先我們來分析一下前面所講排序演算法的穩定性,再來談談穩定性的意義。

  • 氣泡排序。可以保證穩定性,只需在比較相鄰兩個數時只在後一個數比前一個數大的情況下才交換位置即可。
  • 選擇排序。無法保證穩定性,比如序列926532,在第一輪maxIndex的選擇出來之後(maxIndex=0),第二次出現的2(尾元素)將與9交換位置,那麼兩個2的相對次序就發生了變化,而這個交換是否會影響穩定性在我們coding的時候是不可預測的。
  • 插入排序。可以保證穩定性,每次插入一個數到有序序列中時,遇到比它大的就替換,否則不替換。這樣的話,值相同的元素,後面插入的就總在前面插入的後面了。
  • 歸併排序。可以保證穩定性,在左右兩半子序列排好序後的merge過程中,比較大小時如果相等,那麼優先插入左子序列中的數。
  • 快排。不能保證穩定性,因為partition的過程會將比num小的與small區域的右一個數交換位置,將比num大的與great區域的左一個數交換位置,而smallgreat分居序列兩側,很容易打亂值相同元素的相對次序。
  • 堆排序。不能保證穩定性。二叉樹如果交換位置的結點是相鄰層次的可以保證穩定性,但堆排序中彈出堆頂元素後的heapify交換的是第一層的結點和最後一層的結點。

維持穩定性一般是為了滿足業務需求。假設下面是一張不同廠商下同一款產品的價格和銷售情況表:

品牌 價格 銷量
三星 1603 92
小米 1603 74
vivo 1604 92

要求先按價格排序,再按銷量排序。如果保證穩定性,那麼排序後應該是這樣的:

品牌 價格 銷量
三星 1603 92
vivo 1604 92
小米 1603 74

即按銷量排序後,銷量相同的兩條記錄會保持之前的按價格排序的狀態,這樣先前的價格排序這個工作就沒白做。

比較器的使用

之前所講的一些演算法大都是對基本型別的排序,但實際工程中要排序的物件可能是無法預測的,那麼如何實現一個通用的排序演算法以應對呢?事實上,之前的排序都可以歸類為基於比較的排序。也就是說我們只需要對要比較的物件實現一個比較器,然後排序演算法基於比較器來排序,這樣演算法和具體要排序的物件之間就解耦了。以後在排序之前,基於要排序的物件實現一個比較器(定義瞭如何比較物件大小的邏輯),然後將比較器丟給排序演算法即可,這樣就實現了複用。

Java(本人學的是Java方向)中,這個比較器就是Comparator介面,我們需要實現其中的compare方法,對於要排序的物件集合定義一個比較大小的邏輯,然後在構造用來新增這類物件的有序容器時傳入這個構造器即可。封裝好的容器會在容器元素髮生改變時使用我們的比較器來重新組織這些元素。

import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.PriorityQueue;
import java.util.Comparator;

public class ComparatorTest {
    
    @Data
    @AllArgsConstructor
    static class Student {
        private long id;
        private String name;
        private double score;
    }

    static class IdAscendingComparator implements Comparator<Student> {
        /**
         * 底層排序演算法對兩個元素比較時會呼叫這個方法
         * @param o1
         * @param o2
         * @return  若返回正數則認為o1<o2,返回0則認為o1=o2,否則認為o1>o2
         */
        @Override
        public int compare(Student o1, Student o2) {
            return o1.getId() < o2.getId() ? -1 : 1;
        }
    }

    public static void main(String[] args) {
        //大根堆
        PriorityQueue heap = new PriorityQueue(new IdAscendingComparator());
        Student zhangsan = new Student(1000, "zhangsan", 50);
        Student lisi = new Student(999, "lisi", 60);
        Student wangwu = new Student(1001, "wangwu", 50);
        heap.add(zhangsan);
        heap.add(lisi);
        heap.add(wangwu);
        while (!heap.isEmpty()) {
            System.out.println(heap.poll());//彈出並返回堆頂元素
        }
    }
}
複製程式碼

還有TreeSet等,都是在構造是傳入比較器,否則將直接根據元素的值(Java中引用型別變數的值為地址,比較將毫無意義)來比較,這裡就不一一列舉了。

有關排序問題的補充

  1. 歸併排序可以做到額外空間複雜度為O(1),但是比較難,感興趣的可以搜 歸併排序 內部快取法
  2. 快速排序可以做到保證穩定性,但是很難,可以搜01 stable sort(論文)
  3. 有一道題是:是奇數放到陣列左邊,是偶數放到陣列右邊,還要求奇數和奇數之間、偶數和偶數之間的原始相對次序不變。這道題和歸併排序如出一轍,只不過歸併排序是將arr[length-1]arr[randomIndex]作為比較的標準,而這道題是將是否能整除2作為比較的標準,這類問題都同稱為o1 sort,要使這類問題做到穩定性,要看01 stable sort這篇論文。

工程中的綜合排序演算法

實際工程中的排序演算法一般會將 歸併排序插入排序快速排序綜合起來,集大家之所長來應對不同的場景要求:

  • 當要排序的元素為基本資料型別且元素個數較少時,直接使用 插入排序。因為在樣本規模較小時(比如60),O(NlogN)的優勢並不明顯甚至不及O(N^2),而在O(N^2)的演算法中,插入排序的常數時間操作最少。
  • 當要排序的元素為物件資料型別(包含若干欄位),為保證穩定性將採用 歸併排序
  • 當要排序的元素為基本資料型別且樣本規模較大時,將採用 快速排序

桶排序

上一節中所講的都是基於比較的排序,也即通過比較確定每個元素所處的位置。那麼能不能不比較而實現排序呢?這就涉及到了 桶排序 這個方法論:準備一些桶,將序列中的元素按某些規則放入翻入對應的桶中,最後根據既定的規則依次倒出桶中的元素。

非基於比較的排序,與被排序的樣本的實際資料狀況有很大關係,所以在實際中並不常用。

計數排序

計數排序是 桶排序 方法論的一種實現,即準備一個與序列中元素的資料範圍大小相同的陣列,然後遍歷序列,將遇到的元素作為陣列的下標並將該位置上的數加1。例如某序列元素值在0~100之間,請設計一個演算法對其排序,要求時間複雜度為O(N)

#include <stdio.h>
void countSort(int arr[],int length){
    int bucketArr[101];
    int i;
    for(i = 0 ; i <= 100 ; i++){
        bucketArr[i]=0;	//init buckets
    }
    for(i = 0 ; i < length ; i++){
        bucketArr[arr[i]]++;	//put into buckets
    }
    int count, j=0;
    for(i = 0 ; i <= 100 ; i++) {
        if (bucketArr[i] != 0) { //pour out
            count = bucketArr[i];
            while (count-- > 0) {
                arr[j++] = i;
            }
        }
    }
}

void travels(int arr[], int length){
    for (int i = 0; i < length; ++i) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main(){
    int arr[] = {9, 2, 1, 4, 5, 2, 1, 6, 3, 8, 1, 2};
    travels(arr, 12);//9 2 1 4 5 2 1 6 3 8 1 2
    countSort(arr, 12);
    travels(arr, 12);//1 1 1 2 2 2 3 4 5 6 8 9
    return 0;
}
複製程式碼

如果下次面試官問你有沒有事件複雜度比O(N)更優的排序演算法時,不要忘了計數排序哦!!!

補充問題

  1. 給定一個陣列,求如果排序後,相鄰兩數的最大值,要求時間複雜度為O(N),且要求不能用非基於比較的排序。

    這道題的思路比較巧妙:首先為這N個數準備N+1個桶,然後以其中的最小值和最大值為邊界將數值範圍均分成N等分,然後遍歷陣列將對應範圍類的數放入對應的桶中,下圖以陣列長度為9舉例

    左神直通BAT演算法筆記(基礎篇)-上

    這裡比較難理解的是:

    • 題目問的是求如果排序後,相鄰兩數的最大差值。該演算法巧妙的藉助一個空桶(N個數進N+1個桶,必然有一個是空桶),將問題轉向了求兩個相鄰非空桶 (其中可能隔著若干個空桶)之間前桶的最大值和後桶最小值的差值,而無需在意每個桶中進了哪些數(只需記錄每個桶入數的最大值和最小值以及是否有數

    對應程式碼如下:

    #include <stdio.h>
    
    //根據要入桶的數和最大最小值得到對應桶編號
    int getBucketId(int num,int bucketsNum,int min,int max){
        return (num - min) * bucketsNum / (max - min);
    }
    
    int max(int a, int b){
        return a > b ? a : b;
    }
    
    int min(int a, int b){
        return a < b ? a : b;
    }
    
    int getMaxGap(int arr[], int length) {
        if (arr == NULL || length < 2) {
            return -1;
        }
        int maxValue = -999999, minValue = 999999;
        int i;
        //找出最大最小值
        for (i = 0; i < length; ++i) {
            maxValue = max(maxValue, arr[i]);
            minValue = min(minValue, arr[i]);
        }
        //記錄每個桶的最大最小值以及是否有數,初始時每個桶都沒數
        int maxs[length + 1], mins[length + 1];
        bool hasNum[length + 1];
        for (i = 0; i < length + 1; i++) {	
            hasNum[i] = false;
        }
        //put maxValue into the last bucket
        mins[length] = maxs[length] = maxValue;
        hasNum[length] = true;
    
        //iterate the arr
        int bid; //bucket id
        for (i = 0; i < length; i++) {
            if (arr[i] != maxValue) {
                bid = getBucketId(arr[i], length + 1, minValue, maxValue);
              	//如果桶裡沒數,則該數入桶後,最大最小值都是它,否則更新最大最小值
                mins[bid] = !hasNum[bid] ? arr[i] : arr[i] < mins[bid] ? arr[i] : mins[bid];
                maxs[bid] = !hasNum[bid] ? arr[i] : arr[i] > maxs[bid] ? arr[i] : maxs[bid];
                hasNum[bid] = true;
            }
        }
    
        //find the max gap between two nonEmpty buckets
        int res = 0, j = 0;
        for (i = 0; i < length; ++i) {
            j = i + 1;//the next nonEmtpy bucket id
            while (!hasNum[j]) {//the last bucket must has number
                j++;
            }
            res = max(res, (mins[j] - maxs[i]));
        }
    
        return res;
    }
    
    int main(){
        int arr[] = {13, 41, 67, 26, 55, 99, 2, 82, 39, 100};
        printf("%d", getMaxGap(arr, 9));	//17
        return 0;
    }
    複製程式碼

連結串列

反轉單連結串列和雙向連結串列

實現反轉單向連結串列和反轉雙向連結串列的函式,要求時間複雜度為O(N),額外空間複雜度為O(1)

此題的難點就是反轉一個結點的next指標後,就無法在該結點通過next指標找到後續的結點了。因此每次反轉之前需要將該結點的後繼結點記錄下來。

#include<stdio.h>
#include<malloc.h>
#define MAX_SIZE 100

struct LinkNode{
	int data;
	LinkNode* next;
};

void init(LinkNode* &head){
	head = (LinkNode*)malloc(sizeof(LinkNode));
	head->next=NULL;
}

void add(int i,LinkNode* head){
	LinkNode* p = (LinkNode*)malloc(sizeof(LinkNode));
	p->data = i;
	p->next =  head->next;
	head->next = p;
}

void printList(LinkNode* head){
	if(head==NULL)
		return;
	LinkNode* p = head->next;
	while(p != NULL){
		printf("%d ",p->data);
		p = p->next;
	}
	printf("\n");
}
複製程式碼
#include<stdio.h>
#include "LinkList.cpp"

void reverseList(LinkNode *head){
	if(head == NULL)
		return;
	LinkNode* cur = head->next;
	LinkNode* pre = NULL;
	LinkNode* next = NULL;
	while(cur != NULL){
		next = cur->next;
		cur->next = pre;
		pre = cur;
		cur = next;
	}
	//pre -> end node
	head->next = pre;
	return;
}

int main(){
	LinkNode* head;
	init(head);
	add(1,head);
	add(2,head);
	add(3,head);
	add(4,head);
	
	printList(head);
	reverseList(head);
	printList(head);
}
複製程式碼

判斷一個連結串列是否為迴文結構

請實現一個函式判斷某個單連結串列是否是迴文結構,如1->3->1返回true1->2->2->1返回true2->3->1返回false

我們可以利用迴文連結串列前後兩半部分逆序的特點、結合棧先進後出來求解此問題。將連結串列中間結點之前的結點依次壓棧,然後從中間結點的後繼結點開始遍歷連結串列的後半部分,將遍歷的結點與棧彈出的結點比較。

程式碼示例如下:

#include<stdio.h>
#include "LinkList.cpp"
#include "SqStack.cpp"

/*
	判斷某連結串列是否是迴文結構
	1、首先找到連結串列的中間結點(若是偶數個結點則是中間位置的左邊一個結點)
	2、使用一個棧將中間結點之前的結點壓棧,然後從中間結點的後一個結點開始從棧中拿出結點比較 
*/

bool isPalindromeList(LinkNode* head){
	if(head == NULL)
		return false;
		
	LinkNode *slow = head , *fast = head;
	SqStack* stack;
	init(stack);
	
	//fast指標每走兩步,slow指標才走一步 
	while(fast->next != NULL && fast->next->next != NULL){
		fast = fast->next->next;	
		slow = slow->next;	
		push(slow,stack);
	}
	
	//連結串列沒有結點或只有一個結點,不是迴文結構
	if(isEmpty(stack)) 
		return false;
		
	//判斷偶數個結點還是奇數個結點
	if(fast->next != NULL){	//奇數個結點,slow需要再走一步 
		slow = slow->next;
	}
	
	//從slow的後繼結點開始遍歷連結串列,將每個結點與棧頂結點比較
	LinkNode* node;
	slow = slow->next;
	while(slow != NULL){
		pop(stack,node);
		//一旦發現有一個結點不同就不是迴文結構 
		if(slow->data != node->data)
			return false;
		slow = slow->next;
	} 
	return true;
}

int main(){
	
	LinkNode* head;
	init(head);
	add(2,head);
	add(3,head);
	add(3,head);
	add(2,head);
	printList(head);
	
	if(isPalindromeList(head)){
		printf("是迴文連結串列");
	}else{
		printf("不是迴文連結串列");
	}
	return 0;
}
複製程式碼

LinkList.cpp:

#include<stdio.h>
#include<malloc.h>
#define MAX_SIZE 100

struct LinkNode{
	int data;
	LinkNode* next;
};

void init(LinkNode* &head){
	head = (LinkNode*)malloc(sizeof(LinkNode));
	head->next=NULL;
}

void add(int i,LinkNode* head){
	LinkNode* p = (LinkNode*)malloc(sizeof(LinkNode));
	p->data = i;
	p->next =  head->next;
	head->next = p;
}

void printList(LinkNode* head){
	if(head==NULL)
		return;
	LinkNode* p = head->next;
	while(p != NULL){
		printf("%d ",p->data);
		p = p->next;
	}
	printf("\n");
}
複製程式碼

SqStack:

#include<stdio.h>
#include<malloc.h>

struct SqStack{
	LinkNode* data[MAX_SIZE];
	int length;
}; 

void init(SqStack* &stack){
	stack = (SqStack*)malloc(sizeof(SqStack));
	stack->length=0; 
}

bool isEmpty(SqStack* stack){
	if(stack->length > 0)
		return false;
	return true;
}

bool isFull(SqStack* stack){
	if(stack->length == MAX_SIZE)
		return true;
	return false;
}

void push(LinkNode* i,SqStack* stack){
	if(stack==NULL)
		return;
	if(!isFull(stack)){
		stack->data[stack->length++] = i;
	}
}

bool pop(SqStack* stack,LinkNode* &i){
	if(stack==NULL)
		return false;
	if(!isEmpty(stack))
		i = stack->data[--stack->length];
		return true;
}
複製程式碼

進階:要求使用時間複雜度為O(N),額外空間複雜度為O(1)求解此問題。

思路:我們可以先將連結串列的後半部分結點的next指標反向,然後從連結串列的兩頭向中間推進,逐次比較。(當然了,為了不破壞原始資料結構,我們在得出結論之後還需要將連結串列指標恢復原樣)

#include<stdio.h>
#include "LinkList.cpp"
#include "SqStack.cpp"

bool isPalindromeList(LinkNode* head){
    /*第一步、與方法一一樣,找到中間結點*/
    if(head == NULL)
        return false;

    LinkNode *n1 = head , *n2 = head;
    while(n2->next != NULL && n2->next->next != NULL){
        n2 = n2->next->next;
        n1 = n1->next;
    }
    //如果沒有結點或者只有一個首結點
    if(n2 == head){
        return false;
    }
    //如果是奇數個結點
    if(n2->next != NULL){
        n1 = n1->next;  //n1 -> middle node
    }

    /*第二步、不使用額外空間,在連結串列自身上做文章:反轉連結串列後半部分結點的next指標*/
    n2 = n1->next;  // n2 -> right part first node
    n1->next = NULL;//middle node->next = NULL
    LinkNode *n3 = NULL;
    while (n2 != NULL) {
        n3 = n2->next;  //記錄下一個要反轉指標的結點
        n2->next = n1;  //反轉指標
        n1 = n2;
        n2 = n3;
    }
    //n1 -> end node
    n3 = n1;  //record end node
    n2 = head->next;
    while (n2 != NULL) {
        if (n2->data != n1->data) {
            return false;
        }
        n2 = n2->next;  //move n2 forward right
        n1 = n1->next;  //move n1 forward left
    }
    //recover the right part nodes
    n2 = n3; //n2 -> end node
    n1 = NULL;
    while (n2 != NULL) {
        n3 = n2->next;
        n2->next = n1;
        n1=n2;
        n2 = n3;
    }

    return true;
}


/*bool isPalindromeList(LinkNode* head){
    if(head == NULL)
        return false;

    LinkNode *slow = head , *fast = head;
    SqStack* stack;
    init(stack);

    //fast指標每走兩步,slow指標才走一步
    while(fast->next != NULL && fast->next->next != NULL){
        fast = fast->next->next;
        slow = slow->next;
        push(slow,stack);
    }

    //連結串列沒有結點或只有一個結點,不是迴文結構
    if(isEmpty(stack))
        return false;

    //判斷偶數個結點還是奇數個結點
    if(fast->next != NULL){	//奇數個結點,slow需要再走一步
        slow = slow->next;
    }

    //從slow的後繼結點開始遍歷連結串列,將每個結點與棧頂結點比較
    LinkNode* node;
    slow = slow->next;
    while(slow != NULL){
        pop(stack,node);
        //一旦發現有一個結點不同就不是迴文結構
        if(slow->data != node->data)
            return false;
        slow = slow->next;
    }
    return true;
}*/

int main(){

    LinkNode* head;
    init(head);
    add(2,head);
    add(3,head);
    add(3,head);
    add(1,head);
    printList(head);

    if(isPalindromeList(head)){
        printf("yes");
    }else{
        printf("no");
    }
    return 0;
}
複製程式碼

連結串列與荷蘭國旗問題

將單向連結串列按某值劃分成左邊小、中間相等、右邊大的形式

#include<stdio.h>
#include "LinkList.cpp"

/*
	partition一個連結串列有兩種做法。
	1,將連結串列中的所有結點放入一個陣列中,那麼就轉換成了荷蘭國旗問題,但這種做法會使用O(N)的額外空間;
	2,分出邏輯上的small,equal,big三個區域,遍歷連結串列結點將其新增到對應的區域中,最後再將這三個區域連起來。 
	這裡只示範第二種做法: 
*/
void partitionList(LinkNode *head,int val){
	if(head == NULL)
		return;
	LinkNode *smH = NULL;	//small area head node
	LinkNode *smT = NULL;	//small area tail node
	LinkNode *midH = NULL;	//equal area head node
	LinkNode *midT = NULL;	//equal area tail node
	LinkNode *bigH = NULL;	//big area head node
	LinkNode *bigT = NULL;	//big area tail node
	LinkNode *cur = head->next;	
	LinkNode *next = NULL;//next node need to be distributed to the three areas
	while(cur != NULL){
		next = cur->next;
		cur->next = NULL;
		if(cur->data > val){
			if(bigH == NULL){
				bigH = bigT = cur;
			}else{
				bigT->next = cur;
				bigT = cur;
			}
		}else if(cur->data == val){
			if(midH == NULL){
				midH = midT = cur;
			}else{
				midT->next = cur;
				midT = cur;
			}
		}else{
			if(smH == NULL){
				smH = smT = cur;
			}else{
				smT->next = cur;
				smT = cur;
			}
		}
		cur = next;
	}
	//reconnect small and equal
	if(smT != NULL){
		smT->next = midH;
		midT = midT == NULL ? midT : smT;
	}
	//reconnect equal and big
	if(bigT != NULL){
		midT->next = bigH;
	}

	head = smH != NULL ? smH : midH != NULL ? midH : bigH;

	return;
} 

int main(){
	LinkNode* head;
	init(head);
	add(5,head);
	add(2,head);
	add(7,head);
	add(9,head);
	add(1,head);
	add(3,head);
	add(5,head);
	printList(head);
	partitionList(head,5);
	printList(head);
}
複製程式碼

複製含有隨機指標結點的連結串列

藉助雜湊表,額外空間O(N)

將連結串列的所有結點複製一份,以key,value源結點,副本結點的方式儲存到雜湊表中,再建立副本結點之間的關係(next、rand指標域)

import java.util.HashMap;
import java.util.Map;

public class CopyLinkListWithRandom {

    public static class Node {
        public Node(int data) {
            this.data = data;
        }

        public Node() {
        }

        int data;
        Node next;
        Node rand;
    }

    public static Node copyLinkListWithRandom(Node head) {
        if (head == null) {
            return null;
        }
        Node cur = head;
        Map<Node, Node> copyMap = new HashMap<>();
        while (cur != null) {
            copyMap.put(cur, new Node(cur.data));
            cur = cur.next;
        }
        cur = head;
        while (cur != null) {
            copyMap.get(cur).next = copyMap.get(cur.next);
            copyMap.get(cur).rand = copyMap.get(cur.rand);
            cur = cur.next;
        }
        return copyMap.get(head);
    }

    public static void printListWithRandom(Node head) {
        if (head != null) {
            while (head.next != null) {
                head = head.next;
                System.out.print("node data:" + head.data);
                if (head.rand != null) {
                    System.out.println(",rand data:" + head.rand.data);
                } else {
                    System.out.println(",rand is null");
                }
            }
        }
    }
  
    public static void main(String[] args) {
        Node head = new Node();
        head.next = new Node(1);
        head.next.next = new Node(2);
        head.next.next.next = new Node(3);
        head.next.next.next.next = new Node(4);
        head.next.rand = head.next.next.next.next;
        head.next.next.rand = head.next.next.next;
        printListWithRandom(head);

        System.out.println("==========");

        Node copy = copyLinkListWithRandom(head);
        printListWithRandom(copy);
    }
}
複製程式碼

進階操作:額外空間O(1)

將副本結點追加到對應源結點之後,建立副本結點之間的指標域,最後將副本結點從該連結串列中分離出來。

//extra area O(1)
public static Node copyLinkListWithRandom2(Node head){
  if (head == null) {
    return null;
  }
  Node cur = head;
  //copy every node and append
  while (cur != null) {
    Node copy = new Node(cur.data);
    copy.next = cur.next;
    cur.next = copy;
    cur = cur.next.next;
  }
  //set the rand pointer of every copy node
  Node copyHead = head.next;
  cur = head;
  Node curCopy = copyHead;
  while (curCopy != null) {
    curCopy.rand = cur.rand == null ? null : cur.rand.next;
    cur = curCopy.next;
    curCopy = cur == null ? null : cur.next;
  }
  //split
  cur = head;
  Node next = null;
  while (cur != null) {
    curCopy = cur.next;
    next = cur.next.next;
    curCopy.next = next == null ? null : next.next;
    cur.next = next;
    cur = next;
  }
  return copyHead;
}
複製程式碼

若兩個可能有環的單連結串列相交,請返回相交的第一個結點

根據單連結串列的定義,每個結點有且只有一個next指標,那麼如果單連結串列有環,它的結構將是如下所示:

左神直通BAT演算法筆記(基礎篇)-上

相交會導致兩個結點指向同一個後繼結點,但不可能出現一個結點有兩個後繼結點的情況。

1、當相交的結點不在環上時,有如下兩種情況:

左神直通BAT演算法筆記(基礎篇)-上

2、當相交的結點在環上時,只有一種情況:

左神直通BAT演算法筆記(基礎篇)-上

綜上,兩單連結串列若相交,要麼都無環,要麼都有環。

此題還需要注意的一點是如果連結串列有環,那麼如何獲取入環呢(因為不能通過next是否為空來判斷是否是尾結點了)。這裡就涉及到了一個規律:如果快指標fast和慢指標slow同時從頭結點出發,fast走兩步而slow走一步,當兩者相遇時,將fast指標指向頭結點,使兩者都一次只走一步,兩者會在入環結點相遇。

public class FirstIntersectNode {
    public static class Node{
        int data;
        Node next;
        public Node(int data) {
            this.data = data;
        }
    }

    public static Node getLoopNode(Node head) {
        if (head == null) {
            return null;
        }
        Node fast = head;
        Node slow = head;
        do {
            slow = slow.next;
            if (fast.next == null || fast.next.next == null) {
                return null;
            } else {
                fast = fast.next.next;
            }
        } while (fast != slow);
        //fast == slow
        fast = head;
        while (fast != slow) {
            slow = slow.next;
            fast = fast.next;
        }
        return slow;
    }

    public static Node getFirstIntersectNode(Node head1, Node head2) {
        if (head1 == null || head2 == null) {
            return null;
        }
        Node loop1 = getLoopNode(head1);    //兩連結串列的入環結點loop1和loop2
        Node loop2 = getLoopNode(head2);
        //no loop
        if (loop1 == null && loop2 == null) {
            return noLoop(head1, head2);
        }
        //both loop
        if (loop1 != null && loop2 != null) {
            return bothLoop(head1, head2, loop1, loop2);
        }
        //don't intersect
        return null;
    }

    private static Node bothLoop(Node head1, Node head2, Node loop1, Node loop2) {
        Node cur1 = head1;
        Node cur2 = head2;
        //入環結點相同,相交點不在環上
        if (loop1 == loop2) {
            int n = 0;
            while (cur1.next != loop1) {
                n++;
                cur1 = cur1.next;
            }
            while (cur2.next != loop1) {
                n--;
                cur2 = cur2.next;
            }
            cur1 = n > 0 ? head1 : head2;           //將cur1指向結點數較多的連結串列
            cur2 = cur1 == head1 ? head2 : head1;   //將cur2指向另一個連結串列
            n = Math.abs(n);
            while (n != 0) {                        //將cur1先走兩連結串列結點數差值個結點
                cur1 = cur1.next;
                n--;
            }
            while (cur1 != cur2) {                  //cur1和cur2會在入環結點相遇
                cur1 = cur1.next;
                cur2 = cur2.next;
            }
            return cur1;
        }
        //入環結點不同,相交點在環上
        cur1 = loop1.next;
        while (cur1 != loop1) {
            if (cur1 == loop2) {    //連結串列2的入環結點在連結串列1的環上,說明相交
                return loop1;   //返回loop1或loop2均可,因為整個環就是兩連結串列的相交部分
            }
            cur1 = cur1.next;
        }
        //在連結串列1的環上轉了一圈也沒有找到連結串列2的入環結點,說明不想交
        return null;
    }

    private static Node noLoop(Node head1, Node head2) {
        Node cur1 = head1;
        Node cur2 = head2;
        int n = 0;
        while (cur1.next != null) {
            n++;
            cur1 = cur1.next;
        }
        while (cur2.next != null) {
            n--;
            cur2 = cur2.next;
        }
        if (cur1 != cur2) {     //兩連結串列的尾結點不同,不可能相交
            return null;
        }
        cur1 = n > 0 ? head1 : head2;           //將cur1指向結點數較多的連結串列
        cur2 = cur1 == head1 ? head2 : head1;   //將cur2指向另一個連結串列
        n = Math.abs(n);
        while (n != 0) {                        //將cur1先走兩連結串列結點數差值個結點
            cur1 = cur1.next;
            n--;
        }
        while (cur1 != cur2) {                  //cur1和cur2會在入環結點相遇
            cur1 = cur1.next;
            cur2 = cur2.next;
        }
        return cur1;
    }

    public static void printList(Node head) {
        for (int i = 0; i < 50; i++) {
            System.out.print(head.data+" ");
            head = head.next;
        }
        System.out.println();
    }

}
複製程式碼

對應三種情況測試如下:

左神直通BAT演算法筆記(基礎篇)-上

public static void main(String[] args) {

        //==================== both loop ======================
        //1->2->[3]->4->5->6->7->[3]...
        Node head1 = new Node(1);
        head1.next = new Node(2);
        head1.next.next = new Node(3);
        head1.next.next.next = new Node(4);
        head1.next.next.next.next = new Node(5);
        head1.next.next.next.next.next = new Node(6);
        head1.next.next.next.next.next.next = new Node(7);
        head1.next.next.next.next.next.next.next = head1.next.next;

        //9->8->[6]->7->3->4->5->[6]...
        Node head2 = new Node(9);
        head2.next = new Node(8);
        head2.next.next = head1.next.next.next.next.next;
        head2.next.next.next = head1.next.next.next.next.next.next;
        head2.next.next.next.next = head1.next.next;
        head2.next.next.next.next.next = head1.next.next.next;
        head2.next.next.next.next.next.next = head1.next.next.next.next;
        head2.next.next.next.next.next.next.next = head1.next.next.next.next.next;

        printList(head1);
        printList(head2);
        System.out.println(getFirstIntersectNode(head1, head2).data);
        System.out.println("==================");

        //1->[2]->3->4->5->6->7->8->4...
        Node head3 = new Node(1);
        head3.next = new Node(2);
        head3.next.next = new Node(3);
        head3.next.next.next = new Node(4);
        head3.next.next.next.next = new Node(5);
        head3.next.next.next.next.next = new Node(6);
        head3.next.next.next.next.next.next = new Node(7);
        head3.next.next.next.next.next.next.next = new Node(8);
        head3.next.next.next.next.next.next.next.next = head1.next.next.next;

        //9->0->[2]->3->4->5->6->7->8->4...
        Node head4 = new Node(9);
        head4.next = new Node(0);
        head4.next.next = head3.next;
        head4.next.next.next = head3.next.next;
        head4.next.next.next.next = head3.next.next.next;
        head4.next.next.next.next.next = head3.next.next.next.next;
        head4.next.next.next.next.next.next = head3.next.next.next.next.next;
        head4.next.next.next.next.next.next.next = head3.next.next.next.next.next.next;
        head4.next.next.next.next.next.next.next.next = head3.next.next.next.next.next.next.next;
        head4.next.next.next.next.next.next.next.next.next = head3.next.next.next;

        printList(head3);
        printList(head4);
        System.out.println(getFirstIntersectNode(head3,head4).data);
        System.out.println("==================");

        //============= no loop ==============
        //1->[2]->3->4->5
        Node head5 = new Node(1);
        head5.next = new Node(2);
        head5.next.next = new Node(3);
        head5.next.next.next = new Node(4);
        head5.next.next.next.next = new Node(5);
        //6->[2]->3->4->5
        Node head6 = new Node(6);
        head6.next = head5.next;
        head6.next.next = head5.next.next;
        head6.next.next.next = head5.next.next.next;
        head6.next.next.next.next = head5.next.next.next.next;

        System.out.println(getFirstIntersectNode(head5,head6).data);
    }
複製程式碼

棧和佇列

用陣列結構實現大小固定的棧和佇列

//
// Created by zaw on 2018/10/21.
//
#include <stdio.h>
#include <malloc.h>
#define MAX_SIZE 1000

struct ArrayStack{
    int data[MAX_SIZE];
    int top;
};

void init(ArrayStack *&stack) {
    stack = (ArrayStack *) malloc(sizeof(ArrayStack));
    stack->top = -1;
}

bool isEmpty(ArrayStack* stack){
    return stack->top == -1 ?;
}

bool isFull(ArrayStack *stack){
    return stack->top == MAX_SIZE - 1 ?;
}

void push(int i, ArrayStack *stack){
    if (!isFull(stack)) {
        stack->data[++stack->top] = i;
    }
}

int pop(ArrayStack* stack){
    if (!isEmpty(stack)) {
        return stack->data[stack->top--];
    }
}

int getTopElement(ArrayStack *stack){
    if (!isEmpty(stack)) {
        return stack->data[stack->top];
    }
}

int main(){

    ArrayStack* stack;
    init(stack);
    push(1, stack);
    push(2, stack);
    push(3, stack);

    printf("%d ", pop(stack));
    printf("%d ", getTopElement(stack));
    printf("%d ", pop(stack));
    printf("%d ", pop(stack));
		//3 2 2 1
    return 0;
}
複製程式碼
//
// Created by zaw on 2018/10/21.
//
#include <stdio.h>
#include <malloc.h>
#define MAX_SIZE 1000

//陣列結構實現的環形佇列
struct ArrayCircleQueue{
    int data[MAX_SIZE];
    int front,rear;
};

void init(ArrayCircleQueue *&queue){
    queue = (ArrayCircleQueue *) malloc(sizeof(ArrayCircleQueue));
    queue->front = queue->rear = 0;
}

bool isEmpty(ArrayCircleQueue *queue){
    return queue->front == queue->rear;
}

bool isFull(ArrayCircleQueue *queue){
    return (queue->rear+1)%MAX_SIZE==queue->front;
}

void enQueue(int i, ArrayCircleQueue *queue){
    if (!isFull(queue)) {
        //move the rear and fill it
        queue->data[++queue->rear] = i;
    }
}

int deQueue(ArrayCircleQueue *queue){
    if (!isEmpty(queue)) {
        return queue->data[++queue->front];
    }
}

int main(){
    ArrayCircleQueue* queue;
    init(queue);
    enQueue(1, queue);
    enQueue(2, queue);
    enQueue(3, queue);
    while (!isEmpty(queue)) {
        printf("%d ", deQueue(queue));
    }
}
複製程式碼

取棧中最小元素

實現一個特殊的棧,在實現棧的基本功能的基礎上,再實現返回棧中最小元素的操作getMin。要求如下:

  • poppushgetMin操作的時間複雜度都是O(1)
  • 設計的棧型別可以使用現成的棧結構。

思路:由於每次push之後都會可能導致棧中已有元素的最小值發生變化,因此需要一個容器與該棧聯動(記錄每次push產生的棧中最小值)。我們可以藉助一個輔助棧,資料棧push第一個元素時,將其也push到輔助棧,此後每次向資料棧push元素的同時將其和輔助棧的棧頂元素比較,如果小,則將其也push到輔助棧,否則取輔助棧的棧頂元素push到輔助棧。(資料棧正常pushpop資料,而輔助棧push每次資料棧push後產生的棧中最小值;但資料棧pop時,輔助棧也只需簡單的pop即可,保持同步)

//
// Created by zaw on 2018/10/21.
//
#include <stdio.h>
#include <malloc.h>
#include "ArrayStack.cpp"

int min(int a, int b){
    return a < b ? a : b;
}

struct GetMinStack{
    ArrayStack* dataStack;
    ArrayStack* helpStack;
};

void initGetMinStack(GetMinStack* &stack){
    stack = (GetMinStack *) malloc(sizeof(GetMinStack));
    init(stack->dataStack);
    init(stack->helpStack);
}

void push(int i, GetMinStack *stack) {
    if (!isFull(stack->dataStack)) {
        push(i, stack->dataStack);  //ArrayStack.cpp
        if (!isEmpty(stack->helpStack)) {
            i = min(i, getTopElement(stack->helpStack));
        }
        push(i, stack->helpStack);
    }
}

int pop(GetMinStack* stack){
    if (!isEmpty(stack->dataStack)) {
        pop(stack->helpStack);
        return pop(stack->dataStack);
    }
}

int getMin(GetMinStack *stack){
    if (!isEmpty(stack->dataStack)) {
        return getTopElement(stack->helpStack);
    }
}

int main(){
    GetMinStack *stack;
    initGetMinStack(stack);
    push(6, stack);
    printf("%d ", getMin(stack));//6
    push(3, stack);
    printf("%d ", getMin(stack));//3
    push(1, stack);
    printf("%d ", getMin(stack));//1
  	pop(stack);
    printf("%d ", getMin(stack));//3

    return 0;
}
複製程式碼

僅用佇列結構實現棧結構

思路:只要將關注點放在 後進先出 這個特性就不難實現了。使用一個資料佇列和輔助佇列,當放入資料時使用佇列的操作正常向資料佇列中放,但出隊元素時,需將資料佇列的前n-1個數入隊輔助佇列,而將資料佇列的隊尾元素彈出來,最後資料佇列和輔助佇列交換角色。

//
// Created by zaw on 2018/10/21.
//
#include <stdio.h>
#include <malloc.h>
#include "../queue/ArrayCircleQueue.cpp"

struct DoubleQueueStack{
    ArrayCircleQueue* dataQ;
    ArrayCircleQueue* helpQ;
};

void init(DoubleQueueStack* &stack){
    stack = (DoubleQueueStack *) malloc(sizeof(DoubleQueueStack));
    init(stack->dataQ);
    init(stack->helpQ);
}

void swap(ArrayCircleQueue *&dataQ, ArrayCircleQueue *&helpQ){
    ArrayCircleQueue* temp = dataQ;
    dataQ = helpQ;
    helpQ = temp;
}

void push(int i,DoubleQueueStack* stack){
    if (!isFull(stack->dataQ)) {
        return enQueue(i, stack->dataQ);
    }
}

int pop(DoubleQueueStack* stack){
    if (!isEmpty(stack->dataQ)) {
        int i = deQueue(stack->dataQ);
        while (!isEmpty(stack->dataQ)) {
            enQueue(i, stack->helpQ);
            i = deQueue(stack->dataQ);
        }
        swap(stack->dataQ, stack->helpQ);
        return i;
    }
}

bool isEmpty(DoubleQueueStack* stack){
    return isEmpty(stack->dataQ);
}

int getTopElement(DoubleQueueStack* stack){
    if (!isEmpty(stack->dataQ)) {
        int i = deQueue(stack->dataQ);
        while (!isEmpty(stack->dataQ)) {
            enQueue(i, stack->helpQ);
            i = deQueue(stack->dataQ);
        }
        enQueue(i, stack->helpQ);
        swap(stack->dataQ, stack->helpQ);
        return i;
    }
}

int main(){

    DoubleQueueStack *stack;
    init(stack);
    push(1, stack);
    push(2, stack);
    push(3, stack);
    while (!isEmpty(stack)) {
        printf("%d ", pop(stack));
    }
    push(4, stack);
    printf("%d ", getTopElement(stack));
    
    return 0;
}
複製程式碼

僅用棧結構實現佇列結構

思路:使用兩個棧,一個棧PutStack用來放資料,一個棧GetStack用來取資料。取資料時,如果PulllStack為空則需要將PutStack中的所有元素一次性依次pop並放入GetStack

特別要注意的是這個 倒資料的時機:

  • 只有當GetStack為空時才能往裡倒
  • 倒資料時必須一次性將PutStack中的資料倒完
//
// Created by zaw on 2018/10/21.
//
#include <stdio.h>
#include <malloc.h>
#include "../stack/ArrayStack.cpp"

struct DoubleStackQueue{
    ArrayStack* putStack;
    ArrayStack* getStack;
};

void init(DoubleStackQueue *&queue){
    queue = (DoubleStackQueue *) malloc(sizeof(DoubleStackQueue));
    init(queue->putStack);
    init(queue->getStack);
}

bool isEmpty(DoubleStackQueue *queue){
    return isEmpty(queue->getStack) && isEmpty(queue->putStack);
}

void pour(ArrayStack *stack1, ArrayStack *stack2){
    while (!isEmpty(stack1)) {
        push(pop(stack1), stack2);
    }
}

void enQueue(int i, DoubleStackQueue *queue){
    if (!isFull(queue->putStack)) {
        push(i, queue->putStack);
    } else {
        if (isEmpty(queue->getStack)) {
            pour(queue->putStack, queue->getStack);
            push(i, queue->putStack);
        }
    }
}

int deQueue(DoubleStackQueue* queue){
    if (!isEmpty(queue->getStack)) {
        return pop(queue->getStack);
    } else {
        if (!isEmpty(queue->putStack)) {
            pour(queue->putStack, queue->getStack);
            return pop(queue->getStack);
        }
    }
}


int main(){
    DoubleStackQueue *queue;
    init(queue);
    enQueue(1, queue);
    printf("%d\n", deQueue(queue));
    enQueue(2, queue);
    enQueue(3, queue);
    while (!isEmpty(queue)) {
        printf("%d ", deQueue(queue));
    }
    return 0;
}
複製程式碼

相關文章