Efficient String Reversal In C#

Posted by : on

Category : C#

Introduction

Reversing a string in C# might sound straightforward, but when efficiency matters, there are surprisingly many ways to do it. This complexity arises from the different definitions of efficiency, such as speed and memory usage, various ways characters are represented in different character sets, and different runtimes like Unity’s Mono.

In this post, I’ll explore several methods for reversing a string that are commonly found online. I’ll also provide some benchmark results for these methods in .NET 8. However, you shouldn’t rely solely on these benchmarks; it’s crucial to benchmark the algorithms yourself. The results can vary not only across different .NET versions but also on different hardware, especially since some of the more optimized methods depend on low-level hardware implementations. Additionally, the performance will vary depending on the size of the string you want to reverse, so make sure to benchmark these methods with strings that are similar in size to those you expect to work with.

Before diving into the examples, let’s define what we mean by efficiency and discuss some important nuances of the character set that C# uses.

Definition Of Efficiency

When I refer to efficiency in the context of string reversal in this post, I’m focusing on execution speed and heap memory allocation. General memory usage, such as temporary 16-bit local variables stored on the stack, is less of a concern since memory constraints are not as tight as they once were.

I’m also discussing the algorithm’s efficiency, not the programmer’s efficiency. Some of these algorithms can be challenging to understand if you’re not familiar with how they work, and more importantly, they can be difficult to modify if the requirements change—especially when dealing with characters outside the Basic Multilingual Plane. Generally, a programmer’s efficiency is more important than the program’s efficiency.

Unless your code is in a hot path, your string reversal is the bottleneck, or its execution time is significantly greater than other factors affecting performance in your code, the choice of algorithm should prioritize programmer efficiency. Specifically, consider how easy it is to modify, debug, and test your string reversal code.

Always benchmark your specific code on your target hardware, not only because different algorithms yield different results, but also because a more performant algorithm may have minimal or even no impact in your particular situation.

UTF16 Character Set

.NET uses the UTF-16 to store characters and strings. In UTF-16, characters require either one or two 16-bit words for representation, but a char in .NET is only 16 bits in length. This means some UTF-16 characters need two char values, known as surrogates. This has two implications for string handling:

  1. The length of a string may not correspond to the actual number of characters it contains. Some strings may have a greater length than the number of characters due to the presence of surrogate pairs.
  2. Since a single char is insufficient to represent a non-BMP (Basic Multilingual Plane) character, special care must be taken to treat two consecutive char values as a single character, rather than reversing each 16-bit word individually.

While this might sound complex, most programs can ignore these details because the majority of commonly used characters fall within the BMP. The BMP includes characters from many languages and over 30,000 Chinese characters. However, it doesn’t include characters from some ancient languages, musical notation symbols, or many emojis. You should assess your program’s requirements, and if non-BMP characters are needed, you can use the following static methods in the char type to detect non-BMP characters:

  1. Char.IsSurrogate
  2. Char.IsHighSurrogate
  3. Char.IsLowSurrogate
  4. Char.IsSurrogatePair

You can also use the following two char static methods to convert from a 32-bit value to two chars or the reverse:

  1. string ConvertFromUtf32 (int utf32)
  2. int ConvertToUtf32 (char highSurrogate, char lowSurrogate) or int ConvertToUtf32 (string s, int index)

For a more low-level approach, surrogates are easy to identify since each word falls within the range 0xD800 to 0xDFFF. However, using the IsSurrogate method on a span of characters is the most efficient way to handle them.

In the following string reversal algorithms, I do not account for surrogates. These algorithms assume that the input strings contain only characters within the BMP. This is not just because non-BMP characters are rare and a special case, but also because testing would be challenging—it would depend on the number of non-BMP characters relative to the string’s length.

Moreover, implementing logic to handle surrogate characters would significantly increase the complexity of some algorithms. While handling surrogates is relatively straightforward for algorithms that process individual char values, those that use the Reverse method of the internal SpanHelpers class in .NET internally, would require substantial modifications. This method relies on marshalling memory and utilizing hardware registers, so accommodating surrogates would transform a simple statement into a series of complex logic steps, severely impacting performance.

With that in mind, let’s start with the simplest approach and build from there.

Simple Reversals

Let’s start with the least performant ways, that are also the more convenient to use. These methods are slow, but are the easiest to understand and use classes that most commonly come to mind when we have to deal with strings.

