Introduction

Welcome to the 313E Collaborative Study Guide! As the semester progresses, the TAs will be compiling important notes/information for the exams here. The information here is current for spring 2023.

Contributions from students are also welcome! If you would like to add your notes, open up a pull request in our repository: 313e-utaustin/study-guide. Follow the instructions in the README to add a new section or modify an existing one.

If you find a typo or wrong code, please let us know by opening an issue here.

Exam Information

This is the tentative exam schedule:

TestDateTime
Test 1Fri, 10 Feb6:00 PM - 10:00 PM
Makeup 1Sun, 12 Feb10:00 AM - 2:00 PM
Test 2Fri, 24 Mar6:00 PM - 10:00 PM
Makeup 2Sun, 26 Mar10:00 AM - 2:00 PM
Test 3Mon, 24 Apr6:00 PM - 10:00 PM
Makeup 3Tue, 25 Apr10:00 AM - 2:00 PM

The test will conducted through Canvas and Gradescope. There will be 4 or 5 programming questions at or below the difficulty of the previous assignments.

Each question will have an autograder to check for correctness. Unlike the assignments, we will not grade based on style or comments. However, we will still manually review your submissions. If we discover that test cases have been hardcoded into your submission, we will deduct the appropriate amount of points.

There will be an extra credit question that is not related to programming. This will be either a short answer or multiple choice question. Please work on this only if you have passed all the Gradescope test cases.

Exam Tips

Before the exam:

  • Review past assignments and understand the underlying algorithms (for example, merging two intervals in Interval.py).
  • Review important algorithms and data structures covered in class.
  • Be comfortable with Python syntax (this will especially help with debugging).
  • Practice! The only way to get better at thinking logically is to solve more problems.

During the exam:

  • The questions don't need to be solved in a sequential order. Move onto another problem if you're struggling with the current one.
  • Work out some test cases by hand and see if you can identify a pattern.
  • Start with the simplest case and build on top of that (this will especially be helpful for recursion in later exams).
  • Approach the problem from different angles (for example, sorting the list for merging intervals).
  • Write out pseudocode to outline your thought process and identify potential edge cases.
  • Memory constraints are lenient, so feel free to use as many auxiliary data structures as you need -- as long as it's not egregious.

Object-Oriented Programming

Object-oriented programming is a programming paradigm that organizes software around data (objects), instead of functions and logic. You will have have 1 mandatory OOP problem on exam 1, where we will give you the specification for a class for you to implement.

For more information, visit: OOP lecture notes.

Objects

An object is a concept, an abstraction, a thing with sharp boundaries and meaning for an application. It has:

  • Identity - a name
  • State - determined by the values of its attributes
  • Behavior - determined by how the object acts or reacts to requests (messages) from other objects

Classes

A class is a description of a group of objects with common properties (attributes), behavior (operations), relationships, and semantics. An object is an instance of a class.

Classes are an abstraction - given an interface for the class, you do not need to know the implementation details to be able to perform operations on objects of the class.

Operations (Functions)

An operation is a service that can be requested from any object of the class to affect behavior.

An operation can request information from the object's state or update the object's state. The outcome of the operation depends on the current state of the object.

Example

class CookieJar(object):

  # Constructor that accepts 1 integer as a parameter, the number
  # of cookies initially in the jar. This value defaults to 0
  def __init__(self, cookies = 0):
    self.num_cookies = cookies

  # Add a cookie to the jar.
  def add_cookie(self):
    self.num_cookies += 1

  # If there are any cookies left, take a cookie from the jar.
  def take_cookie(self):
    if self.num_cookies >= 1:
      self.num_cookies -= 1
      return "Took a cookie from the cookie jar!"
    return "The cookie jar is empty :("

  # String representation of the cookie jar that describes how many cookies are left.
  def __str__(self):
    return f"There are {self.num_cookies} cookies in the cookie jar."

  # Two cookie jars are equal if they have the same number of cookies.
  def __eq__(self, other):
    return self.num_cookies == other.num_cookies

The self keyword is used to reference the current instance of the class. add_cookie and take_cookie are operations that update the state of an object, while __str__ and __eq__ return information about the state of the object.

Now, here is an example of how the class can be used:

def main():
  jar1 = CookieJar()
  print(jar1)           # There are 0 cookies in the cookie jar.

  jar2 = CookieJar(2)
  print(jar2)           # There are 2 cookies in the cookie jar.
  print(jar1 == jar2)   # False

  jar1.add_cookie()
  print(jar1)           # There are 1 cookies in the cookie jar.

  jar2.take_cookie()
  print(jar1 == jar2)   # True

  print(jar1.take_cookie()) # Took a cookie from the cookie jar!
  print(jar1)           # There are 0 cookies in the cookie jar.

  print(jar1.take_cookie()) # The cookie jar is empty :(
  print(jar1)           # There are 0 cookies in the cookie jar.

Note:

The only piece of state here is the num_cookies attribute of the class. However, to use this class (as shown in main()), there is no need to have any knowledge about it.

The attribute could have been named cookies, cookies_count, n, etc; it could have been stored as a float, or a string, or whatever we liked. None of these implementation details would have affected any of the code in main(), as long as the inputs and outputs of each method of the class is consistent with the specifications.

Data Structures

The following data structures are important for the upcoming exam. Be familiar with their interfaces and know when the appropriate data structure is needed.

Lists

Python lists are mutable and ordered sequence of elements. We use lists when we want to track multiple elements at once and want to preserve their ordering.

Basics

You can instantiate an empty list in two ways:

lst = []
lst = list()

If you would like to initialize a list with a predetermined size and starting values:

default = 0
size = 5

lst = [default for _ in range(size)]
lst = [default] * size

2-dimensional lists are lists of lists. We often use this to represent a matrix or a grid. Here are some ways to instantiate 2-d lists.

# Corresponds to how many lists there are.
rows = 3

# Corresponds to how many elements are in the inner list.
columns = 2

default = 0

matrix = [[default for _ in range(columns)] for _ in range(rows)] 
matrix = [[default] * columns for _ in range(rows)]

Note:

matrix = [[default] * columns] * rows

The above code will not work due to reference issues. The same list will be duplicated across all rows.

Iteration

We can iterate through a 1d list like so:

my_list = [1, 2, 3, 4]

for i in range(len(my_list)):
    print(my_list[i])

We can iterate through a 2d list in multiple ways (as you know from working on WordSearch).

row-column-traversal

  1. Row-order traversal

    for row in range(len(matrix)):
        for col in range(len(matrix[row])):
            # Prints out each element in the matrix.
            print(matrix[row][col])
    
  2. Column-order traversal

    for col in range(len(matrix[0])):
        for row in range(len(matrix)):
            # Prints out each element in the matrix.
            print(matrix[row][col])
    

If you don't care about the index when iterating, you can also use the built-in iterator for lists to go through every element.

for row in matrix:
    for element in row:
        # Prints each element in matrix
        print(element)

Methods

Be familiar with the following list functions:

  • append: adds a new element to the end of the list
  • pop: removes element at the given index
  • insert: inserts an element to the list at the given index
  • sort: sorts the list, can provide a custom lambda for the key
  • extend: adds iterable elemnts to the end of the list

Lists are 0-based indexed, meaning that the first element starts at index 0 instead of 1:

>>> my_list = [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]

>>> my_list[0]
1

To get the elements near the back of the list, we can use negative indices: -1 corresponds to the last element, -2 the second to last, etc...

>>> my_list[-1]
5

We can slice a list by using the following syntax:

>>> my_list[0:2]
[1, 2]

Notice that the ending index is exclusive (this is very similar to the range function).

For more information, see: documentation.

Sets

Python sets are mutable, unordered sequence of elements that are unique and iterable. We use sets when we are interested in guaranteeing the uniquess of elements.

Basics

You can instantiate an empty set like so:

my_set = set()

If you would like to initialize a set with specific values:

values = [1, 2, 3, 4]
my_set = set(values)

If you pass in a list with duplicate values to the constructor, the set will ignore them.

values = [1, 1, 1, 1]
my_set = set(values) # `my_set` here only contains a single element: 1

Note:

Sets guarantee efficient uniqueness by hashing elements that are added to it. This means you can only add elements to the set that are hashable (by default in Python, this includes ints, floats, strings). You can not add a list to a set, even if it contains int, because the list is mutable. You can add a tuple of ints to a set because a tuple is immutable and an int is hashable.

If you have a custom class you would like to add to a set, you would need to implement the __hash__ function.

Iteration

We can iterate through a set by using the built in iterator.

my_set = set([1, 2, 3, 3, 5])

# This prints out 1, 2, 3, and 5 (order not guaranteed).
for elem in my_set:
    print(elem)

Note:

When iterating through a set, the order in which the values are given to you is not deterministic. This means that the above code could possibly print out 1, 2, 3, 5 or 5, 3, 1, 2. There's no way of knowing since the user doesn't know how the set is storing the values internally.

Methods

Be familiar with the following set functions:

  • add: adds a new element to the set, O(1) time complexity.
  • remove: removes a specific element from the set, pass in the value. O(1) time complexity.
  • union: returns the union of the two sets. O(n) time complexity.
  • intersection: returns the intersection of two sets. O(n) time complexity.

Lists are 0-based indexed, meaning that the first element starts at index 0 instead of 1:

Note:

You can not index into a list because the set is unordered. The square bracket notation on a set will throw an error.

To check if an element is in set, we can use in.

my_set = set([1, 2, 'dog'])

print(1 in my_set) # Prints True
print('cat' in my_set) # Prints False

Checking if an element is in a set is a O(1) operation.

For more information, see: documentation.

Dictionaries

Python dictionaries are a collection of a key-value stores. In Python 3.6+, the keys of the dictionaries are preserved in insertion order. The keys of the dictionary must be unique but the values do not have to be. We use a dictionary when we want to associate a specific key with a value and allow for fast lookup later one (a real life analogy would be a phonebook).

Basics

You can instantiate an empty dictionary in two ways:

my_dict = {}
my_dct = dict()

We can add to a dictionary in two ways:

my_dict['bob'] = 10
my_dict.update({'alice' : 11})

# `my_dict` is now:
# { 'bob': 10, 'alice': 11 }

We can retrieve information from a dictionary in two ways:

print(my_dict['bob']) # prints out 10.
print(my_dict.get('alice')) # prints out 11.

The dictionary keeps track of the last value added to the key:

my_dict['bob'] = 10
my_dict['bob'] = 12

print(my_dict.get('bob')) # prints out 12.

Now let's take a look at an example problem to understand why dictionaries are a helfpul data structure.

Example Usage

We are given an array of earnings in an array like so:

earnings = [('Bob', 1), ('Alice', 2), ('Bob', 5), ('Bob', 10), ('Alice', 11) ...]

And now we want to find the total earnings per individual.

A solution without dictionaries might look like:

alice = 0
bob = 0
for person, earning in earnings:
    if person == 'Alice':
        alice += earning
    elif person == 'Bob':
        bob += earning

print(alice)
print(bob)

This might be fine if our array only had values from Bob and Alice. However, what if we added new people? We would be forced to add a new conditional for every single person (which obviously isn't a scalable solution).

Let's look at the solution now using dictionaries.

earning_tracker = {}

for person, earning in earnings:
    earning_tracker[person] = earning_tracker.get(person, 0) + earning

print(earning_tracker)

With the dictionary, we don't need to worry about each individual person; the dictionary does the tracking and updating for us.

Note:

When using get, the second parameter is a default value. The default value is returned if the key (first parameter) does not exist in the dictionary.

Iteration

The default iterator for a dictionary returns the keys.

my_dict = {'Alice': 10, 'Bob': 5}

for key in my_dict:
    print(key) # Prints the key (person name)
    print(my_dict[key]) # Prints the value (earning)

If we want both the key and the value, we can use the items function. items yields the key and value as a tuple.

my_dict = {'Alice': 10, 'Bob': 5}

for key, value in my_dict.items():
    print(key) # Prints the key (person name)
    print(value) # Prints the value (earning)

There are other ways to iterate through a dictionary (for example using the keys and values function), but the above should be sufficient for this course.

Methods

Be familiar with the following set functions:

  • update: Updates the dictionary based on the dictionary passed in.
  • get: Gets a specific value by the passed in key (or a default value if the key does not exist), O(1) time complexity.
  • pop: Removes the key-value pair based on the key passed in, O(1) time complexity

To check if a key is present in a dictionary, we can use in:

my_dict = {'Alice': 10, 'Bob': 5}

print('Alice' in my_dict) # prints True
print('John' in my_dict) # prints False

For more information, see: documentation.

Practice Problems

The following practice problems may be helpful for your preparation. You can expect the exam questions to be around the same level or a little bit harder.

Some tips:

  • Spend around 30 - 50 minutes working on a problem. If you are unable to make any substantial progress, look at a hint or the solution. Afterwards, make sure you write the implementation yourself.
  • If you are able to solve the problem, try to understand other possible solutions.

For more information, visit: official study guide

Worked Problems

LeetCode: Rotate String

Solution

We can generate all rotations of the string by concatenating the two strings together and checking if the substring is present.


class Solution:
def rotateString(self, s: str, goal: str) -> bool:
all_rotations = s \* 2
return len(s) == len(goal) and goal in all_rotations


LeetCode: Two Sum

Solution

The important realization here is that each number's complement (target - number) is unique. Using this information, we can leverage the dictionary's uniqueness key property to store the complement and the index as a key value pair.


class Solution:
  def twoSum(self, nums: List[int], target: int) -> List[int]:

      mapping = {}
      for idx, num in enumerate(nums):

          # We found the complement.
          if num in mapping:
              return [idx, mapping[num]]

          # Store the complement and its index.
          mapping[target - num] = idx


LeetCode: Best Time to Buy and Sell Stock

Solution

The most difficult part of this is ensuring that we sell after buying the sock (i.e, the peak must be after the trough). To do this, let's consider every potential day as a selling day and keep track of the minimum. This way, we guarantee that we've passed the minimum before we sell.


class Solution:
def maxProfit(self, prices: List[int]) -> int:
max_profit = 0

      # Initialize minimum to Infinity.
      minimum = math.inf

      for price in prices:
          minimum = min(price, minimum)
          max_profit = max(max_profit, price - minimum)
      return max_profit


LeetCode: Set Matrix Zeroes

Solution

We can keep track of the rows and columns to set to 0 by using a set. There is another solution which has constant space complexity, but for our exam purposes the below solution will be fine. Submissions will not be graded on efficiency for this exam.


class Solution:
    def setZeroes(self, matrix: List[List[int]]) -> None:
        """
        Do not return anything, modify matrix in-place instead.
        """

        rows = set()
        cols = set()

        for i in range(len(matrix)):
            for j in range(len(matrix[i])):
                if matrix[i][j] == 0:
                    rows.add(i)
                    cols.add(j)

        def set_row(row, matrix):
            for j in range(len(matrix[row])):
                matrix[row][j] = 0

        def set_col(col, matrix):
            for i in range(len(matrix)):
                matrix[i][col] = 0

        for row in rows:
            set_row(row, matrix)

        for col in cols:
            set_col(col, matrix)


LeetCode: Palindromic Substrings

Solution

A palindrome is symmetrical. Knowing this, we can consider every potential starting midpoint for the palindrome and expand outwards to count the number of valid palindromes. We'll also need to handle an edge case for even-lengthed palindromes.


class Solution:
    def countSubstrings(self, s: str) -> int:

        size = len(s)

        def expand_outwards(s, left, right):

            count = 0

            # Expand outwards while left and right pointers are the same character.
            while (left >= 0 and right < len(s) and s[left] == s[right]):
                left -= 1
                right += 1
                count += 1

            return count

        total = 0
        for center in range(size):

            # For odd-lengthed palindromes.
            total += expand_outwards(s, center, center)

            # For even-lengthed palindromes.
            total += expand_outwards(s, center - 1, center)
        return total


LeetCode: Find Winner on a Tic Tac Toe Game

Solution

There are many possible solutions to this problem. A brute force approach is completely acceptable. The below approach is a more optimized/clean solution. The main idea is to keep track of all 8 possible win conditions for each player (3 rows + 3 columns + 2 diagonals). The win conditions are kept track of using a counter. Whenever a counter reaches 3, that player has won.


class Solution:
    def tictactoe(self, moves: List[List[int]]) -> str:

        # Keep track of all 8 possible win conditions for each
        # player.
        winner_a = [0] * 8
        winner_b = [0] * 8

        # Iterate through the moves.
        for idx, pair in enumerate(moves):

            # Determine who the current player is.
            arr = winner_a if idx % 2 == 0 else winner_b
            x, y = pair

            # Increment row 'win' counter.
            arr[x] += 1

            # Increment col 'win' counter.
            arr[y + 3] += 1

            # Increment diagonal 'win' counter.
            if x == y:
                arr[6] += 1

            # Increment anti-diagonal 'win' counter.
            if x == 2 - y:
                arr[7] += 1

        # Check all win conditions.
        for i in range(8):
            if winner_a[i] == 3:
                return "A"
            if winner_b[i] == 3:
                return "B"

        return "Draw" if len(moves) == 9 else "Pending"


LeetCode: Surface Area of 3D Shapes

Solution

Consider each stack of cubes as a standalone stack, and then subtract the sides that are covered by the surrounding stacks.


class Solution:
    def surfaceArea(self, grid: List[List[int]]) -> int:

        # Function for checking if in bounds.
        in_bounds = lambda r, c: 0 <= r < len(grid) and 0 <= c < len(grid[0])

        total = 0

        # Directions for left, up, right, and down.
        dirs = [
            (1, 0),
            (0, 1),
            (-1, 0),
            (0, -1)
        ]

        total = 0
        for i in range(len(grid)):
            for j in range(len(grid[i])):

                # Ignore if there is a hole here.
                if grid[i][j] == 0:
                    continue

                # 2 for the top-down faces, 4 for the lateral sides.
                sa = 2 + 4 * grid[i][j]

                for delta_x, delta_y in dirs:
                    r = i + delta_x
                    c = j + delta_y

                    if not in_bounds(r, c):
                        continue

                    # Subtract neighboring faces.
                    sa -= min(grid[r][c], grid[i][j])
                total += sa

        return total

Additional Problems

Algorithms

The following algorithms are important for the upcoming exam. Be familiar with their implementation and why the algorithm works. For certain questions, you may need to adapt the algorithm to fit the specifications of the problem.

Sorts

Sorting algorithms rearrange a list of elements based on a comparator. There exists a variety of sorting algorithms, some popular ones include:

  • Selection Sort
  • Insertion Sort
  • Quick Sort
  • Merge Sort
  • Counting Sort
  • Radix Sort

Here's a cool video visualizing the different kinds of sorting algorithms.

For the exam, you should be familiar with Selection Sort, Insertion Sort, and the merging step of Merge Sort.

Selection Sort

Main Idea: Select the smallest element on each iteration and place it towards the beginning.

Sample Implementation:


def selection_sort(array: List[int]) -> None:
    """
        Sorts the `array` in place in ascending order.
    """

    size = len(array)

    for pointer_one in range(size):

        min_idx = pointer_one

        for pointer_two in range(pointer_one + 1, size):
            
            # Found something that is smaller, update `min_idx`.
            if array[min_idx] < array[pointer_two]:
                min_idx = pointer_two
        
        # Put minimum element in correct place.
        array[pointer_one], array[min_idx] = array[min_idx], array[pointer_one]

Source

Time Complexity: O(n2)

Insertion Sort

Main Idea: On each iteration, expand the sorted block by placing an unsorted element in its appropriate place.

Sample Implementation:


def insertion_sort(array: List[int]) -> None:
    """
        Sorts `array` in ascending order in place.
    """

    for step in range(1, len(array)):
        key = array[step]
        j = step - 1
        
        # Compare key with each element on the left of it until an element smaller than it is found
        # For descending order, change key<array[j] to key>array[j].        
        while j >= 0 and key < array[j]:
            array[j + 1] = array[j]
            j = j - 1
        
        # Place key at after the element just smaller than it.
        array[j + 1] = key

Source

Time Complexity: O(n2)

Merge Sort

Main Idea: Split list in half, sort on those halves and merge.

Sample Implementation (Merging Step):


def merge_halves(half_one: List[int], half_two: List[int]) -> List[int]:
    """
        Merges two halves of a list (in ascending order). Returns
        a new list.
    """

    h_one_idx = 0
    h_two_idx = 0
    ret = []

    # Iterate until we reach the end of one of the lists
    while h_one_idx < len(half_one) and h_two_idx < len(half_two):
        elem_one = half_one[h_one_idx]
        elem_two = half_two[h_two_idx]

        # Case 1: element one is smaller,
        # add it to the list and advance
        # the pointer for list one.
        if elem_one < elem_two:
            ret.append(elem_one)
            h_one_idx += 1
        # Case 2: element two is smaller,
        # do the same for list two.
        else:
            ret.append(elem_two)
            h_two_idx += 1

    # In case the lists do not have the same length.
    def get_leftover(leftover_list, idx, ret):
        while idx < len(leftover_list):
            ret.append(leftover_list[idx])
            idx += 1
    
    get_leftover(half_one, h_one_idx, ret)
    get_leftover(half_two, h_two_idx, ret)

    return ret

Time Complexity: O(nlogn) for entire sort, O(n) for merging step

Binary Search

Binary Search is one of the most important algorithsm in computer science. Given a sorted search space, we can find our desired element in O(log n) time.

Sample Implementation


def binary_search(array: List[int], find: int) -> bool:
    """
        Searches on `array` for the specified element `find`.
        Requires that `array` be sorted beforehand.

        Returns True if found, False otherwise.
    """

    # Initializing our pointers to the start and end of the array.
    low = 0
    high = len(array) - 1

    while low <= high:

        mid = low + (high - low) // 2

        # Found our element, return.
        if array[mid] == x:
            return True

        # Construct search space since we know
        # the list is sorted.
        if array[mid] < x:
            low = mid + 1
        else:
            high = mid - 1

        return False

Note:

There's a famous overflow bug in the original binary search implementation with (high + low) // 2. This isn't as big of an issue in Python as it is in C or C++, but it is now generally accepted that the correct calculation for mid is low + (high - low) // 2.

Practice Problems

The following practice problems may be helpful for your preparation. You can expect the exam questions to be around the same level or a little bit harder.

Some tips:

  • Spend around 30 - 50 minutes working on a problem. If you are unable to make any substantial progress, look at a hint or the solution. Afterwards, make sure you write the implementation yourself.
  • If you are able to solve the problem, try to understand other possible solutions.

For more information, visit: official study guide

Worked Problems

LeetCode: Nim

Solution

Let's start at the simplest cases and see if we can find a pattern:

  • n = 1, first player wins
  • n = 2, first player wins
  • n = 3, first player wins

For n = 4, let's consider all the cases:

  • picks 1 => n = 3, second player wins by taking 3
  • picks 2 => n = 2, second player wins by taking 2
  • picks 3 => n = 1, second player wins by taking 1

We can see that for each move, we have a new problem with a different number of n and the player's positions swapping.

We might initially come up with a dynamic programming solution like this:

class Solution:
  def canWinNim(self, n: int) -> bool:
      # if n = 1, 2, 3, we can win immediately.
      if n < 4:
          return True

      # Invariant: if dp[i] is True, a player can win with i stones no matter what.
      # initialize from 0 to n, ignore the first 0.
      dp = [None for i in range(n + 1)]

      # base cases:
      for i in range(1, 4):
          dp[i] = True
      dp[4] = False

      # Consider all cases from 4 to n.
      for i in range(5, n + 1):
          # Loop back to consider cases for taking 1 - 3 stones
          for j in range(1, 4):
              # If we take j stones, the other player will have i - j stones.
              if not dp[i - j]:
                  # If there exists a j such that other player cannot winn, then this
                  # player can win.
                  dp[i] = True
                  break
          else:
              # for loop terminated without breaking, meaning it did not find a
              # possible win condition. It is impossible to win with i stones left.
              dp[i] = False

      return dp[n]

Can we do better? If you manually evaluted more points for n (or examined the contents of the dynamic programming array), you'll find something interesting. Every 4th element is false (meaning it's impossible for the first player to win).

