2

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

OneCricketeer
  • 179,855
  • 19
  • 132
  • 245
  • 3
    `'.'*i + char + '.'*(n-i-1)` – rdas Nov 11 '21 at 18:26
  • `"".join(start+mid+end)` would be even worse since you're still concatenating with `+`, but are then re-iterating the resulting string with `join`. Your first way is much better. For the initial part, you don't need `join` or `+` though. You can just multiply strings directly: `'.' * i`. I'd still stick with `+` for the last part though. That shouldn't matter. – Carcigenicate Nov 11 '21 at 18:27
  • 1
    `"".join(['.']*(i)` should just be `'.'*i` – juanpa.arrivillaga Nov 11 '21 at 18:31
  • and this desnt impact running time? I have once heard something about joining being much faster since it's only O(n) while concatenating is sometimes O(n*n) or something – NotQuiteSo1337 Nov 11 '21 at 18:33
  • Building up a string via repeated concatenation is slow in most languages, because Strings are typically implemented as read-only values. Each intermediate concatenation involves copying the intermediate value and adding to the end. So you get `O(N^2)` performance instead of `O(N)`. Using something like `"".join(an_array)` should be quicker, because it would have the foresight to allocate mutable storage that can be edited in-place throughout the whole process – Alexander Nov 11 '21 at 18:33
  • 2
    Although, I've been told that string concatenation has actually be optimized in recent versions of Python. I remember a question about strings being mutated behind the scenes during concatenation. – Carcigenicate Nov 11 '21 at 18:36
  • "*I'm not too sure that this would be the most effecient way, or if this would be more perfomant*" - ... why ask us when you have a Python interpreter right in front of you? Try both, and find out. – TessellatingHeckler Nov 11 '21 at 19:00
  • @TessellatingHeckler I think that gets at the real question: _how to perf test small string building_ and may have the wrong duplicate (though it has some perf information and offers a wide range of (often-ancient) options) – ti7 Nov 11 '21 at 19:02

1 Answers1

-1

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

Federico Baù
  • 6,013
  • 5
  • 30
  • 38
  • The advantage of the "f-string" is because ``build_f_string`` uses a more efficient string operation, not because of the wrapping f-string. Using just ``return "." * i + char + ("." * (n - i + 1))`` in ``buildprint`` has practically identical performance than the f-string variant for me. – MisterMiyagi Nov 11 '21 at 19:00
  • @MisterMiyagi AS the test proves, f string is faster. – Federico Baù Nov 11 '21 at 19:09
  • That's like saying walking is faster than going by car, because you had to fuel up the car first. If you let string concatenation and f-string use the same substrings, they are comparable. – MisterMiyagi Nov 11 '21 at 19:17
  • @MisterMiyagi how can you let string concatenation and f-string use the same substrings? What do you mean? – Federico Baù Nov 11 '21 at 19:21
  • As mentioned above, using ``return "." * i + char + ("." * (n - i + 1))`` is comparable to ``return f'{"." * dots}{char}{"." * (total_char - dots + 1)}'``. – MisterMiyagi Nov 11 '21 at 19:22