《資料結構與演算法分析》學習筆記-第七章-排序

CrazyCatJack發表於2021-02-20


插入排序

  • 插入排序由N-1趟排序組成,對於P=1趟到P=N-1趟,插入排序保證從位置0到位置P上的元素為已排序狀態
  • 基本有序或者規模較小時十分高效
void
InsertSort(int inputArray[], int arrayNum)
{
	int index, value;
	int cnt;
	for (cnt = 1; cnt < arrayNum; cnt++) {
		value = inputArray[cnt];
		index = cnt - 1;
		while (index >= 0 && value < inputArray[index]) {
			inputArray[index + 1] = inputArray[index];
			index--;
		}
		inputArray[index + 1] = value;
	}
}

由於巢狀迴圈,每一個都話費N次迭代,因此插入排序為O(N2),這個界是精確的,因為以反序輸入可以達到該界。如果輸入資料已預先排序,那麼執行時間為O(N)。插入排序的平均情形也是Θ(N2)

  1. N個互異數的陣列的平均逆序數是N(N-1)/4
  2. 通過交換相鄰元素進行排序的任何演算法平均需要Ω(N^2)時間

希爾排序

又叫做縮小增量排序。衝破二次時間屏障的第一批演算法之一。通過比較相距一定間隔的元素來工作,各趟比較所用的距離隨著演算法的進行而減小,直到只比較相鄰元素的最後一趟排序為止。適度的大量輸入,即使是數以萬計的N仍然表現出很好的效能。因此是常用演算法

void
ShellSort(int inputArray[], int arrayNum)
{
	int jump;
	for (jump = arrayNum / 2; jump > 0; jump /= 2) {
		int index, value;
		int cnt;
		for (cnt = jump; cnt < arrayNum; cnt += jump) {
			index = cnt - jump;
			value = inputArray[cnt];
			while (index >= 0 && value < inputArray[index]) {
				inputArray[index + jump] = inputArray[index];
				index -= jump;
			}
			inputArray[index + jump] = value;
		}		
	}
}
  • 使用希爾增量時,希爾排序的最壞情形執行時間為Θ(N^2)
  • 使用Hibbard增量的希爾排序的最壞情形執行時間為Θ(N^1.5)。Hibbard增量為1,3,7,..., 2^k - 1
  • 使用Sedgewick增量的希爾排序的最壞情形執行時間為O(N(4/3)),對於這些增量序列的平均執行時間猜測為O(N(7/6)),該序列中的項是94^i - 92^i + 1或者是4^i- 3*2^i + 1

堆排序

建立N個元素的二叉堆的基本方法,花費O(N)時間。執行N次DeleteMin操作,每次DeleteMin操作花費時間O(logN),總的執行時間是O(NlogN).為了避免使用第二個陣列,聰明的做法是每刪除一個元素,將其放到堆的最後一個元素的位置。堆排序的第一步是以線性時間建一個堆,然後通過將堆中最後一個元素第一個元素交換縮減堆的大小並進行下濾,來執行N-1次DeleteMax操作,當演算法終止時,陣列則以所排的順序包含這些元素。

#define LeftChild(i) (2 * (i) + 1)
void
PercDown(ElementType A[], int i, int N)
{
    int Child;
    ElementType Tmp;
    
    for (Tmp = A[i]; LeftChild(i) < N; i = Child)
    {
        Child = LeftChild(i);
        if (Child != N - 1 && A[Child + 1] > A[Child])
        {
            Child++;
        }
        if (Tmp < A[Child])
        {
            A[i] = A[Child];
        }
        else
        {
            break;
        }
    }
    A[i] = Tmp;
}

void
Heapsort(ElementType A[], int N)
{
    int i;
    for (i = N / 2; i >= 0; i--) 
    {
        // BuildHeap
        PercDown(A, i, N);
    }
    for (i = N - 1; i > 0; i--)
    {
        // DeleteMax
        Swap(&A[0], &A[i]);
        PercDown(A, 0, i);
    }
}

定理:對N個互異項的隨機排列進行堆排序,所用的比較平均次數為2NlogN - O(N)

歸併排序

