Add SwiftTrie package
This commit is contained in:
50
.github/workflows/docs.yml
vendored
Normal file
50
.github/workflows/docs.yml
vendored
Normal 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
16
.github/workflows/swiftlint.yml
vendored
Normal 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
27
.github/workflows/unit.yml
vendored
Normal 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
11
.gitignore
vendored
Normal 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
2
.swiftlint.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
excluded:
|
||||
- .build
|
||||
@@ -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
21
LICENSE
Normal 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
27
Package.swift
Normal 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
53
README.md
Normal file
@@ -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.
|
||||
157
Sources/SwiftTrie/Trie.swift
Normal file
157
Sources/SwiftTrie/Trie.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
Tests/SwiftTrieTests/TrieTests.swift
Normal file
111
Tests/SwiftTrieTests/TrieTests.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user