نكتب البحث في السلاسل الفرعية أفضل من الكتب المدرسية



حياة المهندس مليئة بالمفاجآت: خاصة عندما يكون عليك التعامل مع الإنتاجية. على سبيل المثال ، ماذا يحدث إذا حاولت تشغيل هذا الجزء من كود Java؟ تبدو بريئة جدًا:

//   String.repeat  JDK 11  :
final var needle = "A".repeat(500000) + "B";
final var haystack = "A".repeat(1000000) + "B";
System.out.println(haystack.indexOf(needle));

ننتظر ، ننتظر ، ننتظر ... على الأقل على جهاز الكمبيوتر المحمول OpenJDK 13 لعام 2015 ، يستغرق العثور على إبرة في كومة قش حوالي دقيقة. لقد مرت JVM القديمة الجيدة لدينا عبر عقود من ضبط الأداء ، وقد نفذت بفعالية الجوهرية String.indexOfوما إلى ذلك. أين يمكن أن يكون الخطأ؟
هذه هي بداية سلسلة من عدة مقالات مجاملة من مؤلفها ، Linas Medžiūnas ، ونشرت في الأصل على مدونة WiX Engineering .


نلقي نظرة فاحصة على ما هو المدخلات: يتم تحديد البيانات خصيصا وذلك لتحقيق أداء من الدرجة الثانية في أسوأ الحالات ( O(nm)حيث nهو طول haystackو mهو طول needle) لساذجة خوارزمية البحث فرعية. نحن نتحرك عبر جميع الشخصيات في haystack، وإذا تزامنت مع الأحرف الأولى needle، نبدأ في الجري needleفي الحلقة الداخلية - وهكذا حتى أول حرف غير متطابق.

قد تجادل في أن هذا المثال عديم الفائدة ، لأن بيانات الإدخال هذه تم تصميمها وحفظها بشكل خاص ، ومن الناحية العملية لن تواجهها. فكر مرتين. ماذا لو كنت تعمل على خدمة ويب يمكن لمستخدميها تحميل سلاسل عشوائية ، وفي مكان ما في الجزء الخلفي من الخدمة يوجد رمز يعملindexOfعلى هذه الخطوط؟ ثم مجرد عدد قليل من الطلبات الخبيثة مثل تلك المذكورة أعلاه ستضع الخدمة على ركبتيها. يجدر معرفة ، على الأقل ، عن أسوأ الحالات لبيانات الإدخال.

لحسن الحظ ، هناك خوارزميات بحث فرعية تحتوي على تعقيد خطي ( O(n+m)). ليس لديهم مشاكل مع البيانات من المثال أعلاه. على سبيل المثال ، يعمل رمز Scala التالي على نفس الشيء ، ولكنه يعمل بالمللي ثانية على نفس جهاز الكمبيوتر ، ونفس JVM ، ويستخدم نفس الشيء بالضبط تحت غطاء المحرك java.lang.String:

val needle = "A" * 500000 + "B"
val haystack = "A" * 1000000 + "B"
println(haystack.indexOfSlice(needle))

يكمن سر الفارق الكبير في الطريقة indexOfSliceالتي تعد جزءًا من مكتبة سكالا القياسية . وتنفذ خوارزمية Knut-Morris-Pratt الخطية الذكية . لا ، أنا لا أقول أن اللغة X أفضل من اللغة Y. لسوء الحظ ، كل شيء أكثر تعقيدًا هنا! على سبيل المثال ، indexOfSliceفي Scala ، هذه طريقة معممة لا تعمل فقط مع السلاسل ، ولكن أيضًا في مجموعات متسلسلة أخرى ، ويمكن أن تقارن ليس فقط الأحرف ، ولكن أيضًا عناصر من أنواع أخرى. يجب أن يكون أبطأ بكثير منString.indexOfمن Java في الحالة الوسطى (سنتحدث عن هذا لاحقًا). وبالتالي ، لدينا خوارزمية فعالة ذات أداء أفضل بكثير في أسوأ الحالات ، ولكنها في المتوسط ​​أبطأ لأنها تحتوي على جزء ثابت أكبر بكثير. تمثل هذه المعضلات مشكلة نموذجية في ضبط الأداء. لا توجد حبة سحرية تحل جميع المشاكل - تحتاج إلى تحليل المشكلة بعناية ووضع المعايير الدقيقة المناسبة.



