DSA Placement Preparation Notes
DSA Placement Preparation Notes
Table of Contents
• Arrays
• Strings
• Linked Lists
• Stacks and Queues
• Hash Tables
• Bit Manipulation
• Two Pointers
• Sliding Window
• Binary Search
• Heap and Priority Queue
• Graphs
• Trees
• Tries
• Dynamic Programming
• Backtracking
Arrays
Arrays are collections of elements stored in contiguous memory locations. This allows O(1) random access
by index and efficient iteration. Arrays are foundational for many algorithmic techniques (e.g., prefix sums,
sliding windows) and often serve as the base structure for dynamic programming table computations. They
are suitable for problems requiring fast lookups and iterations, though insertions/deletions can be costly
due to shifting elements.
Common uses/patterns: - Constant time access by index, easy iteration over elements
- Implementing sliding window and prefix sum computations
- Serving as backing storage for other structures (heaps, hash table buckets, DP states)
def moveZeroes(nums):
last_non_zero = 0
for i in range(len(nums)):
if nums[i] != 0:
# Swap the current element with the last found non-zero
nums[last_non_zero], nums[i] = nums[i], nums[last_non_zero]
last_non_zero += 1
1
Majority Element (Easy)
def majorityElement(nums):
count = 0
candidate = None
for num in nums:
if count == 0:
candidate = num
count += 1 if num == candidate else -1
return candidate
def removeDuplicates(nums):
if not nums:
return 0
write_index = 1
for i in range(1, len(nums)):
if nums[i] != nums[i - 1]:
nums[write_index] = nums[i]
write_index += 1
return write_index
def maxProfit(prices):
min_price = float('inf')
max_profit = 0
for price in prices:
if price < min_price:
min_price = price
elif price - min_price > max_profit:
max_profit = price - min_price
return max_profit
2
def rotate(nums, k):
n = len(nums)
k %= n
# Place the last k elements in front and the rest afterwards
nums[:] = nums[-k:] + nums[:-k]
def productExceptSelf(nums):
n = len(nums)
res = [1] * n
# Calculate prefix products for each index
left_prod = 1
for i in range(n):
res[i] = left_prod
left_prod *= nums[i]
# Multiply with suffix products
right_prod = 1
for i in range(n - 1, -1, -1):
res[i] *= right_prod
right_prod *= nums[i]
return res
def maxProfit(prices):
profit = 0
for i in range(1, len(prices)):
if prices[i] > prices[i - 1]:
profit += prices[i] - prices[i - 1]
return profit
3
counting consecutive zeros. Each time we encounter a zero, increment a counter; for a run of m consecutive
zeros, it contributes subarrays of lengths 1 through m. We can accumulate in a single pass: every time we
see a zero, count += 1 and add that to a result accumulator (because a streak of length m adds m new
subarrays ending at the current position). Reset the count when a non-zero is encountered.
def zeroFilledSubarray(nums):
total = 0
consecutive_zeros = 0
for num in nums:
if num == 0:
consecutive_zeros += 1
total += consecutive_zeros
else:
consecutive_zeros = 0
return total
def increasingTriplet(nums):
first = second = float('inf')
for num in nums:
if num <= first:
first = num
elif num <= second:
second = num
else:
# num is greater than first and second, triplet found
return True
return False
4
def firstMissingPositive(nums):
n = len(nums)
# Place each number in its correct index if possible
for i in range(n):
while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]:
# Swap nums[i] with the element at its target position
target_idx = nums[i] - 1
nums[i], nums[target_idx] = nums[target_idx], nums[i]
# Find the first position that is incorrect
for i in range(n):
if nums[i] != i + 1:
return i + 1
return n + 1
Strings
Strings are sequences of characters, typically used to represent text. Common string operations include
concatenation, slicing, searching for substrings, and parsing. String problems often involve techniques such
as two-pointer scans for palindrome checks, hash maps for frequency counting (e.g., anagrams), or
dynamic programming for subsequence/substring problems. Pattern recognition (like identifying
palindromes or repeating patterns) is key in many string challenges.
5
def isPalindrome(s):
i, j = 0, len(s) - 1
while i < j:
# Move i and j to skip non-alphanumeric characters
if not s[i].isalnum():
i += 1
continue
if not s[j].isalnum():
j -= 1
continue
if s[i].lower() != s[j].lower():
return False
i += 1
j -= 1
return True
def longestCommonPrefix(strs):
if not strs:
return ""
prefix = strs[0]
for s in strs[1:]:
# Shorten the prefix until it matches the start of s
while not s.startswith(prefix):
prefix = prefix[:-1]
if prefix == "":
return ""
return prefix
def reverseWords(s):
words = s.split()
# Filter out any empty strings (split takes care of trimming by default)
return " ".join(reversed(words))
6
each index (and each pair between indices for even-length palindromes), expand outward while the
characters match. Track the longest palindrome found. This approach is O(n^2) in the worst case but is
sufficient for typical input sizes. Dynamic programming could also be used (filling a table for substrings),
but the center-expanding method is more space-efficient.
def longestPalindrome(s):
res = ""
for center in range(len(s)):
# odd length palindrome
l, r = center, center
while l >= 0 and r < len(s) and s[l] == s[r]:
if r - l + 1 > len(res):
res = s[l:r+1]
l -= 1
r += 1
# even length palindrome
l, r = center, center + 1
while l >= 0 and r < len(s) and s[l] == s[r]:
if r - l + 1 > len(res):
res = s[l:r+1]
l -= 1
r += 1
return res
def groupAnagrams(strs):
anagram_map = {}
for word in strs:
key = "".join(sorted(word))
if key not in anagram_map:
anagram_map[key] = []
anagram_map[key].append(word)
return list(anagram_map.values())
Linked Lists
A linked list is a linear data structure where elements (nodes) contain a value and a pointer/reference to the
next node. Unlike arrays, linked lists allow efficient insertions or deletions from the middle of the list if you
7
have a pointer to the node (O(1) for pointer manipulations), but they do not allow constant-time random
access. Common patterns include two-pointer techniques (slow/fast pointers) for cycle detection or finding
the middle, and careful pointer manipulation for list reversal or merging.
Common uses/patterns: - Efficient insertion and deletion when only node references are needed (no
shifting like arrays)
- Two-pointer techniques (fast/slow pointers) for finding midpoint or loops
- Stack-like behavior using recursion or reversal due to pointer structure
- Implementing abstract data types like stacks, queues, or adjacency lists for graphs
def reverseList(head):
prev = None
curr = head
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
return prev
def hasCycle(head):
slow, fast = head, head
8
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
return True
return False
def reorderList(head):
if not head or not head.next:
return
# Find middle (end of first half)
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
second_half = slow.next
slow.next = None # split the list into two halves
9
# Reverse second half
prev = None
curr = second_half
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
second_half = prev
# Merge two halves
first, second = head, second_half
while second:
tmp1 = first.next
tmp2 = second.next
first.next = second
second.next = tmp1
first = tmp1
second = tmp2
def isPalindrome(head):
# Find middle
slow, fast = head, head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# Reverse second half
prev = None
curr = slow
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
# Compare first half and reversed second half
p1, p2 = head, prev
while p2:
if p1.val != p2.val:
return False
p1 = p1.next
p2 = p2.next
return True
10
def getIntersectionNode(headA, headB):
if not headA or not headB:
return None
pA, pB = headA, headB
# Traverse both lists; when one ends, switch to the other head.
# They will meet at intersection or at end (None).
while pA is not pB:
pA = pA.next if pA else headB
pB = pB.next if pB else headA
return pA # either the intersection node or None
def detectCycle(head):
slow, fast = head, head
# Detect if a cycle exists
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
# Cycle detected, find the start
slow = head
while slow != fast:
slow = slow.next
fast = fast.next
return slow
return None
11
carry = 0
while l1 or l2 or carry:
val1 = l1.val if l1 else 0
val2 = l2.val if l2 else 0
total = val1 + val2 + carry
carry = total // 10
current.next = ListNode(total % 10)
current = current.next
if l1: l1 = l1.next
if l2: l2 = l2.next
return dummy.next
Common uses/patterns: - Stacks: Parsing and evaluating expressions, backtracking recursion stack, undo
operations, checking balanced parentheses, monotonic stack for next greater/smaller element problems
- Queues: BFS traversal in trees/graphs, scheduling tasks, buffering data streams, implementing caches
(with deque), sliding window (with deque to maintain max/min)
- Double-ended queues (deque): Supports adding/removing from both ends, used in problems like sliding
window maximum or designing LRU cache (with queue behavior)
def isValid(s):
stack = []
pairs = {')': '(', ']': '[', '}': '{'}
for ch in s:
if ch in '([{':
stack.append(ch)
else:
# If it's a closing bracket, stack must not be empty and top must
match
if not stack or stack[-1] != pairs.get(ch):
return False
stack.pop()
return len(stack) == 0
12
Min Stack (Easy)
class MinStack:
def __init__(self):
self.stack = []
self.min_stack = [] # keep track of current minimum
class MyQueue:
def __init__(self):
self.in_stack = []
self.out_stack = []
13
def empty(self) -> bool:
return not self.in_stack and not self.out_stack
def dailyTemperatures(T):
n = len(T)
result = [0] * n
stack = [] # stores indices of days
for i, temp in enumerate(T):
while stack and T[stack[-1]] < temp:
prev_i = stack.pop()
result[prev_i] = i - prev_i
stack.append(i)
return result
def evalRPN(tokens):
stack = []
14
for token in tokens:
if token not in ["+", "-", "*", "/"]:
stack.append(int(token))
else:
b = stack.pop()
a = stack.pop()
if token == '+':
stack.append(a + b)
elif token == '-':
stack.append(a - b)
elif token == '*':
stack.append(a * b)
elif token == '/':
# Python's int() truncates toward 0 for negative values as
required
stack.append(int(a / b))
return stack[0]
def largestRectangleArea(heights):
stack = []
max_area = 0
for i, h in enumerate(heights):
# If current bar is lower than stack's top bar, pop and calculate area
while stack and heights[stack[-1]] > h:
height = heights[stack.pop()]
# Width is either i (if stack empty) or i - stack[-1] - 1
left_index = stack[-1] if stack else -1
width = i - left_index - 1
max_area = max(max_area, height * width)
stack.append(i)
# Clear remaining bars
n = len(heights)
while stack:
height = heights[stack.pop()]
left_index = stack[-1] if stack else -1
width = n - left_index - 1
15
max_area = max(max_area, height * width)
return max_area
Hash Tables
A hash table (or dictionary/map) stores key-value pairs with average O(1) insertion, deletion, and lookup.
Hash tables are very useful for counting frequencies, caching results, or detecting duplicates, among other
uses. In interview problems, hash maps often help to reduce time complexity by trading space for speed,
such as checking if a complement or remainder has been seen before, grouping items by computed keys, or
memoization in DP/backtracking.
def containsDuplicate(nums):
seen = set()
for num in nums:
if num in seen:
return True
seen.add(num)
return False
16
def isValidSudoku(board):
seen = set()
for r in range(9):
for c in range(9):
val = board[r][c]
if val == ".":
continue
# Compose keys for row, col and subgrid
row_key = (r, val)
col_key = (c, val)
box_key = (r//3, c//3, val)
if row_key in seen or col_key in seen or box_key in seen:
return False
seen.add(row_key)
seen.add(col_key)
seen.add(box_key)
return True
def longestConsecutive(nums):
num_set = set(nums)
longest = 0
for num in num_set:
if num - 1 not in num_set:
# only start counting if num is the start of a sequence
length = 1
curr = num
while curr + 1 in num_set:
curr += 1
length += 1
longest = max(longest, length)
return longest
17
index 0. This yields an O(n) solution.
def isHappy(n):
def next_number(x):
total = 0
while x:
x, digit = divmod(x, 10)
total += digit * digit
return total
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = next_number(n)
return n == 1
Bit Manipulation
Bit manipulation techniques use operations on binary representations of numbers (using bitwise AND, OR,
XOR, shifts, etc.). These techniques are powerful for optimizing certain calculations, toggling flags, or
solving problems involving binary data or subsets. Common patterns include using XOR to cancel out
paired values, using bit masks to represent subsets or states, and shifting bits for multiplication/division by
powers of two.
18
Common uses/patterns: - XOR for finding unique elements (because x ^ x = 0 and x ^ 0 = x)
- Bit masks for subset generation or representing a set of boolean values in an integer
- Checking or setting specific bits (using shifts and AND/OR)
- Efficient multiplication/division by 2 using left/right shifts
- Bit DP for problems like Traveling Salesman (using an integer to represent visited set)
def singleNumber(nums):
result = 0
for num in nums:
result ^= num # XOR will cancel out all paired numbers
return result
def missingNumber(nums):
n = len(nums)
total_xor = 0
for i in range(n + 1):
total_xor ^= i
for num in nums:
total_xor ^= num
return total_xor
(The above uses XOR to cancel out all present numbers from the complete range [0..n], leaving the missing
number. Alternatively, summation formula or Gauss formula could be used.)
def hammingWeight(n):
count = 0
while n:
count += n & 1 # add the least significant bit
n >>= 1 # shift right
return count
def countBits(n):
# DP approach: use the relation bits[i] = bits[i>>1] + (i & 1)
bits = [0] * (n + 1)
19
for i in range(1, n + 1):
bits[i] = bits[i >> 1] + (i & 1)
return bits
def reverseBits(n):
result = 0
for _ in range(32): # assuming 32-bit integer
result <<= 1
result |= (n & 1)
n >>= 1
return result
def singleNumber(nums):
result = 0
for bit in range(32): # for each bit position
bit_count = 0
for num in nums:
# mask out everything but this bit and add
bit_count += (num >> bit) & 1
bit_count %= 3
if bit_count:
# set the bit in result
result |= (1 << bit)
# Handle Python's infinite int sign bit issues:
if result >= 2**31:
result -= 2**32
return result
20
def getSum(a, b):
# Mask to get last 32 bits (simulate 32-bit int)
mask = 0xFFFFFFFF
while b != 0:
carry = (a & b) << 1
a = (a ^ b) & mask
b = carry & mask
# if a is negative in 32-bit, convert to Python negative
return a if a < 0x80000000 else ~(a ^ mask)
Two Pointers
The two-pointer technique involves using two indices to traverse a data structure (usually an array or string)
from either end or with one faster than the other. It is useful for searching pairs/triplets that meet certain
criteria in sorted arrays, partitioning arrays, or finding palindromic strings. Common patterns include the
"opposite end" two-pointer for sorted arrays and the "fast-slow" pointer for cycle detection or finding a
middle element.
Common uses/patterns: - Opposite-end two pointers: Start one pointer at the beginning and one at the
end of a sorted array and move inward to find pairs that meet a condition (like two-sum in a sorted array,
finding pair sums, container with most water etc.)
- Same-direction two pointers: One pointer lags behind another (fast/slow) for tasks like removing
duplicates in-place, or sliding window boundaries (though sliding window is its own category)
- Fast-slow (Floyd’s cycle): One moves twice as fast to detect cycles or to split lists
- Sorting-based k-sum problems (2-sum, 3-sum, 4-sum) often use two pointers after sorting the list
21
def twoSumSorted(numbers, target):
left, right = 0, len(numbers) - 1
while left < right:
s = numbers[left] + numbers[right]
if s == target:
return [left + 1, right + 1] # 1-indexed positions as per problem
elif s < target:
left += 1
else:
right -= 1
return [] # not found
def validPalindrome(s):
def is_palindrome(subs):
return subs == subs[::-1]
left, right = 0, len(s) - 1
while left < right:
if s[left] != s[right]:
# Skip either left or right character and check
return is_palindrome(s[left+1:right+1]) or
is_palindrome(s[left:right])
left += 1
right -= 1
return True
3Sum (Medium)
Explanation: To find all unique triplets that sum to zero, sort the array first. Then iterate through each
number as a potential first element of the triplet. For each such number, use two pointers ( lo and hi )
starting at both ends of the remaining list to find pairs that sum to the negative of the first number. Move
the pointers inward based on the sum. Skip duplicate values to avoid repeating triplets. Sorting is O(n log n)
and the two-pointer scan is O(n) for each of n elements, so overall O(n^2).
def threeSum(nums):
nums.sort()
res = []
n = len(nums)
for i in range(n - 2):
if i > 0 and nums[i] == nums[i-1]:
continue # skip duplicates for the first element
target = -nums[i]
lo, hi = i + 1, n - 1
22
while lo < hi:
total = nums[lo] + nums[hi]
if total == target:
res.append([nums[i], nums[lo], nums[hi]])
# Skip duplicates for lo and hi
while lo < hi and nums[lo] == nums[lo+1]:
lo += 1
while lo < hi and nums[hi] == nums[hi-1]:
hi -= 1
lo += 1
hi -= 1
elif total < target:
lo += 1
else:
hi -= 1
return res
4Sum (Medium)
Explanation: Similar to 3Sum, but we fix two numbers and then use two pointers for the remaining two.
After sorting, use four nested loops effectively: two outer loops for the first two numbers (with skipping
duplicates), then two-pointer for the rest. To reduce complexity, one can also use recursion or hash-based
pruning. The overall complexity is O(n^3) for four sum.
23
else:
hi -= 1
return res
def removeDuplicatesII(nums):
if len(nums) <= 2:
return len(nums)
write_index = 1
count = 1
for i in range(1, len(nums)):
if nums[i] == nums[i-1]:
count += 1
else:
count = 1
if count <= 2:
nums[write_index] = nums[i]
write_index += 1
return write_index
def maxArea(height):
left, right = 0, len(height) - 1
max_area = 0
while left < right:
h = min(height[left], height[right])
width = right - left
max_area = max(max_area, h * width)
# Move the pointer at the shorter line inward
if height[left] < height[right]:
left += 1
24
else:
right -= 1
return max_area
def trap(height):
left, right = 0, len(height) - 1
left_max = right_max = 0
trapped = 0
while left < right:
if height[left] < height[right]:
if height[left] >= left_max:
left_max = height[left]
else:
trapped += left_max - height[left]
left += 1
else:
if height[right] >= right_max:
right_max = height[right]
else:
trapped += right_max - height[right]
right -= 1
return trapped
def sortColors(nums):
low, i, high = 0, 0, len(nums) - 1
while i <= high:
if nums[i] == 0:
nums[i], nums[low] = nums[low], nums[i]
low += 1
i += 1
25
elif nums[i] == 2:
nums[i], nums[high] = nums[high], nums[i]
high -= 1
# do not increment i here
else: # nums[i] == 1
i += 1
Sliding Window
The sliding window technique is used for problems dealing with subarrays or substrings within arrays/
strings, especially when looking for a substructure that satisfies a certain condition. The idea is to maintain
a “window” (a contiguous segment of the array/string) and adjust its boundaries (usually moving one end at
a time) to find the optimal solution (max/min length or a count that satisfies a condition). This technique is
often combined with two-pointer approach and sometimes hashing (for variable-length windows with
conditions like unique characters).
Common uses/patterns: - Fixed-length window problems (e.g., maximum sum of subarray of size k) –
straightforward summation.
- Variable-length window problems (expand and contract the window boundaries to meet conditions like “at
most K distinct characters”, or “sum <= X”). Use a moving right pointer to expand and a left pointer to shrink
when a condition is violated.
- Often involves hash maps or counters to track frequency of elements within the window (for problems like
anagrams, substring with unique chars, etc.)
- In general, useful when the problem involves contiguous segments and an optimal subsegment needs to
be found under some constraints.
def lengthOfLongestSubstring(s):
last_index = {}
longest = 0
left = 0
for right, ch in enumerate(s):
if ch in last_index and last_index[ch] >= left:
# Move left pointer right past the last occurrence of ch
left = last_index[ch] + 1
last_index[ch] = right
longest = max(longest, right - left + 1)
return longest
26
Longest Repeating Character Replacement (Medium)
Explanation: We maintain a sliding window and keep track of the count of the most frequent character in the
window. We expand the window with right pointer. If the window size minus the count of the most
frequent char is greater than k (meaning we need to replace more than k chars to make all same), then we
shrink the window from the left. The length of the window at any point where it's valid (<= k replacements
needed) could be a candidate for result. This works in O(n) by using the fact that we only shrink when
necessary and maintain the max frequency count.
27
del window[s2[i - n1]]
if window == target:
return True
return False
def totalFruit(fruits):
from collections import defaultdict
basket = defaultdict(int)
left = 0
max_picked = 0
for right, fruit in enumerate(fruits):
28
basket[fruit] += 1
# If more than 2 types of fruit, shrink window
while len(basket) > 2:
basket[fruits[left]] -= 1
if basket[fruits[left]] == 0:
del basket[fruits[left]]
left += 1
max_picked = max(max_picked, right - left + 1)
return max_picked
29
l, r = res
return s[l:r+1] if min_len != float('inf') else ""
Binary Search
Binary search is an algorithm to find an element’s position (or determine its absence) in a sorted array in
O(log n) time by repeatedly dividing the search interval in half. Binary search is also used in more advanced
scenarios to answer queries or find optimum values (binary search on answer). Key to using binary search is
a monotonic condition – the target space must be ordered or the condition function returns either always
false then always true (or vice versa) as you move through the domain.
30
def binarySearch(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1 # target not found
31
else:
left = mid + 1 # search towards right side
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return res
first = find_bound(True)
last = find_bound(False)
return [first, last]
32
def findMin(nums):
left, right = 0, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] > nums[right]:
left = mid + 1
else:
right = mid
return nums[left]
def findPeakElement(nums):
left, right = 0, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] < nums[mid + 1]:
# there is an ascent to the right, so peak must be right
left = mid + 1
else:
# peak is at mid or to the left
right = mid
return left
33
half_len = (m + n + 1) // 2
imin, imax = 0, m
while imin <= imax:
i = (imin + imax) // 2
j = half_len - i
if i < m and B[j-1] > A[i]:
imin = i + 1 # i is too small, must increase
elif i > 0 and A[i-1] > B[j]:
imax = i - 1 # i is too big, must decrease
else:
# i is perfect
if i == 0: left_max = B[j-1]
elif j == 0: left_max = A[i-1]
else: left_max = max(A[i-1], B[j-1])
if (m + n) % 2 == 1:
return float(left_max)
if i == m: right_min = B[j]
elif j == n: right_min = A[i]
else: right_min = min(A[i], B[j])
return (left_max + right_min) / 2.0
import heapq
def findKthLargest(nums, k):
minheap = []
for num in nums:
heapq.heappush(minheap, num)
34
if len(minheap) > k:
heapq.heappop(minheap)
return minheap[0]
import heapq
def topKFrequent(nums, k):
from collections import Counter
freq = Counter(nums)
heap = []
for num, count in freq.items():
heapq.heappush(heap, (count, num))
if len(heap) > k:
heapq.heappop(heap)
return [num for (count, num) in heap]
import heapq
from collections import Counter, deque
def leastInterval(tasks, n):
if n == 0:
return len(tasks)
freq = Counter(tasks)
max_heap = [-cnt for cnt in freq.values()]
heapq.heapify(max_heap)
time = 0
queue = deque() # to hold tasks during cooling
while max_heap or queue:
time += 1
if max_heap:
cnt = heapq.heappop(max_heap)
cnt += 1 # this task is executed once, so decrement its remaining
count (cnt is negative)
35
if cnt != 0:
# put this task in cooling
queue.append((cnt, time + n))
if queue and queue[0][1] == time:
# the task at front of queue has cooled down, push back to heap
heapq.heappush(max_heap, queue.popleft()[0])
return time
import heapq
def minMeetingRooms(intervals):
if not intervals:
return 0
intervals.sort(key=lambda x: x[0]) # sort by start time
heap = [] # min-heap for end times
heapq.heappush(heap, intervals[0][1])
for interval in intervals[1:]:
if heap[0] <= interval[0]:
heapq.heappop(heap) # reuse room
heapq.heappush(heap, interval[1])
return len(heap)
import heapq
def mergeKLists(lists):
minheap = []
# initialize heap with first node of each list
for idx, node in enumerate(lists):
if node:
heapq.heappush(minheap, (node.val, idx, node))
dummy = ListNode(0)
current = dummy
36
while minheap:
val, idx, node = heapq.heappop(minheap)
current.next = node
current = current.next
if node.next:
heapq.heappush(minheap, (node.next.val, idx, node.next))
return dummy.next
import heapq
class MedianFinder:
def __init__(self):
self.small = [] # max heap (store negatives for Python)
self.large = [] # min heap
37
Graphs
Graphs consist of vertices (nodes) connected by edges. Graph problems often involve traversals (depth-first
search or breadth-first search), shortest path algorithms, or union-find (disjoint set union) for connectivity.
Graphs can be directed or undirected, weighted or unweighted. Key patterns include exploring connected
components, cycle detection, topological sorting for DAGs, and pathfinding.
Common uses/patterns: - Traversal (BFS/DFS): explore all nodes reachable from a starting node (e.g.,
connected components, flood fill, maze solving). BFS is used for shortest path in unweighted graphs, DFS
for exhaustive path exploration or topological sort.
- Shortest Path: Dijkstra’s (with min-heap) for weighted graphs with no negative weights; BFS for
unweighted; Bellman-Ford or Floyd-Warshall for graphs with negative weights (if needed, though less
common in interviews).
- Union-Find (DSU): determine connectivity, find cycles, or minimum spanning tree (Kruskal’s algorithm).
- Topological Sort: order vertices given directed acyclic graph (DAG) constraints (prerequisites problems,
build/order scheduling).
def numIslands(grid):
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
count = 0
def dfs(r, c):
if r < 0 or c < 0 or r >= rows or c >= cols or grid[r][c] != '1':
return
grid[r][c] = '0' # mark visited
dfs(r+1, c); dfs(r-1, c); dfs(r, c+1); dfs(r, c-1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
dfs(r, c)
return count
38
def cloneGraph(node):
if not node:
return None
clone_map = {}
from collections import deque
q = deque([node])
clone_map[node] = Node(node.val, [])
while q:
curr = q.popleft()
for nei in curr.neighbors:
if nei not in clone_map:
clone_map[nei] = Node(nei.val, [])
q.append(nei)
# Append the cloned neighbor to the clone node's neighbors
clone_map[curr].neighbors.append(clone_map[nei])
return clone_map[node]
39
return False
return True
def pacificAtlantic(heights):
if not heights:
return []
m, n = len(heights), len(heights[0])
pacific_reached = [[False] * n for _ in range(m)]
atlantic_reached = [[False] * n for _ in range(m)]
from collections import deque
pac_q = deque()
atl_q = deque()
40
# initialize queues with border cells
for i in range(m):
pac_q.append((i, 0)); pacific_reached[i][0] = True
atl_q.append((i, n-1)); atlantic_reached[i][n-1] = True
for j in range(n):
pac_q.append((0, j)); pacific_reached[0][j] = True
atl_q.append((m-1, j)); atlantic_reached[m-1][j] = True
# BFS from Pacific border
directions = [(1,0),(-1,0),(0,1),(0,-1)]
while pac_q:
r, c = pac_q.popleft()
for dr, dc in directions:
nr, nc = r+dr, c+dc
if 0 <= nr < m and 0 <= nc < n and not pacific_reached[nr][nc] and
heights[nr][nc] >= heights[r][c]:
pacific_reached[nr][nc] = True
pac_q.append((nr, nc))
# BFS from Atlantic border
while atl_q:
r, c = atl_q.popleft()
for dr, dc in directions:
nr, nc = r+dr, c+dc
if 0 <= nr < m and 0 <= nc < n and not atlantic_reached[nr][nc] and
heights[nr][nc] >= heights[r][c]:
atlantic_reached[nr][nc] = True
atl_q.append((nr, nc))
# collect cells that can reach both
result = []
for i in range(m):
for j in range(n):
if pacific_reached[i][j] and atlantic_reached[i][j]:
result.append([i, j])
return result
def findCircleNum(isConnected):
n = len(isConnected)
visited = [False] * n
def dfs(city):
for j in range(n):
if isConnected[city][j] == 1 and not visited[j]:
41
visited[j] = True
dfs(j)
provinces = 0
for i in range(n):
if not visited[i]:
visited[i] = True
dfs(i)
provinces += 1
return provinces
import heapq
def networkDelayTime(times, n, k):
42
graph = {i: [] for i in range(1, n+1)}
for u, v, w in times:
graph[u].append((v, w))
# distances initialized to infinity except start node
dist = {i: float('inf') for i in range(1, n+1)}
dist[k] = 0
# min-heap of (time, node)
heap = [(0, k)]
while heap:
time, node = heapq.heappop(heap)
if time > dist[node]:
continue
for nei, w in graph[node]:
new_time = time + w
if new_time < dist[nei]:
dist[nei] = new_time
heapq.heappush(heap, (new_time, nei))
max_time = max(dist.values())
return max_time if max_time != float('inf') else -1
43
for nei in combos[pattern]:
if nei == endWord:
return steps + 1
if nei not in visited:
visited.add(nei)
q.append((nei, steps + 1))
combos[pattern] = [] # avoid re-processing
return 0
44
return "" # cycle detected
return "".join(order)
Trees
Trees are hierarchical data structures where each node has zero or more children, and typically one node is
designated as the root. Binary trees (each node has up to two children) are common, and binary search
trees (BSTs) are a special kind where the left subtree has smaller values and right subtree larger values than
the node. Common tree problems involve traversals (inorder, preorder, postorder, level-order), depth
calculations, path finding, and recursive solutions given the natural recursive structure of trees.
Common uses/patterns: - Traversals: DFS (preorder, inorder, postorder) often via recursion or stack; BFS
via queue for level order
- Divide and conquer: Many tree problems can be solved by computing results from children (postorder
style) and combining them (e.g., depth, balancing, LCA, path sums)
- Binary Search Tree: Exploit the BST property for search, insert, in-order traversal yields sorted order, etc.
- Tree shape problems: Balanced tree, symmetric tree, invert tree etc., typically straightforward recursive
definitions
- Least Common Ancestor (LCA): classic recursion or parent pointer approach to find shared ancestor in
tree
def invertTree(root):
if not root:
return None
root.left, root.right = invertTree(root.right), invertTree(root.left)
return root
def isSymmetric(root):
def mirror(n1, n2):
45
if not n1 and not n2:
return True
if not n1 or not n2 or n1.val != n2.val:
return False
return mirror(n1.left, n2.right) and mirror(n1.right, n2.left)
return mirror(root, root)
def isBalanced(root):
def height(node):
if not node:
return 0
lh = height(node.left)
if lh == -1:
return -1
rh = height(node.right)
if rh == -1:
return -1
if abs(lh - rh) > 1:
return -1
return max(lh, rh) + 1
return height(root) != -1
46
q.append(node.left)
if node.right:
q.append(node.right)
res.append(level_nodes)
return res
47
Lowest Common Ancestor of a Binary Tree (Medium)
Explanation: For a general binary tree (no ordering), do a DFS. If current node is None or equals p or q,
return it (as a flag that this branch has one of them). Recurse left and right. If both left and right recursion
return non-null, current node is the LCA. If one side is non-null, return that non-null up the chain. This finds
LCA in one pass.
def isValidBST(root):
def validate(node, low, high):
if not node:
return True
if not (low < node.val < high):
return False
return validate(node.left, low, node.val) and validate(node.right,
node.val, high)
return validate(root, float('-inf'), float('inf'))
def serialize(root):
vals = []
def preOrder(node):
if not node:
vals.append("#")
48
else:
vals.append(str(node.val))
preOrder(node.left)
preOrder(node.right)
preOrder(root)
return ",".join(vals)
def deserialize(data):
vals = data.split(",")
it = iter(vals)
def build():
val = next(it)
if val == "#":
return None
node = TreeNode(int(val))
node.left = build()
node.right = build()
return node
return build()
def maxPathSum(root):
max_sum = float('-inf')
def gain(node):
nonlocal max_sum
if not node:
return 0
left_gain = max(gain(node.left), 0)
right_gain = max(gain(node.right), 0)
# Price of the path that goes through the current node
current_path_sum = node.val + left_gain + right_gain
max_sum = max(max_sum, current_path_sum)
# Return the max gain if we continue from current node to parent
return node.val + max(left_gain, right_gain)
gain(root)
return max_sum
49
Subtree of Another Tree (Easy)
Explanation: Check if tree t is a subtree of s . A simple solution is for each node in s , if its value matches
t’s root, then check if starting from that node, the subtree matches t (via a helper function that checks
node-by-node equality). Use DFS on s and the match helper for potential roots. This is O(m * n) worst-
case, but pruning when values don’t match helps average. Alternatively, one can serialize both trees (e.g.,
preorder with markers) and check if t’s serialization is a substring of s’s (unique sentinel to avoid incidental
matches).
Tries
A Trie (prefix tree) is a tree-like data structure that stores strings by sharing common prefixes. Each node
represents a prefix of some of the inserted strings. Tries are particularly useful for quick prefix lookups,
auto-complete suggestions, and implementing algorithms like prefix-based search or word games.
50
Common uses/patterns: - Autocomplete and prefix search: Insert a dictionary of words and then retrieve
all words with a given prefix efficiently.
- Spell checking or word validation: Quickly check if a word or prefix exists in a large dictionary.
- Word games (Boggle, crossword solving): Use a trie to guide DFS so you can prune search when prefix is
not in any word.
- IP routing, T9 predictive text: (Tries can store sequences beyond just letters, e.g., bits or number presses)
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
51
recursion or backtracking for the search function, exploring all branches when encountering '.'.
class WordDictionary:
def __init__(self):
self.root = TrieNode() # reuse TrieNode from above
52
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
res = []
m, n = len(board), len(board[0])
def dfs(r, c, node, path):
char = board[r][c]
if char not in node.children:
return
nxt_node = node.children[char]
path += char
if nxt_node.is_end:
res.append(path)
nxt_node.is_end = False # avoid duplicates
# mark as visited
board[r][c] = '#'
for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
nr, nc = r+dr, c+dc
if 0 <= nr < m and 0 <= nc < n and board[nr][nc] != '#':
dfs(nr, nc, nxt_node, path)
board[r][c] = char # backtrack
# Optional: prune leaf node
if not nxt_node.children:
node.children.pop(char)
for i in range(m):
for j in range(n):
dfs(i, j, trie, "")
return res
53
node = trie
prefix = ""
for ch in word:
if ch not in node.children or node.is_end:
break
node = node.children[ch]
prefix += ch
if node.is_end:
return prefix
return word
return " ".join(replace(word) for word in sentence.split())
def longestWord(words):
trie = TrieNode()
# Insert all words into trie
for word in words:
node = trie
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
longest = ""
# DFS function
def dfs(node, path):
nonlocal longest
# Update longest if current path is a valid word and is longer or
lexicographically smaller
if node.is_end:
if len(path) > len(longest) or (len(path) == len(longest) and path <
longest):
longest = path
else:
return
for ch, child in node.children.items():
dfs(child, path + ch)
# Start DFS from root, with empty path
for ch, child in trie.children.items():
54
dfs(child, ch)
return longest
Dynamic Programming
Dynamic Programming (DP) is an optimization technique for problems that can be broken down into
overlapping subproblems. It typically involves storing results of subproblems (memoization or tabulation)
to avoid recomputation. Key ingredients are optimal substructure (the optimal solution can be constructed
from optimal solutions of subproblems) and overlapping subproblems.
Common uses/patterns: - 1D DP for sequences: e.g., Fibonacci, climbing stairs, house robber (making a
choice at each step), etc. These often use an array dp[i] representing the best value for subproblem ending
at i.
- 2D DP for sequences comparison or grid problems: e.g., Longest Common Subsequence, Edit Distance
(DP table filling), grid unique paths or path sums (dp[i][j] depends on neighbors).
- DP on subsets (bit DP): e.g., Traveling Salesman Problem or bitmask DP for smaller sets.
- DP with two sequences or strings: e.g., LCS, edit distance, typically a matrix with dimensions of input
sizes.
- State machine DP: e.g., stock problems with buy/sell state, DP state can represent something like
"holding stock" vs "not holding".
def climbStairs(n):
if n <= 2:
return n
a, b = 1, 2 # ways to climb to step 1 and 2
for i in range(3, n+1):
a, b = b, a + b # Fibonacci sequence essentially
return b
def rob(nums):
if not nums:
return 0
n = len(nums)
if n == 1:
return nums[0]
prev2, prev1 = nums[0], max(nums[0], nums[1])
55
for i in range(2, n):
curr = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, curr
return prev1
def robCircle(nums):
if not nums:
return 0
n = len(nums)
if n == 1:
return nums[0]
def rob_linear(arr):
prev2, prev1 = 0, 0
for val in arr:
prev2, prev1 = prev1, max(prev1, prev2 + val)
return prev1
# Max of robbing 0 to n-2, or 1 to n-1
return max(rob_linear(nums[:-1]), rob_linear(nums[1:]))
56
method that is O(n log n) using a separate array to store the current LIS tails, but the DP approach is
simpler to implement.
def lengthOfLIS(nums):
if not nums:
return 0
n = len(nums)
dp = [1] * n
best = 1
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
best = max(best, dp[i])
return best
def longestPalindromeSubseq(s):
n = len(s)
dp = [[0]*n for _ in range(n)]
# length 1 substrings are palindromes of length 1
for i in range(n):
dp[i][i] = 1
57
for length in range(2, n+1):
for i in range(n-length+1):
j = i + length - 1
if s[i] == s[j]:
dp[i][j] = 2 + (dp[i+1][j-1] if j-1 >= i+1 else 0)
else:
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
return dp[0][n-1]
def numDecodings(s):
if not s or s[0] == '0':
return 0
n = len(s)
dp = [0] * (n+1)
dp[0] = 1
dp[1] = 1
58
for i in range(2, n+1):
# single digit decode
if s[i-1] != '0':
dp[i] += dp[i-1]
# two-digit decode
two_digit = int(s[i-2:i])
if 10 <= two_digit <= 26:
dp[i] += dp[i-2]
return dp[n]
59
reachable = max(reachable, i + nums[i]) . If reachable >= last index, return True. If we finish loop
and last index not reachable, return False. O(n).
def canJump(nums):
reachable = 0
for i, jump in enumerate(nums):
if i > reachable:
return False
reachable = max(reachable, i + jump)
return True
60