Implement a Persistent Immutable List in JavaScript
Persistent data structures are valuable for scenarios where you need to maintain historical versions of data without expensive copying. This challenge asks you to implement a persistent, immutable list data structure in JavaScript, allowing you to efficiently create new versions of the list upon modification.
Problem Description
Your task is to create a JavaScript class, PersistentList, that represents an immutable list. When you perform an operation that would typically modify a list (like adding or removing an element), instead of altering the original list, your implementation should return a new PersistentList instance representing the modified state. The original PersistentList instances must remain unchanged. This immutability and persistence are achieved by sharing common underlying data between different versions of the list.
Key Requirements:
- Immutability: All operations that conceptually modify the list must return a new
PersistentListinstance. The original instance must not be altered. - Persistence: Different versions of the list should efficiently share their underlying data. This means that if you create a new version of a list, it should ideally reuse as much of the original list's structure as possible.
- Core List Operations: Implement the following methods:
add(element): Returns a newPersistentListwithelementappended to the end.get(index): Returns the element at the specifiedindex.size(): Returns the number of elements in the list.remove(index): Returns a newPersistentListwith the element atindexremoved.set(index, element): Returns a newPersistentListwith the element atindexreplaced byelement.
- Efficient Sharing: While not strictly enforced by tests in this challenge, the design should lend itself to efficient sharing of data (e.g., using a tree-like structure internally).
Expected Behavior:
- Creating a
PersistentListwith initial elements should be possible. - Calling
add,remove, orseton aPersistentListshould yield a newPersistentListinstance. The original list should be unchanged. - Accessing elements via
getshould work correctly on all versions of the list.
Edge Cases:
- Empty list operations.
- Operations on indices out of bounds (e.g.,
get,remove,setwith invalid indices). You should handle these gracefully (e.g., by throwing errors or returning specific values as appropriate for list behavior).
Examples
Example 1:
let list1 = new PersistentList([1, 2, 3]);
console.log(list1.size()); // Output: 3
console.log(list1.get(1)); // Output: 2
let list2 = list1.add(4);
console.log(list1.size()); // Output: 3 (original list unchanged)
console.log(list1.get(3)); // Output: undefined (original list does not have index 3)
console.log(list2.size()); // Output: 4
console.log(list2.get(3)); // Output: 4
console.log(list2.get(2)); // Output: 3 (shares data with list1)
Explanation:
list1 is initialized. add(4) creates list2 with 4 appended. list1 remains unmodified. list2 correctly reflects the addition.
Example 2:
let listA = new PersistentList(['a', 'b']);
let listB = listA.set(0, 'x');
let listC = listB.remove(1);
console.log(listA.get(0)); // Output: 'a'
console.log(listA.size()); // Output: 2
console.log(listB.get(0)); // Output: 'x'
console.log(listB.get(1)); // Output: 'b'
console.log(listB.size()); // Output: 2
console.log(listC.get(0)); // Output: 'x'
console.log(listC.size()); // Output: 1
Explanation:
listA is the original. listB is a new list with the first element changed. listC is a new list with the second element of listB removed. All original lists retain their state.
Example 3: (Edge Case: Empty List)
let emptyList = new PersistentList([]);
console.log(emptyList.size()); // Output: 0
let listAfterAdd = emptyList.add(10);
console.log(emptyList.size()); // Output: 0
console.log(listAfterAdd.size()); // Output: 1
console.log(listAfterAdd.get(0)); // Output: 10
try {
emptyList.get(0);
} catch (e) {
console.log("Caught expected error for get on empty list."); // Output: Caught expected error for get on empty list.
}
Explanation: Operations on an empty list should behave as expected, creating new lists with the added elements and throwing errors for invalid index access.
Constraints
- The
PersistentListclass should be implemented in JavaScript. - The internal representation should aim for efficient sharing of data to avoid full deep copies on modification. Consider techniques like path copying or node sharing if you are familiar with them.
- The
getoperation should have a time complexity of O(log n) or O(1) in the average case, where n is the number of elements in the list. - The
add,remove, andsetoperations should have a time complexity of O(log n) or O(1) in the average case, where n is the number of elements in the list. - The total memory usage should be proportional to the sum of the lengths of all created list versions, not the product.
Notes
This challenge focuses on the fundamental principles of persistent data structures. You might consider using an array-based internal representation that is cleverly structured to enable sharing. Think about how an update at a specific index can affect only a small portion of the underlying structure. For an advanced solution, research techniques like "finger trees" or "balanced trees" adapted for persistence. For a simpler yet still effective approach, consider a form of path copying within an array or tree-like structure. You don't need to implement a full-blown balanced tree if a simpler array-based approach can achieve the desired persistence properties, though a tree-based approach would typically offer better asymptotic performance for all operations.