Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Edited Strings and Arrays guides #2

Merged
merged 2 commits into from
Aug 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions strings_arrays/binary_search.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
Binary search is a method for locating an element in a sorted list efficiently. Searching for an element can done naively in **O(N)** time, but binary search speeds it up to **O(log N)**. Binary search is a great tool to keep in mind for array problems.
Binary search is a technique for efficiently locating an element in a sorted list. Searching for an element can done naively in **O(n)** time by checking every element in the list, but binary search's optimization speeds it up to **O(log n)**. Binary search is a great tool to keep in mind for array problems.

Algorithm
------------------
In binary search, you are provided a list of sorted numbers and a key. The desired output is the index of the key, if it exists and None if it doesn't.
In binary search, you are provided a sorted list of numbers and a key. The desired output of a binary search is the index of the key in the sorted list, if the key is in the list, or ```None``` otherwise.

Binary search is a recursive algorithm. The high level approach is that we examine the middle element of the list. The value of the middle element determines whether to terminate the algorithm (found the key), recursively search the left half of the list, or recursively search the right half of the list.
Binary search is a recursive algorithm. From a high-level perspective, we examine the middle element of the list, which determines whether to terminate the algorithm (found the key), recursively search the left half of the list (middle element value > key), or recursively search the right half of the list (middle element value < key).
```
def binary_search(nums, key):
if nums is empty:
Expand All @@ -13,20 +13,20 @@ def binary_search(nums, key):
return middle index
if middle element is greater than key:
binary search left half of nums
if middle element is less than
if middle element is less than
binary search right half of nums
```

There are two canonical ways of implementing binary search: recursive and iterative. Both solutions utilizes two pointers that keep track of the portion of the list we are searching.

### Recursive Binary Search

The recursive solution utilizes a helper function to keep track of pointers to the section of the list we are currently examining. The search either completes when we find the key, or the two pointers meet.
The recursive approach utilizes a helper function to keep track of pointers to the section of the list we are currently examining. The search either terminates when we find the key or if the two pointers meet.

```python
def binary_search(nums, key):
return binary_search_helper(nums, key, 0, len(nums))

def binary_search_helper(nums, key, start_idx, end_idx):
middle_idx = (start_idx + end_idx) // 2
if start_idx == end_idx:
Expand All @@ -41,7 +41,7 @@ def binary_search_helper(nums, key, start_idx, end_idx):

### Iterative Binary Search

The iterative solution manually keeps track of the section of the list we are examining, using the two-pointer technique. The search either completes when we find the key, or the two pointers meet.
The iterative approach manually keeps track of the section of the list we are examining using the two-pointer technique. The search either terminates when we find the key, or the two pointers meet.
```python
def binary_search(nums, key):
left_idx, right_idx = 0, len(nums)
Expand All @@ -58,7 +58,7 @@ def binary_search(nums, key):

## Runtime and Space Complexity

