IOS排序算法之歸并排序、快速排序

歸并排序和快速排序都用到了分治思想,非常巧妙。我們可以借鑒這個(gè)思想,來(lái)解決非排序的問(wèn)題。

歸并排序

歸并排序的核心思想還是蠻簡(jiǎn)單的。如果要排序一個(gè)數(shù)組,我們先把數(shù)組從中間分成前后兩部分,然后對(duì)前后兩部分分別排序,再將排好的兩部分合并在一起,這樣整個(gè)數(shù)組就都有序了。

image.png

歸并排序使用的就是分治思想。分治,顧名思義,就是分而治之,將一個(gè)大問(wèn)題分解成小的子問(wèn)題來(lái)解決。小的子問(wèn)題解決了,大問(wèn)題也就解決了。

從我剛才的描述,你有沒(méi)有感覺(jué)到,分治思想跟我們前面講的遞歸思想很像。是的,分治算法一般都是用遞歸來(lái)實(shí)現(xiàn)的。分治是一種解決問(wèn)題的處理思想,遞歸是一種編程技巧,這兩者并不沖突。

分治算法的思想我后面會(huì)有專(zhuān)門(mén)的一節(jié)來(lái)講,現(xiàn)在不展開(kāi)討論,我們今天的重點(diǎn)還是排序算法。

前面我通過(guò)舉例讓你對(duì)歸并有了一個(gè)感性的認(rèn)識(shí),又告訴你,歸并排序用的是分治思想,可以用遞歸來(lái)實(shí)現(xiàn)。我們現(xiàn)在就來(lái)看看如何用遞歸代碼來(lái)實(shí)現(xiàn)歸并排序。

寫(xiě)遞歸代碼的技巧就是,分析得出遞推公式, 然后找到終止條件,最后將遞推公式翻譯成遞歸代碼。所以,要想寫(xiě)出歸并排序的代碼,我們先寫(xiě)出歸并排序的遞推公式。

遞推公式:
merge_sort(p…r) = (merge_sort(p…q), merge_sort(q+1…r)) 

終止條件:
p >= r 不用再繼續(xù)分解

我來(lái)解釋一下這個(gè)遞推公式。

merge_ sort(...r) 表示,給下標(biāo)從p到r之間的數(shù)組排序。我們將這個(gè)排序問(wèn)題轉(zhuǎn)化為了兩個(gè)子問(wèn)題,merge_ sort(...q) 和merge_ sort(q+1...r), 其中下標(biāo)q等于p和r的中間位置,也就是(p+r)/2。當(dāng)下標(biāo)從p到q和從q+1到r這兩個(gè)子數(shù)組都排好序之后,我們?cè)賹蓚€(gè)有序的子數(shù)組合并在一起,這樣下標(biāo)從p到r之間的數(shù)據(jù)就也排好序了。

有了遞推公式,轉(zhuǎn)化成代碼就簡(jiǎn)單多了。

#pragma mark -
#pragma mark 歸并排序
- (void)gly_mergeSort:(NSString *)propertyName result:(NSComparisonResult)result
{
    [self gly_mergeSortPropertyName:propertyName result:result p:0 r:self.count - 1];
}

- (void)gly_mergeSortPropertyName:(NSString *)propertyName result:(NSComparisonResult)result p:(NSInteger)p r:(NSInteger)r
{
    if (p >= r)
    {
        return;
    }
    
    NSInteger q = (p + r) / 2;

    // 分治遞歸
    [self gly_mergeSortPropertyName:propertyName result:result p:p r:q];
    [self gly_mergeSortPropertyName:propertyName result:result p:q + 1 r:r];
    
    // 將 A[p...q] 和 A[q+1...r] 合并為 A[p...r]
    [self gly_mergePropertyName:propertyName result:result p:p r:r];
}

你可能已經(jīng)發(fā)現(xiàn)了,[self gly_mergePropertyName:propertyName result:result p:p r:r];這個(gè)函數(shù)的作用就是,將已經(jīng)有序的A[p...q]和A[q+1...r] 合并成一個(gè)有序的數(shù)組,并且放入A[p...r]。 那這個(gè)過(guò)程具體該如何做呢?

