Add SwiftTrie package

This commit is contained in:
2024-06-09 14:44:43 -04:00
commit bda5c1c8e3
11 changed files with 483 additions and 0 deletions

50
.github/workflows/docs.yml vendored Normal file
View File

@@ -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

16
.github/workflows/swiftlint.yml vendored Normal file
View File

@@ -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

27
.github/workflows/unit.yml vendored Normal file
View File

@@ -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

11
.gitignore vendored Normal file
View File

@@ -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

2
.swiftlint.yml Normal file
View File

@@ -0,0 +1,2 @@
excluded:
- .build

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

21
LICENSE Normal file
View File

@@ -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.

27
Package.swift Normal file
View File

@@ -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"])
]
)

53
README.md Normal file
View File

@@ -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.

View File

@@ -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<V: Hashable> {
private var children: [Character: Trie] = [:]
/// Separate exact matches from strict substrings so that exact matches appear first in returned results.
private var exactMatchValues = Set<V>()
private var substringMatchValues = Set<V>()
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<V>(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..<key.count {
var currentNode = self
// Find branch with matching prefix.
for char in key[key.index(key.startIndex, offsetBy: keyIndex)...] {
if let child = currentNode.children[char] {
currentNode = child
} else {
let child = Trie()
child.parent = currentNode
currentNode.children[char] = child
currentNode = child
}
}
if keyIndex == 0 {
currentNode.exactMatchValues.insert(value)
// If includeNonPrefixedMatches is true, the first character of the key can be the only root branch
// and we terminate the loop early.
if !includeNonPrefixedMatches {
return
}
} else {
currentNode.substringMatchValues.insert(value)
}
}
}
/// Removes a value from this trie for the specified key.
/// - Parameters:
/// - key: The key to remove.
/// - value: The value to remove.
func remove(key: String, value: V) {
for keyIndex in 0..<key.count {
var currentNode = self
var foundLeafNode = true
// Find branch with matching prefix.
for keySubIndex in keyIndex..<key.count {
let char = key[key.index(key.startIndex, offsetBy: keySubIndex)]
if let child = currentNode.children[char] {
currentNode = child
} else {
foundLeafNode = false
break
}
}
if foundLeafNode {
currentNode.exactMatchValues.remove(value)
currentNode.substringMatchValues.remove(value)
// Clean up the tree if this leaf node no longer holds values or children.
for keySubIndex in (keyIndex..<key.count).reversed() {
if let parent = currentNode.parent, !currentNode.hasValues && !currentNode.hasChildren {
currentNode = parent
let char = key[key.index(key.startIndex, offsetBy: keySubIndex)]
currentNode.children.removeValue(forKey: char)
}
}
}
}
}
}

View File

@@ -0,0 +1,111 @@
//
// TrieTests.swift
//
//
// Created by Terry Yiu on 6/9/24.
//
import XCTest
@testable import SwiftTrie
final class TrieTests: XCTestCase {
func testFindPrefixedMatches() throws {
let trie = Trie<String>()
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<String>()
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<String>()
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)
}
}