Ordem de classificação natural em C #


129

Alguém tem um bom recurso ou fornece uma amostra de uma ordem natural em C # para uma FileInfomatriz? Estou implementando a IComparerinterface do meu tipo.

Respostas:


148

A coisa mais fácil a fazer é apenas P / Invocar a função interna do Windows e usá-la como a função de comparação em IComparer:

[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
private static extern int StrCmpLogicalW(string psz1, string psz2);

Michael Kaplan tem alguns exemplos de como essa função funciona aqui e as alterações feitas no Vista para fazê-lo funcionar de maneira mais intuitiva. O lado positivo dessa função é que ela terá o mesmo comportamento da versão do Windows em que é executada, no entanto, isso significa que ela difere entre as versões do Windows, portanto, é necessário considerar se isso é um problema para você.

Portanto, uma implementação completa seria algo como:

[SuppressUnmanagedCodeSecurity]
internal static class SafeNativeMethods
{
    [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
    public static extern int StrCmpLogicalW(string psz1, string psz2);
}

public sealed class NaturalStringComparer : IComparer<string>
{
    public int Compare(string a, string b)
    {
        return SafeNativeMethods.StrCmpLogicalW(a, b);
    }
}

public sealed class NaturalFileInfoNameComparer : IComparer<FileInfo>
{
    public int Compare(FileInfo a, FileInfo b)
    {
        return SafeNativeMethods.StrCmpLogicalW(a.Name, b.Name);
    }
}

8
Ótima resposta. Advertência: Isso não funcionará com o Win2000, para aquelas poucas pessoas que ainda executam coisas nesse sistema operacional. Por outro lado, há dicas suficientes entre o blog da Kaplan e a documentação do MSDN para criar uma função semelhante.
Chris Charabaruk 29/10/08

9
Isso não é portátil, funciona apenas no Win32, mas não funciona no Linux / MacOS / Silverlight / Windows Phone / Metro
linquize

20
@linquize - Ele disse que o .NET não é Mono, então o Linux / OSX não é realmente uma preocupação. O Windows Phone / Metro não existia em 2008 quando esta resposta foi publicada. E com que frequência você realiza operações de arquivo no Silverlight? Portanto, para o OP, e provavelmente para a maioria das outras pessoas, foi uma resposta adequada. De qualquer forma, você é livre para fornecer uma resposta melhor; é assim que este site funciona.
Greg Beech

6
Isso não significa que a resposta original estava errada. Acabei de adicionar informações adicionais com informações atualizadas
linquize

2
Para sua informação, se você herdar em Comparer<T>vez de implementar IComparer<T>, obterá uma implementação IComparerinterna da interface (não genérica) que chama seu método genérico, para uso em APIs que usam isso. É basicamente gratuito também: basta excluir o "I" e mudar public int Compare(...)para public override int Compare(...). O mesmo para IEqualityComparer<T>e EqualityComparer<T>.
precisa

75

Apenas pensei em adicionar isso (com a solução mais concisa que eu poderia encontrar):

public static IOrderedEnumerable<T> OrderByAlphaNumeric<T>(this IEnumerable<T> source, Func<T, string> selector)
{
    int max = source
        .SelectMany(i => Regex.Matches(selector(i), @"\d+").Cast<Match>().Select(m => (int?)m.Value.Length))
        .Max() ?? 0;

    return source.OrderBy(i => Regex.Replace(selector(i), @"\d+", m => m.Value.PadLeft(max, '0')));
}

O texto acima preenche todos os números da string no comprimento máximo de todos os números em todas as strings e usa a string resultante para classificar.

A conversão para ( int?) é para permitir coleções de strings sem nenhum número ( .Max()em um enumerável vazio lança um InvalidOperationException).


1
+1 Não é apenas o mais conciso, é o mais rápido que eu já vi. exceto a resposta aceita, mas não posso usá-la devido a dependências da máquina. Classificou mais de 4 milhões de valores em cerca de 35 segundos.
Gene S

4
Isso é bonito e impossível de ler. Suponho que os benefícios do Linq signifiquem (pelo menos) melhor desempenho médio e melhor caso, então acho que vou acompanhá-lo. Apesar da falta de clareza. Muito obrigado @Matthew Horsley
Ian Grainger

1
Isso é muito bom, mas há um erro para determinados números decimais, meu exemplo foi a classificação de k8.11 vs k8.2. Para corrigir isso, implementei o seguinte regex: \ d + ([\.,] \ D)?
devzero

2
Você também precisa levar em consideração o comprimento do segundo grupo (ponto decimal + números decimais) ao preencher esse código m.Value.PadLeft (max, '0') #
devzero

3
Eu acho que você pode usar em .DefaultIfEmpty().Max()vez de transmitir para int?. Também vale a pena fazer um source.ToList()para evitar re-enumerar o enumerável.
22418 Teejay

30

Nenhuma das implementações existentes parecia ótima, então eu escrevi as minhas. Os resultados são quase idênticos à classificação usada pelas versões modernas do Windows Explorer (Windows 7/8). As únicas diferenças que eu vi são 1) embora o Windows costumava (por exemplo, XP) manipular números de qualquer tamanho, agora é limitado a 19 dígitos - o meu é ilimitado, 2) o Windows fornece resultados inconsistentes com certos conjuntos de dígitos Unicode - o meu funciona bom (embora não compare numericamente dígitos de pares substitutos; nem o Windows) e 3) o meu não pode distinguir tipos diferentes de pesos de classificação não primários se ocorrerem em seções diferentes (por exemplo, "e-1é" vs " é1e- "- as seções antes e depois do número têm diferenças de peso diacrítico e de pontuação).