Adding To A String

One of the simplest and less performant ways is the following:

public static string StringSimpleReversal(string text)
{
   string result = String.Empty;
   for (int i = text.Length - 1; i >= 0; i--)
   {
      result += text[i];
   }
   return result;
}

Anyone with basic knowledge of C#, knows about the string immutability and how they affect the garbage collector. There are many different ways that someone can use string addition, this is just one example, but in the end it will always be the least performant way.I’m including this method here solely as a reference point, to provide a baseline for comparing the performance of other methods.

This approach involves traversing the original string backward and appending each character to a result string that starts off as empty.

Using The Reverse Enumerable Extension Method

The next method, reverses a string by using the Enumerable.Reverse method. This method is simpler to understand than the previous, a little more performant too:

public static string ReverseEnumerable(string text) => new(text.Reverse().ToArray());

As simple as it gets, only one statement and easy to understand.

Using StringBuilder

The next method that comes to mind, when dealing with strings is to use the StringBuilder class:

public static string StringBuilderReverseMethod(string text)
{
   var result = new StringBuilder(text.Length);
   for (int i = text.Length - 1; i >= 0; i--)
   {
      result.Append(text[i]);
   }
   return result.ToString();
}

Works the same way as our first method, only now, we don’t have to deal with the garbage collector as much, because of the way StringBuilder handles strings. More performant than the previous two methods, but still pretty slow compared to the “fast” reversal methods below.

Using A Temporary Value For Swapping Characters

After covering the more straightforward methods for string reversal, let’s explore some more efficient techniques. In this method, we use a temporary char variable to swap the positions of two characters. The loop iterates through the string, swapping the first character with the last, the second with the second-to-last, and so on, until it reaches the middle of the string. At this point, all the characters in the string have been reversed.

public static string ReverseStringWithTemp(string value)
{
   Span<char> charSpan = value.ToCharArray();
   
   int left = 0;
   int right = charSpan.Length - 1;
   while (left < right)
   {
      char temp = charSpan[left];
      charSpan[left] = charSpan[right];
      charSpan[right] = temp;
      left++;
      right--;
   }
   
   return new string(charSpan);
}

Interestingly, this method is the one proposed from chatGTP as the most performant way to reverse a string in C#. HINT: It is not. It is pretty performant, but not the most performant and its performance deteriorates in comparison to other methods below, as our strings get bigger.

Same With The Use Of Deconstructor

A variation of the above method, is to use a deconstructor to handle the reversal of two chars:

public static string ReverseStringDeconstruct(string value)
{
   Span<char> charSpan = value.ToCharArray();
   int left = 0;
   int right = charSpan.Length - 1;
   while (left < right)
   {
      (charSpan[left], charSpan[right]) = (charSpan[right], charSpan[left]);
      left++;
      right--;
   }
   return new string(charSpan);
}

This is easier to read than the previous one, but a little slower. The difference in performance compared to the previous is negligible (a few nanoseconds), this happens because if we check the IL code, we have two temporary variables created from the deconstruction. Use whatever seems easier to read to you, between those two methods.

Using XOR

Another way to reverse a string is by using a loop to swap the string’s characters, but this time with a XOR-based approach.

A XOR-based reversal can be particularly useful when dealing with very restrictive memory requirements, as it doesn’t require any temporary variables to swap two characters. Although this method is slower than the previous one, it’s valuable to understand because it can be applied to swap any two values, regardless of their type.

The XOR approach is based on the principle that the XOR operation is its own inverse. For example, if we have two values, A and B, and perform a XOR operation, we get a result: A XOR B = Result. If we then XOR the result with A, we retrieve B, and if we XOR it with B, we retrieve A. This property can be applied to string reversal as follows:

public static string ReverseXor(string s)
{
   char[] charArray = s.ToCharArray();
   int len = s.Length - 1;
   for (int i = 0; i < len; i++, len--)
   {
      charArray[i] ^= charArray[len];
      charArray[len] ^= charArray[i];
      charArray[i] ^= charArray[len];
   }
   return new string(charArray);
}

