"
]
},
"execution_count": 5,
@@ -207,7 +207,7 @@
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
- "nbconvert_exporter": "python3",
+ "nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
}
diff --git a/leetcode/implement_trie_prefix_tree/solution.py b/leetcode/implement_trie_prefix_tree/solution.py
index 19ecc1e..35ea50b 100644
--- a/leetcode/implement_trie_prefix_tree/solution.py
+++ b/leetcode/implement_trie_prefix_tree/solution.py
@@ -1,6 +1,4 @@
-from typing import Any
-
-from leetcode_py.data_structures import DictTree
+from leetcode_py.data_structures import DictTree, RecursiveDict
class Trie(DictTree[str]):
@@ -9,7 +7,7 @@ class Trie(DictTree[str]):
# Time: O(1)
# Space: O(1)
def __init__(self) -> None:
- self.root: dict[str, Any] = {}
+ self.root: RecursiveDict[str] = {}
# Time: O(m) where m is word length
# Space: O(m)
@@ -19,7 +17,7 @@ def insert(self, word: str) -> None:
if char not in node:
node[char] = {}
node = node[char]
- node[self.END_OF_WORD] = True # End of word marker
+ node[self.END_OF_WORD] = True
# Time: O(m) where m is word length
# Space: O(1)
diff --git a/leetcode/longest_substring_without_repeating_characters/README.md b/leetcode/longest_substring_without_repeating_characters/README.md
new file mode 100644
index 0000000..53c884d
--- /dev/null
+++ b/leetcode/longest_substring_without_repeating_characters/README.md
@@ -0,0 +1,46 @@
+# Longest Substring Without Repeating Characters
+
+**Difficulty:** Medium
+**Topics:** Hash Table, String, Sliding Window
+**Tags:** grind-75
+
+**LeetCode:** [Problem 3](https://leetcode.com/problems/longest-substring-without-repeating-characters/description/)
+
+## Problem Description
+
+Given a string `s`, find the length of the **longest** **substring** without duplicate characters.
+
+## Examples
+
+### Example 1:
+
+```
+Input: s = "abcabcbb"
+Output: 3
+```
+
+**Explanation:** The answer is "abc", with the length of 3.
+
+### Example 2:
+
+```
+Input: s = "bbbbb"
+Output: 1
+```
+
+**Explanation:** The answer is "b", with the length of 1.
+
+### Example 3:
+
+```
+Input: s = "pwwkew"
+Output: 3
+```
+
+**Explanation:** The answer is "wke", with the length of 3.
+Notice that the answer must be a substring, "pwke" is a subsequence and not a substring.
+
+## Constraints
+
+- 0 <= s.length <= 5 \* 10^4
+- s consists of English letters, digits, symbols and spaces.
diff --git a/leetcode/longest_substring_without_repeating_characters/__init__.py b/leetcode/longest_substring_without_repeating_characters/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/longest_substring_without_repeating_characters/playground.ipynb b/leetcode/longest_substring_without_repeating_characters/playground.ipynb
new file mode 100644
index 0000000..c437eba
--- /dev/null
+++ b/leetcode/longest_substring_without_repeating_characters/playground.ipynb
@@ -0,0 +1,79 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "s = \"abcabcbb\"\n",
+ "expected = 3"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "3"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().length_of_longest_substring(s)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/longest_substring_without_repeating_characters/solution.py b/leetcode/longest_substring_without_repeating_characters/solution.py
new file mode 100644
index 0000000..034f4a8
--- /dev/null
+++ b/leetcode/longest_substring_without_repeating_characters/solution.py
@@ -0,0 +1,15 @@
+class Solution:
+ # Time: O(n)
+ # Space: O(min(m, n)) where m is charset size
+ def length_of_longest_substring(self, s: str) -> int:
+ seen: set[str] = set()
+ left = max_len = 0
+
+ for right in range(len(s)):
+ while s[right] in seen:
+ seen.remove(s[left])
+ left += 1
+ seen.add(s[right])
+ max_len = max(max_len, right - left + 1)
+
+ return max_len
diff --git a/leetcode/longest_substring_without_repeating_characters/tests.py b/leetcode/longest_substring_without_repeating_characters/tests.py
new file mode 100644
index 0000000..272cdd4
--- /dev/null
+++ b/leetcode/longest_substring_without_repeating_characters/tests.py
@@ -0,0 +1,35 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestLongestSubstringWithoutRepeatingCharacters:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "s, expected",
+ [
+ ("abcabcbb", 3), # "abc"
+ ("bbbbb", 1), # "b"
+ ("pwwkew", 3), # "wke"
+ ("", 0), # empty
+ ("a", 1), # single char
+ ("au", 2), # "au"
+ ("dvdf", 3), # "vdf"
+ ("abcdef", 6), # no repeats
+ ("aab", 2), # "ab"
+ ("cdd", 2), # "cd"
+ ("abba", 2), # "ab" or "ba"
+ ("tmmzuxt", 5), # "mzuxt"
+ (" ", 1), # space char
+ ("!@#$%", 5), # symbols
+ ("abcabcabcabc", 3), # repeating pattern
+ ],
+ )
+ @logged_test
+ def test_length_of_longest_substring(self, s: str, expected: int):
+ result = self.solution.length_of_longest_substring(s)
+ assert result == expected
diff --git a/leetcode/lowest_common_ancestor_of_a_binary_search_tree/playground.ipynb b/leetcode/lowest_common_ancestor_of_a_binary_search_tree/playground.ipynb
index 763d0ea..89e233e 100644
--- a/leetcode/lowest_common_ancestor_of_a_binary_search_tree/playground.ipynb
+++ b/leetcode/lowest_common_ancestor_of_a_binary_search_tree/playground.ipynb
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 1,
"id": "imports",
"metadata": {},
"outputs": [],
@@ -14,7 +14,7 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 2,
"id": "setup",
"metadata": {},
"outputs": [],
@@ -28,21 +28,171 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 3,
"id": "execute",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "6"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
"source": [
"root = TreeNode[int].from_list(root_list)\n",
- "p = find_node(root, p_val)\n",
- "q = find_node(root, q_val)\n",
+ "assert root is not None\n",
+ "p = root.find_node(p_val)\n",
+ "q = root.find_node(q_val)\n",
+ "assert p is not None and q is not None\n",
"result = Solution().lowest_common_ancestor(root, p, q)\n",
"result.val if result else None"
]
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 5,
+ "id": "d84494ee",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "TreeNode([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5])"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "root"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
"id": "test",
"metadata": {},
"outputs": [],
@@ -65,7 +215,7 @@
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
- "nbconvert_exporter": "python3",
+ "nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
}
diff --git a/leetcode/lowest_common_ancestor_of_a_binary_search_tree/tests.py b/leetcode/lowest_common_ancestor_of_a_binary_search_tree/tests.py
index 33767fe..b28db6c 100644
--- a/leetcode/lowest_common_ancestor_of_a_binary_search_tree/tests.py
+++ b/leetcode/lowest_common_ancestor_of_a_binary_search_tree/tests.py
@@ -10,16 +10,6 @@ class TestLowestCommonAncestorOfABinarySearchTree:
def setup_method(self):
self.solution = Solution()
- def _find_node(self, root: TreeNode[int] | None, val: int):
- if not root:
- return None
- if root.val == val:
- return root
- left = self._find_node(root.left, val)
- if left:
- return left
- return self._find_node(root.right, val)
-
@pytest.mark.parametrize(
"root_list, p_val, q_val, expected_val",
[
@@ -37,8 +27,8 @@ def test_lowest_common_ancestor(
):
root = TreeNode[int].from_list(root_list)
assert root is not None
- p = self._find_node(root, p_val)
- q = self._find_node(root, q_val)
+ p = root.find_node(p_val)
+ q = root.find_node(q_val)
assert p is not None and q is not None
result = self.solution.lowest_common_ancestor(root, p, q)
assert result is not None
diff --git a/leetcode/lowest_common_ancestor_of_a_binary_tree/README.md b/leetcode/lowest_common_ancestor_of_a_binary_tree/README.md
new file mode 100644
index 0000000..1c562a6
--- /dev/null
+++ b/leetcode/lowest_common_ancestor_of_a_binary_tree/README.md
@@ -0,0 +1,50 @@
+# Lowest Common Ancestor of a Binary Tree
+
+**Difficulty:** Medium
+**Topics:** Tree, Depth-First Search, Binary Tree
+**Tags:** grind-75
+
+**LeetCode:** [Problem 236](https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-tree/description/)
+
+## Problem Description
+
+Given a binary tree, find the lowest common ancestor (LCA) of two given nodes in the tree.
+
+According to the definition of LCA on Wikipedia: "The lowest common ancestor is defined between two nodes `p` and `q` as the lowest node in `T` that has both `p` and `q` as descendants (where we allow **a node to be a descendant of itself**)."
+
+## Examples
+
+### Example 1:
+
+
+
+```
+Input: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
+Output: 3
+Explanation: The LCA of nodes 5 and 1 is 3.
+```
+
+### Example 2:
+
+
+
+```
+Input: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
+Output: 5
+Explanation: The LCA of nodes 5 and 4 is 5, since a node can be a descendant of itself according to the LCA definition.
+```
+
+### Example 3:
+
+```
+Input: root = [1,2], p = 1, q = 2
+Output: 1
+```
+
+## Constraints
+
+- The number of nodes in the tree is in the range [2, 10^5].
+- -10^9 <= Node.val <= 10^9
+- All Node.val are unique.
+- p != q
+- p and q will exist in the tree.
diff --git a/leetcode/lowest_common_ancestor_of_a_binary_tree/__init__.py b/leetcode/lowest_common_ancestor_of_a_binary_tree/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/lowest_common_ancestor_of_a_binary_tree/playground.ipynb b/leetcode/lowest_common_ancestor_of_a_binary_tree/playground.ipynb
new file mode 100644
index 0000000..6594efb
--- /dev/null
+++ b/leetcode/lowest_common_ancestor_of_a_binary_tree/playground.ipynb
@@ -0,0 +1,222 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution\n",
+ "\n",
+ "from leetcode_py import TreeNode"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "root_list = [3, 5, 1, 6, 2, 0, 8, None, None, 7, 4]\n",
+ "root = TreeNode.from_list(root_list)\n",
+ "assert root is not None\n",
+ "p = root.find_node(5)\n",
+ "q = root.find_node(1)\n",
+ "expected_val = 3"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "6ad16444",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "TreeNode([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4])"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "root"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "3"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().lowest_common_ancestor(root, p, q)\n",
+ "result.val"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result.val == expected_val"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/lowest_common_ancestor_of_a_binary_tree/solution.py b/leetcode/lowest_common_ancestor_of_a_binary_tree/solution.py
new file mode 100644
index 0000000..33f138a
--- /dev/null
+++ b/leetcode/lowest_common_ancestor_of_a_binary_tree/solution.py
@@ -0,0 +1,21 @@
+from leetcode_py import TreeNode
+
+
+class Solution:
+ # Time: O(n)
+ # Space: O(h)
+ def lowest_common_ancestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
+ result = self._lca(root, p, q)
+ assert result is not None
+ return result
+
+ def _lca(self, root: TreeNode | None, p: TreeNode, q: TreeNode) -> TreeNode | None:
+ if not root or root == p or root == q:
+ return root
+
+ left = self._lca(root.left, p, q)
+ right = self._lca(root.right, p, q)
+
+ if left and right:
+ return root
+ return left or right
diff --git a/leetcode/lowest_common_ancestor_of_a_binary_tree/tests.py b/leetcode/lowest_common_ancestor_of_a_binary_tree/tests.py
new file mode 100644
index 0000000..d629f35
--- /dev/null
+++ b/leetcode/lowest_common_ancestor_of_a_binary_tree/tests.py
@@ -0,0 +1,40 @@
+import pytest
+
+from leetcode_py import TreeNode
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestLowestCommonAncestorOfABinaryTree:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "root_list, p_val, q_val, expected_val",
+ [
+ ([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4], 5, 1, 3),
+ ([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4], 5, 4, 5),
+ ([1, 2], 1, 2, 1),
+ ([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4], 6, 7, 5),
+ ([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4], 7, 4, 2),
+ ([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4], 0, 8, 1),
+ ([1], 1, 1, 1),
+ ([2, 1, 3], 1, 3, 2),
+ ([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5], 2, 8, 6),
+ ([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5], 3, 5, 4),
+ ([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5], 0, 3, 2),
+ ],
+ )
+ @logged_test
+ def test_lowest_common_ancestor(
+ self, root_list: list[int | None], p_val: int, q_val: int, expected_val: int
+ ):
+ root = TreeNode.from_list(root_list)
+ assert root is not None
+ p = root.find_node(p_val)
+ q = root.find_node(q_val)
+ assert p is not None and q is not None
+ result = self.solution.lowest_common_ancestor(root, p, q)
+ assert result is not None
+ assert result.val == expected_val
diff --git a/leetcode/lru_cache/solution.py b/leetcode/lru_cache/solution.py
index 33cdbb6..c0bcd7a 100644
--- a/leetcode/lru_cache/solution.py
+++ b/leetcode/lru_cache/solution.py
@@ -1,5 +1,7 @@
from collections import OrderedDict
+from leetcode_py.data_structures.doubly_list_node import DoublyListNode
+
class LRUCache:
# Space: O(capacity)
@@ -31,3 +33,76 @@ def put(self, key: int, value: int) -> None:
self.cache.popitem(last=False)
self.cache[key] = value
+
+
+class CacheNode(DoublyListNode[int]):
+ def __init__(self, key: int = 0, val: int = 0) -> None:
+ super().__init__(val)
+ self.key = key
+
+
+class LRUCacheWithDoublyList:
+ def __init__(self, capacity: int) -> None:
+ self.capacity = capacity
+ self.cache: dict[int, CacheNode] = {}
+
+ # Dummy head and tail nodes
+ self.head = CacheNode()
+ self.tail = CacheNode()
+ self.head.next = self.tail
+ self.tail.prev = self.head
+
+ def _add_node(self, node: CacheNode) -> None:
+ """Add node right after head"""
+ node.prev = self.head
+ node.next = self.head.next
+ if self.head.next:
+ self.head.next.prev = node
+ self.head.next = node
+
+ def _remove_node(self, node: CacheNode) -> None:
+ """Remove node from list"""
+ if node.prev:
+ node.prev.next = node.next
+ if node.next:
+ node.next.prev = node.prev
+
+ def _move_to_head(self, node: CacheNode) -> None:
+ """Move node to head (most recent)"""
+ self._remove_node(node)
+ self._add_node(node)
+
+ def _pop_tail(self) -> CacheNode:
+ """Remove last node before tail"""
+ last_node = self.tail.prev
+ assert isinstance(last_node, CacheNode), "Expected CacheNode"
+ self._remove_node(last_node)
+ return last_node
+
+ def get(self, key: int) -> int:
+ node = self.cache.get(key)
+ if not node:
+ return -1
+
+ # Move to head (most recent)
+ self._move_to_head(node)
+ return node.val
+
+ def put(self, key: int, value: int) -> None:
+ node = self.cache.get(key)
+
+ if node:
+ # Update existing
+ node.val = value
+ self._move_to_head(node)
+ else:
+ # Add new
+ new_node = CacheNode(key, value)
+
+ if len(self.cache) >= self.capacity:
+ # Remove LRU
+ tail = self._pop_tail()
+ del self.cache[tail.key]
+
+ self.cache[key] = new_node
+ self._add_node(new_node)
diff --git a/leetcode/lru_cache/tests.py b/leetcode/lru_cache/tests.py
index ad8b5e4..8d3e6e8 100644
--- a/leetcode/lru_cache/tests.py
+++ b/leetcode/lru_cache/tests.py
@@ -2,10 +2,11 @@
from leetcode_py.test_utils import logged_test
-from .solution import LRUCache
+from .solution import LRUCache, LRUCacheWithDoublyList
class TestLRUCache:
+ @pytest.mark.parametrize("lru_class", [LRUCache, LRUCacheWithDoublyList])
@pytest.mark.parametrize(
"operations, inputs, expected",
[
@@ -13,16 +14,32 @@ class TestLRUCache:
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"],
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]],
[None, None, None, 1, None, -1, None, -1, 3, 4],
- )
+ ),
+ (
+ ["LRUCache", "get", "put", "get", "put", "put", "get", "get"],
+ [[2], [2], [2, 6], [1], [1, 5], [1, 2], [1], [2]],
+ [None, -1, None, -1, None, None, 2, 6],
+ ),
+ (
+ ["LRUCache", "put", "get", "put", "get", "get"],
+ [[1], [2, 1], [2], [3, 2], [2], [3]],
+ [None, None, 1, None, -1, 2],
+ ),
],
)
@logged_test
- def test_lru_cache(self, operations: list[str], inputs: list[list[int]], expected: list[int | None]):
- cache: LRUCache | None = None
+ def test_lru_cache(
+ self,
+ lru_class: type[LRUCache | LRUCacheWithDoublyList],
+ operations: list[str],
+ inputs: list[list[int]],
+ expected: list[int | None],
+ ):
+ cache = None
results: list[int | None] = []
for i, op in enumerate(operations):
if op == "LRUCache":
- cache = LRUCache(inputs[i][0])
+ cache = lru_class(inputs[i][0])
results.append(None)
elif op == "get" and cache is not None:
results.append(cache.get(inputs[i][0]))
diff --git a/leetcode/majority_element/README.md b/leetcode/majority_element/README.md
new file mode 100644
index 0000000..3822272
--- /dev/null
+++ b/leetcode/majority_element/README.md
@@ -0,0 +1,37 @@
+# Majority Element
+
+**Difficulty:** Easy
+**Topics:** Array, Hash Table, Divide and Conquer, Sorting, Counting
+**Tags:** grind-75
+
+**LeetCode:** [Problem 169](https://leetcode.com/problems/majority-element/description/)
+
+## Problem Description
+
+Given an array `nums` of size `n`, return the majority element.
+
+The majority element is the element that appears more than `⌊n / 2⌋` times. You may assume that the majority element always exists in the array.
+
+## Examples
+
+### Example 1:
+
+```
+Input: nums = [3,2,3]
+Output: 3
+```
+
+### Example 2:
+
+```
+Input: nums = [2,2,1,1,1,2,2]
+Output: 2
+```
+
+## Constraints
+
+- n == nums.length
+- 1 <= n <= 5 \* 10^4
+- -10^9 <= nums[i] <= 10^9
+
+**Follow-up:** Could you solve the problem in linear time and in O(1) space?
diff --git a/leetcode/majority_element/__init__.py b/leetcode/majority_element/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/majority_element/playground.ipynb b/leetcode/majority_element/playground.ipynb
new file mode 100644
index 0000000..de16ac0
--- /dev/null
+++ b/leetcode/majority_element/playground.ipynb
@@ -0,0 +1,79 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "nums = [3, 2, 3]\n",
+ "expected = 3"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "3"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().majority_element(nums)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/majority_element/solution.py b/leetcode/majority_element/solution.py
new file mode 100644
index 0000000..1499c32
--- /dev/null
+++ b/leetcode/majority_element/solution.py
@@ -0,0 +1,14 @@
+class Solution:
+ # Time: O(n)
+ # Space: O(1)
+ def majority_element(self, nums: list[int]) -> int:
+ # Boyer-Moore Voting Algorithm
+ candidate = 0
+ count = 0
+
+ for num in nums:
+ if count == 0:
+ candidate = num
+ count += 1 if num == candidate else -1
+
+ return candidate
diff --git a/leetcode/majority_element/tests.py b/leetcode/majority_element/tests.py
new file mode 100644
index 0000000..5414f0f
--- /dev/null
+++ b/leetcode/majority_element/tests.py
@@ -0,0 +1,19 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestMajorityElement:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "nums, expected",
+ [([3, 2, 3], 3), ([2, 2, 1, 1, 1, 2, 2], 2), ([1], 1), ([1, 1, 2], 1), ([2, 2, 2, 1, 1], 2)],
+ )
+ @logged_test
+ def test_majority_element(self, nums: list[int], expected: int):
+ result = self.solution.majority_element(nums)
+ assert result == expected
diff --git a/leetcode/merge_k_sorted_lists/README.md b/leetcode/merge_k_sorted_lists/README.md
new file mode 100644
index 0000000..7912c38
--- /dev/null
+++ b/leetcode/merge_k_sorted_lists/README.md
@@ -0,0 +1,61 @@
+# Merge k Sorted Lists
+
+**Difficulty:** Hard
+**Topics:** Linked List, Divide and Conquer, Heap (Priority Queue), Merge Sort
+**Tags:** grind-75
+
+**LeetCode:** [Problem 23](https://leetcode.com/problems/merge-k-sorted-lists/description/)
+
+## Problem Description
+
+You are given an array of `k` linked-lists `lists`, each linked-list is sorted in ascending order.
+
+_Merge all the linked-lists into one sorted linked-list and return it._
+
+## Examples
+
+### Example 1:
+
+```
+Input: lists = [[1,4,5],[1,3,4],[2,6]]
+Output: [1,1,2,3,4,4,5,6]
+```
+
+**Explanation:** The linked-lists are:
+
+```
+[
+ 1->4->5,
+ 1->3->4,
+ 2->6
+]
+```
+
+merging them into one sorted linked list:
+
+```
+1->1->2->3->4->4->5->6
+```
+
+### Example 2:
+
+```
+Input: lists = []
+Output: []
+```
+
+### Example 3:
+
+```
+Input: lists = [[]]
+Output: []
+```
+
+## Constraints
+
+- `k == lists.length`
+- `0 <= k <= 10^4`
+- `0 <= lists[i].length <= 500`
+- `-10^4 <= lists[i][j] <= 10^4`
+- `lists[i]` is sorted in ascending order.
+- The sum of `lists[i].length` will not exceed `10^4`.
diff --git a/leetcode/merge_k_sorted_lists/__init__.py b/leetcode/merge_k_sorted_lists/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/merge_k_sorted_lists/playground.ipynb b/leetcode/merge_k_sorted_lists/playground.ipynb
new file mode 100644
index 0000000..f374726
--- /dev/null
+++ b/leetcode/merge_k_sorted_lists/playground.ipynb
@@ -0,0 +1,83 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution\n",
+ "\n",
+ "from leetcode_py import ListNode"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "lists_data = [[1, 4, 5], [1, 3, 4], [2, 6]]\n",
+ "lists = [ListNode.from_list(lst) for lst in lists_data]\n",
+ "expected_data = [1, 1, 2, 3, 4, 4, 5, 6]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[1, 1, 2, 3, 4, 4, 5, 6]"
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().merge_k_lists(lists)\n",
+ "ListNode.to_list(result) if result else []"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "expected = ListNode.from_list(expected_data)\n",
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/merge_k_sorted_lists/solution.py b/leetcode/merge_k_sorted_lists/solution.py
new file mode 100644
index 0000000..d6ed124
--- /dev/null
+++ b/leetcode/merge_k_sorted_lists/solution.py
@@ -0,0 +1,35 @@
+from leetcode_py import ListNode
+
+
+class Solution:
+ # Time: O(n log k) where n is total nodes, k is number of lists
+ # Space: O(log k) for recursion stack
+ def merge_k_lists(self, lists: list[ListNode | None]) -> ListNode | None:
+ if not lists:
+ return None
+ return self._divide_conquer(lists, 0, len(lists) - 1)
+
+ def _divide_conquer(self, lists: list[ListNode | None], left: int, right: int) -> ListNode | None:
+ if left == right:
+ return lists[left]
+
+ mid = (left + right) // 2
+ l1 = self._divide_conquer(lists, left, mid)
+ l2 = self._divide_conquer(lists, mid + 1, right)
+ return self._merge_two(l1, l2)
+
+ def _merge_two(self, l1: ListNode | None, l2: ListNode | None) -> ListNode | None:
+ dummy = ListNode(0)
+ curr = dummy
+
+ while l1 and l2:
+ if l1.val <= l2.val:
+ curr.next = l1
+ l1 = l1.next
+ else:
+ curr.next = l2
+ l2 = l2.next
+ curr = curr.next
+
+ curr.next = l1 or l2
+ return dummy.next
diff --git a/leetcode/merge_k_sorted_lists/tests.py b/leetcode/merge_k_sorted_lists/tests.py
new file mode 100644
index 0000000..7249fc0
--- /dev/null
+++ b/leetcode/merge_k_sorted_lists/tests.py
@@ -0,0 +1,45 @@
+import pytest
+
+from leetcode_py import ListNode
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestMergeKSortedLists:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "lists_data, expected_data",
+ [
+ ([[1, 4, 5], [1, 3, 4], [2, 6]], [1, 1, 2, 3, 4, 4, 5, 6]),
+ ([], []),
+ ([[]], []),
+ ([[1]], [1]),
+ ([[1, 2], [3, 4]], [1, 2, 3, 4]),
+ ([[5], [1, 3], [2, 4, 6]], [1, 2, 3, 4, 5, 6]),
+ ([[-1, 0, 1], [-2, 2]], [-2, -1, 0, 1, 2]),
+ ([[1, 1, 1], [2, 2, 2]], [1, 1, 1, 2, 2, 2]),
+ ([[]], []),
+ ([[], [1], []], [1]),
+ ([[0]], [0]),
+ ([[-10, -5, -1], [-8, -3], [-7, -2, 0]], [-10, -8, -7, -5, -3, -2, -1, 0]),
+ ([[1, 2, 3, 4, 5]], [1, 2, 3, 4, 5]),
+ ([[10], [9], [8], [7]], [7, 8, 9, 10]),
+ ([[1, 100], [2, 99], [3, 98]], [1, 2, 3, 98, 99, 100]),
+ ([[], [], []], []),
+ ([[0, 0, 0], [0, 0]], [0, 0, 0, 0, 0]),
+ ([[1, 3, 5, 7], [2, 4, 6, 8]], [1, 2, 3, 4, 5, 6, 7, 8]),
+ (
+ [[100, 200, 300], [50, 150, 250], [75, 125, 175]],
+ [50, 75, 100, 125, 150, 175, 200, 250, 300],
+ ),
+ ],
+ )
+ @logged_test
+ def test_merge_k_lists(self, lists_data: list[list[int]], expected_data: list[int]):
+ lists = [ListNode.from_list(lst) for lst in lists_data]
+ result = self.solution.merge_k_lists(lists)
+ expected = ListNode.from_list(expected_data)
+ assert result == expected
diff --git a/leetcode/merge_two_sorted_lists/README.md b/leetcode/merge_two_sorted_lists/README.md
new file mode 100644
index 0000000..a9721a5
--- /dev/null
+++ b/leetcode/merge_two_sorted_lists/README.md
@@ -0,0 +1,46 @@
+# Merge Two Sorted Lists
+
+**Difficulty:** Easy
+**Topics:** Linked List, Recursion
+**Tags:** grind-75
+
+**LeetCode:** [Problem 21](https://leetcode.com/problems/merge-two-sorted-lists/description/)
+
+## Problem Description
+
+You are given the heads of two sorted linked lists `list1` and `list2`.
+
+Merge the two lists into one **sorted** list. The list should be made by splicing together the nodes of the first two lists.
+
+Return _the head of the merged linked list_.
+
+## Examples
+
+### Example 1:
+
+
+
+```
+Input: list1 = [1,2,4], list2 = [1,3,4]
+Output: [1,1,2,3,4,4]
+```
+
+### Example 2:
+
+```
+Input: list1 = [], list2 = []
+Output: []
+```
+
+### Example 3:
+
+```
+Input: list1 = [], list2 = [0]
+Output: [0]
+```
+
+## Constraints
+
+- The number of nodes in both lists is in the range `[0, 50]`.
+- `-100 <= Node.val <= 100`
+- Both `list1` and `list2` are sorted in **non-decreasing** order.
diff --git a/leetcode/merge_two_sorted_lists/__init__.py b/leetcode/merge_two_sorted_lists/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/merge_two_sorted_lists/playground.ipynb b/leetcode/merge_two_sorted_lists/playground.ipynb
new file mode 100644
index 0000000..492ef37
--- /dev/null
+++ b/leetcode/merge_two_sorted_lists/playground.ipynb
@@ -0,0 +1,74 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution\n",
+ "\n",
+ "from leetcode_py import ListNode"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "list1_vals = [1, 2, 4]\n",
+ "list2_vals = [1, 3, 4]\n",
+ "expected_vals = [1, 1, 2, 3, 4, 4]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "list1 = ListNode.from_list(list1_vals)\n",
+ "list2 = ListNode.from_list(list2_vals)\n",
+ "result = Solution().merge_two_lists(list1, list2)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "expected = ListNode.from_list(expected_vals)\n",
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/merge_two_sorted_lists/solution.py b/leetcode/merge_two_sorted_lists/solution.py
new file mode 100644
index 0000000..07ebb03
--- /dev/null
+++ b/leetcode/merge_two_sorted_lists/solution.py
@@ -0,0 +1,22 @@
+from leetcode_py import ListNode
+
+
+class Solution:
+ # Time: O(m + n)
+ # Space: O(1)
+ def merge_two_lists(self, list1: ListNode | None, list2: ListNode | None) -> ListNode | None:
+
+ dummy = ListNode(0)
+ current = dummy
+
+ while list1 and list2:
+ if list1.val <= list2.val:
+ current.next = list1
+ list1 = list1.next
+ else:
+ current.next = list2
+ list2 = list2.next
+ current = current.next
+
+ current.next = list1 or list2
+ return dummy.next
diff --git a/leetcode/merge_two_sorted_lists/tests.py b/leetcode/merge_two_sorted_lists/tests.py
new file mode 100644
index 0000000..5cb715d
--- /dev/null
+++ b/leetcode/merge_two_sorted_lists/tests.py
@@ -0,0 +1,37 @@
+import pytest
+
+from leetcode_py import ListNode
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestMergeTwoSortedLists:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "list1_vals, list2_vals, expected_vals",
+ [
+ ([1, 2, 4], [1, 3, 4], [1, 1, 2, 3, 4, 4]),
+ ([], [], []),
+ ([], [0], [0]),
+ ([0], [], [0]),
+ ([1], [2], [1, 2]),
+ ([2], [1], [1, 2]),
+ ([1, 1, 1], [2, 2, 2], [1, 1, 1, 2, 2, 2]),
+ ([1, 3, 5], [2, 4, 6], [1, 2, 3, 4, 5, 6]),
+ ([-10, -5, 0], [-8, -3, 1], [-10, -8, -5, -3, 0, 1]),
+ ([5], [1, 2, 3, 4], [1, 2, 3, 4, 5]),
+ ([1, 2, 3, 4], [5], [1, 2, 3, 4, 5]),
+ ],
+ )
+ @logged_test
+ def test_merge_two_lists(
+ self, list1_vals: list[int], list2_vals: list[int], expected_vals: list[int]
+ ):
+ list1 = ListNode.from_list(list1_vals)
+ list2 = ListNode.from_list(list2_vals)
+ expected = ListNode.from_list(expected_vals)
+ result = self.solution.merge_two_lists(list1, list2)
+ assert result == expected
diff --git a/leetcode/middle_of_the_linked_list/README.md b/leetcode/middle_of_the_linked_list/README.md
new file mode 100644
index 0000000..1f36bf2
--- /dev/null
+++ b/leetcode/middle_of_the_linked_list/README.md
@@ -0,0 +1,42 @@
+# Middle of the Linked List
+
+**Difficulty:** Easy
+**Topics:** Linked List, Two Pointers
+**Tags:** grind-75
+
+**LeetCode:** [Problem 876](https://leetcode.com/problems/middle-of-the-linked-list/description/)
+
+## Problem Description
+
+Given the `head` of a singly linked list, return _the middle node of the linked list_.
+
+If there are two middle nodes, return **the second middle** node.
+
+## Examples
+
+### Example 1:
+
+
+
+```
+Input: head = [1,2,3,4,5]
+Output: [3,4,5]
+```
+
+**Explanation:** The middle node of the list is node 3.
+
+### Example 2:
+
+
+
+```
+Input: head = [1,2,3,4,5,6]
+Output: [4,5,6]
+```
+
+**Explanation:** Since the list has two middle nodes with values 3 and 4, we return the second one.
+
+## Constraints
+
+- The number of nodes in the list is in the range `[1, 100]`.
+- `1 <= Node.val <= 100`
diff --git a/leetcode/middle_of_the_linked_list/__init__.py b/leetcode/middle_of_the_linked_list/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/middle_of_the_linked_list/playground.ipynb b/leetcode/middle_of_the_linked_list/playground.ipynb
new file mode 100644
index 0000000..921ff0f
--- /dev/null
+++ b/leetcode/middle_of_the_linked_list/playground.ipynb
@@ -0,0 +1,127 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution\n",
+ "\n",
+ "from leetcode_py import ListNode"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "head_list = [1, 2, 3, 4, 5]\n",
+ "expected_list = [3, 4, 5]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "ListNode([3, 4, 5])"
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "head = ListNode.from_list(head_list)\n",
+ "result = Solution().middle_node(head)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "expected = ListNode.from_list(expected_list)\n",
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/middle_of_the_linked_list/solution.py b/leetcode/middle_of_the_linked_list/solution.py
new file mode 100644
index 0000000..bec661f
--- /dev/null
+++ b/leetcode/middle_of_the_linked_list/solution.py
@@ -0,0 +1,13 @@
+from leetcode_py import ListNode
+
+
+class Solution:
+ # Time: O(n)
+ # Space: O(1)
+ def middle_node(self, head: ListNode | None) -> ListNode | None:
+ slow = fast = head
+ while fast and fast.next:
+ assert slow is not None
+ slow = slow.next
+ fast = fast.next.next
+ return slow
diff --git a/leetcode/middle_of_the_linked_list/tests.py b/leetcode/middle_of_the_linked_list/tests.py
new file mode 100644
index 0000000..7454186
--- /dev/null
+++ b/leetcode/middle_of_the_linked_list/tests.py
@@ -0,0 +1,31 @@
+import pytest
+
+from leetcode_py import ListNode
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestMiddleOfTheLinkedList:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "head_list, expected_list",
+ [
+ ([1, 2, 3, 4, 5], [3, 4, 5]), # odd length
+ ([1, 2, 3, 4, 5, 6], [4, 5, 6]), # even length
+ ([1], [1]), # single node
+ ([1, 2], [2]), # two nodes
+ ([1, 2, 3], [2, 3]), # three nodes
+ ([1, 2, 3, 4], [3, 4]), # four nodes
+ ([10, 20, 30, 40, 50, 60, 70], [40, 50, 60, 70]), # larger odd
+ ([5, 15, 25, 35], [25, 35]), # larger even
+ ],
+ )
+ @logged_test
+ def test_middle_node(self, head_list: list[int], expected_list: list[int]):
+ head = ListNode.from_list(head_list)
+ expected = ListNode.from_list(expected_list)
+ result = self.solution.middle_node(head)
+ assert result == expected
diff --git a/leetcode/min_stack/README.md b/leetcode/min_stack/README.md
new file mode 100644
index 0000000..46edefb
--- /dev/null
+++ b/leetcode/min_stack/README.md
@@ -0,0 +1,53 @@
+# Min Stack
+
+**Difficulty:** Medium
+**Topics:** Stack, Design
+**Tags:** grind-75
+
+**LeetCode:** [Problem 155](https://leetcode.com/problems/min-stack/description/)
+
+## Problem Description
+
+Design a stack that supports push, pop, top, and retrieving the minimum element in constant time.
+
+Implement the `MinStack` class:
+
+- `MinStack()` initializes the stack object.
+- `void push(int val)` pushes the element `val` onto the stack.
+- `void pop()` removes the element on the top of the stack.
+- `int top()` gets the top element of the stack.
+- `int getMin()` retrieves the minimum element in the stack.
+
+You must implement a solution with `O(1)` time complexity for each function.
+
+## Examples
+
+### Example 1:
+
+```
+Input
+["MinStack","push","push","push","getMin","pop","top","getMin"]
+[[],[-2],[0],[-3],[],[],[],[]]
+
+Output
+[null,null,null,null,-3,null,0,-2]
+```
+
+**Explanation:**
+
+```
+MinStack minStack = new MinStack();
+minStack.push(-2);
+minStack.push(0);
+minStack.push(-3);
+minStack.getMin(); // return -3
+minStack.pop();
+minStack.top(); // return 0
+minStack.getMin(); // return -2
+```
+
+## Constraints
+
+- `-2^31 <= val <= 2^31 - 1`
+- Methods `pop`, `top` and `getMin` operations will always be called on **non-empty** stacks.
+- At most `3 * 10^4` calls will be made to `push`, `pop`, `top`, and `getMin`.
diff --git a/leetcode/min_stack/__init__.py b/leetcode/min_stack/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/min_stack/playground.ipynb b/leetcode/min_stack/playground.ipynb
new file mode 100644
index 0000000..72b888c
--- /dev/null
+++ b/leetcode/min_stack/playground.ipynb
@@ -0,0 +1,95 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import MinStack"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "operations = [\"MinStack\", \"push\", \"push\", \"push\", \"getMin\", \"pop\", \"top\", \"getMin\"]\n",
+ "inputs = [[], [-2], [0], [-3], [], [], [], []]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[None, None, None, None, -3, None, 0, -2]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "stack = None\n",
+ "results: list[int | None] = []\n",
+ "for i, op in enumerate(operations):\n",
+ " if op == \"MinStack\":\n",
+ " stack = MinStack()\n",
+ " results.append(None)\n",
+ " elif op == \"push\" and stack is not None:\n",
+ " stack.push(inputs[i][0])\n",
+ " results.append(None)\n",
+ " elif op == \"pop\" and stack is not None:\n",
+ " stack.pop()\n",
+ " results.append(None)\n",
+ " elif op == \"top\" and stack is not None:\n",
+ " results.append(stack.top())\n",
+ " elif op == \"getMin\" and stack is not None:\n",
+ " results.append(stack.get_min())\n",
+ "results"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "expected = [None, None, None, None, -3, None, 0, -2]\n",
+ "assert results == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/min_stack/solution.py b/leetcode/min_stack/solution.py
new file mode 100644
index 0000000..fc59403
--- /dev/null
+++ b/leetcode/min_stack/solution.py
@@ -0,0 +1,43 @@
+class MinStack:
+ # Time: O(1) for all operations
+ # Space: O(n) where n is number of elements
+ def __init__(self) -> None:
+ self.stack: list[int] = []
+ self.min_stack: list[int] = []
+
+ # Time: O(1)
+ # Space: O(1)
+ def push(self, val: int) -> None:
+ self.stack.append(val)
+ if not self.min_stack or val <= self.min_stack[-1]:
+ self.min_stack.append(val)
+
+ # Time: O(1)
+ # Space: O(1)
+ def pop(self) -> None:
+ if self.stack[-1] == self.min_stack[-1]:
+ self.min_stack.pop()
+ self.stack.pop()
+
+ # Time: O(1)
+ # Space: O(1)
+ def top(self) -> int:
+ return self.stack[-1]
+
+ # Time: O(1)
+ # Space: O(1)
+ def get_min(self) -> int:
+ return self.min_stack[-1]
+
+
+# Example walkthrough: push(-2), push(0), push(-3), getMin(), pop(), top(), getMin()
+#
+# Initial: stack=[], min_stack=[]
+#
+# push(-2): stack=[-2], min_stack=[-2] (first element, add to both)
+# push(0): stack=[-2,0], min_stack=[-2] (0 > -2, don't add to min_stack)
+# push(-3): stack=[-2,0,-3], min_stack=[-2,-3] (-3 <= -2, add to min_stack)
+# getMin(): return -3 (top of min_stack)
+# pop(): stack=[-2,0], min_stack=[-2] (-3 was min, remove from both stacks)
+# top(): return 0 (top of main stack)
+# getMin(): return -2 (top of min_stack after pop)
diff --git a/leetcode/min_stack/tests.py b/leetcode/min_stack/tests.py
new file mode 100644
index 0000000..af73df4
--- /dev/null
+++ b/leetcode/min_stack/tests.py
@@ -0,0 +1,52 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import MinStack
+
+
+class TestMinStack:
+ @pytest.mark.parametrize(
+ "operations, inputs, expected",
+ [
+ (
+ ["MinStack", "push", "push", "push", "getMin", "pop", "top", "getMin"],
+ [[], [-2], [0], [-3], [], [], [], []],
+ [None, None, None, None, -3, None, 0, -2],
+ ),
+ (
+ ["MinStack", "push", "top", "getMin", "pop"],
+ [[], [5], [], [], []],
+ [None, None, 5, 5, None],
+ ),
+ (
+ ["MinStack", "push", "push", "push", "getMin", "pop", "getMin", "pop", "getMin"],
+ [[], [1], [1], [2], [], [], [], [], []],
+ [None, None, None, None, 1, None, 1, None, 1],
+ ),
+ (
+ ["MinStack", "push", "push", "getMin", "push", "getMin", "pop", "getMin"],
+ [[], [3], [1], [], [0], [], [], []],
+ [None, None, None, 1, None, 0, None, 1],
+ ),
+ ],
+ )
+ @logged_test
+ def test_min_stack(self, operations: list[str], inputs: list[list[int]], expected: list[int | None]):
+ stack: MinStack | None = None
+ results: list[int | None] = []
+ for i, op in enumerate(operations):
+ if op == "MinStack":
+ stack = MinStack()
+ results.append(None)
+ elif op == "push" and stack is not None:
+ stack.push(inputs[i][0])
+ results.append(None)
+ elif op == "pop" and stack is not None:
+ stack.pop()
+ results.append(None)
+ elif op == "top" and stack is not None:
+ results.append(stack.top())
+ elif op == "getMin" and stack is not None:
+ results.append(stack.get_min())
+ assert results == expected
diff --git a/leetcode/number_of_islands/README.md b/leetcode/number_of_islands/README.md
new file mode 100644
index 0000000..ada9064
--- /dev/null
+++ b/leetcode/number_of_islands/README.md
@@ -0,0 +1,46 @@
+# Number of Islands
+
+**Difficulty:** Medium
+**Topics:** Array, Depth-First Search, Breadth-First Search, Union Find, Matrix
+**Tags:** grind-75
+
+**LeetCode:** [Problem 200](https://leetcode.com/problems/number-of-islands/description/)
+
+## Problem Description
+
+Given an `m x n` 2D binary grid `grid` which represents a map of `'1'`s (land) and `'0'`s (water), return _the number of islands_.
+
+An **island** is surrounded by water and is formed by connecting adjacent lands horizontally or vertically. You may assume all four edges of the grid are all surrounded by water.
+
+## Examples
+
+### Example 1:
+
+```
+Input: grid = [
+ ["1","1","1","1","0"],
+ ["1","1","0","1","0"],
+ ["1","1","0","0","0"],
+ ["0","0","0","0","0"]
+]
+Output: 1
+```
+
+### Example 2:
+
+```
+Input: grid = [
+ ["1","1","0","0","0"],
+ ["1","1","0","0","0"],
+ ["0","0","1","0","0"],
+ ["0","0","0","1","1"]
+]
+Output: 3
+```
+
+## Constraints
+
+- `m == grid.length`
+- `n == grid[i].length`
+- `1 <= m, n <= 300`
+- `grid[i][j]` is `'0'` or `'1'`.
diff --git a/leetcode/number_of_islands/__init__.py b/leetcode/number_of_islands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/number_of_islands/playground.ipynb b/leetcode/number_of_islands/playground.ipynb
new file mode 100644
index 0000000..6b0ed5f
--- /dev/null
+++ b/leetcode/number_of_islands/playground.ipynb
@@ -0,0 +1,72 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "grid = [\n",
+ " [\"1\", \"1\", \"1\", \"1\", \"0\"],\n",
+ " [\"1\", \"1\", \"0\", \"1\", \"0\"],\n",
+ " [\"1\", \"1\", \"0\", \"0\", \"0\"],\n",
+ " [\"0\", \"0\", \"0\", \"0\", \"0\"],\n",
+ "]\n",
+ "expected = 1"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "result = Solution().num_islands(grid)\nresult"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/number_of_islands/solution.py b/leetcode/number_of_islands/solution.py
new file mode 100644
index 0000000..62368c8
--- /dev/null
+++ b/leetcode/number_of_islands/solution.py
@@ -0,0 +1,27 @@
+class Solution:
+ # Time: O(m * n) where m = rows, n = cols
+ # Space: O(m * n) for recursion stack in worst case
+ def num_islands(self, grid: list[list[str]]) -> int:
+ if not grid or not grid[0]:
+ return 0
+ VISITED = "0"
+ UNVISITED_ISLAND = "1"
+
+ rows, cols = len(grid), len(grid[0])
+ islands = 0
+
+ def dfs(r: int, c: int) -> None:
+ if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != UNVISITED_ISLAND:
+ return
+
+ grid[r][c] = VISITED
+ for dr, dc in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
+ dfs(r + dr, c + dc)
+
+ for r in range(rows):
+ for c in range(cols):
+ if grid[r][c] == UNVISITED_ISLAND:
+ islands += 1
+ dfs(r, c)
+
+ return islands
diff --git a/leetcode/number_of_islands/tests.py b/leetcode/number_of_islands/tests.py
new file mode 100644
index 0000000..c3eb4e3
--- /dev/null
+++ b/leetcode/number_of_islands/tests.py
@@ -0,0 +1,67 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestNumberOfIslands:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "grid, expected",
+ [
+ # Basic examples
+ (
+ [
+ ["1", "1", "1", "1", "0"],
+ ["1", "1", "0", "1", "0"],
+ ["1", "1", "0", "0", "0"],
+ ["0", "0", "0", "0", "0"],
+ ],
+ 1,
+ ),
+ (
+ [
+ ["1", "1", "0", "0", "0"],
+ ["1", "1", "0", "0", "0"],
+ ["0", "0", "1", "0", "0"],
+ ["0", "0", "0", "1", "1"],
+ ],
+ 3,
+ ),
+ # Edge cases
+ ([["1"]], 1),
+ ([["0"]], 0),
+ ([["1", "0", "1"], ["0", "1", "0"], ["1", "0", "1"]], 5),
+ # All water
+ ([["0", "0", "0"], ["0", "0", "0"]], 0),
+ # All land
+ ([["1", "1", "1"], ["1", "1", "1"]], 1),
+ # Single row
+ ([["1", "0", "1", "0", "1"]], 3),
+ # Single column
+ ([["1"], ["0"], ["1"], ["0"], ["1"]], 3),
+ # L-shaped island
+ ([["1", "1", "0"], ["1", "0", "0"], ["1", "1", "1"]], 1),
+ # Diagonal pattern (no connections)
+ ([["1", "0", "0"], ["0", "1", "0"], ["0", "0", "1"]], 3),
+ # Large grid with multiple islands
+ (
+ [
+ ["1", "0", "0", "1", "1", "0"],
+ ["0", "0", "0", "0", "1", "0"],
+ ["0", "0", "1", "0", "0", "0"],
+ ["1", "1", "0", "0", "0", "1"],
+ ],
+ 5,
+ ),
+ # Snake-like island
+ ([["1", "1", "0"], ["0", "1", "0"], ["0", "1", "1"]], 1),
+ ],
+ )
+ @logged_test
+ def test_num_islands(self, grid: list[list[str]], expected: int):
+ result = self.solution.num_islands(grid)
+ assert result == expected
diff --git a/leetcode/permutations/README.md b/leetcode/permutations/README.md
new file mode 100644
index 0000000..69459b5
--- /dev/null
+++ b/leetcode/permutations/README.md
@@ -0,0 +1,40 @@
+# Permutations
+
+**Difficulty:** Medium
+**Topics:** Array, Backtracking
+**Tags:** grind-75
+
+**LeetCode:** [Problem 46](https://leetcode.com/problems/permutations/description/)
+
+## Problem Description
+
+Given an array `nums` of distinct integers, return all the possible permutations. You can return the answer in any order.
+
+## Examples
+
+### Example 1:
+
+```
+Input: nums = [1,2,3]
+Output: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
+```
+
+### Example 2:
+
+```
+Input: nums = [0,1]
+Output: [[0,1],[1,0]]
+```
+
+### Example 3:
+
+```
+Input: nums = [1]
+Output: [[1]]
+```
+
+## Constraints
+
+- 1 <= nums.length <= 6
+- -10 <= nums[i] <= 10
+- All the integers of nums are unique.
diff --git a/leetcode/permutations/__init__.py b/leetcode/permutations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/permutations/playground.ipynb b/leetcode/permutations/playground.ipynb
new file mode 100644
index 0000000..a90984e
--- /dev/null
+++ b/leetcode/permutations/playground.ipynb
@@ -0,0 +1,57 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": ["from solution import Solution"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": ["# Example test case\nnums = [1, 2, 3]\nexpected = [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [],
+ "source": ["result = Solution().permute(nums)\nresult"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": ["# Check that we have the right number of permutations\nassert len(result) == len(expected)\n# Sort for comparison since order doesn't matter\nresult_sorted = [sorted(perm) for perm in result]\nexpected_sorted = [sorted(perm) for perm in expected]\nresult_sorted.sort()\nexpected_sorted.sort()\nassert result_sorted == expected_sorted"]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/permutations/solution.py b/leetcode/permutations/solution.py
new file mode 100644
index 0000000..1bdf59f
--- /dev/null
+++ b/leetcode/permutations/solution.py
@@ -0,0 +1,18 @@
+class Solution:
+ # Time: O(n! * n)
+ # Space: O(n! * n) output + O(n) recursion
+ def permute(self, nums: list[int]) -> list[list[int]]:
+ result = []
+
+ def backtrack(start: int) -> None:
+ if start == len(nums):
+ result.append(nums[:])
+ return
+
+ for i in range(start, len(nums)):
+ nums[start], nums[i] = nums[i], nums[start]
+ backtrack(start + 1)
+ nums[start], nums[i] = nums[i], nums[start]
+
+ backtrack(0)
+ return result
diff --git a/leetcode/permutations/tests.py b/leetcode/permutations/tests.py
new file mode 100644
index 0000000..d455d72
--- /dev/null
+++ b/leetcode/permutations/tests.py
@@ -0,0 +1,43 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestPermutations:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "nums, expected",
+ [
+ ([], [[]]),
+ ([1, 2, 3], [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]),
+ ([0, 1], [[0, 1], [1, 0]]),
+ ([1], [[1]]),
+ ([-1, 0, 1], [[-1, 0, 1], [-1, 1, 0], [0, -1, 1], [0, 1, -1], [1, -1, 0], [1, 0, -1]]),
+ ([4, 5], [[4, 5], [5, 4]]),
+ ([1, 2, 3, 4], 24), # Test count only for larger input
+ ([10], [[10]]),
+ ([-5, -3], [[-5, -3], [-3, -5]]),
+ ],
+ )
+ @logged_test
+ def test_permute(self, nums: list[int], expected):
+ result = self.solution.permute(nums)
+
+ # If expected is int, just check count (for larger inputs)
+ if isinstance(expected, int):
+ assert len(result) == expected
+ # Verify all permutations are unique
+ assert len(set(tuple(perm) for perm in result)) == expected
+ return
+
+ # Sort both result and expected for comparison since order doesn't matter
+ result_sorted = [sorted(perm) for perm in result]
+ expected_sorted = [sorted(perm) for perm in expected]
+ result_sorted.sort()
+ expected_sorted.sort()
+ assert len(result) == len(expected)
+ assert result_sorted == expected_sorted
diff --git a/leetcode/ransom_note/README.md b/leetcode/ransom_note/README.md
new file mode 100644
index 0000000..59c1c12
--- /dev/null
+++ b/leetcode/ransom_note/README.md
@@ -0,0 +1,41 @@
+# Ransom Note
+
+**Difficulty:** Easy
+**Topics:** Hash Table, String, Counting
+**Tags:** grind-75
+
+**LeetCode:** [Problem 383](https://leetcode.com/problems/ransom-note/description/)
+
+## Problem Description
+
+Given two strings `ransomNote` and `magazine`, return `true` if `ransomNote` can be constructed by using the letters from `magazine` and `false` otherwise.
+
+Each letter in `magazine` can only be used once in `ransomNote`.
+
+## Examples
+
+### Example 1:
+
+```
+Input: ransomNote = "a", magazine = "b"
+Output: false
+```
+
+### Example 2:
+
+```
+Input: ransomNote = "aa", magazine = "ab"
+Output: false
+```
+
+### Example 3:
+
+```
+Input: ransomNote = "aa", magazine = "aab"
+Output: true
+```
+
+## Constraints
+
+- 1 <= ransomNote.length, magazine.length <= 10^5
+- ransomNote and magazine consist of lowercase English letters.
diff --git a/leetcode/ransom_note/__init__.py b/leetcode/ransom_note/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/ransom_note/playground.ipynb b/leetcode/ransom_note/playground.ipynb
new file mode 100644
index 0000000..864b296
--- /dev/null
+++ b/leetcode/ransom_note/playground.ipynb
@@ -0,0 +1,80 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "ransom_note = \"aa\"\n",
+ "magazine = \"aab\"\n",
+ "expected = True"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().can_construct(ransom_note, magazine)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/ransom_note/solution.py b/leetcode/ransom_note/solution.py
new file mode 100644
index 0000000..f99deef
--- /dev/null
+++ b/leetcode/ransom_note/solution.py
@@ -0,0 +1,18 @@
+from collections import Counter
+
+
+class Solution:
+ # Time: O(m + n) where m = magazine length, n = ransom_note length
+ # Space: O(1) - at most 26 lowercase letters
+ def can_construct(self, ransom_note: str, magazine: str) -> bool:
+ if len(ransom_note) > len(magazine):
+ return False
+
+ magazine_count = Counter(magazine)
+
+ for char in ransom_note:
+ if magazine_count[char] == 0:
+ return False
+ magazine_count[char] -= 1
+
+ return True
diff --git a/leetcode/ransom_note/tests.py b/leetcode/ransom_note/tests.py
new file mode 100644
index 0000000..90881af
--- /dev/null
+++ b/leetcode/ransom_note/tests.py
@@ -0,0 +1,38 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestRansomNote:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "ransom_note, magazine, expected",
+ [
+ # Original test cases
+ ("a", "b", False),
+ ("aa", "ab", False),
+ ("aa", "aab", True),
+ ("aab", "baa", True),
+ # Edge cases
+ ("", "", True), # Both empty
+ ("", "abc", True), # Empty ransom note
+ ("a", "", False), # Empty magazine
+ ("a", "a", True), # Single char match
+ ("ab", "a", False), # Ransom longer than magazine
+ # More complex cases
+ ("abc", "aabbcc", True), # Multiple of each char
+ ("aab", "baa", True), # Same chars, different order
+ ("aaa", "aa", False), # Not enough of same char
+ ("abcd", "dcba", True), # All different chars
+ ("hello", "helloworld", True), # Ransom is substring
+ ("world", "hello", False), # Missing chars
+ ],
+ )
+ @logged_test
+ def test_can_construct(self, ransom_note: str, magazine: str, expected: bool):
+ result = self.solution.can_construct(ransom_note, magazine)
+ assert result == expected
diff --git a/leetcode/rotting_oranges/README.md b/leetcode/rotting_oranges/README.md
new file mode 100644
index 0000000..36d2c82
--- /dev/null
+++ b/leetcode/rotting_oranges/README.md
@@ -0,0 +1,55 @@
+# Rotting Oranges
+
+**Difficulty:** Medium
+**Topics:** Array, Breadth-First Search, Matrix
+**Tags:** grind-75
+
+**LeetCode:** [Problem 994](https://leetcode.com/problems/rotting-oranges/description/)
+
+## Problem Description
+
+You are given an `m x n` `grid` where each cell can have one of three values:
+
+- `0` representing an empty cell,
+- `1` representing a fresh orange, or
+- `2` representing a rotten orange.
+
+Every minute, any fresh orange that is **4-directionally adjacent** to a rotten orange becomes rotten.
+
+Return _the minimum number of minutes that must elapse until no cell has a fresh orange_. If _this is impossible, return_ `-1`.
+
+## Examples
+
+### Example 1:
+
+
+
+```
+Input: grid = [[2,1,1],[1,1,0],[0,1,1]]
+Output: 4
+```
+
+### Example 2:
+
+```
+Input: grid = [[2,1,1],[0,1,1],[1,0,1]]
+Output: -1
+```
+
+**Explanation:** The orange in the bottom left corner (row 2, column 0) is never rotten, because rotting only happens 4-directionally.
+
+### Example 3:
+
+```
+Input: grid = [[0,2]]
+Output: 0
+```
+
+**Explanation:** Since there are already no fresh oranges at minute 0, the answer is just 0.
+
+## Constraints
+
+- `m == grid.length`
+- `n == grid[i].length`
+- `1 <= m, n <= 10`
+- `grid[i][j]` is `0`, `1`, or `2`.
diff --git a/leetcode/rotting_oranges/__init__.py b/leetcode/rotting_oranges/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/rotting_oranges/playground.ipynb b/leetcode/rotting_oranges/playground.ipynb
new file mode 100644
index 0000000..077b70a
--- /dev/null
+++ b/leetcode/rotting_oranges/playground.ipynb
@@ -0,0 +1,57 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": ["from solution import Solution"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": ["# Example test case\ngrid = [[2, 1, 1], [1, 1, 0], [0, 1, 1]]\nexpected = 4"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [],
+ "source": ["result = Solution().oranges_rotting(grid)\nresult"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": ["assert result == expected"]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/rotting_oranges/solution.py b/leetcode/rotting_oranges/solution.py
new file mode 100644
index 0000000..747d2c9
--- /dev/null
+++ b/leetcode/rotting_oranges/solution.py
@@ -0,0 +1,43 @@
+from collections import deque
+
+
+class Solution:
+ # Time: O(m*n)
+ # Space: O(m*n)
+ def oranges_rotting(self, grid: list[list[int]]) -> int:
+ EMPTY, FRESH, ROTTEN = 0, 1, 2
+ _ = EMPTY
+
+ m, n = len(grid), len(grid[0])
+ queue: deque[tuple[int, int]] = deque()
+ fresh = 0
+
+ # Find all rotten oranges and count fresh ones
+ for i in range(m):
+ for j in range(n):
+ if grid[i][j] == ROTTEN:
+ queue.append((i, j))
+ elif grid[i][j] == FRESH:
+ fresh += 1
+
+ if fresh == 0:
+ return 0
+
+ minutes = 0
+ directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
+
+ while queue:
+ size = len(queue)
+ for _ in range(size):
+ x, y = queue.popleft()
+ for dx, dy in directions:
+ nx, ny = x + dx, y + dy
+ if 0 <= nx < m and 0 <= ny < n and grid[nx][ny] == FRESH:
+ grid[nx][ny] = ROTTEN
+ fresh -= 1
+ queue.append((nx, ny))
+
+ if queue:
+ minutes += 1
+
+ return minutes if fresh == 0 else -1
diff --git a/leetcode/rotting_oranges/tests.py b/leetcode/rotting_oranges/tests.py
new file mode 100644
index 0000000..cbf8011
--- /dev/null
+++ b/leetcode/rotting_oranges/tests.py
@@ -0,0 +1,60 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestTestRottingOranges:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "grid, expected",
+ [
+ # Original examples
+ ([[2, 1, 1], [1, 1, 0], [0, 1, 1]], 4),
+ ([[2, 1, 1], [0, 1, 1], [1, 0, 1]], -1),
+ ([[0, 2]], 0),
+ # Single cell cases
+ ([[0]], 0),
+ ([[1]], -1),
+ ([[2]], 0),
+ # Two cell cases
+ ([[1, 2]], 1),
+ ([[2, 1]], 1),
+ ([[0, 1, 2]], 1),
+ # Multiple rotten sources
+ ([[2, 2], [1, 1], [0, 0]], 1),
+ ([[2, 1, 1], [1, 1, 1], [0, 1, 2]], 2),
+ # All empty grid
+ ([[0, 0, 0], [0, 0, 0]], 0),
+ # All rotten grid
+ ([[2, 2, 2], [2, 2, 2]], 0),
+ # All fresh grid (impossible)
+ ([[1, 1, 1], [1, 1, 1]], -1),
+ # Large grid with barriers
+ ([[2, 1, 0, 0, 1], [1, 0, 0, 0, 1], [0, 0, 0, 0, 1], [0, 0, 0, 0, 2]], 3),
+ # Cross pattern
+ ([[0, 1, 0], [1, 2, 1], [0, 1, 0]], 1),
+ # Diagonal (no spread)
+ ([[2, 0, 1], [0, 0, 0], [1, 0, 1]], -1),
+ # Ring pattern
+ ([[1, 1, 1], [1, 0, 1], [1, 1, 1]], -1),
+ # Multiple disconnected fresh groups
+ ([[2, 1, 0, 1], [0, 0, 0, 0], [1, 0, 0, 2]], -1),
+ # Linear spread
+ ([[2, 1, 1, 1, 1]], 4),
+ # Vertical spread
+ ([[2], [1], [1], [1]], 3),
+ # Corner cases
+ ([[2, 0, 0], [0, 0, 0], [0, 0, 1]], -1),
+ ([[1, 0, 0], [0, 0, 0], [0, 0, 2]], -1),
+ # Complex maze-like
+ ([[2, 1, 1, 0, 1], [1, 0, 1, 0, 1], [1, 1, 1, 0, 2]], 4),
+ ],
+ )
+ @logged_test
+ def test_oranges_rotting(self, grid: list[list[int]], expected: int):
+ result = self.solution.oranges_rotting(grid)
+ assert result == expected
diff --git a/leetcode/serialize_and_deserialize_binary_tree/README.md b/leetcode/serialize_and_deserialize_binary_tree/README.md
new file mode 100644
index 0000000..8226e6c
--- /dev/null
+++ b/leetcode/serialize_and_deserialize_binary_tree/README.md
@@ -0,0 +1,38 @@
+# Serialize and Deserialize Binary Tree
+
+**Difficulty:** Hard
+**Topics:** String, Tree, Depth-First Search, Breadth-First Search, Design, Binary Tree
+**Tags:** grind-75
+
+**LeetCode:** [Problem 297](https://leetcode.com/problems/serialize-and-deserialize-binary-tree/description/)
+
+## Problem Description
+
+Serialization is the process of converting a data structure or object into a sequence of bits so that it can be stored in a file or memory buffer, or transmitted across a network connection link to be reconstructed later in the same or another computer environment.
+
+Design an algorithm to serialize and deserialize a binary tree. There is no restriction on how your serialization/deserialization algorithm should work. You just need to ensure that a binary tree can be serialized to a string and this string can be deserialized to the original tree structure.
+
+**Clarification:** The input/output format is the same as how LeetCode serializes a binary tree. You do not necessarily need to follow this format, so please be creative and come up with different approaches yourself.
+
+## Examples
+
+### Example 1:
+
+
+
+```
+Input: root = [1,2,3,null,null,4,5]
+Output: [1,2,3,null,null,4,5]
+```
+
+### Example 2:
+
+```
+Input: root = []
+Output: []
+```
+
+## Constraints
+
+- The number of nodes in the tree is in the range [0, 10^4].
+- -1000 <= Node.val <= 1000
diff --git a/leetcode/serialize_and_deserialize_binary_tree/__init__.py b/leetcode/serialize_and_deserialize_binary_tree/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/serialize_and_deserialize_binary_tree/playground.ipynb b/leetcode/serialize_and_deserialize_binary_tree/playground.ipynb
new file mode 100644
index 0000000..36ca758
--- /dev/null
+++ b/leetcode/serialize_and_deserialize_binary_tree/playground.ipynb
@@ -0,0 +1,167 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Codec\n",
+ "\n",
+ "from leetcode_py import TreeNode"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "root_list = [1, 2, 3, None, None, 4, 5]\n",
+ "root = TreeNode.from_list(root_list) if root_list else None"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Original: [1, 2, 3, None, None, 4, 5]\n",
+ "Serialized: 1,2,#,#,3,4,#,#,5,#,#\n",
+ "Deserialized: [1, 2, 3, None, None, 4, 5]\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "TreeNode([1, 2, 3, None, None, 4, 5])"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "codec = Codec()\n",
+ "serialized = codec.serialize(root)\n",
+ "deserialized = codec.deserialize(serialized)\n",
+ "print(f\"Original: {root.to_list() if root else None}\")\n",
+ "print(f\"Serialized: {serialized}\")\n",
+ "print(f\"Deserialized: {deserialized.to_list() if deserialized else None}\")\n",
+ "deserialized"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "if root is None:\n",
+ " assert deserialized is None\n",
+ "else:\n",
+ " assert deserialized is not None\n",
+ " assert deserialized.to_list() == root.to_list()"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/serialize_and_deserialize_binary_tree/solution.py b/leetcode/serialize_and_deserialize_binary_tree/solution.py
new file mode 100644
index 0000000..aeb3177
--- /dev/null
+++ b/leetcode/serialize_and_deserialize_binary_tree/solution.py
@@ -0,0 +1,81 @@
+from leetcode_py import TreeNode
+
+
+class Codec:
+ # Preorder with Null Markers
+ # Time: O(n)
+ # Space: O(n)
+ def serialize(self, root: TreeNode | None) -> str:
+ vals = []
+
+ def dfs(node: TreeNode | None):
+ if not node:
+ vals.append("#")
+ return
+ vals.append(str(node.val))
+ dfs(node.left)
+ dfs(node.right)
+
+ dfs(root)
+ return ",".join(vals)
+
+ # Time: O(n)
+ # Space: O(n)
+ def deserialize(self, data: str) -> TreeNode | None:
+ vals = iter(data.split(","))
+
+ def dfs():
+ val = next(vals)
+ if val == "#":
+ return None
+ node = TreeNode(int(val))
+ node.left = dfs()
+ node.right = dfs()
+ return node
+
+ return dfs()
+
+
+# Binary Tree Serialization Techniques
+
+# Example Tree:
+# 1
+# / \
+# 2 3
+# / \
+# 4 5
+
+# 1. Preorder with Null Markers (This Implementation)
+# Visit: root → left → right, mark nulls with '#'
+# Result: "1,2,#,#,3,4,#,#,5,#,#"
+# Pros: Self-contained, unambiguous, O(n) reconstruction
+# Cons: Longer string due to null markers
+
+# 2. Level-order (BFS) with Null Markers
+# Visit level by level, mark nulls with '#'
+# Result: "1,2,3,#,#,4,5"
+# Pros: Simple format like preorder, level-by-level intuitive
+# Cons: Still requires queue processing
+
+# 3. Postorder with Null Markers
+# Visit: left → right → root
+# Result: "#,#,2,#,#,4,#,#,5,3,1"
+# Pros: Bottom-up reconstruction
+# Cons: Less intuitive than preorder
+
+# 4. Inorder + Preorder (Two Arrays)
+# Inorder: [2,1,4,3,5], Preorder: [1,2,3,4,5]
+# Pros: Works for any binary tree structure
+# Cons: Requires two arrays, only works with unique values
+
+# 5. Parenthetical Preorder
+# Same traversal as #1 but with parentheses format: value(left)(right)
+# Result: "1(2()())(3(4()())(5()()))"
+# Pros: Human readable structure, shows nesting clearly
+# Cons: Complex parsing, verbose
+
+# 6. Parenthetical Postorder
+# Same traversal as #3 but with parentheses format: (left)(right)value
+# Result: "(()()2)((()()4)(()()5)3)1"
+# Pros: Bottom-up readable structure
+# Cons: Even more complex parsing
diff --git a/leetcode/serialize_and_deserialize_binary_tree/tests.py b/leetcode/serialize_and_deserialize_binary_tree/tests.py
new file mode 100644
index 0000000..f1fb387
--- /dev/null
+++ b/leetcode/serialize_and_deserialize_binary_tree/tests.py
@@ -0,0 +1,100 @@
+import pytest
+
+from leetcode_py import TreeNode
+from leetcode_py.test_utils import logged_test
+
+from .solution import Codec
+
+
+class TestSerializeAndDeserializeBinaryTree:
+ def setup_method(self):
+ self.codec = Codec()
+
+ @pytest.mark.parametrize(
+ "root_list",
+ [
+ # Original test cases
+ ([1, 2, 3, None, None, 4, 5]),
+ ([]),
+ ([1]),
+ ([1, 2]),
+ ([1, None, 2]),
+ ([1, 2, 3, 4, 5, 6, 7]),
+ ([5, 2, 3, None, None, 2, 4, 3, 1]),
+ # Edge cases
+ ([0]), # Single node with value 0
+ ([-1]), # Single node with negative value
+ ([1000]), # Single node with max value
+ ([-1000]), # Single node with min value
+ # Skewed trees
+ ([1, 2, None, 3, None, 4, None]), # Left skewed
+ ([1, None, 2, None, 3, None, 4]), # Right skewed
+ # Trees with negative values
+ ([-5, -3, -8, -2, -1, -7, -9]),
+ ([0, -1, 1, -2, None, None, 2]),
+ # Larger trees
+ ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]),
+ # Trees with mixed null patterns
+ ([1, None, 2, None, 3, None, 4, None, 5]),
+ ([1, 2, None, 3, None, 4, None, 5]),
+ ([5, 4, 7, 3, None, 2, None, -1, None, 9]),
+ # Duplicate values
+ ([1, 1, 1, 1, 1, 1, 1]),
+ ([2, 2, None, 2, None]),
+ # Complex asymmetric trees
+ ([10, 5, 15, None, 6, 12, 20, None, None, None, 13, 18, 25]),
+ ([50, 30, 70, 20, 40, 60, 80, 10, 25, 35, 45]),
+ ],
+ )
+ @logged_test
+ def test_serialize_deserialize(self, root_list: list[int | None]):
+ root = TreeNode.from_list(root_list) if root_list else None
+ serialized = self.codec.serialize(root)
+ deserialized = self.codec.deserialize(serialized)
+ if root is None:
+ assert deserialized is None
+ else:
+ assert deserialized is not None
+ assert deserialized.to_list() == root.to_list()
+
+ @logged_test
+ def test_multiple_serialize_deserialize_cycles(self):
+ """Test that multiple serialize/deserialize cycles preserve the tree"""
+ root_list = [1, 2, 3, None, None, 4, 5]
+ root = TreeNode.from_list(root_list)
+
+ # Perform multiple cycles
+ current = root
+ for _ in range(3):
+ serialized = self.codec.serialize(current)
+ current = self.codec.deserialize(serialized)
+
+ assert current is not None
+ assert current.to_list() == root_list
+
+ @logged_test
+ def test_serialization_format(self):
+ """Test that serialization produces expected string format"""
+ # Simple tree: [1, 2, 3]
+ root = TreeNode.from_list([1, 2, 3])
+ serialized = self.codec.serialize(root)
+
+ # Should be preorder: root, left, right with # for null
+ assert serialized == "1,2,#,#,3,#,#"
+
+ # Empty tree
+ serialized_empty = self.codec.serialize(None)
+ assert serialized_empty == "#"
+
+ @logged_test
+ def test_deserialization_edge_cases(self):
+ """Test deserialization with various string inputs"""
+ # Single null
+ assert self.codec.deserialize("#") is None
+
+ # Single node
+ single = self.codec.deserialize("42,#,#")
+ assert single is not None
+ assert single.val == 42
+ assert single.left is None
+ assert single.right is None
diff --git a/leetcode/sort_colors/README.md b/leetcode/sort_colors/README.md
new file mode 100644
index 0000000..ada8cf0
--- /dev/null
+++ b/leetcode/sort_colors/README.md
@@ -0,0 +1,39 @@
+# Sort Colors
+
+**Difficulty:** Medium
+**Topics:** Array, Two Pointers, Sorting
+**Tags:** grind-75
+
+**LeetCode:** [Problem 75](https://leetcode.com/problems/sort-colors/description/)
+
+## Problem Description
+
+Given an array `nums` with `n` objects colored red, white, or blue, sort them **in-place** so that objects of the same color are adjacent, with the colors in the order red, white, and blue.
+
+We will use the integers `0`, `1`, and `2` to represent the color red, white, and blue, respectively.
+
+You must solve this problem without using the library's sort function.
+
+## Examples
+
+### Example 1:
+
+```
+Input: nums = [2,0,2,1,1,0]
+Output: [0,0,1,1,2,2]
+```
+
+### Example 2:
+
+```
+Input: nums = [2,0,1]
+Output: [0,1,2]
+```
+
+## Constraints
+
+- `n == nums.length`
+- `1 <= n <= 300`
+- `nums[i]` is either `0`, `1`, or `2`.
+
+**Follow up:** Could you come up with a one-pass algorithm using only constant extra space?
diff --git a/leetcode/sort_colors/__init__.py b/leetcode/sort_colors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/sort_colors/playground.ipynb b/leetcode/sort_colors/playground.ipynb
new file mode 100644
index 0000000..33d81fa
--- /dev/null
+++ b/leetcode/sort_colors/playground.ipynb
@@ -0,0 +1,80 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "nums = [2, 0, 2, 1, 1, 0]\n",
+ "expected = [0, 0, 1, 1, 2, 2]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[0, 0, 1, 1, 2, 2]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "nums_copy = nums.copy()\n",
+ "Solution().sort_colors(nums_copy)\n",
+ "nums_copy"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert nums_copy == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/sort_colors/solution.py b/leetcode/sort_colors/solution.py
new file mode 100644
index 0000000..003cf4a
--- /dev/null
+++ b/leetcode/sort_colors/solution.py
@@ -0,0 +1,19 @@
+class Solution:
+ # Dutch National Flag Algorithm - partitions array into 3 regions using 2 pointers
+ # Creates: [0s][1s][2s] with left/right boundaries, mid processes unvisited elements
+ # Time: O(n)
+ # Space: O(1)
+ def sort_colors(self, nums: list[int]) -> None:
+ left = mid = 0
+ right = len(nums) - 1
+
+ while mid <= right:
+ if nums[mid] == 0:
+ nums[left], nums[mid] = nums[mid], nums[left]
+ left += 1
+ mid += 1
+ elif nums[mid] == 1:
+ mid += 1
+ else:
+ nums[mid], nums[right] = nums[right], nums[mid]
+ right -= 1
diff --git a/leetcode/sort_colors/tests.py b/leetcode/sort_colors/tests.py
new file mode 100644
index 0000000..a500d9d
--- /dev/null
+++ b/leetcode/sort_colors/tests.py
@@ -0,0 +1,27 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestSortColors:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "nums, expected",
+ [
+ ([2, 0, 2, 1, 1, 0], [0, 0, 1, 1, 2, 2]),
+ ([2, 0, 1], [0, 1, 2]),
+ ([0], [0]),
+ ([1], [1]),
+ ([2], [2]),
+ ([0, 1, 2], [0, 1, 2]),
+ ],
+ )
+ @logged_test
+ def test_sort_colors(self, nums: list[int], expected: list[int]):
+ nums_copy = nums.copy()
+ self.solution.sort_colors(nums_copy)
+ assert nums_copy == expected
diff --git a/leetcode/three_sum/README.md b/leetcode/three_sum/README.md
new file mode 100644
index 0000000..c031c0e
--- /dev/null
+++ b/leetcode/three_sum/README.md
@@ -0,0 +1,52 @@
+# 3Sum
+
+**Difficulty:** Medium
+**Topics:** Array, Two Pointers, Sorting
+**Tags:** grind-75
+
+**LeetCode:** [Problem 15](https://leetcode.com/problems/three-sum/description/)
+
+## Problem Description
+
+Given an integer array `nums`, return all the triplets `[nums[i], nums[j], nums[k]]` such that `i != j`, `i != k`, and `j != k`, and `nums[i] + nums[j] + nums[k] == 0`.
+
+Notice that the solution set must not contain duplicate triplets.
+
+## Examples
+
+### Example 1:
+
+```
+Input: nums = [-1,0,1,2,-1,-4]
+Output: [[-1,-1,2],[-1,0,1]]
+```
+
+**Explanation:**
+nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0.
+nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0.
+nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0.
+The distinct triplets are [-1,0,1] and [-1,-1,2].
+Notice that the order of the output and the order of the triplets does not matter.
+
+### Example 2:
+
+```
+Input: nums = [0,1,1]
+Output: []
+```
+
+**Explanation:** The only possible triplet does not sum up to 0.
+
+### Example 3:
+
+```
+Input: nums = [0,0,0]
+Output: [[0,0,0]]
+```
+
+**Explanation:** The only possible triplet sums up to 0.
+
+## Constraints
+
+- 3 <= nums.length <= 3000
+- -10^5 <= nums[i] <= 10^5
diff --git a/leetcode/three_sum/__init__.py b/leetcode/three_sum/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/three_sum/playground.ipynb b/leetcode/three_sum/playground.ipynb
new file mode 100644
index 0000000..007270c
--- /dev/null
+++ b/leetcode/three_sum/playground.ipynb
@@ -0,0 +1,57 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": ["from solution import Solution"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": ["# Example test case\nnums = [-1, 0, 1, 2, -1, -4]\nexpected = [[-1, -1, 2], [-1, 0, 1]]"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [],
+ "source": ["result = Solution().three_sum(nums)\nresult"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": ["# Sort for comparison since order doesn't matter\nresult_sorted = [sorted(triplet) for triplet in result]\nexpected_sorted = [sorted(triplet) for triplet in expected]\nresult_sorted.sort()\nexpected_sorted.sort()\nassert result_sorted == expected_sorted"]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/three_sum/solution.py b/leetcode/three_sum/solution.py
new file mode 100644
index 0000000..6846e00
--- /dev/null
+++ b/leetcode/three_sum/solution.py
@@ -0,0 +1,23 @@
+class Solution:
+ # Time: O(n^2)
+ # Space: O(k) where k is number of unique triplets
+ def three_sum(self, nums: list[int]) -> list[list[int]]:
+ nums.sort()
+ result = set()
+
+ for i in range(len(nums) - 2):
+ left, right = i + 1, len(nums) - 1
+
+ while left < right:
+ total = nums[i] + nums[left] + nums[right]
+
+ if total < 0:
+ left += 1
+ elif total > 0:
+ right -= 1
+ else:
+ result.add((nums[i], nums[left], nums[right]))
+ left += 1
+ right -= 1
+
+ return [list(triplet) for triplet in result]
diff --git a/leetcode/three_sum/tests.py b/leetcode/three_sum/tests.py
new file mode 100644
index 0000000..9448085
--- /dev/null
+++ b/leetcode/three_sum/tests.py
@@ -0,0 +1,43 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestThreeSum:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "nums, expected",
+ [
+ ([-1, 0, 1, 2, -1, -4], [[-1, -1, 2], [-1, 0, 1]]),
+ ([0, 1, 1], []),
+ ([0, 0, 0], [[0, 0, 0]]),
+ ([-1, 0, 1], [[-1, 0, 1]]),
+ ([1, 2, -2, -1], []),
+ ([-2, 0, 1, 1, 2], [[-2, 0, 2], [-2, 1, 1]]),
+ # Edge cases
+ ([1, 2, 3], []), # All positive
+ ([-3, -2, -1], []), # All negative
+ ([0, 0, 0, 0], [[0, 0, 0]]), # Multiple zeros
+ ([-1, -1, 2, 2], [[-1, -1, 2]]), # Duplicate pairs
+ ([3, 0, -2, -1, 1, 2], [[-2, -1, 3], [-2, 0, 2], [-1, 0, 1]]), # Multiple solutions
+ (
+ [-4, -2, -2, -2, 0, 1, 2, 2, 2, 3, 3, 4, 4, 6, 6],
+ [[-4, -2, 6], [-4, 0, 4], [-4, 1, 3], [-4, 2, 2], [-2, -2, 4], [-2, 0, 2]],
+ ), # Many duplicates
+ ([1, -1, 0], [[-1, 0, 1]]), # Simple case
+ ([2, -1, -1], [[-1, -1, 2]]), # Solution with duplicates
+ ],
+ )
+ @logged_test
+ def test_three_sum(self, nums: list[int], expected: list[list[int]]):
+ result = self.solution.three_sum(nums)
+ # Sort both result and expected for comparison since order doesn't matter
+ result_sorted = [sorted(triplet) for triplet in result]
+ expected_sorted = [sorted(triplet) for triplet in expected]
+ result_sorted.sort()
+ expected_sorted.sort()
+ assert result_sorted == expected_sorted
diff --git a/leetcode/time_based_key_value_store/README.md b/leetcode/time_based_key_value_store/README.md
new file mode 100644
index 0000000..fd46220
--- /dev/null
+++ b/leetcode/time_based_key_value_store/README.md
@@ -0,0 +1,49 @@
+# Time Based Key-Value Store
+
+**Difficulty:** Medium
+**Topics:** Hash Table, String, Binary Search, Design
+**Tags:** grind-75
+
+**LeetCode:** [Problem 981](https://leetcode.com/problems/time-based-key-value-store/description/)
+
+## Problem Description
+
+Design a time-based key-value data structure that can store multiple values for the same key at different time stamps and retrieve the key's value at a certain timestamp.
+
+Implement the `TimeMap` class:
+
+- `TimeMap()` Initializes the object of the data structure.
+- `void set(String key, String value, int timestamp)` Stores the key `key` with the value `value` at the given time `timestamp`.
+- `String get(String key, int timestamp)` Returns a value such that `set` was called previously, with `timestamp_prev <= timestamp`. If there are multiple such values, it returns the value associated with the largest `timestamp_prev`. If there are no values, it returns `""`.
+
+## Examples
+
+### Example 1:
+
+```
+Input
+["TimeMap", "set", "get", "get", "set", "get", "get"]
+[[], ["foo", "bar", 1], ["foo", 1], ["foo", 3], ["foo", "bar2", 4], ["foo", 4], ["foo", 5]]
+Output
+[null, null, "bar", "bar", null, "bar2", "bar2"]
+```
+
+**Explanation:**
+
+```
+TimeMap timeMap = new TimeMap();
+timeMap.set("foo", "bar", 1); // store the key "foo" and value "bar" along with timestamp = 1.
+timeMap.get("foo", 1); // return "bar"
+timeMap.get("foo", 3); // return "bar", since there is no value corresponding to foo at timestamp 3 and timestamp 2, then the only value is at timestamp 1 is "bar".
+timeMap.set("foo", "bar2", 4); // store the key "foo" and value "bar2" along with timestamp = 4.
+timeMap.get("foo", 4); // return "bar2"
+timeMap.get("foo", 5); // return "bar2"
+```
+
+## Constraints
+
+- `1 <= key.length, value.length <= 100`
+- `key` and `value` consist of lowercase English letters and digits.
+- `1 <= timestamp <= 10^7`
+- All the timestamps `timestamp` of `set` are strictly increasing.
+- At most `2 * 10^5` calls will be made to `set` and `get`.
diff --git a/leetcode/time_based_key_value_store/__init__.py b/leetcode/time_based_key_value_store/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/time_based_key_value_store/playground.ipynb b/leetcode/time_based_key_value_store/playground.ipynb
new file mode 100644
index 0000000..d0567d4
--- /dev/null
+++ b/leetcode/time_based_key_value_store/playground.ipynb
@@ -0,0 +1,89 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import TimeMap"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "time_map = TimeMap()\n",
+ "time_map.set(\"foo\", \"bar\", 1)\n",
+ "result1 = time_map.get(\"foo\", 1)\n",
+ "result2 = time_map.get(\"foo\", 3)\n",
+ "time_map.set(\"foo\", \"bar2\", 4)\n",
+ "result3 = time_map.get(\"foo\", 4)\n",
+ "result4 = time_map.get(\"foo\", 5)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "get(foo, 1): bar\n",
+ "get(foo, 3): bar\n",
+ "get(foo, 4): bar2\n",
+ "get(foo, 5): bar2\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(f\"get(foo, 1): {result1}\")\n",
+ "print(f\"get(foo, 3): {result2}\")\n",
+ "print(f\"get(foo, 4): {result3}\")\n",
+ "print(f\"get(foo, 5): {result4}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result1 == \"bar\"\n",
+ "assert result2 == \"bar\"\n",
+ "assert result3 == \"bar2\"\n",
+ "assert result4 == \"bar2\""
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/time_based_key_value_store/solution.py b/leetcode/time_based_key_value_store/solution.py
new file mode 100644
index 0000000..fcb11cd
--- /dev/null
+++ b/leetcode/time_based_key_value_store/solution.py
@@ -0,0 +1,34 @@
+class TimeMap:
+ # Time: O(1)
+ # Space: O(n)
+ def __init__(
+ self,
+ ) -> None:
+ self.store: dict[str, list[tuple[int, str]]] = {}
+
+ # Time: O(1)
+ # Space: O(1)
+ def set(self, key: str, value: str, timestamp: int) -> None:
+ if key not in self.store:
+ self.store[key] = []
+ self.store[key].append((timestamp, value))
+
+ # Time: O(log n)
+ # Space: O(1)
+ def get(self, key: str, timestamp: int) -> str:
+ if key not in self.store:
+ return ""
+
+ values = self.store[key]
+ left, right = 0, len(values) - 1
+ result = ""
+
+ while left <= right:
+ mid = (left + right) // 2
+ if values[mid][0] <= timestamp:
+ result = values[mid][1]
+ left = mid + 1
+ else:
+ right = mid - 1
+
+ return result
diff --git a/leetcode/time_based_key_value_store/tests.py b/leetcode/time_based_key_value_store/tests.py
new file mode 100644
index 0000000..f4c23a0
--- /dev/null
+++ b/leetcode/time_based_key_value_store/tests.py
@@ -0,0 +1,40 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import TimeMap
+
+
+class TestTimeBasedKeyValueStore:
+ @pytest.mark.parametrize(
+ "operations, inputs, expected",
+ [
+ (
+ ["TimeMap", "set", "get", "get", "set", "get", "get"],
+ [
+ [],
+ ["foo", "bar", 1],
+ ["foo", 1],
+ ["foo", 3],
+ ["foo", "bar2", 4],
+ ["foo", 4],
+ ["foo", 5],
+ ],
+ [None, None, "bar", "bar", None, "bar2", "bar2"],
+ )
+ ],
+ )
+ @logged_test
+ def test_time_map_operations(self, operations: list[str], inputs: list[list], expected: list):
+ time_map: TimeMap | None = None
+ result: list[str | None] = []
+ for i, op in enumerate(operations):
+ if op == "TimeMap":
+ time_map = TimeMap()
+ result.append(None)
+ elif op == "set" and time_map is not None:
+ time_map.set(*inputs[i])
+ result.append(None)
+ elif op == "get" and time_map is not None:
+ result.append(time_map.get(*inputs[i]))
+ assert result == expected
diff --git a/leetcode/valid_anagram/README.md b/leetcode/valid_anagram/README.md
new file mode 100644
index 0000000..1d0cc68
--- /dev/null
+++ b/leetcode/valid_anagram/README.md
@@ -0,0 +1,34 @@
+# Valid Anagram
+
+**Difficulty:** Easy
+**Topics:** Hash Table, String, Sorting
+**Tags:** grind-75
+
+**LeetCode:** [Problem 242](https://leetcode.com/problems/valid-anagram/description/)
+
+## Problem Description
+
+Given two strings `s` and `t`, return `true` if `t` is an anagram of `s`, and `false` otherwise.
+
+## Examples
+
+### Example 1:
+
+```
+Input: s = "anagram", t = "nagaram"
+Output: true
+```
+
+### Example 2:
+
+```
+Input: s = "rat", t = "car"
+Output: false
+```
+
+## Constraints
+
+- 1 <= s.length, t.length <= 5 \* 10^4
+- s and t consist of lowercase English letters.
+
+**Follow up:** What if the inputs contain Unicode characters? How would you adapt your solution to such a case?
diff --git a/leetcode/valid_anagram/__init__.py b/leetcode/valid_anagram/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/valid_anagram/playground.ipynb b/leetcode/valid_anagram/playground.ipynb
new file mode 100644
index 0000000..d312663
--- /dev/null
+++ b/leetcode/valid_anagram/playground.ipynb
@@ -0,0 +1,80 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "s = \"anagram\"\n",
+ "t = \"nagaram\"\n",
+ "expected = True"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().is_anagram(s, t)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/valid_anagram/solution.py b/leetcode/valid_anagram/solution.py
new file mode 100644
index 0000000..ce6f797
--- /dev/null
+++ b/leetcode/valid_anagram/solution.py
@@ -0,0 +1,8 @@
+from collections import Counter
+
+
+class Solution:
+ # Time: O(n)
+ # Space: O(1) - at most 26 unique characters
+ def is_anagram(self, s: str, t: str) -> bool:
+ return Counter(s) == Counter(t)
diff --git a/leetcode/valid_anagram/tests.py b/leetcode/valid_anagram/tests.py
new file mode 100644
index 0000000..aea8280
--- /dev/null
+++ b/leetcode/valid_anagram/tests.py
@@ -0,0 +1,38 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestValidAnagram:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "s, t, expected",
+ [
+ ("anagram", "nagaram", True),
+ ("rat", "car", False),
+ ("listen", "silent", True),
+ ("hello", "bello", False),
+ ("", "", True),
+ ("a", "a", True),
+ ("a", "b", False),
+ ("ab", "ba", True),
+ ("abc", "bca", True),
+ ("abc", "def", False),
+ ("aab", "abb", False),
+ ("aabbcc", "abcabc", True),
+ ("abcd", "abcde", False),
+ ("race", "care", True),
+ ("elbow", "below", True),
+ ("study", "dusty", True),
+ ("night", "thing", True),
+ ("stressed", "desserts", True),
+ ],
+ )
+ @logged_test
+ def test_is_anagram(self, s: str, t: str, expected: bool):
+ result = self.solution.is_anagram(s, t)
+ assert result == expected
diff --git a/leetcode/valid_parentheses/README.md b/leetcode/valid_parentheses/README.md
new file mode 100644
index 0000000..05d24f0
--- /dev/null
+++ b/leetcode/valid_parentheses/README.md
@@ -0,0 +1,59 @@
+# Valid Parentheses
+
+**Difficulty:** Easy
+**Topics:** String, Stack
+**Tags:** grind-75
+
+**LeetCode:** [Problem 20](https://leetcode.com/problems/valid-parentheses/description/)
+
+## Problem Description
+
+Given a string `s` containing just the characters `'('`, `')'`, `'{'`, `'}'`, `'['` and `']'`, determine if the input string is valid.
+
+An input string is valid if:
+
+1. Open brackets must be closed by the same type of brackets.
+2. Open brackets must be closed in the correct order.
+3. Every close bracket has a corresponding open bracket of the same type.
+
+## Examples
+
+### Example 1:
+
+```
+Input: s = "()"
+Output: true
+```
+
+### Example 2:
+
+```
+Input: s = "()[]{}"
+Output: true
+```
+
+### Example 3:
+
+```
+Input: s = "(]"
+Output: false
+```
+
+### Example 4:
+
+```
+Input: s = "([])"
+Output: true
+```
+
+### Example 5:
+
+```
+Input: s = "([)]"
+Output: false
+```
+
+## Constraints
+
+- `1 <= s.length <= 10^4`
+- `s` consists of parentheses only `'()[]{}'`.
diff --git a/leetcode/valid_parentheses/__init__.py b/leetcode/valid_parentheses/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/valid_parentheses/playground.ipynb b/leetcode/valid_parentheses/playground.ipynb
new file mode 100644
index 0000000..b5b4f34
--- /dev/null
+++ b/leetcode/valid_parentheses/playground.ipynb
@@ -0,0 +1,67 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "s = \"()\"\n",
+ "expected = True"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "result = Solution().is_valid(s)\nresult"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/valid_parentheses/solution.py b/leetcode/valid_parentheses/solution.py
new file mode 100644
index 0000000..779f734
--- /dev/null
+++ b/leetcode/valid_parentheses/solution.py
@@ -0,0 +1,14 @@
+class Solution:
+ # Time: O(n)
+ # Space: O(n)
+ def is_valid(self, s: str) -> bool:
+ stack = []
+ pairs = {"(": ")", "[": "]", "{": "}"}
+
+ for char in s:
+ if char in pairs:
+ stack.append(char)
+ elif not stack or pairs[stack.pop()] != char:
+ return False
+
+ return not stack
diff --git a/leetcode/valid_parentheses/tests.py b/leetcode/valid_parentheses/tests.py
new file mode 100644
index 0000000..528dacb
--- /dev/null
+++ b/leetcode/valid_parentheses/tests.py
@@ -0,0 +1,46 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestValidParentheses:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "s, expected",
+ [
+ ("()", True),
+ ("()[]{}", True),
+ ("(]", False),
+ ("([])", True),
+ ("([)]", False),
+ ("", True),
+ ("(", False),
+ (")", False),
+ ("{[()]}", True),
+ ("{[(])}", False),
+ ("(((", False),
+ (")))", False),
+ ("()()()", True),
+ ("({[]})", True),
+ ("({[}])", False),
+ ("[[[[[]]]]]", True),
+ ("[[[[[]]]]", False),
+ ("{{{{{}}}}}", True),
+ ("((((((((((", False),
+ ("))))))))))", False),
+ ("(){}[]", True),
+ ("([{}])", True),
+ ("([{]})", False),
+ ("(())", True),
+ ("(()", False),
+ ("())", False),
+ ],
+ )
+ @logged_test
+ def test_is_valid(self, s: str, expected: bool):
+ result = self.solution.is_valid(s)
+ assert result == expected
diff --git a/leetcode/word_break/README.md b/leetcode/word_break/README.md
new file mode 100644
index 0000000..7bb5206
--- /dev/null
+++ b/leetcode/word_break/README.md
@@ -0,0 +1,49 @@
+# Word Break
+
+**Difficulty:** Medium
+**Topics:** Array, Hash Table, String, Dynamic Programming, Trie, Memoization
+**Tags:** grind-75
+
+**LeetCode:** [Problem 139](https://leetcode.com/problems/word-break/description/)
+
+## Problem Description
+
+Given a string `s` and a dictionary of strings `wordDict`, return `true` if `s` can be segmented into a space-separated sequence of one or more dictionary words.
+
+**Note** that the same word in the dictionary may be reused multiple times in the segmentation.
+
+## Examples
+
+### Example 1:
+
+```
+Input: s = "leetcode", wordDict = ["leet","code"]
+Output: true
+```
+
+**Explanation:** Return true because "leetcode" can be segmented as "leet code".
+
+### Example 2:
+
+```
+Input: s = "applepenapple", wordDict = ["apple","pen"]
+Output: true
+```
+
+**Explanation:** Return true because "applepenapple" can be segmented as "apple pen apple".
+Note that you are allowed to reuse a dictionary word.
+
+### Example 3:
+
+```
+Input: s = "catsandog", wordDict = ["cats","dog","sand","and","cat"]
+Output: false
+```
+
+## Constraints
+
+- `1 <= s.length <= 300`
+- `1 <= wordDict.length <= 1000`
+- `1 <= wordDict[i].length <= 20`
+- `s` and `wordDict[i]` consist of only lowercase English letters.
+- All the strings of `wordDict` are **unique**.
diff --git a/leetcode/word_break/__init__.py b/leetcode/word_break/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/word_break/playground.ipynb b/leetcode/word_break/playground.ipynb
new file mode 100644
index 0000000..3d5b07d
--- /dev/null
+++ b/leetcode/word_break/playground.ipynb
@@ -0,0 +1,80 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "s = \"leetcode\"\n",
+ "word_dict = [\"leet\", \"code\"]\n",
+ "expected = True"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().word_break(s, word_dict)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/word_break/solution.py b/leetcode/word_break/solution.py
new file mode 100644
index 0000000..ed8dc30
--- /dev/null
+++ b/leetcode/word_break/solution.py
@@ -0,0 +1,15 @@
+class Solution:
+ # Time: O(n^2)
+ # Space: O(n)
+ def word_break(self, s: str, word_dict: list[str]) -> bool:
+ word_set = set(word_dict)
+ dp = [False] * (len(s) + 1)
+ dp[0] = True
+
+ for i in range(1, len(s) + 1):
+ for j in range(i):
+ if dp[j] and s[j:i] in word_set:
+ dp[i] = True
+ break
+
+ return dp[-1]
diff --git a/leetcode/word_break/tests.py b/leetcode/word_break/tests.py
new file mode 100644
index 0000000..f250fa1
--- /dev/null
+++ b/leetcode/word_break/tests.py
@@ -0,0 +1,27 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestWordBreak:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "s, word_dict, expected",
+ [
+ ("leetcode", ["leet", "code"], True),
+ ("applepenapple", ["apple", "pen"], True),
+ ("catsandog", ["cats", "dog", "sand", "and", "cat"], False),
+ ("", [], True),
+ ("a", ["a"], True),
+ ("ab", ["a", "b"], True),
+ ("abcd", ["a", "abc", "d"], True),
+ ],
+ )
+ @logged_test
+ def test_word_break(self, s: str, word_dict: list[str], expected: bool):
+ result = self.solution.word_break(s, word_dict)
+ assert result == expected
diff --git a/leetcode/word_ladder/README.md b/leetcode/word_ladder/README.md
new file mode 100644
index 0000000..32820d3
--- /dev/null
+++ b/leetcode/word_ladder/README.md
@@ -0,0 +1,47 @@
+# Word Ladder
+
+**Difficulty:** Hard
+**Topics:** Hash Table, String, Breadth-First Search
+**Tags:** grind-75
+
+**LeetCode:** [Problem 127](https://leetcode.com/problems/word-ladder/description/)
+
+## Problem Description
+
+A **transformation sequence** from word `beginWord` to word `endWord` using a dictionary `wordList` is a sequence of words `beginWord -> s1 -> s2 -> ... -> sk` such that:
+
+- Every adjacent pair of words differs by a single letter.
+- Every `si` for `1 <= i <= k` is in `wordList`. Note that `beginWord` does not need to be in `wordList`.
+- `sk == endWord`
+
+Given two words, `beginWord` and `endWord`, and a dictionary `wordList`, return the **number of words** in the **shortest transformation sequence** from `beginWord` to `endWord`, or `0` if no such sequence exists.
+
+## Examples
+
+### Example 1:
+
+```
+Input: beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]
+Output: 5
+```
+
+**Explanation:** One shortest transformation sequence is "hit" -> "hot" -> "dot" -> "dog" -> "cog", which is 5 words long.
+
+### Example 2:
+
+```
+Input: beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"]
+Output: 0
+```
+
+**Explanation:** The endWord "cog" is not in wordList, therefore there is no valid transformation sequence.
+
+## Constraints
+
+- 1 <= beginWord.length <= 10
+- endWord.length == beginWord.length
+- 1 <= wordList.length <= 5000
+- wordList[i].length == beginWord.length
+- beginWord, endWord, and wordList[i] consist of lowercase English letters.
+- beginWord != endWord
+- All the words in wordList are unique.
diff --git a/leetcode/word_ladder/__init__.py b/leetcode/word_ladder/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/word_ladder/playground.ipynb b/leetcode/word_ladder/playground.ipynb
new file mode 100644
index 0000000..da0260d
--- /dev/null
+++ b/leetcode/word_ladder/playground.ipynb
@@ -0,0 +1,81 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from solution import Solution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Example test case\n",
+ "begin_word = \"hit\"\n",
+ "end_word = \"cog\"\n",
+ "word_list = [\"hot\", \"dot\", \"dog\", \"lot\", \"log\", \"cog\"]\n",
+ "expected = 5"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "5"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "result = Solution().ladder_length(begin_word, end_word, word_list)\n",
+ "result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert result == expected"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/word_ladder/solution.py b/leetcode/word_ladder/solution.py
new file mode 100644
index 0000000..7b74aa4
--- /dev/null
+++ b/leetcode/word_ladder/solution.py
@@ -0,0 +1,36 @@
+class Solution:
+ # Time: O(M^2 * N) where M is length of each word, N is total number of words
+ # Space: O(M * N) for the visited sets
+ def ladder_length(self, begin_word: str, end_word: str, word_list: list[str]) -> int:
+ if end_word not in word_list:
+ return 0
+
+ if begin_word == end_word:
+ return 1
+
+ word_set = set(word_list)
+ begin_set = {begin_word}
+ end_set = {end_word}
+ length = 1
+
+ while begin_set and end_set:
+ if len(begin_set) > len(end_set):
+ begin_set, end_set = end_set, begin_set
+
+ next_set = set()
+ for word in begin_set:
+ for i in range(len(word)):
+ for c in "abcdefghijklmnopqrstuvwxyz":
+ new_word = word[:i] + c + word[i + 1 :]
+
+ if new_word in end_set:
+ return length + 1
+
+ if new_word in word_set:
+ next_set.add(new_word)
+ word_set.remove(new_word)
+
+ begin_set = next_set
+ length += 1
+
+ return 0
diff --git a/leetcode/word_ladder/tests.py b/leetcode/word_ladder/tests.py
new file mode 100644
index 0000000..0b7682f
--- /dev/null
+++ b/leetcode/word_ladder/tests.py
@@ -0,0 +1,42 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestWordLadder:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "begin_word, end_word, word_list, expected",
+ [
+ # Basic cases
+ ("hit", "cog", ["hot", "dot", "dog", "lot", "log", "cog"], 5),
+ ("hit", "cog", ["hot", "dot", "dog", "lot", "log"], 0),
+ ("a", "c", ["a", "b", "c"], 2),
+ # Edge cases
+ ("hot", "dog", ["hot", "dog"], 0), # No intermediate words
+ ("hot", "hot", ["hot"], 1), # Same word
+ ("cat", "dog", [], 0), # Empty word list
+ ("cat", "dog", ["cat"], 0), # End word not in list
+ # Single character changes
+ ("a", "b", ["a", "b"], 2),
+ ("ab", "cd", ["ab", "ad", "cd"], 3),
+ # Longer paths
+ ("red", "tax", ["ted", "tex", "red", "tax", "tad", "den", "rex", "pee"], 4),
+ # Multiple possible paths (should return shortest)
+ ("cat", "dog", ["cat", "bat", "bag", "dag", "dog", "cag", "cog"], 4),
+ # No path exists
+ ("abc", "def", ["abc", "def", "ghi"], 0),
+ # Direct transformation
+ ("cat", "bat", ["cat", "bat"], 2),
+ # Longer word length
+ ("word", "form", ["word", "worm", "form", "foam", "flam", "flab"], 3),
+ ],
+ )
+ @logged_test
+ def test_ladder_length(self, begin_word: str, end_word: str, word_list: list[str], expected: int):
+ result = self.solution.ladder_length(begin_word, end_word, word_list)
+ assert result == expected
diff --git a/leetcode/zero_one_matrix/README.md b/leetcode/zero_one_matrix/README.md
new file mode 100644
index 0000000..29b3127
--- /dev/null
+++ b/leetcode/zero_one_matrix/README.md
@@ -0,0 +1,44 @@
+# 01 Matrix
+
+**Difficulty:** Medium
+**Topics:** Array, Dynamic Programming, Breadth-First Search, Matrix
+**Tags:** grind-75
+
+**LeetCode:** [Problem 542](https://leetcode.com/problems/zero-one-matrix/description/)
+
+## Problem Description
+
+Given an `m x n` binary matrix `mat`, return the distance of the nearest `0` for each cell.
+
+The distance between two cells sharing a common edge is `1`.
+
+## Examples
+
+### Example 1:
+
+
+
+```
+Input: mat = [[0,0,0],[0,1,0],[0,0,0]]
+Output: [[0,0,0],[0,1,0],[0,0,0]]
+```
+
+### Example 2:
+
+
+
+```
+Input: mat = [[0,0,0],[0,1,0],[1,1,1]]
+Output: [[0,0,0],[0,1,0],[1,2,1]]
+```
+
+## Constraints
+
+- `m == mat.length`
+- `n == mat[i].length`
+- `1 <= m, n <= 10^4`
+- `1 <= m * n <= 10^4`
+- `mat[i][j]` is either `0` or `1`
+- There is at least one `0` in `mat`
+
+**Note:** This question is the same as 1765: [Map of Highest Peak](https://leetcode.com/problems/map-of-highest-peak/)
diff --git a/leetcode/zero_one_matrix/__init__.py b/leetcode/zero_one_matrix/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/leetcode/zero_one_matrix/playground.ipynb b/leetcode/zero_one_matrix/playground.ipynb
new file mode 100644
index 0000000..659bd5e
--- /dev/null
+++ b/leetcode/zero_one_matrix/playground.ipynb
@@ -0,0 +1,57 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "imports",
+ "metadata": {},
+ "outputs": [],
+ "source": ["from solution import Solution"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "setup",
+ "metadata": {},
+ "outputs": [],
+ "source": ["# Example test case\nmat = [[0, 0, 0], [0, 1, 0], [1, 1, 1]]\nexpected = [[0, 0, 0], [0, 1, 0], [1, 2, 1]]"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "execute",
+ "metadata": {},
+ "outputs": [],
+ "source": ["result = Solution().update_matrix(mat)\nresult"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "test",
+ "metadata": {},
+ "outputs": [],
+ "source": ["assert result == expected"]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "leetcode-py-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python3",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/leetcode/zero_one_matrix/solution.py b/leetcode/zero_one_matrix/solution.py
new file mode 100644
index 0000000..b70c86c
--- /dev/null
+++ b/leetcode/zero_one_matrix/solution.py
@@ -0,0 +1,30 @@
+from collections import deque
+
+
+class Solution:
+ # Time: O(m * n)
+ # Space: O(m * n)
+ def update_matrix(self, mat: list[list[int]]) -> list[list[int]]:
+ UNSEEN = -1
+ m, n = len(mat), len(mat[0])
+ queue: deque[tuple[int, int]] = deque()
+
+ # Mark 1s as UNSEEN and add all 0s to queue
+ for i in range(m):
+ for j in range(n):
+ if mat[i][j] == 0:
+ queue.append((i, j))
+ else:
+ mat[i][j] = UNSEEN
+
+ # BFS from all 0s simultaneously
+ directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
+ while queue:
+ row, col = queue.popleft()
+ for dr, dc in directions:
+ r, c = row + dr, col + dc
+ if 0 <= r < m and 0 <= c < n and mat[r][c] == UNSEEN:
+ mat[r][c] = mat[row][col] + 1
+ queue.append((r, c))
+
+ return mat
diff --git a/leetcode/zero_one_matrix/tests.py b/leetcode/zero_one_matrix/tests.py
new file mode 100644
index 0000000..6b5a4e4
--- /dev/null
+++ b/leetcode/zero_one_matrix/tests.py
@@ -0,0 +1,29 @@
+import pytest
+
+from leetcode_py.test_utils import logged_test
+
+from .solution import Solution
+
+
+class TestZeroOneMatrix:
+ def setup_method(self):
+ self.solution = Solution()
+
+ @pytest.mark.parametrize(
+ "mat, expected",
+ [
+ ([[0, 0, 0], [0, 1, 0], [0, 0, 0]], [[0, 0, 0], [0, 1, 0], [0, 0, 0]]),
+ ([[0, 0, 0], [0, 1, 0], [1, 1, 1]], [[0, 0, 0], [0, 1, 0], [1, 2, 1]]),
+ ([[0]], [[0]]),
+ ([[1, 1, 1], [1, 1, 1], [1, 1, 0]], [[4, 3, 2], [3, 2, 1], [2, 1, 0]]),
+ ([[0, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]], [[0, 1, 2, 3], [1, 2, 3, 4], [2, 3, 4, 5]]),
+ (
+ [[1, 0, 1, 1, 0, 0, 1, 0, 0, 1], [0, 1, 1, 0, 1, 0, 1, 0, 1, 1]],
+ [[1, 0, 1, 1, 0, 0, 1, 0, 0, 1], [0, 1, 1, 0, 1, 0, 1, 0, 1, 2]],
+ ),
+ ],
+ )
+ @logged_test
+ def test_update_matrix(self, mat: list[list[int]], expected: list[list[int]]):
+ result = self.solution.update_matrix(mat)
+ assert result == expected
diff --git a/leetcode_py/data_structures/__init__.py b/leetcode_py/data_structures/__init__.py
index 34a8d61..8d61bf4 100644
--- a/leetcode_py/data_structures/__init__.py
+++ b/leetcode_py/data_structures/__init__.py
@@ -1,6 +1,6 @@
-from .dict_tree import DictTree
+from .dict_tree import DictTree, RecursiveDict
from .graph_node import GraphNode
from .list_node import ListNode
from .tree_node import TreeNode
-__all__ = ["DictTree", "GraphNode", "ListNode", "TreeNode"]
+__all__ = ["DictTree", "GraphNode", "ListNode", "RecursiveDict", "TreeNode"]
diff --git a/leetcode_py/data_structures/dict_tree.py b/leetcode_py/data_structures/dict_tree.py
index 5b8e614..1f324aa 100644
--- a/leetcode_py/data_structures/dict_tree.py
+++ b/leetcode_py/data_structures/dict_tree.py
@@ -1,19 +1,20 @@
-from typing import Any, Generic, Hashable, TypeVar
+from typing import Any, Generic, Hashable, TypeAlias, TypeVar
K = TypeVar("K", bound=Hashable)
+RecursiveDict: TypeAlias = dict[K, "RecursiveDict[K] | Any"]
class DictTree(Generic[K]):
def __init__(self) -> None:
- self.root: dict[K, Any] = {}
+ self.root: RecursiveDict[K] = {}
def __str__(self) -> str:
if not hasattr(self, "root") or not self.root:
return "Empty"
return self._render_dict_tree(self.root)
- def _render_dict_tree(self, node: dict[K, Any], prefix: str = "", depth: int = 0) -> str:
+ def _render_dict_tree(self, node: RecursiveDict[K], prefix: str = "", depth: int = 0) -> str:
if not node:
return ""
@@ -49,7 +50,9 @@ def _repr_html_(self) -> str:
self._add_dict_nodes(dot, self.root, "root")
return dot.pipe(format="svg", encoding="utf-8")
- def _add_dict_nodes(self, dot: Any, node: dict[K, Any], node_id: str, char: K | str = "") -> None:
+ def _add_dict_nodes(
+ self, dot: Any, node: RecursiveDict[K], node_id: str, char: K | str = ""
+ ) -> None:
if not node:
return
diff --git a/leetcode_py/data_structures/doubly_list_node.py b/leetcode_py/data_structures/doubly_list_node.py
new file mode 100644
index 0000000..74996fa
--- /dev/null
+++ b/leetcode_py/data_structures/doubly_list_node.py
@@ -0,0 +1,128 @@
+from typing import Generic, TypeVar
+
+# TODO: Remove TypeVar when minimum Python version is 3.12+ (use class DoublyListNode[T]: syntax)
+T = TypeVar("T")
+
+
+class DoublyListNode(Generic[T]):
+ def __init__(
+ self, val: T, prev: "DoublyListNode[T] | None" = None, next: "DoublyListNode[T] | None" = None
+ ):
+ self.val = val
+ self.prev = prev
+ self.next = next
+
+ @classmethod
+ def from_list(cls, arr: list[T]) -> "DoublyListNode[T] | None":
+ if not arr:
+ return None
+ head = cls(arr[0])
+ current = head
+ for val in arr[1:]:
+ new_node = cls(val, prev=current)
+ current.next = new_node
+ current = new_node
+ return head
+
+ def _has_cycle(self) -> bool:
+ """Detect cycle using Floyd's algorithm in forward direction."""
+ slow = fast = self
+ while fast and fast.next and fast.next.next:
+ assert slow.next is not None
+ slow = slow.next
+ fast = fast.next.next
+ if slow is fast:
+ return True
+ return False
+
+ def to_list(self, max_length: int = 1000) -> list[T]:
+ result: list[T] = []
+ current: "DoublyListNode[T] | None" = self
+ visited: set[int] = set()
+
+ while current and len(result) < max_length:
+ if id(current) in visited:
+ break
+ visited.add(id(current))
+ result.append(current.val)
+ current = current.next
+ return result
+
+ def __str__(self) -> str:
+ if self._has_cycle():
+ result: list[str] = []
+ current: "DoublyListNode[T] | None" = self
+ visited: dict[int, int] = {}
+ position = 0
+
+ while current:
+ if id(current) in visited:
+ cycle_pos = visited[id(current)]
+ cycle_val = result[cycle_pos]
+ result_str = " <-> ".join(result)
+ return f"{result_str} <-> ... (cycle back to {cycle_val})"
+
+ visited[id(current)] = position
+ result.append(str(current.val))
+ current = current.next
+ position += 1
+
+ values = self.to_list()
+ result_str = " <-> ".join(str(val) for val in values)
+ if len(values) >= 1000:
+ result_str += " <-> ... (long list)"
+ return result_str
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}({self.to_list()})"
+
+ def _repr_html_(self) -> str:
+ """Generate HTML representation with bidirectional arrows."""
+ try:
+ import graphviz
+ except ImportError:
+ return f"{self.__str__()}
"
+
+ dot = graphviz.Digraph(comment="DoublyLinkedList")
+ dot.attr(rankdir="LR")
+ dot.attr("node", shape="box", style="rounded,filled", fillcolor="lightgreen")
+ dot.attr("edge", color="black")
+
+ current: "DoublyListNode[T] | None" = self
+ visited: dict[int, int] = {}
+ node_id = 0
+
+ while current:
+ if id(current) in visited:
+ cycle_target = visited[id(current)]
+ dot.edge(
+ f"node_{node_id - 1}",
+ f"node_{cycle_target}",
+ color="red",
+ style="dashed",
+ label="cycle",
+ )
+ break
+
+ visited[id(current)] = node_id
+ dot.node(f"node_{node_id}", str(current.val))
+
+ if current.next and id(current.next) not in visited:
+ # Forward edge
+ dot.edge(f"node_{node_id}", f"node_{node_id + 1}", label="next")
+ # Backward edge
+ dot.edge(f"node_{node_id + 1}", f"node_{node_id}", label="prev", color="blue")
+
+ current = current.next
+ node_id += 1
+
+ return dot.pipe(format="svg", encoding="utf-8")
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, DoublyListNode):
+ return False
+
+ if self._has_cycle() or other._has_cycle():
+ return False
+
+ return self.to_list() == other.to_list()
diff --git a/leetcode_py/data_structures/tree_node.py b/leetcode_py/data_structures/tree_node.py
index ca96921..a716e9b 100644
--- a/leetcode_py/data_structures/tree_node.py
+++ b/leetcode_py/data_structures/tree_node.py
@@ -109,5 +109,17 @@ def __eq__(self, other: object) -> bool:
return False
return self.to_list() == other.to_list()
+ def find_node(self, val: T) -> "TreeNode[T] | None":
+ """Find node with given value."""
+ if self.val == val:
+ return self
+ if self.left:
+ left_result = self.left.find_node(val)
+ if left_result:
+ return left_result
+ if self.right:
+ return self.right.find_node(val)
+ return None
+
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.to_list()})"
diff --git a/poetry.lock b/poetry.lock
index 2ccaad1..3cc5f73 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -570,14 +570,14 @@ files = [
[[package]]
name = "executing"
-version = "2.2.0"
+version = "2.2.1"
description = "Get the currently executing AST node of a frame, and other information"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
- {file = "executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa"},
- {file = "executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755"},
+ {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"},
+ {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"},
]
[package.extras]
@@ -1308,14 +1308,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
-version = "8.4.1"
+version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"},
- {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"},
+ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
+ {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
@@ -1623,31 +1623,31 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
-version = "0.12.11"
+version = "0.12.12"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
- {file = "ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065"},
- {file = "ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93"},
- {file = "ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8"},
- {file = "ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f"},
- {file = "ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000"},
- {file = "ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2"},
- {file = "ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39"},
- {file = "ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9"},
- {file = "ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3"},
- {file = "ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd"},
- {file = "ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea"},
- {file = "ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d"},
+ {file = "ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc"},
+ {file = "ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727"},
+ {file = "ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45"},
+ {file = "ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489"},
+ {file = "ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee"},
+ {file = "ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1"},
+ {file = "ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d"},
+ {file = "ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093"},
+ {file = "ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6"},
]
[[package]]
diff --git a/tests/data_structures/test_doubly_list_node.py b/tests/data_structures/test_doubly_list_node.py
new file mode 100644
index 0000000..e76a9df
--- /dev/null
+++ b/tests/data_structures/test_doubly_list_node.py
@@ -0,0 +1,237 @@
+from typing import Any
+
+import pytest
+
+from leetcode_py.data_structures.doubly_list_node import DoublyListNode
+
+
+class TestDoublyListNode:
+ @pytest.mark.parametrize(
+ "val, expected_val, expected_prev, expected_next",
+ [
+ (5, 5, None, None),
+ ("hello", "hello", None, None),
+ ],
+ )
+ def test_init(self, val: Any, expected_val: Any, expected_prev: Any, expected_next: Any) -> None:
+ node = DoublyListNode(val)
+ assert node.val == expected_val
+ assert node.prev == expected_prev
+ assert node.next == expected_next
+
+ def test_init_with_prev_next(self) -> None:
+ prev_node = DoublyListNode[int](1)
+ next_node = DoublyListNode[int](3)
+ node = DoublyListNode[int](2, prev_node, next_node)
+ assert node.val == 2
+ assert node.prev == prev_node
+ assert node.next == next_node
+
+ @pytest.mark.parametrize(
+ "input_list, expected_result",
+ [
+ ([], None),
+ ([1], "single_node"),
+ ([1, 2, 3], "multiple_nodes"),
+ (["a", "b"], "string_nodes"),
+ ],
+ )
+ def test_from_list(self, input_list: list[Any], expected_result: str | None) -> None:
+ result = DoublyListNode.from_list(input_list)
+
+ if expected_result is None:
+ assert result is None
+ elif expected_result == "single_node":
+ assert result is not None
+ assert result.val == 1
+ assert result.prev is None
+ assert result.next is None
+ elif expected_result == "multiple_nodes":
+ assert result is not None
+ assert result.val == 1
+ assert result.prev is None
+ assert result.next is not None
+ assert result.next.val == 2
+ assert result.next.prev == result
+ assert result.next.next is not None
+ assert result.next.next.val == 3
+ assert result.next.next.prev == result.next
+ assert result.next.next.next is None
+ elif expected_result == "string_nodes":
+ assert result is not None
+ assert result.val == "a"
+ assert result.prev is None
+ assert result.next is not None
+ assert result.next.val == "b"
+ assert result.next.prev == result
+ assert result.next.next is None
+
+ @pytest.mark.parametrize(
+ "input_list, expected_output",
+ [
+ ([1], [1]),
+ ([1, 2, 3], [1, 2, 3]),
+ (["x", "y"], ["x", "y"]),
+ ],
+ )
+ def test_to_list(self, input_list: list[Any], expected_output: list[Any]) -> None:
+ node = DoublyListNode.from_list(input_list)
+ assert node is not None
+ assert node.to_list() == expected_output
+
+ @pytest.mark.parametrize(
+ "input_list, expected_str, expected_repr",
+ [
+ ([1, 2, 3], "1 <-> 2 <-> 3", "DoublyListNode([1, 2, 3])"),
+ (["a", "b"], "a <-> b", "DoublyListNode(['a', 'b'])"),
+ ],
+ )
+ def test_string_representations(
+ self, input_list: list[Any], expected_str: str, expected_repr: str
+ ) -> None:
+ node = DoublyListNode.from_list(input_list)
+ assert node is not None
+ assert str(node) == expected_str
+ assert repr(node) == expected_repr
+
+ @pytest.mark.parametrize(
+ "list1, list2, should_equal",
+ [
+ ([1, 2, 3], [1, 2, 3], True),
+ ([1, 2, 3], [1, 2, 4], False),
+ ],
+ )
+ def test_equality(self, list1: list[int], list2: list[int], should_equal: bool) -> None:
+ node1 = DoublyListNode.from_list(list1)
+ node2 = DoublyListNode.from_list(list2)
+ assert (node1 == node2) == should_equal
+
+ @pytest.mark.parametrize("other_value", [[1], "1"])
+ def test_equality_different_types(self, other_value: Any) -> None:
+ node = DoublyListNode[int](1)
+ assert node != other_value
+
+ @pytest.mark.parametrize(
+ "test_list",
+ [
+ [1, 2, 3, 4, 5],
+ [1],
+ [10, 20, 30],
+ ["hello", "world"],
+ [True, False, True],
+ ],
+ )
+ def test_roundtrip_conversion(self, test_list: list[Any]) -> None:
+ node = DoublyListNode.from_list(test_list)
+ assert node is not None
+ result = node.to_list()
+ assert result == test_list
+
+ def test_has_cycle_no_cycle(self) -> None:
+ node = DoublyListNode.from_list([1, 2, 3])
+ assert node is not None
+ assert not node._has_cycle()
+
+ def test_has_cycle_with_cycle(self) -> None:
+ node1 = DoublyListNode(1)
+ node2 = DoublyListNode(2)
+ node3 = DoublyListNode(3)
+ node1.next = node2
+ node2.prev = node1
+ node2.next = node3
+ node3.prev = node2
+ node3.next = node2 # Create cycle
+
+ assert node1._has_cycle()
+
+ def test_str_with_cycle(self) -> None:
+ node1 = DoublyListNode(1)
+ node2 = DoublyListNode(2)
+ node1.next = node2
+ node2.prev = node1
+ node2.next = node1 # Create cycle
+
+ result = str(node1)
+ assert "<-> ... (cycle back to 1)" in result
+
+ def test_equality_with_cycles(self) -> None:
+ node1 = DoublyListNode(1)
+ node2 = DoublyListNode(2)
+ node1.next = node2
+ node2.prev = node1
+ node2.next = node1 # Create cycle
+
+ node3 = DoublyListNode(1)
+ node4 = DoublyListNode(2)
+ node3.next = node4
+ node4.prev = node3
+ node4.next = node3 # Create cycle
+
+ assert node1 != node3
+
+ linear_node = DoublyListNode.from_list([1, 2])
+ assert node1 != linear_node
+
+ def test_to_list_with_cycle(self) -> None:
+ node1 = DoublyListNode(1)
+ node2 = DoublyListNode(2)
+ node1.next = node2
+ node2.prev = node1
+ node2.next = node1 # Create cycle
+
+ result = node1.to_list()
+ assert result == [1, 2]
+
+ def test_str_long_list(self) -> None:
+ long_list = list(range(1001))
+ node = DoublyListNode.from_list(long_list)
+ assert node is not None
+ result = str(node)
+ assert "<-> ... (long list)" in result
+
+ def test_repr_html_no_graphviz(self, monkeypatch) -> None:
+ node = DoublyListNode.from_list([1, 2, 3])
+ assert node is not None
+
+ def mock_import(name, *args):
+ if name == "graphviz":
+ raise ImportError("No module named 'graphviz'")
+ return __import__(name, *args)
+
+ monkeypatch.setattr("builtins.__import__", mock_import)
+ result = node._repr_html_()
+ assert "" in result
+ assert "1 <-> 2 <-> 3" in result
+
+ def test_repr_html_with_graphviz(self) -> None:
+ node = DoublyListNode.from_list([1, 2])
+ assert node is not None
+
+ try:
+ import importlib.util
+
+ if importlib.util.find_spec("graphviz") is None:
+ pytest.skip("graphviz not available")
+
+ result = node._repr_html_()
+ assert isinstance(result, str)
+ except ImportError:
+ pytest.skip("graphviz not available")
+
+ def test_repr_html_with_cycle(self) -> None:
+ node1 = DoublyListNode(1)
+ node2 = DoublyListNode(2)
+ node1.next = node2
+ node2.prev = node1
+ node2.next = node1 # Create cycle
+
+ try:
+ import importlib.util
+
+ if importlib.util.find_spec("graphviz") is None:
+ pytest.skip("graphviz not available")
+
+ result = node1._repr_html_()
+ assert isinstance(result, str)
+ except ImportError:
+ pytest.skip("graphviz not available")