Binary search completes in **O(log N)** time because each iteration decreases the size of the list by a factor of 2. Its space complexity is constant because we only need to maintain two pointers to locations in the list. Even the recursive solution has constant space with [tail call optimization](https://en.wikipedia.org/wiki/Tail_call).
Binary search has **O(log n)** time complexity because each iteration decreases the size of the list by a factor of 2. Its space complexity is constant because we only need to maintain two pointers. Even the recursive solution has constant space with [tail call optimization](https://en.wikipedia.org/wiki/Tail_call).

## Example problems
* [Search insert position](https://leetcode.com/problems/search-insert-position/description/)
Expand Down
78 changes: 41 additions & 37 deletions strings_arrays/sorting.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Sorting is a fundamental tool for tackling problems, and is often utilized to help simplify problems.

There are several different sorting algorithms, each with different tradeoffs. In this guide, we will cover several well-known sorting algorithms along with when they are useful.
There are several different sorting algorithms, each with different tradeoffs. In this guide, we will cover several well-known sorting algorithms along with when they are useful.

We will go into detail for merge sort and quick sort, but will describe the rest at a high level.
We will describe merge sort and quick sort in detail and the remainder of the featured sorting algorithms at a high level.

## Terminology
Two commonly used terms in sorting are:
Expand All @@ -11,11 +11,11 @@ Two commonly used terms in sorting are:
2. **stable sort**: retains the order of duplicate elements after the sort ([3, <u>2</u>, 4, **2**] -> [<u>2</u>, **2**, 3, 4])

## Merge sort
**Merge sort** is perhaps the simplest sort to implement and has very consistent behavior. It adopts a divide-and-conquer strategy: recursively sort each half of the list, and then perform an O(N) merging operation to create a fully sorted list.
**Merge sort** is perhaps the simplest sort to implement and has very consistent behavior. It adopts a divide-and-conquer strategy: recursively sort each half of the list, and then perform an O(n) merging operation to create a fully sorted list.

### Implementation

The key operation in merge sort is `merge`, which is a function that takes two sorted lists and returns a single list which is sorted.
The key operation in merge sort is `merge`, which is a function that takes two sorted lists and returns a single sorted list composed of elements of the combined lists.
```python
def merge(list1, list2):
if len(list1) == 0:
Expand All @@ -31,7 +31,6 @@ This is a recursive implementation of `merge`, but an iterative implementation w

Given this `merge` operation, writing merge sort is quite simple.


```python
def merge_sort(nums):
if len(nums) <= 1:
Expand All @@ -43,18 +42,17 @@ def merge_sort(nums):
```

### Runtime

Merge sort is a recursive, divide and conquer algorithm. It takes O(log N) recursive merge sorts and each merge is O(N) time, so we have a final runtime of O(N log N) for merge sort. Its behavior is consistent regardless of the input list (its worst case and best case take the same amount of time).
Merge sort is a recursive, divide and conquer algorithm. It takes O(log n) recursive merge sorts and each merge is O(n) time, so we have a final runtime of O(n log n) for merge sort. Its behavior is consistent regardless of the input list (its worst case and best case take the same amount of time).

**Summary**
* Worst case: O(N log N)
* Best case: O(N log N)
* Stable: yes
* In-place: no

| Worst case | Best case | Stable | In-place|
|:----------:|:---------:|:------:|:-------:|
| O(n log n) | O(n log n) | ✅ | ❌ |

## Quick sort

**Quick sort** is also a divide and conquer strategy, but uses a two-pointer swapping technique instead of `merge`. The core idea of quick sort is to select a "pivot" element in the list (typically the middle element), and swap elements in the list such that everything left of the pivot is less than it, and everything right of the pivot is greater. We call this operation `partition`. Quick sort is notable in its ability to sort efficiently in-place.
**Quick sort** is also a divide and conquer strategy, but uses a two-pointer swapping technique instead of `merge`. The core idea of quick sort is selecting a "pivot" element in the list (typically the middle element), and swapping elements in the list such that everything left of the pivot is less than it, and everything right of the pivot is greater. We call this operation `partition`. Quick sort is notable for its ability to sort efficiently in-place.

```python
def partition(nums, left_idx, right_idx):
Expand All @@ -70,7 +68,7 @@ def partition(nums, left_idx, right_idx):
left_idx += 1
right_idx -= 1
```
The partition function modifies `nums` inplace and takes up no extra memory. It also takes O(N) time in the worst case to fully partition a list.
The partition function modifies `nums` in-place and requires no extra memory. It also takes O(n) time worst case to fully partition a list.

```python
def quick_sort_helper(nums, left_idx, right_idx):
Expand All @@ -88,56 +86,62 @@ def quick_sort(nums):

### Runtime

The best case performance of quick sort is O(N log N), but depending on the structure of the list, quick sort's performance can vary.
The best case performance of quick sort is O(n log n), but depending on the structure of the list, quick sort's performance can vary.

If the pivot happens to be the median of the list, then the list will be divided in half after the partition.
If the pivot happens to be the median of the list, then the list will be divided in half after the partition.

In the worst case, however, the list will be divided into an N - 1 length list and an empty list. Thus, in the worst possible case, quick sort has O(N<sup>2</sup>) performance, since we'll have to recursively quicksort (N - 1), (N - 2), ... many lists. However, on average and in practice, quick sort is still very fast due to how fast swapping array elements is.

The space complexity for this version of quick sort os O(log N), due to the number of call stacks created during recursion, but an iterative version can make space complexity O(1).

**Summary**
* Worst case: O(N<sup>2</sup>)
* Best case: O(N log N)
* Stable: no
* In-place: yes

| Worst case | Best case | Stable | In-place|
|:----------:|:---------:|:------:|:-------:|
| O(n<sup>2</sup>) | O(n log n)| ❌ | ✅ |

## Insertion sort

In **insertion sort**, we incrementally build a sorted list from the unsorted list. We take elements from the unsorted list and insert them into the sorted list, making sure to maintain the order.

This algorithm takes O(N<sup>2</sup>) worst time, because looping through the unsorted list takes O(N) and finding the proper place to insert can take O(N) time in the worst case. However, if the list is already sorted, insertion sort takes O(N) time, since insertion time will be O(1). Insertion sort can be done in-place, so it takes up O(1) space.
This algorithm takes O(n<sup>2</sup>) worst time, because looping through the unsorted list takes O(n) and finding the proper place to insert can take O(n) time in the worst case. However, if the list is already sorted, insertion sort takes O(n) time, since insertion time will be O(1). Insertion sort can be done in-place, so it takes up O(1) space.

Insertion sort is easier on linked lists, which have O(1) insertion whereas arrays have O(N) insertion because in an array, inserting an element requires shifting all the elements behind that element.
Insertion sort is easier on linked lists, which have O(1) insertion whereas arrays have O(n) insertion because in an array, inserting an element requires shifting all the elements behind that element.

**Summary**
* Worst case: O(N^2^)
* Best case: O(N)
* Stable: yes
* In-place: yes

| Worst case | Best case | Stable | In-place|
|:----------:|:---------:|:------:|:-------:|
| O(n<sup>2</sup>) | O(n)| ✅ | ✅ |

## Selection sort

**Selection sort** incrementally builds a sorted list by finding the minimum value in the rest of the list, and swapping it to be in the front.

It takes O(N<sup>2</sup>) time in general, because we have to loop through the unsorted list which is O(N) and in each iteration, we search the rest of the list which always takes O(N). Selection sort can be done in-place, so it takes up O(1) space.
It takes O(n<sup>2</sup>) time in general, because we have to loop through the unsorted list which is O(n) and in each iteration, we search the rest of the list which always takes O(n). Selection sort can be done in-place, so it takes up O(1) space.

**Summary**
* Worst case: O(N<sup>2</sup>)
* Best case: O(N<sup>2</sup>)
* Stable: no
* In-place: yes
| Worst case | Best case | Stable | In-place|
|:----------:|:---------:|:------:|:-------:|
| O(n<sup>2</sup>) | O(N<sup>2</sup>)| ❌ | ✅ |

## Radix sort

**Radix sort** is a situational sorting algorithm when you know that the numbers you are sorting are bounded in some way. It operates by grouping numbers in the list by digit, looping through the digits in some order.

For example, if we had the list [100, 10, 1], radix sort would put 100 in the group which had 1 in the 100s digit place and would put (10, 1) in a group which had 0 in the 100s digit place. It would then sort by the 10s digit place, and finally the 1s digit place.
For example, if we had the list ```[100, 10, 1]```, radix sort would put 100 in the group which had 1 in the 100s digit place and would put (10, 1) in a group which had 0 in the 100s digit place. It would then sort by the 10s digit place, and finally the 1s digit place.

Radix sort thus needs one pass for each digit place it is sorting and takes O(KN) time, where K is the number of passes necessary to cover all digits.

**Summary**
* Worst case: O(KN)
* Best case: O(KN)
* Stable: yes (if going through digits from right to left)
* In-place: no
| Worst case | Best case | Stable | In-place|
|:----------:|:---------:|:------:|:-------:|
| O(kn) | O(kn)| ✅ (if going through digits from right to left) | ❌ |

## Summary

|Sort | Worst case | Best case | Stable | In-place|
|:-:||:----------:|:---------:|:------:|:-------:|
|Merge sort | O(n log n) | O(n log n) | ✅ | ❌ |
|Quick sort | O(n<sup>2</sup>) | O(n log n)| ❌ | ✅ |
| Insertion sort | O(n<sup>2</sup>) | O(n)| ✅ | ✅ |
|Selection sort | O(n<sup>2</sup>) | O(N<sup>2</sup>)| ❌ | ✅ |
|Radix sort| O(kn) | O(kn)| ✅ (if going through digits from right to left) | ❌ |
36 changes: 17 additions & 19 deletions strings_arrays/sorting_colors.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,17 @@ Example:
```

## Approach #1: Merge or quick sort
### Approach
The problem is asking us to sort a list of integers, so we can use an algorithm like merge sort or quick sort.

**Approach**
The problem is asking us to sort a list of integers, so we could potentially use an algorithm like merge sort or quick sort.

**Time and space complexity**
With a sorting algorithm such as is O(N log N) in the worst case. The space complexity is O(1) since we sort in place.
### Time and space complexity
Both of these sorting algorithms have O(n log n) worst case time complexity and, because we sort in-place, O(1) space complexity.

## Approach #2: Counting sort
**Approach**
We know that the numbers we are sorting are 0, 1, or 2. This leads to an efficient counting sort implementation, since we can just count the numbers of each and modify the list in place to match the counts in sorted order.
### Approach
We know that the numbers we are sorting are 0, 1, or 2. This means we can sort more efficiently by simply counting the numbers of times each of the three values occurs and modifying the list in-place to match the counts in sorted order.

**Implementation**
### Implementation
```python
from collections import defaultdict
def sort_colors(colors):
Expand All @@ -42,41 +41,40 @@ def sort_colors(colors):
idx += 1
```

**Time and space complexity**
This solution has complexity O(N), since we loop through the list once, then loop through the dictionary to modify our list, both of which take N time. This solution takes up O(1) space, since everything is done in place and the counts dictionary has a constant size.
### Time and space complexity
This solution has complexity O(n), since we loop through the list once, then loop through the entire dictionary to modify our list. This solution takes up O(1) space, since everything is done in place and the dictionary has a constant size.

## Approach #3: Three-way partition
This approach uses multiple pointers. Reading the [two pointer guide](https://guides.codepath.com/compsci/Two-pointer) may be helpful.

**Approach**
Although we cannot asymptotically do better than O(N) since we need to pass through the list at least once, we can limit our code to only making one pass. This will be slightly faster than approach #2.
### Approach
Although we cannot asymptotically do better than O(n), since we need to pass through the list at least once, we can limit our code to only making one pass. This will be slightly faster than approach #2.

We can accomplish this by seeing that sorting an array with three distinct elements is equivalent to a `partition` operation. Recall that in quick sort, we partition an array to put all elements less than a pivot to the left and greater than to a right. Since we only have three potential values in our list, partitioning using the middle value as a pivot will effectively sort the list.
We can accomplish this by recognizing that sorting an array with three distinct elements is equivalent to a partition operation. Recall that in quick sort, we partition an array to put all elements with values less than a pivot on the left and elements with values greater than a pivot to a right. Since we only have three potential values in our list, partitioning using the middle value as a pivot will effectively sort the list.

This particular type of partition is a bit tricky though because we're partitioning on the middle element (the 1's) of our list. It's called a three-way partition, since we are also grouping together elements that are equal in the middle (the 1's).


**Implementation**

### Implementation
```python
def sort_colors(colors):
left, middle, right = 0, 0, len(colors) - 1
while middle <= right:
if colors[middle] == 0:
colors[middle], colors[left] = colors[left], colors[middle]
colors[middle], colors[left] = colors[left], colors[middle]
left += 1
middle += 1
elif colors[middle] == 1:
middle += 1
elif colors[middle] == 2:
colors[middle], colors[right] = colors[right], colors[middle]
colors[middle], colors[right] = colors[right], colors[middle]
right -= 1
middle += 1
```


**Time and space complexity**
This solution has also has complexity O(N), but only takes one pass since it uses two pointers that stop moving when one moves past the other.
### Time and space complexity
This solution has also has time complexity O(n), but only takes one pass since it uses two pointers that stop moving when one moves past the other.

It is slightly faster than the counting sort and is O(1) space, since it is in-place.

Expand Down
Loading