Is possible to convert multiple if <n in interval> into an array?

Viewed 110

Code like this

def IsEven(n):
    if n%2==0:
        return "Is even"
    else:
        return "Is odd"

can be converted to a function like this

def isEven(n):
    return ["Is even","Is odd"][n%2==0]

The question is if code like this:

def intervalsToOutput(n):
    intervals=[(x1,x2), (x3,x4), (x5,x6), ... (xn,xn+1)]
    if x1<=n<=x2:
        return "in first interval"
    elif x3<=n<=x4:
        return "in second interval"
    elif x5<=n<=x6:
        return "in third interval"
    ...
    elif xn<=n<=xn+1:
        return "in last interval"

... can be replaced efficiently with a function like this:
(assuming the intervals (xi,xi+1) are not overlapping, and are sorted by xi)

def intervalsToOutput(n):
    intervals=[(x1,x2), (x3,x4), (x5,x6), ... (xn,xn+1)]
    answer=["in first interval","in second interval","in third interval",...,"in last interval"]
    return answer[index of (n in Interval)]
    

The best I made (looking for speed) is

def intervalsToOutput(n):
    intervals=[(x1,x2), (x3,x4), (x5,x6), ... (xn,xn+1)]
    answer=["in first interval","in second interval","in third interval",...,"in last interval"]
    import bisect as bisect
    return answer[bisect.bisect_left(intervals, (n, )) - 1]# -1 because it is a zero based list

What bisect_left does is to find the position of n in intervals (in O(log(n)) time) by comparing (n,) with (xn,xn+1) But the purpose of bisect is to find the insertion place, so it fails for any n which is x_{i+1}<n<=x_{j}

... (x_i, x_{i+1}), (x_j, x_{j+1}) ...
[x_i______x_i+1] fails on this interval x_j [________x_j+1]


EDIT: This is a solution using intervaltree instead of bisect, but it has some overhead, because intervaltree returns a set, which may be empty (I also accept faster/more elegant solutions based on this code)

#generate intervals and answers
import random
min=0
max=20
numIntervals=6

def Intervals(min,max,n):
    randInts = random.sample(range(min, max), n * 2)
    randInts.sort()
    return [(x1,x2) for x1,x2 in zip(randInts[::2], randInts[1::2])]

intervals=Intervals(min,max,numIntervals)
answer=[f"In {n}th interval" for n in range(len(intervals))]

#Implement solution

import intervaltree

tree = intervaltree.IntervalTree() 
[tree.addi(i[0],i[1],a) for i,a in zip(intervals,answer)]

def intervalsToOutput(x):
    answer=tree.at(x)
    if len(answer)>0:
        return answer.pop().data
    return "value not found"

print(intervals)
[print(f"{x} is ",intervalsToOutput(x)) for x in random.sample(range(min,max),numIntervals)]
3 Answers

Well here's one way, but don't do this (for readability, and probably performance). You should really just chain if,elif,andelse statements (or use a for loop):

def really_esoteric(n):
    return ["first", "second", "n"][
        [n in range(0,10),
         n in range(11,16),
         n in range(16,30)].index(True)
        ]

REPL:

>>> really_esoteric(5)
'first'
>>> really_esoteric(13)
'second'
>>> really_esoteric(20)
'n'


Another way of going about this:

from bisect import bisect
def search_ranges(n, intervals):
    # Get the index on an insertion
    index = bisect(intervals, (n,))
    
    # Get the low and high values for the interval
    lower_low, lower_high = intervals[index - 1]
    upper_low, upper_high = intervals[index % len(intervals)]

    # Test if n is in the interval
    if n in range(lower_low, lower_high):
        return f"{n} in {intervals[index-1]}"
    elif n in range(upper_low, upper_high):
        return f"{n} in {intervals[index]}"
    else:
        return f"{n} not in an interval"

print(search_ranges(0, [(0, 10)]))
print(search_ranges(4, [(0, 10)]))
print(search_ranges(11, [(0, 10), (11,20)]))
print(search_ranges(12, [(0, 10), (15,20)]))
print(search_ranges(19, [(0, 10), (11,20)]))
print(search_ranges(11, [(0, 10), (11, 20), (21, 30)])) 

Outputs:

0 in (0, 10)
4 in (0, 10)
11 in (11, 20)
12 not in an interval
19 in (11, 20)
11 in (11, 20)

I would do this:

intervals=[(1,2),(6,8),(22,45),(101,110)]

tgt=27
idx=next(i for i,t in enumerate(intervals) if t[0]<=tgt<=t[1])

>>> idx
2

If you want to catch a not-found condition, either use try / except:

try:
   idx=next(i for i,t in enumerate(intervals) if t[0]<=tgt<=t[1])
except StopIteration:
    # not found

Or use the default form of next:

idx=next((i for i,t in enumerate(intervals) if t[0]<=tgt<=t[1]), None)

Fair point regarding this method being slow. Here is a monumentally faster way.

You can take the source of bisect and modify with custom logic to inspect each tuple in the list see if the condition of t[0] <= x <= t[1] and return None if that condition cannot be satisfied.

Given a list of tuples of this form:

[(0, 3), (5, 10), (13, 17)] ... [(69735, 69739), (69742, 69746), (69749, 69752)]

You can have a custom bisect search to find a tuple that satisfies the condition stated:

