~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Mutable and Immutable Objects - Mutable Objects - Objects that are mutable can have their value changed after they are created. - Objects of built-in types like (list, set, dict, user defined classes) are mutable. Example: my_list = [1, 2, 3] print(my_list) - Solution output: [1, 2, 3] `````````````````````````````````````````````````````````````````````````````````````` # you can change the integer in the list like this my_list[1] = 7 Print(my_list) - Solution output: [1, 7, 3] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Immutable Objects - An Object is immutable if its value cannot be changed after it is created. - Objects of built-in types like (int, float, bool, str, tuple, unicode, decimals, ranges) are immutable. Example: my_tuple = (1, 2, 3) print(my_tuple) - Solution output: (1, 2, 3) `````````````````````````````````````````````````````````````````````````````````````` # you will run into an error because the tuple object doesn't support item assignment - this means we cannot change the value itself my_tuple[1] = 7 print(my_tuple) - Solution output: returns an error tuple object doesn't support item assignment `````````````````````````````````````````````````````````````````````````````````````` - Even though we cannot change the value itself, we can still access it in memory - every single object in python has a place in memory and you can actually determine where that place of memory is using using the function called id() - id() tells the identity of an object in Python `````````````````````````````````````````````````````````````````````````````````````` # so if you print id of my_tuple print(id(my_tuple)) - Solution output: 140657764146688 - this is the address in which it stores in memory - every single object in python has this - Solution output: 139693624819008 `````````````````````````````````````````````````````````````````````````````````````` # so if you print id of my_list print(id(my_list)) - Solution output: 1396936244884488 `````````````````````````````````````````````````````````````````````````````````````` - strings are immutable my_string = "Hello" Print(my_string) - Solution output: returns an error str object doesn't support item assignment - Strings are not mutable in Python `````````````````````````````````````````````````````````````````````````````````````` - if we want to change the secound letter in Hello my_string[1] = "y" Print(my_string) - Solution output: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Compare the time complexity of different approaches to a problem using Big O notation - What is Big O notation? - Big-O notation is a metrics used to find algorithm complexity. - Big-O notation signifies the relationship between the input to the algorithm and the steps required to execute the algorithm. Good resource for Big O notation: https://stackabuse.com/big-o-notation-and-algorithm-analysis-with-python-examples/#:~:text=Big%2DO%20notation%20is%20a%20metrics%20used%20to%20find%20algorithm,by%20opening%20and%20closing%20parenthesis. `````````````````````````````````````````````````````````````````````````````````````` - What is an Algorithm? - Set of instructions that executes and solves a problem - Any code you write is an algorithm - If you have two sets of algorithms that solve the exact same problem -> - How do we know which one's the better of the two - In computer science we usually look at two things - time - is the algorithm fast - space - does the algorithm don't take up too much computer memory `````````````````````````````````````````````````````````````````````````````````````` - Time -> how do we measure how fast an algorithm is - you may thing timing it will give us the solution - however, every computer runs at a different speed so that isn't the best practice - The best way to measure the time complexity of an algorithm or how how fast an algorithm is - is to use Big O notation Big O notation -> measures how fast an algorithm grows based on how the input itself grows - if an input grows to a million and the algorithom also grows to a million - this is called Linear runtime `````````````````````````````````````````````````````````````````````````````````````` —Linear Time: Linear Time Complexity describes an algorithm or program who’s complexity will grow in direct proportion to the size of the input data. As a rule of thumb, it is best to try and keep your functions running below or within this range of time-complexity, but obviously it won’t always be possible. `````````````````````````````````````````````````````````````````````````````````````` - Another resource explaining Big O notation and Linear Runtime https://medium.com/@ariel.salem1989/an-easy-to-use-guide-to-big-o-time-complexity-5dcf4be8a444 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Common Big O Run Times ````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` | Classification:|Description: | ````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` ||| | - Constant O(1) | The runtime is entirely unaffected by the input size. This is the ideal solution.| ||| | - Logarithmic O(log n)| As the input size increases, the runtime will grow slightly slower. This is a pretty good solution.| ||| | - Linear O(n)|As the input size increases, the runtime will grow at the same rate. This is a pretty good solution.| ||| | - Polynomial O(n^c)|As the input size increases, the runtime will grow at a faster rate. This might work for small inputs | ||but is not a scalable solution.| ||| | - Exponential O(c^n)|As the input size increases, the runtime will grow at a much faster rate. This solution is inefficient.| ||| | - Factorial O(n!)|As the input size increases, the runtime will grow astronomically, even with relatively small inputs. | ||This solution is exceptionally inefficient.| ||| ````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` | | | ````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````` In our warmup notes checkout the graph showing the curves of these different runtimes - LowerCase n is the size of the data - UpperCase N represents the number of operations ***Note: Big O only matters for large data sets. An O(n^3) solution is adequate, as long as you can guarantee that your datasets will always be small.**** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Examples of a few different examples of Python functions that print something to output ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Constant Time def print_one_item(items): print(items[0])# O(1) - print_one_item -> How fast is it - Items is a list and we are going to print "items" to "0" - How many operation will it take if "items" the length of two -> we are only doing one operation because items is "0" - What if "items" was size one million when we had a list that was a million "items" -> again, we are only doing one operation because items is "0" - No matter how big "items" is --> we are just going to do one operation. So it is constant no matter how big items is. - If it is a constant runtime we would go ahead and call that "O(1)" `````````````````````````````````````````````````````````````````````````````````````` - Linear Time def print_every_item(items): for item in items: print(item)# O(N) - We are taking in the list of values called "items" - For every single value we are going to print it - What if "items" is the lenght of "10" --> We would print "10" values - If "item" was the length of a million --> We would print "1,000,000" values - This would grow respectively with the size of the input - This would be called Linear runtime --> because it grows in the same fashion - With Linear runtime we would call it "O(N)" `````````````````````````````````````````````````````````````````````````````````````` - Quadratic Time def print_pairs(items): for item_one in items: for item_two in items: print(item_one, item_two) - print_pairs -> We have a list of values called "items" - For every "item" -> We are going to go through "items" -> print both of those "items" - We know that with onefor loop it takes "O(N)" -> - But looks for every single "n" -> we are going to have to do that "n" times - So we will have to do "N" * "N" - Calls "O(N*N)" -> "N" * "N" = N^2 - This is a Quadratic runtime and we would call it "O(N^2) `````````````````````````````````````````````````````````````````````````````````````` - What about Constants? def do_a_bunch_of_stuff(items): # O(1 + n/2 + 2000) last_idx = len(items) - 1 print(items[last_idx]) # O(1) middle_idx = len(items) / 2 idx = 0 while idx < middle_idx: # O(n/2) print(items[idx]) idx = idx + 1 for num in range(2000): # O(2000) print(num) # O(N) beg * - This function does a bunch of stuff "do_a_bunch_of_stuff" 1st * - This first part "last_idx" takes constant time "O(1)" - We are going to print just the "items" of the last index -> we said that this takes a constant runtime - Then looking at the middle_idx we are going to print every value - middle_idx is going to take half the length of my list -> since we are going to the middle of the list and printing it 2nd *- the second part "middle_idx" takes "O(N/2)" - then we will go ahead and go from the range(2000) print 3rd *- and the third part "range(2000)" takes "O(2000)" - So what is the overall runtime of this algorithm? - We will need to base it on the size of the input -> As the input grows -> What matters? - As the input grows up to 2000 -> this will take the longest part - However, we need to think about infinite growth - Once N is at "1,000.000" -> parts "first" and "third" will not really matter that much "O(1)" & "O(2000)" - We only care about the middle section - part "second" - Where it is "O(n/2)" - We would actually say that this algorithm runs in "O(N/2)" - But we don't care actually about constants whe doing "Big O Notation" - We would need to get rid of these constants and say "O(N)" `````````````````````````````````````````````````````````````````````````````````````` - Most Significant Term def do_different_things(items): # O(n + n^2) for item in items: # O(n) print(item) for item_one in items: # O(n * n) = O(n^2) for item_two in items: print(item_one, item_two) - This does do_differnt_things 1st * - We are going to pring every "item" -> where it takes "O(N)" linear runtime 2nd *- Then we will do the double for loop and that takes "O(N^2)" - We know the first part takes "N" - And the second part takes "N^2" - So whenever we are analyzing the runtime of an algorithm, we always just take the larger value because that is going to grow much faster - So we would say "O(N^2)" because this is bigger than "O(N)" `````````````````````````````````````````````````````````````````````````````````````` - Big O represents the worse-case def search_for_thing(items, thing): for item in items: if item == thing: return True return False - For "item" and "items" -> if "item" = "thing" return "True" - If "thing" is the first value in this list of "items" -> we are gonna be done after the first for loop that is jsut constant runtime - But we should always think about the worst case scenario - If "thing" is at the end of the "items" list - then we don't have what exactly -> we have to go through every single value in "items" so it would be "O(N)" - `````````````````````````````````````````````````````````````````````````````````````` - Logarithmic runtime def foo(n): i = 1 while i < n print(i) i *= 2 foo(16) answer is: 1 2 4 8 foo(32) answer is: 1 2 4 8 16 foo(64) answer is: 1 2 4 8 16 32 - foo function - "i" equals "1", while "i" is less then "n" - print "i" and then "i" is itself -> multiplied by "2" - Call foo of 16 - Call foo of 32 - we doubled the size of "N" but only increased our operation by "1" - Call foo of 32 - every time we double our input, we are only increasing our input by "1" - We call this Logarithmic runtime - So this would be "O(log N)" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Compare the space complexity of different approached to a problem using "Big O Notation" - The second aspect of determining how good an algorithm is, is space complexity - Space Complexity: - Efficiency of memory usage instead of the number of operations - The space complexity is basically the amount of memory space required to solve a problem in relation to the input size. - Rather than focusing on number of operations -> we are focusing on the efficiency of memory usage `````````````````````````````````````````````````````````````````````````````````````` def print_lambda_n_times(n): for i in range(n): print("lambda") # O(1) - print_lambda_n_times - "n" is the input - Then for "i" range "n" - print lambda - printing does not take up any memory whatsoever -> because we are printing to the terminal and we are not storing anything. - This will take "O(1)" in terms of space complexity. - Because we are not storing anything, this is "O(1)" - No matter how big "n" is we are going to get where we always take "O(1)" -> again this is just for space complexity `````````````````````````````````````````````````````````````````````````````````````` def append_to_list_n_times(n): my_list = [] for _ in range(n): my_list.append("lambda") return my_list - append_to_list_n_times -> we are taking some value called "n" - Then we set an empty list called my_list - Then append lambda "n" times - By appending we are storing a value in a list - It looks like if "n" is "100", we are going to store "lambda" "100" times - And if "n" is "1,000,000", we are going to store "lambda" "1,000,000" times - So this would take "O(N)" in terms of space complexity `````````````````````````````````````````````````````````````````````````````````````` def get_the_max(items_list): maximum = float("-inf") for item in items_list: if item > maximum: maximum = item return maximum# O(1) - function called get_the_max -> we are going to go through all the "items_list" - see if it compares to our maximum - And if it does, we are going to go ahead and return the maximum - "items_list" -> we are going to be storing that value - "items_list" can be any sort of input -> such as "1,000,000" or "10,000,000" - But we are not storing any additional values besides the maximum - So no matter how large the list is, the space complexity will remain the same - because we are not storing anything additional - so no matter if "items_list" is a hundred or hundred million -> we are only storing the maximum - Therefore, the space complexity is "O(1)" - The time complexity is "O(N)" because the number of operations will increase as the list increases - But the actual space complexity will remain constant because we are not adding anything additional to the memory `````````````````````````````````````````````````````````````````````````````````````` *** Remember Space Complexity is no determined on the size of the input itself, but rather on what additional memory is being used. ****