diff --git a/.templates/leetcode/json/binary_tree_right_side_view.json b/.templates/leetcode/json/binary_tree_right_side_view.json new file mode 100644 index 0000000..a5165db --- /dev/null +++ b/.templates/leetcode/json/binary_tree_right_side_view.json @@ -0,0 +1,49 @@ +{ + "problem_name": "binary_tree_right_side_view", + "solution_class_name": "Solution", + "problem_number": "199", + "problem_title": "Binary Tree Right Side View", + "difficulty": "Medium", + "topics": "Tree, Depth-First Search, Breadth-First Search, Binary Tree", + "tags": ["grind-75"], + "readme_description": "Given the `root` of a binary tree, imagine yourself standing on the **right side** of it, return *the values of the nodes you can see ordered from top to bottom*.", + "readme_examples": [ + { + "content": "![Example 1](https://assets.leetcode.com/uploads/2024/11/24/tmpd5jn43fs-1.png)\n\n```\nInput: root = [1,2,3,null,5,null,4]\nOutput: [1,3,4]\n```" + }, + { + "content": "![Example 2](https://assets.leetcode.com/uploads/2024/11/24/tmpkpe40xeh-1.png)\n\n```\nInput: root = [1,2,3,4,null,null,null,5]\nOutput: [1,3,4,5]\n```" + }, + { "content": "```\nInput: root = [1,null,3]\nOutput: [1,3]\n```" }, + { "content": "```\nInput: root = []\nOutput: []\n```" } + ], + "readme_constraints": "- The number of nodes in the tree is in the range `[0, 100]`.\n- `-100 <= Node.val <= 100`", + "readme_additional": "", + "solution_imports": "from leetcode_py import TreeNode", + "solution_methods": [ + { + "name": "right_side_view", + "parameters": "root: TreeNode[int] | None", + "return_type": "list[int]", + "dummy_return": "[]" + } + ], + "test_imports": "import pytest\nfrom leetcode_py.test_utils import logged_test\nfrom leetcode_py import TreeNode\nfrom .solution import Solution", + "test_class_name": "BinaryTreeRightSideView", + "test_helper_methods": [ + { "name": "setup_method", "parameters": "", "body": "self.solution = Solution()" } + ], + "test_methods": [ + { + "name": "test_right_side_view", + "parametrize": "root_list, expected", + "parametrize_typed": "root_list: list[int | None], expected: list[int]", + "test_cases": "[([1, 2, 3, None, 5, None, 4], [1, 3, 4]), ([1, 2, 3, 4, None, None, None, 5], [1, 3, 4, 5]), ([1, None, 3], [1, 3]), ([], [])]", + "body": "root = TreeNode.from_list(root_list)\nresult = self.solution.right_side_view(root)\nassert result == expected" + } + ], + "playground_imports": "from solution import Solution\nfrom leetcode_py import TreeNode", + "playground_test_case": "# Example test case\nroot_list: list[int | None] = [1, 2, 3, None, 5, None, 4]\nexpected = [1, 3, 4]", + "playground_execution": "root = TreeNode.from_list(root_list)\nresult = Solution().right_side_view(root)\nresult", + "playground_assertion": "assert result == expected" +} diff --git a/.templates/leetcode/json/clone_graph.json b/.templates/leetcode/json/clone_graph.json new file mode 100644 index 0000000..2e338d6 --- /dev/null +++ b/.templates/leetcode/json/clone_graph.json @@ -0,0 +1,50 @@ +{ + "problem_name": "clone_graph", + "solution_class_name": "Solution", + "problem_number": "133", + "problem_title": "Clone Graph", + "difficulty": "Medium", + "topics": "Hash Table, Depth-First Search, Breadth-First Search, Graph", + "tags": ["grind-75"], + "readme_description": "Given a reference of a node in a **connected** undirected graph.\n\nReturn a **deep copy** (clone) of the graph.\n\nEach node in the graph contains a value (`int`) and a list (`List[Node]`) of its neighbors.\n\n```\nclass Node {\n public int val;\n public List neighbors;\n}\n```\n\n**Test case format:**\n\nFor simplicity, each node's value is the same as the node's index (1-indexed). For example, the first node with `val == 1`, the second node with `val == 2`, and so on. The graph is represented in the test case using an adjacency list.\n\n**An adjacency list** is a collection of unordered **lists** used to represent a finite graph. Each list describes the set of neighbors of a node in the graph.\n\nThe given node will always be the first node with `val = 1`. You must return the **copy of the given node** as a reference to the cloned graph.", + "readme_examples": [ + { + "content": "\"\"\n\n```\nInput: adjList = [[2,4],[1,3],[2,4],[1,3]]\nOutput: [[2,4],[1,3],[2,4],[1,3]]\nExplanation: There are 4 nodes in the graph.\n1st node (val = 1)'s neighbors are 2nd node (val = 2) and 4th node (val = 4).\n2nd node (val = 2)'s neighbors are 1st node (val = 1) and 3rd node (val = 3).\n3rd node (val = 3)'s neighbors are 2nd node (val = 2) and 4th node (val = 4).\n4th node (val = 4)'s neighbors are 1st node (val = 1) and 3rd node (val = 3).\n```" + }, + { + "content": "\"\"\n\n```\nInput: adjList = [[]]\nOutput: [[]]\nExplanation: Note that the input contains one empty list. The graph consists of only one node with val = 1 and it does not have any neighbors.\n```" + }, + { + "content": "```\nInput: adjList = []\nOutput: []\nExplanation: This an empty graph, it does not have any nodes.\n```" + } + ], + "readme_constraints": "- The number of nodes in the graph is in the range `[0, 100]`.\n- `1 <= Node.val <= 100`\n- `Node.val` is unique for each node.\n- There are no repeated edges and no self-loops in the graph.\n- The Graph is connected and all nodes can be visited starting from the given node.", + "readme_additional": "", + "solution_imports": "from leetcode_py import GraphNode", + "solution_methods": [ + { + "name": "clone_graph", + "parameters": "node: GraphNode | None", + "return_type": "GraphNode | None", + "dummy_return": "None" + } + ], + "test_imports": "import pytest\n\nfrom leetcode_py import GraphNode\nfrom leetcode_py.test_utils import logged_test\n\nfrom .solution import Solution", + "test_class_name": "CloneGraph", + "test_helper_methods": [ + { "name": "setup_method", "parameters": "", "body": "self.solution = Solution()" } + ], + "test_methods": [ + { + "name": "test_clone_graph", + "parametrize": "adj_list", + "parametrize_typed": "adj_list: list[list[int]]", + "test_cases": "[[[2, 4], [1, 3], [2, 4], [1, 3]], [[]], []]", + "body": "node = GraphNode.from_adjacency_list(adj_list)\nresult = self.solution.clone_graph(node)\nassert result.is_clone(node) if result else node is None" + } + ], + "playground_imports": "from solution import Solution\n\nfrom leetcode_py import GraphNode", + "playground_test_case": "# Example test case\nadj_list = [[2,4],[1,3],[2,4],[1,3]]\nnode = GraphNode.from_adjacency_list(adj_list)", + "playground_execution": "result = Solution().clone_graph(node)\nresult", + "playground_assertion": "assert result.is_clone(node) if result else node is None" +} diff --git a/.templates/leetcode/json/evaluate_reverse_polish_notation.json b/.templates/leetcode/json/evaluate_reverse_polish_notation.json new file mode 100644 index 0000000..08e7061 --- /dev/null +++ b/.templates/leetcode/json/evaluate_reverse_polish_notation.json @@ -0,0 +1,50 @@ +{ + "problem_name": "evaluate_reverse_polish_notation", + "solution_class_name": "Solution", + "problem_number": "150", + "problem_title": "Evaluate Reverse Polish Notation", + "difficulty": "Medium", + "topics": "Array, Math, Stack", + "tags": ["grind-75"], + "readme_description": "You are given an array of strings `tokens` that represents an arithmetic expression in a **Reverse Polish Notation**.\n\nEvaluate the expression. Return *an integer that represents the value of the expression*.\n\n**Note that:**\n\n- The valid operators are `'+'`, `'-'`, `'*'`, and `'/'`.\n- Each operand may be an integer or another expression.\n- The division between two integers always **truncates toward zero**.\n- There will not be any division by zero.\n- The input represents a valid arithmetic expression in a reverse polish notation.\n- The answer and all the intermediate calculations can be represented in a **32-bit** integer.", + "readme_examples": [ + { + "content": "```\nInput: tokens = [\"2\",\"1\",\"+\",\"3\",\"*\"]\nOutput: 9\n```\n**Explanation:** ((2 + 1) * 3) = 9" + }, + { + "content": "```\nInput: tokens = [\"4\",\"13\",\"5\",\"/\",\"+\"]\nOutput: 6\n```\n**Explanation:** (4 + (13 / 5)) = 6" + }, + { + "content": "```\nInput: tokens = [\"10\",\"6\",\"9\",\"3\",\"+\",\"-11\",\"*\",\"/\",\"*\",\"17\",\"+\",\"5\",\"+\"]\nOutput: 22\n```\n**Explanation:** ((10 * (6 / ((9 + 3) * -11))) + 17) + 5 = 22" + } + ], + "readme_constraints": "- `1 <= tokens.length <= 10^4`\n- `tokens[i]` is either an operator: `\"+\"`, `\"-\"`, `\"*\"`, or `\"/\"`, or an integer in the range `[-200, 200]`.", + "readme_additional": "", + "solution_imports": "", + "solution_methods": [ + { + "name": "eval_rpn", + "parameters": "tokens: list[str]", + "return_type": "int", + "dummy_return": "0" + } + ], + "test_imports": "import pytest\nfrom leetcode_py.test_utils import logged_test\nfrom .solution import Solution", + "test_class_name": "EvaluateReversePolishNotation", + "test_helper_methods": [ + { "name": "setup_method", "parameters": "", "body": "self.solution = Solution()" } + ], + "test_methods": [ + { + "name": "test_eval_rpn", + "parametrize": "tokens, expected", + "parametrize_typed": "tokens: list[str], expected: int", + "test_cases": "[([\"2\", \"1\", \"+\", \"3\", \"*\"], 9), ([\"4\", \"13\", \"5\", \"/\", \"+\"], 6), ([\"10\", \"6\", \"9\", \"3\", \"+\", \"-11\", \"*\", \"/\", \"*\", \"17\", \"+\", \"5\", \"+\"], 22)]", + "body": "result = self.solution.eval_rpn(tokens)\nassert result == expected" + } + ], + "playground_imports": "from solution import Solution", + "playground_test_case": "# Example test case\ntokens = [\\\"2\\\", \\\"1\\\", \\\"+\\\", \\\"3\\\", \\\"*\\\"]\nexpected = 9", + "playground_execution": "result = Solution().eval_rpn(tokens)\nresult", + "playground_assertion": "assert result == expected" +} diff --git a/.templates/leetcode/json/minimum_height_trees.json b/.templates/leetcode/json/minimum_height_trees.json new file mode 100644 index 0000000..86669a2 --- /dev/null +++ b/.templates/leetcode/json/minimum_height_trees.json @@ -0,0 +1,47 @@ +{ + "problem_name": "minimum_height_trees", + "solution_class_name": "Solution", + "problem_number": "310", + "problem_title": "Minimum Height Trees", + "difficulty": "Medium", + "topics": "Depth-First Search, Breadth-First Search, Graph, Topological Sort", + "tags": ["grind-75"], + "readme_description": "A tree is an undirected graph in which any two vertices are connected by *exactly* one path. In other words, any connected graph without simple cycles is a tree.\n\nGiven a tree of `n` nodes labelled from `0` to `n - 1`, and an array of `n - 1` `edges` where `edges[i] = [ai, bi]` indicates that there is an undirected edge between the two nodes `ai` and `bi` in the tree, you can choose any node of the tree as the root. When you select a node `x` as the root, the result tree has height `h`. Among all possible rooted trees, those with minimum height (i.e. `min(h)`) are called **minimum height trees** (MHTs).\n\nReturn *a list of all **MHTs'** root labels*. You can return the answer in **any order**.\n\nThe **height** of a rooted tree is the number of edges on the longest downward path between the root and a leaf.", + "readme_examples": [ + { + "content": "\"\"\n\n```\nInput: n = 4, edges = [[1,0],[1,2],[1,3]]\nOutput: [1]\nExplanation: As shown, the height of the tree is 1 when the root is the node with label 1 which is the only MHT.\n```" + }, + { + "content": "\"\"\n\n```\nInput: n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]\nOutput: [3,4]\n```" + } + ], + "readme_constraints": "- `1 <= n <= 2 * 10^4`\n- `edges.length == n - 1`\n- `0 <= ai, bi < n`\n- `ai != bi`\n- All the pairs `(ai, bi)` are distinct.\n- The given input is **guaranteed** to be a tree and there will be **no repeated** edges.", + "readme_additional": "", + "solution_imports": "", + "solution_methods": [ + { + "name": "find_min_height_trees", + "parameters": "n: int, edges: list[list[int]]", + "return_type": "list[int]", + "dummy_return": "[]" + } + ], + "test_imports": "import pytest\nfrom leetcode_py.test_utils import logged_test\nfrom .solution import Solution", + "test_class_name": "MinimumHeightTrees", + "test_helper_methods": [ + { "name": "setup_method", "parameters": "", "body": "self.solution = Solution()" } + ], + "test_methods": [ + { + "name": "test_find_min_height_trees", + "parametrize": "n, edges, expected", + "parametrize_typed": "n: int, edges: list[list[int]], expected: list[int]", + "test_cases": "[(4, [[1,0],[1,2],[1,3]], [1]), (6, [[3,0],[3,1],[3,2],[3,4],[5,4]], [3,4]), (1, [], [0])]", + "body": "result = self.solution.find_min_height_trees(n, edges)\nassert sorted(result) == sorted(expected)" + } + ], + "playground_imports": "from solution import Solution", + "playground_test_case": "# Example test case\nn = 4\nedges = [[1,0],[1,2],[1,3]]\nexpected = [1]", + "playground_execution": "result = Solution().find_min_height_trees(n, edges)\nresult", + "playground_assertion": "assert sorted(result) == sorted(expected)" +} diff --git a/.templates/leetcode/json/task_scheduler.json b/.templates/leetcode/json/task_scheduler.json new file mode 100644 index 0000000..aa7481b --- /dev/null +++ b/.templates/leetcode/json/task_scheduler.json @@ -0,0 +1,50 @@ +{ + "problem_name": "task_scheduler", + "solution_class_name": "Solution", + "problem_number": "621", + "problem_title": "Task Scheduler", + "difficulty": "Medium", + "topics": "Array, Hash Table, Greedy, Sorting, Heap (Priority Queue), Counting", + "tags": ["grind-75"], + "readme_description": "You are given an array of CPU `tasks`, each labeled with a letter from A to Z, and a number `n`. Each CPU interval can be idle or allow the completion of one task. Tasks can be completed in any order, but there's a constraint: there has to be a gap of **at least** `n` intervals between two tasks with the same label.\n\nReturn the **minimum** number of CPU intervals required to complete all tasks.", + "readme_examples": [ + { + "content": "```\nInput: tasks = [\"A\",\"A\",\"A\",\"B\",\"B\",\"B\"], n = 2\nOutput: 8\n```\n**Explanation:** A possible sequence is: A -> B -> idle -> A -> B -> idle -> A -> B.\n\nAfter completing task A, you must wait two intervals before doing A again. The same applies to task B. In the 3rd interval, neither A nor B can be done, so you idle. By the 4th interval, you can do A again as 2 intervals have passed." + }, + { + "content": "```\nInput: tasks = [\"A\",\"C\",\"A\",\"B\",\"D\",\"B\"], n = 1\nOutput: 6\n```\n**Explanation:** A possible sequence is: A -> B -> C -> D -> A -> B.\n\nWith a cooling interval of 1, you can repeat a task after just one other task." + }, + { + "content": "```\nInput: tasks = [\"A\",\"A\",\"A\", \"B\",\"B\",\"B\"], n = 3\nOutput: 10\n```\n**Explanation:** A possible sequence is: A -> B -> idle -> idle -> A -> B -> idle -> idle -> A -> B.\n\nThere are only two types of tasks, A and B, which need to be separated by 3 intervals. This leads to idling twice between repetitions of these tasks." + } + ], + "readme_constraints": "- `1 <= tasks.length <= 10^4`\n- `tasks[i]` is an uppercase English letter.\n- `0 <= n <= 100`", + "readme_additional": "", + "solution_imports": "", + "solution_methods": [ + { + "name": "least_interval", + "parameters": "tasks: list[str], n: int", + "return_type": "int", + "dummy_return": "0" + } + ], + "test_imports": "import pytest\nfrom leetcode_py.test_utils import logged_test\nfrom .solution import Solution", + "test_class_name": "TaskScheduler", + "test_helper_methods": [ + { "name": "setup_method", "parameters": "", "body": "self.solution = Solution()" } + ], + "test_methods": [ + { + "name": "test_least_interval", + "parametrize": "tasks, n, expected", + "parametrize_typed": "tasks: list[str], n: int, expected: int", + "test_cases": "[([\"A\", \"A\", \"A\", \"B\", \"B\", \"B\"], 2, 8), ([\"A\", \"C\", \"A\", \"B\", \"D\", \"B\"], 1, 6), ([\"A\", \"A\", \"A\", \"B\", \"B\", \"B\"], 3, 10)]", + "body": "result = self.solution.least_interval(tasks, n)\nassert result == expected" + } + ], + "playground_imports": "from solution import Solution", + "playground_test_case": "# Example test case\ntasks = [\\\"A\\\", \\\"A\\\", \\\"A\\\", \\\"B\\\", \\\"B\\\", \\\"B\\\"]\nn = 2\nexpected = 8", + "playground_execution": "result = Solution().least_interval(tasks, n)\nresult", + "playground_assertion": "assert result == expected" +} diff --git a/.templates/leetcode/json/validate_binary_search_tree.json b/.templates/leetcode/json/validate_binary_search_tree.json new file mode 100644 index 0000000..fd834b8 --- /dev/null +++ b/.templates/leetcode/json/validate_binary_search_tree.json @@ -0,0 +1,47 @@ +{ + "problem_name": "validate_binary_search_tree", + "solution_class_name": "Solution", + "problem_number": "98", + "problem_title": "Validate Binary Search Tree", + "difficulty": "Medium", + "topics": "Tree, Depth-First Search, Binary Search Tree, Binary Tree", + "tags": ["grind-75"], + "readme_description": "Given the `root` of a binary tree, determine if it is a valid binary search tree (BST).\n\nA **valid BST** is defined as follows:\n\n- The left subtree of a node contains only nodes with keys **strictly less than** the node's key.\n- The right subtree of a node contains only nodes with keys **strictly greater than** the node's key.\n- Both the left and right subtrees must also be binary search trees.", + "readme_examples": [ + { + "content": "![Example 1](https://assets.leetcode.com/uploads/2020/12/01/tree1.jpg)\n\n```\nInput: root = [2,1,3]\nOutput: true\n```" + }, + { + "content": "![Example 2](https://assets.leetcode.com/uploads/2020/12/01/tree2.jpg)\n\n```\nInput: root = [5,1,4,null,null,3,6]\nOutput: false\n```\n**Explanation:** The root node's value is 5 but its right child's value is 4." + } + ], + "readme_constraints": "- The number of nodes in the tree is in the range `[1, 10^4]`.\n- `-2^31 <= Node.val <= 2^31 - 1`", + "readme_additional": "", + "solution_imports": "from leetcode_py import TreeNode", + "solution_methods": [ + { + "name": "is_valid_bst", + "parameters": "root: TreeNode[int] | None", + "return_type": "bool", + "dummy_return": "False" + } + ], + "test_imports": "import pytest\nfrom leetcode_py.test_utils import logged_test\nfrom leetcode_py import TreeNode\nfrom .solution import Solution", + "test_class_name": "ValidateBinarySearchTree", + "test_helper_methods": [ + { "name": "setup_method", "parameters": "", "body": "self.solution = Solution()" } + ], + "test_methods": [ + { + "name": "test_is_valid_bst", + "parametrize": "root_list, expected", + "parametrize_typed": "root_list: list[int | None], expected: bool", + "test_cases": "[([2, 1, 3], True), ([5, 1, 4, None, None, 3, 6], False), ([2, 1, 3], True), ([1], True), ([1, 1], False)]", + "body": "root = TreeNode.from_list(root_list)\nresult = self.solution.is_valid_bst(root)\nassert result == expected" + } + ], + "playground_imports": "from solution import Solution\nfrom leetcode_py import TreeNode", + "playground_test_case": "# Example test case\nroot_list: list[int | None] = [2, 1, 3]\nexpected = True", + "playground_execution": "root = TreeNode.from_list(root_list)\nresult = Solution().is_valid_bst(root)\nresult", + "playground_assertion": "assert result == expected" +} diff --git a/Makefile b/Makefile index e9b4dc7..91a7d24 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ PYTHON_VERSION = 3.13 -PROBLEM ?= invert_binary_tree +PROBLEM ?= task_scheduler FORCE ?= 0 sync_submodules: diff --git a/leetcode/binary_tree_right_side_view/README.md b/leetcode/binary_tree_right_side_view/README.md new file mode 100644 index 0000000..b44249a --- /dev/null +++ b/leetcode/binary_tree_right_side_view/README.md @@ -0,0 +1,50 @@ +# Binary Tree Right Side View + +**Difficulty:** Medium +**Topics:** Tree, Depth-First Search, Breadth-First Search, Binary Tree +**Tags:** grind-75 + +**LeetCode:** [Problem 199](https://leetcode.com/problems/binary-tree-right-side-view/description/) + +## Problem Description + +Given the `root` of a binary tree, imagine yourself standing on the **right side** of it, return _the values of the nodes you can see ordered from top to bottom_. + +## Examples + +### Example 1: + +![Example 1](https://assets.leetcode.com/uploads/2024/11/24/tmpd5jn43fs-1.png) + +``` +Input: root = [1,2,3,null,5,null,4] +Output: [1,3,4] +``` + +### Example 2: + +![Example 2](https://assets.leetcode.com/uploads/2024/11/24/tmpkpe40xeh-1.png) + +``` +Input: root = [1,2,3,4,null,null,null,5] +Output: [1,3,4,5] +``` + +### Example 3: + +``` +Input: root = [1,null,3] +Output: [1,3] +``` + +### Example 4: + +``` +Input: root = [] +Output: [] +``` + +## Constraints + +- The number of nodes in the tree is in the range `[0, 100]`. +- `-100 <= Node.val <= 100` diff --git a/leetcode/binary_tree_right_side_view/__init__.py b/leetcode/binary_tree_right_side_view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode/binary_tree_right_side_view/playground.ipynb b/leetcode/binary_tree_right_side_view/playground.ipynb new file mode 100644 index 0000000..1deced0 --- /dev/null +++ b/leetcode/binary_tree_right_side_view/playground.ipynb @@ -0,0 +1,82 @@ +{ + "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: list[int | None] = [1, 2, 3, None, 5, None, 4]\n", + "expected = [1, 3, 4]" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "execute", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[1, 3, 4]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "root = TreeNode.from_list(root_list)\n", + "result = Solution().right_side_view(root)\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/binary_tree_right_side_view/solution.py b/leetcode/binary_tree_right_side_view/solution.py new file mode 100644 index 0000000..841c2c8 --- /dev/null +++ b/leetcode/binary_tree_right_side_view/solution.py @@ -0,0 +1,67 @@ +from collections import deque + +from leetcode_py import TreeNode + + +class Solution: + # Time: O(n) + # Space: O(h) + def right_side_view(self, root: TreeNode[int] | None) -> list[int]: + result: list[int] = [] + + def dfs(node: TreeNode[int] | None, level: int) -> None: + if not node: + return + if level == len(result): + result.append(node.val) + dfs(node.right, level + 1) + dfs(node.left, level + 1) + + dfs(root, 0) + return result + + +class SolutionDFS: + # Time: O(n) + # Space: O(h) + def right_side_view(self, root: TreeNode[int] | None) -> list[int]: + if not root: + return [] + + result: list[int] = [] + stack = [(root, 0)] + + while stack: + node, level = stack.pop() + if level == len(result): + result.append(node.val) + if node.left: + stack.append((node.left, level + 1)) + if node.right: + stack.append((node.right, level + 1)) + + return result + + +class SolutionBFS: + # Time: O(n) + # Space: O(w) + def right_side_view(self, root: TreeNode[int] | None) -> list[int]: + if not root: + return [] + + result: list[int] = [] + queue = deque([root]) + + while queue: + level_size = len(queue) + for i in range(level_size): + node = queue.popleft() + if i == level_size - 1: # rightmost node + result.append(node.val) + if node.left: + queue.append(node.left) + if node.right: + queue.append(node.right) + + return result diff --git a/leetcode/binary_tree_right_side_view/tests.py b/leetcode/binary_tree_right_side_view/tests.py new file mode 100644 index 0000000..acfd3ad --- /dev/null +++ b/leetcode/binary_tree_right_side_view/tests.py @@ -0,0 +1,30 @@ +import pytest + +from leetcode_py import TreeNode +from leetcode_py.test_utils import logged_test + +from .solution import Solution, SolutionBFS, SolutionDFS + + +class TestBinaryTreeRightSideView: + @pytest.mark.parametrize("solution_class", [Solution, SolutionDFS, SolutionBFS]) + @pytest.mark.parametrize( + "root_list, expected", + [ + ([1, 2, 3, None, 5, None, 4], [1, 3, 4]), + ([1, 2, 3, 4, None, None, None, 5], [1, 3, 4, 5]), + ([1, None, 3], [1, 3]), + ([], []), + ], + ) + @logged_test + def test_right_side_view( + self, + root_list: list[int | None], + expected: list[int], + solution_class: type[Solution | SolutionDFS | SolutionBFS], + ): + solution = solution_class() + root = TreeNode.from_list(root_list) + result = solution.right_side_view(root) + assert result == expected diff --git a/leetcode/clone_graph/README.md b/leetcode/clone_graph/README.md new file mode 100644 index 0000000..eeda5d5 --- /dev/null +++ b/leetcode/clone_graph/README.md @@ -0,0 +1,72 @@ +# Clone Graph + +**Difficulty:** Medium +**Topics:** Hash Table, Depth-First Search, Breadth-First Search, Graph +**Tags:** grind-75 + +**LeetCode:** [Problem 133](https://leetcode.com/problems/clone-graph/description/) + +## Problem Description + +Given a reference of a node in a **connected** undirected graph. + +Return a **deep copy** (clone) of the graph. + +Each node in the graph contains a value (`int`) and a list (`List[Node]`) of its neighbors. + +``` +class Node { + public int val; + public List neighbors; +} +``` + +**Test case format:** + +For simplicity, each node's value is the same as the node's index (1-indexed). For example, the first node with `val == 1`, the second node with `val == 2`, and so on. The graph is represented in the test case using an adjacency list. + +**An adjacency list** is a collection of unordered **lists** used to represent a finite graph. Each list describes the set of neighbors of a node in the graph. + +The given node will always be the first node with `val = 1`. You must return the **copy of the given node** as a reference to the cloned graph. + +## Examples + +### Example 1: + + + +``` +Input: adjList = [[2,4],[1,3],[2,4],[1,3]] +Output: [[2,4],[1,3],[2,4],[1,3]] +Explanation: There are 4 nodes in the graph. +1st node (val = 1)'s neighbors are 2nd node (val = 2) and 4th node (val = 4). +2nd node (val = 2)'s neighbors are 1st node (val = 1) and 3rd node (val = 3). +3rd node (val = 3)'s neighbors are 2nd node (val = 2) and 4th node (val = 4). +4th node (val = 4)'s neighbors are 1st node (val = 1) and 3rd node (val = 3). +``` + +### Example 2: + + + +``` +Input: adjList = [[]] +Output: [[]] +Explanation: Note that the input contains one empty list. The graph consists of only one node with val = 1 and it does not have any neighbors. +``` + +### Example 3: + +``` +Input: adjList = [] +Output: [] +Explanation: This an empty graph, it does not have any nodes. +``` + +## Constraints + +- The number of nodes in the graph is in the range `[0, 100]`. +- `1 <= Node.val <= 100` +- `Node.val` is unique for each node. +- There are no repeated edges and no self-loops in the graph. +- The Graph is connected and all nodes can be visited starting from the given node. diff --git a/leetcode/clone_graph/__init__.py b/leetcode/clone_graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode/clone_graph/playground.ipynb b/leetcode/clone_graph/playground.ipynb new file mode 100644 index 0000000..0be0e9e --- /dev/null +++ b/leetcode/clone_graph/playground.ipynb @@ -0,0 +1,209 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from solution import Solution\n", + "\n", + "from leetcode_py import GraphNode" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "setup", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "1\n", + "\n", + "1\n", + "\n", + "\n", + "\n", + "2\n", + "\n", + "2\n", + "\n", + "\n", + "\n", + "1--2\n", + "\n", + "\n", + "\n", + "\n", + "4\n", + "\n", + "4\n", + "\n", + "\n", + "\n", + "1--4\n", + "\n", + "\n", + "\n", + "\n", + "3\n", + "\n", + "3\n", + "\n", + "\n", + "\n", + "2--3\n", + "\n", + "\n", + "\n", + "\n", + "3--4\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "GraphNode({1: [2, 4], 2: [1, 3], 3: [2, 4], 4: [1, 3]})" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Example test case\n", + "adj_list = [[2, 4], [1, 3], [2, 4], [1, 3]]\n", + "node = GraphNode.from_adjacency_list(adj_list)\n", + "node" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "execute", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "1\n", + "\n", + "1\n", + "\n", + "\n", + "\n", + "2\n", + "\n", + "2\n", + "\n", + "\n", + "\n", + "1--2\n", + "\n", + "\n", + "\n", + "\n", + "4\n", + "\n", + "4\n", + "\n", + "\n", + "\n", + "1--4\n", + "\n", + "\n", + "\n", + "\n", + "3\n", + "\n", + "3\n", + "\n", + "\n", + "\n", + "2--3\n", + "\n", + "\n", + "\n", + "\n", + "3--4\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "GraphNode({1: [2, 4], 2: [1, 3], 3: [2, 4], 4: [1, 3]})" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = Solution().clone_graph(node)\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "test", + "metadata": {}, + "outputs": [], + "source": [ + "assert result.is_clone(node) if result else node is None" + ] + } + ], + "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/clone_graph/solution.py b/leetcode/clone_graph/solution.py new file mode 100644 index 0000000..bd53c0b --- /dev/null +++ b/leetcode/clone_graph/solution.py @@ -0,0 +1,73 @@ +from collections import deque + +from leetcode_py import GraphNode + + +class Solution: + # Time: O(V + E) + # Space: O(V) + def clone_graph(self, node: GraphNode | None) -> GraphNode | None: + if node is None: + return None + + def dfs(node: GraphNode, visited: dict[int, GraphNode]): + if node.val in visited: + return visited[node.val] + + clone = GraphNode(node.val) + visited[node.val] = clone + + for neighbor in node.neighbors: + clone.neighbors.append(dfs(neighbor, visited)) + + return clone + + return dfs(node, visited={}) + + +class SolutionDFS: + # DFS Iterative + # Time: O(V + E) + # Space: O(V) + def clone_graph(self, node: GraphNode | None) -> GraphNode | None: + if node is None: + return None + + stack = [node] + visited = {node.val: GraphNode(node.val)} + + while stack: + current = stack.pop() + clone = visited[current.val] + + for neighbor in current.neighbors: + if neighbor.val not in visited: + visited[neighbor.val] = GraphNode(neighbor.val) + stack.append(neighbor) + clone.neighbors.append(visited[neighbor.val]) + + return visited[node.val] + + +class SolutionBFS: + # BFS + # Time: O(V + E) + # Space: O(V) + def clone_graph(self, node: GraphNode | None) -> GraphNode | None: + if node is None: + return None + + queue = deque([node]) + visited = {node.val: GraphNode(node.val)} + + while queue: + current = queue.popleft() + clone = visited[current.val] + + for neighbor in current.neighbors: + if neighbor.val not in visited: + visited[neighbor.val] = GraphNode(neighbor.val) + queue.append(neighbor) + clone.neighbors.append(visited[neighbor.val]) + + return visited[node.val] diff --git a/leetcode/clone_graph/tests.py b/leetcode/clone_graph/tests.py new file mode 100644 index 0000000..fa0a211 --- /dev/null +++ b/leetcode/clone_graph/tests.py @@ -0,0 +1,25 @@ +import pytest + +from leetcode_py import GraphNode +from leetcode_py.test_utils import logged_test + +from .solution import Solution, SolutionBFS, SolutionDFS + + +class TestCloneGraph: + @pytest.mark.parametrize("solution_class", [Solution, SolutionDFS, SolutionBFS]) + @pytest.mark.parametrize( + "adj_list", + [[[2, 4], [1, 3], [2, 4], [1, 3]], [[]], []], + ) + @logged_test + def test_clone_graph( + self, + adj_list: list[list[int]], + solution_class: type[Solution | SolutionDFS | SolutionBFS], + ): + solution = solution_class() + node = GraphNode.from_adjacency_list(adj_list) + result = solution.clone_graph(node) + + assert result.is_clone(node) if result else node is None diff --git a/leetcode/container_with_most_water/playground.ipynb b/leetcode/container_with_most_water/playground.ipynb index 48e3823..539853a 100644 --- a/leetcode/container_with_most_water/playground.ipynb +++ b/leetcode/container_with_most_water/playground.ipynb @@ -29,7 +29,8 @@ "metadata": {}, "outputs": [], "source": [ - "result = Solution().max_area(height)\nresult" + "result = Solution().max_area(height)\n", + "result" ] }, { diff --git a/leetcode/evaluate_reverse_polish_notation/README.md b/leetcode/evaluate_reverse_polish_notation/README.md new file mode 100644 index 0000000..4015f66 --- /dev/null +++ b/leetcode/evaluate_reverse_polish_notation/README.md @@ -0,0 +1,56 @@ +# Evaluate Reverse Polish Notation + +**Difficulty:** Medium +**Topics:** Array, Math, Stack +**Tags:** grind-75 + +**LeetCode:** [Problem 150](https://leetcode.com/problems/evaluate-reverse-polish-notation/description/) + +## Problem Description + +You are given an array of strings `tokens` that represents an arithmetic expression in a **Reverse Polish Notation**. + +Evaluate the expression. Return _an integer that represents the value of the expression_. + +**Note that:** + +- The valid operators are `'+'`, `'-'`, `'*'`, and `'/'`. +- Each operand may be an integer or another expression. +- The division between two integers always **truncates toward zero**. +- There will not be any division by zero. +- The input represents a valid arithmetic expression in a reverse polish notation. +- The answer and all the intermediate calculations can be represented in a **32-bit** integer. + +## Examples + +### Example 1: + +``` +Input: tokens = ["2","1","+","3","*"] +Output: 9 +``` + +**Explanation:** ((2 + 1) \* 3) = 9 + +### Example 2: + +``` +Input: tokens = ["4","13","5","/","+"] +Output: 6 +``` + +**Explanation:** (4 + (13 / 5)) = 6 + +### Example 3: + +``` +Input: tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] +Output: 22 +``` + +**Explanation:** ((10 _ (6 / ((9 + 3) _ -11))) + 17) + 5 = 22 + +## Constraints + +- `1 <= tokens.length <= 10^4` +- `tokens[i]` is either an operator: `"+"`, `"-"`, `"*"`, or `"/"`, or an integer in the range `[-200, 200]`. diff --git a/leetcode/evaluate_reverse_polish_notation/__init__.py b/leetcode/evaluate_reverse_polish_notation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode/evaluate_reverse_polish_notation/playground.ipynb b/leetcode/evaluate_reverse_polish_notation/playground.ipynb new file mode 100644 index 0000000..fc7ba18 --- /dev/null +++ b/leetcode/evaluate_reverse_polish_notation/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", + "tokens = [\"2\", \"1\", \"+\", \"3\", \"*\"]\n", + "expected = 9" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "execute", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "9" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = Solution().eval_rpn(tokens)\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/evaluate_reverse_polish_notation/solution.py b/leetcode/evaluate_reverse_polish_notation/solution.py new file mode 100644 index 0000000..b3b2ea1 --- /dev/null +++ b/leetcode/evaluate_reverse_polish_notation/solution.py @@ -0,0 +1,20 @@ +class Solution: + # Time: O(n) + # Space: O(n) + def eval_rpn(self, tokens: list[str]) -> int: + stack: list[int] = [] + ops = { + "+": lambda a, b: a + b, + "-": lambda a, b: a - b, + "*": lambda a, b: a * b, + "/": lambda a, b: int(a / b), + } + + for token in tokens: + if token in ops: + b, a = stack.pop(), stack.pop() + stack.append(ops[token](a, b)) + else: + stack.append(int(token)) + + return stack[0] diff --git a/leetcode/evaluate_reverse_polish_notation/tests.py b/leetcode/evaluate_reverse_polish_notation/tests.py new file mode 100644 index 0000000..82c126b --- /dev/null +++ b/leetcode/evaluate_reverse_polish_notation/tests.py @@ -0,0 +1,23 @@ +import pytest + +from leetcode_py.test_utils import logged_test + +from .solution import Solution + + +class TestEvaluateReversePolishNotation: + def setup_method(self): + self.solution = Solution() + + @pytest.mark.parametrize( + "tokens, expected", + [ + (["2", "1", "+", "3", "*"], 9), + (["4", "13", "5", "/", "+"], 6), + (["10", "6", "9", "3", "+", "-11", "*", "/", "*", "17", "+", "5", "+"], 22), + ], + ) + @logged_test + def test_eval_rpn(self, tokens: list[str], expected: int): + result = self.solution.eval_rpn(tokens) + assert result == expected diff --git a/leetcode/insert_interval/playground.ipynb b/leetcode/insert_interval/playground.ipynb index 783109a..921d04b 100644 --- a/leetcode/insert_interval/playground.ipynb +++ b/leetcode/insert_interval/playground.ipynb @@ -30,7 +30,8 @@ "metadata": {}, "outputs": [], "source": [ - "result = Solution().insert(intervals, new_interval)\nresult" + "result = Solution().insert(intervals, new_interval)\n", + "result" ] }, { diff --git a/leetcode/invert_binary_tree/playground.ipynb b/leetcode/invert_binary_tree/playground.ipynb index 452ecf4..c4a7c4c 100644 --- a/leetcode/invert_binary_tree/playground.ipynb +++ b/leetcode/invert_binary_tree/playground.ipynb @@ -6,7 +6,11 @@ "id": "imports", "metadata": {}, "outputs": [], - "source": ["from solution import Solution\n\nfrom leetcode_py import TreeNode"] + "source": [ + "from solution import Solution\n", + "\n", + "from leetcode_py import TreeNode" + ] }, { "cell_type": "code", @@ -14,7 +18,12 @@ "id": "setup", "metadata": {}, "outputs": [], - "source": ["# Example test case\nroot_list: list[int | None] = [4, 2, 7, 1, 3, 6, 9]\nroot = TreeNode[int].from_list(root_list)\nexpected = TreeNode[int].from_list([4, 7, 2, 9, 6, 3, 1])"] + "source": [ + "# Example test case\n", + "root_list: list[int | None] = [4, 2, 7, 1, 3, 6, 9]\n", + "root = TreeNode[int].from_list(root_list)\n", + "expected = TreeNode[int].from_list([4, 7, 2, 9, 6, 3, 1])" + ] }, { "cell_type": "code", @@ -22,7 +31,10 @@ "id": "execute", "metadata": {}, "outputs": [], - "source": ["result = Solution().invert_tree(root)\nresult"] + "source": [ + "result = Solution().invert_tree(root)\n", + "result" + ] }, { "cell_type": "code", @@ -30,7 +42,9 @@ "id": "test", "metadata": {}, "outputs": [], - "source": ["assert result == expected"] + "source": [ + "assert result == expected" + ] } ], "metadata": { diff --git a/leetcode/minimum_height_trees/README.md b/leetcode/minimum_height_trees/README.md new file mode 100644 index 0000000..15ad725 --- /dev/null +++ b/leetcode/minimum_height_trees/README.md @@ -0,0 +1,47 @@ +# Minimum Height Trees + +**Difficulty:** Medium +**Topics:** Depth-First Search, Breadth-First Search, Graph, Topological Sort +**Tags:** grind-75 + +**LeetCode:** [Problem 310](https://leetcode.com/problems/minimum-height-trees/description/) + +## Problem Description + +A tree is an undirected graph in which any two vertices are connected by _exactly_ one path. In other words, any connected graph without simple cycles is a tree. + +Given a tree of `n` nodes labelled from `0` to `n - 1`, and an array of `n - 1` `edges` where `edges[i] = [ai, bi]` indicates that there is an undirected edge between the two nodes `ai` and `bi` in the tree, you can choose any node of the tree as the root. When you select a node `x` as the root, the result tree has height `h`. Among all possible rooted trees, those with minimum height (i.e. `min(h)`) are called **minimum height trees** (MHTs). + +Return _a list of all **MHTs'** root labels_. You can return the answer in **any order**. + +The **height** of a rooted tree is the number of edges on the longest downward path between the root and a leaf. + +## Examples + +### Example 1: + + + +``` +Input: n = 4, edges = [[1,0],[1,2],[1,3]] +Output: [1] +Explanation: As shown, the height of the tree is 1 when the root is the node with label 1 which is the only MHT. +``` + +### Example 2: + + + +``` +Input: n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]] +Output: [3,4] +``` + +## Constraints + +- `1 <= n <= 2 * 10^4` +- `edges.length == n - 1` +- `0 <= ai, bi < n` +- `ai != bi` +- All the pairs `(ai, bi)` are distinct. +- The given input is **guaranteed** to be a tree and there will be **no repeated** edges. diff --git a/leetcode/minimum_height_trees/__init__.py b/leetcode/minimum_height_trees/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode/minimum_height_trees/playground.ipynb b/leetcode/minimum_height_trees/playground.ipynb new file mode 100644 index 0000000..51e0605 --- /dev/null +++ b/leetcode/minimum_height_trees/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", + "n = 4\n", + "edges = [[1, 0], [1, 2], [1, 3]]\n", + "expected = [1]" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "execute", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[1]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = Solution().find_min_height_trees(n, edges)\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "test", + "metadata": {}, + "outputs": [], + "source": [ + "assert sorted(result) == sorted(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/minimum_height_trees/solution.py b/leetcode/minimum_height_trees/solution.py new file mode 100644 index 0000000..5b9a6d2 --- /dev/null +++ b/leetcode/minimum_height_trees/solution.py @@ -0,0 +1,29 @@ +from collections import defaultdict, deque + + +class Solution: + # Time: O(V) + # Space: O(V) + def find_min_height_trees(self, n: int, edges: list[list[int]]) -> list[int]: + if n == 1: + return [0] + + graph = defaultdict(set) + for u, v in edges: + graph[u].add(v) + graph[v].add(u) + + leaves = deque([i for i in range(n) if len(graph[i]) == 1]) + + remaining = n + while remaining > 2: + size = len(leaves) + remaining -= size + for _ in range(size): + leaf = leaves.popleft() + neighbor = graph[leaf].pop() + graph[neighbor].remove(leaf) + if len(graph[neighbor]) == 1: + leaves.append(neighbor) + + return list(leaves) diff --git a/leetcode/minimum_height_trees/tests.py b/leetcode/minimum_height_trees/tests.py new file mode 100644 index 0000000..11fd934 --- /dev/null +++ b/leetcode/minimum_height_trees/tests.py @@ -0,0 +1,23 @@ +import pytest + +from leetcode_py.test_utils import logged_test + +from .solution import Solution + + +class TestMinimumHeightTrees: + def setup_method(self): + self.solution = Solution() + + @pytest.mark.parametrize( + "n, edges, expected", + [ + (4, [[1, 0], [1, 2], [1, 3]], [1]), + (6, [[3, 0], [3, 1], [3, 2], [3, 4], [5, 4]], [3, 4]), + (1, [], [0]), + ], + ) + @logged_test + def test_find_min_height_trees(self, n: int, edges: list[list[int]], expected: list[int]): + result = self.solution.find_min_height_trees(n, edges) + assert sorted(result) == sorted(expected) diff --git a/leetcode/reverse_linked_list_ii/playground.ipynb b/leetcode/reverse_linked_list_ii/playground.ipynb index 8fdc5e9..bfdc6d9 100644 --- a/leetcode/reverse_linked_list_ii/playground.ipynb +++ b/leetcode/reverse_linked_list_ii/playground.ipynb @@ -6,7 +6,11 @@ "id": "imports", "metadata": {}, "outputs": [], - "source": ["from solution import Solution\n\nfrom leetcode_py import ListNode"] + "source": [ + "from solution import Solution\n", + "\n", + "from leetcode_py import ListNode" + ] }, { "cell_type": "code", @@ -14,7 +18,13 @@ "id": "setup", "metadata": {}, "outputs": [], - "source": ["# Example test case\nhead_list = [1, 2, 3, 4, 5]\nhead = ListNode[int].from_list(head_list)\nleft, right = 2, 4\nexpected = ListNode[int].from_list([1, 4, 3, 2, 5])"] + "source": [ + "# Example test case\n", + "head_list = [1, 2, 3, 4, 5]\n", + "head = ListNode[int].from_list(head_list)\n", + "left, right = 2, 4\n", + "expected = ListNode[int].from_list([1, 4, 3, 2, 5])" + ] }, { "cell_type": "code", @@ -22,7 +32,10 @@ "id": "execute", "metadata": {}, "outputs": [], - "source": ["result = Solution().reverse_between(head, left, right)\nresult"] + "source": [ + "result = Solution().reverse_between(head, left, right)\n", + "result" + ] }, { "cell_type": "code", @@ -30,7 +43,9 @@ "id": "test", "metadata": {}, "outputs": [], - "source": ["assert result == expected"] + "source": [ + "assert result == expected" + ] } ], "metadata": { diff --git a/leetcode/spiral_matrix/playground.ipynb b/leetcode/spiral_matrix/playground.ipynb index 318e556..c219f95 100644 --- a/leetcode/spiral_matrix/playground.ipynb +++ b/leetcode/spiral_matrix/playground.ipynb @@ -29,7 +29,8 @@ "metadata": {}, "outputs": [], "source": [ - "result = Solution().spiral_order(matrix)\nresult" + "result = Solution().spiral_order(matrix)\n", + "result" ] }, { diff --git a/leetcode/task_scheduler/README.md b/leetcode/task_scheduler/README.md new file mode 100644 index 0000000..be7b1aa --- /dev/null +++ b/leetcode/task_scheduler/README.md @@ -0,0 +1,54 @@ +# Task Scheduler + +**Difficulty:** Medium +**Topics:** Array, Hash Table, Greedy, Sorting, Heap (Priority Queue), Counting +**Tags:** grind-75 + +**LeetCode:** [Problem 621](https://leetcode.com/problems/task-scheduler/description/) + +## Problem Description + +You are given an array of CPU `tasks`, each labeled with a letter from A to Z, and a number `n`. Each CPU interval can be idle or allow the completion of one task. Tasks can be completed in any order, but there's a constraint: there has to be a gap of **at least** `n` intervals between two tasks with the same label. + +Return the **minimum** number of CPU intervals required to complete all tasks. + +## Examples + +### Example 1: + +``` +Input: tasks = ["A","A","A","B","B","B"], n = 2 +Output: 8 +``` + +**Explanation:** A possible sequence is: A -> B -> idle -> A -> B -> idle -> A -> B. + +After completing task A, you must wait two intervals before doing A again. The same applies to task B. In the 3rd interval, neither A nor B can be done, so you idle. By the 4th interval, you can do A again as 2 intervals have passed. + +### Example 2: + +``` +Input: tasks = ["A","C","A","B","D","B"], n = 1 +Output: 6 +``` + +**Explanation:** A possible sequence is: A -> B -> C -> D -> A -> B. + +With a cooling interval of 1, you can repeat a task after just one other task. + +### Example 3: + +``` +Input: tasks = ["A","A","A", "B","B","B"], n = 3 +Output: 10 +``` + +**Explanation:** A possible sequence is: A -> B -> idle -> idle -> A -> B -> idle -> idle -> A -> B. + +There are only two types of tasks, A and B, which need to be separated by 3 intervals. This leads to idling twice between repetitions of these tasks. + +## Constraints + +- `1 <= tasks.length <= 10^4` +- `tasks[i]` is an uppercase English letter. +- `0 <= n <= 100` diff --git a/leetcode/task_scheduler/__init__.py b/leetcode/task_scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode/task_scheduler/playground.ipynb b/leetcode/task_scheduler/playground.ipynb new file mode 100644 index 0000000..7f76881 --- /dev/null +++ b/leetcode/task_scheduler/playground.ipynb @@ -0,0 +1,88 @@ +{ + "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", + "tasks = [\"A\", \"A\", \"A\", \"B\", \"B\", \"B\"]\n", + "n = 2\n", + "expected = 8" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "execute", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Counter({'A': 3, 'B': 3})\n", + "[-3, -3]\n" + ] + }, + { + "data": { + "text/plain": [ + "8" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = Solution().least_interval(tasks, n)\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/task_scheduler/solution.py b/leetcode/task_scheduler/solution.py new file mode 100644 index 0000000..d6a6f15 --- /dev/null +++ b/leetcode/task_scheduler/solution.py @@ -0,0 +1,65 @@ +import heapq +from collections import Counter, deque + + +class Solution: + # Time: O(T * n + m log m) where T = len(tasks), worst case with many idle periods + # Space: O(m) where m ≤ 26, so O(1) + def least_interval(self, tasks: list[str], n: int) -> int: + counts = Counter(tasks) + max_heap = [-count for count in counts.values()] + heapq.heapify(max_heap) + + step_num = 0 + queue: deque[tuple[int, int]] = deque() # (count, available_time) + + while max_heap or queue: + step_num += 1 + + while queue and queue[0][1] <= step_num: + count, _ = queue.popleft() + heapq.heappush(max_heap, count) + + if max_heap: + count = heapq.heappop(max_heap) + count += 1 # Decrease count (was negative) + if count < 0: # Still has tasks left + queue.append((count, step_num + n + 1)) + + return step_num + + +class SolutionGreedy: + # Time: O(T + m) where T = len(tasks), m = unique tasks ≤ 26, so O(T) + # Space: O(m) where m ≤ 26, so O(1) + def least_interval(self, tasks: list[str], n: int) -> int: + """ + Mathematical approach: + + Key insight: The most frequent task determines the minimum time. + + Example: tasks=["A","A","A","B","B","B"], n=2 + + 1. Find max frequency: max_freq = 3 (A and B both appear 3 times) + 2. Count tasks with max frequency: max_count = 2 (A and B) + 3. Create frame structure: + Frame: A B _ | A B _ | A B + - (max_freq - 1) complete frames of size (n + 1) + - Last frame contains only max frequency tasks + + 4. Calculate minimum intervals: + - Frame intervals: (max_freq - 1) * (n + 1) = 2 * 3 = 6 + - Plus max frequency tasks: 6 + 2 = 8 + + 5. Return max(total_tasks, calculated_min) to handle cases where + we have enough variety to fill all gaps without idle time. + """ + counts = Counter(tasks) + max_freq = max(counts.values()) + max_count = sum(1 for freq in counts.values() if freq == max_freq) + + # Minimum intervals needed based on most frequent tasks + min_intervals = (max_freq - 1) * (n + 1) + max_count + + # Return max to handle cases with sufficient task variety + return max(len(tasks), min_intervals) diff --git a/leetcode/task_scheduler/tests.py b/leetcode/task_scheduler/tests.py new file mode 100644 index 0000000..c04842f --- /dev/null +++ b/leetcode/task_scheduler/tests.py @@ -0,0 +1,30 @@ +import pytest + +from leetcode_py.test_utils import logged_test + +from .solution import Solution, SolutionGreedy + + +class TestTaskScheduler: + @pytest.mark.parametrize("solution_class", [Solution, SolutionGreedy]) + @pytest.mark.parametrize( + "tasks, n, expected", + [ + (["A", "A", "A", "B", "B", "B"], 2, 8), + (["A", "C", "A", "B", "D", "B"], 1, 6), + (["A", "A", "A", "B", "B", "B"], 3, 10), + (["A", "A", "A", "A", "A", "A", "B", "C", "D", "E", "F", "G"], 2, 16), + (["A"], 2, 1), + ], + ) + @logged_test + def test_least_interval( + self, + tasks: list[str], + n: int, + expected: int, + solution_class: type[Solution | SolutionGreedy], + ): + solution = solution_class() + result = solution.least_interval(tasks, n) + assert result == expected diff --git a/leetcode/validate_binary_search_tree/README.md b/leetcode/validate_binary_search_tree/README.md new file mode 100644 index 0000000..4b8b99f --- /dev/null +++ b/leetcode/validate_binary_search_tree/README.md @@ -0,0 +1,44 @@ +# Validate Binary Search Tree + +**Difficulty:** Medium +**Topics:** Tree, Depth-First Search, Binary Search Tree, Binary Tree +**Tags:** grind-75 + +**LeetCode:** [Problem 98](https://leetcode.com/problems/validate-binary-search-tree/description/) + +## Problem Description + +Given the `root` of a binary tree, determine if it is a valid binary search tree (BST). + +A **valid BST** is defined as follows: + +- The left subtree of a node contains only nodes with keys **strictly less than** the node's key. +- The right subtree of a node contains only nodes with keys **strictly greater than** the node's key. +- Both the left and right subtrees must also be binary search trees. + +## Examples + +### Example 1: + +![Example 1](https://assets.leetcode.com/uploads/2020/12/01/tree1.jpg) + +``` +Input: root = [2,1,3] +Output: true +``` + +### Example 2: + +![Example 2](https://assets.leetcode.com/uploads/2020/12/01/tree2.jpg) + +``` +Input: root = [5,1,4,null,null,3,6] +Output: false +``` + +**Explanation:** The root node's value is 5 but its right child's value is 4. + +## Constraints + +- The number of nodes in the tree is in the range `[1, 10^4]`. +- `-2^31 <= Node.val <= 2^31 - 1` diff --git a/leetcode/validate_binary_search_tree/__init__.py b/leetcode/validate_binary_search_tree/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leetcode/validate_binary_search_tree/playground.ipynb b/leetcode/validate_binary_search_tree/playground.ipynb new file mode 100644 index 0000000..4b972d5 --- /dev/null +++ b/leetcode/validate_binary_search_tree/playground.ipynb @@ -0,0 +1,82 @@ +{ + "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: list[int | None] = [2, 1, 3]\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": [ + "root = TreeNode.from_list(root_list)\n", + "result = Solution().is_valid_bst(root)\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "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/validate_binary_search_tree/solution.py b/leetcode/validate_binary_search_tree/solution.py new file mode 100644 index 0000000..3b4dcbf --- /dev/null +++ b/leetcode/validate_binary_search_tree/solution.py @@ -0,0 +1,60 @@ +from collections import deque + +from leetcode_py import TreeNode + + +class Solution: + @classmethod + def validate(cls, node: TreeNode[int] | None, min_val: float, max_val: float) -> bool: + if not node: + return True + if node.val <= min_val or node.val >= max_val: + return False + return cls.validate(node.left, min_val, node.val) and cls.validate(node.right, node.val, max_val) + + # Time: O(n) + # Space: O(h) + def is_valid_bst(self, root: TreeNode[int] | None) -> bool: + return self.validate(root, float("-inf"), float("inf")) + + +class SolutionDFS: + # Time: O(n) + # Space: O(h) + def is_valid_bst(self, root: TreeNode[int] | None) -> bool: + if not root: + return True + + stack = [(root, float("-inf"), float("inf"))] + + while stack: + node, min_val, max_val = stack.pop() + if node.val <= min_val or node.val >= max_val: + return False + if node.right: + stack.append((node.right, node.val, max_val)) + if node.left: + stack.append((node.left, min_val, node.val)) + + return True + + +class SolutionBFS: + # Time: O(n) + # Space: O(w) where w is max width + def is_valid_bst(self, root: TreeNode[int] | None) -> bool: + if not root: + return True + + queue = deque([(root, float("-inf"), float("inf"))]) + + while queue: + node, min_val, max_val = queue.popleft() + if node.val <= min_val or node.val >= max_val: + return False + if node.right: + queue.append((node.right, node.val, max_val)) + if node.left: + queue.append((node.left, min_val, node.val)) + + return True diff --git a/leetcode/validate_binary_search_tree/tests.py b/leetcode/validate_binary_search_tree/tests.py new file mode 100644 index 0000000..b03d47e --- /dev/null +++ b/leetcode/validate_binary_search_tree/tests.py @@ -0,0 +1,30 @@ +import pytest + +from leetcode_py import TreeNode +from leetcode_py.test_utils import logged_test + +from .solution import Solution, SolutionBFS, SolutionDFS + + +class TestValidateBinarySearchTree: + @pytest.mark.parametrize("solution_class", [Solution, SolutionDFS, SolutionBFS]) + @pytest.mark.parametrize( + "root_list, expected", + [ + ([2, 1, 3], True), + ([5, 1, 4, None, None, 3, 6], False), + ([1], True), + ([1, 1], False), + ], + ) + @logged_test + def test_is_valid_bst( + self, + root_list: list[int | None], + expected: bool, + solution_class: type[Solution | SolutionDFS | SolutionBFS], + ): + solution = solution_class() + root = TreeNode.from_list(root_list) + result = solution.is_valid_bst(root) + assert result == expected diff --git a/leetcode_py/__init__.py b/leetcode_py/__init__.py index d9be26e..0300fc0 100644 --- a/leetcode_py/__init__.py +++ b/leetcode_py/__init__.py @@ -1,4 +1,5 @@ +from leetcode_py.data_structures.graph_node import GraphNode from leetcode_py.data_structures.list_node import ListNode from leetcode_py.data_structures.tree_node import TreeNode -__all__ = ["ListNode", "TreeNode"] +__all__ = ["GraphNode", "ListNode", "TreeNode"] diff --git a/leetcode_py/data_structures/graph_node.py b/leetcode_py/data_structures/graph_node.py new file mode 100644 index 0000000..26e17d2 --- /dev/null +++ b/leetcode_py/data_structures/graph_node.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from pprint import pformat + +import graphviz + + +class GraphNode: + """ + Graph node for undirected graph problems. + + Each node contains a value and a list of neighbors. + """ + + def __init__(self, val: int = 0, neighbors: list[GraphNode] | None = None) -> None: + self.val = val + self.neighbors = neighbors if neighbors is not None else [] + + def _dfs_traverse(self, visit_fn) -> set[int]: + """Generic DFS traversal with custom visit function.""" + visited = set() + + def dfs(node: GraphNode) -> None: + if node.val in visited: + return + visited.add(node.val) + visit_fn(node) + for neighbor in node.neighbors: + dfs(neighbor) + + dfs(self) + return visited + + def _get_adjacency_dict(self) -> dict[int, list[int]]: + """Get adjacency dictionary representation.""" + adj_dict = {} + + def visit(node: GraphNode) -> None: + adj_dict[node.val] = sorted([n.val for n in node.neighbors]) + + self._dfs_traverse(visit) + return dict(sorted(adj_dict.items())) + + def __eq__(self, other: object) -> bool: + """Compare two graph nodes by adjacency list structure.""" + if not isinstance(other, GraphNode): + return False + return self.to_adjacency_list(self) == self.to_adjacency_list(other) + + def is_clone(self, other: GraphNode | None) -> bool: + """ + Check if other is a proper clone (same structure, different objects). + """ + if other is None: + return False + + # First check if structures are equal + if self != other: + return False + + # Then check all nodes are different objects + visited: set[int] = set() + + def dfs_check_identity(node1: GraphNode, node2: GraphNode) -> bool: + if node1.val in visited: + return True + + visited.add(node1.val) + + # Same object = not a clone + if node1 is node2: + return False + + # Check neighbors (sorted for consistency) + neighbors1 = sorted(node1.neighbors, key=lambda x: x.val) + neighbors2 = sorted(node2.neighbors, key=lambda x: x.val) + + for n1, n2 in zip(neighbors1, neighbors2): + if not dfs_check_identity(n1, n2): + return False + return True + + return dfs_check_identity(self, other) + + def __str__(self) -> str: + """Human-readable string representation using pprint.""" + return pformat(self._get_adjacency_dict(), width=40) + + def _repr_html_(self) -> str: + """HTML representation for Jupyter notebooks using Graphviz.""" + dot = graphviz.Graph(engine="neato") + dot.attr("node", shape="circle", style="filled", fillcolor="lightblue") + dot.attr("edge", color="gray") + + edges = set() + + def visit(node: GraphNode) -> None: + dot.node(str(node.val), str(node.val)) + for neighbor in node.neighbors: + edge = (min(node.val, neighbor.val), max(node.val, neighbor.val)) + if edge not in edges: + edges.add(edge) + dot.edge(str(node.val), str(neighbor.val)) + + self._dfs_traverse(visit) + return dot.pipe(format="svg", encoding="utf-8") + + def __repr__(self) -> str: + """Developer representation showing adjacency dict.""" + return f"GraphNode({self._get_adjacency_dict()})" + + @classmethod + def from_adjacency_list(cls, adj_list: list[list[int]]) -> GraphNode | None: + """ + Create a graph from adjacency list representation. + + Args: + adj_list: List where adj_list[i] contains neighbors of node (i+1) + + Returns: + First node of the graph, or None if empty + """ + if not adj_list: + return None + + # Create all nodes first + nodes: dict[int, GraphNode] = {} + for i in range(len(adj_list)): + nodes[i + 1] = cls(val=i + 1) + + # Connect neighbors + for i, neighbors in enumerate(adj_list): + node_val = i + 1 + for neighbor_val in neighbors: + if neighbor_val in nodes: + nodes[node_val].neighbors.append(nodes[neighbor_val]) + + return nodes.get(1) + + @staticmethod + def to_adjacency_list(node: GraphNode | None) -> list[list[int]]: + """ + Convert graph to adjacency list representation. + + Args: + node: Starting node of the graph + + Returns: + Adjacency list where result[i] contains neighbors of node (i+1) + """ + if node is None: + return [] + + adj_dict = node._get_adjacency_dict() + if not adj_dict: + return [] + + max_val = max(adj_dict.keys()) + return [adj_dict.get(i + 1, []) for i in range(max_val)] diff --git a/tests/data_structures/test_graph_node.py b/tests/data_structures/test_graph_node.py new file mode 100644 index 0000000..704f189 --- /dev/null +++ b/tests/data_structures/test_graph_node.py @@ -0,0 +1,161 @@ +import pytest + +from leetcode_py.data_structures.graph_node import GraphNode + + +class TestGraphNode: + @pytest.mark.parametrize( + "val, neighbors, expected_val, expected_neighbors", + [ + (None, None, 0, []), + (5, None, 5, []), + (1, [GraphNode(2)], 1, 1), # 1 neighbor count + ], + ) + def test_init(self, val, neighbors, expected_val, expected_neighbors) -> None: + if val is None: + node = GraphNode() + elif neighbors is None: + node = GraphNode(val) + else: + node = GraphNode(val, neighbors) + + assert node.val == expected_val + if isinstance(expected_neighbors, int): + assert len(node.neighbors) == expected_neighbors + else: + assert node.neighbors == expected_neighbors + + def test_repr(self) -> None: + node1 = GraphNode(1) + node2 = GraphNode(2) + node1.neighbors = [node2] + node2.neighbors = [node1] + assert repr(node1) == "GraphNode({1: [2], 2: [1]})" + + def test_str(self) -> None: + node1 = GraphNode(1) + node2 = GraphNode(2) + node1.neighbors = [node2] + node2.neighbors = [node1] + expected = "{1: [2], 2: [1]}" + assert str(node1) == expected + + @pytest.mark.parametrize( + "val1, val2, expected", + [ + (1, 1, True), + (1, 2, False), + ], + ) + def test_eq_single_nodes(self, val1, val2, expected) -> None: + node1 = GraphNode(val1) + node2 = GraphNode(val2) + assert (node1 == node2) == expected + + @pytest.mark.parametrize("other", ["not a node", 1, None]) + def test_eq_with_non_graph_node(self, other) -> None: + node = GraphNode(1) + assert node != other + + def test_eq_simple_graph(self) -> None: + # Graph 1: 1-2 + node1_a = GraphNode(1) + node2_a = GraphNode(2) + node1_a.neighbors = [node2_a] + node2_a.neighbors = [node1_a] + + # Graph 2: 1-2 + node1_b = GraphNode(1) + node2_b = GraphNode(2) + node1_b.neighbors = [node2_b] + node2_b.neighbors = [node1_b] + + assert node1_a == node1_b + + def test_eq_different_neighbor_count(self) -> None: + node1_a = GraphNode(1) + node2_a = GraphNode(2) + node1_a.neighbors = [node2_a] + + node1_b = GraphNode(1) + node2_b = GraphNode(2) + node3_b = GraphNode(3) + node1_b.neighbors = [node2_b, node3_b] + + assert node1_a != node1_b + + @pytest.mark.parametrize( + "adj_list, expected_val, expected_neighbors", + [ + ([], None, None), + ([[]], 1, []), + ([[2], [1]], 1, [2]), + ([[2, 4], [1, 3], [2, 4], [1, 3]], 1, [2, 4]), + ], + ) + def test_from_adjacency_list(self, adj_list, expected_val, expected_neighbors) -> None: + result = GraphNode.from_adjacency_list(adj_list) + + if expected_val is None: + assert result is None + else: + assert result is not None + assert result.val == expected_val + if expected_neighbors == []: + assert result.neighbors == [] + else: + neighbor_vals = sorted([n.val for n in result.neighbors]) + assert neighbor_vals == expected_neighbors + + @pytest.mark.parametrize( + "node, expected", + [ + (None, []), + (GraphNode(1), [[]]), + ], + ) + def test_to_adjacency_list(self, node, expected) -> None: + result = GraphNode.to_adjacency_list(node) + assert result == expected + + def test_to_adjacency_list_two_nodes(self) -> None: + node1 = GraphNode(1) + node2 = GraphNode(2) + node1.neighbors = [node2] + node2.neighbors = [node1] + + result = GraphNode.to_adjacency_list(node1) + assert result == [[2], [1]] + + @pytest.mark.parametrize( + "original", + [ + [], + [[]], + [[2], [1]], + [[2, 4], [1, 3], [2, 4], [1, 3]], + ], + ) + def test_roundtrip_conversion(self, original) -> None: + graph = GraphNode.from_adjacency_list(original) + result = GraphNode.to_adjacency_list(graph) + assert result == original + + def test_cycle_handling(self) -> None: + # Create a cycle: 1-2-3-1 + node1 = GraphNode(1) + node2 = GraphNode(2) + node3 = GraphNode(3) + + node1.neighbors = [node2] + node2.neighbors = [node1, node3] + node3.neighbors = [node2, node1] + + # Should not infinite loop + adj_list = GraphNode.to_adjacency_list(node1) + assert len(adj_list) == 3 + + # Recreate and compare + recreated = GraphNode.from_adjacency_list(adj_list) + assert node1 == recreated