3  Examples

Every function, demonstrated

# Install graphfast from GitHub if it isn't already available...
# if (!requireNamespace("graphfast", quietly = TRUE)) {
#   if (!requireNamespace("remotes", quietly = TRUE)) install.packages("remotes")
#   remotes::install_github("KyleHaynes/graphfast")
# }

# # ...along with the optional (Suggests) packages these examples use.
# for (pkg in c("data.table", "visNetwork")) {
#   if (!requireNamespace(pkg, quietly = TRUE)) install.packages(pkg)
# }

print(.libPaths())
[1] "C:/Users/kyleh/AppData/Local/R/win-library/4.5" "C:/Program Files/R/R-4.5.0/library"            
library(graphfast)
library(data.table)
set.seed(1)

This page walks through every exported function with small, self-contained examples. The chunks are live — the output you see is produced when the site is rendered.

A graph in graphfast is just a two-column edge list: a matrix or data.table where each row is an edge from -> to. We’ll reuse this little graph with three components throughout:

edges <- matrix(c(
  1, 2,
  2, 3,
  3, 4,   # component A: {1,2,3,4}
  5, 6,   # component B: {5,6}
  7, 8,
  8, 9    # component C: {7,8,9}
), ncol = 2, byrow = TRUE)
edges
     [,1] [,2]
[1,]    1    2
[2,]    2    3
[3,]    3    4
[4,]    5    6
[5,]    7    8
[6,]    8    9

4 Connected components

4.1 find_connected_components()

The workhorse. Returns one component id per node, the size of each component, and the total count.

cc <- find_connected_components(edges)
cc$n_components
[1] 3
cc$component_sizes
[1] 4 2 3
cc$components          # component id for node 1, 2, 3, ...
[1] 1 1 1 1 2 2 3 3 3

Node 5 and node 6 share component 2; node 1 is in component 1:

cc$components[c(1, 5, 6)]
[1] 1 2 2

compress = FALSE keeps the raw Union–Find root labels instead of tidy consecutive ids (the counts are still correct):

raw <- find_connected_components(edges, compress = FALSE)
raw$n_components
[1] 3
head(raw$components)
[1] 0 0 0 0 4 4

You can pass n_nodes explicitly to skip the scan for the maximum id (handy when you already know it, or to include isolated nodes with no edges):

find_connected_components(edges, n_nodes = 12)$n_components   # nodes 10,11,12 are singletons
[1] 6

4.2 find_connected_components_safe()

When node ids are sparse or large, a dense parent array up to the maximum id would waste memory. This version remaps ids to 1..(unique nodes) with fastmatch, runs the algorithm, then maps the answer back. The returned components vector is named by the original ids.

sparse_edges <- matrix(c(
  1000, 2000,
  2000, 3000,
  9000, 9001
), ncol = 2, byrow = TRUE)

res <- find_connected_components_safe(sparse_edges, verbose = FALSE)
res$n_components
[1] 2
res$components            # names are the original node ids
1000 2000 3000 9000 9001 
   1    1    1    2    2 
res$memory_info$memory_saved_gb
[1] 0.0001005381

4.3 find_connected_components_large()

Handles ids beyond the 32-bit integer limit (≈ 2.1e9) by keeping them as doubles until after remapping. Set verbose = TRUE to see what it decided.

big_edges <- matrix(c(
  1e10, 2e10,
  2e10, 3e10,
  7e10, 8e10
), ncol = 2, byrow = TRUE)

big <- find_connected_components_large(big_edges, verbose = FALSE)
big$n_components
[1] 2
big$components
1e+10 2e+10 3e+10 7e+10 8e+10 
    1     1     1     2     2 

5 Per-edge components

Often you don’t want a node-indexed vector — you want a component id attached to each edge / row of your data.

5.1 get_edge_components()

ec <- get_edge_components(edges)
ec$from_components       # component of each edge's 'from' node
[1] 1 1 1 2 3 3
ec$to_components         # ... and its 'to' node (always equal for a real edge)
[1] 1 1 1 2 3 3
ec$n_components
[1] 3

return_type = "combined" returns just the from_components vector (same length as the number of edges):