def bisect_search(a, x, lo=0, hi=None):
    if lo < 0:
        raise ValueError('lo must be non-negative')
    if hi is None or hi>len(a):
        hi = len(a)

    while lo < hi:
        mid = (lo + hi) // 2
        f_mid=-1 if x<a[mid][0] else 1 if x>a[mid][-1] else 0
        if f_mid < 0:
            hi = mid
        elif f_mid > 0:
            lo = mid + 1
        else:
            return mid
        
    return None

Here is a benchmark for those two methods:

import random 
import time 

def gen_tuples(cnt, span=(1,5)):
    li=[]
    
    lo,hi=0,random.randint(*span)
    
    for _ in range(cnt):
        li.append((lo,hi))
        lo=li[-1][-1]+random.randint(*span)
        hi=lo+random.randint(span[0]+2,span[-1])
        
    rando=random.randint(cnt//2,cnt-1)
    to_find=li[rando][random.randint(0,len(li[rando])-1)]   
    return (rando,to_find,li)



def next_(a, x):
    return next((i for i,t in enumerate(a) if t[0] <= x <=t [1]), None)
    
def bisect_search(a, x, lo=0, hi=None):
    if lo < 0:
        raise ValueError('lo must be non-negative')
    if hi is None or hi>len(a):
        hi = len(a)

    while lo < hi:
        mid = (lo + hi) // 2
        f_mid=-1 if x<a[mid][0] else 1 if x>a[mid][-1] else 0
        if f_mid < 0:
            hi = mid
        elif f_mid > 0:
            lo = mid + 1
        else:
            return mid
        
    return None


def cmpthese(funcs, args=(), cnt=1000, rate=True, micro=True):
    """Generate a Perl style function benchmark"""                   
    def pprint_table(table):
        """Perl style table output"""
        def format_field(field, fmt='{:,.0f}'):
            if type(field) is str: return field
            if type(field) is tuple: return field[1].format(field[0])
            return fmt.format(field)     
        
        def get_max_col_w(table, index):
            return max([len(format_field(row[index])) for row in table])         
        
        col_paddings=[get_max_col_w(table, i) for i in range(len(table[0]))]
        for i,row in enumerate(table):
            # left col
            row_tab=[row[0].ljust(col_paddings[0])]
            # rest of the cols
            row_tab+=[format_field(row[j]).rjust(col_paddings[j]) for j in range(1,len(row))]
            print(' '.join(row_tab))                
            
    results={}
    for i in range(cnt):
        for f in funcs:
            start=time.perf_counter_ns()
            f(*args)
            stop=time.perf_counter_ns()
            results.setdefault(f.__name__, []).append(stop-start)
    results={k:float(sum(v))/len(v) for k,v in results.items()}     
    fastest=sorted(results,key=results.get, reverse=True)
    table=[['']]
    if rate: table[0].append('rate/sec')
    if micro: table[0].append('\u03bcsec/pass')
    table[0].extend(fastest)
    for e in fastest:
        tmp=[e]
        if rate:
            tmp.append('{:,}'.format(int(round(float(cnt)*1000000.0/results[e]))))
            
        if micro:
            tmp.append('{:,.1f}'.format(results[e]/float(cnt)))
            
        for x in fastest:
            if x==e: tmp.append('--')
            else: tmp.append('{:.1%}'.format((results[x]-results[e])/results[e]))
        table.append(tmp) 
        
    pprint_table(table)                    
    
    
    
if __name__=='__main__':
    import sys
    print(sys.version)
    cases=(
        ('small, found', True, 100),
        ('small, not found', False, 100),
        ('large, found', True, 10000),
        ('large, not found', False, 10000)
    )
    for txt, f, cnt in cases:
        rando,to_find,li=gen_tuples(cnt)
        tgt=to_find if f else -1
        args=(li, tgt)
        f1,f2=bisect_search(*args), next_(*args)
        print(f'\n{txt}: {f1}, {f2}')
        cmpthese([next_,bisect_search], args)  

Prints:

3.10.2 (main, Feb  2 2022, 06:19:27) [Clang 13.0.0 (clang-1300.0.29.3)]

small, found: 84, 84
              rate/sec μsec/pass  next_ bisect_search
next_          121,533       8.2     --        -82.0%
bisect_search  675,074       1.5 455.5%            --

small, not found: None, None
              rate/sec μsec/pass  next_ bisect_search
next_          155,885       6.4     --        -81.6%
bisect_search  847,744       1.2 443.8%            --

large, found: 8824, 8824
              rate/sec μsec/pass    next_ bisect_search
next_            1,232     811.9       --        -99.6%
bisect_search  299,677       3.3 24230.9%            --

large, not found: None, None
              rate/sec μsec/pass    next_ bisect_search
next_            1,524     656.0       --        -99.6%
bisect_search  340,112       2.9 22211.5%            --

Which shows that the bisect_search method is substantially faster.

Note: It is tempting to try and use the the key in Python 3.10 bisect to do the same thing. This is not possible since the upper end of the range of the target tuple is not known and the method of (x,) is only comparing to the bottom of the range of the target tuple.

Are the intervals random? i.e. if the range is 0-10 and each there are 5 intervals (0-2)(2-4)(4-6)(6-8)(8-10)

Just do something like

def func(n):
    spacing = 2
    return n // 2
Related