We can shorten this to a simple check:

class Solution:
  def canWinNim(self, n: int) -> bool:
      return n % 4 != 0

Follow up: can you prove this using induction?


LeetCode: Search a 2D Matrix

Solution

The problem specifications hints towards a binary search. However, since this a matrix, we must modify our approach. We can think of the matrix as a large array with the rows stacked on top of each other. Knowing this, we can set our low and high to the appropriate bounds: 0 and rows * columns - 1, respectively. To get the row, we can use mid // columns. To get the column we can use mid % columns.

class Solution:
    def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
        rows = len(matrix)
        cols = len(matrix[0])
        low = 0
        high = rows * cols - 1
        while low <= high:
            mid = low + (high - low) // 2
            row = mid // cols
            col = mid % cols
            val = matrix[row][col]
            if val == target:
                return True
            if val < target:
                low = mid + 1
            else:
                high = mid - 1
        return False

LeetCode: Remove Nth Node from End of List

Solution

In a singly linked list, only the forward connections are stored in the node. To get the backwards connections (or the previous node), we can use recursion. The back/previous connections are stored implicitly on the stack.

In the solution below, the helper function returns two values every time it's called: the new next node for the previous node and the previous node's n-th position from the end.

On every function call, we update the current node's next. The current node's next is unchanged if the next node should not have been deleted.

