pythonlistrangesequencesequences

Convert List of Numbers to String Ranges


I'd like to know if there is a simple (or already created) way of doing the opposite of this: Generate List of Numbers from Hyphenated.... This link could be used to do:

>> list(hyphen_range('1-9,12,15-20,23'))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 15, 16, 17, 18, 19, 20, 23]:

I'm looking to do the opposite (note that 10 and 21 are included so it would be compatible with the range function, where range(1,10)=[1,2,3,4,5,6,7,8,9]):

>> list_to_ranges([1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 15, 16, 17, 18, 19, 20, 23])
'1-10,12,15-21,23'

Eventually, I would like to have the output also incorporate a step where the last number of the output indicates the step:

>> list_to_ranges([1, 3, 5, 7, 8, 9, 10, 11])
'1-13:2,8,10'

Essentially, this would end up being kind of like an "inverse" range function

>> tmp = list_to_ranges([1, 3, 5])
>> print tmp
'1-7:2'
>> range(1, 7, 2)
[1, 3, 5]

My guess is that there is no really easy/simple way to do this, but I thought I would ask on here before I go make some brute force, long method.

EDIT

Using the code from an answer to this post as an example, I came up with a simple way to do the first part. But I think that identifying the patterns to do steps would be a bit harder.

from itertools import groupby
from operator import itemgetter

data = [ 1,  4,5,6, 10, 15,16,17,18, 22, 25,26,27,28]
print data, '\n'

str_list = []
for k, g in groupby(enumerate(data), lambda (i,x):i-x):
   ilist = map(itemgetter(1), g)
   print ilist
   if len(ilist) > 1:
      str_list.append('%d-%d' % (ilist[0], ilist[-1]+1))
   else:
      str_list.append('%d' % ilist[0])
print '\n', ','.join(str_list)

EDIT 2

Here is my attempt at including the step size...it is pretty close, but the first numbers get repeated. I think that with a little bit of tweaking of this, it will be close to what I want - or at least good enough.

import numpy as np
from itertools import groupby

def list_to_ranges(data):
   data = sorted(data)
   diff_data = np.diff(data).tolist()
   ranges = []
   i = 0
   for k, iterable in groupby(diff_data, None):
      rng = list(iterable)
      step = rng[0]
      if len(rng) == 1:
         ranges.append('%d' % data[i])
      elif step == 1:
         ranges.append('%d-%d' % (data[i], data[i+len(rng)]+step))
      else:
         ranges.append('%d-%d:%d' % (data[i], data[i+len(rng)]+step, step))
      i += len(rng)
   return ','.join(ranges)

data = [1, 3, 5, 6, 7, 11, 13, 15, 16, 17, 18, 19, 22, 25, 28]
print data
data_str = list_to_ranges(data)
print data_str

_list = []
for r in data_str.replace('-',':').split(','):
   r = [int(a) for a in r.split(':')]
   if len(r) == 1:
      _list.extend(r)
   elif len(r) == 2:
      _list.extend(range(r[0], r[1]))
   else:
      _list.extend(range(r[0], r[1], r[2]))
print _list
print list(set(_list))

Solution

  • One approach could be "eating" piece by piece the input sequence and store the partial range results untill you've got them all:

    def formatter(start, end, step):
        return '{}-{}:{}'.format(start, end, step)
        # return '{}-{}:{}'.format(start, end + step, step)
    
    def helper(lst):
        if len(lst) == 1:
            return str(lst[0]), []
        if len(lst) == 2:
            return ','.join(map(str,lst)), []
    
        step = lst[1] - lst[0]
        for i,x,y in zip(itertools.count(1), lst[1:], lst[2:]):
            if y-x != step:
                if i > 1:
                    return formatter(lst[0], lst[i], step), lst[i+1:]
                else:
                    return str(lst[0]), lst[1:]
        return formatter(lst[0], lst[-1], step), []
    
    def re_range(lst):
        result = []
        while lst:
            partial,lst = helper(lst)
            result.append(partial)
        return ','.join(result)
    

    I test it with a bunch of unit tests and it passed them all, it can handle negative numbers too, but they'll look kind of ugly (it's really anybody's fault).

    Example:

    >>> re_range([1,  4,5,6, 10, 15,16,17,18, 22, 25,26,27,28])
    '1,4-6:1,10,15-18:1,22,25-28:1'
    >>> re_range([1, 3, 5, 7, 8, 9, 10, 11, 13, 15, 17])
    '1-7:2,8-11:1,13-17:2'
    

    Note: I wrote the code for Python 3.


    Performance

    I didn't put any performance effort in the solution above. In particular, every time a list get re-builded with slicing, it might take some time if the input list has a particular shape. So, the first simple improvement would be using itertools.islice() where possible.

    Anyway here's another implementation of the same algorithm, that scan through the input list with a scan index instead of slicing:

    def re_range(lst):
        n = len(lst)
        result = []
        scan = 0
        while n - scan > 2:
            step = lst[scan + 1] - lst[scan]
            if lst[scan + 2] - lst[scan + 1] != step:
                result.append(str(lst[scan]))
                scan += 1
                continue
    
            for j in range(scan+2, n-1):
                if lst[j+1] - lst[j] != step:
                    result.append(formatter(lst[scan], lst[j], step))
                    scan = j+1
                    break
            else:
                result.append(formatter(lst[scan], lst[-1], step))
                return ','.join(result)
    
        if n - scan == 1:
            result.append(str(lst[scan]))
        elif n - scan == 2:
            result.append(','.join(map(str, lst[scan:])))
    
        return ','.join(result)
    

    I stopped working on it once it got ~65% faster than the previous top solution, it seemed enough :)

    Anyway I'd say that there might still be room for improvement (expecially in the middle for-loop).