Алгоритм поиска всех общих подстрок любой длины между 2 строками, а затем подсчета вхождений в строке 2?

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


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

пример:

строка 1: ABCDE512ABC361EG51D

строка 2: ADE5AHDW4131EG1DG5C

учитывая эти 2 строки, Этот алгоритм найдет следующие общие подстроки: A,C,D,E,5,1,3,G,DE,E5,например,G5,1D,DE5, 1EG

а потом из этих общих подстрок, мы найдем, сколько вхождений есть для каждого из них в строке 2.

A: 2 вхождения в строке 2

C: 1 возникновение в строке 2

D: 3 вхождения в строку 2

etc..


первый подход я взял, чтобы обернуть мою голову вокруг этой проблемы было взломать мой путь через вычисления общих подстрок с помощью 2 вложенные циклы for - очевидно, наименее эффективный, но это был быстрый и грязный способ получить представление о том, что ожидаемые результаты должны быть с меньшим испытание ввода и самым малым временем запуска, который был около 2 минут, чтобы вычислить всех общих подстроки между 2 файлами, содержащими строки ascii размером 50 КБ. Увеличение размера до 1 Мб заставило это остановиться из-за огромного количества общих вложенных итераций, которые должны были произойти, чтобы вычислить это.

следующим подходом было использование деревьев-посмотреть, сколько памяти я мог бы обменять, чтобы оптимизировать время вычисления. Этот подход был намного быстрее. Те же два файла 50kb, которые заняли 2 минуты с помощью метода грубой силы, были почти мгновенными. Против 1Мб файлов был очень слишком быстро (секунды), но поскольку я продолжал тестировать с все большими и большими размерами файлов, я быстро начал работать с проблемами памяти из-за размеров дерева.


Примечание: строковые файлы будут содержать только символы ASCII!


Edit:

я немного усилиться, см.:

https://gist.github.com/braydo25/f7a9ce7ce7ad7c5fb11ec511887789bc

4 ответов


