Essentials of Algorithm Design: A Comprehensive Guide

Harrison Cho
7 min readDec 6, 2023

--

Basic Components of an Algorithm

  1. Input: An algorithm can receive zero or more inputs. These inputs are the data that need to be processed.
  2. Output: At least one output is produced. This output is the solution to the problem.
  3. Clearness: Each step must be clear and unambiguous.
  4. Finiteness: An algorithm must terminate after a finite number of steps.
  5. Effectiveness: Every operation should be basic enough to be feasible.

Important Characteristics of an Algorithm

  1. Correctness: The algorithm must produce accurate results.
  2. Efficiency: Resources (such as time and memory) should be used minimally.
  3. Independence: The algorithm should be an independent problem-solving method, not reliant on any specific programming language or machine.

Performance Evaluation of an Algorithm

Time Complexity

Time complexity is a crucial metric used to evaluate the performance of an algorithm, indicating how the time required for execution varies with the input size. It is expressed using Big O notation, representing the worst-case time complexity.

  1. O(1) — Constant Time: The execution time remains constant regardless of input size. For example, accessing an element at a specific index in an array.
  2. O(log N) — Logarithmic Time: The execution time increases logarithmically with the input size. Binary search is a typical example.
  3. O(N) — Linear Time: The execution time increases linearly with the input size. For example, traversing an array or performing linear search.
  4. O(N log N): Many efficient sorting algorithms fall under this complexity. Merge sort and quicksort are examples.
  5. O(N²) — Quadratic Time: The execution time increases quadratically with the input size. Using nested loops is a typical example.
  6. O(2^N) — Exponential Time: The execution time grows exponentially with the input size. Generating all subsets recursively is an example.
  7. O(N!) — Factorial Time: The execution time increases factorially with the input size. Complete search algorithms fall under this category.

Understanding and considering time complexity is crucial for designing and optimizing algorithms, especially as the input size increases, significantly impacting algorithm performance.

Space Complexity

Space complexity is a metric that indicates the amount of memory space required to execute an algorithm, used to evaluate the algorithm’s memory usage. This is an important aspect of algorithm efficiency, especially in environments with limited memory resources.

  1. O(1) — Constant Space: The memory space required remains constant regardless of input size. For example, simple calculations using a few variables.
  2. O(N) — Linear Space: The memory space required increases linearly with the input size. For example, creating an additional array of the same size as the input array.
  3. O(N²) — Quadratic Space: The memory space required increases quadratically with the input size. Using a 2D array of size N x N is an example.

Optimizing space complexity is important for reducing memory usage and enhancing program efficiency, especially when processing large data sets or in systems with limited memory. When designing algorithms, it’s important to balance memory usage and execution time, sometimes trading off a bit more memory for improved time complexity or vice versa.

Algorithm Design Techniques

Divide and Conquer

Divide and Conquer is a strategy for solving complex problems by breaking them down into more manageable sub-problems. This approach can be divided into three stages: Divide, Conquer, and Combine.

  1. Divide: The original problem is divided into smaller sub-problems, which are smaller versions of the original problem.
  2. Conquer: The sub-problems are solved recursively. When they are small enough to be managed directly, they are solved.
  3. Combine: The solutions to the sub-problems are combined to form the solution to the original problem.

Example of Divide and Conquer: Merge Sort

func mergeSort(_ array: [Int]) -> [Int] {
// If the array size is 1 or less, it's already sorted
guard array.count > 1 else { return array }

// Split the array in half
let middleIndex = array.count / 2
let leftArray = mergeSort(Array(array[..<middleIndex]))
let rightArray = mergeSort(Array(array[middleIndex...]))

// Merge the divided arrays
return merge(leftArray, rightArray)
}

func merge(_ left: [Int], _ right: [Int]) -> [Int] {
var leftIndex = 0
var rightIndex = 0
var result = [Int]()

// Compare elements of both arrays and add the smaller one to the result
while leftIndex < left.count && rightIndex < right.count {
if left[leftIndex] < right[rightIndex] {
result.append(left[leftIndex])
leftIndex += 1
} else {
result.append(right[rightIndex])
rightIndex += 1
}
}

// Add remaining elements to the result
if leftIndex < left.count {
result.append(contentsOf: left[leftIndex...])
}
if rightIndex < right.count {
result.append(contentsOf: right[rightIndex...])
}

return result
}

// Example usage
let unsortedArray = [34, 7, 23, 32, 5, 62]
let sortedArray = mergeSort(unsortedArray)
print(sortedArray) // Output: [5, 7, 23, 32, 34, 62]

Dynamic Programming

Dynamic Programming is a method of solving complex problems by breaking them down into simpler sub-problems. This approach saves and reuses the solutions to sub-problems, efficiently solving the entire problem. Dynamic Programming is often used for optimization problems and has two main approaches: Top-Down and Bottom-Up.

Let’s explore the differences between Top-Down and Bottom-Up with the Fibonacci sequence problem.

  • Top-Down Approach: Uses recursion to break down a large problem into smaller problems and stores the results of sub-problems through Memoization.
