From a45270eb66f4fa2fcbc67101c63fdb7e33191431 Mon Sep 17 00:00:00 2001 From: Manuj Sankrit Date: Mon, 5 Oct 2026 03:23:34 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8C=B2=20add=20tree=20data-struct?= =?UTF-8?q?ure=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CONTRIBUTING.md | 3 +- README.md | 8 +- scripts/build_npm.ts | 2 + src/core/tree.ts | 978 ++++++++++++++++++ src/mod.ts | 4 + src/tests/tree.test.ts | 881 ++++++++++++++++ src/types/index.ts | 24 + website/docs/api/tree.md | 352 +++++++ website/docs/examples/tree-budget-rollup.md | 120 +++ website/docs/examples/tree-file-system.md | 123 +++ website/docs/intro.md | 4 +- .../current/api/tree.md | 352 +++++++ .../current/examples/tree-budget-rollup.md | 120 +++ .../current/examples/tree-file-system.md | 123 +++ .../current/intro.md | 4 +- .../current/api/tree.md | 352 +++++++ .../current/examples/tree-budget-rollup.md | 120 +++ .../current/examples/tree-file-system.md | 123 +++ .../current/intro.md | 4 +- website/sidebars.ts | 1 + 20 files changed, 3686 insertions(+), 12 deletions(-) create mode 100644 src/core/tree.ts create mode 100644 src/tests/tree.test.ts create mode 100644 website/docs/api/tree.md create mode 100644 website/docs/examples/tree-budget-rollup.md create mode 100644 website/docs/examples/tree-file-system.md create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/api/tree.md create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-file-system.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/tree.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-file-system.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index deebbef..33e2a5a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -46,7 +46,6 @@ Branch naming convention: ``` 5. Update documentation: - - Update JSDoc comments - Update relevant markdown files - Update examples if needed @@ -100,7 +99,7 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/) ### Testing - Write unit tests for all new features -- Maintain test coverage above 85% +- Maintain test coverage above 95% - Include edge cases in tests - Use descriptive test names diff --git a/README.md b/README.md index 544e714..3a71a02 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A comprehensive collection of type-safe, zero-dependency data structure implementations for TypeScript/JavaScript. -[![JSR](https://jsr.io/badges/@dstoolkit/data-structures)](https://jsr.io/@dstoolkit/data-structures) [![JSR Score](https://jsr.io/badges/@dstoolkit/data-structures/score)](https://jsr.io/@dstoolkit/data-structures) [![npm version](https://badge.fury.io/js/@dstoolkit%2Fdata-structures.svg)](https://www.npmjs.com/package/@dstoolkit/data-structures) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@dstoolkit/data-structures)](https://bundlephobia.com/package/@dstoolkit/data-structures) [![codecov](https://codecov.io/gh/mandy8055/data-structures/branch/main/graph/badge.svg)](https://codecov.io/gh/mandy8055/data-structures) [![CI](https://github.com/mandy8055/data-structures/actions/workflows/release-please.yml/badge.svg)](https://github.com/mandy8055/data-structures/actions/workflows/release-please.yml) +[![JSR](https://jsr.io/badges/@dstoolkit/data-structures)](https://jsr.io/@dstoolkit/data-structures) [![JSR Score](https://jsr.io/badges/@dstoolkit/data-structures/score)](https://jsr.io/@dstoolkit/data-structures) [![npm version](https://badge.fury.io/js/@dstoolkit%2Fdata-structures.svg)](https://www.npmjs.com/package/@dstoolkit/data-structures) [![codecov](https://codecov.io/gh/mandy8055/data-structures/branch/main/graph/badge.svg)](https://codecov.io/gh/mandy8055/data-structures) [![CI](https://github.com/mandy8055/data-structures/actions/workflows/release-please.yml/badge.svg)](https://github.com/mandy8055/data-structures/actions/workflows/release-please.yml) ## 📚 [Full Documentation](https://data-structures-docs.vercel.app) @@ -46,7 +46,7 @@ console.log(cache.get('key')); // 42 - **Queues**: Queue, Deque, PriorityQueue - **Lists**: LinkedList, DoublyLinkedList - **Heaps**: BinaryHeap (MinHeap, MaxHeap) -- **Trees**: Trie, RedBlackTree +- **Trees**: Tree, Trie, RedBlackTree - **Maps & Caches**: SortedMap, BiDirectionalMap, LRUCache ## Features @@ -54,7 +54,7 @@ console.log(cache.get('key')); // 42 - 🎯 **Type Safety** - Full TypeScript support with generics - 📦 **Zero Dependencies** - Lightweight and secure - ⚡ **Performance** - Optimized implementations with documented time complexities -- 🧪 **Well Tested** - Comprehensive test coverage (>85%) +- 🧪 **Well Tested** - Comprehensive test coverage (~100%) - 🌲 **Tree Shakeable** - Import only what you need - 🔄 **Dual Published** - Available on both JSR and npm @@ -62,7 +62,7 @@ console.log(cache.get('key')); // 42 - 📚 **[Documentation](https://data-structures-docs.vercel.app)** - Complete guides and API reference - 🐛 **[Issues](https://github.com/mandy8055/data-structures/issues)** - Report bugs or request features -- 💬 **[Discussions](https://github.com/mandy8055/data-structures/discussions)** - Join the community +- 💬 **[Discussions](https://github.com/mandy8055/data-structures/discussions)** - Have Idea! Let's discuss ## License diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index 4532756..0809212 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -57,6 +57,8 @@ await build({ 'min-heap', 'max-heap', 'trie', + 'tree', + 'n-ary-tree', 'red-black-tree', 'sorted-map', 'lru-cache', diff --git a/src/core/tree.ts b/src/core/tree.ts new file mode 100644 index 0000000..3f3234b --- /dev/null +++ b/src/core/tree.ts @@ -0,0 +1,978 @@ +// Copyright 2025-2026 the @dstoolkit/data-structures authors. All rights reserved. MIT license. + +import { + EmptyStructureError, + IndexOutOfBoundsError, + InvalidOperationError, +} from '../errors/index.ts'; +import type { NestedNode, TreeOptions } from '../types/index.ts'; +import { Queue } from './queue.ts'; + +/** + * @ignore + * Internal mutable state of a TreeNode. Never exposed outside this module. + */ +interface NodeState { + value: T; + parent: TreeNode | null; + children: TreeNode[]; + tree: Tree | null; + view: ReadonlyArray> | null; +} + +/** @ignore Token guarding TreeNode construction to this module. */ +const NODE_TOKEN: unique symbol = Symbol('TreeNode'); + +/** @ignore Fast internal accessor for nodes already known to be valid. */ +let stateOf: (node: TreeNode) => NodeState; +/** @ignore Brand-checked accessor for untrusted arguments. */ +let maybeStateOf: (node: unknown) => NodeState | null; +/** @ignore Internal node factory. */ +let newNode: ( + tree: Tree, + parent: TreeNode | null, + value: T, +) => TreeNode; + +/** + * A node of an n-ary {@link Tree}. + * + * Nodes can only be created by a `Tree` (via `setRoot`, `addChild`, + * `insertChildAt`, `fromNested`, `map` or `filter`). Their structure is + * read-only from the outside; all structural changes go through the owning + * `Tree`, which enforces ownership, cycle prevention and size limits. + * + * @template T The type of value stored in the node + */ +export class TreeNode { + /** @ignore */ + #s: NodeState; + + static { + stateOf = (node: TreeNode): NodeState => node.#s; + maybeStateOf = (node: unknown): NodeState | null => + typeof node === 'object' && node !== null && #s in node + ? (node as unknown as TreeNode).#s + : null; + newNode = ( + tree: Tree, + parent: TreeNode | null, + value: U, + ): TreeNode => + new TreeNode(NODE_TOKEN, { + value, + parent, + children: [], + tree, + view: null, + }); + } + + /** + * @ignore + * TreeNode instances are created only by Tree. + */ + private constructor(token: symbol, state: NodeState) { + if (token !== NODE_TOKEN) { + throw new InvalidOperationError( + 'TreeNode instances can only be created by a Tree', + ); + } + this.#s = state; + } + + /** + * The value stored in this node + */ + get value(): T { + return this.#s.value; + } + + /** + * Replaces the value stored in this node. This is not a structural change + * and does not invalidate active iterators. + * @param v The new value + */ + set value(v: T) { + this.#s.value = v; + } + + /** + * The parent of this node, or null for the root (or a detached node) + */ + get parent(): TreeNode | null { + return this.#s.parent; + } + + /** + * A frozen, read-only snapshot of this node's children in order. + * Mutating the tree produces a new snapshot; the internal array is never + * exposed. + */ + get children(): ReadonlyArray> { + const s = this.#s; + if (s.view === null) { + s.view = Object.freeze(s.children.slice()); + } + return s.view; + } + + /** + * Whether this node has no children + */ + get isLeaf(): boolean { + return this.#s.children.length === 0; + } + + /** + * Whether this node is the root of the tree that owns it. + * Detached (removed) nodes are never roots. + */ + get isRoot(): boolean { + return this.#s.parent === null && this.#s.tree !== null; + } +} + +/** + * A generic n-ary (rooted, ordered) tree implementation. + * + * Each node may have any number of ordered children. The tree is designed to + * safely hold arbitrary or untrusted data: + * + * Features: + * - Fully iterative algorithms (no recursion) — safe on arbitrarily deep trees + * - Ownership validation: nodes from another tree, or removed nodes, are rejected + * - Cycle prevention when moving subtrees + * - Validated, prototype-pollution-safe construction from nested objects + * - Optional `maxSize` / `maxDepth` bounds + * - Pre-order, post-order and level-order traversal generators + * - Fail-fast iterators: structural modification during iteration throws + * - Implements Iterable interface (pre-order) for use in for...of loops + * + * @template T The type of values stored in the tree + * + * @example + * ```typescript + * const tree = new Tree(); + * const root = tree.setRoot('/'); + * const usr = tree.addChild(root, 'usr'); + * const bin = tree.addChild(usr, 'bin'); + * tree.addChild(root, 'etc'); + * + * console.log([...tree]); // ['/', 'usr', 'bin', 'etc'] + * console.log(tree.path(bin)); // ['/', 'usr', 'bin'] + * console.log(tree.height); // 2 + * ``` + */ +export class Tree implements Iterable { + /** @ignore */ + private rootNode: TreeNode | null = null; + /** @ignore */ + private count = 0; + /** @ignore */ + private version = 0; + /** @ignore */ + private readonly maxSize: number; + /** @ignore */ + private readonly maxDepth: number; + /** @ignore */ + private readonly options: TreeOptions; + + /** + * Creates an empty tree + * @param options Optional limits; `maxSize` bounds the number of nodes and + * `maxDepth` bounds the depth of any node (root has depth 0). Both must be + * positive integers when provided and are unbounded by default. + * @throws {InvalidOperationError} If an option is not a positive integer + */ + constructor(options?: TreeOptions) { + const maxSize = options?.maxSize; + const maxDepth = options?.maxDepth; + if (maxSize !== undefined && !Tree.isPositiveInteger(maxSize)) { + throw new InvalidOperationError('maxSize must be a positive integer'); + } + if (maxDepth !== undefined && !Tree.isPositiveInteger(maxDepth)) { + throw new InvalidOperationError('maxDepth must be a positive integer'); + } + this.maxSize = maxSize ?? Infinity; + this.maxDepth = maxDepth ?? Infinity; + this.options = { maxSize, maxDepth }; + } + + /** + * @ignore + * @private + */ + private static isPositiveInteger(n: unknown): boolean { + return typeof n === 'number' && Number.isInteger(n) && n > 0; + } + + /** + * The root node, or null if the tree is empty + */ + get root(): TreeNode | null { + return this.rootNode; + } + + /** + * Returns the number of nodes in the tree + */ + get size(): number { + return this.count; + } + + /** + * Number of edges on the longest root-to-leaf path. + * An empty tree has height -1 and a single node has height 0. + * Time complexity: O(n) + */ + get height(): number { + return this.rootNode === null ? -1 : this.heightFrom(this.rootNode); + } + + /** + * Checks if the tree is empty + * @returns {boolean} true if the tree contains no nodes + */ + isEmpty(): boolean { + return this.count === 0; + } + + /** + * Removes all nodes from the tree. Every previously owned node becomes + * detached. Time complexity: O(n) + */ + clear(): void { + if (this.rootNode !== null) { + this.detachSubtree(this.rootNode); + } + this.rootNode = null; + this.count = 0; + this.version++; + } + + /** + * Creates the root node of an empty tree + * @param value The value for the root + * @returns The new root node + * @throws {InvalidOperationError} If the tree already has a root + */ + setRoot(value: T): TreeNode { + if (this.rootNode !== null) { + throw new InvalidOperationError('Tree already has a root'); + } + this.version++; + return this.attach(null, value); + } + + /** + * Appends a new child to the given parent. + * Time complexity: O(1), or O(depth) when `maxDepth` is set + * @param parent A node owned by this tree + * @param value The value for the new child + * @returns The new child node + * @throws {InvalidOperationError} If parent is not owned by this tree or a + * limit would be exceeded + */ + addChild(parent: TreeNode, value: T): TreeNode { + const ps = this.assertOwned(parent); + return this.insertChecked(parent, ps.children.length, value); + } + + /** + * Inserts a new child at the given position among the parent's children. + * Time complexity: O(k) where k is the number of siblings + * @param parent A node owned by this tree + * @param index Position in `[0, parent.children.length]` + * @param value The value for the new child + * @returns The new child node + * @throws {InvalidOperationError} If parent is not owned by this tree or a + * limit would be exceeded + * @throws {IndexOutOfBoundsError} If index is out of range + */ + insertChildAt(parent: TreeNode, index: number, value: T): TreeNode { + const ps = this.assertOwned(parent); + if (!Number.isInteger(index) || index < 0 || index > ps.children.length) { + throw new IndexOutOfBoundsError(); + } + return this.insertChecked(parent, index, value); + } + + /** + * Removes a node and its entire subtree. Removing the root empties the + * tree. Every removed node becomes detached and is rejected by all + * subsequent Tree operations. + * Time complexity: O(s + k) where s is the subtree size and k the number of + * siblings + * @param node A node owned by this tree + * @throws {InvalidOperationError} If node is not owned by this tree + */ + remove(node: TreeNode): void { + const s = this.assertOwned(node); + const parent = s.parent; + if (parent === null) { + this.clear(); + return; + } + const ps = stateOf(parent); + ps.children.splice(ps.children.indexOf(node), 1); + ps.view = null; + s.parent = null; + this.count -= this.detachSubtree(node); + this.version++; + } + + /** + * Moves a node (with its subtree) under a new parent within this tree. + * Time complexity: O(depth + k), plus O(subtree size) when `maxDepth` is set + * @param node The node to move (owned by this tree) + * @param newParent The new parent (owned by this tree) + * @param index Optional position among newParent's children, evaluated + * after node is removed from its current position. Defaults to appending. + * @throws {InvalidOperationError} If either node is not owned by this tree, + * if newParent is node or one of its descendants, or if `maxDepth` would be + * exceeded + * @throws {IndexOutOfBoundsError} If index is out of range + */ + move(node: TreeNode, newParent: TreeNode, index?: number): void { + const s = this.assertOwned(node); + const nps = this.assertOwned(newParent); + + // Cycle check: walk up from newParent looking for node. + let cursor: TreeNode | null = newParent; + while (cursor !== null) { + if (cursor === node) { + throw new InvalidOperationError( + 'Cannot move a node under itself or one of its descendants', + ); + } + cursor = stateOf(cursor).parent; + } + + const sameParent = s.parent === newParent; + const limit = nps.children.length - (sameParent ? 1 : 0); + const target = index ?? limit; + if (!Number.isInteger(target) || target < 0 || target > limit) { + throw new IndexOutOfBoundsError(); + } + + if (this.maxDepth !== Infinity) { + const deepest = this.depthOf(newParent) + 1 + this.heightFrom(node); + if (deepest > this.maxDepth) { + throw new InvalidOperationError('Tree maxDepth would be exceeded'); + } + } + // Size is unchanged by a move, so maxSize always remains satisfied. + + // node is never the root here: the root is an ancestor of every node, + // so the cycle check above would have thrown. + const oldParent = s.parent as TreeNode; + const ops = stateOf(oldParent); + ops.children.splice(ops.children.indexOf(node), 1); + ops.view = null; + nps.children.splice(target, 0, node); + nps.view = null; + s.parent = newParent; + this.version++; + } + + /** + * Checks whether a node is owned by this tree. Returns false for nodes of + * other trees, detached nodes and non-node values (never throws). + * Time complexity: O(1) + * @param node The node to check + * @returns {boolean} true if the node belongs to this tree + */ + contains(node: TreeNode): boolean { + const s = maybeStateOf(node); + return s !== null && s.tree === this; + } + + /** + * Finds the first node, in pre-order, whose value satisfies the predicate. + * Time complexity: O(n) + * @param predicate Test function receiving the value and node + * @returns The first matching node, or null if none matches + * @throws {InvalidOperationError} If the predicate modifies the tree + */ + find( + predicate: (value: T, node: TreeNode) => boolean, + ): TreeNode | null { + let found: TreeNode | null = null; + this.walkPreOrder((node, s) => { + if (predicate(s.value, node)) { + found = node; + return false; + } + return true; + }); + return found; + } + + /** + * Finds all nodes, in pre-order, whose values satisfy the predicate. + * Time complexity: O(n) + * @param predicate Test function receiving the value and node + * @returns The matching nodes (empty array if none) + * @throws {InvalidOperationError} If the predicate modifies the tree + */ + findAll( + predicate: (value: T, node: TreeNode) => boolean, + ): TreeNode[] { + const result: TreeNode[] = []; + this.walkPreOrder((node, s) => { + if (predicate(s.value, node)) result.push(node); + return true; + }); + return result; + } + + /** + * Returns the depth of a node (number of edges from the root; root is 0). + * Time complexity: O(depth) + * @param node A node owned by this tree + * @returns The depth of the node + * @throws {InvalidOperationError} If node is not owned by this tree + */ + depth(node: TreeNode): number { + this.assertOwned(node); + return this.depthOf(node); + } + + /** + * Returns the values on the path from the root to the given node. + * Time complexity: O(depth) + * @param node A node owned by this tree + * @returns Values ordered from root to node (inclusive) + * @throws {InvalidOperationError} If node is not owned by this tree + */ + path(node: TreeNode): T[] { + this.assertOwned(node); + const values: T[] = []; + let cursor: TreeNode | null = node; + while (cursor !== null) { + const s: NodeState = stateOf(cursor); + values.push(s.value); + cursor = s.parent; + } + return values.reverse(); + } + + /** + * Iterates values in pre-order (node, then children left to right). + * Time complexity: O(n); extra space O(n) worst case for the explicit stack + * @param from Optional subtree root owned by this tree (defaults to root) + * @returns A generator of values + * @throws {InvalidOperationError} If from is not owned by this tree, or + * (on a later `next()`) if the tree was structurally modified + */ + preOrder(from?: TreeNode): Generator { + return this.preOrderGen(this.startNode(from), this.version); + } + + /** + * Iterates values in post-order (children left to right, then node). + * Time complexity: O(n); extra space O(h) + * @param from Optional subtree root owned by this tree (defaults to root) + * @returns A generator of values + * @throws {InvalidOperationError} If from is not owned by this tree, or + * (on a later `next()`) if the tree was structurally modified + */ + postOrder(from?: TreeNode): Generator { + return this.postOrderGen(this.startNode(from), this.version); + } + + /** + * Iterates values level by level (breadth-first), left to right. + * Time complexity: O(n); extra space O(w) where w is the maximum width + * @param from Optional subtree root owned by this tree (defaults to root) + * @returns A generator of values + * @throws {InvalidOperationError} If from is not owned by this tree, or + * (on a later `next()`) if the tree was structurally modified + */ + levelOrder(from?: TreeNode): Generator { + return this.levelOrderGen(this.startNode(from), this.version); + } + + /** + * @ignore + * Creates an iterator over the tree's values in pre-order + */ + [Symbol.iterator](): Iterator { + return this.preOrder(); + } + + /** + * Converts the tree to an array of values in pre-order + * @returns An array containing all values + */ + toArray(): T[] { + return [...this.preOrder()]; + } + + /** + * Reduces the tree bottom-up (leaves to root). The reducer is called once + * per node, after all of its children, with the results of its children in + * order. Implemented as an iterative post-order traversal. + * Time complexity: O(n); extra space O(h + k) + * @param reducer Combines a node's value with its children's results + * @param from Optional subtree root owned by this tree (defaults to root) + * @returns The result computed for the starting node + * @throws {EmptyStructureError} If the tree is empty and no `from` is given + * @throws {InvalidOperationError} If from is not owned by this tree, or the + * reducer modifies the tree + */ + fold( + reducer: (value: T, childResults: R[], node: TreeNode) => R, + from?: TreeNode, + ): R { + let start: TreeNode; + if (from === undefined) { + if (this.rootNode === null) throw new EmptyStructureError(); + start = this.rootNode; + } else { + this.assertOwned(from); + start = from; + } + const version = this.version; + const nodes: TreeNode[] = [start]; + const indices: number[] = [0]; + const results: R[][] = [[]]; + for (;;) { + const top = nodes.length - 1; + const node = nodes[top]; + const s = stateOf(node); + const i = indices[top]; + if (i < s.children.length) { + indices[top] = i + 1; + nodes.push(s.children[i]); + indices.push(0); + results.push([]); + continue; + } + const r = reducer(s.value, results[top], node); + this.checkVersion(version); + nodes.pop(); + indices.pop(); + results.pop(); + if (top === 0) return r; + results[top - 1].push(r); + } + } + + /** + * Creates a new tree with the same shape and options, whose values are + * produced by `fn`. `fn` is called once per node in pre-order. + * Time complexity: O(n) + * @param fn Mapping function receiving the value and node + * @returns A new, independent tree + * @throws {InvalidOperationError} If fn modifies the tree + */ + map(fn: (value: T, node: TreeNode) => U): Tree { + const out = new Tree(this.options); + this.copyInto( + out, + (value, node) => ({ keep: true, value: fn(value, node) }), + ); + return out; + } + + /** + * Creates a new tree containing only the nodes for which the predicate + * holds for the node itself AND every one of its ancestors. When a node + * fails, its whole subtree is dropped (descendants are not tested), so the + * result keeps the original parent/child relationships of surviving nodes. + * If the root fails, the result is empty. Values are copied by reference. + * Time complexity: O(n) + * @param predicate Test function receiving the value and node + * @returns A new, independent tree with the same options + * @throws {InvalidOperationError} If the predicate modifies the tree + */ + filter(predicate: (value: T, node: TreeNode) => boolean): Tree { + const out = new Tree(this.options); + this.copyInto(out, (value, node) => ({ + keep: predicate(value, node), + value, + })); + return out; + } + + /** + * Builds a tree from a nested `{ value, children? }` object. The input is + * treated as untrusted: every node must be a non-null object with an own + * `value` property, and `children`, if present, must be an array. Only + * `value` and `children` are read. Cycles and shared references (the same + * object reachable twice) are rejected, and `maxSize`/`maxDepth` are + * enforced while building. Iterative; safe for very deep input. + * Time complexity: O(n) + * @param input The nested description of the tree + * @param options Optional limits for the new tree + * @returns A new tree + * @throws {InvalidOperationError} On malformed input, cycles, shared + * references, invalid options or exceeded limits + */ + static fromNested(input: NestedNode, options?: TreeOptions): Tree { + const tree = new Tree(options); + const hasOwn = Object.prototype.hasOwnProperty; + const visited = new Set(); + const srcStack: unknown[] = [input]; + const parentStack: (TreeNode | null)[] = [null]; + const depthStack: number[] = [0]; + + while (srcStack.length > 0) { + const src = srcStack.pop(); + const parent = parentStack.pop() ?? null; + const depth = depthStack.pop() as number; + + if (typeof src !== 'object' || src === null) { + throw new InvalidOperationError( + 'Invalid nested node: expected a non-null object', + ); + } + if (visited.has(src)) { + throw new InvalidOperationError( + 'Invalid nested input: cycle or shared reference detected', + ); + } + visited.add(src); + if (!hasOwn.call(src, 'value')) { + throw new InvalidOperationError( + 'Invalid nested node: missing "value" property', + ); + } + const children = hasOwn.call(src, 'children') + ? (src as { children?: unknown }).children + : undefined; + if (children !== undefined && !Array.isArray(children)) { + throw new InvalidOperationError( + 'Invalid nested node: "children" must be an array', + ); + } + if (depth > tree.maxDepth) { + throw new InvalidOperationError('Tree maxDepth would be exceeded'); + } + // No per-node maxSize check is needed here: maxSize >= 1 admits the + // root, and every other pending node was counted by the fail-fast + // check below before it was pushed. + const node = tree.attach(parent, (src as { value: T }).value); + + if (children !== undefined && children.length > 0) { + // Every pending entry becomes a node or throws, so fail fast. + if (tree.count + srcStack.length + children.length > tree.maxSize) { + throw new InvalidOperationError('Tree maxSize would be exceeded'); + } + for (let i = children.length - 1; i >= 0; i--) { + const child: unknown = children[i]; + if (typeof child !== 'object' || child === null) { + throw new InvalidOperationError( + 'Invalid nested node: expected a non-null object', + ); + } + srcStack.push(child); + parentStack.push(node); + depthStack.push(depth + 1); + } + } + } + return tree; + } + + /** + * Converts the tree to a nested `{ value, children? }` object. Leaf nodes + * have no `children` property. Iterative; safe for very deep trees. + * Time complexity: O(n) + * @returns The nested representation, or null if the tree is empty + */ + toNested(): NestedNode | null { + if (this.rootNode === null) return null; + const rootState = stateOf(this.rootNode); + const result: NestedNode = { value: rootState.value }; + const nodes: TreeNode[] = [this.rootNode]; + const outs: NestedNode[] = [result]; + while (nodes.length > 0) { + const s = stateOf(nodes.pop() as TreeNode); + const out = outs.pop() as NestedNode; + const len = s.children.length; + if (len === 0) continue; + const childOuts: NestedNode[] = new Array(len); + out.children = childOuts; + for (let i = 0; i < len; i++) { + const child = s.children[i]; + const childOut: NestedNode = { value: stateOf(child).value }; + childOuts[i] = childOut; + nodes.push(child); + outs.push(childOut); + } + } + return result; + } + + /** + * @ignore + * Validates that node is a live node owned by this tree + * @private + */ + private assertOwned(node: TreeNode): NodeState { + const s = maybeStateOf(node); + if (s === null) { + throw new InvalidOperationError('Expected a TreeNode'); + } + if (s.tree === null) { + throw new InvalidOperationError( + 'Node has been removed from its tree and is detached', + ); + } + if (s.tree !== this) { + throw new InvalidOperationError('Node does not belong to this tree'); + } + return s; + } + + /** + * @ignore + * Throws if the tree was structurally modified since `version` was captured + * @private + */ + private checkVersion(version: number): void { + if (this.version !== version) { + throw new InvalidOperationError( + 'Tree was structurally modified during iteration', + ); + } + } + + /** + * @ignore + * Resolves the start node of a traversal + * @private + */ + private startNode(from: TreeNode | undefined): TreeNode | null { + if (from === undefined) return this.rootNode; + this.assertOwned(from); + return from; + } + + /** + * @ignore + * Creates and links a node without limit checks (callers check limits). + * Appends to the parent's children when index is omitted. + * @private + */ + private attach( + parent: TreeNode | null, + value: T, + index?: number, + ): TreeNode { + const node = newNode(this, parent, value); + if (parent === null) { + this.rootNode = node; + } else { + const ps = stateOf(parent); + if (index === undefined || index === ps.children.length) { + ps.children.push(node); + } else { + ps.children.splice(index, 0, node); + } + ps.view = null; + } + this.count++; + return node; + } + + /** + * @ignore + * Checks limits, then inserts a new child + * @private + */ + private insertChecked( + parent: TreeNode, + index: number, + value: T, + ): TreeNode { + if (this.count + 1 > this.maxSize) { + throw new InvalidOperationError('Tree maxSize would be exceeded'); + } + if ( + this.maxDepth !== Infinity && this.depthOf(parent) + 1 > this.maxDepth + ) { + throw new InvalidOperationError('Tree maxDepth would be exceeded'); + } + this.version++; + return this.attach(parent, value, index); + } + + /** + * @ignore + * Depth of a node known to be valid + * @private + */ + private depthOf(node: TreeNode): number { + let d = 0; + let cursor = stateOf(node).parent; + while (cursor !== null) { + d++; + cursor = stateOf(cursor).parent; + } + return d; + } + + /** + * @ignore + * Height of the subtree rooted at node (iterative) + * @private + */ + private heightFrom(node: TreeNode): number { + let max = 0; + const nodes: TreeNode[] = [node]; + const depths: number[] = [0]; + while (nodes.length > 0) { + const s = stateOf(nodes.pop() as TreeNode); + const d = depths.pop() as number; + if (d > max) max = d; + for (const child of s.children) { + nodes.push(child); + depths.push(d + 1); + } + } + return max; + } + + /** + * @ignore + * Clears the owner of every node in the subtree; returns the node count + * @private + */ + private detachSubtree(node: TreeNode): number { + let removed = 0; + const stack: TreeNode[] = [node]; + while (stack.length > 0) { + const s = stateOf(stack.pop() as TreeNode); + s.tree = null; + removed++; + for (const child of s.children) stack.push(child); + } + return removed; + } + + /** + * @ignore + * Pre-order walk over nodes calling visit; stops when visit returns false + * @private + */ + private walkPreOrder( + visit: (node: TreeNode, s: NodeState) => boolean, + ): void { + if (this.rootNode === null) return; + const version = this.version; + const stack: TreeNode[] = [this.rootNode]; + while (stack.length > 0) { + const node = stack.pop() as TreeNode; + const s = stateOf(node); + const proceed = visit(node, s); + this.checkVersion(version); + if (!proceed) return; + for (let i = s.children.length - 1; i >= 0; i--) { + stack.push(s.children[i]); + } + } + } + + /** + * @ignore + * Copies this tree into an empty `out` tree in pre-order. `step` decides + * whether to keep each node (dropping its subtree otherwise) and its value. + * @private + */ + private copyInto( + out: Tree, + step: (value: T, node: TreeNode) => { keep: boolean; value: U }, + ): void { + if (this.rootNode === null) return; + const version = this.version; + const src: TreeNode[] = [this.rootNode]; + const dstParents: (TreeNode | null)[] = [null]; + while (src.length > 0) { + const node = src.pop() as TreeNode; + const parent = dstParents.pop() ?? null; + const s = stateOf(node); + const res = step(s.value, node); + this.checkVersion(version); + if (!res.keep) continue; + const copy = out.attach(parent, res.value); + for (let i = s.children.length - 1; i >= 0; i--) { + src.push(s.children[i]); + dstParents.push(copy); + } + } + } + + /** + * @ignore + * @private + */ + private *preOrderGen( + start: TreeNode | null, + version: number, + ): Generator { + if (start === null) return; + const stack: TreeNode[] = [start]; + while (stack.length > 0) { + this.checkVersion(version); + const s = stateOf(stack.pop() as TreeNode); + yield s.value; + this.checkVersion(version); + for (let i = s.children.length - 1; i >= 0; i--) { + stack.push(s.children[i]); + } + } + } + + /** + * @ignore + * @private + */ + private *postOrderGen( + start: TreeNode | null, + version: number, + ): Generator { + if (start === null) return; + const nodes: TreeNode[] = [start]; + const indices: number[] = [0]; + while (nodes.length > 0) { + this.checkVersion(version); + const top = nodes.length - 1; + const s = stateOf(nodes[top]); + const i = indices[top]; + if (i < s.children.length) { + indices[top] = i + 1; + nodes.push(s.children[i]); + indices.push(0); + continue; + } + nodes.pop(); + indices.pop(); + yield s.value; + } + this.checkVersion(version); + } + + /** + * @ignore + * @private + */ + private *levelOrderGen( + start: TreeNode | null, + version: number, + ): Generator { + if (start === null) return; + const queue = new Queue>(); + queue.enqueue(start); + while (!queue.isEmpty()) { + this.checkVersion(version); + const s = stateOf(queue.dequeue()); + yield s.value; + this.checkVersion(version); + for (const child of s.children) queue.enqueue(child); + } + } +} diff --git a/src/mod.ts b/src/mod.ts index 75e87ed..070e356 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -23,6 +23,10 @@ export { BinaryHeap, MaxHeap, MinHeap } from './core/binary-heap.ts'; // Special Trees implementation export { RedBlackTree } from './core/red-black-tree.ts'; export { Trie } from './core/trie.ts'; +export { Tree, TreeNode } from './core/tree.ts'; + +// Public types +export type { NestedNode, TreeOptions } from './types/index.ts'; // Optional: Common errors/exceptions export { diff --git a/src/tests/tree.test.ts b/src/tests/tree.test.ts new file mode 100644 index 0000000..83ba9f8 --- /dev/null +++ b/src/tests/tree.test.ts @@ -0,0 +1,881 @@ +import { + assert, + assertEquals, + assertStrictEquals, + assertThrows, +} from '@std/assert'; +import { Tree, TreeNode } from '../core/tree.ts'; +import { + EmptyStructureError, + IndexOutOfBoundsError, + InvalidOperationError, +} from '../errors/index.ts'; +import type { NestedNode } from '../types/index.ts'; + +/** + * Builds the following tree and returns it with its nodes: + * + * A + * / | \ + * B C D + * / \ | + * E F G + */ +function buildSample() { + const tree = new Tree(); + const a = tree.setRoot('A'); + const b = tree.addChild(a, 'B'); + const c = tree.addChild(a, 'C'); + const d = tree.addChild(a, 'D'); + const e = tree.addChild(b, 'E'); + const f = tree.addChild(b, 'F'); + const g = tree.addChild(d, 'G'); + return { tree, a, b, c, d, e, f, g }; +} + +Deno.test('Tree - empty tree', async (t) => { + await t.step('should create an empty tree', () => { + const tree = new Tree(); + assertEquals(tree.size, 0); + assertEquals(tree.isEmpty(), true); + assertEquals(tree.root, null); + assertEquals(tree.height, -1); + assertEquals(tree.toNested(), null); + assertEquals(tree.toArray(), []); + assertEquals([...tree.preOrder()], []); + assertEquals([...tree.postOrder()], []); + assertEquals([...tree.levelOrder()], []); + }); + + await t.step('should throw EmptyStructureError on fold', () => { + const tree = new Tree(); + assertThrows(() => tree.fold(() => 0), EmptyStructureError); + }); + + await t.step('should return null/[] for queries', () => { + const tree = new Tree(); + assertEquals(tree.find(() => true), null); + assertEquals(tree.findAll(() => true), []); + }); + + await t.step('should map and filter to empty trees', () => { + const tree = new Tree(); + assertEquals(tree.map((v) => v * 2).isEmpty(), true); + assertEquals(tree.filter(() => true).isEmpty(), true); + }); + + await t.step('should allow clear on an empty tree', () => { + const tree = new Tree(); + tree.clear(); + assertEquals(tree.size, 0); + }); +}); + +Deno.test('Tree - setRoot', async (t) => { + await t.step('should set the root once', () => { + const tree = new Tree(); + const root = tree.setRoot(1); + assertStrictEquals(tree.root, root); + assertEquals(root.value, 1); + assertEquals(root.isRoot, true); + assertEquals(root.isLeaf, true); + assertEquals(root.parent, null); + assertEquals(tree.size, 1); + assertEquals(tree.height, 0); + }); + + await t.step('should throw on second setRoot', () => { + const tree = new Tree(); + tree.setRoot(1); + assertThrows(() => tree.setRoot(2), InvalidOperationError); + }); + + await t.step('should allow setRoot again after clear', () => { + const tree = new Tree(); + tree.setRoot(1); + tree.clear(); + const root = tree.setRoot(2); + assertEquals(root.value, 2); + assertEquals(tree.size, 1); + }); +}); + +Deno.test('Tree - TreeNode', async (t) => { + await t.step('should not be constructible directly', () => { + const Ctor = TreeNode as unknown as new (...args: unknown[]) => unknown; + assertThrows(() => new Ctor(Symbol('x'), {}), InvalidOperationError); + }); + + await t.step('should expose a frozen read-only children view', () => { + const { tree, a, b, c, d } = buildSample(); + const view = a.children; + assertEquals(view, [b, c, d]); + assert(Object.isFrozen(view)); + assertThrows(() => { + (view as TreeNode[]).push(b); + }, TypeError); + assertEquals(tree.size, 7); + assertStrictEquals(a.children, view); // cached until mutation + const x = tree.addChild(a, 'X'); + assertEquals(view.length, 3); // old snapshot unaffected + assertEquals(a.children, [b, c, d, x]); + }); + + await t.step('should allow setting values', () => { + const { tree, b } = buildSample(); + b.value = 'BB'; + assertEquals(tree.toArray(), ['A', 'BB', 'E', 'F', 'C', 'D', 'G']); + }); + + await t.step('should report leaf/root state', () => { + const { a, b, e } = buildSample(); + assertEquals(a.isRoot, true); + assertEquals(b.isRoot, false); + assertEquals(b.isLeaf, false); + assertEquals(e.isLeaf, true); + assertStrictEquals(e.parent, b); + }); +}); + +Deno.test('Tree - addChild / insertChildAt', async (t) => { + await t.step('should build structure and track size and height', () => { + const { tree, a, b, c, d, e, f, g } = buildSample(); + assertEquals(tree.size, 7); + assertEquals(tree.height, 2); + assertEquals(tree.depth(a), 0); + assertEquals(tree.depth(b), 1); + assertEquals(tree.depth(g), 2); + assertEquals(tree.path(a), ['A']); + assertEquals(tree.path(c), ['A', 'C']); + assertEquals(tree.path(f), ['A', 'B', 'F']); + assertEquals(tree.path(g), ['A', 'D', 'G']); + tree.addChild(e, 'H'); + assertEquals(tree.height, 3); + assertEquals(tree.size, 8); + assertEquals(d.children.length, 1); + }); + + await t.step('should insert children at specific positions', () => { + const tree = new Tree(); + const root = tree.setRoot(0); + tree.insertChildAt(root, 0, 2); + tree.insertChildAt(root, 0, 1); + tree.insertChildAt(root, 2, 4); + tree.insertChildAt(root, 2, 3); + assertEquals(root.children.map((n) => n.value), [1, 2, 3, 4]); + assertEquals(tree.size, 5); + }); + + await t.step('should throw IndexOutOfBoundsError for bad index', () => { + const tree = new Tree(); + const root = tree.setRoot(0); + tree.addChild(root, 1); + assertThrows(() => tree.insertChildAt(root, -1, 9), IndexOutOfBoundsError); + assertThrows(() => tree.insertChildAt(root, 2, 9), IndexOutOfBoundsError); + assertThrows(() => tree.insertChildAt(root, 0.5, 9), IndexOutOfBoundsError); + assertThrows( + () => tree.insertChildAt(root, NaN, 9), + IndexOutOfBoundsError, + ); + assertEquals(tree.size, 2); + }); +}); + +Deno.test('Tree - remove', async (t) => { + await t.step('should remove a leaf', () => { + const { tree, b, e, f } = buildSample(); + tree.remove(e); + assertEquals(tree.size, 6); + assertEquals(b.children, [f]); + assertEquals(tree.contains(e), false); + assertEquals(e.parent, null); + assertEquals(e.isRoot, false); + }); + + await t.step('should remove a middle node with its subtree', () => { + const { tree, a, b, c, d, e, f } = buildSample(); + tree.remove(b); + assertEquals(tree.size, 4); + assertEquals(a.children, [c, d]); + assertEquals(tree.toArray(), ['A', 'C', 'D', 'G']); + for (const n of [b, e, f]) assertEquals(tree.contains(n), false); + }); + + await t.step('should empty the tree when removing the root', () => { + const { tree, a, g } = buildSample(); + tree.remove(a); + assertEquals(tree.size, 0); + assertEquals(tree.isEmpty(), true); + assertEquals(tree.root, null); + assertEquals(tree.contains(a), false); + assertEquals(tree.contains(g), false); + }); + + await t.step('should reject removed nodes in every operation', () => { + const { tree, a, b, e } = buildSample(); + const other = new Tree(); + const otherRoot = other.setRoot('Z'); + tree.remove(b); + const ops: Array<(n: TreeNode) => unknown> = [ + (n) => tree.addChild(n, 'x'), + (n) => tree.insertChildAt(n, 0, 'x'), + (n) => tree.remove(n), + (n) => tree.move(n, a), + (n) => tree.move(a, n), + (n) => tree.depth(n), + (n) => tree.path(n), + (n) => tree.preOrder(n), + (n) => tree.postOrder(n), + (n) => tree.levelOrder(n), + (n) => tree.fold(() => 0, n), + (n) => other.addChild(n, 'x'), + (n) => other.move(n, otherRoot), + (n) => other.remove(n), + ]; + for (const node of [b, e]) { + for (const op of ops) { + assertThrows(() => op(node), InvalidOperationError, 'detached'); + } + } + }); + + await t.step('should detach all nodes on clear', () => { + const { tree, b } = buildSample(); + tree.clear(); + assertThrows(() => tree.addChild(b, 'x'), InvalidOperationError); + }); +}); + +Deno.test('Tree - move', async (t) => { + await t.step('should move a subtree to a new parent', () => { + const { tree, b, c, d, g } = buildSample(); + tree.move(b, g); + assertEquals(tree.size, 7); + assertStrictEquals(b.parent, g); + assertEquals(tree.path(b), ['A', 'D', 'G', 'B']); + assertEquals(tree.height, 4); + assertEquals(tree.toArray(), ['A', 'C', 'D', 'G', 'B', 'E', 'F']); + assertEquals(tree.root!.children, [c, d]); + }); + + await t.step('should move to a specific index', () => { + const { tree, a, b, c, d, g } = buildSample(); + tree.move(g, a, 0); + assertEquals(a.children, [g, b, c, d]); + assertEquals(d.isLeaf, true); + // Reorder within the same parent: index is evaluated after removal. + tree.move(g, a, 3); + assertEquals(a.children, [b, c, d, g]); + tree.move(d, a, 0); + assertEquals(a.children, [d, b, c, g]); + tree.move(d, a); + assertEquals(a.children, [b, c, g, d]); + }); + + await t.step('should throw on out-of-range index', () => { + const { tree, a, g } = buildSample(); + assertThrows(() => tree.move(g, a, 4), IndexOutOfBoundsError); + assertThrows(() => tree.move(g, a, -1), IndexOutOfBoundsError); + assertThrows(() => tree.move(g, a, 1.5), IndexOutOfBoundsError); + }); + + await t.step('should prevent cycles', () => { + const { tree, a, b, e } = buildSample(); + assertThrows(() => tree.move(b, b), InvalidOperationError); + assertThrows(() => tree.move(b, e), InvalidOperationError); + assertThrows(() => tree.move(a, e), InvalidOperationError); + assertThrows(() => tree.move(a, a), InvalidOperationError); + assertEquals(tree.toArray(), ['A', 'B', 'E', 'F', 'C', 'D', 'G']); + }); + + await t.step('should reject cross-tree moves', () => { + const { tree, a, b } = buildSample(); + const other = buildSample(); + assertThrows(() => tree.move(other.b, a), InvalidOperationError); + assertThrows(() => tree.move(b, other.a), InvalidOperationError); + assertThrows(() => other.tree.move(b, other.a), InvalidOperationError); + }); +}); + +Deno.test('Tree - ownership checks', async (t) => { + await t.step('should reject foreign nodes in every method', () => { + const treeA = buildSample(); + const treeB = buildSample(); + const foreign = treeA.b; + const own = treeB.c; + const ops: Array<() => unknown> = [ + () => treeB.tree.addChild(foreign, 'x'), + () => treeB.tree.insertChildAt(foreign, 0, 'x'), + () => treeB.tree.remove(foreign), + () => treeB.tree.move(foreign, own), + () => treeB.tree.move(own, foreign), + () => treeB.tree.depth(foreign), + () => treeB.tree.path(foreign), + () => treeB.tree.preOrder(foreign), + () => treeB.tree.postOrder(foreign), + () => treeB.tree.levelOrder(foreign), + () => treeB.tree.fold(() => 0, foreign), + ]; + for (const op of ops) { + assertThrows(op, InvalidOperationError, 'does not belong'); + } + assertEquals(treeB.tree.contains(foreign), false); + assertEquals(treeB.tree.contains(own), true); + }); + + await t.step('should reject non-node values', () => { + const tree = new Tree(); + tree.setRoot(1); + const fake = { value: 1 } as unknown as TreeNode; + assertThrows(() => tree.addChild(fake, 2), InvalidOperationError); + assertThrows( + () => tree.depth(null as unknown as TreeNode), + InvalidOperationError, + ); + assertEquals(tree.contains(fake), false); + assertEquals(tree.contains(null as unknown as TreeNode), false); + }); +}); + +Deno.test('Tree - traversal', async (t) => { + await t.step('should traverse in pre-order', () => { + const { tree, b, d } = buildSample(); + assertEquals([...tree.preOrder()], ['A', 'B', 'E', 'F', 'C', 'D', 'G']); + assertEquals([...tree.preOrder(b)], ['B', 'E', 'F']); + assertEquals([...tree.preOrder(d)], ['D', 'G']); + }); + + await t.step('should traverse in post-order', () => { + const { tree, b, d } = buildSample(); + assertEquals([...tree.postOrder()], ['E', 'F', 'B', 'C', 'G', 'D', 'A']); + assertEquals([...tree.postOrder(b)], ['E', 'F', 'B']); + assertEquals([...tree.postOrder(d)], ['G', 'D']); + }); + + await t.step('should traverse in level-order', () => { + const { tree, b, d } = buildSample(); + assertEquals([...tree.levelOrder()], ['A', 'B', 'C', 'D', 'E', 'F', 'G']); + assertEquals([...tree.levelOrder(b)], ['B', 'E', 'F']); + assertEquals([...tree.levelOrder(d)], ['D', 'G']); + }); + + await t.step('should use pre-order as default iterator', () => { + const { tree } = buildSample(); + assertEquals([...tree], [...tree.preOrder()]); + assertEquals(tree.toArray(), [...tree.preOrder()]); + const seen: string[] = []; + for (const v of tree) seen.push(v); + assertEquals(seen, tree.toArray()); + }); + + await t.step('should throw when mutated during iteration', () => { + const makers = ['preOrder', 'postOrder', 'levelOrder'] as const; + for (const m of makers) { + const { tree, a, c, g } = buildSample(); + const it = tree[m](); + it.next(); + tree.addChild(a, 'X'); + assertThrows(() => it.next(), InvalidOperationError, 'modified'); + + // Mutation before the first next() also invalidates. + const it2 = tree[m](); + tree.remove(c); + assertThrows(() => it2.next(), InvalidOperationError); + + // Mutation after the last element is yielded. + const it3 = tree[m](); + const n = tree.size; + for (let i = 0; i < n; i++) it3.next(); + tree.move(g, a); + assertThrows(() => it3.next(), InvalidOperationError); + } + }); + + await t.step('should not invalidate iterators on value changes', () => { + const { tree, b } = buildSample(); + const it = tree.preOrder(); + it.next(); + b.value = 'changed'; + assertEquals(it.next().value, 'changed'); + }); + + await t.step('should throw when mutated via for...of', () => { + const { tree, a } = buildSample(); + assertThrows(() => { + for (const _ of tree) tree.addChild(a, 'loop'); + }, InvalidOperationError); + }); +}); + +Deno.test('Tree - fold', async (t) => { + await t.step('should sum numeric values', () => { + const tree = new Tree(); + const r = tree.setRoot(1); + const x = tree.addChild(r, 2); + tree.addChild(r, 3); + tree.addChild(x, 4); + tree.addChild(x, 5); + const sum = tree.fold( + (v, kids) => v + kids.reduce((a, b) => a + b, 0), + ); + assertEquals(sum, 15); + }); + + await t.step('should build nested objects', () => { + const { tree } = buildSample(); + type Out = { name: string; kids: Out[] }; + const out = tree.fold((v, kids) => ({ name: v, kids })); + assertEquals(out, { + name: 'A', + kids: [ + { + name: 'B', + kids: [{ name: 'E', kids: [] }, { name: 'F', kids: [] }], + }, + { name: 'C', kids: [] }, + { name: 'D', kids: [{ name: 'G', kids: [] }] }, + ], + }); + }); + + await t.step('should handle a single leaf', () => { + const tree = new Tree(); + const r = tree.setRoot(7); + assertEquals(tree.fold((v, kids) => v + kids.length), 7); + assertEquals(tree.fold((v) => v * 2, r), 14); + }); + + await t.step('should fold from a subtree', () => { + const { tree, b } = buildSample(); + const s = tree.fold((v, kids) => v + kids.join(''), b); + assertEquals(s, 'BEF'); + let visitedNode: TreeNode | null = null; + tree.fold((_v, _k, node) => { + visitedNode = node; + return 0; + }, b); + assertStrictEquals(visitedNode, b); + }); + + await t.step('should throw on empty tree', () => { + assertThrows(() => new Tree().fold(() => 0), EmptyStructureError); + }); + + await t.step('should throw if the reducer mutates the tree', () => { + const { tree, a } = buildSample(); + assertThrows( + () => tree.fold(() => tree.addChild(a, 'x')), + InvalidOperationError, + ); + }); +}); + +Deno.test('Tree - find / findAll', async (t) => { + await t.step('should find the first match in pre-order', () => { + const { tree, b, e } = buildSample(); + assertStrictEquals(tree.find((v) => v === 'E'), e); + assertStrictEquals(tree.find((v) => v > 'A'), b); + assertStrictEquals(tree.find((_v, n) => n === e), e); + }); + + await t.step('should return null on miss', () => { + const { tree } = buildSample(); + assertEquals(tree.find((v) => v === 'Z'), null); + }); + + await t.step('should find all matches', () => { + const { tree, b, e, f, g } = buildSample(); + assertEquals(tree.findAll((_v, n) => n.isLeaf).length, 4); + assertEquals(tree.findAll((v) => 'BEFG'.includes(v)), [b, e, f, g]); + assertEquals(tree.findAll((v) => v === 'Z'), []); + }); + + await t.step('should throw if the predicate mutates the tree', () => { + const { tree, a } = buildSample(); + assertThrows( + () => tree.find(() => (tree.addChild(a, 'x'), false)), + InvalidOperationError, + ); + assertThrows( + () => tree.findAll(() => (tree.addChild(a, 'x'), false)), + InvalidOperationError, + ); + }); +}); + +Deno.test('Tree - map / filter', async (t) => { + await t.step('should map values preserving shape', () => { + const { tree } = buildSample(); + const mapped = tree.map((v) => v.toLowerCase()); + assertEquals(mapped.toArray(), ['a', 'b', 'e', 'f', 'c', 'd', 'g']); + assertEquals(mapped.toNested(), { + value: 'a', + children: [ + { value: 'b', children: [{ value: 'e' }, { value: 'f' }] }, + { value: 'c' }, + { value: 'd', children: [{ value: 'g' }] }, + ], + }); + assertEquals(mapped.size, 7); + assertEquals(tree.toArray()[0], 'A'); // original untouched + assertEquals(mapped.contains(tree.root!), false); + }); + + await t.step('should pass nodes to map and keep options', () => { + const tree = new Tree({ maxSize: 3 }); + const r = tree.setRoot(1); + tree.addChild(r, 2); + const depths = tree.map((_v, n) => tree.depth(n)); + assertEquals(depths.toArray(), [0, 1]); + const d = depths.root!; + depths.addChild(d, 9); + assertThrows(() => depths.addChild(d, 9), InvalidOperationError); + }); + + await t.step('should filter keeping nodes whose ancestors pass', () => { + const { tree } = buildSample(); + const tested: string[] = []; + const filtered = tree.filter((v) => { + tested.push(v); + return v !== 'B'; + }); + assertEquals(filtered.toArray(), ['A', 'C', 'D', 'G']); + // E and F are dropped with B and never tested. + assertEquals(tested, ['A', 'B', 'C', 'D', 'G']); + }); + + await t.step('should return an empty tree when the root fails', () => { + const { tree } = buildSample(); + assertEquals(tree.filter((v) => v !== 'A').isEmpty(), true); + }); + + await t.step('should throw if callbacks mutate the tree', () => { + const { tree, a } = buildSample(); + assertThrows( + () => tree.map((v) => (tree.addChild(a, 'x'), v)), + InvalidOperationError, + ); + assertThrows( + () => tree.filter(() => (tree.addChild(a, 'x'), true)), + InvalidOperationError, + ); + }); +}); + +Deno.test('Tree - limits', async (t) => { + await t.step('should validate options', () => { + const bad = [0, -1, 1.5, NaN, Infinity, '3' as unknown as number]; + for (const v of bad) { + assertThrows(() => new Tree({ maxSize: v }), InvalidOperationError); + assertThrows(() => new Tree({ maxDepth: v }), InvalidOperationError); + } + new Tree({ maxSize: 1, maxDepth: 1 }); + new Tree({}); + }); + + await t.step('should enforce maxSize in addChild/insertChildAt', () => { + const tree = new Tree({ maxSize: 3 }); + const r = tree.setRoot(0); + tree.addChild(r, 1); + tree.insertChildAt(r, 0, 2); + assertThrows(() => tree.addChild(r, 3), InvalidOperationError, 'maxSize'); + assertThrows( + () => tree.insertChildAt(r, 0, 3), + InvalidOperationError, + 'maxSize', + ); + assertEquals(tree.size, 3); + }); + + await t.step('should allow move in a full tree (size unchanged)', () => { + const tree = new Tree({ maxSize: 3 }); + const r = tree.setRoot(0); + const x = tree.addChild(r, 1); + const y = tree.addChild(r, 2); + tree.move(y, x); + assertEquals(tree.size, 3); + assertEquals(tree.height, 2); + }); + + await t.step('should enforce maxDepth in addChild/insertChildAt', () => { + const tree = new Tree({ maxDepth: 2 }); + const r = tree.setRoot(0); + const x = tree.addChild(r, 1); + const y = tree.insertChildAt(x, 0, 2); + assertThrows(() => tree.addChild(y, 3), InvalidOperationError, 'maxDepth'); + assertThrows( + () => tree.insertChildAt(y, 0, 3), + InvalidOperationError, + 'maxDepth', + ); + assertEquals(tree.height, 2); + }); + + await t.step('should enforce maxDepth in move', () => { + const tree = new Tree({ maxDepth: 2 }); + const r = tree.setRoot(0); + const x = tree.addChild(r, 1); + const y = tree.addChild(r, 2); + tree.addChild(y, 3); + assertThrows(() => tree.move(y, x), InvalidOperationError, 'maxDepth'); + const z = tree.addChild(r, 4); + tree.move(z, x); // depth 2: allowed + assertEquals(tree.depth(z), 2); + }); + + await t.step('should enforce maxSize/maxDepth in fromNested', () => { + const input: NestedNode = { + value: 0, + children: [{ value: 1, children: [{ value: 2 }] }, { value: 3 }], + }; + assertEquals(Tree.fromNested(input, { maxSize: 4 }).size, 4); + assertThrows( + () => Tree.fromNested(input, { maxSize: 3 }), + InvalidOperationError, + 'maxSize', + ); + assertThrows( + () => Tree.fromNested({ value: 0 }, { maxSize: 0 }), + InvalidOperationError, + ); + assertEquals(Tree.fromNested(input, { maxDepth: 2 }).height, 2); + assertThrows( + () => Tree.fromNested(input, { maxDepth: 1 }), + InvalidOperationError, + 'maxDepth', + ); + }); + + await t.step('should fail fast on a huge children array', () => { + const children: NestedNode[] = []; + children.length = 1_000_000_000; // sparse, would be huge if walked + assertThrows( + () => Tree.fromNested({ value: 0, children }, { maxSize: 10 }), + InvalidOperationError, + 'maxSize', + ); + // Without limits, the first hole is rejected immediately. + assertThrows( + () => Tree.fromNested({ value: 0, children }), + InvalidOperationError, + 'non-null object', + ); + }); +}); + +Deno.test('Tree - fromNested / toNested', async (t) => { + await t.step('should round-trip', () => { + const { tree } = buildSample(); + const nested = tree.toNested()!; + const copy = Tree.fromNested(nested); + assertEquals(copy.toNested(), nested); + assertEquals(copy.toArray(), tree.toArray()); + assertEquals([...copy.postOrder()], [...tree.postOrder()]); + assertEquals(copy.size, tree.size); + assertEquals(copy.height, tree.height); + }); + + await t.step('should accept explicit undefined/empty children', () => { + const tree = Tree.fromNested({ + value: 1, + children: [{ value: 2, children: undefined }, { value: 3, children: [] }], + }); + assertEquals(tree.toArray(), [1, 2, 3]); + }); + + await t.step('should reject malformed input', () => { + const bad: unknown[] = [ + null, + undefined, + 42, + 'str', + true, + {}, + { children: [] }, + { value: 1, children: 'nope' }, + { value: 1, children: null }, + { value: 1, children: { 0: { value: 2 }, length: 1 } }, + { value: 1, children: [null] }, + { value: 1, children: [7] }, + { value: 1, children: [{ notValue: 2 }] }, + Object.create({ value: 1 }), // inherited value is not accepted + ]; + for (const input of bad) { + assertThrows( + () => Tree.fromNested(input as NestedNode), + InvalidOperationError, + ); + } + }); + + await t.step('should not pollute Object.prototype', () => { + const malicious = JSON.parse( + '{"value":1,"__proto__":{"polluted":true},' + + '"children":[{"value":2,"constructor":{"prototype":{"polluted":true}}}]}', + ); + const special = { value: 3 }; + Object.defineProperty(special, 'constructor', { + value: { prototype: { polluted: true } }, + enumerable: true, + }); + malicious.children.push(special); + const tree = Tree.fromNested(malicious); + assertEquals(tree.toArray(), [1, 2, 3]); + // deno-lint-ignore no-explicit-any + assertEquals(({} as any).polluted, undefined); + // deno-lint-ignore no-explicit-any + assertEquals((Object.prototype as any).polluted, undefined); + const out = tree.toNested()!; + assertEquals(Object.keys(out).sort(), ['children', 'value']); + assertEquals(Object.getPrototypeOf(out), Object.prototype); + }); + + await t.step('should store a __proto__-keyed value safely', () => { + const value = JSON.parse('{"__proto__":{"polluted":true}}'); + const tree = Tree.fromNested({ value }); + assertStrictEquals(tree.root!.value, value); + // deno-lint-ignore no-explicit-any + assertEquals(({} as any).polluted, undefined); + }); + + await t.step('should reject cyclic input', () => { + const root: NestedNode = { value: 1, children: [] }; + const child: NestedNode = { value: 2, children: [root] }; + root.children!.push(child); + assertThrows( + () => Tree.fromNested(root), + InvalidOperationError, + 'cycle or shared reference', + ); + const self: NestedNode = { value: 1, children: [] }; + self.children!.push(self); + assertThrows(() => Tree.fromNested(self), InvalidOperationError); + }); + + await t.step('should reject shared references', () => { + const shared: NestedNode = { value: 2 }; + assertThrows( + () => Tree.fromNested({ value: 1, children: [shared, shared] }), + InvalidOperationError, + 'cycle or shared reference', + ); + }); + + await t.step('should only read value and children', () => { + let reads = 0; + const input = { + get value() { + reads++; + return 1; + }, + extra: 'ignored', + }; + const tree = Tree.fromNested(input); + assertEquals(reads, 1); + assertEquals(tree.toNested(), { value: 1 }); + }); +}); + +Deno.test('Tree - depth safety (deep linear chain)', () => { + const N = 200_000; + const start = performance.now(); + const tree = new Tree(); + let leaf = tree.setRoot(0); + for (let i = 1; i < N; i++) leaf = tree.addChild(leaf, i); + + assertEquals(tree.size, N); + assertEquals(tree.height, N - 1); + assertEquals(tree.depth(leaf), N - 1); + assertEquals(tree.path(leaf).length, N); + + let count = 0; + for (const _ of tree.preOrder()) count++; + assertEquals(count, N); + count = 0; + for (const _ of tree.postOrder()) count++; + assertEquals(count, N); + count = 0; + for (const _ of tree.levelOrder()) count++; + assertEquals(count, N); + assertEquals(tree.toArray().length, N); + + assertEquals( + tree.fold((_v, kids) => 1 + (kids.length ? kids[0] : 0)), + N, + ); + assertStrictEquals(tree.find((v) => v === N - 1), leaf); + assertEquals(tree.findAll((v) => v % 2 === 0).length, N / 2); + + const nested = tree.toNested()!; + const copy = Tree.fromNested(nested); + assertEquals(copy.size, N); + assertEquals(copy.height, N - 1); + + const mapped = tree.map((v) => v * 2); + assertEquals(mapped.size, N); + assertEquals(tree.filter((v) => v < N / 2).size, N / 2); + + // Move the chain's second half under the root (exercises cycle check). + const mid = tree.find((v) => v === N / 2)!; + tree.move(mid, tree.root!); + assertEquals(tree.height, N / 2); + + tree.remove(tree.root!); + assertEquals(tree.size, 0); + assertEquals(tree.contains(leaf), false); + + const elapsed = performance.now() - start; + assert(elapsed < 2000, `deep-chain test took ${elapsed.toFixed(0)}ms`); +}); + +Deno.test('Tree - width safety (many children)', () => { + const N = 100_000; + const tree = new Tree(); + const root = tree.setRoot(-1); + for (let i = 0; i < N; i++) tree.addChild(root, i); + assertEquals(tree.size, N + 1); + assertEquals(tree.height, 1); + assertEquals([...tree.preOrder()].length, N + 1); + assertEquals([...tree.postOrder()].length, N + 1); + assertEquals([...tree.levelOrder()].length, N + 1); + assertEquals(tree.fold((_v, kids) => kids.length), N); + assertEquals(root.children.length, N); + assertEquals(Tree.fromNested(tree.toNested()!).size, N + 1); +}); + +Deno.test('Tree - generic types', async (t) => { + await t.step('should store strings', () => { + const tree = new Tree(); + const r = tree.setRoot('root'); + tree.addChild(r, 'child'); + assertEquals(tree.toArray(), ['root', 'child']); + }); + + await t.step('should store objects by reference', () => { + const tree = new Tree<{ id: number }>(); + const obj = { id: 1 }; + const r = tree.setRoot(obj); + tree.addChild(r, { id: 2 }); + assertStrictEquals(tree.root!.value, obj); + assertEquals(tree.find((v) => v.id === 2)?.value, { id: 2 }); + }); + + await t.step('should accept null and undefined as values', () => { + const tree = new Tree(); + const r = tree.setRoot(null); + const c = tree.addChild(r, null); + tree.addChild(r, 0); + assertEquals(tree.size, 3); + assertEquals(tree.toArray(), [null, null, 0]); + assertStrictEquals(tree.find((v) => v === null), r); + assertEquals(tree.findAll((v) => v === null), [r, c]); + assertEquals(tree.toNested(), { + value: null, + children: [{ value: null }, { value: 0 }], + }); + const fromNull = Tree.fromNested({ value: null }); + assertEquals(fromNull.size, 1); + const fromUndef = Tree.fromNested({ value: undefined }); + assertEquals(fromUndef.size, 1); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 89569d0..3895a1e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,3 +81,27 @@ export interface RBNode { /** Reference to the parent node */ parent: RBNode | null; } + +/** + * @ignore + * Represents a plain nested-object description of an n-ary tree, as accepted + * by `Tree.fromNested()` and produced by `Tree.toNested()`. + * @template T The type of value stored in each node + */ +export interface NestedNode { + /** The value stored in the node */ + value: T; + /** Optional ordered list of child nodes */ + children?: NestedNode[]; +} + +/** + * @ignore + * Configuration options for an n-ary `Tree`. + */ +export interface TreeOptions { + /** Maximum number of nodes the tree may hold (positive integer, unbounded by default) */ + maxSize?: number; + /** Maximum depth any node may have, where the root has depth 0 (positive integer, unbounded by default) */ + maxDepth?: number; +} diff --git a/website/docs/api/tree.md b/website/docs/api/tree.md new file mode 100644 index 0000000..89be8bd --- /dev/null +++ b/website/docs/api/tree.md @@ -0,0 +1,352 @@ +--- +id: tree +title: Tree +sidebar_label: Tree +description: Generic n-ary tree with safe, iterative traversal and validated construction +keywords: + [tree, n-ary-tree, hierarchy, traversal, data-structure, typescript, javascript] +--- + +import InstallTabs from '@site/src/components/InstallTabs'; + +# Tree + +A generic n-ary (rooted, ordered) tree where every node can have any number of children. Ideal for hierarchies such as file systems, category trees, org charts, menus and parsed documents. Every algorithm is iterative, so the tree is safe to use with very deep or untrusted data. + +## Installation + + + +## Usage + +```typescript +import { Tree } from '@dstoolkit/data-structures'; + +const tree = new Tree(); +const root = tree.setRoot('root'); +``` + +## API Reference + +### Construction + +```typescript +// Unbounded tree +const tree = new Tree(); + +// With optional limits (positive integers) +const bounded = new Tree({ maxSize: 10_000, maxDepth: 64 }); +``` + +- `maxSize` - Maximum number of nodes in the tree +- `maxDepth` - Maximum depth of any node (the root has depth `0`) + +### Properties + +- `root: TreeNode | null` - The root node, or `null` if empty +- `size: number` - Number of nodes in the tree +- `height: number` - Edges on the longest root-to-leaf path (`-1` when empty, `0` for a single node) +- `isEmpty(): boolean` - Whether the tree is empty + +### TreeNode + +Nodes are created only by the tree and are read-only from the outside, except for their value. + +- `value: T` - The stored value (can be reassigned) +- `parent: TreeNode | null` - Parent node (`null` for the root) +- `children: ReadonlyArray>` - A frozen snapshot of the children +- `isLeaf: boolean` - Whether the node has no children +- `isRoot: boolean` - Whether the node is the root of its tree + +### Methods + +#### Building and Modifying + +```typescript +// Create the root (throws if a root already exists) - O(1) +const root = tree.setRoot('root'); + +// Append a child - O(1) +const child = tree.addChild(root, 'child'); + +// Insert a child at a position - O(k) +tree.insertChildAt(root, 0, 'first'); + +// Move a node (with its subtree) under a new parent - O(depth + k) +tree.move(child, otherNode); // append +tree.move(child, otherNode, 0); // at index + +// Remove a node and its entire subtree - O(s + k) +tree.remove(child); + +// Remove everything - O(n) +tree.clear(); +``` + +where `k` is the number of siblings and `s` is the size of the removed subtree + +#### Querying + +```typescript +// Ownership check - O(1) +tree.contains(node); // true / false + +// First match in pre-order - O(n) +const node = tree.find((value) => value === 'docs'); + +// All matches in pre-order - O(n) +const leaves = tree.findAll((_value, node) => node.isLeaf); + +// Depth of a node (root is 0) - O(depth) +tree.depth(node); + +// Values from the root down to the node - O(depth) +tree.path(node); // ['root', 'docs', 'api'] +``` + +#### Traversal + +All traversals are generators and accept an optional `from` node to traverse only that subtree. + +```typescript +// Pre-order: node, then children left to right - O(n) +[...tree.preOrder()]; + +// Post-order: children left to right, then node - O(n) +[...tree.postOrder()]; + +// Level-order (breadth-first) - O(n) +[...tree.levelOrder()]; + +// Subtree traversal +[...tree.preOrder(someNode)]; + +// Default iteration is pre-order +for (const value of tree) { + console.log(value); +} +const values = tree.toArray(); +``` + +#### Transforming + +```typescript +// Bottom-up reduction (leaves to root) - O(n) +const total = tree.fold( + (value, childResults) => value + childResults.reduce((a, b) => a + b, 0), +); + +// New tree with the same shape and mapped values - O(n) +const upper = tree.map((value) => value.toUpperCase()); + +// New tree keeping nodes whose value AND all ancestors pass - O(n) +const visible = tree.filter((value) => !value.startsWith('.')); +``` + +`filter` drops a failing node together with its whole subtree (descendants of a failing node are not tested). If the root fails, the result is empty. + +#### Nested Objects + +```typescript +// Build from a plain { value, children? } object - O(n) +const fromData = Tree.fromNested({ + value: 'root', + children: [{ value: 'a', children: [{ value: 'a1' }] }, { value: 'b' }], +}); + +// Convert back (leaves have no `children` key) - O(n) +const nested = fromData.toNested(); +``` + +## Examples + +### File System Paths + +```typescript +const fs = new Tree(); +const root = fs.setRoot('/'); +const home = fs.addChild(root, 'home'); +const user = fs.addChild(home, 'alice'); +const docs = fs.addChild(user, 'docs'); +fs.addChild(root, 'etc'); + +console.log(fs.path(docs).join('/').replace('//', '/')); // "/home/alice/docs" +console.log(fs.depth(docs)); // 3 +console.log([...fs.levelOrder()]); // ['/', 'home', 'etc', 'alice', 'docs'] +``` + +### Summing Values up a Hierarchy + +```typescript +interface Item { + name: string; + cost: number; +} + +const budget = new Tree(); +const total = budget.setRoot({ name: 'Total', cost: 0 }); +const travel = budget.addChild(total, { name: 'Travel', cost: 0 }); +budget.addChild(travel, { name: 'Flights', cost: 1200 }); +budget.addChild(travel, { name: 'Hotels', cost: 800 }); +budget.addChild(total, { name: 'Equipment', cost: 500 }); + +const sum = budget.fold( + (item, children) => item.cost + children.reduce((a, b) => a + b, 0), +); +console.log(sum); // 2500 + +const travelOnly = budget.fold( + (item, children) => item.cost + children.reduce((a, b) => a + b, 0), + travel, +); +console.log(travelOnly); // 2000 +``` + +### Rendering an Indented Outline + +```typescript +const outline = Tree.fromNested({ + value: 'Guide', + children: [ + { value: 'Install', children: [{ value: 'npm' }, { value: 'Deno' }] }, + { value: 'Usage' }, + ], +}); + +for (const node of outline.findAll(() => true)) { + console.log(' '.repeat(outline.depth(node)) + node.value); +} +// Guide +// Install +// npm +// Deno +// Usage +``` + +## Error Handling + +```typescript +import { + EmptyStructureError, + IndexOutOfBoundsError, + InvalidOperationError, + Tree, +} from '@dstoolkit/data-structures'; + +const tree = new Tree(); + +try { + tree.fold(() => 0); // Empty tree +} catch (error) { + if (error instanceof EmptyStructureError) { + console.log('Tree is empty'); + } +} + +const root = tree.setRoot(1); +try { + tree.insertChildAt(root, 5, 2); // Only index 0 is valid +} catch (error) { + if (error instanceof IndexOutOfBoundsError) { + console.log('Invalid index'); + } +} + +const other = new Tree(); +other.setRoot(0); +try { + other.addChild(root, 2); // root belongs to `tree`, not `other` +} catch (error) { + if (error instanceof InvalidOperationError) { + console.log('Node does not belong to this tree'); + } +} + +// Queries that can miss never throw +console.log(tree.find((v) => v === 42)); // null +console.log(tree.findAll((v) => v === 42)); // [] +``` + +| Error | Thrown when | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| `EmptyStructureError` | `fold()` is called on an empty tree without a `from` node | +| `IndexOutOfBoundsError` | `insertChildAt()` or `move()` receives an index outside the valid range | +| `InvalidOperationError` | Wrong owner, detached node, root already set, cycle in `move`, invalid input/options, limits | +| `InvalidOperationError` | A traversal continues after the tree was structurally modified (see Safety Guarantees below) | + +## Safety Guarantees + +`Tree` is built to hold arbitrary and untrusted data safely. + +- **No recursion.** Every traversal, `fold`, `height`, `map`, `filter`, `find`, `fromNested` and `toNested` uses an explicit stack or queue. A tree that is hundreds of thousands of levels deep will not overflow the call stack. +- **Ownership validation.** Every node remembers which tree owns it. Passing a node from another tree throws `InvalidOperationError`. Removed nodes (and their descendants) become _detached_ and are rejected by every tree. `contains(node)` is a constant-time ownership check. +- **Read-only structure.** `node.children` returns a frozen snapshot, never the internal array. Structure can only change through `Tree` methods. +- **Cycle prevention.** `move(node, newParent)` throws if `newParent` is `node` itself or one of its descendants. +- **Untrusted input in `fromNested`.** Each input node must be a non-null object with its own `value` property, and `children` (if present) must be an array. Only `value` and `children` are read, so keys like `__proto__` or `constructor` cannot pollute prototypes. Cycles and shared references (the same object appearing twice) are detected and rejected instead of looping forever. +- **Optional bounds.** `maxSize` and `maxDepth` are enforced by `addChild`, `insertChildAt`, `move` and `fromNested`. `fromNested` checks them while building, so oversized input fails fast. +- **Fail-fast iterators.** Structural changes (`setRoot`, `addChild`, `insertChildAt`, `remove`, `move`, `clear`) invalidate running traversals; the next step throws `InvalidOperationError`. Callbacks passed to `find`, `findAll`, `fold`, `map` and `filter` that modify the tree throw in the same way. Assigning `node.value` is not a structural change. + +:::tip +Use `maxSize` and `maxDepth` whenever a tree is built from user-supplied data to put a hard cap on memory use. +::: + +## Performance Characteristics + +| Operation | Time Complexity | Extra Space | Description | +| ------------------------------------------ | --------------- | ----------- | ------------------------------------ | +| `setRoot()` | O(1) | O(1) | Create the root | +| `addChild()` | O(1)\* | O(1) | Append a child | +| `insertChildAt()` | O(k) | O(1) | Insert a child at an index | +| `remove()` | O(s + k) | O(s) | Remove a node and its subtree | +| `move()` | O(depth + k)\* | O(1) | Move a subtree (with cycle check) | +| `contains()` | O(1) | O(1) | Ownership check | +| `depth()` / `path()` | O(depth) | O(1) / O(d) | Depth / values from root | +| `find()` / `findAll()` | O(n) | O(n) | Pre-order search | +| `preOrder()` | O(n) | O(n) | Explicit stack (O(h) for narrow trees) | +| `postOrder()` | O(n) | O(h) | Explicit stack | +| `levelOrder()` | O(n) | O(w) | Queue | +| `fold()` | O(n) | O(h + k) | Bottom-up reduction | +| `height` | O(n) | O(n) | Longest root-to-leaf path | +| `map()` / `filter()` | O(n) | O(n) | Build a new tree | +| `fromNested()` / `toNested()` | O(n) | O(n) | Convert from/to nested objects | +| `clear()` | O(n) | O(n) | Detach all nodes | + +where: + +- `n` = number of nodes +- `h` = height of the tree +- `w` = maximum width (nodes on one level) +- `k` = number of siblings +- `s` = size of the removed subtree + +\* When `maxDepth` is set, `addChild`/`insertChildAt` also pay O(depth), and `move` also pays O(size of the moved subtree), to check the limit. + +:::info When to Use Tree +Perfect for: + +- **File systems and folders** - Paths and breadcrumbs with `path()` +- **Category and menu hierarchies** - Ordered children and subtree moves +- **Org charts and budgets** - Roll up values with `fold()` +- **Parsed documents** - Outlines, ASTs and nested JSON structures +::: + +:::warning When to Avoid +Consider alternatives when: + +- **You need sorted values** → Use [RedBlackTree](./red-black-tree) +- **You need prefix lookups on strings** → Use [Trie](./trie) +- **You need key-value lookups** → Use [SortedMap](./sorted-map) or `Map` +::: + +## See Also + +### Related Examples + +- [File System Explorer](../examples/tree-file-system.md) +- [Budget Rollup](../examples/tree-budget-rollup.md) + +### Other Data Structures + +- [Trie](./trie.md) - Prefix tree for string keys +- [RedBlackTree](./red-black-tree.md) - Sorted unique values +- [Queue](./queue.md) - Used internally for level-order traversal diff --git a/website/docs/examples/tree-budget-rollup.md b/website/docs/examples/tree-budget-rollup.md new file mode 100644 index 0000000..cb21a5e --- /dev/null +++ b/website/docs/examples/tree-budget-rollup.md @@ -0,0 +1,120 @@ +--- +id: tree-budget-rollup +title: Budget Rollup +sidebar_label: Budget Rollup +description: Roll up costs through a hierarchy of departments with Tree.fold() +keywords: [tree, n-ary-tree, fold, rollup, hierarchy, org-chart, example] +--- + +# Budget Rollup with Tree + +Sum a numeric field from the leaves of a hierarchy up to the root using `fold()`, a bottom-up reduction. + +## Implementation + +```typescript +import { Tree } from '@dstoolkit/data-structures'; + +interface Unit { + name: string; + spend: number; // direct spend of this unit +} + +const org = Tree.fromNested({ + value: { name: 'Company', spend: 10_000 }, + children: [ + { + value: { name: 'Engineering', spend: 5_000 }, + children: [ + { value: { name: 'Platform', spend: 40_000 } }, + { value: { name: 'Mobile', spend: 25_000 } }, + ], + }, + { + value: { name: 'Operations', spend: 2_000 }, + children: [{ value: { name: 'Support', spend: 15_000 } }], + }, + ], +}); + +// Total spend: each unit's own spend plus everything below it +const total = org.fold( + (unit, children) => unit.spend + children.reduce((a, b) => a + b, 0), +); +console.log(total); // 97000 +``` + +## Rolled-up Report + +Build a new tree in which every unit carries its rolled-up total. + +```typescript +interface Rollup { + name: string; + total: number; + children: Rollup[]; +} + +const report = org.fold((unit, children) => ({ + name: unit.name, + total: unit.spend + children.reduce((sum, c) => sum + c.total, 0), + children, +})); + +console.log(report.children.map((c) => `${c.name}: ${c.total}`)); +// ["Engineering: 70000", "Operations: 17000"] +``` + +## Rollup for One Branch + +Pass a node as the second argument to fold only that subtree. + +```typescript +const engineering = org.find((u) => u.name === 'Engineering')!; + +const engTotal = org.fold( + (unit, children) => unit.spend + children.reduce((a, b) => a + b, 0), + engineering, +); +console.log(engTotal); // 70000 +``` + +## Other Aggregates + +```typescript +// Headcount of units (number of nodes) +const units = org.fold((_u, c) => 1 + c.reduce((a, b) => a + b, 0)); +console.log(units); // 6 + +// Largest single direct spend +const maxSpend = org.fold((u, c) => Math.max(u.spend, ...c)); +console.log(maxSpend); // 40000 + +// Units with no sub-units +console.log(org.findAll((_u, node) => node.isLeaf).map((n) => n.value.name)); +// ["Platform", "Mobile", "Support"] +``` + +## Reorganizing + +```typescript +const support = org.find((u) => u.name === 'Support')!; + +// Move Support under Engineering and re-run the rollup +org.move(support, engineering); +console.log( + org.fold( + (u, c) => u.spend + c.reduce((a, b) => a + b, 0), + engineering, + ), +); // 85000 +``` + +:::info Why fold? +`fold` visits every node exactly once, after all of its children, so each node sees its children's results. It is iterative, so it works on hierarchies of any depth. +::: + +## See Also + +- [Tree API Reference](../api/tree.md) +- [File System Explorer](./tree-file-system.md) diff --git a/website/docs/examples/tree-file-system.md b/website/docs/examples/tree-file-system.md new file mode 100644 index 0000000..b67cd98 --- /dev/null +++ b/website/docs/examples/tree-file-system.md @@ -0,0 +1,123 @@ +--- +id: tree-file-system +title: File System Explorer +sidebar_label: File System Explorer +description: Model folders and files with Tree, render breadcrumbs with path() and compute folder sizes with fold() +keywords: [tree, n-ary-tree, file-system, breadcrumbs, example] +--- + +# File System Explorer with Tree + +Model a folder hierarchy with `Tree`, then build breadcrumbs, search for files and move folders around safely. + +## Implementation + +```typescript +import { Tree, TreeNode } from '@dstoolkit/data-structures'; + +interface Entry { + name: string; + kind: 'folder' | 'file'; + bytes: number; +} + +const fs = new Tree(); +const root = fs.setRoot({ name: '', kind: 'folder', bytes: 0 }); + +function mkdir(parent: TreeNode, name: string): TreeNode { + return fs.addChild(parent, { name, kind: 'folder', bytes: 0 }); +} + +function touch(parent: TreeNode, name: string, bytes: number) { + return fs.addChild(parent, { name, kind: 'file', bytes }); +} + +const projects = mkdir(root, 'projects'); +const site = mkdir(projects, 'website'); +touch(site, 'index.html', 2_048); +touch(site, 'styles.css', 1_024); +const photos = mkdir(root, 'photos'); +const report = touch(projects, 'report.pdf', 50_000); +touch(photos, 'beach.jpg', 300_000); +``` + +## Breadcrumbs with `path()` + +```typescript +function breadcrumb(node: TreeNode): string { + return '/' + fs.path(node).slice(1).map((e) => e.name).join('/'); +} + +const css = fs.find((e) => e.name === 'styles.css')!; +console.log(breadcrumb(css)); // "/projects/website/styles.css" +console.log(breadcrumb(report)); // "/projects/report.pdf" +``` + +## Folder Sizes with `fold()` + +```typescript +function sizeOf(node: TreeNode): number { + return fs.fold( + (entry, children) => entry.bytes + children.reduce((a, b) => a + b, 0), + node, + ); +} + +console.log(sizeOf(site)); // 3072 +console.log(sizeOf(projects)); // 53072 +console.log(sizeOf(root)); // 353072 +``` + +## Searching + +```typescript +// All files larger than 10 KB +const large = fs + .findAll((e) => e.kind === 'file' && e.bytes > 10_000) + .map(breadcrumb); +console.log(large); // ["/projects/report.pdf", "/photos/beach.jpg"] + +// Directory listing, folders first +function ls(folder: TreeNode): string[] { + return [...folder.children] + .sort((a, b) => b.value.kind.localeCompare(a.value.kind)) + .map((n) => n.value.name); +} +console.log(ls(projects)); // ["website", "report.pdf"] +``` + +## Moving Folders Safely + +```typescript +// Move the website folder under photos +fs.move(site, photos); +console.log(breadcrumb(css)); // "/photos/website/styles.css" + +// Moving a folder into its own subfolder is rejected +try { + fs.move(photos, site); +} catch (error) { + console.log((error as Error).message); + // "Cannot move a node under itself or one of its descendants" +} +``` + +## Loading from JSON + +```typescript +// Data from an API or file is validated while it is loaded +const data = JSON.parse(await Deno.readTextFile('./tree.json')); +const loaded = Tree.fromNested(data, { maxSize: 100_000, maxDepth: 64 }); + +// ...and can be saved back in the same shape +const json = JSON.stringify(loaded.toNested()); +``` + +:::tip +Always pass `maxSize` / `maxDepth` when loading untrusted data. Oversized or malformed input is rejected with an `InvalidOperationError` before the whole structure is built. +::: + +## See Also + +- [Tree API Reference](../api/tree.md) +- [Budget Rollup](./tree-budget-rollup.md) diff --git a/website/docs/intro.md b/website/docs/intro.md index 2f1e401..4a3c122 100644 --- a/website/docs/intro.md +++ b/website/docs/intro.md @@ -13,7 +13,7 @@ A comprehensive collection of type-safe, zero-dependency data structure implemen - **🎯 Type Safety**: Full TypeScript support with generics for complete type safety - **📦 Zero Dependencies**: No external dependencies - lightweight and secure - **⚡ Performance**: Optimized implementations with documented time complexities -- **🧪 Well Tested**: Comprehensive test coverage (>85%) +- **🧪 Well Tested**: Comprehensive test coverage (~100%) - **🌲 Tree Shakeable**: Import only what you need - minimal bundle impact - **📚 Well Documented**: Extensive documentation with examples - **🔄 Dual Published**: Available on both JSR and npm for maximum compatibility @@ -99,7 +99,7 @@ console.log(cache.get('key')); // 42 - **GitHub**: [mandy8055/data-structures](https://github.com/mandy8055/data-structures) - **Issues**: [Report bugs or request features](https://github.com/mandy8055/data-structures/issues) -- **Discussions**: [Join the community](https://github.com/mandy8055/data-structures/discussions) +- **Discussions**: [Have idea! Let's discuss](https://github.com/mandy8055/data-structures/discussions) ## License diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/api/tree.md b/website/i18n/de/docusaurus-plugin-content-docs/current/api/tree.md new file mode 100644 index 0000000..7bad20f --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/api/tree.md @@ -0,0 +1,352 @@ +--- +id: tree +title: Tree +sidebar_label: Tree +description: Generischer n-ärer Baum mit sicherer, iterativer Traversierung und validiertem Aufbau +keywords: + [tree, n-ary-tree, hierarchy, traversal, data-structure, typescript, javascript] +--- + +import InstallTabs from '@site/src/components/InstallTabs'; + +# Tree + +Ein generischer n-ärer (verwurzelter, geordneter) Baum, bei dem jeder Knoten eine beliebige Anzahl von Kindern haben kann. Ideal für Hierarchien wie Dateisysteme, Kategoriebäume, Organigramme, Menüs und geparste Dokumente. Jeder Algorithmus ist iterativ, sodass der Baum auch bei sehr tiefen oder nicht vertrauenswürdigen Daten sicher verwendet werden kann. + +## Installation + + + +## Verwendung + +```typescript +import { Tree } from '@dstoolkit/data-structures'; + +const tree = new Tree(); +const root = tree.setRoot('root'); +``` + +## API-Referenz + +### Konstruktion + +```typescript +// Unbeschränkter Baum +const tree = new Tree(); + +// Mit optionalen Grenzwerten (positive Ganzzahlen) +const bounded = new Tree({ maxSize: 10_000, maxDepth: 64 }); +``` + +- `maxSize` - Maximale Anzahl von Knoten im Baum +- `maxDepth` - Maximale Tiefe eines Knotens (die Wurzel hat Tiefe `0`) + +### Eigenschaften + +- `root: TreeNode | null` - Der Wurzelknoten, oder `null` wenn leer +- `size: number` - Anzahl der Knoten im Baum +- `height: number` - Kanten auf dem längsten Pfad von Wurzel zu Blatt (`-1` wenn leer, `0` für einen einzelnen Knoten) +- `isEmpty(): boolean` - Ob der Baum leer ist + +### TreeNode + +Knoten werden nur vom Baum erstellt und sind von außen nur lesbar, mit Ausnahme ihres Werts. + +- `value: T` - Der gespeicherte Wert (kann neu zugewiesen werden) +- `parent: TreeNode | null` - Elternknoten (`null` für die Wurzel) +- `children: ReadonlyArray>` - Eine eingefrorene Momentaufnahme der Kinder +- `isLeaf: boolean` - Ob der Knoten keine Kinder hat +- `isRoot: boolean` - Ob der Knoten die Wurzel seines Baums ist + +### Methoden + +#### Aufbauen und Verändern + +```typescript +// Wurzel erstellen (wirft Fehler, wenn bereits eine Wurzel existiert) - O(1) +const root = tree.setRoot('root'); + +// Ein Kind anhängen - O(1) +const child = tree.addChild(root, 'child'); + +// Ein Kind an einer Position einfügen - O(k) +tree.insertChildAt(root, 0, 'first'); + +// Einen Knoten (mit seinem Teilbaum) unter einen neuen Elternknoten verschieben - O(depth + k) +tree.move(child, otherNode); // anhängen +tree.move(child, otherNode, 0); // an einem Index + +// Einen Knoten und seinen gesamten Teilbaum entfernen - O(s + k) +tree.remove(child); + +// Alles entfernen - O(n) +tree.clear(); +``` + +wobei `k` die Anzahl der Geschwister und `s` die Größe des entfernten Teilbaums ist + +#### Abfragen + +```typescript +// Besitzprüfung - O(1) +tree.contains(node); // true / false + +// Erste Übereinstimmung in Pre-Order - O(n) +const node = tree.find((value) => value === 'docs'); + +// Alle Übereinstimmungen in Pre-Order - O(n) +const leaves = tree.findAll((_value, node) => node.isLeaf); + +// Tiefe eines Knotens (Wurzel ist 0) - O(depth) +tree.depth(node); + +// Werte von der Wurzel bis zum Knoten - O(depth) +tree.path(node); // ['root', 'docs', 'api'] +``` + +#### Traversierung + +Alle Traversierungen sind Generatoren und akzeptieren einen optionalen `from`-Knoten, um nur diesen Teilbaum zu durchlaufen. + +```typescript +// Pre-Order: Knoten, dann Kinder von links nach rechts - O(n) +[...tree.preOrder()]; + +// Post-Order: Kinder von links nach rechts, dann Knoten - O(n) +[...tree.postOrder()]; + +// Level-Order (Breitensuche) - O(n) +[...tree.levelOrder()]; + +// Teilbaum-Traversierung +[...tree.preOrder(someNode)]; + +// Standard-Iteration ist Pre-Order +for (const value of tree) { + console.log(value); +} +const values = tree.toArray(); +``` + +#### Transformieren + +```typescript +// Bottom-up-Reduktion (von den Blättern zur Wurzel) - O(n) +const total = tree.fold( + (value, childResults) => value + childResults.reduce((a, b) => a + b, 0), +); + +// Neuer Baum mit derselben Struktur und abgebildeten Werten - O(n) +const upper = tree.map((value) => value.toUpperCase()); + +// Neuer Baum, der Knoten behält, deren Wert UND alle Vorfahren bestehen - O(n) +const visible = tree.filter((value) => !value.startsWith('.')); +``` + +`filter` verwirft einen fehlschlagenden Knoten zusammen mit seinem gesamten Teilbaum (Nachkommen eines fehlschlagenden Knotens werden nicht getestet). Wenn die Wurzel fehlschlägt, ist das Ergebnis leer. + +#### Verschachtelte Objekte + +```typescript +// Aus einem einfachen { value, children? }-Objekt aufbauen - O(n) +const fromData = Tree.fromNested({ + value: 'root', + children: [{ value: 'a', children: [{ value: 'a1' }] }, { value: 'b' }], +}); + +// Zurückkonvertieren (Blätter haben keinen `children`-Schlüssel) - O(n) +const nested = fromData.toNested(); +``` + +## Beispiele + +### Dateisystempfade + +```typescript +const fs = new Tree(); +const root = fs.setRoot('/'); +const home = fs.addChild(root, 'home'); +const user = fs.addChild(home, 'alice'); +const docs = fs.addChild(user, 'docs'); +fs.addChild(root, 'etc'); + +console.log(fs.path(docs).join('/').replace('//', '/')); // "/home/alice/docs" +console.log(fs.depth(docs)); // 3 +console.log([...fs.levelOrder()]); // ['/', 'home', 'etc', 'alice', 'docs'] +``` + +### Werte in einer Hierarchie aufsummieren + +```typescript +interface Item { + name: string; + cost: number; +} + +const budget = new Tree(); +const total = budget.setRoot({ name: 'Total', cost: 0 }); +const travel = budget.addChild(total, { name: 'Travel', cost: 0 }); +budget.addChild(travel, { name: 'Flights', cost: 1200 }); +budget.addChild(travel, { name: 'Hotels', cost: 800 }); +budget.addChild(total, { name: 'Equipment', cost: 500 }); + +const sum = budget.fold( + (item, children) => item.cost + children.reduce((a, b) => a + b, 0), +); +console.log(sum); // 2500 + +const travelOnly = budget.fold( + (item, children) => item.cost + children.reduce((a, b) => a + b, 0), + travel, +); +console.log(travelOnly); // 2000 +``` + +### Eine eingerückte Gliederung rendern + +```typescript +const outline = Tree.fromNested({ + value: 'Guide', + children: [ + { value: 'Install', children: [{ value: 'npm' }, { value: 'Deno' }] }, + { value: 'Usage' }, + ], +}); + +for (const node of outline.findAll(() => true)) { + console.log(' '.repeat(outline.depth(node)) + node.value); +} +// Guide +// Install +// npm +// Deno +// Usage +``` + +## Fehlerbehandlung + +```typescript +import { + EmptyStructureError, + IndexOutOfBoundsError, + InvalidOperationError, + Tree, +} from '@dstoolkit/data-structures'; + +const tree = new Tree(); + +try { + tree.fold(() => 0); // Leerer Baum +} catch (error) { + if (error instanceof EmptyStructureError) { + console.log('Baum ist leer'); + } +} + +const root = tree.setRoot(1); +try { + tree.insertChildAt(root, 5, 2); // Nur Index 0 ist gültig +} catch (error) { + if (error instanceof IndexOutOfBoundsError) { + console.log('Ungültiger Index'); + } +} + +const other = new Tree(); +other.setRoot(0); +try { + other.addChild(root, 2); // root gehört zu `tree`, nicht zu `other` +} catch (error) { + if (error instanceof InvalidOperationError) { + console.log('Knoten gehört nicht zu diesem Baum'); + } +} + +// Abfragen, die fehlschlagen können, werfen nie einen Fehler +console.log(tree.find((v) => v === 42)); // null +console.log(tree.findAll((v) => v === 42)); // [] +``` + +| Fehler | Wird ausgelöst, wenn | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| `EmptyStructureError` | `fold()` auf einem leeren Baum ohne `from`-Knoten aufgerufen wird | +| `IndexOutOfBoundsError` | `insertChildAt()` oder `move()` einen Index außerhalb des gültigen Bereichs erhält | +| `InvalidOperationError` | Falscher Besitzer, abgetrennter Knoten, Wurzel bereits gesetzt, Zyklus in `move`, ungültige Eingabe/Optionen, Grenzwerte | +| `InvalidOperationError` | Eine Traversierung wird fortgesetzt, nachdem der Baum strukturell verändert wurde (siehe Sicherheitsgarantien unten) | + +## Sicherheitsgarantien + +`Tree` ist darauf ausgelegt, beliebige und nicht vertrauenswürdige Daten sicher zu speichern. + +- **Keine Rekursion.** Jede Traversierung, `fold`, `height`, `map`, `filter`, `find`, `fromNested` und `toNested` verwendet einen expliziten Stack oder eine Queue. Ein Baum mit hunderttausenden Ebenen führt nicht zu einem Stack-Überlauf. +- **Besitzvalidierung.** Jeder Knoten merkt sich, welcher Baum ihn besitzt. Die Übergabe eines Knotens aus einem anderen Baum wirft `InvalidOperationError`. Entfernte Knoten (und ihre Nachkommen) werden _abgetrennt_ und von jedem Baum zurückgewiesen. `contains(node)` ist eine Besitzprüfung mit konstanter Laufzeit. +- **Nur lesbare Struktur.** `node.children` gibt eine eingefrorene Momentaufnahme zurück, niemals das interne Array. Die Struktur kann nur über `Tree`-Methoden verändert werden. +- **Zyklusvermeidung.** `move(node, newParent)` wirft einen Fehler, wenn `newParent` der Knoten `node` selbst oder einer seiner Nachkommen ist. +- **Nicht vertrauenswürdige Eingabe in `fromNested`.** Jeder Eingabeknoten muss ein nicht-null Objekt mit einer eigenen `value`-Eigenschaft sein, und `children` (falls vorhanden) muss ein Array sein. Es werden nur `value` und `children` gelesen, sodass Schlüssel wie `__proto__` oder `constructor` keine Prototypen verschmutzen können. Zyklen und gemeinsame Referenzen (dasselbe Objekt tritt zweimal auf) werden erkannt und zurückgewiesen, anstatt in eine Endlosschleife zu laufen. +- **Optionale Grenzwerte.** `maxSize` und `maxDepth` werden von `addChild`, `insertChildAt`, `move` und `fromNested` erzwungen. `fromNested` prüft sie während des Aufbaus, sodass übergroße Eingaben sofort fehlschlagen. +- **Fail-Fast-Iteratoren.** Strukturelle Änderungen (`setRoot`, `addChild`, `insertChildAt`, `remove`, `move`, `clear`) machen laufende Traversierungen ungültig; der nächste Schritt wirft `InvalidOperationError`. Callbacks, die an `find`, `findAll`, `fold`, `map` und `filter` übergeben werden und den Baum verändern, werfen auf die gleiche Weise einen Fehler. Die Zuweisung von `node.value` ist keine strukturelle Änderung. + +:::tip +Verwenden Sie `maxSize` und `maxDepth`, wann immer ein Baum aus benutzerdefinierten Daten aufgebaut wird, um eine feste Obergrenze für den Speicherverbrauch festzulegen. +::: + +## Leistungsmerkmale + +| Operation | Zeitkomplexität | Zusätzlicher Speicher | Beschreibung | +| ------------------------------------------ | ---------------- | ---------------------- | ------------------------------------- | +| `setRoot()` | O(1) | O(1) | Wurzel erstellen | +| `addChild()` | O(1)\* | O(1) | Ein Kind anhängen | +| `insertChildAt()` | O(k) | O(1) | Ein Kind an einem Index einfügen | +| `remove()` | O(s + k) | O(s) | Knoten und Teilbaum entfernen | +| `move()` | O(depth + k)\* | O(1) | Teilbaum verschieben (mit Zyklusprüfung) | +| `contains()` | O(1) | O(1) | Besitzprüfung | +| `depth()` / `path()` | O(depth) | O(1) / O(d) | Tiefe / Werte von der Wurzel | +| `find()` / `findAll()` | O(n) | O(n) | Pre-Order-Suche | +| `preOrder()` | O(n) | O(n) | Expliziter Stack (O(h) bei schmalen Bäumen) | +| `postOrder()` | O(n) | O(h) | Expliziter Stack | +| `levelOrder()` | O(n) | O(w) | Queue | +| `fold()` | O(n) | O(h + k) | Bottom-up-Reduktion | +| `height` | O(n) | O(n) | Längster Pfad von Wurzel zu Blatt | +| `map()` / `filter()` | O(n) | O(n) | Einen neuen Baum aufbauen | +| `fromNested()` / `toNested()` | O(n) | O(n) | Umwandlung von/zu verschachtelten Objekten | +| `clear()` | O(n) | O(n) | Alle Knoten abtrennen | + +wobei: + +- `n` = Anzahl der Knoten +- `h` = Höhe des Baums +- `w` = maximale Breite (Knoten auf einer Ebene) +- `k` = Anzahl der Geschwister +- `s` = Größe des entfernten Teilbaums + +\* Wenn `maxDepth` gesetzt ist, zahlen `addChild`/`insertChildAt` zusätzlich O(depth), und `move` zahlt zusätzlich O(Größe des verschobenen Teilbaums), um den Grenzwert zu prüfen. + +:::info Wann Tree verwenden +Perfekt für: + +- **Dateisysteme und Ordner** - Pfade und Breadcrumbs mit `path()` +- **Kategorie- und Menühierarchien** - Geordnete Kinder und Teilbaum-Verschiebungen +- **Organigramme und Budgets** - Werte mit `fold()` aggregieren +- **Geparste Dokumente** - Gliederungen, ASTs und verschachtelte JSON-Strukturen +::: + +:::warning Wann vermeiden +Alternativen in Betracht ziehen, wenn: + +- **Sie sortierte Werte benötigen** → Verwenden Sie [RedBlackTree](./red-black-tree) +- **Sie Präfix-Lookups auf Strings benötigen** → Verwenden Sie [Trie](./trie) +- **Sie Schlüssel-Wert-Lookups benötigen** → Verwenden Sie [SortedMap](./sorted-map) oder `Map` +::: + +## Siehe auch + +### Verwandte Beispiele + +- [Datei-System-Explorer](../examples/tree-file-system.md) +- [Budget-Rollup](../examples/tree-budget-rollup.md) + +### Andere Datenstrukturen + +- [Trie](./trie.md) - Präfixbaum für String-Schlüssel +- [RedBlackTree](./red-black-tree.md) - Sortierte eindeutige Werte +- [Queue](./queue.md) - Wird intern für die Level-Order-Traversierung verwendet diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md b/website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md new file mode 100644 index 0000000..dad8184 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md @@ -0,0 +1,120 @@ +--- +id: tree-budget-rollup +title: Budget Rollup +sidebar_label: Budget Rollup +description: Kosten durch eine Hierarchie von Abteilungen mit Tree.fold() aggregieren +keywords: [tree, n-ary-tree, fold, rollup, hierarchy, org-chart, example] +--- + +# Budget-Rollup mit Tree + +Summieren Sie ein numerisches Feld von den Blättern einer Hierarchie bis zur Wurzel mit `fold()`, einer Bottom-up-Reduktion. + +## Implementierung + +```typescript +import { Tree } from '@dstoolkit/data-structures'; + +interface Unit { + name: string; + spend: number; // direkte Ausgaben dieser Einheit +} + +const org = Tree.fromNested({ + value: { name: 'Company', spend: 10_000 }, + children: [ + { + value: { name: 'Engineering', spend: 5_000 }, + children: [ + { value: { name: 'Platform', spend: 40_000 } }, + { value: { name: 'Mobile', spend: 25_000 } }, + ], + }, + { + value: { name: 'Operations', spend: 2_000 }, + children: [{ value: { name: 'Support', spend: 15_000 } }], + }, + ], +}); + +// Gesamtausgaben: die eigenen Ausgaben jeder Einheit plus alles darunter +const total = org.fold( + (unit, children) => unit.spend + children.reduce((a, b) => a + b, 0), +); +console.log(total); // 97000 +``` + +## Aggregierter Bericht + +Erstellen Sie einen neuen Baum, in dem jede Einheit ihre aggregierte Gesamtsumme trägt. + +```typescript +interface Rollup { + name: string; + total: number; + children: Rollup[]; +} + +const report = org.fold((unit, children) => ({ + name: unit.name, + total: unit.spend + children.reduce((sum, c) => sum + c.total, 0), + children, +})); + +console.log(report.children.map((c) => `${c.name}: ${c.total}`)); +// ["Engineering: 70000", "Operations: 17000"] +``` + +## Rollup für einen einzelnen Zweig + +Übergeben Sie einen Knoten als zweites Argument, um nur diesen Teilbaum zu aggregieren. + +```typescript +const engineering = org.find((u) => u.name === 'Engineering')!; + +const engTotal = org.fold( + (unit, children) => unit.spend + children.reduce((a, b) => a + b, 0), + engineering, +); +console.log(engTotal); // 70000 +``` + +## Weitere Aggregate + +```typescript +// Anzahl der Einheiten (Anzahl der Knoten) +const units = org.fold((_u, c) => 1 + c.reduce((a, b) => a + b, 0)); +console.log(units); // 6 + +// Größte einzelne direkte Ausgabe +const maxSpend = org.fold((u, c) => Math.max(u.spend, ...c)); +console.log(maxSpend); // 40000 + +// Einheiten ohne Untereinheiten +console.log(org.findAll((_u, node) => node.isLeaf).map((n) => n.value.name)); +// ["Platform", "Mobile", "Support"] +``` + +## Umorganisieren + +```typescript +const support = org.find((u) => u.name === 'Support')!; + +// Support unter Engineering verschieben und das Rollup neu ausführen +org.move(support, engineering); +console.log( + org.fold( + (u, c) => u.spend + c.reduce((a, b) => a + b, 0), + engineering, + ), +); // 85000 +``` + +:::info Warum fold? +`fold` besucht jeden Knoten genau einmal, nachdem alle seine Kinder besucht wurden, sodass jeder Knoten die Ergebnisse seiner Kinder sieht. Es ist iterativ implementiert und funktioniert daher bei Hierarchien beliebiger Tiefe. +::: + +## Siehe auch + +- [Tree API-Referenz](../api/tree.md) +- [Datei-System-Explorer](./tree-file-system.md) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-file-system.md b/website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-file-system.md new file mode 100644 index 0000000..f42bcf2 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/examples/tree-file-system.md @@ -0,0 +1,123 @@ +--- +id: tree-file-system +title: File System Explorer +sidebar_label: File System Explorer +description: Ordner und Dateien mit Tree modellieren, Breadcrumbs mit path() rendern und Ordnergrößen mit fold() berechnen +keywords: [tree, n-ary-tree, file-system, breadcrumbs, example] +--- + +# Datei-System-Explorer mit Tree + +Modellieren Sie eine Ordnerhierarchie mit `Tree`, erstellen Sie dann Breadcrumbs, suchen Sie nach Dateien und verschieben Sie Ordner sicher. + +## Implementierung + +```typescript +import { Tree, TreeNode } from '@dstoolkit/data-structures'; + +interface Entry { + name: string; + kind: 'folder' | 'file'; + bytes: number; +} + +const fs = new Tree(); +const root = fs.setRoot({ name: '', kind: 'folder', bytes: 0 }); + +function mkdir(parent: TreeNode, name: string): TreeNode { + return fs.addChild(parent, { name, kind: 'folder', bytes: 0 }); +} + +function touch(parent: TreeNode, name: string, bytes: number) { + return fs.addChild(parent, { name, kind: 'file', bytes }); +} + +const projects = mkdir(root, 'projects'); +const site = mkdir(projects, 'website'); +touch(site, 'index.html', 2_048); +touch(site, 'styles.css', 1_024); +const photos = mkdir(root, 'photos'); +const report = touch(projects, 'report.pdf', 50_000); +touch(photos, 'beach.jpg', 300_000); +``` + +## Breadcrumbs mit `path()` + +```typescript +function breadcrumb(node: TreeNode): string { + return '/' + fs.path(node).slice(1).map((e) => e.name).join('/'); +} + +const css = fs.find((e) => e.name === 'styles.css')!; +console.log(breadcrumb(css)); // "/projects/website/styles.css" +console.log(breadcrumb(report)); // "/projects/report.pdf" +``` + +## Ordnergrößen mit `fold()` + +```typescript +function sizeOf(node: TreeNode): number { + return fs.fold( + (entry, children) => entry.bytes + children.reduce((a, b) => a + b, 0), + node, + ); +} + +console.log(sizeOf(site)); // 3072 +console.log(sizeOf(projects)); // 53072 +console.log(sizeOf(root)); // 353072 +``` + +## Suche + +```typescript +// Alle Dateien größer als 10 KB +const large = fs + .findAll((e) => e.kind === 'file' && e.bytes > 10_000) + .map(breadcrumb); +console.log(large); // ["/projects/report.pdf", "/photos/beach.jpg"] + +// Verzeichnisauflistung, Ordner zuerst +function ls(folder: TreeNode): string[] { + return [...folder.children] + .sort((a, b) => b.value.kind.localeCompare(a.value.kind)) + .map((n) => n.value.name); +} +console.log(ls(projects)); // ["website", "report.pdf"] +``` + +## Ordner sicher verschieben + +```typescript +// Den website-Ordner unter photos verschieben +fs.move(site, photos); +console.log(breadcrumb(css)); // "/photos/website/styles.css" + +// Einen Ordner in seinen eigenen Unterordner zu verschieben wird abgelehnt +try { + fs.move(photos, site); +} catch (error) { + console.log((error as Error).message); + // "Cannot move a node under itself or one of its descendants" +} +``` + +## Laden aus JSON + +```typescript +// Daten von einer API oder Datei werden beim Laden validiert +const data = JSON.parse(await Deno.readTextFile('./tree.json')); +const loaded = Tree.fromNested(data, { maxSize: 100_000, maxDepth: 64 }); + +// ...und können in derselben Struktur zurückgespeichert werden +const json = JSON.stringify(loaded.toNested()); +``` + +:::tip +Übergeben Sie beim Laden nicht vertrauenswürdiger Daten immer `maxSize` / `maxDepth`. Übergroße oder fehlerhafte Eingaben werden mit einem `InvalidOperationError` zurückgewiesen, bevor die gesamte Struktur aufgebaut wird. +::: + +## Siehe auch + +- [Tree API-Referenz](../api/tree.md) +- [Budget-Rollup](./tree-budget-rollup.md) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/intro.md b/website/i18n/de/docusaurus-plugin-content-docs/current/intro.md index 0010f50..681014c 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/intro.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/intro.md @@ -13,7 +13,7 @@ Eine umfassende Sammlung typsicherer, abhängigkeitsfreier Datenstruktur-Impleme - **🎯 Typsicherheit**: Vollständige TypeScript-Unterstützung mit Generics für vollständige Typsicherheit - **📦 Null Abhängigkeiten**: Keine externen Abhängigkeiten - leichtgewichtig und sicher - **⚡ Performance**: Optimierte Implementierungen mit dokumentierten Zeitkomplexitäten -- **🧪 Gut getestet**: Umfassende Testabdeckung (>85%) +- **🧪 Gut getestet**: Umfassende Testabdeckung (~100%) - **🌲 Tree Shakeable**: Importieren Sie nur, was Sie brauchen - minimale Bundle-Auswirkung - **📚 Gut dokumentiert**: Umfangreiche Dokumentation mit Beispielen - **🔄 Dual veröffentlicht**: Verfügbar auf JSR und npm für maximale Kompatibilität @@ -99,7 +99,7 @@ console.log(cache.get('key')); // 42 - **GitHub**: [mandy8055/data-structures](https://github.com/mandy8055/data-structures) - **Issues**: [Fehler melden oder Features anfordern](https://github.com/mandy8055/data-structures/issues) -- **Discussions**: [Treten Sie der Community bei](https://github.com/mandy8055/data-structures/discussions) +- **Discussions**: [Ich habe eine Idee! Lass uns darüber reden](https://github.com/mandy8055/data-structures/discussions) ## Lizenz diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/tree.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/tree.md new file mode 100644 index 0000000..69a67b2 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/tree.md @@ -0,0 +1,352 @@ +--- +id: tree +title: Tree +sidebar_label: Tree +description: 通用 n 叉树,具有安全的迭代遍历和经过验证的构建方式 +keywords: + [tree, n-ary-tree, hierarchy, traversal, data-structure, typescript, javascript] +--- + +import InstallTabs from '@site/src/components/InstallTabs'; + +# Tree + +一种通用的 n 叉(有根、有序)树,每个节点可以有任意数量的子节点。非常适合文件系统、分类树、组织架构图、菜单和已解析文档等层级结构。所有算法都是迭代实现的,因此即使树非常深或数据不可信,也能安全使用。 + +## 安装 + + + +## 使用方法 + +```typescript +import { Tree } from '@dstoolkit/data-structures'; + +const tree = new Tree(); +const root = tree.setRoot('root'); +``` + +## API 参考 + +### 构造 + +```typescript +// 无限制的树 +const tree = new Tree(); + +// 带可选的限制(正整数) +const bounded = new Tree({ maxSize: 10_000, maxDepth: 64 }); +``` + +- `maxSize` - 树中节点的最大数量 +- `maxDepth` - 任意节点的最大深度(根节点的深度为 `0`) + +### 属性 + +- `root: TreeNode | null` - 根节点,如果为空则为 `null` +- `size: number` - 树中的节点数量 +- `height: number` - 从根到叶的最长路径上的边数(为空时为 `-1`,单个节点时为 `0`) +- `isEmpty(): boolean` - 树是否为空 + +### TreeNode + +节点只能由树创建,从外部来看是只读的,除了它的值。 + +- `value: T` - 存储的值(可以重新赋值) +- `parent: TreeNode | null` - 父节点(根节点为 `null`) +- `children: ReadonlyArray>` - 子节点的冻结快照 +- `isLeaf: boolean` - 该节点是否没有子节点 +- `isRoot: boolean` - 该节点是否是其树的根节点 + +### 方法 + +#### 构建与修改 + +```typescript +// 创建根节点(如果已存在根节点则抛出错误) - O(1) +const root = tree.setRoot('root'); + +// 追加一个子节点 - O(1) +const child = tree.addChild(root, 'child'); + +// 在指定位置插入一个子节点 - O(k) +tree.insertChildAt(root, 0, 'first'); + +// 将一个节点(及其子树)移动到新的父节点下 - O(depth + k) +tree.move(child, otherNode); // 追加 +tree.move(child, otherNode, 0); // 指定索引 + +// 移除一个节点及其整个子树 - O(s + k) +tree.remove(child); + +// 移除所有内容 - O(n) +tree.clear(); +``` + +其中 `k` 是兄弟节点的数量,`s` 是被移除子树的大小 + +#### 查询 + +```typescript +// 所有权检查 - O(1) +tree.contains(node); // true / false + +// 前序遍历中的第一个匹配项 - O(n) +const node = tree.find((value) => value === 'docs'); + +// 前序遍历中的所有匹配项 - O(n) +const leaves = tree.findAll((_value, node) => node.isLeaf); + +// 节点的深度(根节点为 0) - O(depth) +tree.depth(node); + +// 从根节点到该节点的值 - O(depth) +tree.path(node); // ['root', 'docs', 'api'] +``` + +#### 遍历 + +所有遍历都是生成器,并接受一个可选的 `from` 节点,以仅遍历该子树。 + +```typescript +// 前序遍历:节点,然后从左到右遍历子节点 - O(n) +[...tree.preOrder()]; + +// 后序遍历:从左到右遍历子节点,然后是节点 - O(n) +[...tree.postOrder()]; + +// 层序遍历(广度优先) - O(n) +[...tree.levelOrder()]; + +// 子树遍历 +[...tree.preOrder(someNode)]; + +// 默认迭代为前序遍历 +for (const value of tree) { + console.log(value); +} +const values = tree.toArray(); +``` + +#### 转换 + +```typescript +// 自底向上的归约(从叶节点到根节点) - O(n) +const total = tree.fold( + (value, childResults) => value + childResults.reduce((a, b) => a + b, 0), +); + +// 具有相同结构和映射值的新树 - O(n) +const upper = tree.map((value) => value.toUpperCase()); + +// 保留其值及所有祖先都通过检验的节点的新树 - O(n) +const visible = tree.filter((value) => !value.startsWith('.')); +``` + +`filter` 会连同其整个子树一起丢弃未通过检验的节点(未通过检验节点的后代不会被测试)。如果根节点未通过检验,结果将为空。 + +#### 嵌套对象 + +```typescript +// 从一个简单的 { value, children? } 对象构建 - O(n) +const fromData = Tree.fromNested({ + value: 'root', + children: [{ value: 'a', children: [{ value: 'a1' }] }, { value: 'b' }], +}); + +// 转换回去(叶节点没有 `children` 键) - O(n) +const nested = fromData.toNested(); +``` + +## 示例 + +### 文件系统路径 + +```typescript +const fs = new Tree(); +const root = fs.setRoot('/'); +const home = fs.addChild(root, 'home'); +const user = fs.addChild(home, 'alice'); +const docs = fs.addChild(user, 'docs'); +fs.addChild(root, 'etc'); + +console.log(fs.path(docs).join('/').replace('//', '/')); // "/home/alice/docs" +console.log(fs.depth(docs)); // 3 +console.log([...fs.levelOrder()]); // ['/', 'home', 'etc', 'alice', 'docs'] +``` + +### 在层级结构中汇总数值 + +```typescript +interface Item { + name: string; + cost: number; +} + +const budget = new Tree(); +const total = budget.setRoot({ name: 'Total', cost: 0 }); +const travel = budget.addChild(total, { name: 'Travel', cost: 0 }); +budget.addChild(travel, { name: 'Flights', cost: 1200 }); +budget.addChild(travel, { name: 'Hotels', cost: 800 }); +budget.addChild(total, { name: 'Equipment', cost: 500 }); + +const sum = budget.fold( + (item, children) => item.cost + children.reduce((a, b) => a + b, 0), +); +console.log(sum); // 2500 + +const travelOnly = budget.fold( + (item, children) => item.cost + children.reduce((a, b) => a + b, 0), + travel, +); +console.log(travelOnly); // 2000 +``` + +### 渲染带缩进的大纲 + +```typescript +const outline = Tree.fromNested({ + value: 'Guide', + children: [ + { value: 'Install', children: [{ value: 'npm' }, { value: 'Deno' }] }, + { value: 'Usage' }, + ], +}); + +for (const node of outline.findAll(() => true)) { + console.log(' '.repeat(outline.depth(node)) + node.value); +} +// Guide +// Install +// npm +// Deno +// Usage +``` + +## 错误处理 + +```typescript +import { + EmptyStructureError, + IndexOutOfBoundsError, + InvalidOperationError, + Tree, +} from '@dstoolkit/data-structures'; + +const tree = new Tree(); + +try { + tree.fold(() => 0); // 空树 +} catch (error) { + if (error instanceof EmptyStructureError) { + console.log('树为空'); + } +} + +const root = tree.setRoot(1); +try { + tree.insertChildAt(root, 5, 2); // 只有索引 0 是有效的 +} catch (error) { + if (error instanceof IndexOutOfBoundsError) { + console.log('无效的索引'); + } +} + +const other = new Tree(); +other.setRoot(0); +try { + other.addChild(root, 2); // root 属于 `tree`,不属于 `other` +} catch (error) { + if (error instanceof InvalidOperationError) { + console.log('节点不属于该树'); + } +} + +// 可能找不到结果的查询永远不会抛出错误 +console.log(tree.find((v) => v === 42)); // null +console.log(tree.findAll((v) => v === 42)); // [] +``` + +| 错误 | 抛出时机 | +| ----------------------- | ---------------------------------------------------------------------------- | +| `EmptyStructureError` | 在没有 `from` 节点的空树上调用 `fold()` | +| `IndexOutOfBoundsError` | `insertChildAt()` 或 `move()` 收到超出有效范围的索引 | +| `InvalidOperationError` | 所有者错误、节点已分离、根节点已存在、`move` 中存在循环、输入/选项无效、超出限制 | +| `InvalidOperationError` | 树结构发生变化后遍历仍在继续(参见下方的安全保证) | + +## 安全保证 + +`Tree` 旨在安全地存储任意和不可信的数据。 + +- **无递归。** 每个遍历、`fold`、`height`、`map`、`filter`、`find`、`fromNested` 和 `toNested` 都使用显式的栈或队列。即使树有数十万层深,也不会导致调用栈溢出。 +- **所有权验证。** 每个节点都记住拥有它的树。传入另一棵树的节点会抛出 `InvalidOperationError`。被移除的节点(及其后代)会变为**已分离**状态,并被所有树拒绝。`contains(node)` 是常数时间的所有权检查。 +- **只读结构。** `node.children` 返回一个冻结快照,而不是内部数组本身。结构只能通过 `Tree` 的方法进行更改。 +- **循环预防。** 如果 `newParent` 是节点 `node` 本身或其后代之一,`move(node, newParent)` 会抛出错误。 +- **`fromNested` 中的不可信输入。** 每个输入节点必须是具有自己的 `value` 属性的非 null 对象,并且 `children`(如果存在)必须是数组。只会读取 `value` 和 `children`,因此像 `__proto__` 或 `constructor` 这样的键不会污染原型。会检测并拒绝循环和共享引用(同一对象出现两次),而不是陷入无限循环。 +- **可选的限制。** `maxSize` 和 `maxDepth` 会由 `addChild`、`insertChildAt`、`move` 和 `fromNested` 强制执行。`fromNested` 在构建过程中进行检查,因此超大输入会快速失败。 +- **快速失败的迭代器。** 结构性更改(`setRoot`、`addChild`、`insertChildAt`、`remove`、`move`、`clear`)会使正在进行的遍历失效;下一步会抛出 `InvalidOperationError`。传递给 `find`、`findAll`、`fold`、`map` 和 `filter` 且修改了树的回调函数也会以同样的方式抛出错误。赋值 `node.value` 不算结构性更改。 + +:::tip +每当从用户提供的数据构建树时,请使用 `maxSize` 和 `maxDepth`,以对内存使用设置硬性上限。 +::: + +## 性能特征 + +| 操作 | 时间复杂度 | 额外空间 | 说明 | +| -------------------------------------------- | ---------------- | -------------- | ---------------------------------- | +| `setRoot()` | O(1) | O(1) | 创建根节点 | +| `addChild()` | O(1)\* | O(1) | 追加一个子节点 | +| `insertChildAt()` | O(k) | O(1) | 在指定索引处插入子节点 | +| `remove()` | O(s + k) | O(s) | 移除节点及其子树 | +| `move()` | O(depth + k)\* | O(1) | 移动子树(带循环检查) | +| `contains()` | O(1) | O(1) | 所有权检查 | +| `depth()` / `path()` | O(depth) | O(1) / O(d) | 深度 / 从根节点开始的值 | +| `find()` / `findAll()` | O(n) | O(n) | 前序搜索 | +| `preOrder()` | O(n) | O(n) | 显式栈(窄树为 O(h)) | +| `postOrder()` | O(n) | O(h) | 显式栈 | +| `levelOrder()` | O(n) | O(w) | 队列 | +| `fold()` | O(n) | O(h + k) | 自底向上归约 | +| `height` | O(n) | O(n) | 从根到叶的最长路径 | +| `map()` / `filter()` | O(n) | O(n) | 构建一棵新树 | +| `fromNested()` / `toNested()` | O(n) | O(n) | 与嵌套对象之间的转换 | +| `clear()` | O(n) | O(n) | 分离所有节点 | + +其中: + +- `n` = 节点数量 +- `h` = 树的高度 +- `w` = 最大宽度(某一层上的节点数) +- `k` = 兄弟节点数量 +- `s` = 被移除子树的大小 + +\* 设置 `maxDepth` 时,`addChild`/`insertChildAt` 还会额外花费 O(depth),`move` 还会额外花费 O(被移动子树的大小) 来检查限制。 + +:::info 何时使用 Tree +非常适合: + +- **文件系统和文件夹** - 使用 `path()` 生成路径和面包屑导航 +- **分类和菜单层级结构** - 有序的子节点和子树移动 +- **组织架构图和预算** - 使用 `fold()` 汇总数值 +- **已解析的文档** - 大纲、AST 和嵌套 JSON 结构 +::: + +:::warning 何时避免使用 +在以下情况下请考虑其他方案: + +- **需要排序的值** → 使用 [RedBlackTree](./red-black-tree) +- **需要对字符串进行前缀查找** → 使用 [Trie](./trie) +- **需要键值查找** → 使用 [SortedMap](./sorted-map) 或 `Map` +::: + +## 另请参阅 + +### 相关示例 + +- [文件系统浏览器](../examples/tree-file-system.md) +- [预算汇总](../examples/tree-budget-rollup.md) + +### 其他数据结构 + +- [Trie](./trie.md) - 用于字符串键的前缀树 +- [RedBlackTree](./red-black-tree.md) - 排序的唯一值 +- [Queue](./queue.md) - 内部用于层序遍历 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md new file mode 100644 index 0000000..4f30aac --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-budget-rollup.md @@ -0,0 +1,120 @@ +--- +id: tree-budget-rollup +title: Budget Rollup +sidebar_label: Budget Rollup +description: 使用 Tree.fold() 在部门层级结构中汇总成本 +keywords: [tree, n-ary-tree, fold, rollup, hierarchy, org-chart, example] +--- + +# 使用 Tree 进行预算汇总 + +使用 `fold()`(一种自底向上的归约操作),将数值字段从层级结构的叶节点汇总到根节点。 + +## 实现 + +```typescript +import { Tree } from '@dstoolkit/data-structures'; + +interface Unit { + name: string; + spend: number; // 该单位的直接支出 +} + +const org = Tree.fromNested({ + value: { name: 'Company', spend: 10_000 }, + children: [ + { + value: { name: 'Engineering', spend: 5_000 }, + children: [ + { value: { name: 'Platform', spend: 40_000 } }, + { value: { name: 'Mobile', spend: 25_000 } }, + ], + }, + { + value: { name: 'Operations', spend: 2_000 }, + children: [{ value: { name: 'Support', spend: 15_000 } }], + }, + ], +}); + +// 总支出:每个单位自身的支出加上其下所有单位的支出 +const total = org.fold( + (unit, children) => unit.spend + children.reduce((a, b) => a + b, 0), +); +console.log(total); // 97000 +``` + +## 汇总报告 + +构建一棵新树,其中每个单位都带有其汇总后的总计。 + +```typescript +interface Rollup { + name: string; + total: number; + children: Rollup[]; +} + +const report = org.fold((unit, children) => ({ + name: unit.name, + total: unit.spend + children.reduce((sum, c) => sum + c.total, 0), + children, +})); + +console.log(report.children.map((c) => `${c.name}: ${c.total}`)); +// ["Engineering: 70000", "Operations: 17000"] +``` + +## 单个分支的汇总 + +将节点作为第二个参数传入,以只汇总该子树。 + +```typescript +const engineering = org.find((u) => u.name === 'Engineering')!; + +const engTotal = org.fold( + (unit, children) => unit.spend + children.reduce((a, b) => a + b, 0), + engineering, +); +console.log(engTotal); // 70000 +``` + +## 其他汇总方式 + +```typescript +// 单位数量(节点数量) +const units = org.fold((_u, c) => 1 + c.reduce((a, b) => a + b, 0)); +console.log(units); // 6 + +// 最大的单笔直接支出 +const maxSpend = org.fold((u, c) => Math.max(u.spend, ...c)); +console.log(maxSpend); // 40000 + +// 没有下属单位的单位 +console.log(org.findAll((_u, node) => node.isLeaf).map((n) => n.value.name)); +// ["Platform", "Mobile", "Support"] +``` + +## 重组 + +```typescript +const support = org.find((u) => u.name === 'Support')!; + +// 将 Support 移动到 Engineering 下,并重新运行汇总 +org.move(support, engineering); +console.log( + org.fold( + (u, c) => u.spend + c.reduce((a, b) => a + b, 0), + engineering, + ), +); // 85000 +``` + +:::info 为什么使用 fold? +`fold` 会在所有子节点都被访问之后,正好访问每个节点一次,因此每个节点都能看到其子节点的结果。它是迭代实现的,因此适用于任意深度的层级结构。 +::: + +## 另请参阅 + +- [Tree API 参考](../api/tree.md) +- [文件系统浏览器](./tree-file-system.md) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-file-system.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-file-system.md new file mode 100644 index 0000000..763ef62 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/examples/tree-file-system.md @@ -0,0 +1,123 @@ +--- +id: tree-file-system +title: File System Explorer +sidebar_label: File System Explorer +description: 使用 Tree 建模文件夹和文件,使用 path() 渲染面包屑导航,并使用 fold() 计算文件夹大小 +keywords: [tree, n-ary-tree, file-system, breadcrumbs, example] +--- + +# 使用 Tree 构建文件系统浏览器 + +使用 `Tree` 建模一个文件夹层级结构,然后构建面包屑导航、搜索文件并安全地移动文件夹。 + +## 实现 + +```typescript +import { Tree, TreeNode } from '@dstoolkit/data-structures'; + +interface Entry { + name: string; + kind: 'folder' | 'file'; + bytes: number; +} + +const fs = new Tree(); +const root = fs.setRoot({ name: '', kind: 'folder', bytes: 0 }); + +function mkdir(parent: TreeNode, name: string): TreeNode { + return fs.addChild(parent, { name, kind: 'folder', bytes: 0 }); +} + +function touch(parent: TreeNode, name: string, bytes: number) { + return fs.addChild(parent, { name, kind: 'file', bytes }); +} + +const projects = mkdir(root, 'projects'); +const site = mkdir(projects, 'website'); +touch(site, 'index.html', 2_048); +touch(site, 'styles.css', 1_024); +const photos = mkdir(root, 'photos'); +const report = touch(projects, 'report.pdf', 50_000); +touch(photos, 'beach.jpg', 300_000); +``` + +## 使用 `path()` 生成面包屑导航 + +```typescript +function breadcrumb(node: TreeNode): string { + return '/' + fs.path(node).slice(1).map((e) => e.name).join('/'); +} + +const css = fs.find((e) => e.name === 'styles.css')!; +console.log(breadcrumb(css)); // "/projects/website/styles.css" +console.log(breadcrumb(report)); // "/projects/report.pdf" +``` + +## 使用 `fold()` 计算文件夹大小 + +```typescript +function sizeOf(node: TreeNode): number { + return fs.fold( + (entry, children) => entry.bytes + children.reduce((a, b) => a + b, 0), + node, + ); +} + +console.log(sizeOf(site)); // 3072 +console.log(sizeOf(projects)); // 53072 +console.log(sizeOf(root)); // 353072 +``` + +## 搜索 + +```typescript +// 所有大于 10 KB 的文件 +const large = fs + .findAll((e) => e.kind === 'file' && e.bytes > 10_000) + .map(breadcrumb); +console.log(large); // ["/projects/report.pdf", "/photos/beach.jpg"] + +// 目录列表,文件夹优先 +function ls(folder: TreeNode): string[] { + return [...folder.children] + .sort((a, b) => b.value.kind.localeCompare(a.value.kind)) + .map((n) => n.value.name); +} +console.log(ls(projects)); // ["website", "report.pdf"] +``` + +## 安全地移动文件夹 + +```typescript +// 将 website 文件夹移动到 photos 下 +fs.move(site, photos); +console.log(breadcrumb(css)); // "/photos/website/styles.css" + +// 将一个文件夹移动到它自己的子文件夹中会被拒绝 +try { + fs.move(photos, site); +} catch (error) { + console.log((error as Error).message); + // "Cannot move a node under itself or one of its descendants" +} +``` + +## 从 JSON 加载 + +```typescript +// 来自 API 或文件的数据在加载时会被验证 +const data = JSON.parse(await Deno.readTextFile('./tree.json')); +const loaded = Tree.fromNested(data, { maxSize: 100_000, maxDepth: 64 }); + +// ……也可以以相同的结构保存回去 +const json = JSON.stringify(loaded.toNested()); +``` + +:::tip +加载不可信数据时,请始终传入 `maxSize` / `maxDepth`。在构建整个结构之前,超大或格式错误的输入会被以 `InvalidOperationError` 拒绝。 +::: + +## 另请参阅 + +- [Tree API 参考](../api/tree.md) +- [预算汇总](./tree-budget-rollup.md) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md index 5be4996..8669c9c 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md @@ -13,7 +13,7 @@ sidebar_position: 1 - **🎯 类型安全**:完整的 TypeScript 支持,带有泛型,提供完全的类型安全 - **📦 零依赖**:无外部依赖 - 轻量且安全 - **⚡ 高性能**:经过优化的实现,并附有时间复杂度文档 -- **🧪 经过充分测试**:全面的测试覆盖率 (>85%) +- **🧪 经过充分测试**:全面的测试覆盖率 (~100%) - **🌲 可 Tree Shake**:只导入您需要的部分 - 最小化打包影响 - **📚 文档齐全**:提供大量示例的详细文档 - **🔄 双重发布**:在 JSR 和 npm 上均可用,实现最大兼容性 @@ -99,7 +99,7 @@ console.log(cache.get('key')); // 42 - **GitHub**: [mandy8055/data-structures](https://github.com/mandy8055/data-structures) - **问题反馈**: [报告错误或请求功能](https://github.com/mandy8055/data-structures/issues) -- **讨论**: [加入社区](https://github.com/mandy8055/data-structures/discussions) +- **讨论**: [有想法了?欢迎一起!](https://github.com/mandy8055/data-structures/discussions) ## 许可证 diff --git a/website/sidebars.ts b/website/sidebars.ts index 9047d13..62b0815 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -18,6 +18,7 @@ const sidebars: SidebarsConfig = { 'api/queue', 'api/red-black-tree', 'api/sorted-map', + 'api/tree', 'api/trie', ], },