Here’s how the XOR-based string reversal works: We take the character at the start of the string as A and the character at the end as B. First, we XOR them and store the result in A’s variable. Next, we XOR B with this result (now stored in A’s variable) and store this operation’s outcome in B’s variable. This gives us the original value of A in B’s variable, while A still holds the XOR result. Finally, we XOR the value now in A’s variable with the value now in B’s variable (which holds the original A). The result of this operation, which is the original value of B, is stored in A’s variable.

Using Array And Span Reverse

The following two methods, are based on the Reverse method of the internal SpanHelpers class in .NET, as this is the method that is eventually called to perform the reversal in every case. This is a highly optimized method, that uses aggressive inlining and checks what registers are supported by the hardware and uses them, depending on their availability. Their performance is about equal, they are the most performant methods from all the above and the bigger the strings we have the greater the differences in performance gains compared to the previous methods.

public static string ReverseArray(string text)
{
   char[] array = text.ToCharArray();
   Array.Reverse(array);
   return new string(array);
}
public static string ReverseSpan(string text)
{
   Span<char> charSpan = text.ToCharArray();
   charSpan.Reverse();
   return charSpan.ToString();
}

Don’t get confused about the choice between using the Array.Reverse and the charSpan.Reverse as both of those under the hood use the same method. The Reverse method of the internal SpanHelpers class, will use the Vector128 Vector256 or Vector512 structs depending on your hardware, and make the reversal by using the shuffle method of those classes, along with some low level optimizations for passing the data.

Using The String.Create Method

Finally, I left the most performant method for last:

public static string StringReverseWithCreate(string text)
{
   return string.Create(text.Length, text, (chars, text) =>
   {
      text.AsSpan().CopyTo(chars);
      chars.Reverse();
   });
}

This method is using the String.Create method. This method, creates a string faster and with no additional allocation, as it uses a Span and unsafe operations internally to get the raw string data and then uses the same reverse method from the internal SpanHelpers class as the two previous methods. The effect is that, we have less heap memory allocations and our reversal executes faster.

The last three methods presented here, that use the reverse method from the internal SpanHelpers class, are the fastest, but they will be really difficult to modify for use with non-BMP characters, as they reverse everything in chunks. We would need a way to create strings that don’t have surrogates, pass those strings to these methods and deal with the surrogates manually. This would be a problem that would increase both the complexity and the execution time of our program, so for strings that are expected to have surrogate characters, modifying one of the methods that deal directly with the char type is preferable.

Benchmark Results

Finally some benchmarks. As I mentioned, benchmarks will differ depending on the hardware used, the length of the provided strings, the runtime and the .NET version. These are just for informational purposes and it is recommended that you run your own benchmarks on your own environment to get a more accurate view of each method’s performance.

Click to view the results for a random string with a length of 1
MethodMeanErrorStdDevMedianGen0Allocated
ReverseStringSimple13.19 ns0.298 ns0.264 ns13.11 ns0.007624 B
ReverseStringEnumerable97.68 ns2.006 ns2.536 ns97.05 ns0.0535168 B
ReverseStringBuilder31.92 ns0.673 ns1.196 ns31.93 ns0.0331104 B
ReverseStringWithTemp23.84 ns0.510 ns0.664 ns24.00 ns0.017956 B
ReverseStringDeconstruct23.39 ns0.446 ns0.395 ns23.54 ns0.017956 B
ReverseStringXor23.37 ns0.450 ns0.399 ns23.27 ns0.017956 B
ReverseStringArray24.85 ns0.518 ns0.692 ns24.87 ns0.017956 B
ReverseStringSpan27.24 ns0.549 ns0.513 ns27.11 ns0.017956 B
ReverseStringCreate11.66 ns0.269 ns0.450 ns11.46 ns0.007624 B


Click to view the results for a random string with a length of 5
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple62.23 ns1.291 ns2.519 ns0.0484152 B
ReverseStringEnumerable155.12 ns3.067 ns2.869 ns0.0713224 B
ReverseStringBuilder43.43 ns0.895 ns1.132 ns0.0382120 B
ReverseStringWithTemp29.11 ns0.620 ns1.195 ns0.022972 B
ReverseStringDeconstruct28.74 ns0.607 ns0.889 ns0.022972 B
ReverseStringXor31.52 ns0.662 ns1.292 ns0.022972 B
ReverseStringArray29.42 ns0.518 ns0.459 ns0.022972 B
ReverseStringSpan33.33 ns0.701 ns1.112 ns0.022972 B
ReverseStringCreate15.64 ns0.352 ns0.643 ns0.010232 B


