# Welcome!

A collection of notes for preparing for coding interviews, created by Jia Hao, a computer science undergraduate from the National University of Singapore.

> Internship hunting and interview preparation takes a lot of practice and a whole lot of luck. While it is easy to feel discouraged when you receive rejection after rejection, it is keep your head high regardless and to continue pushing onward.&#x20;

{% hint style="info" %}
I have posted a blog post about the "meta-process" of preparing for technical interviews that you can find here: <https://blog.woojiahao.com/post/technical-interview-systems/>
{% endhint %}

## Who am I?

My name is Jia Hao. I am a computer science undergraduate from the National University of Singapore.&#x20;

Before May 2023, I was a technical interviewing newbie. Although I had years of experience developing software, I was barely able to solve easy problems. This is because interviewing requires a completely different set of skills. While I was very fortunate to have still landed an internship for summer 2023, I realized that my interviewing skills were lacking and that spurred me to start taking interview preparation seriously.

I devised methods of studying, combining hundreds of resources to develop a strong foundation in many aspects of technical interviews. Thankfully, my effort paid off and I was able to land internship offers from companies like Stripe, Citadel, Google, and Palantir.

I am creating this guide to consolidate my learning and to share this learning with others. I hope that this guide can help accompany you on your own technical interviewing journey and that you are able to land the internship/job of your dreams.

## Using this guide

{% hint style="info" %}
I will not be covering fundamental concepts like how arrays work as those are assumed knowledge
{% endhint %}

I recommend tackling each topic one at a time and focusing on understanding the concepts behind each question you solve. You can find the list of questions and study plan under [Study Plan](/getting-started/study-plan).&#x20;

