commit bda5c1c8e3df50cfe467a30c494e524e0837d823 Author: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Sun Jun 9 14:44:43 2024 -0400 Add SwiftTrie package diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e880497 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +name: Docs + +on: + push: + branches: + - main + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + docs: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Pages + uses: actions/configure-pages@v3 + - name: Generate Docs + uses: fwcd/swift-docc-action@v1 + with: + target: SwiftTrie + output: ./docs + transform-for-static-hosting: 'true' + disable-indexing: 'true' + hosting-base-path: swift-trie + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: ./docs + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: docs + + steps: + - name: Deploy Docs + uses: actions/deploy-pages@v1 \ No newline at end of file diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 0000000..0f184bf --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,16 @@ +name: SwiftLint + +on: + push: + branches: [ '**' ] + workflow_dispatch: + +jobs: + SwiftLint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: GitHub Action for SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --strict diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml new file mode 100644 index 0000000..bd982c3 --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,27 @@ +name: Unit Tests + +on: + push: + branches: [ '**' ] + workflow_dispatch: + +jobs: + build-and-test: + runs-on: macos-latest + strategy: + matrix: + swift: ['5.8', '5.9', '5.10'] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Swift + uses: SwiftyLab/setup-swift@latest + with: + swift-version: ${{ matrix.swift }} + + - name: Build and Test + run: | + swift build + swift test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4356ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved +docs diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..7be817f --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,2 @@ +excluded: + - .build diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a5cf6b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Terry Yiu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5f85cae --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SwiftTrie", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "SwiftTrie", + targets: ["SwiftTrie"]) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "SwiftTrie"), + .testTarget( + name: "SwiftTrieTests", + dependencies: ["SwiftTrie"]) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0598182 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +[![Unit Tests](https://github.com/tyiu/swift-trie/actions/workflows/unit.yml/badge.svg)](https://github.com/tyiu/swift-trie/actions/workflows/unit.yml) [![SwiftLint](https://github.com/tyiu/swift-trie/actions/workflows/swiftlint.yml/badge.svg)](https://github.com/tyiu/swift-trie/actions/workflows/swiftlint.yml) [![Docs](https://github.com/tyiu/swift-trie/actions/workflows/docs.yml/badge.svg)](https://github.com/tyiu/swift-trie/actions/workflows/docs.yml) + +# SwiftTrie + +A Swift package that provides a [Trie](https://en.wikipedia.org/wiki/Trie) data structure that allow efficient searches of values that map from prefixed keys or non-prefixed key substrings. + +## Minimum Requirements + +- Swift 5.8 + +## Installation + +SwiftTrie can be integrated as an Xcode project target or a Swift package target. + +### Xcode Project Target + +1. Go to `File` -> `Add Package Dependencies`. +2. Type https://github.com/tyiu/swift-trie.git into the search field. +3. Select `swift-trie` from the search results. +4. Select `Up to Next Major Version` starting from the latest release as the dependency rule. +5. Ensure your project is selected next to `Add to Project`. +6. Click `Add Package`. +7. On the package product dialog, add `SwiftTrie` to your target and click `Add Package`. + +### Swift Package Target + +In your `Package.swift` file: +1. Add the SwiftTrie package dependency to https://github.com/tyiu/swift-trie.git +2. Add `SwiftTrie` as a dependency on the targets that need to use the SDK. + +```swift +let package = Package( + // ... + dependencies: [ + // ... + .package(url: "https://github.com/tyiu/swift-trie.git", .upToNextMajor(from: "0.1.0")) + ], + targets: [ + .target( + // ... + dependencies: ["SwiftTrie"] + ), + .testTarget( + // ... + dependencies: ["SwiftTrie"] + ) + ] +) +``` + +## Usage + +See [TrieTests.swift](Tests/SwiftTrieTests/TrieTests.swift) for an example of how to use SwiftTrie. diff --git a/Sources/SwiftTrie/Trie.swift b/Sources/SwiftTrie/Trie.swift new file mode 100644 index 0000000..2cca4da --- /dev/null +++ b/Sources/SwiftTrie/Trie.swift @@ -0,0 +1,157 @@ +// +// Trie.swift +// +// +// Created by Terry Yiu on 6/9/24. +// + +import Foundation + +/// Trie is a tree data structure of all the substring permutations of a collection of strings +/// optimized for searching for values of type V. +/// +/// Each node in the tree can have child nodes. +/// Each node represents a single character in substrings, +/// and each of its child nodes represent the subsequent character in those substrings. +/// +/// A node that has no children mean that there are no substrings +/// with any additional characters beyond the branch of letters leading up to that node. +/// +/// A node that has values mean that there are strings that end in the character represented by the node +/// and contain the substring represented by the branch of letters leading up to that node. +/// +/// See the article on [Trie](https://en.wikipedia.org/wiki/Trie) on Wikipedia. +public class Trie { + private var children: [Character: Trie] = [:] + + /// Separate exact matches from strict substrings so that exact matches appear first in returned results. + private var exactMatchValues = Set() + private var substringMatchValues = Set() + + private var parent: Trie? + + var hasChildren: Bool { + return !self.children.isEmpty + } + + var hasValues: Bool { + return !self.exactMatchValues.isEmpty || !self.substringMatchValues.isEmpty + } + + public init() { } +} + +public extension Trie { + /// Finds the branch that matches the specified key and returns the values from all of its descendant nodes. + /// Note: If `key` is an empty string, all values are returned. + /// - Parameters: + /// - key: The key to find in the trie. + /// - Returns: The values that are mapped from matches of `key`. + func find(key: String) -> [V] { + var currentNode = self + + // Find branch with matching prefix. + for char in key { + if let child = currentNode.children[char] { + currentNode = child + } else { + return [] + } + } + + // Perform breadth-first search from matching branch and collect values from all descendants. + var substringMatches = Set(currentNode.substringMatchValues) + var queue = Array(currentNode.children.values) + + while !queue.isEmpty { + let node = queue.removeFirst() + substringMatches.formUnion(node.exactMatchValues) + substringMatches.formUnion(node.substringMatchValues) + queue.append(contentsOf: node.children.values) + } + + // Prioritize exact matches to be returned first, + // and then remove exact matches from the set of partial substring matches that are appended afterward. + return Array(currentNode.exactMatchValues) + (substringMatches.subtracting(currentNode.exactMatchValues)) + } + + /// Inserts a value into this trie for the specified key. + /// This function stores all substring endings of the key, not only the key itself. + /// Runtime performance is O(n^2) and storage cost is O(n), where n is the number of characters in the key. + /// Note: If `key` is an empty string, this operation is effectively a no-op. + /// - Parameters: + /// - key: The key to insert that maps to `value`. + /// - value: The value that is mapped from `key`. + /// - includeNonPrefixedMatches: Whether the key and value should be inserted to allow for non-prefixed matches. + /// By default, it is `false`. If it is `true`, more memory will be used. + func insert(key: String, value: V, includeNonPrefixedMatches: Bool = false) { + // Create root branches for each character of the key to enable substring searches + // instead of only just prefix searches. + // Hence the nested loop. + for keyIndex in 0..() + + let keys = ["foobar", "food", "foo", "somethingelse", "duplicate", "duplicate"] + keys.forEach { + trie.insert(key: $0, value: $0) + } + + let allResults = trie.find(key: "") + XCTAssertEqual(Set(allResults), Set(["foobar", "food", "foo", "somethingelse", "duplicate"])) + + let fooResults = trie.find(key: "foo") + XCTAssertEqual(fooResults.first, "foo") + XCTAssertEqual(Set(fooResults), Set(["foobar", "food", "foo"])) + + let foodResults = trie.find(key: "food") + XCTAssertEqual(foodResults, ["food"]) + + let ooResults = trie.find(key: "oo") + XCTAssertEqual(Set(ooResults), Set([])) + + let notFoundResults = trie.find(key: "notfound") + XCTAssertEqual(notFoundResults, []) + + // Sanity check that the root node has children. + XCTAssertTrue(trie.hasChildren) + + // Sanity check that the root node has no values. + XCTAssertFalse(trie.hasValues) + } + + func testFindNonPrefixedMatches() throws { + let trie = Trie() + + let keys = ["foobar", "food", "foo", "somethingelse", "duplicate", "duplicate"] + keys.forEach { + trie.insert(key: $0, value: $0, includeNonPrefixedMatches: true) + } + + let allResults = trie.find(key: "") + XCTAssertEqual(Set(allResults), Set(["foobar", "food", "foo", "somethingelse", "duplicate"])) + + let fooResults = trie.find(key: "foo") + XCTAssertEqual(fooResults.first, "foo") + XCTAssertEqual(Set(fooResults), Set(["foobar", "food", "foo"])) + + let foodResults = trie.find(key: "food") + XCTAssertEqual(foodResults, ["food"]) + + let ooResults = trie.find(key: "oo") + XCTAssertEqual(Set(ooResults), Set(["foobar", "food", "foo"])) + + let aResults = trie.find(key: "a") + XCTAssertEqual(Set(aResults), Set(["foobar", "duplicate"])) + + let notFoundResults = trie.find(key: "notfound") + XCTAssertEqual(notFoundResults, []) + + // Sanity check that the root node has children. + XCTAssertTrue(trie.hasChildren) + + // Sanity check that the root node has no values. + XCTAssertFalse(trie.hasValues) + } + + func testRemove() { + let trie = Trie() + + let keys = ["foobar", "food", "foo", "somethingelse", "duplicate", "duplicate"] + keys.forEach { + trie.insert(key: $0, value: $0) + } + + keys.forEach { + trie.remove(key: $0, value: $0) + } + + let allResults = trie.find(key: "") + XCTAssertTrue(allResults.isEmpty) + + let fooResults = trie.find(key: "foo") + XCTAssertTrue(fooResults.isEmpty) + + let foodResults = trie.find(key: "food") + XCTAssertTrue(foodResults.isEmpty) + + let ooResults = trie.find(key: "oo") + XCTAssertTrue(ooResults.isEmpty) + + let aResults = trie.find(key: "a") + XCTAssertTrue(aResults.isEmpty) + + // Verify that removal of values from all the keys that were inserted in the trie previously + // also resulted in the cleanup of the trie. + XCTAssertFalse(trie.hasChildren) + XCTAssertFalse(trie.hasValues) + } + +}