17

Can anyone help me with a nice LINQ expression for transforming a list of strings in another list containing only the shortest distinct common prefixes for the strings? The delimiter for prefixes is ..

Example: ["A", "A.B.D", "A", "A.B","E","F.E", "F","B.C"]

Goes to: ["A", "E", "F", "B.C"]

Removed:

  • "A.B.D" and "A.B" because the prefix "A" is already in the list
  • "A" because is duplicate
  • "F.E" because "F" already in list

Thanks!

abatishchev
  • 98,240
  • 88
  • 296
  • 433
dave
  • 171
  • 1
  • 3

12 Answers12

3

Here you go:

from set in
    (from item in list select item.Split('.')).GroupBy(x => x[0])
select
  set.First()
     .TakeWhile((part, index) => set.All(x => x.Length > index && x[index].Equals(part)))
     .Aggregate((x, y) => String.Format("{0}.{1}", x, y));

By way of explanation:

  1. First, we split all the strings by '.' and group by their first token.
  2. Then, we look at the first element of each grouping, and we take parts from it while every element of that group continues to match (TakeWhile).
  3. Then, we take all those parts and recompose them with the Aggregate(String.Format).
jtdubs
  • 13,585
  • 1
  • 17
  • 12
  • This seems like a really nice solution, much shorter than my. Although a littler harder to understand :). +1 for some nice linq. One improvement is to change the second `from` to `(from item in list.distinct()...`, so you remove duplicates right away. – Tomas Jansson Nov 23 '10 at 08:46
  • I also just learned that .NET has string.Join(), so my Aggregate should probably be replaced with that. – jtdubs Nov 23 '10 at 12:22
  • Does not work for e.g. A.B.C, A.B.D (which should result in A.B by your reading I believe) – Stefan Wenig Nov 29 '10 at 14:44
  • @stefan-wenig: With list set to { "A.B.C", "A.B.D" }, the posted code outputs the single prefix "A.B". Are you disagreeing that that is the output? Or, are you disagreeing that that output is correct? – jtdubs Nov 30 '10 at 06:04
  • no, you're right, I just made some copy&paste error. sorry about that. (I do dispute that "A.B" is the correct answer according to dave's question, see my answer, but he did not clarify and I accept that this became a contest for a much trickier problem. great solution you got there.) – Stefan Wenig Nov 30 '10 at 08:55
2

EDIT: thanks to the comments for pointing out a bug in my earlier approach.

To get around that shortcoming this query should work:

var list = new List<string> { "A.B.D", "A", "A.B","E","F.E", "F","B.C", "B.C.D" };
var result = list.OrderBy(s => s)
                 .GroupBy(s => s[0])
                 .Select(g => g.First());

foreach (var s in result)
{
    Console.WriteLine(s);
}

Incorrect approach:

The following query will group each string by the first character. Next, if the group count has more than one item the key is selected, otherwise the single item is selected.

var list = new List<string> { "A", "A.B.D", "A", "A.B", "E", "F.E", "F", "B.C" };
var result = list.GroupBy(s => s[0])
                 .Select(g => g.Count() > 1 ? g.Key.ToString() : g.Single());

foreach (var s in result)
{
    Console.WriteLine(s);
}
Ahmad Mageed
  • 94,561
  • 19
  • 163
  • 174
  • The key is not s[0] always, I'm afraid. – Cheng Chen Nov 21 '10 at 19:38
  • @Danny can you explain? The ternary condition will use the group's key if that group has multiple items, such as `A, A.B.D, A.B` in which case the key is `A`. If exactly one item exists, that item is taken. That is the case for `B.C` where the key is `B` but since it's the sole item in that group then `B.C` is selected. – Ahmad Mageed Nov 21 '10 at 19:43
  • 1
    If the values are `B.C` and `B.C.D` I think `B.C` should be returned because `B` is not in the list. – pinkfloydx33 Nov 21 '10 at 20:45
  • Doesn't work if you add "B.C.D" to the list; in that case the output should contain "B.C", but it only contains "B" – Thomas Levesque Nov 21 '10 at 20:48
  • @pinkfloydx33 and @Thomas, thanks for the feedback. Updated to address that issue. – Ahmad Mageed Nov 21 '10 at 21:06
