diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Collections.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Collections.xcscheme index a3e92863b..dad84604b 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Collections.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Collections.xcscheme @@ -49,6 +49,16 @@ ReferencedContainer = "container:"> + + + + + + + + + + + + .target( + name: "MultiSets", + swiftSettings: settings), + .testTarget( + name: "MultiSetsTests", + dependencies: ["MultiSets", "_CollectionsTestSupport"], + swiftSettings: settings), + // OrderedSet, OrderedDictionary .target( name: "OrderedCollections", diff --git a/Sources/Collections/Collections.swift b/Sources/Collections/Collections.swift index beacfc238..370925640 100644 --- a/Sources/Collections/Collections.swift +++ b/Sources/Collections/Collections.swift @@ -10,5 +10,6 @@ //===----------------------------------------------------------------------===// @_exported import DequeModule +@_exported import MultiSets @_exported import OrderedCollections @_exported import PriorityQueueModule diff --git a/Sources/MultiSets/CountedSet/CountedSet+Collection.swift b/Sources/MultiSets/CountedSet/CountedSet+Collection.swift new file mode 100644 index 000000000..388a7aff5 --- /dev/null +++ b/Sources/MultiSets/CountedSet/CountedSet+Collection.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 +// +//===----------------------------------------------------------------------===// + +extension CountedSet: Collection { + /// The position of an element in a counted set. + @frozen + public struct Index: Comparable { + /// An index in the underlying dictionary storage of a counted set. + /// + /// This is used to distinguish between indices that point to elements with + /// different values. + @usableFromInline + let storageIndex: Dictionary.Index + + /// The relative position of the element. + /// + /// This doesn't actually correspond to a distinct part of memory. Instead, + /// it is used to distinguish between indices that point to elements of the + /// same value. + /// + /// For example, the first index pointing to a value has an index of 0, the + /// second index pointing to that value will have an index of 1, and so on. + /// + /// When a counted set is subscripted with an index, the stored multiplicity + /// is retrieved using the `storageIndex` and compared with the `position` + /// to determine the index's validity. + @usableFromInline + let position: UInt + + @inlinable + public static func < ( + lhs: CountedSet.Index, + rhs: CountedSet.Index + ) -> Bool { + guard lhs.storageIndex != rhs.storageIndex else { + return lhs.storageIndex < rhs.storageIndex + } + return lhs.position < rhs.position + } + + @usableFromInline + init(storageIndex: Dictionary.Index, position: UInt) { + self.storageIndex = storageIndex + self.position = position + } + } + + @inlinable + public func index(after i: Index) -> Index { + guard i.position + 1 < rawValue[i.storageIndex].value else { + return Index( + storageIndex: rawValue.index(after: i.storageIndex), + position: 0 + ) + } + + return Index(storageIndex: i.storageIndex, position: i.position + 1) + } + + @inlinable + public subscript(position: Index) -> Element { + let keyPair = rawValue[position.storageIndex] + precondition( + position.position < keyPair.value, + "Attempting to access CountedSet elements using an invalid index" + ) + return keyPair.key + } + + /// The number of elements in the set. + /// + /// - Complexity: O(*k*), where *k* is the number of unique elements in the + /// set. + @inlinable + public var count: Int { + Int(rawValue.values.reduce(.zero, +)) + } + + @inlinable + public var startIndex: Index { + Index(storageIndex: rawValue.startIndex, position: 0) + } + + @inlinable + public var endIndex: Index { + Index(storageIndex: rawValue.endIndex, position: 0) + } + + /// A value equal to the number of unique elements in the set. + /// + /// - Complexity: O(*k*), where *k* is the number of unique elements in the + /// set. + @inlinable + public var underestimatedCount: Int { + rawValue.count + } +} diff --git a/Sources/MultiSets/CountedSet/CountedSet+ExpressibleByDictionaryLiteral.swift b/Sources/MultiSets/CountedSet/CountedSet+ExpressibleByDictionaryLiteral.swift new file mode 100644 index 000000000..27efd32c0 --- /dev/null +++ b/Sources/MultiSets/CountedSet/CountedSet+ExpressibleByDictionaryLiteral.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 +// +//===----------------------------------------------------------------------===// + +extension CountedSet: ExpressibleByDictionaryLiteral { + /// Creates a counted set initialized with a dictionary literal. + /// + /// Do not call this initializer directly. It is called by the compiler to + /// handle dictionary literals. To use a dictionary literal as the initial + /// value of a counted set, enclose a comma-separated list of key-value pairs + /// in square brackets. + /// + /// For example, the code sample below creates a counted set with string keys. + /// + /// let countriesOfOrigin = ["BR": 2, "GH": 1, "JP": 5] + /// print(countriesOfOrigin) + /// // Prints "["BR", "BR", "JP", "JP", "JP", "JP", "JP", "GH"]" + /// + /// - Parameter elements: The element-multiplicity pairs that will make up the + /// new counted set. + /// - Precondition: Each element must be unique. + @inlinable + public init(dictionaryLiteral elements: (Element, UInt)...) { + _storage = RawValue( + uniqueKeysWithValues: elements.lazy.filter { $0.1 > .zero } + ) + } +} + diff --git a/Sources/MultiSets/CountedSet/CountedSet+Sequence.swift b/Sources/MultiSets/CountedSet/CountedSet+Sequence.swift new file mode 100644 index 000000000..48eac7018 --- /dev/null +++ b/Sources/MultiSets/CountedSet/CountedSet+Sequence.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 +// +//===----------------------------------------------------------------------===// + +extension CountedSet: Sequence { + /// Returns an iterator over the elements of this collection. + /// + /// - Complexity: O(1) + /// - Remark: A type-erased wrapper is used instead of an opaque type purely + /// to preserve compatibility with older versions of Swift. + @inlinable + public func makeIterator() -> AnyIterator { + AnyIterator( + _storage + .lazy + .map { (key, value) -> [Repeated] in + let repetitions = value.quotientAndRemainder( + dividingBy: UInt(Int.max) + ) + + var result = [Repeated]( + repeating: repeatElement(key, count: .max), + count: Int(repetitions.quotient) + ) + result.append(repeatElement(key, count: Int(repetitions.remainder))) + + return result + } + .joined() + .joined() + .makeIterator() + ) + } +} diff --git a/Sources/MultiSets/CountedSet/CountedSet+SetAlgebra.swift b/Sources/MultiSets/CountedSet/CountedSet+SetAlgebra.swift new file mode 100644 index 000000000..e9ae8fda1 --- /dev/null +++ b/Sources/MultiSets/CountedSet/CountedSet+SetAlgebra.swift @@ -0,0 +1,187 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 +// +//===----------------------------------------------------------------------===// + +extension CountedSet: SetAlgebra { + @inlinable + public init() { + _storage = RawValue() + } + + /// Returns a new set with the greater number of elements of both this and the + /// given set. + /// - Parameter other: A set of the same type as the current set. + /// - Returns: A new set with the elements of this set and `other`, preserving + /// the higher multiplicity. + /// - Note: This function does **not** add the multiplicities of each set + /// together. Rather, it discards the lower multiplicity for each element. + @inlinable + public __consuming func union(_ other: __owned CountedSet) + -> CountedSet { + var result = self + result.formUnion(other) + return result + } + + /// Returns a new set with the lesser number of elements of both this and the + /// given set. + /// - Parameter other: A set of the same type as the current set. + /// - Returns: A new set with the elements of this set and `other`, preserving + /// the lower multiplicity. + /// - Complexity: O(*k*), where *k* is the number of unique elements in the + /// current set. + @inlinable + public __consuming func intersection(_ other: CountedSet) + -> CountedSet { + var result = self + result.formIntersection(other) + return result + } + + + /// Returns a new set with the difference between the number of elements of + /// both this and the given set. + /// - Parameter other: A set of the same type as the current set. + /// - Returns: A new set with the elements of this set and `other`, taking the + /// difference between multiplicities. + @inlinable + public __consuming func symmetricDifference( + _ other: __owned CountedSet + ) -> CountedSet { + var result = self + result.formSymmetricDifference(other) + return result + } + + /// Inserts the given element in the set. + /// + /// If an element equal to `newMember` is already contained in the set, its + /// multiplicity is incremented by one. In this example, a new element is + /// inserted into `classDays`, a set of days of the week. + /// + /// enum DayOfTheWeek: Int { + /// case sunday, monday, tuesday, wednesday, thursday, + /// friday, saturday + /// } + /// + /// var classDays: CountedSet = [.wednesday, .friday] + /// print(classDays.insert(.monday)) + /// // Prints "(true, .monday)" + /// print(classDays) + /// // Prints "[.friday, .wednesday, .monday]" + /// + /// print(classDays.insert(.friday)) + /// // Prints "(true, .friday)" + /// print(classDays) + /// // Prints "[.friday, .friday, .wednesday, .monday]" + /// + /// - Parameter newMember: An element to insert into the set. + /// - Complexity: Amortized O(1) + /// - Note: Insertion is always performed, in contrast to an uncounted set. + /// This means that the result's `inserted` value is always `true`. + @inlinable + @discardableResult + public mutating func insert(_ newMember: __owned Element) + -> (inserted: Bool, memberAfterInsert: Element) { + _storage[newMember, default: 0] += 1 + return (inserted: true, memberAfterInsert: newMember) + } + + /// Removes the given element. + /// + /// If an element equal to `member` is contained in the set, its + /// multiplicity is decremented by one. + /// - Parameter member: An element to remove from the set. + /// - Complexity: O(*k*), where *k* is the number of unique elements in the + /// set, if the multiplicity of the given element was one. Otherwise, O(1). + /// - Note: This method is *not* idempotent, in contrast to an uncounted set, + /// as multiple instances of the given element may be present. + @inlinable + @discardableResult + public mutating func remove(_ member: Element) -> Element? { + guard let oldMultiplicity = rawValue[member] else { + return nil + } + + if oldMultiplicity > 1 { + _storage[member] = oldMultiplicity &- 1 + } else { + _storage.removeValue(forKey: member) + } + return member + } + + /// Inserts the given element in the set and replaces equal elements that are + /// already present. + /// + /// If an element equal to `newMember` is contained in the set, its + /// multiplicity is transferred to `newMember` and incremented by one. + /// - Parameter newMember: An element to insert into the set. + /// - Returns: The element equal to `newMember` that was contained in the set, + /// if any. + /// - Complexity: O(*k*), where *k* is the number of unique elements in the + /// set. + @inlinable + @discardableResult + public mutating func update(with newMember: __owned Element) -> Element? { + guard let oldMemberIndex = rawValue.index(forKey: newMember) else { + insert(newMember) + return nil + } + + let (oldMember, oldValue) = _storage.remove(at: oldMemberIndex) + _storage[newMember] = oldValue + 1 + return oldMember + } + + /// Combines the given set into the current set, keeping the higher number of + /// elements. + /// - Parameter other: A set of the same type as the current set. + /// - Returns: A new set with the elements of this set and `other`, preserving + /// the higher multiplicity. + /// - Note: This function does **not** add the multiplicities of each set + /// together. Rather, it discards the lower multiplicity for each element. + @inlinable + public mutating func formUnion(_ other: __owned CountedSet) { + _storage.merge(other.rawValue, uniquingKeysWith: Swift.max) + } + + /// Combines the given set into the current set, keeping the lower number of + /// elements. + /// - Parameter other: A set of the same type as the current set. + /// - Returns: A new set with the elements of this set and `other`, preserving + /// the lower multiplicity. + /// - Complexity: O(*k*), where *k* is the number of unique elements in the + /// current set. + @inlinable + public mutating func formIntersection(_ other: CountedSet) { + _storage = RawValue( + uniqueKeysWithValues: rawValue.lazy.compactMap { key, value in + other.rawValue[key].map { (key, Swift.min($0, value)) } + } + ) + } + + /// Combines the given set into the current set, keeping the difference + /// between the number of elements. + /// - Parameter other: A set of the same type as the current set. + /// - Returns: A new set with the elements of this set and `other`, taking the + /// difference between multiplicities. + @inlinable + public mutating func formSymmetricDifference( + _ other: __owned CountedSet + ) { + _storage = rawValue.merging(other.rawValue) { + $0 >= $1 ? $0 &- $1 : $1 &- $0 + }.filter { + $0.value != .zero + } + } +} diff --git a/Sources/MultiSets/CountedSet/CountedSet+Sum.swift b/Sources/MultiSets/CountedSet/CountedSet+Sum.swift new file mode 100644 index 000000000..fe2d241a6 --- /dev/null +++ b/Sources/MultiSets/CountedSet/CountedSet+Sum.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 +// +//===----------------------------------------------------------------------===// + +extension CountedSet { + /// Combines the elements two sets, adding multiplicities together. + @inlinable + public static func + (lhs: Self, rhs: __owned Self) -> Self { + var result = lhs + result += rhs + return result + } + + /// Adds the elements of a set to another set, adding multiplicities + /// together. + @inlinable + public static func += (lhs: inout Self, rhs: __owned Self) { + lhs._storage.merge(rhs.rawValue, uniquingKeysWith: +) + } +} diff --git a/Sources/MultiSets/CountedSet/CountedSet.swift b/Sources/MultiSets/CountedSet/CountedSet.swift new file mode 100644 index 000000000..cd9d719a0 --- /dev/null +++ b/Sources/MultiSets/CountedSet/CountedSet.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 +// +//===----------------------------------------------------------------------===// + +/// An unordered, counted multiset. +@frozen +public struct CountedSet: RawRepresentable { + // Allows internal setter to be referenced from inlined code + @usableFromInline + internal var _storage = RawValue() + + @inlinable @inline(__always) + public var rawValue: [Element: UInt] { _storage } + + @inlinable + public var isEmpty: Bool { rawValue.isEmpty } + + /// Creates an empty counted set with preallocated space for at least the + /// specified number of unique elements. + /// + /// - Parameter minimumCapacity: The minimum number of elements that the + /// newly created counted set should be able to store without reallocating + /// its storage buffer. + @inlinable + public init(minimumCapacity: Int) { + self._storage = .init(minimumCapacity: minimumCapacity) + } + + @inlinable + public init?(rawValue: [Element: UInt]) { + guard rawValue.values.allSatisfy({ $0 > .zero }) else { return nil } + _storage = rawValue + } +} diff --git a/Tests/MultiSetsTests/CountedSet/CountedSet Tests.swift b/Tests/MultiSetsTests/CountedSet/CountedSet Tests.swift new file mode 100644 index 000000000..4e4565521 --- /dev/null +++ b/Tests/MultiSetsTests/CountedSet/CountedSet Tests.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 MultiSets + +import _CollectionsTestSupport + +class CountedSetTests: CollectionTestCase { + func test_empty() { + let s = CountedSet() + expectEqualElements(s, []) + expectEqual(s.count, 0) + } + + func test_init_minimumCapacity() { + let s = CountedSet(minimumCapacity: 1000) + expectGreaterThanOrEqual(s.rawValue.capacity, 1000) + } +} diff --git a/Tests/MultiSetsTests/CountedSet/CountedSet+SetAlgebra Tests.swift b/Tests/MultiSetsTests/CountedSet/CountedSet+SetAlgebra Tests.swift new file mode 100644 index 000000000..7c3beb96c --- /dev/null +++ b/Tests/MultiSetsTests/CountedSet/CountedSet+SetAlgebra Tests.swift @@ -0,0 +1,159 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 MultiSets + +import _CollectionsTestSupport + + +private let x: CountedSet = ["a": 1, "b": 2, "c": 3, "d": 4] +private let y: CountedSet = ["e", "f", "a", "f"] + +class CountedSetSetAlgebraTests: CollectionTestCase { + /// `S() == []` + func testEmptyArrayLiteralInitialization() { + XCTAssertEqual(CountedSet(), []) + } + + /// `x.intersection(x) == x` + func testIdempotentIntersection() { + XCTAssertEqual(x.intersection(x), x) + } + + /// `x.intersection([]) == []` + func testEmptyIntersection() { + XCTAssertEqual(x.intersection([]), []) + } + + /// `x.union(x) == x` + func testIdempotentUnion() { + XCTAssertEqual(x.union(x), x) + } + + /// `x.union([]) == x` + func testEmptyUnion() { + XCTAssertEqual(x.union([]), x) + } + + /// `x.contains(e)` implies `x.union(y).contains(e)` + func testUnionContainsElementsOfCurrentSet() { + let union = x.union(y) + x.rawValue.keys.forEach { e in XCTAssert(union.contains(e)) } + } + + /// `x.union(y).contains(e)` implies `x.contains(e) || y.contains(e)` + func testUnionContainsOnlyElementsOfCurrentOrGivenSets() { + x.union(y).rawValue.keys.forEach { e in + XCTAssert(x.contains(e) || y.contains(e)) + } + } + + /// `x.contains(e) && y.contains(e)` if and only if + /// `x.intersection(y).contains(e)` + func testIntersectionContainsOnlyAllElementsOfCurrentOrGivenSets() { + XCTAssertEqual( + x.rawValue.keys.filter { (e: Character) in y.contains(e) }, + Array(x.intersection(y).rawValue.keys) + ) + } + + /// `x.isSubset(of: y)` implies `x.union(y) == y` + func testSubsetDomination() { + let y: CountedSet = ["a": 1, "b": 2, "c": 3, "d": 4, "n": 9] + assert(x.isSubset(of: y), "Antecedent not satisfied") + XCTAssertEqual(x.union(y), y) + } + + /// `x.isSuperset(of: y)` implies `x.union(y) == x` + func testSupersetAbsorption() { + let y: CountedSet = ["b", "b"] + assert(x.isSuperset(of: y), "Antecedent not satisfied") + XCTAssertEqual(x.union(y), x) + } + + /// `x.isSubset(of: y)` if and only if `y.isSuperset(of: x)` + func testSubsetOfSuperset() { + XCTAssertEqual(x.isSubset(of: y), y.isSuperset(of: x)) + let y: CountedSet = ["b", "b"] + XCTAssertEqual(x.isSubset(of: y), y.isSuperset(of: x)) + } + + /// `x.isStrictSuperset(of: y)` if and only if `x.isSuperset(of: y) && x != y` + func testStrictSuperset() { + var y = x + XCTAssertEqual(x.isStrictSuperset(of: y), x.isSuperset(of: y) && x != y) + y.remove("a") + XCTAssertEqual(x.isStrictSuperset(of: y), x.isSuperset(of: y) && x != y) + } + + /// `x.isStrictSubset(of: y)` if and only if `x.isSubset(of: y) && x != y` + func testStrictSubset() { + var y = x + XCTAssertEqual(x.isStrictSubset(of: y), x.isSubset(of: y) && x != y) + y.insert("n") + XCTAssertEqual(x.isStrictSubset(of: y), x.isSubset(of: y) && x != y) + } + + func testSymmetricDifference() { + XCTAssertEqual( + x.symmetricDifference(y), + ["b": 2, "c": 3, "d": 4, "e": 1, "f": 2] + ) + } + + func testSymmetricDifferenceWithLargerOperand() { + XCTAssertEqual( + x.symmetricDifference(["b": 5]), + ["a": 1, "b": 3, "c": 3, "d": 4] + ) + } + + func testUpdateExisting() { + var referenceStrings: CountedSet = [ + "testing", + "testing", + "one", + "two", + "three", + ] + + let newTestingString: NSMutableString = "testing" + assert( + referenceStrings.first { $0 == "testing" }! !== newTestingString, + "The new string is identical to what it is meant to replace" + ) + referenceStrings.update(with: newTestingString) + XCTAssertIdentical( + referenceStrings.first { $0 == "testing" }!, + newTestingString + ) + XCTAssertEqual(referenceStrings.rawValue["testing"], 3) + } + + func testUpdateNew() { + var s: CountedSet = ["dog"] + s.update(with: "cow") + XCTAssertEqual(s, ["dog", "cow"]) + } + + func testRemoveEmpty() { + var s = CountedSet() + XCTAssertNil(s.remove(42)) + XCTAssert(s.isEmpty) + } + + func testRemoveExisting() { + var s: CountedSet = ["testing", "testing"] + XCTAssertEqual(s.remove("testing"), "testing") + XCTAssertEqual(s, ["testing"]) + } +} diff --git a/Tests/MultiSetsTests/CountedSet/CountedSet+Sum Tests.swift b/Tests/MultiSetsTests/CountedSet/CountedSet+Sum Tests.swift new file mode 100644 index 000000000..260756899 --- /dev/null +++ b/Tests/MultiSetsTests/CountedSet/CountedSet+Sum Tests.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Collections open source project +// +// Copyright (c) 2021 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 MultiSets + +import _CollectionsTestSupport + + +private let x: CountedSet = ["a": 1, "b": 2, "c": 3, "d": 4] +private let y: CountedSet = ["e", "f", "a", "f"] + +class CountedSetSumTests: CollectionTestCase { + func testSum() { + XCTAssertEqual( + x + y, + ["a": 2, "b": 2, "c": 3, "d": 4, "e": 1, "f": 2] + ) + } +}