Skip to content

cs_survival_kit.data_structures

Data structures, each written by hand for study.

GrowthPolicy

GrowthPolicy = Callable[[int], int]

Maps the current capacity to the new capacity after an array resize.

A valid policy must return a value strictly greater than its input. DynamicArray.append raises ValueError if a policy does not.

AbstractList

Bases: ABC

An ordered collection of elements, each at a position from 0 upward.

The contract has two layers.

Six primitives are abstract. An implementation must supply all of them, and they are the only places its storage is touched: len(a), iteration, a[i], a[i] = x, insert and pop. A subclass that leaves out any primitive cannot be instantiated.

Nine defaults are inherited. Each is written here once, in terms of the primitives: append is insert at the end, prepend is insert at 0, pop_front and pop_back are pop at either end, remove is a scan followed by pop, item in a and repr(a) are scans, reversed(a) is one scan buffered and yielded backward, and reverse swaps a[i] with a[n - 1 - i] inward from both ends. An implementation may override a default, but it rarely needs to: the cost of each default is simply the cost of the primitive it calls at that position, and an implementation whose insert is O(1) at index 0 gets an O(1) prepend for free.

Two defaults deserve a closer look. reverse is only as cheap as indexing, so a structure that cannot index in O(1) overrides it with an algorithm suited to its storage. reversed(a) could have been written the same way, as a[i] from the last position down, but that is quadratic on a linked list. Instead it spends O(n) auxiliary space on a buffer so that it is linear on every implementation. A structure that can walk backward without the buffer, such as an array by index or a doubly linked list by its backward links, overrides it to get the space back.

Every index is a non-negative position. a[i], a[i] = x and pop accept 0 <= index < len(a); insert also accepts index == len(a), which adds at the end. Negative indices and slices are not part of the contract.

Complexity

The interface fixes behaviour only. What each primitive costs is set by the implementation, and comparing those costs is what the implementations are for. Each one documents its own. The defaults cost whatever the primitive they call costs at that index:

Operation Defined in terms of
append insert(len(a), item)
prepend insert(0, item)
pop_front pop(0)
pop_back pop(len(a) - 1)
remove one iteration to find the index, then pop
item in a one iteration
repr(a) one iteration
reversed(a) one iteration into a buffer, yielded backward
reverse n / 2 swaps, each two a[i] reads and two writes

Examples:

The base class cannot be instantiated; an implementation can.

>>> AbstractList()
Traceback (most recent call last):
    ...
TypeError: Can't instantiate abstract class AbstractList...
>>> from cs_survival_kit.data_structures import DynamicArray
>>> isinstance(DynamicArray[int](), AbstractList)
True

The defaults come from the base class, so an implementation that writes only the six primitives supports every operation:

>>> a = DynamicArray[int]()
>>> a.append(2)
>>> a.prepend(1)
>>> a.append(3)
>>> a
DynamicArray([1, 2, 3])
>>> a.pop_front(), a.pop_back()
(1, 3)
>>> a.remove(2)
>>> len(a)
0

reversed walks the elements backward without changing the list; reverse changes the list:

>>> a.append(1)
>>> a.append(2)
>>> list(reversed(a)), list(a)
([2, 1], [1, 2])
>>> a.reverse()
>>> list(a)
[2, 1]

__len__ abstractmethod

__len__() -> int

Return the number of elements in the list.

Returns:

Type Description
int

The number of elements, which is 0 for an empty list.

Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
@abstractmethod
def __len__(self) -> int:
    """Return the number of elements in the list.

    Returns:
        The number of elements, which is 0 for an empty list.
    """
    raise NotImplementedError

__iter__ abstractmethod

__iter__() -> Iterator[T]

Iterate over the elements in position order.

Yields:

Type Description
T

Each element, starting with the one at position 0.

Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
@abstractmethod
def __iter__(self) -> Iterator[T]:
    """Iterate over the elements in position order.

    Yields:
        Each element, starting with the one at position 0.
    """
    raise NotImplementedError

__getitem__ abstractmethod

__getitem__(index: int) -> T

Return the element at position index.

Parameters:

Name Type Description Default
index int

The position of the element. Must satisfy 0 <= index < len(self).

required

Returns:

Type Description
T

The element at index.

Raises:

Type Description
IndexError

If there is no element at index.

Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
@abstractmethod
def __getitem__(self, index: int) -> T:
    """Return the element at position `index`.

    Args:
        index: The position of the element. Must satisfy
            `0 <= index < len(self)`.

    Returns:
        The element at `index`.

    Raises:
        IndexError: If there is no element at `index`.
    """
    raise NotImplementedError

__setitem__ abstractmethod

__setitem__(index: int, item: T) -> None

Replace the element at position index with item.

Only overwrites an existing element; the length does not change. Use insert to add one.

Parameters:

Name Type Description Default
index int

The position of the element to replace. Must satisfy 0 <= index < len(self).

required
item T

The new element.

required

Raises:

Type Description
IndexError

If there is no element at index.

Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
@abstractmethod
def __setitem__(self, index: int, item: T) -> None:
    """Replace the element at position `index` with `item`.

    Only overwrites an existing element; the length does not change. Use
    `insert` to add one.

    Args:
        index: The position of the element to replace. Must satisfy
            `0 <= index < len(self)`.
        item: The new element.

    Raises:
        IndexError: If there is no element at `index`.
    """
    raise NotImplementedError

insert abstractmethod

insert(index: int, item: T) -> None

Add item at position index, moving later elements along by one.

Afterwards self[index] is item and len(self) is one greater. index == len(self) adds the item after the current last one.

Parameters:

Name Type Description Default
index int

The position the new element will occupy. Must satisfy 0 <= index <= len(self).

required
item T

The element to add.

required

Raises:

Type Description
IndexError

If index is negative or greater than len(self).

Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
@abstractmethod
def insert(self, index: int, item: T) -> None:
    """Add `item` at position `index`, moving later elements along by one.

    Afterwards `self[index]` is `item` and `len(self)` is one greater.
    `index == len(self)` adds the item after the current last one.

    Args:
        index: The position the new element will occupy. Must satisfy
            `0 <= index <= len(self)`.
        item: The element to add.

    Raises:
        IndexError: If `index` is negative or greater than `len(self)`.
    """
    raise NotImplementedError

pop abstractmethod

pop(index: int) -> T

Remove and return the element at position index.

Later elements move back by one, so afterwards len(self) is one less and the element that was at index + 1 is at index.

Parameters:

Name Type Description Default
index int

The position of the element to remove. Must satisfy 0 <= index < len(self).

required

Returns:

Type Description
T

The element that was at index.

Raises:

Type Description
IndexError

If there is no element at index.

Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
@abstractmethod
def pop(self, index: int) -> T:
    """Remove and return the element at position `index`.

    Later elements move back by one, so afterwards `len(self)` is one
    less and the element that was at `index + 1` is at `index`.

    Args:
        index: The position of the element to remove. Must satisfy
            `0 <= index < len(self)`.

    Returns:
        The element that was at `index`.

    Raises:
        IndexError: If there is no element at `index`.
    """
    raise NotImplementedError

__contains__

__contains__(item: object) -> bool

Return whether item is in the list.

Checks the elements in position order and stops at the first match. An element matches if it is item or equals it, the same rule that list uses.

Parameters:

Name Type Description Default
item object

The value to look for.

required

Returns:

Type Description
bool

True if some element is item or equals it, otherwise False.

Complexity
  • Time: one iteration, stopping at the first match
  • Space: O(1)
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def __contains__(self, item: object) -> bool:
    """Return whether `item` is in the list.

    Checks the elements in position order and stops at the first match.
    An element matches if it is `item` or equals it, the same rule that
    `list` uses.

    Args:
        item: The value to look for.

    Returns:
        `True` if some element is `item` or equals it, otherwise `False`.

    Complexity:
        - Time: one iteration, stopping at the first match
        - Space: O(1)
    """
    for element in self:
        if element is item or element == item:
            return True
    return False

__repr__

__repr__() -> str

Return a string showing the class name and the elements in order.

Returns:

Type Description
str

A string such as DynamicArray([1, 2, 3]).

Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def __repr__(self) -> str:
    """Return a string showing the class name and the elements in order.

    Returns:
        A string such as `DynamicArray([1, 2, 3])`.
    """
    return f"{type(self).__name__}({list(self)})"

__reversed__

__reversed__() -> Iterator[T]

Iterate over the elements from the last position down to 0.

This is what the built-in reversed(a) calls. The list is not changed; compare reverse, which some structures offer to rewire themselves in place.

The default iterates forward once into a buffer, then yields the buffer from its end. The buffer costs O(n) auxiliary space, and that is a deliberate trade. The alternative, reading a[i] from the last position down, needs no extra memory but is only as cheap as indexing, which is quadratic on a linked list that walks to each index. A linear default that every implementation can inherit beats a constant-space default that some cannot afford. A structure that can walk backward in O(1) space overrides this: an array reads by index, and a doubly linked list follows its backward links.

The buffer is filled when iteration starts, not when reversed(a) is called, and it is a snapshot: changes made to the list after the first element has been yielded do not affect the rest.

Yields:

Type Description
T

Each element, starting with the one at position len(self) - 1.

Complexity
  • Time: O(n), one iteration plus one pass over the buffer
  • Space: O(n) for the buffer
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def __reversed__(self) -> Iterator[T]:
    """Iterate over the elements from the last position down to 0.

    This is what the built-in `reversed(a)` calls. The list is not
    changed; compare `reverse`, which some structures offer to rewire
    themselves in place.

    The default iterates forward once into a buffer, then yields the
    buffer from its end. The buffer costs O(n) auxiliary space, and that
    is a deliberate trade. The alternative, reading `a[i]` from the last
    position down, needs no extra memory but is only as cheap as
    indexing, which is quadratic on a linked list that walks to each
    index. A linear default that every implementation can inherit beats
    a constant-space default that some cannot afford. A structure that
    can walk backward in O(1) space overrides this: an array reads by
    index, and a doubly linked list follows its backward links.

    The buffer is filled when iteration starts, not when `reversed(a)`
    is called, and it is a snapshot: changes made to the list after the
    first element has been yielded do not affect the rest.

    Yields:
        Each element, starting with the one at position `len(self) - 1`.

    Complexity:
        - Time: O(n), one iteration plus one pass over the buffer
        - Space: O(n) for the buffer
    """
    buffer = list(self)
    yield from reversed(buffer)

reverse

reverse() -> None

Reverse the order of the elements in place.

Afterwards the element that was last is at position 0, and so on. Compare reversed(a), which visits the elements backward and changes nothing.

The default is the array algorithm: swap the first element with the last, the second with the second-to-last, and so on, meeting in the middle after n // 2 swaps. It needs nothing but indexing, so on an array it is O(n) with O(1) extra space. A linked list cannot index cheaply, so it overrides this with an algorithm suited to links: a singly linked list re-points every next at the node before it, and a doubly linked list swaps prev and next on every node.

Complexity
  • Time: n // 2 swaps, each two reads and two writes of a[i] at that index's cost
  • Space: O(1)
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def reverse(self) -> None:
    """Reverse the order of the elements in place.

    Afterwards the element that was last is at position 0, and so on.
    Compare `reversed(a)`, which visits the elements backward and changes
    nothing.

    The default is the array algorithm: swap the first element with the
    last, the second with the second-to-last, and so on, meeting in the
    middle after `n // 2` swaps. It needs nothing but indexing, so on an
    array it is O(n) with O(1) extra space. A linked list cannot index
    cheaply, so it overrides this with an algorithm suited to links: a
    singly linked list re-points every `next` at the node before it, and
    a doubly linked list swaps `prev` and `next` on every node.

    Complexity:
        - Time: `n // 2` swaps, each two reads and two writes of `a[i]`
          at that index's cost
        - Space: O(1)
    """
    count = len(self)
    for index in range(count // 2):
        mirror = count - 1 - index
        self[index], self[mirror] = self[mirror], self[index]

append

append(item: T) -> None

Add item to the end of the list.

Afterwards item is the last element and len(self) is one greater.

Parameters:

Name Type Description Default
item T

The element to add.

required
Complexity
  • Time: the cost of insert at index len(self)
  • Space: the cost of insert at index len(self)
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def append(self, item: T) -> None:
    """Add `item` to the end of the list.

    Afterwards `item` is the last element and `len(self)` is one greater.

    Args:
        item: The element to add.

    Complexity:
        - Time: the cost of `insert` at index `len(self)`
        - Space: the cost of `insert` at index `len(self)`
    """
    self.insert(len(self), item)

prepend

prepend(item: T) -> None

Add item to the front of the list.

Afterwards item is at position 0, every other element is one position later, and len(self) is one greater.

Parameters:

Name Type Description Default
item T

The element to add.

required
Complexity
  • Time: the cost of insert at index 0
  • Space: the cost of insert at index 0
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def prepend(self, item: T) -> None:
    """Add `item` to the front of the list.

    Afterwards `item` is at position 0, every other element is one
    position later, and `len(self)` is one greater.

    Args:
        item: The element to add.

    Complexity:
        - Time: the cost of `insert` at index 0
        - Space: the cost of `insert` at index 0
    """
    self.insert(0, item)

pop_front

pop_front() -> T

Remove and return the first element.

Returns:

Type Description
T

The element that was at position 0.

Raises:

Type Description
IndexError

If the list is empty.

Complexity
  • Time: the cost of pop at index 0
  • Space: the cost of pop at index 0
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def pop_front(self) -> T:
    """Remove and return the first element.

    Returns:
        The element that was at position 0.

    Raises:
        IndexError: If the list is empty.

    Complexity:
        - Time: the cost of `pop` at index 0
        - Space: the cost of `pop` at index 0
    """
    if len(self) == 0:
        raise IndexError("pop_front from empty list")
    return self.pop(0)

pop_back

pop_back() -> T

Remove and return the last element.

Returns:

Type Description
T

The element that was at position len(self) - 1.

Raises:

Type Description
IndexError

If the list is empty.

Complexity
  • Time: the cost of pop at index len(self) - 1
  • Space: the cost of pop at index len(self) - 1
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def pop_back(self) -> T:
    """Remove and return the last element.

    Returns:
        The element that was at position `len(self) - 1`.

    Raises:
        IndexError: If the list is empty.

    Complexity:
        - Time: the cost of `pop` at index `len(self) - 1`
        - Space: the cost of `pop` at index `len(self) - 1`
    """
    if len(self) == 0:
        raise IndexError("pop_back from empty list")
    return self.pop(len(self) - 1)

remove

remove(item: T) -> None

Remove the first element that matches item.

Only the first match, scanning from position 0, is removed. An element matches if it is item or equals it, as in item in a.

Parameters:

Name Type Description Default
item T

The value to remove.

required

Raises:

Type Description
ValueError

If no element matches item.

Complexity
  • Time: one iteration to find the match, plus the cost of pop at that index
  • Space: O(1)
Source code in cs_survival_kit/data_structures/abstract_list.py · View on GitHub
def remove(self, item: T) -> None:
    """Remove the first element that matches `item`.

    Only the first match, scanning from position 0, is removed. An
    element matches if it is `item` or equals it, as in `item in a`.

    Args:
        item: The value to remove.

    Raises:
        ValueError: If no element matches `item`.

    Complexity:
        - Time: one iteration to find the match, plus the cost of `pop`
          at that index
        - Space: O(1)
    """
    for index, element in enumerate(self):
        if element is item or element == item:
            self.pop(index)
            return
    raise ValueError("item not in list")

List

List(items: Iterable[T] = (), *, capacity: int = 4, growth: GrowthPolicy = doubling)

Bases: DynamicArray[T]

The default list: a DynamicArray under a name that says "just a list".

List adds nothing to DynamicArray; it is the name. Every operation and every cost is DynamicArray's, and the full table is in its docstring. The name exists so that code can say which it means: a DynamicArray because it wants an array, or a List because it wants the default. If a better general-purpose list is ever added to the kit, this is the name that will point at it.

Complexity

The same as DynamicArray. The operations that make it the default:

Operation Time
a[i], a[i] = x O(1)
append O(1) amortized, O(n) worst
pop_back O(1)
len(a) O(1)
iteration O(n)

And the ones that are not its strength: prepend, pop_front and insert or pop away from the end are O(n), because the elements after the position shift. Constructing from items is O(n).

Parameters:

Name Type Description Default
items Iterable[T]

Elements to add to the new list, in order. Defaults to empty.

()
capacity int

The number of slots to allocate up front, as for DynamicArray.

4
growth GrowthPolicy

The growth policy, as for DynamicArray. Doubling by default.

doubling

Examples:

>>> from cs_survival_kit import List
>>> a = List([3, 1, 2])
>>> a
List([3, 1, 2])
>>> a.append(4)
>>> a[0], a[3], len(a)
(3, 4, 4)
>>> a.pop_back()
4

It is a DynamicArray, so anything written for one accepts it:

>>> from cs_survival_kit.data_structures import AbstractList, DynamicArray
>>> isinstance(a, DynamicArray), isinstance(a, AbstractList)
(True, True)

And it satisfies the sorting contract, like every list in the kit:

>>> from cs_survival_kit.algorithms.sorting import Sortable
>>> isinstance(a, Sortable)
True
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def __init__(
    self,
    items: Iterable[T] = (),
    *,
    capacity: int = 4,
    growth: GrowthPolicy = doubling,
) -> None:
    if capacity <= 0:
        raise ValueError("capacity must be greater than 0")

    self._items: list[T | None] = [None] * capacity
    self._capacity: int = capacity
    self._size: int = 0
    self._growth: GrowthPolicy = growth

    for item in items:
        self.append(item)

DoublyLinkedList

DoublyLinkedList(items: Iterable[T] = ())

Bases: AbstractList[T]

A generic sequence implemented as a doubly linked list.

Elements are stored in nodes connected by prev and next references. The list maintains references to both the head and tail nodes as well as its current size. This permits O(1) insertion and removal at either end and O(1) length queries.

Indexed access requires traversal because nodes are not stored contiguously. Traversal begins at the head or tail depending on which is closer to the requested index, requiring O(min(i, n - 1 - i)) time for index i and O(n) time in the worst case.

Compared with a singly linked list, the backward reference enables direct traversal toward the head and O(1) removal from the tail. The tradeoff is an additional reference per node and additional pointer maintenance. Compared with a dynamic array, this structure provides O(1) insertion and removal at either end without shifting elements, but does not provide O(1) random access and has greater per-element storage overhead.

Complexity
Operation Time Space
a[i], a[i] = x O(min(i, n - 1 - i)), O(n) worst O(1)
insert(i, x) O(min(i, n - i)), O(n) worst O(1)
pop(i) O(min(i, n - 1 - i)), O(n) worst O(1)
prepend O(1) O(1)
append O(1) O(1)
pop_front O(1) O(1)
pop_back O(1) O(1)
remove O(n) O(1)
reverse O(n) O(1)
len(a) O(1) O(1)
item in a O(n) O(1)
iteration O(n) O(1)
reversed iteration O(n) O(1)

The list requires O(n) total storage. Each element is stored in a separate node containing the element and two node references.

The space bounds above describe auxiliary space used by each operation, excluding storage for newly inserted nodes and iterator objects.

Parameters:

Name Type Description Default
items Iterable[T]

Elements used to initialize the list, in iteration order.

()

Examples:

>>> values = DoublyLinkedList([1, 2, 3])
>>> list(values)
[1, 2, 3]
>>> list(reversed(values))
[3, 2, 1]
>>> values.insert(1, 4)
>>> list(values)
[1, 4, 2, 3]
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def __init__(self, items: Iterable[T] = ()) -> None:
    # Initialize the empty state for a doubly linked list.
    self._head: _Node[T] | None = None
    self._tail: _Node[T] | None = None
    self._size: int = 0

    # Append the given items in iteration order.
    for item in items:
        self.append(item)

__len__

__len__() -> int

Return the number of elements in the list.

Returns:

Type Description
int

The number of elements currently stored.

Complexity
  • Time: O(1)
  • Space: O(1)
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def __len__(self) -> int:
    """Return the number of elements in the list.

    Returns:
        The number of elements currently stored.

    Complexity:
        - Time: O(1)
        - Space: O(1)
    """
    return self._size

__iter__

__iter__() -> Iterator[T]

Iterate over the elements from head to tail.

Yields:

Type Description
T

Each element in list order.

Complexity
  • Time: O(n) for complete iteration
  • Space: O(1) auxiliary space
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def __iter__(self) -> Iterator[T]:
    """Iterate over the elements from head to tail.

    Yields:
        Each element in list order.

    Complexity:
        - Time: O(n) for complete iteration
        - Space: O(1) auxiliary space
    """
    current_node: _Node[T] | None = self._head

    while current_node is not None:
        yield current_node.item
        current_node = current_node.next

__reversed__

__reversed__() -> Iterator[T]

Iterate over the elements from tail to head.

Each node stores a reference to its predecessor, allowing traversal backward from the tail without first reversing the list or repeatedly searching from the head. A singly linked list does not have these backward references.

Yields:

Type Description
T

Each element in reverse list order.

Complexity
  • Time: O(n) for complete iteration
  • Space: O(1) auxiliary space

Examples:

>>> values = DoublyLinkedList([1, 2, 3])
>>> list(reversed(values))
[3, 2, 1]
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def __reversed__(self) -> Iterator[T]:
    """Iterate over the elements from tail to head.

    Each node stores a reference to its predecessor, allowing traversal
    backward from the tail without first reversing the list or repeatedly
    searching from the head. A singly linked list does not have these
    backward references.

    Yields:
        Each element in reverse list order.

    Complexity:
        - Time: O(n) for complete iteration
        - Space: O(1) auxiliary space

    Examples:
        >>> values = DoublyLinkedList([1, 2, 3])
        >>> list(reversed(values))
        [3, 2, 1]
    """
    current_node: _Node[T] | None = self._tail

    while current_node is not None:
        yield current_node.item
        current_node = current_node.prev

__getitem__

__getitem__(index: int) -> T

Return the element at the given index.

Traversal begins at whichever end of the list is closer to index. This reduces the number of nodes visited for positions near the tail, although indexed access remains O(n) in the worst case.

Parameters:

Name Type Description Default
index int

Zero-based index of the element to retrieve.

required

Returns:

Type Description
T

The element stored at index.

Raises:

Type Description
IndexError

If index is outside the range [0, n).

Complexity
  • Time: O(min(index, n - 1 - index)); O(n) worst case
  • Space: O(1)
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def __getitem__(self, index: int) -> T:
    """Return the element at the given index.

    Traversal begins at whichever end of the list is closer to `index`.
    This reduces the number of nodes visited for positions near the tail,
    although indexed access remains O(n) in the worst case.

    Args:
        index: Zero-based index of the element to retrieve.

    Returns:
        The element stored at `index`.

    Raises:
        IndexError: If `index` is outside the range `[0, n)`.

    Complexity:
        - Time: O(min(index, n - 1 - index)); O(n) worst case
        - Space: O(1)
    """
    return self._node_at(index).item

__setitem__

__setitem__(index: int, item: T) -> None

Replace the element at the given index.

Parameters:

Name Type Description Default
index int

Zero-based index of the element to replace.

required
item T

New element to store at index.

required

Raises:

Type Description
IndexError

If index is outside the range [0, n).

Complexity
  • Time: O(min(index, n - 1 - index)); O(n) worst case
  • Space: O(1)
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def __setitem__(self, index: int, item: T) -> None:
    """Replace the element at the given index.

    Args:
        index: Zero-based index of the element to replace.
        item: New element to store at `index`.

    Raises:
        IndexError: If `index` is outside the range `[0, n)`.

    Complexity:
        - Time: O(min(index, n - 1 - index)); O(n) worst case
        - Space: O(1)
    """
    self._node_at(index).item = item

insert

insert(index: int, item: T) -> None

Insert an element at the given index.

Insertion at the head or one position past the tail requires no traversal and runs in O(1) time. For an interior insertion, the node currently at index is located by traversing from the nearer end. Its prev reference identifies the predecessor, after which the four links surrounding the new node are updated in O(1) time.

Parameters:

Name Type Description Default
index int

Zero-based position at which to insert the element. An index equal to the current size appends the element.

required
item T

Element to insert.

required

Raises:

Type Description
IndexError

If index is outside the range [0, n].

Complexity
  • Time: O(min(index, n - index)); O(n) worst case
  • Space: O(1) auxiliary space

Examples:

>>> values = DoublyLinkedList([1, 3])
>>> values.insert(1, 2)
>>> list(values)
[1, 2, 3]
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def insert(self, index: int, item: T) -> None:
    """Insert an element at the given index.

    Insertion at the head or one position past the tail requires no
    traversal and runs in O(1) time. For an interior insertion, the node
    currently at `index` is located by traversing from the nearer end.
    Its `prev` reference identifies the predecessor, after which the four
    links surrounding the new node are updated in O(1) time.

    Args:
        index: Zero-based position at which to insert the element. An
            index equal to the current size appends the element.
        item: Element to insert.

    Raises:
        IndexError: If `index` is outside the range `[0, n]`.

    Complexity:
        - Time: O(min(index, n - index)); O(n) worst case
        - Space: O(1) auxiliary space

    Examples:
        >>> values = DoublyLinkedList([1, 3])
        >>> values.insert(1, 2)
        >>> list(values)
        [1, 2, 3]
    """
    # Verify that the index is within the valid range. One position past
    # the final element is allowed because it represents an append.
    if index < 0 or index > self._size:
        raise IndexError("index out of range")

    node = _Node[T](item)

    if self._head is None or self._tail is None:
        self._head = node
        self._tail = node

    elif index == 0:
        node.next = self._head
        self._head.prev = node
        self._head = node

    elif index == self._size:
        node.prev = self._tail
        self._tail.next = node
        self._tail = node

    else:
        # Locate the node that will follow the newly inserted node.
        next_node = self._node_at(index)

        # An interior node necessarily has a predecessor.
        assert next_node.prev is not None
        previous_node = next_node.prev

        # Insert the new node between its predecessor and successor.
        previous_node.next = node
        next_node.prev = node
        node.next = next_node
        node.prev = previous_node

    self._size += 1

pop

pop(index: int) -> T

Remove and return the element at the given index.

Removing the head or tail requires only pointer updates and therefore runs in O(1) time. In particular, the tail's prev reference gives direct access to the new tail, whereas a singly linked list must traverse from the head to locate the tail's predecessor.

Removing an interior element first locates its node by traversing from the nearer end. The node's prev and next references then provide direct access to both neighbors so it can be unlinked in O(1) time.

Parameters:

Name Type Description Default
index int

Zero-based index of the element to remove.

required

Returns:

Type Description
T

The element removed from the list.

Raises:

Type Description
IndexError

If index is outside the range [0, n).

Complexity
  • Time: O(min(index, n - 1 - index)); O(n) worst case
  • Space: O(1)

Examples:

>>> values = DoublyLinkedList([1, 2, 3])
>>> values.pop(1)
2
>>> list(values)
[1, 3]
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def pop(self, index: int) -> T:
    """Remove and return the element at the given index.

    Removing the head or tail requires only pointer updates and therefore
    runs in O(1) time. In particular, the tail's `prev` reference gives
    direct access to the new tail, whereas a singly linked list must
    traverse from the head to locate the tail's predecessor.

    Removing an interior element first locates its node by traversing from
    the nearer end. The node's `prev` and `next` references then provide
    direct access to both neighbors so it can be unlinked in O(1) time.

    Args:
        index: Zero-based index of the element to remove.

    Returns:
        The element removed from the list.

    Raises:
        IndexError: If `index` is outside the range `[0, n)`.

    Complexity:
        - Time: O(min(index, n - 1 - index)); O(n) worst case
        - Space: O(1)

    Examples:
        >>> values = DoublyLinkedList([1, 2, 3])
        >>> values.pop(1)
        2
        >>> list(values)
        [1, 3]
    """
    # Verify that the given index is within the valid range [0, n).
    if index < 0 or index >= self._size:
        raise IndexError("index out of range")

    if index == 0:
        removed_node = self._head
        assert removed_node is not None

        self._head = removed_node.next

        if self._head is None:
            self._tail = None
        else:
            self._head.prev = None

        removed_node.next = None

    elif index == self._size - 1:
        removed_node = self._tail
        assert removed_node is not None
        assert removed_node.prev is not None

        self._tail = removed_node.prev
        self._tail.next = None
        removed_node.prev = None

    else:
        removed_node = self._node_at(index)
        previous_node = removed_node.prev
        next_node = removed_node.next

        # An interior node necessarily has both neighbors.
        assert previous_node is not None
        assert next_node is not None

        previous_node.next = next_node
        next_node.prev = previous_node

        removed_node.prev = None
        removed_node.next = None

    self._size -= 1

    return removed_node.item

reverse

reverse() -> None

Reverse the list in place.

Each node's prev and next references are swapped. After a node's links are swapped, its former next node is reachable through prev, which allows traversal to continue through the original list order. Once every node has been updated, the head and tail references are swapped to complete the reversal.

Complexity
  • Time: O(n)
  • Space: O(1)

Examples:

>>> values = DoublyLinkedList([1, 2, 3])
>>> values.reverse()
>>> list(values)
[3, 2, 1]
Source code in cs_survival_kit/data_structures/doubly_linked_list.py · View on GitHub
def reverse(self) -> None:
    """Reverse the list in place.

    Each node's `prev` and `next` references are swapped. After a node's
    links are swapped, its former `next` node is reachable through `prev`,
    which allows traversal to continue through the original list order.
    Once every node has been updated, the head and tail references are
    swapped to complete the reversal.

    Complexity:
        - Time: O(n)
        - Space: O(1)

    Examples:
        >>> values = DoublyLinkedList([1, 2, 3])
        >>> values.reverse()
        >>> list(values)
        [3, 2, 1]
    """
    # Start traversal at the head of the list.
    current_node: _Node[T] | None = self._head

    # Swap each node's backward and forward references.
    while current_node is not None:
        current_node.prev, current_node.next = (
            current_node.next,
            current_node.prev,
        )

        # The original next node is now referenced by prev.
        current_node = current_node.prev

    # Swap the endpoints to complete the reversal.
    self._head, self._tail = self._tail, self._head

DynamicArray

DynamicArray(items: Iterable[T] = (), *, capacity: int = 4, growth: GrowthPolicy = doubling)

Bases: AbstractList[T]

A resizable array backed by fixed-capacity storage.

Elements are stored in a fixed-size backing list of capacity slots, where only the first len(self) slots are in use. When an insert finds no free slot, the array first resizes: it allocates a larger backing list, sized by the growth policy, and copies every element into it. The backing storage never shrinks, so popping elements leaves capacity unchanged.

Elements sit at evenly spaced positions, so reaching any index is one arithmetic step. The price is paid on insertion and removal anywhere but the end: every later element has to shift by one slot to keep the block contiguous. The inherited append and pop_back work at the end and shift nothing; the inherited prepend and pop_front work at index 0 and shift everything.

Unlike list, indexing accepts only non-negative indices in the range 0 <= index < len(self). Negative indices and slices are not supported.

Complexity
Operation Time Space
a[i], a[i] = x O(1) O(1)
insert O(n) amortized O(1) amortized, O(n) worst
pop O(n) O(1)
append O(1) amortized, O(n) worst O(1) amortized, O(n) worst
pop_back O(1) O(1)
prepend O(n) O(1) amortized, O(n) worst
pop_front O(n) O(1)
remove O(n) O(1)
len(a) O(1) O(1)
item in a O(n) O(1)
iteration O(n) O(1)
reversed(a) O(n) O(1)
reverse O(n) O(1)
resize O(n) O(n)

insert at index i shifts n - i elements, so it is O(1) at the end and O(n) at the front; pop likewise. reverse is the inherited default, swapping inward from both ends, which an array can do because both ends are O(1) away. reversed(a) overrides the inherited default, which buffers a forward pass in O(n) space, with a backward walk by index: a[i] is O(1), so the buffer would buy nothing. The amortized bounds assume a geometric growth policy such as the default doubling. With an additive policy, append is amortized O(n). Total storage is O(capacity). For an array built by appends alone, doubling keeps capacity below 2n once the array has grown past its initial capacity.

Parameters:

Name Type Description Default
items Iterable[T]

Elements to add to the new array, in order. Defaults to empty. Each is appended, so the array grows by the policy as it fills.

()
capacity int

The number of slots to allocate up front. Must be at least 1.

4
growth GrowthPolicy

The growth policy that computes the new capacity on each resize. Must return a value greater than its input. Defaults to doubling.

doubling

Raises:

Type Description
ValueError

If capacity is less than 1.

Examples:

>>> DynamicArray([1, 2, 3])
DynamicArray([1, 2, 3])
>>> a = DynamicArray[int](capacity=2)
>>> a.append(1)
>>> a.append(2)
>>> len(a), a.capacity
(2, 2)
>>> a.append(3)  # no free slot, so the array doubles first
>>> len(a), a.capacity
(3, 4)
>>> a
DynamicArray([1, 2, 3])
>>> a[0] = 10
>>> a.pop_back()
3
>>> a.insert(1, 15)
>>> list(a)
[10, 15, 2]
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def __init__(
    self,
    items: Iterable[T] = (),
    *,
    capacity: int = 4,
    growth: GrowthPolicy = doubling,
) -> None:
    if capacity <= 0:
        raise ValueError("capacity must be greater than 0")

    self._items: list[T | None] = [None] * capacity
    self._capacity: int = capacity
    self._size: int = 0
    self._growth: GrowthPolicy = growth

    for item in items:
        self.append(item)

capacity property

capacity: int

The number of allocated slots, used or not.

Always at least len(self). The two are equal when the array is full, and the next insert will trigger a resize.

Complexity
  • Time: O(1)
  • Space: O(1)

__len__

__len__() -> int

Return the number of elements in the array.

Returns:

Type Description
int

The number of elements in the array.

Complexity
  • Time: O(1)
  • Space: O(1)
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def __len__(self) -> int:
    """Return the number of elements in the array.

    Returns:
        The number of elements in the array.

    Complexity:
        - Time: O(1)
        - Space: O(1)
    """
    return self._size

__getitem__

__getitem__(index: int) -> T

Return the element at index.

Parameters:

Name Type Description Default
index int

The position of the element. Must satisfy 0 <= index < len(self).

required

Returns:

Type Description
T

The element at index.

Raises:

Type Description
IndexError

If index is negative or not less than len(self).

Complexity
  • Time: O(1)
  • Space: O(1)
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def __getitem__(self, index: int) -> T:
    """Return the element at `index`.

    Args:
        index: The position of the element. Must satisfy
            `0 <= index < len(self)`.

    Returns:
        The element at `index`.

    Raises:
        IndexError: If `index` is negative or not less than `len(self)`.

    Complexity:
        - Time: O(1)
        - Space: O(1)
    """
    if index < 0 or index >= self._size:
        raise IndexError("index out of range")

    return typing.cast(T, self._items[index])

__setitem__

__setitem__(index: int, item: T) -> None

Replace the element at index with item.

Only overwrites an existing element. Use insert or append to add one.

Parameters:

Name Type Description Default
index int

The position of the element to replace. Must satisfy 0 <= index < len(self).

required
item T

The new element.

required

Raises:

Type Description
IndexError

If index is negative or not less than len(self).

Complexity
  • Time: O(1)
  • Space: O(1)
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def __setitem__(self, index: int, item: T) -> None:
    """Replace the element at `index` with `item`.

    Only overwrites an existing element. Use `insert` or `append` to add
    one.

    Args:
        index: The position of the element to replace. Must satisfy
            `0 <= index < len(self)`.
        item: The new element.

    Raises:
        IndexError: If `index` is negative or not less than `len(self)`.

    Complexity:
        - Time: O(1)
        - Space: O(1)
    """
    if index < 0 or index >= self._size:
        raise IndexError("index out of range")

    self._items[index] = item

__iter__

__iter__() -> Iterator[T]

Iterate over the elements from index 0 to len(self) - 1.

Unused slots are never visited, so None in a is True only if None was actually stored.

Yields:

Type Description
T

Each element, in index order.

Complexity
  • Time: O(n) to exhaust the iterator
  • Space: O(1)
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def __iter__(self) -> Iterator[T]:
    """Iterate over the elements from index 0 to `len(self) - 1`.

    Unused slots are never visited, so `None in a` is `True` only if
    `None` was actually stored.

    Yields:
        Each element, in index order.

    Complexity:
        - Time: O(n) to exhaust the iterator
        - Space: O(1)
    """
    for i in range(self._size):
        yield typing.cast(T, self._items[i])

__reversed__

__reversed__() -> Iterator[T]

Iterate over the elements from index len(self) - 1 down to 0.

Overrides the inherited default, which buffers one forward pass in O(n) auxiliary space so that it stays linear on structures that cannot index cheaply. An array indexes in O(1), so it walks backward by index instead and needs no buffer.

Yields:

Type Description
T

Each element, starting with the one at index len(self) - 1.

Complexity
  • Time: O(n) to exhaust the iterator
  • Space: O(1)

Examples:

>>> a = DynamicArray[int]()
>>> a.append(1)
>>> a.append(2)
>>> list(reversed(a))
[2, 1]
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def __reversed__(self) -> Iterator[T]:
    """Iterate over the elements from index `len(self) - 1` down to 0.

    Overrides the inherited default, which buffers one forward pass in
    O(n) auxiliary space so that it stays linear on structures that
    cannot index cheaply. An array indexes in O(1), so it walks backward
    by index instead and needs no buffer.

    Yields:
        Each element, starting with the one at index `len(self) - 1`.

    Complexity:
        - Time: O(n) to exhaust the iterator
        - Space: O(1)

    Examples:
        >>> a = DynamicArray[int]()
        >>> a.append(1)
        >>> a.append(2)
        >>> list(reversed(a))
        [2, 1]
    """
    for i in range(self._size - 1, -1, -1):
        yield typing.cast(T, self._items[i])

insert

insert(index: int, item: T) -> None

Add item at index, shifting every later element one slot right.

If the array is full, it first resizes to the capacity returned by the growth policy, copying every element, and only then makes room. Resizing only when there is no free slot means every allocated slot gets used before the array grows. The shift then runs from the last element down to index, so no element is overwritten before it has been moved.

Inserting at len(self) shifts nothing, which is why the inherited append is O(1) amortized. Inserting at 0 shifts every element, which is why the inherited prepend is O(n).

The growth policy's result is checked before anything changes. If it is not greater than the current capacity, ValueError is raised and the array is left exactly as it was.

Parameters:

Name Type Description Default
index int

The position the new element will occupy. Must satisfy 0 <= index <= len(self).

required
item T

The element to add.

required

Raises:

Type Description
IndexError

If index is negative or greater than len(self).

ValueError

If the array is full and the growth policy returns a capacity that is not greater than the current capacity.

Complexity
  • Time: O(n - index) to shift, plus O(n) for the insert that triggers a resize; O(1) amortized at the end with a geometric growth policy
  • Space: O(1) amortized; O(n) for the insert that triggers a resize

Examples:

>>> a = DynamicArray[str](capacity=2)
>>> a.append("x")
>>> a.append("z")  # fills the last free slot; no resize yet
>>> a.capacity
2
>>> a.insert(1, "y")  # no free slot, so the array grows first
>>> a, a.capacity
(DynamicArray(['x', 'y', 'z']), 4)

A growth policy that doesn't grow is rejected:

>>> stuck = DynamicArray[int](capacity=1, growth=lambda c: c)
>>> stuck.append(1)
>>> stuck.append(2)
Traceback (most recent call last):
    ...
ValueError: growth policy must increase capacity (1 -> 1)
>>> list(stuck), stuck.capacity
([1], 1)
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def insert(self, index: int, item: T) -> None:
    """Add `item` at `index`, shifting every later element one slot right.

    If the array is full, it first resizes to the capacity returned by the
    growth policy, copying every element, and only then makes room.
    Resizing only when there is no free slot means every allocated slot
    gets used before the array grows. The shift then runs from the last
    element down to `index`, so no element is overwritten before it has
    been moved.

    Inserting at `len(self)` shifts nothing, which is why the inherited
    `append` is O(1) amortized. Inserting at 0 shifts every element, which
    is why the inherited `prepend` is O(n).

    The growth policy's result is checked before anything changes. If it
    is not greater than the current capacity, `ValueError` is raised and
    the array is left exactly as it was.

    Args:
        index: The position the new element will occupy. Must satisfy
            `0 <= index <= len(self)`.
        item: The element to add.

    Raises:
        IndexError: If `index` is negative or greater than `len(self)`.
        ValueError: If the array is full and the growth policy returns a
            capacity that is not greater than the current capacity.

    Complexity:
        - Time: O(n - index) to shift, plus O(n) for the insert that
          triggers a resize; O(1) amortized at the end with a geometric
          growth policy
        - Space: O(1) amortized; O(n) for the insert that triggers a resize

    Examples:
        >>> a = DynamicArray[str](capacity=2)
        >>> a.append("x")
        >>> a.append("z")  # fills the last free slot; no resize yet
        >>> a.capacity
        2
        >>> a.insert(1, "y")  # no free slot, so the array grows first
        >>> a, a.capacity
        (DynamicArray(['x', 'y', 'z']), 4)

        A growth policy that doesn't grow is rejected:

        >>> stuck = DynamicArray[int](capacity=1, growth=lambda c: c)
        >>> stuck.append(1)
        >>> stuck.append(2)
        Traceback (most recent call last):
            ...
        ValueError: growth policy must increase capacity (1 -> 1)
        >>> list(stuck), stuck.capacity
        ([1], 1)
    """
    # verify that the given index is within the valid range; one past the
    # last element is allowed, which adds at the end
    if index < 0 or index > self._size:
        raise IndexError("index out of range")

    # if the array is full, resize it according to the growth policy before
    # inserting the new item
    if self._size == self._capacity:
        new_capacity: int = self._growth(self._capacity)

        # validate the growth policy's result before mutating any state so a
        # bad policy leaves the array unchanged
        if new_capacity <= self._capacity:
            raise ValueError(
                "growth policy must increase capacity "
                f"({self._capacity} -> {new_capacity})"
            )

        self._resize(new_capacity)

    # shift the elements from the end down to the index one slot to the
    # right, walking backwards so that each element is moved before the
    # element behind it overwrites its old slot
    for i in range(self._size, index, -1):
        self._items[i] = self._items[i - 1]

    # store the item in the slot the shift opened up
    self._items[index] = item

    # update the size of the array
    self._size = self._size + 1

pop

pop(index: int) -> T

Remove and return the element at index, shifting later ones left.

The shift runs from index + 1 up to the last element, so each slot is overwritten only after its element has moved. The slot freed at the end is cleared, so the array no longer holds a reference to the element. Capacity is unchanged.

Popping at len(self) - 1 shifts nothing, which is why the inherited pop_back is O(1). Popping at 0 shifts every remaining element, which is why the inherited pop_front is O(n).

Parameters:

Name Type Description Default
index int

The position of the element to remove. Must satisfy 0 <= index < len(self).

required

Returns:

Type Description
T

The element that was at index.

Raises:

Type Description
IndexError

If index is negative or not less than len(self).

Complexity
  • Time: O(n - index) to shift; O(1) at the end
  • Space: O(1)

Examples:

>>> a = DynamicArray[int]()
>>> for item in (1, 2, 3):
...     a.append(item)
>>> a.pop(0)
1
>>> a
DynamicArray([2, 3])
>>> a.pop(2)
Traceback (most recent call last):
    ...
IndexError: index out of range
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def pop(self, index: int) -> T:
    """Remove and return the element at `index`, shifting later ones left.

    The shift runs from `index + 1` up to the last element, so each slot
    is overwritten only after its element has moved. The slot freed at
    the end is cleared, so the array no longer holds a reference to the
    element. Capacity is unchanged.

    Popping at `len(self) - 1` shifts nothing, which is why the inherited
    `pop_back` is O(1). Popping at 0 shifts every remaining element, which
    is why the inherited `pop_front` is O(n).

    Args:
        index: The position of the element to remove. Must satisfy
            `0 <= index < len(self)`.

    Returns:
        The element that was at `index`.

    Raises:
        IndexError: If `index` is negative or not less than `len(self)`.

    Complexity:
        - Time: O(n - index) to shift; O(1) at the end
        - Space: O(1)

    Examples:
        >>> a = DynamicArray[int]()
        >>> for item in (1, 2, 3):
        ...     a.append(item)
        >>> a.pop(0)
        1
        >>> a
        DynamicArray([2, 3])
        >>> a.pop(2)
        Traceback (most recent call last):
            ...
        IndexError: index out of range
    """
    # verify that the given index is within the valid range
    if index < 0 or index >= self._size:
        raise IndexError("index out of range")

    item: T = typing.cast(T, self._items[index])

    # shift the elements after the index one slot to the left, walking
    # forwards so that each slot is overwritten only after its element has
    # already been moved
    for i in range(index, self._size - 1):
        self._items[i] = self._items[i + 1]

    # clear the slot freed at the end so the array does not keep a stale
    # reference to the last element, then update the size
    self._size = self._size - 1
    self._items[self._size] = None

    return item

SinglyLinkedList

SinglyLinkedList(items: Iterable[T] = ())

Bases: AbstractList[T]

A sequence of nodes, each linked to the next, with head and tail references.

Each element lives in its own node, and each node links only forward to the next one. The list keeps references to the first node (head) and the last node (tail), plus a running size count:

  • head makes insert and pop at index 0 O(1), and so the inherited prepend and pop_front.
  • tail makes insert at index len(self) O(1), and so the inherited append. Without it, appending would mean walking the whole chain to find the last node.
  • The size count makes len O(1) instead of a full traversal.

Links only point forward, so there is no fast way to reach a node's predecessor. Removing the last element means walking from the head to the second-to-last node, so the inherited pop_back is O(n) even though the tail reference finds the last node instantly. That limitation is what a doubly linked list removes.

Compared with DynamicArray, adding or removing at the front is O(1) instead of O(n), but indexing is O(n) instead of O(1), and every element pays for an extra node object and link.

Unlike list, indexing accepts only non-negative indices in the range 0 <= index < len(self). Negative indices and slices are not supported.

Complexity
Operation Time Space
a[i], a[i] = x O(n) O(1)
insert O(n) O(1)
pop O(n) O(1)
prepend O(1) O(1)
append O(1) O(1)
pop_front O(1) O(1)
pop_back O(n) O(1)
remove O(n) O(1)
reverse O(n) O(1)
len(a) O(1) O(1)
item in a O(n) O(1)
iteration O(n) O(1)
reversed(a) O(n) O(n)

insert and pop at index i follow O(i) links, so they are O(1) at the front. insert is also O(1) at the end, thanks to the tail reference. reversed(a) is the inherited default, which iterates once into a buffer and yields it backward. The buffer is O(n) extra space, and it is the price of linear time: nodes have no backward link to follow, so reading each index from the last one down would walk from the head every time and cost O(n²). Total storage is O(n): one node per element.

Parameters:

Name Type Description Default
items Iterable[T]

Elements to add to the new list, in order. Defaults to empty.

()

Examples:

>>> a = SinglyLinkedList[int]([2, 3])
>>> a.prepend(1)
>>> a.append(4)
>>> a
SinglyLinkedList([1, 2, 3, 4])
>>> a.pop_front()
1
>>> a[1], 3 in a, len(a)
(3, True, 3)
>>> a.reverse()
>>> list(a)
[4, 3, 2]
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def __init__(self, items: Iterable[T] = ()) -> None:
    # initialize the empty state for a singly-linked list
    self._head: _Node[T] | None = None
    self._tail: _Node[T] | None = None
    self._size: int = 0

    # iterate over the given items and append them to the
    # end of the singly-linked list
    for item in items:
        self.append(item)

__len__

__len__() -> int

Return the number of elements in the list.

Returns:

Type Description
int

The number of elements in the list.

Complexity
  • Time: O(1), from the stored size count
  • Space: O(1)
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def __len__(self) -> int:
    """Return the number of elements in the list.

    Returns:
        The number of elements in the list.

    Complexity:
        - Time: O(1), from the stored size count
        - Space: O(1)
    """
    return self._size

__iter__

__iter__() -> Iterator[T]

Iterate over the elements from head to tail.

Yields:

Type Description
T

Each element, in list order.

Complexity
  • Time: O(n) to exhaust the iterator
  • Space: O(1)
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def __iter__(self) -> Iterator[T]:
    """Iterate over the elements from head to tail.

    Yields:
        Each element, in list order.

    Complexity:
        - Time: O(n) to exhaust the iterator
        - Space: O(1)
    """
    # establish a node pointer to traverse the linked list
    current_node: _Node[T] | None = self._head

    # while the node pointer points to valid nodes, continue
    # traversing and yielding the current node as you progress
    while current_node is not None:
        yield current_node.item
        current_node = current_node.next

__getitem__

__getitem__(index: int) -> T

Return the element at index.

Nodes are scattered in memory and each one only knows where the next node is, so there is no way to compute where element index lives. The lookup walks index links from the head. An array can jump straight there because its elements sit at evenly spaced positions.

Parameters:

Name Type Description Default
index int

The position of the element. Must satisfy 0 <= index < len(self).

required

Returns:

Type Description
T

The element at index.

Raises:

Type Description
IndexError

If index is negative or not less than len(self).

Complexity
  • Time: O(n); O(index) links are followed
  • Space: O(1)
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def __getitem__(self, index: int) -> T:
    """Return the element at `index`.

    Nodes are scattered in memory and each one only knows where the next
    node is, so there is no way to compute where element `index` lives.
    The lookup walks `index` links from the head. An array can jump
    straight there because its elements sit at evenly spaced positions.

    Args:
        index: The position of the element. Must satisfy
            `0 <= index < len(self)`.

    Returns:
        The element at `index`.

    Raises:
        IndexError: If `index` is negative or not less than `len(self)`.

    Complexity:
        - Time: O(n); O(index) links are followed
        - Space: O(1)
    """
    # verify that the given index is within the valid range
    if index < 0 or index >= self._size:
        raise IndexError("index out of range")

    return self._node_at(index).item

__setitem__

__setitem__(index: int, item: T) -> None

Replace the element at index with item.

Walks index links from the head, exactly as a[i] does, and overwrites the element in the node it reaches. No node is added or unlinked. Use insert to add an element.

Parameters:

Name Type Description Default
index int

The position of the element to replace. Must satisfy 0 <= index < len(self).

required
item T

The new element.

required

Raises:

Type Description
IndexError

If index is negative or not less than len(self).

Complexity
  • Time: O(n); O(index) links are followed
  • Space: O(1)
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def __setitem__(self, index: int, item: T) -> None:
    """Replace the element at `index` with `item`.

    Walks `index` links from the head, exactly as `a[i]` does, and
    overwrites the element in the node it reaches. No node is added or
    unlinked. Use `insert` to add an element.

    Args:
        index: The position of the element to replace. Must satisfy
            `0 <= index < len(self)`.
        item: The new element.

    Raises:
        IndexError: If `index` is negative or not less than `len(self)`.

    Complexity:
        - Time: O(n); O(index) links are followed
        - Space: O(1)
    """
    # verify that the given index is within the valid range
    if index < 0 or index >= self._size:
        raise IndexError("index out of range")

    self._node_at(index).item = item

insert

insert(index: int, item: T) -> None

Add item at index, after walking to the node before it.

A new node is linked in; no existing element moves. An array must shift every element after index one slot to the right to make room, which costs O(n) however far the walk is.

The walk is what costs. Three cases avoid it entirely:

  • index == 0: the new node links to the current head and becomes the new head. This is the inherited prepend.
  • index == len(self): the tail reference points straight at the last node, so the new node is linked after it and becomes the new tail. This is the inherited append.
  • Both at once, when the list is empty: the new node is both head and tail.

Anywhere else, the walk follows index - 1 links to the predecessor, and the new node is linked between it and its successor.

Parameters:

Name Type Description Default
index int

The position the new element will occupy. Must satisfy 0 <= index <= len(self).

required
item T

The element to add.

required

Raises:

Type Description
IndexError

If index is negative or greater than len(self).

Complexity
  • Time: O(n); O(index) links are followed, so O(1) at either end
  • Space: O(1) for the new node

Examples:

>>> a = SinglyLinkedList[str](["a", "c"])
>>> a.insert(1, "b")
>>> a
SinglyLinkedList(['a', 'b', 'c'])
>>> a.prepend("_")  # insert at 0: no walk
>>> a.append("d")  # insert at len(a): no walk, thanks to the tail
>>> a
SinglyLinkedList(['_', 'a', 'b', 'c', 'd'])
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def insert(self, index: int, item: T) -> None:
    """Add `item` at `index`, after walking to the node before it.

    A new node is linked in; no existing element moves. An array must
    shift every element after `index` one slot to the right to make room,
    which costs O(n) however far the walk is.

    The walk is what costs. Three cases avoid it entirely:

    - `index == 0`: the new node links to the current head and becomes the
      new head. This is the inherited `prepend`.
    - `index == len(self)`: the tail reference points straight at the last
      node, so the new node is linked after it and becomes the new tail.
      This is the inherited `append`.
    - Both at once, when the list is empty: the new node is both head and
      tail.

    Anywhere else, the walk follows `index - 1` links to the predecessor,
    and the new node is linked between it and its successor.

    Args:
        index: The position the new element will occupy. Must satisfy
            `0 <= index <= len(self)`.
        item: The element to add.

    Raises:
        IndexError: If `index` is negative or greater than `len(self)`.

    Complexity:
        - Time: O(n); O(index) links are followed, so O(1) at either end
        - Space: O(1) for the new node

    Examples:
        >>> a = SinglyLinkedList[str](["a", "c"])
        >>> a.insert(1, "b")
        >>> a
        SinglyLinkedList(['a', 'b', 'c'])
        >>> a.prepend("_")  # insert at 0: no walk
        >>> a.append("d")  # insert at len(a): no walk, thanks to the tail
        >>> a
        SinglyLinkedList(['_', 'a', 'b', 'c', 'd'])
    """
    # verify that the given index is within the valid range; one past the
    # last element is allowed, which adds at the end
    if index < 0 or index > self._size:
        raise IndexError("index out of range")

    node = _Node(item)

    if self._head is None or self._tail is None:
        # the list is empty, so the new node is both ends
        self._head = node
        self._tail = node
    elif index == 0:
        # link the new node in front of the current head
        node.next = self._head
        self._head = node
    elif index == self._size:
        # link the new node after the current tail
        self._tail.next = node
        self._tail = node
    else:
        # walk to the predecessor and splice the new node in after it;
        # the bounds check above guarantees both neighbours exist
        previous_node = self._node_at(index - 1)
        node.next = previous_node.next
        previous_node.next = node

    self._size += 1

pop

pop(index: int) -> T

Remove and return the element at index, unlinking its node.

Unlinking a node means pointing its predecessor's next past it. At index == 0 there is no predecessor: the head simply moves to the second node, which is why the inherited pop_front is O(1). Anywhere else, the walk follows index - 1 links to reach the predecessor, because nodes carry no backward link.

That walk is why the inherited pop_back is O(n). The tail reference finds the last node instantly, but unlinking it needs the node before it, and only a walk from the head can find that. If the removed node was the tail, the predecessor becomes the new tail. If it was the only node, both references are cleared.

Parameters:

Name Type Description Default
index int

The position of the element to remove. Must satisfy 0 <= index < len(self).

required

Returns:

Type Description
T

The element that was at index.

Raises:

Type Description
IndexError

If index is negative or not less than len(self).

Complexity
  • Time: O(n); O(index) links are followed, so O(1) at the front
  • Space: O(1)

Examples:

>>> a = SinglyLinkedList[int]([1, 2, 3])
>>> a.pop(1)
2
>>> a.pop_front()  # pop at 0: no walk
1
>>> a.pop_back()  # pop at len(a) - 1: walks the whole chain
3
>>> a.pop(0)
Traceback (most recent call last):
    ...
IndexError: index out of range
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def pop(self, index: int) -> T:
    """Remove and return the element at `index`, unlinking its node.

    Unlinking a node means pointing its predecessor's `next` past it. At
    `index == 0` there is no predecessor: the head simply moves to the
    second node, which is why the inherited `pop_front` is O(1). Anywhere
    else, the walk follows `index - 1` links to reach the predecessor,
    because nodes carry no backward link.

    That walk is why the inherited `pop_back` is O(n). The tail reference
    finds the last node instantly, but unlinking it needs the node before
    it, and only a walk from the head can find that. If the removed node
    was the tail, the predecessor becomes the new tail. If it was the only
    node, both references are cleared.

    Args:
        index: The position of the element to remove. Must satisfy
            `0 <= index < len(self)`.

    Returns:
        The element that was at `index`.

    Raises:
        IndexError: If `index` is negative or not less than `len(self)`.

    Complexity:
        - Time: O(n); O(index) links are followed, so O(1) at the front
        - Space: O(1)

    Examples:
        >>> a = SinglyLinkedList[int]([1, 2, 3])
        >>> a.pop(1)
        2
        >>> a.pop_front()  # pop at 0: no walk
        1
        >>> a.pop_back()  # pop at len(a) - 1: walks the whole chain
        3
        >>> a.pop(0)
        Traceback (most recent call last):
            ...
        IndexError: index out of range
    """
    # verify that the given index is within the valid range
    if index < 0 or index >= self._size or self._head is None:
        raise IndexError("index out of range")

    if index == 0:
        # there is no predecessor: the head simply moves along one node
        removed_node = self._head
        self._head = removed_node.next
        previous_node = None
    else:
        # walk to the predecessor and point its link past the removed node;
        # the bounds check above guarantees both nodes exist
        previous_node = self._node_at(index - 1)
        removed_node = previous_node.next
        assert removed_node is not None
        previous_node.next = removed_node.next

    # if the removed node was the tail, the predecessor (or nothing, if the
    # list is now empty) becomes the new tail
    if removed_node is self._tail:
        self._tail = previous_node

    self._size -= 1

    return removed_node.item

reverse

reverse() -> None

Reverse the list in place.

Walks the list once, re-pointing each node's next at the node before it. Three references track progress: the previous node, the current node, and the next node (saved before its link is overwritten). When the walk ends, the old tail is the new head and the old head is the new tail. No nodes are allocated or copied.

This overrides the inherited default, which swaps a[i] with its mirror from both ends inward. That algorithm suits an array, where every index is O(1) away, but here each index is a walk from the head, so the default would be O(n²). Rewiring the links is O(n).

Complexity
  • Time: O(n)
  • Space: O(1)

Examples:

>>> a = SinglyLinkedList[int]([1, 2, 3])
>>> a.reverse()
>>> a
SinglyLinkedList([3, 2, 1])
>>> a.append(0)  # the tail reference was updated too
>>> a
SinglyLinkedList([3, 2, 1, 0])
Source code in cs_survival_kit/data_structures/singly_linked_list.py · View on GitHub
def reverse(self) -> None:
    """Reverse the list in place.

    Walks the list once, re-pointing each node's `next` at the node before
    it. Three references track progress: the previous node, the current
    node, and the next node (saved before its link is overwritten). When
    the walk ends, the old tail is the new head and the old head is the new
    tail. No nodes are allocated or copied.

    This overrides the inherited default, which swaps `a[i]` with its
    mirror from both ends inward. That algorithm suits an array, where
    every index is O(1) away, but here each index is a walk from the head,
    so the default would be O(n²). Rewiring the links is O(n).

    Complexity:
        - Time: O(n)
        - Space: O(1)

    Examples:
        >>> a = SinglyLinkedList[int]([1, 2, 3])
        >>> a.reverse()
        >>> a
        SinglyLinkedList([3, 2, 1])
        >>> a.append(0)  # the tail reference was updated too
        >>> a
        SinglyLinkedList([3, 2, 1, 0])
    """
    self._tail = self._head

    previous_node: _Node[T] | None = None
    current_node: _Node[T] | None = self._head

    while current_node is not None:
        next_node = current_node.next

        current_node.next = previous_node

        previous_node = current_node
        current_node = next_node

    self._head = previous_node

additive

additive(step: int) -> GrowthPolicy

Build a growth policy that adds a fixed number of slots on each resize.

This policy is a counterexample to geometric growth. A resize happens every step appends and copies every element, so n appends perform about n² / (2 · step) copies in total. That is amortized O(n) per append, and O(n²) to build an array of n elements. In exchange, a resize never leaves more than step unused slots.

Parameters:

Name Type Description Default
step int

The number of slots to add on each resize. Must be at least 1.

required

Returns:

Type Description
GrowthPolicy

A growth policy that returns capacity + step.

Raises:

Type Description
ValueError

If step is less than 1.

Examples:

>>> additive(16)(4)
20
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def additive(step: int) -> GrowthPolicy:
    """Build a growth policy that adds a fixed number of slots on each resize.

    This policy is a counterexample to geometric growth. A resize happens
    every `step` appends and copies every element, so n appends perform about
    n² / (2 · step) copies in total. That is amortized O(n) per append, and
    O(n²) to build an array of n elements. In exchange, a resize never leaves
    more than `step` unused slots.

    Args:
        step: The number of slots to add on each resize. Must be at least 1.

    Returns:
        A growth policy that returns `capacity + step`.

    Raises:
        ValueError: If `step` is less than 1.

    Examples:
        >>> additive(16)(4)
        20
    """
    # verify the growth step size is valid; if a step size less than 1 is used,
    # the array will not grow
    if step <= 0:
        raise ValueError("step must be greater than 0")

    def add(curr_capacity: int) -> int:
        return curr_capacity + step

    return add

doubling

doubling(curr_capacity: int) -> int

Double the capacity on each resize.

The default growth policy for DynamicArray. Equivalent to geometric(2.0).

Parameters:

Name Type Description Default
curr_capacity int

The current capacity of the array.

required

Returns:

Type Description
int

Twice the current capacity.

Examples:

>>> doubling(4)
8
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def doubling(curr_capacity: int) -> int:
    """Double the capacity on each resize.

    The default growth policy for `DynamicArray`. Equivalent to
    `geometric(2.0)`.

    Args:
        curr_capacity: The current capacity of the array.

    Returns:
        Twice the current capacity.

    Examples:
        >>> doubling(4)
        8
    """
    return curr_capacity * 2

geometric

geometric(factor: float) -> GrowthPolicy

Build a growth policy that multiplies the capacity by a constant factor.

Any factor greater than 1 gives amortized O(1) appends. The factor trades memory for copying: a resize copies every element, so a larger factor means fewer resizes, but right after a resize the capacity is about factor times the number of elements. CPython's list over-allocates by roughly ⅛, choosing low memory overhead over fewer resizes.

Because the new capacity is truncated to an integer, a small factor applied to a small capacity could round back down to the current capacity. The returned policy always grows the capacity by at least 1 to prevent that.

Parameters:

Name Type Description Default
factor float

The multiplier applied to the current capacity. Must be finite and greater than 1.

required

Returns:

Type Description
GrowthPolicy

A growth policy that returns max(capacity + 1, int(capacity * factor)).

Raises:

Type Description
ValueError

If factor is not finite or is not greater than 1.

Examples:

>>> geometric(1.5)(4)
6
>>> geometric(1.1)(4)  # int(4.4) == 4, so the policy grows by 1 instead
5
Source code in cs_survival_kit/data_structures/dynamic_array.py · View on GitHub
def geometric(factor: float) -> GrowthPolicy:
    """Build a growth policy that multiplies the capacity by a constant factor.

    Any factor greater than 1 gives amortized O(1) appends. The factor trades
    memory for copying: a resize copies every element, so a larger factor means
    fewer resizes, but right after a resize the capacity is about `factor` times
    the number of elements. CPython's `list` over-allocates by roughly 1/8,
    choosing low memory overhead over fewer resizes.

    Because the new capacity is truncated to an integer, a small factor applied
    to a small capacity could round back down to the current capacity. The
    returned policy always grows the capacity by at least 1 to prevent that.

    Args:
        factor: The multiplier applied to the current capacity. Must be finite
            and greater than 1.

    Returns:
        A growth policy that returns `max(capacity + 1, int(capacity * factor))`.

    Raises:
        ValueError: If `factor` is not finite or is not greater than 1.

    Examples:
        >>> geometric(1.5)(4)
        6
        >>> geometric(1.1)(4)  # int(4.4) == 4, so the policy grows by 1 instead
        5
    """
    # verify the geometric growth factor is valid for a strictly increasing
    # geometric growth policy
    if not math.isfinite(factor) or factor <= 1:
        raise ValueError("factor must be finite and greater than 1")

    def growth(curr_capacity: int) -> int:
        # compute the new capacity given the current capacity and the geometric growth
        # factor
        new_geometric_capacity: int = int(curr_capacity * factor)

        # the new capacity must be at least 1 greater than the current capacity
        # this max() statement ensures the growth policy generates a sequence that
        # is strictly increasing even for small geometric factor values and small
        # capacities
        return max(curr_capacity + 1, new_geometric_capacity)

    return growth