0% found this document useful (0 votes)
29 views

Extra Class 1

Uploaded by

melt.bonier.0z
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
29 views

Extra Class 1

Uploaded by

melt.bonier.0z
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 21

CMPE 150 Extra classes 1)

Topic 1) Classes and Inheritance:

Detailed Introduction to Classes and Inheritance in Python


Welcome to our deep dive into Classes and Inheritance in Python! In this session, we'll explore
these foundational concepts of Object-Oriented Programming (OOP) and understand how they
empower us to write more modular, scalable, and maintainable code in Python.

Object-Oriented Programming (OOP)


Object-oriented programming is a programming paradigm that has used "objects" and their
interactions to design applications and computer programs for the past 50 years. It took off in
the past 30 years mostly with the development of C++ and JAVA. It allows us to encapsulate
data and the methods that operate on data within one unit, like encapsulating the properties of a
car within a car object.

Encapsulation is one of the critical features of object-oriented programming, which involves the
bundling of data members and functions inside a single class. Bundling similar data members
and functions inside a class also helps in data hiding. Encapsulation also ensures that objects are
self-sufficient functioning pieces and can work independently.

Everything in Python is an object. int, float, str, dictionary, list, etc. Since functions can be
parameters of other functions they are also objects.

What are Classes in Python?


A class in Python is like a blueprint or a template for creating objects. An object is an
encapsulation of variables and functions into a single entity. Objects get their variables and
functions from classes.

Classes are used to create user-defined data structures. They define the nature of a future
object. Once a class is defined, it can be used to create multiple objects. The class defines
attributes and the object's behavior, while the objects are instances of the class.

Attributes and Methods


1. Attributes: These are the variables that are bound to the instances of a class. For
example, a car class might have attributes like brand, model, and year.
2. Methods: These are functions defined inside a class. They are used to define the
behaviors of an object.

Why Inheritance?

Inheritance allows us to define a class that inherits all the methods and properties from another
class. This concept is useful for:
• Code Reusability: Instead of starting from scratch, you can create a class by deriving from
a pre-existing class.
• Complexity Reduction: It allows us to model a complex problem with class hierarchies
simplifying the structure and understanding of the problem.

Types of Inheritance
1. Single Inheritance: Where a child class inherits from one parent class.
2. Multiple Inheritance: Where a child class inherits from multiple parent classes.

Real-world Analogy for Better Understanding


Imagine a class as a blueprint for a building. This blueprint dictates the overall structure, the
number of rooms, doors, windows, and so on – the "attributes" and "methods" of the building.

Using this blueprint (class), you can construct (instantiate) any number of buildings (objects).
Each building might have different purposes or appearances, but they all follow the same
blueprint.

Inheritance is like creating a new blueprint for a specific type of building, say a school, based on a
general building blueprint. This new blueprint inherits all properties of the general blueprint but
can also have additional unique features or modifications.

In the following sections, we will delve into practical examples to solidify these concepts.

class name_type (object): # Object : Class Parent


# we define attributes here
# we add data / procedures etc that belongs to this class

# Defining a simple Python class

class Book:
# __init__ creates an instance
def __init__(self, title, author): # Data : title and author to
initialize
self.title = title
self.author = author

def display_info(self):
print(f"Book: {self.title} by {self.author}")

# Creating an instance of the Book class


my_book = Book("1984", "George Orwell")
my_book.display_info()

Book: 1984 by George Orwell


Explanation of the Example
In the code snippet above, we have crafted a basic class named Book. This class includes:

• An __init__ method, commonly known as the constructor in OOP. This method is


automatically invoked when a new object of the class is created. It initializes the object's
attributes.
• A method named display_info, which is a custom function that prints the details of
the book.

By creating an instance of the Book class (here, my_book with the title "1984" and the author
"George Orwell"), we instantiate an object of the class. We then invoke the display_info
method to display the attributes of this instance.

Advanced Class Features in Python


After understanding the basics of classes and objects, let's delve into some more advanced
features of Python classes.

Class Variables and Instance Variables


• Class Variables are variables that are shared among all instances of a class. They are not
unique to each instance. For example, if we have a variable that counts the number of
books created, it should be a class variable.
• Instance Variables are unique to each instance. They define the characteristics of that
particular instance.

