Mikko
Mikko

Reputation: 7755

Sequential citation numbering in R: separate numbers by hyphen, if sequential - add comma if not

I want to generate sequential citation numbers for a figure in R. The numbers should be separated by a hyphen, if they are sequential. Otherwise the numbers are separated by a comma. For example, numbers 1, 2, 3, 5, 6, 8, 9, 10, 11 and 13 should come out as 1-3,5,6,8-11,13.

This question has been previously answered for c#, and I have written a function that works for R, but this function can be improved. I post this question as a reference for others that might have a similar need. If you find a similar question for R (which I did not), please vote to close and I will remove the question.

The function below is not very elegant, but seems to do the job. How to make the function shorter and more elegant?

x <- c(1,2,3,5,6,8,9,10,11,13)

library(zoo) ## the function requires zoo::na.approx function 

##' @title Generate hyphenated sequential citation from an integer vector
##' @param x integer vector giving citation or page numbers
##' @importFrom zoo na.approx

seq.citation <- function(x) {

## Result if lenght of the integer vector is 1. 
if(length(x) == 1) return(x) else {

## Sort
x <- sort(x)

## Difference
df <- diff(x)

## Index to determine start and end points
ind <- c("start", rep("no", length(df)-1), "end")
ind[which(df > 1)] <- "end"

## Temporary start point vector
sts <- which(ind == "end") + 1
ind[sts[sts < length(ind)]] <- "start"

## Replace the first index element
ind[1] <- "start"

## Replace the last index element, if preceding one is "end"
if(ind[length(ind)-1] == "end") ind[length(ind)] <- "start"

## Groups for comma separation using "start" as the determining value.
grp <- rep(NA, length(x))
grp[which(ind == "start")] <- 1:length(grp[which(ind == "start")])
grp <- zoo::na.approx(grp, method = "constant", rule = 2)

## Split sequences by group
seqs <- split(x, grp)

seqs <- lapply(seqs, function(k) {
  if(length(k) == 1) k else {
    if(length(k) == 2) paste(k[1], k[2], sep = ",") else {
  paste(k[1], k[length(k)], sep = "-")
  }}
})

## Result
return(do.call("paste", c(seqs, sep = ",")))
}
}

seq.citation(x)
# [1] "1-3,5,6,8-11,13"

Upvotes: 9

Views: 932

Answers (4)

Benjamin Christoffersen
Benjamin Christoffersen

Reputation: 4841

You can save a bit of computation time compared with Imo's answer by avoiding the ifelse call and replacing some paste0/paste calls:

paste0(
  tapply(x, cumsum(c(1, diff(x) != 1)), function(i){
    len <- length(i)
    if(len == 1)
      i else sprintf(if(len == 2) "%d,%d" else "%d-%d", i[1], i[len])
  }), collapse = ",")
#R> [1] "1-3,5,6,8-11,13"

This is faster as shown below:

# check computation time
bench::mark(
  new = paste0(
    tapply(x, cumsum(c(1, diff(x) != 1)), function(i){
      len <- length(i)
      if(len == 1)
        i else sprintf(if(len == 2) "%d,%d" else "%d-%d", i[1], i[len])
    }), collapse = ","),
  Imo = paste(tapply(x, cumsum(c(1, diff(x) != 1)), function(i)
    ifelse(length(i) > 2, paste0(head(i, 1), '-', tail(i, 1)),
           paste(i, collapse = ','))), collapse = ','),
  min_time = 1)
#R> # A tibble: 2 x 13
#R>   expression      min   median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time 
#R>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm> 
#R> 1 new          81.6µs   85.9µs    11228.    24.1KB     22.8  8848    18      788ms 
#R> 2 Imo         116.7µs    127µs     7613.    15.8KB     22.4  6123    18      804ms

# same with longer vector
set.seed(1)
x <- sort(sample.int(1e6, 1e5))
bench::mark(
  new = paste0(
    tapply(x, cumsum(c(1, diff(x) != 1)), function(i){
      len <- length(i)
      if(len == 1)
        i else sprintf(if(len == 2) "%d,%d" else "%d-%d", i[1], i[len])
    }), collapse = ","),
  Imo = paste(tapply(x, cumsum(c(1, diff(x) != 1)), function(i)
    ifelse(length(i) > 2, paste0(head(i, 1), '-', tail(i, 1)),
           paste(i, collapse = ','))), collapse = ','),
  min_time = 1)
#R> # A tibble: 2 x 13
#R>   expression     min   median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time 
#R>   <bch:expr> <bch:t> <bch:tm>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm> 
#R> 1 new          341ms    355ms      2.58    29.5MB     6.88     3     8      1.16s 
#R> 2 Imo          625ms    658ms      1.52    29.2MB    15.2      2    20      1.31s

Upvotes: 1

A5C1D2H2I1M1N2O1R2T1
A5C1D2H2I1M1N2O1R2T1

Reputation: 193527

There is, of course, the seqToHumanReadable function from the "R.utils" package.

library(R.utils)
seqToHumanReadable(x)
# [1] "1-3, 5, 6, 8-11, 13"
seqToHumanReadable(x, tau = 1) ## If you want 5-6 and not 5, 6
# [1] "1-3, 5-6, 8-11, 13"

The appearance of the result can also be controlled:

seqToHumanReadable(x, delimiter = "...", collapse = " | ")
# [1] "1...3 | 5 | 6 | 8...11 | 13"

Upvotes: 5

lmo
lmo

Reputation: 38510

This works for your example and should be fairly general.

# get run lengths of differences, with max value of 2
r <- rle(c(1, pmin(diff(x), 2)))

# paste selected x values with appropriate separator
res <- paste0(x[c(1, cumsum(r$lengths))], c("-", ",")[r$values], collapse="")

# drop final character, which is a separator
res <- substr(res, 1, nchar(res)-1)

This returns

res
[1] "1-3,5-6,8-11,13"

Upvotes: 7

Sotos
Sotos

Reputation: 51592

You can do this easily via base R using tapply,

paste(tapply(x, cumsum(c(1, diff(x) != 1)), function(i) 
    ifelse(length(i) > 2, paste0(head(i, 1), '-', tail(i, 1)), 
                            paste(i, collapse = ','))), collapse = ',')

[1] "1-3,5,6,8-11,13"

Upvotes: 8

Related Questions