هل ما زلت معي حسن! كما ترون ، هذه مجرد مقدمة. أردت تحفيزك على التعامل مع التعقيد النظري والأداء العملي للخوارزميات. في الجزء المتبقي من هذه المقالة ، سنلقي نظرة على بعض عمليات التنفيذ للعديد من خوارزميات بحث السلسلة الفرعية ومعاييرها.

سوف نستكشف ثلاث خوارزميات بحث فرعية. كلهم يعملون في الوقت الخطي ويتطلبون معالجة مسبقة ، ويعتمدون خطيًا على الطول needle. needleمطلوب حساب نفسه مرة واحدة فقط ، وبعد ذلك يمكن إعادة استخدامه في عدة محاولات بحث. هذا أمر معقول ، لأنه في كثير من الحالات نحتاج إلى البحث عن نفس السطر مرارًا وتكرارًا. وحتى إذا لم نفعل ذلك ، فإن عملية المعالجة المسبقة ليست عملية مكلفة للغاية.

جميع الخوارزميات أدناه تتخطى كل حرف فيhaystackمرة واحدة فقط على التوالي (لا يوجد وصول عشوائي حسب الفهرس) ، لذا فإنها تعمل بشكل جيد في وضع البث. ظهرت هذه المقالة أثناء عمل حقيقي على خادم وكيل للإنتاج استنادًا إلى إطار Netty ، وأثر ذلك على بعض قرارات تصميم واجهة برمجة التطبيقات. بالإضافة إلى ذلك ، نظرًا لأننا كنا بحاجة إلى إجراء بحث على المخازن المؤقتة للبايت ، فسيعمل الرمز مع Byte، وليس مع Char.



كنوت موريس برات (خوارزمية KMP)


هذه خوارزمية بحث سلسلة فرعية معروفة يعود تاريخها إلى السبعينيات من القرن الماضي. وقد تم وصفها جيدًا في الأدبيات ، لذلك لن أصفها هنا بالتفصيل. يعتمد ILC على آلات الحالة - خلال مرحلة الحساب الأولية ، يتم إنشاء مجموعة من مؤشرات الارتباطات على أساس needle. أثناء البحث ، يقبل الجهاز الأحرف haystackواحدة تلو الأخرى عند الإدخال ، ويقوم بتحديث حالته الداخلية وفقًا لذلك (والحالة هناك فقط فهرس في جدول العلاقات).

هنا تنفيذ على سكالا .

خوارزمية بحث سلسلة فرعية ثنائية


في البداية ، كان عليّ أن أخترع اسم هذه الخوارزمية بشكل مستقل: لم أر شيئًا مثل هذا في أي مكان في الأدب. ونتيجة لذلك ، جئت إلى اسم "Shifting Bit Mask". اتضح فيما بعد أن هذه الخوارزمية واختلافاتها معروفة منذ عام 1964 تحت أسماء إنجليزية مختلفة مثل "Bitap" و "Shift-or" و "Shift-and" و "Baeza-Yates - Gonnet". شكرا للقراء الذين وجدوها لي. تمت كتابة هذه المقالة قبل هذا الخبر بوقت طويل.

تعتمد هذه الخوارزمية على فكرة بسيطة جدًا وتعمل بشكل جيد جدًا ، نظرًا لعدم وجود قفزات تقريبًا ، وهي تستند إلى العديد من العمليات الثنائية البدائية. ولهذا السبب ، فإن لها حدًا للطول needleالذي سنبحث عنه: لا يمكن أن يكون أطول من 64 بايت. تم أخذ هذا الرقم ببساطة من خلال عدد البتات فيLongفي JVM. هذا القيد سخي بما فيه الكفاية لعدد كبير من المهام الحقيقية.

