diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 2ed02009..e67bb0ad 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -422,11 +422,11 @@ - + diff --git a/src/main/java/dataStructures/avlTree/AVLTree.java b/src/main/java/dataStructures/avlTree/AVLTree.java index 19671702..b4f7e4f4 100644 --- a/src/main/java/dataStructures/avlTree/AVLTree.java +++ b/src/main/java/dataStructures/avlTree/AVLTree.java @@ -60,7 +60,12 @@ public int height(T key) { * @param n node whose height is to be updated */ private void updateHeight(Node n) { - n.setHeight(1 + Math.max(height(n.getLeft()), height(n.getRight()))); + n.setHeight( + 1 + Math.max( + height(n.getLeft()), + height(n.getRight()) + ) + ); } /** @@ -87,12 +92,13 @@ private int getBalance(Node n) { */ private Node rotateRight(Node n) { Node newRoot = n.getLeft(); + // this will become the left child of n after rotation Node newLeftSub = newRoot.getRight(); + newRoot.setRight(n); n.setLeft(newLeftSub); newRoot.setParent(n.getParent()); - n.setParent(newRoot); updateHeight(n); updateHeight(newRoot); @@ -110,12 +116,13 @@ private Node rotateRight(Node n) { */ private Node rotateLeft(Node n) { Node newRoot = n.getRight(); + // this will become the right child of n after rotation Node newRightSub = newRoot.getLeft(); + newRoot.setLeft(n); n.setRight(newRightSub); newRoot.setParent(n.getParent()); - n.setParent(newRoot); updateHeight(n); updateHeight(newRoot); @@ -132,13 +139,19 @@ private Node rebalance(Node n) { updateHeight(n); int balance = getBalance(n); if (balance < -1) { // right-heavy case - if (height(n.getRight().getLeft()) > height(n.getRight().getRight())) { - n.setRight(rotateRight(n.getRight())); + Node rightChild = n.getRight(); + Node leftSubChild = rightChild.getLeft(); + Node rightSubChild = rightChild.getRight(); + if (height(leftSubChild) > height(rightSubChild)) { + n.setRight(rotateRight(rightChild)); } n = rotateLeft(n); } else if (balance > 1) { // left-heavy case - if (height(n.getLeft().getRight()) > height(n.getLeft().getLeft())) { - n.setLeft(rotateLeft(n.getLeft())); + Node leftChild = n.getLeft(); + Node leftSubChild = leftChild.getLeft(); + Node rightSubChild = leftChild.getRight(); + if (height(rightSubChild) > height(leftSubChild)) { + n.setLeft(rotateLeft(leftChild)); } n = rotateRight(n); } @@ -159,6 +172,12 @@ private Node getMostLeft(Node n) { } } + /** + * Find the right-most child of the (sub)tree rooted at a specified node + * + * @param n tree is rooted at this node + * @return right-most node + */ private Node getMostRight(Node n) { if (n.getRight() == null) { return n; @@ -189,11 +208,9 @@ private Node insert(Node node, T key) { return new Node<>(key); } else if (node.getKey().compareTo(key) < 0) { node.setRight(insert(node.getRight(), key)); - node.getRight().setParent(node); // note that insufficient to update parent in rotateLeft & rotateRight if still considered balanced } else if (node.getKey().compareTo(key) > 0) { node.setLeft(insert(node.getLeft(), key)); - node.getLeft().setParent(node); } else { throw new RuntimeException("Duplicate key not supported!"); } @@ -226,20 +243,22 @@ private Node delete(Node node, T key) { node.setLeft(delete(node.getLeft(), key)); } else { if (node.getLeft() == null || node.getRight() == null) { // case of 1 or 0 child - if (node.getLeft() == null && node.getRight() == null) { - node = null; // 0-child case + if (node.getLeft() == null && node.getRight() == null) { // 0-child case; just delete + node = null; } else if (node.getRight() == null) { - node.getLeft().setParent(node.getParent()); + Node parentNode = node.getParent(); + node.getLeft().setParent(parentNode); node = node.getLeft(); } else { - node.getRight().setParent(node.getParent()); + Node parentNode = node.getParent(); + node.getRight().setParent(parentNode); node = node.getRight(); } - } else { // 2-children case + } else { // 2-children case; successor replacement Node successor = getMostLeft(node.getRight()); node.setKey(successor.getKey()); // since this is a 2-children case, successor of deleted node have - // at most one child; right-child (else it would continue going left) + // at most one child; right-child (else, it would continue going left) node.setRight(delete(node.getRight(), successor.getKey())); } } @@ -274,10 +293,13 @@ public Node search(T key) { * Search for the predecessor of a given key. * * @param key find predecessor of this key - * @return generic type value; null if key has no predecessor + * @return generic type value; null if key has no predecessor or tree is empty */ public T predecessor(T key) { Node curr = root; + if (curr == null) { + return null; + } while (curr != null) { if (curr.getKey().compareTo(key) == 0) { break; @@ -325,10 +347,13 @@ private T predecessor(Node node) { * Search for the successor of a given key. * * @param key find successor of this key - * @return generic type value; null if key has no successor + * @return generic type value; null if key has no successor or tree is empty */ public T successor(T key) { Node curr = root; + if (curr == null) { + return null; + } while (curr != null) { if (curr.getKey().compareTo(key) == 0) { break; diff --git a/src/main/java/dataStructures/avlTree/Node.java b/src/main/java/dataStructures/avlTree/Node.java index 2046b3e7..eec2a4aa 100644 --- a/src/main/java/dataStructures/avlTree/Node.java +++ b/src/main/java/dataStructures/avlTree/Node.java @@ -22,6 +22,7 @@ public class Node> { public Node(T key) { this.key = key; + this.height = 0; // height of a new node is 0 (leaf) } public boolean isLeaf() { @@ -40,16 +41,22 @@ public Node getLeft() { return left; } - public void setLeft(Node left) { - this.left = left; + public void setLeft(Node node) { + this.left = node; + if (node != null) { + node.parent = this; + } } public Node getRight() { return right; } - public void setRight(Node right) { - this.right = right; + public void setRight(Node node) { + this.right = node; + if (node != null) { + node.parent = this; + } } public Node getParent() { diff --git a/src/main/java/dataStructures/avlTree/README.md b/src/main/java/dataStructures/avlTree/README.md index d8320043..70916232 100644 --- a/src/main/java/dataStructures/avlTree/README.md +++ b/src/main/java/dataStructures/avlTree/README.md @@ -1,19 +1,15 @@ # AVL Trees ## Background -Say you want to search of a data in an array. If the array were sorted, lucky you! You can do it with binary search in -`O(logn)` time. But if the array weren't sorted, you can't avoid that `O(n)` linear search loop. Now, one idea is to -first sort the array, and incur a 1-time cost of `O(n)` and subsequent search operations can enjoy that `O(logn)` query -cost. This is all gucci, but it assumes that there will be no additional data streaming in. If incoming data is not -infrequent, you'll have to incur `O(n)` insertion cost each time to maintain sorted order, and this can undermine +Say you want to search of a data in an array. If the array were sorted, lucky you! You can do it with binary search in `O(logn)` time. But if the array wasn't sorted, you can't avoid that `O(n)` linear search loop. Now, one idea is to first sort the array, and incur a 1-time cost of `O(n)` and subsequent search operations can enjoy that `O(logn)` query cost. This is all gucci, but it assumes that there will be no additional data streaming in. If incoming data is not infrequent, you'll have to incur `O(n)` insertion cost each time to maintain sorted order, and this can undermine performance as a whole. If only there were some structure that allows us to enjoy `O(logn)` operations across.. We have seen binary search trees (BSTs), which always maintains data in sorted order. This allows us to avoid the overhead of sorting before we search. However, we also learnt that unbalanced BSTs can be incredibly inefficient for insertion, deletion and search operations, which are O(height) in time complexity (in the case of degenerate trees, -think of a linked list, operations can go up to O(n)). +i.e. linked list, operations can go up to `O(n)`). -Here we discuss a type of self-balancing BST, known as the AVL tree, that avoids the worst case O(n) performance +Here we discuss a type of self-balancing BST, known as the AVL tree, that avoids the worst case `O(n)` performance across the operations by ensuring careful updating of the tree's structure whenever there is a change (e.g. insert or delete). @@ -31,10 +27,9 @@ Height: The number of edges on the longest path from that node to a leaf. A leaf ### Definition of Balanced Trees -Balanced trees are a special subset of trees with **height in the order of log(n)**, where n is the number of nodes. -This choice is not an arbitrary one. It can be mathematically shown that a binary tree of n nodes has height of at least -log(n) (in the case of a complete binary tree). So, it makes intuitive sense to give trees whose heights are roughly - in the order of log(n) the desirable 'balanced' label. +Balanced trees are a special subset of trees with **height in the order of `log(n)`**, where `n` is the number of nodes. +
+This choice is not an arbitrary one. It can be mathematically shown that a binary tree of `n` nodes has height of at least `log(n)` (in the case of a complete binary tree). So, it makes intuitive sense to give trees whose heights are roughly in the order of `log(n)` the desirable 'balanced' label.
@@ -51,8 +46,7 @@ What is important is that this **'good' property holds even after every change** The 'good' property in AVL Trees is the **height-balanced** property. Height-balanced on a node is defined as **difference in height between the left and right child node being not more than 1**.
We say the tree is height-balanced if every node in the tree is height-balanced. Be careful not to conflate -the concept of "balanced tree" and "height-balanced" property. They are not the same; the latter is used to achieve the -former. +the concept of "balanced tree" and "height-balanced" property. They are not the same; the latter is used to achieve the former.
Ponder.. @@ -63,8 +57,8 @@ Yes! In fact, you can always construct a large enough AVL tree where their diffe
-It can be mathematically shown that a **height-balanced tree with n nodes, has at most height <= 2log(n)** ( -in fact, using the golden ratio, we can achieve a tighter bound of ~1.44log(n)). +It can be mathematically shown that a **height-balanced tree with n nodes, has at most height <= `2log(n)`** ( +in fact, using the golden ratio, we can achieve a tighter bound of ~`1.44log(n)`). Therefore, following the definition of a balanced tree, AVL trees are balanced.
@@ -73,19 +67,35 @@ Therefore, following the definition of a balanced tree, AVL trees are balanced. Credits: CS2040s Lecture 9
+### Balance Factor +To detect imbalance, each node tracks a **balance factor**: + +``` +balance factor = height(left subtree) - height(right subtree) +``` + +A node is height-balanced if its balance factor is in `{-1, 0, 1}`. When `|balance factor| > 1`, rebalancing is required. + +- **Positive** balance factor → left-heavy +- **Negative** balance factor → right-heavy + ## Complexity Analysis -**Search, Insertion, Deletion, Predecessor & Successor queries Time**: O(height) = O(logn) +**Time:** +| Operation | Complexity | +|-----------|------------| +| Search | `O(log n)` | +| Insert | `O(log n)` | +| Delete | `O(log n)` | +| Predecessor/Successor | `O(log n)` | +| Single Rotation | `O(1)` | -**Space**: O(n)
-where n is the number of elements (whatever the structure, it must store at least n nodes) +**Space**: `O(n)` where `n` is the number of elements ## Operations -Minimally, an implementation of AVL tree must support the standard **insert**, **delete**, and **search** operations. -**Update** can be simulated by searching for the old key, deleting it, and then inserting a node with the new key. +An AVL tree supports the standard **insert**, **delete**, and **search** operations. +**Update** can be simulated by deleting the old key and inserting the new one. -Naturally, with insertions and deletions, the structure of the tree will change, and it may not satisfy the -"height-balance" property of the AVL tree. Without this property, we may lose our O(log(n)) run-time guarantee. -Hence, we need some re-balancing operations. To do so, tree rotation operations are introduced. Below is one example. +Insertions and deletions can violate the height-balanced property. To restore it, we use **rotations**.
@@ -93,19 +103,56 @@ Hence, we need some re-balancing operations. To do so, tree rotation operations Credits: CS2040s Lecture 10
-Prof Seth explains it best! Go re-visit his slides (Lecture 10) for the operations :P
-Here is a [link](https://www.youtube.com/watch?v=dS02_IuZPes&list=PLgpwqdiEMkHA0pU_uspC6N88RwMpt9rC8&index=9) -to prof's lecture on trees.
-_We may add a summary in the near future._ - -## Application -While AVL trees offer excellent lookup, insertion, and deletion times due to their strict balancing, -the overhead of maintaining this balance can make them less preferred for applications -where insertions and deletions are significantly more frequent than lookups. As a result, AVL trees often find itself -over-shadowed in practical use by other counterparts like RB-trees, -which boast a relatively simple implementation and lower overhead, or B-trees which are ideal for optimizing disk -accesses in databases. - -That said, AVL tree is conceptually simple and often used as the base template for further augmentation to tackle -niche problems. Orthogonal Range Searching and Interval Trees can be implemented with some minor augmentation to -an existing AVL tree. +### The 4 Rotation Cases +After an insert or delete, we walk back up to the root, checking balance factors. When a node has `|balance factor| > 1`, one of four cases applies: + +| Case | Condition | Fix | +|------|-----------|-----| +| **Left-Left (LL)** | Left-heavy, left child is left-heavy or balanced | Single right rotation | +| **Right-Right (RR)** | Right-heavy, right child is right-heavy or balanced | Single left rotation | +| **Left-Right (LR)** | Left-heavy, left child is right-heavy | Left rotate left child, then right rotate node | +| **Right-Left (RL)** | Right-heavy, right child is left-heavy | Right rotate right child, then left rotate node | + +
+How to identify the case + +1. Node has balance factor `> 1` (left-heavy): + - If left child's balance factor `>= 0` → **LL case** + - If left child's balance factor `< 0` → **LR case** + +2. Node has balance factor `< -1` (right-heavy): + - If right child's balance factor `<= 0` → **RR case** + - If right child's balance factor `> 0` → **RL case** + +
+ +**Interview tip:** Rotations are `O(1)` - just pointer updates. The `O(log n)` cost of insert/delete comes from traversing the height of the tree, not from rotations. + +Prof Seth explains it best! For visual demonstrations, see [Prof Seth's lecture 10](https://www.youtube.com/watch?v=dS02_IuZPes&list=PLgpwqdiEMkHA0pU_uspC6N88RwMpt9rC8&index=9) on trees. + +## Notes +1. **Height guarantee**: AVL trees have height at most `~1.44 log(n)`, tighter than Red-Black trees' `2 log(n)`. This makes AVL faster for lookup-heavy workloads. + +2. **Rebalancing frequency**: AVL may rotate more often than RB-trees on insert/delete since it enforces stricter balance. This is the trade-off for faster lookups. + +3. **Duplicate keys**: The implementation here does not support duplicate keys. To handle duplicates, you could store a count in each node or use a list as the value. + +4. **Augmentation**: AVL trees are a great base for augmented structures. Store additional info (e.g., subtree size for order statistics) and update it during rotations. + +## Applications +AVL trees offer excellent lookup times due to strict balancing, but the overhead of maintaining balance +can make them less preferred when insertions/deletions vastly outnumber lookups. + +| Use Case | Best Choice | Why | +|----------|-------------|-----| +| Lookup-heavy workloads | AVL | Stricter balance → faster search | +| Insert/delete-heavy | Red-Black | Fewer rotations on average | +| Disk-based storage | B-tree | Optimized for block I/O | +| In-memory databases | AVL or RB | Both work well | + +**Interview tip:** "When would you choose AVL over Red-Black?" → When reads dominate writes, AVL's tighter height bound (`1.44 log n` vs `2 log n`) gives faster lookups. + +AVL trees are also commonly used as a base for augmented structures: +- **Order Statistics Tree** - find k-th smallest element in `O(log n)` +- **Interval Tree** - find all intervals overlapping a point +- **Orthogonal Range Tree** - 2D range queries diff --git a/src/main/java/dataStructures/binarySearchTree/BinarySearchTree.java b/src/main/java/dataStructures/binarySearchTree/BinarySearchTree.java index 5466d0f0..7af11e25 100644 --- a/src/main/java/dataStructures/binarySearchTree/BinarySearchTree.java +++ b/src/main/java/dataStructures/binarySearchTree/BinarySearchTree.java @@ -38,13 +38,17 @@ public class BinarySearchTree, V> { private void insert(Node node, T key, V value) { if (node.getKey().compareTo(key) < 0) { if (node.getRight() == null) { - node.setRight(new Node<>(key, value)); + Node newNode = new Node<>(key, value); + newNode.setParent(node); + node.setRight(newNode); } else { insert(node.getRight(), key, value); } } else if (node.getKey().compareTo(key) > 0) { if (node.getLeft() == null) { - node.setLeft(new Node<>(key, value)); + Node newNode = new Node<>(key, value); + newNode.setParent(node); + node.setLeft(newNode); } else { insert(node.getLeft(), key, value); } @@ -116,11 +120,15 @@ private Node delete(Node node, T key) { } /** - * Removes a key from the tree, if it exists + * Removes a key from the tree, if it exists. * * @param key to be removed + * @throws RuntimeException if key does not exist */ public void delete(T key) { + if (root == null) { + throw new RuntimeException("Key does not exist!"); + } root = delete(root, key); } @@ -206,10 +214,13 @@ private T predecessor(Node node) { * Search for the predecessor of a given key. * * @param key find predecessor of this key - * @return generic type value; null if key has no predecessor + * @return generic type value; null if key has no predecessor or key not found */ public T predecessor(T key) { Node curr = root; + if (curr == null) { + return null; + } while (curr != null) { if (curr.getKey().compareTo(key) == 0) { break; @@ -219,7 +230,9 @@ public T predecessor(T key) { curr = curr.getLeft(); } } - + if (curr == null) { + return null; // key not found + } return predecessor(curr); } @@ -249,10 +262,13 @@ private T successor(Node node) { * Search for the successor of a given key. * * @param key find successor of this key - * @return generic type value; null if key has no successor + * @return generic type value; null if key has no successor or key not found */ public T successor(T key) { Node curr = root; + if (curr == null) { + return null; + } while (curr != null) { if (curr.getKey().compareTo(key) == 0) { break; @@ -262,7 +278,9 @@ public T successor(T key) { curr = curr.getLeft(); } } - + if (curr == null) { + return null; // key not found + } return successor(curr); } @@ -294,7 +312,7 @@ public List getInorder() { } /** - * Stores in-order traversal of tree rooted at node into a list + * Stores pre-order traversal of tree rooted at node into a list * * @param node node which the tree is rooted at */ diff --git a/src/main/java/dataStructures/binarySearchTree/README.md b/src/main/java/dataStructures/binarySearchTree/README.md index b8368371..69905f9b 100644 --- a/src/main/java/dataStructures/binarySearchTree/README.md +++ b/src/main/java/dataStructures/binarySearchTree/README.md @@ -1,74 +1,77 @@ # Binary Search Tree -## Overview +## Background -A Binary Search Tree (BST) is a tree-based data structure in which each node has at most two children, referred to as -the left child and the right child. Each node in a BST contains a unique key and an associated value. The tree is -structured so that, for any given node: +A Binary Search Tree (BST) is a node-based tree structure where each node has at most two children. The key property (**BST invariant**): +- Left subtree contains only nodes with keys **less than** the node's key +- Right subtree contains only nodes with keys **greater than** the node's key -1. The left subtree contains nodes with keys less than the node's key. -2. The right subtree contains nodes with keys greater than the node's key. +This ordering enables efficient search by eliminating roughly half the remaining nodes (assuming balanced) at each step. Often, the time complexity of operations is proportional to the tree's height, making it efficient in the case of balanced trees. -This property makes BSTs efficient for operations like searching, as the average time complexity for many operations is -proportional to the tree's height. +### Predecessor and Successor -Note: in the following explanation a "smaller" node refers to a node with a smaller key and a "larger" node refers to a -node with a larger key. +- **Predecessor**: The largest key smaller than the given key +- **Successor**: The smallest key larger than the given key -## Implementation +Finding these involves two cases: +1. **In subtree**: Predecessor is the rightmost node in left subtree; successor is the leftmost node in right subtree +2. **In ancestors**: Traverse up via parent pointers until finding an ancestor that satisfies the condition -### BinarySearchTree Class +### Delete Operation -The BinarySearchTree class is a generic implementation of a BST. It supports a variety of operations that allow -interaction with the tree: +Delete has three cases based on the node's children: -- root(): Retrieve the root node of the tree. -- insert(T key, V value): Insert a key-value pair into the tree. -- delete(T key): Remove a key and its associated value from the tree. -- search(T key): Find a node with a specified key. -- predecessor(T key): Find the key of the predecessor of a specified key. -- successor(T key): Find the key of the successor of a specified key. -- searchMin(): Find the node with the minimum key in the tree. -- searchMax(): Find the node with the maximum key in the tree. -- getInorder(): Return an in-order traversal of the tree. -- getPreorder(): Return a pre-order traversal of the tree. -- getPostorder(): Return a post-order traversal of the tree. -- getLevelorder(): Return a level-order traversal of the tree. +| Case | Strategy | +|------|----------| +| **0 children** (leaf) | Simply remove the node | +| **1 child** | Replace node with its child | +| **2 children** | Replace node's key/value with its **successor**, then delete the successor | -We will expand on the delete implementation due to its relative complexity. +
Why does the 2-children case work? -#### Delete Implementation Details +The successor (smallest node in right subtree) maintains the BST invariant because: +1. It's larger than everything in the left subtree (since it's larger than the deleted node) +2. It's smaller than everything else in the right subtree (since it's the minimum there) -The delete operation is split into three different cases - when the node to be deleted has no children, one child or -two children. +Using the predecessor (largest in left subtree) works equally well. -**No children:** Simply delete the node. +
-**One child:** Reassign the parent attribute of the child to the parent of the node to be deleted. This will not violate -the binary search tree property as the right child will definitely be smaller than the parent of the deleted node. +## Complexity Analysis -**Two children:** Replace the deleted node with its successor. This works because the binary search tree property is -maintained: +| Operation | Average | Worst Case | Notes | +|-----------|---------|------------|-------| +| `search()` | `O(log n)` | `O(n)` | Worst case: degenerate (linear) tree | +| `insert()` | `O(log n)` | `O(n)` | Same as search | +| `delete()` | `O(log n)` | `O(n)` | Involves search + potential successor lookup | +| `predecessor()` / `successor()` | `O(log n)` | `O(n)` | May traverse full height | +| `searchMin()` / `searchMax()` | `O(log n)` | `O(n)` | Traverse to leftmost/rightmost | +| Traversals | `O(n)` | `O(n)` | Must visit all nodes | -1. the entire left subtree will definitely be smaller than the successor as the successor is larger than the deleted - node -2. the entire right subtree will definitely be larger than the successor as the successor will be the smallest node in - the right subtree +**Space**: `O(n)` for storing n nodes -### Node +**Interview tip:** The worst case `O(n)` occurs when insertions happen in sorted order, creating a "linked list" shape or also known as a "degenerate tree". This is why self-balancing trees (AVL, Red-Black) exist. -The Node class represents the nodes within the BinarySearchTree. Each Node instance contains: +## Notes -- key: The unique key associated with the node. -- value: The value associated with the key. -- left: Reference to the left child. -- right: Reference to the right child. -- parent: Reference to the parent node. +1. **No duplicates**: This implementation throws an exception on duplicate keys. To support duplicates, you could store counts in nodes or use a list as the value. -## Complexity Analysis +2. **Parent pointers**: Nodes maintain parent references to enable upward traversal for predecessor/successor finding. This adds space overhead but simplifies these operations. + +3. **Key-value pairs**: The implementation stores both keys (for ordering) and values (for data). Keys must be `Comparable`. + +4. **Unbalanced by design**: A basic BST does not self-balance. For guaranteed `O(log n)` operations, use [AVL Tree](../avlTree) or Red-Black Tree. + +## Applications -**Time Complexity:** For a balanced tree, most operations (insert, delete, search) can be performed in O(log n) time, -except tree traversal operations which can be performed in O(n) time. However, in the worst case (an unbalanced tree), -these operations may degrade to O(n). +| Use Case | Why BST? | +|----------|----------| +| In-memory sorted data | In-order traversal yields sorted sequence | +| Range queries | Find all keys in `[a, b]` efficiently | +| Floor/ceiling queries | Find largest key ≤ x or smallest key ≥ x | +| Symbol tables | Key-value lookup with ordering | -**Space Complexity:** O(n), where n is the number of elements in the tree. +**When to use BST vs alternatives:** +- Need sorted iteration? → BST (HashMap can't do this) +- Need guaranteed `O(log n)`? → Use AVL/Red-Black instead of basic BST +- Only need insert/search/delete? → HashMap is `O(1)` average, simpler diff --git a/src/main/java/dataStructures/disjointSet/README.md b/src/main/java/dataStructures/disjointSet/README.md index 23de0b52..a97cb4ee 100644 --- a/src/main/java/dataStructures/disjointSet/README.md +++ b/src/main/java/dataStructures/disjointSet/README.md @@ -27,10 +27,23 @@ Querying for connectivity and updating usually tracked with an internal array. a balanced tree and hence complexity does not necessarily improve - Note, this is not implemented but details can be found under weighted union folder. -3. **Weighted Union** - Same idea of using a tree, but constructed in a way that the tree is balanced, leading to -improved complexities. +3. **Weighted Union** - Same idea of using a tree, but constructed in a way that the tree is balanced, leading to +improved complexities. - Can be further augmented with path compression. +## Complexity Analysis + +| Implementation | Union | Find | Space | +|----------------|-------|------|-------| +| Quick Find | `O(n)` | `O(1)` | `O(n)` | +| Quick Union | `O(n)` | `O(n)` | `O(n)` | +| Weighted Union | `O(log n)` | `O(log n)` | `O(n)` | +| Weighted Union + Path Compression | `O(α(n))`* | `O(α(n))`* | `O(n)` | + +*`α(n)` is the inverse Ackermann function, which grows so slowly that it's effectively constant (`≤ 4`) for all practical input sizes. + +**Interview tip:** When asked about Union-Find complexity with path compression, say "amortized nearly constant time" or "O(α(n)) where α is the inverse Ackermann function, practically constant." + ## Applications Because of its efficiency and simplicity in implementing, Disjoint Set structures are widely used in practice: 1. As mentioned, it is often used as a helper structure for Kruskal's MST algorithm diff --git a/src/main/java/dataStructures/disjointSet/quickFind/README.md b/src/main/java/dataStructures/disjointSet/quickFind/README.md index 69291d4c..ee7313d9 100644 --- a/src/main/java/dataStructures/disjointSet/quickFind/README.md +++ b/src/main/java/dataStructures/disjointSet/quickFind/README.md @@ -21,12 +21,18 @@ Simply use the component identifier array to query for the component identity of and check if they are equal. This is why this implementation is known as "Quick Find". ## Complexity Analysis -Let n be the number of elements in consideration. -**Time**: - - Union: O(n) - - Find: O(1) +| Operation | Time | Notes | +|-----------|------|-------| +| Find | `O(1)` | Direct lookup in map | +| Union | `O(n)` | Must scan all elements to update identifiers | -**Space**: O(n) auxiliary space for the component identifier +**Space**: `O(n)` for the component identifier map -## Notes \ No newline at end of file +## Notes + +1. **When to use**: Quick Find is suitable when finds vastly outnumber unions. If you have many union operations, consider Weighted Union instead. + +2. **HashMap vs Array**: Our implementation uses `HashMap` to support arbitrary object types. If elements are integers `0` to `n-1`, a simple array suffices and is faster. + +3. **Union cost adds up**: Performing `n` union operations costs `O(n²)` total, which becomes prohibitive for large datasets. This is the main limitation of Quick Find. \ No newline at end of file diff --git a/src/main/java/dataStructures/disjointSet/weightedUnion/DisjointSet.java b/src/main/java/dataStructures/disjointSet/weightedUnion/DisjointSet.java index 9f1419f1..c5b76f08 100644 --- a/src/main/java/dataStructures/disjointSet/weightedUnion/DisjointSet.java +++ b/src/main/java/dataStructures/disjointSet/weightedUnion/DisjointSet.java @@ -92,9 +92,12 @@ public void add(T obj) { * Checks if object a and object b are in the same component. * @param a * @param b - * @return + * @return true if in same component, false otherwise or if either key doesn't exist */ public boolean find(T a, T b) { + if (!parents.containsKey(a) || !parents.containsKey(b)) { + return false; + } T rootOfA = findRoot(a); T rootOfB = findRoot(b); return rootOfA.equals(rootOfB); @@ -106,17 +109,26 @@ public boolean find(T a, T b) { * @param b */ public void union(T a, T b) { + if (!parents.containsKey(a) || !parents.containsKey(b)) { + return; // key(s) does not exist; do nothing + } + T rootOfA = findRoot(a); T rootOfB = findRoot(b); + + if (rootOfA.equals(rootOfB)) { + return; // already in same component + } + int sizeA = size.get(rootOfA); int sizeB = size.get(rootOfB); if (sizeA < sizeB) { - parents.put(rootOfA, rootOfB); // update root A to be child of root B - size.put(rootOfB, size.get(rootOfB) + size.get(rootOfA)); // update size of bigger tree + parents.put(rootOfA, rootOfB); + size.put(rootOfB, sizeA + sizeB); } else { - parents.put(rootOfB, rootOfA); // update root B to be child of root A - size.put(rootOfA, size.get(rootOfA) + size.get(rootOfB)); // update size of bigger tree + parents.put(rootOfB, rootOfA); + size.put(rootOfA, sizeA + sizeB); } } diff --git a/src/main/java/dataStructures/disjointSet/weightedUnion/README.md b/src/main/java/dataStructures/disjointSet/weightedUnion/README.md index 83b2912f..fbbe4245 100644 --- a/src/main/java/dataStructures/disjointSet/weightedUnion/README.md +++ b/src/main/java/dataStructures/disjointSet/weightedUnion/README.md @@ -28,11 +28,11 @@ For each of the node, we traverse up the tree from the current node until the ro two roots are the same ## Complexity Analysis -**Time**: O(n) for Union and Find operations. While union-ing is indeed quick, it is possibly undermined +**Time**: `O(n)` for Union and Find operations. While union-ing is indeed quick, it is possibly undermined by O(n) traversal in the case of a degenerate tree. Note that at this stage, there is nothing to ensure the trees are balanced. -**Space**: O(n), implementation still involves wrapping the n elements with some structure / wrapper (e.g. Node class). +**Space**: `O(n)`; still involves wrapping the n elements with some structure / wrapper (e.g. Node class). # Weighted Union @@ -70,9 +70,13 @@ In other words, using internal arrays or hash maps to track is sufficient to sim Our implementation uses hash map to account for arbitrary object type. ## Complexity Analysis -**Time**: O(log(n)) for Union and Find operations. -**Space**: Remains at O(n) +| Operation | Time | Notes | +|-----------|------|-------| +| Find | `O(log n)` | Height bounded by `log(n)` | +| Union | `O(log n)` | Dominated by two find operations | + +**Space**: `O(n)` for parent and size tracking ### Path Compression We can further improve on the time complexity of Weighted Union by introducing path compression. Specifically, during @@ -89,12 +93,20 @@ grandparents._ Credits: CS2040s Lecture Slides
-The analysis with compression is a bit trickier here and talks about the inverse-Ackermann function. -Interested readers can find out more [here](https://dl.acm.org/doi/pdf/10.1145/321879.321884). +The analysis with compression is trickier and involves the **inverse Ackermann function** `α(n)`. + +| Operation | Amortized Time | +|-----------|----------------| +| Find | `O(α(n))` | +| Union | `O(α(n))` | + +**What is `α(n)`?** The inverse Ackermann function grows *incredibly* slowly - for all practical values of `n` (up to ~10^80, more than atoms in the universe), `α(n) ≤ 4`. For all practical purposes, it's constant time. + +**Interview tip:** "With weighted union and path compression, union-find operations are amortized O(α(n)), which is effectively constant time for any realistic input size." -**Time**: O(alpha) +For the formal analysis, see [Tarjan's original paper](https://dl.acm.org/doi/pdf/10.1145/321879.321884). -**Space**: O(n) +**Space**: `O(n)` ## Notes ### Sample Demo - LeetCode 684: Redundant Connections diff --git a/src/main/java/dataStructures/heap/MaxHeap.java b/src/main/java/dataStructures/heap/MaxHeap.java index 9c105d37..d71b77a3 100644 --- a/src/main/java/dataStructures/heap/MaxHeap.java +++ b/src/main/java/dataStructures/heap/MaxHeap.java @@ -14,9 +14,10 @@ * offer(T item) - O(log(n)) * poll() - O(log(n)); Often named as extractMax(), poll is the corresponding counterpart in PriorityQueue * remove(T obj) - O(log(n)) + * updateKey(T obj) - O(log(n)) * decreaseKey(T obj) - O(log(n)) * increaseKey(T obj) - O(log(n)) - * heapify(List lst) - O(n) + * heapify(List lst) - O(n) * heapify(T ...seq) - O(n) * toString() * @@ -66,17 +67,17 @@ public T poll() { /** * Inserts item into heap. + * Note: Duplicates are not supported due to the Map augmentation. * * @param item item to be inserted */ public void offer(T item) { - // shouldn't happen as mentioned in README; do nothing, though should customize behaviour in practice if (indexOf.containsKey(item)) { - + return; // duplicates not supported } heap.add(item); // add to the end of the arraylist - indexOf.put(item, size() - 1); // add item into index map; here becomes problematic if there are duplicates + indexOf.put(item, size() - 1); // add item into index map bubbleUp(size() - 1); // bubbleUp to rightful place } @@ -86,8 +87,8 @@ public void offer(T item) { * @param obj object to be removed */ public void remove(T obj) { - if (!indexOf.containsKey(obj)) { // do nothing - + if (!indexOf.containsKey(obj)) { + return; // object not in heap } remove(indexOf.get(obj)); } @@ -99,11 +100,13 @@ public void remove(T obj) { * @return deleted element */ private T remove(int i) { - T item = get(i); // remember element to be removed + T item = get(i); swap(i, size() - 1); // O(1) swap with last element in the heap - heap.remove(size() - 1); // O(1) - indexOf.remove(item); // remove from index map - bubbleDown(i); // O(log n) + heap.remove(size() - 1); + indexOf.remove(item); + if (i < size()) { // only bubbleDown if not removing the last element + bubbleDown(i); + } return item; } @@ -111,17 +114,16 @@ private T remove(int i) { * Decrease the corresponding value of the object. * * @param obj old object - * @param updatedObj updated object + * @param updatedObj updated object with smaller value */ public void decreaseKey(T obj, T updatedObj) { - // shouldn't happen; do nothing, though should customize behaviour in practice - if (updatedObj.compareTo(obj) > 0) { - + if (!indexOf.containsKey(obj) || updatedObj.compareTo(obj) > 0) { + return; // object not found or updatedObj is not smaller } - int idx = indexOf.get(obj); // get the index of the object in the array implementation - heap.set(idx, updatedObj); // simply replace - indexOf.remove(obj); // no longer exists + int idx = indexOf.get(obj); + heap.set(idx, updatedObj); + indexOf.remove(obj); indexOf.put(updatedObj, idx); bubbleDown(idx); } @@ -130,21 +132,41 @@ public void decreaseKey(T obj, T updatedObj) { * Increase the corresponding value of the object. * * @param obj old object - * @param updatedObj updated object + * @param updatedObj updated object with larger value */ public void increaseKey(T obj, T updatedObj) { - // shouldn't happen; do nothing, though should customize behaviour in practice - if (updatedObj.compareTo(obj) < 0) { - return; + if (!indexOf.containsKey(obj) || updatedObj.compareTo(obj) < 0) { + return; // object not found or updatedObj is not larger } - int idx = indexOf.get(obj); // get the index of the object in the array implementation - heap.set(idx, updatedObj); // simply replace - indexOf.remove(obj); // no longer exists + int idx = indexOf.get(obj); + heap.set(idx, updatedObj); + indexOf.remove(obj); indexOf.put(updatedObj, idx); bubbleUp(idx); } + /** + * Update the value of an object in the heap. + * Delegates to increaseKey or decreaseKey based on the comparison. + * In practice, this unified method is often sufficient. + * + * @param obj old object + * @param updatedObj updated object + */ + public void updateKey(T obj, T updatedObj) { + if (!indexOf.containsKey(obj)) { + return; + } + int cmp = updatedObj.compareTo(obj); + if (cmp > 0) { + increaseKey(obj, updatedObj); + } else if (cmp < 0) { + decreaseKey(obj, updatedObj); + } + // if equal, no change needed + } + /** * Takes in a list of objects and convert it into a heap structure. * @@ -183,6 +205,9 @@ public void heapify(T... seq) { */ @Override public String toString() { + if (size() == 0) { + return "[]"; + } StringBuilder ret = new StringBuilder("["); for (int i = 0; i < size(); i++) { ret.append(heap.get(i)); @@ -284,7 +309,8 @@ private void bubbleUp(int i) { * @return boolean value that determines is leaf or not */ private boolean isLeaf(int i) { - // actually, suffice to compare index of left child of a node and size of heap + // check if node does not have a left child and does not have a right child + // actually, suffice to check if left child index is out of bound, as right child index > left child index return getRightIndex(i) >= size() && getLeftIndex(i) >= size(); } @@ -296,25 +322,25 @@ private boolean isLeaf(int i) { */ private void bubbleDown(int i) { while (!isLeaf(i)) { - T maxItem = get(i); - int maxIndex = i; // index of max item + T biggestItem = get(i); + int biggestItemIndex = i; // index of max item // check if left child is greater in priority, if left exists - if (getLeftIndex(i) < size() && maxItem.compareTo(getLeft(i)) < 0) { - maxItem = getLeft(i); - maxIndex = getLeftIndex(i); + if (getLeftIndex(i) < size() && biggestItem.compareTo(getLeft(i)) < 0) { + biggestItem = getLeft(i); + biggestItemIndex = getLeftIndex(i); } // check if right child is greater in priority, if right exists - if (getRightIndex(i) < size() && maxItem.compareTo(getRight(i)) < 0) { - maxIndex = getRightIndex(i); + if (getRightIndex(i) < size() && biggestItem.compareTo(getRight(i)) < 0) { + biggestItem = getRight(i); + biggestItemIndex = getRightIndex(i); } - if (maxIndex != i) { - swap(i, maxIndex); - i = maxIndex; - } else { - break; + if (biggestItemIndex == i) { + break; // heap property is achieved } + swap(i, biggestItemIndex); + i = biggestItemIndex; } } } diff --git a/src/main/java/dataStructures/heap/README.md b/src/main/java/dataStructures/heap/README.md index 554f7990..e8a29a0c 100644 --- a/src/main/java/dataStructures/heap/README.md +++ b/src/main/java/dataStructures/heap/README.md @@ -61,47 +61,84 @@ After all, the log factor in the order of growth will turn log(E) = log(V^2) in to 2log(V) = O(log(V)). ### Heapify - Choice between bubbleUp and bubbleDown -Heapify is a process used to create a heap data structure from an unordered array. One can also call `offer()` or -some insertion equivalent starting from an empty array, but that would take O(nlogn). +Heapify converts an unordered array into a heap. Two approaches exist: + +1. **Naive approach**: Insert elements one by one using `offer()` → `O(n log n)` +2. **Efficient approach**: BubbleDown from back to front → `O(n)` + +Both are **correct**, but bubbleDown is more efficient. Here's why:
- Loose Bound..? +Why bubbleDown gives O(n) but bubbleUp gives O(n log n) -The above mentioned that creating a heap through insertion of n elements would take O(nlogn).
-This is an upper-bound. Specifically, we have `n` insertions, and since we can have up to `n` elements in the heap, -the insertion operation would at most take `log(n)`. Hence, `O(nlogn)`. +The key insight is the **distribution of nodes by level** in a complete binary tree: +- ~`n/2` nodes are at the bottom level (leaves) +- ~`n/4` nodes are one level up +- ~`n/8` nodes are two levels up +- ... and so on +- 1 node at the root -
+**BubbleUp from front** (like repeated insertions): +- Level 0: 1 node, travels 0 levels +- Level 1: 2 nodes, each travels up to 1 level +- Level 2: 4 nodes, each travels up to 2 levels +- ... +- Level `log(n)`: **`n/2` nodes, each travels up to `log(n)` levels** -Heapify deals with bubbling down (for max heap) all elements starting from the back.
-What about bubbling-up all elements starting from the front instead?
-**No issue with correctness, problem lies with efficiency of operation.** +The bottom half alone contributes `(n/2) * log(n)` = **O(n log n)** -The number of operations required for bubbleUp and bubbleDown (to maintain heap property), is proportional to the -distance the node have to move. bubbleDown starts from the bottom level whereas bubbleUp starts from the top level. -Only 1 node is at the top whereas (approx) half the nodes is at the bottom level. It therefore makes sense to use -bubbleDown. +**BubbleDown from back**: +- Level `log(n)` (leaves): **`n/2` nodes, travel 0 levels** (already valid) +- Level `log(n)-1`: `n/4` nodes, each travels at most 1 level +- ... +- Level 0 (root): 1 node, travels at most `log(n)` levels -## Complexity Analysis +Total: `Σ (n/2^(k+1)) * k` for k from 0 to log(n) → converges to **O(n)** + +The difference: bubbleDown makes the **many** leaf nodes do **zero** work, while bubbleUp makes them do the **most** work. -**Time**: O(log(n)) in general for most native operations, -except heapify (building a heap from a sequence of elements) that takes O(n) + + +**Interview tip:** "Why is heapify O(n)?" → Most nodes are near the bottom and do little or no work with bubbleDown. The sum converges to O(n), not O(n log n). + +## Complexity Analysis -**Space**: O(n) +**Time:** +| Operation | Complexity | Notes | +|-----------|------------|-------| +| `peek()` | `O(1)` | Access root | +| `offer()` | `O(log n)` | Insert + bubbleUp | +| `poll()` | `O(log n)` | Remove root + bubbleDown | +| `remove(obj)` | `O(log n)` | With Map augmentation (otherwise `O(n)`) | +| `updateKey()` | `O(log n)` | With Map augmentation | +| `heapify()` (bubbleDown) | `O(n)` | Efficient approach | +| `heapify()` (bubbleUp) | `O(n log n)` | Naive approach | -where n is the number of elements (whatever the structure, it must store at least n nodes) +**Space**: `O(n)` where n is the number of elements (whatever the structure, it must store at least `n` nodes) ## Notes -1. Heaps are often presented as max-heaps (eg. in textbooks), hence the implementation follows a max-heap structure - - Still, it is not too difficult to convert a max heap to a min heap, simply negate the key value -2. The heap implemented here is actually augmented with a Map data type. This allows identification of nodes by key. - - Java's PriorityQueue and Python's heap actually support the removal of a node identified by its value / key. - Note that this is not a typical operation introduced alongside the concept heap simply because the time complexity - would now be O(n), no longer log(n). And indeed, both Java's and Python's version have time complexities - of O(n) for this remove operation since their underlying implementation is not augmented. - - The trade-off would be that the heap does not support insertion of duplicate objects else the Map would not work - as intended. -3. Rather than using Java arrays, where size must be declared upon initializing, we use list here in the implementation. -4. [Good read](https://stackoverflow.com/questions/9755721/how-can-building-a-heap-be-on-time-complexity?) on the - time complexity of heapify and making the correct choice between bubbleUp and bubbleDown. +1. **Max vs Min heap**: This implementation is a max-heap. To convert to min-heap, simply negate the key values or reverse the comparator. + +2. **Map augmentation**: The implementation uses a `Map` to track element positions, enabling `O(log n)` removal and key updates. Standard library heaps (Java's `PriorityQueue`, Python's `heapq`) lack this - their `remove()` is `O(n)` due to linear search. + - **Trade-off**: Duplicate elements are not supported since the Map requires unique keys. + +3. **updateKey in practice**: The implementation provides `increaseKey()` and `decreaseKey()` separately for educational purposes (they bubble in opposite directions). In practice, a unified `updateKey()` that determines the direction automatically is often sufficient. + +4. **ArrayList vs array**: We use `ArrayList` for dynamic resizing. A fixed-size array would require manual resizing logic. + +5. **Further reading**: [Why is building a heap O(n)?](https://stackoverflow.com/questions/9755721/how-can-building-a-heap-be-on-time-complexity) - good explanation of the heapify complexity analysis. + +## Applications + +Heaps are the underlying data structure for **priority queues**, enabling efficient access to the highest (or lowest) priority element. + +| Use Case | Why Heap? | +|----------|-----------| +| Dijkstra's shortest path | Extract min-distance vertex in `O(log V)` | +| Huffman encoding | Build optimal prefix codes by repeatedly merging smallest frequencies | +| K largest/smallest elements | Maintain a heap of size k while streaming | +| Median maintenance | Use two heaps (max-heap for lower half, min-heap for upper half) | +| Task scheduling | Priority-based job scheduling | + +**Interview tip:** For "find k largest elements in a stream", use a **min-heap** of size k. When a new element arrives, if it's larger than the heap's min, replace the min. The heap always contains the k largest seen so far. diff --git a/src/main/java/dataStructures/lruCache/README.md b/src/main/java/dataStructures/lruCache/README.md index 48f04a4c..db9abeaa 100644 --- a/src/main/java/dataStructures/lruCache/README.md +++ b/src/main/java/dataStructures/lruCache/README.md @@ -2,49 +2,74 @@ ## Background -Assuming that software engineers develop their applications using well-structured design patterns, programs tend to reuse data and instructions they've recently accessed (temporal locality) or access data elements that are close together in memory (spatial locality). +An **LRU (Least Recently Used) Cache** is a fixed-size cache that evicts the least recently accessed item when full. It exploits **temporal locality** - the principle that recently accessed data is likely to be accessed again soon. -### Temporal Locality +### How It Works -The Least Recently Used (LRU) Cache operates on the principle that the data most recently accessed is likely to be accessed again in the near future (temporal locality). By evicting the least recently accessed items first, LRU cache ensures that the most relevant data remains available in the cache. +The cache maintains items ordered by recency of access: +- **On access (`get`)**: Move item to front (most recent) +- **On insert (`put`)**: Add to front; if full, evict from back (least recent) +- **On update (`put` existing key)**: Update value and move to front -### Applications +### Data Structures -
    -
  1. Operating systems: Operating systems use LRU cache for memory management in page replacement algorithms. When a program requires more memory pages than are available in physical memory, the operating system decides which pages to evict to disc based on LRU caching, ensuring that the most recently accessed pages remain in memory.
  2. -
  3. Web browsers: Web browsers use LRU cache to store frequently accessed web pages. This allows users to quickly revisit pages without the need to fetch the entire content from the server.
  4. -
  5. Databases: Databases use LRU cache to store frequent query results. This reduces the need to access the underlying storage system for repeated queries.
  6. -
+LRU cache combines two structures for `O(1)` operations: -### Data Structures +| Structure | Purpose | +|-----------|---------| +| **HashMap** | `O(1)` lookup by key | +| **Doubly-linked list** | `O(1)` insertion/removal, maintains recency order | -Implementing an LRU cache typically involves using a combination of data structures. A common approach is to use a doubly-linked list to maintain the order of items based on access recency and a hash map to achieve constant-time access to any item in the cache. This combination effectively creates a data structure that supports the operations required for LRU cache. As nodes are connected in a doubly-linked list fashion, updating neighbours when rearranging recently cached items is as simple as redirecting the next and previous pointers of affected nodes. +``` +Most Recent Least Recent + ↓ ↓ +[HEAD] ⟷ [Node A] ⟷ [Node B] ⟷ [Node C] ⟷ [TAIL] + ↑ + Evict this on overflow +``` -Hash Map +The HashMap stores `key → node` mappings, allowing direct access to any node. The doubly-linked list allows `O(1)` move-to-front and removal without traversal. -### Cache Key +**Implementation detail**: Our implementation uses **sentinel nodes** (dummy head and tail), which eliminates null checks when inserting/removing at boundaries. -The hash map values are accessed through cache keys, which are unique references to the cached items in a LRU cache. Moreover, storing key-value pairs of hash keys and their corresponding nodes, which encapsulate cached items in a hash map and allows us to avoid O(n) sequential access of cached items. +## Complexity Analysis -### Eviction +| Operation | Time | Notes | +|-----------|------|-------| +| `get(key)` | `O(1)` expected | HashMap lookup + move to front | +| `put(key, value)` | `O(1)` expected | HashMap insert + list manipulation | -When the cache is full and a new item needs to be added, the eviction process is triggered. The item at the back of the list, which represents the least recently used data, is removed from both the list and the hash map. The new item is then added to the front of the list, and the cache key is stored in the hash map along with its corresponding cache value. +**Space**: `O(capacity)` - stores at most `capacity` items -However, if a cached item is accessed through a read-only operation, we still move the cached item to the front of the list without any eviction. Therefore, any form of interaction with a key will move its corresponding node to the front of the doubly-linked list without evection being triggered. Eviction is only applicable to write operations when a cache is considered full. +**Interview tip:** LRU Cache (LeetCode 146) is a classic design question. The key insight is combining HashMap + doubly-linked list. Know how to implement the move-to-front operation cleanly. -## Complexity Analysis +## Notes -**Time**: **expected** O(1) complexity +1. **Why doubly-linked?** When removing a node, we need to update its predecessor's `next` pointer. With a singly-linked list, finding the predecessor requires `O(n)` traversal. -As we rely on basic hash map operations to insert, access, and delete cache nodes, the get and put operations supported by LRU cache are influenced by the time complexity of these hash map operations. Insertion, lookup, and deletion operations in a well-designed hash map take O(1) time on average. Therefore, the hash map provides expected O(1) time on operations, and the doubly-linked list provides insertion and removal of nodes in O(1) time. +2. **Cache hit ratio**: A good cache achieves 95-99% hit ratio. If hits are rare, caching overhead may not be worthwhile. -**Space**: O(cache capacity) +3. **Stale data**: Frequently accessed items may stay cached indefinitely, becoming outdated. Consider TTL (time-to-live) for data that changes. -## Notes +4. **Thread safety**: For concurrent access, synchronization is needed (e.g., locks, concurrent data structures). Our implementation is not thread-safe. + +## Applications + +| Use Case | Example | +|----------|---------| +| OS page replacement | Keep recently used memory pages in RAM | +| Web browsers | Cache recently visited pages | +| Database query cache | Cache frequent query results | +| CDN edge caching | Cache popular content at edge servers | + +## Other Caching Strategies + +| Strategy | Eviction Rule | Best For | +|----------|---------------|----------| +| **LRU** | Least recently used | General purpose, temporal locality | +| **LFU** | Least frequently used | Popularity-based access patterns | +| **FIFO** | First in, first out | Simple, predictable eviction | +| **MRU** | Most recently used | Stack-like access (e.g., file scans) | +| **Random** | Random eviction | Low overhead, unpredictable access | -
    -
  1. Cache hit/miss ratio: A simple metric for measuring the effectiveness of the cache is the cache hit ratio. It is represented by the percentage of requests that are served from the cache without needing to access the original data store. Generally speaking, for most applications, a hit ratio of 95 - 99% is ideal.
  2. -
  3. Outdated cached data: A cached item that is constantly accessed and remains in cache for too long may become outdated.
  4. -
  5. Thread safety: When working with parallel computation, careful considerations have to be made when multiple threads try to access the cache at the same time. Thread-safe caching mechanisms may involve the proper use of mutex locks.
  6. -
  7. Other caching algorithms: First-In-First-Out (FIFO) cache, Least Frequently Used (LFU) cache, Most Recently Used (MRU) cache, and Random Replacement (RR) cache. The performance of different caching algorithms depends entirely on the application. LRU caching provides a good balance between performance and memory usage, making it suitable for a wide range of applications as most applications obey recency of data access (we often do reuse the same data in many applications). However, in the event that access patterns are random or even anti-recent, random replacement may perform better as it has less overhead when compared to LRU due to lack of bookkeeping.
  8. -
+**Interview tip:** Be ready to discuss trade-offs. LRU has more bookkeeping overhead than FIFO/Random but performs better when access patterns show temporal locality. diff --git a/src/main/java/dataStructures/queue/Deque/Deque.java b/src/main/java/dataStructures/queue/Deque/Deque.java index ea9afd62..f03b6146 100644 --- a/src/main/java/dataStructures/queue/Deque/Deque.java +++ b/src/main/java/dataStructures/queue/Deque/Deque.java @@ -114,6 +114,8 @@ public T pollFirst() { Node newFirstNode = this.first.next; if (newFirstNode != null) { newFirstNode.prev = null; + } else { + this.last = null; // removed the only element } this.first = newFirstNode; firstNode.next = null; @@ -134,6 +136,8 @@ public T pollLast() { Node newLastNode = lastNode.prev; if (newLastNode != null) { newLastNode.next = null; + } else { + this.first = null; // removed the only element } lastNode.prev = null; this.last = newLastNode; diff --git a/src/main/java/dataStructures/queue/Deque/README.md b/src/main/java/dataStructures/queue/Deque/README.md index b8e0980f..9eda6baa 100644 --- a/src/main/java/dataStructures/queue/Deque/README.md +++ b/src/main/java/dataStructures/queue/Deque/README.md @@ -1,21 +1,68 @@ -# Double Ended Queue (Deque) +# Double-Ended Queue (Deque) -![Deque](https://media.geeksforgeeks.org/wp-content/uploads/anod.png) +## Background -*Source: GeeksForGeeks* +A deque (pronounced "deck") is a queue that supports insertion and removal at **both ends**. It generalizes both stacks and queues - you can use it as either. -Deque is a variant of queue where elements can be removed or added from the head and tail of the queue. -Deque could come in handy when trying to solve sliding window problems. This means it neither follows a fixed FIFO -or LIFO order but rather can utilise either orders flexibly. +
+ Deque +
+ Source: GeeksForGeeks +
-A deque can be implemented in multiple ways, using doubly linked lists, arrays or two stacks. +### Core Operations +| Front | Back | +|-------|------| +| `addFirst()` | `addElement()` (addLast) | +| `pollFirst()` | `pollLast()` | +| `peekFirst()` | `peekLast()` | -## Analysis +## Complexity Analysis -Much like a queue, deque operations involves the head / tail, resulting in *O(1)* complexity for most operations. +| Operation | Time | Notes | +|-----------|------|-------| +| `addFirst()` / `addElement()` | `O(1)` | Insert at either end | +| `pollFirst()` / `pollLast()` | `O(1)` | Remove from either end | +| `peekFirst()` / `peekLast()` | `O(1)` | Access either end | + +**Space**: `O(n)` for n elements ## Notes -Just like a queue, a monotonic deque could also be created to solve more specific sliding window problems. +1. **Implementation**: Our implementation uses a doubly-linked list. Alternatives include circular arrays (Java's `ArrayDeque`) or two stacks. + +2. **Versatility**: A deque can simulate: + - **Queue**: `addElement()` + `pollFirst()` (FIFO) + - **Stack**: `addFirst()` + `pollFirst()` (LIFO) + +3. **Java's ArrayDeque**: Preferred over `LinkedList` for most use cases - faster due to cache locality and no node allocation overhead. + +**Interview tip:** When asked to implement a queue using stacks (or vice versa), a deque provides both interfaces naturally. Understanding deque operations helps with these classic interview questions. + +## Applications + +| Use Case | Why Deque? | +|----------|-----------| +| Sliding window maximum/minimum | Remove expired elements from front, maintain order from back | +| Palindrome checking | Compare characters from both ends simultaneously | +| Work stealing (parallel computing) | Threads steal from opposite end to reduce contention | +| Undo/Redo with bounded history | Remove oldest when limit reached, add new at one end | +| Browser history | Back/forward navigation from current position | + +### Sliding Window Pattern + +Deques are essential for the **sliding window maximum/minimum** pattern: + +``` +Window slides right → +[1, 3, -1, -3, 5, 3, 6, 7], k=3 + +Window [1,3,-1] → max = 3 +Window [3,-1,-3] → max = 3 +Window [-1,-3,5] → max = 5 +... +``` +A [Monotonic Queue](../monotonicQueue) (built on a deque) solves this in `O(n)` instead of `O(n*k)`. +**Interview tip:** When you see "sliding window" + "maximum/minimum", think deque. The key insight: elements that can never be the answer can be discarded early. diff --git a/src/main/java/dataStructures/queue/README.md b/src/main/java/dataStructures/queue/README.md index 9d2477bf..0b55be69 100644 --- a/src/main/java/dataStructures/queue/README.md +++ b/src/main/java/dataStructures/queue/README.md @@ -1,42 +1,53 @@ # Queue ## Background -A queue is a linear data structure that restricts the order in which operations can be performed on its elements. -### Operation Orders +A queue is a linear data structure that follows **FIFO** (First In, First Out) order - the earliest element added is the first one removed. -![Queue](https://media.geeksforgeeks.org/wp-content/cdn-uploads/20221213113312/Queue-Data-Structures.png) +
+ Queue +
+ Source: GeeksForGeeks +
-*Source: GeeksForGeeks* +### Core Operations +- **enqueue** (offer/add): Add element to the back +- **dequeue** (poll/remove): Remove element from the front +- **peek**: View front element without removing -Queue follows a FIFO, first in first out order. -This means the earliest element -added to the stack is the one operations are conducted on first. +## Complexity Analysis -A [stack](../stack/README.md) is a queue with operations conducted in an opposite manner. +| Operation | Time | Notes | +|-----------|------|-------| +| `enqueue()` | `O(1)` | Add to back | +| `dequeue()` | `O(1)` | Remove from front | +| `peek()` | `O(1)` | Access front | +| `isEmpty()` | `O(1)` | Check size | -## Analysis - -As a queue only interacts with either the first or last element regardless during its operations, -it only needs to keep the pointers of the two element at hand, which is constantly updated as more -elements are removed / added. This allows queue operations to only incur a *O(1)* time complexity. +**Space**: `O(n)` for n elements ## Notes -### Stack vs Queues +1. **Array vs Linked List**: Our implementation uses a linked list, allowing unbounded growth. Array-based queues are faster (cache-friendly) but need resizing or circular buffer logic. + +2. **Java equivalents**: `enqueue()` → `offer()`/`add()`, `dequeue()` → `poll()`/`remove()` -Stack and queues only differ in terms of operation order, you should aim to use a stack when -you want the most recent elements to be operated on. -Some situations where a stack would work well include build redo / undo systems and backtracking problems. +3. **Queue vs Stack**: + - Queue (FIFO): Process in arrival order → BFS, task scheduling, buffering + - [Stack](../stack) (LIFO): Process most recent first → DFS, undo/redo, parsing -On the other hand, a queue allows you to operate on elements that enter first. Some situations where -this would be useful include Breadth First Search algorithms and task / resource allocation systems. +## Applications -### Arrays vs Linked List +| Use Case | Why Queue? | +|----------|-----------| +| BFS traversal | Process nodes level by level | +| Task scheduling | First-come-first-served processing | +| Buffering | Handle producer-consumer speed mismatch | +| Print spooling | Documents printed in submission order | -It is worth noting that queues can be implemented with either a array or with a [linked list](../linkedList/README.md). -In the context of ordered operations, the lookup is only restricted to the first element. +## Variants -Hence, there is not much advantage in using a array, which only has a better lookup speed (*O(1)* time complexity) -to implement a queue. Especially when using a linked list to construct your queue -would allow you to grow or shrink the queue as you wish. +- [**Deque**](./Deque) - Double-ended queue, add/remove from both ends +- [**Monotonic Queue**](./monotonicQueue) - Maintains sorted order, useful for sliding window problems +- **Priority Queue** - Elements ordered by priority, not arrival time (see [Heap](../heap)) +- **Circular Queue** - Fixed-size array with wrap-around, efficient for bounded buffers diff --git a/src/main/java/dataStructures/queue/monotonicQueue/MonotonicQueue.java b/src/main/java/dataStructures/queue/monotonicQueue/MonotonicQueue.java index 790c3896..e7fd03bd 100644 --- a/src/main/java/dataStructures/queue/monotonicQueue/MonotonicQueue.java +++ b/src/main/java/dataStructures/queue/monotonicQueue/MonotonicQueue.java @@ -1,4 +1,4 @@ -package dataStructures.queue; +package dataStructures.queue.monotonicQueue; import java.util.ArrayDeque; @@ -44,7 +44,6 @@ public boolean isEmpty() { * @param obj to be inserted. */ public void push(T obj) { - Integer count = 0; while (!dq.isEmpty() && obj.compareTo(dq.peekLast()) > 0) { dq.pollLast(); // Removes elements that do not conform the non-increasing sequence. } diff --git a/src/main/java/dataStructures/queue/monotonicQueue/README.md b/src/main/java/dataStructures/queue/monotonicQueue/README.md index c3e114f3..d2d57016 100644 --- a/src/main/java/dataStructures/queue/monotonicQueue/README.md +++ b/src/main/java/dataStructures/queue/monotonicQueue/README.md @@ -1,16 +1,141 @@ -# Monotonic Queue +# Monotonic Queue / Monotonic Stack -This is a variant of queue where elements within the queue are either strictly increasing or decreasing. -Monotonic queues are often implemented with a deque. +## Background -Within a increasing monotonic queue, any element that is smaller than the current minimum is removed. -Within a decreasing monotonic queue, any element that is larger than the current maximum is removed. +A **monotonic queue** (or **monotonic stack**) maintains elements in sorted order (increasing or decreasing) by removing elements that violate the monotonic property when new elements are added. -It is worth mentioning that the most elements added to the monotonic queue would always be in a -increasing / decreasing order, -hence, we only need to compare down the monotonic queue from the back when adding new elements. +The key insight: **elements that can never be useful are discarded immediately**. -## Operation Orders +### Monotonic Queue vs Monotonic Stack -Just like a queue, a monotonic queue mainly has *O(1)* operations, -which is unique given that it ensures a certain order, which usually incurs operations of a higher complexity. +| Structure | Remove from | Use case | +|-----------|-------------|----------| +| **Monotonic Stack** | Same end as insert (LIFO) | "Next greater/smaller element" | +| **Monotonic Queue** | Both ends (deque-based) | "Sliding window max/min" | + +Both maintain monotonic order; the difference is whether you need to expire old elements (queue) or just find relationships (stack). + +## How It Works + +### Decreasing Monotonic Queue (for sliding window maximum) + +A monotonic **queue** is needed when elements must **expire** - i.e., sliding window problems. You remove from **both ends**: +- **Back**: Remove smaller elements (they can't be max while larger element exists) +- **Front**: Remove expired elements (outside the window) + +``` +Array: [5, 3, 4, 1, 2], k=3 +Queue stores indices. Front is always the window maximum. + +i=0 (5): push 0 queue=[0] +i=1 (3): 3 < 5, push 1 queue=[0,1] +i=2 (4): 4 > 3, pop 1; 4 < 5, push 2 queue=[0,2] → window[0-2], max=arr[0]=5 +i=3 (1): 1 < 4, push 3 queue=[0,2,3] + window is [1-3], idx 0 < 1 → EXPIRED, pop front! + queue=[2,3] → max=arr[2]=4 +i=4 (2): 2 > 1, pop 3; 2 < 4, push 4 queue=[2,4] + window is [2-4], idx 2 >= 2 → valid → max=arr[2]=4 + +Output: [5, 4, 4] – Length of final result is `n - k + 1`. +``` + +**Why queue, not stack?** The front removal for expiration (see i=3). A stack can't efficiently remove from the front. + +### Decreasing (non-increasing) Monotonic Stack (for "next greater element") + +Stack holds indices of elements **waiting** for their next greater. When a new element is greater than the stack top, we've found the answer for that top. + +``` +Array: [2, 1, 2, 4, 3] +Find: next greater element for each + +Process left to right, maintain decreasing stack (of indices): + +i=0 (2): stack empty, push 0 stack=[0] +i=1 (1): 1 < arr[0]=2, push 1 stack=[0,1] +i=2 (2): 2 > arr[1]=1 → pop 1, ans[1]=2; push 2 stack=[0,2] +i=3 (4): 4 > arr[2]=2 → pop 2, ans[2]=4 + 4 > arr[0]=2 → pop 0, ans[0]=4; push 3 stack=[3] +i=4 (3): 3 < arr[3]=4, push 4 stack=[3,4] +End: remaining indices have no next greater ans[3]=-1, ans[4]=-1 + +Result: [4, 2, 4, -1, -1] +``` + +**Why decreasing?** Elements in the stack are waiting for something bigger. If a new element is smaller, it also needs to wait → push it. If bigger, it's the answer → pop and record. + +## Complexity Analysis + +| Operation | Amortized Time | Notes | +|-----------|----------------|-------| +| `push()` | `O(1)` | Each element pushed/popped at most once | +| `pop()` | `O(1)` | Remove from front | +| `max()` / `min()` | `O(1)` | Front of queue | + +**Overall**: Processing n elements takes `O(n)` total, not `O(n²)`. + +**Space**: `O(n)` worst case (when input is already sorted in desired order) + +## When to Use: Pattern Recognition + +### Use Monotonic STACK when: + +| Pattern | Example Problem | +|---------|-----------------| +| "Next greater element" | Next Greater Element I, II (LC 496, 503) | +| "Next smaller element" | Daily Temperatures (LC 739) | +| "Previous greater/smaller" | Process left-to-right instead | +| Histogram problems | Largest Rectangle in Histogram (LC 84) | +| Stock span problems | Online Stock Span (LC 901) | + +**Trigger phrases**: "next greater", "next smaller", "how many days until", "span of consecutive" + +### Use Monotonic QUEUE when: + +| Pattern | Example Problem | +|---------|-----------------| +| "Sliding window maximum" | Sliding Window Maximum (LC 239) | +| "Sliding window minimum" | Shortest Subarray with Sum ≥ K (LC 862) | +| Fixed-size window + max/min | Maximum of all subarrays of size k | +| Constrained optimization | Jump Game VI (LC 1696) | + +**Trigger phrases**: "sliding window", "subarray of size k", "maximum/minimum in window" + +## Classic Problems + +### 1. Sliding Window Maximum (LC 239) +``` +Input: nums = [1,3,-1,-3,5,3,6,7], k = 3 +Output: [3,3,5,5,6,7] + +Use decreasing monotonic queue: +- Push new element (remove smaller elements from back) +- Pop expired elements from front (index out of window) +- Front is always the window maximum +``` + +### 2. Largest Rectangle in Histogram (LC 84) +``` +For each bar, find: how far left/right can it extend? += find previous smaller & next smaller elements + +Use increasing monotonic stack for both passes. +``` + +## Notes + +1. **Increasing vs Decreasing**: + - Decreasing queue/stack → finding **maximums** or **next greater** + - Increasing queue/stack → finding **minimums** or **next smaller** + +2. **Both directions work**: Left-to-right treats the stack as elements "waiting" for their answer. Right-to-left treats the stack as "candidates" to the right. Choose whichever mental model clicks for you. + +3. **Queue vs Stack choice**: + - Need to expire old elements? → Queue (sliding window) + - Just need relationships? → Stack (next/previous element) + +**Interview tip:** When you see `O(n²)` brute force for finding max/min or next greater/smaller, monotonic structures often give `O(n)`. The insight is that dominated elements can be discarded—if a newer element is better, older worse elements will never be chosen. + +## Implementation Note + +Our implementation is a **decreasing monotonic queue** (maintains maximum at front). For a monotonic stack, simply use a regular stack and apply the same comparison logic—no special data structure needed. diff --git a/src/main/java/dataStructures/trie/README.md b/src/main/java/dataStructures/trie/README.md index 76193ddf..f67877bb 100644 --- a/src/main/java/dataStructures/trie/README.md +++ b/src/main/java/dataStructures/trie/README.md @@ -54,19 +54,27 @@ traversing an edge labeled 'b' from one node to another means you're adding the ## Complexity Analysis -Let the length of the longest word be `L` and the number of words be `N`. +Let `L` be the length of the word (or longest word), and `N` be the number of words. -**Time**: O(`L`) -An upper-bound. For typical trie operations like insert, delete, and search, -since it is likely that every char is iterated over. +| Operation | Time | Notes | +|-----------|------|-------| +| `insert()` | `O(L)` | Traverse/create nodes for each character | +| `search()` | `O(L)` | Traverse nodes for each character | +| `delete()` | `O(L)` | Traverse and unmark end flag | +| `deleteAndPrune()` | `O(L)` | Traverse twice (down then up for pruning) | +| `wordsWithPrefix()` | `O(L + M)` | `L` to reach prefix, `M` = total chars in matching words | -**Space**: O(`N*L`) -In the worst case, we can have minimal overlap between words and every character of every word needs to be captured -with a node. +**Space**: `O(N * L)` in the worst case, when words have minimal overlap and every character needs its own node. -A trie can be space-intensive. For a very large corpus of words, with the naive assumption of characters being -likely to occur in any position, another naive estimation on the size of the tree is O(_26^l_) where _l_ here is -the average length of a word. Note, 26 is used since are only 26 alphabets. +
Space usage in practice + +The `O(N * L)` bound assumes each word contributes all its characters as separate nodes. In practice, shared prefixes reduce this (e.g., "app", "apple", "application" share the "app" path). + +A theoretical maximum for a trie structure is `O(26^L)` - this would occur if the trie stored *every possible string* of length up to `L` (all combinations of 26 letters). This is only relevant for complete/exhaustive tries, not for storing a specific vocabulary of `N` words. + +Using a HashMap for children (as in our implementation) only allocates space for existing children, avoiding the cost of 26-element arrays at each node. + +
## Operations Here we briefly discuss the typical operations supported by a trie. @@ -101,8 +109,20 @@ to quickly find out how many complete words stored in a trie have a given prefix descendant nodes whose boolean flag is set to true at each node. ## Notes -### Applications -- [auto-completion](https://medium.com/geekculture/how-to-effortlessly-implement-an-autocomplete-data-structure-in-javascript-using-a-trie-ea87a7d5a804) -- [spell-checker](https://medium.com/@vithusha.ravirajan/enhancing-spell-checking-with-trie-data-structure-eb649ee0b1b5) -- [prefix matching](https://medium.com/@shenchenlei/how-to-implement-a-prefix-matcher-using-trie-tree-1aea9a01013) -- sorting large datasets of textual data \ No newline at end of file + +1. **Case sensitivity**: Our implementation converts all words to lowercase. To support case-sensitive storage, remove the `toLowerCase()` calls. + +2. **HashMap vs Array**: We use `HashMap` for children, which supports arbitrary characters (Unicode, spaces, etc.). An alternative is a fixed-size array (e.g., `TrieNode[26]` for lowercase letters), which is faster but wastes space when nodes have few children. + +3. **Pruning**: The basic `delete()` only unmarks the end flag - the nodes remain. For long-running applications, use `deleteAndPrune()` to reclaim memory. + +4. **Trie vs HashMap**: For simple word lookup, a `HashSet` is `O(L)` time and simpler. Tries shine when you need prefix operations (autocomplete, prefix counting) or lexicographic ordering. + +**Interview tip:** When asked "why use a trie instead of a hash table?", emphasize prefix operations. A HashSet can check if "apple" exists, but cannot efficiently find all words starting with "app". + +## Applications +- [Auto-completion](https://medium.com/geekculture/how-to-effortlessly-implement-an-autocomplete-data-structure-in-javascript-using-a-trie-ea87a7d5a804) - find all words with a given prefix +- [Spell-checker](https://medium.com/@vithusha.ravirajan/enhancing-spell-checking-with-trie-data-structure-eb649ee0b1b5) - suggest corrections by finding similar words +- [Prefix matching](https://medium.com/@shenchenlei/how-to-implement-a-prefix-matcher-using-trie-tree-1aea9a01013) - IP routing, longest prefix match +- Lexicographic sorting - in-order traversal yields sorted words +- Word games - Boggle solvers, Scrabble word validators \ No newline at end of file diff --git a/src/main/java/dataStructures/trie/Trie.java b/src/main/java/dataStructures/trie/Trie.java index 63fc4e5b..a996f30e 100644 --- a/src/main/java/dataStructures/trie/Trie.java +++ b/src/main/java/dataStructures/trie/Trie.java @@ -41,7 +41,7 @@ public void insert(String word) { for (int i = 0; i < word.length(); i++) { curr = word.charAt(i); if (!trav.children.containsKey(curr)) { - trav.children.put(curr, new TrieNode()); // recall: new char is represented by this child node + trav.children.put(curr, new TrieNode()); // new char is represented by this child node } trav = trav.children.get(curr); } @@ -98,6 +98,7 @@ public void delete(String word) { * @param word */ public void deleteAndPrune(String word) { + word = word.toLowerCase(); List trackNodes = new ArrayList<>(); TrieNode trav = root; trackNodes.add(trav); @@ -132,6 +133,7 @@ public void deleteAndPrune(String word) { * @return a list of words. */ public List wordsWithPrefix(String prefix) { + prefix = prefix.toLowerCase(); List ret = new ArrayList<>(); TrieNode trav = root; for (int i = 0; i < prefix.length(); i++) { diff --git a/src/test/java/dataStructures/queue/MonotonicQueueTest.java b/src/test/java/dataStructures/queue/MonotonicQueueTest.java index 8bca1530..a30d0dbd 100644 --- a/src/test/java/dataStructures/queue/MonotonicQueueTest.java +++ b/src/test/java/dataStructures/queue/MonotonicQueueTest.java @@ -3,6 +3,8 @@ import org.junit.Assert; import org.junit.Test; +import dataStructures.queue.monotonicQueue.MonotonicQueue; + /** * This class implements tests for the monotonic queue. */