Extra Class 1
Extra Class 1
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.
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.
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.
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 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}")
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.
class Book:
total_books = 0 # Class variable
@classmethod
def total(cls):
return f"Total books created: {cls.total_books}"
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
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.
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.
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}")
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.
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
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.
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")
1984
Title cannot be empty
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.
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"
# Example usage
car = create_vehicle("Car", "Toyota", "Corolla", 2020)
display_vehicle_info(car)
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}"
<class '__main__.Vehicle'>
<class '__main__.Car'>
<class '__main__.Truck'>
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.
Here's an example implementation of the __add__ method in a Point class, which represents
a point in 2D space:
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
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
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.
target_value = 4
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
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
2. Simple Iteration: Many algorithms with linear complexity involve simple iterations
through the input data, examining each element once.
One of the most straightforward examples of linear time complexity is the linear search
algorithm:
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.
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.
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)
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)).
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)).
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)
https://www.bigocheatsheet.com/