Introduction

Dear Software Engineering Students,

Welcome to the CSF101 Class Notes Repository!

This repository serves as a comprehensive collection of all the class notes, exercises, and instructions for the CSF101 course. Whether you're a student looking to review previous lessons you'll find everything you need right here.

All the class notes are organized by topic, making it easy for you to navigate through the material. The course is divided into several units, each covering a different aspect of computer science and programming.

You will be tasked to complete all of the exercises provided in the class notes to reinforce your understanding of the concepts covered in the course and push to your github repo

  • These exercises are designed to help you practice and apply what you've learned in a hands-on manner.

Feel free to explore the various folders and files within this repository to access the class notes for each topic covered in the course. Each set of notes is accompanied by exercises and clear instructions to help you reinforce your understanding of the material.

We hope that this repository will serve as a valuable resource throughout your CSF101 journey.

Happy learning!

CSF101 - Programming Methodology (Module Descriptor)

Programme: BE in Software Engineering
Credit: 12
Module Tutor: Sonam Yangchen
Module Coordinator: Douglas Sim

General Objective

This module introduces students to the fundamental concepts of programming and computational thinking. Starting with the foundations of computing and digital logic, the module progresses through programming fundamentals, data structures, and algorithms. Students will gain a comprehensive understanding of how computers process information, how to implement simple algorithms, and the process of formulating solutions using pseudocode and flowcharts.

Learning outcomes

On completion of this module, the students will be able to:

  1. Explain fundamental concepts in computing, including binary number systems, boolean logic, and basic computer architecture components.
  2. Convert between decimal and binary number systems, and perform basic binary operations.
  3. Analyze and design algorithms using pseudocode and flowcharts to solve computational problems.
  4. Implement basic programming constructs such as variables, operators, conditional statements, loops, and functions.
  5. Apply file operation methods to read from and write to files.
  6. Implement abstract data structures such as trees, graphs, stacks, queues, and linked lists using arrays.
  7. Implement and compare the efficiency of different searching and sorting algorithms on abstract data structures.
  8. Analyze the time and space complexity of algorithms using Big O notation.
  9. Apply graph traversal algorithms to solve pathfinding problems.
  10. Implement dynamic programming solutions for optimization problems.

Learning and teaching approach

TypeApproachHours/weekCredit hours
ContactLecture230
Practical230
Independent StudyAssignment230
Self-study230
Total8120

Assessment Approach

Assessment components consist of Continuous Assessment (CA) Theory - 60% and Continuous Assessment (CA) Practical - 40%. The CA Theory will consist of a Mid-Term test (15%), Assignment (30%), Quiz (15%), and CA Practical consists of Whiteboarding.

A. Mid-term Test: (15%)

Students will take a closed book written exam of a 2-hour duration covering subject matter from Unit-I to Unit-III. The exam will be marked out of 15 marks.

B. Programming Assignment: (30%)

The students will be given two programming assignments during the 7th and 12th week. Each assignment will be for 15 marks.

Programming Assignment I

Solve a programming problem using programming constructs, simple data structures, and algorithms. The assignment will be evaluated according to the following criteria:

  • 2 Handling of input file
  • 5 Time & Space Complexity Analysis
  • 3 Code quality and readability
  • 5 Implementation of appropriate data structures and algorithms

Programming Assignment II

Solve a programming problem using the concepts of dynamic programming:

  • 2 Handling of input file
  • 5 Time & Space Complexity Analysis
  • 3 Code quality and readability
  • 5 Implementation of appropriate data structures and algorithms

C. Quiz: (10%)

The students will be required to attempt two closed-book quizzes. Quiz one will test the student's understanding of the subject matter from Unit III and Quiz two will test the student's understanding of the subject matter from Unit IV. Each quiz will be conducted out of 10 marks each for one hour.

D. Practical Work & Report: (10%)

On completion of each weekly practical, a student is required to submit a weekly lab report with an executable file of the practical before the start of the subsequent practical class on a GitHub repository. The report will be written in markdown and submitted along with the executable file for which the format and instructions will be provided by the module tutor. Each report will be evaluated as per the following criteria:

  • 3 Executability of the submission file without errors
  • 2 Compliance with the instruction document
  • 2 Solution approach
  • 2 Use of adequate data structure and algorithm
  • 1 Timely submission

E. Whiteboarding: (35%)

Students will participate in a whiteboarding assessment that consists of two parts: a Written Test and a Viva Voce. This examination will assess the student's ability to solve programming problems, explain their thought process, and optimize their solutions. The examination will be conducted in the 15th Week.

A. Written Test: (10%)

Each student will take a closed-book practical test for 1 hour where the students solve a specific programming problem. The test will be assessed based on the following criteria:

  • 5 Pseudocode
  • 5 Flowchart

B. Viva Voce: (25%)

Following the Practical Test, students will participate in a whiteboarding exercise where they will explain their solution approach for the question they solved in the practical test. They will also be asked to justify their solution methods and discuss potential optimizations. The assessment criteria for the Viva Voce are as follows:

  • 3 Problem Comprehension
  • 8 Technical Implementation
  • 6 Solution Approach
  • 4 Space/Time Analysis
  • 4 Optimizations

Overview of the assessment approaches and weighting

Areas of AssessmentQuantityWeighting (%)
A. Mid-term Test115
B. Programming Assignment230
C. Quiz210
D. Practical Work & Report910
E. Whiteboarding135
Total15100

Pre-requisites

None

Subject matter

Unit I: Foundations of computing and digital logic

1.1 Introduction to computing
1.2 Information representation
   1.2.1 Bits and binary numbers
   1.2.2 Binary, octal, and hexadecimal number systems
   1.2.3 Two's complement representation
   1.2.4 Binary operations (addition and subtraction)
   1.2.5 Half adders and full adders
   1.2.6 Multi-bit addition
1.3 Character encoding
   1.3.1 ASCII standard
   1.3.2 Unicode and UTF-8
1.4 Boolean logic and logic gates
   1.4.1 Basic logic gates: AND, OR, NOT
   1.4.2 Composite logic gates: NAND, XOR
1.5 Introduction to Computer Architecture
   1.5.1 Von Neumann Architecture
   1.5.2 Harvard Architecture

Unit II: Programming Fundamentals

2.1 Evolution of programming languages
   2.1.1 Machine language, assembly language, and high-level languages
   2.1.2 Paradigms: imperative, declarative and functional
2.2 Compilers and interpreters
2.3 Computational problem solving
   2.3.1 Pseudocode
   2.3.2 Flowcharts
   2.3.3 Stepwise refinement and top-down design
2.4 Variables and Data Types
   2.4.1 Primitive data types
   2.4.2 Composite/Compound data types
   2.4.3 Type conversion and type casting
2.5 Operators
   2.5.1 Arithmetic operators
   2.5.2 Unary operators
   2.5.3 Assignment operators
   2.5.4 Comparative operators
   2.5.5 Logical operators
   2.5.6 Bitwise operators
   2.5.7 Conditional operators
2.6 Control structures
   2.6.1 Conditional statements (if-else, switch-case)
   2.6.2 Loops (for, while)
   2.6.3 Break and continue statements
2.7 Functions and Scope
   2.7.1 Function definition and invocation
   2.7.2 Parameters and return values
   2.7.3 Local and global scope
   2.7.4 Function Call Stack & Recursion
2.8 Memory Addresses & Pointers
2.9 File operations
   2.9.1 File input/output
   2.9.2 Text and binary file handling

Unit III: Data Structures

3.1 Introduction to storing data
   3.1.1 Static vs dynamic data structures
3.2 Elementary data structures
   3.2.1 Array and its properties
   3.2.2 Array operations and methods
   3.2.3 Multi-dimensional arrays
3.3 Linear Data Types
   3.3.1 List Abstract Data Structure (ADT)
   3.3.2 Stack, Queue, and Deque ADTs in their Linked List Implementations
   3.3.3 Stack and Queue in their (resize-able/circular) Array implementations
3.4 Linked structures
   3.4.1 Singly linked lists
   3.4.2 Doubly linked lists
   3.4.3 Circular linked lists
3.5 Tree data structures
   3.5.1 Binary trees and their properties
   3.5.2 Binary search trees (BST)
      3.5.2.1 BST properties and structure
      3.5.2.2 BST operations: insert, delete, search
      3.5.2.3 Tree traversals: in-order, pre-order, post-order
3.6 Graph data structures
   3.6.1 Graph representations: adjacency matrix and adjacency list
   3.6.2 Weighted and unweighted graphs
   3.6.3 Directed and undirected graphs
3.7 Table/Map ADT Concept
   3.7.1 Definition and Operations
   3.7.2 Unordered map characteristics
   3.7.3 Applications of unordered maps
3.8 Hash Table Implementation
   3.8.1 Hash function design
   3.8.2 Collision resolution: chaining
   3.8.3 Collision resolution: open addressing
   3.8.4 Dynamic resizing and rehashing

Unit IV: Searching & Sorting Algorithms

4.1 Linear Search
   4.1.1 Linear Search algorithm and implementation
   4.1.2 Time and space complexity
4.2 Binary Search
4.3 Depth-First Search (DFS)
   4.3.1 DFS algorithm and implementation
   4.3.2 DFS tree and edge classification
   4.3.3 Time and space complexity
   4.3.4 Applications: cycle detection, topological sorting
4.4 Breadth-First Search (BFS)
   4.4.1 BFS algorithm and implementation
   4.4.2 BFS tree and level order traversal
   4.4.3 Time and space complexity
4.5 Insertion Sort
4.6 Quick Sort
4.7 Bubble Sort

Unit V: Introduction to Computational Problems & Strategies

5.1 Space & Time Complexity, Asymptotic Notation
   5.1.1 Understanding space complexity
   5.1.2 Understanding time complexity
   5.1.3 Big O notation
   5.1.4 Omega and Theta notations
   5.1.5 Analyzing algorithm efficiency
   5.1.6 Comparing algorithm efficiencies
5.2 Arrays & Hashing
   5.2.1 Contains Duplicate problem
   5.2.2 Valid Anagram problem
   5.2.3 Two Sums problem
   5.2.4 Hash table implementations
   5.2.5 Collision resolution techniques
5.3 Two Pointers
   5.3.1 Valid Palindrome problem
   5.3.2 Three Sums problem
   5.3.3 Two pointers technique for array manipulation
   5.3.4 Applications in string problems
5.4 Sliding Window
   5.4.1 Best Time to Buy And Sell Stock problem
   5.4.2 Longest Substring Without Repeating Characters problem
   5.4.3 Fixed-size sliding window technique
   5.4.4 Variable-size sliding window technique
5.5 Stacks & Queues
   5.5.1 Valid Parentheses problem
   5.5.2 Implement Stack using Queue(s) problem
   5.5.3 Stack implementations and applications
   5.5.4 Queue implementations and applications
5.6 Linked List
   5.6.1 Reverse Linked List problem
   5.6.2 Merge Two Sorted Lists problem
   5.6.3 Remove Nth Node From End of List problem
   5.6.4 Singly and doubly linked list operations
5.7 Recursions
   5.7.1 Climbing Stairs problem
   5.7.2 Tower of Hanoi problem
   5.7.3 Understanding recursive algorithms
   5.7.4 Tail recursion optimization
5.8 Bit Manipulation
   5.8.1 Number of 1 Bits problem
   5.8.2 Counting Bits problem
   5.8.3 Reverse Bits problem
   5.8.4 Missing Numbers problem
   5.8.5 Bitwise operations and their applications

List of Practical(s)

  1. Implement a decimal-to-binary converter and basic logic gate simulator
  2. Create a text file analyzer that calculates various statistics using control structures
  3. Implement recursive and iterative Fibonacci sequence generators
  4. Implement linear and binary search algorithms
  5. Implement stack and queue data structures and use them to solve practical problems
  6. Create a singly linked list with basic operations and list manipulation functions
  7. Implement a binary search tree with insertion, deletion, search, and traversal operations
  8. Implement bubble, merge, and quick sort algorithms
  9. Create a graph data structure and implement basic graph traversal algorithms

Reading list

Essential Reading

  • Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2022). Introduction to Algorithms, fourth edition. MIT Press.
  • Abelson, H. (1996). Structure and interpretation of computer programs. Mit Press.
  • Bryant, R. E., & O'Hallaron, D. R. (2015). Computer systems: A Programmer's Perspective, Global Edition.

Additional Reading

  • Simpson, K. (2020). You don't know JS yet: Get Started.
  • Haverbeke, M. (2018). Eloquent JavaScript, 3rd Edition: A Modern Introduction to Programming. No Starch Press.

Date: August 2024

Git & GitHub for Beginners

Introduction to Git

Git is a distributed version control system that helps developers track changes in their code, collaborate with others, and manage different versions of their projects. It's an essential tool for modern software development, especially when working in teams or on open-source projects.

Setting Up Git

Before you start using Git, you need to install it and configure your identity:

  1. Linux users can install Git using their package manager (e.g., sudo apt install git)
  2. Windows users can download git using choco install git.
  3. Mac users can install Git using brew install git
  4. Open a terminal or command prompt
  5. Set your name and email (used for commit messages):
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"

Creating a repository in GitHub

GitHub Tutorial Link

Github Creating Repo

Basic Git Commands

Initializing a Repository

To start tracking a project with Git, navigate to your project directory and run:

git init

This creates a new Git repository in your current directory.

Checking Repository Status

To see the current state of your repository:

git status

This command shows which files have been modified, staged, or are untracked.

Adding Files to the Staging Area

To prepare files for commit, you need to add them to the staging area:

# Add a specific file
git add filename.txt

# Add all files in the current directory
git add .

# Add all files with a specific extension
git add *.js

Committing Changes

To save your staged changes to the repository:

git commit -m "Your commit message here"

For a more detailed commit message, omit the -m flag:

git commit

This will open your default text editor where you can write a multi-line commit message.

Viewing Commit History

To see the history of commits:

# View all commits
git log

# View a condensed version of the log
git log --oneline

# View the last 5 commits
git log -n 5

Working with Remote Repositories (GitHub)

Cloning a Repository

To create a local copy of a remote repository:

git clone https://github.com/username/repository.git

Adding a Remote Repository

If you've created a local repository and want to link it to a GitHub repository:

git remote add origin https://github.com/username/repository.git

Pushing Changes to GitHub

To send your local commits to the remote repository:

# First time push (sets up tracking)
git push -u origin main

# Subsequent pushes
git push

Pulling Changes from GitHub

To fetch and merge changes from the remote repository:

git pull

This is equivalent to running git fetch followed by git merge.

Branching

Branches allow you to work on different features or versions of your project simultaneously.

Creating a New Branch

git branch new-feature

Switching to a Branch

git checkout new-feature

Or, create and switch to a new branch in one command:

git checkout -b another-feature

Merging Branches

To merge changes from one branch into another:

# First, switch to the branch you want to merge into
git checkout main

# Then merge the feature branch
git merge new-feature

Handling Merge Conflicts

Sometimes Git can't automatically merge changes, and you'll need to resolve conflicts manually:

  1. Open the conflicting file(s) in your text editor
  2. Look for the conflict markers (<<<<<<<, =======, >>>>>>>)
  3. Edit the file to resolve the conflict
  4. Save the file
  5. Stage the resolved file: git add filename.txt
  6. Complete the merge by committing: git commit -m "Resolved merge conflict"

Undoing Changes

Discard Changes in Working Directory

git checkout -- filename.txt

Unstage a File

git reset HEAD filename.txt

Amend the Last Commit

git commit --amend

This opens your editor to modify the commit message. If you've staged changes, they'll be added to the amended commit.

Best Practices

  1. Commit often: Make small, focused commits that are easy to understand and review.
  2. Write clear commit messages: Briefly describe what changes were made and why.
  3. Use branches for new features or experiments.
  4. Pull changes from the remote repository before starting new work to avoid conflicts.
  5. Review your changes before committing: Use git diff to see what you've modified.

Online Resources for Further Exploration:

Introduction To Computing

The word computer derives from the word compute which means to calculate. The history of computers which are devices used to calculate and do simple arithmetic operation dates all the way back to ancient China where abacus was used to perform basic mathematical operations fast and efficicently. They were also often used to store the results of such operations.

A model of one of the world's first computers (the Difference Engine invented by Charles Babbage) at the Computer History Museum in Mountain View, California, USA

History

While the evolutionary journey of computing is fascinating, its concerns lie beyond the scope of the syllabus, but feel free to read more about it here. What cocnerns us most now is the invention of Mark I in 1944 by Dr. Howard Aiken and Grace Hopper. This is said to be a crucial landmark for the world of computing as Mark I was considered to be worlds first electormechanical computer capable of making logical decisions.

Today

Our second point of concern is invention of transistors in 1948 at Bell Labs which forever changed the course of computers and modern day electronics. Ever since these signifcant milestones, computers have become smaller and faster as described by the Moores Law.Today, we use hand held devices with the computing power which is atleast 100,000 times more than what was used to send the Apollo 11 mission to the moon.

Tomorrow

But what is in store for us tomorrow? With the growth of AI industry and improvements in GPU compute, we are pushing the limits of computing as well as data to an unimaginable scale. With the potential of quantum computers still in the horizon the future of computers sure is promising. I hope that throughout the course of this module, we are able to give you a good feel of what computers are, what they are capable of and how you can use them to your advantage for years to come.

Note: Dont be concerned if the terms and concepts discussed in these articles seem concerning, we will discuss the key concepts in class.

Information Representation

An over simplified definition of a computer would be a device that can recieve input, process this input to do some computation, store necesarry data, and retrieve this data to present an output when needed. We will discuss more about various parts of a computer and their unqiue functions in the later part of the chapter, but for now we, will take a bottom-up approach of learning the fundamental components that makes up a computer.

Computer

For this topic, we are to understand the following key concepts:

  • How does a computer store data?
  • Where does a computer store data?
  • What format does a computer store data in?
  • How does a computer perform basic airthmetic operations with this stored information?

1.2.1 Bits and Binary numbers.

Computers are made up of millions of small electronic devices. These devices are responsible for representing information, performing calculations and storing information and while these functions seem distinctly different from each other, the parts that perform these functions are all made up of the same fundamental electronic components in different configurations.

Bits

Imagine a light bulb that is connected to a single switch. When the switch is turned on the bulb lights up and when it is turned off the bulb turns off. A lightbulb in this case can represent two states of either being on or off. We can then go ahead and label the off state of a light bulb to be 0 and the on state to be 1. A bulb can only have two possible states in this analogy and we can proceed to describe the nature of states of a light bulb to be binary.

bulbs analogy

Similar to this imaginary light bulb, computer are made up of millions of parts that function similar to the aforementioned switches, where a bit may represent 0 or 1. Note that one bit can represent only two states, however if two bits are placed side by the side, they can represent 4 states in combination as shown in the graphic below.

one bit

Fig: one bit represents two states

two

Fig: two bits represents four states

Similarly many bits can be used to represent many states, note that if the number of bits used is equal to n, the total number of states a combination of n bits can represent can be calculcated as 2n(where n>=1 and cannot be 0, negative or a fraction).

Binary Number System

This positional number system which represents numbers using 0's and 1's is known as the binary number system.The following table highlights number of bits and its relation to number of states the bits can represent:

No of bitsNo of States
12
24
38
416
532
664
7128
8256

Storage

In practise, when storing information the term byte is often used which represents a total of 256 states (23) which can be represented by a combination of 8 bits. Meaning 1 byte consists of 256 possible states and one byte of information represets one such state, Similarly other units of measurements are as stated in the table below:

Units of measurementExperession in powers of 2
Byte23
Kibibyte(KiB)210
Mebibyte(MiB)220
Gigibyte(GiB)230
Tebibyte(TiB)240
Pebibyte(PiB)250

There is a variation in how size of data is representation in terms of binary and decimal number systems. For marketing and generic purposes, the terms such as Kilobytes, Megabytes and Gigabytes are used more often. You can read more about his in the official IBM article here.

1.2.2 Binary, Octal, and Hexadecimal, Number systems

A number can be represented with different base values. We are familiar with the numbers in the base 10 (known as decimal numbers), with digits taking values 0,1,2,…,8,9

A computer uses a Binary number system which has a base 2 and digits can have only TWO values: 0 and 1.

A decimal number with a few digits can be expressed in binary form using a large number of digits. Thus the number 65 can be expressed in binary form as 1000001.

The binary form can be expressed more compactly by grouping 3 binary digits together to form an octal number. An octal number with base 8 makes use of the EIGHT digits 0,1,2,3,4,5,6 and 7

A more compact representation is used by Hexadecimal representation which groups 4 binary digits together. It can make use of 16 digits, but since we have only 10 digits, the remaining 6 digits are made up of first 6 letters of the alphabet. Thus the hexadecimal base uses 0,1,2,….8,9,A,B,C,D,E,F as digits.

The table below shows how different number represents different number:

DecimalBinaryOctalHexadecimal
0000000
1000111
2001022
3001133
4010044
5010155
6011066
7011177
81000108
91001119
10101012A
11101113B
12110014C
13110115D
14111016E
15111117F

1.2.2 Twos complement representation

