49

So, I had an idea that I could use a range of numbers as a key for a single value in a dictionary.

I wrote the code bellow, but I cannot get it to work. Is it even possible?

    stealth_roll = randint(1, 20)
    # select from a dictionary of 4 responses using one of four ranges.
    ## not working.
    stealth_check = {
                    range(1, 6) : 'You are about as stealthy as thunderstorm.',
                    range(6, 11) : 'You tip-toe through the crowd of walkers, while loudly calling them names.',
                    range(11, 16) : 'You are quiet, and deliberate, but still you smell.',
                    range(16, 20) : 'You move like a ninja, but attracting a handful of walkers was inevitable.'
                    }

    print stealth_check[stealth_roll]
Cuylar Conly
  • 635
  • 1
  • 6
  • 11
  • 4
    rather than attempting to use a range for the key why not roll based on the size of the dictionary? – TheLazyScripter Sep 06 '16 at 21:28
  • 2
    As a sidenote, such a dictionary is possible on python3. As keys are ranges, you must access the dict accordingly: `stealth_check[range(6, 11)]` will work. That's completely useless to your purpose though, just wanted to show the object model is consistent. – spectras Sep 06 '16 at 21:54
  • @TheLazyScripter I had adopted the (1, 20) convention throughout the script and I typically also use the random value as a multiplier as well as scenario selector. Also, if this works I could apply different weights to each possible outcome. – Cuylar Conly Sep 08 '16 at 14:25
  • I have been asking this question for some time and never gotten a good answer for it. I've found it better to simply use an if/elif/else structure to get it done. – Harlin Sep 02 '21 at 21:36

11 Answers11

41

It is possible on Python 3 — and on Python 2 if you use xrange instead of range:

stealth_check = {
                xrange(1, 6) : 'You are about as stealthy as thunderstorm.', #...
                }

However, the way you're trying to use it it won't work. You could iterate over the keys, like this:

for key in stealth_check:
    if stealth_roll in key:
        print stealth_check[key]
        break

Performance of this isn't nice (O(n)) but if it's a small dictionary like you showed it's okay. If you actually want to do that, I'd subclass dict to work like that automatically:

class RangeDict(dict):
    def __getitem__(self, item):
        if not isinstance(item, range): # or xrange in Python 2
            for key in self:
                if item in key:
                    return self[key]
            raise KeyError(item)
        else:
            return super().__getitem__(item) # or super(RangeDict, self) for Python 2

stealth_check = RangeDict({range(1,6): 'thunderstorm', range(6,11): 'tip-toe'})
stealth_roll = 8
print(stealth_check[stealth_roll]) # prints 'tip-toe'
L3viathan
  • 26,748
  • 2
  • 58
  • 81
  • 2
    Using ipython ```timeit``` to measure the execution time of ```RangeDict``` for those data, proved that it is -by far- the fastest technique of the ones mentioned until now: ```Best of 3: 6.47 µs per loop, the slowest run took 6.15 times longer than the fastest``` while the best of the other techniques returned numbers like ```Best of 3: 17 µs per loop, the slowest run took 20 times longer than the fastest``` – raratiru Jan 30 '17 at 16:09
  • 2
    Note that you've modified the behavior of `dict`. It no longer throws an error if a non-`range` key is not found. I'd add a `raise` following the loop. – jpmc26 Apr 06 '18 at 22:40
  • @raratiru I've added some additional alternatives to my answer. Except for the numpy one, all of them outspeed this one by my testing in Python 3.6. They come in at roughly 50% to 60% of this one's speed. – jpmc26 Apr 06 '18 at 23:11
  • if type(item) != range shouldn't it be if type(item) == range – baklarz2048 May 12 '20 at 14:24
  • @baklarz2048 No, because the "special" usage is when you're trying to index it with a normal number, e.g. `stealth_check[8]`. Only then do we want to do our special handling. – L3viathan May 12 '20 at 15:46
16

dict is the wrong tool for this job. dict is for mapping specific keys to specific values. That isn't what you're doing; you're trying to map ranges. Here are some more straightforward options.

Map every value to an outcome

Instead of trying to use the ranges for the keys, you could reformulate your problem into one that does map specific keys to specific values. You do so by looping through the ranges and generating a full dict containing all the possible values:

def setall(d, keys, value):
    for k in keys:
        d[k] = value

OUTCOMES = {}
setall(OUTCOMES, range(1, 6), 'You are about as stealthy as thunderstorm.')
setall(OUTCOMES, range(6, 11), 'You tip-toe through the crowd of walkers, while loudly calling them names.')
setall(OUTCOMES, range(11, 16), 'You are quiet, and deliberate, but still you smell.')
setall(OUTCOMES, range(16, 21), 'You move like a ninja, but attracting a handful of walkers was inevitable.')