2
string[] source = {"A", "A.B", "A.B.D", "B.C", "B.C.D", "B.D", "E", "F", "F.E"};
var result = 
source.Distinct()
      .Select(str => str.Split('.'))
      .GroupBy(arr => arr[0])
      .Select(g =>
        {
          return string.Join(".", 
                 g.Aggregate((arr1, arr2) =>
                    {
                      return arr1.TakeWhile((str, index) => index < arr2.Length 
                                               && str.Equals(arr2[index]))
                                 .ToArray();
                    }));
        });

Steps:

(1) Remove duplicated elements by Distinct()

(2) Split each element to an array, also get ready to be grouped

(3) Group those arrays by the first string in the array

(4) For each group, create one common prefix by aggregating all arrays in the group. The logic for aggregating is that for two arrays arr1 and arr2, take the elements in arr1 until (1)out of bounds (2) corresponding element in arr2 is different

Note: I add two return statements in the code, to make it look cleaner. It can be shorter if remove return and its {} brackets.

Cheng Chen
  • 42,509
  • 16
  • 113
  • 174
  • @Thomas: Em...you are correct. I think I misunderstood OP's question. I'll modify the answer after OP answered your question in the comment. – Cheng Chen Nov 22 '10 at 06:52
2
    var items = new[] { "A", "A.B.D", "A", "A.B", "E", "F.E", "F", "B.C" };
    var result = items
        .OrderBy(s => s.Length)
        .Distinct()
        .ToLookup(s => s.Substring(0, 1))
        .Select(g => g.First());

Order the items by their length, call distinct to remove duplicates, convert to groupings based on the first character, and select the first item in each group.

Yields: "A", "E", "F", "B.C"

Edit: You probably don't even need Distinct as your selecting the first item in each group anyway, so it's really redundant.

Matthew Abbott
  • 60,571
  • 9
  • 104
  • 129
2

Nailed it - assuming that if the source list contains "Q.X" & "Q.Y" then the result should contain "Q".

var source = new []
{
    "A", "A.B.D", "A",
    "A.B", "E", "F.E",
    "F", "B.C",
    "Q.X", "Q.Y",
    "D.A.A", "D.A.B",
};

Func<string, int> startsWithCount =
    s => source.Where(x => x.StartsWith(s)).Count();

var results =
    (from x in source.Distinct()
    let xx = x.Split('.')
    let splits = Enumerable
        .Range(1, xx.Length)
        .Select(n => String.Join(".", xx.Take(n)))
    let first = startsWithCount(splits.First())
    select splits
        .Where(s => startsWithCount(s) == first)
        .Last()
    ).Distinct();


// results == ["A", "E", "F", "B.C", "Q", "D.A"]
Enigmativity
  • 113,464
  • 11
  • 89
  • 172
2

How about:

var possible = new List<string> { "A", "A.B.D", "A", "A.B", "E", "F.E", "F", "B.C" };
var shortest = possible.Distinct().Where(x => possible.Distinct().Where(y => !y.Equals(x) && x.StartsWith(y)).Count() == 0).ToList();

