diff --git a/CHANGELOG.md b/CHANGELOG.md index b070cf5a..268368e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ This project follows semantic versioning. ## [Unreleased] -*No new changes.* +### Additions + +- The `degreeOfInclusion(with:by:)` and `degreeOfInclusion(with:)` methods have + been added. They report how much overlap two sorted sequences have, expressed + by the `SetInclusion` type. ([#38]) --- @@ -288,6 +292,7 @@ This changelog's format is based on [Keep a Changelog](https://keepachangelog.co [#24]: https://github.com/apple/swift-algorithms/pull/24 [#31]: https://github.com/apple/swift-algorithms/pull/31 [#35]: https://github.com/apple/swift-algorithms/pull/35 +[#38]: https://github.com/apple/swift-algorithms/pull/38 [#46]: https://github.com/apple/swift-algorithms/pull/46 [#51]: https://github.com/apple/swift-algorithms/pull/51 [#54]: https://github.com/apple/swift-algorithms/pull/54 diff --git a/Guides/DegreeOfInclusion.md b/Guides/DegreeOfInclusion.md new file mode 100644 index 00000000..8e81bb79 --- /dev/null +++ b/Guides/DegreeOfInclusion.md @@ -0,0 +1,77 @@ +# Sorted Sequence Inclusion + +[[Source](../Sources/Algorithms/DegreeOfInclusion.swift) | + [Tests](../Tests/SwiftAlgorithmsTests/DegreeOfInclusionTests.swift)] + +Methods to find how much two sorted sequences overlap. + +```swift +if (1...7).degreeOfInclusion(with: [1, 5, 6]).doesFirstIncludeSecond { + print("The range is a superset of the array.") +} +``` + +The result is an enumeration detailing the precise containment relationship, if +any. If the result was `Bool`, then the method would need to be called again +(with swapped arguments) to confirm the actual inclusion degree. + +## Detailed Design + +The inclusion-detection methods are declared as extensions to `Sequence`. The +overload that defaults comparisons to the standard less-than operator is +constrained to when the `Element` type conforms to `Comparable`. + +A reported inclusion state is expressed with the `SetInclusion` type. This state +is based on the existence of elements that are shared, exclusive to the first +sequence, and exclusive to the second sequence. This includes all the +degenerate combinations, which are the ones where at least one source is empty. +Use the convenience properties `doesFirstIncludeSecond` and +`doesSecondIncludeFirst` (and `areIdentical`) to actually check if one source is +a superset of the other. + +```swift +enum SetInclusion { + case bothUninhabited, onlyFirstInhabited, onlySecondInhabited, + dualExclusivesOnly, sharedOnly, firstExtendsSecond, + secondExtendsFirst, dualExclusivesAndShared +} + +extension SetInclusion { + var hasExclusivesToFirst: Bool { get } + var hasExclusivesToSecond: Bool { get } + var hasSharedElements: Bool { get } + var areIdentical: Bool { get } + var doesFirstIncludeSecond: Bool { get } + var doesSecondIncludeFirst: Bool { get } +} + +extension Sequence { + func degreeOfInclusion( + with other: S, + by areInIncreasingOrder: (Element, Element) throws -> Bool + ) rethrows -> SetInclusion where S.Element == Element +} + +extension Sequence where Element: Comparable { + func degreeOfInclusion( + with other: S + ) -> SetInclusion where S.Element == Element +} +``` + +### Complexity + +All of these methods have to walk the entirety of both sources, so they work in +O(_n_) operations, where _n_ is the length of the shorter source. + +### Comparison with other languages + +**C++:** The `` library defines the `includes` function, whose +functionality is part of the semantics of `degreeOfInclusion`. The `includes` +function only detects of the second sequence is included within the first; it +doesn't notify if the inclusion is degenerate, or if inclusion fails because +it's actually reversed, both of which `degreeOfInclusion` can do. To get the +direct functionality of `includes`, check the `doesFirstIncludeSecond` property +of the return value from `degreeOfInclusion`. + +(To-do: add other languages.) diff --git a/README.md b/README.md index 270055fb..4c368059 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Read more about the package, and the intent behind it, in the [announcement on s - [`reductions(_:)`, `reductions(_:_:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Reductions.md): Returns all the intermediate states of reducing the elements of a sequence or collection. - [`split(maxSplits:omittingEmptySubsequences:whereSeparator)`, `split(separator:maxSplits:omittingEmptySubsequences)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Split.md): Lazy versions of the Standard Library's eager operations that split sequences and collections into subsequences separated by the specified separator element. - [`windows(ofCount:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/Windows.md): Breaks a collection into overlapping subsequences where elements are slices from the original collection. +- [`degreeOfInclusion(with:by:)`, `degreeOfInclusion(with:)`](https://github.com/apple/swift-algorithms/blob/main/Guides/DegreeOfInclusion.md): Reports the degree two sorted sequences overlap. ## Adding Swift Algorithms as a Dependency diff --git a/Sources/Algorithms/DegreeOfInclusion.swift b/Sources/Algorithms/DegreeOfInclusion.swift new file mode 100644 index 00000000..003a161c --- /dev/null +++ b/Sources/Algorithms/DegreeOfInclusion.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Algorithms open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +/// The manner two (multi-)sets may overlap, including degenerate cases. +public enum SetInclusion: UInt, CaseIterable { + /// Neither source had any elements. + case bothUninhabited + /// Only the first source had any elements. + case onlyFirstInhabited + /// Only the second source had any elements. + case onlySecondInhabited + /// Each source has its own elements, without any shared. + case dualExclusivesOnly + /// Each source has elements, all of them shared. + case sharedOnly + /// The second source has elements, but the first has those and some more. + case firstExtendsSecond + /// The first source has elements, but the second has those and some more. + case secondExtendsFirst + /// Each source has exclusive elements, and there are some shared ones. + case dualExclusivesAndShared +} + +extension SetInclusion { + /// Whether there are elements exclusive to the first source. + @inlinable public var hasExclusivesToFirst: Bool { rawValue & 0x01 != 0 } + /// Whether there are elements exclusive to the second source. + @inlinable public var hasExclusivesToSecond: Bool { rawValue & 0x02 != 0 } + /// Whether there are elements shared by both sources. + @inlinable public var hasSharedElements: Bool { rawValue & 0x04 != 0 } + + /// Whether the sources are identical. + @inlinable public var areIdentical: Bool { rawValue & 0x03 == 0 } + /// Whether the first source contains everything from the second. + @inlinable public var doesFirstIncludeSecond: Bool { !hasExclusivesToSecond } + /// Whether the second source contains everything from the first. + @inlinable public var doesSecondIncludeFirst: Bool { !hasExclusivesToFirst } +} + +//===----------------------------------------------------------------------===// +// degreeOfInclusion(with:by:) +//===----------------------------------------------------------------------===// + +extension Sequence { + /// Returns how this sequence and the given sequence overlap, assuming both + /// are sorted according to the given predicate that can compare 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) + /// + /// - Precondition: Both the receiver and `other` are sorted according to + /// `areInIncreasingOrder`; and both should be finite. + /// + /// - Parameters: + /// - other: A sequence to compare to this sequence. + /// - areInIncreasingOrder: A predicate that returns `true` if its first + /// argument should be ordered before its second argument; otherwise, + /// `false`. + /// - Returns: The degree of inclusion between the sequences. The receiver is + /// considered the first source, and `other` second. + /// + /// - Complexity: O(*m*), where *m* is the lesser of the length of the + /// sequence and the length of `other`. + public func degreeOfInclusion( + with other: S, + by areInIncreasingOrder: (Element, Element) throws -> Bool + ) rethrows -> SetInclusion where S.Element == Element { + var rawResult: UInt = 0, cache, otherCache: Element?, isDone = false + var iterator = makeIterator(), otherIterator = other.makeIterator() + while !isDone { + cache = cache ?? iterator.next() + otherCache = otherCache ?? otherIterator.next() + switch (cache, otherCache) { + case (nil, nil): + isDone = true + case (_?, nil): + rawResult |= 0x01 + isDone = true + case (nil, _?): + rawResult |= 0x02 + isDone = true + case let (first?, second?): + if try areInIncreasingOrder(first, second) { + rawResult |= 0x01 + cache = nil + } else if try areInIncreasingOrder(second, first) { + rawResult |= 0x02 + otherCache = nil + } else { + rawResult |= 0x04 + cache = nil + otherCache = nil + } + isDone = rawResult == 0x07 + } + } + return SetInclusion(rawValue: rawResult)! + } +} + +//===----------------------------------------------------------------------===// +// degreeOfInclusion(with:) +//===----------------------------------------------------------------------===// + +extension Sequence where Element: Comparable { + /// Returns how this sequence and the given sequence overlap, assuming both + /// are sorted. + /// + /// - Precondition: Both the receiver and `other` are sorted; and both should + /// be finite. + /// + /// - Parameters: + /// - other: A sequence to compare to this sequence. + /// - Returns: The degree of inclusion between the sequences. The receiver is + /// considered the first source, and `other` second. + /// + /// - Complexity: O(*m*), where *m* is the lesser of the length of the + /// sequence and the length of `other`. + @inlinable + public func degreeOfInclusion(with other: S) -> SetInclusion + where S.Element == Element { + return degreeOfInclusion(with: other, by: <) + } +} diff --git a/Tests/SwiftAlgorithmsTests/DegreeOfInclusion.swift b/Tests/SwiftAlgorithmsTests/DegreeOfInclusion.swift new file mode 100644 index 00000000..0a7f1f18 --- /dev/null +++ b/Tests/SwiftAlgorithmsTests/DegreeOfInclusion.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Algorithms open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import XCTest +import Algorithms + +/// Unit tests for the `sortedOverlap` method and `SetInclusion` type. +final class SortedInclusionTests: XCTestCase { + /// Check the `SetInclusion` type's properties. + func testInclusion() { + XCTAssertEqualSequences(SetInclusion.allCases, [ + .bothUninhabited, .onlyFirstInhabited, .onlySecondInhabited, + .dualExclusivesOnly, .sharedOnly, .firstExtendsSecond, + .secondExtendsFirst, .dualExclusivesAndShared + ]) + + XCTAssertEqualSequences(SetInclusion.allCases.map(\.hasExclusivesToFirst), [ + false, true, false, true, false, true, false, true + ]) + XCTAssertEqualSequences(SetInclusion.allCases.map(\.hasExclusivesToSecond), [ + false, false, true, true, false, false, true, true + ]) + XCTAssertEqualSequences(SetInclusion.allCases.map(\.hasSharedElements), [ + false, false, false, false, true, true, true, true + ]) + + XCTAssertEqualSequences(SetInclusion.allCases.map(\.areIdentical), [ + true, false, false, false, true, false, false, false + ]) + XCTAssertEqualSequences(SetInclusion.allCases.map(\.doesFirstIncludeSecond), [ + true, true, false, false, true, true, false, false + ]) + XCTAssertEqualSequences(SetInclusion.allCases.map(\.doesSecondIncludeFirst), [ + true, false, true, false, true, false, true, false + ]) + } + + /// Check when both sources are empty. + func testEmpty() { + let empty = EmptyCollection() + XCTAssertEqual(empty.degreeOfInclusion(with: empty), .bothUninhabited) + } + + /// Check when exactly one source is empty. + func testOnlyOneEmpty() { + let empty = EmptyCollection(), single = CollectionOfOne(1) + XCTAssertEqual(single.degreeOfInclusion(with: empty), .onlyFirstInhabited) + XCTAssertEqual(empty.degreeOfInclusion(with: single), .onlySecondInhabited) + } + + /// Check when there are no common elements. + func testDisjoint() { + let one = CollectionOfOne(1), two = CollectionOfOne(2) + XCTAssertEqual(one.degreeOfInclusion(with: two), .dualExclusivesOnly) + XCTAssertEqual(two.degreeOfInclusion(with: one), .dualExclusivesOnly) + // The order changes which comparison branch is used and which versus-nil + // case is used. + } + + /// Check when there are only common elements. + func testIdentical() { + let single = CollectionOfOne(1) + XCTAssertEqual(single.degreeOfInclusion(with: single), .sharedOnly) + } + + /// Check when the first source is a superset of the second. + func testFirstIncludesSecond() { + XCTAssertEqual([1, 2, 3, 5, 7].degreeOfInclusion(with: [1, 3, 5, 7]), + .firstExtendsSecond) + XCTAssertEqual([2, 4, 6, 8].degreeOfInclusion(with: [2, 4, 6]), + .firstExtendsSecond) + // The logic path differs if the last elements tie, or the first source's + // last element is bigger. (The second's last element can't be biggest.) + } + + /// Check when the second source is a superset of the first. + func testSecondIncludesFirst() { + XCTAssertEqual([1, 3, 5, 7].degreeOfInclusion(with: [1, 2, 3, 5, 7]), + .secondExtendsFirst) + XCTAssertEqual([2, 4, 6].degreeOfInclusion(with: [2, 4, 6, 8]), + .secondExtendsFirst) + // The logic path differs if the last elements tie, or the second source's + // last element is bigger. (The first's last element can't be biggest.) + } + + /// Check when there are shared and two-way exclusive elements. + func testPartialOverlap() { + XCTAssertEqual([3, 6, 9].degreeOfInclusion(with: [2, 4, 6, 8]), + .dualExclusivesAndShared) + XCTAssertEqual([1, 2, 4].degreeOfInclusion(with: [1, 4, 16]), + .dualExclusivesAndShared) + // For the three categories; exclusive to first, exclusive to second, and + // shared; if the third one encountered isn't from the last element(s) from + // a sequence(s), then the iteration will end early. The first example + // uses the short-circuit condition. + } +}