如圖所示,我們申請(qǐng)一-個(gè)臨時(shí)數(shù)組tmp,大小與A[p...r] 相同。我們用兩個(gè)游標(biāo)i和j,分別指向A.p...q]和A[q+1...r] 的第一個(gè)元素。比較這兩個(gè)元素A[i] 和A[j],如果A[i]<=A[j],我們就把A[i]放入到臨時(shí)數(shù)組tmp,并且i后移-位,否則將A[j] 放入到數(shù)組tmp, j 后移-位。

繼續(xù)上述比較過(guò)程,直到其中一個(gè)子數(shù)組中的所有數(shù)據(jù)都放入臨時(shí)數(shù)組中,再把另一個(gè)數(shù)組中的數(shù)據(jù)依次加入到臨時(shí)數(shù)組的末尾,這個(gè)時(shí)候,臨時(shí)數(shù)組中存儲(chǔ)的就是兩個(gè)子數(shù)組合并之后的結(jié)果了。最后再把臨時(shí)數(shù)組tmp中的數(shù)據(jù)拷貝到原數(shù)組Ap..r]中。

image.png

下面是合并方法

- (void)gly_mergePropertyName:(NSString *)propertyName result:(NSComparisonResult)result  p:(NSInteger)p r:(NSInteger)r
{
    NSInteger q = (p + r) / 2;
    
    NSInteger i = p;
    NSInteger j = q + 1;
    NSInteger k = 0;
    
    NSMutableArray *tempArray = [NSMutableArray array];
    
    while (i <= q && j <= r)
    {
        NSNumber *numberOne = [self[i] valueForKey:propertyName];
        NSNumber *numberTwo = [self[j] valueForKey:propertyName];
        if ([numberOne compare:numberTwo] == result)
        {
            [tempArray insertObject:self[j++] atIndex:k++];
        }
        else
        {
            [tempArray insertObject:self[i++] atIndex:k++];
        }
    }
    
    // 判斷哪個(gè)子數(shù)組中有剩余的數(shù)據(jù)
    NSInteger start = i;
    NSInteger end = q;
    if (j <= r)
    {
        start = j;
        end = r;
    }
    
    // 將剩余的數(shù)據(jù)拷貝到臨時(shí)數(shù)組 tmp
    while (start <= end)
    {
        [tempArray insertObject:self[start++] atIndex:k++];
    }
    
    // 將 tmp 中的數(shù)組拷貝回 A[p...r]
    [self replaceObjectsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(p, r - p + 1)] withObjects:tempArray];
}
歸并排序的性能分析

這樣跟著我一步一步分析,歸并排序是不是沒(méi)那么難啦?還記得上節(jié)課我們分析排序算法的三個(gè)問(wèn)題嗎?接下來(lái),我們來(lái)看歸并排序的三個(gè)問(wèn)題。

第一,歸并排序是穩(wěn)定的排序算法嗎?

結(jié)合我前面畫(huà)的那張圖和歸并排序的偽代碼,你應(yīng)該能發(fā)現(xiàn),歸并排序穩(wěn)不穩(wěn)定關(guān)鍵要看merge()函數(shù),也就是兩個(gè)有序子數(shù)組合并成一個(gè)有序數(shù)組的那部分代碼。

在合并的過(guò)程中,如果Ap...q]和A[+1...r]之間有值相同的元素,那我們可以像偽代碼中那樣,先把A[p...q]中的元素放入tmp數(shù)組。這樣就保證了值相同的元素,在合并前后的先后順序不變。所以,歸并排序是一個(gè)穩(wěn)定的排序算法。

第二,歸并排序的時(shí)間復(fù)雜度是多少?

歸并排序涉及遞歸,時(shí)間復(fù)雜度的分析稍微有點(diǎn)復(fù)雜。我們正好借此機(jī)會(huì)來(lái)學(xué)習(xí)一下,如何分析遞歸代碼的時(shí)間復(fù)雜度。

在遞歸那一節(jié)我們講過(guò),遞歸的適用場(chǎng)景是,一個(gè)問(wèn)題a可以分解為多個(gè)子問(wèn)題b、c,那求解問(wèn)題a就可以分解為求解問(wèn)題b、c。問(wèn)題b、c解決之后,我再把b、c的結(jié)果合并成a的結(jié)果。

如果我們定義求解問(wèn)題a的時(shí)間是T(a),求解問(wèn)題b、c的時(shí)間分別是T(b)和T( c),那我們就可以得到這樣的遞推關(guān)系式:

T(a) = T(b) + T(c) + K

其中K等于將兩個(gè)子問(wèn)題b、c的結(jié)果合并成問(wèn)題a的結(jié)果所消耗的時(shí)間。

從剛剛的分析,我們可以得到一個(gè)重要的結(jié)論:不僅遞歸求解的問(wèn)題可以寫(xiě)成遞推公式,遞歸代碼的時(shí)間復(fù)雜度也可以寫(xiě)成遞推公式。

套用這個(gè)公式,我們來(lái)分析一下歸并排序的時(shí)間復(fù)雜度。

我們假設(shè)對(duì)n個(gè)元素進(jìn)行歸并排序需要的時(shí)間是T(n),那分解成兩個(gè)子數(shù)組排序的時(shí)間都是T(n/2)。我們知道,merge() 函數(shù)合并兩個(gè)有序子數(shù)組的時(shí)間雜度是O(n)。所以,套用前面的公式,歸并排序的時(shí)間復(fù)雜度的計(jì)算公式就是:

T(1) = C;   n=1 時(shí),只需要常量級(jí)的執(zhí)行時(shí)間,所以表示為 C。
T(n) = 2*T(n/2) + n; n>1

通過(guò)這個(gè)公式,如何來(lái)求解 T(n) 呢?還不夠直觀?那我們?cè)龠M(jìn)一步分解一下計(jì)算過(guò)程。

T(n) = 2*T(n/2) + n
     = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
     = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
     = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
     ......

通過(guò)這樣一步一步分解推導(dǎo),我們可以得到T(n) = 2kT(n/2k)+kn。當(dāng)T(n/2k)=T(1)時(shí),也就是n/2k=1,我們得到k=log2n。我們將k值代入上面的公式,得到T(n)=Cn+nlog2n。如果我們用大0標(biāo)記法來(lái)表示的話(huà),T(n) 就等于O(nlogn)。所以歸并排序的時(shí)間復(fù)雜度是O(nlogn)。

從我們的原理分析和偽代碼可以看出,歸并排序的執(zhí)行效率與要排序的原始數(shù)組的有序程度無(wú)關(guān),所以其時(shí)間復(fù)雜度是非常穩(wěn)定的,不管是最好情況、最壞情況,還是平均情況,時(shí)間復(fù)雜度都是O(nlogn)。

第三,歸并排序的空間復(fù)雜度是多少?

歸并排序的時(shí)間復(fù)雜度任何情況下都是O(nlogn),看起來(lái)非常優(yōu)秀。(待會(huì)兒你會(huì)發(fā)現(xiàn), 即便是快速排序,最壞情況下,時(shí)間復(fù)雜度也是O(n2)。)但是,歸并排序并沒(méi)有像快排那樣,應(yīng)用廠泛,這是為什么呢?因?yàn)樗幸粋€(gè)致命的“弱點(diǎn)”,那就是歸并排序不是原地排序算法。

這是因?yàn)闅w并排序的合并函數(shù),在合并兩個(gè)有序數(shù)組為一一個(gè)有序數(shù)組時(shí),需要借助額外的存儲(chǔ)空間。這一點(diǎn)你應(yīng)該很容易理解。那我現(xiàn)在問(wèn)你,歸并排序的空間復(fù)雜度到底是多少呢?是O(n),還是O(nlogn),應(yīng)該如何分析呢?

