Directi (now part of Radix) is a well-known Indian tech conglomerate founded by the Bhavin Turakhia and Divyank Turakhia brothers. The company started as a domain registration and web hosting business and later expanded into various tech ventures, including ad tech, communication, and SaaS products. Employees Over 1,500 across 8 global offices, Enterprise value over $1.4 billion.
Founded: 1998
Headquarters: Mumbai, India (with global offices in the US, UAE, and Europe)
Key Founders: Bhavin Turakhia & Divyank Turakhia
Current Status: Most of its businesses were sold, and the remaining operations are under Radix.
BigRock (Domain Registration & Web Hosting) – Part of Endurance International Group (sold in 2014).
ResellerClub (Domain reselling platform) – Also sold to Endurance International Group.
Media.net (Ad-Tech, Contextual Advertising) – Sold for $900M (2016) to a Chinese consortium.
Flock (Team Communication App) – Now a separate company.
Zeta (FinTech, Banking Tech) – Became an independent unicorn startup.
Radix (Top-Level Domain Registry) – Manages extensions like .tech, .store, .online, etc.
Radix is now the primary business under the Directi umbrella, focusing on new domain extensions (gTLDs).
It owns and operates popular domain extensions like:
.tech
.store
.online
.fun
.site
Media.net was one of the largest ad-tech acquisitions ($900M in 2016).
Zeta became a unicorn in the fintech space.
Flock competes with Slack & Microsoft Teams in team collaboration.
Known for a startup-like, high-performance culture.
Hiring involves competitive coding, system design, and problem-solving rounds.
Strong focus on engineering talent and product innovation.
Bhavin Turakhia now runs Zeta (FinTech) and Flock (Communication).
Divyank Turakhia founded Media.net (sold) and is now an investor.
Since Directi has sold most of its businesses (like BigRock, Media.net, ResellerClub), the primary hiring now happens under Radix (for domain-related roles) and other spin-offs like Flock (team messaging) and Zeta (fintech).
However, the recruitment process for Directi (Radix & other legacy companies) is known for being rigorous, coding-heavy, and focused on problem-solving. Here’s a general outline:
Apply via careers page, LinkedIn, or referrals.
Resume shortlisting based on technical skills (DSA, system design, projects).
Platform: HackerRank, CodeChef, or Codility.
Duration: 60-90 mins.
Topics:
Data Structures & Algorithms (Arrays, Strings, Trees, Graphs, DP).
Problem-solving (Medium to Hard LeetCode-style questions).
Efficiency matters (Optimal time & space complexity required).
Format: Live coding (shared editor) + discussion.
Questions:
Medium-Hard LeetCode (e.g., Sliding Window, BFS/DFS, Dynamic Programming).
Puzzles & Math-heavy problems (sometimes).
Evaluation:
Correctness, optimization, clean code.
Topics:
Scalability, databases, caching, microservices.
Example: Design a URL shortener, chat app (like Flock), or domain registry system.
Topics:
OS (Processes, Threads, Deadlocks).
DBMS (Indexing, Transactions).
Networking (HTTP, DNS, TCP/IP).
Discussion on projects, past experience.
Behavioral questions (e.g., conflict resolution, teamwork).
Alignment with company culture (innovation-driven, ownership).
Compensation: Competitive (higher than most Indian startups).
Perks: ESOPs, flexible work culture (depends on the subsidiary).
* High coding bar – Expect LeetCode Hard-level questions.
* System design depth – Especially for senior roles.
* Puzzle-solving – Sometimes asked in initial rounds.
DSA: Practice Codeforces, LeetCode (Hard), InterviewBit.
System Design: Study scalability, databases, caching.
Puzzles: Check GeeksforGeeks/Glassdoor for past questions.
Radix hires for domain-tech roles (backend, DevOps).
Flock (ex-Directi) hires for frontend, backend, DevOps.
Zeta (Bhavin’s fintech) hires for Java/Python, cloud, banking tech. .. Apply Now.
Arrays and linked lists are both data structures used to store collections of elements, but they differ in how they store data and how you interact with them.
Here’s a quick breakdown of the key differences:
Array:
A fixed-size container where elements are stored in contiguous memory locations.
Accessed via an index (e.g., array[0]
, array[1]
, etc.).
Linked List:
A collection of nodes, where each node contains the data and a reference (or pointer) to the next node.
Memory is not contiguous.
Array:
Static (in many languages): You declare the size up front.
Efficient memory usage if you know the size in advance.
Linked List:
Dynamic: Grows and shrinks as needed at runtime.
Can lead to overhead due to extra pointers.
Array:
O(1) time to access any element by index (random access).
Linked List:
O(n) time to access an element (you must traverse from the head node).
Array:
Slow for inserting or deleting in the middle (requires shifting elements).
O(n) time in the worst case.
Linked List:
Fast insertion/deletion (if you have the pointer to the right place).
O(1) time to insert or delete a node at the head or tail (with a tail pointer).
Array:
Lower overhead.
Only stores the elements.
Linked List:
Higher overhead due to storage of pointers along with each data element.
Feature | Array | Linked List |
---|---|---|
Memory layout | Contiguous | Non-contiguous |
Access time | O(1) | O(n) |
Insertion | O(n) | O(1) (at head) |
Deletion | O(n) | O(1) (at head) |
Size flexibility | Fixed size | Dynamic |
Memory overhead | Low | High (pointers) |
A concise comparison of time complexities for insert, delete, and search operations across arrays, linked lists, stacks, and queues:
Data Structure | Insert | Delete | Search |
---|---|---|---|
Array (unsorted) | O(1) at end / O(n) elsewhere | O(n) | O(n) |
Array (sorted) | O(n) | O(n) | O(log n) (binary search) |
Linked List (Singly) | O(1) at head / O(n) at tail/middle | O(1) at head / O(n) | O(n) |
Linked List (Doubly) | O(1) at head/tail / O(n) at middle | O(1) at head/tail / O(n) | O(n) |
Stack (Array or Linked List) | O(1) | O(1) (pop) | O(n) |
Queue (Array or Linked List) | O(1) (enqueue) | O(1) (dequeue) | O(n) |
Array (Unsorted):
Insertion at end is O(1) unless resizing is needed (in dynamic arrays).
Deletion/search requires shifting or scanning → O(n).
Array (Sorted):
Binary search gives O(log n), but insertion/deletion needs shifting → O(n).
Linked List:
Insertion/deletion at head is O(1).
To access or modify anything beyond the head, traversal is needed → O(n).
Stack:
LIFO (Last-In-First-Out).
Always O(1) for push/pop.
Queue:
FIFO (First-In-First-Out).
O(1) for enqueue and dequeue if implemented with linked list or circular buffer.
Recursion is a programming technique where a function calls itself to solve a problem.
Each recursive call should work on a smaller or simpler subproblem and eventually reach a point where it doesn’t make any more calls — that’s the base case.
def factorial(n):
if n == 0: # Base case
return 1
else:
return n * factorial(n - 1) # Recursive case
Calling factorial(5)
will call factorial(4)
, then factorial(3)
, and so on — until it hits factorial(0)
, which returns 1 and starts "unwinding" the stack.
Recursion is especially useful when a problem:
Can be broken into similar subproblems (divide-and-conquer).
Has a natural recursive structure (e.g., trees, graphs).
Involves backtracking (like solving mazes or puzzles).
Needs to traverse nested or hierarchical data.
Mathematical problems: factorial, Fibonacci, GCD
Data structures: binary trees, graphs, linked lists
Divide and conquer algorithms: merge sort, quicksort
Backtracking: solving Sudoku, generating permutations
Parsing expressions or directories
When the problem has huge depth (risk of stack overflow).
If it can be done more efficiently using iteration (loops).
When performance is critical, and function call overhead is too costly.
In those cases, iterative or tail-recursive approaches (if supported by your language) may be better.
Feature | Recursion | Iteration |
---|---|---|
Style | Elegant, concise | Often more verbose |
Overhead | More (function calls) | Less |
Readability | High (for recursive problems) | High (for simple loops) |
Risk | Stack overflow | None (unless infinite loop) |
Let’s go over both iterative and recursive approaches to reverse a singly linked list.
class Node:
def __init__(self, data):
self.data = data
self.next = None
def reverse_iterative(head):
prev = None
current = head
while current:
next_node = current.next # save next
current.next = prev # reverse pointer
prev = current # move prev forward
current = next_node # move current forward
return prev # new head
def reverse_recursive(head):
# Base case: empty list or end of list
if head is None or head.next is None:
return head
# Reverse the rest of the list
new_head = reverse_recursive(head.next)
# Re-point the next node's next to current node
head.next.next = head
head.next = None
return new_head
# Helper to print the list
def print_list(head):
while head:
print(head.data, end=" -> ")
head = head.next
print("None")
# Create a linked list: 1 -> 2 -> 3 -> None
head = Node(1)
head.next = Node(2)
head.next.next = Node(3)
print("Original:")
print_list(head)
reversed_head = reverse_iterative(head)
print("Reversed (Iterative):")
print_list(reversed_head)
# To test the recursive method, re-create the original list again
head = Node(1)
head.next = Node(2)
head.next.next = Node(3)
reversed_head = reverse_recursive(head)
print("Reversed (Recursive):")
print_list(reversed_head)
Detecting a loop in a linked list is a classic problem, and the most efficient way to do it is using Floyd’s Cycle Detection Algorithm, also known as the Tortoise and Hare algorithm 🐢🐇.
Use two pointers:
slow
moves one step at a time.
fast
moves two steps at a time.
If there is a loop, the fast
pointer will eventually meet the slow
pointer inside the loop.
def has_loop(head):
slow = head
fast = head
while fast and fast.next:
slow = slow.next # move 1 step
fast = fast.next.next # move 2 steps
if slow == fast:
return True # Loop detected
return False # No loop
class Node:
def __init__(self, data):
self.data = data
self.next = None
# Create nodes
head = Node(1)
head.next = Node(2)
head.next.next = Node(3)
head.next.next.next = Node(4)
# Create a loop: 4 -> 2
head.next.next.next.next = head.next
# Detect loop
print(has_loop(head)) # Output: True
Finding the middle of a linked list in one pass can be done using the slow and fast pointer approach — same technique used in cycle detection.
slow
moves 1 step at a time.
fast
moves 2 steps at a time.
When fast
reaches the end, slow
will be at the middle.
def find_middle(head):
slow = head
fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow # This is the middle node
class Node:
def __init__(self, data):
self.data = data
self.next = None
# Create linked list: 1 -> 2 -> 3 -> 4 -> 5
head = Node(1)
head.next = Node(2)
head.next.next = Node(3)
head.next.next.next = Node(4)
head.next.next.next.next = Node(5)
mid = find_middle(head)
print(f"Middle node data: {mid.data}") # Output: 3
A palindrome is a string that reads the same forward and backward, like "radar"
or "level"
.
You can use a stack to help with this, since a stack works on Last In, First Out (LIFO) — perfect for reversing a string.
Push each character of the string onto a stack.
Pop characters off the stack and build a reversed version.
Compare the reversed string to the original.
def is_palindrome(s):
stack = []
# Push all characters onto the stack
for char in s:
stack.append(char)
# Build reversed string from stack
reversed_s = ''
while stack:
reversed_s += stack.pop()
return s == reversed_s
print(is_palindrome("racecar")) # True
print(is_palindrome("hello")) # False
print(is_palindrome("madam")) # True
To make it case-insensitive: s = s.lower()
To ignore non-alphanumeric characters (like punctuation):
import re
s = re.sub(r'[^a-zA-Z0-9]', '', s.lower())
Sorting a stack using recursion! It's a neat problem that really highlights the power of thinking recursively. Since stacks inherently follow a Last-In, First-Out (LIFO) principle, we need to be a bit clever to get them sorted. We can't just directly access elements in the middle.
Here's the general idea, and then we can walk through the logic:
Let's think about how we'd implement that "insert in sorted position" part. We'd need another recursive helper function for this:
insertSorted(stack, element)
:
element
we want to insert, we can just push the element
onto the stack.insertSorted
with the remaining stack and the element
.element
has been placed correctly), push the element we initially popped back onto the stack. This ensures we don't lose the elements that were already in the stack.Now, let's put it all together in a conceptual way (you can imagine this as pseudocode):
function sortStack(stack):
if stack is empty:
return stack
topElement = pop(stack)
sortStack(stack) // Recursively sort the rest of the stack
insertSorted(stack, topElement)
return stack
function insertSorted(stack, element):
if stack is empty or top(stack) < element:
push(stack, element)
return
temp = pop(stack)
insertSorted(stack, element) // Find the right place for 'element'
push(stack, temp) // Put the popped element back
Let's walk through a small example:
Suppose our stack initially is [3, 1, 4, 2]
(top is 2).
sortStack([3, 1, 4, 2])
:
topElement = 2
sortStack([3, 1, 4])
is called.sortStack([3, 1, 4])
:
topElement = 4
sortStack([3, 1])
is called.sortStack([3, 1])
:
topElement = 1
sortStack([3])
is called.sortStack([3])
:
topElement = 3
sortStack([])
is called.sortStack([])
: Returns (base case - empty stack is sorted).sortStack([3])
: insertSorted([ ], 3)
is called. 3
is pushed onto the stack. Stack is now [3]
.sortStack([3, 1])
: insertSorted([3], 1)
is called.
temp = 3
is popped. Stack is []
.insertSorted([], 1)
is called. 1
is pushed. Stack is [1]
.3
is pushed back. Stack is [1, 3]
.sortStack([3, 1, 4])
: insertSorted([1, 3], 4)
is called. 4
is pushed. Stack is [1, 3, 4]
.sortStack([3, 1, 4, 2])
: insertSorted([1, 3, 4], 2)
is called.
temp = 4
is popped. Stack is [1, 3]
.insertSorted([1, 3], 2)
is called.
temp = 3
is popped. Stack is [1]
.insertSorted([1], 2)
is called. 2
is pushed. Stack is [1, 2]
.3
is pushed back. Stack is [1, 2, 3]
.4
is pushed back. Stack is [1, 2, 3, 4]
.Finally, the sorted stack [1, 2, 3, 4]
is returned.
Finding the kth smallest or largest element in an array is a classic problem, and there are a few different ways to solve it depending on your needs for efficiency, simplicity, and whether you need the array sorted.
def kth_smallest(arr, k):
arr.sort()
return arr[k - 1] # kth smallest (1-based index)
def kth_largest(arr, k):
arr.sort()
return arr[-k] # kth largest
Perfectly fine for small to medium arrays.
For kth smallest, use a max heap of size k
For kth largest, use a min heap of size k
import heapq
def kth_largest_heap(arr, k):
return heapq.nlargest(k, arr)[-1]
import heapq
def kth_smallest_heap(arr, k):
return heapq.nsmallest(k, arr)[-1]
Quickselect is a variation of Quicksort:
Average time: O(n)
Worst case: O(n²), but rare with good pivots
import random
def quickselect(arr, k, smallest=True):
if not arr:
return None
pivot = random.choice(arr)
left = [x for x in arr if x < pivot]
mid = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
if smallest:
if k <= len(left):
return quickselect(left, k, True)
elif k <= len(left) + len(mid):
return pivot
else:
return quickselect(right, k - len(left) - len(mid), True)
else:
return quickselect(arr, len(arr) - k + 1, True) # reuse kth smallest logic
arr = [7, 10, 4, 3, 20, 15]
print(quickselect(arr, 3, smallest=True)) # 3rd smallest => 7
print(quickselect(arr, 2, smallest=False)) # 2nd largest => 15
While NumPy and Pandas are incredibly powerful for handling large datasets and performing efficient operations, the concept of a "stack" as a data structure with only push and pop operations doesn't directly translate to the core functionalities of these libraries.
NumPy arrays and Pandas DataFrames are designed for random access and vectorized operations on homogeneous or tabular data, respectively. They don't inherently support the LIFO (Last-In, First-Out) principle that defines a stack.
However, we can simulate a stack's behavior using NumPy arrays or Pandas Series, but it wouldn't be a true stack in the abstract data type sense. Also, using recursion on very large NumPy arrays or Pandas Series might not be the most memory-efficient approach due to the potential depth of the recursion stack in Python.
That being said, if your "big data array" is already in a NumPy array or Pandas Series, and you want to achieve a sorted version of it, you would typically use the built-in sorting functions provided by these libraries, which are highly optimized for performance.
Using NumPy:
import numpy as np
big_numpy_array = np.array([3, 1, 4, 1, 5, 9, 2, 6])
sorted_numpy_array = np.sort(big_numpy_array)
print(sorted_numpy_array) # Output: [1 1 2 3 4 5 6 9]
NumPy's np.sort()
function returns a new sorted array without modifying the original. You can also use big_numpy_array.sort()
to sort the array in-place. These sorting algorithms in NumPy (typically a highly optimized quicksort or mergesort variant) are very efficient for large arrays and are implemented in C for speed.
Using Pandas:
import pandas as pd
big_pandas_series = pd.Series([3, 1, 4, 1, 5, 9, 2, 6])
sorted_pandas_series = big_pandas_series.sort_values()
print(sorted_pandas_series)
Pandas Series.sort_values()
returns a new Series with the values sorted. You can also use inplace=True
to sort the Series in place. Similar to NumPy, Pandas leverages efficient sorting algorithms under the hood.
Why a Recursive Stack Simulation with NumPy/Pandas Might Not Be Ideal for Big Data:
push
and pop
operations in the way a stack data structure does. Simulating these would involve array/Series manipulations that can be less efficient than using optimized sorting functions.RecursionError
.Finding the Lowest Common Ancestor (LCA) in a Binary Tree is a classic problem! It has several elegant solutions, and here's a breakdown of a common recursive approach:
Understanding the Problem
The Lowest Common Ancestor of two nodes (let's call them p
and q
) in a binary tree is the lowest node in the tree that has both p
and q
as descendants (where we allow a node to be a descendant of itself).
Recursive Approach
The core idea behind the recursive solution is to traverse the tree and, at each node, check if it's the LCA.
Here's the breakdown of the recursive function, let's call it findLCA(root, p, q)
:
Base Cases:
root
is None
(we've gone past a leaf), then the LCA cannot be in this subtree, so we return None
.root
is equal to either p
or q
, then this node itself is a potential ancestor. Since we're looking for the lowest such ancestor, we return the root
. This is because if the current node is one of the target nodes, any ancestor of this node will also have both as descendants (including itself).Recursive Steps:
p
and q
in the left subtree: left_lca = findLCA(root.left, p, q)
.p
and q
in the right subtree: right_lca = findLCA(root.right, p, q)
.Determining the LCA:
left_lca
and right_lca
are not None
: This means we found one of the target nodes (p
or q
) in the left subtree and the other in the right subtree. Therefore, the current root
must be their Lowest Common Ancestor. We return root
.left_lca
is not None
: This means we found both p
and q
in the left subtree (or one of them is the left_lca
itself, and the other is a descendant in its left subtree). In this case, the LCA is the left_lca
. We return left_lca
.right_lca
is not None
: Similarly, if we found both p
and q
in the right subtree, the LCA is right_lca
. We return right_lca
.left_lca
and right_lca
are None
: This means neither p
nor q
were found in the subtrees of the current root
. Therefore, the LCA is not in this subtree, and we return None
.
Conceptual Illustration
Imagine you're searching for two people in a family tree.
Example (Conceptual)
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
Let's say p = 5 and q = 1.
1. Start at root 3.
2. Search left (node 5). `findLCA(5, 5, 1)` returns 5 (base case: root == p).
3. Search right (node 1). `findLCA(1, 5, 1)` returns 1 (base case: root == q).
4. Since the left and right recursive calls returned non-None values (5 and 1), the current root (3) is the LCA.
Let's say p = 5 and q = 4.
1. Start at root 3.
2. Search left (node 5). `findLCA(5, 5, 4)`:
* Returns 5 (base case).
3. Search right (node 1). `findLCA(1, 5, 4)`:
* Returns None (neither 5 nor 4 found in this subtree).
4. Since only the left call returned a non-None value (5), the LCA found so far is 5. However, we need to continue up the tree.
5. Back at root 3, `left_lca` is 5, `right_lca` is None. So, the LCA is 5 (as 4 is a descendant of 5).
Let's say p = 7 and q = 4.
1. Start at root 3.
2. Search left (node 5). `findLCA(5, 7, 4)`:
* Search left (node 6) -> None
* Search right (node 2) -> `findLCA(2, 7, 4)`:
* Search left (node 7) -> returns 7
* Search right (node 4) -> returns 4
* Since both left and right of 2 are non-None, 2 is the LCA of 7 and 4 in this subtree.
3. Search right (node 1) -> None (neither 7 nor 4 found).
4. Back at root 3, `left_lca` is 2, `right_lca` is None. So, the LCA found so far is 2.
**Implementation (Conceptual Python)**
```python
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
def findLCA(root, p, q):
if root is None or root == p or root == q:
return root
left_lca = findLCA(root.left, p, q)
right_lca = findLCA(root.right, p, q)
if left_lca and right_lca:
return root # p and q are in different subtrees
elif left_lca:
return left_lca # Both p and q are in the left subtree
else:
return right_lca # Both p and q are in the right subtree
Time and Space Complexity
This recursive approach is generally clean and efficient for finding the LCA in a binary tree. There are also iterative solutions, often involving finding the paths from the root to the two nodes, but the recursive method is often more intuitive to understand.
Let's implement Dijkstra's algorithm. A* is a natural extension of Dijkstra's, so understanding Dijkstra's well is the first step.
Dijkstra's Algorithm
Dijkstra's algorithm is a greedy algorithm used to find the shortest paths from a single source node to all other nodes in a weighted graph (where edge weights are non-negative).
Conceptual Steps:
Initialization:
Iteration:
u
with the smallest distance from the priority queue.u
has already been visited, skip it.u
as visited.v
of u
:
v
through u
: distance[u] + weight(u, v)
.distance[v]
:
distance[v]
to the new shorter distance.v
to u
(to reconstruct the path later).v
to the priority queue (or update its priority if it's already there).Termination: Once the priority queue is empty, the distance
array will contain the shortest distances from the source node to all other reachable nodes. The predecessor
information can be used to reconstruct the shortest paths.
Python Implementation using heapq
(for priority queue):
import heapq
import math
def dijkstra(graph, start_node):
distances = {node: math.inf for node in graph}
distances[start_node] = 0
priority_queue = [(0, start_node)] # (distance, node)
predecessors = {node: None for node in graph}
visited = set()
while priority_queue:
current_distance, current_node = heapq.heappop(priority_queue)
if current_node in visited:
continue
visited.add(current_node)
for neighbor, weight in graph.get(current_node, {}).items():
distance = current_distance + weight
if distance < distances[neighbor]:
distances[neighbor] = distance
predecessors[neighbor] = current_node
heapq.heappush(priority_queue, (distance, neighbor))
return distances, predecessors
# Example Graph (represented as an adjacency list with weights)
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'D': 5, 'E': 2},
'C': {'A': 4, 'F': 6},
'D': {'B': 5, 'E': 1, 'G': 3},
'E': {'B': 2, 'D': 1, 'F': 7},
'F': {'C': 6, 'E': 7, 'G': 2},
'G': {'D': 3, 'F': 2}
}
start_node = 'A'
shortest_distances, predecessors = dijkstra(graph, start_node)
print(f"Shortest distances from {start_node}: {shortest_distances}")
print(f"Predecessors: {predecessors}")
# Function to reconstruct the shortest path to a target node
def reconstruct_path(predecessors, target_node):
path = []
current = target_node
while current is not None:
path.insert(0, current)
current = predecessors[current]
return path
target_node = 'G'
shortest_path = reconstruct_path(predecessors, target_node)
print(f"Shortest path from {start_node} to {target_node}: {shortest_path}")
A* Algorithm
A* algorithm is an informed search algorithm, meaning it uses a heuristic function to estimate the cost from the current node to the goal node. It combines the actual cost from the source to the current node (like Dijkstra's) with this heuristic estimate to make more intelligent decisions about which nodes to explore next.
Key Difference from Dijkstra's:
A* uses a cost function f(n) = g(n) + h(n)
, where:
g(n)
is the actual cost (shortest distance found so far) from the start node to node n
. (This is what Dijkstra's uses).h(n)
is a heuristic estimate of the cost from node n
to the goal node.The priority queue in A* prioritizes nodes with a lower f(n)
value.
Implementation of A* (Modification of Dijkstra's):
To implement A*, you need to provide a heuristic function heuristic(node, goal_node)
. The quality of the heuristic significantly impacts the performance of A*.
import heapq
import math
def a_star(graph, start_node, goal_node, heuristic):
distances = {node: math.inf for node in graph}
distances[start_node] = 0
priority_queue = [(heuristic(start_node, goal_node), 0, start_node)] # (f_score, g_score, node)
predecessors = {node: None for node in graph}
visited = set()
while priority_queue:
f_score, current_distance, current_node = heapq.heappop(priority_queue)
if current_node in visited:
continue
visited.add(current_node)
if current_node == goal_node:
return distances, predecessors # Goal found
for neighbor, weight in graph.get(current_node, {}).items():
distance = current_distance + weight
if distance < distances[neighbor]:
distances[neighbor] = distance
predecessors[neighbor] = current_node
f_score = distance + heuristic(neighbor, goal_node)
heapq.heappush(priority_queue, (f_score, distance, neighbor))
return distances, predecessors # Goal not reachable
# Example Graph (same as before)
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'D': 5, 'E': 2},
'C': {'A': 4, 'F': 6},
'D': {'B': 5, 'E': 1, 'G': 3},
'E': {'B': 2, 'D': 1, 'F': 7},
'F': {'C': 6, 'E': 7, 'G': 2},
'G': {'D': 3, 'F': 2}
}
start_node = 'A'
goal_node = 'G'
# Example Heuristic (Manhattan distance - not really applicable to this abstract graph,
# but serves as an example. A good heuristic should estimate the cost to the goal)
def simple_heuristic(node, goal):
# A very basic heuristic that is admissible (never overestimates)
# In a real scenario, this would need to be more informed based on the graph's structure.
if node == goal:
return 0
else:
return 1
shortest_distances_astar, predecessors_astar = a_star(graph, start_node, goal_node, simple_heuristic)
print(f"Shortest distances (A*) from {start_node} to {goal_node}: {shortest_distances_astar}")
print(f"Predecessors (A*): {predecessors_astar}")
shortest_path_astar = reconstruct_path(predecessors_astar, goal_node)
print(f"Shortest path (A*) from {start_node} to {goal_node}: {shortest_path_astar}")
Key Points about A*:
h(n) <= cost(n, neighbor) + h(neighbor)
. If a heuristic is consistent, every time A* expands a node, it has already found the shortest path to that node. A consistent heuristic is always admissible.Let's design and implement an LRU (Least Recently Used) Cache in Python.
Understanding LRU Cache
An LRU cache is a data structure that stores key-value pairs and has a limited capacity. When the cache is full and a new key needs to be added, the least recently used item is evicted to make space. The cache needs to support two main operations efficiently:
get(key)
: Retrieves the value associated with the given key. If the key exists, it should also mark the key as recently used. If the key is not found, it should return a specific value (e.g., None
or raise an exception).put(key, value)
: Inserts or updates the value associated with the given key. If the cache is full, it should evict the least recently used item before adding the new one. This operation should also mark the key as recently used.Data Structures to Use
To implement an LRU cache efficiently, we need two main data structures:
get
and put
operations based on the key.Implementation Details
get
, the corresponding node in the doubly linked list is moved to the head.put
, a new node (if the key is new) is created and placed at the head of the list. If the key already exists, its corresponding node is moved to the head.Python Implementation
class Node:
def __init__(self, key, value):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.cache = {} # Hash map to store key-value pairs
self.head = None # Most recently used node
self.tail = None # Least recently used node
self.size = 0
def _move_to_head(self, node):
if node == self.head:
return
if node == self.tail:
self.tail = node.prev
self.tail.next = None
else:
node.prev.next = node.next
node.next.prev = node.prev
node.next = self.head
node.prev = None
if self.head:
self.head.prev = node
self.head = node
if not self.tail:
self.tail = self.head
def get(self, key):
if key in self.cache:
node = self.cache[key]
self._move_to_head(node)
return node.value
return None
def put(self, key, value):
if key in self.cache:
node = self.cache[key]
node.value = value
self._move_to_head(node)
else:
new_node = Node(key, value)
self.cache[key] = new_node
if self.size == self.capacity:
# Evict the least recently used
evicted_node = self.tail
del self.cache[evicted_node.key]
self.tail = self.tail.prev
if self.tail:
self.tail.next = None
else:
self.head = None
self.size -= 1
new_node.next = self.head
new_node.prev = None
if self.head:
self.head.prev = new_node
self.head = new_node
if not self.tail:
self.tail = self.head
self.size += 1
# Example Usage
cache = LRUCache(3)
cache.put(1, "one")
cache.put(2, "two")
cache.put(3, "three")
print(cache.get(1)) # Output: one (moves 1 to the head)
cache.put(4, "four") # Evicts 2 (least recently used)
print(cache.get(2)) # Output: None
print(cache.get(3)) # Output: three (moves 3 to the head)
print(cache.get(4)) # Output: four (moves 4 to the head)
Explanation:
Node
Class: Represents a node in the doubly linked list, storing the key, value, and pointers to the previous and next nodes.LRUCache
Class:
__init__(self, capacity)
: Initializes the cache with a given capacity, an empty hash map (self.cache
), and pointers to the head and tail of the doubly linked list (self.head
, self.tail
). self.size
keeps track of the number of items in the cache._move_to_head(self, node)
: This helper function moves a given node to the head of the doubly linked list, updating the prev
and next
pointers accordingly.get(self, key)
:
key
exists in the self.cache
.Node
, moves it to the head of the linked list (marking it as recently used), and returns its value
.None
.put(self, key, value)
:
key
already exists in the self.cache
:
value
of the corresponding Node
.Node
to the head of the linked list.key
is new:
Node
with the given key
and value
.self.size == self.capacity
):
tail
) from the linked list and its entry from the self.cache
.tail
pointer.self.size
.Node
to the head of the linked list and into the self.cache
.head
and tail
pointers if necessary.self.size
.Time Complexity:
get(key)
: O(1) on average, due to the hash map lookup and the doubly linked list operations (moving to head).put(key, value)
: O(1) on average, for similar reasons as get
.Space Complexity:
capacity
key-value pairs and the doubly linked list has at most capacity
nodes.Merging k sorted linked lists is a common and important problem. Here are a few approaches with their explanations and Python implementations:
Approach 1: Using a Min-Heap (Priority Queue)
This is often the most efficient approach.
Algorithm:
current
pointer to this dummy head.current
pointer's next
.current
pointer to the newly added node.next
node in its original list, insert that next
node into the min-heap.next
of the dummy head, which is the head of the merged sorted list.Python Implementation:
import heapq
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def mergeKLists(lists):
head = ListNode(0)
current = head
min_heap = []
# Initialize the min-heap with the head nodes of all non-empty lists
for i, linked_list in enumerate(lists):
if linked_list:
heapq.heappush(min_heap, (linked_list.val, i, linked_list))
# Store the original list index to avoid comparison issues in the heap
# when values are the same.
while min_heap:
smallest_val, list_index, smallest_node = heapq.heappop(min_heap)
current.next = smallest_node
current = current.next
if smallest_node.next:
heapq.heappush(min_heap, (smallest_node.next.val, list_index, smallest_node.next))
return head.next
# Example Usage:
list1 = ListNode(1, ListNode(4, ListNode(5)))
list2 = ListNode(1, ListNode(3, ListNode(4)))
list3 = ListNode(2, ListNode(6))
lists = [list1, list2, list3]
merged_list = mergeKLists(lists)
# Print the merged list: 1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6
while merged_list:
print(merged_list.val, end=" -> ")
merged_list = merged_list.next
print("None")
Time Complexity: O(N log k), where N is the total number of nodes across all k lists. We perform log k heap operations for each of the N nodes.
Space Complexity: O(k), for storing at most k nodes in the min-heap.
Approach 2: Divide and Conquer (Merge Two Lists Repeatedly)
This approach recursively merges pairs of lists until only one merged list remains.
Algorithm:
None
.Python Implementation:
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def mergeTwoLists(l1, l2):
head = ListNode(0)
current = head
while l1 and l2:
if l1.val <= l2.val:
current.next = l1
l1 = l1.next
else:
current.next = l2
l2 = l2.next
current = current.next
if l1:
current.next = l1
if l2:
current.next = l2
return head.next
def mergeKLists_divide_conquer(lists):
if not lists:
return None
if len(lists) == 1:
return lists[0]
mid = len(lists) // 2
left = mergeKLists_divide_conquer(lists[:mid])
right = mergeKLists_divide_conquer(lists[mid:])
return mergeTwoLists(left, right)
# Example Usage (same as before):
list1 = ListNode(1, ListNode(4, ListNode(5)))
list2 = ListNode(1, ListNode(3, ListNode(4)))
list3 = ListNode(2, ListNode(6))
lists = [list1, list2, list3]
merged_list = mergeKLists_divide_conquer(lists)
# Print the merged list: 1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6
while merged_list:
print(merged_list.val, end=" -> ")
merged_list = merged_list.next
print("None")
Time Complexity: O(N log k), where N is the total number of nodes and k is the number of lists. The merging process takes O(N) time in total, and the divide and conquer approach has a depth of log k.
Space Complexity: O(log k) due to the recursive calls on the stack.
Approach 3: Iterative Merging
This approach iteratively merges the lists one by one.
Algorithm:
merged
list to the first list in the input lists
.merged
list with the next list in the input using the standard merge two sorted lists algorithm.merged
list will be the final sorted merged list.Python Implementation:
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def mergeTwoLists(l1, l2):
# (Same mergeTwoLists function as in the divide and conquer approach)
head = ListNode(0)
current = head
while l1 and l2:
if l1.val <= l2.val:
current.next = l1
l1 = l1.next
else:
current.next = l2
l2 = l2.next
current = current.next
if l1:
current.next = l1
if l2:
current.next = l2
return head.next
def mergeKLists_iterative(lists):
if not lists:
return None
merged = lists[0]
for i in range(1, len(lists)):
merged = mergeTwoLists(merged, lists[i])
return merged
# Example Usage (same as before):
list1 = ListNode(1, ListNode(4, ListNode(5)))
list2 = ListNode(1, ListNode(3, ListNode(4)))
list3 = ListNode(2, ListNode(6))
lists = [list1, list2, list3]
merged_list = mergeKLists_iterative(lists)
# Print the merged list: 1 -> 1 -> 2 -> 3 -> 4 -> 4 -> 5 -> 6
while merged_list:
print(merged_list.val, end=" -> ")
merged_list = merged_list.next
print("None")
Time Complexity: O(N * k) in the worst case. In each of the k-1 merge operations, we might traverse up to N nodes.
Space Complexity: O(1) extra space (excluding the space for the merged list itself).
Comparison of Approaches:
Binary Search is a highly efficient searching algorithm for finding a specific element within a sorted array (or list). Its core idea is to repeatedly divide the search interval in half. If the middle element matches the target, the search is successful. If the target is less than the middle element, the search continues in the left half. If the target is greater, the search continues in the right half.
Core Algorithm (Standard Binary Search):
Let's say we're searching for target
in a sorted array arr
of size n
.
low = 0
and high = n - 1
.low <= high
:
mid = low + (high - low) // 2
(This way of calculating mid
prevents potential integer overflow).arr[mid] == target
:
mid
(target found at this index).target < arr[mid]
:
high = mid - 1
(search in the left half).target > arr[mid]
):
low = mid + 1
(search in the right half).Example:
def binary_search(arr, target):
low = 0
high = len(arr) - 1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] == target:
return mid
elif target < arr[mid]:
high = mid - 1
else:
low = mid + 1
return -1
arr = [2, 5, 7, 8, 11, 12]
target = 13
index = binary_search(arr, target)
if index != -1:
print(f"Element found at index {index}")
else:
print("Element not found")
Time Complexity: O(log n), where n is the number of elements in the array. This is because the search space is halved in each step.
Space Complexity: O(1) for the iterative approach (as shown above). The recursive approach would have a space complexity of O(log n) due to the function call stack.
Variants of Binary Search:
While the standard binary search finds if an element exists and returns one of its indices if it does, many variations are designed to solve specific problems in sorted data. Here are some common variants:
1. Finding the First Occurrence of an Element:
If the target element appears multiple times, this variant finds the index of its first occurrence.
def first_occurrence(arr, target):
low = 0
high = len(arr) - 1
result = -1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] == target:
result = mid # Potential first occurrence, keep searching left
high = mid - 1
elif target < arr[mid]:
high = mid - 1
else:
low = mid + 1
return result
arr = [2, 5, 5, 5, 7, 8]
target = 5
index = first_occurrence(arr, target)
print(f"First occurrence of {target} is at index {index}") # Output: 1
2. Finding the Last Occurrence of an Element:
Similar to finding the first occurrence, this variant finds the index of the last occurrence.
def last_occurrence(arr, target):
low = 0
high = len(arr) - 1
result = -1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] == target:
result = mid # Potential last occurrence, keep searching right
low = mid + 1
elif target < arr[mid]:
high = mid - 1
else:
low = mid + 1
return result
arr = [2, 5, 5, 5, 7, 8]
target = 5
index = last_occurrence(arr, target)
print(f"Last occurrence of {target} is at index {index}") # Output: 3
3. Finding the First Element Greater Than the Target (Ceiling):
This variant finds the smallest element in the array that is greater than or equal to the target.
def ceiling(arr, target):
low = 0
high = len(arr) - 1
result = -1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] >= target:
result = mid
high = mid - 1 # Try to find a smaller index with a value >= target
elif target > arr[mid]:
low = mid + 1
else: # target == arr[mid]
return mid
return result if result != -1 else (0 if arr else -1) # Handle empty array
arr = [2, 3, 5, 6, 8, 9]
target = 4
index = ceiling(arr, target)
if index != -1:
print(f"Ceiling of {target} is {arr[index]} at index {index}") # Output: 5 at index 2
else:
print("No ceiling found")
4. Finding the First Element Greater Than the Target (Strictly Greater):
This variant finds the smallest element in the array that is strictly greater than the target.
def first_greater(arr, target):
low = 0
high = len(arr) - 1
result = -1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] > target:
result = mid
high = mid - 1 # Try to find a smaller index with a value > target
else:
low = mid + 1
return result if result != -1 else -1
arr = [2, 3, 5, 6, 8, 9]
target = 4
index = first_greater(arr, target)
if index != -1:
print(f"First greater than {target} is {arr[index]} at index {index}") # Output: 5 at index 2
else:
print("No element greater than target found")
5. Finding the Last Element Smaller Than the Target (Floor):
This variant finds the largest element in the array that is less than or equal to the target.
def floor(arr, target):
low = 0
high = len(arr) - 1
result = -1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] <= target:
result = mid
low = mid + 1 # Try to find a larger index with a value <= target
elif target < arr[mid]:
high = mid - 1
else: # target == arr[mid]
return mid
return result
arr = [2, 3, 5, 6, 8, 9]
target = 7
index = floor(arr, target)
if index != -1:
print(f"Floor of {target} is {arr[index]} at index {index}") # Output: 6 at index 3
else:
print("No floor found")
6. Finding the Last Element Smaller Than the Target (Strictly Smaller):
This variant finds the largest element in the array that is strictly smaller than the target.
def last_smaller(arr, target):
low = 0
high = len(arr) - 1
result = -1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] < target:
result = mid
low = mid + 1 # Try to find a larger index with a value < target
else:
high = mid - 1
return result
arr = [2, 3, 5, 6, 8, 9]
target = 7
index = last_smaller(arr, target)
if index != -1:
print(f"Last smaller than {target} is {arr[index]} at index {index}") # Output: 6 at index 3
else:
print("No element smaller than target found")
7. Binary Search in Rotated Sorted Array:
When a sorted array is rotated at some unknown pivot, the standard binary search won't work directly. We need to adapt it. The key idea is to determine which half of the array is still sorted.
def search_rotated_sorted_array(arr, target):
low = 0
high = len(arr) - 1
while low <= high:
mid = low + (high - low) // 2
if arr[mid] == target:
return mid
# Check if the left half is sorted
if arr[low] <= arr[mid]:
if arr[low] <= target < arr[mid]:
high = mid - 1
else:
low = mid + 1
# Otherwise, the right half is sorted
else:
if arr[mid] < target <= arr[high]:
low = mid + 1
else:
high = mid - 1
return -1
arr = [4, 5, 6, 7, 0, 1, 2]
target = 0
index = search_rotated_sorted_array(arr, target)
print(f"Element {target} found at index {index} in rotated array") # Output: 4
8. Finding the Pivot in a Rotated Sorted Array:
This variant finds the index of the pivot element (the smallest element) in a rotated sorted array.
def find_pivot(arr):
low = 0
high = len(arr) - 1
while low < high:
mid = low + (high - low) // 2
if arr[mid] > arr[high]:
low = mid + 1
else:
high = mid
return low
arr = [4, 5, 6, 7, 0, 1, 2]
pivot_index = find_pivot(arr)
print(f"Pivot index is {pivot_index}") # Output: 4
Applications of Binary Search and its Variants:
Understanding the core binary search algorithm and its common variations is crucial for efficiently solving a wide range of problems involving sorted data. Remember that the input array must be sorted for binary search to work correctly and efficiently.
Backtracking is a powerful algorithmic technique for solving problems that involve exploring a set of choices. It's essentially a form of depth-first search (DFS) where we explore a potential solution path step by step. If at any point we realize that the current path cannot lead to a valid solution, we "backtrack" – we undo the last decision and try a different path.
Think of it like navigating a maze. You go down a path, and if you hit a dead end, you go back to the last fork and try another direction.
Key Concepts of Backtracking:
How Backtracking Works (General Steps):
When to Use Backtracking:
Backtracking is typically used for problems that have the following characteristics:
Common Examples of Problems Solved Using Backtracking:
Advantages of Backtracking:
Disadvantages of Backtracking:
Illustrative Example: Generating Permutations of a String
Let's say we want to find all permutations of the string "ABC".
[]
['A']
['A', 'B']
['A', 'B', 'C']
(Complete permutation, add to results)['B']
['B', 'A']
['B', 'A', 'C']
(Complete permutation, add to results)This systematic exploration ensures that we generate all possible permutations.
Greedy algorithms are a simple and intuitive approach to problem-solving. They work by making the locally optimal choice at each step with the hope of finding a global optimum. The idea is to select the best immediate option without considering the long-term consequences.
Think of it like picking the biggest cookie from the jar at each turn, hoping that you'll end up with the most cookies overall. While this works in some scenarios, it doesn't guarantee the best result in all cases.
Key Characteristics of Greedy Algorithms:
General Structure of a Greedy Algorithm:
When Greedy Algorithms Work (and When They Don't):
Greedy algorithms are effective for problems that exhibit the following properties:
However, greedy algorithms do not always guarantee a globally optimal solution. If the greedy choice at some step leads to a suboptimal overall outcome, the algorithm will not be able to correct itself.
Common Examples of Problems Solved Using Greedy Algorithms:
Examples Where Greedy Algorithms Might Fail:
Advantages of Greedy Algorithms:
Disadvantages of Greedy Algorithms: