算法專題 - 快排問題

快排問題

快排核心思想就是分區分治

算法原理

快排的思想是這樣的:如果要排序數組中下標從p到r之間的一組數據,我們選擇p到r之間的任意一個數據作爲pivot(分區點)。

然後遍歷p到r之間的數據,將小於pivot的放到左邊,將大於pivot的放到右邊,將povit放到中間。經過這一步之後,數組p到r之間的數據就分成了3部分,前面p到q-1之間都是小於povit的,中間是povit,後面的q+1到r之間是大於povit的。

根據分治、遞歸的處理思想,我們可以用遞歸排序下標從p到q-1之間的數據和下標從q+1到r之間的數據,直到區間縮小爲1,就說明所有的數據都有序了。

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

終止條件:p >= r

代碼實現


快排的思想是這樣的:如果要排序數組中下標從 p 到 r 之間的一組數據,我們選擇 p 到 r 之間的任意一個數據作爲 pivot(分區點)。

我們遍歷 p 到 r 之間的數據,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。經過這一步驟之後,數組 p 到 r 之間的數據就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。

根據分治、遞歸的處理思想,我們可以用遞歸排序下標從 p 到 q-1 之間的數據和下標從 q+1 到 r 之間的數據,直到區間縮小爲 1,就說明所有的數據都有序了。


// 快速排序,A 是數組,n 表示數組的大小
quick_sort(A, n) {
  quick_sort_c(A, 0, n-1)
}
// 快速排序遞歸函數,p,r 爲下標
quick_sort_c(A, p, r) {
  if p >= r then return
  q = partition(A, p, r) // 獲取分區點
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q+1, r)
}
//分區函數
partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i
}
分區函數代碼說明:通過遊標i把A[p...r-1]分成2部分,A[p...i-1]的元素都是小於pivot的,我們暫且叫它“已處理區間”,A[i+1...r-1]是“未處理區間”。我們每次都從未處理區間取出一個元素A[j],與poivt相比,如果小於pivot,則將其加入到已處理區間的尾部,也就是A[i]位置。


def quick_sort2(lists, left, right):
    """
    通過一趟排序將要排序的數據分割成獨立的兩部分,其中一部分的所有數據都比另外一部分的所有數據都要小,
    然後再按此方法對這兩部分數據分別進行快速排序,整個排序過程可以遞歸進行,以此達到整個數據變成有序序列。

    :param lists: 
    :param left: 
    :param right: 
    :return:
    """
    if left >= right:
        return lists
    pivot = lists[left]
    low = left
    high = right
    while left != right:
        while left < right and lists[right] >= pivot:
            right -= 1
        lists[left] = lists[right]
        while left < right and lists[left] <= pivot:
            left += 1
        lists[right] = lists[left]
    lists[right] = pivot
    quick_sort2(lists, low, left - 1)
    quick_sort2(lists, left + 1, high)
    return lists

性能分析

  • 算法穩定性:

因爲分區過程中涉及交換操作,如果數組中有兩個8,其中一個是pivot,經過分區處理後,後面的8就有可能放到了另一個8的前面,先後順序就顛倒了,所以快速排序是不穩定的排序算法。比如數組[1,2,3,9,8,11,8],取後面的8作爲pivot,那麼分區後就會將後面的8與9進行交換。

  • 時間複雜度:最好、最壞、平均情況

快排也是用遞歸實現的,所以時間複雜度也可以用遞推公式表示。

如果每次分區操作都能正好把數組分成大小接近相等的兩個小區間,那快排的時間複雜度遞推求解公式跟歸併的相同。

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

T(n) = 2*T(n/2) + n; n>1

所以,快排的時間複雜度也是O(nlogn)

如果數組中的元素原來已經有序了,比如1,3,5,6,8,若每次選擇最後一個元素作爲pivot,那每次分區得到的兩個區間都是不均等的,需要進行大約n次的分區,才能完成整個快排過程,而每次分區我們平均要掃描大約n/2個元素,這種情況下,快排的時間複雜度就是O(n^2)。

