You can also find all 35 answers here π Devinterview.io - Backtracking Algorithms
Backtracking is an algorithmic technique that uses a depth-first search approach to systematically build candidates for solutions. Each potential solution is represented as nodes in a tree structure.
If a particular pathway does not lead to a valid solution, the algorithm reverts or "backtracks" to a previous state. This strategy ensures a thorough exploration of the solution space by methodically traversing each branch of the tree.
-
Sudoku Solvers: Algorithms employ backtracking to determine valid number placements on the grid according to the game's rules.
-
Boggle Word Finders: Systems utilize backtracking to identify all valid words from a grid of letters in the Boggle game.
-
Network Router Configuration: Optimal configurations in complex networks, like routes and bandwidth allocations, are determined using backtracking.
-
University Timetable Scheduling: Backtracking aids in efficiently scheduling university courses, minimizing overlaps and optimizing resource usage.
-
Interactive Storytelling in VR: In virtual reality games, backtracking navigates and selects optimal story paths based on user decisions, ensuring a cohesive narrative.
Place
Here is the Python code:
def is_valid(board, row, col):
for i in range(row):
if board[i] in [col, col - (row - i), col + (row - i)]:
return False
return True
def place_queen(board, row):
n = len(board)
if row == n:
return True
for col in range(n):
if is_valid(board, row, col):
board[row] = col
if place_queen(board, row + 1):
return True
board[row] = -1 # Backtrack
return False
def solve_n_queens(n):
board = [-1] * n
if place_queen(board, 0):
print("Solution exists:")
print(board)
else:
print("No solution exists.")
solve_n_queens(4)
The is_valid
function evaluates queen placement validity, while place_queen
recursively attempts to place all
Backtracking and brute force algorithms both explore all potential solutions but differ in their methodology, efficiency, and the application-specific constraints they leverage.
- Backtracking: This method is concerned with exploring a decision tree to locate a satisfactory solution following a trial-and-error approach.
- Brute Force: It suggests exhaustive evaluation of all possible solutions according to a defined problem space.
- Backtracking: Designed to prune the solution space, it often delivers improved efficiency, especially for large problem instances.
- Brute Force: May evaluate an excessive number of possibilities and can be impractical for complex problems.
- Backtracking: Employs forward and backward techniques for more focused search, tracking both immediate and future consequences to make discerning decisions.
- Brute Force: Offers no tools for trimming the solution space other than examining every solution within the defined space.
- Backtracking: Typically seeks a single, best solution. In some scenarios, it can be adapted to produce all solutions of interest.
- Brute Force: Obliges a complete assay of all conceivable solutions, offering details on the whole spectrum of possibilities, which could be beneficial in specific contexts.
Decision trees are a fundamental component of backtracking algorithms. These trees visually represent the sequences of decisions and steps taken during backtracking to achieve a solution.
- Nodes: Responsible for encapsulating elements of a data set. They contain references, often referred to as branches, leading to other nodes.
- Edges: These are the references or pathways between nodes that help define direction.
-
Data Organization: Decision trees proficiently organize discrete states and their evolution, essential for problems with many possible configurations, such as the Knight's Tour or the N-Queens problem.
-
State Management: Each node in the tree serves to encapsulate a unique state of the problem. For backtracking, the algorithm continually traverses the tree, updating the current problem state, moving to a child state, and, if necessary, returning to a parent state.
-
Bounding and Limitations: The problem landscape often entails constraints, resource limits, or goals to achieve. These aspects are effectively integrated within the tree, allowing backtracking algorithms to carefully manage problem spaces.
-
Solution Identification: When the algorithm successfully identifies a "leaf node" or a node without children, thereby indicating a specific problem solution or a path leading to it, the solution can be extracted and validated.
To better understand this concept, let's look at its practical application in classic problems such as the N-Queens and Maze-solving.
Backtracking algorithms can be improved through various strategies.
In many problems, you can eliminate certain paths or subtrees because they are known to be unproductive. This strategy is known as pruning.
A classic illustration is the N-Queens problem. If two queens threaten each other, there's no point in placing any more queens in the same row. Therefore, you can prune the entire subtree corresponding to that row.
-
Code Example: N-Queens
def is_safe(board, row, col): for i in range(row): if board[i] == col or abs(board[i] - col) == row - i: return False return True def solve_n_queens(board, row=0): if row == len(board): print(board) return for col in range(len(board)): if is_safe(board, row, col): board[row] = col solve_n_queens(board, row+1)
Here,
is_safe
checks for safety, and the recursive functionsolve_n_queens
prunes based on the result ofis_safe
.
Heuristics aid in making choices that are more likely to lead to a solution. In other words, they help focus the search in promising directions. Additionally, problem reduction techniques simplify problems into a smaller form for which you can find partial solutions.
In the Sudoku game, for instance, one heuristic is to start with cells that have fewer available choices. This reduces the branching factor and, potentially, the search space, leading to faster solutions.
-
Code Example: Sudoku with Minimum Remaining Values (MRV)
def find_empty_location(grid, l): for row in range(9): for col in range(9): if grid[row][col] == 0 and len(l[row][col]) == min(len(choice) for choice in l): return row, col return -1, -1 def solve_sudoku(grid, l, row=0, col=0): row, col = find_empty_location(grid, l) if row == -1: print_solution(grid) return True for val in l[row][col]: if is_safe(grid, row, col, val): grid[row][col] = val if solve_sudoku(grid, l): return True grid[row][col] = 0 return False
In this code,
l
is a list of options for each empty cell, andfind_empty_location
uses this for quick selections.
Similarly for Problem Reduction, if you can identify a problem as a specific instance of a known type or category, there might be tailored ways to solve it. An example of this is transforming a general Graph Coloring problem into an Interval Graph Coloring problem which is known to have a linear time solution.
Several tasks in backtracking can be independently executed. Whenever such opportunities arise, parallelize those tasks. This can lead to substantial speed benefits on multi-core CPUs or when distributed among multiple systems.
For instance, in a problem like the Travelling Salesman Problem, each city permutation could be calculated simultaneously or on separate cores, speeding up the process.
This is achieved in code through tools for parallelism such as multi-threading or distributed computing.
Sometimes, while traversing and backtracking, you might revisit the same state or configuration multiple times. You can cache the result for such visited configurations to eliminate unnecessary re-computation. This technique is often referred to as "memoization".
In the All-Pairs Shortest Path problem, memoization allows us to calculate shortest paths between all pairs of vertices just once and reuse these values every time a query is made.
Memos can be set up using various data structures like dictionaries in Python, where a unique configuration can serve as the key and the value can be the associated result so that future recomputation is unnecessary.
-
Code Example: Shortest Path with Memoization
from functools import wraps def memoize(func): memo = {} @wraps(func) def memoizer(*args): if args not in memo: memo[args] = func(*args) return memo[args] return memoizer @memoize def shortest_path(graph, k, i, j): return min(graph[i][j], graph[i][k] + graph[k][j])
Here,
memoize
is a decorator that caches the shortest path as calculated by theshortest_path
function.
Backtracking and Divide-and-Conquer are both strategies in algorithm design, but they operate in distinct ways.
In Backtracking, algorithms make decisions at each step and can retract them.
In Divide-and-Conquer, there are no such decisions; the algorithm follows a consistent divide-and-split process.
Backtracking is often used for problems with a large and varied solution space, exploring many possibilities exhaustively.
In contrast, Divide-and-Conquer is typically more efficient and aims for a focused solution.
Backtracking is especially suited to optimization problems and those requiring combinatorial search, where both an optimal solution and its path need to be determined.
Divide-and-Conquer is useful for problems that can be broken down into independent, smaller sub-problems, like many sorting and searching tasks.
State-space trees provide a visual representation of the problem-solving process in backtracking algorithms. These trees help in both explaining and implementing the backtracking approach.
-
Nodes: Each node corresponds to a particular state or decision in the solution process.
-
Edges: Directed edges connect nodes, depicting transitions or choices made between states.
-
Leaves: Terminal nodes without children represent completed or failed solutions.
The TSP creates a state-space tree where each node represents a unique city ordering.
With graph coloring, every node in the state-space tree represents a potential color assignment for a vertex.
Here is the Java code:
import java.util.Stack;
public class TSPStateSpaceTree {
public static class StateNode {
int cityIndex;
int level;
StateNode(int cityIndex, int level) {
this.cityIndex = cityIndex;
this.level = level;
}
}
public static void main(String[] args) {
int[][] adjacencyMatrix = {
{0, 10, 15, 20},
{10, 0, 35, 25},
{15, 35, 0, 30},
{20, 25, 30, 0}
};
int n = adjacencyMatrix.length;
Stack<StateNode> stack = new Stack<>();
stack.push(new StateNode(0, 0));
}
private static boolean isValid(int[][] adjacencyMatrix, int n, Stack<StateNode> stack, int cityIndex) {
// To be filled by the learner if needed.
return true;
}
private static int calculateBound(int[][] adjacencyMatrix, int n, Stack<StateNode> stack) {
// To be filled by the learner if needed.
return 0;
}
}
The main
method initializes the TSP problem using an adjacency matrix and a stack for DFS traversal of the state-space tree. The methods isValid
and calculateBound
need to be implemented to define the TSP problem's constraints and objective function.
Constraint satisfaction is a fundamental aspect of backtracking algorithms which play a critical role in various problem-solving tasks, especially in combinatorial optimization.
Constraint Satisfaction Problems (CSPs) are tasks where you aim to find a combination of values for a defined set of variables that both satisfy the problem's constraints and, if applicable, optimize defined goals.
-
Variable: An entity that requires a value from a specified domain. These could be discrete or continuous.
-
Domain: The set of potential values that a variable can have.
-
Constraint: The rule or relation that links one or more variables, placing restrictions on their possible assignments.
-
Solution: An assignment of values to variables that complies with all constraints.
Backtracking algorithms adopt a depth-first search strategy to explore and discover combinations of variable assignments. They validate the assignments found against the given constraints. Whenever any constraint is violated, it backtracks to the previous variable and explores the next option.
function backtrack(assignment):
if assignment is complete:
return assignment
var = select_unassigned_variable(assignment)
for value in order_domain_values(var, assignment):
if value is consistent with assignment:
assignment = assignment + (var = value)
result = backtrack(assignment)
if result is not None:
return result
assignment = assignment - (var = value)
return null
Here is the Python code:
def is_safe(board, row, col):
# Check if no two queens threaten each other
for i in range(col):
if board[row][i] == 1:
return False
for i, j in zip(range(row, -1, -1), range(col, -1, -1)):
if board[i][j] == 1:
return False
for i, j in zip(range(row, N, 1), range(col, -1, -1)):
if board[i][j] == 1:
return False
return True
def solve_n_queens(board, col):
if col >= N:
return True
for i in range(N):
if is_safe(board, i, col):
board[i][col] = 1
if solve_n_queens(board, col + 1):
return True
board[i][col] = 0
return False
N = 8
chess_board = [[0] * N for _ in range(N)]
if solve_n_queens(chess_board, 0):
for row in chess_board:
print(row)
else:
print("No solution exists.")
Although "iterative backtracking" might seem like an oxymoron given that backtracking itself is a recurring process, it is possible to manage the backtracking implementation through a purposeful stack management framework.
-
Backtracking: A trial-and-error approach that aims to build a solution incrementally. Upon encountering a dead-end or failing a constraint, the algorithm backtracks to the most recent decision point, potentially altering or undoing previous choices.
-
State-Space Tree: Visual representation of the entire problem space, with each node marking a feasible state of the problem.
-
Decision Tree: Serves as a roadmap of choices made and the subsequent consequences.
-
Stack Management: A primary challenge is keeping track of the decision points. The stack needs to reflect the most recent set of decisions and their subsequent effects on the solution.
-
Loops: A well-defined process employing loops at times absolves the need for recursive function calls.
-
Initialize: Set your initial values, like the starting point, and push them onto the stack.
-
Manage Loops for Choices: Use a while loop to repeatedly make choices, pushing relevant nodes onto the stack.
-
Constrain the Search Space: Implement stopping criteria within the while loop to control the search.
-
Implement Backtracking Logic: Inside the loop, handle dead-ends or reach goals, popping the stack accordingly.
-
Handle Results: After reaching the solution or termination, process the stack to retrieve the solution or relevant data.
Here is the Python code:
def iterative_backtracking(graph, start):
stack = [start]
while stack:
node = stack.pop()
process_node(node)
for next_node in get_unvisited_neighbors(node, graph):
stack.append(next_node)
-
Predictable Space Complexity: Handy for large datasets when preserving the call stack in recursive methods is taxing.
-
Locality of Execution: Might be quicker as it doesn't involve function calls which can be costly.
-
Versatility: Some platforms do not support recursion, making iterative methods the only viable option.
- Visual Clarity Impact: The code becomes less readable than its recursive counterpart.
- Build-up and Teardown Overhead: Manual stack management adds an overhead for pushing and popping commands.
Let's have a look at the various considerations involved in choosing candidates at each step during a backtracking algorithm.
-
Initial State: Starting point before the exploration begins.
-
Selecting Candidates: Narrowing down the choices, e.g., using a list of available options.
-
Fulfillment Test: A condition the current path must satisfy to be a valid solution. If not met, the path is abandoned.
-
Termination Test: Identifies when the problem has been solved.
-
Making a Move: Indicates the next step or action to take.
-
Control the Depth of Search: Ensures that the algorithm explores the
Candidates
to an appropriate depth.
This step aims to identify the various options available at each decision point.
-
Enumeration: Where the range of options is finite, predetermined, and small.
-
Generation: Generating options dynamically based on the current state.
-
Ordered: Presenting options in a specific sequence which could exploit problem characteristics (e.g., early stopping).
-
Unordered: Options have no predefined order, making the approach more generic.
def subsets(input_list):
if input_list:
subsets_rec([], sorted(input_list))
else:
print("List is empty.")
def subsets_rec(current, remaining):
if not remaining:
print(current)
return
subsets_rec(current + [remaining[0]], remaining[1:])
subsets_rec(current, remaining[1:])
# Example
subsets([1,2,3])
In the function subsets_rec()
, as remaining
gets smaller, the program dynamically generates more options.
Pruning in the context of backtracking refers to techniques that reduce the search space by intelligently eliminating unpromising candidates.
- Performance: Pruning improves algorithm efficiency by narrowing down the solution space.
- Avoiding Redundancy: It prevents the same subproblem from being solved multiple times.
-
Simple Pruning: Uses clear constraints to reduce the solution space. For example:
- In the N-Queens problem, if two queens are in the same row or column, there's no need to check for diagonal conflicts.
- In the knapsack problem, exploring a node where the current weight exceeds the knapsack capacity is unnecessary.
-
Advanced Pruning:
- Constraint Propagation: Infers additional constraints by considering the implications of earlier choices. This technique is prevalent in constraint satisfaction problems.
- Dynamic Programming with Memoization: Leverages previous solutions to avoid redundant calculations.
-
Heuristic Techniques: These are often used in combination with other methods to guide the search in a particular direction:
- Heuristic Ordering: Prioritizes potential solutions for exploration based on a heuristic function, as seen in A*-search.
- Look-Ahead Methods: Anticipates the future impact of a choiceβcommon in games and optimization problems.
-
Efficiency Measures: Special strategies can be employed to tackle problem-specific inefficiencies:
- Problem Factorization: Breaks down complex problems into easier subproblems for quicker solutions.
- Adaptive Pruning: Adapts the pruning strategy based on evolving information or problem states.
While pruning reduces the solution space's size, which is beneficial, its impact on the overall computational complexity varies. Some backtracking problems, even with pruning, can remain exponentially complex.
For example, the knapsack problem under some configurations can still have an exponential solution space, despite using sophisticated pruning rules to focus the search.
Backtracking often involves exploring all possible solutions to a problem. The process is guided by a set of rules and uses recursion to handle different states. This approach is exhaustive but can be slow.
- Example: Solving a Sudoku puzzle.
Memoization enhances backtracking by storing previously computed results in a data structure like a dictionary, helping to avoid redundant work. This approach, often referred to as Dynamic Programming, speeds up the search process.
- Example: Solving a Sudoku puzzle with memoization.
- Often used in problems with discrete decision points.
- Useful for tasks such as generating all permutations or combinations.
- Tends to be slower in problems with overlapping subproblems.
- Choose: Make a decision at a given point.
- Explore: Move to the next state, often recursively.
- Unchoose: Undo decisions, returning to a previous state.
Here is the Python code:
def backtrack(remaining_choices, path):
if no_more_choices(remaining_choices):
process_solution(path)
return
for choice in remaining_choices:
make_choice(choice)
backtrack(new_remainings(remaining_choices, choice), path + [choice])
unmake_choice(choice)
- Storage: Memoization adds memory storage for the results of subproblems. This storage is then used to avoid redundant work.
- Speed: By avoiding repeated work, memoization speeds up the process for problems with overlapping subproblems.
Backtracking is pivotal in many recursive algorithms as it saves resources by terminating searches that won't lead to a solution. This pruning technique typically involves a depth-first search, prevalent in graph algorithms and optimization problems.
Its adaptability, especially in problem domains represented by trees and graphs, is key to its universal utility.
Consider a maze: reaching a dead end by choosing the wrong path prompts a backtrack. Similarly, in problems like the N-Queens puzzle, placing a queen limits the available spaces for others. Knowing this allows the algorithm to explore only valid configurations.
-
Pruning: By recognizing conditions impervious to solution, unnecessary branches are ignored. In the context of the sum of subsets problem, going beyond the target value is a no-win situation.
-
Heuristics: While exploration techniques like BFS or DFS serve as your foundation, additional insights can streamline your paths. In the knight's tour, tactics like Warnsdorff's rule guide the next move. But fetch quests, exemplified by the travel through a directed graph solely for specific elements, can be instrumental in solutions like the word break problem.
-
Parallelism and Memory: Techniques such as tasking and memoization effectively trim run times, albeit the latter necessitates additional memory.
Backtracking's resource trade-off is time for space. Although beneficial in many scenarios, its exponential time complexity and potential termination quirks necessitate a strategic approach.
- Optimization Opportunities: Backtracking problems, often marked by a distinct set of actions and a state space, are favorable candidates for optimization. Such fine-tuning can restrict the domain of possibilities, minimizing computational load.
- Resource Scalability: The technique's tendency to unearth all plausible solutions may not be the most practical in larger datasets.
Its trial-and-error nature alters how backtracking algorithms are validated and troubleshot.
-
Union of Possibilities: Rather than exclusively one solution, backtracking sometimes renders a set. This factor catalyzes feedback-driven implementation, making correctness checks slightly more intricate.
-
Deterministic Route: Every entry point redirects down identical trails. Its predictableness adds a layer of debug-ability, streamlining the quest for missteps.
Its adaptability, especially in problem domains represented by trees and graphs, is key to its universal utility.
- Circuit Complexity: In examining relationships between NP-complete problems and their subproblems, this mechanism plays a crucial part.
- Interactive Design: From games like chess and puzzles such as Sudoku to user input validation, backtracking offers versatile influence.
- Artificial Intelligence: Certain AI methodologies like rule-based systems, with their dependency on inference engines, are reliant upon backtracking.
-
Graph Traversal: From exploring a host of nodes in a unique graph to traveling specific routes, backtracking aids in a multitude of contexts.
-
Tree Search: The cohabitation of backtracking and trees is so entrenched that their combined phrase describes a legion of problems: "tree traversal."
These explicit and implicit links, along with the symbiotic interplay between trees and backtracking in the king-making world of algorithms, instill a sense of verification and efficiency.
Variable ordering techniques play a crucial role in the efficiency of backtracking algorithms. They manage the selection order of variables from the decision space, influencing the search strategy and the algorithm's performance.
- Time Complexity: Proper ordering can lead to earlier detection of infeasible solutions, which reduces the need for extensive exploratory search.
- Space Complexity: It can help minimize the number of nodes in the search tree, leading to better memory usage.
-
Most Constrained Variable (MCV): Select the variable with the fewest remaining values. It's effective in reducing the search space, especially in domains where constraint tightness varies.
-
Least Constraining Value (LCV): This strategy prioritizes values that constrain other variables the least. It's useful in scenarios with complex, interdependent constraints.
-
Minimum Remaining Values (MRV): Select the variable that is likely to cause a "dead-end," i.e., one that has the fewest remaining legal values. This choice often leads to quick refinements.
-
Maximum Remaining Values (MaxRV): This is the opposite of MRV and selects the variable with the most remaining legal values. While it can offer some advantages, its utility is often limited compared to MRV.
Here is the Python code:
def mcv_ordering(variables, assignment, current_domain):
return min(variables, key=lambda var: len(current_domain[var]))
def lcv_ordering(values, variable, assignment, constraints):
return sorted(values, key=lambda val: count_conflicts(variable, val, assignment, constraints))
Backtracking algorithms aim to find a solution incrementally; if the current path doesn't lead to a solution, the algorithm backtracks.
-
Worst-Case: Backtracking explores all possible paths. If each decision point has
$b$ choices and the problem's size is$n$ , the worst-case time complexity is often$O(b^n)$ . - Average-Case: This is difficult to quantify precisely and can vary greatly between different backtracking problems.
- It depends on the depth of the recursive calling structure.
- If the recursive stack can be as deep as your data, the space complexity is often
$O(n)$ . However, this can be improved in some cases through techniques like iterative deepening or tail recursion.
Here is the Python code:
def generate_ranges(minimum, maximum, current=None):
if not current:
current = []
if sum(current) == maximum:
print(current)
return
for i in range(minimum, maximum + 1):
current.append(i)
generate_ranges(minimum, maximum, current)
current.pop()
While backtracking is a powerful algorithmic tool, its nature entails some worst-case scenario limitations. Let's explore these, and look at how they compare to other algorithms.
-
Depth-First Search: During backtracking, the algorithm often behaves like a depth-first search, potentially exploring a substantial solution space before hitting a dead end. This may lead to time and memory inefficiencies, especially in worst-case scenarios where the entire solution space must be explored.
-
State Space Explosion: In its quest to find a solution, the backtracking algorithm builds and evaluates numerous state or decision trees, leading to an exponential worst-case time complexity. This rapid growth makes certain problems infeasible for brute-force solving.
-
Heuristic Inefficiencies: The effectiveness of backtracking in problems relies heavily on the "goodness" of its heuristic. Should the heuristic fail to accurately guide the search, the algorithm devolves into a brute-force strategy, slowing down considerably as the problem size increases. This can be attributed to the worst-case scenario where the search space is so unpredictable that a heuristic cannot serve its intended purpose.
-
Time Complexity: For backtracking, which is often structured top-down and depth-first, the time complexity can be exponential. This stems from the potential need to explore all leaves of a state or decision tree.
For instance, the time complexity can be$O(2^n)$ in cases where all subsets of a set need to be enumerated. - Space Complexity: When comparing backtracking to algorithms like Dijkstra's Shortest Path algorithm, the former often lag in terms of space efficiency. Backtracking algorithms might necessitate an entire tree's worth of space in the worst-case scenario, whereas algorithms like Dijkstra's remain more space-conservative.
- Solution Completeness: Despite its potential for state space explosion, backtracking guarantees the discovery of all possible solutions. Consequently, it's often the approach of choice when such completeness is mandatory.
- Memory Requirements: Backtracking, especially in its pure, recursive form, can have severe memory considerations, particularly when exploring deep branches in a state or decision tree.
Here is the Python code:
def backtrack_solution(nums):
def backtrack(start=0, curr=[]):
result.append(curr[:])
for i in range(start, len(nums)):
curr.append(nums[i])
backtrack(i + 1, curr)
curr.pop()
result = []
backtrack()
return result