// Dictionary for Memoization
var memo = [Int: Int]()

// Function to calculate Fibonacci numbers
func fibonacci(_ n: Int) -> Int {
// Base case: return n if it's 0 or 1
if n <= 1 {
return n
}

// Memoization: return the stored value if already calculated
if let memoizedValue = memo[n] {
return memoizedValue
}

// Recursive call to calculate Fibonacci numbers
memo[n] = fibonacci(n - 1) + fibonacci(n - 2)

// Return the calculated Fibonacci number
return memo[n]!
}

// Example: Calculate the 10th Fibonacci number
let result = fibonacci(10) // Result: 55
print(result)
  • Bottom-Up Approach: Starts with smaller problems and gradually expands to larger problems, storing each step’s results in a table.
func fibonacciBottomUp(_ n: Int) -> Int {
// Return n directly if it's 0 or 1
if n <= 1 {
return n
}

// The first two Fibonacci numbers are 0 and 1
var fib = [0, 1]

// Calculate each Fibonacci number from 2 to n and store in the array
for i in 2...n {
fib.append(fib[i - 1] + fib[i - 2])
}

// Return the nth Fibonacci number
return fib[n]
}

// Example: Calculate the 10th Fibonacci number
let result = fibonacciBottomUp(10) // Result: 55
print(result)

Greedy Algorithm

The Greedy Algorithm makes the locally optimal choice at each step, aiming to find the globally optimal solution. This algorithm operates under the assumption that the best local choices will lead to the best overall solution. Greedy Algorithms are primarily used for optimization problems, where each step’s choice impacts the overall solution.

Features of the Greedy Algorithm

  • Simple and Intuitive Approach: Simplifies the problem by making the best choice at each step.
  • Local Optimization: Each step involves making the optimal choice, but this doesn’t always guarantee the best overall solution.
  • Efficient Computation: Greedy Algorithms often provide faster solutions than other methods.

The change-making problem is a classic example of a problem that can be solved using a Greedy Algorithm. It involves finding the minimum number of coins needed to make change for a given amount of money.

func minCoins(for amount: Int, using denominations: [Int]) -> Int {
let sortedDenominations = denominations.sorted(by: >) // Sort denominations in descending order
var remainingAmount = amount
var coinCount = 0

for coin in sortedDenominations {
// Calculate the maximum number of coins of the current denomination
let numCoins = remainingAmount / coin
remainingAmount -= numCoins * coin
coinCount += numCoins

// Stop if all the amount has been covered
if remainingAmount == 0 {
break
}
}

return coinCount
}

// Example: Find the minimum number of coins needed for 47 units
let coinsNeeded = minCoins(for: 47, using: [1, 5, 10, 25])
print(coinsNeeded) // Result: 5 (1 coin of 25, 2 coins of 10, 2 coins of 1)

Backtracking

Backtracking is a method of solving problems by trying all possible combinations of solutions. This approach typically uses a Decision Tree to explore all possible cases. Backtracking solves problems recursively, and if the current choice is not promising, it backtracks to the previous step to try a different option.

Features of Backtracking

  • Systematic Exploration: Explores all possible solutions systematically.
  • Pruning: Unpromising paths are eliminated early to reduce unnecessary exploration.
  • Recursive Structure: Most backtracking algorithms are implemented recursively.

The N-Queens problem, where N queens must be placed on an N×N chessboard without attacking each other, can be solved using backtracking.

// Function to solve the N-Queens problem
func solveNQueens(_ n: Int) -> [[String]] {
// Initialize the chessboard: all cells set to '.'
var board = Array(repeating: Array(repeating: ".", count: n), count: n)
var solutions = [[String]]() // Array to store solutions

// Call the function to place queens
placeQueens(&board, row: 0, n: n, solutions: &solutions)
return solutions
}

// Recursive function to place queens
func placeQueens(_ board: inout [[String]], row: Int, n: Int, solutions: inout [[String]]) {
// Add the solution to the solutions array if all rows are covered
if row == n {
solutions.append(board.map { $0.joined() })
return
}

// Try placing a queen in each column of the current row
for col in 0..<n {
if isValid(board, row: row, col: col, n: n) {
board[row][col] = "Q" // Place the queen
placeQueens(&board, row: row + 1, n: n, solutions: &solutions) // Move to the next row
board[row][col] = "." // Backtrack: remove the queen
}
}
}

// Function to check if the current position is valid for placing a queen
func isValid(_ board: [[String]], row: Int, col: Int, n: Int) -> Bool {
for i in 0..<row {
// Check for queens in the same column and diagonals
if board[i][col] == "Q" || // Check same column
(col - i - 1 >= 0 && board[row - i - 1][col - i - 1] == "Q") || // Check left diagonal
(col + i + 1 < n && board[row - i - 1][col + i + 1] == "Q") { // Check right diagonal
return false
}
}
return true
}

// Example: Print solutions for the 4-Queens problem
let solutions = solveNQueens(4)
for solution in solutions {
for row in solution {
print(row)
}
print("---")
}

--

--