حياة المهندس مليئة بالمفاجآت: خاصة عندما يكون عليك التعامل مع الإنتاجية. على سبيل المثال ، ماذا يحدث إذا حاولت تشغيل هذا الجزء من كود Java؟ تبدو بريئة جدًا:
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": بالإضافة إلى ذلك ، تحتاج إلى حساب مسبق ، مما سيساعد على فهم أننا وجدنا تطابقًا تامًا. تبدو كقيمة ، مع وضع صغير :
successBitMask
Long
1
needle.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 .