Оптимизация строковых операций в C#

Для выполнения следующего кода C# требуется 5 минут:

int i = 1;
string fraction = "";
while (fraction.Length < 1000000)
{
    fraction += i.ToString();
    i++;
}

Такая «оптимизация» заставляет его работать за 1,5 секунды:

int i = 1;
string fraction = "";
while (fraction.Length < 1000000)
{
    // concatenating strings is much faster for small strings
    string tmp = "";
    for (int j = 0; j < 1000; j++)
    {
        tmp += i.ToString();
        i++;
    }
    fraction += tmp;
}

Обновлено: Некоторые люди предлагали использовать StringBuilder, что тоже является отличным предложением, и это дает 0,06 с:

int i = 1;
StringBuilder fraction = new StringBuilder();
while (fraction.Length < 1000000)
{
    fraction.Append(i);
    i++;
}

Попытки найти оптимальное значение j - это тема для другого раза, но почему именно эта неочевидная оптимизация работает так хорошо? Кроме того, по связанной теме, я слышал, что вы никогда не должны использовать оператор + со строками в пользу string.Format(), это правда?

Интересно, интересно, сколько времени занимает использование System.Text.StringBuilder, но я слишком устал, чтобы запускать виртуальную машину, в избранное (если это слово)

Kris 12.11.2008 02:18

Проверьте это, я уже сделал тест StringBuilder. Он медленнее, чем мой внутренний цикл, но все же НАМНОГО быстрее исходного кода.

Matthew Scharley 12.11.2008 02:22

Обратите внимание, что StringBuilder принимает в качестве аргумента необязательную начальную емкость! Тогда это должно быть на много быстрее.

Konrad Rudolph 12.11.2008 02:25

Фактически, в этом примере его использование практически не имело никакого практического значения.

Matthew Scharley 12.11.2008 02:27

Когда вы тестируете построитель строк, вызовите Append (i);

Aaron Fischer 12.11.2008 02:46
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
7
5
2 871
7
Перейти к ответу Данный вопрос помечен как решенный

Ответы 7

Используйте StringBuilder для объединения более (приблизительно) 5 строк (результаты могут незначительно отличаться). Также дайте конструктору StringBuilder подсказку об ожидаемом максимальном размере.

[Обновление]: просто комментирую ваши изменения в вопросе. Вы также можете повысить производительность StringBuilder, если у вас есть приблизительное (или точное) представление об окончательном размере объединенных строк, потому что это уменьшит количество выделений памяти, которые он должен выполнить:

// e.g. Initialise to 10MB
StringBuilder fraction = new StringBuilder(10000000);

Also, on a related topic, I've heard it said that you should never use the + operator with strings, in favour of string.Format(), is this true?

Нет, как и все безусловные утверждения, это чушь. Однако является верно, что использование Format обычно делает код форматирования более читаемым и часто немного быстрее, чем конкатенация, но скорость здесь не решающий фактор.

Что касается вашего кода ... он приводит к копированию строк меньшего размера (а именно, tmp) при конкатенации. Конечно, в fraction += tmp вы копируете строку большего размера, но это происходит реже.

Таким образом, вы уменьшили много больших копий до нескольких больших и многих маленьких копий.

Хм, я только что заметил, что ваш внешний цикл имеет одинаковый размер в обоих случаях. Тогда это не должно быть быстрее.

внешний цикл находится на длине строки, а не на i

BCS 12.11.2008 02:21

Внешний цикл фактически останавливается с более длинным ответом во втором фрагменте кода из-за способа его обработки, поэтому он генерирует БОЛЬШУЮ последовательность за гораздо меньшее время.

Matthew Scharley 12.11.2008 02:23

«Как и все абсолютные утверждения, это ерунда». Привет, +1 за иронию!

Joel Mueller 12.11.2008 02:42

Я не могу сейчас проводить тесты, но попробую использовать StringBuilder.

int i = 1;
    StringBuilder fraction = new StringBuilder();
    while (fraction.Length < 1000000)
    {
        fraction.Append(i);
        i++;
    }
return sb.ToString();
Ответ принят как подходящий

Вы, вероятно, увидите, что первые 1000 символов почти не занимают времени, в отличие от последних 1000 символов.

Я бы предположил, что трудоемкая часть - это фактическое копирование большой строки в новую область памяти каждый раз, когда вы добавляете символ, что является сложной работой для вашего компьютера.

Вашу оптимизацию легко сравнить с тем, что вы обычно делаете с потоками, если вы используете буфер. Большие фрагменты обычно приводят к повышению производительности до тех пор, пока вы не достигнете критического размера, когда он больше не имеет никакого значения, и начинает быть недостатком, когда вы обрабатываете небольшие объемы данных.

Однако, если бы вы с самого начала определили массив символов подходящего размера, он, вероятно, сработал бы быстро, потому что тогда ему не пришлось бы копировать его снова и снова.

Это требует гораздо большего количества кода, выполняющего преобразования между строками и массивами символов, но я согласен с вашим анализом ситуации. Мне только что показались интересными точные уровни, на которых это может быть препятствием.

Matthew Scharley 12.11.2008 02:25

Я вообще не получаю твоих результатов. В моем случае StringBuilder выигрывает. Не могли бы вы выложить полную программу тестирования? Вот мой, с тремя вариантами: оптимизация конкатенации строк, «простой» StringBuilder и StringBuilder с начальной емкостью. Я увеличил лимит, так как на моем ящике он двигался слишком быстро, чтобы его можно было измерить.

using System;
using System.Diagnostics;
using System.Text;

public class Test
{
    const int Limit = 4000000;

    static void Main()
    {
        Time(Concatenation, "Concat");
        Time(SimpleStringBuilder, "StringBuilder as in post");
        Time(SimpleStringBuilderNoToString, "StringBuilder calling Append(i)");
        Time(CapacityStringBuilder, "StringBuilder with appropriate capacity");
    }

    static void Time(Action action, string name)
    {
        Stopwatch sw = Stopwatch.StartNew();
        action();
        sw.Stop();
        Console.WriteLine("{0}: {1}ms", name, sw.ElapsedMilliseconds);
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }

    static void Concatenation()
    {
        int i = 1;
        string fraction = "";
        while (fraction.Length < Limit)
        {
            // concatenating strings is much faster for small strings
            string tmp = "";
            for (int j = 0; j < 1000; j++)
            {
                tmp += i.ToString();
                i++;
            }
            fraction += tmp;            
        }
    }

    static void SimpleStringBuilder()
    {
        int i = 1;
        StringBuilder fraction = new StringBuilder();
        while (fraction.Length < Limit)
        {
            fraction.Append(i.ToString());
            i++;
        }
    }

    static void SimpleStringBuilderNoToString()
    {
        int i = 1;
        StringBuilder fraction = new StringBuilder();
        while (fraction.Length < Limit)
        {
            fraction.Append(i);
            i++;
        }
    }

    static void CapacityStringBuilder()
    {
        int i = 1;
        StringBuilder fraction = new StringBuilder(Limit + 10);
        while (fraction.Length < Limit)
        {
            fraction.Append(i);
            i++;
        }
    }
}

И результаты:

Concat: 5879ms
StringBuilder as in post: 206ms
StringBuilder calling Append(i): 196ms
StringBuilder with appropriate capacity: 184ms

Причина, по которой ваша конкатенация выполняется быстрее, чем самое первое решение, проста - вы выполняете несколько «дешевых» конкатенаций (при которых каждый раз копируется относительно мало данных) и относительно небольшое количество «больших» конкатенаций (пока что для всей строки) . В оригинале каждый шаг копирует все данные, полученные на данный момент, что, очевидно, дороже.

Я использую DateTime.Now для измерения времени (я застрял с 2.0), но кроме этого, есть только несколько умножений в конце цифр, извлеченных из строки, и это постоянное время для всех прогонов.

Matthew Scharley 12.11.2008 02:38

На самом деле, нет, теперь, когда я думаю об этом, вы правы, у меня были распечатки с консоли ... Исправляем время в вопросе сейчас.

Matthew Scharley 12.11.2008 02:39

То же самое. Для меня оригинал все еще работает, 1-й был около 700 мс, а последний (StringBuilder) 63 мс.

Quibblesome 12.11.2008 02:42

Секундомер был представлен в .NET 2.0, так что вы не зацикливаетесь на DateTime.Now :)

Jon Skeet 12.11.2008 02:42

:). Я забыл запустить секундомер во втором заходе! ^^ исправлено.

Quibblesome 12.11.2008 02:43

Вы правы, StringBuilder выигрывает. Это был только Console.Write, который замедлял его (во внешнем цикле был только один, поэтому в строковом коде tmp их было намного меньше).

Matthew Scharley 12.11.2008 02:45