نظرًا لأنني طورت هذه الخوارزمية في الأصل ، سأحاول التحدث عنها بمزيد من التفصيل. أولاً ، نقوم مسبقًا بحساب سياق البحث للسياق المطلوب needle:

  def computeBitMasks(needle: Array[Byte]): Array[Long] = {
    require(needle.length <= 64, "Maximum supported search pattern length is 64.")
    val bitMasks = Array.ofDim[Long](256)
    var bit = 1L
    for (c <- needle) {
      bitMasks(toUnsignedInt(c)) |= bit
      bit <<= 1
    }
    bitMasks
  }

نقوم بالحساب المسبق bitMask(64 بت Long) لكل قيمة بايت محتملة (256 قطعة bitMask). بالنسبة لبعض القيمة بايت X، فإنه bitmaskيحتوي على يحتوي على وحدات في جميع الأماكن التي Xهي في needle. على سبيل المثال ، إليك قناع بسيط للسلسلة "abracadabra": بالإضافة إلى ذلك ، تحتاج إلى حساب مسبق ، مما سيساعد على فهم أننا وجدنا تطابقًا تامًا. تبدو كقيمة ، مع وضع صغير :



successBitMaskLong1needle.length — 1

  def computeSuccessBitMask(needle: Array[Byte]): Long = {
    1L << (needle.length - 1)
  }

وأخيرًا ، تحتاج إلى إجراء بحث. الحالة الوحيدة القابلة للتغيير التي نريد تخزينها هي currentMask( Long). لكل بايت في haystackننتقل currentMaskيسارًا 1ببتة ، نضع أقل بته أهمية في 1، ونقوم بعمل بتات andبين النتيجة bitMask، ويتم حسابها لقيمة البايت التي تمت معالجتها حاليًا haystack(يؤدي هذا إلى andمسح جميع البتات في تلك الأماكن currentMaskالتي لا تتطابق مع البايت الحالي المعالج).

وهكذا ، بعد معالجة كل بايت ، ستبقى البتات التي هي في مواقع مناسبة فقط. ومع معالجة كل بايت ، يتم تحويل جميع البتات إلى اليسار بموضع واحد. إذا كانت البتة "تنجو" أثناء عدد التكرارات تساوي الطولneedle- وجدنا مباراة! ويمكننا التحقق من ذلك من خلال successBitMask:

  def process(value: Byte): Boolean = {
    currentMask = ((currentMask << 1) | 1) & bitMasks(toUnsignedInt(value))
    (currentMask & successBitMask) == 0
  }

ملاحظة: ترجع الطريقة الموضحة أعلاه falseإذا تم العثور على شيء ما ، ويبدو أنه غير بديهي. يمكن فهم ذلك بحيث trueتعني القيمة الحاجة إلى متابعة البحث ، ولكن falseتوقفه - وهذا يرجع إلى حقيقة أنه ، كما كتبت أعلاه ، تم جعل واجهة برمجة التطبيقات متوافقة مع Netty. إذا كنت تتساءل عن كيفية إجراء بحث ، فإليك مثال.

ونتيجة لذلك ، يتلخص كل المنطق في بعض تعليمات المعالج البسيطة. لسوء الحظ ، لا يزال هناك فحص غير مفيد تمامًا bitMasksلحدود فهارس المصفوفة ، والتي لا يمكن لأي JDK إزالتها (ونظرت إلى المجمع الذي تم إنشاؤه بواسطة عدة JDKs مختلفة).

هنا هو التنفيذ الكامل على سكالا .

Aho korasik


