iosNudge

Strings

Chapter 1 of 6 3 min read

A String is a collection of Characters

A Swift String is Unicode-correct. Each Character is what a human sees as one letter. Inside, one character can be several Unicode scalars (code points).

let a = "é"             // one scalar
let b = "e\u{301}"      // "e" + a combining accent: two scalars
print(a == b)           // true: Swift compares what you see
print(b.count)          // 1 character
print(b.unicodeScalars.count)   // 2 scalars
print("café".utf8.count)        // 5 bytes: "é" takes 2 bytes in UTF-8

Because characters have different sizes, Swift cannot jump to "character number 5" in O(1). So there is no integer subscript on String.

Indexing with String.Index

let s = "swift"
// s[1]                                  // error: no Int subscript
let i = s.index(s.startIndex, offsetBy: 1)
print(s[i])                              // "w"
print(s.first!, s.last!)                 // "s" "t"

let tail = s[s.index(after: s.startIndex)...]   // "wift", a Substring
let head = s.prefix(3)                          // "swi", a Substring

index(_:offsetBy:) walks from the start. It is O(k). Inside a loop, this becomes O(n²).

The interview move: convert to an array once

Convert the string to [Character] at the start. Now every access is O(1).

func isPalindrome(_ s: String) -> Bool {
    let chars = Array(s)          // O(n) once
    var left = 0
    var right = chars.count - 1
    while left < right {
        if chars[left] != chars[right] { return false }
        left += 1
        right -= 1
    }
    return true
}

print(isPalindrome("racecar"))   // true
print(isPalindrome(""))          // true

Say it out loud: "Swift strings have no integer indexing, so I convert to an array first. That costs O(n) extra space."

Bytes for ASCII input

If the input is only ASCII (for example, lowercase English letters), use the UTF-8 bytes. A [UInt8] is smaller and faster than [Character].

let bytes = Array("hello".utf8)     // [104, 101, 108, 108, 111]
let a = UInt8(ascii: "a")           // 97
print(Int(bytes[1] - a))            // 4: "e" is letter number 4 (counting from 0)
print(String(decoding: bytes, as: UTF8.self))   // "hello"

Ask the interviewer: "Is the input only lowercase English letters?" The answer changes your data structure.

Back to a String

let chars: [Character] = ["s", "w", "i", "f", "t"]
let word = String(chars)                    // "swift"
let reversedWord = String(word.reversed())  // "tfiws"
let fromSub = String(word.prefix(2))        // "sw": Substring to String

Character helpers

let c: Character = "A"
print(c.isLetter)              // true
print(c.isUppercase)           // true
print(c.isNumber)              // false
print(c.isWhitespace)          // false
print(c.lowercased())          // "a" (this is a String, not a Character)
print(c.asciiValue!)           // 65
print(Character("7").wholeNumberValue!)   // 7

Careful: isNumber is also true for symbols like "½". For digits, use wholeNumberValue or c.isASCII && c.isNumber.

Converting between letters and numbers:

func letterIndex(_ c: Character) -> Int {
    Int(c.asciiValue! - Character("a").asciiValue!)   // "a" -> 0, "z" -> 25
}

func letter(at index: Int) -> Character {
    Character(UnicodeScalar(UInt8(97 + index)))       // 0 -> "a"
}

print(letterIndex("d"))   // 3
print(letter(at: 3))      // "d"

Everyday operations

Most coding sites let you import Foundation. Some methods below come from it.

import Foundation

let text = "  Hello, World  "
print(text.trimmingCharacters(in: .whitespaces))   // "Hello, World"
print(text.lowercased())                           // "  hello, world  "
print(text.contains("World"))                      // true
print(text.hasPrefix("  He"))                      // true
print(text.replacingOccurrences(of: "l", with: "L"))   // "  HeLLo, WorLd  "

let words = "a quick  brown fox".split(separator: " ")
print(words)                     // ["a", "quick", "brown", "fox"]: empty parts are dropped

let parts = "a,b,,c".split(separator: ",", omittingEmptySubsequences: false)
print(parts)                     // ["a", "b", "", "c"]

print(["a", "b", "c"].joined(separator: "-"))   // "a-b-c"

Numbers and strings:

print(Int("42") as Any)            // Optional(42)
print(Int("4x") as Any)            // nil
print(String(42))                  // "42"
print(String(5, radix: 2))         // "101"
print(Int("101", radix: 2)!)       // 5
print(String(repeating: "ab", count: 3))   // "ababab"

Substring

split, prefix, suffix and ranges return a Substring. A substring shares memory with the original string, so it is cheap to make.

let sentence = "hello world"
let firstWord = sentence.split(separator: " ")[0]   // Substring
let saved: String = String(firstWord)                // copy it before you store it
var seen: Set<String> = []
seen.insert(saved)                                   // a Set<String> needs a String

Convert to String when you store it, return it, or use it as a dictionary key.

Counting characters

A dictionary works for any characters:

func isAnagram(_ s: String, _ t: String) -> Bool {
    guard s.count == t.count else { return false }
    var counts: [Character: Int] = [:]
    for c in s { counts[c, default: 0] += 1 }
    for c in t {
        counts[c, default: 0] -= 1
        if counts[c]! < 0 { return false }
    }
    return true
}

print(isAnagram("listen", "silent"))   // true

For lowercase English letters, a fixed array of 26 is faster:

func letterCounts(_ s: String) -> [Int] {
    var counts = Array(repeating: 0, count: 26)
    let a = Int(UInt8(ascii: "a"))
    for byte in s.utf8 {
        counts[Int(byte) - a] += 1
    }
    return counts
}

print(letterCounts("eat") == letterCounts("tea"))   // true

[Int] is Hashable, so this array can be a dictionary key. That is the trick behind "group anagrams".

Building a string

Use += or append. Both change the string in place.

var result = ""
for word in ["swift", "is", "fun"] {
    if !result.isEmpty { result += " " }
    result += word
}
print(result)   // "swift is fun"

When you change characters in the middle, work on a [Character] and convert back at the end:

func reverseVowels(_ s: String) -> String {
    var chars = Array(s)
    let vowels: Set<Character> = ["a", "e", "i", "o", "u", "A", "E", "I", "O", "U"]
    var left = 0
    var right = chars.count - 1
    while left < right {
        if !vowels.contains(chars[left]) { left += 1; continue }
        if !vowels.contains(chars[right]) { right -= 1; continue }
        chars.swapAt(left, right)
        left += 1
        right -= 1
    }
    return String(chars)
}

print(reverseVowels("hello"))   // "holle"

To reorder whole words, split, reorder and join:

func reverseWords(_ s: String) -> String {
    s.split(separator: " ").reversed().joined(separator: " ")
}

print(reverseWords("the sky  is blue"))   // "blue is sky the"

Performance traps

TrapWhy it is slowFix
s.count inside a loopcount walks the whole string: O(n)Save it once, or use the array's count
s.index(s.startIndex, offsetBy: i) in a loopO(i) each time, O(n²) totalArray(s) before the loop
Array(s) inside a loopCopies the whole string every timeDo it once, before the loop
s = s + x in a loopBuilds a new string each time: O(n²)s += x or s.append(x)
String(...) of many substrings in recursionEach one copiesPass start and end indices instead
Character work on big ASCII inputUnicode rules cost timeUse s.utf8 bytes

The same task, slow and fast:

// Slow: O(n^2), because of index(offsetBy:) inside the loop
func countVowelsSlow(_ s: String) -> Int {
    var total = 0
    for i in 0..<s.count {
        let c = s[s.index(s.startIndex, offsetBy: i)]
        if "aeiou".contains(c) { total += 1 }
    }
    return total
}

// Fast: O(n), one pass with a Set lookup
func countVowels(_ s: String) -> Int {
    let vowels: Set<Character> = ["a", "e", "i", "o", "u"]
    return s.reduce(0) { $0 + (vowels.contains($1) ? 1 : 0) }
}

print(countVowels("interview"))   // 4

Interview checklist

  • Convert to [Character] (or [UInt8] for ASCII) once, at the start.
  • Ask about the character set: ASCII only, or any Unicode?
  • c.lowercased() returns a String. Compare with c.lowercased() == d.lowercased().
  • split drops empty parts by default. Use omittingEmptySubsequences: false if you need them.
  • Convert a Substring to String before you store it.