關于我的倉庫
- 這篇文章是我為面試準備的學習總結中的一篇
- 我將準備面試中找到的所有學習資料,寫的Demo,寫的博客都放在了這個倉庫里iOS-Engineer-Interview
- 歡迎star????
- 其中的博客在簡書,CSDN都有發布
- 博客中提到的相關的代碼Demo可以在倉庫里相應的文件夾里找到
前言
- 該系列為學習《數據結構與算法之美》的系列學習筆記
- 總結規律為一周一更,內容包括其中的重要知識帶你,以及課后題的解答
- 算法的學習學與刷題并進,希望能真正養成解算法題的思維
- LeetCode刷題倉庫:LeetCode-All-In
- 多說無益,你應該開始打代碼了
11講排序(上):為什么插入排序比冒泡排序更受歡迎
- 開始進入排序章節了,專注于會,懂,好吧
- 敲之前我回我回,敲不出來我的我的
- GOGOGO
如何比較排序算法
-
執行效率
- 最好,最壞,平均時間復雜度
- 這樣可以看出對于有序度比較高/低的測試數據效果如何
-
內存損耗
- 以空間復雜度衡量
- 這里顯然原地排序算法比較屌,就是空間復雜度為O(1)的算法
-
穩定性
- 如果待排序的序列中存在值相等的元素,經過排序之后,相等元素之間原有的先后順序不變
- 不變就是穩定排序算法,變就是穩定排序算法
- 這里對于數字看起來沒有什么意義,但是如果我們比較的是一個對象,就可以在比較A的基礎上,保證B的順序不變
- 比如我們希望實現按金額排序訂單,對于金額相同的訂單又希望下單時間從早到晚有序
- 我們的做法其實就是先對下單時間排序,再對金額穩定排序:
img
冒泡排序(Bubble Sort)
- 冒泡就是對于相鄰元素做比較,如果順序不對就進行交換
原理
- 一次冒泡的詳細過程:
- 完成排序就只要進行六次這樣的操作:
- 進行優化就是,假如有一次沒有任何交換,說明已經有序,可以終止排序了
代碼
void bubbleSort(vector<int> &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
for (int i = 0; i < arrLen; i++) {
bool flag = false;
for (int j = 0; j < arrLen - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
flag = true;
}
}
if (!flag) {
break;
}
}
return ;
}
特點分析
- 原地排序算法
- 由于我們設定了當相鄰兩個元素大小相等的時候,不做交換,所以冒泡是穩定的
- 時間復雜度為O(n2)
插入排序(Insertion Sort)
- 插入排序就是將待排序區間的插入到已排序區間即可
原理
- 將數據區域分成已排序區間和未排序區間,初始已排序區間只有一個元素就是第一個元素
代碼
void insertionSort(vector<int> &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
for (int i = 1; i < arrLen; i++) {
int value = arr[i];
int j = i - 1;
for (; j >= 0; j--) {
if (arr[j] > value) {
arr[j + 1] = arr[j];
} else {
break;
}
}
arr[j + 1] = value;
}
}
特點分析
- 原地排序算法
- 我們可以選擇將后面出現的元素,插入到前面的出現的元素后面【對于相同的元素】,所以是穩定的
- 時間復雜度為O(n2)
選擇排序(Selection Sort)
- 選擇排序本質就是從未排序區間中找到最小的元素,放到已排序區間的末尾
原理
- 將數據區域分成已排序區間和未排序區間,初始已排序區間只有一個元素就是第一個元素
代碼
void selectionSort(vector<int> &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
for (int i = 0; i < arrLen; i++) {
int minNum = arr[i];
int minIndex = i;
for (int j = i; j < arrLen; j++) {
if (minNum > arr[j]) {
minNum = arr[j];
minIndex = j;
}
}
swap(arr[i], arr[minIndex]);
}
}
特點分析
- 原地排序算法
- 比如5,8,5,2,9這樣一組數據,使用選擇排序算法來排序的話,第一次找到最小元素2,與第一個5交換位置,那第一個5和中間的5順序就變了,所以就不穩定了。正是因此,相對于冒泡排序和插入排序,選擇排序就稍微遜色了?!静环€定】
- 時間復雜度為O(n2)
希爾排序(Shell Sort)
- 將需要排序的序列劃分為若干個較小的序列,對這些序列進行直接插入排序,通過這樣的操作可使需要排序的數列基本有序,最后再使用一次直接插入排序
原理
- 在希爾排序中首先要解決的是怎樣劃分序列,對于子序列的構成不是簡單地分段,而是采取將相隔某個增量的數據組成一個序列。一般選擇增量的規則是:取上一個增量的一半作為此次子序列劃分的增量,一般初始值元素的總數量
代碼
void shellSort(vector<int> &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
int d = arrLen / 2;
int x, j, k = 1;
while (d >= 1) {
for (int i = d; i < arrLen; i++) {
x = arr[i];
j = i - d;
// 直接插入排序,會向前找所適合的位置
while (j >= 0 && arr[j] > x) {
// 交換位置
arr[j + d] = arr[j];
j = j - d;
}
arr[j + d] = x;
}
d = d / 2;
}
}
特點分析
- 原地排序算法
- 不穩定
- 時間復雜度為O n的3/2次【比log(n)快】
總結
- 在真正地使用中,我們傾向于使用插入排序,因為不涉及交換,操作次數少,雖然它的時間復雜度和冒泡一樣,而選擇排序更是弟中弟
課后題:我們今天講的幾種排序算法,都是基于數組實現的。如果數據存儲在鏈表中,這三種排序算法還能工作嗎?如果能,那相應的時間、空間復雜度又是多少呢?
- 對于老師所提課后題,覺得應該有個前提,是否允許修改鏈表的節點value值,還是只能改變節點的位置。一般而言,考慮只能改變節點位置,冒泡排序相比于數組實現,比較次數一致,但交換時操作更復雜;插入排序,比較次數一致,不需要再有后移操作,找到位置后可以直接插入,但排序完畢后可能需要倒置鏈表;選擇排序比較次數一致,交換操作同樣比較麻煩。綜上,時間復雜度和空間復雜度并無明顯變化,若追求極致性能,冒泡排序的時間復雜度系數會變大,插入排序系數會減小,選擇排序無明顯變化。
12講排序(下):如何用快排思想在O(n)內查找第K大元素
歸并排序(Merge Sort)
- 如果要排序一個數組,我們先把數組從中間分成前后兩部分,然后對前后兩部分分別排序,再將排好序的兩部分合并在一起,這樣整個數組就都有序了。
原理
- 先看一次分解圖
這個的關鍵將在于merge函數,也就是將兩個已經有序的子數組合并到一起應該怎么做
這里其實就和我們進行鏈表的插入一樣,兩個子數組同時遍歷,比較,將小的跟在大的后面,這是這里我們不再是只要進行節點指向就可以解決問題了,而是需要使用輔助數組,在輔助數組里進行插入,在最后給原數組進行賦值
代碼
void merge(vector<int> &arr, int l, int mid, int r) {
int help[r - l + 1];
int lIndex = l;
int rIndex = mid + 1;
int i = 0;
while (lIndex <= mid && rIndex <= r) {
help[i++] = arr[lIndex] < arr[rIndex] ? arr[lIndex++] : arr[rIndex++];
}
while (lIndex <= mid) {
help[i++] = arr[lIndex++];
}
while (rIndex <= r) {
help[i++] = arr[rIndex++];
}
for (i = 0; i < r - l + 1; i++) {
arr[l + i] = help[i];
}
}
static void mergeSort(vector<int> &arr, int l, int r) {
if (l == r) {
return;
}
int mid = (l + r) / 2;
mergeSort(arr, l, mid);
mergeSort(arr, mid + 1, r);
merge(arr, l, mid, r);
}
void mergeSort(vector<int> &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
mergeSort(arr, 0, arrLen - 1);
}
特點分析
- 非原地算法
- 在merge時,遇到相同的元素,我們可以保證先把前一個數組里的數據放入,這樣就保證了不會錯位,所以是穩定的
- 時間:O(nlogn) 空間:O(n)
快速排序(Quick Sort)
原理
- 如果要排序數組中下標從p到r之間的一組數據,我們選擇p到r之間的任意一個數據作為pivot(分區點)
- 我們遍歷p到r之間的數據,將小于pivot的放到左邊,將大于pivot的放到右邊,將pivot放到中間。經過這一步驟之后,數組p到r之間的數據就被分成了三個部分,前面p到q-1之間都是小于pivot的,中間是pivot,后面的q+1到r之間是大于pivot的
- 這里有個partition分區函數,就和上面的merge一樣需要一波理解,我們需要把所有比游標小的數字放在左邊,把比游標大的數字放在右邊,返回游標的下標
- 具體操作如圖:
代碼
int partition(vector<int> &arr, int p, int r) {
int pivot = arr[r];
int i = p;
for (int j = p; j < r; j++) {
if (arr[j] < pivot) {
swap(arr[i], arr[j]);
i++;
}
}
swap(arr[i], arr[r]);
return i;
}
static void quickSort(vector<int> &arr, int p, int r) {
if (p >= r) {
return;
}
int q = partition(arr, p, r);
quickSort(arr, p, q - 1);
quickSort(arr, q + 1, r);
}
void quickSort(vector<int> &arr) {
int arrLen = arr.size();
if (arrLen == 0) {
return ;
}
quickSort(arr, 0, arrLen - 1);
}
特點分析
- 原地算法
- 不穩定算法
- 時間:O(nlogn)
快速排序與歸并排序的異同
- 兩種排序很大一個區別就是歸并排序是從下到上的,快速排序是從上到下的
如何用快排思想在O(n)內查找第K大元素
- 這個問題其實就是在快排的partition就可以找到,每次選擇區分點后,把小的放前面,大的放后面
- 這樣我們可以判斷出我們要找的數字所在區間是哪一個,對其繼續劃分
實際題目LeetCode 215 數組中的第K個最大元素
在未排序的數組中找到第 k 個最大的元素。請注意,你需要找的是數組排序后的第 k 個最大的元素,而不是第 k 個不同的元素。
示例 1:
輸入: [3,2,1,5,6,4] 和 k = 2
輸出: 5
示例 2:
輸入: [3,2,3,1,2,4,5,5,6] 和 k = 4
輸出: 4
說明:
你可以假設 k 總是有效的,且 1 ≤ k ≤ 數組的長度。
題解
class Solution {
public:
int partition(vector<int> &nums, int p, int r) {
if (p == r) {
return p;
}
int pivot = nums[r];
int i = p;
for (int j = p; j < r; j++) {
if (nums[j] > pivot) {
swap(nums[i], nums[j]);
i++;
}
}
swap(nums[i], nums[r]);
return i;
}
int findKthLargest(vector<int>& nums, int k) {
int len = nums.size();
if (len == 0 || len < k) {
return 0;
}
int res = 0;
int p = 0;
int r = len;
while (1) {
int index = partition(nums, p, r - 1);
// res += index;
if ((index + 1) == k) {
return nums[index];
} else if ((index + 1) > k) {
r = index;
} else {
p = index + 1;
}
}
return -99;
}
};
課后題:現在你有10個接口訪問日志文件,每個日志文件大小約300MB,每個文件里的日志都是按照時間戳從小到大排序的。你希望將這10個較小的日志文件,合并為1個日志文件,合并之后的日志仍然按照時間戳從小到大排列。如果處理上述排序任務的機器內存只有1GB,你有什么好的解決思路,能“快速”地將這10個日志文件合并嗎?
- 每次從各個文件中取一條數據,在內存中根據數據時間戳構建一個最小堆,然后每次把最小值給寫入新文件,同時將最小值來自的那個文件再出來一個數據,加入到最小堆中。這個空間復雜度為常數,但沒能很好利用1g內存,而且磁盤單個讀取比較慢,所以考慮每次讀取一批數據,沒了再從磁盤中取,時間復雜度還是一樣O(n)。
- 先構建十條io流,分別指向十個文件,每條io流讀取對應文件的第一條數據,然后比較時間戳,選擇出時間戳最小的那條數據,將其寫入一個新的文件,然后指向該時間戳的io流讀取下一行數據,然后繼續剛才的操作,比較選出最小的時間戳數據,寫入新文件,io流讀取下一行數據,以此類推,完成文件的合并, 這種處理方式,日志文件有n個數據就要比較n次,每次比較選出一條數據來寫入,時間復雜度是O(n),空間復雜度是O(1),幾乎不占用內存,這是我想出的認為最好的操作了,希望老師指出最佳的做法??!
13講線性排序:如何根據年齡給100萬用戶數據排序
桶排序(Bucket 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)。
適用場景
- 桶排序比較適合用在外部排序中。所謂的外部排序就是數據存儲在外部磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中。
- 將所有訂單根據金額劃分到100個桶里,第一個桶我們存儲金額在1元到1000元之內的訂單,第二桶存儲金額在1001元到2000元之內的訂單,以此類推。每一個桶對應一個文件,并且按照金額范圍的大小順序編號命名(00,01,02…99)。
計數排序(Counting Sort)
- 計數排序就是之前我理解的桶排序,一個桶里只存放一個數據的個數
- 這里額外講到了怎么根據桶中的內容推算在有序數組中的位置
- 首先,進行順序求和,結果就是小于等于k的個數【OS:這有啥難的呢】
- 之后就是遍歷原數組和這個C數組來復原這個排序后的序列【這有啥用咧】
- 我們從后到前依次掃描數組A。比如,當掃描到3時,我們可以從數組C中取出下標為3的值7,也就是說,到目前為止,包括自己在內,分數小于等于3的考生有7個,也就是說3是數組R中的第7個元素(也就是數組R中下標為6的位置)。當3放入到數組R中后,小于等于3的元素就只剩下了6個了,所以相應的C[3]要減1,變成6。
基數排序(Radix Sort)
- 基數排序就很簡單,只要是一位一位排序就行
- 就和第一次講排序的時候提到的穩定排序一樣,從后往前,保證穩定排序
- 另外對于長度不齊的可以通過補零來對齊
- 基數排序對要排序的數據是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關系,如果a數據的高位比b數據大,那剩下的低位就不用比較了。除此之外,每一位的數據范圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間復雜度就無法做到O(n)了
課后題:假設我們現在需要對D,a,F,B,c,A,z這個字符串進行排序,要求將其中所有小寫字母都排在大寫字母的前面,但小寫字母內部和大寫字母內部不要求有序。比如經過排序之后為a,c,z,D,F,B,A,這個如何來實現呢?如果字符串中存儲的不僅有大小寫字母,還有數字。要將小寫字母的放到前面,大寫字母放在最后,數字放在中間,不用排序算法,又該怎么解決呢?
- 用兩個指針a、b:a指針從頭開始往后遍歷,遇到大寫字母就停下,b從后往前遍歷,遇到小寫字母就停下,交換a、b指針對應的元素;重復如上過程,直到a、b指針相交。
- 利用桶排序思想,弄小寫,大寫,數字三個桶,遍歷一遍,都放進去,然后再從桶中取出來就行了。相當于遍歷了兩遍,復雜度O(n)
14講排序優化:如何實現一個通用的、高性能的排序函數
前面講的八種排序算法總結
- 這里要注意的是雖然歸并排序看起來很爽,但是由于需要重開一塊空間進行分區,所以空間復雜度太高,我們不使用
- 歸并排序可以做到平均情況、最壞情況下的時間復雜度都是O(nlogn)
如何優化快速排序
- 當我們需要排序的序列本身就已經是接近有序的時候,我們的快速排序效率是最低的,因為每次選擇的分區點都是最后一個數據,這樣時間復雜度就會退化到O(n2)
- 理想的分區點應該是在左右兩個分區中,數據的數量都差不多
- 這里介紹兩種分區算法
三數取中法
- 選擇第一個,中間一個,最后一個三個數中間那個作為分區點
- 如果要排序的數組比較大,那“三數取中”可能就不夠了,可能要“五數取中”或者“十數取中”
隨機法
- 機法就是每次從要排序的區間中,隨機選擇一個元素作為分區點。這種方法并不能保證每次分區點都選的比較好,但是從概率的角度來看,也不大可能會出現每次分區點都選的很差的情況,所以平均情況下,這樣選的分區點是比較好的。時間復雜度退化為最糟糕的O(n2)的情況,出現的可能性不大。
Glibc中的qsort()函數
- ibc是GNU發布的libc庫,即c運行庫。glibc是linux系統中最底層的api,幾乎其它任何運行庫都會依賴于glibc
- 對于數據特別小的,會使用插入排序;較小的使用歸并排序;特別大的是用快速排序
- 這很重要的一點是因為我們的時間復雜度代表的是一種上升趨勢,對于數據在代入不同的值的時候要區別思考
課后題:在今天的內容中,我分析了C語言的中的qsort()的底層排序算法,你能否分析一下你所熟悉的語言中的排序函數都是用什么排序算法實現的呢?都有哪些優化技巧?
-
查看了下Arrays.sort的源碼,主要采用TimSort算法, 大致思路是這樣的:
1 元素個數 < 32, 采用二分查找插入排序(Binary Sort)
2 元素個數 >= 32, 采用歸并排序,歸并的核心是分區(Run)
3 找連續升或降的序列作為分區,分區最終被調整為升序后壓入棧
4 如果分區長度太小,通過二分插入排序擴充分區長度到分區最小闕值
5 每次壓入棧,都要檢查棧內已存在的分區是否滿足合并條件,滿足則進行合并
6 最終棧內的分區被全部合并,得到一個排序好的數組Timsort的合并算法非常巧妙:
1 找出左分區最后一個元素(最大)及在右分區的位置
2 找出右分區第一個元素(最小)及在左分區的位置
3 僅對這兩個位置之間的元素進行合并,之外的元素本身就是有序的
15講二分查找(上):如何用最省內存的方式實現快速查找功能
- 二分查找雖然簡單,但隨著做的題目多了,發現后續的變化是真的多,甚至不需要一定使用有序的序列
導入:智力題,猜數字
- 二分查找針對的是一個有序的數據集合,查找思想有點類似分治思想。每次都通過跟區間的中間元素對比,將待查找的區間縮小為之前的一半,直到找到要查找的元素,或者區間被縮小為0
驚人的查找速度
- 我們的時間復雜度是O(logn),這是一個很牛掰的速度,就算我們需要查找的數據集大小為2的32次也就大約是42億,也只需要查找32次就能出答案了
- 這里可以參考下阿基米德與國王下棋的故事參考下指數的強大
這是一個很著名的故事:阿基米德與國王下棋,國王輸了,國王問阿基米德要什么獎賞?阿基米德對國王說:“我只要在棋盤上第一格放一粒米,第二格放二粒,第三格放四粒,第四格放十六?!催@個方法放滿整個棋盤就行.”國王以為要不了多少糧食,就隨口答應了,結果國王輸了
最后需要的大米數量為2的64次方-1
代碼實現
非遞歸
- 學到了,使用mid = low+((high-low)>>1)
- 別問為什么,問就是學了
int BinarySearch(vector<int> &arr, int value) {
int len = arr.size();
int left = 0;
int right = len - 1;
int mid = left + ((right - left) >> 1);
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (arr[mid] == value) {
return mid;
} else if (arr[mid] < value) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return right;
}
- 這樣子返回right,如果查找不到value,那么返回的就是在其中靠左的下標
- e.g. arr為{1, 3, 7, 9, 13},value為0返回-1,value為2返回0
遞歸
- 遞歸做法沒得說,學學學
int bsearchInternally(vector<int> &arr, int left, int right, int value) {
if (left > right) {
return -1;
}
int mid = left + ((right - left) >> 1);
if (arr[mid] == value) {
return mid;
} else if (arr[mid] < value) {
return bsearchInternally(arr, mid + 1, right, value);
} else {
return bsearchInternally(arr, left, mid - 1, value);
}
}
- 這個遞歸應該算是比較好理解的那種了
二分的局限性
- 首先,二分查找依賴的是順序表結構,簡單點說就是數組。
- 其次,二分查找針對的是有序數據。
- 再次,數據量太小不適合二分查找。
- 最后,數據量太大也不適合二分查找。
開篇的思考題:如何在1000萬個整數中快速查找某個整數?
- 雖然大部分情況下,用二分查找可以解決的問題,用散列表、二叉樹都可以解決。但是,我們后面會講,不管是散列表還是二叉樹,都會需要比較多的額外的內存空間。如果用散列表或者二叉樹來存儲這1000萬的數據,用100MB的內存肯定是存不下的。而二分查找底層依賴的是數組,除了數據本身之外,不需要額外存儲其他信息,是最省內存空間的存儲方式,所以剛好能在限定的內存大小下解決這個問題。
課后題
如何編程實現“求一個數的平方根”?要求精確到小數點后6位。
- 根據x的值,判斷求解值y的取值范圍。假設求解值范圍min < y < max。若0<x<1,則min=x,max=1;若x=1,則y=1;x>1,則min=1,max=x;在確定了求解范圍之后,利用二分法在求解值的范圍中取一個中間值middle=(min+max)÷2,判斷middle是否是x的平方根?若(middle+0.000001)(middle+0.000001)>x且(middle-0.000001)(middle-0.000001)<x,根據介值定理,可知middle既是求解值;若middlemiddle > x,表示middle>實際求解值,max=middle; 若middlemiddle < x,表示middle<實際求解值,min =middle;之后遞歸求解!
備注:因為是保留6位小數,所以middle上下浮動0.000001用于介值定理的判斷
我剛才說了,如果數據使用鏈表存儲,二分查找的時間復雜就會變得很高,那查找的時間復雜度究竟是多少呢?如果你自己推導一下,你就會深刻地認識到,為何我們會選擇用數組而不是鏈表來實現二分查找了。
-
說說第二題吧,感覺爭議比較大:
假設鏈表長度為n,二分查找每次都要找到中間點(計算中忽略奇偶數差異):
第一次查找中間點,需要移動指針n/2次;
第二次,需要移動指針n/4次;
第三次需要移動指針n/8次;
......
以此類推,一直到1次為值總共指針移動次數(查找次數) = n/2 + n/4 + n/8 + ...+ 1,這顯然是個等比數列,根據等比數列求和公式:Sum = n - 1.
最后算法時間復雜度是:O(n-1),忽略常數,記為O(n),時間復雜度和順序查找時間復雜度相同
但是稍微思考下,在二分查找的時候,由于要進行多余的運算,嚴格來說,會比順序查找時間慢