您有没有问过自己Swift的排序方法使用了哪种算法?那里有很多排序算法,很可能你很少需要使用语言内置方法以外的东西。但是,如果您想要防止不需要的行为和令人讨厌的边缘情况,那么了解内置于您的语言中的排序算法的属性非常重要。sort()
分析排序算法时,您需要搜索两个属性:
1 - 分类稳定性
排序算法的稳定性表示算法在排序后维持相等元素的原始顺序的能力。一个不稳定的排序算法没有保证了未排序的阵列中的相同的元件的顺序将保持排序后相同的,而一个稳定的一个保证它们将保持不变。
这可能听起来很奇怪,毕竟,如果元素相同,我为什么要关心他们的整体秩序呢?如果您按值排序元素,但是在按任意优先级排序元素时,使用不稳定算法可能会给您带来不希望的结果。
让我们假设我们正在构建一个音乐播放器,我们当前的任务是根据歌曲的受欢迎程度对歌曲进行排序:
struct Music: Comparable, Equatable, CustomStringConvertible {
let name: String
let popularityValue: Int
static func < (lhs: Music, rhs: Music) -> Bool {
return lhs.popularityValue < rhs.popularityValue
}
var description: String {
return name
}
}
var songs: [Music] = [
Music(name: "I'm that swifty", popularityValue: 3),
Music(name: "Swift boi", popularityValue: 5),
Music(name: "Swift That Walk", popularityValue: 1),
Music(name: "Too Swift", popularityValue: 5),
]
如果我们songs使用Quicksort 排序,我们将得到以下结果:
extension Array where Element: Equatable & Comparable {
func quicksort(comparison: ((Element, Element) -> Bool)) -> [Element] {
var copy = self
copy.quick(0, count - 1, comparison: comparison)
return copy
}
mutating private func quick(_ i: Int, _ j: Int, comparison: ((Element, Element) -> Bool)) {
guard i < j else {
return
}
let pivot = partition(i, j, comparison: comparison)
quick(i, pivot - 1, comparison: comparison)
quick(pivot + 1, j, comparison: comparison)
}
mutating private func partition(_ i: Int, _ j: Int, comparison: ((Element, Element) -> Bool)) -> Int {
let pivotElement = self[j]
var indexToAdd = i - 1
for k in i..<j {
if comparison(self[k], pivotElement) {
indexToAdd += 1
swapAt(indexToAdd, k)
}
}
swapAt(indexToAdd + 1, j)
return indexToAdd + 1
}
}
songs = songs.quicksort {
$0.popularityValue > $1.popularityValue
}
print(songs)
// [Too Swift, Swift boi, I'm that swifty, Swift That Walk]
虽然"Swift boi"被放置"Too Swift"在原始阵列之前,但Quicksort改变了他们的位置!
虽然我们从未真正使用过未排序的数组版本,但这并不算太糟糕。但是,请考虑如果我们多次重新排序数组会发生什么:
songs = songs.quicksort {
$0.popularityValue > $1.popularityValue
}
print(songs)
songs = songs.quicksort {
$0.popularityValue > $1.popularityValue
}
print(songs)
songs = songs.quicksort {
$0.popularityValue > $1.popularityValue
}
print(songs)
songs = songs.quicksort {
$0.popularityValue > $1.popularityValue
}
print(songs)
songs = songs.quicksort {
$0.popularityValue > $1.popularityValue
}
print(songs)
// [Too Swift, Swift boi, I'm that swifty, Swift That Walk]
// [Swift boi, Too Swift, I'm that swifty, Swift That Walk]
// [Too Swift, Swift boi, I'm that swifty, Swift That Walk]
// [Swift boi, Too Swift, I'm that swifty, Swift That Walk]
// [Too Swift, Swift boi, I'm that swifty, Swift That Walk]
他们的相对秩序不断变化!
原因是因为Quicksort是一种不稳定的排序算法。如果由于某种原因我们需要在UI中不断更新此列表,则用户将看到歌曲改变排名中的位置,即使它们具有相同的优先级。那不是很好。
为了保持秩序,我们需要使用像Mergesort这样的稳定算法。
extension Array where Element: Equatable & Comparable {
func mergesort(comparison: ((Element, Element) -> Bool)) -> [Element] {
return merge(0, count - 1, comparison: comparison)
}
private func merge(_ i: Int, _ j: Int, comparison: ((Element, Element) -> Bool)) -> [Element] {
guard i <= j else {
return []
}
guard i != j else {
return [self[i]]
}
let half = i + (j - i) / 2
let left = merge(i, half, comparison: comparison)
let right = merge(half + 1, j, comparison: comparison)
var i1 = 0
var i2 = 0
var new = [Element]()
new.reserveCapacity(left.count + right.count)
while i1 < left.count && i2 < right.count {
if comparison(right[i2], left[i1]) {
new.append(right[i2])
i2 += 1
} else {
new.append(left[i1])
i1 += 1
}
}
while i1 < left.count {
new.append(left[i1])
i1 += 1
}
while i2 < right.count {
new.append(right[i2])
i2 += 1
}
return new
}
}
// [Swift boi, Too Swift, I'm that swifty, Swift That Walk]
// [Swift boi, Too Swift, I'm that swifty, Swift That Walk]
// [Swift boi, Too Swift, I'm that swifty, Swift That Walk]
// [Swift boi, Too Swift, I'm that swifty, Swift That Walk]
// [Swift boi, Too Swift, I'm that swifty, Swift That Walk]
2 - 时间/空间复杂性
需要注意的第二个重要事项是算法运行需要多少额外内存以及算法的最佳/最差情况。
我最喜欢的例子是Counting Sort:通过简单地计算每个元素数量的元素的出现次数然后按顺序排列它们来对数组进行排序。如果每个值之间的差异很小,比如说,这个算法可以在非常接近O(n)的运行时对数组进行排序- 但如果差异很大,比如,Counting Sort将花费大量的时间来运行,即使数组很小。[3,1,4,2,5][1,1000000000]
同样,着名的快速排序被认为是一种平均快速和就地O(n log n)算法,但如果枢轴总是最高/最小的元素,它有一个可怕的最坏情况O(n2)。分区。如果您正在处理大量数据或受限制的环境,那么将有一个最适合您需求的特定排序算法。
Pre-Swift 5算法:Introsort
在Swift 5之前,Swift的排序算法是一种称为Introsort的混合算法,它将强度和单一算法混合在一起Quicksort,以保证更糟的O(n log n)情况。HeapsortInsertion Sort
Introsort背后的想法很简单:首先,如果分区中的元素少于20个,则使用Insertion Sort。尽管该算法具有O(n2)的最坏情况,但它也具有O(n)的最佳情况。与一般的O(n log n)算法相比,Insertion Sort在小输入中总是表现更好。
如果数组不小,将使用Quicksort。这将把我们最好的情况带到O(n log n),但也保持O(n2)的最坏情况。但是,Introsort可以避免它 - 如果Quicksort的递归树太深,分区将切换到Heapsort。在这种情况下,“太深”被认为是。2 * floor(log2(array.count))
internal mutating func _introSortImpl(within range: Range<Index>,
by areInIncreasingOrder: (Element, Element) throws -> Bool,
depthLimit: Int) rethrows {
// Insertion sort is better at handling smaller regions.
if distance(from: range.lowerBound, to: range.upperBound) < 20 {
try _insertionSort(within: range, by: areInIncreasingOrder)
} else if depthLimit == 0 {
try _heapSort(within: range, by: areInIncreasingOrder)
} else {
// Partition and sort.
// We don't check the depthLimit variable for underflow because this
// variable is always greater than zero (see check above).
let partIdx = try _partition(within: range, by: areInIncreasingOrder)
try _introSortImpl(
within: range.lowerBound..<partIdx,
by: areInIncreasingOrder,
depthLimit: depthLimit &- 1)
try _introSortImpl(
within: partIdx..<range.upperBound,
by: areInIncreasingOrder,
depthLimit: depthLimit &- 1)
}
}
Introsort贪婪地尝试为给定情况选择最佳算法,在前一个选择出错时备份到不同的选项。它具有O(n)和O(n log n)的最坏情况的最佳情况,使其成为一种适合的一刀切算法。
在内存使用方面,它将比通常的排序算法稍差。虽然这三种算法可以在适当的位置进行排序,但Introsort在Swift中的实现是递归的。由于Swift不保证尾部递归调用将被优化,因此如果保持较低的内存使用率,运行内置的Swift 5之前并不是最佳选择。sort()
最值得注意的是Introsort 不稳定。尽管Insertion Sort是稳定的,但Quicksort和Heapsort的默认实现却不是。如果相同元素的顺序很重要,那么使用Swift 5之前也不是一个好主意。sort()
在Swift 5之后 - Timsort
多个线程在2015年和2018年之间浮出水面,向Swift添加一个不依赖递归的稳定排序算法,但有希望的讨论首先出现在2018年初。2018年10月,一个拉动请求最终被合并以将Introsort更改为Timsort的修改版本。
Timsort是一种像Introsort这样的混合算法,但采用不同的方法。它的工作原理是将数组划分为较小的部分,使用“插入排序”对这些较小的部分进行排序,然后将这些部分与“合并排序”合并在一起。因为Insertion Sort和Mergesort都是稳定的,所以Timsort是稳定的,虽然它也有最坏的O(n log n)和非恒定的空间复杂性,但它往往比现实场景中更天真的算法快得多。 。Timsort如此快速的原因在于虽然描述听起来很简单,但实际上每个步骤都经过高度调整以提高效率。
找到下一个“运行”(分区)
Timsort不是将所有内容先分割并合并为Mergesort的最后一步,而是扫描阵列一次,并逐渐合并这些区域(称为“运行”)。
美丽的是,与Mergesort不同,跑步不仅仅是阵列除以一半。Timsort滥用了这样一个事实:你要排序的每个数组都可能有几个连续的子序列几乎或已经排序,这恰好是Insertion Sort的最佳情况。为了找到下一次运行,Timsort将前进一个指针,直到当前序列停止为升序/降序模式:
[1, 2, 3, 1, 9, 6]
i j
注意:这个例子仅用于视觉目的 - 因为这里的数组很小,Timsort只会插入它立即排序。
从i到j的范围定义了我们的第一次运行,但优化并不止于此。
第一:如果序列正在下降,我们已经可以通过反转元素在线性时间内对其进行排序。
第二:为了提高插入排序的速度并平衡稍后将要完成的合并操作的数量,Timsort定义每次运行应该具有16到128之间的最小 2的幂,或者至少非常接近它。如果我们发现的运行的大小小于最小运行大小,则扩展当前运行的范围并使用Insertion Sort进行排序。
// Find the next consecutive run, reversing it if necessary.
var (end, descending) = try _findNextRun(in: self, from: start, by: areInIncreasingOrder)
if descending {
_reverse(within: start..<end)
}
// If the current run is shorter than the minimum length, use the
// insertion sort to extend it.
if end < endIndex && end - start < minimumRunLength {
let newEnd = Swift.min(endIndex, start + minimumRunLength)
try _insertionSort(within: start..<newEnd, sortedEnd: end, by: areInIncreasingOrder)
end = newEnd
}
对于实际大小,Swift特别选择32到64之间的值,该值根据阵列的大小而变化。最后,在找到一个运行后,它被添加到一个堆栈中,其中包含我们找到的所有其他先前运行。
合并运行
每次发现一次运行时,Timsort都会尝试将堆栈的前三次运行合并为一个,直到满足以下条件:
runs.count < 3 || runs[runs.count - 2].size > (runs[runs.count - 1].size + runs.last!.count)
&&
runs.count < 2 || runs[runs.count - 1].size > runs.last!.size
这样做是为了减少我们必须记住的运行量,但主要是为了平衡运行的总体大小,因为将它们靠近在一起有利于Timsort的合并阶段。
乍一看,Timsort的合并阶段就像Mergesort一样:我们比较每个数组中的一对元素,选择最小的元素并将其移动到最终数组中的正确位置。
然而,Timsort精美地赋予了这样一个事实,即如果一个特定阵列继续“赢得”比较,那么它可能会继续这样做。如果发生这种情况,我们可以简单地从“获胜”阵列中的“丢失”数组中二元搜索元素的正确位置,而不是继续比较元素,将所有元素移动到最终数组之前。这被称为疾驰,它让我们跳过比较获胜阵列的整个块,从而节省了我们很多时间。
如果二进制搜索过程“丢失”到常规比较过程,则可以中止疾驰过程。您可以看到Timsort描述中完成的所有优化。最后,在找到所有运行之后,堆栈中的剩余运行逐渐合并在一起,直到整个数组被排序。
Swift的主要区别在于编译器的Timsort实现不使用奔腾,它试图基于最后四次运行而不是最后三次运行来折叠运行。尽管如此,它几乎在每种情况下都优于Introsort。
结论
Timsort是解决现实问题的最快排序算法之一。了解您的语言使用的算法可以根据您正在处理的数据做出更好的决策,从而帮助您优化代码。
点击此处进交流群 ,来一起交流或者发布您的问题,意见或反馈,让我知道您想要分享的任何建议和更正。