This answer is intended to serve as a compilation of the other two great answers offered here, backed up by example code. Note that the following was tested using the .NET Core 2.2 run-time on a machine with a 64-bit Intel CPU running Ubuntu 19.04 LTS Desktop.
First, the code.
The way I see it, this is the optimal solution for your situation as you have presented it (which as others have helpfully pointed out, is formally referred to as Run-length encoding or RLE):
using System;
using System.Collections.Generic; // Queue
using System.Text; // StringBuilder
namespace ExtensionMethods
{
public static class Program
{
public static void Main(string[] args)
{
if (args.Length > 0) {
foreach (string @string in args) {
@string.PrintRLE();
@string.PrintRLE(false);
}
} else {
RunLengthEncoding.TestNullOrEmptyRLE();
}
}
}
public static class RunLengthEncoding
{
public static string EncodeRLE(this string inputString, bool postfix = true)
{
var buffer = new StringBuilder();
if (!string.IsNullOrEmpty(inputString)) {
var queue = new Queue<char>(inputString);
uint count = 0;
while (queue.Count > 0) {
char character = queue.Dequeue();
count++;
if (queue.Count == 0 || character != queue.Peek()) {
if (postfix) {
buffer.Append(character);
if (count > 1) buffer.Append(count);
} else {
if (count > 1) buffer.Append(count);
buffer.Append(character);
}
count = 0;
}
}
}
return buffer.ToString();
}
public static void PrintRLE(this string inputString, bool postfix = true)
{
Console.WriteLine("BEFORE:\t\t{0}\nAFTER:\t\t{1}\nPOSTFIX:\t{2}\n",
inputString, inputString.EncodeRLE(postfix), postfix);
}
public static void TestNullOrEmptyRLE()
{
string inputString = null;
inputString.PrintRLE();
inputString.PrintRLE(false);
inputString = "";
inputString.PrintRLE();
inputString.PrintRLE(false);
}
}
}
Next, the output.
The above code produces the following hard-coded default output:
dyndrilliac@DynHome:~$ dotnet run
BEFORE:
AFTER:
POSTFIX: True
BEFORE:
AFTER:
POSTFIX: False
BEFORE:
AFTER:
POSTFIX: True
BEFORE:
AFTER:
POSTFIX: False
Also you can test custom input on-the-fly from the command-line:
dyndrilliac@DynHome:~$ dotnet run aaaaabbbccdeeeee aaabbccdddee a
BEFORE: aaaaabbbccdeeeee
AFTER: a5b3c2de5
POSTFIX: True
BEFORE: aaaaabbbccdeeeee
AFTER: 5a3b2cd5e
POSTFIX: False
BEFORE: aaabbccdddee
AFTER: a3b2c2d3e2
POSTFIX: True
BEFORE: aaabbccdddee
AFTER: 3a2b2c3d2e
POSTFIX: False
BEFORE: a
AFTER: a
POSTFIX: True
BEFORE: a
AFTER: a
POSTFIX: False
Finally, the explanation.
- String as a Queue. We are choosing to transform the input string (which is really just a character array, and more importantly, an implementer of the
IEnumerable<char>
interface) into a Queue for several reasons. Firstly, while the creation of the Queue is relatively expensive being an O(n) operation, both Peek and Dequeue are O(1) operations. Additionally, it lets us easily process all the characters in the string using simple logic with a single loop cycle which is another O(n) operation. Lastly, a Queue or Stack allows us to easily implement a Pushdown Automaton which can be easily extended to handle lexing/parsing much more complicated string formats than the one dealt with here, and is the type of technique presented in a formal compiler design course at a university.
- Class design. We're creating a static class that doesn't require instantiation, and also serves as a repository of Extension Methods which are a great feature that allows you to expand the functionality of existing classes (such as
System.String
) without having to use inheritance and create your own wrapper classes.
- StringBuilder. As others have suggested, we want to use a StringBuilder. That's because normal strings in .NET are immutable. Every time you think you're changing a string, you're actually creating a whole new string in memory and waiting for garbage collection to take care of de-allocating the old one. Instead of allocating memory for all these string operations, we want to use a StringBuilder which basically provides us with a mutable string for lots of fast cheap concatenation operations.
- Single Responsibility Principle. We have chosen to implement three separate methods each with a singular purpose: one to encode a string, another to print that encoding to standard output, and finally a special-case test driver to print the output for two hard-coded strings (empty string and null pointer). This allows us to modify the algorithm logic without affecting the input/output logic and vice-versa.
- General Loop Optimizations. This code attempts to achieve the goal of the original while minimizing the number of loop iterations and minimizing the number of operations per loop iteration.
- Descriptive Variable Names. Note that we used descriptive variable names to make it relatively easy to follow the algorithm. No single character variable names like
i
or s
.
- Optional Postfix Parameter. Lastly, by adding an optional
bool
parameter to the argument lists of both methods, we can choose whether we want the resulting encoded string to be postfix (count comes after character) or prefix (count comes before character). We shouldn't duplicate code unnecessarily; making two encoding methods and two print methods to accommodate a postfix/prefix choice would be wasteful.
More about the choice to use a Queue
While it would be faster in this case to use the array directly, that's only due to the upfront cost of copying the data from the array into the queue. One of the upsides of the queue is similar to the rationale for the StringBuilder in that the queue provides mutability (unlike an array/string) thanks to Dequeue, which as previously mentioned is an O(1) operation! This also allows the code to more easily be adapted for more complicated context-free parsing algorithms that depend on the behavior of a queue or stack to implement a Pushdown Automaton, like the LL parser. To be clear, it's not that you can't implement a Pushdown Automaton with an array (it's not uncommon for Queues and Stacks to be backed up under-the-hood by ArrayList objects or similar, after all) - but all the additional loops and code that would require make it unnecessarily harder to read the solution. In the case of more complex algorithms, clean code that's easy to read is more valuable than the one-time upfront cost of calling the queue constructor. This answer assumes that the person asking this question will eventually need to perform more complex translation operations in the future, and the formal techniques and algorithms that use more robust data structures tend to be easier to scale-up than those that don't. Another nice thing about going the Pushdown Automaton route is that if you start out writing a bottom-up parser with a stack (where the top of the stack represents the last successfully read token) and then decide part-way through that it would be better to do a top-down parser with a queue (where the front of the queue represents the first successfully read token), then you can keep most or all of the logic the same and just simply Find/Replace Dequeue with Pop and Enqueue with Push or vice-versa. Peek remains Peek whether you are peeking at the front of a queue or the top of a stack.