算法學習筆記之遞歸排序與查找

算法學習筆記之遞歸排序與查找

算法學習筆記系列,這篇主要總結匯總一下基礎算法中的遞歸,排序,查找。是接上一篇關於基礎數據結構的《算法學習筆記之複雜度分析與線性表》

1. 遞歸

遞歸需要滿足的三個條件,1. 一個問題的解可以分解爲幾個子問題的解。2. 這個問題與分解之後的子問題,除了數據規模不同,求解思路完全一樣。3. 存在遞歸終止條件。寫遞歸代碼的關鍵就是找到如何將大問題分解爲小問題的規律,並且基於此寫出遞推公式,然後再推敲終止條件,最後將遞推公式和終止條件翻譯成代碼。編寫遞歸代碼的關鍵是,只要遇到遞歸,我們就把它抽象成一個遞推公式,不用想一層層的調用關係,不要試圖用人腦去分解遞歸的每個步驟。

  • 注意事項:
  • 遞歸代碼要警惕堆棧溢出
  • 遞歸代碼要警惕重複計算
    爲了避免重複計算,我們可以通過一個數據結構(比如散列表)來保存已經求解過的 f(k)。當遞歸調用到 f(k) 時,先看下是否已經求解過了。如果是,則直接從散列表中取值返回,不需要重複計算

public int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  
  // hasSolvedList可以理解成一個Map,key是n,value是f(n)
  if (hasSolvedList.containsKey(n)) {
    return hasSolvedList.get(n);
  }
  
  int ret = f(n-1) + f(n-2);
  hasSolvedList.put(n, ret);
  return ret;
}

因爲遞歸本身就是藉助棧來實現的,只不過我們使用的棧是系統或者虛擬機本身提供的,我們沒有感知罷了。如果我們自己在內存堆上實現棧,手動模擬入棧、出棧過程,這樣任何遞歸代碼都可以改寫成看上去不是遞歸代碼的樣子。

2. 排序

排序算法最常用的:冒泡排序、插入排序、選擇排序、歸併排序、快速排序、計數排序、基數排序、桶排序。

2.1 冒泡排序(Bubble Sort)

冒泡排序只會操作相鄰的兩個數據。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個數據的排序工作。當某次冒泡操作已經沒有數據交換時,說明已經達到完全有序,不用再繼續執行後續的冒泡操作。

  • 冒泡的過程只涉及相鄰數據的交換操作,只需要常量級的臨時空間,所以它的空間複雜度爲 O(1),是一個原地排序算法。
  • 在冒泡排序中,只有交換纔可以改變兩個元素的前後順序。爲了保證冒泡排序算法的穩定性,當有相鄰的兩個元素大小相等的時候,我們不做交換,相同大小的數據在排序前後不會改變順序,所以冒泡排序是穩定的排序算法。

// 冒泡排序,a表示數組,n表示數組大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;
 
 for (int i = 0; i < n; ++i) {
    // 提前退出冒泡循環的標誌位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交換
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有數據交換      
      }
    }
    if (!flag) break;  // 沒有數據交換,提前退出
  }
}
2.2 插入排序(Insertion Sort)

將數組中的數據分爲兩個區間,已排序區間和未排序區間。初始已排序區間只有一個元素,就是數組的第一個元素。插入算法的核心思想是取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間數據一直有序。重複這個過程,直到未排序區間中元素爲空,算法結束。
插入排序也包含兩種操作,一種是元素的比較,一種是元素的移動。當我們需要將一個數據 a 插入到已排序區間時,需要拿 a 與已排序區間的元素依次比較大小,找到合適的插入位置。找到插入點之後,我們還需要將插入點之後的元素順序往後移動一位,這樣才能騰出位置給元素 a 插入。

// 插入排序,a表示數組,n表示數組大小
public void insertionSort(int[] a, int n) {
  if (n <= 1) return;

  for (int i = 1; i < n; ++i) {
    int value = a[i];
    int j = i - 1;
    // 查找插入的位置
    for (; j >= 0; --j) {
      if (a[j] > value) {
        a[j+1] = a[j];  // 數據移動
      } else {
        break;
      }
    }
    a[j+1] = value; // 插入數據
  }
}
2.3 選擇排序(Selection Sort)

