演算法第五記-歸併排序
今天我要講的排序演算法是歸併排序,首先我想提出一個問題(很多演算法題的思路都源於此),給定兩個已排序的序列,如何將其兩個序列合併為一個大序列並且依然保持有序。思路很簡單每個小序列維持一個指標指向左邊界,然後兩個序列的左邊界進行比較大小,小的那方加入新的序列中,同時小的那方左邊界右移一個位置。以此類推,如果有一方序列已經到達尾部,則直接將另一方的序列直接放入新序列的尾部。
序列1:1,3,5,7,9
序列2:2,4,6,8,10
開始時兩個左邊界分別指向1和2,然後比較大小,小的一方是1.將其裝入新序列中,然後序列1的左邊界指向到了3.然後繼續3,2進行比較,小的一方是2,所以2進入新序列,序列2的左邊界右移1個。直到一方序列到達了尾部。再直接把另一方的左邊界至尾部的所有元素直接放入新序列中。因為我們每次放入新序列時都是兩個小序列的左邊界進行過比較的,同時每個小序列本身又是有序的,所以有一方提前達到了自己的序列尾,那麼另一方剩餘的元素肯定都是大於新序列中的元素的。
下面先給出歸併操作的程式碼:
void merge(int arr[], int low, int mid, int high) { int i = low, j = mid + 1; int* new_arr = new int[high - low + 1]; int k = 0; while (i <= mid&&j <= high) { if (arr[i] <= arr[j]) new_arr[k++] = arr[i++]; else new_arr[k++] = arr[j++]; } while (i <= mid) new_arr[k++] = arr[i++]; while (j <= high) new_arr[k++] = arr[j++]; for (int i = low, j = 0; i <= high;) arr[i++] = new_arr[j++]; delete[]new_arr; }
基本思路我剛已經講過:我們可以看看這個演算法的效率,首先由我們剛才所講,我們需要一段新的的大序列存放我們歸併之後的序列,這也就是為什麼我們動態申請了一段記憶體,整體執行的時間複雜度是O(n)(假設我們兩段序列分別是低〜中,中+ 1~高),因為第一個而迴圈的結束條件有兩個,如果其中一個序列到了尾部,需要繼續遍歷另一個序列並將其元素複製到大序列中,這也是後面兩個同時的作用(任意時刻只會進入其中一個而,原因很明顯)。最後還有一個用於迴圈的作用,一般我們操作肯定想對原陣列操作的對吧,所以將操作完畢的序列複製回原序列。這裡有個需要注意的地方,我們新申請的陣列是從0開始的,而低〜中期,中期+ 1 〜高我們這裡把他們當作兩個獨立序列來看待,其實在歸併排序時這就是你待排序的序列中的兩段子序列
我剛講的這個演算法其實就是歸併的關鍵操作合併,那麼歸併排序究竟要做些什麼呢?分治+歸併歸併就是我們剛講的那個操作。那麼分治是什麼意思?它又做了什麼呢?其實分治就是將一個大問題分為一個又一個的小問題,將小問題解決之後得到的結果進行整合。給出示意圖體會一下分治的思路。
上半部是分解的步驟,下半部就是我之前寫的歸併步驟。
下面我給出完整的歸併排序程式碼:
void merge(int arr[], int low, int mid, int high)
{
int i = low, j = mid + 1;
int* new_arr = new int[high - low + 1];
int k = 0;
while (i <= mid&&j <= high)
{
if (arr[i] <= arr[j])
new_arr[k++] = arr[i++];
else
new_arr[k++] = arr[j++];
}
while (i <= mid)
new_arr[k++] = arr[i++];
while (j <= high)
new_arr[k++] = arr[j++];
for (int i = low, j = 0; i <= high;)
arr[i++] = new_arr[j++];
delete[]new_arr;
}
void merge_Sort_core(int arr[], int low, int high)
{
if (high - low >0)
{
int mid = low + ((high - low) >> 1);
merge_Sort_core(arr, low, mid);
merge_Sort_core(arr, mid+1, high);
merge(arr, low, mid, high);
}
}
void merge_Sort(int arr[], int length)
{
if (!arr || length <= 0)
return;
merge_Sort_core(arr, 0, length - 1);
}
下面來分析一下歸併排序的效率:前面我們提到了在合併操作時需要開闢一段額外的空間,在上面的分解圖中我們可以看到兩個最長子序列歸併時,長度剛好等於原序列的長度。其實每一層都需要開闢空間,但最後都釋放了,我們取最大的空間複雜度為O(N)。接下來分析歸併排序的穩定性,關於歸併排序是否穩定這個問題取決於你的程式碼編寫,在合併操作中如果第一個左邊界的值小於等於另一個左邊界的值,我們則將第一個左邊界的值放入新序列中。這樣處理的話,歸併排序就是穩定的。否則不一定。下面我們來分析一下歸併排序的時間複雜度,這裡我們需要採用一種叫做“遞迴樹分析法”的分析思路。原序列先對半分,分號的子序列繼續分,直到子序列只剩一個元素。那麼如上圖所示,劃分之後的樣子如同一棵樹一般。樹的高度是O(logn)時間時間 時間,所以我花的時間就等於樹的層數✖ - 層消耗的時間。每層消耗的時間其實可以簡單地看出出(數學分析其實更好記憶推薦看演算法導論的分析很棒),有沒有注意到每一層的所有元素加起來就等於N,只是分成了更多份。層數越往下分的份數就越多但總和還是N,所以這也就不難理解歸併排序的ö(nlogn)的由來了。
按照慣例給出相應的演算法面試題和部分思考題:
1.描述一個執行時間為O(nlogn)的演算法,給定Ñ個整數的集合小號和另一個整數的X,該演算法能確定小號是否存在兩個其和剛好為X的元素。
答:思路:題目沒有空間複雜度的限制,所以我們用歸併排序(其他nlogn的排序也行)排好序列,然後定義兩個指標,一個指向陣列頭,一個指向陣列尾然後我們來判斷一下頭指標和尾指標指向的值相加與X進行比較,如果大於的話,尾指標左移,如果小於的話,頭指標右移。如果等於的話就結束。如果兩個指標相遇還沒出現等於的情況,那麼返回未找到。
bool find_equal_x(int arr[], int length,int x)
{
merge_Sort(arr, length);
int i = 0, j = length - 1;
while (i < j)
{
if ((arr[i] + arr[j]) == x)
return true;
else if ((arr[i] + arr[j]) > x)
j--;
else
i++;
}
return false;
}
2.想一想可以如何去改進一下歸併排序?
答:插入排序在規模較小的時候效率比較高,所以不一定時間複雜度大的排序就不好用,要看規模一般規模小於43(演算法導論給出的結果個人覺得應該在20左右)的工作化的排序演算法基本都是這樣設計的採用快速排序,當遞迴深度過深採用堆排序,規模小於8時採用插入排序)
void merge_Sort_core(int arr[], int low, int high)
{
if (high - low >8)//不一定為8 為其他小值都行
{
int mid = low + ((high - low) >> 1);
merge_Sort_core(arr, low, mid);
merge_Sort_core(arr, mid+1, high);
merge(arr, low, mid, high);
}
else
insert_Sort_1(arr + low, high - low+1);
}
3.給出一個確定在Ñ個元素的任何排列中逆序對數量的演算法,最壞情況需要O(nlogn)時間。
答:此題也用到了分治的思路,先把陣列進行劃分求各個子序列各自的逆序對,然後合併的時候再接著計算逆序對,其實相當於一個特殊的歸併排序當左邊序列的左邊界元素大於右邊序列的左邊界元素,則開始計算逆序對數量,因為序列都是有序的,所以如果此時左邊序列的左邊界元素大於右邊序列的左邊界元素時,左邊序列包括左邊界在內的往往的所有元素此時也都應該大於右邊序列的左邊界元素。所以此時逆序對數量應該加上中期I + 1.依次類推我們直接給出程式碼。
int num;
void merge(int arr[], int low, int mid, int high)
{
int i = low, j = mid + 1;
int* new_arr = new int[high - low + 1];
int k = 0;
while (i <= mid&&j <= high)
{
if (arr[i] <= arr[j])
new_arr[k++] = arr[i++];
else
{
num += (mid-i+1);
new_arr[k++] = arr[j++];
}
}
while (i <= mid)
{
new_arr[k++] = arr[i++];
}
while (j <= high)
new_arr[k++] = arr[j++];
for (int i = low, j = 0; i <= high;)
arr[i++] = new_arr[j++];
delete[]new_arr;
}
4.輸入一個整型陣列,數組裡有整數也有負數。陣列中的一個或連續多個整陣列成一個子陣列。求所有子陣列的和的最大值。要求時間複雜度為O(nlogn)
答:此題也可以使用歸併的思路,求最大子陣列,我們可以先求左半邊的最大值,再求右半邊的最大值,然後最後求跨越中間的子序列最大值,3個部分進行比較返回最大的。
struct myval
{
myval(int l, int r,int ms) :left(l), right(r),max_sum(ms) {}
int left;
int right;
int max_sum;
};
myval find_Max_crossing_subarray(int arr[], int low, int mid, int high)
{
int left_sum = INT_MIN;
int sum = 0;
int max_left;
for (int i = mid; i >= low; i--)
{
sum += arr[i];
if (sum > left_sum)
{
left_sum = sum;
max_left = i;
}
}
int right_sum = INT_MIN;
sum = 0;
int max_right;
for (int i = mid + 1; i <=high; i++)
{
sum += arr[i];
if (sum > right_sum)
{
right_sum = sum;
max_right = i;
}
}
return myval(max_left, max_right, left_sum+right_sum);
}
myval find_maximum_subarray(int arr[], int low, int high)
{
if (low == high)
return myval(low, high, arr[low]);
else
{
int mid = low + ((high - low) >> 1);
myval left = find_maximum_subarray(arr, low, mid);
myval right = find_maximum_subarray(arr, mid + 1, high);
myval mix_lr = find_Max_crossing_subarray(arr, low, mid, high);
if ((left.max_sum) >= (right.max_sum) && (left.max_sum) >= (mix_lr.max_sum))
return left;
else if ((right.max_sum) >= (left.max_sum) && (right.max_sum) >= (mix_lr.max_sum))
return right;
else
return mix_lr;
}
}
5.合併兩個排序的連結串列
答:此題的思路就很清晰了,就是我們前面用到的合併,就是操作連結串列的時候需要小心。
list_node* sorted_list_merge(list_node* l1, list_node* l2)
{
if (!l1 || !l2) //如果任意一方為空 直接返回另一方的連結串列
{
if (!l1&&l2)
return l2;
if (!l2&&l1)
return l1;
return nullptr;//如果兩個都是空表 直接返回空
}
list_node* head=new list_node();
list_node* tail=head;
list_node* h1=l1;
list_node* h2=l2;
while(h1&&h2)
{
if((h1->val)<=(h2->val))
{
tail->next=h1;
tail=tail->next;
h1=h1->next;
}
else
{
tail->next=h2;
tail=tail->next;
h2=h2->next;
}
}
if(h1)
tail->next=h1;
if(h2)
tail->next=h2;
return head;
}
6.輸入一個整型陣列,數組裡有整數也有負數。陣列中的一個或連續多個整陣列成一個子陣列。求所有子陣列的和的最大值。要求時間複雜度為O(N)(注:此題與分治無關,只是在劍指提供上有發上來)
答:如果累積和大於0,接下來不斷往後遍歷並加上後面的值,如果大於greatest_sum則更新它,小於的話就正常跳過繼續加後面的值如果累積和小於等於0,就拿後面的值覆蓋當前的累積和並重新計算累積和。為什麼要重新計算呢?因為如果當前的累積和為負數,後面拿來覆蓋的值是正數的話正合我們的意,因為我們不就是要求最大序列麼,一個負的累積和加上一個正數怎麼會大於這個整數本身呢?所以我們不如從這個新的正數開始重新計算累積和。然後我們剛好還可以拿這個被正數覆蓋之後的累積和與great_sum進行比較如果恰巧比他大的話咱麼就更新great_sum,如果小於的話就正常跳過。但是如果拿來的是負數我們也不要擔心,因為之前的great_sum已經記錄了在此之前的最大和。因為如果是負數的話加在累積和上只會負得越多,這就違背了我們要 求最大和的目的了,所以我們同樣開始重新計算累積和。
void find_max_array(int arr[],int length)
{
if(!arr||length<=0)
return;
int cur_sum=0,greatest_sum=INT_MIN;
for(int i=0;i<length;i++)
{
if(cur_sum<=0)
cur_sum=arr[i];
else
cur_sum+=arr[i];
if(cur_sum>greatest_sum)
greatest_sum=cur_sum;
}
}
8.連結串列的是否可以歸併排序呢?
答:答案當然是可以的。只不過它與陣列的歸併排序相比在效率上稍稍差了一點。為什麼呢?主要是由於陣列的物理儲存使得它可以隨機定位。我們在找中間劃分點時,陣列可以在O(1)時間內找到,而連結串列由於不是順序儲存的,所以需要一個一個地走到中間位置。那麼現在唯一的難點在於如何找到連結串列的中間點,這裡就要運用到了一個額外的技巧——快慢指標。快慢指標常常用於判斷連結串列中是否有環,簡單來說就是有兩個指標,快指標每次走兩個節點,慢指標每次走一個節點,當快指標走到連結串列盡頭時,慢指標才走到了連結串列的一半,此時就是我們想要的中間節點。歸併排序連結串列實現與陣列實現的效率差就差在這。在每次劃分時,陣列可以直接劃分子序列。而連結串列需要花費O(n)的時間劃分。那麼按照遞迴樹去分析的話,我們的樹的高度為logn,合併過程陣列和連結串列都是一樣需要消耗O(n)的,所以綜合來說 連結串列在每一層比陣列多花了O(n)。也就是說最終的效率為O(2nlogn)忽略常係數的話,就是O(nlogn)。而陣列的效率就搶在那個常係數比連結串列實現的更低。