Static Methods and Class Methods


• Static Methods: These are methods that belong to the class and not to any particular
object. They don't require an instance of the class to be used. They are defined using the
@staticmethod decorator.
• Class Methods: Unlike static methods, class methods take a cls parameter that points
to the class—and not the object instance—when the method is called. They are defined
using the @classmethod decorator and can access the class variables.

Let's look at some examples to understand these concepts better.

# Example demonstrating class variables, instance variables, and class


methods

class Book:
total_books = 0 # Class variable

def __init__(self, title, author):


self.title = title # Instance variable
self.author = author # Instance variable
Book.total_books += 1 # Modifying the class variable
def display_info(self):
print(f"Book: {self.title} by {self.author}")

@classmethod
def total(cls):
return f"Total books created: {cls.total_books}"

# Creating instances of the Book class


book1 = Book("To Kill a Mockingbird", "Harper Lee")
book2 = Book("The Great Gatsby", "F. Scott Fitzgerald")

# Accessing the class method


print(Book.total())
book1.display_info()
book2.display_info()

Total books created: 2


Book: To Kill a Mockingbird by Harper Lee
Book: The Great Gatsby by F. Scott Fitzgerald

Explaining Class Variables, Instance Variables, and Class Methods


In this example, we delve deeper into the distinction between class variables and instance
variables, and also explore class methods.

Class Variables vs. Instance Variables


• Class Variables are shared across all instances of a class. In our Book class,
total_books is a class variable, used to keep track of the total number of books
created.
• Instance Variables are unique to each instance (object) created from the class. In our
example, title and author are instance variables, unique to each Book object.

Class Methods
• Class Methods work with class variables and are bound to the class itself, not the object
instances. We use the @classmethod decorator to define a class method. In our
example, the total method is a class method that returns the total number of books
created.

By creating two instances of Book, we manipulate the class variable total_books to reflect
the total count. The total method then accesses this class variable to display the current total.

Code Example

```python class Book: total_books = 0 # Class variable

def __init__(self, title, author):


self.title = title # Instance variable
self.author = author # Instance variable
Book.total_books += 1 # Modifying the class variable
@classmethod
def total(cls):
return f"Total books created: {cls.total_books}"

Creating instances of the Book class


book1 = Book("To Kill a Mockingbird", "Harper Lee") book2 = Book("The Great Gatsby", "F. Scott
Fitzgerald")

Accessing the class method


print(Book.total())

What is Inheritance in Python?


Inheritance is a mechanism in which one class acquires another class's properties (methods and
attributes). It offers a powerful and natural mechanism for organizing and structuring your
software.

Why Inheritance?

Inheritance allows us to define a class that inherits all the methods and properties from another
class. This concept is useful for:

• Code Reusability: Instead of starting from scratch, you can create a class by deriving from
a pre-existing class.
• Complexity Reduction: It allows us to model a complex problem with class hierarchies
simplifying the structure and understanding of the problem.

Types of Inheritance
1. Single Inheritance: Where a child class inherits from one parent class.
2. Multiple Inheritance: Where a child class inherits from multiple parent classes.

Real-world Analogy for Better Understanding


Imagine a class as a blueprint for a building. This blueprint dictates the overall structure, the
number of rooms, doors, windows, and so on – the "attributes" and "methods" of the building.

Using this blueprint (class), you can construct (instantiate) any number of buildings (objects).
Each building might have different purposes or appearances but follow the same blueprint.

Inheritance is like creating a new blueprint for a specific type of building, say a school, based on a
general building blueprint. This new blueprint inherits all properties of the general blueprint but
can also have additional unique features or modifications.
In the following sections, we will delve into practical examples to solidify these concepts.

# Example of inheritance in Python

class Textbook(Book):
def __init__(self, title, author, subject):
super().__init__(title, author) # Parent Class is called super
class, and child class is called subclass
self.subject = subject # New attribute for Textbook

def display_subject(self):
print(f"The subject of '{self.title}' is {self.subject}")

# Creating an instance of the Textbook class


my_textbook = Textbook("Python Programming", "Guido van Rossum",
"Computer Science")
my_textbook.display_info() # Method from the parent class
my_textbook.display_subject() # Method from the Textbook class

Book: Python Programming by Guido van Rossum


The subject of 'Python Programming' is Computer Science

Understanding Inheritance with a Practical Example


In this section, we will see how inheritance is implemented in Python through a practical
example. We will create a Textbook class that inherits from the Book class.

Single Inheritance
• Single Inheritance occurs when a child class inherits from only one parent class. In our
example, Textbook is a child class that inherits from the Book parent class.

The Textbook Class


• The Textbook class inherits all attributes and methods from the Book class.
• It also introduces a new instance variable, subject, which is specific to the Textbook
class.
• The __init__ method of Textbook uses super() to call the __init__ method of
the Book class. This ensures that the title and author attributes are initialized in the
Textbook class as well.
• We define a new method display_subject to showcase the additional functionality in
the Textbook class.

Code Example

```python class Textbook(Book): def init(self, title, author, subject): super().__init__(title, author)
self.subject = subject # New attribute for Textbook

