A trie is a tree - like data structure used for efficient retrieval of keys in a dataset of strings. It is particularly useful for autocomplete, spell - checking, and IP routing.
class TrieNode:
def __init__(self):
self.children = {}
self.is_end_of_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end_of_word = True
def search(self, word):
node = self.root
for char in word:
if char not in node.children:
return False
node = node.children[char]
return node.is_end_of_word
# Usage example
trie = Trie()
trie.insert("apple")
print(trie.search("apple")) # Output: True
print(trie.search("app")) # Output: False
A Fenwick tree is used for efficient range queries and updates on an array. It can calculate the sum of a range of elements in an array in $O(\log n)$ time and update an element in $O(\log n)$ time.
class FenwickTree:
def __init__(self, n):
self.bit = [0] * (n + 1)
self.n = n
def update(self, idx, val):
idx += 1
while idx <= self.n:
self.bit[idx] += val
idx += idx & (-idx)
def query(self, idx):
idx += 1
res = 0
while idx > 0:
res += self.bit[idx]
idx -= idx & (-idx)
return res
# Usage example
arr = [1, 2, 3, 4, 5]
n = len(arr)
bit = FenwickTree(n)
for i in range(n):
bit.update(i, arr[i])
print(bit.query(2)) # Output: 6
A segment tree is a tree data structure used for storing information about intervals, or segments. It allows efficient querying and updating of ranges in an array.
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
self.build(arr, 0, 0, self.n - 1)
def build(self, arr, node, start, end):
if start == end:
self.tree[node] = arr[start]
else:
mid = (start + end) // 2
left_child = 2 * node + 1
right_child = 2 * node + 2
self.build(arr, left_child, start, mid)
self.build(arr, right_child, mid + 1, end)
self.tree[node] = self.tree[left_child] + self.tree[right_child]
def query(self, node, start, end, l, r):
if r < start or l > end:
return 0
if l <= start and r >= end:
return self.tree[node]
mid = (start + end) // 2
left_child = 2 * node + 1
right_child = 2 * node + 2
return self.query(left_child, start, mid, l, r) + self.query(right_child, mid + 1, end, l, r)
# Usage example
arr = [1, 3, 5, 7, 9, 11]
seg_tree = SegmentTree(arr)
print(seg_tree.query(0, 0, len(arr) - 1, 1, 3)) # Output: 15
The quickselect algorithm is used to find the k - th smallest element in an unsorted list. It has an average time complexity of $O(n)$.
import random
def partition(arr, low, high):
pivot_index = random.randint(low, high)
arr[pivot_index], arr[high] = arr[high], arr[pivot_index]
pivot = arr[high]
i = low - 1
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1
def quickselect(arr, k):
low, high = 0, len(arr) - 1
while True:
pivot_index = partition(arr, low, high)
if pivot_index == k - 1:
return arr[pivot_index]
elif pivot_index > k - 1:
high = pivot_index - 1
else:
low = pivot_index + 1
# Usage example
arr = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
k = 3
print(quickselect(arr, k)) # Output: 2
Binary search can be modified to solve different types of problems, such as finding the first or last occurrence of an element in a sorted array.
def first_occurrence(arr, target):
left, right = 0, len(arr) - 1
result = -1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target:
result = mid
right = mid - 1
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return result
# Usage example
arr = [1, 2, 2, 2, 3, 4, 5]
target = 2
print(first_occurrence(arr, target)) # Output: 1
Dijkstra’s algorithm is used to find the shortest path between nodes in a graph with non - negative edge weights.
import heapq
def dijkstra(graph, start):
distances = {node: float('inf') for node in graph}
distances[start] = 0
priority_queue = [(0, start)]
while priority_queue:
current_distance, current_node = heapq.heappop(priority_queue)
if current_distance > distances[current_node]:
continue
for neighbor, weight in graph[current_node].items():
distance = current_distance + weight
if distance < distances[neighbor]:
distances[neighbor] = distance
heapq.heappush(priority_queue, (distance, neighbor))
return distances
# Usage example
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'C': 2, 'D': 5},
'C': {'A': 4, 'B': 2, 'D': 1},
'D': {'B': 5, 'C': 1}
}
start_node = 'A'
print(dijkstra(graph, start_node))
Kruskal’s algorithm is used to find the minimum spanning tree of a connected, weighted graph.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
root_x = self.find(x)
root_y = self.find(y)
if root_x != root_y:
if self.rank[root_x] > self.rank[root_y]:
self.parent[root_y] = root_x
elif self.rank[root_x] < self.rank[root_y]:
self.parent[root_x] = root_y
else:
self.parent[root_y] = root_x
self.rank[root_x] += 1
def kruskal(graph):
edges = []
for u in graph:
for v, weight in graph[u].items():
edges.append((weight, u, v))
edges.sort()
n = len(graph)
uf = UnionFind(n)
mst = []
for weight, u, v in edges:
if uf.find(u) != uf.find(v):
uf.union(u, v)
mst.append((u, v, weight))
return mst
# Usage example
graph = {
0: {1: 10, 2: 6, 3: 5},
1: {0: 10, 3: 15},
2: {0: 6, 3: 4},
3: {0: 5, 1: 15, 2: 4}
}
print(kruskal(graph))
Memoization is a top - down approach where we store the results of expensive function calls and return the cached result when the same inputs occur again. Tabulation is a bottom - up approach where we fill a table with the results of subproblems.
# Memoization example
def fibonacci_memo(n, memo={}):
if n in memo:
return memo[n]
if n <= 1:
result = n
else:
result = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
memo[n] = result
return result
# Tabulation example
def fibonacci_tab(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# Usage example
n = 10
print(fibonacci_memo(n))
print(fibonacci_tab(n))
In dynamic programming, we can sometimes reduce the space complexity by carefully choosing the state representation.
# Space - optimized Fibonacci
def fibonacci_optimized(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
# Usage example
n = 10
print(fibonacci_optimized(n))
In this blog, we have explored various advanced tactics related to data structures and algorithms in Python. From advanced data structures like tries and Fenwick trees to complex graph algorithms and dynamic programming techniques, these concepts can significantly enhance your problem - solving skills. By following the best practices and tips, you can write more optimized and reliable Python code. Remember, practice is key to mastering these concepts, so keep solving problems and implementing these algorithms in your projects.