def get_stealthiness(roll):
    if roll not in OUTCOMES.keys():
        raise ValueError('Unsupported roll: {}'.format(roll))
    return OUTCOMES[roll]

stealth_roll = randint(1, 20)
print(get_stealthiness(stealth_roll))

In this case, we use the ranges to generate a dict that we can look up a result in. We map each roll to an outcome, reusing the same outcomes multiple times. This uses dict properly: it maps a single key to a single value.

Use if blocks

For a small list of values, using the obvious and straightforward if blocks is perfectly fine:

def get_stealthiness(roll):
    if 1 <= roll < 6:
        return 'You are about as stealthy as thunderstorm.'
    elif 6 <= roll < 11:
        return 'You tip-toe through the crowd of walkers, while loudly calling them names.'
    elif 11 <= roll < 16:
        return 'You are quiet, and deliberate, but still you smell.'
    elif 16 <= roll <= 20:
        return 'You move like a ninja, but attracting a handful of walkers was inevitable.'
    else:
        raise ValueError('Unsupported roll: {}'.format(roll))

stealth_roll = randint(1, 20)
print(get_stealthiness(stealth_roll))

There is absolutely nothing wrong with this approach. It really doesn't need to be any more complex. This is much more intuitive, much easier to figure out, and much more efficient than trying to use a dict with ranges as keys.

Doing it this way also makes the boundary handling more visible. In the code I present above, you can quickly spot whether the range uses < or <= in each place. The code above also throws a meaningful error message for values outside of 1 to 20. It also supports non-integer input for free, although you may not care about that.

Compute according to probabilities

You could choose the result based on a probabilities calculation. The basic idea is to compute a "cumulative" probability (which you already have with the top end of the roll values) and then loop through until the cumulative probability exceeds the random value. There's plenty of ideas of how to go about it here.

Some simple options are:

  • numpy.random.choice

  • A loop:

    # Must be in order of cummulative weight
    OUTCOME_WITH_CUM_WEIGHT = [
        ('You are about as stealthy as thunderstorm.', 5),
        ('You tip-toe through the crowd of walkers, while loudly calling them names.', 10),
        ('You are quiet, and deliberate, but still you smell.', 15),
        ('You move like a ninja, but attracting a handful of walkers was inevitable.', 20),
    ]
    
    def get_stealthiness(roll):
        if 1 > roll or 20 < roll:
            raise ValueError('Unsupported roll: {}'.format(roll))
        for stealthiness, cumweight in OUTCOME_WITH_CUM_WEIGHT:
            if roll <= cumweight:
                return stealthiness
        raise Exception('Reached end of get_stealthiness without returning. This is a bug. roll was ' + str(roll))
    
    stealth_roll = randint(1, 20)
    print(get_stealthiness(stealth_roll))
    
  • random.choices (requires Python 3.6 or higher)

    OUTCOMES_SENTENCES = [
        'You are about as stealthy as thunderstorm.',
        'You tip-toe through the crowd of walkers, while loudly calling them names.',
        'You are quiet, and deliberate, but still you smell.',
        'You move like a ninja, but attracting a handful of walkers was inevitable.',
    ]
    OUTCOME_CUMULATIVE_WEIGHTS = [5, 10, 15, 20]
    
    def make_stealth_roll():
        return random.choices(
            population=OUTCOMES_SENTENCES,
            cum_weights=OUTCOME_CUMULATIVE_WEIGHTS,
        )
    
    print(make_stealth_roll())
    

Some have the downside of taking the actual numeric roll out of your hands, but they're a lot simpler to implement and maintain.

Pythonic

"Pythonic" means keeping your code straightforward and approachable. It means using structures for the purposes they were designed for. dict was not designed for what you're doing.

Speed

All of these options are comparatively fast. According to raratiru's comment, the RangeDict was the fastest answer at the time. However, my testing script shows that except for numpy.random.choice, all the options I've suggested are about 30% to 45% faster:

get_stealthiness_rangedict(randint(1, 20)): 2.347477641014848 µs per loop (baseline)
get_stealthiness_ifs(randint(1, 20)): 1.3355229599983431 µs per loop (56.89% of baseline)
get_stealthiness_dict(randint(1, 20)): 1.3621300339582376 µs per loop (58.03% of baseline)
get_stealthiness_cumweight(randint(1, 20)): 1.4149694619700313 µs per loop (60.28% of baseline)
make_stealth_roll_randomchoice(): 1.6616826370009221 µs per loop (70.79% of baseline)
make_stealth_roll_numpychoice(): 27.418932934000622 µs per loop (1168.02% of baseline)
numpy.choice all at once: 0.5978886220254935 µs per loop (25.47% of baseline)