public static int CompareNatural(string strA, string strB) {
    return CompareNatural(strA, strB, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase);
}

public static int CompareNatural(string strA, string strB, CultureInfo culture, CompareOptions options) {
    CompareInfo cmp = culture.CompareInfo;
    int iA = 0;
    int iB = 0;
    int softResult = 0;
    int softResultWeight = 0;
    while (iA < strA.Length && iB < strB.Length) {
        bool isDigitA = Char.IsDigit(strA[iA]);
        bool isDigitB = Char.IsDigit(strB[iB]);
        if (isDigitA != isDigitB) {
            return cmp.Compare(strA, iA, strB, iB, options);
        }
        else if (!isDigitA && !isDigitB) {
            int jA = iA + 1;
            int jB = iB + 1;
            while (jA < strA.Length && !Char.IsDigit(strA[jA])) jA++;
            while (jB < strB.Length && !Char.IsDigit(strB[jB])) jB++;
            int cmpResult = cmp.Compare(strA, iA, jA - iA, strB, iB, jB - iB, options);
            if (cmpResult != 0) {
                // Certain strings may be considered different due to "soft" differences that are
                // ignored if more significant differences follow, e.g. a hyphen only affects the
                // comparison if no other differences follow
                string sectionA = strA.Substring(iA, jA - iA);
                string sectionB = strB.Substring(iB, jB - iB);
                if (cmp.Compare(sectionA + "1", sectionB + "2", options) ==
                    cmp.Compare(sectionA + "2", sectionB + "1", options))
                {
                    return cmp.Compare(strA, iA, strB, iB, options);
                }
                else if (softResultWeight < 1) {
                    softResult = cmpResult;
                    softResultWeight = 1;
                }
            }
            iA = jA;
            iB = jB;
        }
        else {
            char zeroA = (char)(strA[iA] - (int)Char.GetNumericValue(strA[iA]));
            char zeroB = (char)(strB[iB] - (int)Char.GetNumericValue(strB[iB]));
            int jA = iA;
            int jB = iB;
            while (jA < strA.Length && strA[jA] == zeroA) jA++;
            while (jB < strB.Length && strB[jB] == zeroB) jB++;
            int resultIfSameLength = 0;
            do {
                isDigitA = jA < strA.Length && Char.IsDigit(strA[jA]);
                isDigitB = jB < strB.Length && Char.IsDigit(strB[jB]);
                int numA = isDigitA ? (int)Char.GetNumericValue(strA[jA]) : 0;
                int numB = isDigitB ? (int)Char.GetNumericValue(strB[jB]) : 0;
                if (isDigitA && (char)(strA[jA] - numA) != zeroA) isDigitA = false;
                if (isDigitB && (char)(strB[jB] - numB) != zeroB) isDigitB = false;
                if (isDigitA && isDigitB) {
                    if (numA != numB && resultIfSameLength == 0) {
                        resultIfSameLength = numA < numB ? -1 : 1;
                    }
                    jA++;
                    jB++;
                }
            }
            while (isDigitA && isDigitB);
            if (isDigitA != isDigitB) {
                // One number has more digits than the other (ignoring leading zeros) - the longer
                // number must be larger
                return isDigitA ? 1 : -1;
            }
            else if (resultIfSameLength != 0) {
                // Both numbers are the same length (ignoring leading zeros) and at least one of
                // the digits differed - the first difference determines the result
                return resultIfSameLength;
            }
            int lA = jA - iA;
            int lB = jB - iB;
            if (lA != lB) {
                // Both numbers are equivalent but one has more leading zeros
                return lA > lB ? -1 : 1;
            }
            else if (zeroA != zeroB && softResultWeight < 2) {
                softResult = cmp.Compare(strA, iA, 1, strB, iB, 1, options);
                softResultWeight = 2;
            }
            iA = jA;
            iB = jB;
        }
    }
    if (iA < strA.Length || iB < strB.Length) {
        return iA < strA.Length ? 1 : -1;
    }
    else if (softResult != 0) {
        return softResult;
    }
    return 0;
}

A assinatura corresponde ao Comparison<string>delegado:

