Chapters

Hide chapters

Data Structures & Algorithms in Swift

Third Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

18. Tries
Written by Kelvin Lau

The trie (pronounced as try) is a tree that specializes in storing data that can be represented as a collection, such as English words:

A trie containing the words CAT, CUT, CUTE, TO, and B
A trie containing the words CAT, CUT, CUTE, TO, and B

Each character in a string is mapped to a node. The last node in each string is marked as a terminating node (a dot in the image above). The benefits of a trie are best illustrated by looking at it in the context of prefix matching.

In this chapter, you’ll first compare the performance of the trie to the array. Then you’ll implement the trie from scratch!

Example

You are given a collection of strings. How would you build a component that handles prefix matching? Here’s one way:

class EnglishDictionary {

  private var words: [String]
  
  func words(matching prefix: String) -> [String] {
    words.filter { $0.hasPrefix(prefix) }
  }
}

words(matching:) will go through the collection of strings and return the strings that match the prefix.

If the number of elements in the words array is small, this is a reasonable strategy. But if you’re dealing with more than a few thousand words, the time it takes to go through the words array will be unacceptable. The time complexity of words(matching:) is O(k*n), where k is the longest string in the collection, and n is the number of words you need to check.

Imagine the number of words Google needs to parse
Imagine the number of words Google needs to parse

The trie data structure has excellent performance characteristics for this type of problem; as a tree with nodes that support multiple children, each node can represent a single character.

You form a word by tracing the collection of characters from the root to a node with a special indicator — a terminator — represented by a black dot. An interesting characteristic of the trie is that multiple words can share the same characters.

To illustrate the performance benefits of the trie, consider the following example in which you need to find the words with the prefix CU.

First, you travel to the node containing C. That quickly excludes other branches of the trie from the search operation:

Next, you need to find the words that have the next letter U. You traverse to the U node:

Since that’s the end of your prefix, the trie would return all collections formed by the chain of nodes from the U node. In this case, the words CUT and CUTE would be returned. Imagine if this trie contained hundreds of thousands of words.

The number of comparisons you can avoid by employing a trie is substantial.

Implementation

As always, open up the starter playground for this chapter.

TrieNode

You’ll begin by creating the node for the trie. In the Sources directory, create a new file named TrieNode.swift. Add the following to the file:

public class TrieNode<Key: Hashable> {

  // 1
  public var key: Key?
  
  // 2
  public weak var parent: TrieNode?
  
  // 3
  public var children: [Key: TrieNode] = [:]

  // 4
  public var isTerminating = false

  public init(key: Key?, parent: TrieNode?) {
    self.key = key
    self.parent = parent
  }
}

This interface is slightly different compared to the other nodes you’ve encountered:

  1. key holds the data for the node. This is optional because the root node of the trie has no key.
  2. A TrieNode holds a weak reference to its parent. This reference simplifies the remove method later on.
  3. In binary search trees, nodes have a left and right child. In a trie, a node needs to hold multiple different elements. You’ve declared a children dictionary to help with that.
  4. As discussed earlier, isTerminating acts as an indicator for the end of a collection.

Trie

Next, you’ll create the trie itself, which will manage the nodes. In the Sources folder, create a new file named Trie.swift. Add the following to the file:

public class Trie<CollectionType: Collection>
    where CollectionType.Element: Hashable {
  
  public typealias Node = TrieNode<CollectionType.Element>
  
  private let root = Node(key: nil, parent: nil)
  
  public init() {}
}

The Trie class is built for all types that adopt the Collection protocol, including String. In addition to this requirement, each element inside the collection must be Hashable. This is required because you’ll use the collection’s elements as keys for the children dictionary in TrieNode.

Next, you’ll implement four operations for the trie: insert, contains, remove and a prefix match.

Insert

Tries work with any type that conforms to Collection. The trie will take the collection and represent it as a series of nodes in which each node maps to an element in the collection.

Add the following method to Trie:

public func insert(_ collection: CollectionType) {
  // 1
  var current = root
  
  // 2
  for element in collection {
    if current.children[element] == nil {
      current.children[element] = Node(key: element, parent: current)
    }
    current = current.children[element]!
  }
  
  // 3
  current.isTerminating = true
}

Here’s what’s going on:

  1. current keeps track of your traversal progress, which starts with the root node.
  2. A trie stores each element of a collection in separate nodes. For each element of the collection, you first check if the node currently exists in the children dictionary. If it doesn’t, you create a new node. During each loop, you move current to the next node.
  3. After iterating through the for loop, current should be referencing the node representing the end of the collection. You mark that node as the terminating node.

The time complexity for this algorithm is O(k), where k is the number of elements in the collection you’re trying to insert. This is because you need to traverse through or create each node that represents each element of the new collection.

Contains

contains is very similar to insert. Add the following method to Trie:

public func contains(_ collection: CollectionType) -> Bool {
  var current = root
  for element in collection {
    guard let child = current.children[element] else {
      return false
    }
    current = child
  }
  return current.isTerminating
}

Here, you traverse the trie in a way similar to insert. You check every element of the collection to see if it’s in the tree. When you reach the last element of the collection, it must be a terminating element. If not, the collection was not added to the tree and what you’ve found is merely a subset of a larger collection.

The time complexity of contains is O(k), where k is the number of elements in the collection that you’re looking for. This is because you need to traverse through k nodes to find out whether or not the collection is in the trie.