Once you have felt like you have mastered the fundamentals, feel free to move on to working on problem lists like [Neetcode](https://neetcode.io/roadmap) and [Grind75](https://www.techinterviewhandbook.org/grind75).

This guide is not all powerful and you will not become a technical interview expert in two weeks. It is important that you start your preparations early. I have included my timeline for securing my internships for summer 2024: [Summer 2024 Timeline](/getting-started/summer-2024-timeline).

## Contact me

{% hint style="warning" %}
Unfortunately, due to being too busy with work, I am unable to provide personalized advice/coaching about interviewing. If you do have any questions, you are free to drop me an email, but I do not have any SLAs in place to respond to them. Thank you for your understanding!
{% endhint %}

If you wish to contribute to this guide, have found errors in my notes, or have questions about this guide or for me, please drop me an email at <woojiahao1234@gmail.com>.

If you have used this guide to land yourself an internship or just enjoyed reading it, feel free to drop me an email as well, I would love to learn how others are using this guide! You may also consider starring the [Github repository](https://github.com/woojiahao/interviews).

## Acknowledgements

The basis of this guide is inspired by the [Tech Interview Handbook](https://www.techinterviewhandbook.org/) written by [Yangshun](https://www.linkedin.com/in/yangshun/?originalSubdomain=sg). I started my journey using that guide and have combined it with many other resources to create this guide. I highly recommend giving it a read as it covers many aspects of interviewing that I did not cover.


# Study Plan

These are the questions I used for revising each topic, try to solve each topic completely and understand the core components before moving on to the next topic

{% hint style="info" %}
These questions were compiled from the [Tech Interview Handbook](https://www.techinterviewhandbook.org/algorithms/study-cheatsheet/) across its various topics. The order of studying and study plan is also inspired by the [Tech Interview Handbook](https://www.techinterviewhandbook.org/coding-interview-study-plan/#week-1---4-topical-study--practice).
{% endhint %}

## Preface

It took me about **a month** to complete the study plan (not including the dynamic programming roadmap). It's important to preface that I had completed a data structures & algorithms (DSA) course right before starting my revision so a lot of the fundamentals was fresh in my head. You may find yourself spending more/less time than I did and that is alright.

{% hint style="info" %}
Check out my dedicated blog post for the key aspects of preparing for technical interviews: <https://blog.woojiahao.com/post/technical-interview-systems/>
{% endhint %}

If you are not familiar with fundamental DSA, it is recommended that you read a book on DSA before diving into LeetCode as it will help you better understand how to apply the data structures/algorithms to the problems. You can find my recommendations in the [FAQs](/getting-started/faqs).

## How to use?

Feel free to refer to the associated sections about each topic (in the left sidebar) to learn the techniques and patterns commonly associated to questions in that topic.

While it is good if you are "discover" these patterns yourself, having them formally introduced can help you to structure your thinking going into each problem.

### Notation

* :star: : requires LeetCode premium
* :triangular\_flag\_on\_post: : problems that I found incredibly tricky and often tapped out

### Duplicate questions

My recommended approach for duplicate questions is to try the question again but using the associated topic to solve it, rather than glossing over them again.

## Week 1

<details>

<summary>Array</summary>

* [ ] Two Sum
* [ ] Best Time to Buy and Sell Stock
* [ ] Product of Array Except Self
* [ ] Maximum Subarray
* [ ] Contains Duplicates
* [ ] Maximum Product Subarray
* [ ] Search in Rotated Sorted Array
* [ ] 3Sum
* [ ] Container With Most Water
* [ ] Sliding Window Maximum :triangular\_flag\_on\_post:

</details>

<details>

<summary>String</summary>

* [ ] Valid Anagram
* [ ] Valid Palindrome
* [ ] Longest Substring Without Repeating Characters
* [ ] Longest Repeating Character Replacement
* [ ] Find All Anagrams in a String :triangular\_flag\_on\_post:
* [ ] Minimum Window Substring
* [ ] Group Anagrams :triangular\_flag\_on\_post:
* [ ] Longest Palindromic Substring :triangular\_flag\_on\_post:
* [ ] Encode and Decode Strings :star:

</details>

<details>

<summary>Hash Table</summary>

* [ ] Two Sum
* [ ] Ransom Note
* [ ] Group Anagrams
* [ ] Insert Delete GetRandom O(1) :triangular\_flag\_on\_post:
* [ ] First Missing Positive :triangular\_flag\_on\_post:
* [ ] LRU Cache :triangular\_flag\_on\_post:
* [ ] All O\`one Data Structure :triangular\_flag\_on\_post:

</details>

<details>

<summary>Recursion</summary>

* [ ] Generate Parentheses :triangular\_flag\_on\_post:
* [ ] Combinations
* [ ] Subsets
* [ ] Letter Combinations of a Phone Number
* [ ] Subsets 2
* [ ] Permutations&#x20;
* [ ] Sudoku Solver :triangular\_flag\_on\_post:
* [ ] Strobogrammatic Number 2 :star:

</details>

## Week 2

<details>

<summary>Sorting and Searching</summary>

* [ ] Binary Search
* [ ] Search in Rotated Sorted Array
* [ ] Kth Smallest Element in a Sorted Matrix :triangular\_flag\_on\_post:
* [ ] Search a 2D Matrix
* [ ] Kth Largest Element in an Array
* [ ] Find Minimum in Rotated Sorted Array
* [ ] Median of Two Sorted Arrays :triangular\_flag\_on\_post:

</details>

<details>

<summary>Matrix</summary>

* [ ] Set Matrix Zeroes
* [ ] Spiral Matrix :triangular\_flag\_on\_post:
* [ ] Rotate Image
* [ ] Valid Sudoku :triangular\_flag\_on\_post:

</details>

<details>

<summary>Linked List</summary>

* [ ] Reverse a Linked List
* [ ] Detect Cycle in a Linked List
* [ ] Merge Two Sorted Lists
* [ ] Merge K Sorted Lists
* [ ] Remove Nth Node From End of List
* [ ] Reorder List

</details>

<details>

<summary>Queue</summary>

* [ ] Implement Stack using Queues
* [ ] Implement Queue using Stacks
* [ ] Design Circular Queue
* [ ] Design Hit Counter :star:

</details>

<details>

<summary>Stack</summary>

* [ ] Valid Parentheses
* [ ] Implement Queue using Stacks
* [ ] Implement Stack using Queues
* [ ] Min Stack
* [ ] Asteroid Collision
* [ ] Evaluate Reverse Polish Notation
* [ ] Basic Calculator :triangular\_flag\_on\_post:
* [ ] Basic Calculator 2 :triangular\_flag\_on\_post:
* [ ] Daily Temperature
* [ ] Trapping Rain Water :triangular\_flag\_on\_post:
* [ ] Largest Rectangle in Histogram :triangular\_flag\_on\_post:

</details>

## Week 3

<details>

<summary>Tree</summary>

* [ ] Same Tree
* [ ] Binary Tree Maximum Path Sum :triangular\_flag\_on\_post:
* [ ] Binary Tree Level Order Traversal
* [ ] Lowest Common Ancestor of a Binary Tree
* [ ] Binary Tree Right Side View
* [ ] Subset of Another Tree :triangular\_flag\_on\_post:
* [ ] Construct Binary Tree from Preorder and Inorder Traversal :triangular\_flag\_on\_post:
* [ ] Serialize and Deserialize Binary Tree :triangular\_flag\_on\_post:
* [ ] Validate Binary Search Tree :triangular\_flag\_on\_post:
* [ ] Kth Smallest Element in a BST

</details>

<details>

<summary>Graph</summary>

* [ ] Number of Islands
* [ ] Flood Fill
* [ ] 01 Matrix
* [ ] Rotting Oranges
* [ ] Minimum Knight Moves :star:
* [ ] Clone Graph
* [ ] Pacific Atlantic Water Flow :triangular\_flag\_on\_post:
* [ ] Number of Connected Components in an Undirected Graph :star:
* [ ] Graph Valid Tree :star:
* [ ] Course Schedule
* [ ] Alien Dictionary :star:

</details>

<details>

<summary>Heap</summary>

* [ ] Merge K Sorted Lists
* [ ] K Closest Points to Origin
* [ ] Top K Frequent Elements
* [ ] Find Median from Data Stream :triangular\_flag\_on\_post:

</details>

<details>

<summary>Trie</summary>

* [ ] Implement Trie (Prefix Trie)
* [ ] Add and Search Word
* [ ] Word Break :triangular\_flag\_on\_post:
* [ ] Word Search 2 :triangular\_flag\_on\_post:

</details>

## Week 4

<details>

<summary>Interval</summary>

* [ ] Merge Intervals
* [ ] Insert Intervals
* [ ] Non-overlapping Intervals
* [ ] Meeting Rooms :star:
* [ ] Meeting Rooms 2 :star:

</details>

<details>

<summary>Dynamic Programming</summary>

For more questions on Dynamic Programming, refer to the [#dynamic-programming-roadmap](#dynamic-programming-roadmap "mention") after you are done with this initial study plan

* [ ] Climbing Stairs
* [ ] Coin Change
* [ ] House Robber
* [ ] Longest Increasing Subsequence
* [ ] 0/1 Knapsack or Partition Equal Subset Sum
* [ ] Longest Common Subsequence
* [ ] Word Break
* [ ] Combination Sum
* [ ] House Robber 2
* [ ] Decode Ways
* [ ] Unique Paths
* [ ] Jump Game

</details>

<details>

<summary>Binary</summary>

* [ ] Sum of Two Integers
* [ ] Number of 1 bits
* [ ] Counting Bits
* [ ] Missing Number
* [ ] Reverse Bits
* [ ] Single Number

</details>

<details>

<summary>Math</summary>

* [ ] Pow(x, n)
* [ ] Sqrt(x)
* [ ] Integer to English Words

</details>

<details>

<summary>Geometry</summary>

* [ ] Rectangle Overlap
* [ ] K Closest Points to Origin
* [ ] Rectangle Area

</details>

## Week 5 onwards

Once you have completed the study plan, feel free to use other question banks like Grind75 and Neetcode to continue improving your familiarity and speed.&#x20;

You will notice that many of the questions from this study plan overlaps with these question banks. I recommend leaving them to the end and redoing them when you've completed the other questions.

You may also want to try improving your dynamic programming skills with the dynamic programming roadmap below.

## Dynamic programming roadmap

{% hint style="info" %}
These questions were collated from [this Reddit post](https://www.reddit.com/r/leetcode/comments/14o10jd/the_ultimate_dynamic_programming_roadmap/)
{% endhint %}

I have written up a problems guide for this roadmap as I personally think that developing the intuition for dynamic programming is not easy and I would like to help bridge the gap. The problems guide can be found [here.](/problems-guide/dynamic-programming-roadmap)

<details>

<summary>Warmup</summary>

* [ ] Climbing Stairs
* [ ] Nth Tribonacci Number
* [ ] Perfect Squares

</details>

<details>

<summary>Linear Sequences</summary>

These are problems that require solving sub-problems based on the prefix of the array with a constant transition

* [ ] Minimum Cost to Climb Stairs
* [ ] Minimum Time to Make Rope Colorful :triangular\_flag\_on\_post:
* [ ] House Robber
* [ ] Decode Ways
* [ ] Minimum Cost for Tickets :triangular\_flag\_on\_post:
* [ ] Solving Questions with Brainpower

</details>

<details>

<summary>Grids</summary>

These are problems where the dynamic programming array is the same dimensions as the grid

* [ ] Unique Paths
* [ ] Unique Paths 2 :triangular\_flag\_on\_post:
* [ ] Minimum Path Sum
* [ ] Count Square Submatrices with All Ones :triangular\_flag\_on\_post:
* [ ] Maximal Square
* [ ] Dungeon Game :triangular\_flag\_on\_post:

</details>

<details>

<summary>Two Sequences</summary>

These problems often require $$O(MN)$$, where $$dp\[i]\[j]$$ solves for $$arr1\[:i]$$ and $$arr2\[:j]$$

* [ ] Longest Common Subsequence
* [ ] Uncrossed Lines
* [ ] Minimum ASCII Delete Sum for Two Strings
* [ ] Edit Distance
* [ ] Distinct Subsequences
* [ ] Shortest Common Supersequence

</details>

<details>

<summary>Intervals</summary>

These problems often require solving for every interval of the array

* [ ] Longest Palindromic Subsequnce
* [ ] Strong Game 7 :triangular\_flag\_on\_post:
* [ ] Palindromic Substrings
* [ ] Minimum Cost Tree from Leaf Values
* [ ] Strange Pointer
* [ ] Burst Balloons

</details>

<details>

<summary>Linear Sequence Transitions</summary>

These problems are often solved on every prefix of the array, transition from every $$j < i$$

* [ ] Count Number of Teams
* [ ] Longest Increasing Subsequence
* [ ] Partition Array for Maximum Sum
* [ ] Largest Sum of Averages
* [ ] Filling Bookcase Shelves

</details>

<details>

<summary>Knapsack-like</summary>

* [ ] Partition Equal Subset Sum
* [ ] Number of Dice Rolls with Target Sum
* [ ] Combination Sum 4
* [ ] Ones and Zeros
* [ ] Coin Change
* [ ] Coin Change 2
* [ ] Target Sum
* [ ] Last Stone Weight 2
* [ ] Profitable Schemes

</details>

<details>

<summary>Topological Sort/Graphs</summary>

These problems often require solving on all sub-graphs connected to each node

* [ ] Longest String Chain
* [ ] Longest Increasing Path in a Matrix
* [ ] Course Schedule 3

</details>

<details>

<summary>Trees</summary>

These problems often require solving on all subtrees

* [ ] House Robbers 3 :triangular\_flag\_on\_post:
* [ ] Binary Tree Cameras

</details>

<details>

<summary>Interesting Problems</summary>

Other interesting problems that I have done so far

* [ ] String Compression 2 :triangular\_flag\_on\_post:
* [ ] Minimum Difficulty of a Job Schedule :triangular\_flag\_on\_post:

</details>


# Optimizing Revision

Sharing some tips & tricks to optimize your revision process

## Tracking your progress&#x20;

The best way to revise these questions is to take notes for each question. You can use software like Excel sheets to track basic details about each question or slightly more complex tools like Notion to track more details about the problems.

Regardless of what tool you are using, you should focus on jotting down the key intuition behind each question and the topics/ideas you used to arrive at the solution.

### My method

I use a Notion database to track every question, breaking down my understanding into the following components:

1. **Intuition:** how the optimal solution can be understood by the layman
2. **Optimizations/Notes:** additional notes/tricks to optimize the solution
3. **Alternatives:** alternative solutions; usually I refer to the solutions section of LeetCode
4. **Remarks:** any remarks to future you as you are referring back to this list

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2FArAhXuB2uKDMDMtRxcY0%2FScreenshot%202023-12-24%20at%2012.38.50.png?alt=media&amp;token=f2c7fd35-ec1c-4836-977f-663f2f1db824" alt=""><figcaption></figcaption></figure>

## Revision strategy

When revising questions, you should prioritize the problems you wanted to redo or the ones that you tapped out first. Additionally, if the original solution was very intuitive for you, you can also try solving the problem using one of the alternatives to expand your repertoire of techniques.

Refer to the [FAQs](/getting-started/faqs) for more information about common questions I had when starting out.


# Summer 2024 Timeline

I have included my timeline for summer 2024 so that you can better understand when's the best times to start preparing for each stage

My biggest success factor is having started the process early.&#x20;

Being able to submit your applications early, and receive and solve online assessments quickly are crucial in increasing your success rate as that means you are in more companies' pipelines.

{% hint style="info" %}
I understand that some of you may not receive your offers as early as I did. Do not be discouraged and continue pushing forward, you will eventually receive good news!
{% endhint %}

This was my timeline for my summer 2024 internship hunt:

* **Start of May 2023:** sourcing for problems and figuring out my weaknesses when it comes to interviewing
* **May 2023 to mid June 2023:** working on my fundamentals by completing questions by topic
* **Mid Jun 2023 to Jul 2023:** working on other problems to expand my exposure and speed
* **Jul 2023 to Oct 2023:** start applying for roles in Singapore, US, and UK
* **Aug 2023 to Nov 2023:** started interviewing and received my offers


# FAQs

## Is this guide really free?

Absolutely. I have no intentions on making money off of it. The information in this guide was all readily available to me for free when I started. I am simply compiling it into a neater and centralized format that you can learn from. I believe that it takes way more than just reading about a bunch of patterns to get good at technical interviewing. A lot of the benefit that you will be able to extract from this guide comes from your own effort and dedication to working through the problems and identifying the patterns yourself.

## When should I tap out?

Generally, I tap out when I have spent about **one to two hours** on a problem and still have no idea about the intuition or question type to solve the problem. If I have an intuition but cannot implement it within **an hour**, I also tap out.

After talking to others, there seems to be this fear of tapping out as it might feel like you were not able to reach the solution on your own. However, not everyone can come up with an ingenious 3D DP solution on their first go and that is completely alright. A big part of maximizing your learning is knowing when to stop banging your head against a wall. A crucial next step after tapping out is recording that you had done so and returning to the question after a while to attempt it again.

## How long should I spend on each question?

* **Easy:** 20 minutes
* **Medium:** 40 minutes
* **Hard:** 1-2 hours

Feel free to adjust these to your experience. If you are starting out, allocate more time to the easy problems. There are some problems that are a lot easier than their grading. Do not get complacent if you are able to solve them quickly.

## Should I spam questions if I am short on time?

Absolutely NOT. Quality > quantity every time. In an ideal scenario, you should not be "cram studying" and should have followed a similar timeline as I did. If you are not able to do so and are rushing for time, then I would recommend focusing on completing **at least the first two weeks** of the revision questions along with **Graph and Tree questions.**

You do not need to do every question, just pick the questions that you are able to comfortably solve within the time you have. Focus on understanding every question and its solution before moving on: knowing how to solve 100 questions means nothing if all you did was memorize the code.

## How many questions should I be doing everyday?

Ideally, you should be able to complete at least five questions a day (given dedicated practice and time) but I would generally aim to solve **at least three** as a good starting pace. Throughout the process, focus on developing a deep understanding of the problems and the intuition behind them.

## Should I just stick to doing LeetCode dailies to get good?

In general, I think that LeetCode dailies are pointless if you're trying to seriously study for technical interviews. While these problems may follow a weekly topic, they do not expose you to the common interview question patterns as well as targeted practice will.&#x20;

I recommend prioritizing the study plan over random questions, at least until you are confident in your foundations or have the time to attempt LeetCode dailies.

## What language should I be using?

I personally use **Python** for all my interviews and OAs, but you should use the language you are most comfortable with. Languages like Java and C++ have extensive built-in library support for data structures like heaps and tree sets.

You should focus on getting as comfortable as possible in your chosen language, understanding the tricks that your language have to save time.

## Do I need LeetCode Premium?

Not at all, most of the problems within the [Study Plan](/getting-started/study-plan) are free and those that require a premium account can be found on other websites like LintCode instead.&#x20;

The only perk LeetCode premium really gives you is the information about the companies that use a question. However, there are open-source lists of that information circulating online as well.

## How should I learn data structure & algorithms if I know nothing?

I went through an algorithm's class the semester before starting my technical interview preparation (I have included a copy of my notes below). However, if you are not in university/have the chance to take an algorithms course, these are some of my recommendations for textbooks:

1. [Introduction to Algorithms](http://mitpress.mit.edu/9780262046305/introduction-to-algorithms/)
2. [The Algorithm Design Manual](https://www.algorist.com/)
3. [Grokking Algorithms](https://www.manning.com/books/grokking-algorithms)

My personal recommendation if you are very new is to use The Algorithm Design Manual as it provides a gentle yet in-depth introduction to core algorithms and data structures. Introduction to Algorithms is very theoretical and I use it as a supplement to understand certain concepts better.

If you are interested in getting a condensed set of notes about DSA, you are free to use mine from my algorithms class:

{% file src="/files/M12l7xRrZTtB4YAzruob" %}

## Are there any tools/resources that I can use to simplify my application process?

Absolutely. These were just some of the tools I used:

1. Simplify: quickly filling up applications
2. Google Sheets: tracking my application process
3. Notion: storing my knowledge base from all the preparation
4. Google Calendar/Apple Calendar: tracking my deadlines

## Should I study with someone?

A thousand times yes. I was very fortunate to have the support of my friend, Guan Zhou, throughout this process. We were always discussing problems together and checking each other's understanding by explaining our intuitions to one another. I would say that if you are lucky enough to have such a person to bounce ideas off of and discuss problems with, do it and keep doing it till the both of you succeed.


# Binary Search

Binary search typically appears when the array is sorted or its left and right halves both possess the same properties

## Runtime analysis

$$
T(n)=T(\frac{n}{2})+O(1)=O(\log n)
$$

## Take note…

* Is array sorted?
  * If not, can you sort it and still preserve information?
* Can the array be separated into two distinct parts where the first half satisfies a condition while the other does not?

## Techniques

### Peak finding

Move towards the direction of larger/smaller values relative to the mid-point chosen.

* This does not work with arrays that can have duplicates (naively picking a direction to move in could be disastrous)
* This can only produce local peaks

### Peak finding in rotated arrays

{% hint style="info" %}
Rotated arrays refer to arrays where the values after index `i` are moved to the front of the array instead, preserving their order\
\
`[1, 2, 3, 4, 5]` can be rotated to become `[3, 4, 5, 1, 2]`
{% endhint %}

The arrays always exhibit the following properties:

* `nums[0] > nums[-1]`
* There are essentially 2 sorted arrays, the boundary can be found by comparing `nums[i]` against `nums[0]`
  * If `nums[i] > nums[0]`, `i` is in the first half, otherwise, it is in the second half

Problems commonly test to see if you can identify these properties (such as [Find Minimum in Rotated Sorted Array](https://leetcode.com/problems/find-minimum-in-rotated-sorted-array/) or [Search in Rotated Sorted Array](https://leetcode.com/problems/search-in-rotated-sorted-array/))

* Use `nums[0]` as a marker for determining where `i` is in the array

### Apply binary search to matrices

Try using the values of the arrays as a range or treating using the pure index like `(0, 0)` to `(n - 1, n - 1)` has the last index as `(n * n) - 1`

* These problems are harder to notice but a common property that can be found is when the matrix is sorted in some order (row)

Problems can include [searching a fully sorted 2D matrix](https://leetcode.com/problems/kth-smallest-element-in-a-sorted-matrix/description/) or modifying binary search to search the matrix in a consistent manner such as [Kth Smallest Element in a Sorted Matrix](https://leetcode.com/problems/kth-smallest-element-in-a-sorted-matrix/solutions/1322101/c-java-python-maxheap-minheap-binary-search-picture-explain-clean-concise/)

## General template

{% tabs %}
{% tab title="Template" %}
{% code lineNumbers="true" %}

```python
left, right = 0, len(nums) - 1  # start to end of array
while left < right:  # terminates when left = right
	mid = left + (right - left) // 2  # avoids overflow
	if nums[mid] < target:  # mid is left leaning
		left = mid + 1  # mid cannot be the answer
	else:
		right = mid  # mid might be the answer
return left if nums[left] == target else -1
```

{% endcode %}
{% endtab %}

{% tab title="Variations" %}

```python
# Search for index of target, else -1 if target does not exist
def search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi: # Use <= here since the target index could be at lo == hi
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] > target: # Mid is too large, move search space to left
            hi = mid - 1
        else: # Mid is too small, move search space to right
            lo = mid + 1
    return -1 # Not found

# Search for the smallest index i such that nums[i] >= target
def search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo < hi: # not using <= since if our answer is at lo == hi, infinite loop occurs due to floor division
        mid = (lo + hi) // 2
        if nums[mid] >= target: # mid could be the index we want since nums[mid] >= target
            hi = mid 
        else: # nums[mid] < target, move search space to right
            lo = mid + 1
    return lo # smallest index i that satisfies nums[i] >= target if nums[lo] >= target. If nums[lo] < target, all numbers are < target.

# Search for largest index i such that nums[i] <= target
def search(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo < hi: # not using <= since if our answer is at lo == hi, infinite loop occurs
        mid = (lo + hi) // 2
        if nums[mid] <= target: # mid could be the index we want since nums[mid] <= target
            lo = mid 
        else: # nums[mid] > target, move search space to left
            hi = mid - 1
    return hi # largest index i that satisfies nums[i] <= target if nums[hi] <= target. If nums[hi] > target, all numbers are > target.
```

{% endtab %}
{% endtabs %}

{% tabs %}
{% tab title="Range" %}
Always from `[0, n)`
{% endtab %}

{% tab title="Condition" %}
`<` vs `<=`

* Former is often used for approximate answers while latter is used for guaranteed answers
* Consider the case when `l == r` and we can still move, where would `l` be? Where would `r` be? Is that information useful to us?
* If `<=` used, must include the following to avoid infinite loop

  * Must track the answer internally
  * Must move pointers by `m - 1` and `m + 1` to avoid infinite looping

  ```python
  l, r = i + 1, n - 1
  j = -1
  while l <= r:
      m = l + (r - l) // 2
      if values[m][0] >= values[i][1]:
          j = m
          r = m - 1
      else:
          l = m + 1
  ```

{% endtab %}

{% tab title="Pointer Shifts" %}
`mid` is **left leaning:**

`mid = left + (right - left) // 2`&#x20;

* Set `right = mid` and `left = mid + 1`
* Useful when dealing with cases where the `right` could also be the answer
* I.e. finding first instance of element

`mid` is **right leaning**:&#x20;

`mid = left + (right - left) // 2 + 1`&#x20;

* Set `left = mid` and `right = mid - 1`
* Useful when dealing with cases where `left` could also be the answer
* I.e. finding last instance of element
  {% endtab %}

{% tab title="Return Value" %}
Run through some examples to figure out where the `left` lies (usually we care only about `left`)
{% endtab %}

{% tab title="Considerations" %}

* When do we move towards the left?
* When do we move towards the right?
* What assumptions can we make about the way the data is arranged?
* Does the middle element tell us anything about everything on the left/right?
  {% endtab %}
  {% endtabs %}


# Sorting

Sorting algorithms aren't usually tested alone. They are often paired with another problem type and the goal is to really see if you're able to think about the algorithms in a wider context

{% tabs %}
{% tab title="Bubble Sort" %}
$$
O(n^2)
$$

* Traverse from left to right, swap element with neighbor is neighbor is less than element
* Repeat till no more swaps are needed
* Invariant: after every iteration, the largest elements are arranged at the end in ascending order
  {% endtab %}

{% tab title="Selection Sort" %}
$$
O(n^2)
$$

* Traverse from left to right, maintaining a “sorted” section in the front of the array
* Every iteration, we seek the next smallest element in the “unsorted” section and insert to the front
* Invariant: after iteration $$j$$, the front $$j$$ elements are arranged the smallest elements sorted in ascending order
  {% endtab %}

{% tab title="Insertion Sort" %}
$$
O(n^2)
$$

* Traverse from left to right
* When encountering a number less than the current element, we move backwards, finding elements before the current element that is greater than the number
* Repeat till a smaller number found, and then swap the elements into position
* Invariant: after iteration $$j$$, the front $$j$$ elements are arranged in ascending order (no guarantee to be the smallest $$j$$
  {% endtab %}

{% tab title="Merge Sort" %}
$$
O(n \log n)
$$

* $$T(n) = 2T(n/2) + O(n)$$
* Break up the array into half and merge at the end
* Stop breaking it up once only 1 element is left
* Invariant: the leftmost set of elements will be sorted before the rightmost

```python
# [left, right)
def merge_sort(arr, left, right):
    if left == right:
        return arr[left]
    elif left < right:
        mid = left + (right - left) // 2
        left_merged = merge_sort(arr, left, mid)   # [left, mid)
        right_merged = merge_sort(arr, mid, right) # [mid, right)
        return merge(left_merged, right_merged)
        
def merge(a, b):
    c = []
    i, j = 0, 0
    while i < len(a) and j < len(b):
        if a[i] < b[j]:
            c.append(a[i])
            i += 1
        else:
            c.append(b[j])
            j += 1
    idx, remaining = (i, a) if i < len(a) else (j, b)
    for k in range(idx, len(remaining)):
        c.append(remaining[k])
        
    return c
```

{% endtab %}

{% tab title="Quick Sort" %}
$$
O(n\log n)
$$

* Pick a pivot, partition array around pivot value, and repeat for all halves till 1 element
* Same recurrence as merge sort
* If duplicates allowed, use [dutch flag algorithm](https://www.geeksforgeeks.org/sort-an-array-of-0s-1s-and-2s/), otherwise, can refer to the partitioning algorithms used in [Quick Select](/algorithms/quick-select)
* Worst case can be $$O(n^2)$$ if array already sorted
  {% endtab %}

{% tab title="Counting Sort" %}
$$
O(n+k)
$$

* $$k$$ is the number of distinct elements
* Count frequency of each element
* Reconstruct the array from the frequency
* Can appear as ways to re-construct an array that has very little elements but many duplicates

```python
def counting_sort(arr):
    offset = min(arr)
    freq = [0] * (max(arr) - offset + 1)
    for num in arr:
        freq[num - offset] += 1
    result = []
    for i in range(freq):
        while freq[i] > 0:
            result.append(i + offset)
    return result
    
```

{% endtab %}

{% tab title="Bucket Sort" %}
$$
O(n + \frac{n^2}{k} + k)
$$

* For every element in the array, create buckets that correspond to some property (such as value and each bucket is a range)
* Sort elements of each individual bucket
* Join all buckets to form result
* Usually best if $$k \approx n$$ so the overall runtime could be $$O(n)$$
  {% endtab %}
  {% endtabs %}


# Recursion

Recursion is quite a typical problem and can be rather tricky to get right on the first go. Try visualizing recursive algorithms by tracing them out on paper with simpler recursions

## Runtime analysis

### Time complexity

The runtime of recursive algorithms can often be determined by looking at the branching factor and using things like [master theorem](https://en.wikipedia.org/wiki/Master_theorem_\(analysis_of_algorithms\)) to calculate accurately.

### Space complexity

There is always space used for recursive calls in Python. But in languages with tail-call optimization like Elixir, the space can be $$O(1)$$

## Take note...

1. Define base cases to terminate the recursion
2. Recursion implies using a stack to model the problem so a stack can be used to replace recursive methods
3. Recursion is particularly useful for permutation problems

## Corner cases

1. `n = 0`
2. `n = 1`
3. `n >= len(arr)`

## Techniques

### Reducing space

Rather than doing `acc + [nums[i]]` for each recursive call (this creates a new array every time), use `.append()` before the recursive call and `.pop()` after. Then, when a base case is reached, use `acc[::]` to duplicate the array just once

```python
def fn(i, acc):
    if i >= len(arr):
        ans.append(acc[::])
        return
    for j in range(i, len(arr)):
        acc.append(arr[i])
        fn(j + 1, acc)
        acc.pop()
```

### Memoization

Commonly used to optimize repeated computations. The memoization stores the result of a branch and allows future calls to that branch to re-use the memoized values instead of performing the computation again

* Commonly associated with bottom-up approach

### Tabulation

Storing the result of computations and using them for later computations

* Commonly associated with top-down approach

### Converting to iterative

Store the previous iteration's answer and use it as the basis for the next iteration, performing operations to modify it

* Base case is usually `[]` but it may vary

### Dealing with duplicates

Duplicate values can be particularly annoying to deal with because they can cause repeated and wrong computation. To resolve this, rather than iterating over the entire array directly, iterate over a frequency map of the elements, controlling how many elements appear in each recursive call

* Particularly useful for problems like [Combination Sum 2](https://leetcode.com/problems/combination-sum-ii/) where the duplicates can cause overcounting if naively recursed

For questions about **permutations** where the order of the elements matter, we can continue to use the above strategy or create an auxiliary array that maintains the order of the elements so that the first `1` cannot be used after the second `1`, thus enforcing the order of the elements


# Graph

Graph algorithms are quite common and usually fall under the following algorithms. You will usually be required to modify these algorithms to fit the problem but it's good to know the fundamentals

## Time complexities

<table><thead><tr><th width="125">Algorithm</th><th width="176">Time Complexity</th><th width="156">Space Complexity</th><th>Remarks</th></tr></thead><tbody><tr><td>BFS</td><td><span class="math">O(E + V)</span></td><td><span class="math">O(V)</span></td><td></td></tr><tr><td>DFS</td><td><span class="math">O(E + V)</span></td><td><span class="math">O(V)</span></td><td>Space accounting for stack frames used in recursion</td></tr><tr><td>Topological Sorting</td><td><span class="math">O(E + V)</span></td><td><span class="math">O(V)</span> for storing the edges and frontier</td><td>Note that when iterating over every node, we only decrease the edges at most <span class="math">O(E)</span> times</td></tr><tr><td>Dijkstra</td><td><span class="math">O((V + E) \log V)</span></td><td><span class="math">O(E)</span> for priority queue</td><td></td></tr><tr><td>Bellman-Ford</td><td><span class="math">O(EV)</span></td><td></td><td>For <span class="math">O(V)</span> vertices, we iterate through all <span class="math">O(E)</span> edges and compute the SSP</td></tr><tr><td>Prim’s Algorithm</td><td><span class="math">O(E \log V)</span> or <span class="math">O(V^2)</span></td><td></td><td>Time complexity achieved if using Fibonacci heap AND iterating over the entire priority queue</td></tr><tr><td>Kruskal’s Algorithm</td><td><span class="math">O(E \log V)</span></td><td></td><td></td></tr></tbody></table>

## Frequency in interviews

1. Common: BFS, DFS
2. Uncommon:  Topological sort, Dijkstra
3. Almost never: Bellman-Ford, Floyd Warshall, Prim’s, Kruskal’s

## BFS

{% hint style="info" %}
Trees do not require a `visited` set since there is only 1 path between nodes (property of trees)
{% endhint %}

```python
from collections import deque

def bfs(matrix):
  # Check for an empty matrix/graph.
  if not matrix:
    return []

  rows, cols = len(matrix), len(matrix[0])
  visited = set()
  directions = ((0, 1), (0, -1), (1, 0), (-1, 0))

  def traverse(i, j):
    queue = deque([(i, j)])
    while queue:
      curr_i, curr_j = queue.popleft()
      if (curr_i, curr_j) not in visited:
        visited.add((curr_i, curr_j))
        # Traverse neighbors.
        for direction in directions:
          next_i, next_j = curr_i + direction[0], curr_j + direction[1]
          if 0 <= next_i < rows and 0 <= next_j < cols:
            # Add in question-specific checks, where relevant.
            queue.append((next_i, next_j))

  for i in range(rows):
    for j in range(cols):
      traverse(i, j)
```

## DFS

```python
def dfs(matrix):
  # Check for an empty matrix/graph.
  if not matrix:
    return []

  rows, cols = len(matrix), len(matrix[0])
  visited = set()
  directions = ((0, 1), (0, -1), (1, 0), (-1, 0))

  def traverse(i, j):
    if (i, j) in visited:
      return

    visited.add((i, j))
    # Traverse neighbors.
    for direction in directions:
      next_i, next_j = i + direction[0], j + direction[1]
      if 0 <= next_i < rows and 0 <= next_j < cols:
        # Add in question-specific checks, where relevant.
        traverse(next_i, next_j)

  for i in range(rows):
    for j in range(cols):
      traverse(i, j)
```

## Topological sorting

* Used for job scheduling a sequence of jobs or tasks that have dependencies on other jobs/tasks
  * Jobs represent vertices and edges from X to Y (directed) if X depends on Y

```python
def graph_topo_sort(num_nodes, edges):
    from collections import deque
    nodes, order, queue = {}, [], deque()
    
    # O(V)
    for node_id in range(num_nodes):
        nodes[node_id] = { 'in': 0, 'out': set() }
		
    # O(E)
    for node_id, pre_id in edges:
        nodes[node_id]['in'] += 1
        nodes[pre_id]['out'].add(node_id)

    # O(V)
    for node_id in nodes.keys():
        if nodes[node_id]['in'] == 0:
            queue.append(node_id)

    # O(E), total number of decreases happen O(E) times at most
    while len(queue):  # At most O(V) elements
        node_id = queue.pop()
        for outgoing_id in nodes[node_id]['out']:  # At most O(V - 1) edges
            nodes[outgoing_id]['in'] -= 1
            if nodes[outgoing_id]['in'] == 0:
                queue.append(outgoing_id)
        order.append(node_id)
    return order if len(order) == num_nodes else None

print(graph_topo_sort(4, [[0, 1], [0, 2], [2, 1], [3, 0]]))
# [1, 2, 0, 3]
```

## Dijkstra

{% hint style="info" %}
Only works with non-negative edge weights. If the edges have a negative weight, use [#bellman-ford](#bellman-ford "mention") instead
{% endhint %}

### Optimizations

1. If target node known, once we process target node (i.e. pop is target node), we can early return

```python
import heapq
import math

class Solution:
    def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
        graph = {}
        # O(E)
        # Maintain the edge costs + edges
        for [source, target, time] in times:
            if (source - 1) not in graph:
                graph[source - 1] = []
            graph[source - 1].append((target - 1, time))
	       
	# All nodes start with inf cost to reach
        costs = [math.inf] * n
	# Start node has cost of 0 (naturally)
        costs[k - 1] = 0
        q = [(0, k - 1)]
        visited = set()
				
	# O(V log V) + O(E log V)
	# O((E + V) log V)
	# O(E log V)
        while q:  # At most O(V)
	    # O(log V)
            node_cost, node = heapq.heappop(q)
            if node in visited:
                continue
            visited.add(node)
            if node not in graph:
                continue
	    # O(E log V)
            for neighbor, time in graph[node]: # Visit at most O(E) nodes
		# Only update cost and re-queue if the cost to reach neighbor decreases
                if node_cost + time < costs[neighbor]:
                    costs[neighbor] = node_cost + time
                    heapq.heappush(q, (node_cost + time, neighbor))
        
        if len(visited) != n:
            # Disjoint component
	    # Alternative way to check is to check if any cost is still inf
            return -1

        return max(costs)
```

## Bellman-Ford

{% hint style="info" %}
Works with negative weight edges but does not work if there are negative weight **cycles.** For those cases, we can detect that a cycle exists but cannot do anything about it
{% endhint %}

Loop for `v - 1` times and for each loop, relax all edges

To detect negative weight edges, check if the cost of the same node decreases twice

### Optimizations

1. Track if any costs decreased, if none did, then we can early terminate since that means we found the lowest possible cost for all edges

```python
import heapq
import math

class Solution:
    def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
        costs = [math.inf] * n
        costs[k - 1] = 0
        # O(V)
        for _ in range(n - 1):
            has_change = False

	# O(E)
            for source, target, weight in times:
                if costs[target - 1] > costs[source - 1] + weight:
                    costs[target - 1] = costs[source - 1] + weight
                    has_change = True

            if not has_change:
                break
        
        if any([cost == math.inf for cost in costs]):
            return -1
        
        return max(costs) 
```

## Prim’s algorithm

Finds the Minimum Spanning Tree of a graph and is easier to implement than [#kruskals-algorithm](#kruskals-algorithm "mention")

{% hint style="info" %}
The general intuition for Prim's algorithm is as such:\
\
Given a node `i`, go through all connected points and add the edge weights to a min heap `(weight, other point)`. For every element in the min heap, if visited before, discard, otherwise, use the topmost point’s edge. Iterate at most `N - 1` times as that is the maximum number of edges of a tree.
{% endhint %}

For this specific implementation, the time complexity is $$O(EV \log V)$$

```python
import heapq

# edges are: [from, to, weight]
# n points in total
def prim(n: int, edges: List[List[int]]):
	graph = {}
	# O(E)
	for f, t, w in edges:
		if f not in graph: graph[f] = []
		if t not in graph: graph[t] = []
		graph[f].append((t, w))
		graph[t].append((f, w))

	visited = [False] * n
	h = []
	node = 0
	min_weight = 0
	
	# O(VE log V)
	for i in range(N - 1):
		visited[node] = True
		# O(E log V)
		for neighbor, weight in graph[node]: # Total O(E)
			if not visited[neighbor]:
				heapq.heappush(h, (weight, neighbor))

		# O(log V)
		while visited[h[0][1]]:
			heapq.heappop(h)

		# O(log V)
		weight, node = heapq.heappop(h)
		min_weight += weight

	return min_weight
```

### Fully-connected graphs

For fully connected graphs, instead of using a heap, use a `min_d` array, tracking the minimum weight to reach each point in the graph.

{% hint style="info" %}
The general intuition for this variation is:\
\
For each node `node`, we update the edge weight to all connected points. Then the point we choose to use would be the one that has not yet been visited and has the shortest edge weight.

Once a `node` is visited, set the weight to reach to be `inf` so no extra `visited` is needed
{% endhint %}

```python
import math

def prim(n: int, edges: List[List[int]]):
	graph = {}
	for f, t, w in edges:
		if f not in graph: graph[f] = []
		if t not in graph: graph[t] = []
		graph[f].append((t, w))
		graph[t].append((f, w))

	min_d = [10*8] * n
	node = 0
	min_weight = 0
	for i in range(N - 1):
		min_d[node] = math.inf
		min_j = node
		for neighbor, weight in graph[node]:
			if min_d[neighbor] != math.inf:
				min_d[neighbor] = min(min_d[neighbor], weight)
				min_j = neighbor if min_d[neighbor] < min_d[min_j] else min_j
		min_weight += min_d[min_j]
		node = min_j
	
	return min_weight
```

## Kruskal’s algorithm

Use [Union-Find Disjoint Set (UFDS)](/data-structures/union-find-disjoint-set-ufds) to determine which edges are redundant and use a min heap to store the weights of the edges. Redundant edges are those whose points already exist in the same set (meaning that there exists another path between these two points in the MST so far).

This implementation has a time complexity of $$O(E \log E + E \log V)$$ because we’re not sorting the entire graph at once. If we sorted, the time complexity would be similar but $$O(\log E) = O(\log V^2) = O(2 \log V) = O(\log V)$$ dominates the term so we take that instead

```python
import heapq

def find(ds, i):
	# O(log V)
	if ds[i] == i:
		return i
	
	ds[i] = find(ds, ds[i])
	return ds[i]

def union(ds, p, q):
	# O(log V)
	root_p = find(p)
	root_q = find(q)
	ds[root_p] = root_q

def kruskal(n: int, edges: List[List[int]]):
	ds = list(range(n))
	h = []
	min_weight = 0
	# O(E)
	for f, t, w in edges:
		h.append((w, f, t))
	# O(E)
	heapq.heapify(h)
	used = 0	

	# O(E log E + E log V)
	# O(E)
	while h:
		# O(log E)
		weight, i, j = heapq.heappop(h)
		# O(log V)
		i = find(ds, i)
		j = find(ds, j)
		if i != j:
			min_weight += weight
			union(ds, i, j)
			used += 1
			if used == n - 1:
				break

	return min_weight
			
```


# Quick Select

Quick select often comes up when the question asks for the first/last K elements or the Kth largest/smallest element

## Runtime

$$
T(n) = T(\frac{n}{2}) + O(n)
$$

* Best/Average: $$O(n)$$ can guaranteed by shuffling the array
* Worst: $$O(n^2)$$if sorted

## Techniques

### Kth Element

Arrange largest elements in front by inverting the pivot comparison. This is a common problem type that tests if you know exactly how the partitioning algorithm works.

## Partitioning Algorithms

{% hint style="info" %}
Pick a pivot, partition elements smaller than the pivot to the left, and elements larger to the right of the pivot
{% endhint %}

{% tabs %}
{% tab title="Lomuto" %}
{% hint style="info" %}
`idx-1` is the pivot index after performing the partitioning the array. Think of `idx` as the position for the next element that is smaller than the pivot value
{% endhint %}

* Easier to implement

{% code lineNumbers="true" %}

```python
def select(start, end):
    # Randomly choose a pivot value to guarantee O(n)
    random = randint(start, end)
    # Swap the pivot value to the end
    points[end], points[random] = points[random], points[end]
    # Calculate the value of the pivot
    pivot_value = calculate(points[end])
    idx = start
    for i in range(start, end + 1):
        # If current value less than pivot, then swap to idx position
        if calculate(points[i]) <= pivot_value:
            points[i], points[idx] = points[idx], points[i]
            idx += 1
    return idx - 1

s, e, pos = 0, len(points) - 1, len(points)
while pos != k:
    pos = select(s, e)
    if pos < k:
        s = pos + 1
    else:
        e = pos - 1
return points[:k]
```

{% endcode %}
{% endtab %}

{% tab title="Hoare" %}
{% hint style="info" %}
Choose first element as pivot
{% endhint %}

* Faster in theory

Two pointers, find 2 elements where `arr[left] > pivot` and `arr[right] < pivot`, swap and continue. When pointers converge, they converge at where the pivot should be so swap `arr[start]` and `arr[left]`.

{% code lineNumbers="true" %}

```python
partition(start, end):
    i, j = start, end + 1
    while True:
        while i < end and arr[i] < arr[start]:
            i += 1
        while j > start and arr[i] > arr[start]:
            j -= 1
        if i >= j:
            break
        swap(arr[i], arr[j])
        swap(arr[start], arr[j])
    return j
```

{% endcode %}
{% endtab %}
{% endtabs %}


# Intervals

Interval problems aren't easy to spot and require some practice, try looking for ways to model the problem as a set of ranges/intervals

## Take note…

1. Clarify if `[1, 2]` and `[2, 3]` are considered overlapping intervals
2. Clarify if `[a, b]` will strictly follow `a < b`

## Corner cases

1. No intervals
2. Single interval
3. Two intervals
4. Non-overlapping intervals
5. Interval totally consuming within another
6. Duplicate intervals
7. Intervals which start right where another ends

## Techniques

### Overlap checking

{% hint style="info" %}
Try to remember it as checking`0 < 1` in both intervals
{% endhint %}

```python
def is_overlap(a, b):
	return a[0] < b[1] and b[0] < a[1]
```

### Merge intervals

This is commonly used when looking to combine a bunch of intervals into a giant interval as long as there is some overlap between them

```python
def merge(a, b):
	return [min(a[0], b[0]), max(a[1], b[1])]
```

### Sorting first

Sort the array of intervals by the starting point or by ending point first

{% hint style="info" %}
It is useful to think of why we need to sort and what information can be gathered from sorting first (i.e. what guarantees do we have once we sort)
{% endhint %}

* Used to find the maximum number of non-overlaps, see [Non-Overlapping Intervals](https://leetcode.com/problems/non-overlapping-intervals/)
  * Sort by end and if the next interval starts after the current ending, we want to extend the ending only if there isn’t any overlaps
  * To maximize the most non-overlaps, we want to schedule the intervals that end earliest first

### Umbrella intervals

Instead of trying to think of discrete intervals, think of the entire interval as one whole, then operate on it as a whole

* This can be useful when we don't actually care about the interval spans but rather whether or not the interval can reach a certain point like in [Jump Game 2](https://leetcode.com/problems/jump-game-ii/description/)

### Line sweep

{% embed url="<https://www.youtube.com/watch?v=phrSBwaBs7o&list=RDQMJRqhglnbU-8&start_radio=1>" %}

A relatively interesting class of problems where each interval represents a duration of an event occurring. When the interval starts, the event starts and when the interval ends, so does the event.

* To model these problems, create events for the start/end of intervals and any additional intermediate events (usually arranged as `(point, event_type)`)
  * The order of the events is dependent on how they are calculated, for instance, if the end of an interval should be counted before starting another interval
  * However, typically, `end interval` events should occur before `start interval` ones
* Sort this array and then simulate by running through it linearly

Common problems are the [streetlights problem](https://www.youtube.com/watch?v=9wy6OA3Yvpg\&feature=youtu.be)

### Operate at the interval level

Rather than focusing on individual values within an interval, try solving by breaking intervals into sub-intervals

* Useful when optimizing problems that have too many values inside an interval
* Take extra care when dealing with leading and trailing intervals that may be leftover from doing the partitioning

A good example of this is part 2 of Advent of Code 2023 Day 5


# Binary

Binary problems are quite rare in interviews and even online assessments but it's always good to know

## Corner cases

1. Check for overflow/underflow
2. Negative numbers (they use the [twos complement system](https://en.wikipedia.org/wiki/Two%27s_complement))

## XOR behavior

The XOR (^) operator is quite commonly used to solve binary problems, these are some important properties you should familiarize yourself with:

1. `n ^ n = 0`
2. `n ^ m == m ^ n`
3. `n ^ (m ^ k) == (n ^ m) ^ k`
4. `n ^ 0 = n`

## Common bitmasks

Bitmasks are commonly used to "force" a certain set of bits to be used. They are also used to constraint Python's numbers as Python doesn't use 32 bits for integers so using a manual bitmask is necessary for constraining it

1. Retrieving the upper 16 bits: `0xffff0000`
2. Retrieving the lower 16 bits: `0x0000ffff`
3. Retrieving all bits in groups of 4: `0xff00ff00`
4. Retrieving all bits in groups of 2: `0xcccccccc`
5. Retrieving all single bits: `0xaaaaaaaa`

## Techniques

1. Test is bit K is set: `num & (1 << k) != 0`
2. Set bit K: `num |= (1 << k)`
3. Turn off bit K: `num &= ~(1 << k)`
4. Toggle bit K: `num ^= (1 << k)`
5. Multiply by $$2^K$$: `num << k`
6. Divide by $$2^K$$: `num >> k`
7. Check if number is power of 2: `(num & num - 1) == 0` or `num & (-num) == num`
8. Remove rightmost set bit: `num & (num - 1)`
9. Swapping two variables (only positive numbers): `num1 ^= num2; num2 ^= num1; num1 ^= num2`

For more information and tricks, refer to this [post.](https://leetcode.com/problems/sum-of-two-integers/solutions/84278/a-summary-how-to-use-bit-manipulation-to-solve-problems-easily-and-efficiently/)


# Geometry

Geometry problems are quite niche and I have really only ever encountered a small handful of them myself. Advent of Code 2023 was where I developed these pointers

## Techniques

### Check if point is inside polygon

Otherwise known to be raycasting, crossing number algorithm, or the even-odd rule algorithm

* From point $$(x, y)$$, draw a line infinitely long in 1 direction and check how many sides of the polygon it intersects
  * If intersect odd number of points, inside polygon
  * Otherwise, outside of polygon
* Ensure to check for corner pieces such as `L` and `7` pieces and do not count those


# Dynamic Programming

Dynamic Programming is an incredibly confusing topic when you first start, it is good to try recognizing commonly occurring patterns to reduce the amount of head scratching you do

## Recognizing DP problems

1. Minimizing/maximizing:&#x20;
2. Number of ways:
3.

## Steps to solving DP

1. Identify states
2. Identify state transitions
3. Implement top-down
4. Convert to bottom-up

## Types of DP

1. Linear sequence
2. Grid
3. Two sequences

## Common recurrence relations

## Recursive to iterative

## Optimizing space


# Arrays

Array problems are by far the most common type of algorithm problems I have encountered. There are a few techniques that are used very commonly so it is best to familiarize yourself with all of them

## Runtime analysis

* Insert
  * Start/End: $$O(1)$$
  * Middle: $$O(n)$$
* Search
  * Sorted (binary search): $$O(\log n)$$
  * Unsorted (linear search): $$O(n)$$
* Delete: similar to insert
* Access: $$O(1)$$

## Corner cases

1. Empty array
2. Array of size 1 or 2
3. Monotonically increasing/decreasing
4. Duplicate values
5. Consecutive repeated elements
6. Odd number of elements

## Take note…

1. Index out of bounds, check with: $$0 \leq |x| < len(arr)$$
2. Handling duplicate entries
3. Is the array sorted?
   * If so, can you use [Binary Search](/algorithms/binary-search)instead?
   * If not, can you sort it while preserving information?
4. Do not over-concatenate
   * Use `append` and then copy the accumulation using `acc[::]` to reduce the amount of concatenation, refer to [Recursion](/algorithms/recursion)for more information on this technique
5. Relationship between elements in an array such as the information that can be gathered based on the values/indices of the elements

## Techniques

### Sorting first

This depends entirely on whether or not the information in the array can be preserved after sorting (take note of the effects of stable vs unstable sorting).

If the array can be sorted first, [Binary Search](/algorithms/binary-search)could be applied afterwards

* Useful when finding the minimum difference between all elements and finding the lowest possible running total using prefix sums (commonly found in subsequence problems)

If the array cannot be sorted without losing information, then try other solutions

### Two pointers

Pointers that point to different/same points in the array and move (often) independently of one another

* Can be used to traverse across two arrays simultaneously
* Pointers can cross one another
* Commonly used for problems that involve palindromes

When solving two pointer problems, focus on realizing the following conditions (similar to [Binary Search](/algorithms/binary-search)):

1. When to move the left pointer?
2. When to move the right pointer?
3. Where to move if neither condition is met?

### Sliding window

Sliding windows are simply two pointers that never cross one another. They can be of a fixed size (useful if the window size is pre-defined) or a flexible size (resizing depending on conditions)

To understand how the window moves, first answer the following questions:

1. Is the window fixed?
2. When to expand right?
3. When to retract left? (some problems don't require retracting the left at all)
4. What does each window represent?
5. What to do when an element enters/leaves the window?

Sliding window problems are often paired with frequency arrays like [Longest Substring Without Repeating Character](https://leetcode.com/problems/longest-substring-without-repeating-characters/)

There are two categories of problem that can be solved using sliding windows, namely:

#### Maximizing problems

The sliding window never shrinks, meaning that it is only extending by one or shifting by one, such as in [Maximum Product Subarray](https://leetcode.com/problems/maximum-product-subarray/)

The final answer is usually the size of the sliding window since the starting position or contents of the window does not matter

```python
start = 0
for i in range(len(nums)):
	# Always extend
	window.append(nums[i])
	# Shift to the right
	if not condition:
		window.remove(nums[start])
		start += 1
return len(nums) - start
```

#### Minimizing problems

The sliding window retracts until the window no longer satisfies a given condition, such as [Minimum Window Substring](https://leetcode.com/problems/minimum-window-substring/)

The size of the window should be tracked after each retraction and that represents the final answer

```python
window, i, j = 0, 0, 0
while j < len(nums):
	# Extend first
	window += nums[j]
	# Retract till the current window is just right
	while condition:
		window -= nums[i]
		i += 1
		ans = min(ans, j - i + 1) # window size
	j += 1
```

### Reverse traversal

Rather than traversing from left to right, try traversing from right to left and see if new information can be gathered instead

* Try combining both normal and reverse traversal together to see if the problem can be solved that way

### Prefix/Suffix sums

Often used to reduce repeated computation that requires the prefix/suffix of the array

* Not limited to the sum, product and other properties are possible as well
* May require both sums so try that out as well
  * If both are required, there's a chance that the one of the arrays can be converted to an accumulating variable that is computed to save both time and space
* Pay extra attention to whether or not the prefix should include the value at `i`

```python
# Prefix sums
prefix = [0] * len(arr)
prefix[0] = arr[0]
for i in range(1, len(arr)):
    prefix[i] = prefix[i - 1] + arr[i]
    
# Suffix sums
suffix = [0] * len(arr)
suffix[-1] = arr[-1]
for i in range(len(arr) - 2, -1, -1):
    suffix[i] = suffix[i + 1] + arr[i]
```

### Using index as key/Treating indices as buckets

Sometimes, you can visualize the array as a set of buckets where the index corresponds to a key/bucket and allows you to use this information to perform computation. This pattern is quite hard to spot so I have included some questions you can try to develop the intuition for

* Each index represents an element in the array relative to that position (like index 0 is supposed to be element 1)
* Algorithms like cyclic sorting use this assumption
* Common manipulations: negate the value in the cell to indicate that the value is present in some other part of the array or adding the `MAX + 1` to those values

Practice questions:

* First Missing Positive
* Find the Duplicate Number
* Missing Number

### Kadane's algorithm

This is a relatively niche algorithm used in problems like [Maximum Product Subarray](https://leetcode.com/problems/maximum-product-subarray/) and [Maximum Subarray](https://leetcode.com/problems/maximum-subarray/). However, it can still be generalized to solve other problems

Focus on understanding how the accumulation of values can be generalized across elements

* Often involves tracking a local value (running variable) and global value (final result)
* Update the global value after every iteration to avoid missing the last element

### Divide and conquer

Divide the existing array into two/`N` parts and perform simpler work on each part. This technique is the corner stone of merge sort

### Iteration patterns

* Iterate over one array while moving the other with a pointer
* Jumping around the array given the current value


# Matrices

Matrix problems are quite common and are relatively straightforward, just focus on understanding the techniques required

## Runtime analysis

Most matrix problems have a run time of $$O(mn)$$for a $$m \times n$$matrix

## Corner cases

* Empty matrix (ensure none of the array length is 0)
* 1 by 1 matrix
* Row/column matrix

## Techniques

### Creating an empty M x N matrix

Typically used to initialize the values for traversal or dynamic programming.

* Make a copy of the matrix with the same dimensions with empty values to store the state
* When initializing the rows, must use `for _ in range` rather than `* M` instead, otherwise, any changes to any of the columns affects every single row

```python
# Create zero matrix
# Inner array is the columns
# Outer array is the rows
zero_matrix = [[0] * len(matrix[0]) for _ in range(len(matrix))]

# Copy matrix
copied_matrix = [row[:] for row in matrix]
```

### Matrix transposition

Rows of a matrix becomes the columns and vice versa.

* Used when single direction verification can be repeated (verify horizontally, transpose, verify horizontally again)

{% code fullWidth="false" %}

```python
# *matrix -> return the rows
# zip -> pairs every value in the same position with each other
transposed_matrix = zip(*matrix)

# Bruteforce
transposed = [[0] * len(matrix[0]) for _ in range(len(matrix))]
for i in range(len(matrix)):
	for j in range(len(matrix[0])):
		tranposed[i][j] = matrix[i][j]
```

{% endcode %}

### Traversing common shapes

Commonly used when the elements of a matrix follow a given property and these elements create a common shape that can be traversed

* Common shapes include stairs (as seen in [Count Negative Numbers in a Sorted Matrix](https://leetcode.com/problems/count-negative-numbers-in-a-sorted-matrix/)) or pairs
  * Stair traversal: either move 1 row up/down or move 1 col left/right
* See if can make assumptions about the entire row based on some indicated value (like if first value is < 0, then rest of the row is 0
* Given the shape or pattern, how do we traverse the matrix (when do we move 1 row up/down, when do we move 1 col left/right)

### Treating matrix as flat array&#x20;

Rather than trying to traverse using two for-loops, traverse in one from 0 to $$mn$$. See [searching a fully sorted 2D matrix](https://leetcode.com/problems/kth-smallest-element-in-a-sorted-matrix/description/) for an example of such traversal

* Given an index `i` where $$i \in \[0, mn]$$, then
  * The equivalent row is `i // n`
  * The equivalent column is `i % n`

### Creating sub-grids

Given a matrix of size `N x N`, break up the matrix into sub-grids of size `M x M`&#x20;

* Given a coordinate `(r, c)`, then associated sub-grid is `(r // 3 * 3, c // 3)`
* Commonly used when trying to calculate a property within sub-grids such as [Valid Sudoku](https://leetcode.com/problems/valid-sudoku/)


# Strings

String problems are very similar to array problems as they both use very similar techniques but there are some slight differences

## Runtime analysis

Similar to [Arrays](/data-structures/arrays)with some differences:

1. Concatenation: $$O(m + n)$$
2. Find substring: $$O(m \times n)$$ (can be improved using Rabin Karp)
3. Slice: $$O(m)$$
4. Split by token: $$O(m + n)$$
5. Strip: $$O(n)$$

## Corner cases

1. Empty strings
2. Strings with 1 or 2 characters
3. Strings with repeated characters
4. Strings with only distinct characters

## Take note...

1. Input character set (ASCII, UTF-8, all lowercase alphabets, etc.)
2. Case sensitivity

## Techniques

Similar to [Arrays](/data-structures/arrays)with some additions:

1. Think about using two pointers more
2. Count the frequency of characters using a fingerprint frequency array over a hash table if input character set is fixed size
3. Bitmasking to find duplicates
4. Anagram checking using frequency over sorting
5. Palindrome checking using converging two pointers
6. Counting the number of palindromes using two pointers diverging from middle


# Linked Lists

Linked lists are useful when paired with hash tables as it allows search/insertion in O(1) time and they can also be used alone to solve problems

## Runtime analysis

1. Access: $$O(n)$$
2. Search: $$O(n)$$
3. Insert: $$O(1)$$ (assuming you traverse to the insertion position or insert at head)
4. Remove: $$O(1)$$ (assuming traversed to node removed)

## Corner cases

1. Empty linked list
2. Single node
3. Two nodes
4. Linked lists with cycles (must clarify)

## Techniques

### Sentinel/dummy nodes

Adding a dummy node to the front and/or end of the linked list is quite a powerful technique

* Helps resolve edge cases for operations performed at the head or tail
* Must be removed at the end of the operation
* Used when creating a doubly linked list to simplify implementation (see [#doubly-linked-list](#doubly-linked-list "mention") for more information)

### Two pointers

This is somewhat similar to the two pointers in [Arrays](/data-structures/arrays) but in the case of linked lists, the two pointers are often used to perform operations on different parts of the linked list.

* Getting kth from last node
  * Delay a second pointer by k and once seeking pointer reaches end, second pointer is the answer
* Detecting cycles
  * Two pointer with fast and slow pointers
* Getting the middle node
  * Fast and slow pointers where once fast is at the end, slow is in the middle

### Using additional linked lists

Rather than trying to operate solely on the given linked list, create a new linked list and copy nodes over

* Increases space usage but makes things less difficulty

### Linked list modifications

* Truncate list by setting `next` to `null`
* Swapping values (either by swapping reference or values)
* Combining two lists by attaching head of second list to tail of first list

### Reversing linked lists

Common problem and good to just recall how the algorithm is implemented

```python
prev, cur = None, head
while cur:
    next_node = cur.next
    prev = cur
    cur.next = prev
    cur = next_node
return prev
```

### Floyd’s cycle detection

Very useful algorithm to know as there are several ways to apply it:

1. Determine the center of a linked list
2. Determine if a cycle exists in a linked list
3. Find the start of the cycle in the linked list

* Fast and slow pointer to detect cycles when the pointers converge with one another

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2F3tZvedn92ukZZ5Rdy9Dx%2FUntitled.png?alt=media&amp;token=9f81f6eb-bb0d-4bde-b611-add46a02c073" alt=""><figcaption></figcaption></figure>

Finding the start of the cycle:

* Once cycle detected, we reset slow to be at the head again and move both pointers by 1 node until they converge again, that’s the starting of the loop
* The distance for the fast pointer to reach back to the start of the cycle is the same distance required for the slow pointer to reach the start of the cycle (so when they converge, that’s when the cycle starts)

$$
\text{fast}=x+y+z+y=x+2y+z\ \text{slow}=x+y\ \text{given that fast = 2 \* slow}\ x+2y+z = 2(x+y)\ x=z
$$


# Doubly Linked Lists

Doubly linked lists hold references to both the previous and next elements

{% hint style="info" %}
Doubly linked lists are particularly useful when paired with other data structures like hash tables since it allows for $$O(1)$$ retrieval and deletions
{% endhint %}

It is easiest to use a dummy head and tail pointer for the doubly linked list to avoid weird edge cases

```python
class DoublyLinkedList:
    def __init__(self):
        self.head = Node(0, 'HEAD')
        self.tail = Node(-1, 'TAIL')
	# Setup the dummy pointers
        self.head.next = self.tail
        self.tail.prev = self.head
        self.size = 0

    def insert_end(self, node):
        last = self.tail.prev
	# Fix the last element pointers
        last.next = node
        node.prev = last
	# Set the current element pointers
        node.next = self.tail
        self.tail.prev = node
        self.size += 1

        return node

    def delete(self, node):
        node_prev, node_next = node.prev, node.next
	# All we need to do is re-point the next and prev
	# Don't need to care too much about head/tail 
	# because we're using dummy pointers
        node.prev.next = node_next
        node.next.prev = node_prev
        node.next = None
        node.prev = None
        self.size -= 1

    def front(self):
        if self.head.next == self.tail: return None
        return self.head.next

    def length(self):
        return self.size

class Node:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None
        self.prev = None
```


# Hash Tables

Hash tables are a corner stone of data structures for most problems, they are useful when there are unique keys with given values

## Runtime analysis

1. Insert: $$O(1)$$
2. Search: $$O(1)$$
3. Delete: $$O(1)$$
4. Update: $$O(1)$$

## Take note…

1. Do we need a custom hashing function?

## Techniques

### Anagram hashing

This technique is particularly when trying to detect all anagrams and store them in buckets of same anagrams

* Multiplicative hash with prime numbers (3 onwards)
* Assign each character a prime number and calculate the hash
* Hash will be unique per anagram
* Does not scale well if more than 52 characters possible or $$n > 5000$$

```python
primes = [3, 5, 7, ...]
def hash(string):
	value = 1
	for ch in string:
		value *= primes[ord(ch) - ord('a')]
	return value
```

### Fingerprint hash tables/arrays over hash tables

I would recommend using these over hash tables where possible as they can represent the same information while reducing the complexity of the code

```python
freq = [0] * 26
# is the same as 
freq = {}
for ch in s:
    if ch not in freq:
        freq[ch] = 0
```

### Duplicate checking using bitmasks

This is a slight optimization over using hash tables/sets if the only thing that needs to be done is duplicate detection

* Not feasible if the numbers can be very large as size grows by powers of 2

```python
acc = 0
for ch in string:
	mask = 1 << (ord(ch) - ord('a'))
	if acc ^ mask < acc: # duplicate found
		return False
	acc |= mask
```

### Multiplicative hashing

$$
h(k) = \lfloor m(\phi \times k - \lfloor \phi \times k \rfloor) \rfloor
$$

* Where $$k$$ is the key to hash, $$\phi$$ is the golden ratio = $$\sqrt{5} - \frac{1}{2}$$

### Pairing with another data structure

Often used to improve performance at the cost of using more space to store the hash table

* Use [Linked Lists](/data-structures/linked-lists) to optimize to quickly delete/insert nodes in a certain order
  * Hash table has pointers to the nodes of the linked list

## Types of hashing

1. Chaining (linked list per bucket)
2. Open addressing (probing for a blank spot if current bucket is filled)


# Graphs

Graph problems are plentiful but the core data structure does have some unique techniques that can be applied to it

{% hint style="info" %}
Most of the algorithms used for graph problems are found under [Graph](/algorithms/graph) instead
{% endhint %}

## Runtime analysis

* DFS/BFS/Topological sort: $$O(|V| + |E|)$$
* Number of vertices is $$O(V)$$
* Number of edges is $$O(E)$$

## Graph representations

1. Adjacency matrix
2. Adjacency list
3. Hash table of hash tables

## Take note…

* Tree like diagrams could be a graph with cycles so clarify before assuming
* Correctly track visited nodes and not visit each node more than once

## Corner cases

1. Empty graphs
2. Graph with one or two nodes
3. Disconnected graphs
4. Graphs with cycles

## Techniques

### Shifting the goalpost

Rather than starting from the original problem, try reversing the problem and start with the end state in mind or the other set of elements

### "Virus" traversal

This method of traversal is also known as "level-order BFS" but I like to name is as a "virus" traversal as you can imagine it as a virus spreading across the graph.

The infected nodes spread to their neighbors at once, must like how level-order BFS works

* Queue all elements that fit a criteria and BFS from that queue at once
* Ensure that `visited` set is maintained so no duplicate elements are queued
* Useful for propagating an operation to all neighbors

Each batch of processing of processing is often seen as "1 day" of virus spreading of one step.


# Trees

Trees are special types of graphs that have exactly N - 1 edges with exactly one path between nodes. They are one of the most common category of problems so it is a must to master them

## Terminology

1. **Neighbor:** parent or child of a node
2. **Ancestor:** node reachable by traversing its parent chain
3. **Descendant:** node in the node’s subtree
4. **Degree:** number of children of a node
5. **Degree of a tree:** maximum degree of nodes in the tree
6. **Distance:** number of edges along the shortest path between two nodes
7. **Level/depth:** number of edges along the unique path between a node and the root node
8. **Height:** maximum number of edges between node till last leaf
9. **Width:** number of nodes in a level
10. **Complete binary tree:** every level except possibly last is completely filled, left first
11. **Balanced:** height differs no more than 1

## Traversals

1. Pre-order: SELF, left, right
2. In-order: left, SELF, right
3. Post-order: left, right, SELF

## Runtime analysis

Unbalanced trees have `h = n` in worst case, balanced trees have `h = log n`

1. Access: $$O(h)$$
2. Search: $$O(h)$$
3. Insert: $$O(h)$$
4. Remove: $$O(h)$$

## Corner cases

1. Empty tree
2. Single node
3. Two nodes
4. Skewed tree

## Take note…

1. When using BFS/DFS, `visited` array is not needed since tree has only 1 direction (assuming directed tree)

## Common operations

1. Insert value
   * Find appropriate position and insert
2. Delete value
   * Cases:
     1. No children: delete as per usual
     2. 1 child: swap node with child and delete
     3. 2 child: swap with successor and delete
3. Count number of nodes in tree
4. Whether value is in tree
   * Tree searching: think of conditions to branch left and right
5. Calculate height of the tree
   * `NULL` nodes have height of `-1` while leafs have height of `0`

{% code fullWidth="false" %}

```python
def height(node):
    if not node:
        return -1
    return max(height(node.left), height(node.right)) + 1
```

{% endcode %}

## Techniques

### Recursion

Most tree problems will require [Recursion](/algorithms/recursion) to some degree. Some tips to think about recursion for trees:

* Think of each recursive call as returning the value we want for all subsequent calls (often thinking of it as returning value from the left/right sub-tree)
* Try replacing the return result with multiple values (using a tuple or array) or returning another tree node

### Level-order traversal

Some problems can also be solved by traversing in level-order, much like the "virus" traversal for graphs

### Summation of nodes

Nodes can be summed to verify if nodes are negative

### Modifying existing traversal algorithms

Some problems such as [Binary Tree Right Side View](https://leetcode.com/problems/binary-tree-right-side-view/) requires some modification to the predefined traversal orders in [#traversals](#traversals "mention"). As such, think about possibilities of reversing the order of traversal to start from the right first

### Merkle hashing

This is a relatively unused technique but it is useful when you're trying to do tree comparisons to detect mismatches

* `SHA-256` is used to hash every leaf
* Combining the hashes of the left and right forms the hash of the parent's hash
* Repeat this process till the root of the tree to form the tree hash

I have only seen this technique used for [Subtree of Another Tree](https://leetcode.com/problems/subtree-of-another-tree/)

### Split tree problems

This is a unique class of tree problems where we break the problem into two smaller sub-problems:

1. Finding the overall answer
2. Finding the recursive answer

These problems often arise when the optimal answer can be found by either taking a path through a node (thus voiding the remaining path) or by continuing upwards through the traversal (as seen in [Binary Tree Maximum Path Sum](https://leetcode.com/problems/binary-tree-maximum-path-sum/) and [Diameter of Binary Tree](https://leetcode.com/problems/diameter-of-binary-tree/))

The first sub-problem focuses on solving the overall answer (by assuming a path is taken through the current node) while the second sub-problem focuses on solving the recursive answer, often optimizing the optimal value along a linear path (i.e. does not pass through a sub-tree's root)

```python
ans = 0
def split(node):
    nonlocal ans
    if not node:
        return -10**7
    
    left = split(node.left)
    right = split(node.right)
    ans = max(ans, left + right + node.val)
    return max(left, right) + node.val    
```

### Tree construction

For most tree construction questions, you will need both the pre-order/post-order and in-order traversal to re-construct the tree:

```python
# 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 buildTree(self, preorder: List[int], inorder: List[int]) -> Optional[TreeNode]:
        indices = {}
        N = len(preorder)
        for i in range(N):
            indices[inorder[i]] = i

        def build(i, l, r):
            if l > r:
                return None

            if l == r:
                return TreeNode(preorder[i])
            
            root_value = preorder[i]
            node = TreeNode(preorder[i])
            node.left = build(i + 1, l, indices[root_value] - 1)
            node.right = build(i + indices[root_value] - l + 1, indices[root_value] + 1, r)
            return node
        
        return build(0, 0, N - 1)
```

This is based on the property that the values in a pre-order traversal represent the root of each sub-tree throughout the traversal. The in-order traversal is split along the root's value, the left being the values found in the left sub-tree and the right being the values found in the right sub-tree

Another interesting property is that if given the choice to serialize a tree, you can achieve a complete reconstruction using only the pre-order traversal given that you also serialize the `NULL` nodes into fixed characters like `#`. This way, we can just continue removing elements from the top traversal and taking those as the left and right values

### Finding the center of a tree

A center node of a tree is one whose sub-trees have the minimum height, causing nodes to be distributed evenly on the left and right. Additionally, a tree can contain at most two centers

To find the center of a tree, topological sort (found under [Graph](/algorithms/graph)) can be used, eliminating all leaf nodes at most two nodes remain

```python
class Solution:
    def findMinHeightTrees(self, n: int, edges: List[List[int]]) -> List[int]:
        if n <= 2:
            return list(range(n))

        graph = {i: set() for i in range(n)}
        for a, b in edges:
            graph[a].add(b)
            graph[b].add(a)
        
        frontier = []
        for i, s in graph.items():
            if len(s) == 1:
                frontier.append(i)
            
        while n > 2:
            n -= len(frontier)
            next_frontier = []
            for i in frontier:
                for o in graph[i]:
                    graph[o].remove(i)
                    if len(graph[o]) == 1:
                        next_frontier.append(o)
            frontier = next_frontier

        return frontier
```


# Binary Search Trees

Binary Search Trees are the first of many specialized trees

{% hint style="info" %}
Binary search trees have the core property where `left <= node < right` thus allowing elements to be stored in sorted fashion easily
{% endhint %}

## Runtime analysis

BSTs have the same runtime as [Trees](/data-structures/graphs/trees) but if the tree is balanced, it implies that insertions of elements in sorted order can be done in $$O(\log n)$$ which is faster than what inserting into a sorted array can achieve

## Core properties

1. `left <= node < right`
2. In-order traversal produces a sorted array
3. The maximum value exists on the rightmost leaf of the right sub-tree
4. The minimum value exists on the leftmost leaf of the left sub-tree

## Techniques

Most of the techniques of a BST is the same as those [Trees](/data-structures/graphs/trees), however, there is a technique that only BSTs can achieve

### Using in-order traversal

The core property of BSTs allow in-order traversal to become a sequential traversal of the elements in sorted order. Once a problem mentions that the tree is a BST, try thinking of how to exploit this property to solve the problem


# Heaps

Heaps or priority queues are a very powerful data structure that can be used to easily store information that has some intrinsic order

{% hint style="info" %}
Heaps can be implemented as trees under the hood, but some are also implemented using arrays. I have chosen to stick to the implementation I was taught and park heaps under Trees
{% endhint %}

## Runtime analysis

1. Find min/max: $$O(1)$$
2. Insert: $$O(\log n)$$
3. Remove: $$O(\log n)$$
4. Heapify: $$O(n)$$

## Techniques

### Max heaps

In Python, `heapq` is defaults to using a min heap. This might not be what you want by default. The only way to make `heapq` work as a max heap is by inverting the values inserted

### K-smallest/largest

K-smallest implies using a k-sized max heap, removing the maximum element if the size becomes `> k`

K-largest implies using a k-sized min heap instead

### Using quick select instead

Sometimes, the information required from a k-sized heap can be replicated by using [Quick Select](/algorithms/quick-select). The benefit of doing so is that the runtime can be reduced to $$O(n)$$ whereas with heaps, it would be $$O(n \log n)$$

### Finding the median of data

Simulate the median by having a max heap for the elements to the left of the median and a min heap for the elements to the right of the median


# Tries

Tries are special types of trees that make searching and storing strings more efficient

```python
class Trie:
	def __init__(self):
		self.ch = [None] * 26
		self.end = False
	
	def insert(self, word):
		if not word:
			self.end = True
			return

		cur = self
		for ch in word:
			if not cur.ch[ord(ch) - ord('a')]:
				cur.ch[ord(ch) - ord('a')] = Trie()
			cur = cur.ch[ord(ch) - ord('a')]
		cur.end = True

	def word(self, target):
		if not target: return self.end
		
		cur = self
		for ch in target:
			if not cur.ch[ord(ch) - ord('a')]: return False
			cur = cur.ch[ord(ch) - ord('a')]
		return cur.end
```

## Runtime analysis

### Time complexity

`m` is the length of the string

* Search: $$O(m)$$
* Insert: $$O(m)$$
* Remove: $$O(m)$$

### Space complexity

$$O(m \times n)$$ where $$n$$ is the number of strings and $$m$$ is the length of the longest string

## Corner cases

1. Searching for a string in an empty trie
2. Inserting empty strings into a trie

## Techniques

### Preprocessing list of words

Use a trie to store a list of words to improve the efficiency for searching for a word of length `k` among `n` words

* It takes only $$O(k)$$ over $$O(n)$$

### Using DFS for pattern matching

Pattern matching with values like `*` and `.` can be performed using DFS where branching is done to all valid "next" characters when these characters are encountered, otherwise normal traversal is used

### Inverting what is stored

Some problems will use tries by storing a different set of data available, try out various inputs to see what works best

* See problems like [Word Search 2](https://leetcode.com/problems/word-search-ii/) for reference

### Augmenting nodes to store words that end at the node

This is a slight optimization to reduce the need to accumulate the strings as the Trie traversal is occurring

* See problems like [Word Search 2](https://leetcode.com/problems/word-search-ii/) for reference


# Segment Trees

Segment trees are a unique application of trees that help to solve problems that involve many range queries over an array

{% hint style="info" %}
Whenever you see problems that involve multiple range queries like finding the maximum across ranges, explore using segment trees
{% endhint %}

The fundamental idea of segment trees is that each leaf corresponds to an element in the array (by index) and each subsequent parent represents a range of that array, `i..j`. As a result, while segment trees can be represented as an actual tree, it can also just be represented directly using an array.

## Implementation

{% hint style="info" %}
For this implementation of segment trees, updates are relatively expensive because they have to traverse the entire tree to ensure that all nodes are updated accordingly. To optimize updates, you can look into [lazy propagation on segment trees](https://www.geeksforgeeks.org/lazy-propagation-in-segment-tree/)
{% endhint %}

### Building

Build from bottom up using merge sort like algorithms

```cpp
void build(int node, int start, int end)
{
    if(start == end)
    {
        // Leaf node will have a single element
        tree[node] = A[start];
    }
    else
    {
        int mid = (start + end) / 2;
        // Recurse on the left child
        build(2*node, start, mid);
        // Recurse on the right child
        build(2*node+1, mid+1, end);
        // Internal node will have the sum of both of its children
        tree[node] = tree[2*node] + tree[2*node+1];
    }
}
```

### Updating

```cpp
void update(int node, int start, int end, int idx, int val)
{
    if(start == end)
    {
        // Leaf node
        A[idx] += val;
        tree[node] += val;
    }
    else
    {
        int mid = (start + end) / 2;
        if(start <= idx and idx <= mid)
        {
            // If idx is in the left child, recurse on the left child
            update(2*node, start, mid, idx, val);
        }
        else
        {
            // if idx is in the right child, recurse on the right child
            update(2*node+1, mid+1, end, idx, val);
        }
        // Internal node will have the sum of both of its children
        tree[node] = tree[2*node] + tree[2*node+1];
    }
}
```

### Querying

When querying for a range, we're simply looking for the ranges (represented by our nodes) that fully encompass our search range

```cpp
int query(int node, int start, int end, int l, int r)
{
    if(r < start or end < l)
    {
        // range represented by a node is completely outside the given range
        return 0;
    }
    if(l <= start and end <= r)
    {
        // range represented by a node is completely inside the given range
        return tree[node];
    }
    // range represented by a node is partially inside and partially outside the given range
    int mid = (start + end) / 2;
    int p1 = query(2*node, start, mid, l, r);
    int p2 = query(2*node+1, mid+1, end, l, r);
    return (p1 + p2);
}
```


# Stacks

Stack problems usually come in the form of monotonic stack problems

## Runtime analysis

1. Top/peek: $$O(1)$$
2. Push: $$O(1)$$
3. Pop: $$O(1)$$
4. Search: $$O(n)$$
5. isEmpty: $$O(1)$$

## Corner cases

1. Empty stack
2. Stack with one item
3. Stack with two items

## Take note…

1. `pop()` is LIFO so if order of insertion matters, reverse the popped values

## Techniques

### Monotonic stack&#x20;

Push elements while monotonically increasing/decreasing. Then, keep popping the elements once the reverse occurs until either empty or incoming element is eventually increasing/decreasing

* The final stack can represent the overall values

### Mathematical equation parsing

Stacks are useful for problems like evaluating equations as you can push numbers onto the stack and operations just need to pop the top two elements of the stack


# Queues

Queue problems are less common but it is still a data structure that is worth understanding well

## Runtime analysis

1. Enqueue/offer: $$O(1)$$
2. Dequeue/poll: $$O(1)$$
3. Front: $$O(1)$$
4. Back: $$O(1)$$
5. isEmpty: $$O(1)$$

## Take note…

1. Built in data structure like `[]` in Python use $$O(n)$$, not $$O(1)$$
   * Check if can assume data structure is optimal
   * To optimize for Python, use `from collections import deque` instead to `popleft()`

## Corner cases

1. Empty queues
2. Queue with one item
3. Queue with two items

## Techniques

### Rotating the queue on itself

Pop the queue and push back onto itself to bring the last element to the front


# Double Ended Queues

For most double ended queue problems, focus on the following questions to determine how to best use the double ended queue:

* When to push left
* When to push right
* When to pop left (more important)
* When to pop right (more important)


# Union-Find Disjoint Set (UFDS)

UFDS is a straightforward but powerful data structure often used to determine when data is in the same set as one another

## Runtime analysis

| Type                              | Find(p)  | Union(p, q) |
| --------------------------------- | -------- | ----------- |
| Quick Find                        | O(1)     | O(n)        |
| Quick Union                       | O(n)     | O(n)        |
| Weighted union (union by rank)    | O(log n) | O(log n)    |
| Path compression                  | O(log n) | O(log n)    |
| Weighted union + path compression | a(m, n)  | a(m, n)     |

## Implementation

### Key variables

```python
parent = list(range(n + 1))
rank = [0] * (n + 1)
```

### Find with path compression

```python
def find(p):
	if p == parent[p]:
		return p
	
	parent[p] = find(parent[p])
	return parent[p]
```

### Union by rank

```python
def union(p, q):
	root_p = find(p)
	root_q = find(q)
	if rank[root_p] > rank[root_q]:
		parent[root_q] = root_p
		rank[root_p] += 1
	else:
		parent[root_p] = root_q
		rank[root_q] += 1
```


# Dynamic Programming Roadmap

Going through the problems found in the dynamic programming roadmap

{% hint style="info" %}
From here on out, I will be referring to Dynamic Programming as DP instead\
\
Another note is that while some of these problems have a more optimal approach, I will not discuss them in their respective guides because the focus of this problem guide is to cover how DP problems can be approached using DP
{% endhint %}

To find the list of problems in this roadmap, refer to [Study Plan](/getting-started/study-plan#dynamic-programming-roadmap)&#x20;

The core patterns for dynamic programming can be found under [Dynamic Programming](/algorithms/dynamic-programming)


# Warmup

These are just basic problems to ease you into the idea of DP, the further questions are not as easy and will take some time to get used to.&#x20;

Do NOT worry about that and just focus on learning as much as you can.


# Climbing Stairs

## Transitions

Observe that for every stair, you can make two decisions:

1. Climb one step
2. Climb two steps

## Recurrence tree

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2FQdZPtconSLP6XhSFWP6s%2Fimage.png?alt=media&amp;token=bcd00698-fe33-410d-897e-18da0766d128" alt="" width="563"><figcaption></figcaption></figure>

We don't count leaves that end with values where `v > n`. We can also memoize the results of sub-trees such as `2`, reducing the computation of the right sub-tree to a $$O(1)$$ lookup.

## Top-down

{% hint style="info" %}
Top-down can often be implemented by directly modelling the state transitions
{% endhint %}

```python
def climb_stairs(n):
    memo = {}
    def climb(m):
        if m in memo: return memo[m]
        if m == n: return 1
        if m > n: return 0
        memo[m] = climb(m + 1) + climb(m + 2)
        return memo[m]
```

The recurrence relation above looks like this:

$$
dp(m) = \begin{cases}
1, m == n\\
0, m > n \\
dp(m + 1) + dp(m + 2)
\end{cases}
$$

## Bottom-up

{% hint style="success" %}
**Trick:** Re-framing the problem\
\
A way to convert top-down to bottom-up solutions is to **re-frame the problem** in a way that only **restricts your view of the data to a subset**, such as the prefix/suffix/sub-array. Solving this subset gives you the tools to solve larger subsets.\
\
A good way to do this is to ask: "Given index i, how do I use the solutions of 0 to i-1 (prefix) or i+1 to n (suffix) solve for index i?"
{% endhint %}

By re-framing the problem, we will notice that if we are index $$m$$ and we have computed the optimal solution for $$0..m-1$$ (prefix), then we can obtain the optimal solution for $$m$$ using the following recurrence:

$$
dp(m) = \begin{cases}
1, m = 1\\
2, m = 2\\
dp(m-1) + dp(m - 2)
\end{cases}
$$

{% hint style="success" %}
**Trick:** Interpreting recurrence relations\
\
Define what $$dp(i)$$ means and read the recurrence in terms of that definition.\
\
For instance, the above can be interpreted as:\
\
$$dp(m)$$ is the number of ways to reach step $$m$$\
If $$m = 1$$, then it means we can only take 1 step to reach it (i.e. 1 way)\
If $$m = 2$$, then we can either take 2x1 step or 1x2 steps to reach it (i.e. 2 ways)\
Otherwise, the number of ways to reach step $$m$$ is by stepping once from $$m-1$$ or stepping twice from $$m-2$$
{% endhint %}

```python
def climb_stairs(n):
    if n == 1: return 1 # corner case
    stairs = [0] * (n + 1)
    stairs[1] = 1 # reaching stair 1 takes 1 way
    stairs[2] = 2 # reaching stair 2 takes 2 ways
    for i in range(3, n + 1):
        stairs[i] = stairs[i - 1] + stairs[i - 2]
    return stairs[n]
```

## Optimization

From the recurrence relation, we only ever require the previous 2 states, `dp(m - 1)` and `dp(m - 2)` to compute `dp(m)`. So, we can store these 2 states as variables instead of using an array.

{% hint style="success" %}
**Optimization:** $$n$$-state caching\
\
If your recurrence relies on a finite number of $$n$$ states only, we can use $$n$$ variables to represent these states, removing the need for an array
{% endhint %}

```python
def climb_stairs(n):
    if n == 1: return 1 # corner case
    s1, s2 = 1, 2 # s1 -> dp(m-2), s2 -> dp(m-1) 
    for i in range(3, n + 1):
        s1, s2 = s2, s1 + s2
    return s2
```

{% hint style="info" %}
Keen observers will notice that this is basically the Fibonacci sequence
{% endhint %}


# Nth Tribonacci Number

This problem is basically [Climbing Stairs](/problems-guide/dynamic-programming-roadmap/warmup/climbing-stairs) but using three states instead.

## Bottom-up

$$
dp(m) = \begin{cases}
0, m = 0\\
1, m = 1\\
1, m = 2\\
dp(m-2) + dp(m - 1) + dp(m)
\end{cases}
$$

As discussed in [Climbing Stairs](/problems-guide/dynamic-programming-roadmap/warmup/climbing-stairs), since we only rely on the past `3` states, we can use $$n$$ state caching and store them as variables instead.

```python
def tribonacci(n):
    if n == 0: return 0 # base case
    t0, t1, t2 = 0, 1, 1
    for i in range(3, n + 1):
        t0, t1, t2 = t1, t2, t0 + t1 + t2
    return t2
```


# Perfect Squares

{% hint style="info" %}
There is a purely [mathematical solution](https://leetcode.com/problems/perfect-squares/solutions/71488/summary-of-4-different-solutions-bfs-dp-static-dp-and-mathematics/) for this as well but I won't cover it here
{% endhint %}

## Observations

* The number of perfect square numbers (PSN) that sum to a perfect square is `1`
* The minimum number of PSNs to form `n` is found by trying all possible combinations of perfect square numbers
* Using PSN `p` means we have to find the minimum number of PSNs to form `n - p` after

## Recurrence relation

$$
dp(n) = \begin{cases}
1, \lfloor{\sqrt{n}}\rfloor \times \lfloor{\sqrt{n}\rfloor} = n\\
\forall p \in Z, p^2 < n \land \min(1+dp(n-p))
\end{cases}
$$

We add $$1$$ to $$dp(n-p)$$ because we are using $$p$$ as the first perfect square.

{% hint style="success" %}
**Recurrence pattern:** past ~~lives~~ states<br>

Some states rely on multiple previously computed state. This contrasts the $$n$$ state caching optimization as $$n$$ is not fixed.&#x20;
{% endhint %}

## Bottom-up

```python
def perfect_squares(n):
    dp = {}
    for i in range(1, n + 1): # we want to iterate from [1, n]
        root = i**0.5
        if int(root)**2 == i:
            dp[i] = 1
        else:
            p = 1
            dp[i] = 10**9 # we set it to 10^9 because we are using min()
            while p**2 < i:
                dp[i] = min(dp[i], 1 + dp[i - p**2])
                p += 1
    return dp[n]
```

{% hint style="success" %}
**Trick:** using impossibly high/low numbers to initialize $$dp(i)$$\
\
If `min(dp[i], ...)` is used, then use an impossibly high number to initialize `dp[i]`\
\
Otherwise, if `max(dp[i], ...)` is used, then use an impossibly low number or `0` if no other states can be `0`.
{% endhint %}


# Linear Sequence

In general, the problems under this category fall under a similar problem scope as [Perfect Squares](/problems-guide/dynamic-programming-roadmap/warmup/perfect-squares) where the optimal answer can usually acquired by solving for every prefix/suffix of the array and then using the previously computed past states to compute the current state.

This is a combination of the trick to "re-frame the problem" and the recurrence pattern of "past states".


# Min Cost to Climb Stairs

## Observations

* On each step `n`, we must incur the cost since we have to decide whether or not to move one or two steps up
* To reach step `n`, you must move from step `n - 1` or step `n - 2`
* To minimize the cost to reach step `n`, we need to minimize the cost it takes to reach it

## Recurrence relation

$$
dp(n) = \begin{cases}
cost(0), n = 0\\
cost(1), n = 1\\
\min(dp(n-1),dp(n-2)) + cost(n)
\end{cases}
$$

We can define $$dp(n)$$ as the minimum cost it takes to climb from step $$n$$. If we start at the first step, the cost is $$cost(0)$$ and if we start at the second step, the cost is $$cost(1)$$. Otherwise, for every step $$n$$, the cost to climb from it is the cost it took to reach it with the cost of leaving the step.

## Bottom-up

The $$n$$ state caching optimization can be applied here again, given that each state depends only on the previous 2 states.

```python
def climb_stairs(costs):
    s1, s2 = costs[0], costs[1]
    for i in range(2, len(costs)):
        s1, s2 = s2, min(s1, s2) + costs[i]
    return min(s1, s2)
```


# Minimum Time to Make Rope Colorful

{% hint style="info" %}
There is are approaches that uses [Arrays](/data-structures/arrays#two-pointers) or greedy instead but I will not cover those.
{% endhint %}

## Clarification

Consecutive balloons do not count the popped balloons, so popping the middle `R` in `RRR` will still violate the criteria of having no consecutive colors.

## Observations

Let's start with an arbitrary rope with balloons: `RRRGGBB` and an arbitrary cost of `[2, 1, 5, 3, 4, 1, 2]`. From hand-simulation, we know that the ultimate "optimal" answer is `XXRXGBX` where `X` are the popped balloons.

1. For every segment of like colors, we never pop the highest cost balloon to achieve the lowest possible cost within the segment (the mathematical proof is relatively easy to come up with)
2. When encountering a same color, a decision between popping the current balloon or the previously seen, unpopped, same balloon should be made, with the decision made to be the smallest of the two (see (1))
3. For every segment of same color, once a balloon encountered is of a different color, there's no need to pop anymore of the previous color so the color tracked can be changed

## Recurrence relation

$$
dp(i) = \begin{cases}
dp(i-1), r\[i] \neq r\[prev]\\
dp(i-1)+\min(time\[i], time\[prev])
\end{cases}
$$

`prev` is needed alongside the recurrence.

Processing from left to right, $$dp(i)$$ can be interpreted as the minimum time to make the rope $$r\[:i+1]$$ colorful. This means we are solving for the prefix of the array.

If $$r\[i]$$ is the same as the previous color, we need to make the decision to pop based on observation (1) using the minimum between the current cost to pop and the cost to pop the previous same color balloon.

If $$r\[i]$$ is not the same, then we simply need to update the "previous color" and we know that $$dp(i) = dp(i-1)$$ since there is no balloons to pop.

## Bottom-up

We will apply the $$n$$ state caching optimization, where $$n = 1$$, only storing the previous time to make the rope colorful as $$t$$.

```python
def min_time(r, time):
    n = len(time)
    t = 0
    prev = 0
    for i in range(1, n):
        if r[i] == r[prev]:
            t += min(time[i], time[prev])
            if time[prev] < time[i]:
                prev = i # since we pop the prev balloon, we have a new prev
        else:
            prev = i # we don't need to track the same color anymore
    return t
```


# House Robber

## Transitions

1. Robber chooses to rob the current house
2. Robber does not rob the current house

## Top-down

The naive top-down approach would be trying all possibilities given the transitions:

```python
def house_robber(houses):
    def rob(i):
        if i >= len(houses):
            return 0
        return max(rob(i + 1), rob(i + 2) + houses[i])
    return rob(0)
```

If house `i` isn't robbed, then we can move on to house `i + 1` to try again.

If house `i` is robbed, then we can only start robbing from house `i + 2` onwards.

This generates the following recurrence relation:

$$
dp(i) = \begin{cases}
0, i >= |houses|\\
\max(dp(i+1), dp(i+2)+houses\[i])
\end{cases}
$$

## Deriving bottom-up

We will apply the trick of "re-framing the problem" to derive a bottom-up solution. Given a prefix of $$i-1$$ houses, can the optimal answer for house `i` be derived?

To rob house `i`, we must do so when we had not robbed the previous house, thus, we must have robbed at most house `i - 2`.

If house `i` is not going to be robbed, then the most amount of money we can rob from house `0` to `i` is the same as if we only looked out houses `0` to `i - 1`.

As a result, we can form a new recurrence relation:

$$
dp(i) = \begin{cases}
houses\[0], i =0\\
\max(houses\[0], houses\[1]), i = 1\\
\max(dp(i-1), dp(i-2)+houses\[i])
\end{cases}
$$

The base cases arise from the following observations:

1. If you only have 1 house, the optimal choice is always to rob it
2. If you have 2 houses, you can either rob the first but not the second or rob the second but not the first so we pick the maximum of the two

## Bottom-up

We will apply the $$n$$ state caching optimization where $$n = 2$$.

```python
def house_robber(houses):
    if len(houses) == 1: return houses[0]
    h1, h2 = houses[0], max(houses[0], houses[1])
    for i in range(2, len(houses)):
        h1, h2 = h2, max(h2, h1 + houses[i])
    return h2
```

Note that `h1` corresponds to `dp(i-2)` and `h2` corresponds to `dp(i-1)`.


# Decode Ways

## Transitions

1. If `s[i] = '0'` , then invalid ways (0)
2. If `s[i] = '1'`, then we can choose to take `s[i]` as it is, or pair it with the next digit (no matter what, it will form a valid number)
3. If `s[i] = '2'`, then we can choose to take `s[i]` as it is, or pair it with the next digit as long as the next digit is from `'0'` to `'6'`
4. Any other digits have to be taken as it is

## Top-down

Modelling the transitions as-is gives us:

$$
dp(i) = \begin{cases}
0, s\[i] = 0\\
1, i \geq |s|\\
dp(i+1), s\[i] \not \in \[1,2]\\
dp(i+1) + dp(i+2), s\[i] = 1 \lor (s\[i] = 2 \land s\[i+1] < 6)
\end{cases}
$$

```python
def decode_ways(s):
    def ways(i):
        if i >= len(s): return 1
        if s[i] == '0': return 0
        ans = ways(i + 1)
        if i + 1 < len(s) and (s[i] == '1' or (s[i] == '2' and s[i + 1] <= '6')):
            ans += ways(i + 2)
        return ans
    return ways(0)
```

We can then memoize the value of each recursive call by index `i`.

## Bottom-up

To solve this problem using bottom-up, let's re-frame the problem. However, notice that we cannot use the prefix of the array. This is because if we were to use the prefix `s[:i+1]`, we would need to look-ahead to `s[i+1]`, which should not be available yet. So we can re-framing the problem using suffixes instead:

> Given $$\[i, n)$$, can we find out how many ways there are to form $$s\[i-1]$$?

{% hint style="success" %}
**Trick:** Implementing recurrence relations\
\
If the recurrence relation looks like $$dp(i) = dp(i - 1)$$, then it must be processed from left to right using prefixes. If it looks like $$dp(i) = dp(i + 1)$$, then it must be processed from right to left using suffixes.
{% endhint %}

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2Fa2MyQUGIp4Uq96eWWmhc%2Fimage.png?alt=media&amp;token=5e5f41be-145b-4557-834d-bd1e9a88f150" alt="" width="274"><figcaption></figcaption></figure>

Looking at the example above, if we have index `i`, then we can use the values computed from `i+1` onwards to figure out how many ways there are to form `s[i:].`

This gives us the recurrence relation:

$$
dp(i) = \begin{cases}
1, i = |s|\\
0, s\[i] = 0\\
dp(i+1), s\[i] \not\in \[1, 2]\\
dp(i+1) + dp(i+2), s\[i] = 1 \lor (s\[i] = 2 \land s\[i+1] < 6)
\end{cases}
$$

Notice that it looks very similar to the original recurrence as we are essentially doing the same operations.&#x20;

We can also apply the $$n$$ state caching optimization, where $$n = 2$$.

```python
def decode_ways(s):
    n = len(s)
    if n == 1: return 1
    s1, s2 = 1, 0 # s1 -> dp(i+1), s2 -> dp(i+2)
    for i in range(n - 1, -1, -1):
        si = 0 if s[i] == '0' else s1 # equivalent to collapsing the first 3 cases
        if i < n - 1 and (s[i] == '1' or (s[i] == '2' and s[i + 1] <= '6')): 
            si += s2
        s1, s2 = si, s1
    return s1
```

Note that the default values of `s1` and `s2` are both derived from the two base cases we have, with `s1 = 1` because `i = |s|` and `s2 = 0` because `i > |s|` (out of bounds so no ways to form it).


# Minimum Cost for Tickets

## Transitions

1. On day `i`, if there is an existing ticket covering today, then there is no need to buy any more
2. On day `i`, if there are not existing tickets covering today, then we can try buying all three kinds of tickets

A part of the transitions involves keeping track of the duration of the ticket bought.

## Top-down

Modelling the transitions, we get:

```python
def cost_tickets(days, prices):
    def solve(i, expiry):
        if i >= len(days): return 0
        if days[i] < expiry: return solve(i + 1, expiry)
        buy_one = solve(i + 1, days[i] + 1) + prices[0]
        buy_seven = solve(i + 1, days[i] + 7) + prices[1]
        buy_thirty = solve(i + 1, days[i] + 30) + prices[2]
        return min(buy_one, buy_seven, buy_thirty)
    return solve(0, 0)
```

The recurrence relation is:

$$
dp(i, e) = \begin{cases}
0, i \geq |days|\\
dp(i+1, e), days\[i] < e\\
\min \begin{cases}
dp(i+1, days\[i]+1)+prices\[0]\\
dp(i+1, days\[i]+7)+prices\[1]\\
dp(i+1, days\[i]+30)+prices\[2]
\end{cases}
\end{cases}
$$

Where we would be trying to buy all types of tickets whenever we aren't being covered by a ticket. However, this means that we are trying about $$3^N$$ possibilities. While memoization can help, it may be better to try reducing the trial-and-error.

## Bottom-up

> Given days $$\[0, i)$$, what is the minimum cost to travel on day `i`?

If we intend to buy a ticket on day `i`, we cannot be under an existing ticket. So we have to look backwards by (1/7/30) days to find the last time that ticket was bought.

To determine what's the cheapest, we would simply pick the cheapest among the three possible choices when we are looking back.

Hence, the following recurrence relation:

$$
dp(i) = \begin{cases}
0, i-1 < 0 \lor i -7 < 0 \lor i -30 < 0\\
dp(i-1), i \not\in days\\
\min \begin{cases}
dp(i-1) + prices\[0]\\
dp(i-7) + prices\[1]\\
dp(i-30) + prices\[2]
\end{cases}
\end{cases}
$$

If we aren't going to travel on day `i`, then we should not try purchasing a ticket, and instead defer the computation via $$dp(i) = dp(i - 1)$$.

```python
def cost_tickets(days, prices):
    last_day = days[-1]
    dp = [0] * (last_day + 1)
    days = set(days)
    for i in range(1, last_day + 1):
        if i not in days: 
            dp[i] = dp[i - 1]
        else:
            one_day = dp[max(0, i - 1)] + prices[0]
            seven_days = dp[max(0, i - 7)] + prices[1]
            thirty_days = dp[max(0, i - 30)] + prices[2]
            dp[i] = min(one_day, seven_days, thirty_days)
    return dp[last_day]
```


# Solving Questions with Brainpower

## Transitions

For question `i`, either:

1. Solve the question, gain the points, and only be able to answer the `i + cooldown` problem onwards
2. Skip the question

## Top-down

```python
def brainpower(questions):
    def solve(i):
        if i >= len(questions): return 0
        return max(solve(i + 1), solve(i + questions[i][1] + 1) + questions[i][0])
    return solve(0)
```

The recurrence relation, much like other [Linear Sequence](/problems-guide/dynamic-programming-roadmap/linear-sequence) problems is obtained by directly mapping the transitions.

$$
dp(i) = \begin{cases}
0, i \geq |q|\\
\max(dp(i+1), dp(i+q\[i]\[1]+1)  +q\[i]\[0])
\end{cases}
$$

## Bottom-up

The recurrence is the same as the top-down approach as we need to have information about the "next" state, so prefixes will not be an ideal solution for this.

This means we will have to iterate from the back. We also add a padding of `0` towards the end of the array to represent the case when `i + 1` or `i + questions[i][1] + 1` exceed `N` (length of questions).

{% hint style="success" %}
**Trick:** Boundary conditions\
\
Very often you will encounter state transitions that can cause out of bounds such as `i + 1 >= N` above. In these scenarios, you can introduction boundary conditions by modifying the `dp` array to include additional leading/trailing elements.\
\
If the state transition may go *very* out of bounds like `i + questions[i][1] + 1`, then you can use `min(transition, N)` to default it to use the boundary condition.\
\
The value of the boundary condition is often the case when there is nothing to process. In this case, we add `0` as that's the default value that exists when there's "no questions".
{% endhint %}

```python
def brainpower(questions):
    N = len(questions)
    dp = [0] * (N + 1)
    for i in range(N - 1, -1, -1):
        dp[i] = max(dp[i + 1], dp[min(i + questions[i][1] + 1, N)] + questions[i][0]) 
    return dp[0]
```

## Further discussion

Actually, there is a way to implement the bottom-up solution using the "re-framing the problem" trick.

> Given that we want to solve question `i`, what's the maximum points achievable?

By thinking of the problem this way, we can devise an approach to figure out how to forcibly "solve" question `i`. We can iterate through `0..i-1` to find all days where we would have solved it and the cooldown does not exceed `i`. This results in the following recurrence instead:

$$
dp(i) = \max(q\[i]\[0], \forall j \in \[0, i), j + q\[j]\[1] < i \land dp(j) + q\[i]\[0])
$$

The reason we do not take $$dp(i - 1)$$ is because if $$dp(i - 1)$$ is chosen, we might not be able to solve question `i`. The only problem with this solution is that it takes $$O(n^2)$$ time to compute which does not fit within the constraints of the problem.

However, I would like to bring this up as an alternative thought process to highlight how sometimes "re-framing" the problem might not always be the optimal solution.


# General Problem Solving

Consolidating some of the common patterns and problem solving methods you can try when working on problems

{% hint style="info" %}
This list is not exhaustive! If you wish to contribute more techniques, please email me at <woojiahao1234@gmail.com>
{% endhint %}

1. Finding the median of data: focus on the definition of a median and model the solution after it
2. Sub-array problems: think about using sliding windows discussed under [Arrays](/data-structures/arrays)
3. Sub-sequence problems: think about sorting (if possible)
4. $$O(n \log n)$$ upper bound (found using [Runtime Predictions](/other-technical-topics/runtime-predictions))
   1. Divide and conquer, similar to merge sort
   2. [Arrays](/data-structures/arrays) manipulation + [Binary Search](/algorithms/binary-search) such as prefix sums + binary search
   3. [Sorting](/algorithms/sorting) + operations on sorted array
   4. [Segment Trees](/data-structures/graphs/trees/segment-trees)+ iterating over all ranges
   5. Applying the sweep line algorithm if there are [Intervals](/algorithms/intervals) + events
5. Minimum/maximum across queries: try using [Heaps](/data-structures/graphs/trees/heaps) or [Double Ended Queues](/data-structures/queues/double-ended-queues)
   1. [Heaps](/data-structures/graphs/trees/heaps) can be used to track the minimum during any query
   2. [Double Ended Queues](/data-structures/queues/double-ended-queues) can be used to represent the current maximum (as the front) and potential maximums (subsequent elements) in the event where the current maximum "expires"


# Runtime Predictions

These are some of the more commonly seen runtimes based on the input constraints

{% hint style="info" %}
These predictions are taken from this [Codeforces article.](https://codeforces.com/blog/entry/21344)
{% endhint %}

| n <=     | Allowed Time Complexity   |
| -------- | ------------------------- |
| 12       | $$O(n!)$$                 |
| 25       | $$O(2^n)$$                |
| 100      | $$O(n^4)$$                |
| 500      | $$O(n^3)$$                |
| $$10^4$$ | $$O(n^2)$$                |
| $$10^6$$ | $$O(n \log n)$$           |
| $$10^8$$ | $$O(n)$$                  |
| Others   | $$O(\log n)$$ or $$O(1)$$ |


# System Design

While rare, system design problems are quite challenging to tackle if it's the first time you are dealing with them. The best practice is building real-world projects

{% hint style="info" %}
There is no better substitute to getting better at design systems than building projects that use these techniques, however, if you are short on time, I have tried compiling the key ideas of system design.\
\
The structure and some of the components have been inspired by this Medium article about the [glossary of system design basics](https://medium.com/@wakefulinsomnia/glossary-of-system-design-basics-longread-69fef72c4079) and I have updated it to suit my needs when revising. Some of the diagrams have been used from that article as well
{% endhint %}

## Characteristics of Distributed Systems

### Scalability

{% hint style="info" %}
Ability of a system, process, or network to grow and cope with increasing demand
{% endhint %}

* Horizontal scaling: scale by adding more servers (Cassandra/MongoDB)
* Vertical scaling: scale by adding more power to existing server (MySQL)
  * Involves downtime of the current server

### Reliability

{% hint style="info" %}
Probability a system will fail in a given period
{% endhint %}

* Focus on redundancy of both software and data components
* Availability over time
* Reliability and performance are exclusive
  * High reliability but poor performance and high performance but poor reliability are both possibilities

### Availability

{% hint style="info" %}
Time a system remains operational to perform its required function
{% endhint %}

* Includes maintainability, repair time, spares availability, etc.
* Reliable → Available but converse is not true
* Remaining accessible even if one or more nodes is down

### Efficiency

{% hint style="info" %}
Measured using **latency** and **throughput**
{% endhint %}

* Latency: delay to obtain the first item (time spent waiting to get first item)
* Throughput: number of items delivered in a given time unit (how many items can be sent in a time period)

### Serviceability/Manageability

{% hint style="info" %}
Simplicity/speed with which a system can be repaired or maintained
{% endhint %}

* Ease of diagnosing and understanding problems, ease of making updates/modifications, how simple the system is to operate

## Load Balancing

{% hint style="info" %}
Spreads traffic across a cluster of servers to improve responsiveness and availability of applications. Keeps track of status of all resources while distributing requests
{% endhint %}

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2FGfLb3DF6ChDGQLAcxFAo%2FUntitled_2.png?alt=media&amp;token=e1ad9922-4a3a-4a05-bccc-6740a9f108e2" alt="" width="563"><figcaption></figcaption></figure>

* Reduces individual server load and avoids single point of failure
* Added:
  * Between user and web server
  * Between web server and internal platform layer like application/cache servers
  * Between internal platform layer and database

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2Fq2Q6jVkLh4xJV9E9z6i9%2FUntitled_3.png?alt=media&amp;token=69043acf-5cdc-4756-89b4-6c14786a5902" alt=""><figcaption></figcaption></figure>

* Health check: periodically attempt to connect to backend server, if failed, removes server from pool

### Benefits

* Faster, uninterrupted service
* Service providers experience less downtime and higher throughput
* Easier for system administrators to handle incoming requests while decreasing wait time for others
* Smart load balancers can provide predictive analytics, detecting traffic bottlenecks before they happen
* Fewer failed or stressed components

### Algorithms

* Least connection method: server with fewest active connections; useful for large number of persistent client connections
* Least response time method: server with fewest active connections and lowest average response time
* Least bandwidth method: server that is currently serving the least amount of traffic measured in Mbps
* Round robin method: cycles through list of servers and sends each new request to the next server; useful when servers are of equal specification and not many persistent connections
* Weighted round robin method: servers with different processing capabilities; each server has a weight and higher weighted servers receive new connections first
* IP hash: hash of IP address of client is calculated to redirect request to server
* Redundancy: second load balancer can connect to primary to form cluster; each perform health checks on each other, if main is down, secondary takes over

## Caching

{% hint style="info" %}
Make better use of existing resources and achieving previously unattainable requirements
{% endhint %}

* Take advantage of locality of reference principle: recently requested data is likely to be requested again
* Application server cache: caching database resources or user requested data; can be distributed to global/distributed cache
* Content Delivery Network (CDN): caching for blobs/static media
  * Push CDN: changes from server uploaded directly to CDN; good for low traffic systems
  * Pull CDN: new content grabbed from the server when the first user requests the content from the site; slower requests due to cache miss; good for high traffic website

### Cache invalidation

* Write-through: write to both database and cache; high reliability, high latency for writes
  * Achieves data consistency
  * Introduce delay when writing
* Write-around: data written directly to database; less write operations to cache but cache miss needed to cache record
* Write-back: write to cache first and periodically write to database; low latency and high throughput for write intensive, risky for data loss; async write-through
  * Better performance

### Cache eviction policies

* FIFO: first block accessed first
* LIFO: last block accessed
* LRU: least recently used
* MRU: most recently used
* LFU: items with least use discarded first
* Random Replacement: randomly selected

## Data Partitioning

{% hint style="info" %}
Breaking up a big database into many small parts
{% endhint %}

* Improves manageability, performance, availability, and load balancing
* Grow horizontally instead of vertically

### Partitioning methods

* Horizontal: range-based partitioning (store rows with key in range to different tables); key must be balanced; [#sharding](#sharding "mention")
* Vertical: divide data into separate servers; further growth can force each component to partition more finely; [#partitioning](#partitioning "mention")
* Dictionary-based: lookup service that maps each key to the database server with specific partitioning algorithm; easy to add new servers/change partitioning scheme

### Partitioning criteria

* Key/Hash-based partitioning: hash a key attribute to get partition number; fixes total number of servers since more servers requires hash function to change, involving redistribution of data and downtime; resolve using consistent hashing
* List partitioning: each partition assigned list of values and assign records to partition containing key
* Round-robin partitioning: given `n` partitions, `i` row assigned to partition `i % n`
* Composite partitioning: combining the above partitioning; consistent hashing combines hash and list partitioning to reduce key-space to size that can be listed

### Problems with data partitioning

* Joins and denormalization: not feasible to perform joins that span database partitions and not efficient; workaround to denormalize database so queries can be performed from single table; introduces risk of data inconsistency
* Referential integrity: enforcing integrity constraints like foreign key is difficult, most RDBMS does not support cross server foreign key; requires enforcement on application layer & periodic jobs to clean dangling references
* Rebalancing: non-uniform data distribution and high load on partition requires more database partitions or rebalancing of existing partitions, requiring movement of data to new locations; use dictionary-based partitioning to make rebalancing easier but system more complex and single point of failure (lookup table)

## Indexing

{% hint style="info" %}
Makes searching through a table faster by using one or more columns of the database table, providing the basis for both rapid random lookups and efficient access of ordered records
{% endhint %}

* Speed up data retrieval by introducing more keys but can reduce data insertion/update/delete time
* Not worth using if write-heavy

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2FHETYhErSHJlMrvnL4obR%2FUntitled_4.png?alt=media&amp;token=d4ea43d2-9a05-478f-af1b-045e1a70cef3" alt="" width="563"><figcaption></figcaption></figure>

## Proxies

{% hint style="info" %}
Intermediate piece of software/hardware that sits between client and server, facilitating requests for resources from other servers on behalf of the client, anonymizing the client (forward proxy) or anonymizing the server (reverse proxy)
{% endhint %}

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2FyBQ8TKUhhwnNkFpbhHSy%2FUntitled_5.png?alt=media&amp;token=c083374d-cfb3-4072-b3d0-a37b2899697c" alt="" width="563"><figcaption></figcaption></figure>

### Forward proxy

Cache data, filter requests, log requests, transform requests (add/remove headers, encrypt/decrypt, compressing resource)

* Optimize request traffic by combining same data access requests into one request and return the result to the user (collapsed forwarding)

### Reverse proxy

Retrieve resources from one or more servers on behalf of a client, anonymizing the server

* Similar role to forward proxy

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2FcNH4M9cdjNJRYYonlNO4%2FUntitled_6.png?alt=media&amp;token=25b07a72-7a83-45ba-95d9-2e0643535e23" alt="" width="563"><figcaption></figcaption></figure>

## Redundancy & Replication

{% hint style="info" %}
Redundancy is the duplication of critical components or functions of a system with the intention of increasing the reliability of the system, usually in the form of a backup or fail-safe, or to improve actual system performance
{% endhint %}

* Removes single points of failure and provides backup in case of crisis
* Focus on components/servers

{% hint style="info" %}
Replication is the sharing of information to ensure consistency between redundant resources (software/hardware) to improve reliability, fault-tolerance, or accessibility
{% endhint %}

* Focus on the data
* Primary-replica relationship between original and copies
  * Primary gets all updates, ripple through to replica servers
  * Replicas outputs a message stating it has received the update and allow subsequent updates

## SQL vs NoSQL

* Relational database: structured with predefined schemas like phone books that store phone numbers and addresses
* Non-relational database: unstructured, distributed, and have dynamic schema

### SQL

* Relational database
* Stored in rows and columns
* Row contains all information about entity
* Column contains separate data points

### NoSQL

* Key-value stores: data stored as key-value pairs; e.g. Redis, Voldemort, Dynamo
* Document databases: data stored as documents and grouped together in collections with each document having completely different structure; e.g. CouchDB and MongoDB
* Wide-Column database: column families act as containers for rows, each row does not have the same number of columns; columns not known up front; useful for analyzing large datasets; e.g. Cassandra and HBase
* Graph database: store data whose relations are best represented as a graph; data saved in graph structures with nodes as entities containing properties and lines as edges between entities; e.g. Neo4j and InfiniteGraph

### Key differences

| Property           | SQL                                                                     | NoSQL                                                                                  |
| ------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Storage            | Data in tables                                                          | Different data storage models                                                          |
| Schema             | Fixed schema, any alterations alter all rows                            | Dynamic schemas without uniformity needed                                              |
| Querying           | Uses SQL to define and manipulate                                       | Uses UnQL (Unstructured Query Language) and query focused on a collection of documents |
| Scalability        | Traditionally built to scale vertically                                 | Horizontally scalable with ease to distribute data across servers automatically        |
| Reliability (ACID) | ACID compliant, reliable and safe guarantee for performing transactions | Sacrifice ACID for performance and scalability                                         |

### ACID

* Atomicity: all transactions must succeed or fail completely and cannot be left partially complete even in system failure
* Consistency: database follows rules that validate and prevent corruption at every step
* Isolation: concurrent transactions cannot affect each other
* Durability: transactions are final and system failures cannot rollback a complete transaction

### BASE

* Basically Available: ensures availability by spreading and replicating data across the nodes of the database cluster
* Soft-state: delegates responsibility of synchronizing changes to developers
* Eventually consistent: data reads are possible even until data is eventually consistent

### Why SQL?

* Ensuring ACID compliance: reducing anomalies and protects integrity of database by prescribing exactly how transactions interact with the database
* Structured and unchanging data: data is consistent so there’s no need to use a system to support variety of data types and high traffic volume
* Update once: normalizing data allows single change to affect all related data
* Write-heavy flows can be optimized but must be careful about indexing

### Why NoSQL?

* Storing large volumes of data with little to no structure
* Make the most of cloud computing and storage: cloud computing emphasizes horizontally scaling and NoSQL database support this well
* Rapid development: useful for rapidly changing data structure and iterating without much downtime between versions
* High performance and availability
* Optimized for high-throughput read and write operations

## CAP Theorem

{% hint style="info" %}
CAP Theorem states that it is impossible for a distributed system to simultaneously provide the following properties: consistency, availability, and partition tolerance
{% endhint %}

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2F0xccI0DzErGoGdEmWJn8%2FUntitled_7.png?alt=media&amp;token=d3b5970e-03e2-423f-8947-4274c9e4a03f" alt="" width="375"><figcaption></figcaption></figure>

* Any distributed system must pick 2 out of 3 properties
* Options are CA, CP, and AP
  * CA doesn’t make sense as a non-partition tolerant system is forced to give up either consistency or availability in the event of a network partition

### Consistency

{% hint style="info" %}
All nodes see the same data at the same time
{% endhint %}

* Users can read or write from/to any node in the system and will receive the same data
* Same as having single up-to-date copy of the data

### Availability

{% hint style="info" %}
Every request received by a non-failing node in the system must result in a response
{% endhint %}

* Every request must terminate even during severe network failure

### Partition tolerance

* Partition is a communication break/network failure between any 2 nodes in the system

{% hint style="info" %}
System must continue to operate even if there are partitions in the system
{% endhint %}

## PACELC Theorem

<figure><img src="https://2726477159-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjAfNlXNVLzsC3J7sS0s2%2Fuploads%2F4c0nbHBryaoq5BRQa3V0%2FUntitled_8.png?alt=media&amp;token=138e4809-3bf5-4765-9bed-622e021a03e4" alt="" width="563"><figcaption></figcaption></figure>

* In the event of no partition (E), then trade off between latency and consistency

### Examples

* DynamoDB and Cassandra: PA/EL
* BigTable and HBase: PC/EC
* MongoDB: PA/EC

## Long-Polling vs WebSockets vs Server-Sent Events

{% hint style="info" %}
Communication protocols between client and web server
{% endhint %}

### AJAX Polling

{% hint style="info" %}
Client repeatedly polls/requests a server for data
{% endhint %}

* Regular intervals of 0.5 seconds
* No data available → empty response
* If data is sparse, lots of HTTP overhead

### HTTP Long-Polling

{% hint style="info" %}
Server pushes information to client whenever data is available
{% endhint %}

* Client requests for data but does not expect an immediate response (hanging GET)
* Server holds onto request if no data available and sends full response once available or until timeout expires

### WebSockets

{% hint style="info" %}
Full-duplex (bidirectional) communication channels over a single TCP connection
{% endhint %}

* Persistent connection between client and server
* Client establish web socket with WebSocket handshake

### Server-Sent Events

{% hint style="info" %}
Unidirectional client <- server connection
{% endhint %}

* Client establishes connection
* Server sends data to client in chunks when available (used for ChatGPT API with streaming API)
* Best when needing real-time traffic from server to client or server generates data in a loop

## Sharding vs Partitioning

### Sharding

{% hint style="info" %}
Divide single dataset among many databases to be distributed
{% endhint %}

* Improves response time with less rows per database
* Avoids total service outage since not every server will go down
* Scales efficiently so more servers can be added

#### Methods

1. Range-based sharding: shard key assigned to a range; can cause unbalanced data but easy to implement
2. Hashed sharding: shard key assigned to each row using has function; results in even distribution of data but new hash function can cause problems (more servers)
3. Directory sharding: lookup table to match database information with shard key; flexible but can fail if lookup table has wrong information
4. Geo sharding: split and store database information according to geographical location; fast information retrieval due to shorter distance between shard and customer; cause uneven data distribution

### Partitioning

{% hint style="info" %}
Distributing database objects across different servers; some tables belong to certain servers
{% endhint %}

* Usually referring to vertical partitioning
* Improves reliability, availability, and performance

## Consistency Patterns

1. Weak consistency: after data write, read request may or may not be able to get new data
2. Eventual consistency: post data write, reads will eventually see the latest data within milliseconds; data replicated asynchronously
3. Strong consistency: after data write, subsequent reads immediately see latest data; done synchronously

## Common Questions

### Why use a UUID and not an auto-incrementing ID as primary key?

<table><thead><tr><th width="86.33333333333331">Type</th><th>Pros</th><th>Cons</th></tr></thead><tbody><tr><td>UUID</td><td><ul><li>Globally unique across tables/distributed system</li><li>Stateless</li><li>Secure as people cannot guess the ID</li><li>Can store timestamps</li></ul></td><td><ul><li>Not readable</li><li>Not naturally sortable by creation time</li><li>Potentially bad for insertion time (clustered primary key); requires reordering before insertion</li><li>Uses 16 bytes to store</li></ul></td></tr><tr><td>ID</td><td><ul><li>Readable</li><li>Less space needed (4/8 bytes at most)</li></ul></td><td><ul><li>Cannot be used in distributed systems due to duplicates</li><li>Cannot be generated in isolation (must read the previous ID)</li><li>Prone to guess attacks</li></ul></td></tr></tbody></table>

### What happens when you enter a URL into the browser?

1. Request is sent to the local DNS where the appropriate IP address is found
2. Client tries to establish TCP connection with server residing at IP address
3. Once connection established, client makes the HTTP request
4. Server processes request and sends response
5. \[Optional] Any additional objects are requested separately or through a persistent TCP connection

### How are web pages loaded?

1. Build the HTML DOM tree
2. Build the CSSOM tree, handle JS parsing, accessibility tree
3. Combine the DOM and CSSOM trees into render tree
4. Layout the geometry of the elements into boxes
5. Paint the boxes (first meaningful paint)

### What’s the difference between hot and cold storage?

Hot storage refers to data that is accessed frequently or requires fast access while cold storage is used for data rarely needed.

### How do you deal with high frequency user input?

Apply techniques like debouncing. The user input function can be triggered after a period of no-action to avoid repeatedly spamming the backend with requests. Alternatively, the user input function can trigger once and wait for a cooldown period (no-action).

### How does debouncing differ from throttling?

Throttling involves running function at regular intervals while debouncing focuses on creating a cooldown period to avoid creating too many requests.


# SQL

I have not really seen that many SQL questions in OAs/technical interviews but it is a good to know

{% hint style="info" %}
SQL is a really large topic and I will not be able to cover every aspect of it. However, I have attached my notes from the database systems module in NUS ([CS2102](https://nusmods.com/courses/CS2102/database-systems)) for you to self-study
{% endhint %}

{% file src="/files/TkHwBp9k5m2ALmwCw4Jv" %}


# Accessing APIs

I have noticed an increase in questions where you are required to access an API. The following is a quick primer on how API access works in Python

{% hint style="info" %}
This section focuses on accessing APIs in Python, if you are not using Python, please refer to the respective documentation for your language to learn how to access APIs
{% endhint %}

To make an API request, use the `requests` library, which you need to import first:

```python
import requests
```

Then, to make a `GET` request, use `requests.get(URL)`. This returns a `Response` object that you can use to extract information from the API:

```python
resp = requests.get('fake URL')
resp.status # status of the request
resp.json() # body of response as JSON, access it as you would an array/dictionary
```

To upload data via a `POST` request, use `requests.post(URL, json=JSON)`

```python
body = {'foo': 'bar'}
requests.post('fake URL', json=body) # this sends the JSON to the URL
```


# Operating Systems

You don't need to know everything about operating systems, but there are some commonly asked questions that you should know how to answer

{% hint style="info" %}
Much like [SQL](/other-technical-topics/system-design/sql), there is too much things to cover when talking about operating systems and most importantly, concurrency patterns. I have attached my notes from the operating systems module in NUS ([CS2106](https://nusmods.com/courses/CS2106/introduction-to-operating-systems)) for you to self-study
{% endhint %}

{% file src="/files/tjpQwStNsugEQm4dc9JY" %}
Notes
{% endfile %}

{% file src="/files/hYHa2fdsGTFwz4N3K9qC" %}
Classical concurrency problems
{% endfile %}

Some of the other commonly asked questions include things like:

1. What is a deadlock?
2. What is a race condition?
3. What is the difference between deadlocks and race conditions?
4. How do you detect a deadlock?
5. How do you solve a deadlock?
6. What is paging?
7. What is a page fault?
8. Where are logs stored on a Linux system?
9. How do you deal with thread explosion?


# Behavioral Interviews

Most companies expect you to have the technical skills for the job, so the behavioral component of the interview is what can help set you apart

{% hint style="info" %}
A very ***BIG*** disclaimer is that the behavioral component of interviews is a very individual process. The techniques and advice that I give here are what have worked best for me, but it may not suit your personality/goals. So take my advice with a pinch of salt!\
\
Also note that this advice is focused on **internship** applications.
{% endhint %}

For this section, I will be focusing on two kinds of behavioral interviews: technical-behavioral and pure-behavioral. Before diving into how I prepare for both types of interview, I would like to provide some general advice that can be applied to both types.

## General advice

### About you

Most interviews start out with "Tell me about yourself", so it is good to prepare a short blurb describing the following:

1. Your name
2. Where do you attend school and what do you study
3. (If you have had internship experience) What you did during your most internship, focusing on your contributions
4. (If you do not have internship experience) What is a project that you are currently working on, focusing on the key challenges you have faced and how you tackled them
5. (If you have neither internship experience or project experience) What are some of the interesting modules you have taken in school thus far
6. (If you are in any technical clubs) What are some extracurricular things you do

This blurb should be at most **three minutes long**. This was (an abbreviated version of) mine:

> My name is Jia Hao (1). I am a computer science undergraduate at the National University of Singapore (2).&#x20;
>
> Over the past summer, I was interning at a software startup called Betafi where I worked on full-stack development, using Elixir and Phoenix. At Betafi, we are trying to build a centralized user research platform, moving the entire process of user interviewing... I was fortunate to have had the opportunity to contribute key features used in the production system. For instance, ... (3)&#x20;
>
> Outside of my time during summer, I am also a coreteam member of NUS Hackers, a student group that aims to spread hacking culture. As a coreteam member, I ... (4)

You should practice this blurb as often as you can as this something that you will constantly be repeating so it's good to make it a natural part of your presentation.

### Going with the flow

Apart from the "About me" blurb, I do not commit most answers to memory. Rather than focusing on memorizing a script for every possible question, I try to focus on keeping a mental signpost of "key points" that I will recall when I am asked a question.&#x20;

This is achieved by doing reflection on my personal experiences. For each experience, I would think about the following:

1. What is a project I had spent a lot of time on?
2. What was challenging about that project?&#x20;
3. Why was it so challenging?
4. Was it a soft skill or hard skill challenge?
5. How did I overcome it?

Then, I make a note of the experiences that I have thought about and the key points from these experiences. So when I am asked a question, I try to adjust my answers to suit the question AND company best. The reason why each answer differs between companies is not because I am lying about my experiences, but rather, because each company has different core values and focuses. So, it is better to match the experience with the company to ensure that I am able to align with their values.

For a longer list of questions that can be asked, refer to this page in the [Tech Interview Handbook](https://www.techinterviewhandbook.org/behavioral-interview-questions/).

### Research

Before every interview, I try to research and read up on the following:

1. What is the company's core values?
2. What is the company's primary product?
3. What does the company's technology division look like?
4. What are some key projects that the company has worked out?
5. Does the company contribute to open source?
6. What are some things that the company does outside of work? Such as volunteer programs
7. What does the role entail?
8. (If the role is SWE-adjacent) How does the role differ from SWE?
9. What are the core competencies that applicants for the role should display?

I compile all of this information into a dedicate Notion page and focus on key points that I think will be important and good to talk about during the interview. Then, I start reflecting on my experiences to try matching the experience to the company.

Doing this has allowed me to talk about things that really interest me, rather than regurgitating basic information about the company.

### Being enthusiastic

{% hint style="info" %}
This is a relatively personal piece of advice since everyone's personality can differ. But since I tend to get really excited when talking about tech with others, this is something I have managed to use to my advantage during interviews
{% endhint %}

Because I get really excited when talking about tech with others, I often try to focus on talking about things that I find exciting and this often comes off as having a strong genuine passion about both tech and the company.

This is why I highly recommend you carefully research the company before the interview to find projects that the company does that excite you the most. By doing so, I am able to excitedly share about what I had learnt or ask questions about it.

### Having standards

{% hint style="info" %}
This should only be done when you can afford to be picky about the roles you get. \
\
If you are scrambling to find an internship, then I would say that any experience is better than none. However, do not stoop to lying about your experiences!!
{% endhint %}

It's important to keep your personal expectations and standards in the back of your mind at all times during the entire process. If you have a salary expectation or project expectation AND can afford to be slightly picky, do not lower these standards.&#x20;

Do not apply to companies that do not align with your interests. Do not accept offers that are not what you enjoy.&#x20;

This also means that you should not be disingenuous when you are answering questions. If the interviewer asks if you are interesting in working on something you do not find interesting, do not lie. It is easy to notice when someone is lying about their interests and it also does a disservice to the company should you intern for them as you would not be giving it your all.

## Technical-behavioral

Technical-behavioral tend to happen alongside technical challenges where the structure might look like:

1. Introductions
2. Technical-behavioral questions
3. Technical challenge
4. Reverse interview

{% hint style="info" %}
**Goal of technical-behavioral:**\
\
The goal of these questions are to try placing your technical competencies with your past experiences. They are also a way for interviewers to assess your interest/awareness of the company and its tech division.
{% endhint %}

Some common questions can include:

1. Tell me about a past project
2. What were some technical challenges when working on it?
3. Why do you want to work at Company Y?
4. If given a choice between project A and project B, how would you decide between the two?

The best way to prepare for such questions is to do self-reflection (as described in [#going-with-the-flow](#going-with-the-flow "mention")) and researching about the company (as described in [#research](#research "mention")). It is important to keep in the back of your mind that you should always focus on trying to tie your experiences back to the role/company and painting everything in a positive light.&#x20;

{% hint style="info" %}
Try not to have an overly negative tone when talking about experiences. If you realize that you are speaking very poorly about the experience, you should either pivot away or stop talking about it entirely.
{% endhint %}

## Pure-behavioral

These are the more standard interview questions you might hear about. For most pure-behavioral interviews, they will be a separate interview with a senior hiring manager or engineering manager of the company.&#x20;

{% hint style="info" %}
**Goal of pure-behavioral:** \
\
The goal of this type of interview is to understand your interest in/awareness of the company (not just the tech division). It is also a way to show off your soft skills and prove to them why you are a good fit for their culture.
{% endhint %}

You can expect to find the more conventional questions like:

1. What are your strengths?
2. What are your weaknesses?
3. Tell me about a time you faced conflict and how did you resolve it?
4. How would you describe yourself in three words?
5. What is something that hinders your productivity?

Much like [#technical-behavioral](#technical-behavioral "mention"), the best way to prepare for these questions are to do self-reflection and research about the company.

However, due to the goal of this interview, you should focus on trying to connect with the company's core values and showing that you are someone that they would love to work with.

## Reverse interviewing

A key component of the interview that most underestimate is the reverse interview component, or the "Do you have any questions for me?" section towards the end of the interview.&#x20;

In my personal experience, it is an opportunity for you to learn more about the company but to also share more about yourself if you felt like you failed to do so during the start of the interview.

You should NOT be spending most of the time talking about yourself. That is not the goal. Instead, let your interviewer share about their experiences/work/company life. If you are interested in it or have had experiences with it, then you can use it as an opportunity to talk more about the topic and change it more to a conversation.

An example could look like:

> You: What are some opportunities you have working in Company X?\
> Interviewer: Oh, I got to try out GPT-4 before it was launched!\
> You: Wow! That's so cool. I never got to try GPT-4 before its public launch but I did use it for a while during my previous internship and was so mind blown by its capabilities. Did you compare it with GPT-3.5?

Let me re-iterate, do NOT talk all about yourself. Just use your own experiences to turn this into a conversation. This helps to improve the interviewer's impression of you and ends the interview on a good note.

These are some questions I typically use:

1. What does your day to day look like?
2. What does working in Company X mean?
3. What are some interesting engineering challenges you face?
4. What is the team structure like?

For more reverse interview questions, refer to [this GitHub repository.](https://github.com/viraptor/reverse-interview)


# Resumes

Resume writing is an art and it can be hard to get right

There are plenty of resources for how to write a resume online so I won't re-iterate the fundamentals of resume writing. Instead, I would like to highlight some advice I received or discovered after many iterations of resume writing.

For reference, this was the resume I had used when applying for internships for summer 2024:

{% file src="/files/QsfXz11lXgopqdVXbgpL" %}

## Recommended structure

1. Name
2. Contact information
3. Work authorization (if needed)
4. Education
5. Work experience
6. Extracurricular
7. Skills & Technologies

## Key points

1. Include your GPA if possible (> 4 if you're from NUS/NTU or > 3 otherwise)
2. Split up the sections of "Skills" into "Skills & Technologies"
3. Remove any non-important non-technical sections like "Hobbies"
4. It is optional to include the location of where you worked before
5. Use short-form for months and keep this consistent across the entire resume
6. Work authorization
   1. If you are a Singapore Citizen, you are eligible for the H1B1 US visa
   2. If you have graduated from NUS or NTU, you are eligible for the HPI UK visa
   3. Do not include this section if you are applying to local companies
7. Prioritize your education at the top
8. Specify your intended graduation date
9. When writing points for each experience, focus on the WHY and OUTCOME, less on the HOW
   1. Include as much numbers and statistics as possible
   2. You can come up with a preliminary draft full of details and use ChatGPT to summarize these points, a prompt I have used is "Shorten this to a single sentence that can be used for a software engineering intern resume"
10. Always prioritize your technical experiences over non-technical experiences