string[] files = Directory.GetFiles(@"C:\");
Array.Sort(files, CompareNatural);

Aqui está uma classe de wrapper para uso como IComparer<string>:

public class CustomComparer<T> : IComparer<T> {
    private Comparison<T> _comparison;

    public CustomComparer(Comparison<T> comparison) {
        _comparison = comparison;
    }

    public int Compare(T x, T y) {
        return _comparison(x, y);
    }
}

Exemplo:

string[] files = Directory.EnumerateFiles(@"C:\")
    .OrderBy(f => f, new CustomComparer<string>(CompareNatural))
    .ToArray();

Aqui está um bom conjunto de nomes de arquivos que eu uso para testar:

Func<string, string> expand = (s) => { int o; while ((o = s.IndexOf('\\')) != -1) { int p = o + 1;
    int z = 1; while (s[p] == '0') { z++; p++; } int c = Int32.Parse(s.Substring(p, z));
    s = s.Substring(0, o) + new string(s[o - 1], c) + s.Substring(p + z); } return s; };
string encodedFileNames =
    "KDEqLW4xMiotbjEzKjAwMDFcMDY2KjAwMlwwMTcqMDA5XDAxNyowMlwwMTcqMDlcMDE3KjEhKjEtISox" +
    "LWEqMS4yNT8xLjI1KjEuNT8xLjUqMSoxXDAxNyoxXDAxOCoxXDAxOSoxXDA2NioxXDA2NyoxYSoyXDAx" +
    "NyoyXDAxOCo5XDAxNyo5XDAxOCo5XDA2Nio9MSphMDAxdGVzdDAxKmEwMDF0ZXN0aW5nYTBcMzEqYTAw" +
    "Mj9hMDAyIGE/YTAwMiBhKmEwMDIqYTAwMmE/YTAwMmEqYTAxdGVzdGluZ2EwMDEqYTAxdnNmcyphMSph" +
    "MWEqYTF6KmEyKmIwMDAzcTYqYjAwM3E0KmIwM3E1KmMtZSpjZCpjZipmIDEqZipnP2cgMT9oLW4qaG8t" +
    "bipJKmljZS1jcmVhbT9pY2VjcmVhbT9pY2VjcmVhbS0/ajBcNDE/ajAwMWE/ajAxP2shKmsnKmstKmsx" +
    "KmthKmxpc3QqbTAwMDNhMDA1YSptMDAzYTAwMDVhKm0wMDNhMDA1Km0wMDNhMDA1YSpuMTIqbjEzKm8t" +
    "bjAxMypvLW4xMipvLW40P28tbjQhP28tbjR6P28tbjlhLWI1Km8tbjlhYjUqb24wMTMqb24xMipvbjQ/" +
    "b240IT9vbjR6P29uOWEtYjUqb245YWI1Km/CrW4wMTMqb8KtbjEyKnAwMCpwMDEqcDAxwr0hKnAwMcK9" +
    "KnAwMcK9YSpwMDHCvcK+KnAwMipwMMK9KnEtbjAxMypxLW4xMipxbjAxMypxbjEyKnItMDAhKnItMDAh" +
    "NSpyLTAwIe+8lSpyLTAwYSpyLe+8kFwxIS01KnIt77yQXDEhLe+8lSpyLe+8kFwxISpyLe+8kFwxITUq" +
    "ci3vvJBcMSHvvJUqci3vvJBcMWEqci3vvJBcMyE1KnIwMCEqcjAwLTUqcjAwLjUqcjAwNSpyMDBhKnIw" +
    "NSpyMDYqcjQqcjUqctmg2aYqctmkKnLZpSpy27Dbtipy27Qqctu1KnLfgN+GKnLfhCpy34UqcuClpuCl" +
    "rCpy4KWqKnLgpasqcuCnpuCnrCpy4KeqKnLgp6sqcuCppuCprCpy4KmqKnLgqasqcuCrpuCrrCpy4Kuq" +
    "KnLgq6sqcuCtpuCtrCpy4K2qKnLgrasqcuCvpuCvrCpy4K+qKnLgr6sqcuCxpuCxrCpy4LGqKnLgsasq" +
    "cuCzpuCzrCpy4LOqKnLgs6sqcuC1puC1rCpy4LWqKnLgtasqcuC5kOC5lipy4LmUKnLguZUqcuC7kOC7" +
    "lipy4LuUKnLgu5UqcuC8oOC8pipy4LykKnLgvKUqcuGBgOGBhipy4YGEKnLhgYUqcuGCkOGClipy4YKU" +
    "KnLhgpUqcuGfoOGfpipy4Z+kKnLhn6UqcuGgkOGglipy4aCUKnLhoJUqcuGlhuGljCpy4aWKKnLhpYsq" +
    "cuGnkOGnlipy4aeUKnLhp5UqcuGtkOGtlipy4a2UKnLhrZUqcuGusOGutipy4a60KnLhrrUqcuGxgOGx" +
    "hipy4bGEKnLhsYUqcuGxkOGxlipy4bGUKnLhsZUqcuqYoFwx6pilKnLqmKDqmKUqcuqYoOqYpipy6pik" +
    "KnLqmKUqcuqjkOqjlipy6qOUKnLqo5UqcuqkgOqkhipy6qSEKnLqpIUqcuqpkOqplipy6qmUKnLqqZUq" +
    "cvCQkqAqcvCQkqUqcvCdn5gqcvCdn50qcu+8kFwxISpy77yQXDEt77yVKnLvvJBcMS7vvJUqcu+8kFwx" +
    "YSpy77yQXDHqmKUqcu+8kFwx77yO77yVKnLvvJBcMe+8lSpy77yQ77yVKnLvvJDvvJYqcu+8lCpy77yV" +
    "KnNpKnPEsSp0ZXN02aIqdGVzdNmi2aAqdGVzdNmjKnVBZS0qdWFlKnViZS0qdUJlKnVjZS0xw6kqdWNl" +
    "McOpLSp1Y2Uxw6kqdWPDqS0xZSp1Y8OpMWUtKnVjw6kxZSp3ZWlhMSp3ZWlhMip3ZWlzczEqd2Vpc3My" +
    "KndlaXoxKndlaXoyKndlacOfMSp3ZWnDnzIqeSBhMyp5IGE0KnknYTMqeSdhNCp5K2EzKnkrYTQqeS1h" +
    "Myp5LWE0KnlhMyp5YTQqej96IDA1MD96IDIxP3ohMjE/ejIwP3oyMj96YTIxP3rCqTIxP1sxKl8xKsKt" +
    "bjEyKsKtbjEzKsSwKg==";
string[] fileNames = Encoding.UTF8.GetString(Convert.FromBase64String(encodedFileNames))
    .Replace("*", ".txt?").Split(new[] { "?" }, StringSplitOptions.RemoveEmptyEntries)
    .Select(n => expand(n)).ToArray();

As seções de dígitos precisam ser comparadas em seções, ou seja, 'abc12b' deve ser menor que 'abc123'.
SOUser

Você pode tentar os seguintes dados: public string [] filenames = {"-abc12.txt", " abc12.txt", "1abc_2.txt", "a0000012.txt", "a0000012c.txt", "a000012.txt" , "a000012b.txt", "a012.txt", "a0000102.txt", "abc1_2.txt", "abc12 .txt", "abc12b.txt", "abc123.txt", "abccde.txt", " b0000.txt "," b00001.txt "," b0001.txt "," b001.txt "," c0000.txt "," c0000c.txt "," c00001.txt "," c000b.txt "," d0. 20.2b.txt "," d0.1000c.txt "," d0.2000y.txt "," d0.20000.2b.txt ","
SOUser 21/02

@XichenLi Obrigado pelo bom caso de teste. Se você deixar o Windows Explorer classificar esses arquivos, obterá resultados diferentes dependendo da versão do Windows que estiver usando. Meu código classifica esses nomes de forma idêntica ao Server 2003 (e presumivelmente XP), mas diferente do Windows 8. Se eu tiver uma chance, tentarei descobrir como o Windows 8 está fazendo isso e atualizar meu código.
JD

2
Tem bug. Índice fora de alcance
linquize 18/03/2013

3
Ótima solução! Quando o comparei em um cenário normal com cerca de 10.000 arquivos, era mais rápido que o exemplo de regex de Matthew e quase o mesmo desempenho que StrCmpLogicalW (). Há um erro menor no código acima: o "while (strA [jA] == zeroA) jA ++;" e "while (strB [jB] == zeroB) jB ++;" deve ser "while (jA <strA.Length && strA [jA] == zeroA) jA ++;" e "while (jB <strB.Length && strB [jB] == zeroB) jB ++;". Caso contrário, cadeias contendo apenas zeros lançarão uma exceção.
Kuroki

22

Solução C # pura para linq orderby:

http://zootfroot.blogspot.com/2009/09/natural-sort-compare-with-linq-orderby.html

public class NaturalSortComparer<T> : IComparer<string>, IDisposable
{
    private bool isAscending;

    public NaturalSortComparer(bool inAscendingOrder = true)
    {
        this.isAscending = inAscendingOrder;
    }

    #region IComparer<string> Members

    public int Compare(string x, string y)
    {
        throw new NotImplementedException();
    }

    #endregion

    #region IComparer<string> Members

    int IComparer<string>.Compare(string x, string y)
    {
        if (x == y)
            return 0;

        string[] x1, y1;

        if (!table.TryGetValue(x, out x1))
        {
            x1 = Regex.Split(x.Replace(" ", ""), "([0-9]+)");
            table.Add(x, x1);
        }

        if (!table.TryGetValue(y, out y1))
        {
            y1 = Regex.Split(y.Replace(" ", ""), "([0-9]+)");
            table.Add(y, y1);
        }

        int returnVal;

        for (int i = 0; i < x1.Length && i < y1.Length; i++)
        {
            if (x1[i] != y1[i])
            {
                returnVal = PartCompare(x1[i], y1[i]);
                return isAscending ? returnVal : -returnVal;
            }
        }

        if (y1.Length > x1.Length)
        {
            returnVal = 1;
        }
        else if (x1.Length > y1.Length)
        { 
            returnVal = -1; 
        }
        else
        {
            returnVal = 0;
        }

        return isAscending ? returnVal : -returnVal;
    }

    private static int PartCompare(string left, string right)
    {
        int x, y;
        if (!int.TryParse(left, out x))
            return left.CompareTo(right);

        if (!int.TryParse(right, out y))
            return left.CompareTo(right);

        return x.CompareTo(y);
    }

    #endregion

    private Dictionary<string, string[]> table = new Dictionary<string, string[]>();

    public void Dispose()
    {
        table.Clear();
        table = null;
    }
}

2
Esse código é basicamente de codeproject.com/KB/recipes/NaturalComparer.aspx (que não é orientado a LINQ).
Mhry1384

2
A postagem do blog credita Justin Jones ( codeproject.com/KB/string/NaturalSortComparer.aspx ) pelo IComparer, não por Pascal Ganaye.
James McCormack

1
Nota menor, essa solução ignora espaços que não são iguais aos do Windows e não são tão bons quanto o código de Matthew Horsley abaixo. Portanto, você pode obter 'string01' 'string 01' 'string 02' 'string02' por exemplo (que parece feia). Se você remover a remoção de espaços, ele ordenará as seqüências para trás, ou seja, 'string01' vem antes de 'string 01', que pode ou não ser aceitável.
Michael Parker

Isso funcionou para endereços, ou seja, "1 Smith Rd", "10 Smith Rd", "2 Smith Rd", etc. - classificados naturalmente. Sim! Agradável!
Piotr Kula

A propósito, notei (e os comentários nessa página vinculada também parecem indicar) que o argumento Type <T> é completamente desnecessário.
jv-dev

18

A resposta de Matthews Horsleys é o método mais rápido que não altera o comportamento, dependendo da versão do Windows em que o seu programa está sendo executado. No entanto, pode ser ainda mais rápido criando a regex uma vez e usando RegexOptions.Compiled. Também adicionei a opção de inserir um comparador de cadeias para que você possa ignorar maiúsculas e minúsculas, se necessário, e melhorar um pouco a legibilidade.

    public static IEnumerable<T> OrderByNatural<T>(this IEnumerable<T> items, Func<T, string> selector, StringComparer stringComparer = null)
    {
        var regex = new Regex(@"\d+", RegexOptions.Compiled);

        int maxDigits = items
                      .SelectMany(i => regex.Matches(selector(i)).Cast<Match>().Select(digitChunk => (int?)digitChunk.Value.Length))
                      .Max() ?? 0;

        return items.OrderBy(i => regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture);
    }

Usado por

var sortedEmployees = employees.OrderByNatural(emp => emp.Name);

São necessários 450ms para classificar 100.000 strings em comparação com 300ms para a comparação padrão de .net - muito rápido!


2
Isso vale a pena leitura wrt acima - Compilação e re-uso em expressões regulares
mungflesh

16

Minha solução:

void Main()
{
    new[] {"a4","a3","a2","a10","b5","b4","b400","1","C1d","c1d2"}.OrderBy(x => x, new NaturalStringComparer()).Dump();
}

public class NaturalStringComparer : IComparer<string>
{
    private static readonly Regex _re = new Regex(@"(?<=\D)(?=\d)|(?<=\d)(?=\D)", RegexOptions.Compiled);

    public int Compare(string x, string y)
    {
        x = x.ToLower();
        y = y.ToLower();
        if(string.Compare(x, 0, y, 0, Math.Min(x.Length, y.Length)) == 0)
        {
            if(x.Length == y.Length) return 0;
            return x.Length < y.Length ? -1 : 1;
        }
        var a = _re.Split(x);
        var b = _re.Split(y);
        int i = 0;
        while(true)
        {
            int r = PartCompare(a[i], b[i]);
            if(r != 0) return r;
            ++i;
        }
    }

    private static int PartCompare(string x, string y)
    {
        int a, b;
        if(int.TryParse(x, out a) && int.TryParse(y, out b))
            return a.CompareTo(b);
        return x.CompareTo(y);
    }
}

Resultados:

1
a2
a3
a4
a10
b4
b5
b400
C1d
c1d2

Eu gosto disso. É fácil de entender e não requer Linq.

11

Você precisa ter cuidado - lembro-me vagamente de ler que StrCmpLogicalW, ou algo parecido, não era estritamente transitivo, e observei que os métodos de classificação do .NET às vezes ficam presos em loops infinitos se a função de comparação quebrar essa regra.

Uma comparação transitiva sempre relatará que a <c se a <b e b <c. Existe uma função que faz uma comparação de ordem de classificação natural que nem sempre atende a esse critério, mas não me lembro se é StrCmpLogicalW ou algo mais.


Você tem alguma prova dessa afirmação? Depois de pesquisar no Google, não consigo encontrar nenhuma indicação de que seja verdade.
Mhry1384 31/08/2010

1
Eu experimentei esses loops infinitos com StrCmpLogicalW.
THD


O item de feedback do Visual Studio 236900 não existe mais, mas aqui está um mais atualizado que confirma o problema: connect.microsoft.com/VisualStudio/feedback/details/774540/… Ele também fornece uma solução alternativa : CultureInfopossui uma propriedade CompareInfo, e o objeto que ele retorna pode fornecer SortKeyobjetos. Estes, por sua vez, podem ser comparados e garantem transitividade.
Jonathan Gilbert

9

Este é o meu código para classificar uma string com caracteres alfa e numéricos.

Primeiro, este método de extensão:

public static IEnumerable<string> AlphanumericSort(this IEnumerable<string> me)
{
    return me.OrderBy(x => Regex.Replace(x, @"\d+", m => m.Value.PadLeft(50, '0')));
}

Em seguida, basta usá-lo em qualquer lugar do seu código como este:

List<string> test = new List<string>() { "The 1st", "The 12th", "The 2nd" };
test = test.AlphanumericSort();

Como isso funciona ? Substituindo por zeros:

  Original  | Regex Replace |      The      |   Returned
    List    | Apply PadLeft |    Sorting    |     List
            |               |               |
 "The 1st"  |  "The 001st"  |  "The 001st"  |  "The 1st"
 "The 12th" |  "The 012th"  |  "The 002nd"  |  "The 2nd"
 "The 2nd"  |  "The 002nd"  |  "The 012th"  |  "The 12th"

Funciona com múltiplos números:

 Alphabetical Sorting | Alphanumeric Sorting
                      |
 "Page 21, Line 42"   | "Page 3, Line 7"
 "Page 21, Line 5"    | "Page 3, Line 32"
 "Page 3, Line 32"    | "Page 21, Line 5"
 "Page 3, Line 7"     | "Page 21, Line 42"

Espero que isso ajude.


6

Adicionando a resposta de Greg Beech (porque eu acabei de procurar por isso), se você quiser usar isso no Linq, poderá usar o OrderByque leva um IComparer. Por exemplo:

var items = new List<MyItem>();

// fill items

var sorted = items.OrderBy(item => item.Name, new NaturalStringComparer());

2

Aqui está um exemplo relativamente simples que não usa P / Invoke e evita qualquer alocação durante a execução.

internal sealed class NumericStringComparer : IComparer<string>
{
    public static NumericStringComparer Instance { get; } = new NumericStringComparer();

    public int Compare(string x, string y)
    {
        // sort nulls to the start
        if (x == null)
            return y == null ? 0 : -1;
        if (y == null)
            return 1;

        var ix = 0;
        var iy = 0;

        while (true)
        {
            // sort shorter strings to the start
            if (ix >= x.Length)
                return iy >= y.Length ? 0 : -1;
            if (iy >= y.Length)
                return 1;

            var cx = x[ix];
            var cy = y[iy];

            int result;
            if (char.IsDigit(cx) && char.IsDigit(cy))
                result = CompareInteger(x, y, ref ix, ref iy);
            else
                result = cx.CompareTo(y[iy]);

            if (result != 0)
                return result;

            ix++;
            iy++;
        }
    }

    private static int CompareInteger(string x, string y, ref int ix, ref int iy)
    {
        var lx = GetNumLength(x, ix);
        var ly = GetNumLength(y, iy);

        // shorter number first (note, doesn't handle leading zeroes)
        if (lx != ly)
            return lx.CompareTo(ly);

        for (var i = 0; i < lx; i++)
        {
            var result = x[ix++].CompareTo(y[iy++]);
            if (result != 0)
                return result;
        }

        return 0;
    }

    private static int GetNumLength(string s, int i)
    {
        var length = 0;
        while (i < s.Length && char.IsDigit(s[i++]))
            length++;
        return length;
    }
}

Ele não ignora os zeros iniciais, então 01vem depois2 .

Teste de unidade correspondente:

public class NumericStringComparerTests
{
    [Fact]
    public void OrdersCorrectly()
    {
        AssertEqual("", "");
        AssertEqual(null, null);
        AssertEqual("Hello", "Hello");
        AssertEqual("Hello123", "Hello123");
        AssertEqual("123", "123");
        AssertEqual("123Hello", "123Hello");

        AssertOrdered("", "Hello");
        AssertOrdered(null, "Hello");
        AssertOrdered("Hello", "Hello1");
        AssertOrdered("Hello123", "Hello124");
        AssertOrdered("Hello123", "Hello133");
        AssertOrdered("Hello123", "Hello223");
        AssertOrdered("123", "124");
        AssertOrdered("123", "133");
        AssertOrdered("123", "223");
        AssertOrdered("123", "1234");
        AssertOrdered("123", "2345");
        AssertOrdered("0", "1");
        AssertOrdered("123Hello", "124Hello");
        AssertOrdered("123Hello", "133Hello");
        AssertOrdered("123Hello", "223Hello");
        AssertOrdered("123Hello", "1234Hello");
    }

    private static void AssertEqual(string x, string y)
    {
        Assert.Equal(0, NumericStringComparer.Instance.Compare(x, y));
        Assert.Equal(0, NumericStringComparer.Instance.Compare(y, x));
    }

    private static void AssertOrdered(string x, string y)
    {
        Assert.Equal(-1, NumericStringComparer.Instance.Compare(x, y));
        Assert.Equal( 1, NumericStringComparer.Instance.Compare(y, x));
    }
}

2

Na verdade, eu o implementei como um método de extensão StringComparerpara que você possa fazer, por exemplo:

  • StringComparer.CurrentCulture.WithNaturalSort() ou
  • StringComparer.OrdinalIgnoreCase.WithNaturalSort().

A resultante IComparer<string>pode ser usado em todos os lugares como OrderBy, OrderByDescending, ThenBy, ThenByDescending,SortedSet<string> , etc. E você ainda pode facilmente emenda maiúsculas e minúsculas, cultura, etc.

A implementação é bastante trivial e deve ter um bom desempenho, mesmo em grandes seqüências.


Também o publiquei como um pequeno pacote NuGet , para que você possa fazer:

Install-Package NaturalSort.Extension

O código, incluindo comentários da documentação XML e conjunto de testes, está disponível no repositório NaturalSort.Extension GitHub .


O código inteiro é este (se você ainda não pode usar o C # 7, basta instalar o pacote NuGet):

public static class StringComparerNaturalSortExtension
{
    public static IComparer<string> WithNaturalSort(this StringComparer stringComparer) => new NaturalSortComparer(stringComparer);

    private class NaturalSortComparer : IComparer<string>
    {
        public NaturalSortComparer(StringComparer stringComparer)
        {
            _stringComparer = stringComparer;
        }

        private readonly StringComparer _stringComparer;
        private static readonly Regex NumberSequenceRegex = new Regex(@"(\d+)", RegexOptions.Compiled | RegexOptions.CultureInvariant);
        private static string[] Tokenize(string s) => s == null ? new string[] { } : NumberSequenceRegex.Split(s);
        private static ulong ParseNumberOrZero(string s) => ulong.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var result) ? result : 0;

        public int Compare(string s1, string s2)
        {
            var tokens1 = Tokenize(s1);
            var tokens2 = Tokenize(s2);

            var zipCompare = tokens1.Zip(tokens2, TokenCompare).FirstOrDefault(x => x != 0);
            if (zipCompare != 0)
                return zipCompare;

            var lengthCompare = tokens1.Length.CompareTo(tokens2.Length);
            return lengthCompare;
        }
        
        private int TokenCompare(string token1, string token2)
        {
            var number1 = ParseNumberOrZero(token1);
            var number2 = ParseNumberOrZero(token2);

            var numberCompare = number1.CompareTo(number2);
            if (numberCompare != 0)
                return numberCompare;

            var stringCompare = _stringComparer.Compare(token1, token2);
            return stringCompare;
        }
    }
}

2

Aqui está uma maneira ingênua de LINQ sem regex de uma linha (emprestada do python):

var alphaStrings = new List<string>() { "10","2","3","4","50","11","100","a12","b12" };
var orderedString = alphaStrings.OrderBy(g => new Tuple<int, string>(g.ToCharArray().All(char.IsDigit)? int.Parse(g) : int.MaxValue, g));
// Order Now: ["2","3","4","10","11","50","100","a12","b12"]

Removido Dump () e atribuído a var e isso funciona como um encanto!
Arne S #

@ ArneS: Foi escrito em LinQPad; e eu esqueci de remover o Dump(). Obrigado por apontar.
Mshsayem 9/1018

1

Expandindo algumas das respostas anteriores e fazendo uso de métodos de extensão, vim com as seguintes advertências de possíveis enumerações enumeráveis ​​múltiplas ou problemas de desempenho relacionados ao uso de vários objetos regex ou à chamada desnecessária de regex, que sendo dito, ele usa ToList (), que pode negar os benefícios em coleções maiores.

O seletor suporta digitação genérica para permitir que qualquer delegado seja atribuído; os elementos na coleção de origem são alterados pelo seletor e convertidos em seqüências de caracteres com ToString ().

    private static readonly Regex _NaturalOrderExpr = new Regex(@"\d+", RegexOptions.Compiled);

    public static IEnumerable<TSource> OrderByNatural<TSource, TKey>(
        this IEnumerable<TSource> source, Func<TSource, TKey> selector)
    {
        int max = 0;

        var selection = source.Select(
            o =>
            {
                var v = selector(o);
                var s = v != null ? v.ToString() : String.Empty;

                if (!String.IsNullOrWhiteSpace(s))
                {
                    var mc = _NaturalOrderExpr.Matches(s);

                    if (mc.Count > 0)
                    {
                        max = Math.Max(max, mc.Cast<Match>().Max(m => m.Value.Length));
                    }
                }

                return new
                {
                    Key = o,
                    Value = s
                };
            }).ToList();

        return
            selection.OrderBy(
                o =>
                String.IsNullOrWhiteSpace(o.Value) ? o.Value : _NaturalOrderExpr.Replace(o.Value, m => m.Value.PadLeft(max, '0')))
                     .Select(o => o.Key);
    }

    public static IEnumerable<TSource> OrderByDescendingNatural<TSource, TKey>(
        this IEnumerable<TSource> source, Func<TSource, TKey> selector)
    {
        int max = 0;

        var selection = source.Select(
            o =>
            {
                var v = selector(o);
                var s = v != null ? v.ToString() : String.Empty;

                if (!String.IsNullOrWhiteSpace(s))
                {
                    var mc = _NaturalOrderExpr.Matches(s);

                    if (mc.Count > 0)
                    {
                        max = Math.Max(max, mc.Cast<Match>().Max(m => m.Value.Length));
                    }
                }

                return new
                {
                    Key = o,
                    Value = s
                };
            }).ToList();

        return
            selection.OrderByDescending(
                o =>
                String.IsNullOrWhiteSpace(o.Value) ? o.Value : _NaturalOrderExpr.Replace(o.Value, m => m.Value.PadLeft(max, '0')))
                     .Select(o => o.Key);
    }

1

Inspirada na solução de Michael Parker, aqui está uma IComparerimplementação que você pode usar em qualquer um dos métodos de pedidos linq:

private class NaturalStringComparer : IComparer<string>
{
    public int Compare(string left, string right)
    {
        int max = new[] { left, right }
            .SelectMany(x => Regex.Matches(x, @"\d+").Cast<Match>().Select(y => (int?)y.Value.Length))
            .Max() ?? 0;

        var leftPadded = Regex.Replace(left, @"\d+", m => m.Value.PadLeft(max, '0'));
        var rightPadded = Regex.Replace(right, @"\d+", m => m.Value.PadLeft(max, '0'));

        return string.Compare(leftPadded, rightPadded);
    }
}

0

Precisávamos de uma classificação natural para lidar com o texto com o seguinte padrão:

"Test 1-1-1 something"
"Test 1-2-3 something"
...

Por alguma razão, quando olhei pela primeira vez no SO, não encontrei este post e implementei o nosso. Comparado a algumas das soluções apresentadas aqui, embora com conceito semelhante, poderia ter o benefício de ser talvez mais simples e fácil de entender. No entanto, embora eu tenha tentado analisar gargalos de desempenho, ainda é uma implementação muito mais lenta que a padrão OrderBy().

Aqui está o método de extensão que eu implemento:

public static class EnumerableExtensions
{
    // set up the regex parser once and for all
    private static readonly Regex Regex = new Regex(@"\d+|\D+", RegexOptions.Compiled | RegexOptions.Singleline);

    // stateless comparer can be built once
    private static readonly AggregateComparer Comparer = new AggregateComparer();

    public static IEnumerable<T> OrderByNatural<T>(this IEnumerable<T> source, Func<T, string> selector)
    {
        // first extract string from object using selector
        // then extract digit and non-digit groups
        Func<T, IEnumerable<IComparable>> splitter =
            s => Regex.Matches(selector(s))
                      .Cast<Match>()
                      .Select(m => Char.IsDigit(m.Value[0]) ? (IComparable) int.Parse(m.Value) : m.Value);
        return source.OrderBy(splitter, Comparer);
    }

    /// <summary>
    /// This comparer will compare two lists of objects against each other
    /// </summary>
    /// <remarks>Objects in each list are compare to their corresponding elements in the other
    /// list until a difference is found.</remarks>
    private class AggregateComparer : IComparer<IEnumerable<IComparable>>
    {
        public int Compare(IEnumerable<IComparable> x, IEnumerable<IComparable> y)
        {
            return
                x.Zip(y, (a, b) => new {a, b})              // walk both lists
                 .Select(pair => pair.a.CompareTo(pair.b))  // compare each object
                 .FirstOrDefault(result => result != 0);    // until a difference is found
        }
    }
}

A idéia é dividir as seqüências originais em blocos de dígitos e não dígitos ("\d+|\D+" ). Como essa é uma tarefa potencialmente cara, é feita apenas uma vez por entrada. Em seguida, usamos um comparador de objetos comparáveis ​​(desculpe, não consigo encontrar uma maneira mais adequada de dizê-lo). Ele compara cada bloco ao seu bloco correspondente na outra string.

Gostaria de receber feedback sobre como isso pode ser melhorado e quais são as principais falhas. Observe que a manutenção é importante para nós neste momento e atualmente não a estamos usando em conjuntos de dados extremamente grandes.


1
Isso trava quando tenta comparar seqüências de caracteres estruturalmente diferentes - por exemplo, comparar "a-1" com "a-2" funciona bem, mas comparar "a" com "1" não é porque "a" .CompareTo (1) lança uma exceção.
jimrandomh

@jimrandomh, você está correto. Essa abordagem foi específica para nossos padrões.
11118 Eric Claptoni

0

Uma versão mais fácil de ler / manter.

public class NaturalStringComparer : IComparer<string>
{
    public static NaturalStringComparer Instance { get; } = new NaturalStringComparer();

    public int Compare(string x, string y) {
        const int LeftIsSmaller = -1;
        const int RightIsSmaller = 1;
        const int Equal = 0;

        var leftString = x;
        var rightString = y;

        var stringComparer = CultureInfo.CurrentCulture.CompareInfo;

        int rightIndex;
        int leftIndex;

        for (leftIndex = 0, rightIndex = 0;
             leftIndex < leftString.Length && rightIndex < rightString.Length;
             leftIndex++, rightIndex++) {
            var leftChar = leftString[leftIndex];
            var rightChar = rightString[leftIndex];

            var leftIsNumber = char.IsNumber(leftChar);
            var rightIsNumber = char.IsNumber(rightChar);

            if (!leftIsNumber && !rightIsNumber) {
                var result = stringComparer.Compare(leftString, leftIndex, 1, rightString, leftIndex, 1);
                if (result != 0) return result;
            } else if (leftIsNumber && !rightIsNumber) {
                return LeftIsSmaller;
            } else if (!leftIsNumber && rightIsNumber) {
                return RightIsSmaller;
            } else {
                var leftNumberLength = NumberLength(leftString, leftIndex, out var leftNumber);
                var rightNumberLength = NumberLength(rightString, rightIndex, out var rightNumber);

                if (leftNumberLength < rightNumberLength) {
                    return LeftIsSmaller;
                } else if (leftNumberLength > rightNumberLength) {
                    return RightIsSmaller;
                } else {
                    if(leftNumber < rightNumber) {
                        return LeftIsSmaller;
                    } else if(leftNumber > rightNumber) {
                        return RightIsSmaller;
                    }
                }
            }
        }

        if (leftString.Length < rightString.Length) {
            return LeftIsSmaller;
        } else if(leftString.Length > rightString.Length) {
            return RightIsSmaller;
        }

        return Equal;
    }

    public int NumberLength(string str, int offset, out int number) {
        if (string.IsNullOrWhiteSpace(str)) throw new ArgumentNullException(nameof(str));
        if (offset >= str.Length) throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be less than the length of the string.");

        var currentOffset = offset;

        var curChar = str[currentOffset];

        if (!char.IsNumber(curChar))
            throw new ArgumentException($"'{curChar}' is not a number.", nameof(offset));

        int length = 1;

        var numberString = string.Empty;

        for (currentOffset = offset + 1;
            currentOffset < str.Length;
            currentOffset++, length++) {

            curChar = str[currentOffset];
            numberString += curChar;

            if (!char.IsNumber(curChar)) {
                number = int.Parse(numberString);

                return length;
            }
        }

        number = int.Parse(numberString);

        return length;
    }
}

-2

Deixe-me explicar meu problema e como consegui resolvê-lo.

Problema: - Classifique os arquivos com base em FileName dos objetos FileInfo que são recuperados de um Diretório.

Solução: - Selecionei os nomes dos arquivos no FileInfo e aparei a parte ".png" do nome do arquivo. Agora, basta fazer List.Sort (), que classifica os nomes dos arquivos na ordem de classificação Natural. Com base nos meus testes, descobri que ter .png atrapalha a ordem de classificação. Dê uma olhada no código abaixo

var imageNameList = new DirectoryInfo(@"C:\Temp\Images").GetFiles("*.png").Select(x =>x.Name.Substring(0, x.Name.Length - 4)).ToList();
imageNameList.Sort();

Posso saber o motivo de -1 nesta resposta?
girishkatta9
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.