هذه خوارزمية شائعة أخرى معروفة منذ عام 1975. ميزته المميزة (وأحيانًا مفيدة جدًا) هي القدرة على البحث needleفي العديد منها في وقت واحد في نفس الوقت ، بينما haystackيتم تجاوز جميع الأحرف من مرة واحدة بالضبط (أعتقد أنه رائع!). فكرة أن كل هذا يعمل عليه هو امتداد لخوارزمية KMP ، وهي آلة حالة محدودة تستخدم شجرة بادئة (مبنية على أساس عدة needle) ، تحتوي على روابط إلى روابط (مقارنة بمصفوفة أحادية البعد من KMP). بناءً على هذه الروابط ، يتم تبديل الحالة الداخلية للأوتوماتون بين عقد شجرة البادئة بعد كل رمز معالج ، وتشير بعض العقد إلى نتيجة بحث إيجابية عن معينneedle. مرحلة ما قبل الحوسبة هنا معقدة نوعًا ما ، ولكن مرحلة البحث بسيطة للغاية بشكل غير متوقع.

هنا رابط لتنفيذ العمل على سكالا .



كانت هذه قائمة غير مكتملة تمامًا لخوارزميات بحث السلسلة الفرعية. لقد جربنا أيضًا خوارزمية رابين كارب وخوارزمية بوير مور . من بين هذين ، أظهر Boyer-Moore أداءً مشابهًا ، لكن كلاهما غير متوافق مع البث (باستخدام الوصول العشوائي haystackبواسطة الفهرس) ، ولذا فقد أسقطتهما من هذا التحقيق.



المعايير


سنقارن الخوارزميات الثلاثة الموضحة أعلاه ، بالإضافة إلى ذلك ، نلقي نظرة على نتائج الطريقتين String.indexOf(Java) و indexOfSlice(Scala). لنكون صادقين ، هذه ليست مقارنة صحيحة تمامًا ، لأنها String.indexOfتعمل مع السلاسل ، وجميع الطرق الأخرى على صفائف بايت. ولكن لا يبدو أن هذا يبطل نتائج هذه المقارنة. علاوة على ذلك ، قمت أيضًا بتضمين نتائج Bytes.indexOfالجوافة (الإصدار 28.1). تعمل هذه الطريقة على صفائف البايت. وقد كتبوه على Google - كل ما يكتبونه هناك يعمل بسرعة فائقة ، أليس كذلك؟

من الصعب دائمًا كتابة المعايير ، لأنه يمكنك إرسال بيانات مختلفة تمامًا إلى المدخلات ، وتغييرها بعدة طرق مختلفة - ليس فقط في الطول needleوhaystack، ولكن أيضًا من خلال المحتوى الداخلي لهذه الخطوط (والذي يمكن أن يؤثر بشكل كبير على بعض الخوارزميات). من الناحية العملية ، يجدر دائمًا التحقق من بيانات الإدخال الأكثر تشابهًا مع البيانات من مهامك الحقيقية (هذا ما فعلناه في مشروعنا).

لتقصير هذه المقالة ، استخدمت نوعين فقط من المدخلات. الغرض من أحدها هو عكس الحالة الحقيقية: haystackحجم 1.5 كيلوبايت تقريبًا (مع نص قابل للقراءة في الداخل) needle- 9 بايت ، وليس في haystackهذا التسلسل (هذا ضروري لإجبار الخوارزمية على إجراء مسح كامل).

هناك حاجة إلى نوع آخر من المدخلات للحصول على سلوك أسوأ حالة لخوارزمية من الدرجة الثانية. إنها أقصر بكثير من البيانات من بداية هذه المقالة: وإلا فسيتعين علينا الانتظار لمدة دقيقة كاملة ، أتذكر؟ مجموعة مصفوفةhaystackيتم تعيينه في التنسيق "AA...AAB"(نفس طول نوع البيانات الأول) ، و needle- 64 بايت (خاصة لخوارزمية بحث السلسلة الفرعية للتعامل معها) صفيف من نفس النوع (المطابقة هي فقط في النهاية haystack).

يمكن العثور على معيار مكتوب في إطار JMH هنا . إذا كانت لديك أفكار أخرى حول كيفية وكيفية القياس هنا - يمكنك استنساخ هذا المستودع وتغيير شيء ما ونشر التعليقات.

بناء على اقتراح فلاديمير سيتنيكوف ، أضفت نتائج مرجعية لـ java.util.regex.Pattern؛ يستخدم خوارزمية Boyer-Moore تحت غطاء المحرك.


