一、什么是算法
算法(Algorithm)是指解題方案的准確而完整的描述,是一系列解決問題的清晰指令,算法代表着用系統的方法描述解決問題的策略機制。也就是說,能夠對一定規范的輸入,在有限時間內獲得所要求的輸出。如果一個算法有缺陷,或不適合於某個問題,執行這個算法將不會解決這個問題。不同的算法可能用不同的時間、空間或效率來完成同樣的任務。一個算法的優劣可以用空間復雜度與時間復雜度來衡量。
算法中的指令描述的是一個計算,當其運行時能從一個初始狀態和(可能為空的)初始輸入開始,經過一系列有限而清晰定義的狀態,最終產生輸出並停止於一個終態。一個狀態到另一個狀態的轉移不一定是確定的。隨機化算法在內的一些算法,包含了一些隨機輸入。
二、算法的幾大特征
一個算法應該具有 “有窮性”、“確切性”、“輸入項”、“輸出項”、“可行性” 等重要的特征。這些特征對應的含義如下:
有窮性(Finiteness)-- 算法的有窮性是指算法必須能在執行有限個步驟之后終止;
確切性 (Definiteness) -- 算法的每一步驟必須有確切的定義;
輸入項 (Input) -- 一個算法有0個或多個輸入,以刻畫運算對象的初始情況,所謂0個輸入是指算法本身定出了初始條件;
輸出項 (Output) -- 一個算法有一個或多個輸出,以反映對輸入數據加工后的結果。沒有輸出的算法是毫無意義的;
可行性 (Effectiveness) -- 算法中執行的任何計算步驟都是可以被分解為基本的可執行的操作步,即每個計算步都可以在有限時間內完成(也稱之為有效性)。
三、算法兩大要素
1. 數據對象的運算和操作
計算機可以執行的基本操作是以指令的形式描述的。一個計算機系統能執行的所有指令的集合,成為該計算機系統的指令系統。
一個計算機的基本運算和操作有如下四類:
1) 算術運算:加減乘除等運算
2) 邏輯運算:或、且、非等運算
3) 關系運算:大於、小於、等於、不等於等運算
4) 數據傳輸:輸入、輸出、賦值等運算 [1]
2. 算法的控制結構
一個算法的功能結構不僅取決於所選用的操作,而且還與各操作之間的執行順序有關。
四、算法好壞的評定
你說這個算法好、他卻說這個算法不好,兩人爭論不休。那么好與不好應該怎么評定呢?
同一問題可用不同算法解決,而一個算法的質量優劣將影響到程序的效率。算法分析的目的在於選擇合適算法和改進算法;一個算法的評價主要從時間復雜度和空間復雜度來考慮。
1. 時間復雜度
算法的時間復雜度是指執行算法所需要的計算工作量。一般來說,計算機算法是問題規模n 的函數f(n),算法的時間復雜度也因此記做。T(n)=Ο(f(n)),因此,問題的規模n 越大,算法執行的時間的增長率與f(n) 的增長率正相關,稱作漸進時間復雜度(Asymptotic Time Complexity)。
2. 空間復雜度
算法的空間復雜度是指算法需要消耗的內存空間。其計算和表示方法與時間復雜度類似,一般都用復雜度的漸近性來表示。同時間復雜度相比,空間復雜度的分析要簡單得多。
3. 正確性
算法的正確性是評價一個算法優劣的最重要的標准。
4. 可讀性
算法的可讀性是指一個算法可供人們閱讀的容易程度。
5.健壯性
健壯性是指一個算法對不合理數據輸入的反應能力和處理能力,也稱為容錯性。
以上的理論知識可以讓我們對算法有大致的理解和認知,接下來我們將使用 Python 實現幾個經典的排序算法,並在文末對比 Java 的實現。
- 內部排序指的是在內存中進行排序;
- 外部排序指的是由於數據量較大,無法讀入內存而需要在排序過程中訪問外部存儲的情況;
名詞解釋:
n:數據規模
k:“桶”的個數
In-place:占用常數內存,不占用額外內存
Out-place:占用額外內存
穩定性:排序后2個相等鍵值的順序和排序之前它們的順序相同
五、十種排序算法舉例
1. 冒泡排序(Bubble Sort)
冒泡排序須知:
冒泡排序每次找出一個最大的元素,因此需要遍歷 n-1 次。還有一種優化算法,就是立一個flag,當在一趟序列遍歷中元素沒有發生交換,則證明該序列已經有序。但這種改進對於提升性能來說並沒有什么太大作用。
什么時候最快(Best Cases):
當輸入的數據已經是正序時。
什么時候最慢(Worst Cases):
當輸入的數據是反序時。
冒泡排序動圖演示:
冒泡排序 Python 代碼實現:
def bubbleSort(nums):
for i in range(len(nums) - 1): # 遍歷 len(nums)-1 次
for j in range(len(nums) - i - 1): # 已排好序的部分不用再次遍歷
if nums[j] > nums[j+1]:
nums[j], nums[j+1] = nums[j+1], nums[j] # Python 交換兩個數不用中間變量
return nums
2.選擇排序(Selection Sort)
選擇排序須知:
選擇排序不受輸入數據的影響,即在任何情況下時間復雜度不變。選擇排序每次選出最小的元素,因此需要遍歷 n-1 次。
選擇排序動圖演示:
選擇排序 Python 代碼實現:
def selectionSort(nums):
for i in range(len(nums) - 1): # 遍歷 len(nums)-1 次
minIndex = i
for j in range(i + 1, len(nums)):
if nums[j] < nums[minIndex]: # 更新最小值索引
minIndex = j
nums[i], nums[minIndex] = nums[minIndex], nums[i] # 把最小數交換到前面
return nums
3. 插入排序(Insertion Sort)
插入排序須知:
插入排序如同打撲克一樣,每次將后面的牌插到前面已經排好序的牌中。插入排序有一種優化算法,叫做拆半插入。因為前面是局部排好的序列,因此可以用折半查找的方法將牌插入到正確的位置,而不是從后往前一一比對。折半查找只是減少了比較次數,但是元素的移動次數不變,所以時間復雜度仍為 O(n^2) !
插入排序動圖演示:
插入排序 Python 代碼實現:
def insertionSort(nums):
for i in range(len(nums) - 1): # 遍歷 len(nums)-1 次
curNum, preIndex = nums[i+1], i # curNum 保存當前待插入的數
while preIndex >= 0 and curNum < nums[preIndex]: # 將比 curNum 大的元素向后移動
nums[preIndex + 1] = nums[preIndex]
preIndex -= 1
nums[preIndex + 1] = curNum # 待插入的數的正確位置
return nums
4. 希爾排序(Shell Sort)
希爾排序須知:
希爾排序是插入排序的一種更高效率的實現。它與插入排序的不同之處在於,它會優先比較距離較遠的元素。
【例子】對於待排序列 {44,12,59,36,62,43,94,7,35,52,85},我們可設定增量序列為 {5,3,1}。
【解析】第一個增量為 5,因此 {44,43,85}、{12,94}、{59,7}、{36,35}、{62,52} 分別隸屬於同一個子序列,子序列內部進行插入排序;然后選取第二個增量3,因此 {43,35,94,62}、{12,52,59,85}、{7,44,36} 分別隸屬於同一個子序列;最后一個增量為 1,這一次排序相當於簡單插入排序,但是經過前兩次排序,序列已經基本有序,因此此次排序時間效率就提高了很多。希爾排序過程如下:
希爾排序的核心在於間隔序列的設定。既可以提前設定好間隔序列,也可以動態的定義間隔序列。動態定義間隔序列的算法是《算法(第4版》的合著者 Robert Sedgewick 提出的。在這里,我就使用了這種方法。
希爾排序 Python 代碼實現:
def shellSort(nums):
lens = len(nums)
gap = 1
while gap < lens // 3:
gap = gap * 3 + 1 # 動態定義間隔序列
while gap > 0:
for i in range(gap, lens):
curNum, preIndex = nums[i], i - gap # curNum 保存當前待插入的數
while preIndex >= 0 and curNum < nums[preIndex]:
nums[preIndex + gap] = nums[preIndex] # 將比 curNum 大的元素向后移動
preIndex -= gap
nums[preIndex + gap] = curNum # 待插入的數的正確位置
gap //= 3 # 下一個動態間隔
return nums
5. 歸並排序(Merge Sort)
歸並排序須知:
作為一種典型的分而治之思想的算法應用,歸並排序的實現由兩種方法:
- 自上而下的遞歸(所有遞歸的方法都可以用迭代重寫,所以就有了第2種方法)
- 自下而上的迭代
和選擇排序一樣,歸並排序的性能不受輸入數據的影響,但表現比選擇排序好的多,因為始終都是O(n log n)的時間復雜度。代價是需要額外的內存空間。
歸並排序動圖演示:
歸並排序 Python 代碼實現:
def mergeSort(nums):
# 歸並過程
def merge(left, right):
result = [] # 保存歸並后的結果
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result = result + left[i:] + right[j:] # 剩余的元素直接添加到末尾
return result
# 遞歸過程
if len(nums) <= 1:
return nums
mid = len(nums) // 2
left = mergeSort(nums[:mid])
right = mergeSort(nums[mid:])
return merge(left, right)
6. 快速排序(Quick Sort)
快速排序須知:
又是一種分而治之思想在排序算法上的典型應用。本質上來看,快速排序應該算是在冒泡排序基礎上的遞歸分治法。它是處理大數據最快的排序算法之一,雖然 Worst Case 的時間復雜度達到了 O(n²),但是在大多數情況下都比平均時間復雜度為 O(n log n) 的排序算法表現要更好,因為 O(n log n) 記號中隱含的常數因子很小,而且快速排序的內循環比大多數排序算法都要短小,這意味着它無論是在理論上還是在實際中都要更快,比復雜度穩定等於 O(n log n) 的歸並排序要小很多。所以,對絕大多數順序性較弱的隨機數列而言,快速排序總是優於歸並排序。它的主要缺點是非常脆弱,在實現時要非常小心才能避免低劣的性能。
快速排序動圖演示:
快速排序 Python 代碼實現:
def quickSort(nums): # 這種寫法的平均空間復雜度為 O(nlogn)
if len(nums) <= 1:
return nums
pivot = nums[0] # 基准值
left = [nums[i] for i in range(1, len(nums)) if nums[i] < pivot]
right = [nums[i] for i in range(1, len(nums)) if nums[i] >= pivot]
return quickSort(left) + [pivot] + quickSort(right)
'''
@param nums: 待排序數組
@param left: 數組上界
@param right: 數組下界
'''
def quickSort2(nums, left, right): # 這種寫法的平均空間復雜度為 O(logn)
# 分區操作
def partition(nums, left, right):
pivot = nums[left] # 基准值
while left < right:
while left < right and nums[right] >= pivot:
right -= 1
nums[left] = nums[right] # 比基准小的交換到前面
while left < right and nums[left] <= pivot:
left += 1
nums[right] = nums[left] # 比基准大交換到后面
nums[left] = pivot # 基准值的正確位置,也可以為 nums[right] = pivot
return left # 返回基准值的索引,也可以為 return right
# 遞歸操作
if left < right:
pivotIndex = partition(nums, left, right)
quickSort2(nums, left, pivotIndex - 1) # 左序列
quickSort2(nums, pivotIndex + 1, right) # 右序列
return nums
7. 堆排序(Heap Sort)
堆排序須知:
堆排序可以說是一種利用堆的概念來排序的選擇排序。分為兩種方法:
- 大根堆:每個節點的值都大於或等於其子節點的值,用於升序排列;
- 小根堆:每個節點的值都小於或等於其子節點的值,用於降序排列。
如下圖所示,首先將一個無序的序列生成一個最大堆,如圖(a)所示。接下來我們不需要將堆頂元素輸出,只要將它與堆的最后一個元素對換位置即可,如圖(b)所示。這時我們確知最后一個元素 99 一定是遞增序列的最后一個元素,而且已經在正確的位置上。 現在問題變成了如何將剩余的元素重新生成一個最大堆——也很簡單,只要依次自上而下進行過濾,使其符合最大堆的性質。圖(c)是調整后形成的新的最大堆。要注意的是,99 已經被排除在最大堆之外,即在調整的時候,堆中元素的個數應該減 1 。結束第 1 輪調整后,再次將當前堆中的最后一個元素 22 與堆頂元素換位,如圖(d)所示,再繼續調整成新的最大堆……如此循環,直到堆中只剩 1 個元素,即可停止,得到一個從小到大排列的有序序列。
堆排序動圖演示:
堆排序 Python 代碼實現:
# 大根堆(從小打大排列)
def heapSort(nums):
# 調整堆
def adjustHeap(nums, i, size):
# 非葉子結點的左右兩個孩子
lchild = 2 * i + 1
rchild = 2 * i + 2
# 在當前結點、左孩子、右孩子中找到最大元素的索引
largest = i
if lchild < size and nums[lchild] > nums[largest]:
largest = lchild
if rchild < size and nums[rchild] > nums[largest]:
largest = rchild
# 如果最大元素的索引不是當前結點,把大的結點交換到上面,繼續調整堆
if largest != i:
nums[largest], nums[i] = nums[i], nums[largest]
# 第 2 個參數傳入 largest 的索引是交換前大數字對應的索引
# 交換后該索引對應的是小數字,應該把該小數字向下調整
adjustHeap(nums, largest, size)
# 建立堆
def builtHeap(nums, size):
for i in range(len(nums)//2)[::-1]: # 從倒數第一個非葉子結點開始建立大根堆
adjustHeap(nums, i, size) # 對所有非葉子結點進行堆的調整
# print(nums) # 第一次建立好的大根堆
# 堆排序
size = len(nums)
builtHeap(nums, size)
for i in range(len(nums))[::-1]:
# 每次根結點都是最大的數,最大數放到后面
nums[0], nums[i] = nums[i], nums[0]
# 交換完后還需要繼續調整堆,只需調整根節點,此時數組的 size 不包括已經排序好的數
adjustHeap(nums, 0, i)
return nums # 由於每次大的都會放到后面,因此最后的 nums 是從小到大排列
8. 計數排序(Counting Sort)
計數排序須知:
計數排序要求輸入數據的范圍在 [0,N-1] 之間,則可以開辟一個大小為 N 的數組空間,將輸入的數據值轉化為鍵存儲在該數組空間中,數組中的元素為該元素出現的個數。它是一種線性時間復雜度的排序。
計數排序動圖演示:
計數排序 Python 代碼實現:
def countingSort(nums):
bucket = [0] * (max(nums) + 1) # 桶的個數
for num in nums: # 將元素值作為鍵值存儲在桶中,記錄其出現的次數
bucket[num] += 1
i = 0 # nums 的索引
for j in range(len(bucket)):
while bucket[j] > 0:
nums[i] = j
bucket[j] -= 1
i += 1
return nums
9. 桶排序(Bucket Sort)
桶排序須知:
桶排序是計數排序的升級版。它利用了函數的映射關系,高效與否的關鍵就在於這個映射函數的確定。
為了使桶排序更加高效,我們需要做到這兩點:
- 在額外空間充足的情況下,盡量增大桶的數量
- 使用的映射函數能夠將輸入的 N 個數據均勻的分配到 K 個桶中
同時,對於桶中元素的排序,選擇何種比較排序算法對於性能的影響至關重要。
什么時候最快(Best Cases):
當輸入的數據可以均勻的分配到每一個桶中
什么時候最慢(Worst Cases):
當輸入的數據被分配到了同一個桶中
桶排序 Python 代碼實現:
def bucketSort(nums, defaultBucketSize = 5):
maxVal, minVal = max(nums), min(nums)
bucketSize = defaultBucketSize # 如果沒有指定桶的大小,則默認為5
bucketCount = (maxVal - minVal) // bucketSize + 1 # 數據分為 bucketCount 組
buckets = [] # 二維桶
for i in range(bucketCount):
buckets.append([])
# 利用函數映射將各個數據放入對應的桶中
for num in nums:
buckets[(num - minVal) // bucketSize].append(num)
nums.clear() # 清空 nums
# 對每一個二維桶中的元素進行排序
for bucket in buckets:
insertionSort(bucket) # 假設使用插入排序
nums.extend(bucket) # 將排序好的桶依次放入到 nums 中
return nums
10. 基數排序(Radix Sort)
基數排序須知:
基數排序是桶排序的一種推廣,它所考慮的待排記錄包含不止一個關鍵字。例如對一副牌的整理,可將每張牌看作一個記錄,包含兩個關鍵字:花色、面值。一般我們可以將一個有序列是先按花色划分為四大塊,每一塊中又再按面值大小排序。這時“花色”就是一張牌的“最主位關鍵字”,而“面值”是“最次位關鍵字”。
基數排序有兩種方法:
- MSD (主位優先法):從高位開始進行排序
- LSD (次位優先法):從低位開始進行排序
LSD基數排序動圖演示:
基數排序 Python 代碼實現:
# LSD Radix Sort
def radixSort(nums):
mod = 10
div = 1
mostBit = len(str(max(nums))) # 最大數的位數決定了外循環多少次
buckets = [[] for row in range(mod)] # 構造 mod 個空桶
while mostBit:
for num in nums: # 將數據放入對應的桶中
buckets[num // div % mod].append(num)
i = 0 # nums 的索引
for bucket in buckets: # 將數據收集起來
while bucket:
nums[i] = bucket.pop(0) # 依次取出
i += 1
div *= 10
mostBit -= 1
return nums
補充:外部排序
外部排序是指大文件排序,即待排序的數據記錄以文件的形式存儲在外存儲器上。由於文件中的記錄很多、信息容量龐大,所以整個文件所占據的存儲單元往往會超過了計算機的內存量,因此,無法將整個文件調入內存中進行排序。於是,在排序過程中需進行多次的內外存之間的交換。在實際應用中,由於使用的外設不一致,通常可以分為磁盤文件排序和磁帶文件排序兩大類。
外部排序基本上由兩個相對獨立的階段組成。首先,按可用內存大小,將外存上含 N 個記錄的文件分成若干長度為 L(<N) 的子文件,依次讀入內存,利用內部排序算法進行排序。然后,將排序后的文件寫入外存,通常將這些文件稱為歸並段(Run)或“順串”;對這些歸並段進行逐步歸並,最終得到整個有序文件。可見外部排序的基本方法是歸並排序法,下面的例子給出了一個簡單的外部排序解決過程。
【例子】給定磁盤上有6大塊記錄需要排序,而計算機內存最多只能對3個記錄塊進行內排序,則外部排序的過程如下圖所示。
【解析】首先將連續的3大塊記錄讀入內存,用任何一種內部排序算法完成排序,再寫回磁盤。經過2次3大塊記錄的內部排序,得到上圖(a)的結果。然后另用一個可容納6大塊記錄的周轉盤,輔助最后的歸並。方法是將內存分成3塊,其中2塊用於輸入,1塊用於輸出,指定一個輸入塊只負責讀取一個歸並段中的記錄,如上圖(b)所示。歸並步驟為:
當任一輸入塊為空時,歸並暫停,將相應歸並段中的一塊信息寫入內存
將內存中2個輸入塊中的記錄逐一歸並入輸出塊
當輸出塊寫滿時,歸並暫停,將輸出塊中的記錄寫入周轉盤
如此可將2個歸並段在周轉盤上歸並成一個有序的歸並段。上例的解決方法是最簡單的歸並法,事實上外部排序的效率還可以進一步提高。要提高外排的效率,關鍵要解決以下4個問題:
- 如何減少歸並輪數
- 如何有效安排內存中的輸入、輸出塊,使得機器的並行處理能力被最大限度利用
- 如何有效生成歸並段
- 如何將歸並段進行有效歸並
針對這四大問題,人們設計了多種解決方案,例如釆用多路歸並取代簡單的二路歸並,就可以減少歸並輪數;例如在內存中划分出2個輸出塊,而不是只用一個,就可以設計算法使得歸並排序不會因為磁盤的寫操作而暫停,達到歸並和寫周轉盤同時並行的效果;例如通過一種“敗者樹”的數據結構,可以一次生成2倍於內存容量的歸並段;例如利用哈夫曼樹的貪心策略選擇歸並次序,可以耗費最少的磁盤讀寫時間等。
六、其他一些比較
基數排序 vs 計數排序 vs 桶排序
這三種排序算法都利用了桶的概念,但對桶的使用方法上有明顯差異
基數排序:根據鍵值的每位數字來分配桶
計數排序:每個桶只存儲單一鍵值
桶排序:每個桶存儲一定范圍的數值
哪些排序算法可以在未結束排序時找出第 k 大元素?
冒泡、選擇、堆排序、快排(想想為什么?)
七、總結
本章用 Python3 語言實現了經典的十大排序算法,對它們的優缺點、復雜度等方面進行了詳細的比較。最后,還對外部排序進行了簡單的介紹。
快排、歸並排序、堆排序、計數排序(桶排序)一般是面試中常問的題目,筆者覺得其中比較難的是堆排序,因為涉及建堆、調整堆的過程,手寫該算法還是有一定難度的。