Show the three forms of range() and the effect of the start, stop and step arguments.
range() produces a sequence of numbers, and it comes in three forms. With one argument it counts from zero. With two arguments it counts from the start up to, but not including, the stop value. With three arguments it also takes a step, and that step may be negative.
print(list(range(5))) # [0, 1, 2, 3, 4]
print(list(range(2, 6))) # [2, 3, 4, 5]
print(list(range(2, 10, 3))) # [2, 5, 8]
print(list(range(5, 0, -1))) # [5, 4, 3, 2, 1]
print(list(range(10, 0, -3))) # [10, 7, 4, 1]
The most important detail is that the stop value is never included. range(5) is five numbers that start at zero and not six, and range(2, 6) is 2, 3, 4, and 5. This is the opposite of slicing, where the stop index is included, and mixing the two up is a classic off-by-one mistake.
The step argument can be any integer except zero. A positive step counts upward and a negative step counts downward. In the negative case the start has to be greater than the stop, or the range is empty, so range(0, 5, -1) produces nothing at all.
Two more details are worth knowing. range accepts any integers and not only literal numbers, so range(len(items)) is the standard way to produce the indexes of a list. And range is happy with very large values, because it does not build them. range(10 ** 18) is created instantly and takes no meaningful memory, which is the subject of the next answer.