選擇排序算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。選擇排序是一種不穩定的排序算法。選擇排序每次都要找剩餘未排序元素中的最小值,並和前面的元素交換位置,這樣破壞了穩定性。

2.4 歸併排序(Merge Sort)

如果要排序一個數組,我們先把數組從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合併在一起,這樣整個數組就都有序了。歸併排序是一個穩定的排序算法。歸併排序的時間複雜度是 O(nlogn)。歸併排序不是原地排序算法。

2.5 快速排序算法(Quicksort)

如果要排序數組中下標從 p 到 r 之間的一組數據,我們選擇 p 到 r 之間的任意一個數據作爲 pivot(分區點)。我們遍歷 p 到 r 之間的數據,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。經過這一步驟之後,數組 p 到 r 之間的數據就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。
根據分治、遞歸的處理思想,我們可以用遞歸排序下標從 p 到 q-1 之間的數據和下標從 q+1 到 r 之間的數據,直到區間縮小爲 1,就說明所有的數據都有序了。
快速排序並不是一個穩定的排序算法。
歸併排序的處理過程是由下到上的,先處理子問題,然後再合併。而快排正好相反,它的處理過程是由上到下的,先分區,然後再處理子問題。歸併排序雖然是穩定的、時間複雜度爲 O(nlogn) 的排序算法,但是它是非原地排序算法。我們前面講過,歸併之所以是非原地排序算法,主要原因是合併函數無法在原地執行。快速排序通過設計巧妙的原地分區函數,可以實現原地排序,解決了歸併排序佔用太多內存的問題。
快排是一種原地、不穩定的排序算法

2.6 桶排序(Bucket sort)

三種時間複雜度是 O(n) 的排序算法:桶排序、計數排序、基數排序。因爲這些排序算法的時間複雜度是線性的,所以我們把這類排序算法叫作線性排序(Linear sort)。之所以能做到線性的時間複雜度,主要原因是,這三個算法是非基於比較的排序算法,都不涉及元素之間的比較操作。

桶排序,顧名思義,會用到“桶”,核心思想是將要排序的數據分到幾個有序的桶裏,每個桶裏的數據再單獨進行排序。桶內排完序之後,再把每個桶裏的數據按照順序依次取出,組成的序列就是有序的了。
如果要排序的數據有 n 個,我們把它們均勻地劃分到 m 個桶內,每個桶裏就有 k=n/m 個元素。每個桶內部使用快速排序,時間複雜度爲 O(k * logk)。m 個桶排序的時間複雜度就是 O(m * k * logk),因爲 k=n/m,所以整個桶排序的時間複雜度就是 O(n*log(n/m))。當桶的個數 m 接近數據個數 n 時,log(n/m) 就是一個非常小的常量,這個時候桶排序的時間複雜度接近 O(n)。

2.7 計數排序(Counting sort)

當要排序的 n 個數據,所處的範圍並不大的時候,比如最大值是 k,我們就可以把數據劃分成 k 個桶。每個桶內的數據值都是相同的,省掉了桶內排序的時間。


// 計數排序,a是數組,n是數組大小。假設數組中存儲的都是非負整數。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;

  // 查找數組中數據的範圍
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }

  int[] c = new int[max + 1]; // 申請一個計數數組c,下標大小[0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }

  // 計算每個元素的個數,放入c中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }

  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }

  // 臨時數組r,存儲排序之後的結果
  int[] r = new int[n];
  // 計算排序的關鍵步驟,有點難理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }

  // 將結果拷貝給a數組
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

計數排序只能用在數據範圍不大的場景中,如果數據範圍 k 比要排序的數據 n 大很多,就不適合用計數排序了。而且,計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化爲非負整數。

2.8 基數排序(Radix sort)

