前言:
現在安卓面試,對於演算法的問題也越來越多了,要求也越來越多,特別是排序,基本必考題,而且還動不動就要手寫,所以陸續要寫演算法的文章,也正好當自己學習。o(╥﹏╥)o
Android技能書系列:
Android基礎知識
Android技能樹 — Android儲存路徑及IO操作小結
資料結構基礎知識
演算法基礎知識
本文主要講演算法基礎知識及排序演算法。
基礎知識:
穩定性:
我們經常聽到說XXX排序演算法是穩定性演算法,XXX排序演算法是不穩定性演算法,那穩定性到底是啥呢?
舉個最簡單的例子:我們知道氣泡排序中最重要的是二二進行比較,然後按照大小來換位置:
if(arr[j]>arr[j+1]){
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
複製程式碼
我們可以看到這裡的大小判定是前一個比後一個大,就換位置,如果相等就不會進入到if的執行程式碼中,所以我們二個相同的數挨在一起,不會進行移動,所以氣泡排序是穩定的排序演算法,但是如果我們把上面的程式碼改動一下if裡面的判斷:
if(arr[j]>=arr[j+1]){
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
複製程式碼
我們新增了一個等號,那這個時候就不是穩定排序演算法了,因為我們可以看到相等的時候它也換了位置了。
證明某個排序是不穩定很簡單,比如你只要傳入{2,2},只要換了位置就是不穩定,證明不穩定只要一種情況下是不穩定的,那麼就是不穩定排序演算法。
複雜度
複雜度包括了時間複雜度和空間複雜度,但是通常我們單純說複雜度的時候都指時間複雜度。
時間複雜度
用1+2+3+...+100為例: 普通寫法:
int sum = 0, n = 100;//執行1次
for(int i =1 ; i <= 100 ; i++){ //執行n+1次
sum = sum + i; //執行n次
}
Log.v("demo","sum:"+sum); //執行1次
複製程式碼
我們可以看到一共執行了2n+3次。(我們這裡是2*100+3)
高斯演算法:
int sum = 0,n = 100; //執行1次
sum = (1+n)*n /2; //執行1次
Log.v("demo","sum:"+sum); //執行1次
複製程式碼
我們可以看到一共執行了3次。
但是當我們的n很大的時候,比如變成了1000,第一種演算法就是2*1000+3,但是第二種還是3次。
在進行演算法分析時,語句總的執行次數T(n)是關於問題規模n的函式,進而分析T(n)隨n的變化情況並確定T(n)的數量級。演算法的時間複雜度,也就是演算法的時間量度,記作:T(n) = O(f(n))。它表示隨問題規模n的增大,演算法執行時間的增長率和f(n)的增長率相同,稱作演算法的漸近時間複雜度,簡稱為時間複雜度。其中f(n)是問題規模n的某個函式。
我們已經根據上面的解釋看到了: T(n) = O(f(n));
所以第一種是:2*n + 3 = O(f(n)),第二種是3 = O(f(n));我們可以看到是增長率和f(n)的增長率相同。
我們以前學高數都知道:比如f(x) = x^3 + 2x ,隨著x的變大,其實基本都是x^3的值,而2x的的值後面影響越來越小,所以有高階的時候,其他低階都可以隨著x的變大而忽略,同理前面的相乘的係數也是一樣,所以:
那我們上面的第一種就變成了O(n)(ps:只保留最高位,係數變為1,),第二種變為了O(1)(ps:常數都變為1)
常見的時間複雜度:
最壞/最好/平均情況
比如我們玩猜數字,讓你在1-n範圍內猜某個數字,而你是從頭到尾報數,如果猜的數正好是1,則最好情況下複雜度是1,如果猜的數是n,則最壞是n,平均的話就是n/2。排序也是一樣,比如2,1,3,4,5,6,7,8,9你只需要調換2,1就可以,但是如果是9,8,7,6,5,4,3,2,1讓你從小到大排序,你需要調換很多次。空間複雜度
引用《大話資料結構》中的例子,比如你要計算某一年是不是閏年,你可以寫一個演算法:
if(year%4==0 && year%100 != 0){
System.out.println("該年是閏年");
}else if(year % 400 == 0){
System.out.println("該年是閏年");
}else{
System.out.println("該年是平年");
}
複製程式碼
但是如果你在記憶體中儲存了一個2150元素的陣列,然後這個陣列中是index是閏年的陣列設定為1,其他設定為0,這樣別人比如問你2000年是不是閏年,你直接檢視該陣列index為2000裡面的值是不是1即可。這樣通過一筆空間上的開銷來換取了計算時間。
一個演算法的優劣主要從演算法的執行時間和所需要佔用的儲存空間兩個方面衡量。
排序演算法:
排序方法分為兩大類: 一類是內部排序, 指的是待排序記錄存放在計算機儲存器中進行的排序過程;另一類是外部排序, 指的是待排序記錄的數量很大,以至於記憶體一次不能容納全部記錄,在排序過程中尚需對外存進行訪問的排序過程。
內部排序:
氣泡排序:
氣泡排序演算法的運作如下:(從後往前) 1.比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。 2.對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。在這一點,最後的元素應該會是最大的數。 3.針對所有的元素重複以上的步驟,除了最後一個。 4.持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。
我們以最簡單的陣列{3,2,1}來看:
我們可以看到一種二個大的藍色步驟(ps:3 - 1),然後每個藍色裡面的交換步驟是一步步變少(ps:2,1)。
所以我們就知道是二個for迴圈了:
/**
我們的藍色大框一共執行(3-1)次,
也就是(nums.length -1)次
*/
for (int i = length -1; i > 0; i--) {
/**
我們藍色大框交換步驟從(3-1)次開始,
且每個藍色大框裡面的交換步驟在逐步減一,
正好就是上面的藍色大框的(i變數)
*/
for (int j = 0; j < i; j++) {
//對比邏輯程式碼
}
}
複製程式碼
然後在裡面加上判斷,如果前一個比後一個大,交換位置即可。
public void bubbleSort(int[] nums) {
//傳進來的陣列只有0或者1個元素,則不需要排序
int length = nums.length;
if (length < 2) {
return;
}
for (int i = length -1; i > 0; i--) {
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
int data = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = data;
}
}
}
}
複製程式碼
快速排序:
我們會先找一個關鍵資料,通常為第一個數,比如我們這裡的5,然後把數字小於5的數字都放在5的左邊,大於5的數字都放在5右邊,然後對於左邊的數字使用相同的方法,取第一個為關鍵資料,對其排序,然後一直這麼重複。
虛擬碼:
quickSort ( nums ){
//小於2個的陣列直接返回,因為個數為0或者1的肯定是有序陣列
if(nums.length < 2){
return nums;
}
//取陣列第一個數為參考值
data = nums[0];
//左邊的陣列
smallNums = (遍歷nums中比data小的數)
//右邊的陣列
bigNums = (遍歷nums中比data大的數)
//使用遞迴,對左邊和右邊的陣列分別再使用我們寫的這個方法。
return quickSort(smallNums) + data + quickSort(bigNums);
}
複製程式碼
我們一步步來看如何實現具體的程式碼:(我會先根據思路寫一個步驟很多的寫法,用於介紹,再寫一個好的。)
其實要實現功能,這個很簡單,我們可以新建二個陣列,然後再完全遍歷整個原始陣列,把比參考值小的和大的分別放入二個陣列。
//取第一個數為參考值
int data = nums[0];
//我們先獲取比參考值大的數及小的數各自是多少。
int smallSize = 0, bigSize = 0;
for (int i = 1; i < nums.length; i++) {
if (nums[i] <= data) {
smallSize++;
} else {
bigSize++;
}
}
//建立相應的陣列,等會用來放左邊和右邊的陣列
int[] smallNums = new int[smallSize];
int[] bigNums = new int[bigSize];
//遍歷nums陣列,把各自大於或者小於參考值的放入各自左邊和右邊的陣列。
int smallIndex = 0;
int bigIndex = 0;
for (int i = 1; i < nums.length; i++) {
if (nums[i] > data) {
bigNums[bigIndex] = nums[i];
bigIndex++;
}else{
smallNums[smallIndex] = nums[i];
smallIndex++;
}
}
//左邊和右邊再各自使用遞迴呼叫
smallNums = quickSort(smallNums);
bigNums = quickSort(bigNums);
//然後再把smallNums的所有數值賦給data左邊,然後nums中間為data,然後再bigNums把data右邊。
for (int i = 0; i < smallNums.length; i++) {
nums[i] = smallNums[i];
}
nums[smallNums.length] = data;
for (int i = smallSize + 1; i < bigNums.length + smallSize + 1; i++) {
nums[i] = bigNums[i - smallSize - 1];
}
複製程式碼
當然這也是可以實現的,可是感覺程式碼很多,而且每次呼叫quickSort進行遞迴的時候,都要新建二個陣列,這樣後面遞迴呼叫次數越多,新建的陣列物件也會很多。我們可不可以思路不變,參考值左邊是小的值,參考值右邊是大的值,但是不新建陣列。答案是當然!!(這逼裝的太累了,休息一下。)
- 我們在左邊開始的地方標記為 i ,右邊為 j ,然後因為 i 的位置已經是我們的參考值了,所以從 j 那邊開始移動,
- 因為我們的目標是左邊的數是比參考值小,右邊的比參考值大,所以從 j 開始往左移動,當找到一個比5小的數字,然後停住,
- 然後 i 從左邊開始往右移動,然後找到比參考值大的數,然後停住,
- 交換 i 跟 j 指向的數
- 重複 2,3,4 直到 i 跟 j 重合(比如index為h的地方),然後交換我們的參考值跟這個 h 交換資料。
剩下的左邊和右邊的陣列也都通過遞迴執行這個方法即可。
public static void QuickSort(int[] nums, int start, int end) {
//如果start >= end了,說明陣列就一個數了。不需要排序
if(start >= end){
return;
}
//取第一個數為參考值
int data = nums[start];
int i = start, j = end;
//當 i 和 j 還沒有碰到一起時候,一直重複移動 j 和 i 等操作
while (i != j) {
//當 j 位置比參考值大的時候,繼續往左邊移動,直到找到一個比參考值小的數才停下
while (nums[j] >= data && i < j) {
j--;
}
//當 i 位置比參考值小的時候,繼續往右邊移動,直到找到一個比參考值大的數才停下
while (nums[i] <= data && i < j) {
i++;
}
//交換二邊的數
if (i < j) {
int t = nums[i];
nums[i] = nums[j];
nums[j] = t;
}
}
//當退出上面的迴圈,說明 i 和 j 的位置交匯了,更換參考值與 i 位置的值。
nums[start] = nums[i];
nums[i] = data;
//左邊的陣列通過遞迴繼續呼叫,這時候因為參考值在 i 的位置,所以左邊是從start 到 i -1
QuickSort(nums, start, i - 1);
//右邊的陣列通過遞迴繼續呼叫,這時候因為參考值在 i 的位置,所以右邊是從 i -1 到 end
QuickSort(nums, i + 1, end);
}
複製程式碼
直接插入排序:
可以看到,我們是預設把第一個數字當做是排好序的陣列(廢話,一個數字當然是排好序的),然後每次後一個跟前面的進行比較排序,然後重複。所以我們可以看到最外面的一共是N-1層。然後每一層裡面的比較次數是跟當前層數數量相同。
比如第二層。我們的數字2要和前面的1,3比較,那就要先跟3比較**(當然如果此處比3大就不需要比較了,因為前面已經是個有序陣列了,你比這個有序陣列最大的值都大,前面的就不需要比了。)**如果比3小就要跟1比較,正好比較2次,跟層數相同。
所以基本程式碼肯定是:
for(int i = 1; i < n ; i ++){
if( 當前待比較的數 >= 前面的有序陣列最後一個數){
continue; //這就沒必要比較了。
}
for(int j = i-1 ; j >=0 ; j--){
// 當前待比較數 與 前面的有序陣列中的數一個個進行比較。然後插在合適的位置。
}
}
複製程式碼
針對這個排序,程式碼本來只需要像下面這個一樣即可:
public static void InsertSort(int[] nums) {
for (int i = 1; i < nums.length; i += 1) {
if (nums[i - 1] <= nums[i]) {
continue;
}
int va = i;
int data = nums[i];
for (int j = i - 1; j >= 0; j--) {
if (nums[j] > data) {
va = j;
nums[j + 1] = nums[j];
}
}
nums[va] = data;
}
}
複製程式碼
因為我們這裡的數字是連續的,所以間隔是1,但是為了下一個排序的講解方便,我們假設它們的間隔是可能不是1,所以改造成下面這個:
public static void InsertSort(int[] nums, int gap) {
for (int i = gap; i < nums.length; i += gap) {
if (nums[i - gap] > nums[i]) {
int va = i;
int data = nums[i];
for (int j = i - gap; j >= 0; j -= gap) {
if (nums[j] > data) {
va = j;
nums[j + gap] = nums[j];
}
}
nums[va] = data;
}
}
}
複製程式碼
希爾排序:
希爾排序是直接插入排序演算法的一種更高效的改進版本。
我們假設現在是1-6個數字,我們取陣列的<數量/2>為間隔數(ps:所以為6/2 = 3),然後按照這個間隔數分別分組:
這樣我們可以當場有三組陣列{3,4,},{1,6},{5,2} 然後對每組陣列使用直接插入排序。然後我們把間隔數再除以2(PS:為 3/2 = 1,取為1)。
然後再使用直接插入排序就可以得到最後的結果了。
所以還記不記得我們上面的直接插入排序程式碼寫成了public static void InsertSort(int[] nums, int gap)
,就是為了考慮上面的多個間隔不為1的陣列。
所以只要考慮我們的迴圈了幾次,每次間隔數是多少就可以了:
public void ShellSort(int[] nums) {
int length = nums.length;
for (int gap = length / 2; gap > 0; gap /= 2) {
InsertSort(nums, gap);
}
}
複製程式碼
是不是發現超級簡單。
(ps:這裡記錄一下,比如有10個數字,因為理論上是每次除以2,比如應該是5,2,1; 但是有些文章是寫著5,3,1,有些文章寫著5,2,1。我寫的程式碼也是5,2,1。。。o(╥﹏╥)o到底哪個更準確點。)
選擇排序:
選擇排序很簡單,就是每次遍歷,找出最小的一個放在前面(或者最大的一個放在後面),然後接著把剩下的再遍歷一個個的找出來排序。
public void selectSort(int[] nums) {
int min;
int va;
for (int i = 0; i < nums.length; i++) {
min = nums[i];
va = i;
for (int j = i + 1; j < nums.length; j++) {
if (nums[j] < min) {
min = nums[j];
va = j;
}
}
int temp = nums[i];
nums[i] = min;
nums[va] = temp;
}
}
複製程式碼
堆排序:
這裡我暫時空著,因為跟二叉樹有關係,所以我準備先寫一篇二叉樹的資料結構,然後再寫這個排序。有興趣的可以自己去搜下。
歸併排序:
歸併演算法,指的是將兩個順序序列合併成一個順序序列的方法。
比如有數列{6,202,100,301,38,8,1} 初始狀態:6,202,100,301,38,8,1 第一次歸併後:{6,202},{100,301},{8,38},{1},比較次數:3; 第二次歸併後:{6,100,202,301},{1,8,38},比較次數:4; 第三次歸併後:{1,6,8,38,100,202,301},比較次數:4;
這個引入網路上的圖片了:
根據這個我們可以看到,我們要先不停的取中間拆分,左右二邊拆開,一直拆到為一個元素的時候停止,然後再合併。
public void mergeSort(int[] nums, int L, int R) {
//如果只有一個元素,那就不用排序了
if (L == R) {
return;
} else {
int M = (R + L) / 2;//每次取中間值
mergeSort(nums, L, M);//通過遞迴再把左邊的按照這個方式繼續不停的左右拆分
mergeSort(nums, M + 1, R);//通過遞迴再把右邊的按照這個方式繼續不停的左右拆分
merge(nums, L, M + 1, R);//合併拆分的部分
}
}
複製程式碼
我們繼續通過圖片來說明上圖最後合併的操作:
然後重複這個操作。如果比如左邊的都比較完了,右邊還剩好幾個,只需要把右邊剩下的全部都移入即可。
public void merge(int[] nums, int L, int M, int R) {
int[] leftNums = new int[M - L];
int[] rightNums = new int[R - M + 1];
for (int i = L; i < M; i++) {
leftNums[i - L] = nums[i];
}
for (int i = M; i <= R; i++) {
rightNums[i - M] = nums[i];
}
int i = 0;
int j = 0;
int k = L;
//左邊還沒有全部比較完,右邊還沒有全部比較完
while (i < leftNums.length && j < rightNums.length) {
if (leftNums[i] >= rightNums[j]) {
nums[k] = rightNums[j];
j++;
k++;
} else {
nums[k] = leftNums[i];
i++;
k++;
}
}
//二邊的比完之後,如果左邊還有剩下,就把左邊的全部移入陣列尾部
while (i < leftNums.length) {
nums[k] = leftNums[i];
i++;
k++;
}
//二邊的比完之後,如果右邊還有剩下,就把右邊的全部移入陣列尾部
while (j < rightNums.length) {
nums[k] = rightNums[j];
j++;
k++;
}
}
複製程式碼
基數排序:
先說明一個簡單的桶排序吧:
比如我們要給{5,3,5,2,8}排序,我們初始化一組內容為0的陣列(做為桶),只要把他們當做陣列的index值,比如第一個是5,我們就nums[5] ++ ; 這樣我們只要對這個陣列遍歷,取出裡面的值,只要不為0就列印出來。
但是這樣這裡就會有一個問題了,就是如果我的陣列裡面最大的數是100000,那豈不是我初始化的陣列長度是100000了,明顯不能這樣。我們知道一個數字肯定是由{0-9}這些陣列成,只是處於不同的位數而已,所以我們可以還是按照{0-9}來放入某個桶,但是是先按照個位數排序,然後按照十位數,百位數,千位數.....等來一樣樣來放。
- 比如我們現在是{25,8,1000,158}四個數 第一次,我們比較的是個位數:
所以我們從左到右,從上到下的新陣列的順序是{1000,25,8,158}
- 第二次,我們比較的是十位數:
所以新陣列是{1000,8,25,158}
3.第三次,我們比較百位數:
所以新陣列還是{1000,8,25,158}
- 第四次,我們比較千位數:
這時候我們就可以看到最終排序是{8,25,158,1000}
ps:如果還有萬位數等,持續進行以上的動作直至最高位數為止
我們既然知道了,要一直最外層迴圈要進行最高數的次數,所以我們第一步是找出最大的數有幾位:
//可以通過遞迴找最大值:
public static int findMax(int[] arrays, int L, int R) {
//如果該陣列只有一個數,那麼最大的就是該陣列第一個值了
if (L == R) {
return arrays[L];
} else {
int a = arrays[L];
int b = findMax(arrays, L + 1, R);//找出整體的最大值
if (a > b) {
return a;
} else {
return b;
}
}
}
//也可以通過for迴圈找最大值:
public static int findMax(int[] arrays, int L, int R) {
int length = arrays.length;
int max = arrays[0];
for (int i = 1; i < length; i++) {
if (arrays[i] > max) {
max = arrays[i];
}
}
return max;
}
複製程式碼
然後主要的排序程式碼:
public static void radixSort(int[] arrays) {
int max = findMax(arrays, 0, arrays.length - 1);
//需要遍歷的次數由陣列最大值的位數來決定
for (int i = 1; max / i > 0; i = i * 10) {
int[][] buckets = new int[arrays.length][10];
//獲取每一位數字(個、十、百、千位...分配到桶子裡)
for (int j = 0; j < arrays.length; j++) {
int num = (arrays[j] / i) % 10;
//將其放入桶子裡
buckets[j][num] = arrays[j];
}
//回收桶子裡的元素
int k = 0;
//有10個桶子
for (int j = 0; j < 10; j++) {
//對每個桶子裡的元素進行回收
for (int l = 0; l < arrays.length; l++) {
//如果桶子裡面有元素就回收(資料初始化會為0)
if (buckets[l][j] != 0) {
arrays[k++] = buckets[l][j];
}
}
}
}
}
複製程式碼
外部排序:
一般來說外排序分為兩個步驟:預處理和合並排序。首先,根據可用記憶體的大小,將外存上含有n個紀錄的檔案分成若干長度為t的子檔案(或段);其次,利用內部排序的方法,對每個子檔案的t個紀錄進行內部排序。這些經過排序的子檔案(段)通常稱為順串(run),順串生成後即將其寫入外存。這樣在外存上就得到了m個順串(m=[n/t])。最後,對這些順串進行歸併,使順串的長度逐漸增大,直到所有的待排序的記錄成為一個順串為止。
結語:
最後附上百度上的排序圖:
文章哪裡不對,幫忙指出,謝謝。。o( ̄︶ ̄)o
參考:
《大話資料結構》
《演算法圖解》
《啊哈,演算法》