Packages

Ndim provides a clean interface for working with n-dimensional nested lists in Elixir.

Current section

Files

Jump to
ndim lib ndim.ex
Raw

lib/ndim.ex

defmodule Ndim do
@moduledoc """
The `Ndim` lib provides functions for working with n-dimensional lists.
This module is particularly useful when you need to:
* Map functions over deeply nested lists at a specific level while maintaining their shape
* Transform between the n-dimensional list and the coordinated map
## Terminology
### N-dimensional List
An n-dimensional list is a regularly nested list structure where n represents the depth
of nesting. Each level must maintain consistent structure:
# 1-dimensional list (vector)
```elixir
[1, 2, 3]
```
# 2-dimensional list (matrix)
```elixir
[[1, 2, 3],
[4, 5, 6]]
```
# 3-dimensional list (cube)
```elixir
[[[1, 2], [3, 4]],
[[5, 6], [7, 8]]]
```
### Coordinate map
A coordinate map is a map representation of an n-dimensional list where each value
is keyed by its dimensional coordinates. This is useful for sparse data or when
you need direct coordinate access:
#### From 2-dimensional list to 2d coordinate map
```elixir
[
[1, 2], --> %{{0, 0} => 1, {0, 1} => 2,
[3, 4] {1, 0} => 3, {1, 1} => 4}
]
```
#### From 3-dimensional list to 3d coordinate map
```elixir
[
[[1, 2]], --> %{{0, 0, 0} => 1, {0, 0, 1} => 2,
[[3, 4]] {1, 0, 0} => 3, {1, 0, 1} => 4}
]
```
## Core functions
* `d2map/2` ... `d5map/2` - Map a function over a 2-dimensonal list (upto 5)
* `dmap/3` - The general version which maps a function over elements at any specified dimensional list
* `to_coordinate_map/1` - Transform a n-dimensional list to a coordinate map
## Examples
Map a function over a 2-dimensional list
```elixir
iex> numbers = [[1, 2], [3, 4]]
iex> Ndim.d2map(numbers, fn x -> x * 2 end)
[[2, 4], [6, 8]]
```
Transform a 2-dimensional list to a coordinate map
```elixir
iex> vector = [[1, 2], [3, 4]]
iex> Ndim.to_coordinate_map(vector)
%{{0, 0} => 1, {0, 1} => 2, {1, 0} => 3, {1, 1} => 4}
```
"""
@doc """
Maps a function over a specified dimensional list.
Takes a n-dimensional list as the first argument, followed by the nesting level, then a mapping function.
## Parameters
* `list` - A n-dimensional nested list to transform
* `dim` - The dimension at which to apply the function (1-based indexing)
* `fun` - The function to apply at the specified dimension
## Examples
```elixir
iex> numbers = [[1, 2], [3, 4], [5, 6]]
iex> Ndim.dmap(numbers, 2, fn i -> i * 10 end)
[[10, 20], [30, 40], [50, 60]]
iex> nested = [[["a", "b"], ["c", "d"]], [["e", "f"], ["g", "h"]], [["i", "j"], ["k", "l"]]]
iex> Ndim.dmap(nested, 3, &String.upcase/1)
[[["A", "B"], ["C", "D"]], [["E", "F"], ["G", "H"]], [["I", "J"], ["K", "L"]]]
```
"""
def dmap(list, dim, func) do
dim_accessors = List.duplicate(Access.all(), dim)
update_in(list, dim_accessors, func)
end
@doc """
Maps a function over a 2-dimensional list.
Takes a 2-dimensional list as the first argument, followed by a mapping function.
This is equivalent to calling `dmap(list, 2, fun)`.
## Examples
iex> numbers = [[1, 2], [3, 4], [5, 6]]
iex> Ndim.d2map(numbers, fn x -> x * 2 end)
[[2, 4], [6, 8], [10, 12]]
iex> strings = [["a", "b"], ["c", "d"]]
iex> Ndim.d2map(strings, &String.upcase/1)
[["A", "B"], ["C", "D"]]
"""
def d2map(list, fun), do: dmap(list, 2, fun)
@doc """
Maps a function over a 3-dimensional list.
Takes a 3-dimensional list as the first argument, followed by a mapping function.
This is equivalent to calling `dmap(list, 3, fun)`.
## Examples
iex> numbers = [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
iex> Ndim.d3map(numbers, fn x -> x + 1 end)
[[[2, 3], [4, 5]], [[6, 7], [8, 9]]]
iex> strings = [[["hello", "world"], ["foo"]], [["bar"], ["baz", "qux"]]]
iex> Ndim.d3map(strings, &String.capitalize/1)
[[["Hello", "World"], ["Foo"]], [["Bar"], ["Baz", "Qux"]]]
"""
def d3map(list, fun), do: dmap(list, 3, fun)
@doc """
Maps a function over a 4-dimensional list.
Takes a 4-dimensional list as the first argument, followed by a mapping function.
This is equivalent to calling `dmap(list, 4, fun)`.
## Examples
iex> numbers = [[[[1, 2], [3]], [[4, 5]]], [[[6]], [[7, 8], [9]]]]
iex> Ndim.d4map(numbers, fn x -> x * 2 end)
[[[[2, 4], [6]], [[8, 10]]], [[[12]], [[14, 16], [18]]]]
iex> strings = [[[[{"a", 1}]]], [[[{"b", 2}]]]]
iex> Ndim.d4map(strings, fn {str, num} -> {String.upcase(str), num} end)
[[[[{"A", 1}]]], [[[{"B", 2}]]]]
"""
def d4map(list, fun), do: dmap(list, 4, fun)
@doc """
Maps a function over a 5-dimensional list.
Takes a 5-dimensional list as the first argument, followed by a mapping function.
This is equivalent to calling `dmap(list, 5, fun)`.
## Examples
iex> numbers = [[[[[1]], [[2]]], [[[3]]]], [[[[4]]], [[[5]], [[6]]]]]
iex> Ndim.d5map(numbers, fn x -> x + 10 end)
[[[[[11]], [[12]]], [[[13]]]], [[[[14]]], [[[15]], [[16]]]]]
iex> data = [[[[[%{x: 1}]], [[%{x: 2}]]]], [[[[%{x: 3}]]]]]
iex> Ndim.d5map(data, fn map -> Map.update!(map, :x, & &1 * 2) end)
[[[[[%{x: 2}]], [[%{x: 4}]]]], [[[[%{x: 6}]]]]]
"""
def d5map(list, fun), do: dmap(list, 5, fun)
@doc """
Converts an n-dimensional nested list into a coordinate map where each value
is keyed by its dimensional coordinates. The dimension can be specified to
handle specific cases, or automatically detected from the list structure.
## Parameters
* `list` - An n-dimensional nested list to convert
* `dimension` - (Optional) The expected dimension of the nested list
## Examples
# Automatic dimension detection
iex> matrix = [[1, 2], [3, 4]]
iex> Ndim.to_coordinate_map(matrix)
%{{0, 0} => 1, {0, 1} => 2, {1, 0} => 3, {1, 1} => 4}
iex> cube = [[[1, 2]], [[3, 4]]]
iex> Ndim.to_coordinate_map(cube)
%{{0, 0, 0} => 1, {0, 0, 1} => 2, {1, 0, 0} => 3, {1, 0, 1} => 4}
# Explicitly specifying 2D
iex> matrix = [[1, 2], [3, 4]]
iex> Ndim.to_coordinate_map(matrix, 2)
%{{0, 0} => 1, {0, 1} => 2, {1, 0} => 3, {1, 1} => 4}
# Explicitly specifying 3D
iex> cube = [[[1, 2]], [[3, 4]]]
iex> Ndim.to_coordinate_map(cube, 3)
%{{0, 0, 0} => 1, {0, 0, 1} => 2, {1, 0, 0} => 3, {1, 0, 1} => 4}
## Return Value
Returns a map where keys are tuples of coordinates and values are the corresponding
elements from the input list.
Note: Coordinates use zero-based indexing, following programming conventions.
"""
def to_coordinate_map(list, 2) do
for {row, i} <- Enum.with_index(list),
{value, j} <- Enum.with_index(row),
into: %{},
do: {{i, j}, value}
end
def to_coordinate_map(list, 3) do
for {row, x} <- Enum.with_index(list),
{column, y} <- Enum.with_index(row),
{value, z} <- Enum.with_index(column),
into: %{},
do: {{x, y, z}, value}
end
def to_coordinate_map(_list, n),
do: raise(ArgumentError, "to_coordinate_map/2 not implement for #{n} dimensional list (yet)")
def to_coordinate_map(list) do
depth = get_depth(list)
to_coordinate_map(list, depth)
end
def regular?(list) do
try do
check_regular(list)
catch
:irregular -> false
end
end
defp check_regular([]), do: true
defp check_regular([head | tail]) do
if is_list(head) do
# Check if all elements at current level are lists
not Enum.any?(tail, fn x -> not is_list(x) end) or throw(:irregular)
# Check if all lists at current level have the same length
head_length = length(head)
not Enum.any?(tail, fn x -> length(x) != head_length end) or throw(:irregular)
# Check if all sublists have the same type for their first element
first_child_type = list_first_type(head)
for list <- tail do
if list_first_type(list) != first_child_type, do: throw(:irregular)
end
# Recursively check sublist structure
check_regular(head) or throw(:irregular)
else
# Check if all other elements are not lists
not Enum.any?(tail, &is_list/1) or throw(:irregular)
end
end
defp list_first_type([]), do: :empty
defp list_first_type([head | _]), do: is_list(head)
@doc """
Gets the depth (dimension) of a regular nested list. Returns 0 for an empty list,
1 for a flat list, and n for an n-dimensional nested list. Only works with regular
nested structures where elements at the same depth share the same dimensionality.
## Examples
iex> Ndim.get_depth([])
0
iex> Ndim.get_depth([1, 2, 3])
1
iex> Ndim.get_depth([[1, 2], [3, 4]])
2
iex> Ndim.get_depth([[[1, 2]], [[3, 4]]])
3
## Error cases
iex> Ndim.get_depth([1, [2, 3]])
** (ArgumentError) list is not a regular nested structure
iex> Ndim.get_depth([[1, 2], [3]])
** (ArgumentError) list is not a regular nested structure
## Return Value
Returns an integer representing the depth (dimension) of the nested list.
For irregular structures, raises an ArgumentError.
Note: The depth counting starts from 0 for an empty list, 1 for a flat list,
and increments by one for each level of regular nesting.
"""
def get_depth(list) do
if regular?(list) do
get_depth(list, :validate)
else
raise ArgumentError, "list is not a regular nested structure"
end
end
def get_depth([], :validate), do: 0
def get_depth([head | _], :validate) when not is_list(head), do: 1
def get_depth([head | _], :validate), do: 1 + get_depth(head, :validate)
end