From bda5c1c8e3df50cfe467a30c494e524e0837d823 Mon Sep 17 00:00:00 2001
From: Terry Yiu <963907+tyiu@users.noreply.github.com>
Date: Sun, 9 Jun 2024 14:44:43 -0400
Subject: [PATCH] Add SwiftTrie package
---
.github/workflows/docs.yml | 50 ++++++
.github/workflows/swiftlint.yml | 16 ++
.github/workflows/unit.yml | 27 +++
.gitignore | 11 ++
.swiftlint.yml | 2 +
.../xcshareddata/IDEWorkspaceChecks.plist | 8 +
LICENSE | 21 +++
Package.swift | 27 +++
README.md | 53 ++++++
Sources/SwiftTrie/Trie.swift | 157 ++++++++++++++++++
Tests/SwiftTrieTests/TrieTests.swift | 111 +++++++++++++
11 files changed, 483 insertions(+)
create mode 100644 .github/workflows/docs.yml
create mode 100644 .github/workflows/swiftlint.yml
create mode 100644 .github/workflows/unit.yml
create mode 100644 .gitignore
create mode 100644 .swiftlint.yml
create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
create mode 100644 LICENSE
create mode 100644 Package.swift
create mode 100644 README.md
create mode 100644 Sources/SwiftTrie/Trie.swift
create mode 100644 Tests/SwiftTrieTests/TrieTests.swift
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 @@
+[](https://github.com/tyiu/swift-trie/actions/workflows/unit.yml) [](https://github.com/tyiu/swift-trie/actions/workflows/swiftlint.yml) [](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)
+ }
+
+}