def display_subject(self):
print(f"The subject of '{self.title}' is {self.subject}")
Creating an instance of the Textbook class
my_textbook = Textbook("Python Programming", "Guido van Rossum", "Computer Science")
my_textbook.display_info() # Method from the parent class my_textbook.display_subject() #
Method from the Textbook class

Understanding Getters and Setters in Python


In this section, we will explore the concept of getters and setters in Python, which are integral to
the principle of encapsulation in Object-Oriented Programming (OOP).

Encapsulation and Its Importance


Encapsulation is one of the key principles of OOP. It involves bundling the data (attributes) and
the methods (functions) that operate on the data into a single unit, known as a class.
Encapsulation also includes the idea of restricting access to certain components of an object,
which is where getters and setters come into play.

Why Use Getters and Setters?


1. Controlled Access: Getters and setters allow for controlled access to an object's
attributes. This control can include validation checks, logging, or other transformations,
ensuring the data remains accurate and consistent.
2. Maintain Encapsulation: By using getters and setters, we hide the internal
representation of an object from the outside. This approach allows the internal
implementation to change without affecting code that uses the object.
3. Additional Logic: Getters and setters enable additional logic when getting or setting a
value, beyond merely reading or writing an attribute.

Implementation in Python
Python provides a more elegant and Pythonic way to implement getters and setters using
decorators like @property, @attribute_name.getter, and @attribute_name.setter.

Example: A Book Class with a Title Attribute


Let's consider a Book class with a private attribute _title. We will use getters and setters to
access and modify this attribute.