前面兩種情況,一個是分區及其均衡,一個是分區極不均衡,它們分別對應了快排的最好情況時間複雜度和最壞情況時間複雜度。那快排的平均時間複雜度是多少呢?T(n)大部分情況下是O(nlogn),只有在極端情況下才是退化到O(n^2),而且我們也有很多方法將這個概率降低。

  • 空間複雜度:快排是一種原地排序算法,空間複雜度是O(1)

應用

  • O(n)時間複雜度內求無序數組中第K大元素,比如4,2,5,12,3這樣一組數據,第3大元素是4。

快排核心思想就是分治分區

我們選擇數組區間A[0…n-1]的最後一個元素作爲pivot,對數組A[0…n-1]進行原地分區,這樣數組就分成了3部分,A[0…p-1]、A[p]、A[p+1…n-1]。

如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,說明第K大元素出現在A[p+1…n-1]區間,我們按照上面的思路遞歸地在A[p+1…n-1]這個區間查找。同理,如果K<p+1,那我們就在A[0…p-1]區間查找。

時間複雜度分析?

第一次分區查找,我們需要對大小爲n的數組進行分區操作,需要遍歷n個元素。第二次分區查找,我們需要對大小爲n/2的數組執行分區操作,需要遍歷n/2個元素。依次類推,分區遍歷元素的個數分別爲n、n/2、n/4、n/8、n/16…直到區間縮小爲1。如果把每次分區遍歷的元素個數累加起來,就是等比數列求和,結果爲2n-1。所以,上述解決問題的思路爲O(n)。

你可能會說,我有個很笨的辦法,每次取數組中的最小值,將其移動到數組的最前面,然後在剩下的數組中繼續找最小值,以此類推,執行 K 次,找到的數據不就是第 K 大元素了嗎?

不過,時間複雜度就並不是 O(n) 了,而是 O(K * n)。你可能會說,時間複雜度前面的係數不是可以忽略嗎?O(K * n) 不就等於 O(n) 嗎?

這個可不能這麼簡單地劃等號。當 K 是比較小的常量時,比如 1、2,那最好時間複雜度確實是 O(n);但當 K 等於 n/2 或者 n 時,這種最壞情況下的時間複雜度就是 O(n^2)。

  • 有10個訪問日誌文件,每個日誌文件大小約爲300MB,每個文件裏的日誌都是按照時間戳從小到大排序的。現在需要將這10個較小的日誌文件合併爲1個日誌文件,合併之後的日誌仍然按照時間戳從小到大排列。如果處理上述任務的機器內存只有1GB,你有什麼好的解決思路能快速地將這10個日誌文件合併?

先構建十條io流,分別指向十個文件,每條io流讀取對應文件的第一條數據,然後比較時間戳,選擇出時間戳最小的那條數據,將其寫入一個新的文件,然後指向該時間戳的io流讀取下一行數據,然後繼續剛纔的操作,比較選出最小的時間戳數據,寫入新文件,io流讀取下一行數據,以此類推,完成文件的合併, 這種處理方式,日誌文件有n個數據就要比較n次,每次比較選出一條數據來寫入,時間複雜度是O(n),空間複雜度是O(1)

Leetcode

  • 148 排序鏈表
# Definition for singly-linked list.
# class ListNode(object):
#     def __init__(self, x):
#         self.val = x
#         self.next = None

class Solution(object):
    def sortList(self, head):
        """
        :type head: ListNode
        :rtype: ListNode
        分治
        """
        if not head or not head.next:
            return head

        second = self.findMid(head) # 找到鏈表後半段的head
        l = self.sortList(head)
        r = self.sortList(second)
        return self.merge(l, r)

    def merge(self, l, r): # O(NlgN)
        if not l or not r:
            return l or r
        dummy = head = ListNode(None)
        head.next = l
        while l and r:
            if l.val < r.val:
                head.next = l
                l = l.next
            else:
                head.next = r
                r = r.next
            head = head.next
        head.next = l or r # l and r at least one is None
        return dummy.next
    
    def findMid(self, head):
        fast, slow = head, head 
        while fast.next and fast.next.next:
            fast = fast.next.next
            slow = slow.next
        second = slow.next
        slow.next = None
        return second
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章