swift 算法 学习笔记

1,101 阅读3分钟

二分搜索

下面二分搜索的工作原理:

将数组分成两半,并确定您要查找的内容(称为搜索键)是在左半部分还是在右半部分。 您如何确定搜索键的键在哪一半呢? 这就是首先要对数组进行排序的原因,排好序你就可以做一个简单的<或>比较。 如果搜索键位于左半部分,则在那里重复该过程:将左半部分分成两个更小的部分,并查看搜索键位于哪一块。 (同样,对于右半部分同样处理。) 重复此操作直到找到搜索键。 如果无法进一步拆分数组,则必须遗憾地得出结论,搜索键不在数组中。 现在你知道为什么它被称为“二分”搜索:在每一步中它将数组分成两半。 分而治之 可以快速缩小搜索键所在的位置。

这是Swift中二分搜索的递归实现:

func binarySearch<T: Comparable>(_ a: [T], key: T, range: Range<Int>) -> Int? {
    if range.lowerBound >= range.upperBound {
        // If we get here, then the search key is not present in the array.
        return nil

    } else {
        // Calculate where to split the array.
        let midIndex = range.lowerBound + (range.upperBound - range.lowerBound) / 2

        // Is the search key in the left half?
        if a[midIndex] > key {
            return binarySearch(a, key: key, range: range.lowerBound ..< midIndex)

        // Is the search key in the right half?
        } else if a[midIndex] < key {
            return binarySearch(a, key: key, range: midIndex + 1 ..< range.upperBound)

        // If we get here, then we've found the search key!
        } else {
            return midIndex
        }
    }
}

在Swift中可以通过实现Equatable协议使自定义类型支持==以及!=这两种运算符;Comparable协议继承于Equatable,实现Comparable协议可以在Equatable的基础上使类型支持>,>=,<,<=四种运算符。

尝试一下,将代码复制到 playground 并执行以下操作:

let numbers = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67]
binarySearch(numbers, key: 43, range: 0 ..< numbers.count)  // gives 13

请注意,numbers数组已排序。 否则二分搜索算法不能工作!

二分搜索通过将数组分成两半来搜索,但我们实际上并没有创建两个新数组。 我们使用SwiftRange对象跟踪这些拆分。起初,此范围涵盖整个数组,0 .. <numbers.count。 当我们拆分数组时,范围变得越来越小。

注意: 需要注意的一点是range.upperBound总是指向最后一个元素之后的元素。 在这个例子中,范围是0 .. <19,因为数组中有19个数字,所以range.lowerBound = 0和range.upperBound = 19。但是在我们的数组中,最后一个元素是在索引18而不是19,因为我们从0开始计数。在处理范围时要记住这一点:upperBound总是比最后一个元素的索引多一。

注意: 二分搜索许多执行会计算 midIndex = (lowerBound + upperBound) / 2。这包含了一个在非常大的数组中会出现的细微bug,因为lowerBound + upperBound可能溢出一个整数可以容纳的最大数。这种情况不太可能发生在64位CPU上,但绝对可能在32位机器上发生。

二分搜索本质上是递归的,因为您将相同的逻辑一遍又一遍地应用于越来越小的子数组。 但是,这并不意味着您必须将binarySearch()实现为递归函数。 将递归算法转换为迭代版本通常更有效,使用简单的循环而不是大量的递归函数调用。

这是Swift中二分搜索的迭代实现:

func binarySearch<T: Comparable>(_ a: [T], key: T) -> Int? {
    var lowerBound = 0
    var upperBound = a.count
    while lowerBound < upperBound {
        let midIndex = lowerBound + (upperBound - lowerBound) / 2
        if a[midIndex] == key {
            return midIndex
        } else if a[midIndex] < key {
            lowerBound = midIndex + 1
        } else {
            upperBound = midIndex
        }
    }
    return nil
}

如您所见,代码与递归版本非常相似。 主要区别在于使用while循环。

使用迭代实现:

let numbers = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67]
binarySearch(numbers, key: 43)  // gives 13
总结:

数组必须先排序是一个问题? 排序是需要时间的 —— 二分搜索和排序的组合可能比进行简单的线性搜索要慢。但是在您只排序一次然后进行多次搜索的情况下,二分搜索会起到大作用。

插入排序(Insertion Sort)

func insertionSort(_ array: [Int]) -> [Int] {
    var a = array			 // 1
    for x in 1..<a.count {		 // 2
        var y = x
        while y > 0 && a[y] < a[y - 1] { // 3
            a.swapAt(y - 1, y)
            y -= 1
        }
    }
    return a
}

let list = [ 10, -1, 3, 9, 2, 27, 8, 5, 1, 3, 0, 26 ]
insertionSort(list)

代码工作原理:

先创建一个数组的拷贝。因为我们不能直接修改参数array中的内容,所以这是非常必要的。insertionSort() 会返回一个原始数组的拷贝,就像Swift已拥有的sort() 方法一样。

在函数里有两个循环,外循环依次查找数组中的每一个元素;这就是从数字堆中取最上面的数字的过程。变量x是有序部分结束和堆开始的索引(也就是 | 符号的位置)。要记住的是,在任何时候,从0到x的位置数组都是有序的,剩下的则是无序的。

内循环则从 x 位置的元素开始查找。x是堆顶的元素,它有可能比前面的所有元素都小。内循环从有序数组的后面开始往前查找。每次找到一个大的元素,就交换它们的位置,直到内层循环结束,数组的前面部分依然是有序的,有序的元素也增加了一个。

注意: 外层循环是从1开始,而不是0。从堆顶将第一个元素移动到有序数组没有任何意义,可以跳过。

性能

如果数组是已经排好序的话,插入排序是非常快速的。这听起来好像很明显,但是不是所有的搜索算法都是这样的。在实际中,有很多数据(大部分,可能不是全部)是已经排序好的,插入排序在这种情况下就是一个非常好的选择。

插入排序的最差和平均性能是 O(n^2)。这是因为在函数里有两个嵌套的循环。其他如快速排序和归并排序的性能则是 O(n log n),在有大量输入的时候会更快。

插入排序在对小数组进行排序的时候实际是非常快的。一些标准库在数据量小于或者等于10的时候会从快速排序切换到插入排序。

我们做了一个速度测试来对比我们的 insertionSort() 和 Swift 内置的 sort()。在大概有 100 个元素的数组中,速度上的差异非常小。然后,如果输入一个非常大的数据量, O(n^2) 马上就比 O(n log n) 的性能糟糕多了,插入排序差很多。

选择排序(Selection Sort)

func selectionSort(_ array: [Int]) -> [Int] {
  guard array.count > 1 else { return array }  // 1

  var a = array                    // 2

  for x in 0 ..< a.count - 1 {     // 3

    var lowest = x
    for y in x + 1 ..< a.count {   // 4
      if a[y] < a[lowest] {
        lowest = y
      }
    }

    if x != lowest {               // 5
      a.swapAt(x, lowest)
    }
  }
  return a
}

let list = [ 10, -1, 3, 9, 2, 27, 8, 5, 1, 3, 0, 26 ]
selectionSort(list)
// [-1, 0, 1, 2, 3, 3, 5, 8, 9, 10, 26, 27]

代码逐步说明:

如果数组为空或仅包含单个元素,则无需排序。

生成数组的副本。 这是必要的,因为我们不能直接在Swift中修改array参数的内容。 与Swift的sort()函数一样,selectionSort()函数将返回排完序的原始数组拷贝。

函数内有两个循环。 外循环依次查看数组中的每个元素; 这就是向前移动|栏的原因。

内循环实现找到数组其余部分中的最小数字。

使用当前数组索引数字交换最小数字。 if判断是必要的,因为你不能在Swift中swap()同一个元素。

Swift中的数组没有swap()方法,只有swapAt()方法,而且swapAt()交换同一个元素是没有问题的。这可能是Swift版本更新的问题。

总结:

对于数组的每个元素,选择排序使用数组其余部分中的最小值交换位置。结果,数组从左到右排序。(你也可以从右到左进行,在这种情况下你总是寻找数组中最大的数字。试一试!)