Code Implementation
```python class Book: def init(self, title): self._title = title # Private attribute

@property
def title(self):
"The title property."
return self._title
@title.setter
def title(self, value):
if not value:
raise ValueError("Title cannot be empty")
self._title = value

class Book:
def __init__(self, title):
self._title = title # Initialize the private attribute

@property
def title(self):
return self._title # Use the private attribute

@title.setter
def title(self, value):
if not value:
raise ValueError("Title cannot be empty")
self._title = value # Modify the private attribute

my_book = Book("1984")

# Using the getter


print(my_book.title) # Output: 1984

# Using the setter


my_book.title = "Animal Farm"

# Trying to set an invalid title


try:
my_book.title = "" # This will raise ValueError
except ValueError as e:
print(e)

1984
Title cannot be empty

Understanding self._title vs. self.title in Python Classes


When working with Python classes, particularly with getters and setters, you might notice the
use of self._title instead of self.title within the class methods. This distinction is
crucial for maintaining good encapsulation practices and avoiding recursive calls.

Private Attribute Convention


• Internal Use: The single underscore prefix, as in _title, is a naming convention in
Python to indicate that an attribute is intended for internal use within the class. It's a way
of signaling to other developers that this attribute should not be accessed or modified
directly from outside the class.
• Encapsulation: Using _title adheres to encapsulation by keeping the internal state of
the class private and only accessible through public methods (getters and setters).

Avoiding Recursive Calls


In a class with getters and setters, directly using self.title inside the class can lead to
unintended recursive calls.

Within Getter and Setter Methods


• Getter Method: If self.title is used inside the getter method, it would cause a
recursive call to the getter itself, leading to a stack overflow error. By using
self._title, we directly access the underlying data, bypassing the getter method.
• Setter Method: Similarly, using self.title = value in the setter would recursively
call the setter. This can lead to infinite recursion and a stack overflow error. Instead,
self._title = value modifies the underlying attribute directly, avoiding recursion.

Example for Final exam:


Vehicle Hierarchy
Background
Develop a Python program to model a vehicle hierarchy using classes and inheritance. The
system should represent different types of vehicles and their characteristics.

Problem Statement
Implement Base Class - Vehicle
• Attributes:
– make (str): The manufacturer of the vehicle.
– model (str): The model of the vehicle.
– year (int): The year of manufacture of the vehicle.
• Methods:
– __init__: Initializes the vehicle's make, model, and year.
– vehicle_info: Returns a string with the vehicle's information.

Implement Inherited Classes - Car and Truck


• Both classes inherit from Vehicle.
• Override the vehicle_info method in each class to include specific details (e.g., "Car"
for Car and "Truck" for Truck).
• Add an additional attribute cargo_capacity for Truck.
• Include a method describe_cargo in Truck that returns information about the cargo
capacity.

Function Requirements
• create_vehicle Function:
– Parameters: vehicle_type (str), make (str), and model (str).
– Returns an instance of Car or Truck based on vehicle_type.
• display_vehicle_info Function:
– Takes a Vehicle object as a parameter.
– Prints the information of the vehicle.
class Vehicle(object): # This is the Parent Class we are creating for
all vehicles
def __init__(self, make, model, year): # initialize the method
with make, model, and year attributes
self._make = make
self._model = model
self._year = year

def vehicle_info(self):
return f"{self._make} {self._model} ({self._year})"

class Car(Vehicle):
def __init__(self, make, model, year): # this one not needed we
are not changing anything
super().__init__(make,model,year)

def vehicle_info(self):
return f"{self._make} {self._model} (Car, {self._year})"

class Truck(Vehicle):
def __init__(self, make, model, year, cargo_capacity):
super().__init__(make, model, year) # now we definitely need
this because we are modifying it for Truck subclass
self._cargo_capacity = cargo_capacity

def vehicle_info(self):
return f"{self._make} {self._model} (Truck, {self._year})"

def describe_cargo(self):
return f"{self._make} {self._model} (Truck, {self._year}) with
{self._cargo_capacity} units cargo capacity"

def create_vehicle(vehicle_type, make, model, year,


cargo_capacity=None):
if vehicle_type == "Car":
return Car(make, model, year)
elif vehicle_type == "Truck":
return Truck(make, model, year, cargo_capacity)
else:
raise ValueError("Invalid vehicle type")
def display_vehicle_info(vehicle):
print(vehicle.vehicle_info())

# Example usage
car = create_vehicle("Car", "Toyota", "Corolla", 2020)
display_vehicle_info(car)

truck = create_vehicle("Truck", "Ford", "F-150", 2020, 2000)


display_vehicle_info(truck)

Toyota Corolla (Car, 2020)


Ford F-150 (Truck, 2020)

Special Methods in Python: __str__ and __add__


Python provides a range of special methods (often called magic methods) that you can define in
your classes to customize their behavior in various contexts. Two commonly used special
methods are __str__ and __add__.

The __str__ Method


Purpose:

The __str__ method is used to define a human-readable string representation of an object. It


is called by the str() function and is also used by the print() function.

When to Use:
• To provide a meaningful description of an object, which is especially useful for
debugging.
• When you need to log or display object information in a format that is easy to
understand.

How to Implement:

```python class Vehicle: def init(self, make, model, year): self.make = make self.model = model
self.year = year

def __str__(self):
return f"{self.year} {self.make} {self.model}"

print(Vehicle) , print(Car) , print(Truck)

<class '__main__.Vehicle'>
<class '__main__.Car'>
<class '__main__.Truck'>

(None, None, None)


Understanding the __add__ Method in Python
Python's __add__ method is a special or "magic" method used to define custom behavior for
the addition operator + within a class. This method allows objects of a class to be added
together using the + operator in a way that is logical for that particular class.