The twos complement method is a common way to represent signed integers in computers. It is also often used to simplify binary operations as its used to represent negative numbers in binary. The steps for twos complement method are as follows:

  • Step 1: Start with the absolute binary representation of a number
  • Step 2: Ivert all bits (change 0's to 1s and vice vaersa)
  • Step 3: Add 1 to this representation

The way twos complement method can be used to represent negative integers as explained in this handout.

1.2.3 Binary operations

Similar to how we perform arithmetic operations such as addition and subtraction in decimal number system, we can do the same for binary numbers. For binary addition, the binary representation of both the numbers are to be placed in a cascade such that both binary representations have equal number of digits. Once this is done the digits can be compared from the position of least significant bit(right most) with the following rules:

  • Rule 1: 0 + 0 = 0
  • Rule 2: 0 + 0 = 1
  • Rule 3: 1 + 0 = 1
  • Rule 4: 1 + 1 = 0 (carry)

Similarly for binary subtraction:

  • Rule 1: 0 - 0 = 0
  • Rule 2: 1 - 0 = 1
  • Rule 3: 0 - 1 = 1 (borrow)
  • Rule 4: 1 - 1 = 0

Note that multiplication and division are just repeated addition and subtraction respectively. Examples of binary addition and subtraction operations are available in the following video.

While the above mentioned rules for binary subtraction is applicable, computers compute differences using the two's complements method. Therefore for binary subtraction using the two's complement method:

  • Step 1: take the binary representation of two numbers to be subtracted.
  • Step 2: identify the two's complement representation of the subtrahend
  • Step 3: add the binary representation of the initial number to the two's complement representation of the subtrahend.
  • Step 4: discard the most signifcant bit of the solution

Essential resources:

  1. Half adders and full adder : LINK
  2. Binary adder implementation using full adders : LINK
  3. Binary subtractor implementation using full adders : LINK

Additional Resources

  1. Binary multiplier circuits : LINK
  2. How a computer memory works : LINK

Character Encoding

1.3.1 The ASCII standard

With the understanding of binary and how it can be used to represent many states in combination, it is now important to understand how binary represents different characters in a computer. We can group characters into three main groups i.e. alphabetic characters (a z and A to Z), numeric characters (0-9) and special characters ($,%,#,@ etc).

ASCII is an international standard which stands for American Standard Code for Information Interchange, which is a character encoding standard for almost all electronic communication. The chart below provides a visualization of how these characters are represented in binary and what is the equivalent decimal representation for each unique character.

ASCII chart (1972)

The standard ASCII characters which adds up to 126 unique characters can be represented using 7 bits. However an extended version of the ASCII table called the extended ASCII can represent up to 256 characters using 8 bits.

Excercise: Use the standard ASCII table to write your full name.

1.3.2 Unicode and UTF8 encoding

While the ASCII table was enough to represent basic alphanumeric characters and few special characters, the computers today have to store other characters such as emojis and characters from different languages. This is not simple as there are many languages with different structures, some languages use alphabets, vowels, and consonants while some langauges use strokes and other form of expression to represent different characters.

Unicode is one such standard that tries to solve the problem of storing and representing different characters in a computer. While each character is mapped to a bit representation, unicode uses codepoints where different characters are mapped to uniique hexadecimal digita. This representation is made as U+XXXX where U+ means unicode and the XXXX represents hexadecimal numbers.

The UTF-8 encoding scheme was later developed to ensure that no extra bit space is consumed while representing characters. UTF-8 representation of characters for english is very similar to that of the ASCII table where it only uses one byte(8 bits) or two hexadecimals to represent characters. However, however for more than 127 characters, several bytes are used to represent the characters. You can read more about this in the following articles and official documentation:

  1. Unicode
  2. UTF-8
  3. General overview of character encoding

1.4 Boolean logic and Logic gates

1.4.1 Basic logic gates

The expression of values in binary which uses 0's and 1's can also be used to experess simple logic such as true or false. Computers first used the binary number system as there was an entire branch of mathematics that dealt with representaition and manipulation of true and falase values known as the boolean algebra. The mathematical analysis of logic book written by George Boole in 1847 states that boolean algebra allows for truth to be systematically and formallly proven through logic equations.

There are three fundamental operations used in boolean algebra known as the AND, OR and the NOT operation. Note that these operations were later simulated into electrical components using transistors and are called logic gates which follow their respective properties as expressed in boolean algebra. A detailed video on this concept can be found here.

Transistors are electrically controlled switches whereby, when an adequate amount of electricity flows through one of the input electrode(base), the transistors allows electricity to flow through the other two electrodes(collector to emitter) as shown in the graphic below.

A simple transistor

We can represent various logic gates using truth tables. The AND and OR gates can be represented as shown below:

Truth tables for AND and OR gates

A NOT gate takes in one input and flips it to its reverse state therefore its truth table is as shown below:

NOT gate

1.2.2 Composite logic gate

There are other logic gates that prove to be very useful in computers when applying more complex computations. One such example is use of logic gates to perform binary addition and subtraction. The NAND gate in particular is very important as it is also often termed as the universal gate as it can be used to constructure AND, OR and NOT gates when placed in a certain configuration.

Similarly the XOR gate is important as it is used for arithmetic computation. This can be observed in the networking module where XOR logic is used to do error checking and binary addition. There truth tables and symbols are as follows:

1.5 Introduction to computer architecutre

Over the course of many generations in advancement of computers and technology, there has been various improvements with regard to different parts of a computer. A brief history of these generation can be found in this article.

The modern day computers have very distinct parts with specialized functions. At a high level, a computer can be escribed using the following diagram:

Block diagram of a computer

Different parts of a computer and its functions

  1. Central Processing Unit (CPU)
  • Executes instructions and performs calculations
  • Key components:
    • Control Unit: Manages and coordinates CPU operations
    • Arithmetic Logic Unit (ALU): Performs arithmetic and logical operations
    • Registers: Small, fast storage locations within the CPU
  1. Memory
  • A physical device that stores information temporarily or permanently.
  • Provides quick access to data and instructions for the CPU, act as a speed buffer, serve as an active workspace, and hold temporary data
  • The two main types include:
    • Random Access Memory (RAM):
      • Volatile memory used for temporary data storage
      • Faster access times compared to storage devices
    • Read-Only Memory (ROM):
      • Non-volatile memory containing essential instructions (e.g., BIOS)
  1. Storage devices
  • These devices stores data into devices such as drives or disks.
  • Solid state drives (SSD) and Hard disk drives(HDD) are most commonly used.
  1. Input/Output devices (I/O)
  • Input: Keyboard, mouse, microphone, camera
  • Output: Monitor, speakers, printer
  1. Bus systems
  • Provides connection and enables communication between different parts of a computer
  • Data Bus: Transfers data between components
  • Address Bus: Carries memory addresses
  • Control Bus: Carries control signal

The Fetch-Decode-Execute Cycle

The fetch-decode-execute cycle (also known as the instruction cycle) is the basic operational process of a computer. It's the sequence of steps that the CPU follows to process each instruction in a program.

  1. Fetch
  • The CPU retrieves (fetches) an instruction from memory.
  • This instruction is stored in a special register called the Instruction Register (IR).
  • The Program Counter (PC) keeps track of which instruction to fetch next.
  1. Decode
  • The CPU interprets (decodes) the instruction.
  • It figures out what operation needs to be performed.
  • For example, it might be an addition, a memory access, or a jump to another part of the program.
  1. Execute
  • The CPU carries out (executes) the instruction.
  • This might involve:
    • Performing a calculation
    • Moving data
    • Changing the sequence of instructions (in case of a jump)

1.5.1 Von Neumann Architecture

Both Harvard and von Neumann architectures are fundamental designs for computer systems, each with its own approach to handling program instructions and data. Understanding these architectures helps in grasping how different computer systems implement the fetch-decode-execute cycle.

Key Characteristics:

  • Single memory space for both data and instructions
  • Uses a single bus for both data and instruction transfer

Fetch-Decode-Execute in von Neumann:

  • Fetch: Instructions and data are fetched from the same memory.
  • Decode: The CPU decodes the instruction.
  • Execute: The CPU executes the instruction, potentially accessing the same memory for data.

Advantages:

  • Simpler design
  • Flexible use of memory (can allocate more space to either instructions or data as needed)

Disadvantages:

  • Potential bottleneck due to single bus (known as the von Neumann bottleneck)
  • Instructions and data compete for memory access

1.5.2 Harvard Architecture

Key Characteristics:

  • Separate memory spaces for instructions and data
  • Uses separate buses for instruction and data transfer

Fetch-Decode-Execute in Harvard:

  • Fetch: Instructions are fetched from instruction memory.
  • Decode: The CPU decodes the instruction.
  • Execute: The CPU executes the instruction, accessing data memory if needed.

Advantages:

  • Can fetch next instruction and access data simultaneously
  • Potentially faster execution due to parallel access
  • Better security (can make instruction memory read-only)

Disadvantages:

  • More complex design
  • Fixed allocation of memory between instructions and data

Comparison in Context

  • Memory Access:

    • von Neumann: One memory access per cycle (either instruction or data)
    • Harvard: Can perform instruction fetch and data access in the same cycle
  • Parallelism:

    • von Neumann: Limited by single bus
    • Harvard: Allows for more parallelism in instruction processing
  • Impact on Fetch-Decode-Execute Cycle

    • von Neumann: The cycle may be slowed down when instruction fetch and data access compete for the same memory bus.
    • Harvard: The cycle can potentially be faster as instruction fetch doesn't compete with data access.

Modern Implementations:

Many modern CPUs use a modified Harvard architecture, with separate caches for instructions and data, but a unified main memory (like von Neumann)

Psuedocode

1. Introduction to Pseudocode

Pseudocode is a method of describing algorithms in a structured, readable format that is close to a programming language but is not tied to any specific language syntax.

It allows algorithm designers to express ideas clearly without getting bogged down in language-specific details.

2. Key Principles of Pseudocode

2.1 Clarity and Readability

Pseudocode should be easy to read and understand, even for those not familiar with specific programming languages.

2.2 Precision

While not as strict as actual code, pseudocode should be precise enough to be translated into a programming language without ambiguity.

2.3 Abstraction

Pseudocode allows for a higher level of abstraction than actual code, focusing on the logic rather than implementation details.

3. Common Pseudocode Conventions

3.1 Indentation

Use indentation to show the structure and nesting of control structures.

3.2 Keywords

Use keywords like IF, ELSE, WHILE, FOR, RETURN in uppercase for clarity.

3.3 Comments

Use // for single-line comments and /* */ for multi-line comments.

4. Basic Structures in Pseudocode

4.1 Assignment

x = 5

4.2 Input/Output

READ x
PRINT "The value is:", x

4.3 Conditional Statements

IF condition THEN
    statement1
    statement2
ELSE
    statement3
ENDIF

4.4 Loops

// While Loops
WHILE condition DO
    statement1
    statement2
ENDWHILE

// For Loops
FOR i = 1 TO n
    // statements
ENDFOR

4.5 Functions

FUNCTION FunctionName(parameter1, parameter2)
    // statements
    RETURN value
ENDFUNCTION

5. Example Pseudocode Algorithms

ALGORITHM LinearSearch(A, n, x)
    INPUT: An array A of n elements and a value x
    OUTPUT: Index of x in A or -1 if not found

    FOR i = 0 TO n - 1
        IF A[i] = x THEN
            RETURN i
        ENDIF
    ENDFOR
    RETURN -1
ALGORITHM BinarySearch(A, n, x)
    INPUT: A sorted array A of n elements and a value x
    OUTPUT: Index of x in A or -1 if not found

    left = 0
    right = n - 1
    WHILE left ≤ right DO
        mid - ⌊(left + right) / 2⌋
        IF A[mid] = x THEN
            RETURN mid
        ELSE IF A[mid] < x THEN
            left = mid + 1
        ELSE
            right = mid - 1
        ENDIF
    ENDWHILE
    RETURN -1

5.3 Insertion Sort

ALGORITHM InsertionSort(A, n)
    INPUT: An array A of n elements
    OUTPUT: A sorted in ascending order

    FOR i = 1 TO n - 1
        key = A[i]
        j = i - 1
        WHILE j ≥ 0 AND A[j] > key
            A[j + 1] = A[j]
            j = j - 1
        ENDWHILE
        A[j + 1] = key
    ENDFOR

6. Advanced Pseudocode Techniques

6.1 Recursive Algorithms

Example: Factorial calculation

FUNCTION Factorial(n)
    IF n = 0 THEN
        RETURN 1
    ELSE
        RETURN n * Factorial(n - 1)
    ENDIF

7. Best Practices for Writing Pseudocode

  1. Be consistent in your style and notation.
  2. Use meaningful variable and function names.
  3. Include comments to explain complex logic.
  4. Use appropriate levels of abstraction.
  5. Revise and refine your pseudocode as you develop your algorithm.

Remember, the goal of pseudocode is to communicate algorithmic ideas clearly and effectively.

Flowcharts

Flowchart Symblols

1. Introduction to Flowcharts

Flowcharts are graphical representations of algorithms, workflows, or processes.

They use standardized symbols to illustrate the steps and decision points in a process, making it easier to visualize and understand complex procedures.

2. Key Principles of Flowcharts

2.1 Clarity and Readability

Flowcharts should be easy to follow and understand, even for those not familiar with the specific process being described.

2.2 Consistency

Use standardized symbols and follow consistent conventions throughout the flowchart.

2.3 Simplicity

Break down complex processes into simpler steps, using sub-processes where necessary to maintain clarity.

3. Standard Flowchart Symbols and Their Meanings

3.1 Oval (Terminal)

  • Represents the start or end of a program or process.
  • Typically contains "Start" or "End" text.
  • Indicates the entry and exit points of a flowchart.

3.2 Arrow (Flow Line)

  • Shows the direction of process flow.
  • Connects different elements of the flowchart.
  • Indicates the sequence of operations.

3.3 Parallelogram (Input/Output)

  • Represents input or output operations.
  • Used for displaying data entry or results.
  • Can indicate manual input, printed output, or displayed information.

3.4 Rectangle (Process)

  • Represents a processing step or action.
  • Indicates any operation where data is manipulated or changed.
  • Can represent calculations, data transformations, or function calls.

3.5 Diamond (Decision)

  • Represents a decision point or branching in the process.
  • Contains a question or condition that can be answered with "Yes" or "No" (True or False).
  • Has two outgoing arrows, typically labeled with the possible outcomes.

4. Tools for Creating Flowcharts

Use any of the tools below to create flowcharts for your assignments:

  • Exacalidraw
  • FigJam
  • Microsoft Visio
  • Lucidchart
  • Draw.io
  • SmartDraw
  • Creately

These tools provide templates and drag-and-drop interfaces for creating professional flowcharts.

Exercise

Create Flowcharts and Psuedocode for the following problems:

NOTE:

  • For terms and topics you do not understand: Google to understand what they are.
  • Most of the terms are important computing terms/problems
  • You need to practise Googling

Level 1

  1. Calculate the area of a rectangle given its length and width.
  2. Determine if a number is even or odd.
  3. Find the largest of three given numbers.
  4. Convert temperature from Celsius to Fahrenheit.
  5. Calculate the sum of all numbers from 1 to n.
  6. Check if a given year is a leap year.
  7. Generate the first n terms of the Fibonacci sequence.
  8. Calculate the factorial of a given number.
  9. Determine if a given string is a palindrome.
  10. Find the average of n numbers.
  11. Convert a decimal number to binary.
  12. Check if a number is prime.
  13. Reverse a given string.
  14. Calculate the compound interest for a given principal, rate, and time.
  15. Find the GCD (Greatest Common Divisor) of two numbers.
  16. Convert a given number of days to years, weeks, and days.
  17. Generate a multiplication table for a given number.
  18. Calculate the power of a number (x^n).
  19. Find the smallest element in an array.
  20. Determine if a triangle is equilateral, isosceles, or scalene given its sides.
  21. Calculate the roots of a quadratic equation.
  22. Convert a given number of seconds to hours, minutes, and seconds.
  23. Find the number of vowels in a given string.
  24. Calculate the perimeter of a circle given its radius.
  25. Determine if a number is a perfect square.
  26. Generate all prime numbers up to n.
  27. Calculate the sum of digits of a given number.
  28. Find the LCM (Least Common Multiple) of two numbers.
  29. Check if a given number is a Armstrong number.
  30. Calculate the simple interest for a given principal, rate, and time.

Level 2

  1. Find the sum of all even numbers between 1 and n.
  2. Calculate the area of a triangle given its base and height.
  3. Determine if a number is positive, negative, or zero.
  4. Convert a binary number to decimal.
  5. Find the factors of a given number.
  6. Calculate the volume of a cube given its side length.
  7. Generate a sequence of n random numbers between 1 and 100.
  8. Find the maximum and minimum values in an array.
  9. Calculate the sum of squares of numbers from 1 to n.
  10. Determine if a given character is a vowel or consonant.
  11. Calculate the perimeter of a rectangle given its length and width.
  12. Find the number of occurrences of a specific digit in a given number.
  13. Generate a pattern of asterisks in the shape of a right-angled triangle.
  14. Calculate the average of numbers in an array, excluding the largest and smallest values.
  15. Determine if a given year is a century year.
  16. Find the sum of all odd numbers between two given numbers.
  17. Calculate the distance traveled given initial velocity, acceleration, and time.
  18. Generate the first n terms of an arithmetic sequence given the first term and common difference.
  19. Find the absolute difference between two numbers.
  20. Calculate the area of a circle given its diameter.

Level 3

  1. Implement a basic calculator that can handle multiple operations in one expression. (BEDMAS)
  2. Find the second largest and second smallest elements in an array.
  3. Check if a given password is strong based on criteria: length, inclusion of numbers, special characters.
  4. Calculate the Body Mass Index (BMI) and categorize it (underweight, normal, overweight, obese).
  5. Implement a basic Caesar cipher for encrypting and decrypting messages.
  6. Find the most frequent element in an array.
  7. Calculate the nth term of a geometric sequence.
  8. Implement a simple game of rock-paper-scissors against the computer.
  9. Convert a decimal number to its Roman numeral representation (up to 3999).
  10. Calculate the area of a regular polygon given the number of sides and side length.

Variables & Data Types

Variables and Data Types in Python

Variables are containers for storing data values. In Python, you don't need to declare variables before using them or declare their type.

Python automatically determines the variable's data type based on the value assigned to it.

1. Primitive Data Types

Python has several built-in primitive data types:

Integers (int)

Whole numbers, positive or negative, without decimals.

age = 25
temperature = -5

Floating-point numbers (float)

Numbers with decimal points or in exponential form.

pi = 3.14159
avogadro = 6.022e23  # Scientific notation

Strings (str)

Sequences of characters, enclosed in single or double quotes.

name = "Alice"
message = 'Hello, World!'
multiline = """This is a
multiline string."""

Booleans (bool)

Represents True or False values.

is_python_fun = True
is_raining = False

None

Represents the absence of a value or a null value.

result = None

2. Composite/Compound Data Types

Composite data types are collections of other data types:

Lists

Ordered, mutable sequences of elements.

fruits = ["apple", "banana", "cherry"]
mixed_list = [1, "two", 3.0, True]

Tuples

Ordered, immutable sequences of elements.

coordinates = (10, 20)
rgb = (255, 0, 128)

Dictionaries

Unordered collections of key-value pairs.

person = {
    "name": "Bob",
    "age": 30,
    "city": "New York"
}

Sets

Unordered collections of unique elements.

unique_numbers = {1, 2, 3, 4, 5}
vowels = set(['a', 'e', 'i', 'o', 'u'])

Type Conversion and Type Casting

Python allows you to convert between different data types:

Implicit Type Conversion

Python automatically converts one data type to another if needed.

x = 5
y = 2.0
result = x + y  # result will be a float (7.0)

Explicit Type Conversion (Type Casting)

You can manually convert between types using built-in functions.

# String to Integer
age_str = "25"
age_int = int(age_str)  # 25

# Integer to String
number = 42
number_str = str(number)  # "42"

# String to Float
price_str = "19.99"
price_float = float(price_str)  # 19.99

# Float to Integer (truncates decimal part)
height = 1.75
height_int = int(height)  # 1

# Integer to Float
count = 10
count_float = float(count)  # 10.0

# String to List
word = "Python"
char_list = list(word)  # ['P', 'y', 't', 'h', 'o', 'n']

# List to Set (removes duplicates)
numbers = [1, 2, 2, 3, 4, 4, 5]
unique_set = set(numbers)  # {1, 2, 3, 4, 5}

Remember that not all type conversions are possible, and some may result in loss of information or raise exceptions if the conversion is invalid.

Exercise

Exercises: Variables and Data Types

These exercises are designed to help you practice working with variables and different data types in Python. Follow each step carefully and try to predict the output before running the code.

File Organization

To keep your work organized, we'll use the following file structure:

csf101-python_exercises/
│
├── basics/
│   ├── numbers.py
│   ├── strings.py
│   └── booleans.py
│
└── data_structures/
    ├── lists.py
    └── dictionaries.py

Create a new directory called python_exercises and navigate into it. Then, create two subdirectories: basics and data_structures.

Exercise 1: Working with Integers and Floats

File: basics/numbers.py

Create a new file called numbers.py in the basics directory and complete the following exercises in this file.

  1. Create a variable age and assign it your age as an integer.

    age = 25  # Replace with your actual age
    print(age)
    

    Expected output: 25

  2. Create a variable height and assign it your height in meters as a float.

    height = 1.75  # Replace with your actual height
    print(height)
    

    Expected output: 1.75

  3. Calculate your age in days (assume 365 days per year) and store it in a variable age_in_days.

    age_in_days = age * 365
    print(age_in_days)
    

    Expected output: 9125

  4. Divide your age by 7 and print the result.

    result = age / 7
    print(result)
    

    Expected output: 3.5714285714285716

    Note: The result is a float, even though we started with integers.

Exercise 2: Working with Strings

File: basics/strings.py

Create a new file called strings.py in the basics directory and complete the following exercises in this file.

  1. Create a variable name and assign it your full name as a string.

    name = "John Doe"  # Replace with your actual name
    print(name)
    

    Expected output: John Doe

  2. Use string concatenation to create a greeting message.

    greeting = "Hello, " + name + "!"
    print(greeting)
    

    Expected output: Hello, John Doe!

  3. Use f-strings to create the same greeting message.

    greeting_f = f"Hello, {name}!"
    print(greeting_f)
    

    Expected output: Hello, John Doe!

  4. Print the length of your name.

    name_length = len(name)
    print(name_length)
    

    Expected output: 8

    Warning: Remember that spaces count as characters too!

Exercise 3: Working with Booleans

File: basics/booleans.py

Create a new file called booleans.py in the basics directory and complete the following exercises in this file.

  1. Create two boolean variables, is_student and is_employed, and assign them appropriate values.

    is_student = True
    is_employed = False
    print(is_student, is_employed)
    

    Expected output: True False

  2. Use the and operator to check if you are both a student and employed.

    is_student_and_employed = is_student and is_employed
    print(is_student_and_employed)
    

    Expected output: False

  3. Use the or operator to check if you are either a student or employed.

    is_student_or_employed = is_student or is_employed
    print(is_student_or_employed)
    

    Expected output: True

Exercise 4: Type Conversion

File: basics/type_conversion.py

Create a new file called type_conversion.py in the basics directory and complete the following exercises in this file.

  1. Convert your age to a string and concatenate it with a message.

    age = 25  # Use the same age as in numbers.py
    age_str = str(age)
    message = "I am " + age_str + " years old."
    print(message)
    

    Expected output: I am 25 years old.

  2. Try to convert a string to an integer.

    num_str = "42"
    num_int = int(num_str)
    print(num_int)
    

    Expected output: 42

  3. Now try to convert a non-numeric string to an integer.

    non_num_str = "Hello"
    try:
        non_num_int = int(non_num_str)
        print(non_num_int)
    except ValueError as e:
        print(f"Error: {e}")
    

    Expected output: Error: invalid literal for int() with base 10: 'Hello'

    Note: This will raise a ValueError, which we catch and print.

Exercise 5: Working with Lists

File: data_structures/lists.py

Create a new file called lists.py in the data_structures directory and complete the following exercises in this file.

  1. Create a list of your favorite fruits.

    fruits = ["apple", "banana", "cherry"]
    print(fruits)
    

    Expected output: ['apple', 'banana', 'cherry']

  2. Add a new fruit to your list using the append() method.

    fruits.append("date")
    print(fruits)
    

    Expected output: ['apple', 'banana', 'cherry', 'date']

  3. Access and print the second fruit in your list.

    second_fruit = fruits[1]
    print(second_fruit)
    

    Expected output: banana

    Warning: Remember that list indices start at 0!

Exercise 6: Working with Dictionaries

File: data_structures/dictionaries.py

Create a new file called dictionaries.py in the data_structures directory and complete the following exercises in this file.

  1. Create a dictionary with information about yourself.

    name = "John Doe"  # Use the same name as in strings.py
    age = 25  # Use the same age as in numbers.py
    height = 1.75  # Use the same height as in numbers.py
    is_student = True  # Use the same value as in booleans.py
    
    person_info = {
        "name": name,
        "age": age,
        "height": height,
        "is_student": is_student
    }
    print(person_info)
    

    Expected output: {'name': 'John Doe', 'age': 25, 'height': 1.75, 'is_student': True}

  2. Add your favorite color to the dictionary.

    person_info["favorite_color"] = "blue"  # Replace with your actual favorite color
    print(person_info)
    

    Expected output: {'name': 'John Doe', 'age': 25, 'height': 1.75, 'is_student': True, 'favorite_color': 'blue'}

  3. Try to access a key that doesn't exist in the dictionary.

    try:
        print(person_info["weight"])
    except KeyError as e:
        print(f"Error: {e}")
    

    Expected output: Error: 'weight'

    Note: This will raise a KeyError because 'weight' is not a key in our dictionary.

Congratulations!

Final Notes on File Organization

  • Keeping related concepts in the same directory (basics or data_structures) helps in organizing your learning process.
  • As you progress in your Python journey, you can add more directories for advanced topics (e.g., functions, classes, modules).
  • Always try to keep your code organized - it's a good habit that will help you as you work on larger projects.

Remember to run each file separately to see the output of your exercises. You can do this by navigating to the appropriate directory in your terminal and running python filename.py (e.g., python numbers.py).

Operators

Python Operators

Operators in Python

Operators are special symbols or keywords that perform operations on one or more operands. Python provides a rich set of operators for various purposes.

1. Arithmetic Operators

Arithmetic operators are used to perform mathematical operations.

OperatorDescriptionExample
+Addition5 + 3 = 8
-Subtraction5 - 3 = 2
*Multiplication5 * 3 = 15
/Division (float result)5 / 3 = 1.6666667
//Floor Division (integer result)5 // 3 = 1
%Modulus (remainder)5 % 3 = 2
**Exponentiation5 ** 3 = 125

Examples:

a, b = 10, 3

print(f"Addition: {a + b}")        # Output: 13
print(f"Subtraction: {a - b}")     # Output: 7
print(f"Multiplication: {a * b}")  # Output: 30
print(f"Division: {a / b}")        # Output: 3.3333333333333335
print(f"Floor Division: {a // b}") # Output: 3
print(f"Modulus: {a % b}")         # Output: 1
print(f"Exponentiation: {a ** b}") # Output: 1000

Note: The division operator (/) always returns a float, even if the result is a whole number. Use floor division (//) if you need an integer result.

2. Unary Operators

Unary operators work with a single operand.

OperatorDescriptionExample
-Negation-5
+Positive (no effect)+5

Examples:

x = 5
print(f"Negation: {-x}")  # Output: -5
print(f"Positive: {+x}")  # Output: 5

# Unary operators with expressions
y = 10
print(f"Negation of expression: {-(x + y)}")  # Output: -15

Note: The unary + operator is rarely used as it doesn't change the value. It's included mainly for completeness and symmetry with the - operator.

3. Assignment Operators

Assignment operators are used to assign values to variables.

OperatorDescriptionExampleEquivalent to
=Simple assignmentx = 5-
+=Add and assignx += 3x = x + 3
-=Subtract and assignx -= 3x = x - 3
*=Multiply and assignx *= 3x = x * 3
/=Divide and assignx /= 3x = x / 3
//=Floor divide and assignx //= 3x = x // 3
%=Modulus and assignx %= 3x = x % 3
**=Exponentiate and assignx **= 3x = x ** 3

Examples:

x = 10
print(f"Initial x: {x}")  # Output: 10

x += 5
print(f"After x += 5: {x}")  # Output: 15

x -= 3
print(f"After x -= 3: {x}")  # Output: 12

x *= 2
print(f"After x *= 2: {x}")  # Output: 24

x /= 4
print(f"After x /= 4: {x}")  # Output: 6.0

x //= 2
print(f"After x //= 2: {x}")  # Output: 3.0

x %= 2
print(f"After x %= 2: {x}")  # Output: 1.0

x **= 3
print(f"After x **= 3: {x}")  # Output: 1.0

Note: Assignment operators modify the variable in-place. They're a shorthand for longer expressions and can make code more readable.

4. Comparison Operators

Comparison operators are used to compare values. They return Boolean results (True or False).

OperatorDescriptionExample
==Equal to5 == 5 returns True
!=Not equal to5 != 4 returns True
<Less than3 < 5 returns True
>Greater than5 > 3 returns True
<=Less than or equal to3 <= 3 returns True
>=Greater than or equal to5 >= 5 returns True

Examples:

a, b = 10, 5

print(f"a == b: {a == b}")  # Output: False
print(f"a != b: {a != b}")  # Output: True
print(f"a < b: {a < b}")    # Output: False
print(f"a > b: {a > b}")    # Output: True
print(f"a <= b: {a <= b}")  # Output: False
print(f"a >= b: {a >= b}")  # Output: True

# Comparison chaining
x = 5
print(f"1 < x < 10: {1 < x < 10}")  # Output: True

Note: Python allows comparison chaining, which is a concise way to write multiple comparisons in a single expression.

5. Logical Operators

Logical operators are used to combine conditional statements.

OperatorDescriptionExample
andReturns True if both statements are truex < 5 and x < 10
orReturns True if one of the statements is truex < 5 or x < 4
notReverses the result, returns False if the result is truenot(x < 5 and x < 10)

Examples:

x = 5
y = 10

print(f"x < 10 and y > 5: {x < 10 and y > 5}")  # Output: True
print(f"x > 10 or y > 5: {x > 10 or y > 5}")    # Output: True
print(f"not(x < 10): {not(x < 10)}")            # Output: False

# Short-circuit evaluation
def true_func():
    print("true_func called")
    return True

def false_func():
    print("false_func called")
    return False

print(f"false_func() and true_func(): {false_func() and true_func()}")
# Output: false_func called
#         False

print(f"true_func() or false_func(): {true_func() or false_func()}")
# Output: true_func called
#         True

Note: Python uses short-circuit evaluation for logical operators. In and operations, if the first operand is False, the second operand is not evaluated. In or operations, if the first operand is True, the second operand is not evaluated.

6. Bitwise Operators

Bitwise operators perform operations on the binary representations of numbers.

OperatorDescriptionExample
&AND5 & 3 = 1
^XOR5 ^ 3 = 6
~NOT~5 = -6
<<Left shift5 << 1 = 10
>>Right shift5 >> 1 = 2

Examples:

a, b = 5, 3  # In binary: a = 101, b = 011

print(f"a & b: {a & b}")  # Output: 1 (001 in binary)
print(f"a | b: {a | b}")  # Output: 7 (111 in binary)
print(f"a ^ b: {a ^ b}")  # Output: 6 (110 in binary)
print(f"~a: {~a}")        # Output: -6 (Two's complement representation)
print(f"a << 1: {a << 1}")  # Output: 10 (1010 in binary)
print(f"a >> 1: {a >> 1}")  # Output: 2 (10 in binary)

# Practical use: Checking if a number is even or odd
num = 42
is_even = not (num & 1)  # If the least significant bit is 0, the number is even
print(f"{num} is {'even' if is_even else 'odd'}")  # Output: 42 is even

Note: Bitwise operators are less commonly used in everyday programming but are important in certain areas like low-level programming, cryptography, and optimization.

7. Conditional Operators (Ternary Operator)

Python provides a conditional expression, often called the ternary operator, which is a shorthand way of writing an if-else statement in a single line.

Syntax: value_if_true if condition else value_if_false

Examples:

# Basic usage
x = 10
result = "Even" if x % 2 == 0 else "Odd"
print(f"{x} is {result}")  # Output: 10 is Even

# Ternary operator in a function
def abs_value(num):
    return num if num >= 0 else -num

print(f"Absolute value of -5: {abs_value(-5)}")  # Output: 5
print(f"Absolute value of 3: {abs_value(3)}")    # Output: 3

# Nested ternary operator (use with caution for readability)
a, b = 5, 10
result = "a is greater" if a > b else "b is greater" if b > a else "a and b are equal"
print(result)  # Output: b is greater

# Ternary operator with function calls
def is_even(n):
    return n % 2 == 0

numbers = [1, 2, 3, 4, 5]
even_odd = ['even' if is_even(n) else 'odd' for n in numbers]
print(even_odd)  # Output: ['odd', 'even', 'odd', 'even', 'odd']

Note: While the ternary operator can make code more concise, it's important to use it judiciously. For complex conditions or when clarity is more important than brevity, it's often better to use a full if-else statement.

Summary

  1. Arithmetic Operators: Covers basic mathematical operations with examples.
  2. Unary Operators: Explains operators that work with a single operand.
  3. Assignment Operators: Details various ways to assign values to variables.
  4. Comparison Operators: Covers operators used for comparing values.
  5. Logical Operators: Explains how to combine conditional statements.
  6. Bitwise Operators: Describes operators that work on the binary representation of numbers.
  7. Conditional Operators: Covers the ternary operator for concise if-else statements.

For each type of operator, I've included:

  • A table explaining each operator
  • Python code examples demonstrating their use
  • Expected outputs for each example
  • Additional notes on behavior, common use cases, or potential pitfalls

Some key points to note:

  • I've used f-strings extensively in the examples for clear and readable output formatting.
  • For bitwise operators, I included a practical example of checking if a number is even or odd.
  • The section on logical operators includes an example of short-circuit evaluation.
  • The conditional operator section shows how it can be used in list comprehensions and with function calls.

Exercise

Python Operators

These exercises are designed to help you practice working with various operators in Python. Follow each step carefully and try to predict the output before running the code.

File Organization

We'll add a new directory called operators to your existing file structure. The updated structure will look like this:

csf101-python_exercises/
│
├── basics/
│   ├── numbers.py
│   ├── strings.py
│   └── booleans.py
│
├── data_structures/
│   ├── lists.py
│   └── dictionaries.py
│
└── operators/
    ├── arithmetic.py
    ├── assignment.py
    ├── comparison.py
    └── logical.py

Create a new directory called operators inside your csf101-python_exercises directory.

Exercise 1: Arithmetic Operators

File: operators/arithmetic.py

Create a new file called arithmetic.py in the operators directory and complete the following exercises in this file.

  1. Create two variables a and b with values 15 and 4 respectively.

    a, b = 15, 4
    print(f"a = {a}, b = {b}")
    

    Expected output: a = 15, b = 4

  2. Perform addition, subtraction, multiplication, and division with these variables.

    print(f"Addition: {a + b}")
    print(f"Subtraction: {a - b}")
    print(f"Multiplication: {a * b}")
    print(f"Division: {a / b}")
    

    Expected output:

    Addition: 19
    Subtraction: 11
    Multiplication: 60
    Division: 3.75
    
  3. Use the modulus operator to find the remainder when a is divided by b.

    print(f"Modulus: {a % b}")
    

    Expected output: Modulus: 3

  4. Use the exponentiation operator to calculate a to the power of b.

    print(f"Exponentiation: {a ** b}")
    

    Expected output: Exponentiation: 50625

  5. Use floor division to divide a by b.

    print(f"Floor Division: {a // b}")
    

    Expected output: Floor Division: 3

Exercise 2: Assignment Operators

File: operators/assignment.py

Create a new file called assignment.py in the operators directory and complete the following exercises in this file.

  1. Create a variable x with an initial value of 10.

    x = 10
    print(f"Initial x: {x}")
    

    Expected output: Initial x: 10

  2. Use the += operator to add 5 to x.

    x += 5
    print(f"After x += 5: {x}")
    

    Expected output: After x += 5: 15

  3. Use the -= operator to subtract 3 from x.

    x -= 3
    print(f"After x -= 3: {x}")
    

    Expected output: After x -= 3: 12

  4. Use the *= operator to multiply x by 2.

    x *= 2
    print(f"After x *= 2: {x}")
    

    Expected output: After x *= 2: 24

  5. Use the /= operator to divide x by 4.

    x /= 4
    print(f"After x /= 4: {x}")
    

    Expected output: After x /= 4: 6.0

Exercise 3: Comparison Operators

File: operators/comparison.py

Create a new file called comparison.py in the operators directory and complete the following exercises in this file.

  1. Create two variables a and b with values 10 and 5 respectively.

    a, b = 10, 5
    print(f"a = {a}, b = {b}")
    

    Expected output: a = 10, b = 5

  2. Use comparison operators to compare a and b.

    print(f"a == b: {a == b}")
    print(f"a != b: {a != b}")
    print(f"a > b: {a > b}")
    print(f"a < b: {a < b}")
    print(f"a >= b: {a >= b}")
    print(f"a <= b: {a <= b}")
    

    Expected output:

    a == b: False
    a != b: True
    a > b: True
    a < b: False
    a >= b: True
    a <= b: False
    
  3. Create a variable c with value 10 and compare it with a.

    c = 10
    print(f"a == c: {a == c}")
    

    Expected output: a == c: True

Exercise 4: Logical Operators

File: operators/logical.py

Create a new file called logical.py in the operators directory and complete the following exercises in this file.

  1. Create two boolean variables x and y.

    x = True
    y = False
    print(f"x = {x}, y = {y}")
    

    Expected output: x = True, y = False

  2. Use the and operator with x and y.

    print(f"x and y: {x and y}")
    

    Expected output: x and y: False

  3. Use the or operator with x and y.

    print(f"x or y: {x or y}")
    

    Expected output: x or y: True

  4. Use the not operator with x and y.

    print(f"not x: {not x}")
    print(f"not y: {not y}")
    

    Expected output:

    not x: False
    not y: True
    

Congratulations!

Remember to run each file separately to see the output of your exercises. You can do this by navigating to the appropriate directory in your terminal and running python filename.py (e.g., python arithmetic.py).

Control Structures

Python Control Structures

Control Structures in Python

Control structures are programming constructs that allow you to control the flow of your program's execution. They enable you to make decisions, repeat actions, and organize your code into logical blocks.

1. Conditional Statements

Conditional statements allow your program to make decisions based on certain conditions.

If-Else Statements

The if-else statement is the most common type of conditional statement.

Syntax:

if condition:
    # code to execute if condition is True
elif another_condition:
    # code to execute if another_condition is True
else:
    # code to execute if all conditions are False

Examples:

  1. Basic if-else:
age = 20

if age >= 18:
    print("You are an adult.")
else:
    print("You are a minor.")

# Output: You are an adult.
  1. If-elif-else chain:
score = 85

if score >= 90:
    grade = "A"
elif score >= 80:
    grade = "B"
elif score >= 70:
    grade = "C"
elif score >= 60:
    grade = "D"
else:
    grade = "F"

print(f"Your grade is: {grade}")

# Output: Your grade is: B
  1. Nested if statements:
x = 10
y = 5

if x > 0:
    if y > 0:
        print("Both x and y are positive.")
    else:
        print("x is positive, but y is not.")
else:
    print("x is not positive.")

# Output: Both x and y are positive.
  1. Ternary operator (conditional expression):
age = 20
status = "adult" if age >= 18 else "minor"
print(status)

# Output: adult

Switch-Case Equivalent in Python

Python doesn't have a built-in switch-case statement, but you can achieve similar functionality using dictionaries or if-elif chains.

Example using a dictionary:

def switch_demo(argument):
    switcher = {
        1: "January",
        2: "February",
        3: "March",
        4: "April"
    }
    return switcher.get(argument, "Invalid month")

print(switch_demo(2))  # Output: February
print(switch_demo(5))  # Output: Invalid month

2. Loops

Loops allow you to execute a block of code repeatedly.

For Loops

For loops are used to iterate over a sequence (like a list, tuple, string, or range) or other iterable objects.

Syntax:

for item in iterable:
    # code to execute for each item

Examples:

  1. Iterating over a list:
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# Output:
# apple
# banana
# cherry
  1. Using range():
for i in range(5):
    print(i)

# Output:
# 0
# 1
# 2
# 3
# 4
  1. Enumerating a list:
fruits = ["apple", "banana", "cherry"]
for index, fruit in enumerate(fruits):
    print(f"Index {index}: {fruit}")

# Output:
# Index 0: apple
# Index 1: banana
# Index 2: cherry
  1. Nested for loops:
for i in range(1, 4):
    for j in range(1, 4):
        print(f"{i} * {j} = {i*j}")
    print()  # Print a newline after each inner loop

# Output:
# 1 * 1 = 1
# 1 * 2 = 2
# 1 * 3 = 3

# 2 * 1 = 2
# 2 * 2 = 4
# 2 * 3 = 6

# 3 * 1 = 3
# 3 * 2 = 6
# 3 * 3 = 9

While Loops

While loops execute a block of code as long as a condition is true.

Syntax:

while condition:
    # code to execute while condition is True

Examples:

  1. Basic while loop:
count = 0
while count < 5:
    print(count)
    count += 1

# Output:
# 0
# 1
# 2
# 3
# 4
  1. While loop with user input:
user_input = ""
while user_input.lower() != "quit":
    user_input = input("Enter a command (type 'quit' to exit): ")
    print(f"You entered: {user_input}")

print("Program ended.")

# Sample run:
# Enter a command (type 'quit' to exit): hello
# You entered: hello
# Enter a command (type 'quit' to exit): python
# You entered: python
# Enter a command (type 'quit' to exit): quit
# You entered: quit
# Program ended.
  1. Infinite loop with a break condition:
while True:
    number = int(input("Enter a positive number: "))
    if number <= 0:
        print("That's not a positive number. Try again.")
    else:
        print(f"You entered: {number}")
        break

print("Loop ended.")

# Sample run:
# Enter a positive number: -5
# That's not a positive number. Try again.
# Enter a positive number: 0
# That's not a positive number. Try again.
# Enter a positive number: 10
# You entered: 10
# Loop ended.

3. Break and Continue Statements

Break and continue statements allow you to control the flow of loops more precisely.

Break Statement

The break statement terminates the loop containing it. Control of the program flows to the statement immediately after the body of the loop.

Example:

for number in range(1, 10):
    if number == 5:
        break
    print(number)

print("Loop ended")

# Output:
# 1
# 2
# 3
# 4
# Loop ended

Continue Statement

The continue statement skips the rest of the code inside the loop for the current iteration only. Loop does not terminate but continues on with the next iteration.

Example:

for number in range(1, 6):
    if number == 3:
        continue
    print(number)

# Output:
# 1
# 2
# 4
# 5

Using Break and Continue in While Loops

Example:

count = 0
while True:
    count += 1
    if count == 3:
        continue
    if count == 5:
        break
    print(count)

print("Loop ended")

# Output:
# 1
# 2
# 4
# Loop ended

Else Clause in Loops

Python allows the use of else clauses with both for and while loops. The else block is executed when the loop condition becomes false.

Example with for loop:

for i in range(5):
    print(i)
else:
    print("Loop completed normally")

# Output:
# 0
# 1
# 2
# 3
# 4
# Loop completed normally

Example with while loop and break:

n = 0
while n < 5:
    if n == 3:
        break
    print(n)
    n += 1
else:
    print("Loop completed normally")

print("Outside the loop")

# Output:
# 0
# 1
# 2
# Outside the loop

Note that in the second example, the else block is not executed because the loop was terminated by a break statement.

These control structures form the backbone of program flow in Python. Understanding and effectively using them will greatly enhance your ability to write complex and efficient Python programs. Remember to practice these concepts with your own examples to reinforce your learning!

Summary

Conditional Statements:

  • If-Else statements with examples of basic, chained, and nested conditions
  • Ternary operator for concise conditional expressions
  • Switch-case equivalent using dictionaries

Loops:

  • For loops with examples of iterating over lists, using range(), enumeration, and nested loops
  • While loops with examples of basic usage, user input handling, and infinite loops with break conditions

Break and Continue Statements:

  • Examples of using break to exit loops early
  • Examples of using continue to skip iterations
  • Demonstration of break and continue in both for and while loops

Additional Topics:

  • Else clause in loops, showing how it works with both for and while loops
  • Examples of how break affects the else clause in loops

Exercise

Python Control Structures

These exercises are designed to help you practice working with control structures in Python. Follow each step carefully and try to predict the output before running the code.

File Organization

We'll add a new directory called control_structures to your existing file structure. The updated structure will look like this:

csf101-python_exercises/
│
├── basics/
│   ├── numbers.py
│   ├── strings.py
│   └── booleans.py
│
├── data_structures/
│   ├── lists.py
│   └── dictionaries.py
│
├── operators/
│   ├── arithmetic.py
│   ├── assignment.py
│   ├── comparison.py
│   ├── logical.py
│   └── bitwise.py
│
└── control_structures/
    ├── conditionals.py
    ├── loops.py
    └── break_continue.py

Create a new directory called control_structures inside your csf101-python_exercises directory.

Exercise 1: Conditional Statements

File: control_structures/conditionals.py

Create a new file called conditionals.py in the control_structures directory and complete the following exercises in this file.

  1. Write an if-else statement to check if a number is positive or negative.

    number = 10
    if number > 0:
        print("The number is positive.")
    else:
        print("The number is non-positive.")
    

    Expected output: The number is positive.

  2. Extend the previous example to include zero as a separate case.

    number = 0
    if number > 0:
        print("The number is positive.")
    elif number < 0:
        print("The number is negative.")
    else:
        print("The number is zero.")
    

    Expected output: The number is zero.

  3. Write a program that assigns a letter grade based on a numerical score.

    score = 85
    if score >= 90:
        grade = "A"
    elif score >= 80:
        grade = "B"
    elif score >= 70:
        grade = "C"
    elif score >= 60:
        grade = "D"
    else:
        grade = "F"
    print(f"Your grade is: {grade}")
    

    Expected output: Your grade is: B

  4. Use a ternary operator to check if a number is even or odd.

    number = 7
    result = "even" if number % 2 == 0 else "odd"
    print(f"The number is {result}.")
    

    Expected output: The number is odd.

  5. Implement a simple calculator using if-elif-else statements.

    num1 = 10
    num2 = 5
    operator = "+"
    
    if operator == "+":
        result = num1 + num2
    elif operator == "-":
        result = num1 - num2
    elif operator == "*":
        result = num1 * num2
    elif operator == "/":
        result = num1 / num2 if num2 != 0 else "Error: Division by zero"
    else:
        result = "Error: Invalid operator"
    
    print(f"Result: {result}")
    

    Expected output: Result: 15

Exercise 2: Loops

File: control_structures/loops.py

Create a new file called loops.py in the control_structures directory and complete the following exercises in this file.

  1. Write a for loop to print numbers from 1 to 5.

    for i in range(1, 6):
        print(i)
    

    Expected output:

    1
    2
    3
    4
    5
    
  2. Use a while loop to print numbers from 5 to 1 in reverse order.

    count = 5
    while count > 0:
        print(count)
        count -= 1
    

    Expected output:

    5
    4
    3
    2
    1
    
  3. Write a for loop to calculate the sum of numbers from 1 to 10.

    total = 0
    for num in range(1, 11):
        total += num
    print(f"The sum of numbers from 1 to 10 is: {total}")
    

    Expected output: The sum of numbers from 1 to 10 is: 55

  4. Use a for loop to iterate over a list and print each item.

    fruits = ["apple", "banana", "cherry"]
    for fruit in fruits:
        print(fruit)
    

    Expected output:

    apple
    banana
    cherry
    
  5. Write a nested loop to create a multiplication table for numbers 1 to 3.

    for i in range(1, 4):
        for j in range(1, 4):
            print(f"{i} * {j} = {i*j}")
        print()  # Print a newline after each inner loop
    

    Expected output:

    1 * 1 = 1
    1 * 2 = 2
    1 * 3 = 3
    
    2 * 1 = 2
    2 * 2 = 4
    2 * 3 = 6
    
    3 * 1 = 3
    3 * 2 = 6
    3 * 3 = 9
    

Exercise 3: Break and Continue Statements

File: control_structures/break_continue.py

Create a new file called break_continue.py in the control_structures directory and complete the following exercises in this file.

  1. Use a break statement to exit a while loop when a certain condition is met.

    count = 0
    while True:
        print(count)
        count += 1
        if count >= 5:
            break
    print("Loop ended")
    

    Expected output:

    0
    1
    2
    3
    4
    Loop ended
    
  2. Use a continue statement to skip even numbers in a for loop.

    for num in range(1, 6):
        if num % 2 == 0:
            continue
        print(num)
    

    Expected output:

    1
    3
    5
    
  3. Write a loop that searches for a specific number in a list and stops when it's found.

    numbers = [4, 2, 7, 1, 8, 3, 6]
    search_for = 8
    
    for num in numbers:
        if num == search_for:
            print(f"Found {search_for}!")
            break
        print(f"Not {search_for}...")
    

    Expected output:

    Not 8...
    Not 8...
    Not 8...
    Not 8...
    Found 8!
    
  4. Implement a simple number guessing game using a while loop and break statement.

    import random
    
    secret_number = random.randint(1, 10)
    attempts = 0
    
    while True:
        guess = int(input("Guess the number (1-10): "))
        attempts += 1
    
        if guess == secret_number:
            print(f"Congratulations! You guessed it in {attempts} attempts.")
            break
        elif guess < secret_number:
            print("Too low. Try again.")
        else:
            print("Too high. Try again.")
    

    Sample run:

    Guess the number (1-10): 5
    Too low. Try again.
    Guess the number (1-10): 8
    Too high. Try again.
    Guess the number (1-10): 7
    Congratulations! You guessed it in 3 attempts.
    
  5. Use a for loop with else to check if a number is prime.

    def is_prime(n):
        if n < 2:
            return False
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                return False
        return True
    
    number = 17
    if is_prime(number):
        print(f"{number} is prime.")
    else:
        print(f"{number} is not prime.")
    

    Expected output: 17 is prime.

Congratulations!

Remember to run each file separately to see the output of your exercises. You can do this by navigating to the appropriate directory in your terminal and running python filename.py (e.g., python conditionals.py).

Functions & Scope

Python Functions and Scope

Functions and Scope in Python

Functions are reusable blocks of code that perform a specific task. They help in organizing code, improving readability, and reducing repetition. Understanding function scope is crucial for writing efficient and bug-free code.

1. Function Definition and Invocation

Function Definition

In Python, functions are defined using the def keyword, followed by the function name and parentheses containing any parameters.

Syntax:

def function_name(parameter1, parameter2, ...):
    # Function body
    # Code to be executed
    return value  # Optional

Function Invocation

To use a function, you need to call it. This is done by using the function name followed by parentheses containing any required arguments.

Examples:

  1. Simple function definition and invocation:
def greet():
    print("Hello, World!")

greet()  # Function call

# Output: Hello, World!
  1. Function with parameters:
def greet_person(name):
    print(f"Hello, {name}!")

greet_person("Alice")  # Function call with argument

# Output: Hello, Alice!
  1. Function with return value:
def add_numbers(a, b):
    return a + b

result = add_numbers(5, 3)  # Function call with arguments
print(result)

# Output: 8

2. Parameters and Return Values

Parameters

Parameters are variables listed in the function definition. They act as placeholders for the values that will be passed to the function when it's called.

  1. Default parameters:
def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Bob")  # Uses default greeting
greet("Alice", "Hi")  # Overrides default greeting

# Output:
# Hello, Bob!
# Hi, Alice!
  1. Keyword arguments:
def describe_pet(animal_type, pet_name):
    print(f"I have a {animal_type} named {pet_name}.")

describe_pet(animal_type="dog", pet_name="Rex")
describe_pet(pet_name="Whiskers", animal_type="cat")

# Output:
# I have a dog named Rex.
# I have a cat named Whiskers.
  1. Variable-length arguments:
    • *args for non-keyword arguments
    • **kwargs for keyword arguments
def print_args(*args, **kwargs):
    for arg in args:
        print(arg)
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_args(1, 2, 3, name="Alice", age=30)

# Output:
# 1
# 2
# 3
# name: Alice
# age: 30

Return Values

Functions can return values using the return statement. A function can return a single value, multiple values, or nothing (implicitly returns None).

  1. Returning a single value:
def square(n):
    return n ** 2

result = square(4)
print(result)  # Output: 16
  1. Returning multiple values:
def min_max(numbers):
    return min(numbers), max(numbers)

lowest, highest = min_max([1, 2, 3, 4, 5])
print(f"Lowest: {lowest}, Highest: {highest}")

# Output: Lowest: 1, Highest: 5
  1. Early return:
def absolute_value(n):
    if n >= 0:
        return n
    else:
        return -n

print(absolute_value(-5))  # Output: 5
print(absolute_value(3))   # Output: 3

3. Local and Global Scope

Local Scope

Variables defined inside a function have a local scope and can only be accessed within that function.

Example:

def local_example():
    x = 10  # Local variable
    print(f"Inside function: {x}")

local_example()
# print(x)  # This would raise a NameError

# Output: Inside function: 10

Global Scope

Variables defined outside of any function have a global scope and can be accessed from anywhere in the module.

Example:

y = 20  # Global variable

def global_example():
    print(f"Inside function: {y}")

global_example()
print(f"Outside function: {y}")

# Output:
# Inside function: 20
# Outside function: 20

Modifying Global Variables

To modify a global variable inside a function, you need to use the global keyword.

Example:

count = 0

def increment():
    global count
    count += 1
    print(f"Inside function: {count}")

increment()
print(f"Outside function: {count}")

# Output:
# Inside function: 1
# Outside function: 1

Nonlocal Variables

For nested functions, you can use the nonlocal keyword to work with variables in the nearest enclosing scope.

Example:

def outer():
    x = "local"

    def inner():
        nonlocal x
        x = "nonlocal"
        print(f"Inner: {x}")

    inner()
    print(f"Outer: {x}")

outer()

# Output:
# Inner: nonlocal
# Outer: nonlocal

4. Function Call Stack & Recursion

Function Call Stack

When a function is called, Python creates a new local namespace for that function. This is added to the call stack, which keeps track of the point to which each active function should return control when it finishes executing.

Example:

def func1():
    print("In func1")
    func2()
    print("Back in func1")

def func2():
    print("In func2")

func1()

# Output:
# In func1
# In func2
# Back in func1

Recursion

Recursion is a programming technique where a function calls itself to solve a problem by breaking it down into smaller, similar sub-problems.

Example: Calculating factorial

def factorial(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n - 1)

print(factorial(5))  # Output: 120

How it works:

  1. factorial(5)
    • 5 * factorial(4)
      • 4 * factorial(3)
        • 3 * factorial(2)
          • 2 * factorial(1)
            • Returns 1
          • Returns 2 * 1 = 2
        • Returns 3 * 2 = 6
      • Returns 4 * 6 = 24
    • Returns 5 * 24 = 120

Tail Recursion

Tail recursion is a special case of recursion where the recursive call is the last operation in the function. Python doesn't optimize for tail recursion, but it's a useful concept to understand.

Example: Tail-recursive factorial

def factorial_tail(n, accumulator=1):
    if n == 0 or n == 1:
        return accumulator
    else:
        return factorial_tail(n - 1, n * accumulator)

print(factorial_tail(5))  # Output: 120

Recursion vs. Iteration

While recursion can lead to elegant solutions for some problems, it's important to consider the trade-offs. Recursive functions can be more memory-intensive and slower than their iterative counterparts for large inputs.

Example: Fibonacci sequence (recursive vs. iterative)

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

def fib_iterative(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

# Compare performance
import time

n = 30

start = time.time()
print(f"Recursive result: {fib_recursive(n)}")
print(f"Recursive time: {time.time() - start}")

start = time.time()
print(f"Iterative result: {fib_iterative(n)}")
print(f"Iterative time: {time.time() - start}")

# Sample Output:
# Recursive result: 832040
# Recursive time: 0.2814083099365234
# Iterative result: 832040
# Iterative time: 0.0000240802764892578

As you can see, for larger values of n, the iterative solution is significantly faster than the recursive one.

Understanding functions and scope in Python is crucial for writing efficient, organized, and maintainable code. Practice these concepts regularly to become proficient in using them effectively in your programs.

Summary

Function Definition and Invocation:

  • Syntax for defining functions
  • Examples of simple functions, functions with parameters, and functions with return values

Parameters and Return Values:

  • Different types of parameters (default, keyword, variable-length)
  • Examples of returning single and multiple values
  • Early return demonstration

Local and Global Scope:

  • Explanation of local and global scopes with examples
  • How to modify global variables within functions
  • Nonlocal variables in nested functions

Function Call Stack & Recursion:

  • Explanation of the function call stack
  • Recursive functions with factorial example
  • Tail recursion concept
  • Comparison of recursion vs. iteration with Fibonacci sequence example

Exercise

Python Functions and Scope

These exercises are designed to help you practice working with functions and scope in Python. Follow each step carefully and try to predict the output before running the code.

File Organization

We'll add a new directory called functions_and_scope to your existing file structure. The updated structure will look like this:

csf101-python_exercises/
│
├── basics/
│   ├── numbers.py
│   ├── strings.py
│   └── booleans.py
│
├── data_structures/
│   ├── lists.py
│   └── dictionaries.py
│
├── operators/
│   ├── arithmetic.py
│   ├── assignment.py
│   ├── comparison.py
│   ├── logical.py
│   └── bitwise.py
│
├── control_structures/
│   ├── conditionals.py
│   ├── loops.py
│   └── break_continue.py
│
└── functions_and_scope/
    ├── basic_functions.py
    ├── parameters_and_returns.py
    ├── scope.py
    └── recursion.py

Create a new directory called functions_and_scope inside your csf101-python_exercises directory.

Exercise 1: Basic Functions

File: functions_and_scope/basic_functions.py

Create a new file called basic_functions.py in the functions_and_scope directory and complete the following exercises in this file.

  1. Write a function called greet that prints "Hello, World!".

    def greet():
        print("Hello, World!")
    
    greet()
    

    Expected output: Hello, World!

  2. Modify the greet function to take a name parameter and greet that person.

    def greet(name):
        print(f"Hello, {name}!")
    
    greet("Alice")
    

    Expected output: Hello, Alice!

  3. Write a function called square that takes a number and returns its square.

    def square(number):
        return number ** 2
    
    result = square(5)
    print(result)
    

    Expected output: 25

  4. Create a function called is_even that takes a number and returns True if it's even, False otherwise.

    def is_even(number):
        return number % 2 == 0
    
    print(is_even(4))
    print(is_even(7))
    

    Expected output:

    True
    False
    
  5. Write a function called print_numbers that prints numbers from 1 to n (inclusive).

    def print_numbers(n):
        for i in range(1, n + 1):
            print(i)
    
    print_numbers(5)
    

    Expected output:

    1
    2
    3
    4
    5
    

Exercise 2: Parameters and Return Values

File: functions_and_scope/parameters_and_returns.py

Create a new file called parameters_and_returns.py in the functions_and_scope directory and complete the following exercises in this file.

  1. Write a function called greet_with_default that takes a name parameter with a default value of "Guest".

    def greet_with_default(name="Guest"):
        print(f"Hello, {name}!")
    
    greet_with_default()
    greet_with_default("Bob")
    

    Expected output:

    Hello, Guest!
    Hello, Bob!
    
  2. Create a function called calculate_rectangle_area that takes width and height as parameters and returns the area.

    def calculate_rectangle_area(width, height):
        return width * height
    
    area = calculate_rectangle_area(5, 3)
    print(f"The area of the rectangle is: {area}")
    

    Expected output: The area of the rectangle is: 15

  3. Write a function called print_info that takes any number of keyword arguments and prints them.

    def print_info(**kwargs):
        for key, value in kwargs.items():
            print(f"{key}: {value}")
    
    print_info(name="Alice", age=30, city="New York")
    

    Expected output:

    name: Alice
    age: 30
    city: New York
    
  4. Create a function called min_max that takes a list of numbers and returns both the minimum and maximum values.

    def min_max(numbers):
        return min(numbers), max(numbers)
    
    result = min_max([5, 2, 8, 1, 9])
    print(f"Min: {result[0]}, Max: {result[1]}")
    

    Expected output: Min: 1, Max: 9

  5. Write a function called safe_divide that takes two numbers and returns their division, or returns "Cannot divide by zero" if the second number is 0.

    def safe_divide(a, b):
        if b == 0:
            return "Cannot divide by zero"
        return a / b
    
    print(safe_divide(10, 2))
    print(safe_divide(5, 0))
    

    Expected output:

    5.0
    Cannot divide by zero
    

Exercise 3: Scope

File: functions_and_scope/scope.py

Create a new file called scope.py in the functions_and_scope directory and complete the following exercises in this file.

  1. Demonstrate the difference between local and global variables.

    x = 10  # Global variable
    
    def print_x():
        x = 20  # Local variable
        print(f"Local x: {x}")
    
    print_x()
    print(f"Global x: {x}")
    

    Expected output:

    Local x: 20
    Global x: 10
    
  2. Modify a global variable from within a function.

    count = 0
    
    def increment():
        global count
        count += 1
        print(f"Count: {count}")
    
    increment()
    increment()
    print(f"Final count: {count}")
    

    Expected output:

    Count: 1
    Count: 2
    Final count: 2
    
  3. Create a function that uses a nonlocal variable.

    def outer():
        x = "outer"
    
        def inner():
            nonlocal x
            x = "inner"
            print(f"Inner x: {x}")
    
        inner()
        print(f"Outer x: {x}")
    
    outer()
    

    Expected output:

    Inner x: inner
    Outer x: inner
    

Exercise 4: Recursion

File: functions_and_scope/recursion.py

Create a new file called recursion.py in the functions_and_scope directory and complete the following exercises in this file.

  1. Write a recursive function to calculate the factorial of a number.

    def factorial(n):
        if n == 0 or n == 1:
            return 1
        else:
            return n * factorial(n - 1)
    
    print(factorial(5))
    

    Expected output: 120

  2. Create a recursive function to generate the nth Fibonacci number.

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

    Expected output: 13

Congratulations!

Remember to run each file separately to see the output of your exercises. You can do this by navigating to the appropriate directory in your terminal and running python filename.py (e.g., python basic_functions.py).

File Operations

Python File Operations

File Input/Output and File Handling in Python

File operations are crucial for many programming tasks, allowing you to read from and write to files on your computer. Python provides powerful and easy-to-use functions for file handling.

1. File Input/Output

Opening a File

To work with a file, you first need to open it. The open() function is used for this purpose.

Syntax:

file = open(filename, mode)
  • filename: the name or path of the file
  • mode: specifies the purpose of opening the file (read, write, append, etc.)

Common modes:

  • 'r': Read (default)
  • 'w': Write (overwrites the file if it exists)
  • 'a': Append (adds to the end of the file)
  • 'x': Exclusive creation (fails if the file already exists)
  • 'b': Binary mode
  • 't': Text mode (default)

Example:

# Opening a file for reading
file = open('example.txt', 'r')

# Opening a file for writing
file = open('new_file.txt', 'w')

Closing a File

It's important to close a file after you're done with it to free up system resources.

file.close()

Using with Statement

The with statement is recommended as it automatically closes the file after you're done:

with open('example.txt', 'r') as file:
    # File operations here
    content = file.read()
    print(content)
# File is automatically closed outside the with block

Reading from a File

There are several methods to read from a file:

  1. read(): Reads the entire file
with open('example.txt', 'r') as file:
    content = file.read()
    print(content)
  1. readline(): Reads a single line
with open('example.txt', 'r') as file:
    first_line = file.readline()
    print(first_line)
  1. readlines(): Reads all lines into a list
with open('example.txt', 'r') as file:
    lines = file.readlines()
    for line in lines:
        print(line.strip())  # strip() removes leading/trailing whitespace
  1. Iterating over the file object
with open('example.txt', 'r') as file:
    for line in file:
        print(line.strip())

Writing to a File

  1. write(): Writes a string to the file
with open('new_file.txt', 'w') as file:
    file.write("Hello, World!\n")
    file.write("This is a new line.")
  1. writelines(): Writes a list of strings to the file
lines = ["Line 1\n", "Line 2\n", "Line 3\n"]
with open('new_file.txt', 'w') as file:
    file.writelines(lines)

Appending to a File

To add content to the end of an existing file, use the 'a' mode:

with open('existing_file.txt', 'a') as file:
    file.write("\nThis line is appended.")

2. Text and Binary File Handling

Text Files

Text files contain human-readable characters. When working with text files, Python handles line endings and character encoding.

Example: Reading and writing a CSV file

import csv

# Writing to a CSV file
data = [
    ['Name', 'Age', 'City'],
    ['Alice', '30', 'New York'],
    ['Bob', '25', 'Los Angeles']
]

with open('data.csv', 'w', newline='') as file:
    writer = csv.writer(file)
    writer.writerows(data)

# Reading from a CSV file
with open('data.csv', 'r') as file:
    reader = csv.reader(file)
    for row in reader:
        print(', '.join(row))

Binary Files

Binary files contain data in binary format, which is not human-readable. They are used for non-text data like images, audio files, etc.

Example: Copying an image file

# Copying a binary file (e.g., an image)
with open('original_image.jpg', 'rb') as source:
    with open('copy_image.jpg', 'wb') as dest:
        dest.write(source.read())

Working with JSON Files

JSON is a popular data format. Python's json module makes it easy to work with JSON data.

import json

# Writing JSON to a file
data = {
    "name": "Alice",
    "age": 30,
    "city": "New York"
}

with open('data.json', 'w') as file:
    json.dump(data, file, indent=4)

# Reading JSON from a file
with open('data.json', 'r') as file:
    loaded_data = json.load(file)
    print(loaded_data)

File Pointer and Seeking

You can move the file pointer to different positions using seek():

with open('example.txt', 'r') as file:
    file.seek(5)  # Move to the 6th byte in the file
    print(file.read(10))  # Read 10 characters from that position

Error Handling in File Operations

It's good practice to use try-except blocks when working with files:

try:
    with open('nonexistent_file.txt', 'r') as file:
        content = file.read()
except FileNotFoundError:
    print("The file does not exist.")
except IOError:
    print("An error occurred while reading the file.")

Working with Paths

The os and pathlib modules provide functions for working with file paths:

import os
from pathlib import Path

# Using os
current_dir = os.getcwd()
file_path = os.path.join(current_dir, 'example.txt')

# Using pathlib
current_path = Path.cwd()
file_path = current_path / 'example.txt'

print(f"File exists: {file_path.exists()}")

Temporary Files

For temporary file operations, you can use the tempfile module:

import tempfile

with tempfile.TemporaryFile('w+t') as temp:
    temp.write('This is a temporary file')
    temp.seek(0)
    print(temp.read())
# File is automatically deleted after the with block

File operations are fundamental in many programming tasks. They allow you to persist data, read configurations, process large datasets, and much more. Understanding these concepts will greatly enhance your ability to work with files efficiently in Python.

Summary

File Input/Output:

  • Opening and closing files
  • Using the with statement
  • Reading from files (whole file, line by line, into a list)
  • Writing to files
  • Appending to files

Text and Binary File Handling:

  • Working with text files (including CSV example)
  • Handling binary files
  • JSON file operations
  • File pointer and seeking
  • Error handling in file operations
  • Working with file paths using os and pathlib
  • Using temporary files

Exercise

Python File Operations

These exercises are designed to help you practice working with file operations in Python. Follow each step carefully and try to predict the output before running the code.

Important: Be cautious when working with file operations, especially when deleting or overwriting files. Always make sure you're working in the correct directory and with the intended files.

File Organization

We'll add a new directory called file_operations to your existing file structure. The updated structure will look like this:

csf101-python_exercises/
│
├── basics/
│   ├── numbers.py
│   ├── strings.py
│   └── booleans.py
│
├── data_structures/
│   ├── lists.py
│   └── dictionaries.py
│
├── operators/
│   ├── arithmetic.py
│   ├── assignment.py
│   ├── comparison.py
│   ├── logical.py
│   └── bitwise.py
│
├── control_structures/
│   ├── conditionals.py
│   ├── loops.py
│   └── break_continue.py
│
├── functions_and_scope/
│   ├── basic_functions.py
│   ├── parameters_and_returns.py
│   ├── scope.py
│   └── recursion.py
│
└── file_operations/
    ├── text_files.py
    ├── binary_files.py
    └── file_management.py

Create a new directory called file_operations inside your csf101-python_exercises directory.

Exercise 1: Working with Text Files

File: file_operations/text_files.py

Create a new file called text_files.py in the file_operations directory and complete the following exercises in this file.

  1. Write a function that creates a new text file and writes a few lines to it.

    def create_and_write_file(filename):
        with open(filename, 'w') as file:
            file.write("This is the first line.\n")
            file.write("This is the second line.\n")
            file.write("This is the third line.\n")
    
    create_and_write_file('sample.txt')
    print("File created and written successfully.")
    
  2. Write a function that reads and prints the contents of the file you just created.

    def read_and_print_file(filename):
        with open(filename, 'r') as file:
            content = file.read()
            print(content)
    
    read_and_print_file('sample.txt')
    
  3. Write a function that appends a new line to an existing file.

    def append_to_file(filename, new_line):
        with open(filename, 'a') as file:
            file.write(new_line + "\n")
    
    append_to_file('sample.txt', "This is an appended line.")
    print("Line appended successfully.")
    read_and_print_file('sample.txt')  # Verify the appended line
    
  4. Write a function that reads a file line by line and prints each line with its line number.

    def print_lines_with_numbers(filename):
        with open(filename, 'r') as file:
            for index, line in enumerate(file, start=1):
                print(f"{index}: {line.strip()}")
    
    print_lines_with_numbers('sample.txt')
    
  5. Write a function that counts the number of words in a text file.

    def count_words(filename):
        with open(filename, 'r') as file:
            content = file.read()
            words = content.split()
            return len(words)
    
    word_count = count_words('sample.txt')
    print(f"The file contains {word_count} words.")
    

Exercise 2: Working with Binary Files

File: file_operations/binary_files.py

Create a new file called binary_files.py in the file_operations directory and complete the following exercises in this file.

  1. Write a function that creates a binary file containing some bytes.

    def create_binary_file(filename):
        data = bytes([0, 1, 2, 3, 4, 5])
        with open(filename, 'wb') as file:
            file.write(data)
    
    create_binary_file('binary_sample.bin')
    print("Binary file created successfully.")
    
  2. Write a function that reads and prints the contents of the binary file as bytes.

    def read_binary_file(filename):
        with open(filename, 'rb') as file:
            content = file.read()
            print("Binary content:", content)
    
    read_binary_file('binary_sample.bin')
    
  3. Write a function that appends bytes to an existing binary file.

    def append_to_binary_file(filename, data):
        with open(filename, 'ab') as file:
            file.write(data)
    
    append_to_binary_file('binary_sample.bin', bytes([6, 7, 8, 9]))
    print("Bytes appended to binary file.")
    read_binary_file('binary_sample.bin')  # Verify the appended bytes
    

Exercise 3: File Management

File: file_operations/file_management.py

Create a new file called file_management.py in the file_operations directory and complete the following exercises in this file.

  1. Write a function that checks if a file exists.

    import os
    
    def file_exists(filename):
        return os.path.exists(filename)
    
    print(f"'sample.txt' exists: {file_exists('sample.txt')}")
    print(f"'nonexistent.txt' exists: {file_exists('nonexistent.txt')}")
    
  2. Write a function that renames a file.

    import os
    
    def rename_file(old_name, new_name):
        os.rename(old_name, new_name)
    
    rename_file('sample.txt', 'renamed_sample.txt')
    print("File renamed successfully.")
    print(f"'renamed_sample.txt' exists: {file_exists('renamed_sample.txt')}")
    
  3. Write a function that deletes a file.

    import os
    
    def delete_file(filename):
        if os.path.exists(filename):
            os.remove(filename)
            print(f"{filename} has been deleted.")
        else:
            print(f"{filename} does not exist.")
    
    delete_file('binary_sample.bin')
    
  4. Write a function that creates a new directory.

    import os
    
    def create_directory(directory_name):
        if not os.path.exists(directory_name):
            os.makedirs(directory_name)
            print(f"Directory '{directory_name}' created successfully.")
        else:
            print(f"Directory '{directory_name}' already exists.")
    
    create_directory('new_folder')
    
  5. Write a function that lists all files in a directory.

    import os
    
    def list_files(directory):
        files = os.listdir(directory)
        for file in files:
            print(file)
    
    print("Files in the current directory:")
    list_files('.')
    
  6. Write a function that copies a file from one location to another.

    import shutil
    
    def copy_file(source, destination):
        shutil.copy2(source, destination)
        print(f"File copied from {source} to {destination}")
    
    copy_file('renamed_sample.txt', 'new_folder/copied_sample.txt')
    
  7. Write a function that reads a CSV file and prints its contents.

    import csv
    
    def read_csv_file(filename):
        with open(filename, 'r', newline='') as file:
            csv_reader = csv.reader(file)
            for row in csv_reader:
                print(', '.join(row))
    
    # First, create a sample CSV file
    with open('sample.csv', 'w', newline='') as file:
        csv_writer = csv.writer(file)
        csv_writer.writerow(['Name', 'Age', 'City'])
        csv_writer.writerow(['Alice', '30', 'New York'])
        csv_writer.writerow(['Bob', '25', 'Los Angeles'])
    
    print("Contents of sample.csv:")
    read_csv_file('sample.csv')
    

Congratulations!

Remember to run each file separately to see the output of your exercises. You can do this by navigating to the appropriate directory in your terminal and running python filename.py (e.g., python text_files.py).

Important: Be cautious when working with file operations, especially when deleting or overwriting files. Always make sure you're working in the correct directory and with the intended files.

Unit 1 Challange Ex

Fruit Transaction Analysis

In this challenge, you'll work with a file containing fruit transaction data. Your task is to read the file, process the data, and perform some calculations. Follow the steps below to complete the challenge.

Setup

  1. Make sure you're in the challenge-ex directory.
  2. Create a new file called ex-ch1.py.
  3. Get the input file fruit_transactions.txt from your CSF Tutor (Kamal).

At the end of the challenge, your directory should look like this:

csf101-python_exercises/
│
├── challenge-ex/
    ├── ex-ch1.py                 # Student's solution file
    ├── fruit_transactions.txt    # Input data file
    └── transaction_summary.txt   # Output summary file (created by student's code)

Exercise Steps

  1. Open the file:

    • Use the with statement to open fruit_transactions.txt in read mode.

    Hint: Remember to use the open() function and specify the file mode.

  2. Read the file contents:

    • Read all lines from the file into a list.

    Hint: You can use the readlines() method or a list comprehension.

  3. Process the data:

    • For each line in the file: a. Split the line into its components (name, action, quantity, item, price). b. Convert quantity to an integer and price to a float.
    • Store this processed data in a suitable data structure (e.g., a list of tuples or dictionaries).

    Hint: The split() method will be useful here. Don't forget to handle the newline character.

  4. Calculate total sales:

    • Sum up the total value of all "sold" transactions.
    • Print the result.

    Hint: You'll need to use a loop and an if statement to check the action.

  5. Find the most popular fruit:

    • Determine which fruit was involved in the most transactions (regardless of action).
    • Print the fruit name and the number of transactions.

    Hint: Consider using a dictionary to keep track of fruit counts.

  6. Calculate average transaction value:

    • Compute the average value of all transactions (price * quantity).
    • Print the result rounded to two decimal places.

    Hint: You'll need to keep a running sum and count of transactions.

  7. Identify the biggest spender:

    • Find the person who spent the most money on "bought" transactions.
    • Print their name and the total amount they spent.

    Hint: Another good use case for a dictionary!

  8. Write a summary report:

    • Create a new file called transaction_summary.txt.
    • Write a summary of your findings (total sales, most popular fruit, average transaction value, biggest spender) to this file.

    Hint: Use the with statement again, but this time open the file in write mode.

Bonus Challenge

If you finish early, try to create a simple bar chart using ASCII characters to visualize the popularity of each fruit.

Remember to add comments to your code explaining what each section does. Good luck!

Recursion Examples

For each problem, try to solve it on your own first. When you're ready to see the solution, click on the dropdown to reveal it.

1. Sum of numbers from 1 to n

Write a recursive function to calculate the sum of all numbers from 1 to n.

Click to see solution
def sum_to_n(n):
    if n == 1:
        return 1
    else:
        return n + sum_to_n(n - 1)

2. Product of numbers from 1 to n (factorial)

Implement a recursive function to compute the product of all numbers from 1 to n (essentially, the factorial of n).

Click to see solution
def factorial(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n - 1)

3. Print numbers from n to 1

Create a recursive function to print all numbers from n to 1 (backwards).

Click to see solution
def print_backward(n):
    if n == 0:
        return
    else:
        print(n)
        print_backward(n - 1)

4. Calculate nth power

Develop a recursive function to calculate the nth power of a given number.

Click to see solution
def power(base, exponent):
    if exponent == 0:
        return 1
    else:
        return base * power(base, exponent - 1)

5. Check if string is palindrome

Write a recursive function to determine if a given string is a palindrome.

Click to see solution
def is_palindrome(s):
    if len(s) <= 1:
        return True
    else:
        return s[0] == s[-1] and is_palindrome(s[1:-1])

Implement a recursive binary search algorithm.

Click to see solution
def binary_search(arr, target, low, high):
    if low > high:
        return -1
    mid = (low + high) // 2
    if arr[mid] == target:
        return mid
    elif arr[mid] > target:
        return binary_search(arr, target, low, mid - 1)
    else:
        return binary_search(arr, target, mid + 1, high)

7. Greatest Common Divisor (GCD)

Create a recursive function to find the greatest common divisor (GCD) of two numbers using the Euclidean algorithm.

Click to see solution
def gcd(a, b):
    if b == 0:
        return a
    else:
        return gcd(b, a % b)

8. Fibonacci sequence

Develop a recursive function to generate the nth term of the Fibonacci sequence.

Click to see solution
def fibonacci(n):
    if n <= 1:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

9. Count occurrences of a digit

Write a recursive function to count the number of occurrences of a specific digit in a given number.

Click to see solution
def count_digit(number, digit):
    if number == 0:
        return 0
    if number % 10 == digit:
        return 1 + count_digit(number // 10, digit)
    return count_digit(number // 10, digit)

10. Reverse a string

Implement a recursive function to reverse a given string.

Click to see solution
def reverse_string(s):
    if len(s) <= 1:
        return s
    else:
        return reverse_string(s[1:]) + s[0]

11. Sum of digits

Create a recursive function to find the sum of digits of a given number.

Click to see solution
def sum_of_digits(n):
    if n < 10:
        return n
    else:
        return n % 10 + sum_of_digits(n // 10)

12. Check if array is sorted

Develop a recursive function to check if a given array is sorted in ascending order.

Click to see solution
def is_sorted(arr):
    if len(arr) <= 1:
        return True
    return arr[0] <= arr[1] and is_sorted(arr[1:])

13. Calculate string length

Write a recursive function to calculate the length of a string without using any built-in functions.

Click to see solution
def string_length(s):
    if s == "":
        return 0
    else:
        return 1 + string_length(s[1:])

14. Find minimum element in array

Implement a recursive function to find the minimum element in an array.

Click to see solution
def find_min(arr):
    if len(arr) == 1:
        return arr[0]
    else:
        return min(arr[0], find_min(arr[1:]))

15. Generate string permutations

Create a recursive function to generate all possible permutations of a string.

Click to see solution
def permutations(s):
    if len(s) <= 1:
        return [s]
    else:
        perms = []
        for i, char in enumerate(s):
            for perm in permutations(s[:i] + s[i+1:]):
                perms.append(char + perm)
        return perms

16. Convert decimal to binary

Develop a recursive function to convert a decimal number to its binary representation.

Click to see solution
def decimal_to_binary(n):
    if n == 0:
        return "0"
    elif n == 1:
        return "1"
    else:
        return decimal_to_binary(n // 2) + str(n % 2)

17. Tower of Hanoi

Write a recursive function to solve the Tower of Hanoi problem.

Click to see solution
def tower_of_hanoi(n, source, auxiliary, destination):
    if n == 1:
        print(f"Move disk 1 from {source} to {destination}")
        return
    tower_of_hanoi(n-1, source, destination, auxiliary)
    print(f"Move disk {n} from {source} to {destination}")
    tower_of_hanoi(n-1, auxiliary, source, destination)

18. Flatten a nested list

Implement a recursive function to flatten a nested list.

Click to see solution
def flatten_list(nested_list):
    if not nested_list:
        return []
    if isinstance(nested_list[0], list):
        return flatten_list(nested_list[0]) + flatten_list(nested_list[1:])
    return [nested_list[0]] + flatten_list(nested_list[1:])

19. Sum of elements in a linked list

Create a recursive function to calculate the sum of elements in a linked list.

Click to see solution
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def sum_linked_list(head):
    if not head:
        return 0
    return head.val + sum_linked_list(head.next)

20. Check if a number is prime

Develop a recursive function to determine if a number is prime.

Click to see solution
def is_prime(n, divisor=2):
    if n <= 2:
        return n == 2
    if n % divisor == 0:
        return False
    if divisor * divisor > n:
        return True
    return is_prime(n, divisor + 1)

Introduction to storing data: The "WHY?"

1. Introduction to Storing Data

Why it matters:

Understanding how data is stored is fundamental to computer science and programming. It directly impacts how efficiently a program can run and how effectively it can utilize a computer's resources.

Historical context:

In the early days of computing, memory was extremely limited and expensive. Programmers had to be incredibly efficient with how they stored and manipulated data.

As computers evolved, memory became more abundant, but the principles of efficient data storage remained crucial for performance.

Old Computers

How computers work:

At its core, a computer stores data in binary format (0s and 1s) in its memory. How this data is organized and accessed can significantly affect the speed and efficiency of operations.

Different data structures provide different ways to organize this binary data for various use cases.

Evolution:

As programming languages evolved from low-level (like assembly) to high-level languages, abstractions were created to make data storage more intuitive. However, understanding the underlying principles remains crucial for writing efficient code.

2. Static vs Dynamic Data Structures

Why it matters:

The choice between static and dynamic data structures impacts memory usage, performance, and the flexibility of your program. Understanding both types allows programmers to make informed decisions based on their specific needs.

How computers work:

  • Static structures: These have a fixed size, allocated at compile time. They're stored in a part of memory called the stack, which is fast but limited in size.
  • Dynamic structures: These can grow or shrink at runtime. They're stored in the heap, a larger but slower part of memory.

Stack and a Heap

Historical context:

Early programs primarily used static structures due to limited memory and the need for predictability. As memory became more abundant and programs more complex, dynamic structures became increasingly important.

Evolution:

  1. Early days: Programs used mostly static arrays and structures.
  2. Introduction of pointers: Allowed for more flexible memory management.
  3. Development of dynamic allocation: Enabled the creation of data structures that could grow and shrink as needed.
  4. Modern era: High-level languages often abstract away the details, but understanding the underlying principles remains crucial for optimization.

Impact on programming:

  • Static structures are still used for their speed and simplicity in certain scenarios.
  • Dynamic structures enable more flexible and scalable programs, crucial for modern software development.
  • Understanding both allows programmers to make informed decisions based on performance needs, memory constraints, and problem requirements.

In conclusion, learning about data structures, including the fundamental concepts of data storage and the distinction between static and dynamic structures, is essential for any programmer. It provides the foundation for writing efficient, scalable, and robust software, regardless of the evolution of computer hardware and programming languages.

Elementary Data Structure: The Array

Definition

An Array is a fundamental data structure that stores a fixed-size sequential collection of elements of the same type.

Array Representation

Key Properties

  1. Fixed Size: Once declared, the size of an array is fixed.
  2. Homogeneous: All elements in an array must be of the same data type.
  3. Contiguous Memory: Elements are stored in contiguous memory locations.
  4. Zero-Indexed: The first element is typically accessed with index 0 (in most programming languages).
  5. Random Access: Elements can be accessed directly using their index.

*Time Complexity

  • Access: O(1)
  • Search: O(n) for unsorted, O(log n) for sorted (using binary search)
  • Insertion: O(n)
  • Deletion: O(n)

Memory Usage

  • Memory = (size of data type) * (number of elements)

Advantages

  1. Simple and easy to use
  2. Fast access to elements (constant time)
  3. Efficient for storing and accessing sequential data

Disadvantages

  1. Fixed size (can't be changed after declaration)
  2. Inefficient insertion and deletion
  3. Wasted memory if not all elements are used

*Common Operations

  1. Traversal: Visiting each element of the array
  2. Insertion: Adding an element at a given index
  3. Deletion: Removing an element from a given index
  4. Search: Finding the index of a given element
  5. Update: Modifying the value of an existing element

*Types of Arrays

  1. One-dimensional: Linear array (e.g., [1, 2, 3, 4, 5])
  2. Multi-dimensional (Matrices): Array of arrays (e.g., 2D array: [[1, 2], [3, 4]])

Use Cases

  1. Storing and manipulating sequential data
  2. Implementing other data structures (e.g., stacks, queues)
  3. Buffering in I/O operations
  4. Lookup tables and hash tables

Real World Applications

Arrays are versatile data structures that can be applied to solve various real-world problems. Here are some examples:

  1. Image Processing

    • Representing digital images as 2D arrays of pixels
    • Applying filters and transformations to images
  2. Financial Applications

    • Storing and analyzing time series data (e.g., stock prices over time)
    • Managing portfolios and calculating returns
  3. Inventory Management

    • Tracking product quantities and locations in warehouses
    • Managing stock levels and reordering
  4. Scheduling and Calendars

    • Representing days, weeks, or months in calendar applications
    • Managing time slots for appointments or reservations
  5. Sensor Data Collection

    • Storing readings from multiple sensors over time
    • Analyzing environmental data (e.g., temperature, humidity)
  6. Game Development

    • Representing game boards (e.g., chess, tic-tac-toe)
    • Managing character inventories or attributes
  7. Audio Processing

    • Storing and manipulating audio samples
    • Implementing digital audio effects
  8. Database Systems

    • Implementing index structures for faster data retrieval
    • Storing and managing records in memory
  9. Text Editors and Word Processors

    • Storing lines of text for efficient editing and display
    • Implementing undo/redo functionality
  10. Network Packet Management

    • Buffering and processing network packets
    • Implementing network protocols
  11. Scientific Computing

    • Storing and manipulating matrices for linear algebra operations
    • Implementing numerical methods and simulations
  12. Geographic Information Systems (GIS)

    • Representing map data as grids
    • Storing and analyzing spatial information
  13. Memory Management in Operating Systems

    • Managing memory allocation and deallocation
    • Implementing page tables for virtual memory
  14. Compiler Design

    • Storing and manipulating tokens during lexical analysis
    • Managing symbol tables
  15. Data Compression

    • Implementing run-length encoding
    • Storing frequency tables for Huffman coding

Memory Techniques for Retention

  1. Visualization: Imagine an array as a row of boxes, each containing a value.
  2. Analogy: Compare an array to building with ground floor 0.
  3. Acronym: FHCRZ (Fixed, Homogeneous, Contiguous, Random access, Zero-indexed)

Code Example (Python)

# Creating an array
fruits = ["apple", "banana", "cherry", "date", "elderberry"]

# Accessing elements
print(fruits[0])  # Output: apple
print(fruits[-1])  # Output: elderberry

# Updating an element
fruits[1] = "blackberry"

# Traversing the array
for fruit in fruits:
    print(fruit)

# Finding the length
print(len(fruits))  # Output: 5

# Slicing
print(fruits[1:4])  # Output: ['blackberry', 'cherry', 'date']

List (Dynamic Array) Data Structure

Definition

A List, also known as a Dynamic Array, is a data structure that allows elements to be added or removed, and can grow or shrink in size automatically.

List Data Structure

Key Properties

  1. Dynamic Size: Can grow or shrink as needed.
  2. Heterogeneous (in some languages): Can store elements of different data types (e.g., in Python).
  3. Contiguous Memory: Elements are stored in contiguous memory locations.
  4. Zero-Indexed: The first element is typically accessed with index 0 (in most programming languages).
  5. Random Access: Elements can be accessed directly using their index.

Time Complexity

  • Access: O(1)
  • Search: O(n) for unsorted, O(log n) for sorted (using binary search)
  • Insertion: O(1) amortized for append, O(n) for arbitrary position
  • Deletion: O(n)

Memory Usage

  • Initial Memory = (size of data type) * (initial capacity)
  • Grows dynamically, often doubling in size when capacity is reached

Advantages

  1. Flexible size (can grow or shrink as needed)
  2. Fast access to elements (constant time)
  3. Efficient for storing and accessing sequential data
  4. Supports various built-in operations and methods

Disadvantages

  1. Slower insertions and deletions compared to linked lists
  2. May waste some memory due to over-allocation
  3. Resizing operations can be costly

Common Operations

  1. Append: Adding an element to the end of the list
  2. Insert: Adding an element at a specific index
  3. Remove: Deleting an element by value or index
  4. Pop: Removing and returning the last element
  5. Index: Finding the position of a given element
  6. Slice: Extracting a portion of the list
  7. Sort: Arranging elements in a specific order

Implementation Details

  1. Resizing: When capacity is reached, a new, larger array is allocated (typically 2x size)
  2. Amortized Analysis: Explains O(1) average time for append operations

Use Cases

  1. Implementing stacks and queues
  2. Managing collections of data in memory
  3. Representing polynomials or sparse matrices
  4. Building more complex data structures

Memory Techniques for Retention

  1. Visualization: Imagine a rubber band that can stretch to accommodate more items
  2. Analogy: Compare a list to an accordion folder that can expand or contract
  3. Acronym: DCHRO (Dynamic, Contiguous, Heterogeneous, Resizable, Operations-rich)
  4. Chunking: Group properties into categories (e.g., structural, performance, operations)

Code Example (Python)

# Creating a list
fruits = ["apple", "banana", "cherry"]

# Appending elements
fruits.append("date")
fruits.extend(["elderberry", "fig"])

# Inserting at a specific index
fruits.insert(1, "blackberry")

# Removing elements
fruits.remove("cherry")
last_fruit = fruits.pop()

# Accessing elements
print(fruits[0])  # Output: apple
print(fruits[-1])  # Output: elderberry

# Slicing
print(fruits[1:4])  # Output: ['blackberry', 'banana', 'date']

# Sorting
fruits.sort()
print(fruits)  # Output: ['apple', 'banana', 'blackberry', 'date', 'elderberry']

# List comprehension
squares = [x**2 for x in range(5)]
print(squares)  # Output: [0, 1, 4, 9, 16]

Stack Data Structure

Definition

A Stack is a linear data structure that follows the Last-In-First-Out (LIFO) principle. Elements are added to and removed from the same end, called the "top" of the stack.

Stack Visualization

Key Properties

  1. LIFO (Last-In-First-Out): The last element added is the first one to be removed.
  2. Single-ended: Elements are added and removed only from one end (the top).
  3. Abstract Data Type (ADT): Can be implemented using arrays or linked lists.
  4. Dynamic Size: Typically grows and shrinks as elements are pushed and popped.

Basic Operations

  1. Push: Add an element to the top of the stack
  2. Pop: Remove and return the top element from the stack
  3. Peek/Top: View the top element without removing it
  4. isEmpty: Check if the stack is empty

Time Complexity

  • Push: O(1)
  • Pop: O(1)
  • Peek: O(1)
  • Search: O(n)

Memory Usage

  • Depends on the underlying implementation (array-based or linked list-based)
  • Memory = (size of data type) * (number of elements)

Advantages

  1. Simple and easy to implement
  2. Efficient insertion and deletion (constant time)
  3. Memory efficient (when implemented with a linked list)
  4. Useful for tracking state in algorithms

Disadvantages

  1. Limited access (only to the top element)
  2. Not suitable for certain types of data access patterns
  3. Potential for stack overflow if not managed properly

Common Use Cases

  1. Function call stack in programming languages
  2. Undo mechanisms in text editors
  3. Expression evaluation and syntax parsing
  4. Backtracking algorithms
  5. Browser history (back button functionality)

Real-World Applications of Stack Data Structure

  1. Web Browsing History
  • Scenario: Implementing the "Back" button in web browsers.
  • How Stack is Used: Each visited page URL is pushed onto a stack. When the user clicks "Back", the top URL is popped and loaded.
  1. Undo Functionality in Software
  • Scenario: Providing undo capability in text editors, graphic design software, etc.
  • How Stack is Used: Each action is pushed onto a stack. When the user requests an undo, the last action is popped and reversed.
  1. Function Call Management in Programming
  • Scenario: Managing function calls and local variables in program execution.
  • How Stack is Used: When a function is called, its context (parameters, local variables, return address) is pushed onto the call stack. When the function returns, its context is popped.
  1. Expression Evaluation in Calculators
  • Scenario: Evaluating mathematical expressions, especially those with parentheses.
  • How Stack is Used: Operators and operands are pushed and popped from the stack to handle operator precedence and nested expressions.
  1. Backtracking Algorithms
  • Scenario: Solving mazes, puzzles, or in game AI for chess.
  • How Stack is Used: Possible moves or states are pushed onto the stack. If a path leads to a dead-end, the program backtracks by popping the stack.
  1. Syntax Parsing in Compilers
  • Scenario: Checking for balanced parentheses or brackets in code.
  • How Stack is Used: Opening brackets are pushed onto the stack. Each closing bracket is matched with the top of the stack.
  1. Memory Management
  • Scenario: Managing memory allocation in operating systems.
  • How Stack is Used: Memory blocks are allocated and deallocated in a LIFO manner for efficient memory management.
  1. Recursion Simulation
  • Scenario: Implementing or optimizing recursive algorithms.
  • How Stack is Used: Instead of using actual recursion, which can lead to stack overflow, an explicit stack can be used to simulate recursive calls.
  1. Graph Algorithms
  • Scenario: Depth-First Search (DFS) in graph traversal.
  • How Stack is Used: Vertices to be visited are pushed onto the stack. The algorithm pops a vertex, processes it, and pushes its unvisited neighbors.
  1. Clipboard History
  • Scenario: Maintaining a history of copied items in an operating system.
  • How Stack is Used: Each copied item is pushed onto a stack. Users can cycle through previous copies by popping from the stack.
  1. Call Center Systems
  • Scenario: Managing customer service calls in a Last-In-First-Out manner.
  • How Stack is Used: Incoming calls are pushed onto a stack. The most recent caller is served first (popped) when an agent becomes available.
  1. Plate Stacking in Restaurants
  • Scenario: Managing a stack of plates in a cafeteria or buffet.
  • How Stack is Used: Clean plates are pushed onto the stack. Diners take plates from the top (pop operation).

Variations

  1. Min Stack: Keeps track of the minimum element
  2. Max Stack: Keeps track of the maximum element
  3. Double-ended Stack: Allows push and pop from both ends

Implementation Approaches

  1. Array-based: Uses a dynamic array to store elements
  2. Linked List-based: Uses a singly linked list with head as top

Memory Techniques for Retention

  1. Visualization: Imagine a stack of plates where you can only add or remove from the top
  2. Analogy: Compare to a Pringles can - you can only add or remove chips from the top
  3. Acronym: LIPS (Last-In, Push-Pop Stack)
  4. Mnemonic: "Last to arrive, first to leave" (like at a party)

Code Example (Python)

class Stack:
    def __init__(self):
        self.items = []

    def is_empty(self):
        return len(self.items) == 0

    def push(self, item):
        self.items.append(item)

    def pop(self):
        if not self.is_empty():
            return self.items.pop()
        else:
            raise IndexError("Stack is empty")

    def peek(self):
        if not self.is_empty():
            return self.items[-1]
        else:
            raise IndexError("Stack is empty")

    def size(self):
        return len(self.items)

# Usage example
stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)

print(stack.pop())  # Output: 3
print(stack.peek())  # Output: 2
print(stack.size())  # Output: 2

Queue Data Structure

Definition

A Queue is a linear data structure that follows the First-In-First-Out (FIFO) principle. Elements are added at one end (rear) and removed from the other end (front).

Queue Data Structure

Key Properties

  1. FIFO (First-In-First-Out): The first element added is the first one to be removed.
  2. Two-ended: Elements are added at the rear and removed from the front.
  3. Abstract Data Type (ADT): Can be implemented using arrays or linked lists.
  4. Dynamic Size: Typically grows and shrinks as elements are enqueued and dequeued.

Basic Operations

  1. Enqueue: Add an element to the rear of the queue
  2. Dequeue: Remove and return the front element from the queue
  3. Front: View the front element without removing it
  4. isEmpty: Check if the queue is empty
  5. Size: Get the number of elements in the queue

Time Complexity

  • Enqueue: O(1)
  • Dequeue: O(1)
  • Front: O(1)
  • isEmpty: O(1)
  • Size: O(1)

Memory Usage

  • Depends on the underlying implementation (array-based or linked list-based)
  • Memory = (size of data type) * (number of elements)

Advantages

  1. Maintains order of insertion
  2. Efficient insertion and deletion (constant time)
  3. Useful for managing resources and scheduling
  4. Supports both synchronous and asynchronous processing

Disadvantages

  1. Fixed size in array-based implementation (can be mitigated with circular queue)
  2. Potential for queue overflow or underflow if not managed properly
  3. No random access to elements

Common Use Cases

  1. Task scheduling in operating systems
  2. Breadth-First Search algorithm in graphs
  3. Print job spooling
  4. Handling of requests on a single shared resource (e.g., CPU)
  5. Buffering for data streams

Real World Application

Queues are widely used in computer science and everyday life to manage processes where first-come, first-served order is important. Here are some practical applications:

  1. Customer Service Systems
  • Call Centers: Incoming calls are placed in a queue and answered in the order they were received.
  • Help Desk Ticketing: Support requests are processed in the order they are submitted.
  1. Transportation and Logistics
  • Traffic Management: Vehicles at a traffic signal are served in the order they arrive.
  • Airline Boarding: Passengers board the plane based on their order in the queue.
  • Shipping and Delivery: Orders are processed and shipped based on their order in the queue.
  1. Operating Systems
  • CPU Scheduling: Processes waiting for CPU time are managed in a queue.
  • Print Spooling: Print jobs are processed in the order they are received.
  • I/O Buffer Management: Data is read from or written to devices in a queued order.
  1. Networking
  • Routers and Switches: Network packets are queued before being transmitted.
  • Web Servers: HTTP requests are often processed in a FIFO manner.
  1. Healthcare
  • Emergency Room Triage: While not strictly FIFO due to severity considerations, queues help manage patient wait times.
  • Organ Donation Lists: Patients waiting for organ transplants are often managed in a queue-like system.
  1. Entertainment and Services
  • Ticket Sales: Online queuing systems for high-demand event tickets.
  • Amusement Park Ride Lines: Physical queues for managing ride access.
  • Streaming Services: Video or audio buffers use queues to ensure smooth playback.
  1. Manufacturing and Production
  • Assembly Lines: Products move through stages of assembly in a queue-like manner.
  • Inventory Management: First-In-First-Out (FIFO) method for perishable goods.
  1. Software and Web Development
  • Task Scheduling: Background jobs or tasks are often managed in queues.
  • Message Brokers: Systems like RabbitMQ use queues to manage message passing between services.
  1. Financial Systems
  • Transaction Processing: Banking transactions are often processed in the order they are received.
  • Stock Market Orders: Some types of stock market orders are processed in a FIFO manner.
  1. Education
  • Course Waitlists: Students are added to course waitlists in the order they apply.
  • Grading Systems: Some professors grade assignments in the order they were submitted.

Variations

  1. Circular Queue: Efficient use of fixed-size array
  2. Double-ended Queue (Deque): Allows insertion and deletion at both ends
  3. Priority Queue: Elements have associated priorities

Implementation Approaches

  1. Array-based: Uses a dynamic or circular array to store elements
  2. Linked List-based: Uses a singly linked list with front and rear pointers

Memory Techniques for Retention

  1. Visualization: Imagine a line of people waiting for a bus - first in line is first to board
  2. Analogy: Compare to a pipe where items enter one end and exit the other
  3. Acronym: FIFE (First-In, First-Exit)
  4. Mnemonic: "First to arrive, first to leave" (like at a bakery)

Code Example (Python)

from collections import deque

class Queue:
    def __init__(self):
        self.items = deque()

    def is_empty(self):
        return len(self.items) == 0

    def enqueue(self, item):
        self.items.append(item)

    def dequeue(self):
        if not self.is_empty():
            return self.items.popleft()
        else:
            raise IndexError("Queue is empty")

    def front(self):
        if not self.is_empty():
            return self.items[0]
        else:
            raise IndexError("Queue is empty")

    def size(self):
        return len(self.items)

# Usage example
queue = Queue()
queue.enqueue("Task 1")
queue.enqueue("Task 2")
queue.enqueue("Task 3")

print(queue.dequeue())  # Output: Task 1
print(queue.front())    # Output: Task 2
print(queue.size())     # Output: 2

Remember: Implement and experiment with queues in your preferred programming language to reinforce your understanding!

Deque (Double-ended Queue) Data Structure

Definition

A Deque (pronounced "deck") is a linear data structure that allows insertion and deletion of elements from both ends. It combines the features of both stacks and queues.

Dequeue Data Structure

Key Properties

  1. Double-ended: Elements can be added or removed from both front and rear.
  2. Flexible: Supports both LIFO (Last-In-First-Out) and FIFO (First-In-First-Out) operations.
  3. Abstract Data Type (ADT): Can be implemented using dynamic arrays or doubly linked lists.
  4. Dynamic Size: Typically grows and shrinks as elements are added and removed.

Basic Operations

  1. insertFront: Add an element to the front of the deque
  2. insertRear: Add an element to the rear of the deque
  3. deleteFront: Remove and return the front element
  4. deleteRear: Remove and return the rear element
  5. getFront: View the front element without removing it
  6. getRear: View the rear element without removing it
  7. isEmpty: Check if the deque is empty
  8. size: Get the number of elements in the deque

Time Complexity

  • All basic operations (insertFront, insertRear, deleteFront, deleteRear, getFront, getRear): O(1)
  • isEmpty: O(1)
  • size: O(1)

Memory Usage

  • Depends on the underlying implementation (array-based or linked list-based)
  • Memory = (size of data type) * (number of elements) + overhead for pointers (in linked list implementation)

Advantages

  1. Combines functionality of both stacks and queues
  2. Efficient insertion and deletion at both ends (constant time)
  3. Flexible for various use cases
  4. Supports both LIFO and FIFO operations

Disadvantages

  1. More complex implementation compared to simple stacks or queues
  2. Slightly higher memory usage due to additional pointers (in linked list implementation)
  3. No random access to elements (except in array-based implementations)

Common Use Cases

  1. Implementing undo-redo functionality
  2. Managing work stealing in multiprocessing environments
  3. Palindrome checking
  4. Sliding window problems in algorithms
  5. Browser history (forward and backward navigation)

Real-World Use Cases for Deques (Double-ended Queues)

  1. Undo-Redo Functionality in Applications
  • Scenario: Text editors, graphic design software, or any application with undo-redo features.
  • Solution: Use a deque to store user actions. Recent actions are added to one end for undo, and when undone, they're moved to the other end for potential redo.
  • Benefit: Efficient O(1) operations for both undo and redo actions.
  1. Browser History Navigation
  • Scenario: Implementing forward and backward navigation in web browsers.
  • Solution: Use a deque to store visited web pages. The current page is at one end, previously visited pages at the other. New pages are added to the front, and navigation uses both ends.
  • Benefit: Quick access to both recently visited and older pages.
  1. Task Scheduling in Operating Systems
  • Scenario: Managing processes or tasks in a multi-tasking environment.
  • Solution: Use a deque for task queues. High-priority tasks can be added to the front, while regular tasks are added to the rear.
  • Benefit: Flexible prioritization of tasks without separate data structures.
  1. Palindrome Checking
  • Scenario: Efficiently checking if a word or phrase is a palindrome.
  • Solution: Use a deque to store characters. Compare characters from both ends, moving inwards.
  • Benefit: O(n/2) comparisons, with easy handling of even and odd-length strings.
  1. Sliding Window Problems in Data Analysis
  • Scenario: Analyzing time series data or streaming data with a fixed-size window.
  • Solution: Use a deque to represent the sliding window. Add new elements to one end and remove old elements from the other.
  • Benefit: Efficient updates to the window contents as it slides over the data.
  1. A-Steal Work Scheduling Algorithm
  • Scenario: Load balancing in parallel computing environments.
  • Solution: Each processor maintains a deque of tasks. Busy processors add tasks to one end, while idle processors can steal tasks from the other end of busy processors' deques.
  • Benefit: Efficient work distribution and load balancing.
  1. Network Packet Processing
  • Scenario: Managing network packets in routers or switches.
  • Solution: Use deques to handle packet queues. High-priority packets can be inserted at the front, while normal packets are added to the rear.
  • Benefit: Flexible packet prioritization and efficient processing.
  1. Music Player Playlist Management
  • Scenario: Implementing features like "play next" and "add to queue" in music players.
  • Solution: Use a deque to manage the playlist. New songs can be added to either the front ("play next") or the back ("add to queue").
  • Benefit: Flexible playlist management with efficient insertions at both ends.
  1. Customer Service Queue Management
  • Scenario: Managing customer service requests with priority handling.
  • Solution: Use a deque for the customer queue. VIP customers can be added to the front, while regular customers join at the rear.
  • Benefit: Efficient prioritization without multiple separate queues.
  1. Memory-Efficient Caching
  • Scenario: Implementing a cache with both FIFO and LIFO eviction policies.
  • Solution: Use a deque to store cached items. New items can be added to one end, and eviction can happen from either end based on the desired policy.
  • Benefit: Flexible caching strategy with efficient insertions and deletions.

Remember: The key advantage of deques in these scenarios is their ability to efficiently handle operations at both ends, providing flexibility that single-ended data structures like stacks or queues cannot match.

Variations

  1. Scroll Buffer: Used in text editors for efficient insertion and deletion
  2. A-Steal Deque: Used in work-stealing schedulers

Implementation Approaches

  1. Array-based: Uses a dynamic circular array
  2. Linked List-based: Uses a doubly linked list

Memory Techniques for Retention

  1. Visualization: Imagine a double-ended subway train where passengers can board and exit from both ends
  2. Analogy: Compare to a deck of cards where you can add or remove cards from both top and bottom
  3. Acronym: DIDO (Double-In, Double-Out)
  4. Mnemonic: "First or last, in or out, deque handles it all about"

Code Example (Python)

from collections import deque

class Deque:
    def __init__(self):
        self.items = deque()

    def is_empty(self):
        return len(self.items) == 0

    def insert_front(self, item):
        self.items.appendleft(item)

    def insert_rear(self, item):
        self.items.append(item)

    def delete_front(self):
        if not self.is_empty():
            return self.items.popleft()
        else:
            raise IndexError("Deque is empty")

    def delete_rear(self):
        if not self.is_empty():
            return self.items.pop()
        else:
            raise IndexError("Deque is empty")

    def get_front(self):
        if not self.is_empty():
            return self.items[0]
        else:
            raise IndexError("Deque is empty")

    def get_rear(self):
        if not self.is_empty():
            return self.items[-1]
        else:
            raise IndexError("Deque is empty")

    def size(self):
        return len(self.items)

# Usage example
deque = Deque()
deque.insert_front("A")
deque.insert_rear("B")
deque.insert_front("C")

print(deque.delete_front())  # Output: C
print(deque.delete_rear())   # Output: B
print(deque.get_front())     # Output: A
print(deque.size())          # Output: 1

Linked List Data Structure

Definition

A Linked List is a linear data structure where elements are stored in nodes. Each node contains a data field and a reference (or link) to the next node in the sequence.

Linked List Concept

Key Properties

  1. Dynamic Size: Can grow or shrink in size during execution.
  2. Non-contiguous Memory: Nodes can be stored anywhere in memory.
  3. Efficient Insertion/Deletion: Adding or removing elements doesn't require shifting other elements.
  4. Sequential Access: Elements are accessed sequentially starting from the first node.

Types of Linked Lists

  1. Singly Linked List: Each node points to the next node.
  2. Doubly Linked List: Each node has pointers to both next and previous nodes.
  3. Circular Linked List: Last node points back to the first node.

Basic Components

  1. Node: Contains data and pointer(s) to other node(s).
  2. Head: Points to the first node in the list.
  3. Tail: Points to the last node in the list (in some implementations).

Basic Operations

  1. Insertion: Add a new node (at the beginning, end, or middle).
  2. Deletion: Remove a node (from the beginning, end, or middle).
  3. Traversal: Visit each node in the list.
  4. Search: Find a node with a specific value.

Time Complexity

  • Access: O(n)
  • Search: O(n)
  • Insertion: O(1) if inserting at known position, O(n) if searching first
  • Deletion: O(1) if deleting known position, O(n) if searching first

Memory Usage

  • Memory = (size of data + size of pointer) * (number of nodes)
  • Additional memory for head (and tail) pointers

Advantages

  1. Dynamic size
  2. Efficient insertions and deletions
  3. No memory wastage (allocates exact memory required)
  4. Implementation of other data structures (stacks, queues, etc.)

Disadvantages

  1. Sequential access (no random access)
  2. Extra memory for pointers
  3. Not cache-friendly due to non-contiguous memory allocation

Common Use Cases

  1. Implementation of stacks, queues, and graphs
  2. Undo functionality in applications
  3. Hash tables (chaining for collision resolution)
  4. Polynomial arithmetic
  5. Music playlists

Real-World Applications of Linked Lists

Linked Lists are versatile data structures that find applications in various real-world scenarios. Here are some practical situations where Linked Lists are commonly used:

  1. Music Player Playlists
  • Scenario: Managing a list of songs in a music player.
  • Application: Each node represents a song, containing metadata and a pointer to the next song.
  • Benefit: Easy to add, remove, or reorder songs without affecting the entire playlist.
  1. Browser History
  • Scenario: Implementing forward and backward navigation in web browsers.
  • Application: Each node represents a webpage, with links to both previous and next pages (doubly linked list).
  • Benefit: Efficient navigation through browser history in both directions.
  1. Image Viewer Carousel
  • Scenario: Creating a circular image gallery or slideshow.
  • Application: Each node contains an image, with the last image linking back to the first (circular linked list).
  • Benefit: Smooth, continuous navigation through images in both directions.
  1. Undo Functionality in Text Editors
  • Scenario: Implementing undo/redo features in word processors or text editors.
  • Application: Each node represents a state of the document, allowing easy traversal through edit history.
  • Benefit: Efficient storage and navigation of document states for undo/redo operations.
  1. Memory Management in Operating Systems
  • Scenario: Managing free memory blocks in an operating system.
  • Application: Each node represents a free memory block, easily split or merged as needed.
  • Benefit: Efficient allocation and deallocation of memory without fragmentation.
  1. Job Queue in Print Spoolers
  • Scenario: Managing print jobs in a printer queue.
  • Application: Each node represents a print job, easily added or removed from the queue.
  • Benefit: Flexible management of print jobs, allowing priority insertions or cancellations.
  1. Polynomial Arithmetic
  • Scenario: Representing and manipulating polynomials in mathematical software.
  • Application: Each node represents a term in the polynomial, with easy insertion and removal of terms.
  • Benefit: Efficient storage and manipulation of polynomials with varying numbers of terms.
  1. Card Games
  • Scenario: Implementing a deck of cards in digital card games.
  • Application: Each node represents a card, allowing easy shuffling, dealing, and returning to the deck.
  • Benefit: Flexible manipulation of the deck without need for shifting elements.
  1. Symbol Tables in Compilers
  • Scenario: Managing symbols (variables, functions) during compilation.
  • Application: Each node represents a symbol, with easy insertion, deletion, and lookup.
  • Benefit: Efficient management of symbols with varying lifetimes during compilation.
  1. Social Media Feed
  • Scenario: Implementing an infinite scroll feature in social media apps.
  • Application: Each node represents a post, with new posts easily added to the top or bottom of the feed.
  • Benefit: Efficient insertion of new content and removal of old content in the feed.

Remember: These applications leverage the key strengths of Linked Lists, such as dynamic size, efficient insertions and deletions, and flexible memory allocation. Understanding these real-world use cases can provide valuable context for when to consider using Linked Lists in your own projects.

Variations

  1. Skip List: Multiple layers of linked lists for faster searching
  2. Unrolled Linked List: Storing multiple elements in each node
  3. XOR Linked List: Memory-efficient doubly linked list using bitwise XOR

Memory Techniques for Retention

  1. Visualization: Imagine a train where each car (node) is connected to the next by a coupling (pointer).
  2. Analogy: Compare to a scavenger hunt where each clue points to the location of the next clue.
  3. Acronym: LEND (Linked Elements with Node Data)
  4. Mnemonic: "Link by link, the chain grows long, each points ahead, where it belongs"

Code Example (Python)

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

class LinkedList:
    def __init__(self):
        self.head = None

    def is_empty(self):
        return self.head is None

    def insert_front(self, data):
        new_node = Node(data)
        new_node.next = self.head
        self.head = new_node

    def insert_end(self, data):
        new_node = Node(data)
        if self.is_empty():
            self.head = new_node
        else:
            current = self.head
            while current.next:
                current = current.next
            current.next = new_node

    def delete_front(self):
        if not self.is_empty():
            self.head = self.head.next
        else:
            raise IndexError("List is empty")

    def search(self, data):
        current = self.head
        while current:
            if current.data == data:
                return True
            current = current.next
        return False

    def display(self):
        elements = []
        current = self.head
        while current:
            elements.append(current.data)
            current = current.next
        return ' -> '.join(map(str, elements))

# Usage example
ll = LinkedList()
ll.insert_end(1)
ll.insert_end(2)
ll.insert_front(0)
print(ll.display())  # Output: 0 -> 1 -> 2
print(ll.search(1))  # Output: True
ll.delete_front()
print(ll.display())  # Output: 1 -> 2

Doubly Linked List Data Structure

Definition

A Doubly Linked List is a linear data structure where each node contains data and two references (or links): one to the next node and one to the previous node in the sequence.

Dobly Linked List

Key Properties

  1. Bidirectional: Can be traversed in both forward and backward directions.
  2. Dynamic Size: Can grow or shrink in size during execution.
  3. Non-contiguous Memory: Nodes can be stored anywhere in memory.
  4. Efficient Insertion/Deletion: Adding or removing elements is O(1) when position is known.

Basic Components

  1. Node: Contains data, a pointer to the next node, and a pointer to the previous node.
  2. Head: Points to the first node in the list.
  3. Tail: Points to the last node in the list.

Basic Operations

  1. Insertion: Add a new node (at the beginning, end, or middle).
  2. Deletion: Remove a node (from the beginning, end, or middle).
  3. Forward Traversal: Visit each node from head to tail.
  4. Backward Traversal: Visit each node from tail to head.
  5. Search: Find a node with a specific value.

Time Complexity

  • Access: O(n)
  • Search: O(n)
  • Insertion: O(1) if inserting at known position, O(n) if searching first
  • Deletion: O(1) if deleting known position, O(n) if searching first

Memory Usage

  • Memory = (size of data + size of two pointers) * (number of nodes)
  • Additional memory for head and tail pointers

Advantages

  1. Bidirectional traversal
  2. Efficient insertions and deletions at both ends
  3. Easy implementation of certain algorithms (e.g., LRU cache)
  4. Simpler to reverse the list compared to singly linked list

Disadvantages

  1. More memory usage due to extra pointer
  2. Slightly more complex implementation than singly linked list
  3. Still no random access
  4. Potential for inconsistency if pointers are not updated correctly

Common Use Cases

  1. Implementation of advanced data structures (e.g., deques)
  2. Browser's forward and backward navigation
  3. Undo and redo functionality in applications
  4. Music player (next and previous track)
  5. Implementing LRU (Least Recently Used) cache

Real-World Use Cases of Doubly Linked Lists

Doubly Linked Lists find applications in various real-world scenarios due to their unique properties, particularly their ability to traverse in both directions and efficiently insert or delete elements at any position. Here are some prominent use cases:

  1. Browser History Navigation

    • Implementing the back and forward functionality in web browsers.
    • Each node represents a webpage, allowing quick navigation in both directions.
  2. Music Player Playlists

    • Managing playlists where users can move both forward and backward through tracks.
    • Efficient for adding or removing songs from any position in the playlist.
  3. Undo-Redo Functionality

    • In text editors, graphic design software, or any application with undo-redo features.
    • Each node represents a state, allowing easy navigation through edit history.
  4. Cache Management (e.g., LRU Cache)

    • Implementing Least Recently Used (LRU) cache, where both ends of the list need to be accessed quickly.
    • Efficient for moving recently used items to the front and removing least used items from the end.
  5. Image Viewer Applications

    • Allowing users to scroll through images in both directions.
    • Efficient for loading next/previous images and maintaining a viewing history.
  6. Text Editors with Cursor Movement

    • Implementing efficient cursor movement in both directions in text editors.
    • Facilitates operations like insert, delete, and navigate through text.
  7. Palindrome Checking

    • Efficient algorithm for checking if a long string or linked list is a palindrome.
    • Can traverse from both ends towards the middle simultaneously.
  8. Train Carriage Management Systems

    • Modeling train compositions where carriages can be added or removed from either end.
    • Useful for efficiently reorganizing carriages.
  9. Multi-level Undo in CAD Software

    • Computer-Aided Design (CAD) software often requires complex undo-redo functionality.
    • Doubly linked lists can manage multiple levels of undo and redo operations.
  10. Blockchain Implementation

    • In some blockchain designs, where each block needs to reference both the previous and next blocks.
    • Allows for efficient verification and traversal of the blockchain in both directions.
  11. Memory Management in Operating Systems

    • Managing free and allocated memory blocks.
    • Efficient for splitting and merging memory blocks as needed.
  12. Implementation of Advanced Data Structures

    • Used as a building block for more complex data structures like:
      • Deques (double-ended queues)
      • Circular buffers with efficient wraparound
      • Specialized graph representations
  13. DNA Sequence Analysis

    • Representing DNA sequences for efficient forward and backward analysis.
    • Useful in bioinformatics algorithms that require bidirectional traversal of genetic data.
  14. Elevator Systems

    • Managing elevator requests and optimizing elevator movement.
    • Efficient for handling requests in both up and down directions.

These use cases leverage the doubly linked list's ability to efficiently insert, delete, and traverse in both directions, making it a versatile data structure for scenarios requiring bidirectional access or manipulation of sequential data.

Variations

  1. Circular Doubly Linked List: Last node's next points to first, first node's previous points to last
  2. XOR Linked List: Memory-efficient version using bitwise XOR of addresses

Memory Techniques for Retention

  1. Visualization: Imagine a two-way street where you can move in both directions.
  2. Analogy: Compare to a railway train where each car is connected to both the car in front and behind.
  3. Acronym: BOND (Bidirectional Ordered Node Data)
  4. Mnemonic: "Double the links, double the direction, forward and back without objection"

Code Example (Python)

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None
        self.prev = None

class DoublyLinkedList:
    def __init__(self):
        self.head = None
        self.tail = None

    def is_empty(self):
        return self.head is None

    def insert_front(self, data):
        new_node = Node(data)
        if self.is_empty():
            self.head = self.tail = new_node
        else:
            new_node.next = self.head
            self.head.prev = new_node
            self.head = new_node

    def insert_end(self, data):
        new_node = Node(data)
        if self.is_empty():
            self.head = self.tail = new_node
        else:
            new_node.prev = self.tail
            self.tail.next = new_node
            self.tail = new_node

    def delete_front(self):
        if not self.is_empty():
            if self.head == self.tail:
                self.head = self.tail = None
            else:
                self.head = self.head.next
                self.head.prev = None
        else:
            raise IndexError("List is empty")

    def delete_end(self):
        if not self.is_empty():
            if self.head == self.tail:
                self.head = self.tail = None
            else:
                self.tail = self.tail.prev
                self.tail.next = None
        else:
            raise IndexError("List is empty")

    def display_forward(self):
        elements = []
        current = self.head
        while current:
            elements.append(current.data)
            current = current.next
        return ' <-> '.join(map(str, elements))

    def display_backward(self):
        elements = []
        current = self.tail
        while current:
            elements.append(current.data)
            current = current.prev
        return ' <-> '.join(map(str, elements))

# Usage example
dll = DoublyLinkedList()
dll.insert_end(1)
dll.insert_end(2)
dll.insert_front(0)
print(dll.display_forward())   # Output: 0 <-> 1 <-> 2
print(dll.display_backward())  # Output: 2 <-> 1 <-> 0
dll.delete_front()
dll.delete_end()
print(dll.display_forward())   # Output: 1

Circular Linked List Data Structure

Definition

A Circular Linked List is a variation of linked list in which the last node points back to the first node, creating a circle-like structure.

Circular Linked List

Key Properties

  1. Circular Structure: The last node points to the first node, forming a closed loop.
  2. No Null Termination: There's no null at the end of the list.
  3. Dynamic Size: Can grow or shrink in size during execution.
  4. Continuous Traversal: Can be traversed starting from any point in the list.

Types of Circular Linked Lists

  1. Singly Circular Linked List: Each node has a single pointer to the next node.
  2. Doubly Circular Linked List: Each node has pointers to both next and previous nodes.

Basic Components

  1. Node: Contains data and pointer(s) to other node(s).
  2. Head: Points to any node in the list (often the first inserted node).

Basic Operations

  1. Insertion: Add a new node (at the beginning, end, or middle).
  2. Deletion: Remove a node (from the beginning, end, or middle).
  3. Traversal: Visit each node in the list.
  4. Search: Find a node with a specific value.

Time Complexity

  • Access: O(n)
  • Search: O(n)
  • Insertion: O(1) if inserting at known position, O(n) if searching first
  • Deletion: O(1) if deleting known position, O(n) if searching first

Memory Usage

  • Memory = (size of data + size of pointer) * (number of nodes)
  • No additional memory for tail pointer needed

Advantages

  1. Constant-time insertion at the beginning and end of the list
  2. Simplified list operations (no need to check for null)
  3. Useful for applications that require repetitive cycling through a list
  4. Efficient memory utilization (no null pointers)

Real-World Use Cases of Circular Linked Lists

Circular Linked Lists find applications in various domains due to their unique properties. Here are some notable real-world use cases:

  1. Operating System Resource Management

    • Process Scheduling: Used in round-robin scheduling algorithms where each process gets a fixed time slice in a cyclic manner.
    • Memory Management: In systems using circular memory allocation strategies.
  2. Computer Networking

    • Token Ring Networks: In this network topology, a token is passed around the network in a circular manner.
    • Bluetooth Device Discovery: For managing the list of discoverable devices in a circular fashion.
  3. Multimedia Applications

    • Playlist Management: For creating looping playlists in music or video players.
    • Image Carousel: In web and mobile applications for cycling through images.
  4. Gaming

    • Turn-Based Board Games: To manage player turns in multiplayer games.
    • Circular Buffering in Game Engines: For efficient memory management in game loops.
  5. Embedded Systems

    • Task Scheduling: In real-time operating systems for cyclic execution of tasks.
    • Circular Buffers: Used in data logging and communication protocols.
  6. Database Systems

    • Query Optimization: For managing circular dependencies in query execution plans.
    • Buffer Pool Management: In database management systems for cyclical page replacement.
  7. Computer Graphics

    • Vertex Management: In 3D graphics for closed polygonal models.
    • Animation Loops: For creating seamless looping animations.
  8. Timekeeping and Scheduling Applications

    • Circular Clock Representations: For digital clock implementations.
    • Appointment Scheduling: In calendar applications for recurring events.
  9. Text Editing and Word Processing

    • Undo-Redo Functionality: Maintaining a circular history of document changes.
    • Cursor Movement: For wrapping cursor movement in text editors.
  10. Financial Systems

    • Rotating Savings and Credit Associations (ROSCAs): For managing cyclical distribution of funds.
    • Circular Debt Management: In financial modeling of circular debt scenarios.
  11. Transportation and Logistics

    • Traffic Light Control Systems: For cyclic management of traffic signal phases.
    • Delivery Route Optimization: In logistics for routes that return to the starting point.
  12. Telecommunications

    • Call Center Queue Management: For fair distribution of incoming calls to agents.
    • Cellular Network Channel Allocation: In mobile networks for frequency reuse patterns.
  13. Manufacturing and Industrial Automation

    • Assembly Line Management: For cyclical process control in manufacturing.
    • Robotic Arm Movement: In industrial robots for repetitive task execution.
  14. Scientific Simulations

    • Planetary Orbit Calculations: In astrophysics simulations.
    • Molecular Structure Modeling: For cyclic molecular structures in chemistry.

These use cases demonstrate the versatility of Circular Linked Lists in solving problems that involve cyclic or repetitive processes, resource sharing, and efficient memory management across various fields and industries.

Disadvantages

  1. Slightly more complex implementation than singly linked list
  2. Risk of infinite loops if not implemented carefully
  3. No natural end point, requiring special consideration during traversal

Common Use Cases

  1. Round-robin scheduling in operating systems
  2. Implementing circular buffers
  3. Managing computer resources in a multi-user environment
  4. Multiplayer board games (turn rotation)
  5. Repetitive task management in embedded systems

Variations

  1. Josephus Problem: A counting-out game, often implemented using circular linked lists
  2. Circular Buffer: Also known as a ring buffer, used in embedded systems and data streaming

Memory Techniques for Retention

  1. Visualization: Imagine a merry-go-round where you can start from any horse and go around indefinitely.
  2. Analogy: Compare to a circular race track where runners keep going around without a finish line.
  3. Acronym: CIRCLE (Circularly Interconnected Repeating Cyclic Linked Elements)
  4. Mnemonic: "Round and round the list we go, where it stops, there's no null to show"

Code Example (Python)

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

class CircularLinkedList:
    def __init__(self):
        self.head = None

    def is_empty(self):
        return self.head is None

    def insert_end(self, data):
        new_node = Node(data)
        if self.is_empty():
            self.head = new_node
            new_node.next = self.head
        else:
            current = self.head
            while current.next != self.head:
                current = current.next
            current.next = new_node
            new_node.next = self.head

    def insert_beginning(self, data):
        new_node = Node(data)
        if self.is_empty():
            self.head = new_node
            new_node.next = self.head
        else:
            current = self.head
            while current.next != self.head:
                current = current.next
            new_node.next = self.head
            current.next = new_node
            self.head = new_node

    def delete(self, key):
        if self.is_empty():
            return

        if self.head.data == key and self.head.next == self.head:
            self.head = None
        elif self.head.data == key:
            current = self.head
            while current.next != self.head:
                current = current.next
            current.next = self.head.next
            self.head = self.head.next
        else:
            current = self.head
            prev = None
            while current.next != self.head:
                prev = current
                current = current.next
                if current.data == key:
                    prev.next = current.next
                    break

    def display(self):
        if self.is_empty():
            return "List is empty"
        elements = []
        current = self.head
        while True:
            elements.append(str(current.data))
            current = current.next
            if current == self.head:
                break
        return ' -> '.join(elements) + ' -> (back to start)'

# Usage example
cll = CircularLinkedList()
cll.insert_end(1)
cll.insert_end(2)
cll.insert_beginning(0)
print(cll.display())  # Output: 0 -> 1 -> 2 -> (back to start)
cll.delete(1)
print(cll.display())  # Output: 0 -> 2 -> (back to start)

Tree Data Structure

Definition

A Tree is a hierarchical data structure consisting of nodes connected by edges. It starts with a root node and branches out to child nodes, forming a parent-child relationship between nodes.

Tree Data Structure

Key Properties

  1. Hierarchical Structure: Organizes data in a parent-child relationship.
  2. Root Node: The topmost node of the tree.
  3. Parent and Child Nodes: Each node (except the root) has one parent and can have multiple children.
  4. Leaf Nodes: Nodes with no children.
  5. Subtree: A tree structure formed by a node and its descendants.
  6. Depth: The number of edges from the root to a node.
  7. Height: The number of edges on the longest path from a node to a leaf.

Types of Trees

  1. Binary Tree: Each node has at most two children.
  2. Binary Search Tree (BST): A binary tree with ordered nodes.
  3. AVL Tree: Self-balancing binary search tree.
  4. Red-Black Tree: Self-balancing binary search tree with color properties.
  5. N-ary Tree: Each node can have N children.
  6. Trie: Used for storing strings, where each node represents a character.

Basic Components

  1. Node: Contains data and references to its children.
  2. Root: The topmost node of the tree.
  3. Edge: The link between two nodes.

Basic Operations

  1. Insertion: Add a new node to the tree.
  2. Deletion: Remove a node from the tree.
  3. Traversal: Visit all nodes of the tree (In-order, Pre-order, Post-order, Level-order).
  4. Search: Find a node with a specific value.

Time Complexity (for balanced binary trees)

  • Access: O(log n)
  • Search: O(log n)
  • Insertion: O(log n)
  • Deletion: O(log n)

Memory Usage

  • Memory = (size of data + size of pointers to children) * (number of nodes)

Advantages

  1. Hierarchical data representation
  2. Efficient searching and sorting (in balanced trees)
  3. Natural representation for recursive structures
  4. Basis for many advanced data structures and algorithms

Disadvantages

  1. Can become unbalanced, leading to poor performance
  2. More complex to implement than linear data structures
  3. May require more memory due to pointer overhead

Common Use Cases

  1. File systems in operating systems
  2. HTML DOM (Document Object Model)
  3. Abstract Syntax Trees in compilers
  4. Decision trees in machine learning
  5. Family tree and organizational structures
  6. Database indexing (B-trees and B+ trees)

Real-World Applications of Tree Data Structures

  1. File Systems
  • Problem: Organizing and managing files and directories on a computer.
  • Solution: Use a tree structure where directories are internal nodes and files are leaf nodes.
  • Benefit: Efficient navigation, searching, and management of hierarchical file structures.
  1. Organization Charts
  • Problem: Representing company hierarchies and reporting structures.
  • Solution: Use a tree where each node represents an employee, with edges showing reporting relationships.
  • Benefit: Clear visualization of organizational structure and relationships.
  1. XML/HTML DOM
  • Problem: Parsing and representing structured documents.
  • Solution: Use a tree to represent the document structure, with elements as nodes and attributes as properties.
  • Benefit: Enables efficient parsing, manipulation, and rendering of web documents.
  1. Database Indexing
  • Problem: Fast data retrieval in large databases.
  • Solution: Use B-trees or B+ trees for creating database indexes.
  • Benefit: Significantly speeds up search, insertion, and deletion operations in databases.
  1. Decision Support Systems
  • Problem: Making complex decisions based on multiple factors.
  • Solution: Use decision trees to model various outcomes and their probabilities.
  • Benefit: Aids in decision-making processes by clearly showing possible outcomes and their likelihoods.
  1. Game AI
  • Problem: Implementing strategic decision-making in games.
  • Solution: Use game trees (like Minimax trees) to represent possible moves and outcomes.
  • Benefit: Enables AI to make optimal decisions by evaluating future game states.
  1. Compression Algorithms
  • Problem: Efficient data compression.
  • Solution: Use Huffman trees for variable-length encoding in data compression algorithms.
  • Benefit: Achieves optimal prefix-free compression of data.
  1. Network Routing
  • Problem: Finding efficient paths in computer networks.
  • Solution: Use spanning trees to determine optimal routing paths.
  • Benefit: Ensures efficient and loop-free packet routing in networks.
  1. Compiler Design
  • Problem: Parsing and analyzing programming language code.
  • Solution: Use abstract syntax trees (ASTs) to represent the structure of code.
  • Benefit: Facilitates code analysis, optimization, and translation in compilers.
  1. Biological Classification
  • Problem: Organizing and classifying living organisms.
  • Solution: Use phylogenetic trees to represent evolutionary relationships.
  • Benefit: Provides a structured way to understand and study biodiversity and evolution.
  1. Machine Learning
  • Problem: Making predictions based on input features.
  • Solution: Use decision trees and random forests for classification and regression tasks.
  • Benefit: Creates interpretable models for prediction and feature importance analysis.
  1. Spelling Checkers
  • Problem: Efficiently storing and searching large vocabularies.
  • Solution: Use trie data structures to store dictionaries.
  • Benefit: Enables fast prefix-based searching and suggestions.
  1. Expression Evaluation
  • Problem: Evaluating mathematical or logical expressions.
  • Solution: Use expression trees to represent and evaluate complex expressions.
  • Benefit: Allows for efficient parsing, evaluation, and manipulation of expressions.
  1. Genealogy
  • Problem: Representing and analyzing family histories.
  • Solution: Use family trees to show lineage and relationships.
  • Benefit: Provides a clear visual representation of familial connections across generations.

Remember: The versatility of tree structures makes them applicable in many more domains. Their hierarchical nature and efficient operations make them a go-to solution for many problems involving hierarchical data or requiring fast search and insertion operations.

Variations

  1. Segment Tree: Used for range query problems
  2. Fenwick Tree (Binary Indexed Tree): Efficient for range sum queries
  3. Suffix Tree: Used in string processing algorithms

Memory Techniques for Retention

  1. Visualization: Imagine a family tree with generations branching out.
  2. Analogy: Compare to a real tree with trunk (root), branches (internal nodes), and leaves.
  3. Acronym: BRANCH (Branching Representation of Abstract Nodes in a Connected Hierarchy)
  4. Mnemonic: "Rooted in data, branching wide, leaves at the end, information inside"

Code Example (Python)

class TreeNode:
    def __init__(self, data):
        self.data = data
        self.left = None
        self.right = None

class BinaryTree:
    def __init__(self):
        self.root = None

    def insert(self, data):
        if not self.root:
            self.root = TreeNode(data)
        else:
            self._insert_recursive(self.root, data)

    def _insert_recursive(self, node, data):
        if data < node.data:
            if node.left is None:
                node.left = TreeNode(data)
            else:
                self._insert_recursive(node.left, data)
        else:
            if node.right is None:
                node.right = TreeNode(data)
            else:
                self._insert_recursive(node.right, data)

    def inorder_traversal(self):
        return self._inorder_recursive(self.root)

    def _inorder_recursive(self, node):
        if node is None:
            return []
        return (self._inorder_recursive(node.left) + 
                [node.data] + 
                self._inorder_recursive(node.right))

# Usage example
tree = BinaryTree()
tree.insert(5)
tree.insert(3)
tree.insert(7)
tree.insert(1)
tree.insert(9)

print(tree.inorder_traversal())  # Output: [1, 3, 5, 7, 9]

Binary Search Tree (BST) Data Structure

Definition

A Binary Search Tree is a binary tree data structure with the property that for each node, all elements in its left subtree are less than the node, and all elements in its right subtree are greater than the node.

Binary Search Tree

Key Properties

  1. Binary Structure: Each node has at most two children.
  2. Ordering: Left subtree < Node < Right subtree for all nodes.
  3. Unique Elements: Typically, all node values are distinct.
  4. Efficiency: Provides efficient insertion, deletion, and search operations.
  5. In-order Traversal: Produces sorted output.

Basic Components

  1. Node: Contains data and references to left and right children.
  2. Root: The topmost node of the tree.
  3. Leaf: A node with no children.

Basic Operations

  1. Insertion: Add a new node while maintaining BST properties.
  2. Deletion: Remove a node while maintaining BST properties.
  3. Search: Find a node with a specific value.
  4. Traversal: In-order, Pre-order, Post-order.

Time Complexity

  • Average Case (balanced tree):
    • Search: O(log n)
    • Insertion: O(log n)
    • Deletion: O(log n)
  • Worst Case (unbalanced tree):
    • Search: O(n)
    • Insertion: O(n)
    • Deletion: O(n)

Memory Usage

  • Memory = (size of data + size of two pointers) * (number of nodes)

Advantages

  1. Efficient searching, insertion, and deletion (in balanced trees)
  2. Maintains sorted data structure
  3. Allows for in-order traversal to get sorted output
  4. Flexible size (can grow or shrink dynamically)

Disadvantages

  1. Performance degrades if tree becomes unbalanced
  2. No constant-time operations (unlike arrays)
  3. More complex to implement than simple linear data structures

Common Use Cases

  1. Implementing symbol tables in compilers
  2. Database indexing
  3. Implementing associative arrays
  4. Sorting algorithms (tree sort)
  5. Expression evaluation

Real-World Applications of Binary Search Trees

Binary Search Trees (BSTs) are versatile data structures that find applications in various domains due to their efficient searching, insertion, and deletion operations. Here are some real-world problems that can be solved or optimized using BSTs:

  1. File System Organization

    • Problem: Efficiently manage and search through directory structures.
    • BST Solution: Represent the file system hierarchy, allowing for quick file/folder lookups.
  2. Database Indexing

    • Problem: Speed up database queries on specific fields.
    • BST Solution: Create index structures (often B-trees, a variation of BST) for faster data retrieval.
  3. Autocomplete and Spell Checkers

    • Problem: Quickly suggest words as users type.
    • BST Solution: Store dictionary words in a BST for efficient prefix-based searching.
  4. IP Routing Tables

    • Problem: Efficiently route network packets based on IP addresses.
    • BST Solution: Organize routing information for quick lookups during packet forwarding.
  5. Compiler Symbol Tables

    • Problem: Manage variable and function names during compilation.
    • BST Solution: Store and quickly retrieve symbol information for efficient compilation.
  6. Game Development: Scene Graphs

    • Problem: Organize and render game objects efficiently.
    • BST Solution: Represent hierarchical relationships between game objects for optimized rendering and collision detection.
  7. Appointment Scheduling Systems

    • Problem: Manage and query time slots efficiently.
    • BST Solution: Organize appointments by time, allowing for quick insertion, deletion, and overlap checking.
  8. Air Traffic Control Systems

    • Problem: Track and manage multiple aircraft efficiently.
    • BST Solution: Organize aircraft by altitude or position for quick updates and collision avoidance checks.
  9. Stock Market Trading Systems

    • Problem: Quickly process and match buy/sell orders.
    • BST Solution: Organize orders by price for efficient matching and execution.
  10. Hierarchical Data Representation

    • Problem: Represent and query organizational structures or taxonomies.
    • BST Solution: Model hierarchical relationships with efficient searching and updating capabilities.
  11. Morse Code Decoding

    • Problem: Efficiently translate Morse code to text.
    • BST Solution: Represent Morse code dictionary for quick character lookups.
  12. Implementing Associative Arrays

    • Problem: Create key-value pair data structures with efficient operations.
    • BST Solution: Use keys to organize data for quick insertion, deletion, and retrieval of values.
  13. Priority Queues in Operating Systems

    • Problem: Manage process scheduling with different priorities.
    • BST Solution: Organize processes by priority for efficient selection of next process to run.
  14. Geographical Information Systems (GIS)

    • Problem: Efficiently store and query spatial data.
    • BST Solution: Organize geographical data points for range queries and nearest neighbor searches.
  15. Huffman Coding in Data Compression

    • Problem: Generate optimal prefix codes for data compression.
    • BST Solution: Construct and traverse Huffman trees for encoding and decoding.

Remember that in many of these applications, variations of BSTs like AVL trees, Red-Black trees, or B-trees might be used to ensure balanced structures and optimal performance in large-scale systems.

Variations

  1. AVL Tree: Self-balancing BST
  2. Red-Black Tree: Self-balancing BST with color properties
  3. Splay Tree: Self-adjusting BST that moves recently accessed elements closer to the root
  4. Treap: Randomized BST

Memory Techniques for Retention

  1. Visualization: Imagine a family tree where younger generations are always to the left, older to the right.
  2. Analogy: Compare to a dictionary, where words to the left come before, and to the right come after the current word.
  3. Acronym: LESS (Left Elements Smaller Subtree)
  4. Mnemonic: "Left less, right more, search faster than before"

Code Example (Python)

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None

class BinarySearchTree:
    def __init__(self):
        self.root = None

    def insert(self, key):
        self.root = self._insert_recursive(self.root, key)

    def _insert_recursive(self, root, key):
        if root is None:
            return Node(key)
        if key < root.key:
            root.left = self._insert_recursive(root.left, key)
        else:
            root.right = self._insert_recursive(root.right, key)
        return root

    def search(self, key):
        return self._search_recursive(self.root, key)

    def _search_recursive(self, root, key):
        if root is None or root.key == key:
            return root
        if key < root.key:
            return self._search_recursive(root.left, key)
        return self._search_recursive(root.right, key)

    def inorder_traversal(self):
        result = []
        self._inorder_recursive(self.root, result)
        return result

    def _inorder_recursive(self, root, result):
        if root:
            self._inorder_recursive(root.left, result)
            result.append(root.key)
            self._inorder_recursive(root.right, result)

    def delete(self, key):
        self.root = self._delete_recursive(self.root, key)

    def _delete_recursive(self, root, key):
        if root is None:
            return root
        if key < root.key:
            root.left = self._delete_recursive(root.left, key)
        elif key > root.key:
            root.right = self._delete_recursive(root.right, key)
        else:
            if root.left is None:
                return root.right
            elif root.right is None:
                return root.left
            temp = self._min_value_node(root.right)
            root.key = temp.key
            root.right = self._delete_recursive(root.right, temp.key)
        return root

    def _min_value_node(self, node):
        current = node
        while current.left is not None:
            current = current.left
        return current

# Usage example
bst = BinarySearchTree()
bst.insert(50)
bst.insert(30)
bst.insert(70)
bst.insert(20)
bst.insert(40)
bst.insert(60)
bst.insert(80)

print("Inorder traversal:", bst.inorder_traversal())
print("Search 40:", "Found" if bst.search(40) else "Not Found")
bst.delete(40)
print("Inorder traversal after deleting 40:", bst.inorder_traversal())

Remember: Implement and experiment with Binary Search Trees in your preferred programming language to reinforce your understanding!

Binary Search Tree (BST) Operations: Insert, Delete, Search

A Binary Search Tree (BST) is a binary tree data structure with the following properties:

  • The left subtree of a node contains only nodes with keys less than the node's key.
  • The right subtree of a node contains only nodes with keys greater than the node's key.
  • Both the left and right subtrees must also be binary search trees.

1. Insert Operation

The insert operation adds a new node with a given key to the BST while maintaining its properties.

Algorithm:

  1. If the tree is empty, create a new node and set it as the root.
  2. If the tree is not empty, compare the key to be inserted with the root's key:
    • If the key is less than the root's key, recursively insert into the left subtree.
    • If the key is greater than the root's key, recursively insert into the right subtree.
  3. If the key already exists, typically no action is taken (assuming duplicate keys are not allowed).

Time Complexity: O(h), where h is the height of the tree.

2. Delete Operation

The delete operation removes a node with a given key from the BST while maintaining its properties.

Algorithm:

  1. Search for the node to be deleted.
  2. If the node is found, there are three cases: a. Node has no children (leaf node):
    • Simply remove the node. b. Node has one child:
    • Replace the node with its child. c. Node has two children:
    • Find the inorder successor (smallest node in the right subtree).
    • Replace the node's key with the inorder successor's key.
    • Delete the inorder successor.

Time Complexity: O(h), where h is the height of the tree.

3. Search Operation

The search operation finds a node with a given key in the BST.

Algorithm:

  1. Start at the root.
  2. Compare the search key with the current node's key:
    • If they match, return the current node.
    • If the search key is less than the current node's key, recursively search the left subtree.
    • If the search key is greater than the current node's key, recursively search the right subtree.
  3. If the end of the tree is reached without finding the key, return null or indicate that the key was not found.

Time Complexity: O(h), where h is the height of the tree.

Note: In a balanced BST, the height h is approximately log(n), where n is the number of nodes. However, in the worst case (a skewed tree), h can be n, leading to O(n) time complexity for all operations.

Tree Traversals: In-order, Pre-order, Post-order

Tree traversal is the process of visiting (checking and/or updating) each node in a tree data structure, exactly once. Unlike linear data structures (Array, Linked List, Queues, Stacks, etc.) which have only one logical way to traverse them, trees can be traversed in different ways.

1. In-order Traversal

In an in-order traversal, we visit the left subtree, then the root, and finally the right subtree.

Algorithm:

  1. Recursively traverse the left subtree.
  2. Visit the root.
  3. Recursively traverse the right subtree.

Pseudocode:

inorder(node)
    if node is null, return
    inorder(node.left)
    visit(node)
    inorder(node.right)

Use Case:

In-order traversal is commonly used with Binary Search Trees (BST) as it visits nodes in ascending order.

2. Pre-order Traversal

In a pre-order traversal, we visit the root first, then the left subtree, and finally the right subtree.

Algorithm:

  1. Visit the root.
  2. Recursively traverse the left subtree.
  3. Recursively traverse the right subtree.

Pseudocode:

preorder(node)
    if node is null, return
    visit(node)
    preorder(node.left)
    preorder(node.right)

Use Case:

Pre-order traversal is used to create a copy of the tree or to get prefix expression on an expression tree.

3. Post-order Traversal

In a post-order traversal, we visit the left subtree, then the right subtree, and finally the root.

Algorithm:

  1. Recursively traverse the left subtree.
  2. Recursively traverse the right subtree.
  3. Visit the root.

Pseudocode:

postorder(node)
    if node is null, return
    postorder(node.left)
    postorder(node.right)
    visit(node)

Use Case:

Post-order traversal is used when we want to delete the tree or to get the postfix expression of an expression tree.

Comparison and Time Complexity

Consider this binary tree:

    1
   / \
  2   3
 / \
4   5

Traversal results:

  • In-order: 4 2 5 1 3
  • Pre-order: 1 2 4 5 3
  • Post-order: 4 5 2 3 1

Time Complexity: All three traversals have a time complexity of O(n), where n is the number of nodes in the tree, as they visit each node exactly once.

Space Complexity: O(h) for the recursive call stack, where h is the height of the tree. In the worst case of a skewed tree, this could be O(n).

Note: These traversals can also be implemented iteratively using a stack, which can be beneficial in certain scenarios, especially when dealing with very deep trees where recursive approaches might lead to stack overflow.

Graph Data Structure

Definition

A Graph is a non-linear data structure consisting of vertices (or nodes) and edges that connect these vertices. It is used to represent relationships between pairs of objects.

Graph Data Structure

Key Properties

  1. Vertices: The fundamental units of the graph.
  2. Edges: Connections between pairs of vertices.
  3. Direction: Graphs can be directed (edges have direction) or undirected.
  4. Weight: Edges can have weights to represent costs, distances, etc.
  5. Connectivity: A graph can be connected or disconnected.
  6. Cyclicity: A graph can be cyclic or acyclic.

Types of Graphs

  1. Undirected Graph: Edges have no direction.
  2. Directed Graph (Digraph): Edges have direction.
  3. Weighted Graph: Edges have associated weights.
  4. Complete Graph: Every vertex is connected to every other vertex.
  5. Bipartite Graph: Vertices can be divided into two disjoint sets.
  6. Tree: A connected acyclic graph.

Types of Graphs

Basic Components

  1. Vertex: A node in the graph.
  2. Edge: A connection between two vertices.
  3. Path: A sequence of vertices connected by edges.
  4. Cycle: A path that starts and ends at the same vertex.

Basic Operations

  1. Add Vertex: Insert a new vertex into the graph.
  2. Add Edge: Add a new edge between two vertices.
  3. Remove Vertex: Delete a vertex and all its incident edges.
  4. Remove Edge: Delete an edge between two vertices.
  5. Graph Traversal: Visit all vertices in a graph (BFS, DFS).
  6. Search: Find a path between two vertices.

Representations

  1. Adjacency Matrix: 2D array where rows and columns represent vertices.
  2. Adjacency List: Array of lists, each representing connections of a vertex.
  3. Edge List: List of all edges in the graph.

Time Complexity (for V vertices and E edges)

  • Adjacency Matrix:
    • Add Vertex: O(V^2)
    • Add Edge: O(1)
    • Remove Vertex: O(V^2)
    • Remove Edge: O(1)
    • Query: O(1)
    • Storage: O(V^2)
  • Adjacency List:
    • Add Vertex: O(1)
    • Add Edge: O(1)
    • Remove Vertex: O(V + E)
    • Remove Edge: O(E)
    • Query: O(V)
    • Storage: O(V + E)

Advantages

  1. Model real-world relationships and networks
  2. Solve complex problems like shortest path, network flow
  3. Flexible structure for representing various types of data
  4. Efficient for certain operations depending on representation

Disadvantages

  1. Can be complex to implement and manage
  2. Some operations can be inefficient for large graphs
  3. Memory intensive, especially for dense graphs

Common Use Cases

  1. Social Networks (Facebook friends, LinkedIn connections)
  2. Geographic Maps and Navigation Systems
  3. Computer Networks and Communication Systems
  4. Recommendation Systems
  5. Dependency Resolution in Software Engineering
  6. Circuit Design in Electronics

Real-World Applications of Graph and Tree Data Structures

  1. Social Networks

    1. Friend recommendations
    2. Influence analysis
    3. Community detection
    4. Shortest path between two users
  2. Transportation and Navigation

    1. GPS and route planning
    2. Traffic flow optimization
    3. Airline flight paths
    4. Public transit systems
  3. Computer Networks

    1. Internet routing protocols
    2. Network topology analysis
    3. Data packet routing
    4. Network flow optimization
  4. Biology and Genetics

    1. Phylogenetic trees (evolutionary relationships)
    2. Protein interaction networks
    3. Gene regulatory networks
    4. Ecological food webs
  5. Computer Science and Software Engineering

    1. File system hierarchies
    2. Syntax trees in compilers
    3. Dependency resolution in package managers
    4. State machines and game trees
  6. Artificial Intelligence and Machine Learning

    1. Decision trees in machine learning
    2. Knowledge representation
    3. Neural networks
    4. Game-playing algorithms (e.g., chess, Go)
  7. Business and Organization

    1. Company hierarchies
    2. Supply chain management
    3. Project management (PERT charts)
    4. Customer relationship mapping
  8. Web Technologies

    1. Web crawling and indexing
    2. DOM (Document Object Model) in web browsers
    3. Website sitemaps
    4. Hyperlink structure analysis
  9. Telecommunications

    1. Call routing in telephone networks
    2. Network capacity planning
    3. Cellular tower placement

These applications demonstrate the versatility and power of graph and tree data structures in modeling and solving complex real-world problems across various domains. The ability to represent relationships, hierarchies, and networks makes these structures fundamental tools in computer science and beyond.

Algorithms

  1. Breadth-First Search (BFS): Level-wise traversal
  2. Depth-First Search (DFS): Explore as far as possible along branches
  3. Dijkstra's Algorithm: Find shortest paths in weighted graphs
  4. Bellman-Ford Algorithm: Find shortest paths with negative weights
  5. Floyd-Warshall Algorithm: All pairs shortest paths
  6. Kruskal's and Prim's Algorithms: Minimum Spanning Tree
  7. Topological Sorting: Ordering of vertices in a directed acyclic graph

Memory Techniques for Retention

  1. Visualization: Imagine a social network diagram with people as nodes and friendships as edges.
  2. Analogy: Compare to a road map where cities are vertices and roads are edges.
  3. Acronym: VENOM (Vertices and Edges in a Network Object Model)
  4. Mnemonic: "Vertices vex, edges express, in graphs we connect and progress"

Code Example (Python)

from collections import defaultdict

class Graph:
    def __init__(self):
        self.graph = defaultdict(list)

    def add_edge(self, u, v):
        self.graph[u].append(v)

    def bfs(self, start):
        visited = set()
        queue = [start]
        visited.add(start)

        while queue:
            vertex = queue.pop(0)
            print(vertex, end=" ")

            for neighbor in self.graph[vertex]:
                if neighbor not in visited:
                    visited.add(neighbor)
                    queue.append(neighbor)

    def dfs_util(self, v, visited):
        visited.add(v)
        print(v, end=" ")

        for neighbor in self.graph[v]:
            if neighbor not in visited:
                self.dfs_util(neighbor, visited)

    def dfs(self, start):
        visited = set()
        self.dfs_util(start, visited)

# Usage example
g = Graph()
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 2)
g.add_edge(2, 0)
g.add_edge(2, 3)
g.add_edge(3, 3)

print("BFS starting from vertex 2:")
g.bfs(2)
print("\nDFS starting from vertex 2:")
g.dfs(2)

Hash Table (Hash Map) Data Structure

Definition

A Hash Table is a data structure that implements an associative array abstract data type, a structure that can map keys to values. It uses a hash function to compute an index into an array of buckets or slots, from which the desired value can be found.

Key Properties

  1. Key-Value Pairs: Stores data as key-value pairs.
  2. Hash Function: Uses a hash function to map keys to array indices.
  3. Collision Resolution: Handles situations where different keys hash to the same index.
  4. Dynamic Sizing: Can resize to maintain efficiency as the number of elements grows.
  5. Load Factor: Ratio of occupied slots to total slots, affects performance.

Basic Components

  1. Hash Function: Converts keys into array indices.
  2. Array: Stores the key-value pairs.
  3. Collision Resolution Method: Handles multiple keys mapping to the same index.

Basic Operations

  1. Insert: Add a new key-value pair.
  2. Delete: Remove a key-value pair.
  3. Search: Find the value associated with a given key.
  4. Update: Modify the value associated with a given key.

Time Complexity

  • Average Case (with a good hash function):
    • Insert: O(1)
    • Delete: O(1)
    • Search: O(1)
  • Worst Case (many collisions):
    • All operations: O(n)

Space Complexity

  • O(n), where n is the number of key-value pairs stored

Collision Resolution Techniques

  1. Chaining: Each array index points to a linked list of entries.
  2. Open Addressing:
    • Linear Probing: Check next slot sequentially.
    • Quadratic Probing: Check slots at quadratic intervals.
    • Double Hashing: Use a second hash function.

Advantages

  1. Fast average-case access, insertion, and deletion (O(1))
  2. Flexible keys (can use strings, objects, etc. as keys)
  3. Efficient for large datasets when properly tuned
  4. Implements dictionary/map abstract data type

Disadvantages

  1. Poor worst-case performance
  2. May require resizing, which is expensive
  3. Not efficient for small datasets
  4. No ordering of keys

Common Use Cases

  1. Database indexing
  2. Caches (e.g., web browser cache)
  3. Symbol tables in compilers
  4. Spell checkers
  5. Implementing associative arrays
  6. Counting distinct elements

Real-World Applications of Hash Maps

  1. Web Development and Databases
  • Caching frequently accessed data to reduce database load
  • Session storage in web applications
  • URL shorteners
  • Implementing database indexes for faster querying
  1. Network and Systems
  • IP address to domain name mapping (DNS lookups)
  • Load balancing in distributed systems
  • Implementing routing tables in network routers
  • Storing configuration settings for quick access
  1. Computer Science and Programming
  • Symbol tables in compilers and interpreters
  • Implementing sets and dictionaries in programming languages
  • Memoization in dynamic programming to store computed results
  • Counting sort algorithm implementation
  1. Data Processing and Analytics
  • Counting word frequencies in large text documents
  • Deduplication of data entries
  • Implementing sparse matrices
  • Histogram creation for data analysis
  1. Cybersecurity
  • Password hashing and verification
  • Bloom filters for malware detection
  • Storing and checking against blacklists (e.g., IP addresses, email domains)
  • Implementing hash-based message authentication codes (HMAC)
  1. Gaming
  • Storing game states for quick save/load operations
  • Implementing inventory systems in RPGs
  • Collision detection in 2D games
  • Caching pre-computed game scenarios
  1. File Systems and Operating Systems
  • File system implementation (mapping file names to inodes)
  • Implementing disk cache for faster file access
  • Process and thread management in operating systems
  • Storing environment variables
  1. E-commerce and Finance
  • Shopping cart implementation in online stores
  • Currency conversion tables
  • Implementing stock symbol lookups
  • Caching product information for quick display
  1. Social Media and Communication
  • Storing user profiles for quick access
  • Implementing friend lists or follower systems
  • Message deduplication in chat applications
  • Caching recent posts or tweets
  1. Artificial Intelligence and Machine Learning
  • Feature hashing in machine learning models
  • Implementing associative memories in neural networks
  • Storing and retrieving trained model parameters
  • Implementing efficient nearest neighbor search algorithms
  1. Graphics and Multimedia
  • Color mapping in image processing
  • Texture caching in 3D rendering engines
  • Storing and retrieving media metadata
  • Implementing sprite sheets in 2D game development
  1. Biotechnology and Bioinformatics
  • DNA sequence analysis (k-mer counting)
  • Protein structure prediction (storing intermediate results)
  • Implementing genome databases for quick lookups
  • Drug discovery (molecular fingerprinting)

Variations

  1. Bloom Filter: Space-efficient probabilistic data structure
  2. Cuckoo Hashing: Uses multiple hash functions for better worst-case performance
  3. Perfect Hashing: Achieves O(1) worst-case lookup time for static sets

Memory Techniques for Retention

  1. Visualization: Imagine a library where books (values) are placed on shelves (array slots) based on a code (hash) derived from their titles (keys).
  2. Analogy: Compare to a valet parking system where car placement is determined by a function of the license plate number.
  3. Acronym: HASH (Hashed Array Stores Haplessly)
  4. Mnemonic: "Key to index, value in place, constant time access, at lightning pace"

Code Example (Python)

class HashTable:
    def __init__(self, size=10):
        self.size = size
        self.table = [[] for _ in range(self.size)]

    def _hash(self, key):
        return hash(key) % self.size

    def insert(self, key, value):
        index = self._hash(key)
        for item in self.table[index]:
            if item[0] == key:
                item[1] = value
                return
        self.table[index].append([key, value])

    def get(self, key):
        index = self._hash(key)
        for item in self.table[index]:
            if item[0] == key:
                return item[1]
        raise KeyError(key)

    def remove(self, key):
        index = self._hash(key)
        for i, item in enumerate(self.table[index]):
            if item[0] == key:
                del self.table[index][i]
                return
        raise KeyError(key)

    def __str__(self):
        return str(self.table)

# Usage example
ht = HashTable()
ht.insert("apple", 5)
ht.insert("banana", 7)
ht.insert("orange", 3)

print(ht.get("banana"))  # Output: 7
ht.remove("apple")
print(ht)  # Output: [[], [['banana', 7]], [], [], [], [], [], [], [], [['orange', 3]]]

Practical_Work Submission Guideline

GitHub Repository Structure

Create a new GitHub repository for your lab submissions with the following structure:

SWE_Practical_Works/ # this is the repo name and not a directory under the repo.
│
├── README.md # description about the repo and what have you implemented
├── lab2/
│   ├── text_analyzer.py
│   ├── sample.txt
│ . . . 
├── lab8/
│   ├── graph_algorithms.py

Submission Process

  • Create a new GitHub repository named SWE_Practical_Works following the structure outlined above.
  • Implement each lab in its respective directory.
  • Commit once per lab - there should be a total of 8 commits.
  • Once you've completed a lab, you NEED TO COMMIT AND PUSH to your github repo and your commit message should be the lab that you did: E.g: "lab1"
  • Submit the GitHub repository URL and the specific release tag for each lab to your instructor through the Excel sheel provided.

Practical 0 - Setup and Git Fundamentals

Please enable the autosave feature in VScode before proceeding with this practical, this can be found in the File tab in the VScode window:


Part 1:Creating and saving a Python file.

Step 1: Open the VSCode application.

Step 2: On the top left corner, click on the file tab. Select the new file option.

Step 3: In the drop-down menu as shown below, select the Python file option.

Step 4: Type the following simple program that will print your name and student ID:

studentName = "Sonam Choden"
studentNumber = "02240122"

print("My name is " + studentName + " and my student number is " + studentNumber)

Step 5: In the top left-hand corner, select the File tab, click on the Save As tab, and select a destination folder (eg: Desktop). Then save your file with the name practical1.py

Note that your file name must have no spaces and avoid using special characters, you must follow this rule for all files & folders created for your practicals here after

Step 6: Click on the terminal window, and use the ls (list all files) command to locate the folder you previously saved your file in (i.e. Desktop). Use the cd(change directory eg: cd Desktop ) command to access your saved file.

Step 7: Use the python3 command followed by your file name to run your file.

Congratulations, you have completed the first part of your practical. You now know:

  1. How to create a Python file.
  2. How to save a Python file.
  3. How to locate a file in your computer using the ls and cd command in the terminal window.
  4. How to run your Python file.

Part 2: Git installation

Step 1: Download git on your computer. The steps may vary depending on if you are using a Windows, Mac, or Linux operating system on your computer.

Tutorial for windows: https://www.youtube.com/watch?v=iYkLrXobBbA

Tutorial for Mac: https://www.youtube.com/watch?v=B4qsvQ5IqWk

Note: it is recommended to use a package manager like homebrew for any software you install into your computer here after

Download git from here: https://git-scm.com/downloads


Part 3: Using git

Once you have successfully installed git, open the VScode window you were previously working on. Click on the terminal window.

Step 1: To confirm if git was properly installed in your computer, in the terminal window, type the following command:

git -version

The output must show the current version of git installed on your computer. If there are any errors, it means that git wasn't properly installed on your computer, seek assistance from your module tutor to fix this issue.

Before you proceed to step 2, ensure that the file you created earlier is now saved inside a folder. The folder can be named CSF101Practicals.

Step 2: From the terminal window, ensure that your current directory is the folder you created inside which you have saved for your file. You can verify this by checking your terminal and seeing if the file path is correct:

Example: in the image below, you can see that the last part of my file path reflects the current directory I am accessing using the terminal.

Step 3: Use the following command to initialize a git repository

git init 

This initializes an empty git repository inside your folder

Step 4:

Create branch in the repository

git branch -M main

Step 5: Next, type the following command

git add .

This adds all the files that have been changed on created to a staging environment.

Step 6: Next, type the following command

git commit -m "My first commit" 

You have now made your very first git commit. To check all commits that were made to your git repository, you can type the following command

git log

A successful repository and commit can be seen as follows:


Part 4: Making changes to the file in a git repository

Step 1: Open your VScode window.

Step 2: Make some changes in the file that is saved in your git repository (i.e. Practical1.py ). You can see the changes I have in comparison to my previous code snippet. I have changed the student name and student number variable.

studentName = "Jigme Zangmo"
studentNumber = "02190102"

print("My name is " + studentName + " and my student number is " + studentNumber)

Step 3: go to the terminal and follow Step 4 from Part 3 of this practical as follows.

git add .

Then type the following command:

git status

You can see that git has detected some changes made to your working repository.

Continue with the following commands:

git commit -m "My Second commit"

Step 5: Using the git log command, you can now see the details of the two separate commits that were made into the repository.

Congratulations you now know how to:

  1. Initialize a git repository
  2. Add and commit files to the git repository
  3. Make changes to the files inside the repository and make new commits
  4. View the log of all commits made to the repository

Part 5: Using GitHub

Lets start first by creating a repository in github

Step 1: Using a browser, search for github.com and login using your credentials

Step 2: Click on your profile avatar in the top right-hand corner of the screen, select the repository option in the drop-down menu

Step 3: Click on the new option on the top right-hand side of the screen. In the create repository page, name your new repository in the format shown below. Select your account as the repository owner and click on the Create Repository button on the bottom right side of the page:

Eg: 02240224_CSF101_Practicals

Step 4: Once a empty repository is created as shown below, click on the SSH option and copy the link as highlighted below:

Step 5: Go back to the terminal of your VScode window and paste this link there. Once you have entered the command, you can type git remote -v to check if the repository has been added successfully:

Note: the following step is very important

Step 6: Navigate to the same drop-down menu as shown in Step 2 and click on the settings option. Navigate the settings page and select the developer options

Step 7: Click on the Tokens (classic) option on the left-hand side and select the generate new token(classic) option.

Step 8: In the generate token page, provide a small note for the token, and in the Expiration drop-down menu, select the no expiration option. Then select all the checkboxes on this page and click on the generate token button at the bottom of the page

Step 9: Copy the token link that has been generated and save it in any application on your desktop. This token is important and will not be visible again.

Step 10: Take your token and replace it with the following command:

git remote set-url origin https://<token>@github.com/<username>/<repo>

Where:

  • token - token you just generated in GitHub
  • username - your GitHub username
  • repo - the name of your GitHub repo

Your command should look like this:

git remote set-url origin https://ghp_OBJnrsdfsdaP251jFasdfasde4ObzzAw1qc1tA@github.com/Darshansgit/02190108_CSF101_Practicals

Step 11: Finally you can type the following command in your terminal:

git push -u origin main

If your push was successful, you will see the following output.

You can now go and check your empty GitHub repository and notice that the file that you had created on your computers local repository is now on git.

Note: for future pushes, you need not follow Step 6 to Step 10. The GitHub personal access token generation is a one-time thing (once for each repo) and you do not need to generate a new key for every push. Once you are done committing your code locally using git, you may proceed with the git push -u origin main command to directly push your code to git here after.

Congratulations, you now know how to:

  1. Create an empty GitHub repository
  2. Generate an access token for you to push your local repository into the remote repository on GitHub
  3. Authenticate using the access token (this needs to be done one time only)
  4. Push your local repository to your remote repository.

This concludes Practical 0. Hereafter, all your practicals are expected to be completed and pushed to Git Hub. Your GitHub repository will be reviewed before the end of the semester to check if you have completed all your practicals. Please practice this process to get familiar with git and github.


Practical 1: Python Basics

Python Comments

This is done with the # character at the beginning of the line

# This is a comment line in the code

Creating a Function

In Python a function is defined using the def keyword:

def my_function():
  print("Hello from a function")
# Calling the function
my_function() 

Scope

Notice how s has different defined value for the string in local scope within the function func() versus global scope for the overall python file.

def func():
    # Local scope
    s = "Me too! (on local scope)"
    print(s)
# Global scope
s = "I love python! (on global scope)"
print(s)

Basic Data Types - Integer, Float, Boolean, None and Type Casting

# Integer
pi = 3.14
pi2 = int(pi)
print(pi)
print(pi2)

# Float
pi3 = "3.14"
print(type(pi3))
pi4 = float(pi3)
print(type(pi4))

# Boolean
print(0<1)
print(1>0)
bool(0)
bool(1)
bool("Hello")

# None
x = None
print(x)

Basic Data Types: String and Manipulations

print("Hello!!!!")
print("This is my first script!")
a = """Lorem ipsum dolor sit amet,
consectetur adipiscing elit,
sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua."""
print(a)

# String functions
print(len(a))
print(a.upper())
print(a.lower())
print(a.count('i'))
print(a.find('d'))
print(a.split())

# String Concatenation
b = "Hello"
c = "Hello"
d = b + "!!" + c + "??"
print(d)

# String replication
print("Alice" * 5)

# String formatting
name = "Karma"
print(f"Hello {name}")
print("Greeting to you, {}".format(name))
Number = 2
print("There are %d %s in the class" %(Number, name))

Basic Data Structures

# List
thislist = ["apple", "banana", "cherry"]
print(thislist)
print(len(thislist))
print(thislist.index("banana"))
thislist.remove("banana")
thislist.insert(1, "strawberry")
print(thislist)

# Tuple
thistuple = ("apple", "banana", "cherry")
print(thistuple)
print(len(thistuple))
print(type(thistuple))

# Set
thisset = {"apple", "banana", "cherry", True, 1, 2}
print(thisset)
print(len(thisset))
print(type(myset))

# Dictionary
thisdict = {
  "brand": "Ford",
  "model": "Mustang",
  "year": 1964
}
print(thisdict)
print(thisdict["brand"])

Class excercise:

Q1. Array manipulation:
Refer to the following documentation on python list methods Link
Create a python list and name it first_list as shown below:

first_list = [0,1,2,3,4,5,6,7,8,9]

Create a second empty list named inverse_list:

inverse_list = []

Using the

 .append() 
 .pop()

methods, create the implementation of a stack whereby you de-que elements from the first list and enqueee them into the second list. Your output must be as follows:

 inverse_list = [9,8,7,6,5,4,3,2,1,0]

Sample scaffold (fill up with logic and code where necesarry):

first_list = [0,1,2,3,4,5,6,7,8,9]
inverse_list = [9,8,7,6,5,4,3,2,1,0]

index = 9
#Hint, use a while loop to loop throught first list from the last index
while index < :
  inverse_list.append()
  index = 

print(inverse_list)

Q2. Implementing Functions
Take your array invers implementation from question 1 and define a function known as reverse_array. Pass the first_list() as an argument into the array and ensure that inverse_list is returned when the function is called.


def reverse_array(first_list):
  #logic and code from Q1


reverse_array(first_list)

Practical 2: Coding Basic Functions

Objective

In this lab, you will create a Python program that analyzes a text file and calculates various statistics using control structures. This exercise will help you practice file handling, string manipulation, and using loops and conditionals in Python.

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of file operations

Lab Steps

Step 0: Preparation

Create a folder and name it practical4. Download the sample.txt text file from this link and save it inside the folder. Create your practical5.py file in the same folder and continue with the subsequent steps.

Step 1: Open and Read a Text File

First, let's create a function to open and read a text file:

def read_file(filename):
    with open(filename, 'r') as file:
        return file.read()

# Test the function
content = read_file('sample.txt')
print(content[:100])  # Print the first 100 characters

Step 2: Count the Number of Lines

Now, let's count the number of lines in the file:

def count_lines(content):
    return len(content.split('\n'))

# Test the function
num_lines = count_lines(content)
print(f"Number of lines: {num_lines}")

Step 3: Count Words

Next, we'll count the total number of words in the file:

def count_words(content):
    return len(content.split())

# Test the function
num_words = count_words(content)
print(f"Number of words: {num_words}")

Step 4: Find the Most Common Word

Let's find the most common word in the text:

from collections import Counter

def most_common_word(content):
    words = content.lower().split()
    word_counts = Counter(words)
    return word_counts.most_common(1)[0]

# Test the function
common_word, count = most_common_word(content)
print(f"Most common word: '{common_word}' (appears {count} times)")

Step 5: Calculate Average Word Length

Now, let's calculate the average word length:

def average_word_length(content):
    words = content.split()
    total_length = sum(len(word) for word in words)
    return total_length / len(words)

# Test the function
avg_length = average_word_length(content)
print(f"Average word length: {avg_length:.2f} characters")

Step 6: Combine Everything into a Main Function

Finally, let's combine all these functions into a main function that analyzes the text file:

def analyze_text(filename):
    content = read_file(filename)
    
    num_lines = count_lines(content)
    num_words = count_words(content)
    common_word, count = most_common_word(content)
    avg_length = average_word_length(content)
    
    print(f"File: {filename}")
    print(f"Number of lines: {num_lines}")
    print(f"Number of words: {num_words}")
    print(f"Most common word: '{common_word}' (appears {count} times)")
    print(f"Average word length: {avg_length:.2f} characters")

# Run the analysis
analyze_text('sample.txt')

Exercises for Students

  1. Modify the program to count the number of unique words in the text.
  2. Add a function to find the longest word in the text.
  3. Implement a feature to count the occurrences of a specific word (case-insensitive).
  4. Create a function to calculate the percentage of words that are longer than the average word length.

Conclusion

In this lab, you've created a text file analyzer using Python. You've practiced file handling, string manipulation, and using control structures like loops and conditionals. The modular approach we've taken allows for easy expansion and modification of the program.

Remember to test your code with different text files to ensure it works correctly in various scenarios.

Practical 3: Practicing Python Loops and Logical Conditions

Objective

In this lab, you will implement both recursive and iterative approaches to generate Fibonacci sequences in Python. This exercise will help you understand the differences between recursive and iterative problem-solving techniques, as well as analyze their performance characteristics.

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of functions in Python
  • Familiarity with recursion and iteration concepts

Lab Steps

Step 1: Python Operators

Looking into python operators with print statements

import random
a = 10
b = random.randint(0,20)
c = 100
print("a is", a, "and", "b is", b)
print("The answer to a + b is", a + b)
print("a < b is", a < b)
print("a == b is", a == b)
print("a + b is", a + b)
print("a * b is", a * b)
print("a to the power of b is", a ** b)

Step 2: If-Else Statements

Trying out if, elif and else

a = 33
b = 200
if b > a:
    print("b is greater than a")
a = 33
b = 33
if b > a:
    print("b is greater than a")
elif a == b:
    print("a and b are equal")
a = 200
b = 33
if b > a:
    print("b is greater than a")
elif a == b:
    print("a and b are equal")
else:
    print("a is greater than b")

Step 3: Match case Statements

Printing different days

def main():
    day = 8
    match day:
        case 1:
            print("Monday")
        case 2:
            print("Tuesday")
        case 3:
            print("Wednesday")
        case 4:
            print("Thursday")
        case 5:
            print("Friday")
        case 6:
            print("Saturday")
        case 7:
            print("Sunday")
        case _:
            print("Not a weekday")
main()

Step 4: While Loops

While Loop

A guessing game using a while loop

import random
a = 10
b = random.randint(0,20)
while(c != b):
    c = int(input("Enter Guess! "))
    if (c == b):
        print("You won!")
        break
    else:
        print("Wrong Answer, Try Again!")

Remember to analyze the time and space complexity of each approach and consider how they might perform with very large inputs.

Practical 4: Flow Chart and Pseudo Code

Objective

In this lab, you will implement following Exercises. This exercise will help you understand the flow chart and pseudo code principles.

Submission Date:

Prerequisites

  • Basic knowledge of Python syntax

Lab Steps

Step 1: Download and install Flowgorithm

Get Flowgorithm from the following link

http://www.flowgorithm.org/download/index.html

Step 2: Follow Basic Tutorial

Check the beginner video about checking a number for even or odd property.

Flowgorithm Full Tutorial

Step 3: Implement Basic Sum

Implement the following flow chart, and create python source code

image

Step 4: Implement Largest Number

Implement the following flow chart, and create python source code

image

Step 5: Study Pseudo Code

Binary search is a searching algorithm that works only for sorted search space. It repeatedly divides the search space into half by using the fact that the search space is sorted and checking if the desired search result will be found in the left or right half. Example: Given a sorted array Arr[] and a value X, The task is to find the index at which X is present in Arr[]. Below is the pseudocode for Binary search.

BinarySearch(ARR, X, LOW, HIGH)
       repeat till LOW = HIGH
              MID = (LOW + HIGH)/2
              if (X == ARR[mid])
                  return MID
              else if (x > ARR[MID]) 
                  LOW = MID + 1
              else                  
                  HIGH = MID – 1

Step 6: Try past midterm question

Design a simple weather data analysis program for a local meteorology station. The program should calculate the average temperature, convert units, and determine temperature ranges.

Create a flowchart and write pseudocode for this system with the following requirements:

  • Ask the user to input the highest and lowest temperatures for a day (in Celsius).
  • Calculate and display the average temperature for the day.
  • Convert the average temperature to Fahrenheit and display it.

Determine and display the temperature range category based on the following criteria:

  • If the range (difference between highest and lowest) is less than 10°C: "Stable"
  • If the range is between 10°C and 20°C: "Moderate"
  • If the range is greater than 20°C: "Volatile"
  • If either input temperature is less than -50°C or greater than 50°C, display an error message for invalid input.

Exercises for Students

  1. Implement more complex algorithms in flowgorithm
  2. Create Pseudo Code for these two simple algorithms

Conclusion

In this lab, you've learned flow charts and on looking at pseudo code

Key takeaways:

Practical 5: Implementing Linear and Binary Search Algorithms

Objective

In this lab, you will implement both linear and binary search algorithms in Python. You'll learn about the differences between these search methods, their time complexities, and when to use each one. This exercise will help you practice algorithm implementation, list manipulation, and control structures in Python.

Submission Date: October 29th

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of lists and functions in Python
  • Familiarity with control structures (if statements, loops)

Lab Steps

Let's start by implementing the linear search algorithm:

def linear_search(arr, target):
    for i in range(len(arr)):
        if arr[i] == target:
            return i  # Return the index if the target is found
    return -1  # Return -1 if the target is not in the list

# Test the function
test_list = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
result = linear_search(test_list, 6)
print(f"Linear Search: Index of 6 is {result}")

Now, let's implement the binary search algorithm. Remember, binary search requires a sorted list:

def binary_search(arr, target):
    left, right = 0, len(arr) - 1
    
    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid  # Return the index if the target is found
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    
    return -1  # Return -1 if the target is not in the list

# Test the function
test_list_sorted = sorted(test_list)
result = binary_search(test_list_sorted, 6)
print(f"Binary Search: Index of 6 in sorted list is {result}")

Step 3: Compare Performance

Let's create a function to compare the performance of both search algorithms:

import time

def compare_search_algorithms(arr, target):
    # Linear Search
    start_time = time.time()
    linear_result = linear_search(arr, target)
    linear_time = time.time() - start_time
    
    # Binary Search (on sorted array)
    arr_sorted = sorted(arr)
    start_time = time.time()
    binary_result = binary_search(arr_sorted, target)
    binary_time = time.time() - start_time
    
    print(f"Linear Search: Found at index {linear_result}, Time: {linear_time:.6f} seconds")
    print(f"Binary Search: Found at index {binary_result}, Time: {binary_time:.6f} seconds")

# Test with a larger list
large_list = list(range(10000))
compare_search_algorithms(large_list, 8888)

Let's also implement a recursive version of binary search:

def binary_search_recursive(arr, target, left, right):
    if left > right:
        return -1
    
    mid = (left + right) // 2
    if arr[mid] == target:
        return mid
    elif arr[mid] < target:
        return binary_search_recursive(arr, target, mid + 1, right)
    else:
        return binary_search_recursive(arr, target, left, mid - 1)

# Test the recursive function
result = binary_search_recursive(test_list_sorted, 6, 0, len(test_list_sorted) - 1)
print(f"Recursive Binary Search: Index of 6 in sorted list is {result}")

Step 5: Create a Main Function

Finally, let's create a main function that demonstrates all our search algorithms:

def main():
    # Create a list of 20 random integers between 1 and 100
    import random
    test_list = [random.randint(1, 100) for _ in range(20)]
    
    print("Original list:", test_list)
    print("Sorted list:", sorted(test_list))
    
    target = random.choice(test_list)  # Choose a random target from the list
    print(f"\nSearching for: {target}")
    
    # Linear Search
    result = linear_search(test_list, target)
    print(f"Linear Search: Found at index {result}")
    
    # Binary Search (iterative)
    sorted_list = sorted(test_list)
    result = binary_search(sorted_list, target)
    print(f"Binary Search (iterative): Found at index {result}")
    
    # Binary Search (recursive)
    result = binary_search_recursive(sorted_list, target, 0, len(sorted_list) - 1)
    print(f"Binary Search (recursive): Found at index {result}")
    
    # Compare performance
    print("\nPerformance Comparison:")
    compare_search_algorithms(list(range(100000)), 99999)

if __name__ == "__main__":
    main()

Exercises for Students

  1. Modify the linear search function to return all indices where the target appears, not just the first one.
  2. Implement a function that uses binary search to find the insertion point for a target value in a sorted list.
  3. Create a function that counts the number of comparisons made in each search algorithm.
  4. Implement a jump search algorithm and compare its performance with linear and binary search.

Conclusion

In this lab, you've implemented both linear and binary search algorithms in Python. You've learned about their differences, time complexities, and when to use each one. The modular approach we've taken allows for easy testing and comparison of these algorithms.

Remember that while binary search is generally faster for large sorted lists, it requires the list to be sorted first. Linear search, although slower for large lists, works on unsorted lists and can be more efficient for small lists or when searching for multiple occurrences of an element.

Practical 6: Implementing Sorting Algorithms

Objective

In this lab, you will implement three classic sorting algorithms: Bubble Sort, Merge Sort, and Quick Sort. This exercise will help you understand the mechanics of these algorithms and compare their performance.

Submission Date: November 4st

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of lists and functions in Python
  • Familiarity with time complexity concepts (optional, but helpful)

Lab Steps

Step 1: Implement Bubble Sort

Bubble Sort is a simple sorting algorithm that repeatedly steps through the list, compares adjacent elements and swaps them if they are in the wrong order.

def bubble_sort(arr):
    n = len(arr)
    for i in range(n):
        for j in range(0, n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
    return arr

# Test the function
test_arr = [64, 34, 25, 12, 22, 11, 90]
sorted_arr = bubble_sort(test_arr.copy())
print("Bubble Sort Result:", sorted_arr)

Step 2: Implement Merge Sort

Merge Sort is a divide-and-conquer algorithm that divides the input array into two halves, recursively sorts them, and then merges the two sorted halves.

def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    
    return merge(left, right)

def merge(left, right):
    result = []
    i, j = 0, 0
    
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    
    result.extend(left[i:])
    result.extend(right[j:])
    return result

# Test the function
test_arr = [64, 34, 25, 12, 22, 11, 90]
sorted_arr = merge_sort(test_arr)
print("Merge Sort Result:", sorted_arr)

Step 3: Implement Quick Sort

Quick Sort is another divide-and-conquer algorithm that picks an element as a pivot and partitions the array around the pivot.

def quick_sort(arr):
    if len(arr) <= 1:
        return arr
    else:
        pivot = arr[0]
        less = [x for x in arr[1:] if x <= pivot]
        greater = [x for x in arr[1:] if x > pivot]
        return quick_sort(less) + [pivot] + quick_sort(greater)

# Test the function
test_arr = [64, 34, 25, 12, 22, 11, 90]
sorted_arr = quick_sort(test_arr)
print("Quick Sort Result:", sorted_arr)

Step 4: Compare Performance

Now, let's create a function to compare the performance of these sorting algorithms:

import time
import random

def compare_sorting_algorithms(arr):
    algorithms = [
        ("Bubble Sort", bubble_sort),
        ("Merge Sort", merge_sort),
        ("Quick Sort", quick_sort)
    ]
    
    for name, func in algorithms:
        arr_copy = arr.copy()
        start_time = time.time()
        func(arr_copy)
        end_time = time.time()
        print(f"{name} took {end_time - start_time:.6f} seconds")

# Generate a large random array
large_arr = [random.randint(1, 1000) for _ in range(1000)]

# Compare the algorithms
compare_sorting_algorithms(large_arr)

Exercises for Students

  1. Implement an in-place version of Quick Sort to improve its space efficiency.
  2. Modify Bubble Sort to stop early if the list becomes sorted before all passes are complete.
  3. Implement a hybrid sorting algorithm that uses Insertion Sort for small subarrays in Merge Sort or Quick Sort.
  4. Create a visualization of how each sorting algorithm works using a library like Matplotlib.

Conclusion

In this lab, you've implemented three classic sorting algorithms: Bubble Sort, Merge Sort, and Quick Sort. You've also compared their performance on a large random array.

Key takeaways:

  • Bubble Sort is simple but inefficient for large datasets (O(n^2) time complexity).
  • Merge Sort provides stable, predictable performance (O(n log n) time complexity) but requires extra space.
  • Quick Sort is often the fastest in practice (average O(n log n) time complexity) but can degrade to O(n^2) in worst cases.

Remember that the choice of sorting algorithm depends on the specific requirements of your application, such as the size and initial order of the data, memory constraints, and whether a stable sort is needed.

Practical 7: Algorithms Exercises on Arrays, Hashing, Two Pointers, Sliding Window

Objective

In this lab, you will implement following algorithms: Valid Anagram, Two Sums, Valid Palindrome, Three Sums, Sliding Window Exercises. This exercise will help you understand the mechanics of these algorithms and compare their performance.

Submission Date: November 4st

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of lists and functions in Python
  • Familiarity with time complexity concepts (optional, but helpful)

Lab Steps: Algorithm Problems on Contains Duplicate, Valid Anagram, Two Sums

Leetcode problems on Contains Duplicate, Valid Anagram, Two Sums

Step 1: Contains Duplicate

https://leetcode.com/problems/contains-duplicate
Given an integer array nums, return true if any value appears at least twice in the array, and return false if every element is distinct.

Example:
Input: nums = [1,2,3,1]
Output: true
Explanation: The element 1 occurs at the indices 0 and 3.

Input: nums = [1,2,3,4]
Output: false
Explanation: All elements are distinct.

Python

class Solution:
	def containsDuplicate(self, nums):
		return len(set(nums))!=len(nums)

Python 3

class Solution:
	def containsDuplicate(self, nums: List[int]) -> bool:
		return len(set(nums))!=len(nums)

HashSet Method

# Python Program check if there are any duplicates  
# in the array using Hashing

def checkDuplicates(arr):
    n = len(arr)

    # Create a set to store the unique elements
    st = set()

    # Iterate through each element
    for i in range(n):
        
        # If the element is already present, return true
        # Else insert the element into the set
        if arr[i] in st:
            return True
        else:
            st.add(arr[i])

    # If no duplicates are found, return false
    return False

if __name__ == "__main__":
    arr = [4, 5, 6, 4]
    print(checkDuplicates(arr))

Step 2: Valid Anagram

https://leetcode.com/problems/valid-anagram
Given two strings s and t, return true if t is an anagram of s, and false otherwise. Anagram is a word, phrase, or name formed by rearranging the letters of another.

Example:
Input: s = "anagram", t = "nagaram"
Output: true

Input: s = "rat", t = "car"
Output: false

Python

class Solution:
    def isAnagram(self, s, t):
        if len(s) != len(t):
            return False
        count = [0] * 26
        for i in range(len(s)):
            count[ord(s[i]) - ord('a')] += 1
            count[ord(t[i]) - ord('a')] -= 1
        return (len(set(count)) == 1 and list(set(count))[0] == 0)

Python 3

class Solution:
    def isAnagram(self, s: str, t: str) -> bool:
        if len(s) != len(t):
            return False
        count = [0] * 26
        for i in range(len(s)):
            count[ord(s[i]) - ord('a')] += 1
            count[ord(t[i]) - ord('a')] -= 1
        return (len(set(count)) == 1 and list(set(count))[0] == 0)

Using Hash Map or Dictionary

# Python Code to check if two Strings are anagram of 
# each other using Dictionary

def areAnagrams(s1, s2):
    
    # Create a hashmap to store character frequencies
    charCount = {}
    
    # Count frequency of each character in string s1
    for ch in s1:
        charCount[ch] = charCount.get(ch, 0) + 1
  
    # Count frequency of each character in string s2
    for ch in s2:
        charCount[ch] = charCount.get(ch, 0) - 1
  
    # Check if all frequencies are zero
    for value in charCount.values():
        if value != 0:
            return False
    
    # If all conditions satisfied, they are anagrams
    return True

if __name__ == "__main__":
    s1 = "geeks"
    s2 = "kseeg"
    print("true" if areAnagrams(s1, s2) else "false")

Step 3: Two Sum

https://leetcode.com/problems/two-sum
Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target. You may assume that each input would have exactly one solution, and you may not use the same element twice. You can return the answer in any order.

Example:
Input: nums = [2,7,11,15], target = 9
Output: [0,1]
Explanation: Because nums[0] + nums[1] == 9, we return [0, 1].

Input: nums = [3,2,4], target = 6
Output: [1,2]

Python3

class Solution:
    def twoSum(self, nums: list[int], target: int) -> list[int]:
        for i in range(1, len(nums)):
            for j in range(i, len(nums)):
                if nums[j] + nums[j - i] == target:
                    return [j, j - i]
        return []

Python

class Solution:
    def twoSum(self, nums, target):
        for i in range(1, len(nums)):
            for j in range(i, len(nums)):
                if nums[j] + nums[j - i] == target:
                    return [j, j - i]
        return []

One-Pass Hash Table Method

class Solution(object):
    def twoSum(self, nums, target):
      numMap = {}
      for i, num in enumerate(nums):
          complement = target - num
          if complement in numMap:
            return [numMap[complement], i]
          numMap[num] = i
      return []

Algorithm Problems on Valid Palindrome, Three Sums

Leetcode problems on Valid Palindrome, Three Sums

Step 4: Valid Palindrome

https://leetcode.com/problems/valid-palindrome
A phrase is a palindrome if, after converting all uppercase letters into lowercase letters and removing all non-alphanumeric characters, it reads the same forward and backward. Alphanumeric characters include letters and numbers. Given a string s, return true if it is a palindrome, or false otherwise.

Example:
Input: s = "A man, a plan, a canal: Panama"
Output: true
Explanation: "amanaplanacanalpanama" is a palindrome.

Input: s = "race a car"
Output: false
Explanation: "raceacar" is not a palindrome.

Python 3

class Solution:
    def isPalindrome(self, s: str) -> bool:
        s = [c.lower() for c in s if c.isalnum()]
        return all (s[i] == s[~i] for i in range(len(s)//2))

Python

class Solution:
    def isPalindrome(self, s):
        s = [c.lower() for c in s if c.isalnum()]
        return all (s[i] == s[~i] for i in range(len(s)//2))

Step 5: Three Sum

https://leetcode.com/problems/3sum
Given an integer array nums, return all the triplets [nums[i], nums[j], nums[k]] such that i != j, i != k, and j != k, and nums[i] + nums[j] + nums[k] == 0. Notice that the solution set must not contain duplicate triplets.

Example:
Input: nums = [-1,0,1,2,-1,-4]
Output: [[-1,-1,2],[-1,0,1]]
Explanation:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0.
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0.
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0.
The distinct triplets are [-1,0,1] and [-1,-1,2].
Notice that the order of the output and the order of the triplets does not matter.

Input: nums = [0,1,1]
Output: []
Explanation: The only possible triplet does not sum up to 0.

Python 3

class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        target = 0
        nums.sort()
        s = set()
        output = []
        for i in range(len(nums)):
            j = i + 1
            k = len(nums) - 1
            while j < k:
                sum = nums[i] + nums[j] + nums[k]
                if sum == target:
                    s.add((nums[i], nums[j], nums[k]))
                    j += 1
                    k -= 1
                elif sum < target:
                    j += 1
                else:
                    k -= 1
        output = list(s)
        return output

Python

class Solution:
    def threeSum(self, nums):
        target = 0
        nums.sort()
        s = set()
        output = []
        for i in range(len(nums)):
            j = i + 1
            k = len(nums) - 1
            while j < k:
                sum = nums[i] + nums[j] + nums[k]
                if sum == target:
                    s.add((nums[i], nums[j], nums[k]))
                    j += 1
                    k -= 1
                elif sum < target:
                    j += 1
                else:
                    k -= 1
        output = list(s)
        return output

Algorithm Problems on Sliding Window (Best Time to Buy And Sell Stock), Longest Substring Without Repeating Characters

Leetcode problems on Sliding Window (Best Time to Buy And Sell Stock), Longest Substring Without Repeating Characters

Step 6: Best Time to Buy And Sell Stock

https://leetcode.com/problems/best-time-to-buy-and-sell-stock
You are given an array prices where prices[i] is the price of a given stock on the ith day. You want to maximize your profit by choosing a single day to buy one stock and choosing a different day in the future to sell that stock. Return the maximum profit you can achieve from this transaction. If you cannot achieve any profit, return 0.

Example:
Input: prices = [7,1,5,3,6,4]
Output: 5
Explanation: Buy on day 2 (price = 1) and sell on day 5 (price = 6), profit = 6-1 = 5. Note that buying on day 2 and selling on day 1 is not allowed because you must buy before you sell.

Python

class Solution:
    def maxProfit(self, prices):
        buy = prices[0]
        profit = 0
        for i in range(1, len(prices)):
            if prices[i] < buy:
                buy = prices[i]
            elif prices[i] - buy > profit:
                profit = prices[i] - buy
        return profit

Step 7: Longest Substring Without Repeating Characters

https://leetcode.com/problems/longest-substring-without-repeating-characters
Given a string s, find the length of the longest without duplicate characters.

Example:
Input: s = "abcabcbb”
Output: 3
Explanation: The answer is "abc", with the length of 3.

Input: s = "bbbbb"
Output: 1
Explanation: The answer is "b", with the length of 1.

class Solution:
    def lengthOfLongestSubstring(self, s):
        n = len(s)
        maxLength = 0
        charSet = set()
        left = 0
        for right in range(n):
            if s[right] not in charSet:
                charSet.add(s[right])
                maxLength = max(maxLength, right - left + 1)
            else:
                while s[right] in charSet:
                    charSet.remove(s[left])
                    left += 1
                charSet.add(s[right])
        return maxLength

Exercises for Students

Conclusion

In this lab, you've implemented some algorithms: Contains Duplicate, Valid Anagram, Two Sums, Valid Palindrome, Three Sums and Sliding Window Exercises.

Key takeaways:

  • How to create algorithms that solve specific problems.

Practical 8: Implementing Stacks and Queues, including leetcode problems to Implement Stack using Queue(s), and Valid Parentheses

Objective

In this lab, you will implement stack and queue data structures in Python and use them to solve practical problems. This exercise will help you understand these fundamental data structures and their applications.

Submission Date: October 29th

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of lists in Python
  • Familiarity with classes in Python (optional, but helpful)

Part 1: Implementing a Stack

A stack is a Last-In-First-Out (LIFO) data structure. Let's implement a basic stack class:

class Stack:
    def __init__(self):
        self.items = []

    def is_empty(self):
        return len(self.items) == 0

    def push(self, item):
        self.items.append(item)

    def pop(self):
        if not self.is_empty():
            return self.items.pop()
        else:
            raise IndexError("Stack is empty")

    def peek(self):
        if not self.is_empty():
            return self.items[-1]
        else:
            raise IndexError("Stack is empty")

    def size(self):
        return len(self.items)

# Test the Stack
stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)
print(stack.pop())  # Should print 3
print(stack.peek())  # Should print 2
print(stack.size())  # Should print 2

Part 2: Implementing a Queue

A queue is a First-In-First-Out (FIFO) data structure. Let's implement a basic queue class:

class Queue:
    def __init__(self):
        self.items = []

    def is_empty(self):
        return len(self.items) == 0

    def enqueue(self, item):
        self.items.append(item)

    def dequeue(self):
        if not self.is_empty():
            return self.items.pop(0)
        else:
            raise IndexError("Queue is empty")

    def front(self):
        if not self.is_empty():
            return self.items[0]
        else:
            raise IndexError("Queue is empty")

    def size(self):
        return len(self.items)

# Test the Queue
queue = Queue()
queue.enqueue(1)
queue.enqueue(2)
queue.enqueue(3)
print(queue.dequeue())  # Should print 1
print(queue.front())  # Should print 2
print(queue.size())  # Should print 2

Part 3: Leetcode Lesson: Implementing Stack Using Queue

1. Problem Statement

Implement a last-in-first-out (LIFO) stack using only two queues. The implemented stack should support all the functions of a normal stack (push, pop, top, empty).

Implement the MyStack class:

  • push(x) Pushes element x to the top of the stack.
  • pop() Removes the element on the top of the stack and returns it.
  • top() Returns the element on the top of the stack.
  • empty() Returns true if the stack is empty, false otherwise.

Notes:

  • You must use only standard queue operations, which means only push to back, peek/pop from front, size, and is empty are valid.
  • Depending on your language, the queue may not be supported natively. You may simulate a queue using a list or deque (double-ended queue), as long as you use only a queue's standard operations.

2. Conceptual Understanding

This problem challenges us to implement a stack (LIFO - Last In, First Out) data structure using only queue (FIFO - First In, First Out) operations. It's like trying to create a stack of plates (where you add and remove from the top) using only queues (where you add to the back and remove from the front).

The key is to find a way to reverse the order of elements when needed, as stacks and queues have opposite ordering principles.

Here's how we can implement the stack using two queues:

  1. Use two queues: q1 and q2.
  2. For push operation:
    • Add the new element to q2.
    • Move all elements from q1 to q2.
    • Swap q1 and q2.
  3. For pop, top, and empty operations:
    • Perform these operations directly on q1.

This approach ensures that q1 always has the elements in the correct stack order (newest on top).

3. Python Implementation

from queue import Queue

class MyStack:
    def __init__(self):
        self.q1 = Queue()
        self.q2 = Queue()

    def push(self, x: int) -> None:
        self.q2.put(x)
        while not self.q1.empty():
            self.q2.put(self.q1.get())
        self.q1, self.q2 = self.q2, self.q1

    def pop(self) -> int:
        return self.q1.get()

    def top(self) -> int:
        return self.q1.queue[0]

    def empty(self) -> bool:
        return self.q1.empty()
  • We use Python's Queue class for our queues.
  • push adds the new element to q2, moves all elements from q1 to q2, then swaps q1 and q2.
  • pop and top operate directly on q1.
  • empty checks if q1 is empty.

Solving Practical Problems: Now that we have implemented our stack and queue, let's use them to solve some practical problems.

Part 4 Problem: Reverse a String

Use a stack to reverse a string:

def reverse_string(s):
    stack = Stack()
    for char in s:
        stack.push(char)
    
    reversed_string = ""
    while not stack.is_empty():
        reversed_string += stack.pop()
    
    return reversed_string

# Test the function
print(reverse_string("Hello, World!"))  # Should print "!dlroW ,olleH"

Part 5 Problem: Hot Potato Simulation

Use a queue to simulate the Hot Potato game:

def hot_potato(names, num):
    queue = Queue()
    for name in names:
        queue.enqueue(name)
    
    while queue.size() > 1:
        for _ in range(num):
            queue.enqueue(queue.dequeue())
        queue.dequeue()
    
    return queue.dequeue()

# Test the function
names = ["Bill", "David", "Susan", "Jane", "Kent", "Brad"]
print(hot_potato(names, 7))  # The winner's name will be printed

Part 6 Problem: Balanced Parentheses

Use a stack to check if a string of parentheses is balanced:

def is_balanced(parentheses):
    stack = Stack()
    for p in parentheses:
        if p == '(':
            stack.push(p)
        elif p == ')':
            if stack.is_empty():
                return False
            stack.pop()
    return stack.is_empty()

# Test the function
print(is_balanced("((()))"))  # Should print True
print(is_balanced("(()"))  # Should print False

Part 7 Leetcode Lesson: Valid Parentheses

1. Problem Statement

Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid.

An input string is valid if:

  1. Open brackets must be closed by the same type of brackets.
  2. Open brackets must be closed in the correct order.
  3. Every close bracket has a corresponding open bracket of the same type.

Example 1:

Input: s = "()"

Output: true

Example 2:

Input: s = "()[]{}"

Output: true

Example 3:

Input: s = "(]"

Output: false

Constraints:

  • 1 <= s.length <= 10^4
  • s consists of parentheses only '()[]{}'

2. Conceptual Understanding

This problem is about validating the structure of nested parentheses. It's similar to checking if HTML tags or code blocks are properly nested and closed.

Think of it like nesting dolls: each smaller doll (inner parenthesis) needs to be completely enclosed by its larger doll (outer parenthesis) of the same type.

We'll use a stack-based approach:

  1. Initialize an empty stack.
  2. Iterate through each character in the string:
    • If it's an opening bracket, push it onto the stack.
    • If it's a closing bracket:
      • If the stack is empty, return False (no matching opening bracket).
      • If the top of the stack doesn't match the current closing bracket, return False.
      • If it matches, pop the top element from the stack.
  3. After the loop, return True if the stack is empty, False otherwise.

This approach ensures that brackets are closed in the correct order and that every closing bracket has a matching opening bracket.

3. Python Implementation

def isValid(s: str) -> bool:
    stack = []
    bracket_map = {")": "(", "}": "{", "]": "["}
    
    for char in s:
        if char in bracket_map:  # it's a closing bracket
            if not stack or stack[-1] != bracket_map[char]:
                return False
            stack.pop()
        else:  # it's an opening bracket
            stack.append(char)
    
    return len(stack) == 0
  • We use a list stack to simulate a stack data structure.
  • bracket_map is a dictionary that maps closing brackets to their corresponding opening brackets.
  • stack[-1] accesses the top element of the stack.
  • stack.pop() removes and returns the top element of the stack.
  • stack.append(char) adds an element to the top of the stack.

Further Exercises for Students

  1. Implement a function that uses a stack to evaluate postfix expressions.
  2. Create a function that uses two stacks to implement a queue.
  3. Use a queue to implement a basic task scheduler that processes tasks in the order they were added.
  4. Implement a function that uses a stack to convert infix expressions to postfix.

Conclusion

In this lab, you've implemented stack and queue data structures in Python and used them to solve practical problems. These fundamental data structures are crucial in computer science and are used in various applications, from algorithm implementation to system design.

Remember to test your code with different inputs to ensure it works correctly in various scenarios. As you progress, try to think of other real-world problems that could be solved using stacks and queues.

Practical 9: Singly Linked List Implementation including leetcode problems to Reverse Linked List, Merge Two Sorted Lists, Remove Nth Node From End of List

Objective

In this lab, you will implement a singly linked list data structure in Python. You'll create basic operations and list manipulation functions, gaining a deeper understanding of linked data structures and their operations.

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of classes
  • Familiarity with data structures concepts

Part 1: Singly Linked List Implementation

Step 1: Define the Node Class

First, let's create a Node class to represent individual elements in our linked list:

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

Step 2: Create the LinkedList Class

Now, let's create the LinkedList class with a constructor:

class LinkedList:
    def __init__(self):
        self.head = None

Step 3: Implement the Append Method

Let's add a method to append nodes to the end of the list:

class LinkedList:
    # ... (previous code)

    def append(self, data):
        new_node = Node(data)
        if not self.head:
            self.head = new_node
            return
        current = self.head
        while current.next:
            current = current.next
        current.next = new_node

# Test the append method
ll = LinkedList()
ll.append(1)
ll.append(2)
ll.append(3)

Step 4: Implement the Display Method

Now, let's add a method to display the list contents:

class LinkedList:
    # ... (previous code)

    def display(self):
        elements = []
        current = self.head
        while current:
            elements.append(current.data)
            current = current.next
        print(" -> ".join(map(str, elements)))

# Test the display method
ll.display()  # Output: 1 -> 2 -> 3

Step 5: Implement the Insert Method

Let's add a method to insert a node at a specific position:

class LinkedList:
    # ... (previous code)

    def insert(self, data, position):
        new_node = Node(data)
        if position == 0:
            new_node.next = self.head
            self.head = new_node
            return
        current = self.head
        for _ in range(position - 1):
            if current is None:
                raise IndexError("Position out of range")
            current = current.next
        new_node.next = current.next
        current.next = new_node

# Test the insert method
ll.insert(4, 1)
ll.display()  # Output: 1 -> 4 -> 2 -> 3

Step 6: Implement the Delete Method

Now, let's implement a method to delete a node by its value:

class LinkedList:
    # ... (previous code)

    def delete(self, data):
        if not self.head:
            return
        if self.head.data == data:
            self.head = self.head.next
            return
        current = self.head
        while current.next:
            if current.next.data == data:
                current.next = current.next.next
                return
            current = current.next

# Test the delete method
ll.delete(2)
ll.display()  # Output: 1 -> 4 -> 3

Step 7: Implement the Search Method

Let's add a method to search for a value in the list:

class LinkedList:
    # ... (previous code)

    def search(self, data):
        current = self.head
        position = 0
        while current:
            if current.data == data:
                return position
            current = current.next
            position += 1
        return -1

# Test the search method
print(ll.search(4))  # Output: 1
print(ll.search(5))  # Output: -1

Step 8: Implement the Reverse Method

Finally, let's add a method to reverse the linked list:

class LinkedList:
    # ... (previous code)

    def reverse(self):
        prev = None
        current = self.head
        while current:
            next_node = current.next
            current.next = prev
            prev = current
            current = next_node
        self.head = prev

# Test the reverse method
ll.reverse()
ll.display()  # Output: 3 -> 4 -> 1

Part 2: Reverse Linked List

1. Problem Statement

Given the head of a singly linked list, reverse the list, and return the reversed list.

Example 1:

Input: head = [1,2,3,4,5]

Output: [5,4,3,2,1]

Example 2:

Input: head = [1,2]

Output: [2,1]

Example 3:

Input: head = []

Output: []

Constraints:

  • The number of nodes in the list is the range [0, 5000].
  • -5000 <= Node.val <= 5000

2. Conceptual Understanding

A linked list is a data structure where each element (node) contains a value and a reference (or link) to the next node in the sequence. Reversing a linked list means changing these links so that each node points to its previous node instead of its next node.

Imagine a chain where each link is holding hands with the next link. To reverse it, we need to make each link let go of the hand it's holding and grab the hand of the link behind it instead.

We'll focus on the iterative approach as it's often the most intuitive and efficient:

  1. Initialize three pointers: prev as None, current as the head of the list, and next as None.
  2. Traverse the list:
    • Save the next node.
    • Reverse the current node's pointer to point to the previous node.
    • Move prev and current one step forward.
  3. Return prev as the new head of the reversed list.

This approach allows us to reverse the list in a single pass, changing links as we go.

3. Python Implementation

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def reverseList(head):
    prev = None
    current = head
    
    while current is not None:
        next_temp = current.next  # Store next node
        current.next = prev       # Reverse the link
        prev = current            # Move prev one step
        current = next_temp       # Move current one step
    
    return prev  # prev is the new head of the reversed list
  • We define a ListNode class to represent each node in the linked list.
  • The reverseList function takes the head of the list and returns the new head of the reversed list.
  • We use three pointers: prev, current, and next_temp to manage the reversal process.

Part 3: Merge Two Sorted Lists

1. Problem Statement

You are given the heads of two sorted linked lists list1 and list2.

Merge the two lists in a one sorted list. The list should be made by splicing together the nodes of the first two lists.

Return the head of the merged linked list.

Example 1:

Input: list1 = [1,2,4], list2 = [1,3,4]

Output: [1,1,2,3,4,4]

Example 2:

Input: list1 = [], list2 = []

Output: []

Example 3:

Input: list1 = [], list2 = [0]

Output: [0]

Constraints:

  • The number of nodes in both lists is in the range [0, 50].
  • -100 <= Node.val <= 100
  • Both list1 and list2 are sorted in non-decreasing order.

2. Conceptual Understanding

This problem involves working with linked lists, a fundamental data structure in computer science. The task is to combine two already sorted linked lists into a single sorted linked list.

Think of it like merging two sorted piles of numbered cards into one sorted pile. You compare the top cards of each pile and always take the smaller one to add to your new pile.

We'll use the in-place merge approach as it's efficient and doesn't require extra space:

  1. Create a dummy node as the start of our result list.
  2. Use a pointer to keep track of where we're inserting nodes.
  3. Iterate through both lists simultaneously:
    • Compare the current nodes of both lists.
    • Append the smaller node to our result list.
    • Move forward in the list we took the node from.
  4. If one list is exhausted, append the remainder of the other list.
  5. Return the next node after the dummy node (the actual head of our merged list).

This approach is optimal because it merges the lists in a single pass and doesn't create any new nodes.

3. Python Implementation

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def mergeTwoLists(list1: ListNode, list2: ListNode) -> ListNode:
    dummy = ListNode(0)
    current = dummy
    
    while list1 and list2:
        if list1.val <= list2.val:
            current.next = list1
            list1 = list1.next
        else:
            current.next = list2
            list2 = list2.next
        current = current.next
    
    if list1:
        current.next = list1
    if list2:
        current.next = list2
    
    return dummy.next
  • We use a dummy node to simplify handling the head of the merged list.
  • current keeps track of the last node in our merged list.
  • We compare list1.val and list2.val, always choosing the smaller one.
  • After the loop, we append any remaining nodes from either list.

Part 4: Remove Nth Node From End of List

1. Problem Statement

Given the head of a linked list, remove the nth node from the end of the list and return its head.

Example 1:

Input: head = [1,2,3,4,5], n = 2

Output: [1,2,3,5]

Example 2:

Input: head = [1], n = 1

Output: []

Example 3:

Input: head = [1,2], n = 1

Output: [1]

Constraints:

  • The number of nodes in the list is sz.
  • 1 <= sz <= 30
  • 0 <= Node.val <= 100
  • 1 <= n <= sz

2. Conceptual Understanding

This problem involves manipulating a linked list, which is a linear data structure where elements are stored in nodes. Each node contains a data field and a reference (or link) to the next node in the sequence.

The challenge here is to remove a node from a specific position, counting from the end of the list. This requires us to think about how to traverse the list and keep track of positions relative to the end.

We'll use the one-pass algorithm with two pointers, as it's efficient and doesn't require extra space:

  1. Initialize two pointers, fast and slow, to the head of the list.
  2. Move fast n nodes ahead.
  3. If fast is null, it means we need to remove the head. Return head.next.
  4. Move both fast and slow until fast reaches the last node.
  5. Now, slow is just before the node we want to remove.
  6. Update slow.next to skip the next node (effectively removing it).
  7. Return the head of the modified list.

This approach is optimal because it solves the problem in one pass and uses constant extra space.

3. Python Implementation

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def removeNthFromEnd(head: ListNode, n: int) -> ListNode:
    dummy = ListNode(0)
    dummy.next = head
    fast = slow = dummy
    
    # Move fast pointer n nodes ahead
    for _ in range(n):
        fast = fast.next
    
    # Move both pointers until fast reaches the end
    while fast.next:
        fast = fast.next
        slow = slow.next
    
    # Remove the nth node
    slow.next = slow.next.next
    
    return dummy.next
  • We use a dummy node to handle the case where we need to remove the head.
  • fast and slow are our two pointers.
  • We move fast n nodes ahead, then move both until fast reaches the end.
  • Finally, we update slow.next to skip (and thus remove) the nth node from the end.

Further Exercises for Students

  1. Implement a method to find the middle element of the linked list.
  2. Create a method to detect if the linked list has a cycle.
  3. Implement a method to remove duplicates from an unsorted linked list.
  4. Add a method to merge two sorted linked lists into a single sorted linked list.

Conclusion

In this lab, you've implemented a singly linked list in Python with various operations such as append, insert, delete, search, and reverse. You've practiced working with linked data structures and manipulating pointers.

Remember to test your implementation thoroughly with different scenarios to ensure it works correctly. Understanding linked lists is crucial for grasping more complex data structures and algorithms.

Practical 10: Recursions and Backtracking Algorithms Exercises

Objective

In this lab, you will implement backtracking and recursion exercises. This exercise will help you understand the mechanics of these algorithms and compare their performance.

Submission Date: November 4st

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of lists and functions in Python
  • Familiarity with time complexity concepts (optional, but helpful)

Part 1: Factorial

Let's create a recursive function to generate factorial solution:

def factorial(n):
    if n == 1:
        return 1
    else:
        return (n * factorial(n-1))

# Test the function
n=3
print("The factorial of", num, "is", factorial(num))

Part 2: Fibonacci Generator

Step 1: Implement a Recursive Fibonacci Generator

Let's create a recursive function to generate Fibonacci numbers:

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

# Test the function
for i in range(10):
    print(f"F({i}) = {fibonacci_recursive(i)}")

Step 2: Implement an Iterative Fibonacci Generator

Now, let's create an iterative function to generate Fibonacci numbers:

def fibonacci_iterative(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

# Test the function
for i in range(10):
    print(f"F({i}) = {fibonacci_iterative(i)}")

Step 3: Compare Performance

Let's create a function to measure the execution time of both approaches:

import time

def measure_time(func, n):
    start = time.time()
    result = func(n)
    end = time.time()
    return result, end - start

# Test both functions and compare their execution times
n = 30
recursive_result, recursive_time = measure_time(fibonacci_recursive, n)
iterative_result, iterative_time = measure_time(fibonacci_iterative, n)

print(f"Recursive: F({n}) = {recursive_result}, Time: {recursive_time:.6f} seconds")
print(f"Iterative: F({n}) = {iterative_result}, Time: {iterative_time:.6f} seconds")

Step 4: Implement a Generator Function for Fibonacci Sequence

Now, let's create a generator function that yields Fibonacci numbers:

def fibonacci_generator(limit):
    a, b = 0, 1
    count = 0
    while count < limit:
        yield a
        a, b = b, a + b
        count += 1

# Test the generator
for i, fib in enumerate(fibonacci_generator(10)):
    print(f"F({i}) = {fib}")

Step 5: Implement Memoization for Recursive Fibonacci

To improve the performance of the recursive approach, let's implement memoization:

def fibonacci_memoized(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fibonacci_memoized(n-1, memo) + fibonacci_memoized(n-2, memo)
    return memo[n]

# Test the memoized function
for i in range(10):
    print(f"F({i}) = {fibonacci_memoized(i)}")

# Compare performance with the original recursive function
n = 30
memoized_result, memoized_time = measure_time(fibonacci_memoized, n)
print(f"Memoized: F({n}) = {memoized_result}, Time: {memoized_time:.6f} seconds")

Part 3 Problem: Climbing Stairs

1. Problem Statement

You are climbing a staircase. It takes n steps to reach the top.

Each time you can either climb 1 or 2 steps. In how many distinct ways can you climb to the top?

Example 1: Input: n = 2 Output: 2 Explanation: There are two ways to climb to the top.

  1. 1 step + 1 step
  2. 2 steps

Example 2: Input: n = 3 Output: 3 Explanation: There are three ways to climb to the top.

  1. 1 step + 1 step + 1 step
  2. 1 step + 2 steps
  3. 2 steps + 1 step

Constraints:

  • 1 <= n <= 45

2. Conceptual Understanding

This problem is about finding the number of different combinations to reach a goal (the top of the stairs) given a set of fixed moves (1 or 2 steps at a time).

Think of it like this: You're standing at the bottom of a staircase, and for each step, you have two choices: take 1 step or take 2 steps. We need to count all the possible sequences of these choices that lead to the top.

This problem introduces the concept of dynamic programming, where we build the solution to a larger problem from solutions to smaller subproblems.

We'll use the Dynamic Programming approach:

  1. Create an array dp where dp[i] represents the number of ways to climb to the i-th step.
  2. Initialize the base cases: dp[1] = 1 (one way to climb 1 step) and dp[2] = 2 (two ways to climb 2 steps).
  3. For steps 3 to n, the number of ways to reach step i is the sum of ways to reach the previous step (and then take 1 step) and the ways to reach two steps back (and then take 2 steps). So, dp[i] = dp[i-1] + dp[i-2]
  4. Return dp[n] as the final answer.

This approach is optimal because it solves each subproblem only once and uses the results to build up to the final solution.

3. Python Implementation

Recursive Method

def climbStairs(n):
    def climb(n):
        if n==1: #only one step option is available
            return 1
        if n ==2: # two options are possible: to take two 1-steps or to only take one 2-steps
            return 2
        return climb(n-1) + climb(n-2)
    return climb(n)

Dynamic Programming Method

def climbStairs(n):
    if n <= 2:
        return n
    
    dp = [0] * (n + 1)
    dp[1] = 1
    dp[2] = 2
    
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    
    return dp[n]
  • We use a list dp to store the number of ways for each step.
  • We initialize the base cases for 1 and 2 steps.
  • We then iterate from 3 to n, filling in the dp array.
  • The final answer is in dp[n].

Part 4: Tower of Hanoi

1. Problem Statement

The Tower of Hanoi is a classic problem in computer science and mathematics. The problem setup is as follows:

  • There are three rods and a number of disks of different sizes which can slide onto any rod.
  • The puzzle starts with the disks neatly stacked in ascending order of size on one rod, the smallest disk at the top.
  • The objective of the puzzle is to move the entire stack to another rod, obeying the following rules:
    1. Only one disk can be moved at a time.
    2. Each move consists of taking the upper disk from one of the stacks and placing it on top of another stack or on an empty rod.
    3. No larger disk may be placed on top of a smaller disk.

Your task is to write a function that prints out the series of moves required to solve the Tower of Hanoi puzzle for n disks.

Example: Input: n = 3 (number of disks) Output:

Move disk 1 from rod A to rod C
Move disk 2 from rod A to rod B
Move disk 1 from rod C to rod B
Move disk 3 from rod A to rod C
Move disk 1 from rod B to rod A
Move disk 2 from rod B to rod C
Move disk 1 from rod A to rod C

2. Conceptual Understanding

The Tower of Hanoi problem is an excellent example of a problem that can be solved using recursion. The key insight is that to move n disks:

  1. Move n-1 disks from the source to the auxiliary rod.
  2. Move the largest disk from the source to the destination rod.
  3. Move the n-1 disks from the auxiliary rod to the destination rod.

This process is recursive because moving n-1 disks involves the same process with a smaller number of disks.

Think of it like unpacking nested boxes. You need to unpack the smaller boxes (move smaller disks) before you can move the largest box (disk).

The recursive solution follows these steps:

  1. Base case: If there's only one disk, move it directly from the source to the destination.
  2. Recursive case: a. Move n-1 disks from source to auxiliary rod. b. Move the nth disk from source to destination. c. Move n-1 disks from auxiliary to destination.

This approach elegantly solves the problem by breaking it down into smaller, manageable subproblems.

3. Python Implementation

def tower_of_hanoi(n, source, destination, auxiliary):
    if n == 1:
        print(f"Move disk 1 from rod {source} to rod {destination}")
        return
    tower_of_hanoi(n-1, source, auxiliary, destination)
    print(f"Move disk {n} from rod {source} to rod {destination}")
    tower_of_hanoi(n-1, auxiliary, destination, source)

# Example usage
n = 3
tower_of_hanoi(n, 'A', 'C', 'B')

Backtracking Algorithms

Part 5: Combination Sum

1. Problem Statement

Given an array of distinct integers candidates and a target integer target, return a list of all unique combinations of candidates where the chosen numbers sum to target. You may return the combinations in any order.

The same number may be chosen from candidates an unlimited number of times. Two combinations are unique if the of at least one of the chosen numbers is different.

The test cases are generated such that the number of unique combinations that sum up to target is less than 150 combinations for the given input.

Example

Input: candidates = [2,3,6,7], target = 7

Output: [[2,2,3],[7]]

Explanation:

2 and 3 are candidates, and 2 + 2 + 3 = 7.

Note that 2 can be used multiple times.

7 is a candidate, and 7 = 7.

These are the only two combinations.

2. Python Implementation

class Solution(object):
    def combinationSum(self, candidates, target):
        def backtrack(start, target, path):
            if target == 0:
                result.append(path)
                return
            if target < 0:
                return
            for i in range(start, len(candidates)):
                backtrack(i, target - candidates[i], path + [candidates[i]])
        result = []
        candidates.sort()
        backtrack(0, target, [])
        return result

1. Problem Statement

Given an m x n grid of characters board and a string word, return true if word exists in the grid.

The word can be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once.

Example:

Input: board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED”

Output: true

image

2. Conceptual Understanding

Implement a recursive function backtrack that takes the current position (i, j) in the grid and the current index k of the word.

Base cases:

  • If k equals the length of the word, return True, indicating that the word has been found.
  • If the current position (i, j) is out of the grid boundaries or the character at (i, j) does not match the character at index k of the word, return False.
  • Mark the current cell as visited by changing its value or marking it as empty.
  • Recursively explore all four directions (up, down, left, right) by calling backtrack with updated positions (i+1, j), (i-1, j), (i, j+1), and (i, j-1).
  • If any recursive call returns True, indicating that the word has been found, return True.
  • If none of the recursive calls returns True, reset the current cell to its original value and return False.
  • Iterate through all cells in the grid and call the backtrack function for each cell. If any call returns True, return True, indicating that the word exists in the grid. Otherwise, return False.

3. Python Implementation

class Solution(object):
    def exist(self, board, word):
        def backtrack(i, j, k):
            if k == len(word):
                return True
            if i < 0 or i >= len(board) or j < 0 or j >= len(board[0]) or board[i][j] != word[k]:
                return False
            temp = board[i][j]
            board[i][j] = '' 
            if backtrack(i+1, j, k+1) or backtrack(i-1, j, k+1) or backtrack(i, j+1, k+1) or backtrack(i, j-1, k+1):
                return True
            board[i][j] = temp
            return False
        for i in range(len(board)):
            for j in range(len(board[0])):
                if backtrack(i, j, 0):
                    return True
        return False

Part 7: Permutations

1. Problem Statement

Given an array nums of distinct integers, return all the possible . You can return the answer in any order.

Example:

Input: nums = [1,2,3]

Output: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

Input: nums = [0,1]

Output: [[0,1],[1,0]]

Input: nums = [1]

Output: [[1]]

2. Conceptual Understanding

image

3. Python Implementation

Solution 1:

from itertools import permutations
class Solution(object):
    def permute(self, nums):
        return list(permutations(nums))

Solution 2:

class Solution(object):
    def permute(self, nums):
        def recursive(nums, perm=[], res=[]):  # helper
            if not nums:
                res.append(perm[::])
            for i in range(len(nums)): # [1,2,3]
                newNums = nums[:i] + nums[i+1:]
                perm.append(nums[i])
                recursive(newNums, perm, res) # - recursive call will reach the leaf
                perm.pop() 
            return res
        return recursive(nums)
  • The function takes four parameters: the number of disks n, and the names of the source, destination, and auxiliary rods.
  • The base case (n == 1) simply moves the disk and returns.
  • For n > 1, we recursively move n-1 disks, then move the nth disk, then recursively move the n-1 disks again.

Further Exercises for Students

  1. Modify the iterative function to return a list of Fibonacci numbers up to n, instead of just the nth number.
  2. Implement a function that finds the index of the first Fibonacci number that exceeds a given value.
  3. Create a function that determines if a given number is a Fibonacci number.
  4. Implement a function that calculates the ratio between consecutive Fibonacci numbers and observe how it approaches the golden ratio.

Discussion Questions

  1. What are the advantages and disadvantages of the recursive approach compared to the iterative approach?
  2. How does memoization improve the performance of the recursive function? Are there any drawbacks?
  3. In what scenarios might you prefer to use a generator function over other implementations?
  4. How does the space complexity differ between these implementations?

Conclusion

In this lab, you've implemented multiple approaches to generate Fibonacci sequences in Python. You've explored recursive, iterative, and generator-based solutions, as well as an optimization technique (memoization). By comparing these approaches, you can gain insights into algorithm design, performance optimization, and the trade-offs between different implementation strategies.

Key takeaways:

Practical 14 (Optional): Implementing a Binary Search Tree

Objective

In this lab, you will implement a Binary Search Tree (BST) in Python, including methods for insertion, deletion, search, and various traversal operations. This exercise will help you understand tree data structures and practice recursive algorithms.

Submission Date: November 1st

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of recursive functions
  • Familiarity with tree data structures (conceptual understanding)

Lab Steps

Step 1: Define the Node Class

First, let's define a class for the nodes of our BST:

class Node:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

Step 2: Implement the Binary Search Tree Class

Now, let's create the BST class with a constructor:

class BinarySearchTree:
    def __init__(self):
        self.root = None

Step 3: Implement the Insertion Method

Let's implement the insertion method:

class BinarySearchTree:
    # ... (previous code)

    def insert(self, value):
        if not self.root:
            self.root = Node(value)
        else:
            self._insert_recursive(self.root, value)

    def _insert_recursive(self, node, value):
        if value < node.value:
            if node.left is None:
                node.left = Node(value)
            else:
                self._insert_recursive(node.left, value)
        else:
            if node.right is None:
                node.right = Node(value)
            else:
                self._insert_recursive(node.right, value)

# Test insertion
bst = BinarySearchTree()
for value in [5, 3, 7, 2, 4, 6, 8]:
    bst.insert(value)

Step 4: Implement the Search Method

Now, let's implement the search method:

class BinarySearchTree:
    # ... (previous code)

    def search(self, value):
        return self._search_recursive(self.root, value)

    def _search_recursive(self, node, value):
        if node is None or node.value == value:
            return node
        if value < node.value:
            return self._search_recursive(node.left, value)
        return self._search_recursive(node.right, value)

# Test search
print(bst.search(4))  # Should return a Node
print(bst.search(9))  # Should return None

Step 5: Implement Traversal Methods

Let's implement in-order, pre-order, and post-order traversals:

class BinarySearchTree:
    # ... (previous code)

    def inorder_traversal(self):
        result = []
        self._inorder_recursive(self.root, result)
        return result

    def _inorder_recursive(self, node, result):
        if node:
            self._inorder_recursive(node.left, result)
            result.append(node.value)
            self._inorder_recursive(node.right, result)

    def preorder_traversal(self):
        result = []
        self._preorder_recursive(self.root, result)
        return result

    def _preorder_recursive(self, node, result):
        if node:
            result.append(node.value)
            self._preorder_recursive(node.left, result)
            self._preorder_recursive(node.right, result)

    def postorder_traversal(self):
        result = []
        self._postorder_recursive(self.root, result)
        return result

    def _postorder_recursive(self, node, result):
        if node:
            self._postorder_recursive(node.left, result)
            self._postorder_recursive(node.right, result)
            result.append(node.value)

# Test traversals
print("In-order:", bst.inorder_traversal())
print("Pre-order:", bst.preorder_traversal())
print("Post-order:", bst.postorder_traversal())

Step 6: Implement the Deletion Method

Finally, let's implement the deletion method:

class BinarySearchTree:
    # ... (previous code)

    def delete(self, value):
        self.root = self._delete_recursive(self.root, value)

    def _delete_recursive(self, node, value):
        if node is None:
            return node

        if value < node.value:
            node.left = self._delete_recursive(node.left, value)
        elif value > node.value:
            node.right = self._delete_recursive(node.right, value)
        else:
            # Node with only one child or no child
            if node.left is None:
                return node.right
            elif node.right is None:
                return node.left

            # Node with two children
            node.value = self._min_value(node.right)
            node.right = self._delete_recursive(node.right, node.value)

        return node

    def _min_value(self, node):
        current = node
        while current.left is not None:
            current = current.left
        return current.value

# Test deletion
bst.delete(3)
print("After deleting 3:", bst.inorder_traversal())

Exercises for Students

  1. Implement a method to find the maximum value in the BST.
  2. Add a method to count the total number of nodes in the BST.
  3. Implement a level-order traversal (breadth-first search) for the BST.
  4. Create a method to find the height of the BST.
  5. Implement a method to check if the tree is a valid BST.

Conclusion

In this lab, you've implemented a Binary Search Tree with insertion, deletion, search, and traversal operations. You've practiced working with recursive algorithms and tree data structures. The modular approach we've taken allows for easy testing and expansion of the BST implementation.

Remember to test your code with different inputs to ensure it works correctly in various scenarios. Try implementing the additional exercises to further enhance your understanding of BSTs and tree algorithms.

Practical 15 (Optional): Graph Data Structure and Traversal Algorithms

Objective

In this lab, you will implement a graph data structure and basic graph traversal algorithms in Python. This exercise will help you understand graph representations and practice implementing depth-first search (DFS) and breadth-first search (BFS) algorithms.

Submission Date: November 4st

Prerequisites

  • Basic knowledge of Python syntax
  • Understanding of data structures (particularly dictionaries)
  • Familiarity with object-oriented programming in Python

Lab Steps

Step 1: Implement the Graph Class

First, let's create a Graph class using an adjacency list representation:

class Graph:
    def __init__(self):
        self.graph = {}
    
    def add_vertex(self, vertex):
        if vertex not in self.graph:
            self.graph[vertex] = []
    
    def add_edge(self, vertex1, vertex2):
        self.add_vertex(vertex1)
        self.add_vertex(vertex2)
        self.graph[vertex1].append(vertex2)
        self.graph[vertex2].append(vertex1)  # For undirected graph
    
    def print_graph(self):
        for vertex in self.graph:
            print(f"{vertex}: {' '.join(map(str, self.graph[vertex]))}")

# Test the Graph class
g = Graph()
g.add_edge(0, 1)
g.add_edge(0, 2)
g.add_edge(1, 2)
g.add_edge(2, 3)
g.print_graph()

Step 2: Implement Depth-First Search (DFS)

Now, let's implement the DFS algorithm:

class Graph:
    # ... (previous methods remain the same)

    def dfs(self, start_vertex):
        visited = set()
        self._dfs_recursive(start_vertex, visited)
    
    def _dfs_recursive(self, vertex, visited):
        visited.add(vertex)
        print(vertex, end=' ')
        
        for neighbor in self.graph[vertex]:
            if neighbor not in visited:
                self._dfs_recursive(neighbor, visited)

# Test DFS
print("\nDFS starting from vertex 0:")
g.dfs(0)

Step 3: Implement Breadth-First Search (BFS)

Next, let's implement the BFS algorithm:

from collections import deque

class Graph:
    # ... (previous methods remain the same)

    def bfs(self, start_vertex):
        visited = set()
        queue = deque([start_vertex])
        visited.add(start_vertex)

        while queue:
            vertex = queue.popleft()
            print(vertex, end=' ')

            for neighbor in self.graph[vertex]:
                if neighbor not in visited:
                    visited.add(neighbor)
                    queue.append(neighbor)

# Test BFS
print("\nBFS starting from vertex 0:")
g.bfs(0)

Step 4: Implement a Method to Find All Paths

Let's add a method to find all paths between two vertices:

class Graph:
    # ... (previous methods remain the same)

    def find_all_paths(self, start_vertex, end_vertex, path=[]):
        path = path + [start_vertex]
        if start_vertex == end_vertex:
            return [path]
        if start_vertex not in self.graph:
            return []
        paths = []
        for neighbor in self.graph[start_vertex]:
            if neighbor not in path:
                new_paths = self.find_all_paths(neighbor, end_vertex, path)
                for new_path in new_paths:
                    paths.append(new_path)
        return paths

# Test finding all paths
print("\nAll paths from vertex 0 to vertex 3:")
all_paths = g.find_all_paths(0, 3)
for path in all_paths:
    print(' -> '.join(map(str, path)))

Step 5: Implement a Method to Check if the Graph is Connected

Finally, let's add a method to check if the graph is connected:

class Graph:
    # ... (previous methods remain the same)

    def is_connected(self):
        if not self.graph:
            return True
        start_vertex = next(iter(self.graph))
        visited = set()
        self._dfs_recursive(start_vertex, visited)
        return len(visited) == len(self.graph)

# Test if the graph is connected
print("\nIs the graph connected?", g.is_connected())

# Add a disconnected vertex and test again
g.add_vertex(4)
print("After adding a disconnected vertex:")
print("Is the graph connected?", g.is_connected())

Exercises for Students

  1. Implement a method to find the shortest path between two vertices using BFS.
  2. Add a method to detect cycles in the graph.
  3. Implement Dijkstra's algorithm to find the shortest path in a weighted graph.
  4. Create a method to determine if the graph is bipartite.

Conclusion

In this lab, you've implemented a graph data structure and basic graph traversal algorithms in Python. You've practiced creating a Graph class, implementing DFS and BFS, finding all paths between two vertices, and checking if a graph is connected.

These fundamental graph algorithms form the basis for solving many complex problems in computer science. As you work on the additional exercises, you'll gain a deeper understanding of graph theory and its applications.

Remember to test your code with different graph structures to ensure it works correctly in various scenarios.