如果我們繼續(xù)按照分析遞歸時(shí)間復(fù)雜度的方法,通過(guò)遞推公式來(lái)求解,那整個(gè)歸并過(guò)程需要的空間復(fù)雜度就是O(nlogn)。不過(guò),類(lèi)似分析時(shí)間復(fù)雜度那樣來(lái)分析空間復(fù)雜度,這個(gè)思路對(duì)嗎?

實(shí)際上,遞歸代碼的空間復(fù)雜度并不能像時(shí)間復(fù)雜度那樣累加。剛剛我們忘記了最重要的一點(diǎn),那就是,盡管每次合并操作都需要申請(qǐng)額外的內(nèi)存空間,但在合并完成之后,臨時(shí)開(kāi)辟的內(nèi)存空間就被釋放掉了。在任意時(shí)刻,CPU只會(huì)有一個(gè)函數(shù)在執(zhí)行,也就只會(huì)有一個(gè)臨時(shí)的內(nèi)存空間在使用。臨時(shí)內(nèi)存空間最大也不會(huì)超過(guò)n個(gè)數(shù)據(jù)的大小,所以空間復(fù)雜度是O(n)。

快速排序

我們?cè)賮?lái)看快速排序算法(Quicksort) ,我們習(xí)慣性把它簡(jiǎn)稱(chēng)為“快排”??炫爬玫囊彩欠种嗡枷?。乍看起來(lái),它有點(diǎn)像歸并排序,但是思路其實(shí)完全不一-樣。我們待會(huì)會(huì)講兩者的區(qū)別?,F(xiàn)在,我們先來(lái)看下快排的核心思想。

快排的思想是這樣的:如果要排序數(shù)組中下標(biāo)從p到r之間的一-組數(shù)據(jù),我們選擇p到r之間的任意一個(gè)數(shù)據(jù)作為pivot (分區(qū)點(diǎn))。

我們遍歷p到r之間的數(shù)據(jù),將小于pivot的放到左邊,將大于pivot的放到右邊,將pivot放到中間。經(jīng)過(guò)這一-步驟之后,數(shù)組p到r之間的數(shù)據(jù)就被分成了三個(gè)部分,前面p到q-1之間都是小于pivot的,中間是pivot,后面的q+1到r之間是大于pivot的。

image.png

根據(jù)分治、遞歸的處理思想,我們可以用遞歸排序下標(biāo)從 p 到 q-1 之間的數(shù)據(jù)和下標(biāo)從 q+1 到 r 之間的數(shù)據(jù),直到區(qū)間縮小為 1,就說(shuō)明所有的數(shù)據(jù)都有序了。

如果我們用遞推公式來(lái)將上面的過(guò)程寫(xiě)出來(lái)的話(huà),就是這樣:

遞推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)

終止條件:
p >= r

我將遞推公式轉(zhuǎn)化成遞歸代碼。

#pragma mark -
#pragma mark 快速排序
- (void)gly_quickSort:(NSString *)propertyName result:(NSComparisonResult)result
{
    [self gly_quickSortPropertyName:propertyName result:result p:0 r:self.count - 1];
}

- (void)gly_quickSortPropertyName:(NSString *)propertyName result:(NSComparisonResult)result p:(NSInteger)p r:(NSInteger)r
{
    if (p >= r)
    {
        return;
    }
    
    NSInteger q = [self gly_partitionPropertyName:propertyName result:result p:p r:r];

    [self gly_quickSortPropertyName:propertyName result:result p:p r:q - 1];
    [self gly_quickSortPropertyName:propertyName result:result p:q + 1 r:r];
}

歸并排序中有一個(gè)[self gly_mergePropertyName:propertyName result:result p:p r:r]合并函數(shù),我們這里有一個(gè)[self gly_partitionPropertyName:propertyName result:result p:p r:r]分區(qū)函數(shù)。分區(qū)函數(shù)實(shí)際上我們前面已經(jīng)講過(guò)了,就是隨機(jī)選擇一個(gè)元素作為pivot (一般情況下,可以選擇p到r區(qū)間的最后一個(gè)元素),然后對(duì)A[p...r] 分區(qū),函數(shù)返回pivot的下標(biāo)。

