Handling N-dimensional Lists in Elixir with Ndim

Process the n-dimensional list #

In data processing, I constantly have to work with the n-dimensional list, which something looks like these:

# 2 dimensional list (matrix)
[
  [1, 2],
  [3, 4]
]

As an elixir alchemist, use Enum.map/2 to transform list is our second nature. However, when dealing with nested lists, transforming elements at specific depths requires multiple nested mapping operations. Consider these approaches:

[[1, 2], [3, 4]]
|> Enum.map(fn row ->
     Enum.map(row, fn i ->
       i * 10
     end)
   end)

$> [[10, 20], [30, 40]]

And the things get worse when we like to work with 3-dimensional list or deeper:

[
  [
    [1, 2]
  ],
  [
    [3, 4]
  ]
]
|> Enum.map(fn row ->
     Enum.map(fn column ->
       Enum.map(fn i ->
        i * 10
       end)
     end)
end)

Function Lifting through Functors in Haskell #

In Haskell, a lazy evaluation functional programming language, lists implement the Functor typeclass. This allows us to ’lift’ functions that work on single values into a functoin works on lists, using fmap.

For example, if we have a function (+ 1) that adds 1 to a single number, fmap (+ 1) lifts this function to work on an entire list of numbers, transforming each element. This is conceptually similar to Enum.map/2 in Elixir.

And the key feature of function lifting is its composability through functor composition. Consider how the same function can be lifted to operate on increasingly nested structures:

-- Function operating on a single value
(+ 1) 1
--> 2

-- Function lifted to operate on a list (1-dimensional)
fmap (+ 1) [1, 2]
--> [2, 3]

-- Function lifted to operate on a nested list (2-dimensional)
(fmap . fmap) (+ 1) [[1, 2], [3, 4]]
--> [[2, 3], [4, 5]]

-- Function lifted to operate on a doubly nested list (3-dimensional)
(fmap . fmap . fmap) (+ 1) [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
--> [[[2, 3], [4, 5]], [[6, 7], [8, 9]]]

The composition of fmap with itself (fmap . fmap) allows us to reach deeper into nested structures while preserving their dimensionality. Each additional composition of fmap adds another level of penetration into the nested structure.

Using Kernel.get_in/3 #

In Elixir, we can achieve similar behavior using Kernel.update_in/3 with Access.all/1. This approach allows us to traverse and transform nested lists at specific depths:

# list
[1, 2] |> update_in([Access.all()], &(&1 + 1)) #=> [2, 3]

# 2 dimensional list
[[1, 2], [3, 4]] |> update_in([Access.all(), Access.all()], &(&1 + 1))
#=> [[2, 3], [4, 5]]

# 3 dimensional list
[[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
|> update_in([Access.all(), Access.all(), Access.all()], &(&1 + 1))
#=> [[[2, 3], [4, 5]], [[6, 7], [8, 9]]]

While this approach works, it requires repetitive use of Access.all() for each level of nesting, making it syntactically verbose.

Dimensional Mapping with Ndim #

The Ndim library, named for its ability to handle n-dimensional lists, provides a more concise syntax for transforming nested lists. It offers both dimension-specific functions and a general-purpose mapping function:

# Transform elements in a 2-dimensional list
[[1, 2], [3, 4]]
|> Ndim.d2map(&(&1 + 1))
#=> [[2, 3], [4, 5]]

# Transform elements in a 3-dimensional list
[[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
|> Ndim.d3map(&(&1 + 1))
#=> [[[2, 3], [4, 5]], [[6, 7], [8, 9]]]

In addition to the dimension-specific functions (d2map/2 through d5map/2), Ndim provides a general-purpose dmap/3 that can operate on lists of any dimension:

# Transform elements at any specified dimension
nested_list |> Ndim.dmap(dimension, transformation_function)

# Example: Transform a 6-dimensional list
deeply_nested_list |> Ndim.dmap(6, &(&1 + 1))

Converting N-dimensional Lists to Coordinate Maps #

The Ndim library also provides functionality to transform n-dimensional lists into coordinate maps, where each value is keyed by its dimensional coordinates. Currently supports 2-dimensional and 3-dimensional structures:

# Converting a 2-dimensional list to a coordinate map
[[1, 2], [3, 4]]
|> Ndim.to_coordinate_map()
#=> %{{0, 0} => 1, {0, 1} => 2, {1, 0} => 3, {1, 1} => 4}

# Converting a 3-dimensional list to a coordinate map
[[[1, 2]], [[3, 4]]]
|> Ndim.to_coordinate_map()
#=> %{{0, 0, 0} => 1, {0, 0, 1} => 2, {1, 0, 0} => 3, {1, 0, 1} => 4}

This coordinate mapping is particularly useful for:

  • Sparse matrix operations
  • Direct coordinate-based access
  • Grid-based calculations

Hope you like it, and happy hacking!