Purpose of __add__:
• Custom Addition Logic: The __add__ method enables you to define how instances of
your class should be added together. This is particularly useful for classes where addition
is a meaningful operation.
• Enhancing Readability: Implementing __add__ makes your code more intuitive and
readable, as it allows for the natural use of the + operator in expressions.

When to Use __add__:


• You should implement the __add__ method in your class when you want to provide a
specific way to add or combine objects of that class.
• It's commonly used in classes that represent mathematical concepts (like vectors or
complex numbers), but it can also be useful in other contexts where combining instances
is meaningful.

How to Implement __add__:

Here's an example implementation of the __add__ method in a Point class, which represents
a point in 2D space:

```python class Point: def init(self, x, y): self.x = x self.y = y

def __add__(self, other):


if isinstance(other, Point):
return Point(self.x + other.x, self.y + other.y)
else:
raise TypeError("Can only add Point to Point")

Example Usage:
point1 = Point(1, 2) point2 = Point(3, 4) point3 = point1 + point2 # Uses the add method
print(point3.x, point3.y) # Output: 4 6

Other magic methods: sub , eq , len , lt ( self < other ) etc.


Topic 2) Big O notation:
you can check https://www.youtube.com/watch?v=D6xkbGLQesk&ab_channel=CSDojo for a
detailed information

Big O Notation Class Notes


Introduction to Big O Notation
• Definition: Big O notation is a mathematical notation used to describe the upper bound
or worst-case performance of an algorithm in terms of its input size.
• Purpose: It helps analyze and compare the efficiency of different algorithms and
understand how their performance scales as input data grows.
• Notation: Big O notation is represented as (O(f(n))), where (f(n)) is a function that
describes the upper bound of the algorithm's time or space complexity.

How is Big O Notation Used?


• Measuring Efficiency: Big O notation quantifies the efficiency of algorithms by focusing
on their most significant factors, such as the number of operations or memory usage.
• Comparing Algorithms: It allows us to compare different algorithms and make informed
choices based on their scalability.
• Predicting Performance: By analyzing an algorithm's time complexity, we can predict
how it will perform as input size increases.

Big O notation O(f(n) is a mathematical notation used to describe the upper bound or worst-case
time complexity of an algorithm in terms of the size of its input (n). It provides a concise way to
express how the runtime of an algorithm scales as the input size increases.

Mathematical Definition

In mathematical terms, Big O notation is defined as follows:

O(f(n)) = { g(n) | ∃ c > 0, n_0 > 0 such that 0 ≤ g(n) ≤ c * f(n) for all n ≥ n_0 }

• (O(f(n))) represents the set of functions that describe the upper bound of the runtime of
an algorithm.
• (g(n)) is the actual runtime of the algorithm for a given input size (n).
• (f(n)) is a specific mathematical function that characterizes the worst-case behavior of
the algorithm.
• (c) is a positive constant that scales the function (f(n)).
• (n_0) is a positive integer that defines the input size beyond which the upper bound
holds.

Interpretation
• (O(f(n))) signifies that the runtime of the algorithm does not grow faster than a constant
multiple of (f(n)) for sufficiently large input sizes ((n \geq n_0)).
• It describes an upper limit on the growth rate of the algorithm's runtime in relation to the
input size.
Example

For example, if an algorithm has a time complexity of (O(n^2)), it means that the worst-case
runtime of the algorithm grows quadratically with the input size (n). In mathematical terms,
there exists a constant (c) and an input size (n_0) such that (0 \leq g(n) \leq c \cdot n^2) for all (n \
geq n_0).

Big O notation is a valuable tool for analyzing and comparing the efficiency of algorithms, as it
allows us to express their performance characteristics in a concise and standardized way.

Examples of Big O Notation


O(1) - Constant Time Complexity
• Description: Algorithms with constant time complexity have a fixed number of
operations, regardless of input size.
• Example: Accessing an element in an array by index is (O(1)).
def access_element(arr, index):
return arr[index]

O(log n) - Logarithmic Time Complexity


• Description: Algorithms with (O(log n)) time complexity have a runtime that grows in a
logarithmic relationship with the size of the input. They often involve dividing the input
into smaller portions in each step.
• Example: Binary Search is an (O(log n)) algorithm used to find an element in a sorted
array.
list1 = [1 , 2 ,5 ,6 , 8 ,9 , 9, 11, 13]

target_value = 4

def binary_search(arr, target):

left, right = 0, len(arr) - 1

while left <= right:

mid = left + (right-left) // 2

if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1 # Element not found

binary_search(list1,target_value)

-1

Binary Search and its Logarithmic Time Complexity


Binary Search is an efficient algorithm used to find a specific element in a sorted array. It exhibits
a time complexity of (O(log n)), where (n) represents the size of the input array.

How Binary Search Works


1. Divide and Conquer: Binary Search employs a divide-and-conquer strategy. It
repeatedly divides the search range in half, reducing the number of elements to
consider with each iteration.

2. Comparison with Midpoint: At each step, Binary Search compares the target
element with the middle element of the current search range. If they match, the
search is successful. Otherwise, the search range is halved based on whether the
target is greater or smaller than the middle element.

3. Iterative Process: This process continues iteratively, narrowing down the search
range until the target element is found or until the search range becomes empty.
Logarithmic Time Complexity

The key to Binary Search's efficiency is its halving of the search range in each step. This behavior
leads to a logarithmic time complexity.

• In the worst case, Binary Search can find an element in a sorted array of size (n) in
approximately (log_2 n) steps.
• This logarithmic behavior means that even for significantly large arrays, Binary Search
can quickly pinpoint the desired element.

Example

Consider a sorted array of 1024 elements. Binary Search would typically take at most 10 steps to
find the target element, regardless of the array's size. As the array size doubles, the number of
steps increases by just one, showcasing the logarithmic relationship.

In summary, Binary Search's ability to eliminate half of the remaining search space in each
iteration results in its impressive logarithmic time complexity, making it an efficient choice for
searching in sorted collections.

Lets plot this algorithm's execution time vs various input sizes and plot it to see the logarithmic
behavior:
import timeit
import matplotlib.pyplot as plt
import random

def binary_search(arr, target):


left, right = 0, len(arr) - 1
while left <= right:
mid = left + (right - left) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1 # Element not found

# Function to measure execution time for Binary Search


def measure_execution_time(arr, target):
return timeit.timeit(lambda: binary_search(arr, target), number=1)

# Test different input sizes and measure execution time


input_sizes = [1, 10, 100, 1000, 10000, 100000, 1000000]
execution_times = []

for size in input_sizes:


data = sorted([random.randint(1, 1000) for _ in range(size)])
target = random.randint(1, 1000) # Random target value
execution_time = measure_execution_time(data, target)
execution_times.append(execution_time)

# Plot the results


plt.figure(figsize=(8, 6))
plt.plot(input_sizes, execution_times, marker='o')
plt.title("Execution Time vs. Input Size (Binary Search)")
plt.xlabel("Input Size")
plt.ylabel("Execution Time (seconds)")
plt.grid(True)
plt.show()
Linear Time Complexity (O(n))
Linear time complexity ((O(n))) is a characteristic of algorithms whose runtime grows linearly
with the size of the input ((n)). This means that as the input size increases, the time required for
the algorithm to complete also increases linearly.

Key Features of Linear Time Complexity


1. Proportional Growth: In linear time complexity, the number of operations or
iterations performed by the algorithm is directly proportional to the size of the
input data. As the input size doubles, the number of operations also doubles.

2. Simple Iteration: Many algorithms with linear complexity involve simple iterations
through the input data, examining each element once.

3. Examples: Linear search, traversal of an array or list, and summation of elements in


a collection are common tasks with linear time complexity.
Example: Linear Search

One of the most straightforward examples of linear time complexity is the linear search
algorithm:

def linear_search(arr, target):


for i in range(len(arr)):
if arr[i] == target:
return i # Target found
return -1 # Target not found

Polynomial Time Complexity (O(n^k))


Polynomial time complexity ((O(n^k))) is a classification of algorithms whose runtime grows as a
polynomial function of the input size ((n)). The exponent (k) represents the degree of the
polynomial, and it determines how quickly the runtime increases as the input size grows.

Key Features of Polynomial Time Complexity


1. Polynomial Growth: In polynomial time complexity, the runtime of the algorithm
increases as a polynomial function of the input size. The degree (k) of the
polynomial determines the rate of growth.

2. Degree (k): The degree (k) indicates the highest power of (n) in the polynomial
function. For example, (O(n^2)) represents quadratic time complexity.

3. Examples: Sorting algorithms like Bubble Sort ((O(n^2))) and algorithms that
involve nested loops often exhibit polynomial time complexity.

Example: Quadratic Time Complexity


One common example of polynomial time complexity is the quadratic time complexity
((O(n^2))):

def quadratic_algorithm(arr):
n = len(arr)
sum = 0
for i in range(n):
for j in range(n):
sum += arr[i]*arr[j]
return sum

quadratic_algorithm([1,2,3,4])

100
O(n log n) - Linearithmic Time Complexity
• Description: Algorithms with (O(n \log n)) time complexity have a runtime that grows in
a logarithmic-linear relationship with input size. They often involve dividing the input
into smaller portions and performing operations on those portions.
• Example: Merge Sort is an (O(n \log n)) sorting algorithm that divides the array into
smaller sub-arrays, recursively sorts them, and then merges them back together.

```python def merge_sort(arr): # Implementation

