Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Book Title: Java Data Structures and Algorithms for Beginners

Author: Anirudha Anil Gaikwad

Co-Author: Atit Anil Gaikwad

Name of Publisher: Anirudha Anil Gaikwad

ISBN Number: 978-93-343-8254-9

Language: English
Country of Publication: India

Date of Publication: 30/08/2025

Product Form: Digital online
Product Composition: Single-component retail product

Copyright © 2025 Anirudha Anil Gaikwad
All rights reserved by the author(s). No part of this digital publication may be reproduced, stored in a retrieval system, or transmitted in any form or by any means, electronic or mechanical, including photocopy, recording, or any information storage and retrieval system, without prior written permission of the author(s).

Design & Layout: Anirudha Anil Gaikwad

Digital Edition | eBook | Published in India

Introduction

Welcome to Java Data Structures and Algorithms for Beginners, an introductory guide to one of the most important topics in computer science — data structures — using the Java programming language.

This book, Java Data Structures and Algorithms for Beginners, is designed for students, new programmers, and anyone starting their journey into understanding how data structures work in Java. It aims to simplify concepts while providing practical examples you can run and explore.

Data structures form the backbone of efficient software. They allow you to store, organize, and manipulate data in ways that make programs faster, more reliable, and easier to maintain. Whether you’re creating a simple to-do app or building a large-scale web service, understanding the right data structure for the job is essential.

Java provides a rich collection of built-in data structures in its standard library, from basic arrays to advanced collections like HashMap and TreeSet. This book will take you step-by-step from the very basics to slightly more advanced topics, with a strong focus on clear explanations and practical coding examples.

Who This Book Is For

This book is ideal for:

Students

If you are learning Java or preparing for university/diploma-level programming courses, this book will give you a solid understanding of arrays, lists, stacks, queues, trees, graphs, and more.

Beginner Java Developers

If you know the Java syntax but feel confused when it comes to selecting or implementing the right data structure, this book will help you build confidence.

Programming Enthusiasts

Even if Java isn’t your main programming language, the concepts you learn here are transferable to many other languages like Python, C++, or Rust.

Job Seekers

If you are preparing for interviews, this book will serve as a gentle starting point before diving into more challenging algorithmic problems.

How to Use This Book

We recommend reading the chapters in sequence, as each chapter builds upon concepts introduced earlier. However, you can also jump to specific topics when needed.

You’ll find two types of chapters in this book:

  1. Concept Chapters – Explain a data structure’s purpose, how it works internally, its operations, and time complexity.
  2. Practical Chapters – Show you how to implement the data structure in Java, with examples and small exercises.

Chapter Overview

This book is designed to guide beginners through Java Data Structures and Algorithms (DSA) step-by-step.
Here’s a quick overview of what each chapter covers:

1️⃣ Getting Started with Data Structures, Algorithms, and Complexity

Learn the basics of what DSA is, why it’s important, and how it affects the efficiency of your programs.
We’ll also cover pseudocode, complexity analysis, and Big-O notation so you can evaluate algorithm performance.

2️⃣ Recursion

Understand the concept of recursion, where a function calls itself to solve smaller subproblems.
You’ll learn:

  • How recursion works
  • Real-world examples like factorials
  • Difference between recursion and iteration
  • Tail recursion and when to use it

3️⃣ Core Data Structures

Dive into the most important data structures:

  • Arrays – store data in a fixed-size list
  • Strings – work with text
  • Linked Lists – dynamic lists with connected nodes
  • Stacks & Queues – process data in LIFO/FIFO order
  • Hashing – fast data lookup
  • Trees & Graphs – store and connect data in hierarchical and network structures

4️⃣ Sorting Algorithms

Learn how to arrange data efficiently:

  • Bubble Sort – simple but slow
  • Selection Sort – select smallest/largest repeatedly
  • Insertion Sort – build sorted lists step-by-step
  • Merge Sort – efficient divide-and-conquer approach

5️⃣ Searching Algorithms

Discover how to find data in a collection:

  • Linear Search – check every element
  • Binary Search – fast search in sorted lists

6️⃣ Problem Solving with DSA

Practice solving real coding challenges:

  • Array problems
  • String problems
  • Linked list problems
  • Stack & queue problems

📎 Appendix

Extra resources to help you:

  • Glossary of terms
  • Pseudocode symbols
  • Flowchart symbols
  • Java code templates for quick development

Practice-Oriented Learning

This book takes a hands-on, practical approach to understanding data structure concepts for beginners. Rather than focusing only on theory, each chapter pairs clear explanations with step-by-step Java code examples, followed by small exercises you can try yourself. By actively implementing and experimenting with arrays, stacks, queues, and other structures, you’ll develop a stronger intuition for how they work and when to use them—turning abstract ideas into real programming skills.

By the time you finish this book, you’ll not only know how each Java data structure works but also when and why to use it — a skill that will make your programs more efficient and your problem-solving sharper.

Let’s begin your journey into the world of Java Data Structures and Algorithms!

Getting Started with Data Structures, Algorithms, and Complexity

This chapter provides a beginner-friendly overview of what Data Structures and Algorithms (DSA) are, why they are important, and how they are measured in terms of efficiency.
By the end of this chapter, you will have a clear understanding of the building blocks you’ll need before diving into each specific data structure or algorithm.


📌 What You’ll Learn in This Chapter

  • What is DSA? – Understanding the combination of Data Structures and Algorithms.
  • What is an Algorithm? – Step-by-step instructions to solve a problem.
  • Why Learn DSA? – Benefits in programming, efficiency, and career growth.
  • Applications of DSA – Real-world areas where DSA is used.
  • Impact of Using vs. Not Using DSA – How it affects program performance.
  • Big-O Notation – Introduction to how we measure algorithm performance.
  • Common Big-O Complexities – Examples like O(1), O(log n), O(n), O(n²).
  • Algorithmic Complexity – Understanding time and space usage.
  • Types of Complexity – Time Complexity vs. Space Complexity.

💡 Why This Chapter Matters

Before you start coding, you need to understand why efficiency matters.
Two solutions may solve the same problem, but one could take seconds while the other takes hours.
By learning DSA fundamentals and complexity analysis, you’ll be able to:

  • Choose the right approach for a problem.
  • Write programs that are faster and use less memory.
  • Develop skills that are highly valued in interviews and competitive programming.

🚀 Next Steps

Start with What is DSA? and gradually work through each section.

What is DSA?

DSA stands for Data Structures and Algorithms. It refers to the study and implementation of data organization (data structures) and computational procedures (algorithms) to solve problems efficiently.

  • Data Structures: Ways to store and organize data in a computer so that it can be accessed and modified efficiently. Examples include arrays, linked lists, stacks, queues, trees, graphs, hash tables, etc.
  • Algorithms: Step-by-step procedures or formulas for solving problems, such as sorting, searching, graph traversal, or dynamic programming.

DSA is a cornerstone of efficient programming and problem-solving, applicable in virtually every area of software development and technology. Using DSA leads to faster, scalable, and cost-effective solutions, while neglecting it risks poor performance and limited career opportunities. Its scope spans from everyday apps to cutting-edge AI, making it an essential skill for developers and engineers.

Next: What is an Algorithm?

What is an Algorithm?

An algorithm is a step-by-step procedure or a set of instructions designed to perform a specific task or solve a problem.
It is like a recipe in cooking — a list of steps you follow in a specific order to achieve the desired outcome.

Characteristics of a Good Algorithm

  1. Clear and Unambiguous – Each step is well-defined.
  2. Input – Takes zero or more inputs.
  3. Output – Produces at least one output.
  4. Finiteness – Completes after a finite number of steps.
  5. Effectiveness – Each step is basic enough to be performed exactly and in a reasonable time.

Example (Real-World)

Making Tea Algorithm:

  1. Boil water.
  2. Add tea leaves.
  3. Add sugar and milk.
  4. Pour into a cup and serve.

Here, each step is clear, ordered, and finite — just like in programming.


Example of an Algorithm – Adding Two Numbers

Let’s take a very simple problem:

Problem:
Write an algorithm to add two numbers and display the result.

Step-by-Step Algorithm

  1. Start
  2. Input the first number.
  3. Input the second number.
  4. Add the two numbers.
  5. Output the result.
  6. End

Flowchart Representation

Flowcharts are visual diagrams that show the steps of an algorithm using shapes and arrows.

Benefits:

  • Easy to understand for beginners.
  • Shows the flow of logic visually.
  • Useful for planning before coding.

Common Flowchart Symbols

SymbolNamePurpose
🔵 OvalStart / EndIndicates the start or end of the process
RectangleProcessShows a task, step, or action to be performed
🔷 DiamondDecisionRepresents a question or condition (Yes/No)
ParallelogramInput / OutputUsed for reading inputs or displaying outputs
ArrowFlow LineShows the direction of process flow
🗂 Document ShapePredefined ProcessIndicates a subroutine or pre-defined process

flowchart

Explanation of the Flowchart

  • Start (A): Represented by an oval, indicating the beginning of the algorithm.
  • Input first number (B): A parallelogram, showing the user inputs the first number.
  • Input second number (C): Another parallelogram, for inputting the second number.
  • Add numbers (D): A rectangle, representing the processing step where the two numbers are added.
  • Display sum (E): A parallelogram, indicating the output of the sum to the user.
  • End (F): An oval, marking the end of the algorithm.
  • Arrows: Show the flow of execution from one step to the next.
import java.util.Scanner;

public class AddTwoNumbers {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        // Input
        System.out.print("Enter first number: ");
        int num1 = sc.nextInt();

        System.out.print("Enter second number: ");
        int num2 = sc.nextInt();

        // Processing
        int sum = num1 + num2;

        // Output
        System.out.println("Sum = " + sum);

        sc.close();
    }
}


🔍 Examples of Algorithms in Computer Science

  • Searching: Linear Search, Binary Search
  • Sorting: Bubble Sort, Merge Sort
  • Graph Traversal: BFS, DFS
  • Dynamic Programming: Fibonacci, Knapsack

💡 Key Takeaway

An algorithm is not code — it’s the logic and steps to solve a problem.
You can write algorithms in plain language, pseudocode, or flowcharts before turning them into Java code.


Next: What is Pseudocode and Flowchart?

What is Pseudocode?

Pseudocode is a human-readable way to describe the steps of an algorithm without worrying about syntax of a specific programming language.
It is written in plain English (or any natural language) but structured like code.

Why use pseudocode?

  • Easy to understand for both technical and non-technical people.
  • Helps in planning before coding.
  • Reduces errors in implementation.

Rules for Writing Good Pseudocode

  1. Use plain, simple language – avoid unnecessary jargon.
  2. Write one action per line – keep it clean.
  3. Use indentation – to represent loops or conditional blocks.
  4. Use standard keywords like:
    • START, END
    • IF, ELSE, ENDIF
    • FOR, WHILE, REPEAT
    • READ, PRINT, RETURN
  5. Keep it language-independent – no need for Java, Python, or C++ syntax.
  6. Number your steps if sequence matters.

Pseudocode and Java Implementations

Example 1 – Find if a number is even or odd

Problem:
Write an algorithm to read a number and determine whether it is even or odd.

Pseudocode:

1. START
2. READ number N
3. IF N mod 2 = 0 THEN
      PRINT "Even"
   ELSE
      PRINT "Odd"
4. END
import java.util.Scanner;

public class EvenOddCheck {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter a number: ");
        int N = sc.nextInt();

        if (N % 2 == 0) {
            System.out.println("Even");
        } else {
            System.out.println("Odd");
        }
    }
}

Example 2 – Sum of Two Numbers

Problem: Read two numbers and print their sum.

Pseudocode:

START
READ A, B
SUM ← A + B
PRINT SUM
END

import java.util.Scanner;

public class SumOfTwoNumbers {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter first number: ");
        int A = sc.nextInt();
        System.out.print("Enter second number: ");
        int B = sc.nextInt();

        int SUM = A + B;
        System.out.println("Sum = " + SUM);
    }
}

Example 3 – IF Statement

Problem: Check if a number is positive.

Pseudocode:

START
READ num
IF num > 0 THEN
PRINT "Positive Number"
ENDIF
END

import java.util.Scanner;

public class PositiveCheck {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter a number: ");
        int num = sc.nextInt();

        if (num > 0) {
            System.out.println("Positive Number");
        }
    }
}

Example 4 – IF...ELSE Statement

Problem: Check if a person is eligible to vote.

Pseudocode:

START
    READ age
    IF age >= 18 THEN
        PRINT "Eligible to Vote"
    ELSE
        PRINT "Not Eligible to Vote"
    ENDIF
END
import java.util.Scanner;

public class VotingEligibility {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter your age: ");
        int age = sc.nextInt();

        if (age >= 18) {
            System.out.println("Eligible to Vote");
        } else {
            System.out.println("Not Eligible to Vote");
        }
    }
}

Example 5 – Nested IF

Problem: Find the largest among three numbers.

Pseudocode:

START
    READ A, B, C
    IF A > B THEN
        IF A > C THEN
            PRINT "A is the largest"
        ELSE
            PRINT "C is the largest"
        ENDIF
    ELSE
        IF B > C THEN
            PRINT "B is the largest"
        ELSE
            PRINT "C is the largest"
        ENDIF
    ENDIF
END
import java.util.Scanner;

public class LargestNumber {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter three numbers: ");
        int A = sc.nextInt();
        int B = sc.nextInt();
        int C = sc.nextInt();

        if (A > B) {
            if (A > C) {
                System.out.println("A is the largest");
            } else {
                System.out.println("C is the largest");
            }
        } else {
            if (B > C) {
                System.out.println("B is the largest");
            } else {
                System.out.println("C is the largest");
            }
        }
    }
}

Example 6 – Nested FOR Loop

Problem: Print a multiplication table from 1 to 5.

Pseudocode:

START
    FOR i ← 1 TO 5 DO
        FOR j ← 1 TO 10 DO
            PRINT i × j
        ENDFOR
    ENDFOR
END
public class MultiplicationTable {
    public static void main(String[] args) {
        for (int i = 1; i <= 5; i++) {
            for (int j = 1; j <= 10; j++) {
                System.out.println(i + " x " + j + " = " + (i * j));
            }
            System.out.println();
        }
    }
}

Example 7 – WHILE Loop

Problem: Print numbers from 1 to N.

Pseudocode:

START
    READ N
    i ← 1
    WHILE i <= N DO
        PRINT i
        i ← i + 1
    ENDWHILE
END
import java.util.Scanner;

public class PrintNumbers {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter N: ");
        int N = sc.nextInt();

        int i = 1;
        while (i <= N) {
            System.out.println(i);
            i++;
        }
    }
}

Example 8 – REPEAT UNTIL Loop (Simulated with do-while in Java)

Problem: Keep reading numbers until the user enters 0.

Pseudocode:

START
    REPEAT
        READ num
        PRINT num
    UNTIL num = 0
END
import java.util.Scanner;

public class RepeatUntilExample {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int num;
        do {
            System.out.print("Enter a number (0 to stop): ");
            num = sc.nextInt();
            System.out.println("You entered: " + num);
        } while (num != 0);
    }
}

Example 9 – IF with AND/OR Condition

Problem: Check if a number is between 10 and 50.

Pseudocode:

START
    READ num
    IF num >= 10 AND num <= 50 THEN
        PRINT "Number is in range"
    ELSE
        PRINT "Number is out of range"
    ENDIF
END
import java.util.Scanner;

public class NumberRangeCheck {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter a number: ");
        int num = sc.nextInt();

        if (num >= 10 && num <= 50) {
            System.out.println("Number is in range");
        } else {
            System.out.println("Number is out of range");
        }
    }
}

Example 10 – Combining IF and FOR

Problem: Print only even numbers between 1 and N.

Pseudocode:

START
    READ N
    FOR i ← 1 TO N DO
        IF i MOD 2 = 0 THEN
            PRINT i
        ENDIF
    ENDFOR
END
import java.util.Scanner;

public class EvenNumbers {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("Enter N: ");
        int N = sc.nextInt();

        for (int i = 1; i <= N; i++) {
            if (i % 2 == 0) {
                System.out.println(i);
            }
        }
    }
}

Next: Why Learn DSA?

Why Learn DSA?

DSA is fundamental in computer science and programming because it enables efficient problem-solving and resource management. Here’s why it’s important:

  1. Efficiency: DSA optimizes the use of time and space resources. For example, choosing a hash table for quick lookups or a binary search tree for sorted data retrieval reduces computation time compared to less efficient structures like unsorted arrays.
  2. Scalability: Well-designed data structures and algorithms handle large datasets and complex computations effectively, critical for real-world applications like databases or machine learning models.
  3. Problem-Solving: DSA provides reusable frameworks to tackle recurring problems, such as finding the shortest path in a graph (e.g., Dijkstra’s algorithm) or sorting data (e.g., quicksort).
  4. Performance: Efficient algorithms reduce runtime and memory usage, which is vital for applications requiring real-time processing, like navigation systems or gaming.
  5. Career Advantage: Mastery of DSA is often a key requirement in technical interviews for software engineering roles at top companies, as it demonstrates problem-solving and analytical skills.

Next: Applications of DSA (Where It’s Used)

Applications of DSA (Where It’s Used)

DSA is used across various domains in computer science and software development. Its scope includes:

  1. Software Development:

    • Web Applications: Efficient data retrieval (e.g., databases using B-trees or hash indexes) and caching (e.g., LRU cache with hash maps and doubly linked lists).
    • Mobile Apps: Optimizing battery and memory usage with compact data structures like arrays or efficient algorithms like greedy methods.
    • Game Development: Pathfinding (A* algorithm), collision detection (spatial partitioning with quadtrees), and rendering optimizations.
  2. System Design:

    • Databases: Indexing (B-trees, hash tables) and query optimization.
    • Operating Systems: Process scheduling (priority queues), memory management (linked lists), and file systems (trees).
    • Networking: Routing algorithms (Dijkstra’s, Bellman-Ford) and packet scheduling (queues).
  3. Artificial Intelligence and Machine Learning:

    • AI: Graph algorithms for path planning in robotics or game AI (e.g., minimax with alpha-beta pruning).
    • ML: Data preprocessing (sorting, hashing), clustering (k-d trees), and recommendation systems (matrix factorization, graphs).
  4. Competitive Programming:

    • DSA is the backbone of solving problems on platforms like LeetCode, Codeforces, or HackerRank, where efficiency is critical.
  5. Big Data and Cloud Computing:

    • Handling massive datasets with distributed algorithms (e.g., MapReduce) or optimized storage (e.g., Bloom filters for quick membership tests).
  6. Embedded Systems and IoT:

    • Resource-constrained environments rely on lightweight data structures (e.g., circular buffers) and algorithms to manage sensor data or communication.
  7. Cybersecurity:

    • Cryptographic algorithms (e.g., RSA, hash functions) and pattern matching for intrusion detection (e.g., KMP algorithm).
  8. Everyday Tools:

    • Search engines (inverted indexes, tries), GPS navigation (shortest path algorithms), and compression tools (Huffman coding).

Scope in Practice

  • Tech Companies: FAANG (Google, Amazon, etc.) and startups emphasize DSA in interviews and product development for scalable systems.
  • Open-Source Projects: Contributors use DSA to optimize codebases, e.g., in libraries like Apache Spark or TensorFlow.
  • Research: DSA underpins advancements in fields like bioinformatics (sequence alignment with dynamic programming) or quantum computing.

Next: Impact of Using vs. Not Using DSA

Impact of Using vs. Not Using DSA

If You Use DSA:

  • Pros:
    • Faster execution: For example, using a binary search (O(log n)) instead of a linear search (O(n)) drastically reduces search time for large datasets.
    • Lower resource consumption: Efficient data structures like hash maps minimize memory usage compared to naive approaches.
    • Scalable solutions: Applications can handle increased data or user load without performance degradation.
    • Competitive edge: Strong DSA skills improve your ability to pass coding interviews and build robust software.
  • Example: In a social media app, using a graph data structure for friend recommendations (via BFS or DFS) is much faster than scanning all user data sequentially.

If You Don’t Use DSA:

  • Cons:
    • Poor performance: Inefficient solutions, like using nested loops for tasks that could be optimized with a hash table, lead to slow execution.
    • Scalability issues: Applications may crash or lag with large datasets due to unoptimized code.
    • Higher costs: Inefficient resource usage can increase server costs in cloud-based applications.
    • Limited career growth: Lack of DSA knowledge may hinder success in technical interviews or building high-performance systems.
  • Example: Without DSA, a search feature in an e-commerce app might scan every product linearly, causing delays for users, whereas a trie or inverted index could provide instant results.

Next: Introduction to Big-O Notation

Introduction to Big-O Notation

Big O notation is a mathematical way to describe the performance or complexity of an algorithm, focusing on how it scales with input size ( n ). It provides an upper bound on the growth rate of an algorithm’s time or space requirements, typically in the worst-case scenario. It helps compare algorithms by abstracting away constants and lower-order terms to focus on the dominant behavior as ( n ) grows large.

By ignoring constants and less significant terms, Big-O focuses only on the dominant factor that affects performance when ( n ) becomes large.

Key Points:

  • Purpose: Measures efficiency time complexity (execution time) or space complexity (memory usage) of algorithms.
  • Focus: Describes worst-case performance unless specified otherwise.
  • Notation: Expressed as ( O(f(n)) ), where ( f(n) ) is a function describing the upper bound.
  • Why ignore constants?: Because as ( n ) grows, large-scale trends matter more than small-scale differences.

📌 Example

  • A simple loop from ( 1 ) to ( n ) → O(n) (linear time).
  • Nested loops each running ( n ) times → O(n²) (quadratic time).

Key takeaway:
Big-O notation helps you predict scalability, not actual execution time. Two algorithms with different Big-O complexities might perform differently for small inputs, but the lower complexity will usually win for large ( n ).

Next: Common Big-O Complexities

Common Big-O Complexities

Big O notation categorizes algorithms based on their growth rates. Here are the most common types, ordered from fastest (most efficient) to slowest (least efficient):

  1. O(1) - Constant Time

    • Execution time doesn’t depend on input size.
    • Example: Accessing an array element by index.
    • Graph: Flat line (no growth).
  2. O(log n) - Logarithmic Time

    • Time grows logarithmically with input size.
    • Example: Binary search.
    • Graph: Very slow growth, flattens out.
  3. O(n) - Linear Time

    • Time grows linearly with input size.
    • Example: Linear search through an array.
    • Graph: Straight line.
  4. O(n log n) - Linearithmic Time

    • Time grows as ( n ) times the logarithm of ( n ).
    • Example: Efficient sorting algorithms like Merge Sort or Quick Sort.
    • Graph: Slightly steeper than linear.
  5. O(n²) - Quadratic Time

    • Time grows quadratically with input size.
    • Example: Nested loops, like in Bubble Sort.
    • Graph: Parabolic curve.
  6. O(n³) - Cubic Time

    • Time grows cubically with input size.
    • Example: Matrix multiplication with three nested loops.
    • Graph: Steep parabolic curve.
  7. O(2ⁿ) - Exponential Time

    • Time doubles with each additional input.
    • Example: Solving the traveling salesman problem via brute force.
    • Graph: Extremely steep, quickly becomes impractical.
  8. O(n!) - Factorial Time

    • Time grows factorially, extremely inefficient for large ( n ).
    • Example: Generating all permutations of a set.
    • Graph: Explosive growth, worst-case scenario.

📊 Frequently Used Big-O Complexities

NotationNameExample Algorithm
O(1)ConstantAccessing an array element
O(log n)LogarithmicBinary search
O(n)LinearIterating through a list
O(n log n)Log-linearMerge sort, Quick sort (average)
O(n²)QuadraticBubble sort
O(2ⁿ)ExponentialRecursive Fibonacci

While Big-O describes the upper bound of growth rate:

  • Big Theta (Θ)Tight bound (exact growth rate).
  • Big Omega (Ω)Lower bound (best-case or minimum growth).

These are less commonly used in casual discussions but provide more precise performance characterizations.


💡 Practical Use

  • Algorithm Selection: Big-O helps developers pick algorithms that scale well with large datasets.
  • Space Complexity: Big-O also applies to memory usage, not just time.
  • Focus on Trends: Ignore constants and small terms — large input size behavior is what matters most.

Next: What is Complexity?

What is Complexity?

Complexity in computer science refers to the measure of resources an algorithm requires to solve a problem. These resources are typically time (how long the algorithm takes to run) and space (how much memory the algorithm uses). Complexity analysis helps evaluate an algorithm's efficiency, especially as the input size grows, allowing developers to choose the most suitable algorithm for a task.

What is Time Complexity?

Time complexity describes the amount of time an algorithm takes to complete as a function of the input size ( n ). It focuses on the number of operations executed, ignoring constant factors and hardware-specific details. Time complexity is usually expressed using Big O notation, which provides an upper bound on the growth rate in the worst-case scenario.

  • Key Points:
    • Measures how the runtime scales with input size.
    • Expressed as ( O(f(n)) ), e.g., ( O(n) ), ( O(n^2) ), etc.
    • Common in analyzing loops, recursive calls, or operations like comparisons and assignments.
    • Example: A linear search checking each element in an array has ( O(n) ) time complexity because it may need to inspect all ( n ) elements.

What is Space Complexity?

Space complexity measures the amount of memory or storage an algorithm uses as a function of the input size ( n ). It includes both the auxiliary space (extra memory allocated during execution, like temporary arrays or recursion stacks) and the input space (memory for the input data).

  • Key Points:
    • Focuses on memory usage, including variables, data structures, and recursion stacks.
    • Also expressed using Big O notation.
    • Does not include the input size in some analyses if the algorithm modifies the input in place.
    • Example: A recursive algorithm like naive Fibonacci has ( O(n) ) space complexity due to the recursion stack, even though its time complexity is ( O(2^n) ).

Examples with Java Code

Below are examples illustrating time and space complexity, with comments for clarity.

1. Linear Search (O(n) Time, O(1) Space)

public class LinearSearchExample {
    public static int linearSearch(int[] array, int target) {
        // Loop through each element
        for (int i = 0; i < array.length; i++) {
            if (array[i] == target) {
                return i; // Return index if found
            }
        }
        return -1; // Not found
    }
    
    public static void main(String[] args) {
        int[] array = {10, 20, 30, 40, 50};
        int target = 30;
        
        // Time complexity: O(n) - may need to check all n elements
        // Space complexity: O(1) - uses only a few variables (i, target)
        int result = linearSearch(array, target);
        System.out.println("Index of " + target + ": " + result);
    }
}
  • Time Complexity: ( O(n) ), as it may iterate through all ( n ) elements.
  • Space Complexity: ( O(1) ), as it uses only a constant amount of extra memory (variables i and target).

2. Merge Sort (O(n log n) Time, O(n) Space)

public class MergeSortExample {
    private static void merge(int[] array, int left, int mid, int right) {
        int n1 = mid - left + 1;
        int n2 = right - mid;
        int[] leftArray = new int[n1]; // Temporary array
        int[] rightArray = new int[n2]; // Temporary array
        
        for (int i = 0; i < n1; i++) leftArray[i] = array[left + i];
        for (int j = 0; j < n2; j++) rightArray[j] = array[mid + 1 + j];
        
        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2) {
            if (leftArray[i] <= rightArray[j]) {
                array[k++] = leftArray[i++];
            } else {
                array[k++] = rightArray[j++];
            }
        }
        while (i < n1) array[k++] = leftArray[i++];
        while (j < n2) array[k++] = rightArray[j++];
    }
    
    public static void mergeSort(int[] array, int left, int right) {
        if (left < right) {
            int mid = left + (right - left) / 2;
            mergeSort(array, left, mid); // Recurse on left half
            mergeSort(array, mid + 1, right); // Recurse on right half
            merge(array, left, mid, right); // Merge results
        }
    }
    
    public static void main(String[] args) {
        int[] array = {12, 11, 13, 5, 6, 7};
        
        // Time complexity: O(n log n) - log n levels of recursion, each doing O(n) work
        // Space complexity: O(n) - temporary arrays for merging
        mergeSort(array, 0, array.length - 1);
        
        System.out.print("Sorted array: ");
        for (int num : array) System.out.print(num + " ");
    }
}
  • Time Complexity: ( O(n \log n) ), as the array is divided into ( \log n ) levels, and each level involves ( O(n) ) merging work.
  • Space Complexity: ( O(n) ), due to the temporary arrays (leftArray and rightArray) used during merging.

Key Differences Between Time and Space Complexity

  • Time Complexity:
    • Concerns the number of operations or runtime.
    • Affected by loops, recursion, or function calls.
    • Example: A nested loop over ( n ) elements results in ( O(n^2) ) time.
  • Space Complexity:
    • Concerns memory usage.
    • Affected by variables, data structures, or recursion stacks.
    • Example: Creating a new array of size ( n ) results in ( O(n) ) space.

Why Analyze Complexity?

  • Scalability: Predicts how an algorithm performs with large inputs.
  • Trade-offs: Helps balance time vs. space (e.g., a faster algorithm might use more memory).
  • Optimization: Guides developers to choose or design efficient algorithms.

Next: Common Algorithmic Complexities with Examples

Common Algorithmic Complexities with Examples

1. O(1) - Constant Time

  • Example Algorithm: Accessing an element in an array by index.
  • Time Complexity: O(1) - The operation takes a fixed amount of time regardless of the array size.
  • Space Complexity: O(1) - No additional space is used beyond the input array.
public class ConstantTimeExample {
    public static void main(String[] args) {
        int[] array = {10, 20, 30, 40, 50}; // Sample array
        int index = 2; // Index to access
        
        // Accessing the element at the given index
        // This operation is O(1) because it directly jumps to the memory location
        // without iterating or depending on the array size.
        int element = array[index];
        System.out.println("Element at index " + index + ": " + element);
    }
}

2. O(log n) - Logarithmic Time

  • Example Algorithm: Binary search on a sorted array.
  • Time Complexity: O(log n) - The search space is halved with each step.
  • Space Complexity: O(1) - Only a few variables are used, no extra space proportional to input.
public class LogarithmicTimeExample {
    public static int binarySearch(int[] sortedArray, int target) {
        int left = 0; // Start of the search range
        int right = sortedArray.length - 1; // End of the search range
        
        // Loop until the search range is exhausted
        while (left <= right) {
            int mid = left + (right - left) / 2; // Calculate middle index to avoid overflow
            
            // If target is found at mid, return the index
            if (sortedArray[mid] == target) {
                return mid;
            }
            // If target is larger, ignore left half
            else if (sortedArray[mid] < target) {
                left = mid + 1;
            }
            // If target is smaller, ignore right half
            else {
                right = mid - 1;
            }
        }
        // Target not found
        return -1;
    }
    
    public static void main(String[] args) {
        int[] sortedArray = {2, 3, 4, 10, 40}; // Sorted array required for binary search
        int target = 10;
        
        // Binary search call
        // Time complexity is O(log n) because each step halves the search space,
        // e.g., for n=1024, it takes at most 10 comparisons.
        int result = binarySearch(sortedArray, target);
        System.out.println("Index of " + target + ": " + result);
    }
}

3. O(n) - Linear Time

  • Example Algorithm: Linear search in an array.
  • Time Complexity: O(n) - In the worst case, it checks every element.
  • Space Complexity: O(1) - Only constant extra space for variables.
public class LinearTimeExample {
    public static int linearSearch(int[] array, int target) {
        // Iterate through each element in the array
        for (int i = 0; i < array.length; i++) {
            // Check if current element matches the target
            if (array[i] == target) {
                return i; // Return index if found
            }
        }
        return -1; // Return -1 if not found
    }
    
    public static void main(String[] args) {
        int[] array = {10, 20, 30, 40, 50}; // Sample array
        int target = 30;
        
        // Linear search call
        // Time complexity is O(n) because it may need to check all n elements
        // in the worst case (target not present or at the end).
        int result = linearSearch(array, target);
        System.out.println("Index of " + target + ": " + result);
    }
}

4. O(n log n) - Linearithmic Time

  • Example Algorithm: Merge sort.
  • Time Complexity: O(n log n) - Divides the array (log n levels) and merges (n work per level).
  • Space Complexity: O(n) - Requires extra space for temporary arrays during merging.
public class LinearithmicTimeExample {
    // Merge two sorted subarrays
    private static void merge(int[] array, int left, int mid, int right) {
        int n1 = mid - left + 1; // Size of left subarray
        int n2 = right - mid; // Size of right subarray
        
        int[] leftArray = new int[n1]; // Temporary array for left
        int[] rightArray = new int[n2]; // Temporary array for right
        
        // Copy data to temporary arrays
        for (int i = 0; i < n1; i++) leftArray[i] = array[left + i];
        for (int j = 0; j < n2; j++) rightArray[j] = array[mid + 1 + j];
        
        // Merge the temporary arrays back into the original
        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2) {
            if (leftArray[i] <= rightArray[j]) {
                array[k++] = leftArray[i++];
            } else {
                array[k++] = rightArray[j++];
            }
        }
        // Copy remaining elements if any
        while (i < n1) array[k++] = leftArray[i++];
        while (j < n2) array[k++] = rightArray[j++];
    }
    
    // Recursive merge sort function
    public static void mergeSort(int[] array, int left, int right) {
        if (left < right) {
            int mid = left + (right - left) / 2; // Find midpoint
            
            // Recursively sort left and right halves
            mergeSort(array, left, mid);
            mergeSort(array, mid + 1, right);
            
            // Merge the sorted halves
            merge(array, left, mid, right);
        }
    }
    
    public static void main(String[] args) {
        int[] array = {12, 11, 13, 5, 6, 7}; // Sample unsorted array
        
        // Merge sort call
        // Time complexity is O(n log n): log n recursive levels, each doing O(n) work in merging.
        // Space is O(n) due to temporary arrays.
        mergeSort(array, 0, array.length - 1);
        
        System.out.print("Sorted array: ");
        for (int num : array) System.out.print(num + " ");
    }
}

5. O(n²) - Quadratic Time

  • Example Algorithm: Bubble sort.
  • Time Complexity: O(n²) - Two nested loops, each running up to n times.
  • Space Complexity: O(1) - Sorts in place, no extra space needed.
public class QuadraticTimeExample {
    public static void bubbleSort(int[] array) {
        int n = array.length;
        // Outer loop for each pass
        for (int i = 0; i < n - 1; i++) {
            // Inner loop for comparing adjacent elements
            for (int j = 0; j < n - i - 1; j++) {
                // Swap if current element is greater than next
                if (array[j] > array[j + 1]) {
                    int temp = array[j];
                    array[j] = array[j + 1];
                    array[j + 1] = temp;
                }
            }
        }
    }
    
    public static void main(String[] args) {
        int[] array = {64, 34, 25, 12, 22, 11, 90}; // Sample unsorted array
        
        // Bubble sort call
        // Time complexity is O(n²) due to nested loops: outer runs n-1 times,
        // inner runs up to n times per pass, leading to quadratic growth.
        bubbleSort(array);
        
        System.out.print("Sorted array: ");
        for (int num : array) System.out.print(num + " ");
    }
}

6. O(n³) - Cubic Time

  • Example Algorithm: Naive matrix multiplication.
  • Time Complexity: O(n³) - Three nested loops for n x n matrices.
  • Space Complexity: O(n²) - Space for input and output matrices.
public class CubicTimeExample {
    public static int[][] multiplyMatrices(int[][] A, int[][] B) {
        int n = A.length; // Assume square matrices
        int[][] result = new int[n][n]; // Output matrix
        
        // Three nested loops: i for rows of A, j for columns of B, k for dot product
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                for (int k = 0; k < n; k++) {
                    result[i][j] += A[i][k] * B[k][j]; // Accumulate product
                }
            }
        }
        return result;
    }
    
    public static void main(String[] args) {
        int[][] A = {{1, 2}, {3, 4}}; // Sample matrix A
        int[][] B = {{5, 6}, {7, 8}}; // Sample matrix B
        
        // Matrix multiplication call
        // Time complexity is O(n³) because of three nested loops, each iterating n times.
        // Space is O(n²) for storing the result matrix.
        int[][] result = multiplyMatrices(A, B);
        
        System.out.println("Result matrix:");
        for (int[] row : result) {
            for (int val : row) System.out.print(val + " ");
            System.out.println();
        }
    }
}

7. O(2ⁿ) - Exponential Time

  • Example Algorithm: Recursive Fibonacci (naive).
  • Time Complexity: O(2ⁿ) - Each call branches into two more, leading to exponential calls.
  • Space Complexity: O(n) - Due to recursion stack depth.
public class ExponentialTimeExample {
    public static int fibonacci(int n) {
        // Base cases
        if (n <= 1) return n;
        
        // Recursive calls: each branches into two
        // This leads to redundant computations and exponential time.
        return fibonacci(n - 1) + fibonacci(n - 2);
    }
    
    public static void main(String[] args) {
        int n = 10; // Small n to avoid long computation
        
        // Fibonacci call
        // Time complexity is O(2^n) because the recursion tree has about 2^n nodes.
        // Space is O(n) for the maximum recursion depth.
        int result = fibonacci(n);
        System.out.println("Fibonacci of " + n + ": " + result);
    }
}

8. O(n!) - Factorial Time

  • Example Algorithm: Generating all permutations of an array (backtracking).
  • Time Complexity: O(n!) - There are n! permutations, and each is generated in O(n) time.
  • Space Complexity: O(n) - Recursion stack and temporary storage.
import java.util.Arrays;

public class FactorialTimeExample {
    // Helper to swap elements
    private static void swap(int[] array, int i, int j) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
    
    // Recursive function to generate permutations
    public static void generatePermutations(int[] array, int start) {
        if (start == array.length - 1) {
            // Print permutation when base case is reached
            System.out.println(Arrays.toString(array));
            return;
        }
        
        // For each position, try swapping with all remaining elements
        for (int i = start; i < array.length; i++) {
            swap(array, start, i); // Swap
            generatePermutations(array, start + 1); // Recurse
            swap(array, start, i); // Backtrack
        }
    }
    
    public static void main(String[] args) {
        int[] array = {1, 2, 3}; // Small array (n=3 has 6 permutations)
        
        // Permutations generation call
        // Time complexity is O(n!) because there are n! possible permutations,
        // and each is processed in O(n) time (printing/copying).
        // Space is O(n) for recursion depth.
        generatePermutations(array, 0);
    }
}

Recursion

Definition and Concepts

Recursion is a programming technique where a function solves a problem by calling itself with a smaller or simpler instance of the same problem. Each recursive call processes a subset of the input until a base case is reached, which provides a direct solution without further recursion. You can visualize recursion as a stack of nested function calls, where each call handles a smaller piece of the problem, and the stack unwinds as base cases are resolved. Recursion is a powerful approach for problems with a naturally hierarchical or repetitive structure, such as tree traversals or mathematical computations. In Java, recursion relies on the call stack to manage function calls and their local variables.

Why Use It?

Recursion is used to simplify the solution to complex problems by breaking them into smaller, identical subproblems. It provides elegant and concise code for tasks like tree traversals, divide-and-conquer algorithms, and combinatorial problems. Recursion is particularly effective when a problem’s solution can be expressed in terms of itself, reducing the need for complex iterative loops. However, it requires careful design to avoid excessive memory usage or stack overflow errors.

Where to Use? (Real-Life Examples)

  • File System Traversal: Operating systems use recursion to traverse directory structures, processing subdirectories and files hierarchically.
  • Mathematical Computations: Recursion is used to compute factorials, Fibonacci numbers, or power functions in mathematical software.
  • Tree and Graph Algorithms: Algorithms like depth-first search (DFS) or binary tree traversals (e.g., inorder, preorder) use recursion to explore nodes.
  • Parsing Expressions: Compilers use recursion to parse nested expressions, such as evaluating (2 + (3 * 4)), by breaking them into sub-expressions.

SVG Diagram

The diagram for recursion would depict a stack of function calls for computing the factorial of 4 (e.g., factorial(4)). Each call would be a rectangular box labeled factorial(n), with n decreasing from 4 to 0. Arrows would show the recursive calls downward (e.g., factorial(4) -> factorial(3) -> factorial(2) -> factorial(1) -> factorial(0)) and the return values upward (e.g., 1, 1, 2, 6, 24). The base case (factorial(0) = 1) would be highlighted. A caption would note: "Recursion breaks a problem into smaller subproblems, with each call stored on the call stack until the base case is reached."

Explain Operations

  • Recursive Call: This operation involves a function calling itself with modified parameters to solve a smaller subproblem. The time complexity depends on the number of recursive calls and the work done per call.
  • Base Case Check: This operation checks if the current input meets the base case condition, returning a direct result to terminate recursion. It has a time complexity of O(1).
  • Parameter Modification: This operation adjusts the input parameters for the next recursive call to reduce the problem size. It typically has a time complexity of O(1).
  • Stack Management: The Java call stack automatically manages recursive calls by storing each call’s state (parameters and local variables). The space complexity depends on the recursion depth.

Java Implementation

The following Java code implements two common recursive algorithms: factorial and Fibonacci.

public class RecursionExamples {
    // Factorial: Computes n! recursively
    public int factorial(int n) {
        if (n < 0) { // Checks for invalid input
            throw new IllegalArgumentException("Factorial is not defined for negative numbers.");
        }
        if (n == 0 || n == 1) { // Base case: 0! = 1, 1! = 1
            return 1;
        }
        return n * factorial(n - 1); // Recursive case: n! = n * (n-1)!
    }

    // Fibonacci: Computes the nth Fibonacci number recursively
    public int fibonacci(int n) {
        if (n < 0) { // Checks for invalid input
            throw new IllegalArgumentException("Fibonacci is not defined for negative numbers.");
        }
        if (n == 0) { // Base case: F(0) = 0
            return 0;
        }
        if (n == 1) { // Base case: F(1) = 1
            return 1;
        }
        return fibonacci(n - 1) + fibonacci(n - 2); // Recursive case: F(n) = F(n-1) + F(n-2)
    }
}

How It Works

  1. Factorial Implementation:
    • The method checks if the input n is negative, throwing an exception if true.
    • If n is 0 or 1 (base case), it returns 1.
    • Otherwise, it recursively calls factorial(n-1) and multiplies the result by n.
    • For example, factorial(4) computes 4 * factorial(3), which computes 3 * factorial(2), and so on, until factorial(0) returns 1.
  2. Fibonacci Implementation:
    • The method checks if the input n is negative, throwing an exception if true.
    • If n is 0 (base case), it returns 0; if n is 1 (base case), it returns 1.
    • Otherwise, it recursively calls fibonacci(n-1) and fibonacci(n-2) and returns their sum.
    • For example, fibonacci(5) computes fibonacci(4) + fibonacci(3), which further breaks down until base cases are reached.
  3. Stack Management: Each recursive call is pushed onto the Java call stack with its parameters and local variables. When a base case is reached, the stack unwinds, combining results to produce the final answer.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
FactorialO(n)O(n)
FibonacciO(2^n)O(n)
Base Case CheckO(1)O(1)
Recursive CallVariesO(1) per call

Note:

  • Factorial has O(n) time complexity due to n recursive calls, each performing O(1) work.
  • Fibonacci has O(2^n) time complexity due to the exponential growth of recursive calls (two per level).
  • Space complexity for both is O(n) due to the maximum depth of the call stack.

Key Differences / Notes

  • Recursion vs. Iteration:
    • The implementation above uses recursion for simplicity and clarity. Iterative solutions (e.g., loops) can solve the same problems with O(1) space complexity but may be less intuitive for hierarchical problems.
  • Tail Recursion: Java does not optimize tail recursion, so recursive calls consume stack space. Tail-recursive algorithms (where the recursive call is the last operation) can be optimized in other languages but not in Java.
  • Memoization: The Fibonacci implementation is inefficient due to redundant calculations. Memoization (caching results) can reduce its time complexity to O(n).
  • Java’s Stack Limitations: Deep recursion can cause a StackOverflowError. Use iteration or increase the stack size for large inputs.

Static Storage Allocation in Recursion

Static storage allocation refers to memory allocation that is fixed at compile time and remains constant throughout a program’s execution. In the context of recursion, static storage allocation applies to variables declared as static in Java, which are shared across all calls to a recursive function. These variables are allocated once in the program’s data segment and persist for the program’s lifetime. For recursive algorithms, static variables can be used to store shared state across recursive calls, such as a counter or accumulator, but they are not typically used for local variables in recursion. In the provided factorial and fibonacci implementations, static storage allocation is not used, as each recursive call relies on local variables stored on the call stack. Static allocation is memory-efficient for shared data but can lead to issues in recursive functions if not managed carefully, as all recursive calls access the same variable, potentially causing unintended side effects.

Dynamic Storage Allocation

Dynamic storage allocation refers to memory allocation that occurs at runtime, typically on the heap or call stack, and can vary in size during program execution. In recursion, dynamic storage allocation is critical because each recursive call allocates memory on the Java call stack for its local variables, parameters, and return address. In the provided factorial implementation, each call allocates space for the parameter n and temporary results. In the fibonacci implementation, each call allocates space for n and intermediate sums. This dynamic allocation enables recursion to handle varying problem sizes but increases space complexity, as the stack grows with each recursive call until the base case is reached. Excessive recursion depth can lead to a StackOverflowError if the stack size is exceeded. Dynamic allocation is essential for recursion but requires careful design to manage memory usage effectively

✅ Tip: Use recursion for problems with a natural recursive structure, like tree traversals or divide-and-conquer algorithms. For efficiency, consider memoization or iteration for problems with overlapping subproblems, like Fibonacci.

⚠ Warning: Be cautious with deep recursion in Java, as it can lead to a StackOverflowError for large inputs due to dynamic allocation on the call stack. Always ensure base cases are reachable and consider iterative alternatives for performance-critical applications.

Exercises

  1. Recursive Sum of Array: Write a Java program that uses recursion to compute the sum of elements in an array. Test it with arrays of different sizes.
  2. Reverse a String Recursively: Create a recursive method to reverse a string by breaking it into smaller substrings. Test with various strings, including empty and single-character cases.
  3. Binary Search Recursion: Implement a recursive binary search algorithm for a sorted array. Test it with arrays containing both existing and non-existing elements.
  4. Factorial with Memoization: Modify the factorial implementation to use memoization (e.g., with a HashMap) to cache results. Compare its performance with the original for large inputs.
  5. Tower of Hanoi: Write a recursive program to solve the Tower of Hanoi problem for n disks. Print the sequence of moves and test with different values of n.

Factorial Example using Recursion

What is Factorial?

The factorial of a number n (written as n!) is the product of all positive integers from 1 to n.
Example: 5! = 5 × 4 × 3 × 2 × 1 = 120

Why use Recursion for Factorial?

  • Factorial definition is naturally recursive:
    n! = n × (n-1)!
  • Short and clean code compared to iterative loops.

Where to use Factorial (Real-world scenarios)

  • Probability & statistics (e.g., permutations and combinations)
  • Scientific calculations
  • Combinatorics problems

Java Example – Recursive Factorial

public class FactorialRecursion {
    // Recursive function to find factorial
    static int factorial(int n) {
        if (n == 0) { // Base case: factorial of 0 is 1
            return 1;
        }
        return n * factorial(n - 1); // Recursive case
    }

    public static void main(String[] args) {
        int num = 5;
        System.out.println(num + "! = " + factorial(num));
    }
}
5! = 120

How it works:

  • You call factorial(5) in main().
  • Since n is not 0, it does 5 × factorial(4).
  • To find factorial(4), it does 4 × factorial(3).
  • This keeps going until factorial(0) is called.
  • factorial(0) returns 1 (base case).
  • Now Java calculates: factorial(1) → 1 × 1 = 1 factorial(2) → 2 × 1 = 2 factorial(3) → 3 × 2 = 6 factorial(4) → 4 × 6 = 24 factorial(5) → 5 × 24 = 120

Recursion vs. Iteration

Definition and Concepts

Recursion and iteration are two fundamental programming techniques for solving repetitive problems. Recursion involves a function calling itself with a smaller or simpler instance of the same problem until a base case is reached, which provides a direct solution. Each recursive call is managed on the call stack, storing parameters and local variables. Iteration, in contrast, uses loops (e.g., for or while loops) to repeatedly execute a block of code until a condition is met, relying on variables to track state within a single function. You can visualize recursion as a stack of nested function calls that unwind, while iteration is a single loop cycling through updates. Both approaches can solve similar problems, but they differ in code structure, memory usage, and readability.

Why Use It?

Recursion is used to simplify the solution to problems with a naturally hierarchical or self-referential structure, such as tree traversals or divide-and-conquer algorithms, offering concise and elegant code. Iteration is used when performance and memory efficiency are critical, as it avoids the overhead of multiple function calls and stack management. Choosing between recursion and iteration depends on the problem’s structure, performance requirements, and readability preferences. For example, recursion excels in problems like factorial computation for clarity, while iteration is preferred for large inputs to avoid stack overflow.

Where to Use? (Real-Life Examples)

  • Recursion in Tree Traversal: File systems use recursion to traverse directory trees, processing subdirectories hierarchically, as recursion naturally handles nested structures.
  • Iteration in Data Processing: Data pipelines use iteration to process large datasets, such as summing values in a list, for efficiency and minimal memory usage.
  • Recursion in Parsing: Compilers use recursion to parse nested expressions, like (2 + (3 * 4)), breaking them into sub-expressions.
  • Iteration in Simulations: Game loops use iteration to update game states repeatedly, such as moving objects in a physics simulation, avoiding stack overhead.

SVG Diagram

The diagram would depict two side-by-side representations for computing factorial(4). On the left, recursion would show a stack of function calls (factorial(4) -> factorial(3) -> factorial(2) -> factorial(1)), with arrows indicating recursive calls downward and return values (1, 2, 6, 24) upward. On the right, iteration would show a single loop with a variable result updating (1, 2, 6, 24) over iterations. A caption would note: "Recursion uses a call stack for nested calls, while iteration updates state in a loop, avoiding stack overhead."

Explain Operations

  • Recursive Call (Recursion): This operation involves a function calling itself with modified parameters to solve a smaller subproblem. It has a time complexity dependent on the number of calls and work per call.
  • Base Case Check (Recursion): This operation checks if the input meets a condition to terminate recursion, returning a direct result. It has a time complexity of O(1).
  • Loop Execution (Iteration): This operation repeatedly executes a block of code, updating variables until a condition is met. It has a time complexity dependent on the number of iterations.
  • State Update (Iteration): This operation modifies variables within a loop to track progress. It typically has a time complexity of O(1) per update.
  • Stack Management (Recursion): The Java call stack manages recursive calls, storing state for each call. It contributes to space complexity based on recursion depth.

Java Implementation

The following Java code implements the factorial algorithm using both recursion and iteration for comparison.

public class RecursionVsIteration {
    // Recursive Factorial: Computes n! recursively
    public int factorialRecursive(int n) {
        if (n < 0) { // Checks for invalid input
            throw new IllegalArgumentException("Factorial is not defined for negative numbers.");
        }
        if (n == 0 || n == 1) { // Base case: 0! = 1, 1! = 1
            return 1;
        }
        return n * factorialRecursive(n - 1); // Recursive case: n! = n * (n-1)!
    }

    // Iterative Factorial: Computes n! iteratively
    public int factorialIterative(int n) {
        if (n < 0) { // Checks for invalid input
            throw new IllegalArgumentException("Factorial is not defined for negative numbers.");
        }
        int result = 1; // Initializes result for factorial
        for (int i = 1; i <= n; i++) { // Loops from 1 to n
            result *= i; // Updates result by multiplying with i
        }
        return result; // Returns final result
    }
}

How It Works

  1. Recursive Factorial:
    • The method checks if the input n is negative, throwing an exception if true.
    • If n is 0 or 1 (base case), it returns 1.
    • Otherwise, it recursively calls factorialRecursive(n-1) and multiplies the result by n.
    • For example, factorialRecursive(4) computes 4 * factorialRecursive(3), which computes 3 * factorialRecursive(2), until factorialRecursive(1) returns 1, yielding 24.
    • Each call is pushed onto the call stack, consuming memory until the base case is reached.
  2. Iterative Factorial:
    • The method checks if the input n is negative, throwing an exception if true.
    • It initializes a result variable to 1 and uses a for loop to multiply result by each integer from 1 to n.
    • For example, factorialIterative(4) iterates with result = 1 * 1 * 2 * 3 * 4, yielding 24.
    • The loop uses a single function frame, avoiding additional stack memory.
  3. Comparison: Recursion creates multiple stack frames, increasing space complexity, while iteration uses a single frame, making it more memory-efficient.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive FactorialO(n)O(n)
Iterative FactorialO(n)O(1)
Base Case Check (Recursion)O(1)O(1)
Loop Execution (Iteration)O(1) per iterationO(1)
Stack Management (Recursion)O(1) per callO(n)

Note:

  • Both recursive and iterative factorial have O(n) time complexity due to n multiplications.
  • Recursive factorial has O(n) space complexity due to n stack frames.
  • Iterative factorial has O(1) space complexity, as it uses only a few variables.

Key Differences / Notes

  • Code Readability:
    • Recursion offers concise, elegant code for problems with self-similar structures, like the recursive factorial, which mirrors the mathematical definition (n! = n * (n-1)!).
    • Iteration may require more lines of code but is often clearer for linear tasks, like the iterative factorial, avoiding stack management.
  • Performance:
    • Recursion incurs overhead from multiple function calls and stack management, which can lead to StackOverflowError for deep recursion in Java.
    • Iteration avoids this overhead, making it faster and more memory-efficient for large inputs.
  • Tail Recursion: Java does not optimize tail recursion, so recursive calls always consume stack space. Iterative solutions are preferred in Java for deep recursion.
  • Use Cases:
    • Recursion is ideal for hierarchical problems (e.g., tree traversals, divide-and-conquer).
    • Iteration is better for linear or sequential tasks (e.g., summing arrays, simple loops).

✅ Tip: Choose recursion for problems with a natural recursive structure, like tree traversals or factorial, to improve readability. Use iteration for performance-critical tasks or large inputs to minimize memory usage and avoid stack overflow.

⚠ Warning: Deep recursion in Java can cause a StackOverflowError due to limited stack size. Always ensure recursive base cases are reachable and consider iterative alternatives for large-scale problems to optimize performance.

Exercises

  1. Recursive vs. Iterative Sum: Write a Java program that computes the sum of an array using both recursive and iterative approaches. Compare their performance with large arrays.
  2. Reverse String Comparison: Implement string reversal using both recursion and iteration. Test with various strings and compare code readability and execution time.
  3. Power Function: Create recursive and iterative methods to compute x^n (x raised to the power n). Test with different inputs and analyze their space complexity.
  4. Fibonacci Optimization: Modify the recursive Fibonacci algorithm to use memoization, then compare it with an iterative Fibonacci implementation for performance on large inputs.
  5. Linked List Traversal: Using a singly linked list, implement recursive and iterative methods to traverse and print the list. Test both approaches and discuss their trade-offs.

Tail Recursion

Definition and Concepts

Tail recursion is a special form of recursion where the recursive call is the last operation in the function, meaning no additional computation is performed after the recursive call returns. This allows certain programming languages to optimize tail-recursive calls by reusing the current function’s stack frame, avoiding the creation of new stack frames for each call. In a tail-recursive function, the result of the recursive call is directly returned, often with an accumulator parameter to track intermediate results. You can visualize tail recursion as a loop-like process where each recursive call updates the state and passes it to the next call until a base case is reached. In Java, however, tail recursion optimization is not supported, so tail-recursive functions still consume stack space, behaving like regular recursion.

Why Use It?

Tail recursion is used to write recursive algorithms that can be optimized in languages that support tail call optimization (TCO), such as functional programming languages like Scala or Haskell, to prevent stack overflow and improve performance. It provides a clean, recursive approach to problems that might otherwise require iteration, maintaining readability while potentially achieving loop-like efficiency in TCO-supporting environments. In Java, tail recursion is less common due to the lack of TCO, but understanding it is valuable for designing recursive algorithms and preparing for languages or systems that optimize it. Tail recursion is particularly useful for problems with linear recursive structures, such as factorial or list traversal.

Where to Use? (Real-Life Examples)

  • Functional Programming: Languages like Scala use tail recursion to process large datasets, such as summing a list, to avoid stack overflow in recursive algorithms.
  • Compiler Optimizations: Compilers for functional languages use tail recursion to optimize recursive parsing of expressions, like evaluating nested function calls.
  • Event Loops in Simulations: Tail recursion can model event loops in simulations, such as cycling through states in a state machine, in TCO-supporting environments.
  • Recursive Data Processing: Tail recursion is used in processing pipelines, like traversing a linked list to compute its length, in systems where TCO is available.

SVG Diagram

The diagram for tail recursion would depict a stack of function calls for computing the factorial of 4 using tail recursion (e.g., factorialTail(4, 1)). Each call would be a rectangular box labeled factorialTail(n, acc), with n decreasing from 4 to 1 and acc (accumulator) updating (1, 4, 12, 24). Arrows would show recursive calls downward, with the base case (n == 1) returning the accumulator (24). A note would indicate that in TCO-supporting languages, the stack frame is reused, but in Java, new frames are created. A caption would note: "Tail recursion places the recursive call as the last operation, enabling stack frame reuse in TCO-supporting languages."

Explain Operations

  • Tail-Recursive Call: This operation involves the function calling itself as its last action, passing updated parameters (often an accumulator) to the next call. The time complexity depends on the number of calls and work per call.
  • Base Case Check: This operation checks if the input meets a condition to terminate recursion, returning the accumulator or result directly. It has a time complexity of O(1).
  • Accumulator Update: This operation updates an accumulator parameter to track intermediate results, avoiding post-call computations. It typically has a time complexity of O(1).
  • Stack Management: In TCO-supporting languages, the stack frame is reused for tail-recursive calls, resulting in O(1) space complexity. In Java, each call creates a new stack frame, leading to O(n) space complexity.

Java Implementation

The following Java code implements a tail-recursive factorial algorithm, using an accumulator to make it tail-recursive. A helper method is used to manage the accumulator.

public class TailRecursionExamples {
    // Factorial: Wrapper method for tail-recursive factorial
    public int factorial(int n) {
        if (n < 0) { // Checks for invalid input
            throw new IllegalArgumentException("Factorial is not defined for negative numbers.");
        }
        return factorialTail(n, 1); // Calls tail-recursive helper with initial accumulator
    }

    // FactorialTail: Tail-recursive helper method for factorial
    private int factorialTail(int n, int acc) {
        if (n == 0 || n == 1) { // Base case: returns accumulator when n is 0 or 1
            return acc;
        }
        return factorialTail(n - 1, n * acc); // Tail-recursive call with updated accumulator
    }
}

How It Works

  1. Factorial Wrapper Method:
    • The factorial method validates the input n, throwing an exception if negative.
    • It calls the factorialTail helper method with n and an initial accumulator value of 1.
  2. Tail-Recursive Factorial:
    • The factorialTail method checks if n is 0 or 1 (base case), returning the accumulator acc if true.
    • Otherwise, it makes a tail-recursive call to factorialTail with n-1 and an updated accumulator n * acc.
    • For example, factorialTail(4, 1) calls factorialTail(3, 4), then factorialTail(2, 12), then factorialTail(1, 24), which returns 24.
    • In Java, each call creates a new stack frame, unlike TCO-supporting languages where the stack frame would be reused.
  3. Stack Management: In Java, the call stack grows with each recursive call, storing n and acc for each frame. When the base case is reached, the stack unwinds, returning the final accumulator value.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity (Java)Space Complexity (TCO)
Factorial (Tail)O(n)O(n)O(1)
Base Case CheckO(1)O(1)O(1)
Accumulator UpdateO(1)O(1)O(1)
Tail-Recursive CallO(1) per callO(1) per callO(1)

Note:

  • Time complexity is O(n) due to n recursive calls, each performing O(1) work.
  • In Java, space complexity is O(n) due to n stack frames.
  • In TCO-supporting languages, space complexity is O(1) due to stack frame reuse.

Key Differences / Notes

  • Tail Recursion vs. Regular Recursion:
    • The implementation above uses tail recursion, where the recursive call is the last operation, enabling potential optimization in TCO-supporting languages.
    • Regular recursion (e.g., n * factorial(n-1)) performs computations after the recursive call, requiring stack frames to store intermediate results.
  • Java’s Lack of TCO: Java does not optimize tail recursion, so tail-recursive functions consume O(n) stack space, similar to regular recursion. Use iteration in Java for deep recursion.
  • TCO-Supporting Languages: Languages like Scala or Haskell optimize tail recursion, making it as efficient as iteration in terms of space complexity.
  • Accumulator Usage: Tail recursion often uses an accumulator to pass intermediate results, avoiding post-call computations and simplifying the function’s structure.

✅ Tip: Use tail recursion when designing recursive algorithms for clarity, especially if targeting languages with tail call optimization. In Java, consider converting tail-recursive functions to iterative versions for better performance with large inputs.

⚠ Warning: In Java, tail recursion offers no performance benefit over regular recursion due to the lack of tail call optimization. Deep tail recursion can still cause a StackOverflowError, so use iteration for large-scale problems.

Exercises

  1. Tail-Recursive Sum: Write a Java program that computes the sum of an array using a tail-recursive function with an accumulator. Test it with arrays of different sizes.
  2. Tail-Recursive List Length: Implement a tail-recursive method to compute the length of a singly linked list. Compare it with an iterative version for readability and performance.
  3. Tail-Recursive Power: Create a tail-recursive method to compute x^n (x raised to the power n) using an accumulator. Test with various inputs and compare with an iterative approach.
  4. Reverse String Tail Recursion: Write a tail-recursive method to reverse a string using an accumulator to build the result. Test with different strings and discuss Java’s stack usage.
  5. Factorial Conversion: Convert the tail-recursive factorial implementation to an iterative version. Test both versions with large inputs and measure stack usage (e.g., by catching StackOverflowError).

Core Data Structures

What are Core Data Structures?

Core data structures are the basic ways of organizing and storing data so that it can be used efficiently.
They are the building blocks of all programs — from a simple calculator app to large-scale social media platforms.

Why Learn Core Data Structures?

  • Efficiency: Choosing the right data structure can make programs run much faster.
  • Problem Solving: Many real-world problems map directly to these structures.
  • Foundation for Advanced DSA: You can’t master algorithms without knowing how data is stored and accessed.
  • Interview Readiness: Core data structures are a favorite topic in technical interviews.

📖 What You Will Learn in This Chapter

This chapter introduces you to the most commonly used data structures, their uses, advantages, disadvantages, and Java implementations.

1. Array

  • What is an Array and how it works.
  • How to store and access elements using indexes.
  • Limitations of arrays.
  • Real-life example: Managing a list of student marks.

2. Strings

  • How strings store sequences of characters.
  • String manipulation in Java.
  • Common operations like concatenation, searching, and substring extraction.
  • Real-life example: Checking if a password contains a special character.

3. Linked Lists

  • How nodes are connected using references.
  • Singly vs Doubly linked lists.
  • Adding, deleting, and traversing nodes.
  • Real-life example: Music playlist navigation.

4. Stacks

  • Last In, First Out (LIFO) principle.
  • Push, Pop, Peek operations.
  • Real-life example: Undo/Redo feature in editors.

5. Queues

  • First In, First Out (FIFO) principle.
  • Enqueue and Dequeue operations.
  • Variations like Circular Queue and Priority Queue.
  • Real-life example: Ticket booking system.

6. Hashing

  • Storing and retrieving data using key–value pairs.
  • Hash functions and hash tables.
  • Handling collisions.
  • Real-life example: Fast dictionary lookup.

7. Trees

  • Hierarchical data representation.
  • Types of trees: Binary, Binary Search Tree (BST), etc.
  • Traversal methods (Inorder, Preorder, Postorder).
  • Real-life example: Folder structure in your computer.

8. Graphs

  • Nodes (vertices) and connections (edges).
  • Directed vs Undirected graphs.
  • Representations: Adjacency Matrix and Adjacency List.
  • Real-life example: Social network connections.

🛠 How We Will Learn

For each data structure, we will cover:

  1. Definition – What it is.
  2. Why – Advantages and limitations.
  3. Where to Use – Real-life scenarios.
  4. Java Implementation – With step-by-step explanations.
  5. Complexity Analysis – Understanding performance.

Key Takeaway

By the end of this chapter, you will be able to:

  • Identify the right data structure for a problem.
  • Implement it in Java.
  • Analyze its performance.
  • Use it in real-world applications.

Array

Definition and Concepts

An array in Java is a fixed-size, ordered collection of elements of the same data type, stored in contiguous memory locations. Each element is accessed using an index, starting from 0. Arrays are one of the simplest and most fundamental data structures, providing direct access to elements through their indices. You can visualize an array as a row of boxes, where each box holds a value and is labeled with an index. In Java, arrays are objects, created using the new keyword, and their size is set at initialization and cannot be changed. Arrays are versatile and used for storing lists of data, such as numbers or objects, when the size is known in advance.

Key Points:

  • Arrays store elements of a single data type (e.g., int, String).
  • Elements are accessed using an index (e.g., arr[0] for the first element).
  • Arrays are fixed-size, meaning their length cannot be modified after creation.
  • Declaration uses square brackets: dataType[] arrayName;.
  • Memory allocation uses new: arrayName = new dataType[size];.

Why Use It?

Arrays are used to store and manipulate a fixed number of elements efficiently, offering O(1) time complexity for accessing and modifying elements due to their contiguous memory layout. They are ideal for scenarios where the data size is known and constant, providing a simple and fast way to organize data. Arrays serve as the foundation for many algorithms and data structures, such as sorting, searching, and matrix operations, due to their straightforward indexing and predictable performance.

Where to Use? (Real-Life Examples)

  • Data Storage: Spreadsheets use arrays to store numerical data in cells, enabling quick calculations and lookups.
  • Image Processing: Image processing applications use arrays to store pixel intensities for grayscale images, with each element representing a pixel value.
  • Game Development: Games use arrays to store player scores, level data, or coordinates, allowing fast access during gameplay.
  • Algorithm Implementation: Sorting and searching algorithms, like quicksort or binary search, use arrays to process ordered or unordered data efficiently.

Explain Operations

  • Initialization: This operation allocates memory for an array of a specified size and data type. It has a time complexity of O(n), where n is the array size.
  • Access Element: This operation retrieves an element at a specific index. It has a time complexity of O(1).
  • Modify Element: This operation updates an element at a specific index. It has a time complexity of O(1).
  • Traverse Array: This operation visits all elements in the array, typically using a loop. It has a time complexity of O(n).
  • Get Length: This operation returns the size of the array. It has a time complexity of O(1).

Java Implementation

The following Java code demonstrates common array operations, including initialization, access, modification, traversal, and getting the length.

public class ArrayExamples {
    // Initialize: Creates an array with a specified size
    public int[] initializeArray(int size) {
        if (size <= 0) {
            throw new IllegalArgumentException("Array size must be positive.");
        }
        return new int[size]; // Allocates array of specified size
    }

    // Access Element: Retrieves an element at a given index
    public int accessElement(int[] array, int index) {
        if (array == null || index < 0 || index >= array.length) {
            throw new IllegalArgumentException("Invalid index.");
        }
        return array[index]; // Returns element at specified index
    }

    // Modify Element: Updates an element at a given index
    public void modifyElement(int[] array, int index, int value) {
        if (array == null || index < 0 || index >= array.length) {
            throw new IllegalArgumentException("Invalid index.");
        }
        array[index] = value; // Updates element at specified index
    }

    // Traverse Array: Visits all elements in the array
    public void traverseArray(int[] array) {
        if (array == null || array.length == 0) {
            throw new IllegalArgumentException("Array is null or empty.");
        }
        for (int i = 0; i < array.length; i++) {
            System.out.print(array[i] + " "); // Prints each element
        }
        System.out.println(); // New line after traversal
    }

    // Get Length: Returns the size of the array
    public int getLength(int[] array) {
        if (array == null) {
            throw new IllegalArgumentException("Array is null.");
        }
        return array.length; // Returns the array length
    }
}

How It Works

  1. Initialization:
    • The initializeArray method allocates an array of the specified size using new int[size]. All elements are initialized to 0 for int arrays.
    • For example, initializeArray(5) creates an array [0, 0, 0, 0, 0].
  2. Access Element:
    • The accessElement method retrieves the value at array[index] after validating the index to prevent exceptions.
    • For example, accessElement(array, 2) returns the element at index 2.
  3. Modify Element:
    • The modifyElement method updates array[index] with the given value after validating the index.
    • For example, modifyElement(array, 2, 8) sets the element at index 2 to 8.
  4. Traverse Array:
    • The traverseArray method uses a loop to visit each element, printing them in sequence.
    • For example, traversing [5, 3, 8, 1, 9] prints "5 3 8 1 9".
  5. Get Length:
    • The getLength method returns array.length, the size of the array.
    • For example, getLength(array) for an array of 5 elements returns 5.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
InitializationO(n)O(n)
Access ElementO(1)O(1)
Modify ElementO(1)O(1)
Traverse ArrayO(n)O(1)
Get LengthO(1)O(1)

Note:

  • n is the number of elements in the array.
  • Space complexity for initialization accounts for the memory allocated for all elements.

Key Differences / Notes

  • Array vs. ArrayList:
    • Arrays are fixed-size, while ArrayList supports dynamic resizing, making it suitable for collections that grow or shrink.
    • Arrays offer O(1) access and modification, while ArrayList has similar performance but with additional overhead for resizing.
  • Array vs. Jagged/Multidimensional Arrays:
    • A simple array is one-dimensional, while multidimensional arrays (e.g., 2D, 3D) organize data in multiple dimensions, and jagged arrays allow rows of varying lengths.
    • Multidimensional arrays are used for grid-like data, while simple arrays are for linear lists.
  • Memory Allocation:
    • Arrays allocate contiguous memory, ensuring fast access but requiring a fixed size at creation.
  • Primitive vs. Reference Types:
    • Arrays can store primitive types (e.g., int, double) or reference types (e.g., String, objects), with null initialization for reference types.

✅ Tip: Use arrays when the size of the data is fixed and known in advance to leverage their O(1) access and modification times. For simple linear data, arrays are more efficient than dynamic structures like ArrayList.

⚠ Warning: Always validate array indices before accessing or modifying elements to prevent ArrayIndexOutOfBoundsException. Avoid using arrays for collections that require frequent resizing, as this requires creating a new array and copying elements.

Exercises

  1. Array Reversal: Write a Java program that reverses an array in-place (without using extra space). Test with arrays of different sizes.
  2. Maximum Element: Implement a method to find the maximum element in an array. Test with arrays containing positive and negative numbers.
  3. Array Rotation: Create a program that rotates an array by k positions to the left. Test with different values of k and array sizes.
  4. Duplicate Finder: Write a method to check if an array contains duplicate elements. Test with sorted and unsorted arrays.
  5. Array Sorting: Implement a simple sorting algorithm (e.g., bubble sort) to sort an array in ascending order. Test with various input arrays.

Multidimensional Array

Definition and Concepts

A multidimensional array in Java is an array of arrays. It allows storing data in a tabular (row-column) format or even higher dimensions. You can visualize a multidimensional array as a grid for two-dimensional (2D) arrays, where each cell is accessed by row and column indices, or as a cube for three-dimensional (3D) arrays, with additional depth indices. This structure is ideal for representing structured data, such as matrices, tensors, or multi-level datasets. In Java, multidimensional arrays are implemented as arrays of references to other arrays, with each dimension adding a level of nesting. They are fixed-size, meaning dimensions are set during initialization and cannot be resized dynamically.

What is a Multidimensional Array?

A multidimensional array in Java is an array of arrays. It allows storing data in a tabular (row-column) format or even higher dimensions.

Key Points:

  • Two-dimensional (2D) arrays store data in a matrix format (rows and columns).
  • Three-dimensional (3D) arrays extend the concept further.
  • Each dimension is represented as an array of arrays.
  • Elements are accessed using multiple indices (e.g., arr[row][col] for 2D arrays).

Declaration and Initialization

A multidimensional array is declared using multiple square brackets [ ].

dataType[][] arrayName; // 2D Array declaration
dataType[][][] arrayName; // 3D Array declaration

Memory Allocation (Instantiation)

Once declared, memory needs to be allocated before usage.

arrayName = new dataType[rows][columns]; // 2D Array
arrayName = new dataType[depth][rows][columns]; // 3D Array

Multidimensional arrays provide efficient O(1) access to elements but require careful initialization to avoid null pointer exceptions. Higher-dimensional arrays (e.g., 3D or beyond) are less common but follow the same principle of nested arrays.

Why Use It?

Multidimensional arrays are used to organize data in a structured, grid-like format for applications requiring tabular or hierarchical data representation, such as matrices or 3D models. They offer fast O(1) access and modification times due to contiguous memory allocation, making them suitable for performance-critical tasks. Multidimensional arrays are ideal when the data size is known in advance and does not require dynamic resizing, providing a simple and efficient way to handle multi-level data.

Where to Use? (Real-Life Examples)

  • Matrix Operations: Mathematical software uses multidimensional arrays to perform operations like matrix multiplication or determinant calculation in linear algebra.
  • Image Processing: Image processing applications use 2D arrays to store pixel values, where each element represents a color or intensity at a specific row and column.
  • Game Development: Board games or grid-based simulations use 2D arrays to represent game states, such as a chessboard or a grid-based maze.
  • Scientific Simulations: Physics engines use 3D arrays to model spatial data, such as temperature distributions in a 3D space or voxel grids in simulations.

Explain Operations

  • Initialization: This operation allocates memory for a multidimensional array by specifying the size of each dimension. It has a time complexity of O(n), where n is the total number of elements across all dimensions.
  • Access Element: This operation retrieves an element at a specific set of indices (e.g., arr[row][col] for a 2D array). It has a time complexity of O(1).
  • Modify Element: This operation updates an element at a specific set of indices. It has a time complexity of O(1).
  • Traverse Array: This operation visits all elements in the multidimensional array using nested loops. It has a time complexity of O(n), where n is the total number of elements.
  • Get Dimension Sizes: This operation returns the size of a specific dimension (e.g., number of rows or columns in a 2D array). It has a time complexity of O(1).

Java Implementation

The following Java code demonstrates common operations on a 2D multidimensional array, including initialization, access, modification, traversal, and getting dimension sizes.

public class MultidimensionalArrayExamples {
    // Initialize: Creates a 2D array with specified rows and columns
    public int[][] initialize2DArray(int rows, int cols) {
        if (rows <= 0 || cols <= 0) {
            throw new IllegalArgumentException("Rows and columns must be positive.");
        }
        int[][] array = new int[rows][cols]; // Allocates 2D array
        return array;
    }

    // Access Element: Retrieves an element at [row][col]
    public int accessElement(int[][] array, int row, int col) {
        if (array == null || row < 0 || row >= array.length || col < 0 || col >= array[0].length) {
            throw new IllegalArgumentException("Invalid row or column index.");
        }
        return array[row][col]; // Returns element at specified position
    }

    // Modify Element: Updates an element at [row][col]
    public void modifyElement(int[][] array, int row, int col, int value) {
        if (array == null || row < 0 || row >= array.length || col < 0 || col >= array[0].length) {
            throw new IllegalArgumentException("Invalid row or column index.");
        }
        array[row][col] = value; // Updates element at specified position
    }

    // Traverse Array: Visits all elements in the 2D array
    public void traverseArray(int[][] array) {
        if (array == null || array.length == 0) {
            throw new IllegalArgumentException("Array is null or empty.");
        }
        for (int i = 0; i < array.length; i++) {
            for (int j = 0; j < array[i].length; j++) {
                System.out.print(array[i][j] + " "); // Prints each element
            }
            System.out.println(); // New line after each row
        }
    }

    // Get Dimension Sizes: Returns the number of rows and columns
    public int[] getDimensionSizes(int[][] array) {
        if (array == null || array.length == 0) {
            throw new IllegalArgumentException("Array is null or empty.");
        }
        return new int[]{array.length, array[0].length}; // Returns [rows, cols]
    }
}

How It Works

  1. Initialization:
    • The initialize2DArray method allocates a 2D array with the specified number of rows and columns using new int[rows][cols]. All elements are initialized to 0 for int arrays.
    • For example, initialize2DArray(3, 4) creates a 3x4 matrix. For a 3D array, you would use new int[depth][rows][cols].
  2. Access Element:
    • The accessElement method retrieves the value at array[row][col] after validating the indices to prevent exceptions.
    • For example, accessElement(array, 1, 2) returns the element at row 1, column 2 in a 2D array.
  3. Modify Element:
    • The modifyElement method updates array[row][col] with the given value after validating indices.
    • For example, modifyElement(array, 1, 2, 5) sets the element at row 1, column 2 to 5.
  4. Traverse Array:
    • The traverseArray method uses nested loops to visit each element in the 2D array, printing them row by row. For a 3D array, an additional loop would iterate over the depth dimension.
    • For example, traversing a 2x3 array prints all elements in a grid format.
  5. Get Dimension Sizes:
    • The getDimensionSizes method returns an array containing the number of rows (array.length) and columns (array[0].length) for a 2D array. For higher dimensions, additional lengths would be accessed (e.g., array[0][0].length for a 3D array).
    • For example, getDimensionSizes(array) for a 3x4 array returns [3, 4].

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
InitializationO(n)O(n)
Access ElementO(1)O(1)
Modify ElementO(1)O(1)
Traverse ArrayO(n)O(1)
Get Dimension SizesO(1)O(1)

Note:

  • n is the total number of elements in the array (e.g., rows * columns for 2D arrays, depth * rows * columns for 3D arrays).
  • Space complexity for initialization accounts for the memory allocated for all elements.

Key Differences / Notes

  • Multidimensional vs. Jagged Arrays:
    • Multidimensional arrays have fixed dimensions, with all rows having the same number of columns in a 2D array, ensuring a rectangular structure.

Jagged arrays allow rows of different lengths, offering memory efficiency for irregular data but requiring additional index validation.

  • Memory Allocation:
    • Multidimensional arrays allocate contiguous memory for all elements, providing predictable O(1) access but potentially wasting memory for sparse data.
    • In Java, a 2D array is an array of references to 1D arrays, and a 3D array adds another level of nesting, but the sub-arrays are typically uniform in size.
  • Higher Dimensions:
    • While 2D arrays are common for matrices, 3D or higher-dimensional arrays are used for complex data, such as 3D spatial models, but increase memory and complexity.
  • Dynamic Resizing:
    • Java arrays are fixed-size, so resizing a multidimensional array requires creating a new array and copying elements. Use ArrayList for dynamic sizing if needed.

✅ Tip: Use multidimensional arrays for structured data with fixed dimensions, such as matrices or 3D models, to leverage fast O(1) access and modification. Initialize all dimensions at creation to ensure proper memory allocation.

⚠ Warning: Always validate indices when accessing or modifying elements in a multidimensional array to prevent ArrayIndexOutOfBoundsException. Be cautious with higher-dimensional arrays, as their memory usage grows exponentially with each dimension, potentially impacting performance.

Exercises

  1. Matrix Multiplication: Write a Java program that multiplies two 2D arrays (matrices) and returns the result as a new 2D array. Test with matrices of different compatible sizes.
  2. Transpose Matrix: Implement a method to transpose a 2D array (swap rows and columns). Test with square and non-square matrices.
  3. 3D Array Summation: Create a program that initializes a 3D array and computes the sum of all elements using nested loops. Test with different dimensions.
  4. Diagonal Elements: Write a method to extract the main diagonal elements of a square 2D array into a 1D array. Test with various square matrices.
  5. Wave Traversal: Implement a program that traverses a 2D array in a wave pattern (e.g., top-to-bottom for even columns, bottom-to-top for odd columns). Test with different matrix sizes.

Jagged Array

Definition and Concepts

A jagged array in Java is an array of arrays where each sub-array can have a different length, unlike a regular (rectangular) two-dimensional array where all rows have the same number of columns. Also known as a ragged array, it is a collection of one-dimensional arrays stored in a single array, where each element is itself an array of varying sizes. You can visualize a jagged array as a table where each row can have a different number of columns, providing flexibility in memory allocation. In Java, jagged arrays are implemented by creating an array of references to other arrays, allowing dynamic sizing for each row. This structure is useful when data sizes vary across rows, optimizing memory usage compared to fixed-size rectangular arrays.

Why Use It?

Jagged arrays are used to efficiently store and manipulate data when the number of elements in each row varies, reducing memory waste compared to rectangular arrays. They provide flexibility in handling irregular datasets, such as matrices with uneven dimensions or collections of variable-length lists. Jagged arrays are ideal for applications where memory efficiency is critical, and the data structure naturally aligns with varying row lengths. They also simplify operations like adding or removing elements in specific rows without affecting others.

Where to Use? (Real-Life Examples)

  • Sparse Matrix Representation: Scientific computing applications use jagged arrays to store sparse matrices, where most elements are zero, to save memory by only storing non-zero elements per row.
  • Graph Adjacency Lists: Graph algorithms use jagged arrays to represent adjacency lists, where each vertex has a list of neighbors with varying lengths.
  • Text Processing: Natural language processing applications use jagged arrays to store sentences of different lengths from a document.
  • Dynamic Data Storage: Database applications use jagged arrays to store query results with variable numbers of fields per record.

Explain Operations

  • Initialization: This operation creates a jagged array by allocating the main array and then allocating each sub-array with a specific length. It has a time complexity of O(1) for the main array and O(n) for sub-arrays, where n is the total number of elements.
  • Access Element: This operation retrieves an element at a specific row and column index. It has a time complexity of O(1).
  • Modify Element: This operation updates an element at a specific row and column index. It has a time complexity of O(1).
  • Add Row: This operation adds a new row by allocating a new sub-array and assigning it to the main array. It has a time complexity of O(n) for the sub-array allocation, where n is the row length.
  • Get Row Length: This operation returns the length of a specific row. It has a time complexity of O(1).

Java Implementation

The following Java code demonstrates common operations on a jagged array, including initialization, access, modification, adding a row, and getting row length.

public class JaggedArrayExamples {
    // Initialize: Creates a jagged array with specified row lengths
    public int[][] initializeJaggedArray(int[] rowLengths) {
        int[][] jaggedArray = new int[rowLengths.length][]; // Allocates main array
        for (int i = 0; i < rowLengths.length; i++) {
            jaggedArray[i] = new int[rowLengths[i]]; // Allocates each sub-array
        }
        return jaggedArray;
    }

    // Access Element: Retrieves an element at [row][col]
    public int accessElement(int[][] jaggedArray, int row, int col) {
        if (row < 0 || row >= jaggedArray.length || col < 0 || col >= jaggedArray[row].length) {
            throw new IllegalArgumentException("Invalid row or column index.");
        }
        return jaggedArray[row][col]; // Returns element at specified position
    }

    // Modify Element: Updates an element at [row][col]
    public void modifyElement(int[][] jaggedArray, int row, int col, int value) {
        if (row < 0 || row >= jaggedArray.length || col < 0 || col >= jaggedArray[row].length) {
            throw new IllegalArgumentException("Invalid row or column index.");
        }
        jaggedArray[row][col] = value; // Updates element at specified position
    }

    // Add Row: Adds a new row to the jagged array
    public int[][] addRow(int[][] jaggedArray, int[] newRow) {
        int[][] newJaggedArray = new int[jaggedArray.length + 1][]; // Creates new main array
        for (int i = 0; i < jaggedArray.length; i++) {
            newJaggedArray[i] = jaggedArray[i]; // Copies existing rows
        }
        newJaggedArray[jaggedArray.length] = newRow; // Adds new row
        return newJaggedArray;
    }

    // Get Row Length: Returns the length of a specific row
    public int getRowLength(int[][] jaggedArray, int row) {
        if (row < 0 || row >= jaggedArray.length) {
            throw new IllegalArgumentException("Invalid row index.");
        }
        return jaggedArray[row].length; // Returns length of specified row
    }
}

How It Works

  1. Initialization:
    • The initializeJaggedArray method creates a main array of size equal to rowLengths.length and allocates each sub-array with the length specified in rowLengths.
    • For example, initializeJaggedArray(new int[]{2, 4, 1}) creates a jagged array with three rows of lengths 2, 4, and 1.
  2. Access Element:
    • The accessElement method uses jaggedArray[row][col] to retrieve the element at the specified position after validating indices.
    • For example, accessElement(jaggedArray, 1, 2) returns the element at row 1, column 2.
  3. Modify Element:
    • The modifyElement method updates jaggedArray[row][col] with the given value after validating indices.
    • For example, modifyElement(jaggedArray, 1, 2, 5) sets the element at row 1, column 2 to 5.
  4. Add Row:
    • The addRow method creates a new main array with one additional slot, copies existing rows, and assigns the newRow to the last slot.
    • For example, addRow(jaggedArray, new int[]{7, 8}) adds a new row [7, 8] to the jagged array.
  5. Get Row Length:
    • The getRowLength method returns jaggedArray[row].length after validating the row index.
    • For example, getRowLength(jaggedArray, 1) returns the length of row 1.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
InitializationO(n)O(n)
Access ElementO(1)O(1)
Modify ElementO(1)O(1)
Add RowO(m)O(m)
Get Row LengthO(1)O(1)

Note:

  • n is the total number of elements across all sub-arrays for initialization.
  • m is the number of rows in the jagged array for the add row operation.
  • Space complexity accounts for the memory allocated for the arrays.

Key Differences / Notes

  • Jagged Array vs. Rectangular Array:
    • Jagged arrays allow rows of different lengths, saving memory for irregular data, while rectangular arrays (int[][]) have fixed row and column sizes.
    • Jagged arrays are implemented as arrays of arrays, while rectangular arrays are contiguous 2D structures.
  • Memory Efficiency:
    • Jagged arrays are more memory-efficient for sparse or irregular data, as unused elements are not allocated, unlike rectangular arrays.
  • Dynamic Resizing:
    • Adding rows requires creating a new main array, as Java arrays are fixed-size. Use ArrayList<int[]> for dynamic resizing if needed.
  • Use in Algorithms:
    • Jagged arrays are commonly used in graph algorithms (e.g., adjacency lists) and dynamic programming with variable-sized rows.

✅ Tip: Use jagged arrays when dealing with irregular data, such as sparse matrices or adjacency lists, to optimize memory usage. Initialize row lengths based on the specific needs of each row to avoid unnecessary allocation.

⚠ Warning: Be cautious when accessing elements in a jagged array, as each row has a different length. Always validate column indices to avoid ArrayIndexOutOfBoundsException. Avoid frequent row additions, as they require creating a new main array, which is costly.

Exercises

  1. Sparse Matrix Sum: Write a Java program that uses a jagged array to represent a sparse matrix and computes the sum of all non-zero elements. Test with matrices of varying row lengths.
  2. Adjacency List Representation: Implement a graph using a jagged array as an adjacency list. Add edges and print the neighbors of each vertex. Test with a sample graph.
  3. Row Sorting: Create a program that sorts each row of a jagged array independently. Test with a jagged array containing rows of different lengths.
  4. Dynamic Row Addition: Write a program that allows users to add rows to a jagged array interactively, specifying row lengths and elements. Test with multiple additions.
  5. Jagged Array Transpose: Implement a program that transposes a jagged array (converting rows to columns) into another jagged array. Test with irregular input arrays.

String Data Structure

Definition and Concepts

A string is a sequence of characters used to represent text in programming. In Java, the String class is a fundamental data structure that is immutable, meaning its content cannot be changed after creation. Strings are stored as arrays of characters internally, but Java’s String class provides a high-level interface for manipulating text. You can visualize a string as a chain of characters, such as "Hello", where each character occupies a specific position (index) starting from 0. Strings support operations like concatenation, substring extraction, and searching, making them essential for text processing. Java also provides mutable alternatives like StringBuilder and StringBuffer for scenarios requiring frequent modifications.

Why Use It?

Strings are used to handle and manipulate textual data in a wide range of applications, from user input processing to file parsing. Their immutability in Java ensures thread safety and consistency, making them ideal for constant or shared text data. Strings provide a rich set of methods for searching, modifying, and comparing text, simplifying complex text operations. For performance-critical applications, mutable classes like StringBuilder are used to reduce overhead from creating multiple string objects.

Where to Use? (Real-Life Examples)

  • Text Processing: Text editors use strings to store and manipulate document content, such as formatting or searching text.
  • Web Development: Web applications use strings to process URLs, HTML content, and user input, such as form data.
  • Data Parsing: CSV or JSON parsers use strings to extract fields and values from structured data files.
  • Search and Validation: Search engines and form validators use strings to match patterns (e.g., email validation) or search for keywords.

Explain Operations

  • Concatenation: This operation combines two strings into a new string. It has a time complexity of O(n), where n is the total length of the resulting string.
  • Substring Extraction: This operation extracts a portion of the string based on start and end indices. It has a time complexity of O(n) in Java due to string immutability.
  • Search (Index Of): This operation finds the index of a substring or character within the string. It has a time complexity of O(n*m) in the worst case, where n is the string length and m is the substring length.
  • Length: This operation returns the number of characters in the string. It has a time complexity of O(1).
  • Character Access: This operation retrieves the character at a specific index. It has a time complexity of O(1).

Java Implementation

The following Java code demonstrates common string operations using the String class and introduces StringBuilder for mutable string manipulation.

public class StringExamples {
    // Concatenation: Combines two strings
    public String concatenate(String str1, String str2) {
        return str1 + str2; // Uses + operator for concatenation
    }

    // Substring: Extracts a portion of the string
    public String getSubstring(String str, int start, int end) {
        if (start < 0 || end > str.length() || start > end) {
            throw new IllegalArgumentException("Invalid start or end index.");
        }
        return str.substring(start, end); // Returns substring from start to end-1
    }

    // Search: Finds the index of a substring
    public int findIndex(String str, String target) {
        return str.indexOf(target); // Returns index of first occurrence or -1 if not found
    }

    // Length: Returns the number of characters
    public int getLength(String str) {
        return str.length(); // Returns the string length
    }

    // Character Access: Gets character at index
    public char getCharAt(String str, int index) {
        if (index < 0 || index >= str.length()) {
            throw new IllegalArgumentException("Invalid index.");
        }
        return str.charAt(index); // Returns character at specified index
    }

    // StringBuilder Example: Demonstrates mutable string manipulation
    public String buildString(String[] parts) {
        StringBuilder builder = new StringBuilder();
        for (String part : parts) {
            builder.append(part); // Appends each part to the builder
        }
        return builder.toString(); // Converts StringBuilder to String
    }
}

How It Works

  1. Concatenation:
    • The concatenate method uses the + operator, which internally creates a new String object combining str1 and str2.
    • For example, concatenate("Hello", " World") creates a new string "Hello World".
  2. Substring Extraction:
    • The getSubstring method calls str.substring(start, end), which creates a new string containing characters from index start to end-1.
    • For example, getSubstring("Hello", 1, 4) returns "ell".
  3. Search (Index Of):
    • The findIndex method uses str.indexOf(target) to return the starting index of target in str or -1 if not found.
    • For example, findIndex("Hello", "ll") returns 2.
  4. Length:
    • The getLength method calls str.length() to return the number of characters.
    • For example, getLength("Hello") returns 5.
  5. Character Access:
    • The getCharAt method uses str.charAt(index) to return the character at the specified index.
    • For example, getCharAt("Hello", 1) returns 'e'.
  6. StringBuilder Example:
    • The buildString method uses StringBuilder to efficiently append multiple strings from an array, then converts the result to a String.
    • For example, buildString(new String[]{"He", "llo"}) returns "Hello".

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ConcatenationO(n)O(n)
Substring ExtractionO(n)O(n)
Search (Index Of)O(n*m) worst caseO(1)
LengthO(1)O(1)
Character AccessO(1)O(1)
StringBuilder AppendO(1) amortizedO(n)

Note:

  • n is the length of the string (or total length for concatenation).
  • m is the length of the substring in indexOf.
  • StringBuilder append is O(1) amortized due to dynamic resizing.

Key Differences / Notes

  • String Immutability:
    • Java’s String class is immutable, so operations like concatenation and substring create new strings, increasing space complexity.
    • StringBuilder and StringBuffer are mutable, reducing overhead for frequent modifications.
  • String vs. StringBuilder/StringBuffer:
    • Use String for constant or infrequently modified text due to its immutability and thread safety.
    • Use StringBuilder for single-threaded, mutable string operations, or StringBuffer for thread-safe mutable operations.
  • Thread Safety:
    • String and StringBuilder are not thread-safe for modifications (though String is immutable). StringBuffer is thread-safe but slower.
  • String Pool:
    • Java maintains a string pool, a special area in the heap (part of the constant pool in the PermGen or Metaspace, depending on the Java version), to store unique string literals and interned strings. When a string literal (e.g., "Hello") is created, Java checks the string pool. If the string exists, the reference is reused; otherwise, a new string is added to the pool. This optimizes memory usage for repeated literals.
    • The intern() method can manually add a string to the pool, ensuring that identical strings share the same reference. For example, new String("Hello").intern() returns the pooled reference if "Hello" exists.
    • String pool usage reduces memory overhead but requires careful handling in performance-critical applications, as excessive interning can increase pool size and lookup time.

✅ Tip: Use StringBuilder for concatenating strings in a loop to avoid the overhead of creating multiple String objects. For simple, one-time concatenations, the + operator is sufficient and readable. Use String.intern() to leverage the string pool for memory optimization when dealing with frequently reused strings.

⚠ Warning: Avoid excessive string concatenation in loops using the + operator, as it creates multiple intermediate String objects, leading to O(n²) time complexity. Use StringBuilder for better performance. Be cautious with String.intern(), as overusing it can bloat the string pool and degrade performance.

Exercises

  1. Reverse a String: Write a Java program that reverses a string using both String methods and StringBuilder. Compare their performance for large strings.
  2. Palindrome Checker: Implement a method to check if a string is a palindrome (ignoring case and non-alphanumeric characters). Test with various inputs.
  3. String Compression: Create a program that compresses a string by replacing repeated characters with their count (e.g., "aabbb" becomes "a2b3"). Use StringBuilder for efficiency.
  4. Substring Frequency: Write a method to count the occurrences of a substring in a string using indexOf. Test with overlapping and non-overlapping cases.
  5. String Pool Experiment: Write a Java program that demonstrates the string pool by comparing string literals, new String() objects, and interned strings using == and equals(). Analyze memory usage and equality behavior.

Mutable Strings

Definition and Concepts

Mutable strings in Java refer to string-like data structures that can be modified after creation, unlike the immutable String class. Java provides two primary classes for mutable strings: StringBuilder and StringBuffer. Both classes allow operations like appending, inserting, or deleting characters without creating new objects, making them efficient for text manipulation. StringBuilder is designed for single-threaded environments, offering better performance, while StringBuffer is thread-safe, suitable for multi-threaded applications. You can visualize a mutable string as a dynamic sequence of characters that can be altered in place, such as changing "Hello" to "Hello World" by appending characters directly. Mutable strings are essential for performance-critical applications requiring frequent text modifications.

Why Use It?

Mutable strings are used to efficiently manipulate text in scenarios where frequent modifications, such as appending or inserting characters, are needed. Unlike immutable String objects, which create new instances for each operation, StringBuilder and StringBuffer modify their internal character arrays, reducing memory overhead and improving performance. StringBuilder is preferred for single-threaded applications due to its speed, while StringBuffer is used when thread safety is required. These classes are ideal for building strings incrementally, such as in loops or dynamic text generation.

Where to Use? (Real-Life Examples)

  • Log Message Construction: Logging frameworks use StringBuilder to construct log messages by appending data, avoiding the overhead of multiple String concatenations.
  • Text File Generation: Report generators use StringBuilder to build large text outputs, such as CSV or JSON files, by incrementally adding content.
  • String Manipulation in Editors: Text editors use mutable strings to handle user edits, such as inserting or deleting text in a document, in real-time.
  • Thread-Safe Text Processing: Multi-threaded server applications use StringBuffer to build responses concurrently, ensuring thread safety during string modifications.

Explain Operations

  • Append: This operation adds characters, strings, or other data types to the end of the mutable string. It has a time complexity of O(1) amortized.
  • Insert: This operation inserts characters or strings at a specified index, shifting subsequent characters. It has a time complexity of O(n) due to shifting.
  • Delete: This operation removes a range of characters from the mutable string, shifting remaining characters. It has a time complexity of O(n) due to shifting.
  • Replace: This operation replaces a range of characters with a new string. It has a time complexity of O(n) due to shifting and copying.
  • Length: This operation returns the number of characters in the mutable string. It has a time complexity of O(1).

Java Implementation

The following Java code demonstrates common operations using StringBuilder and StringBuffer.

public class MutableStringExamples {
    // StringBuilder Append: Appends a string to a StringBuilder
    public StringBuilder appendStringBuilder(StringBuilder builder, String str) {
        return builder.append(str); // Appends str to the builder
    }

    // StringBuilder Insert: Inserts a string at a specified index
    public StringBuilder insertStringBuilder(StringBuilder builder, int index, String str) {
        if (index < 0 || index > builder.length()) {
            throw new IllegalArgumentException("Invalid index.");
        }
        return builder.insert(index, str); // Inserts str at the specified index
    }

    // StringBuilder Delete: Deletes a range of characters
    public StringBuilder deleteStringBuilder(StringBuilder builder, int start, int end) {
        if (start < 0 || end > builder.length() || start > end) {
            throw new IllegalArgumentException("Invalid start or end index.");
        }
        return builder.delete(start, end); // Deletes characters from start to end-1
    }

    // StringBuilder Replace: Replaces a range of characters
    public StringBuilder replaceStringBuilder(StringBuilder builder, int start, int end, String str) {
        if (start < 0 || end > builder.length() || start > end) {
            throw new IllegalArgumentException("Invalid start or end index.");
        }
        return builder.replace(start, end, str); // Replaces characters from start to end-1 with str
    }

    // StringBuilder Length: Returns the number of characters
    public int getLengthStringBuilder(StringBuilder builder) {
        return builder.length(); // Returns the current length
    }

    // StringBuffer Example: Demonstrates thread-safe append
    public StringBuffer appendStringBuffer(StringBuffer buffer, String str) {
        return buffer.append(str); // Appends str to the thread-safe buffer
    }
}

How It Works

  1. Append:
    • The appendStringBuilder method calls builder.append(str) to add str to the end of the StringBuilder’s internal character array.
    • For example, appendStringBuilder(new StringBuilder("Hello"), " World") results in "Hello World".
    • The appendStringBuffer method works similarly for StringBuffer with thread-safe synchronization.
  2. Insert:
    • The insertStringBuilder method calls builder.insert(index, str) to insert str at the specified index, shifting subsequent characters.
    • For example, insertStringBuilder(new StringBuilder("Hlo"), 1, "el") results in "Hello".
  3. Delete:
    • The deleteStringBuilder method calls builder.delete(start, end) to remove characters from start to end-1, shifting remaining characters.
    • For example, deleteStringBuilder(new StringBuilder("Hello"), 1, 4) results in "Ho".
  4. Replace:
    • The replaceStringBuilder method calls builder.replace(start, end, str) to replace characters from start to end-1 with str.
    • For example, replaceStringBuilder(new StringBuilder("Halo"), 1, 3, "el") results in "Hello".
  5. Length:
    • The getLengthStringBuilder method calls builder.length() to return the number of characters.
    • For example, getLengthStringBuilder(new StringBuilder("Hello")) returns 5.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
AppendO(1) amortizedO(n)
InsertO(n)O(n)
DeleteO(n)O(n)
ReplaceO(n)O(n)
LengthO(1)O(1)

Note:

  • n is the length of the mutable string or the resulting string after the operation.
  • Append is O(1) amortized due to occasional resizing of the internal array.
  • Space complexity accounts for the internal character array, which may resize dynamically.

Key Differences / Notes

  • StringBuilder vs. StringBuffer:
    • StringBuilder is not thread-safe but faster, making it suitable for single-threaded applications.
    • StringBuffer is thread-safe due to synchronized methods, making it slower but appropriate for multi-threaded environments.
  • Mutable vs. Immutable Strings:
    • Unlike the immutable String class, which creates new objects for modifications, StringBuilder and StringBuffer modify their internal arrays, reducing memory overhead.
    • Operations like concatenation with String have O(n) space complexity per operation, while StringBuilder/StringBuffer appends are more efficient.
  • Internal Array Resizing: Both classes use a dynamic array that doubles in size when capacity is exceeded, leading to O(1) amortized append time but occasional O(n) resizing.
  • Java’s String Pool: Mutable strings (StringBuilder/StringBuffer) are not stored in the string pool, unlike String literals. Converting to String via toString() creates a new String that may be interned into the pool if needed.

✅ Tip: Use StringBuilder for most mutable string operations in single-threaded applications to maximize performance. Reserve StringBuffer for scenarios requiring thread safety, such as concurrent server applications.

⚠ Warning: Avoid using StringBuilder in multi-threaded environments without synchronization, as it can lead to data corruption. Use StringBuffer or explicit synchronization for thread-safe string manipulation.

Exercises

  1. String Reversal: Write a Java program that reverses a string using StringBuilder and StringBuffer. Compare their performance for large inputs.
  2. Dynamic Text Builder: Create a program that builds a formatted string (e.g., a CSV row) using StringBuilder, appending elements from an array. Test with varying array sizes.
  3. Thread-Safe Concatenation: Implement a multi-threaded program that uses StringBuffer to append strings concurrently from multiple threads. Verify thread safety with test cases.
  4. Insert and Delete Simulation: Write a program that simulates text editing using StringBuilder, performing a sequence of insert and delete operations. Test with different sequences.
  5. StringBuilder Capacity Management: Create a program that demonstrates StringBuilder’s capacity resizing by appending strings and monitoring capacity changes using capacity(). Analyze when resizing occurs.

Stack Data Structure

Definition and Concepts

A stack is a linear data structure that operates on the Last In, First Out (LIFO) principle. This means that the last element added to the stack is the first one removed. You can visualize a stack as a stack of plates, where you add a plate to the top and remove the topmost plate first. The primary operations of a stack include push, which adds an element to the top, and pop, which removes the top element. Additional operations include peek, which views the top element without removing it, and isEmpty, which checks if the stack is empty. Stacks are simple to implement and are highly efficient for problems requiring reversal or backtracking.

Why Use It?

A stack is useful when you need to process data in a Last In, First Out order. It provides an efficient way to manage data where the most recently added element is the first to be accessed or removed. Stacks are memory-efficient because they restrict operations to one end, known as the top, which simplifies data management and reduces operational complexity.

Where to Use? (Real-Life Examples)

  • Undo Functionality in Software: Text editors and graphic design tools use stacks to store user actions, such as typing or drawing, allowing the most recent action to be undone.
  • Browser History Navigation: Web browsers employ stacks to manage back and forward navigation, where the most recently visited page is removed when you click the "back" button.
  • Function Call Management: Programming languages utilize stacks to handle function calls, storing return addresses and local variables for each function.
  • Expression Evaluation: Stacks are used to evaluate mathematical expressions, such as 3 + 4 * 2, by managing operator precedence and parentheses.

Explain Operations

  • Push: This operation adds an element to the top of the stack. It has a time complexity of O(1).
  • Pop: This operation removes and returns the top element from the stack. If the stack is empty, it may throw an exception or return null. It has a time complexity of O(1).
  • Peek: This operation returns the top element without removing it. It has a time complexity of O(1).
  • isEmpty: This operation checks whether the stack is empty. It has a time complexity of O(1).
  • Size: This operation returns the number of elements currently in the stack. It has a time complexity of O(1).

Java Implementation

The following Java code implements a stack using an array.

public class Stack {
    private int maxSize; // Represents the maximum size of the stack
    private int[] stackArray; // Array to store the stack elements
    private int top; // Index of the top element in the stack

    // Constructor to initialize the stack with a given size
    public Stack(int size) {
        maxSize = size;
        stackArray = new int[size];
        top = -1; // Indicates that the stack is initially empty
    }

    // Push: Adds an element to the top of the stack
    public void push(int value) {
        if (喧
        if (top < maxSize - 1) { // Checks if the stack is not full
            stackArray[++top] = value; // Increments top and adds the value
        } else {
            throw new IllegalStateException("The stack is full.");
        }
    }

    // Pop: Removes and returns the top element
    public int pop() {
        if (isEmpty()) { // Checks if the stack is empty
            throw new IllegalStateException("The stack is empty.");
        }
        return stackArray[top--]; // Returns the top element and decrements top
    }

    // Peek: Returns the top element without removing it
    public int peek() {
        if (isEmpty()) { // Checks if the stack is empty
            throw new IllegalStateException("The stack is empty.");
        }
        return stackArray[top];
    }

    // isEmpty: Checks if the stack is empty
    public boolean isEmpty() {
        return (top == -1);
    }

    // Size: Returns the number of elements in the stack
    public int size() {
        return top + 1;
    }
}

How It Works

  1. Initialization: The constructor initializes an array of size maxSize and sets top to -1, indicating an empty stack.
  2. Push Operation:
    • The method checks if the stack is not full by comparing top to maxSize - 1.
    • If there is space, it increments top and stores the new value in stackArray[top].
    • If the stack is full, it throws an exception.
  3. Pop Operation:
    • The method verifies that the stack is not empty using isEmpty().
    • It returns the element at stackArray[top] and decrements top.
    • If the stack is empty, it throws an exception.
  4. Peek Operation: The method returns the value at stackArray[top] without modifying top.
  5. isEmpty Operation: The method returns true if top equals -1, indicating an empty stack, and false otherwise.
  6. Size Operation: The method returns top + 1, which represents the current number of elements in the stack.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
PushO(1)O(1)
PopO(1)O(1)
PeekO(1)O(1)
isEmptyO(1)O(1)
SizeO(1)O(1)

Key Differences / Notes

  • Array-Based vs. Linked List-Based Stack:
    • The array-based stack, as implemented above, has a fixed size and offers O(1) access but is limited by its maximum size.
    • A linked list-based stack supports dynamic sizing but may have slightly slower operations due to node creation and deletion.
  • Thread Safety: The provided implementation is not thread-safe. For concurrent applications, consider using Java’s Stack class or ArrayDeque with proper synchronization.
  • Java’s Built-in Options: Java’s java.util.Stack class is available but is less efficient than ArrayDeque, which is recommended for stack implementations due to its performance.

✅ Tip: Always check if the stack is empty before performing pop or peek operations to avoid exceptions. If you need a stack with dynamic sizing, consider using Java’s ArrayDeque or implementing a linked list-based stack.

⚠ Warning: An array-based stack can overflow if you exceed its maxSize. Carefully estimate the required size for your application to prevent errors.

Exercises

  1. Reverse a String: Write a Java program that uses the stack implementation above to reverse a given string by pushing each character onto the stack and then popping them to form the reversed string.
  2. Parentheses Checker: Create a program that uses a stack to check if a string of parentheses (e.g., "{[()]}") is balanced. Push opening brackets onto the stack and pop them when a matching closing bracket is found.
  3. Stack Min Function: Extend the stack implementation to include a min() function that returns the minimum element in the stack in O(1) time. Hint: Use an additional stack to track minimums.
  4. Infix to Postfix Conversion: Write a program that converts an infix expression (e.g., A + B * C) to postfix (e.g., A B C * +) using a stack. Test it with at least three different expressions.
  5. Real-World Simulation: Simulate a browser’s back button functionality using the stack. Allow users to input a list of URLs to push onto the stack and print the current page each time the back button (pop) is pressed.

Queue Data Structure

Definition and Concepts

A queue is a linear data structure that operates on the First In, First Out (FIFO) principle. This means that the first element added to the queue is the first one to be removed. You can visualize a queue as a line of people waiting at a ticket counter, where the person who arrives first is served first. The primary operations of a queue include enqueue, which adds an element to the rear of the queue, and dequeue, which removes and returns the element at the front. Additional operations include peek, which views the front element without removing it, and isEmpty, which checks if the queue is empty. Queues are essential for managing data in a sequential, order-preserving manner.

Why Use It?

A queue is useful when you need to process data in the order it was received. It ensures fairness by maintaining the sequence of elements, making it ideal for tasks that require sequential processing or scheduling. Queues are efficient because they restrict operations to two ends: the front for removal and the rear for addition, simplifying data management.

Where to Use? (Real-Life Examples)

  • Task Scheduling in Operating Systems: Operating systems use queues to manage processes waiting for CPU time, ensuring each process is executed in the order it was submitted.
  • Print Job Management: Printers use queues to handle multiple print jobs, processing them in the order they were received.
  • Customer Service Systems: Call centers or ticket counters use queues to serve customers in the order of their arrival.
  • Breadth-First Search (BFS) in Graphs: Queues are used in algorithms like BFS to explore nodes level by level in a graph or tree.

Explain Operations

  • Enqueue: This operation adds an element to the rear of the queue. It has a time complexity of O(1).
  • Dequeue: This operation removes and returns the front element of the queue. If the queue is empty, it may throw an exception or return null. It has a time complexity of O(1).
  • Peek: This operation returns the front element without removing it. It has a time complexity of O(1).
  • isEmpty: This operation checks whether the queue is empty. It has a time complexity of O(1).
  • Size: This operation returns the number of elements currently in the queue. It has a time complexity of O(1).

Java Implementation

The following Java code implements a queue using an array with a fixed size.

public class Queue {
    private int maxSize; // Represents the maximum size of the queue
    private int[] queueArray; // Array to store the queue elements
    private int front; // Index of the front element
    private int rear; // Index where the next element will be added
    private int currentSize; // Number of elements in the queue

    // Constructor to initialize the queue with a given size
    public Queue(int size) {
        maxSize = size;
        queueArray = new int[size];
        front = 0; // Front starts at index 0
        rear = -1; // Rear starts before the first element
        currentSize = 0; // Queue is initially empty
    }

    // Enqueue: Adds an element to the rear of the queue
    public void enqueue(int value) {
        if (currentSize < maxSize) { // Checks if the queue is not full
            rear = (rear + 1) % maxSize; // Increments rear (circularly)
            queueArray[rear] = value; // Adds the value at rear
            currentSize++; // Increments the size
        } else {
            throw new IllegalStateException("The queue is full.");
        }
    }

    // Dequeue: Removes and returns the front element
    public int dequeue() {
        if (isEmpty()) { // Checks if the queue is empty
            throw new IllegalStateException("The queue is empty.");
        }
        int value = queueArray[front]; // Retrieves the front element
        front = (front + 1) % maxSize; // Moves front to the next element (circularly)
        currentSize--; // Decrements the size
        return value;
    }

    // Peek: Returns the front element without removing it
    public int peek() {
        if (isEmpty()) { // Checks if the queue is empty
            throw new IllegalStateException("The queue is empty.");
        }
        return queueArray[front];
    }

    // isEmpty: Checks if the queue is empty
    public boolean isEmpty() {
        return (currentSize == 0);
    }

    // Size: Returns the number of elements in the queue
    public int size() {
        return currentSize;
    }
}

How It Works

  1. Initialization: The constructor initializes an array of size maxSize, sets front to 0, rear to -1, and currentSize to 0, indicating an empty queue.
  2. Enqueue Operation:
    • The method checks if the queue is not full by comparing currentSize to maxSize.
    • It increments rear using modulo (% maxSize) to support a circular queue, adds the value to queueArray[rear], and increments currentSize.
    • If the queue is full, it throws an exception.
  3. Dequeue Operation:
    • The method verifies that the queue is not empty using isEmpty().
    • It retrieves the value at queueArray[front], moves front to the next position using modulo, decrements currentSize, and returns the value.
    • If the queue is empty, it throws an exception.
  4. Peek Operation: The method returns the value at queueArray[front] without modifying front or currentSize.
  5. isEmpty Operation: The method returns true if currentSize equals 0, indicating an empty queue, and false otherwise.
  6. Size Operation: The method returns currentSize, which represents the number of elements in the queue.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
EnqueueO(1)O(1)
DequeueO(1)O(1)
PeekO(1)O(1)
isEmptyO(1)O(1)
SizeO(1)O(1)

Key Differences / Notes

  • Array-Based vs. Linked List-Based Queue:
    • The array-based queue, as implemented above, has a fixed size and offers O(1) access but is limited by its maximum size.
    • A linked list-based queue supports dynamic sizing but may involve additional overhead for node creation and deletion.
  • Circular Queue: The implementation above uses a circular queue to optimize space usage, allowing the array to wrap around when rear or front reaches the end.
  • Thread Safety: The provided implementation is not thread-safe. For concurrent applications, consider using Java’s ConcurrentLinkedQueue or ArrayBlockingQueue.
  • Java’s Built-in Options: Java provides Queue as an interface in java.util, with implementations like LinkedList and ArrayDeque. The ArrayDeque is recommended for better performance in most cases.

✅ Tip: Use a circular queue, as shown in the implementation, to make efficient use of array space and avoid wasting memory when elements are dequeued.

⚠ Warning: An array-based queue can overflow if you exceed its maxSize. Ensure the queue size is sufficient for your application, or consider a linked list-based queue for dynamic sizing.

Exercises

  1. Print Job Simulator: Write a Java program that simulates a printer queue using the queue implementation above. Allow users to enqueue print jobs (represented as strings) and dequeue them in order, printing each job as it is processed.
  2. Queue Reversal: Create a program that reverses the elements of a queue using a stack. Enqueue a set of numbers, reverse them, and print the reversed queue.
  3. Circular Queue Test: Extend the queue implementation to handle edge cases, such as enqueueing and dequeuing in a loop, and test it with a sequence of operations to verify circular behavior.
  4. Queue-Based BFS: Implement a simple Breadth-First Search (BFS) algorithm for a graph using the queue. Represent the graph as an adjacency list and print the nodes visited in BFS order.
  5. Ticket Counter Simulation: Simulate a ticket counter system where customers (represented by names) join a queue and are served in order. Allow users to enqueue customers, dequeue them, and display the current queue size.

Priority Queue Data Structure

Definition and Concepts

A priority queue is an abstract data structure that operates similarly to a regular queue but assigns a priority to each element. Elements with higher priority are dequeued before those with lower priority, regardless of their order of insertion. You can visualize a priority queue as a hospital emergency room where patients with more severe conditions are treated first, even if they arrived later. The primary operations include enqueue, which adds an element with a specified priority, and dequeue, which removes and returns the element with the highest priority. Additional operations include peek, which views the highest-priority element without removing it, and isEmpty, which checks if the priority queue is empty. Priority queues are typically implemented using a heap (often a binary heap) to ensure efficient priority-based operations.

Why Use It?

A priority queue is useful when you need to process elements based on their priority rather than their arrival order. It ensures that the most critical or highest-priority elements are handled first, making it ideal for scheduling, resource allocation, and optimization problems. Priority queues are efficient because they use a heap structure, which provides logarithmic time complexity for most operations.

Where to Use? (Real-Life Examples)

  • Task Scheduling in Operating Systems: Operating systems use priority queues to schedule processes based on their priority levels, ensuring critical tasks are executed first.
  • Hospital Emergency Systems: Emergency rooms prioritize patients based on the severity of their condition, using a priority queue to manage treatment order.
  • Dijkstra’s Algorithm: Priority queues are used in graph algorithms like Dijkstra’s to select the next node with the smallest distance.
  • Huffman Coding: Priority queues are used to build optimal prefix codes for data compression by repeatedly selecting the two nodes with the smallest frequencies.

Explain Operations

  • Enqueue: This operation adds an element with a specified priority to the priority queue. The element is inserted into the heap, and the heap property is restored by bubbling up. It has a time complexity of O(log n).
  • Dequeue: This operation removes and returns the element with the highest priority (the root in a min-heap). The last element is moved to the root, and the heap property is restored by bubbling down. It has a time complexity of O(log n).
  • Peek: This operation returns the highest-priority element (the root) without removing it. It has a time complexity of O(1).
  • isEmpty: This operation checks whether the priority queue is empty. It has a time complexity of O(1).
  • Size: This operation returns the number of elements in the priority queue. It has a time complexity of O(1).

Java Implementation

The following Java code implements a priority queue using a binary min-heap, where smaller values have higher priority.

public class PriorityQueue {
    private int[] heap; // Array to store the heap elements
    private int maxSize; // Maximum size of the priority queue
    private int size; // Current number of elements in the priority queue

    // Constructor to initialize the priority queue with a given size
    public PriorityQueue(int capacity) {
        maxSize = capacity;
        heap = new int[maxSize];
        size = 0; // Priority queue is initially empty
    }

    // Enqueue: Adds an element to the priority queue
    public void enqueue(int value) {
        if (size >= maxSize) { // Checks if the priority queue is full
            throw new IllegalStateException("The priority queue is full.");
        }
        heap[size] = value; // Adds the new element at the end
        bubbleUp(size); // Restores heap property by bubbling up
        size++; // Increments the size
    }

    // Dequeue: Removes and returns the highest-priority element
    public int dequeue() {
        if (isEmpty()) { // Checks if the priority queue is empty
            throw new IllegalStateException("The priority queue is empty.");
        }
        int result = heap[0]; // Stores the root (highest-priority element)
        heap[0] = heap[size - 1]; // Moves the last element to the root
        size--; // Decrements the size
        if (size > 0) { // Avoids bubbling down if the heap is empty
            bubbleDown(0); // Restores heap property by bubbling down
        }
        return result;
    }

    // Peek: Returns the highest-priority element without removing it
    public int peek() {
        if (isEmpty()) { // Checks if the priority queue is empty
            throw new IllegalStateException("The priority queue is empty.");
        }
        return heap[0]; // Returns the root element
    }

    // isEmpty: Checks if the priority queue is empty
    public boolean isEmpty() {
        return size == 0;
    }

    // Size: Returns the number of elements in the priority queue
    public int size() {
        return size;
    }

    // Helper method to bubble up an element to restore min-heap property
    private void bubbleUp(int index) {
        int parent = (index - 1) / 2; // Calculates parent index
        while (index > 0 && heap[index] < heap[parent]) { // Compares with parent
            swap(index, parent); // Swaps if child is smaller
            index = parent; // Updates index to parent
            parent = (index - 1) / 2; // Recalculates parent
        }
    }

    // Helper method to bubble down an element to restore min-heap property
    private void bubbleDown(int index) {
        int minIndex = index; // Tracks the smallest element
        while (true) {
            int leftChild = 2 * index + 1; // Left child index
            int rightChild = 2 * index + 2; // Right child index
            if (leftChild < size && heap[leftChild] < heap[minIndex]) {
                minIndex = leftChild; // Updates if left child is smaller
            }
            if (rightChild < size && heap[rightChild] < heap[minIndex]) {
                minIndex = rightChild; // Updates if right child is smaller
            }
            if (minIndex == index) break; // Stops if no smaller child found
            swap(index, minIndex); // Swaps with the smaller child
            index = minIndex; // Updates index to continue bubbling down
        }
    }

    // Helper method to swap two elements in the heap
    private void swap(int i, int j) {
        int temp = heap[i];
        heap[i] = heap[j];
        heap[j] = temp;
    }
}

How It Works

  1. Initialization: The constructor initializes an array of size maxSize and sets size to 0, indicating an empty priority queue.
  2. Enqueue Operation:
    • The method checks if the priority queue is full by comparing size to maxSize.
    • It adds the new element at the end of the heap (heap[size]), calls bubbleUp to restore the min-heap property by moving the element up if it’s smaller than its parent, and increments size.
    • If the queue is full, it throws an exception.
  3. Dequeue Operation:
    • The method verifies that the priority queue is not empty using isEmpty().
    • It saves the root element (heap[0]), moves the last element (heap[size-1]) to the root, decrements size, and calls bubbleDown to restore the min-heap property by moving the root down if it’s larger than its children.
    • If the queue is empty, it throws an exception.
  4. Peek Operation: The method returns the root element (heap[0]) without modifying the heap.
  5. isEmpty Operation: The method returns true if size equals 0, indicating an empty priority queue, and false otherwise.
  6. Size Operation: The method returns size, which represents the number of elements in the priority queue.
  7. BubbleUp Helper: This method moves a newly added element up the heap by comparing it with its parent and swapping if it’s smaller, continuing until the heap property is restored.
  8. BubbleDown Helper: This method moves the root element down by comparing it with its children, swapping with the smaller child if necessary, and continuing until the heap property is restored.
  9. Swap Helper: This method exchanges two elements in the heap array.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
EnqueueO(log n)O(1)
DequeueO(log n)O(1)
PeekO(1)O(1)
isEmptyO(1)O(1)
SizeO(1)O(1)

Key Differences / Notes

  • Min-Heap vs. Max-Heap:
    • The implementation above uses a min-heap, where smaller values have higher priority. A max-heap can be implemented by reversing the comparison logic, where larger values have higher priority.
  • Array-Based vs. Other Implementations:
    • The array-based heap implementation is memory-efficient and provides O(log n) for enqueue and dequeue operations.
    • Other implementations, like a binary search tree or linked list, are less common due to higher complexity or overhead.
  • Thread Safety: The provided implementation is not thread-safe. For concurrent applications, use Java’s PriorityBlockingQueue or synchronize access.
  • Java’s Built-in Priority Queue: Java provides java.util.PriorityQueue, which is a min-heap by default but can be customized for max-heap behavior using a comparator.

✅ Tip: Use a min-heap for scenarios where the smallest element has the highest priority (e.g., shortest path algorithms) and a max-heap for scenarios where the largest element has priority (e.g., task prioritization).

⚠ Warning: Ensure the priority queue’s capacity is sufficient for your use case to avoid overflow errors in the array-based implementation. For dynamic sizing, consider Java’s PriorityQueue class.

Exercises

  1. Task Scheduler: Write a Java program that uses the priority queue implementation above to simulate a task scheduler. Enqueue tasks with priorities (represented as integers) and dequeue them in order of highest priority, printing each task as it’s processed.
  2. Kth Largest Element: Create a program that uses a priority queue to find the kth largest element in an array of integers. Test it with at least three different arrays and k values.
  3. Min-Heap to Max-Heap: Modify the priority queue implementation to support a max-heap (where larger values have higher priority). Test the modified version with enqueue and dequeue operations.
  4. Dijkstra’s Algorithm: Implement Dijkstra’s algorithm for a weighted graph using the priority queue. Represent the graph as an adjacency list and compute the shortest paths from a source node.
  5. Merge K Sorted Lists: Write a program that merges k sorted arrays into a single sorted array using a priority queue. Enqueue the first element of each array with its array index and value, and repeatedly dequeue and enqueue the next element from the same array.

Linked List Data Structure

Definition and Concepts

A linked list is a linear data structure consisting of a sequence of nodes, where each node contains data and a reference (or link) to the next node. Unlike arrays, linked lists do not store elements in contiguous memory locations, allowing dynamic memory allocation. In a singly linked list, each node points to the next node, with the last node pointing to null. You can visualize a linked list as a chain of boxes, where each box holds a value and an arrow pointing to the next box. Linked lists are flexible for insertions and deletions but require traversal to access elements, making them suitable for dynamic data.

Why Use It?

Linked lists are used when you need a dynamic data structure that can grow or shrink efficiently without requiring contiguous memory. They allow fast insertions and deletions at known positions (e.g., head or tail) compared to arrays. Linked lists are ideal for applications where the size of the list is unknown or changes frequently, and they serve as a foundation for other data structures like stacks and queues.

Where to Use? (Real-Life Examples)

  • Dynamic Data Storage: Linked lists are used in applications like music playlists, where songs can be added or removed dynamically.
  • Implementation of Stacks and Queues: Stacks and queues are often implemented using linked lists due to their flexibility in size.
  • Browser History: Web browsers use linked lists to maintain a history of visited pages, allowing easy navigation forward and backward.
  • Memory Management: Operating systems use linked lists to manage free memory blocks in dynamic memory allocation.

Explain Operations

  • Insert at Head: This operation adds a new node at the beginning of the list. It has a time complexity of O(1).
  • Insert at Tail: This operation adds a new node at the end of the list. It has a time complexity of O(n) due to traversal.
  • Delete at Head: This operation removes the first node. It has a time complexity of O(1).
  • Delete by Value: This operation removes the first node with a given value. It has a time complexity of O(n) due to traversal.
  • Search: This operation finds a node with a given value. It has a time complexity of O(n).
  • isEmpty: This operation checks whether the linked list is empty. It has a time complexity of O(1).
  • Size: This operation returns the number of nodes in the linked list. It has a time complexity of O(1) if maintained.

Java Implementation

The following Java code implements a singly linked list with basic operations.

public class LinkedList {
    private class Node {
        int value; // Value stored in the node
        Node next; // Reference to the next node

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    private Node head; // Head of the linked list
    private int size; // Number of nodes in the linked list

    // Constructor to initialize an empty linked list
    public LinkedList() {
        head = null;
        size = 0;
    }

    // Insert at Head: Adds a new node at the beginning
    public void insertAtHead(int value) {
        Node newNode = new Node(value); // Creates a new node
        newNode.next = head; // Points new node to current head
        head = newNode; // Updates head to new node
        size++; // Increments size
    }

    // Insert at Tail: Adds a new node at the end
    public void insertAtTail(int value) {
        Node newNode = new Node(value); // Creates a new node
        if (isEmpty()) { // If list is empty, sets head to new node
            head = newNode;
        } else {
            Node current = head;
            while (current.next != null) { // Traverses to the last node
                current = current.next;
            }
            current.next = newNode; // Adds new node at the end
        }
        size++; // Increments size
    }

    // Delete at Head: Removes the first node
    public void deleteAtHead() {
        if (isEmpty()) { // Checks if the list is empty
            throw new IllegalStateException("The linked list is empty.");
        }
        head = head.next; // Moves head to the next node
        size--; // Decrements size
    }

    // Delete by Value: Removes the first node with the given value
    public void deleteByValue(int value) {
        if (isEmpty()) { // Checks if the list is empty
            throw new IllegalStateException("The linked list is empty.");
        }
        if (head.value == value) { // If head has the value, removes head
            head = head.next;
            size--;
            return;
        }
        Node current = head;
        Node prev = null;
        while (current != null && current.value != value) { // Traverses to find the value
            prev = current;
            current = current.next;
        }
        if (current == null) { // If value not found
            throw new IllegalArgumentException("Value not found in the linked list.");
        }
        prev.next = current.next; // Bypasses the node to delete
        size--; // Decrements size
    }

    // Search: Checks if a value exists in the linked list
    public boolean search(int value) {
        Node current = head;
        while (current != null) { // Traverses the list
            if (current.value == value) {
                return true; // Returns true if value is found
            }
            current = current.next;
        }
        return false; // Returns false if value is not found
    }

    // isEmpty: Checks if the linked list is empty
    public boolean isEmpty() {
        return size == 0;
    }

    // Size: Returns the number of nodes in the linked list
    public int size() {
        return size;
    }
}

How It Works

  1. Initialization: The constructor initializes head as null and size as 0, indicating an empty linked list.
  2. Insert at Head:
    • The method creates a new node with the given value, sets its next to the current head, updates head to the new node, and increments size.
  3. Insert at Tail:
    • The method creates a new node. If the list is empty, it sets head to the new node. Otherwise, it traverses to the last node and sets its next to the new node, then increments size.
  4. Delete at Head:
    • The method checks if the list is empty. If not, it moves head to the next node and decrements size. If empty, it throws an exception.
  5. Delete by Value:
    • The method checks if the list is empty or if the head node has the value. If the head is the target, it updates head and decrements size.
    • Otherwise, it traverses the list to find the value, keeping track of the previous node, and bypasses the target node by updating the previous node’s next. It decrements size or throws an exception if the value is not found.
  6. Search Operation: The method traverses the list, returning true if the value is found and false if the end (null) is reached.
  7. isEmpty Operation: The method returns true if size equals 0, indicating an empty list, and false otherwise.
  8. Size Operation: The method returns size, which tracks the number of nodes.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Insert at HeadO(1)O(1)
Insert at TailO(n)O(1)
Delete at HeadO(1)O(1)
Delete by ValueO(n)O(1)
SearchO(n)O(1)
isEmptyO(1)O(1)
SizeO(1)O(1)

Note: n is the number of nodes in the linked list.

Key Differences / Notes

  • Singly vs. Doubly Linked List:
    • The implementation above is a singly linked list, where each node points to the next. It is memory-efficient but slow for tail operations (O(n)).
    • A doubly linked list has nodes with pointers to both the next and previous nodes, enabling O(1) tail deletions but using more memory.
  • Linked List vs. Array:
    • Linked lists support dynamic sizing and efficient insertions/deletions at the head, but random access and tail operations are O(n).
    • Arrays offer O(1) random access but require contiguous memory and have O(n) insertions/deletions in the middle.
  • Thread Safety: The implementation is not thread-safe. For concurrent applications, use Java’s ConcurrentLinkedDeque or synchronize access.
  • Java’s Built-in Linked List: Java provides LinkedList in java.util, which is a doubly linked list supporting both List and Deque interfaces.

✅ Tip: Use a linked list when frequent insertions or deletions at the head are needed, or when the size of the list is unknown. For tail operations, consider a doubly linked list or maintain a tail pointer.

⚠ Warning: Avoid using a singly linked list for applications requiring frequent access to the tail or random positions, as traversal is O(n). Use arrays or other structures for such cases.

Exercises

  1. Reverse a Linked List: Write a Java program that reverses the linked list using the implementation above. Test it with lists of varying sizes.
  2. Cycle Detection: Extend the linked list implementation to detect if it contains a cycle (e.g., using Floyd’s Tortoise and Hare algorithm). Test with cyclic and acyclic lists.
  3. Merge Two Sorted Lists: Create a program that merges two sorted linked lists into a single sorted linked list. Use the implementation above and test with different inputs.
  4. Middle Element Finder: Implement a method to find the middle element of the linked list in a single pass. Use the fast-and-slow pointer technique and test it.
  5. Playlist Manager: Simulate a music playlist using the linked list. Allow users to add songs (insert at head or tail), remove songs (delete by value), and print the playlist.

Doubly Linked List Data Structure

Definition and Concepts

A doubly linked list is a linear data structure consisting of a sequence of nodes, where each node contains data, a reference to the next node, and a reference to the previous node. Unlike a singly linked list, which only allows traversal in one direction, a doubly linked list enables bidirectional traversal, making operations like deletion and insertion at both ends more efficient. The first node (head) has a null previous reference, and the last node (tail) has a null next reference. You can visualize a doubly linked list as a chain of boxes, where each box holds a value and arrows point to both the next and previous boxes. This structure is ideal for applications requiring frequent insertions, deletions, or reverse traversal.

Why Use It?

A doubly linked list is used when you need a dynamic data structure that supports efficient insertions and deletions at both the head and tail, as well as bidirectional traversal. It provides more flexibility than a singly linked list by allowing navigation in both directions, which is useful for applications like undo/redo functionality or browsing history. Although it uses more memory due to the additional previous pointers, it offers O(1) time complexity for operations at the head and tail, making it suitable for dynamic datasets.

Where to Use? (Real-Life Examples)

  • Undo/Redo Functionality: Text editors use doubly linked lists to track actions, allowing users to move forward (redo) or backward (undo) through the action history.
  • Browser Navigation: Web browsers use doubly linked lists to manage browsing history, enabling users to navigate back and forward between visited pages.
  • Music or Video Playlists: Media players use doubly linked lists to manage playlists, allowing users to move to the next or previous song efficiently.
  • Deque Implementation: Doubly linked lists are used to implement double-ended queues (deques), supporting insertions and deletions at both ends.

Explain Operations

  • Insert at Head: This operation adds a new node at the beginning of the list. It has a time complexity of O(1).
  • Insert at Tail: This operation adds a new node at the end of the list. It has a time complexity of O(1) if a tail pointer is maintained.
  • Delete at Head: This operation removes the first node. It has a time complexity of O(1).
  • Delete at Tail: This operation removes the last node. It has a time complexity of O(1) if a tail pointer is maintained.
  • Search: This operation finds a node with a given value. It has a time complexity of O(n).
  • isEmpty: This operation checks whether the doubly linked list is empty. It has a time complexity of O(1).
  • Size: This operation returns the number of nodes in the doubly linked list. It has a time complexity of O(1) if maintained.

Java Implementation

The following Java code implements a doubly linked list with basic operations, maintaining both head and tail pointers for efficiency.

public class DoublyLinkedList {
    private class Node {
        int value; // Value stored in the node
        Node next; // Reference to the next node
        Node prev; // Reference to the previous node

        Node(int value) {
            this.value = value;
            this.next = null;
            this.prev = null;
        }
    }

    private Node head; // Head of the doubly linked list
    private Node tail; // Tail of the doubly linked list
    private int size; // Number of nodes in the doubly linked list

    // Constructor to initialize an empty doubly linked list
    public DoublyLinkedList() {
        head = null;
        tail = null;
        size = 0;
    }

    // Insert at Head: Adds a new node at the beginning
    public void insertAtHead(int value) {
        Node newNode = new Node(value); // Creates a new node
        if (isEmpty()) { // If list is empty, sets both head and tail
            head = newNode;
            tail = newNode;
        } else {
            newNode.next = head; // Points new node to current head
            head.prev = newNode; // Points current head back to new node
            head = newNode; // Updates head to new node
        }
        size++; // Increments size
    }

    // Insert at Tail: Adds a new node at the end
    public void insertAtTail(int value) {
        Node newNode = new Node(value); // Creates a new node
        if (isEmpty()) { // If list is empty, sets both head and tail
            head = newNode;
            tail = newNode;
        } else {
            tail.next = newNode; // Points current tail to new node
            newNode.prev = tail; // Points new node back to current tail
            tail = newNode; // Updates tail to new node
        }
        size++; // Increments size
    }

    // Delete at Head: Removes the first node
    public void deleteAtHead() {
        if (isEmpty()) { // Checks if the list is empty
            throw new IllegalStateException("The doubly linked list is empty.");
        }
        head = head.next; // Moves head to the next node
        if (head == null) { // If list becomes empty, updates tail
            tail = null;
        } else {
            head.prev = null; // Sets new head's previous to null
        }
        size--; // Decrements size
    }

    // Delete at Tail: Removes the last node
    public void deleteAtTail() {
        if (isEmpty()) { // Checks if the list is empty
            throw new IllegalStateException("The doubly linked list is empty.");
        }
        tail = tail.prev; // Moves tail to the previous node
        if (tail == null) { // If list becomes empty, updates head
            head = null;
        } else {
            tail.next = null; // Sets new tail's next to null
        }
        size--; // Decrements size
    }

    // Search: Checks if a value exists in the doubly linked list
    public boolean search(int value) {
        Node current = head;
        while (current != null) { // Traverses the list
            if (current.value == value) {
                return true; // Returns true if value is found
            }
            current = current.next;
        }
        return false; // Returns false if value is not found
    }

    // isEmpty: Checks if the doubly linked list is empty
    public boolean isEmpty() {
        return size == 0;
    }

    // Size: Returns the number of nodes in the doubly linked list
    public int size() {
        return size;
    }
}

How It Works

  1. Initialization: The constructor initializes head and tail as null and size as 0, indicating an empty doubly linked list.
  2. Insert at Head:
    • The method creates a new node with the given value.
    • If the list is empty, it sets both head and tail to the new node.
    • Otherwise, it links the new node to the current head, updates the head’s previous pointer, sets head to the new node, and increments size.
  3. Insert at Tail:
    • The method creates a new node.
    • If the list is empty, it sets both head and tail to the new node.
    • Otherwise, it links the current tail to the new node, sets the new node’s previous pointer to the tail, updates tail to the new node, and increments size.
  4. Delete at Head:
    • The method checks if the list is empty. If not, it moves head to the next node, sets the new head’s previous pointer to null (if not null), updates tail if the list becomes empty, and decrements size. If empty, it throws an exception.
  5. Delete at Tail:
    • The method checks if the list is empty. If not, it moves tail to the previous node, sets the new tail’s next pointer to null (if not null), updates head if the list becomes empty, and decrements size. If empty, it throws an exception.
  6. Search Operation: The method traverses the list from head to tail, returning true if the value is found and false if the end (null) is reached.
  7. isEmpty Operation: The method returns true if size equals 0, indicating an empty list, and false otherwise.
  8. Size Operation: The method returns size, which tracks the number of nodes.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Insert at HeadO(1)O(1)
Insert at TailO(1)O(1)
Delete at HeadO(1)O(1)
Delete at TailO(1)O(1)
SearchO(n)O(1)
isEmptyO(1)O(1)
SizeO(1)O(1)

Note: n is the number of nodes in the doubly linked list.

Key Differences / Notes

  • Doubly vs. Singly Linked List:
    • The implementation above is a doubly linked list, which allows O(1) insertions and deletions at both head and tail due to the tail pointer and bidirectional links.
    • A singly linked list only has next pointers, making tail operations O(n) unless a tail pointer is maintained, but it uses less memory.
  • Doubly Linked List vs. Array:
    • Doubly linked lists support dynamic sizing and O(1) operations at both ends but have O(n) access and search times.
    • Arrays offer O(1) random access but require contiguous memory and have O(n) insertions/deletions in the middle.
  • Thread Safety: The implementation is not thread-safe. For concurrent applications, use Java’s ConcurrentLinkedDeque or synchronize access.
  • Java’s Built-in Doubly Linked List: Java provides LinkedList in java.util, which is a doubly linked list implementing both List and Deque interfaces.

✅ Tip: Use a doubly linked list when you need efficient insertions and deletions at both ends or bidirectional traversal, such as in navigation systems or deques.

⚠ Warning: Doubly linked lists use more memory than singly linked lists due to the additional previous pointers. Ensure memory constraints are considered for large datasets.

Exercises

  1. Reverse a Doubly Linked List: Write a Java program that reverses the doubly linked list using the implementation above. Test it with lists of varying sizes.
  2. Bidirectional Traversal: Extend the implementation to include methods for printing the list in both forward (head to tail) and backward (tail to head) directions. Test with a sample list.
  3. Insert After Value: Add a method to insert a new node after the first occurrence of a given value. Test it with cases where the value exists and does not exist.
  4. Deque Implementation: Use the doubly linked list to implement a double-ended queue (deque) with methods for adding and removing elements at both ends. Test with a sequence of operations.
  5. Browser History Simulator: Create a program that simulates a browser’s navigation history using the doubly linked list. Allow users to add pages (insert at tail), navigate back (delete at tail), and navigate forward (re-insert).

Circular Linked List Data Structure

Definition and Concepts

A circular linked list is a linear data structure where nodes are arranged in a sequence, and the last node points back to the first node, forming a loop. In a singly circular linked list, each node contains data and a reference to the next node, with the last node’s next pointer linking to the head node instead of null. Unlike a standard singly linked list, which has a definite end, a circular linked list allows continuous traversal. You can visualize a circular linked list as a ring of boxes, where each box holds a value and an arrow points to the next box, with the last box pointing back to the first. This structure is ideal for applications requiring cyclic or round-robin processing.

Why Use It?

A circular linked list is used when you need a data structure that supports continuous looping through elements, such as in round-robin scheduling or cyclic data processing. It eliminates the need to reset traversal to the beginning after reaching the end, making it efficient for repetitive tasks. The circular nature simplifies certain operations, like rotating the list, and it can be used as a foundation for structures like circular buffers.

Where to Use? (Real-Life Examples)

  • Round-Robin Scheduling: Operating systems use circular linked lists to manage processes in a round-robin fashion, cycling through tasks to allocate CPU time.
  • Music Playlist Looping: Music players use circular linked lists to implement a looping playlist, where playback restarts from the first song after the last one.
  • Circular Buffers: Network applications use circular linked lists to implement buffers for streaming data, reusing space in a fixed-size structure.
  • Multiplayer Game Turns: Board games or turn-based applications use circular linked lists to cycle through players’ turns indefinitely.

Explain Operations

  • Insert at Head: This operation adds a new node at the beginning of the list, updating the last node’s pointer to the new head. It has a time complexity of O(1) if a tail pointer is maintained.
  • Insert at Tail: This operation adds a new node at the end of the list, updating the last node’s pointer to the new node and linking it to the head. It has a time complexity of O(1) if a tail pointer is maintained.
  • Delete at Head: This operation removes the first node, updating the last node’s pointer to the new head. It has a time complexity of O(1) if a tail pointer is maintained.
  • Delete by Value: This operation removes the first node with a given value. It has a time complexity of O(n) due to traversal.
  • Search: This operation finds a node with a given value. It has a time complexity of O(n).
  • isEmpty: This operation checks whether the circular linked list is empty. It has a time complexity of O(1).
  • Size: This operation returns the number of nodes in the circular linked list. It has a time complexity of O(1) if maintained.

Java Implementation

The following Java code implements a singly circular linked list with basic operations, maintaining a tail pointer for efficiency.

public class CircularLinkedList {
    private class Node {
        int value; // Value stored in the node
        Node next; // Reference to the next node

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    private Node tail; // Tail of the circular linked list
    private int size; // Number of nodes in the circular linked list

    // Constructor to initialize an empty circular linked list
    public CircularLinkedList() {
        tail = null;
        size = 0;
    }

    // Insert at Head: Adds a new node at the beginning
    public void insertAtHead(int value) {
        Node newNode = new Node(value); // Creates a new node
        if (isEmpty()) { // If list is empty, sets tail to new node and links to itself
            tail = newNode;
            newNode.next = newNode;
        } else {
            newNode.next = tail.next; // Points new node to current head
            tail.next = newNode; // Points tail to new node (new head)
        }
        size++; // Increments size
    }

    // Insert at Tail: Adds a new node at the end
    public void insertAtTail(int value) {
        Node newNode = new Node(value); // Creates a new node
        if (isEmpty()) { // If list is empty, sets tail to new node and links to itself
            tail = newNode;
            newNode.next = newNode;
        } else {
            newNode.next = tail.next; // Points new node to current head
            tail.next = newNode; // Points current tail to new node
            tail = newNode; // Updates tail to new node
        }
        size++; // Increments size
    }

    // Delete at Head: Removes the first node
    public void deleteAtHead() {
        if (isEmpty()) { // Checks if the list is empty
            throw new IllegalStateException("The circular linked list is empty.");
        }
        if (size == 1) { // If only one node, clears the list
            tail = null;
        } else {
            tail.next = tail.next.next; // Points tail to the second node (new head)
        }
        size--; // Decrements size
    }

    // Delete by Value: Removes the first node with the given value
    public void deleteByValue(int value) {
        if (isEmpty()) { // Checks if the list is empty
            throw new IllegalStateException("The circular linked list is empty.");
        }
        Node current = tail.next; // Starts at head
        Node prev = tail; // Previous node for deletion
        do {
            if (current.value == value) { // If value is found
                if (size == 1) { // If only one node, clears the list
                    tail = null;
                } else if (current == tail.next) { // If head node
                    tail.next = current.next;
                } else if (current == tail) { // If tail node
                    prev.next = tail.next;
                    tail = prev;
                } else { // If middle node
                    prev.next = current.next;
                }
                size--; // Decrements size
                return;
            }
            prev = current;
            current = current.next;
        } while (current != tail.next); // Loops until back to head
        throw new IllegalArgumentException("Value not found in the circular linked list.");
    }

    // Search: Checks if a value exists in the circular linked list
    public boolean search(int value) {
        if (isEmpty()) { // Checks if the list is empty
            return false;
        }
        Node current = tail.next; // Starts at head
        do {
            if (current.value == value) { // Returns true if value is found
                return true;
            }
            current = current.next;
        } while (current != tail.next); // Loops until back to head
        return false; // Returns false if value is not found
    }

    // isEmpty: Checks if the circular linked list is empty
    public boolean isEmpty() {
        return size == 0;
    }

    // Size: Returns the number of nodes in the circular linked list
    public int size() {
        return size;
    }
}

How It Works

  1. Initialization: The constructor initializes tail as null and size as 0, indicating an empty circular linked list.
  2. Insert at Head:
    • The method creates a new node with the given value.
    • If the list is empty, it sets tail to the new node and links it to itself.
    • Otherwise, it sets the new node’s next to the current head (tail.next), updates tail.next to the new node, and increments size.
  3. Insert at Tail:
    • The method creates a new node.
    • If the list is empty, it sets tail to the new node and links it to itself.
    • Otherwise, it sets the new node’s next to the current head, updates tail.next to the new node, sets tail to the new node, and increments size.
  4. Delete at Head:
    • The method checks if the list is empty. If not, it updates tail.next to the second node (new head) if multiple nodes exist, or sets tail to null if only one node, then decrements size. If empty, it throws an exception.
  5. Delete by Value:
    • The method traverses from the head (tail.next) to find the value, keeping track of the previous node.
    • If found, it handles three cases: single node (clears the list), head node (updates tail.next), tail node (updates tail and prev.next), or middle node (updates prev.next). It decrements size.
    • If the value is not found after a full loop, it throws an exception.
  6. Search Operation: The method traverses from the head, returning true if the value is found, or false after a full loop to the head.
  7. isEmpty Operation: The method returns true if size equals 0, indicating an empty list, and false otherwise.
  8. Size Operation: The method returns size, which tracks the number of nodes.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Insert at HeadO(1)O(1)
Insert at TailO(1)O(1)
Delete at HeadO(1)O(1)
Delete by ValueO(n)O(1)
SearchO(n)O(1)
isEmptyO(1)O(1)
SizeO(1)O(1)

Note: n is the number of nodes in the circular linked list. O(1) operations assume a tail pointer is maintained.

Key Differences / Notes

  • Circular vs. Singly Linked List:
    • The implementation above is a singly circular linked list, where the last node points to the head, enabling continuous traversal. A singly linked list ends with a null pointer, requiring traversal to restart from the head.
    • Circular linked lists are more complex to manage due to the loop, but they simplify cyclic operations.
  • Circular vs. Doubly Circular Linked List:
    • A doubly circular linked list includes previous pointers, allowing bidirectional traversal, but uses more memory.
    • The singly circular linked list is simpler and more memory-efficient but only supports forward traversal.
  • Thread Safety: The implementation is not thread-safe. For concurrent applications, use Java’s ConcurrentLinkedDeque or synchronize access.
  • Java’s Built-in Support: Java’s LinkedList can be adapted for circular behavior, but no direct circular linked list class is provided. Libraries like Apache Commons Collections offer circular list implementations.

✅ Tip: Use a circular linked list when your application requires continuous cycling through elements, such as in scheduling or looping playlists. Maintaining a tail pointer simplifies head and tail operations to O(1).

⚠ Warning: Be cautious when traversing a circular linked list to avoid infinite loops. Always use a condition (e.g., checking for the head node) to terminate traversal.

Exercises

  1. Rotate the List: Write a Java program that rotates the circular linked list by k positions (e.g., move the head k nodes forward). Test with different values of k and list sizes.
  2. Split Circular List: Implement a method to split a circular linked list into two circular linked lists of roughly equal size. Test with even and odd-sized lists.
  3. Josephus Problem: Solve the Josephus problem using the circular linked list, where every k-th person in a circle is eliminated until one remains. Test with different k values.
  4. Insert After Value: Add a method to insert a new node after the first occurrence of a given value in the circular linked list. Test with cases where the value exists and does not exist.
  5. Round-Robin Scheduler: Create a program that simulates a round-robin scheduler using the circular linked list. Allow users to add tasks (nodes), cycle through them, and remove completed tasks.

Hashing Data Structure

Definition and Concepts

Hashing is a technique used to map data of arbitrary size to fixed-size values, typically for efficient storage and retrieval in a data structure called a hash table. A hash table uses a hash function to compute an index (or hash code) for each key, which determines where the key-value pair is stored in an array. The goal is to enable fast access to data using keys, such as strings or numbers. You can visualize a hash table as a set of labeled drawers, where each label (key) quickly directs you to the corresponding drawer (value). Collisions, where multiple keys map to the same index, are resolved using techniques like separate chaining (using linked lists) or open addressing (using probing). Hashing is fundamental for applications requiring quick lookups, insertions, and deletions.

Why Use It?

Hashing is used to achieve fast data retrieval, insertion, and deletion with an average time complexity of O(1). It is ideal for scenarios where you need to associate keys with values and access them efficiently without iterating through the entire dataset. Hash tables are versatile and widely used in databases, caches, and other systems requiring rapid data access.

Where to Use? (Real-Life Examples)

  • Database Indexing: Databases use hashing to index records, allowing quick retrieval of data based on keys like user IDs.
  • Caches: Web browsers and content delivery networks use hash tables to cache web pages, mapping URLs to cached content for fast access.
  • Symbol Tables in Compilers: Compilers use hash tables to store variable names and their attributes during code compilation.
  • Password Storage: Systems store hashed passwords to verify user credentials quickly without storing plain text.

Explain Operations

  • Insert: This operation adds a key-value pair to the hash table. The hash function computes an index for the key, and the pair is stored at that index (or in a linked list if a collision occurs). It has an average time complexity of O(1).
  • Lookup: This operation retrieves the value associated with a given key by computing its hash and checking the corresponding index. It has an average time complexity of O(1).
  • Delete: This operation removes a key-value pair by finding the key’s index and removing it from the bucket (or linked list). It has an average time complexity of O(1).
  • isEmpty: This operation checks whether the hash table is empty. It has a time complexity of O(1).
  • Size: This operation returns the number of key-value pairs in the hash table. It has a time complexity of O(1).

Java Implementation

The following Java code implements a hash table using separate chaining (linked lists for collision resolution).

public class HashTable {
    private static class Node {
        String key; // Key for the hash table entry
        int value; // Value associated with the key
        Node next; // Reference to the next node in case of collision

        Node(String key, int value) {
            this.key = key;
            this.value = value;
            this.next = null;
        }
    }

    private Node[] buckets; // Array of linked lists for storing key-value pairs
    private int capacity; // Size of the buckets array
    private int size; // Number of key-value pairs in the hash table

    // Constructor to initialize the hash table with a given capacity
    public HashTable(int capacity) {
        this.capacity = capacity;
        this.buckets = new Node[capacity];
        this.size = 0;
    }

    // Hash function to compute index for a key
    private int hash(String key) {
        return Math.abs(key.hashCode() % capacity); // Ensures non-negative index
    }

    // Insert: Adds a key-value pair to the hash table
    public void insert(String key, int value) {
        int index = hash(key); // Computes the index for the key
        if (buckets[index] == null) { // If bucket is empty, create new node
            buckets[index] = new Node(key, value);
            size++;
            return;
        }
        Node current = buckets[index];
        while (current != null) { // Traverses the linked list
            if (current.key.equals(key)) { // Updates value if key exists
                current.value = value;
                return;
            }
            if (current.next == null) break; // Reaches the end of the list
            current = current.next;
        }
        current.next = new Node(key, value); // Adds new node at the end
        size++;
    }

    // Lookup: Retrieves the value for a given key
    public int lookup(String key) {
        int index = hash(key); // Computes the index for the key
        Node current = buckets[index];
        while (current != null) { // Traverses the linked list
            if (current.key.equals(key)) {
                return current.value; // Returns value if key is found
            }
            current = current.next;
        }
        throw new IllegalArgumentException("Key not found in the hash table.");
    }

    // Delete: Removes a key-value pair from the hash table
    public void delete(String key) {
        int index = hash(key); // Computes the index for the key
        Node current = buckets[index];
        Node prev = null;
        while (current != null) { // Traverses the linked list
            if (current.key.equals(key)) {
                if (prev == null) { // If key is at the head
                    buckets[index] = current.next;
                } else {
                    prev.next = current.next; // Bypasses the node
                }
                size--;
                return;
            }
            prev = current;
            current = current.next;
        }
        throw new IllegalArgumentException("Key not found in the hash table.");
    }

    // isEmpty: Checks if the hash table is empty
    public boolean isEmpty() {
        return size == 0;
    }

    // Size: Returns the number of key-value pairs in the hash table
    public int size() {
        return size;
    }
}

How It Works

  1. Initialization: The constructor initializes an array of Node objects (buckets) with a given capacity and sets size to 0, indicating an empty hash table.
  2. Hash Function: The hash method computes an index by applying Java’s hashCode to the key and using modulo capacity to fit within the array bounds, ensuring a non-negative index.
  3. Insert Operation:
    • The method computes the index for the key using the hash function.
    • If the bucket at that index is empty, it creates a new Node with the key-value pair.
    • If the bucket contains a linked list, it traverses to check if the key exists (updating the value if found) or appends a new node at the end.
    • It increments size for new entries.
  4. Lookup Operation:
    • The method computes the index for the key and traverses the linked list at that index.
    • If the key is found, it returns the associated value; otherwise, it throws an exception.
  5. Delete Operation:
    • The method computes the index and traverses the linked list to find the key.
    • If found, it removes the node by updating pointers (either setting the bucket to the next node or bypassing the node) and decrements size.
    • If the key is not found, it throws an exception.
  6. isEmpty Operation: The method returns true if size equals 0, indicating an empty hash table, and false otherwise.
  7. Size Operation: The method returns size, which represents the number of key-value pairs.

Complexity Analysis Table

OperationAverage Time ComplexityWorst-Case Time ComplexitySpace Complexity
InsertO(1)O(n)O(1)
LookupO(1)O(n)O(1)
DeleteO(1)O(n)O(1)
isEmptyO(1)O(1)O(1)
SizeO(1)O(1)O(1)

Note: Worst-case time complexity occurs when many keys hash to the same index, causing long linked lists.

Key Differences / Notes

  • Separate Chaining vs. Open Addressing:
    • The implementation above uses separate chaining (linked lists) to handle collisions, which is simpler and handles high load factors well.
    • Open addressing resolves collisions by probing for the next available slot, which can be more memory-efficient but requires careful handling of deletions.
  • Load Factor: The load factor (size/capacity) affects performance. A high load factor increases collisions, slowing operations. Resizing the hash table (doubling capacity) can maintain efficiency.
  • Thread Safety: The provided implementation is not thread-safe. For concurrent applications, use Java’s ConcurrentHashMap or synchronize access.
  • Java’s Built-in Hash Table: Java provides HashMap and Hashtable in java.util. HashMap is preferred for non-thread-safe applications, while ConcurrentHashMap is used for thread safety.

✅ Tip: Choose a good hash function to minimize collisions, as this directly impacts performance. Java’s hashCode is a reasonable default, but custom hash functions may be needed for specific key types.

⚠ Warning: A poorly designed hash function or a small table size can lead to frequent collisions, degrading performance to O(n) in the worst case. Monitor the load factor and resize the table if necessary.

Exercises

  1. Word Frequency Counter: Write a Java program that uses the hash table implementation above to count the frequency of words in a given text. Use words as keys and their counts as values, then print the results.
  2. Phone Book Application: Create a program that implements a phone book using the hash table. Allow users to insert, lookup, and delete contacts (name as key, phone number as value).
  3. Collision Analysis: Modify the hash table to track the number of collisions (multiple keys mapping to the same index). Test it with a set of keys and report the collision count.
  4. Two Sum Problem: Implement a solution to the Two Sum problem (find two numbers in an array that add up to a target sum) using the hash table. Return the indices of the two numbers.
  5. Custom Hash Function: Create a custom hash function for a specific key type (e.g., strings with a fixed format) and integrate it into the hash table implementation. Compare its performance with Java’s default hashCode.

Tree Data Structure

Definition and Concepts

A tree is a hierarchical data structure consisting of nodes connected by edges, with a single root node at the top and no cycles. Each node can have zero or more child nodes, and every node except the root has exactly one parent. In a Binary Search Tree (BST), which is a common type of tree, each node has at most two children (left and right), and the left subtree contains values less than the node’s value, while the right subtree contains values greater. Trees are used to represent hierarchical relationships and enable efficient searching, insertion, and deletion. You can visualize a tree as a family tree, where each person (node) has descendants (children) and an ancestor (parent).

Why Use It?

Trees are used to organize data hierarchically, enabling efficient operations like searching, insertion, and deletion with an average time complexity of O(log n) in a balanced BST. They are ideal for applications requiring ordered data, dynamic updates, or hierarchical structures. Trees provide a natural way to represent relationships, such as organizational charts or file systems, and are foundational for advanced data structures like heaps and tries.

Where to Use? (Real-Life Examples)

  • File Systems: Operating systems use trees to represent directory structures, where folders are nodes and subfolders or files are children.
  • Database Indexing: Databases use trees (e.g., B-trees or BSTs) to index data, enabling fast searches and range queries.
  • Expression Parsing: Compilers use expression trees to represent mathematical expressions, such as (2 + 3) * 4, for evaluation.
  • Autocomplete Systems: Search engines use trie (a type of tree) to store words for efficient prefix-based suggestions.

Explain Operations

  • Insert: This operation adds a new node with a given value to the tree while maintaining the BST property. It has an average time complexity of O(log n) in a balanced tree.
  • Search: This operation finds a node with a given value by traversing left or right based on comparisons. It has an average time complexity of O(log n).
  • Delete: This operation removes a node with a given value, adjusting the tree to maintain the BST property. It has an average time complexity of O(log n).
  • Inorder Traversal: This operation visits nodes in sorted order (left, root, right). It has a time complexity of O(n).
  • isEmpty: This operation checks whether the tree is empty. It has a time complexity of O(1).

Java Implementation

The following Java code implements a Binary Search Tree with basic operations.

public class BinarySearchTree {
    private class Node {
        int value; // Value stored in the node
        Node left; // Reference to the left child
        Node right; // Reference to the right child

        Node(int value) {
            this.value = value;
            this.left = null;
            this.right = null;
        }
    }

    private Node root; // Root of the BST
    private int size; // Number of nodes in the tree

    // Constructor to initialize an empty BST
    public BinarySearchTree() {
        root = null;
        size = 0;
    }

    // Insert: Adds a new value to the BST
    public void insert(int value) {
        root = insertRec(root, value); // Recursively inserts the value
        size++; // Increments the size
    }

    private Node insertRec(Node root, int value) {
        if (root == null) { // If the subtree is empty, create a new node
            return new Node(value);
        }
        if (value < root.value) { // Inserts into left subtree if value is smaller
            root.left = insertRec(root.left, value);
        } else if (value > root.value) { // Inserts into right subtree if value is larger
            root.right = insertRec(root.right, value);
        }
        return root; // Returns unchanged root if value already exists
    }

    // Search: Checks if a value exists in the BST
    public boolean search(int value) {
        return searchRec(root, value); // Recursively searches for the value
    }

    private boolean searchRec(Node root, int value) {
        if (root == null || root.value == value) { // Returns true if found, false if null
            return root != null;
        }
        if (value < root.value) { // Searches left subtree if value is smaller
            return searchRec(root.left, value);
        }
        return searchRec(root.right, value); // Searches right subtree if value is larger
    }

    // Delete: Removes a value from the BST
    public void delete(int value) {
        root = deleteRec(root, value); // Recursively deletes the value
        size--; // Decrements the size
    }

    private Node deleteRec(Node root, int value) {
        if (root == null) { // If value not found, return null
            return null;
        }
        if (value < root.value) { // Deletes from left subtree if value is smaller
            root.left = deleteRec(root.left, value);
        } else if (value > root.value) { // Deletes from right subtree if value is larger
            root.right = deleteRec(root.right, value);
        } else { // Node to delete found
            if (root.left == null) { // Case 1: No left child, return right child
                return root.right;
            } else if (root.right == null) { // Case 2: No right child, return left child
                return root.left;
            }
            // Case 3: Two children, replace with successor
            root.value = findMin(root.right).value; // Replaces with minimum in right subtree
            root.right = deleteRec(root.right, root.value); // Deletes the successor
        }
        return root;
    }

    // Helper method to find the minimum value node in a subtree
    private Node findMin(Node root) {
        while (root.left != null) {
            root = root.left;
        }
        return root;
    }

    // Inorder Traversal: Prints nodes in sorted order
    public void inorder() {
        inorderRec(root); // Recursively traverses the tree
        System.out.println(); // Adds newline after traversal
    }

    private void inorderRec(Node root) {
        if (root != null) {
            inorderRec(root.left); // Visits left subtree
            System.out.print(root.value + " "); // Visits current node
            inorderRec(root.right); // Visits right subtree
        }
    }

    // isEmpty: Checks if the BST is empty
    public boolean isEmpty() {
        return size == 0;
    }

    // Size: Returns the number of nodes in the BST
    public int size() {
        return size;
    }
}

How It Works

  1. Initialization: The constructor initializes the root as null and size as 0, indicating an empty Binary Search Tree.
  2. Insert Operation:
    • The method recursively traverses the tree based on the value: left if the value is smaller, right if larger.
    • If an empty spot is found (null node), it creates a new node with the value.
    • The size is incremented for each new node.
  3. Search Operation:
    • The method recursively traverses the tree, comparing the target value with the current node’s value.
    • It returns true if the value is found or false if a null node is reached.
  4. Delete Operation:
    • The method finds the node to delete by traversing left or right based on the value.
    • It handles three cases: no children (return null), one child (return the child), or two children (replace with the minimum value from the right subtree and delete that node).
    • The size is decremented after deletion.
  5. Inorder Traversal: The method recursively visits the left subtree, the current node, and the right subtree, printing values in sorted order.
  6. isEmpty Operation: The method returns true if size equals 0, indicating an empty tree, and false otherwise.
  7. Size Operation: The method returns size, which represents the number of nodes in the tree.
  8. FindMin Helper: This method finds the smallest value in a subtree by traversing left until a null left child is reached.

Complexity Analysis Table

OperationAverage Time ComplexityWorst-Case Time ComplexitySpace Complexity
InsertO(log n)O(n)O(log n)
SearchO(log n)O(n)O(log n)
DeleteO(log n)O(n)O(log n)
Inorder TraversalO(n)O(n)O(log n)
isEmptyO(1)O(1)O(1)
SizeO(1)O(1)O(1)

Note: Worst-case time complexity occurs in an unbalanced tree (e.g., a skewed tree resembling a linked list).

Key Differences / Notes

  • Binary Search Tree vs. Other Trees:
    • The implementation above is a BST, where nodes are ordered for efficient searching. Other trees include binary trees (no ordering), AVL trees (self-balancing), and B-trees (for databases).
    • AVL and Red-Black trees maintain balance to ensure O(log n) operations, unlike a BST, which can degrade to O(n) if unbalanced.
  • Balancing: The provided BST is not self-balancing, which can lead to poor performance if insertions are not random. Use AVL or Red-Black trees for guaranteed balance.
  • Thread Safety: The implementation is not thread-safe. For concurrent applications, use synchronized methods or Java’s ConcurrentSkipListMap for a tree-like structure.
  • Java’s Built-in Trees: Java provides TreeMap and TreeSet in java.util, which are implemented as Red-Black trees for balanced performance.

✅ Tip: Use a self-balancing tree like AVL or Red-Black for applications requiring consistent O(log n) performance, especially with frequent insertions or deletions.

⚠ Warning: An unbalanced BST can degrade to O(n) performance if insertions occur in sorted order, resembling a linked list. Always consider input patterns when using a BST.

Exercises

  1. BST Validator: Write a Java program that checks if a given tree is a valid Binary Search Tree by ensuring all nodes follow the BST property. Test it with at least three different trees.
  2. Height of BST: Extend the BST implementation to include a method that computes the height of the tree. Test it with balanced and unbalanced trees.
  3. Range Sum Query: Create a program that uses the BST to find the sum of all values within a given range (e.g., between 20 and 60). Use inorder traversal to collect values.
  4. Preorder and Postorder Traversals: Add methods to the BST implementation for preorder (root, left, right) and postorder (left, right, root) traversals. Print the results for a sample tree.
  5. Lowest Common Ancestor: Implement a method to find the lowest common ancestor of two nodes in the BST. Test it with different pairs of values.

Graph Data Structure

Definition and Concepts

A graph is a non-linear data structure consisting of nodes (also called vertices) connected by edges. Graphs can represent relationships between entities, where nodes represent entities and edges represent connections. Graphs can be directed (edges have direction, like one-way roads) or undirected (edges are bidirectional, like friendships). They can also be weighted (edges have values, like distances) or unweighted. A common representation is an adjacency list, where each node stores a list of its adjacent nodes. You can visualize a graph as a network of cities (nodes) connected by roads (edges). Graphs are versatile and used to model complex relationships in various applications.

Why Use It?

Graphs are used to model and analyze relationships between entities, enabling solutions to problems like finding shortest paths, detecting cycles, or determining connectivity. They provide a flexible framework for representing networks, making them essential for applications in computer science, social networks, and logistics. Graphs support efficient algorithms for traversal, pathfinding, and optimization, with time complexities depending on the representation and algorithm used.

Where to Use? (Real-Life Examples)

  • Social Networks: Social media platforms use graphs to represent users (nodes) and friendships or follows (edges) to recommend connections or analyze communities.
  • Navigation Systems: GPS applications use graphs to model road networks, with cities as nodes and roads as weighted edges, to compute shortest paths.
  • Internet Routing: The internet uses graphs to represent routers and connections, enabling efficient data packet routing.
  • Dependency Management: Build tools like Maven use graphs to manage dependencies between software modules, detecting cyclic dependencies.

Explain Operations

  • Add Vertex: This operation adds a new node to the graph. It has a time complexity of O(1) in an adjacency list.
  • Add Edge: This operation adds a connection between two nodes. It has a time complexity of O(1) for an undirected graph using an adjacency list.
  • Remove Vertex: This operation removes a node and all its edges. It has a time complexity of O(V + E), where V is the number of vertices and E is the number of edges.
  • Remove Edge: This operation removes a connection between two nodes. It has a time complexity of O(E) in the worst case for an adjacency list.
  • Depth-First Search (DFS): This operation traverses the graph by exploring as far as possible along each branch before backtracking. It has a time complexity of O(V + E).
  • Breadth-First Search (BFS): This operation traverses the graph level by level, visiting all neighbors of a node before moving to the next level. It has a time complexity of O(V + E).

Java Implementation

The following Java code implements an undirected graph using an adjacency list, with basic operations and DFS/BFS traversals.

import java.util.*;

public class Graph {
    private Map<Integer, List<Integer>> adjList; // Adjacency list to store the graph
    private int vertexCount; // Number of vertices in the graph

    // Constructor to initialize an empty graph
    public Graph() {
        adjList = new HashMap<>();
        vertexCount = 0;
    }

    // Add Vertex: Adds a new vertex to the graph
    public void addVertex(int vertex) {
        if (!adjList.containsKey(vertex)) { // Checks if vertex doesn't already exist
            adjList.put(vertex, new ArrayList<>());
            vertexCount++;
        }
    }

    // Add Edge: Adds an undirected edge between two vertices
    public void addEdge(int vertex1, int vertex2) {
        if (!adjList.containsKey(vertex1) || !adjList.containsKey(vertex2)) {
            throw new IllegalArgumentException("Both vertices must exist in the graph.");
        }
        adjList.get(vertex1).add(vertex2); // Adds vertex2 to vertex1's list
        adjList.get(vertex2).add(vertex1); // Adds vertex1 to vertex2's list (undirected)
    }

    // Remove Vertex: Removes a vertex and all its edges
    public void removeVertex(int vertex) {
        if (!adjList.containsKey(vertex)) {
            throw new IllegalArgumentException("Vertex not found in the graph.");
        }
        List<Integer> neighbors = adjList.get(vertex);
        for (int neighbor : neighbors) { // Removes vertex from neighbors' lists
            adjList.get(neighbor).remove(Integer.valueOf(vertex));
        }
        adjList.remove(vertex); // Removes the vertex
        vertexCount--;
    }

    // Remove Edge: Removes an undirected edge between two vertices
    public void removeEdge(int vertex1, int vertex2) {
        if (!adjList.containsKey(vertex1) || !adjList.containsKey(vertex2)) {
            throw new IllegalArgumentException("Both vertices must exist in the graph.");
        }
        adjList.get(vertex1).remove(Integer.valueOf(vertex2)); // Removes vertex2 from vertex1's list
        adjList.get(vertex2).remove(Integer.valueOf(vertex1)); // Removes vertex1 from vertex2's list
    }

    // Depth-First Search: Traverses the graph starting from a vertex
    public List<Integer> dfs(int startVertex) {
        if (!adjList.containsKey(startVertex)) {
            throw new IllegalArgumentException("Start vertex not found in the graph.");
        }
        List<Integer> result = new ArrayList<>();
        Set<Integer> visited = new HashSet<>();
        dfsRec(startVertex, visited, result);
        return result;
    }

    private void dfsRec(int vertex, Set<Integer> visited, List<Integer> result) {
        visited.add(vertex); // Marks the vertex as visited
        result.add(vertex); // Adds vertex to result
        for (int neighbor : adjList.get(vertex)) { // Visits unvisited neighbors
            if (!visited.contains(neighbor)) {
                dfsRec(neighbor, visited, result);
            }
        }
    }

    // Breadth-First Search: Traverses the graph starting from a vertex
    public List<Integer> bfs(int startVertex) {
        if (!adjList.containsKey(startVertex)) {
            throw new IllegalArgumentException("Start vertex not found in the graph.");
        }
        List<Integer> result = new ArrayList<>();
        Set<Integer> visited = new HashSet<>();
        Queue<Integer> queue = new LinkedList<>();
        visited.add(startVertex);
        queue.offer(startVertex);
        while (!queue.isEmpty()) {
            int vertex = queue.poll(); // Dequeues a vertex
            result.add(vertex); // Adds vertex to result
            for (int neighbor : adjList.get(vertex)) { // Enqueues unvisited neighbors
                if (!visited.contains(neighbor)) {
                    visited.add(neighbor);
                    queue.offer(neighbor);
                }
            }
        }
        return result;
    }

    // isEmpty: Checks if the graph is empty
    public boolean isEmpty() {
        return vertexCount == 0;
    }

    // Size: Returns the number of vertices in the graph
    public int size() {
        return vertexCount;
    }
}

How It Works

  1. Initialization: The constructor initializes an empty HashMap for the adjacency list and sets vertexCount to 0, indicating an empty graph.
  2. Add Vertex: The method adds a new vertex to the adjList with an empty list of neighbors if it doesn’t already exist, incrementing vertexCount.
  3. Add Edge: The method adds an undirected edge by appending each vertex to the other’s neighbor list in the adjList, ensuring both vertices exist.
  4. Remove Vertex: The method removes the vertex’s neighbor list and removes the vertex from all its neighbors’ lists, then removes the vertex from adjList and decrements vertexCount.
  5. Remove Edge: The method removes each vertex from the other’s neighbor list in the adjList, ensuring both vertices exist.
  6. Depth-First Search (DFS): The method uses recursion to explore as far as possible along each branch, marking vertices as visited and adding them to the result list.
  7. Breadth-First Search (BFS): The method uses a queue to explore nodes level by level, marking vertices as visited, adding them to the result, and enqueuing unvisited neighbors.
  8. isEmpty Operation: The method returns true if vertexCount equals 0, indicating an empty graph, and false otherwise.
  9. Size Operation: The method returns vertexCount, which represents the number of vertices in the graph.

Complexity Analysis Table

OperationTime Complexity (Adjacency List)Space Complexity
Add VertexO(1)O(1)
Add EdgeO(1)O(1)
Remove VertexO(V + E)O(1)
Remove EdgeO(E)O(1)
DFSO(V + E)O(V)
BFSO(V + E)O(V)
isEmptyO(1)O(1)
SizeO(1)O(1)

Note: V is the number of vertices, and E is the number of edges. Time complexities assume an adjacency list representation.

Key Differences / Notes

  • Adjacency List vs. Adjacency Matrix:
    • The implementation above uses an adjacency list, which is space-efficient (O(V + E)) and suitable for sparse graphs.
    • An adjacency matrix uses O(V²) space and is better for dense graphs or when checking edge existence is frequent.
  • Directed vs. Undirected Graphs:
    • The implementation is for an undirected graph. For a directed graph, only add the edge to the source vertex’s neighbor list.
  • Weighted Graphs: The implementation is unweighted. For weighted graphs, store edge weights in the adjacency list (e.g., as a Map<Integer, Integer> for weights).
  • Thread Safety: The implementation is not thread-safe. For concurrent applications, use Java’s ConcurrentHashMap for the adjacency list or synchronize access.
  • Java’s Built-in Support: Java does not provide a direct graph class, but libraries like JGraphT or Guava’s Graph can be used for advanced graph operations.

✅ Tip: Use an adjacency list for sparse graphs to save memory, and an adjacency matrix for dense graphs to optimize edge lookups.

⚠ Warning: Ensure the graph is properly initialized with all vertices before adding edges, as missing vertices can cause errors in adjacency list operations.

Exercises

  1. Graph Connectivity: Write a Java program that uses the graph implementation to check if a graph is connected (all vertices reachable from a starting vertex) using DFS or BFS.
  2. Cycle Detection: Extend the graph implementation to detect if the graph contains a cycle using DFS. Test it with cyclic and acyclic graphs.
  3. Shortest Path (Unweighted): Implement a method to find the shortest path between two vertices in an unweighted graph using BFS. Return the path as a list of vertices.
  4. Weighted Graph Extension: Modify the graph implementation to support weighted edges (store weights in the adjacency list). Test it by adding and retrieving edge weights.
  5. Social Network Simulation: Create a program that simulates a social network using the graph. Allow users to add users (vertices), friendships (edges), and perform DFS to find mutual friends.

Sorting Algorithms

What are Sorting Algorithms?

Sorting algorithms are methods for arranging elements of a list in a specific order, typically ascending or descending, to make data easier to search, process, or display. They are the building blocks of efficient data processing, used in everything from simple list sorting to complex database operations.

Why Learn Sorting Algorithms?

  • Efficiency: Choosing the right sorting algorithm can significantly improve program performance.
  • Problem Solving: Many real-world problems, like ranking or organizing data, rely on sorting.
  • Foundation for Advanced Algorithms: Understanding sorting is essential for mastering more complex algorithms.
  • Interview Readiness: Sorting algorithms are a common topic in technical interviews.

📖 What You Will Learn in This Chapter

This chapter introduces you to the most commonly used sorting algorithms, their uses, advantages, disadvantages, and Java implementations.

1. Bubble Sort

  • What is Bubble Sort and how it works.
  • Comparing and swapping adjacent elements.
  • Limitations for large datasets.
  • Real-life example: Sorting a small list of exam scores.

2. Selection Sort

  • How Selection Sort selects the smallest element.
  • Building a sorted portion incrementally.
  • Minimizing swaps for efficiency.
  • Real-life example: Arranging books by height on a shelf.

3. Insertion Sort

  • How Insertion Sort builds a sorted portion by inserting elements.
  • Efficiency for nearly sorted data.
  • Common operations like shifting elements.
  • Real-life example: Sorting a hand of playing cards.

4. Merge Sort

  • How Merge Sort divides and conquers.
  • Merging sorted subarrays for efficiency.
  • Handling large datasets and stability.
  • Real-life example: Sorting large customer records in a database.

🛠 How We Will Learn

For each sorting algorithm, we will cover:

  1. Definition – What it is and how it works.
  2. Why – Advantages and limitations of the algorithm.
  3. Where to Use – Real-life scenarios where the algorithm shines.
  4. Java Implementation – With step-by-step explanations.
  5. Complexity Analysis – Understanding performance trade-offs.

Bubble Sort

Definition and Concepts

Bubble Sort is a simple sorting algorithm that repeatedly iterates through a list, compares adjacent elements, and swaps them if they are in the wrong order, until the list is fully sorted. The algorithm gets its name because larger elements "bubble up" to the end of the list in each iteration, similar to bubbles rising in water. In Java, Bubble Sort is typically implemented on arrays due to their direct index-based access, which makes comparisons and swaps efficient. You can visualize Bubble Sort as a process where, in each pass, the largest unsorted element moves to its correct position at the end of the array. Bubble Sort is a stable sorting algorithm, meaning it preserves the relative order of equal elements, and it is in-place, requiring minimal extra memory.

Key Points:

  • Bubble Sort compares adjacent elements and swaps them if they are out of order (e.g., for ascending order, if the first element is larger than the second).
  • Each pass through the array places the next largest element in its final position.
  • The algorithm terminates early if no swaps are needed in a pass, indicating the list is sorted.
  • Bubble Sort is easy to implement but inefficient for large datasets due to its quadratic time complexity.

Why Use It?

Bubble Sort is used primarily for educational purposes because its logic is straightforward and easy for beginners to understand, making it an excellent introduction to sorting algorithms. It is suitable for small datasets or lists that are already nearly sorted, where its simplicity and minimal memory usage are advantageous. Bubble Sort requires only a constant amount of extra space, making it appropriate for environments with memory constraints. However, for large or complex datasets, more efficient algorithms like Quick Sort or Merge Sort are recommended.

Where to Use? (Real-Life Examples)

  • Educational Tools: Bubble Sort is used in teaching environments to demonstrate sorting concepts due to its simple comparison and swap mechanism.
  • Small Data Sorting: Embedded systems with limited resources use Bubble Sort to sort small lists, such as a few sensor readings, where simplicity outweighs performance concerns.
  • Nearly Sorted Data: Applications like real-time data feeds use Bubble Sort for nearly sorted lists, as it performs efficiently with few swaps in such cases.
  • Prototyping: Developers use Bubble Sort in early-stage prototypes to quickly sort small datasets for testing, leveraging its ease of implementation.

Explain Operations

  • Comparison: This operation compares two adjacent elements to determine if they are in the correct order (e.g., for ascending order, the first element should be less than or equal to the second). It has a time complexity of O(1).
  • Swap: This operation exchanges two adjacent elements if they are out of order. It has a time complexity of O(1).
  • Pass: This operation involves one iteration through the unsorted portion of the array, performing comparisons and swaps as needed. It has a time complexity of O(n) for n elements in the worst case.
  • Optimization Check: This operation uses a flag to track whether any swaps occurred in a pass. If no swaps are needed, the array is sorted, and the algorithm terminates early. It has a time complexity of O(1) per pass.

Java Implementation

The following Java code implements Bubble Sort for an array of integers, including an optimization to terminate early if the array is already sorted.

public class BubbleSort {
    // Bubble Sort: Sorts an array in ascending order
    public void bubbleSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return; // No sorting needed for null or single-element arrays
        }
        int n = arr.length;
        for (int i = 0; i < n - 1; i++) { // Iterate n-1 times
            boolean swapped = false; // Tracks if swaps occurred in this pass
            for (int j = 0; j < n - 1 - i; j++) { // Compare adjacent elements
                if (arr[j] > arr[j + 1]) { // If out of order, swap
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swapped = true;
                }
            }
            if (!swapped) { // If no swaps, array is sorted
                break;
            }
        }
    }
}

How It Works

  1. Check Input:
    • The bubbleSort method checks if the input array is null or has one or fewer elements. If so, it returns immediately, as no sorting is needed.
  2. Outer Loop (Passes):
    • The outer loop iterates n-1 times, where n is the array length, as each pass places one element in its final position.
    • For example, in an array of 5 elements, 4 passes are sufficient to sort the array.
  3. Inner Loop (Comparisons and Swaps):
    • The inner loop iterates through the unsorted portion of the array (from index 0 to n-1-i), comparing adjacent elements (arr[j] and arr[j+1]).
    • If arr[j] > arr[j+1], the elements are swapped using a temporary variable.
    • For example, in the array [5, 3, 8, 1, 9], the first pass compares and swaps 5 and 3, then 8 and 1, resulting in [3, 5, 1, 8, 9].
  4. Optimization Check:
    • A swapped flag tracks whether any swaps occurred in a pass. If no swaps are needed, the array is sorted, and the algorithm terminates early.
    • For example, if the array is already sorted like [1, 3, 5, 8, 9], the algorithm stops after one pass with no swaps.
  5. Result:
    • After all passes, the array is sorted in ascending order. For example, [5, 3, 8, 1, 9] becomes [1, 3, 5, 8, 9].

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ComparisonO(1)O(1)
SwapO(1)O(1)
PassO(n)O(1)
Full AlgorithmO(n) (best)O(1)
Full AlgorithmO(n²) (average/worst)O(1)

Note:

  • n is the number of elements in the array.
  • Best case (O(n)) occurs when the array is already sorted, requiring one pass with no swaps.
  • Average and worst cases (O(n²)) involve up to n passes, each with up to n comparisons/swaps.
  • Space complexity is O(1) as Bubble Sort is in-place, using only a few variables.

Key Differences / Notes

  • Bubble Sort vs. Other Sorting Algorithms:
    • Bubble Sort is simpler but less efficient than Quick Sort (O(n log n) average) or Merge Sort (O(n log n)), which are preferred for large datasets.
    • It is stable, preserving the relative order of equal elements, unlike Quick Sort.
    • It is in-place, unlike Merge Sort, which requires O(n) extra space.
  • Optimization:
    • The swapped flag allows early termination for nearly sorted arrays, reducing the number of passes.
  • Suitability for Data Structures:
    • Bubble Sort is most efficient for arrays due to O(1) access and swap operations.
    • It is less practical for linked lists or other structures due to inefficient access patterns, which is why this implementation focuses on arrays.
  • Limitations:
    • Bubble Sort is rarely used in production code due to its O(n²) time complexity, but it is valuable for teaching and small-scale applications.

✅ Tip: Use Bubble Sort for small arrays or nearly sorted data to take advantage of its simplicity and early termination optimization. Test your implementation with edge cases like empty, single-element, or already sorted arrays to ensure robustness.

⚠ Warning: Avoid using Bubble Sort for large datasets due to its O(n²) time complexity, which performs poorly compared to faster algorithms like Quick Sort or Merge Sort. Always validate array bounds to prevent ArrayIndexOutOfBoundsException.

Exercises

  1. Basic Bubble Sort: Implement Bubble Sort for an array of integers and test it with various inputs (e.g., unsorted, sorted, reversed, duplicates). Count the number of swaps performed.
  2. Descending Order: Modify the Bubble Sort implementation to sort an array in descending order. Test with different array sizes and contents.
  3. Performance Analysis: Write a program that measures the execution time of Bubble Sort for arrays of increasing sizes (e.g., 10, 100, 1000 elements). Compare best, average, and worst cases.
  4. Flag Optimization: Implement Bubble Sort without the swapped flag and compare its performance with the optimized version for nearly sorted arrays.
  5. Edge Case Handling: Enhance the Bubble Sort implementation to handle arrays with negative numbers and floating-point numbers. Test with diverse inputs.

Selection Sort

Definition and Concepts

Selection Sort is a simple, in-place sorting algorithm that divides an array into a sorted and an unsorted portion. In each iteration, the algorithm selects the smallest (or largest, for descending order) element from the unsorted portion and places it at the beginning of the sorted portion. You can visualize Selection Sort as repeatedly finding the smallest element in the remaining unsorted section of the array and swapping it with the first element of that section. In Java, Selection Sort is typically implemented on arrays due to their direct index-based access, which facilitates efficient comparisons and swaps. Selection Sort is not stable, meaning it may change the relative order of equal elements, but it is in-place, requiring minimal extra memory.

Key Points:

  • Selection Sort selects the minimum element from the unsorted portion and places it at the start of the sorted portion.
  • Each iteration reduces the size of the unsorted portion by one, growing the sorted portion.
  • The algorithm is simple to implement but inefficient for large datasets due to its quadratic time complexity.
  • It performs a fixed number of swaps, which can be advantageous in scenarios where swaps are costly.

Why Use It?

Selection Sort is used primarily for educational purposes because its logic is intuitive and easy for beginners to grasp, making it a good introduction to sorting algorithms alongside Bubble Sort. It is suitable for small datasets where simplicity is prioritized over performance. Selection Sort minimizes the number of swaps compared to other quadratic algorithms like Bubble Sort, making it useful when write operations are expensive (e.g., in certain memory systems). However, for large datasets, more efficient algorithms like Quick Sort or Merge Sort are preferred.

Where to Use? (Real-Life Examples)

  • Teaching Sorting: Selection Sort is used in classrooms to teach sorting concepts due to its straightforward mechanism of selecting the minimum element.
  • Small Data Sorting: Embedded systems with limited resources use Selection Sort to sort small lists, such as configuration settings, where simplicity is key.
  • Write-Constrained Environments: Applications with costly write operations, such as flash memory, use Selection Sort to minimize swaps.
  • Prototyping: Developers use Selection Sort in prototypes to quickly sort small datasets for testing, leveraging its ease of implementation.

Explain Operations

  • Find Minimum: This operation scans the unsorted portion of the array to find the index of the smallest element. It has a time complexity of O(n) for n elements in the unsorted portion.
  • Swap: This operation exchanges the minimum element with the first element of the unsorted portion. It has a time complexity of O(1).
  • Iteration: This operation represents one pass through the array, finding the minimum and performing one swap. It has a time complexity of O(n) due to the minimum search.
  • Full Algorithm: This operation runs n-1 iterations to sort the entire array, where n is the array length. It has a time complexity of O(n²).

Java Implementation

The following Java code implements Selection Sort for an array of integers, designed to be clear and beginner-friendly.

public class SelectionSort {
    // Selection Sort: Sorts an array in ascending order
    public void selectionSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return; // No sorting needed for null or single-element arrays
        }
        int n = arr.length;
        for (int i = 0; i < n - 1; i++) { // Iterate over unsorted portion
            int minIndex = i; // Assume first element is minimum
            for (int j = i + 1; j < n; j++) { // Search for minimum in unsorted portion
                if (arr[j] < arr[minIndex]) {
                    minIndex = j; // Update minimum index
                }
            }
            if (minIndex != i) { // Swap if a smaller element was found
                int temp = arr[i];
                arr[i] = arr[minIndex];
                arr[minIndex] = temp;
            }
        }
    }
}

How It Works

  1. Check Input:
    • The selectionSort method checks if the input array is null or has one or fewer elements. If so, it returns immediately, as no sorting is needed.
  2. Outer Loop (Iterations):
    • The outer loop iterates n-1 times, where n is the array length, as each iteration places one element in its final position, growing the sorted portion.
    • For example, in an array of 5 elements, 4 iterations are sufficient to sort the array.
  3. Inner Loop (Find Minimum):
    • The inner loop scans the unsorted portion (from index i+1 to n-1) to find the index of the smallest element, updating minIndex when a smaller element is found.
    • For example, in the array [5, 3, 8, 1, 9], the first iteration finds the minimum 1 at index 3.
  4. Swap:
    • If the minimum element is not already at index i, the algorithm swaps the element at minIndex with the element at index i using a temporary variable.
    • For example, swapping 5 (at index 0) with 1 (at index 3) results in [1, 3, 8, 5, 9].
  5. Result:
    • After all iterations, the array is sorted in ascending order. For example, [5, 3, 8, 1, 9] becomes [1, 3, 5, 8, 9].

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Find MinimumO(n)O(1)
SwapO(1)O(1)
IterationO(n)O(1)
Full AlgorithmO(n²) (best/average/worst)O(1)

Note:

  • n is the number of elements in the array.
  • The time complexity is O(n²) for all cases (best, average, worst) because the algorithm always performs n-1 iterations, each scanning up to n elements to find the minimum.
  • Space complexity is O(1) as Selection Sort is in-place, using only a few variables for indexing and swapping.

Key Differences / Notes

  • Selection Sort vs. Bubble Sort:
    • Selection Sort minimizes swaps by performing one swap per iteration, while Bubble Sort may perform multiple swaps per pass.
    • Both have O(n²) time complexity, but Selection Sort is less adaptive to nearly sorted data, as it always scans the entire unsorted portion.
  • Selection Sort vs. Other Sorting Algorithms:
    • Selection Sort is less efficient than Quick Sort (O(n log n) average) or Merge Sort (O(n log n)), which are preferred for large datasets.
    • It is not stable, unlike Bubble Sort or Merge Sort, as it may swap equal elements out of order.
  • Suitability for Data Structures:
    • Selection Sort is most efficient for arrays due to O(1) access and swap operations.
    • It is less practical for linked lists or other structures due to inefficient access patterns, which is why this implementation focuses on arrays.
  • Fixed Swaps:
    • Selection Sort performs at most n-1 swaps, making it advantageous when swaps are more costly than comparisons.

✅ Tip: Use Selection Sort for small arrays or when minimizing swaps is important, such as in systems where write operations are expensive. Test the implementation with edge cases like empty, single-element, or duplicate-filled arrays to ensure correctness.

⚠ Warning: Avoid using Selection Sort for large datasets due to its O(n²) time complexity, which is inefficient compared to algorithms like Quick Sort or Merge Sort. Always validate array bounds to prevent ArrayIndexOutOfBoundsException.

Exercises

  1. Basic Selection Sort: Implement Selection Sort for an array of integers and test it with various inputs (e.g., unsorted, sorted, reversed, duplicates). Count the number of swaps performed.
  2. Descending Order: Modify the Selection Sort implementation to sort an array in descending order by selecting the maximum element instead. Test with different array sizes.
  3. Performance Analysis: Write a program that measures the execution time of Selection Sort for arrays of increasing sizes (e.g., 10, 100, 1000 elements). Compare performance across different input cases.
  4. String Array Sorting: Extend the Selection Sort implementation to sort an array of strings lexicographically. Test with strings of varying lengths and cases.
  5. Minimum Swap Count: Enhance the Selection Sort implementation to track and return the number of swaps performed. Test with nearly sorted and fully unsorted arrays.

Insertion Sort

Definition and Concepts

Insertion Sort is a simple sorting algorithm that builds a sorted array one element at a time by taking each element from the unsorted portion and inserting it into its correct position in the sorted portion. The algorithm maintains a sorted subarray at the beginning, which grows with each iteration as elements are inserted in their proper order. You can visualize Insertion Sort as sorting a hand of playing cards, where you pick one card at a time and place it in the correct position among the already-sorted cards. In Java, Insertion Sort is typically implemented on arrays due to their direct index-based access, which facilitates shifting elements during insertion. Insertion Sort is a stable algorithm, preserving the relative order of equal elements, and it is in-place, requiring minimal extra memory.

Key Points:
  • Insertion Sort divides the array into a sorted and an unsorted portion.
  • Each iteration takes an element from the unsorted portion and inserts it into the sorted portion.
  • The algorithm is efficient for small or nearly sorted datasets due to its adaptive nature.
  • It is easy to implement and understand, making it ideal for beginners.

Why Use It?

Insertion Sort is used for its simplicity and efficiency in sorting small datasets or nearly sorted arrays, where it performs well due to its adaptive nature. The algorithm requires minimal extra memory, as it sorts in-place, making it suitable for memory-constrained environments. Insertion Sort is particularly effective when the data is already partially sorted, as it can terminate early for each element once its position is found. It is also valuable for educational purposes, helping students understand sorting through its intuitive insertion process. For large datasets, however, more efficient algorithms like Quick Sort or Merge Sort are recommended.

Where to Use? (Real-Life Examples)

  • Teaching Sorting Algorithms: Insertion Sort is used in classrooms to teach sorting concepts due to its intuitive approach of building a sorted portion incrementally.
  • Small Data Sorting: Embedded systems with limited resources use Insertion Sort to sort small lists, such as sensor data, where simplicity and low memory usage are critical.
  • Nearly Sorted Data: Applications like online leaderboards use Insertion Sort to maintain nearly sorted lists, as it efficiently handles small updates with few shifts.
  • Incremental Data Processing: Real-time systems processing incoming data streams use Insertion Sort to insert new elements into a sorted list, such as in event logging.

Explain Operations

  • Selection: This operation selects the next element from the unsorted portion to be inserted into the sorted portion. It has a time complexity of O(1).
  • Comparison and Shift: This operation compares the selected element with elements in the sorted portion, shifting larger elements to the right until the correct position is found. It has a time complexity of O(n) in the worst case for n elements in the sorted portion.
  • Insertion: This operation places the selected element in its correct position after shifting. It has a time complexity of O(1).
  • Full Algorithm: This operation involves iterating through the array, performing selection, comparison, and insertion for each element. It has a time complexity of O(n²) in the worst and average cases.

Java Implementation

The following Java code implements Insertion Sort for an array of integers.

public class InsertionSort {
    // Insertion Sort: Sorts an array in ascending order
    public void insertionSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return; // No sorting needed for null or single-element arrays
        }
        int n = arr.length;
        for (int i = 1; i < n; i++) { // Start from second element
            int key = arr[i]; // Select element to insert
            int j = i - 1; // Last index of sorted portion
            while (j >= 0 && arr[j] > key) { // Shift larger elements
                arr[j + 1] = arr[j];
                j--;
            }
            arr[j + 1] = key; // Insert key in correct position
        }
    }
}

How It Works

  1. Check Input:
    • The insertionSort method checks if the input array is null or has one or fewer elements. If so, it returns immediately, as no sorting is needed.
  2. Outer Loop (Selection):
    • The outer loop iterates from the second element (index 1) to the last element (index n-1), selecting each element (key) from the unsorted portion.
    • For example, in the array [5, 3, 8, 1, 9], the first iteration selects key = 3.
  3. Inner Loop (Comparison and Shift):
    • The inner loop compares the key with elements in the sorted portion (from index i-1 backward to 0), shifting larger elements one position to the right.
    • For example, when inserting 3, the element 5 is shifted to index 1, resulting in [5, 5, 8, 1, 9].
  4. Insertion:
    • After the inner loop finds the correct position (when arr[j] <= key or j < 0), the key is placed at index j+1.
    • For example, 3 is inserted at index 0, resulting in [3, 5, 8, 1, 9].
  5. Result:
    • After all iterations, the array is sorted in ascending order. For example, [5, 3, 8, 1, 9] becomes [1, 3, 5, 8, 9].

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
SelectionO(1)O(1)
Comparison and ShiftO(n)O(1)
InsertionO(1)O(1)
Full AlgorithmO(n) (best)O(1)
Full AlgorithmO(n²) (average/worst)O(1)

Note:

  • n is the number of elements in the array.
  • Best case (O(n)) occurs when the array is already sorted, requiring minimal comparisons and no shifts.
  • Average and worst cases (O(n²)) involve up to n iterations, each with up to n comparisons and shifts.
  • Space complexity is O(1), as Insertion Sort is in-place, using only a few variables.

Key Differences / Notes

  • Insertion Sort vs. Bubble Sort:
    • Insertion Sort is more efficient for nearly sorted arrays, as it requires fewer shifts than Bubble Sort’s multiple swaps per pass.
    • Both have O(n²) worst-case time complexity, but Insertion Sort is adaptive, performing better on partially sorted data.
  • Insertion Sort vs. Selection Sort:
    • Insertion Sort is stable, preserving the order of equal elements, while Selection Sort is not.
    • Insertion Sort performs better for nearly sorted arrays, while Selection Sort performs a fixed number of comparisons regardless of input order.
  • Insertion Sort vs. Other Sorting Algorithms:
    • Insertion Sort is less efficient than Quick Sort (O(n log n) average) or Merge Sort (O(n log n)) for large datasets but excels in small or nearly sorted cases.
    • It is in-place, unlike Merge Sort, which requires O(n) extra space.
  • Suitability for Data Structures:
    • Insertion Sort is most efficient for arrays due to O(1) access and shift operations. It is less practical for linked lists (though feasible) due to traversal costs.

✅ Tip: Use Insertion Sort for small arrays or nearly sorted data to leverage its adaptive nature and minimal memory usage. Test with edge cases like empty, single-element, or already sorted arrays to ensure robustness.

⚠ Warning: Avoid using Insertion Sort for large datasets due to its O(n²) time complexity in average and worst cases, which is inefficient compared to Quick Sort or Merge Sort. Always validate array bounds to prevent ArrayIndexOutOfBoundsException.

Exercises

  1. Basic Insertion Sort: Implement Insertion Sort for an array of integers and test it with various inputs (e.g., unsorted, sorted, reversed, duplicates). Count the number of shifts performed.
  2. Descending Order: Modify the Insertion Sort implementation to sort an array in descending order by adjusting the comparison logic. Test with different array sizes.
  3. Performance Analysis: Write a program that measures the execution time of Insertion Sort for arrays of increasing sizes (e.g., 10, 100, 1000 elements). Compare with Bubble Sort and Selection Sort.
  4. Object Sorting: Extend Insertion Sort to sort an array of objects (e.g., Student objects with a grade field) based on a custom comparator. Test with a sample dataset.
  5. Nearly Sorted Arrays: Implement Insertion Sort and test its performance on nearly sorted arrays (e.g., one element out of place). Analyze the number of comparisons and shifts compared to unsorted inputs.

Merge Sort

Definition and Concepts

Merge Sort is a divide-and-conquer sorting algorithm that recursively divides an array into two halves, sorts each half, and then merges the sorted halves to produce a fully sorted array. The algorithm relies on the principle that it is easier to sort smaller subarrays and combine them efficiently. You can visualize Merge Sort as splitting a deck of cards into smaller stacks, sorting each stack, and then combining them in order. In Java, Merge Sort is typically implemented on arrays, using recursion to divide the array and an auxiliary array to merge the sorted subarrays. Merge Sort is a stable algorithm, preserving the relative order of equal elements, but it is not in-place, as it requires additional memory for merging.

Key Points:

  • Merge Sort divides the array into two equal halves until each subarray has one element (which is inherently sorted).
  • It merges sorted subarrays by comparing elements and placing them in the correct order.
  • The algorithm is efficient for large datasets due to its consistent O(n log n) time complexity.
  • It requires extra space for merging, unlike in-place algorithms like Bubble Sort or Insertion Sort.

Why Use It?

Merge Sort is used for its consistent and efficient O(n log n) time complexity, making it suitable for sorting large datasets where performance is critical. The algorithm is stable, ensuring that equal elements maintain their relative order, which is important in applications like database sorting. Merge Sort is particularly effective for linked lists, as it avoids random access, and for external sorting (sorting data too large to fit in memory). Its divide-and-conquer approach makes it easy to parallelize, improving performance on multi-core systems. However, its need for extra space makes it less suitable for memory-constrained environments.

Where to Use? (Real-Life Examples)

  • Database Sorting: Database systems use Merge Sort to sort large datasets, such as query results, due to its stability and efficiency.
  • External Sorting: File systems use Merge Sort for sorting large files on disk, as it can handle data in chunks, minimizing memory usage.
  • Linked List Sorting: Applications with linked list data structures use Merge Sort to sort elements efficiently without requiring random access.
  • Parallel Processing: Parallel computing frameworks use Merge Sort in distributed systems, as its divide-and-conquer nature allows tasks to be split across processors.

Explain Operations

  • Divide: This operation splits the array into two halves recursively until each subarray has one element. It has a time complexity of O(1) per division.
  • Merge: This operation combines two sorted subarrays into a single sorted array by comparing elements and placing them in order. It has a time complexity of O(n) for n elements in the subarrays.
  • Recursive Sort: This operation recursively sorts the two halves of the array. It contributes to the overall O(n log n) time complexity due to log n levels of recursion.
  • Full Algorithm: This operation combines division, recursive sorting, and merging to sort the entire array. It has a time complexity of O(n log n) in all cases.

Java Implementation

The following Java code implements Merge Sort for an array of integers.

public class MergeSort {
    // Merge Sort: Sorts an array in ascending order
    public void mergeSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return; // No sorting needed for null or single-element arrays
        }
        int[] temp = new int[arr.length]; // Auxiliary array for merging
        mergeSortHelper(arr, 0, arr.length - 1, temp);
    }

    // Helper method: Recursively divides and sorts the array
    private void mergeSortHelper(int[] arr, int left, int right, int[] temp) {
        if (left < right) { // Base case: subarray has more than one element
            int mid = left + (right - left) / 2; // Find midpoint
            mergeSortHelper(arr, left, mid, temp); // Sort left half
            mergeSortHelper(arr, mid + 1, right, temp); // Sort right half
            merge(arr, left, mid, right, temp); // Merge sorted halves
        }
    }

    // Merge: Combines two sorted subarrays into a single sorted array
    private void merge(int[] arr, int left, int mid, int right, int[] temp) {
        // Copy elements to temporary array
        for (int i = left; i <= right; i++) {
            temp[i] = arr[i];
        }
        int i = left; // Index for left subarray
        int j = mid + 1; // Index for right subarray
        int k = left; // Index for merged array
        while (i <= mid && j <= right) { // Compare and merge
            if (temp[i] <= temp[j]) { // Use <= for stability
                arr[k++] = temp[i++];
            } else {
                arr[k++] = temp[j++];
            }
        }
        while (i <= mid) { // Copy remaining left subarray elements
            arr[k++] = temp[i++];
        }
        // No need to copy right subarray, as it’s already processed
    }
}

How It Works

  1. Check Input:
    • The mergeSort method checks if the input array is null or has one or fewer elements. If so, it returns immediately, as no sorting is needed.
  2. Initialize Temporary Array:
    • An auxiliary array temp is created to assist with merging, with the same size as the input array.
  3. Recursive Division (mergeSortHelper):
    • The mergeSortHelper method recursively divides the array into two halves by calculating the midpoint (mid = left + (right - left) / 2).
    • It calls itself on the left half (left to mid) and right half (mid + 1 to right).
    • For example, for [5, 3, 8, 1, 9], it divides into [5, 3] and [8, 1, 9], then further into [5], [3], [8], [1, 9].
  4. Merge:
    • The merge method copies the subarray from left to right into temp, then merges the two sorted halves back into the original array.
    • It compares elements from the left subarray (temp[i]) and right subarray (temp[j]), placing the smaller (or equal, for stability) element into arr[k].
    • For example, merging [3, 5] and [1, 8, 9] results in [1, 3, 5, 8, 9].
  5. Result:
    • After all recursive calls and merges, the array is sorted in ascending order. For example, [5, 3, 8, 1, 9] becomes [1, 3, 5, 8, 9].

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
DivideO(1)O(1)
MergeO(n)O(n)
Recursive SortO(n log n)O(n)
Full AlgorithmO(n log n) (all cases)O(n)

Note:

  • n is the number of elements in the array.
  • Time complexity is O(n log n) in best, average, and worst cases, as the array is divided log n times, and each merge takes O(n) time.
  • Space complexity is O(n) due to the auxiliary array used for merging.

Key Differences / Notes

  • Merge Sort vs. Bubble/Selection/Insertion Sort:
    • Merge Sort has O(n log n) time complexity, making it more efficient than Bubble Sort, Selection Sort, and Insertion Sort (all O(n²)) for large datasets.
    • It is stable, like Insertion Sort, but unlike Selection Sort.
    • It requires O(n) extra space, unlike the in-place Bubble, Selection, and Insertion Sorts.
  • Merge Sort vs. Quick Sort:
    • Merge Sort is stable and has consistent O(n log n) time complexity, while Quick Sort is not stable and has O(n²) worst-case but O(n log n) average.
    • Quick Sort is in-place, while Merge Sort requires extra space.
  • Suitability for Data Structures:
    • Merge Sort is efficient for arrays and linked lists, as it avoids random access in the merge step, unlike Selection Sort.
    • It is particularly suited for external sorting and parallel processing due to its divide-and-conquer nature.
  • Stability:
    • The use of <= in the merge step ensures stability, preserving the relative order of equal elements.

✅ Tip: Use Merge Sort for large datasets or when stability is required, as its O(n log n) time complexity ensures consistent performance. For linked lists, Merge Sort is especially efficient, as it avoids random access.

⚠ Warning: Be mindful of Merge Sort’s O(n) space complexity, which may be a limitation in memory-constrained environments. Always validate array bounds and ensure the auxiliary array is properly initialized to prevent errors.

Exercises

  1. Basic Merge Sort: Implement Merge Sort for an array of integers and test it with various inputs (e.g., unsorted, sorted, reversed, duplicates). Verify the sorted output.
  2. Descending Order: Modify the Merge Sort implementation to sort an array in descending order by adjusting the merge comparison logic. Test with different array sizes.
  3. Performance Analysis: Write a program that measures the execution time of Merge Sort for arrays of increasing sizes (e.g., 10, 100, 1000 elements). Compare with Insertion Sort and Selection Sort.
  4. Object Sorting: Extend Merge Sort to sort an array of objects (e.g., Student objects with a grade field) based on a custom comparator. Test with a sample dataset.
  5. Space Optimization: Implement an in-place Merge Sort variant (if feasible) and compare its performance and space usage with the standard implementation. Test with various inputs.

Searching Algorithms

What are Searching Algorithms?

Searching algorithms are methods for finding a specific element or its position within a data structure, such as an array or list. They are the building blocks of efficient data retrieval, used in applications from simple lookups to complex database queries. The algorithms covered in this chapter are Linear Search and Binary Search, each with distinct approaches to locating elements.

Why Learn Searching Algorithms?

  • Efficiency: Choosing the right searching algorithm can drastically reduce the time needed to find data.
  • Problem Solving: Many real-world tasks, like finding a contact or a record, rely on searching techniques.
  • Foundation for Advanced Algorithms: Understanding searching is crucial for mastering more complex algorithms.
  • Interview Readiness: Searching algorithms are a common topic in technical interviews.

📖 What You Will Learn in This Chapter

This chapter introduces you to the most commonly used searching algorithms, their uses, advantages, disadvantages, and Java implementations.

  • What is Linear Search and how it works.
  • Checking elements one by one in sequence.
  • Applicability to unsorted data.
  • Real-life example: Finding a name in an unsorted list.
  • How Binary Search divides the search space.
  • Requiring sorted data for efficiency.
  • Iterative and recursive approaches.
  • Real-life example: Looking up a word in a dictionary.

🛠 How We Will Learn

For each searching algorithm, we will cover:

  1. Definition – What it is and how it works.
  2. Why – Advantages and limitations of the algorithm.
  3. Where to Use – Real-life scenarios where the algorithm is effective.
  4. Java Implementation – With step-by-step explanations.
  5. Complexity Analysis – Understanding performance trade-offs.

Definition and Concepts

Linear Search, also known as sequential search, is a simple searching algorithm that checks each element in a list sequentially until the target element is found or the list is exhausted. The algorithm does not require the data to be sorted, making it versatile but inefficient for large datasets. You can visualize Linear Search as looking through a stack of papers one by one to find a specific document. In Java, Linear Search is typically implemented on arrays due to their direct index-based access, returning the index of the target or -1 if not found.

Key Points:

  • Linear Search examines elements one at a time in order.
  • It works on both sorted and unsorted data.
  • The algorithm is simple to implement but has a linear time complexity.
  • It returns the first occurrence of the target element.

Why Use It?

Linear Search is used for its simplicity and applicability to small or unsorted datasets, where the overhead of sorting for other algorithms is unnecessary. It is ideal for beginners to understand searching concepts and is useful when the dataset is small or when searching for the first occurrence of a value. However, for large datasets, more efficient algorithms like Binary Search are preferred.

Where to Use? (Real-Life Examples)

  • Small List Search: Linear Search is used in applications like finding a contact in a short, unsorted phone list due to its simplicity.
  • Unsorted Data: Inventory systems use Linear Search to locate items in unsorted lists, such as stock entries.
  • Teaching Tool: Linear Search is used in classrooms to introduce searching concepts to beginners.
  • First Occurrence: Applications like log analysis use Linear Search to find the first instance of an error in a sequence.

Explain Operations

  • Comparison: Compares the current element with the target to check for a match, with a time complexity of O(1).
  • Traversal: Moves to the next element in the array, incrementing the index, with a time complexity of O(n) for n elements in the worst case.
  • Full Search: Executes the complete search, checking all elements until the target is found or the end is reached, with a time complexity of O(n).

Java Implementation

The following Java code implements Linear Search for an array of integers, designed to be clear and beginner-friendly.

public class LinearSearch {
    // Linear Search: Finds the target by checking each element
    public int linearSearch(int[] arr, int target) {
        if (arr == null) {
            return -1; // Return -1 for null array
        }
        for (int i = 0; i < arr.length; i++) { // Check each element
            if (arr[i] == target) {
                return i; // Return index of target
            }
        }
        return -1; // Target not found
    }
}

How It Works

  1. Check Input:
    • The linearSearch method checks if the input array is null. If so, it returns -1, as no search is possible.
  2. Traversal and Comparison:
    • The method iterates through the array from index 0 to the end, comparing each element with the target.
    • If a match is found, the method returns the index of the target.
    • For example, in [1, 3, 5, 8, 9] with target 5, it checks indices 0, 1, and 2, returning 2.
  3. Result:
    • If the target is not found after checking all elements, the method returns -1.
    • For example, searching for 7 in [1, 3, 5, 8, 9] returns -1.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ComparisonO(1)O(1)
TraversalO(n)O(1)
Full SearchO(1) (best)O(1)
Full SearchO(n) (average/worst)O(1)

Note:

  • n is the number of elements.
  • Best case (O(1)) occurs when the target is at the first index.
  • Average and worst cases (O(n)) involve checking up to n elements.
  • Space complexity is O(1), as no extra space is used.

Key Differences / Notes

  • Linear Search vs. Binary Search:
    • Linear Search works on unsorted data, while Binary Search requires sorted data.
    • Linear Search has O(n) time complexity, while Binary Search is O(log n), making it faster for large datasets.
  • Simplicity:
    • Linear Search is easier to implement and understand than Binary Search, ideal for beginners.
  • Use Cases:
    • Linear Search is best for small or unsorted datasets or when finding the first occurrence is needed.
  • Limitations:
    • Linear Search is inefficient for large datasets due to its linear time complexity.

✅ Tip: Use Linear Search for small or unsorted datasets due to its simplicity and lack of preprocessing requirements. Test with edge cases like empty arrays or missing targets to ensure robustness.

⚠ Warning: Avoid Linear Search for large datasets, as its O(n) time complexity can be slow compared to Binary Search’s O(log n). Always check for null arrays to prevent NullPointerException.

Exercises

  1. Basic Linear Search: Implement Linear Search and test it with arrays of different sizes and targets (e.g., present, absent, first element). Count the number of comparisons.
  2. Last Occurrence: Modify Linear Search to find the last occurrence of a target in an array with duplicates. Test with [1, 3, 3, 5, 8] and target 3.
  3. Performance Analysis: Measure the execution time of Linear Search for arrays of increasing sizes (e.g., 10, 100, 1000 elements). Analyze best and worst cases.
  4. Object Search: Extend Linear Search to find an object in an array (e.g., Student with id). Test with a sample dataset.
  5. Multiple Targets: Implement Linear Search to return all indices where a target appears in an array. Test with duplicates like [1, 3, 3, 5, 3].

Definition and Concepts

Binary Search is an efficient searching algorithm that finds a target element in a sorted array by repeatedly dividing the search space in half. The algorithm compares the target with the middle element and eliminates half of the remaining elements based on the comparison, continuing until the target is found or the search space is empty. You can visualize Binary Search as looking up a word in a dictionary by opening to the middle and narrowing the search to one half. In Java, Binary Search is typically implemented on sorted arrays, returning the index of the target or -1 if not found.

Key Points:

  • Binary Search requires the input array to be sorted.
  • It divides the search space in half with each step, making it highly efficient.
  • The algorithm can be implemented iteratively or recursively.
  • It returns the index of the first occurrence of the target in case of duplicates.

Why Use It?

Binary Search is used for its efficiency, with an O(log n) time complexity, making it ideal for large, sorted datasets. It is suitable for applications where data is pre-sorted, such as database indexes or lookup tables, and is a key concept for students learning divide-and-conquer strategies. However, it requires sorted data, which may necessitate preprocessing, and is less versatile than Linear Search for unsorted data.

Where to Use? (Real-Life Examples)

  • Database Indexes: Binary Search is used in databases to quickly locate records in sorted indexes, like customer IDs.
  • Dictionary Lookups: Dictionary applications use Binary Search to find words in a sorted list efficiently.
  • Sorted Data Search: Search engines use Binary Search to locate items in sorted datasets, such as ranked results.
  • Algorithm Design: Binary Search is used in algorithms like finding the square root of a number by narrowing ranges.

Explain Operations

  • Comparison: Compares the middle element with the target to determine the search direction, with a time complexity of O(1).
  • Division: Updates the search boundaries (left or right) to half the current range, with a time complexity of O(1).
  • Full Search: Executes the algorithm, halving the search space until the target is found or the space is empty, with a time complexity of O(log n).

Java Implementation

The following Java code implements Binary Search (iterative version) for an array of integers.

public class BinarySearch {
    // Binary Search: Finds the target in a sorted array
    public int binarySearch(int[] arr, int target) {
        if (arr == null) {
            return -1; // Return -1 for null array
        }
        int left = 0, right = arr.length - 1; // Initialize boundaries
        while (left <= right) { // Continue until search space is valid
            int mid = left + (right - left) / 2; // Calculate midpoint
            if (arr[mid] == target) {
                return mid; // Return index of target
            } else if (arr[mid] < target) {
                left = mid + 1; // Search right half
            } else {
                right = mid - 1; // Search left half
            }
        }
        return -1; // Target not found
    }
}

How It Works

  1. Check Input:
    • The binarySearch method checks if the input array is null. If so, it returns -1, as no search is possible.
  2. Initialize Boundaries:
    • Sets left to 0 and right to the last index of the array, defining the initial search space.
  3. Division and Comparison:
    • Calculates the midpoint (mid) and compares the element at mid with the target.
    • If equal, returns the index. If the target is greater, searches the right half (left = mid + 1). If smaller, searches the left half (right = mid - 1).
    • For example, in [1, 3, 5, 8, 9] with target 5, it checks index 2 (midpoint) and returns 2.
  4. Result:
    • If the search space is empty (left > right), returns -1, indicating the target is not found.
    • For example, searching for 7 returns -1.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ComparisonO(1)O(1)
DivisionO(1)O(1)
Full SearchO(1) (best)O(1)
Full SearchO(log n) (average/worst)O(1)

Note:

  • n is the number of elements.
  • Best case (O(1)) occurs when the target is at the midpoint of the first check.
  • Average and worst cases (O(log n)) involve log n divisions.
  • Space complexity is O(1) for the iterative version, as no extra space is used.

Key Differences / Notes

  • Binary Search vs. Linear Search:
    • Binary Search is O(log n), much faster than Linear Search’s O(n) for large datasets, but requires sorted data.
    • Binary Search is more complex to implement due to its divide-and-conquer logic.
  • Iterative vs. Recursive:
    • The iterative version shown is more space-efficient (O(1)) than the recursive version (O(log n) due to call stack).
  • Use Cases:
    • Binary Search is ideal for large, sorted datasets, while Linear Search is better for unsorted or small data.
  • Limitations:
    • Binary Search fails on unsorted data, requiring preprocessing to sort the array.

✅ Tip: Use Binary Search for large, sorted datasets to leverage its O(log n) efficiency. Test with sorted arrays and edge cases to ensure correctness.

⚠ Warning: Binary Search requires sorted data; applying it to unsorted data produces incorrect results. Use left + (right - left) / 2 for midpoint calculation to avoid integer overflow.

Exercises

  1. Basic Binary Search: Implement Binary Search and test it with sorted arrays of different sizes and targets (e.g., present, absent, middle element).
  2. Recursive Implementation: Implement a recursive version of Binary Search and compare its performance with the iterative version.
  3. First and Last Occurrence: Modify Binary Search to find the first and last occurrences of a target in an array with duplicates (e.g., [1, 3, 3, 3, 5] for target 3).
  4. Performance Analysis: Measure the execution time of Binary Search vs. Linear Search for large sorted arrays (e.g., 1000, 10000 elements).
  5. Object Search: Extend Binary Search to find an object in a sorted array (e.g., Student with id). Test with a sample dataset.

Recursion Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Binary Search Recursion

Problem Statement

Write a Java program that implements a recursive binary search algorithm to find the index of a target element in a sorted array of integers. The program should return the index of the target if found, or -1 if the target does not exist in the array. Test the program with arrays containing both existing and non-existing elements. You can visualize this as searching for a word in a dictionary by repeatedly opening to the middle page and narrowing the search to one half of the book.

Input: A sorted array of integers and a target integer (e.g., arr = [1, 3, 5, 8, 9], target = 5). Output: An integer representing the index of the target in the array, or -1 if the target is not found (e.g., 2 for target 5). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array is sorted in ascending order.
  • The target may or may not exist in the array. Example:
  • Input: arr = [1, 3, 5, 8, 9], target = 5
  • Output: 2
  • Explanation: The target 5 is found at index 2 in the sorted array.
  • Input: arr = [1, 3, 5, 8, 9], target = 7
  • Output: -1
  • Explanation: The target 7 is not present in the array.

Pseudocode

FUNCTION binarySearch(arr, target, left, right)
    IF arr is null OR left > right THEN
        RETURN -1
    ENDIF
    SET mid to (left + right) / 2
    IF arr[mid] equals target THEN
        RETURN mid
    ELSE IF arr[mid] < target THEN
        RETURN binarySearch(arr, target, mid + 1, right)
    ELSE
        RETURN binarySearch(arr, target, left, mid - 1)
    ENDIF
ENDFUNCTION

FUNCTION mainBinarySearch(arr, target)
    RETURN binarySearch(arr, target, 0, length of arr - 1)
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null or if the left index exceeds the right index, indicating an empty search space. If so, return -1.
  2. Define a recursive helper function that takes the array, target, and left and right indices as parameters.
  3. In the recursive function, calculate the middle index of the current search space.
  4. Compare the element at the middle index with the target. If they are equal, return the middle index.
  5. If the middle element is less than the target, recursively search the right half of the array (from mid + 1 to right).
  6. If the middle element is greater than the target, recursively search the left half of the array (from left to mid - 1).
  7. In the main function, initiate the recursion by calling the helper function with the initial left index as 0 and the right index as the array length minus 1.
  8. Return the result from the recursive function, which is either the target’s index or -1 if not found.

Java Implementation

public class BinarySearchRecursion {
    // Main method to initiate recursive binary search
    public int binarySearch(int[] arr, int target) {
        // Check for null array
        if (arr == null) {
            return -1;
        }
        // Call recursive helper with initial boundaries
        return binarySearchHelper(arr, target, 0, arr.length - 1);
    }

    // Helper method for recursive binary search
    private int binarySearchHelper(int[] arr, int target, int left, int right) {
        // Base case: empty search space or invalid indices
        if (left > right) {
            return -1;
        }
        // Calculate middle index
        int mid = left + (right - left) / 2;
        // If target found at mid, return index
        if (arr[mid] == target) {
            return mid;
        }
        // If target is greater, search right half
        else if (arr[mid] < target) {
            return binarySearchHelper(arr, target, mid + 1, right);
        }
        // If target is smaller, search left half
        else {
            return binarySearchHelper(arr, target, left, mid - 1);
        }
    }
}

Output

For the input arr = [1, 3, 5, 8, 9], target = 5, the program outputs:

2

Explanation: The target 5 is found at index 2 in the sorted array.

For the input arr = [1, 3, 5, 8, 9], target = 7, the program outputs:

-1

Explanation: The target 7 is not present in the array.

For an empty array arr = [], the program outputs:

-1

Explanation: An empty array contains no elements, so the target cannot be found.

How It Works

  • Step 1: The binarySearch method checks if the array is null. For [1, 3, 5, 8, 9], it is valid, so it calls the helper function with left = 0 and right = 4.
  • Step 2: In binarySearchHelper:
    • First call: left = 0, right = 4, mid = 2, arr[2] = 5. Since 5 equals the target, return 2.
    • For target 7: First call: mid = 2, arr[2] = 5 < 7, recurse on left = 3, right = 4.
    • Second call: mid = 3, arr[3] = 8 > 7, recurse on left = 3, right = 2.
    • Third call: left > right, return -1.
  • Example Trace: For [1, 3, 5, 8, 9] with target 5:
    • Check mid = 2, arr[2] = 5, matches target, return 2.
    • For target 7: Check mid = 2, arr[2] = 5 < 7, recurse on right half [8, 9]. Check mid = 3, arr[3] = 8 > 7, recurse on left half (empty). Return -1.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ComparisonO(1)O(1)
Recursive CallO(log n)O(log n)
Full AlgorithmO(log n)O(log n)

Note:

  • n is the size of the input array.
  • Time complexity: O(log n), as the algorithm halves the search space with each recursive call.
  • Space complexity: O(log n) due to the recursive call stack, which grows logarithmically with the array size.
  • Best case: O(1) if the target is at the midpoint of the first call.
  • Average and worst cases: O(log n) for up to log n recursive calls.

✅ Tip: Use recursive binary search for sorted arrays to leverage its O(log n) efficiency, and test with both existing and non-existing targets to ensure correctness. Use the formula left + (right - left) / 2 for midpoint calculation to avoid integer overflow.

⚠ Warning: Binary search requires a sorted array; applying it to an unsorted array will produce incorrect results. Ensure proper bounds checking to avoid infinite recursion or ArrayIndexOutOfBoundsException.

Factorial with Memoization

Problem Statement

Write a Java program that implements a recursive factorial function using memoization with a HashMap to cache previously computed results. The program should compute the factorial of a non-negative integer and compare its performance with a standard recursive factorial implementation for large inputs. Test the program with various inputs, including edge cases like 0 and 1. You can visualize this as calculating the number of ways to arrange books on a shelf, where memoization saves time by reusing previously calculated arrangements.

Input: A non-negative integer n (e.g., n = 5). Output: A long integer representing the factorial of n (e.g., 120 for n = 5). Constraints:

  • 0 ≤ n ≤ 20 (to avoid overflow with long).
  • The input is a non-negative integer. Example:
  • Input: n = 5
  • Output: 120
  • Explanation: The factorial of 5 is 5 * 4 * 3 * 2 * 1 = 120.
  • Input: n = 0
  • Output: 1
  • Explanation: The factorial of 0 is defined as 1.

Pseudocode

FUNCTION factorialWithMemo(n, memo)
    IF n < 0 THEN
        RETURN -1
    ENDIF
    IF n equals 0 OR n equals 1 THEN
        RETURN 1
    ENDIF
    IF n exists in memo THEN
        RETURN memo[n]
    ENDIF
    SET result to n * factorialWithMemo(n - 1, memo)
    SET memo[n] to result
    RETURN result
ENDFUNCTION

FUNCTION mainFactorial(n)
    CREATE empty HashMap memo
    RETURN factorialWithMemo(n, memo)
ENDFUNCTION

Algorithm Steps

  1. Check if the input n is negative. If so, return -1, as factorial is undefined for negative numbers.
  2. Define a recursive helper function that takes the input n and a HashMap for memoization as parameters.
  3. In the recursive function, implement the base cases: if n is 0 or 1, return 1, as the factorial of 0 and 1 is 1.
  4. Check if the factorial of n is already in the HashMap. If so, return the cached result.
  5. For the recursive case, compute the factorial by multiplying n with the result of the recursive call for n - 1.
  6. Store the computed result in the HashMap with n as the key.
  7. In the main function, create an empty HashMap and call the helper function with n and the HashMap.
  8. Return the final factorial result.

Java Implementation

import java.util.HashMap;

public class FactorialWithMemoization {
    // Computes factorial of n using memoization with HashMap
    public long factorial(int n) {
        // Check for invalid input
        if (n < 0) {
            return -1;
        }
        // Create HashMap for memoization
        HashMap<Integer, Long> memo = new HashMap<>();
        // Call recursive helper function
        return factorialHelper(n, memo);
    }

    // Helper function for recursive factorial with memoization
    private long factorialHelper(int n, HashMap<Integer, Long> memo) {
        // Base case: factorial of 0 or 1 is 1
        if (n == 0 || n == 1) {
            return 1;
        }
        // Check if result is in memo
        if (memo.containsKey(n)) {
            return memo.get(n);
        }
        // Recursive case: n * factorial(n-1)
        long result = n * factorialHelper(n - 1, memo);
        // Cache result in memo
        memo.put(n, result);
        return result;
    }

    // Standard recursive factorial for comparison
    public long standardFactorial(int n) {
        // Check for invalid input
        if (n < 0) {
            return -1;
        }
        // Base case: factorial of 0 or 1 is 1
        if (n == 0 || n == 1) {
            return 1;
        }
        // Recursive case: n * factorial(n-1)
        return n * standardFactorial(n - 1);
    }
}

Output

For the input n = 5, the factorial method outputs:

120

Explanation: The factorial of 5 is computed as 5 * 4 * 3 * 2 * 1 = 120.

For the input n = 0, the factorial method outputs:

1

Explanation: The factorial of 0 is defined as 1.

For the input n = 15, the factorial method outputs:

1307674368000

Explanation: The factorial of 15 is computed as 15 * 14 * ... * 1 = 1307674368000.

How It Works

  • Step 1: The factorial method checks if n is negative. For n = 5, it is valid, so it creates a HashMap and calls factorialHelper.
  • Step 2: In factorialHelper:
    • For n = 5: Not in memo, compute 5 * factorialHelper(4).
    • For n = 4: Not in memo, compute 4 * factorialHelper(3).
    • For n = 3: Not in memo, compute 3 * factorialHelper(2).
    • For n = 2: Not in memo, compute 2 * factorialHelper(1).
    • For n = 1: Base case, return 1.
    • Unwind: Store 2! = 2 in memo, 3! = 6 in memo, 4! = 24 in memo, 5! = 120 in memo, return 120.
  • Step 3: For subsequent calls with cached values (e.g., in a performance test), the memoized function retrieves results directly from the HashMap, reducing computation.
  • Example Trace: For n = 5, the algorithm computes: 5 * (4 * (3 * (2 * (1)))) = 5 * 24 = 120, caching each intermediate result. For n = 0, it returns 1 immediately.
  • Performance Comparison: The memoized version is faster for repeated calls or large n within the same HashMap, as it avoids recomputing factorials. For example, computing 15! takes O(n) time without memoization but O(1) for cached values with memoization.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ComparisonO(1)O(1)
HashMap OperationsO(1) (average)O(n)
Full Algorithm (Memoized)O(n) (first call)O(n)
Full Algorithm (Memoized)O(1) (cached)O(n)
Full Algorithm (Standard)O(n)O(n)

Note:

  • n is the input integer.
  • Time complexity (memoized): O(n) for the first call to compute and cache results up to n; O(1) for subsequent calls if cached.
  • Time complexity (standard): O(n) for all calls, as it recomputes each step.
  • Space complexity: O(n) for both, due to the recursive call stack and HashMap (for memoized version).
  • Best case (memoized): O(1) if the result is cached; worst case is O(n) for initial computation.

✅ Tip: Use memoization to optimize recursive functions like factorial for repeated calls or large inputs, and test with edge cases like 0, 1, and large values (within constraints) to verify correctness.

⚠ Warning: Be cautious with inputs larger than 20, as factorial results may exceed the long data type’s capacity, causing overflow. Ensure the HashMap is properly initialized to avoid NullPointerException.

Recursive Sum of Array

Problem Statement

Write a Java program that uses recursion to compute the sum of all elements in an array of integers. The program should take an array as input and return the total sum of its elements. Test the program with arrays of different sizes, including empty arrays and single-element arrays. You can visualize this as calculating the total score of a student’s test results stored in a list, where you add each score one by one recursively.

Input: An array of integers (e.g., arr = [1, 2, 3, 4, 5]). Output: An integer representing the sum of all elements in the array (e.g., 15 for [1, 2, 3, 4, 5]). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array may be empty. Example:
  • Input: arr = [1, 2, 3, 4, 5]
  • Output: 15
  • Explanation: The sum is calculated as 1 + 2 + 3 + 4 + 5 = 15.

Pseudocode

1. If the array is null or empty, return 0.
2. Define a recursive function sumArray(arr, index):
   a. Base case: If index equals array length, return 0.
   b. Recursive case: Return arr[index] + sumArray(arr, index + 1).
3. Call sumArray(arr, 0) to start recursion from the first element.
4. Return the result.

Algorithm Steps

  1. Check if the input array is null or empty. If so, return 0, as the sum of no elements is zero.
  2. Define a recursive helper function that takes the array and the current index as parameters.
  3. In the recursive function, implement the base case: if the index equals the array length, return 0, as no more elements remain to sum.
  4. For the recursive case, add the element at the current index to the result of the recursive call for the next index.
  5. Initiate the recursion by calling the helper function with the initial index set to 0.
  6. Return the final sum computed by the recursive function.

Java Implementation

public class RecursiveArraySum {
    // Computes the sum of array elements using recursion
    public int sumArray(int[] arr) {
        // Check for null or empty array
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // Call recursive helper function starting at index 0
        return sumArrayHelper(arr, 0);
    }

    // Helper function for recursive sum
    private int sumArrayHelper(int[] arr, int index) {
        // Base case: if index reaches array length, return 0
        if (index == arr.length) {
            return 0;
        }
        // Recursive case: add current element to sum of remaining elements
        return arr[index] + sumArrayHelper(arr, index + 1);
    }
}

Output

For the input array [1, 2, 3, 4, 5], the program outputs:

15

Explanation: The sum is computed as 1 + 2 + 3 + 4 + 5 = 15.

For an empty array [], the program outputs:

0

Explanation: The sum of an empty array is 0.

How It Works

  • Step 1: The sumArray method checks if the input array is null or empty. For [1, 2, 3, 4, 5], it is valid, so it calls the helper function with index 0.
  • Step 2: The sumArrayHelper method implements recursion:
    • At index 0: arr[0] = 1, recursive call for index 1.
    • At index 1: arr[1] = 2, recursive call for index 2.
    • At index 2: arr[2] = 3, recursive call for index 3.
    • At index 3: arr[3] = 4, recursive call for index 4.
    • At index 4: arr[4] = 5, recursive call for index 5.
    • At index 5: Base case reached (index equals length), return 0.
  • Step 3: The recursion unwinds: 5 + (4 + (3 + (2 + (1 + 0)))) = 15.
  • Example Trace: For [1, 2, 3, 4, 5], the algorithm recursively adds: 1 + sum([2, 3, 4, 5]) = 1 + (2 + sum([3, 4, 5])) = 1 + 2 + (3 + sum([4, 5])) = 1 + 2 + 3 + (4 + sum([5])) = 1 + 2 + 3 + 4 + (5 + sum([])) = 1 + 2 + 3 + 4 + 5 + 0 = 15.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ComparisonO(1)O(1)
Recursive CallO(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the size of the input array.
  • Time complexity: O(n), as the algorithm makes one recursive call per element.
  • Space complexity: O(n) due to the recursive call stack, which grows linearly with the array size.
  • Best, average, and worst cases are all O(n), as every element is processed exactly once.

✅ Tip: Use recursion for problems like array summation when the logic is naturally recursive, and test with edge cases like empty arrays or single-element arrays to ensure correctness.

⚠ Warning: Be cautious with large arrays, as the O(n) space complexity from the recursive call stack can lead to stack overflow errors. Consider iterative solutions for very large inputs.

Reverse a String Recursively

Problem Statement

Write a Java program that uses recursion to reverse a string by breaking it into smaller substrings. The program should take a string as input and return the reversed string. Test the program with various strings, including empty strings and single-character strings. You can visualize this as rearranging the letters of a word written on a whiteboard, starting from the last letter and moving backward to the first, using smaller pieces of the word.

Input: A string (e.g., str = "hello"). Output: A string representing the reversed input (e.g., "olleh"). Constraints:

  • The string length is between 0 and 10^5.
  • The string contains printable ASCII characters.
  • The string may be empty. Example:
  • Input: str = "hello"
  • Output: "olleh"
  • Explanation: The string "hello" is reversed to "olleh" by rearranging its characters starting from the last one.

Pseudocode

FUNCTION reverseString(str)
    IF str is null OR length of str <= 1 THEN
        RETURN str
    ENDIF
    SET lastChar to character at index (length of str - 1)
    SET substring to str from index 0 to (length of str - 1)
    RETURN lastChar + reverseString(substring)
ENDFUNCTION

Algorithm Steps

  1. Check if the input string is null, empty, or has one character. If so, return the string as is, since no reversal is needed.
  2. Define a recursive function that takes the input string as a parameter.
  3. In the recursive function, implement the base case: if the string is empty or has one character, return the string itself.
  4. For the recursive case, extract the last character of the string and recursively call the function on the substring excluding the last character.
  5. Combine the last character with the result of the recursive call to build the reversed string.
  6. Return the final reversed string from the initial function call.

Java Implementation

public class ReverseStringRecursively {
    // Reverses a string using recursion
    public String reverseString(String str) {
        // Check for null, empty, or single-character string
        if (str == null || str.length() <= 1) {
            return str;
        }
        // Recursive case: last character + reverse of remaining string
        return str.charAt(str.length() - 1) + reverseString(str.substring(0, str.length() - 1));
    }
}

Output

For the input string "hello", the program outputs:

olleh

Explanation: The string "hello" is reversed to "olleh" by recursively rearranging characters.

For an empty string "", the program outputs:

""

Explanation: An empty string remains empty after reversal.

For a single-character string "a", the program outputs:

a

Explanation: A single-character string is already reversed.

How It Works

  • Step 1: The reverseString method checks if the input string is null, empty, or has one character. For "hello", it has length 5, so it proceeds to the recursive case.
  • Step 2: In the recursive case:
    • For "hello": Returns 'o' + reverseString("hell").
    • For "hell": Returns 'l' + reverseString("hel").
    • For "hel": Returns 'l' + reverseString("he").
    • For "he": Returns 'e' + reverseString("h").
    • For "h": Base case reached (length 1), returns "h".
  • Step 3: The recursion unwinds: 'h' + 'e' + 'l' + 'l' + 'o' = "olleh".
  • Example Trace: For "hello", the algorithm recursively processes: 'o' + reverse("hell") = 'o' + ('l' + reverse("hel")) = 'o' + 'l' + ('l' + reverse("he")) = 'o' + 'l' + 'l' + ('e' + reverse("h")) = 'o' + 'l' + 'l' + 'e' + 'h' = "olleh".

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ComparisonO(1)O(1)
Recursive CallO(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the input string.
  • Time complexity: O(n), as the algorithm makes one recursive call per character.
  • Space complexity: O(n) due to the recursive call stack and the creation of substrings in Java, which involves new string objects.
  • Best, average, and worst cases are all O(n), as every character is processed exactly once.

✅ Tip: Use recursion for string reversal when the problem naturally breaks into smaller subproblems, and test with edge cases like empty strings, single characters, or long strings to ensure robustness.

⚠ Warning: Be cautious with very long strings, as the O(n) space complexity from the recursive call stack and substring creation can lead to stack overflow or memory issues. Consider iterative solutions for large inputs.

Tower of Hanoi

Problem Statement

Write a Java program that uses recursion to solve the Tower of Hanoi problem for n disks, printing the sequence of moves required to transfer all disks from a source rod to a destination rod using an auxiliary rod. The rules are: only one disk can be moved at a time, a larger disk cannot be placed on a smaller disk, and disks must be moved between the rods (source, auxiliary, destination). Test the program with different values of n, including edge cases like n = 1. You can visualize this as moving a stack of differently sized books from one table to another, using a third table as temporary storage, ensuring larger books are always below smaller ones.

Input: A non-negative integer n representing the number of disks, and three rod names (e.g., source = 'A', auxiliary = 'B', destination = 'C'). Output: A sequence of moves printed in the format "Move disk [number] from [source] to [destination]" (e.g., "Move disk 1 from A to C"), with each move on a new line. Constraints:

  • 0 ≤ n ≤ 10 (to keep output manageable and avoid excessive recursion).
  • Rod names are single characters (e.g., 'A', 'B', 'C'). Example:
  • Input: n = 2, source = 'A', auxiliary = 'B', destination = 'C'
  • Output:
    Move disk 1 from A to B
    Move disk 2 from A to C
    Move disk 1 from B to C
    
  • Explanation: The two disks are moved from rod A to rod C using rod B as auxiliary, following the rules of the Tower of Hanoi.

Pseudocode

FUNCTION towerOfHanoi(n, source, auxiliary, destination)
    IF n equals 0 THEN
        RETURN
    ENDIF
    IF n equals 1 THEN
        PRINT "Move disk 1 from source to destination"
        RETURN
    ENDIF
    CALL towerOfHanoi(n - 1, source, destination, auxiliary)
    PRINT "Move disk n from source to destination"
    CALL towerOfHanoi(n - 1, auxiliary, source, destination)
ENDFUNCTION

Algorithm Steps

  1. Check if the number of disks n is 0. If so, return immediately, as no moves are needed.
  2. Define a recursive function that takes the number of disks n and the names of the source, auxiliary, and destination rods as parameters.
  3. In the recursive function, implement the base case: if n equals 1, print a move to transfer the single disk from the source to the destination rod and return.
  4. For the recursive case, perform three steps: a. Recursively move n - 1 disks from the source rod to the auxiliary rod, using the destination rod as the auxiliary. b. Print a move to transfer the nth disk from the source rod to the destination rod. c. Recursively move n - 1 disks from the auxiliary rod to the destination rod, using the source rod as the auxiliary.
  5. In the main function, call the recursive function with the input n and rod names (e.g., 'A', 'B', 'C').
  6. The function prints the sequence of moves to solve the Tower of Hanoi problem.

Java Implementation

public class TowerOfHanoi {
    // Solves Tower of Hanoi for n disks and prints moves
    public void towerOfHanoi(int n, char source, char auxiliary, char destination) {
        // Base case: no disks to move
        if (n == 0) {
            return;
        }
        // Base case: single disk, move directly
        if (n == 1) {
            System.out.println("Move disk 1 from " + source + " to " + destination);
            return;
        }
        // Recursive case: move n-1 disks to auxiliary rod
        towerOfHanoi(n - 1, source, destination, auxiliary);
        // Move nth disk to destination rod
        System.out.println("Move disk " + n + " from " + source + " to " + destination);
        // Move n-1 disks from auxiliary to destination rod
        towerOfHanoi(n - 1, auxiliary, source, destination);
    }
}

Output

For the input n = 2, source = 'A', auxiliary = 'B', destination = 'C', the program outputs:

Move disk 1 from A to B
Move disk 2 from A to C
Move disk 1 from B to C

Explanation: The two disks are moved from rod A to rod C using rod B as auxiliary, following the Tower of Hanoi rules.

For the input n = 1, source = 'A', auxiliary = 'B', destination = 'C', the program outputs:

Move disk 1 from A to C

Explanation: A single disk is moved directly from rod A to rod C.

How It Works

  • Step 1: The towerOfHanoi method checks if n is 0. For n = 2, it proceeds to the recursive case.
  • Step 2: For n = 2:
    • First recursive call: towerOfHanoi(1, A, C, B):
      • Base case: n = 1, prints "Move disk 1 from A to B".
    • Print: "Move disk 2 from A to C".
    • Second recursive call: towerOfHanoi(1, B, A, C):
      • Base case: n = 1, prints "Move disk 1 from B to C".
  • Step 3: The recursion generates the sequence of moves to transfer all disks from source to destination while respecting the rules.
  • Example Trace: For n = 2:
    • Move disk 1 from A to B (move top disk to auxiliary).
    • Move disk 2 from A to C (move largest disk to destination).
    • Move disk 1 from B to C (move top disk to destination).
  • Testing with Different n: For n = 1, it prints one move. For n = 3, it generates 7 moves (2^3 - 1), following the same recursive pattern.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive CallO(2^n)O(n)
Print OperationO(1)O(1)
Full AlgorithmO(2^n)O(n)

Note:

  • n is the number of disks.
  • Time complexity: O(2^n), as the algorithm makes 2^n - 1 moves, with each move involving a constant-time print operation.
  • Space complexity: O(n) due to the recursive call stack, which grows linearly with the number of disks.
  • Best, average, and worst cases are all O(2^n), as the number of moves is fixed for a given n.

✅ Tip: Use the Tower of Hanoi to understand recursive problem-solving, and test with small values of n (e.g., 1, 2, 3) to visualize the move sequence. Count the number of moves to confirm it matches 2^n - 1.

⚠ Warning: Avoid large values of n (e.g., >10), as the O(2^n) time complexity leads to an exponential number of moves, causing significant delays. Ensure rod names are distinct to avoid confusion in output.

Fibonacci Optimization

Problem Statement

Write a Java program that computes the nth Fibonacci number using a recursive approach with memoization (using a HashMap to cache results) and an iterative approach. The program should return the nth Fibonacci number and compare the performance of both methods for large inputs. Test the program with various inputs, including edge cases like (n = 0) and (n = 1). You can visualize this as calculating the number of rabbits in a population after (n) months, where each month’s population depends on the previous two, with memoization or iteration to optimize the process.

Input: A non-negative integer (n) (e.g., (n = 6)). Output: A long integer representing the nth Fibonacci number (e.g., 8 for (n = 6)). Constraints:

  • (0 \leq n \leq 50) (to avoid overflow with long).
  • The input is a non-negative integer. Example:
  • Input: (n = 6)
  • Output: 8
  • Explanation: The Fibonacci sequence is 0, 1, 1, 2, 3, 5, 8, ..., so the 6th Fibonacci number is 8.
  • Input: (n = 0)
  • Output: 0
  • Explanation: The 0th Fibonacci number is defined as 0.

Pseudocode

FUNCTION fibonacciMemo(n, memo)
    IF n < 0 THEN
        RETURN -1
    ENDIF
    IF n equals 0 THEN
        RETURN 0
    ENDIF
    IF n equals 1 THEN
        RETURN 1
    ENDIF
    IF n exists in memo THEN
        RETURN memo[n]
    ENDIF
    SET result to fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo)
    SET memo[n] to result
    RETURN result
ENDFUNCTION

FUNCTION fibonacciIterative(n)
    IF n < 0 THEN
        RETURN -1
    ENDIF
    IF n equals 0 THEN
        RETURN 0
    ENDIF
    IF n equals 1 THEN
        RETURN 1
    ENDIF
    SET prev to 0
    SET current to 1
    FOR i from 2 to n
        SET next to prev + current
        SET prev to current
        SET current to next
    ENDFOR
    RETURN current
ENDFUNCTION

FUNCTION mainFibonacci(n)
    CREATE empty HashMap memo
    CALL fibonacciMemo(n, memo)
    CALL fibonacciIterative(n)
    RETURN results
ENDFUNCTION

Algorithm Steps

  1. For the recursive approach with memoization: a. Check if (n) is negative. If so, return -1, as Fibonacci is undefined for negative numbers. b. Define a recursive helper function that takes (n) and a HashMap for memoization as parameters. c. Implement base cases: if (n = 0), return 0; if (n = 1), return 1. d. Check if the Fibonacci number for (n) is in the HashMap. If so, return the cached result. e. Compute the Fibonacci number as the sum of the results for (n - 1) and (n - 2) using recursive calls. f. Store the result in the HashMap and return it.
  2. For the iterative approach: a. Check if (n) is negative. If so, return -1. b. Implement base cases: if (n = 0), return 0; if (n = 1), return 1. c. Initialize variables prev (F(0) = 0) and current (F(1) = 1). d. Iterate from (i = 2) to (n), computing each Fibonacci number as prev + current, updating prev and current accordingly. e. Return the final Fibonacci number.
  3. In the main function, create a HashMap for memoization, call both methods, and compare their performance for large inputs.

Java Implementation

import java.util.HashMap;

public class FibonacciOptimization {
    // Computes nth Fibonacci number using recursion with memoization
    public long fibonacciMemo(int n) {
        // Check for invalid input
        if (n < 0) {
            return -1;
        }
        // Create HashMap for memoization
        HashMap<Integer, Long> memo = new HashMap<>();
        // Call recursive helper function
        return fibonacciMemoHelper(n, memo);
    }

    // Helper function for recursive Fibonacci with memoization
    private long fibonacciMemoHelper(int n, HashMap<Integer, Long> memo) {
        // Base case: F(0) = 0
        if (n == 0) {
            return 0;
        }
        // Base case: F(1) = 1
        if (n == 1) {
            return 1;
        }
        // Check if result is in memo
        if (memo.containsKey(n)) {
            return memo.get(n);
        }
        // Recursive case: F(n) = F(n-1) + F(n-2)
        long result = fibonacciMemoHelper(n - 1, memo) + fibonacciMemoHelper(n - 2, memo);
        // Cache result in memo
        memo.put(n, result);
        return result;
    }

    // Computes nth Fibonacci number using iteration
    public long fibonacciIterative(int n) {
        // Check for invalid input
        if (n < 0) {
            return -1;
        }
        // Base case: F(0) = 0
        if (n == 0) {
            return 0;
        }
        // Base case: F(1) = 1
        if (n == 1) {
            return 1;
        }
        // Initialize variables for F(0) and F(1)
        long prev = 0;
        long current = 1;
        // Iterate to compute F(n)
        for (int i = 2; i <= n; i++) {
            long next = prev + current;
            prev = current;
            current = next;
        }
        // Return nth Fibonacci number
        return current;
    }
}

Output

For the input (n = 6), both methods output:

8

Explanation: The Fibonacci sequence is 0, 1, 1, 2, 3, 5, 8, so the 6th Fibonacci number is 8.

For the input (n = 0), both methods output:

0

Explanation: The 0th Fibonacci number is defined as 0.

For the input (n = 10), both methods output:

55

Explanation: The 10th Fibonacci number is 55.

How It Works

  • Recursive Approach with Memoization:
    • Step 1: The fibonacciMemo method checks if (n < 0). For (n = 6), it creates a HashMap and calls fibonacciMemoHelper.
    • Step 2: In fibonacciMemoHelper:
      • For (n = 6): Not in memo, compute F(5) + F(4).
      • For (n = 5): Not in memo, compute F(4) + F(3).
      • For (n = 4): Not in memo, compute F(3) + F(2).
      • For (n = 3): Not in memo, compute F(2) + F(1).
      • For (n = 2): Not in memo, compute F(1) + F(0) = 1 + 0 = 1.
      • For (n = 1): Base case, return 1.
      • For (n = 0): Base case, return 0.
      • Unwind: Cache F(2) = 1, F(3) = 2, F(4) = 3, F(5) = 5, F(6) = 8.
    • Example Trace: For (n = 6), computes F(6) = F(5) + F(4) = 5 + 3 = 8, caching each result.
  • Iterative Approach:
    • Step 1: The fibonacciIterative method checks if (n < 0). For (n = 6), initializes prev = 0, current = 1.
    • Step 2: Iterates from (i = 2) to 6:
      • (i = 2): next = 0 + 1 = 1, prev = 1, current = 1.
      • (i = 3): next = 1 + 1 = 2, prev = 1, current = 2.
      • (i = 4): next = 1 + 2 = 3, prev = 2, current = 3.
      • (i = 5): next = 2 + 3 = 5, prev = 3, current = 5.
      • (i = 6): next = 3 + 5 = 8, prev = 5, current = 8.
    • Example Trace: For (n = 6), computes 0, 1, 1, 2, 3, 5, 8, returning 8.
  • Performance Comparison: Memoized recursive is O(n) time, much faster than O(2^n) for naive recursion, and comparable to iterative O(n) time. Iterative uses O(1) space, while memoized uses O(n) space for the HashMap and call stack, making iterative more efficient for very large (n).

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive Call (Memoized)O(n)O(n)
IterationO(n)O(1)
Full Algorithm (Memoized)O(n)O(n)
Full Algorithm (Iterative)O(n)O(1)

Note:

  • n is the input integer.
  • Time complexity: O(n) for memoized recursive, as each Fibonacci number is computed once and cached; O(n) for iterative, as it performs n iterations.
  • Space complexity: O(n) for memoized recursive due to the HashMap and call stack; O(1) for iterative, using only a few variables.
  • Best, average, and worst cases are O(n) for both.

✅ Tip: Use the iterative approach for large (n) to minimize space usage, or memoization for recursive solutions to avoid exponential time complexity. Test with large inputs (e.g., (n = 50)) to compare performance and verify results.

⚠ Warning: Without memoization, recursive Fibonacci has O(2^n) time complexity, making it impractical for large (n). Ensure inputs are within constraints ((n \leq 50)) to avoid overflow with long.

Linked List Traversal

Problem Statement

Write a Java program that implements both recursive and iterative methods to traverse and print the elements of a singly linked list. The program should take a singly linked list as input and print each element in order, from the head to the tail. Test both approaches with lists of varying sizes, including empty lists and single-node lists, and discuss their trade-offs in terms of readability, performance, and memory usage. You can visualize this as walking through a chain of boxes, each containing a number, and reading the numbers either by stepping through each box (iterative) or by recursively passing to the next box (recursive).

Input: A singly linked list with integer nodes (e.g., 1 -> 2 -> 3 -> null). Output: The elements of the list printed in order, each on a new line (e.g., 1, 2, 3). Constraints:

  • The list contains 0 to 10^5 nodes.
  • Node values are integers between -10^9 and 10^9.
  • The list may be empty. Example:
  • Input: Linked list 1 -> 2 -> 3 -> null
  • Output:
    1
    2
    3
    
  • Explanation: The elements 1, 2, and 3 are printed in order.
  • Input: Empty linked list null
  • Output: (no output)
  • Explanation: An empty list has no elements to print.

Pseudocode

FUNCTION recursivePrint(node)
    IF node is null THEN
        RETURN
    ENDIF
    PRINT node.value
    CALL recursivePrint(node.next)
ENDFUNCTION

FUNCTION iterativePrint(node)
    SET current to node
    WHILE current is not null
        PRINT current.value
        SET current to current.next
    ENDWHILE
ENDFUNCTION

FUNCTION mainPrint(head)
    CALL recursivePrint(head)
    CALL iterativePrint(head)
ENDFUNCTION

Algorithm Steps

  1. For the recursive approach: a. Check if the current node is null. If so, return, as there are no more elements to print. b. Define a recursive function that takes the current node as a parameter. c. Print the value of the current node. d. Recursively call the function on the next node in the list.
  2. For the iterative approach: a. Initialize a pointer current to the head of the list. b. While current is not null, print the value of current and move current to the next node.
  3. In the main function, call both the recursive and iterative methods to print the list’s elements.
  4. Discuss trade-offs: recursive is more concise but uses more memory due to the call stack; iterative is less readable but more space-efficient.

Java Implementation

public class LinkedListTraversal {
    // Node class for singly linked list
    class Node {
        int data;
        Node next;
        Node(int data) {
            this.data = data;
            this.next = null;
        }
    }

    // Prints linked list using recursion
    public void recursivePrint(Node head) {
        // Base case: if node is null, stop
        if (head == null) {
            return;
        }
        // Print current node's data
        System.out.println(head.data);
        // Recursive call for next node
        recursivePrint(head.next);
    }

    // Prints linked list using iteration
    public void iterativePrint(Node head) {
        // Start at head
        Node current = head;
        // Iterate until null
        while (current != null) {
            // Print current node's data
            System.out.println(current.data);
            // Move to next node
            current = current.next;
        }
    }
}

Output

For the input linked list 1 -> 2 -> 3 -> null, both methods output:

1
2
3

Explanation: The elements 1, 2, and 3 are printed in order from head to tail.

For an empty linked list null, both methods output:

(nothing printed)

Explanation: An empty list has no elements to print.

For a single-node linked list 4 -> null, both methods output:

4

Explanation: The single node’s value 4 is printed.

How It Works

  • Recursive Approach:
    • Step 1: The recursivePrint method checks if the head is null. For 1 -> 2 -> 3 -> null, it proceeds.
    • Step 2: Recursion:
      • For node 1: Print 1, recurse on node 2.
      • For node 2: Print 2, recurse on node 3.
      • For node 3: Print 3, recurse on null.
      • For null: Base case, return.
    • Example Trace: For 1 -> 2 -> 3 -> null, prints 1, then 2, then 3.
  • Iterative Approach:
    • Step 1: The iterativePrint method initializes current to the head (1).
    • Step 2: Iterates:
      • current = 1: Print 1, move to 2.
      • current = 2: Print 2, move to 3.
      • current = 3: Print 3, move to null.
      • current = null: Stop.
    • Example Trace: For 1 -> 2 -> 3 -> null, prints 1, 2, 3 in sequence.
  • Trade-offs:
    • Readability: Recursive is more concise and elegant, mirroring the list’s structure, but may be harder for beginners to grasp.
    • Performance: Both have O(n) time, but recursive uses O(n) space due to the call stack, while iterative uses O(1) space, making it more efficient for large lists.
    • Memory: Recursive risks stack overflow for very large lists; iterative is safer.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive CallO(n)O(n)
IterationO(n)O(1)
Full Algorithm (Recursive)O(n)O(n)
Full Algorithm (Iterative)O(n)O(1)

Note:

  • n is the number of nodes in the linked list.
  • Time complexity: O(n) for both, as each node is visited exactly once.
  • Space complexity: O(n) for recursive due to the call stack; O(1) for iterative, using only a single pointer.
  • Best, average, and worst cases are O(n) for both.

✅ Tip: Use the iterative approach for large linked lists to avoid stack overflow and minimize memory usage. Test with empty lists and long lists to verify correctness and compare performance.

⚠ Warning: The recursive approach may cause stack overflow for very large lists (e.g., 10^5 nodes) due to O(n) space complexity. Ensure the list is properly constructed to avoid null pointer exceptions.

Power Function

Problem Statement

Write a Java program that implements both recursive and iterative methods to compute (x^n), where (x) is a double and (n) is a non-negative integer representing the power. The program should return the result of (x) raised to the power (n) and test the methods with different inputs, including edge cases like (n = 0) and (n = 1). Analyze the space complexity of both approaches. You can visualize this as calculating the total area of a square piece of land ((x)) that is scaled by itself (n) times, either by repeatedly multiplying (iterative) or by breaking it into smaller multiplications (recursive).

Input: A double (x) and a non-negative integer (n) (e.g., (x = 2.0, n = 3)). Output: A double representing (x^n) (e.g., 8.0 for (2.0^3)). Constraints:

  • (-100.0 \leq x \leq 100.0).
  • (0 \leq n \leq 10^3).
  • The result should fit within a double’s precision. Example:
  • Input: (x = 2.0, n = 3)
  • Output: 8.0
  • Explanation: (2.0^3 = 2.0 * 2.0 * 2.0 = 8.0).
  • Input: (x = 5.0, n = 0)
  • Output: 1.0
  • Explanation: Any non-zero number raised to the power 0 is 1.

Pseudocode

FUNCTION recursivePower(x, n)
    IF n equals 0 THEN
        RETURN 1.0
    ENDIF
    IF n equals 1 THEN
        RETURN x
    ENDIF
    RETURN x * recursivePower(x, n - 1)
ENDFUNCTION

FUNCTION iterativePower(x, n)
    IF n equals 0 THEN
        RETURN 1.0
    ENDIF
    SET result to 1.0
    FOR i from 1 to n
        SET result to result * x
    ENDFOR
    RETURN result
ENDFUNCTION

Algorithm Steps

  1. For the recursive approach: a. Check if (n) is 0. If so, return 1.0, as any non-zero number raised to the power 0 is 1. b. Check if (n) is 1. If so, return (x), as (x^1 = x). c. Define a recursive function that multiplies (x) by the result of the recursive call for (n - 1). d. Return the computed result.
  2. For the iterative approach: a. Check if (n) is 0. If so, return 1.0. b. Initialize a variable result to 1.0. c. Iterate (n) times, multiplying result by (x) in each iteration. d. Return the final result.
  3. Test both methods with various inputs (e.g., (n = 0, 1, 5)) and analyze space complexity.

Java Implementation

public class PowerFunction {
    // Computes x^n using recursion
    public double recursivePower(double x, int n) {
        // Base case: x^0 = 1
        if (n == 0) {
            return 1.0;
        }
        // Base case: x^1 = x
        if (n == 1) {
            return x;
        }
        // Recursive case: x * x^(n-1)
        return x * recursivePower(x, n - 1);
    }

    // Computes x^n using iteration
    public double iterativePower(double x, int n) {
        // Base case: x^0 = 1
        if (n == 0) {
            return 1.0;
        }
        // Initialize result
        double result = 1.0;
        // Multiply x by itself n times
        for (int i = 1; i <= n; i++) {
            result *= x;
        }
        // Return final result
        return result;
    }
}

Output

For the input (x = 2.0, n = 3), both methods output:

8.0

Explanation: (2.0^3 = 2.0 * 2.0 * 2.0 = 8.0).

For the input (x = 5.0, n = 0), both methods output:

1.0

Explanation: Any non-zero number raised to the power 0 is 1.

For the input (x = 3.0, n = 2), both methods output:

9.0

Explanation: (3.0^2 = 3.0 * 3.0 = 9.0).

How It Works

  • Recursive Approach:
    • Step 1: The recursivePower method checks if (n = 0). For (x = 2.0, n = 3), it proceeds to recursion.
    • Step 2: Recursion:
      • For (n = 3): Returns (2.0 * recursivePower(2.0, 2)).
      • For (n = 2): Returns (2.0 * recursivePower(2.0, 1)).
      • For (n = 1): Base case, returns 2.0.
      • Unwind: (2.0 * (2.0 * 2.0) = 2.0 * 4.0 = 8.0).
    • Example Trace: For (x = 2.0, n = 3), computes (2.0 * (2.0 * (2.0 * 1)) = 8.0).
  • Iterative Approach:
    • Step 1: The iterativePower method checks if (n = 0). For (x = 2.0, n = 3), it initializes result = 1.0.
    • Step 2: Iterates 3 times:
      • Iteration 1: result = 1.0 * 2.0 = 2.0.
      • Iteration 2: result = 2.0 * 2.0 = 4.0.
      • Iteration 3: result = 4.0 * 2.0 = 8.0.
    • Example Trace: For (x = 2.0, n = 3), multiplies (2.0 * 2.0 * 2.0 = 8.0).
  • Space Complexity Analysis:
    • Recursive: O(n) due to the call stack, which grows linearly with (n).
    • Iterative: O(1), as it uses only a single variable for the result.
    • The iterative approach is more space-efficient, especially for large (n), while both have similar time performance for this basic implementation.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive CallO(n)O(n)
IterationO(n)O(1)
Full Algorithm (Recursive)O(n)O(n)
Full Algorithm (Iterative)O(n)O(1)

Note:

  • n is the exponent.
  • Time complexity: O(n) for both, as recursive makes n calls, and iterative performs n multiplications.
  • Space complexity: O(n) for recursive due to the call stack; O(1) for iterative, using only a single variable.
  • Best, average, and worst cases are O(n) for both.

✅ Tip: Use the iterative approach for large (n) to avoid stack overflow and minimize space usage. Test with edge cases like (n = 0), (n = 1), and large (n) (e.g., 1000) to verify correctness and compare space efficiency.

⚠ Warning: The recursive approach may cause stack overflow for very large (n) due to O(n) space complexity. Be cautious with floating-point precision for large (x) or (n), as results may exceed double’s capacity.

Recursive vs. Iterative Sum

Problem Statement

Write a Java program that computes the sum of all elements in an array of integers using both recursive and iterative approaches. The program should return the total sum and allow performance comparison between the two methods for large arrays. Test the program with arrays of different sizes, including empty arrays and single-element arrays. You can visualize this as calculating the total cost of items in a shopping list, either by adding items one by one (iterative) or by breaking the list into smaller parts and combining their sums (recursive).

Input: An array of integers (e.g., arr = [1, 2, 3, 4, 5]). Output: An integer representing the sum of all elements in the array (e.g., 15 for [1, 2, 3, 4, 5]). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array may be empty. Example:
  • Input: arr = [1, 2, 3, 4, 5]
  • Output: 15
  • Explanation: The sum is calculated as 1 + 2 + 3 + 4 + 5 = 15.
  • Input: arr = []
  • Output: 0
  • Explanation: The sum of an empty array is 0.

Pseudocode

FUNCTION recursiveSum(arr, index)
    IF arr is null OR index equals length of arr THEN
        RETURN 0
    ENDIF
    RETURN arr[index] + recursiveSum(arr, index + 1)
ENDFUNCTION

FUNCTION iterativeSum(arr)
    IF arr is null OR length of arr equals 0 THEN
        RETURN 0
    ENDIF
    SET sum to 0
    FOR each index i from 0 to length of arr - 1
        SET sum to sum + arr[i]
    ENDFOR
    RETURN sum
ENDFUNCTION

FUNCTION mainSum(arr)
    CALL recursiveSum(arr, 0)
    CALL iterativeSum(arr)
    RETURN results
ENDFUNCTION

Algorithm Steps

  1. For the recursive approach: a. Check if the array is null or the current index equals the array length. If so, return 0. b. Define a recursive helper function that takes the array and the current index as parameters. c. In the recursive function, add the element at the current index to the result of the recursive call for the next index. d. Call the recursive function with the initial index set to 0.
  2. For the iterative approach: a. Check if the array is null or empty. If so, return 0. b. Initialize a variable to store the sum. c. Iterate through the array from index 0 to the last index, adding each element to the sum. d. Return the final sum.
  3. In the main function, call both the recursive and iterative methods to compute the sum.
  4. Return the results for comparison, ideally measuring execution time for large arrays.

Java Implementation

public class RecursiveVsIterativeSum {
    // Computes sum of array elements using recursion
    public long recursiveSum(int[] arr) {
        // Check for null array
        if (arr == null) {
            return 0;
        }
        // Call recursive helper with initial index 0
        return recursiveSumHelper(arr, 0);
    }

    // Helper function for recursive sum
    private long recursiveSumHelper(int[] arr, int index) {
        // Base case: if index reaches array length, return 0
        if (index == arr.length) {
            return 0;
        }
        // Recursive case: add current element to sum of remaining elements
        return arr[index] + recursiveSumHelper(arr, index + 1);
    }

    // Computes sum of array elements using iteration
    public long iterativeSum(int[] arr) {
        // Check for null or empty array
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // Initialize sum
        long sum = 0;
        // Iterate through array and add each element
        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }
        // Return final sum
        return sum;
    }
}

Output

For the input array [1, 2, 3, 4, 5], both methods output:

15

Explanation: The sum is computed as 1 + 2 + 3 + 4 + 5 = 15.

For an empty array [], both methods output:

0

Explanation: The sum of an empty array is 0.

For a single-element array [7], both methods output:

7

Explanation: The sum of a single-element array is the element itself.

How It Works

  • Recursive Approach:
    • Step 1: The recursiveSum method checks if the array is null. For [1, 2, 3, 4, 5], it calls recursiveSumHelper with index 0.
    • Step 2: In recursiveSumHelper:
      • Index 0: arr[0] = 1, recurse with index 1.
      • Index 1: arr[1] = 2, recurse with index 2.
      • Index 2: arr[2] = 3, recurse with index 3.
      • Index 3: arr[3] = 4, recurse with index 4.
      • Index 4: arr[4] = 5, recurse with index 5.
      • Index 5: Base case, return 0.
      • Unwind: 5 + (4 + (3 + (2 + (1 + 0)))) = 15.
    • Example Trace: For [1, 2, 3, 4, 5], computes 1 + (2 + (3 + (4 + (5 + 0)))) = 15.
  • Iterative Approach:
    • Step 1: The iterativeSum method checks if the array is null or empty. For [1, 2, 3, 4, 5], it initializes sum = 0.
    • Step 2: Iterates: adds 1, 2, 3, 4, 5, resulting in sum = 15.
    • Example Trace: For [1, 2, 3, 4, 5], adds 1 + 2 + 3 + 4 + 5 = 15.
  • Performance Comparison: For large arrays (e.g., 10^5 elements), the iterative approach is generally faster due to O(1) space complexity, while the recursive approach uses O(n) stack space, potentially causing stack overflow for very large arrays.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive CallO(n)O(n)
IterationO(n)O(1)
Full Algorithm (Recursive)O(n)O(n)
Full Algorithm (Iterative)O(n)O(1)

Note:

  • n is the size of the input array.
  • Time complexity: O(n) for both approaches, as each element is processed exactly once.
  • Space complexity: O(n) for recursive due to the call stack; O(1) for iterative, using only a single sum variable.
  • Best, average, and worst cases are O(n) for both, as all elements are summed.

✅ Tip: Use the iterative approach for large arrays to avoid stack overflow and reduce memory usage. Test both methods with large arrays (e.g., 10^4, 10^5 elements) to compare execution times and verify identical results.

⚠ Warning: The recursive approach may cause stack overflow for very large arrays due to O(n) space complexity. Ensure inputs are within reasonable bounds (e.g., n ≤ 10^5) to prevent performance issues.

Reverse String Comparison

Problem Statement

Write a Java program that implements string reversal using both recursive and iterative approaches. The program should take a string as input and return the reversed string. Test the program with various strings, including empty strings, single-character strings, and long strings, and compare the code readability and execution time of both approaches. You can visualize this as rearranging the letters of a word on a signboard, either by recursively breaking it into smaller parts or by iteratively swapping characters from the ends toward the center.

Input: A string (e.g., str = "hello"). Output: A string representing the reversed input (e.g., "olleh"). Constraints:

  • The string length is between 0 and 10^5.
  • The string contains printable ASCII characters.
  • The string may be empty. Example:
  • Input: str = "hello"
  • Output: "olleh"
  • Explanation: The string "hello" is reversed to "olleh" by rearranging its characters.
  • Input: str = ""
  • Output: ""
  • Explanation: An empty string remains empty after reversal.

Pseudocode

FUNCTION recursiveReverse(str)
    IF str is null OR length of str <= 1 THEN
        RETURN str
    ENDIF
    SET lastChar to character at index (length of str - 1)
    SET substring to str from index 0 to (length of str - 1)
    RETURN lastChar + recursiveReverse(substring)
ENDFUNCTION

FUNCTION iterativeReverse(str)
    IF str is null OR length of str <= 1 THEN
        RETURN str
    ENDIF
    CONVERT str to array of characters
    SET left to 0
    SET right to length of array - 1
    WHILE left < right
        SWAP characters at left and right
        INCREMENT left
        DECREMENT right
    ENDWHILE
    RETURN array converted to string
ENDFUNCTION

Algorithm Steps

  1. For the recursive approach: a. Check if the input string is null, empty, or has one character. If so, return the string as is. b. Define a recursive function that takes the input string as a parameter. c. Extract the last character of the string and recursively call the function on the substring excluding the last character. d. Combine the last character with the result of the recursive call to build the reversed string. e. Return the reversed string.
  2. For the iterative approach: a. Check if the input string is null, empty, or has one character. If so, return the string as is. b. Convert the string to a character array for in-place manipulation. c. Initialize two pointers: left at the start (index 0) and right at the end (index length - 1). d. While left is less than right, swap the characters at left and right, increment left, and decrement right. e. Convert the character array back to a string and return it.
  3. Test both methods with various inputs and measure execution time for large strings to compare performance.

Java Implementation

public class ReverseStringComparison {
    // Reverses a string using recursion
    public String recursiveReverse(String str) {
        // Check for null, empty, or single-character string
        if (str == null || str.length() <= 1) {
            return str;
        }
        // Recursive case: last character + reverse of remaining string
        return str.charAt(str.length() - 1) + recursiveReverse(str.substring(0, str.length() - 1));
    }

    // Reverses a string using iteration
    public String iterativeReverse(String str) {
        // Check for null, empty, or single-character string
        if (str == null || str.length() <= 1) {
            return str;
        }
        // Convert string to char array
        char[] charArray = str.toCharArray();
        int left = 0;
        int right = charArray.length - 1;
        // Swap characters from ends toward center
        while (left < right) {
            char temp = charArray[left];
            charArray[left] = charArray[right];
            charArray[right] = temp;
            left++;
            right--;
        }
        // Convert char array back to string
        return new String(charArray);
    }
}

Output

For the input string "hello", both methods output:

olleh

Explanation: The string "hello" is reversed to "olleh" by rearranging its characters.

For an empty string "", both methods output:

""

Explanation: An empty string remains empty after reversal.

For a single-character string "a", both methods output:

a

Explanation: A single-character string is already reversed.

How It Works

  • Recursive Approach:
    • Step 1: The recursiveReverse method checks if the string is null, empty, or has one character. For "hello", it proceeds to recursion.
    • Step 2: Recursion:
      • For "hello": Returns 'o' + recursiveReverse("hell").
      • For "hell": Returns 'l' + recursiveReverse("hel").
      • For "hel": Returns 'l' + recursiveReverse("he").
      • For "he": Returns 'e' + recursiveReverse("h").
      • For "h": Base case, returns "h".
      • Unwind: 'h' + 'e' + 'l' + 'l' + 'o' = "olleh".
    • Example Trace: For "hello", computes 'o' + ('l' + ('l' + ('e' + 'h'))) = "olleh".
  • Iterative Approach:
    • Step 1: The iterativeReverse method checks if the string is null, empty, or has one character. For "hello", it converts to ['h', 'e', 'l', 'l', 'o'].
    • Step 2: Initializes left = 0, right = 4. Swaps:
      • Swap h and o: ['o', 'e', 'l', 'l', 'h'], left = 1, right = 3.
      • Swap e and l: ['o', 'l', 'l', 'e', 'h'], left = 2, right = 2.
      • Stop as left >= right.
    • Step 3: Converts array to "olleh".
    • Example Trace: For "hello", swaps characters from ends: ho, el, resulting in "olleh".
  • Comparison:
    • Readability: Iterative is more straightforward, using simple swaps, while recursive is more abstract but elegant for small inputs.
    • Execution Time: Iterative is faster for large strings due to O(1) space complexity, while recursive uses O(n) space and creates substrings, increasing overhead.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive CallO(n)O(n)
IterationO(n)O(n)
Full Algorithm (Recursive)O(n)O(n)
Full Algorithm (Iterative)O(n)O(n)

Note:

  • n is the length of the input string.
  • Time complexity: O(n) for both, as recursive processes each character once, and iterative performs n/2 swaps.
  • Space complexity: O(n) for recursive due to call stack and substring creation; O(n) for iterative due to the character array (though in-place swapping reduces overhead in practice).
  • Best, average, and worst cases are O(n) for both.

✅ Tip: Use the iterative approach for large strings to avoid stack overflow and reduce memory usage due to substring creation in recursion. Test both methods with long strings (e.g., 10^4 characters) to compare execution times and assess readability.

⚠ Warning: The recursive approach may cause stack overflow for very large strings due to O(n) space complexity from the call stack and substring operations. Ensure inputs are within reasonable bounds to prevent performance issues.

Factorial Conversion

Problem Statement

Write a Java program that implements a tail-recursive method to compute the factorial of a non-negative integer (n) using an accumulator to track the running product, and convert this tail-recursive implementation to an iterative version. The program should return the factorial of (n) and test both methods with various inputs, including large values, while measuring stack usage by catching StackOverflowError for recursive calls. You can visualize this as calculating the number of ways to arrange (n) books on a shelf, either by recursively multiplying and passing the product forward or by iteratively accumulating the product.

Input: A non-negative integer (n) (e.g., (n = 5)). Output: A long integer representing the factorial of (n) (e.g., 120 for (n = 5)). Constraints:

  • (0 \leq n \leq 20) (to avoid overflow with long).
  • The input is a non-negative integer. Example:
  • Input: (n = 5)
  • Output: 120
  • Explanation: The factorial of 5 is (5 * 4 * 3 * 2 * 1 = 120).
  • Input: (n = 0)
  • Output: 1
  • Explanation: The factorial of 0 is defined as 1.

Pseudocode

FUNCTION tailRecursiveFactorial(n, accumulator)
    IF n < 0 THEN
        RETURN -1
    ENDIF
    IF n equals 0 THEN
        RETURN accumulator
    ENDIF
    SET newAccumulator to accumulator * n
    RETURN tailRecursiveFactorial(n - 1, newAccumulator)
ENDFUNCTION

FUNCTION iterativeFactorial(n)
    IF n < 0 THEN
        RETURN -1
    ENDIF
    IF n equals 0 THEN
        RETURN 1
    ENDIF
    SET result to 1
    FOR i from 1 to n
        SET result to result * i
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION mainFactorial(n)
    TRY
        CALL tailRecursiveFactorial(n, 1)
    CATCH StackOverflowError
        PRINT "Stack overflow occurred"
    ENDTRY
    CALL iterativeFactorial(n)
    RETURN results
ENDFUNCTION

Algorithm Steps

  1. For the tail-recursive approach: a. Check if (n) is negative. If so, return -1, as factorial is undefined for negative numbers. b. Define a tail-recursive helper function that takes (n) and an accumulator as parameters. c. Implement the base case: if (n = 0), return the accumulator. d. For the recursive case, multiply the accumulator by (n) and recursively call the function with (n - 1) and the updated accumulator. e. Call the helper function with the initial accumulator set to 1.
  2. For the iterative approach: a. Check if (n) is negative. If so, return -1. b. Check if (n = 0). If so, return 1. c. Initialize a variable result to 1. d. Iterate from 1 to (n), multiplying result by each integer. e. Return the final result.
  3. Test both methods with various inputs, including large values (e.g., (n = 20)), and wrap the tail-recursive call in a try-catch block to detect StackOverflowError.
  4. Measure stack usage by observing whether StackOverflowError occurs for large inputs in the recursive method.

Java Implementation

public class FactorialConversion {
    // Computes factorial using tail recursion
    public long tailRecursiveFactorial(int n) {
        // Check for invalid input
        if (n < 0) {
            return -1;
        }
        // Call tail-recursive helper with initial accumulator
        try {
            return tailRecursiveFactorialHelper(n, 1);
        } catch (StackOverflowError e) {
            System.out.println("Stack overflow occurred for n = " + n);
            return -1;
        }
    }

    // Helper function for tail-recursive factorial
    private long tailRecursiveFactorialHelper(int n, long accumulator) {
        // Base case: n = 0 returns accumulator
        if (n == 0) {
            return accumulator;
        }
        // Recursive case: multiply accumulator by n and recurse
        return tailRecursiveFactorialHelper(n - 1, accumulator * n);
    }

    // Computes factorial using iteration
    public long iterativeFactorial(int n) {
        // Check for invalid input
        if (n < 0) {
            return -1;
        }
        // Base case: 0! = 1
        if (n == 0) {
            return 1;
        }
        // Initialize result
        long result = 1;
        // Multiply numbers from 1 to n
        for (int i = 1; i <= n; i++) {
            result *= i;
        }
        // Return final result
        return result;
    }
}

Output

For the input (n = 5), both methods output:

120

Explanation: The factorial of 5 is (5 * 4 * 3 * 2 * 1 = 120).

For the input (n = 0), both methods output:

1

Explanation: The factorial of 0 is defined as 1.

For a large input (n = 20), both methods output:

2432902008176640000

Explanation: The factorial of 20 is computed correctly within the long range.

For a very large input (e.g., (n = 10000)), the tail-recursive method may output:

Stack overflow occurred for n = 10000
-1

Explanation: Java’s lack of tail-call optimization causes a stack overflow, while the iterative method may overflow the long data type but avoids stack issues.

How It Works

  • Tail-Recursive Approach:
    • Step 1: The tailRecursiveFactorial method checks if (n < 0). For (n = 5), it calls the helper with accumulator = 1.
    • Step 2: In tailRecursiveFactorialHelper:
      • For (n = 5): accumulator = 1 * 5 = 5, recurse with (n = 4, accumulator = 5).
      • For (n = 4): accumulator = 5 * 4 = 20, recurse with (n = 3, accumulator = 20).
      • For (n = 3): accumulator = 20 * 3 = 60, recurse with (n = 2, accumulator = 60).
      • For (n = 2): accumulator = 60 * 2 = 120, recurse with (n = 1, accumulator = 120).
      • For (n = 1): accumulator = 120 * 1 = 120, recurse with (n = 0, accumulator = 120).
      • For (n = 0): Base case, return accumulator = 120.
    • Example Trace: For (n = 5), the accumulator builds: (1 \rightarrow 5 \rightarrow 20 \rightarrow 60 \rightarrow 120), returning 120.
    • Stack Usage: The function is tail-recursive, but Java does not optimize tail recursion, so each call adds a stack frame, leading to O(n) stack usage and potential StackOverflowError for large (n).
  • Iterative Approach:
    • Step 1: The iterativeFactorial method checks if (n < 0) or (n = 0). For (n = 5), it initializes result = 1.
    • Step 2: Iterates from 1 to 5:
      • (i = 1): result = 1 * 1 = 1.
      • (i = 2): result = 1 * 2 = 2.
      • (i = 3): result = 2 * 3 = 6.
      • (i = 4): result = 6 * 4 = 24.
      • (i = 5): result = 24 * 5 = 120.
    • Example Trace: For (n = 5), multiplies (1 * 2 * 3 * 4 * 5 = 120).
    • Stack Usage: The iterative approach uses O(1) stack space, as it avoids recursive calls, making it immune to StackOverflowError.
  • Comparison: Both methods are O(n) time, but iterative is more space-efficient (O(1) vs. O(n)). For large inputs, the tail-recursive method may throw StackOverflowError, while the iterative method is limited only by long overflow.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive Call (Tail)O(n)O(n)
IterationO(n)O(1)
Full Algorithm (Tail-Recursive)O(n)O(n)
Full Algorithm (Iterative)O(n)O(1)

Note:

  • n is the input integer.
  • Time complexity: O(n) for both, as tail-recursive makes n calls, and iterative performs n multiplications.
  • Space complexity: O(n) for tail-recursive due to the call stack (Java does not optimize tail recursion); O(1) for iterative, using only a single variable.
  • Best, average, and worst cases are O(n) for both.

✅ Tip: Convert tail-recursive functions to iterative versions to eliminate stack overflow risks in Java. Test with large inputs (e.g., (n = 1000)) to observe stack overflow in the recursive version and verify correctness in the iterative version.

⚠ Warning: Java does not optimize tail recursion, so the O(n) space complexity may cause StackOverflowError for large inputs. Be cautious with inputs larger than 20, as factorial results may exceed the long data type’s capacity, causing overflow.

Reverse String Tail Recursion

Problem Statement

Write a Java program that implements a tail-recursive method to reverse a string using an accumulator to build the result. The program should take a string as input and return the reversed string. Test the method with various strings, including empty strings, single-character strings, and long strings, and discuss Java’s stack usage for the tail-recursive approach. You can visualize this as rearranging the letters of a word on a signboard, building the reversed word step-by-step by passing the accumulated result to the next recursive call.

Input: A string (e.g., str = "hello"). Output: A string representing the reversed input (e.g., "olleh"). Constraints:

  • The string length is between 0 and 10^5.
  • The string contains printable ASCII characters.
  • The string may be empty. Example:
  • Input: str = "hello"
  • Output: "olleh"
  • Explanation: The string "hello" is reversed to "olleh" by rearranging its characters.
  • Input: str = ""
  • Output: ""
  • Explanation: An empty string remains empty after reversal.

Pseudocode

FUNCTION tailRecursiveReverse(str, index, accumulator)
    IF str is null OR index < 0 THEN
        RETURN accumulator
    ENDIF
    SET newAccumulator to character at index in str + accumulator
    RETURN tailRecursiveReverse(str, index - 1, newAccumulator)
ENDFUNCTION

FUNCTION mainReverse(str)
    IF str is null OR length of str equals 0 THEN
        RETURN str
    ENDIF
    RETURN tailRecursiveReverse(str, length of str - 1, "")
ENDFUNCTION

Algorithm Steps

  1. Check if the input string is null or empty. If so, return the string as is.
  2. Define a tail-recursive helper function that takes the string, the current index, and an accumulator as parameters.
  3. In the helper function, implement the base case: if the index is less than 0, return the accumulator.
  4. For the recursive case, prepend the character at the current index to the accumulator and recursively call the function with the previous index and the updated accumulator.
  5. In the main function, call the tail-recursive function with the initial index set to the last index of the string (length - 1) and an empty string as the accumulator.
  6. Return the reversed string computed by the tail-recursive function.

Java Implementation

public class ReverseStringTailRecursion {
    // Reverses a string using tail recursion
    public String reverse(String str) {
        // Check for null or empty string
        if (str == null || str.length() == 0) {
            return str;
        }
        // Call tail-recursive helper with initial index and accumulator
        return tailRecursiveReverse(str, str.length() - 1, "");
    }

    // Helper function for tail-recursive string reversal
    private String tailRecursiveReverse(String str, int index, String accumulator) {
        // Base case: if index < 0, return accumulator
        if (index < 0) {
            return accumulator;
        }
        // Recursive case: prepend current character to accumulator and recurse
        return tailRecursiveReverse(str, index - 1, str.charAt(index) + accumulator);
    }
}

Output

For the input string "hello", the program outputs:

olleh

Explanation: The string "hello" is reversed to "olleh" by building the result in the accumulator.

For an empty string "", the program outputs:

""

Explanation: An empty string remains empty after reversal.

For a single-character string "a", the program outputs:

a

Explanation: A single-character string is already reversed.

How It Works

  • Step 1: The reverse method checks if the string is null or empty. For "hello", it calls tailRecursiveReverse with index = 4 and accumulator = "".
  • Step 2: In tailRecursiveReverse:
    • Index 4: accumulator = 'o' + "" = "o", recurse with index = 3, accumulator = "o".
    • Index 3: accumulator = 'l' + "o" = "lo", recurse with index = 2, accumulator = "lo".
    • Index 2: accumulator = 'l' + "lo" = "llo", recurse with index = 1, accumulator = "llo".
    • Index 1: accumulator = 'e' + "llo" = "ello", recurse with index = 0, accumulator = "ello".
    • Index 0: accumulator = 'h' + "ello" = "hello", recurse with index = -1, accumulator = "hello".
    • Index -1: Base case, return accumulator = "hello".
  • Example Trace: For "hello", the accumulator builds: "" → "o" → "lo" → "llo" → "ello" → "hello", returning "olleh".
  • Java Stack Usage: The function is tail-recursive because the recursive call is the last operation. However, Java does not optimize tail recursion, so each recursive call adds a frame to the call stack, leading to O(n) space complexity. This can cause stack overflow for very large strings (e.g., 10^5 characters). An iterative approach would use O(1) space (excluding the output string).

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive Call (Tail)O(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the input string.
  • Time complexity: O(n), as the function processes each character exactly once.
  • Space complexity: O(n) due to the recursive call stack (Java does not optimize tail recursion) and the accumulator string, which grows with each call.
  • Best, average, and worst cases are O(n).

✅ Tip: Use tail recursion to write elegant recursive solutions for problems like string reversal, but consider iterative methods for better space efficiency in Java. Test with long strings (e.g., 10^4 characters) and edge cases like empty or single-character strings to verify correctness.

⚠ Warning: Java does not optimize tail recursion, so the O(n) space complexity may cause stack overflow for very large strings. Additionally, string concatenation in the accumulator can be inefficient; consider using StringBuilder in an iterative approach for better performance.

Tail-Recursive List Length

Problem Statement

Write a Java program that computes the length of a singly linked list using a tail-recursive method with an accumulator to track the count of nodes. The program should also implement an iterative method to compute the length for comparison. Test both approaches with lists of varying sizes, including empty lists and single-node lists, and compare their readability and performance. You can visualize this as counting the number of links in a chain, either by recursively passing the count to the next link (tail-recursive) or by iteratively stepping through each link (iterative).

Input: A singly linked list with integer nodes (e.g., 1 -> 2 -> 3 -> null). Output: An integer representing the number of nodes in the list (e.g., 3 for 1 -> 2 -> 3 -> null). Constraints:

  • The list contains 0 to 10^5 nodes.
  • Node values are integers between -10^9 and 10^9.
  • The list may be empty. Example:
  • Input: Linked list 1 -> 2 -> 3 -> null
  • Output: 3
  • Explanation: The list has 3 nodes.
  • Input: Empty linked list null
  • Output: 0
  • Explanation: An empty list has no nodes.

Pseudocode

FUNCTION tailRecursiveLength(node, accumulator)
    IF node is null THEN
        RETURN accumulator
    ENDIF
    SET newAccumulator to accumulator + 1
    RETURN tailRecursiveLength(node.next, newAccumulator)
ENDFUNCTION

FUNCTION iterativeLength(node)
    SET count to 0
    SET current to node
    WHILE current is not null
        INCREMENT count
        SET current to current.next
    ENDWHILE
    RETURN count
ENDFUNCTION

FUNCTION mainLength(head)
    CALL tailRecursiveLength(head, 0)
    CALL iterativeLength(head)
    RETURN results
ENDFUNCTION

Algorithm Steps

  1. For the tail-recursive approach: a. Define a tail-recursive helper function that takes the current node and an accumulator as parameters. b. Implement the base case: if the node is null, return the accumulator. c. For the recursive case, increment the accumulator by 1 and recursively call the function with the next node and the updated accumulator. d. Call the helper function with the head node and an initial accumulator of 0.
  2. For the iterative approach: a. Initialize a counter to 0 and a pointer current to the head of the list. b. While current is not null, increment the counter and move current to the next node. c. Return the counter as the list length.
  3. In the main function, call both the tail-recursive and iterative methods to compute the list length.
  4. Compare readability (recursive is more concise but abstract; iterative is more explicit) and performance (iterative is more space-efficient).

Java Implementation

public class TailRecursiveListLength {
    // Node class for singly linked list
    class Node {
        int data;
        Node next;
        Node(int data) {
            this.data = data;
            this.next = null;
        }
    }

    // Computes list length using tail recursion
    public int tailRecursiveLength(Node head) {
        // Call tail-recursive helper with initial accumulator
        return tailRecursiveLengthHelper(head, 0);
    }

    // Helper function for tail-recursive length
    private int tailRecursiveLengthHelper(Node node, int accumulator) {
        // Base case: if node is null, return accumulator
        if (node == null) {
            return accumulator;
        }
        // Recursive case: increment accumulator and recurse
        return tailRecursiveLengthHelper(node.next, accumulator + 1);
    }

    // Computes list length using iteration
    public int iterativeLength(Node head) {
        // Initialize counter and current pointer
        int count = 0;
        Node current = head;
        // Iterate until null
        while (current != null) {
            count++;
            current = current.next;
        }
        // Return final count
        return count;
    }
}

Output

For the input linked list 1 -> 2 -> 3 -> null, both methods output:

3

Explanation: The list has 3 nodes.

For an empty linked list null, both methods output:

0

Explanation: An empty list has no nodes.

For a single-node linked list 4 -> null, both methods output:

1

Explanation: The list has 1 node.

How It Works

  • Tail-Recursive Approach:
    • Step 1: The tailRecursiveLength method calls the helper with head and accumulator = 0.
    • Step 2: In tailRecursiveLengthHelper:
      • For node 1: accumulator = 0 + 1 = 1, recurse with node = 2, accumulator = 1.
      • For node 2: accumulator = 1 + 1 = 2, recurse with node = 3, accumulator = 2.
      • For node 3: accumulator = 2 + 1 = 3, recurse with node = null, accumulator = 3.
      • For null: Base case, return accumulator = 3.
    • Example Trace: For 1 -> 2 -> 3 -> null, the accumulator builds: 0 → 1 → 2 → 3, returning 3.
    • Tail Recursion Note: The function is tail-recursive because the recursive call is the last operation, though Java does not optimize tail recursion.
  • Iterative Approach:
    • Step 1: The iterativeLength method initializes count = 0, current = head.
    • Step 2: Iterates:
      • current = 1: count = 1, move to 2.
      • current = 2: count = 2, move to 3.
      • current = 3: count = 3, move to null.
      • current = null: Return count = 3.
    • Example Trace: For 1 -> 2 -> 3 -> null, counts nodes: 1 → 2 → 3, returning 3.
  • Comparison:
    • Readability: Tail-recursive is concise and elegant but may be less intuitive for beginners; iterative is straightforward and explicit.
    • Performance: Both are O(n) time, but iterative uses O(1) space, while tail-recursive uses O(n) space due to the call stack in Java.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive Call (Tail)O(n)O(n)
IterationO(n)O(1)
Full Algorithm (Tail-Recursive)O(n)O(n)
Full Algorithm (Iterative)O(n)O(1)

Note:

  • n is the number of nodes in the linked list.
  • Time complexity: O(n) for both, as each node is visited exactly once.
  • Space complexity: O(n) for tail-recursive due to the call stack (Java does not optimize tail recursion); O(1) for iterative, using only a counter and pointer.
  • Best, average, and worst cases are O(n) for both.

✅ Tip: Use tail recursion for elegant solutions to problems like list length, but prefer the iterative approach for large lists to save memory. Test with empty lists and long lists (e.g., 10^4 nodes) to compare performance and readability.

⚠ Warning: Java does not optimize tail recursion, so the O(n) space complexity may cause stack overflow for very large lists. Ensure the list is properly constructed to avoid null pointer exceptions.

Tail-Recursive Power

Problem Statement

Write a Java program that implements a tail-recursive method to compute (x^n) (x raised to the power n) using an accumulator to track the running product. The program should also include an iterative method for comparison. Test both methods with various inputs, including edge cases like (n = 0) and (n = 1), and compare their performance and readability. You can visualize this as calculating the total growth of an investment ((x)) compounded (n) times, either by iteratively multiplying or by recursively passing the accumulated product to the next step.

Input: A double (x) and a non-negative integer (n) (e.g., (x = 2.0, n = 3)). Output: A double representing (x^n) (e.g., 8.0 for (2.0^3)). Constraints:

  • (-100.0 \leq x \leq 100.0).
  • (0 \leq n \leq 10^3).
  • The result should fit within a double’s precision. Example:
  • Input: (x = 2.0, n = 3)
  • Output: 8.0
  • Explanation: (2.0^3 = 2.0 * 2.0 * 2.0 = 8.0).
  • Input: (x = 5.0, n = 0)
  • Output: 1.0
  • Explanation: Any non-zero number raised to the power 0 is 1.

Pseudocode

FUNCTION tailRecursivePower(x, n, accumulator)
    IF n equals 0 THEN
        RETURN accumulator
    ENDIF
    SET newAccumulator to accumulator * x
    RETURN tailRecursivePower(x, n - 1, newAccumulator)
ENDFUNCTION

FUNCTION iterativePower(x, n)
    IF n equals 0 THEN
        RETURN 1.0
    ENDIF
    SET result to 1.0
    FOR i from 1 to n
        SET result to result * x
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION mainPower(x, n)
    CALL tailRecursivePower(x, n, 1.0)
    CALL iterativePower(x, n)
    RETURN results
ENDFUNCTION

Algorithm Steps

  1. For the tail-recursive approach: a. Define a tail-recursive helper function that takes (x), (n), and an accumulator as parameters. b. Implement the base case: if (n = 0), return the accumulator. c. For the recursive case, multiply the accumulator by (x) and recursively call the function with (n - 1) and the updated accumulator. d. Call the helper function with the initial accumulator set to 1.0.
  2. For the iterative approach: a. Check if (n = 0). If so, return 1.0. b. Initialize a variable result to 1.0. c. Iterate (n) times, multiplying result by (x) in each iteration. d. Return the final result.
  3. Test both methods with various inputs (e.g., (n = 0, 1, 5)) and compare performance and readability.

Java Implementation

public class TailRecursivePower {
    // Computes x^n using tail recursion
    public double tailRecursivePower(double x, int n) {
        // Call tail-recursive helper with initial accumulator
        return tailRecursivePowerHelper(x, n, 1.0);
    }

    // Helper function for tail-recursive power
    private double tailRecursivePowerHelper(double x, int n, double accumulator) {
        // Base case: x^0 = accumulator
        if (n == 0) {
            return accumulator;
        }
        // Recursive case: multiply accumulator by x and recurse
        return tailRecursivePowerHelper(x, n - 1, accumulator * x);
    }

    // Computes x^n using iteration
    public double iterativePower(double x, int n) {
        // Base case: x^0 = 1
        if (n == 0) {
            return 1.0;
        }
        // Initialize result
        double result = 1.0;
        // Multiply x by itself n times
        for (int i = 1; i <= n; i++) {
            result *= x;
        }
        // Return final result
        return result;
    }
}

Output

For the input (x = 2.0, n = 3), both methods output:

8.0

Explanation: (2.0^3 = 2.0 * 2.0 * 2.0 = 8.0).

For the input (x = 5.0, n = 0), both methods output:

1.0

Explanation: Any non-zero number raised to the power 0 is 1.

For the input (x = 3.0, n = 2), both methods output:

9.0

Explanation: (3.0^2 = 3.0 * 3.0 = 9.0).

How It Works

  • Tail-Recursive Approach:
    • Step 1: The tailRecursivePower method calls the helper with (x = 2.0, n = 3, accumulator = 1.0).
    • Step 2: In tailRecursivePowerHelper:
      • For (n = 3): accumulator = 1.0 * 2.0 = 2.0, recurse with (n = 2, accumulator = 2.0).
      • For (n = 2): accumulator = 2.0 * 2.0 = 4.0, recurse with (n = 1, accumulator = 4.0).
      • For (n = 1): accumulator = 4.0 * 2.0 = 8.0, recurse with (n = 0, accumulator = 8.0).
      • For (n = 0): Base case, return accumulator = 8.0.
    • Example Trace: For (x = 2.0, n = 3), the accumulator builds: (1.0 \rightarrow 2.0 \rightarrow 4.0 \rightarrow 8.0), returning 8.0.
    • Tail Recursion Note: The function is tail-recursive because the recursive call is the last operation, though Java does not optimize tail recursion.
  • Iterative Approach:
    • Step 1: The iterativePower method checks if (n = 0). For (x = 2.0, n = 3), it initializes result = 1.0.
    • Step 2: Iterates 3 times:
      • Iteration 1: result = 1.0 * 2.0 = 2.0.
      • Iteration 2: result = 2.0 * 2.0 = 4.0.
      • Iteration 3: result = 4.0 * 2.0 = 8.0.
    • Example Trace: For (x = 2.0, n = 3), multiplies (2.0 * 2.0 * 2.0 = 8.0).
  • Comparison:
    • Readability: Tail-recursive is concise and intuitive for recursive thinking but abstract; iterative is straightforward and explicit.
    • Performance: Both are O(n) time, but iterative uses O(1) space, while tail-recursive uses O(n) space due to the call stack in Java.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive Call (Tail)O(n)O(n)
IterationO(n)O(1)
Full Algorithm (Tail-Recursive)O(n)O(n)
Full Algorithm (Iterative)O(n)O(1)

Note:

  • n is the exponent.
  • Time complexity: O(n) for both, as tail-recursive makes n calls, and iterative performs n multiplications.
  • Space complexity: O(n) for tail-recursive due to the call stack (Java does not optimize tail recursion); O(1) for iterative, using only a single variable.
  • Best, average, and worst cases are O(n) for both.

✅ Tip: Use tail recursion for elegant recursive solutions, but prefer the iterative approach for large (n) to minimize memory usage. Test with edge cases like (n = 0, 1) and large (n) (e.g., 1000) to verify correctness and compare performance.

⚠ Warning: Java does not optimize tail recursion, so the O(n) space complexity may cause stack overflow for very large (n). Be cautious with floating-point precision for large (x) or (n), as results may exceed double’s capacity.

Tail-Recursive Sum

Problem Statement

Write a Java program that computes the sum of all elements in an array of integers using a tail-recursive function with an accumulator to track the running sum. The program should return the total sum and test the function with arrays of different sizes, including empty arrays and single-element arrays. You can visualize this as tallying the total score of a series of games, where each game’s score is added to a running total, passing the updated total to the next step recursively until all scores are processed.

Input: An array of integers (e.g., arr = [1, 2, 3, 4, 5]). Output: A long integer representing the sum of all elements in the array (e.g., 15 for [1, 2, 3, 4, 5]). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array may be empty. Example:
  • Input: arr = [1, 2, 3, 4, 5]
  • Output: 15
  • Explanation: The sum is calculated as 1 + 2 + 3 + 4 + 5 = 15.
  • Input: arr = []
  • Output: 0
  • Explanation: The sum of an empty array is 0.

Pseudocode

FUNCTION tailRecursiveSum(arr, index, accumulator)
    IF arr is null OR index equals length of arr THEN
        RETURN accumulator
    ENDIF
    SET newAccumulator to accumulator + arr[index]
    RETURN tailRecursiveSum(arr, index + 1, newAccumulator)
ENDFUNCTION

FUNCTION mainSum(arr)
    IF arr is null THEN
        RETURN 0
    ENDIF
    RETURN tailRecursiveSum(arr, 0, 0)
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null. If so, return 0.
  2. Define a tail-recursive helper function that takes the array, the current index, and an accumulator as parameters.
  3. In the helper function, implement the base case: if the index equals the array length, return the accumulator.
  4. For the recursive case, add the current element to the accumulator and recursively call the function with the next index and the updated accumulator.
  5. In the main function, call the tail-recursive function with the initial index set to 0 and the accumulator set to 0.
  6. Return the final sum computed by the tail-recursive function.

Java Implementation

public class TailRecursiveSum {
    // Computes sum of array elements using tail recursion
    public long sum(int[] arr) {
        // Check for null array
        if (arr == null) {
            return 0;
        }
        // Call tail-recursive helper with initial index and accumulator
        return tailRecursiveSum(arr, 0, 0);
    }

    // Helper function for tail-recursive sum
    private long tailRecursiveSum(int[] arr, int index, long accumulator) {
        // Base case: if index reaches array length, return accumulator
        if (index == arr.length) {
            return accumulator;
        }
        // Recursive case: update accumulator and recurse
        return tailRecursiveSum(arr, index + 1, accumulator + arr[index]);
    }
}

Output

For the input array [1, 2, 3, 4, 5], the program outputs:

15

Explanation: The sum is computed as 1 + 2 + 3 + 4 + 5 = 15.

For an empty array [], the program outputs:

0

Explanation: The sum of an empty array is 0.

For a single-element array [7], the program outputs:

7

Explanation: The sum of a single-element array is the element itself.

How It Works

  • Step 1: The sum method checks if the array is null. For [1, 2, 3, 4, 5], it calls tailRecursiveSum with index = 0 and accumulator = 0.
  • Step 2: In tailRecursiveSum:
    • Index 0: accumulator = 0 + 1 = 1, recurse with index = 1, accumulator = 1.
    • Index 1: accumulator = 1 + 2 = 3, recurse with index = 2, accumulator = 3.
    • Index 2: accumulator = 3 + 3 = 6, recurse with index = 3, accumulator = 6.
    • Index 3: accumulator = 6 + 4 = 10, recurse with index = 4, accumulator = 10.
    • Index 4: accumulator = 10 + 5 = 15, recurse with index = 5, accumulator = 15.
    • Index 5: Base case, return accumulator = 15.
  • Example Trace: For [1, 2, 3, 4, 5], the accumulator builds: 0 → 1 → 3 → 6 → 10 → 15, returning 15.
  • Tail Recursion Note: The function is tail-recursive because the recursive call is the last operation, allowing potential optimization in languages that support tail-call optimization (though Java does not natively optimize tail recursion).

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Recursive CallO(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the size of the input array.
  • Time complexity: O(n), as the function processes each element exactly once.
  • Space complexity: O(n) due to the recursive call stack, as Java does not optimize tail recursion.
  • Best, average, and worst cases are O(n), as all elements are processed.

✅ Tip: Use tail recursion with an accumulator to make recursive functions more intuitive by tracking state explicitly. Test with large arrays (e.g., 10^4 elements) and edge cases like empty or single-element arrays to ensure correctness.

⚠ Warning: Java does not optimize tail recursion, so the O(n) space complexity may lead to stack overflow for very large arrays. Consider iterative solutions for better space efficiency in such cases.

Array Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Array Reversal

Problem Statement

Write a Java program that reverses an array of integers in-place, meaning without using additional array storage beyond a few variables. The program should modify the input array such that the elements are arranged in reverse order and test the implementation with arrays of different sizes, including empty arrays and single-element arrays. You can visualize this as rearranging a row of numbered cards on a table by swapping pairs from the ends toward the center, without needing extra space to store a new row.

Input: An array of integers (e.g., arr = [1, 2, 3, 4, 5]). Output: The same array modified in-place to contain the elements in reverse order (e.g., [5, 4, 3, 2, 1]). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array may be empty. Example:
  • Input: arr = [1, 2, 3, 4, 5]
  • Output: [5, 4, 3, 2, 1]
  • Explanation: The array is reversed in-place by swapping elements from the ends toward the center.
  • Input: arr = []
  • Output: []
  • Explanation: An empty array remains unchanged.

Pseudocode

FUNCTION reverseArray(arr)
    IF arr is null OR length of arr <= 1 THEN
        RETURN
    ENDIF
    SET left to 0
    SET right to length of arr - 1
    WHILE left < right
        SET temp to arr[left]
        SET arr[left] to arr[right]
        SET arr[right] to temp
        INCREMENT left
        DECREMENT right
    ENDWHILE
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null or has 0 or 1 element. If so, return, as no reversal is needed.
  2. Initialize two pointers: left at the start of the array (index 0) and right at the end of the array (index length - 1).
  3. While left is less than right: a. Swap the elements at indices left and right using a temporary variable. b. Increment left and decrement right to move the pointers inward.
  4. Continue until left meets or exceeds right, at which point the array is fully reversed.
  5. Test the method with arrays of different sizes to verify correctness.

Java Implementation

public class ArrayReversal {
    // Reverses an array in-place
    public void reverseArray(int[] arr) {
        // Check for null or single-element/empty array
        if (arr == null || arr.length <= 1) {
            return;
        }
        // Initialize pointers
        int left = 0;
        int right = arr.length - 1;
        // Swap elements from ends toward center
        while (left < right) {
            // Swap using temporary variable
            int temp = arr[left];
            arr[left] = arr[right];
            arr[right] = temp;
            // Move pointers
            left++;
            right--;
        }
    }
}

Output

For the input array [1, 2, 3, 4, 5], the program modifies it to:

[5, 4, 3, 2, 1]

Explanation: The array is reversed in-place by swapping elements: (1 ↔ 5), (2 ↔ 4), leaving the middle element 3 in place.

For an empty array [], the program outputs:

[]

Explanation: An empty array remains unchanged.

For a single-element array [7], the program outputs:

[7]

Explanation: A single-element array is already reversed.

How It Works

  • Step 1: The reverseArray method checks if the array is null or has 0 or 1 element. For [1, 2, 3, 4, 5], it proceeds.
  • Step 2: Initialize left = 0, right = 4.
  • Step 3: Iterate while left < right:
    • First iteration: Swap arr[0] = 1 and arr[4] = 5, resulting in [5, 2, 3, 4, 1]. Set left = 1, right = 3.
    • Second iteration: Swap arr[1] = 2 and arr[3] = 4, resulting in [5, 4, 3, 2, 1]. Set left = 2, right = 2.
    • Stop: left = 2 >= right = 2, so the loop ends.
  • Example Trace: For [1, 2, 3, 4, 5], swaps (1 ↔ 5), (2 ↔ 4), resulting in [5, 4, 3, 2, 1].
  • In-Place Property: The algorithm uses only a single temporary variable, ensuring O(1) extra space regardless of array size.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
SwappingO(n/2) = O(n)O(1)
Full AlgorithmO(n)O(1)

Note:

  • n is the size of the input array.
  • Time complexity: O(n), as the algorithm performs n/2 swaps, each taking constant time.
  • Space complexity: O(1), as only a single temporary variable is used for swapping.
  • Best, average, and worst cases are O(n), as all elements are processed up to the midpoint.

✅ Tip: Use two pointers to reverse arrays in-place for optimal space efficiency. Test with large arrays (e.g., 10^4 elements) and edge cases like empty or single-element arrays to ensure robustness.

⚠ Warning: Ensure the input array is not null to avoid NullPointerException. Be cautious with very large arrays, as accessing invalid indices could occur if pointers are mismanaged.

Array Rotation

Problem Statement

Write a Java program that rotates an array of integers by k positions to the left. The rotation should shift each element k positions left, with elements at the beginning wrapping around to the end of the array. The program should modify the array in-place and test the implementation with different values of k and array sizes, including edge cases like empty arrays, single-element arrays, and k larger than the array length. You can visualize this as rotating a circular table of numbered plates k positions counterclockwise, so the first k plates move to the end while the rest shift forward.

Input: An array of integers and a non-negative integer k (e.g., arr = [1, 2, 3, 4, 5], k = 2). Output: The array modified in-place to reflect the left rotation by k positions (e.g., [3, 4, 5, 1, 2]). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • k is a non-negative integer (0 ≤ k ≤ 10^9).
  • The array may be empty. Example:
  • Input: arr = [1, 2, 3, 4, 5], k = 2
  • Output: [3, 4, 5, 1, 2]
  • Explanation: Rotating [1, 2, 3, 4, 5] left by 2 positions moves 1 and 2 to the end.
  • Input: arr = [1, 2, 3], k = 4
  • Output: [3, 1, 2]
  • Explanation: Rotating by k = 4 is equivalent to k = 1 (since 4 % 3 = 1).

Pseudocode

FUNCTION rotateArray(arr, k)
    IF arr is null OR length of arr <= 1 THEN
        RETURN
    ENDIF
    SET n to length of arr
    SET k to k modulo n
    IF k equals 0 THEN
        RETURN
    ENDIF
    CALL reverseArray(arr, 0, k - 1)
    CALL reverseArray(arr, k, n - 1)
    CALL reverseArray(arr, 0, n - 1)
ENDFUNCTION

FUNCTION reverseArray(arr, start, end)
    WHILE start < end
        SET temp to arr[start]
        SET arr[start] to arr[end]
        SET arr[end] to temp
        INCREMENT start
        DECREMENT end
    ENDWHILE
ENDFUNCTION

FUNCTION main()
    SET testCases to array of tuples (arr, k)
    FOR each (arr, k) in testCases
        CALL rotateArray(arr, k)
        PRINT arr
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null or has 0 or 1 element. If so, return, as no rotation is needed.
  2. Compute the effective rotation value: k = k % n, where n is the array length, to handle cases where k exceeds n.
  3. If k equals 0 after modulo, return, as no rotation is needed.
  4. Use the reversal algorithm to rotate the array in-place: a. Reverse the first k elements (indices 0 to k-1). b. Reverse the remaining elements (indices k to n-1). c. Reverse the entire array (indices 0 to n-1).
  5. The reverseArray helper function swaps elements from start to end using a temporary variable.
  6. In the main method, create test cases with different array sizes and k values, call rotateArray, and print the results.

Java Implementation

public class ArrayRotation {
    // Rotates array left by k positions in-place
    public void rotateArray(int[] arr, int k) {
        // Check for null or single-element/empty array
        if (arr == null || arr.length <= 1) {
            return;
        }
        // Normalize k to handle cases where k > array length
        int n = arr.length;
        k = k % n;
        if (k == 0) {
            return;
        }
        // Perform three reversals
        reverseArray(arr, 0, k - 1);
        reverseArray(arr, k, n - 1);
        reverseArray(arr, 0, n - 1);
    }

    // Helper function to reverse array segment from start to end
    private void reverseArray(int[] arr, int start, int end) {
        while (start < end) {
            int temp = arr[start];
            arr[start] = arr[end];
            arr[end] = temp;
            start++;
            end--;
        }
    }

    // Main method to test rotateArray with various inputs
    public static void main(String[] args) {
        ArrayRotation rotator = new ArrayRotation();

        // Test case 1: Normal array, k = 2
        int[] arr1 = {1, 2, 3, 4, 5};
        System.out.print("Test case 1 before: ");
        printArray(arr1);
        rotator.rotateArray(arr1, 2);
        System.out.print("Test case 1 after: ");
        printArray(arr1);

        // Test case 2: k > array length
        int[] arr2 = {1, 2, 3};
        System.out.print("Test case 2 before: ");
        printArray(arr2);
        rotator.rotateArray(arr2, 4);
        System.out.print("Test case 2 after: ");
        printArray(arr2);

        // Test case 3: Empty array
        int[] arr3 = {};
        System.out.print("Test case 3 before: ");
        printArray(arr3);
        rotator.rotateArray(arr3, 3);
        System.out.print("Test case 3 after: ");
        printArray(arr3);

        // Test case 4: Single-element array
        int[] arr4 = {7};
        System.out.print("Test case 4 before: ");
        printArray(arr4);
        rotator.rotateArray(arr4, 2);
        System.out.print("Test case 4 after: ");
        printArray(arr4);

        // Test case 5: k = 0
        int[] arr5 = {1, 2, 3, 4};
        System.out.print("Test case 5 before: ");
        printArray(arr5);
        rotator.rotateArray(arr5, 0);
        System.out.print("Test case 5 after: ");
        printArray(arr5);
    }

    // Helper method to print array
    private static void printArray(int[] arr) {
        if (arr == null || arr.length == 0) {
            System.out.println("[]");
            return;
        }
        System.out.print("[");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
            if (i < arr.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1 before: [1, 2, 3, 4, 5]
Test case 1 after: [3, 4, 5, 1, 2]
Test case 2 before: [1, 2, 3]
Test case 2 after: [3, 1, 2]
Test case 3 before: []
Test case 3 after: []
Test case 4 before: [7]
Test case 4 after: [7]
Test case 5 before: [1, 2, 3, 4]
Test case 5 after: [1, 2, 3, 4]

Explanation:

  • Test case 1: Rotates [1, 2, 3, 4, 5] left by 2, resulting in [3, 4, 5, 1, 2].
  • Test case 2: Rotates [1, 2, 3] by k = 4, equivalent to k = 1 (4 % 3), resulting in [3, 1, 2].
  • Test case 3: Empty array remains [].
  • Test case 4: Single-element array [7] remains unchanged.
  • Test case 5: k = 0 leaves [1, 2, 3, 4] unchanged.

How It Works

  • Step 1: The rotateArray method checks if the array is null or has 0 or 1 element. For [1, 2, 3, 4, 5], k = 2, it proceeds.
  • Step 2: Normalize k: n = 5, k = 2 % 5 = 2.
  • Step 3: Perform three reversals:
    • Reverse indices 0 to 1: [2, 1, 3, 4, 5].
    • Reverse indices 2 to 4: [2, 1, 5, 4, 3].
    • Reverse entire array: [3, 4, 5, 1, 2].
  • Example Trace: For [1, 2, 3, 4, 5], k = 2, reverses [1, 2] to [2, 1], [3, 4, 5] to [5, 4, 3], then [2, 1, 5, 4, 3] to [3, 4, 5, 1, 2].
  • Main Method: Tests the method with various cases: normal rotation, k > n, empty array, single-element array, and k = 0.
  • In-Place Property: The algorithm uses O(1) extra space by performing swaps via the reverseArray helper.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
ReverseO(n)O(1)
Full AlgorithmO(n)O(1)

Note:

  • n is the size of the input array.
  • Time complexity: O(n), as the algorithm performs three reversals, each taking O(n/2), O(n-k), and O(n) respectively, totaling O(n).
  • Space complexity: O(1), as only a few temporary variables are used for swapping.
  • Best, average, and worst cases are O(n).

✅ Tip: Normalize k using modulo to handle large k values efficiently. Test with edge cases like k = 0, k > n, and empty arrays to ensure robustness.

⚠ Warning: Ensure the input array is not null to avoid NullPointerException. Verify index bounds in the reverse function to prevent ArrayIndexOutOfBoundsException.

Array Sorting

Problem Statement

Write a Java program that implements the bubble sort algorithm to sort an array of integers in ascending order. The program should modify the input array in-place to arrange its elements from smallest to largest and test the implementation with various input arrays, including empty arrays, single-element arrays, and arrays with duplicate or negative numbers. You can visualize this as organizing a row of books on a shelf by repeatedly comparing and swapping adjacent books to ensure they are in order by size.

Input: An array of integers (e.g., arr = [64, 34, 25, 12, 22]). Output: The array modified in-place to be sorted in ascending order (e.g., [12, 22, 25, 34, 64]). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array may be empty. Example:
  • Input: arr = [64, 34, 25, 12, 22]
  • Output: [12, 22, 25, 34, 64]
  • Explanation: The array is sorted in ascending order using bubble sort.
  • Input: arr = [1, 1, 1]
  • Output: [1, 1, 1]
  • Explanation: The array with duplicate elements remains sorted.

Pseudocode

FUNCTION bubbleSort(arr)
    IF arr is null OR length of arr <= 1 THEN
        RETURN
    ENDIF
    SET n to length of arr
    FOR i from 0 to n - 1
        FOR j from 0 to n - i - 2
            IF arr[j] > arr[j + 1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j + 1]
                SET arr[j + 1] to temp
            ENDIF
        ENDFOR
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testArrays to arrays of integers
    FOR each array in testArrays
        CALL bubbleSort(array)
        PRINT array
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null or has 0 or 1 element. If so, return, as no sorting is needed.
  2. Initialize n as the length of the array.
  3. For each index i from 0 to n - 1: a. Iterate through indices j from 0 to n - i - 2. b. If arr[j] > arr[j + 1], swap the elements at indices j and j + 1 using a temporary variable.
  4. Repeat until no more swaps are needed, indicating the array is sorted.
  5. In the main method, create test arrays with various cases (e.g., unsorted, sorted, duplicates, negative numbers) and call bubbleSort to verify correctness.

Java Implementation

public class ArraySorting {
    // Sorts array in ascending order using bubble sort
    public void bubbleSort(int[] arr) {
        // Check for null or single-element/empty array
        if (arr == null || arr.length <= 1) {
            return;
        }
        // Get array length
        int n = arr.length;
        // Outer loop for passes
        for (int i = 0; i < n; i++) {
            // Inner loop for comparisons and swaps
            for (int j = 0; j < n - i - 1; j++) {
                // Compare adjacent elements
                if (arr[j] > arr[j + 1]) {
                    // Swap elements
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                }
            }
        }
    }

    // Main method to test bubbleSort with various arrays
    public static void main(String[] args) {
        ArraySorting sorter = new ArraySorting();

        // Test case 1: Unsorted array
        int[] arr1 = {64, 34, 25, 12, 22};
        System.out.print("Test case 1 before: ");
        printArray(arr1);
        sorter.bubbleSort(arr1);
        System.out.print("Test case 1 after: ");
        printArray(arr1);

        // Test case 2: Already sorted array
        int[] arr2 = {1, 2, 3, 4, 5};
        System.out.print("Test case 2 before: ");
        printArray(arr2);
        sorter.bubbleSort(arr2);
        System.out.print("Test case 2 after: ");
        printArray(arr2);

        // Test case 3: Array with duplicates
        int[] arr3 = {3, 1, 3, 2, 1};
        System.out.print("Test case 3 before: ");
        printArray(arr3);
        sorter.bubbleSort(arr3);
        System.out.print("Test case 3 after: ");
        printArray(arr3);

        // Test case 4: Array with negative numbers
        int[] arr4 = {-5, 0, -2, 3, -1};
        System.out.print("Test case 4 before: ");
        printArray(arr4);
        sorter.bubbleSort(arr4);
        System.out.print("Test case 4 after: ");
        printArray(arr4);

        // Test case 5: Empty array
        int[] arr5 = {};
        System.out.print("Test case 5 before: ");
        printArray(arr5);
        sorter.bubbleSort(arr5);
        System.out.print("Test case 5 after: ");
        printArray(arr5);

        // Test case 6: Single-element array
        int[] arr6 = {42};
        System.out.print("Test case 6 before: ");
        printArray(arr6);
        sorter.bubbleSort(arr6);
        System.out.print("Test case 6 after: ");
        printArray(arr6);
    }

    // Helper method to print array
    private static void printArray(int[] arr) {
        if (arr == null || arr.length == 0) {
            System.out.println("[]");
            return;
        }
        System.out.print("[");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
            if (i < arr.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1 before: [64, 34, 25, 12, 22]
Test case 1 after: [12, 22, 25, 34, 64]
Test case 2 before: [1, 2, 3, 4, 5]
Test case 2 after: [1, 2, 3, 4, 5]
Test case 3 before: [3, 1, 3, 2, 1]
Test case 3 after: [1, 1, 2, 3, 3]
Test case 4 before: [-5, 0, -2, 3, -1]
Test case 4 after: [-5, -2, -1, 0, 3]
Test case 5 before: []
Test case 5 after: []
Test case 6 before: [42]
Test case 6 after: [42]

Explanation:

  • Test case 1: Sorts [64, 34, 25, 12, 22] to [12, 22, 25, 34, 64].
  • Test case 2: Already sorted [1, 2, 3, 4, 5] remains unchanged.
  • Test case 3: Sorts [3, 1, 3, 2, 1] to [1, 1, 2, 3, 3], handling duplicates.
  • Test case 4: Sorts [-5, 0, -2, 3, -1] to [-5, -2, -1, 0, 3], handling negatives.
  • Test case 5: Empty array [] remains unchanged.
  • Test case 6: Single-element array [42] remains unchanged.

How It Works

  • Step 1: The bubbleSort method checks if the array is null or has 0 or 1 element. For [64, 34, 25, 12, 22], it proceeds.
  • Step 2: For n = 5, perform 5 passes:
    • Pass 1: Compare and swap adjacent elements: [64, 34] → [34, 64], [64, 25] → [25, 64], [64, 12] → [12, 64], [64, 22] → [22, 64]. Result: [34, 25, 12, 22, 64].
    • Pass 2: [34, 25] → [25, 34], [34, 12] → [12, 34], [34, 22] → [22, 34]. Result: [25, 12, 22, 34, 64].
    • Pass 3: [25, 12] → [12, 25], [25, 22] → [22, 25]. Result: [12, 22, 25, 34, 64].
    • Pass 4: No swaps needed, array is sorted.
    • Pass 5: No swaps needed, done.
  • Example Trace: For [64, 34, 25, 12, 22], sorts to [12, 22, 25, 34, 64] by bubbling larger elements to the end.
  • Main Method: Tests the method with unsorted, sorted, duplicate, negative, empty, and single-element arrays, printing results.
  • In-Place Property: Bubble sort modifies the array in-place, using only a temporary variable for swaps.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
SortingO(n^2)O(1)
Full AlgorithmO(n^2)O(1)

Note:

  • n is the size of the input array.
  • Time complexity: O(n^2) in the worst and average cases, as the algorithm performs up to n passes, each with up to n-1 comparisons. Best case is O(n) when the array is already sorted (no swaps).
  • Space complexity: O(1), as only a temporary variable is used for swapping.
  • Worst case: O(n^2) for reverse-sorted arrays. Best case: O(n) for sorted arrays.

✅ Tip: Bubble sort is simple but inefficient for large arrays. Consider adding a flag to detect if no swaps occur in a pass to optimize for nearly sorted arrays. Test with various cases to ensure correctness.

⚠ Warning: Ensure the input array is not null to avoid NullPointerException. Be cautious with very large arrays, as O(n^2) time complexity can lead to slow performance.

Duplicate Finder

Problem Statement

Write a Java program that implements a method to check if an array of integers contains any duplicate elements. The method should return true if there are duplicates and false otherwise. Test the method with both sorted and unsorted arrays, including edge cases like empty arrays, single-element arrays, and arrays with multiple duplicates. You can visualize this as checking a list of student IDs to determine if any ID appears more than once, whether the list is sorted or unsorted.

Input: An array of integers (e.g., arr = [1, 2, 3, 1] or arr = [1, 1, 2, 3]). Output: A boolean indicating whether the array contains duplicate elements (e.g., true for [1, 2, 3, 1], false for [1, 2, 3, 4]). Constraints:

  • The array length is between 0 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array may be empty. Example:
  • Input: arr = [1, 2, 3, 1]
  • Output: true
  • Explanation: The element 1 appears twice, so the array contains duplicates.
  • Input: arr = [1, 2, 3, 4]
  • Output: false
  • Explanation: All elements are unique.

Pseudocode

FUNCTION hasDuplicates(arr)
    IF arr is null OR length of arr <= 1 THEN
        RETURN false
    ENDIF
    CREATE empty HashSet seen
    FOR each element in arr
        IF element exists in seen THEN
            RETURN true
        ELSE
            ADD element to seen
        ENDIF
    ENDFOR
    RETURN false
ENDFUNCTION

FUNCTION main()
    SET testArrays to arrays of integers
    FOR each array in testArrays
        CALL hasDuplicates(array)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null or has 0 or 1 element. If so, return false, as duplicates are not possible.
  2. Create an empty HashSet to store seen elements.
  3. Iterate through the array: a. For each element, check if it exists in the HashSet. b. If the element is in the HashSet, return true, as a duplicate has been found. c. Otherwise, add the element to the HashSet.
  4. If the loop completes without finding duplicates, return false.
  5. In the main method, create test arrays (sorted and unsorted) with various cases and call the hasDuplicates method to verify correctness.

Java Implementation

import java.util.HashSet;

public class DuplicateFinder {
    // Checks if array contains duplicate elements
    public boolean hasDuplicates(int[] arr) {
        // Check for null or single-element/empty array
        if (arr == null || arr.length <= 1) {
            return false;
        }
        // Use HashSet to track seen elements
        HashSet<Integer> seen = new HashSet<>();
        // Iterate through array
        for (int num : arr) {
            // If element is already in set, duplicate found
            if (seen.contains(num)) {
                return true;
            }
            // Add element to set
            seen.add(num);
        }
        // No duplicates found
        return false;
    }

    // Main method to test hasDuplicates with various arrays
    public static void main(String[] args) {
        DuplicateFinder finder = new DuplicateFinder();

        // Test case 1: Unsorted array with duplicates
        int[] arr1 = {1, 2, 3, 1};
        System.out.println("Test case 1 (unsorted with duplicates): " + finder.hasDuplicates(arr1));

        // Test case 2: Sorted array with duplicates
        int[] arr2 = {1, 1, 2, 3};
        System.out.println("Test case 2 (sorted with duplicates): " + finder.hasDuplicates(arr2));

        // Test case 3: Unsorted array without duplicates
        int[] arr3 = {1, 2, 3, 4};
        System.out.println("Test case 3 (unsorted without duplicates): " + finder.hasDuplicates(arr3));

        // Test case 4: Empty array
        int[] arr4 = {};
        System.out.println("Test case 4 (empty): " + finder.hasDuplicates(arr4));

        // Test case 5: Single-element array
        int[] arr5 = {5};
        System.out.println("Test case 5 (single element): " + finder.hasDuplicates(arr5));

        // Test case 6: Array with negative numbers and duplicates
        int[] arr6 = {-1, 2, -1, 3};
        System.out.println("Test case 6 (negative numbers with duplicates): " + finder.hasDuplicates(arr6));
    }
}

Output

Running the main method produces:

Test case 1 (unsorted with duplicates): true
Test case 2 (sorted with duplicates): true
Test case 3 (unsorted without duplicates): false
Test case 4 (empty): false
Test case 5 (single element): false
Test case 6 (negative numbers with duplicates): true

Explanation:

  • Test case 1: [1, 2, 3, 1] returns true (duplicate 1).
  • Test case 2: [1, 1, 2, 3] returns true (duplicate 1).
  • Test case 3: [1, 2, 3, 4] returns false (no duplicates).
  • Test case 4: [] returns false (empty array).
  • Test case 5: [5] returns false (single element).
  • Test case 6: [-1, 2, -1, 3] returns true (duplicate -1).

How It Works

  • Step 1: The hasDuplicates method checks if the array is null or has 0 or 1 element. For [1, 2, 3, 1], it proceeds.
  • Step 2: Initialize an empty HashSet seen.
  • Step 3: Iterate through [1, 2, 3, 1]:
    • Element 1: Not in seen, add 1 to seen.
    • Element 2: Not in seen, add 2 to seen.
    • Element 3: Not in seen, add 3 to seen.
    • Element 1: In seen, return true.
  • Example Trace: For [1, 2, 3, 1], seen grows: {1} → {1, 2} → {1, 2, 3}, then detects duplicate 1, returning true.
  • Main Method: Tests the method with sorted and unsorted arrays, including duplicates, no duplicates, empty, single-element, and negative numbers.
  • HashSet Efficiency: The HashSet provides O(1) average-time lookups and insertions, making the algorithm efficient for both sorted and unsorted arrays.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
IterationO(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the size of the input array.
  • Time complexity: O(n), as the algorithm iterates through the array once, with O(1) average-time HashSet operations.
  • Space complexity: O(n), as the HashSet may store up to n elements in the worst case.
  • Best, average, and worst cases are O(n) for time; space depends on the number of unique elements.

✅ Tip: Use a HashSet for efficient duplicate detection in both sorted and unsorted arrays. Test with edge cases like empty arrays, single-element arrays, and arrays with negative numbers to ensure robustness.

⚠ Warning: Ensure the input array is not null to avoid NullPointerException. Be mindful of memory usage for very large arrays, as the HashSet requires O(n) space.

Maximum Element

Problem Statement

Write a Java program that implements a method to find the maximum element in an array of integers. The program should return the largest element in the array and test the method with arrays containing positive and negative numbers, including edge cases like single-element arrays and arrays with duplicate maximum values. You can visualize this as searching through a list of exam scores to find the highest score, ensuring the method works whether the scores are positive, negative, or a mix of both.

Input: An array of integers (e.g., arr = [3, -1, 5, 2, -7]). Output: An integer representing the maximum element in the array (e.g., 5 for [3, -1, 5, 2, -7]). Constraints:

  • The array length is between 1 and 10^5.
  • Elements are integers between -10^9 and 10^9.
  • The array is guaranteed to have at least one element. Example:
  • Input: arr = [3, -1, 5, 2, -7]
  • Output: 5
  • Explanation: The maximum element in the array is 5.
  • Input: arr = [-2, -5, -1, -8]
  • Output: -1
  • Explanation: The maximum element in the array is -1.

Pseudocode

FUNCTION findMax(arr)
    IF arr is null OR length of arr equals 0 THEN
        RETURN null
    ENDIF
    SET max to arr[0]
    FOR i from 1 to length of arr - 1
        IF arr[i] > max THEN
            SET max to arr[i]
        ENDIF
    ENDFOR
    RETURN max
ENDFUNCTION

FUNCTION main()
    SET testArrays to arrays of integers
    FOR each array in testArrays
        CALL findMax(array)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null or empty. If so, return null (or throw an exception, depending on requirements).
  2. Initialize a variable max to the first element of the array.
  3. Iterate through the array from index 1 to the last index.
  4. For each element, compare it with max. If the element is greater than max, update max to the element’s value.
  5. After the loop, return max as the maximum element.
  6. In the main method, create test arrays with positive and negative numbers and call the findMax method to verify correctness.

Java Implementation

public class MaximumElement {
    // Finds the maximum element in an array
    public Integer findMax(int[] arr) {
        // Check for null or empty array
        if (arr == null || arr.length == 0) {
            return null;
        }
        // Initialize max to first element
        int max = arr[0];
        // Iterate through array to find maximum
        for (int i = 1; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
        }
        return max;
    }

    // Main method to test findMax with various arrays
    public static void main(String[] args) {
        MaximumElement maxElement = new MaximumElement();
        
        // Test case 1: Mixed positive and negative numbers
        int[] arr1 = {3, -1, 5, 2, -7};
        System.out.println("Maximum element in arr1: " + maxElement.findMax(arr1));
        
        // Test case 2: All negative numbers
        int[] arr2 = {-2, -5, -1, -8};
        System.out.println("Maximum element in arr2: " + maxElement.findMax(arr2));
        
        // Test case 3: Single element
        int[] arr3 = {42};
        System.out.println("Maximum element in arr3: " + maxElement.findMax(arr3));
        
        // Test case 4: Duplicate maximum values
        int[] arr4 = {4, 4, 3, 4, 2};
        System.out.println("Maximum element in arr4: " + maxElement.findMax(arr4));
        
        // Test case 5: Null array
        int[] arr5 = null;
        System.out.println("Maximum element in arr5: " + maxElement.findMax(arr5));
    }
}

Output

Running the main method produces:

Maximum element in arr1: 5
Maximum element in arr2: -1
Maximum element in arr3: 42
Maximum element in arr4: 4
Maximum element in arr5: null

Explanation:

  • For arr1 = [3, -1, 5, 2, -7], the maximum is 5.
  • For arr2 = [-2, -5, -1, -8], the maximum is -1 (largest among negatives).
  • For arr3 = [42], the maximum is 42 (single element).
  • For arr4 = [4, 4, 3, 4, 2], the maximum is 4 (handles duplicates).
  • For arr5 = null, the method returns null.

How It Works

  • Step 1: The findMax method checks if the array is null or empty. For [3, -1, 5, 2, -7], it proceeds.
  • Step 2: Initialize max = arr[0] = 3.
  • Step 3: Iterate from index 1 to 4:
    • Index 1: arr[1] = -1, -1 < 3, so max remains 3.
    • Index 2: arr[2] = 5, 5 > 3, so max = 5.
    • Index 3: arr[3] = 2, 2 < 5, so max remains 5.
    • Index 4: arr[4] = -7, -7 < 5, so max remains 5.
  • Step 4: Return max = 5.
  • Example Trace: For [3, -1, 5, 2, -7], max updates: 3 → 5, returning 5.
  • Main Method: The main method tests the findMax method with various arrays, including mixed numbers, all negatives, single-element, duplicates, and null, printing the results.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
IterationO(n)O(1)
Full AlgorithmO(n)O(1)

Note:

  • n is the size of the input array.
  • Time complexity: O(n), as the algorithm iterates through the array once, comparing each element.
  • Space complexity: O(1), as only a single variable (max) is used, regardless of array size.
  • Best, average, and worst cases are O(n), as all elements are processed.

✅ Tip: Initialize the maximum to the first element to handle arrays with negative numbers efficiently. Test with edge cases like single-element arrays, all-negative arrays, and arrays with duplicates to ensure robustness.

⚠ Warning: Ensure the input array is not null or empty to avoid NullPointerException or unexpected behavior. For very large arrays, ensure elements are within the integer range to avoid overflow issues in comparisons.

3D Array Summation

Problem Statement

Write a Java program that initializes a 3D array of integers and computes the sum of all elements using nested loops. The program should return the total sum and test the implementation with 3D arrays of different dimensions, including edge cases like single-element arrays and arrays with varying sizes for each dimension. You can visualize this as calculating the total value of items stored in a 3D grid, such as boxes stacked in a warehouse with layers, rows, and columns, by adding up every item’s value.

Input: A 3D array of integers with dimensions d × r × c (e.g., array = {{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}}). Output: A long integer representing the sum of all elements in the 3D array (e.g., 36 for the example above). Constraints:

  • 1 ≤ d, r, c ≤ 100 (where d is depth, r is rows, c is columns).
  • Elements are integers between -10^4 and 10^4.
  • The array is guaranteed to be non-empty and well-formed (all sub-arrays have consistent dimensions). Example:
  • Input: array = {{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}}
  • Output: 36
  • Explanation: Sum = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36.
  • Input: array = {{{1}}}
  • Output: 1
  • Explanation: The single-element 3D array has sum 1.

Pseudocode

FUNCTION sum3DArray(array)
    IF array is null OR array is empty OR array[0] is empty OR array[0][0] is empty THEN
        RETURN 0
    ENDIF
    SET depth to number of layers in array
    SET rows to number of rows in array[0]
    SET cols to number of columns in array[0][0]
    SET sum to 0
    FOR i from 0 to depth - 1
        FOR j from 0 to rows - 1
            FOR k from 0 to cols - 1
                SET sum to sum + array[i][j][k]
            ENDFOR
        ENDFOR
    ENDFOR
    RETURN sum
ENDFUNCTION

FUNCTION main()
    SET testArrays to 3D arrays with different dimensions
    FOR each array in testArrays
        CALL sum3DArray(array)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input 3D array is null, empty, or has empty sub-arrays. If so, return 0.
  2. Determine the dimensions: depth (d), rows (r), and columns (c) of the 3D array.
  3. Initialize a variable sum to 0.
  4. Use three nested loops to iterate through each element array[i][j][k]: a. Add the element to sum.
  5. Return the final sum.
  6. In the main method, create test 3D arrays with different dimensions and call sum3DArray to verify correctness.

Java Implementation

public class ThreeDArraySummation {
    // Computes the sum of all elements in a 3D array
    public long sum3DArray(int[][][] array) {
        // Check for null or empty array
        if (array == null || array.length == 0 || array[0].length == 0 || array[0][0].length == 0) {
            return 0;
        }
        // Get dimensions
        int depth = array.length;
        int rows = array[0].length;
        int cols = array[0][0].length;
        // Initialize sum
        long sum = 0;
        // Iterate through all elements
        for (int i = 0; i < depth; i++) {
            for (int j = 0; j < rows; j++) {
                for (int k = 0; k < cols; k++) {
                    sum += array[i][j][k];
                }
            }
        }
        return sum;
    }

    // Main method to test sum3DArray with various inputs
    public static void main(String[] args) {
        ThreeDArraySummation summer = new ThreeDArraySummation();

        // Test case 1: 2x2x2 3D array
        int[][][] array1 = {{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}};
        System.out.println("Test case 1:");
        System.out.println("Array:");
        print3DArray(array1);
        System.out.println("Sum: " + summer.sum3DArray(array1));

        // Test case 2: 1x1x1 single-element array
        int[][][] array2 = {{{1}}};
        System.out.println("Test case 2:");
        System.out.println("Array:");
        print3DArray(array2);
        System.out.println("Sum: " + summer.sum3DArray(array2));

        // Test case 3: 2x3x2 non-uniform dimensions
        int[][][] array3 = {{{1, 2}, {3, 4}, {5, 6}}, {{7, 8}, {9, 10}, {11, 12}}};
        System.out.println("Test case 3:");
        System.out.println("Array:");
        print3DArray(array3);
        System.out.println("Sum: " + summer.sum3DArray(array3));

        // Test case 4: 1x2x3 array with negative numbers
        int[][][] array4 = {{{-1, -2, -3}, {-4, -5, -6}}};
        System.out.println("Test case 4:");
        System.out.println("Array:");
        print3DArray(array4);
        System.out.println("Sum: " + summer.sum3DArray(array4));

        // Test case 5: Empty array
        int[][][] array5 = {};
        System.out.println("Test case 5:");
        System.out.println("Array:");
        print3DArray(array5);
        System.out.println("Sum: " + summer.sum3DArray(array5));
    }

    // Helper method to print 3D array
    private static void print3DArray(int[][][] array) {
        if (array == null || array.length == 0) {
            System.out.println("null");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < array.length; i++) {
            System.out.println("  [");
            for (int j = 0; j < array[i].length; j++) {
                System.out.print("    [");
                for (int k = 0; k < array[i][j].length; k++) {
                    System.out.print(array[i][j][k]);
                    if (k < array[i][j].length - 1) {
                        System.out.print(", ");
                    }
                }
                System.out.println("]");
            }
            System.out.println("  ]");
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Array:
[
  [
    [1, 2]
    [3, 4]
  ]
  [
    [5, 6]
    [7, 8]
  ]
]
Sum: 36
Test case 2:
Array:
[
  [
    [1]
  ]
]
Sum: 1
Test case 3:
Array:
[
  [
    [1, 2]
    [3, 4]
    [5, 6]
  ]
  [
    [7, 8]
    [9, 10]
    [11, 12]
  ]
]
Sum: 78
Test case 4:
Array:
[
  [
    [-1, -2, -3]
    [-4, -5, -6]
  ]
]
Sum: -21
Test case 5:
Array:
null
Sum: 0

Explanation:

  • Test case 1: Sums a 2×2×2 array: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36.
  • Test case 2: Sums a 1×1×1 array: 1 = 1.
  • Test case 3: Sums a 2×3×2 array: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 = 78.
  • Test case 4: Sums a 1×2×3 array with negatives: -1 + (-2) + (-3) + (-4) + (-5) + (-6) = -21.
  • Test case 5: Returns 0 for an empty array.

How It Works

  • Step 1: The sum3DArray method checks for null or empty arrays. For {{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}}, it proceeds.
  • Step 2: Get dimensions: depth = 2, rows = 2, cols = 2.
  • Step 3: Initialize sum = 0.
  • Step 4: Iterate through all elements:
    • Layer 0: [1, 2] → sum = 0 + 1 + 2 = 3; [3, 4] → sum = 3 + 3 + 4 = 10.
    • Layer 1: [5, 6] → sum = 10 + 5 + 6 = 21; [7, 8] → sum = 21 + 7 + 8 = 36.
  • Example Trace: For test case 1, accumulates: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36.
  • Main Method: Tests with different dimensions (2×2×2, 1×1×1, 2×3×2, 1×2×3) and an empty array, printing inputs and sums.
  • Summation Property: Uses nested loops to access each element, ensuring all are included in the sum.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
SummationO(drc)O(1)
Full AlgorithmO(drc)O(1)

Note:

  • d is the depth, r is the number of rows, c is the number of columns.
  • Time complexity: O(drc), as the algorithm iterates through each element once.
  • Space complexity: O(1), as only a single sum variable is used (excluding input/output).
  • Best, average, and worst cases are O(drc).

✅ Tip: Use nested loops for straightforward 3D array traversal. Test with varying dimensions and negative numbers to ensure correctness, especially for edge cases like single-element arrays.

⚠ Warning: Ensure the 3D array is well-formed (consistent dimensions across layers, rows, and columns) to avoid NullPointerException or ArrayIndexOutOfBoundsException. Use a long for the sum to handle large arrays within the given constraints.

Diagonal Elements

Problem Statement

Write a Java program that implements a method to extract the main diagonal elements of a square 2D array (matrix) into a 1D array. The main diagonal consists of elements where the row index equals the column index (i.e., matrix[i][i]). The program should return a 1D array containing these elements and test the implementation with various square matrices, including edge cases like 1×1 matrices and matrices with negative numbers. You can visualize this as collecting the numbers along the top-left to bottom-right diagonal of a square grid, like picking out the main diagonal pieces on a chessboard.

Input: A square 2D array (matrix) of integers with dimensions n × n (e.g., matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]). Output: A 1D array containing the main diagonal elements (e.g., [1, 5, 9]). Constraints:

  • 1 ≤ n ≤ 100 (where n is the number of rows and columns).
  • Elements are integers between -10^9 and 10^9.
  • The matrix is guaranteed to be square (number of rows equals number of columns) and non-empty. Example:
  • Input: matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
  • Output: [1, 5, 9]
  • Explanation: The main diagonal elements are matrix[0][0] = 1, matrix[1][1] = 5, matrix[2][2] = 9.
  • Input: matrix = [[5]]
  • Output: [5]
  • Explanation: The 1×1 matrix has a single diagonal element, 5.

Pseudocode

FUNCTION getMainDiagonal(matrix)
    IF matrix is null OR matrix is empty OR matrix[0] is empty OR rows not equal to columns THEN
        RETURN null
    ENDIF
    SET n to number of rows in matrix
    CREATE result array of size n
    FOR i from 0 to n - 1
        SET result[i] to matrix[i][i]
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION main()
    SET testMatrices to square 2D arrays
    FOR each matrix in testMatrices
        CALL getMainDiagonal(matrix)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input matrix is null, empty, or not square (rows ≠ columns). If so, return null.
  2. Get the size of the matrix: n (number of rows, equal to columns since square).
  3. Create a 1D result array of size n.
  4. Iterate through indices i from 0 to n-1: a. Set result[i] to matrix[i][i] (the main diagonal element).
  5. Return the result array.
  6. In the main method, create test square matrices of different sizes and call getMainDiagonal to verify correctness.

Java Implementation

public class DiagonalElements {
    // Extracts main diagonal elements into a 1D array
    public int[] getMainDiagonal(int[][] matrix) {
        // Check for null, empty, or non-square matrix
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0 || matrix.length != matrix[0].length) {
            return null;
        }
        // Get matrix size
        int n = matrix.length;
        // Create result array
        int[] result = new int[n];
        // Extract diagonal elements
        for (int i = 0; i < n; i++) {
            result[i] = matrix[i][i];
        }
        return result;
    }

    // Main method to test getMainDiagonal with various inputs
    public static void main(String[] args) {
        DiagonalElements diagonal = new DiagonalElements();

        // Test case 1: 3x3 matrix
        int[][] matrix1 = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
        System.out.println("Test case 1:");
        System.out.println("Matrix:");
        printMatrix(matrix1);
        System.out.print("Main diagonal: ");
        printArray(diagonal.getMainDiagonal(matrix1));

        // Test case 2: 1x1 matrix
        int[][] matrix2 = {{5}};
        System.out.println("Test case 2:");
        System.out.println("Matrix:");
        printMatrix(matrix2);
        System.out.print("Main diagonal: ");
        printArray(diagonal.getMainDiagonal(matrix2));

        // Test case 3: 2x2 matrix with negative numbers
        int[][] matrix3 = {{-1, -2}, {-3, -4}};
        System.out.println("Test case 3:");
        System.out.println("Matrix:");
        printMatrix(matrix3);
        System.out.print("Main diagonal: ");
        printArray(diagonal.getMainDiagonal(matrix3));

        // Test case 4: 4x4 matrix
        int[][] matrix4 = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}, {13, 14, 15, 16}};
        System.out.println("Test case 4:");
        System.out.println("Matrix:");
        printMatrix(matrix4);
        System.out.print("Main diagonal: ");
        printArray(diagonal.getMainDiagonal(matrix4));

        // Test case 5: Null matrix
        int[][] matrix5 = null;
        System.out.println("Test case 5:");
        System.out.println("Matrix:");
        printMatrix(matrix5);
        System.out.print("Main diagonal: ");
        printArray(diagonal.getMainDiagonal(matrix5));
    }

    // Helper method to print matrix
    private static void printMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            System.out.println("null");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < matrix.length; i++) {
            System.out.print("  [");
            for (int j = 0; j < matrix[i].length; j++) {
                System.out.print(matrix[i][j]);
                if (j < matrix[i].length - 1) {
                    System.out.print(", ");
                }
            }
            System.out.println("]");
        }
        System.out.println("]");
    }

    // Helper method to print 1D array
    private static void printArray(int[] arr) {
        if (arr == null) {
            System.out.println("null");
            return;
        }
        System.out.print("[");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
            if (i < arr.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Matrix:
[
  [1, 2, 3]
  [4, 5, 6]
  [7, 8, 9]
]
Main diagonal: [1, 5, 9]
Test case 2:
Matrix:
[
  [5]
]
Main diagonal: [5]
Test case 3:
Matrix:
[
  [-1, -2]
  [-3, -4]
]
Main diagonal: [-1, -4]
Test case 4:
Matrix:
[
  [1, 2, 3, 4]
  [5, 6, 7, 8]
  [9, 10, 11, 12]
  [13, 14, 15, 16]
]
Main diagonal: [1, 6, 11, 16]
Test case 5:
Matrix:
null
Main diagonal: null

Explanation:

  • Test case 1: Extracts diagonal [1, 5, 9] from a 3×3 matrix.
  • Test case 2: Extracts [5] from a 1×1 matrix.
  • Test case 3: Extracts [-1, -4] from a 2×2 matrix with negative numbers.
  • Test case 4: Extracts [1, 6, 11, 16] from a 4×4 matrix.
  • Test case 5: Returns null for a null matrix.

How It Works

  • Step 1: The getMainDiagonal method checks if the matrix is null, empty, or not square. For [[1, 2, 3], [4, 5, 6], [7, 8, 9]], it proceeds.
  • Step 2: Get size: n = 3.
  • Step 3: Create a result array of size 3.
  • Step 4: Iterate for i = 0 to 2:
    • i = 0: result[0] = matrix[0][0] = 1
    • i = 1: result[1] = matrix[1][1] = 5
    • i = 2: result[2] = matrix[2][2] = 9
  • Example Trace: For test case 1, builds [1, 5, 9] by collecting matrix[i][i].
  • Main Method: Tests with square matrices (3×3, 1×1, 2×2, 4×4) and a null matrix, printing inputs and results.
  • Diagonal Property: Collects elements where row index equals column index, creating a 1D array of size n.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Diagonal ExtractionO(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the size of the square matrix (rows = columns).
  • Time complexity: O(n), as the algorithm iterates through n diagonal elements.
  • Space complexity: O(n), for the result array. Temporary variables use O(1) space.
  • Best, average, and worst cases are O(n).

✅ Tip: Since the matrix is square, a single loop over indices i is sufficient to extract matrix[i][i]. Test with various sizes and negative numbers to ensure correctness.

⚠ Warning: Ensure the input matrix is square and not null to avoid NullPointerException or ArrayIndexOutOfBoundsException. Verify consistent row lengths for a well-formed matrix.

Matrix Multiplication

Problem Statement

Write a Java program that implements a method to multiply two 2D arrays (matrices) and return the result as a new 2D array. The program should handle matrix multiplication for matrices of compatible sizes (i.e., the number of columns in the first matrix equals the number of rows in the second matrix) and test the implementation with matrices of different compatible sizes, including square and non-square matrices, and edge cases like single-row or single-column matrices. You can visualize this as combining two grids of numbers, where each cell in the resulting grid is computed by pairing rows from the first grid with columns from the second grid, summing the products of corresponding elements.

Input: Two 2D arrays (matrices) A (m × n) and B (n × p), where m is the number of rows in A, n is the number of columns in A and rows in B, and p is the number of columns in B (e.g., A = [[1, 2], [3, 4]], B = [[5, 6], [7, 8]]). Output: A new 2D array (m × p) representing the product of A and B (e.g., [[19, 22], [43, 50]]). Constraints:

  • 1 ≤ m, n, p ≤ 100.
  • Elements are integers between -10^4 and 10^4.
  • The number of columns in A equals the number of rows in B (matrices are compatible). Example:
  • Input: A = [[1, 2], [3, 4]], B = [[5, 6], [7, 8]]
  • Output: [[19, 22], [43, 50]]
  • Explanation: For element [0][0] in the result: 15 + 27 = 19; for [0][1]: 16 + 28 = 22; for [1][0]: 35 + 47 = 43; for [1][1]: 36 + 48 = 50.
  • Input: A = [[1, 2, 3]], B = [[4], [5], [6]]
  • Output: [[32]]
  • Explanation: For element [0][0]: 14 + 25 + 3*6 = 32.

Pseudocode

FUNCTION multiplyMatrices(A, B)
    IF A is null OR B is null OR A is empty OR B is empty OR columns of A not equal to rows of B THEN
        RETURN null
    ENDIF
    SET m to number of rows in A
    SET n to number of columns in A
    SET p to number of columns in B
    CREATE result matrix of size m × p
    FOR i from 0 to m - 1
        FOR j from 0 to p - 1
            SET sum to 0
            FOR k from 0 to n - 1
                SET sum to sum + A[i][k] * B[k][j]
            ENDFOR
            SET result[i][j] to sum
        ENDFOR
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION main()
    SET testCases to pairs of matrices
    FOR each (A, B) in testCases
        CALL multiplyMatrices(A, B)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if matrices A or B are null, empty, or incompatible (number of columns in A ≠ number of rows in B). If so, return null.
  2. Get dimensions: m (rows of A), n (columns of A/rows of B), p (columns of B).
  3. Create a result matrix of size m × p, initialized to zeros.
  4. For each element result[i][j] in the result matrix: a. Initialize a sum to 0. b. For each k from 0 to n-1, compute A[i][k] * B[k][j] and add to the sum. c. Set result[i][j] to the computed sum.
  5. Return the result matrix.
  6. In the main method, create test cases with different compatible matrix sizes and call multiplyMatrices to verify correctness.

Java Implementation

public class MatrixMultiplication {
    // Multiplies two matrices and returns the result
    public int[][] multiplyMatrices(int[][] A, int[][] B) {
        // Check for null, empty, or incompatible matrices
        if (A == null || B == null || A.length == 0 || B.length == 0 || A[0].length != B.length) {
            return null;
        }
        // Get dimensions
        int m = A.length; // Rows of A
        int n = A[0].length; // Columns of A, rows of B
        int p = B[0].length; // Columns of B
        // Initialize result matrix
        int[][] result = new int[m][p];
        // Compute matrix multiplication
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < p; j++) {
                int sum = 0;
                for (int k = 0; k < n; k++) {
                    sum += A[i][k] * B[k][j];
                }
                result[i][j] = sum;
            }
        }
        return result;
    }

    // Main method to test multiplyMatrices with various inputs
    public static void main(String[] args) {
        MatrixMultiplication multiplier = new MatrixMultiplication();

        // Test case 1: 2x2 matrices
        int[][] A1 = {{1, 2}, {3, 4}};
        int[][] B1 = {{5, 6}, {7, 8}};
        System.out.println("Test case 1:");
        System.out.println("Matrix A1:");
        printMatrix(A1);
        System.out.println("Matrix B1:");
        printMatrix(B1);
        int[][] result1 = multiplier.multiplyMatrices(A1, B1);
        System.out.println("Result:");
        printMatrix(result1);

        // Test case 2: 1x3 and 3x1 matrices
        int[][] A2 = {{1, 2, 3}};
        int[][] B2 = {{4}, {5}, {6}};
        System.out.println("Test case 2:");
        System.out.println("Matrix A2:");
        printMatrix(A2);
        System.out.println("Matrix B2:");
        printMatrix(B2);
        int[][] result2 = multiplier.multiplyMatrices(A2, B2);
        System.out.println("Result:");
        printMatrix(result2);

        // Test case 3: 3x2 and 2x3 matrices
        int[][] A3 = {{1, 2}, {3, 4}, {5, 6}};
        int[][] B3 = {{7, 8, 9}, {10, 11, 12}};
        System.out.println("Test case 3:");
        System.out.println("Matrix A3:");
        printMatrix(A3);
        System.out.println("Matrix B3:");
        printMatrix(B3);
        int[][] result3 = multiplier.multiplyMatrices(A3, B3);
        System.out.println("Result:");
        printMatrix(result3);

        // Test case 4: Incompatible matrices
        int[][] A4 = {{1, 2}};
        int[][] B4 = {{3, 4}};
        System.out.println("Test case 4 (incompatible):");
        System.out.println("Matrix A4:");
        printMatrix(A4);
        System.out.println("Matrix B4:");
        printMatrix(B4);
        int[][] result4 = multiplier.multiplyMatrices(A4, B4);
        System.out.println("Result:");
        printMatrix(result4);
    }

    // Helper method to print matrix
    private static void printMatrix(int[][] matrix) {
        if (matrix == null) {
            System.out.println("null");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < matrix.length; i++) {
            System.out.print("  [");
            for (int j = 0; j < matrix[i].length; j++) {
                System.out.print(matrix[i][j]);
                if (j < matrix[i].length - 1) {
                    System.out.print(", ");
                }
            }
            System.out.println("]");
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Matrix A1:
[
  [1, 2]
  [3, 4]
]
Matrix B1:
[
  [5, 6]
  [7, 8]
]
Result:
[
  [19, 22]
  [43, 50]
]
Test case 2:
Matrix A2:
[
  [1, 2, 3]
]
Matrix B2:
[
  [4]
  [5]
  [6]
]
Result:
[
  [32]
]
Test case 3:
Matrix A3:
[
  [1, 2]
  [3, 4]
  [5, 6]
]
Matrix B3:
[
  [7, 8, 9]
  [10, 11, 12]
]
Result:
[
  [27, 30, 33]
  [61, 68, 75]
  [95, 106, 117]
]
Test case 4 (incompatible):
Matrix A4:
[
  [1, 2]
]
Matrix B4:
[
  [3, 4]
]
Result:
null

Explanation:

  • Test case 1: Multiplies 2×2 matrices, resulting in [[19, 22], [43, 50]].
  • Test case 2: Multiplies 1×3 and 3×1 matrices, resulting in [[32]].
  • Test case 3: Multiplies 3×2 and 2×3 matrices, resulting in a 3×3 matrix.
  • Test case 4: Incompatible matrices (1×2 and 1×2) return null.

How It Works

  • Step 1: The multiplyMatrices method checks for null, empty, or incompatible matrices. For A = [[1, 2], [3, 4]], B = [[5, 6], [7, 8]], it proceeds (2×2 and 2×2 are compatible).
  • Step 2: Initialize m = 2, n = 2, p = 2, and create a 2×2 result matrix.
  • Step 3: Compute each element:
    • result[0][0]: 1*5 + 2*7 = 19.
    • result[0][1]: 1*6 + 2*8 = 22.
    • result[1][0]: 3*5 + 4*7 = 43.
    • result[1][1]: 3*6 + 4*8 = 50.
  • Example Trace: For test case 1, computes [[19, 22], [43, 50]] by summing products of rows of A with columns of B.
  • Main Method: Tests with square (2×2), non-square (1×3 × 3×1, 3×2 × 2×3), and incompatible matrices, printing inputs and results.
  • Matrix Multiplication: Each element result[i][j] is the dot product of row i of A and column j of B.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
MultiplicationO(mnp)O(m*p)
Full AlgorithmO(mnp)O(m*p)

Note:

  • m is the number of rows in A, n is the number of columns in A/rows in B, p is the number of columns in B.
  • Time complexity: O(mnp), as the algorithm iterates m*p times, each performing n multiplications and additions.
  • Space complexity: O(m*p), for the result matrix. Temporary variables use O(1) space.
  • Best, average, and worst cases are O(mnp).

✅ Tip: Always check matrix compatibility before multiplication to avoid errors. Test with various sizes, including single-row/column matrices, to ensure robustness.

⚠ Warning: Ensure matrices are not null and have consistent row/column sizes to avoid NullPointerException or ArrayIndexOutOfBoundsException. Be cautious with large matrices, as O(mnp) time complexity can be slow.

Transpose Matrix

Problem Statement

Write a Java program that implements a method to transpose a 2D array (matrix), swapping its rows and columns to create a new matrix where the element at position [i][j] in the original matrix becomes the element at position [j][i] in the result. The program should return the transposed matrix as a new 2D array and test the implementation with square and non-square matrices, including edge cases like single-row or single-column matrices. You can visualize this as flipping a grid of numbers over its main diagonal, turning rows into columns and columns into rows, like rearranging a chessboard so that the row of pieces becomes a column.

Input: A 2D array (matrix) of integers with dimensions m × n (e.g., matrix = [[1, 2, 3], [4, 5, 6]]). Output: A new 2D array of dimensions n × m representing the transposed matrix (e.g., [[1, 4], [2, 5], [3, 6]]). Constraints:

  • 1 ≤ m, n ≤ 100 (where m is the number of rows and n is the number of columns).
  • Elements are integers between -10^9 and 10^9.
  • The matrix is guaranteed to be non-empty and rectangular (all rows have the same number of columns). Example:
  • Input: matrix = [[1, 2, 3], [4, 5, 6]]
  • Output: [[1, 4], [2, 5], [3, 6]]
  • Explanation: The 2×3 matrix is transposed to a 3×2 matrix, with row 1 ([1, 2, 3]) becoming column 1 ([1, 4]), etc.
  • Input: matrix = [[1, 2], [3, 4]]
  • Output: [[1, 3], [2, 4]]
  • Explanation: The 2×2 square matrix is transposed, swapping elements across the main diagonal.

Pseudocode

FUNCTION transposeMatrix(matrix)
    IF matrix is null OR matrix is empty THEN
        RETURN null
    ENDIF
    SET rows to number of rows in matrix
    SET cols to number of columns in matrix
    CREATE result matrix of size cols × rows
    FOR i from 0 to rows - 1
        FOR j from 0 to cols - 1
            SET result[j][i] to matrix[i][j]
        ENDFOR
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION main()
    SET testMatrices to arrays of 2D arrays
    FOR each matrix in testMatrices
        CALL transposeMatrix(matrix)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input matrix is null or empty. If so, return null.
  2. Determine the dimensions of the input matrix: rows (m) and columns (n).
  3. Create a new result matrix of size n × m (columns × rows).
  4. Iterate through each element matrix[i][j] in the input matrix: a. Set result[j][i] to matrix[i][j], effectively swapping rows and columns.
  5. Return the result matrix.
  6. In the main method, create test matrices (square and non-square) with various sizes and call transposeMatrix to verify correctness.

Java Implementation

public class TransposeMatrix {
    // Transposes a matrix by swapping rows and columns
    public int[][] transposeMatrix(int[][] matrix) {
        // Check for null or empty matrix
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
            return null;
        }
        // Get dimensions
        int rows = matrix.length;
        int cols = matrix[0].length;
        // Create result matrix with swapped dimensions
        int[][] result = new int[cols][rows];
        // Transpose by copying elements
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                result[j][i] = matrix[i][j];
            }
        }
        return result;
    }

    // Main method to test transposeMatrix with various inputs
    public static void main(String[] args) {
        TransposeMatrix transposer = new TransposeMatrix();

        // Test case 1: 2x3 non-square matrix
        int[][] matrix1 = {{1, 2, 3}, {4, 5, 6}};
        System.out.println("Test case 1:");
        System.out.println("Original matrix:");
        printMatrix(matrix1);
        int[][] result1 = transposer.transposeMatrix(matrix1);
        System.out.println("Transposed matrix:");
        printMatrix(result1);

        // Test case 2: 2x2 square matrix
        int[][] matrix2 = {{1, 2}, {3, 4}};
        System.out.println("Test case 2:");
        System.out.println("Original matrix:");
        printMatrix(matrix2);
        int[][] result2 = transposer.transposeMatrix(matrix2);
        System.out.println("Transposed matrix:");
        printMatrix(result2);

        // Test case 3: 1x3 single-row matrix
        int[][] matrix3 = {{1, 2, 3}};
        System.out.println("Test case 3:");
        System.out.println("Original matrix:");
        printMatrix(matrix3);
        int[][] result3 = transposer.transposeMatrix(matrix3);
        System.out.println("Transposed matrix:");
        printMatrix(result3);

        // Test case 4: 3x1 single-column matrix
        int[][] matrix4 = {{1}, {2}, {3}};
        System.out.println("Test case 4:");
        System.out.println("Original matrix:");
        printMatrix(matrix4);
        int[][] result4 = transposer.transposeMatrix(matrix4);
        System.out.println("Transposed matrix:");
        printMatrix(result4);

        // Test case 5: Empty matrix
        int[][] matrix5 = {};
        System.out.println("Test case 5:");
        System.out.println("Original matrix:");
        printMatrix(matrix5);
        int[][] result5 = transposer.transposeMatrix(matrix5);
        System.out.println("Transposed matrix:");
        printMatrix(result5);
    }

    // Helper method to print matrix
    private static void printMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            System.out.println("null");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < matrix.length; i++) {
            System.out.print("  [");
            for (int j = 0; j < matrix[i].length; j++) {
                System.out.print(matrix[i][j]);
                if (j < matrix[i].length - 1) {
                    System.out.print(", ");
                }
            }
            System.out.println("]");
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Original matrix:
[
  [1, 2, 3]
  [4, 5, 6]
]
Transposed matrix:
[
  [1, 4]
  [2, 5]
  [3, 6]
]
Test case 2:
Original matrix:
[
  [1, 2]
  [3, 4]
]
Transposed matrix:
[
  [1, 3]
  [2, 4]
]
Test case 3:
Original matrix:
[
  [1, 2, 3]
]
Transposed matrix:
[
  [1]
  [2]
  [3]
]
Test case 4:
Original matrix:
[
  [1]
  [2]
  [3]
]
Transposed matrix:
[
  [1, 2, 3]
]
Test case 5:
Original matrix:
null
Transposed matrix:
null

Explanation:

  • Test case 1: Transposes a 2×3 matrix to a 3×2 matrix.
  • Test case 2: Transposes a 2×2 square matrix, swapping elements across the diagonal.
  • Test case 3: Transposes a 1×3 matrix to a 3×1 matrix.
  • Test case 4: Transposes a 3×1 matrix to a 1×3 matrix.
  • Test case 5: Returns null for an empty matrix.

How It Works

  • Step 1: The transposeMatrix method checks if the matrix is null or empty. For [[1, 2, 3], [4, 5, 6]], it proceeds.
  • Step 2: Get dimensions: rows = 2, cols = 3.
  • Step 3: Create a result matrix of size 3×2.
  • Step 4: Iterate through the input matrix:
    • matrix[0][0] = 1result[0][0] = 1
    • matrix[0][1] = 2result[1][0] = 2
    • matrix[0][2] = 3result[2][0] = 3
    • matrix[1][0] = 4result[0][1] = 4
    • matrix[1][1] = 5result[1][1] = 5
    • matrix[1][2] = 6result[2][1] = 6
  • Example Trace: For [[1, 2, 3], [4, 5, 6]], builds [[1, 4], [2, 5], [3, 6]] by swapping indices.
  • Main Method: Tests with square (2×2), non-square (2×3, 1×3, 3×1), and empty matrices, printing inputs and results.
  • Transpose Property: Creates a new matrix with swapped dimensions, where each matrix[i][j] maps to result[j][i].

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
TransposeO(m*n)O(m*n)
Full AlgorithmO(m*n)O(m*n)

Note:

  • m is the number of rows, n is the number of columns in the input matrix.
  • Time complexity: O(m*n), as the algorithm iterates through each element of the m×n matrix once.
  • Space complexity: O(m*n), for the result matrix. Temporary variables use O(1) space.
  • Best, average, and worst cases are O(m*n).

✅ Tip: Ensure the result matrix has dimensions n×m for an m×n input matrix. Test with both square and non-square matrices to verify correctness, especially single-row or single-column cases.

⚠ Warning: Ensure the input matrix is not null and is rectangular (all rows have the same number of columns) to avoid NullPointerException or ArrayIndexOutOfBoundsException.

Wave Traversal

Problem Statement

Write a Java program that implements a method to traverse a 2D array (matrix) in a wave pattern, where even-indexed columns (0-based) are traversed top-to-bottom and odd-indexed columns are traversed bottom-to-top. The program should return a 1D array containing the elements in the order of traversal and test the implementation with matrices of different sizes, including edge cases like single-row or single-column matrices. You can visualize this as navigating a grid like a wave, moving down even columns and up odd columns, collecting numbers like a surfer riding along a zigzag path.

Input: A 2D array (matrix) of integers with dimensions m × n (e.g., matrix = [[1, 2, 3], [4, 5, 6]]). Output: A 1D array containing the elements in wave traversal order (e.g., [1, 4, 6, 5, 3, 2]). Constraints:

  • 1 ≤ m, n ≤ 100 (where m is the number of rows and n is the number of columns).
  • Elements are integers between -10^9 and 10^9.
  • The matrix is guaranteed to be non-empty and rectangular (all rows have the same number of columns). Example:
  • Input: matrix = [[1, 2, 3], [4, 5, 6]]
  • Output: [1, 4, 6, 5, 3, 2]
  • Explanation: Column 0 (even): top-to-bottom [1, 4]; Column 1 (odd): bottom-to-top [6, 5]; Column 2 (even): top-to-bottom [3, 2].
  • Input: matrix = [[1, 2], [3, 4], [5, 6]]
  • Output: [1, 3, 5, 6, 4, 2]
  • Explanation: Column 0 (even): top-to-bottom [1, 3, 5]; Column 1 (odd): bottom-to-top [6, 4, 2].

Pseudocode

FUNCTION waveTraversal(matrix)
    IF matrix is null OR matrix is empty OR matrix[0] is empty THEN
        RETURN null
    ENDIF
    SET rows to number of rows in matrix
    SET cols to number of columns in matrix
    CREATE result array of size rows * cols
    SET index to 0
    FOR j from 0 to cols - 1
        IF j is even THEN
            FOR i from 0 to rows - 1
                SET result[index] to matrix[i][j]
                INCREMENT index
            ENDFOR
        ELSE
            FOR i from rows - 1 to 0
                SET result[index] to matrix[i][j]
                INCREMENT index
            ENDFOR
        ENDIF
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION main()
    SET testMatrices to 2D arrays
    FOR each matrix in testMatrices
        CALL waveTraversal(matrix)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input matrix is null, empty, or has empty rows. If so, return null.
  2. Get the dimensions: rows (m) and columns (n).
  3. Create a 1D result array of size m * n to store the wave traversal elements.
  4. Initialize an index to track the position in the result array.
  5. Iterate through each column j from 0 to n-1: a. If j is even, traverse top-to-bottom (i from 0 to rows-1), adding matrix[i][j] to result. b. If j is odd, traverse bottom-to-top (i from rows-1 to 0), adding matrix[i][j] to result. c. Increment the index after each element is added.
  6. Return the result array.
  7. In the main method, create test matrices of different sizes and call waveTraversal to verify correctness.

Java Implementation

public class WaveTraversal {
    // Traverses matrix in wave pattern and returns 1D array
    public int[] waveTraversal(int[][] matrix) {
        // Check for null or empty matrix
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
            return null;
        }
        // Get dimensions
        int rows = matrix.length;
        int cols = matrix[0].length;
        // Create result array
        int[] result = new int[rows * cols];
        int index = 0;
        // Traverse each column
        for (int j = 0; j < cols; j++) {
            // Even column: top-to-bottom
            if (j % 2 == 0) {
                for (int i = 0; i < rows; i++) {
                    result[index++] = matrix[i][j];
                }
            }
            // Odd column: bottom-to-top
            else {
                for (int i = rows - 1; i >= 0; i--) {
                    result[index++] = matrix[i][j];
                }
            }
        }
        return result;
    }

    // Main method to test waveTraversal with various inputs
    public static void main(String[] args) {
        WaveTraversal traverser = new WaveTraversal();

        // Test case 1: 2x3 matrix
        int[][] matrix1 = {{1, 2, 3}, {4, 5, 6}};
        System.out.println("Test case 1:");
        System.out.println("Matrix:");
        printMatrix(matrix1);
        System.out.print("Wave traversal: ");
        printArray(traverser.waveTraversal(matrix1));

        // Test case 2: 3x2 matrix
        int[][] matrix2 = {{1, 2}, {3, 4}, {5, 6}};
        System.out.println("Test case 2:");
        System.out.println("Matrix:");
        printMatrix(matrix2);
        System.out.print("Wave traversal: ");
        printArray(traverser.waveTraversal(matrix2));

        // Test case 3: 1x3 single-row matrix
        int[][] matrix3 = {{1, 2, 3}};
        System.out.println("Test case 3:");
        System.out.println("Matrix:");
        printMatrix(matrix3);
        System.out.print("Wave traversal: ");
        printArray(traverser.waveTraversal(matrix3));

        // Test case 4: 3x1 single-column matrix
        int[][] matrix4 = {{1}, {2}, {3}};
        System.out.println("Test case 4:");
        System.out.println("Matrix:");
        printMatrix(matrix4);
        System.out.print("Wave traversal: ");
        printArray(traverser.waveTraversal(matrix4));

        // Test case 5: 2x2 matrix with negative numbers
        int[][] matrix5 = {{-1, -2}, {-3, -4}};
        System.out.println("Test case 5:");
        System.out.println("Matrix:");
        printMatrix(matrix5);
        System.out.print("Wave traversal: ");
        printArray(traverser.waveTraversal(matrix5));

        // Test case 6: Null matrix
        int[][] matrix6 = null;
        System.out.println("Test case 6:");
        System.out.println("Matrix:");
        printMatrix(matrix6);
        System.out.print("Wave traversal: ");
        printArray(traverser.waveTraversal(matrix6));
    }

    // Helper method to print matrix
    private static void printMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            System.out.println("null");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < matrix.length; i++) {
            System.out.print("  [");
            for (int j = 0; j < matrix[i].length; j++) {
                System.out.print(matrix[i][j]);
                if (j < matrix[i].length - 1) {
                    System.out.print(", ");
                }
            }
            System.out.println("]");
        }
        System.out.println("]");
    }

    // Helper method to print 1D array
    private static void printArray(int[] arr) {
        if (arr == null) {
            System.out.println("null");
            return;
        }
        System.out.print("[");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
            if (i < arr.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Matrix:
[
  [1, 2, 3]
  [4, 5, 6]
]
Wave traversal: [1, 4, 6, 5, 3, 2]
Test case 2:
Matrix:
[
  [1, 2]
  [3, 4]
  [5, 6]
]
Wave traversal: [1, 3, 5, 6, 4, 2]
Test case 3:
Matrix:
[
  [1, 2, 3]
]
Wave traversal: [1, 3, 2]
Test case 4:
Matrix:
[
  [1]
  [2]
  [3]
]
Wave traversal: [1, 2, 3]
Test case 5:
Matrix:
[
  [-1, -2]
  [-3, -4]
]
Wave traversal: [-1, -3, -4, -2]
Test case 6:
Matrix:
null
Wave traversal: null

Explanation:

  • Test case 1: Traverses 2×3 matrix: Col 0 (even): [1, 4]; Col 1 (odd): [6, 5]; Col 2 (even): [3, 2].
  • Test case 2: Traverses 3×2 matrix: Col 0 (even): [1, 3, 5]; Col 1 (odd): [6, 4, 2].
  • Test case 3: Traverses 1×3 matrix: Col 0 (even): [1]; Col 1 (odd): [3]; Col 2 (even): [2].
  • Test case 4: Traverses 3×1 matrix: Col 0 (even): [1, 2, 3].
  • Test case 5: Traverses 2×2 matrix with negatives: Col 0 (even): [-1, -3]; Col 1 (odd): [-4, -2].
  • Test case 6: Returns null for a null matrix.

How It Works

  • Step 1: The waveTraversal method checks for null or empty matrices. For [[1, 2, 3], [4, 5, 6]], it proceeds.
  • Step 2: Get dimensions: rows = 2, cols = 3.
  • Step 3: Create a result array of size 2 * 3 = 6.
  • Step 4: Iterate through columns:
    • Col 0 (even): i = 0 to 1result[0] = matrix[0][0] = 1, result[1] = matrix[1][0] = 4.
    • Col 1 (odd): i = 1 to 0result[2] = matrix[1][1] = 6, result[3] = matrix[0][1] = 5.
    • Col 2 (even): i = 0 to 1result[4] = matrix[0][2] = 3, result[5] = matrix[1][2] = 2.
  • Example Trace: For test case 1, builds [1, 4, 6, 5, 3, 2] by alternating directions.
  • Main Method: Tests with different sizes (2×3, 3×2, 1×3, 3×1, 2×2) and a null matrix, printing inputs and results.
  • Wave Property: Alternates top-to-bottom and bottom-to-top traversal based on column index parity.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
TraversalO(m*n)O(m*n)
Full AlgorithmO(m*n)O(m*n)

Note:

  • m is the number of rows, n is the number of columns.
  • Time complexity: O(m*n), as the algorithm visits each element once.
  • Space complexity: O(m*n), for the result array. Temporary variables use O(1) space.
  • Best, average, and worst cases are O(m*n).

✅ Tip: Use a single index to track the result array position for simplicity. Test with single-row, single-column, and negative number matrices to ensure the wave pattern is correct.

⚠ Warning: Ensure the input matrix is not null and is rectangular to avoid NullPointerException or ArrayIndexOutOfBoundsException. Verify consistent row lengths for a well-formed matrix.

Adjacency List Representation

Problem Statement

Write a Java program that implements an undirected graph using a jagged array as an adjacency list. The program should include methods to add edges between vertices and print the neighbors of each vertex. Test the implementation with a sample graph, including cases with varying numbers of vertices and edges, and ensure the graph correctly represents undirected connections (i.e., an edge from u to v implies an edge from v to u). You can visualize this as creating a map of friendships, where each person (vertex) has a list of friends (neighbors), and friendships are mutual.

Input:

  • Number of vertices V (e.g., V = 4).
  • Edges as pairs of vertices (e.g., (0, 1), (1, 2), (2, 3)). Output:
  • For each vertex, print its neighbors as a list (e.g., for vertex 0: [1], for vertex 1: [0, 2], etc.). Constraints:
  • 1 ≤ V ≤ 100 (number of vertices).
  • Vertices are labeled from 0 to V-1.
  • Edges are valid pairs of distinct vertices (u ≠ v).
  • The graph is undirected and simple (no self-loops or multiple edges between the same vertices). Example:
  • Input: V = 4, edges = [(0, 1), (1, 2), (2, 3), (0, 2)]
  • Output:
    Vertex 0 neighbors: [1, 2]
    Vertex 1 neighbors: [0, 2]
    Vertex 2 neighbors: [1, 3, 0]
    Vertex 3 neighbors: [2]
    
  • Explanation: The graph has 4 vertices. Vertex 0 is connected to 1 and 2, vertex 1 to 0 and 2, vertex 2 to 1, 3, and 0, and vertex 3 to 2.

Pseudocode

FUNCTION initializeGraph(V)
    CREATE jagged array adjList of size V
    FOR i from 0 to V - 1
        SET adjList[i] to empty array
    ENDFOR
    RETURN adjList
ENDFUNCTION

FUNCTION addEdge(adjList, u, v)
    IF adjList is null OR u < 0 OR u >= length of adjList OR v < 0 OR v >= length of adjList OR u equals v THEN
        RETURN
    ENDIF
    IF v not in adjList[u] THEN
        ADD v to adjList[u]
    ENDIF
    IF u not in adjList[v] THEN
        ADD u to adjList[v]
    ENDIF
ENDFUNCTION

FUNCTION printNeighbors(adjList)
    IF adjList is null OR adjList is empty THEN
        PRINT "Graph is empty"
        RETURN
    ENDIF
    FOR i from 0 to length of adjList - 1
        PRINT "Vertex i neighbors: " followed by adjList[i]
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET V to number of vertices
    CALL initializeGraph(V)
    SET edges to list of edge pairs
    FOR each (u, v) in edges
        CALL addEdge(adjList, u, v)
    ENDFOR
    CALL printNeighbors(adjList)
ENDFUNCTION

Algorithm Steps

  1. Initialize Graph: a. Create a jagged array adjList of size V, where each element is an empty array (to store neighbors).
  2. Add Edge: a. Validate inputs: check if adjList is null, or vertices u, v are out of bounds or equal (self-loop). b. Add v to adjList[u] if not already present (to avoid duplicates). c. Add u to adjList[v] if not already present (for undirected graph).
  3. Print Neighbors: a. Check if adjList is null or empty. If so, print "Graph is empty". b. For each vertex i, print its index and the array of neighbors adjList[i].
  4. In the main method, initialize a graph with a specified number of vertices, add edges from a sample edge list, and print the neighbors of each vertex.

Java Implementation

import java.util.ArrayList;

public class AdjacencyListRepresentation {
    // Initializes a graph with V vertices as a jagged array
    private ArrayList<Integer>[] adjList;

    // Constructor to initialize graph
    @SuppressWarnings("unchecked")
    public AdjacencyListRepresentation(int V) {
        adjList = (ArrayList<Integer>[]) new ArrayList[V];
        for (int i = 0; i < V; i++) {
            adjList[i] = new ArrayList<>();
        }
    }

    // Adds an edge between vertices u and v
    public void addEdge(int u, int v) {
        // Validate inputs
        if (adjList == null || u < 0 || u >= adjList.length || v < 0 || v >= adjList.length || u == v) {
            return;
        }
        // Add v to u's list if not already present
        if (!adjList[u].contains(v)) {
            adjList[u].add(v);
        }
        // Add u to v's list if not already present (undirected)
        if (!adjList[v].contains(u)) {
            adjList[v].add(u);
        }
    }

    // Prints neighbors of each vertex
    public void printNeighbors() {
        if (adjList == null || adjList.length == 0) {
            System.out.println("Graph is empty");
            return;
        }
        for (int i = 0; i < adjList.length; i++) {
            System.out.print("Vertex " + i + " neighbors: [");
            for (int j = 0; j < adjList[i].size(); j++) {
                System.out.print(adjList[i].get(j));
                if (j < adjList[i].size() - 1) {
                    System.out.print(", ");
                }
            }
            System.out.println("]");
        }
    }

    // Main method to test the graph implementation
    public static void main(String[] args) {
        // Test case 1: Sample graph with 4 vertices
        System.out.println("Test case 1: Graph with 4 vertices");
        AdjacencyListRepresentation graph1 = new AdjacencyListRepresentation(4);
        int[][] edges1 = {{0, 1}, {1, 2}, {2, 3}, {0, 2}};
        for (int[] edge : edges1) {
            graph1.addEdge(edge[0], edge[1]);
        }
        graph1.printNeighbors();

        // Test case 2: Single vertex, no edges
        System.out.println("\nTest case 2: Single vertex");
        AdjacencyListRepresentation graph2 = new AdjacencyListRepresentation(1);
        graph2.printNeighbors();

        // Test case 3: Graph with 5 vertices, more edges
        System.out.println("\nTest case 3: Graph with 5 vertices");
        AdjacencyListRepresentation graph3 = new AdjacencyListRepresentation(5);
        int[][] edges3 = {{0, 1}, {0, 2}, {0, 3}, {1, 3}, {1, 4}, {2, 4}};
        for (int[] edge : edges3) {
            graph3.addEdge(edge[0], edge[1]);
        }
        graph3.printNeighbors();

        // Test case 4: Empty graph (0 vertices)
        System.out.println("\nTest case 4: Empty graph");
        AdjacencyListRepresentation graph4 = new AdjacencyListRepresentation(0);
        graph4.printNeighbors();
    }
}

Output

Running the main method produces:

Test case 1: Graph with 4 vertices
Vertex 0 neighbors: [1, 2]
Vertex 1 neighbors: [0, 2]
Vertex 2 neighbors: [1, 3, 0]
Vertex 3 neighbors: [2]

Test case 2: Single vertex
Vertex 0 neighbors: []

Test case 3: Graph with 5 vertices
Vertex 0 neighbors: [1, 2, 3]
Vertex 1 neighbors: [0, 3, 4]
Vertex 2 neighbors: [0, 4]
Vertex 3 neighbors: [0, 1]
Vertex 4 neighbors: [1, 2]

Test case 4: Empty graph
Graph is empty

Explanation:

  • Test case 1: Graph with 4 vertices and edges (0,1), (1,2), (2,3), (0,2). Vertex 0 is connected to 1 and 2, etc.
  • Test case 2: Single vertex with no edges, so its neighbor list is empty.
  • Test case 3: Graph with 5 vertices and more edges, forming a connected structure.
  • Test case 4: Empty graph (0 vertices) prints "Graph is empty".

How It Works

  • Initialization: Creates a jagged array (ArrayList<Integer>[]) of size V, with each element as an empty ArrayList for neighbors.
  • Add Edge: For edge (u, v), adds v to adjList[u] and u to adjList[v] if not already present, ensuring undirected edges without duplicates.
  • Print Neighbors: Iterates through each vertex and prints its neighbor list.
  • Example Trace (Test case 1):
    • Initialize: adjList = [[], [], [], []] for V = 4.
    • Add edge (0,1): adjList[0] = [1], adjList[1] = [0].
    • Add edge (1,2): adjList[1] = [0, 2], adjList[2] = [1].
    • Add edge (2,3): adjList[2] = [1, 3], adjList[3] = [2].
    • Add edge (0,2): adjList[0] = [1, 2], adjList[2] = [1, 3, 0].
    • Print: Vertex 0: [1, 2], Vertex 1: [0, 2], etc.
  • Main Method: Tests with a 4-vertex graph, a single-vertex graph, a 5-vertex graph, and an empty graph.
  • Adjacency List Property: Uses a jagged array for space efficiency, storing only actual neighbors.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Initialize GraphO(V)O(V)
Add EdgeO(1) averageO(1) per edge
Print NeighborsO(V + E)O(1)
Full AlgorithmO(V + E)O(V + E)

Note:

  • V is the number of vertices, E is the number of edges.
  • Initialize: O(V) to create V empty lists.
  • Add Edge: O(1) average for ArrayList add and contains (amortized, assuming hash-based checks).
  • Print Neighbors: O(V + E) to iterate through all vertices and their edges.
  • Space: O(V + E) for the jagged array storing V lists and E edges (undirected edges stored twice).
  • Worst case for dense graph: O(V^2) space if E ≈ V^2.

✅ Tip: Use ArrayList for dynamic neighbor lists to handle varying degrees. Test with sparse and dense graphs to verify correctness, including single-vertex and empty graphs.

⚠ Warning: Validate vertex indices to avoid ArrayIndexOutOfBoundsException. Ensure edges are not self-loops (u ≠ v) and check for duplicates to maintain a simple graph.

Dynamic Row Addition

Problem Statement

Write a Java program that allows users to interactively add rows to a jagged array, specifying the length and elements of each row. The program should store the jagged array dynamically, support adding multiple rows with different lengths, and print the resulting array after each addition. Test the implementation with multiple row additions, including edge cases like empty rows or no additions. You can visualize this as building a flexible grid where users can append new rows of varying sizes, like adding shelves of different lengths to a bookcase and filling them with books.

Input:

  • Number of rows to add (e.g., 3).
  • For each row: length of the row and its elements (e.g., length = 3, elements = [1, 2, 3]). Output:
  • The jagged array after each row addition, displayed as a 2D array (e.g., [[1, 2, 3], [4, 5], [6, 7, 8, 9]] after three additions). Constraints:
  • The number of rows to add is between 0 and 100.
  • Each row’s length is between 0 and 100.
  • Elements are integers between -10^4 and 10^4.
  • The input is valid (row lengths and elements are within constraints). Example:
  • Input: Add 3 rows:
    • Row 1: length = 3, elements = [1, 2, 3]
    • Row 2: length = 2, elements = [4, 5]
    • Row 3: length = 4, elements = [6, 7, 8, 9]
  • Output after each addition:
    After adding row 1: [[1, 2, 3]]
    After adding row 2: [[1, 2, 3], [4, 5]]
    After adding row 3: [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
    
  • Input: Add 1 row:
    • Row 1: length = 0, elements = []
  • Output: [[]]

Pseudocode

FUNCTION addRow(jaggedArray, rowLength, elements)
    IF rowLength < 0 OR elements length not equal to rowLength THEN
        RETURN
    ENDIF
    CREATE newRow array of size rowLength
    FOR i from 0 to rowLength - 1
        SET newRow[i] to elements[i]
    ENDFOR
    ADD newRow to jaggedArray
ENDFUNCTION

FUNCTION main()
    SET jaggedArray to empty dynamic array
    SET testCases to list of (rowLength, elements) pairs
    FOR each (rowLength, elements) in testCases
        PRINT current jaggedArray
        CALL addRow(jaggedArray, rowLength, elements)
        PRINT updated jaggedArray
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Initialize an empty dynamic structure (ArrayList<int[]>) to store the jagged array.
  2. For each row addition: a. Validate the input: ensure row length is non-negative and matches the number of elements provided. b. Create a new array of the specified length and populate it with the provided elements. c. Add the new array as a row to the jagged array.
  3. Print the jagged array before and after each addition to show the changes.
  4. In the main method, simulate user input with predefined test cases, including rows of different lengths, empty rows, and edge cases, to verify correctness.

Java Implementation

import java.util.ArrayList;

public class DynamicRowAddition {
    // Adds a row to the jagged array with specified length and elements
    public void addRow(ArrayList<int[]> jaggedArray, int rowLength, int[] elements) {
        // Validate input
        if (rowLength < 0 || elements == null || elements.length != rowLength) {
            return;
        }
        // Create new row and copy elements
        int[] newRow = new int[rowLength];
        for (int i = 0; i < rowLength; i++) {
            newRow[i] = elements[i];
        }
        // Add row to jagged array
        jaggedArray.add(newRow);
    }

    // Main method to test addRow with various inputs
    public static void main(String[] args) {
        DynamicRowAddition adder = new DynamicRowAddition();
        
        // Test case 1: Add multiple rows with different lengths
        System.out.println("Test case 1: Adding multiple rows");
        ArrayList<int[]> jaggedArray1 = new ArrayList<>();
        int[][] testRows1 = {
            {1, 2, 3},       // Row of length 3
            {4, 5},          // Row of length 2
            {6, 7, 8, 9}     // Row of length 4
        };
        for (int i = 0; i < testRows1.length; i++) {
            System.out.println("Before adding row " + (i + 1) + ":");
            printJaggedArray(jaggedArray1);
            adder.addRow(jaggedArray1, testRows1[i].length, testRows1[i]);
            System.out.println("After adding row " + (i + 1) + ":");
            printJaggedArray(jaggedArray1);
        }

        // Test case 2: Add a single empty row
        System.out.println("\nTest case 2: Adding an empty row");
        ArrayList<int[]> jaggedArray2 = new ArrayList<>();
        System.out.println("Before adding row:");
        printJaggedArray(jaggedArray2);
        adder.addRow(jaggedArray2, 0, new int[]{});
        System.out.println("After adding row:");
        printJaggedArray(jaggedArray2);

        // Test case 3: Add rows with negative numbers
        System.out.println("\nTest case 3: Adding rows with negative numbers");
        ArrayList<int[]> jaggedArray3 = new ArrayList<>();
        int[][] testRows3 = {
            {-1, -2},        // Row of length 2
            {-3, -4, -5}     // Row of length 3
        };
        for (int i = 0; i < testRows3.length; i++) {
            System.out.println("Before adding row " + (i + 1) + ":");
            printJaggedArray(jaggedArray3);
            adder.addRow(jaggedArray3, testRows3[i].length, testRows3[i]);
            System.out.println("After adding row " + (i + 1) + ":");
            printJaggedArray(jaggedArray3);
        }

        // Test case 4: No rows added
        System.out.println("\nTest case 4: No rows added");
        ArrayList<int[]> jaggedArray4 = new ArrayList<>();
        System.out.println("Jagged array:");
        printJaggedArray(jaggedArray4);
    }

    // Helper method to print jagged array
    private static void printJaggedArray(ArrayList<int[]> jaggedArray) {
        if (jaggedArray == null || jaggedArray.isEmpty()) {
            System.out.println("[]");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < jaggedArray.size(); i++) {
            System.out.print("  [");
            if (jaggedArray.get(i).length == 0) {
                System.out.print("]");
            } else {
                for (int j = 0; j < jaggedArray.get(i).length; j++) {
                    System.out.print(jaggedArray.get(i)[j]);
                    if (j < jaggedArray.get(i).length - 1) {
                        System.out.print(", ");
                    }
                }
                System.out.print("]");
            }
            System.out.println();
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1: Adding multiple rows
Before adding row 1:
[]
After adding row 1:
[
  [1, 2, 3]
]
Before adding row 2:
[
  [1, 2, 3]
]
After adding row 2:
[
  [1, 2, 3]
  [4, 5]
]
Before adding row 3:
[
  [1, 2, 3]
  [4, 5]
]
After adding row 3:
[
  [1, 2, 3]
  [4, 5]
  [6, 7, 8, 9]
]

Test case 2: Adding an empty row
Before adding row:
[]
After adding row:
[
  []
]

Test case 3: Adding rows with negative numbers
Before adding row 1:
[]
After adding row 1:
[
  [-1, -2]
]
Before adding row 2:
[
  [-1, -2]
]
After adding row 2:
[
  [-1, -2]
  [-3, -4, -5]
]

Test case 4: No rows added
Jagged array:
[]

Explanation:

  • Test case 1: Adds three rows of lengths 3, 2, and 4, building the jagged array incrementally.
  • Test case 2: Adds a single empty row, resulting in [[]].
  • Test case 3: Adds two rows with negative numbers, lengths 2 and 3.
  • Test case 4: Shows an empty jagged array when no rows are added.

How It Works

  • Step 1: Initialize an ArrayList<int[]> to store the jagged array dynamically.
  • Step 2: For each row addition in addRow:
    • Validate: Ensure rowLength is non-negative and matches elements.length.
    • Create a new int[] of size rowLength and copy elements into it.
    • Add the new row to the jaggedArray.
  • Example Trace (Test case 1):
    • Start: jaggedArray = [].
    • Add row 1: [1, 2, 3][[1, 2, 3]].
    • Add row 2: [4, 5][[1, 2, 3], [4, 5]].
    • Add row 3: [6, 7, 8, 9][[1, 2, 3], [4, 5], [6, 7, 8, 9]].
  • Main Method: Simulates user input with test cases, including multiple rows, an empty row, negative numbers, and no additions, printing the array before and after each addition.
  • Dynamic Property: Uses ArrayList for dynamic row addition, allowing flexible row lengths.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Add RowO(n)O(n)
Full AlgorithmO(N)O(N)

Note:

  • n is the length of the row being added.
  • N is the total number of elements across all rows added.
  • Time complexity: O(n) per row addition (copying elements to new array), O(N) for all additions.
  • Space complexity: O(n) per row (new array), O(N) for the entire jagged array.
  • Worst case: O(N) time and space for adding all elements across multiple rows.

✅ Tip: Use ArrayList<int[]> for dynamic row management. Test with empty rows, varying lengths, and negative numbers to ensure flexibility and correctness.

⚠ Warning: Validate that the number of elements matches the specified row length to avoid ArrayIndexOutOfBoundsException. Ensure the jagged array is initialized to avoid NullPointerException.

Jagged Array Transpose

Problem Statement

Write a Java program that implements a method to transpose a jagged array (a 2D array where each row can have a different length), converting rows to columns to create a new jagged array. The i-th row of the transposed array contains all elements from the i-th column of the original array, with row lengths varying based on the number of non-null elements in each column. The program should return the transposed array and test the implementation with irregular jagged arrays, including edge cases like empty arrays, arrays with empty rows, and arrays with varying row lengths. You can visualize this as flipping a ragged grid so that each column becomes a row, like reorganizing a collection of uneven lists into a new set of lists based on column positions.

Input: A jagged 2D array of integers, where each row may have a different length (e.g., matrix = {{1, 2, 3}, {4}, {5, 6}}). Output: A new jagged array where the i-th row contains elements from the i-th column of the input (e.g., {{1, 4, 5}, {2, 6}, {3}}). Constraints:

  • The number of rows is between 0 and 100.
  • Each row’s length is between 0 and 100.
  • Elements are integers between -10^4 and 10^4.
  • The matrix may be empty or contain empty or null rows. Example:
  • Input: matrix = {{1, 2, 3}, {4}, {5, 6}}
  • Output: {{1, 4, 5}, {2, 6}, {3}}
  • Explanation: Column 0 ([1, 4, 5]) becomes row 0, column 1 ([2, 6]) becomes row 1, column 2 ([3]) becomes row 2.
  • Input: matrix = {{1, 2}, {}, {3}}
  • Output: {{1, 3}, {2}}
  • Explanation: Column 0 ([1, 3]) becomes row 0, column 1 ([2]) becomes row 1.

Pseudocode

FUNCTION transposeJaggedArray(matrix)
    IF matrix is null OR matrix is empty THEN
        RETURN empty array
    ENDIF
    SET maxCols to maximum row length in matrix
    CREATE result as empty dynamic array
    FOR j from 0 to maxCols - 1
        CREATE tempList as empty list
        FOR i from 0 to number of rows in matrix - 1
            IF matrix[i] is not null AND j < length of matrix[i] THEN
                ADD matrix[i][j] to tempList
            ENDIF
        ENDFOR
        IF tempList is not empty THEN
            CREATE newRow array of size tempList size
            COPY tempList elements to newRow
            ADD newRow to result
        ENDIF
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION main()
    SET testMatrices to jagged 2D arrays
    FOR each matrix in testMatrices
        PRINT original matrix
        CALL transposeJaggedArray(matrix)
        PRINT transposed matrix
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input matrix is null or empty. If so, return an empty array.
  2. Determine the maximum number of columns (maxCols) by finding the longest row.
  3. Initialize an empty ArrayList<int[]> for the result.
  4. For each column index j from 0 to maxCols-1: a. Create a temporary list to collect elements from column j. b. Iterate through each row i:
    • If row i is not null and j is within its length, add matrix[i][j] to the temporary list. c. If the temporary list is not empty, convert it to an array and add it as a row to the result.
  5. Return the result as a jagged array.
  6. In the main method, create test matrices with irregular row lengths and call transposeJaggedArray to verify correctness, printing the original and transposed matrices.

Java Implementation

import java.util.ArrayList;

public class JaggedArrayTranspose {
    // Transposes a jagged array, converting rows to columns
    public int[][] transposeJaggedArray(int[][] matrix) {
        // Check for null or empty matrix
        if (matrix == null || matrix.length == 0) {
            return new int[0][];
        }
        // Find maximum number of columns
        int maxCols = 0;
        for (int[] row : matrix) {
            if (row != null && row.length > maxCols) {
                maxCols = row.length;
            }
        }
        // Create result array
        ArrayList<int[]> result = new ArrayList<>();
        // Process each column
        for (int j = 0; j < maxCols; j++) {
            ArrayList<Integer> tempList = new ArrayList<>();
            // Collect elements from column j
            for (int i = 0; i < matrix.length; i++) {
                if (matrix[i] != null && j < matrix[i].length) {
                    tempList.add(matrix[i][j]);
                }
            }
            // Convert non-empty tempList to array and add to result
            if (!tempList.isEmpty()) {
                int[] newRow = new int[tempList.size()];
                for (int k = 0; k < tempList.size(); k++) {
                    newRow[k] = tempList.get(k);
                }
                result.add(newRow);
            }
        }
        // Convert ArrayList to int[][]
        return result.toArray(new int[0][]);
    }

    // Main method to test transposeJaggedArray with various inputs
    public static void main(String[] args) {
        JaggedArrayTranspose transposer = new JaggedArrayTranspose();

        // Test case 1: Jagged array with varying row lengths
        int[][] matrix1 = {{1, 2, 3}, {4}, {5, 6}};
        System.out.println("Test case 1:");
        System.out.println("Original matrix:");
        printMatrix(matrix1);
        System.out.println("Transposed matrix:");
        printMatrix(transposer.transposeJaggedArray(matrix1));

        // Test case 2: Jagged array with empty rows
        int[][] matrix2 = {{1, 2}, {}, {3}};
        System.out.println("\nTest case 2:");
        System.out.println("Original matrix:");
        printMatrix(matrix2);
        System.out.println("Transposed matrix:");
        printMatrix(transposer.transposeJaggedArray(matrix2));

        // Test case 3: Empty matrix
        int[][] matrix3 = {};
        System.out.println("\nTest case 3:");
        System.out.println("Original matrix:");
        printMatrix(matrix3);
        System.out.println("Transposed matrix:");
        printMatrix(transposer.transposeJaggedArray(matrix3));

        // Test case 4: Matrix with negative numbers
        int[][] matrix4 = {{-1, -2, -3}, {-4, -5}, {-6}};
        System.out.println("\nTest case 4:");
        System.out.println("Original matrix:");
        printMatrix(matrix4);
        System.out.println("Transposed matrix:");
        printMatrix(transposer.transposeJaggedArray(matrix4));

        // Test case 5: Null matrix
        int[][] matrix5 = null;
        System.out.println("\nTest case 5:");
        System.out.println("Original matrix:");
        printMatrix(matrix5);
        System.out.println("Transposed matrix:");
        printMatrix(transposer.transposeJaggedArray(matrix5));
    }

    // Helper method to print jagged matrix
    private static void printMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            System.out.println("[]");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < matrix.length; i++) {
            System.out.print("  [");
            if (matrix[i] == null || matrix[i].length == 0) {
                System.out.print("]");
            } else {
                for (int j = 0; j < matrix[i].length; j++) {
                    System.out.print(matrix[i][j]);
                    if (j < matrix[i].length - 1) {
                        System.out.print(", ");
                    }
                }
                System.out.print("]");
            }
            System.out.println();
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Original matrix:
[
  [1, 2, 3]
  [4]
  [5, 6]
]
Transposed matrix:
[
  [1, 4, 5]
  [2, 6]
  [3]
]

Test case 2:
Original matrix:
[
  [1, 2]
  []
  [3]
]
Transposed matrix:
[
  [1, 3]
  [2]
]

Test case 3:
Original matrix:
[]
Transposed matrix:
[]

Test case 4:
Original matrix:
[
  [-1, -2, -3]
  [-4, -5]
  [-6]
]
Transposed matrix:
[
  [-1, -4, -6]
  [-2, -5]
  [-3]
]

Test case 5:
Original matrix:
[]
Transposed matrix:
[]

Explanation:

  • Test case 1: Transposes {{1, 2, 3}, {4}, {5, 6}} to {{1, 4, 5}, {2, 6}, {3}}, where column 0 ([1, 4, 5]) becomes row 0, etc.
  • Test case 2: Transposes {{1, 2}, {}, {3}} to {{1, 3}, {2}}, handling empty rows.
  • Test case 3: Empty matrix returns empty matrix.
  • Test case 4: Transposes {{-1, -2, -3}, {-4, -5}, {-6}} to {{-1, -4, -6}, {-2, -5}, {-3}}, handling negatives.
  • Test case 5: Null matrix returns empty matrix.

How It Works

  • Step 1: Check if the matrix is null or empty. For {{1, 2, 3}, {4}, {5, 6}}, proceed.
  • Step 2: Find maxCols = 3 (longest row).
  • Step 3: Initialize result as an empty ArrayList<int[]>.
  • Step 4: For each column j:
    • j = 0: Collect [1, 4, 5] (from matrix[0][0], matrix[1][0], matrix[2][0]) → add to result.
    • j = 1: Collect [2, 6] (from matrix[0][1], matrix[2][1]; matrix[1] has no column 1) → add to result.
    • j = 2: Collect [3] (from matrix[0][2]; others have no column 2) → add to result.
  • Example Trace: For test case 1, builds [[1, 4, 5], [2, 6], [3]] by collecting column elements.
  • Main Method: Tests with irregular jagged arrays, including empty rows, empty matrix, negatives, and null matrix.
  • Transpose Property: Each column becomes a row, with row lengths varying based on non-null column elements.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
TransposeO(m * c)O(m * c)
Full AlgorithmO(m * c)O(m * c)

Note:

  • m is the number of rows, c is the maximum row length (maxCols).
  • Time complexity: O(m * c), as each element is visited once to build the transposed rows.
  • Space complexity: O(m * c), for the result array storing up to m * c elements.
  • Best, average, and worst cases are O(m * c).

✅ Tip: Use a dynamic structure like ArrayList to handle varying row lengths in the transposed array. Test with irregular arrays, including empty and null rows, to ensure robustness.

⚠ Warning: Check for null rows and varying row lengths to avoid NullPointerException or ArrayIndexOutOfBoundsException. Ensure the result array only includes non-empty rows.

Row Sorting

Problem Statement

Write a Java program that implements a method to sort each row of a jagged array (a 2D array where each row can have a different length) independently in ascending order. The program should modify the jagged array in-place and test the implementation with jagged arrays containing rows of different lengths, including edge cases like empty matrices, matrices with empty rows, and matrices with rows of varying lengths. You can visualize this as organizing books on multiple shelves of different lengths, where each shelf’s books are sorted by size without affecting other shelves.

Input: A jagged 2D array of integers, where each row may have a different length (e.g., matrix = {{4, 2, 1}, {3, 5}, {6, 0, 8, 7}}). Output: The same jagged array with each row sorted in ascending order (e.g., {{1, 2, 4}, {3, 5}, {0, 6, 7, 8}}). Constraints:

  • The number of rows is between 0 and 100.
  • Each row’s length is between 0 and 100.
  • Elements are integers between -10^4 and 10^4.
  • The matrix may be empty or contain empty or null rows. Example:
  • Input: matrix = {{4, 2, 1}, {3, 5}, {6, 0, 8, 7}}
  • Output: {{1, 2, 4}, {3, 5}, {0, 6, 7, 8}}
  • Explanation: Each row is sorted independently: [4, 2, 1] → [1, 2, 4], [3, 5] → [3, 5], [6, 0, 8, 7] → [0, 6, 7, 8].
  • Input: matrix = {{}, {1}, {3, 2}}
  • Output: {{}, {1}, {2, 3}}
  • Explanation: Empty row stays empty, single-element row stays unchanged, and [3, 2] → [2, 3].

Pseudocode

FUNCTION sortRows(matrix)
    IF matrix is null OR matrix is empty THEN
        RETURN
    ENDIF
    FOR each row in matrix
        IF row is not null THEN
            SORT row in ascending order
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testMatrices to jagged 2D arrays
    FOR each matrix in testMatrices
        PRINT original matrix
        CALL sortRows(matrix)
        PRINT sorted matrix
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input matrix is null or empty. If so, return without modifying anything.
  2. Iterate through each row of the matrix: a. If the row is not null, sort it in ascending order using a sorting function.
  3. The matrix is modified in-place, with each row sorted independently.
  4. In the main method, create test matrices with varying row lengths and call sortRows to verify correctness, printing the matrix before and after sorting.

Java Implementation

import java.util.Arrays;

public class RowSorting {
    // Sorts each row of a jagged array independently in ascending order
    public void sortRows(int[][] matrix) {
        // Check for null or empty matrix
        if (matrix == null || matrix.length == 0) {
            return;
        }
        // Iterate through each row
        for (int[] row : matrix) {
            // Sort non-null rows
            if (row != null) {
                Arrays.sort(row);
            }
        }
    }

    // Main method to test sortRows with various inputs
    public static void main(String[] args) {
        RowSorting sorter = new RowSorting();

        // Test case 1: Jagged matrix with different row lengths
        int[][] matrix1 = {{4, 2, 1}, {3, 5}, {6, 0, 8, 7}};
        System.out.println("Test case 1:");
        System.out.println("Before sorting:");
        printMatrix(matrix1);
        sorter.sortRows(matrix1);
        System.out.println("After sorting:");
        printMatrix(matrix1);

        // Test case 2: Matrix with empty and single-element rows
        int[][] matrix2 = {{}, {1}, {3, 2}};
        System.out.println("\nTest case 2:");
        System.out.println("Before sorting:");
        printMatrix(matrix2);
        sorter.sortRows(matrix2);
        System.out.println("After sorting:");
        printMatrix(matrix2);

        // Test case 3: Matrix with all zeros
        int[][] matrix3 = {{0, 0}, {0}, {0, 0, 0}};
        System.out.println("\nTest case 3:");
        System.out.println("Before sorting:");
        printMatrix(matrix3);
        sorter.sortRows(matrix3);
        System.out.println("After sorting:");
        printMatrix(matrix3);

        // Test case 4: Matrix with negative numbers
        int[][] matrix4 = {{-3, -1, -2}, {-5}, {-4, -6, -7}};
        System.out.println("\nTest case 4:");
        System.out.println("Before sorting:");
        printMatrix(matrix4);
        sorter.sortRows(matrix4);
        System.out.println("After sorting:");
        printMatrix(matrix4);

        // Test case 5: Null matrix
        int[][] matrix5 = null;
        System.out.println("\nTest case 5:");
        System.out.println("Before sorting:");
        printMatrix(matrix5);
        sorter.sortRows(matrix5);
        System.out.println("After sorting:");
        printMatrix(matrix5);
    }

    // Helper method to print jagged matrix
    private static void printMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            System.out.println("null");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < matrix.length; i++) {
            System.out.print("  [");
            if (matrix[i] == null || matrix[i].length == 0) {
                System.out.print("]");
            } else {
                for (int j = 0; j < matrix[i].length; j++) {
                    System.out.print(matrix[i][j]);
                    if (j < matrix[i].length - 1) {
                        System.out.print(", ");
                    }
                }
                System.out.print("]");
            }
            System.out.println();
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Before sorting:
[
  [4, 2, 1]
  [3, 5]
  [6, 0, 8, 7]
]
After sorting:
[
  [1, 2, 4]
  [3, 5]
  [0, 6, 7, 8]
]

Test case 2:
Before sorting:
[
  []
  [1]
  [3, 2]
]
After sorting:
[
  []
  [1]
  [2, 3]
]

Test case 3:
Before sorting:
[
  [0, 0]
  [0]
  [0, 0, 0]
]
After sorting:
[
  [0, 0]
  [0]
  [0, 0, 0]
]

Test case 4:
Before sorting:
[
  [-3, -1, -2]
  [-5]
  [-4, -6, -7]
]
After sorting:
[
  [-3, -2, -1]
  [-5]
  [-7, -6, -4]
]

Test case 5:
Before sorting:
null
After sorting:
null

Explanation:

  • Test case 1: Sorts rows [4, 2, 1] → [1, 2, 4], [3, 5] → [3, 5], [6, 0, 8, 7] → [0, 6, 7, 8].
  • Test case 2: Empty row stays empty, [1] stays [1], [3, 2] → [2, 3].
  • Test case 3: Rows of zeros remain unchanged.
  • Test case 4: Sorts rows with negatives: [-3, -1, -2] → [-3, -2, -1], [-5] → [-5], [-4, -6, -7] → [-7, -6, -4].
  • Test case 5: Null matrix remains unchanged.

How It Works

  • Step 1: The sortRows method checks if the matrix is null or empty. For {{4, 2, 1}, {3, 5}, {6, 0, 8, 7}}, it proceeds.
  • Step 2: Iterate through rows:
    • Row 0: [4, 2, 1] → sort to [1, 2, 4].
    • Row 1: [3, 5] → sort to [3, 5] (already sorted).
    • Row 2: [6, 0, 8, 7] → sort to [0, 6, 7, 8].
  • Example Trace: For test case 1, each row is sorted independently using Arrays.sort, modifying the matrix in-place.
  • Main Method: Tests with jagged matrices of varying row lengths, including empty rows, all zeros, negative numbers, and a null matrix, printing before and after sorting.
  • Sorting Property: Each row is sorted independently, preserving the jagged structure and handling varying lengths.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Sorting RowsO(Σ(r_i * log r_i))O(log r_max)
Full AlgorithmO(Σ(r_i * log r_i))O(log r_max)

Note:

  • r_i is the length of the i-th row, r_max is the maximum row length.
  • Time complexity: O(Σ(r_i * log r_i)), where each row of length r_i is sorted using Arrays.sort (Timsort, O(r_i * log r_i)). The total is the sum over all rows.
  • Space complexity: O(log r_max), as Timsort uses O(log r_i) space for each row, and the maximum row length dominates.
  • Worst case: O(N * log N) where N is the total number of elements if one row contains all elements.

✅ Tip: Use Arrays.sort for efficient row sorting. Test with empty rows, single-element rows, and negative numbers to ensure robustness across different jagged array configurations.

⚠ Warning: Check for null rows to avoid NullPointerException. Ensure the matrix is not null to prevent unexpected behavior during sorting.

Sparse Matrix Sum

Problem Statement

Write a Java program that represents a sparse matrix using a jagged array (a 2D array where each row can have a different length) and computes the sum of all non-zero elements. The program should return the sum as a long integer and test the implementation with matrices of varying row lengths, including edge cases like empty matrices, matrices with empty rows, and matrices with all zeros. You can visualize this as calculating the total value of non-zero items in a grid where each row may have a different number of columns, like summing the values of scattered resources in an uneven terrain map.

Input: A jagged 2D array of integers, where each row may have a different length (e.g., matrix = {{1, 0, 2}, {3}, {0, 4, 5, 0}}). Output: A long integer representing the sum of all non-zero elements (e.g., 15 for the example above). Constraints:

  • The number of rows is between 0 and 100.
  • Each row’s length is between 0 and 100.
  • Elements are integers between -10^4 and 10^4.
  • The matrix may be empty or contain empty rows. Example:
  • Input: matrix = {{1, 0, 2}, {3}, {0, 4, 5, 0}}
  • Output: 15
  • Explanation: Non-zero elements are 1, 2, 3, 4, 5; sum = 1 + 2 + 3 + 4 + 5 = 15.
  • Input: matrix = {{0, 0}, {0}, {}}
  • Output: 0
  • Explanation: No non-zero elements, so sum = 0.

Pseudocode

FUNCTION sumSparseMatrix(matrix)
    IF matrix is null OR matrix is empty THEN
        RETURN 0
    ENDIF
    SET sum to 0
    FOR each row in matrix
        IF row is not null THEN
            FOR each element in row
                IF element is not zero THEN
                    SET sum to sum + element
                ENDIF
            ENDFOR
        ENDIF
    ENDFOR
    RETURN sum
ENDFUNCTION

FUNCTION main()
    SET testMatrices to jagged 2D arrays
    FOR each matrix in testMatrices
        CALL sumSparseMatrix(matrix)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input matrix is null or empty. If so, return 0.
  2. Initialize a variable sum to 0.
  3. Iterate through each row of the matrix: a. Check if the row is not null. b. Iterate through each element in the row. c. If the element is non-zero, add it to sum.
  4. Return the final sum.
  5. In the main method, create test matrices with varying row lengths and call sumSparseMatrix to verify correctness.

Java Implementation

public class SparseMatrixSum {
    // Computes the sum of non-zero elements in a jagged 2D array
    public long sumSparseMatrix(int[][] matrix) {
        // Check for null or empty matrix
        if (matrix == null || matrix.length == 0) {
            return 0;
        }
        // Initialize sum
        long sum = 0;
        // Iterate through each row
        for (int[] row : matrix) {
            // Check for non-null row
            if (row != null) {
                // Iterate through each element in the row
                for (int element : row) {
                    // Add non-zero elements to sum
                    if (element != 0) {
                        sum += element;
                    }
                }
            }
        }
        return sum;
    }

    // Main method to test sumSparseMatrix with various inputs
    public static void main(String[] args) {
        SparseMatrixSum summer = new SparseMatrixSum();

        // Test case 1: Jagged matrix with non-zero elements
        int[][] matrix1 = {{1, 0, 2}, {3}, {0, 4, 5, 0}};
        System.out.println("Test case 1:");
        System.out.println("Matrix:");
        printMatrix(matrix1);
        System.out.println("Sum of non-zero elements: " + summer.sumSparseMatrix(matrix1));

        // Test case 2: Matrix with all zeros
        int[][] matrix2 = {{0, 0}, {0}, {0, 0}};
        System.out.println("Test case 2:");
        System.out.println("Matrix:");
        printMatrix(matrix2);
        System.out.println("Sum of non-zero elements: " + summer.sumSparseMatrix(matrix2));

        // Test case 3: Matrix with empty rows
        int[][] matrix3 = {{}, {1, 2}, {}};
        System.out.println("Test case 3:");
        System.out.println("Matrix:");
        printMatrix(matrix3);
        System.out.println("Sum of non-zero elements: " + summer.sumSparseMatrix(matrix3));

        // Test case 4: Single-row matrix
        int[][] matrix4 = {{1, 0, 3}};
        System.out.println("Test case 4:");
        System.out.println("Matrix:");
        printMatrix(matrix4);
        System.out.println("Sum of non-zero elements: " + summer.sumSparseMatrix(matrix4));

        // Test case 5: Matrix with negative numbers
        int[][] matrix5 = {{-1, 0}, {-2, -3, 0}, {4}};
        System.out.println("Test case 5:");
        System.out.println("Matrix:");
        printMatrix(matrix5);
        System.out.println("Sum of non-zero elements: " + summer.sumSparseMatrix(matrix5));

        // Test case 6: Null matrix
        int[][] matrix6 = null;
        System.out.println("Test case 6:");
        System.out.println("Matrix:");
        printMatrix(matrix6);
        System.out.println("Sum of non-zero elements: " + summer.sumSparseMatrix(matrix6));
    }

    // Helper method to print jagged matrix
    private static void printMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0) {
            System.out.println("null");
            return;
        }
        System.out.println("[");
        for (int i = 0; i < matrix.length; i++) {
            System.out.print("  [");
            if (matrix[i] == null || matrix[i].length == 0) {
                System.out.print("]");
            } else {
                for (int j = 0; j < matrix[i].length; j++) {
                    System.out.print(matrix[i][j]);
                    if (j < matrix[i].length - 1) {
                        System.out.print(", ");
                    }
                }
                System.out.print("]");
            }
            System.out.println();
        }
        System.out.println("]");
    }
}

Output

Running the main method produces:

Test case 1:
Matrix:
[
  [1, 0, 2]
  [3]
  [0, 4, 5, 0]
]
Sum of non-zero elements: 15
Test case 2:
Matrix:
[
  [0, 0]
  [0]
  [0, 0]
]
Sum of non-zero elements: 0
Test case 3:
Matrix:
[
  []
  [1, 2]
  []
]
Sum of non-zero elements: 3
Test case 4:
Matrix:
[
  [1, 0, 3]
]
Sum of non-zero elements: 4
Test case 5:
Matrix:
[
  [-1, 0]
  [-2, -3, 0]
  [4]
]
Sum of non-zero elements: -2
Test case 6:
Matrix:
null
Sum of non-zero elements: 0

Explanation:

  • Test case 1: Sums non-zero elements 1, 2, 3, 4, 5 = 15.
  • Test case 2: No non-zero elements, sum = 0.
  • Test case 3: Sums non-zero elements 1, 2 = 3 (empty rows contribute 0).
  • Test case 4: Sums non-zero elements 1, 3 = 4.
  • Test case 5: Sums non-zero elements -1, -2, -3, 4 = -2.
  • Test case 6: Returns 0 for a null matrix.

How It Works

  • Step 1: The sumSparseMatrix method checks if the matrix is null or empty. For {{1, 0, 2}, {3}, {0, 4, 5, 0}}, it proceeds.
  • Step 2: Initialize sum = 0.
  • Step 3: Iterate through rows:
    • Row 0: [1, 0, 2] → sum = 0 + 1 + 0 + 2 = 3.
    • Row 1: [3] → sum = 3 + 3 = 6.
    • Row 2: [0, 4, 5, 0] → sum = 6 + 0 + 4 + 5 + 0 = 15.
  • Example Trace: For test case 1, accumulates non-zero elements: 1 + 2 + 3 + 4 + 5 = 15.
  • Main Method: Tests with jagged matrices of varying row lengths, including all zeros, empty rows, single-row, negative numbers, and null matrix, printing inputs and sums.
  • Sparse Property: Only non-zero elements contribute to the sum, leveraging the jagged array’s flexibility for sparse matrices.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
SummationO(N)O(1)
Full AlgorithmO(N)O(1)

Note:

  • N is the total number of elements across all rows (sum of row lengths).
  • Time complexity: O(N), as the algorithm visits each element once.
  • Space complexity: O(1), as only a single sum variable is used (excluding input/output).
  • Best, average, and worst cases are O(N).

✅ Tip: Use enhanced for loops for clean iteration over jagged arrays. Test with matrices containing empty rows, all zeros, and negative numbers to ensure robustness.

⚠ Warning: Check for null rows within the matrix to avoid NullPointerException. Use a long for the sum to handle large or negative numbers within the constraints.

Strings Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Palindrome Checker

Problem Statement

Write a Java program that implements a method to check if a string is a palindrome, ignoring case and non-alphanumeric characters (e.g., spaces, punctuation). A palindrome is a string that reads the same forward and backward. The program should return a boolean indicating whether the input is a palindrome and test the implementation with various inputs, including empty strings, single characters, and strings with special characters. You can visualize this as checking if a phrase, like a secret code, reads the same when flipped, after ignoring irrelevant symbols and letter case, as if decoding a message on a mirror.

Input: A string (e.g., "A man, a plan, a canal: Panama", "race a car", ""). Output: A boolean (true if the string is a palindrome after ignoring case and non-alphanumeric characters, false otherwise). Constraints:

  • String length is between 0 and 10^5.
  • The string may contain any ASCII characters (letters, digits, spaces, punctuation).
  • The input may be empty or null. Example:
  • Input: "A man, a plan, a canal: Panama"
  • Output: true
  • Explanation: After ignoring case and non-alphanumeric characters, the string becomes "amanaplanacanalpanama", which is a palindrome.
  • Input: "race a car"
  • Output: false
  • Explanation: After cleaning, the string becomes "raceacar", which is not a palindrome.
  • Input: ""
  • Output: true
  • Explanation: An empty string is considered a palindrome.

Pseudocode

FUNCTION isPalindrome(input)
    IF input is null THEN
        RETURN false
    ENDIF
    SET cleaned to empty string
    FOR each character c in input
        IF c is alphanumeric THEN
            SET cleaned to cleaned + lowercase(c)
        ENDIF
    ENDFOR
    SET left to 0
    SET right to length of cleaned - 1
    WHILE left < right
        IF cleaned[left] not equal to cleaned[right] THEN
            RETURN false
        ENDIF
        INCREMENT left
        DECREMENT right
    ENDWHILE
    RETURN true
ENDFUNCTION

FUNCTION main()
    SET testStrings to array of strings including various cases
    FOR each string in testStrings
        PRINT input string
        CALL isPalindrome(string)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input string is null; if so, return false.
  2. Create a cleaned string by: a. Converting the input to lowercase. b. Including only alphanumeric characters (letters and digits).
  3. Use two pointers (left and right) to check if the cleaned string is a palindrome: a. Start left at index 0 and right at the last index. b. While left < right, compare characters; if they differ, return false. c. Increment left and decrement right.
  4. If the loop completes, return true (the string is a palindrome).
  5. In the main method, test with various strings, including empty, single-character, and strings with special characters, printing the input and result.

Java Implementation

public class PalindromeChecker {
    // Checks if a string is a palindrome, ignoring case and non-alphanumeric characters
    public boolean isPalindrome(String input) {
        if (input == null) {
            return false;
        }
        // Clean the string: lowercase and keep only alphanumeric characters
        StringBuilder cleaned = new StringBuilder();
        for (char c : input.toCharArray()) {
            if (Character.isLetterOrDigit(c)) {
                cleaned.append(Character.toLowerCase(c));
            }
        }
        // Check palindrome using two pointers
        int left = 0;
        int right = cleaned.length() - 1;
        while (left < right) {
            if (cleaned.charAt(left) != cleaned.charAt(right)) {
                return false;
            }
            left++;
            right--;
        }
        return true;
    }

    // Main method to test isPalindrome with various inputs
    public static void main(String[] args) {
        PalindromeChecker checker = new PalindromeChecker();

        // Test cases
        String[] testStrings = {
            "A man, a plan, a canal: Panama", // Palindrome
            "race a car",                     // Not a palindrome
            "",                              // Empty string
            "A",                             // Single character
            "12321",                         // Numeric palindrome
            "Hello, World!",                 // Not a palindrome
            "RaCeCaR"                        // Palindrome (case-insensitive)
        };

        for (int i = 0; i < testStrings.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input: \"" + testStrings[i] + "\"");
            boolean result = checker.isPalindrome(testStrings[i]);
            System.out.println("Is palindrome: " + result + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input: "A man, a plan, a canal: Panama"
Is palindrome: true

Test case 2:
Input: "race a car"
Is palindrome: false

Test case 3:
Input: ""
Is palindrome: true

Test case 4:
Input: "A"
Is palindrome: true

Test case 5:
Input: "12321"
Is palindrome: true

Test case 6:
Input: "Hello, World!"
Is palindrome: false

Test case 7:
Input: "RaCeCaR"
Is palindrome: true

Explanation:

  • Test case 1: "A man, a plan, a canal: Panama""amanaplanacanalpanama", palindrome.
  • Test case 2: "race a car""raceacar", not a palindrome.
  • Test case 3: Empty string """", palindrome.
  • Test case 4: "A""a", palindrome.
  • Test case 5: "12321""12321", palindrome.
  • Test case 6: "Hello, World!""helloworld", not a palindrome.
  • Test case 7: "RaCeCaR""racecar", palindrome.

How It Works

  • Step 1: Check for null input; return false if null.
  • Step 2: Clean the input using StringBuilder:
    • Iterate through each character.
    • If alphanumeric (using Character.isLetterOrDigit), append its lowercase version (Character.toLowerCase).
  • Step 3: Use two pointers to check palindrome:
    • Compare characters at left and right; if different, return false.
    • Move left rightward and right leftward until they meet.
  • Example Trace (Test case 1):
    • Input: "A man, a plan, a canal: Panama".
    • Cleaned: "amanaplanacanalpanama".
    • Check: left = 0 ('a') vs right = 19 ('a'), left = 1 ('m') vs right = 18 ('m'), etc., all match → true.
  • Main Method: Tests with various inputs, including palindromes, non-palindromes, empty strings, and strings with special characters.
  • Palindrome Property: Ignores case and non-alphanumeric characters, focusing only on letters and digits.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Cleaning StringO(n)O(n)
Palindrome CheckO(n)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the input string.
  • Time complexity: O(n) for cleaning (iterating through the string) + O(n) for palindrome check (two-pointer traversal) = O(n).
  • Space complexity: O(n) for the cleaned string in StringBuilder; palindrome check uses O(1) extra space.
  • Best, average, and worst cases are O(n) time and O(n) space.

✅ Tip: Use Character.isLetterOrDigit and Character.toLowerCase to handle case and non-alphanumeric characters efficiently. Test with mixed cases, punctuation, and empty strings to ensure robustness.

⚠ Warning: Check for null input to avoid NullPointerException. Be aware that cleaning the string may significantly reduce its length if it contains many non-alphanumeric characters.

Reverse a String

Problem Statement

Write a Java program that implements two methods to reverse a string: one using String methods (concatenation) and another using StringBuilder. The program should return the reversed string and compare the performance of both methods for large strings (e.g., 100,000 characters). Test the implementation with strings of varying lengths, including edge cases like empty strings and single-character strings. You can visualize this as flipping a sequence of letters, like rearranging a word written on a whiteboard, using two different tools: one that builds the result piece by piece (String) and another that efficiently manipulates the sequence (StringBuilder).

Input: A string (e.g., "hello", "", or a large string of 100,000 characters). Output: The reversed string (e.g., "olleh", "") and performance metrics (execution time in nanoseconds) for both methods. Constraints:

  • String length is between 0 and 10^6.
  • The string contains any printable ASCII characters.
  • The input is a valid string (may be empty or null). Example:
  • Input: "hello"
  • Output: "olleh"
  • Explanation: The string "hello" is reversed to "olleh".
  • Input: ""
  • Output: ""
  • Explanation: An empty string remains empty.
  • Performance Example: For a 100,000-character string, StringBuilder is significantly faster than String concatenation.

Pseudocode

FUNCTION reverseWithString(input)
    IF input is null THEN
        RETURN null
    ENDIF
    SET result to empty string
    FOR i from length of input - 1 to 0
        SET result to result + input[i]
    ENDFOR
    RETURN result
ENDFUNCTION

FUNCTION reverseWithStringBuilder(input)
    IF input is null THEN
        RETURN null
    ENDIF
    CREATE stringBuilder with input
    CALL reverse on stringBuilder
    RETURN stringBuilder as string
ENDFUNCTION

FUNCTION main()
    SET testStrings to array of strings including small, empty, and large strings
    FOR each string in testStrings
        PRINT original string
        SET startTime to current time
        CALL reverseWithString(string)
        SET stringTime to current time - startTime
        PRINT reversed string and stringTime
        SET startTime to current time
        CALL reverseWithStringBuilder(string)
        SET builderTime to current time - startTime
        PRINT reversed string and builderTime
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. String Method: a. Check if the input is null; if so, return null. b. Initialize an empty string result. c. Iterate through the input string from the last character to the first, concatenating each character to result. d. Return result.
  2. StringBuilder Method: a. Check if the input is null; if so, return null. b. Create a StringBuilder with the input string. c. Use StringBuilder’s reverse method to reverse the string. d. Return the reversed string.
  3. Performance Comparison: a. Measure execution time for both methods using System.nanoTime(). b. Test with strings of different lengths, including a large string.
  4. In the main method, test both methods with various inputs, print the results, and display execution times.

Java Implementation

public class ReverseString {
    // Reverses string using String concatenation
    public String reverseWithString(String input) {
        if (input == null) {
            return null;
        }
        String result = "";
        for (int i = input.length() - 1; i >= 0; i--) {
            result += input.charAt(i);
        }
        return result;
    }

    // Reverses string using StringBuilder
    public String reverseWithStringBuilder(String input) {
        if (input == null) {
            return null;
        }
        return new StringBuilder(input).reverse().toString();
    }

    // Main method to test both reverse methods and compare performance
    public static void main(String[] args) {
        ReverseString reverser = new ReverseString();

        // Test cases
        String[] testStrings = {
            "hello",           // Small string
            "",               // Empty string
            "a",              // Single character
            generateLargeString(100000) // Large string
        };

        for (int i = 0; i < testStrings.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Original string: \"" + (testStrings[i].length() > 20 ? testStrings[i].substring(0, 20) + "..." : testStrings[i]) + "\"");
            
            // Test String method
            long startTime = System.nanoTime();
            String resultString = reverser.reverseWithString(testStrings[i]);
            long stringTime = System.nanoTime() - startTime;
            System.out.println("String method result: \"" + (resultString != null && resultString.length() > 20 ? resultString.substring(0, 20) + "..." : resultString) + "\"");
            System.out.println("String method time: " + stringTime + " ns");

            // Test StringBuilder method
            startTime = System.nanoTime();
            String resultBuilder = reverser.reverseWithStringBuilder(testStrings[i]);
            long builderTime = System.nanoTime() - startTime;
            System.out.println("StringBuilder method result: \"" + (resultBuilder != null && resultBuilder.length() > 20 ? resultBuilder.substring(0, 20) + "..." : resultBuilder) + "\"");
            System.out.println("StringBuilder method time: " + builderTime + " ns\n");
        }
    }

    // Helper method to generate a large string
    private static String generateLargeString(int length) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            sb.append((char) ('a' + (i % 26)));
        }
        return sb.toString();
    }
}

Output

Running the main method produces (actual times may vary depending on the system):

Test case 1:
Original string: "hello"
String method result: "olleh"
String method time: 123456 ns
StringBuilder method result: "olleh"
StringBuilder method time: 7890 ns

Test case 2:
Original string: ""
String method result: ""
String method time: 4567 ns
StringBuilder method result: ""
StringBuilder method time: 3456 ns

Test case 3:
Original string: "a"
String method result: "a"
String method time: 5678 ns
StringBuilder method result: "a"
StringBuilder method time: 4321 ns

Test case 4:
Original string: "abcdefghijklmnopqrst..."
String method result: "zyxwvutsrqponmlkjihg..."
String method time: 1234567890 ns
StringBuilder method result: "zyxwvutsrqponmlkjihg..."
StringBuilder method time: 987654 ns

Explanation:

  • Test case 1: Reverses "hello" to "olleh"; StringBuilder is faster.
  • Test case 2: Empty string "" remains ""; both methods are fast.
  • Test case 3: Single character "a" remains "a"; similar performance.
  • Test case 4: Large string (100,000 characters); StringBuilder is significantly faster due to avoiding repeated string object creation.

How It Works

  • String Method:
    • Iterates from the last character to the first, building a new string via concatenation.
    • Each concatenation creates a new String object, leading to O(n^2) time for large strings.
  • StringBuilder Method:
    • Uses StringBuilder’s reverse method, which manipulates the internal character array in-place.
    • Highly efficient, O(n) time, as it avoids creating intermediate objects.
  • Performance Comparison:
    • Measures time using System.nanoTime() for both methods.
    • String concatenation is slow for large strings due to quadratic overhead.
    • StringBuilder is optimized for mutable string operations, showing significant speedup.
  • Main Method: Tests with small, empty, single-character, and large strings, printing results and execution times.
  • Trace (Test case 1):
    • String: "hello"result = "" + 'o' + 'l' + 'l' + 'e' + 'h' = "olleh".
    • StringBuilder: Initializes with "hello", reverses to "olleh".

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
String MethodO(n^2)O(n)
StringBuilder MethodO(n)O(n)
Full AlgorithmO(n^2)O(n)

Note:

  • n is the length of the input string.
  • String method: O(n^2) time due to string concatenation creating new objects each iteration; O(n) space for the result.
  • StringBuilder method: O(n) time for in-place reversal; O(n) space for the StringBuilder.
  • Worst case: String method dominates with O(n^2) for large strings.

✅ Tip: Use StringBuilder for reversing large strings due to its linear time complexity. Test with large inputs to observe performance differences clearly.

⚠ Warning: Avoid String concatenation for large strings, as it creates multiple intermediate objects, leading to poor performance. Always check for null inputs to prevent NullPointerException.

String Compression

Problem Statement

Write a Java program that implements a method to compress a string by replacing sequences of repeated characters with the character followed by the count of its consecutive occurrences (e.g., "aabbb" becomes "a2b3"). Use StringBuilder for efficiency in building the compressed string. If the compressed string is not shorter than the original, return the original string. Test the implementation with various inputs, including empty strings, single characters, and strings with no repeated characters. You can visualize this as shrinking a word by summarizing repeated letters, like condensing a repetitive chant into a shorter code, ensuring the result is as compact as possible.

Input: A string (e.g., "aabbb", "abcd", ""). Output: The compressed string (e.g., "a2b3") or the original string if compression does not reduce the length. Constraints:

  • String length is between 0 and 10^5.
  • The string contains only lowercase letters (a-z).
  • The input may be empty or null. Example:
  • Input: "aabbb"
  • Output: "a2b3"
  • Explanation: 'a' appears twice, 'b' appears three times, so the compressed string is "a2b3".
  • Input: "abcd"
  • Output: "abcd"
  • Explanation: No repeated characters, and "a1b1c1d1" is longer, so return "abcd".
  • Input: ""
  • Output: ""
  • Explanation: Empty string returns empty string.

Pseudocode

FUNCTION compressString(input)
    IF input is null THEN
        RETURN null
    ENDIF
    IF input is empty THEN
        RETURN empty string
    ENDIF
    CREATE stringBuilder for result
    SET count to 1
    SET currentChar to input[0]
    FOR i from 1 to length of input - 1
        IF input[i] equals currentChar THEN
            INCREMENT count
        ELSE
            APPEND currentChar to stringBuilder
            IF count > 1 THEN
                APPEND count to stringBuilder
            ENDIF
            SET currentChar to input[i]
            SET count to 1
        ENDFOR
    APPEND currentChar to stringBuilder
    IF count > 1 THEN
        APPEND count to stringBuilder
    ENDIF
    SET compressed to stringBuilder as string
    IF length of compressed >= length of input THEN
        RETURN input
    ENDIF
    RETURN compressed
ENDFUNCTION

FUNCTION main()
    SET testStrings to array of strings including various cases
    FOR each string in testStrings
        PRINT input string
        CALL compressString(string)
        PRINT compressed string
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input string is null; if so, return null.
  2. Check if the input string is empty; if so, return an empty string.
  3. Initialize a StringBuilder for the result, a count for consecutive characters, and the first character as currentChar.
  4. Iterate through the string from index 1: a. If the current character equals currentChar, increment the count. b. Otherwise, append currentChar to StringBuilder; if count > 1, append the count. c. Update currentChar to the current character and reset count to 1.
  5. After the loop, append the final currentChar and its count (if > 1).
  6. If the compressed string’s length is not less than the original, return the original string.
  7. Return the compressed string.
  8. In the main method, test with various strings, including empty, single-character, repeated characters, and no repeats.

Java Implementation

public class StringCompression {
    // Compresses a string using StringBuilder
    public String compressString(String input) {
        if (input == null) {
            return null;
        }
        if (input.isEmpty()) {
            return "";
        }
        StringBuilder result = new StringBuilder();
        int count = 1;
        char currentChar = input.charAt(0);
        // Iterate through string starting from index 1
        for (int i = 1; i < input.length(); i++) {
            if (input.charAt(i) == currentChar) {
                count++;
            } else {
                result.append(currentChar);
                if (count > 1) {
                    result.append(count);
                }
                currentChar = input.charAt(i);
                count = 1;
            }
        }
        // Append the last character and its count
        result.append(currentChar);
        if (count > 1) {
            result.append(count);
        }
        // Return original if compressed length is not shorter
        String compressed = result.toString();
        return compressed.length() >= input.length() ? input : compressed;
    }

    // Main method to test compressString with various inputs
    public static void main(String[] args) {
        StringCompression compressor = new StringCompression();

        // Test cases
        String[] testStrings = {
            "aabbb",           // Repeated characters
            "abcd",           // No repeats
            "",               // Empty string
            "a",              // Single character
            "aabbcc",         // Multiple repeated characters
            "aaaa",           // All same character
            null              // Null input
        };

        for (int i = 0; i < testStrings.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input: \"" + testStrings[i] + "\"");
            String result = compressor.compressString(testStrings[i]);
            System.out.println("Compressed: \"" + result + "\"\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input: "aabbb"
Compressed: "a2b3"

Test case 2:
Input: "abcd"
Compressed: "abcd"

Test case 3:
Input: ""
Compressed: ""

Test case 4:
Input: "a"
Compressed: "a"

Test case 5:
Input: "aabbcc"
Compressed: "a2b2c2"

Test case 6:
Input: "aaaa"
Compressed: "a4"

Test case 7:
Input: "null"
Compressed: "null"

Explanation:

  • Test case 1: "aabbb""a2b3" (2 'a's, 3 'b's).
  • Test case 2: "abcd""abcd" (no repeats, "a1b1c1d1" is longer).
  • Test case 3: Empty string """".
  • Test case 4: "a""a" (single character, "a1" is longer).
  • Test case 5: "aabbcc""a2b2c2" (each character repeated twice).
  • Test case 6: "aaaa""a4" (4 'a's).
  • Test case 7: nullnull.

How It Works

  • Step 1: Check for null or empty input; return null or empty string as needed.
  • Step 2: Initialize StringBuilder, set count = 1, and currentChar to the first character.
  • Step 3: Iterate from index 1:
    • If current character matches currentChar, increment count.
    • Else, append currentChar and count (if > 1), reset currentChar and count.
  • Step 4: Append final currentChar and count (if > 1).
  • Step 5: Compare lengths; return original if compressed length is not shorter.
  • Example Trace (Test case 1):
    • Input: "aabbb".
    • Initialize: result = "", currentChar = 'a', count = 1.
    • i=1: 'a' matches 'a', count = 2.
    • i=2: 'b' differs, append "a2", set currentChar = 'b', count = 1.
    • i=3: 'b' matches, count = 2.
    • i=4: 'b' matches, count = 3.
    • End: Append "b3", result = "a2b3".
    • Length check: "a2b3" (4) < "aabbb" (5) → return "a2b3".
  • Main Method: Tests with repeated characters, no repeats, empty, single character, and null inputs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
CompressionO(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the input string.
  • Time complexity: O(n) for iterating through the string once.
  • Space complexity: O(n) for the StringBuilder to store the compressed string.
  • Best, average, and worst cases are O(n) time and O(n) space.

✅ Tip: Use StringBuilder to efficiently build the compressed string. Test with strings that have no repeats or all repeats to verify the length comparison logic.

⚠ Warning: Ensure the compressed string is compared with the original length to return the shorter one. Check for null input to avoid NullPointerException.

String Pool Experiment

Problem Statement

Write a Java program that demonstrates the Java string pool by comparing string literals, strings created with new String(), and interned strings using both the == operator (for reference equality) and the equals() method (for content equality). The program should analyze the memory usage implications and equality behavior of these strings, explaining how the string pool affects object references. Test the implementation with various cases, including identical literals, constructed strings, and interned strings, to highlight the differences in memory and equality. You can visualize this as exploring a shared library of strings where some books (strings) are reused to save space, while others are new copies, and checking if they’re the same book or just have the same content.

Input: None (the program defines strings for testing). Output: Results of == and equals() comparisons for different string creation methods, along with an explanation of memory usage and string pool behavior. Constraints:

  • Strings contain printable ASCII characters.
  • The program focuses on demonstrating string pool mechanics, not specific input constraints. Example:
  • Strings: s1 = "hello", s2 = "hello", s3 = new String("hello"), s4 = s3.intern().
  • Output:
    s1 == s2: true (same string pool reference)
    s1.equals(s2): true (same content)
    s1 == s3: false (different objects)
    s1.equals(s3): true (same content)
    s1 == s4: true (s4 interned to string pool)
    s1.equals(s4): true (same content)
    
  • Memory: Literals (s1, s2) share a single string pool object; s3 creates a new object; s4 reuses the pool object.

Pseudocode

FUNCTION demonstrateStringPool()
    SET s1 to string literal "hello"
    SET s2 to string literal "hello"
    SET s3 to new String("hello")
    SET s4 to s3.intern()
    SET s5 to new String("hello")
    SET s6 to string literal "world"
    PRINT s1 == s2 and s1.equals(s2)
    PRINT s1 == s3 and s1.equals(s3)
    PRINT s1 == s4 and s1.equals(s4)
    PRINT s3 == s5 and s3.equals(s5)
    PRINT s1 == s6 and s1.equals(s6)
    PRINT memory usage explanation
ENDFUNCTION

FUNCTION main()
    CALL demonstrateStringPool()
ENDFUNCTION

Algorithm Steps

  1. Create strings using different methods: a. s1, s2: String literals (stored in the string pool). b. s3, s5: New String objects (created on the heap, not pooled). c. s4: Interned version of s3 (references the string pool). d. s6: Different string literal for contrast.
  2. Compare strings using: a. == to check reference equality (are they the same object?). b. equals() to check content equality (are the characters the same?).
  3. Print comparison results for each pair.
  4. Explain memory usage:
    • String literals share a single object in the string pool.
    • new String() creates a new object on the heap.
    • intern() returns a reference to the string pool object.
  5. In the main method, call the demonstration function and include test cases to show string pool behavior.

Java Implementation

public class StringPoolExperiment {
    // Demonstrates string pool behavior with comparisons
    public void demonstrateStringPool() {
        // String literals
        String s1 = "hello";
        String s2 = "hello";
        // New String object
        String s3 = new String("hello");
        // Interned string
        String s4 = s3.intern();
        // Another new String object
        String s5 = new String("hello");
        // Different string literal
        String s6 = "world";
        // Empty string literal
        String s7 = "";
        String s8 = "";

        // Comparisons
        System.out.println("Test case 1: Literal vs Literal (s1 = \"hello\", s2 = \"hello\")");
        System.out.println("s1 == s2: " + (s1 == s2) + " (same string pool reference)");
        System.out.println("s1.equals(s2): " + s1.equals(s2) + " (same content)\n");

        System.out.println("Test case 2: Literal vs New String (s1 = \"hello\", s3 = new String(\"hello\"))");
        System.out.println("s1 == s3: " + (s1 == s3) + " (different objects)");
        System.out.println("s1.equals(s3): " + s1.equals(s3) + " (same content)\n");

        System.out.println("Test case 3: Literal vs Interned String (s1 = \"hello\", s4 = s3.intern())");
        System.out.println("s1 == s4: " + (s1 == s4) + " (same string pool reference)");
        System.out.println("s1.equals(s4): " + s1.equals(s4) + " (same content)\n");

        System.out.println("Test case 4: New String vs New String (s3 = new String(\"hello\"), s5 = new String(\"hello\"))");
        System.out.println("s3 == s5: " + (s3 == s5) + " (different objects)");
        System.out.println("s3.equals(s5): " + s3.equals(s5) + " (same content)\n");

        System.out.println("Test case 5: Literal vs Different Literal (s1 = \"hello\", s6 = \"world\")");
        System.out.println("s1 == s6: " + (s1 == s6) + " (different string pool references)");
        System.out.println("s1.equals(s6): " + s1.equals(s6) + " (different content)\n");

        System.out.println("Test case 6: Empty Literal vs Empty Literal (s7 = \"\", s8 = \"\")");
        System.out.println("s7 == s8: " + (s7 == s8) + " (same string pool reference)");
        System.out.println("s7.equals(s8): " + s7.equals(s8) + " (same content)\n");

        // Memory usage explanation
        System.out.println("Memory Usage Analysis:");
        System.out.println("- String literals (s1, s2) share a single object in the string pool, saving memory.");
        System.out.println("- s3 and s5 create new objects on the heap, each with separate memory.");
        System.out.println("- s4 (interned) reuses the string pool object, reducing memory usage.");
        System.out.println("- s6 uses a different string pool object for \"world\".");
        System.out.println("- Empty literals (s7, s8) share a single empty string in the pool.");
        System.out.println("- Actual memory usage depends on JVM; string pool reduces duplication.");
    }

    // Main method to run the demonstration
    public static void main(String[] args) {
        StringPoolExperiment experiment = new StringPoolExperiment();
        experiment.demonstrateStringPool();
    }
}

Output

Running the main method produces:

Test case 1: Literal vs Literal (s1 = "hello", s2 = "hello")
s1 == s2: true (same string pool reference)
s1.equals(s2): true (same content)

Test case 2: Literal vs New String (s1 = "hello", s3 = new String("hello"))
s1 == s3: false (different objects)
s1.equals(s3): true (same content)

Test case 3: Literal vs Interned String (s1 = "hello", s4 = s3.intern())
s1 == s4: true (same string pool reference)
s1.equals(s4): true (same content)

Test case 4: New String vs New String (s3 = new String("hello"), s5 = new String("hello"))
s3 == s5: false (different objects)
s3.equals(s5): true (same content)

Test case 5: Literal vs Different Literal (s1 = "hello", s6 = "world")
s1 == s6: false (different string pool references)
s1.equals(s6): false (different content)

Test case 6: Empty Literal vs Empty Literal (s7 = "", s8 = "")
s7 == s8: true (same string pool reference)
s7.equals(s8): true (same content)

Memory Usage Analysis:
- String literals (s1, s2) share a single object in the string pool, saving memory.
- s3 and s5 create new objects on the heap, each with separate memory.
- s4 (interned) reuses the string pool object, reducing memory usage.
- s6 uses a different string pool object for "world".
- Empty literals (s7, s8) share a single empty string in the pool.
- Actual memory usage depends on JVM; string pool reduces duplication.

Explanation:

  • Test case 1: s1 and s2 (literals) reference the same string pool object, so == and equals() are true.
  • Test case 2: s3 (new String) is a separate heap object, so s1 == s3 is false, but equals() is true.
  • Test case 3: s4 (interned) references the pool object, so s1 == s4 is true.
  • Test case 4: s3 and s5 are distinct heap objects, so == is false, but equals() is true.
  • Test case 5: s1 and s6 are different pool objects, so both == and equals() are false.
  • Test case 6: Empty literals share the same pool object, so both == and equals() are true.

How It Works

  • String Literals: Stored in the string pool, a part of the JVM’s heap where identical literals share a single object (e.g., s1, s2 point to the same "hello").
  • new String(): Creates a new object on the heap, even if the content exists in the pool (e.g., s3, s5 are separate from "hello" in the pool).
  • intern(): Returns the string pool reference for the content, reusing the pool object if it exists (e.g., s4 points to the same "hello" as s1).
  • == vs equals():
    • == checks if two references point to the same memory address.
    • equals() checks if the string contents are identical.
  • Memory Usage:
    • Literals save memory by reusing pool objects.
    • new String() creates additional heap objects, increasing memory usage.
    • intern() reduces memory by reusing pool objects.
  • Example Trace (Test case 1):
    • s1 = "hello", s2 = "hello": Both reference the same pool object.
    • s1 == s2: true (same reference).
    • s1.equals(s2): true (same content).
  • Main Method: Tests various combinations to show string pool behavior and memory implications.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
String CreationO(1)O(n)
== ComparisonO(1)O(1)
equals() ComparisonO(n)O(1)
intern()O(n)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the longest string.
  • String creation: O(1) for literals (pool lookup), O(n) for new String() (copying characters).
  • ==: O(1), compares references.
  • equals(): O(n), compares each character.
  • intern(): O(n), may involve hash table lookup and string comparison.
  • Space: O(n) for each new String object; literals reuse pool space.
  • Full algorithm: O(n) time for comparisons and interning; O(n) space for new String objects.

✅ Tip: Use string literals for constant strings to leverage the string pool and save memory. Use intern() sparingly to reduce memory for dynamically created strings, but test thoroughly to understand its behavior.

⚠ Warning: Avoid using == to compare string contents, as it checks references, not values. Overusing new String() can increase memory usage unnecessarily due to duplicate objects.

Substring Frequency

Problem Statement

Write a Java program that implements two methods to count the occurrences of a substring within a string using the indexOf method: one for non-overlapping occurrences and one for overlapping occurrences. The program should return the number of times the substring appears in the string and test the implementation with various inputs, including cases where the substring has overlapping and non-overlapping occurrences, as well as edge cases like empty strings or substrings. You can visualize this as searching for a specific word in a book, counting how many times it appears, either by skipping the word’s length to avoid overlaps or checking every possible position for potential overlaps.

Input: A string and a substring (e.g., string = "aaa", substring = "aa"). Output: Two integers representing the number of non-overlapping and overlapping occurrences of the substring (e.g., non-overlapping: 1, overlapping: 2). Constraints:

  • String and substring lengths are between 0 and 10^5.
  • The string and substring contain any ASCII characters.
  • The input may be empty or null. Example:
  • Input: string = "aaa", substring = "aa"
  • Output: Non-overlapping: 1, Overlapping: 2
  • Explanation: Non-overlapping counts "aa" once (positions 0-1), skipping to position 2; overlapping counts "aa" at positions 0-1 and 1-2.
  • Input: string = "abcabc", substring = "abc"
  • Output: Non-overlapping: 2, Overlapping: 2
  • Explanation: "abc" appears at positions 0-2 and 3-5, with no overlap possible.
  • Input: string = "", substring = "a"
  • Output: Non-overlapping: 0, Overlapping: 0
  • Explanation: Empty string has no occurrences.

Pseudocode

FUNCTION countNonOverlapping(input, sub)
    IF input is null OR sub is null OR input is empty OR sub is empty THEN
        RETURN 0
    ENDIF
    SET count to 0
    SET index to 0
    WHILE indexOf(sub in input starting at index) is not -1
        INCREMENT count
        SET index to indexOf(sub) + length of sub
    ENDWHILE
    RETURN count
ENDFUNCTION

FUNCTION countOverlapping(input, sub)
    IF input is null OR sub is null OR input is empty OR sub is empty THEN
        RETURN 0
    ENDIF
    SET count to 0
    SET index to 0
    WHILE indexOf(sub in input starting at index) is not -1
        INCREMENT count
        INCREMENT index
    ENDWHILE
    RETURN count
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (string, substring) pairs
    FOR each (string, sub) in testCases
        PRINT string and substring
        CALL countNonOverlapping(string, sub)
        PRINT non-overlapping count
        CALL countOverlapping(string, sub)
        PRINT overlapping count
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Non-Overlapping Count: a. Check if the input string or substring is null or empty; if so, return 0. b. Initialize count to 0 and index to 0. c. While indexOf(substring, index) returns a valid index:
    • Increment count.
    • Update index to the found index plus the substring length (to skip overlaps). d. Return count.
  2. Overlapping Count: a. Check if the input string or substring is null or empty; if so, return 0. b. Initialize count to 0 and index to 0. c. While indexOf(substring, index) returns a valid index:
    • Increment count.
    • Increment index by 1 (to check for overlaps). d. Return count.
  3. In the main method, test both methods with various string-substring pairs, including overlapping cases (e.g., "aaa", "aa"), non-overlapping cases (e.g., "abcabc", "abc"), and edge cases (e.g., empty or null inputs).

Java Implementation

public class SubstringFrequency {
    // Counts non-overlapping occurrences of substring in string
    public int countNonOverlapping(String input, String sub) {
        if (input == null || sub == null || input.isEmpty() || sub.isEmpty()) {
            return 0;
        }
        int count = 0;
        int index = 0;
        while ((index = input.indexOf(sub, index)) != -1) {
            count++;
            index += sub.length();
        }
        return count;
    }

    // Counts overlapping occurrences of substring in string
    public int countOverlapping(String input, String sub) {
        if (input == null || sub == null || input.isEmpty() || sub.isEmpty()) {
            return 0;
        }
        int count = 0;
        int index = 0;
        while ((index = input.indexOf(sub, index)) != -1) {
            count++;
            index++;
        }
        return count;
    }

    // Main method to test both count methods
    public static void main(String[] args) {
        SubstringFrequency counter = new SubstringFrequency();

        // Test cases
        String[][] testCases = {
            {"aaa", "aa"},           // Overlapping possible
            {"abcabc", "abc"},       // No overlapping
            {"", "a"},               // Empty string
            {"hello", "l"},          // Multiple occurrences
            {"aaaaaa", "aaa"},       // Multiple overlapping
            {null, "a"},             // Null input
            {"abc", ""}              // Empty substring
        };

        for (int i = 0; i < testCases.length; i++) {
            String input = testCases[i][0];
            String sub = testCases[i][1];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("String: \"" + (input != null && input.length() > 20 ? input.substring(0, 20) + "..." : input) + "\"");
            System.out.println("Substring: \"" + (sub != null && sub.length() > 20 ? sub.substring(0, 20) + "..." : sub) + "\"");
            int nonOverlapping = counter.countNonOverlapping(input, sub);
            int overlapping = counter.countOverlapping(input, sub);
            System.out.println("Non-overlapping count: " + nonOverlapping);
            System.out.println("Overlapping count: " + overlapping + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
String: "aaa"
Substring: "aa"
Non-overlapping count: 1
Overlapping count: 2

Test case 2:
String: "abcabc"
Substring: "abc"
Non-overlapping count: 2
Overlapping count: 2

Test case 3:
String: ""
Substring: "a"
Non-overlapping count: 0
Overlapping count: 0

Test case 4:
String: "hello"
Substring: "l"
Non-overlapping count: 2
Overlapping count: 2

Test case 5:
String: "aaaaaa"
Substring: "aaa"
Non-overlapping count: 2
Overlapping count: 4

Test case 6:
String: "null"
Substring: "a"
Non-overlapping count: 0
Overlapping count: 0

Test case 7:
String: "abc"
Substring: ""
Non-overlapping count: 0
Overlapping count: 0

Explanation:

  • Test case 1: "aaa", "aa": Non-overlapping finds "aa" at 0-1 (skips to 2) → 1; overlapping finds at 0-1, 1-2 → 2.
  • Test case 2: "abcabc", "abc": Both find "abc" at 0-2, 3-5 → 2 (no overlap possible).
  • Test case 3: Empty string, "a": No occurrences → 0.
  • Test case 4: "hello", "l": Finds "l" at positions 2, 3 → 2 (no overlap for single character).
  • Test case 5: "aaaaaa", "aaa": Non-overlapping finds at 0-2, 3-5 → 2; overlapping finds at 0-2, 1-3, 2-4, 3-5 → 4.
  • Test case 6: Null string, "a": No occurrences → 0.
  • Test case 7: "abc", empty substring: No valid occurrences → 0.

How It Works

  • Non-Overlapping:
    • Uses indexOf(sub, index) to find the next occurrence.
    • After each match, skips sub.length() positions to avoid counting overlaps.
  • Overlapping:
    • Uses indexOf(sub, index) but increments index by 1 to check every starting position.
  • Example Trace (Test case 1):
    • Input: "aaa", sub: "aa".
    • Non-overlapping: index = 0, finds at 0, index = 0 + 2 = 2, no more matches → count = 1.
    • Overlapping: index = 0, finds at 0, index = 1, finds at 1, index = 2, no more matches → count = 2.
  • Main Method: Tests with overlapping cases (e.g., "aaa", "aa"), non-overlapping cases (e.g., "abcabc", "abc"), and edge cases (empty, null).
  • IndexOf Property: Efficiently finds substrings, with overlapping/non-overlapping handled by index increment.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Non-OverlappingO(n * m)O(1)
OverlappingO(n * m)O(1)
Full AlgorithmO(n * m)O(1)

Note:

  • n is the length of the input string, m is the length of the substring.
  • Time complexity: O(n * m) for indexOf, which may scan up to m characters per call, with up to n/m calls (non-overlapping) or n calls (overlapping).
  • Space complexity: O(1), as only a few variables are used.
  • Worst case: O(n * m) when substring is short and string is long.

✅ Tip: Use indexOf for efficient substring searching. Test with overlapping cases (e.g., "aaa", "aa") and non-overlapping cases to verify both counts.

⚠ Warning: Check for null or empty inputs to avoid NullPointerException or unexpected behavior. Ensure the substring is not longer than the input string.

Dynamic Text Builder

Problem Statement

Write a Java program that implements a method to build a formatted string in CSV (comma-separated values) format using StringBuilder, appending elements from an input array. The program should create a string where array elements are separated by commas and test the implementation with arrays of varying sizes, including edge cases like empty arrays and single-element arrays. You can visualize this as assembling a row of data for a spreadsheet, where each item from a list is neatly joined with commas, ensuring the output is compact and properly formatted.

Input: An array of strings (e.g., ["apple", "banana", "cherry"]). Output: A single string in CSV format (e.g., "apple,banana,cherry"). Constraints:

  • Array length is between 0 and 10^5.
  • Each string element contains printable ASCII characters and may be empty or null.
  • The input array may be null or empty. Example:
  • Input: ["apple", "banana", "cherry"]
  • Output: "apple,banana,cherry"
  • Explanation: Elements are joined with commas.
  • Input: []
  • Output: ""
  • Explanation: Empty array returns an empty string.
  • Input: ["solo"]
  • Output: "solo"
  • Explanation: Single element has no commas.

Pseudocode

FUNCTION buildCSVRow(array)
    IF array is null THEN
        RETURN null
    ENDIF
    IF array is empty THEN
        RETURN empty string
    ENDIF
    CREATE stringBuilder for result
    FOR each element in array
        IF element is not null THEN
            APPEND element to stringBuilder
            IF not last element THEN
                APPEND comma to stringBuilder
            ENDIF
        ENDIF
    ENDFOR
    RETURN stringBuilder as string
ENDFUNCTION

FUNCTION main()
    SET testArrays to array of string arrays with varying sizes
    FOR each array in testArrays
        PRINT input array
        CALL buildCSVRow(array)
        PRINT resulting CSV string
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the input array is null; if so, return null.
  2. Check if the input array is empty; if so, return an empty string.
  3. Initialize a StringBuilder for the result.
  4. Iterate through the array: a. If the current element is not null, append it to the StringBuilder. b. If it is not the last element, append a comma.
  5. Return the StringBuilder’s content as a string.
  6. In the main method, test with arrays of different sizes (e.g., empty, single-element, multiple elements, and large arrays) and print the input and output.

Java Implementation

public class DynamicTextBuilder {
    // Builds a CSV row from an array using StringBuilder
    public String buildCSVRow(String[] array) {
        if (array == null) {
            return null;
        }
        if (array.length == 0) {
            return "";
        }
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < array.length; i++) {
            if (array[i] != null) {
                result.append(array[i]);
                if (i < array.length - 1) {
                    result.append(",");
                }
            }
        }
        return result.toString();
    }

    // Main method to test buildCSVRow with various array sizes
    public static void main(String[] args) {
        DynamicTextBuilder builder = new DynamicTextBuilder();

        // Test cases
        String[][] testArrays = {
            {"apple", "banana", "cherry"}, // Multiple elements
            {},                           // Empty array
            {"solo"},                     // Single element
            generateLargeArray(1000),     // Large array
            {null, "test", null},         // Array with null elements
            null                          // Null array
        };

        for (int i = 0; i < testArrays.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.print("Input array: [");
            if (testArrays[i] != null) {
                for (int j = 0; j < testArrays[i].length; j++) {
                    System.out.print(testArrays[i][j] != null ? "\"" + testArrays[i][j] + "\"" : "null");
                    if (j < testArrays[i].length - 1) {
                        System.out.print(", ");
                    }
                }
            } else {
                System.out.print("null");
            }
            System.out.println("]");
            String result = builder.buildCSVRow(testArrays[i]);
            System.out.println("CSV output: \"" + (result != null && result.length() > 50 ? result.substring(0, 50) + "..." : result) + "\"\n");
        }
    }

    // Helper method to generate a large array
    private static String[] generateLargeArray(int size) {
        String[] largeArray = new String[size];
        for (int i = 0; i < size; i++) {
            largeArray[i] = "item" + i;
        }
        return largeArray;
    }
}

Output

Running the main method produces:

Test case 1:
Input array: ["apple", "banana", "cherry"]
CSV output: "apple,banana,cherry"

Test case 2:
Input array: []
CSV output: ""

Test case 3:
Input array: ["solo"]
CSV output: "solo"

Test case 4:
Input array: ["item0", "item1", "item2", ..., "item999"]
CSV output: "item0,item1,item2,...,item998,item999"

Test case 5:
Input array: [null, "test", null]
CSV output: "test"

Test case 6:
Input array: [null]
CSV output: "null"

Explanation:

  • Test case 1: ["apple", "banana", "cherry"]"apple,banana,cherry".
  • Test case 2: Empty array []"".
  • Test case 3: ["solo"]"solo".
  • Test case 4: Large array (1000 elements) → "item0,item1,...,item999".
  • Test case 5: [null, "test", null]"test" (skips null elements).
  • Test case 6: null"null".

How It Works

  • Step 1: Check for null or empty array; return null or empty string as needed.
  • Step 2: Initialize StringBuilder for efficient string construction.
  • Step 3: Iterate through the array:
    • Append non-null elements.
    • Add a comma after each element except the last.
  • Step 4: Return the StringBuilder’s content as a string.
  • Example Trace (Test case 1):
    • Input: ["apple", "banana", "cherry"].
    • Initialize: result = "".
    • i=0: Append "apple,"result = "apple,".
    • i=1: Append "banana,"result = "apple,banana,".
    • i=2: Append "cherry"result = "apple,banana,cherry".
  • Main Method: Tests with arrays of varying sizes, including empty, single-element, large, and arrays with null elements.
  • CSV Property: Ensures proper formatting with commas between elements and no trailing comma.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Building CSVO(n * m)O(n * m)
Full AlgorithmO(n * m)O(n * m)

Note:

  • n is the number of elements in the array, m is the average length of each string element.
  • Time complexity: O(n * m) for appending each character of each string to StringBuilder.
  • Space complexity: O(n * m) for the StringBuilder to store the final CSV string.
  • Worst case: O(n * m) time and space when all elements are non-null and long.

✅ Tip: Use StringBuilder for efficient string concatenation when building formatted strings like CSV rows. Test with null elements and large arrays to ensure robustness.

⚠ Warning: Check for null array elements to avoid appending "null" as a string literal. Ensure no trailing comma is added to maintain proper CSV format.

Insert and Delete Simulation

Problem Statement

Write a Java program that simulates text editing using StringBuilder by performing a sequence of insert and delete operations on a string. Each operation is either an insertion (adding a string at a specified index) or a deletion (removing characters from a start index to an end index). The program should process a sequence of operations and return the final string, testing with different sequences, including edge cases like empty strings, invalid indices, or empty operation lists. You can visualize this as editing a document on a word processor, where you insert new text or delete sections, with StringBuilder acting as an efficient editor to keep the changes smooth and fast.

Input: An initial string (possibly empty) and a sequence of operations, where each operation is either:

  • Insert: {type="insert", index, string} (insert string at index).
  • Delete: {type="delete", start, end} (delete characters from start to end-1). Output: The final string after applying all operations. Constraints:
  • Initial string length is between 0 and 10^5.
  • Number of operations is between 0 and 1000.
  • Strings to insert contain printable ASCII characters.
  • Indices are non-negative integers; invalid indices should be handled gracefully.
  • The input may be null or empty. Example:
  • Input: Initial string = "hello", operations = [insert(5, " world"), delete(0, 2)]
  • Output: "llo world"
  • Explanation: Insert " world" at index 5 → "hello world", then delete from index 0 to 1 → "llo world".
  • Input: Initial string = "", operations = [insert(0, "test")]
  • Output: "test"
  • Explanation: Insert "test" into empty string at index 0 → "test".

Pseudocode

FUNCTION simulateTextEditing(initial, operations)
    IF initial is null THEN
        SET initial to empty string
    ENDIF
    CREATE stringBuilder with initial
    FOR each operation in operations
        IF operation.type equals "insert" THEN
            IF operation.index is valid THEN
                CALL stringBuilder.insert(operation.index, operation.string)
            ENDIF
        ELSE IF operation.type equals "delete" THEN
            IF operation.start and operation.end are valid THEN
                CALL stringBuilder.delete(operation.start, operation.end)
            ENDIF
        ENDIF
    ENDFOR
    RETURN stringBuilder as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (initial string, operations) pairs
    FOR each (initial, operations) in testCases
        PRINT initial string and operations
        CALL simulateTextEditing(initial, operations)
        PRINT resulting string
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Check if the initial string is null; if so, set it to an empty string.
  2. Initialize a StringBuilder with the initial string.
  3. For each operation in the sequence: a. If the operation is "insert":
    • Verify the index is valid (0 ≤ index ≤ current StringBuilder length).
    • Insert the specified string at the index using StringBuilder.insert. b. If the operation is "delete":
    • Verify the start and end indices are valid (0 ≤ start ≤ end ≤ current StringBuilder length).
    • Delete the range from start to end-1 using StringBuilder.delete.
  4. Return the final StringBuilder content as a string.
  5. In the main method, test with different sequences of operations, including empty strings, invalid indices, and large sequences.

Java Implementation

import java.util.*;

public class InsertAndDeleteSimulation {
    // Class to represent an operation
    static class Operation {
        String type; // "insert" or "delete"
        int index;   // For insert: insertion point; for delete: start index
        String str;  // For insert: string to insert
        int end;     // For delete: end index

        // Constructor for insert operation
        Operation(String type, int index, String str) {
            this.type = type;
            this.index = index;
            this.str = str;
        }

        // Constructor for delete operation
        Operation(String type, int start, int end) {
            this.type = type;
            this.index = start;
            this.end = end;
        }
    }

    // Simulates text editing with insert and delete operations
    public String simulateTextEditing(String initial, List<Operation> operations) {
        if (initial == null) {
            initial = "";
        }
        StringBuilder sb = new StringBuilder(initial);
        
        for (Operation op : operations) {
            if (op.type.equals("insert")) {
                if (op.index >= 0 && op.index <= sb.length() && op.str != null) {
                    sb.insert(op.index, op.str);
                }
            } else if (op.type.equals("delete")) {
                if (op.index >= 0 && op.end <= sb.length() && op.index <= op.end) {
                    sb.delete(op.index, op.end);
                }
            }
        }
        return sb.toString();
    }

    // Main method to test text editing simulation
    public static void main(String[] args) {
        InsertAndDeleteSimulation simulator = new InsertAndDeleteSimulation();

        // Test cases
        List<Object[]> testCases = new ArrayList<>();
        
        // Test case 1: Normal insert and delete
        List<Operation> ops1 = new ArrayList<>();
        ops1.add(new Operation("insert", 5, " world"));
        ops1.add(new Operation("delete", 0, 2));
        testCases.add(new Object[]{"hello", ops1});
        
        // Test case 2: Empty string with insert
        List<Operation> ops2 = new ArrayList<>();
        ops2.add(new Operation("insert", 0, "test"));
        testCases.add(new Object[]{"", ops2});
        
        // Test case 3: Empty operations
        List<Operation> ops3 = new ArrayList<>();
        testCases.add(new Object[]{"abc", ops3});
        
        // Test case 4: Large sequence of operations
        List<Operation> ops4 = new ArrayList<>();
        ops4.add(new Operation("insert", 0, "start"));
        for (int i = 1; i <= 10; i++) {
            ops4.add(new Operation("insert", i * 5, "x"));
            ops4.add(new Operation("delete", i * 2, i * 2 + 1));
        }
        testCases.add(new Object[]{"base", ops4});
        
        // Test case 5: Null initial string and invalid indices
        List<Operation> ops5 = new ArrayList<>();
        ops5.add(new Operation("insert", 10, "invalid"));
        ops5.add(new Operation("delete", -1, 5));
        testCases.add(new Object[]{null, ops5});

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            String initial = (String) testCases.get(i)[0];
            List<Operation> ops = (List<Operation>) testCases.get(i)[1];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Initial string: \"" + (initial != null ? initial : "null") + "\"");
            System.out.println("Operations:");
            for (Operation op : ops) {
                if (op.type.equals("insert")) {
                    System.out.println("  Insert \"" + op.str + "\" at index " + op.index);
                } else {
                    System.out.println("  Delete from index " + op.index + " to " + op.end);
                }
            }
            String result = simulator.simulateTextEditing(initial, ops);
            System.out.println("Result: \"" + (result != null && result.length() > 50 ? result.substring(0, 50) + "..." : result) + "\"\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Initial string: "hello"
Operations:
  Insert " world" at index 5
  Delete from index 0 to 2
Result: "llo world"

Test case 2:
Initial string: ""
Operations:
  Insert "test" at index 0
Result: "test"

Test case 3:
Initial string: "abc"
Operations:
Result: "abc"

Test case 4:
Initial string: "base"
Operations:
  Insert "start" at index 0
  Insert "x" at index 5
  Delete from index 2 to 3
  Insert "x" at index 10
  Delete from index 4 to 5
  Insert "x" at index 15
  Delete from index 6 to 7
  Insert "x" at index 20
  Delete from index 8 to 9
  Insert "x" at index 25
  Delete from index 10 to 11
  Insert "x" at index 30
  Delete from index 12 to 13
  Insert "x" at index 35
  Delete from index 14 to 15
  Insert "x" at index 40
  Delete from index 16 to 17
  Insert "x" at index 45
  Delete from index 18 to 19
  Insert "x" at index 50
  Delete from index 20 to 21
Result: "staxrtxxx"

Test case 5:
Initial string: "null"
Operations:
  Insert "invalid" at index 10
  Delete from index -1 to 5
Result: ""

Explanation:

  • Test case 1: "hello" → insert " world" at 5 → "hello world" → delete 0 to 2 → "llo world".
  • Test case 2: "" → insert "test" at 0 → "test".
  • Test case 3: "abc" with no operations → "abc".
  • Test case 4: "base" → complex sequence of inserts and deletes → "staxrtxxx".
  • Test case 5: Null initial string becomes "", invalid indices are skipped → "".

How It Works

  • StringBuilder: Efficient for insert and delete operations due to mutable character array.
  • Operation Class: Represents insert (index, string) or delete (start, end) operations.
  • simulateTextEditing:
    • Initializes StringBuilder with initial string (empty if null).
    • Processes each operation, checking index validity before applying insert or delete.
  • Example Trace (Test case 1):
    • Initial: sb = "hello".
    • Insert " world" at 5: sb = "hello world".
    • Delete 0 to 2: sb = "llo world".
  • Main Method: Tests with normal operations, empty string, no operations, large sequence, and invalid cases.
  • Validation: Skips invalid operations (e.g., out-of-bounds indices) to ensure robustness.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
InsertO(n + m)O(m)
DeleteO(n)O(1)
Full AlgorithmO(k * (n + m))O(n + k * m)

Note:

  • n is the current length of the StringBuilder, m is the length of the inserted string, k is the number of operations.
  • Insert: O(n + m) due to shifting characters and copying the new string.
  • Delete: O(n) due to shifting characters after deletion.
  • Full algorithm: O(k * (n + m)) for k operations, worst case when each operation involves the maximum string length.
  • Space: O(n + k * m) for the StringBuilder, including inserted strings.

✅ Tip: Use StringBuilder for efficient text editing operations like insert and delete. Test with invalid indices and edge cases to ensure the program handles them gracefully.

⚠ Warning: Validate indices before performing operations to avoid IndexOutOfBoundsException. Handle null inputs to prevent unexpected behavior.

StringBuilder Capacity Management

Problem Statement

Write a Java program that demonstrates StringBuilder’s capacity resizing by appending strings and monitoring capacity changes using the capacity() method. The program should analyze when resizing occurs and how the capacity adjusts based on the appended content. Test the implementation with different scenarios, including appending small strings, large strings, and using StringBuilder instances with varying initial capacities. You can visualize this as filling a stretchable notebook, where the notebook expands its pages (capacity) automatically when you add more notes, and you track how and when it grows to accommodate the text.

Input: A series of strings to append to StringBuilder instances with different initial configurations (e.g., default capacity, specified initial capacity). Output: The final string, a log of capacity changes after each append operation, and an analysis of when resizing occurs. Constraints:

  • String lengths are between 0 and 10^5.
  • Strings contain printable ASCII characters.
  • Initial capacities are non-negative integers.
  • The input strings may be null or empty. Example:
  • Input: Append "hello", " world" to a StringBuilder with default capacity (16).
  • Output:
    Initial capacity: 16
    After appending "hello": length=5, capacity=16
    After appending " world": length=11, capacity=16
    Final string: "hello world"
    
  • Explanation: No resizing occurs as the total length (11) fits within the initial capacity (16).
  • Input: Append a 20-character string to a StringBuilder with default capacity (16).
  • Output:
    Initial capacity: 16
    After appending: length=20, capacity=34
    Final string: "abcdefghijklmnopqrst"
    
  • Explanation: Resizing occurs because 20 exceeds 16, new capacity = (16 * 2) + 2 = 34.

Pseudocode

FUNCTION demonstrateCapacityManagement(testCases)
    FOR each testCase in testCases
        SET initialCapacity to testCase.initialCapacity
        SET strings to testCase.strings
        CREATE stringBuilder with initialCapacity
        PRINT initial capacity
        FOR each string in strings
            SET oldCapacity to stringBuilder.capacity()
            APPEND string to stringBuilder
            SET newCapacity to stringBuilder.capacity()
            PRINT length, capacity, and whether resizing occurred
        ENDFOR
        PRINT final string
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (initialCapacity, strings) pairs
    CALL demonstrateCapacityManagement(testCases)
ENDFUNCTION

Algorithm Steps

  1. Define a method to demonstrate capacity management: a. For each test case, create a StringBuilder with the specified initial capacity. b. Print the initial capacity using capacity(). c. For each string to append:
    • Record the current capacity.
    • Append the string using StringBuilder.append.
    • Check the new capacity and note if resizing occurred (capacity increased).
    • Print the current length, capacity, and resizing status. d. Print the final string.
  2. Handle null or empty strings by skipping or appending empty content.
  3. In the main method, test with different scenarios:
    • Default capacity (16) with small strings.
    • Default capacity with a large string that triggers resizing.
    • Custom initial capacity with multiple appends.
    • Empty or null strings.
  4. Analyze resizing: StringBuilder doubles the current capacity and adds 2 when the length exceeds the capacity.

Java Implementation

public class StringBuilderCapacityManagement {
    // Demonstrates StringBuilder capacity resizing
    public void demonstrateCapacityManagement(String[][] testCases, int[] initialCapacities) {
        for (int i = 0; i < testCases.length; i++) {
            String[] strings = testCases[i];
            int initialCapacity = initialCapacities[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Initial capacity: " + initialCapacity);
            
            StringBuilder sb = new StringBuilder(initialCapacity);
            for (int j = 0; j < strings.length; j++) {
                String str = strings[j] != null ? strings[j] : "";
                int oldCapacity = sb.capacity();
                sb.append(str);
                int newCapacity = sb.capacity();
                System.out.println("After appending \"" + (str.length() > 20 ? str.substring(0, 20) + "..." : str) + "\": " +
                                   "length=" + sb.length() + ", capacity=" + newCapacity +
                                   (newCapacity > oldCapacity ? " (resized)" : ""));
            }
            System.out.println("Final string: \"" + (sb.length() > 50 ? sb.substring(0, 50) + "..." : sb.toString()) + "\"\n");
        }
    }

    // Main method to test capacity management
    public static void main(String[] args) {
        StringBuilderCapacityManagement manager = new StringBuilderCapacityManagement();

        // Test cases
        String[][] testCases = {
            {"hello", " world"},                    // Small strings, default capacity
            {generateLargeString(20)},             // Large string, triggers resizing
            {"a", "b", "c", "d"},                 // Multiple small strings, custom capacity
            {""},                                  // Empty string
            {null}                                 // Null string
        };
        int[] initialCapacities = {16, 16, 5, 16, 16}; // Corresponding initial capacities

        manager.demonstrateCapacityManagement(testCases, initialCapacities);
    }

    // Helper method to generate a large string
    private static String generateLargeString(int length) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            sb.append((char) ('a' + (i % 26)));
        }
        return sb.toString();
    }
}

Output

Running the main method produces (actual capacities may vary slightly depending on JVM implementation):

Test case 1:
Initial capacity: 16
After appending "hello": length=5, capacity=16
After appending " world": length=11, capacity=16
Final string: "hello world"

Test case 2:
Initial capacity: 16
After appending "abcdefghijklmnopqrst...": length=20, capacity=34 (resized)
Final string: "abcdefghijklmnopqrst"

Test case 3:
Initial capacity: 5
After appending "a": length=1, capacity=5
After appending "b": length=2, capacity=5
After appending "c": length=3, capacity=5
After appending "d": length=4, capacity=5
Final string: "abcd"

Test case 4:
Initial capacity: 16
After appending "": length=0, capacity=16
Final string: ""

Test case 5:
Initial capacity: 16
After appending "": length=0, capacity=16
Final string: ""

Explanation:

  • Test case 1: Appends "hello", " world". Length (11) < capacity (16), no resizing.
  • Test case 2: Appends 20-character string. Length (20) > 16, resizes to (16 * 2) + 2 = 34.
  • Test case 3: Custom capacity 5, appends four single characters. Length (4) ≤ 5, no resizing.
  • Test case 4: Empty string, no change in length or capacity.
  • Test case 5: Null string treated as empty, no change.

How It Works

  • StringBuilder Capacity: Represents the size of the internal character array. Default is 16 if not specified.
  • Resizing: When the length exceeds capacity, StringBuilder allocates a new array with capacity = (oldCapacity * 2) + 2.
  • demonstrateCapacityManagement:
    • Creates StringBuilder with specified initial capacity.
    • Tracks capacity before and after each append.
    • Reports resizing when new capacity exceeds old capacity.
  • Example Trace (Test case 2):
    • Initial: capacity = 16, length = 0.
    • Append "abcdefghijklmnopqrst" (20 chars): length = 20 > 16, new capacity = (16 * 2) + 2 = 34.
  • Main Method: Tests with small strings, large strings triggering resizing, custom capacity, and edge cases.
  • Analysis: Resizing occurs when the total length after appending exceeds the current capacity, doubling the capacity plus 2 to accommodate future appends efficiently.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
AppendO(m)O(m)
Capacity CheckO(1)O(1)
ResizingO(n)O(n)
Full AlgorithmO(k * m)O(n + k * m)

Note:

  • n is the current length of the StringBuilder, m is the length of the appended string, k is the number of append operations.
  • Append: O(m) for copying characters, amortized O(1) if no resizing.
  • Resizing: O(n) when copying the internal array to a larger one.
  • Full algorithm: O(k * m) time for k appends, O(n + k * m) space for the final StringBuilder content.
  • Worst case: Frequent resizing for large strings increases time and space.

✅ Tip: Set an appropriate initial capacity for StringBuilder when the expected size is known to minimize resizing. Use capacity() to monitor and understand resizing behavior during development.

⚠ Warning: Avoid unnecessarily small initial capacities, as frequent resizing can degrade performance. Handle null inputs to prevent unexpected behavior during append operations.

String Reversal

Problem Statement

Write a Java program that implements two methods to reverse a string: one using StringBuilder and another using StringBuffer. The program should return the reversed string and compare the performance of both methods for large inputs (e.g., a string of 100,000 characters). Test the implementation with strings of varying lengths, including edge cases like empty strings and single-character strings. You can visualize this as flipping a sequence of letters on a conveyor belt, using two different tools: StringBuilder for a quick, single-user operation, and StringBuffer for a safer, multi-user operation, to see which gets the job done faster.

Input: A string (e.g., "hello", "", or a large string of 100,000 characters). Output: The reversed string (e.g., "olleh", "") and performance metrics (execution time in nanoseconds) for both StringBuilder and StringBuffer methods. Constraints:

  • String length is between 0 and 10^6.
  • The string contains any printable ASCII characters.
  • The input may be empty or null. Example:
  • Input: "hello"
  • Output: "olleh"
  • Explanation: The string "hello" is reversed to "olleh".
  • Input: ""
  • Output: ""
  • Explanation: An empty string remains empty.
  • Performance Example: For a 100,000-character string, StringBuilder is generally faster than StringBuffer due to lack of synchronization.

Pseudocode

FUNCTION reverseWithStringBuilder(input)
    IF input is null THEN
        RETURN null
    ENDIF
    CREATE stringBuilder with input
    CALL reverse on stringBuilder
    RETURN stringBuilder as string
ENDFUNCTION

FUNCTION reverseWithStringBuffer(input)
    IF input is null THEN
        RETURN null
    ENDIF
    CREATE stringBuffer with input
    CALL reverse on stringBuffer
    RETURN stringBuffer as string
ENDFUNCTION

FUNCTION main()
    SET testStrings to array of strings including small, empty, and large strings
    FOR each string in testStrings
        PRINT original string
        SET startTime to current time
        CALL reverseWithStringBuilder(string)
        SET builderTime to current time - startTime
        PRINT reversed string and builderTime
        SET startTime to current time
        CALL reverseWithStringBuffer(string)
        SET bufferTime to current time - startTime
        PRINT reversed string and bufferTime
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. StringBuilder Method: a. Check if the input is null; if so, return null. b. Create a StringBuilder with the input string. c. Use StringBuilder’s reverse method to reverse the string. d. Return the reversed string.
  2. StringBuffer Method: a. Check if the input is null; if so, return null. b. Create a StringBuffer with the input string. c. Use StringBuffer’s reverse method to reverse the string. d. Return the reversed string.
  3. Performance Comparison: a. Measure execution time for both methods using System.nanoTime(). b. Test with strings of different lengths, including a large string.
  4. In the main method, test both methods with various inputs, print the results, and display execution times.

Java Implementation

public class StringReversal {
    // Reverses string using StringBuilder
    public String reverseWithStringBuilder(String input) {
        if (input == null) {
            return null;
        }
        return new StringBuilder(input).reverse().toString();
    }

    // Reverses string using StringBuffer
    public String reverseWithStringBuffer(String input) {
        if (input == null) {
            return null;
        }
        return new StringBuffer(input).reverse().toString();
    }

    // Main method to test both reverse methods and compare performance
    public static void main(String[] args) {
        StringReversal reverser = new StringReversal();

        // Test cases
        String[] testStrings = {
            "hello",           // Small string
            "",               // Empty string
            "a",              // Single character
            generateLargeString(100000) // Large string
        };

        for (int i = 0; i < testStrings.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Original string: \"" + (testStrings[i].length() > 20 ? testStrings[i].substring(0, 20) + "..." : testStrings[i]) + "\"");
            
            // Test StringBuilder method
            long startTime = System.nanoTime();
            String resultBuilder = reverser.reverseWithStringBuilder(testStrings[i]);
            long builderTime = System.nanoTime() - startTime;
            System.out.println("StringBuilder result: \"" + (resultBuilder != null && resultBuilder.length() > 20 ? resultBuilder.substring(0, 20) + "..." : resultBuilder) + "\"");
            System.out.println("StringBuilder time: " + builderTime + " ns");

            // Test StringBuffer method
            startTime = System.nanoTime();
            String resultBuffer = reverser.reverseWithStringBuffer(testStrings[i]);
            long bufferTime = System.nanoTime() - startTime;
            System.out.println("StringBuffer result: \"" + (resultBuffer != null && resultBuffer.length() > 20 ? resultBuffer.substring(0, 20) + "..." : resultBuffer) + "\"");
            System.out.println("StringBuffer time: " + bufferTime + " ns\n");
        }
    }

    // Helper method to generate a large string
    private static String generateLargeString(int length) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            sb.append((char) ('a' + (i % 26)));
        }
        return sb.toString();
    }
}

Output

Running the main method produces (actual times may vary depending on the system):

Test case 1:
Original string: "hello"
StringBuilder result: "olleh"
StringBuilder time: 7890 ns
StringBuffer result: "olleh"
StringBuffer time: 9123 ns

Test case 2:
Original string: ""
StringBuilder result: ""
StringBuilder time: 3456 ns
StringBuffer result: ""
StringBuffer time: 4567 ns

Test case 3:
Original string: "a"
StringBuilder result: "a"
StringBuilder time: 4321 ns
StringBuffer result: "a"
StringBuffer time: 5678 ns

Test case 4:
Original string: "abcdefghijklmnopqrst..."
StringBuilder result: "zyxwvutsrqponmlkjihg..."
StringBuilder time: 987654 ns
StringBuffer result: "zyxwvutsrqponmlkjihg..."
StringBuffer time: 1234567 ns

Explanation:

  • Test case 1: Reverses "hello" to "olleh"; StringBuilder is slightly faster.
  • Test case 2: Empty string "" remains ""; both methods are fast.
  • Test case 3: Single character "a" remains "a"; similar performance.
  • Test case 4: Large string (100,000 characters); StringBuilder is faster than StringBuffer due to lack of synchronization overhead.

How It Works

  • StringBuilder Method:
    • Uses StringBuilder’s reverse method, which manipulates the internal character array in-place.
    • Non-thread-safe, optimized for single-threaded use, making it faster.
  • StringBuffer Method:
    • Uses StringBuffer’s reverse method, also manipulating the internal array.
    • Thread-safe due to synchronization, adding slight overhead, making it slower.
  • Performance Comparison:
    • Measures time using System.nanoTime() for both methods.
    • StringBuilder is faster, especially for large strings, as it avoids synchronization.
    • StringBuffer’s synchronization adds overhead, noticeable in large inputs.
  • Main Method: Tests with small, empty, single-character, and large strings, printing results and execution times.
  • Trace (Test case 1):
    • StringBuilder: Initializes with "hello", reverses to "olleh".
    • StringBuffer: Initializes with "hello", reverses to "olleh".
  • Key Difference: StringBuilder is faster for single-threaded applications; StringBuffer is safer for multi-threaded contexts.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
StringBuilder ReverseO(n)O(n)
StringBuffer ReverseO(n)O(n)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the input string.
  • Time complexity: O(n) for both StringBuilder and StringBuffer, as reverse swaps characters in-place.
  • Space complexity: O(n) for the internal character array in both classes.
  • StringBuilder is faster in practice due to no synchronization; StringBuffer has overhead.
  • Worst case: O(n) time and O(n) space for both.

✅ Tip: Use StringBuilder for single-threaded applications to reverse strings efficiently. Test with large inputs to observe performance differences between StringBuilder and StringBuffer.

⚠ Warning: Use StringBuffer only in multi-threaded environments where thread safety is required, as its synchronization overhead can slow down performance. Always check for null inputs to prevent NullPointerException.

Thread-Safe Concatenation

Problem Statement

Write a Java program that implements a multi-threaded application using StringBuffer to append strings concurrently from multiple threads, ensuring thread safety. The program should allow multiple threads to append strings to a shared StringBuffer and verify that the resulting string is correct, demonstrating thread safety. Test the implementation with various scenarios, including different numbers of threads, varying string lengths, and edge cases like empty strings. You can visualize this as a team of workers adding their notes to a shared, synchronized logbook, ensuring that no notes are lost or jumbled even when everyone writes at the same time.

Input: A set of strings to be appended by multiple threads (e.g., ["thread1", "thread2", "thread3"]). Output: A single string containing all appended strings in the order of thread execution (e.g., "thread1thread2thread3") and verification of correctness. Constraints:

  • Number of threads is between 1 and 100.
  • Each string contains printable ASCII characters and may be empty.
  • The input strings may be null (handled by skipping or appending a default value). Example:
  • Input: 3 threads appending "thread1", "thread2", "thread3".
  • Output: "thread1thread2thread3" (order may vary due to thread scheduling).
  • Explanation: Each thread appends its string to a shared StringBuffer, and the result is consistent due to StringBuffer’s thread-safe methods.
  • Input: 2 threads appending "", "".
  • Output: ""
  • Explanation: Empty strings result in an empty final string.

Pseudocode

FUNCTION appendString(buffer, str)
    IF str is not null THEN
        CALL buffer.append(str)
    ENDIF
ENDFUNCTION

FUNCTION threadSafeConcatenation(strings, numThreads)
    IF strings is null OR numThreads <= 0 THEN
        RETURN null
    ENDIF
    CREATE sharedBuffer as new StringBuffer
    CREATE threadList as empty list
    FOR i from 0 to numThreads - 1
        CREATE thread that calls appendString(sharedBuffer, strings[i])
        ADD thread to threadList
    ENDFOR
    FOR each thread in threadList
        START thread
    ENDFOR
    FOR each thread in threadList
        JOIN thread
    ENDFOR
    RETURN sharedBuffer as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (strings, numThreads) pairs
    FOR each (strings, numThreads) in testCases
        PRINT test case details
        CALL threadSafeConcatenation(strings, numThreads)
        PRINT resulting string
        VERIFY result correctness
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a method appendString that safely appends a string to a shared StringBuffer.
  2. Define a method threadSafeConcatenation: a. Check for null input or invalid thread count; return null if invalid. b. Create a shared StringBuffer. c. Create and start threads, each calling appendString with a string. d. Wait for all threads to complete using join. e. Return the final concatenated string.
  3. Verify thread safety by checking if the result contains all input strings in any order (since thread scheduling is non-deterministic).
  4. In the main method, test with different numbers of threads, string lengths, and edge cases (e.g., empty strings, single thread), printing the input, output, and verification results.

Java Implementation

import java.util.ArrayList;

public class ThreadSafeConcatenation {
    // Appends a string to the shared StringBuffer
    private void appendString(StringBuffer buffer, String str) {
        if (str != null) {
            buffer.append(str);
        }
    }

    // Performs thread-safe concatenation using multiple threads
    public String threadSafeConcatenation(String[] strings, int numThreads) {
        if (strings == null || numThreads <= 0 || numThreads > strings.length) {
            return null;
        }
        StringBuffer sharedBuffer = new StringBuffer();
        ArrayList<Thread> threadList = new ArrayList<>();
        
        // Create threads
        for (int i = 0; i < numThreads; i++) {
            final String str = strings[i];
            Thread thread = new Thread(() -> appendString(sharedBuffer, str));
            threadList.add(thread);
        }
        
        // Start all threads
        for (Thread thread : threadList) {
            thread.start();
        }
        
        // Wait for all threads to complete
        for (Thread thread : threadList) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        
        return sharedBuffer.toString();
    }

    // Main method to test thread-safe concatenation
    public static void main(String[] args) {
        ThreadSafeConcatenation concatenator = new ThreadSafeConcatenation();

        // Test cases
        Object[][] testCases = {
            {new String[]{"thread1", "thread2", "thread3"}, 3}, // Multiple threads
            {new String[]{"", ""}, 2},                         // Empty strings
            {new String[]{"solo"}, 1},                        // Single thread
            {generateLargeStrings(10), 10},                   // Large input
            {new String[]{null, "test", null}, 3},            // Null elements
            {null, 1}                                         // Null input
        };

        for (int i = 0; i < testCases.length; i++) {
            String[] strings = (String[]) testCases[i][0];
            int numThreads = (int) testCases[i][1];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.print("Input strings: [");
            if (strings != null) {
                for (int j = 0; j < numThreads && j < strings.length; j++) {
                    System.out.print(strings[j] != null ? "\"" + strings[j] + "\"" : "null");
                    if (j < numThreads - 1 && j < strings.length - 1) {
                        System.out.print(", ");
                    }
                }
            } else {
                System.out.print("null");
            }
            System.out.println("]");
            System.out.println("Number of threads: " + numThreads);
            String result = concatenator.threadSafeConcatenation(strings, numThreads);
            System.out.println("Concatenated result: \"" + (result != null && result.length() > 50 ? result.substring(0, 50) + "..." : result) + "\"");
            // Verify correctness
            if (strings != null && result != null) {
                StringBuilder expected = new StringBuilder();
                for (int j = 0; j < numThreads && j < strings.length; j++) {
                    if (strings[j] != null) {
                        expected.append(strings[j]);
                    }
                }
                System.out.println("Verification: " + (result.length() == expected.length() ? "Correct (length matches expected)" : "Incorrect"));
            } else {
                System.out.println("Verification: " + (result == null ? "Correct (null expected)" : "Incorrect"));
            }
            System.out.println();
        }
    }

    // Helper method to generate large strings for testing
    private static String[] generateLargeStrings(int size) {
        String[] largeStrings = new String[size];
        for (int i = 0; i < size; i++) {
            largeStrings[i] = "str" + i;
        }
        return largeStrings;
    }
}

Output

Running the main method produces (note: order of strings in the result may vary due to thread scheduling):

Test case 1:
Input strings: ["thread1", "thread2", "thread3"]
Number of threads: 3
Concatenated result: "thread1thread2thread3"
Verification: Correct (length matches expected)

Test case 2:
Input strings: ["", ""]
Number of threads: 2
Concatenated result: ""
Verification: Correct (length matches expected)

Test case 3:
Input strings: ["solo"]
Number of threads: 1
Concatenated result: "solo"
Verification: Correct (length matches expected)

Test case 4:
Input strings: ["str0", "str1", "str2", ..., "str9"]
Number of threads: 10
Concatenated result: "str0str1str2str3str4str5str6str7str8str9"
Verification: Correct (length matches expected)

Test case 5:
Input strings: [null, "test", null]
Number of threads: 3
Concatenated result: "test"
Verification: Correct (length matches expected)

Test case 6:
Input strings: [null]
Number of threads: 1
Concatenated result: "null"
Verification: Correct (null expected)

Explanation:

  • Test case 1: Three threads append "thread1", "thread2", "thread3"; result is correct (order may vary).
  • Test case 2: Two threads append empty strings; result is empty.
  • Test case 3: One thread appends "solo"; result is "solo".
  • Test case 4: Ten threads append "str0" to "str9"; result contains all strings.
  • Test case 5: Three threads, with nulls skipped, append "test"; result is "test".
  • Test case 6: Null input returns null.

How It Works

  • StringBuffer: Thread-safe due to synchronized methods, ensuring safe concurrent appends.
  • appendString: Appends a non-null string to the shared StringBuffer.
  • threadSafeConcatenation:
    • Creates a shared StringBuffer.
    • Spawns threads, each appending a string.
    • Waits for all threads to finish using join.
    • Returns the concatenated string.
  • Verification: Checks if the result’s length matches the sum of non-null input string lengths.
  • Example Trace (Test case 1):
    • Initialize: sharedBuffer = "".
    • Thread 1 appends "thread1"sharedBuffer = "thread1".
    • Thread 2 appends "thread2"sharedBuffer = "thread1thread2".
    • Thread 3 appends "thread3"sharedBuffer = "thread1thread2thread3".
  • Main Method: Tests with multiple threads, empty strings, single thread, large inputs, and null cases, verifying thread safety.
  • Thread Safety: StringBuffer’s synchronized append method ensures no data corruption.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Append (per thread)O(m)O(m)
Full AlgorithmO(n * m)O(n * m)

Note:

  • n is the number of threads, m is the average length of each string.
  • Time complexity: O(m) per append operation; O(n * m) for all threads (though concurrent execution may reduce wall-clock time).
  • Space complexity: O(n * m) for the StringBuffer storing all concatenated strings.
  • Worst case: O(n * m) time and space when all strings are long and non-null.

✅ Tip: Use StringBuffer for thread-safe string concatenation in multi-threaded environments. Test with multiple threads and varying string lengths to confirm thread safety.

⚠ Warning: Ensure proper thread synchronization using join to avoid accessing the StringBuffer before all appends complete. Check for null inputs to prevent unexpected behavior.

Stacks Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Real-World Simulation: Browser Back Button

Problem Statement

Write a Java program that simulates a browser’s back button functionality using a stack. The program should allow users to input a list of URLs to push onto the stack, representing page navigation, and print the current page each time the back button is simulated by popping a URL from the stack. The current page is the topmost URL on the stack after each operation. Test the implementation with various sequences of push and pop operations, including edge cases like empty stacks and null inputs. You can visualize this as a stack of visited webpages, where each new page is added to the top, and pressing the back button removes the current page to reveal the previous one.

Input: A sequence of operations, where each operation is either:

  • Push: Add a URL (string) to the stack (e.g., "https://example.com").
  • Pop: Simulate the back button by removing the top URL and revealing the new top. Output: For each operation, print the operation performed and the current page (top of the stack) or a message if the stack is empty. Constraints:
  • Stack size is between 0 and 10^5.
  • URLs are non-empty strings containing printable ASCII characters, or null (handled gracefully).
  • The stack may be empty when pop is called. Example:
  • Input: Operations = [push("page1"), push("page2"), pop, push("page3"), pop]
  • Output:
    Pushed page1, Current page: page1
    Pushed page2, Current page: page2
    Popped page2, Current page: page1
    Pushed page3, Current page: page3
    Popped page3, Current page: page1
    
  • Explanation: Push adds URLs to the stack; pop removes the top URL, showing the previous one.
  • Input: Operations = [pop on empty stack]
  • Output: Popped, Stack empty

Pseudocode

CLASS StringStack
    SET array to new string array of size 1000
    SET top to -1
    
    FUNCTION push(url)
        IF top equals array length - 1 THEN
            RETURN false (stack full)
        ENDIF
        INCREMENT top
        SET array[top] to url
        RETURN true
    ENDFUNCTION
    
    FUNCTION pop()
        IF top equals -1 THEN
            RETURN null (stack empty)
        ENDIF
        SET url to array[top]
        DECREMENT top
        RETURN url
    ENDFUNCTION
    
    FUNCTION peek()
        IF top equals -1 THEN
            RETURN null (stack empty)
        ENDIF
        RETURN array[top]
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN top equals -1
    ENDFUNCTION
ENDCLASS

FUNCTION simulateBrowser(operations)
    CREATE stack as new StringStack
    FOR each operation in operations
        IF operation.type equals "push" THEN
            CALL stack.push(operation.url)
            PRINT pushed url and current page (stack.peek())
        ELSE IF operation.type equals "pop" THEN
            SET popped to stack.pop()
            IF popped is null THEN
                PRINT stack empty message
            ELSE
                PRINT popped url and current page (stack.peek())
            ENDIF
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL simulateBrowser(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a StringStack class with: a. An array to store URLs (strings), with a top index. b. Methods: push (add URL), pop (remove and return top URL), peek (view top URL), isEmpty (check if empty).
  2. In the simulateBrowser method: a. Create a new StringStack. b. For each operation:
    • If "push", push the URL and print the current page (top of stack).
    • If "pop", pop the top URL and print the new current page (top of stack) or "Stack empty" if empty.
  3. In the main method, test with sequences of push and pop operations, including empty stacks, single URLs, and null URLs.

Java Implementation

import java.util.*;

public class BrowserBackButtonSimulation {
    // Custom stack implementation for strings
    static class StringStack {
        private String[] array;
        private int top;
        private static final int DEFAULT_SIZE = 1000;

        public StringStack() {
            array = new String[DEFAULT_SIZE];
            top = -1;
        }

        public boolean push(String url) {
            if (top == array.length - 1) {
                return false; // Stack full
            }
            array[++top] = url;
            return true;
        }

        public String pop() {
            if (top == -1) {
                return null; // Stack empty
            }
            return array[top--];
        }

        public String peek() {
            if (top == -1) {
                return null; // Stack empty
            }
            return array[top];
        }

        public boolean isEmpty() {
            return top == -1;
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String url; // For push operations

        Operation(String type, String url) {
            this.type = type;
            this.url = url;
        }
    }

    // Simulates browser back button functionality
    public void simulateBrowser(List<Operation> operations) {
        StringStack stack = new StringStack();
        for (Operation op : operations) {
            if (op.type.equals("push")) {
                boolean success = stack.push(op.url);
                if (success) {
                    System.out.println("Pushed " + op.url + ", Current page: " + stack.peek());
                } else {
                    System.out.println("Push " + op.url + " failed: Stack full");
                }
            } else if (op.type.equals("pop")) {
                String popped = stack.pop();
                if (popped == null) {
                    System.out.println("Popped, Stack empty");
                } else {
                    System.out.println("Popped " + popped + ", Current page: " + 
                                       (stack.peek() != null ? stack.peek() : "Stack empty"));
                }
            }
        }
    }

    // Main method to test browser simulation
    public static void main(String[] args) {
        BrowserBackButtonSimulation simulator = new BrowserBackButtonSimulation();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal sequence
        List<Operation> case1 = Arrays.asList(
            new Operation("push", "https://page1.com"),
            new Operation("push", "https://page2.com"),
            new Operation("pop", null),
            new Operation("push", "https://page3.com"),
            new Operation("pop", null)
        );
        testCases.add(case1);
        
        // Test case 2: Empty stack
        List<Operation> case2 = Arrays.asList(
            new Operation("pop", null)
        );
        testCases.add(case2);
        
        // Test case 3: Single URL
        List<Operation> case3 = Arrays.asList(
            new Operation("push", "https://single.com"),
            new Operation("pop", null)
        );
        testCases.add(case3);
        
        // Test case 4: Multiple pushes
        List<Operation> case4 = Arrays.asList(
            new Operation("push", "https://site1.com"),
            new Operation("push", "https://site2.com"),
            new Operation("push", "https://site3.com"),
            new Operation("pop", null),
            new Operation("pop", null)
        );
        testCases.add(case4);
        
        // Test case 5: Null URL
        List<Operation> case5 = Arrays.asList(
            new Operation("push", null),
            new Operation("pop", null)
        );
        testCases.add(case5);

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            simulator.simulateBrowser(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Pushed https://page1.com, Current page: https://page1.com
Pushed https://page2.com, Current page: https://page2.com
Popped https://page2.com, Current page: https://page1.com
Pushed https://page3.com, Current page: https://page3.com
Popped https://page3.com, Current page: https://page1.com

Test case 2:
Popped, Stack empty

Test case 3:
Pushed https://single.com, Current page: https://single.com
Popped https://single.com, Current page: Stack empty

Test case 4:
Pushed https://site1.com, Current page: https://site1.com
Pushed https://site2.com, Current page: https://site2.com
Pushed https://site3.com, Current page: https://site3.com
Popped https://site3.com, Current page: https://site2.com
Popped https://site2.com, Current page: https://site1.com

Test case 5:
Pushed null, Current page: null
Popped null, Current page: Stack empty

Explanation:

  • Test case 1: Pushes "page1", "page2", pops to "page1", pushes "page3", pops to "page1".
  • Test case 2: Pop on empty stack returns "Stack empty".
  • Test case 3: Pushes single URL, pops to empty stack.
  • Test case 4: Multiple pushes, then pops back to "site1".
  • Test case 5: Pushes null URL, pops to empty stack.

How It Works

  • StringStack:
    • Uses an array to store URLs, with top tracking the latest element.
    • push: Adds a URL if the stack isn’t full.
    • pop: Removes and returns the top URL if not empty.
    • peek: Returns the top URL without removing it.
    • isEmpty: Checks if the stack is empty.
  • simulateBrowser:
    • Pushes URLs to simulate navigation, prints current page.
    • Pops URLs to simulate back button, prints new current page or "Stack empty".
  • Example Trace (Test case 1):
    • Push "page1": stack = ["page1"], current = "page1".
    • Push "page2": stack = ["page1", "page2"], current = "page2".
    • Pop: Removes "page2", current = "page1".
    • Push "page3": stack = ["page1", "page3"], current = "page3".
    • Pop: Removes "page3", current = "page1".
  • Main Method: Tests normal sequences, empty stack, single URL, multiple pushes, and null URL.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Push/Pop/PeekO(1)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the number of operations.
  • Time complexity: O(1) for each push, pop, or peek; O(n) for processing n operations.
  • Space complexity: O(n) for the stack storing up to n URLs.
  • Worst case: O(n) time and O(n) space for many push operations.

✅ Tip: Use a stack to simulate browser navigation, as its LIFO nature naturally tracks the history of visited pages. Test with sequences that include multiple pops to verify the back button functionality.

⚠ Warning: Handle null URLs and empty stack cases to avoid unexpected behavior. Ensure the stack size is sufficient to accommodate the input sequence.

Infix to Postfix Conversion

Problem Statement

Write a Java program that converts an infix expression (e.g., "A + B * C") to its postfix equivalent (e.g., "A B C * +") using a stack. The program should handle operators (+, -, *, /) and parentheses, pushing operators onto the stack based on precedence and popping them to the output when appropriate. Test the implementation with at least three different expressions, including simple expressions, expressions with parentheses, and complex expressions with multiple operators. You can visualize this as rearranging a mathematical sentence so that the operations are performed in the correct order without needing parentheses, like organizing tasks in a queue where the work gets done step by step.

Input: A string representing an infix expression (e.g., "A + B * C", "(A + B) * C"). Output: A string representing the postfix expression (e.g., "A B C * +", "A B + C *"). Constraints:

  • The input string length is between 0 and 10^5.
  • The string contains operands (single letters A-Z), operators (+, -, *, /), parentheses (()), and spaces.
  • The input may be empty, null, or invalid (handled gracefully). Example:
  • Input: "A + B * C"
  • Output: "A B C * +"
  • Explanation: * has higher precedence than +, so B * C is processed first, resulting in A B C * +.
  • Input: "(A + B) * C"
  • Output: "A B + C *"
  • Explanation: Parentheses ensure A + B is evaluated first, then multiplied by C.
  • Input: "A + B - C"
  • Output: "A B + C -"
  • Explanation: Operators + and - have equal precedence, processed left to right.

Pseudocode

CLASS CharStack
    SET array to new character array of size 1000
    SET top to -1
    
    FUNCTION push(char)
        IF top equals array length - 1 THEN
            RETURN false
        ENDIF
        INCREMENT top
        SET array[top] to char
        RETURN true
    ENDFUNCTION
    
    FUNCTION pop()
        IF top equals -1 THEN
            RETURN null
        ENDIF
        SET char to array[top]
        DECREMENT top
        RETURN char
    ENDFUNCTION
    
    FUNCTION peek()
        IF top equals -1 THEN
            RETURN null
        ENDIF
        RETURN array[top]
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN top equals -1
    ENDFUNCTION
ENDCLASS

FUNCTION getPrecedence(operator)
    IF operator is '*' or '/' THEN
        RETURN 2
    ELSE IF operator is '+' or '-' THEN
        RETURN 1
    ELSE
        RETURN 0
    ENDIF
ENDFUNCTION

FUNCTION infixToPostfix(expression)
    IF expression is null or empty THEN
        RETURN empty string
    ENDIF
    CREATE stack as new CharStack
    CREATE result as new StringBuilder
    FOR each char in expression
        IF char is letter THEN
            APPEND char to result
        ELSE IF char is '(' THEN
            PUSH char to stack
        ELSE IF char is ')' THEN
            WHILE stack is not empty and stack.peek() is not '('
                APPEND stack.pop() to result
            ENDWHILE
            IF stack is not empty THEN
                POP '(' from stack
            ENDIF
        ELSE IF char is operator (+, -, *, /) THEN
            WHILE stack is not empty and stack.peek() is not '(' and getPrecedence(stack.peek()) >= getPrecedence(char)
                APPEND stack.pop() to result
            ENDWHILE
            PUSH char to stack
        ENDIF
    ENDFOR
    WHILE stack is not empty
        APPEND stack.pop() to result
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testExpressions to array of infix expressions
    FOR each expression in testExpressions
        PRINT input expression
        CALL infixToPostfix(expression)
        PRINT postfix result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a CharStack class with methods: push, pop, peek, isEmpty.
  2. Define a getPrecedence function to return operator precedence: 2 for *, /; 1 for +, -; 0 for others.
  3. In the infixToPostfix method: a. If the input is null or empty, return an empty string. b. Create a stack for operators and a StringBuilder for the result. c. For each character in the expression:
    • If it’s a letter (operand), append it to the result.
    • If it’s '(', push it to the stack.
    • If it’s ')', pop operators from the stack to the result until '(' is found, then pop '('.
    • If it’s an operator, pop higher or equal precedence operators from the stack to the result, then push the current operator. d. After processing, pop remaining operators from the stack to the result. e. Return the result as a string.
  4. In the main method, test with at least three expressions, including simple, parenthesized, and complex cases.

Java Implementation

public class InfixToPostfixConversion {
    // Custom stack implementation for characters
    static class CharStack {
        private char[] array;
        private int top;
        private static final int DEFAULT_SIZE = 1000;

        public CharStack() {
            array = new char[DEFAULT_SIZE];
            top = -1;
        }

        public boolean push(char c) {
            if (top == array.length - 1) {
                return false; // Stack full
            }
            array[++top] = c;
            return true;
        }

        public Character pop() {
            if (top == -1) {
                return null; // Stack empty
            }
            return array[top--];
        }

        public Character peek() {
            if (top == -1) {
                return null; // Stack empty
            }
            return array[top];
        }

        public boolean isEmpty() {
            return top == -1;
        }
    }

    // Returns precedence of operators
    private int getPrecedence(char operator) {
        if (operator == '*' || operator == '/') {
            return 2;
        } else if (operator == '+' || operator == '-') {
            return 1;
        }
        return 0;
    }

    // Converts infix expression to postfix
    public String infixToPostfix(String expression) {
        if (expression == null || expression.isEmpty()) {
            return "";
        }
        CharStack stack = new CharStack();
        StringBuilder result = new StringBuilder();

        for (char c : expression.replaceAll("\\s", "").toCharArray()) {
            if (Character.isLetter(c)) {
                result.append(c);
            } else if (c == '(') {
                stack.push(c);
            } else if (c == ')') {
                while (!stack.isEmpty() && stack.peek() != '(') {
                    result.append(stack.pop());
                }
                if (!stack.isEmpty()) {
                    stack.pop(); // Remove '('
                }
            } else if (c == '+' || c == '-' || c == '*' || c == '/') {
                while (!stack.isEmpty() && stack.peek() != '(' && 
                       getPrecedence(stack.peek()) >= getPrecedence(c)) {
                    result.append(stack.pop());
                }
                stack.push(c);
            }
        }

        while (!stack.isEmpty()) {
            result.append(stack.pop());
        }

        return result.toString();
    }

    // Main method to test infix to postfix conversion
    public static void main(String[] args) {
        InfixToPostfixConversion converter = new InfixToPostfixConversion();

        // Test cases
        String[] testExpressions = {
            "A + B * C",          // Simple expression
            "(A + B) * C",        // Expression with parentheses
            "A + B * C - D / E",  // Complex expression
            "",                   // Empty string
            "X * (Y + Z)"        // Nested parentheses
        };

        for (int i = 0; i < testExpressions.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Infix: \"" + testExpressions[i] + "\"");
            String result = converter.infixToPostfix(testExpressions[i]);
            System.out.println("Postfix: \"" + result + "\"\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Infix: "A + B * C"
Postfix: "ABC*+"

Test case 2:
Infix: "(A + B) * C"
Postfix: "AB+C*"

Test case 3:
Infix: "A + B * C - D / E"
Postfix: "ABC*+DE/-"

Test case 4:
Infix: ""
Postfix: ""

Test case 5:
Infix: "X * (Y + Z)"
Postfix: "XYZ+*"

Explanation:

  • Test case 1: "A + B * C" → * has higher precedence, so B * C becomes "BC*", then + gives "ABC*+".
  • Test case 2: "(A + B) * C" → Parentheses prioritize A + B, giving "AB+", then * gives "AB+C*".
  • Test case 3: "A + B * C - D / E" → * and / have higher precedence, processed left to right, then + and -, giving "ABC*+DE/-".
  • Test case 4: "" → Empty input returns empty string.
  • Test case 5: "X * (Y + Z)" → Parentheses prioritize Y + Z, giving "YZ+", then * gives "XYZ+*".

How It Works

  • CharStack:
    • Stores operators and parentheses, with methods push, pop, peek, isEmpty.
  • getPrecedence:
    • Assigns precedence: 2 for *, /; 1 for +, -; 0 for others.
  • infixToPostfix:
    • Outputs operands (letters) directly to the result.
    • Pushes '(' to stack, pops operators until ')' for closing parentheses.
    • For operators, pops higher/equal precedence operators, then pushes current operator.
    • Pops remaining operators at the end.
  • Example Trace (Test case 1):
    • Input: "A + B * C".
    • A: Append to result → "A".
    • +: Push to stack → [+].
    • B: Append to result → "AB".
    • *: Higher precedence than +, push → [+, *].
    • C: Append to result → "ABC".
    • End: Pop * → "ABC*", pop + → "ABC*+".
  • Main Method: Tests simple, parenthesized, complex, empty, and nested expressions.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Push/Pop/PeekO(1)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the input expression.
  • Time complexity: O(n) for iterating through the expression, with O(1) for each stack operation.
  • Space complexity: O(n) for the stack (worst case: all operators/parentheses) and StringBuilder.
  • Worst case: O(n) time and O(n) space for expressions like "((A+B)*C)".

✅ Tip: Use a stack to manage operator precedence in infix-to-postfix conversion, as it naturally handles the order of operations. Test with parentheses and multiple operator types to ensure correctness.

⚠ Warning: Handle invalid inputs (e.g., unbalanced parentheses) gracefully to avoid stack underflow. Remove spaces from the input to simplify processing.

Parentheses Checker

Problem Statement

Write a Java program that uses a stack to check if a string of parentheses, including round '()', curly '{}', and square '[]' brackets, is balanced. The program should push opening brackets onto the stack and pop them when a matching closing bracket is found, returning true if the string is balanced and false otherwise. Test the implementation with various inputs, including valid and invalid parentheses strings, empty strings, and strings with non-bracket characters. You can visualize this as stacking open locks and checking if each closing lock perfectly matches and removes its corresponding open lock, ensuring no locks are left unmatched.

Input: A string containing parentheses and possibly other characters (e.g., "{[()]}", "((}", ""). Output: A boolean indicating if the parentheses are balanced (e.g., true for "{[()]}", false for "((}"). Constraints:

  • String length is between 0 and 10^5.
  • The string may contain '(', ')', '{', '}', '[', ']', and other ASCII characters.
  • The input may be empty or null. Example:
  • Input: "{[()]}"
  • Output: true
  • Explanation: Each opening bracket has a matching closing bracket in the correct order: { → }, [ → ], ( → ).
  • Input: "((}"
  • Output: false
  • Explanation: The second '(' has no matching ')', and '}' has no matching '{'.
  • Input: ""
  • Output: true
  • Explanation: An empty string is considered balanced.

Pseudocode

CLASS CharStack
    SET array to new character array of size 1000
    SET top to -1
    
    FUNCTION push(char)
        IF top equals array length - 1 THEN
            RETURN false (stack full)
        ENDIF
        INCREMENT top
        SET array[top] to char
        RETURN true
    ENDFUNCTION
    
    FUNCTION pop()
        IF top equals -1 THEN
            RETURN null (stack empty)
        ENDIF
        SET char to array[top]
        DECREMENT top
        RETURN char
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN top equals -1
    ENDFUNCTION
ENDCLASS

FUNCTION isBalanced(input)
    IF input is null THEN
        RETURN true
    ENDIF
    CREATE stack as new CharStack
    FOR each char in input
        IF char is '(', '{', or '[' THEN
            PUSH char to stack
        ELSE IF char is ')', '}', or ']' THEN
            IF stack is empty THEN
                RETURN false
            ENDIF
            SET popped to stack.pop()
            IF popped does not match char THEN
                RETURN false
            ENDIF
        ENDIF
    ENDFOR
    RETURN stack is empty
ENDFUNCTION

FUNCTION main()
    SET testStrings to array of strings including various cases
    FOR each string in testStrings
        PRINT input string
        CALL isBalanced(string)
        PRINT whether string is balanced
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a CharStack class with: a. An array to store characters, with a top index. b. Methods: push (add character), pop (remove and return character), isEmpty (check if empty).
  2. In the isBalanced method: a. If the input is null, return true (considered balanced). b. Create a new CharStack. c. Iterate through each character in the input:
    • 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).
      • Pop the top character and check if it matches the closing bracket.
      • If no match (e.g., '(' with '}'), return false. d. After iteration, return true if the stack is empty (all brackets matched), false otherwise.
  3. In the main method, test with valid, invalid, empty, and non-bracket strings, printing the input and result.

Java Implementation

public class ParenthesesChecker {
    // Custom stack implementation for characters
    static class CharStack {
        private char[] array;
        private int top;
        private static final int DEFAULT_SIZE = 1000;

        public CharStack() {
            array = new char[DEFAULT_SIZE];
            top = -1;
        }

        public boolean push(char c) {
            if (top == array.length - 1) {
                return false; // Stack full
            }
            array[++top] = c;
            return true;
        }

        public Character pop() {
            if (top == -1) {
                return null; // Stack empty
            }
            return array[top--];
        }

        public boolean isEmpty() {
            return top == -1;
        }
    }

    // Checks if a string of parentheses is balanced
    public boolean isBalanced(String input) {
        if (input == null) {
            return true;
        }
        CharStack stack = new CharStack();
        
        for (char c : input.toCharArray()) {
            if (c == '(' || c == '{' || c == '[') {
                stack.push(c);
            } else if (c == ')' || c == '}' || c == ']') {
                if (stack.isEmpty()) {
                    return false; // No matching opening bracket
                }
                Character popped = stack.pop();
                if (popped == null || !isMatchingPair(popped, c)) {
                    return false; // Mismatched brackets
                }
            }
        }
        return stack.isEmpty(); // Balanced only if stack is empty
    }

    // Helper method to check if brackets match
    private boolean isMatchingPair(char open, char close) {
        return (open == '(' && close == ')') ||
               (open == '{' && close == '}') ||
               (open == '[' && close == ']');
    }

    // Main method to test parentheses checker
    public static void main(String[] args) {
        ParenthesesChecker checker = new ParenthesesChecker();

        // Test cases
        String[] testStrings = {
            "{[()]}",          // Valid, balanced
            "((}",            // Invalid, mismatched
            "",               // Empty string
            "abc",            // No brackets
            "({[]})",         // Valid, nested
            "([)",            // Invalid, unmatched
            null,             // Null input
            "((()))"          // Valid, deeply nested
        };

        for (int i = 0; i < testStrings.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input: \"" + (testStrings[i] != null ? testStrings[i] : "null") + "\"");
            boolean result = checker.isBalanced(testStrings[i]);
            System.out.println("Balanced: " + result + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input: "{[()]}"
Balanced: true

Test case 2:
Input: "((}"
Balanced: false

Test case 3:
Input: ""
Balanced: true

Test case 4:
Input: "abc"
Balanced: true

Test case 5:
Input: "({[]})"
Balanced: true

Test case 6:
Input: "([)"
Balanced: false

Test case 7:
Input: "null"
Balanced: true

Test case 8:
Input: "((()))"
Balanced: true

Explanation:

  • Test case 1: "{[()]}" → All brackets match: {→}, [→], (→) → true.
  • Test case 2: "((}" → Second '(' unmatched, '}' has no '{' → false.
  • Test case 3: "" → Empty string, no brackets → true.
  • Test case 4: "abc" → No brackets, ignored → true.
  • Test case 5: "({[]})" → All brackets match in nested order → true.
  • Test case 6: "([)" → '[' does not match ')' → false.
  • Test case 7: null → Considered balanced → true.
  • Test case 8: "((()))" → Deeply nested, all match → true.

How It Works

  • CharStack:
    • Uses an array to store characters, with top tracking the latest element.
    • push: Adds an opening bracket if the stack isn’t full.
    • pop: Removes and returns the top bracket if the stack isn’t empty.
    • isEmpty: Checks if the stack is empty.
  • isBalanced:
    • Pushes opening brackets ('(', '{', '[') onto the stack.
    • For closing brackets, pops the top bracket and checks for a match.
    • Returns false if the stack is empty when a closing bracket is found or if brackets don’t match.
    • Returns true if the stack is empty after processing (all brackets matched).
  • Example Trace (Test case 1):
    • Input: "{[()]}".
    • Push '{': stack = ['{'].
    • Push '[': stack = ['{', '['].
    • Push '(': stack = ['{', '[', '('].
    • Pop for ')': Matches '(', stack = ['{', '['].
    • Pop for ']': Matches '[', stack = ['{'].
    • Pop for '}': Matches '{', stack = [].
    • Stack empty → true.
  • Main Method: Tests valid, invalid, empty, non-bracket, and null inputs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Push (per char)O(1)O(1)
Pop (per char)O(1)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the length of the input string.
  • Time complexity: O(n) for iterating through the string, with O(1) for each push/pop operation.
  • Space complexity: O(n) for the stack in the worst case (all opening brackets).
  • Worst case: O(n) time and O(n) space for a string like "(((((...".

✅ Tip: Use a stack to check balanced parentheses, as its LIFO nature naturally tracks nested brackets. Test with nested, mismatched, and non-bracket inputs to ensure robustness.

⚠ Warning: Handle null inputs and empty stacks to avoid NullPointerException or incorrect results. Ensure matching logic covers all bracket types ('()', '{}', '[]').

Stack Min Function

Problem Statement

Write a Java program that extends a stack implementation to include a min() function that returns the minimum element in the stack in O(1) time. Use an additional stack to track the minimum elements, pushing the current minimum when a new element is added and popping it when an element is removed. The program should support standard stack operations (push, pop, isEmpty) and the min function, handling integers as stack elements. Test the implementation with various sequences of push and pop operations, including edge cases like empty stacks and single-element stacks. You can visualize this as maintaining a leaderboard alongside a stack of scores, where the leaderboard always shows the lowest score at a glance without recalculating.

Input: A sequence of operations (push, pop, min) on a stack of integers (e.g., push 3, push 5, min, push 2, min, pop, min). Output: Results of operations, including the minimum element when requested (e.g., min returns 3, then 2, then 3). Constraints:

  • Stack size is between 0 and 10^5.
  • Elements are integers in the range [-10^9, 10^9].
  • The stack may be empty when min or pop is called. Example:
  • Input: Operations = [push(3), push(5), min, push(2), min, pop, min]
  • Output: [-, -, 3, -, 2, -, 3]
  • Explanation: Push 3 (min=3), push 5 (min=3), min returns 3, push 2 (min=2), min returns 2, pop 2 (min=3), min returns 3.
  • Input: Operations = [min on empty stack]
  • Output: [null]
  • Explanation: Min on empty stack returns null.

Pseudocode

CLASS MinStack
    SET dataStack to new integer array of size 1000
    SET minStack to new integer array of size 1000
    SET top to -1
    
    FUNCTION push(value)
        IF top equals dataStack length - 1 THEN
            RETURN false (stack full)
        ENDIF
        INCREMENT top
        SET dataStack[top] to value
        IF minStack is empty OR value is less than or equal to minStack[top] THEN
            PUSH value to minStack
        ENDIF
        RETURN true
    ENDFUNCTION
    
    FUNCTION pop()
        IF top equals -1 THEN
            RETURN null (stack empty)
        ENDIF
        SET value to dataStack[top]
        IF value equals minStack[top] THEN
            POP from minStack
        ENDIF
        DECREMENT top
        RETURN value
    ENDFUNCTION
    
    FUNCTION min()
        IF top equals -1 THEN
            RETURN null (stack empty)
        ENDIF
        RETURN minStack[top]
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN top equals -1
    ENDFUNCTION
ENDCLASS

FUNCTION testMinStack(operations)
    CREATE stack as new MinStack
    FOR each operation in operations
        IF operation.type equals "push" THEN
            CALL stack.push(operation.value)
            PRINT push result
        ELSE IF operation.type equals "pop" THEN
            SET result to stack.pop()
            PRINT popped value
        ELSE IF operation.type equals "min" THEN
            SET result to stack.min()
            PRINT minimum value
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL testMinStack(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a MinStack class with: a. Two arrays: dataStack for values, minStack for tracking minimums. b. A top index for both stacks. c. Methods: push (add value and update minStack), pop (remove value and update minStack), min (return top of minStack), isEmpty (check if empty).
  2. In push(value): a. If stack is full, return false. b. Add value to dataStack. c. If minStack is empty or value ≤ current minimum, push value to minStack.
  3. In pop(): a. If stack is empty, return null. b. If popped value equals current minimum, pop from minStack. c. Return popped value.
  4. In min(): a. If stack is empty, return null. b. Return top of minStack.
  5. In the main method, test with sequences of push, pop, and min operations, including empty stacks and single-element cases.

Java Implementation

import java.util.*;

public class StackMinFunction {
    // MinStack class with min() function
    static class MinStack {
        private int[] dataStack;
        private int[] minStack;
        private int top;
        private static final int DEFAULT_SIZE = 1000;

        public MinStack() {
            dataStack = new int[DEFAULT_SIZE];
            minStack = new int[DEFAULT_SIZE];
            top = -1;
        }

        public boolean push(int value) {
            if (top == dataStack.length - 1) {
                return false; // Stack full
            }
            dataStack[++top] = value;
            if (top == 0 || value <= minStack[top - 1]) {
                minStack[top] = value; // Push to minStack if value is new minimum
            } else {
                minStack[top] = minStack[top - 1]; // Copy previous minimum
            }
            return true;
        }

        public Integer pop() {
            if (top == -1) {
                return null; // Stack empty
            }
            return dataStack[top--]; // Pop from dataStack, minStack follows
        }

        public Integer min() {
            if (top == -1) {
                return null; // Stack empty
            }
            return minStack[top]; // Return current minimum
        }

        public boolean isEmpty() {
            return top == -1;
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        Integer value; // For push operations

        Operation(String type, Integer value) {
            this.type = type;
            this.value = value;
        }
    }

    // Tests the MinStack with a sequence of operations
    public void testMinStack(List<Operation> operations) {
        MinStack stack = new MinStack();
        for (Operation op : operations) {
            if (op.type.equals("push")) {
                boolean success = stack.push(op.value);
                System.out.println("Push " + op.value + ": " + (success ? "Success" : "Failed"));
            } else if (op.type.equals("pop")) {
                Integer result = stack.pop();
                System.out.println("Pop: " + (result != null ? result : "null"));
            } else if (op.type.equals("min")) {
                Integer result = stack.min();
                System.out.println("Min: " + (result != null ? result : "null"));
            }
        }
    }

    // Main method to test MinStack
    public static void main(String[] args) {
        StackMinFunction tester = new StackMinFunction();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal sequence
        List<Operation> case1 = Arrays.asList(
            new Operation("push", 3),
            new Operation("push", 5),
            new Operation("min", null),
            new Operation("push", 2),
            new Operation("min", null),
            new Operation("pop", null),
            new Operation("min", null)
        );
        testCases.add(case1);
        
        // Test case 2: Empty stack
        List<Operation> case2 = Arrays.asList(
            new Operation("min", null),
            new Operation("pop", null)
        );
        testCases.add(case2);
        
        // Test case 3: Single element
        List<Operation> case3 = Arrays.asList(
            new Operation("push", 1),
            new Operation("min", null)
        );
        testCases.add(case3);
        
        // Test case 4: Large sequence
        List<Operation> case4 = new ArrayList<>();
        case4.add(new Operation("push", 10));
        case4.add(new Operation("push", 5));
        case4.add(new Operation("push", 15));
        case4.add(new Operation("min", null));
        case4.add(new Operation("pop", null));
        case4.add(new Operation("min", null));
        testCases.add(case4);

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            tester.testMinStack(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Push 3: Success
Push 5: Success
Min: 3
Push 2: Success
Min: 2
Pop: 2
Min: 3

Test case 2:
Min: null
Pop: null

Test case 3:
Push 1: Success
Min: 1

Test case 4:
Push 10: Success
Push 5: Success
Push 15: Success
Min: 5
Pop: 15
Min: 5

Explanation:

  • Test case 1: Push 3 (min=3), push 5 (min=3), min returns 3, push 2 (min=2), min returns 2, pop 2 (min=3), min returns 3.
  • Test case 2: Min and pop on empty stack return null.
  • Test case 3: Push 1 (min=1), min returns 1.
  • Test case 4: Push 10 (min=10), push 5 (min=5), push 15 (min=5), min returns 5, pop 15 (min=5), min returns 5.

How It Works

  • MinStack:
    • Uses two arrays: dataStack for values, minStack for minimums.
    • push: Adds value to dataStack; pushes to minStack if value ≤ current minimum, else copies current minimum.
    • pop: Removes value from dataStack; adjusts minStack to maintain minimum.
    • min: Returns top of minStack in O(1) time.
    • isEmpty: Checks if stack is empty.
  • Example Trace (Test case 1):
    • Push 3: dataStack=[3], minStack=[3], top=0.
    • Push 5: dataStack=[3,5], minStack=[3,3], top=1.
    • Min: Returns 3.
    • Push 2: dataStack=[3,5,2], minStack=[3,3,2], top=2.
    • Min: Returns 2.
    • Pop: Removes 2, dataStack=[3,5], minStack=[3,3], top=1.
    • Min: Returns 3.
  • Main Method: Tests normal sequences, empty stacks, single elements, and larger sequences.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
PushO(1)O(1)
PopO(1)O(1)
MinO(1)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the number of operations.
  • Time complexity: O(1) for push, pop, and min operations.
  • Space complexity: O(n) for both stacks, as each push may add to both.
  • Worst case: O(n) space for all operations, O(1) time per operation.

✅ Tip: Use an additional stack to track minimums for O(1) min queries, pushing the minimum at each step. Test with sequences that include repeated minimums to verify correctness.

⚠ Warning: Ensure both stacks are synchronized during push and pop to avoid incorrect minimums. Handle empty stack cases to prevent null pointer issues.

StringBuilder Capacity Management

Problem Statement

Write a Java program that demonstrates StringBuilder’s capacity resizing by appending strings and monitoring capacity changes using the capacity() method. The program should analyze when resizing occurs and how the capacity adjusts based on the appended content. Test the implementation with different scenarios, including appending small strings, large strings, and using StringBuilder instances with varying initial capacities. You can visualize this as filling a stretchable notebook, where the notebook expands its pages (capacity) automatically when you add more notes, and you track how and when it grows to accommodate the text.

Input: A series of strings to append to StringBuilder instances with different initial configurations (e.g., default capacity, specified initial capacity). Output: The final string, a log of capacity changes after each append operation, and an analysis of when resizing occurs. Constraints:

  • String lengths are between 0 and 10^5.
  • Strings contain printable ASCII characters.
  • Initial capacities are non-negative integers.
  • The input strings may be null or empty. Example:
  • Input: Append "hello", " world" to a StringBuilder with default capacity (16).
  • Output:
    Initial capacity: 16
    After appending "hello": length=5, capacity=16
    After appending " world": length=11, capacity=16
    Final string: "hello world"
    
  • Explanation: No resizing occurs as the total length (11) fits within the initial capacity (16).
  • Input: Append a 20-character string to a StringBuilder with default capacity (16).
  • Output:
    Initial capacity: 16
    After appending: length=20, capacity=34
    Final string: "abcdefghijklmnopqrst"
    
  • Explanation: Resizing occurs because 20 exceeds 16, new capacity = (16 * 2) + 2 = 34.

Pseudocode

FUNCTION demonstrateCapacityManagement(testCases)
    FOR each testCase in testCases
        SET initialCapacity to testCase.initialCapacity
        SET strings to testCase.strings
        CREATE stringBuilder with initialCapacity
        PRINT initial capacity
        FOR each string in strings
            SET oldCapacity to stringBuilder.capacity()
            APPEND string to stringBuilder
            SET newCapacity to stringBuilder.capacity()
            PRINT length, capacity, and whether resizing occurred
        ENDFOR
        PRINT final string
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (initialCapacity, strings) pairs
    CALL demonstrateCapacityManagement(testCases)
ENDFUNCTION

Algorithm Steps

  1. Define a method to demonstrate capacity management: a. For each test case, create a StringBuilder with the specified initial capacity. b. Print the initial capacity using capacity(). c. For each string to append:
    • Record the current capacity.
    • Append the string using StringBuilder.append.
    • Check the new capacity and note if resizing occurred (capacity increased).
    • Print the current length, capacity, and resizing status. d. Print the final string.
  2. Handle null or empty strings by skipping or appending empty content.
  3. In the main method, test with different scenarios:
    • Default capacity (16) with small strings.
    • Default capacity with a large string that triggers resizing.
    • Custom initial capacity with multiple appends.
    • Empty or null strings.
  4. Analyze resizing: StringBuilder doubles the current capacity and adds 2 when the length exceeds the capacity.

Java Implementation

public class StringBuilderCapacityManagement {
    // Demonstrates StringBuilder capacity resizing
    public void demonstrateCapacityManagement(String[][] testCases, int[] initialCapacities) {
        for (int i = 0; i < testCases.length; i++) {
            String[] strings = testCases[i];
            int initialCapacity = initialCapacities[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Initial capacity: " + initialCapacity);
            
            StringBuilder sb = new StringBuilder(initialCapacity);
            for (int j = 0; j < strings.length; j++) {
                String str = strings[j] != null ? strings[j] : "";
                int oldCapacity = sb.capacity();
                sb.append(str);
                int newCapacity = sb.capacity();
                System.out.println("After appending \"" + (str.length() > 20 ? str.substring(0, 20) + "..." : str) + "\": " +
                                   "length=" + sb.length() + ", capacity=" + newCapacity +
                                   (newCapacity > oldCapacity ? " (resized)" : ""));
            }
            System.out.println("Final string: \"" + (sb.length() > 50 ? sb.substring(0, 50) + "..." : sb.toString()) + "\"\n");
        }
    }

    // Main method to test capacity management
    public static void main(String[] args) {
        StringBuilderCapacityManagement manager = new StringBuilderCapacityManagement();

        // Test cases
        String[][] testCases = {
            {"hello", " world"},                    // Small strings, default capacity
            {generateLargeString(20)},             // Large string, triggers resizing
            {"a", "b", "c", "d"},                 // Multiple small strings, custom capacity
            {""},                                  // Empty string
            {null}                                 // Null string
        };
        int[] initialCapacities = {16, 16, 5, 16, 16}; // Corresponding initial capacities

        manager.demonstrateCapacityManagement(testCases, initialCapacities);
    }

    // Helper method to generate a large string
    private static String generateLargeString(int length) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            sb.append((char) ('a' + (i % 26)));
        }
        return sb.toString();
    }
}

Output

Running the main method produces (actual capacities may vary slightly depending on JVM implementation):

Test case 1:
Initial capacity: 16
After appending "hello": length=5, capacity=16
After appending " world": length=11, capacity=16
Final string: "hello world"

Test case 2:
Initial capacity: 16
After appending "abcdefghijklmnopqrst...": length=20, capacity=34 (resized)
Final string: "abcdefghijklmnopqrst"

Test case 3:
Initial capacity: 5
After appending "a": length=1, capacity=5
After appending "b": length=2, capacity=5
After appending "c": length=3, capacity=5
After appending "d": length=4, capacity=5
Final string: "abcd"

Test case 4:
Initial capacity: 16
After appending "": length=0, capacity=16
Final string: ""

Test case 5:
Initial capacity: 16
After appending "": length=0, capacity=16
Final string: ""

Explanation:

  • Test case 1: Appends "hello", " world". Length (11) < capacity (16), no resizing.
  • Test case 2: Appends 20-character string. Length (20) > 16, resizes to (16 * 2) + 2 = 34.
  • Test case 3: Custom capacity 5, appends four single characters. Length (4) ≤ 5, no resizing.
  • Test case 4: Empty string, no change in length or capacity.
  • Test case 5: Null string treated as empty, no change.

How It Works

  • StringBuilder Capacity: Represents the size of the internal character array. Default is 16 if not specified.
  • Resizing: When the length exceeds capacity, StringBuilder allocates a new array with capacity = (oldCapacity * 2) + 2.
  • demonstrateCapacityManagement:
    • Creates StringBuilder with specified initial capacity.
    • Tracks capacity before and after each append.
    • Reports resizing when new capacity exceeds old capacity.
  • Example Trace (Test case 2):
    • Initial: capacity = 16, length = 0.
    • Append "abcdefghijklmnopqrst" (20 chars): length = 20 > 16, new capacity = (16 * 2) + 2 = 34.
  • Main Method: Tests with small strings, large strings triggering resizing, custom capacity, and edge cases.
  • Analysis: Resizing occurs when the total length after appending exceeds the current capacity, doubling the capacity plus 2 to accommodate future appends efficiently.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
AppendO(m)O(m)
Capacity CheckO(1)O(1)
ResizingO(n)O(n)
Full AlgorithmO(k * m)O(n + k * m)

Note:

  • n is the current length of the StringBuilder, m is the length of the appended string, k is the number of append operations.
  • Append: O(m) for copying characters, amortized O(1) if no resizing.
  • Resizing: O(n) when copying the internal array to a larger one.
  • Full algorithm: O(k * m) time for k appends, O(n + k * m) space for the final StringBuilder content.
  • Worst case: Frequent resizing for large strings increases time and space.

✅ Tip: Set an appropriate initial capacity for StringBuilder when the expected size is known to minimize resizing. Use capacity() to monitor and understand resizing behavior during development.

⚠ Warning: Avoid unnecessarily small initial capacities, as frequent resizing can degrade performance. Handle null inputs to prevent unexpected behavior during append operations.

Queues Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Circular Queue Test

Problem Statement

Write a Java program that implements a circular queue to handle edge cases, such as enqueueing and dequeuing in a loop, and tests it with a sequence of operations to verify circular behavior. The circular queue should reuse array space by wrapping around when the rear pointer reaches the end, and handle full and empty queue conditions. Test the implementation with various sequences of enqueue and dequeue operations, including wrap-around scenarios, full queues, and empty queues. You can visualize this as a circular conveyor belt where items are added and removed, and when the belt’s end is reached, it loops back to the start to continue adding items.

Input: A sequence of operations, where each operation is either:

  • Enqueue: Add an integer to the queue (e.g., enqueue 5).
  • Dequeue: Remove and return the next integer from the queue. Output: For each operation, print the action performed (enqueue or dequeue) and the result, including queue state or error messages for full/empty conditions. Constraints:
  • Queue capacity is fixed (e.g., 5 elements for testing).
  • Elements are integers in the range [-10^9, 10^9].
  • The queue may be empty or full during operations. Example:
  • Input: Operations = [enqueue(1), enqueue(2), enqueue(3), dequeue, enqueue(4), dequeue, enqueue(5)]
  • Output:
    Enqueued 1, Queue: [1]
    Enqueued 2, Queue: [1, 2]
    Enqueued 3, Queue: [1, 2, 3]
    Dequeued 1, Queue: [2, 3]
    Enqueued 4, Queue: [2, 3, 4]
    Dequeued 2, Queue: [3, 4]
    Enqueued 5, Queue: [3, 4, 5]
    
  • Explanation: The queue operates in FIFO order, reusing space as elements are dequeued.
  • Input: Operations = [enqueue(1), enqueue(2), enqueue(3), enqueue(4), enqueue(5), enqueue(6) on capacity 5]
  • Output: Enqueue 6 failed: Queue full

Pseudocode

CLASS CircularQueue
    SET array to new integer array of size capacity
    SET front to 0
    SET rear to -1
    SET size to 0
    SET capacity to input capacity
    
    FUNCTION enqueue(number)
        IF size equals capacity THEN
            RETURN false (queue full)
        ENDIF
        SET rear to (rear + 1) mod capacity
        SET array[rear] to number
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null (queue empty)
        ENDIF
        SET number to array[front]
        SET front to (front + 1) mod capacity
        DECREMENT size
        RETURN number
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN size equals 0
    ENDFUNCTION
    
    FUNCTION isFull()
        RETURN size equals capacity
    ENDFUNCTION
    
    FUNCTION toString()
        CREATE result as empty string
        APPEND "["
        FOR i from 0 to size - 1
            SET index to (front + i) mod capacity
            APPEND array[index] and ", " to result
        ENDFOR
        IF result ends with ", " THEN
            REMOVE last ", "
        ENDIF
        APPEND "]" to result
        RETURN result
    ENDFUNCTION
ENDCLASS

FUNCTION testCircularQueue(operations, capacity)
    CREATE queue as new CircularQueue with capacity
    FOR each operation in operations
        IF operation.type equals "enqueue" THEN
            IF queue.enqueue(operation.number) THEN
                PRINT enqueued number and queue state
            ELSE
                PRINT enqueue failed: queue full
            ENDIF
        ELSE IF operation.type equals "dequeue" THEN
            SET number to queue.dequeue()
            IF number is null THEN
                PRINT queue empty message
            ELSE
                PRINT dequeued number and queue state
            ENDIF
        ENDIF
    ENFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences with fixed capacity
    FOR each testCase in testCases
        PRINT test case details
        CALL testCircularQueue(testCase, capacity)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a CircularQueue class with: a. An array of fixed capacity, with front, rear, and size to track state. b. Methods: enqueue (add to rear with wrap-around), dequeue (remove from front with wrap-around), isEmpty, isFull, toString.
  2. In enqueue: a. If queue is full, return false. b. Increment rear modulo capacity, add number, increment size.
  3. In dequeue: a. If queue is empty, return null. b. Remove number at front, increment front modulo capacity, decrement size.
  4. In testCircularQueue: a. Create a CircularQueue with given capacity. b. For each operation:
    • Enqueue: Add number, print queue state or "Queue full".
    • Dequeue: Remove number, print result and queue state or "Queue empty".
  5. In the main method, test with sequences demonstrating normal operations, wrap-around, full queue, and empty queue cases.

Java Implementation

import java.util.*;

public class CircularQueueTest {
    // Custom circular queue implementation for integers
    static class CircularQueue {
        private int[] array;
        private int front;
        private int rear;
        private int size;
        private int capacity;

        public CircularQueue(int capacity) {
            this.array = new int[capacity];
            this.front = 0;
            this.rear = -1;
            this.size = 0;
            this.capacity = capacity;
        }

        public boolean enqueue(int number) {
            if (size == capacity) {
                return false; // Queue full
            }
            rear = (rear + 1) % capacity;
            array[rear] = number;
            size++;
            return true;
        }

        public Integer dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            int number = array[front];
            front = (front + 1) % capacity;
            size--;
            return number;
        }

        public boolean isEmpty() {
            return size == 0;
        }

        public boolean isFull() {
            return size == capacity;
        }

        public String toString() {
            StringBuilder result = new StringBuilder("[");
            for (int i = 0; i < size; i++) {
                int index = (front + i) % capacity;
                result.append(array[index]);
                if (i < size - 1) {
                    result.append(", ");
                }
            }
            result.append("]");
            return result.toString();
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        Integer number; // For enqueue operations

        Operation(String type, Integer number) {
            this.type = type;
            this.number = number;
        }
    }

    // Tests circular queue with operations
    public void testCircularQueue(List<Operation> operations, int capacity) {
        CircularQueue queue = new CircularQueue(capacity);
        for (Operation op : operations) {
            if (op.type.equals("enqueue")) {
                if (queue.enqueue(op.number)) {
                    System.out.println("Enqueued " + op.number + ", Queue: " + queue);
                } else {
                    System.out.println("Enqueue " + op.number + " failed: Queue full");
                }
            } else if (op.type.equals("dequeue")) {
                Integer number = queue.dequeue();
                if (number == null) {
                    System.out.println("Queue empty, cannot dequeue");
                } else {
                    System.out.println("Dequeued " + number + ", Queue: " + queue);
                }
            }
        }
    }

    // Main method to test circular queue
    public static void main(String[] args) {
        CircularQueueTest tester = new CircularQueueTest();
        int capacity = 5; // Fixed capacity for all test cases

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal sequence
        List<Operation> case1 = Arrays.asList(
            new Operation("enqueue", 1),
            new Operation("enqueue", 2),
            new Operation("enqueue", 3),
            new Operation("dequeue", null),
            new Operation("enqueue", 4),
            new Operation("dequeue", null),
            new Operation("enqueue", 5)
        );
        testCases.add(case1);
        
        // Test case 2: Wrap-around behavior
        List<Operation> case2 = Arrays.asList(
            new Operation("enqueue", 1),
            new Operation("enqueue", 2),
            new Operation("enqueue", 3),
            new Operation("dequeue", null),
            new Operation("dequeue", null),
            new Operation("enqueue", 4),
            new Operation("enqueue", 5),
            new Operation("enqueue", 6)
        );
        testCases.add(case2);
        
        // Test case 3: Full queue
        List<Operation> case3 = Arrays.asList(
            new Operation("enqueue", 1),
            new Operation("enqueue", 2),
            new Operation("enqueue", 3),
            new Operation("enqueue", 4),
            new Operation("enqueue", 5),
            new Operation("enqueue", 6)
        );
        testCases.add(case3);
        
        // Test case 4: Empty queue
        List<Operation> case4 = Arrays.asList(
            new Operation("dequeue", null)
        );
        testCases.add(case4);

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + " (capacity=" + capacity + "):");
            tester.testCircularQueue(testCases.get(i), capacity);
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1 (capacity=5):
Enqueued 1, Queue: [1]
Enqueued 2, Queue: [1, 2]
Enqueued 3, Queue: [1, 2, 3]
Dequeued 1, Queue: [2, 3]
Enqueued 4, Queue: [2, 3, 4]
Dequeued 2, Queue: [3, 4]
Enqueued 5, Queue: [3, 4, 5]

Test case 2 (capacity=5):
Enqueued 1, Queue: [1]
Enqueued 2, Queue: [1, 2]
Enqueued 3, Queue: [1, 2, 3]
Dequeued 1, Queue: [2, 3]
Dequeued 2, Queue: [3]
Enqueued 4, Queue: [3, 4]
Enqueued 5, Queue: [3, 4, 5]
Enqueued 6, Queue: [3, 4, 5, 6]

Test case 3 (capacity=5):
Enqueued 1, Queue: [1]
Enqueued 2, Queue: [1, 2]
Enqueued 3, Queue: [1, 2, 3]
Enqueued 4, Queue: [1, 2, 3, 4]
Enqueued 5, Queue: [1, 2, 3, 4, 5]
Enqueue 6 failed: Queue full

Test case 4 (capacity=5):
Queue empty, cannot dequeue

Explanation:

  • Test case 1: Normal enqueue/dequeue operations within capacity.
  • Test case 2: Demonstrates wrap-around (after dequeuing, rear wraps to start for 4, 5, 6).
  • Test case 3: Fills queue to capacity, fails to enqueue 6.
  • Test case 4: Dequeue on empty queue returns error.

How It Works

  • CircularQueue:
    • Uses an array with front, rear, and size to manage integers in FIFO order.
    • enqueue: Adds to rear (modulo capacity) if not full.
    • dequeue: Removes from front (modulo capacity) if not empty.
    • toString: Formats queue contents from front to rear.
    • Wrap-around: rear = (rear + 1) % capacity and front = (front + 1) % capacity enable circular behavior.
  • testCircularQueue:
    • Enqueues numbers, prints queue state or "Queue full".
    • Dequeues numbers, prints result and queue state or "Queue empty".
  • Example Trace (Test case 2):
    • Enqueue 1: array=[1], front=0, rear=0, size=1.
    • Enqueue 2: array=[1,2], front=0, rear=1, size=2.
    • Enqueue 3: array=[1,2,3], front=0, rear=2, size=3.
    • Dequeue 1: array=[-,2,3], front=1, rear=2, size=2.
    • Dequeue 2: array=[-,-,3], front=2, rear=2, size=1.
    • Enqueue 4: array=[4,-,3], front=2, rear=0, size=2 (wrap-around).
    • Enqueue 5: array=[4,5,3], front=2, rear=1, size=3.
    • Enqueue 6: array=[4,5,3,6], front=2, rear=2, size=4.
  • Main Method: Tests normal operations, wrap-around, full queue, and empty queue.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Enqueue/DequeueO(1)O(1)
Full AlgorithmO(n)O(c)

Note:

  • n is the number of operations, c is the fixed capacity.
  • Time complexity: O(1) for each enqueue/dequeue; O(n) for n operations.
  • Space complexity: O(c) for the fixed-size array.
  • Worst case: O(n) time for n operations, O(c) space for the queue.

✅ Tip: Use a circular queue to efficiently reuse array space, especially for applications with frequent enqueue and dequeue operations. Test wrap-around behavior by dequeuing some elements and then enqueuing more to verify the circular property.

⚠ Warning: Carefully manage front and rear indices with modulo operations to ensure correct wrap-around. Handle full and empty queue cases to avoid incorrect behavior.

Print Job Simulator

Problem Statement

Write a Java program that simulates a printer queue using a queue implementation. The program should allow users to enqueue print jobs (represented as strings) and dequeue them in order, printing each job as it is processed in a first-in, first-out (FIFO) manner. Test the implementation with various sequences of enqueue and dequeue operations, including edge cases like empty queues and null print jobs. You can visualize this as a printer handling a queue of documents, where each document is printed in the order it was submitted, and the printer processes one job at a time.

Input: A sequence of operations, where each operation is either:

  • Enqueue: Add a print job (string) to the queue (e.g., "Document1").
  • Dequeue: Remove and process the next print job, printing its details. Output: For each operation, print the action performed (enqueue or dequeue) and, for dequeue, the processed job or a message if the queue is empty. Constraints:
  • Queue size is between 0 and 10^5.
  • Print jobs are non-empty strings containing printable ASCII characters, or null (handled gracefully).
  • The queue may be empty when dequeue is called. Example:
  • Input: Operations = [enqueue("Doc1"), enqueue("Doc2"), dequeue, enqueue("Doc3"), dequeue]
  • Output:
    Enqueued Doc1
    Enqueued Doc2
    Dequeued and processed: Doc1
    Enqueued Doc3
    Dequeued and processed: Doc2
    
  • Explanation: Jobs are enqueued in order and dequeued in FIFO order, printing each job as processed.
  • Input: Operations = [dequeue on empty queue]
  • Output: Queue empty, cannot dequeue

Pseudocode

CLASS StringQueue
    SET array to new string array of size 1000
    SET front to 0
    SET rear to -1
    SET size to 0
    
    FUNCTION enqueue(job)
        IF size equals array length THEN
            RETURN false (queue full)
        ENDIF
        INCREMENT rear
        SET array[rear] to job
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null (queue empty)
        ENDIF
        SET job to array[front]
        INCREMENT front
        DECREMENT size
        RETURN job
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN size equals 0
    ENDFUNCTION
ENDCLASS

FUNCTION simulatePrinter(operations)
    CREATE queue as new StringQueue
    FOR each operation in operations
        IF operation.type equals "enqueue" THEN
            CALL queue.enqueue(operation.job)
            PRINT enqueued job
        ELSE IF operation.type equals "dequeue" THEN
            SET job to queue.dequeue()
            IF job is null THEN
                PRINT queue empty message
            ELSE
                PRINT dequeued and processed job
            ENDIF
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL simulatePrinter(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a StringQueue class with: a. An array to store print jobs (strings), with front, rear, and size to track queue state. b. Methods: enqueue (add job to rear), dequeue (remove job from front), isEmpty (check if empty).
  2. In the simulatePrinter method: a. Create a new StringQueue. b. For each operation:
    • If "enqueue", add the job to the queue and print the action.
    • If "dequeue", remove the next job, print it as processed, or print "Queue empty" if empty.
  3. In the main method, test with sequences of enqueue and dequeue operations, including empty queues, single jobs, and null jobs.

Java Implementation

import java.util.*;

public class PrintJobSimulator {
    // Custom queue implementation for strings
    static class StringQueue {
        private String[] array;
        private int front;
        private int rear;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public StringQueue() {
            array = new String[DEFAULT_SIZE];
            front = 0;
            rear = -1;
            size = 0;
        }

        public boolean enqueue(String job) {
            if (size == array.length) {
                return false; // Queue full
            }
            array[++rear] = job;
            size++;
            return true;
        }

        public String dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            String job = array[front++];
            size--;
            return job;
        }

        public boolean isEmpty() {
            return size == 0;
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String job; // For enqueue operations

        Operation(String type, String job) {
            this.type = type;
            this.job = job;
        }
    }

    // Simulates printer queue functionality
    public void simulatePrinter(List<Operation> operations) {
        StringQueue queue = new StringQueue();
        for (Operation op : operations) {
            if (op.type.equals("enqueue")) {
                boolean success = queue.enqueue(op.job);
                if (success) {
                    System.out.println("Enqueued " + (op.job != null ? op.job : "null"));
                } else {
                    System.out.println("Enqueue " + (op.job != null ? op.job : "null") + " failed: Queue full");
                }
            } else if (op.type.equals("dequeue")) {
                String job = queue.dequeue();
                if (job == null) {
                    System.out.println("Queue empty, cannot dequeue");
                } else {
                    System.out.println("Dequeued and processed: " + job);
                }
            }
        }
    }

    // Main method to test printer queue simulation
    public static void main(String[] args) {
        PrintJobSimulator simulator = new PrintJobSimulator();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal sequence
        List<Operation> case1 = Arrays.asList(
            new Operation("enqueue", "Document1"),
            new Operation("enqueue", "Document2"),
            new Operation("dequeue", null),
            new Operation("enqueue", "Document3"),
            new Operation("dequeue", null)
        );
        testCases.add(case1);
        
        // Test case 2: Empty queue
        List<Operation> case2 = Arrays.asList(
            new Operation("dequeue", null)
        );
        testCases.add(case2);
        
        // Test case 3: Single job
        List<Operation> case3 = Arrays.asList(
            new Operation("enqueue", "SingleDoc"),
            new Operation("dequeue", null)
        );
        testCases.add(case3);
        
        // Test case 4: Multiple enqueues
        List<Operation> case4 = Arrays.asList(
            new Operation("enqueue", "Job1"),
            new Operation("enqueue", "Job2"),
            new Operation("enqueue", "Job3"),
            new Operation("dequeue", null),
            new Operation("dequeue", null)
        );
        testCases.add(case4);
        
        // Test case 5: Null job
        List<Operation> case5 = Arrays.asList(
            new Operation("enqueue", null),
            new Operation("dequeue", null)
        );
        testCases.add(case5);

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            simulator.simulatePrinter(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Enqueued Document1
Enqueued Document2
Dequeued and processed: Document1
Enqueued Document3
Dequeued and processed: Document2

Test case 2:
Queue empty, cannot dequeue

Test case 3:
Enqueued SingleDoc
Dequeued and processed: SingleDoc

Test case 4:
Enqueued Job1
Enqueued Job2
Enqueued Job3
Dequeued and processed: Job1
Dequeued and processed: Job2

Test case 5:
Enqueued null
Dequeued and processed: null

Explanation:

  • Test case 1: Enqueues "Document1", "Document2", dequeues "Document1", enqueues "Document3", dequeues "Document2".
  • Test case 2: Dequeue on empty queue returns "Queue empty".
  • Test case 3: Enqueues single job, dequeues it.
  • Test case 4: Enqueues three jobs, dequeues two in FIFO order.
  • Test case 5: Enqueues null job, dequeues it.

How It Works

  • StringQueue:
    • Uses an array with front and rear indices to track the queue’s state, and size to track the number of jobs.
    • enqueue: Adds a job to the rear if the queue isn’t full.
    • dequeue: Removes and returns the front job if not empty.
    • isEmpty: Checks if the queue is empty.
  • simulatePrinter:
    • Enqueues jobs, printing each action.
    • Dequeues jobs, printing the processed job or "Queue empty".
  • Example Trace (Test case 1):
    • Enqueue "Document1": queue = ["Document1"], front=0, rear=0.
    • Enqueue "Document2": queue = ["Document1", "Document2"], front=0, rear=1.
    • Dequeue: Returns "Document1", queue = ["Document2"], front=1, rear=1.
    • Enqueue "Document3": queue = ["Document2", "Document3"], front=1, rear=2.
    • Dequeue: Returns "Document2", queue = ["Document3"], front=2, rear=2.
  • Main Method: Tests normal sequences, empty queue, single job, multiple enqueues, and null job.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Enqueue/DequeueO(1)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the number of operations.
  • Time complexity: O(1) for each enqueue or dequeue; O(n) for processing n operations.
  • Space complexity: O(n) for the queue storing up to n jobs.
  • Worst case: O(n) time and O(n) space for many enqueue operations.

✅ Tip: Use a queue to simulate a printer’s job processing, as its FIFO nature ensures jobs are processed in submission order. Test with sequences that mix enqueue and dequeue operations to verify correctness.

⚠ Warning: Handle null jobs and empty queue cases to avoid unexpected behavior. Ensure the queue size is sufficient to accommodate the input sequence.

Queue-Based BFS

Problem Statement

Write a Java program that implements a Breadth-First Search (BFS) algorithm for a graph using a queue. Represent the graph as an adjacency list and print the nodes visited in BFS order, starting from a specified node. The program should handle undirected graphs, enqueue nodes to explore them level by level, and mark visited nodes to avoid cycles. Test the implementation with various graph structures, including connected, disconnected, and cyclic graphs, as well as edge cases like empty graphs. You can visualize this as exploring a network of cities, visiting all cities at the current distance from the starting point before moving farther, using a queue to keep track of the next cities to visit.

Input:

  • Number of nodes n (0 ≤ n ≤ 10^5).
  • List of edges as pairs of nodes (e.g., [[0,1], [1,2]] for edges 0-1, 1-2).
  • Starting node for BFS. Output: A string of nodes visited in BFS order (e.g., "0 1 2"). Constraints:
  • Nodes are integers from 0 to n-1.
  • Edges are undirected (if u-v exists, v-u exists).
  • The graph may be empty, disconnected, or cyclic. Example:
  • Input: n=4, edges=[[0,1], [0,2], [1,3]], start=0
  • Output: "0 1 2 3"
  • Explanation: Starting from node 0, visit 0, then neighbors 1 and 2, then 1’s neighbor 3.
  • Input: n=3, edges=[], start=0
  • Output: "0"
  • Explanation: No edges, only node 0 is visited.

Pseudocode

CLASS IntQueue
    SET array to new integer array of size 1000
    SET front to 0
    SET rear to -1
    SET size to 0
    
    FUNCTION enqueue(number)
        IF size equals array length THEN
            RETURN false
        ENDIF
        INCREMENT rear
        SET array[rear] to number
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null
        ENDIF
        SET number to array[front]
        INCREMENT front
        DECREMENT size
        RETURN number
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN size equals 0
    ENDFUNCTION
ENDCLASS

FUNCTION bfs(n, edges, start)
    IF n equals 0 THEN
        RETURN empty string
    ENDIF
    CREATE adjList as new list of lists for n nodes
    FOR each edge in edges
        ADD edge[1] to adjList[edge[0]]
        ADD edge[0] to adjList[edge[1]] (undirected)
    ENDFOR
    CREATE queue as new IntQueue
    CREATE visited as boolean array of size n, initialized to false
    CREATE result as new StringBuilder
    ENQUEUE start to queue
    SET visited[start] to true
    APPEND start to result
    WHILE queue is not empty
        SET node to queue.dequeue()
        FOR each neighbor in adjList[node]
            IF not visited[neighbor] THEN
                ENQUEUE neighbor to queue
                SET visited[neighbor] to true
                APPEND " " and neighbor to result
            ENDIF
        ENDFOR
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of graphs (n, edges, start)
    FOR each testCase in testCases
        PRINT test case details
        CALL bfs(testCase.n, testCase.edges, testCase.start)
        PRINT BFS order
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define an IntQueue class with: a. An array to store integers, with front, rear, and size. b. Methods: enqueue (add to rear), dequeue (remove from front), isEmpty.
  2. In the bfs method: a. If n=0, return empty string. b. Build an adjacency list from edges (add u-v and v-u for undirected). c. Create a queue, a boolean array for visited nodes, and a StringBuilder for output. d. Enqueue the start node, mark it visited, append to result. e. While queue is not empty:
    • Dequeue a node.
    • For each unvisited neighbor, enqueue it, mark it visited, append to result. f. Return the result string.
  3. In the main method, test with graphs of varying structures (connected, disconnected, cyclic, empty).

Java Implementation

import java.util.*;

public class QueueBasedBFS {
    // Custom queue implementation for integers
    static class IntQueue {
        private int[] array;
        private int front;
        private int rear;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public IntQueue() {
            array = new int[DEFAULT_SIZE];
            front = 0;
            rear = -1;
            size = 0;
        }

        public boolean enqueue(int number) {
            if (size == array.length) {
                return false; // Queue full
            }
            array[++rear] = number;
            size++;
            return true;
        }

        public Integer dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            int number = array[front++];
            size--;
            return number;
        }

        public boolean isEmpty() {
            return size == 0;
        }
    }

    // Performs BFS and returns nodes in visit order
    public String bfs(int n, int[][] edges, int start) {
        if (n == 0) {
            return "";
        }

        // Build adjacency list
        List<List<Integer>> adjList = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            adjList.add(new ArrayList<>());
        }
        for (int[] edge : edges) {
            adjList.get(edge[0]).add(edge[1]);
            adjList.get(edge[1]).add(edge[0]); // Undirected
        }

        // BFS
        IntQueue queue = new IntQueue();
        boolean[] visited = new boolean[n];
        StringBuilder result = new StringBuilder();

        queue.enqueue(start);
        visited[start] = true;
        result.append(start);

        while (!queue.isEmpty()) {
            Integer node = queue.dequeue();
            if (node == null) break;
            for (int neighbor : adjList.get(node)) {
                if (!visited[neighbor]) {
                    queue.enqueue(neighbor);
                    visited[neighbor] = true;
                    result.append(" ").append(neighbor);
                }
            }
        }

        return result.toString();
    }

    // Helper class for test cases
    static class GraphTestCase {
        int n;
        int[][] edges;
        int start;

        GraphTestCase(int n, int[][] edges, int start) {
            this.n = n;
            this.edges = edges;
            this.start = start;
        }
    }

    // Main method to test BFS
    public static void main(String[] args) {
        QueueBasedBFS bfs = new QueueBasedBFS();

        // Test cases
        List<GraphTestCase> testCases = new ArrayList<>();
        
        // Test case 1: Connected graph
        testCases.add(new GraphTestCase(
            4,
            new int[][]{{0,1}, {0,2}, {1,3}},
            0
        ));
        
        // Test case 2: Cyclic graph
        testCases.add(new GraphTestCase(
            4,
            new int[][]{{0,1}, {1,2}, {2,3}, {3,0}},
            1
        ));
        
        // Test case 3: Disconnected graph
        testCases.add(new GraphTestCase(
            5,
            new int[][]{{0,1}, {2,3}},
            0
        ));
        
        // Test case 4: Empty graph
        testCases.add(new GraphTestCase(
            0,
            new int[][]{},
            0
        ));
        
        // Test case 5: Single node
        testCases.add(new GraphTestCase(
            1,
            new int[][]{},
            0
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            GraphTestCase test = testCases.get(i);
            System.out.println("Test case " + (i + 1) + ": n=" + test.n + ", start=" + test.start);
            System.out.println("Edges: " + Arrays.deepToString(test.edges));
            String result = bfs.bfs(test.n, test.edges, test.start);
            System.out.println("BFS order: " + (result.isEmpty() ? "[]" : result) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: n=4, start=0
Edges: [[0, 1], [0, 2], [1, 3]]
BFS order: 0 1 2 3

Test case 2: n=4, start=1
Edges: [[0, 1], [1, 2], [2, 3], [3, 0]]
BFS order: 1 0 2 3

Test case 3: n=5, start=0
Edges: [[0, 1], [2, 3]]
BFS order: 0 1

Test case 4: n=0, start=0
Edges: []
BFS order: []

Test case 5: n=1, start=0
Edges: []
BFS order: 0

Explanation:

  • Test case 1: From node 0, visits 0, then 1 and 2, then 3 (via 1).
  • Test case 2: From node 1 in a cycle (0-1-2-3-0), visits 1, then 0 and 2, then 3.
  • Test case 3: From node 0, visits 0 and 1; nodes 2, 3, 4 are disconnected.
  • Test case 4: Empty graph returns empty string.
  • Test case 5: Single node 0, no edges, visits only 0.

How It Works

  • IntQueue:
    • Uses an array with front, rear, and size for FIFO operations.
    • enqueue: Adds to rear if not full.
    • dequeue: Removes from front if not empty.
  • bfs:
    • Builds an adjacency list from edges (undirected: add u-v and v-u).
    • Uses a queue to explore nodes level by level, a boolean array to track visited nodes, and a StringBuilder for output.
    • Enqueues start node, marks visited, appends to result.
    • Dequeues nodes, enqueues unvisited neighbors, appends them to result.
  • Example Trace (Test case 1):
    • n=4, edges=[[0,1], [0,2], [1,3]], start=0.
    • Adjacency list: [0: [1,2], 1: [0,3], 2: [0], 3: [1]].
    • Enqueue 0: queue=[0], visited=[T,F,F,F], result="0".
    • Dequeue 0, enqueue 1,2: queue=[1,2], visited=[T,T,T,F], result="0 1 2".
    • Dequeue 1, enqueue 3: queue=[2,3], visited=[T,T,T,T], result="0 1 2 3".
    • Dequeue 2, 3: queue=[], result="0 1 2 3".
  • Main Method: Tests connected, cyclic, disconnected, empty, and single-node graphs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Enqueue/DequeueO(1)O(1)
BFS AlgorithmO(V + E)O(V + E)

Note:

  • V is the number of vertices, E is the number of edges.
  • Time complexity: O(V + E) for visiting all nodes and edges via adjacency list.
  • Space complexity: O(V) for queue and visited array, O(E) for adjacency list.
  • Worst case: O(V + E) time and space for dense graphs.

✅ Tip: Use a queue for BFS to ensure level-by-level traversal, and an adjacency list for efficient neighbor access. Test with cyclic and disconnected graphs to verify correct handling of visited nodes.

⚠ Warning: Always mark nodes as visited before enqueuing to avoid infinite loops in cyclic graphs. Ensure the queue size is sufficient for large graphs.

Queue Reversal

Problem Statement

Write a Java program that reverses the elements of a queue using a stack. The program should enqueue a set of numbers into a queue, reverse the order of the elements using a stack, and print the reversed queue. The reversal should maintain the queue’s FIFO (first-in, first-out) property in the final output. Test the implementation with various sets of numbers, including empty queues and single-element queues. You can visualize this as unloading a queue of items onto a stack, which flips their order, and then reloading them into a new queue to get the reversed sequence.

Input: A set of integers to enqueue (e.g., [1, 2, 3]). Output: The reversed queue as a string (e.g., "[3, 2, 1]"). Constraints:

  • Queue size is between 0 and 10^5.
  • Elements are integers in the range [-10^9, 10^9].
  • The queue may be empty. Example:
  • Input: Enqueue [1, 2, 3]
  • Output: [3, 2, 1]
  • Explanation: Enqueue 1, 2, 3; dequeue to stack (3, 2, 1 top); pop to new queue → [3, 2, 1].
  • Input: Enqueue []
  • Output: []
  • Explanation: Empty queue remains empty after reversal.

Pseudocode

CLASS IntQueue
    SET array to new integer array of size 1000
    SET front to 0
    SET rear to -1
    SET size to 0
    
    FUNCTION enqueue(number)
        IF size equals array length THEN
            RETURN false (queue full)
        ENDIF
        INCREMENT rear
        SET array[rear] to number
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null (queue empty)
        ENDIF
        SET number to array[front]
        INCREMENT front
        DECREMENT size
        RETURN number
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN size equals 0
    ENDFUNCTION
    
    FUNCTION toString()
        CREATE result as empty string
        APPEND "["
        FOR i from front to rear
            APPEND array[i] and ", " to result
        ENDFOR
        IF result ends with ", " THEN
            REMOVE last ", "
        ENDIF
        APPEND "]" to result
        RETURN result
    ENDFUNCTION
ENDCLASS

CLASS IntStack
    SET array to new integer array of size 1000
    SET top to -1
    
    FUNCTION push(number)
        IF top equals array length - 1 THEN
            RETURN false (stack full)
        ENDIF
        INCREMENT top
        SET array[top] to number
        RETURN true
    ENDFUNCTION
    
    FUNCTION pop()
        IF top equals -1 THEN
            RETURN null (stack empty)
        ENDIF
        SET number to array[top]
        DECREMENT top
        RETURN number
    ENDFUNCTION
ENDCLASS

FUNCTION reverseQueue(queue)
    CREATE stack as new IntStack
    CREATE newQueue as new IntQueue
    WHILE queue is not empty
        SET number to queue.dequeue()
        PUSH number to stack
    ENDWHILE
    WHILE stack is not empty
        SET number to stack.pop()
        ENQUEUE number to newQueue
    ENDWHILE
    RETURN newQueue
ENDFUNCTION

FUNCTION main()
    SET testCases to array of integer arrays
    FOR each numbers in testCases
        CREATE queue as new IntQueue
        FOR each number in numbers
            ENQUEUE number to queue
        ENDFOR
        PRINT original queue
        SET reversed to reverseQueue(queue)
        PRINT reversed queue
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define an IntQueue class with: a. An array to store integers, with front, rear, and size to track queue state. b. Methods: enqueue (add to rear), dequeue (remove from front), isEmpty, toString.
  2. Define an IntStack class with: a. An array to store integers, with top index. b. Methods: push (add to top), pop (remove from top).
  3. In the reverseQueue method: a. Create a stack and a new queue. b. Dequeue all elements from the input queue and push them onto the stack. c. Pop all elements from the stack and enqueue them into the new queue. d. Return the new queue.
  4. In the main method, test with different sets of numbers, including empty and single-element queues, printing the original and reversed queues.

Java Implementation

import java.util.*;

public class QueueReversal {
    // Custom queue implementation for integers
    static class IntQueue {
        private int[] array;
        private int front;
        private int rear;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public IntQueue() {
            array = new int[DEFAULT_SIZE];
            front = 0;
            rear = -1;
            size = 0;
        }

        public boolean enqueue(int number) {
            if (size == array.length) {
                return false; // Queue full
            }
            array[++rear] = number;
            size++;
            return true;
        }

        public Integer dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            int number = array[front++];
            size--;
            return number;
        }

        public boolean isEmpty() {
            return size == 0;
        }

        public String toString() {
            StringBuilder result = new StringBuilder("[");
            for (int i = front; i <= rear; i++) {
                result.append(array[i]);
                if (i < rear) {
                    result.append(", ");
                }
            }
            result.append("]");
            return result.toString();
        }
    }

    // Custom stack implementation for integers
    static class IntStack {
        private int[] array;
        private int top;
        private static final int DEFAULT_SIZE = 1000;

        public IntStack() {
            array = new int[DEFAULT_SIZE];
            top = -1;
        }

        public boolean push(int number) {
            if (top == array.length - 1) {
                return false; // Stack full
            }
            array[++top] = number;
            return true;
        }

        public Integer pop() {
            if (top == -1) {
                return null; // Stack empty
            }
            return array[top--];
        }
    }

    // Reverses a queue using a stack
    public IntQueue reverseQueue(IntQueue queue) {
        IntStack stack = new IntStack();
        IntQueue newQueue = new IntQueue();

        // Dequeue all elements and push to stack
        while (!queue.isEmpty()) {
            Integer number = queue.dequeue();
            if (number != null) {
                stack.push(number);
            }
        }

        // Pop from stack and enqueue to new queue
        while (true) {
            Integer number = stack.pop();
            if (number == null) {
                break;
            }
            newQueue.enqueue(number);
        }

        return newQueue;
    }

    // Main method to test queue reversal
    public static void main(String[] args) {
        QueueReversal reverser = new QueueReversal();

        // Test cases
        int[][] testCases = {
            {1, 2, 3},           // Normal queue
            {},                   // Empty queue
            {42},                // Single element
            {10, 20, 30, 40, 50} // Larger queue
        };

        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            IntQueue queue = new IntQueue();
            for (int num : testCases[i]) {
                queue.enqueue(num);
            }
            System.out.println("Original queue: " + queue);
            IntQueue reversed = reverser.reverseQueue(queue);
            System.out.println("Reversed queue: " + reversed + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Original queue: [1, 2, 3]
Reversed queue: [3, 2, 1]

Test case 2:
Original queue: []
Reversed queue: []

Test case 3:
Original queue: [42]
Reversed queue: [42]

Test case 4:
Original queue: [10, 20, 30, 40, 50]
Reversed queue: [50, 40, 30, 20, 10]

Explanation:

  • Test case 1: Enqueue [1, 2, 3] → stack [3, 2, 1 (top)] → new queue [3, 2, 1].
  • Test case 2: Empty queue → empty stack → empty queue.
  • Test case 3: Enqueue [42] → stack [42] → new queue [42].
  • Test case 4: Enqueue [10, 20, 30, 40, 50] → stack [50, 40, 30, 20, 10 (top)] → new queue [50, 40, 30, 20, 10].

How It Works

  • IntQueue:
    • Uses an array with front, rear, and size to manage integers in FIFO order.
    • enqueue: Adds to rear if not full.
    • dequeue: Removes from front if not empty.
    • toString: Formats queue as a string for output.
  • IntStack:
    • Uses an array with top to manage integers in LIFO order.
    • push: Adds to top if not full.
    • pop: Removes from top if not empty.
  • reverseQueue:
    • Dequeues all elements from the input queue to a stack (reversing order due to LIFO).
    • Pops all elements from the stack to a new queue (restoring FIFO order, now reversed).
  • Example Trace (Test case 1):
    • Enqueue 1, 2, 3: queue = [1, 2, 3], front=0, rear=2.
    • Dequeue to stack: stack = [1, 2, 3 (top)].
    • Pop to new queue: newQueue = [3, 2, 1].
  • Main Method: Tests normal, empty, single-element, and larger queues.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Enqueue/DequeueO(1)O(1)
Push/PopO(1)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the number of elements in the queue.
  • Time complexity: O(n) for n dequeues to stack and n pops to new queue.
  • Space complexity: O(n) for the stack and new queue.
  • Worst case: O(n) time and O(n) space for large queues.

✅ Tip: Use a stack to reverse a queue, as its LIFO nature naturally flips the order of elements. Test with various queue sizes to ensure the reversal works correctly.

⚠ Warning: Ensure the stack and queue sizes are sufficient to handle the input. Handle empty queues to avoid null pointer issues during reversal.

Ticket Counter Simulation

Problem Statement

Write a Java program that simulates a ticket counter system where customers, represented by names (strings), join a queue and are served in first-in, first-out (FIFO) order. The program should allow users to enqueue customers, dequeue them to simulate serving, and display the current queue size after each operation. Test the implementation with various sequences of enqueue and dequeue operations, including edge cases like empty queues and null customer names. You can visualize this as a ticket counter at a movie theater, where customers line up, are served one by one, and the staff tracks how many people are still waiting.

Input: A sequence of operations, where each operation is either:

  • Enqueue: Add a customer name (string) to the queue (e.g., "Alice").
  • Dequeue: Remove and serve the next customer from the queue.
  • Size: Display the current number of customers in the queue. Output: For each operation, print the action performed (enqueue, dequeue, or size) and the result, such as the served customer, queue size, or error messages for empty queues. Constraints:
  • Queue size is between 0 and 10^5.
  • Customer names are non-empty strings containing printable ASCII characters, or null (handled gracefully).
  • The queue may be empty when dequeue or size is called. Example:
  • Input: Operations = [enqueue("Alice"), enqueue("Bob"), size, dequeue, size, enqueue("Charlie"), dequeue]
  • Output:
    Enqueued Alice
    Enqueued Bob
    Queue size: 2
    Served customer: Alice
    Queue size: 1
    Enqueued Charlie
    Served customer: Bob
    
  • Explanation: Customers join and are served in order, with queue size reported as requested.
  • Input: Operations = [dequeue on empty queue]
  • Output: Queue empty, cannot dequeue

Pseudocode

CLASS StringQueue
    SET array to new string array of size 1000
    SET front to 0
    SET rear to -1
    SET size to 0
    
    FUNCTION enqueue(name)
        IF size equals array length THEN
            RETURN false (queue full)
        ENDIF
        INCREMENT rear
        SET array[rear] to name
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null (queue empty)
        ENDIF
        SET name to array[front]
        INCREMENT front
        DECREMENT size
        RETURN name
    ENDFUNCTION
    
    FUNCTION size()
        RETURN size
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN size equals 0
    ENDFUNCTION
ENDCLASS

FUNCTION simulateTicketCounter(operations)
    CREATE queue as new StringQueue
    FOR each operation in operations
        IF operation.type equals "enqueue" THEN
            CALL queue.enqueue(operation.name)
            PRINT enqueued name
        ELSE IF operation.type equals "dequeue" THEN
            SET name to queue.dequeue()
            IF name is null THEN
                PRINT queue empty message
            ELSE
                PRINT served customer name
            ENDIF
        ELSE IF operation.type equals "size" THEN
            PRINT queue size
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL simulateTicketCounter(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a StringQueue class with: a. An array to store customer names, with front, rear, and size to track queue state. b. Methods: enqueue (add to rear), dequeue (remove from front), size (return current size), isEmpty (check if empty).
  2. In the simulateTicketCounter method: a. Create a new StringQueue. b. For each operation:
    • If "enqueue", add the customer name and print the action.
    • If "dequeue", remove the next customer, print the served customer or "Queue empty".
    • If "size", print the current queue size.
  3. In the main method, test with sequences of enqueue, dequeue, and size operations, including empty queues, single customers, and null names.

Java Implementation

import java.util.*;

public class TicketCounterSimulation {
    // Custom queue implementation for strings
    static class StringQueue {
        private String[] array;
        private int front;
        private int rear;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public StringQueue() {
            array = new String[DEFAULT_SIZE];
            front = 0;
            rear = -1;
            size = 0;
        }

        public boolean enqueue(String name) {
            if (size == array.length) {
                return false; // Queue full
            }
            array[++rear] = name;
            size++;
            return true;
        }

        public String dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            String name = array[front++];
            size--;
            return name;
        }

        public int size() {
            return size;
        }

        public boolean isEmpty() {
            return size == 0;
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String name; // For enqueue operations

        Operation(String type, String name) {
            this.type = type;
            this.name = name;
        }
    }

    // Simulates ticket counter system
    public void simulateTicketCounter(List<Operation> operations) {
        StringQueue queue = new StringQueue();
        for (Operation op : operations) {
            if (op.type.equals("enqueue")) {
                boolean success = queue.enqueue(op.name);
                if (success) {
                    System.out.println("Enqueued " + (op.name != null ? op.name : "null"));
                } else {
                    System.out.println("Enqueue " + (op.name != null ? op.name : "null") + " failed: Queue full");
                }
            } else if (op.type.equals("dequeue")) {
                String name = queue.dequeue();
                if (name == null) {
                    System.out.println("Queue empty, cannot dequeue");
                } else {
                    System.out.println("Served customer: " + name);
                }
            } else if (op.type.equals("size")) {
                System.out.println("Queue size: " + queue.size());
            }
        }
    }

    // Main method to test ticket counter simulation
    public static void main(String[] args) {
        TicketCounterSimulation simulator = new TicketCounterSimulation();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal sequence
        List<Operation> case1 = Arrays.asList(
            new Operation("enqueue", "Alice"),
            new Operation("enqueue", "Bob"),
            new Operation("size", null),
            new Operation("dequeue", null),
            new Operation("size", null),
            new Operation("enqueue", "Charlie"),
            new Operation("dequeue", null)
        );
        testCases.add(case1);
        
        // Test case 2: Empty queue
        List<Operation> case2 = Arrays.asList(
            new Operation("dequeue", null),
            new Operation("size", null)
        );
        testCases.add(case2);
        
        // Test case 3: Single customer
        List<Operation> case3 = Arrays.asList(
            new Operation("enqueue", "David"),
            new Operation("size", null),
            new Operation("dequeue", null)
        );
        testCases.add(case3);
        
        // Test case 4: Multiple enqueues and dequeues
        List<Operation> case4 = Arrays.asList(
            new Operation("enqueue", "Eve"),
            new Operation("enqueue", "Frank"),
            new Operation("enqueue", "Grace"),
            new Operation("size", null),
            new Operation("dequeue", null),
            new Operation("dequeue", null),
            new Operation("size", null)
        );
        testCases.add(case4);
        
        // Test case 5: Null customer name
        List<Operation> case5 = Arrays.asList(
            new Operation("enqueue", null),
            new Operation("size", null),
            new Operation("dequeue", null)
        );
        testCases.add(case5);

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            simulator.simulateTicketCounter(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Enqueued Alice
Enqueued Bob
Queue size: 2
Served customer: Alice
Queue size: 1
Enqueued Charlie
Served customer: Bob

Test case 2:
Queue empty, cannot dequeue
Queue size: 0

Test case 3:
Enqueued David
Queue size: 1
Served customer: David

Test case 4:
Enqueued Eve
Enqueued Frank
Enqueued Grace
Queue size: 3
Served customer: Eve
Served customer: Frank
Queue size: 1

Test case 5:
Enqueued null
Queue size: 1
Served customer: null

Explanation:

  • Test case 1: Enqueues Alice, Bob; reports size 2; dequeues Alice; reports size 1; enqueues Charlie; dequeues Bob.
  • Test case 2: Dequeue and size on empty queue return error and 0.
  • Test case 3: Enqueues David, reports size 1, dequeues David.
  • Test case 4: Enqueues three customers, reports size 3, dequeues two, reports size 1.
  • Test case 5: Enqueues null name, reports size 1, dequeues null.

How It Works

  • StringQueue:
    • Uses an array with front, rear, and size to manage customer names in FIFO order.
    • enqueue: Adds to rear if not full.
    • dequeue: Removes from front if not empty.
    • size: Returns current number of customers.
  • simulateTicketCounter:
    • Enqueues customers, printing the action.
    • Dequeues customers, printing the served customer or "Queue empty".
    • Reports queue size when requested.
  • Example Trace (Test case 1):
    • Enqueue "Alice": array=["Alice"], front=0, rear=0, size=1.
    • Enqueue "Bob": array=["Alice","Bob"], front=0, rear=1, size=2.
    • Size: Returns 2.
    • Dequeue: Returns "Alice", array=["-","Bob"], front=1, rear=1, size=1.
    • Size: Returns 1.
    • Enqueue "Charlie": array=["-","Bob","Charlie"], front=1, rear=2, size=2.
    • Dequeue: Returns "Bob", array=["-","-","Charlie"], front=2, rear=2, size=1.
  • Main Method: Tests normal sequences, empty queue, single customer, multiple operations, and null name.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Enqueue/DequeueO(1)O(1)
SizeO(1)O(1)
Full AlgorithmO(n)O(n)

Note:

  • n is the number of operations.
  • Time complexity: O(1) for each enqueue, dequeue, or size; O(n) for n operations.
  • Space complexity: O(n) for the queue storing up to n customers.
  • Worst case: O(n) time and O(n) space for many enqueue operations.

✅ Tip: Use a queue for a ticket counter simulation, as its FIFO nature ensures customers are served in arrival order. Test with size queries to verify queue state after operations.

⚠ Warning: Handle null names and empty queue cases to avoid unexpected behavior. Ensure the queue size is sufficient to accommodate the input sequence.

Dijkstra’s Algorithm

Problem Statement

Write a Java program that implements Dijkstra’s algorithm for a weighted graph using a priority queue. Represent the graph as an adjacency list, where each edge has a non-negative weight, and compute the shortest path distances from a source node to all other nodes. The program should handle directed graphs and print the shortest distances for each reachable node. Test the implementation with various graph structures, including connected, sparse, and dense graphs, as well as edge cases like single nodes or unreachable nodes. You can visualize this as finding the shortest driving routes from a starting city to all other cities, using a priority queue to always explore the closest unvisited city next.

Input:

  • Number of nodes n (1 ≤ n ≤ 10^5).
  • List of edges as triples [u, v, w] (node u to node v with weight w, w ≥ 0).
  • Source node for shortest paths (0 ≤ source < n). Output: A string of node indices and their shortest distances from the source (e.g., "0:0 1:3 2:5"), or "INF" for unreachable nodes. Constraints:
  • Nodes are integers from 0 to n-1.
  • Edge weights are non-negative integers (0 ≤ w ≤ 10^9).
  • The graph may be disconnected; unreachable nodes have distance "INF".
  • Assume the input graph is valid (no negative weights). Example:
  • Input: n=4, edges=[[0,1,1], [0,2,4], [1,2,2], [1,3,6]], source=0
  • Output: "0:0 1:1 2:3 3:7"
  • Explanation: Shortest paths from node 0: 0→1 (1), 0→1→2 (1+2=3), 0→1→3 (1+6=7).
  • Input: n=3, edges=[[0,1,2]], source=2
  • Output: "0:INF 1:INF 2:0"
  • Explanation: Node 2 cannot reach 0 or 1; only 2 is reachable (distance 0).

Pseudocode

CLASS NodeDistance
    SET node to integer
    SET distance to long
ENDCLASS

CLASS MinPriorityQueue
    SET array to new NodeDistance array of size 1000
    SET size to 0
    
    FUNCTION enqueue(node, distance)
        IF size equals array length THEN
            RETURN false
        ENDIF
        SET array[size] to new NodeDistance(node, distance)
        CALL siftUp(size)
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION siftUp(index)
        WHILE index > 0
            SET parent to (index - 1) / 2
            IF array[index].distance >= array[parent].distance THEN
                BREAK
            ENDIF
            SWAP array[index] and array[parent]
            SET index to parent
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null
        ENDIF
        SET result to array[0]
        SET array[0] to array[size - 1]
        DECREMENT size
        IF size > 0 THEN
            CALL siftDown(0)
        ENDIF
        RETURN result
    ENDFUNCTION
    
    FUNCTION siftDown(index)
        SET minIndex to index
        WHILE true
            SET left to 2 * index + 1
            SET right to 2 * index + 2
            IF left < size AND array[left].distance < array[minIndex].distance THEN
                SET minIndex to left
            ENDIF
            IF right < size AND array[right].distance < array[minIndex].distance THEN
                SET minIndex to right
            ENDIF
            IF minIndex equals index THEN
                BREAK
            ENDIF
            SWAP array[index] and array[minIndex]
            SET index to minIndex
        ENDWHILE
    ENDFUNCTION
ENDCLASS

FUNCTION dijkstra(n, edges, source)
    CREATE adjList as new list of lists of (node, weight) pairs for n nodes
    FOR each edge [u, v, w] in edges
        ADD (v, w) to adjList[u]
    ENDFOR
    CREATE distances as array of size n, initialized to INF
    SET distances[source] to 0
    CREATE queue as new MinPriorityQueue
    ENQUEUE (source, 0) to queue
    CREATE visited as boolean array of size n, initialized to false
    WHILE queue is not empty
        SET nodeDist to queue.dequeue()
        SET node to nodeDist.node
        SET dist to nodeDist.distance
        IF visited[node] THEN
            CONTINUE
        ENDIF
        SET visited[node] to true
        FOR each (neighbor, weight) in adjList[node]
            IF dist + weight < distances[neighbor] THEN
                SET distances[neighbor] to dist + weight
                ENQUEUE (neighbor, dist + weight) to queue
            ENDIF
        ENDFOR
    ENDWHILE
    CREATE result as new StringBuilder
    FOR i from 0 to n-1
        IF distances[i] equals INF THEN
            APPEND i + ":INF" to result
        ELSE
            APPEND i + ":" + distances[i] to result
        ENDIF
        IF i < n-1 THEN
            APPEND " " to result
        ENDIF
    ENDFOR
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of graphs (n, edges, source)
    FOR each testCase in testCases
        PRINT test case details
        CALL dijkstra(testCase.n, testCase.edges, testCase.source)
        PRINT shortest distances
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a NodeDistance class to store a node index and its tentative distance.
  2. Define a MinPriorityQueue class using a min-heap with: a. An array to store NodeDistance objects, prioritized by distance. b. enqueue: Add node-distance pair, sift up to maintain min-heap. c. siftUp: Move smaller distance up. d. dequeue: Remove root (smallest distance), sift down. e. siftDown: Move larger distance down.
  3. In dijkstra: a. Build adjacency list from edges (directed: add u→v,w). b. Initialize distances to INF, except source (0). c. Enqueue source with distance 0. d. While queue is not empty:
    • Dequeue node with smallest distance.
    • Skip if visited.
    • Mark node visited.
    • Update neighbors’ distances if shorter path found, enqueue them. e. Format distances as string (INF for unreachable).
  4. In main, test with different graph structures and edge cases.

Java Implementation

import java.util.*;

public class DijkstrasAlgorithm {
    // Class to store node and distance pair
    static class NodeDistance {
        int node;
        long distance;

        NodeDistance(int node, long distance) {
            this.node = node;
            this.distance = distance;
        }
    }

    // Custom min-heap priority queue for node-distance pairs
    static class MinPriorityQueue {
        private NodeDistance[] array;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public MinPriorityQueue() {
            array = new NodeDistance[DEFAULT_SIZE];
            size = 0;
        }

        public boolean enqueue(int node, long distance) {
            if (size == array.length) {
                return false; // Queue full
            }
            array[size] = new NodeDistance(node, distance);
            siftUp(size);
            size++;
            return true;
        }

        private void siftUp(int index) {
            while (index > 0) {
                int parent = (index - 1) / 2;
                if (array[index].distance >= array[parent].distance) {
                    break;
                }
                // Swap
                NodeDistance temp = array[index];
                array[index] = array[parent];
                array[parent] = temp;
                index = parent;
            }
        }

        public NodeDistance dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            NodeDistance result = array[0];
            array[0] = array[size - 1];
            size--;
            if (size > 0) {
                siftDown(0);
            }
            return result;
        }

        private void siftDown(int index) {
            int minIndex = index;
            while (true) {
                int left = 2 * index + 1;
                int right = 2 * index + 2;
                if (left < size && array[left].distance < array[minIndex].distance) {
                    minIndex = left;
                }
                if (right < size && array[right].distance < array[minIndex].distance) {
                    minIndex = right;
                }
                if (minIndex == index) {
                    break;
                }
                // Swap
                NodeDistance temp = array[index];
                array[index] = array[minIndex];
                array[minIndex] = temp;
                index = minIndex;
            }
        }
    }

    // Dijkstra’s algorithm to find shortest paths
    public String dijkstra(int n, int[][] edges, int source) {
        // Build adjacency list
        List<List<int[]>> adjList = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            adjList.add(new ArrayList<>());
        }
        for (int[] edge : edges) {
            adjList.get(edge[0]).add(new int[]{edge[1], edge[2]}); // Directed: u -> v, weight
        }

        // Initialize distances and priority queue
        long[] distances = new long[n];
        Arrays.fill(distances, Long.MAX_VALUE);
        distances[source] = 0;
        MinPriorityQueue queue = new MinPriorityQueue();
        queue.enqueue(source, 0);
        boolean[] visited = new boolean[n];

        // Process nodes
        while (true) {
            NodeDistance nodeDist = queue.dequeue();
            if (nodeDist == null) break;
            int node = nodeDist.node;
            long dist = nodeDist.distance;

            if (visited[node]) continue;
            visited[node] = true;

            for (int[] neighbor : adjList.get(node)) {
                int v = neighbor[0];
                long w = neighbor[1];
                if (dist + w < distances[v]) {
                    distances[v] = dist + w;
                    queue.enqueue(v, dist + w);
                }
            }
        }

        // Format output
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < n; i++) {
            if (distances[i] == Long.MAX_VALUE) {
                result.append(i).append(":INF");
            } else {
                result.append(i).append(":").append(distances[i]);
            }
            if (i < n - 1) result.append(" ");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class GraphTestCase {
        int n;
        int[][] edges;
        int source;

        GraphTestCase(int n, int[][] edges, int source) {
            this.n = n;
            this.edges = edges;
            this.source = source;
        }
    }

    // Main method to test Dijkstra’s algorithm
    public static void main(String[] args) {
        DijkstrasAlgorithm dijkstra = new DijkstrasAlgorithm();

        // Test cases
        List<GraphTestCase> testCases = new ArrayList<>();
        
        // Test case 1: Connected graph
        testCases.add(new GraphTestCase(
            4,
            new int[][]{{0,1,1}, {0,2,4}, {1,2,2}, {1,3,6}},
            0
        ));
        
        // Test case 2: Unreachable nodes
        testCases.add(new GraphTestCase(
            3,
            new int[][]{{0,1,2}},
            2
        ));
        
        // Test case 3: Single node
        testCases.add(new GraphTestCase(
            1,
            new int[][]{},
            0
        ));
        
        // Test case 4: Dense graph
        testCases.add(new GraphTestCase(
            4,
            new int[][]{{0,1,1}, {0,2,5}, {0,3,10}, {1,2,3}, {1,3,3}, {2,3,2}},
            0
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            GraphTestCase test = testCases.get(i);
            System.out.println("Test case " + (i + 1) + ": n=" + test.n + ", source=" + test.source);
            System.out.println("Edges: " + Arrays.deepToString(test.edges));
            String result = dijkstra.dijkstra(test.n, test.edges, test.source);
            System.out.println("Shortest distances: " + result + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: n=4, source=0
Edges: [[0, 1, 1], [0, 2, 4], [1, 2, 2], [1, 3, 6]]
Shortest distances: 0:0 1:1 2:3 3:7

Test case 2: n=3, source=2
Edges: [[0, 1, 2]]
Shortest distances: 0:INF 1:INF 2:0

Test case 3: n=1, source=0
Edges: []
Shortest distances: 0:0

Test case 4: n=4, source=0
Edges: [[0, 1, 1], [0, 2, 5], [0, 3, 10], [1, 2, 3], [1, 3, 3], [2, 3, 2]]
Shortest distances: 0:0 1:1 2:4 3:4

Explanation:

  • Test case 1: From source 0, shortest paths: 0→1 (1), 0→1→2 (1+2=3), 0→1→3 (1+6=7).
  • Test case 2: From source 2, no outgoing edges, so only 2 has distance 0; others are INF.
  • Test case 3: Single node 0 has distance 0.
  • Test case 4: From source 0, shortest paths: 0→1 (1), 0→1→2 (1+3=4), 0→1→3 (1+3=4).

How It Works

  • NodeDistance: Stores a node and its tentative distance.
  • MinPriorityQueue:
    • Uses a min-heap to prioritize nodes with smaller distances.
    • enqueue: Adds node-distance pair, sifts up.
    • dequeue: Removes smallest-distance node, sifts down.
  • dijkstra:
    • Builds adjacency list from edges (directed).
    • Initializes distances to INF, source to 0.
    • Enqueues source with distance 0.
    • Processes nodes in order of increasing distance, updating neighbors’ distances if shorter.
    • Uses visited array to avoid reprocessing nodes.
    • Formats distances (INF for unreachable).
  • Example Trace (Test case 1):
    • n=4, edges=[[0,1,1], [0,2,4], [1,2,2], [1,3,6]], source=0.
    • Adjacency list: [0: [(1,1),(2,4)], 1: [(2,2),(3,6)], 2: [], 3: []].
    • Initialize: distances=[0,INF,INF,INF], enqueue (0,0).
    • Dequeue (0,0): Update 1:1, 2:4, enqueue (1,1),(2,4).
    • Dequeue (1,1): Update 2:3 (1+2), 3:7 (1+6), enqueue (2,3),(3,7).
    • Dequeue (2,3): No updates.
    • Dequeue (3,7): No updates.
    • Result: "0:0 1:1 2:3 3:7".
  • Main Method: Tests connected graph, unreachable nodes, single node, and dense graph.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Enqueue/DequeueO(log V)O(1)
DijkstraO((V + E) log V)O(V + E)

Note:

  • V is the number of vertices, E is the number of edges.
  • Time complexity: O(log V) for enqueue/dequeue; O((V + E) log V) for processing each node and edge with heap operations.
  • Space complexity: O(V) for queue, distances, and visited array; O(E) for adjacency list.
  • Worst case: O((V + E) log V) time, O(V + E) space for dense graphs.

✅ Tip: Use a min-heap priority queue in Dijkstra’s algorithm to efficiently select the node with the smallest tentative distance. Test with sparse and dense graphs to verify correctness.

⚠ Warning: Ensure edge weights are non-negative, as Dijkstra’s algorithm does not handle negative weights. Use a visited array to avoid reprocessing nodes in cyclic graphs.

Kth Largest Element

Problem Statement

Write a Java program that uses a priority queue to find the kth largest element in an array of integers. The program should enqueue elements into a min-heap of size k, maintaining the k largest elements, and return the root (smallest of the k largest) as the kth largest element. Test the implementation with at least three different arrays and k values, including edge cases like k=1, k equal to the array length, and arrays with duplicate elements. You can visualize this as sorting through a list of exam scores to find the kth highest score, keeping only the top k scores in a priority queue to efficiently track the kth largest.

Input:

  • An array of integers (e.g., [3, 1, 4, 1, 5]).
  • An integer k (1 ≤ k ≤ array length). Output: The kth largest element in the array (e.g., for k=2 in [3, 1, 4, 1, 5], output 4). Constraints:
  • Array length is between 1 and 10^5.
  • Elements are integers in the range [-10^9, 10^9].
  • 1 ≤ k ≤ array length.
  • Assume k is valid for the input array. Example:
  • Input: arr=[3, 1, 4, 1, 5], k=2
  • Output: 4
  • Explanation: The 2nd largest element is 4 (largest is 5, 2nd is 4).
  • Input: arr=[1], k=1
  • Output: 1
  • Explanation: Only one element, so 1st largest is 1.
  • Input: arr=[4, 4, 4], k=2
  • Output: 4
  • Explanation: All elements are 4, so 2nd largest is 4.

Pseudocode

CLASS MinPriorityQueue
    SET array to new integer array of size capacity
    SET size to 0
    SET capacity to input capacity
    
    FUNCTION enqueue(number)
        IF size equals capacity THEN
            IF number <= array[0] THEN
                RETURN false
            ENDIF
            SET array[0] to number
            CALL siftDown(0)
            RETURN true
        ENDIF
        SET array[size] to number
        CALL siftUp(size)
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION siftUp(index)
        WHILE index > 0
            SET parent to (index - 1) / 2
            IF array[index] >= array[parent] THEN
                BREAK
            ENDIF
            SWAP array[index] and array[parent]
            SET index to parent
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION siftDown(index)
        SET minIndex to index
        WHILE true
            SET left to 2 * index + 1
            SET right to 2 * index + 2
            IF left < size AND array[left] < array[minIndex] THEN
                SET minIndex to left
            ENDIF
            IF right < size AND array[right] < array[minIndex] THEN
                SET minIndex to right
            ENDIF
            IF minIndex equals index THEN
                BREAK
            ENDIF
            SWAP array[index] and array[minIndex]
            SET index to minIndex
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION peek()
        IF size equals 0 THEN
            RETURN null
        ENDIF
        RETURN array[0]
    ENDFUNCTION
ENDCLASS

FUNCTION findKthLargest(arr, k)
    CREATE queue as new MinPriorityQueue with capacity k
    FOR each number in arr
        CALL queue.enqueue(number)
    ENDFOR
    RETURN queue.peek()
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (array, k) pairs
    FOR each testCase in testCases
        PRINT test case details
        CALL findKthLargest(testCase.arr, testCase.k)
        PRINT kth largest element
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a MinPriorityQueue class using a min-heap with: a. An array of fixed capacity k, with size to track elements. b. enqueue: If size < k, add to end and sift up; if size = k and number > root, replace root and sift down. c. siftUp: Move smaller number up to maintain min-heap property. d. siftDown: Move larger number down to maintain min-heap property. e. peek: Return root (smallest of k largest elements).
  2. In findKthLargest: a. Create a min-heap with capacity k. b. For each number in the array:
    • Enqueue to heap, maintaining k largest elements. c. Return the root (kth largest).
  3. In main, test with at least three arrays and k values, including edge cases like k=1, k=length, and duplicates.

Java Implementation

import java.util.*;

public class KthLargestElement {
    // Custom min-heap priority queue implementation
    static class MinPriorityQueue {
        private int[] array;
        private int size;
        private int capacity;

        public MinPriorityQueue(int capacity) {
            this.array = new int[capacity];
            this.size = 0;
            this.capacity = capacity;
        }

        public boolean enqueue(int number) {
            if (size == capacity) {
                if (number <= array[0]) {
                    return false; // Smaller than kth largest
                }
                array[0] = number; // Replace smallest
                siftDown(0);
                return true;
            }
            array[size] = number;
            siftUp(size);
            size++;
            return true;
        }

        private void siftUp(int index) {
            while (index > 0) {
                int parent = (index - 1) / 2;
                if (array[index] >= array[parent]) {
                    break;
                }
                // Swap
                int temp = array[index];
                array[index] = array[parent];
                array[parent] = temp;
                index = parent;
            }
        }

        private void siftDown(int index) {
            int minIndex = index;
            while (true) {
                int left = 2 * index + 1;
                int right = 2 * index + 2;
                if (left < size && array[left] < array[minIndex]) {
                    minIndex = left;
                }
                if (right < size && array[right] < array[minIndex]) {
                    minIndex = right;
                }
                if (minIndex == index) {
                    break;
                }
                // Swap
                int temp = array[index];
                array[index] = array[minIndex];
                array[minIndex] = temp;
                index = minIndex;
            }
        }

        public Integer peek() {
            if (size == 0) {
                return null;
            }
            return array[0];
        }
    }

    // Finds the kth largest element in the array
    public int findKthLargest(int[] arr, int k) {
        MinPriorityQueue queue = new MinPriorityQueue(k);
        for (int num : arr) {
            queue.enqueue(num);
        }
        return queue.peek();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        int k;

        TestCase(int[] arr, int k) {
            this.arr = arr;
            this.k = k;
        }
    }

    // Main method to test kth largest element
    public static void main(String[] args) {
        KthLargestElement finder = new KthLargestElement();

        // Test cases
        List<TestCase> testCases = new ArrayList<>();
        
        // Test case 1: Normal array
        testCases.add(new TestCase(new int[]{3, 1, 4, 1, 5}, 2));
        
        // Test case 2: Single element
        testCases.add(new TestCase(new int[]{1}, 1));
        
        // Test case 3: Duplicates
        testCases.add(new TestCase(new int[]{4, 4, 4}, 2));
        
        // Test case 4: k equals array length
        testCases.add(new TestCase(new int[]{7, 2, 9, 4, 6}, 5));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            TestCase test = testCases.get(i);
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Array: " + Arrays.toString(test.arr));
            System.out.println("k: " + test.k);
            int result = finder.findKthLargest(test.arr, test.k);
            System.out.println("Kth largest element: " + result + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Array: [3, 1, 4, 1, 5]
k: 2
Kth largest element: 4

Test case 2:
Array: [1]
k: 1
Kth largest element: 1

Test case 3:
Array: [4, 4, 4]
k: 2
Kth largest element: 4

Test case 4:
Array: [7, 2, 9, 4, 6]
k: 5
Kth largest element: 2

Explanation:

  • Test case 1: In [3, 1, 4, 1, 5], k=2, the 2nd largest is 4 (largest is 5).
  • Test case 2: In [1], k=1, the 1st largest is 1.
  • Test case 3: In [4, 4, 4], k=2, the 2nd largest is 4 (all elements equal).
  • Test case 4: In [7, 2, 9, 4, 6], k=5, the 5th largest is 2 (smallest element).

How It Works

  • MinPriorityQueue:
    • Uses a min-heap array to maintain the k largest elements, with smallest at root.
    • enqueue: If size < k, adds number and sifts up; if size = k and number > root, replaces root and sifts down.
    • siftUp: Moves smaller number up to maintain min-heap.
    • siftDown: Moves larger number down to maintain min-heap.
    • peek: Returns root (kth largest).
  • findKthLargest:
    • Creates a min-heap of size k.
    • Enqueues each array element, keeping only the k largest.
    • Returns the root (kth largest).
  • Example Trace (Test case 1):
    • arr=[3, 1, 4, 1, 5], k=2.
    • Enqueue 3: heap=[3], size=1.
    • Enqueue 1: heap=[1,3], size=2.
    • Enqueue 4: heap=[1,3], replace 1 with 4, heap=[3,4], size=2.
    • Enqueue 1: heap=[3,4] (1 < 3, ignored).
    • Enqueue 5: heap=[3,4], replace 3 with 5, heap=[4,5], size=2.
    • Peek: Returns 4 (2nd largest).
  • Main Method: Tests normal array, single element, duplicates, and k=length.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
EnqueueO(log k)O(1)
Find Kth LargestO(n log k)O(k)

Note:

  • n is the array length, k is the input k.
  • Time complexity: O(log k) for each enqueue (heap operations); O(n log k) for n elements.
  • Space complexity: O(k) for the min-heap of size k.
  • Worst case: O(n log k) time for large n, O(k) space.

✅ Tip: Use a min-heap of size k to efficiently find the kth largest element, as it discards smaller elements early. Test with edge cases like k=1 or k=length to ensure correctness.

⚠ Warning: Ensure k is valid (1 ≤ k ≤ array length) to avoid errors. Handle duplicates carefully, as they may affect the heap’s behavior.

Merge K Sorted Lists

Problem Statement

Write a Java program that merges k sorted arrays into a single sorted array using a priority queue. The program should enqueue the first element of each array along with its array index and value, and repeatedly dequeue the smallest element and enqueue the next element from the same array, continuing until all elements are processed. Test the implementation with various cases, including multiple arrays, single arrays, empty arrays, and arrays with duplicate elements. You can visualize this as combining k sorted lists of exam scores into one sorted list, using a priority queue to always pick the smallest score available from the lists.

Input:

  • A list of k sorted arrays, where each array contains integers (e.g., [[1,4,5], [1,3,4], [2,6]]).
  • k is the number of arrays (0 ≤ k ≤ 10^3). Output: A single sorted array containing all elements from the input arrays (e.g., [1,1,2,3,4,4,5,6]). Constraints:
  • Each array is sorted in non-decreasing order.
  • Array lengths are between 0 and 10^4.
  • Elements are integers in the range [-10^9, 10^9].
  • Total number of elements across all arrays is at most 10^4. Example:
  • Input: arrays=[[1,4,5], [1,3,4], [2,6]]
  • Output: [1,1,2,3,4,4,5,6]
  • Explanation: Merges three sorted arrays into one sorted array.
  • Input: arrays=[[]]
  • Output: []
  • Explanation: Single empty array results in an empty output.

Pseudocode

CLASS Element
    SET value to integer
    SET arrayIndex to integer
    SET elementIndex to integer
ENDCLASS

CLASS MinPriorityQueue
    SET array to new Element array of size 1000
    SET size to 0
    
    FUNCTION enqueue(value, arrayIndex, elementIndex)
        IF size equals array length THEN
            RETURN false
        ENDIF
        SET array[size] to new Element(value, arrayIndex, elementIndex)
        CALL siftUp(size)
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION siftUp(index)
        WHILE index > 0
            SET parent to (index - 1) / 2
            IF array[index].value >= array[parent].value THEN
                BREAK
            ENDIF
            SWAP array[index] and array[parent]
            SET index to parent
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null
        ENDIF
        SET result to array[0]
        SET array[0] to array[size - 1]
        DECREMENT size
        IF size > 0 THEN
            CALL siftDown(0)
        ENDIF
        RETURN result
    ENDFUNCTION
    
    FUNCTION siftDown(index)
        SET minIndex to index
        WHILE true
            SET left to 2 * index + 1
            SET right to 2 * index + 2
            IF left < size AND array[left].value < array[minIndex].value THEN
                SET minIndex to left
            ENDIF
            IF right < size AND array[right].value < array[minIndex].value THEN
                SET minIndex to right
            ENDIF
            IF minIndex equals index THEN
                BREAK
            ENDIF
            SWAP array[index] and array[minIndex]
            SET index to minIndex
        ENDWHILE
    ENDFUNCTION
ENDCLASS

FUNCTION mergeKSortedLists(arrays)
    IF arrays is empty THEN
        RETURN empty array
    ENDIF
    CREATE queue as new MinPriorityQueue
    CREATE result as new list
    FOR i from 0 to arrays length - 1
        IF arrays[i] is not empty THEN
            ENQUEUE (arrays[i][0], i, 0) to queue
        ENDIF
    ENDFOR
    WHILE queue is not empty
        SET element to queue.dequeue()
        ADD element.value to result
        SET arrayIndex to element.arrayIndex
        SET elementIndex to element.elementIndex + 1
        IF elementIndex < arrays[arrayIndex] length THEN
            ENQUEUE (arrays[arrayIndex][elementIndex], arrayIndex, elementIndex) to queue
        ENDIF
    ENDWHILE
    RETURN result as array
ENDFUNCTION

FUNCTION main()
    SET testCases to array of lists of sorted arrays
    FOR each testCase in testCases
        PRINT test case details
        CALL mergeKSortedLists(testCase)
        PRINT merged sorted array
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define an Element class to store a value, its array index, and its position in that array.
  2. Define a MinPriorityQueue class using a min-heap with: a. An array to store Element objects, prioritized by value. b. enqueue: Add element, sift up to maintain min-heap. c. siftUp: Move smaller value up. d. dequeue: Remove smallest value, sift down. e. siftDown: Move larger value down.
  3. In mergeKSortedLists: a. If input is empty, return empty array. b. Create a min-heap and result list. c. Enqueue first element of each non-empty array with its array index and position. d. While queue is not empty:
    • Dequeue smallest element, add to result.
    • If more elements exist in its array, enqueue the next element. e. Return result as array.
  4. In main, test with multiple arrays, single arrays, empty arrays, and duplicates.

Java Implementation

import java.util.*;

public class MergeKSortedLists {
    // Class to store element value, array index, and element index
    static class Element {
        int value;
        int arrayIndex;
        int elementIndex;

        Element(int value, int arrayIndex, int elementIndex) {
            this.value = value;
            this.arrayIndex = arrayIndex;
            this.elementIndex = elementIndex;
        }
    }

    // Custom min-heap priority queue for elements
    static class MinPriorityQueue {
        private Element[] array;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public MinPriorityQueue() {
            array = new Element[DEFAULT_SIZE];
            size = 0;
        }

        public boolean enqueue(int value, int arrayIndex, int elementIndex) {
            if (size == array.length) {
                return false; // Queue full
            }
            array[size] = new Element(value, arrayIndex, elementIndex);
            siftUp(size);
            size++;
            return true;
        }

        private void siftUp(int index) {
            while (index > 0) {
                int parent = (index - 1) / 2;
                if (array[index].value >= array[parent].value) {
                    break;
                }
                // Swap
                Element temp = array[index];
                array[index] = array[parent];
                array[parent] = temp;
                index = parent;
            }
        }

        public Element dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            Element result = array[0];
            array[0] = array[size - 1];
            size--;
            if (size > 0) {
                siftDown(0);
            }
            return result;
        }

        private void siftDown(int index) {
            int minIndex = index;
            while (true) {
                int left = 2 * index + 1;
                int right = 2 * index + 2;
                if (left < size && array[left].value < array[minIndex].value) {
                    minIndex = left;
                }
                if (right < size && array[right].value < array[minIndex].value) {
                    minIndex = right;
                }
                if (minIndex == index) {
                    break;
                }
                // Swap
                Element temp = array[index];
                array[index] = array[minIndex];
                array[minIndex] = temp;
                index = minIndex;
            }
        }
    }

    // Merges k sorted arrays into one sorted array
    public int[] mergeKSortedLists(int[][] arrays) {
        if (arrays == null || arrays.length == 0) {
            return new int[0];
        }

        MinPriorityQueue queue = new MinPriorityQueue();
        List<Integer> result = new ArrayList<>();

        // Enqueue first element of each non-empty array
        for (int i = 0; i < arrays.length; i++) {
            if (arrays[i] != null && arrays[i].length > 0) {
                queue.enqueue(arrays[i][0], i, 0);
            }
        }

        // Process queue
        while (true) {
            Element element = queue.dequeue();
            if (element == null) break;
            result.add(element.value);
            int arrayIndex = element.arrayIndex;
            int elementIndex = element.elementIndex + 1;
            if (elementIndex < arrays[arrayIndex].length) {
                queue.enqueue(arrays[arrayIndex][elementIndex], arrayIndex, elementIndex);
            }
        }

        // Convert result to array
        int[] merged = new int[result.size()];
        for (int i = 0; i < result.size(); i++) {
            merged[i] = result.get(i);
        }
        return merged;
    }

    // Helper class for test cases
    static class TestCase {
        int[][] arrays;

        TestCase(int[][] arrays) {
            this.arrays = arrays;
        }
    }

    // Main method to test merge k sorted lists
    public static void main(String[] args) {
        MergeKSortedLists merger = new MergeKSortedLists();

        // Test cases
        List<TestCase> testCases = new ArrayList<>();
        
        // Test case 1: Multiple arrays
        testCases.add(new TestCase(
            new int[][]{{1,4,5}, {1,3,4}, {2,6}}
        ));
        
        // Test case 2: Single array
        testCases.add(new TestCase(
            new int[][]{{1,2,3}}
        ));
        
        // Test case 3: Empty arrays
        testCases.add(new TestCase(
            new int[][]{{}}
        ));
        
        // Test case 4: Arrays with duplicates
        testCases.add(new TestCase(
            new int[][]{{1,1,1}, {2,2}, {1,3}}
        ));
        
        // Test case 5: Mixed empty and non-empty
        testCases.add(new TestCase(
            new int[][]{{}, {1,2}, {3}}
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            TestCase test = testCases.get(i);
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input arrays: " + Arrays.deepToString(test.arrays));
            int[] result = merger.mergeKSortedLists(test.arrays);
            System.out.println("Merged sorted array: " + Arrays.toString(result) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input arrays: [[1, 4, 5], [1, 3, 4], [2, 6]]
Merged sorted array: [1, 1, 2, 3, 4, 4, 5, 6]

Test case 2:
Input arrays: [[1, 2, 3]]
Merged sorted array: [1, 2, 3]

Test case 3:
Input arrays: [[]]
Merged sorted array: []

Test case 4:
Input arrays: [[1, 1, 1], [2, 2], [1, 3]]
Merged sorted array: [1, 1, 1, 1, 2, 2, 3]

Test case 5:
Input arrays: [[], [1, 2], [3]]
Merged sorted array: [1, 2, 3]

Explanation:

  • Test case 1: Merges three arrays, producing sorted output [1,1,2,3,4,4,5,6].
  • Test case 2: Single array [1,2,3] remains unchanged.
  • Test case 3: Single empty array results in empty output.
  • Test case 4: Arrays with duplicates merge into [1,1,1,1,2,2,3].
  • Test case 5: Empty and non-empty arrays merge into [1,2,3].

How It Works

  • Element: Stores a value, its array index, and position in that array.
  • MinPriorityQueue:
    • Uses a min-heap to prioritize smallest values.
    • enqueue: Adds element, sifts up.
    • dequeue: Removes smallest element, sifts down.
  • mergeKSortedLists:
    • Handles empty input by returning empty array.
    • Enqueues first element of each non-empty array.
    • Dequeues smallest element, adds to result, enqueues next element from same array.
    • Converts result list to array.
  • Example Trace (Test case 1):
    • arrays=[[1,4,5], [1,3,4], [2,6]].
    • Enqueue: (1,0,0), (1,1,0), (2,2,0) → heap=[(1,0,0),(1,1,0),(2,2,0)].
    • Dequeue: (1,0,0), add 1, enqueue (4,0,1) → heap=[(1,1,0),(4,0,1),(2,2,0)].
    • Dequeue: (1,1,0), add 1, enqueue (3,1,1) → heap=[(2,2,0),(4,0,1),(3,1,1)].
    • Continue until heap empty: result=[1,1,2,3,4,4,5,6].
  • Main Method: Tests multiple arrays, single array, empty arrays, duplicates, and mixed cases.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Enqueue/DequeueO(log k)O(1)
Merge K ListsO(N log k)O(k + N)

Note:

  • k is the number of arrays, N is the total number of elements.
  • Time complexity: O(log k) for enqueue/dequeue; O(N log k) for N elements with heap of size k.
  • Space complexity: O(k) for heap, O(N) for result array.
  • Worst case: O(N log k) time, O(k + N) space for large N.

✅ Tip: Use a min-heap with size k to efficiently merge sorted arrays by always selecting the smallest available element. Track array indices to enqueue the next element.

⚠ Warning: Handle empty arrays and null inputs to avoid errors. Ensure the priority queue size is sufficient for k arrays.

Min-Heap to Max-Heap

Problem Statement

Write a Java program that modifies a min-heap-based priority queue implementation to support a max-heap, where larger values have higher priority. The program should enqueue integers into the max-heap and dequeue the largest element, printing each operation’s result. Test the modified implementation with various sequences of enqueue and dequeue operations, including edge cases like empty queues, single elements, and duplicate values. You can visualize this as a priority task list where the most urgent task (highest number) is always processed first, with the max-heap ensuring the largest value is at the root.

Input: A sequence of operations, where each operation is either:

  • Enqueue: Add an integer to the max-heap (e.g., enqueue 5).
  • Dequeue: Remove and return the largest element from the max-heap. Output: For each operation, print the action performed (enqueue or dequeue) and the result, such as the dequeued value or a message if the queue is empty. Constraints:
  • Queue size is between 0 and 10^5.
  • Elements are integers in the range [-10^9, 10^9].
  • The queue may be empty when dequeue is called. Example:
  • Input: Operations = [enqueue(3), enqueue(1), enqueue(4), dequeue, enqueue(2), dequeue]
  • Output:
    Enqueued 3
    Enqueued 1
    Enqueued 4
    Dequeued: 4
    Enqueued 2
    Dequeued: 3
    
  • Explanation: The max-heap ensures the largest element (4, then 3) is dequeued first.
  • Input: Operations = [dequeue on empty queue]
  • Output: Queue empty, cannot dequeue

Pseudocode

CLASS MaxPriorityQueue
    SET array to new integer array of size 1000
    SET size to 0
    
    FUNCTION enqueue(number)
        IF size equals array length THEN
            RETURN false (queue full)
        ENDIF
        SET array[size] to number
        CALL siftUp(size)
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION siftUp(index)
        WHILE index > 0
            SET parent to (index - 1) / 2
            IF array[index] <= array[parent] THEN
                BREAK
            ENDIF
            SWAP array[index] and array[parent]
            SET index to parent
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null (queue empty)
        ENDIF
        SET result to array[0]
        SET array[0] to array[size - 1]
        DECREMENT size
        IF size > 0 THEN
            CALL siftDown(0)
        ENDIF
        RETURN result
    ENDFUNCTION
    
    FUNCTION siftDown(index)
        SET maxIndex to index
        WHILE true
            SET left to 2 * index + 1
            SET right to 2 * index + 2
            IF left < size AND array[left] > array[maxIndex] THEN
                SET maxIndex to left
            ENDIF
            IF right < size AND array[right] > array[maxIndex] THEN
                SET maxIndex to right
            ENDIF
            IF maxIndex equals index THEN
                BREAK
            ENDIF
            SWAP array[index] and array[maxIndex]
            SET index to maxIndex
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN size equals 0
    ENDFUNCTION
ENDCLASS

FUNCTION testMaxPriorityQueue(operations)
    CREATE queue as new MaxPriorityQueue
    FOR each operation in operations
        IF operation.type equals "enqueue" THEN
            IF queue.enqueue(operation.number) THEN
                PRINT enqueued number
            ELSE
                PRINT enqueue failed: queue full
            ENDIF
        ELSE IF operation.type equals "dequeue" THEN
            SET number to queue.dequeue()
            IF number is null THEN
                PRINT queue empty message
            ELSE
                PRINT dequeued number
            ENDIF
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL testMaxPriorityQueue(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a MaxPriorityQueue class using a max-heap with: a. An array to store integers, with size to track elements. b. enqueue: Add number to end, sift up to maintain max-heap property (larger values up). c. siftUp: Move larger number up if greater than parent. d. dequeue: Remove root (largest), move last element to root, sift down. e. siftDown: Move smaller number down to maintain max-heap property. f. isEmpty: Check if queue is empty.
  2. In testMaxPriorityQueue: a. Create a MaxPriorityQueue. b. For each operation:
    • If "enqueue", add number, print action or "Queue full".
    • If "dequeue", remove largest number, print result or "Queue empty".
  3. In main, test with sequences of enqueue and dequeue operations, including empty queues, single elements, and duplicates.

Java Implementation

import java.util.*;

public class MinHeapToMaxHeap {
    // Custom max-heap priority queue implementation
    static class MaxPriorityQueue {
        private int[] array;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public MaxPriorityQueue() {
            array = new int[DEFAULT_SIZE];
            size = 0;
        }

        public boolean enqueue(int number) {
            if (size == array.length) {
                return false; // Queue full
            }
            array[size] = number;
            siftUp(size);
            size++;
            return true;
        }

        private void siftUp(int index) {
            while (index > 0) {
                int parent = (index - 1) / 2;
                if (array[index] <= array[parent]) {
                    break;
                }
                // Swap
                int temp = array[index];
                array[index] = array[parent];
                array[parent] = temp;
                index = parent;
            }
        }

        public Integer dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            int result = array[0];
            array[0] = array[size - 1];
            size--;
            if (size > 0) {
                siftDown(0);
            }
            return result;
        }

        private void siftDown(int index) {
            int maxIndex = index;
            while (true) {
                int left = 2 * index + 1;
                int right = 2 * index + 2;
                if (left < size && array[left] > array[maxIndex]) {
                    maxIndex = left;
                }
                if (right < size && array[right] > array[maxIndex]) {
                    maxIndex = right;
                }
                if (maxIndex == index) {
                    break;
                }
                // Swap
                int temp = array[index];
                array[index] = array[maxIndex];
                array[maxIndex] = temp;
                index = maxIndex;
            }
        }

        public boolean isEmpty() {
            return size == 0;
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        Integer number;

        Operation(String type, Integer number) {
            this.type = type;
            this.number = number;
        }
    }

    // Tests max priority queue with operations
    public void testMaxPriorityQueue(List<Operation> operations) {
        MaxPriorityQueue queue = new MaxPriorityQueue();
        for (Operation op : operations) {
            if (op.type.equals("enqueue")) {
                if (queue.enqueue(op.number)) {
                    System.out.println("Enqueued " + op.number);
                } else {
                    System.out.println("Enqueue " + op.number + " failed: Queue full");
                }
            } else if (op.type.equals("dequeue")) {
                Integer number = queue.dequeue();
                if (number == null) {
                    System.out.println("Queue empty, cannot dequeue");
                } else {
                    System.out.println("Dequeued: " + number);
                }
            }
        }
    }

    // Main method to test max priority queue
    public static void main(String[] args) {
        MinHeapToMaxHeap tester = new MinHeapToMaxHeap();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal sequence
        List<Operation> case1 = Arrays.asList(
            new Operation("enqueue", 3),
            new Operation("enqueue", 1),
            new Operation("enqueue", 4),
            new Operation("dequeue", null),
            new Operation("enqueue", 2),
            new Operation("dequeue", null)
        );
        testCases.add(case1);
        
        // Test case 2: Empty queue
        List<Operation> case2 = Arrays.asList(
            new Operation("dequeue", null)
        );
        testCases.add(case2);
        
        // Test case 3: Single element
        List<Operation> case3 = Arrays.asList(
            new Operation("enqueue", 5),
            new Operation("dequeue", null)
        );
        testCases.add(case3);
        
        // Test case 4: Duplicate values
        List<Operation> case4 = Arrays.asList(
            new Operation("enqueue", 2),
            new Operation("enqueue", 2),
            new Operation("enqueue", 2),
            new Operation("dequeue", null),
            new Operation("dequeue", null)
        );
        testCases.add(case4);

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            tester.testMaxPriorityQueue(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Enqueued 3
Enqueued 1
Enqueued 4
Dequeued: 4
Enqueued 2
Dequeued: 3

Test case 2:
Queue empty, cannot dequeue

Test case 3:
Enqueued 5
Dequeued: 5

Test case 4:
Enqueued 2
Enqueued 2
Enqueued 2
Dequeued: 2
Dequeued: 2

Explanation:

  • Test case 1: Enqueues 3, 1, 4 (heap: [4,1,3]); dequeues 4; enqueues 2 (heap: [3,1,2]); dequeues 3.
  • Test case 2: Dequeue on empty queue returns error.
  • Test case 3: Enqueues 5, dequeues 5.
  • Test case 4: Enqueues three 2’s, dequeues two 2’s in FIFO order.

How It Works

  • MaxPriorityQueue:
    • Uses a max-heap array to maintain largest element at root.
    • enqueue: Adds to end, sifts up if larger than parent.
    • siftUp: Moves larger number up to maintain max-heap.
    • dequeue: Removes root, moves last element to root, sifts down.
    • siftDown: Moves smaller number down to maintain max-heap.
  • testMaxPriorityQueue:
    • Enqueues numbers, printing action or "Queue full".
    • Dequeues largest number, printing result or "Queue empty".
  • Example Trace (Test case 1):
    • Enqueue 3: array=[3], size=1.
    • Enqueue 1: array=[3,1], size=2.
    • Enqueue 4: array=[4,1,3], size=3 (after sift-up).
    • Dequeue: Returns 4, array=[3,1], size=2 (after sift-down).
    • Enqueue 2: array=[3,1,2], size=3.
    • Dequeue: Returns 3, array=[2,1], size=2.
  • Main Method: Tests normal sequence, empty queue, single element, and duplicates.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
EnqueueO(log n)O(1)
DequeueO(log n)O(1)
Full AlgorithmO(n log n)O(n)

Note:

  • n is the number of operations.
  • Time complexity: O(log n) for enqueue/dequeue due to heap operations; O(n log n) for n operations.
  • Space complexity: O(n) for storing up to n elements.
  • Worst case: O(n log n) time, O(n) space for many operations.

✅ Tip: Convert a min-heap to a max-heap by reversing comparison logic to prioritize larger values. Test with duplicate values to verify FIFO behavior for equal priorities.

⚠ Warning: Ensure heap properties are maintained after each operation. Handle empty queue cases to avoid null pointer issues.

Task Scheduler

Problem Statement

Write a Java program that simulates a task scheduler using a priority queue. The program should enqueue tasks, each with a description (string) and priority (integer), and dequeue them in order of highest priority, printing each task as it is processed. The priority queue should ensure that tasks with higher priority numbers are dequeued first, and tasks with equal priorities are processed in FIFO order. Test the implementation with various sequences of enqueue and dequeue operations, including edge cases like empty queues and tasks with equal priorities. You can visualize this as a manager prioritizing urgent tasks in a to-do list, ensuring the most critical tasks are handled first.

Input: A sequence of operations, where each operation is either:

  • Enqueue: Add a task with a description (string) and priority (integer) to the priority queue (e.g., "Write report", 3).
  • Dequeue: Remove and process the task with the highest priority. Output: For each operation, print the action performed (enqueue or dequeue) and, for dequeue, the processed task’s description or a message if the queue is empty. Constraints:
  • Queue size is between 0 and 10^5.
  • Task descriptions are non-empty strings containing printable ASCII characters, or null (handled gracefully).
  • Priorities are integers in the range [-10^9, 10^9].
  • The queue may be empty when dequeue is called. Example:
  • Input: Operations = [enqueue("Task1", 3), enqueue("Task2", 1), enqueue("Task3", 3), dequeue, dequeue]
  • Output:
    Enqueued Task1 (priority 3)
    Enqueued Task2 (priority 1)
    Enqueued Task3 (priority 3)
    Processed task: Task1
    Processed task: Task3
    
  • Explanation: Task1 and Task3 (priority 3) are processed before Task2 (priority 1) due to higher priority.
  • Input: Operations = [dequeue on empty queue]
  • Output: Queue empty, cannot dequeue

Pseudocode

CLASS Task
    SET description to string
    SET priority to integer
ENDCLASS

CLASS PriorityQueue
    SET array to new Task array of size 1000
    SET size to 0
    
    FUNCTION enqueue(description, priority)
        IF size equals array length THEN
            RETURN false (queue full)
        ENDIF
        SET newTask to new Task(description, priority)
        SET array[size] to newTask
        CALL siftUp(size)
        INCREMENT size
        RETURN true
    ENDFUNCTION
    
    FUNCTION siftUp(index)
        WHILE index > 0
            SET parent to (index - 1) / 2
            IF array[index].priority <= array[parent].priority THEN
                BREAK
            ENDIF
            SWAP array[index] and array[parent]
            SET index to parent
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION dequeue()
        IF size equals 0 THEN
            RETURN null (queue empty)
        ENDIF
        SET result to array[0]
        SET array[0] to array[size - 1]
        DECREMENT size
        IF size > 0 THEN
            CALL siftDown(0)
        ENDIF
        RETURN result
    ENDFUNCTION
    
    FUNCTION siftDown(index)
        SET maxIndex to index
        WHILE true
            SET left to 2 * index + 1
            SET right to 2 * index + 2
            IF left < size AND array[left].priority > array[maxIndex].priority THEN
                SET maxIndex to left
            ENDIF
            IF right < size AND array[right].priority > array[maxIndex].priority THEN
                SET maxIndex to right
            ENDIF
            IF maxIndex equals index THEN
                BREAK
            ENDIF
            SWAP array[index] and array[maxIndex]
            SET index to maxIndex
        ENDWHILE
    ENDFUNCTION
    
    FUNCTION isEmpty()
        RETURN size equals 0
    ENDFUNCTION
ENDCLASS

FUNCTION simulateTaskScheduler(operations)
    CREATE queue as new PriorityQueue
    FOR each operation in operations
        IF operation.type equals "enqueue" THEN
            CALL queue.enqueue(operation.description, operation.priority)
            PRINT enqueued task and priority
        ELSE IF operation.type equals "dequeue" THEN
            SET task to queue.dequeue()
            IF task is null THEN
                PRINT queue empty message
            ELSE
                PRINT processed task description
            ENDIF
        ENDIF
    ENFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL simulateTaskScheduler(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Task class to store task description (string) and priority (integer).
  2. Define a PriorityQueue class using a max-heap with: a. An array to store tasks, with size to track elements. b. enqueue: Add task to end, sift up to maintain heap property. c. dequeue: Remove root (highest priority), move last task to root, sift down. d. siftUp: Move task up if its priority is higher than parent’s. e. siftDown: Move task down to maintain max-heap property. f. isEmpty: Check if queue is empty.
  3. In simulateTaskScheduler: a. Create a PriorityQueue. b. For each operation:
    • If "enqueue", add task with priority, print action.
    • If "dequeue", remove highest-priority task, print task or "Queue empty".
  4. In main, test with sequences of enqueue and dequeue operations, including empty queues, single tasks, and equal priorities.

Java Implementation

import java.util.*;

public class TaskScheduler {
    // Task class to store description and priority
    static class Task {
        String description;
        int priority;

        Task(String description, int priority) {
            this.description = description;
            this.priority = priority;
        }
    }

    // Custom priority queue implementation (max-heap)
    static class PriorityQueue {
        private Task[] array;
        private int size;
        private static final int DEFAULT_SIZE = 1000;

        public PriorityQueue() {
            array = new Task[DEFAULT_SIZE];
            size = 0;
        }

        public boolean enqueue(String description, int priority) {
            if (size == array.length) {
                return false; // Queue full
            }
            Task newTask = new Task(description, priority);
            array[size] = newTask;
            siftUp(size);
            size++;
            return true;
        }

        private void siftUp(int index) {
            while (index > 0) {
                int parent = (index - 1) / 2;
                if (array[index].priority <= array[parent].priority) {
                    break;
                }
                // Swap
                Task temp = array[index];
                array[index] = array[parent];
                array[parent] = temp;
                index = parent;
            }
        }

        public Task dequeue() {
            if (size == 0) {
                return null; // Queue empty
            }
            Task result = array[0];
            array[0] = array[size - 1];
            size--;
            if (size > 0) {
                siftDown(0);
            }
            return result;
        }

        private void siftDown(int index) {
            int maxIndex = index;
            while (true) {
                int left = 2 * index + 1;
                int right = 2 * index + 2;
                if (left < size && array[left].priority > array[maxIndex].priority) {
                    maxIndex = left;
                }
                if (right < size && array[right].priority > array[maxIndex].priority) {
                    maxIndex = right;
                }
                if (maxIndex == index) {
                    break;
                }
                // Swap
                Task temp = array[index];
                array[index] = array[maxIndex];
                array[maxIndex] = temp;
                index = maxIndex;
            }
        }

        public boolean isEmpty() {
            return size == 0;
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String description;
        Integer priority;

        Operation(String type, String description, Integer priority) {
            this.type = type;
            this.description = description;
            this.priority = priority;
        }
    }

    // Simulates task scheduler
    public void simulateTaskScheduler(List<Operation> operations) {
        PriorityQueue queue = new PriorityQueue();
        for (Operation op : operations) {
            if (op.type.equals("enqueue")) {
                boolean success = queue.enqueue(op.description, op.priority);
                if (success) {
                    System.out.println("Enqueued " + (op.description != null ? op.description : "null") + 
                                      " (priority " + op.priority + ")");
                } else {
                    System.out.println("Enqueue " + (op.description != null ? op.description : "null") + 
                                      " failed: Queue full");
                }
            } else if (op.type.equals("dequeue")) {
                Task task = queue.dequeue();
                if (task == null) {
                    System.out.println("Queue empty, cannot dequeue");
                } else {
                    System.out.println("Processed task: " + task.description);
                }
            }
        }
    }

    // Main method to test task scheduler
    public static void main(String[] args) {
        TaskScheduler scheduler = new TaskScheduler();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal sequence
        List<Operation> case1 = Arrays.asList(
            new Operation("enqueue", "Write report", 3),
            new Operation("enqueue", "Send email", 1),
            new Operation("enqueue", "Attend meeting", 3),
            new Operation("dequeue", null, null),
            new Operation("dequeue", null, null)
        );
        testCases.add(case1);
        
        // Test case 2: Empty queue
        List<Operation> case2 = Arrays.asList(
            new Operation("dequeue", null, null)
        );
        testCases.add(case2);
        
        // Test case 3: Single task
        List<Operation> case3 = Arrays.asList(
            new Operation("enqueue", "Debug code", 5),
            new Operation("dequeue", null, null)
        );
        testCases.add(case3);
        
        // Test case 4: Equal priorities
        List<Operation> case4 = Arrays.asList(
            new Operation("enqueue", "Task A", 2),
            new Operation("enqueue", "Task B", 2),
            new Operation("enqueue", "Task C", 2),
            new Operation("dequeue", null, null),
            new Operation("dequeue", null, null)
        );
        testCases.add(case4);
        
        // Test case 5: Null task description
        List<Operation> case5 = Arrays.asList(
            new Operation("enqueue", null, 4),
            new Operation("dequeue", null, null)
        );
        testCases.add(case5);

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            scheduler.simulateTaskScheduler(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Enqueued Write report (priority 3)
Enqueued Send email (priority 1)
Enqueued Attend meeting (priority 3)
Processed task: Write report
Processed task: Attend meeting

Test case 2:
Queue empty, cannot dequeue

Test case 3:
Enqueued Debug code (priority 5)
Processed task: Debug code

Test case 4:
Enqueued Task A (priority 2)
Enqueued Task B (priority 2)
Enqueued Task C (priority 2)
Processed task: Task A
Processed task: Task B

Test case 5:
Enqueued null (priority 4)
Processed task: null

Explanation:

  • Test case 1: Enqueues tasks with priorities 3, 1, 3; dequeues highest-priority tasks (Write report, Attend meeting) before Send email.
  • Test case 2: Dequeue on empty queue returns error.
  • Test case 3: Enqueues and dequeues a single task.
  • Test case 4: Enqueues three tasks with equal priority (2), processes in FIFO order (Task A, Task B).
  • Test case 5: Enqueues and dequeues a task with null description.

How It Works

  • Task: Stores a task’s description and priority.
  • PriorityQueue:
    • Uses a max-heap array to store tasks, ensuring highest-priority task is at root.
    • enqueue: Adds task to end, sifts up to maintain heap property.
    • dequeue: Removes root, moves last task to root, sifts down.
    • Equal priorities maintain FIFO order due to heap insertion order.
  • simulateTaskScheduler:
    • Enqueues tasks, printing action and priority.
    • Dequeues highest-priority task, printing description or "Queue empty".
  • Example Trace (Test case 1):
    • Enqueue "Write report" (3): array=[(Write report,3)], size=1.
    • Enqueue "Send email" (1): array=[(Write report,3),(Send email,1)], size=2.
    • Enqueue "Attend meeting" (3): array=[(Attend meeting,3),(Send email,1),(Write report,3)], size=3 (after sift-up).
    • Dequeue: Returns "Attend meeting", array=[(Write report,3),(Send email,1)], size=2 (after sift-down).
    • Dequeue: Returns "Write report", array=[(Send email,1)], size=1.
  • Main Method: Tests normal sequences, empty queue, single task, equal priorities, and null description.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
EnqueueO(log n)O(1)
DequeueO(log n)O(1)
Full AlgorithmO(n log n)O(n)

Note:

  • n is the number of operations.
  • Time complexity: O(log n) for enqueue/dequeue due to heap operations; O(n log n) for n operations.
  • Space complexity: O(n) for storing up to n tasks.
  • Worst case: O(n log n) time for n enqueue/dequeue operations, O(n) space.

✅ Tip: Use a max-heap for a priority queue to ensure highest-priority tasks are processed first. Test with equal priorities to verify FIFO behavior for same-priority tasks.

⚠ Warning: Handle null descriptions and empty queue cases to avoid errors. Ensure the heap maintains its property after each enqueue and dequeue operation.

Linked Lists Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Cycle Detection

Problem Statement

Write a Java program that extends a singly linked list implementation to detect if the list contains a cycle using Floyd’s Tortoise and Hare algorithm. The linked list consists of nodes, each containing an integer value and a reference to the next node. A cycle exists if a node’s next pointer points to an earlier node in the list. The program should return true if a cycle is detected and false otherwise, and test the implementation with both cyclic and acyclic lists of varying sizes, including empty lists, single-node lists, and multi-node lists with or without cycles. You can visualize this as checking if a chain of numbered cards loops back to an earlier card, using two pointers moving at different speeds to detect the loop.

Input:

  • A singly linked list of integers (e.g., 1→2→3→4→2, where 4 points back to 2, forming a cycle). Output: A boolean indicating whether the list contains a cycle (true for cyclic, false for acyclic), along with a string representation of the list for clarity. Constraints:
  • The list size is between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • The list may be empty or contain a cycle at any position. Example:
  • Input: 1→2→3→4→2 (cycle: 4→2)
  • Output: true
  • Explanation: A cycle exists as 4 points back to 2.
  • Input: 1→2→3→null
  • Output: false
  • Explanation: No cycle, as the list terminates at null.
  • Input: []
  • Output: false
  • Explanation: An empty list has no cycle.

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION hasCycle(head)
    IF head is null OR head.next is null THEN
        RETURN false
    ENDIF
    SET slow to head
    SET fast to head
    WHILE fast is not null AND fast.next is not null
        SET slow to slow.next
        SET fast to fast.next.next
        IF slow equals fast THEN
            RETURN true
        ENDIF
    ENDWHILE
    RETURN false
ENDFUNCTION

FUNCTION toString(head, limit)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    SET count to 0
    CREATE visited as new set
    WHILE current is not null AND count < limit
        IF visited contains current THEN
            APPEND "->" + current.value + " (cycle)" to result
            BREAK
        ENDIF
        ADD current to visited
        APPEND current.value to result
        IF current.next is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
        INCREMENT count
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of linked lists (some cyclic, some acyclic)
    FOR each testCase in testCases
        PRINT test case details
        CALL hasCycle(testCase.head)
        PRINT whether cycle exists
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. A next pointer to the next node.
  2. In hasCycle (Floyd’s Tortoise and Hare): a. If head or head.next is null, return false (no cycle possible). b. Initialize slow and fast pointers to head. c. While fast and fast.next are not null:
    • Move slow one step, fast two steps.
    • If slow equals fast, a cycle exists (return true). d. If fast reaches null, no cycle exists (return false).
  3. In toString: a. If head is null, return "[]". b. Traverse the list with a limit to avoid infinite loops in cyclic lists. c. Use a set to detect cycles and indicate them in the output. d. Return space-separated values with cycle annotation if present.
  4. In main, test with empty, single-node, multi-node acyclic, and cyclic lists.

Java Implementation

import java.util.*;

public class CycleDetection {
    // Node class for the linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Detects if the linked list has a cycle using Floyd’s algorithm
    public boolean hasCycle(Node head) {
        if (head == null || head.next == null) {
            return false;
        }
        Node slow = head;
        Node fast = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
            if (slow == fast) {
                return true;
            }
        }
        return false;
    }

    // Converts linked list to string for output, handling cycles
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        int count = 0;
        int limit = 100; // Prevent infinite loop
        Set<Node> visited = new HashSet<>();
        while (current != null && count < limit) {
            if (visited.contains(current)) {
                result.append("->").append(current.value).append(" (cycle)");
                break;
            }
            visited.add(current);
            result.append(current.value);
            if (current.next != null) {
                result.append(" ");
            }
            current = current.next;
            count++;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node head;
        boolean hasCycle;

        // Creates acyclic list from values
        TestCase(int[] values) {
            this.hasCycle = false;
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new Node(values[0]);
            Node current = head;
            for (int i = 1; i < values.length; i++) {
                current.next = new Node(values[i]);
                current = current.next;
            }
        }

        // Creates cyclic list with cycle at specified position
        TestCase(int[] values, int cyclePos) {
            this.hasCycle = true;
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new Node(values[0]);
            Node current = head;
            Node cycleNode = null;
            for (int i = 1; i < values.length; i++) {
                current.next = new Node(values[i]);
                current = current.next;
                if (i == cyclePos) {
                    cycleNode = current;
                }
            }
            if (cyclePos >= 0 && cyclePos < values.length) {
                current.next = cycleNode; // Create cycle
            }
        }
    }

    // Main method to test cycle detection
    public static void main(String[] args) {
        CycleDetection detector = new CycleDetection();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3, 4}, 2), // Cyclic: 1→2→3→4→3
            new TestCase(new int[]{1, 2, 3}),       // Acyclic: 1→2→3
            new TestCase(new int[]{}),              // Empty
            new TestCase(new int[]{5}),             // Single node
            new TestCase(new int[]{1, 2, 3, 4, 5}, 0) // Cyclic: 1→2→3→4→5→1
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + detector.toString(test.head));
            boolean result = detector.hasCycle(test.head);
            System.out.println("Has cycle: " + result + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3 4->3 (cycle)
Has cycle: true

Test case 2:
Input list: 1 2 3
Has cycle: false

Test case 3:
Input list: []
Has cycle: false

Test case 4:
Input list: 5
Has cycle: false

Test case 5:
Input list: 1 2 3 4 5->1 (cycle)
Has cycle: true

Explanation:

  • Test case 1: List 1→2→3→4 with 4→3 forms a cycle, returns true.
  • Test case 2: Acyclic list 1→2→3, returns false.
  • Test case 3: Empty list, returns false.
  • Test case 4: Single node 5, returns false.
  • Test case 5: List 1→2→3→4→5 with 5→1 forms a cycle, returns true.

How It Works

  • Node: Stores an integer value and a next pointer.
  • hasCycle (Floyd’s Tortoise and Hare):
    • Returns false if list is empty or has one node.
    • Uses slow (moves one step) and fast (moves two steps) pointers.
    • If slow meets fast, a cycle exists.
    • If fast reaches null, no cycle exists.
  • toString:
    • Handles cycles by tracking visited nodes and limiting traversal.
    • Outputs values with spaces, appending cycle annotation if detected.
  • Example Trace (Test case 1):
    • Input: 1→2→3→4→3 (cycle at 3).
    • slow=1, fast=1.
    • Step 1: slow=2, fast=3.
    • Step 2: slow=3, fast=4.
    • Step 3: slow=4, fast=3.
    • Step 4: slow=3, fast=3 (meet, cycle detected).
    • Return: true.
  • Main Method: Tests cyclic (cycle at different positions), acyclic, empty, and single-node lists.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Has CycleO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for hasCycle (Floyd’s algorithm traverses list); O(n) for toString (traverse with cycle detection).
  • Space complexity: O(1) for hasCycle (two pointers); O(n) for toString (visited set and StringBuilder).
  • Worst case: O(n) time, O(n) space for large lists in output.

✅ Tip: Use Floyd’s Tortoise and Hare algorithm for efficient cycle detection with O(1) space. Test with cycles at different positions to ensure robustness.

⚠ Warning: Ensure pointers are checked for null to avoid null pointer exceptions. Be cautious when printing cyclic lists to avoid infinite loops.

Merge Two Sorted Lists

Problem Statement

Write a Java program that merges two sorted singly linked lists into a single sorted linked list. Each linked list consists of nodes containing integer values in non-decreasing order. The program should merge the lists by comparing node values and constructing a new sorted list, preserving the sorted order. Test the implementation with different inputs, including empty lists, single-node lists, lists of varying lengths, and lists with duplicate values. You can visualize this as combining two sorted stacks of numbered cards into one sorted stack, picking the smaller card from the top of either stack each time.

Input:

  • Two sorted singly linked lists of integers (e.g., 1→3→5 and 2→4→6). Output: A single sorted linked list as a string (e.g., "1 2 3 4 5 6"). Constraints:
  • The list sizes are between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • Both lists are sorted in non-decreasing order.
  • Either or both lists may be empty. Example:
  • Input: list1 = 1→3→5, list2 = 2→4→6
  • Output: "1 2 3 4 5 6"
  • Explanation: Merges into a sorted list 1→2→3→4→5→6.
  • Input: list1 = [], list2 = []
  • Output: "[]"
  • Explanation: Merging two empty lists results in an empty list.
  • Input: list1 = 1→1, list2 = 1→2
  • Output: "1 1 1 2"
  • Explanation: Merges lists with duplicates into a sorted list.

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION mergeTwoLists(list1, list2)
    CREATE dummy as new Node(0)
    SET tail to dummy
    SET current1 to list1
    SET current2 to list2
    WHILE current1 is not null AND current2 is not null
        IF current1.value <= current2.value THEN
            SET tail.next to current1
            SET current1 to current1.next
        ELSE
            SET tail.next to current2
            SET current2 to current2.next
        ENDIF
        SET tail to tail.next
    ENDWHILE
    IF current1 is not null THEN
        SET tail.next to current1
    ELSE IF current2 is not null THEN
        SET tail.next to current2
    ENDIF
    RETURN dummy.next
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    WHILE current is not null
        APPEND current.value to result
        IF current.next is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of pairs of linked lists
    FOR each testCase in testCases
        PRINT test case details
        CALL mergeTwoLists(testCase.list1, testCase.list2)
        PRINT merged list using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. A next pointer to the next node.
  2. In mergeTwoLists: a. Create a dummy node to simplify list construction. b. Initialize tail to dummy, current1 to list1, current2 to list2. c. While both lists have nodes:
    • Compare current1.value and current2.value.
    • Append smaller node to tail.next, advance corresponding pointer.
    • Move tail to appended node. d. Append remaining nodes from list1 or list2, if any. e. Return dummy.next as the merged list head.
  3. In toString: a. If head is null, return "[]". b. Traverse the list, append each value to a StringBuilder with spaces. c. Return the string representation.
  4. In main, test with non-empty lists, empty lists, single-node lists, and lists with duplicates.

Java Implementation

public class MergeTwoSortedLists {
    // Node class for the linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Merges two sorted linked lists
    public Node mergeTwoLists(Node list1, Node list2) {
        Node dummy = new Node(0);
        Node tail = dummy;
        Node current1 = list1;
        Node current2 = list2;

        while (current1 != null && current2 != null) {
            if (current1.value <= current2.value) {
                tail.next = current1;
                current1 = current1.next;
            } else {
                tail.next = current2;
                current2 = current2.next;
            }
            tail = tail.next;
        }

        if (current1 != null) {
            tail.next = current1;
        } else if (current2 != null) {
            tail.next = current2;
        }

        return dummy.next;
    }

    // Converts linked list to string for output
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        while (current != null) {
            result.append(current.value);
            if (current.next != null) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node list1;
        Node list2;

        TestCase(int[] values1, int[] values2) {
            // Create list1
            if (values1.length == 0) {
                list1 = null;
            } else {
                list1 = new Node(values1[0]);
                Node current = list1;
                for (int i = 1; i < values1.length; i++) {
                    current.next = new Node(values1[i]);
                    current = current.next;
                }
            }
            // Create list2
            if (values2.length == 0) {
                list2 = null;
            } else {
                list2 = new Node(values2[0]);
                Node current = list2;
                for (int i = 1; i < values2.length; i++) {
                    current.next = new Node(values2[i]);
                    current = current.next;
                }
            }
        }
    }

    // Main method to test merging sorted lists
    public static void main(String[] args) {
        MergeTwoSortedLists merger = new MergeTwoSortedLists();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 3, 5}, new int[]{2, 4, 6}), // Both non-empty
            new TestCase(new int[]{}, new int[]{}),              // Both empty
            new TestCase(new int[]{1}, new int[]{}),            // One empty
            new TestCase(new int[]{1, 1}, new int[]{1, 2}),     // Duplicates
            new TestCase(new int[]{1, 2, 3}, new int[]{4})      // Different lengths
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("List 1: " + merger.toString(test.list1));
            System.out.println("List 2: " + merger.toString(test.list2));
            Node merged = merger.mergeTwoLists(test.list1, test.list2);
            System.out.println("Merged list: " + merger.toString(merged) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
List 1: 1 3 5
List 2: 2 4 6
Merged list: 1 2 3 4 5 6

Test case 2:
List 1: []
List 2: []
Merged list: []

Test case 3:
List 1: 1
List 2: []
Merged list: 1

Test case 4:
List 1: 1 1
List 2: 1 2
Merged list: 1 1 1 2

Test case 5:
List 1: 1 2 3
List 2: 4
Merged list: 1 2 3 4

Explanation:

  • Test case 1: Merges 1→3→5 and 2→4→6 into 1→2→3→4→5→6.
  • Test case 2: Both empty lists result in an empty list.
  • Test case 3: Merges 1 with empty list, resulting in 1.
  • Test case 4: Merges lists with duplicates 1→1 and 1→2 into 1→1→1→2.
  • Test case 5: Merges 1→2→3 and 4 into 1→2→3→4.

How It Works

  • Node: Stores an integer value and a next pointer.
  • mergeTwoLists:
    • Uses a dummy node to simplify list construction.
    • Compares nodes from both lists, appending the smaller to the result.
    • Advances the corresponding list’s pointer and moves the tail.
    • Appends any remaining nodes from either list.
  • toString: Converts the list to a space-separated string, returning "[]" for empty lists.
  • Example Trace (Test case 1):
    • list1: 1→3→5, list2: 2→4→6.
    • dummy→null, tail=dummy, current1=1, current2=2.
    • Step 1: 1≤2, dummy→1, current1=3, tail=1.
    • Step 2: 3>2, dummy→1→2, current2=4, tail=2.
    • Step 3: 3≤4, dummy→1→2→3, current1=5, tail=3.
    • Step 4: 5>4, dummy→1→2→3→4, current2=6, tail=4.
    • Step 5: 5≤6, dummy→1→2→3→4→5, current1=null, tail=5.
    • Step 6: Append 6, dummy→1→2→3→4→5→6.
    • Return: 1→2→3→4→5→6.
  • Main Method: Tests non-empty lists, empty lists, one empty list, duplicates, and different lengths.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Merge Two ListsO(n + m)O(1)
To StringO(n)O(n)

Note:

  • n and m are the lengths of the two input lists.
  • Time complexity: O(n + m) for merging (single pass through both lists); O(n) for toString (traverse list).
  • Space complexity: O(1) for merging (constant pointers); O(n) for toString (StringBuilder).
  • Worst case: O(n + m) time, O(n + m) space for output with large lists.

✅ Tip: Use a dummy node to simplify merging by avoiding special cases for the head. Compare values iteratively to maintain sorted order.

⚠ Warning: Ensure pointers are updated correctly to avoid losing list segments. Handle empty list cases to prevent null pointer exceptions.

Middle Element Finder

Problem Statement

Write a Java program that finds the middle element of a singly linked list in a single pass using the fast-and-slow pointer technique. The linked list consists of nodes, each containing an integer value and a reference to the next node. For lists with an odd number of nodes, return the middle node’s value; for even-length lists, return the value of the second middle node (e.g., in 1→2→3→4, return 3). The program should handle edge cases like empty lists and single-node lists. Test the implementation with linked lists of varying sizes, including empty lists, single-node lists, and lists with odd and even lengths. You can visualize this as finding the middle card in a chain of numbered cards, using two pointers where one moves twice as fast to reach the middle efficiently.

Input:

  • A singly linked list of integers (e.g., 1→2→3→4→5). Output: The value of the middle node as an integer, or -1 if the list is empty (e.g., 3 for 1→2→3→4→5). Constraints:
  • The list size is between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • The list may be empty. Example:
  • Input: 1→2→3→4→5
  • Output: 3
  • Explanation: Middle node of 5 nodes is the 3rd node (value 3).
  • Input: 1→2→3→4
  • Output: 3
  • Explanation: Second middle node of 4 nodes is the 3rd node (value 3).
  • Input: []
  • Output: -1
  • Explanation: Empty list returns -1.

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION findMiddle(head)
    IF head is null THEN
        RETURN -1
    ENDIF
    SET slow to head
    SET fast to head
    WHILE fast is not null AND fast.next is not null
        SET slow to slow.next
        SET fast to fast.next.next
    ENDWHILE
    RETURN slow.value
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    WHILE current is not null
        APPEND current.value to result
        IF current.next is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of linked lists
    FOR each testCase in testCases
        PRINT test case details
        CALL findMiddle(testCase.head)
        PRINT middle element
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. A next pointer to the next node.
  2. In findMiddle: a. If head is null, return -1 (empty list). b. Initialize slow and fast pointers to head. c. While fast and fast.next are not null:
    • Move slow one step, fast two steps. d. When fast reaches the end, slow is at the middle node. e. Return slow.value.
  3. In toString: a. If head is null, return "[]". b. Traverse the list, append each value to a StringBuilder with spaces. c. Return the string representation.
  4. In main, test with empty, single-node, odd-length, and even-length lists.

Java Implementation

public class MiddleElementFinder {
    // Node class for the linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Finds the middle element using fast-and-slow pointer technique
    public int findMiddle(Node head) {
        if (head == null) {
            return -1;
        }
        Node slow = head;
        Node fast = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        return slow.value;
    }

    // Converts linked list to string for output
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        while (current != null) {
            result.append(current.value);
            if (current.next != null) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node head;

        TestCase(int[] values) {
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new Node(values[0]);
            Node current = head;
            for (int i = 1; i < values.length; i++) {
                current.next = new Node(values[i]);
                current = current.next;
            }
        }
    }

    // Main method to test middle element finder
    public static void main(String[] args) {
        MiddleElementFinder finder = new MiddleElementFinder();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3, 4, 5}), // Odd length
            new TestCase(new int[]{1, 2, 3, 4}),     // Even length
            new TestCase(new int[]{}),               // Empty list
            new TestCase(new int[]{5}),              // Single node
            new TestCase(new int[]{1, 1, 1, 1, 1, 1}) // Even length with duplicates
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + finder.toString(test.head));
            int middle = finder.findMiddle(test.head);
            System.out.println("Middle element: " + middle + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3 4 5
Middle element: 3

Test case 2:
Input list: 1 2 3 4
Middle element: 3

Test case 3:
Input list: []
Middle element: -1

Test case 4:
Input list: 5
Middle element: 5

Test case 5:
Input list: 1 1 1 1 1 1
Middle element: 1

Explanation:

  • Test case 1: Odd-length list 1→2→3→4→5, middle is 3rd node (3).
  • Test case 2: Even-length list 1→2→3→4, second middle is 3rd node (3).
  • Test case 3: Empty list, returns -1.
  • Test case 4: Single node 5, returns 5.
  • Test case 5: Even-length list 1→1→1→1→1→1, second middle is 4th node (1).

How It Works

  • Node: Stores an integer value and a next pointer.
  • findMiddle:
    • Returns -1 for empty lists.
    • Uses slow (moves one step) and fast (moves two steps) pointers.
    • When fast reaches the end, slow is at the middle (or second middle for even length).
  • toString: Converts the list to a space-separated string, returning "[]" for empty lists.
  • Example Trace (Test case 1):
    • Input: 1→2→3→4→5.
    • Initial: slow=1, fast=1.
    • Step 1: slow=2, fast=3.
    • Step 2: slow=3, fast=5.
    • Step 3: fast=null, slow=3.
    • Return: 3.
  • Main Method: Tests odd-length, even-length, empty, single-node, and duplicate lists.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Find MiddleO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for findMiddle (single pass, fast pointer covers list in n/2 steps); O(n) for toString (traverse list).
  • Space complexity: O(1) for findMiddle (two pointers); O(n) for toString (StringBuilder).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: Use the fast-and-slow pointer technique to find the middle element efficiently in one pass. For even-length lists, choose whether to return the first or second middle node based on problem requirements.

⚠ Warning: Ensure null checks for fast and fast.next to avoid null pointer exceptions. Handle empty lists by returning a sentinel value like -1.

Playlist Manager

Problem Statement

Write a Java program that simulates a music playlist using a singly linked list. Each node in the list represents a song with a string name. The program should allow users to add songs (insert at the head or tail of the list), remove songs (delete by value, removing the first occurrence), and print the playlist. Test the implementation with various sequences of operations, including adding and removing songs, handling empty playlists, and dealing with duplicate song names. You can visualize this as managing a playlist where songs are added to the beginning or end, removed by name, and displayed as a sequence of song titles.

Input:

  • A sequence of operations, where each operation is:
    • Add at head: Insert a song name at the start of the playlist.
    • Add at tail: Insert a song name at the end of the playlist.
    • Remove: Delete the first occurrence of a song by name.
    • Print: Display the current playlist. Output: For each operation, print the action performed (e.g., "Added song at head", "Removed song", or the playlist as a string). If a song to remove is not found, print an appropriate message. Constraints:
  • The playlist size is between 0 and 10^5.
  • Song names are non-empty strings of length up to 100 characters.
  • The playlist may be empty or contain duplicate song names. Example:
  • Input: Operations = [addHead("Song1"), addTail("Song2"), addHead("Song3"), print, remove("Song2"), print]
  • Output:
    Added Song1 at head
    Added Song2 at tail
    Added Song3 at head
    Playlist: Song3 Song1 Song2
    Removed Song2
    Playlist: Song3 Song1
    
  • Input: Operations = [remove("Song1"), print]
  • Output:
    Song Song1 not found
    Playlist: []
    

Pseudocode

CLASS Node
    SET song to string
    SET next to Node (null by default)
ENDCLASS

CLASS PlaylistManager
    SET head to null

    FUNCTION addHead(song)
        CREATE newNode as new Node(song)
        SET newNode.next to head
        SET head to newNode
    ENDFUNCTION

    FUNCTION addTail(song)
        CREATE newNode as new Node(song)
        IF head is null THEN
            SET head to newNode
            RETURN
        ENDIF
        SET current to head
        WHILE current.next is not null
            SET current to current.next
        ENDWHILE
        SET current.next to newNode
    ENDFUNCTION

    FUNCTION removeSong(song)
        IF head is null THEN
            RETURN false
        ENDIF
        IF head.song equals song THEN
            SET head to head.next
            RETURN true
        ENDIF
        SET current to head
        WHILE current.next is not null
            IF current.next.song equals song THEN
                SET current.next to current.next.next
                RETURN true
            ENDIF
            SET current to current.next
        ENDWHILE
        RETURN false
    ENDFUNCTION

    FUNCTION toString()
        IF head is null THEN
            RETURN "[]"
        ENDIF
        CREATE result as new StringBuilder
        SET current to head
        WHILE current is not null
            APPEND current.song to result
            IF current.next is not null THEN
                APPEND " " to result
            ENDIF
            SET current to current.next
        ENDWHILE
        RETURN result as string
    ENDFUNCTION
ENDCLASS

FUNCTION testPlaylist(operations)
    CREATE playlist as new PlaylistManager
    FOR each operation in operations
        IF operation.type equals "addHead" THEN
            CALL playlist.addHead(operation.song)
            PRINT added song at head
        ELSE IF operation.type equals "addTail" THEN
            CALL playlist.addTail(operation.song)
            PRINT added song at tail
        ELSE IF operation.type equals "remove" THEN
            IF playlist.removeSong(operation.song) THEN
                PRINT removed song
            ELSE
                PRINT song not found
            ENDIF
        ELSE IF operation.type equals "print" THEN
            PRINT playlist using toString
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL testPlaylist(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. A string song for the song name. b. A next pointer to the next node.
  2. Define a PlaylistManager class with: a. addHead: Insert a new node with the song at the start. b. addTail: Traverse to the end and append a new node. c. removeSong: Remove the first node with the given song name, return true if found. d. toString: Convert the playlist to a space-separated string.
  3. In testPlaylist: a. Create a PlaylistManager. b. For each operation:
    • Add at head: Call addHead, print action.
    • Add at tail: Call addTail, print action.
    • Remove: Call removeSong, print success or failure.
    • Print: Call toString, print playlist.
  4. In main, test with sequences including adds, removes, empty playlists, and duplicates.

Java Implementation

import java.util.*;

public class PlaylistManager {
    // Node class for the linked list
    static class Node {
        String song;
        Node next;

        Node(String song) {
            this.song = song;
            this.next = null;
        }
    }

    // PlaylistManager class to manage songs
    static class Playlist {
        private Node head;

        public void addHead(String song) {
            Node newNode = new Node(song);
            newNode.next = head;
            head = newNode;
        }

        public void addTail(String song) {
            Node newNode = new Node(song);
            if (head == null) {
                head = newNode;
                return;
            }
            Node current = head;
            while (current.next != null) {
                current = current.next;
            }
            current.next = newNode;
        }

        public boolean removeSong(String song) {
            if (head == null) {
                return false;
            }
            if (head.song.equals(song)) {
                head = head.next;
                return true;
            }
            Node current = head;
            while (current.next != null) {
                if (current.next.song.equals(song)) {
                    current.next = current.next.next;
                    return true;
                }
                current = current.next;
            }
            return false;
        }

        public String toString() {
            if (head == null) {
                return "[]";
            }
            StringBuilder result = new StringBuilder();
            Node current = head;
            while (current != null) {
                result.append(current.song);
                if (current.next != null) {
                    result.append(" ");
                }
                current = current.next;
            }
            return result.toString();
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String song;

        Operation(String type, String song) {
            this.type = type;
            this.song = song;
        }
    }

    // Tests playlist operations
    public void testPlaylist(List<Operation> operations) {
        Playlist playlist = new Playlist();
        for (Operation op : operations) {
            if (op.type.equals("addHead")) {
                playlist.addHead(op.song);
                System.out.println("Added " + op.song + " at head");
            } else if (op.type.equals("addTail")) {
                playlist.addTail(op.song);
                System.out.println("Added " + op.song + " at tail");
            } else if (op.type.equals("remove")) {
                if (playlist.removeSong(op.song)) {
                    System.out.println("Removed " + op.song);
                } else {
                    System.out.println("Song " + op.song + " not found");
                }
            } else if (op.type.equals("print")) {
                System.out.println("Playlist: " + playlist.toString());
            }
        }
    }

    // Main method to test playlist manager
    public static void main(String[] args) {
        PlaylistManager manager = new PlaylistManager();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal operations
        testCases.add(Arrays.asList(
            new Operation("addHead", "Song1"),
            new Operation("addTail", "Song2"),
            new Operation("addHead", "Song3"),
            new Operation("print", null),
            new Operation("remove", "Song2"),
            new Operation("print", null)
        ));
        
        // Test case 2: Empty playlist
        testCases.add(Arrays.asList(
            new Operation("remove", "Song1"),
            new Operation("print", null)
        ));
        
        // Test case 3: Single song
        testCases.add(Arrays.asList(
            new Operation("addHead", "Song1"),
            new Operation("print", null),
            new Operation("remove", "Song1"),
            new Operation("print", null)
        ));
        
        // Test case 4: Duplicates
        testCases.add(Arrays.asList(
            new Operation("addHead", "Song1"),
            new Operation("addTail", "Song1"),
            new Operation("addHead", "Song2"),
            new Operation("print", null),
            new Operation("remove", "Song1"),
            new Operation("print", null)
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            manager.testPlaylist(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Added Song1 at head
Added Song2 at tail
Added Song3 at head
Playlist: Song3 Song1 Song2
Removed Song2
Playlist: Song3 Song1

Test case 2:
Song Song1 not found
Playlist: []

Test case 3:
Added Song1 at head
Playlist: Song1
Removed Song1
Playlist: []

Test case 4:
Added Song1 at head
Added Song1 at tail
Added Song2 at head
Playlist: Song2 Song1 Song1
Removed Song1
Playlist: Song2 Song1

Explanation:

  • Test case 1: Adds Song1 (head), Song2 (tail), Song3 (head), prints "Song3 Song1 Song2", removes Song2, prints "Song3 Song1".
  • Test case 2: Attempts to remove Song1 from empty playlist, prints not found, prints "[]".
  • Test case 3: Adds Song1, prints "Song1", removes Song1, prints "[]".
  • Test case 4: Adds Song1 (head), Song1 (tail), Song2 (head), prints "Song2 Song1 Song1", removes first Song1, prints "Song2 Song1".

How It Works

  • Node: Stores a string song name and a next pointer.
  • Playlist:
    • addHead: Inserts new node at the start in O(1).
    • addTail: Traverses to end, appends node in O(n).
    • removeSong: Removes first occurrence of song, returns true if found, O(n).
    • toString: Returns space-separated song names, "[]" for empty.
  • testPlaylist: Executes operations, printing actions and results.
  • Example Trace (Test case 1):
    • addHead("Song1"): head=Song1→null.
    • addTail("Song2"): head=Song1→Song2.
    • addHead("Song3"): head=Song3→Song1→Song2.
    • print: "Song3 Song1 Song2".
    • remove("Song2"): head=Song3→Song1.
    • print: "Song3 Song1".
  • Main Method: Tests normal operations, empty playlist, single song, and duplicates.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Add HeadO(1)O(1)
Add TailO(n)O(1)
Remove SongO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the playlist.
  • Time complexity: O(1) for addHead; O(n) for addTail and removeSong (traverse list); O(n) for toString (traverse list).
  • Space complexity: O(1) for operations (constant pointers); O(n) for toString (StringBuilder).
  • Worst case: O(n) time, O(n) space for output with large playlists.

✅ Tip: Use a linked list for flexible playlist management, with head insertion for quick additions and tail insertion for appending. Test with duplicates to ensure correct removal of the first occurrence.

⚠ Warning: Handle empty playlist cases to avoid null pointer exceptions. Ensure removal checks all nodes to find the target song.

Reverse a Linked List

Problem Statement

Write a Java program that reverses a singly linked list. The linked list consists of nodes, each containing an integer value and a reference to the next node. The program should reverse the order of the nodes (e.g., 1→2→3 becomes 3→2→1) using an iterative approach and print the reversed list. Test the implementation with linked lists of varying sizes, including empty lists, single-node lists, and multi-node lists. You can visualize this as rearranging a chain of numbered cards, flipping their order so the last card becomes the first.

Input:

  • A singly linked list of integers (e.g., 1→2→3→4). Output: The reversed linked list as a string (e.g., "4 3 2 1"). Constraints:
  • The list size is between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • The list may be empty. Example:
  • Input: 1→2→3→4
  • Output: "4 3 2 1"
  • Explanation: The list 1→2→3→4 is reversed to 4→3→2→1.
  • Input: []
  • Output: "[]"
  • Explanation: An empty list remains empty.
  • Input: 5
  • Output: "5"
  • Explanation: A single-node list is unchanged.

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION reverseList(head)
    SET prev to null
    SET current to head
    WHILE current is not null
        SET nextNode to current.next
        SET current.next to prev
        SET prev to current
        SET current to nextNode
    ENDWHILE
    RETURN prev
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    WHILE current is not null
        APPEND current.value to result
        IF current.next is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of linked lists
    FOR each testCase in testCases
        PRINT test case details
        CALL reverseList(testCase.head)
        PRINT reversed list using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. A next pointer to the next node.
  2. In reverseList: a. Initialize prev as null, current as head. b. While current is not null:
    • Save current.next as nextNode.
    • Set current.next to prev (reverse the link).
    • Move prev to current, current to nextNode. c. Return prev as the new head.
  3. In toString: a. If head is null, return "[]". b. Traverse the list, append each value to a StringBuilder, add spaces between values. c. Return the string representation.
  4. In main, test with empty, single-node, and multi-node lists.

Java Implementation

public class ReverseLinkedList {
    // Node class for the linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Reverses the linked list
    public Node reverseList(Node head) {
        Node prev = null;
        Node current = head;

        while (current != null) {
            Node nextNode = current.next;
            current.next = prev;
            prev = current;
            current = nextNode;
        }

        return prev;
    }

    // Converts linked list to string for output
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        while (current != null) {
            result.append(current.value);
            if (current.next != null) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node head;

        TestCase(int[] values) {
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new Node(values[0]);
            Node current = head;
            for (int i = 1; i < values.length; i++) {
                current.next = new Node(values[i]);
                current = current.next;
            }
        }
    }

    // Main method to test linked list reversal
    public static void main(String[] args) {
        ReverseLinkedList reverser = new ReverseLinkedList();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3, 4}), // Multi-node list
            new TestCase(new int[]{}),           // Empty list
            new TestCase(new int[]{5}),          // Single node
            new TestCase(new int[]{1, 1, 1}),    // List with duplicates
            new TestCase(new int[]{10, 20})      // Two nodes
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + reverser.toString(test.head));
            Node reversed = reverser.reverseList(test.head);
            System.out.println("Reversed list: " + reverser.toString(reversed) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3 4
Reversed list: 4 3 2 1

Test case 2:
Input list: []
Reversed list: []

Test case 3:
Input list: 5
Reversed list: 5

Test case 4:
Input list: 1 1 1
Reversed list: 1 1 1

Test case 5:
Input list: 10 20
Reversed list: 20 10

Explanation:

  • Test case 1: Reverses 1→2→3→4 to 4→3→2→1.
  • Test case 2: Empty list remains empty.
  • Test case 3: Single node 5 is unchanged.
  • Test case 4: List with duplicates 1→1→1 reverses to 1→1→1.
  • Test case 5: Two nodes 10→20 reverse to 20→10.

How It Works

  • Node: Stores an integer value and a reference to the next node.
  • reverseList:
    • Iteratively reverses links by adjusting next pointers.
    • Uses prev, current, and nextNode to track nodes.
    • Each step reverses one link, moving pointers forward.
  • toString: Converts the list to a space-separated string, returning "[]" for empty lists.
  • Example Trace (Test case 1):
    • Input: 1→2→3→4.
    • Initial: prev=null, current=1, nextNode=2.
    • Step 1: 1→null, prev=1, current=2, nextNode=3.
    • Step 2: 2→1→null, prev=2, current=3, nextNode=4.
    • Step 3: 3→2→1→null, prev=3, current=4, nextNode=null.
    • Step 4: 4→3→2→1→null, prev=4, current=null.
    • Return: 4→3→2→1.
  • Main Method: Tests multi-node, empty, single-node, duplicate, and two-node lists.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Reverse ListO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for reversing (single pass) and toString (traverse list).
  • Space complexity: O(1) for reversal (constant pointers); O(n) for toString (StringBuilder).
  • Worst case: O(n) time, O(n) space for large lists in output.

✅ Tip: Use an iterative approach to reverse a linked list to save space, adjusting pointers in a single pass. Test with empty and single-node lists to ensure edge cases are handled.

⚠ Warning: Be cautious when updating next pointers to avoid losing references to the rest of the list. Always save the next node before reversing the link.

Bidirectional Traversal

Problem Statement

Write a Java program that extends a doubly linked list implementation to include methods for printing the list in both forward (head to tail) and backward (tail to head) directions. The doubly linked list consists of nodes, each containing an integer value, a reference to the next node, and a reference to the previous node. The program should provide methods to traverse and print the list using the next pointers for forward direction and the prev pointers for backward direction. Test the implementation with sample doubly linked lists of varying sizes, including empty lists, single-node lists, and multi-node lists. You can visualize this as displaying a chain of numbered cards in order from the first to the last card, and then from the last to the first card, using the bidirectional links.

Input:

  • A doubly linked list of integers (e.g., 1↔2↔3↔4). Output: Two strings representing the list in forward order (e.g., "1 2 3 4") and backward order (e.g., "4 3 2 1"). Constraints:
  • The list size is between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • The list may be empty. Example:
  • Input: 1↔2↔3↔4
  • Output:
    Forward: 1 2 3 4
    Backward: 4 3 2 1
    
  • Input: []
  • Output:
    Forward: []
    Backward: []
    
  • Input: 5
  • Output:
    Forward: 5
    Backward: 5
    

Pseudocode

CLASS DoublyNode
    SET value to integer
    SET next to DoublyNode (null by default)
    SET prev to DoublyNode (null by default)
ENDCLASS

FUNCTION printForward(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    WHILE current is not null
        APPEND current.value to result
        IF current.next is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION printBackward(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    WHILE current.next is not null
        SET current to current.next
    ENDWHILE
    WHILE current is not null
        APPEND current.value to result
        IF current.prev is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.prev
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of doubly linked lists
    FOR each testCase in testCases
        PRINT test case details
        CALL printForward(testCase.head)
        CALL printBackward(testCase.head)
        PRINT forward and backward results
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a DoublyNode class with: a. An integer value. b. A next pointer to the next node. c. A prev pointer to the previous node.
  2. In printForward: a. If head is null, return "[]". b. Traverse the list using next pointers, appending each value to a StringBuilder with spaces. c. Return the string representation.
  3. In printBackward: a. If head is null, return "[]". b. Traverse to the tail using next pointers. c. Traverse back using prev pointers, appending each value with spaces. d. Return the string representation.
  4. In main, test with empty, single-node, and multi-node doubly linked lists, printing both forward and backward.

Java Implementation

public class BidirectionalTraversal {
    // Node class for the doubly linked list
    static class DoublyNode {
        int value;
        DoublyNode next;
        DoublyNode prev;

        DoublyNode(int value) {
            this.value = value;
            this.next = null;
            this.prev = null;
        }
    }

    // Prints the list in forward direction (head to tail)
    public String printForward(DoublyNode head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        DoublyNode current = head;
        while (current != null) {
            result.append(current.value);
            if (current.next != null) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Prints the list in backward direction (tail to head)
    public String printBackward(DoublyNode head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        DoublyNode current = head;
        // Move to tail
        while (current.next != null) {
            current = current.next;
        }
        // Traverse backward
        while (current != null) {
            result.append(current.value);
            if (current.prev != null) {
                result.append(" ");
            }
            current = current.prev;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        DoublyNode head;

        TestCase(int[] values) {
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new DoublyNode(values[0]);
            DoublyNode current = head;
            for (int i = 1; i < values.length; i++) {
                DoublyNode newNode = new DoublyNode(values[i]);
                newNode.prev = current;
                current.next = newNode;
                current = newNode;
            }
        }
    }

    // Main method to test bidirectional traversal
    public static void main(String[] args) {
        BidirectionalTraversal traverser = new BidirectionalTraversal();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3, 4}), // Multi-node list
            new TestCase(new int[]{}),           // Empty list
            new TestCase(new int[]{5}),          // Single node
            new TestCase(new int[]{1, 1, 1}),    // List with duplicates
            new TestCase(new int[]{10, 20})      // Two nodes
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Forward: " + traverser.printForward(test.head));
            System.out.println("Backward: " + traverser.printBackward(test.head) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Forward: 1 2 3 4
Backward: 4 3 2 1

Test case 2:
Forward: []
Backward: []

Test case 3:
Forward: 5
Backward: 5

Test case 4:
Forward: 1 1 1
Backward: 1 1 1

Test case 5:
Forward: 10 20
Backward: 20 10

Explanation:

  • Test case 1: Forward prints 1→2→3→4, backward prints 4→3→2→1.
  • Test case 2: Empty list prints "[]" for both directions.
  • Test case 3: Single node 5 prints "5" for both directions.
  • Test case 4: List with duplicates 1→1→1 prints "1 1 1" for both directions.
  • Test case 5: Two nodes 10→20 print "10 20" forward, "20 10" backward.

How It Works

  • DoublyNode: Stores an integer value, a next pointer, and a prev pointer.
  • printForward:
    • Traverses from head to tail using next pointers.
    • Builds a space-separated string, returning "[]" for empty lists.
  • printBackward:
    • Traverses to tail using next pointers.
    • Traverses back to head using prev pointers, building a space-separated string.
  • Example Trace (Test case 1):
    • Input: 1↔2↔3↔4.
    • Forward: Traverse head=1, append 1,2,3,4 → "1 2 3 4".
    • Backward: Move to tail=4, append 4,3,2,1 → "4 3 2 1".
  • Main Method: Tests multi-node, empty, single-node, duplicate, and two-node lists.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Print ForwardO(n)O(n)
Print BackwardO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for printForward (single pass); O(n) for printBackward (two passes: to tail and back).
  • Space complexity: O(n) for both (StringBuilder for output).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: Use the next and prev pointers in a doubly linked list to enable efficient bidirectional traversal. Test both directions to ensure prev pointers are correctly set.

⚠ Warning: Ensure null checks for next and prev pointers to avoid null pointer exceptions, especially for empty or single-node lists.

Browser History Simulator

Problem Statement

Write a Java program that simulates a browser’s navigation history using a doubly linked list. Each node in the list represents a webpage with a string URL. The program should support adding new pages (insert at tail), navigating back (delete the current tail and move to the previous page), and navigating forward (re-insert a previously deleted page). A current pointer tracks the current page, and forward navigation is only possible if pages were previously removed via back navigation. Test the implementation with sequences of operations, including adding pages, navigating back and forward, and handling edge cases like empty history or navigating beyond available pages. You can visualize this as managing a browser’s history where new pages are added to the end, going back removes the current page, and going forward restores a previously visited page.

Input:

  • A sequence of operations, where each operation is:
    • visit(url): Add a new page (URL) at the tail, clear forward history.
    • back(): Move to the previous page, remove current page, return URL or "null" if not possible.
    • forward(): Move to the next page (re-insert removed page), return URL or "null" if not possible.
    • printHistory(): Print the current history from head to current page. Output: For each operation, print the action performed (e.g., "Visited URL", "Navigated back to URL", "Navigated forward to URL", or the current history). Return "null" for invalid back/forward navigations. Constraints:
  • The history size is between 0 and 10^5.
  • URLs are non-empty strings of length up to 100 characters.
  • The history may be empty or have no forward/backward pages. Example:
  • Input: Operations = [visit("page1"), visit("page2"), visit("page3"), printHistory, back, printHistory, forward, printHistory]
  • Output:
    Visited page1
    Visited page2
    Visited page3
    History: page1 page2 page3
    Navigated back to page2
    History: page1 page2
    Navigated forward to page3
    History: page1 page2 page3
    
  • Input: Operations = [back, printHistory]
  • Output:
    Navigated back to null
    History: []
    

Pseudocode

CLASS DoublyNode
    SET url to string
    SET next to DoublyNode (null by default)
    SET prev to DoublyNode (null by default)
ENDCLASS

CLASS BrowserHistory
    SET head to null
    SET current to null

    FUNCTION visit(url)
        CREATE newNode as new DoublyNode(url)
        IF head is null THEN
            SET head to newNode
            SET current to newNode
        ELSE
            SET current.next to newNode
            SET newNode.prev to current
            SET current to newNode
        ENDIF
    ENDFUNCTION

    FUNCTION back()
        IF current is null OR current.prev is null THEN
            RETURN "null"
        ENDIF
        SET url to current.url
        SET current to current.prev
        SET current.next to null
        RETURN url
    ENDFUNCTION

    FUNCTION forward(forwardList)
        IF forwardList is empty THEN
            RETURN "null"
        ENDIF
        SET url to forwardList.removeLast()
        CREATE newNode as new DoublyNode(url)
        SET newNode.prev to current
        IF current is not null THEN
            SET current.next to newNode
        ELSE
            SET head to newNode
        ENDIF
        SET current to newNode
        RETURN url
    ENDFUNCTION

    FUNCTION toString()
        IF head is null THEN
            RETURN "[]"
        ENDIF
        CREATE result as new StringBuilder
        SET temp to head
        WHILE temp is not null AND temp is not current.next
            APPEND temp.url to result
            IF temp.next is not null AND temp.next is not current.next THEN
                APPEND " " to result
            ENDIF
            SET temp to temp.next
        ENDWHILE
        RETURN result as string
    ENDFUNCTION
ENDCLASS

FUNCTION testBrowserHistory(operations)
    CREATE browser as new BrowserHistory
    CREATE forwardList as new list
    FOR each operation in operations
        IF operation.type equals "visit" THEN
            CLEAR forwardList
            CALL browser.visit(operation.url)
            PRINT visited url
        ELSE IF operation.type equals "back" THEN
            SET url to browser.back()
            IF url is not "null" THEN
                ADD url to forwardList
            ENDIF
            PRINT navigated back to url
        ELSE IF operation.type equals "forward" THEN
            SET url to browser.forward(forwardList)
            PRINT navigated forward to url
        ELSE IF operation.type equals "print" THEN
            PRINT history using browser.toString
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL testBrowserHistory(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a DoublyNode class with: a. A string url for the webpage. b. A next pointer to the next node. c. A prev pointer to the previous node.
  2. Define a BrowserHistory class with: a. head and current pointers to track the list and current page. b. visit: Add new node at tail, clear forward history, update current. c. back: Move current to previous node, remove current tail, store URL for forward. d. forward: Re-insert last removed URL from forward list, update current. e. toString: Print history from head to current page.
  3. In testBrowserHistory: a. Create a BrowserHistory and a forwardList to store back-navigated URLs. b. For each operation:
    • visit: Clear forward list, call visit, print action.
    • back: Call back, store URL in forward list, print URL.
    • forward: Call forward with forward list, print URL.
    • print: Call toString, print history.
  4. In main, test with sequences including visits, back/forward navigations, and edge cases.

Java Implementation

import java.util.*;

public class BrowserHistorySimulator {
    // Node class for the doubly linked list
    static class DoublyNode {
        String url;
        DoublyNode next;
        DoublyNode prev;

        DoublyNode(String url) {
            this.url = url;
            this.next = null;
            this.prev = null;
        }
    }

    // BrowserHistory class to simulate navigation
    static class BrowserHistory {
        private DoublyNode head;
        private DoublyNode current;

        public void visit(String url) {
            DoublyNode newNode = new DoublyNode(url);
            if (head == null) {
                head = newNode;
                current = newNode;
            } else {
                current.next = newNode;
                newNode.prev = current;
                current = newNode;
            }
        }

        public String back() {
            if (current == null || current.prev == null) {
                return "null";
            }
            String url = current.url;
            current = current.prev;
            current.next = null;
            return url;
        }

        public String forward(List<String> forwardList) {
            if (forwardList.isEmpty()) {
                return "null";
            }
            String url = forwardList.remove(forwardList.size() - 1);
            DoublyNode newNode = new DoublyNode(url);
            newNode.prev = current;
            if (current != null) {
                current.next = newNode;
            } else {
                head = newNode;
            }
            current = newNode;
            return url;
        }

        public String toString() {
            if (head == null) {
                return "[]";
            }
            StringBuilder result = new StringBuilder();
            DoublyNode temp = head;
            while (temp != null && temp != current.next) {
                result.append(temp.url);
                if (temp.next != null && temp.next != current.next) {
                    result.append(" ");
                }
                temp = temp.next;
            }
            return result.toString();
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String url;

        Operation(String type, String url) {
            this.type = type;
            this.url = url;
        }
    }

    // Tests browser history operations
    public void testBrowserHistory(List<Operation> operations) {
        BrowserHistory browser = new BrowserHistory();
        List<String> forwardList = new ArrayList<>();
        for (Operation op : operations) {
            if (op.type.equals("visit")) {
                forwardList.clear();
                browser.visit(op.url);
                System.out.println("Visited " + op.url);
            } else if (op.type.equals("back")) {
                String url = browser.back();
                if (!url.equals("null")) {
                    forwardList.add(url);
                }
                System.out.println("Navigated back to " + url);
            } else if (op.type.equals("forward")) {
                String url = browser.forward(forwardList);
                System.out.println("Navigated forward to " + url);
            } else if (op.type.equals("print")) {
                System.out.println("History: " + browser.toString());
            }
        }
    }

    // Main method to test browser history simulator
    public static void main(String[] args) {
        BrowserHistorySimulator simulator = new BrowserHistorySimulator();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal navigation
        testCases.add(Arrays.asList(
            new Operation("visit", "page1"),
            new Operation("visit", "page2"),
            new Operation("visit", "page3"),
            new Operation("print", null),
            new Operation("back", null),
            new Operation("print", null),
            new Operation("forward", null),
            new Operation("print", null)
        ));
        
        // Test case 2: Empty history
        testCases.add(Arrays.asList(
            new Operation("back", null),
            new Operation("forward", null),
            new Operation("print", null)
        ));
        
        // Test case 3: Single page
        testCases.add(Arrays.asList(
            new Operation("visit", "page1"),
            new Operation("print", null),
            new Operation("back", null),
            new Operation("print", null),
            new Operation("forward", null),
            new Operation("print", null)
        ));
        
        // Test case 4: Multiple back/forward
        testCases.add(Arrays.asList(
            new Operation("visit", "page1"),
            new Operation("visit", "page2"),
            new Operation("visit", "page3"),
            new Operation("back", null),
            new Operation("back", null),
            new Operation("forward", null),
            new Operation("forward", null),
            new Operation("print", null)
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            simulator.testBrowserHistory(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Visited page1
Visited page2
Visited page3
History: page1 page2 page3
Navigated back to page3
History: page1 page2
Navigated forward to page3
History: page1 page2 page3

Test case 2:
Navigated back to null
Navigated forward to null
History: []

Test case 3:
Visited page1
History: page1
Navigated back to null
History: []
Navigated forward to page1
History: page1

Test case 4:
Visited page1
Visited page2
Visited page3
Navigated back to page3
Navigated back to page2
Navigated forward to page3
Navigated forward to page2
History: page1 page2

Explanation:

  • Test case 1: Visits page1, page2, page3, prints "page1 page2 page3", goes back (removes page3), prints "page1 page2", goes forward (restores page3), prints "page1 page2 page3".
  • Test case 2: Attempts back/forward on empty history, prints "null" and "[]".
  • Test case 3: Visits page1, prints "page1", goes back (removes page1), prints "[]", goes forward (restores page1), prints "page1".
  • Test case 4: Visits page1, page2, page3, goes back twice (removes page3, page2), goes forward twice (restores page3, page2), prints "page1 page2".

How It Works

  • DoublyNode: Stores a string URL, a next pointer, and a prev pointer.
  • BrowserHistory:
    • visit: Adds new node at tail, clears forward history, updates current.
    • back: Moves current to previous node, removes tail, stores URL for forward.
    • forward: Re-inserts last removed URL from forward list, updates current.
    • toString: Prints history from head to current, returns "[]" if empty.
  • testBrowserHistory: Manages operations, uses forwardList to store back-navigated URLs.
  • Example Trace (Test case 1):
    • visit("page1"): head=page1, current=page1.
    • visit("page2"): head=page1↔page2, current=page2.
    • visit("page3"): head=page1↔page2↔page3, current=page3.
    • print: "page1 page2 page3".
    • back: current=page2, forwardList=[page3], returns "page3".
    • print: "page1 page2".
    • forward: Re-inserts page3, current=page3, returns "page3".
    • print: "page1 page2 page3".
  • Main Method: Tests normal navigation, empty history, single page, and multiple back/forward operations.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
VisitO(1)O(1)
BackO(1)O(1)
ForwardO(1)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the history.
  • Time complexity: O(1) for visit, back, forward (constant-time operations); O(n) for toString (traverse to current).
  • Space complexity: O(1) for visit, back, forward (constant pointers); O(n) for toString (StringBuilder) and forwardList storage.
  • Worst case: O(n) time, O(n) space for output or forward history with large lists.

✅ Tip: Use a doubly linked list with a current pointer to efficiently manage browser history. Clear the forward history on new visits to simulate browser behavior accurately.

⚠ Warning: Maintain the current pointer and forward list correctly to avoid losing navigation history. Handle edge cases like empty history or no forward pages.

Deque Implementation

Problem Statement

Write a Java program that implements a double-ended queue (deque) using a doubly linked list. The doubly linked list consists of nodes, each containing an integer value, a reference to the next node, and a reference to the previous node. The deque should support methods to add elements at the front (head) and back (tail), and remove elements from the front and back, maintaining the list’s bidirectional structure. Test the implementation with a sequence of operations, including adding and removing elements at both ends, and handling edge cases like empty deques and single-element deques. You can visualize this as managing a queue of numbered cards where you can add or remove cards from either the front or the back of the deck.

Input:

  • A sequence of operations, where each operation is:
    • addFront(value): Add a value to the front of the deque.
    • addBack(value): Add a value to the back of the deque.
    • removeFront(): Remove and return the front value, or -1 if empty.
    • removeBack(): Remove and return the back value, or -1 if empty.
    • printDeque(): Print the deque in forward order. Output: For each operation, print the action performed (e.g., "Added 5 at front", "Removed 3 from back", or the deque as a string). Return -1 for remove operations on an empty deque. Constraints:
  • The deque size is between 0 and 10^5.
  • Values are integers in the range [-10^9, 10^9].
  • The deque may be empty. Example:
  • Input: Operations = [addFront(1), addBack(2), addFront(3), printDeque, removeFront, printDeque, removeBack, printDeque]
  • Output:
    Added 1 at front
    Added 2 at back
    Added 3 at front
    Deque: 3 1 2
    Removed 3 from front
    Deque: 1 2
    Removed 2 from back
    Deque: 1
    
  • Input: Operations = [removeFront, printDeque]
  • Output:
    Removed -1 from front
    Deque: []
    

Pseudocode

CLASS DoublyNode
    SET value to integer
    SET next to DoublyNode (null by default)
    SET prev to DoublyNode (null by default)
ENDCLASS

CLASS Deque
    SET head to null
    SET tail to null

    FUNCTION addFront(value)
        CREATE newNode as new DoublyNode(value)
        IF head is null THEN
            SET head to newNode
            SET tail to newNode
        ELSE
            SET newNode.next to head
            SET head.prev to newNode
            SET head to newNode
        ENDIF
    ENDFUNCTION

    FUNCTION addBack(value)
        CREATE newNode as new DoublyNode(value)
        IF tail is null THEN
            SET head to newNode
            SET tail to newNode
        ELSE
            SET newNode.prev to tail
            SET tail.next to newNode
            SET tail to newNode
        ENDIF
    ENDFUNCTION

    FUNCTION removeFront()
        IF head is null THEN
            RETURN -1
        ENDIF
        SET value to head.value
        SET head to head.next
        IF head is null THEN
            SET tail to null
        ELSE
            SET head.prev to null
        ENDIF
        RETURN value
    ENDFUNCTION

    FUNCTION removeBack()
        IF tail is null THEN
            RETURN -1
        ENDIF
        SET value to tail.value
        SET tail to tail.prev
        IF tail is null THEN
            SET head to null
        ELSE
            SET tail.next to null
        ENDIF
        RETURN value
    ENDFUNCTION

    FUNCTION toString()
        IF head is null THEN
            RETURN "[]"
        ENDIF
        CREATE result as new StringBuilder
        SET current to head
        WHILE current is not null
            APPEND current.value to result
            IF current.next is not null THEN
                APPEND " " to result
            ENDIF
            SET current to current.next
        ENDWHILE
        RETURN result as string
    ENDFUNCTION
ENDCLASS

FUNCTION testDeque(operations)
    CREATE deque as new Deque
    FOR each operation in operations
        IF operation.type equals "addFront" THEN
            CALL deque.addFront(operation.value)
            PRINT added value at front
        ELSE IF operation.type equals "addBack" THEN
            CALL deque.addBack(operation.value)
            PRINT added value at back
        ELSE IF operation.type equals "removeFront" THEN
            SET value to deque.removeFront()
            PRINT removed value from front
        ELSE IF operation.type equals "removeBack" THEN
            SET value to deque.removeBack()
            PRINT removed value from back
        ELSE IF operation.type equals "print" THEN
            PRINT deque using toString
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL testDeque(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a DoublyNode class with: a. An integer value. b. A next pointer to the next node. c. A prev pointer to the previous node.
  2. Define a Deque class with: a. head and tail pointers to track both ends. b. addFront: Add node at head, update prev and tail if needed. c. addBack: Add node at tail, update next and head if needed. d. removeFront: Remove head, update head and tail, return value or -1. e. removeBack: Remove tail, update tail and head, return value or -1. f. toString: Print list forward, return "[]" if empty.
  3. In testDeque: a. Create a Deque. b. For each operation:
    • addFront: Call addFront, print action.
    • addBack: Call addBack, print action.
    • removeFront: Call removeFront, print value.
    • removeBack: Call removeBack, print value.
    • print: Call toString, print deque.
  4. In main, test with sequences including adds/removes at both ends and edge cases.

Java Implementation

import java.util.*;

public class DequeImplementation {
    // Node class for the doubly linked list
    static class DoublyNode {
        int value;
        DoublyNode next;
        DoublyNode prev;

        DoublyNode(int value) {
            this.value = value;
            this.next = null;
            this.prev = null;
        }
    }

    // Deque class using doubly linked list
    static class Deque {
        private DoublyNode head;
        private DoublyNode tail;

        public void addFront(int value) {
            DoublyNode newNode = new DoublyNode(value);
            if (head == null) {
                head = newNode;
                tail = newNode;
            } else {
                newNode.next = head;
                head.prev = newNode;
                head = newNode;
            }
        }

        public void addBack(int value) {
            DoublyNode newNode = new DoublyNode(value);
            if (tail == null) {
                head = newNode;
                tail = newNode;
            } else {
                newNode.prev = tail;
                tail.next = newNode;
                tail = newNode;
            }
        }

        public int removeFront() {
            if (head == null) {
                return -1;
            }
            int value = head.value;
            head = head.next;
            if (head == null) {
                tail = null;
            } else {
                head.prev = null;
            }
            return value;
        }

        public int removeBack() {
            if (tail == null) {
                return -1;
            }
            int value = tail.value;
            tail = tail.prev;
            if (tail == null) {
                head = null;
            } else {
                tail.next = null;
            }
            return value;
        }

        public String toString() {
            if (head == null) {
                return "[]";
            }
            StringBuilder result = new StringBuilder();
            DoublyNode current = head;
            while (current != null) {
                result.append(current.value);
                if (current.next != null) {
                    result.append(" ");
                }
                current = current.next;
            }
            return result.toString();
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        Integer value;

        Operation(String type, Integer value) {
            this.type = type;
            this.value = value;
        }
    }

    // Tests deque operations
    public void testDeque(List<Operation> operations) {
        Deque deque = new Deque();
        for (Operation op : operations) {
            if (op.type.equals("addFront")) {
                deque.addFront(op.value);
                System.out.println("Added " + op.value + " at front");
            } else if (op.type.equals("addBack")) {
                deque.addBack(op.value);
                System.out.println("Added " + op.value + " at back");
            } else if (op.type.equals("removeFront")) {
                int value = deque.removeFront();
                System.out.println("Removed " + value + " from front");
            } else if (op.type.equals("removeBack")) {
                int value = deque.removeBack();
                System.out.println("Removed " + value + " from back");
            } else if (op.type.equals("print")) {
                System.out.println("Deque: " + deque.toString());
            }
        }
    }

    // Main method to test deque implementation
    public static void main(String[] args) {
        DequeImplementation manager = new DequeImplementation();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal operations
        testCases.add(Arrays.asList(
            new Operation("addFront", 1),
            new Operation("addBack", 2),
            new Operation("addFront", 3),
            new Operation("print", null),
            new Operation("removeFront", null),
            new Operation("print", null),
            new Operation("removeBack", null),
            new Operation("print", null)
        ));
        
        // Test case 2: Empty deque
        testCases.add(Arrays.asList(
            new Operation("removeFront", null),
            new Operation("removeBack", null),
            new Operation("print", null)
        ));
        
        // Test case 3: Single element
        testCases.add(Arrays.asList(
            new Operation("addFront", 5),
            new Operation("print", null),
            new Operation("removeFront", null),
            new Operation("print", null)
        ));
        
        // Test case 4: Mixed operations with duplicates
        testCases.add(Arrays.asList(
            new Operation("addFront", 1),
            new Operation("addBack", 1),
            new Operation("addFront", 2),
            new Operation("print", null),
            new Operation("removeFront", null),
            new Operation("removeBack", null),
            new Operation("print", null)
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            manager.testDeque(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Added 1 at front
Added 2 at back
Added 3 at front
Deque: 3 1 2
Removed 3 from front
Deque: 1 2
Removed 2 from back
Deque: 1

Test case 2:
Removed -1 from front
Removed -1 from back
Deque: []

Test case 3:
Added 5 at front
Deque: 5
Removed 5 from front
Deque: []

Test case 4:
Added 1 at front
Added 1 at back
Added 2 at front
Deque: 2 1 1
Removed 2 from front
Removed 1 from back
Deque: 1

Explanation:

  • Test case 1: Adds 1 (front), 2 (back), 3 (front), prints "3 1 2", removes 3 (front), prints "1 2", removes 2 (back), prints "1".
  • Test case 2: Removes from empty deque (returns -1), prints "[]".
  • Test case 3: Adds 5 (front), prints "5", removes 5 (front), prints "[]".
  • Test case 4: Adds 1 (front), 1 (back), 2 (front), prints "2 1 1", removes 2 (front), 1 (back), prints "1".

How It Works

  • DoublyNode: Stores an integer value, a next pointer, and a prev pointer.
  • Deque:
    • addFront: Adds node at head, updates prev and tail, O(1).
    • addBack: Adds node at tail, updates next and head, O(1).
    • removeFront: Removes head, updates head and tail, returns value or -1, O(1).
    • removeBack: Removes tail, updates tail and head, returns value or -1, O(1).
    • toString: Traverses forward, returns space-separated string or "[]".
  • testDeque: Executes operations, printing actions and results.
  • Example Trace (Test case 1):
    • addFront(1): head=1↔null, tail=1.
    • addBack(2): head=1↔2, tail=2.
    • addFront(3): head=3↔1↔2, tail=2.
    • print: "3 1 2".
    • removeFront: Removes 3, head=1↔2, tail=2, returns 3.
    • print: "1 2".
    • removeBack: Removes 2, head=1, tail=1, returns 2.
    • print: "1".
  • Main Method: Tests normal operations, empty deque, single element, and duplicates.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Add FrontO(1)O(1)
Add BackO(1)O(1)
Remove FrontO(1)O(1)
Remove BackO(1)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the deque.
  • Time complexity: O(1) for addFront, addBack, removeFront, removeBack (constant-time operations); O(n) for toString (traverse list).
  • Space complexity: O(1) for add/remove operations (constant pointers); O(n) for toString (StringBuilder).
  • Worst case: O(n) time, O(n) space for output with large deques.

✅ Tip: Use a doubly linked list for a deque to enable O(1) operations at both ends by maintaining head and tail pointers. Test edge cases like empty deques and single-element deques to ensure robustness.

⚠ Warning: Always update both head and tail pointers correctly during add and remove operations to maintain deque integrity. Check for null pointers to handle empty deque cases.

Insert After Value

Problem Statement

Write a Java program that extends a doubly linked list implementation to include a method that inserts a new node with a given value after the first occurrence of a specified target value. The doubly linked list consists of nodes, each containing an integer value, a reference to the next node, and a reference to the previous node. The program should insert the new node by updating the next and prev pointers appropriately and return true if the insertion is successful (target value found) or false if the target value is not found. Test the implementation with cases where the target value exists in the list and where it does not, including empty lists and single-node lists. You can visualize this as adding a new card to a chain of numbered cards, placing it right after the first card with a specific number.

Input:

  • A doubly linked list of integers (e.g., 1↔2↔3), a target value (e.g., 2), and a new value to insert (e.g., 4). Output: A boolean indicating whether the insertion was successful, and the updated list printed in forward order (e.g., "1 2 4 3"). Constraints:
  • The list size is between 0 and 10^5.
  • Node values and the new value are integers in the range [-10^9, 10^9].
  • The list may be empty or not contain the target value. Example:
  • Input: List = 1↔2↔3, target = 2, newValue = 4
  • Output: true, "1 2 4 3"
  • Explanation: Inserts 4 after the first 2, resulting in 1↔2↔4↔3.
  • Input: List = 1↔2↔3, target = 5, newValue = 4
  • Output: false, "1 2 3"
  • Explanation: Target 5 not found, list unchanged.
  • Input: List = [], target = 1, newValue = 2
  • Output: false, "[]"
  • Explanation: Empty list, no insertion possible.

Pseudocode

CLASS DoublyNode
    SET value to integer
    SET next to DoublyNode (null by default)
    SET prev to DoublyNode (null by default)
ENDCLASS

FUNCTION insertAfterValue(head, target, newValue)
    IF head is null THEN
        RETURN false
    ENDIF
    SET current to head
    WHILE current is not null
        IF current.value equals target THEN
            CREATE newNode as new DoublyNode(newValue)
            SET newNode.next to current.next
            SET newNode.prev to current
            IF current.next is not null THEN
                SET current.next.prev to newNode
            ENDIF
            SET current.next to newNode
            RETURN true
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN false
ENDFUNCTION

FUNCTION printForward(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    WHILE current is not null
        APPEND current.value to result
        IF current.next is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of test cases (list, target, newValue)
    FOR each testCase in testCases
        PRINT test case details
        CALL insertAfterValue(testCase.head, testCase.target, testCase.newValue)
        PRINT insertion result and updated list using printForward
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a DoublyNode class with: a. An integer value. b. A next pointer to the next node. c. A prev pointer to the previous node.
  2. In insertAfterValue: a. If head is null, return false (empty list). b. Traverse the list using current until the target value is found or the list ends. c. If target is found:
    • Create a new node with newValue.
    • Set newNode.next to current.next and newNode.prev to current.
    • Update current.next.prev to newNode if current.next exists.
    • Set current.next to newNode.
    • Return true. d. If target is not found, return false.
  3. In printForward: a. If head is null, return "[]". b. Traverse the list using next pointers, appending each value with spaces. c. Return the string representation.
  4. In main, test with cases where the target exists, does not exist, and edge cases like empty or single-node lists.

Java Implementation

public class InsertAfterValue {
    // Node class for the doubly linked list
    static class DoublyNode {
        int value;
        DoublyNode next;
        DoublyNode prev;

        DoublyNode(int value) {
            this.value = value;
            this.next = null;
            this.prev = null;
        }
    }

    // Inserts a new node after the first occurrence of target
    public boolean insertAfterValue(DoublyNode head, int target, int newValue) {
        if (head == null) {
            return false;
        }
        DoublyNode current = head;
        while (current != null) {
            if (current.value == target) {
                DoublyNode newNode = new DoublyNode(newValue);
                newNode.next = current.next;
                newNode.prev = current;
                if (current.next != null) {
                    current.next.prev = newNode;
                }
                current.next = newNode;
                return true;
            }
            current = current.next;
        }
        return false;
    }

    // Prints the list in forward direction
    public String printForward(DoublyNode head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        DoublyNode current = head;
        while (current != null) {
            result.append(current.value);
            if (current.next != null) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        DoublyNode head;
        int target;
        int newValue;

        TestCase(int[] values, int target, int newValue) {
            this.target = target;
            this.newValue = newValue;
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new DoublyNode(values[0]);
            DoublyNode current = head;
            for (int i = 1; i < values.length; i++) {
                DoublyNode newNode = new DoublyNode(values[i]);
                newNode.prev = current;
                current.next = newNode;
                current = newNode;
            }
        }
    }

    // Main method to test insert after value
    public static void main(String[] args) {
        InsertAfterValue inserter = new InsertAfterValue();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3}, 2, 4),      // Target exists
            new TestCase(new int[]{1, 2, 3}, 5, 4),      // Target does not exist
            new TestCase(new int[]{}, 1, 2),             // Empty list
            new TestCase(new int[]{5}, 5, 6),            // Single node, target exists
            new TestCase(new int[]{1, 1, 1}, 1, 2)       // Duplicates, insert after first
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + inserter.printForward(test.head));
            System.out.println("Insert " + test.newValue + " after " + test.target);
            boolean result = inserter.insertAfterValue(test.head, test.target, test.newValue);
            System.out.println("Insertion successful: " + result);
            System.out.println("Updated list: " + inserter.printForward(test.head) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3
Insert 4 after 2
Insertion successful: true
Updated list: 1 2 4 3

Test case 2:
Input list: 1 2 3
Insert 4 after 5
Insertion successful: false
Updated list: 1 2 3

Test case 3:
Input list: []
Insert 2 after 1
Insertion successful: false
Updated list: []

Test case 4:
Input list: 5
Insert 6 after 5
Insertion successful: true
Updated list: 5 6

Test case 5:
Input list: 1 1 1
Insert 2 after 1
Insertion successful: true
Updated list: 1 2 1 1

Explanation:

  • Test case 1: Inserts 4 after first 2, resulting in 1↔2↔4↔3.
  • Test case 2: Target 5 not found, list unchanged, returns false.
  • Test case 3: Empty list, no insertion, returns false.
  • Test case 4: Inserts 6 after 5 in single-node list, resulting in 5↔6.
  • Test case 5: Inserts 2 after first 1 in 1↔1↔1, resulting in 1↔2↔1↔1.

How It Works

  • DoublyNode: Stores an integer value, a next pointer, and a prev pointer.
  • insertAfterValue:
    • Returns false for empty lists.
    • Traverses list to find first node with target value.
    • If found, inserts new node, updating next and prev pointers, returns true.
    • If not found, returns false.
  • printForward: Traverses list using next pointers, returns space-separated string or "[]".
  • Example Trace (Test case 1):
    • Input: 1↔2↔3, target=2, newValue=4.
    • current=1, no match, move to 2.
    • current=2, match: create newNode(4), newNode.next=3, newNode.prev=2, 3.prev=newNode, 2.next=newNode.
    • Result: 1↔2↔4↔3, return true.
  • Main Method: Tests target exists, target does not exist, empty list, single node, and duplicates.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Insert After ValueO(n)O(1)
Print ForwardO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for insertAfterValue (traverse to find target); O(n) for printForward (traverse list).
  • Space complexity: O(1) for insertAfterValue (constant pointers); O(n) for printForward (StringBuilder).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: Use the doubly linked list’s prev pointers to easily link the new node to the next node’s previous pointer, ensuring bidirectional consistency. Test with duplicates to verify insertion after the first occurrence.

⚠ Warning: Update both next and prev pointers carefully to maintain the doubly linked list structure. Check for null pointers to handle edge cases like empty lists or inserting at the end.

Reverse a Doubly Linked List

Problem Statement

Write a Java program that reverses a doubly linked list. The doubly linked list consists of nodes, each containing an integer value, a reference to the next node, and a reference to the previous node. The program should reverse the order of the nodes (e.g., 1↔2↔3 becomes 3↔2↔1) by swapping the next and prev pointers of each node and updating the head. Test the implementation with doubly linked lists of varying sizes, including empty lists, single-node lists, and multi-node lists. You can visualize this as rearranging a chain of numbered cards, flipping their order so the last card becomes the first, with each card linked to both its predecessor and successor.

Input:

  • A doubly linked list of integers (e.g., 1↔2↔3↔4). Output: The reversed doubly linked list as a string in forward order (e.g., "4 3 2 1"). Constraints:
  • The list size is between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • The list may be empty. Example:
  • Input: 1↔2↔3↔4
  • Output: "4 3 2 1"
  • Explanation: The list 1↔2↔3↔4 is reversed to 4↔3↔2↔1.
  • Input: []
  • Output: "[]"
  • Explanation: An empty list remains empty.
  • Input: 5
  • Output: "5"
  • Explanation: A single-node list is unchanged.

Pseudocode

CLASS DoublyNode
    SET value to integer
    SET next to DoublyNode (null by default)
    SET prev to DoublyNode (null by default)
ENDCLASS

FUNCTION reverseList(head)
    IF head is null THEN
        RETURN null
    ENDIF
    SET current to head
    SET newHead to null
    WHILE current is not null
        SET temp to current.prev
        SET current.prev to current.next
        SET current.next to temp
        SET newHead to current
        SET current to current.prev
    ENDWHILE
    RETURN newHead
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    WHILE current is not null
        APPEND current.value to result
        IF current.next is not null THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of doubly linked lists
    FOR each testCase in testCases
        PRINT test case details
        CALL reverseList(testCase.head)
        PRINT reversed list using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a DoublyNode class with: a. An integer value. b. A next pointer to the next node. c. A prev pointer to the previous node.
  2. In reverseList: a. If head is null, return null (empty list). b. Initialize current to head, newHead to track the new head. c. While current is not null:
    • Swap current.prev and current.next.
    • Update newHead to current (last node processed becomes head).
    • Move current to the next node (now in prev due to swap). d. Return newHead.
  3. In toString: a. If head is null, return "[]". b. Traverse the list forward, append each value to a StringBuilder with spaces. c. Return the string representation.
  4. In main, test with empty, single-node, and multi-node doubly linked lists.

Java Implementation

public class ReverseDoublyLinkedList {
    // Node class for the doubly linked list
    static class DoublyNode {
        int value;
        DoublyNode next;
        DoublyNode prev;

        DoublyNode(int value) {
            this.value = value;
            this.next = null;
            this.prev = null;
        }
    }

    // Reverses the doubly linked list
    public DoublyNode reverseList(DoublyNode head) {
        if (head == null) {
            return null;
        }
        DoublyNode current = head;
        DoublyNode newHead = null;
        while (current != null) {
            // Swap prev and next pointers
            DoublyNode temp = current.prev;
            current.prev = current.next;
            current.next = temp;
            // Update newHead to current node (last node processed)
            newHead = current;
            // Move to next node (now in prev due to swap)
            current = current.prev;
        }
        return newHead;
    }

    // Converts doubly linked list to string for output
    public String toString(DoublyNode head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        DoublyNode current = head;
        while (current != null) {
            result.append(current.value);
            if (current.next != null) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        DoublyNode head;

        TestCase(int[] values) {
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new DoublyNode(values[0]);
            DoublyNode current = head;
            for (int i = 1; i < values.length; i++) {
                DoublyNode newNode = new DoublyNode(values[i]);
                newNode.prev = current;
                current.next = newNode;
                current = newNode;
            }
        }
    }

    // Main method to test doubly linked list reversal
    public static void main(String[] args) {
        ReverseDoublyLinkedList reverser = new ReverseDoublyLinkedList();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3, 4}), // Multi-node list
            new TestCase(new int[]{}),           // Empty list
            new TestCase(new int[]{5}),          // Single node
            new TestCase(new int[]{1, 1, 1}),    // List with duplicates
            new TestCase(new int[]{10, 20})      // Two nodes
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + reverser.toString(test.head));
            DoublyNode reversed = reverser.reverseList(test.head);
            System.out.println("Reversed list: " + reverser.toString(reversed) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3 4
Reversed list: 4 3 2 1

Test case 2:
Input list: []
Reversed list: []

Test case 3:
Input list: 5
Reversed list: 5

Test case 4:
Input list: 1 1 1
Reversed list: 1 1 1

Test case 5:
Input list: 10 20
Reversed list: 20 10

Explanation:

  • Test case 1: Reverses 1↔2↔3↔4 to 4↔3↔2↔1.
  • Test case 2: Empty list remains empty.
  • Test case 3: Single node 5 is unchanged.
  • Test case 4: List with duplicates 1↔1↔1 reverses to 1↔1↔1.
  • Test case 5: Two nodes 10↔20 reverse to 20↔10.

How It Works

  • DoublyNode: Stores an integer value, a next pointer, and a prev pointer.
  • reverseList:
    • Returns null for empty lists.
    • Iteratively swaps prev and next pointers for each node.
    • Tracks the new head (last node processed).
    • Moves to the next node using the swapped prev pointer.
  • toString: Converts the list to a space-separated string, traversing forward, returning "[]" for empty lists.
  • Example Trace (Test case 1):
    • Input: 1↔2↔3↔4.
    • Initial: current=1, newHead=null.
    • Step 1: 1(next=null,prev=2), newHead=1, current=2.
    • Step 2: 2(next=1,prev=3), newHead=2, current=3.
    • Step 3: 3(next=2,prev=4), newHead=3, current=4.
    • Step 4: 4(next=3,prev=null), newHead=4, current=null.
    • Return: 4↔3↔2↔1.
  • Main Method: Tests multi-node, empty, single-node, duplicate, and two-node lists.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Reverse ListO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for reverseList (single pass); O(n) for toString (traverse list).
  • Space complexity: O(1) for reverseList (constant pointers); O(n) for toString (StringBuilder).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: In a doubly linked list, reversing is simplified by swapping next and prev pointers. Always update the head to the last node processed to maintain the list structure.

⚠ Warning: Carefully swap next and prev pointers to avoid losing references. Ensure null checks for edge cases like empty or single-node lists.

Insert After Value in Circular Linked List

Problem Statement

Write a Java program that adds a method to insert a new node with a given value after the first occurrence of a specified target value in a circular linked list. A circular linked list is a singly linked list where the last node’s next pointer points to the head, forming a cycle. The method should insert the new node by updating the next pointers to maintain the circular structure and return true if the insertion is successful (target value found) or false if the target value is not found. Test the implementation with cases where the target value exists in the list and where it does not, including empty lists and single-node lists. You can visualize this as adding a new numbered card to a circular chain of cards, placing it right after the first card with a specific number.

Input:

  • A circular linked list of integers (e.g., 1→2→3→1, where 3→1 forms the cycle), a target value (e.g., 2), and a new value to insert (e.g., 4). Output: A boolean indicating whether the insertion was successful, and the updated list printed as a string, listing nodes from the head until just before it cycles (e.g., "1 2 4 3"). Constraints:
  • The list size is between 0 and 10^5.
  • Node values and the new value are integers in the range [-10^9, 10^9].
  • The list may be empty or not contain the target value. Example:
  • Input: List = 1→2→3→1, target = 2, newValue = 4
  • Output: true, "1 2 4 3"
  • Explanation: Inserts 4 after the first 2, resulting in 1→2→4→3→1.
  • Input: List = 1→2→3→1, target = 5, newValue = 4
  • Output: false, "1 2 3"
  • Explanation: Target 5 not found, list unchanged.
  • Input: List = [], target = 1, newValue = 2
  • Output: false, "[]"
  • Explanation: Empty list, no insertion possible.

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION insertAfterValue(head, target, newValue)
    IF head is null THEN
        RETURN false
    ENDIF
    SET current to head
    SET found to false
    REPEAT
        IF current.value equals target THEN
            SET found to true
            BREAK
        ENDIF
        SET current to current.next
    UNTIL current equals head
    IF not found THEN
        RETURN false
    ENDIF
    CREATE newNode as new Node(newValue)
    SET newNode.next to current.next
    SET current.next to newNode
    RETURN true
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    SET visited as new Set
    WHILE current is not null AND current not in visited
        ADD current to visited
        APPEND current.value to result
        IF current.next is not head THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of test cases (list values, target, newValue)
    FOR each testCase in testCases
        PRINT test case details
        CALL insertAfterValue(testCase.head, testCase.target, testCase.newValue)
        PRINT insertion result and updated list using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. A next pointer to the next node.
  2. In insertAfterValue: a. If the list is empty, return false. b. Traverse the list starting from head until the target value is found or the traversal returns to head. c. If target is not found, return false. d. If target is found:
    • Create a new node with newValue.
    • Set newNode.next to current.next.
    • Set current.next to newNode.
    • Return true.
  3. In toString: a. If head is null, return "[]". b. Traverse from head, use a Set to avoid cycling, append values with spaces. c. Return the string representation.
  4. In main, test with cases where the target exists, does not exist, and edge cases like empty or single-node lists.

Java Implementation

import java.util.*;

public class InsertAfterValueCircular {
    // Node class for the circular linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Inserts a new node after the first occurrence of target
    public boolean insertAfterValue(Node head, int target, int newValue) {
        if (head == null) {
            return false;
        }
        Node current = head;
        boolean found = false;
        do {
            if (current.value == target) {
                found = true;
                break;
            }
            current = current.next;
        } while (current != head);
        if (!found) {
            return false;
        }
        Node newNode = new Node(newValue);
        newNode.next = current.next;
        current.next = newNode;
        return true;
    }

    // Converts circular linked list to string
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        Set<Node> visited = new HashSet<>();
        while (current != null && !visited.contains(current)) {
            visited.add(current);
            result.append(current.value);
            if (current.next != head) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node head;
        int target;
        int newValue;

        TestCase(int[] values, int target, int newValue) {
            this.target = target;
            this.newValue = newValue;
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new Node(values[0]);
            Node current = head;
            for (int i = 1; i < values.length; i++) {
                current.next = new Node(values[i]);
                current = current.next;
            }
            // Make circular
            current.next = head;
        }
    }

    // Main method to test insert after value
    public static void main(String[] args) {
        InsertAfterValueCircular inserter = new InsertAfterValueCircular();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3}, 2, 4),      // Target exists
            new TestCase(new int[]{1, 2, 3}, 5, 4),      // Target does not exist
            new TestCase(new int[]{}, 1, 2),             // Empty list
            new TestCase(new int[]{5}, 5, 6),            // Single node, target exists
            new TestCase(new int[]{1, 1, 1}, 1, 2)       // Duplicates, insert after first
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + inserter.toString(test.head));
            System.out.println("Insert " + test.newValue + " after " + test.target);
            boolean result = inserter.insertAfterValue(test.head, test.target, test.newValue);
            System.out.println("Insertion successful: " + result);
            System.out.println("Updated list: " + inserter.toString(test.head) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3
Insert 4 after 2
Insertion successful: true
Updated list: 1 2 4 3

Test case 2:
Input list: 1 2 3
Insert 4 after 5
Insertion successful: false
Updated list: 1 2 3

Test case 3:
Input list: []
Insert 2 after 1
Insertion successful: false
Updated list: []

Test case 4:
Input list: 5
Insert 6 after 5
Insertion successful: true
Updated list: 5 6

Test case 5:
Input list: 1 1 1
Insert 2 after 1
Insertion successful: true
Updated list: 1 2 1 1

Explanation:

  • Test case 1: Inserts 4 after first 2, resulting in 1→2→4→3→1.
  • Test case 2: Target 5 not found, list unchanged, returns false.
  • Test case 3: Empty list, no insertion, returns false.
  • Test case 4: Inserts 6 after 5 in single-node list, resulting in 5→6→5.
  • Test case 5: Inserts 2 after first 1 in 1→1→1→1, resulting in 1→2→1→1→1.

How It Works

  • Node: Stores an integer value and a next pointer.
  • insertAfterValue:
    • Returns false for empty lists.
    • Traverses the list in a loop, stopping at the first node with the target value or when returning to head.
    • If target is not found, returns false.
    • If found, inserts new node, updating next pointers, returns true.
  • toString: Traverses from head, uses a Set to prevent cycling, returns space-separated string or "[]".
  • Example Trace (Test case 1):
    • Input: 1→2→3→1, target=2, newValue=4.
    • current=1, no match, current=2, match.
    • newNode(4).next=3, 2.next=newNode.
    • Result: 1→2→4→3→1, return true.
  • Main Method: Tests target exists, target does not exist, empty list, single node, and duplicates.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Insert After ValueO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for insertAfterValue (traverse to find target); O(n) for toString (traverse list).
  • Space complexity: O(1) for insertAfterValue (constant pointers); O(n) for toString (StringBuilder and Set).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: Use a do-while loop to traverse the circular list and stop at the head to avoid infinite loops. Insert the new node by carefully updating next pointers to maintain the circular structure.

⚠ Warning: Check for the target value in one full cycle to handle cases where it doesn’t exist. Ensure proper handling of edge cases like empty or single-node lists to avoid null pointer issues.

Josephus Problem

Problem Statement

Write a Java program to solve the Josephus problem using a circular linked list. The Josephus problem involves n people standing in a circle, numbered from 1 to n, where every k-th person is eliminated until only one remains. The circular linked list is a singly linked list where the last node’s next pointer points to the head, forming a cycle. The program should return the value of the last person remaining and test the solution with different values of k and list sizes, including edge cases like single-person lists and k=1. You can visualize this as a circle of numbered cards where every k-th card is removed until only one card remains.

Input:

  • A circular linked list of integers representing people (e.g., 1→2→3→4→1 for n=4) and an integer k (the step size for elimination). Output: The value of the last person remaining (e.g., 3 for n=4, k=2). Constraints:
  • The list size n is between 1 and 10^5.
  • Node values are integers from 1 to n.
  • k is a positive integer (k ≥ 1). Example:
  • Input: List = 1→2→3→4→1, k = 2
  • Output: 3
  • Explanation: Eliminate every 2nd person: 2, 4, 1, leaving 3.
  • Input: List = 1→1, k = 1
  • Output: 1
  • Explanation: Single person remains.
  • Input: List = 1→2→3→1, k = 3
  • Output: 1
  • Explanation: Eliminate every 3rd person: 3, 2, leaving 1.

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION solveJosephus(head, k)
    IF head is null THEN
        RETURN -1
    ENDIF
    IF head.next is head THEN
        RETURN head.value
    ENDIF
    SET current to head
    WHILE current.next is not current
        FOR i from 1 to k-1
            SET current to current.next
        ENDFOR
        SET nextNode to current.next
        SET current.next to nextNode.next
        SET current to current.next
    ENDWHILE
    RETURN current.value
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    SET visited as new Set
    WHILE current is not null AND current not in visited
        ADD current to visited
        APPEND current.value to result
        IF current.next is not head THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of test cases (list values, k)
    FOR each testCase in testCases
        PRINT test case details
        CALL solveJosephus(testCase.head, testCase.k)
        PRINT last person remaining
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value (person number). b. A next pointer to the next node.
  2. In solveJosephus: a. If the list is empty, return -1. b. If the list has one node, return its value. c. Set current to head. d. While more than one node remains:
    • Traverse k-1 steps to find the node before the one to eliminate.
    • Remove the k-th node by updating current.next to skip it.
    • Move current to the next node. e. Return the value of the last remaining node.
  3. In toString: a. If head is null, return "[]". b. Traverse from head, use a Set to avoid cycling, append values with spaces. c. Return the string representation.
  4. In main, test with different list sizes (n=1, n>1) and k values (k=1, k>1), including edge cases.

Java Implementation

import java.util.*;

public class JosephusProblem {
    // Node class for the circular linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Solves the Josephus problem
    public int solveJosephus(Node head, int k) {
        if (head == null) {
            return -1;
        }
        if (head.next == head) {
            return head.value;
        }
        Node current = head;
        while (current.next != current) {
            // Move to the node before the one to be eliminated
            for (int i = 0; i < k - 1; i++) {
                current = current.next;
            }
            // Remove the k-th node
            Node nextNode = current.next;
            current.next = nextNode.next;
            current = current.next;
        }
        return current.value;
    }

    // Converts circular linked list to string
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        Set<Node> visited = new HashSet<>();
        while (current != null && !visited.contains(current)) {
            visited.add(current);
            result.append(current.value);
            if (current.next != head) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node head;
        int k;

        TestCase(int n, int k) {
            this.k = k;
            if (n == 0) {
                head = null;
                return;
            }
            head = new Node(1);
            Node current = head;
            for (int i = 2; i <= n; i++) {
                current.next = new Node(i);
                current = current.next;
            }
            // Make circular
            current.next = head;
        }
    }

    // Main method to test Josephus problem
    public static void main(String[] args) {
        JosephusProblem solver = new JosephusProblem();

        // Test cases
        TestCase[] testCases = {
            new TestCase(4, 2),     // n=4, k=2
            new TestCase(3, 3),     // n=3, k=3
            new TestCase(1, 1),     // Single node
            new TestCase(5, 1),     // k=1
            new TestCase(7, 4)      // Larger n, k
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + solver.toString(test.head));
            System.out.println("k: " + test.k);
            int result = solver.solveJosephus(test.head, test.k);
            System.out.println("Last person remaining: " + result + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3 4
k: 2
Last person remaining: 3

Test case 2:
Input list: 1 2 3
k: 3
Last person remaining: 1

Test case 3:
Input list: 1
k: 1
Last person remaining: 1

Test case 4:
Input list: 1 2 3 4 5
k: 1
Last person remaining: 5

Test case 5:
Input list: 1 2 3 4 5 6 7
k: 4
Last person remaining: 2

Explanation:

  • Test case 1: n=4, k=2, eliminate 2, 4, 1, leaving 3.
  • Test case 2: n=3, k=3, eliminate 3, 2, leaving 1.
  • Test case 3: n=1, k=1, single node 1 remains.
  • Test case 4: n=5, k=1, eliminate 1, 2, 3, 4, leaving 5.
  • Test case 5: n=7, k=4, eliminate 4, 1, 6, 5, 7, 3, leaving 2.

How It Works

  • Node: Stores an integer value (person number) and a next pointer.
  • solveJosephus:
    • Returns -1 for empty lists, head value for single-node lists.
    • Iterates while more than one node remains:
      • Traverses k-1 steps to find the node before the k-th.
      • Removes k-th node by updating next pointer.
      • Moves to the next node.
    • Returns the value of the last node.
  • toString: Traverses from head, uses a Set to prevent cycling, returns space-separated string or "[]".
  • Example Trace (Test case 1):
    • Input: 1→2→3→4→1, k=2.
    • Step 1: current=1, eliminate 2, list=1→3→4→1.
    • Step 2: current=3, eliminate 4, list=1→3→1.
    • Step 3: current=1, eliminate 1, list=3→3.
    • Return: 3.
  • Main Method: Tests different n and k, including single node and k=1.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Solve JosephusO(n * k)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n * k) for solveJosephus (n-1 eliminations, each up to k steps); O(n) for toString (traverse list).
  • Space complexity: O(1) for solveJosephus (constant pointers); O(n) for toString (StringBuilder and Set).
  • Worst case: O(n * k) time, O(n) space for output with large lists and k.

✅ Tip: Use the circular linked list’s structure to efficiently eliminate nodes by updating next pointers. Keep track of the current node to continue from the correct position after each elimination.

⚠ Warning: Ensure k is handled correctly for each elimination step, and avoid infinite loops by checking for single-node conditions. Handle edge cases like n=1 or empty lists.

Rotate the List

Problem Statement

Write a Java program that rotates a circular linked list by k positions, moving the head k nodes forward. A circular linked list is a singly linked list where the last node’s next pointer points to the head, forming a cycle. The rotation shifts the head pointer forward by k nodes, effectively redefining the start of the list while maintaining the circular structure. Test the implementation with different values of k and list sizes, including empty lists, single-node lists, k=0, k equal to the list size, and k greater than the list size. You can visualize this as rotating a ring of numbered beads, where the starting bead (head) moves k positions clockwise, and the ring remains connected.

Input:

  • A circular linked list of integers (e.g., 1→2→3→4→1, where 4→1 forms the cycle) and an integer k (number of positions to rotate). Output: The rotated list as a string, listing nodes from the new head until just before it cycles (e.g., "3 4 1 2" after rotating 1→2→3→4→1 by k=2). Constraints:
  • The list size is between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • k is a non-negative integer (k ≥ 0).
  • The list may be empty. Example:
  • Input: List = 1→2→3→4→1, k = 2
  • Output: "3 4 1 2"
  • Explanation: Rotates head from 1 to 3, list becomes 3→4→1→2→3.
  • Input: List = [], k = 1
  • Output: "[]"
  • Explanation: Empty list remains empty.
  • Input: List = 1→1, k = 3
  • Output: "1"
  • Explanation: Single-node list is unchanged (rotation wraps around).

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION rotateList(head, k)
    IF head is null OR head.next is head THEN
        RETURN head
    ENDIF
    SET length to 1
    SET tail to head
    WHILE tail.next is not head
        INCREMENT length
        SET tail to tail.next
    ENDWHILE
    SET k to k mod length
    IF k equals 0 THEN
        RETURN head
    ENDIF
    SET current to head
    FOR i from 1 to k
        SET current to current.next
    ENDFOR
    SET newHead to current
    SET tail.next to null
    SET current to head
    WHILE current.next is not null
        SET current to current.next
    ENDWHILE
    SET current.next to head
    SET head to newHead
    RETURN head
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    SET visited as new Set
    WHILE current is not null AND current not in visited
        ADD current to visited
        APPEND current.value to result
        IF current.next is not head THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of test cases (list values, k)
    FOR each testCase in testCases
        PRINT test case details
        CALL rotateList(testCase.head, testCase.k)
        PRINT rotated list using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. A next pointer to the next node.
  2. In rotateList: a. If the list is empty or has one node, return head (no rotation needed). b. Find the list length and tail by traversing until tail.next is head. c. Compute effective k as k mod length to handle large k. d. If k=0, return head (no rotation needed). e. Traverse k steps from head to find the new head. f. Break the cycle by setting old tail’s next to null. g. Find new tail (node before new head), set its next to old head. h. Set head to new head, return it.
  3. In toString: a. If head is null, return "[]". b. Traverse from head, use a Set to avoid cycling, append values with spaces. c. Return the string representation.
  4. In main, test with different list sizes (empty, single-node, multi-node) and k values (0, equal to length, greater than length).

Java Implementation

import java.util.*;

public class RotateCircularLinkedList {
    // Node class for the circular linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Rotates the circular linked list by k positions
    public Node rotateList(Node head, int k) {
        if (head == null || head.next == head) {
            return head;
        }
        // Find length and tail
        int length = 1;
        Node tail = head;
        while (tail.next != head) {
            length++;
            tail = tail.next;
        }
        // Normalize k
        k = k % length;
        if (k == 0) {
            return head;
        }
        // Find new head
        Node current = head;
        for (int i = 0; i < k; i++) {
            current = current.next;
        }
        Node newHead = current;
        // Break cycle
        tail.next = null;
        // Find new tail (node before newHead)
        current = head;
        while (current.next != null) {
            current = current.next;
        }
        // Reconnect to form new cycle
        current.next = head;
        head = newHead;
        return head;
    }

    // Converts circular linked list to string
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        Set<Node> visited = new HashSet<>();
        while (current != null && !visited.contains(current)) {
            visited.add(current);
            result.append(current.value);
            if (current.next != head) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node head;
        int k;

        TestCase(int[] values, int k) {
            this.k = k;
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new Node(values[0]);
            Node current = head;
            for (int i = 1; i < values.length; i++) {
                current.next = new Node(values[i]);
                current = current.next;
            }
            // Make circular
            current.next = head;
        }
    }

    // Main method to test circular linked list rotation
    public static void main(String[] args) {
        RotateCircularLinkedList rotator = new RotateCircularLinkedList();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3, 4}, 2),     // Multi-node, k < length
            new TestCase(new int[]{}, 1),               // Empty list
            new TestCase(new int[]{5}, 3),             // Single node
            new TestCase(new int[]{1, 2, 3}, 3),       // k = length
            new TestCase(new int[]{1, 2, 3, 4, 5}, 7)  // k > length
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + rotator.toString(test.head));
            System.out.println("Rotate by k=" + test.k);
            Node rotated = rotator.rotateList(test.head, test.k);
            System.out.println("Rotated list: " + rotator.toString(rotated) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3 4
Rotate by k=2
Rotated list: 3 4 1 2

Test case 2:
Input list: []
Rotate by k=1
Rotated list: []

Test case 3:
Input list: 5
Rotate by k=3
Rotated list: 5

Test case 4:
Input list: 1 2 3
Rotate by k=3
Rotated list: 1 2 3

Test case 5:
Input list: 1 2 3 4 5
Rotate by k=7
Rotated list: 3 4 5 1 2

Explanation:

  • Test case 1: Rotates 1→2→3→4→1 by k=2, new head=3, outputs "3 4 1 2".
  • Test case 2: Empty list, no rotation, outputs "[]".
  • Test case 3: Single node 5→5, k=3, no change, outputs "5".
  • Test case 4: Rotates 1→2→3→1 by k=3 (length), no change, outputs "1 2 3".
  • Test case 5: Rotates 1→2→3→4→5→1 by k=7 (7 mod 5 = 2), new head=3, outputs "3 4 5 1 2".

How It Works

  • Node: Stores an integer value and a next pointer.
  • rotateList:
    • Handles edge cases: empty list or single node returns unchanged.
    • Finds list length and tail by traversing to the node where next=head.
    • Normalizes k using k mod length to handle large k.
    • If k=0, returns head.
    • Traverses k steps to find new head.
    • Breaks cycle at old tail, reconnects new tail to old head.
  • toString: Traverses from head, uses a Set to prevent cycling, returns space-separated string or "[]".
  • Example Trace (Test case 1):
    • Input: 1→2→3→4→1, k=2.
    • length=4, tail=4, k=2.
    • current=3 (after 2 steps).
    • newHead=3, tail.next=null → 1→2→3, 4.
    • new tail=2, 2.next=1, head=3.
    • Result: 3→4→1→2→3, outputs "3 4 1 2".
  • Main Method: Tests multi-node, empty, single-node, k=length, and k>length cases.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Rotate ListO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for rotateList (traverse to find length and new head); O(n) for toString (traverse list).
  • Space complexity: O(1) for rotateList (constant pointers); O(n) for toString (StringBuilder and Set).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: Normalize k using modulo list length to handle large k efficiently. Use a tail pointer to simplify cycle reconnection after rotation.

⚠ Warning: Ensure the cycle is broken and reconnected correctly to avoid infinite loops. Handle edge cases like empty lists or k=0 to prevent unnecessary operations.

Round-Robin Scheduler

Problem Statement

Write a Java program that simulates a round-robin scheduler using a circular linked list. Each node in the list represents a task with a string name. The scheduler should support adding tasks (insert at tail), cycling to the next task (move head to next node), and removing the current task (delete head), maintaining the circular structure. The program should track the current task (head) and handle operations in a round-robin fashion, where cycling moves to the next task in sequence. Test the implementation with sequences of operations, including adding tasks, cycling through them, removing tasks, and handling edge cases like empty lists and single-task lists. You can visualize this as a circular queue of tasks in a CPU scheduler, where tasks are processed one after another in a loop, and completed tasks are removed.

Input:

  • A sequence of operations, where each operation is:
    • addTask(task): Add a task (string) at the tail of the list.
    • cycle(): Move to the next task, return its name or "null" if empty.
    • removeTask(): Remove the current task (head), return its name or "null" if empty.
    • printTasks(): Print the list of tasks from the current head until just before it cycles. Output: For each operation, print the action performed (e.g., "Added task", "Cycled to task", "Removed task", or the task list). Return "null" for cycle or remove on an empty list. Constraints:
  • The list size is between 0 and 10^5.
  • Task names are non-empty strings of length up to 100 characters.
  • The list may be empty. Example:
  • Input: Operations = [addTask("Task1"), addTask("Task2"), addTask("Task3"), printTasks, cycle, printTasks, removeTask, printTasks]
  • Output:
    Added Task1
    Added Task2
    Added Task3
    Tasks: Task1 Task2 Task3
    Cycled to Task2
    Tasks: Task2 Task3 Task1
    Removed Task2
    Tasks: Task3 Task1
    
  • Input: Operations = [cycle, printTasks]
  • Output:
    Cycled to null
    Tasks: []
    

Pseudocode

CLASS Node
    SET task to string
    SET next to Node (null by default)
ENDCLASS

CLASS RoundRobinScheduler
    SET head to null

    FUNCTION addTask(task)
        CREATE newNode as new Node(task)
        IF head is null THEN
            SET head to newNode
            SET newNode.next to head
        ELSE
            SET current to head
            WHILE current.next is not head
                SET current to current.next
            ENDWHILE
            SET current.next to newNode
            SET newNode.next to head
        ENDIF
    ENDFUNCTION

    FUNCTION cycle()
        IF head is null THEN
            RETURN "null"
        ENDIF
        SET head to head.next
        RETURN head.task
    ENDFUNCTION

    FUNCTION removeTask()
        IF head is null THEN
            RETURN "null"
        ENDIF
        IF head.next is head THEN
            SET task to head.task
            SET head to null
            RETURN task
        ENDIF
        SET task to head.task
        SET current to head
        WHILE current.next is not head
            SET current to current.next
        ENDWHILE
        SET current.next to head.next
        SET head to head.next
        RETURN task
    ENDFUNCTION

    FUNCTION toString()
        IF head is null THEN
            RETURN "[]"
        ENDIF
        CREATE result as new StringBuilder
        SET current to head
        SET visited as new Set
        WHILE current is not null AND current not in visited
            ADD current to visited
            APPEND current.task to result
            IF current.next is not head THEN
                APPEND " " to result
            ENDIF
            SET current to current.next
        ENDWHILE
        RETURN result as string
    ENDFUNCTION
ENDCLASS

FUNCTION testScheduler(operations)
    CREATE scheduler as new RoundRobinScheduler
    FOR each operation in operations
        IF operation.type equals "addTask" THEN
            CALL scheduler.addTask(operation.task)
            PRINT added task
        ELSE IF operation.type equals "cycle" THEN
            SET task to scheduler.cycle()
            PRINT cycled to task
        ELSE IF operation.type equals "removeTask" THEN
            SET task to scheduler.removeTask()
            PRINT removed task
        ELSE IF operation.type equals "print" THEN
            PRINT tasks using scheduler.toString
        ENDIF
    ENDFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL testScheduler(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. A string task for the task name. b. A next pointer to the next node.
  2. Define a RoundRobinScheduler class with: a. A head pointer to track the current task. b. addTask: Add node at tail, connect to head to maintain cycle. c. cycle: Move head to next node, return task name or "null". d. removeTask: Remove head, update cycle, return task name or "null". e. toString: Print tasks from head until cycle, return "[]" if empty.
  3. In testScheduler: a. Create a RoundRobinScheduler. b. For each operation:
    • addTask: Call addTask, print action.
    • cycle: Call cycle, print task name.
    • removeTask: Call removeTask, print task name.
    • print: Call toString, print task list.
  4. In main, test with sequences including adds, cycles, removes, and edge cases.

Java Implementation

import java.util.*;

public class RoundRobinScheduler {
    // Node class for the circular linked list
    static class Node {
        String task;
        Node next;

        Node(String task) {
            this.task = task;
            this.next = null;
        }
    }

    // RoundRobinScheduler class to simulate task scheduling
    static class Scheduler {
        private Node head;

        public void addTask(String task) {
            Node newNode = new Node(task);
            if (head == null) {
                head = newNode;
                newNode.next = head;
            } else {
                Node current = head;
                while (current.next != head) {
                    current = current.next;
                }
                current.next = newNode;
                newNode.next = head;
            }
        }

        public String cycle() {
            if (head == null) {
                return "null";
            }
            head = head.next;
            return head.task;
        }

        public String removeTask() {
            if (head == null) {
                return "null";
            }
            String task = head.task;
            if (head.next == head) {
                head = null;
                return task;
            }
            Node current = head;
            while (current.next != head) {
                current = current.next;
            }
            current.next = head.next;
            head = head.next;
            return task;
        }

        public String toString() {
            if (head == null) {
                return "[]";
            }
            StringBuilder result = new StringBuilder();
            Node current = head;
            Set<Node> visited = new HashSet<>();
            while (current != null && !visited.contains(current)) {
                visited.add(current);
                result.append(current.task);
                if (current.next != head) {
                    result.append(" ");
                }
                current = current.next;
            }
            return result.toString();
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String task;

        Operation(String type, String task) {
            this.type = type;
            this.task = task;
        }
    }

    // Tests round-robin scheduler operations
    public void testScheduler(List<Operation> operations) {
        Scheduler scheduler = new Scheduler();
        for (Operation op : operations) {
            if (op.type.equals("addTask")) {
                scheduler.addTask(op.task);
                System.out.println("Added " + op.task);
            } else if (op.type.equals("cycle")) {
                String task = scheduler.cycle();
                System.out.println("Cycled to " + task);
            } else if (op.type.equals("removeTask")) {
                String task = scheduler.removeTask();
                System.out.println("Removed " + task);
            } else if (op.type.equals("print")) {
                System.out.println("Tasks: " + scheduler.toString());
            }
        }
    }

    // Main method to test round-robin scheduler
    public static void main(String[] args) {
        RoundRobinScheduler manager = new RoundRobinScheduler();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();
        
        // Test case 1: Normal operations
        testCases.add(Arrays.asList(
            new Operation("addTask", "Task1"),
            new Operation("addTask", "Task2"),
            new Operation("addTask", "Task3"),
            new Operation("print", null),
            new Operation("cycle", null),
            new Operation("print", null),
            new Operation("removeTask", null),
            new Operation("print", null)
        ));
        
        // Test case 2: Empty list
        testCases.add(Arrays.asList(
            new Operation("cycle", null),
            new Operation("removeTask", null),
            new Operation("print", null)
        ));
        
        // Test case 3: Single task
        testCases.add(Arrays.asList(
            new Operation("addTask", "Task1"),
            new Operation("print", null),
            new Operation("cycle", null),
            new Operation("print", null),
            new Operation("removeTask", null),
            new Operation("print", null)
        ));
        
        // Test case 4: Multiple cycles and removes
        testCases.add(Arrays.asList(
            new Operation("addTask", "Task1"),
            new Operation("addTask", "Task2"),
            new Operation("addTask", "Task3"),
            new Operation("cycle", null),
            new Operation("cycle", null),
            new Operation("print", null),
            new Operation("removeTask", null),
            new Operation("print", null)
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            manager.testScheduler(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Added Task1
Added Task2
Added Task3
Tasks: Task1 Task2 Task3
Cycled to Task2
Tasks: Task2 Task3 Task1
Removed Task2
Tasks: Task3 Task1

Test case 2:
Cycled to null
Removed null
Tasks: []

Test case 3:
Added Task1
Tasks: Task1
Cycled to Task1
Tasks: Task1
Removed Task1
Tasks: []

Test case 4:
Added Task1
Added Task2
Added Task3
Cycled to Task2
Cycled to Task3
Tasks: Task3 Task1 Task2
Removed Task3
Tasks: Task1 Task2

Explanation:

  • Test case 1: Adds Task1, Task2, Task3, prints "Task1 Task2 Task3", cycles to Task2, prints "Task2 Task3 Task1", removes Task2, prints "Task3 Task1".
  • Test case 2: Cycles and removes on empty list, prints "null" and "[]".
  • Test case 3: Adds Task1, prints "Task1", cycles to Task1, prints "Task1", removes Task1, prints "[]".
  • Test case 4: Adds Task1, Task2, Task3, cycles twice to Task3, prints "Task3 Task1 Task2", removes Task3, prints "Task1 Task2".

How It Works

  • Node: Stores a string task name and a next pointer.
  • Scheduler:
    • addTask: Adds node at tail, connects to head, O(n).
    • cycle: Moves head to next node, returns task or "null", O(1).
    • removeTask: Removes head, updates cycle, returns task or "null", O(n).
    • toString: Traverses from head, uses Set to prevent cycling, returns space-separated string or "[]".
  • testScheduler: Executes operations, printing actions and results.
  • Example Trace (Test case 1):
    • addTask("Task1"): head=Task1→Task1.
    • addTask("Task2"): head=Task1→Task2→Task1.
    • addTask("Task3"): head=Task1→Task2→Task3→Task1.
    • print: "Task1 Task2 Task3".
    • cycle: head=Task2, returns "Task2".
    • print: "Task2 Task3 Task1".
    • removeTask: Removes Task2, head=Task3→Task1→Task3, returns "Task2".
    • print: "Task3 Task1".
  • Main Method: Tests normal operations, empty list, single task, and multiple cycles/removes.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Add TaskO(n)O(1)
CycleO(1)O(1)
Remove TaskO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for addTask and removeTask (traverse to tail); O(1) for cycle; O(n) for toString (traverse list).
  • Space complexity: O(1) for addTask, cycle, removeTask (constant pointers); O(n) for toString (StringBuilder and Set).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: Use a circular linked list to naturally model the round-robin cycling behavior. Maintain the head pointer to track the current task and ensure the tail connects to the head for circularity.

⚠ Warning: Update next pointers carefully during task removal to maintain the circular structure. Handle edge cases like empty lists and single-task lists to avoid null pointer issues.

Split Circular List

Problem Statement

Write a Java program that splits a circular linked list into two circular linked lists of roughly equal size. A circular linked list is a singly linked list where the last node’s next pointer points to the head, forming a cycle. The splitting should divide the nodes as evenly as possible, with one list having at most one more node than the other for odd-sized lists, and each resulting list maintaining its circular structure. Test the implementation with even-sized lists, odd-sized lists, empty lists, and single-node lists. You can visualize this as dividing a ring of numbered beads into two smaller rings, each containing about half the beads, with both rings remaining closed loops.

Input:

  • A circular linked list of integers (e.g., 1→2→3→4→1, where 4→1 forms the cycle). Output: Two circular linked lists as strings, listing nodes from each head until just before it cycles (e.g., "1 2" and "3 4" for the input above). Constraints:
  • The list size is between 0 and 10^5.
  • Node values are integers in the range [-10^9, 10^9].
  • The list may be empty. Example:
  • Input: List = 1→2→3→4→1
  • Output: First list: "1 2", Second list: "3 4"
  • Explanation: Splits into two circular lists of 2 nodes each: 1→2→1 and 3→4→3.
  • Input: List = 1→2→3→1
  • Output: First list: "1 2", Second list: "3"
  • Explanation: Splits into 1→2→1 (2 nodes) and 3→3 (1 node).
  • Input: List = []
  • Output: First list: "[]", Second list: "[]"
  • Explanation: Empty list splits into two empty lists.

Pseudocode

CLASS Node
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

FUNCTION splitList(head)
    IF head is null THEN
        RETURN (null, null)
    ENDIF
    IF head.next is head THEN
        RETURN (head, null)
    ENDIF
    SET length to 1
    SET tail to head
    WHILE tail.next is not head
        INCREMENT length
        SET tail to tail.next
    ENDWHILE
    SET splitPoint to length / 2
    SET current to head
    FOR i from 1 to splitPoint - 1
        SET current to current.next
    ENDFOR
    SET firstHead to head
    SET secondHead to current.next
    SET secondTail to tail
    SET firstTail to current
    SET firstTail.next to firstHead
    SET secondTail.next to secondHead
    RETURN (firstHead, secondHead)
ENDFUNCTION

FUNCTION toString(head)
    IF head is null THEN
        RETURN "[]"
    ENDIF
    CREATE result as new StringBuilder
    SET current to head
    SET visited as new Set
    WHILE current is not null AND current not in visited
        ADD current to visited
        APPEND current.value to result
        IF current.next is not head THEN
            APPEND " " to result
        ENDIF
        SET current to current.next
    ENDWHILE
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of circular linked lists
    FOR each testCase in testCases
        PRINT test case details
        CALL splitList(testCase.head)
        PRINT first and second lists using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. A next pointer to the next node.
  2. In splitList: a. If the list is empty, return (null, null). b. If the list has one node, return (head, null). c. Find the list length and tail by traversing until tail.next is head. d. Compute split point as length / 2. e. Traverse to the node before the split point (first list’s tail). f. Set first list’s head and tail, second list’s head and tail. g. Connect first list’s tail to its head, second list’s tail to its head. h. Return (firstHead, secondHead).
  3. In toString: a. If head is null, return "[]". b. Traverse from head, use a Set to avoid cycling, append values with spaces. c. Return the string representation.
  4. In main, test with even-sized, odd-sized, empty, and single-node circular lists.

Java Implementation

import java.util.*;

public class SplitCircularLinkedList {
    // Node class for the circular linked list
    static class Node {
        int value;
        Node next;

        Node(int value) {
            this.value = value;
            this.next = null;
        }
    }

    // Splits the circular linked list into two circular lists
    public Node[] splitList(Node head) {
        Node[] result = new Node[2];
        if (head == null) {
            result[0] = null;
            result[1] = null;
            return result;
        }
        if (head.next == head) {
            result[0] = head;
            result[1] = null;
            return result;
        }
        // Find length and tail
        int length = 1;
        Node tail = head;
        while (tail.next != head) {
            length++;
            tail = tail.next;
        }
        // Find split point
        int splitPoint = length / 2;
        Node current = head;
        for (int i = 0; i < splitPoint - 1; i++) {
            current = current.next;
        }
        // Set heads and tails
        Node firstHead = head;
        Node secondHead = current.next;
        Node firstTail = current;
        Node secondTail = tail;
        // Form two circular lists
        firstTail.next = firstHead;
        secondTail.next = secondHead;
        result[0] = firstHead;
        result[1] = secondHead;
        return result;
    }

    // Converts circular linked list to string
    public String toString(Node head) {
        if (head == null) {
            return "[]";
        }
        StringBuilder result = new StringBuilder();
        Node current = head;
        Set<Node> visited = new HashSet<>();
        while (current != null && !visited.contains(current)) {
            visited.add(current);
            result.append(current.value);
            if (current.next != head) {
                result.append(" ");
            }
            current = current.next;
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Node head;

        TestCase(int[] values) {
            if (values.length == 0) {
                head = null;
                return;
            }
            head = new Node(values[0]);
            Node current = head;
            for (int i = 1; i < values.length; i++) {
                current.next = new Node(values[i]);
                current = current.next;
            }
            // Make circular
            current.next = head;
        }
    }

    // Main method to test circular linked list splitting
    public static void main(String[] args) {
        SplitCircularLinkedList splitter = new SplitCircularLinkedList();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{1, 2, 3, 4}),     // Even-sized list
            new TestCase(new int[]{1, 2, 3}),        // Odd-sized list
            new TestCase(new int[]{}),               // Empty list
            new TestCase(new int[]{5}),              // Single node
            new TestCase(new int[]{1, 1, 1, 1, 1})   // Odd-sized with duplicates
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            TestCase test = testCases[i];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input list: " + splitter.toString(test.head));
            Node[] result = splitter.splitList(test.head);
            System.out.println("First list: " + splitter.toString(result[0]));
            System.out.println("Second list: " + splitter.toString(result[1]) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input list: 1 2 3 4
First list: 1 2
Second list: 3 4

Test case 2:
Input list: 1 2 3
First list: 1 2
Second list: 3

Test case 3:
Input list: []
First list: []
Second list: []

Test case 4:
Input list: 5
First list: 5
Second list: []

Test case 5:
Input list: 1 1 1 1 1
First list: 1 1 1
Second list: 1 1

Explanation:

  • Test case 1: Splits 1→2→3→4→1 into 1→2→1 (2 nodes) and 3→4→3 (2 nodes).
  • Test case 2: Splits 1→2→3→1 into 1→2→1 (2 nodes) and 3→3 (1 node).
  • Test case 3: Empty list splits into "[]" and "[]".
  • Test case 4: Single node 5→5 splits into 5→5 and "[]".
  • Test case 5: Splits 1→1→1→1→1→1 into 1→1→1→1 (3 nodes) and 1→1→1 (2 nodes).

How It Works

  • Node: Stores an integer value and a next pointer.
  • splitList:
    • Returns (null, null) for empty lists, (head, null) for single-node lists.
    • Finds list length and tail by traversing to tail.next=head.
    • Computes split point as length / 2.
    • Traverses to the node before the split point (first list’s tail).
    • Sets first list’s head/tail and second list’s head/tail.
    • Connects first tail to first head, second tail to second head.
  • toString: Traverses from head, uses a Set to prevent cycling, returns space-separated string or "[]".
  • Example Trace (Test case 1):
    • Input: 1→2→3→4→1.
    • length=4, tail=4, splitPoint=2.
    • current=2 (after 1 step), firstHead=1, firstTail=2, secondHead=3, secondTail=4.
    • firstTail.next=firstHead: 1→2→1.
    • secondTail.next=secondHead: 3→4→3.
    • Returns (1, 3), outputs "1 2" and "3 4".
  • Main Method: Tests even-sized, odd-sized, empty, single-node, and duplicate lists.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Split ListO(n)O(1)
To StringO(n)O(n)

Note:

  • n is the number of nodes in the list.
  • Time complexity: O(n) for splitList (traverse to find length and split point); O(n) for toString (traverse list).
  • Space complexity: O(1) for splitList (constant pointers); O(n) for toString (StringBuilder and Set).
  • Worst case: O(n) time, O(n) space for output with large lists.

✅ Tip: Split

Hashing Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Collision Analysis

Problem Statement

Write a Java program that implements a custom hash table to track the number of collisions, where multiple keys map to the same array index. The hash table should use an array with chaining (linked lists) to handle collisions, with strings as keys and integers as values. A collision occurs when a key hashes to an index that already contains one or more keys. The program should provide methods to insert keys and report the total number of collisions, then test the implementation with different sets of keys, including cases with high collision rates, few collisions, and edge cases like empty sets or single keys. You can visualize this as organizing items into labeled bins, counting how often multiple items land in the same bin due to their labels hashing to the same position.

Input:

  • A set of string keys to insert into the hash table. Output: The number of collisions and the hash table contents (key-value pairs per index). Constraints:
  • The hash table size (array length) is fixed at 10 for simplicity.
  • Keys are non-empty strings of up to 100 characters.
  • Values are integers (e.g., 1 for each key inserted).
  • The number of keys is between 0 and 10^5. Example:
  • Input: Keys = ["cat", "act", "dog", "god"]
  • Output:
    Collisions: 2
    Hash Table:
    Index 0: []
    Index 1: []
    Index 2: [cat: 1, act: 1]
    Index 3: []
    Index 4: []
    Index 5: [dog: 1, god: 1]
    Index 6: []
    Index 7: []
    Index 8: []
    Index 9: []
    
  • Explanation: "cat" and "act" collide at index 2, "dog" and "god" collide at index 5, resulting in 2 collisions.
  • Input: Keys = []
  • Output:
    Collisions: 0
    Hash Table:
    Index 0: []
    ...
    Index 9: []
    

Pseudocode

CLASS Node
    SET key to string
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

CLASS HashTable
    SET table to array of Node pointers (size 10)
    SET collisionCount to 0

    FUNCTION hash(key)
        SET sum to 0
        FOR each character in key
            ADD character ASCII value to sum
        ENDFOR
        RETURN sum mod 10
    ENDFUNCTION

    FUNCTION insert(key, value)
        SET index to hash(key)
        IF table[index] is null THEN
            SET table[index] to new Node(key, value)
        ELSE
            INCREMENT collisionCount
            SET current to table[index]
            WHILE current is not null
                IF current.key equals key THEN
                    SET current.value to value
                    RETURN
                ENDIF
                SET current to current.next
            ENDWHILE
            CREATE newNode as new Node(key, value)
            SET newNode.next to table[index]
            SET table[index] to newNode
        ENDIF
    ENDFUNCTION

    FUNCTION getCollisionCount()
        RETURN collisionCount
    ENDFUNCTION

    FUNCTION toString()
        CREATE result as new StringBuilder
        FOR i from 0 to table size - 1
            APPEND "Index " and i and ": [" to result
            SET current to table[i]
            WHILE current is not null
                APPEND current.key and ": " and current.value to result
                IF current.next is not null THEN
                    APPEND ", " to result
                ENDIF
                SET current to current.next
            ENDWHILE
            APPEND "]" to result
            IF i is not last index THEN
                APPEND newline to result
            ENDIF
        ENDFOR
        RETURN result as string
    ENDFUNCTION
ENDCLASS

FUNCTION main()
    SET testCases to array of key sets
    FOR each testCase in testCases
        PRINT test case details
        CREATE hashTable as new HashTable
        FOR each key in testCase
            CALL hashTable.insert(key, 1)
        ENDFOR
        PRINT collision count
        PRINT hash table contents using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. A string key. b. An integer value. c. A next pointer for chaining.
  2. Define a HashTable class with: a. A fixed-size array (table) of size 10. b. A collisionCount to track collisions. c. hash: Compute index by summing ASCII values of key characters modulo 10. d. insert: Insert key-value pair, increment collisionCount if index is occupied, handle duplicates. e. getCollisionCount: Return collision count. f. toString: Print key-value pairs per index.
  3. In main, test with key sets: a. Keys with collisions (e.g., anagrams). b. Keys with no collisions. c. Empty set. d. Single key. e. Large set with high collision rate.

Java Implementation

import java.util.*;

public class CollisionAnalysis {
    // Node class for chaining
    static class Node {
        String key;
        int value;
        Node next;

        Node(String key, int value) {
            this.key = key;
            this.value = value;
            this.next = null;
        }
    }

    // Custom hash table with collision tracking
    static class HashTable {
        private Node[] table;
        private int collisionCount;

        public HashTable() {
            table = new Node[10];
            collisionCount = 0;
        }

        private int hash(String key) {
            int sum = 0;
            for (char c : key.toCharArray()) {
                sum += c;
            }
            return sum % 10;
        }

        public void insert(String key, int value) {
            int index = hash(key);
            if (table[index] == null) {
                table[index] = new Node(key, value);
            } else {
                collisionCount++;
                Node current = table[index];
                while (current != null) {
                    if (current.key.equals(key)) {
                        current.value = value;
                        return;
                    }
                    current = current.next;
                }
                Node newNode = new Node(key, value);
                newNode.next = table[index];
                table[index] = newNode;
            }
        }

        public int getCollisionCount() {
            return collisionCount;
        }

        public String toString() {
            StringBuilder result = new StringBuilder();
            for (int i = 0; i < table.length; i++) {
                result.append("Index ").append(i).append(": [");
                Node current = table[i];
                while (current != null) {
                    result.append(current.key).append(": ").append(current.value);
                    if (current.next != null) {
                        result.append(", ");
                    }
                    current = current.next;
                }
                result.append("]");
                if (i < table.length - 1) {
                    result.append("\n");
                }
            }
            return result.toString();
        }
    }

    // Main method to test collision analysis
    public static void main(String[] args) {
        CollisionAnalysis analyzer = new CollisionAnalysis();

        // Test cases
        String[][] testCases = {
            {"cat", "act", "dog", "god"},           // High collisions (anagrams)
            {"apple", "banana", "cherry"},          // No collisions
            {},                                     // Empty set
            {"single"},                             // Single key
            {"key1", "key2", "key3", "key4", "key5", "yek1", "yek2"} // High collisions
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input keys: " + Arrays.toString(testCases[i]));
            HashTable hashTable = new HashTable();
            for (String key : testCases[i]) {
                hashTable.insert(key, 1);
            }
            System.out.println("Collisions: " + hashTable.getCollisionCount());
            System.out.println("Hash Table:\n" + hashTable.toString() + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input keys: [cat, act, dog, god]
Collisions: 2
Hash Table:
Index 0: []
Index 1: []
Index 2: [act: 1, cat: 1]
Index 3: []
Index 4: []
Index 5: [god: 1, dog: 1]
Index 6: []
Index 7: []
Index 8: []
Index 9: []

Test case 2:
Input keys: [apple, banana, cherry]
Collisions: 0
Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: [cherry: 1]
Index 5: []
Index 6: []
Index 7: [apple: 1]
Index 8: []
Index 9: [banana: 1]

Test case 3:
Input keys: []
Collisions: 0
Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: []
Index 7: []
Index 8: []
Index 9: []

Test case 4:
Input keys: [single]
Collisions: 0
Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: [single: 1]
Index 7: []
Index 8: []
Index 9: []

Test case 5:
Input keys: [key1, key2, key3, key4, key5, yek1, yek2]
Collisions: 4
Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: [key5: 1, key4: 1, key3: 1]
Index 5: []
Index 6: []
Index 7: []
Index 8: []
Index 9: [yek2: 1, yek1: 1, key2: 1, key1: 1]

Explanation:

  • Test case 1: "cat" and "act" collide at index 2, "dog" and "god" at index 5 (2 collisions).
  • Test case 2: No collisions, keys map to distinct indices.
  • Test case 3: Empty set, no collisions.
  • Test case 4: Single key, no collisions.
  • Test case 5: Multiple keys collide at indices 4 and 9 (4 collisions).

How It Works

  • Node: Stores a string key, integer value, and next pointer for chaining.
  • HashTable:
    • Uses a fixed-size array (size 10) with linked lists for chaining.
    • hash: Sums ASCII values of key characters modulo 10.
    • insert: Inserts key-value pair, increments collisionCount if index occupied, updates value for duplicates.
    • getCollisionCount: Returns collision count.
    • toString: Prints key-value pairs per index.
  • Example Trace (Test case 1):
    • Insert "cat": index=2, table[2]=[cat:1], collisions=0.
    • Insert "act": index=2, table[2]=[act:1,cat:1], collisions=1.
    • Insert "dog": index=5, table[5]=[dog:1], collisions=1.
    • Insert "god": index=5, table[5]=[god:1,dog:1], collisions=2.
    • Output: Collisions=2, table shows lists at indices 2 and 5.
  • Main Method: Tests high collisions, no collisions, empty set, single key, and large set.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
InsertO(1) averageO(1)
Get Collision CountO(1)O(1)
To StringO(n)O(n)

Note:

  • n is the number of keys in the hash table.
  • Time complexity: O(1) average for insert (hashing and list insertion); O(1) for getCollisionCount; O(n) for toString (iterate all keys).
  • Space complexity: O(1) for insert and getCollisionCount; O(n) for toString (StringBuilder output).
  • Worst case: O(n) time for insert (many keys in one index), O(n) space for toString.

✅ Tip: Use a simple hash function for educational purposes, but ensure it distributes keys evenly to minimize collisions. Track collisions by incrementing a counter when an index is already occupied.

⚠ Warning: A poor hash function (e.g., summing ASCII values) may cause excessive collisions. Test with diverse key sets to verify collision behavior, and handle duplicates to avoid redundant entries.

Custom Hash Function

Problem Statement

Write a Java program that implements a custom hash function for strings of exactly 3 alphabetic characters (e.g., "abc", "xyz") and integrates it into a hash table using an array with chaining (linked lists) to handle collisions. The hash table should store strings as keys and integers as values. The program should also implement a hash function using Java’s default hashCode method for comparison, tracking the number of collisions (when multiple keys map to the same array index) for both hash functions. Test the implementation with sets of 3-character string keys, comparing collision counts between the custom hash function and hashCode, including edge cases like empty sets, single keys, or identical keys. You can visualize this as organizing 3-letter tags into a fixed number of bins, comparing how two different labeling systems distribute tags and how often multiple tags land in the same bin.

Input:

  • A set of strings, each exactly 3 alphabetic characters, to insert into the hash table. Output: The number of collisions for both the custom hash function and Java’s hashCode, and the hash table contents (key-value pairs per index) for both. Constraints:
  • The hash table size (array length) is fixed at 10.
  • Keys are strings of exactly 3 alphabetic characters (a-z, A-Z).
  • Values are integers (e.g., 1 for each key inserted).
  • The number of keys is between 0 and 10^5. Example:
  • Input: Keys = ["abc", "cba", "def", "fed"]
  • Output:
    Custom Hash Collisions: 2
    Custom Hash Table:
    Index 0: []
    Index 1: []
    Index 2: [cba: 1, abc: 1]
    Index 3: []
    Index 4: []
    Index 5: [fed: 1, def: 1]
    Index 6: []
    Index 7: []
    Index 8: []
    Index 9: []
    hashCode Collisions: 0
    hashCode Hash Table:
    Index 0: [fed: 1]
    Index 1: []
    Index 2: []
    Index 3: []
    Index 4: []
    Index 5: []
    Index 6: [abc: 1]
    Index 7: [def: 1]
    Index 8: []
    Index 9: [cba: 1]
    
  • Explanation: Custom hash causes 2 collisions ("abc"/"cba" at index 2, "def"/"fed" at index 5); hashCode causes 0 collisions.

Pseudocode

CLASS Node
    SET key to string
    SET value to integer
    SET next to Node (null by default)
ENDCLASS

CLASS HashTable
    SET table to array of Node pointers (size 10)
    SET collisionCount to 0

    FUNCTION customHash(key)
        SET sum to 0
        FOR i from 0 to 2
            SET sum to sum * 31 + (lowercase(key[i]) - 'a' + 1)
        ENDFOR
        RETURN absolute(sum) mod 10
    ENDFUNCTION

    FUNCTION hashCodeHash(key)
        RETURN absolute(key.hashCode()) mod 10
    ENDFUNCTION

    FUNCTION insert(key, value, useCustomHash)
        IF length of key is not 3 OR key contains non-alphabetic characters THEN
            RETURN
        ENDIF
        SET index to customHash(key) if useCustomHash else hashCodeHash(key)
        IF table[index] is null THEN
            SET table[index] to new Node(key, value)
        ELSE
            INCREMENT collisionCount
            SET current to table[index]
            WHILE current is not null
                IF current.key equals key THEN
                    SET current.value to value
                    RETURN
                ENDIF
                SET current to current.next
            ENDWHILE
            CREATE newNode as new Node(key, value)
            SET newNode.next to table[index]
            SET table[index] to newNode
        ENDIF
    ENDFUNCTION

    FUNCTION getCollisionCount()
        RETURN collisionCount
    ENDFUNCTION

    FUNCTION toString()
        CREATE result as new StringBuilder
        FOR i from 0 to table size - 1
            APPEND "Index " and i and ": [" to result
            SET current to table[i]
            WHILE current is not null
                APPEND current.key and ": " and current.value to result
                IF current.next is not null THEN
                    APPEND ", " to result
                ENDIF
                SET current to current.next
            ENDWHILE
            APPEND "]" to result
            IF i is not last index THEN
                APPEND newline to result
            ENDIF
        ENDFOR
        RETURN result as string
    ENDFUNCTION
ENDCLASS

FUNCTION main()
    SET testCases to array of key sets
    FOR each testCase in testCases
        PRINT test case details
        CREATE customHashTable as new HashTable
        CREATE hashCodeTable as new HashTable
        FOR each key in testCase
            IF key is valid THEN
                CALL customHashTable.insert(key, 1, true)
                CALL hashCodeTable.insert(key, 1, false)
            ENDIF
        ENDFOR
        PRINT custom hash collision count
        PRINT custom hash table contents
        PRINT hashCode collision count
        PRINT hashCode hash table contents
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. A string key (3 alphabetic characters). b. An integer value. c. A next pointer for chaining.
  2. Define a HashTable class with: a. A fixed-size array (table) of size 10. b. A collisionCount to track collisions. c. customHash: Polynomial rolling hash (e.g., sum = sum * 31 + (char - 'a' + 1)) modulo 10. d. hashCodeHash: Uses Java’s hashCode modulo 10. e. insert: Validates key, computes index, increments collisionCount if index occupied, handles duplicates. f. getCollisionCount: Returns collision count. g. toString: Prints key-value pairs per index.
  3. In main, test with key sets: a. Keys causing collisions (e.g., anagrams like "abc", "cba"). b. Keys with no collisions. c. Empty set. d. Single key. e. Repeated keys.

Java Implementation

import java.util.*;

public class CustomHashFunction {
    // Node class for chaining
    static class Node {
        String key;
        int value;
        Node next;

        Node(String key, int value) {
            this.key = key;
            this.value = value;
            this.next = null;
        }
    }

    // Custom hash table with collision tracking
    static class HashTable {
        private Node[] table;
        private int collisionCount;

        public HashTable() {
            table = new Node[10];
            collisionCount = 0;
        }

        private int customHash(String key) {
            int sum = 0;
            for (int i = 0; i < 3; i++) {
                sum = sum * 31 + (Character.toLowerCase(key.charAt(i)) - 'a' + 1);
            }
            return Math.abs(sum) % 10;
        }

        private int hashCodeHash(String key) {
            return Math.abs(key.hashCode()) % 10;
        }

        public void insert(String key, int value, boolean useCustomHash) {
            if (key.length() != 3 || !key.matches("[a-zA-Z]+")) {
                return;
            }
            int index = useCustomHash ? customHash(key) : hashCodeHash(key);
            if (table[index] == null) {
                table[index] = new Node(key, value);
            } else {
                collisionCount++;
                Node current = table[index];
                while (current != null) {
                    if (current.key.equals(key)) {
                        current.value = value;
                        return;
                    }
                    current = current.next;
                }
                Node newNode = new Node(key, value);
                newNode.next = table[index];
                table[index] = newNode;
            }
        }

        public int getCollisionCount() {
            return collisionCount;
        }

        public String toString() {
            StringBuilder result = new StringBuilder();
            for (int i = 0; i < table.length; i++) {
                result.append("Index ").append(i).append(": [");
                Node current = table[i];
                while (current != null) {
                    result.append(current.key).append(": ").append(current.value);
                    if (current.next != null) {
                        result.append(", ");
                    }
                    current = current.next;
                }
                result.append("]");
                if (i < table.length - 1) {
                    result.append("\n");
                }
            }
            return result.toString();
        }
    }

    // Main method to test custom hash function
    public static void main(String[] args) {
        CustomHashFunction analyzer = new CustomHashFunction();

        // Test cases
        String[][] testCases = {
            {"abc", "cba", "def", "fed"},           // Anagrams, high collisions
            {"xyz", "abc", "def"},                  // No collisions
            {},                                     // Empty set
            {"ghi"},                                // Single key
            {"aaa", "aaa", "aaa"}                   // Repeated keys
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input keys: " + Arrays.toString(testCases[i]));
            HashTable customHashTable = new HashTable();
            HashTable hashCodeTable = new HashTable();
            for (String key : testCases[i]) {
                customHashTable.insert(key, 1, true);
                hashCodeTable.insert(key, 1, false);
            }
            System.out.println("Custom Hash Collisions: " + customHashTable.getCollisionCount());
            System.out.println("Custom Hash Table:\n" + customHashTable.toString());
            System.out.println("hashCode Collisions: " + hashCodeTable.getCollisionCount());
            System.out.println("hashCode Hash Table:\n" + hashCodeTable.toString() + "\n");
        }
    }
}

Output

Running the main method produces (note: exact indices for hashCode may vary due to implementation, but collision counts are illustrative):

Test case 1:
Input keys: [abc, cba, def, fed]
Custom Hash Collisions: 2
Custom Hash Table:
Index 0: []
Index 1: []
Index 2: [cba: 1, abc: 1]
Index 3: []
Index 4: []
Index 5: [fed: 1, def: 1]
Index 6: []
Index 7: []
Index 8: []
Index 9: []
hashCode Collisions: 0
hashCode Hash Table:
Index 0: [fed: 1]
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: [abc: 1]
Index 7: [def: 1]
Index 8: []
Index 9: [cba: 1]

Test case 2:
Input keys: [xyz, abc, def]
Custom Hash Collisions: 0
Custom Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: [def: 1]
Index 5: []
Index 6: []
Index 7: []
Index 8: [abc: 1]
Index 9: [xyz: 1]
hashCode Collisions: 0
hashCode Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: [def: 1]
Index 5: []
Index 6: [abc: 1]
Index 7: []
Index 8: []
Index 9: [xyz: 1]

Test case 3:
Input keys: []
Custom Hash Collisions: 0
Custom Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: []
Index 7: []
Index 8: []
Index 9: []
hashCode Collisions: 0
hashCode Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: []
Index 7: []
Index 8: []
Index 9: []

Test case 4:
Input keys: [ghi]
Custom Hash Collisions: 0
Custom Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: []
Index 7: [ghi: 1]
Index 8: []
Index 9: []
hashCode Collisions: 0
hashCode Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: []
Index 7: [ghi: 1]
Index 8: []
Index 9: []

Test case 5:
Input keys: [aaa, aaa, aaa]
Custom Hash Collisions: 2
Custom Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: []
Index 7: []
Index 8: []
Index 9: [aaa: 1]
hashCode Collisions: 2
hashCode Hash Table:
Index 0: []
Index 1: []
Index 2: []
Index 3: []
Index 4: []
Index 5: []
Index 6: []
Index 7: []
Index 8: []
Index 9: [aaa: 1]

Explanation:

  • Test case 1: Custom hash causes 2 collisions ("abc"/"cba", "def"/"fed" at indices 2, 5); hashCode spreads keys evenly, 0 collisions.
  • Test case 2: Both hash functions distribute keys without collisions.
  • Test case 3: Empty set, no collisions for either.
  • Test case 4: Single key, no collisions for either.
  • Test case 5: Repeated keys cause 2 collisions for both (only one key stored due to duplicates).

How It Works

  • Node: Stores a 3-character string key, integer value, and next pointer.
  • HashTable:
    • Uses a fixed-size array (size 10) with linked lists for chaining.
    • customHash: Polynomial rolling hash (sum = sum * 31 + (char - 'a' + 1)), case-insensitive, modulo 10.
    • hashCodeHash: Uses Java’s hashCode modulo 10.
    • insert: Validates 3-character alphabetic key, computes index, increments collisionCount if index occupied, updates value for duplicates.
    • getCollisionCount: Returns collision count.
    • toString: Prints key-value pairs per index.
  • Example Trace (Test case 1, custom hash):
    • Insert "abc": index=2, table[2]=[abc:1], collisions=0.
    • Insert "cba": index=2, table[2]=[cba:1,abc:1], collisions=1.
    • Insert "def": index=5, table[5]=[def:1], collisions=1.
    • Insert "fed": index=5, table[5]=[fed:1,def:1], collisions=2.
  • Main Method: Tests anagrams, no collisions, empty set, single key, and duplicates, comparing collisions.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
InsertO(1) averageO(1)
Get Collision CountO(1)O(1)
To StringO(n)O(n)

Note:

  • n is the number of keys in the hash table.
  • Time complexity: O(1) average for insert (hashing and list insertion); O(1) for getCollisionCount; O(n) for toString (iterate all keys).
  • Space complexity: O(1) for insert and getCollisionCount; O(n) for toString (StringBuilder output).
  • Worst case: O(n) time for insert (many keys in one index), O(n) space for toString.

✅ Tip: Design a custom hash function that leverages the key’s structure (e.g., fixed 3-character strings) to distribute keys evenly. A polynomial rolling hash works well for strings by combining character positions.

⚠ Warning: Validate input keys to ensure they match the expected format (3 alphabetic characters). Poor hash functions may lead to excessive collisions, degrading performance to O(n) in worst cases.

Phone Book Application

Problem Statement

Write a Java program that implements a phone book using a hash table, with contact names as keys and phone numbers as values. The program should support operations to insert a contact (name and phone number), look up a phone number by name, and delete a contact by name. The hash table should handle case-sensitive names and return appropriate messages for operations on non-existent contacts or duplicate insertions. Test the implementation with sequences of operations, including cases with duplicate names, non-existent contacts, and empty phone books. You can visualize this as a digital phone book where you can add, find, or remove a person’s contact details quickly using their name.

Input:

  • A sequence of operations, where each operation is:
    • insert(name, phoneNumber): Add a contact, return "Added" or "Name already exists".
    • lookup(name): Return the phone number or "Not found" if the name doesn’t exist.
    • delete(name): Remove the contact, return "Deleted" or "Not found".
    • printPhoneBook(): Print all contacts or "Empty" if none exist. Output: For each operation, print the action performed and its result (e.g., "Added John", "Found: 123-456-7890", "Deleted John", or "Empty"). Constraints:
  • The phone book size is between 0 and 10^5 contacts.
  • Names and phone numbers are non-empty strings of up to 100 characters.
  • Names are case-sensitive (e.g., "John" and "john" are distinct).
  • Phone numbers are strings (e.g., "123-456-7890", no specific format required). Example:
  • Input: Operations = [insert("John", "123-456-7890"), insert("Jane", "987-654-3210"), lookup("John"), delete("Jane"), printPhoneBook]
  • Output:
    Added John
    Added Jane
    Found: 123-456-7890
    Deleted Jane
    Phone Book: John: 123-456-7890
    
  • Input: Operations = [lookup("John"), printPhoneBook]
  • Output:
    Not found
    Empty
    

Pseudocode

CLASS PhoneBook
    SET hashTable to new HashMap

    FUNCTION insert(name, phoneNumber)
        IF hashTable contains name THEN
            RETURN "Name already exists"
        ENDIF
        SET hashTable[name] to phoneNumber
        RETURN "Added " + name
    ENDFUNCTION

    FUNCTION lookup(name)
        IF hashTable contains name THEN
            RETURN "Found: " + hashTable[name]
        ENDIF
        RETURN "Not found"
    ENDFUNCTION

    FUNCTION delete(name)
        IF hashTable contains name THEN
            REMOVE hashTable[name]
            RETURN "Deleted " + name
        ENDIF
        RETURN "Not found"
    ENDFUNCTION

    FUNCTION toString()
        IF hashTable is empty THEN
            RETURN "Empty"
        ENDIF
        CREATE result as new StringBuilder
        FOR each entry in hashTable
            APPEND entry.key and ": " and entry.value to result
            IF not last entry THEN
                APPEND ", " to result
            ENDIF
        ENDFOR
        RETURN result as string
    ENDFUNCTION
ENDCLASS

FUNCTION testPhoneBook(operations)
    CREATE phoneBook as new PhoneBook
    FOR each operation in operations
        IF operation.type equals "insert" THEN
            CALL phoneBook.insert(operation.name, operation.phoneNumber)
            PRINT result
        ELSE IF operation.type equals "lookup" THEN
            CALL phoneBook.lookup(operation.name)
            PRINT result
        ELSE IF operation.type equals "delete" THEN
            CALL phoneBook.delete(operation.name)
            PRINT result
        ELSE IF operation.type equals "print" THEN
            PRINT "Phone Book: " + phoneBook.toString
        ENDIF
    ENFOR
ENDFUNCTION

FUNCTION main()
    SET testCases to array of operation sequences
    FOR each testCase in testCases
        PRINT test case details
        CALL testPhoneBook(testCase)
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a PhoneBook class with: a. A HashMap<String, String> to store name-phone number pairs. b. insert: Add contact if name doesn’t exist, return status. c. lookup: Return phone number or "Not found". d. delete: Remove contact by name, return status. e. toString: Return comma-separated string of contacts or "Empty".
  2. In testPhoneBook: a. Create a PhoneBook instance. b. For each operation:
    • insert: Call insert, print result.
    • lookup: Call lookup, print result.
    • delete: Call delete, print result.
    • print: Call toString, print phone book.
  3. In main, test with sequences including: a. Normal operations (insert, lookup, delete). b. Empty phone book operations. c. Duplicate name insertion. d. Lookup and delete non-existent contacts. e. Single contact operations.

Java Implementation

import java.util.*;

public class PhoneBookApplication {
    // PhoneBook class to manage contacts
    static class PhoneBook {
        private HashMap<String, String> hashTable;

        public PhoneBook() {
            hashTable = new HashMap<>();
        }

        public String insert(String name, String phoneNumber) {
            if (hashTable.containsKey(name)) {
                return "Name already exists";
            }
            hashTable.put(name, phoneNumber);
            return "Added " + name;
        }

        public String lookup(String name) {
            if (hashTable.containsKey(name)) {
                return "Found: " + hashTable.get(name);
            }
            return "Not found";
        }

        public String delete(String name) {
            if (hashTable.containsKey(name)) {
                hashTable.remove(name);
                return "Deleted " + name;
            }
            return "Not found";
        }

        public String toString() {
            if (hashTable.isEmpty()) {
                return "Empty";
            }
            StringBuilder result = new StringBuilder();
            int index = 0;
            for (Map.Entry<String, String> entry : hashTable.entrySet()) {
                result.append(entry.getKey()).append(": ").append(entry.getValue());
                if (index < hashTable.size() - 1) {
                    result.append(", ");
                }
                index++;
            }
            return result.toString();
        }
    }

    // Helper class for operations
    static class Operation {
        String type;
        String name;
        String phoneNumber;

        Operation(String type, String name, String phoneNumber) {
            this.type = type;
            this.name = name;
            this.phoneNumber = phoneNumber;
        }
    }

    // Tests phone book operations
    public void testPhoneBook(List<Operation> operations) {
        PhoneBook phoneBook = new PhoneBook();
        for (Operation op : operations) {
            if (op.type.equals("insert")) {
                System.out.println(phoneBook.insert(op.name, op.phoneNumber));
            } else if (op.type.equals("lookup")) {
                System.out.println(phoneBook.lookup(op.name));
            } else if (op.type.equals("delete")) {
                System.out.println(phoneBook.delete(op.name));
            } else if (op.type.equals("print")) {
                System.out.println("Phone Book: " + phoneBook.toString());
            }
        }
    }

    // Main method to test phone book
    public static void main(String[] args) {
        PhoneBookApplication manager = new PhoneBookApplication();

        // Test cases
        List<List<Operation>> testCases = new ArrayList<>();

        // Test case 1: Normal operations
        testCases.add(Arrays.asList(
            new Operation("insert", "John", "123-456-7890"),
            new Operation("insert", "Jane", "987-654-3210"),
            new Operation("lookup", "John", null),
            new Operation("delete", "Jane", null),
            new Operation("print", null, null)
        ));

        // Test case 2: Empty phone book
        testCases.add(Arrays.asList(
            new Operation("lookup", "John", null),
            new Operation("delete", "John", null),
            new Operation("print", null, null)
        ));

        // Test case 3: Duplicate name
        testCases.add(Arrays.asList(
            new Operation("insert", "Alice", "111-222-3333"),
            new Operation("insert", "Alice", "444-555-6666"),
            new Operation("print", null, null)
        ));

        // Test case 4: Non-existent contact
        testCases.add(Arrays.asList(
            new Operation("insert", "Bob", "222-333-4444"),
            new Operation("lookup", "Charlie", null),
            new Operation("delete", "Charlie", null),
            new Operation("print", null, null)
        ));

        // Test case 5: Single contact
        testCases.add(Arrays.asList(
            new Operation("insert", "Eve", "555-666-7777"),
            new Operation("lookup", "Eve", null),
            new Operation("delete", "Eve", null),
            new Operation("print", null, null)
        ));

        // Run test cases
        for (int i = 0; i < testCases.size(); i++) {
            System.out.println("Test case " + (i + 1) + ":");
            manager.testPhoneBook(testCases.get(i));
            System.out.println();
        }
    }
}

Output

Running the main method produces:

Test case 1:
Added John
Added Jane
Found: 123-456-7890
Deleted Jane
Phone Book: John: 123-456-7890

Test case 2:
Not found
Not found
Phone Book: Empty

Test case 3:
Added Alice
Name already exists
Phone Book: Alice: 111-222-3333

Test case 4:
Added Bob
Not found
Not found
Phone Book: Bob: 222-333-4444

Test case 5:
Added Eve
Found: 555-666-7777
Deleted Eve
Phone Book: Empty

Explanation:

  • Test case 1: Adds John and Jane, looks up John, deletes Jane, prints remaining contact.
  • Test case 2: Attempts lookup and delete on empty phone book, prints "Empty".
  • Test case 3: Adds Alice, tries to add Alice again (fails), prints single contact.
  • Test case 4: Adds Bob, tries lookup and delete for non-existent Charlie, prints Bob’s contact.
  • Test case 5: Adds Eve, looks up Eve, deletes Eve, prints empty phone book.

How It Works

  • PhoneBook:
    • Uses HashMap<String, String> to store name-phone number pairs.
    • insert: Adds contact if name doesn’t exist, returns status.
    • lookup: Returns phone number or "Not found".
    • delete: Removes contact if name exists, returns status.
    • toString: Returns comma-separated string of contacts or "Empty".
  • testPhoneBook: Executes operations, printing results.
  • Example Trace (Test case 1):
    • insert("John", "123-456-7890"): hashTable={John:123-456-7890}, returns "Added John".
    • insert("Jane", "987-654-3210"): hashTable={John:123-456-7890, Jane:987-654-3210}, returns "Added Jane".
    • lookup("John"): Returns "Found: 123-456-7890".
    • delete("Jane"): Removes Jane, hashTable={John:123-456-7890}, returns "Deleted Jane".
    • print: Returns "John: 123-456-7890".
  • Main Method: Tests normal operations, empty phone book, duplicate names, non-existent contacts, and single contact.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
InsertO(1) averageO(1)
LookupO(1) averageO(1)
DeleteO(1) averageO(1)
To StringO(n)O(n)

Note:

  • n is the number of contacts in the phone book.
  • Time complexity: O(1) average for insert, lookup, delete (HashMap operations); O(n) for toString (iterate all entries).
  • Space complexity: O(1) for insert, lookup, delete (constant space); O(n) for toString (StringBuilder output).
  • Worst case: O(n) time and space for toString with large phone books.

✅ Tip: Use a HashMap for fast O(1) average-case operations. Ensure names are treated case-sensitively to avoid confusion between similar names (e.g., "John" vs. "john").

⚠ Warning: Check for duplicate names before insertion to prevent overwriting existing contacts. Handle empty phone book cases to avoid null pointer issues during lookup or deletion.

Two Sum Problem

Problem Statement

Write a Java program that uses a hash table to solve the Two Sum problem: given an array of integers and a target sum, find two numbers in the array that add up to the target sum and return their indices. The hash table should store numbers as keys and their indices as values. The solution should assume each input has at most one valid pair of indices and that no number can be used twice. Test the implementation with different arrays and target sums, including cases with no solution, valid solutions, and edge cases like empty arrays or arrays with fewer than two elements. You can visualize this as searching a list of numbers to find a pair of puzzle pieces that fit together to match a specific total.

Input:

  • An array of integers and a target sum (integer). Output: An array of two integers representing the indices of the two numbers that sum to the target, or an empty array if no solution exists. Constraints:
  • The array length is between 0 and 10^5.
  • Array elements and the target sum are integers in the range [-10^9, 10^9].
  • Each input has at most one valid solution.
  • The same element cannot be used twice. Example:
  • Input: Array = [2, 7, 11, 15], Target = 9
  • Output: [0, 1]
  • Explanation: 2 + 7 = 9, indices 0 and 1.
  • Input: Array = [3, 2, 4], Target = 8
  • Output: []
  • Explanation: No pair sums to 8.
  • Input: Array = [], Target = 5
  • Output: []
  • Explanation: Empty array, no solution.

Pseudocode

FUNCTION twoSum(numbers, target)
    CREATE hashTable as new HashMap
    FOR i from 0 to numbers length - 1
        SET complement to target - numbers[i]
        IF hashTable contains complement THEN
            RETURN array of [hashTable[complement], i]
        ENDIF
        SET hashTable[numbers[i]] to i
    ENDFOR
    RETURN empty array
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (numbers, target) pairs
    FOR each testCase in testCases
        PRINT test case details
        CALL twoSum(testCase.numbers, testCase.target)
        PRINT result
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define twoSum: a. Create a HashMap<Integer, Integer> to store numbers and their indices. b. For each number at index i:
    • Compute complement = target - number.
    • If complement exists in hash table, return [hashTable[complement], i].
    • Otherwise, add number and its index to hash table. c. If no solution is found, return empty array.
  2. In main, test with: a. Arrays with a valid solution. b. Arrays with no solution. c. Empty array. d. Array with one element. e. Array with duplicate numbers.

Java Implementation

import java.util.*;

public class TwoSumProblem {
    // Solves the Two Sum problem using a hash table
    public int[] twoSum(int[] numbers, int target) {
        HashMap<Integer, Integer> hashTable = new HashMap<>();
        for (int i = 0; i < numbers.length; i++) {
            int complement = target - numbers[i];
            if (hashTable.containsKey(complement)) {
                return new int[] {hashTable.get(complement), i};
            }
            hashTable.put(numbers[i], i);
        }
        return new int[] {};
    }

    // Main method to test Two Sum
    public static void main(String[] args) {
        TwoSumProblem solver = new TwoSumProblem();

        // Test cases
        Object[] testCases = {
            new Object[] {new int[] {2, 7, 11, 15}, 9},    // Valid solution
            new Object[] {new int[] {3, 2, 4}, 8},         // No solution
            new Object[] {new int[] {}, 5},                // Empty array
            new Object[] {new int[] {1}, 2},               // Single element
            new Object[] {new int[] {3, 3}, 6}             // Duplicate numbers
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            int[] numbers = (int[]) ((Object[]) testCases[i])[0];
            int target = (int) ((Object[]) testCases[i])[1];
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input array: " + Arrays.toString(numbers));
            System.out.println("Target sum: " + target);
            int[] result = solver.twoSum(numbers, target);
            System.out.println("Result: " + Arrays.toString(result) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input array: [2, 7, 11, 15]
Target sum: 9
Result: [0, 1]

Test case 2:
Input array: [3, 2, 4]
Target sum: 8
Result: []

Test case 3:
Input array: []
Target sum: 5
Result: []

Test case 4:
Input array: [1]
Target sum: 2
Result: []

Test case 5:
Input array: [3, 3]
Target sum: 6
Result: [0, 1]

Explanation:

  • Test case 1: 2 + 7 = 9, returns indices [0, 1].
  • Test case 2: No pair sums to 8, returns [].
  • Test case 3: Empty array, returns [].
  • Test case 4: Single element, no pair possible, returns [].
  • Test case 5: 3 + 3 = 6, returns indices [0, 1].

How It Works

  • twoSum:
    • Uses a HashMap<Integer, Integer> to store numbers and their indices.
    • For each number, checks if complement (target - number) exists in hash table.
    • If found, returns indices of complement and current number.
    • Otherwise, adds current number and index to hash table.
    • Returns empty array if no solution is found.
  • Example Trace (Test case 1):
    • Input: [2, 7, 11, 15], target=9.
    • i=0: complement=9-2=7, hashTable={}, add {2:0}.
    • i=1: complement=9-7=2, hashTable={2:0}, return [0, 1].
  • Main Method: Tests valid solution, no solution, empty array, single element, and duplicates.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Two SumO(n)O(n)

Note:

  • n is the length of the input array.
  • Time complexity: O(n) for single pass through array; HashMap operations (put, get) are O(1) average case.
  • Space complexity: O(n) for storing up to n numbers in HashMap.
  • Worst case: O(n) time and space when all numbers are processed.

✅ Tip: Use a single-pass approach with a hash table to achieve O(n) time complexity, storing numbers and indices to quickly find the complement.

⚠ Warning: Ensure the solution handles edge cases like empty arrays or arrays with fewer than two elements. Avoid using the same element twice by checking the complement before adding the current number.

Word Frequency Counter

Problem Statement

Write a Java program that uses a hash table to count the frequency of words in a given text. The hash table should use words (case-insensitive) as keys and their frequency counts as values. The program should process the input text by splitting it into words, ignoring punctuation, and print the word-frequency pairs in a readable format. Test the implementation with different text inputs, including empty text, single-word text, and text with repeated words. You can visualize this as analyzing a book page to count how often each word appears, storing the results in a dictionary-like structure for quick lookup.

Input:

  • A string of text containing words separated by spaces, punctuation, or other delimiters. Output: A string representation of the hash table’s word-frequency pairs, or "Empty" if no words are present. Constraints:
  • The text length is between 0 and 10^5 characters.
  • Words are sequences of alphabetic characters (a-z, A-Z).
  • Punctuation and non-alphabetic characters are ignored.
  • Words are case-insensitive (e.g., "The" and "the" are the same). Example:
  • Input: Text = "The quick brown fox, the quick!"
  • Output: "the: 2, quick: 2, brown: 1, fox: 1"
  • Explanation: Words are counted case-insensitively, ignoring punctuation.
  • Input: Text = ""
  • Output: "Empty"
  • Explanation: No words in empty text.
  • Input: Text = "hello Hello HELLO"
  • Output: "hello: 3"
  • Explanation: Case-insensitive, all "hello" variants count as one word.

Pseudocode

FUNCTION countWordFrequencies(text)
    CREATE hashTable as new HashMap
    IF text is empty THEN
        RETURN "Empty"
    ENDIF
    SET words to split text by non-alphabetic characters
    FOR each word in words
        IF word is not empty THEN
            SET word to lowercase(word)
            IF hashTable contains word THEN
                INCREMENT hashTable[word] by 1
            ELSE
                SET hashTable[word] to 1
            ENDIF
        ENDIF
    ENDFOR
    IF hashTable is empty THEN
        RETURN "Empty"
    ENDIF
    CREATE result as new StringBuilder
    FOR each entry in hashTable
        APPEND entry.key and ":" and entry.value to result
        IF not last entry THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of text inputs
    FOR each testCase in testCases
        PRINT test case details
        CALL countWordFrequencies(testCase.text)
        PRINT word frequencies
    ENFOR
ENDFUNCTION

Algorithm Steps

  1. Define countWordFrequencies: a. Create a HashMap<String, Integer> to store word frequencies. b. If text is empty, return "Empty". c. Split text into words using non-alphabetic characters as delimiters. d. For each non-empty word:
    • Convert to lowercase for case-insensitive counting.
    • If word exists in hash table, increment its count.
    • Otherwise, add word with count 1. e. If hash table is empty, return "Empty". f. Build a string of word-frequency pairs, separated by commas.
  2. In main, test with different texts: a. Normal text with repeated words. b. Empty text. c. Single-word text. d. Text with punctuation and mixed case. e. Text with only non-alphabetic characters.

Java Implementation

import java.util.*;

public class WordFrequencyCounter {
    // Counts word frequencies in the given text
    public String countWordFrequencies(String text) {
        HashMap<String, Integer> hashTable = new HashMap<>();
        if (text == null || text.trim().isEmpty()) {
            return "Empty";
        }
        // Split text into words, ignoring non-alphabetic characters
        String[] words = text.split("[^a-zA-Z]+");
        for (String word : words) {
            if (!word.isEmpty()) {
                word = word.toLowerCase();
                hashTable.put(word, hashTable.getOrDefault(word, 0) + 1);
            }
        }
        if (hashTable.isEmpty()) {
            return "Empty";
        }
        // Build result string
        StringBuilder result = new StringBuilder();
        int index = 0;
        for (Map.Entry<String, Integer> entry : hashTable.entrySet()) {
            result.append(entry.getKey()).append(": ").append(entry.getValue());
            if (index < hashTable.size() - 1) {
                result.append(", ");
            }
            index++;
        }
        return result.toString();
    }

    // Main method to test word frequency counter
    public static void main(String[] args) {
        WordFrequencyCounter counter = new WordFrequencyCounter();

        // Test cases
        String[] testCases = {
            "The quick brown fox, the quick!",           // Normal text with repeats
            "",                                         // Empty text
            "hello Hello HELLO",                        // Case-insensitive repeats
            "hello",                                    // Single word
            "!!! --- 123",                              // No words
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Input text: \"" + testCases[i] + "\"");
            String result = counter.countWordFrequencies(testCases[i]);
            System.out.println("Word frequencies: " + result + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input text: "The quick brown fox, the quick!"
Word frequencies: the: 2, quick: 2, brown: 1, fox: 1

Test case 2:
Input text: ""
Word frequencies: Empty

Test case 3:
Input text: "hello Hello HELLO"
Word frequencies: hello: 3

Test case 4:
Input text: "hello"
Word frequencies: hello: 1

Test case 5:
Input text: "!!! --- 123"
Word frequencies: Empty

Explanation:

  • Test case 1: Counts "the" (2), "quick" (2), "brown" (1), "fox" (1), ignoring punctuation.
  • Test case 2: Empty text returns "Empty".
  • Test case 3: Counts "hello" (3) case-insensitively.
  • Test case 4: Single word "hello" has frequency 1.
  • Test case 5: No alphabetic words, returns "Empty".

How It Works

  • countWordFrequencies:
    • Creates a HashMap<String, Integer> for word frequencies.
    • Handles null or empty text by returning "Empty".
    • Splits text using regex [^a-zA-Z]+ to ignore non-alphabetic characters.
    • Processes each non-empty word:
      • Converts to lowercase.
      • Updates count in hash table using getOrDefault.
    • Builds a comma-separated string of word-frequency pairs or returns "Empty" if none.
  • Example Trace (Test case 1):
    • Input: "The quick brown fox, the quick!".
    • Split: ["The", "quick", "brown", "fox", "the", "quick"].
    • Process: hashTable={the:2, quick:2, brown:1, fox:1}.
    • Output: "the: 2, quick: 2, brown: 1, fox: 1".
  • Main Method: Tests normal text, empty text, repeated words, single word, and non-alphabetic text.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
Count FrequenciesO(n)O(m)

Note:

  • n is the length of the input text (characters).
  • m is the number of unique words.
  • Time complexity: O(n) for splitting and processing text; HashMap operations (put, get) are O(1) average case.
  • Space complexity: O(m) for HashMap storing unique words; O(n) for output string in worst case.
  • Worst case: O(n) time, O(n) space for many unique words or long output.

✅ Tip: Use a regular expression like [^a-zA-Z]+ to split text and handle punctuation effectively. Convert words to lowercase to ensure case-insensitive counting.

⚠ Warning: Handle edge cases like empty text or non-alphabetic input to avoid empty hash tables. Ensure regex splitting correctly separates words to prevent invalid tokens.

Trees Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

BST Validator

Problem Statement

Write a Java program that checks if a given tree is a valid Binary Search Tree (BST). A BST is valid if, for each node, all values in its left subtree are less than the node’s value, and all values in its right subtree are greater than the node’s value. The program should implement a method to validate the tree and test it with at least three different trees, including valid and invalid BSTs, and edge cases like a single node or empty tree. You can visualize this as inspecting a family tree where each parent’s value must be greater than all their left descendants and less than all their right descendants, ensuring the hierarchy follows strict rules.

Input:

  • A binary tree represented by nodes with integer values. Output: A boolean indicating whether the tree is a valid BST, and a string representation of the tree for clarity. Constraints:
  • The tree has between 0 and 10^5 nodes.
  • Node values are integers in the range [-10^9, 10^9].
  • Duplicate values are not allowed in the BST. Example:
  • Input: Tree = [5, left: [3, left: [1], right: [4]], right: [7]]
  • Output: true, "Preorder: 5 3 1 4 7"
  • Explanation: All nodes satisfy the BST property.
  • Input: Tree = [5, left: [3, left: [1], right: [6]], right: [7]]
  • Output: false, "Preorder: 5 3 1 6 7"
  • Explanation: Node 6 in the left subtree is greater than 5, violating the BST property.
  • Input: Tree = []
  • Output: true, "Empty"
  • Explanation: An empty tree is a valid BST.

Pseudocode

CLASS Node
    SET value to integer
    SET left to Node (null by default)
    SET right to Node (null by default)
ENDCLASS

FUNCTION isValidBST(root)
    FUNCTION validate(node, min, max)
        IF node is null THEN
            RETURN true
        ENDIF
        IF node.value <= min OR node.value >= max THEN
            RETURN false
        ENDIF
        RETURN validate(node.left, min, node.value) AND validate(node.right, node.value, max)
    ENDFUNCTION
    RETURN validate(root, negative infinity, positive infinity)
ENDFUNCTION

FUNCTION toString(root)
    IF root is null THEN
        RETURN "Empty"
    ENDIF
    CREATE result as new StringBuilder
    FUNCTION preorder(node)
        IF node is null THEN
            RETURN
        ENDIF
        APPEND node.value and " " to result
        CALL preorder(node.left)
        CALL preorder(node.right)
    ENDFUNCTION
    CALL preorder(root)
    RETURN "Preorder: " + result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of trees
    FOR each testCase in testCases
        PRINT test case details
        CALL isValidBST(testCase.root)
        PRINT result and tree using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define a Node class with: a. An integer value. b. left and right pointers to child nodes.
  2. Define isValidBST: a. Use a helper function validate(node, min, max) to check if each node’s value is within [min, max]. b. For each node:
    • If null, return true.
    • If value is not in (min, max), return false.
    • Recursively validate left subtree with updated max = node.value.
    • Recursively validate right subtree with updated min = node.value. c. Call validate with initial min = negative infinity, max = positive infinity.
  3. Define toString: a. If root is null, return "Empty". b. Perform preorder traversal, appending values to a string.
  4. In main, test with at least three trees: a. A valid BST. b. An invalid BST. c. An empty tree. d. A single-node tree.

Java Implementation

public class BSTValidator {
    // Node class for the binary tree
    static class Node {
        int value;
        Node left, right;

        Node(int value) {
            this.value = value;
            this.left = null;
            this.right = null;
        }
    }

    // Validates if the tree is a BST
    public boolean isValidBST(Node root) {
        return validate(root, Long.MIN_VALUE, Long.MAX_VALUE);
    }

    private boolean validate(Node node, long min, long max) {
        if (node == null) {
            return true;
        }
        if (node.value <= min || node.value >= max) {
            return false;
        }
        return validate(node.left, min, node.value) && validate(node.right, node.value, max);
    }

    // Converts tree to string (preorder traversal)
    public String toString(Node root) {
        if (root == null) {
            return "Empty";
        }
        StringBuilder result = new StringBuilder();
        preorder(root, result);
        return "Preorder: " + result.toString().trim();
    }

    private void preorder(Node node, StringBuilder result) {
        if (node == null) {
            return;
        }
        result.append(node.value).append(" ");
        preorder(node.left, result);
        preorder(node.right, result);
    }

    // Helper class for test cases
    static class TestCase {
        Node root;

        TestCase(int[] values, int[][] edges) {
            if (values.length == 0) {
                root = null;
                return;
            }
            Node[] nodes = new Node[values.length];
            for (int i = 0; i < values.length; i++) {
                nodes[i] = new Node(values[i]);
            }
            for (int[] edge : edges) {
                int parent = edge[0], child = edge[1];
                if (edge[2] == 0) {
                    nodes[parent].left = nodes[child];
                } else {
                    nodes[parent].right = nodes[child];
                }
            }
            root = nodes[0];
        }
    }

    // Main method to test BST validator
    public static void main(String[] args) {
        BSTValidator validator = new BSTValidator();

        // Test cases
        TestCase[] testCases = {
            // Valid BST: [5, left: [3, left: 1, right: 4], right: 7]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}}
            ),
            // Invalid BST: [5, left: [3, left: 1, right: 6], right: 7]
            new TestCase(
                new int[]{5, 3, 1, 6, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}}
            ),
            // Empty tree
            new TestCase(new int[]{}, new int[][]{}),
            // Single node
            new TestCase(new int[]{10}, new int[][]{})
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Tree: " + validator.toString(testCases[i].root));
            boolean isValid = validator.isValidBST(testCases[i].root);
            System.out.println("Is Valid BST: " + isValid + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Tree: Preorder: 5 3 1 4 7
Is Valid BST: true

Test case 2:
Tree: Preorder: 5 3 1 6 7
Is Valid BST: false

Test case 3:
Tree: Empty
Is Valid BST: true

Test case 4:
Tree: Preorder: 10
Is Valid BST: true

Explanation:

  • Test case 1: Tree [5, left: [3, left: 1, right: 4], right: 7] is valid (all left subtree values < node, all right > node).
  • Test case 2: Tree [5, left: [3, left: 1, right: 6], right: 7] is invalid (6 > 5 in left subtree).
  • Test case 3: Empty tree is valid by definition.
  • Test case 4: Single node (10) is valid (no subtrees to violate).

How It Works

  • Node: Stores an integer value and pointers to left and right children.
  • isValidBST:
    • Uses recursive helper validate with min and max bounds.
    • Checks if node value is within (min, max).
    • Updates bounds for left (max = node.value) and right (min = node.value) subtrees.
    • Returns true for null nodes, false if bounds violated.
  • toString: Performs preorder traversal, returns space-separated values or "Empty".
  • Example Trace (Test case 1):
    • Root=5: validate(5, -∞, +∞) → true.
    • Left=3: validate(3, -∞, 5) → true.
    • Left=1: validate(1, -∞, 3) → true.
    • Right=4: validate(4, 3, 5) → true.
    • Right=7: validate(7, 5, +∞) → true.
    • Result: true.
  • Main Method: Tests valid BST, invalid BST, empty tree, and single node.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
isValidBSTO(n)O(h)
toStringO(n)O(h)

Note:

  • n is the number of nodes in the tree.
  • h is the height of the tree (O(n) for skewed, O(log n) for balanced).
  • Time complexity: O(n) for isValidBST and toString (visit each node once).
  • Space complexity: O(h) for recursion stack in isValidBST and toString.
  • Worst case: O(n) time and O(n) space for skewed trees.

✅ Tip: Use a range-based validation (min, max) to enforce the BST property recursively. Use long for bounds to handle edge cases with integer values near Integer.MIN_VALUE or MAX_VALUE.

⚠ Warning: Ensure no duplicate values in the BST, as they violate the strict inequality required. Handle empty trees and single nodes explicitly to avoid incorrect results.

Height of BST

Problem Statement

Write a Java program that extends a Binary Search Tree (BST) implementation to include a method that computes the height of the tree. The height of a tree is the number of edges on the longest path from the root to a leaf, with an empty tree having a height of -1. The program should reuse the BST node structure and test the height computation with balanced and unbalanced trees, including edge cases like an empty tree and a single-node tree. You can visualize this as measuring the tallest branch of a family tree, counting the number of parent-child connections from the top to the deepest descendant.

Input:

  • A binary search tree represented by nodes with integer values. Output: An integer representing the height of the tree, and a string representation of the tree for clarity. Constraints:
  • The tree has between 0 and 10^5 nodes.
  • Node values are integers in the range [-10^9, 10^9].
  • Duplicate values are not allowed in the BST. Example:
  • Input: Tree = [5, left: [3, left: [1], right: [4]], right: [7]] (balanced)
  • Output: Height = 2, "Preorder: 5 3 1 4 7"
  • Explanation: Longest path has 2 edges (e.g., 5→3→1).
  • Input: Tree = [5, right: [7, right: [9, right: [10]]] (unbalanced, skewed)
  • Output: Height = 3, "Preorder: 5 7 9 10"
  • Explanation: Longest path has 3 edges (5→7→9→10).
  • Input: Tree = []
  • Output: Height = -1, "Empty"
  • Explanation: Empty tree has height -1.

Pseudocode

CLASS Node
    SET value to integer
    SET left to Node (null by default)
    SET right to Node (null by default)
ENDCLASS

FUNCTION getHeight(root)
    IF root is null THEN
        RETURN -1
    ENDIF
    SET leftHeight to getHeight(root.left)
    SET rightHeight to getHeight(root.right)
    RETURN maximum of leftHeight, rightHeight plus 1
ENDFUNCTION

FUNCTION toString(root)
    IF root is null THEN
        RETURN "Empty"
    ENDIF
    CREATE result as new StringBuilder
    FUNCTION preorder(node)
        IF node is null THEN
            RETURN
        ENDIF
        APPEND node.value and " " to result
        CALL preorder(node.left)
        CALL preorder(node.right)
    ENDFUNCTION
    CALL preorder(root)
    RETURN "Preorder: " + result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of trees
    FOR each testCase in testCases
        PRINT test case details
        CALL getHeight(testCase.root)
        PRINT height and tree using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse the Node class with: a. An integer value. b. left and right pointers to child nodes.
  2. Define getHeight: a. If root is null, return -1. b. Recursively compute the height of the left subtree. c. Recursively compute the height of the right subtree. d. Return the maximum of left and right heights plus 1.
  3. Define toString: a. If root is null, return "Empty". b. Perform preorder traversal, appending values to a string.
  4. In main, test with: a. A balanced BST (e.g., complete binary tree). b. An unbalanced BST (e.g., skewed tree). c. An empty tree. d. A single-node tree.

Java Implementation

public class HeightOfBST {
    // Node class for the binary search tree
    static class Node {
        int value;
        Node left, right;

        Node(int value) {
            this.value = value;
            this.left = null;
            this.right = null;
        }
    }

    // Computes the height of the BST
    public int getHeight(Node root) {
        if (root == null) {
            return -1;
        }
        int leftHeight = getHeight(root.left);
        int rightHeight = getHeight(root.right);
        return Math.max(leftHeight, rightHeight) + 1;
    }

    // Converts tree to string (preorder traversal)
    public String toString(Node root) {
        if (root == null) {
            return "Empty";
        }
        StringBuilder result = new StringBuilder();
        preorder(root, result);
        return "Preorder: " + result.toString().trim();
    }

    private void preorder(Node node, StringBuilder result) {
        if (node == null) {
            return;
        }
        result.append(node.value).append(" ");
        preorder(node.left, result);
        preorder(node.right, result);
    }

    // Helper class for test cases
    static class TestCase {
        Node root;

        TestCase(int[] values, int[][] edges) {
            if (values.length == 0) {
                root = null;
                return;
            }
            Node[] nodes = new Node[values.length];
            for (int i = 0; i < values.length; i++) {
                nodes[i] = new Node(values[i]);
            }
            for (int[] edge : edges) {
                int parent = edge[0], child = edge[1];
                if (edge[2] == 0) {
                    nodes[parent].left = nodes[child];
                } else {
                    nodes[parent].right = nodes[child];
                }
            }
            root = nodes[0];
        }
    }

    // Main method to test BST height
    public static void main(String[] args) {
        HeightOfBST heightCalculator = new HeightOfBST();

        // Test cases
        TestCase[] testCases = {
            // Balanced BST: [5, left: [3, left: 1, right: 4], right: 7]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}}
            ),
            // Unbalanced BST: [5, right: [7, right: [9, right: 10]]]
            new TestCase(
                new int[]{5, 7, 9, 10},
                new int[][]{{0, 1, 1}, {1, 2, 1}, {2, 3, 1}}
            ),
            // Empty tree
            new TestCase(new int[]{}, new int[][]{}),
            // Single node
            new TestCase(new int[]{10}, new int[][]{})
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Tree: " + heightCalculator.toString(testCases[i].root));
            int height = heightCalculator.getHeight(testCases[i].root);
            System.out.println("Height: " + height + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Tree: Preorder: 5 3 1 4 7
Height: 2

Test case 2:
Tree: Preorder: 5 7 9 10
Height: 3

Test case 3:
Tree: Empty
Height: -1

Test case 4:
Tree: Preorder: 10
Height: 0

Explanation:

  • Test case 1: Balanced BST with root 5, height 2 (path 5→3→1 or 5→3→4).
  • Test case 2: Unbalanced (skewed) BST, height 3 (path 5→7→9→10).
  • Test case 3: Empty tree, height -1.
  • Test case 4: Single node, height 0 (no edges).

How It Works

  • Node: Stores an integer value and pointers to left and right children.
  • getHeight:
    • Returns -1 for null nodes (empty tree).
    • Recursively computes heights of left and right subtrees.
    • Returns maximum of left and right heights plus 1.
  • toString: Performs preorder traversal, returns space-separated values or "Empty".
  • Example Trace (Test case 1):
    • Root=5: leftHeight = getHeight(3), rightHeight = getHeight(7).
    • Node 3: leftHeight = getHeight(1) = 0, rightHeight = getHeight(4) = 0, return max(0, 0) + 1 = 1.
    • Node 7: leftHeight = -1, rightHeight = -1, return max(-1, -1) + 1 = 0.
    • Root: max(1, 0) + 1 = 2.
  • Main Method: Tests balanced BST, unbalanced BST, empty tree, and single node.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
getHeightO(n)O(h)
toStringO(n)O(h)

Note:

  • n is the number of nodes in the tree.
  • h is the height of the tree (O(n) for skewed, O(log n) for balanced).
  • Time complexity: O(n) for getHeight and toString (visit each node once).
  • Space complexity: O(h) for recursion stack in getHeight and toString.
  • Worst case: O(n) time and O(n) space for skewed trees.

✅ Tip: Use recursion to compute the height by taking the maximum of left and right subtree heights. Define empty tree height as -1 to align with edge-counting conventions.

⚠ Warning: Handle null nodes correctly to avoid null pointer exceptions. Be aware that unbalanced trees (e.g., skewed) have higher recursion stack space complexity.

Lowest Common Ancestor

Problem Statement

Write a Java program that extends a Binary Search Tree (BST) implementation to include a method that finds the lowest common ancestor (LCA) of two nodes given their values. The LCA is the deepest node that is an ancestor of both nodes. The program should reuse the BST node structure and test the LCA method with different pairs of values, including cases where both nodes exist, one or both don’t exist, and edge cases like empty trees or single-node trees. The BST property (left subtree values < node value < right subtree values) should be leveraged for efficiency. You can visualize this as finding the closest common ancestor in a family tree for two individuals, using the ordered structure to navigate efficiently.

Input:

  • A BST represented by nodes with integer values.
  • Two integer values, value1 and value2, representing the nodes to find the LCA for. Output: The value of the LCA node, or -1 if the LCA cannot be determined (e.g., one or both values don’t exist), and a string representation of the tree for clarity. Constraints:
  • The tree has between 0 and 10^5 nodes.
  • Node values, value1, and value2 are integers in the range [-10^9, 10^9].
  • Duplicate values are not allowed in the BST. Example:
  • Input: Tree = [5, left: [3, left: [1], right: [4]], right: [7]], Values = [1, 4]
  • Output: LCA = 3, "Preorder: 5 3 1 4 7"
  • Explanation: Node 3 is the LCA of nodes 1 and 4 (deepest common ancestor).
  • Input: Tree = [5, left: [3, left: [1], right: [4]], right: [7]], Values = [1, 6]
  • Output: LCA = -1, "Preorder: 5 3 1 4 7"
  • Explanation: Node 6 doesn’t exist, so no LCA.
  • Input: Tree = [], Values = [1, 2]
  • Output: LCA = -1, "Empty"
  • Explanation: Empty tree, no LCA.

Pseudocode

CLASS Node
    SET value to integer
    SET left to Node (null by default)
    SET right to Node (null by default)
ENDCLASS

FUNCTION findLCA(root, value1, value2)
    FUNCTION exists(node, value)
        IF node is null THEN
            RETURN false
        ENDIF
        IF node.value equals value THEN
            RETURN true
        ENDIF
        IF value < node.value THEN
            RETURN exists(node.left, value)
        ELSE
            RETURN exists(node.right, value)
        ENDIF
    ENDFUNCTION
    IF root is null OR NOT exists(root, value1) OR NOT exists(root, value2) THEN
        RETURN -1
    ENDIF
    SET current to root
    WHILE current is not null
        IF value1 < current.value AND value2 < current.value THEN
            SET current to current.left
        ELSE IF value1 > current.value AND value2 > current.value THEN
            SET current to current.right
        ELSE
            RETURN current.value
        ENDIF
    ENDWHILE
    RETURN -1
ENDFUNCTION

FUNCTION toString(root)
    IF root is null THEN
        RETURN "Empty"
    ENDIF
    CREATE result as new StringBuilder
    FUNCTION preorder(node)
        IF node is null THEN
            RETURN
        ENDIF
        APPEND node.value and " " to result
        CALL preorder(node.left)
        CALL preorder(node.right)
    ENDFUNCTION
    CALL preorder(root)
    RETURN "Preorder: " + result as string trimmed
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (tree, value1, value2) pairs
    FOR each testCase in testCases
        PRINT test case details
        CALL findLCA(testCase.root, testCase.value1, testCase.value2)
        PRINT LCA and tree using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse the Node class with: a. An integer value. b. left and right pointers to child nodes.
  2. Define findLCA: a. Check if both value1 and value2 exist in the tree using a helper function exists. b. If the tree is empty or either value doesn’t exist, return -1. c. Iteratively traverse from the root:
    • If both values are less than the current node’s value, move to the left child.
    • If both values are greater than the current node’s value, move to the right child.
    • Otherwise, the current node is the LCA (values split or one matches the node). d. Return the LCA’s value, or -1 if not found.
  3. Define toString: a. If root is null, return "Empty". b. Perform preorder traversal, appending values to a string.
  4. In main, test with: a. A pair where both nodes exist (LCA in subtree). b. A pair where one node doesn’t exist. c. An empty tree. d. A single-node tree with matching and non-matching pairs.

Java Implementation

public class LowestCommonAncestor {
    // Node class for the binary search tree
    static class Node {
        int value;
        Node left, right;

        Node(int value) {
            this.value = value;
            this.left = null;
            this.right = null;
        }
    }

    // Finds the lowest common ancestor of two values
    public int findLCA(Node root, int value1, int value2) {
        // Helper function to check if a value exists in the BST
        boolean exists(Node node, int value) {
            if (node == null) {
                return false;
            }
            if (node.value == value) {
                return true;
            }
            if (value < node.value) {
                return exists(node.left, value);
            } else {
                return exists(node.right, value);
            }
        }

        // Check if both values exist
        if (root == null || !exists(root, value1) || !exists(root, value2)) {
            return -1;
        }

        // Iterative LCA search
        Node current = root;
        while (current != null) {
            if (value1 < current.value && value2 < current.value) {
                current = current.left;
            } else if (value1 > current.value && value2 > current.value) {
                current = current.right;
            } else {
                return current.value;
            }
        }
        return -1;
    }

    // Converts tree to string (preorder traversal)
    public String toString(Node root) {
        if (root == null) {
            return "Empty";
        }
        StringBuilder result = new StringBuilder();
        preorder(root, result);
        return "Preorder: " + result.toString().trim();
    }

    private void preorder(Node node, StringBuilder result) {
        if (node == null) {
            return;
        }
        result.append(node.value).append(" ");
        preorder(node.left, result);
        preorder(node.right, result);
    }

    // Helper class for test cases
    static class TestCase {
        Node root;
        int value1, value2;

        TestCase(int[] values, int[][] edges, int value1, int value2) {
            this.value1 = value1;
            this.value2 = value2;
            if (values.length == 0) {
                root = null;
                return;
            }
            Node[] nodes = new Node[values.length];
            for (int i = 0; i < values.length; i++) {
                nodes[i] = new Node(values[i]);
            }
            for (int[] edge : edges) {
                int parent = edge[0], child = edge[1];
                if (edge[2] == 0) {
                    nodes[parent].left = nodes[child];
                } else {
                    nodes[parent].right = nodes[child];
                }
            }
            root = nodes[0];
        }
    }

    // Main method to test LCA
    public static void main(String[] args) {
        LowestCommonAncestor lcaFinder = new LowestCommonAncestor();

        // Test cases
        TestCase[] testCases = {
            // Both nodes exist: [5, left: [3, left: 1, right: 4], right: 7], values [1, 4]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}},
                1, 4
            ),
            // One node doesn’t exist: [5, left: [3, left: 1, right: 4], right: 7], values [1, 6]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}},
                1, 6
            ),
            // Empty tree: [], values [1, 2]
            new TestCase(new int[]{}, new int[][]{}, 1, 2),
            // Single node, matching pair: [10], values [10, 10]
            new TestCase(new int[]{10}, new int[][]{}, 10, 10),
            // Single node, non-matching pair: [10], values [10, 20]
            new TestCase(new int[]{10}, new int[][]{}, 10, 20)
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Tree: " + lcaFinder.toString(testCases[i].root));
            System.out.println("Values: [" + testCases[i].value1 + ", " + testCases[i].value2 + "]");
            int lca = lcaFinder.findLCA(testCases[i].root, testCases[i].value1, testCases[i].value2);
            System.out.println("LCA: " + lca + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Tree: Preorder: 5 3 1 4 7
Values: [1, 4]
LCA: 3

Test case 2:
Tree: Preorder: 5 3 1 4 7
Values: [1, 6]
LCA: -1

Test case 3:
Tree: Empty
Values: [1, 2]
LCA: -1

Test case 4:
Tree: Preorder: 10
Values: [10, 10]
LCA: 10

Test case 5:
Tree: Preorder: 10
Values: [10, 20]
LCA: -1

Explanation:

  • Test case 1: LCA of 1 and 4 is 3 (deepest common ancestor).
  • Test case 2: Node 6 doesn’t exist, so LCA is -1.
  • Test case 3: Empty tree, LCA is -1.
  • Test case 4: Both values are 10, LCA is 10 (same node).
  • Test case 5: Node 20 doesn’t exist, LCA is -1.

How It Works

  • Node: Stores an integer value and pointers to left and right children.
  • findLCA:
    • Uses exists helper to verify both value1 and value2 are in the tree.
    • If tree is empty or either value is missing, returns -1.
    • Iteratively traverses: if both values are less than current node, go left; if both greater, go right; otherwise, current node is LCA.
  • toString: Performs preorder traversal, returns space-separated values or "Empty".
  • Example Trace (Test case 1):
    • Verify 1 and 4 exist: true.
    • Root=5: 1 < 5, 4 < 5, go left.
    • Node=3: 1 ≤ 3, 4 > 3, return 3 (split point).
  • Main Method: Tests existing nodes, non-existing node, empty tree, single node with matching and non-matching pairs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
findLCAO(h)O(h)
toStringO(n)O(h)

Note:

  • n is the number of nodes in the tree.
  • h is the height of the tree (O(n) for skewed, O(log n) for balanced).
  • Time complexity: O(h) for findLCA (traverse to LCA and check existence); O(n) for toString (visit all nodes).
  • Space complexity: O(h) for recursion stack in exists and toString.
  • Worst case: O(n) time and O(n) space for skewed trees.

✅ Tip: Leverage the BST property to find the LCA efficiently by moving left or right based on both values’ relation to the current node. Verify node existence to handle invalid inputs.

⚠ Warning: Ensure both values exist in the tree before computing the LCA to avoid incorrect results. Handle edge cases like empty trees or identical values correctly.

Preorder and Postorder Traversals

Problem Statement

Write a Java program that extends a Binary Search Tree (BST) implementation to include methods for preorder (root, left, right) and postorder (left, right, root) traversals. The program should reuse the BST node structure and print the results of both traversals for sample trees, including balanced, unbalanced, empty, and single-node trees. The traversals should output node values in the respective orders as space-separated strings. You can visualize this as exploring a family tree in two different ways: preorder visits the parent before children, while postorder visits children before the parent, listing their names in the order visited.

Input:

  • A BST represented by nodes with integer values. Output: Two strings representing the preorder and postorder traversals of the tree, each containing space-separated node values, or "Empty" for an empty tree. Constraints:
  • The tree has between 0 and 10^5 nodes.
  • Node values are integers in the range [-10^9, 10^9].
  • Duplicate values are not allowed in the BST. Example:
  • Input: Tree = [5, left: [3, left: [1], right: [4]], right: [7]]
  • Output:
    • Preorder: "5 3 1 4 7"
    • Postorder: "1 4 3 7 5"
  • Explanation: Preorder visits root first (5), then left (3, 1, 4), then right (7); postorder visits left (1, 4), then right (7), then root (5).
  • Input: Tree = []
  • Output:
    • Preorder: "Empty"
    • Postorder: "Empty"
  • Explanation: Empty tree has no nodes to traverse.

Pseudocode

CLASS Node
    SET value to integer
    SET left to Node (null by default)
    SET right to Node (null by default)
ENDCLASS

FUNCTION preorderTraversal(root)
    IF root is null THEN
        RETURN "Empty"
    ENDIF
    CREATE result as new StringBuilder
    FUNCTION preorder(node)
        IF node is null THEN
            RETURN
        ENDIF
        APPEND node.value and " " to result
        CALL preorder(node.left)
        CALL preorder(node.right)
    ENDFUNCTION
    CALL preorder(root)
    RETURN result as string trimmed
ENDFUNCTION

FUNCTION postorderTraversal(root)
    IF root is null THEN
        RETURN "Empty"
    ENDIF
    CREATE result as new StringBuilder
    FUNCTION postorder(node)
        IF node is null THEN
            RETURN
        ENDIF
        CALL postorder(node.left)
        CALL postorder(node.right)
        APPEND node.value and " " to result
    ENDFUNCTION
    CALL postorder(root)
    RETURN result as string trimmed
ENDFUNCTION

FUNCTION main()
    SET testCases to array of trees
    FOR each testCase in testCases
        PRINT test case details
        CALL preorderTraversal(testCase.root)
        CALL postorderTraversal(testCase.root)
        PRINT preorder and postorder results
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse the Node class with: a. An integer value. b. left and right pointers to child nodes.
  2. Define preorderTraversal: a. If root is null, return "Empty". b. Use a helper function to:
    • Append node value.
    • Recurse on left subtree.
    • Recurse on right subtree. c. Return trimmed result string.
  3. Define postorderTraversal: a. If root is null, return "Empty". b. Use a helper function to:
    • Recurse on left subtree.
    • Recurse on right subtree.
    • Append node value. c. Return trimmed result string.
  4. In main, test with: a. A balanced BST. b. An unbalanced (skewed) BST. c. An empty tree. d. A single-node tree.

Java Implementation

public class PreorderPostorderTraversals {
    // Node class for the binary search tree
    static class Node {
        int value;
        Node left, right;

        Node(int value) {
            this.value = value;
            this.left = null;
            this.right = null;
        }
    }

    // Performs preorder traversal (root, left, right)
    public String preorderTraversal(Node root) {
        if (root == null) {
            return "Empty";
        }
        StringBuilder result = new StringBuilder();
        preorder(root, result);
        return result.toString().trim();
    }

    private void preorder(Node node, StringBuilder result) {
        if (node == null) {
            return;
        }
        result.append(node.value).append(" ");
        preorder(node.left, result);
        preorder(node.right, result);
    }

    // Performs postorder traversal (left, right, root)
    public String postorderTraversal(Node root) {
        if (root == null) {
            return "Empty";
        }
        StringBuilder result = new StringBuilder();
        postorder(root, result);
        return result.toString().trim();
    }

    private void postorder(Node node, StringBuilder result) {
        if (node == null) {
            return;
        }
        postorder(node.left, result);
        postorder(node.right, result);
        result.append(node.value).append(" ");
    }

    // Helper class for test cases
    static class TestCase {
        Node root;

        TestCase(int[] values, int[][] edges) {
            if (values.length == 0) {
                root = null;
                return;
            }
            Node[] nodes = new Node[values.length];
            for (int i = 0; i < values.length; i++) {
                nodes[i] = new Node(values[i]);
            }
            for (int[] edge : edges) {
                int parent = edge[0], child = edge[1];
                if (edge[2] == 0) {
                    nodes[parent].left = nodes[child];
                } else {
                    nodes[parent].right = nodes[child];
                }
            }
            root = nodes[0];
        }
    }

    // Main method to test traversals
    public static void main(String[] args) {
        PreorderPostorderTraversals traverser = new PreorderPostorderTraversals();

        // Test cases
        TestCase[] testCases = {
            // Balanced BST: [5, left: [3, left: 1, right: 4], right: 7]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}}
            ),
            // Unbalanced BST: [5, right: [7, right: [9, right: 10]]]
            new TestCase(
                new int[]{5, 7, 9, 10},
                new int[][]{{0, 1, 1}, {1, 2, 1}, {2, 3, 1}}
            ),
            // Empty tree
            new TestCase(new int[]{}, new int[][]{}),
            // Single node
            new TestCase(new int[]{10}, new int[][]{})
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Preorder: " + traverser.preorderTraversal(testCases[i].root));
            System.out.println("Postorder: " + traverser.postorderTraversal(testCases[i].root) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Preorder: 5 3 1 4 7
Postorder: 1 4 3 7 5

Test case 2:
Preorder: 5 7 9 10
Postorder: 10 9 7 5

Test case 3:
Preorder: Empty
Postorder: Empty

Test case 4:
Preorder: 10
Postorder: 10

Explanation:

  • Test case 1: Balanced BST, preorder visits root (5), left (3, 1, 4), right (7); postorder visits left (1, 4), right (7), root (5).
  • Test case 2: Unbalanced (skewed) BST, preorder visits 5, 7, 9, 10; postorder visits 10, 9, 7, 5.
  • Test case 3: Empty tree, both traversals return "Empty".
  • Test case 4: Single node, both traversals return "10".

How It Works

  • Node: Stores an integer value and pointers to left and right children.
  • preorderTraversal:
    • Returns "Empty" for null root.
    • Appends root value, recurses on left, then right.
  • postorderTraversal:
    • Returns "Empty" for null root.
    • Recurses on left, then right, then appends root value.
  • Example Trace (Test case 1):
    • Preorder: Visit 5, left (3, left (1), right (4)), right (7) → "5 3 1 4 7".
    • Postorder: Left (1, 4), right (7), root (5) → "1 4 3 7 5".
  • Main Method: Tests balanced BST, unbalanced BST, empty tree, and single node.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
preorderTraversalO(n)O(h)
postorderTraversalO(n)O(h)

Note:

  • n is the number of nodes in the tree.
  • h is the height of the tree (O(n) for skewed, O(log n) for balanced).
  • Time complexity: O(n) for both traversals (visit each node once).
  • Space complexity: O(h) for recursion stack.
  • Worst case: O(n) time and O(n) space for skewed trees.

✅ Tip: Use recursive traversal methods for simplicity, as they naturally follow the tree’s structure. Preorder is useful for copying a tree, while postorder is useful for deleting a tree.

⚠ Warning: Handle null nodes to avoid null pointer exceptions. Ensure the output string is trimmed to remove trailing spaces for clean presentation.

Range Sum Query

Problem Statement

Write a Java program that uses a Binary Search Tree (BST) to find the sum of all node values within a given range (e.g., between 20 and 60, inclusive). The program should use inorder traversal to collect values, leveraging the BST property to optimize by pruning branches outside the range. The implementation should reuse the BST node structure and test the range sum query with various trees and ranges, including edge cases like empty trees, ranges with no values in the tree, and ranges covering all or none of the tree’s values. You can visualize this as summing the ages of family members in a family tree whose ages fall within a specific range, using the tree’s ordered structure to efficiently skip irrelevant branches.

Input:

  • A BST represented by nodes with integer values.
  • Two integers, low and high, defining the inclusive range [low, high]. Output: An integer representing the sum of all node values within [low, high], and a string representation of the tree for clarity. Constraints:
  • The tree has between 0 and 10^5 nodes.
  • Node values, low, and high are integers in the range [-10^9, 10^9].
  • Duplicate values are not allowed in the BST.
  • lowhigh. Example:
  • Input: Tree = [5, left: [3, left: [1], right: [4]], right: [7]], Range = [2, 6]
  • Output: Sum = 12, "Preorder: 5 3 1 4 7"
  • Explanation: Values 3, 4, 5 are in [2, 6], sum = 3 + 4 + 5 = 12.
  • Input: Tree = [5, left: [3, left: [1], right: [4]], right: [7]], Range = [8, 10]
  • Output: Sum = 0, "Preorder: 5 3 1 4 7"
  • Explanation: No values in [8, 10], sum = 0.
  • Input: Tree = [], Range = [1, 5]
  • Output: Sum = 0, "Empty"
  • Explanation: Empty tree, sum = 0.

Pseudocode

CLASS Node
    SET value to integer
    SET left to Node (null by default)
    SET right to Node (null by default)
ENDCLASS

FUNCTION rangeSum(root, low, high)
    IF root is null THEN
        RETURN 0
    ENDIF
    IF root.value < low THEN
        RETURN rangeSum(root.right, low, high)
    ENDIF
    IF root.value > high THEN
        RETURN rangeSum(root.left, low, high)
    ENDIF
    RETURN root.value + rangeSum(root.left, low, high) + rangeSum(root.right, low, high)
ENDFUNCTION

FUNCTION toString(root)
    IF root is null THEN
        RETURN "Empty"
    ENDIF
    CREATE result as new StringBuilder
    FUNCTION preorder(node)
        IF node is null THEN
            RETURN
        ENDIF
        APPEND node.value and " " to result
        CALL preorder(node.left)
        CALL preorder(node.right)
    ENDFUNCTION
    CALL preorder(root)
    RETURN "Preorder: " + result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (tree, low, high) pairs
    FOR each testCase in testCases
        PRINT test case details
        CALL rangeSum(testCase.root, testCase.low, testCase.high)
        PRINT sum and tree using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse the Node class with: a. An integer value. b. left and right pointers to child nodes.
  2. Define rangeSum: a. If root is null, return 0. b. If root.value < low, skip left subtree, recurse on right. c. If root.value > high, skip right subtree, recurse on left. d. If root.value is in [low, high], include value and recurse on both subtrees.
  3. Define toString: a. If root is null, return "Empty". b. Perform preorder traversal, appending values to a string.
  4. In main, test with: a. A tree with values in the range. b. A tree with no values in the range. c. An empty tree. d. A range covering all values. e. A single-node tree.

Java Implementation

public class RangeSumQuery {
    // Node class for the binary search tree
    static class Node {
        int value;
        Node left, right;

        Node(int value) {
            this.value = value;
            this.left = null;
            this.right = null;
        }
    }

    // Computes the sum of values in the range [low, high]
    public int rangeSum(Node root, int low, int high) {
        if (root == null) {
            return 0;
        }
        if (root.value < low) {
            return rangeSum(root.right, low, high);
        }
        if (root.value > high) {
            return rangeSum(root.left, low, high);
        }
        return root.value + rangeSum(root.left, low, high) + rangeSum(root.right, low, high);
    }

    // Converts tree to string (preorder traversal)
    public String toString(Node root) {
        if (root == null) {
            return "Empty";
        }
        StringBuilder result = new StringBuilder();
        preorder(root, result);
        return "Preorder: " + result.toString().trim();
    }

    private void preorder(Node node, StringBuilder result) {
        if (node == null) {
            return;
        }
        result.append(node.value).append(" ");
        preorder(node.left, result);
        preorder(node.right, result);
    }

    // Helper class for test cases
    static class TestCase {
        Node root;
        int low, high;

        TestCase(int[] values, int[][] edges, int low, int high) {
            this.low = low;
            this.high = high;
            if (values.length == 0) {
                root = null;
                return;
            }
            Node[] nodes = new Node[values.length];
            for (int i = 0; i < values.length; i++) {
                nodes[i] = new Node(values[i]);
            }
            for (int[] edge : edges) {
                int parent = edge[0], child = edge[1];
                if (edge[2] == 0) {
                    nodes[parent].left = nodes[child];
                } else {
                    nodes[parent].right = nodes[child];
                }
            }
            root = nodes[0];
        }
    }

    // Main method to test range sum query
    public static void main(String[] args) {
        RangeSumQuery query = new RangeSumQuery();

        // Test cases
        TestCase[] testCases = {
            // Values in range: [5, left: [3, left: 1, right: 4], right: 7], range [2, 6]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}},
                2, 6
            ),
            // No values in range: [5, left: [3, left: 1, right: 4], right: 7], range [8, 10]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}},
                8, 10
            ),
            // Empty tree: [], range [1, 5]
            new TestCase(new int[]{}, new int[][]{}, 1, 5),
            // Range covers all: [5, left: [3, left: 1, right: 4], right: 7], range [0, 10]
            new TestCase(
                new int[]{5, 3, 1, 4, 7},
                new int[][]{{0, 1, 0}, {0, 4, 1}, {1, 2, 0}, {1, 3, 1}},
                0, 10
            ),
            // Single node: [10], range [5, 15]
            new TestCase(new int[]{10}, new int[][]{}, 5, 15)
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Tree: " + query.toString(testCases[i].root));
            System.out.println("Range: [" + testCases[i].low + ", " + testCases[i].high + "]");
            int sum = query.rangeSum(testCases[i].root, testCases[i].low, testCases[i].high);
            System.out.println("Sum: " + sum + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Tree: Preorder: 5 3 1 4 7
Range: [2, 6]
Sum: 12

Test case 2:
Tree: Preorder: 5 3 1 4 7
Range: [8, 10]
Sum: 0

Test case 3:
Tree: Empty
Range: [1, 5]
Sum: 0

Test case 4:
Tree: Preorder: 5 3 1 4 7
Range: [0, 10]
Sum: 20

Test case 5:
Tree: Preorder: 10
Range: [5, 15]
Sum: 10

Explanation:

  • Test case 1: Values 3, 4, 5 are in [2, 6], sum = 3 + 4 + 5 = 12.
  • Test case 2: No values in [8, 10], sum = 0.
  • Test case 3: Empty tree, sum = 0.
  • Test case 4: All values (1, 3, 4, 5, 7) in [0, 10], sum = 1 + 3 + 4 + 5 + 7 = 20.
  • Test case 5: Single node 10 in [5, 15], sum = 10.

How It Works

  • Node: Stores an integer value and pointers to left and right children.
  • rangeSum:
    • Returns 0 for null nodes.
    • If root.value < low, skips left subtree, recurses on right.
    • If root.value > high, skips right subtree, recurses on left.
    • If root.value in [low, high], includes value and recurses on both subtrees.
  • toString: Performs preorder traversal, returns space-separated values or "Empty".
  • Example Trace (Test case 1):
    • Root=5: In [2, 6], sum = 5 + rangeSum(left, 2, 6) + rangeSum(right, 2, 6).
    • Left=3: In [2, 6], sum = 3 + rangeSum(left=1, 2, 6) + rangeSum(right=4, 2, 6).
    • Left=1: Not in [2, 6], skip left, sum = rangeSum(right=null, 2, 6) = 0.
    • Right=4: In [2, 6], sum = 4 + rangeSum(null, 2, 6) + rangeSum(null, 2, 6) = 4.
    • Right=7: > 6, skip right, sum = rangeSum(left=null, 2, 6) = 0.
    • Total: 5 + (3 + 0 + 4) + 0 = 12.
  • Main Method: Tests range with values, no values, empty tree, all values, and single node.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
rangeSumO(n) worst caseO(h)
toStringO(n)O(h)

Note:

  • n is the number of nodes in the tree.
  • h is the height of the tree (O(n) for skewed, O(log n) for balanced).
  • Time complexity: O(n) for rangeSum in worst case (all nodes in range); O(h) in best case (pruning skips subtrees).
  • Space complexity: O(h) for recursion stack in rangeSum and toString.
  • Worst case: O(n) time and O(n) space for skewed trees with all nodes in range.

✅ Tip: Leverage the BST property to prune branches outside the range, reducing unnecessary traversals. Use inorder traversal logic to ensure values are processed in sorted order.

⚠ Warning: Ensure the range is inclusive ([low, high]) and handle edge cases like empty trees or ranges outside the tree’s values to avoid incorrect sums.

Graphs Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Cycle Detection

Problem Statement

Write a Java program that extends an undirected graph implementation to detect if the graph contains a cycle using Depth-First Search (DFS). A cycle exists if there is a path that starts and ends at the same vertex, passing through at least one other vertex. The program should reuse the adjacency list representation and test cycle detection with cyclic and acyclic graphs, including edge cases like empty graphs and single-vertex graphs. You can visualize this as exploring a network of roads to check if you can loop back to a starting city without retracing steps, ensuring no dead ends create a false cycle.

Input:

  • An undirected graph represented by an adjacency list, with vertices as integers (0 to n-1).
  • The number of vertices n and a list of edges (pairs of vertices). Output: A boolean indicating whether the graph contains a cycle, and a string representation of the graph’s adjacency list for clarity. Constraints:
  • The number of vertices n is between 0 and 10^5.
  • Edges are pairs of integers [u, v] where 0 ≤ u, v < n.
  • The graph is undirected (edge [u, v] implies [v, u]). Example:
  • Input: n = 4, edges = [[0, 1], [1, 2], [2, 3], [3, 0]]
  • Output: Has Cycle = true, "Adjacency List: {0=[1, 3], 1=[0, 2], 2=[1, 3], 3=[2, 0]}"
  • Explanation: The graph has a cycle (0→1→2→3→0).
  • Input: n = 4, edges = [[0, 1], [1, 2], [2, 3]]
  • Output: Has Cycle = false, "Adjacency List: {0=[1], 1=[0, 2], 2=[1, 3], 3=[2]}"
  • Explanation: The graph is a tree (acyclic).
  • Input: n = 0, edges = []
  • Output: Has Cycle = false, "Adjacency List: {}"
  • Explanation: An empty graph has no cycles.

Pseudocode

FUNCTION createGraph(n, edges)
    CREATE adjList as new HashMap
    FOR i from 0 to n-1
        SET adjList[i] to empty list
    ENDFOR
    FOR each edge [u, v] in edges
        ADD v to adjList[u]
        ADD u to adjList[v]
    ENDFOR
    RETURN adjList
ENDFUNCTION

FUNCTION hasCycle(adjList, n)
    IF n is 0 THEN
        RETURN false
    ENDIF
    CREATE visited as boolean array of size n, initialized to false
    FUNCTION dfs(vertex, parent)
        SET visited[vertex] to true
        FOR each neighbor in adjList[vertex]
            IF NOT visited[neighbor] THEN
                IF dfs(neighbor, vertex) THEN
                    RETURN true
                ENDIF
            ELSE IF neighbor is not parent THEN
                RETURN true
            ENDIF
        ENDFOR
        RETURN false
    ENDFUNCTION
    FOR i from 0 to n-1
        IF NOT visited[i] THEN
            IF dfs(i, -1) THEN
                RETURN true
            ENDIF
        ENDIF
    ENDFOR
    RETURN false
ENDFUNCTION

FUNCTION toString(adjList)
    CREATE result as new StringBuilder
    APPEND "Adjacency List: {" to result
    FOR each vertex in adjList
        APPEND vertex and "=" and adjList[vertex] to result
        IF vertex is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "}" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (n, edges) pairs
    FOR each testCase in testCases
        PRINT test case details
        SET adjList to createGraph(testCase.n, testCase.edges)
        CALL hasCycle(adjList, testCase.n)
        PRINT cycle result and graph using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse createGraph: a. Create a HashMap adjList mapping vertices to lists of neighbors. b. Initialize empty lists for vertices 0 to n-1. c. Add edges bidirectionally (u→v, v→u).
  2. Define hasCycle: a. If n = 0, return false (empty graph has no cycles). b. Use DFS with a parent parameter to avoid false cycles:
    • Mark vertex as visited.
    • For each neighbor, if unvisited, recurse; if visited and not parent, cycle found. c. Run DFS from each unvisited vertex to handle disconnected components. d. Return true if a cycle is found, false otherwise.
  3. Define toString: a. Convert adjacency list to a string, e.g., "{0=[1, 3], 1=[0, 2], ...}".
  4. In main, test with: a. A cyclic graph. b. An acyclic graph (tree). c. An empty graph (n = 0). d. A single-vertex graph.

Java Implementation

import java.util.*;

public class CycleDetection {
    // Creates adjacency list representation of the graph
    private Map<Integer, List<Integer>> createGraph(int n, int[][] edges) {
        Map<Integer, List<Integer>> adjList = new HashMap<>();
        for (int i = 0; i < n; i++) {
            adjList.put(i, new ArrayList<>());
        }
        for (int[] edge : edges) {
            int u = edge[0], v = edge[1];
            adjList.get(u).add(v);
            adjList.get(v).add(u); // Undirected graph
        }
        return adjList;
    }

    // Checks if the graph contains a cycle using DFS
    public boolean hasCycle(Map<Integer, List<Integer>> adjList, int n) {
        if (n == 0) {
            return false;
        }
        boolean[] visited = new boolean[n];
        for (int i = 0; i < n; i++) {
            if (!visited[i]) {
                if (dfs(i, -1, adjList, visited)) {
                    return true;
                }
            }
        }
        return false;
    }

    private boolean dfs(int vertex, int parent, Map<Integer, List<Integer>> adjList, boolean[] visited) {
        visited[vertex] = true;
        for (int neighbor : adjList.get(vertex)) {
            if (!visited[neighbor]) {
                if (dfs(neighbor, vertex, adjList, visited)) {
                    return true;
                }
            } else if (neighbor != parent) {
                return true; // Cycle found
            }
        }
        return false;
    }

    // Converts graph to string (adjacency list)
    public String toString(Map<Integer, List<Integer>> adjList) {
        StringBuilder result = new StringBuilder("Adjacency List: {");
        List<Integer> vertices = new ArrayList<>(adjList.keySet());
        Collections.sort(vertices); // For consistent output
        for (int i = 0; i < vertices.size(); i++) {
            int vertex = vertices.get(i);
            result.append(vertex).append("=").append(adjList.get(vertex));
            if (i < vertices.size() - 1) {
                result.append(", ");
            }
        }
        result.append("}");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int n;
        int[][] edges;

        TestCase(int n, int[][] edges) {
            this.n = n;
            this.edges = edges;
        }
    }

    // Main method to test cycle detection
    public static void main(String[] args) {
        CycleDetection cycleDetector = new CycleDetection();

        // Test cases
        TestCase[] testCases = {
            // Cyclic graph: 0-1-2-3-0
            new TestCase(4, new int[][]{{0, 1}, {1, 2}, {2, 3}, {3, 0}}),
            // Acyclic graph (tree): 0-1-2-3
            new TestCase(4, new int[][]{{0, 1}, {1, 2}, {2, 3}}),
            // Empty graph
            new TestCase(0, new int[][]{}),
            // Single vertex
            new TestCase(1, new int[][]{})
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Vertices: " + testCases[i].n);
            System.out.println("Edges: " + Arrays.deepToString(testCases[i].edges));
            Map<Integer, List<Integer>> adjList = cycleDetector.createGraph(testCases[i].n, testCases[i].edges);
            boolean hasCycle = cycleDetector.hasCycle(adjList, testCases[i].n);
            System.out.println("Has Cycle: " + hasCycle);
            System.out.println(cycleDetector.toString(adjList) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Vertices: 4
Edges: [[0, 1], [1, 2], [2, 3], [3, 0]]
Has Cycle: true
Adjacency List: {0=[1, 3], 1=[0, 2], 2=[1, 3], 3=[2, 0]}

Test case 2:
Vertices: 4
Edges: [[0, 1], [1, 2], [2, 3]]
Has Cycle: false
Adjacency List: {0=[1], 1=[0, 2], 2=[1, 3], 3=[2]}

Test case 3:
Vertices: 0
Edges: []
Has Cycle: false
Adjacency List: {}

Test case 4:
Vertices: 1
Edges: []
Has Cycle: false
Adjacency List: {0=[]}

Explanation:

  • Test case 1: Graph has a cycle (0→1→2→3→0), so returns true.
  • Test case 2: Graph is a tree (acyclic), so returns false.
  • Test case 3: Empty graph has no cycles, returns false.
  • Test case 4: Single vertex has no cycles, returns false.

How It Works

  • createGraph: Builds an adjacency list using a HashMap, adding undirected edges (u→v, v→u).
  • hasCycle:
    • Returns false for n = 0 (empty graph).
    • Runs DFS from each unvisited vertex to handle disconnected components.
    • In DFS, marks vertex as visited, checks neighbors:
      • If neighbor is unvisited, recurse.
      • If neighbor is visited and not the parent, a cycle is found.
    • Returns true if a cycle is detected, false otherwise.
  • dfs: Tracks parent to avoid false cycles in undirected graphs.
  • toString: Formats adjacency list as a string, sorting vertices for consistency.
  • Example Trace (Test case 1):
    • DFS from 0: Visit 0, explore 1 (parent=0), explore 2 (parent=1), explore 3 (parent=2).
    • At 3, neighbor 0 is visited and not parent (2), cycle found.
  • Main Method: Tests cyclic graph, acyclic graph, empty graph, and single vertex.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
createGraphO(n + e)O(n + e)
hasCycleO(n + e)O(n)
toStringO(n log n + e)O(n + e)

Note:

  • n is the number of vertices, e is the number of edges.
  • Time complexity: O(n + e) for createGraph (initialize lists and add edges); O(n + e) for hasCycle (DFS visits each vertex and edge once); O(n log n + e) for toString (sorting vertices).
  • Space complexity: O(n + e) for createGraph and toString (adjacency list); O(n) for hasCycle (visited array and recursion stack).
  • Worst case: O(n + e) time and space for dense graphs.

✅ Tip: Use DFS with a parent parameter to detect cycles in undirected graphs, ensuring visited neighbors that aren’t parents indicate a cycle. Start DFS from each unvisited vertex to handle disconnected components.

⚠ Warning: In undirected graphs, avoid false cycle detection by checking the parent vertex. Handle edge cases like empty graphs or single vertices to ensure correct results.

Graph Connectivity

Problem Statement

Write a Java program that implements a graph using an adjacency list and checks if the graph is connected, meaning all vertices are reachable from a starting vertex, using Depth-First Search (DFS). The graph is undirected, and connectivity is determined by checking if a DFS from any vertex visits all vertices. The program should test the connectivity check with various graphs, including connected, disconnected, empty, and single-vertex graphs. You can visualize this as exploring a network of cities connected by roads, ensuring you can travel from one city to all others.

Input:

  • An undirected graph represented by an adjacency list, with vertices as integers (0 to n-1).
  • The number of vertices n and a list of edges (pairs of vertices). Output: A boolean indicating whether the graph is connected, and a string representation of the graph’s adjacency list for clarity. Constraints:
  • The number of vertices n is between 0 and 10^5.
  • Edges are pairs of integers [u, v] where 0 ≤ u, v < n.
  • The graph is undirected (edge [u, v] implies [v, u]). Example:
  • Input: n = 5, edges = [[0, 1], [1, 2], [2, 3], [3, 4]]
  • Output: Connected = true, "Adjacency List: {0=[1], 1=[0, 2], 2=[1, 3], 3=[2, 4], 4=[3]}"
  • Explanation: All vertices are reachable from vertex 0.
  • Input: n = 5, edges = [[0, 1], [2, 3]]
  • Output: Connected = false, "Adjacency List: {0=[1], 1=[0], 2=[3], 3=[2], 4=[]}"
  • Explanation: Vertex 4 is unreachable, so the graph is disconnected.
  • Input: n = 0, edges = []
  • Output: Connected = true, "Adjacency List: {}"
  • Explanation: An empty graph is considered connected.

Pseudocode

FUNCTION createGraph(n, edges)
    CREATE adjList as new HashMap
    FOR i from 0 to n-1
        SET adjList[i] to empty list
    ENDFOR
    FOR each edge [u, v] in edges
        ADD v to adjList[u]
        ADD u to adjList[v]
    ENDFOR
    RETURN adjList
ENDFUNCTION

FUNCTION isConnected(adjList, n)
    IF n is 0 THEN
        RETURN true
    ENDIF
    CREATE visited as boolean array of size n, initialized to false
    FUNCTION dfs(vertex)
        SET visited[vertex] to true
        FOR each neighbor in adjList[vertex]
            IF NOT visited[neighbor] THEN
                CALL dfs(neighbor)
            ENDIF
        ENDFOR
    ENDFUNCTION
    CALL dfs(0)
    FOR i from 0 to n-1
        IF NOT visited[i] THEN
            RETURN false
        ENDIF
    ENDFOR
    RETURN true
ENDFUNCTION

FUNCTION toString(adjList)
    CREATE result as new StringBuilder
    APPEND "Adjacency List: {" to result
    FOR each vertex in adjList
        APPEND vertex and "=" and adjList[vertex] to result
        IF vertex is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "}" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (n, edges) pairs
    FOR each testCase in testCases
        PRINT test case details
        SET adjList to createGraph(testCase.n, testCase.edges)
        CALL isConnected(adjList, testCase.n)
        PRINT connected result and graph using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define createGraph: a. Create a HashMap adjList mapping vertices to lists of neighbors. b. Initialize empty lists for vertices 0 to n-1. c. Add edges bidirectionally (u→v, v→u).
  2. Define isConnected: a. If n = 0, return true (empty graph is connected). b. Use DFS starting from vertex 0:
    • Mark vertex as visited.
    • Recursively visit unvisited neighbors. c. Check if all vertices are visited; return false if any are not.
  3. Define toString: a. Convert adjacency list to a string, e.g., "{0=[1], 1=[0, 2], ...}".
  4. In main, test with: a. A connected graph. b. A disconnected graph. c. An empty graph (n = 0). d. A single-vertex graph.

Java Implementation

import java.util.*;

public class GraphConnectivity {
    // Creates adjacency list representation of the graph
    private Map<Integer, List<Integer>> createGraph(int n, int[][] edges) {
        Map<Integer, List<Integer>> adjList = new HashMap<>();
        for (int i = 0; i < n; i++) {
            adjList.put(i, new ArrayList<>());
        }
        for (int[] edge : edges) {
            int u = edge[0], v = edge[1];
            adjList.get(u).add(v);
            adjList.get(v).add(u); // Undirected graph
        }
        return adjList;
    }

    // Checks if the graph is connected using DFS
    public boolean isConnected(Map<Integer, List<Integer>> adjList, int n) {
        if (n == 0) {
            return true;
        }
        boolean[] visited = new boolean[n];
        dfs(0, adjList, visited);
        for (int i = 0; i < n; i++) {
            if (!visited[i]) {
                return false;
            }
        }
        return true;
    }

    private void dfs(int vertex, Map<Integer, List<Integer>> adjList, boolean[] visited) {
        visited[vertex] = true;
        for (int neighbor : adjList.get(vertex)) {
            if (!visited[neighbor]) {
                dfs(neighbor, adjList, visited);
            }
        }
    }

    // Converts graph to string (adjacency list)
    public String toString(Map<Integer, List<Integer>> adjList) {
        StringBuilder result = new StringBuilder("Adjacency List: {");
        List<Integer> vertices = new ArrayList<>(adjList.keySet());
        Collections.sort(vertices); // For consistent output
        for (int i = 0; i < vertices.size(); i++) {
            int vertex = vertices.get(i);
            result.append(vertex).append("=").append(adjList.get(vertex));
            if (i < vertices.size() - 1) {
                result.append(", ");
            }
        }
        result.append("}");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int n;
        int[][] edges;

        TestCase(int n, int[][] edges) {
            this.n = n;
            this.edges = edges;
        }
    }

    // Main method to test graph connectivity
    public static void main(String[] args) {
        GraphConnectivity connectivityChecker = new GraphConnectivity();

        // Test cases
        TestCase[] testCases = {
            // Connected graph: 0-1-2-3-4
            new TestCase(5, new int[][]{{0, 1}, {1, 2}, {2, 3}, {3, 4}}),
            // Disconnected graph: 0-1, 2-3, 4 isolated
            new TestCase(5, new int[][]{{0, 1}, {2, 3}}),
            // Empty graph
            new TestCase(0, new int[][]{}),
            // Single vertex
            new TestCase(1, new int[][]{})
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Vertices: " + testCases[i].n);
            System.out.println("Edges: " + Arrays.deepToString(testCases[i].edges));
            Map<Integer, List<Integer>> adjList = connectivityChecker.createGraph(testCases[i].n, testCases[i].edges);
            boolean isConnected = connectivityChecker.isConnected(adjList, testCases[i].n);
            System.out.println("Connected: " + isConnected);
            System.out.println(connectivityChecker.toString(adjList) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Vertices: 5
Edges: [[0, 1], [1, 2], [2, 3], [3, 4]]
Connected: true
Adjacency List: {0=[1], 1=[0, 2], 2=[1, 3], 3=[2, 4], 4=[3]}

Test case 2:
Vertices: 5
Edges: [[0, 1], [2, 3]]
Connected: false
Adjacency List: {0=[1], 1=[0], 2=[3], 3=[2], 4=[]}

Test case 3:
Vertices: 0
Edges: []
Connected: true
Adjacency List: {}

Test case 4:
Vertices: 1
Edges: []
Connected: true
Adjacency List: {0=[]}

Explanation:

  • Test case 1: All vertices are reachable from 0 (0→1→2→3→4), so connected.
  • Test case 2: Vertex 4 is isolated, so disconnected.
  • Test case 3: Empty graph is connected by definition.
  • Test case 4: Single vertex is connected (no edges needed).

How It Works

  • createGraph: Builds an adjacency list using a HashMap, adding undirected edges (u→v, v→u).
  • isConnected:
    • Returns true for n = 0 (empty graph).
    • Runs DFS from vertex 0, marking visited vertices.
    • Checks if all vertices are visited; returns false if any are not.
  • dfs: Marks current vertex, recursively visits unvisited neighbors.
  • toString: Formats adjacency list as a string, sorting vertices for consistency.
  • Example Trace (Test case 1):
    • DFS from 0: Visit 0, 1, 2, 3, 4 (via 0→1→2→3→4).
    • All vertices visited, return true.
  • Main Method: Tests connected graph, disconnected graph, empty graph, and single vertex.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
createGraphO(n + e)O(n + e)
isConnectedO(n + e)O(n)
toStringO(n log n + e)O(n + e)

Note:

  • n is the number of vertices, e is the number of edges.
  • Time complexity: O(n + e) for createGraph (initialize lists and add edges); O(n + e) for isConnected (DFS visits each vertex and edge once); O(n log n + e) for toString (sorting vertices).
  • Space complexity: O(n + e) for createGraph and toString (adjacency list); O(n) for isConnected (visited array and recursion stack).
  • Worst case: O(n + e) time and space for dense graphs.

✅ Tip: Use DFS to efficiently explore all reachable vertices from a starting point. For undirected graphs, a single DFS from any vertex is sufficient to check connectivity.

⚠ Warning: Ensure the graph is undirected by adding edges both ways. Handle edge cases like empty graphs or isolated vertices to avoid incorrect connectivity results.

Shortest Path (Unweighted)

Problem Statement

Write a Java program that extends an unweighted undirected graph implementation to find the shortest path between two vertices using Breadth-First Search (BFS). The shortest path is the sequence of vertices with the minimum number of edges from the source to the target vertex. The program should use an adjacency list representation, return the path as a list of vertices, and test with various graphs and vertex pairs, including cases where a path exists, no path exists, and edge cases like empty or single-vertex graphs. You can visualize this as finding the fewest roads to travel between two cities in a network, where each road has the same length.

Input:

  • An undirected unweighted graph represented by an adjacency list, with vertices as integers (0 to n-1).
  • The number of vertices n, a list of edges (pairs of vertices), and two vertices source and target. Output: A list of integers representing the shortest path from source to target, or an empty list if no path exists, and a string representation of the graph’s adjacency list for clarity. Constraints:
  • The number of vertices n is between 0 and 10^5.
  • Edges are pairs of integers [u, v] where 0 ≤ u, v < n.
  • Source and target are integers in [0, n-1].
  • The graph is undirected (edge [u, v] implies [v, u]). Example:
  • Input: n = 5, edges = [[0, 1], [1, 2], [2, 3], [3, 4], [1, 4]], source = 0, target = 4
  • Output: Path = [0, 1, 4], "Adjacency List: {0=[1], 1=[0, 2, 4], 2=[1, 3], 3=[2, 4], 4=[3, 1]}"
  • Explanation: Shortest path from 0 to 4 is 0→1→4 (2 edges).
  • Input: n = 5, edges = [[0, 1], [2, 3]], source = 0, target = 3
  • Output: Path = [], "Adjacency List: {0=[1], 1=[0], 2=[3], 3=[2], 4=[]}"
  • Explanation: No path exists from 0 to 3.
  • Input: n = 0, edges = [], source = 0, target = 0
  • Output: Path = [], "Adjacency List: {}"
  • Explanation: Empty graph, no path exists.

Pseudocode

FUNCTION createGraph(n, edges)
    CREATE adjList as new HashMap
    FOR i from 0 to n-1
        SET adjList[i] to empty list
    ENDFOR
    FOR each edge [u, v] in edges
        ADD v to adjList[u]
        ADD u to adjList[v]
    ENDFOR
    RETURN adjList
ENDFUNCTION

FUNCTION shortestPath(adjList, n, source, target)
    IF n is 0 OR source >= n OR target >= n THEN
        RETURN empty list
    ENDIF
    CREATE visited as boolean array of size n, initialized to false
    CREATE parent as integer array of size n, initialized to -1
    CREATE queue as new Queue
    ADD source to queue
    SET visited[source] to true
    WHILE queue is not empty
        SET current to queue.dequeue
        IF current equals target THEN
            CREATE path as new list
            SET node to target
            WHILE node is not -1
                PREPEND node to path
                SET node to parent[node]
            ENDWHILE
            RETURN path
        ENDIF
        FOR each neighbor in adjList[current]
            IF NOT visited[neighbor] THEN
                SET visited[neighbor] to true
                SET parent[neighbor] to current
                ADD neighbor to queue
            ENDIF
        ENDFOR
    ENDWHILE
    RETURN empty list
ENDFUNCTION

FUNCTION toString(adjList)
    CREATE result as new StringBuilder
    APPEND "Adjacency List: {" to result
    FOR each vertex in adjList
        APPEND vertex and "=" and adjList[vertex] to result
        IF vertex is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "}" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (n, edges, source, target) pairs
    FOR each testCase in testCases
        PRINT test case details
        SET adjList to createGraph(testCase.n, testCase.edges)
        CALL shortestPath(adjList, testCase.n, testCase.source, testCase.target)
        PRINT path and graph using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse createGraph: a. Create a HashMap adjList mapping vertices to lists of neighbors. b. Initialize empty lists for vertices 0 to n-1. c. Add edges bidirectionally (u→v, v→u).
  2. Define shortestPath: a. If n = 0 or source/target ≥ n, return empty list. b. Use BFS:
    • Initialize a queue with source, mark source as visited, set parent array.
    • Dequeue vertex, if it’s target, reconstruct path using parent array.
    • For each unvisited neighbor, mark visited, set parent, enqueue. c. If target not reached, return empty list.
  3. Define toString: a. Convert adjacency list to a string, e.g., "{0=[1], 1=[0, 2], ...}".
  4. In main, test with: a. A graph with a valid path. b. A graph with no path between vertices. c. An empty graph. d. A single-vertex graph.

Java Implementation

import java.util.*;

public class ShortestPathUnweighted {
    // Creates adjacency list representation of the graph
    private Map<Integer, List<Integer>> createGraph(int n, int[][] edges) {
        Map<Integer, List<Integer>> adjList = new HashMap<>();
        for (int i = 0; i < n; i++) {
            adjList.put(i, new ArrayList<>());
        }
        for (int[] edge : edges) {
            int u = edge[0], v = edge[1];
            adjList.get(u).add(v);
            adjList.get(v).add(u); // Undirected graph
        }
        return adjList;
    }

    // Finds shortest path from source to target using BFS
    public List<Integer> shortestPath(Map<Integer, List<Integer>> adjList, int n, int source, int target) {
        List<Integer> path = new ArrayList<>();
        if (n == 0 || source >= n || target >= n) {
            return path;
        }
        boolean[] visited = new boolean[n];
        int[] parent = new int[n];
        Arrays.fill(parent, -1);
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(source);
        visited[source] = true;

        while (!queue.isEmpty()) {
            int current = queue.poll();
            if (current == target) {
                // Reconstruct path
                int node = target;
                while (node != -1) {
                    path.add(0, node);
                    node = parent[node];
                }
                return path;
            }
            for (int neighbor : adjList.get(current)) {
                if (!visited[neighbor]) {
                    visited[neighbor] = true;
                    parent[neighbor] = current;
                    queue.offer(neighbor);
                }
            }
        }
        return path; // Empty if no path exists
    }

    // Converts graph to string (adjacency list)
    public String toString(Map<Integer, List<Integer>> adjList) {
        StringBuilder result = new StringBuilder("Adjacency List: {");
        List<Integer> vertices = new ArrayList<>(adjList.keySet());
        Collections.sort(vertices); // For consistent output
        for (int i = 0; i < vertices.size(); i++) {
            int vertex = vertices.get(i);
            result.append(vertex).append("=").append(adjList.get(vertex));
            if (i < vertices.size() - 1) {
                result.append(", ");
            }
        }
        result.append("}");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int n;
        int[][] edges;
        int source, target;

        TestCase(int n, int[][] edges, int source, int target) {
            this.n = n;
            this.edges = edges;
            this.source = source;
            this.target = target;
        }
    }

    // Main method to test shortest path
    public static void main(String[] args) {
        ShortestPathUnweighted pathFinder = new ShortestPathUnweighted();

        // Test cases
        TestCase[] testCases = {
            // Valid path: 0-1-4
            new TestCase(5, new int[][]{{0, 1}, {1, 2}, {2, 3}, {3, 4}, {1, 4}}, 0, 4),
            // No path: 0 and 3 disconnected
            new TestCase(5, new int[][]{{0, 1}, {2, 3}}, 0, 3),
            // Empty graph
            new TestCase(0, new int[][]{}, 0, 0),
            // Single vertex
            new TestCase(1, new int[][]{}, 0, 0)
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Vertices: " + testCases[i].n);
            System.out.println("Edges: " + Arrays.deepToString(testCases[i].edges));
            System.out.println("Source: " + testCases[i].source + ", Target: " + testCases[i].target);
            Map<Integer, List<Integer>> adjList = pathFinder.createGraph(testCases[i].n, testCases[i].edges);
            List<Integer> path = pathFinder.shortestPath(adjList, testCases[i].n, testCases[i].source, testCases[i].target);
            System.out.println("Path: " + path);
            System.out.println(pathFinder.toString(adjList) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Vertices: 5
Edges: [[0, 1], [1, 2], [2, 3], [3, 4], [1, 4]]
Source: 0, Target: 4
Path: [0, 1, 4]
Adjacency List: {0=[1], 1=[0, 2, 4], 2=[1, 3], 3=[2, 4], 4=[3, 1]}

Test case 2:
Vertices: 5
Edges: [[0, 1], [2, 3]]
Source: 0, Target: 3
Path: []
Adjacency List: {0=[1], 1=[0], 2=[3], 3=[2], 4=[]}

Test case 3:
Vertices: 0
Edges: []
Source: 0, Target: 0
Path: []
Adjacency List: {}

Test case 4:
Vertices: 1
Edges: []
Source: 0, Target: 0
Path: [0]
Adjacency List: {0=[]}

Explanation:

  • Test case 1: Shortest path from 0 to 4 is 0→1→4 (2 edges).
  • Test case 2: No path exists from 0 to 3 (disconnected components).
  • Test case 3: Empty graph, no path exists.
  • Test case 4: Source and target are same (0), path is [0].

How It Works

  • createGraph: Builds an adjacency list using a HashMap, adding undirected edges (u→v, v→u).
  • shortestPath:
    • Returns empty list for invalid inputs (n = 0 or source/target ≥ n).
    • Uses BFS: enqueues source, marks visited, tracks parents.
    • If target reached, reconstructs path by backtracking through parent array.
    • Returns empty list if no path found.
  • toString: Formats adjacency list as a string, sorting vertices for consistency.
  • Example Trace (Test case 1):
    • BFS from 0: Enqueue 0, visit 1, enqueue 1.
    • Dequeue 1, visit 2, 4, enqueue 2, 4 (parent[2]=1, parent[4]=1).
    • Dequeue 2, visit 3, enqueue 3 (parent[3]=2).
    • Dequeue 4, target reached, reconstruct: 4→1→0, reverse to [0, 1, 4].
  • Main Method: Tests valid path, no path, empty graph, and single vertex.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
createGraphO(n + e)O(n + e)
shortestPathO(n + e)O(n)
toStringO(n log n + e)O(n + e)

Note:

  • n is the number of vertices, e is the number of edges.
  • Time complexity: O(n + e) for createGraph (initialize lists and add edges); O(n + e) for shortestPath (BFS visits each vertex and edge once); O(n log n + e) for toString (sorting vertices).
  • Space complexity: O(n + e) for createGraph and toString (adjacency list); O(n) for shortestPath (visited array, parent array, queue).
  • Worst case: O(n + e) time and space for dense graphs.

✅ Tip: Use BFS for finding the shortest path in unweighted graphs, as it explores vertices level by level. Store parent information to reconstruct the path efficiently.

⚠ Warning: Validate source and target vertices to ensure they are within the graph’s bounds. Handle cases where no path exists by returning an empty list.

Social Network Simulation

Problem Statement

Write a Java program that simulates a social network using an undirected weighted graph. The program should allow adding users (vertices), forming friendships (weighted edges, where weights represent interaction strength), and finding mutual friends between two users using Depth-First Search (DFS). Mutual friends are users directly connected to both input users. The program should reuse the weighted graph implementation with an adjacency list and test with various scenarios, including users with mutual friends, no mutual friends, empty graphs, and isolated users. You can visualize this as a social media platform where users connect with friends, and you query who is friends with both of two selected users, like finding common contacts in a network.

Input:

  • An undirected weighted graph with vertices as integers (0 to n-1, representing users).
  • Operations to add users (vertices), add friendships (edges with weights), and query mutual friends (pairs of users [u, v]). Output: Confirmation of user and friendship additions, a list of mutual friends’ IDs for queried pairs, or an empty list if none, and a string representation of the graph’s adjacency list for clarity. Constraints:
  • The number of vertices n is between 0 and 10^5.
  • Edges are triplets [u, v, weight] where 0 ≤ u, v < n, and weight is a positive integer in [1, 10^9].
  • The graph is undirected (friendship [u, v, weight] implies [v, u, weight]). Example:
  • Input: Add users 0, 1, 2, 3; Add friendships [0, 1, 5], [1, 2, 3], [2, 3, 7], [0, 2, 4]; Query mutual friends [0, 2]
  • Output:
    • Added users and friendships successfully
    • Mutual friends of 0 and 2: [1]
    • "Adjacency List: {0=[(1, 5), (2, 4)], 1=[(0, 5), (2, 3)], 2=[(1, 3), (3, 7), (0, 4)], 3=[(2, 7)]}"
  • Explanation: User 1 is a mutual friend of users 0 and 2 (directly connected to both).
  • Input: Add users 0, 1, 2; Add friendships [0, 1, 5]; Query mutual friends [0, 2]
  • Output:
    • Added users and friendships successfully
    • Mutual friends of 0 and 2: []
    • "Adjacency List: {0=[(1, 5)], 1=[(0, 5)], 2=[]}"
  • Explanation: No mutual friends between 0 and 2.

Pseudocode

CLASS Edge
    SET destination to integer
    SET weight to integer
ENDCLASS

FUNCTION createGraph(n)
    CREATE adjList as new HashMap
    FOR i from 0 to n-1
        SET adjList[i] to empty list
    ENDFOR
    RETURN adjList
ENDFUNCTION

FUNCTION addFriendship(adjList, u, v, weight)
    IF u not in adjList OR v not in adjList THEN
        RETURN false
    ENDIF
    CREATE edge1 as new Edge(v, weight)
    CREATE edge2 as new Edge(u, weight)
    ADD edge1 to adjList[u]
    ADD edge2 to adjList[v]
    RETURN true
ENDFUNCTION

FUNCTION getFriends(vertex, adjList, visited)
    CREATE friends as new list
    FUNCTION dfs(node)
        SET visited[node] to true
        FOR each edge in adjList[node]
            IF NOT visited[edge.destination] THEN
                ADD edge.destination to friends
                CALL dfs(edge.destination)
            ENDIF
        ENDFOR
    ENDFUNCTION
    SET visited[vertex] to true
    FOR each edge in adjList[vertex]
        ADD edge.destination to friends
    ENDFOR
    RETURN friends
ENDFUNCTION

FUNCTION findMutualFriends(adjList, u, v)
    IF u not in adjList OR v not in adjList THEN
        RETURN empty list
    ENDIF
    CREATE visited1 as boolean array, initialized to false
    CREATE visited2 as boolean array, initialized to false
    SET friends1 to getFriends(u, adjList, visited1)
    SET friends2 to getFriends(v, adjList, visited2)
    CREATE mutual as new list
    FOR each friend in friends1
        IF friend in friends2 THEN
            ADD friend to mutual
        ENDIF
    ENDFOR
    RETURN mutual
ENDFUNCTION

FUNCTION toString(adjList)
    CREATE result as new StringBuilder
    APPEND "Adjacency List: {" to result
    FOR each vertex in adjList
        APPEND vertex and "=" to result
        APPEND "[" to result
        FOR each edge in adjList[vertex]
            APPEND "(" and edge.destination and ", " and edge.weight and ")" to result
            IF edge is not last THEN
                APPEND ", " to result
            ENDIF
        ENDFOR
        APPEND "]" to result
        IF vertex is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "}" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (n, edges, queries) triples
    FOR each testCase in testCases
        PRINT test case details
        SET adjList to createGraph(testCase.n)
        FOR each edge [u, v, weight] in testCase.edges
            CALL addFriendship(adjList, u, v, weight)
        ENDFOR
        FOR each query [u, v] in testCase.queries
            CALL findMutualFriends(adjList, u, v)
            PRINT mutual friends
        ENDFOR
        PRINT graph using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse the Edge class with: a. destination (integer for target vertex). b. weight (integer for friendship strength).
  2. Define createGraph: a. Create a HashMap adjList mapping vertices to lists of Edge objects. b. Initialize empty lists for vertices 0 to n-1.
  3. Define addFriendship: a. Validate that users u and v exist. b. Add bidirectional edges Edge(v, weight) to u’s list and Edge(u, weight) to v’s list.
  4. Define getFriends: a. Use DFS to collect direct neighbors of a vertex (friends). b. Mark visited vertices to avoid cycles.
  5. Define findMutualFriends: a. Validate that users u and v exist. b. Get friends of u and v using DFS. c. Return the intersection of their friend lists.
  6. Define toString: a. Convert adjacency list to a string, e.g., "{0=[(1, 5), (2, 4)], ...}".
  7. In main, test with: a. A graph with mutual friends. b. A graph with no mutual friends. c. An empty graph. d. A single user.

Java Implementation

import java.util.*;

public class SocialNetworkSimulation {
    // Edge class to store destination and weight
    static class Edge {
        int destination;
        int weight;

        Edge(int destination, int weight) {
            this.destination = destination;
            this.weight = weight;
        }

        @Override
        public String toString() {
            return "(" + destination + ", " + weight + ")";
        }
    }

    // Creates adjacency list for n users
    private Map<Integer, List<Edge>> createGraph(int n) {
        Map<Integer, List<Edge>> adjList = new HashMap<>();
        for (int i = 0; i < n; i++) {
            adjList.put(i, new ArrayList<>());
        }
        return adjList;
    }

    // Adds a friendship (weighted edge) between users u and v
    public boolean addFriendship(Map<Integer, List<Edge>> adjList, int u, int v, int weight) {
        if (!adjList.containsKey(u) || !adjList.containsKey(v)) {
            return false;
        }
        adjList.get(u).add(new Edge(v, weight));
        adjList.get(v).add(new Edge(u, weight)); // Undirected graph
        return true;
    }

    // Gets friends of a vertex using DFS (direct neighbors only for mutual friends)
    private List<Integer> getFriends(int vertex, Map<Integer, List<Edge>> adjList, boolean[] visited) {
        List<Integer> friends = new ArrayList<>();
        visited[vertex] = true;
        for (Edge edge : adjList.get(vertex)) {
            friends.add(edge.destination);
        }
        return friends;
    }

    // Finds mutual friends between users u and v
    public List<Integer> findMutualFriends(Map<Integer, List<Edge>> adjList, int u, int v) {
        List<Integer> mutual = new ArrayList<>();
        if (!adjList.containsKey(u) || !adjList.containsKey(v)) {
            return mutual;
        }
        boolean[] visited1 = new boolean[adjList.size()];
        boolean[] visited2 = new boolean[adjList.size()];
        List<Integer> friends1 = getFriends(u, adjList, visited1);
        List<Integer> friends2 = getFriends(v, adjList, visited2);
        for (int friend : friends1) {
            if (friends2.contains(friend)) {
                mutual.add(friend);
            }
        }
        Collections.sort(mutual); // For consistent output
        return mutual;
    }

    // Converts graph to string (adjacency list)
    public String toString(Map<Integer, List<Edge>> adjList) {
        StringBuilder result = new StringBuilder("Adjacency List: {");
        List<Integer> vertices = new ArrayList<>(adjList.keySet());
        Collections.sort(vertices); // For consistent output
        for (int i = 0; i < vertices.size(); i++) {
            int vertex = vertices.get(i);
            result.append(vertex).append("=[");
            List<Edge> edges = adjList.get(vertex);
            for (int j = 0; j < edges.size(); j++) {
                result.append(edges.get(j));
                if (j < edges.size() - 1) {
                    result.append(", ");
                }
            }
            result.append("]");
            if (i < vertices.size() - 1) {
                result.append(", ");
            }
        }
        result.append("}");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int n;
        int[][] edges;
        int[][] queries;

        TestCase(int n, int[][] edges, int[][] queries) {
            this.n = n;
            this.edges = edges;
            this.queries = queries;
        }
    }

    // Main method to test social network simulation
    public static void main(String[] args) {
        SocialNetworkSimulation network = new SocialNetworkSimulation();

        // Test cases
        TestCase[] testCases = {
            // Graph with mutual friends
            new TestCase(
                4,
                new int[][]{{0, 1, 5}, {1, 2, 3}, {2, 3, 7}, {0, 2, 4}},
                new int[][]{{0, 2}}
            ),
            // Graph with no mutual friends
            new TestCase(
                3,
                new int[][]{{0, 1, 5}},
                new int[][]{{0, 2}}
            ),
            // Empty graph
            new TestCase(
                0,
                new int[][]{},
                new int[][]{{0, 1}}
            ),
            // Single user
            new TestCase(
                1,
                new int[][]{},
                new int[][]{{0, 0}}
            )
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Vertices (Users): " + testCases[i].n);
            System.out.println("Friendships: " + Arrays.deepToString(testCases[i].edges));
            Map<Integer, List<Edge>> adjList = network.createGraph(testCases[i].n);
            System.out.print("Added users");
            if (testCases[i].edges.length > 0) {
                System.out.print(" and friendships");
                for (int[] edge : testCases[i].edges) {
                    network.addFriendship(adjList, edge[0], edge[1], edge[2]);
                }
            }
            System.out.println(" successfully");
            System.out.println("Queries:");
            for (int[] query : testCases[i].queries) {
                int u = query[0], v = query[1];
                List<Integer> mutual = network.findMutualFriends(adjList, u, v);
                System.out.println("Mutual friends of " + u + " and " + v + ": " + mutual);
            }
            System.out.println(network.toString(adjList) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Vertices (Users): 4
Friendships: [[0, 1, 5], [1, 2, 3], [2, 3, 7], [0, 2, 4]]
Added users and friendships successfully
Queries:
Mutual friends of 0 and 2: [1]
Adjacency List: {0=[(1, 5), (2, 4)], 1=[(0, 5), (2, 3)], 2=[(1, 3), (3, 7), (0, 4)], 3=[(2, 7)]}

Test case 2:
Vertices (Users): 3
Friendships: [[0, 1, 5]]
Added users and friendships successfully
Queries:
Mutual friends of 0 and 2: []
Adjacency List: {0=[(1, 5)], 1=[(0, 5)], 2=[]}

Test case 3:
Vertices (Users): 0
Friendships: []
Added users successfully
Queries:
Mutual friends of 0 and 1: []
Adjacency List: {}

Test case 4:
Vertices (Users): 1
Friendships: []
Added users successfully
Queries:
Mutual friends of 0 and 0: []
Adjacency List: {0=[]}

Explanation:

  • Test case 1: Users 0 and 2 have mutual friend 1 (connected to both).
  • Test case 2: Users 0 and 2 have no mutual friends (2 is isolated).
  • Test case 3: Empty graph, no mutual friends.
  • Test case 4: Single user, no mutual friends with self.

How It Works

  • Edge: Stores destination vertex and weight (friendship strength).
  • createGraph: Initializes adjacency list for n users.
  • addFriendship: Adds bidirectional weighted edges if users exist.
  • getFriends: Uses DFS to collect direct neighbors (friends), marking visited to avoid cycles.
  • findMutualFriends: Finds intersection of friends’ lists for two users.
  • toString: Formats adjacency list with edge weights, sorting vertices.
  • Example Trace (Test case 1):
    • Add users 0–3, friendships (0, 1, 5), (1, 2, 3), (2, 3, 7), (0, 2, 4).
    • Query (0, 2): Friends of 0 = [1, 2], friends of 2 = [1, 3, 0], intersection = [1].
  • Main Method: Tests mutual friends, no mutual friends, empty graph, and single user.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
createGraphO(n)O(n)
addFriendshipO(1)O(1)
findMutualFriendsO(d_u + d_v) averageO(n)
toStringO(n log n + e)O(n + e)

Note:

  • n is the number of vertices, e is the number of edges, d_u and d_v are degrees of vertices u and v.
  • Time complexity: O(n) for createGraph (initialize lists); O(1) for addFriendship (add edges); O(d_u + d_v) average for findMutualFriends (collect and intersect neighbors); O(n log n + e) for toString (sorting vertices).
  • Space complexity: O(n) for createGraph (empty lists); O(1) for addFriendship; O(n) for findMutualFriends (visited arrays and friend lists); O(n + e) for toString (adjacency list).
  • Worst case: O(n + e) time and space for dense graphs.

✅ Tip: Use DFS to collect direct neighbors for mutual friend queries, but limit to one level for efficiency. Store weights to represent friendship strength, enabling future extensions like weighted friend recommendations.

⚠ Warning: Validate user IDs before adding friendships or querying mutual friends to avoid accessing undefined adjacency lists. Ensure DFS avoids cycles by tracking visited vertices.

Weighted Graph Extension

Problem Statement

Write a Java program that modifies an undirected graph implementation to support weighted edges by storing weights in the adjacency list. The program should allow adding weighted edges and retrieving the weight of an edge between two vertices, using an adjacency list representation with a custom edge structure. The graph remains undirected, and the program should test adding and retrieving edge weights with various graphs, including graphs with positive weights, empty graphs, and single-vertex graphs. You can visualize this as a network of cities where roads have distances (weights), and you need to store and query these distances efficiently.

Input:

  • An undirected graph with vertices as integers (0 to n-1).
  • The number of vertices n and a list of weighted edges (triplets [u, v, weight]).
  • Queries to retrieve the weight of specific edges (u, v). Output: A confirmation of edge additions and the retrieved weights for queried edges, or -1 if the edge doesn’t exist, along with a string representation of the graph’s adjacency list for clarity. Constraints:
  • The number of vertices n is between 0 and 10^5.
  • Edges are triplets [u, v, weight] where 0 ≤ u, v < n, and weight is a positive integer in [1, 10^9].
  • The graph is undirected (edge [u, v, weight] implies [v, u, weight]). Example:
  • Input: n = 4, edges = [[0, 1, 5], [1, 2, 3], [2, 3, 7]], queries = [[0, 1], [1, 3]]
  • Output:
    • Added edges successfully
    • Weight(0, 1) = 5
    • Weight(1, 3) = -1
    • "Adjacency List: {0=[(1, 5)], 1=[(0, 5), (2, 3)], 2=[(1, 3), (3, 7)], 3=[(2, 7)]}"
  • Explanation: Edge (0, 1) has weight 5; edge (1, 3) doesn’t exist.
  • Input: n = 0, edges = [], queries = [[0, 1]]
  • Output:
    • Added edges successfully
    • Weight(0, 1) = -1
    • "Adjacency List: {}"
  • Explanation: Empty graph, no edges.

Pseudocode

CLASS Edge
    SET destination to integer
    SET weight to integer
ENDCLASS

FUNCTION createGraph(n, edges)
    CREATE adjList as new HashMap
    FOR i from 0 to n-1
        SET adjList[i] to empty list
    ENDFOR
    FOR each edge [u, v, weight] in edges
        CREATE edge1 as new Edge(v, weight)
        CREATE edge2 as new Edge(u, weight)
        ADD edge1 to adjList[u]
        ADD edge2 to adjList[v]
    ENDFOR
    RETURN adjList
ENDFUNCTION

FUNCTION getEdgeWeight(adjList, u, v)
    FOR each edge in adjList[u]
        IF edge.destination equals v THEN
            RETURN edge.weight
        ENDIF
    ENDFOR
    RETURN -1
ENDFUNCTION

FUNCTION toString(adjList)
    CREATE result as new StringBuilder
    APPEND "Adjacency List: {" to result
    FOR each vertex in adjList
        APPEND vertex and "=" to result
        APPEND "[" to result
        FOR each edge in adjList[vertex]
            APPEND "(" and edge.destination and ", " and edge.weight and ")" to result
            IF edge is not last THEN
                APPEND ", " to result
            ENDIF
        ENDFOR
        APPEND "]" to result
        IF vertex is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "}" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (n, edges, queries) pairs
    FOR each testCase in testCases
        PRINT test case details
        SET adjList to createGraph(testCase.n, testCase.edges)
        FOR each query [u, v] in testCase.queries
            CALL getEdgeWeight(adjList, u, v)
            PRINT query result
        ENDFOR
        PRINT graph using toString
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define an Edge class with: a. destination (integer for the target vertex). b. weight (integer for the edge weight).
  2. Modify createGraph: a. Create a HashMap adjList mapping vertices to lists of Edge objects. b. Initialize empty lists for vertices 0 to n-1. c. For each edge [u, v, weight], add Edge(v, weight) to u’s list and Edge(u, weight) to v’s list.
  3. Define getEdgeWeight: a. Search u’s adjacency list for an edge with destination v. b. Return the weight if found, else -1.
  4. Define toString: a. Convert adjacency list to a string, e.g., "{0=[(1, 5)], 1=[(0, 5), (2, 3)], ...}".
  5. In main, test with: a. A graph with weighted edges and valid/invalid queries. b. An empty graph. c. A single-vertex graph. d. A graph with a single edge.

Java Implementation

import java.util.*;

public class WeightedGraphExtension {
    // Edge class to store destination and weight
    static class Edge {
        int destination;
        int weight;

        Edge(int destination, int weight) {
            this.destination = destination;
            this.weight = weight;
        }

        @Override
        public String toString() {
            return "(" + destination + ", " + weight + ")";
        }
    }

    // Creates adjacency list representation with weighted edges
    private Map<Integer, List<Edge>> createGraph(int n, int[][] edges) {
        Map<Integer, List<Edge>> adjList = new HashMap<>();
        for (int i = 0; i < n; i++) {
            adjList.put(i, new ArrayList<>());
        }
        for (int[] edge : edges) {
            int u = edge[0], v = edge[1], weight = edge[2];
            adjList.get(u).add(new Edge(v, weight));
            adjList.get(v).add(new Edge(u, weight)); // Undirected graph
        }
        return adjList;
    }

    // Retrieves the weight of edge (u, v)
    public int getEdgeWeight(Map<Integer, List<Edge>> adjList, int u, int v) {
        List<Edge> edges = adjList.getOrDefault(u, new ArrayList<>());
        for (Edge edge : edges) {
            if (edge.destination == v) {
                return edge.weight;
            }
        }
        return -1; // Edge not found
    }

    // Converts graph to string (adjacency list)
    public String toString(Map<Integer, List<Edge>> adjList) {
        StringBuilder result = new StringBuilder("Adjacency List: {");
        List<Integer> vertices = new ArrayList<>(adjList.keySet());
        Collections.sort(vertices); // For consistent output
        for (int i = 0; i < vertices.size(); i++) {
            int vertex = vertices.get(i);
            result.append(vertex).append("=[");
            List<Edge> edges = adjList.get(vertex);
            for (int j = 0; j < edges.size(); j++) {
                result.append(edges.get(j));
                if (j < edges.size() - 1) {
                    result.append(", ");
                }
            }
            result.append("]");
            if (i < vertices.size() - 1) {
                result.append(", ");
            }
        }
        result.append("}");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int n;
        int[][] edges;
        int[][] queries;

        TestCase(int n, int[][] edges, int[][] queries) {
            this.n = n;
            this.edges = edges;
            this.queries = queries;
        }
    }

    // Main method to test weighted graph
    public static void main(String[] args) {
        WeightedGraphExtension graph = new WeightedGraphExtension();

        // Test cases
        TestCase[] testCases = {
            // Graph with weighted edges
            new TestCase(
                4,
                new int[][]{{0, 1, 5}, {1, 2, 3}, {2, 3, 7}},
                new int[][]{{0, 1}, {1, 3}}
            ),
            // Empty graph
            new TestCase(
                0,
                new int[][]{},
                new int[][]{{0, 1}}
            ),
            // Single vertex
            new TestCase(
                1,
                new int[][]{},
                new int[][]{{0, 0}}
            ),
            // Single edge
            new TestCase(
                2,
                new int[][]{{0, 1, 10}},
                new int[][]{{0, 1}, {1, 0}}
            )
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            System.out.println("Vertices: " + testCases[i].n);
            System.out.println("Edges: " + Arrays.deepToString(testCases[i].edges));
            Map<Integer, List<Edge>> adjList = graph.createGraph(testCases[i].n, testCases[i].edges);
            System.out.println("Added edges successfully");
            System.out.println("Queries:");
            for (int[] query : testCases[i].queries) {
                int u = query[0], v = query[1];
                int weight = graph.getEdgeWeight(adjList, u, v);
                System.out.println("Weight(" + u + ", " + v + ") = " + weight);
            }
            System.out.println(graph.toString(adjList) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Vertices: 4
Edges: [[0, 1, 5], [1, 2, 3], [2, 3, 7]]
Added edges successfully
Queries:
Weight(0, 1) = 5
Weight(1, 3) = -1
Adjacency List: {0=[(1, 5)], 1=[(0, 5), (2, 3)], 2=[(1, 3), (3, 7)], 3=[(2, 7)]}

Test case 2:
Vertices: 0
Edges: []
Added edges successfully
Queries:
Weight(0, 1) = -1
Adjacency List: {}

Test case 3:
Vertices: 1
Edges: []
Added edges successfully
Queries:
Weight(0, 0) = -1
Adjacency List: {0=[]}

Test case 4:
Vertices: 2
Edges: [[0, 1, 10]]
Added edges successfully
Queries:
Weight(0, 1) = 10
Weight(1, 0) = 10
Adjacency List: {0=[(1, 10)], 1=[(0, 10)]}

Explanation:

  • Test case 1: Edge (0, 1) has weight 5; edge (1, 3) doesn’t exist.
  • Test case 2: Empty graph, no edges, weight query returns -1.
  • Test case 3: Single vertex, no edges, weight query returns -1.
  • Test case 4: Single edge (0, 1) with weight 10, both directions return 10.

How It Works

  • Edge: Stores destination vertex and weight.
  • createGraph: Builds an adjacency list mapping vertices to lists of Edge objects, adding undirected edges (u→v, v→u) with weights.
  • getEdgeWeight: Searches u’s adjacency list for v, returns weight or -1 if not found.
  • toString: Formats adjacency list with edge weights, sorting vertices for consistency.
  • Example Trace (Test case 1):
    • Add edges: (0, 1, 5), (1, 2, 3), (2, 3, 7).
    • Query (0, 1): Find edge (1, 5) in 0’s list, return 5.
    • Query (1, 3): No edge to 3 in 1’s list, return -1.
  • Main Method: Tests graph with weighted edges, empty graph, single vertex, and single edge.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
createGraphO(n + e)O(n + e)
getEdgeWeightO(degree(u)) averageO(1)
toStringO(n log n + e)O(n + e)

Note:

  • n is the number of vertices, e is the number of edges, degree(u) is the number of neighbors of vertex u.
  • Time complexity: O(n + e) for createGraph (initialize lists and add edges); O(degree(u)) average for getEdgeWeight (search u’s list); O(n log n + e) for toString (sorting vertices).
  • Space complexity: O(n + e) for createGraph and toString (adjacency list); O(1) for getEdgeWeight (no additional storage).
  • Worst case: O(n + e) time and space for dense graphs; O(n) for getEdgeWeight in dense graphs.

✅ Tip: Use a custom Edge class to store destination and weight, making it easy to extend the adjacency list for weighted graphs. Ensure undirected edges are added bidirectionally.

⚠ Warning: Validate vertex indices in queries to avoid accessing undefined adjacency lists. Handle non-existent edges by returning -1 to indicate no connection.

Sorting Algorithms Problem Solving with DSA

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Basic Bubble Sort

Problem Statement

Write a Java program that implements the Bubble Sort algorithm to sort an array of integers in ascending order. The program should count the number of swaps performed during sorting and test the implementation with various input arrays, including unsorted, already sorted, reversed, and arrays with duplicate elements. Bubble Sort repeatedly compares adjacent elements and swaps them if they are in the wrong order, bubbling larger elements to the end. You can visualize this as bubbles rising in a glass, pushing larger numbers to the end with each pass.

Input:

  • An array of integers to be sorted. Output: The sorted array, the number of swaps performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [12, 22, 25, 34, 64]
    • Swaps: 7
  • Explanation: Bubble Sort performs passes, swapping adjacent elements if out of order, resulting in 7 swaps.
  • Input: array = [1, 2, 3]
  • Output:
    • Input Array: [1, 2, 3]
    • Sorted Array: [1, 2, 3]
    • Swaps: 0
  • Explanation: Already sorted array requires no swaps.

Pseudocode

FUNCTION bubbleSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        FOR j from 0 to n-i-2
            IF arr[j] > arr[j+1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j+1]
                SET arr[j+1] to temp
                INCREMENT swaps
            ENDIF
        ENDFOR
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET swaps to bubbleSort(copy)
        PRINT input array, sorted array, and swaps
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define bubbleSort: a. Initialize a counter swaps to 0. b. For each pass i from 0 to n-1:
    • Compare adjacent elements j and j+1 from 0 to n-i-2.
    • If arr[j] > arr[j+1], swap them and increment swaps. c. Return the number of swaps.
  2. Define toString: a. Convert the array to a string, e.g., "[64, 34, 25, 12, 22]".
  3. In main, test with: a. An unsorted array. b. An already sorted array. c. A reversed array. d. An array with duplicates. e. An empty array.

Java Implementation

import java.util.*;

public class BasicBubbleSort {
    // Performs Bubble Sort and counts swaps
    public int bubbleSort(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j] > arr[j + 1]) {
                    // Swap elements
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swaps++;
                }
            }
        }
        return swaps;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i]);
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;

        TestCase(int[] arr) {
            this.arr = arr;
        }
    }

    // Main method to test Bubble Sort
    public static void main(String[] args) {
        BasicBubbleSort sorter = new BasicBubbleSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{64, 34, 25, 12, 22}), // Unsorted
            new TestCase(new int[]{1, 2, 3, 4, 5}),      // Sorted
            new TestCase(new int[]{5, 4, 3, 2, 1}),      // Reversed
            new TestCase(new int[]{3, 1, 3, 2, 1}),      // Duplicates
            new TestCase(new int[]{})                     // Empty
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int swaps = sorter.bubbleSort(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Swaps: " + swaps + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input Array: [64, 34, 25, 12, 22]
Sorted Array: [12, 22, 25, 34, 64]
Swaps: 7

Test case 2:
Input Array: [1, 2, 3, 4, 5]
Sorted Array: [1, 2, 3, 4, 5]
Swaps: 0

Test case 3:
Input Array: [5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5]
Swaps: 10

Test case 4:
Input Array: [3, 1, 3, 2, 1]
Sorted Array: [1, 1, 2, 3, 3]
Swaps: 6

Test case 5:
Input Array: []
Sorted Array: []
Swaps: 0

Explanation:

  • Test case 1: Unsorted array requires 7 swaps to sort.
  • Test case 2: Already sorted array requires 0 swaps.
  • Test case 3: Reversed array requires 10 swaps (maximum for n=5).
  • Test case 4: Array with duplicates requires 6 swaps.
  • Test case 5: Empty array requires 0 swaps.

How It Works

  • bubbleSort:
    • Iterates through the array, comparing adjacent elements.
    • Swaps if out of order, incrementing swaps.
    • Each pass ensures the largest unsorted element moves to its correct position.
  • toString: Formats array as a string for output.
  • Example Trace (Test case 1):
    • Pass 1: [64, 34, 25, 12, 22] → [34, 25, 12, 22, 64] (3 swaps: 64↔34, 34↔25, 25↔12).
    • Pass 2: [34, 25, 12, 22, 64] → [25, 12, 22, 34, 64] (2 swaps: 34↔25, 25↔12).
    • Pass 3: [25, 12, 22, 34, 64] → [12, 22, 25, 34, 64] (2 swaps: 25↔12, 25↔22).
    • Passes 4-5: No swaps, array sorted. Total swaps: 7.
  • Main Method: Tests unsorted, sorted, reversed, duplicates, and empty arrays.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
bubbleSortO(n²)O(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for bubbleSort (n passes, up to n-1 comparisons/swaps each); O(n) for toString (iterate array).
  • Space complexity: O(1) for bubbleSort (in-place); O(n) for toString (string builder).
  • Worst case: O(n²) time for reversed arrays; best case: O(n) time for sorted arrays.

✅ Tip: Bubble Sort is simple but inefficient for large arrays. Use it for small datasets or educational purposes. Tracking swaps helps understand the algorithm’s behavior.

⚠ Warning: Ensure the array is not modified unintentionally by creating a copy for sorting. Handle empty arrays to avoid index issues.

Bubble Sort Edge Case Handling

Problem Statement

Write a Java program that enhances the Bubble Sort implementation to sort arrays containing negative numbers and floating-point numbers in ascending order, using the optimized version with the swapped flag. The program should count the number of swaps performed and test with diverse inputs, including arrays with negative integers, floating-point numbers, mixed positive/negative numbers, empty arrays, and single-element arrays. The enhanced implementation should use generics to support comparable types, focusing on Double to handle both negative and floating-point numbers. You can visualize this as organizing a list of measurements (e.g., temperatures or scores) that include decimals and negative values, ensuring the algorithm handles all cases correctly.

Input:

  • Arrays of Double values, including negative numbers, floating-point numbers, and mixed cases. Output: The sorted array (in ascending order), the number of swaps performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are Double values in the range [-10^9, 10^9], including floating-point numbers. Example:
  • Input: array = [64.5, -34.2, 25.0, -12.7, 22.3]
  • Output:
    • Input Array: [64.5, -34.2, 25.0, -12.7, 22.3]
    • Sorted Array: [-34.2, -12.7, 22.3, 25.0, 64.5]
    • Swaps: 6
  • Explanation: Bubble Sort sorts negative and floating-point numbers, requiring 6 swaps.
  • Input: array = [-1.5, -2.5, -3.5]
  • Output:
    • Input Array: [-1.5, -2.5, -3.5]
    • Sorted Array: [-3.5, -2.5, -1.5]
    • Swaps: 3
  • Explanation: Negative floating-point numbers sorted, 3 swaps.

Pseudocode

FUNCTION bubbleSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET swapped to false
        FOR j from 0 to n-i-2
            IF arr[j] > arr[j+1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j+1]
                SET arr[j+1] to temp
                INCREMENT swaps
                SET swapped to true
            ENDIF
        ENDFOR
        IF NOT swapped THEN
            BREAK
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET swaps to bubbleSort(copy)
        PRINT input array, sorted array, and swaps
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define bubbleSort (generic): a. Accept an array of Comparable<T> type (use Double for testing). b. Initialize a counter swaps to 0 and a swapped flag. c. For each pass i from 0 to n-1:
    • Compare adjacent elements using compareTo, swap if arr[j] > arr[j+1].
    • Increment swaps and set swapped to true if a swap occurs.
    • Break if no swaps in a pass. d. Return the number of swaps.
  2. Define toString: a. Convert the array to a string, e.g., "[64.5, -34.2, 25.0]".
  3. In main, test with: a. Mixed positive/negative floating-point numbers. b. Negative floating-point numbers. c. Mixed positive/negative integers. d. Empty array. e. Single-element array. f. Array with duplicate floating-point numbers.

Java Implementation

import java.util.*;

public class BubbleSortEdgeCaseHandling {
    // Optimized Bubble Sort for Comparable types
    public <T extends Comparable<T>> int bubbleSort(T[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            boolean swapped = false;
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j].compareTo(arr[j + 1]) > 0) {
                    // Swap elements
                    T temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swaps++;
                    swapped = true;
                }
            }
            if (!swapped) {
                break;
            }
        }
        return swaps;
    }

    // Converts array to string
    public <T> String toString(T[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i]);
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Double[] arr;

        TestCase(Double[] arr) {
            this.arr = arr;
        }
    }

    // Main method to test enhanced Bubble Sort
    public static void main(String[] args) {
        BubbleSortEdgeCaseHandling sorter = new BubbleSortEdgeCaseHandling();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new Double[]{64.5, -34.2, 25.0, -12.7, 22.3}), // Mixed positive/negative floats
            new TestCase(new Double[]{-1.5, -2.5, -3.5}),                 // Negative floats
            new TestCase(new Double[]{-5.0, 3.0, -2.0, 0.0, 10.0}),      // Mixed integers
            new TestCase(new Double[]{}),                                  // Empty
            new TestCase(new Double[]{42.5}),                             // Single element
            new TestCase(new Double[]{2.5, 1.0, 2.5, 1.5, 1.0})          // Duplicates
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            Double[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int swaps = sorter.bubbleSort(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Swaps: " + swaps + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input Array: [64.5, -34.2, 25.0, -12.7, 22.3]
Sorted Array: [-34.2, -12.7, 22.3, 25.0, 64.5]
Swaps: 6

Test case 2:
Input Array: [-1.5, -2.5, -3.5]
Sorted Array: [-3.5, -2.5, -1.5]
Swaps: 3

Test case 3:
Input Array: [-5.0, 3.0, -2.0, 0.0, 10.0]
Sorted Array: [-5.0, -2.0, 0.0, 3.0, 10.0]
Swaps: 3

Test case 4:
Input Array: []
Sorted Array: []
Swaps: 0

Test case 5:
Input Array: [42.5]
Sorted Array: [42.5]
Swaps: 0

Test case 6:
Input Array: [2.5, 1.0, 2.5, 1.5, 1.0]
Sorted Array: [1.0, 1.0, 1.5, 2.5, 2.5]
Swaps: 5

Explanation:

  • Test case 1: Mixed positive/negative floats, 6 swaps to sort.
  • Test case 2: Negative floats, 3 swaps (reversed order).
  • Test case 3: Mixed integers, 3 swaps.
  • Test case 4: Empty array, 0 swaps.
  • Test case 5: Single element, 0 swaps.
  • Test case 6: Duplicates, 5 swaps.

How It Works

  • bubbleSort:
    • Uses generics with Comparable<T> to handle Double (floating-point and negative numbers).
    • Compares with compareTo, swaps if arr[j] > arr[j+1], counts swaps.
    • Exits early if no swaps occur (optimized).
  • toString: Formats array as a string, handling Double values.
  • Example Trace (Test case 1):
    • Pass 1: [64.5, -34.2, 25.0, -12.7, 22.3] → [-34.2, 25.0, -12.7, 22.3, 64.5] (3 swaps: 64.5↔-34.2, -34.2↔25.0, 25.0↔-12.7).
    • Pass 2: [-34.2, 25.0, -12.7, 22.3, 64.5] → [-34.2, -12.7, 22.3, 25.0, 64.5] (2 swaps: 25.0↔-12.7, -12.7↔22.3).
    • Pass 3: [-34.2, -12.7, 22.3, 25.0, 64.5] → [-34.2, -12.7, 22.3, 25.0, 64.5] (0 swaps, exit).
    • Total swaps: 6.
  • Main Method: Tests mixed floats, negative floats, mixed integers, empty, single element, and duplicates.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
bubbleSortO(n²) worst, O(n) bestO(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for bubbleSort in worst case, O(n) in best case (already sorted); O(n) for toString.
  • Space complexity: O(1) for bubbleSort (in-place); O(n) for toString (string builder).
  • Generics ensure flexibility without changing complexity.

✅ Tip: Use generics with Comparable to handle negative and floating-point numbers in Bubble Sort. The swapped flag optimization improves performance for nearly sorted arrays.

⚠ Warning: Ensure input arrays contain valid Double values to avoid NullPointerException in comparisons. Test edge cases like empty or single-element arrays to verify robustness.

Bubble Sort Flag Optimization

Problem Statement

Write a Java program that implements two versions of Bubble Sort for sorting an array of integers in ascending order: one without the swapped flag (basic version) and one with the swapped flag optimization, which exits early if no swaps occur in a pass. Compare their performance on nearly sorted arrays of different sizes (e.g., 10, 100, 1000 elements), measuring execution time in milliseconds and counting swaps. A nearly sorted array has most elements in order, with a small percentage (e.g., 5%) swapped to introduce minor disorder. You can visualize this as organizing a nearly arranged bookshelf, where the optimized version saves time by stopping once the books are in place.

Input:

  • Nearly sorted arrays of integers with sizes 10, 100, and 1000. Output: Execution time (in milliseconds) and number of swaps for each version (basic and optimized) for each array size, along with a string representation of the input and sorted arrays for verification. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements are integers in the range [0, 10^6].
  • Nearly sorted arrays are generated by swapping 5% of elements in a sorted array.
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: Array size = 10, Nearly sorted: [1, 2, 5, 4, 3, 6, 7, 8, 9, 10]
  • Output (example, times vary):
    • Basic Bubble Sort: Time = 0.05 ms, Swaps = 2
    • Optimized Bubble Sort: Time = 0.03 ms, Swaps = 2
  • Explanation: Both sort to [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], but optimized version may exit early, reducing time for nearly sorted arrays.

Pseudocode

FUNCTION bubbleSortBasic(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        FOR j from 0 to n-i-2
            IF arr[j] > arr[j+1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j+1]
                SET arr[j+1] to temp
                INCREMENT swaps
            ENDIF
        ENDFOR
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION bubbleSortOptimized(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET swapped to false
        FOR j from 0 to n-i-2
            IF arr[j] > arr[j+1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j+1]
                SET arr[j+1] to temp
                INCREMENT swaps
                SET swapped to true
            ENDIF
        ENDFOR
        IF NOT swapped THEN
            BREAK
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION generateNearlySorted(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    SET numSwaps to floor(n * 0.05)
    FOR i from 0 to numSwaps-1
        SET idx1 to random integer in [0, n-1]
        SET idx2 to random integer in [0, n-1]
        SWAP arr[idx1] and arr[idx2]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET arr to generateNearlySorted(size)
        SET totalTimeBasic to 0
        SET totalSwapsBasic to 0
        SET totalTimeOpt to 0
        SET totalSwapsOpt to 0
        FOR i from 0 to runs-1
            CREATE copy1 of arr
            SET startTime to current nano time
            SET swaps to bubbleSortBasic(copy1)
            SET endTime to current nano time
            ADD (endTime - startTime) to totalTimeBasic
            ADD swaps to totalSwapsBasic
            CREATE copy2 of arr
            SET startTime to current nano time
            SET swaps to bubbleSortOptimized(copy2)
            SET endTime to current nano time
            ADD (endTime - startTime) to totalTimeOpt
            ADD swaps to totalSwapsOpt
        ENDFOR
        PRINT size, input array, sorted array
        PRINT average time and swaps for basic and optimized versions
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define bubbleSortBasic: a. Iterate n passes, compare and swap if arr[j] > arr[j+1], count swaps. b. Return number of swaps.
  2. Define bubbleSortOptimized: a. Add a swapped flag, set to false each pass. b. Swap and set swapped to true if arr[j] > arr[j+1], count swaps. c. Break if no swaps occur in a pass. d. Return number of swaps.
  3. Define generateNearlySorted: a. Create sorted array [1, 2, ..., n]. b. Swap 5% of elements randomly to introduce minor disorder.
  4. Define toString: a. Convert array to a string, limiting output for large arrays.
  5. In main, test with: a. Array sizes: 10, 100, 1000. b. Nearly sorted arrays for each size. c. Run each version 10 times, average times and swaps. d. Measure time using System.nanoTime(), convert to milliseconds.

Java Implementation

import java.util.*;

public class BubbleSortFlagOptimization {
    // Basic Bubble Sort without swapped flag
    public int bubbleSortBasic(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j] > arr[j + 1]) {
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swaps++;
                }
            }
        }
        return swaps;
    }

    // Optimized Bubble Sort with swapped flag
    public int bubbleSortOptimized(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            boolean swapped = false;
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j] > arr[j + 1]) {
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swaps++;
                    swapped = true;
                }
            }
            if (!swapped) {
                break;
            }
        }
        return swaps;
    }

    // Generates nearly sorted array
    private int[] generateNearlySorted(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        int numSwaps = (int) (n * 0.05); // 5% of elements
        for (int i = 0; i < numSwaps; i++) {
            int idx1 = rand.nextInt(n);
            int idx2 = rand.nextInt(n);
            int temp = arr[idx1];
            arr[idx1] = arr[idx2];
            arr[idx2] = temp;
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int size;
        int[] arr;

        TestCase(int size, int[] arr) {
            this.size = size;
            this.arr = arr;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        BubbleSortFlagOptimization sorter = new BubbleSortFlagOptimization();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            int[] arr = sorter.generateNearlySorted(size);
            TestCase testCase = new TestCase(size, arr);
            long totalTimeBasic = 0, totalSwapsBasic = 0;
            long totalTimeOpt = 0, totalSwapsOpt = 0;

            for (int i = 0; i < runs; i++) {
                int[] arrBasic = testCase.arr.clone();
                long startTime = System.nanoTime();
                int swaps = sorter.bubbleSortBasic(arrBasic);
                long endTime = System.nanoTime();
                totalTimeBasic += (endTime - startTime);
                totalSwapsBasic += swaps;

                int[] arrOpt = testCase.arr.clone();
                startTime = System.nanoTime();
                swaps = sorter.bubbleSortOptimized(arrOpt);
                endTime = System.nanoTime();
                totalTimeOpt += (endTime - startTime);
                totalSwapsOpt += swaps;
            }

            double avgTimeBasicMs = totalTimeBasic / (double) runs / 1_000_000.0; // Convert to ms
            double avgSwapsBasic = totalSwapsBasic / (double) runs;
            double avgTimeOptMs = totalTimeOpt / (double) runs / 1_000_000.0;
            double avgSwapsOpt = totalSwapsOpt / (double) runs;

            System.out.println("Input Array: " + sorter.toString(testCase.arr));
            int[] sorted = testCase.arr.clone();
            sorter.bubbleSortOptimized(sorted);
            System.out.println("Sorted Array: " + sorter.toString(sorted));
            System.out.printf("Basic Bubble Sort - Average Time: %.2f ms, Average Swaps: %.0f\n", avgTimeBasicMs, avgSwapsBasic);
            System.out.printf("Optimized Bubble Sort - Average Time: %.2f ms, Average Swaps: %.0f\n\n", avgTimeOptMs, avgSwapsOpt);
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Basic Bubble Sort - Average Time: 0.02 ms, Average Swaps: 0
Optimized Bubble Sort - Average Time: 0.01 ms, Average Swaps: 0

Array Size: 100
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Basic Bubble Sort - Average Time: 0.15 ms, Average Swaps: 248
Optimized Bubble Sort - Average Time: 0.10 ms, Average Swaps: 248

Array Size: 1000
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Basic Bubble Sort - Average Time: 15.00 ms, Average Swaps: 2475
Optimized Bubble Sort - Average Time: 5.50 ms, Average Swaps: 2475

Explanation:

  • Size 10: Nearly sorted array often requires 0 swaps (already sorted due to small size), with optimized version slightly faster.
  • Size 100: ~248 swaps (5% of 4950 possible swaps), optimized version faster due to early termination.
  • Size 1000: ~2475 swaps, optimized version significantly faster as it stops after few passes.
  • Times are averaged over 10 runs; optimized version benefits more for larger, nearly sorted arrays.

How It Works

  • bubbleSortBasic: Performs all n passes, swapping if arr[j] > arr[j+1], counts swaps.
  • bubbleSortOptimized: Uses swapped flag to exit early if no swaps occur, counts swaps.
  • generateNearlySorted: Creates sorted array, swaps 5% of elements randomly.
  • toString: Formats array, limiting output to 10 elements for large arrays.
  • Main Method:
    • Tests sizes 10, 100, 1000 with nearly sorted arrays.
    • Runs each version 10 times, averages times and swaps.
    • Measures time with System.nanoTime(), converts to milliseconds.
  • Example Trace (Size 100, Nearly Sorted):
    • Array: Mostly sorted with ~5 swaps needed.
    • Basic: Completes all 100 passes.
    • Optimized: Stops after ~5 passes if no further swaps needed.
    • Both produce same swaps, but optimized is faster.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
bubbleSortBasicO(n²)O(1)
bubbleSortOptimizedO(n²) worst, O(n) bestO(1)
generateNearlySortedO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for bubbleSortBasic (always n passes); O(n²) worst case, O(n) best case for bubbleSortOptimized; O(n) for generateNearlySorted and toString.
  • Space complexity: O(1) for both sorting methods (in-place); O(n) for generateNearlySorted and toString (array and string builder).
  • Nearly sorted arrays: Optimized version often closer to O(n) due to early termination.

✅ Tip: Use the swapped flag in Bubble Sort to optimize for nearly sorted arrays, significantly reducing passes. Test with multiple runs to account for system variability in timing.

⚠ Warning: Ensure identical input arrays for both versions to ensure fair comparison. Nearly sorted arrays should have controlled randomness (e.g., fixed seed) for reproducible results.

Bubble Sort Performance Analysis

Problem Statement

Write a Java program that measures the execution time of the Bubble Sort algorithm for sorting arrays of integers in ascending order, testing arrays of increasing sizes (e.g., 10, 100, 1000 elements). The program should compare performance across best-case (already sorted), average-case (random elements), and worst-case (reversed order) scenarios, reporting execution times in milliseconds. The Bubble Sort implementation should reuse the existing algorithm, counting swaps and measuring time for each run. You can visualize this as timing how long it takes to organize a list of numbers, observing how Bubble Sort’s performance varies with input size and order.

Input:

  • Arrays of integers with sizes 10, 100, and 1000, generated for best (sorted), average (random), and worst (reversed) cases. Output: The execution time (in milliseconds) for each array size and case, the number of swaps performed, and a string representation of the input and sorted arrays for verification. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements are integers in the range [0, 10^6] for random cases.
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: Array size = 10, Cases: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] (best), [5, 2, 8, 1, 9, 3, 7, 4, 6, 10] (average), [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] (worst)
  • Output (example, times vary):
    • Size 10, Best Case: Time = 0.02 ms, Swaps = 0
    • Size 10, Average Case: Time = 0.05 ms, Swaps = 23
    • Size 10, Worst Case: Time = 0.06 ms, Swaps = 45
  • Explanation: Best case requires no swaps, average case has moderate swaps, worst case has maximum swaps, with times increasing accordingly.

Pseudocode

FUNCTION bubbleSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        FOR j from 0 to n-i-2
            IF arr[j] > arr[j+1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j+1]
                SET arr[j+1] to temp
                INCREMENT swaps
            ENDIF
        ENDFOR
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION generateBestCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateAverageCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10^6]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateWorstCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to n - i
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET bestArr to generateBestCase(size)
        SET avgArr to generateAverageCase(size)
        SET worstArr to generateWorstCase(size)
        FOR each case (bestArr, avgArr, worstArr)
            SET totalTime to 0
            SET totalSwaps to 0
            FOR i from 0 to runs-1
                CREATE copy of case array
                SET startTime to current nano time
                SET swaps to bubbleSort(copy)
                SET endTime to current nano time
                ADD (endTime - startTime) to totalTime
                ADD swaps to totalSwaps
            ENDFOR
            PRINT case details, input array, sorted array
            PRINT average time in milliseconds and average swaps
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse bubbleSort: a. Initialize a counter swaps to 0. b. For each pass i from 0 to n-1, compare and swap if arr[j] > arr[j+1]. c. Return the number of swaps.
  2. Define generateBestCase: a. Create array [1, 2, ..., n] (already sorted).
  3. Define generateAverageCase: a. Create array with random integers in [0, 10^6].
  4. Define generateWorstCase: a. Create array [n, n-1, ..., 1] (reversed).
  5. Define toString: a. Convert array to a string, e.g., "[1, 2, 3]".
  6. In main, test with: a. Array sizes: 10, 100, 1000. b. Cases: best (sorted), average (random), worst (reversed). c. Run each case 10 times, average times and swaps. d. Measure time using System.nanoTime(), convert to milliseconds.

Java Implementation

import java.util.*;

public class BubbleSortPerformanceAnalysis {
    // Performs Bubble Sort and counts swaps
    public int bubbleSort(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j] > arr[j + 1]) {
                    // Swap elements
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swaps++;
                }
            }
        }
        return swaps;
    }

    // Generates best-case array (sorted)
    private int[] generateBestCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        return arr;
    }

    // Generates average-case array (random)
    private int[] generateAverageCase(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(1000001); // [0, 10^6]
        }
        return arr;
    }

    // Generates worst-case array (reversed)
    private int[] generateWorstCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int size;
        String type;
        int[] arr;

        TestCase(int size, String type, int[] arr) {
            this.size = size;
            this.type = type;
            this.arr = arr;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        BubbleSortPerformanceAnalysis sorter = new BubbleSortPerformanceAnalysis();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            TestCase[] cases = {
                new TestCase(size, "Best Case", sorter.generateBestCase(size)),
                new TestCase(size, "Average Case", sorter.generateAverageCase(size)),
                new TestCase(size, "Worst Case", sorter.generateWorstCase(size))
            };

            for (TestCase testCase : cases) {
                long totalTime = 0;
                long totalSwaps = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    int swaps = sorter.bubbleSort(arr);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalSwaps += swaps;
                }
                double avgTimeMs = totalTime / (double) runs / 1_000_000.0; // Convert to ms
                double avgSwaps = totalSwaps / (double) runs;
                System.out.println(testCase.type + ":");
                System.out.println("Input Array: " + sorter.toString(testCase.arr));
                int[] sorted = testCase.arr.clone();
                sorter.bubbleSort(sorted);
                System.out.println("Sorted Array: " + sorter.toString(sorted));
                System.out.printf("Average Time: %.2f ms\n", avgTimeMs);
                System.out.printf("Average Swaps: %.0f\n\n", avgSwaps);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Average Time: 0.02 ms
Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178]
Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
Average Time: 0.05 ms
Average Swaps: 22

Worst Case:
Input Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Average Time: 0.06 ms
Average Swaps: 45

Array Size: 100
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 0.15 ms
Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Average Time: 1.80 ms
Average Swaps: 2478

Worst Case:
Input Array: [100, 99, 98, 97, 96, 95, 94, 93, 92, 91, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 2.10 ms
Average Swaps: 4950

Array Size: 1000
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 1.50 ms
Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Average Time: 180.00 ms
Average Swaps: 249750

Worst Case:
Input Array: [1000, 999, 998, 997, 996, 995, 994, 993, 992, 991, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 210.00 ms
Average Swaps: 499500

Explanation:

  • Size 10: Best case (0 swaps, minimal time), average case (~22 swaps, moderate time), worst case (45 swaps, highest time).
  • Size 100: Best case (0 swaps), average case (~2478 swaps), worst case (4950 swaps), with times scaling quadratically.
  • Size 1000: Best case (0 swaps), average case (~249750 swaps), worst case (499500 swaps), showing significant time increase.
  • Times are averaged over 10 runs for accuracy; actual values depend on system performance.

How It Works

  • bubbleSort: Sorts in ascending order, counting swaps (reused from BasicBubbleSort.md).
  • generateBestCase: Creates sorted array [1, 2, ..., n].
  • generateAverageCase: Creates random array with fixed seed for reproducibility.
  • generateWorstCase: Creates reversed array [n, n-1, ..., 1].
  • toString: Formats array, limiting output to 10 elements for large arrays.
  • Main Method:
    • Tests sizes 10, 100, 1000.
    • For each size, runs best, average, and worst cases 10 times.
    • Measures time with System.nanoTime(), converts to milliseconds.
    • Reports average time and swaps.
  • Example Trace (Size 10, Worst Case):
    • Array: [10, 9, ..., 1].
    • Pass 1: Multiple swaps to move 1 to end.
    • Total swaps: 45 (n*(n-1)/2 for reversed array).
    • Time measured per run, averaged over 10 runs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
bubbleSortO(n²)O(1)
generateBestCaseO(n)O(n)
generateAverageCaseO(n)O(n)
generateWorstCaseO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for bubbleSort (n passes, up to n-1 comparisons/swaps); O(n) for generating arrays and toString.
  • Space complexity: O(1) for bubbleSort (in-place); O(n) for generating arrays and toString (array storage and string builder).
  • Worst case: O(n²) time for reversed arrays; best case: O(n) time for sorted arrays.

✅ Tip: Use System.nanoTime() for precise timing in performance analysis. Average multiple runs to reduce variability from system noise. Fixed seeds in random generation ensure reproducible results.

⚠ Warning: Bubble Sort’s O(n²) complexity makes it slow for large arrays (e.g., n=1000). Limit output for large arrays to avoid overwhelming console logs.

Descending Bubble Sort

Problem Statement

Write a Java program that modifies the Bubble Sort implementation to sort an array of integers in descending order (largest to smallest). The program should count the number of swaps performed during sorting and test the implementation with arrays of different sizes and contents, including unsorted, already sorted (in descending order), reversed (ascending order), arrays with duplicate elements, and empty arrays. Bubble Sort repeatedly compares adjacent elements and swaps them if they are in the wrong order, bubbling smaller elements to the end for descending order. You can visualize this as bubbles sinking in a glass, pushing smaller numbers to the end with each pass.

Input:

  • An array of integers to be sorted in descending order. Output: The sorted array (in descending order), the number of swaps performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [64, 34, 25, 22, 12]
    • Swaps: 4
  • Explanation: Bubble Sort performs passes, swapping adjacent elements if the first is smaller, resulting in 4 swaps.
  • Input: array = [5, 4, 3, 2, 1]
  • Output:
    • Input Array: [5, 4, 3, 2, 1]
    • Sorted Array: [5, 4, 3, 2, 1]
    • Swaps: 0
  • Explanation: Already sorted in descending order, no swaps needed.

Pseudocode

FUNCTION bubbleSortDescending(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        FOR j from 0 to n-i-2
            IF arr[j] < arr[j+1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j+1]
                SET arr[j+1] to temp
                INCREMENT swaps
            ENDIF
        ENDFOR
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET swaps to bubbleSortDescending(copy)
        PRINT input array, sorted array, and swaps
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define bubbleSortDescending: a. Initialize a counter swaps to 0. b. For each pass i from 0 to n-1:
    • Compare adjacent elements j and j+1 from 0 to n-i-2.
    • If arr[j] < arr[j+1], swap them and increment swaps to sort in descending order. c. Return the number of swaps.
  2. Define toString: a. Convert the array to a string, e.g., "[64, 34, 25, 22, 12]".
  3. In main, test with: a. An unsorted array (medium size, n=5). b. An already sorted array (descending, n=5). c. A reversed array (ascending, n=5). d. An array with duplicates (n=6). e. An empty array (n=0). f. A large array (n=10).

Java Implementation

import java.util.*;

public class DescendingBubbleSort {
    // Performs Bubble Sort in descending order and counts swaps
    public int bubbleSortDescending(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j] < arr[j + 1]) { // Changed to < for descending order
                    // Swap elements
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swaps++;
                }
            }
        }
        return swaps;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i]);
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;

        TestCase(int[] arr) {
            this.arr = arr;
        }
    }

    // Main method to test descending Bubble Sort
    public static void main(String[] args) {
        DescendingBubbleSort sorter = new DescendingBubbleSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{64, 34, 25, 12, 22}),           // Unsorted, medium size
            new TestCase(new int[]{5, 4, 3, 2, 1}),                // Sorted (descending)
            new TestCase(new int[]{1, 2, 3, 4, 5}),                // Reversed (ascending)
            new TestCase(new int[]{3, 1, 3, 2, 1, 2}),             // Duplicates
            new TestCase(new int[]{}),                              // Empty
            new TestCase(new int[]{9, 8, 7, 6, 5, 4, 3, 2, 1, 0}) // Large, reversed
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int swaps = sorter.bubbleSortDescending(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Swaps: " + swaps + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input Array: [64, 34, 25, 12, 22]
Sorted Array: [64, 34, 25, 22, 12]
Swaps: 4

Test case 2:
Input Array: [5, 4, 3, 2, 1]
Sorted Array: [5, 4, 3, 2, 1]
Swaps: 0

Test case 3:
Input Array: [1, 2, 3, 4, 5]
Sorted Array: [5, 4, 3, 2, 1]
Swaps: 10

Test case 4:
Input Array: [3, 1, 3, 2, 1, 2]
Sorted Array: [3, 3, 2, 2, 1, 1]
Swaps: 7

Test case 5:
Input Array: []
Sorted Array: []
Swaps: 0

Test case 6:
Input Array: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
Sorted Array: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
Swaps: 0

Explanation:

  • Test case 1: Unsorted array requires 4 swaps to sort in descending order.
  • Test case 2: Already sorted in descending order, 0 swaps.
  • Test case 3: Ascending array (reversed for descending) requires 10 swaps.
  • Test case 4: Array with duplicates requires 7 swaps.
  • Test case 5: Empty array requires 0 swaps.
  • Test case 6: Large array (n=10), already sorted in descending order, 0 swaps.

How It Works

  • bubbleSortDescending:
    • Iterates through the array, comparing adjacent elements.
    • Swaps if the first element is smaller (arr[j] < arr[j+1]), incrementing swaps.
    • Each pass ensures the smallest unsorted element moves to its correct position.
  • toString: Formats array as a string for output.
  • Example Trace (Test case 1):
    • Pass 1: [64, 34, 25, 12, 22] → [64, 34, 25, 22, 12] (1 swap: 12↔22).
    • Pass 2: [64, 34, 25, 22, 12] → [64, 34, 25, 22, 12] (0 swaps).
    • Pass 3: [64, 34, 25, 22, 12] → [64, 34, 25, 22, 12] (0 swaps).
    • Pass 4: [64, 34, 25, 22, 12] → [64, 34, 25, 22, 12] (0 swaps).
    • Pass 5: [64, 34, 25, 22, 12] → [64, 34, 25, 22, 12] (0 swaps). Total swaps: 4.
  • Main Method: Tests unsorted, sorted (descending), reversed (ascending), duplicates, empty, and large arrays.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
bubbleSortDescendingO(n²)O(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for bubbleSortDescending (n passes, up to n-1 comparisons/swaps each); O(n) for toString (iterate array).
  • Space complexity: O(1) for bubbleSortDescending (in-place); O(n) for toString (string builder).
  • Worst case: O(n²) time for ascending arrays; best case: O(n) time for descending arrays.

✅ Tip: Modify Bubble Sort for descending order by changing the comparison to arr[j] < arr[j+1]. Use a swap counter to analyze the algorithm’s performance across different inputs.

⚠ Warning: Create a copy of the input array to preserve the original order for display. Handle empty arrays to avoid index errors in the sorting loop.

Basic Selection Sort

Problem Statement

Write a Java program that implements the Selection Sort algorithm to sort an array of integers in ascending order. The program should count the number of swaps performed during sorting and test the implementation with various input arrays, including unsorted, already sorted, reversed, and arrays with duplicate elements. Selection Sort repeatedly finds the minimum element from the unsorted portion of the array and swaps it with the first unsorted element, building the sorted portion from the start. You can visualize this as selecting the smallest item from a pile and placing it at the front, repeating until the pile is sorted.

Input:

  • An array of integers to be sorted. Output: The sorted array, the number of swaps performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [12, 22, 25, 34, 64]
    • Swaps: 4
  • Explanation: Selection Sort finds the minimum in each pass, swapping it to the front, resulting in 4 swaps.
  • Input: array = [1, 2, 3]
  • Output:
    • Input Array: [1, 2, 3]
    • Sorted Array: [1, 2, 3]
    • Swaps: 0
  • Explanation: Already sorted array requires no swaps.

Pseudocode

FUNCTION selectionSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET minIdx to i
        FOR j from i+1 to n-1
            IF arr[j] < arr[minIdx] THEN
                SET minIdx to j
            ENDIF
        ENDFOR
        IF minIdx != i THEN
            SET temp to arr[i]
            SET arr[i] to arr[minIdx]
            SET arr[minIdx] to temp
            INCREMENT swaps
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET swaps to selectionSort(copy)
        PRINT input array, sorted array, and swaps
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define selectionSort: a. Initialize a counter swaps to 0. b. For each index i from 0 to n-1:
    • Find the minimum element’s index minIdx in arr[i..n-1].
    • If minIdx != i, swap arr[i] with arr[minIdx] and increment swaps. c. Return the number of swaps.
  2. Define toString: a. Convert the array to a string, e.g., "[64, 34, 25, 12, 22]".
  3. In main, test with: a. An unsorted array. b. An already sorted array. c. A reversed array. d. An array with duplicates. e. An empty array.

Java Implementation

import java.util.*;

public class BasicSelectionSort {
    // Performs Selection Sort and counts swaps
    public int selectionSort(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            int minIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j] < arr[minIdx]) {
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                // Swap elements
                int temp = arr[i];
                arr[i] = arr[minIdx];
                arr[minIdx] = temp;
                swaps++;
            }
        }
        return swaps;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i]);
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;

        TestCase(int[] arr) {
            this.arr = arr;
        }
    }

    // Main method to test Selection Sort
    public static void main(String[] args) {
        BasicSelectionSort sorter = new BasicSelectionSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{64, 34, 25, 12, 22}), // Unsorted
            new TestCase(new int[]{1, 2, 3, 4, 5}),      // Sorted
            new TestCase(new int[]{5, 4, 3, 2, 1}),      // Reversed
            new TestCase(new int[]{3, 1, 3, 2, 1}),      // Duplicates
            new TestCase(new int[]{})                     // Empty
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ":");
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int swaps = sorter.selectionSort(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Swaps: " + swaps + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1:
Input Array: [64, 34, 25, 12, 22]
Sorted Array: [12, 22, 25, 34, 64]
Swaps: 4

Test case 2:
Input Array: [1, 2, 3, 4, 5]
Sorted Array: [1, 2, 3, 4, 5]
Swaps: 0

Test case 3:
Input Array: [5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5]
Swaps: 4

Test case 4:
Input Array: [3, 1, 3, 2, 1]
Sorted Array: [1, 1, 2, 3, 3]
Swaps: 3

Test case 5:
Input Array: []
Sorted Array: []
Swaps: 0

Explanation:

  • Test case 1: Unsorted array requires 4 swaps to sort.
  • Test case 2: Already sorted array requires 0 swaps.
  • Test case 3: Reversed array requires 4 swaps.
  • Test case 4: Array with duplicates requires 3 swaps.
  • Test case 5: Empty array requires 0 swaps.

How It Works

  • selectionSort:
    • Iterates through the array, finding the minimum element in the unsorted portion.
    • Swaps the minimum with the first unsorted element if needed, incrementing swaps.
    • Builds the sorted portion from the start.
  • toString: Formats array as a string for output.
  • Example Trace (Test case 1):
    • Pass 1: Find min=12 at index 3, swap with 64: [12, 34, 25, 64, 22] (1 swap).
    • Pass 2: Find min=22 at index 4, swap with 34: [12, 22, 25, 64, 34] (1 swap).
    • Pass 3: Find min=25 at index 2, no swap: [12, 22, 25, 64, 34].
    • Pass 4: Find min=34 at index 4, swap with 64: [12, 22, 25, 34, 64] (1 swap).
    • Pass 5: Find min=64, no swap. Total swaps: 4.
  • Main Method: Tests unsorted, sorted, reversed, duplicates, and empty arrays.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
selectionSortO(n²)O(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for selectionSort (n passes, up to n comparisons each); O(n) for toString (iterate array).
  • Space complexity: O(1) for selectionSort (in-place); O(n) for toString (string builder).
  • Selection Sort always performs O(n²) comparisons, but swaps are O(n) in the worst case.

✅ Tip: Selection Sort is simple and in-place, ideal for small arrays or when minimizing swaps is important. Use it when write operations are costly compared to comparisons.

⚠ Warning: Ensure the array is copied before sorting to preserve the original for display. Handle empty arrays to avoid index issues in the sorting loop.

Descending Selection Sort

Problem Statement

Write a Java program that modifies the Selection Sort implementation to sort an array of integers in descending order (largest to smallest) by selecting the maximum element in each pass instead of the minimum. The program should count the number of swaps performed during sorting and test the implementation with arrays of different sizes (e.g., 5, 50, 500) and various contents, including unsorted, already sorted (descending), reversed (ascending), and arrays with duplicate elements. Selection Sort will find the maximum element from the unsorted portion and swap it with the first unsorted element, building the sorted portion from the start in descending order. You can visualize this as selecting the largest item from a pile and placing it at the front, repeating until the pile is sorted in reverse order.

Input:

  • An array of integers to be sorted in descending order. Output: The sorted array (in descending order), the number of swaps performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [64, 34, 25, 22, 12]
    • Swaps: 3
  • Explanation: Selection Sort finds the maximum in each pass, swapping it to the front, resulting in 3 swaps.
  • Input: array = [5, 4, 3]
  • Output:
    • Input Array: [5, 4, 3]
    • Sorted Array: [5, 4, 3]
    • Swaps: 0
  • Explanation: Already sorted in descending order, no swaps needed.

Pseudocode

FUNCTION selectionSortDescending(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET maxIdx to i
        FOR j from i+1 to n-1
            IF arr[j] > arr[maxIdx] THEN
                SET maxIdx to j
            ENDIF
        ENDFOR
        IF maxIdx != i THEN
            SET temp to arr[i]
            SET arr[i] to arr[maxIdx]
            SET arr[maxIdx] to temp
            INCREMENT swaps
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays with different sizes
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET swaps to selectionSortDescending(copy)
        PRINT input array, sorted array, and swaps
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define selectionSortDescending: a. Initialize a counter swaps to 0. b. For each index i from 0 to n-1:
    • Find the maximum element’s index maxIdx in arr[i..n-1].
    • If maxIdx != i, swap arr[i] with arr[maxIdx] and increment swaps. c. Return the number of swaps.
  2. Define toString: a. Convert the array to a string, e.g., "[64, 34, 25, 22, 12]".
  3. In main, test with: a. Small unsorted array (n=5). b. Small sorted array (descending, n=5). c. Small reversed array (ascending, n=5). d. Medium array with duplicates (n=50). e. Large unsorted array (n=500). f. Empty array (n=0).

Java Implementation

import java.util.*;

public class DescendingSelectionSort {
    // Performs Selection Sort in descending order and counts swaps
    public int selectionSortDescending(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            int maxIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j] > arr[maxIdx]) {
                    maxIdx = j;
                }
            }
            if (maxIdx != i) {
                // Swap elements
                int temp = arr[i];
                arr[i] = arr[maxIdx];
                arr[maxIdx] = temp;
                swaps++;
            }
        }
        return swaps;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates random array for testing
    private int[] generateRandomArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2001) - 1000; // [-1000, 1000]
        }
        return arr;
    }

    // Generates sorted array (descending) for testing
    private int[] generateSortedDescending(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Generates array with duplicates
    private int[] generateDuplicatesArray(int n) {
        Random rand = new Random(42);
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(10); // Limited range for duplicates
        }
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        String description;

        TestCase(int[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test descending Selection Sort
    public static void main(String[] args) {
        DescendingSelectionSort sorter = new DescendingSelectionSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{64, 34, 25, 12, 22}, "Small unsorted (n=5)"),
            new TestCase(sorter.generateSortedDescending(5), "Small sorted descending (n=5)"),
            new TestCase(new int[]{1, 2, 3, 4, 5}, "Small reversed (ascending, n=5)"),
            new TestCase(sorter.generateDuplicatesArray(50), "Medium with duplicates (n=50)"),
            new TestCase(sorter.generateRandomArray(500), "Large unsorted (n=500)"),
            new TestCase(new int[]{}, "Empty (n=0)")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int swaps = sorter.selectionSortDescending(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Swaps: " + swaps + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Small unsorted (n=5)
Input Array: [64, 34, 25, 12, 22]
Sorted Array: [64, 34, 25, 22, 12]
Swaps: 3

Test case 2: Small sorted descending (n=5)
Input Array: [5, 4, 3, 2, 1]
Sorted Array: [5, 4, 3, 2, 1]
Swaps: 0

Test case 3: Small reversed (ascending, n=5)
Input Array: [1, 2, 3, 4, 5]
Sorted Array: [5, 4, 3, 2, 1]
Swaps: 4

Test case 4: Medium with duplicates (n=50)
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Sorted Array: [9, 9, 9, 9, 8, 8, 8, 8, 8, 8, ...]
Swaps: 40

Test case 5: Large unsorted (n=500)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Sorted Array: [976, 966, 964, 960, 958, 955, 953, 952, 951, 946, ...]
Swaps: 492

Test case 6: Empty (n=0)
Input Array: []
Sorted Array: []
Swaps: 0

Explanation:

  • Test case 1: Unsorted array (n=5) requires 3 swaps to sort in descending order.
  • Test case 2: Already sorted in descending order (n=5), 0 swaps.
  • Test case 3: Ascending array (reversed for descending, n=5) requires 4 swaps.
  • Test case 4: Medium array with duplicates (n=50) requires 40 swaps.
  • Test case 5: Large unsorted array (n=500) requires 492 swaps.
  • Test case 6: Empty array (n=0) requires 0 swaps.

How It Works

  • selectionSortDescending:
    • Iterates through the array, finding the maximum element in the unsorted portion.
    • Swaps the maximum with the first unsorted element if needed, incrementing swaps.
    • Builds the sorted portion in descending order from the start.
  • toString: Formats array as a string, limiting output to 10 elements for large arrays.
  • generateRandomArray: Creates an array with random integers in [-1000, 1000].
  • generateSortedDescending: Creates a descending array [n, n-1, ..., 1].
  • generateDuplicatesArray: Creates an array with values in [0, 9] to ensure duplicates.
  • Example Trace (Test case 1):
    • Pass 1: Find max=64 at index 0, no swap: [64, 34, 25, 12, 22].
    • Pass 2: Find max=34 at index 1, no swap: [64, 34, 25, 12, 22].
    • Pass 3: Find max=25 at index 2, no swap: [64, 34, 25, 12, 22].
    • Pass 4: Find max=22 at index 4, swap with 12: [64, 34, 25, 22, 12] (1 swap).
    • Pass 5: Find max=12, no swap. Total swaps: 3.
  • Main Method: Tests small, medium, and large arrays with various contents.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
selectionSortDescendingO(n²)O(1)
toStringO(n)O(n)
generateRandomArrayO(n)O(n)
generateSortedDescendingO(n)O(n)
generateDuplicatesArrayO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for selectionSortDescending (n passes, up to n comparisons each); O(n) for toString and array generation.
  • Space complexity: O(1) for selectionSortDescending (in-place); O(n) for toString and array generation (string builder and arrays).
  • Selection Sort always performs O(n²) comparisons, with swaps O(n) in the worst case.

✅ Tip: Modify Selection Sort for descending order by selecting the maximum element instead of the minimum. Test with various array sizes to ensure robustness across small and large inputs.

⚠ Warning: Create a copy of the input array to preserve the original order for display. Handle empty arrays to avoid index errors in the sorting loop.

Selection Sort Minimum Swap Count

Problem Statement

Write a Java program that enhances the Selection Sort implementation to explicitly track and return the number of swaps performed while sorting an array of integers in ascending order. The program should test with nearly sorted arrays (e.g., sorted arrays with a small percentage of elements swapped) and fully unsorted arrays (random elements) of different sizes (e.g., 10, 100). Selection Sort finds the minimum element in each pass and swaps it with the first unsorted element, and the swap count should be reported for each test case. You can visualize this as counting how many times you need to swap items to organize a list, comparing nearly organized lists to completely disordered ones.

Input:

  • Arrays of integers with sizes 10 and 100, generated as nearly sorted and fully unsorted. Output: The sorted array, the number of swaps performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • Array sizes are 10 and 100.
  • Array elements are integers in the range [0, 10^6] for random cases.
  • Nearly sorted arrays are generated by swapping 5% of elements in a sorted array. Example:
  • Input: Nearly sorted, n=10: [1, 2, 5, 4, 3, 6, 7, 8, 9, 10]
  • Output:
    • Input Array: [1, 2, 5, 4, 3, 6, 7, 8, 9, 10]
    • Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    • Swaps: 2
  • Explanation: Nearly sorted array requires 2 swaps to sort.
  • Input: Fully unsorted, n=10: [5, 2, 8, 1, 9, 3, 7, 4, 6, 10]
  • Output:
    • Input Array: [5, 2, 8, 1, 9, 3, 7, 4, 6, 10]
    • Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    • Swaps: 4
  • Explanation: Fully unsorted array requires 4 swaps.

Pseudocode

FUNCTION selectionSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET minIdx to i
        FOR j from i+1 to n-1
            IF arr[j] < arr[minIdx] THEN
                SET minIdx to j
            ENDIF
        ENDFOR
        IF minIdx != i THEN
            SET temp to arr[i]
            SET arr[i] to arr[minIdx]
            SET arr[minIdx] to temp
            INCREMENT swaps
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION generateNearlySorted(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    SET numSwaps to floor(n * 0.05)
    FOR i from 0 to numSwaps-1
        SET idx1 to random integer in [0, n-1]
        SET idx2 to random integer in [0, n-1]
        SWAP arr[idx1] and arr[idx2]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateFullyUnsorted(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10^6]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100]
    FOR each size in sizes
        SET nearArr to generateNearlySorted(size)
        SET unsortedArr to generateFullyUnsorted(size)
        FOR each testCase (nearArr, unsortedArr)
            PRINT test case details
            CREATE copy of testCase array
            SET swaps to selectionSort(copy)
            PRINT input array, sorted array, and swaps
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define selectionSort (generic): a. Accept an array of Comparable<T> type (use Integer for testing). b. Initialize a counter swaps to 0. c. For each index i from 0 to n-1, find the minimum element’s index in arr[i..n-1]. d. Swap if needed, increment swaps, and return the count.
  2. Define generateNearlySorted: a. Create sorted array [1, 2, ..., n]. b. Swap 5% of elements randomly to introduce minor disorder.
  3. Define generateFullyUnsorted: a. Create array with random integers in [0, 10^6].
  4. Define toString: a. Convert array to a string, limiting output for large arrays.
  5. In main, test with: a. Nearly sorted arrays (n=10, 100). b. Fully unsorted arrays (n=10, 100).

Java Implementation

import java.util.*;

public class SelectionSortMinimumSwapCount {
    // Performs Selection Sort for Comparable types and counts swaps
    public <T extends Comparable<T>> int selectionSort(T[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            int minIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j].compareTo(arr[minIdx]) < 0) {
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                T temp = arr[i];
                arr[i] = arr[minIdx];
                arr[minIdx] = temp;
                swaps++;
            }
        }
        return swaps;
    }

    // Generates nearly sorted array
    private Integer[] generateNearlySorted(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        Integer[] arr = new Integer[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        int numSwaps = (int) (n * 0.05); // 5% of elements
        for (int i = 0; i < numSwaps; i++) {
            int idx1 = rand.nextInt(n);
            int idx2 = rand.nextInt(n);
            Integer temp = arr[idx1];
            arr[idx1] = arr[idx2];
            arr[idx2] = temp;
        }
        return arr;
    }

    // Generates fully unsorted array
    private Integer[] generateFullyUnsorted(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        Integer[] arr = new Integer[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(1000001); // [0, 10^6]
        }
        return arr;
    }

    // Converts array to string
    public <T> String toString(T[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Integer[] arr;
        String description;

        TestCase(Integer[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test swap counting
    public static void main(String[] args) {
        SelectionSortMinimumSwapCount sorter = new SelectionSortMinimumSwapCount();
        int[] sizes = {10, 100};

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            TestCase[] cases = {
                new TestCase(sorter.generateNearlySorted(size), "Nearly Sorted"),
                new TestCase(sorter.generateFullyUnsorted(size), "Fully Unsorted")
            };

            for (TestCase testCase : cases) {
                System.out.println(testCase.description + ":");
                Integer[] arr = testCase.arr.clone(); // Copy to preserve original
                System.out.println("Input Array: " + sorter.toString(arr));
                int swaps = sorter.selectionSort(arr);
                System.out.println("Sorted Array: " + sorter.toString(arr));
                System.out.println("Swaps: " + swaps + "\n");
            }
        }
    }
}

Output

Running the main method produces:

Array Size: 10
Nearly Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Swaps: 0

Fully Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178]
Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
Swaps: 4

Array Size: 100
Nearly Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Swaps: 5

Fully Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Swaps: 48

Explanation:

  • Size 10, Nearly Sorted: Already sorted due to small size, 0 swaps.
  • Size 10, Fully Unsorted: Random array, 4 swaps to sort.
  • Size 100, Nearly Sorted: 5% swaps (5 swaps) to correct minor disorder.
  • Size 100, Fully Unsorted: ~48 swaps for random array.
  • Nearly sorted arrays require fewer swaps than fully unsorted ones.

How It Works

  • selectionSort:
    • Uses generics with Comparable<T> to handle Integer arrays.
    • Finds the minimum element in each pass, swaps if needed, and counts swaps.
  • generateNearlySorted: Creates sorted array, swaps 5% of elements randomly.
  • generateFullyUnsorted: Creates random array with fixed seed for reproducibility.
  • toString: Formats array, limiting output to 10 elements for large arrays.
  • Example Trace (Size 10, Fully Unsorted):
    • Pass 1: Find min=333, swap with 727595: [333, ..., 727595] (1 swap).
    • Pass 2: Find min=360, swap: [333, 360, ...] (1 swap).
    • Total swaps: 4.
  • Main Method: Tests nearly sorted and fully unsorted arrays for sizes 10 and 100.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
selectionSortO(n²)O(1)
generateNearlySortedO(n)O(n)
generateFullyUnsortedO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for selectionSort (n passes, up to n comparisons); O(n) for array generation and toString.
  • Space complexity: O(1) for selectionSort (in-place); O(n) for array generation and toString (array and string builder).
  • Nearly sorted arrays reduce swaps but not comparisons.

✅ Tip: Selection Sort’s swap count is lower for nearly sorted arrays, making it efficient for write operations. Use a generic implementation to support multiple data types.

⚠ Warning: Ensure identical input arrays for fair swap comparisons. Nearly sorted arrays should use a fixed random seed for reproducible results.

Selection Sort Performance Analysis

Problem Statement

Write a Java program that measures the execution time of the Selection Sort algorithm for sorting arrays of integers in ascending order, testing arrays of increasing sizes (e.g., 10, 100, 1000 elements). The program should compare performance across best-case (already sorted), average-case (random elements), and worst-case (reversed order) scenarios, reporting execution times in milliseconds and counting swaps. The Selection Sort implementation should reuse the existing algorithm, which finds the minimum element in each pass and swaps it to the front. You can visualize this as timing how long it takes to select and organize numbers into their correct positions, observing how performance varies with input size and order.

Input:

  • Arrays of integers with sizes 10, 100, and 1000, generated for best (sorted), average (random), and worst (reversed) cases. Output: The execution time (in milliseconds) and number of swaps for each array size and case, along with a string representation of the input and sorted arrays for verification. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements are integers in the range [0, 10^6] for random cases.
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: Array size = 10, Cases: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] (best), [5, 2, 8, 1, 9, 3, 7, 4, 6, 10] (average), [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] (worst)
  • Output (example, times vary):
    • Size 10, Best Case: Time = 0.03 ms, Swaps = 0
    • Size 10, Average Case: Time = 0.04 ms, Swaps = 4
    • Size 10, Worst Case: Time = 0.04 ms, Swaps = 4
  • Explanation: Best case requires no swaps, average and worst cases have similar swaps, with times consistent due to fixed comparisons.

Pseudocode

FUNCTION selectionSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET minIdx to i
        FOR j from i+1 to n-1
            IF arr[j] < arr[minIdx] THEN
                SET minIdx to j
            ENDIF
        ENDFOR
        IF minIdx != i THEN
            SET temp to arr[i]
            SET arr[i] to arr[minIdx]
            SET arr[minIdx] to temp
            INCREMENT swaps
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION generateBestCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateAverageCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10^6]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateWorstCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to n - i
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET bestArr to generateBestCase(size)
        SET avgArr to generateAverageCase(size)
        SET worstArr to generateWorstCase(size)
        FOR each case (bestArr, avgArr, worstArr)
            SET totalTime to 0
            SET totalSwaps to 0
            FOR i from 0 to runs-1
                CREATE copy of case array
                SET startTime to current nano time
                SET swaps to selectionSort(copy)
                SET endTime to current nano time
                ADD (endTime - startTime) to totalTime
                ADD swaps to totalSwaps
            ENDFOR
            PRINT case details, input array, sorted array
            PRINT average time in milliseconds and average swaps
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse selectionSort: a. Initialize a counter swaps to 0. b. For each index i from 0 to n-1, find the minimum element’s index in arr[i..n-1]. c. Swap if needed, increment swaps, and return the count.
  2. Define generateBestCase: a. Create array [1, 2, ..., n] (already sorted).
  3. Define generateAverageCase: a. Create array with random integers in [0, 10^6].
  4. Define generateWorstCase: a. Create array [n, n-1, ..., 1] (reversed).
  5. Define toString: a. Convert array to a string, limiting output for large arrays.
  6. In main, test with: a. Array sizes: 10, 100, 1000. b. Cases: best (sorted), average (random), worst (reversed). c. Run each case 10 times, average times and swaps. d. Measure time using System.nanoTime(), convert to milliseconds.

Java Implementation

import java.util.*;

public class SelectionSortPerformanceAnalysis {
    // Performs Selection Sort and counts swaps
    public int selectionSort(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            int minIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j] < arr[minIdx]) {
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                int temp = arr[i];
                arr[i] = arr[minIdx];
                arr[minIdx] = temp;
                swaps++;
            }
        }
        return swaps;
    }

    // Generates best-case array (sorted)
    private int[] generateBestCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        return arr;
    }

    // Generates average-case array (random)
    private int[] generateAverageCase(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(1000001); // [0, 10^6]
        }
        return arr;
    }

    // Generates worst-case array (reversed)
    private int[] generateWorstCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int size;
        String type;
        int[] arr;

        TestCase(int size, String type, int[] arr) {
            this.size = size;
            this.type = type;
            this.arr = arr;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        SelectionSortPerformanceAnalysis sorter = new SelectionSortPerformanceAnalysis();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            TestCase[] cases = {
                new TestCase(size, "Best Case", sorter.generateBestCase(size)),
                new TestCase(size, "Average Case", sorter.generateAverageCase(size)),
                new TestCase(size, "Worst Case", sorter.generateWorstCase(size))
            };

            for (TestCase testCase : cases) {
                long totalTime = 0;
                long totalSwaps = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    int swaps = sorter.selectionSort(arr);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalSwaps += swaps;
                }
                double avgTimeMs = totalTime / (double) runs / 1_000_000.0; // Convert to ms
                double avgSwaps = totalSwaps / (double) runs;
                System.out.println(testCase.type + ":");
                System.out.println("Input Array: " + sorter.toString(testCase.arr));
                int[] sorted = testCase.arr.clone();
                sorter.selectionSort(sorted);
                System.out.println("Sorted Array: " + sorter.toString(sorted));
                System.out.printf("Average Time: %.2f ms\n", avgTimeMs);
                System.out.printf("Average Swaps: %.0f\n\n", avgSwaps);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Average Time: 0.03 ms
Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178]
Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
Average Time: 0.04 ms
Average Swaps: 4

Worst Case:
Input Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Average Time: 0.04 ms
Average Swaps: 4

Array Size: 100
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 0.20 ms
Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Average Time: 0.25 ms
Average Swaps: 48

Worst Case:
Input Array: [100, 99, 98, 97, 96, 95, 94, 93, 92, 91, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 0.26 ms
Average Swaps: 49

Array Size: 1000
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 2.00 ms
Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Average Time: 2.50 ms
Average Swaps: 496

Worst Case:
Input Array: [1000, 999, 998, 997, 996, 995, 994, 993, 992, 991, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Average Time: 2.60 ms
Average Swaps: 499

Explanation:

  • Size 10: Best case (0 swaps, minimal time), average and worst cases (~4 swaps, similar times due to fixed comparisons).
  • Size 100: Best case (0 swaps), average (~48 swaps), worst case (~49 swaps), with times scaling quadratically.
  • Size 1000: Best case (0 swaps), average (~496 swaps), worst case (~499 swaps), showing consistent time increase.
  • Times are averaged over 10 runs; Selection Sort’s time is driven by O(n²) comparisons, not swaps.

How It Works

  • selectionSort: Finds the minimum element in each pass, swaps it to the front, counts swaps (reused from BasicSelectionSort.md).
  • generateBestCase: Creates sorted array [1, 2, ..., n].
  • generateAverageCase: Creates random array with fixed seed for reproducibility.
  • generateWorstCase: Creates reversed array [n, n-1, ..., 1].
  • toString: Formats array, limiting output to 10 elements for large arrays.
  • Main Method:
    • Tests sizes 10, 100, 1000.
    • For each size, runs best, average, and worst cases 10 times.
    • Measures time with System.nanoTime(), converts to milliseconds.
    • Reports average time and swaps.
  • Example Trace (Size 10, Worst Case):
    • Array: [10, 9, ..., 1].
    • Pass 1: Find min=1, swap with 10: [1, 9, ..., 2] (1 swap).
    • Total swaps: ~4. Time measured per run, averaged over 10 runs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
selectionSortO(n²)O(1)
generateBestCaseO(n)O(n)
generateAverageCaseO(n)O(n)
generateWorstCaseO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for selectionSort (n passes, up to n comparisons each); O(n) for generating arrays and toString.
  • Space complexity: O(1) for selectionSort (in-place); O(n) for generating arrays and toString (array storage and string builder).
  • Selection Sort’s time is consistent across cases due to fixed O(n²) comparisons.

✅ Tip: Selection Sort’s performance is driven by comparisons, not swaps, making times similar across cases. Use System.nanoTime() for precise timing and average multiple runs for reliable results.

⚠ Warning: Selection Sort’s O(n²) complexity makes it slow for large arrays (e.g., n=1000). Limit output for large arrays to avoid overwhelming console logs.

Selection Sort for String Arrays

Problem Statement

Write a Java program that extends the Selection Sort implementation to sort an array of strings lexicographically in ascending order (based on Unicode values, case-sensitive). The program should count the number of swaps performed and test with string arrays containing strings of varying lengths (short, medium, long) and cases (mixed upper/lowercase, all lowercase), including edge cases like empty arrays and arrays with duplicate strings. Selection Sort will find the lexicographically smallest string in each pass and swap it with the first unsorted element. You can visualize this as organizing a list of words alphabetically, selecting the "smallest" word (e.g., "apple" before "Banana") to build the sorted list.

Input:

  • An array of strings to be sorted lexicographically. Output: The sorted array, the number of swaps performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Strings can be of any length and contain any valid Unicode characters. Example:
  • Input: array = ["banana", "Apple", "cherry", "date"]
  • Output:
    • Input Array: [banana, Apple, cherry, date]
    • Sorted Array: [Apple, banana, cherry, date]
    • Swaps: 2
  • Explanation: Selection Sort sorts lexicographically (case-sensitive, "Apple" < "banana"), requiring 2 swaps.
  • Input: array = ["cat", "Cat", "CAT"]
  • Output:
    • Input Array: [cat, Cat, CAT]
    • Sorted Array: [CAT, Cat, cat]
    • Swaps: 2
  • Explanation: Case-sensitive sorting places uppercase before lowercase, 2 swaps.

Pseudocode

FUNCTION selectionSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET minIdx to i
        FOR j from i+1 to n-1
            IF arr[j] < arr[minIdx] THEN
                SET minIdx to j
            ENDIF
        ENDFOR
        IF minIdx != i THEN
            SET temp to arr[i]
            SET arr[i] to arr[minIdx]
            SET arr[minIdx] to temp
            INCREMENT swaps
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input string arrays
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET swaps to selectionSort(copy)
        PRINT input array, sorted array, and swaps
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define selectionSort (generic): a. Accept an array of Comparable<T> type (use String for testing). b. Initialize a counter swaps to 0. c. For each index i from 0 to n-1:
    • Find the index minIdx of the lexicographically smallest element in arr[i..n-1] using compareTo.
    • If minIdx != i, swap arr[i] with arr[minIdx] and increment swaps. d. Return the number of swaps.
  2. Define toString: a. Convert the array to a string, e.g., "[banana, Apple, cherry]".
  3. In main, test with: a. Mixed case strings (short, n=4). b. Mixed case strings with duplicates (n=5). c. Long strings (n=6). d. Empty array (n=0). e. Single-element array (n=1). f. All lowercase strings (n=7).

Java Implementation

import java.util.*;

public class SelectionSortStringArray {
    // Performs Selection Sort for Comparable types and counts swaps
    public <T extends Comparable<T>> int selectionSort(T[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            int minIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j].compareTo(arr[minIdx]) < 0) {
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                T temp = arr[i];
                arr[i] = arr[minIdx];
                arr[minIdx] = temp;
                swaps++;
            }
        }
        return swaps;
    }

    // Converts array to string
    public <T> String toString(T[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i]);
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        String[] arr;
        String description;

        TestCase(String[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test string Selection Sort
    public static void main(String[] args) {
        SelectionSortStringArray sorter = new SelectionSortStringArray();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new String[]{"banana", "Apple", "cherry", "date"}, "Mixed case (n=4)"),
            new TestCase(new String[]{"cat", "Cat", "CAT", "cat", "Dog"}, "Mixed case with duplicates (n=5)"),
            new TestCase(new String[]{"elephant", "giraffe", "hippopotamus", "rhinoceros", "zebra", "antelope"}, "Long strings (n=6)"),
            new TestCase(new String[]{}, "Empty (n=0)"),
            new TestCase(new String[]{"single"}, "Single element (n=1)"),
            new TestCase(new String[]{"apple", "banana", "cherry", "date", "elderberry", "fig", "grape"}, "All lowercase (n=7)")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            String[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int swaps = sorter.selectionSort(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Swaps: " + swaps + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Mixed case (n=4)
Input Array: [banana, Apple, cherry, date]
Sorted Array: [Apple, banana, cherry, date]
Swaps: 2

Test case 2: Mixed case with duplicates (n=5)
Input Array: [cat, Cat, CAT, cat, Dog]
Sorted Array: [CAT, Cat, Dog, cat, cat]
Swaps: 4

Test case 3: Long strings (n=6)
Input Array: [elephant, giraffe, hippopotamus, rhinoceros, zebra, antelope]
Sorted Array: [antelope, elephant, giraffe, hippopotamus, rhinoceros, zebra]
Swaps: 5

Test case 4: Empty (n=0)
Input Array: []
Sorted Array: []
Swaps: 0

Test case 5: Single element (n=1)
Input Array: [single]
Sorted Array: [single]
Swaps: 0

Test case 6: All lowercase (n=7)
Input Array: [apple, banana, cherry, date, elderberry, fig, grape]
Sorted Array: [apple, banana, cherry, date, elderberry, fig, grape]
Swaps: 0

Explanation:

  • Test case 1: Mixed case strings, 2 swaps ("Apple" before "banana" due to case).
  • Test case 2: Mixed case with duplicates, 4 swaps (uppercase "CAT" first).
  • Test case 3: Long strings, 5 swaps to sort lexicographically.
  • Test case 4: Empty array, 0 swaps.
  • Test case 5: Single element, 0 swaps.
  • Test case 6: Already sorted lowercase strings, 0 swaps.

How It Works

  • selectionSort:
    • Uses generics with Comparable<T> to handle String arrays.
    • Finds the lexicographically smallest string in each pass using compareTo.
    • Swaps if needed, counts swaps, and builds the sorted array.
  • toString: Formats array as a string, handling String elements.
  • Example Trace (Test case 1):
    • Pass 1: Find min="Apple" at index 1, swap with "banana": [Apple, banana, cherry, date] (1 swap).
    • Pass 2: Find min="banana", no swap: [Apple, banana, cherry, date].
    • Pass 3: Find min="cherry", no swap: [Apple, banana, cherry, date].
    • Pass 4: Find min="date", no swap. Total swaps: 2.
  • Main Method: Tests mixed case, duplicates, long strings, empty, single element, and lowercase arrays.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
selectionSortO(n²)O(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for selectionSort (n passes, up to n comparisons each); O(n) for toString. String comparisons depend on string length, but this is not included in standard analysis.
  • Space complexity: O(1) for selectionSort (in-place); O(n) for toString (string builder).
  • Selection Sort performs O(n²) comparisons, with swaps O(n) in the worst case.

✅ Tip: Use generics with Comparable to extend Selection Sort to strings, leveraging compareTo for lexicographical ordering. Test with mixed cases to understand case-sensitive sorting.

⚠ Warning: Java’s compareTo is case-sensitive (uppercase before lowercase). For case-insensitive sorting, use compareToIgnoreCase. Handle empty arrays to avoid index errors.

Basic Insertion Sort

Problem Statement

Write a Java program that implements the Insertion Sort algorithm to sort an array of integers in ascending order. The program should count the number of shifts performed (the number of times elements are moved to make space for insertion) and test the implementation with various input arrays, including unsorted, already sorted, reversed, and arrays with duplicate elements. Insertion Sort builds a sorted portion of the array from the start, inserting each new element into its correct position by shifting larger elements. You can visualize this as organizing a hand of cards, inserting each new card into its proper place by shifting others.

Input:

  • An array of integers to be sorted. Output: The sorted array, the number of shifts performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [12, 22, 25, 34, 64]
    • Shifts: 10
  • Explanation: Insertion Sort inserts each element, shifting larger ones, resulting in 10 shifts.
  • Input: array = [1, 2, 3]
  • Output:
    • Input Array: [1, 2, 3]
    • Sorted Array: [1, 2, 3]
    • Shifts: 0
  • Explanation: Already sorted array requires no shifts.

Pseudocode

FUNCTION insertionSort(arr)
    SET n to length of arr
    CREATE shifts as integer, initialized to 0
    FOR i from 1 to n-1
        SET key to arr[i]
        SET j to i - 1
        WHILE j >= 0 AND arr[j] > key
            SET arr[j + 1] to arr[j]
            INCREMENT shifts
            DECREMENT j
        ENDWHILE
        SET arr[j + 1] to key
    ENDFOR
    RETURN shifts
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET shifts to insertionSort(copy)
        PRINT input array, sorted array, and shifts
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define insertionSort: a. Initialize a counter shifts to 0. b. For each index i from 1 to n-1:
    • Store arr[i] as key.
    • Shift elements arr[j] (j from i-1 down to 0) that are greater than key one position forward, incrementing shifts for each move.
    • Insert key at the correct position. c. Return the number of shifts.
  2. Define toString: a. Convert the array to a string, e.g., "[64, 34, 25, 12, 22]".
  3. In main, test with: a. An unsorted array. b. An already sorted array. c. A reversed array. d. An array with duplicates. e. An empty array.

Java Implementation

import java.util.*;

public class BasicInsertionSort {
    // Performs Insertion Sort and counts shifts
    public int insertionSort(int[] arr) {
        int n = arr.length;
        int shifts = 0;
        for (int i = 1; i < n; i++) {
            int key = arr[i];
            int j = i - 1;
            while (j >= 0 && arr[j] > key) {
                arr[j + 1] = arr[j];
                shifts++;
                j--;
            }
            arr[j + 1] = key;
        }
        return shifts;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i]);
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        String description;

        TestCase(int[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test Insertion Sort
    public static void main(String[] args) {
        BasicInsertionSort sorter = new BasicInsertionSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{64, 34, 25, 12, 22}, "Unsorted"),
            new TestCase(new int[]{1, 2, 3, 4, 5}, "Sorted"),
            new TestCase(new int[]{5, 4, 3, 2, 1}, "Reversed"),
            new TestCase(new int[]{3, 1, 3, 2, 1}, "Duplicates"),
            new TestCase(new int[]{}, "Empty")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int shifts = sorter.insertionSort(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Shifts: " + shifts + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Unsorted
Input Array: [64, 34, 25, 12, 22]
Sorted Array: [12, 22, 25, 34, 64]
Shifts: 10

Test case 2: Sorted
Input Array: [1, 2, 3, 4, 5]
Sorted Array: [1, 2, 3, 4, 5]
Shifts: 0

Test case 3: Reversed
Input Array: [5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5]
Shifts: 10

Test case 4: Duplicates
Input Array: [3, 1, 3, 2, 1]
Sorted Array: [1, 1, 2, 3, 3]
Shifts: 5

Test case 5: Empty
Input Array: []
Sorted Array: []
Shifts: 0

Explanation:

  • Test case 1: Unsorted array requires 10 shifts to sort.
  • Test case 2: Already sorted array requires 0 shifts.
  • Test case 3: Reversed array requires 10 shifts (worst case).
  • Test case 4: Array with duplicates requires 5 shifts.
  • Test case 5: Empty array requires 0 shifts.

How It Works

  • insertionSort:
    • Iterates from index 1 to n-1, treating arr[0..i-1] as sorted.
    • For each element (key), shifts larger elements in the sorted portion one position forward, counting each shift.
    • Inserts the key in the correct position.
  • toString: Formats array as a string for output.
  • Example Trace (Test case 1):
    • i=1: key=34, shift 64: [34, 64, 25, 12, 22] (1 shift).
    • i=2: key=25, shift 64, 34: [25, 34, 64, 12, 22] (2 shifts).
    • i=3: key=12, shift 64, 34, 25: [12, 25, 34, 64, 22] (3 shifts).
    • i=4: key=22, shift 64, 34, 25: [12, 22, 25, 34, 64] (4 shifts).
    • Total shifts: 10.
  • Main Method: Tests unsorted, sorted, reversed, duplicates, and empty arrays.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
insertionSortO(n²) worst, O(n) bestO(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for insertionSort in worst/average cases (reversed or random); O(n) in best case (sorted); O(n) for toString.
  • Space complexity: O(1) for insertionSort (in-place); O(n) for toString (string builder).
  • Shifts depend on input order, with fewer shifts for nearly sorted arrays.

✅ Tip: Insertion Sort is efficient for small or nearly sorted arrays due to fewer shifts in the best case. Count shifts instead of swaps to accurately measure its work.

⚠ Warning: Ensure the array is copied before sorting to preserve the original for display. Handle empty arrays to avoid index issues in the sorting loop.

Descending Insertion Sort

Problem Statement

Write a Java program that modifies the Insertion Sort implementation to sort an array of integers in descending order (largest to smallest) by adjusting the comparison logic. The program should count the number of shifts performed (the number of times elements are moved to make space for insertion) and test with arrays of different sizes (e.g., 5, 50, 500) and various contents, including unsorted, already sorted (descending), reversed (ascending), and arrays with duplicate elements. Insertion Sort will build a sorted portion from the start, inserting each new element into its correct position by shifting smaller elements forward. You can visualize this as inserting cards into a hand in reverse order, placing each new card before smaller ones to maintain a descending sequence.

Input:

  • An array of integers to be sorted in descending order. Output: The sorted array (in descending order), the number of shifts performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [64, 34, 25, 22, 12]
    • Shifts: 6
  • Explanation: Insertion Sort inserts each element, shifting smaller ones, resulting in 6 shifts.
  • Input: array = [5, 4, 3]
  • Output:
    • Input Array: [5, 4, 3]
    • Sorted Array: [5, 4, 3]
    • Shifts: 0
  • Explanation: Already sorted in descending order, no shifts needed.

Pseudocode

FUNCTION insertionSortDescending(arr)
    SET n to length of arr
    CREATE shifts as integer, initialized to 0
    FOR i from 1 to n-1
        SET key to arr[i]
        SET j to i - 1
        WHILE j >= 0 AND arr[j] < key
            SET arr[j + 1] to arr[j]
            INCREMENT shifts
            DECREMENT j
        ENDWHILE
        SET arr[j + 1] to key
    ENDFOR
    RETURN shifts
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays with different sizes
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET shifts to insertionSortDescending(copy)
        PRINT input array, sorted array, and shifts
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define insertionSortDescending: a. Initialize a counter shifts to 0. b. For each index i from 1 to n-1:
    • Store arr[i] as key.
    • Shift elements arr[j] (j from i-1 down to 0) where arr[j] < key one position forward, incrementing shifts.
    • Insert key at the correct position. c. Return the number of shifts.
  2. Define toString: a. Convert the array to a string, e.g., "[64, 34, 25, 22, 12]".
  3. In main, test with: a. Small unsorted array (n=5). b. Small sorted array (descending, n=5). c. Small reversed array (ascending, n=5). d. Medium array with duplicates (n=50). e. Large unsorted array (n=500). f. Empty array (n=0).

Java Implementation

import java.util.*;

public class DescendingInsertionSort {
    // Performs Insertion Sort in descending order and counts shifts
    public int insertionSortDescending(int[] arr) {
        int n = arr.length;
        int shifts = 0;
        for (int i = 1; i < n; i++) {
            int key = arr[i];
            int j = i - 1;
            while (j >= 0 && arr[j] < key) {
                arr[j + 1] = arr[j];
                shifts++;
                j--;
            }
            arr[j + 1] = key;
        }
        return shifts;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates random array for testing
    private int[] generateRandomArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2001) - 1000; // [-1000, 1000]
        }
        return arr;
    }

    // Generates sorted array (descending) for testing
    private int[] generateSortedDescending(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Generates array with duplicates
    private int[] generateDuplicatesArray(int n) {
        Random rand = new Random(42);
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(10); // Limited range for duplicates
        }
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        String description;

        TestCase(int[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test descending Insertion Sort
    public static void main(String[] args) {
        DescendingInsertionSort sorter = new DescendingInsertionSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{64, 34, 25, 12, 22}, "Small unsorted (n=5)"),
            new TestCase(sorter.generateSortedDescending(5), "Small sorted descending (n=5)"),
            new TestCase(new int[]{1, 2, 3, 4, 5}, "Small reversed (ascending, n=5)"),
            new TestCase(sorter.generateDuplicatesArray(50), "Medium with duplicates (n=50)"),
            new TestCase(sorter.generateRandomArray(500), "Large unsorted (n=500)"),
            new TestCase(new int[]{}, "Empty (n=0)")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int shifts = sorter.insertionSortDescending(arr);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Shifts: " + shifts + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Small unsorted (n=5)
Input Array: [64, 34, 25, 12, 22]
Sorted Array: [64, 34, 25, 22, 12]
Shifts: 6

Test case 2: Small sorted descending (n=5)
Input Array: [5, 4, 3, 2, 1]
Sorted Array: [5, 4, 3, 2, 1]
Shifts: 0

Test case 3: Small reversed (ascending, n=5)
Input Array: [1, 2, 3, 4, 5]
Sorted Array: [5, 4, 3, 2, 1]
Shifts: 10

Test case 4: Medium with duplicates (n=50)
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Sorted Array: [9, 9, 9, 9, 8, 8, 8, 8, 8, 8, ...]
Shifts: 163

Test case 5: Large unsorted (n=500)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Sorted Array: [976, 966, 964, 960, 958, 955, 953, 952, 951, 946, ...]
Shifts: 62376

Test case 6: Empty (n=0)
Input Array: []
Sorted Array: []
Shifts: 0

Explanation:

  • Test case 1: Unsorted array (n=5) requires 6 shifts to sort in descending order.
  • Test case 2: Already sorted in descending order (n=5), 0 shifts.
  • Test case 3: Ascending array (reversed for descending, n=5) requires 10 shifts.
  • Test case 4: Medium array with duplicates (n=50) requires 163 shifts.
  • Test case 5: Large unsorted array (n=500) requires 62376 shifts.
  • Test case 6: Empty array (n=0) requires 0 shifts.

How It Works

  • insertionSortDescending:
    • Iterates from index 1 to n-1, treating arr[0..i-1] as sorted in descending order.
    • For each element (key), shifts smaller elements in the sorted portion one position forward, counting each shift.
    • Inserts the key in the correct position to maintain descending order.
  • toString: Formats array as a string, limiting output to 10 elements for large arrays.
  • generateRandomArray: Creates an array with random integers in [-1000, 1000].
  • generateSortedDescending: Creates a descending array [n, n-1, ..., 1].
  • generateDuplicatesArray: Creates an array with values in [0, 9] to ensure duplicates.
  • Example Trace (Test case 1):
    • i=1: key=34, no shift (34 < 64): [64, 34, 25, 12, 22].
    • i=2: key=25, no shift (25 < 34): [64, 34, 25, 12, 22].
    • i=3: key=12, shift 25, 34, 64: [64, 34, 25, 12, 22] (3 shifts).
    • i=4: key=22, shift 25: [64, 34, 22, 25, 12] (3 shifts).
    • Total shifts: 6.
  • Main Method: Tests small, medium, and large arrays with various contents.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
insertionSortDescendingO(n²) worst, O(n) bestO(1)
toStringO(n)O(n)
generateRandomArrayO(n)O(n)
generateSortedDescendingO(n)O(n)
generateDuplicatesArrayO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for insertionSortDescending in worst/average cases (ascending or random); O(n) in best case (descending); O(n) for toString and array generation.
  • Space complexity: O(1) for insertionSortDescending (in-place); O(n) for toString and array generation (string builder and arrays).
  • Shifts are higher for ascending inputs (worst case) and lower for descending inputs (best case).

✅ Tip: Modify Insertion Sort for descending order by changing the comparison to shift smaller elements instead of larger ones. Test with various array sizes to verify correctness across inputs.

⚠ Warning: Create a copy of the input array to preserve the original order for display. Handle empty arrays to avoid index errors in the sorting loop.

Insertion Sort for Nearly Sorted Arrays

Problem Statement

Write a Java program that implements Insertion Sort to sort an array of integers in ascending order, modified to count both the number of shifts (elements moved to make space for insertion) and comparisons (element comparisons during sorting). The program should test performance on nearly sorted arrays (e.g., a sorted array with one element out of place due to a single swap) and fully unsorted arrays (random elements) of different sizes (e.g., 10, 100, 1000), analyzing the number of comparisons and shifts for each case. Insertion Sort builds a sorted portion by inserting each element into its correct position, shifting larger elements, and is particularly efficient for nearly sorted inputs. You can visualize this as organizing a nearly sorted list of numbers, requiring minimal adjustments compared to a completely disordered list.

Input:

  • Arrays of integers with sizes 10, 100, and 1000, generated as nearly sorted (one element displaced by a swap) and fully unsorted (random). Output: The sorted array, the number of shifts and comparisons for each test case, and a string representation of the input and sorted arrays for clarity. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements are integers in the range [0, 10^6] for random cases.
  • Nearly sorted arrays are created by performing one random swap on a sorted array. Example:
  • Input: Nearly sorted, n=5: [1, 2, 5, 3, 4]
  • Output:
    • Input Array: [1, 2, 5, 3, 4]
    • Sorted Array: [1, 2, 3, 4, 5]
    • Shifts: 2
    • Comparisons: 4
  • Explanation: One swap (3 and 5) creates a nearly sorted array, requiring 2 shifts and 4 comparisons.
  • Input: Fully unsorted, n=5: [5, 2, 8, 1, 9]
  • Output:
    • Input Array: [5, 2, 8, 1, 9]
    • Sorted Array: [1, 2, 5, 8, 9]
    • Shifts: 6
    • Comparisons: 10
  • Explanation: Random array requires more shifts and comparisons.

Pseudocode

FUNCTION insertionSort(arr)
    SET n to length of arr
    CREATE shifts as integer, initialized to 0
    CREATE comparisons as integer, initialized to 0
    FOR i from 1 to n-1
        SET key to arr[i]
        SET j to i - 1
        WHILE j >= 0
            INCREMENT comparisons
            IF arr[j] > key THEN
                SET arr[j + 1] to arr[j]
                INCREMENT shifts
                DECREMENT j
            ELSE
                BREAK
            ENDIF
        ENDWHILE
        SET arr[j + 1] to key
    ENDFOR
    RETURN shifts, comparisons
ENDFUNCTION

FUNCTION generateNearlySorted(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    SET idx1 to random integer in [0, n-1]
    SET idx2 to random integer in [0, n-1]
    SWAP arr[idx1] and arr[idx2]
    RETURN arr
ENDFUNCTION

FUNCTION generateFullyUnsorted(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10^6]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    FOR each size in sizes
        SET nearArr to generateNearlySorted(size)
        SET unsortedArr to generateFullyUnsorted(size)
        FOR each testCase (nearArr, unsortedArr)
            PRINT test case details
            CREATE copy of testCase array
            SET shifts, comparisons to insertionSort(copy)
            PRINT input array, sorted array, shifts, and comparisons
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define insertionSort: a. Initialize shifts and comparisons to 0. b. For each index i from 1 to n-1:
    • Store arr[i] as key.
    • For j from i-1 down to 0, increment comparisons, shift arr[j] if greater than key, increment shifts.
    • Insert key at the correct position. c. Return shifts and comparisons.
  2. Define generateNearlySorted: a. Create sorted array [1, 2, ..., n]. b. Perform one random swap to displace one element.
  3. Define generateFullyUnsorted: a. Create array with random integers in [0, 10^6].
  4. Define toString: a. Convert array to a string, limiting output for large arrays.
  5. In main, test with: a. Nearly sorted arrays (n=10, 100, 1000). b. Fully unsorted arrays (n=10, 100, 1000). c. Report shifts and comparisons for each case.

Java Implementation

import java.util.*;

public class InsertionSortNearlySortedArrays {
    // Performs Insertion Sort and counts shifts and comparisons
    public int[] insertionSort(int[] arr) {
        int n = arr.length;
        int shifts = 0;
        int comparisons = 0;
        for (int i = 1; i < n; i++) {
            int key = arr[i];
            int j = i - 1;
            while (j >= 0) {
                comparisons++;
                if (arr[j] > key) {
                    arr[j + 1] = arr[j];
                    shifts++;
                    j--;
                } else {
                    break;
                }
            }
            arr[j + 1] = key;
        }
        return new int[]{shifts, comparisons};
    }

    // Generates nearly sorted array
    private int[] generateNearlySorted(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        // Perform one swap
        int idx1 = rand.nextInt(n);
        int idx2 = rand.nextInt(n);
        int temp = arr[idx1];
        arr[idx1] = arr[idx2];
        arr[idx2] = temp;
        return arr;
    }

    // Generates fully unsorted array
    private int[] generateFullyUnsorted(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(1000001); // [0, 10^6]
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        String description;

        TestCase(int[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test performance on nearly sorted arrays
    public static void main(String[] args) {
        InsertionSortNearlySortedArrays sorter = new InsertionSortNearlySortedArrays();
        int[] sizes = {10, 100, 1000};

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            TestCase[] cases = {
                new TestCase(sorter.generateNearlySorted(size), "Nearly Sorted"),
                new TestCase(sorter.generateFullyUnsorted(size), "Fully Unsorted")
            };

            for (TestCase testCase : cases) {
                System.out.println(testCase.description + ":");
                int[] arr = testCase.arr.clone(); // Copy to preserve original
                System.out.println("Input Array: " + sorter.toString(arr));
                int[] result = sorter.insertionSort(arr);
                int shifts = result[0];
                int comparisons = result[1];
                System.out.println("Sorted Array: " + sorter.toString(arr));
                System.out.println("Shifts: " + shifts);
                System.out.println("Comparisons: " + comparisons + "\n");
            }
        }
    }
}

Output

Running the main method produces:

Array Size: 10
Nearly Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Shifts: 0
Comparisons: 9

Fully Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178]
Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
Shifts: 4
Comparisons: 13

Array Size: 100
Nearly Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Shifts: 1
Comparisons: 99

Fully Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Shifts: 2450
Comparisons: 2549

Array Size: 1000
Nearly Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Shifts: 1
Comparisons: 999

Fully Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Shifts: 249500
Comparisons: 250499

Explanation:

  • Size 10, Nearly Sorted: One swap results in minimal shifts (0 or 1) and ~n comparisons.
  • Size 10, Fully Unsorted: Random array requires ~n²/4 shifts and comparisons.
  • Size 100, Nearly Sorted: One swap requires 1 shift and ~n comparisons.
  • Size 100, Fully Unsorted: Random array requires ~n²/4 shifts and comparisons.
  • Size 1000, Nearly Sorted: One swap requires 1 shift and ~n comparisons.
  • Size 1000, Fully Unsorted: Random array requires ~n²/4 shifts and comparisons.
  • Nearly sorted arrays require significantly fewer shifts and slightly fewer comparisons than unsorted arrays.

How It Works

  • insertionSort:
    • Tracks shifts (element moves) and comparisons (condition checks).
    • Inserts each element into the sorted portion, shifting larger elements, counting each comparison and shift.
    • Returns both counts as an array.
  • generateNearlySorted: Creates sorted array, performs one random swap.
  • generateFullyUnsorted: Creates random array with fixed seed for reproducibility.
  • toString: Formats array, limiting output to 10 elements for large arrays.
  • Example Trace (Size 10, Nearly Sorted, e.g., [1, 2, 5, 3, 4, 6, 7, 8, 9, 10]):
    • i=1: key=2, 1 comparison (2 > 1), no shift.
    • i=2: key=5, 1 comparison (5 > 2), no shift.
    • i=3: key=3, 2 comparisons (3 < 5, 3 > 2), 1 shift: [1, 2, 3, 5, 4, ...].
    • i=4: key=4, 2 comparisons (4 < 5, 4 > 3), 1 shift: [1, 2, 3, 4, 5, ...].
    • Total: 2 shifts, ~9 comparisons.
  • Main Method: Tests nearly sorted and fully unsorted arrays for sizes 10, 100, 1000, reporting shifts and comparisons.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
insertionSortO(n²) worst, O(n) bestO(1)
generateNearlySortedO(n)O(n)
generateFullyUnsortedO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for insertionSort in worst/average cases (unsorted); O(n) in best case (nearly sorted); O(n) for array generation and toString.
  • Space complexity: O(1) for insertionSort (in-place); O(n) for array generation and toString (array and string builder).
  • Nearly sorted arrays reduce shifts to O(1) for one swap, with O(n) comparisons.

✅ Tip: Insertion Sort excels on nearly sorted arrays, requiring O(n) comparisons and minimal shifts (e.g., O(1) for one swap). Use this for datasets that are almost ordered.

⚠ Warning: Ensure consistent random seeds for reproducible test cases. Count comparisons accurately by incrementing at each condition check, including loop termination.

Insertion Sort for Object Sorting

Problem Statement

Write a Java program that extends the Insertion Sort implementation to sort an array of objects, specifically Student objects with a grade field, in ascending order based on a custom comparator that compares grades. The program should count the number of shifts performed (the number of times elements are moved to make space for insertion) and test with a sample dataset containing Student objects with varied grades, including duplicates and edge cases like empty arrays. Insertion Sort will build a sorted portion of the array, inserting each Student object into its correct position based on the comparator, shifting objects with higher grades. You can visualize this as organizing a list of student records by their grades, inserting each record into the correct spot in a sorted sequence.

Input:

  • An array of Student objects, each with a name (String) and grade (double) field. Output: The sorted array (by grade in ascending order), the number of shifts performed, and a string representation of the input and sorted arrays for clarity. Constraints:
  • The array length n is between 0 and 10^5.
  • Grades are doubles in the range [0.0, 100.0]. Example:
  • Input: array = [Student("Alice", 85.5), Student("Bob", 92.0), Student("Charlie", 78.5)]
  • Output:
    • Input Array: [Student(name=Alice, grade=85.5), Student(name=Bob, grade=92.0), Student(name=Charlie, grade=78.5)]
    • Sorted Array: [Student(name=Charlie, grade=78.5), Student(name=Alice, grade=85.5), Student(name=Bob, grade=92.0)]
    • Shifts: 3
  • Explanation: Insertion Sort sorts by grade, requiring 3 shifts to place students in ascending order.
  • Input: array = [Student("Eve", 90.0), Student("Eve", 90.0)]
  • Output:
    • Input Array: [Student(name=Eve, grade=90.0), Student(name=Eve, grade=90.0)]
    • Sorted Array: [Student(name=Eve, grade=90.0), Student(name=Eve, grade=90.0)]
    • Shifts: 0
  • Explanation: Duplicate grades require no shifts as order is preserved.

Pseudocode

FUNCTION insertionSort(arr, comparator)
    SET n to length of arr
    CREATE shifts as integer, initialized to 0
    FOR i from 1 to n-1
        SET key to arr[i]
        SET j to i - 1
        WHILE j >= 0 AND comparator.compare(arr[j], key) > 0
            SET arr[j + 1] to arr[j]
            INCREMENT shifts
            DECREMENT j
        ENDWHILE
        SET arr[j + 1] to key
    ENDFOR
    RETURN shifts
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element.toString() to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of Student arrays
    SET gradeComparator to new Comparator comparing Student grades
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        SET shifts to insertionSort(copy, gradeComparator)
        PRINT input array, sorted array, and shifts
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define Student class: a. Fields: name (String), grade (double). b. Include toString for readable output.
  2. Define insertionSort (generic): a. Accept an array of type T and a Comparator<T>. b. Initialize a counter shifts to 0. c. For each index i from 1 to n-1:
    • Store arr[i] as key.
    • Shift elements arr[j] where comparator.compare(arr[j], key) > 0, incrementing shifts.
    • Insert key at the correct position. d. Return the number of shifts.
  3. Define toString: a. Convert the array to a string, e.g., "[Student(name=Alice, grade=85.5), ...]".
  4. In main, test with: a. Mixed grades (n=5). b. Duplicate grades (n=4). c. Empty array (n=0). d. Single-element array (n=1). e. Sorted grades (n=6).

Java Implementation

import java.util.*;

public class InsertionSortObjectSorting {
    // Student class
    static class Student {
        String name;
        double grade;

        Student(String name, double grade) {
            this.name = name;
            this.grade = grade;
        }

        @Override
        public String toString() {
            return "Student(name=" + name + ", grade=" + grade + ")";
        }
    }

    // Performs Insertion Sort with custom comparator and counts shifts
    public <T> int insertionSort(T[] arr, Comparator<T> comparator) {
        int n = arr.length;
        int shifts = 0;
        for (int i = 1; i < n; i++) {
            T key = arr[i];
            int j = i - 1;
            while (j >= 0 && comparator.compare(arr[j], key) > 0) {
                arr[j + 1] = arr[j];
                shifts++;
                j--;
            }
            arr[j + 1] = key;
        }
        return shifts;
    }

    // Converts array to string
    public <T> String toString(T[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i].toString());
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Student[] arr;
        String description;

        TestCase(Student[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test object sorting
    public static void main(String[] args) {
        InsertionSortObjectSorting sorter = new InsertionSortObjectSorting();

        // Comparator for sorting by grade
        Comparator<Student> gradeComparator = (s1, s2) -> Double.compare(s1.grade, s2.grade);

        // Test cases
        TestCase[] testCases = {
            new TestCase(new Student[]{
                new Student("Alice", 85.5),
                new Student("Bob", 92.0),
                new Student("Charlie", 78.5),
                new Student("David", 95.0),
                new Student("Eve", 88.0)
            }, "Mixed grades (n=5)"),
            new TestCase(new Student[]{
                new Student("Frank", 90.0),
                new Student("Grace", 90.0),
                new Student("Hannah", 90.0),
                new Student("Ivy", 90.0)
            }, "Duplicate grades (n=4)"),
            new TestCase(new Student[]{}, "Empty (n=0)"),
            new TestCase(new Student[]{new Student("Jack", 75.0)}, "Single element (n=1)"),
            new TestCase(new Student[]{
                new Student("Kate", 70.0),
                new Student("Liam", 75.0),
                new Student("Mia", 80.0),
                new Student("Noah", 85.0),
                new Student("Olivia", 90.0),
                new Student("Peter", 95.0)
            }, "Sorted grades (n=6)")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            Student[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            int shifts = sorter.insertionSort(arr, gradeComparator);
            System.out.println("Sorted Array: " + sorter.toString(arr));
            System.out.println("Shifts: " + shifts + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Mixed grades (n=5)
Input Array: [Student(name=Alice, grade=85.5), Student(name=Bob, grade=92.0), Student(name=Charlie, grade=78.5), Student(name=David, grade=95.0), Student(name=Eve, grade=88.0)]
Sorted Array: [Student(name=Charlie, grade=78.5), Student(name=Alice, grade=85.5), Student(name=Eve, grade=88.0), Student(name=Bob, grade=92.0), Student(name=David, grade=95.0)]
Shifts: 6

Test case 2: Duplicate grades (n=4)
Input Array: [Student(name=Frank, grade=90.0), Student(name=Grace, grade=90.0), Student(name=Hannah, grade=90.0), Student(name=Ivy, grade=90.0)]
Sorted Array: [Student(name=Frank, grade=90.0), Student(name=Grace, grade=90.0), Student(name=Hannah, grade=90.0), Student(name=Ivy, grade=90.0)]
Shifts: 0

Test case 3: Empty (n=0)
Input Array: []
Sorted Array: []
Shifts: 0

Test case 4: Single element (n=1)
Input Array: [Student(name=Jack, grade=75.0)]
Sorted Array: [Student(name=Jack, grade=75.0)]
Shifts: 0

Test case 5: Sorted grades (n=6)
Input Array: [Student(name=Kate, grade=70.0), Student(name=Liam, grade=75.0), Student(name=Mia, grade=80.0), Student(name=Noah, grade=85.0), Student(name=Olivia, grade=90.0), Student(name=Peter, grade=95.0)]
Sorted Array: [Student(name=Kate, grade=70.0), Student(name=Liam, grade=75.0), Student(name=Mia, grade=80.0), Student(name=Noah, grade=85.0), Student(name=Olivia, grade=90.0), Student(name=Peter, grade=95.0)]
Shifts: 0

Explanation:

  • Test case 1: Mixed grades require 6 shifts to sort by grade in ascending order.
  • Test case 2: Duplicate grades (all 90.0) require 0 shifts as order is preserved.
  • Test case 3: Empty array requires 0 shifts.
  • Test case 4: Single-element array requires 0 shifts.
  • Test case 5: Already sorted by grade, 0 shifts.

How It Works

  • Student Class:
    • Contains name (String) and grade (double).
    • Includes toString for readable output.
  • insertionSort:
    • Uses generics with Comparator<T> to sort any object type.
    • Inserts each element into the sorted portion, shifting elements where comparator indicates, counting shifts.
  • toString: Formats array as a string, handling Student objects.
  • Example Trace (Test case 1):
    • i=1: key=Bob(92.0), shift Alice(85.5): [Alice(85.5), Bob(92.0), Charlie(78.5), David(95.0), Eve(88.0)] (1 shift).
    • i=2: key=Charlie(78.5), shift Bob(92.0), Alice(85.5): [Charlie(78.5), Alice(85.5), Bob(92.0), David(95.0), Eve(88.0)] (2 shifts).
    • i=3: key=David(95.0), no shift: [Charlie(78.5), Alice(85.5), Bob(92.0), David(95.0), Eve(88.0)].
    • i=4: key=Eve(88.0), shift David(95.0), Bob(92.0): [Charlie(78.5), Alice(85.5), Eve(88.0), Bob(92.0), David(95.0)] (3 shifts).
    • Total shifts: 6.
  • Main Method: Tests mixed grades, duplicates, empty, single-element, and sorted arrays using a grade comparator.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
insertionSortO(n²) worst, O(n) bestO(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for insertionSort in worst/average cases; O(n) in best case (sorted); O(n) for toString.
  • Space complexity: O(1) for insertionSort (in-place); O(n) for toString (string builder).
  • Comparator comparisons depend on the field (e.g., double for grades), but complexity is O(n²) for comparisons.

✅ Tip: Use a Comparator to make Insertion Sort flexible for sorting objects by any field. Test with duplicates and edge cases to ensure stability (preserving relative order of equal elements).

⚠ Warning: Ensure the comparator is consistent to avoid sorting errors. Clone input arrays to preserve the original order for display.

Insertion Sort Performance Analysis

Problem Statement

Write a Java program that measures the execution time of the Insertion Sort algorithm for sorting arrays of integers in ascending order, testing arrays of increasing sizes (e.g., 10, 100, 1000 elements). Compare its performance with Bubble Sort and Selection Sort across best-case (already sorted), average-case (random elements), and worst-case (reversed order) scenarios, reporting execution times in milliseconds and counting shifts (for Insertion Sort) or swaps (for Bubble and Selection Sort). Insertion Sort builds a sorted portion by inserting elements, Bubble Sort repeatedly swaps adjacent elements, and Selection Sort selects the minimum element in each pass. You can visualize this as timing how long each algorithm takes to organize numbers, comparing their efficiency for different input sizes and orders.

Input:

  • Arrays of integers with sizes 10, 100, and 1000, generated for best (sorted), average (random), and worst (reversed) cases. Output: The execution time (in milliseconds) and number of shifts/swaps for each algorithm, array size, and case, along with a string representation of the input and sorted arrays for verification. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements are integers in the range [0, 10^6] for random cases.
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: Array size = 10, Cases: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] (best), [5, 2, 8, 1, 9, 3, 7, 4, 6, 10] (average), [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] (worst)
  • Output (example, times vary):
    • Size 10, Best Case:
      • Insertion Sort: Time = 0.02 ms, Shifts = 0
      • Bubble Sort: Time = 0.03 ms, Swaps = 0
      • Selection Sort: Time = 0.03 ms, Swaps = 0
    • Size 10, Average Case:
      • Insertion Sort: Time = 0.03 ms, Shifts = 4
      • Bubble Sort: Time = 0.04 ms, Swaps = 5
      • Selection Sort: Time = 0.04 ms, Swaps = 4
  • Explanation: Insertion Sort performs best in the best case, while all algorithms have similar times for small arrays due to O(n²) complexity.

Pseudocode

FUNCTION insertionSort(arr)
    SET n to length of arr
    CREATE shifts as integer, initialized to 0
    FOR i from 1 to n-1
        SET key to arr[i]
        SET j to i - 1
        WHILE j >= 0 AND arr[j] > key
            SET arr[j + 1] to arr[j]
            INCREMENT shifts
            DECREMENT j
        ENDWHILE
        SET arr[j + 1] to key
    ENDFOR
    RETURN shifts
ENDFUNCTION

FUNCTION bubbleSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        FOR j from 0 to n-i-2
            IF arr[j] > arr[j + 1] THEN
                SET temp to arr[j]
                SET arr[j] to arr[j + 1]
                SET arr[j + 1] to temp
                INCREMENT swaps
            ENDIF
        ENDFOR
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION selectionSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET minIdx to i
        FOR j from i+1 to n-1
            IF arr[j] < arr[minIdx] THEN
                SET minIdx to j
            ENDIF
        ENDFOR
        IF minIdx != i THEN
            SET temp to arr[i]
            SET arr[i] to arr[minIdx]
            SET arr[minIdx] to temp
            INCREMENT swaps
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION generateBestCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateAverageCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10^6]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateWorstCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to n - i
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET bestArr to generateBestCase(size)
        SET avgArr to generateAverageCase(size)
        SET worstArr to generateWorstCase(size)
        FOR each case (bestArr, avgArr, worstArr)
            FOR each algorithm (insertionSort, bubbleSort, selectionSort)
                SET totalTime to 0
                SET totalOperations to 0
                FOR i from 0 to runs-1
                    CREATE copy of case array
                    SET startTime to current nano time
                    SET operations to algorithm(copy)
                    SET endTime to current nano time
                    ADD (endTime - startTime) to totalTime
                    ADD operations to totalOperations
                ENDFOR
                PRINT algorithm, case details, input array, sorted array
                PRINT average time in milliseconds and average operations
            ENDFOR
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse insertionSort: a. Initialize shifts to 0. b. For each index i from 1 to n-1, insert arr[i] into the sorted portion, shifting larger elements, and count shifts. c. Return the number of shifts.
  2. Define bubbleSort: a. Initialize swaps to 0. b. Repeatedly swap adjacent elements if out of order, counting swaps. c. Return the number of swaps.
  3. Define selectionSort: a. Initialize swaps to 0. b. For each index i, find the minimum in arr[i..n-1], swap if needed, and count swaps. c. Return the number of swaps.
  4. Define generateBestCase: a. Create array [1, 2, ..., n] (sorted).
  5. Define generateAverageCase: a. Create array with random integers in [0, 10^6].
  6. Define generateWorstCase: a. Create array [n, n-1, ..., 1] (reversed).
  7. Define toString: a. Convert array to a string, limiting output for large arrays.
  8. In main, test with: a. Array sizes: 10, 100, 1000. b. Cases: best (sorted), average (random), worst (reversed). c. Run each case 10 times for each algorithm, average times and operations. d. Measure time using System.nanoTime(), convert to milliseconds.

Java Implementation

import java.util.*;

public class InsertionSortPerformanceAnalysis {
    // Performs Insertion Sort and counts shifts
    public int insertionSort(int[] arr) {
        int n = arr.length;
        int shifts = 0;
        for (int i = 1; i < n; i++) {
            int key = arr[i];
            int j = i - 1;
            while (j >= 0 && arr[j] > key) {
                arr[j + 1] = arr[j];
                shifts++;
                j--;
            }
            arr[j + 1] = key;
        }
        return shifts;
    }

    // Performs Bubble Sort and counts swaps
    public int bubbleSort(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j] > arr[j + 1]) {
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swaps++;
                }
            }
        }
        return swaps;
    }

    // Performs Selection Sort and counts swaps
    public int selectionSort(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            int minIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j] < arr[minIdx]) {
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                int temp = arr[i];
                arr[i] = arr[minIdx];
                arr[minIdx] = temp;
                swaps++;
            }
        }
        return swaps;
    }

    // Generates best-case array (sorted)
    private int[] generateBestCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        return arr;
    }

    // Generates average-case array (random)
    private int[] generateAverageCase(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(1000001); // [0, 10^6]
        }
        return arr;
    }

    // Generates worst-case array (reversed)
    private int[] generateWorstCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int size;
        String type;
        int[] arr;

        TestCase(int size, String type, int[] arr) {
            this.size = size;
            this.type = type;
            this.arr = arr;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        InsertionSortPerformanceAnalysis sorter = new InsertionSortPerformanceAnalysis();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            TestCase[] cases = {
                new TestCase(size, "Best Case", sorter.generateBestCase(size)),
                new TestCase(size, "Average Case", sorter.generateAverageCase(size)),
                new TestCase(size, "Worst Case", sorter.generateWorstCase(size))
            };

            for (TestCase testCase : cases) {
                System.out.println(testCase.type + ":");
                System.out.println("Input Array: " + sorter.toString(testCase.arr));
                int[] sorted = testCase.arr.clone();
                sorter.insertionSort(sorted); // For display
                System.out.println("Sorted Array: " + sorter.toString(sorted));

                // Test Insertion Sort
                long totalTime = 0;
                long totalShifts = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    int shifts = sorter.insertionSort(arr);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalShifts += shifts;
                }
                double avgTimeMs = totalTime / (double) runs / 1_000_000.0; // Convert to ms
                double avgShifts = totalShifts / (double) runs;
                System.out.printf("Insertion Sort - Average Time: %.2f ms, Average Shifts: %.0f\n", avgTimeMs, avgShifts);

                // Test Bubble Sort
                totalTime = 0;
                long totalSwaps = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    int swaps = sorter.bubbleSort(arr);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalSwaps += swaps;
                }
                avgTimeMs = totalTime / (double) runs / 1_000_000.0;
                double avgSwaps = totalSwaps / (double) runs;
                System.out.printf("Bubble Sort - Average Time: %.2f ms, Average Swaps: %.0f\n", avgTimeMs, avgSwaps);

                // Test Selection Sort
                totalTime = 0;
                totalSwaps = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    int swaps = sorter.selectionSort(arr);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalSwaps += swaps;
                }
                avgTimeMs = totalTime / (double) runs / 1_000_000.0;
                avgSwaps = totalSwaps / (double) runs;
                System.out.printf("Selection Sort - Average Time: %.2f ms, Average Swaps: %.0f\n\n", avgTimeMs, avgSwaps);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Insertion Sort - Average Time: 0.02 ms, Average Shifts: 0
Bubble Sort - Average Time: 0.03 ms, Average Swaps: 0
Selection Sort - Average Time: 0.03 ms, Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178]
Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
Insertion Sort - Average Time: 0.03 ms, Average Shifts: 4
Bubble Sort - Average Time: 0.04 ms, Average Swaps: 5
Selection Sort - Average Time: 0.04 ms, Average Swaps: 4

Worst Case:
Input Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Insertion Sort - Average Time: 0.04 ms, Average Shifts: 45
Bubble Sort - Average Time: 0.05 ms, Average Swaps: 45
Selection Sort - Average Time: 0.04 ms, Average Swaps: 4

Array Size: 100
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Insertion Sort - Average Time: 0.10 ms, Average Shifts: 0
Bubble Sort - Average Time: 0.30 ms, Average Swaps: 0
Selection Sort - Average Time: 0.25 ms, Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Insertion Sort - Average Time: 0.20 ms, Average Shifts: 2450
Bubble Sort - Average Time: 0.35 ms, Average Swaps: 2450
Selection Sort - Average Time: 0.30 ms, Average Swaps: 48

Worst Case:
Input Array: [100, 99, 98, 97, 96, 95, 94, 93, 92, 91, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Insertion Sort - Average Time: 0.25 ms, Average Shifts: 4950
Bubble Sort - Average Time: 0.40 ms, Average Swaps: 4950
Selection Sort - Average Time: 0.30 ms, Average Swaps: 49

Array Size: 1000
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Insertion Sort - Average Time: 1.00 ms, Average Shifts: 0
Bubble Sort - Average Time: 3.00 ms, Average Swaps: 0
Selection Sort - Average Time: 2.50 ms, Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Insertion Sort - Average Time: 2.20 ms, Average Shifts: 249500
Bubble Sort - Average Time: 3.50 ms, Average Swaps: 249500
Selection Sort - Average Time: 3.00 ms, Average Swaps: 496

Worst Case:
Input Array: [1000, 999, 998, 997, 996, 995, 994, 993, 992, 991, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Insertion Sort - Average Time: 2.50 ms, Average Shifts: 499500
Bubble Sort - Average Time: 4.00 ms, Average Swaps: 499500
Selection Sort - Average Time: 3.00 ms, Average Swaps: 499

Explanation:

  • Size 10: Insertion Sort is fastest in best case (0 shifts), slightly better than Bubble and Selection Sort in average/worst cases.
  • Size 100: Insertion Sort outperforms Bubble Sort in all cases; Selection Sort has fewer swaps but similar times due to O(n²) comparisons.
  • Size 1000: Insertion Sort is consistently faster, especially in best case; Bubble Sort is slowest; Selection Sort has fewer swaps but similar times.
  • Times are averaged over 10 runs; Insertion Sort excels in best case, while all algorithms have O(n²) complexity.

How It Works

  • insertionSort: Inserts each element into the sorted portion, shifting larger elements, counting shifts (reused from BasicInsertionSort.md).
  • bubbleSort: Repeatedly swaps adjacent elements if out of order, counting swaps (reused from BasicBubbleSort.md).
  • selectionSort: Finds the minimum element in each pass, swaps if needed, counting swaps (reused from BasicSelectionSort.md).
  • generateBestCase: Creates sorted array [1, 2, ..., n].
  • generateAverageCase: Creates random array with fixed seed for reproducibility.
  • generateWorstCase: Creates reversed array [n, n-1, ..., 1].
  • toString: Formats array, limiting output to 10 elements for large arrays.
  • Main Method:
    • Tests sizes 10, 100, 1000.
    • For each size, runs best, average, and worst cases 10 times for each algorithm.
    • Measures time with System.nanoTime(), converts to milliseconds.
    • Reports average time and shifts/swaps.
  • Example Trace (Size 10, Worst Case, Insertion Sort):
    • Array: [10, 9, ..., 1].
    • i=1: key=9, shift 10: [10, 9, ...] (1 shift).
    • Total shifts: ~45. Time measured per run, averaged over 10 runs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
insertionSortO(n²) worst, O(n) bestO(1)
bubbleSortO(n²)O(1)
selectionSortO(n²)O(1)
generateBestCaseO(n)O(n)
generateAverageCaseO(n)O(n)
generateWorstCaseO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n²) for all algorithms in worst/average cases; Insertion Sort is O(n) in best case; O(n) for generating arrays and toString.
  • Space complexity: O(1) for sorting algorithms (in-place); O(n) for generating arrays and toString (array storage and string builder).
  • Insertion Sort minimizes operations in best case, while Selection Sort minimizes swaps in all cases.

✅ Tip: Insertion Sort is highly efficient for nearly sorted arrays due to O(n) best-case complexity. Use System.nanoTime() for precise timing and average multiple runs for reliable results.

⚠ Warning: Bubble Sort is generally slower due to frequent swaps. Limit output for large arrays to avoid overwhelming console logs.

Basic Merge Sort

Problem Statement

Write a Java program that implements the Merge Sort algorithm to sort an array of integers in ascending order. The program should test the implementation with various input arrays, including unsorted, already sorted, reversed, and arrays with duplicate elements, and verify that the output is correctly sorted. Merge Sort is a divide-and-conquer algorithm that recursively divides the array into two halves, sorts each half, and merges them into a sorted array. You can visualize this as splitting a deck of cards into smaller piles, sorting each pile, and combining them in order.

Input:

  • An array of integers to be sorted. Output: The sorted array and a string representation of the input and sorted arrays for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [12, 22, 25, 34, 64]
  • Explanation: Merge Sort divides, sorts, and merges to produce a sorted array.
  • Input: array = [1, 2, 3]
  • Output:
    • Input Array: [1, 2, 3]
    • Sorted Array: [1, 2, 3]
  • Explanation: Already sorted array remains unchanged after sorting.

Pseudocode

FUNCTION mergeSort(arr, left, right)
    IF left < right THEN
        SET mid to floor((left + right) / 2)
        CALL mergeSort(arr, left, mid)
        CALL mergeSort(arr, mid + 1, right)
        CALL merge(arr, left, mid, right)
    ENDIF
ENDFUNCTION

FUNCTION merge(arr, left, mid, right)
    SET n1 to mid - left + 1
    SET n2 to right - mid
    CREATE leftArr as array of size n1
    CREATE rightArr as array of size n2
    FOR i from 0 to n1-1
        SET leftArr[i] to arr[left + i]
    ENDFOR
    FOR i from 0 to n2-1
        SET rightArr[i] to arr[mid + 1 + i]
    ENDFOR
    SET i to 0, j to 0, k to left
    WHILE i < n1 AND j < n2
        IF leftArr[i] <= rightArr[j] THEN
            SET arr[k] to leftArr[i]
            INCREMENT i
        ELSE
            SET arr[k] to rightArr[j]
            INCREMENT j
        ENDIF
        INCREMENT k
    ENDWHILE
    WHILE i < n1
        SET arr[k] to leftArr[i]
        INCREMENT i
        INCREMENT k
    ENDWHILE
    WHILE j < n2
        SET arr[k] to rightArr[j]
        INCREMENT j
        INCREMENT k
    ENDWHILE
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        CALL mergeSort(copy, 0, length-1)
        PRINT input array, sorted array
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define mergeSort: a. If left < right, divide array into two halves at mid. b. Recursively sort left half (left to mid) and right half (mid+1 to right). c. Merge the sorted halves using merge.
  2. Define merge: a. Create temporary arrays for left and right halves. b. Compare elements from both halves, merging into the original array in sorted order. c. Copy remaining elements from either half.
  3. Define toString: a. Convert array to a string, e.g., "[64, 34, 25, 12, 22]".
  4. In main, test with: a. An unsorted array. b. An already sorted array. c. A reversed array. d. An array with duplicates. e. An empty array.

Java Implementation

import java.util.*;

public class BasicMergeSort {
    // Performs Merge Sort on the array
    public void mergeSort(int[] arr, int left, int right) {
        if (left < right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            mergeSort(arr, left, mid);
            mergeSort(arr, mid + 1, right);
            merge(arr, left, mid, right);
        }
    }

    // Merges two sorted subarrays
    private void merge(int[] arr, int left, int mid, int right) {
        int n1 = mid - left + 1;
        int n2 = right - mid;
        int[] leftArr = new int[n1];
        int[] rightArr = new int[n2];

        // Copy data to temporary arrays
        for (int i = 0; i < n1; i++) {
            leftArr[i] = arr[left + i];
        }
        for (int i = 0; i < n2; i++) {
            rightArr[i] = arr[mid + 1 + i];
        }

        // Merge the temporary arrays
        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2) {
            if (leftArr[i] <= rightArr[j]) {
                arr[k] = leftArr[i];
                i++;
            } else {
                arr[k] = rightArr[j];
                j++;
            }
            k++;
        }

        // Copy remaining elements
        while (i < n1) {
            arr[k] = leftArr[i];
            i++;
            k++;
        }
        while (j < n2) {
            arr[k] = rightArr[j];
            j++;
            k++;
        }
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i]);
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        String description;

        TestCase(int[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test Merge Sort
    public static void main(String[] args) {
        BasicMergeSort sorter = new BasicMergeSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new int[]{64, 34, 25, 12, 22}, "Unsorted"),
            new TestCase(new int[]{1, 2, 3, 4, 5}, "Sorted"),
            new TestCase(new int[]{5, 4, 3, 2, 1}, "Reversed"),
            new TestCase(new int[]{3, 1, 3, 2, 1}, "Duplicates"),
            new TestCase(new int[]{}, "Empty")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            sorter.mergeSort(arr, 0, arr.length - 1);
            System.out.println("Sorted Array: " + sorter.toString(arr) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Unsorted
Input Array: [64, 34, 25, 12, 22]
Sorted Array: [12, 22, 25, 34, 64]

Test case 2: Sorted
Input Array: [1, 2, 3, 4, 5]
Sorted Array: [1, 2, 3, 4, 5]

Test case 3: Reversed
Input Array: [5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5]

Test case 4: Duplicates
Input Array: [3, 1, 3, 2, 1]
Sorted Array: [1, 1, 2, 3, 3]

Test case 5: Empty
Input Array: []
Sorted Array: []

Explanation:

  • Test case 1: Unsorted array is correctly sorted in ascending order.
  • Test case 2: Already sorted array remains unchanged.
  • Test case 3: Reversed array is sorted into ascending order.
  • Test case 4: Array with duplicates is sorted, preserving duplicates.
  • Test case 5: Empty array remains empty.

How It Works

  • mergeSort:
    • Recursively divides the array into two halves until each subarray has one element.
    • Calls merge to combine sorted subarrays.
  • merge:
    • Creates temporary arrays for left and right halves.
    • Merges elements in sorted order, using <= to ensure stability (preserving order of equal elements).
    • Copies remaining elements from either half.
  • toString: Formats array as a string for output.
  • Example Trace (Test case 1):
    • Divide: [64, 34, 25, 12, 22] → [64, 34, 25] and [12, 22].
    • Divide: [64, 34, 25] → [64] and [34, 25]; [12, 22] → [12] and [22].
    • Divide: [34, 25] → [34] and [25].
    • Merge: [34], [25] → [25, 34].
    • Merge: [64], [25, 34] → [25, 34, 64].
    • Merge: [12], [22] → [12, 22].
    • Merge: [25, 34, 64], [12, 22] → [12, 22, 25, 34, 64].
  • Main Method: Tests unsorted, sorted, reversed, duplicates, and empty arrays, verifying correctness.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
mergeSortO(n log n)O(n)
mergeO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n log n) for mergeSort in all cases (divide: log n levels, merge: O(n) per level); O(n) for toString.
  • Space complexity: O(n) for mergeSort (temporary arrays in merge); O(n) for toString (string builder).
  • Merge Sort is stable and consistent across input types.

✅ Tip: Merge Sort guarantees O(n log n) time complexity for all input cases, making it efficient for large datasets. Use <= in the merge step to ensure stability for duplicates.

⚠ Warning: Allocate sufficient space for temporary arrays in the merge step to avoid index errors. Clone input arrays to preserve the original for display.

Descending Merge Sort

Problem Statement

Write a Java program that modifies the Merge Sort implementation to sort an array of integers in descending order (largest to smallest) by adjusting the comparison logic in the merge step. The program should test the implementation with arrays of different sizes (e.g., 10, 100, 1000) and various contents, including unsorted, already sorted (descending), reversed (ascending), and arrays with duplicate elements, verifying that the output is correctly sorted. Merge Sort will recursively divide the array into two halves, sort each half, and merge them in descending order. You can visualize this as splitting a deck of cards, sorting each pile in reverse order, and combining them to place larger cards first.

Input:

  • An array of integers to be sorted in descending order. Output: The sorted array (in descending order) and a string representation of the input and sorted arrays for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22]
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Sorted Array: [64, 34, 25, 22, 12]
  • Explanation: Merge Sort divides, sorts, and merges to produce a descending order array.
  • Input: array = [5, 4, 3]
  • Output:
    • Input Array: [5, 4, 3]
    • Sorted Array: [5, 4, 3]
  • Explanation: Already sorted in descending order, remains unchanged.

Pseudocode

FUNCTION mergeSortDescending(arr, left, right)
    IF left < right THEN
        SET mid to floor((left + right) / 2)
        CALL mergeSortDescending(arr, left, mid)
        CALL mergeSortDescending(arr, mid + 1, right)
        CALL mergeDescending(arr, left, mid, right)
    ENDIF
ENDFUNCTION

FUNCTION mergeDescending(arr, left, mid, right)
    SET n1 to mid - left + 1
    SET n2 to right - mid
    CREATE leftArr as array of size n1
    CREATE rightArr as array of size n2
    FOR i from 0 to n1-1
        SET leftArr[i] to arr[left + i]
    ENDFOR
    FOR i from 0 to n2-1
        SET rightArr[i] to arr[mid + 1 + i]
    ENDFOR
    SET i to 0, j to 0, k to left
    WHILE i < n1 AND j < n2
        IF leftArr[i] >= rightArr[j] THEN
            SET arr[k] to leftArr[i]
            INCREMENT i
        ELSE
            SET arr[k] to rightArr[j]
            INCREMENT j
        ENDIF
        INCREMENT k
    ENDWHILE
    WHILE i < n1
        SET arr[k] to leftArr[i]
        INCREMENT i
        INCREMENT k
    ENDWHILE
    WHILE j < n2
        SET arr[k] to rightArr[j]
        INCREMENT j
        INCREMENT k
    ENDWHILE
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of input arrays with different sizes
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        CALL mergeSortDescending(copy, 0, length-1)
        PRINT input array, sorted array
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define mergeSortDescending: a. If left < right, divide array into two halves at mid. b. Recursively sort left half (left to mid) and right half (mid+1 to right). c. Merge sorted halves using mergeDescending.
  2. Define mergeDescending: a. Create temporary arrays for left and right halves. b. Compare elements, prioritizing larger ones (leftArr[i] >= rightArr[j]) to merge in descending order. c. Copy remaining elements from either half.
  3. Define toString: a. Convert array to a string, limiting output for large arrays.
  4. In main, test with: a. Small unsorted array (n=10). b. Small sorted array (descending, n=10). c. Small reversed array (ascending, n=10). d. Medium array with duplicates (n=100). e. Large unsorted array (n=1000). f. Empty array (n=0).

Java Implementation

import java.util.*;

public class DescendingMergeSort {
    // Performs Merge Sort in descending order
    public void mergeSortDescending(int[] arr, int left, int right) {
        if (left < right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            mergeSortDescending(arr, left, mid);
            mergeSortDescending(arr, mid + 1, right);
            mergeDescending(arr, left, mid, right);
        }
    }

    // Merges two sorted subarrays in descending order
    private void mergeDescending(int[] arr, int left, int mid, int right) {
        int n1 = mid - left + 1;
        int n2 = right - mid;
        int[] leftArr = new int[n1];
        int[] rightArr = new int[n2];

        // Copy data to temporary arrays
        for (int i = 0; i < n1; i++) {
            leftArr[i] = arr[left + i];
        }
        for (int i = 0; i < n2; i++) {
            rightArr[i] = arr[mid + 1 + i];
        }

        // Merge in descending order
        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2) {
            if (leftArr[i] >= rightArr[j]) {
                arr[k] = leftArr[i];
                i++;
            } else {
                arr[k] = rightArr[j];
                j++;
            }
            k++;
        }

        // Copy remaining elements
        while (i < n1) {
            arr[k] = leftArr[i];
            i++;
            k++;
        }
        while (j < n2) {
            arr[k] = rightArr[j];
            j++;
            k++;
        }
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates random array for testing
    private int[] generateRandomArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2001) - 1000; // [-1000, 1000]
        }
        return arr;
    }

    // Generates sorted array (descending) for testing
    private int[] generateSortedDescending(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Generates array with duplicates
    private int[] generateDuplicatesArray(int n) {
        Random rand = new Random(42);
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(10); // Limited range for duplicates
        }
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        String description;

        TestCase(int[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test descending Merge Sort
    public static void main(String[] args) {
        DescendingMergeSort sorter = new DescendingMergeSort();

        // Test cases
        TestCase[] testCases = {
            new TestCase(sorter.generateRandomArray(10), "Small unsorted (n=10)"),
            new TestCase(sorter.generateSortedDescending(10), "Small sorted descending (n=10)"),
            new TestCase(new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, "Small reversed (ascending, n=10)"),
            new TestCase(sorter.generateDuplicatesArray(100), "Medium with duplicates (n=100)"),
            new TestCase(sorter.generateRandomArray(1000), "Large unsorted (n=1000)"),
            new TestCase(new int[]{}, "Empty (n=0)")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            sorter.mergeSortDescending(arr, 0, arr.length - 1);
            System.out.println("Sorted Array: " + sorter.toString(arr) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Small unsorted (n=10)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628]
Sorted Array: [727, 648, 374, 360, 304, 289, -333, -628, -766, -767]

Test case 2: Small sorted descending (n=10)
Input Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Sorted Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

Test case 3: Small reversed (ascending, n=10)
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

Test case 4: Medium with duplicates (n=100)
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Sorted Array: [9, 9, 9, 9, 8, 8, 8, 8, 8, 8, ...]

Test case 5: Large unsorted (n=1000)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Sorted Array: [976, 966, 964, 960, 958, 955, 953, 952, 951, 946, ...]

Test case 6: Empty (n=0)
Input Array: []
Sorted Array: []

Explanation:

  • Test case 1: Unsorted array (n=10) is sorted in descending order.
  • Test case 2: Already sorted in descending order (n=10), remains unchanged.
  • Test case 3: Ascending array (n=10) is sorted into descending order.
  • Test case 4: Medium array with duplicates (n=100) is sorted, preserving duplicates.
  • Test case 5: Large unsorted array (n=1000) is sorted in descending order.
  • Test case 6: Empty array (n=0) remains empty.

How It Works

  • mergeSortDescending:
    • Recursively divides the array into two halves until each subarray has one element.
    • Calls mergeDescending to combine sorted subarrays in descending order.
  • mergeDescending:
    • Creates temporary arrays for left and right halves.
    • Merges elements, prioritizing larger ones (leftArr[i] >= rightArr[j]), ensuring stability.
    • Copies remaining elements from either half.
  • toString: Formats array, limiting output to 10 elements for large arrays.
  • generateRandomArray: Creates an array with random integers in [-1000, 1000].
  • generateSortedDescending: Creates a descending array [n, n-1, ..., 1].
  • generateDuplicatesArray: Creates an array with values in [0, 9] for duplicates.
  • Example Trace (Test case 1):
    • Divide: [727, -333, 648, 374, -767] → [727, -333, 648] and [374, -767].
    • Divide: [727, -333, 648] → [727] and [-333, 648]; [374, -767] → [374] and [-767].
    • Divide: [-333, 648] → [-333] and [648].
    • Merge: [-333], [648] → [648, -333].
    • Merge: [727], [648, -333] → [727, 648, -333].
    • Merge: [374], [-767] → [374, -767].
    • Merge: [727, 648, -333], [374, -767] → [727, 648, 374, -333, -767].
  • Main Method: Tests small, medium, and large arrays with various contents.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
mergeSortDescendingO(n log n)O(n)
mergeDescendingO(n)O(n)
toStringO(n)O(n)
generateRandomArrayO(n)O(n)
generateSortedDescendingO(n)O(n)
generateDuplicatesArrayO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n log n) for mergeSortDescending in all cases (divide: log n levels, merge: O(n) per level); O(n) for toString and array generation.
  • Space complexity: O(n) for mergeSortDescending (temporary arrays); O(n) for toString and array generation (string builder and arrays).
  • Descending order does not affect Merge Sort’s complexity.

✅ Tip: Modify Merge Sort for descending order by changing the merge comparison to prioritize larger elements (>=). Test with various array sizes to verify correctness across inputs.

⚠ Warning: Use >= in the merge step to ensure stability for duplicates. Clone input arrays to preserve the original order for display.

Merge Sort for Object Sorting

Problem Statement

Write a Java program that extends the Merge Sort implementation to sort an array of objects, specifically Student objects with a grade field, in ascending order based on a custom comparator that compares grades. The program should test the implementation with a sample dataset containing Student objects with varied grades, including duplicates and edge cases like empty arrays, verifying that the output is correctly sorted. Merge Sort will recursively divide the array into two halves, sort each half, and merge them based on the comparator, prioritizing smaller grades for ascending order. You can visualize this as splitting a list of student records into smaller groups, sorting each group by grade, and merging them to maintain the sorted order.

Input:

  • An array of Student objects, each with a name (String) and grade (double) field. Output: The sorted array (by grade in ascending order) and a string representation of the input and sorted arrays for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Grades are doubles in the range [0.0, 100.0]. Example:
  • Input: array = [Student("Alice", 85.5), Student("Bob", 92.0), Student("Charlie", 78.5)]
  • Output:
    • Input Array: [Student(name=Alice, grade=85.5), Student(name=Bob, grade=92.0), Student(name=Charlie, grade=78.5)]
    • Sorted Array: [Student(name=Charlie, grade=78.5), Student(name=Alice, grade=85.5), Student(name=Bob, grade=92.0)]
  • Explanation: Merge Sort divides, sorts, and merges to produce an ascending order array by grade.
  • Input: array = [Student("Eve", 90.0), Student("Eve", 90.0)]
  • Output:
    • Input Array: [Student(name=Eve, grade=90.0), Student(name=Eve, grade=90.0)]
    • Sorted Array: [Student(name=Eve, grade=90.0), Student(name=Eve, grade=90.0)]
  • Explanation: Duplicate grades are preserved in order due to Merge Sort’s stability.

Pseudocode

FUNCTION mergeSort(arr, left, right, comparator)
    IF left < right THEN
        SET mid to floor((left + right) / 2)
        CALL mergeSort(arr, left, mid, comparator)
        CALL mergeSort(arr, mid + 1, right, comparator)
        CALL merge(arr, left, mid, right, comparator)
    ENDIF
ENDFUNCTION

FUNCTION merge(arr, left, mid, right, comparator)
    SET n1 to mid - left + 1
    SET n2 to right - mid
    CREATE leftArr as array of size n1
    CREATE rightArr as array of size n2
    FOR i from 0 to n1-1
        SET leftArr[i] to arr[left + i]
    ENDFOR
    FOR i from 0 to n2-1
        SET rightArr[i] to arr[mid + 1 + i]
    ENDFOR
    SET i to 0, j to 0, k to left
    WHILE i < n1 AND j < n2
        IF comparator.compare(leftArr[i], rightArr[j]) <= 0 THEN
            SET arr[k] to leftArr[i]
            INCREMENT i
        ELSE
            SET arr[k] to rightArr[j]
            INCREMENT j
        ENDIF
        INCREMENT k
    ENDWHILE
    WHILE i < n1
        SET arr[k] to leftArr[i]
        INCREMENT i
        INCREMENT k
    ENDWHILE
    WHILE j < n2
        SET arr[k] to rightArr[j]
        INCREMENT j
        INCREMENT k
    ENDWHILE
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element.toString() to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of Student arrays
    SET gradeComparator to new Comparator comparing Student grades
    FOR each testCase in testCases
        PRINT test case details
        CREATE copy of testCase array
        CALL mergeSort(copy, 0, length-1, gradeComparator)
        PRINT input array, sorted array
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define Student class: a. Fields: name (String), grade (double). b. Include toString for readable output.
  2. Define mergeSort (generic): a. Accept an array of type T, left, right indices, and a Comparator<T>. b. If left < right, divide array at mid, recursively sort halves, and merge using merge.
  3. Define merge (generic): a. Create temporary arrays for left and right halves. b. Use comparator to merge elements in ascending order (comparator.compare(leftArr[i], rightArr[j]) <= 0). c. Copy remaining elements from either half.
  4. Define toString: a. Convert array to a string, e.g., "[Student(name=Alice, grade=85.5), ...]".
  5. In main, test with: a. Mixed grades (n=5). b. Duplicate grades (n=4). c. Empty array (n=0). d. Single-element array (n=1). e. Sorted grades (n=6).

Java Implementation

import java.util.*;

public class MergeSortObjectSorting {
    // Student class
    static class Student {
        String name;
        double grade;

        Student(String name, double grade) {
            this.name = name;
            this.grade = grade;
        }

        @Override
        public String toString() {
            return "Student(name=" + name + ", grade=" + grade + ")";
        }
    }

    // Performs Merge Sort with custom comparator
    public <T> void mergeSort(T[] arr, int left, int right, Comparator<T> comparator) {
        if (left < right) {
            int mid = left + (right - left) / 2;
            mergeSort(arr, left, mid, comparator);
            mergeSort(arr, mid + 1, right, comparator);
            merge(arr, left, mid, right, comparator);
        }
    }

    // Merges two sorted subarrays using comparator
    private <T> void merge(T[] arr, int left, int mid, int right, Comparator<T> comparator) {
        int n1 = mid - left + 1;
        int n2 = right - mid;
        @SuppressWarnings("unchecked")
        T[] leftArr = (T[]) new Object[n1];
        @SuppressWarnings("unchecked")
        T[] rightArr = (T[]) new Object[n2];

        // Copy data to temporary arrays
        for (int i = 0; i < n1; i++) {
            leftArr[i] = arr[left + i];
        }
        for (int i = 0; i < n2; i++) {
            rightArr[i] = arr[mid + 1 + i];
        }

        // Merge in ascending order
        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2) {
            if (comparator.compare(leftArr[i], rightArr[j]) <= 0) {
                arr[k] = leftArr[i];
                i++;
            } else {
                arr[k] = rightArr[j];
                j++;
            }
            k++;
        }

        // Copy remaining elements
        while (i < n1) {
            arr[k] = leftArr[i];
            i++;
            k++;
        }
        while (j < n2) {
            arr[k] = rightArr[j];
            j++;
            k++;
        }
    }

    // Converts array to string
    public <T> String toString(T[] arr) {
        StringBuilder result = new StringBuilder("[");
        for (int i = 0; i < arr.length; i++) {
            result.append(arr[i].toString());
            if (i < arr.length - 1) {
                result.append(", ");
            }
        }
        result.append("]");
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Student[] arr;
        String description;

        TestCase(Student[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test object sorting
    public static void main(String[] args) {
        MergeSortObjectSorting sorter = new MergeSortObjectSorting();

        // Comparator for sorting by grade
        Comparator<Student> gradeComparator = (s1, s2) -> Double.compare(s1.grade, s2.grade);

        // Test cases
        TestCase[] testCases = {
            new TestCase(new Student[]{
                new Student("Alice", 85.5),
                new Student("Bob", 92.0),
                new Student("Charlie", 78.5),
                new Student("David", 95.0),
                new Student("Eve", 88.0)
            }, "Mixed grades (n=5)"),
            new TestCase(new Student[]{
                new Student("Frank", 90.0),
                new Student("Grace", 90.0),
                new Student("Hannah", 90.0),
                new Student("Ivy", 90.0)
            }, "Duplicate grades (n=4)"),
            new TestCase(new Student[]{}, "Empty (n=0)"),
            new TestCase(new Student[]{new Student("Jack", 75.0)}, "Single element (n=1)"),
            new TestCase(new Student[]{
                new Student("Kate", 70.0),
                new Student("Liam", 75.0),
                new Student("Mia", 80.0),
                new Student("Noah", 85.0),
                new Student("Olivia", 90.0),
                new Student("Peter", 95.0)
            }, "Sorted grades (n=6)")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            Student[] arr = testCases[i].arr.clone(); // Copy to preserve original
            System.out.println("Input Array: " + sorter.toString(arr));
            sorter.mergeSort(arr, 0, arr.length - 1, gradeComparator);
            System.out.println("Sorted Array: " + sorter.toString(arr) + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Mixed grades (n=5)
Input Array: [Student(name=Alice, grade=85.5), Student(name=Bob, grade=92.0), Student(name=Charlie, grade=78.5), Student(name=David, grade=95.0), Student(name=Eve, grade=88.0)]
Sorted Array: [Student(name=Charlie, grade=78.5), Student(name=Alice, grade=85.5), Student(name=Eve, grade=88.0), Student(name=Bob, grade=92.0), Student(name=David, grade=95.0)]

Test case 2: Duplicate grades (n=4)
Input Array: [Student(name=Frank, grade=90.0), Student(name=Grace, grade=90.0), Student(name=Hannah, grade=90.0), Student(name=Ivy, grade=90.0)]
Sorted Array: [Student(name=Frank, grade=90.0), Student(name=Grace, grade=90.0), Student(name=Hannah, grade=90.0), Student(name=Ivy, grade=90.0)]

Test case 3: Empty (n=0)
Input Array: []
Sorted Array: []

Test case 4: Single element (n=1)
Input Array: [Student(name=Jack, grade=75.0)]
Sorted Array: [Student(name=Jack, grade=75.0)]

Test case 5: Sorted grades (n=6)
Input Array: [Student(name=Kate, grade=70.0), Student(name=Liam, grade=75.0), Student(name=Mia, grade=80.0), Student(name=Noah, grade=85.0), Student(name=Olivia, grade=90.0), Student(name=Peter, grade=95.0)]
Sorted Array: [Student(name=Kate, grade=70.0), Student(name=Liam, grade=75.0), Student(name=Mia, grade=80.0), Student(name=Noah, grade=85.0), Student(name=Olivia, grade=90.0), Student(name=Peter, grade=95.0)]

Explanation:

  • Test case 1: Mixed grades (n=5) are sorted by grade in ascending order.
  • Test case 2: Duplicate grades (n=4, all 90.0) are sorted, preserving order due to stability.
  • Test case 3: Empty array (n=0) remains empty.
  • Test case 4: Single-element array (n=1) is trivially sorted.
  • Test case 5: Already sorted by grade (n=6) remains unchanged.

How It Works

  • Student Class:
    • Contains name (String) and grade (double).
    • Includes toString for readable output.
  • mergeSort:
    • Uses generics with Comparator<T> to sort any object type.
    • Recursively divides the array and merges using the comparator.
  • merge:
    • Creates temporary arrays for left and right halves.
    • Merges elements based on comparator (comparator.compare(leftArr[i], rightArr[j]) <= 0), ensuring stability.
    • Copies remaining elements.
  • toString: Formats array as a string, handling Student objects.
  • Example Trace (Test case 1):
    • Array: [Alice(85.5), Bob(92.0), Charlie(78.5), David(95.0), Eve(88.0)].
    • Divide: [Alice(85.5), Bob(92.0), Charlie(78.5)] and [David(95.0), Eve(88.0)].
    • Divide: [Alice(85.5)] and [Bob(92.0), Charlie(78.5)]; [David(95.0)] and [Eve(88.0)].
    • Merge: [Bob(92.0), Charlie(78.5)] → [Charlie(78.5), Bob(92.0)].
    • Merge: [Alice(85.5)], [Charlie(78.5), Bob(92.0)] → [Charlie(78.5), Alice(85.5), Bob(92.0)].
    • Merge: [David(95.0)], [Eve(88.0)] → [Eve(88.0), David(95.0)].
    • Merge: [Charlie(78.5), Alice(85.5), Bob(92.0)], [Eve(88.0), David(95.0)] → [Charlie(78.5), Alice(85.5), Eve(88.0), Bob(92.0), David(95.0)].
  • Main Method: Tests mixed grades, duplicates, empty, single-element, and sorted arrays using a grade comparator.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
mergeSortO(n log n)O(n)
mergeO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n log n) for mergeSort in all cases (divide: log n levels, merge: O(n) per level); O(n) for toString.
  • Space complexity: O(n) for mergeSort (temporary arrays); O(n) for toString (string builder).
  • Comparator comparisons depend on the field (e.g., double for grades), but complexity remains O(n log n).

✅ Tip: Use a Comparator to make Merge Sort flexible for sorting objects by any field. Ensure stability by using <= in the merge step to preserve the order of equal elements.

⚠ Warning: Ensure the comparator is consistent to avoid sorting errors. Use @SuppressWarnings("unchecked") for generic array creation in Java, and clone input arrays to preserve the original order for display.

Merge Sort Performance Analysis

Problem Statement

Write a Java program that measures the execution time of the Merge Sort algorithm for sorting arrays of integers in ascending order, testing arrays of increasing sizes (e.g., 10, 100, 1000 elements). Compare its performance with Insertion Sort and Selection Sort across best-case (already sorted), average-case (random elements), and worst-case (reversed order) scenarios, reporting execution times in milliseconds. Additionally, count shifts for Insertion Sort and swaps for Selection Sort to provide further insight into their performance. Merge Sort uses a divide-and-conquer approach, recursively dividing the array into halves, sorting them, and merging, while Insertion Sort inserts elements into a sorted portion, and Selection Sort selects the minimum element in each pass. You can visualize this as timing how long each algorithm takes to organize numbers, comparing their efficiency for different input sizes and orders.

Input:

  • Arrays of integers with sizes 10, 100, and 1000, generated for best (sorted), average (random), and worst (reversed) cases. Output: The execution time (in milliseconds), shifts (for Insertion Sort), and swaps (for Selection Sort) for each algorithm, array size, and case, along with a string representation of the input and sorted arrays for verification. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements are integers in the range [0, 10^6] for random cases.
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: Array size = 10, Cases: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] (best), [5, 2, 8, 1, 9, 3, 7, 4, 6, 10] (average), [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] (worst)
  • Output (example, times vary):
    • Size 10, Best Case:
      • Merge Sort: Time = 0.05 ms
      • Insertion Sort: Time = 0.02 ms, Shifts = 0
      • Selection Sort: Time = 0.03 ms, Swaps = 0
    • Size 10, Average Case:
      • Merge Sort: Time = 0.05 ms
      • Insertion Sort: Time = 0.03 ms, Shifts = 4
      • Selection Sort: Time = 0.04 ms, Swaps = 4
  • Explanation: Merge Sort maintains consistent O(n log n) time, while Insertion Sort excels in the best case, and Selection Sort has fewer swaps but consistent O(n²) time.

Pseudocode

FUNCTION mergeSort(arr, left, right)
    IF left < right THEN
        SET mid to floor((left + right) / 2)
        CALL mergeSort(arr, left, mid)
        CALL mergeSort(arr, mid + 1, right)
        CALL merge(arr, left, mid, right)
    ENDIF
ENDFUNCTION

FUNCTION merge(arr, left, mid, right)
    SET n1 to mid - left + 1
    SET n2 to right - mid
    CREATE leftArr as array of size n1
    CREATE rightArr as array of size n2
    FOR i from 0 to n1-1
        SET leftArr[i] to arr[left + i]
    ENDFOR
    FOR i from 0 to n2-1
        SET rightArr[i] to arr[mid + 1 + i]
    ENDFOR
    SET i to 0, j to 0, k to left
    WHILE i < n1 AND j < n2
        IF leftArr[i] <= rightArr[j] THEN
            SET arr[k] to leftArr[i]
            INCREMENT i
        ELSE
            SET arr[k] to rightArr[j]
            INCREMENT j
        ENDIF
        INCREMENT k
    ENDWHILE
    WHILE i < n1
        SET arr[k] to leftArr[i]
        INCREMENT i
        INCREMENT k
    ENDWHILE
    WHILE j < n2
        SET arr[k] to rightArr[j]
        INCREMENT j
        INCREMENT k
    ENDWHILE
ENDFUNCTION

FUNCTION insertionSort(arr)
    SET n to length of arr
    CREATE shifts as integer, initialized to 0
    FOR i from 1 to n-1
        SET key to arr[i]
        SET j to i - 1
        WHILE j >= 0 AND arr[j] > key
            SET arr[j + 1] to arr[j]
            INCREMENT shifts
            DECREMENT j
        ENDWHILE
        SET arr[j + 1] to key
    ENDFOR
    RETURN shifts
ENDFUNCTION

FUNCTION selectionSort(arr)
    SET n to length of arr
    CREATE swaps as integer, initialized to 0
    FOR i from 0 to n-1
        SET minIdx to i
        FOR j from i+1 to n-1
            IF arr[j] < arr[minIdx] THEN
                SET minIdx to j
            ENDIF
        ENDFOR
        IF minIdx != i THEN
            SET temp to arr[i]
            SET arr[i] to arr[minIdx]
            SET arr[minIdx] to temp
            INCREMENT swaps
        ENDIF
    ENDFOR
    RETURN swaps
ENDFUNCTION

FUNCTION generateBestCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateAverageCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10^6]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateWorstCase(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to n - i
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET bestArr to generateBestCase(size)
        SET avgArr to generateAverageCase(size)
        SET worstArr to generateWorstCase(size)
        FOR each case (bestArr, avgArr, worstArr)
            FOR each algorithm (mergeSort, insertionSort, selectionSort)
                SET totalTime to 0
                SET totalOperations to 0
                FOR i from 0 to runs-1
                    CREATE copy of case array
                    SET startTime to current nano time
                    IF algorithm is mergeSort THEN
                        CALL mergeSort(copy, 0, length-1)
                    ELSE IF algorithm is insertionSort THEN
                        SET operations to insertionSort(copy)
                    ELSE
                        SET operations to selectionSort(copy)
                    ENDIF
                    SET endTime to current nano time
                    ADD (endTime - startTime) to totalTime
                    ADD operations to totalOperations
                ENDFOR
                PRINT algorithm, case details, input array, sorted array
                PRINT average time in milliseconds and average operations
            ENDFOR
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Reuse mergeSort and merge: a. Divide array into halves recursively until single elements. b. Merge sorted halves using <= for ascending order.
  2. Reuse insertionSort: a. Insert elements into sorted portion, shifting larger elements, counting shifts.
  3. Reuse selectionSort: a. Select minimum element in each pass, swap if needed, counting swaps.
  4. Define generateBestCase: a. Create sorted array [1, 2, ..., n].
  5. Define generateAverageCase: a. Create array with random integers in [0, 10^6].
  6. Define generateWorstCase: a. Create reversed array [n, n-1, ..., 1].
  7. Define toString: a. Convert array to a string, limiting output for large arrays.
  8. In main, test with: a. Array sizes: 10, 100, 1000. b. Cases: best (sorted), average (random), worst (reversed). c. Run each case 10 times for each algorithm, average times and operations. d. Measure time using System.nanoTime(), convert to milliseconds.

Java Implementation

import java.util.*;

public class MergeSortPerformanceAnalysis {
    // Performs Merge Sort
    public void mergeSort(int[] arr, int left, int right) {
        if (left < right) {
            int mid = left + (right - left) / 2;
            mergeSort(arr, left, mid);
            mergeSort(arr, mid + 1, right);
            merge(arr, left, mid, right);
        }
    }

    private void merge(int[] arr, int left, int mid, int right) {
        int n1 = mid - left + 1;
        int n2 = right - mid;
        int[] leftArr = new int[n1];
        int[] rightArr = new int[n2];

        for (int i = 0; i < n1; i++) {
            leftArr[i] = arr[left + i];
        }
        for (int i = 0; i < n2; i++) {
            rightArr[i] = arr[mid + 1 + i];
        }

        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2) {
            if (leftArr[i] <= rightArr[j]) {
                arr[k] = leftArr[i];
                i++;
            } else {
                arr[k] = rightArr[j];
                j++;
            }
            k++;
        }

        while (i < n1) {
            arr[k] = leftArr[i];
            i++;
            k++;
        }
        while (j < n2) {
            arr[k] = rightArr[j];
            j++;
            k++;
        }
    }

    // Performs Insertion Sort and counts shifts
    public int insertionSort(int[] arr) {
        int n = arr.length;
        int shifts = 0;
        for (int i = 1; i < n; i++) {
            int key = arr[i];
            int j = i - 1;
            while (j >= 0 && arr[j] > key) {
                arr[j + 1] = arr[j];
                shifts++;
                j--;
            }
            arr[j + 1] = key;
        }
        return shifts;
    }

    // Performs Selection Sort and counts swaps
    public int selectionSort(int[] arr) {
        int n = arr.length;
        int swaps = 0;
        for (int i = 0; i < n; i++) {
            int minIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j] < arr[minIdx]) {
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                int temp = arr[i];
                arr[i] = arr[minIdx];
                arr[minIdx] = temp;
                swaps++;
            }
        }
        return swaps;
    }

    // Generates best-case array (sorted)
    private int[] generateBestCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        return arr;
    }

    // Generates average-case array (random)
    private int[] generateAverageCase(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(1000001); // [0, 10^6]
        }
        return arr;
    }

    // Generates worst-case array (reversed)
    private int[] generateWorstCase(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int size;
        String type;
        int[] arr;

        TestCase(int size, String type, int[] arr) {
            this.size = size;
            this.type = type;
            this.arr = arr;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        MergeSortPerformanceAnalysis sorter = new MergeSortPerformanceAnalysis();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            TestCase[] cases = {
                new TestCase(size, "Best Case", sorter.generateBestCase(size)),
                new TestCase(size, "Average Case", sorter.generateAverageCase(size)),
                new TestCase(size, "Worst Case", sorter.generateWorstCase(size))
            };

            for (TestCase testCase : cases) {
                System.out.println(testCase.type + ":");
                System.out.println("Input Array: " + sorter.toString(testCase.arr));
                int[] sorted = testCase.arr.clone();
                sorter.mergeSort(sorted, 0, sorted.length - 1); // For display
                System.out.println("Sorted Array: " + sorter.toString(sorted));

                // Test Merge Sort
                long totalTime = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    sorter.mergeSort(arr, 0, arr.length - 1);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                }
                double avgTimeMs = totalTime / (double) runs / 1_000_000.0; // Convert to ms
                System.out.printf("Merge Sort - Average Time: %.2f ms\n", avgTimeMs);

                // Test Insertion Sort
                totalTime = 0;
                long totalShifts = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    int shifts = sorter.insertionSort(arr);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalShifts += shifts;
                }
                avgTimeMs = totalTime / (double) runs / 1_000_000.0;
                double avgShifts = totalShifts / (double) runs;
                System.out.printf("Insertion Sort - Average Time: %.2f ms, Average Shifts: %.0f\n", avgTimeMs, avgShifts);

                // Test Selection Sort
                totalTime = 0;
                long totalSwaps = 0;
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    int swaps = sorter.selectionSort(arr);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalSwaps += swaps;
                }
                avgTimeMs = totalTime / (double) runs / 1_000_000.0;
                double avgSwaps = totalSwaps / (double) runs;
                System.out.printf("Selection Sort - Average Time: %.2f ms, Average Swaps: %.0f\n\n", avgTimeMs, avgSwaps);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Merge Sort - Average Time: 0.05 ms
Insertion Sort - Average Time: 0.02 ms, Average Shifts: 0
Selection Sort - Average Time: 0.03 ms, Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178]
Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
Merge Sort - Average Time: 0.05 ms
Insertion Sort - Average Time: 0.03 ms, Average Shifts: 4
Selection Sort - Average Time: 0.04 ms, Average Swaps: 4

Worst Case:
Input Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Merge Sort - Average Time: 0.05 ms
Insertion Sort - Average Time: 0.04 ms, Average Shifts: 45
Selection Sort - Average Time: 0.04 ms, Average Swaps: 4

Array Size: 100
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Merge Sort - Average Time: 0.30 ms
Insertion Sort - Average Time: 0.10 ms, Average Shifts: 0
Selection Sort - Average Time: 0.25 ms, Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Merge Sort - Average Time: 0.35 ms
Insertion Sort - Average Time: 0.20 ms, Average Shifts: 2450
Selection Sort - Average Time: 0.30 ms, Average Swaps: 48

Worst Case:
Input Array: [100, 99, 98, 97, 96, 95, 94, 93, 92, 91, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Merge Sort - Average Time: 0.35 ms
Insertion Sort - Average Time: 0.25 ms, Average Shifts: 4950
Selection Sort - Average Time: 0.30 ms, Average Swaps: 49

Array Size: 1000
Best Case:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Merge Sort - Average Time: 2.50 ms
Insertion Sort - Average Time: 1.00 ms, Average Shifts: 0
Selection Sort - Average Time: 2.50 ms, Average Swaps: 0

Average Case:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Merge Sort - Average Time: 2.60 ms
Insertion Sort - Average Time: 2.20 ms, Average Shifts: 249500
Selection Sort - Average Time: 3.00 ms, Average Swaps: 496

Worst Case:
Input Array: [1000, 999, 998, 997, 996, 995, 994, 993, 992, 991, ...]
Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Merge Sort - Average Time: 2.60 ms
Insertion Sort - Average Time: 2.50 ms, Average Shifts: 499500
Selection Sort - Average Time: 3.00 ms, Average Swaps: 499

Explanation:

  • Size 10: Insertion Sort is fastest in best case (0 shifts), Merge Sort has consistent O(n log n) time, Selection Sort has fewer swaps but similar times.
  • Size 100: Insertion Sort outperforms in best case; Merge Sort is faster in average/worst cases; Selection Sort has fewer swaps but O(n²) comparisons.
  • Size 1000: Merge Sort is consistently faster due to O(n log n); Insertion Sort slows significantly in average/worst cases; Selection Sort is slowest.
  • Times are averaged over 10 runs; Merge Sort’s consistency contrasts with Insertion Sort’s best-case efficiency and Selection Sort’s fewer swaps.

How It Works

  • mergeSort: Divides array recursively, sorts halves, and merges using <= for ascending order (reused from BasicMergeSort.md).
  • insertionSort: Inserts elements into sorted portion, counting shifts (reused from BasicInsertionSort.md).
  • selectionSort: Finds minimum in each pass, counting swaps (reused from BasicSelectionSort.md).
  • generateBestCase: Creates sorted array [1, 2, ..., n].
  • generateAverageCase: Creates random array with fixed seed.
  • generateWorstCase: Creates reversed array [n, n-1, ..., 1].
  • toString: Formats array, limiting output to 10 elements.
  • Main Method:
    • Tests sizes 10, 100, 1000.
    • Runs best, average, worst cases 10 times for each algorithm.
    • Measures time with System.nanoTime(), converts to milliseconds.
    • Reports average time and shifts/swaps.
  • Example Trace (Size 10, Worst Case, Merge Sort):
    • Array: [10, 9, ..., 1].
    • Divide: [10, 9, 8, 7, 6] and [5, 4, 3, 2, 1].
    • Merge: Produces [1, 2, ..., 10].
    • Time measured per run, averaged over 10 runs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
mergeSortO(n log n)O(n)
insertionSortO(n²) worst, O(n) bestO(1)
selectionSortO(n²)O(1)
generateBestCaseO(n)O(n)
generateAverageCaseO(n)O(n)
generateWorstCaseO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n log n) for mergeSort in all cases; O(n²) for insertionSort/selectionSort in worst/average cases, O(n) for insertionSort in best case; O(n) for array generation and toString.
  • Space complexity: O(n) for mergeSort (temporary arrays); O(1) for insertionSort/selectionSort (in-place); O(n) for array generation and toString.
  • Merge Sort is efficient for large arrays; Insertion Sort excels in best case; Selection Sort minimizes swaps.

✅ Tip: Merge Sort’s O(n log n) time complexity makes it ideal for large datasets, unlike O(n²) algorithms like Insertion and Selection Sort. Use multiple runs to average out timing variability.

⚠ Warning: Insertion Sort is sensitive to input order, performing poorly on large unsorted arrays. Limit output for large arrays to avoid overwhelming console logs.

Merge Sort Space Optimization

Problem Statement

Write a Java program that implements an in-place Merge Sort variant (if feasible) for sorting an array of integers in ascending order, aiming to reduce auxiliary space usage compared to the standard Merge Sort implementation. Compare the performance (execution time in milliseconds) and space usage of the in-place variant with the standard Merge Sort across various input arrays, including unsorted, already sorted, reversed, and arrays with duplicates, for different sizes (e.g., 10, 100, 1000). Verify that the output is correctly sorted. Standard Merge Sort uses O(n) auxiliary space for temporary arrays during merging, while an in-place variant attempts to minimize this, though true O(1) space may compromise stability or efficiency. You can visualize this as sorting a deck of cards by dividing and merging piles, trying to reuse the original deck space instead of creating new piles.

Input:

  • Arrays of integers with sizes 10, 100, and 1000, generated as unsorted (random), sorted, reversed, and with duplicates. Output: The sorted array, execution time (in milliseconds), and estimated auxiliary space usage (in terms of array elements) for both the in-place variant and standard Merge Sort for each test case, along with a string representation of the input and sorted arrays for verification. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements are integers in the range [0, 10^6] for random cases.
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: array = [64, 34, 25, 12, 22], size = 5
  • Output (example, times vary):
    • Input Array: [64, 34, 25, 12, 22]
    • Standard Merge Sort: Sorted Array: [12, 22, 25, 34, 64], Time: 0.05 ms, Space: 5 elements
    • In-Place Merge Sort: Sorted Array: [12, 22, 25, 34, 64], Time: 0.06 ms, Space: 3 elements
  • Explanation: Both algorithms sort correctly; in-place variant attempts to reduce auxiliary space but may increase time.
  • Input: array = [1, 1, 1], size = 3
  • Output:
    • Input Array: [1, 1, 1]
    • Standard Merge Sort: Sorted Array: [1, 1, 1], Time: 0.03 ms, Space: 3 elements
    • In-Place Merge Sort: Sorted Array: [1, 1, 1], Time: 0.04 ms, Space: 2 elements
  • Explanation: Duplicates are handled correctly; space savings are minimal for small arrays.

Pseudocode

FUNCTION standardMergeSort(arr, left, right)
    IF left < right THEN
        SET mid to floor((left + right) / 2)
        CALL standardMergeSort(arr, left, mid)
        CALL standardMergeSort(arr, mid + 1, right)
        CALL standardMerge(arr, left, mid, right)
    ENDIF
ENDFUNCTION

FUNCTION standardMerge(arr, left, mid, right)
    SET n1 to mid - left + 1
    SET n2 to right - mid
    CREATE leftArr as array of size n1
    CREATE rightArr as array of size n2
    FOR i from 0 to n1-1
        SET leftArr[i] to arr[left + i]
    ENDFOR
    FOR i from 0 to n2-1
        SET rightArr[i] to arr[mid + 1 + i]
    ENDFOR
    SET i to 0, j to 0, k to left
    WHILE i < n1 AND j < n2
        IF leftArr[i] <= rightArr[j] THEN
            SET arr[k] to leftArr[i]
            INCREMENT i
        ELSE
            SET arr[k] to rightArr[j]
            INCREMENT j
        ENDIF
        INCREMENT k
    ENDWHILE
    WHILE i < n1
        SET arr[k] to leftArr[i]
        INCREMENT i
        INCREMENT k
    ENDWHILE
    WHILE j < n2
        SET arr[k] to rightArr[j]
        INCREMENT j
        INCREMENT k
    ENDWHILE
ENDFUNCTION

FUNCTION inPlaceMergeSort(arr, left, right)
    IF left < right THEN
        SET mid to floor((left + right) / 2)
        CALL inPlaceMergeSort(arr, left, mid)
        CALL inPlaceMergeSort(arr, mid + 1, right)
        CALL inPlaceMerge(arr, left, mid, right)
    ENDIF
ENDFUNCTION

FUNCTION inPlaceMerge(arr, left, mid, right)
    SET i to left
    SET j to mid + 1
    WHILE i <= mid AND j <= right
        IF arr[i] <= arr[j] THEN
            INCREMENT i
        ELSE
            SET key to arr[j]
            FOR k from j to i+1 step -1
                SET arr[k] to arr[k-1]
            ENDFOR
            SET arr[i] to key
            INCREMENT i
            INCREMENT mid
            INCREMENT j
        ENDIF
    ENDWHILE
ENDFUNCTION

FUNCTION generateUnsorted(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10^6]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateSorted(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to i + 1
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateReversed(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to n - i
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION generateDuplicates(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [0, 10]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET testCases to array of input arrays (unsorted, sorted, reversed, duplicates)
        FOR each testCase in testCases
            PRINT test case details
            CREATE copy1, copy2 of testCase array
            SET totalTimeStandard to 0
            SET totalSpaceStandard to 0
            SET totalTimeInPlace to 0
            SET totalSpaceInPlace to 0
            FOR i from 0 to runs-1
                SET copyStandard to copy1.clone()
                SET copyInPlace to copy2.clone()
                SET startTime to current nano time
                CALL standardMergeSort(copyStandard, 0, length-1)
                SET endTime to current nano time
                ADD (endTime - startTime) to totalTimeStandard
                ADD size to totalSpaceStandard
                SET startTime to current nano time
                CALL inPlaceMergeSort(copyInPlace, 0, length-1)
                SET endTime to current nano time
                ADD (endTime - startTime) to totalTimeInPlace
                ADD ceiling(size/2) to totalSpaceInPlace
            ENDFOR
            PRINT input array, sorted arrays
            PRINT average time and space for standard and in-place Merge Sort
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Implement standardMergeSort and standardMerge: a. Divide array recursively into halves until single elements. b. Merge using temporary arrays of size n1 and n2, total O(n) space per merge level.
  2. Implement inPlaceMergeSort and inPlaceMerge: a. Divide recursively, similar to standard Merge Sort. b. In inPlaceMerge, shift elements within the array to insert elements from the right subarray into the correct position in the left subarray, minimizing temporary space to O(1) per merge but increasing time complexity.
  3. Define array generation functions: a. generateUnsorted: Random integers in [0, 10^6]. b. generateSorted: Sorted array [1, 2, ..., n]. c. generateReversed: Reversed array [n, n-1, ..., 1]. d. generateDuplicates: Array with values in [0, 10] for duplicates.
  4. Define toString: a. Convert array to a string, limiting output for large arrays.
  5. In main, test with: a. Array sizes: 10, 100, 1000. b. Cases: unsorted, sorted, reversed, duplicates. c. Run each case 10 times for both algorithms, averaging times. d. Measure time with System.nanoTime() and estimate space usage (elements allocated).

Java Implementation

import java.util.*;

public class MergeSortSpaceOptimization {
    // Standard Merge Sort
    public void standardMergeSort(int[] arr, int left, int right) {
        if (left < right) {
            int mid = left + (right - left) / 2;
            standardMergeSort(arr, left, mid);
            standardMergeSort(arr, mid + 1, right);
            standardMerge(arr, left, mid, right);
        }
    }

    private void standardMerge(int[] arr, int left, int mid, int right) {
        int n1 = mid - left + 1;
        int n2 = right - mid;
        int[] leftArr = new int[n1];
        int[] rightArr = new int[n2];

        for (int i = 0; i < n1; i++) {
            leftArr[i] = arr[left + i];
        }
        for (int i = 0; i < n2; i++) {
            rightArr[i] = arr[mid + 1 + i];
        }

        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2) {
            if (leftArr[i] <= rightArr[j]) {
                arr[k] = leftArr[i];
                i++;
            } else {
                arr[k] = rightArr[j];
                j++;
            }
            k++;
        }

        while (i < n1) {
            arr[k] = leftArr[i];
            i++;
            k++;
        }
        while (j < n2) {
            arr[k] = rightArr[j];
            j++;
            k++;
        }
    }

    // In-Place Merge Sort Variant
    public void inPlaceMergeSort(int[] arr, int left, int right) {
        if (left < right) {
            int mid = left + (right - left) / 2;
            inPlaceMergeSort(arr, left, mid);
            inPlaceMergeSort(arr, mid + 1, right);
            inPlaceMerge(arr, left, mid, right);
        }
    }

    private void inPlaceMerge(int[] arr, int left, int mid, int right) {
        int i = left;
        int j = mid + 1;
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) {
                i++;
            } else {
                int key = arr[j];
                for (int k = j; k > i; k--) {
                    arr[k] = arr[k - 1];
                }
                arr[i] = key;
                i++;
                mid++;
                j++;
            }
        }
    }

    // Generates random array
    private int[] generateUnsorted(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(1000001); // [0, 10^6]
        }
        return arr;
    }

    // Generates sorted array
    private int[] generateSorted(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = i + 1;
        }
        return arr;
    }

    // Generates reversed array
    private int[] generateReversed(int n) {
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = n - i;
        }
        return arr;
    }

    // Generates array with duplicates
    private int[] generateDuplicates(int n) {
        Random rand = new Random(42);
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(11); // [0, 10] for duplicates
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        String description;

        TestCase(int[] arr, String description) {
            this.arr = arr;
            this.description = description;
        }
    }

    // Main method to test space optimization
    public static void main(String[] args) {
        MergeSortSpaceOptimization sorter = new MergeSortSpaceOptimization();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            TestCase[] cases = {
                new TestCase(sorter.generateUnsorted(size), "Unsorted"),
                new TestCase(sorter.generateSorted(size), "Sorted"),
                new TestCase(sorter.generateReversed(size), "Reversed"),
                new TestCase(sorter.generateDuplicates(size), "Duplicates")
            };

            for (TestCase testCase : cases) {
                System.out.println(testCase.description + ":");
                System.out.println("Input Array: " + sorter.toString(testCase.arr));

                // Standard Merge Sort
                long totalTimeStandard = 0;
                long totalSpaceStandard = 0;
                int[] sortedStandard = testCase.arr.clone();
                sorter.standardMergeSort(sortedStandard, 0, sortedStandard.length - 1);
                System.out.println("Standard Sorted Array: " + sorter.toString(sortedStandard));
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    sorter.standardMergeSort(arr, 0, arr.length - 1);
                    long endTime = System.nanoTime();
                    totalTimeStandard += (endTime - startTime);
                    totalSpaceStandard += arr.length; // Approximate max space
                }
                double avgTimeStandardMs = totalTimeStandard / (double) runs / 1_000_000.0;
                double avgSpaceStandard = totalSpaceStandard / (double) runs;

                // In-Place Merge Sort
                long totalTimeInPlace = 0;
                long totalSpaceInPlace = 0;
                int[] sortedInPlace = testCase.arr.clone();
                sorter.inPlaceMergeSort(sortedInPlace, 0, sortedInPlace.length - 1);
                System.out.println("In-Place Sorted Array: " + sorter.toString(sortedInPlace));
                for (int i = 0; i < runs; i++) {
                    int[] arr = testCase.arr.clone();
                    long startTime = System.nanoTime();
                    sorter.inPlaceMergeSort(arr, 0, arr.length - 1);
                    long endTime = System.nanoTime();
                    totalTimeInPlace += (endTime - startTime);
                    totalSpaceInPlace += (arr.length + 1) / 2; // Approximate max space
                }
                double avgTimeInPlaceMs = totalTimeInPlace / (double) runs / 1_000_000.0;
                double avgSpaceInPlace = totalSpaceInPlace / (double) runs;

                System.out.printf("Standard Merge Sort - Avg Time: %.2f ms, Avg Space: %.0f elements\n", avgTimeStandardMs, avgSpaceStandard);
                System.out.printf("In-Place Merge Sort - Avg Time: %.2f ms, Avg Space: %.0f elements\n\n", avgTimeInPlaceMs, avgSpaceInPlace);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178]
Standard Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
In-Place Sorted Array: [333, 360, 289796, 304135, 374316, 628178, 648054, 727595, 766336, 767890]
Standard Merge Sort - Avg Time: 0.05 ms, Avg Space: 10 elements
In-Place Merge Sort - Avg Time: 0.07 ms, Avg Space: 5 elements

Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Standard Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
In-Place Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Standard Merge Sort - Avg Time: 0.04 ms, Avg Space: 10 elements
In-Place Merge Sort - Avg Time: 0.06 ms, Avg Space: 5 elements

Reversed:
Input Array: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Standard Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
In-Place Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Standard Merge Sort - Avg Time: 0.05 ms, Avg Space: 10 elements
In-Place Merge Sort - Avg Time: 0.07 ms, Avg Space: 5 elements

Duplicates:
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7]
Standard Sorted Array: [3, 4, 4, 6, 6, 6, 7, 7, 8, 9]
In-Place Sorted Array: [3, 4, 4, 6, 6, 6, 7, 7, 8, 9]
Standard Merge Sort - Avg Time: 0.05 ms, Avg Space: 10 elements
In-Place Merge Sort - Avg Time: 0.07 ms, Avg Space: 5 elements

Array Size: 100
Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Standard Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
In-Place Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Standard Merge Sort - Avg Time: 0.35 ms, Avg Space: 100 elements
In-Place Merge Sort - Avg Time: 0.50 ms, Avg Space: 50 elements

Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Standard Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
In-Place Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Standard Merge Sort - Avg Time: 0.30 ms, Avg Space: 100 elements
In-Place Merge Sort - Avg Time: 0.45 ms, Avg Space: 50 elements

Reversed:
Input Array: [100, 99, 98, 97, 96, 95, 94, 93, 92, 91, ...]
Standard Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
In-Place Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Standard Merge Sort - Avg Time: 0.35 ms, Avg Space: 100 elements
In-Place Merge Sort - Avg Time: 0.50 ms, Avg Space: 50 elements

Duplicates:
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Standard Sorted Array: [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, ...]
In-Place Sorted Array: [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, ...]
Standard Merge Sort - Avg Time: 0.35 ms, Avg Space: 100 elements
In-Place Merge Sort - Avg Time: 0.50 ms, Avg Space: 50 elements

Array Size: 1000
Unsorted:
Input Array: [727595, 333, 648054, 374316, 767890, 360, 766336, 304135, 289796, 628178, ...]
Standard Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
In-Place Sorted Array: [90, 333, 360, 1350, 2734, 3965, 6618, 10422, 13764, 16008, ...]
Standard Merge Sort - Avg Time: 2.60 ms, Avg Space: 1000 elements
In-Place Merge Sort - Avg Time: 4.00 ms, Avg Space: 500 elements

Sorted:
Input Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Standard Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
In-Place Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Standard Merge Sort - Avg Time: 2.50 ms, Avg Space: 1000 elements
In-Place Merge Sort - Avg Time: 3.80 ms, Avg Space: 500 elements

Reversed:
Input Array: [1000, 999, 998, 997, 996, 995, 994, 993, 992, 991, ...]
Standard Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
In-Place Sorted Array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
Standard Merge Sort - Avg Time: 2.60 ms, Avg Space: 1000 elements
In-Place Merge Sort - Avg Time: 4.00 ms, Avg Space: 500 elements

Duplicates:
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Standard Sorted Array: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
In-Place Sorted Array: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
Standard Merge Sort - Avg Time: 2.60 ms, Avg Space: 1000 elements
In-Place Merge Sort - Avg Time: 4.00 ms, Avg Space: 500 elements

Explanation:

  • Size 10: Standard Merge Sort is faster (O(n log n)) and uses O(n) space; in-place variant is slower due to shifts but reduces space to ~n/2 elements.
  • Size 100: In-place variant shows noticeable time overhead; space savings are proportional to array size.
  • Size 1000: Standard Merge Sort outperforms significantly; in-place variant halves space but increases time due to O(n²) merge operations.
  • Both algorithms correctly sort all inputs, including duplicates, but in-place variant sacrifices efficiency.

How It Works

  • standardMergeSort:
    • Recursively divides the array into halves until single elements.
    • Merges using temporary arrays (O(n) space per level), ensuring stability with <=.
  • inPlaceMergeSort:
    • Divides recursively like standard Merge Sort.
    • In inPlaceMerge, shifts elements within the array to insert right subarray elements into the left subarray, avoiding large temporary arrays but increasing time complexity to O(n²) per merge due to shifts.
    • Space is reduced to O(1) per merge, but recursion stack uses O(log n) space.
  • generateUnsorted/Sorted/Reversed/Duplicates: Creates test arrays for various cases.
  • toString: Formats array, limiting output to 10 elements.
  • Example Trace (Unsorted, n=5, [64, 34, 25, 12, 22]):
    • Standard:
      • Divide: [64, 34, 25] and [12, 22].
      • Merge: [64, 34, 25] → [25, 34, 64]; [12, 22] → [12, 22].
      • Merge: [25, 34, 64], [12, 22] → [12, 22, 25, 34, 64] (uses temp arrays).
    • In-Place:
      • Divide similarly.
      • Merge: [64, 34, 25, 12, 22], for i=0, j=3, key=12 < 64, shift [64, 34, 25] right, insert 12: [12, 64, 34, 25, 22].
      • Continue merging, shifting elements as needed.
  • Main Method: Tests both algorithms across sizes and cases, averaging times and estimating space (standard: O(n), in-place: ~O(n/2) for largest merge).

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
standardMergeSortO(n log n)O(n)
inPlaceMergeSortO(n² log n) worstO(log n) recursion
standardMergeO(n)O(n)
inPlaceMergeO(n²) worstO(1)
generateUnsortedO(n)O(n)
generateSortedO(n)O(n)
generateReversedO(n)O(n)
generateDuplicatesO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n log n) for standardMergeSort; O(n² log n) for inPlaceMergeSort due to O(n²) merges; O(n) for array generation and toString.
  • Space complexity: O(n) for standardMergeSort (temporary arrays); O(log n) for inPlaceMergeSort (recursion stack, O(1) per merge); O(n) for array generation and toString.
  • In-place variant reduces auxiliary space but increases time due to shifting.

✅ Tip: In-place Merge Sort variants reduce auxiliary space but often increase time complexity. Use standard Merge Sort for stable, efficient sorting unless memory is a critical constraint.

⚠ Warning: The in-place variant sacrifices stability and efficiency (O(n² log n) time) due to in-array shifts. Test thoroughly with duplicates to ensure correctness, and note that true O(1) space stable Merge Sort is generally infeasible.

Searching Algorithms

🛠 What you will learn

This section outlines the format used for each DSA problem statement, designed to guide students through solving exercise problems in a clear, structured learning process. Each component serves a specific purpose to enhance understanding and practical application:

  • Problem Statement: Clearly defines the task, including inputs, outputs, constraints, and an example. A real-world analogy makes the problem relatable, aligning with the engaging tone of the "Core Data Structures" summary, ensuring students grasp the problem’s context.
  • Pseudocode: Provides a high-level, language-agnostic outline of the solution using standardized syntax (e.g., FUNCTION, IF, SET, RETURN). This helps students understand the logic before diving into code, bridging theory and implementation.
  • Algorithm Steps: Breaks down the pseudocode into detailed, actionable steps, connecting theoretical logic to practical coding. This ensures students can follow the implementation process systematically.
  • Java Implementation: Offers a complete, commented Java code solution that students can study and run. It includes a main method with at least four test cases (e.g., target present, absent, middle element, and edge cases like duplicates or single elements) to verify correctness and explore different scenarios, maintaining a beginner-friendly approach.
  • Output: Shows the expected results from the test cases, including indices, comparisons, and execution times (where applicable), helping students verify their code’s correctness and understand the algorithm’s behavior.
  • How It Works: Traces the algorithm’s execution with a detailed example, showing step-by-step how the search narrows down the range. This reinforces understanding by illustrating the algorithm in action.
  • Complexity Analysis Table: Summarizes time and space complexity for best, average, and worst cases, teaching students to evaluate efficiency and compare trade-offs across implementations.
  • Tip or Warning Box: Provides practical advice (e.g., when to use DSA) and highlights pitfalls (e.g., ensuring the array is sorted), guiding students toward best practices and common error avoidance.

Each exercise includes test cases to verify correctness and performance, aligning with a learning platform featuring embedded compilers for hands-on practice and visualizations to illustrate the halving process of DSA.

Basic Linear Search

Problem Statement

Write a Java program that implements the Linear Search algorithm to find a target integer in an array of integers. The program should test the implementation with arrays of different sizes (e.g., 10, 100, 1000) and various target values, including cases where the target is present, absent, or the first element, and count the number of comparisons made during each search. Linear Search sequentially checks each element in the array until the target is found or the array is fully traversed. You can visualize this as searching through a list of numbers one by one until you find the desired value or reach the end.

Input:

  • An array of integers and a target integer to find. Output: The index of the target (or -1 if not found), the number of comparisons made, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements and target are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [64, 34, 25, 12, 22], target = 25
  • Output:
    • Input Array: [64, 34, 25, 12, 22]
    • Target: 25
    • Index: 2
    • Comparisons: 3
  • Explanation: Linear Search checks elements at indices 0, 1, and 2, finding 25 after 3 comparisons.
  • Input: array = [1, 2, 3], target = 4
  • Output:
    • Input Array: [1, 2, 3]
    • Target: 4
    • Index: -1
    • Comparisons: 3
  • Explanation: Linear Search checks all elements and returns -1 as 4 is not found.

Pseudocode

FUNCTION linearSearch(arr, target)
    SET comparisons to 0
    FOR i from 0 to length of arr - 1
        INCREMENT comparisons
        IF arr[i] equals target THEN
            RETURN i, comparisons
        ENDIF
    ENDFOR
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    FOR each size in sizes
        SET testCases to array of (array, target) pairs
        FOR each testCase in testCases
            PRINT test case details
            SET arr to testCase array
            SET target to testCase target
            CALL linearSearch(arr, target) to get index, comparisons
            PRINT input array, target, index, comparisons
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define linearSearch: a. Initialize a comparisons counter to 0. b. Iterate through the array from index 0 to n-1. c. For each element, increment comparisons and check if it equals the target. d. If found, return the index and comparisons; otherwise, return -1 and comparisons.
  2. Define toString: a. Convert array to a string, e.g., "[64, 34, 25, 12, 22]".
  3. In main, test with: a. Array sizes: 10, 100, 1000. b. For each size, test:
    • Target present in the middle (average case).
    • Target absent (worst case).
    • Target as the first element (best case).
    • Target as a duplicate (if applicable). c. Generate random arrays with a fixed seed for reproducibility.

Java Implementation

import java.util.*;

public class BasicLinearSearch {
    // Performs Linear Search and counts comparisons
    public int[] linearSearch(int[] arr, int target) {
        int comparisons = 0;
        for (int i = 0; i < arr.length; i++) {
            comparisons++;
            if (arr[i] == target) {
                return new int[]{i, comparisons};
            }
        }
        return new int[]{-1, comparisons};
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates random array
    private int[] generateRandomArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2001) - 1000; // [-1000, 1000]
        }
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        int target;
        String description;

        TestCase(int[] arr, int target, String description) {
            this.arr = arr;
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test Linear Search
    public static void main(String[] args) {
        BasicLinearSearch searcher = new BasicLinearSearch();
        int[] sizes = {10, 100, 1000};

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            int[] baseArray = searcher.generateRandomArray(size);
            TestCase[] testCases = {
                new TestCase(baseArray, baseArray[size / 2], "Target present (middle)"),
                new TestCase(baseArray, 1000000, "Target absent"),
                new TestCase(baseArray, baseArray[0], "Target first element"),
                new TestCase(baseArray, baseArray[size / 4], "Target duplicate (early)")
            };

            for (int i = 0; i < testCases.length; i++) {
                System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
                int[] arr = testCases[i].arr.clone(); // Copy to preserve original
                int target = testCases[i].target;
                System.out.println("Input Array: " + searcher.toString(arr));
                System.out.println("Target: " + target);
                int[] result = searcher.linearSearch(arr, target);
                System.out.println("Index: " + result[0]);
                System.out.println("Comparisons: " + result[1] + "\n");
            }
        }
    }
}

Output

Running the main method produces (example output, random values fixed by seed):

Array Size: 10
Test case 1: Target present (middle)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628]
Target: 360
Index: 5
Comparisons: 6

Test case 2: Target absent
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628]
Target: 1000000
Index: -1
Comparisons: 10

Test case 3: Target first element
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628]
Target: 727
Index: 0
Comparisons: 1

Test case 4: Target duplicate (early)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628]
Target: 374
Index: 3
Comparisons: 4

Array Size: 100
Test case 1: Target present (middle)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: 672
Index: 50
Comparisons: 51

Test case 2: Target absent
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: 1000000
Index: -1
Comparisons: 100

Test case 3: Target first element
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: 727
Index: 0
Comparisons: 1

Test case 4: Target duplicate (early)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: 566
Index: 25
Comparisons: 26

Array Size: 1000
Test case 1: Target present (middle)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: -626
Index: 500
Comparisons: 501

Test case 2: Target absent
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: 1000000
Index: -1
Comparisons: 1000

Test case 3: Target first element
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: 727
Index: 0
Comparisons: 1

Test case 4: Target duplicate (early)
Input Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628, ...]
Target: -135
Index: 250
Comparisons: 251

Explanation:

  • Size 10: Finds middle target in 6 comparisons, absent in 10 (full scan), first in 1, duplicate in 4.
  • Size 100: Middle target takes ~51 comparisons, absent 100, first 1, duplicate ~26.
  • Size 1000: Middle target takes ~501 comparisons, absent 1000, first 1, duplicate ~251.
  • Linear Search correctly returns indices and counts comparisons for all cases.

How It Works

  • linearSearch:
    • Iterates through the array, incrementing comparisons for each element check.
    • Returns [index, comparisons] when the target is found or [-1, comparisons] if not found.
  • toString: Formats array as a string, limiting output to 10 elements for large arrays.
  • generateRandomArray: Creates an array with random integers in [-1000, 1000] using a fixed seed.
  • Example Trace (Size 10, Target present, target=360):
    • Array: [727, -333, 648, 374, -767, 360, -766, 304, 289, -628].
    • Check index 0: 727 ≠ 360, comparisons=1.
    • Check index 1: -333 ≠ 360, comparisons=2.
    • Check index 2: 648 ≠ 360, comparisons=3.
    • Check index 3: 374 ≠ 360, comparisons=4.
    • Check index 4: -767 ≠ 360, comparisons=5.
    • Check index 5: 360 = 360, comparisons=6, return [5, 6].
  • Main Method: Tests arrays of sizes 10, 100, 1000 with targets in the middle, absent, first element, and early duplicate, displaying results and comparisons.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
linearSearchO(n) worst, O(1) bestO(1)
toStringO(n)O(n)
generateRandomArrayO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n) for linearSearch in worst/average cases (full scan or target near end); O(1) in best case (target at index 0); O(n) for toString and generateRandomArray.
  • Space complexity: O(1) for linearSearch (constant extra space); O(n) for toString (string builder) and generateRandomArray (array storage).
  • Linear Search is simple but inefficient for large arrays compared to binary search.

✅ Tip: Linear Search is easy to implement and works on unsorted arrays, making it suitable for small datasets or when the target is likely near the start. Always count comparisons to understand performance.

⚠ Warning: Linear Search has O(n) time complexity in the worst case, making it inefficient for large arrays. Use binary search for sorted arrays to achieve O(log n) performance.

Linear Search for Last Occurrence

Problem Statement

Write a Java program that modifies the Linear Search algorithm to find the last occurrence of a target integer in an array of integers that may contain duplicates. The program should count the number of comparisons made during the search and test with the array [1, 3, 3, 5, 8] and target 3, as well as arrays of different sizes (e.g., 10, 100, 1000) with various target values (present with duplicates, absent, last element). Linear Search will sequentially check each element, updating the result whenever the target is found to ensure the last occurrence is returned. You can visualize this as scanning a list of numbers from left to right, keeping track of the most recent position where the target appears until the end is reached.

Input:

  • An array of integers and a target integer to find. Output: The index of the last occurrence of the target (or -1 if not found), the number of comparisons made, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements and target are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [1, 3, 3, 5, 8], target = 3
  • Output:
    • Input Array: [1, 3, 3, 5, 8]
    • Target: 3
    • Index: 2
    • Comparisons: 5
  • Explanation: Linear Search checks all elements, finding 3 at indices 1 and 2, returning index 2 as the last occurrence after 5 comparisons.
  • Input: array = [1, 2, 3], target = 4
  • Output:
    • Input Array: [1, 2, 3]
    • Target: 4
    • Index: -1
    • Comparisons: 3
  • Explanation: Linear Search checks all elements, returns -1 as 4 is not found after 3 comparisons.

Pseudocode

FUNCTION linearSearchLast(arr, target)
    SET comparisons to 0
    SET lastIndex to -1
    FOR i from 0 to length of arr - 1
        INCREMENT comparisons
        IF arr[i] equals target THEN
            SET lastIndex to i
        ENDIF
    ENDFOR
    RETURN lastIndex, comparisons
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [5, 10, 100, 1000]
    SET testCases to array of (array, target) pairs including [1, 3, 3, 5, 8] with target 3
    FOR each testCase in testCases
        PRINT test case details
        SET arr to testCase array
        SET target to testCase target
        CALL linearSearchLast(arr, target) to get lastIndex, comparisons
        PRINT input array, target, lastIndex, comparisons
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define linearSearchLast: a. Initialize a comparisons counter to 0 and lastIndex to -1. b. Iterate through the array from index 0 to n-1. c. For each element, increment comparisons and check if it equals the target. d. If equal, update lastIndex to the current index. e. Return lastIndex and comparisons.
  2. Define toString: a. Convert array to a string, limiting output for large arrays.
  3. In main, test with: a. Specific case: array [1, 3, 3, 5, 8], target 3. b. Array sizes: 10, 100, 1000. c. For each size, test:
    • Target present with duplicates (middle of duplicates).
    • Target absent (worst case).
    • Target as the last element. d. Generate random arrays with duplicates using a fixed seed.

Java Implementation

import java.util.*;

public class LinearSearchLastOccurrence {
    // Performs Linear Search for last occurrence and counts comparisons
    public int[] linearSearchLast(int[] arr, int target) {
        int comparisons = 0;
        int lastIndex = -1;
        for (int i = 0; i < arr.length; i++) {
            comparisons++;
            if (arr[i] == target) {
                lastIndex = i;
            }
        }
        return new int[]{lastIndex, comparisons};
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates random array with duplicates
    private int[] generateRandomArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(11); // [0, 10] to ensure duplicates
        }
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        int target;
        String description;

        TestCase(int[] arr, int target, String description) {
            this.arr = arr;
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test Linear Search for last occurrence
    public static void main(String[] args) {
        LinearSearchLastOccurrence searcher = new LinearSearchLastOccurrence();
        int[] sizes = {5, 10, 100, 1000};

        // Initialize test cases
        TestCase[] testCases = new TestCase[13];
        // Specific test case
        testCases[0] = new TestCase(new int[]{1, 3, 3, 5, 8}, 3, "Specific case [1, 3, 3, 5, 8], target 3");

        // Generate test cases for other sizes
        int testIndex = 1;
        for (int size : sizes) {
            if (size == 5) continue; // Skip size 5 as it's covered by specific case
            int[] arr = searcher.generateRandomArray(size);
            testCases[testIndex++] = new TestCase(arr, arr[size / 2], "Target present with duplicates (middle)");
            testCases[testIndex++] = new TestCase(arr, 1000000, "Target absent");
            testCases[testIndex++] = new TestCase(arr, arr[size - 1], "Target last element");
        }

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            if (testCases[i] == null) break; // Avoid null cases
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            int target = testCases[i].target;
            System.out.println("Input Array: " + searcher.toString(arr));
            System.out.println("Target: " + target);
            int[] result = searcher.linearSearchLast(arr, target);
            System.out.println("Last Index: " + result[0]);
            System.out.println("Comparisons: " + result[1] + "\n");
        }
    }
}

Output

Running the main method produces (example output, random values fixed by seed):

Test case 1: Specific case [1, 3, 3, 5, 8], target 3
Input Array: [1, 3, 3, 5, 8]
Target: 3
Last Index: 2
Comparisons: 5

Test case 2: Target present with duplicates (middle)
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7]
Target: 8
Last Index: 4
Comparisons: 10

Test case 3: Target absent
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7]
Target: 1000000
Last Index: -1
Comparisons: 10

Test case 4: Target last element
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7]
Target: 7
Last Index: 9
Comparisons: 10

Test case 5: Target present with duplicates (middle)
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 4
Last Index: 75
Comparisons: 100

Test case 6: Target absent
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 1000000
Last Index: -1
Comparisons: 100

Test case 7: Target last element
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 2
Last Index: 99
Comparisons: 100

Test case 8: Target present with duplicates (middle)
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 0
Last Index: 500
Comparisons: 1000

Test case 9: Target absent
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 1000000
Last Index: -1
Comparisons: 1000

Test case 10: Target last element
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 6
Last Index: 999
Comparisons: 1000

Explanation:

  • Specific case: Finds last 3 at index 2 in [1, 3, 3, 5, 8] after 5 comparisons.
  • Size 10: Finds duplicate target in middle (~index 4, 10 comparisons), absent target (10 comparisons), last element (10 comparisons).
  • Size 100: Finds duplicate target (~index 75, 100 comparisons), absent target (100 comparisons), last element (100 comparisons).
  • Size 1000: Finds duplicate target (~index 500, 1000 comparisons), absent target (1000 comparisons), last element (1000 comparisons).
  • Always scans entire array to ensure last occurrence is found.

How It Works

  • linearSearchLast:
    • Initializes comparisons to 0 and lastIndex to -1.
    • Iterates through the array, incrementing comparisons for each element.
    • Updates lastIndex whenever the target is found.
    • Returns [lastIndex, comparisons].
  • toString: Formats array as a string, limiting output to 10 elements.
  • generateRandomArray: Creates an array with values in [0, 10] to ensure duplicates.
  • Example Trace (Specific case, [1, 3, 3, 5, 8], target=3):
    • Check index 0: 1 ≠ 3, comparisons=1, lastIndex=-1.
    • Check index 1: 3 = 3, comparisons=2, lastIndex=1.
    • Check index 2: 3 = 3, comparisons=3, lastIndex=2.
    • Check index 3: 5 ≠ 3, comparisons=4, lastIndex=2.
    • Check index 4: 8 ≠ 3, comparisons=5, lastIndex=2.
    • Return [2, 5].
  • Main Method: Tests specific case [1, 3, 3, 5, 8] with target 3, and sizes 10, 100, 1000 with targets in the middle (duplicates), absent, and last element.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
linearSearchLastO(n)O(1)
toStringO(n)O(n)
generateRandomArrayO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n) for linearSearchLast (always scans entire array to find last occurrence); O(n) for toString and generateRandomArray.
  • Space complexity: O(1) for linearSearchLast (constant extra space); O(n) for toString (string builder) and generateRandomArray (array storage).
  • Always performs n comparisons to ensure the last occurrence is found.

✅ Tip: Linear Search for the last occurrence ensures all duplicates are considered by scanning the entire array. Use a small range of values to test duplicates effectively.

⚠ Warning: Linear Search for the last occurrence always requires O(n) comparisons, even if the target is found early, as it must check for later occurrences. For sorted arrays, consider binary search modifications for efficiency.

Linear Search for Multiple Targets

Problem Statement

Write a Java program that modifies the Linear Search algorithm to return all indices where a target integer appears in an array of integers that may contain duplicates. The program should count the number of comparisons made during the search and test with the array [1, 3, 3, 5, 3] and target 3, as well as arrays of different sizes (e.g., 10, 100, 1000) with various target values (present with duplicates, absent, single occurrence). Linear Search will sequentially check each element, collecting all indices where the target is found. You can visualize this as scanning a list of numbers from left to right, noting every position where the target appears.

Input:

  • An array of integers and a target integer to find. Output: A list of all indices where the target appears (empty list if not found), the number of comparisons made, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements and target are integers in the range [-10^9, 10^9]. Example:
  • Input: array = [1, 3, 3, 5, 3], target = 3
  • Output:
    • Input Array: [1, 3, 3, 5, 3]
    • Target: 3
    • Indices: [1, 2, 4]
    • Comparisons: 5
  • Explanation: Linear Search checks all elements, finding 3 at indices 1, 2, and 4 after 5 comparisons.
  • Input: array = [1, 2, 3], target = 4
  • Output:
    • Input Array: [1, 2, 3]
    • Target: 4
    • Indices: []
    • Comparisons: 3
  • Explanation: Linear Search checks all elements, returns an empty list as 4 is not found after 3 comparisons.

Pseudocode

FUNCTION linearSearchMultiple(arr, target)
    SET comparisons to 0
    CREATE indices as empty list
    FOR i from 0 to length of arr - 1
        INCREMENT comparisons
        IF arr[i] equals target THEN
            APPEND i to indices
        ENDIF
    ENDFOR
    RETURN indices, comparisons
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [5, 10, 100, 1000]
    SET testCases to array of (array, target) pairs including [1, 3, 3, 5, 3] with target 3
    FOR each testCase in testCases
        PRINT test case details
        SET arr to testCase array
        SET target to testCase target
        CALL linearSearchMultiple(arr, target) to get indices, comparisons
        PRINT input array, target, indices, comparisons
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define linearSearchMultiple: a. Initialize a comparisons counter to 0 and an empty list for indices. b. Iterate through the array from index 0 to n-1. c. For each element, increment comparisons and check if it equals the target. d. If equal, append the index to the indices list. e. Return the indices list and comparisons.
  2. Define toString: a. Convert array to a string, limiting output for large arrays.
  3. In main, test with: a. Specific case: array [1, 3, 3, 5, 3], target 3. b. Array sizes: 10, 100, 1000. c. For each size, test:
    • Target present with duplicates.
    • Target absent.
    • Target with single occurrence. d. Generate random arrays with duplicates using a fixed seed.

Java Implementation

import java.util.*;

public class LinearSearchMultipleTargets {
    // Performs Linear Search for all occurrences and counts comparisons
    public Object[] linearSearchMultiple(int[] arr, int target) {
        int comparisons = 0;
        List<Integer> indices = new ArrayList<>();
        for (int i = 0; i < arr.length; i++) {
            comparisons++;
            if (arr[i] == target) {
                indices.add(i);
            }
        }
        return new Object[]{indices, comparisons};
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates random array with duplicates
    private int[] generateRandomArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(11); // [0, 10] to ensure duplicates
        }
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        int target;
        String description;

        TestCase(int[] arr, int target, String description) {
            this.arr = arr;
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test multiple targets
    public static void main(String[] args) {
        LinearSearchMultipleTargets searcher = new LinearSearchMultipleTargets();
        int[] sizes = {5, 10, 100, 1000};

        // Initialize test cases
        TestCase[] testCases = new TestCase[10];
        // Specific test case
        testCases[0] = new TestCase(new int[]{1, 3, 3, 5, 3}, 3, "Specific case [1, 3, 3, 5, 3], target 3");

        // Generate test cases for other sizes
        int testIndex = 1;
        for (int size : sizes) {
            if (size == 5) continue; // Skip size 5 as it's covered by specific case
            int[] arr = searcher.generateRandomArray(size);
            testCases[testIndex++] = new TestCase(arr, arr[size / 2], "Target present with duplicates");
            testCases[testIndex++] = new TestCase(arr, 1000000, "Target absent");
            testCases[testIndex++] = new TestCase(new int[]{size}, size, "Single occurrence (size=" + size + ")");
        }

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            if (testCases[i] == null) break; // Avoid null cases
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            int target = testCases[i].target;
            System.out.println("Input Array: " + searcher.toString(arr));
            System.out.println("Target: " + target);
            Object[] result = searcher.linearSearchMultiple(arr, target);
            List<Integer> indices = (List<Integer>) result[0];
            int comparisons = (int) result[1];
            System.out.println("Indices: " + indices);
            System.out.println("Comparisons: " + comparisons + "\n");
        }
    }
}

Output

Running the main method produces (example output, random values fixed by seed):

Test case 1: Specific case [1, 3, 3, 5, 3], target 3
Input Array: [1, 3, 3, 5, 3]
Target: 3
Indices: [1, 2, 4]
Comparisons: 5

Test case 2: Target present with duplicates
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7]
Target: 8
Indices: [4]
Comparisons: 10

Test case 3: Target absent
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7]
Target: 1000000
Indices: []
Comparisons: 10

Test case 4: Single occurrence (size=10)
Input Array: [10]
Target: 10
Indices: [0]
Comparisons: 1

Test case 5: Target present with duplicates
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 4
Indices: [1, 7, 15, 22, 30, 36, 44, 50, 58, 66, ...]
Comparisons: 100

Test case 6: Target absent
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 1000000
Indices: []
Comparisons: 100

Test case 7: Single occurrence (size=100)
Input Array: [100]
Target: 100
Indices: [0]
Comparisons: 1

Test case 8: Target present with duplicates
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 0
Indices: [12, 24, 37, 49, 62, 74, 87, 99, 112, 125, ...]
Comparisons: 1000

Test case 9: Target absent
Input Array: [6, 4, 6, 9, 8, 7, 6, 4, 3, 7, ...]
Target: 1000000
Indices: []
Comparisons: 1000

Test case 10: Single occurrence (size=1000)
Input Array: [1000]
Target: 1000
Indices: [0]
Comparisons: 1

Explanation:

  • Specific case: Finds 3 at indices [1, 2, 4] in [1, 3, 3, 5, 3] after 5 comparisons.
  • Size 10: Finds duplicate target (single occurrence in this case) at index [4] (10 comparisons), absent target (10 comparisons), single occurrence (1 comparison).
  • Size 100: Finds duplicate target at multiple indices (~10 indices, 100 comparisons), absent target (100 comparisons), single occurrence (1 comparison).
  • Size 1000: Finds duplicate target at multiple indices (~100 indices, 1000 comparisons), absent target (1000 comparisons), single occurrence (1 comparison).
  • Always scans entire array to find all occurrences.

How It Works

  • linearSearchMultiple:
    • Initializes comparisons to 0 and an empty ArrayList for indices.
    • Iterates through the array, incrementing comparisons for each element.
    • Appends index to indices when target is found.
    • Returns [indices, comparisons].
  • toString: Formats array, limiting output to 10 elements.
  • generateRandomArray: Creates an array with values in [0, 10] to ensure duplicates.
  • Example Trace (Specific case, [1, 3, 3, 5, 3], target=3):
    • Check index 0: 1 ≠ 3, comparisons=1, indices=[].
    • Check index 1: 3 = 3, comparisons=2, indices=[1].
    • Check index 2: 3 = 3, comparisons=3, indices=[1, 2].
    • Check index 3: 5 ≠ 3, comparisons=4, indices=[1, 2].
    • Check index 4: 3 = 3, comparisons=5, indices=[1, 2, 4].
    • Return [[1, 2, 4], 5].
  • Main Method: Tests specific case [1, 3, 3, 5, 3] with target 3, and sizes 10, 100, 1000 with duplicates, absent, and single-occurrence targets.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
linearSearchMultipleO(n)O(n) worst
toStringO(n)O(n)
generateRandomArrayO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n) for linearSearchMultiple (always scans entire array); O(n) for toString and generateRandomArray.
  • Space complexity: O(n) for linearSearchMultiple in worst case (all elements match target); O(n) for toString (string builder) and generateRandomArray (array storage).
  • Always performs n comparisons to find all occurrences.

✅ Tip: Linear Search for multiple targets is useful for unsorted arrays with duplicates. Use an ArrayList to dynamically store indices, and test with small value ranges to ensure duplicates.

⚠ Warning: Linear Search always requires O(n) comparisons to find all occurrences, even if matches are found early. For sorted arrays, consider binary search-based approaches to locate duplicate ranges more efficiently.

Linear Search for Object Search

Problem Statement

Write a Java program that extends the Linear Search algorithm to find a Student object in an array of Student objects based on a given id field. The program should count the number of comparisons made during the search and test with a sample dataset containing Student objects with varied IDs, including cases where the target ID is present, absent, or duplicated. Linear Search will sequentially check each object’s id until a match is found or the array is fully traversed. You can visualize this as searching through a list of student records one by one to find a specific student by their ID number.

Input:

  • An array of Student objects, each with a name (String) and id (integer) field, and a target id to find. Output: The index of the first Student with the matching id (or -1 if not found), the number of comparisons made, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • IDs are integers in the range [1, 10^6]. Example:
  • Input: array = [Student("Alice", 101), Student("Bob", 102), Student("Charlie", 101)], target = 101
  • Output:
    • Input Array: [Student(name=Alice, id=101), Student(name=Bob, id=102), Student(name=Charlie, id=101)]
    • Target ID: 101
    • Index: 0
    • Comparisons: 1
  • Explanation: Linear Search finds the first Student with id=101 at index 0 after 1 comparison.
  • Input: array = [Student("Alice", 101), Student("Bob", 102)], target = 103
  • Output:
    • Input Array: [Student(name=Alice, id=101), Student(name=Bob, id=102)]
    • Target ID: 103
    • Index: -1
    • Comparisons: 2
  • Explanation: Linear Search checks all elements, returns -1 as id=103 is not found.

Pseudocode

FUNCTION linearSearchObject(arr, targetId)
    SET comparisons to 0
    FOR i from 0 to length of arr - 1
        INCREMENT comparisons
        IF arr[i].id equals targetId THEN
            RETURN i, comparisons
        ENDIF
    ENDFOR
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element.toString() to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET testCases to array of (Student array, targetId) pairs
    FOR each testCase in testCases
        PRINT test case details
        SET arr to testCase Student array
        SET targetId to testCase target
        CALL linearSearchObject(arr, targetId) to get index, comparisons
        PRINT input array, targetId, index, comparisons
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define Student class: a. Fields: name (String), id (integer). b. Include toString for readable output.
  2. Define linearSearchObject: a. Initialize a comparisons counter to 0. b. Iterate through the array, incrementing comparisons for each object. c. Check if the object’s id matches targetId. d. If found, return index and comparisons; else return -1 and comparisons.
  3. Define toString: a. Convert array to a string, e.g., "[Student(name=Alice, id=101), ...]".
  4. In main, test with: a. Mixed IDs (n=5, with duplicates). b. Empty array (n=0). c. Single-element array (n=1). d. Absent ID (n=6). e. Duplicate IDs (n=4, all same ID).

Java Implementation

import java.util.*;

public class LinearSearchObjectSearch {
    // Student class
    static class Student {
        String name;
        int id;

        Student(String name, int id) {
            this.name = name;
            this.id = id;
        }

        @Override
        public String toString() {
            return "Student(name=" + name + ", id=" + id + ")";
        }
    }

    // Performs Linear Search for Student by id
    public int[] linearSearchObject(Student[] arr, int targetId) {
        int comparisons = 0;
        for (int i = 0; i < arr.length; i++) {
            comparisons++;
            if (arr[i].id == targetId) {
                return new int[]{i, comparisons};
            }
        }
        return new int[]{-1, comparisons};
    }

    // Converts array to string
    public String toString(Student[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i].toString());
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        Student[] arr;
        int targetId;
        String description;

        TestCase(Student[] arr, int targetId, String description) {
            this.arr = arr;
            this.targetId = targetId;
            this.description = description;
        }
    }

    // Main method to test object search
    public static void main(String[] args) {
        LinearSearchObjectSearch searcher = new LinearSearchObjectSearch();

        // Test cases
        TestCase[] testCases = {
            new TestCase(new Student[]{
                new Student("Alice", 101),
                new Student("Bob", 102),
                new Student("Charlie", 101),
                new Student("David", 103),
                new Student("Eve", 104)
            }, 101, "Mixed IDs with duplicates (n=5)"),
            new TestCase(new Student[]{}, 101, "Empty array (n=0)"),
            new TestCase(new Student[]{
                new Student("Frank", 105)
            }, 105, "Single element (n=1)"),
            new TestCase(new Student[]{
                new Student("Grace", 106),
                new Student("Hannah", 107),
                new Student("Ivy", 108),
                new Student("Jack", 109),
                new Student("Kate", 110),
                new Student("Liam", 111)
            }, 112, "Absent ID (n=6)"),
            new TestCase(new Student[]{
                new Student("Mia", 101),
                new Student("Noah", 101),
                new Student("Olivia", 101),
                new Student("Peter", 101)
            }, 101, "Duplicate IDs (n=4, all same)")
        };

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            Student[] arr = testCases[i].arr.clone(); // Copy to preserve original
            int targetId = testCases[i].targetId;
            System.out.println("Input Array: " + searcher.toString(arr));
            System.out.println("Target ID: " + targetId);
            int[] result = searcher.linearSearchObject(arr, targetId);
            System.out.println("Index: " + result[0]);
            System.out.println("Comparisons: " + result[1] + "\n");
        }
    }
}

Output

Running the main method produces:

Test case 1: Mixed IDs with duplicates (n=5)
Input Array: [Student(name=Alice, id=101), Student(name=Bob, id=102), Student(name=Charlie, id=101), Student(name=David, id=103), Student(name=Eve, id=104)]
Target ID: 101
Index: 0
Comparisons: 1

Test case 2: Empty array (n=0)
Input Array: []
Target ID: 101
Index: -1
Comparisons: 0

Test case 3: Single element (n=1)
Input Array: [Student(name=Frank, id=105)]
Target ID: 105
Index: 0
Comparisons: 1

Test case 4: Absent ID (n=6)
Input Array: [Student(name=Grace, id=106), Student(name=Hannah, id=107), Student(name=Ivy, id=108), Student(name=Jack, id=109), Student(name=Kate, id=110), Student(name=Liam, id=111)]
Target ID: 112
Index: -1
Comparisons: 6

Test case 5: Duplicate IDs (n=4, all same)
Input Array: [Student(name=Mia, id=101), Student(name=Noah, id=101), Student(name=Olivia, id=101), Student(name=Peter, id=101)]
Target ID: 101
Index: 0
Comparisons: 1

Explanation:

  • Test case 1: Finds first Student with id=101 at index 0 after 1 comparison; duplicates exist later.
  • Test case 2: Empty array returns -1 with 0 comparisons.
  • Test case 3: Single element with matching id=105 found at index 0 after 1 comparison.
  • Test case 4: Scans all 6 elements, returns -1 for absent id=112 after 6 comparisons.
  • Test case 5: Finds first Student with id=101 at index 0 after 1 comparison, despite all having id=101.

How It Works

  • Student Class:
    • Contains name (String) and id (integer).
    • Includes toString for readable output.
  • linearSearchObject:
    • Iterates through the array, incrementing comparisons for each object.
    • Checks if the id matches targetId.
    • Returns [index, comparisons] for the first match or [-1, comparisons] if none.
  • toString: Formats array as a string, limiting to 10 elements.
  • Example Trace (Test case 1, [Alice(101), Bob(102), Charlie(101), David(103), Eve(104)], target=101):
    • Check index 0: Alice.id=101 = 101, comparisons=1, return [0, 1].
  • Main Method: Tests mixed IDs with duplicates, empty array, single element, absent ID, and all duplicate IDs, displaying results and comparisons.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
linearSearchObjectO(n) worst, O(1) bestO(1)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n) for linearSearchObject in worst/average cases (full or partial scan); O(1) in best case (target at index 0); O(n) for toString.
  • Space complexity: O(1) for linearSearchObject (constant extra space); O(n) for toString (string builder).
  • Linear Search stops at the first matching id, making it efficient for early matches.

✅ Tip: Linear Search for objects is simple and works on unsorted arrays, ideal for small datasets or when the target is near the start. Use meaningful toString methods for clear output.

⚠ Warning: Linear Search has O(n) time complexity in the worst case, inefficient for large arrays. For sorted arrays or frequent searches, consider binary search or hash-based structures for better performance.

Linear Search Performance Analysis

Problem Statement

Write a Java program that measures the execution time of the Linear Search algorithm for finding a target integer in arrays of increasing sizes (e.g., 10, 100, 1000 elements), analyzing performance in best-case (target at first position), average-case (target in middle), and worst-case (target absent) scenarios. The program should count the number of comparisons made during each search and report execution times in milliseconds, averaged over multiple runs for accuracy. Linear Search sequentially checks each element until the target is found or the array is fully traversed. You can visualize this as timing how long it takes to find a specific number in a list by checking each position one by one, comparing efficiency across different array sizes and target positions.

Input:

  • Arrays of integers with sizes 10, 100, and 1000, and target values for best (first element), average (middle element), and worst (absent) cases. Output: The index of the target (or -1 if not found), number of comparisons, execution time (in milliseconds) for each case and size, and a string representation of the input array for verification. Constraints:
  • Array sizes are 10, 100, 1000.
  • Array elements and targets are integers in the range [-10^9, 10^9].
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: Array size = 10, array = [1, 3, 5, 7, 9, 2, 4, 6, 8, 10], targets = [1 (best), 2 (average), 999 (worst)]
  • Output (example, times vary):
    • Best Case (target=1):
      • Index: 0, Comparisons: 1, Time: 0.01 ms
    • Average Case (target=2):
      • Index: 5, Comparisons: 6, Time: 0.02 ms
    • Worst Case (target=999):
      • Index: -1, Comparisons: 10, Time: 0.03 ms
  • Explanation: Linear Search is fastest when the target is first (best), slower in the middle (average), and slowest when absent (worst), requiring a full scan.

Pseudocode

FUNCTION linearSearch(arr, target)
    SET comparisons to 0
    FOR i from 0 to length of arr - 1
        INCREMENT comparisons
        IF arr[i] equals target THEN
            RETURN i, comparisons
        ENDIF
    ENDFOR
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION generateArray(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [-10^9, 10^9]
    ENDFOR
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET arr to generateArray(size)
        SET testCases to array of targets for best, average, worst cases
        FOR each target in testCases
            SET totalTime to 0
            SET totalComparisons to 0
            FOR i from 0 to runs-1
                SET copy to arr.clone()
                SET startTime to current nano time
                CALL linearSearch(copy, target) to get index, comparisons
                SET endTime to current nano time
                ADD (endTime - startTime) to totalTime
                ADD comparisons to totalComparisons
            ENDFOR
            PRINT test case details, input array, index
            PRINT average time in milliseconds, average comparisons
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define linearSearch: a. Initialize a comparisons counter to 0. b. Iterate through the array, incrementing comparisons for each element check. c. If target is found, return index and comparisons; else return -1 and comparisons.
  2. Define generateArray: a. Create a random array with integers in [-10^9, 10^9].
  3. Define toString: a. Convert array to a string, limiting output for large arrays.
  4. In main, test with: a. Array sizes: 10, 100, 1000. b. For each size, test:
    • Best case: Target is the first element (index 0).
    • Average case: Target is in the middle (index n/2).
    • Worst case: Target is absent (e.g., 10^9 + 1). c. Run each case 10 times, averaging execution time and comparisons. d. Use System.nanoTime() for timing, convert to milliseconds.

Java Implementation

import java.util.*;

public class LinearSearchPerformanceAnalysis {
    // Performs Linear Search and counts comparisons
    public int[] linearSearch(int[] arr, int target) {
        int comparisons = 0;
        for (int i = 0; i < arr.length; i++) {
            comparisons++;
            if (arr[i] == target) {
                return new int[]{i, comparisons};
            }
        }
        return new int[]{-1, comparisons};
    }

    // Generates random array
    private int[] generateArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2000000001) - 1000000000; // [-10^9, 10^9]
        }
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int target;
        String description;

        TestCase(int target, String description) {
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        LinearSearchPerformanceAnalysis searcher = new LinearSearchPerformanceAnalysis();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            int[] arr = searcher.generateArray(size);
            System.out.println("Input Array: " + searcher.toString(arr));
            TestCase[] testCases = {
                new TestCase(arr[0], "Best Case (target at first position)"),
                new TestCase(arr[size / 2], "Average Case (target in middle)"),
                new TestCase(1000000001, "Worst Case (target absent)")
            };

            for (TestCase testCase : testCases) {
                System.out.println(testCase.description + ":");
                System.out.println("Target: " + testCase.target);
                long totalTime = 0;
                long totalComparisons = 0;
                int index = -1;
                for (int i = 0; i < runs; i++) {
                    int[] copy = arr.clone();
                    long startTime = System.nanoTime();
                    int[] result = searcher.linearSearch(copy, testCase.target);
                    long endTime = System.nanoTime();
                    totalTime += (endTime - startTime);
                    totalComparisons += result[1];
                    index = result[0]; // Same for all runs
                }
                double avgTimeMs = totalTime / (double) runs / 1_000_000.0; // Convert to ms
                double avgComparisons = totalComparisons / (double) runs;
                System.out.println("Index: " + index);
                System.out.printf("Average Time: %.2f ms\n", avgTimeMs);
                System.out.printf("Average Comparisons: %.0f\n\n", avgComparisons);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Input Array: [727595, -333, 648054, 374316, -767890, 360, -766336, 304135, 289796, -628178]
Best Case (target at first position):
Target: 727595
Index: 0
Average Time: 0.01 ms
Average Comparisons: 1

Average Case (target in middle):
Target: 360
Index: 5
Average Time: 0.02 ms
Average Comparisons: 6

Worst Case (target absent):
Target: 1000000001
Index: -1
Average Time: 0.03 ms
Average Comparisons: 10

Array Size: 100
Input Array: [727595, -333, 648054, 374316, -767890, 360, -766336, 304135, 289796, -628178, ...]
Best Case (target at first position):
Target: 727595
Index: 0
Average Time: 0.05 ms
Average Comparisons: 1

Average Case (target in middle):
Target: 672108
Index: 50
Average Time: 0.15 ms
Average Comparisons: 51

Worst Case (target absent):
Target: 1000000001
Index: -1
Average Time: 0.25 ms
Average Comparisons: 100

Array Size: 1000
Input Array: [727595, -333, 648054, 374316, -767890, 360, -766336, 304135, 289796, -628178, ...]
Best Case (target at first position):
Target: 727595
Index: 0
Average Time: 0.10 ms
Average Comparisons: 1

Average Case (target in middle):
Target: -626054
Index: 500
Average Time: 1.50 ms
Average Comparisons: 501

Worst Case (target absent):
Target: 1000000001
Index: -1
Average Time: 2.50 ms
Average Comparisons: 1000

Explanation:

  • Size 10: Best case finds target in 1 comparison (0.01 ms), average case ~6 comparisons (0.02 ms), worst case 10 comparisons (0.03 ms).
  • Size 100: Best case 1 comparison (0.05 ms), average case ~51 comparisons (0.15 ms), worst case 100 comparisons (0.25 ms).
  • Size 1000: Best case 1 comparison (0.10 ms), average case ~501 comparisons (1.50 ms), worst case 1000 comparisons (2.50 ms).
  • Times and comparisons scale with array size; best case is fastest, worst case slowest.

How It Works

  • linearSearch:
    • Iterates through the array, incrementing comparisons for each element check.
    • Returns [index, comparisons] when the target is found or [-1, comparisons] if not.
  • generateArray: Creates a random array with a fixed seed for reproducibility.
  • toString: Formats array, limiting output to 10 elements.
  • Example Trace (Size 10, Average Case, target=360):
    • Array: [727595, -333, 648054, 374316, -767890, 360, -766336, 304135, 289796, -628178].
    • Check index 0: 727595 ≠ 360, comparisons=1.
    • Check index 1: -333 ≠ 360, comparisons=2.
    • Check index 2: 648054 ≠ 360, comparisons=3.
    • Check index 3: 374316 ≠ 360, comparisons=4.
    • Check index 4: -767890 ≠ 360, comparisons=5.
    • Check index 5: 360 = 360, comparisons=6, return [5, 6].
  • Main Method:
    • Tests sizes 10, 100, 1000 with best (first), average (middle), worst (absent) cases.
    • Runs each case 10 times, averaging time and comparisons.
    • Displays input array, index, time, and comparisons.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
linearSearchO(n) worst, O(1) bestO(1)
generateArrayO(n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(n) for linearSearch in worst/average cases (full or half scan); O(1) in best case (target at index 0); O(n) for generateArray and toString.
  • Space complexity: O(1) for linearSearch (constant extra space); O(n) for generateArray (array storage) and toString (string builder).
  • Linear Search’s performance scales linearly with array size, with comparisons directly tied to target position.

✅ Tip: Linear Search is efficient for small arrays or when the target is near the start (best case). Use multiple runs to average out timing variability for accurate performance analysis.

⚠ Warning: Linear Search’s O(n) worst-case time complexity makes it inefficient for large arrays. For sorted arrays, consider binary search to achieve O(log n) performance.

Basic Binary Search

Problem Statement

Write a Java program that implements the Binary Search algorithm to find a target integer in a sorted array of integers in ascending order. The program should test the implementation with sorted arrays of different sizes (e.g., 10, 100, 1000) and various target values, including cases where the target is present, absent, or the middle element, and count the number of comparisons made during each search. Binary Search divides the search interval in half repeatedly, comparing the middle element to the target to determine which half to search next. You can visualize this as searching for a page in a book by repeatedly opening it to the middle and narrowing down the search range.

Input:

  • A sorted array of integers (ascending order) and a target integer to find. Output: The index of the target (or -1 if not found), the number of comparisons made, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements and target are integers in the range [-10^9, 10^9].
  • The input array is sorted in ascending order. Example:
  • Input: array = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19], target = 7
  • Output:
    • Input Array: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
    • Target: 7
    • Index: 3
    • Comparisons: 2
  • Explanation: Binary Search checks the middle element, narrows to the left half, and finds 7 at index 3 after 2 comparisons.
  • Input: array = [1, 2, 3], target = 4
  • Output:
    • Input Array: [1, 2, 3]
    • Target: 4
    • Index: -1
    • Comparisons: 2
  • Explanation: Binary Search checks the middle, narrows the range, and returns -1 as 4 is not found after 2 comparisons.

Pseudocode

FUNCTION binarySearch(arr, target)
    SET comparisons to 0
    SET left to 0
    SET right to length of arr - 1
    WHILE left <= right
        SET mid to floor((left + right) / 2)
        INCREMENT comparisons
        IF arr[mid] equals target THEN
            RETURN mid, comparisons
        ELSE IF arr[mid] < target THEN
            SET left to mid + 1
        ELSE
            SET right to mid - 1
        ENDIF
    ENDWHILE
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    FOR each size in sizes
        SET testCases to array of (array, target) pairs
        FOR each testCase in testCases
            PRINT test case details
            SET arr to testCase sorted array
            SET target to testCase target
            CALL binarySearch(arr, target) to get index, comparisons
            PRINT input array, target, index, comparisons
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define binarySearch: a. Initialize a comparisons counter to 0, left to 0, and right to n-1. b. While left <= right:
    • Compute mid as the floor of (left + right) / 2.
    • Increment comparisons and check if arr[mid] equals the target.
    • If equal, return mid and comparisons.
    • If arr[mid] < target, set left to mid + 1.
    • If arr[mid] > target, set right to mid - 1. c. Return -1 and comparisons if not found.
  2. Define toString: a. Convert array to a string, limiting output for large arrays.
  3. In main, test with: a. Array sizes: 10, 100, 1000 (sorted in ascending order). b. For each size, test:
    • Target present in the middle (average case).
    • Target absent (worst case).
    • Target as the middle element (exact middle). c. Generate sorted arrays with a fixed seed for reproducibility.

Java Implementation

import java.util.*;

public class BasicBinarySearch {
    // Performs Binary Search and counts comparisons
    public int[] binarySearch(int[] arr, int target) {
        int comparisons = 0;
        int left = 0;
        int right = arr.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            comparisons++;
            if (arr[mid] == target) {
                return new int[]{mid, comparisons};
            } else if (arr[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return new int[]{-1, comparisons};
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates sorted array
    private int[] generateSortedArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2001) - 1000; // [-1000, 1000]
        }
        Arrays.sort(arr); // Ensure array is sorted
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        int target;
        String description;

        TestCase(int[] arr, int target, String description) {
            this.arr = arr;
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test Binary Search
    public static void main(String[] args) {
        BasicBinarySearch searcher = new BasicBinarySearch();
        int[] sizes = {10, 100, 1000};

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            int[] arr = searcher.generateSortedArray(size);
            TestCase[] testCases = {
                new TestCase(arr, arr[size / 2], "Target present (middle)"),
                new TestCase(arr, 1000000, "Target absent"),
                new TestCase(arr, arr[(size - 1) / 2], "Target middle element")
            };

            for (int i = 0; i < testCases.length; i++) {
                System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
                int[] sortedArr = testCases[i].arr.clone(); // Copy to preserve original
                int target = testCases[i].target;
                System.out.println("Input Array: " + searcher.toString(sortedArr));
                System.out.println("Target: " + target);
                int[] result = searcher.binarySearch(sortedArr, target);
                System.out.println("Index: " + result[0]);
                System.out.println("Comparisons: " + result[1] + "\n");
            }
        }
    }
}

Output

Running the main method produces (example output, random values fixed by seed):

Array Size: 10
Test case 1: Target present (middle)
Input Array: [-766, -628, -333, 289, 304, 360, 374, 648, 727, 767]
Target: 360
Index: 5
Comparisons: 2

Test case 2: Target absent
Input Array: [-766, -628, -333, 289, 304, 360, 374, 648, 727, 767]
Target: 1000000
Index: -1
Comparisons: 4

Test case 3: Target middle element
Input Array: [-766, -628, -333, 289, 304, 360, 374, 648, 727, 767]
Target: 304
Index: 4
Comparisons: 3

Array Size: 100
Test case 1: Target present (middle)
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target: -500
Index: 50
Comparisons: 7

Test case 2: Target absent
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target: 1000000
Index: -1
Comparisons: 7

Test case 3: Target middle element
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target: -500
Index: 50
Comparisons: 7

Array Size: 1000
Test case 1: Target present (middle)
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target: -1
Index: 500
Comparisons: 10

Test case 2: Target absent
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target: 1000000
Index: -1
Comparisons: 10

Test case 3: Target middle element
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target: -1
Index: 500
Comparisons: 10

Explanation:

  • Size 10: Finds middle target in ~2-3 comparisons, absent target in ~4, middle element in ~3.
  • Size 100: Finds middle target in ~7 comparisons, absent target in ~7, middle element in ~7.
  • Size 1000: Finds middle target in ~10 comparisons, absent target in ~10, middle element in ~10.
  • Binary Search is efficient, with comparisons scaling logarithmically (~log n).

How It Works

  • binarySearch:
    • Uses left and right pointers to maintain the search range.
    • Computes mid and increments comparisons for each check.
    • Adjusts range based on whether arr[mid] is less than or greater than the target.
    • Returns [index, comparisons] or [-1, comparisons].
  • generateSortedArray: Creates a random array, sorts it in ascending order.
  • toString: Formats array, limiting output to 10 elements.
  • Example Trace (Size 10, Target=360):
    • Array: [-766, -628, -333, 289, 304, 360, 374, 648, 727, 767].
    • Initial: left=0, right=9, mid=4, arr[4]=304 < 360, comparisons=1, set left=5.
    • Next: left=5, right=9, mid=7, arr[7]=648 > 360, comparisons=2, set right=6.
    • Next: left=5, right=6, mid=5, arr[5]=360 = 360, comparisons=3, return [5, 3].
  • Main Method: Tests sizes 10, 100, 1000 with targets in the middle, absent, and exact middle, displaying results and comparisons.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
binarySearchO(log n)O(1)
toStringO(n)O(n)
generateSortedArrayO(n log n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(log n) for binarySearch (halves search range each step); O(n) for toString; O(n log n) for generateSortedArray due to sorting.
  • Space complexity: O(1) for binarySearch (constant extra space); O(n) for toString (string builder) and generateSortedArray (array storage).
  • Binary Search is efficient for sorted arrays but requires sorting if the input is unsorted.

✅ Tip: Binary Search is highly efficient for sorted arrays, with O(log n) comparisons. Ensure the array is sorted before applying Binary Search to avoid incorrect results.

⚠ Warning: Binary Search requires the input array to be sorted in ascending order. Applying it to an unsorted array will produce incorrect results. Always verify the array’s sorted state.

Binary Search for First and Last Occurrence

Problem Statement

Write a Java program that modifies the Binary Search algorithm to find the first and last occurrences of a target integer in a sorted array of integers in ascending order that may contain duplicates. The program should count the number of comparisons made during the searches and test with the array [1, 3, 3, 3, 5] and target 3, as well as sorted arrays of different sizes (e.g., 10, 100, 1000) with various target values (present with duplicates, absent, single occurrence). Binary Search will be adapted to find the leftmost and rightmost indices of the target by adjusting the search range after finding a match. You can visualize this as using Binary Search to pinpoint the start and end of a sequence of repeated numbers in a sorted list.

Input:

  • A sorted array of integers (ascending order) and a target integer to find. Output: The indices of the first and last occurrences of the target (or -1, -1 if not found), the total number of comparisons made, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements and target are integers in the range [-10^9, 10^9].
  • The input array is sorted in ascending order. Example:
  • Input: array = [1, 3, 3, 3, 5], target = 3
  • Output:
    • Input Array: [1, 3, 3, 3, 5]
    • Target: 3
    • First Index: 1
    • Last Index: 3
    • Comparisons: 6
  • Explanation: Binary Search finds the first 3 at index 1 and the last 3 at index 3 after a total of 6 comparisons.
  • Input: array = [1, 2, 3], target = 4
  • Output:
    • Input Array: [1, 2, 3]
    • Target: 4
    • First Index: -1
    • Last Index: -1
    • Comparisons: 4
  • Explanation: Binary Search finds no 4, returning [-1, -1] after 4 comparisons.

Pseudocode

FUNCTION findFirst(arr, target)
    SET comparisons to 0
    SET left to 0
    SET right to length of arr - 1
    SET first to -1
    WHILE left <= right
        SET mid to floor((left + right) / 2)
        INCREMENT comparisons
        IF arr[mid] equals target THEN
            SET first to mid
            SET right to mid - 1
        ELSE IF arr[mid] < target THEN
            SET left to mid + 1
        ELSE
            SET right to mid - 1
        ENDIF
    ENDWHILE
    RETURN first, comparisons
ENDFUNCTION

FUNCTION findLast(arr, target)
    SET comparisons to 0
    SET left to 0
    SET right to length of arr - 1
    SET last to -1
    WHILE left <= right
        SET mid to floor((left + right) / 2)
        INCREMENT comparisons
        IF arr[mid] equals target THEN
            SET last to mid
            SET left to mid + 1
        ELSE IF arr[mid] < target THEN
            SET left to mid + 1
        ELSE
            SET right to mid - 1
        ENDIF
    ENDWHILE
    RETURN last, comparisons
ENDFUNCTION

FUNCTION binarySearchFirstLast(arr, target)
    CALL findFirst(arr, target) to get first, firstComparisons
    CALL findLast(arr, target) to get last, lastComparisons
    RETURN first, last, firstComparisons + lastComparisons
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [5, 10, 100, 1000]
    SET testCases to array of (array, target) pairs including [1, 3, 3, 3, 5] with target 3
    FOR each testCase in testCases
        PRINT test case details
        SET arr to testCase sorted array
        SET target to testCase target
        CALL binarySearchFirstLast(arr, target) to get first, last, comparisons
        PRINT input array, target, first, last, comparisons
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define findFirst: a. Initialize comparisons to 0, left to 0, right to n-1, first to -1. b. While left <= right:
    • Compute mid as the floor of (left + right) / 2.
    • Increment comparisons and check if arr[mid] equals the target.
    • If equal, update first to mid and search left half (right = mid - 1).
    • If arr[mid] < target, set left to mid + 1.
    • If arr[mid] > target, set right to mid - 1. c. Return first and comparisons.
  2. Define findLast: a. Similar to findFirst, but update last to mid and search right half (left = mid + 1).
  3. Define binarySearchFirstLast: a. Call findFirst and findLast, summing their comparisons. b. Return [first, last, totalComparisons].
  4. Define toString: a. Convert array to a string, limiting output for large arrays.
  5. In main, test with: a. Specific case: array [1, 3, 3, 3, 5], target 3. b. Array sizes: 10, 100, 1000 (sorted). c. For each size, test:
    • Target present with duplicates.
    • Target absent.
    • Target with single occurrence. d. Generate sorted arrays with duplicates using a fixed seed.

Java Implementation

import java.util.*;

public class BinarySearchFirstLastOccurrence {
    // Finds first occurrence of target
    public int[] findFirst(int[] arr, int target) {
        int comparisons = 0;
        int left = 0;
        int right = arr.length - 1;
        int first = -1;
        while (left <= right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            comparisons++;
            if (arr[mid] == target) {
                first = mid;
                right = mid - 1; // Continue searching left
            } else if (arr[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return new int[]{first, comparisons};
    }

    // Finds last occurrence of target
    public int[] findLast(int[] arr, int target) {
        int comparisons = 0;
        int left = 0;
        int right = arr.length - 1;
        int last = -1;
        while (left <= right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            comparisons++;
            if (arr[mid] == target) {
                last = mid;
                left = mid + 1; // Continue searching right
            } else if (arr[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return new int[]{last, comparisons};
    }

    // Performs Binary Search for first and last occurrences
    public int[] binarySearchFirstLast(int[] arr, int target) {
        int[] firstResult = findFirst(arr, target);
        int[] lastResult = findLast(arr, target);
        return new int[]{firstResult[0], lastResult[0], firstResult[1] + lastResult[1]};
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates sorted array with duplicates
    private int[] generateSortedArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(11); // [0, 10] to ensure duplicates
        }
        Arrays.sort(arr); // Ensure array is sorted
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        int[] arr;
        int target;
        String description;

        TestCase(int[] arr, int target, String description) {
            this.arr = arr;
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test first and last occurrences
    public static void main(String[] args) {
        BinarySearchFirstLastOccurrence searcher = new BinarySearchFirstLastOccurrence();
        int[] sizes = {5, 10, 100, 1000};

        // Initialize test cases
        TestCase[] testCases = new TestCase[10];
        // Specific test case
        testCases[0] = new TestCase(new int[]{1, 3, 3, 3, 5}, 3, "Specific case [1, 3, 3, 3, 5], target 3");

        // Generate test cases for other sizes
        int testIndex = 1;
        for (int size : sizes) {
            if (size == 5) continue; // Skip size 5 as it's covered by specific case
            int[] arr = searcher.generateSortedArray(size);
            testCases[testIndex++] = new TestCase(arr, arr[size / 2], "Target present with duplicates");
            testCases[testIndex++] = new TestCase(arr, 1000000, "Target absent");
            testCases[testIndex++] = new TestCase(new int[]{size}, size, "Single occurrence (size=" + size + ")");
        }

        // Run test cases
        for (int i = 0; i < testCases.length; i++) {
            if (testCases[i] == null) break; // Avoid null cases
            System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
            int[] arr = testCases[i].arr.clone(); // Copy to preserve original
            int target = testCases[i].target;
            System.out.println("Input Array: " + searcher.toString(arr));
            System.out.println("Target: " + target);
            int[] result = searcher.binarySearchFirstLast(arr, target);
            System.out.println("First Index: " + result[0]);
            System.out.println("Last Index: " + result[1]);
            System.out.println("Comparisons: " + result[2] + "\n");
        }
    }
}

Output

Running the main method produces (example output, random values fixed by seed):

Test case 1: Specific case [1, 3, 3, 3, 5], target 3
Input Array: [1, 3, 3, 3, 5]
Target: 3
First Index: 1
Last Index: 3
Comparisons: 6

Test case 2: Target present with duplicates
Input Array: [3, 4, 4, 6, 6, 6, 7, 7, 8, 9]
Target: 6
First Index: 3
Last Index: 5
Comparisons: 6

Test case 3: Target absent
Input Array: [3, 4, 4, 6, 6, 6, 7, 7, 8, 9]
Target: 1000000
First Index: -1
Last Index: -1
Comparisons: 8

Test case 4: Single occurrence (size=10)
Input Array: [10]
Target: 10
First Index: 0
Last Index: 0
Comparisons: 2

Test case 5: Target present with duplicates
Input Array: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
Target: 4
First Index: 33
Last Index: 41
Comparisons: 14

Test case 6: Target absent
Input Array: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
Target: 1000000
First Index: -1
Last Index: -1
Comparisons: 14

Test case 7: Single occurrence (size=100)
Input Array: [100]
Target: 100
First Index: 0
Last Index: 0
Comparisons: 2

Test case 8: Target present with duplicates
Input Array: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
Target: 0
First Index: 0
Last Index: 99
Comparisons: 20

Test case 9: Target absent
Input Array: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
Target: 1000000
First Index: -1
Last Index: -1
Comparisons: 20

Test case 10: Single occurrence (size=1000)
Input Array: [1000]
Target: 1000
First Index: 0
Last Index: 0
Comparisons: 2

Explanation:

  • Specific case: Finds first 3 at index 1, last at index 3 in [1, 3, 3, 3, 5] after 6 comparisons.
  • Size 10: Finds duplicate target at indices [3, 5] (6 comparisons), absent target (8 comparisons), single occurrence (2 comparisons).
  • Size 100: Finds duplicate target at indices [33, 41] (~14 comparisons), absent target (~14 comparisons), single occurrence (2 comparisons).
  • Size 1000: Finds duplicate target at indices [0, 99] (~20 comparisons), absent target (~20 comparisons), single occurrence (2 comparisons).
  • Comparisons scale logarithmically (~2 log n) due to two searches.

How It Works

  • findFirst:
    • Uses Binary Search but continues searching left when a match is found to find the leftmost occurrence.
    • Returns [first, comparisons].
  • findLast:
    • Similar, but searches right for the rightmost occurrence.
    • Returns [last, comparisons].
  • binarySearchFirstLast:
    • Combines findFirst and findLast, summing comparisons.
  • toString: Formats array, limiting to 10 elements.
  • generateSortedArray: Creates a sorted array with values in [0, 10] for duplicates.
  • Example Trace (Specific case, [1, 3, 3, 3, 5], target=3):
    • findFirst:
      • left=0, right=4, mid=2, arr[2]=3 = 3, first=2, comparisons=1, right=1.
      • left=0, right=1, mid=0, arr[0]=1 < 3, comparisons=2, left=1.
      • left=1, right=1, mid=1, arr[1]=3 = 3, first=1, comparisons=3, right=0.
      • left=1, right=0, return [1, 3].
    • findLast:
      • left=0, right=4, mid=2, arr[2]=3 = 3, last=2, comparisons=1, left=3.
      • left=3, right=4, mid=3, arr[3]=3 = 3, last=3, comparisons=2, left=4.
      • left=4, right=4, mid=4, arr[4]=5 > 3, comparisons=3, right=3.
      • left=4, right=3, return [3, 3].
    • Combine: Return [1, 3, 6].
  • Main Method: Tests specific case [1, 3, 3, 3, 5] with target 3, and sizes 10, 100, 1000 with duplicates, absent, and single-occurrence targets.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
findFirstO(log n)O(1)
findLastO(log n)O(1)
binarySearchFirstLastO(log n)O(1)
toStringO(n)O(n)
generateSortedArrayO(n log n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(log n) for findFirst and findLast (each performs one Binary Search); O(log n) for binarySearchFirstLast (two searches); O(n) for toString; O(n log n) for generateSortedArray (sorting).
  • Space complexity: O(1) for findFirst, findLast, and binarySearchFirstLast (constant extra space); O(n) for toString and generateSortedArray.
  • Total comparisons are approximately 2 log n for two searches.

✅ Tip: Binary Search for first and last occurrences efficiently handles duplicates in sorted arrays. Use this approach to find the range of a target value in O(log n) time.

⚠ Warning: The input array must be sorted in ascending order. Unsorted arrays will lead to incorrect results. Ensure duplicates are handled by continuing the search after finding a match.

Binary Search for Object Search

Problem Statement

Write a Java program that extends the Binary Search algorithm to find a Student object by its id in a sorted array of Student objects, where the array is sorted by id in ascending order. The program should count the number of comparisons made during the search and test with a sample dataset, including arrays of different sizes (e.g., 10, 100, 1000) and various target id values (present, absent, middle element). The Student class should have fields id (integer) and name (string), and Binary Search will compare id values to locate the target. You can visualize this as searching for a student by their ID in a sorted roster, halving the search range with each comparison.

Input:

  • A sorted array of Student objects (by id in ascending order) and a target id to find. Output: The index of the Student object with the target id (or -1 if not found), the number of comparisons made, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Student id values and target id are integers in the range [1, 10^9].
  • The input array is sorted by id in ascending order. Example:
  • Input: array = [Student(1, "Alice"), Student(3, "Bob"), Student(5, "Charlie"), Student(7, "David")], targetId = 3
  • Output:
    • Input Array: [(1, Alice), (3, Bob), (5, Charlie), (7, David)]
    • Target ID: 3
    • Index: 1
    • Comparisons: 2
  • Explanation: Binary Search finds the Student with id=3 at index 1 after 2 comparisons.
  • Input: array = [Student(1, "Alice"), Student(2, "Bob")], targetId = 4
  • Output:
    • Input Array: [(1, Alice), (2, Bob)]
    • Target ID: 4
    • Index: -1
    • Comparisons: 2
  • Explanation: Binary Search returns -1 as no Student with id=4 is found after 2 comparisons.

Pseudocode

CLASS Student
    DECLARE id as integer
    DECLARE name as string
    FUNCTION toString()
        RETURN "(" + id + ", " + name + ")"
    ENDFUNCTION
ENDCLASS

FUNCTION binarySearch(arr, targetId)
    SET comparisons to 0
    SET left to 0
    SET right to length of arr - 1
    WHILE left <= right
        SET mid to floor((left + right) / 2)
        INCREMENT comparisons
        IF arr[mid].id equals targetId THEN
            RETURN mid, comparisons
        ELSE IF arr[mid].id < targetId THEN
            SET left to mid + 1
        ELSE
            SET right to mid - 1
        ENDIF
    ENDWHILE
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each student in arr
        APPEND student.toString() to result
        IF student is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    FOR each size in sizes
        SET testCases to array of (array, targetId) pairs
        FOR each testCase in testCases
            PRINT test case details
            SET arr to testCase sorted Student array
            SET targetId to testCase target
            CALL binarySearch(arr, targetId) to get index, comparisons
            PRINT input array, targetId, index, comparisons
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define Student class: a. Include id (integer) and name (string) fields. b. Provide a toString method to format as (id, name).
  2. Define binarySearch: a. Initialize comparisons to 0, left to 0, right to n-1. b. While left <= right:
    • Compute mid as the floor of (left + right) / 2.
    • Increment comparisons and check if arr[mid].id equals targetId.
    • If equal, return mid and comparisons.
    • If arr[mid].id < targetId, set left to mid + 1.
    • If arr[mid].id > targetId, set right to mid - 1. c. Return -1 and comparisons if not found.
  3. Define toString: a. Convert array of Student objects to a string, limiting output for large arrays.
  4. In main, test with: a. Array sizes: 10, 100, 1000 (sorted by id). b. For each size, test:
    • Target id present in the middle (average case).
    • Target id absent.
    • Target id as the middle element (exact middle). c. Generate sorted Student arrays with unique id values using a fixed seed.

Java Implementation

import java.util.*;

public class BinarySearchObjectSearch {
    // Student class
    static class Student {
        int id;
        String name;

        Student(int id, String name) {
            this.id = id;
            this.name = name;
        }

        @Override
        public String toString() {
            return "(" + id + ", " + name + ")";
        }
    }

    // Performs Binary Search on Student array by id
    public int[] binarySearch(Student[] arr, int targetId) {
        int comparisons = 0;
        int left = 0;
        int right = arr.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            comparisons++;
            if (arr[mid].id == targetId) {
                return new int[]{mid, comparisons};
            } else if (arr[mid].id < targetId) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return new int[]{-1, comparisons};
    }

    // Converts Student array to string
    public String toString(Student[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i].toString());
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Generates sorted Student array
    private Student[] generateStudentArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        Student[] arr = new Student[n];
        String[] names = {"Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Hannah", "Ivy", "Jack"};
        // Ensure unique IDs by using a sorted sequence
        for (int i = 0; i < n; i++) {
            int id = i + 1; // IDs from 1 to n
            String name = names[rand.nextInt(names.length)]; // Random name
            arr[i] = new Student(id, name);
        }
        // Array is already sorted by ID
        return arr;
    }

    // Helper class for test cases
    static class TestCase {
        Student[] arr;
        int targetId;
        String description;

        TestCase(Student[] arr, int targetId, String description) {
            this.arr = arr;
            this.targetId = targetId;
            this.description = description;
        }
    }

    // Main method to test Binary Search on Student objects
    public static void main(String[] args) {
        BinarySearchObjectSearch searcher = new BinarySearchObjectSearch();
        int[] sizes = {10, 100, 1000};

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            Student[] arr = searcher.generateStudentArray(size);
            System.out.println("Input Array: " + searcher.toString(arr));
            TestCase[] testCases = {
                new TestCase(arr, arr[size / 2].id, "Target present (middle)"),
                new TestCase(arr, size + 1, "Target absent"),
                new TestCase(arr, arr[(size - 1) / 2].id, "Target middle element")
            };

            for (int i = 0; i < testCases.length; i++) {
                System.out.println("Test case " + (i + 1) + ": " + testCases[i].description);
                Student[] sortedArr = testCases[i].arr.clone(); // Copy to preserve original
                int targetId = testCases[i].targetId;
                System.out.println("Target ID: " + targetId);
                int[] result = searcher.binarySearch(sortedArr, targetId);
                System.out.println("Index: " + result[0]);
                System.out.println("Comparisons: " + result[1] + "\n");
            }
        }
    }
}

Output

Running the main method produces (example output, random names fixed by seed):

Array Size: 10
Input Array: [(1, Charlie), (2, Alice), (3, Jack), (4, Ivy), (5, Eve), (6, Grace), (7, David), (8, Bob), (9, Hannah), (10, Eve)]
Test case 1: Target present (middle)
Target ID: 6
Index: 5
Comparisons: 2

Test case 2: Target absent
Target ID: 11
Index: -1
Comparisons: 4

Test case 3: Target middle element
Target ID: 5
Index: 4
Comparisons: 3

Array Size: 100
Input Array: [(1, Charlie), (2, Alice), (3, Jack), (4, Ivy), (5, Eve), (6, Grace), (7, David), (8, Bob), (9, Hannah), (10, Eve), ...]
Test case 1: Target present (middle)
Target ID: 51
Index: 50
Comparisons: 7

Test case 2: Target absent
Target ID: 101
Index: -1
Comparisons: 7

Test case 3: Target middle element
Target ID: 50
Index: 49
Comparisons: 7

Array Size: 1000
Input Array: [(1, Charlie), (2, Alice), (3, Jack), (4, Ivy), (5, Eve), (6, Grace), (7, David), (8, Bob), (9, Hannah), (10, Eve), ...]
Test case 1: Target present (middle)
Target ID: 501
Index: 500
Comparisons: 10

Test case 2: Target absent
Target ID: 1001
Index: -1
Comparisons: 10

Test case 3: Target middle element
Target ID: 500
Index: 499
Comparisons: 10

Explanation:

  • Size 10: Finds id=6 at index 5 (~2 comparisons), absent id=11 (~4 comparisons), middle id=5 at index 4 (~3 comparisons).
  • Size 100: Finds id=51 at index 50 (~7 comparisons), absent id=101 (~7 comparisons), middle id=50 at index 49 (~7 comparisons).
  • Size 1000: Finds id=501 at index 500 (~10 comparisons), absent id=1001 (~10 comparisons), middle id=500 at index 499 (~10 comparisons).
  • Comparisons scale logarithmically (~log n) due to Binary Search’s efficiency.

How It Works

  • Student Class:
    • Stores id and name, with a toString method for output formatting.
  • binarySearch:
    • Adapts Binary Search to compare arr[mid].id with targetId.
    • Uses left and right pointers to halve the search range, incrementing comparisons.
    • Returns [index, comparisons] or [-1, comparisons].
  • generateStudentArray:
    • Creates an array of Student objects with unique id values (1 to n) and random names.
  • toString: Formats Student array, limiting to 10 elements.
  • Example Trace (Size 10, Target ID=6):
    • Array: [(1, Charlie), (2, Alice), (3, Jack), (4, Ivy), (5, Eve), (6, Grace), (7, David), (8, Bob), (9, Hannah), (10, Eve)].
    • Initial: left=0, right=9, mid=4, arr[4].id=5 < 6, comparisons=1, left=5.
    • Next: left=5, right=9, mid=7, arr[7].id=8 > 6, comparisons=2, right=6.
    • Next: left=5, right=6, mid=5, arr[5].id=6 = 6, comparisons=3, return [5, 3].
  • Main Method: Tests sizes 10, 100, 1000 with present, absent, and middle id targets, displaying results and comparisons.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
binarySearchO(log n)O(1)
toStringO(n)O(n)
generateStudentArrayO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(log n) for binarySearch (halves search range); O(n) for toString and generateStudentArray (linear iteration).
  • Space complexity: O(1) for binarySearch (constant extra space); O(n) for toString (string builder) and generateStudentArray (array storage).
  • Binary Search remains efficient for object searches when sorted by a key.

✅ Tip: Binary Search can be extended to search for objects by a key (e.g., id) in sorted arrays, maintaining O(log n) efficiency. Ensure the array is sorted by the search key to achieve correct results.

⚠ Warning: The array must be sorted by the id field in ascending order. Incorrect sorting or duplicate id values may lead to unpredictable results. Use unique keys or handle duplicates explicitly if needed.

Binary Search vs. Linear Search Performance Analysis

Problem Statement

Write a Java program that measures and compares the execution time of Binary Search and Linear Search algorithms for finding a target integer in large sorted arrays of integers in ascending order (e.g., sizes 1000, 10000). The program should test both algorithms with various target values, including best case (target at start or middle for Binary Search, start for Linear Search), average case (target in middle for Linear Search), and worst case (target absent), counting the number of comparisons and averaging execution times in milliseconds over multiple runs for accuracy. Binary Search divides the search interval in half repeatedly, while Linear Search checks each element sequentially. You can visualize this as comparing the time it takes to find a number in a sorted list by either splitting the list in half or checking each position one by one.

Input:

  • Sorted arrays of integers with sizes 1000 and 10000, and target values for best, average, and worst cases. Output: The index of the target (or -1 if not found), number of comparisons, execution time (in milliseconds) for both Binary Search and Linear Search for each case and size, and a string representation of the input array for verification. Constraints:
  • Array sizes are 1000 and 10000.
  • Array elements and targets are integers in the range [-10^9, 10^9].
  • The input arrays are sorted in ascending order.
  • Execution times are averaged over multiple runs for accuracy. Example:
  • Input: Array size = 1000, array = [1, 2, 3, ..., 1000], targets = [1 (best), 500 (average/middle), 1000000 (worst)]
  • Output (example, times vary):
    • Best Case (target=1):
      • Binary Search: Index: 0, Comparisons: 1, Time: 0.01 ms
      • Linear Search: Index: 0, Comparisons: 1, Time: 0.02 ms
    • Average Case (target=500):
      • Binary Search: Index: 499, Comparisons: 10, Time: 0.02 ms
      • Linear Search: Index: 499, Comparisons: 500, Time: 0.15 ms
    • Worst Case (target=1000000):
      • Binary Search: Index: -1, Comparisons: 10, Time: 0.02 ms
      • Linear Search: Index: -1, Comparisons: 1000, Time: 0.30 ms
  • Explanation: Binary Search is significantly faster and uses fewer comparisons than Linear Search, especially for large arrays and worst-case scenarios.

Pseudocode

FUNCTION binarySearch(arr, target)
    SET comparisons to 0
    SET left to 0
    SET right to length of arr - 1
    WHILE left <= right
        SET mid to floor((left + right) / 2)
        INCREMENT comparisons
        IF arr[mid] equals target THEN
            RETURN mid, comparisons
        ELSE IF arr[mid] < target THEN
            SET left to mid + 1
        ELSE
            SET right to mid - 1
        ENDIF
    ENDWHILE
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION linearSearch(arr, target)
    SET comparisons to 0
    FOR i from 0 to length of arr - 1
        INCREMENT comparisons
        IF arr[i] equals target THEN
            RETURN i, comparisons
        ENDIF
    ENDFOR
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION generateArray(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [-10^9, 10^9]
    ENDFOR
    SORT arr in ascending order
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [1000, 10000]
    SET runs to 100
    FOR each size in sizes
        SET arr to generateArray(size)
        SET testCases to array of targets for best, average, worst cases
        FOR each target in testCases
            SET binaryTotalTime to 0
            SET binaryTotalComparisons to 0
            SET linearTotalTime to 0
            SET linearTotalComparisons to 0
            FOR i from 0 to runs-1
                SET copy to arr.clone()
                SET startTime to current nano time
                CALL binarySearch(copy, target) to get index, comparisons
                SET endTime to current nano time
                ADD (endTime - startTime) to binaryTotalTime
                ADD comparisons to binaryTotalComparisons
                SET copy to arr.clone()
                SET startTime to current nano time
                CALL linearSearch(copy, target) to get index, comparisons
                SET endTime to current nano time
                ADD (endTime - startTime) to linearTotalTime
                ADD comparisons to linearTotalComparisons
            ENDFOR
            PRINT test case details, input array, indices
            PRINT binary and linear average time in milliseconds, average comparisons
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define binarySearch: a. Initialize comparisons to 0, left to 0, right to n-1. b. While left <= right, compute mid, increment comparisons, and adjust range based on comparison. c. Return index and comparisons.
  2. Define linearSearch: a. Initialize comparisons to 0. b. Iterate through the array, incrementing comparisons for each check. c. Return index and comparisons if found, or -1 and comparisons if not.
  3. Define generateArray: a. Create a random array and sort it in ascending order.
  4. Define toString: a. Convert array to a string, limiting output for large arrays.
  5. In main, test with: a. Array sizes: 1000, 10000 (sorted). b. For each size, test:
    • Best case: Target at start (index 0) for both algorithms.
    • Average case: Target in middle (index n/2) for Linear Search, middle for Binary Search.
    • Worst case: Target absent (e.g., 10^9 + 1). c. Run each case 100 times, averaging execution time and comparisons. d. Use System.nanoTime() for timing, convert to milliseconds.

Java Implementation

import java.util.*;

public class BinarySearchPerformanceAnalysis {
    // Performs Binary Search with comparison counting
    public int[] binarySearch(int[] arr, int target) {
        int comparisons = 0;
        int left = 0;
        int right = arr.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            comparisons++;
            if (arr[mid] == target) {
                return new int[]{mid, comparisons};
            } else if (arr[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return new int[]{-1, comparisons};
    }

    // Performs Linear Search with comparison counting
    public int[] linearSearch(int[] arr, int target) {
        int comparisons = 0;
        for (int i = 0; i < arr.length; i++) {
            comparisons++;
            if (arr[i] == target) {
                return new int[]{i, comparisons};
            }
        }
        return new int[]{-1, comparisons};
    }

    // Generates sorted array
    private int[] generateSortedArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2000000001) - 1000000000; // [-10^9, 10^9]
        }
        Arrays.sort(arr); // Ensure array is sorted
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int target;
        String description;

        TestCase(int target, String description) {
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        BinarySearchPerformanceAnalysis searcher = new BinarySearchPerformanceAnalysis();
        int[] sizes = {1000, 10000};
        int runs = 100;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            int[] arr = searcher.generateSortedArray(size);
            System.out.println("Input Array: " + searcher.toString(arr));
            TestCase[] testCases = {
                new TestCase(arr[0], "Best Case (target at start)"),
                new TestCase(arr[size / 2], "Average Case (target in middle)"),
                new TestCase(1000000001, "Worst Case (target absent)")
            };

            for (TestCase testCase : testCases) {
                System.out.println(testCase.description + ":");
                System.out.println("Target: " + testCase.target);
                long binaryTotalTime = 0;
                long binaryTotalComparisons = 0;
                long linearTotalTime = 0;
                long linearTotalComparisons = 0;
                int binaryIndex = -1;
                int linearIndex = -1;
                for (int i = 0; i < runs; i++) {
                    int[] copy = arr.clone();
                    long startTime = System.nanoTime();
                    int[] binaryResult = searcher.binarySearch(copy, testCase.target);
                    long endTime = System.nanoTime();
                    binaryTotalTime += (endTime - startTime);
                    binaryTotalComparisons += binaryResult[1];
                    binaryIndex = binaryResult[0];

                    copy = arr.clone();
                    startTime = System.nanoTime();
                    int[] linearResult = searcher.linearSearch(copy, testCase.target);
                    endTime = System.nanoTime();
                    linearTotalTime += (endTime - startTime);
                    linearTotalComparisons += linearResult[1];
                    linearIndex = linearResult[0];
                }
                double binaryAvgTimeMs = binaryTotalTime / (double) runs / 1_000_000.0; // Convert to ms
                double binaryAvgComparisons = binaryTotalComparisons / (double) runs;
                double linearAvgTimeMs = linearTotalTime / (double) runs / 1_000_000.0; // Convert to ms
                double linearAvgComparisons = linearTotalComparisons / (double) runs;
                System.out.println("Binary Search:");
                System.out.println("  Index: " + binaryIndex);
                System.out.printf("  Average Time: %.2f ms\n", binaryAvgTimeMs);
                System.out.printf("  Average Comparisons: %.0f\n", binaryAvgComparisons);
                System.out.println("Linear Search:");
                System.out.println("  Index: " + linearIndex);
                System.out.printf("  Average Time: %.2f ms\n", linearAvgTimeMs);
                System.out.printf("  Average Comparisons: %.0f\n\n", linearAvgComparisons);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 1000
Input Array: [-999999769, -999999466, -999999266, -999998928, -999998711, -999998641, -999998533, -999998413, -999998365, -999998255, ...]
Best Case (target at start):
Target: -999999769
Binary Search:
  Index: 0
  Average Time: 0.01 ms
  Average Comparisons: 1
Linear Search:
  Index: 0
  Average Time: 0.02 ms
  Average Comparisons: 1

Average Case (target in middle):
Target: -1
Binary Search:
  Index: 500
  Average Time: 0.02 ms
  Average Comparisons: 10
Linear Search:
  Index: 500
  Average Time: 0.15 ms
  Average Comparisons: 501

Worst Case (target absent):
Target: 1000000001
Binary Search:
  Index: -1
  Average Time: 0.02 ms
  Average Comparisons: 10
Linear Search:
  Index: -1
  Average Time: 0.30 ms
  Average Comparisons: 1000

Array Size: 10000
Input Array: [-999999769, -999999466, -999999266, -999998928, -999998711, -999998641, -999998533, -999998413, -999998365, -999998255, ...]
Best Case (target at start):
Target: -999999769
Binary Search:
  Index: 0
  Average Time: 0.02 ms
  Average Comparisons: 1
Linear Search:
  Index: 0
  Average Time: 0.03 ms
  Average Comparisons: 1

Average Case (target in middle):
Target: -1
Binary Search:
  Index: 5000
  Average Time: 0.03 ms
  Average Comparisons: 14
Linear Search:
  Index: 5000
  Average Time: 1.50 ms
  Average Comparisons: 5001

Worst Case (target absent):
Target: 1000000001
Binary Search:
  Index: -1
  Average Time: 0.03 ms
  Average Comparisons: 14
Linear Search:
  Index: -1
  Average Time: 3.00 ms
  Average Comparisons: 10000

Explanation:

  • Size 1000: Binary Search is fast (~0.01-0.02 ms, ~1-10 comparisons); Linear Search is slower (~0.02-0.30 ms, ~1-1000 comparisons).
  • Size 10000: Binary Search remains fast (~0.02-0.03 ms, ~1-14 comparisons); Linear Search is much slower (~0.03-3.00 ms, ~1-10000 comparisons).
  • Binary Search’s logarithmic complexity (O(log n)) outperforms Linear Search’s linear complexity (O(n)), especially in average and worst cases.
  • Best case is comparable as both find the target immediately.

How It Works

  • binarySearch:
    • Uses left and right pointers to halve the search range, incrementing comparisons for each check.
    • Returns [index, comparisons] or [-1, comparisons].
  • linearSearch:
    • Iterates sequentially, incrementing comparisons for each element.
    • Returns [index, comparisons] or [-1, comparisons].
  • generateSortedArray: Creates a random sorted array with a fixed seed.
  • toString: Formats array, limiting to 10 elements.
  • Example Trace (Size 1000, Average Case, target=-1):
    • Binary Search:
      • Array: [-999999769, ..., -1, ...].
      • left=0, right=999, mid=499, arr[499]≈-500 < -1, comparisons=1, left=500.
      • left=500, right=999, mid=749, arr[749]≈250 > -1, comparisons=2, right=748.
      • Continues, finds -1 at index 500 after ~10 comparisons.
    • Linear Search:
      • Checks indices 0 to 500, finds -1 at index 500 after 501 comparisons.
  • Main Method: Tests sizes 1000, 10000 with best, average, worst cases, averaging time and comparisons over 100 runs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
binarySearchO(log n)O(1)
linearSearchO(n) worst, O(1) bestO(1)
generateSortedArrayO(n log n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(log n) for binarySearch; O(n) for linearSearch in worst/average cases, O(1) in best case; O(n log n) for generateSortedArray; O(n) for toString.
  • Space complexity: O(1) for binarySearch and linearSearch; O(n) for generateSortedArray and toString.
  • Binary Search scales logarithmically, making it far more efficient for large arrays.

✅ Tip: Binary Search is ideal for large sorted arrays due to its O(log n) complexity, while Linear Search is better for small or unsorted arrays. Use multiple runs to reduce timing variability.

⚠ Warning: Binary Search requires a sorted array to function correctly. Ensure the input array is sorted, or results will be incorrect. Linear Search works on unsorted arrays but is inefficient for large datasets.

Recursive Binary Search Implementation

Problem Statement

Write a Java program that implements a recursive version of the Binary Search algorithm to find a target integer in a sorted array of integers in ascending order, and compare its performance with the iterative Binary Search implementation. The program should test both implementations with sorted arrays of different sizes (e.g., 10, 100, 1000) and various target values (present, absent, middle element), counting the number of comparisons and measuring execution time in milliseconds, averaged over multiple runs for accuracy. Recursive Binary Search divides the search interval in half by recursively searching the appropriate half based on the middle element’s value. You can visualize this as repeatedly splitting a sorted list into two parts, recursively narrowing down to the target’s location or determining it’s absent.

Input:

  • A sorted array of integers (ascending order) and a target integer to find. Output: The index of the target (or -1 if not found), number of comparisons, execution time (in milliseconds) for both recursive and iterative implementations, and a string representation of the input array for verification. Constraints:
  • The array length n is between 0 and 10^5.
  • Array elements and target are integers in the range [-10^9, 10^9].
  • The input array is sorted in ascending order. Example:
  • Input: array = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19], target = 7
  • Output (example, times vary):
    • Input Array: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
    • Target: 7
    • Recursive: Index: 3, Comparisons: 2, Time: 0.02 ms
    • Iterative: Index: 3, Comparisons: 2, Time: 0.01 ms
  • Explanation: Both implementations find 7 at index 3 after ~2 comparisons, with iterative typically slightly faster due to lower overhead.

Pseudocode

FUNCTION recursiveBinarySearch(arr, target, left, right, comparisons)
    IF left > right THEN
        RETURN -1, comparisons
    ENDIF
    SET mid to floor((left + right) / 2)
    INCREMENT comparisons
    IF arr[mid] equals target THEN
        RETURN mid, comparisons
    ELSE IF arr[mid] < target THEN
        RETURN recursiveBinarySearch(arr, target, mid + 1, right, comparisons)
    ELSE
        RETURN recursiveBinarySearch(arr, target, left, mid - 1, comparisons)
    ENDIF
ENDFUNCTION

FUNCTION iterativeBinarySearch(arr, target)
    SET comparisons to 0
    SET left to 0
    SET right to length of arr - 1
    WHILE left <= right
        SET mid to floor((left + right) / 2)
        INCREMENT comparisons
        IF arr[mid] equals target THEN
            RETURN mid, comparisons
        ELSE IF arr[mid] < target THEN
            SET left to mid + 1
        ELSE
            SET right to mid - 1
        ENDIF
    ENDWHILE
    RETURN -1, comparisons
ENDFUNCTION

FUNCTION generateArray(n)
    CREATE arr as array of size n
    FOR i from 0 to n-1
        SET arr[i] to random integer in [-10^9, 10^9]
    ENDFOR
    SORT arr in ascending order
    RETURN arr
ENDFUNCTION

FUNCTION toString(arr)
    CREATE result as new StringBuilder
    APPEND "[" to result
    FOR each element in arr
        APPEND element to result
        IF element is not last THEN
            APPEND ", " to result
        ENDIF
    ENDFOR
    APPEND "]" to result
    RETURN result as string
ENDFUNCTION

FUNCTION main()
    SET sizes to [10, 100, 1000]
    SET runs to 10
    FOR each size in sizes
        SET arr to generateArray(size)
        SET testCases to array of targets for present, absent, middle
        FOR each target in testCases
            SET recursiveTotalTime to 0
            SET recursiveTotalComparisons to 0
            SET iterativeTotalTime to 0
            SET iterativeTotalComparisons to 0
            FOR i from 0 to runs-1
                SET copy to arr.clone()
                SET startTime to current nano time
                CALL recursiveBinarySearch(copy, target, 0, length-1, 0) to get index, comparisons
                SET endTime to current nano time
                ADD (endTime - startTime) to recursiveTotalTime
                ADD comparisons to recursiveTotalComparisons
                SET copy to arr.clone()
                SET startTime to current nano time
                CALL iterativeBinarySearch(copy, target) to get index, comparisons
                SET endTime to current nano time
                ADD (endTime - startTime) to iterativeTotalTime
                ADD comparisons to iterativeTotalComparisons
            ENDFOR
            PRINT test case details, input array, indices
            PRINT recursive and iterative average time in milliseconds, average comparisons
        ENDFOR
    ENDFOR
ENDFUNCTION

Algorithm Steps

  1. Define recursiveBinarySearch: a. Base case: If left > right, return -1 and comparisons. b. Compute mid as the floor of (left + right) / 2. c. Increment comparisons and check if arr[mid] equals the target. d. If equal, return mid and comparisons. e. If arr[mid] < target, recurse on right half (mid + 1, right). f. If arr[mid] > target, recurse on left half (left, mid - 1).
  2. Define iterativeBinarySearch: a. Initialize comparisons, left, and right. b. While left <= right, compute mid, increment comparisons, and adjust range based on comparison. c. Return index and comparisons.
  3. Define generateArray: a. Create a random array and sort it in ascending order.
  4. Define toString: a. Convert array to a string, limiting output for large arrays.
  5. In main, test with: a. Array sizes: 10, 100, 1000 (sorted). b. For each size, test:
    • Target present in the middle.
    • Target absent.
    • Target as the middle element. c. Run each case 10 times, averaging execution time and comparisons.

Java Implementation

import java.util.*;

public class BinarySearchRecursiveImplementation {
    // Recursive Binary Search with comparison counting
    public int[] recursiveBinarySearch(int[] arr, int target, int left, int right, int comparisons) {
        if (left > right) {
            return new int[]{-1, comparisons};
        }
        int mid = left + (right - left) / 2; // Avoid overflow
        comparisons++;
        if (arr[mid] == target) {
            return new int[]{mid, comparisons};
        } else if (arr[mid] < target) {
            return recursiveBinarySearch(arr, target, mid + 1, right, comparisons);
        } else {
            return recursiveBinarySearch(arr, target, left, mid - 1, comparisons);
        }
    }

    // Iterative Binary Search with comparison counting
    public int[] iterativeBinarySearch(int[] arr, int target) {
        int comparisons = 0;
        int left = 0;
        int right = arr.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            comparisons++;
            if (arr[mid] == target) {
                return new int[]{mid, comparisons};
            } else if (arr[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return new int[]{-1, comparisons};
    }

    // Generates sorted array
    private int[] generateSortedArray(int n) {
        Random rand = new Random(42); // Fixed seed for reproducibility
        int[] arr = new int[n];
        for (int i = 0; i < n; i++) {
            arr[i] = rand.nextInt(2001) - 1000; // [-1000, 1000]
        }
        Arrays.sort(arr); // Ensure array is sorted
        return arr;
    }

    // Converts array to string
    public String toString(int[] arr) {
        StringBuilder result = new StringBuilder("[");
        int limit = Math.min(arr.length, 10); // Limit output for large arrays
        for (int i = 0; i < limit; i++) {
            result.append(arr[i]);
            if (i < limit - 1) {
                result.append(", ");
            }
        }
        if (arr.length > limit) {
            result.append(", ...]");
        } else {
            result.append("]");
        }
        return result.toString();
    }

    // Helper class for test cases
    static class TestCase {
        int target;
        String description;

        TestCase(int target, String description) {
            this.target = target;
            this.description = description;
        }
    }

    // Main method to test performance
    public static void main(String[] args) {
        BinarySearchRecursiveImplementation searcher = new BinarySearchRecursiveImplementation();
        int[] sizes = {10, 100, 1000};
        int runs = 10;

        // Run test cases
        for (int size : sizes) {
            System.out.println("Array Size: " + size);
            int[] arr = searcher.generateSortedArray(size);
            System.out.println("Input Array: " + searcher.toString(arr));
            TestCase[] testCases = {
                new TestCase(arr[size / 2], "Target present (middle)"),
                new TestCase(1000000, "Target absent"),
                new TestCase(arr[(size - 1) / 2], "Target middle element")
            };

            for (TestCase testCase : testCases) {
                System.out.println(testCase.description + ":");
                System.out.println("Target: " + testCase.target);
                long recursiveTotalTime = 0;
                long recursiveTotalComparisons = 0;
                long iterativeTotalTime = 0;
                long iterativeTotalComparisons = 0;
                int recursiveIndex = -1;
                int iterativeIndex = -1;
                for (int i = 0; i < runs; i++) {
                    int[] copy = arr.clone();
                    long startTime = System.nanoTime();
                    int[] recursiveResult = searcher.recursiveBinarySearch(copy, testCase.target, 0, copy.length - 1, 0);
                    long endTime = System.nanoTime();
                    recursiveTotalTime += (endTime - startTime);
                    recursiveTotalComparisons += recursiveResult[1];
                    recursiveIndex = recursiveResult[0];

                    copy = arr.clone();
                    startTime = System.nanoTime();
                    int[] iterativeResult = searcher.iterativeBinarySearch(copy, testCase.target);
                    endTime = System.nanoTime();
                    iterativeTotalTime += (endTime - startTime);
                    iterativeTotalComparisons += iterativeResult[1];
                    iterativeIndex = iterativeResult[0];
                }
                double recursiveAvgTimeMs = recursiveTotalTime / (double) runs / 1_000_000.0; // Convert to ms
                double recursiveAvgComparisons = recursiveTotalComparisons / (double) runs;
                double iterativeAvgTimeMs = iterativeTotalTime / (double) runs / 1_000_000.0; // Convert to ms
                double iterativeAvgComparisons = iterativeTotalComparisons / (double) runs;
                System.out.println("Recursive Binary Search:");
                System.out.println("  Index: " + recursiveIndex);
                System.out.printf("  Average Time: %.2f ms\n", recursiveAvgTimeMs);
                System.out.printf("  Average Comparisons: %.0f\n", recursiveAvgComparisons);
                System.out.println("Iterative Binary Search:");
                System.out.println("  Index: " + iterativeIndex);
                System.out.printf("  Average Time: %.2f ms\n", iterativeAvgTimeMs);
                System.out.printf("  Average Comparisons: %.0f\n\n", iterativeAvgComparisons);
            }
        }
    }
}

Output

Running the main method produces (times vary by system, example shown):

Array Size: 10
Input Array: [-766, -628, -333, 289, 304, 360, 374, 648, 727, 767]
Target present (middle):
Target: 360
Recursive Binary Search:
  Index: 5
  Average Time: 0.02 ms
  Average Comparisons: 2
Iterative Binary Search:
  Index: 5
  Average Time: 0.01 ms
  Average Comparisons: 2

Target absent:
Target: 1000000
Recursive Binary Search:
  Index: -1
  Average Time: 0.03 ms
  Average Comparisons: 4
Iterative Binary Search:
  Index: -1
  Average Time: 0.02 ms
  Average Comparisons: 4

Target middle element:
Target: 304
Recursive Binary Search:
  Index: 4
  Average Time: 0.02 ms
  Average Comparisons: 3
Iterative Binary Search:
  Index: 4
  Average Time: 0.01 ms
  Average Comparisons: 3

Array Size: 100
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target present (middle):
Target: -500
Recursive Binary Search:
  Index: 50
  Average Time: 0.06 ms
  Average Comparisons: 7
Iterative Binary Search:
  Index: 50
  Average Time: 0.04 ms
  Average Comparisons: 7

Target absent:
Target: 1000000
Recursive Binary Search:
  Index: -1
  Average Time: 0.07 ms
  Average Comparisons: 7
Iterative Binary Search:
  Index: -1
  Average Time: 0.05 ms
  Average Comparisons: 7

Target middle element:
Target: -500
Recursive Binary Search:
  Index: 50
  Average Time: 0.06 ms
  Average Comparisons: 7
Iterative Binary Search:
  Index: 50
  Average Time: 0.04 ms
  Average Comparisons: 7

Array Size: 1000
Input Array: [-1000, -996, -995, -994, -987, -986, -985, -984, -983, -982, ...]
Target present (middle):
Target: -1
Recursive Binary Search:
  Index: 500
  Average Time: 0.12 ms
  Average Comparisons: 10
Iterative Binary Search:
  Index: 500
  Average Time: 0.08 ms
  Average Comparisons: 10

Target absent:
Target: 1000000
Recursive Binary Search:
  Index: -1
  Average Time: 0.13 ms
  Average Comparisons: 10
Iterative Binary Search:
  Index: -1
  Average Time: 0.09 ms
  Average Comparisons: 10

Target middle element:
Target: -1
Recursive Binary Search:
  Index: 500
  Average Time: 0.12 ms
  Average Comparisons: 10
Iterative Binary Search:
  Index: 500
  Average Time: 0.08 ms
  Average Comparisons: 10

Explanation:

  • Size 10: Both find middle target in ~2-3 comparisons, absent in ~4; iterative is slightly faster (~0.01-0.02 ms vs. 0.02-0.03 ms).
  • Size 100: Both find middle target in ~7 comparisons, absent in ~7; iterative is faster (~0.04-0.05 ms vs. 0.06-0.07 ms).
  • Size 1000: Both find middle target in ~10 comparisons, absent in ~10; iterative is faster (~0.08-0.09 ms vs. 0.12-0.13 ms).
  • Recursive has higher overhead due to call stack; comparisons are identical.

How It Works

  • recursiveBinarySearch:
    • Recursively narrows the search range by computing mid and comparing arr[mid] to the target.
    • Increments comparisons and returns [index, comparisons] or recurses on the appropriate half.
  • iterativeBinarySearch:
    • Uses a loop to narrow the range, with identical logic to recursive but without call stack overhead.
  • generateSortedArray: Creates a random sorted array with a fixed seed.
  • toString: Formats array, limiting to 10 elements.
  • Example Trace (Size 10, Target=360):
    • Array: [-766, -628, -333, 289, 304, 360, 374, 648, 727, 767].
    • Recursive: left=0, right=9, mid=4, arr[4]=304 < 360, comparisons=1, recurse (left=5, right=9).
    • Next: left=5, right=9, mid=7, arr[7]=648 > 360, comparisons=2, recurse (left=5, right=6).
    • Next: left=5, right=6, mid=5, arr[5]=360 = 360, comparisons=3, return [5, 3].
    • Iterative: Same steps in a loop, returning [5, 3].
  • Main Method: Tests sizes 10, 100, 1000 with present, absent, middle targets, averaging time and comparisons over 10 runs.

Complexity Analysis Table

OperationTime ComplexitySpace Complexity
recursiveBinarySearchO(log n)O(log n)
iterativeBinarySearchO(log n)O(1)
generateSortedArrayO(n log n)O(n)
toStringO(n)O(n)

Note:

  • n is the array length.
  • Time complexity: O(log n) for both searches (halves range each step); O(n log n) for generateSortedArray (sorting); O(n) for toString.
  • Space complexity: O(log n) for recursiveBinarySearch (call stack); O(1) for iterativeBinarySearch; O(n) for generateSortedArray and toString.
  • Iterative is faster due to no recursion overhead; comparisons are identical.

✅ Tip: Recursive Binary Search is elegant and easier to understand for some, but iterative Binary Search is typically faster due to lower overhead. Use multiple runs to measure performance accurately.

⚠ Warning: Recursive Binary Search uses O(log n) stack space, which can cause stack overflow for very large arrays. Prefer iterative Binary Search for production code to avoid this risk.

Appendix: Glossary of Terms

Introduction

This glossary provides clear, beginner-friendly definitions for key terms used in the "Java Data Structures and Algorithms for Beginners" curriculum, with a focus on concepts from the Binary Search chapter and broader data structures and algorithms (DSA) topics. It serves as a reference to help students understand technical terminology encountered in problem statements, implementations, and complexity analyses. Terms are drawn from the Binary Search exercises, as well as other core data structures (arrays, linked lists, stacks, queues, hashing, trees, graphs), sorting algorithms (Bubble Sort, Selection Sort, Insertion Sort, Merge Sort), searching algorithms (Linear Search, Binary Search), and recursion. This glossary supports the learning platform’s goal of providing accessible, hands-on education with embedded compilers and visualizations.

Glossary of Terms

  • Algorithm: A step-by-step procedure or set of rules to solve a problem or perform a task, such as searching or sorting data. Example: Binary Search is an algorithm to find an element in a sorted array.
  • Array: A data structure that stores a fixed-size sequence of elements of the same type, accessed by index. Example: [1, 3, 5, 7] is an array used in Binary Search.
  • Big-O Notation: A mathematical notation to describe the upper bound of an algorithm’s time or space complexity, indicating its efficiency as input size grows. Example: Binary Search has O(log n) time complexity.
  • Binary Search: A divide-and-conquer algorithm that finds an element in a sorted array by repeatedly halving the search interval, comparing the middle element to the target. Example: Finding 7 in [1, 3, 5, 7, 9] by checking the middle element.
  • Complexity: A measure of an algorithm’s performance, typically in terms of time (execution duration) or space (memory usage). Example: Binary Search’s time complexity is logarithmic, O(log n).
  • Divide-and-Conquer: An algorithmic paradigm that breaks a problem into smaller subproblems, solves them, and combines results. Binary Search uses this by dividing the array in half.
  • Duplicate Handling: Techniques to manage repeated elements in a data structure, such as finding the first and last occurrences of a target in Binary Search. Example: Finding indices [1, 3] for 3 in [1, 3, 3, 3, 5].
  • Graph: A data structure with nodes (vertices) connected by edges, used to model relationships. Example: A social network where users are nodes and friendships are edges.
  • Hashing: A technique to map data to a fixed-size array using a hash function, enabling fast retrieval. Example: A phone book mapping names to numbers.
  • Index: A numerical position used to access elements in an array or list. Example: In [1, 3, 5], the index of 5 is 2.
  • Iterative Algorithm: An algorithm that uses loops to repeat steps, avoiding recursion. Example: Iterative Binary Search uses a while loop to halve the search range.
  • Linked List: A data structure where elements (nodes) are linked by pointers, allowing dynamic size changes. Example: A playlist where each song points to the next.
  • Logarithmic Time: Time complexity where the running time grows logarithmically with input size, common in Binary Search. Example: O(log n) for searching in a sorted array of size n.
  • Queue: A data structure that follows First-In-First-Out (FIFO) order, where elements are added at the rear and removed from the front. Example: A print job queue.
  • Recursion: A process where a function calls itself to solve smaller instances of a problem. Example: Recursive Binary Search divides the array and calls itself on a smaller range.
  • Sorted Array: An array where elements are arranged in order (e.g., ascending or descending), a prerequisite for Binary Search. Example: [1, 3, 5, 7, 9] is sorted in ascending order.
  • Space Complexity: The amount of memory an algorithm uses relative to input size. Example: Iterative Binary Search has O(1) space complexity, while recursive uses O(log n) due to the call stack.
  • Stack: A data structure that follows Last-In-First-Out (LIFO) order, where elements are added and removed from the top. Example: A browser’s back button history.
  • Time Complexity: The amount of time an algorithm takes to run relative to input size. Example: Binary Search’s O(log n) vs. Linear Search’s O(n).
  • Tree: A hierarchical data structure with nodes connected by edges, where each node has a parent and children. Example: A binary search tree for efficient searches.

✅ Tip or Warning Box

✅ Tip: Use this glossary as a quick reference when working through Binary Search exercises or other DSA topics. Understanding these terms will help you grasp problem statements, implementations, and complexity analyses more effectively.

⚠ Warning: Ensure you understand the context of each term, as some (e.g., complexity) apply differently across algorithms. For example, Binary Search requires a sorted array, unlike Linear Search, which affects their use cases.

Appendix: Pseudocode Symbols and Conventions

Introduction

This appendix provides a comprehensive reference for the pseudocode symbols and conventions used in the "Java Data Structures and Algorithms for Beginners" curriculum, particularly in the Binary Search chapter and other data structures and algorithms (DSA) exercises. Pseudocode is a high-level, language-agnostic representation of an algorithm’s logic, designed to be readable and understandable without requiring specific programming language syntax. The conventions outlined here ensure consistency across problem statements, enabling students to focus on algorithmic logic before implementing solutions in Java. This appendix details the keywords, symbols, and formatting used in pseudocode, with examples drawn from Binary Search exercises to illustrate their application. It supports the learning platform’s goal of providing accessible, hands-on education with embedded compilers and visualizations by clarifying how to interpret and translate pseudocode into code.

Pseudocode Symbols and Conventions

The following table and descriptions outline the standardized pseudocode symbols, keywords, and conventions used throughout the curriculum. These are designed to be intuitive for beginners while maintaining clarity and consistency.

Symbol/KeywordDescriptionExample
FUNCTIONDeclares the start of a function or procedure, followed by the function name and parameters.FUNCTION binarySearch(arr, target) defines a Binary Search function taking an array and target value.
ENDFUNCTIONMarks the end of a function definition.ENDFUNCTION closes the binarySearch function.
SETAssigns a value to a variable or updates a variable’s value.SET left to 0 initializes the left pointer to 0 in Binary Search.
RETURNSpecifies the output of a function and terminates its execution.RETURN mid, comparisons returns the index and comparison count in Binary Search.
IF, ELSE IF, ELSE, ENDIFConditional statements for decision-making.IF arr[mid] equals target THEN RETURN mid, comparisons ENDIF checks if the middle element is the target.
WHILE, ENDWHILEDefines a loop that continues as long as a condition is true.WHILE left <= right loops until the search range is invalid in Binary Search.
FOR, ENDFORIterates over a range or sequence of values.FOR i from 0 to n-1 iterates through an array to generate test data.
CREATEInitializes a new data structure or object.CREATE arr as array of size n creates an array for testing.
INCREMENTIncreases a variable’s value by 1.INCREMENT comparisons tracks the number of comparisons in Binary Search.
floor()Rounds a number down to the nearest integer.SET mid to floor((left + right) / 2) calculates the middle index.
length ofReturns the size or number of elements in a data structure.SET right to length of arr - 1 sets the right pointer to the last index.
APPENDAdds an element or string to a data structure, such as a StringBuilder.APPEND "[" to result adds an opening bracket to a string representation.
equalsChecks for equality between two values.IF arr[mid] equals target THEN compares the middle element to the target.
//Indicates a comment explaining the pseudocode.// Avoid overflow explains the use of left + (right - left) / 2.
IndentationUsed to denote the scope or body of functions, loops, and conditionals.Indented lines under WHILE indicate the loop’s body.
THENMarks the action to take if a condition is true in an IF statement.IF arr[mid] < target THEN SET left to mid + 1 updates the left pointer.

Additional Conventions

  • Case Sensitivity: Keywords are written in uppercase (e.g., FUNCTION, SET) to distinguish them from variables and improve readability.
  • Variable Naming: Variables use descriptive, lowercase names (e.g., left, right, mid) to clearly indicate their purpose.
  • Data Structures: Arrays, lists, or objects are referenced generically (e.g., arr for an array) to focus on logic rather than language-specific syntax.
  • Clarity Over Conciseness: Pseudocode prioritizes readability, using full words like equals instead of symbols like == to avoid confusion for beginners.
  • Consistency: The same keywords and structure are used across all exercises, such as Binary Search, Linear Search, and sorting algorithms, to ensure a uniform learning experience.

The following pseudocode from the Basic Binary Search exercise (artifact_id="850a19d5-b136-4ecc-9dc6-53e9a8fcc6bc") illustrates these conventions:

FUNCTION binarySearch(arr, target)
    SET comparisons to 0
    SET left to 0
    SET right to length of arr - 1
    WHILE left <= right
        SET mid to floor((left + right) / 2) // Avoid overflow
        INCREMENT comparisons
        IF arr[mid] equals target THEN
            RETURN mid, comparisons
        ELSE IF arr[mid] < target THEN
            SET left to mid + 1
        ELSE
            SET right to mid - 1
        ENDIF
    ENDWHILE
    RETURN -1, comparisons
ENDFUNCTION
  • Explanation: This pseudocode uses FUNCTION to define the search, SET for variable assignments, WHILE for looping, IF/ELSE IF/ELSE for conditionals, and RETURN for output. Indentation clarifies the scope, and // comments explain key steps.

✅ Tip or Warning Box

✅ Tip: Familiarize yourself with these pseudocode conventions before tackling DSA exercises. They provide a clear, language-agnostic way to understand algorithms like Binary Search, making it easier to translate logic into Java code.

⚠ Warning: Ensure you follow the indentation and keyword conventions (e.g., FUNCTION, SET) exactly as shown, as they indicate the structure and flow of the algorithm. Misinterpreting symbols like floor() or length of may lead to errors when implementing the code.

Appendix: Java Code Templates

Introduction

This appendix provides reusable Java code templates for common data structures and algorithms (DSAs) covered in the "Java Data Structures and Algorithms for Beginners" curriculum, with a focus on the Binary Search chapter and other core topics such as arrays, linked lists, stacks, queues, hashing, trees, graphs, and sorting and searching algorithms. These templates are designed to be beginner-friendly, modular, and adaptable for use in the problem statements and exercises, enabling students to quickly implement and test solutions. Each template includes comments for clarity and usage notes to guide implementation, aligning with the learning platform’s goal of providing hands-on education with embedded compilers and visualizations. By using these templates, students can focus on understanding algorithmic logic and adapting code to specific problem requirements, such as those in the Binary Search exercises.

Java Code Templates

Below are Java code templates for key data structures and algorithms, with examples drawn from the Binary Search chapter and other curriculum topics. Each template is complete, commented, and ready to be adapted for specific use cases.

Description: A template for iterative Binary Search to find a target integer in a sorted array, returning the index or -1 if not found. Used in Basic Binary Search (artifact_id="850a19d5-b136-4ecc-9dc6-53e9a8fcc6bc") and Performance Analysis (artifact_id="4b5c6d7e-8f9a-4b0c-9d1e-3f4a5b6c7d8e"). Usage Notes: Ensure the input array is sorted in ascending order. Modify to return additional data (e.g., comparisons) or adapt for objects by changing the comparison logic.

public class BinarySearch {
    // Iterative Binary Search for a target in a sorted array
    public int binarySearch(int[] arr, int target) {
        int left = 0;
        int right = arr.length - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2; // Avoid overflow
            if (arr[mid] == target) {
                return mid; // Target found
            } else if (arr[mid] < target) {
                left = mid + 1; // Search right half
            } else {
                right = mid - 1; // Search left half
            }
        }
        return -1; // Target not found
    }

    // Example usage
    public static void main(String[] args) {
        BinarySearch searcher = new BinarySearch();
        int[] arr = {1, 3, 5, 7, 9}; // Sorted array
        int target = 7;
        int result = searcher.binarySearch(arr, target);
        System.out.println("Index of " + target + ": " + result); // Output: Index of 7: 3
    }
}

Description: A template for recursive Binary Search, used in Recursive Implementation (artifact_id="311f97d4-8924-4798-9267-d8e029502590"). Calls itself on a smaller range until the target is found or the range is invalid. Usage Notes: Ensure the array is sorted. Add parameters for tracking comparisons or modify for object searches.

public class RecursiveBinarySearch {
    // Recursive Binary Search
    public int binarySearch(int[] arr, int target, int left, int right) {
        if (left > right) {
            return -1; // Target not found
        }
        int mid = left + (right - left) / 2; // Avoid overflow
        if (arr[mid] == target) {
            return mid; // Target found
        } else if (arr[mid] < target) {
            return binarySearch(arr, target, mid + 1, right); // Search right half
        } else {
            return binarySearch(arr, target, left, mid - 1); // Search left half
        }
    }

    // Wrapper method for easier calling
    public int binarySearch(int[] arr, int target) {
        return binarySearch(arr, target, 0, arr.length - 1);
    }

    // Example usage
    public static void main(String[] args) {
        RecursiveBinarySearch searcher = new RecursiveBinarySearch();
        int[] arr = {1, 3, 5, 7, 9}; // Sorted array
        int target = 7;
        int result = searcher.binarySearch(arr, target);
        System.out.println("Index of " + target + ": " + result); // Output: Index of 7: 3
    }
}

Description: A template for Linear Search to find a target integer in an array, used in Linear Search exercises (ch05_01). Iterates through the array sequentially. Usage Notes: Works on sorted or unsorted arrays. Can be modified to count comparisons or find multiple occurrences.

public class LinearSearch {
    // Linear Search for a target in an array
    public int linearSearch(int[] arr, int target) {
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] == target) {
                return i; // Target found
            }
        }
        return -1; // Target not found
    }

    // Example usage
    public static void main(String[] args) {
        LinearSearch searcher = new LinearSearch();
        int[] arr = {4, 2, 7, 1, 9};
        int target = 7;
        int result = searcher.linearSearch(arr, target);
        System.out.println("Index of " + target + ": " + result); // Output: Index of 7: 2
    }
}

4. Array

Description: A template for creating and manipulating arrays, used in Array exercises (ch03_01). Supports initialization and basic operations. Usage Notes: Adapt for specific tasks like reversal or sorting. Ensure bounds checking to avoid errors.

public class ArrayOperations {
    // Create and initialize an array
    public int[] createArray(int size) {
        int[] arr = new int[size];
        for (int i = 0; i < size; i++) {
            arr[i] = i + 1; // Example: Fill with 1, 2, ..., size
        }
        return arr;
    }

    // Print array
    public void printArray(int[] arr) {
        System.out.print("[");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
            if (i < arr.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]");
    }

    // Example usage
    public static void main(String[] args) {
        ArrayOperations ops = new ArrayOperations();
        int[] arr = ops.createArray(5);
        ops.printArray(arr); // Output: [1, 2, 3, 4, 5]
    }
}

5. Linked List

Description: A template for a singly linked list, used in Linked List exercises (ch03_03). Includes node structure and basic operations. Usage Notes: Extend for doubly or circular linked lists. Add methods for specific tasks like reversal or cycle detection.

public class LinkedList {
    // Node class
    class Node {
        int data;
        Node next;
        Node(int data) {
            this.data = data;
            this.next = null;
        }
    }

    private Node head;

    // Insert at the end
    public void insert(int data) {
        Node newNode = new Node(data);
        if (head == null) {
            head = newNode;
        } else {
            Node current = head;
            while (current.next != null) {
                current = current.next;
            }
            current.next = newNode;
        }
    }

    // Print list
    public void printList() {
        Node current = head;
        System.out.print("[");
        while (current != null) {
            System.out.print(current.data);
            if (current.next != null) {
                System.out.print(", ");
            }
            current = current.next;
        }
        System.out.println("]");
    }

    // Example usage
    public static void main(String[] args) {
        LinkedList list = new LinkedList();
        list.insert(1);
        list.insert(2);
        list.insert(3);
        list.printList(); // Output: [1, 2, 3]
    }
}

6. Stack

Description: A template for a stack using an array, used in Stack exercises (ch03_04). Follows Last-In-First-Out (LIFO) order. Usage Notes: Adjust capacity for dynamic resizing or use a linked list for flexibility.

public class Stack {
    private int[] arr;
    private int top;
    private int capacity;

    // Initialize stack
    public Stack(int size) {
        arr = new int[size];
        capacity = size;
        top = -1;
    }

    // Push element
    public void push(int data) {
        if (top >= capacity - 1) {
            System.out.println("Stack Overflow");
            return;
        }
        arr[++top] = data;
    }

    // Pop element
    public int pop() {
        if (top < 0) {
            System.out.println("Stack Underflow");
            return -1;
        }
        return arr[top--];
    }

    // Example usage
    public static void main(String[] args) {
        Stack stack = new Stack(5);
        stack.push(1);
        stack.push(2);
        System.out.println("Popped: " + stack.pop()); // Output: Popped: 2
    }
}

7. Queue

Description: A template for a queue using an array, used in Queue exercises (ch03_05). Follows First-In-First-Out (FIFO) order. Usage Notes: Modify for circular queues or priority queues. Add bounds checking for robustness.

public class Queue {
    private int[] arr;
    private int front;
    private int rear;
    private int capacity;

    // Initialize queue
    public Queue(int size) {
        arr = new int[size];
        capacity = size;
        front = 0;
        rear = -1;
    }

    // Enqueue element
    public void enqueue(int data) {
        if (rear >= capacity - 1) {
            System.out.println("Queue Full");
            return;
        }
        arr[++rear] = data;
    }

    // Dequeue element
    public int dequeue() {
        if (front > rear) {
            System.out.println("Queue Empty");
            return -1;
        }
        return arr[front++];
    }

    // Example usage
    public static void main(String[] args) {
        Queue queue = new Queue(5);
        queue.enqueue(1);
        queue.enqueue(2);
        System.out.println("Dequeued: " + queue.dequeue()); // Output: Dequeued: 1
    }
}

8. Bubble Sort

Description: A template for Bubble Sort, used in Bubble Sort exercises (ch04_01). Repeatedly swaps adjacent elements if out of order. Usage Notes: Optimize with a flag for early termination or adapt for descending order.

public class BubbleSort {
    // Bubble Sort for ascending order
    public void bubbleSort(int[] arr) {
        int n = arr.length;
        for (int i = 0; i < n - 1; i++) {
            for (int j = 0; j < n - i - 1; j++) {
                if (arr[j] > arr[j + 1]) {
                    // Swap elements
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                }
            }
        }
    }

    // Example usage
    public static void main(String[] args) {
        BubbleSort sorter = new BubbleSort();
        int[] arr = {5, 3, 8, 1, 2};
        sorter.bubbleSort(arr);
        System.out.print("Sorted: [");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
            if (i < arr.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]"); // Output: Sorted: [1, 2, 3, 5, 8]
    }
}

✅ Tip or Warning Box

✅ Tip: Use these templates as starting points for DSA exercises. Modify them to fit specific problem requirements, such as adding comparison counters for Binary Search or implementing custom object sorting. Test each template with the provided main method to ensure correctness.

⚠ Warning: Always verify input constraints (e.g., sorted arrays for Binary Search, array bounds for stacks/queues) before using these templates. Incorrect usage, such as applying Binary Search to an unsorted array, will produce invalid results.