For i in range(x) adding to x

I’ve been doing some CodingBat Python practice problems and while messing around in vscode I found something I don’t quite understand.

When using the following bit of code the output will be: “112123”

def string_splosion(thing:str) -> str:
  word = ""
  x = 0
  for x in range(len(thing)):
    word = word + thing[0:x+1]
  return word

print(string_splosion("123"))

however, if instead of adding 1 to x inside of the string thing I do it after with x+=1 the word equation is finished it cuts off the 123 at the end so the output looks like this:“112”

Here is what the code looks like as described above:

def string_splosion(thing:str) -> str:
  word = ""
  x = 0
  for x in range(len(thing)):
    word = word + thing[0:x]
    x+=1
  return word

print(string_splosion("123"))

Why does it work this way? I assume it has something to do with the way x is getting counted differing between the two. I did some tests where I added print(x) before and after x+=1 and x increased as expected, but when adding print(x) to the first bit of code before and after word = word + thing[0:x+1] x did not change at all

x is being re-assigned in every iteration of the loop. You don’t even need to initialize it with x = 0–that line is pointless.

x + 1 doesn’t change the value of x, the result is a new value that is not assigned to anything.

In the first iteration, x is assigned 0 (the first value from range). When you write word = word + thing[0:x+1] it adds 1 to x so you end up with word being thing[0:1]. Then in the next iteration x is 1 and you add thing[0:2]. Overall you are adding up thing[0:1], thing[0:2], ... thing[0:len(thing)]. Given the input 123 that’ll be 112123.

In the second version, the statement x+=1 is pointless–immediately after that, you throw away x and reassign the next value from range. So you’re adding up thing[0:0], thing[0:1], ... thing[0:len(thing) - 1] which will be 112.

1 Like

Ohhhhh ok, thank you so much.

Would then a better way to write the code to be to add one like this range(len(thing)+1) instead of adding one to x in word = word + thing[0:x+1] ?
Or does it not really matter that much?

If you want the numbers 1..len(thing) you could use range(1, len(thing) + 1).

1 Like

If you’d really like a better, more efficient and more Pythonic way to write the above code, I’d suggest using a generator or comprehension to build a sequence of strings and str.join() to concatenate them together in one go:

def string_splosion(thing: str) -> str:
    return "".join(thing[0:i+1] for i in range(len(thing)))

This is more idiomatic, simplifies your code to a single line, and performs much better on longer strings (due to simple iterative string concatenation scaling with O(n) rather than O(n^2), where the “splosion” would otherwise lead to a “splosion” in runtime. On very short strings, the difference is insignificant (tested on 3.11):

%timeit string_splosion("123")  # Your original
396 ns ± 1.59 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

%timeit string_splosion2("123")  # Comprehension
513 ns ± 1.78 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

For moderate-length strings like string.printable, it is e.g. 2x and starts to matter in a tight loop:

%timeit string_splosion(string.printable)
15.4 μs ± 70.1 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

%timeit string_splosion2(string.printable)
8.1 μs ± 55.1 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

But for longer strings, the difference can be many orders of magnitude, e.g. 20 ms vs 20 seconds per run:

%timeit string_splosion(string.printable * 100)
20.7 s ± 695 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit string_splosion2(string.printable * 100)
28.7 ms ± 4.69 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

By Grabthar’s Hammar…what a savings!

1 Like

@CAM-Gerlach That genexp is alright, but I prefer this:

from itertools import accumulate

def string_splosion(thing:str) -> str:
    return "".join(accumulate(thing))
2 Likes

More elegant, and more performant too, with the most benefit on shorter (2x) vs. longer (1.5x) strings:

%timeit string_splosion3("123")
241 ns ± 1.5 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

%timeit string_splosion3(string.printable)
4.85 μs ± 22.4 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

%timeit string_splosion3(string.printable * 100)
19.4 ms ± 1.11 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
2 Likes

You could of course make yours a bit more efficient with range(1, len(thing) + 1)) instead of the +1 for every slice, and by using a listcomp instead of a genexp.

1 Like

Yes, though at least per my initial testing the difference in practice was not that substantial (about 8% peak on medium-length strings, and essentially nothing on short and long ones), so I went with the more concise code:

%timeit string_splosion2("123")  # +1 slice
648 ns ± 6.72 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

%timeit string_splosion21("123")  # +1 range
635 ns ± 3.33 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

%timeit string_splosion2(string.printable)  # +1 slice
9.75 μs ± 20.3 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

%timeit string_splosion21(string.printable)  # +1 range
8.98 μs ± 71.6 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

%timeit string_splosion2(string.printable * 100)  # +1 slice
17.1 ms ± 156 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit string_splosion21(string.printable * 100)  # +1 range
17.1 ms ± 67.5 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

I actually did try this previously; it did improve speed modestly for the shorter strings at the cost of increased memory usage (testing on Py 3.13 so don’t have %memit to provide quantitative numbers), but for longer ones where improvement in speed actually matters in practice and where memory usage is more impactful, it made virtually no difference:

%timeit string_splosion2("123")  # genexp
648 ns ± 6.72 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

%timeit string_splosion21("123")  # listcomp
499 ns ± 2.97 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

%timeit string_splosion2(string.printable)
9.75 μs ± 20.3 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

%timeit string_splosion21(string.printable)
8.2 μs ± 70.3 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

%timeit string_splosion2(string.printable * 100)
17.1 ms ± 152 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit string_splosion21(string.printable * 100)
17.1 ms ± 298 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

My times:

"123"
589.0 ± 0.2 ns  original
578.4 ± 0.2 ns  ranger
326.9 ± 0.1 ns  listcomp
320.1 ± 0.2 ns  listcomp_ranger

string.printable
  8.3 ± 0.0 μs  original
  7.6 ± 0.0 μs  ranger
  6.7 ± 0.0 μs  listcomp
  6.1 ± 0.0 μs  listcomp_ranger

string.printable * 100
 37.4 ± 0.2 ms  original
 37.0 ± 0.0 ms  ranger
 36.8 ± 0.1 ms  listcomp
 36.7 ± 0.2 ms  listcomp_ranger

Python: 3.13.3 (tags/v3.13.3:6280bb54784, May  3 2025, 13:46:12) [Clang 18.1.8 (Fedora 18.1.8-2.fc40)]
benchmark script
def original(thing: str) -> str:
    return "".join(thing[0:i+1] for i in range(len(thing)))

def ranger(thing: str) -> str:
    return "".join(thing[0:i] for i in range(1, len(thing) + 1))

def listcomp(thing: str) -> str:
    return "".join([thing[0:i+1] for i in range(len(thing))])

def listcomp_ranger(thing: str) -> str:
    return "".join([thing[0:i] for i in range(1, len(thing) + 1)])

funcs = [
    original,
    ranger,
    listcomp,
    listcomp_ranger,
]

from timeit import timeit
from statistics import mean, stdev
import string
import random
import sys

# Correctness
for f in funcs:
    print(f('abcde'))

cases = [
    ('"123"', 1_000_000, 'ns', 1e9),
    ('string.printable', 100_000, 'μs', 1e6),
    ('string.printable * 100', 100, 'ms', 1e3),
]

# Speed
for case, number, unit, scale in cases:
    print(case)
    number //= 100

    times = {f: [] for f in funcs}
    def stats(f):
        ts = [t * scale for t in sorted(times[f])[:5]]
        return f'{mean(ts):5.1f} ± {stdev(ts):3.1f} {unit} '
    for _ in range(100):
        for f in random.sample(funcs, len(funcs)):
            t = timeit(f'f({case})', 'from __main__ import f, string', number=number) / number
            times[f].append(t)
    for f in funcs:
        print(stats(f), f.__name__)
    print()

print('\nPython:', sys.version)

Not increased but decreased. With the listcomp it takes a little less memory. Because str.join builds a list from the generator as well, and you have the generator overhead. Here are tracemalloc peaks for string length 1000:

1050022 original
1049758 listcomp
1050022 original
1049758 listcomp
code
def original(thing: str) -> str:
    return "".join(thing[0:i+1] for i in range(len(thing)))

def listcomp(thing: str) -> str:
    return "".join([thing[0:i+1] for i in range(len(thing))])

funcs = [
    original,
    listcomp,
]

import tracemalloc

for f in funcs * 2:
    tracemalloc.start()
    f("a" * 1000)
    mem = tracemalloc.get_traced_memory()[1]
    tracemalloc.stop()
    print(mem, f.__name__)

Attempt This Online!

1 Like