def quicksort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quicksort(left) + middle + quicksort(right)

# Example usage:
arr = [38, 27, 43, 3, 9, 82, 10]
sorted_arr = quicksort(arr)
print("Sorted array:", sorted_arr)

Sorted array: [3, 9, 10, 27, 38, 43, 82]

Explanation of Quick Sort's Time Complexity (O(n log n))


The provided code implements the Quick Sort algorithm, a popular and efficient sorting
algorithm. Quick Sort is known for its average-case time complexity of (O(n \log n)). Let's break
down why this is the case:

Key Steps in Quick Sort


1. Partitioning: Quick Sort selects a pivot element from the array and rearranges the
elements such that elements smaller than the pivot are on the left, and elements
greater than the pivot are on the right. This partitioning step is linear in time,
typically (O(n)).

2. Recursion: After partitioning, Quick Sort recursively sorts the subarrays on the left
and right of the pivot. This recursion continues until the base case is reached, where
subarrays have only one or zero elements.
Average-Case Analysis

In the average case, Quick Sort tends to select a pivot that roughly divides the array into two
equal-sized subarrays. This balanced partitioning results in a roughly balanced recursive tree.

• At each level of the recursion, the algorithm performs linear work during the partitioning
step ((O(n))).
• The recursion tree has a depth of (\log n) levels because the array size is halved in each
recursive call.
As a result, the average-case time complexity of Quick Sort is (O(n \log n)).