It checks the list against itself excluding items that are equal and any items that starts with any of the other items. I'm not sure about the effeciency though :)

  • @clausus: I think your solution is wrong, but nice looking :). If you take the following input: `{ "A", "A.B.D", "A", "A.B", "E", "F.E", "F", "B.C", "B.C.D", "B.E" }`, your solution will output both B.C and B.E, and I think that should be shortened down to just B. – Tomas Jansson Nov 22 '10 at 15:34
  • Yes, that might the expected outcome :) I find the question a bit ambiguous though. I guess in order for this solution to work like you suggest, you could add a step before selecting the items where you create all sub strings of each item: B.C -> B, B.C and B.E -> B, B.E .. – Claus Asbjørn Sørensen Nov 22 '10 at 19:23
  • I'm not sure what you mean, and I'm not sure it solves the problem either. I don't think Linq should be used to solve the problem, it is a powerful tool but it is used mainly for querying and this problem concerns the implementation of an algorithm. I provided a solution to the problem, http://stackoverflow.com/questions/4239763/linq-expression-for-shortest-common-prefix/4247033#4247033, right after you that is using an recursive approach, and the recursive part makes it hard to translate to linq. – Tomas Jansson Nov 22 '10 at 19:29
  • I agree that LINQ shouldn't be used to solve this, but it's an interesting challenging :) – Claus Asbjørn Sørensen Nov 22 '10 at 19:34
  • But I really think you need to use some kind of recursion, and you can probably use linq if you define some kind of function that you pass around. – Tomas Jansson Nov 22 '10 at 20:53
  • I see your point, and you might well be right. I considered changing my initial suggestion by adding a LINQ element before the actual query that changes the set by adding the sub elements of each element. By splitting the element and using Enumerator.Range and getting subsets of the original element, you could turn the element A.B.C into three elements: A, A.B, A.B.C .. My idea was that by doing this on all elements before doing my initial solution, the query would return B for elements B.C and B.E, instead of returning both. I'm not sure it will work, it's just an idea :) – Claus Asbjørn Sørensen Nov 22 '10 at 21:14
  • I think you're right. The question defines the requirement only via what's excluded. The wording "shortest distinct common prefixes" is unprecise and contradicts the sample, that would result in "["A", "E", "F", "B"]. – Stefan Wenig Nov 24 '10 at 10:24
  • I also notice that your answer looks remarkably like mine, including the bug it originally had. Replacing != with !Equals and !Any with Count()==0 does not really make for an original answer, does it? ;-) – Stefan Wenig Nov 24 '10 at 10:25
  • @Stefan: Glad you agree with me .. But honestly, I looked at the question and came up with my own answer without looking at any of the others :) If it resembles your solution it is purely by accident .. Great minds think alike etc ;) – Claus Asbjørn Sørensen Nov 24 '10 at 13:22
  • no problem, we'll both lose this one anyway. some other answers are just more exciting, nevermind the question ;-) – Stefan Wenig Nov 24 '10 at 19:15
2

I think it might be hard to solve with one single nice looking linq expression so I wrote a recursive function using linq that solves the problem:

class Program
{
    static void Main(string[] args)
    {
        var input = new string[] { "A", "A.B.D", "A", "A.B", "E", "F.E", "F", "B.C", "B.C.D", "B.E" };

        var output = FilterFunc(input);
        foreach (var str in output)
            Console.WriteLine(str);

        Console.ReadLine();
    }

    static string[] FilterFunc(string[] input)
    {
        if (input.Length <= 1)
            return input;
        else
        {
            var firstElem = input[0];
            var indexNr = firstElem.Length;
            var maxFilteredElems = 0;
            for (int i = firstElem.Length; i > 0; i--)
            {
                var numberOfFilteredElems = input.Where(x => x.StartsWith(firstElem.Substring(0, i))).Count();
                if (numberOfFilteredElems > maxFilteredElems)
                {
                    maxFilteredElems = numberOfFilteredElems;
                    indexNr = i;
                }
            }
            var prefix = firstElem.Substring(0, indexNr);
            var recursiveResult = FilterFunc(input.Where(x => !x.StartsWith(prefix)).ToArray());
            var result = recursiveResult.ToList();
            prefix = prefix.EndsWith(".") ? prefix.Substring(0, prefix.Length - 1) : prefix;
            result.Insert(0, prefix);
            return result.ToArray();
        }
    }
}

The code could probably be more effective and more organized but don't have time for that now. I think the other solutions are wrong so far, so that's why you get my longer one. I think you need to solve it recursively to be sure to get the shortest list.

