将整数列表转换为逗号分隔范围字符串的Pythonic方式

21
  • Input: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 15, 16, 19]
  • Output: "1-10,12,14-16,19"
 [0, 1, 2, 3] -> "0-3"
 [0, 1, 2, 4, 8] -> "0-2,4,8"

等等。

我仍在学习更多处理列表的Python方式,这个问题对我来说有点困难。我最近的想法是创建一个列表的列表来跟踪成对的数字:

[ [0, 3], [4, 4], [5, 9], [20, 20] ]

我可以遍历这个结构,将每个子列表作为一个范围或单个值打印出来。

我不喜欢用两个循环来实现,但是我似乎无法在每个循环中跟踪每个数字。我的想法是像这样做:

这是我最近的尝试。它能工作,但我并不完全满意;我一直认为有一种更优雅的解决方案,但我还没有想到。我知道字符串处理的迭代不是最好的 -- 对我来说,这是一个相当早的早晨 :)

def createRangeString(zones):
        rangeIdx = 0
        ranges   = [[zones[0], zones[0]]]
        for zone in list(zones):
            if ranges[rangeIdx][1] in (zone, zone-1):
                ranges[rangeIdx][1] = zone
            else:
                ranges.append([zone, zone])
                rangeIdx += 1

        rangeStr = ""
        for range in ranges:
            if range[0] != range[1]:
                rangeStr = "%s,%d-%d" % (rangeStr, range[0], range[1])
            else:
                rangeStr = "%s,%d" % (rangeStr, range[0])

        return rangeStr[1:]

有没有一种简单的方法可以将这个操作合并到单个迭代中?还有什么其他方法可以使它更具Python风格?


在此处查看more_itertools.consecutive_groups工具的演示链接 - pylang
7个回答

22
>>> from itertools import count, groupby
>>> L=[1, 2, 3, 4, 6, 7, 8, 9, 12, 13, 19, 20, 22, 23, 40, 44]
>>> G=(list(x) for _,x in groupby(L, lambda x,c=count(): next(c)-x))
>>> print ",".join("-".join(map(str,(g[0],g[-1])[:len(g)])) for g in G)
1-4,6-9,12-13,19-20,22-23,40,44
这里的思路是将每个元素与count()配对。然后对于连续的值,值和count()之间的差异是恒定的。groupby()完成了其余的工作。
正如Jeff所建议的那样,与count()相比,另一种选择是使用enumerate()。这会添加一些额外的字符,需要在打印语句中去除。
G=(list(x) for _,x in groupby(enumerate(L), lambda (i,x):i-x))
print ",".join("-".join(map(str,(g[0][1],g[-1][1])[:len(g)])) for g in G)

更新:对于这里给出的示例列表,使用枚举的版本在我的电脑上运行比使用count()的版本慢约5%。


4
我能收回那个吗?你的解决方案太棒了,我向你致敬。 - Muhammad Alkarouri
@gnibbler,这看起来很棒。我喜欢你在打印语句中使用映射的方式。我不认为我遇到过next()方法。你可以传递任何可迭代项给它吗? - bedwyr
@gnibbler: 了不起。在我把它挂在墙上之前,你能不能用G=(list(x) for _,x in ..呢?最近我正在学习itertools并寻找这样的宝石。 - Muhammad Alkarouri
我发现对该解决方案进行排序是必要的。 - paragbaxi
@ghee22,是的,它期望一个预排序的列表。 - John La Rooy
显示剩余6条评论

3

这是否符合Pythonic的标准存在争议,但它非常简洁。真正的精华在于Rangify()函数。如果你想要更高的效率或者更符合Python风格,仍有改进的空间。

def CreateRangeString(zones):
    #assuming sorted and distinct
    deltas = [a-b for a, b in zip(zones[1:], zones[:-1])]
    deltas.append(-1)
    def Rangify((b, p), (z, d)):
        if p is not None:
            if d == 1: return (b, p)
            b.append('%d-%d'%(p,z))
            return (b, None)
        else:
            if d == 1: return (b, z)
            b.append(str(z))
            return (b, None)
    return ','.join(reduce(Rangify, zip(zones, deltas), ([], None))[0])

参数说明:

  • deltas是到下一个值的距离(灵感来源于这里在SO上的答案)
  • Rangify()对这些参数进行了缩减
    • b - 基数或累加器
    • p - 前一个起始范围
    • z - 区域编号
    • d - 距离

你可以假设列表是不同的,但不一定排序的。然而,由于列表是数字类型,所以使用sort()方法解决这个假设是相当简单的。 - bedwyr
实际上,我撤回之前说的话 - 我的代码将列表推送到函数中执行预排序。没关系 :) - bedwyr

1

要连接字符串,您应该使用','.join。这将删除第二个循环。

def createRangeString(zones):
        rangeIdx = 0
        ranges   = [[zones[0], zones[0]]]
        for zone in list(zones):
            if ranges[rangeIdx][1] in (zone, zone-1):
                ranges[rangeIdx][1] = zone
            else:
                ranges.append([zone, zone])
                rangeIdx += 1

       return ','.join(
                map(
                  lambda p: '%s-%s'%tuple(p) if p[0] != p[1] else str(p[0]),
                  ranges
                )
              )

虽然我更喜欢一种更通用的方法:

from itertools import groupby