The time complexity is O(n) because we do one full traversal of the linked list.


# Definition for singly-linked list.
# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
  def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:

      def helper(node, place_to_remove):

          # reached the end, let the previous node
          # know that it's first
          if not node:
              return None, 1

          node_next, place = helper(node.next, place_to_remove)

          # update connection if necessary
          node.next = node_next

          # delete this node by returning this node's next to be updated
          updated_node = node.next if place == place_to_remove else node

          return updated_node, place + 1

      return helper(head, n)[0]


LeetCode: Valid Parantheses

Solution

Again, with problems like this it is good to write out some manual cases and manually identify if they are valid (while doing this look for ways to formalize the patterns you observe into code):

  • ((())) => OK
  • ((((() => WRONG
  • {(}) => WRONG
  • (())()) => WRONG
  • )() => WRONG

Some observations:

  • Closing brackets must match with the opening brackets.
  • Closing bracket pairs with the closest open bracket to its left.
  • Each closing bracket must have an opening bracket to pair with.

Knowing this, we want to use a stack because we are concerned with the ordering of the open parantheses -- more specifically, the most recent open parantheses we've found before.

In our algorithm, whenever we come across an open parantheses, we add it to our stack of unclosed parantheses. Whenever we come across a closing parantheses, we want to check if the most recent open parantheses that we added matches it, if it does pop the open paranthese from the stack (it's no longer unclosed).

The string is valid if our stack is empty at the end. This means we've cloesd all of our unopened parantheses.


class Solution:
    def isValid(self, s: str) -> bool:


        stack = []
        close_to_open = { ')' : '(', ']' : '[', '}' : '{'}

        for char in s:
            # Must be an opening bracket
            if char not in close_to_open:
                stack.append(char)

            # Closing bracket
            else:
                # No opening bracket to match.
                if not stack:
                    return False

                # Opening bracket exists but doesn't match
                if close_to_open[char] != stack.pop():
                    return False

        return not stack


LeetCode: Letter Combinations Phone Number

Solution

Create a mapping of the digits to all the possible letters. Iterate through all of them to consider all possibilities. Add to the auxiliary data structure, make the recursive call, then backtrack by popping.


class Solution:
    def letterCombinations(self, digits: str) -> List[str]:
        saved = {
            '1': [],
            '2': ['a', 'b', 'c'],
            '3': ['d', 'e', 'f'],
            '4': ['g', 'h', 'i'],
            '5': ['j', 'k', 'l'],
            '6': ['m', 'n', 'o'],
            '7': ['p', 'q', 'r', 's'],
            '8': ['t', 'u', 'v'],
            '9': ['w', 'x', 'y', 'z']
        }

        # edge case
        if not digits:
            return []

        def helper(result, digits, index, acc):
            if index == len(digits):
                result.append(''.join(acc))
                return

            combos = saved[digits[index]]
            for combo in combos:
                acc.append(combo)
                helper(result, digits, index + 1, acc)
                acc.pop()

        builder = []
        helper(builder, digits, 0, [])
        return builder


LeetCode: House Robber

Solution

At every index, we have two choices: Rob this house or don't. If we rob this house, we have a smaller subproblem that excludes the nearby houses. If we don't, we have a smaller subproblem that excludes the current house. At first, a brute force approach might seem appropriate, but let's see if we can optimize this even further with memoization.

Let's define DP[i] as the maxmimum money we can rob by considering all houses from indices 0 to i. Our recurrence can be something like: DP[i + 1] = max(DP[i], DP[i - 1] + money[i]). At index i + 1, we can either pick this house and add the optimal subproblem of i - 1 or we can exclude this house and pick the optimal subproblem of i.


class Solution:
    def rob(self, nums: List[int]) -> int:
        if len(nums) == 1:
            return nums[0]

        dp = [0] * len(nums)

        # base cases
        dp[0] = nums[0]
        dp[1] = max(dp[0], nums[1])

        for i in range(2, len(nums)):
            dp[i] = max(dp[i-2] + nums[i], dp[i-1])

        # last index considers all the homes
        return dp[-1]

Additional Problems

Practice Problems

The following practice problems may be helpful for your preparation. You can expect the exam questions to be around the same level or a little bit harder.

Some tips:

  • Spend around 30 - 50 minutes working on a problem. If you are unable to make any substantial progress, look at a hint or the solution. Afterwards, make sure you write the implementation yourself.
  • If you are able to solve the problem, try to understand other possible solutions.

For more information, visit: official study guide

Note:

The 3rd exam will be structured differently from the other two. There will be a short answer section in addition to the coding portion. For the coding portion, choose three out of the five problems to solve. The short answer section will cover a simulation of a graph algorithm discussed in class (Prim's, Dijkstra's, etc...) as well as a tabulation DP problem.

Worked Problems

LeetCode: Count Good Nodes in Binary Tree [Trees]

Solution

An important observation is that the path from the root to any node in the tree is unique (due to the fact that every node has exactly one parent). We can traverse the tree using DFS and build our path accordingly. We don't actually need to store the entire path since we are only concerned about the maximum along the path.

A possible recursive solution:

class Solution:
    def goodNodes(self, root: TreeNode) -> int:
        def helper(node, maxi):
            """
                Returns the number of good nodes for the subtree at `node`.

                `node`: root of the subtree to evaluate
                `maxi`: the maximum value from the root to the parent of `node`
                        (inclusive).
            """
            # null node, return 0 for no good nodes.
            if not node:
                return 0

            count = 0

            # check if this node is good.
            maxi = max(maxi, node.val)
            if maxi == node.val:
                count += 1
            
            # add contributions from left and right.
            count += helper(node.left, maxi)
            count += helper(node.right, maxi)
            
            return count
        return helper(root, -math.inf)

A possible iterative solution (same algorithm):


class Solution:
    def goodNodes(self, root: TreeNode) -> int:
        
        count = 0
        stack = []
        
        stack.append((root, -math.inf))
        
        while stack:
            cur, maxi = stack.pop()
            
            if not cur:
                continue
            
            maxi = max(maxi, cur.val)
            if maxi == cur.val:
                count += 1
            
            stack.append((cur.left, maxi))
            stack.append((cur.right, maxi))
        
        return count


LeetCode: Keys and Rooms [Graphs]

Solution

The problem is essentially asking if the graph is connected. Using DFS or BFS from the starting room (room 0), we can visit all the rooms reachable. Once we've completed our graph traversal, we can check if we've reached every room by comparing the length of the visited set to the number of rooms.

The deque class used here is a double-ended queue. We use this class for faster append and pop operations. popLeft() is equivalent to pop(0) in that they both pop the first element from the queue, however, pop(0) executes in N2 runtime complexity due to the shifting of elements whereas popLeft() happens in constant time.


from collections import deque

class Solution:
  def canVisitAllRooms(self, rooms: List[List[int]]) -> bool:
      
      # use queue for breadth first search.
      queue = deque()
      queue.append(0)

      # keep track of visited rooms.
      visited = set()
      while queue:
          cur = queue.popleft()

          # already visited, ignore.
          if cur in visited:
              continue
          visited.add(cur)
          
          room = rooms[cur]

          # add rooms reachable from here.
          for key in room:
              queue.append(key)
      
      return len(visited) == len(rooms)


LeetCode: Maximum Subarray [Dynamic Programming]

Solution

The brute force solution solves this in quadratic time (consider every single possible contiguous subarray). We can speed this up using dynamic programming/memoization: rather than recomputing the sums every time, we can store it on every iteration. Additionally, the problem is only asking for the maximum sum, so we don't need to store every sum for every possible pair of start/end indices.

Let's define DP[i] as the maximum subarray sum ending at index i (inclusive). When we consider the i + 1th index, we have two options:

  1. Include the previous maximum sum (we can guarantee it is contiguous because it ends on the index before).
  2. Don't include the previous maxmium sum, only include the current number.

Now we can find the maximum subarray sum in linear time.

A potential solution:


class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        
        dp = [0] * len(nums)
        
        best = -math.inf
        for i, num in enumerate(nums):
            if i == 0:
                dp[i] = num
            else:
                dp[i] = max(nums[i], dp[i - 1] + nums[i])
            best = max(dp[i], best)
        return best

This algorithm is known as Kadane's algorithm.

Follow-up: can you optimize this to be O(1) in space?


LeetCode: House Robber III [Trees + Dynamic Programming]

Solution

At a single node we have two options:

  1. Rob the node, don't rob the children of the node.
  2. Don't rob the node, consider robbing the children of the node.

For each node, we memoize the best/maxmium profit gained from each of the two conditions (with and without) and then utilize the recursive substructure of the tree to propogate the values up to the root.

The trickiest part is in the second case where we don't rob the code -- we need to consider all four combinations between the maximums including the left and the right and the maximums not including the left and the right. It is not always optimal to rob the children of the node in the second case.

See comments in the code for more information.


# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right

class Solution:
    def rob(self, root: Optional[TreeNode]) -> int:
        
        def helper(node):
            if node is None:
                return 0, 0
            
            with_l, without_l = helper(node.left)
            with_r, without_r = helper(node.right)
            
            # if we rob this house, we have to pick the ones without.
            with_node = node.val + without_l + without_r
            
            # all possible combinations of with_l, without_l and with_r, without_r
            # these are all possible if we don't rob the current node.
            possibles = [x + y for x in [with_l, without_l] for y in [with_r, without_r]]
            
            return with_node, max(possibles)
        
        
        return max(helper(root))


Kattis: Knight Jump [Graphs]

Solution

We can model this problem like a graph. The nodes are the squares of the board. An edge exists between two nodes if a knight can jump from one node to another. We can use either DFS or BFS to determine if the target square is reachable. However, the problem stipulates that we need to find the minimum number of moves.

In order to find the minimum, we must use BFS. Remember that from a starting vertex, BFS finds the shortest unweighted path to every other connected vertex. To return the number of moves required, we can store the distance as part of the BFS state along with the node.


from collections import deque

# single integer
inp = lambda: int(input())

# string list
strl = lambda: list(input().strip())

# in bounds check for 2d array
in_bounds = lambda x, y, grid: x >= 0 and x < len(grid) and y >= 0 and y < len(grid[0])


def valid(coord, board):
    """ Checks if coordinate is valid. """
    return in_bounds(*coord, board) and board[coord[0]][coord[1]] != '#'

def solve(board):
    start = None

    # finding the start.
    for i, row in enumerate(board):
        for j, elem in enumerate(row):
            if elem == 'K':
                start = (i, j)

    
    assert start is not None

    queue = deque([(start, 0)])
    visited = set()

    # all different knight movements.
    movement = [
        (2, 1),
        (2, -1),
        (-2, 1),
        (-2, -1),
        (1, 2),
        (1, -2),
        (-1, 2),
        (-1, -2)
    ]

    # perform bfs.
    while queue:
        cur, dist = queue.popleft()

        # reached the target, return `dist` immediately.
        if cur[0] == 0 and cur[1] == 0:
            return dist

        # already visited or not a valid square.
        if cur in visited or not valid(cur, board):
            continue

        visited.add(cur)

        # add next possible moves.
        for move in movement:
            delta = (
                (cur[0] + move[0], cur[1] + move[1]),
                dist + 1
            )
            
            queue.append(delta)
    
    return -1

if __name__ == '__main__':
    
    n = inp()
    board = []
    
    for _ in range(n):
        board.append(strl())
    
    print(solve(board))


LeetCode: Decode Ways [Dynamic Programming]

Solution

The first important observation is that the encoded numbers are at most two digits. That means for a given index i, we need to consider the single digit number (character at i) as well as the double digit number (substring i - 1 to i).

Once we've fixed a single number, we need to consider the number of ways to decode for the remaining substring. A possible recursive/brute force solution might add the following two numbers together for string s.

  1. Fixed single digit number at i: decode_ways(s[:i])
  2. Fixed double digit number [i-1:i+1]: decode_ways([s:i-1])

Notice that we are always recomputing the decode ways for the beginning of the string, we can use bottom-up memoization to speed up our algorithm.


class Solution:
    def numDecodings(self, s: str) -> int:
        
        # all valid encodings.
        valid_numbers = set([str(i) for i in range(1, 27)])
        
        # dp[i] stores number of ways to decode for substring up to index i
        dp = [0] * (len(s) + 1)
        
        # base cases
        dp[0] = 1        
        dp[1] = 1 if s[0] in valid_numbers else 0

        if len(s) == 1:
            return dp[1]
        
        for i in range(2, len(s) + 1):
            
            # separate index for string since i is exclusive
            str_idx = i - 1
            
            # consider the case where we fix a single digit number
            dp[i] += dp[i - 1] if s[str_idx] in valid_numbers else 0

            # consider the case where we fix a double digit number
            dp[i] += dp[i - 2] if s[str_idx-1:str_idx+1] in valid_numbers else 0
        
        return dp[len(s)]


LeetCode: Validate BST [Trees]

Solution

An important binary search tree property is that the entire subtree of the right must be greater than the root node and the entire subtree of the left must be less than the root node. It's not enough to check the children in order to validate the BST.

When inserting a node into the BST, there is only one place where the node can be placed (according to our BST algorithm). Intuitively, each node acts as a range check for the node being inserted. We can use this same principle in order to validate the BST.


# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right

class Solution:
    def isValidBST(self, root: TreeNode) -> bool:
        return self.helper(root, -math.inf, math.inf)

    def helper(self, node: TreeNode, min_b: int, max_b: int) -> bool:
        if node is None:
            return True

        # falls within proper range
        ok = node.val >= min_b and node.val <= max_b
        
        # check left and right, left max is node.val - 1, right min is node.val + 1
        return ok and self.helper(node.left, min_b, node.val - 1) and self.helper(node.right, node.val + 1, max_b)

Additional Problems