Хорошее объяснение. И я тоже получаю аналогичные результаты. Почему вы выбрали Limit + 10 в качестве начальной емкости StringBuilder? Необходимая мощность намного больше, не так ли?

C. Dragon 76 12.11.2008 02:53

Вы не добавляете отдельные символы в строку, вы можете добавить к строке большое число. Добавление 10-символьного буфера к пределу гарантирует, что будет достаточно места для любого числа, которое может поместиться в int (около 2 миллиардов)

Matthew Scharley 12.11.2008 03:02

Почему необходимая емкость должна быть намного больше? Он должен содержать только до предела символов + все, что требуется, чтобы продвинуть его за предел.

Jon Skeet 12.11.2008 03:02

Поскольку Fraction.Append (i) добавляет более одного символа в строковый буфер, за исключением случаев, когда i> = 0 && i <= 9. Math.Log10 (Limit) * Limit Я думаю, это разумная верхняя граница. Не уверен, какое выражение является оптимальным. Может потребоваться слишком много теории чисел. :)

C. Dragon 76 12.11.2008 03:22

@cdragon: Да, он добавляет более одного символа - вот почему у меня есть лишние 10 в качестве «запасных». i по-прежнему будет меньше 10 символов, поэтому он никогда не переполнится. В качестве проверки я только что перезапустил тест и распечатал окончательную емкость - это то же самое, что и начало.

Jon Skeet 12.11.2008 09:51

Ответ на измененный квестон («почему эта неочевидная оптимизация так хорошо работает» и «правда ли, что не следует использовать оператор + для строк»):

Я не уверен, о какой неочевидной оптимизации вы говорите. Но ответ на второй вопрос, я думаю, охватывает все основы.

В C# строки работают так, что они выделяются как фиксированная длина и не могут быть изменены. Это означает, что каждый раз, когда вы пытаетесь изменить длину строки, создается целая новая строка, а старая строка копируется до нужной длины. Очевидно, это медленный процесс. Когда вы используете String.Format, он внутренне использует StringBuilder для создания строки.

StringBuilders работают с использованием буфера памяти, который распределяется более разумно, чем строки фиксированной длины, и поэтому в большинстве ситуаций работает значительно лучше. Я не уверен в деталях StringBuilder внутри компании, поэтому вам придется задать для этого новый вопрос. Я могу предположить, что он либо не перераспределяет старые части строки (вместо этого создает связанный список внутри и только фактически выделяет окончательный вывод, когда это необходимо ToString), либо перераспределяется с экспоненциальным ростом (когда ему не хватает памяти, он выделяет вдвое больше в следующий раз, таким образом, для строки размером 2 ГБ потребуется перераспределить всего около 30 раз).

Ваш пример с вложенными циклами растет линейно. он берет небольшую строку и увеличивает ее до 1000, а затем прикрепляет эту 1000 к большей строке за одну большую операцию. Поскольку большая строка становится действительно большой, копирование, полученное в результате создания новой строки, занимает много времени. Когда вы уменьшаете количество раз, когда это делается (вместо этого чаще изменяя размер меньшей строки), вы увеличиваете скорость. Конечно, StringBuilder еще умнее выделяет память и, следовательно, работает намного быстрее.

Добавление символа в строку может иметь два последствия:

  • если еще есть место для символа, он просто добавляется в конце; (как заметил комментатор, этого не может произойти со строками C#, поскольку они неизменны).
  • если в конце нет места, для новой строки выделяется новый блок памяти, туда копируется содержимое старой строки и добавляется символ.

Чтобы проанализировать ваш код, проще добавить 1000000 раз один символ. Ваш точный пример немного сложнее объяснить, потому что для более высоких i вы добавляете больше символов за раз.

Затем в ситуации, когда дополнительное пространство не зарезервировано, в первом примере необходимо выполнить 1000000 выделений и копий в среднем 0,5 * 1000000 символов. Второй должен сделать 1000 выделений и копий в среднем 0,5 * 1000000 символов и 1000000 выделений и копий 0,5 * 1000 символов. Если копирование выполняется линейно с размером копии и свободным от размещения, первая ситуация занимает 500 000 000 000 единиц времени, а вторая - 500 000 000 + 500 000 000 единиц времени.

В C# строки неизменяемы. Это не меняет струны на месте. Каждый раз, когда добавляется символ, создается совершенно новая строка.

Neil 12.11.2008 03:07

Хм, я знал это ... Просто не обращайте внимания на первое последствие

Stephan Eggermont 12.11.2008 03:24

Другие вопросы по теме