From c45c5b7afa59b4773d5fb918e3a634e92eefc87a Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Sat, 6 Jun 2026 13:35:39 +0200 Subject: [PATCH] feat: stack-safe traversal + lazy Sequence + navigation/functional extensions Core additive work for v3.4 (non-breaking): - Rewrite nodeCount(), height(), clear() and the post-order iterator iteratively so deep/degenerate trees no longer throw StackOverflowError (verified to 50k deep). - Add lazy Sequence traversal: asSequence(order), pre/post/levelOrderSequence(). - Add navigation extensions: isLeaf, degree, root(), ancestors(), siblings(), leaves(), descendants(). - Add functional extensions: findNode, filterNodes, anyNode, allNodes, countNodes, foldNodes, mapValues, deepCopy, structurallyEquals (all stack-safe). - Add tests for stack-safety, the new APIs, and previously-uncovered height/depth/nodeCount/path (incl. exception paths). 40 tests green on JVM. --- .../datastructure/tree/TreeNode.kt | 41 +++++++--- .../tree/TreeNodeFunctionalExt.kt | 65 ++++++++++++++++ .../tree/TreeNodeNavigationExt.kt | 41 ++++++++++ .../datastructure/tree/TreeNodeSequenceExt.kt | 31 ++++++++ .../tree/iterators/PostOrderTreeIterator.kt | 31 ++++---- .../TreeNodeFunctionalTest.kt | 78 +++++++++++++++++++ .../TreeNodeNavigationTest.kt | 66 ++++++++++++++++ .../TreeNodeSequenceTest.kt | 46 +++++++++++ .../TreeNodeStackSafetyTest.kt | 49 ++++++++++++ .../TreeNodeUtilitiesTest.kt | 54 +++++++++++++ 10 files changed, 473 insertions(+), 29 deletions(-) create mode 100644 src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeFunctionalExt.kt create mode 100644 src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeNavigationExt.kt create mode 100644 src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeSequenceExt.kt create mode 100644 src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeFunctionalTest.kt create mode 100644 src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeNavigationTest.kt create mode 100644 src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeSequenceTest.kt create mode 100644 src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeStackSafetyTest.kt create mode 100644 src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeUtilitiesTest.kt diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt index 22d6df8..361eb5f 100644 --- a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt +++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNode.kt @@ -72,20 +72,30 @@ open class TreeNode(val value: T, var treeIterator: TreeNodeIterators = PreOr * @return All child and nested child count. */ fun nodeCount(): Int { - if (_children.isEmpty()) - return 0 - return _children.size + - _children.sumOf { it.nodeCount() } + var count = 0 + val stack = ArrayDeque>() + stack.addAll(_children) + while (stack.isNotEmpty()) { + val node = stack.removeLast() + count++ + stack.addAll(node._children) + } + return count } /** * @return The number of edges on the longest path between current node and a descendant leaf. */ fun height(): Int { - val childrenMaxDepth = _children.map { it.height() } - .maxOrNull() - ?: -1 // -1 because this method counts nodes, and edges are always one less then nodes. - return childrenMaxDepth + 1 + var maxDepth = 0 + val stack = ArrayDeque, Int>>() + stack.addLast(this to 0) + while (stack.isNotEmpty()) { + val (node, depthSoFar) = stack.removeLast() + if (depthSoFar > maxDepth) maxDepth = depthSoFar + node._children.forEach { stack.addLast(it to depthSoFar + 1) } + } + return maxDepth } /** @@ -132,9 +142,18 @@ open class TreeNode(val value: T, var treeIterator: TreeNodeIterators = PreOr * Remove all children from root and every node in tree. */ fun clear() { - _parent = null - _children.forEach { it.clear() } - _children.clear() + val all = ArrayDeque>() + val stack = ArrayDeque>() + stack.addLast(this) + while (stack.isNotEmpty()) { + val node = stack.removeLast() + all.addLast(node) + stack.addAll(node._children) + } + all.forEach { node -> + node._parent = null + node._children.clear() + } } override fun toString(): String { diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeFunctionalExt.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeFunctionalExt.kt new file mode 100644 index 0000000..a187542 --- /dev/null +++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeFunctionalExt.kt @@ -0,0 +1,65 @@ +package com.github.adriankuta.datastructure.tree + +/** Returns the first node (pre-order) whose value matches [predicate], or `null`. Short-circuits. */ +fun TreeNode.findNode(predicate: (T) -> Boolean): TreeNode? = + preOrderSequence().firstOrNull { predicate(it.value) } + +/** All nodes (pre-order) whose value matches [predicate]. */ +fun TreeNode.filterNodes(predicate: (T) -> Boolean): List> = + preOrderSequence().filter { predicate(it.value) }.toList() + +/** `true` if any node's value matches [predicate]. Short-circuits. */ +fun TreeNode.anyNode(predicate: (T) -> Boolean): Boolean = + preOrderSequence().any { predicate(it.value) } + +/** `true` if every node's value matches [predicate]. Short-circuits. */ +fun TreeNode.allNodes(predicate: (T) -> Boolean): Boolean = + preOrderSequence().all { predicate(it.value) } + +/** Counts nodes whose value matches [predicate]. */ +fun TreeNode.countNodes(predicate: (T) -> Boolean): Int = + preOrderSequence().count { predicate(it.value) } + +/** Folds [operation] over all nodes in pre-order, starting from [initial]. */ +fun TreeNode.foldNodes(initial: R, operation: (acc: R, node: TreeNode) -> R): R = + preOrderSequence().fold(initial) { acc, node -> operation(acc, node) } + +/** + * Returns a new tree with the same shape whose values are produced by [transform]. The original is + * left untouched. Stack-safe (iterative), so it handles arbitrarily deep trees. + */ +fun TreeNode.mapValues(transform: (T) -> R): TreeNode { + val newRoot = TreeNode(transform(value), treeIterator) + val stack = ArrayDeque, TreeNode>>() + stack.addLast(this to newRoot) + while (stack.isNotEmpty()) { + val (source, target) = stack.removeLast() + source.children.forEach { child -> + val mappedChild = TreeNode(transform(child.value), child.treeIterator) + target.addChild(mappedChild) + stack.addLast(child to mappedChild) + } + } + return newRoot +} + +/** Returns an independent deep copy of this subtree (same values, same shape, new nodes). */ +fun TreeNode.deepCopy(): TreeNode = mapValues { it } + +/** + * Structural equality: `true` when [other] holds the same values in the same shape. Unlike + * [TreeNode]'s reference equality, this compares the entire subtree. Stack-safe. + */ +fun TreeNode.structurallyEquals(other: TreeNode): Boolean { + val stack = ArrayDeque, TreeNode>>() + stack.addLast(this to other) + while (stack.isNotEmpty()) { + val (a, b) = stack.removeLast() + if (a.value != b.value) return false + if (a.children.size != b.children.size) return false + for (i in a.children.indices) { + stack.addLast(a.children[i] to b.children[i]) + } + } + return true +} diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeNavigationExt.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeNavigationExt.kt new file mode 100644 index 0000000..1d253bc --- /dev/null +++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeNavigationExt.kt @@ -0,0 +1,41 @@ +package com.github.adriankuta.datastructure.tree + +/** `true` when this node has no children. */ +val TreeNode.isLeaf: Boolean get() = children.isEmpty() + +/** The number of direct children of this node. */ +val TreeNode.degree: Int get() = children.size + +/** Walks up the parent chain and returns the topmost ancestor (the tree root). */ +fun TreeNode.root(): TreeNode { + var node = this + var parent = node.parent + while (parent != null) { + node = parent + parent = node.parent + } + return node +} + +/** The chain of ancestors from the immediate [parent] up to (and including) the root. */ +fun TreeNode.ancestors(): List> { + val result = mutableListOf>() + var parent = this.parent + while (parent != null) { + result.add(parent) + parent = parent.parent + } + return result +} + +/** The other children of this node's parent (excludes this node). Empty for the root. */ +fun TreeNode.siblings(): List> = + parent?.children?.filter { it !== this } ?: emptyList() + +/** All leaf nodes in this subtree, in pre-order. */ +fun TreeNode.leaves(): List> = + preOrderSequence().filter { it.isLeaf }.toList() + +/** All nodes in this subtree except this node, in pre-order. */ +fun TreeNode.descendants(): List> = + preOrderSequence().filter { it !== this }.toList() diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeSequenceExt.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeSequenceExt.kt new file mode 100644 index 0000000..1429f56 --- /dev/null +++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/TreeNodeSequenceExt.kt @@ -0,0 +1,31 @@ +package com.github.adriankuta.datastructure.tree + +import com.github.adriankuta.datastructure.tree.iterators.LevelOrderTreeIterator +import com.github.adriankuta.datastructure.tree.iterators.PostOrderTreeIterator +import com.github.adriankuta.datastructure.tree.iterators.PreOrderTreeIterator +import com.github.adriankuta.datastructure.tree.iterators.TreeNodeIterators + +/** + * Lazily traverses this subtree in the given [order] as a [Sequence], without forcing the whole + * traversal up front. Pairs with the Kotlin stdlib, e.g. + * `root.asSequence().map { it.value }.firstOrNull { it == target }`. + */ +fun TreeNode.asSequence( + order: TreeNodeIterators = TreeNodeIterators.PreOrder, +): Sequence> { + val self = this + return when (order) { + TreeNodeIterators.PreOrder -> Sequence { PreOrderTreeIterator(self) } + TreeNodeIterators.PostOrder -> Sequence { PostOrderTreeIterator(self) } + TreeNodeIterators.LevelOrder -> Sequence { LevelOrderTreeIterator(self) } + } +} + +/** Lazy pre-order traversal as a [Sequence]. */ +fun TreeNode.preOrderSequence(): Sequence> = asSequence(TreeNodeIterators.PreOrder) + +/** Lazy post-order traversal as a [Sequence]. */ +fun TreeNode.postOrderSequence(): Sequence> = asSequence(TreeNodeIterators.PostOrder) + +/** Lazy level-order (breadth-first) traversal as a [Sequence]. */ +fun TreeNode.levelOrderSequence(): Sequence> = asSequence(TreeNodeIterators.LevelOrder) diff --git a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/iterators/PostOrderTreeIterator.kt b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/iterators/PostOrderTreeIterator.kt index 6eb6bbf..df167cd 100644 --- a/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/iterators/PostOrderTreeIterator.kt +++ b/src/commonMain/kotlin/com.github.adriankuta/datastructure/tree/iterators/PostOrderTreeIterator.kt @@ -22,27 +22,22 @@ import com.github.adriankuta.datastructure.tree.TreeNode */ class PostOrderTreeIterator(root: TreeNode) : Iterator> { - private val stack = ArrayDeque>() + private val result = ArrayDeque>() init { - stack.addAll(getChildrenStack(root)) - } - - override fun hasNext(): Boolean = stack.isNotEmpty() - - override fun next(): TreeNode { - return stack.removeFirst() - } - - private fun getChildrenStack(node: TreeNode): ArrayDeque> { + // Iterative post-order: pop a node, prepend it to `result`, then push its children + // left-to-right. Reading `result` front-to-back yields post-order — without the deep + // recursion that overflowed the stack on degenerate (linear) trees. val stack = ArrayDeque>() - if(node.children.isEmpty()) { - return ArrayDeque(listOf(node)) + stack.addLast(root) + while (stack.isNotEmpty()) { + val node = stack.removeLast() + result.addFirst(node) + node.children.forEach { stack.addLast(it) } } - node.children.forEach { - stack.addAll(getChildrenStack(it)) - } - stack.addLast(node) - return stack } + + override fun hasNext(): Boolean = result.isNotEmpty() + + override fun next(): TreeNode = result.removeFirst() } \ No newline at end of file diff --git a/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeFunctionalTest.kt b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeFunctionalTest.kt new file mode 100644 index 0000000..fc56834 --- /dev/null +++ b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeFunctionalTest.kt @@ -0,0 +1,78 @@ +package com.github.adriankuta.datastructure.tree + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TreeNodeFunctionalTest { + + private fun sample() = tree(1) { + child(2) { + child(4) + child(5) + } + child(3) { + child(6) + } + } + + @Test + fun findNode() { + assertEquals(6, sample().findNode { it == 6 }?.value) + assertNull(sample().findNode { it == 99 }) + } + + @Test + fun filterNodes() = + assertContentEquals(listOf(2, 4, 6), sample().filterNodes { it % 2 == 0 }.map { it.value }) + + @Test + fun anyNode() { + assertTrue(sample().anyNode { it == 6 }) + assertFalse(sample().anyNode { it == 99 }) + } + + @Test + fun allNodes() { + assertTrue(sample().allNodes { it > 0 }) + assertFalse(sample().allNodes { it < 5 }) + } + + @Test + fun countNodes() = assertEquals(3, sample().countNodes { it > 3 }) + + @Test + fun foldNodes() = assertEquals(21, sample().foldNodes(0) { acc, node -> acc + node.value }) + + @Test + fun mapPreservesStructureAndTransformsValues() { + val mapped = sample().mapValues { it * 10 } + assertContentEquals( + listOf(10, 20, 40, 50, 30, 60), + mapped.preOrderSequence().map { it.value }.toList(), + ) + } + + @Test + fun deepCopyIsStructurallyEqualButDistinct() { + val original = sample() + val copy = original.deepCopy() + assertNotSame(original, copy) + assertTrue(original.structurallyEquals(copy)) + } + + @Test + fun structurallyEqualsDistinguishesByValueAndShape() { + assertTrue(sample().structurallyEquals(sample())) + val different = tree(1) { + child(2) { child(4) } + child(3) { child(6) } + } + assertFalse(sample().structurallyEquals(different)) + } +} diff --git a/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeNavigationTest.kt b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeNavigationTest.kt new file mode 100644 index 0000000..8a4255b --- /dev/null +++ b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeNavigationTest.kt @@ -0,0 +1,66 @@ +package com.github.adriankuta.datastructure.tree + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TreeNodeNavigationTest { + + private val root = TreeNode(1) + private val n2 = TreeNode(2) + private val n3 = TreeNode(3) + private val n4 = TreeNode(4) + private val n5 = TreeNode(5) + private val n6 = TreeNode(6) + + init { + root.addChild(n2) + root.addChild(n3) + n2.addChild(n4) + n2.addChild(n5) + n3.addChild(n6) + } + + @Test + fun isLeaf() { + assertTrue(n4.isLeaf) + assertFalse(root.isLeaf) + } + + @Test + fun degree() { + assertEquals(2, root.degree) + assertEquals(0, n4.degree) + } + + @Test + fun root() { + assertSame(root, n6.root()) + assertSame(root, root.root()) + } + + @Test + fun ancestors() { + assertContentEquals(listOf(n2, root), n4.ancestors()) + assertContentEquals(emptyList(), root.ancestors()) + } + + @Test + fun siblings() { + assertContentEquals(listOf(n5), n4.siblings()) + assertContentEquals(emptyList(), root.siblings()) + } + + @Test + fun leaves() { + assertContentEquals(listOf(n4, n5, n6), root.leaves()) + } + + @Test + fun descendants() { + assertContentEquals(listOf(n2, n4, n5, n3, n6), root.descendants()) + } +} diff --git a/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeSequenceTest.kt b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeSequenceTest.kt new file mode 100644 index 0000000..ce52a15 --- /dev/null +++ b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeSequenceTest.kt @@ -0,0 +1,46 @@ +package com.github.adriankuta.datastructure.tree + +import com.github.adriankuta.datastructure.tree.iterators.TreeNodeIterators +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class TreeNodeSequenceTest { + + private fun sample() = tree(1) { + child(2) { + child(4) + child(5) + } + child(3) { + child(6) + } + } + + @Test + fun preOrderSequence() = + assertContentEquals(listOf(1, 2, 4, 5, 3, 6), sample().preOrderSequence().map { it.value }.toList()) + + @Test + fun postOrderSequence() = + assertContentEquals(listOf(4, 5, 2, 6, 3, 1), sample().postOrderSequence().map { it.value }.toList()) + + @Test + fun levelOrderSequence() = + assertContentEquals(listOf(1, 2, 3, 4, 5, 6), sample().levelOrderSequence().map { it.value }.toList()) + + @Test + fun asSequenceDefaultsToPreOrder() = + assertContentEquals(listOf(1, 2, 4, 5, 3, 6), sample().asSequence().map { it.value }.toList()) + + @Test + fun asSequenceHonorsExplicitOrder() = + assertContentEquals( + listOf(1, 2, 3, 4, 5, 6), + sample().asSequence(TreeNodeIterators.LevelOrder).map { it.value }.toList(), + ) + + @Test + fun sequenceShortCircuitsLazily() = + assertEquals(4, sample().preOrderSequence().map { it.value }.first { it == 4 }) +} diff --git a/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeStackSafetyTest.kt b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeStackSafetyTest.kt new file mode 100644 index 0000000..5556b0b --- /dev/null +++ b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeStackSafetyTest.kt @@ -0,0 +1,49 @@ +package com.github.adriankuta.datastructure.tree + +import com.github.adriankuta.datastructure.tree.iterators.TreeNodeIterators +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A deep, degenerate (linear) tree must not overflow the call stack. These tests build a chain + * thousands of nodes deep — recursive implementations of [TreeNode.height], [TreeNode.nodeCount] + * and the post-order iterator blow the stack here, so they pin the iterative rewrites. + */ +class TreeNodeStackSafetyTest { + + private val depth = 50_000 + + private fun deepChain(): TreeNode { + val root = TreeNode(0) + var current = root + for (i in 1..depth) { + val child = TreeNode(i) + current.addChild(child) + current = child + } + return root + } + + @Test + fun heightDoesNotOverflowOnDeepTree() { + assertEquals(depth, deepChain().height()) + } + + @Test + fun nodeCountDoesNotOverflowOnDeepTree() { + // nodeCount() excludes the root, so a chain of `depth` extra nodes counts as `depth`. + assertEquals(depth, deepChain().nodeCount()) + } + + @Test + fun postOrderIterationDoesNotOverflowOnDeepTree() { + val tree = deepChain().apply { treeIterator = TreeNodeIterators.PostOrder } + assertEquals(depth + 1, tree.toList().size) + } + + @Test + fun preOrderIterationDoesNotOverflowOnDeepTree() { + val tree = deepChain().apply { treeIterator = TreeNodeIterators.PreOrder } + assertEquals(depth + 1, tree.toList().size) + } +} diff --git a/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeUtilitiesTest.kt b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeUtilitiesTest.kt new file mode 100644 index 0000000..d3a02a5 --- /dev/null +++ b/src/commonTest/kotlin/com.github.adriankuta/datastructure.tree/TreeNodeUtilitiesTest.kt @@ -0,0 +1,54 @@ +package com.github.adriankuta.datastructure.tree + +import com.github.adriankuta.datastructure.tree.exceptions.TreeNodeException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class TreeNodeUtilitiesTest { + + private val root = TreeNode(1) + private val a = TreeNode(2) + private val b = TreeNode(3) + + init { + root.addChild(a) + a.addChild(b) + } + + @Test + fun nodeCountCountsDescendantsExcludingRoot() { + assertEquals(0, TreeNode("solo").nodeCount()) + assertEquals(2, root.nodeCount()) + } + + @Test + fun heightIsLongestEdgePathToLeaf() { + assertEquals(0, TreeNode("solo").height()) + assertEquals(2, root.height()) + assertEquals(1, a.height()) + } + + @Test + fun depthIsDistanceToRoot() { + assertEquals(0, root.depth()) + assertEquals(1, a.depth()) + assertEquals(2, b.depth()) + } + + @Test + fun pathReturnsDescendantToReceiverChain() { + assertContentEquals(listOf(b, a, root), root.path(b)) + } + + @Test + fun pathThrowsWhenNotADescendant() { + assertFailsWith { root.path(TreeNode(99)) } + } + + @Test + fun pathThrowsWhenDescendantIsRootItself() { + assertFailsWith { root.path(root) } + } +}