To test out insert and contains, navigate to the playground page and add the following code:

example(of: "insert and contains") {
  let trie = Trie<String>()
  trie.insert("cute")
  if trie.contains("cute") {
    print("cute is in the trie")
  }
}

You should see the following console output:

---Example of: insert and contains---
cute is in the trie

Remove

Removing a node in the trie is a bit more tricky. You need to be particularly careful when removing each node, since nodes can be shared between two different collections.

Write the following method just below contains:

public func remove(_ collection: CollectionType) {
  // 1
  var current = root
  for element in collection {
    guard let child = current.children[element] else {
      return
    }
    current = child
  }
  guard current.isTerminating else {
    return
  }
  // 2
  current.isTerminating = false
  // 3
  while let parent = current.parent,
        current.children.isEmpty && !current.isTerminating {
      parent.children[current.key!] = nil
      current = parent
  }
}

Taking it comment-by-comment:

  1. This part should look familiar, as it’s basically the implementation of contains. You use it here to check if the collection is part of the trie and to point current to the last node of the collection.

  2. You set isTerminating to false so the current node can be removed by the loop in the next step.

  3. This is the tricky part. Since nodes can be shared, you don’t want to carelessly remove elements that belong to another collection. If there are no other children in the current node, it means that other collections do not depend on the current node.

    You also check to see if the current node is a terminating node. If it is, then it belongs to another collection. As long as current satisfies these conditions, you continually backtrack through the parent property and remove the nodes.

The time complexity of this algorithm is O(k), where k represents the number of elements of the collection that you’re trying to remove.

Head back to the playground page and add the following to the bottom:

example(of: "remove") {
  let trie = Trie<String>()
  trie.insert("cut")
  trie.insert("cute")
  
  print("\n*** Before removing ***")
  assert(trie.contains("cut"))
  print("\"cut\" is in the trie")
  assert(trie.contains("cute"))
  print("\"cute\" is in the trie")
  
  print("\n*** After removing cut ***")
  trie.remove("cut")
  assert(!trie.contains("cut"))
  assert(trie.contains("cute"))
  print("\"cute\" is still in the trie")
}

You should see the following output added to the console:

---Example of: remove---

*** Before removing ***
"cut" is in the trie
"cute" is in the trie

*** After removing cut ***
"cute" is still in the trie

Prefix matching

The most iconic algorithm for the trie is the prefix-matching algorithm. Write the following at the bottom of Trie.swift:

public extension Trie where CollectionType: RangeReplaceableCollection {
  
}

Your prefix-matching algorithm will sit inside this extension, where CollectionType is constrained to RangeReplaceableCollection. This is required because the algorithm will need access to the append method of RangeReplaceableCollection types.

Next, add the following method inside the extension:

func collections(startingWith prefix: CollectionType) -> [CollectionType] {
  // 1
  var current = root
  for element in prefix {
    guard let child = current.children[element] else {
      return []
    }
    current = child
  }
  
  // 2
  return collections(startingWith: prefix, after: current)
}
  1. You start by verifying that the trie contains the prefix. If not, you return an empty array.
  2. After you’ve found the node that marks the end of the prefix, you call a recursive helper method collections(startingWith:after:) to find all the sequences after the current node.

Next, add the code for the helper method:

private func collections(startingWith prefix: CollectionType,
                         after node: Node) -> [CollectionType] {
  // 1
  var results: [CollectionType] = []
  
  if node.isTerminating {
    results.append(prefix)
  }
  
  // 2
  for child in node.children.values {
    var prefix = prefix
    prefix.append(child.key!)
    results.append(contentsOf: collections(startingWith: prefix,
                                           after: child))
  }
  
  return results
}
  1. You create an array to hold the results. If the current node is a terminating node, you add it to the results.
  2. Next, you need to check the current node’s children. For every child node, you recursively call collections(startingWith:after:) to seek out other terminating nodes.

collection(startingWith:) has a time complexity of O(k*m), where k represents the longest collection matching the prefix and m represents the number of collections that match the prefix.

Recall that arrays have a time complexity of O(k*n), where n is the number of elements in the collection.

For large sets of data in which each collection is uniformly distributed, tries have far better performance as compared to using arrays for prefix matching.

Time to take the method for a spin. Navigate back to the playground page and add the following:

example(of: "prefix matching") {
  let trie = Trie<String>()
  trie.insert("car")
  trie.insert("card")
  trie.insert("care")
  trie.insert("cared")
  trie.insert("cars")
  trie.insert("carbs")
  trie.insert("carapace")
  trie.insert("cargo")

  print("\nCollections starting with \"car\"")
  let prefixedWithCar = trie.collections(startingWith: "car")
  print(prefixedWithCar)

  print("\nCollections starting with \"care\"")
  let prefixedWithCare = trie.collections(startingWith: "care")
  print(prefixedWithCare)
}

You should see the following output in the console:

---Example of: prefix matching---

Collections starting with "car"
["car", "carbs", "care", "cared", "cars", "carapace", "cargo", "card"]

Collections starting with "care"
["care", "cared"]

Key points

  • Tries provide great performance metrics in regards to prefix matching.
  • Tries are relatively memory efficient since individual nodes can be shared between many different values. For example, “car,” “carbs,” and “care” can share the first three letters of the word.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.