本篇是《數據結構 & 算法 in Swift》系列連載的第二篇,內容分爲以下兩個部分:前端
該部分是給那些對算法以及相關知識不瞭解的讀者準備的,若是已經對算法的相關知識有所瞭解,能夠略過該部分,直接看本文的第二部分:排序算法。git
關於該部分的討論不屬於本文介紹的重點,所以沒有過多很是專業的論述,只是讓那些對算法不瞭解的讀者能夠對算法先有一個基本的認識,爲閱讀和理解本文的第二部分作好準備。程序員
算法是解決特定問題求解步驟的描述,在計算機中表現爲指令的有限序列,而且每條指令表示一個或多個操做。github
摘自《大話數據結構》面試
簡單說來,算法就是「一個問題的解法」。對於相同一個問題,可能會有多種不一樣的解法。這些解法雖然能夠獲得相同的結果,可是每一個算法的執行所須要的時間和空間資源卻能夠是千差萬別的。算法
以消耗的時間的角度爲出發點,咱們看一下對於同一個問題,兩種不一樣的解法的效率會相差多大:編程
如今讓咱們解決這個問題:計算從1到100數字的總和。swift
把比較容易想到的下面兩種方法做爲比較:後端
用Swift函數來分別實現一下:數組
func sumOpration1(_ n:Int) -> Int{
var sum = 0
for i in 1 ... n {
sum += i
}
return sum
}
sumOpration1(100)//5050
func sumOpration2(_ n:Int) -> Int{
return (1 + n) * n/2
}
sumOpration2(100)//5050
複製代碼
上面的代碼中,sumOpration1
使用的是循環遍歷的方式;sumOpration2
使用的是等差數列求和的公式。
雖然兩個函數都能獲得正確的結果,可是不難看出兩個函數實現的效率是有區別的:
遍歷求和所須要的時間是依賴於傳入函數的n的大小的,而等差數列求和的方法所須要的時間對傳入的n的大小是徹底不依賴的。
在遍歷求和中,若是傳入的n值是100,則須要遍歷100次並相加才能獲得結果,那麼若是傳入的n值是一百萬呢?
而在等差數列求和的函數中,不管n值有多大,只須要一個公式就能夠解決。
咱們對此能夠以小見大:世上千千萬萬種問題(算法題)可能也有相似的狀況:相同的問題,相同的結果,可是執行效率缺差之千里。那麼有沒有什麼方法能夠度量某種算法的執行效率以方便人們去選擇或是衡量算法之間的差別呢? 答案是確定的。
下面筆者就向你們介紹算法所消耗資源的兩個維度:時間複雜度和空間複雜度。
算法的時間複雜度是指算法須要消耗的時間資源。通常來講,計算機算法是問題規模!n的函數f(n),算法的時間複雜度也所以記作:
常見的時間複雜度有:常數階O(1),對數階O(log n),線性階 O(n),線性對數階O(nlog n),平方階O(n^{2}),立方階O(n^{3}),!k次方階O(n^{k}),指數階 O(2^{n})}。隨着問題規模n的不斷增大,上述時間複雜度不斷增大,算法的執行效率越低。
拿其中幾個複雜度作對比:
從上圖中咱們能夠看到,平方階O(n^{2})隨着n值的增大,其複雜度近乎直線飆升;而線性階 O(n)隨着n的增大,複雜度是線性增加的;咱們還能夠看到常數階 O(1)隨着n增大,其複雜度是不變的。
參考上一節的求和問題,咱們能夠看出來遍歷求和的算法複雜度是線性階O(n):隨着求和的最大數值的大小而線性增加;而等差數列求和算法的複雜度爲常數階 O(1)其算法複雜度與輸入n值的大小無關。
讀者能夠試着想一個算法的複雜度與輸入值n的平方成正比的算法。
在這裏筆者舉一個例子:求一個數組中某兩個元素和爲某個值的元素index的算法。數組爲[0,2,1,3,6]
,和爲8:
func findTwoSum(_ array: [Int], target: Int) -> (Int, Int)? {
guard array.count > 1 else {
return nil
}
for i in 0..<array.count {
let left = array[i]
for j in (i + 1)..<array.count {
let right = array[j]
if left + right == target {
return (i, j)
}
}
}
return nil
}
let array = [0,2,1,3,6]
if let indexes = findTwoSum(array, target: 8) {
print(indexes) //1, 4
} else {
print("No pairs are found")
}
複製代碼
上面的算法準確地計算出了兩個元素的index爲1和4。由於使用了兩層的遍歷,因此這裏算法的複雜度是平方階O(n^{2})。
而其實,不須要遍歷兩層,只須要遍歷一層便可:在遍歷的時候,我麼知道當前元素的值a,那麼只要其他元素裏面有值等於(target - a)的值便可。因此此次算法的複雜度就是線性階O(n)了。
來看一下代碼的實現:
//O(n)
func findTwoSumOptimized(_ array: [Int], target: Int) -> (Int, Int)? {
guard array.count > 1 else {
return nil
}
var diffs = Dictionary<Int, Int>()
for i in 0..<array.count {
let left = array[i]
let right = target - left
if let foundIndex = diffs[right] {
return (i, foundIndex)
} else {
diffs[left] = i
}
}
return nil
}
複製代碼
一樣地,上面兩種算法雖然能夠達到相同的效果,可是當n很是大的時候,兩者的計算效率就會相差更大:n = 1000的時候,兩者獲得結果所須要的時間可能會差好幾百倍。能夠說平方階O(n^{2})複雜度的算法在數據量很大的時候是沒法讓人接受的。
算法的空間複雜度是指算法須要消耗的空間資源。其計算和表示方法與時間複雜度相似,通常都用複雜度的漸近性來表示。同時間複雜度相比,空間複雜度的分析要簡單得多。並且控件複雜度不屬於本文討論的重點,所以在這裏不展開介紹了。
在算法的實現中,遍歷與遞歸是常常出現的兩種操做。
對於遍歷,無非就是使用一個for循環來遍歷集合裏的元素,相信你們已經很是熟悉了。可是對於遞歸操做就可能比較陌生。並且因爲本文第二部分講解算法的是時候有兩個算法(也是比較重要)的算法使用了遞歸操做,因此爲了能幫助你們理解這兩個算法,筆者以爲有必要將遞歸單獨拿出來說解。
先看一下遞歸的概念。
遞歸的概念是:在數學與計算機科學中,是指在函數的定義中使用函數自身的方法摘自維基百科
摘自維基百科
經過使用遞歸,能夠把一個大型複雜的問題逐層轉化爲一個與原問題類似的規模較小的問題來求解。所以若是使用遞歸,能夠達到使用少許的代碼就可描述出解題過程所需的屢次重複計算的目的,減小了程序的代碼量 。
下面用一個例子來具體感覺一下遞歸操做:
你們應該都比較熟悉階乘的算法:3!= 3 * 2 * 1 ; 4!= 4 * 3 * 2 * 1
不難看出,在這裏反覆執行了一個逐漸-1和相乘的操做,若是可使用某段代碼達到重複調用的效果就很方便了,在這裏就可使用遞歸:
func factorial(_ n:Int) -> Int{
return n < 2 ? 1: n * factorial(n-1)
}
factorial(3) //6
複製代碼
在上面的代碼裏,factorial
函數調用了它本身,而且在n<2的時候返回了1;不然繼續調用本身。
從代碼自己其實不難理解函數調用的方式,可是這個6到底是怎麼算出來的呢?這就涉及到遞歸的實現原理了。
遞歸的調用其實是經過調用棧(callback stack)來實現的,筆者用一張圖從factorial(3)開始調用到最後得出6這個順序之間發生的事情畫了出來:
由上圖能夠看出,整個遞歸的過程和棧的入棧出棧的操做很是相似:橘黃色背景的圓角矩形表明了棧頂元素,也就是正在執行的操做,而灰色背景的圓角矩形則表明了其他的元素,它們的順序就是當初被調用的順序,並且在內容上保持了當時被調用時執行的代碼。
如今筆者按照時間順序從左到右來講明一下整個調用的過程:
按照筆者我的的理解:整個遞歸的過程能夠大體理解爲:在使遞歸繼續的條件爲false以前,持續遞歸調用,以棧的形式保存調用上下文(臨時變量,函數等)。一旦這個條件變爲true,則當即按照出棧的順序(入棧順序的逆序)來返回值,逐個傳遞,最終傳遞到最開始調用的那一層返回最終結果。
再簡單點,遞歸中的「遞」就是入棧,傳遞調用信息;「歸」就是出棧,輸出返回值。
而這個分界線就是遞歸的終止條件。很顯然,這個終止條件在整個遞歸過程當中起着舉足輕重的做用。試想一下,若是這個條件永遠不會改變,那麼就會一直入棧,就會發生棧溢出的狀況。
基於上面遞歸的例子,咱們將遞歸終止條件去掉:
func factorialInfinite(_ n:Int) -> Int{
return n * factorialInfinite(n-1)
}
factorialInfinite(3)
複製代碼
這段代碼若是放在playground裏,通過一小段時間(幾秒鐘或更多)後,會報一個運行時錯誤。也能夠在return語句上面寫一個print函數打印一些字符串,接着就會看到不停的打印,直到運行時錯誤,棧溢出。
因此說在從此寫關於遞歸的代碼的時候,必定要注意遞歸的終止條件是否合理,由於即便條件存在也不必定就是合理的條件。咱們看一下下面這個例子:
func sumOperation( _ n:Int) -> Int {
if n == 0 {
return 0
}
return n + sumOperation(n - 1)
}
sumOperation(2) //3
複製代碼
上面的代碼跟階乘相似,也是和小於當前參數的值相加,若是傳入2,那麼知道 n=0時就開始出棧,
2 + 1 + 0 = 3。看似沒什麼問題,可是若是一開始傳入 - 1 呢?結果就是不停的入棧,直到棧溢出。由於 n == 0 這個條件在傳入 - 1 的時候是沒法終止入棧的,由於 - 1 以後的 -1 操做都是非0的。
因此說這個條件就不是合理的,一個比較合理的條件是 n < = 0。
func sumOperation( _ n:Int) -> Int {
if n <= 0 {
return 0
}
return n + sumOperation(n - 1)
}
sumOperation(-1) //0
複製代碼
相信到這裏,讀者應該對遞歸的使用,調用過程以及注意事項有個基本的認識了。
那麼到這裏,關於算法的基本介紹已經講完了,下面正式開始講解排序算法。
講解算法以前,咱們先來看一下幾個常見的排序算法的對比:
排序算法 | 平均狀況下 | 最好狀況 | 最壞狀況 | 穩定性 | 空間複雜度 |
---|---|---|---|---|---|
冒泡 | O(n^2) | O(n) | O(n^2) | 穩定 | 1 |
選擇排序 | O(n^2) | O(n^2) | O(n^2) | 不穩定 | 1 |
插入排序 | O(n^2) | O(n) | O(n^2) | 穩定 | 1 |
希爾排序 | O(nlogn) | 依賴步長 | 依賴步長 | 穩定 | 1 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | 穩定 | 1 |
歸併排序 | O(nlogn) | O(nlogn) | O(nlogn) | 穩定 | O(n) |
快速排序 | O(nlogn) | O(nlogn) | O(n^2) | 不穩定 | O(logn) |
最好狀況和最壞狀況以及穩定性的概念不在本文的討論範圍以內,有興趣的讀者能夠查閱相關資料。
如今只看平均狀況下的性能:
本篇要給你們介紹的是冒泡排序,選擇排序,插入排序,歸併排序和快速排序。
希爾排序是基於插入排序,理解了插入排序之後,理解希爾排序會很容易,故在本文不作介紹。堆排序涉及到一個全新的數據結構:堆,因此筆者將堆這個數據結構和堆排序放在下一篇來作介紹。
在講排序算法以前,咱們先看一種最簡單的排序算法(也是性能最低的,也是最好理解的),在這裏先稱之爲「交換排序」。
注意,這個名稱是筆者本身起的,在互聯網和相關技術書籍上面沒有對該算法起名。
用兩個循環來嵌套遍歷:
咱們用一個例子看一下是怎麼作交換的:
給定一個初始數組:array = [4, 1, 2, 5, 0]
i = 0 時:
[1, 4, 2, 5, 0]
,內層的j繼續遍歷,j++。[0, 4, 2, 5, 1]
,i = 0的外層循環結束,i++。i = 1時:
[0, 2, 4, 5, 1]
,內層的j繼續遍歷,j++。[0, 1, 4, 5, 2]
,i = 1的外層循環結束,i++。i = 2 時:
[0, 1, 2, 5, 4]
,i = 2的外層循環結束,i++。i = 3 時:
[0, 1, 2, 4, 5]
,i = 3的外層循環結束,i++。i = 4 時:不符合內循環的邊界條件,不進行內循環,排序結束。
那麼用代碼如何實現呢?
func switchSort(_ array: inout [Int]) -> [Int] {
guard array.count > 1 else { return array }
for i in 0 ..< array.count {
for j in i + 1 ..< array.count {
if array[i] > array[j] {
array.swapAt(i, j)
}
}
}
return array
}
複製代碼
這裏面swapAt
函數是使用了Swift內置的數組內部交換兩個index的函數,在後面會常常用到。
爲了用代碼驗證上面所講解的交換過程,能夠在swapAt
函數下面將交換元素後的數組打印出來:
var originalArray = [4,1,2,5,0]
print("original array:\n\(originalArray)\n")
func switchSort(_ array: inout [Int]) -> [Int] {
guard array.count > 1 else { return array }
for i in 0 ..< array.count {
for j in i + 1 ..< array.count {
if array[i] > array[j] {
array.swapAt(i, j)
print("\(array)")
}
}
}
return array
}
switchSort(&originalArray)
複製代碼
打印結果:
original array:
[4, 1, 2, 5, 0]
switch sort...
[1, 4, 2, 5, 0]
[0, 4, 2, 5, 1]
[0, 2, 4, 5, 1]
[0, 1, 4, 5, 2]
[0, 1, 2, 5, 4]
[0, 1, 2, 4, 5]
複製代碼
驗證後咱們能夠看到,結果和上面分析的結果是同樣的。
各位讀者也能夠本身設置原數組,而後在運行代碼以前按照本身的理解,把每一次交換的結果寫出來,接着和運行算法以後進行對比。該方法對算法的理解頗有幫助,推薦你們使用~
請務必理解好上面的邏輯,能夠經過動筆寫結果的方式來幫助理解和鞏固,有助於對下面講解的排序算法的理解。
你們看上面的交換過程(排序過程)有沒有什麼問題?相信細緻的讀者已經看出來了:在原數組中,1和2都是比較靠前的位置,可是通過中間的排序之後,被放在了數組後方,而後再次又交換回來。這顯然是比較低效的,給人的感受像是作了無用功。
那麼有沒有什麼方法能夠優化一下交換的過程,讓交換後的結果與元素最終在數組的位置基本保持一致呢?
答案是確定的,這就引出了筆者要第一個正式介紹的排序算法冒泡排序:
與上面講的交換排序相似的是,冒泡排序也是用兩層的循環來實現的;但與其不一樣的是:
循環的邊界條件:冒泡排序的外層是[0,array.count-1);內層是[0,array.count-1-i)。能夠看到內層的範圍是不斷縮小的,並且範圍的前端不變,後端在向前移。
交換排序比較的是內外層索引的元素(array[i] 和 array[j]),可是冒泡排序比較的是兩個相鄰的內層索引的元素:array[j]和array[j+1]。
筆者用和上面交換排序使用的同一個數組來演示下元素是如何交換的:
初始數組:array = [4, 1, 2, 5, 0]
i = 0 時:
[1, 4, 2, 5, 0]
,內層的j繼續遍歷,j++。[1, 2, 4, 5, 0]
,內層的j繼續遍歷,j++。[1, 2, 4, 0, 5]
,i = 0的外層循環結束,i++。i = 1時:
[1, 2, 0, 4, 5]
,內層的j繼續遍歷,j++。i = 2 時:
[1, 0, 2, 4, 5]
,內層的j繼續遍歷,j++,直到退出i=2的外層循環,i++。i = 3 時:
[0, 1, 2, 4, 5]
,內層的j繼續遍歷,j++,直到退出i=3的外層循環,i++。i = 4 時:不符合外層循環的邊界條件,不進行外層循環,排序結束。
咱們來看一下冒泡排序的代碼:
func bubbleSort(_ array: inout [Int]) -> [Int] {
guard array.count > 1 else { return array }
for i in 0 ..< array.count - 1 {
for j in 0 ..< array.count - 1 - i {
if array[j] > array[j+1] {
array.swapAt(j, j+1)
}
}
}
return array
}
複製代碼
從上面的代碼咱們能夠清楚地看到循環遍歷的邊界條件和交換時機。一樣地,咱們添加上log,將冒泡排序每次交換後的數組打印出來(爲了進行對比,筆者將交換排序的log也打印了出來):
original array:
[4, 1, 2, 5, 0]
switch sort...
[1, 4, 2, 5, 0]
[0, 4, 2, 5, 1]
[0, 2, 4, 5, 1]
[0, 1, 4, 5, 2]
[0, 1, 2, 5, 4]
[0, 1, 2, 4, 5]
bubble sort...
[1, 4, 2, 5, 0]
[1, 2, 4, 5, 0]
[1, 2, 4, 0, 5]
[1, 2, 0, 4, 5]
[1, 0, 2, 4, 5]
[0, 1, 2, 4, 5]
複製代碼
從上面兩組打印能夠看出,冒泡排序算法解決了交換排序算法的不足:
如今咱們知道冒泡排序是好於交換排序的,並且它的作法是相鄰元素的兩兩比較:若是是逆序(左大右小)的話就作交換。
那麼若是在排序過程當中,數組已經變成有序的了,那麼再進行兩兩比較就很不划算了。
爲了證明上面這個排序算法的侷限性,咱們用新的測試用例來看一下:
var originalArray = [2,1,3,4,5]
複製代碼
並且此次咱們不只僅在交換之後打log,也記錄一下做比較的次數:
func bubbleSort(_ array: inout [Int]) -> [Int] {
guard array.count > 1 else { return array }
var compareCount = 0
for i in 0 ..< array.count - 1 {
for j in 0 ..< array.count - 1 - i {
compareCount += 1
print("No.\(compareCount) compare \(array[j]) and \(array[j+1])")
if array[j] > array[j+1] {
array.swapAt(j, j+1) //keeping index of j is the smaller one
print("after swap: \(array)")
}
}
}
return array
}
複製代碼
打印結果:
original array:
[2, 1, 3, 4, 5]
bubble sort...
No.1 compare 2 and 1
after swap: [1, 2, 3, 4, 5] //already sorted, but keep comparing
No.2 compare 2 and 3
No.3 compare 3 and 4
No.4 compare 4 and 5
No.5 compare 1 and 2
No.6 compare 2 and 3
No.7 compare 3 and 4
No.8 compare 1 and 2
No.9 compare 2 and 3
No.10 compare 1 and 2
複製代碼
從打印的結果能夠看出,其實在第一次交換過以後,數組已是有序的了,可是該算法仍是繼續在比較,作了不少無用功,能不能有個辦法可讓這種兩兩比較在已知有序的狀況下提早結束呢?答案是確定的。
提早結束這個操做很容易,咱們只須要跳出最外層的循環就行了。關鍵是這個時機:咱們須要讓算法本身知道何時數組已是有序的了。
是否已經想到了呢?就是在一次內循環事後,若是沒有發生元素交換,就說明數組已是有序的,不須要再次縮小內循環的範圍繼續比較了。因此咱們須要在外部設置一個布爾值的變量來標記「該數組是否有序」:
咱們將這個算法稱爲:advanced bubble sort
func bubbleSortAdvanced(_ array: inout [Int]) -> [Int] {
guard array.count > 1 else { return array }
for i in 0 ..< array.count - 1 {
//bool switch
var swapped = false
for j in 0 ..< array.count - i - 1 {
if array[j] > array [j+1] {
array.swapAt(j, j+1)
swapped = true;
}
}
//if there is no swapping in inner loop, it means the the part looped is already sorted,
//so it's time to break
if (swapped == false){ break }
}
return array
}
複製代碼
從上面的代碼能夠看出,在第一個冒泡排序的算法以內,只添加了一個swapped
這個布爾值,默認爲false:
爲了驗證上面這個改進冒泡排序是否能解決最初給出的冒泡排序的問題,咱們添加上對比次數的log:
original array:
[2, 1, 3, 4, 5]
bubble sort...
No.1 compare 2 and 1
after swap: [1, 2, 3, 4, 5]
No.2 compare 2 and 3
No.3 compare 3 and 4
No.4 compare 4 and 5
No.5 compare 1 and 2
No.6 compare 2 and 3
No.7 compare 3 and 4
No.8 compare 1 and 2
No.9 compare 2 and 3
No.10 compare 1 and 2
bubble sort time duration : 1.96ms
advanced bubble sort...
No.1 compare 2 and 1
after swap: [1, 2, 3, 4, 5]
No.2 compare 2 and 3
No.3 compare 3 and 4
No.4 compare 4 and 5
No.5 compare 1 and 2
No.6 compare 2 and 3
No.7 compare 3 and 4
複製代碼
咱們能夠看到,在使用改進的冒泡排序後,對比的次數少了3次。之因此沒有當即返回,是由於即便在交換完變成有序數組之後,也沒法在當前內循環判斷出是有序的。須要在下次內循環才能驗證出來。
由於數組的元素數量比較小,因此可能對這個改進所達到的效果體會得不是很明顯。如今咱們增長一下數組元素的個數,並用記錄比較總和的方式來看一下兩者的區別:
original array:
[2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
bubble sort...
total compare count: 91
advanced bubble sort...
total compare count: 25
複製代碼
從比較結果能夠看出,這兩種算法在該測試樣本下的差距是比較大的,並且隨着元素個數的增多這個差距會愈來愈大(由於作了更多沒有意義的比較)。
雖然這種測試樣本比較極端,可是在某種意義上仍是優化了最初的冒泡排序算法。通常在網上的冒泡排序算法應該都能看到這個優化版的。
如今咱們知道這個優化版的冒泡排序算法能夠在知道當前數組已經有序的時候提早結束,可是畢竟不斷的交換仍是比較耗費性能的,有沒有什麼方法能夠只移動一次就能作好當前元素的排序呢?答案又是確定的,這就引出了筆者即將介紹的選擇排序算法。
選擇排序也是兩層循環:
具體作法是:
咱們仍是用手寫迭代的方式看一下選擇排序的機制,使用的數組和上面交換排序和冒泡排序(非優化版)的數組一致:[4, 1, 2, 5, 0]
i = 0 時:
[0, 1, 2, 5, 4]
。當前內層循環結束,i++。i = 1 時:
i = 2 時:
i = 3 時:
[0, 1, 2, 4, 5]
。當前內層循環結束,i++。i = 4 時:不符合外層循環的邊界條件,不進行外層循環,排序結束。
咱們能夠看到,一樣的初始序列,使用選擇排序只進行了2次交換,由於它知道須要替換的最小值是什麼,作了不多沒意義的交換。
咱們用代碼來實現一下上面選擇排序的算法:
func selectionSort(_ array: inout [Int]) -> [Int] {
guard array.count > 1 else { return array }
for i in 0 ..< array.count - 1{
var min = i
for j in i + 1 ..< array.count {
if array[j] < array[min] {
min = j
}
}
//if min has changed, it means there is value smaller than array[min]
//if min has not changed, it means there is no value smallter than array[min]
if i != min {
array.swapAt(i, min)
}
}
return array
}
複製代碼
從上面的代碼能夠看到,在這裏使用了min
這個變量記錄了當前外層循環所須要被比較的index值,若是當前外層循環的內層循環內部找到了比這個最小值還小的值,就替換他們。
下面咱們使用log來看一下此時選擇排序做替換的次數:
original array:
[4, 1, 2, 5, 0]
advanced bubble sort...
after swap: [1, 4, 2, 5, 0]
after swap: [1, 2, 4, 5, 0]
after swap: [1, 2, 4, 0, 5]
after swap: [1, 2, 0, 4, 5]
after swap: [1, 0, 2, 4, 5]
after swap: [0, 1, 2, 4, 5]
selection sort...
after swap: [0, 1, 2, 5, 4]
after swap: [0, 1, 2, 4, 5]
複製代碼
從上面的log能夠看出兩者的對比應該比較明顯了。
爲了進一步驗證選擇排序的性能,筆者在網上找到了兩個工具:
executionTimeInterval.swift
Array+Extension.swift
首先看executionTimeInterval.swift
的實現:
//time interval
public func executionTimeInterval(block: () -> ()) -> CFTimeInterval {
let start = CACurrentMediaTime()
block();
let end = CACurrentMediaTime()
return end - start
}
//formatted time
public extension CFTimeInterval {
public var formattedTime: String {
return self >= 1000 ? String(Int(self)) + "s"
: self >= 1 ? String(format: "%.3gs", self)
: self >= 1e-3 ? String(format: "%.3gms", self * 1e3)
: self >= 1e-6 ? String(format: "%.3gµs", self * 1e6)
: self < 1e-9 ? "0s"
: String(format: "%.3gns", self * 1e9)
}
}
複製代碼
第一個函數以block的形式傳入須要測試運行時間的函數,返回了函數運行的時間。
第二個函數是CFTimeInterval
的分類,將秒數添加了單位:毫秒級的以毫秒顯示,微秒級的以微秒顯示,大於1秒的以秒單位顯示。
使用方法是:將兩個swift文件拖進playground裏面的Sources文件夾裏,並點擊兩者後,進入playground內部:
var selectionSortedArray = [Int]()
var time4 = executionTimeInterval{
selectionSortedArray = selectionSort(&originalArray4) //要測試的函數
}
print("selection sort time duration : \(time4.formattedTime)") //打印出時間
複製代碼
再來看一下Array+Extension.swift
類:
先介紹其中的一個方法,生成隨機數組:
import Foundation
extension Array {
static public func randomArray(size: Int, maxValue: UInt) -> [Int] {
var result = [Int](repeating: 0, count:size)
for i in 0 ..< size {
result[i] = Int(arc4random_uniform(UInt32(maxValue)))
}
return result
}
}
複製代碼
這個方法只須要傳入數組的大小以及最大值就能夠生成一個不超過這個最大值的隨機數組。
好比咱們要生成一個數組長度爲10,最大值爲100的數組:
var originalArray = Array<Int>.randomArray(size: inputSize, maxValue:100)
//originalArray:[87, 56, 54, 20, 86, 33, 41, 9, 88, 55]
複製代碼
那麼如今有了上面兩個工具,咱們就能夠按照咱們本身的意願來生成測試用例數組,而且打印出所用算法的執行時間。咱們如今生成一個數組長度爲10,最大值爲100的數組,而後分別用優化的冒泡排序和選擇排序來看一下兩者的性能:
original array:
[1, 4, 80, 83, 92, 63, 83, 23, 9, 85]
advanced bubble sort...
advanced bubble sort result: [1, 4, 9, 23, 63, 80, 83, 83, 85, 92] time duration : 8.53ms
selection sort...
selection sort result: [1, 4, 9, 23, 63, 80, 83, 83, 85, 92] time duration : 3.4ms
複製代碼
咱們如今讓數組長度更長一點:一個長度爲100,最大值爲200:
advanced bubble sort...
advanced bubble sort sorted elemets: 100 time duration : 6.27s
selection sort...
selection sort sorted elemets: 100 time duration : 414ms
複製代碼
能夠看到,兩者的差異大概在12倍左右。這個差異已經很大了,若是說用選擇排序須要1天的話,冒泡排序須要12天。
如今咱們學習了選擇排序,知道了它是經過減小交換次數來提升排序算法的性能的。
可是關於排序,除了交換操做之外,對比操做也是須要時間的:選擇排序經過內層循環的不斷對比才獲得了當前內層循環的最小值,而後進行後續的判斷和操做。
那麼有什麼辦法能夠減小對比的次數呢?猜對了,答案又是確定的。這就引出了筆者下面要說的算法:插入排序算法。
插入排序的基本思想是:從數組中拿出一個元素(一般就是第一個元素)之後,再從數組中按順序拿出其餘元素。若是拿出來的這個元素比這個元素小,就放在這個元素左側;反之,則放在右側。總體上看來有點和玩兒撲克牌的時候將剛拿好的牌來作排序差很少。
插入排序也是兩層循環:
j>0 && array[j] < array[j - 1]
,循環內側是交換j-1和j的元素,並使得j-1。能夠簡單理解爲若是當前的元素比前一個元素小,則調換位置;反之進行下一個外層循環。下面咱們仍是用手寫迭代的方式看一下插入排序的機制,使用的數組和上面選擇排序的數組一致:[4, 1, 2, 5, 0]
i = 1 時:
[1, 4, 2, 5, 0]
,j-1以後不符合內層循環條件,退出內層循環,i+1。i = 2 時:
[1, 2, 4, 5, 0]
,j向左移動,array[2] > array[1],不符合內層循環條件,退出內層循環,i+1。i = 3 時:
i = 4 時:
[1, 2, 4, 0, 5]
,j -1。[1, 2, 0, 4, 5]
,j -1。[1, 0, 2, 4, 5]
,j -1。[0, 1, 2, 4, 5]
,j -1 = 0,不符合內層循環條件,退出內層循環,i+1 = 5,不符合外層循環條件,排序終止。從上面的描述能夠看出,和選擇排序相比,插入排序的內層循環是能夠提早推出的,其條件就是array[j] >= array[j - 1]
,也就是說,當前index爲j的元素只要比前面的元素大,那麼該內層循環就當即退出,不須要再排序了,由於該算法從一開始就是小的放前面,大的放後面。
下面咱們經過代碼來看一下如何實現插入排序算法:
func insertionSort(_ array: inout [Int]) -> [Int] {
guard array.count > 1 else { return array }
for i in 1..<array.count {
var j = i
while j > 0 && array[j] < array[j - 1] {
array.swapAt(j - 1, j)
j -= 1
}
}
return array
}
複製代碼
從上面的代碼能夠看出插入排序內層循環的條件:j > 0 && array[j] < array[j - 1]
。只要當前元素比前面的元素小,就會一直交換下去;反之,當大於等於前面的元素,就會當即跳出循環。
以前筆者有提到相對於選擇排序,說插入排序能夠減小元素之間對比的次數,下面咱們經過打印對比次數來對比一下兩種算法:
使用元素個數爲50,最大值爲50的隨機數組:
selection sort...
compare times:1225
selection sort time duration : 178ms
insertion sort...
compare times:519
insertion sort time duration : 676ms
複製代碼
咱們能夠看到,使用選擇排序的比較次數比插入排序的比較次數多了2倍。可是遺憾的是總體的性能選擇排序要高於插入排序。
也就是說雖然插入排序的比較次數少了,可是交換的次數卻比選擇排序要多,因此性能上有時可能不如選擇排序。
注意,這不與筆者以前的意思相矛盾,筆者只是說在減小比較次數上插入排序是優於選擇排序的,但沒有說插入排序總體上優於選擇排序。
那麼有何種特性的數組可讓排序算法有其用武之地呢?
從上面使用插入排序來排序[4, 1, 2, 5, 0]
這個數組的時候,咱們能夠看到,由於0這個元素已經在末尾了,因此在j=4的時候咱們費了好大勁才把它移到前面去。
那麼將這個狀況做爲一個極端,咱們能夠這樣想:若是這個數組裏的元素裏的index大體於最終順序差很少的狀況是否是就不用作這麼多的搬移了?。這句話聽起來像是理所固然的話,可是有一種數組屬於「基本有序」的數組,這種數組也是無需的,可是它在總體上是有序的,好比:
[2,1,3,6,4,5,9,7,8]
用筆者的話就叫作總體有序,部分無序。
咱們能夠簡單用這個數組來分別進行選擇排序和插入排序作個比較:
selection sort...
compare times:36
selection sort time duration : 4.7ms
insertion sort...
compare times:5
insertion sort time duration : 3.2ms
複製代碼
咱們能夠看到插入排序在基本有序的測試用例下表現更好。爲了讓差距更明顯,筆者在Array+Extension.swift
文件裏增長了一個生成基本有序隨機數組的方法:
static public func nearlySortedArray(size: Int, gap:Int) -> [Int] {
var result = [Int](repeating: 0, count:size)
for i in 0 ..< size {
result[i] = i
}
let count : Int = size / gap
var arr = [Int]()
for i in 0 ..< count {
arr.append(i*gap)
}
for j in 0 ..< arr.count {
let swapIndex = arr[j]
result.swapAt(swapIndex,swapIndex+1)
}
return result
}
複製代碼
該函數須要傳入數組的長度以及須要打亂順序的index的跨度,它的實現是這樣子的:
舉個例子,若是咱們生成一個數組長度爲12,跨度爲3的基本有序的數組,就能夠這麼調用:
var originalArray = Array<Int>.nearlySortedArray(size: 12, gap: 3)
//[1, 0, 2, 4, 3, 5, 7, 6, 8, 10, 9, 11]
複製代碼
跨度爲3,說明有12/3 = 4 - 1 = 3 個元素須要調換位置,序號分別爲0,3,6,9。因此序號爲0,1;3,4;6,7;9,10的元素被調換了位置,能夠看到調換後的數組仍是基本有序的。
如今咱們能夠用一個比較大的數組來驗證了:
var originalArray = Array<Int>.nearlySortedArray(size: 100, gap: 10)
複製代碼
結果爲:
selection sort...
compare times:4950
selection sort time duration : 422ms
insertion sort...
compare times:10
insertion sort time duration : 56.4ms
複製代碼
咱們能夠看到差距是很是明顯的,插入排序的性能是選擇排序的性能的近乎10倍
歸併排序使用了算法思想裏的分治思想(divide conquer)。顧名思義,就是將一個大問題,分紅相似的小問題來逐個攻破。在歸併排序的算法實現上,首先逐步將要排序的數組等分紅最小的組成部分(一般是1各元素),而後再反過來逐步合併。
用一張圖來體會一下歸併算法的實現過程:
上圖面的虛線箭頭表明拆分的過程;實線表明合併的過程。仔細看能夠發現,拆分和歸併的操做都是重複進行的,在這裏面咱們可使用遞歸來操做。
首先看一下歸併的操做:
歸併的操做就是把兩個數組(在這裏這兩個數組的元素個數一般是一致的)合併成一個徹底有序數組。
歸併操做的實現步驟是:
咱們來看一下歸併排序算法的代碼實現:
func _merge(leftPile: [Int], rightPile: [Int]) -> [Int] {
var leftIndex = 0 //left pile index, start from 0
var rightIndex = 0 //right pile index, start from 0
var sortedPile = [Int]() //sorted pile, empty in the first place
while leftIndex < leftPile.count && rightIndex < rightPile.count {
//append the smaller value into sortedPile
if leftPile[leftIndex] < rightPile[rightIndex] {
sortedPile.append(leftPile[leftIndex])
leftIndex += 1
} else if leftPile[leftIndex] > rightPile[rightIndex] {
sortedPile.append(rightPile[rightIndex])
rightIndex += 1
} else {
//same value, append both of them and move the corresponding index
sortedPile.append(leftPile[leftIndex])
leftIndex += 1
sortedPile.append(rightPile[rightIndex])
rightIndex += 1
}
}
//left pile is not empty
while leftIndex < leftPile.count {
sortedPile.append(leftPile[leftIndex])
leftIndex += 1
}
//right pile is not empty
while rightIndex < rightPile.count {
sortedPile.append(rightPile[rightIndex])
rightIndex += 1
}
return sortedPile
}
複製代碼
由於該函數是歸併排序函數內部調用的函數,因此在函數名稱的前面添加了下劃線。僅僅是爲了區分,並非必須的。
從上面代碼能夠看出合併的實現邏輯:
理解了合併的算法,下面咱們看一下拆分的算法。拆分算法使用了遞歸:
func mergeSort(_ array: [Int]) -> [Int] {
guard array.count > 1 else { return array }
let middleIndex = array.count / 2
let leftArray = mergeSort(Array(array[0..<middleIndex])) // recursively split left part of original array
let rightArray = mergeSort(Array(array[middleIndex..<array.count])) // recursively split right part of original array
return _merge(leftPile: leftArray, rightPile: rightArray) // merge left part and right part
}
複製代碼
咱們能夠看到mergeSort
調用了自身,它的遞歸終止條件是!(array.count >1)
,也就是說當數組元素個數 = 1的時候就會返回,會觸發調用棧的出棧。
從這個遞歸函數的實現能夠看到它的做用是不斷以中心店拆分傳入的數組。根據他的遞歸終止條件,當數組元素 > 1的時候,拆分會繼續進行。而下面的合併函數只有在遞歸終止,開始出棧的時候纔開始真正執行。也就是說在拆分結束後纔開始進行合併,這樣符合了上面筆者介紹的歸併算法的實現過程。
上段文字須要反覆體會。
爲了更形象體現出歸併排序的實現過程,能夠在合併函數(_merge
)內部添加log來驗證上面的說法:
func _merge(leftPile: [Int], rightPile: [Int]) -> [Int] {
print("\nmerge left pile:\(leftPile) | right pile:\(rightPile)")
...
print("sorted pile:\(sortedPile)")
return sortedPile
}
複製代碼
並且爲了方便和上圖做比較,初始數組能夠取圖中的[3, 5, 9, 2, 7, 4, 8, 0]
。運行一下看看效果:
original array:
[3, 5, 9, 2, 7, 4, 8, 0]
merge sort...
merge left pile:[3] | right pile:[5]
sorted pile:[3, 5]
merge left pile:[9] | right pile:[2]
sorted pile:[2, 9]
merge left pile:[3, 5] | right pile:[2, 9]
sorted pile:[2, 3, 5, 9]
merge left pile:[7] | right pile:[4]
sorted pile:[4, 7]
merge left pile:[8] | right pile:[0]
sorted pile:[0, 8]
merge left pile:[4, 7] | right pile:[0, 8]
sorted pile:[0, 4, 7, 8]
merge left pile:[2, 3, 5, 9] | right pile:[0, 4, 7, 8]
sorted pile:[0, 2, 3, 4, 5, 7, 8, 9]
複製代碼
咱們能夠看到,拆分歸併的操做是先處理原數組的左側部分,而後處理原數組的右側部分。這是爲何呢?
咱們來看下最初函數是怎麼調用的:
最開始咱們調用函數:
func mergeSort(_ array: [Int]) -> [Int] {
guard array.count > 1 else { return array }
let middleIndex = array.count / 2
let leftArray = mergeSort(Array(array[0..<middleIndex])) //1
let rightArray = mergeSort(Array(array[middleIndex..<array.count])) //2
return _merge(leftPile: leftArray, rightPile: rightArray) //3
}
複製代碼
在//1這一行開始了遞歸,這個時候數組是原數組,元素個數是8,而調用mergeSort時原數組被拆分了一半,是4。而4>1,不知足遞歸終止的條件,繼續遞歸,直到符合了終止條件([3]),遞歸開始返回。覺得此時最初被拆分的是數組的左半部分,因此左半部分的拆分會逐步合併,最終獲得了[2,3,5,9]
。
同理,再回到了最初被拆分的數組的右半部分(上面代碼段中的//2),也是和左測同樣的拆分和歸併,獲得了右側部分的歸併結果:[0,4,7,8
。
而此時的遞歸調用棧只有一個mergeSort函數了,mergeSort會進行最終的合併(上面代碼段中的//3),調用_merge
函數,獲得了最終的結果:[0, 2, 3, 4, 5, 7, 8, 9]
。
關於歸併排序的性能:因爲使用了分治和遞歸而且利用了一些其餘的內存空間,因此其性能是高於上述介紹的全部排序的,不過前提是初始元素量不小的狀況下。
咱們能夠將選擇排序和歸併排序作個比較:初始數組爲長度500,最大值爲500的隨機數組:
selection sort...
selection sort time duration : 12.7s
merge sort...
merge sort time duration : 5.21s
複製代碼
能夠看到歸併排序的算法是優與選擇排序的。
如今咱們知道歸併排序使用了分治思想並且使用了遞歸,可以高效地將數組排序。其實還有一個也是用分治思想和遞歸,可是卻比歸併排序還要優秀的算法 - 快速排序算法。
快速排序算法被稱之爲20世紀十大算法之一,也是各大公司面試比較喜歡考察的算法。
快速排序的基本思想是:經過一趟排序將帶排記錄分割成獨立的兩部分,其中一部分記錄的關鍵字均比另外一部分記錄的關鍵字小,則可分別對這兩部分記錄繼續進行排序,以達到整個序列有序的目的。
上述文字摘自《大話數據結構》
它的實現步驟爲:
從上面的描述能夠看出,分區操做是快速排序中的核心算法。下面筆者結合實例來描述一下分區操做的過程。
首先拿到初始的數組:[5,4,9,1,3,6,7,8,2]
[2,4,9,1,3,6,7,8,5]
;[2,4,5,1,3,6,7,8,9]
。由於在Swift中有一個數組的filter函數能夠找出數組中符合某範圍的一些數值,因此筆者先介紹一個會用該函數的簡單的快速排序的實現:
func quickSort0<T: Comparable>(_ array: [T]) -> [T] {
guard array.count > 1 else { return array }
let pivot = array[array.count/2]
let less = array.filter { $0 < pivot }
let greater = array.filter { $0 > pivot }
return quickSort0(less) + quickSort0(greater)
}
複製代碼
不難看出這裏面使用了遞歸:選中pivot之後,將數組分紅了兩個部分,最後將它們合併在一塊兒。雖然這裏面使用了Swift裏面內置的函數來找出符合這兩個個部分的元素,可是讀者能夠經過這個例子更好地理解快速排序的實現方式。
除了使用swift內置的filter函數,固然咱們也能夠本身實現分區的功能,一般使用的是自定義的partition函數。
func _partition(_ array: inout [Int], low: Int, high: Int) -> Int{
var low = low
var high = high
let pivotValue = array[low]
while low < high {
while low < high && array[high] >= pivotValue {
high -= 1
}
array[low] = array[high]
while low < high && array[low] <= pivotValue {
low += 1
}
array[high] = array[low]
}
array[low] = pivotValue
return low
}
複製代碼
從代碼實現能夠看出,最初在這裏選擇的pivotValue是當前數組的第一個元素。
而後從數組的最右側的index逐漸向左側移動,若是值大於pivotValue,那麼index-1;不然直接將high與low位置上的元素調換;一樣左側的index也是相似的操做。
該函數執行的最終效果就是將最初的array按照選定的pivotValue先後劃分。
那麼_partition
如何使用呢?
func quickSort1(_ array: inout [Int], low: Int, high: Int){
guard array.count > 1 else { return }
if low < high {
let pivotIndex = _partition(&array, low: low, high: high)
quickSort1(&array, low: low, high: pivotIndex - 1)
quickSort1(&array, low: pivotIndex + 1, high: high)
}
}
複製代碼
外層調用的quickSort1
是一個遞歸函數,不斷地進行分區操做,最終獲得排好序的結果。
咱們將上面實現的歸併排序,使用swift內置函數的快速排序,以及自定義partition函數的快速排序的性能做對比:
merge sort...
merge sort time duration : 4.85s
quick sort...
quick sort0 time duration : 984ms //swift filter function
quick sort1 time duration : 2.64s //custom partition
複製代碼
上面的測試用例是選擇隨機數組的,咱們看一下測試用例爲元素個數一致的基本有序的數組試一下:
merge sort...
merge sort time duration : 4.88s
quick sort...
quick sort0 time duration : 921ms
quick sort1 time duration : 11.3s
複製代碼
雖然元素個數一致,可是性能卻差了不少,是爲何呢?由於咱們在分區的時候,pivot的index強制爲第一個。那麼若是這個第一個元素的值原本就很是小,那麼就會形成分區不均的狀況(前重後輕),並且因爲是迭代操做,每次分區都會形成分區不均,致使性能直線降低。因此有一個相對合理的方案就是在選取pivot的index的時候隨機選取。
實現方法肯簡單,只需在分區函數裏將pivotValue的index隨機生成便可:
func _partitionRandom(_ array: inout [Int], low: Int, high: Int) -> Int{
let x = UInt32(low)
let y = UInt32(high)
let pivotIndex = Int(arc4random() % (y - x)) + Int(x)
let pivotValue = array[pivotIndex]
...
}
複製代碼
如今用一個數組長度和上面的測試用例一致的基本有序的數組來測試一下隨機選取pivotValue的算法:
merge sort...
merge sort time duration : 4.73s
quick sort...
quick sort0 time duration : 866ms
quick sort1 time duration : 15.1s //fixed pivote index
quick sort2 time duration : 4.28s //random pivote index
複製代碼
咱們能夠看到當隨機抽取pivot的index的時候,其運行速度速度是上面方案的3倍。
如今咱們知道了3種快速排序的實現,都是根據pivotValue將原數組一分爲二。可是若是數組中有大量的重複的元素,並且pivotValue頗有可能落在這些元素裏,那麼顯然上面這些算法對於這些可能出現屢次於pivotValue重複的狀況沒有單獨作處理。而爲了很好解決存在與pivot值相等的元素不少的數組的排序,使用三路排序算法會比較有效果。
三路快速排序將大於,等於,小於pivotValue的元素都區分開,咱們看一下具體的實現。先看一下partition函數的實現:
func swap(_ arr: inout [Int], _ j: Int, _ k: Int) {
guard j != k else {
return;
}
let temp = arr[j]
arr[j] = arr[k]
arr[k] = temp
}
func quickSort3W(_ array: inout [Int], low: Int, high: Int) {
if high <= low { return }
var lt = low // arr[low+1...lt] < v
var gt = high + 1 // arr[gt...high] > v
var i = low + 1 // arr[lt+1...i) == v
let pivoteIndex = low
let pivoteValue = array[pivoteIndex]
while i < gt {
if array[i] < pivoteValue {
swap(&array, i, lt + 1)
i += 1
lt += 1
}else if pivoteValue < array[i]{
swap(&array, i, gt - 1)
gt -= 1
}else {
i += 1
}
}
swap(&array, low, lt)
quickSort3W(&array, low: low, high: lt - 1)
quickSort3W(&array, low: gt, high: high)
}
func quickSort3(_ array: inout [Int] ){
quickSort3W(&array, low: 0, high: array.count - 1)
}
複製代碼
主要看quickSort3W
方法,這裏將數組分紅了三個區間,分別是大於,等於,小於pivote的值,對有大量重複元素的數組作了比較好的處理。
咱們生成一個元素數量爲500,最大值爲5的隨機數組看一下這些快速排序算法的性能:
quick sort1 time duration : 6.19s //fixed pivote index
quick sort2 time duration : 8.1s //random pivote index
quick sort3 time duration : 4.81s //quick sort 3 way
複製代碼
能夠看到三路快速排序(quick sort 3 way)在處理大量重複元素的數組的表現最好。
對於三路快速排序,咱們也可使用Swift內置的filter函數來實現:
func quicksort4(_ array: [Int]) -> [Int] {
guard array.count > 1 else { return array }
let pivot = array[array.count/2]
let less = array.filter { $0 < pivot }
let equal = array.filter { $0 == pivot }
let greater = array.filter { $0 > pivot }
return quicksort4(less) + equal + quicksort4(greater)
}
複製代碼
以上,介紹完了快速排序在Swift中的5中實現方式。
本文講解了算法的一些基本概念以及結合了Swift代碼的實現講解了冒泡排序,選擇排序,插入排序,歸併排序,快速排序。相信認真閱讀本文的讀者能對這些算法有進一步的瞭解。由於筆者也剛剛接觸這一領域的知識,因此不免會在有些地方的表述有不穩當的地方,還需讀者多多給出意見和建議。
關於算法的學習,筆者有一些思考想分享出來,也有可能有不對的地方,但筆者以爲有必要在這裏說出來,但願能夠引起讀者的思考:
上圖的Question是指問題;Mind是指想法,或者解決問題的思路;Code是指代碼實現。
在閱讀資料或書籍的算法學習過程,每每是按照圖中1,2,3這些實線的路徑進行的:
這些路徑在算法的學習中雖然也是必不可少的,可是很容易給人一個錯覺,這個錯覺就是「我已經學會了這個算法了」。可是,僅僅是經過這些路徑,對於真正理解算法,和從此對算法的應用仍是遠遠不夠的,緣由是:
上面所說的兩點的第一點,對應的是上圖的路徑4:給定一個策略或是設計,要思考這個策略或是設計是解決什麼樣的問題的,這樣也就理解了這個策略或是設計的意義在哪裏;而第二點對應的是上圖中的路徑5:怎樣根據一個給定的策略來正確地,合理地用代碼地實現出來;而上圖中的路徑6,筆者以爲也很重要:給定一份解決問題的代碼,是否能夠想到它所對應的問題是什麼。
綜上所述,筆者認爲對於算法的學習,須要常常反覆在問題,策略以及代碼之間反覆思考,這樣才能真正地達到學以至用。
Swift代碼
本篇中出現的代碼已經放在GitHub倉庫中:
參考文獻&網站
《大話數據結構》
《數據結構與算法分析:C語言描述》
下篇預告
下篇會介紹堆這個數據結構以及堆排序算法。
本文已經同步到我的博客:傳送門
---------------------------- 2018年7月17日更新 ----------------------------
注意注意!!!
筆者在近期開通了我的公衆號,主要分享編程,讀書筆記,思考類的文章。
由於公衆號天天發佈的消息數有限制,因此到目前爲止尚未將全部過去的精選文章都發布在公衆號上,後續會逐步發佈的。
並且由於各大博客平臺的各類限制,後面還會在公衆號上發佈一些短小精幹,以小見大的乾貨文章哦~
掃下方的公衆號二維碼並點擊關注,期待與您的共同成長~