get_edge_components(edges, return_type = "combined")
[1] 1 1 1 2 3 3

5.2 group_edges() and edge_components()

group_edges() is the convenience alias returning one component id per edge row. edge_components() does the same but takes a data.table and column names, so it drops straight into a data.table expression.

# Matrix in -> vector out
group_edges(edges)
[1] 1 1 1 2 3 3
dt <- data.table(from = c(1, 2, 5), to = c(2, 3, 6))
dt[, component := edge_components(.SD, "from", "to")]
dt
from to component
1 2 1
2 3 1
5 6 3

5.3 add_component_column()

Adds a component column to a data.table by reference (no copy), mapping node ids with fastmatch internally. Works with any column names.

dt2 <- data.table(source = c(1, 2, 5, 7), target = c(2, 3, 6, 8), weight = runif(4))
add_component_column(dt2, from_col = "source", to_col = "target")
source target weight component
1 2 0.2655087 1
2 3 0.3721239 1
5 6 0.5728534 2
7 8 0.9082078 3
dt2
source target weight component
1 2 0.2655087 1
2 3 0.3721239 1
5 6 0.5728534 2
7 8 0.9082078 3

Use in_place = FALSE to leave the original untouched and get a copy back, and verbose = TRUE for per-step timing on large data:

out <- add_component_column(dt2, from_col = "source", to_col = "target",
                            component_col = "grp", in_place = FALSE)
out
source target weight component grp
1 2 0.2655087 1 1
2 3 0.3721239 1 1
5 6 0.5728534 2 2
7 8 0.9082078 3 3

6 Connectivity and distance

6.1 are_connected()

Batched “are these two nodes in the same component?” queries. Builds Union–Find once, then answers each pair in near-constant time.

queries <- matrix(c(
  1, 4,   # same component A
  1, 5,   # A vs B
  7, 9    # same component C
), ncol = 2, byrow = TRUE)

are_connected(edges, queries)
[1]  TRUE FALSE  TRUE

6.2 shortest_paths()

Hop-distance (unweighted BFS) for each query pair. -1 means no path exists.

shortest_paths(edges, queries)     # 1->4 is 3 hops; 1->5 unreachable; 7->9 is 2 hops
[1]  3 -1  2

max_distance stops the search early — anything farther returns -1:

shortest_paths(edges, matrix(c(1, 4), ncol = 2), max_distance = 2)   # 1->4 needs 3 hops
[1] -1

6.3 graph_statistics()

Degree summary and density in a single pass — no adjacency matrix.

stats <- graph_statistics(edges)
stats$n_nodes
[1] 9
stats$n_edges
[1] 6
stats$density
[1] 0.1666667
stats$degree_stats
$min
[1] 1

$max
[1] 2

$mean
[1] 1.333333

7 Entity resolution / grouping

These solve “group rows that share a value across any of these columns” as a connected-components problem.

7.1 group_id()

The general engine. Give it a data.frame/data.table and the columns to match on (regex by default), plus values that should never glue rows together (incomparables).

customers <- data.frame(
  id     = 1:6,
  phone1 = c("123-456-7890", "987-654-3210", "123-456-7890", "",            "555-0123", ""),
  phone2 = c("",             "987-654-3210", "555-1234",     "123-456-7890","",         "555-0123"),
  email  = c("a@x.com",      "b@x.com",      "c@x.com",      "a@x.com",     "d@x.com",  "d@x.com"),
  stringsAsFactors = FALSE
)

# cols = "phone" matches phone1 + phone2 by regex; "" is incomparable
group_id(customers, cols = "phone", incomparables = c(""))
[1] 1 2 1 1 3 3

Match across all three columns by passing exact names (use_regex = FALSE):

group_id(customers, cols = c("phone1", "phone2", "email"),
         use_regex = FALSE, incomparables = c(""))
[1] 1 2 1 1 3 3

return_details = TRUE returns a rich object with a print method showing which shared values created which groups:

res <- group_id(customers, cols = c("phone1", "phone2", "email"),
                use_regex = FALSE, incomparables = c(""), return_details = TRUE)
res
Multi-Column Group ID Results
=============================
Total rows: 6 
Number of groups: 3 
Group sizes: 3, 1, 2 
Columns used: 3 
Case sensitive: TRUE 
Min group size: 1 
Incomparables:  

Group IDs (first 20):
[1] 1 2 1 1 3 3

Shared values creating groups (first 10):
'd@x.com': rows 5, 6
'123-456-7890': rows 1, 3, 4
'987-654-3210': rows 2, 2
'555-0123': rows 5, 6
'a@x.com': rows 1, 4

Other useful arguments:

# Only keep groups of at least 2 (singletons get id 0)
group_id(customers, cols = "phone", incomparables = c(""), min_group_size = 2)
[1] 1 0 1 1 2 2
# Case-insensitive matching
group_id(data.frame(a = c("ABC", "abc", "xyz")), case_sensitive = FALSE)
[1] 1 1 2

7.2 add_group_ids()

The data.table wrapper for group_id() — adds the group column by reference.

dt_cust <- as.data.table(customers)
add_group_ids(dt_cust, cols = c("phone1", "phone2", "email"),
              group_col = "entity_id", use_regex = FALSE, incomparables = c(""))
dt_cust
id phone1 phone2 email entity_id
1 123-456-7890 a@x.com 1
2 987-654-3210 987-654-3210 b@x.com 2
3 123-456-7890 555-1234 c@x.com 1
4 123-456-7890 a@x.com 1
5 555-0123 d@x.com 3
6 555-0123 d@x.com 3

7.3 set_group_id()

An alternative grouping engine using the bipartite edge reduction (melt → edges → components). It requires an id column and writes the group id back by reference.

7.3.1 Basic grouping

Two rows land in the same group when they share any value across the chosen columns. Here rows 1 and 3 share phone "123", and rows 1 and 4 share phone "111", so all three collapse into one group.

dt_sg <- data.table(
  id     = 1:5,
  phone1 = c("123", "456", "123", "789", "456"),
  phone2 = c("111", "222", "333", "111", "222")
)
set_group_id(dt_sg, cols = "phone", var_output_name = "gid")
id phone1 phone2 gid
1 123 111 1
2 456 222 2
3 123 333 1
4 789 111 1
5 456 222 2
dt_sg
id phone1 phone2 gid
1 123 111 1
2 456 222 2
3 123 333 1
4 789 111 1
5 456 222 2

7.3.2 Choosing columns with a regex

cols is a regular expression matched against the column names, so one pattern can pull in several value columns at once. Here "phone|email" grabs all three.

dt_re <- data.table(
  id     = 1:5,
  phone1 = c("123-456-7890", "987-654-3210", "123-456-7890", "", "555-0123"),
  phone2 = c("", "987-654-3210", "555-1234", "123-456-7890", ""),
  email  = c("john@email.com", "jane@email.com", "bob@email.com",
             "john@email.com", "alice@email.com")
)
set_group_id(dt_re, cols = "phone|email")
id phone1 phone2 email gid
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 alice@email.com 3
dt_re
id phone1 phone2 email gid
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 alice@email.com 3

7.3.3 Naming the output column

var_output_name controls the column written back (default "gid").

dt_name <- copy(dt_re)[, gid := NULL]
set_group_id(dt_name, cols = "phone", var_output_name = "household")
id phone1 phone2 email household
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 alice@email.com 3
dt_name
id phone1 phone2 email household
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 alice@email.com 3

7.3.5 Treating column sets in isolation

By default every selected column shares one value namespace, so a phone number appearing in an email column would link the two. Pass isolate — a list of column sets — to keep each set’s values separate: identical text only links rows within the same set. The columns to group on come from the union of the sets, and cols is ignored.

Note row 5: its phone number "123-456-7890" sits in the email column. Because email is isolated from the phone columns, that value does not pull row 5 into the phone group.

dt_iso <- data.table(
  id     = 1:5,
  phone1 = c("123-456-7890", "987-654-3210", "123-456-7890", "", "555-0123"),
  phone2 = c("", "987-654-3210", "555-1234", "123-456-7890", ""),
  email  = c("john@email.com", "jane@email.com", "bob@email.com",
             "john@email.com", "123-456-7890")
)
set_group_id(dt_iso, isolate = list(c("phone1", "phone2"), "email"))
id phone1 phone2 email gid
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 123-456-7890 3
dt_iso
id phone1 phone2 email gid
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 123-456-7890 3