Click to view the results for a random string with a length of 10
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple122.36 ns1.901 ns1.686 ns0.1147360 B
ReverseStringEnumerable197.44 ns3.922 ns4.516 ns0.0918288 B
ReverseStringBuilder58.41 ns1.213 ns3.130 ns0.0459144 B
ReverseStringWithTemp35.53 ns0.748 ns1.748 ns0.030696 B
ReverseStringDeconstruct36.74 ns0.767 ns1.441 ns0.030696 B
ReverseStringXor38.86 ns0.634 ns0.562 ns0.030696 B
ReverseStringArray37.55 ns0.771 ns1.245 ns0.030696 B
ReverseStringSpan40.00 ns0.830 ns1.387 ns0.030696 B
ReverseStringCreate19.45 ns0.418 ns0.371 ns0.015348 B


Click to view the results for a random string with a length of 20
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple264.08 ns3.724 ns3.484 ns0.2933920 B
ReverseStringEnumerable292.97 ns5.717 ns5.871 ns0.1373432 B
ReverseStringBuilder78.20 ns1.595 ns1.638 ns0.0560176 B
ReverseStringWithTemp42.76 ns0.876 ns0.860 ns0.0408128 B
ReverseStringDeconstruct41.82 ns0.379 ns0.336 ns0.0408128 B
ReverseStringXor53.30 ns0.884 ns0.826 ns0.0408128 B
ReverseStringArray38.69 ns0.808 ns1.951 ns0.0408128 B
ReverseStringSpan39.69 ns0.776 ns0.797 ns0.0408128 B
ReverseStringCreate18.99 ns0.225 ns0.200 ns0.020464 B


Click to view the results for a random string with a length of 30
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple435.15 ns7.917 ns7.406 ns0.53501680 B
ReverseStringEnumerable302.83 ns1.087 ns0.908 ns0.1526480 B
ReverseStringBuilder101.02 ns0.786 ns0.735 ns0.0714224 B
ReverseStringWithTemp50.98 ns0.289 ns0.257 ns0.0561176 B
ReverseStringDeconstruct52.13 ns0.508 ns0.475 ns0.0561176 B
ReverseStringXor67.75 ns1.127 ns1.054 ns0.0560176 B
ReverseStringArray39.49 ns0.390 ns0.365 ns0.0561176 B
ReverseStringSpan43.94 ns0.164 ns0.137 ns0.0561176 B
ReverseStringCreate21.53 ns0.244 ns0.228 ns0.028088 B


Click to view the results for a random string with a length of 40
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple650.72 ns12.900 ns15.842 ns0.84112640 B
ReverseStringEnumerable388.90 ns2.778 ns2.598 ns0.1912600 B
ReverseStringBuilder131.57 ns0.479 ns0.400 ns0.0815256 B
ReverseStringWithTemp62.61 ns0.453 ns0.424 ns0.0663208 B
ReverseStringDeconstruct63.09 ns0.361 ns0.282 ns0.0663208 B
ReverseStringXor85.10 ns1.068 ns0.999 ns0.0663208 B
ReverseStringArray45.48 ns0.244 ns0.191 ns0.0663208 B
ReverseStringSpan48.42 ns0.071 ns0.055 ns0.0663208 B
ReverseStringCreate24.56 ns0.108 ns0.090 ns0.0331104 B


Click to view the results for a random string with a length of 50
MethodMeanErrorStdDevMedianGen0Allocated
ReverseStringSimple1,093.42 ns25.522 ns75.251 ns1,112.69 ns1.21123800 B
ReverseStringEnumerable483.19 ns9.684 ns15.078 ns485.91 ns0.2060648 B
ReverseStringBuilder170.81 ns3.381 ns6.182 ns173.18 ns0.0968304 B
ReverseStringWithTemp75.72 ns1.549 ns3.163 ns74.24 ns0.0815256 B
ReverseStringDeconstruct73.11 ns0.455 ns0.403 ns73.14 ns0.0815256 B
ReverseStringXor100.78 ns0.700 ns0.654 ns100.57 ns0.0815256 B
ReverseStringArray53.12 ns0.478 ns0.399 ns52.98 ns0.0816256 B
ReverseStringSpan57.38 ns0.968 ns0.858 ns57.22 ns0.0816256 B
ReverseStringCreate30.55 ns0.432 ns0.383 ns30.48 ns0.0408128 B