如果我們不考慮空間消耗的話(huà),partition() 分區(qū)函數(shù)可以寫(xiě)得非常簡(jiǎn)單。我們申請(qǐng)兩個(gè)臨時(shí)數(shù)組X和Y,遍歷Ap...r],將小于pivot的元素都拷貝到臨時(shí)數(shù)組X,將大于pivot的元素都拷貝到臨時(shí)數(shù)組Y,最后再將數(shù)組X和數(shù)組Y中數(shù)據(jù)順序拷貝到A[p...r]。

image.png

但是,如果按照這種思路實(shí)現(xiàn)的話(huà),分區(qū)函數(shù)就需要很多額外的內(nèi)存空間,所以快排就不是原地排序算法了。如果我們希望快排是原地排序算法,那它的空間復(fù)雜度得是0(1),那分區(qū)函數(shù)就不能占用太多額外的內(nèi)存空間,我們就需要在A[p...r]的原地完成分區(qū)操作。

原地分區(qū)函數(shù)的實(shí)現(xiàn)思路非常巧妙,我們一起來(lái)看一下。

- (NSInteger)gly_partitionPropertyName:(NSString *)propertyName result:(NSComparisonResult)result p:(NSInteger)p r:(NSInteger)r
{
    NSNumber *pivot = [self[r] valueForKey:propertyName];
    NSInteger i = p;
    
    for (NSInteger j = p; j < r; j++)
    {
        NSNumber *tempNumber = [self[j] valueForKey:propertyName];
        if ([pivot compare:tempNumber] == result)
        {
            [self exchangeObjectAtIndex:i withObjectAtIndex:j];
            i++;
        }
    }
    
    [self exchangeObjectAtIndex:i withObjectAtIndex:r];
    
    return i;
}

這里的處理有點(diǎn)類(lèi)似選擇排序。我們通過(guò)游標(biāo)i把A[p...r-1]分成兩部分。A[p...i-1] 的元素都是小于pivot的,我們暫且叫它“已處理區(qū)間”,A[..r-1] 是“未處理區(qū)間”。我們每次都從未處理的區(qū)間A.[...r-1]中取一-個(gè)元素A[j],與pivot 對(duì)比,如果小于pivot,則將其加入到已處理區(qū)間的尾部,也就是A[i]的位置。

在數(shù)組某個(gè)位置插入元素,需要搬移數(shù)據(jù),非常耗時(shí)。當(dāng)時(shí)我們也講了一種處理技巧,就是交換,在0(1)的時(shí)間復(fù)雜度內(nèi)完成插入操作。這里我們也借助這個(gè)思想,只需要將A[i]與A[j]交換,就可以在0(1)時(shí)間復(fù)雜度內(nèi)將A[j]放到下標(biāo)為i的位置。

文字不如圖直觀,所以我畫(huà)了一張圖來(lái)展示分區(qū)的整個(gè)過(guò)程。

image.png

因?yàn)榉謪^(qū)的過(guò)程涉及交換操作,如果數(shù)組中有兩個(gè)相同的元素,比如序列6,8, 7, 6, 3, 5, 9,4,在經(jīng)過(guò)第一-次分區(qū)操作之后,兩個(gè)6的相對(duì)先后順序就會(huì)改變。所以,快速排序并不是一-個(gè)穩(wěn)定的排序算法。

到此,快速排序的原理你應(yīng)該也掌握了?,F(xiàn)在,我再來(lái)看另外-個(gè)問(wèn)題:快排和歸并用的都是分治思想,遞推公式和遞歸代碼也非常相似,那它們的區(qū)別在哪里呢?

image.png