注意: 外循环以索引a.count - 2结束。 最后一个元素将自动处于正确的位置,因为此时没有剩下其他较小的元素了。

性能:

选择排序很容易理解,但执行速度慢 O(n^2)。它比插入排序更糟,但优于冒泡排序。查找数组其余部分中的最低元素很慢,特别是因为内部循环将重复执行。

堆排序使用与选择排序相同的原则,但使用了一种快速方法在数组的其余部分中查找最小值。 堆排序性能是 O(nlogn)

归并排序(Merge Sort)

自上而下的实施(递归法)

归并排序的Swift实现:

func mergeSort(_ array: [Int]) -> [Int] {
    guard array.count > 1 else { return array }    // 1
    
    let middleIndex = array.count / 2              // 2
    
    let leftArray = mergeSort(Array(array[0..<middleIndex]))             // 3
    
    let rightArray = mergeSort(Array(array[middleIndex..<array.count]))  // 4
    
    return merge(leftPile: leftArray, rightPile: rightArray)             // 5
}
func merge(leftPile: [Int], rightPile: [Int]) -> [Int] {
    // 1
    var leftIndex = 0
    var rightIndex = 0
    
    // 2
    var orderedPile = [Int]()
    
    // 3
    while leftIndex < leftPile.count && rightIndex < rightPile.count {
        if leftPile[leftIndex] < rightPile[rightIndex] {
            orderedPile.append(leftPile[leftIndex])
            leftIndex += 1
        } else if leftPile[leftIndex] > rightPile[rightIndex] {
            orderedPile.append(rightPile[rightIndex])
            rightIndex += 1
        } else {
            orderedPile.append(leftPile[leftIndex])
            leftIndex += 1
            orderedPile.append(rightPile[rightIndex])
            rightIndex += 1
        }
    }
    
    // 4
    while leftIndex < leftPile.count {
        orderedPile.append(leftPile[leftIndex])
        leftIndex += 1
    }
    
    while rightIndex < rightPile.count {
        orderedPile.append(rightPile[rightIndex])
        rightIndex += 1
    }
    
    return orderedPile
}

//[22, 44, 50, 66, 77, 123, 654, 888, 999]
mergeSort([50,66,44,22,77,999,123,654,888])

}

自下而上的实施(迭代)
func mergeSortBottomUp<T>(_ a: [T], _ isOrderedBefore: (T, T) -> Bool) -> [T] {
  let n = a.count

  var z = [a, a]      // 1
  var d = 0

  var width = 1
  while width < n {   // 2

    var i = 0
    while i < n {     // 3

      var j = i
      var l = i
      var r = i + width

      let lmax = min(l + width, n)
      let rmax = min(r + width, n)

      while l < lmax && r < rmax {                // 4
        if isOrderedBefore(z[d][l], z[d][r]) {
          z[1 - d][j] = z[d][l]
          l += 1
        } else {
          z[1 - d][j] = z[d][r]
          r += 1
        }
        j += 1
      }
      while l < lmax {
        z[1 - d][j] = z[d][l]
        j += 1
        l += 1
      }
      while r < rmax {
        z[1 - d][j] = z[d][r]
        j += 1
        r += 1
      }

      i += width*2
    }

    width *= 2
    d = 1 - d      // 5
  }
  return z[d]
}

let array = [2, 1, 5, 4, 9]
mergeSortBottomUp(array, <)   // [1, 2, 4, 5, 9]
性能

归并排序算法的速度取决于它需要排序的数组的大小。 数组越大,它需要做的工作就越多。 初始数组是否已经排序不会影响归并排序算法的速度,因为无论元素的初始顺序如何,您都将进行相同数量的拆分和比较。 因此,最佳,最差和平均情况的时间复杂度将始终为 O(n log n)。 归并排序算法的一个缺点是它需要一个临时的“工作”数组,其大小与被排序的数组相同。 它不是原地排序,不像例如quicksort。 大多数实现归并排序算法是稳定的排序。这意味着具有相同排序键的数组元素在排序后将保持相对于彼此的相同顺序。这对于数字或字符串等简单值并不重要,但在排序更复杂的对象时,如果不是稳定的排序可能会出现问题。