Click to view the results for a random string with a length of 100
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple2,340.57 ns22.962 ns19.174 ns4.016912600 B
ReverseStringEnumerable657.08 ns13.165 ns15.673 ns0.3157992 B
ReverseStringBuilder266.68 ns1.979 ns1.653 ns0.1578496 B
ReverseStringWithTemp130.31 ns1.171 ns1.038 ns0.1428448 B
ReverseStringDeconstruct130.66 ns1.021 ns0.905 ns0.1428448 B
ReverseStringXor184.13 ns1.872 ns1.659 ns0.1428448 B
ReverseStringArray77.03 ns0.449 ns0.375 ns0.1428448 B
ReverseStringSpan87.03 ns1.071 ns1.001 ns0.1428448 B
ReverseStringCreate44.35 ns0.586 ns0.520 ns0.0714224 B


Click to view the results for a random string with a length of 200
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple7,080.09 ns40.533 ns31.645 ns14.404345200 B
ReverseStringEnumerable1,115.08 ns21.869 ns31.364 ns0.53221672 B
ReverseStringBuilder497.35 ns2.983 ns2.644 ns0.2851896 B
ReverseStringWithTemp237.31 ns1.759 ns1.469 ns0.2699848 B
ReverseStringDeconstruct237.65 ns3.459 ns2.889 ns0.2699848 B
ReverseStringXor340.99 ns5.452 ns4.553 ns0.2699848 B
ReverseStringArray127.70 ns0.508 ns0.425 ns0.2701848 B
ReverseStringSpan132.87 ns1.486 ns1.241 ns0.2701848 B
ReverseStringCreate74.15 ns1.237 ns1.096 ns0.1351424 B


Click to view the results for a random string with a length of 500
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple38,178.8 ns507.39 ns449.78 ns83.8013256.84 KB
ReverseStringEnumerable2,286.6 ns16.11 ns14.28 ns1.11393.41 KB
ReverseStringBuilder1,220.5 ns12.04 ns10.06 ns0.66762.05 KB
ReverseStringWithTemp566.1 ns8.04 ns7.52 ns0.65232 KB
ReverseStringDeconstruct570.6 ns3.92 ns3.27 ns0.65232 KB
ReverseStringXor832.5 ns5.91 ns4.93 ns0.65232 KB
ReverseStringArray310.1 ns6.08 ns5.08 ns0.65232 KB
ReverseStringSpan319.3 ns6.14 ns6.03 ns0.65232 KB
ReverseStringCreate173.7 ns1.73 ns1.53 ns0.32621 KB


Click to view the results for a random string with a length of 1000
MethodMeanErrorStdDevGen0Allocated
ReverseStringSimple142,143.8 ns689.67 ns611.37 ns326.90431001.95 KB
ReverseStringEnumerable4,252.4 ns59.55 ns79.49 ns2.08286.39 KB
ReverseStringBuilder2,390.0 ns22.35 ns17.45 ns1.30464 KB
ReverseStringWithTemp1,120.2 ns14.06 ns13.15 ns1.28943.95 KB
ReverseStringDeconstruct1,113.1 ns4.70 ns3.67 ns1.28943.95 KB
ReverseStringXor1,630.4 ns8.60 ns7.18 ns1.28943.95 KB
ReverseStringArray592.5 ns6.55 ns5.81 ns1.29033.95 KB
ReverseStringSpan599.2 ns4.23 ns3.53 ns1.29033.95 KB
ReverseStringCreate345.5 ns3.70 ns3.28 ns0.64521.98 KB


Conclusion

These are the different ways that I have found to perform string reversal in C#. As you can see, it is not that simple, as they don’t only depend on hardware and input length, as all benchmarks do, but we also have to take into account the characters those strings contain and more specifically if they belong in the Basic Multilingual Plane. Generally, if surrogate characters are of no concern, then the methods from the BCL that use the Reversal method of the internal SpanHelpers class are preferable, as it contains low level optimizations and will get upgrades over time if needed.

If you know more performant ways to reverse a string, or you get different benchmark results in your hardware I would love if you could leave a comment below. Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: