Perfomance of concatenating strings vs joining lists?

Viewed 65

I'm doing a small python exercises for fun.

I need to print something that looks like this based on some input, and some logic:

.P....
.I....
.D....
.Z....
BANANA
.M....
.A....

And right now I'm struggling a bit with constructing the strings with the dots in them. So what I need is a function that takes a number n a number i, and a char, like this;

def buildstring(n,i, char):

And then returns a string of length n, consisiting only of dots where the i'th char is the char given.

I currently have this attempt:

def buildprint(n,i,char):

    start = "".join(['.']*(i))
    mid = char 
    end = "".join(['.']*(n - i-1))
    print(start+mid+end)


buildprint(10,3,"j")

which produces:

...j......

Which is what I want. But my solution will be graded on time, but I'm not toosure that this would be the most effecient way of concatenating the strings, since I remember something about concatenating strings often being the slow part of a program or if this would be more perfomant:

def buildprint(n,i,char):

    start = ['.']*(i)
    mid = [char] 
    end = ['.']*(n - i-1)
    print("".join(start+mid+end))

So the question is really about the way string concatenation works, vs joining lists

1 Answers

I would use a completly different approach using string interpolation and the f-string

def buildprint(n,i,char):
    start = "".join(['.']*(i))
    mid = char
    end = "".join(['.']*(n - i-1))
    print(start+mid+end)


def build_f_string(total_char, dots, char):
    total_char -= dots + 1
    print(f'{"." * dots}{char}{"." * total_char}')

test_1 = (10, 3, "j")
buildprint(*test_1)
build_f_string(*test_1)

One Liner

build_f_string = lambda total_char, dots, char : print(f'{"." * dots}{char}{"." * (total_char - (dots + 1 ))}')
build_f_string(*test_1)

Performance

  1. Make a timer function

     from time import perf_counter
     from functools import wraps
    
    
     def timer(runs):
         def _timer(f,):
             @wraps(f)
             def wrapper(*args, **kwargs):
                 start = perf_counter()
                 for test in range(runs):
                     res = f(*args, **kwargs)
                 end = perf_counter()
                 print(f'{f.__name__}: Total Time: {end - start}')
                 return res
    
             return wrapper
         return _timer
    
  2. Decorate our functions

         TEST_RUNS = 100_000
    
    
         @timer(runs=TEST_RUNS)
         def buildprint(n, i, char):
             start = "".join(['.'] * (i))
             mid = char
             end = "".join(['.'] * (n - i - 1))
             return start + mid + end
    
    
         @timer(runs=TEST_RUNS)
         def build_f_string(total_char, dots, char):
            return f'{"." * dots}{char}{"." * (total_char - dots + 1)}'
    
  3. Run Tests

     test_1 = (10, 3, "j")
     buildprint(*test_1)
     build_f_string(*test_1)
    
     # Output
    
     # buildprint: Total Time: 0.06150109999999999
     # build_f_string: Total Time: 0.027191400000000004
    

Going to next level

We can use Python Deque

Deques are a generalization of stacks and queues (the name is pronounced “deck” and is short for “double-ended queue”). Deques support thread-safe, memory efficient appends and pops from either side of the deque with approximately the same O(1) performance in either direction.

def build_withdeque(n, i, char):
    hyper_list = deque([char]) # initialize the deque with the char, is in the middle already
    hyper_list.extendleft(["." for _ in range(i)]) # Add left side
    hyper_list.extend(["." for _ in range(n - i - 1)])
    return "".join(hyper_list)

Running test

    from collections import deque
    TEST_RUNS = 10_00_000
    
    @timer(runs=TEST_RUNS)
    def build_withdeque(n, i, char):
        hyper_list = deque([char]) # initialize the deque with the char, is in the middle already
        hyper_list.extendleft(["." for _ in range(i)]) # Add left side
        hyper_list.extend(["." for _ in range(n - i - 1)])
        return "".join(hyper_list)
    
    test_1 = (10, 3, "j")
    buildprint(*test_1)
    build_f_string(*test_1)
    build_withdeque(*test_1)
    
    # buildprint: Total Time: 0.546178
    # build_f_string: Total Time: 0.29445559999999993
    # build_withdeque: Total Time: 1.4074019

Still not so good..

Pre build a list

@timer(runs=TEST_RUNS)
def build_list_complete(n, i, char):
    return ''.join(["." * i, char, "." * (n - i - 1)])



test_1 = (10, 3, "j")
buildprint(*test_1)
build_f_string(*test_1)
build_withdeque(*test_1)
build_list_complete(*test_1)

# buildprint: Total Time: 0.5440142
# build_f_string: Total Time: 0.3002815999999999
# build_withdeque: Total Time: 1.4215970999999998
# build_list_complete: Total Time: 0.30323829999999985

Final Result

# TEST_RUNS = 10_00_000

test_1 = (10, 3, "j")
buildprint(*test_1)
build_f_string(*test_1)
build_withdeque(*test_1)
build_list_complete(*test_1)

# buildprint: Total Time: 0.6512364
# build_f_string: Total Time: 0.2695955000000001
# build_withdeque: Total Time: 14.0086889
# build_list_complete: Total Time: 3.049139399999998

Wait no so fast

As @MisterMiyagi point out, using return "." * i + char + ("." * (n - i + 1)) could be equivalent than using return f'{"." * dots}{char}{"." * (total_char - dots + 1)}'.

However it is actually Faster.

Look at this:

def SuperHyperMegaUltraFastButBasicallyLikeFStringThatMisterMiyagiSayd(n, i, char):
    return "." * i + char + ("." * (n - i + 1))

Run some test with 100_00_000 repetitions.

@timer(runs=TEST_RUNS)
def SuperHyperMegaUltraFastButBasicallyLikeFStringThatMisterMiyagiSaid(n, i, char):
    return "." * i + char + ("." * (n - i + 1))


test_1 = (10, 3, "j")
buildprint(*test_1)
build_f_string(*test_1)
build_withdeque(*test_1)
build_list_complete(*test_1)

    
    buildprint: Total Time: 5.3067210000000005
    build_f_string: Total Time: 2.721678
    build_withdeque: Total Time: 14.302031600000001
    build_list_complete: Total Time: 3.0364287999999995        SuperHyperMegaUltraFastButBasicallyLikeFStringThatMisterMiyagiSayd: Total Time: 2.440598699999999


    buildprint: Total Time: 5.3067210000000005
    build_f_string: Total Time: 2.721678
    build_withdeque: Total Time: 14.302031600000001
    build_list_complete: Total Time: 3.0364287999999995
    SuperHyperMegaUltraFastButBasicallyLikeFStringThatMisterMiyagiSayd 

Total Time: 2.440598699999999

The f-string string concatenation is the clear winner here, untill proven otherwise

Related