numpy is an order of magnitude slower if you get one result at a time from it; however, it's an order of magnitude faster if you generate your results in bulk.

jpmc26
  • 28,463
  • 14
  • 94
  • 146
  • 1
    I think this is a good example of answering the deeper question rather than the apparent question. I understand why some would object (as it sort of says 'wrong'), but it's good to know that someone knows how to choose the right tools for the job. You get my +1 – Konchog Jul 09 '20 at 09:19
12

Yes, you can, only if you convert your range lists as immutable tuple, so they are hashable and accepted as keys of your dictionary:

stealth_check = {
                tuple(range(1, 6)) : 'You are about as stealthy as thunderstorm.',

EDIT: actually it works in Python 3 as range is an immutable sequence type and generate an immutable tuple instead of a list as L3viathan stated.

but you cannot access them with a single integer as key though. Your last line won't work.

I took some time to create a solution which would work whatever the values may be (picking one entry in the dictionary works as long as the lines are not "weighted" by bigger ranges.

It calls bisect on the sorted keys to find the insertion point, hacks it a bit, and finds the best value in the dictionary, with O(log(N)) complexity, which means it can handle a really big list (maybe a little too much here :) but the dictionary is also too much in that case)

from random import randint
import bisect

stealth_roll = randint(1, 20)
# select from a dictionary of 4 responses using one of four thresholds.

stealth_check = {
                1 : 'You are about as stealthy as thunderstorm.',
                6 : 'You tip-toe through the crowd of walkers, while loudly calling them names.',
                11 : 'You are quiet, and deliberate, but still you smell.',
                16 : 'You move like a ninja, but attracting a handful of walkers was inevitable.'
                }

sorted_keys = sorted(stealth_check)


insertion_point = bisect.bisect_left(sorted_keys,stealth_roll)

# adjust, as bisect returns not exactly what we want
if insertion_point==len(sorted_keys) or sorted_keys[insertion_point]!=stealth_roll:
    insertion_point-=1

print(insertion_point,stealth_roll,stealth_check[sorted_keys[insertion_point]])
Jean-François Fabre
  • 137,073
  • 23
  • 153
  • 219
10

You can't build a dictionary directly from a range, unless you want the range itself to be the key. I don't think you want that. To get individual entries for each possibility within the range:

stealth_check = dict(
                    [(n, 'You are about as stealthy as thunderstorm.')
                        for n in range(1, 6)] +
                    [(n, 'You tip-toe through the crowd of walkers, while loudly calling them names.')
                        for n in range(6, 11)] +
                    [(n, 'You are quiet, and deliberate, but still you smell.')
                        for n in range(11, 16)] +
                    [(n, 'You move like a ninja, but attracting a handful of walkers was inevitable.')
                        for n in range(16, 20)]
                    )

When you have a dict indexed by a small range of integers, you really should consider using a list instead:

stealth_check = [None]
stealth_check[1:6] = (6 - 1) * ['You are about as stealthy as thunderstorm.']
stealth_check[6:11] = (11 - 6) * ['You tip-toe through the crowd of walkers, while loudly calling them names.']
stealth_check[11:16] = (16 - 11) * ['You are quiet, and deliberate, but still you smell.']
stealth_check[16:20] = (20 - 16) * ['You move like a ninja, but attracting a handful of walkers was inevitable.']
Mark Ransom
  • 299,747
  • 42
  • 398
  • 622
6

I wrote a RangeKeyDict class for handling cases like this, which is more general and easy to use. For usage, check the codes in __main__

to install it using:

pip install range-key-dict

Usage:

from range_key_dict import RangeKeyDict

if __name__ == '__main__':
    range_key_dict = RangeKeyDict({
        (0, 100): 'A',
        (100, 200): 'B',
        (200, 300): 'C',
    })

    # test normal case
    assert range_key_dict[70] == 'A'
    assert range_key_dict[170] == 'B'
    assert range_key_dict[270] == 'C'

    # test case when the number is float
    assert range_key_dict[70.5] == 'A'

    # test case not in the range, with default value
    assert range_key_dict.get(1000, 'D') == 'D'

https://github.com/albertmenglongli/range-key-dict

Menglong Li
  • 2,177
  • 14
  • 19
  • 6
    The time complexity of the problem is O(log(N)), whereas the time complexity of your algorithm is O(N). In other words, the speed your solution will scale suboptimally. – Witiko Oct 01 '18 at 18:30
3
stealth_check = {
                    0 : 'You are about as stealthy as thunderstorm.',
                    1 : 'You tip-toe through the crowd of walkers, while loudly calling them names.',
                    2 : 'You are quiet, and deliberate, but still you smell.',
                    3 : 'You move like a ninja, but attracting a handful of walkers was inevitable.'
                    }
stealth_roll = randint(0, len(stealth_check))
return stealth_check[stealth_roll]
TheLazyScripter
  • 2,541
  • 1
  • 10
  • 19
3

I might be late for the party, but here how I solved the similar problem.

import bisect

outcomes = ["You are about as stealthy as thunderstorm.",
            "You tip-toe through the crowd of walkers, while loudly calling them names.",
            "You are quiet, and deliberate, but still you smell.",
            "You move like a ninja, but attracting a handful of walkers was inevitable."]
ranges = [6, 11, 16]

outcome_index = bisect.bisect(ranges, 20)
print(outcomes[outcome_index])
Vasya Run
  • 31
  • 3
2

This approach will accomplish what you want, and the last line will work (assumes Py3 behavior of range and print):

def extend_dict(d, value, x):
    for a in x:
        d[a] = value

stealth_roll = randint(1, 20)
# select from a dictionary of 4 responses using one of four ranges.
## not working.
stealth_check = {}
extend_dict(stealth_check,'You are about as stealthy as thunderstorm.',range(1,6))
extend_dict(stealth_check,'You tip-toe through the crowd of walkers, while loudly calling them names.',range(6,11))
extend_dict(stealth_check,'You are quiet, and deliberate, but still you smell.',range(11,16))
extend_dict(stealth_check,'You move like a ninja, but attracting a handful of walkers was inevitable.',range(16,20))

print(stealth_check[stealth_roll])

BTW if you're simulating a 20-side die you need the final index to be 21, not 20 (since 20 is not in range(1,20)).

Paul Cornelius
  • 9,245
  • 1
  • 15
  • 24
  • `randint` returns values between 1 and 20. Your last line should be `range(16,21)`. Is that what's your implying? – Jean-François Fabre Sep 06 '16 at 21:51
  • 1
    Yes, random.randint(1,20) returns a value from 1 to 20, inclusive; but range(1,20) only steps from 1 to 19, inclusive. So his code will run but the odds will not be what he expects. I know this because I've made this mistake myself more than once :-) – Paul Cornelius Sep 06 '16 at 23:16
1

The following is probably maximally efficient in mapping a randint to one of a set of fixed category strings with fixed probability.

from random import randint
stealth_map = (None, 0,0,0,0,0,0,1,1,1,1,1,2,2,2,2,2,3,3,3,3)
stealth_type = (
    'You are about as stealthy as thunderstorm.',
    'You tip-toe through the crowd of walkers, while loudly calling them names.',
    'You are quiet, and deliberate, but still you smell.',
    'You move like a ninja, but attracting a handful of walkers was inevitable.',
    )
for i in range(10):
    stealth_roll = randint(1, 20)
    print(stealth_type[stealth_map[stealth_roll]])
Terry Jan Reedy
  • 18,414
  • 3
  • 40
  • 52
0

Thank you everyone for your responses. I kept hacking away, and I came up with a solution that will suit my purposes quite well. It is most similar to the suggestions of @PaulCornelius.

stealth_roll = randint(1, 20)
# select from a dictionary of 4 responses using one of four ranges.
# only one resolution can be True. # True can be a key value.

def check(i, a, b): # check if i is in the range. # return True or False
    if i in range(a, b):
        return True
    else:
        return False
### can assign returned object as dictionary key! # assign key as True or False.
stealth_check = {
                check(stealth_roll, 1, 6) : 
                'You are about as stealthy as a thunderstorm.',
                check(stealth_roll, 6, 11) : 
                'You tip-toe through the crowd of walkers, while loudly calling them names.',
                check(stealth_roll, 11, 16) : 
                'You are quiet, and deliberate, but still you smell.',
                check(stealth_roll, 15, 21) : 
                'You move like a ninja, but attracting a handful of walkers was inevitable.'
                }

print stealth_check[True] # print the dictionary value that is True.
Cuylar Conly
  • 635
  • 1
  • 6
  • 11
  • 1
    Nice solution. However, I have used ```timeit``` in order to measure the time it takes for each technique to execute and -by far- the most efficient turned out to be the one that [subclasses ```dict```](http://stackoverflow.com/a/39358140/2996101) – raratiru Jan 30 '17 at 16:05
0

jaraco.collections implements the RangeMap.

$ pip-run -q jaraco.collections -- -q
>>> import jaraco.collections
>>> lookup = jaraco.collections.RangeMap({6: 'thunderstorm', 11: 'loudly', 16: 'quiet', 20: 'ninja'})
>>> lookup[1]
'thunderstorm'
>>> lookup[5]
'thunderstorm'
>>> lookup[7]
'loudly'
>>> lookup[20]
'ninja'

It may not be highly performant, but is suitable for small mappings like the described use-case, and concisely defined.

Jason R. Coombs
  • 41,115
  • 10
  • 83
  • 93