以O(NlogN)最壞執行時間執行,所使用的比較次數幾乎是最優的。它是遞迴演算法一個很好的例項。採用遞迴和分治的策略,將一個待排序的數列分成兩部分,將這兩部分排序後,進行歸併操作,即比較每個數列第一個元素的大小,將小的放入第三數列,進行到最後,一定會有一個數列是有剩餘元素的,將其全部拷貝到第三數列即可。很難用於主存排序,因其額外申請空間,並且有一些拷貝操作

void
Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd)
{
    int i, LeftEnd, NumElements, TmpPos;
    
    LeftEnd = Rpos - 1;
    TmpPos = Lpos;
    NumElements = Right End - Lpos + 1;
    
    /* main loop */
    while (Lpos <= LeftEnd && Rpos <= RightEnd)
    {
        if (A[Lpos] <= A[Rpos])
        {
            TmpArray[TmpPos++] = A[Lpos++];
        }
        else
        {
            TmpArray[TmpPos++] = A[Rpos++];
        }
    }
    
    /* Copy rest of first half */
    while (Lpos <= LeftEnd)
    {
        TmpArray[TmpPos++] = A[Lpos++];
    }
    /* Copy rest of second half */
    while (Rpos <= RightEnd)
    {
        TmpArray[TmpPos++] = A[Rpos++];
    }
    
    /* Copy TmpArray back */
    for (i = 0; i < NumElements; i++, RightEnd--)
    {
        A[RightEnd] = TmpArray[RightEnd];
    }
}

void
MSort(ElementType A[], ElementType TmpArray[], int Left, int Right)
{
    int Center;
    
    if (Left < Right)
    {
        Center = (Left + Right) / 2;
        MSort(A, TmpArray, Left, Center);
        MSort(A, TmpArray, Center + 1, Right);
        Merge(A, TmpArray, Left, Center + 1, Right);
    }
}

void
Mergesort(ElementType A[], int N)
{
    ElementType *TmpArray;
    
    TmpArray = malloc(N * sizeof(ElementType));
    if (TmpArray != NULL)
    {
        MSort(A, TmpArray, 0, N - 1);
        free(TmpArray);
    }
    else
    {
        printf("malloc tmp array failed\n");
    }
}

證明:最壞時間複雜度為O(NlogN)

已知:
T(1) = 1
T(N) = 2 * T(N/2) + N

等式兩邊同時除以N得:
T(N) / N = T(N/2) / (N/2) + 1

帶入N/2,N/4...得:
T(N/2) / (N/2) = T(N/4) / (N/4) + 1
T(N/4) / (N/4) = T(N/8) / (N/8) + 1
...
T(2) / 2 = T(1) / 1 + 1

將這些等式相加得到:
T(N) / N = T(1) + logN =
T(N) / N = 1 + logN
(疊縮求和)

最終得到:
T(N) = NlogN + N

快速排序

平均執行時間是O(NlogN).是實踐中最快的已知排序方法。由於非常精煉和高度優化內部迴圈,最壞情形效能為O(N^2),但是稍加努力就可避免這種情形。快速排序和歸併排序一樣,也是一種分治遞迴演算法。

實現原理

  1. 如果陣列S中元素個數是0或1,則return
  2. 取陣列S中任一元素v,稱之為樞紐元
  3. 將S - {v} (S中其餘元素)分成兩個不相交的集合:
    1. 大於等於v的所有元素
    2. 小於等於v的所有元素
  4. 返回quicksort(S1)後,繼隨v,繼而quicksort(S2)

選擇樞紐元

  1. 錯誤的方法:選擇已經過預排序或者反序的第一個元素用作樞紐元,這樣時間複雜度很有可能是O(N^2);另一種是選擇前兩個互異的關鍵字中較大者作為樞紐元,同樣的害處
  2. 安全的做法:隨機選取樞紐元,一般來說這種策略非常安全,除非隨機數生成器有問題。但是生成隨機數會花費大量時間
  3. 三數中值分割法:樞紐元的最好的選擇是陣列的中值,很難算出,而且會花費計算時間。因此隨機選取三個元素,並用他們的中值作為樞紐元得到。例如,一般的做法是取左端、右端和中心位置上的三個元素的中值未樞紐元。中心位置為(Leftpos + Rightpos)/2.通過這樣的做法,還能夠消除預排序輸入的壞情形,並減少快速排序大約5%的執行時間

分割策略