вот реализация C, основанная на обходе массива суффиксов конкатенации входных данных с помощью самого длинного общего массива префиксов. Вы можете заменить реализацию массива суффиксов программирования (O(n log^2 n)) на реальную (O(n) или O(N log n)) для большого улучшения производительности. (EDIT: сделал это, с некоторыми другими изменениями, отражающими новые требования asker:https://github.com/eisenstatdavid/commonsub .)

#include <inttypes.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

typedef int_fast32_t I32;

#define Constrain(expression) _Static_assert(expression, #expression)
Constrain(CHAR_BIT == 8);
#define InputMaxBytes 80000000
Constrain(InputMaxBytes <= (INT_LEAST32_MAX - 2) / 2);
#define MaxLen (2 * InputMaxBytes + 2)
Constrain(MaxLen <= INT_FAST32_MAX / 2);

static I32 Len;
static I32 Begin2;
static signed char Buf[MaxLen];
static int_least32_t SufArr[MaxLen];
static int_least32_t SufRank[MaxLen];
static int_least32_t NewRank[MaxLen];
static int_least32_t *const LongCommPre = NewRank;  // aliased to save space
static uint_least64_t Bitmap2[(MaxLen >> 6) + 1];
static int_least32_t SparseCount2[(MaxLen >> 6) + 1];
static int_least32_t *const Stack = SufRank;  // aliased to save space

static void Slurp(const char *filename) {
  FILE *stream = fopen(filename, "r");
  if (stream == NULL) goto fail;
  I32 n = fread(Buf + Len, sizeof *Buf, InputMaxBytes + 1, stream);
  if (ferror(stream)) goto fail;
  if (n > InputMaxBytes) {
    fprintf(stderr, "%s: file is too large; increase InputMaxBytes\n",
            filename);
    exit(EXIT_FAILURE);
  }
  for (I32 i = 0; i < n; i++) {
    if (Buf[Len + i] < 0) {
      fprintf(stderr,
              "%s: file contains non-ASCII byte at offset %" PRIdFAST32 "\n",
              filename, i);
      exit(EXIT_FAILURE);
    }
  }
  Len += n;
  if (fclose(stream) == EOF) goto fail;
  return;
fail:
  perror(filename);
  exit(EXIT_FAILURE);
}

static I32 Radix;

static int CompareRankPairs(const void *iPtr, const void *jPtr) {
  I32 i = *(const int_least32_t *)iPtr;
  I32 j = *(const int_least32_t *)jPtr;
  if (SufRank[i] < SufRank[j]) return -1;
  if (SufRank[i] > SufRank[j]) return 1;
  I32 iRank = i + Radix < Len ? SufRank[i + Radix] : -2;
  I32 jRank = j + Radix < Len ? SufRank[j + Radix] : -2;
  if (iRank < jRank) return -1;
  if (iRank > jRank) return 1;
  return 0;
}

static void BuildSuffixArray(void) {
  for (I32 i = 0; i < Len; i++) {
    SufArr[i] = i;
    SufRank[i] = Buf[i];
  }
  for (Radix = 1; true; Radix *= 2) {
    qsort(SufArr, Len, sizeof *SufArr, CompareRankPairs);
    NewRank[0] = 0;
    for (I32 i = 1; i < Len; i++) {
      NewRank[i] = CompareRankPairs(&SufArr[i - 1], &SufArr[i]) == 0
                       ? NewRank[i - 1]
                       : NewRank[i - 1] + 1;
    }
    for (I32 i = 0; i < Len; i++) {
      SufRank[SufArr[i]] = NewRank[i];
    }
    if (NewRank[Len - 1] == Len - 1) break;
  }

  I32 lenCommPre = 0;
  for (I32 i = 0; i < Len; i++) {
    if (SufRank[i] == Len - 1) {
      LongCommPre[SufRank[i]] = -1;
      continue;
    }
    while (Buf[i + lenCommPre] == Buf[SufArr[SufRank[i] + 1] + lenCommPre]) {
      lenCommPre++;
    }
    LongCommPre[SufRank[i]] = lenCommPre;
    if (lenCommPre > 0) lenCommPre--;
  }
}

static I32 PopCount(uint_fast64_t x) {
  I32 v = 0;
  while (x != 0) {
    x &= x - 1;
    v++;
  }
  return v;
}

static void BuildCumCount2(void) {
  for (I32 i = 0; i < Len; i++) {
    if (SufArr[i] >= Begin2) {
      Bitmap2[i >> 6] |= UINT64_C(1) << (i & 63);
      SparseCount2[i >> 6]++;
    }
  }
  for (I32 i = 0; i < (Len >> 6); i++) {
    SparseCount2[i + 1] += SparseCount2[i];
  }
}

static I32 CumCount2(I32 i) {
  return SparseCount2[i >> 6] - PopCount(Bitmap2[i >> 6] >> (i & 63));
}

static void FindCommonStrings(void) {
  I32 lenCommPre = -1;
  for (I32 i = 0; i < Len; i++) {
    while (lenCommPre > LongCommPre[i]) {
      I32 begin = Stack[lenCommPre];
      I32 end = i + 1;
      I32 count2 = CumCount2(end) - CumCount2(begin);
      if (count2 > 0 && count2 < end - begin && lenCommPre > 0) {
        printf("%" PRIdFAST32 "\t%.*s\n", count2, (int)lenCommPre,
               Buf + SufArr[begin]);
      }
      lenCommPre--;
    }
    while (lenCommPre < LongCommPre[i]) {
      lenCommPre++;
      Stack[lenCommPre] = i;
    }
  }
}

int main(int argc, char *argv[]) {
  if (argc != 3) {
    fputs("usage: commonsub needle haystack\n", stderr);
    exit(EXIT_FAILURE);
  }
  Len = 0;
  Slurp(argv[1]);
  Buf[Len] = -1;
  Len++;
  Begin2 = Len;
  Slurp(argv[2]);
  Buf[Len] = -2;  // sentinel
  BuildSuffixArray();
  if (false) {
    for (I32 i = 0; i < Len; i++) {
      printf("%" PRIdFAST32 "\t%" PRIdLEAST32 "\t%" PRIdLEAST32 "\t%.*s\n", i,
             SufArr[i], LongCommPre[i], (int)(Len - SufArr[i]),
             Buf + SufArr[i]);
    }
  }
  BuildCumCount2();
  FindCommonStrings();
}

вот некоторый код, иллюстрирующий идею, которую я представил в комментариях выше. Хотя это управляемый код C++, это более псевдокод в том смысле, что используемые структуры данных, безусловно, не являются оптимальными, но они позволяют ясно видеть алгоритм.

struct Occurrence
{
    //The vectors contain indices to the first character of the occurrence in ...
    std::vector<size_t> s1;  // ... string 1 and ...
    std::vector<size_t> s2;  // ... string 2.
};

int main()
{
    //If you cannot load the entire strings in memory, a memory-mapped file might be
    //worth considering
    std::string s1 = "ABCDE512ABC361EG51D";
    std::string s2 = "ADE5AHDW4131EG1DG5C";

    //These vectors store the occurrences of substrings for the current and next length
    std::vector<Occurrence> occurrences, nextOccurrences;
    int length = 1;

    std::map<char, Occurrence> occurrenceMap;
    //Initialize occurrences
    for (int i = 0; i < s1.length(); ++i)
        occurrenceMap[s1[i]].s1.push_back(i);
    for (int i = 0; i < s2.length(); ++i)
        occurrenceMap[s2[i]].s2.push_back(i);

    for (auto& pair : occurrenceMap)
    {
        if (pair.second.s1.size() > 0 && pair.second.s2.size() > 0)
            occurrences.push_back(std::move(pair.second));
    }

    do
    {
        nextOccurrences.clear();

        std::cout << "Length " << length << std::endl;
        for(auto& o : occurrences)
        {
            std::cout << std::string(s1.c_str() + o.s1[0], length) << " occurred "
                      << o.s1.size() << " / " << o.s2.size() << " times." << std::endl;

            //Expand the occurrence
            occurrenceMap.clear();
            for (auto p : o.s1)
            {
                if (p + length < s1.length())
                    occurrenceMap[s1[p + length]].s1.push_back(p);
            }                   
            for (auto p : o.s2)
            {
                if (p + length < s2.length())
                occurrenceMap[s2[p + length]].s2.push_back(p);
            }
            for (auto& pair : occurrenceMap)
            {
                if (pair.second.s1.size() > 0 && pair.second.s2.size() > 0)
                    nextOccurrences.push_back(std::move(pair.second));
            }
        }

        ++length;
        std::swap(occurrences, nextOccurrences);

    } while (!occurrences.empty());


    return 0;
}

выход:

Length 1
1 occurred 3 / 3 times.
3 occurred 1 / 1 times.
5 occurred 2 / 2 times.
A occurred 2 / 2 times.
C occurred 2 / 1 times.
D occurred 2 / 3 times.
E occurred 2 / 2 times.
G occurred 1 / 2 times.
Length 2
1D occurred 1 / 1 times.
1E occurred 1 / 1 times.
DE occurred 1 / 1 times.
E5 occurred 1 / 1 times.
EG occurred 1 / 1 times.
G5 occurred 1 / 1 times.
Length 3
1EG occurred 1 / 1 times.
DE5 occurred 1 / 1 times.

наибольший объем памяти будет использоваться во время инициализации, потому что будет запись для каждого символа обеих входных строк. Если вы знаете приблизительную длину строки, вы можете выбрать более подходящий тип индекса, чем size_t. Объем необходимой памяти находится в порядке размера входного сигнала. Таким образом, два файла 100 MB не должны быть проблемой для обычных компьютеров. После инициализации (точнее, после первой итерации цикла) Большая часть этих данных будет удалена, поскольку она больше не нужна.


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

String 1: ABCDE512ABC361EG51D  // S1
String 2: ADE5AHDW4131EG1DG5C  // S2

когда я думал об этом, мы можем сравнивать символы и / или подстроки от S1 до S2, отслеживая вхождения.

S1[0] = 'A'  compare S2[0]  = 'A' = true : found A in S2 at location 0
S1[0] = 'A'  compare S2[1]  = 'D' = false
S1[0] = 'A'  compare S2[2]  = 'E' = false
S1[0] = 'A'  compare S2[3]  = '5' = false
S1[0] = 'A'  compare S2[4]  = 'A' = true : found A in S2 at location 4
S1[0] = 'A'  compare S2[5]  = 'H' = false
S1[0] = 'A'  compare S2[6]  = 'D' = false
S1[0] = 'A'  compare S2[7]  = 'W' = false
S1[0] = 'A'  compare S2[8]  = '4' = false
S1[0] = 'A'  compare S2[9]  = '1' = false
S1[0] = 'A'  compare S2[10] = '3' = false
S1[0] = 'A'  compare S2[11] = '1' = false; 
S1[0] = 'A'  compare S2[12] = 'E' = false; 
S1[0] = 'A'  compare S2[13] = 'G' = false;
S1[0] = 'A'  compare S2[14] = '1' = false;
S1[0] = 'A'  compare S2[15] = 'D' = false;
S1[0] = 'A'  compare S2[16] = 'G' = false;
S1[0] = 'A'  compare S2[17] = '5' = false;
S1[0] = 'A'  compare S2[18] = 'C' = false;

// End of First Search - Occurrences of 'A' in S2 is 2 at locations {0,4}

// Next Iteration
String 1: ABCDE512ABC361EG51D  // S1
String 2: ADE5AHDW4131EG1DG5C  // S2

// Repeat this for all single characters Of S1 against S2
'A' in S2 = 2  at {0,4}
'B' in S2 = 0 
'C' in S2 = 1  at {18}
'D' in S2 = 3  at {1,6,15}
'E' in S2 = 2  at {2,12}
'5' in S2 = 2  at {3,17}
'1' in S2 = 3  at {9,11,14}
'2' in S2 = 0
'A' Already Found Above Skip
'B' Already Found Above Skip
'C' Already Found Above Skip
'3' in S2 = 1  at {10}
'6' in S2 = 0
'1' Already Found Above Skip
'E' Already Found Above Skip
'G' in S2 = 2  at {13, 16}
'5' Already Found Above Skip
'1' Already Found Above Skip
'D' Already Found Above Skip

это завершит первый набор итераций для выполнения всех отдельных символов, и, как вы можете видеть, мы также построили список и карту или наборы не только вхождения, но и их местоположения, и мы можем хранить их для будущих ссылок. Поэтому, если мы начнем искать S1[0 & 1] в S2, мы знаем, что S1[1] не существует в S2, поэтому мы можем сломать и не нужно спускаться по этой цепи, и так как мы можем вырваться из этой ветви, мы также можем пропустить выполнение S1[1 & ...N] и перейдите непосредственно к S1[2], и мы знаем, что существует только 1 вхождение S1[2], которое является " C " в S2, расположенном в {18}, который является концом строки, поэтому нет необходимости искать S1[2 & ... N] Итак мы можем пропустить это и перейти к S1[3], который является "D", и мы знаем, что он существует в S2 в {1,6,15}, поэтому теперь мы можем начать наш поиск S1[3 & ... N] начиная с S2[1 & ... N] затем снова выполните тот же поиск S1[3 & ... N] начиная с S2[6 & ... N] и, наконец, снова начиная S2[15 & ...N] тогда мы теперь нашли все подстроки, которые начинаются с D в S2, и мы можем сохранить их вхождения; однако это мы хотим найти самую длинную подстроку между ними. Самая длинная строка sub "DE5 "и есть только одно вхождение этого, но из этого мы также уже нашли подстроки" DE " & "E5", поэтому мы можем искать их в этой точке, а затем мы обнаруживаем, что есть 1 вхождение каждого. И мы просто повторяем этот процесс. Сначала это займет много времени, но чем больше вы пройдете через строки, тем быстрее это будет работать из-за устранения уже найденных вхождений, а также пропуска не найденных подстрок S1 в S2.

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

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

// C++ - The user asked for this in C but I haven't used C in nearly 10 years so this is my version of it in C++ :( 
#include <string>
#include <vector>

class SubStringSearch {
private:
    std::string S1;
    std::string S2; 

    struct SubstringResult {
        std::string substring;
        bool found;
        std::vector<unsigned> positions;

        SubstringResult(){}
        SubstringResult( const std::string& substringIn, bool foundIn, std::vector<unsigned> positionsIn ) :
            substring( substringIn ), found( foundIn ), positions( positionsIn ) {}
    };

    std::vector<SubstringResult> results;

public:
    SubStringSearch( const std::string& s1, const std::string& s2 ) : S1( s1 ), S2( s2 ) {}

    void compareStringsFirstPass();
    std::vector<unsigned> findLocations( const std::string& str, char findIt );
    void printResults() const;

};

std::vector<unsigned> SubStringSearch::findLocations( const std::string& str, char findIt ) {
    std::vector<unsigned> locations;
    for ( unsigned i = 0; i < str.size(); ++i ) {
        if ( str[i] == findIt ) {
            locations.push_back( i );
        }
    }
    return locations;
}

void SubStringSearch::compareStringsFirstPass() {
    std::vector<unsigned> positions;
    std::string sub;
    bool alreadyFound = false;

    for ( unsigned idx = 0; idx < S1.size(); ++idx ) {
        sub = S1[idx];

        if ( idx > 0 ) {
            for ( unsigned u = 0; u < results.size(); ++u ) {
                if ( sub == results[u].substring ) {
                    alreadyFound = true;
                    break;
                }
            }
        }

        // Added An If Else Here To Reduce Unneeded Calls To findLocations()
        if ( alreadyFound ) {
            alreadyFound = false;
            continue;
        } else {
            positions = findLocations( S2, S1[idx] );
        }

        if ( positions.size() > 0 && !alreadyFound ) {
            results.push_back( SubstringResult( sub, true, positions ) );
        } else if ( !alreadyFound ) {
            positions.clear();
            results.push_back( SubstringResult( sub, false, positions ) );
        }

        positions.clear();
        alreadyFound = false;
    }
}

void SubStringSearch::printResults() const {
    for ( unsigned u = 0; u < results.size(); ++u ) {
        if ( results[u].found ) {
            std::cout << results[u].substring << " found in S2 at " << std::setw(2);
            for ( unsigned i = 0; i < results[u].positions.size(); ++i ) {
                std::cout << std::setw(2) << results[u].positions[i] << " ";
            }
            std::cout << std::endl;
        }
    }
}

int main() {
    std::string S1( "ABCDE512ABC361EG51D" );
    std::string S2( "ADE5AHDW4131EG1DG5C" );

    SubStringSearch searchStrings( S1, S2 );
    searchStrings.compareStringsFirstPass();

    std::cout << "break point";

    return 0;
} // main

поместите точку останова на этой последней строке печати и перейдите в отладчик для ваших локальных или ваших автомобилей в MSVC или что-то эквивалентное для вашей версии вашего компилятора / отладчика и проверьте содержимое переменной-члена класса это std:: vector, и вы увидите символ из S1 и прикрепленный к нему флаг bool, если он найден или нет, а также std::vector для каждой из позиций. Поэтому, если флаг false, то размер вектора должен быть 0 и наоборот, если размер вектора > 0, то флаг должен быть true; также размер вектора позиций также является подсчетом или вхождениями этого символа во 2-й строке, что делает это приятным, потому что нам не нужно вычислять что-либо еще, мы можем просто получите это от самого вектора.

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


из того, что я могу понять, разбиение строки на все возможные подстроки само по себе является операцией O(n*n).

abcd
====
a,b,c,d
ab,bc,cd
abc,bcd
abcd
************************
abcdefgh
========
a,b,c,d,e,f,g,h
ab,bc,cd,de,ef,fg,gh
abc,bcd,cde,def,efg,fgh
abcd,bcde,cdef,defg,efgh
abcde,bcdef,cdefg,defgh
abcdef,bcdefg,cdefgh
abcdefg,bcdefgh
abcdefgh

таким образом, это не похоже на решение в линейном времени возможно.

еще больше, чтобы фактически решить его, с точки зрения языка Java, вам нужно сначала разбить его и сохранить его в наборе или карте (карта может иметь подстроку как ключ и количество вхождений как количество).

затем повторите этот шаг для второго и струны тоже.

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

Если вы используете 'C', то вы можете попробовать отсортировать массив подстрок, а затем используйте двоичный поиск для поиска совпадений (при наличии двумерного массива для отслеживания строки и количества вхождений).

Вы сказали, что у вас есть подход к дереву, который работает быстрее. Вы не против опубликовать образец, чтобы узнать, как вы использовали дерево ? Было ли это для представления подстрок или для его создания?