From 4dcd52ebba1b55f1b843404ed789a6ee8a25f82b Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Sat, 24 Oct 2026 05:52:59 +0530 Subject: [PATCH] fix: preserve red-black tree insertion invariants --- src/data-structures/tree/BinaryTreeNode.js | 8 +-- .../tree/__test__/BinaryTreeNode.test.js | 16 +++++ .../tree/red-black-tree/RedBlackTree.js | 5 +- .../__test__/RedBlackTree.invariants.test.js | 65 +++++++++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 src/data-structures/tree/red-black-tree/__test__/RedBlackTree.invariants.test.js diff --git a/src/data-structures/tree/BinaryTreeNode.js b/src/data-structures/tree/BinaryTreeNode.js index 44c9390e41..25b8a8baa7 100644 --- a/src/data-structures/tree/BinaryTreeNode.js +++ b/src/data-structures/tree/BinaryTreeNode.js @@ -100,8 +100,8 @@ export default class BinaryTreeNode { * @return {BinaryTreeNode} */ setLeft(node) { - // Reset parent for left node since it is going to be detached. - if (this.left) { + // Detach only if the old child was not already reparented by a rotation. + if (this.left && this.left.parent === this) { this.left.parent = null; } @@ -121,8 +121,8 @@ export default class BinaryTreeNode { * @return {BinaryTreeNode} */ setRight(node) { - // Reset parent for right node since it is going to be detached. - if (this.right) { + // Detach only if the old child was not already reparented by a rotation. + if (this.right && this.right.parent === this) { this.right.parent = null; } diff --git a/src/data-structures/tree/__test__/BinaryTreeNode.test.js b/src/data-structures/tree/__test__/BinaryTreeNode.test.js index 8eeda4dda5..796c378794 100644 --- a/src/data-structures/tree/__test__/BinaryTreeNode.test.js +++ b/src/data-structures/tree/__test__/BinaryTreeNode.test.js @@ -1,6 +1,22 @@ import BinaryTreeNode from '../BinaryTreeNode'; describe('BinaryTreeNode', () => { + it.each(['setLeft', 'setRight'])('should preserve a reparented child during %s', (setter) => { + const previousParent = new BinaryTreeNode(1); + const nextParent = new BinaryTreeNode(2); + const child = new BinaryTreeNode(3); + const replacement = new BinaryTreeNode(4); + + previousParent[setter](child); + nextParent[setter](child); + previousParent[setter](replacement); + + expect(child.parent).toBe(nextParent); + expect(replacement.parent).toBe(previousParent); + previousParent[setter](null); + expect(replacement.parent).toBeNull(); + }); + it('should create node', () => { const node = new BinaryTreeNode(); diff --git a/src/data-structures/tree/red-black-tree/RedBlackTree.js b/src/data-structures/tree/red-black-tree/RedBlackTree.js index 6e747afc75..cf32f2d15a 100644 --- a/src/data-structures/tree/red-black-tree/RedBlackTree.js +++ b/src/data-structures/tree/red-black-tree/RedBlackTree.js @@ -44,8 +44,9 @@ export default class RedBlackTree extends BinarySearchTree { * @param {BinarySearchTreeNode} node */ balance(node) { - // If it is a root node then nothing to balance here. - if (this.nodeComparator.equal(node, this.root)) { + // Only a red node can violate the rule against adjacent red nodes. + // A black subtree root produced by a rotation needs no further balancing. + if (this.nodeComparator.equal(node, this.root) || this.isNodeBlack(node)) { return; } diff --git a/src/data-structures/tree/red-black-tree/__test__/RedBlackTree.invariants.test.js b/src/data-structures/tree/red-black-tree/__test__/RedBlackTree.invariants.test.js new file mode 100644 index 0000000000..9888595476 --- /dev/null +++ b/src/data-structures/tree/red-black-tree/__test__/RedBlackTree.invariants.test.js @@ -0,0 +1,65 @@ +import RedBlackTree from '../RedBlackTree'; + +// Verify membership and every red-black invariant after each insertion, rather +// than assuming a particular (non-unique) valid tree shape. +const verifyTree = (tree, values) => { + expect(tree.root.parent).toBeNull(); + expect(tree.isNodeBlack(tree.root)).toBe(true); + expect(tree.root.traverseInOrder()).toEqual([...values].sort((a, b) => a - b)); + + const blackHeight = (node, parent) => { + if (!node) { + return 1; + } + + expect(node.parent).toBe(parent); + expect(tree.isNodeColored(node)).toBe(true); + if (tree.isNodeRed(node)) { + expect(!node.left || tree.isNodeBlack(node.left)).toBe(true); + expect(!node.right || tree.isNodeBlack(node.right)).toBe(true); + } + + const leftHeight = blackHeight(node.left, node); + const rightHeight = blackHeight(node.right, node); + expect(leftHeight).toBe(rightHeight); + return leftHeight + Number(tree.isNodeBlack(node)); + }; + + blackHeight(tree.root, null); +}; + +describe('RedBlackTree insertion invariants', () => { + it.each([ + [[1, 3, 2, 4, 5]], + [[1, 4, 2, 3, 5, 6]], + [[6, 2, 1, 3, 7, 4, 5]], + [[5, 3, 4, 2, 1]], + [[6, 3, 5, 4, 2, 1]], + [[2, 6, 7, 5, 1, 4, 3]], + ])('should retain all nodes and colors for %j', (values) => { + const tree = new RedBlackTree(); + values.forEach((value, index) => { + tree.insert(value); + verifyTree(tree, values.slice(0, index + 1)); + }); + }); + + it('should preserve the invariants for every permutation of six keys', () => { + const checkPermutations = (prefix, remaining) => { + if (!remaining.length) { + const tree = new RedBlackTree(); + prefix.forEach((value, index) => { + tree.insert(value); + verifyTree(tree, prefix.slice(0, index + 1)); + }); + return; + } + + remaining.forEach((value, index) => { + checkPermutations([...prefix, value], remaining.filter((_, i) => i !== index)); + }); + }; + + checkPermutations([], [1, 2, 3, 4, 5, 6]); + }); +});