It is clear that the code is becoming more and more complex as time goes on. This code complexity creates a number of challenges for developers, including confusion, mistakes, and longer development times. While these challenges can be frustrating, they are also what make coding an interesting and ever-evolving profession.
An overview of Asymptotic Notation in algorithms
Asymptotic notation is a powerful tool in the analysis of algorithms. It provides a way to analyze the running time of algorithms in a way that is independent of the specific implementation of the algorithm. This can be helpful in identifying potential problems with an algorithm and in comparing the running times of different algorithms.
An algorithm’s running time is usually expressed using big O notation. In big O notation, the running time of an algorithm is represented as a function of the size of the input. The function represents the worst case scenario, so the running time of the algorithm is asymptotically bounded by the function.
Asymptotic notation is a mathematical notation used to describe the behavior of algorithms as their inputs become large. it is a way of expressing the relative speeds of different algorithms. for example, the notation “O(n)” means that the algorithm’s running time increases linearly with the size of the input. the notation “O(n^2)” means that the algorithm’s running time increases quadratically with the size of the input.
The running time of an algorithm that searches for a particular element in a list of size N can be expressed as O(N). This means that the running time of the algorithm is bounded by a function that is proportional to the size of the input.
An algorithm with a polynomial running time is generally considered feasible, while an exponential one is generally useless. Although this isn’t entirely true in practice, it is, in many cases, a useful distinction.
Understanding about asymptotic in case of python coding complexity
Let’s focus on how to determine the asymptotic running time of a program, specifically for programs where the running time only varies with the problem size. This is all algorithm analysis in terms of complexity of code.
The focus of further discussion is loops and code blocks, which are important for now, and function calls, which don’t complicate things but just calculate the complexity for the call and insert it at the right place.
Level one complexity: The loop-free code
The little two-line program fragment is: Append is Θ(1), while insert at position 0 is Θ(n).
# append nums[0:] to nums nums = append(nums, nums[0:]) # insert nums[1:] at position nums = insert(nums, nums[1:], 0) or simply: nums.append(1) nums.insert(0,2)
The first line of code takes constant time, meaning the time doesn’t change no matter how many items are in the list. The second line takes time that depends on the size of the list, so it has a complexity of Θ(n). This means that the total running time is the sum of the two complexities, Θ(1) + Θ(n) = Θ(n).
Level one complexity: With loop code
A for loop is a type of loop that allows you to iterate through a sequence of items. It has a keyword, “for”, followed by an expression and a block. The expression is evaluated once and the block is executed once for each item in the sequence.
seq = 0 for i in seq1: seq += i
Let’s say we have to sum up all possible products of the elements in seq as:
For this seq = [1,2,3,4]
We get the following results:
- 1 + 2 = 3
- 1 + 3 = 4
- 2 + 3 = 5
- 1 + 4 = 5
- 3 + 4 = 7
This is a straightforward implementation of the sum function, which iterates over a sequence and adds the elements together. This performs a single constant-time operation for each of the n elements of the sequence, which means that its running time is linear, or Θ(n).
Level two complexity: for loop in list comprehension
The list comprehension also has a linear running-time complexity because the loops are camouflaged
squares = [x**2 for x in seq]
When working with functions and methods that deal with every element in a container, it’s important to be aware of potential hidden loops. This generally applies to any function or method that takes a container as an input. Things can get a little bit tricky when we start nesting loops, but not a lot.
We can create a function that will sum all the products of the elements in a sequence. The function will take in a sequence and return the sum of all the products of the elements in the sequence.
Let’s take the following code:
d = 0 for x in seq: for y in seq: d += x*y
This implementation of the product iterator uses order to store products and runs in time O(n), where n is the length of the sequence. It is worth noting that each product will be added twice (if 12 and 13 are both in seq, for example, we’ll add both 12*13 and 13*12), but this doesn’t affect the running time.
Level three complexity: Nested and Sequential cases
Adding the complexity of code blocks executed one after the other just results in a more complex problem. The complexities of nested loops are multiplied, because for each round of the outer loop, the inner one is executed in full. This results in a running time that is Θ(n · n) = Θ(n 2 ).
This rule of complexity of code blocks states that if there are further levels of nesting, the number of loops will just increment the power. This means that for 3 nested loops, the time complexity will be Θ(n 3 ), for 4 nested loops it will be Θ(n 4 ), and so forth.
A nested case is a case that is one level of abstraction below a higher-level case. Nested cases are usually used to group cases together that are related to a specific problem.
A sequential case is a case that is one level of abstraction above a lower-level case. Sequential cases are usually used to group cases together that are related to a specific problem.
Let’s combine sequential and nested cases as:
d = 0 for x in seq: for y in seq: d += x*y for z in seq: for w in seq: d += x-w
We can compute the running time for the given code, using our rules of computational complexity. The z-loop runs for a linear number of iterations, and it contains a linear loop, so the total complexity there is quadratic, or Θ(n 2 ). The y-loop is obviously Θ(n).
The running time of the inner block can be calculated as cubic, since the y-loop is submissive by the z-loop, which runs in linear time.
We can arrive at this conclusion even more easily by noting that the y-loop is dominated by the z-loop and can be ignored, giving the inner block a cubic running time.
The code is designed to operate on two sequences of items, where the first sequence has n items and the second sequence has m items. The code will have a running time of Θ(nm).
d = 0 for x in seq1: for y in seq2: d += x*y
The inner loop doesn’t need to be executed the same number of times for each iteration of the outer loop; this can get a bit fiddly. Instead of multiplying two iteration counts, we now have to sum the iteration counts of the inner loop.
A loop within a loop or a loop within nested or sequential loops, means we are going towards more complexity of code.
d = 0 n = len(seq) for i in range(n-1): for j in range(i+1, n): d += seq[i] * seq[j]
The code now avoids adding the same product twice by iterating over the items in the array only after the last one currently considered by the outer loop. This makes the code a lot less confusing, but finding the complexity here requires a little bit more care.
In general, it is best to keep code as simple as possible to make it more maintainable. There are several best practices that can help with this, such as using meaningful variable and function names, breaking code into small modules, and avoiding complex expressions. By following these guidelines, code can be made more readable and easier to understand. Algorithm analysis and design is the backbone of programming.