diff --git a/CHANGELOG.md b/CHANGELOG.md index aadcc0da..565abfcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ package updates, you can specify your package dependency using ## [Unreleased] --`adjacentPairs()` lazily iterates over tuples of adjacent elements of a sequence. +- `adjacentPairs()` lazily iterates over tuples of adjacent elements of a sequence. +- `minAndMax()` finds both the smallest and largest elements of a sequence in a single pass. --- diff --git a/Guides/MinMax.md b/Guides/MinMax.md index 17455449..090d7a13 100644 --- a/Guides/MinMax.md +++ b/Guides/MinMax.md @@ -1,4 +1,4 @@ -# Min/Max with Count +# Minima and/or Maxima [[Source](https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/MinMax.swift) | [Tests](https://github.com/apple/swift-algorithms/blob/main/Tests/SwiftAlgorithmsTests/MinMaxTests.swift)] @@ -13,6 +13,17 @@ let smallestThree = numbers.min(count: 3, sortedBy: <) // [1, 2, 3] ``` +Return the smallest and largest elements of this sequence, determined by a predicate or in the order defined by `Comparable` conformance. + +If you need both the minimum and maximum values of a collection, using these methods can give you a performance boost over running the `min` method followed by the `max` method. Plus they work with single-pass sequences. + +```swift +let numbers = [7, 1, 6, 2, 8, 3, 9] +if let (smallest, largest) = numbers.minAndMax(by: <) { + // Work with 1 and 9.... +} +``` + ## Detailed Design This adds the `Collection` methods shown below: @@ -31,6 +42,16 @@ extension Collection { } ``` +And the `Sequence` method: + +```swift +extension Sequence { + public func minAndMax( + by areInIncreasingOrder: (Element, Element) throws -> Bool + ) rethrows -> (min: Element, max: Element)? +} +``` + Additionally, versions of these methods for `Comparable` types are also provided: ```swift @@ -39,20 +60,26 @@ extension Collection where Element: Comparable { public func max(count: Int) -> [Element] } + +extension Sequence where Element: Comparable { + public func minAndMax() -> (min: Element, max: Element)? +} ``` ### Complexity -The algorithm used is based on [Soroush Khanlou's research on this matter](https://khanlou.com/2018/12/analyzing-complexity/). The total complexity is `O(k log k + nk)`, which will result in a runtime close to `O(n)` if *k* is a small amount. If *k* is a large amount (more than 10% of the collection), we fall back to sorting the entire array. Realistically, this means the worst case is actually `O(n log n)`. +The algorithm used for minimal- or maximal-ordered subsets is based on [Soroush Khanlou's research on this matter](https://khanlou.com/2018/12/analyzing-complexity/). The total complexity is `O(k log k + nk)`, which will result in a runtime close to `O(n)` if *k* is a small amount. If *k* is a large amount (more than 10% of the collection), we fall back to sorting the entire array. Realistically, this means the worst case is actually `O(n log n)`. Here are some benchmarks we made that demonstrates how this implementation (SmallestM) behaves when *k* increases (before implementing the fallback): ![Benchmark](Resources/SortedPrefix/FewElements.png) ![Benchmark 2](Resources/SortedPrefix/ManyElements.png) +The algorithm used for simultaneous minimum and maximum is slightly optimized. At each iteration, two elements are read, their relative order is determined, then each is compared against exactly one of the current extrema for potential replacement. When a comparison predicate has to analyze every component of both operands, the optimized algorithm isn't much faster than the straightforward approach. But when a predicate only needs to compare a small part of each instance, the optimization shines through. + ### Comparison with other languages -**C++:** The `<algorithm>` library defines a `partial_sort` function where the entire array is returned using a partial heap sort. +**C++:** The `<algorithm>` library defines a `partial_sort` function where the entire array is returned using a partial heap sort. It also defines a `minmax_element` function that scans a range for its minimal and maximal elements. **Python:** Defines a `heapq` priority queue that can be used to manually achieve the same result. diff --git a/README.md b/README.md index 03a99df7..7ff5fd31 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Read more about the package, and the intent behind it, in the [announcement on s - [`suffix(while:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Suffix.md): Returns the suffix of a collection where all element pass a given predicate. - [`trimming(while:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Trim.md): Returns a slice by trimming elements from a collection's start and end. - [`uniqued()`, `uniqued(on:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Unique.md): The unique elements of a collection, preserving their order. +- [`minAndMax()`, `minAndMax(by:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/MinMax.md): Returns the smallest and largest elements of a sequence. #### Partial sorting diff --git a/Sources/Algorithms/MinMax.swift b/Sources/Algorithms/MinMax.swift index 6e856479..29a3be7b 100644 --- a/Sources/Algorithms/MinMax.swift +++ b/Sources/Algorithms/MinMax.swift @@ -383,3 +383,141 @@ extension Collection where Element: Comparable { return max(count: count, sortedBy: <) } } + +//===----------------------------------------------------------------------===// +// Simultaneous minimum and maximum evaluation +//===----------------------------------------------------------------------===// + +extension Sequence { + /// Returns both the minimum and maximum elements in the sequence, using the + /// given predicate as the comparison between elements. + /// + /// The predicate must be a *strict weak ordering* over the elements. That + /// is, for any elements `a`, `b`, and `c`, the following conditions must + /// hold: + /// + /// - `areInIncreasingOrder(a, a)` is always `false`. (Irreflexivity) + /// - If `areInIncreasingOrder(a, b)` and `areInIncreasingOrder(b, c)` are + /// both `true`, then `areInIncreasingOrder(a, c)` is also + /// `true`. (Transitive comparability) + /// - Two elements are *incomparable* if neither is ordered before the other + /// according to the predicate. If `a` and `b` are incomparable, and `b` + /// and `c` are incomparable, then `a` and `c` are also incomparable. + /// (Transitive incomparability) + /// + /// This example shows how to use the `minAndMax(by:)` method on a + /// dictionary to find the key-value pair with the lowest value and the pair + /// with the highest value. + /// + /// let hues = ["Heliotrope": 296, "Coral": 16, "Aquamarine": 156] + /// if let extremeHues = hues.minAndMax(by: {$0.value < $1.value}) { + /// print(extremeHues.min, extremeHues.max) + /// } else { + /// print("There are no hues") + /// } + /// // Prints: "(key: "Coral", value: 16) (key: "Heliotrope", value: 296)" + /// + /// - Precondition: The sequence is finite. + /// + /// - Parameter areInIncreasingOrder: A predicate that returns `true` + /// if its first argument should be ordered before its second + /// argument; otherwise, `false`. + /// - Returns: A tuple with the sequence's minimum element, followed by its + /// maximum element. For either member, if the sequence provides multiple + /// qualifying elements, the one chosen is unspecified. The same element may + /// be used for both members if all the elements are equivalent. If the + /// sequence has no elements, returns `nil`. + /// + /// - Complexity: O(*n*), where *n* is the length of the sequence. + public func minAndMax( + by areInIncreasingOrder: (Element, Element) throws -> Bool + ) rethrows -> (min: Element, max: Element)? { + // Check short sequences. + var iterator = makeIterator() + guard var lowest = iterator.next() else { return nil } + guard var highest = iterator.next() else { return (lowest, lowest) } + + // Confirm the initial bounds. + if try areInIncreasingOrder(highest, lowest) { swap(&lowest, &highest) } + + #if true + // Read the elements in pairwise. Structuring the comparisons around this + // is actually faster than loops based on extracting and testing elements + // one-at-a-time. + while var low = iterator.next() { + if var high = iterator.next() { + // Update the upper bound with the larger new element. + if try areInIncreasingOrder(high, low) { swap(&low, &high) } + if try !areInIncreasingOrder(high, highest) { highest = high } + } else { + // Update the upper bound by reusing the last element. The next element + // iteration will also fail, ending the loop. + if try !areInIncreasingOrder(low, highest) { highest = low } + } + + // Update the lower bound with the smaller new element, which may need a + // swap first to determine. + if try areInIncreasingOrder(low, lowest) { lowest = low } + } + #else + /// Ensure the second argument has a value that is ranked at least as much as + /// the first argument. + func sort(_ a: inout Element, _ b: inout Element) throws { + if try areInIncreasingOrder(b, a) { swap(&a, &b) } + } + + /// Find the smallest and largest values out of a group of four arguments. + func minAndMaxOf4( + _ a: Element, _ b: Element, _ c: Element, _ d: Element + ) throws -> (min: Element, max: Element) { + var (a, b, c, d) = (a, b, c, d) + try sort(&a, &b) + try sort(&c, &d) + try sort(&a, &c) + try sort(&b, &d) + return (a, d) + } + + // Read the elements in four-at-a-time. Some say this is more effective + // than a two-at-a-time loop. + while let a = iterator.next() { + let b = iterator.next() ?? a + let c = iterator.next() ?? b + let d = iterator.next() ?? c + let (low, high) = try minAndMaxOf4(a, b, c, d) + if try areInIncreasingOrder(low, lowest) { lowest = low } + if try !areInIncreasingOrder(high, highest) { highest = high } + } + #endif + return (lowest, highest) + } +} + +extension Sequence where Element: Comparable { + /// Returns both the minimum and maximum elements in the sequence. + /// + /// This example finds the smallest and largest values in an array of height + /// measurements. + /// + /// let heights = [67.5, 65.7, 64.3, 61.1, 58.5, 60.3, 64.9] + /// if let (lowestHeight, greatestHeight) = heights.minAndMax() { + /// print(lowestHeight, greatestHeight) + /// } else { + /// print("The list of heights is empty") + /// } + /// // Prints: "58.5 67.5" + /// + /// - Precondition: The sequence is finite. + /// + /// - Returns: A tuple with the sequence's minimum element, followed by its + /// maximum element. For either member, if there is a tie for the extreme + /// value, the element chosen is unspecified. The same element may be used + /// for both members if all the elements are equal. If the sequence has no + /// elements, returns `nil`. + /// + /// - Complexity: O(*n*), where *n* is the length of the sequence. + @inlinable + public func minAndMax() -> (min: Element, max: Element)? { + return minAndMax(by: <) + } +} diff --git a/Tests/SwiftAlgorithmsTests/MinMaxTests.swift b/Tests/SwiftAlgorithmsTests/MinMaxTests.swift index c0dedb32..bf8a35d5 100644 --- a/Tests/SwiftAlgorithmsTests/MinMaxTests.swift +++ b/Tests/SwiftAlgorithmsTests/MinMaxTests.swift @@ -185,3 +185,67 @@ final class SortedPrefixTests: XCTestCase { } } } + +final class MinAndMaxTests: XCTestCase { + /// Confirms that empty sequences yield no results. + func testEmpty() { + XCTAssertNil(EmptyCollection<Int>().minAndMax()) + } + + /// Confirms the same element is used when there is only one. + func testSingleElement() { + let result = CollectionOfOne(2).minAndMax() + XCTAssertEqual(result?.min, 2) + XCTAssertEqual(result?.max, 2) + } + + /// Confirms the same value is used when all the elements have it. + func testSingleValueMultipleElements() { + let result = repeatElement(3.3, count: 5).minAndMax() + XCTAssertEqual(result?.min, 3.3) + XCTAssertEqual(result?.max, 3.3) + + // Even count + let result2 = repeatElement("c" as Character, count: 6).minAndMax() + XCTAssertEqual(result2?.min, "c") + XCTAssertEqual(result2?.max, "c") + } + + /// Confirms when the minimum value is constantly updated, but the maximum + /// never is. + func testRampDown() { + let result = (1...5).reversed().minAndMax() + XCTAssertEqual(result?.min, 1) + XCTAssertEqual(result?.max, 5) + + // Even count + let result2 = "fedcba".minAndMax() + XCTAssertEqual(result2?.min, "a") + XCTAssertEqual(result2?.max, "f") + } + + /// Confirms when the maximum value is constantly updated, but the minimum + /// never is. + func testRampUp() { + let result = (1...5).minAndMax() + XCTAssertEqual(result?.min, 1) + XCTAssertEqual(result?.max, 5) + + // Even count + let result2 = "abcdef".minAndMax() + XCTAssertEqual(result2?.min, "a") + XCTAssertEqual(result2?.max, "f") + } + + /// Confirms when the maximum and minimum change during a run. + func testUpsAndDowns() { + let result = [4, 3, 3, 5, 2, 0, 7, 6].minAndMax() + XCTAssertEqual(result?.min, 0) + XCTAssertEqual(result?.max, 7) + + // Odd count + let result2 = "gfabdec".minAndMax() + XCTAssertEqual(result2?.min, "a") + XCTAssertEqual(result2?.max, "g") + } +}