├── README.md ├── algorithms ├── permutations.py ├── binarysearch.py └── sorting.py └── datastructs ├── trees.py ├── graphs.py ├── sortedarraytobst.py ├── linkedlists.py └── binaryheap.py /README.md: -------------------------------------------------------------------------------- 1 | # Public Interview Notes 2 | Hi There :) 3 | 4 | I take notes to help me not make the same mistakes in my next interview and have something to look back on. All of these are beginner friendly. I take notes as if I'm tutoring someone. 5 | 6 | All the notes are in the python files themselves. I didnt wanna jump between READMEs and the code so all the notes are commented out for me to read along. Feel free to snoop around. 7 | -------------------------------------------------------------------------------- /algorithms/permutations.py: -------------------------------------------------------------------------------- 1 | # There was this interesting question I did on an interview 2 | # it really stumpted me, and I found the most elegent code out on 3 | # geeksforgeeks on it. 4 | 5 | # Question: leetcode # 46. Permutations 6 | # Given a collection of distinct integers, return all possible permutations. 7 | 8 | class Solution(object): 9 | def permute(self, nums): 10 | """ 11 | :type nums: List[int] 12 | :rtype: List[List[int]] 13 | """ 14 | def getperms(num, l, r, soln): 15 | if l == r: 16 | soln.append(num[:]) 17 | return 18 | for i in range(l, r+1): 19 | num[i], num[l] = num[l], num[i] 20 | getperms(num, l+1, r, soln) 21 | num[i], num[l] = num[l], num[i] 22 | 23 | soln = [] 24 | getperms(nums, 0, len(nums)-1, soln) 25 | return soln -------------------------------------------------------------------------------- /algorithms/binarysearch.py: -------------------------------------------------------------------------------- 1 | # Binary search can only be used on sorted arrays. Given an array, 2 | # it will repeatedly consider the approprirate halves of the array 3 | # and drill down to one index that matches or stop. It continually 4 | # the middle and pivots left or right of that. Here's a visual: 5 | # array = [1 , 5, 14, 45, 99, 100, 115, 120] find: 99 6 | # l = 0 ^ r = 7 ^ 7 | # note: m = 3, arr[m] = 45 < 99, the val is after index m 8 | # array = [1 , 5, 14, 45, 99, 100, 115, 120] find: 99 9 | # l = 4 ^ r = 7 ^ 10 | # note: m = 5, arr[m] = 100 > 99, the val is before index m 11 | # array = [1 , 5, 14, 45, 99, 100, 115, 120] find: 99 12 | # l, r = 4 ^ 13 | # note: m = 4, arr[m] = 99 == 99, you found it! 14 | 15 | 16 | # Here's the iterative binary search algorithm: 17 | def binarySearch1(nums, val): 18 | start, end = 0, len(nums) - 1 19 | while start <= end: 20 | mid = (start + end ) // 2 21 | if nums[mid] > val: 22 | end = mid - 1 23 | elif nums[mid] < val: 24 | start = mid + 1 25 | else: 26 | return mid 27 | else: 28 | return -1 29 | 30 | # Here's the recursive binary search algorithm: 31 | def binarySearch2(nums, val, start, end): 32 | if start > end: 33 | return -1 34 | 35 | mid = (start + end) // 2 36 | if nums[mid] == val: 37 | return mid 38 | elif nums[mid] > val: 39 | return binarySearch2(nums, val, start, mid-1) 40 | else: 41 | return binarySearch2(nums, val, mid+1, end) 42 | 43 | # Linear search is O(n) 44 | # You go through every value in the array 45 | # Binary search is O(log n) 46 | # You go through the values diving n in half everytime, until we have 1 value. 47 | # The number of steps you have is defined by how many times you have to divide 48 | # by two to get one -> this is the definition of Log(n) (with base 2, but we 49 | # drop the constant like that in Big-O analysis). -------------------------------------------------------------------------------- /datastructs/trees.py: -------------------------------------------------------------------------------- 1 | # Trees are often used to express some sort of hierarchy. Much like a 2 | # graph and hashmap, it is not a linear data structure. It is noteworthy 3 | # that trees are actually graphs with certain restrictions. There are 4 | # different types of trees we can study but each tree has a root node, 5 | # each node has 0 or more child nodes. Each child node has one or more 6 | # child nodes. Every node, besides the root, has exactly one parent node 7 | # Note: a tree is made up of subtrees. 8 | 9 | # Tree vocabulary to talk about trees: 10 | # - root: the node that has 0 parent nodes. start of the tree. 11 | # - depth: the number of edges between node and the root. 12 | # - height: the maximum number of edges in a tree (longest path size). 13 | # - level: the number of edges between the a node and the root node. 14 | 15 | # Definitions for catagorizing trees: 16 | # - binary: a tree where each node can have a maximum of two nodes. 17 | # - binary search tree: a binary tree where left child is less than the parent 18 | # and the right child is equal to or greater than the parent node. 19 | # - balanced: a tree where the left and right subtree heights do not 20 | # differ by more than 1. This is true for each node in the tree. 21 | # - full (binary trees): where each node can have 0 or 2 nodes. 22 | # - compelete: every level of the tree is full except the last one. 23 | # the last level is full only from left to right. 24 | # - tries: an n-ary tree that has characters for nodes. each path down a tree 25 | # may represent a word. 26 | 27 | # In interviews, you are often given the root tree node to deal with. For 28 | # tree problems you must know how to traverse a tree. There are three 29 | # ways of traversals: Preorder, Inorder, and Postorder. 30 | 31 | # For Binary trees 32 | class TreeNode: 33 | def __init__(self, x): 34 | self.data = x 35 | self.left = None 36 | self.right = None 37 | 38 | # Building of the binary search tree 39 | root = TreeNode(8) 40 | n1= TreeNode(1) 41 | n9= TreeNode(9) 42 | n13= TreeNode(13) 43 | n4= TreeNode(4) 44 | n5= TreeNode(5) 45 | root.left = n4 46 | root.right = n13 47 | n4.left = n1 48 | n4.right = n5 49 | n13.left = n9 50 | # 8 51 | # / \ 52 | # 4 13 53 | # / \ / 54 | # 1 5 9 55 | 56 | 57 | def inorder(node): 58 | if(node != None): 59 | inorder(node.left) 60 | print(node.data) 61 | inorder(node.right) 62 | 63 | def preorder(node): 64 | if(node != None): 65 | print(node.data) 66 | preorder(node.left) 67 | preorder(node.right) 68 | 69 | def postorder(node): 70 | if(node != None): 71 | preorder(node.left) 72 | preorder(node.right) 73 | print(node.data) 74 | 75 | # I've also found these helpful when working with binary trees: 76 | # left child node will be = i * 2 + 1 77 | # right child node will be = i * 2 + 2 78 | # parent node will be = i // 2 79 | # where i will be the index of the current node. -------------------------------------------------------------------------------- /datastructs/graphs.py: -------------------------------------------------------------------------------- 1 | # Graphs are great for representing pairwise relationships between many objects. 2 | # A social network is a classic example of graphs! Graphs can be directed or 3 | # undirected. though rees are graphs, not all graphs are trees interviews, 4 | # you are often given this in a list of pairs that represent connections 5 | # between nodes, also known as edges. There are two main ways of 6 | # representing graphs, adjacency list and an adjacency matrix. 7 | from collections import defaultdict 8 | from collections import deque 9 | 10 | # default dictionary is nice bc if the keys are not found, it will not complain 11 | # like the built in map, but instead just create the key-value pair. as you see 12 | # below, the default dictionary must know what the value types will be, i believe 13 | # this is for memory allocation purposes. our will be a list. 14 | def adjListRep(edges): 15 | alist = defaultdict(list) 16 | for v1, v2 in edges: 17 | alist[v1].append(v2) 18 | alist[v2].append(v1) 19 | print("adj list:", alist) 20 | return alist 21 | 22 | # There are some basic traversals we also have to know for virtually every 23 | # graph problem we will be given. There are two main traversals for graphs: 24 | # BFS and DFS. There are a LOT of similarities, and only a few differences. 25 | 26 | # Note: BFS and DFS are incredibly powerful and are given very very often in 27 | # interviews! you can run these on matrixes/grids, trees, and ofc graphs. 28 | # Both of these runtimes are O(n), because at worst case it visits all nodes 29 | # but never one node twice, bc, as you'll see, we maintain a set for this reason. 30 | 31 | 32 | # Breadth First Traversals: you vist one node, visit all its 33 | # neighbors, and then visit the neighbor's neighbors. We do this until we 34 | # have visited all the nodes in the trees. 35 | # When BFS -> Think Queue 36 | def bfs(edges, s): 37 | adjList = adjListRep(edges) 38 | visit = deque() 39 | visit.append(s) 40 | visited = set() 41 | while len(visit) > 0: 42 | currNode = visit.popleft() 43 | print(currNode) 44 | visited.add(currNode) 45 | neighbors = adjList[currNode] 46 | for neighbor in neighbors: 47 | if neighbor not in visited: 48 | visit.append(neighbor) 49 | 50 | # Depth First Search will start at one node and go as deep as it can. So it'll 51 | # visit one node, then one of its neighbor, then another one of its neighbor 52 | # and do that until it gets to a node that has no more unvisited neigbors. 53 | # When it has no more unvisited neighbors, it will backtrack to the previously 54 | # visited node that has neighbors and go deep down until it hits another with 55 | # no more neighbors and redo all of that. 56 | # When DFS -> Think Stack. 57 | def dfs(edges, s): 58 | adjList = adjListRep(edges) 59 | visit = deque() 60 | visit.append(s) 61 | visited = set() 62 | while len(visit) > 0: 63 | currNode = visit.pop() 64 | print(currNode) 65 | visited.add(currNode) 66 | neighbors = adjList[currNode] 67 | for neighbor in neighbors: 68 | if neighbor not in visited: 69 | visit.append(neighbor) 70 | 71 | 72 | # Edges of an undirected graph. 73 | edges = [(1, 2), (1, 5), (2, 5), (2, 4), (2, 3), (3, 8)] 74 | dfs(edges, 3) -------------------------------------------------------------------------------- /datastructs/sortedarraytobst.py: -------------------------------------------------------------------------------- 1 | # Leetcode # 108 2 | # Given a sorted array: [1, 2, 3, 4, 5, 6, 7] 3 | # return a balanced BST: 4 | # 4 5 | # 2 6 6 | # 1 3 5 7 7 | 8 | # Thinking process: 9 | # There's one thing that's already evident to me: 10 | # the array is sorted -> we can do binary serach on it! and we 11 | # probably need to for this question 12 | 13 | 14 | # arr = [1, 2, 3, 4, 5, 6, 7] 15 | # thinking about this example and binary search on paper: 16 | # theres a pattern I noticed 17 | # Stack 0: 18 | # l: 0, r: 6, mid = 3 -> this is the head arr[3] => 4 19 | # lets look at the left of it 20 | # Stack 1: 21 | # l: 0, r: 2, mid = 1 -> this is head.left arr[1] => 2 22 | # let's say our algo keeps going left... 23 | # Stack 2: 24 | # l: 0, r: 0, mid = 0 -> this is head.left.left arr[0] => 1 and go up the stack 25 | # [this seems recursive, if so this could be base case] 26 | # let's say we go up the stack now, we'll have this. 27 | # Stack 1: 28 | # l: 0, r: 2, mid was 1 -> like binary search, we could now chose r = mid + 1 29 | # let's go right this time to this: 30 | # Stack 2: 31 | # l: 2, r: 2, mid = 2 -> create node arr[2] => 3 and exit. in case you're not 32 | # seeing it, we now have 4, 2, 1, and 3. it looks like this rn: 33 | # 4 34 | # 2 35 | # 1 3 36 | # and now we will go up another stack and get back to this: 37 | # Stack 1: 38 | # l: 0, r: 2 we visited self, left and right child so we can go up agian 39 | # Stack 0: 40 | # l: 0, r: 6, and mid was 3 -> so now we call it on the right and do it all over again. 41 | # Stack 1: l: 4, r: 6, mid = 5 -> create node arr[5] => 6 42 | # Stack 2: l: 4, r:4, mid 4 -> create node arr[4] => 5 43 | # 4 44 | # 2 6 45 | # 1 3 5 46 | # Stack 1: l: 4, r: 6, mid = 5 47 | # Stack 2: l: 6, r: 6, mid = 6 => create arr[6] => 7 48 | # Stack 1 49 | # Stack 0: return 50 | # 4 51 | # 2 6 52 | # 1 3 5 7 53 | 54 | # After that lengthy explaination, the code should be clear to you: 55 | 56 | # Definition for a binary tree node. 57 | class TreeNode(object): 58 | def __init__(self, x): 59 | self.val = x 60 | self.left = None 61 | self.right = None 62 | 63 | class Solution(object): 64 | def sortedArrayToBST(self, nums): 65 | """ 66 | :type nums: List[int] 67 | :rtype: TreeNode 68 | """ 69 | return self.createBST(nums, 0, len(nums)-1) 70 | 71 | def createBST(self, nums, l, r): 72 | if l > r: return None 73 | if l == r: 74 | return TreeNode(nums[l]) 75 | 76 | mid = (l + r) // 2 77 | parent = TreeNode(nums[mid]) 78 | parent.left = self.createBST(nums, l, mid - 1) 79 | parent.right = self.createBST(nums, mid + 1, r) 80 | return parent 81 | 82 | # Here's an even cleaner algorithm: 83 | # def sortedArrayToBST(self, num): 84 | # if not num: return None 85 | 86 | # mid = len(num) // 2 87 | # root = TreeNode(num[mid]) 88 | # root.left = sortedArrayToBST(num[:mid]) 89 | # root.right = sortedArrayToBST(num[mid+1:]) 90 | 91 | # return root 92 | -------------------------------------------------------------------------------- /datastructs/linkedlists.py: -------------------------------------------------------------------------------- 1 | # Linked Lists are a linear data structure where one node 2 | # points to another. A visual: 3 | # 1 -> 2 -> 3 4 | # linked lists can also be doubly linked: 5 | # 1 <-> 2 <-> 3 6 | # Since each node has two pointers, next and previous. Here is 7 | # how we often are given or create on during interviews: 8 | 9 | class Node(object): 10 | def __init__(self, val=None): 11 | self.val = val 12 | self.next = None 13 | self.prev = None 14 | 15 | def __repr__(self): 16 | node = self 17 | while node != None: 18 | print node.val 19 | node = node.next 20 | return "" 21 | 22 | class DoublyNode(Node): 23 | def __init__(self, val=None): 24 | Node.__init__(self, val) 25 | self.prev = None 26 | 27 | class SinglyNode(Node): 28 | def __init__(self, val=None): 29 | Node.__init__(self, val) 30 | 31 | 32 | # There are some common things you should know how to do in an interview, for each 33 | # consider returning new, doing it in place, singly, doubly, and all edge cases: 34 | 35 | # 1. Reverse a linked list 36 | # 1 -> 2 -> 3 -> 4 -> None 37 | def reversesingly1(head): 38 | if head == None or head.next == None: return head 39 | newhead = reversesingly1(head.next) # this will be the last element on the list 40 | head.next.next = head 41 | head.next = None 42 | return newhead # we keep passing thew new head unchanged up the stack until returned 43 | 44 | # I feel like I understand this and forget it way too many times so here's an 45 | # explaination: 46 | # 1. if they gave you an empty linked list or you are on the last item, return head 47 | # that last item (or empty head) will be the first node in your reversed linked list. 48 | # 2. whatever is returned (like for instance, the first head) save it. and recursively call 49 | # the function passing on the next item (this will add stack calls all the way until 50 | # we are at the last item) 51 | # 3. now, this is the tricky part. this line gets called, after the stack calls have returned. 52 | # for instance, for 1 -> 2 -> 3 -> 4 -> None, the first time this algo runs is on node 4 53 | # it has just returned and now you have newhead = 4, and head is 3, bc you are on that stack 54 | # call. so head.next.next is setting 4's next to head which is 3. so you know have 55 | # 1 -> 2 -> 3 -> 4 -> None to 1 -> 2 -> 3 -> None 4 -| 56 | # h nh ^_____________| 57 | # and then you return 4 -> 3 -> None to the next stack call. In the next stack call, you 58 | # have 3 as the head and 4 as the newhead. notice that you keep returning new head. this is the 59 | # final head you'd want returned as the solution. so this time you have the following viz: 60 | # 1 -> 2 -> 3 -> None 4 -> 3 -> None 61 | # h nh 62 | # and now you make head's next (3) point to head (2) and and have head point to none like so: 63 | # 1 -> 2 -> None and 4 -> 3 -> 2 -> None 64 | # you get it. at the end, it returns with the reversed linked list. 65 | 66 | def reversesingly2(head): 67 | pass 68 | 69 | 70 | 71 | # 2. Delete in a linked list in place and return the new head 72 | def deleteNode(head, val): 73 | if head == None: return head 74 | if head.val == val: return head.next 75 | 76 | temp = head 77 | while head.next: 78 | if head.next.val == val: 79 | head.next = head.next.next 80 | break 81 | head = head.next 82 | return temp 83 | 84 | 85 | # 3. Insert in a given position 86 | # 4. Merge two sorted linked list 87 | 88 | n1 = SinglyNode(1) 89 | n2 = SinglyNode(2) 90 | n3 = SinglyNode(3) 91 | n4 = SinglyNode(4) 92 | n1.next = n2 93 | n2.next = n3 94 | n3.next = n4 95 | print(deleteNode(n1, 1)) 96 | -------------------------------------------------------------------------------- /datastructs/binaryheap.py: -------------------------------------------------------------------------------- 1 | # Implement a priority queue (AKA heap) 2 | # A priority queue is a data structure that maintains a maximum 3 | # of a set of elements. Instead of FIFO, it maintains the queue 4 | # in terms of their priority in an array. You can only access the 5 | # max element. A priority queue has the following functions: 6 | # - top - get top element 7 | # - insert - inserts an element into heap 8 | # - pop - remove the top element 9 | # a priority queue is implemented as a binary max heap tree. A 10 | # binary heap has the following properties: 11 | # 1. all levels must be filled one at a time with last one full 12 | # from left to right. in other words, must be a complete tree. 13 | # 2. the root will be the largest element in the tree. this must 14 | # hold for all nodes / subtrees. 15 | # Note: The passage above describes the Binary Max Heap, however, binary 16 | # min heaps are a thing! Istead of maintaining the max, it retains the 17 | # minimum element. 18 | 19 | 20 | # we have a Heap Object that represents the Binary Max Heap. 21 | class Heap(): 22 | def __init__(self): 23 | self.heap = [] # are heap will be set as an array. 24 | self.size = 0 # size look up will be O(1) if we maintain it here 25 | self.top = None # getting the max will be O(1) as well. 26 | 27 | # to build a heap from an array, you can just keep inserting with the helper fn 28 | def makeheap(self, arr): 29 | for n in arr: 30 | self.insert(n) 31 | print(self.heap) 32 | 33 | # this is essentially inserting at the end of the array, and letting 34 | # helper fn, bubbleUp, bring it up the tree to the right place. 35 | def insert(self, num): 36 | self.heap.append(num) 37 | self.size += 1 38 | self.bubbleUp(self.size - 1) 39 | self.top = self.heap[0] # TODO: can we extract this into init? 40 | 41 | # swap the parent with the index as long as the index val is 42 | # larger than parent val, and the index is not the root. 43 | # the run time of this is O(logn) becuase it is a balanced binary tree 44 | # and we are always comparing the item to it's parents. instead of 45 | # looking at every element (n) we are looking at logn bc a full binary 46 | # tree has height of logn 47 | def bubbleUp(self, i): 48 | parent = i // 2 49 | while (parent >= 0 and i > 0): 50 | if (self.heap[i] > self.heap[parent]): 51 | self.heap[parent], self.heap[i] = self.heap[i], self.heap[parent] 52 | i = parent 53 | parent = i // 2 54 | 55 | # to pop, you switch the max element, with the element at the end of the 56 | # array, decrement the size, and bubbleDown the top element, to it's right 57 | # place. remember: pop gives you the largest element! 58 | # runtime is O(logn) bc popping is O(1) and bubble down is O(logn) 59 | def pop(self): 60 | top = self.heap[0] 61 | self.heap[0], self.heap[self.size-1] = self.heap[self.size-1], self.heap[0] 62 | self.size -= 1 # TODO: can we manage this in init function? 63 | self.heap.pop() # self.heap is the array! you want to run pop so python internally 64 | # can accurately behave when you do operations such as append, pop, 65 | # size, expand, and so on! 66 | self.bubbleDown(0) 67 | print self.heap 68 | 69 | # find the two children of the given index/node, swap it with the largest 70 | # child until both children are less than the given index or it has no 71 | # children. the following method assumes only valid indexes, i, will be given 72 | # the runtime of this is also logn because the work we do is proportional to 73 | # the height, much like bubble up. bubble up looks at the parents, and bubble 74 | # down looks at the children. this is a constant larger, but constants are dropped 75 | # in big O analysis 76 | def bubbleDown(self, i): 77 | left = i * 2 + 1 78 | right = i * 2 + 2 79 | 80 | # if both left and right is out of bounds, return. this is the base case 81 | # as it means it is all the way at the end. 82 | if left > self.size-1 and right > self.size-1: return 83 | 84 | # if the left index is out of bounds, and the right child is greater than i 85 | if (left > self.size-1 and self.heap[right] > self.heap[i]): 86 | self.heap[right], self.heap[i] = self.heap[i], self.heap[right] 87 | self.bubbleDown(right) 88 | # if the right index is out of bounds, and the left child is greater than i 89 | elif (right > self.size-1 and self.heap[left] > self.heap[i]): 90 | self.heap[left], self.heap[i] = self.heap[i], self.heap[left] 91 | self.bubbleDown(left) 92 | else: 93 | # if both nodes are valid, compare them and swap i with the greater child 94 | if(self.heap[left] > self.heap[right]): 95 | self.heap[left], self.heap[i] = self.heap[i], self.heap[left] 96 | self.bubbleDown(left) 97 | else: 98 | self.heap[right], self.heap[i] = self.heap[i], self.heap[right] 99 | self.bubbleDown(right) 100 | 101 | 102 | heap = Heap() 103 | heap.makeheap([1, 2, 3, 4, 5, 6, 6, 9, 10]) 104 | print "now we try popping" 105 | heap.pop() -------------------------------------------------------------------------------- /algorithms/sorting.py: -------------------------------------------------------------------------------- 1 | # Bubble sort - compares adjacent pairs and swaps every time until sorted 2 | # Worst case is O(n^2) this happens when its reverse sorted 3 | # Best case is O(n) this happens when its sorted (due to the sorted boolean) 4 | def bubblesort(arr): 5 | sorted = False 6 | for i in range(len(arr)): # go through whole array 7 | for j in range(len(arr)-1-i): #go through the array up to n-1-i 8 | # (bc we access arr[j+1] and we already know the last i elements will 9 | # be sorted after our i'th passing.) 10 | sorted = True 11 | print("comparing", arr[j], "and", arr[j+1]) 12 | if arr[j] > arr[j+1]: 13 | sorted = False 14 | arr[j], arr[j+1] = arr[j+1], arr[j] 15 | if sorted: return arr # optimized to return if its sorted after one pass 16 | print("after one passing", arr) 17 | return arr 18 | 19 | # Selection sort - bubble sort brings the largest elements to the end slowly, 20 | # in that sense, selection sort is the inverse of that (not exactly since we 21 | # conpare pairs) in the sens that it always brings the smallest down to the front. 22 | # Selection sort basically finds the smallest in the subarray and start to sort 23 | # the first half of the array by swapping it to its right location. 24 | # Runtime: O(n^2) 25 | def selectionsort(arr): 26 | for i in range(len(arr)): # the whole array O(n) 27 | min = i 28 | curr = i + 1 29 | while curr < len(arr): # starting from i+1 to the end of the array 30 | # this is still considered O(n) 31 | if arr[curr] < arr[min]: 32 | min = curr 33 | curr += 1 34 | arr[i], arr[min] = arr[min], arr[i] 35 | print(i+1,":",arr) 36 | 37 | # insertion sort - keeps the left partition of the array sorted and looks 38 | # directly right of the partition to see if its larger the last elemented 39 | # in the sorted partition. If it is, it keeps swapping to the left until it 40 | # determines the right place to insert the new element. 41 | # Worst case you shift every thing over to insert for every element. therefore 42 | # O(n^2) 43 | def insertionsort(arr): 44 | for i in range(len(arr)): # O(n) 45 | j = i + 1 # adj to the partion and the elem we will be inserting 46 | while j - 1 >= 0 and j < len(arr) and arr[j] < arr[j-1]: # while index 47 | # is valid and j needs to have things shifted over to insert. 48 | arr[j], arr[j-1] = arr[j-1], arr[j] 49 | j -= 1 # keep going further left to check if the item is at the right place 50 | print(arr) 51 | 52 | # merge sort - uses the classic divide and conquer to sort an array 53 | # it splits the array into half util it is comparing two elements. 54 | # for each of those left and right halves, it sorts them at each level 55 | # for instance: for array [1, 4, 8, 5] 56 | # [1, 4] and [8, 5] 57 | # then [1] and [4] as well as [8] and [5] 58 | # one and four get merged as [1, 4] and returned to be comapred to [5, 8] 59 | # both of those are compared to return [1, 4, 5, 8] 60 | # we'll need a recursive mergesort, and a helper function to sort the array 61 | # runtime: O(nlogn) this is becuse it splits it in half till base case of 1 62 | # this is the definition of log (base 2) n. and for each of these n, we 63 | # compare the elements which are of size n. The interesting thing about mergesort 64 | # is that it has a great runtime but it's always O(nlogn) where as bubble sort 65 | # that has a worse big O runtime of O(n^2) will sometimes outperform it. For intance 66 | # if it's already sorted, bubble sort will do O(n) but mergesort will still do O(nlogn) 67 | def mergesort(arr): 68 | if len(arr) < 2: return arr 69 | mid = len(arr) // 2 70 | left = mergesort(arr[:mid]) 71 | right = mergesort(arr[mid:]) 72 | return merge(left, right) 73 | 74 | 75 | def merge(arr1, arr2): 76 | if not arr1 or not arr2: return arr1 or arr2 77 | len1, len2 = len(arr1), len(arr2) 78 | i, j = 0, 0, 79 | arr = [] 80 | print("arr1:", arr1, "arr2:", arr2) 81 | while i < len1 and j < len2: 82 | print(arr1[i], "vs", arr2[j]) 83 | if arr1[i] < arr2[j]: 84 | arr.append(arr1[i]) 85 | i += 1 86 | else: 87 | arr.append(arr2[j]) 88 | j += 1 89 | if i < len1: 90 | arr.extend(arr1[i:]) 91 | if j < len2: 92 | arr.extend(arr2[j:]) 93 | return arr 94 | 95 | # quicksort - Quicksort works by repeatedly splitting the array to 96 | # partitions around the pivot--meaning, it'll organize the subarr/arr 97 | # to less than pivot and greater than pivot. this essentially ensures 98 | # that the pivot is at the exact location it needs to be in after the 99 | # partition. every element will get to be the pivot and therefore 100 | # find it's perfect spot. partition is what picks the pivot (the last 101 | # element in the subarray) and puts everything less then before the pivot 102 | # and everything greater than after the pivot, puts the pivot in the right 103 | # place. then it does the partition (pick pivot and rearrange) for each of 104 | # the subarrays left and right of the partition. 105 | # Runtime: O(n^2) -> if you pick the wrong pivot (greatest element or 106 | # smallest element) you keep doing partitons unevenly. however this would 107 | # be if your array is sorted in some order (ascending/descending) 108 | # more often, you dont have to worry about this worst case. it's often O(nlogn) 109 | # bc you split it each time and you do a factor or n comparisions for each logn 110 | # space complexity is O(logn) as you do logn recursion calls. 111 | 112 | def quicksort(arr, begin, end): 113 | if begin >= end: return 114 | pivot = partition(arr, begin, end) # the place to partition around 115 | quicksort(arr, begin, pivot - 1) # run partition on the left side of pivot 116 | quicksort(arr, pivot + 1, end) # run partition on the right side of pivot 117 | 118 | def partition(arr, begin, end): 119 | pivot = end # the last element is our pivot 120 | i, j = begin - 1 , begin # i always will start off index, j will be where to begin 121 | while j < pivot: # as long we dont see the pivit 122 | if arr[j] <= arr[pivot]: 123 | i += 1 124 | arr[i], arr[j] = arr[j], arr[i] 125 | j += 1 126 | arr[i+1], arr[pivot] = arr[pivot], arr[i+1] 127 | return i+1 128 | 129 | 130 | # tim sort --------------------------------------------------------------------------------