# auxiliary functor to allow groupby to compare by adjacent elements.
class cmp_to_groupby_key(object):
  def __init__(self, f):
    self.f = f
    self.uninitialized = True
  def __call__(self, newv):
    if self.uninitialized or not self.f(self.oldv, newv):
      self.curkey = newv
      self.uninitialized = False
    self.oldv = newv
    return self.curkey

# returns the first and last element of an iterable with O(1) memory.
def first_and_last(iterable):
  first = next(iterable)
  last = first
  for i in iterable:
    last = i
  return (first, last)

# convert groups into list of range strings
def create_range_string_from_groups(groups):
  for _, g in groups:
    first, last = first_and_last(g)
    if first != last:
      yield "{0}-{1}".format(first, last)
    else:
      yield str(first)

def create_range_string(zones):
  groups = groupby(zones, cmp_to_groupby_key(lambda a,b: b-a<=1))
  return ','.join(create_range_string_from_groups(groups))

assert create_range_string([0,1,2,3]) == '0-3'
assert create_range_string([0, 1, 2, 4, 8]) == '0-2,4,8'
assert create_range_string([1,2,3,4,6,7,8,9,12,13,19,20,22,22,22,23,40,44]) == '1-4,6-9,12-13,19-20,22-23,40,44'

1

这个更冗长,主要是因为我使用了我自己的通用函数,它们是itertools函数和配方的小变体:

from itertools import tee, izip_longest
def pairwise_longest(iterable):
    "variation of pairwise in http://docs.python.org/library/itertools.html#recipes"
    a, b = tee(iterable)
    next(b, None)
    return izip_longest(a, b)

def takeuntil(predicate, iterable):
    """returns all elements before and including the one for which the predicate is true
    variation of http://docs.python.org/library/itertools.html#itertools.takewhile"""
    for x in iterable:
        yield x
        if predicate(x):
            break

def get_range(it):
    "gets a range from a pairwise iterator"
    rng = list(takeuntil(lambda (a,b): (b is None) or (b-a>1), it))
    if rng:
        b, e = rng[0][0], rng[-1][0]
        return "%d-%d" % (b,e) if b != e else "%d" % b

def create_ranges(zones):
    it = pairwise_longest(zones)
    return ",".join(iter(lambda:get_range(it),None))

k=[0,1,2,4,5,7,9,12,13,14,15]
print create_ranges(k) #0-2,4-5,7,9,12-15

1

这个乱七八糟的怎么样...

def rangefy(mylist):
    mylist, mystr, start = mylist + [None], "", 0
    for i, v in enumerate(mylist[:-1]):
            if mylist[i+1] != v + 1:
                    mystr += ["%d,"%v,"%d-%d,"%(start,v)][start!=v]
                    start = mylist[i+1]
    return mystr[:-1]

有没有人知道如何使用join重写代码,或者如何避免对None求和,或者如何使上述代码更短? - Lord British

0
这是我的解决方案。在迭代列表并创建结果时,您需要跟踪各种信息 - 这让我想到了生成器。所以就这样吧:
def rangeStr(start, end):
    '''convert two integers into a range start-end, or a single value if they are the same''' 
    return str(start) if start == end else "%s-%s" %(start, end)

def makeRange(seq):
    '''take a sequence of ints and return a sequence
    of strings with the ranges
    '''
    # make sure that seq is an iterator
    seq = iter(seq)
    start = seq.next()
    current = start
    for val in seq:
        current += 1
        if val != current:
            yield rangeStr(start, current-1)
            start = current = val
    # make sure the last range is included in the output
    yield rangeStr(start, current)

def stringifyRanges(seq):
    return ','.join(makeRange(seq))

>>> l = [1,2,3, 7,8,9, 11, 20,21,22,23]
>>> l2 = [1,2,3, 7,8,9, 11, 20,21,22,23, 30]
>>> stringifyRanges(l)
'1-3,7-9,11,20-23'
>>> stringifyRanges(l2)
'1-3,7-9,11,20-23,30'

如果给一个空列表,我的版本将能够正确工作,而其他一些版本可能不行。

>>> stringifyRanges( [] )
''

makeRanges可以处理返回整数的任何迭代器,并懒惰地返回字符串序列,因此可用于无限序列。

编辑:我已更新代码以处理不属于范围的单个数字。

编辑2:重构rangeStr以消除重复。


OP did specify what happens when a single number appears in the list. [0, 1, 2, 4, 8] -> "0-2,4,8". - kennytm
谢谢Kenny,我没注意到。我已经更新了代码以正确处理单个数字。 - Dave Kirby

0
def createRangeString(zones):
    """Create a string with integer ranges in the format of '%d-%d'
    >>> createRangeString([0, 1, 2, 4, 8])
    "0-2,4,8"
    >>> createRangeString([1,2,3,4,6,7,8,9,12,13,19,20,22,22,22,23,40,44])
    "1-4,6-9,12-13,19-20,22-23,40,44"
    """
    buffer = []

    try:
        st = ed = zones[0]
        for i in zones[1:]:
            delta = i - ed
            if delta == 1: ed = i
            elif not (delta == 0):
                buffer.append((st, ed))
                st = ed = i
        else: buffer.append((st, ed))
    except IndexError:
        pass

    return ','.join(
            "%d" % st if st==ed else "%d-%d" % (st, ed)
            for st, ed in buffer)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接