(ملاحظة المترجم: بالمناسبة ، فلاديمير سيتنيكوف عضو في العديد من لجان البرنامج في مجموعة JUG Ru ويقوم بإعداد تقارير مثيرة للاهتمام بنفسه. على سبيل المثال ، يتوفر مقطع فيديو من تقريره من JPoint 2019 بعنوان "Java slows down: CodeCache edition" على الرابط ).

نتائج قياس الأداء


يتم تقديم النتائج بالمللي ثانية ، الأقل أفضل: هنا كل شيء كما هو متوقع:

# JMH version: 1.21
# VM version: JDK 13.0.1, OpenJDK 64-Bit Server VM, 13.0.1+9
Benchmark (searchInput) Mode Cnt Score Error Units
javaIndexOf REGULAR avgt 5 0.622 ± 0.002 us/op
shiftingBitMask REGULAR avgt 5 1.982 ± 0.017 us/op
regexPattern REGULAR avgt 5 2.184 ± 0.006 us/op
kmp REGULAR avgt 5 2.635 ± 0.016 us/op
scalaIndexOfSlice REGULAR avgt 5 3.202 ± 0.009 us/op
guavaIndexOf REGULAR avgt 5 3.696 ± 0.095 us/op
ahoCorasic REGULAR avgt 5 7.063 ± 0.040 us/op
shiftingBitMask WORST_CASE avgt 5 1.986 ± 0.010 us/op
kmp WORST_CASE avgt 5 5.120 ± 0.006 us/op
ahoCorasic WORST_CASE avgt 5 6.892 ± 0.025 us/op
scalaIndexOfSlice WORST_CASE avgt 5 8.765 ± 0.007 us/op
regexPattern WORST_CASE avgt 5 11.566 ± 0.086 us/op
javaIndexOf WORST_CASE avgt 5 23.029 ± 0.124 us/op
guavaIndexOf WORST_CASE avgt 5 52.927 ± 0.275 us/op



  • بالنسبة للبيانات العادية ، فهي تهيمن javaIndexOf، لأنها تستخدم الجوهر الداخلي عالي الأداء ، حيث يكون الجزء الثابت صغيرًا ؛
  • , : , (O(nm)) javaIndexOf, — , shiftingBitMask ( ) .
  • guavaIndexOf , javaIndexOf; , 2 , shiftingBitMask;
  • scalaIndexOfSlice - , knuthMorrisPratt, , — , ;
  • الأداء ليس أقوى ميزة ahoCorasic(أو على الأقل تنفيذه ؛ يجب أن أعترف بأنني لم أحاول فعلًا إجراء التحسينات الدقيقة فيه ، لأنني أضفته فقط بسبب ميزته المميزة: القدرة على البحث عبر عدة أسطر في وقت واحد ، وهذا على غرار موضوع مقال منفصل) ؛
  • بيانات الإدخال (والطول needle) لم تؤثر على الأداء shiftingBitMaskو ahoCorasic.

الموجودات


في حالات مختلفة ، يمكن أن تعمل المعايير بطرق مختلفة. على الرغم من حقيقة أن النتائج المذكورة أعلاه تبدو إرشادية للغاية ، يجب عليك دائمًا إجراء القياسات بنفسك وعلى البيانات التي تعكس مهامك الحقيقية.

بناءً على البيانات المقدمة ، توصلت إلى الاستنتاجات التالية:

  • String- , , String.indexOf ( java.util.regex.Pattern — );
  • , needle 64 , ;
  • , --;
  • Scala - ( ), indexOfSlice — ;
  • , -.

هذا كل شئ! إذا كنت تستمتع بقراءة الخوارزميات والأداء وما شابه ذلك (وأيضًا عن Scala و JVM و Java بشكل عام) ، فاشترك في مؤلف هذا المقال ، Linas Medziunas ( Medium ، Twitter ).

مستودع جيثب مع جميع التعليمات البرمجية في هذه المقالة هنا .



يتم نشر ترجمات المقالات بدعم من JUG Ru Group و JPoint Conference .


All Articles