Uploaded by Paulina Siemińska

1-3 skiena algorithms manual

advertisement
Examples of operations involving square roots and their properties:
•
•
Square Root of a Product:
√(ab) = √a * √b
•
•
Square Root of a Quotient:
√(a/b) = √a / √b
•
•
Square Root of a Square:
√(a^2) = |a|
•
•
Square Root of a Sum (This cannot be simplified in general):
√(a + b)
Square roots can not be simplified
While some algebraic manipulations can be done with square roots, they do not have the same kind
of logarithmic properties that allow direct substitution and simplification like in the case of
logarithms: log(n^2) = 2 * log(n)
For example, √(n^2) is not equal to n;
it's equal to |n| (the absolute value of n),
and this absolute value cannot be simplified in the same way as logarithms.
Logarithmic Power Rule:
logₐ(b^c) = c * logₐ(b)
When you have a logarithm of a number raised to a power, you can bring that power down as
a coefficient in front of the logarithm.
For example:
• log₃(2^4) = 4 * log₃(2)
• log₁₀(5^2) = 2 * log₁₀(5)
• ln(e^6) = 6 * ln(e), where ln represents the natural logarithm and e is Euler's number.
This rule is particularly useful for simplifying complex logarithmic expressions involving
exponentials.
Part 1. Analysis of Algorithms
True or false:
Let's analyze each statement:
(a) 2n^2 + 1 = O(n^2)
•
This statement is true. The term 2n^2 + 1 grows at most quadratically with n, so it is indeed
bounded by O(n^2).
(b) √n = O(log n)
•
This statement is false. The square root of n (√n) grows slower than logarithmically, so √n is
not bounded by O(log n).
(c) log n = O(√n)
•
This statement is false. Logarithmic growth is slower than the growth of a square root, so log
n is not bounded by O(√n).
(d) n^2(1 + √n) = O(n^2 log n)
•
This statement is true. The term n^2(1 + √n) does not grow faster than n^2 log n for
sufficiently large n, so it is bounded by O(n^2 log n).
(e) 3n^2 + √n = O(n^2)
•
This statement is true. The term 3n^2 + √n grows at most quadratically with n, so it is
bounded by O(n^2).
(f) √n log n = O(n)
•
This statement is true. The term √n log n grows slower than linearly with n, so it is bounded
by O(n).
(g) log n = O(n^(-1/2))
•
This statement is true. Logarithmic growth is slower than the growth of n raised to the power
of -1/2, so log n is indeed bounded by O(n^(-1/2)).
Part 3 Data structures
3.9 Array ordered and unordered
Unordered array
In this example:
•
We define the UnorderedArray class with an array to hold the elements and a size variable to
keep track of the number of elements in the array.
•
The add function adds an element to the end of the array if there is space available.
•
The remove function searches for the given value in the array and removes it if found by
replacing it with the last element.
•
The search function iterates through the array to find the given value and returns true if
found, otherwise false.
•
In the main method, we demonstrate how to use the UnorderedArray class by adding
elements, searching for elements, and removing elements.
Note that this is a simple example of an unordered array, and its performance for search and remove
operations is linear (O(n)), as it requires iterating through the entire array in the worst case. If you
require better search and removal performance, other data structures like hash tables or balanced
trees might be more suitable.
Example of unordered array:
public class UnorderedArray {
private int[] array;
private int size;
public UnorderedArray(int capacity) {
array = new int[capacity];
size = 0;
}
// Function to add an element to the array
public void add(int value) {
if (size < array.length) {
array[size] = value;
size++;
} else {
System.out.println("Array is full, cannot add more elements.");
}
}
// Function to remove an element from the array
public void remove(int value) {
int index = -1;
for (int i = 0; i < size; i++) {
if (array[i] == value) {
index = i;
break;
}
}
if (index != -1) {
// Replace the element to be removed with the last element
array[index] = array[size - 1];
size--;
} else {
System.out.println("Element not found in the array.");
}
}
// Function to search for an element in the array
public boolean search(int value) {
for (int i = 0; i < size; i++) {
if (array[i] == value) {
return true;
}
}
return false;
}
public static void main(String[] args) {
UnorderedArray arr = new UnorderedArray(5);
arr.add(10);
arr.add(20);
arr.add(30);
System.out.println("Search 20: " + arr.search(20)); // Output: true
System.out.println("Search 25: " + arr.search(25)); // Output: false
arr.remove(20);
System.out.println("Search 20 after remove: " + arr.search(20)); // Output:
false
}
}
Unordered arrays are typically used in situations where you need a simple and straightforward
collection of elements, and you don't require fast search or removal operations.
Here are some scenarios where unordered arrays might be suitable:
•
Small Collections: When dealing with a small number of elements, the overhead of more
complex data structures (like hash tables or trees) might not be justified. Unordered arrays can
be sufficient and easy to manage in such cases.
•
Preserving Order: If you want to maintain the order in which elements were added, an
unordered array is suitable. Other data structures like hash tables do not guarantee element
order.
•
Simple Implementation: Unordered arrays have a simple implementation and are easy to
understand and use. They are a good choice when the complexity of other data structures is
unnecessary.
•
Limited Operations: If you only need to add elements and iterate through them, an
unordered array can be efficient for these basic operations.
Limitations:
•
Search Performance: Searching for an element in an unordered array requires iterating
through all elements, resulting in linear time complexity (O(n)). For larger collections, this
can be slow.
•
Removal Performance: Removing an element from an unordered array also involves linear
time complexity (O(n)) since you need to find the element first and then rearrange the array.
•
Insertion Performance: While adding elements to the end of an unordered array is fast
(O(1)), inserting elements in the middle requires shifting all subsequent elements, resulting in
linear time complexity (O(n)).
•
Memory Waste: Unordered arrays might have unused memory slots if the size of the array is
larger than the number of elements it holds.
Given these limitations, if your application requires frequent search, removal, or insertion
operations, or if you are dealing with larger collections, other data structures like hash tables,
trees, or linked lists might be more suitable to achieve better time complexity for these operations.
An ordered array.
An ordered array, also known as a sorted array, is an array in which the elements are arranged in a
specific order, usually in ascending or descending order. Unlike an unordered array, where the
elements are placed in arbitrary positions, an ordered array maintains a clear ordering of its
elements.
Here's how you can use an ordered array:
1. **Searching:** Searching for an element in an ordered array can be more efficient
compared to an unordered array. You can use techniques like binary search to quickly locate an
element, resulting in a time complexity of O(log n) instead of O(n) in the worst case.
2. **Insertion and Removal:** Inserting and removing elements in an ordered array can be more
complex. When inserting an element, you need to find the appropriate position while maintaining
the order. Similarly, when removing an element, you must adjust the remaining elements to fill the
gap.
3. **Maintaining Order:** If the order of elements is important for your application, an ordered
array is a good choice. This is useful when you need to retrieve elements in a specific order or
perform range-based operations.
4. **Efficient Range Queries:** Because the elements are ordered, you can efficiently perform
range-based queries. For example, finding all elements within a certain range requires only a few
comparisons.
5. **Memory Efficiency:** Ordered arrays can have better cache performance compared to
unordered arrays, especially when searching, due to their contiguous memory layout.
6. **Simple Implementation:** The concept of ordered arrays is relatively simple and can be
implemented using basic array manipulation algorithms.
Limitations:
- **Insertion and Removal Overhead:** The main drawback of ordered arrays is the overhead
involved in inserting and removing elements. These operations might require shifting a significant
portion of the array.
- **Static Structure:** Once an element is added, it needs to be inserted in the correct position to
maintain the order. This can limit the dynamic nature of your data structure.
- **Balancing:** If you need to maintain order while allowing efficient insertion and removal, data
structures like binary search trees (BSTs) might be a better choice.
In summary, ordered arrays are useful when you frequently perform searches and range
queries, and the order of elements is significant. However, if you need more dynamic operations
or if insertions and removals are common, you might want to consider alternative data structures
like binary search trees or linked lists.
Example of ordered array:
public class OrderedArray {
private int[] array;
private int size;
public OrderedArray(int capacity) {
array = new int[capacity];
size = 0;
}
// Function to search for an element in the ordered array using binary search
public boolean search(int value) {
int left = 0;
int right = size - 1;
while (left <= right) {
int mid = left + (right - left) / 2; // Calculate middle index
if (array[mid] == value) {
return true; // Element found
} else if (array[mid] < value) {
left = mid + 1; // Adjust left boundary
} else {
right = mid - 1; // Adjust right boundary
}
}
return false; // Element not found
}
// Function to insert an element into the ordered array while maintaining order
public void insert(int value) {
int index = size;
while (index > 0 && array[index - 1] > value) {
array[index] = array[index - 1]; // Shift elements to make space for new
element
index--;
}
array[index] = value; // Insert the new element in its correct position
size++;
}
// Function to remove an element from the ordered array
public void remove(int value) {
int index = -1;
for (int i = 0; i < size; i++) {
if (array[i] == value) {
index = i;
break;
}
}
if (index != -1) {
// Shift subsequent elements to fill the gap left by the removed element
for (int i = index; i < size - 1; i++) {
array[i] = array[i + 1];
}
size--;
} else {
System.out.println("Element not found in the array.");
}
}
public static void main(String[] args) {
OrderedArray arr = new OrderedArray(10);
arr.insert(10);
arr.insert(20);
arr.insert(15);
System.out.println("Search 15: " + arr.search(15)); // Output: true
System.out.println("Search 25: " + arr.search(25)); // Output: false
arr.remove(20);
System.out.println("Search 20 after remove: " + arr.search(20)); // Output:
false
}
}
Linked List
An unordered linked list is a basic data structure where elements, often referred to as nodes, are
stored in a linear sequence. Unlike arrays, linked lists do not require contiguous memory allocation;
each node contains data and a reference (or link) to the next node in the sequence. In an unordered
linked list, the order of insertion doesn't necessarily determine the order of traversal or retrieval.
Here are some key characteristics of an unordered linked list:
1. **Node Structure:** Each node in the linked list contains two parts: the data (or value) it
holds and a reference (or link) to the next node in the sequence. The last node typically has a
reference to `null`, indicating the end of the list.
2. **Dynamic Size:** Linked lists can grow or shrink dynamically without needing to allocate a
fixed amount of memory upfront. This contrasts with arrays, which often require resizing if more
elements are added than initially allocated.
3. **Insertion and Removal:** Inserting and removing elements in a linked list is relatively
efficient, especially when adding or removing elements at the beginning or end of the list. Inserting
or removing elements in the middle of the list may require traversing to the desired position, which
can take linear time.
4. **Memory Overhead:** Linked lists have a bit more memory overhead than arrays because
each node requires memory not only for the data but also for the reference to the next node.
5. **Search Performance:** Searching for an element in an unordered linked list is generally
inefficient, as it requires traversing the list one node at a time, resulting in linear time complexity
(O(n)).
6. **Ordering:** In an unordered linked list, the order of insertion does not necessarily affect the
order of traversal or retrieval. As a result, if maintaining order is important, you might consider
using an ordered linked list or other data structures.
7. **Implementation Flexibility:** Linked lists can be singly linked (each node points to the next
node) or doubly linked (each node has references to both the previous and next nodes). Doubly
linked lists allow for easier traversal in both directions but require more memory.
When to use it:
- **Simple Data Structures:** If you need a basic data structure without requiring complex
operations like sorting, searching, or range queries, an unordered linked list can be a good choice.
- **Dynamic Growth:** Linked lists are suitable when the number of elements is unknown or
can change frequently.
- **Frequent Insertions and Removals:** Linked lists can be efficient for frequent insertions and
removals, especially when performed at the beginning or end of the list.
- **Memory Allocation Constraints:** If you want to minimize memory allocation overhead and
don't require contiguous memory, linked lists can be advantageous.
However, keep in mind that unordered linked lists are not suitable for scenarios that require fast
search, sorting, or efficient range queries. In those cases, more advanced data structures like
hash tables, binary search trees, or balanced trees might be better options.
Example of unordered Linked List:
class ListNode {
int val;
ListNode next;
public ListNode(int val) {
this.val = val;
this.next = null;
}
}
public class UnorderedLinkedList {
private ListNode head; // Reference to the head of the linked list
// Function to insert a new node at the beginning of the linked list
public void insert(int value) {
ListNode newNode = new ListNode(value); // Create a new node
newNode.next = head; // Set the new node's next to the current head
head = newNode; // Update the head to the new node
}
// Function to remove a node with a specific value from the linked list
public void remove(int value) {
if (head == null) {
return; // Empty list, nothing to remove
}
if (head.val == value) {
head = head.next; // Remove the first node by updating the head
return;
}
ListNode current = head;
while (current.next != null && current.next.val != value) {
current = current.next; // Traverse until the node before the one to be
removed
}
if (current.next != null) {
current.next = current.next.next; // Remove the node by adjusting references
}
}
// Function to search for a specific value in the linked list
public boolean search(int value) {
ListNode current = head;
while (current != null) {
if (current.val == value) {
return true; // Found the value
}
current = current.next; // Move to the next node
}
return false; // Value not found
}
public static void main(String[] args) {
UnorderedLinkedList list = new UnorderedLinkedList();
list.insert(10);
list.insert(20);
list.insert(15);
System.out.println("Search 15: " + list.search(15)); // Output: true
System.out.println("Search 25: " + list.search(25)); // Output: false
list.remove(20);
System.out.println("Search 20 after remove: " + list.search(20)); // Output:
false
}
}
Ordered Linked List
An ordered linked list is a type of linked list where the elements (nodes) are arranged in a specific
order, typically in ascending or descending order based on their values. Unlike unordered linked
lists, where the order of insertion doesn't necessarily determine the order of traversal or retrieval,
ordered linked lists maintain a clear ordering of their elements.
Here are the key characteristics of an ordered linked list:
1. **Node Structure:** Each node in the ordered linked list contains two parts: the data (or
value) it holds and a reference (or link) to the next node in the sequence. Similar to unordered
linked lists, the last node typically has a reference to `null`, indicating the end of the list.
2. **Ordering:** The main feature of an ordered linked list is that its elements are sorted in a
specific order based on their values. This order is maintained even as new elements are added.
3. **Dynamic Size:** Like all linked lists, ordered linked lists can grow or shrink dynamically
without requiring a fixed amount of memory upfront.
4. **Insertion and Removal:** Inserting elements into an ordered linked list requires placing
them in the appropriate position to maintain the order. Similarly, removing elements requires
adjusting the references while preserving the order.
5. **Search Performance:** Searching for an element in an ordered linked list can be more
efficient than in an unordered linked list, especially when using techniques like binary search on
sorted data. Binary search achieves a time complexity of O(log n), unlike the linear search in
unordered linked lists.
6. **Memory Overhead:** As with all linked lists, there's a bit more memory overhead due to the
requirement of memory for both data and references.
7. **Balancing:** Similar to unordered linked lists, you can have singly linked ordered lists (each
node has a reference to the next node) or doubly linked ordered lists (each node has references to
both the previous and next nodes). Doubly linked ordered lists allow easier traversal in both
directions but require more memory.
When to use it:
- **Maintaining Order:** When maintaining the order of elements is crucial for your application,
such as when you need to retrieve elements in a specific order or perform range-based operations.
- **Efficient Searching:** If your main focus is searching for elements, an ordered linked list
(especially with binary search) can provide better performance compared to unordered linked lists.
- **Ordered Insertions:** When you frequently insert elements while preserving the order, an
ordered linked list ensures that the elements stay sorted without additional sorting steps.
Limitations:
- **Insertion and Removal Overhead:** Inserting and removing elements require shifting nodes
and adjusting references, which can be more complex and time-consuming compared to unordered
linked lists.
- **Static Structure:** Once an element is added, it needs to be inserted in its proper position to
maintain the order. This can limit the dynamic nature of your data structure.
- **Dynamic Operations:** If you require dynamic operations like efficient insertion, removal, or
reordering, you might consider more advanced data structures like balanced trees.
In summary, ordered linked lists are useful when maintaining sorted order and searching efficiency
are important, but they might not be the best choice for scenarios that require frequent
insertions, removals, or dynamic reordering.
class ListNode {
int val;
ListNode next;
public ListNode(int val) {
this.val = val;
this.next = null;
}
}
public class OrderedLinkedList {
private ListNode head; // Reference to the head of the linked list
// Function to insert a new node into the ordered linked list while maintaining
order
public void insert(int value) {
ListNode newNode = new ListNode(value); // Create a new node
if (head == null || head.val >= value) {
newNode.next = head;
head = newNode; // Insert at the beginning or when value is smaller than
head
} else {
ListNode current = head;
while (current.next != null && current.next.val < value) {
current = current.next; // Traverse until the correct position
}
newNode.next = current.next; // Insert the new node
current.next = newNode;
}
}
// Function to search for a specific value in the ordered linked list
public boolean search(int value) {
ListNode current = head;
while (current != null) {
if (current.val == value) {
return true; // Found the value
} else if (current.val > value) {
return false; // Value not found (ordered)
}
current = current.next; // Move to the next node
}
return false; // Value not found
}
public static void main(String[] args) {
OrderedLinkedList list = new OrderedLinkedList();
list.insert(20);
list.insert(10);
list.insert(15);
System.out.println("Search 15: " + list.search(15)); // Output: true
System.out.println("Search 25: " + list.search(25)); // Output: false
}
}
In this example:
• The ListNode class represents nodes in the linked list, each holding a value and a reference to
the next node.
• The OrderedLinkedList class maintains the head reference to the first node in the ordered
linked list and provides the insert and search operations.
• The insert operation inserts a new node into the ordered linked list while maintaining the
order of elements.
• The search operation searches for a specific value in the ordered linked list by traversing the
list and comparing values.
The comments provided in the code explain each section, making it easier to understand how the
ordered linked list works and how the insert and search operations are performed.
3.10 Stack, queue and Lists
A stack is a fundamental data structure that follows the Last-In-First-Out (LIFO) principle. In a
stack, elements are added and removed from one end called the "top." The last element added to the
stack is the first one to be removed, hence the name "Last-In-First-Out."
Key characteristics of a stack:
• Operations: Stacks support two main operations:
• Push: Adds an element to the top of the stack.
• Pop: Removes the top element from the stack.
• Peek (or Top): Returns the value of the top element without removing it.
•
Size: Stacks can have a fixed size (bounded) or dynamic size (unbounded).
•
Usage: Stacks are used in various applications, including parsing expressions, implementing
function calls and recursion, managing memory in compilers, tracking program execution
flow, and more.
•
Implementation: Stacks can be implemented using arrays or linked lists. Arrays provide
constant-time access to the top element, but their size may need to be predetermined. Linked
lists provide flexibility in size but require more memory due to node pointers.
Common stack operations:
• Push: Adds an element to the top of the stack. If the stack is full (for a bounded stack), it's an
overflow error.
• Pop: Removes and returns the top element from the stack. If the stack is empty, it's an
underflow error.
• Peek (or Top): Returns the value of the top element without removing it.
• Size: Returns the number of elements in the stack.
• isEmpty: Checks if the stack is empty.
• isFull: Checks if the stack is full (for a bounded stack).
•
Applications of stacks:
•
Expression Evaluation: Stacks are used to evaluate arithmetic expressions, handle
parentheses, and convert infix expressions to postfix or prefix notation.
•
Function Calls: Stacks manage the call hierarchy in programming languages, ensuring proper
function return after execution.
•
Undo and Redo: Stacks can be used to implement undo and redo functionality in
applications.
•
Backtracking: Used in algorithms like depth-first search for graph traversal.
•
Memory Management: Stacks are essential in memory allocation and deallocation in
languages like C and C++.
•
Compiler Syntax Parsing: Stacks help with parsing and evaluating programming language
syntax.
•
Expression Conversion: Stacks are used to convert between different forms of expressions
(e.g., infix to postfix).
In summary, stacks are a crucial concept in computer science and programming, widely used in
various scenarios where you need to maintain a specific order of elements and handle operations in
a Last-In-First-Out manner.
import java.util.EmptyStackException;
public class StackExample {
private int[] stackArray;
private int top;
private int capacity;
public StackExample(int capacity) {
this.capacity = capacity;
stackArray = new int[capacity];
top = -1; // Initialize top to -1 (empty stack)
}
// Function to push an element onto the stack
public void push(int value) {
if (top == capacity - 1) {
System.out.println("Stack is full. Cannot push.");
return;
}
stackArray[++top] = value;
}
// Function to pop an element from the top of the stack
public int pop() {
if (isEmpty()) {
throw new EmptyStackException();
}
return stackArray[top--];
}
// Function to search for a specific value in the stack
public boolean search(int value) {
for (int i = 0; i <= top; i++) {
if (stackArray[i] == value) {
return true;
}
}
return false;
}
// Function to check if the stack is empty
public boolean isEmpty() {
return top == -1;
}
public static void main(String[] args) {
StackExample stack = new StackExample(5);
stack.push(10);
stack.push(20);
stack.push(15);
System.out.println("Search 15: " + stack.search(15)); // Output: true
System.out.println("Search 25: " + stack.search(25)); // Output: false
System.out.println("Pop: " + stack.pop()); // Output: 15
System.out.println("Pop: " + stack.pop()); // Output: 20
}
}
In this example:
•
•
•
•
•
The StackExample class implements a stack using an array, maintaining the top index.
The push operation adds an element to the top of the stack.
The pop operation removes and returns the top element from the stack.
The search operation searches for a specific value in the stack.
The isEmpty function checks if the stack is empty.
In the main method, we demonstrate how to use the StackExample class by pushing elements onto the stack,
searching for values, and popping elements from the stack.
Keep in mind that this is a basic example to demonstrate the concepts of stack operations. Real-world
scenarios might involve more sophisticated implementations, error handling, and additional features.
A queue is a fundamental data structure that follows the First-In-First-Out (FIFO) principle. In a
queue, elements are added to the back and removed from the front. The first element added to the
queue is the first one to be removed, hence the name "First-In-First-Out."
Key characteristics of a queue:
1. **Operations:** Queues support two main operations:
- **Enqueue (or Push):** Adds an element to the back of the queue.
- **Dequeue (or Pop):** Removes the front element from the queue.
2. **Front and Rear (or Back):** Elements are added at the rear (back) and removed from the
front.
3. **Size:** Queues can have a fixed size (bounded) or dynamic size (unbounded).
4. **Usage:** Queues are used in scenarios where tasks, requests, or elements need to be processed
in the order they were added. Examples include task scheduling, job processing, breadth-first
search, and more.
5. **Implementation:** Queues can be implemented using arrays or linked lists. Arrays provide
constant-time access to the front and rear, but their size may need to be predetermined. Linked lists
provide flexibility in size but require more memory due to node pointers.
Common queue operations:
- **Enqueue (or Push):** Adds an element to the back of the queue. If the queue is full (for a
bounded queue), it's an overflow error.
- **Dequeue (or Pop):** Removes and returns the front element from the queue. If the queue is
empty, it's an underflow error.
- **Front (or Peek):** Returns the value of the front element without removing it.
- **Rear (or Back):** Returns the value of the rear (back) element without removing it.
- **Size:** Returns the number of elements in the queue.
- **isEmpty:** Checks if the queue is empty.
- **isFull:** Checks if the queue is full (for a bounded queue).
Applications of queues:
1. **Task Scheduling:** Queues are used to manage tasks in the order they should be executed,
like job scheduling in operating systems.
2. **Breadth-First Search:** In graph traversal algorithms like breadth-first search, a queue is
used to explore vertices level by level.
3. **Print Queue:** In printers, a queue is used to manage print jobs in the order they were
submitted.
4. **Resource Allocation:** Queues are used to manage resources such as network requests or
customer service calls.
5. **Buffering:** Queues can be used as buffers to store data temporarily before processing.
6. **Implementing Algorithms:** Queues are used in various algorithms like the sliding window
technique and asynchronous programming.
In summary, queues are a foundational concept in computer science and programming, widely used
in scenarios where tasks or data need to be processed in a specific order.
import java.util.LinkedList;
public class QueueExample {
private LinkedList<Integer> queueList; // Using LinkedList for the queue
implementation
public QueueExample() {
queueList = new LinkedList<>();
}
// Function to enqueue (add) an element to the back of the queue
public void enqueue(int value) {
queueList.addLast(value);
}
// Function to dequeue (remove) the front element from the queue
public int dequeue() {
if (isEmpty()) {
throw new IllegalStateException("Queue is empty");
}
return queueList.removeFirst();
}
// Function to search for a specific value in the queue
public boolean search(int value) {
return queueList.contains(value);
}
// Function to check if the queue is empty
public boolean isEmpty() {
return queueList.isEmpty();
}
public static void main(String[] args) {
QueueExample queue = new QueueExample();
queue.enqueue(10);
queue.enqueue(20);
queue.enqueue(15);
System.out.println("Search 15: " + queue.search(15)); // Output: true
System.out.println("Search 25: " + queue.search(25)); // Output: false
System.out.println("Dequeue: " + queue.dequeue()); // Output: 10
System.out.println("Dequeue: " + queue.dequeue()); // Output: 20
}
}
3.1 Balance the string
A common problem for compilers and text editors is determining whether
the parentheses in a string are balanced and properly nested. For example, the
string ((())())() contains properly nested pairs of parentheses, while the strings
)()( and ()) do not. Give an algorithm that returns true if a string contains
properly nested and balanced parentheses, and false if otherwise. For full credit,
identify the position of the first offending parenthesis if the string is not properly
nested and balanced.
Algorithm:
1. Initialize an empty stack.
2. For each character c in the input string:
a. If c is an opening parenthesis ('('), push its index onto the stack.
b. If c is a closing parenthesis (')'):
i. If the stack is empty, return false since there is no corresponding opening parenthesis.
ii. Pop the index from the stack.
3. If the stack is empty, return true (all parentheses are balanced).
4. If the stack is not empty, return false (there are unmatched opening parentheses).
If step 4 is reached:
The index at the top of the stack corresponds to the position of the first offending parenthesis.
public class ParenthesesChecker {
public static void main(String[] args) {
String input = "(()())()";
boolean isBalanced = true;
int offendingIndex = -1;
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
if (c == '(') {
stack.push(i);
} else if (c == ')') {
if (stack.isEmpty()) {
isBalanced = false;
offendingIndex = i;
break;
}
stack.pop();
}
}
if (isBalanced && stack.isEmpty()) {
System.out.println("The parentheses are balanced.");
} else {
System.out.println("The parentheses are not balanced. First
offending parenthesis at index " + offendingIndex + ".");
}
}
}
3.4
Design a stack S that supports S.push(x), S.pop(), and S.findmin(), which
returns the minimum element of S. All operations should run in constant time.
To design a stack that supports push, pop, and findmin operations in constant time, you can utilize
two stacks. One stack will hold the elements as they are pushed, and the other stack will keep track
of the minimum elements at each step. Here's how you can implement it:
Two Stacks
import java.util.Stack;
public class MinStack {
private Stack<Integer> dataStack; // Stack to hold the elements
private Stack<Integer> minStack; // Stack to keep track of minimum elements
public MinStack() {
dataStack = new Stack<>();
minStack = new Stack<>();
}
public void push(int x) {
dataStack.push(x); // Push the element onto the dataStack
}
// Check if x is smaller than or equal to the top element of minStack
// If so, push it onto minStack to maintain the minimum element
if (minStack.isEmpty() || x <= minStack.peek()) {
minStack.push(x);
}
public int pop() {
if (dataStack.isEmpty()) {
return -1; // Return an appropriate value indicating an empty stack
}
int popped = dataStack.pop(); // Pop the element from dataStack
}
// Check if the popped element was also the top element of minStack
// If so, pop it from minStack to maintain the correct minimum element
if (popped == minStack.peek()) {
minStack.pop();
}
return popped;
public int findMin() {
if (minStack.isEmpty()) {
return -1; // Return an appropriate value indicating no minimum
}
return minStack.peek(); // Return the top element of minStack, which is the
minimum
}
public static void main(String[] args) {
MinStack stack = new MinStack();
stack.push(3);
stack.push(1);
stack.push(4);
stack.push(2);
System.out.println("Minimum element: " + stack.findMin());
element: 1
stack.pop();
// Output: Minimum
System.out.println("Minimum element: " + stack.findMin());
element: 1
stack.pop();
System.out.println("Minimum element: " + stack.findMin());
element: 1
}
}
// Output: Minimum
// Output: Minimum
In this implementation, the data_stack holds the elements as they are pushed, and the min_stack
keeps track of the minimum elements. When you push an element onto the data_stack, you also
check whether it's smaller than or equal to the top element of the min_stack. If so, you push it onto
the min_stack as well. This ensures that the min_stack always has the minimum element at the top.
The pop operation checks whether the element being popped is also the top element of the
min_stack. If it is, you pop from both stacks. The findmin operation simply returns the top element
of the min_stack.
Both push, pop, and findmin operations run in constant time O(1) because the min_stack always
provides the minimum element directly without the need for searching through the entire stack.
3.5 We have seen how dynamic arrays enable arrays to grow while still achieving
constant-time amortized performance. This problem concerns extending
dynamic arrays to let them both grow and shrink on demand.
(a) Consider an underflow strategy that cuts the array size in half whenever
the array falls below half full. Give an example sequence of insertions and
deletions where this strategy gives a bad amortized cost.
Dinamic Array:
import java.util.ArrayList;
public class DynamicArray {
private ArrayList<Integer> array;
private int size;
public DynamicArray() {
array = new ArrayList<>();
size = 0;
}
public void insert(int value) {
array.add(value);
size++;
}
public Integer delete() {
if (size == 0) {
return null;
}
Integer deletedValue = array.remove(array.size() - 1);
size--;
return deletedValue;
}
public static void main(String[] args) {
DynamicArray dynamicArray = new DynamicArray();
// Insert 8 elements, making the array full
for (int i = 0; i < 8; i++) {
dynamicArray.insert(i);
}
// Delete 4 elements, leaving the array half full
for (int i = 0; i < 4; i++) {
dynamicArray.delete();
}
// Insert 4 elements, making the array full again
for (int i = 0; i < 4; i++) {
dynamicArray.insert(i);
}
// Delete 2 elements, leaving the array one-quarter full
for (int i = 0; i < 2; i++) {
dynamicArray.delete();
}
}
}
System.out.println(dynamicArray.array);
// Remaining elements in the array
(a) Example of Bad Amortized Cost with Half-Full Underflow Strategy:
Imagine we start with an array of size 8. For this example, let's say the insertion and deletion sequence is as
follows:
• Insertion: Insert 8 elements, making the array full.
• Deletion: Remove 4 elements, leaving the array half full.
• Insertion: Insert 4 elements, making the array full again.
• Deletion: Remove 2 elements, leaving the array one-quarter full.
Now, let's calculate the amortized cost for this sequence using the half-full underflow strategy.
• Initial array size: 8
• Total cost for 8 insertions: 8
• After the first 8 insertions, the array is full.
• Total cost for 4 deletions: 4 * (8/2) = 16 (half of the array size for each deletion)
• After the deletions, the array is half full.
• Total cost for 4 insertions: 4
• After the insertions, the array is full.
• Total cost for 2 deletions: 2 * (8/2) = 8 (half of the array size for each deletion)
Total cost for the entire sequence: 8 + 16 + 4 + 8 = 36
The sequence of operations results in a total cost of 36 for 18 operations. The amortized cost per operation is
36/18 = 2, which is not constant and grows as the number of operations increases.
(b) Constant Amortized Cost per Deletion:
To achieve a constant amortized cost per deletion, a better underflow strategy is to reduce the array size by a
constant factor, not necessarily cutting it in half.
For example, instead of halving the array size when the array falls below half full, we could reduce the size
by a factor of 4 (or any other constant). This way, even though there might be more copying involved, the
amortized cost per deletion remains constant.
In this scenario, for each deletion, the amortized cost would account for the reduced size and copying
overhead, resulting in a constant amortized cost per deletion.
The specific factor by which you reduce the array size can be adjusted based on practical considerations and
the trade-offs you're willing to make between insertion and deletion performance.
By using a better underflow strategy that reduces the array size by a constant factor, you can achieve constant
amortized cost per deletion, addressing the problem with the half-full underflow strategy.
import java.util.ArrayList;
public class BetterDynamicArray {
private ArrayList<Integer> array;
private int size;
private int capacity;
public BetterDynamicArray() {
capacity = 8; // Initial capacity
array = new ArrayList<>(capacity);
size = 0;
}
public void insert(int value) {
if (size == capacity) {
resize(2 * capacity); // Double the capacity
}
array.add(value);
size++;
}
public Integer delete() {
if (size == 0) {
return null;
}
Integer deletedValue = array.remove(array.size() - 1);
size--;
}
// Check if the array is too sparse
if (size > 0 && size == capacity / 4) {
resize(capacity / 2); // Halve the capacity
}
return deletedValue;
private void resize(int newCapacity) {
ArrayList<Integer> newArray = new ArrayList<>(newCapacity);
for (int i = 0; i < size; i++) {
newArray.add(array.get(i));
}
array = newArray;
capacity = newCapacity;
}
public static void main(String[] args) {
BetterDynamicArray dynamicArray = new BetterDynamicArray();
// Insert 8 elements
for (int i = 0; i < 8; i++) {
dynamicArray.insert(i);
}
// Delete 6 elements
for (int i = 0; i < 6; i++) {
dynamicArray.delete();
}
array
}
}
System.out.println(dynamicArray.array);
// Remaining elements in the
In this example, the BetterDynamicArray class implements an improved underflow strategy. It doubles the
capacity of the array when it becomes full and halves the capacity when it becomes one-quarter full. This
ensures that the array doesn't shrink too aggressively and maintains a balance between the number of
insertions and deletions.
The resize method is used to adjust the capacity of the array and copy elements to the new array. The
example sequence of operations inserts and deletes elements, and the remaining elements in the array are
printed at the end. This strategy achieves a constant amortized cost per deletion.
3.8 Create a data structure with O(n) space that accepts a sequence
of moves, and reports in constant time whether the last move won the
game.
Tic-tac-toe is a game played on an n°øn board (typically n = 3)
where two
players take consecutive turns placing “O” and “X” marks onto the
board cells.
The game is won if n consecutive “O” or ‘X” marks are placed in a
row, column,
or diagonal. Create a data structure with O(n) space that accepts a
sequence
of moves, and reports in constant time whether the last move won the
game.
public class TicTacToe {
private int[] rowCount;
private int[] colCount;
private int mainDiagonalCount;
private int antiDiagonalCount;
private int n;
//
//
//
//
//
Array to track move counts in each row
Array to track move counts in each column
Count of moves in the main diagonal
Count of moves in the anti-diagonal
Size of the board (n x n)
public TicTacToe(int n) {
this.n = n;
rowCount = new int[n];
colCount = new int[n];
}
public int move(int row, int col, int player) {
int playerValue = (player == 1) ? 1 : -1; // Assign a value for the player (1 or
-1)
rowCount[row] += playerValue;
colCount[col] += playerValue;
if (row == col) {
mainDiagonalCount += playerValue;
applicable
}
if (row + col == n - 1) {
antiDiagonalCount += playerValue;
applicable
}
// Update row count
// Update column count
// Update main diagonal count if
// Update anti-diagonal count if
// Check if the move resulted in a win for the player
if (Math.abs(rowCount[row]) == n || Math.abs(colCount[col]) == n ||
Math.abs(mainDiagonalCount) == n || Math.abs(antiDiagonalCount) == n) {
return player; // Return the player who won
}
return 0; // The game hasn't been won yet
}
public static void main(String[] args) {
TicTacToe ticTacToe = new TicTacToe(3);
System.out.println(ticTacToe.move(0, 0, 1)); // Output: 0 (no winner yet)
System.out.println(ticTacToe.move(0, 2, 2)); // Output: 0 (no winner yet)
System.out.println(ticTacToe.move(2, 2, 1)); // Output: 0 (no winner yet)
System.out.println(ticTacToe.move(1,
System.out.println(ticTacToe.move(2,
System.out.println(ticTacToe.move(1,
System.out.println(ticTacToe.move(2,
1,
0,
0,
1,
2));
1));
2));
1));
//
//
//
//
Output:
Output:
Output:
Output:
0
0
0
1
(no winner yet)
(no winner yet)
(no winner yet)
(player 1 wins)
}
}
In this implementation, the TicTacToe class has a constructor that takes the size of the board n as an
argument. The move method is used to process a player's move and returns the player who won the
game (1 for player 1, -1 for player 2) or 0 if no winner yet.
The main idea is to keep track of move counts in each row, column, main diagonal, and antidiagonal as players make their moves. If any count reaches n (or -n), it means that the
corresponding player has won the game.
3.9 Write a function which, given a sequence of digits 2–9 and a dictionary of n
words, reports all words described by this sequence when typed in on a standard
telephone keypad. For the sequence 269 you should return any, box, boy, and
cow, among other words.
import java.util.*;
public class TelephoneKeypadWords {
// Mapping of digits to corresponding letters on the telephone keypad
private static final String[] KEYPAD_MAPPING = {
"",
// Digit 0
"",
// Digit 1
"abc", // Digit 2
"def", // Digit 3
"ghi", // Digit 4
"jkl", // Digit 5
"mno", // Digit 6
"pqrs", // Digit 7
"tuv", // Digit 8
"wxyz" // Digit 9
};
// Function to find words described by a sequence of digits and a dictionary
public static List<String> findWords(String digits, Set<String> dictionary) {
List<String> result = new ArrayList<>();
if (digits == null || digits.isEmpty()) {
return result;
}
generateWords(digits, 0, new StringBuilder(), result, dictionary);
return result;
}
// Recursive function to generate words using the keypad mapping
private static void generateWords(String digits, int index, StringBuilder
currentWord, List<String> result, Set<String> dictionary) {
if (index == digits.length()) {
String word = currentWord.toString();
if (dictionary.contains(word)) {
result.add(word);
}
return;
}
int digit = digits.charAt(index) - '0';
String letters = KEYPAD_MAPPING[digit];
for (char letter : letters.toCharArray()) {
currentWord.append(letter);
generateWords(digits, index + 1, currentWord, result, dictionary);
currentWord.deleteCharAt(currentWord.length() - 1);
}
}
public static void main(String[] args) {
// Example dictionary of words
Set<String> dictionary = new HashSet<>(Arrays.asList("any", "box", "boy", "cow",
"dog", "fox"));
String sequence = "269"; // Example sequence of digits
List<String> words = findWords(sequence, dictionary);
System.out.println("Words described by the sequence " + sequence + ": " +
words);
}
}
In this implementation, the KEYPAD_MAPPING array maps each digit (2 to 9) to the
corresponding letters on the telephone keypad. The findWords function generates all possible
combinations of letters corresponding to the given sequence of digits and checks if each generated
word is present in the dictionary.
The generateWords function uses a recursive approach to generate all possible combinations of
letters. It builds the current word using a StringBuilder and checks if the word exists in the
dictionary. If it does, the word is added to the result list.
The main method demonstrates the usage of the function with a given dictionary and sequence of
digits.
3.11 Operations on Tree Structures and other Dictionary structures
PriorityQueue
3.22 Design a data structure that supports the following two operations:
• insert(x) – Insert item x from the data stream to the data structure.
• median() – Return the median of all elements so far.
All operations must take O(log n) time on an n-element set.
To achieve O(log n) time complexity for both insertion and nding the median in a data stream, you
can use two heaps: a max-heap to store the elements less than or equal to the current median, and a
min-heap to store the elements greater than the current median. This way, the median will be the
root of either the max-heap or the min-heap, depending on whether the total number of elements is
even or odd
:
fi
.
Here's the Java code with comments explaining each part of the implementation
import java.util.PriorityQueue;
public class MedianFinder {
private PriorityQueue<Integer> maxHeap; // Max-heap for the left half
private PriorityQueue<Integer> minHeap; // Min-heap for the right half
public MedianFinder() {
maxHeap = new PriorityQueue<>((a, b) -> b - a); // Max-heap comparator
order)
minHeap = new PriorityQueue<>(); // Min-heap comparator is default (ascending
}
public void insert(int x) {
// Insert the element into the appropriate heap
if (maxHeap.isEmpty() || x <= maxHeap.peek()) {
maxHeap.offer(x);
} else {
minHeap.offer(x);
}
// Balance the heaps if their sizes differ by more than 1
if (maxHeap.size() > minHeap.size() + 1) {
minHeap.offer(maxHeap.poll());
} else if (minHeap.size() > maxHeap.size()) {
maxHeap.offer(minHeap.poll());
}
}
public double median() {
if (maxHeap.size() == minHeap.size()) {
return (maxHeap.peek() + minHeap.peek()) / 2.0;
} else {
return maxHeap.peek();
}
}
public static void main(String[] args) {
MedianFinder medianFinder = new MedianFinder();
medianFinder.insert(5);
medianFinder.insert(2);
medianFinder.insert(7);
medianFinder.insert(3);
System.out.println("Median: " + medianFinder.median()); // Output: Median: 4.0
}
}
A priority queue is a data structure that stores a collection of elements, each associated with a
priority. It allows for efficient retrieval of the element with the highest (or lowest) priority. Priority
queues are commonly used in scenarios where elements need to be processed in order of their
priority, rather than in a strict linear or FIFO order.
Key characteristics of a priority queue:
1. **Priority Order:** Elements in a priority queue are associated with a priority value. The
priority determines the order in which elements are retrieved. The element with the highest (or
lowest) priority is considered the "top" element.
2. **Operations:** Priority queues support two main operations:
- **Insert (Enqueue):** Adds an element with a priority to the queue.
- **Extract-Max (or Extract-Min):** Removes and returns the element with the highest (or
lowest) priority.
3. **Access to Highest (or Lowest) Priority:** Priority queues provide quick access to the
element with the highest (or lowest) priority, without the need to traverse the entire collection.
4. **Implementation:** Priority queues can be implemented using various data structures,
including binary heaps (commonly used), Fibonacci heaps, and more. Binary heaps offer good
balance between efficiency and ease of implementation.
5. **Types:** Priority queues can be max-priority queues (highest priority first) or min-priority
queues (lowest priority first), depending on the desired order.
Common priority queue operations:
- **Insert (or Enqueue):** Adds an element with a priority to the queue.
- **Extract-Max (or Extract-Min):** Removes and returns the element with the highest (or
lowest) priority.
- **Peek (or Top):** Returns the element with the highest (or lowest) priority without removing it.
Applications of priority queues:
1. **Task Scheduling:** Priority queues are used to schedule tasks based on their priority,
ensuring that higher-priority tasks are executed first.
2. **Event-driven Simulation:** In simulations, priority queues manage events, ensuring that the
next event with the highest priority is processed.
3. **Graph Algorithms:** Priority queues are used in algorithms like Dijkstra's shortest path
algorithm and Prim's minimum spanning tree algorithm.
4. **Huffman Coding:** Used in data compression algorithms like Huffman coding to build
optimal binary trees.
5. **Load Balancing:** Priority queues help balance loads in systems where tasks have different
processing times.
6. **Operating System Schedulers:** Priority queues can be used in task scheduling within an
operating system.
In summary, a priority queue is a versatile data structure that allows elements to be stored with
associated priorities, enabling efficient retrieval of elements based on their priority. It's a
fundamental tool for managing tasks, events, and other elements in order of importance.
Priorityqueue:
import java.util.Arrays;
public class MaxPriorityQueue {
private int[] heapArray;
private int size;
public MaxPriorityQueue(int capacity) {
heapArray = new int[capacity + 1]; // 1-based indexing
size = 0;
}
// Function to insert an element with priority into the max-priority queue
public void insert(int value) {
if (size == heapArray.length - 1) {
System.out.println("Priority queue is full. Cannot insert.");
return;
}
size++;
heapArray[size] = value; // Insert the new element at the end
swim(size); // Restore the heap order property
}
// Function to delete and return the maximum element from the max-priority queue
public int deleteMax() {
if (isEmpty()) {
throw new IllegalStateException("Priority queue is empty");
}
int max = heapArray[1]; // Get the maximum element (at the root)
swap(1, size); // Swap with the last element
size--;
sink(1); // Restore the heap order property
return max;
}
// Function to get the maximum element (peek) from the max-priority queue
public int getMax() {
if (isEmpty()) {
throw new IllegalStateException("Priority queue is empty");
}
return heapArray[1]; // The maximum element is at the root
}
// Function to check if the priority queue is empty
public boolean isEmpty() {
return size == 0;
}
// Helper function to restore heap order property while inserting
private void swim(int k) {
while (k > 1 && heapArray[k] > heapArray[k / 2]) {
swap(k, k / 2);
k = k / 2;
}
}
// Helper function to restore heap order property while deleting
private void sink(int k) {
while (2 * k <= size) {
int j = 2 * k;
if (j < size && heapArray[j] < heapArray[j + 1]) {
j++;
}
if (heapArray[k] >= heapArray[j]) {
break;
}
swap(k, j);
k = j;
}
}
// Helper function to swap elements in the heap array
private void swap(int i, int j) {
int temp = heapArray[i];
heapArray[i] = heapArray[j];
heapArray[j] = temp;
}
public static void main(String[] args) {
MaxPriorityQueue pq = new MaxPriorityQueue(10);
pq.insert(20);
pq.insert(10);
pq.insert(30);
System.out.println("Max: " + pq.getMax()); // Output: 30
System.out.println("Delete Max: " + pq.deleteMax()); // Output: 30
System.out.println("Max after deletion: " + pq.getMax()); // Output: 20
}
}
In this example:
The MaxPriorityQueue class implements a max-priority queue using a binary heap.
•
The insert operation adds an element to the priority queue while maintaining the max-heap order
•
property.
The deleteMax operation removes and returns the maximum element from the priority queue and
•
restores the max-heap order.
The getMax operation retrieves the maximum element from the priority queue without removing it.
•
The isEmpty function checks if the priority queue is empty.
•
•
•
The swim function restores the max-heap order property while inserting an element.
The sink function restores the max-heap order property while deleting an element.
•
The swap function swaps elements in the heap array.
In the main method, we demonstrate how to use the MaxPriorityQueue class by inserting elements
into the priority queue, retrieving the maximum element, and deleting the maximum element.
Note that this example demonstrates a max-priority queue. You can implement a min-priority queue
by reversing the comparison operators (< and >) in the code.
Dictionary
A dictionary is an abstract data structure that stores a collection of elements, each associated with a
unique key. It provides efficient methods for inserting, retrieving, and deleting elements based on
their keys. Dictionaries are also known as associative arrays, maps, symbol tables, or hash
maps, depending on the terminology used in different programming languages or contexts.
Key characteristics of a dictionary:
• Key-Value Pairs: Each element in a dictionary consists of a key and a corresponding value. The
key is used to uniquely identify the value.
• Fast Lookup: Dictionaries provide fast retrieval of values based on their keys. The time
complexity for most operations (insertion, deletion, and retrieval) is typically close to O(1) on
average, making dictionaries highly efficient for managing large datasets.
• No Duplicate Keys: Keys are unique within a dictionary. If you try to insert a value with an
existing key, the new value will replace the old one.
• Unordered (in Some Cases): While the order of elements in a dictionary is typically not
guaranteed (unordered), some implementations maintain an order based on the keys' insertion or
other criteria.
• Hashing: Many dictionary implementations use hashing techniques to map keys to indices,
allowing for efficient key-based operations.
Common dictionary operations:
• Insert (Put): Adds a key-value pair to the dictionary.
• Retrieve (Get): Retrieves the value associated with a given key.
• Delete (Remove): Removes a key-value pair from the dictionary.
• Contains (ContainsKey): Checks if a specific key is present in the dictionary.
• Size (Count): Returns the number of key-value pairs in the dictionary.
• Iterate: Allows you to iterate over the key-value pairs.
Applications of dictionaries:
• Caching: Dictionaries are used to cache data, enabling efficient retrieval without recalculating or
fetching data from slower sources.
• Data Storage: Dictionaries store structured data, such as user profiles, settings, and
configurations.
• Database Indexing: Dictionaries (or hash maps) are used for indexing data in databases to speed
up queries.
• Symbol Tables: Dictionaries are used to store symbols (variable names, function names) and
their corresponding values in compilers and interpreters.
• Frequency Counting: Dictionaries are used to count the frequency of elements in datasets.
• Graph Algorithms: Dictionaries are essential for graph algorithms like Dijkstra's algorithm,
where they store vertex-to-distance mappings.
• Hashing: Dictionaries play a key role in hash-based data structures like hash tables and hash sets.
• Language Features: Many programming languages include dictionary-like data types, such as
Python's dictionaries, JavaScript's objects, and Java's HashMaps.
In summary, dictionaries are versatile data structures that provide efficient key-based access to
values. They are widely used in various programming scenarios where you need to store,
retrieve, and manage data associated with unique keys.
Dictionary code example:
import java.util.HashMap;
public class DictionaryExample {
public static void main(String[] args) {
// Create a new HashMap (dictionary)
HashMap<String, Integer> dictionary = new HashMap<>();
// Insert key-value pairs
dictionary.put("apple", 5);
dictionary.put("banana", 3);
dictionary.put("orange", 7);
// Retrieve and print the value associated with a key
String keyToSearch = "banana";
if (dictionary.containsKey(keyToSearch)) {
int value = dictionary.get(keyToSearch);
System.out.println(keyToSearch + ": " + value); // Output: banana: 3
} else {
System.out.println(keyToSearch + " not found");
}
// Remove a key-value pair
String keyToRemove = "apple";
if (dictionary.containsKey(keyToRemove)) {
int removedValue = dictionary.remove(keyToRemove);
System.out.println("Removed " + keyToRemove + ": " + removedValue); //
Output: Removed apple: 5
} else {
System.out.println(keyToRemove + " not found");
}
// Get the number of key-value pairs in the dictionary
int size = dictionary.size();
System.out.println("Dictionary size: " + size); // Output: Dictionary size: 2
// Search for a key that doesn't exist
String keyToSearchNonExistent = "grape";
if (dictionary.containsKey(keyToSearchNonExistent)) {
System.out.println(keyToSearchNonExistent + ": " +
dictionary.get(keyToSearchNonExistent));
} else {
System.out.println(keyToSearchNonExistent + " not found"); // Output: grape
not found
}
}
}
In this example:
• We use the HashMap class to implement a dictionary-like data structure.
• The put operation inserts key-value pairs into the dictionary.
• The get operation retrieves a value associated with a given key.
• The remove operation removes a key-value pair from the dictionary.
• The containsKey operation checks if a key exists in the dictionary.
• The size operation returns the number of key-value pairs in the dictionary.
The comments in the code explain each operation and its purpose. This example demonstrates how
to use a dictionary (or HashMap in Java) to manage key-value pairs, search for keys, remove keys,
and get the size of the dictionary.
3-23. [5] Assume we are given a standard dictionary (balanced binary search tree)
defined on a set of n strings, each of length at most l. We seek to print out all
strings beginning with a particular prefix p. Show how to do this in O(ml log n)
time, where m is the number of strings.
To print all strings in a standard dictionary (balanced binary search tree) that begin with a particular
prefix p, we can perform a modified in-order traversal of the tree. This traversal should focus on
visiting only the nodes where the prefix matches or could match the prefix p. In this way, we can
efficiently retrieve all the strings with the given prefix while avoiding unnecessary traversals.
Here's how you can achieve this in O(ml log n) time:
• Start at the root of the binary search tree.
• Traverse down the tree while comparing the prefix p with the nodes' values.
• If the prefix p matches the current node's value, then continue the traversal in both the left and
right subtrees (since any string that begins with p could exist in either subtree).
If the prefix p is lexicographically smaller than the current node's value, move to the left
subtree.
• If the prefix p is lexicographically larger than the current node's value, move to the right
subtree.
By focusing the traversal only on nodes where the prefix could match, we can efficiently retrieve
the desired strings without traversing the entire tree.
•
class TreeNode {
String value;
TreeNode left;
TreeNode right;
public TreeNode(String value) {
this.value = value;
this.left = null;
this.right = null;
}
}
public class PrefixSearchTree {
public static void printStringsWithPrefix(TreeNode root, String prefix) {
if (root == null) {
return;
}
// Traverse left subtree if prefix is less than or equal to current node's value
if (prefix.compareTo(root.value) <= 0) {
printStringsWithPrefix(root.left, prefix);
}
// Print current node's value if it starts with the given prefix
if (root.value.startsWith(prefix)) {
System.out.println(root.value);
}
// Traverse right subtree if prefix is less than current node's value
if (prefix.compareTo(root.value) < 0) {
printStringsWithPrefix(root.right, prefix);
}
}
public static void main(String[] args) {
// Construct a sample binary search tree
TreeNode root = new TreeNode("apple");
root.left = new TreeNode("app");
root.right = new TreeNode("banana");
root.left.left = new TreeNode("ape");
root.left.right = new TreeNode("appetite");
root.right.right = new TreeNode("grape");
String prefix = "app";
printStringsWithPrefix(root, prefix);
}
}
In this Java implementation, the TreeNode class represents a node in the binary search tree. The
PrefixSearchTree class includes the printStringsWithPrefix method that performs the in-order
traversal, printing all strings that match the given prefix.
The main method demonstrates how to construct a sample binary search tree and call the
printStringsWithPrefix method with a specific prefix.
Hotel room management using a Binary Search Tree (BST)
You are consulting for a hotel that has n one-bed rooms. When a guest
checks in, they ask for a room whose number is in the range [l, h]. Propose a
data structure that supports the following data operations in the allotted time:
(a) Initialize(n): Initialize the data structure for empty rooms numbered
1, 2, . . . , n, in polynomial time.
(b) Count(l, h): Return the number of available rooms in [l, h], in O(log n)
time.
(c) Checkin(l, h): InO(log n) time, return the first empty room in [l, h] and
mark it occupied, or return NIL if all the rooms in [l, h] are occupied.
(d) Checkout(x): Mark room x as unoccupied, in O(log n) time.
class TreeNode {
int roomNumber;
boolean occupied;
TreeNode left;
TreeNode right;
public TreeNode(int roomNumber) {
this.roomNumber = roomNumber;
this.occupied = false;
this.left = null;
this.right = null;
}
}
public class HotelRoomsBST {
private TreeNode root;
// Constructor to initialize the data structure with n rooms
public HotelRoomsBST(int n) {
root = buildBST(1, n);
}
// Helper function to build the binary search tree
private TreeNode buildBST(int start, int end) {
if (start > end) {
return null;
}
int mid = start + (end - start) / 2;
TreeNode node = new TreeNode(mid);
node.left = buildBST(start, mid - 1);
node.right = buildBST(mid + 1, end);
return node;
}
// Count the number of available rooms in the range [l, h]
public int count(int l, int h) {
return count(root, l, h);
}
// Helper function to count available rooms in a specific range
private int count(TreeNode node, int l, int h) {
if (node == null) {
return 0;
}
if (node.roomNumber < l) {
return count(node.right, l, h);
} else if (node.roomNumber > h) {
return count(node.left, l, h);
} else {
int countLeft = count(node.left, l, h);
int countRight = count(node.right, l, h);
return node.occupied ? countLeft + countRight : countLeft + countRight + 1;
}
}
// Check in a guest and return an available room in the range [l, h]
public Integer checkin(int l, int h) {
return checkin(root, l, h);
}
// Helper function to check in a guest and find an available room
private Integer checkin(TreeNode node, int l, int h) {
if (node == null) {
return null;
}
if (node.roomNumber < l) {
return checkin(node.right, l, h);
} else if (node.roomNumber > h) {
return checkin(node.left, l, h);
} else {
if (!node.occupied) {
node.occupied = true;
return node.roomNumber;
}
Integer result = checkin(node.left, l, h);
return result != null ? result : checkin(node.right, l, h);
}
}
// Mark a room as unoccupied
public void checkout(int x) {
checkout(root, x);
}
// Helper function to mark a room as unoccupied
private void checkout(TreeNode node, int x) {
if (node == null) {
return;
}
if (node.roomNumber == x) {
node.occupied = false;
} else if (node.roomNumber < x) {
checkout(node.right, x);
} else {
checkout(node.left, x);
}
}
public static void main(String[] args) {
HotelRoomsBST hotel = new HotelRoomsBST(10);
System.out.println(hotel.count(1, 10));
available)
// Output: 10 (all rooms are initially
System.out.println(hotel.checkin(3,
System.out.println(hotel.checkin(2,
System.out.println(hotel.checkin(4,
System.out.println(hotel.checkin(1,
are occupied)
//
//
//
//
6));
5));
7));
8));
Output:
Output:
Output:
Output:
3 (room 3
5 (room 5
4 (room 4
null (all
is available)
is available)
is available)
rooms from 3 to 7
hotel.checkout(4);
System.out.println(hotel.checkin(1, 8)); // Output: 4 (room 4 is available
again)
}
}
Interview Problems
3-34. [3] What method would you use to look up a word in a dictionary?
In computer science, there are several methods that can be used to look up a word in a dictionary
efficiently, depending on the context and requirements of the application. Some common methods
include:
• Hash Table: Hash tables provide constant-time average-case lookup if the hash function is
well-designed and collisions are managed effectively. This is a popular method for dictionarylike data structures.
•
Binary Search Tree (BST): BSTs can provide efficient lookup (O(log n) time) if the tree is
balanced. Balanced variants like AVL trees or Red-Black trees maintain logarithmic height,
ensuring efficient lookup.
•
Trie: A trie (also called a prefix tree or radix tree) is a tree-like data structure that is often
used to store a dynamic set of strings. It provides efficient string lookup and insertion
operations, making it suitable for dictionaries and word-related applications.
•
Balanced Search Trees: In addition to standard binary search trees, more specialized
balanced search trees like B-trees and B+ trees are used for dictionary-like applications,
especially when dealing with large datasets.
•
Sorted Array: If the dictionary is static and not frequently updated, a sorted array can provide
efficient lookup using binary search (O(log n) time). However, inserting new words can be
slower due to array shifting.
•
Hash-Based Data Structures: Other hash-based data structures like cuckoo hash tables and
hopscotch hash tables can provide efficient lookup with different collision resolution
strategies.
•
Perfect Hashing: In situations where the set of keys is known in advance and doesn't change,
perfect hashing can achieve constant-time lookup with minimal space overhead.
The choice of method depends on various factors such as the size of the dictionary, frequency of
insertions and lookups, memory constraints, and specific performance requirements. It's important
to choose a method that best suits the needs of your application and data characteristics.
Dictionary using HashMap
import java.util.*;
public class SimpleDictionary {
private HashMap<String, String> dictionary;
// Constructor to initialize the dictionary
public SimpleDictionary() {
dictionary = new HashMap<>();
}
// Add a word and its definition to the dictionary
public void addWord(String word, String definition) {
dictionary.put(word, definition);
}
// Remove a word and its definition from the dictionary
public void removeWord(String word) {
dictionary.remove(word);
}
// Look up the definition of a word in the dictionary
public String lookup(String word) {
return dictionary.getOrDefault(word, "Word not found in the dictionary.");
}
public static void main(String[] args) {
SimpleDictionary dict = new SimpleDictionary();
// Adding words and definitions to the dictionary
dict.addWord("apple", "A fruit that grows on trees.");
dict.addWord("computer", "An electronic device that performs calculations and
processes information.");
dict.addWord("book", "A written or printed work consisting of pages glued or
sewn together.");
System.out.println(dict.lookup("apple"));
// Output: A fruit that grows on
trees.
System.out.println(dict.lookup("computer")); // Output: An electronic device
that performs calculations and processes information.
System.out.println(dict.lookup("book"));
// Output: A written or printed
work consisting of pages glued or sewn together.
// Removing a word from the dictionary
dict.removeWord("apple");
System.out.println(dict.lookup("apple"));
dictionary.
}
}
// Output: Word not found in the
In this implementation, we're using a HashMap to store words as keys and their corresponding
definitions as values.
Here's how the code works:
• We create a SimpleDictionary class with a private HashMap member variable to store the
word-definition pairs.
• The addWord method allows us to add a word and its definition to the dictionary by putting
the word as the key and the definition as the value in the HashMap.
• The removeWord method lets us remove a word and its definition from the dictionary using
the remove method of the HashMap.
• The lookup method provides the definition of a given word by retrieving the value associated
with the key from the HashMap. If the key doesn't exist, it returns a default "Word not found
in the dictionary." message.
• In the main method, we demonstrate how to use the SimpleDictionary class by adding words,
looking up their definitions, and removing a word.
Binary Search Tree (BST) in Java with insert, remove, and lookup functions.
public class SimpleBST {
private class TreeNode {
int value;
TreeNode left;
TreeNode right;
public TreeNode(int value) {
this.value = value;
this.left = null;
this.right = null;
}
}
private TreeNode root;
// Constructor to initialize an empty BST
public SimpleBST() {
root = null;
}
// Insert a value into the BST
public void insert(int value) {
root = insertRecursive(root, value);
}
// Recursive helper method to insert a value into the BST
private TreeNode insertRecursive(TreeNode node, int value) {
if (node == null) {
return new TreeNode(value);
}
if (value < node.value) {
node.left = insertRecursive(node.left, value);
} else if (value > node.value) {
node.right = insertRecursive(node.right, value);
}
return node;
}
// Look up if a value exists in the BST
public boolean lookup(int value) {
return lookupRecursive(root, value);
}
// Recursive helper method to look up a value in the BST
private boolean lookupRecursive(TreeNode node, int value) {
if (node == null) {
return false;
}
if (value == node.value) {
return true;
} else if (value < node.value) {
return lookupRecursive(node.left, value);
} else {
return lookupRecursive(node.right, value);
}
}
// Remove a value from the BST
public void remove(int value) {
root = removeRecursive(root, value);
}
// Recursive helper method to remove a value from the BST
private TreeNode removeRecursive(TreeNode node, int value) {
if (node == null) {
return null;
}
if (value == node.value) {
if (node.left == null) {
return node.right;
} else if (node.right == null) {
return node.left;
}
node.value = findMinValue(node.right);
node.right = removeRecursive(node.right, node.value);
} else if (value < node.value) {
node.left = removeRecursive(node.left, value);
} else {
node.right = removeRecursive(node.right, value);
}
return node;
}
// Helper method to find the minimum value in a subtree
private int findMinValue(TreeNode node) {
while (node.left != null) {
node = node.left;
}
return node.value;
}
public static void main(String[] args) {
SimpleBST bst = new SimpleBST();
// Insert values into the BST
bst.insert(5);
bst.insert(3);
bst.insert(8);
bst.insert(2);
bst.insert(4);
bst.insert(7);
bst.insert(9);
// Look up values in the BST
System.out.println(bst.lookup(7)); // Output: true
System.out.println(bst.lookup(10)); // Output: false
// Remove a value from the BST
bst.remove(3);
System.out.println(bst.lookup(3));
// Output: false
}
}
In this implementation, we have a simplified Binary Search Tree (BST) with the insert, lookup, and
remove functions.
Here's how the code works:
• We define a private inner class TreeNode to represent nodes in the BST. Each node contains a
value and references to its left and right child nodes.
• The SimpleBST class maintains a root reference to the root node of the BST.
• The insert method inserts a value into the BST while maintaining the BST property. We use
the insertRecursive helper method to perform the insertion recursively.
• The lookup method checks if a given value exists in the BST by finding the corresponding
node using the lookupRecursive helper method.
• The remove method removes a value from the BST while maintaining the BST property. We
use the removeRecursive and findMinValue helper methods to handle removal cases.
• In the main method, we demonstrate how to use the SimpleBST class by inserting values,
looking up values, and removing a value.
The time complexity of operations in this simplified implementation depends on the height of the
BST, which can range from O(log n) for balanced trees to O(n) for unbalanced trees in the worst
case.
•
Simple implementation of a Trie data structure in Java with insert, search, and delete
functions
public class SimpleTrie {
private class TrieNode {
TrieNode[] children;
boolean isEndOfWord;
public TrieNode() {
children = new TrieNode[26]; // For lowercase English alphabet
isEndOfWord = false;
}
}
private TrieNode root;
// Constructor to initialize an empty Trie
public SimpleTrie() {
root = new TrieNode();
}
// Insert a word into the Trie
public void insert(String word) {
TrieNode current = root;
for (char c : word.toCharArray()) {
int index = c - 'a';
if (current.children[index] == null) {
current.children[index] = new TrieNode();
}
current = current.children[index];
}
current.isEndOfWord = true;
}
// Search for a word in the Trie
public boolean search(String word) {
TrieNode current = root;
for (char c : word.toCharArray()) {
int index = c - 'a';
if (current.children[index] == null) {
return false;
}
current = current.children[index];
}
return current.isEndOfWord;
}
// Delete a word from the Trie
public void delete(String word) {
deleteRecursive(root, word, 0);
}
// Recursive helper method to delete a word from the Trie
private boolean deleteRecursive(TrieNode node, String word, int index) {
if (node == null) {
return false;
}
if (index == word.length()) {
if (!node.isEndOfWord) {
return false;
}
node.isEndOfWord = false;
// If the node has no other children, it can be removed
return isNodeEmpty(node);
}
int charIndex = word.charAt(index) - 'a';
if (deleteRecursive(node.children[charIndex], word, index + 1)) {
node.children[charIndex] = null;
// If the node has no other children and is not the end of a word, it can be
removed
return !node.isEndOfWord && isNodeEmpty(node);
}
return false;
}
// Helper method to check if a node has no children
private boolean isNodeEmpty(TrieNode node) {
for (TrieNode child : node.children) {
if (child != null) {
return false;
}
}
return true;
}
public static void main(String[] args) {
SimpleTrie trie = new SimpleTrie();
// Insert words into the Trie
trie.insert("apple");
trie.insert("app");
System.out.println(trie.search("apple")); // Output: true
System.out.println(trie.search("app"));
// Output: true
trie.delete("app");
System.out.println(trie.search("app"));
// Output: false
System.out.println(trie.search("apple")); // Output: true
}
}
In this implementation, we have a simplified Trie data structure with the insert, search, and delete
functions.
Here's how the code works:
•
We define a private inner class TrieNode to represent nodes in the Trie. Each node contains
an array of children nodes and a flag indicating whether it's the end of a word.
•
The SimpleTrie class maintains a root reference to the root node of the Trie.
•
The insert method inserts a word into the Trie by traversing the Trie and creating new nodes
as needed for each character in the word.
The search method searches for a given word in the Trie by traversing the Trie and checking
if the characters exist in the correct order.
•
The delete method deletes a word from the Trie by recursively removing nodes
corresponding to the characters in the word. The isNodeEmpty helper method checks if a
node has no children.
•
In the main method, we demonstrate how to use the SimpleTrie class by inserting words,
searching for words, and deleting words.
The time complexity of operations in this simplified implementation depends on the length of the
words and the structure of the Trie. In a well-balanced Trie, operations can be done in O(L) time,
where L is the length of the word being processed. However, in the worst case (e.g., all words share
a common prefix), the complexity can approach O(N), where N is the total number of characters in
the Trie.
•
3.36 Write a function to find the middle node of a singly linked list.
In this implementation:
• We define a ListNode class to represent nodes in the linked list, each having a value and a
reference to the next node.
• The MiddleOfLinkedList class contains the findMiddle method that takes the head of the
linked list as an input and returns the middle node.
• We use two pointers, slow and fast, initialized to the head of the linked list.
• The slow pointer advances one step at a time, while the fast pointer advances two steps at a
time.
• When the fast pointer reaches the end of the list (i.e., fast becomes null or fast.next becomes
null), the slow pointer will be at the middle node.
The time complexity of this algorithm is O(n), where n is the number of nodes in the linked list,
since we traverse the list once with both slow and fast pointers.
class ListNode {
int val;
ListNode next;
public ListNode(int val) {
this.val = val;
this.next = null;
}
}
public class MiddleOfLinkedList {
// Function to find the middle node of a singly linked list
public ListNode findMiddle(ListNode head) {
if (head == null) {
return null; // Return null for an empty list
}
ListNode slow = head; // Slow pointer initially at the head
ListNode fast = head; // Fast pointer initially at the head
// Traverse the linked list with two pointers
// Slow pointer advances one step, fast pointer advances two steps
while (fast != null && fast.next != null) {
slow = slow.next;
// Move slow pointer one step
fast = fast.next.next;
// Move fast pointer two steps
}
return slow; // Return the middle node (or the first middle if the list has an
even length)
}
public static void main(String[] args) {
MiddleOfLinkedList solution = new MiddleOfLinkedList();
// Create a linked list: 1 -> 2 -> 3 -> 4 -> 5
ListNode head = new ListNode(1);
head.next = new ListNode(2);
head.next.next = new ListNode(3);
head.next.next.next = new ListNode(4);
head.next.next.next.next = new ListNode(5);
// Find and print the middle node value
ListNode middle = solution.findMiddle(head);
System.out.println("Middle node value: " + middle.val);
}
}
// Output: 3
Download