可以發(fā)現(xiàn),歸并排序的處理過(guò)程是由下到上的,先處理子問(wèn)題,然后再合并。而快排正好相反,它的處理過(guò)程是由.上到下的,先分區(qū),然后再處理子問(wèn)題。歸并排序雖然是穩(wěn)定的、時(shí)間復(fù)雜度為O(nlogn)的排序算法,但是它是非原地排序算法。我們前面講過(guò),歸并之所以是非原地排序算法,主要原因是合并函數(shù)無(wú)法在原地執(zhí)行。快速排序通過(guò)設(shè)計(jì)巧妙的原地分區(qū)函數(shù),可以實(shí)現(xiàn)原地排序,解決了歸并排序占用太多內(nèi)存的問(wèn)題。

快速排序的性能分析

現(xiàn)在,我們來(lái)分析一下快速排序的性能。我在講解快排的實(shí)現(xiàn)原理的時(shí)候,已經(jīng)分析了穩(wěn)定性和空間復(fù)雜度??炫攀且?種原地、不穩(wěn)定的排序算法。現(xiàn)在,我們集中精力來(lái)看快排的時(shí)間復(fù)雜度。

快排也是用遞歸來(lái)實(shí)現(xiàn)的。對(duì)于遞歸代碼的時(shí)間復(fù)雜度,我前面總結(jié)的公式,這里也還是適用的。如果每次分區(qū)操作,都能正好把數(shù)組分成大小接近相等的兩個(gè)小區(qū)間,那快排的時(shí)間復(fù)雜度遞推求解公式跟歸并是相同的。所以,快排的時(shí)間復(fù)雜度也是O(nlogn)。

T(1) = C;   n=1 時(shí),只需要常量級(jí)的執(zhí)行時(shí)間,所以表示為 C。
T(n) = 2*T(n/2) + n; n>1

但是,公式成立的前提是每次分區(qū)操作,我們選擇的pivot都很合適,正好能將大區(qū)間對(duì)等地一分為二。但實(shí)際上這種情況是很難實(shí)現(xiàn)的。

我舉一個(gè)比較極端的例子。如果數(shù)組中的數(shù)據(jù)原來(lái)已經(jīng)是有序的了,比如1, 3, 5, 6, 8。如果我們每次選擇最后- -個(gè)元素作為pivot,那每次分區(qū)得到的兩個(gè)區(qū)間都是不均等的。我們需要進(jìn)行大約n次分區(qū)操作,才能完成快排的整個(gè)過(guò)程。每次分區(qū)我們平均要掃描大約n/2個(gè)元素,這種情況下,快排的時(shí)間復(fù)雜度就從0(nlogn)退化成了0(n2)。

我們剛剛講了兩個(gè)極端情況下的時(shí)間復(fù)雜度,一個(gè)是分區(qū)極其均衡,一個(gè)是分區(qū)極其不均衡。它們分別對(duì)應(yīng)快排的最好情況時(shí)間復(fù)雜度和最壞情況時(shí)間復(fù)雜度。那快排的平均情況時(shí)間復(fù)雜度是多少呢?

我們假設(shè)每次分區(qū)操作都將區(qū)間分成大小為9:1的兩個(gè)小區(qū)間。我們繼續(xù)套用遞歸時(shí)間復(fù)雜度的遞推公式,就會(huì)變成這樣:

T(1) = C;   n=1 時(shí),只需要常量級(jí)的執(zhí)行時(shí)間,所以表示為 C。

T(n) = T(n/10) + T(9*n/10) + n; n>1

這個(gè)公式的遞推求解的過(guò)程非常復(fù)雜,雖然可以求解,但我不推薦用這種方法。實(shí)際上,遞歸的時(shí)間復(fù)雜度的求解方法除了遞推公式之外,還有遞歸樹(shù),在樹(shù)那一節(jié)我再講,這里暫時(shí)不說(shuō)。我這里直接給你結(jié)論: T(n) 在大部分情況下的時(shí)間復(fù)雜度都可以做到O(nlogn),只有在極端情況下,才會(huì)退化到O(n2)。而且,我們也有很多方法將這個(gè)概率降到很低,如何來(lái)做?我們后面章節(jié)再講。

參考

最后:

自己寫(xiě)了一個(gè)NSMutableArray+GLYSort算法分類(lèi),只需1行代碼,即可完成復(fù)雜排序操作。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容