當選擇好樞紐元后,將樞紐元從陣列拿出來,設定兩個指標i和j,指標i指向第0個元素,指標j指向最後一個元素。兩者向陣列中心靠近。當i指向的元素大於樞紐元時停止,當j指向的元素小於樞紐元時停止,二者互相交換元素,直到i和j交錯。實踐證明,當遇到等於樞紐元的元素時,i和j還是停下做交換,這樣會得到兩個差不多一樣大的子陣列,比較平衡。是正確的做法。這時總的執行時間是O(NlogN)

小陣列

對於很小的陣列(N<=20),快速排序不如插入排序好。使用這種策略大概可以節省約15%的執行時間。一種好的截止範圍是N=10,這種做法避免了一些有害的情形,比如想取三個元素的中值,而陣列總共只有1個或2個元素的情況

實際的快速排序例程

  1. 選取樞紐元
  2. 對A[Left], A[Right], A[Center]進行排序,並將元素放到對應位置上。同時將樞紐元放到A[Right - 1]的位置上
  3. i初始化在Left + 1,j初始化在Right - 2。因為A[Left] < 樞紐元,所以將它做j的境界標記,避免j越界;同理,樞紐元放到A[Right - 1],將成為i的境界標記,避免i越界。
#define CUTOFF	3

void Swap(int *a, int *b)
{
	int tmp;
	tmp = *a;
	*a = *b;
	*b = tmp;
}

void
InsertSort(int inputArray[], int arrayNum)
{
	int index, value;
	int cnt;
	for (cnt = 1; cnt < arrayNum; cnt++) {
		value = inputArray[cnt];
		index = cnt - 1;
		while (index >= 0 && value < inputArray[index]) {
			inputArray[index + 1] = inputArray[index];
			index--;
		}
		inputArray[index + 1] = value;
	}
}

int 
getsn(int A[], int Left, int Center, int Right)
{
	if (A[Right] < A[Center])
	{
		Swap(&A[Right], &A[Center]);
	}

	if (A[Right] < A[Left])
	{
		Swap(&A[Right], &A[Left]);
	}

	if (A[Center] < A[Left])
	{
		Swap(&A[Center], &A[Left]);
	}

	Swap(&A[Center], &A[Right - 1]);
	return A[Right - 1];
}

void
Qsort(int A[], int Left, int Right)
{
	int i, j;
	int sn;
	int Center;
	
	if (Left + CUTOFF <= Right)
	{
		i = Left;
		j = Right - 1;
		Center = (Left + Right) / 2;
		sn = getsn(A, Left, Center, Right);
		for (;;)
		{
			while (A[++i] < sn);
			while (A[--j] > sn);
			if (i < j)
			{
				Swap(&A[i], &A[j]);
			}
			else
			{
				break;
			}
		}
		Swap(&A[i], &A[Right - 1]);
		Qsort(A, Left, i - 1);
		Qsort(A, i + 1, Right);
	}
	else
	{
		InsertSort(A + Left, Right - Left + 1);
	}
}

void
Quicksort(int A[], int N)
{
	Qsort(A, 0, N - 1);
}

選擇的線性期望時間演算法

尋找陣列S中第k大的元素。平均執行時間O(N)

void
Qselect(int A[], int k, int Left, int Right)
{
    int i,j;
    int sn;
    int Center;
    
    if (Left + CUTOFF <= Right)
    {
        i = Left;
        j = Right - 1;
        Center = (Left + Right) / 2;
        sn = getsn(A, Left, Center, Right);
        for(;;)
        {
            while(A[++i] < sn);
            while(A[--j] > sn);
            if (i < j)
            {
                Swap(&A[i], &A[j]);
            }
            else
            {
                break;
            }
        }
        Swap(&A[i], &A[Right - 1]);
        if (k <= i)
        {
            Qselect(A, k, Left, i - 1);
        }
        else
        {
            Qselect(A, k, i + 1, Right);
        }
    }
    else
    {
        InsertSort(A + Left, Right - Left + 1);
    }
}

大型結構的排序

當要對大型結構進行排序時,如果在排序的過程中,交換兩個結構,那麼代價非常高昂。因此採用比較指標去指向結構,並在必要時交換指標進行排序。這代表,所有的資料運動基本上就像我們對整數排序那樣進行,稱之為間接排序