Tomas Jansson
  • 22,767
  • 13
  • 83
  • 137
  • This solution doesn't work if any of the substrings have a length greater than one. So if the input were `["Q.X", "Q.Y", "QQ.X", "QQ.Y"]` the result should be `["Q", "QQ"]`, not `["Q"]`. – Enigmativity Nov 23 '10 at 06:06
  • It shouldn't be that hard to get this solution to work for your case as well.... but the question doesn't specify that the result should be ["Q", "QQ"], does it? You might be right, and if you are it's pretty easy to change the solution as I said. – Tomas Jansson Nov 23 '10 at 06:22
2

My attempt, loop through items removing anything prefixed with another item.



static void Run()
{
    var list = new string[] {"A", "A.B.D", "A",
                            "A.B", "E", "F.E",
                            "F", "B.C",
                            "Q.X", "Q.Y",
                            "D.A.A", "D.A.B"
                        };

    int size = 0;
    var prefixList = new string[list.Length];
    Array.Copy(list, prefixList, list.Length);

    for (int i = 0; i < list.Length; i++)
        prefixList 
        = prefixList
            .Where(c => !c.StartsWith(list[i]) || c == list[i])
            .Distinct()
                .ToArray();

    foreach (string s in prefixList)
        Console.WriteLine(s);
    Console.ReadLine();
}

Patrick Huber
  • 756
  • 6
  • 21
2
var list = new[] { "A.B.D", "A", "E", "A.B", "F", "F.E", "B.C.D", "B.C" };

var result = from s in list
             group s by s.Split('.').First() into g
             select LongestCommonPrefix(g);

foreach (var s in result)
{
    Console.WriteLine(s);
}

Output:

A
E
F
B.C

Method to find longest common prefix from here (replace / with .).

Community
  • 1
  • 1
dtb
  • 213,145
  • 36
  • 401
  • 431
1

My understanding of the question says a list containing both "B.C" and "B.E" but no "B" would get both "B.C" and "B.E".

string[] items = { "A", "A.B.D", "A", "A.B", "E", "F.E", "F", "B.C" };
char delimiter = '.';
var result = (from item in items.Distinct()
             where !items.Any(other => item.StartsWith(other + delimiter))
             select item).ToArray();

foreach (var item in result)
{
    Console.WriteLine(item);
}

output

A
E
F
B.C

also works with multi-character prefixes

string[] items = 
{
    "Alpha",
    "Alpha.Beta.Delta",
    "Alpha",
    "Alpha.Beta",
    "Echo",
    "Foxtrot.Echo",
    "Foxtrot",
    "Baker.Charlie"
 };

gets

Alpha
Echo
Foxtrot
Baker.Charlie
Handcraftsman
  • 6,863
  • 2
  • 40
  • 33
-1
var list = new List<string> { "A", "A.B.D", "A", "A.B", "E", "F.E", "F", "B.C" };

var result = (list.Select(a => a.Split('.').First())).Distinct();
abatishchev
  • 98,240
  • 88
  • 296
  • 433
AlexandrYZ
  • 331
  • 2
  • 6
-1

If I strictly stick to the definition that dave provided, the answer is easier than it seems:

  • remove duplicates => distinct
  • remove any item that starts with any other item in the list

so we get:

from item in items.Distinct()
where !items.Any(other => other != item && item.StartsWith(other + '.'))
select item;

For the B.C and B.D question, this works as specified: Neither one includes the other, so none of the removing conditions mentioned by dave is triggered.

I admit that there might be more exciting anwers, but I'm afraid that's just not in the question ;)

Update: added delimiter to where clause in order to account for multi-char words. thanks svick!

Stefan Wenig
  • 158
  • 9
  • This doesn't use the fact that delimiter is `.`. What if you had `AB` and `A`? I think that should return both, but your code return only `A`. – svick Nov 22 '10 at 17:25
  • svick you're right, I cut one corner too much. The simple test data was just too tempting. Updated. – Stefan Wenig Nov 24 '10 at 09:55