For comparison, the same data without isolation lets the stray phone number in email link row 5 into the phone group:

dt_noiso <- copy(dt_iso)[, gid := NULL]
set_group_id(dt_noiso, cols = "phone|email")
id phone1 phone2 email gid
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 123-456-7890 1
dt_noiso
id phone1 phone2 email gid
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 123-456-7890 1

7.3.6 Returning the edge list of pair connections

Pass return_edges = TRUE to get back the record-to-record edge list — every pair of ids that share a value, with the shared value itself. The group-id column is still written to dt by reference, so you get both the grouping and the underlying connections in one call.

dt_edges <- copy(dt_iso)[, gid := NULL]
edges <- set_group_id(dt_edges, isolate = list(c("phone1", "phone2"), "email"),
                      return_edges = TRUE)
edges          # from / to / value
from to value
1 3 123-456-7890
1 4 123-456-7890
3 4 123-456-7890
1 4 john@email.com
dt_edges       # gid still added by reference
id phone1 phone2 email gid
1 123-456-7890 john@email.com 1
2 987-654-3210 987-654-3210 jane@email.com 2
3 123-456-7890 555-1234 bob@email.com 1
4 123-456-7890 john@email.com 1
5 555-0123 123-456-7890 3

7.3.7 Plotting the linkage with visNetwork

The edge list drops straight into visNetwork: use the id/gid columns for the nodes (colouring by group) and the returned edges for the connections. The shared value becomes the edge tooltip.

library(visNetwork)

nodes <- data.frame(
  id    = dt_edges$id,
  label = paste0("rec ", dt_edges$id),
  group = dt_edges$gid
)
vis_edges <- data.frame(
  from  = edges$from,
  to    = edges$to,
  title = edges$value   # hover shows the value that links the pair
)

visNetwork(nodes, vis_edges, width = "100%", height = "320px") |>
  visNodes(shape = "dot", size = 22) |>
  visOptions(highlightNearest = TRUE) |>
  visLegend()
library(visNetwork)

nodes <- data.table(
  id = 1:5,
  phone1 = c("123-456-7890", "987-654-3210", "123-456-7890", "", "555-0123"),
  phone2 = c("", "987-654-3210", "555-1234", "123-456-7890", ""),
  email = c("john@email.com", "jane@email.com", "bob@email.com",
            "john@email.com", "123-456-7890")
)
nodes[, title := paste0("phone1: ", phone1, "</br>phone2: ", phone2, "</br>email: ", email)]

edges <- set_group_id(nodes, isolate = list(c("phone1", "phone2"), "email"),
                      return_edges = TRUE)

visNetwork(nodes, edges, width = "100%", height = "320px") |>
  visNodes(shape = "dot", size = 22) |>
  visOptions(highlightNearest = TRUE) |>
  visLegend()
library(visNetwork)

n_queries <- 100
query_nodes <- sample(1:100, 2 * n_queries, replace = TRUE)
batch_queries <- data.table(matrix(query_nodes, ncol = 2))
batch_queries[, id := 1:.N]
batch_queries[, title := paste0("V1: ", V1, ", V2: ", V2)]

edges <- set_group_id(batch_queries, "V", return_edges = TRUE)

visNetwork(batch_queries, edges, width = "100%", height = "320px") |>
  visNodes(shape = "dot", size = 22) |>
  visOptions(highlightNearest = TRUE) |>
  visLegend()

8 Putting it together

A realistic data.table flow: take an edge list, attach components, and inspect the largest blob — all without ever building a graph object.

g <- data.table(
  from = sample(1:50, 200, replace = TRUE),
  to   = sample(1:50, 200, replace = TRUE)
)
g <- g[from != to]

g[, component := edge_components(.SD, "from", "to")]

# Size of each component, largest first
sizes <- g[, .N, by = component][order(-N)]
head(sizes)
component N
1 194

See Benchmarks for how these scale to millions and hundreds of millions of edges, and Reference for the full argument lists.