排序的一般下界

  • 最壞情況下只用到比較的演算法需要 Ω(Nlog(N))次比較,從而Ω(Nlog(N))時間,因此歸併排序堆排序在一個常數因子範圍內是最優的。
  • 平均情況下只用到比較任意排序演算法都需要進行Ω(Nlog(N))次比較,這意味著快速排序在相差一個常數因子的範圍內平均是最優的
  • 最壞情況下只用到比較任何排序演算法都需要log(N!)次比較並平均需要log(N!)次比較。

決策樹

通常只使用比較進行排序的每一種演算法都可以用決策樹表示。只有輸入資料非常少的情況畫決策樹才是可行的。有排序演算法所使用的比較次數等於最深的樹葉的深度,所使用的比較的平均次數等於樹葉的平均深度

  1. 令T是深度為d的二叉樹,則T最多有2^d個樹葉
  2. 具有L片樹葉的二叉樹,深度至少是logL
  3. 只使用元素間比較的任何排序演算法在最壞情況下至少需要log(N!)次比較
  4. 只使用元素間比較的任何排序演算法需要進行Ω(Nlog(N))次比較

桶式排序

簡單來說,就是設定將要排序的數列中的數最大不能超過X,那麼就建立一個大小為X+1的陣列ARRAY[X+1]。以下標進行排序。將陣列輸入的同時,就拍好了順序,設當前輸入的數為Y,那麼ARRAY[Y] = Y。當所有資料輸入完成,那麼列印該陣列ARRAY即可.該演算法用時O(X+1 + N)。桶式排序看似平凡用處不大,但是如果輸入只是一些小整數,那麼桶式排序就派上了用場。這時使用像快速排序這樣的排序方法就太小題大做了。

外部排序

目前為止,學習的排序演算法都需要將輸入資料裝入記憶體。但是存在一些應用程式,它們輸入資料量太大裝不進記憶體,並且外部訪問的速度非常慢。因此使用外部排序,它被設計用來處理很大的輸入

外部排序模型

各種各樣的海量儲存裝置使得外部排序比內部排序對裝置的依賴性要嚴重得多。由於訪問磁帶上的一個元素需要把磁帶轉動到正確的位置,因此磁帶必須要有兩個方向上連續的順序才能夠被有效的訪問。我們假設至少有三個磁帶驅動器進行排序工作,我們需要兩個驅動器執行有效的排序,第三個驅動器進行簡化的工作。如果只有一個磁帶驅動器可用,那麼我們不得不說,任何演算法都將需要Ω(N^2)

簡單演算法

基本的外部排序演算法使用歸併排序中的Merge例程,設我們有四盤磁帶:Ta1, Ta2, Tb1, Tb2,他們是兩盤輸入磁帶兩盤輸出磁帶。根據演算法的特點,磁帶a和磁帶b要麼用作輸入磁帶,要麼用作輸出磁帶。

  1. 資料最初在Ta1上,並設記憶體可以一次容納和排序M個記錄,在內部將這些記錄排序,然後把這些排過序的記錄交替的寫入Tb1和Tb2上。我們將每組排過序的記錄叫做一個順串。做完這些之後,我們倒回所有的磁帶。
  2. 現在Tb1和Tb2包含一組順串。我們將每個磁帶的第一個順串取出,並將兩者合併,結果寫到Ta1上。該結果是一個2倍長的順串,然後,我們再從每盤磁帶取出下一個順串,合併,並將結果寫道Ta2上。繼續這個過程,交替使用Ta1和Ta2,直到Tb1或Tb2為空,此時或者Tb1和Tb2為空,或者只剩下一個順串。如果有剩就把它拷貝到相應的磁帶上。將全部四盤磁帶倒回,並重復相同的步驟。這一次用兩盤a磁帶作為輸入,兩盤b磁帶作為輸出,結果得到一些4M長的順串,繼續這個過程知道的到長為N的一個順串。該演算法將需要log(N/M)趟工作,外加一趟構造初始的順串。

多路合併