Best and Worst Cases

While the average-case time complexity is (O(n \log n)), it's worth noting that in the best-case
scenario (perfectly balanced partitioning), Quick Sort can achieve a time complexity of (O(n \log
n)) as well. However, in the worst-case scenario (unbalanced partitioning, e.g., already sorted
input), Quick Sort can degrade to (O(n^2)).

Explanation of Exponential Time Complexity (O(2^n))


Exponential time complexity, represented as (O(2^n)), is a classification of algorithms whose
runtime grows exponentially with the input size ((n)). This means that the number of operations
or iterations increases exponentially as the input size grows.

Key Characteristics of Exponential Time Complexity


1. Exponential Growth: In exponential time complexity, the runtime of the algorithm
grows exponentially as a power of 2 in relation to the input size. It signifies rapid
and unsustainable growth.

2. Examples: Algorithms with nested recursive calls that generate binary trees, such
as the recursive calculation of the Fibonacci sequence, often exhibit exponential
time complexity.
Example: Recursive Fibonacci

A classic example of an algorithm with exponential time complexity is the recursive calculation
of the Fibonacci sequence:

def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)

%%timeit
fibonacci(5)

330 ns ± 6.94 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops
each)

%%timeit
fibonacci(10)

4.18 µs ± 112 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops
each)

%%timeit
fibonacci(20)
487 µs ± 9.89 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops
each)

%%timeit
fibonacci(30)

59.1 ms ± 844 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

3620/306 , 458/3.62 , 56600/458

(11.830065359477125, 126.51933701657458, 123.58078602620087)

https://www.bigocheatsheet.com/

from IPython.display import Image

# Display the image


Image(filename='big-o-cheat-sheet-poster.png')

You might also like