本篇是《数据结构 & 算法 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日更新 ----------------------------
注意注意!!!
笔者在近期开通了我的公众号,主要分享编程,读书笔记,思考类的文章。
由于公众号天天发布的消息数有限制,因此到目前为止尚未将全部过去的精选文章都发布在公众号上,后续会逐步发布的。
并且由于各大博客平台的各类限制,后面还会在公众号上发布一些短小精干,以小见大的干货文章哦~
扫下方的公众号二维码并点击关注,期待与您的共同成长~