如果我們有額外的次貸,可以減少將輸入資料排序所需要的趟數,通過將基本的2-路合併擴充為k-路合併能夠做到這一點。兩個順串的合併操作通過將每一個輸入磁帶轉到每個順串的開頭來完成,然後,找到較小的元素,把它放到輸出磁帶上,並將相應的輸入磁帶帶向前推進。如果有k盤輸入磁帶,那麼這種方法一相同的方式工作,惟一的區別在於它發現k個元素中最小的元素的過程稍微有些複雜。我們通過使用優先佇列找出這些元素中的最小元。為了得出下一個寫到磁碟上的元素,我們進行一次DeleteMin操作,將相應的磁帶向前推進,如果在輸入磁帶上的順串尚未完成,那麼我們將新元素插入到優先佇列中。在初始順串構造階段之後,使用k-路合併所需要的趟數為log(k)(N/M),因為每趟這些順串達到K倍大小。

多相合並

上一節討論的k-路合併方法需要使用2k盤磁帶,這對某些應用極為不便。只使用k+1盤磁帶也有可能完成排序工作。例如2-路合併,可以用3個磁碟實現。採用斐波那契數列,將待排序的數列分成兩部分,一部分順串放入Ta1, 一部分順串放入Ta2,將排序後的順串放入Tb1,Ta1和Ta2中必有一個有剩餘,假設為Ta1,此時再將Ta1和Tb1再次進行歸併,結果放入Ta2.以此類推,直到排序結束。對於2-路合併,F(N) = F(N-1) + F(N-2),對於K-路合併,Fk(N) = FK(N-1) + FK(N-2) + ... FK(N-K)

替換選擇

順串的改造。開始,M個記錄被讀入記憶體並被放到一個優先佇列中。我們執行一次DeleteMin,把最小的記錄寫到輸出磁帶上,再從輸入磁帶讀入下一個記錄。如果他比剛剛寫出的記錄大,那麼我們可以把他加到優先佇列中,否則不能把它放入當前的順串。由於優先佇列少一個元素,因此,我們可以把這個新元素存入優先佇列的死區。直到順串完成構建,而該新元素用於下一個順串。將一個元素存入死區的做法,類似於在堆排序中的做法。我們繼續這樣的步驟直到優先佇列的大小為零,此時該順串構建完成。我們是用死區中的所有元素通過建立一個新的優先佇列開始構建一個新的順串。有可能替換選擇做的並不比標準演算法更好,然而,輸入資料常常從排序或幾乎從排序開始,此時替換選擇僅僅產少數非常長的順串。這種型別的輸入通常要進行外部排序,這就使得替換選擇具有特殊的價值。

總結

對於最一般的內部排序應用程式,選用的方法不是插入排序,希爾排序,就是快速排序。它們選用主要是根據輸入的大小來決定。一個例子標明在排序元素小於等於100個的時候,希爾排序用時最少。高度優化的快速排序演算法,即使對於很少的輸入資料也能像希爾排序一樣快。如果需要對一些大型的檔案排序,那麼快速排序則是應該選用的方法。但是永遠不要圖省事兒輕易的把第一個元素用作樞紐元,對輸入排序隨機的假設是不安全的,如果不想過多地考慮這個問題,那麼就使用希爾排序。希爾排序的最壞情況也不過是O(N^(4/3)),這種最壞情況發生的機率也是微不足道的。堆排序比希爾排序慢,儘管他是一個帶有明顯緊湊內迴圈的O(NlogN)演算法。為了移動資料,堆排序要進行兩次比較。Floyd提出的改進演算法移動資料基本上只需要一次比較。實現這種改進演算法時的程式碼多少要長一些。插入排序只用在小的或是非常接近排好序的輸入資料上。而歸併排序,因為它的效能對於駐村排序不如快速排序那麼好,而且它的程式設計一點也不省事,然而我們已經看到合併卻是外部排序的中心思想

參考文獻

  1. Mark Allen Weiss.資料結構與演算法分析[M].America, 2007
  2. code隨筆.詳解直接插入排序演算法:https://zhuanlan.zhihu.com/p/120693682
  3. 祥哥的說.希爾排序--簡單易懂圖解:https://blog.csdn.net/qq_39207948/article/details/80006224

本文作者: CrazyCatJack

本文連結: https://www.cnblogs.com/CrazyCatJack/p/14408185.html

版權宣告:本部落格所有文章除特別宣告外,均採用 BY-NC-SA 許可協議。轉載請註明出處!

關注博主:如果您覺得該文章對您有幫助,可以點選文章右下角推薦一下,您的支援將成為我最大的動力!


相關文章