19
\$\begingroup\$

This code will take as output any string (long, empty, with spaces...) and will return its reverse. Can I improve this algorithm so that it can be faster?

Right now its complexity is \$O(n)\$.

def reverse(stri):
    output = ''
    length = len(stri)
    while length > 0:
        output += stri[-1]
        stri, length = (stri[0:length - 1], length - 1)
    return output
\$\endgroup\$
  • 11
    \$\begingroup\$ I'd say your code is O(n**2) because it creates at least n strings with length between 1 and n. \$\endgroup\$ – Eric Duminil Mar 11 at 13:01
  • 1
    \$\begingroup\$ I think you're right, since I didn't take into account the immutability of strings in python, but the graph of @Graipher shows something similar to O(nlgn) complexity for my code, I don't know I'm a bit confused... \$\endgroup\$ – Midos Mar 11 at 13:26
  • 1
    \$\begingroup\$ Depending on how large the string is and what you want to do with it, it might be a good idea to create a lazy ReverseString class, which only accesses the data when needed. \$\endgroup\$ – Eric Duminil Mar 11 at 13:27
  • 3
    \$\begingroup\$ @Midos That might be due to the Python implementation used. AFAIK, cPython does sometimes manage to reuse one of the two strings (i.e. it tries to reserve enough space after/before one of the two strings and needs to copy only one string, but don't quote me on that). However, that is both implementation dependent and potentially dependent on the available memory, which is why Python's official style-guide recommends not relying on it. \$\endgroup\$ – Graipher Mar 11 at 14:16
  • 1
    \$\begingroup\$ @Graipher I can't find it now, but I think the docs once said that it running in linear time is a CPython implementation detail. All I can find now is that strings are 100% immutable. 1 2 3 4 (note 6 talks about this very usecase) \$\endgroup\$ – Peilonrayz Mar 13 at 3:40
52
\$\begingroup\$

Yes, this can be faster. Adding strings using + is usually a bad idea in Python, since strings are immutable. This means that whenever you add two strings, a new string needs to be allocated with the size of the resulting strings and then both string contents need to be copied there. Even worse is doing so in a loop, because this has to happen ever time. Instead you usually want to build a list of strings and ''.join them at the end (where you pay this cost only once).

But here you can just use the fact that strings can be sliced and you can specify a negative step:

def reverse_g(s):
    return s[::-1]

Here is a timing comparison for random strings of length from one up to 1M characters, where reverse is your function and reverse_g is this one using slicing. Note the double-log scale, for the largest string your function is almost a hundred thousand times slower.

enter image description here


The reverse_s function uses the reversed built-in, as suggested in the (now deleted, so 10k+ reputation) answer by @sleblanc and assumes you actually need the reversed string and not just an iterator over it:

def reverse_s(s):
    return ''.join(reversed(s))

The reverse_b function uses the C implementation, compiled with -O3, provided in the answer by @Broman, with a wrapper to create the string buffers and extract the output:

from ctypes import *

revlib = cdll.LoadLibrary("rev.so")
_reverse_b = revlib.reverse
_reverse_b.argtypes = [c_char_p, c_char_p, c_size_t]

def reverse_b(s):
    stri = create_string_buffer(s.encode('utf-8'))
    stro = create_string_buffer(b'\000' * (len(s)+1))
    _reverse_b(stro, stri, len(s) - 1)
    return stro.value.decode()

In the no interface version, just the call to _reverse_b is timed.

\$\endgroup\$
  • 3
    \$\begingroup\$ How did you measure the uncertainty bars, is the drop in processing time with increasing string length near length 10 for reverse_g significant and if so why? \$\endgroup\$ – gerrit Mar 11 at 13:23
  • 3
    \$\begingroup\$ @gerrit: The uncertainty bars come from the fact that each timing measurement is performed three times, so the value is the mean and the uncertainty the uncertainty of the mean (i.e. standard deviation / sqrt(n)). So I would doubt that the drop is significant. Sometimes you get large increases due to some other activity on the machine, so that is what could have happened in the first case. \$\endgroup\$ – Graipher Mar 11 at 13:55
  • 3
    \$\begingroup\$ Data is code and code is data. Ponder why you might want to reverse the string, and consider instead to use an iterator that simply reads the string backwards. This is essentially what this answer is, while the actual implementation the iterator is buried inside CPython's internals. Nice answer \$\endgroup\$ – sleblanc Mar 12 at 2:17
  • 3
    \$\begingroup\$ Adding strings using + is usually a bad idea in Python, since strings are immutable. Immutability (and the use of Python) isn't what matters here; in nearly every case, string addition requires iterating over at least one of the strings. A pre-allocated, mutable C-string concatenation still requires linear time to copy the contents of the second string into the tail of the first. \$\endgroup\$ – Schism Mar 12 at 3:42
  • 1
    \$\begingroup\$ @IsmaelMiguel: As mentioned in the text reverse_g is the function with the slicing, i.e. the one in the answer. Renamed it so it is the correct name now, though. \$\endgroup\$ – Graipher Mar 12 at 10:14
14
\$\begingroup\$

In terms of pure complexity, the answer is simple: No, it is not possible to reverse a string faster than O(n). That is the theoretical limit when you look at the pure algorithm.

However, your code does not achieve that because the operations in the loop are not O(1). For instance, output += stri[-1] does not do what you think it does. Python is a very high level language that does a lot of strange things under the hood compared to languages such as C. Strings are immutable in Python, which means that each time this line is executed, a completely new string is created.

If you really need the speed, you could consider writing a C function and call it from Python. Here is an example:

rev.c:

#include <stddef.h>
void reverse(char * stro, char * stri, size_t length) {
    for(size_t i=0; i<length; i++) stro[i]=stri[length-1-i];
    stro[length]='\0';
}

Compile the above function with this command:

gcc -o rev.so -shared -fPIC rev.c

And here is a python script using that function.

rev.py:

from ctypes import *

revlib = cdll.LoadLibrary("rev.so");
reverse = revlib.reverse
reverse.argtypes = [c_char_p, c_char_p, c_size_t]

hello = "HelloWorld"
stri = create_string_buffer(hello)
stro = create_string_buffer(b'\000' * (len(hello)+1))

reverse(stro, stri, len(stri)-1)

print(repr(stri.value))
print(repr(stro.value))

Please note that I'm by no means an expert on this. I tested this with string of length 10⁸, and I tried the method from Graipher, calling the C function from Python and calling the C function from C. I used -O3 optimization. When I did not use any optimization it was slower to call the C function from Python. Also note that I did NOT include the time it took to create the buffers.

stri[::-1] :                  0.98s
calling reverse from python : 0.59s
calling reverse from c:       0.06s

It's not a huge improvement, but it is an improvement. But the pure C program was WAY faster. The main function I used was this one:

int __attribute__((optimize("0"))) // Disable optimization for main
main(int argc, char ** argv) {     // so that reverse is not inlined

    const size_t size = 1e9;
    char * str = malloc(size+1);

    static const char alphanum[] =
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    // Take data from outside the program to fool the optimizer        
    alphanum[atoi(argv[1])]='7';

    // Load string with random data to fool the optimizer        
    srand(time(NULL));
    for (size_t i = 0; i < size; ++i) {
        str[i] = alphanum[rand() % (sizeof(alphanum) - 1)];
    }

    char *o = malloc(size+1);
    reverse(o, str, strlen(str));

    // Do something with the data to fool the optimizer        
    for(size_t i=0; i<size; i++) 
        if(str[i] != o[size-i-1]) {
            printf("Error\n");
            exit(1);
        }
}

Then, to get the runtime I ran:

gcc -O3 -pg rev.c; ./a.out; gprof a.out gmon.out | head -n8
\$\endgroup\$
  • 1
    \$\begingroup\$ Thus, this answer is simply wrong as it doesn't address the issue with the presented code \$\endgroup\$ – PascalVKooten Mar 12 at 15:04
  • \$\begingroup\$ @PascalVKooten Fixed \$\endgroup\$ – Broman Mar 12 at 20:27
  • \$\begingroup\$ Added the timings to the plot. To be a fair competition I included the time of the wrapper function (since if you want to use the function in Python you want it to accept normal strings and return a normal string). I also had to modify the creation of the input buffer to include an encoding, otherwise it was giving me TypeErrors. I compiled the runtime with -O3. \$\endgroup\$ – Graipher Mar 13 at 8:47
  • \$\begingroup\$ @Graipher I was unsure if I should include the buffer creation or not. One can think of many circumstances where you continue to work on the same buffer and perform more operations. Maybe the most fair thing to do would be to do one with and one without. \$\endgroup\$ – Broman Mar 13 at 9:05
  • \$\begingroup\$ @Broman: To be honest, part of including it was so that getting and plotting the timings is easier, since all functions have the same interface, which let's me use a function for that. But I'll see what I can do. \$\endgroup\$ – Graipher Mar 13 at 9:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.