Here are some of my notes on coding.
- The backtracking algorithm solves the optimization problem by incrementally builds out candidate solutions and backtracks a candidate as soon as we can determine it will not leads to optimal.
- If we represent the partial solutions as a tree, backtracking is doing depth-first search in this tree.
- Since it explores all of the tree in worst case, its upper bounded to have exponential time complexity. It usually need some constraints to prune the tree.
- Very often we would pass both the candidate solution as well as the state of partial solution to the recursive call to speed up checks.
To find one solution
def backtrack(candidate, state): if criterion(candidate, state): return candidate for choice in choices: if not constraints(candidate, choice, state): continue candidate, state = expand_candidate(candidate, choice, state) if backtrack(candidate): return candidate candidate, state = restore_candidate(candidate, choice, state) return solution = backtrack(init_candidate, init_state)
To collect all possible solutions
solutions = set() def backtrack(candidate, state): if criterion(candidate, state): solutions.add(candidate) return for choice in choices: if not constraints(candidate, choice, state): continue backtrack(expand_candidate(candidate, choice, state)) backtrack(init_candidate, init_state)
- The binary search algorithm solves the problem of finding a target value within a sorted array.
- The algorithm iteratively updating the lower and upper bound positions in the array where the target value may resides, by checking the middle element determined by the current bounds.
- The algorithm runs in O(logN) time worst case.
To find a the target value in sorted array.
def binary_search(array, target): low, high = 0, len(array) - 1 while low <= high: mid = (low + high) // 2 if array[mid] == target: return mid elif array[mid] > target: high = mid - 1 else: low = mid + 1 return -1
note at the end of loop, low is the insertion point for x in a to maintain sorted order.
This algorithm can be generalized to find values in bounds that satisfy a criterion, given that the feasible values are collocated as a contiguous range within the bounds, and that the criterion can give us direction to update the bounds. We can return either the left most or the right most value if so desired.
def binary_search(low, high): # based on prior knowledge of bounds. # note here we are assuming a feasible solution exists in the bounds. def criterion(mid): # to be implemented per question while low < high: mid = (low + high) // 2 if criterion(mid): high = mid else: low = mid + 1 return # low for left most low - 1 for right most
python has a standard library bisect for binary search.
Breadth Frist Search
- The breadth first search algorithm traversals tree / graph by exploring all the immediate child nodes / neighboring nodes first before moving to next depth level.
Some dummy criterion and constraints.
def criterion(node, target_val=4): return node and node.val == target_val def constraints(node): return node
Iterative implementation using queue.
def bfs(root): queue = deque([root]) while queue: node = queue.popleft() if criterion(node): return node for next_node in node.childrens: if not constraints(next_node): continue queue.append(next_node) return result = bfs(root)
Depth Frist Search
- The depth first search algorithm traversals tree / graph by exploring the node branch / edges as far as possible before backtrack and explore other alternatives.
Iterative implementation using queue.
def dfs(root): stack = deque([root]) while stack: node = stack.pop() if criterion(node): return node for next_node in node.childrens: if not constraints(next_node): continue stack.append(next_node) return result = dfs(root)
Recursive implementation with a hash map memorizing visited nodes.
visited = set() def constraints(node): return node and node not in visited def dfs(node): visited.add(node) if criterion(node): return node for next_node in node.neighbors: if not constraints(next_node): continue result = dfs(next_node) if result: return result else: return result = dfs(root)
- A Linked List is a recursive data structure that is either empty or a node holds an item and a reference to another linked list.
- Its often useful to keep track of both the head and the tail of a linked list.
Common Operations with Linked Lists
class Node(object): def __init__(self, item=None): self.item = item self.next = None first = Node('head') second = Node('shoulders') third = Node('knees') forth = Node('toes') first.next = second second.next = third third.next = forth # Traversal to the end node = first while node and node.next: node = node.next last = node # Insert at the begining. prev_first = first first = Node('hat') first.next = prev_first # Remove from the begining. first = first.next # Insert at the end. prev_last = last last = Node('shoes') prev_last.next = last
Priority Queue / Heap
- Priority Queue is a collection data structure which supports log(N) time for both insertion of a new element and query the maximum(or minimum) element.
- Heap is a tree in which the parent node is always larger(or smaller) than its children.
- Binary heap a heap with the tree been a binary tree.
- Binary heap queue is binary heap plus the binary tree is complete.
- Binary heap queue is an implementation for priority queue and it is usually stored as an array.
- The key operations for binary heap queue are sink and swim.
- Swim is to move the newly inserted element(in the bottom) up to its rightful location.
- Sink is to move the newly promoted element(at the top) down to its rightful location.
from operator import lt, gt class Heapq(object): def __init__(self, data=None, mode='min'): self.mode = mode self._heapq = [None] + list(data) if data else [None] self.comparator = gt if mode == 'min' else lt self._n = len(self._heapq) - 1 if data: self.heapify() def __len__(self): return self._n def is_empty(self): return self._n == 0 def _compare(self, i, j): return self.comparator(self._heapq[i], self._heapq[j]) def _swap(self, i, j): self._heapq[i], self._heapq[j] = self._heapq[j], self._heapq[i] def top(self): assert not self.is_empty(), 'heapq is empty' return self._heapq def push(self, x): self._heapq.append(x) self._n += 1 self.swim(self._n) def pop(self): assert not self.is_empty(), 'heapq is empty' self._swap(1, self._n) self._n -= 1 self.sink(1) return self._heapq.pop() def swim(self, k): while k > 1 and self._compare(k // 2, k): self._swap(k // 2, k) k //= 2 def sink(self, k, n=None): n = n if n else self._n while k * 2 <= n: j = k * 2 if j < n and self._compare(j, j + 1): j += 1 if not self._compare(k, j): break self._swap(k, j) k = j def heapify(self): for k in range(self._n // 2, 0, -1): self.sink(k)
- A queue is a collection that is First-In-First-Out(FIFO).
An implementation of Queue using linked list.
class Queue(object): class Node(object): def __init__(self, item=None): self.item = item self.next = None def __init__(self): self.head = None self.tail = None self._n = 0 @property def is_empty(self): return not self.head def __len__(self): return self.n def enqueue(self, item): prev_tail = self.tail self.tail = self.Node(item) if self.is_empty: self.head = self.tail else: prev_tail.next = self.tail self._n += 1 def dequeue(self): assert not self.is_empty, "Queue is empty" item = self.head.item self.head = self.head.next self._n -= 1 if self.is_empty: self.last = None return item
Here are implementations of a few array sorting algorithms.
a = list('sortingexample')
1. Selection Sort
- Iteratively put the ith smallest element at ith location from the left.
- We maintain a sorted section from the left.
- In each iteration, we expand the sorted section by swap the smallest element from the unsorted section into the location.
def selection_sort(a): n = len(a) for i in range(n): s = i for j in range(i + 1, n): if a[j] < a[s]: s = j a[i], a[s] = a[s], a[i]
2. Bubble Sort
- Iteratively put the ith largest element to ith location from the end.
- We maintain a sorted seciton from the right.
- In each iteration, we compare adjacent pairs of elements and swap larger values to the right until we reach the sorted part.
def bubble_sort(a): n = len(a) for i in range(1, n): for j in range(n - i): if a[j] > a[j + 1]: a[j + 1], a[j] = a[j], a[j + 1]
3. Insertion Sort
- Iteratively insert new element into the sorted part.
- We maintain a sorted section from the left.
- In each iteration, we sink one new element from the right to the correct location in the sorted part.
def insertion_sort(a): n = len(a) for i in range(n): for j in reversed(range(1, i + 1)): if a[j] < a[j - 1]: a[j - 1], a[j] = a[j], a[j - 1]
4. Merge Sort
- Recursively divide the array into two halves, sort both(recursively) and then merge the two sorted array.
- It follows the Divide-and-Conquer paradigm.
# A quick and dirty non-inplace version def merge_sort(a): def merge(left, right): merged =  while left and right: merged.append(left.pop(0) if left < right else right.pop(0)) merged.extend(left + right) return merged if len(a) <= 1: return a mid = len(a) // 2 left = merge_sort(a[:mid]) right = merge_sort(a[mid:]) return merge(left, right) # A in place version def merge_sort(a): temp = a.copy() def merge(a, low, mid, high): for i in range(low, high + 1): temp[i] = a[i] i, j = low, mid + 1 for k in range(low, high + 1): if i > mid: a[k] = temp[j] j += 1 elif j > high: a[k] = temp[i] i += 1 elif temp[j] < temp[i]: a[k] = temp[j] j += 1 else: a[k] = temp[i] i += 1 # bottom-up version # n = len(a) # step_size = 1 # while step_size < n: # for low in range(0, n, 2 * step_size): # mid = low + step_size - 1 # high = min(n - 1, low + 2 * step_size - 1) # merge(a, low, mid, high) # step_size *= 2 # top-down version def _merge_sort(a, low, high): if low >= high: return mid = (low + high) // 2 _merge_sort(a, low, mid) _merge_sort(a, mid + 1, high) merge(a, low, mid, high) _merge_sort(a, 0, len(a) - 1)
- Pick a random pivot item, put it in the right place by moving items smaller than it to its left and items larger than it to its right.
- Use the pivot item(since its in the right location now), to cut the problem in two halves and recursively sort the whole thing.
import random # an inplace implementation, with random pivot item instead of shuffle and alwasy pick leftmost as pivot. def quick_sort(a): def partition(l, h): if l >= h: return p = random.randint(l, h) a[l], a[p] = a[p], a[l] pivot = a[l] i, j = l, h while i < j: while i < h and a[i] <= pivot: i += 1 while j > l and a[j] >= pivot: j -= 1 if j > i and a[i] > a[j]: a[i], a[j] = a[j], a[i] a[j], a[l] = a[l], a[j] return j def sort(l, h): if h > l: j = partition(l, h) sort(l, j - 1) sort(j + 1, h) sort(0, len(a) - 1)
- Heapify the array then poping item one at a time.
- See Heap section for how heap works.
- A stack is a collection that is Last-In-First-Out(LIFO).
An implementation of Stack using linked list.
class Stack(object): class Node(object): def __init__(self, item=None): self.item = item self.next = None def __init__(self): self.head = None self._n = 0 @property def is_empty(self): return not self.head def __len__(self): return self.n def push(self, item): prev_head = self.head self.head = self.Node(item) self.head.next = prev_head self._n += 1 def pop(self): assert not self.is_empty, "Stack is empty" item = self.head.item self.head = self.head.next self._n -= 1 return item
- Union Find is a data structure keeps track of a set of elements that are partitioned into a number of disjoint subsets.
- It has two operations
find(p, q), where find tells whether
qare in the same subset and union merges the subsets containing
- It memory usage is O(N), and find and union operations are near O(N).
- The algorithm can be used to find all connected components in a network.
class UnionFind(object): def __init__(self, n): self.ids = list(range(n)) self.sizes =  * n def root(self, i): while i != self.ids[i]: self.ids[i] = self.ids[self.ids[i]] # path compression i = self.ids[i] return i def find(self, p, q): return self.root(p) == self.root(q) def union(self, p, q): rootp, rootq = map(self.root, (p, q)) small, big = sorted([rootp, rootq], key=lambda x: self.sizes[x]) self.ids[small] = self.ids[big] self.sizes[big] += self.sizes[small]
Lets say there are
n nodes in a graph and we encode the connectivity information with adjacency list as
connections which looks like
[(p1, q1), (p2, q2) ...].
To get the number of connected components:
uf = UnionFind(n) for p, q in connections: uf.union(p, q) num_components = len(set(uf.root(i) for i in range(n)))