In doing this, we could
- use a more high-level C++ approach,
std::string reverse(const std::string &str); // ... std::string reversed = reverse("uncategorized"); printf(reversed); // dezirogetacnuwhere the function returns a reversed copy of the original string. (Btw, to reverse a std::string, use the std::reverse algorithm, see bottom of this post.)
Alternatively, we can
- write a C-styled function, using a "raw" pointer and then modify the string, in-place:
void reverseInPlace(char *str); // ... char s[] = "november"; reverseInPlace(s); printf(s); // rebmevonSince we are going for minimal memory footprint and optimal performance, we will pursue the latter approach.
Here, we do not consider utf-8 encoding, or some other important aspects of string handling that should be taken into account in a real-life application scenario.
Using a string buffer
Here is a simple C implementation. We write the string in reverse order to a separate, stack-allocated char array, and then copy the data back to the memory location of the original string using memcpy. Again, this is not the most efficient solution, but it works.
#include <stdio.h> #include <memory.h> void reverse(char *str) { int n = 0; while (str && str[n++]); if (--n < 2) return; char r[n--]; int x = 0; while (x <= n) r[x] = str[n - x++]; memcpy(str, r, x); } int main(int argc, char *argv[]) { if (argc > 1) { char *str = argv[1]; reverse(str); printf("%s\n", str); } return 0; }We save this as main.c, compile using,
gcc main.c -o reverse -std=c99and run:
./reverse '!hserf'
Haskell
Before we examine how this algorithm can be improved, let's take a look at a similar function in Haskell:r [] = [] r (x:xs) = r xs ++ [x]What!? Where is the program?
Let's see what happens. Keep in mind that declarative programming is really more about what things are (i.e., pure functions), as opposed to what they do (control flow). Also note that a string in Haskell is a list of characters.
Prelude> let str = ['h', 'e', 'l', 'l', 'o'] Prelude> str "hello"
Evaluation of the expression can be broken down into the following steps (in pseudo-code):
r "hello" = (r "ello") ++ ['h'] = (r "llo") ++ ['e'] ++ ['h'] = (r "lo") ++ ['l'] ++ ['e'] ++ ['h'] = (r "o") ++ ['l'] ++ ['l'] ++ ['e'] ++ ['h'] = ['o'] ++ ['l'] ++ ['l'] ++ ['e'] ++ ['h']Just for fun, let's compress this program even more (jQuery style), and we end up with this one-liner:
r[]=[];r(x:y)=r y++[x]We could also have defined r as:
r=foldl(flip(:))[]Ok, to be fair to C, this program doesn't have a main entry point, and it doesn't write anything to the output stream. Here is a complete program that also reads the input string from the command line arguments:
module Main where import System.Environment r [] = [] r (x:xs) = r xs ++ [x] main = do args <- getArgs; print (r (head args))Save this file as reverse.hs, compile with
ghc reverse.hs -o reverseand then (on Linux):
./reverse '!!cigam'
In-place swapping
Back to the C program. Using pointer arithmetic, we can get rid of the redundant string buffer and instead, truly, modify the string in-place. This illustration explains the idea:Here is a C program:
#include <stdio.h> void reverse(char *str) { unsigned int n = 0; while (str && str[n++]); if (--n < 2) return; int x = 0; unsigned int nn = n >> 1; while (n-- > nn) { char c = str[x]; str[x++] = str[n]; str[n] = c; } } int main(int argc, char *argv[]) { if (argc > 1) { char *str = argv[1]; reverse(str); printf("%s\n", str); } return 0; }We could also have used std::swap here, with the same result.
while (n > nn) std::swap(str[x++], str[--n]);
Ye olde XOR swap trick
There is an old assembly programming trick to swap the contents of two memory addresses using three consecutive XOR instructions. Remembering the old mantra, "premature optimization is the root of all evil", these kinds of "hacks" rarely give much (if any) performance benefit and are usually best avoided. Nevertheless… let's try it. The diagram shows how the trick works:Here is the C program:
#include <stdio.h> void reverse(char *str) { unsigned int n = 0; while (str && str[n++]); if (--n < 2) return; int x = 0; unsigned int nn = (n - 1) >> 1; while (--n > nn) { str[n] ^= str[x]; str[x] ^= str[n]; str[n] ^= str[x++]; } } int main(int argc, char *argv[]) { if (argc > 1) { char *str = argv[1]; reverse(str); printf("%s\n", str); } return 0; }Save as main3.c, and as usual, compile with
gcc main3.c -o revhack -std=c99and run:
./revhack '!skcor rox'
Inline assembly
To see if we can get even better performance, here is an assembly routine written in x86-64 assembly using the inline GAS (AT&T) syntax supported by gcc. (I didn't use the XOR trick here.) This program will only work on compatible hardware.#include <stdio.h> void reverse(char *str) { __asm__( "movq %%rax, %%rdi" ";" "movq %%rdi, %%rsi" ";" "count:" "incq %%rdi" ";" "cmpb $0, (%%rdi)" ";" "jne count" ";" "movq %%rdi, %%rcx" ";" "subq %%rsi, %%rcx" ";" "cmpq $2, %%rcx" ";" "jl exit" ";" "leaq (%%rsi, %%rcx), %%rdi" ";" "decq %%rdi" ";" "loop:" "movb (%%rsi), %%al" ";" "movb (%%rdi), %%bl" ";" "movb %%al, (%%rdi)" ";" "movb %%bl, (%%rsi)" ";" "incq %%rsi" ";" "decq %%rdi" ";" "cmpq %%rsi, %%rdi" ";" "jg loop" ";" "exit:" : : "a" (str) : "%rsi", "%rdi", "%rcx", "%rbx" ); } int main(int argc, char *argv[]) { if (argc > 1) { char *str = argv[1]; reverse(str); printf("%s\n", str); } return 0; }Compile and run the same way as the other examples.
Comparison
Here are the results when I benchmarked the various algorithms with some different size strings. (The Haskell version is not included in the test.)Some observations:
- The XOR hack turned out to be something of a fiasco. This is not surprising, clever optimization tricks usually turn out to be not so clever. For more reasonably sized strings, it even run longer than the first algorithm.
- The first (buffer/copy) algorithm is asymptotically slower (O(N log2 N)) than the others, who all have linear time complexity. It also, as noted earlier, uses more memory.
- The assembly routine is just slightly faster than the C version. It is amazing how optimized the code generated by gcc is, and it shows that we should really just trust the compiler most of the time.
Conclusion
My choice is (2), the simple swapping C algorithm, for a good balance of- readability,
- portability,
- performance, and
- memory efficiency.
The functional programming approach also looks very attractive. Haskell combines elegance, performance and portability and offers all the advantages of referential transparency. Haskell also has great support for concurrency.
Assembly language is a candidate when targeting specific hardware, if performance and memory use are of critical importance.
std::string and std::reverse
Finally, to reverse a std::string, we only need to:std::string str("eromsissel"); std::reverse(str.begin(), str.end()); std::cout << str;The reverse function is part of STL algorithm. This is the solution I would use in real-life C++ code.
0 comments:
Post a Comment