基數排序對要排序的數據是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關係,如果 a 數據的高位比 b 數據大,那剩下的低位就不用比較了。除此之外,每一位的數據範圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間複雜度就無法做到 O(n) 了。

2.9 排序算法總結

總結之前的基礎排序算法如下:

排序算法 時間複雜度 是否穩定排序 是否原地排序
冒泡排序 O(n2)O_{(n^2)}
插入排序 O(n2)O_{(n^2)}
選擇排序 O(n2)O_{(n^2)}
快速排序 O(nlogn)O_{(nlog_n)}
歸併排序 O(nlogn)O_{(nlog_n)}
計數排序 O(n+k)kO_{(n+k)},k是數據範圍
桶排序 O(n)O_{(n)}
基數排序 O(dn)O_{(dn)},d是維度
  • Glibc 中的 qsort() 函數
    qsort() 會優先使用歸併排序來排序輸入數據,要排序的數據量比較大的時候,qsort() 會改爲用快速排序算法來排序。
    qsort() 選擇分區點的方法是“三數取中法”,即,從區間的首、尾、中間,分別取出一個數,然後對比大小,取這 3 個數的中間值作爲分區點。這樣每間隔某個固定的長度,取數據出來比較,將中間值作爲分區點的分區算法。
    qsort() 並不僅僅用到了歸併排序和快速排序,它還用到了插入排序。在快速排序的過程中,當要排序的區間中,元素的個數小於等於 4 時,qsort() 就退化爲插入排序,不再繼續用遞歸來做快速排序

3. 二分查找

二分思想,每次都與區間的中間數據比對大小,縮小查找區間的範圍。
二分查找針對的是一個有序的數據集合,查找思想有點類似分治思想。每次都通過跟區間的中間元素對比,將待查找的區間縮小爲之前的一半,直到找到要查找的元素,或者區間被縮小爲 0。
時間複雜度爲 O(logn)。O(logn) :對數時間複雜度。這是一種極其高效的時間複雜度,有的時候甚至比時間複雜度是常量級 O(1) 的算法還要高效。

3.1 簡單的二分查找
  • 最簡單的情況就是有序數組中不存在重複元素,我們在其中用二分查找值等於給定值的數據。

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;

  while (low <= high) {
    int mid = (low + high) / 2;
    if (a[mid] == value) {
      return mid;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }

  return -1;
}
  • 用遞歸來實現

// 二分查找的遞歸實現
public int bsearch(int[] a, int n, int val) {
  return bsearchInternally(a, 0, n - 1, val);
}

private int bsearchInternally(int[] a, int low, int high, int value) {
  if (low > high) return -1;

  int mid =  low + ((high - low) >> 1);
  if (a[mid] == value) {
    return mid;
  } else if (a[mid] < value) {
    return bsearchInternally(a, mid+1, high, value);
  } else {
    return bsearchInternally(a, low, mid-1, value);
  }
}
  • 應用場景的侷限性
  1. 二分查找依賴的是順序表結構,簡單點說就是數組
  2. 二分查找針對的是有序數據
  3. 數據量太小不適合二分查找
  4. 數據量太大也不適合二分查找
3.2 查找第一個值等於給定值的元素

有序數據集合中存在重複的數據,以數據是從小到大排列爲前提。


public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == 0) || (a[mid - 1] != value)) return mid;
      else high = mid - 1;
    }
  }
  return -1;
}
3.3 查找最後一個值等於給定值的元素

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}
3.4 查找第一個大於等於給定值的元素

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}
3.5 查找最後一個小於等於給定值的元素

public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

4. 跳錶

鏈表加多級索引的結構,就是跳錶。跳錶使用空間換時間的設計思路,通過構建多級索引來提高查詢的效率,實現了基於鏈表的“二分查找”。跳錶是一種動態數據結構,支持快速的插入、刪除、查找操作,時間複雜度都是 O(logn)。跳錶的空間複雜度是 O(n)。不過,跳錶的實現非常靈活,可以通過改變索引構建策略,有效平衡執行效率和內存消耗。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章