در این مقاله به بررسی مفصل و جامع الگوریتم تیمسورت میپردازیم.
بعد از کمی مقدمه، معرفی الگوریتم و بررسی یک تاریخچهی کوتاه، گام به گام الگوریتم و تمامی سیاستهایی که در آن بکار گرفته شدهاند را بررسی میکنیم و توابعی در CPython که در هر گام، وظیفه مرتب کردن یک لیست را دارند (list.sort) معرفی میکنیم.
در دنیای علوم کامپیوتر، مرتبسازی یک عملیات اساسی با کاربردهای بیشمار است. در میان انبوهی از الگوریتمهای مرتبسازی، یکی از الگوریتمها به دلیل کارایی، تطبیقپذیری و طراحی زیبا متمایز شده است: الگوریتم تیمسورت. این الگوریتم که توسط تیم پیترز برای زبان برنامه نویسی پایتون توسعه یافته است، به سنگ بنای پیادهسازی مرتبسازی در زبانها و محیطهای مختلف برنامهنویسی تبدیل شده است.
ترکیب منحصر به فرد مرتبسازی ادغامی (Merge Sort) و مرتبسازی درجی (Insertion Sort) به همراه بهینهسازیهای مخصوص روی هر الگوریتم و بهینهسازیهای تطبیقی، تیمسورت را به یکی از پیچیدهترین، کاربردیترین و یکی از بهینهترین الگوریتمهای مرتبسازی تبدیل کرده است.
الگوریتم تیمسورت، در سال ۲۰۰۲ توسعه یافت. این الگوریتم از نسخهی 2.3 پایتون تا حدود بیست سال، الگوریتم استاندارد مرتبسازی در پایتون بود و از نسخهی 3.11.1 به دلیل تغییراتی که در سیاستهای ادغام آن بوجود آمد، الگورتیمی به اسم Powersort بر پایهی تیمسورت، جایگزین آن شد.
الگوریتم تیمسورت در
پیادهسازی شده است.
تیم پیترز این الگوریتم را اینگونه توصیف میکند:
A non-recursive adaptive stable natural merge sort / binary insertion sort hybrid algorithm
و آنرا بخواهیم بر اساس حرف اول کلمات مرتب کنیم، چنین چیزی میگیریم:
اگر دقت کنید در لیست اولیه، straw قبل از spork آمده بود و در لیست مرتب شده هم همین ترتیب حفظ شد. به این نگهداری ترتیب اولیهی عناصر، پایداری الگوریتم مرتبسازی میگویند.
مقایسهی کلی بین پیچیدگی زمانی الگوریتمهای TimSort و Merge Sort و Quick Sort و Heap Sort چنین چیزیست:
اما این تحلیلِ کلی، یک سری جزئیات راجع به پیچیدگی زمانی الگوریتم را پنهان میکند که آن یکconstant factor اثرگذار در میزان پیچیدگی الگوریتم است.
پس اگر بتوانیم این constant factor را کاهش دهیم میتوانیم سرعت بیشتری از O(nlgn) بگیریم.
پیچیدگی زمانی Insertion Sort برابر با O(n^2) است و constant factor آن بسیار بسیار کوچک است؛ چون اولا inplace عمل میکند (پس نیازی به فضای اضافه ندارد) و ثانیا فقط بین عناصر آرایه پیمایش انجام میدهد (پس Locality of Reference هم در آن بسیار خوب است و پردازنده میتواند دادهها را کش کند.) در تحلیلهای انجام شده روی الگوریتمها، این الگوریتم روی تعداد ورودی ۶۴ و پایینتر، از الگوریتمهای دیگر مرتب سازی سریعتر عمل میکند.
الگوریتم Binary Insertion Sort بجای جستجوی خطی در آرایه (با پیچیدگی O(n)) در آن جستجوی دودویی انجام داده و در زمان لوگاریتمی (O(lgn)) مکان صحیح آیتم را پیدا میکند (علت استفاده از این الگوریتم در ادامه روشن خواهد شد.)
با تعویض نوع جستجوی این الگوریتم میزان پیچیدگی آن (حالت مورد انتظار و در بدترین حالت) تغییری نکرده و همان O(n^2) باقی میماند؛ اما در CPython مقایسهها (بخاطر ماهیت dynamic typed بودن زبان) نسبت به جابجا کردن آبجکتها بسیار وحشتناک کندتر هستند.
جابجا کردن آبجکتها صرفا کپی کردن ۸ بایت pointer است اما مقایسهها میتوانند بسیار کند باشند (چون ممکن است چند متد در سطح پایتون را صدا بزنند) و حتی در حالات ساده ممکن است بین ۳ یا ۴ تصمیم گرفته بشود:
پس یک مقایسه ساده باعث تعداد بسیار زیادی C-level pointer dereference، عملیاتهای شرطی و صدا زده شدن توابع میشود.
پس اگر ما تعداد مقایسهها کمتر کنیم میتوانیم سرعت مرتب سازی را بیشتر کنیم (که با استفاده ازbinary insertion sort ما تعداد مقایسهها را کم میکنیم.)
این مرتب سازی با تابع binarysort در کدهای CPython انجام میشود.
اگر آرایه را به تکههای کوچک تقسیم کنیم (برای مثال ۳۲ تا ۶۴ تایی) و سپس آنها را جدا جدا با مرتب سازی درجی مرتب کنیم و سپس همه را ادغام کنیم، میتوانیم سرعت مرتب سازی را افزایش دهیم. با اینکار:
و پیچیدگی تیمسورت تا اینجا چنین میشود:
که چون اینکه پیچیدگی Binary Insertion Sort در nهای بزرگ کوچک است میتوان از آن چشمپوشی کرد.
در دنیای واقعی و دادههای واقعی معمولا آرایهها اصطلاحا partially sorted هستند. این به این معناست که تکههایی از آرایه از قبل مرتب هستند؛ برای مثال در این آرایه:
قسمت
از قبل مرتب است.
یا دادهها حداقل به صورت صعودی یا نزولی پشت سر هم حضور دارند؛ برای مثال در این آرایه:
قسمت
به صورت صعودی و قسمت
به صورت نزولی مرتب است.
الگوریتم تیمسورت این تکههای صعودی و یا اکیدا نزولی را در آرایه پیدا میکند و آنها را Run مینامد و از مرتب بودن اولیه آرایه برای افزایش سرعت استفاده میکند.
اگر از حقیقت قبلی استفاده کنیم و آرایه را به قسمتهایی صعودی و یا اکیدا نزولی تقسیم کنیم میتوانیم درخت Merge sort حتی بیشتر از قبل هم کوتاه کنیم، و پیچیدگی را به
تبدیل کنیم. پس هر طول و تعداد Runها بیشتر باشد، مقدار x بیشتر و درخت ادغام ما کوتاهتر و الگوریتم سریعتر میشود.
تکههایی از آرایه که از قبل دارای ترتیب هستند، در این الگوریتم یک Run نام میگیرند. Runها میتوانند:
دلیل اینکه یک Run باید نزولی اکید باشد تا یک Run شناخته شود، اینست که الگوریتم تیمسورت Runهای نزولی را به صورت درجا، برعکس میکند و اگر در یک Run نزولی (و نه اکیدا نزولی) دو عنصر یکسان وجود داشته باشد، ماهیت stable بودن الگوریتم نقض میشود. برای مثال این آرایه:
اگر برعکس شود:
که ترتیب عناصر ۳ عوض شده است. اما اگر اکیدا نزولی باشد دیگر این مشکل وجود نخواهد داشت.
نکتهی دیگر اینست که Runها حداقل دو آیتم دارند مگر وقتی که آخرین عضو آرایه را برای Run جدید برگزینیم.
اگر عناصر آرایه رندوم باشند، بعید است که ما Runهای طبیعی (یعنی قسمتی از آرایه که از قبل مرتب شده باشد) بلندی را شاهد باشیم. اگر یک Run طبیعی تعداد عناصرش کمتر از minrun باشد (توضیح داده خواهد شد،) الگوریتم با استفاده از Binray Insertion Sort تعداد آنرا به حداقل اندازهی Run میرساند. دلیلی که میتوانیم از Binray Insertion Sort استفاده کنیم اینست که هر Run خودش مرتب است، پس میتوان در آن جستجوی دودویی انجام داد.
الگوریتم برای پیدا کردن Runها چنین عمل میکند: فرض کنید که اندازهی Runها ۳ تعیین شده است و چنین آرایهای داریم:
از چپ به راست حرکت میکنیم ۸ را جدا میکنیم و سراغ آیتم بعدی میرویم و متوجه میشویم که یک Run صعودی داریم:
ادامه میدهیم و به عدد ۹ میرسیم، چون این Run باید صعودی باشد، و حداقل طول هم ۳ است و ۹ از ۱۲ کمتر است با استفاده از Binary Insertion Sort جایگاه ۹ را پیدا میکنیم.
عدد بعدی هم بزرگتر از ۱۲ است و آن را هم به این Run اضافه میکنیم:
عدد بعدی، از ۱۷ کمتر است و چون ما حداقل اندازهی یک Run را داریم آنرا دیگر به این Run اضافه نمیکنیم و به سراغ Run بعدی میرویم. پس:
و سپس با Binary Insertion Sort:
و چون 11 از 1- بیشتر است و ما حداقل اندازهی یک Run را داریم ابتدا آنرا برعکس:
و به سراغ Run بعدی میرویم؛ که Run بعدی هم این است:
که الگوریتم آنرا هم برعکس میکند تا صعودی شود:
اگر دادهها رندوم باشند، اکثر Runها یک اندازه خواهند داشت که دو خوبی دارد:
برای دادههای واقعی هم، ما چون Runهای نسبتا بلندی خواهیم داشت توانستهایم کوتاهترین درخت بازگشتی ادغام را داشته باشیم و در نتیجه تعداد ادغامها را کم کنیم. پیدا کردن Runها در آرایه توسط تابع count_run انجام میشود.
کدی که به Runهای اضافه شده، عنصر اضافه میکند تا به اندازهی minrun برسند:
محاسبهی minrun بر اساس اندازهی اولیه آرایه، انجام میشود:
اگر طول آرایه کمتر از 64 باشد، از Binary Insertion Sort برای مرتب کردن آن استفاده میشود.
اگر طول آرایه توانی از ۲ بود، طبق تستهای انجام شده تمامی اعداد ۸ و ۱۶ و ۳۲ و ۶۴ و ۱۲۸ سرعت یکسانی را به الگوریتم میدادند اما مثلا در اندازهی ۲۵۶ تا، جابجا کردن عناصر در مرتب سازی هزینه بردار و در اندازهی ۸ تعداد صدا زده شدن توابع هزینه بردار بود. بعد از کمی مطالعه عدد ۳۲ برای minrun انتخاب شد.
اما بعد از زمان زیادی یک اشکال در انتخاب این عدد پیدا شد، این مثال را ببینید:
که اگر با این تعداد Run ما ادغام را انجام بدهیم، در پایان باید یک آرایهی ۲۰۴۸ عضوی و یک آرایهی ۶۴ عضوی را با هم ادغام کنیم که اصلا خوب نیست. اما اگر عدد ۳۳ را برای minrun انتخاب کنیم ما ۶۴ تا Run با اندازهی ۳۳ داریم که برای ادغام خیلی بهتر است.
سیاستی که برای محاسبهی minrun در پیش گرفته شده است اینست که این مقدار از range(32, 64) به صورتی انتخاب میشود که N/minrun یا دقیقا توانی از دو باشد، یا اگر این ممکن نبود، اکیدا کمتر از توان ۲ باشد.
این انتخاب توسط تابع merge_compute_minrun انجام میشود.
الگوریتم ادغام در Merge sort الگوریتمی inplace نیست و برای ادغام کردن دو آرایه با طولهای n و m یک آرایهی جدید به طول n + m نیاز دارد، اما تیمسورت برای inplace مرتب کردن و کمتر کردن space overhead الگوریتم مرتب سازی ادغامی از روش بهتری برای ادغام کردن دو Run استفاده میکند.
اگر آرایهای به این صورت داشته باشیم:
در این روش جدید ادغام ما یک آرایهی موقت به اندازهی Run کوچکتر درست میکنیم و عناصر Run کوچکتر را در آن کپی میکنیم. کپی کردنهای این چنین هم (یعنی یک تکه از یک آرایه) به بهینهترین حالت در سطح پایین اجرا میشوند. پس حالا ما چنین چیزی داریم:
چون آرایهی کوچکتر سمت چپ بود، پس از سمت چپ آرایهی موقت و دومین Run شروع به ادغام کردن میکنیم. که یعنی:
را باهم مقایسه، و برنده (عنصر کوچکتر) را در ایندکس صفر آرایهی A میگذاریم. که میشود:
سپس:
که میشود:
سپس:
که میشود:
سپس:
که میشود:
سپس:
که میشود:
سپس:
که میشود:
سپس:
که در اینجا عنصر ۲۲ آرایهی موقت انتخاب میشود. چون برای حفظ پایداری الگوریتم مرتب سازی لازم است که بیست و دویی که زودتر آمده انتخاب شود:
وضعیت کنونی ادغام:
و حالا که عناصر آرایهی موقت تمام شده، باقی عناصر باقی مانده سر جای خود هستند و دیگر مقایسه و جابجایی لازم نیست و آرایه به صورت مرتب ادغام شد:
در مثال قبلی Run کوچکتر در سمت چپ بود و ما ادغام را از سمت چپ Run بزرگتر و آرایهی موقت انجام دادیم، اما اگر چنین حالتی باشد:
ما چنین چیزی داریم:
در این حالت که Run کوچکتر در سمت راست بود، ما هم تمام ادغام را از سمت راست انجام میدهیم
که میشود:
و سپس:
که میشود:
و الی آخر...
تابعی که عمل ادغام را شروع میکند، تابع merge_collapse است که در ادامه بیشتر راجع به آن توضیح داده خواهد شد.
چنین سناریویی را تصور کنید: ما این آرایه و Runها را داریم:
در تکنیک Galloping، ما با استفاده از binary search مکان اولین عضو Y را در X پیدا میکنیم که میبینیم در جایگاه ۴ باید آن را بگذاریم، این یعنی تمامی عناصر قرمز سر جای واقعی و درست خود قرار دارند:
در جایگاه درستی قرار دارند.
همین کار را برای آخرین عنصر X نسبت به Y انجام میدهیم که مکان آن در جایگاه پنجم دومین Run است، این یعنی تمامی عناصر قرمز در جایگاه درستی قرار دارند.
پس ما چنین آرایه و Runهایی داریم:
و صرفا باید الگوریتم ادغامی که بالاتر بحث شد را روی این X و Y کوچکتر انجام بدهیم که هم سریعتر است و هم فضای کمتری استفاده خواهد کرد.
نکتهای که در تکنیک Galloping وجود دارد اینست که این روش فقط بعضی وقتها فقط به نفع ماست. به این مثال دقت کنید:
ابتدا مکان ۲ را در X پیدا میکنیم که در جایگاه دوم باید قرار بگیرد و سپس مکان ۹ را در Y پیدا میکنیم که در جایگاه پنجم باید قرار بگیرد.
که به چنین آرایهای میرسیم:
که مشخصا هیچ بهبودی را برای ما نداشت هیچ، بلکه چندین مقایسه اضافه هم با Binary Search انجام دادهایم.
طبق تستهایی که تیم پیترز انجام داده است، تعداد عنصری که باید سر جای خودشان باشند، در ابتدای الگوریتم، باید حداقل ۷ تا باشد که این عدد نام min_gallop دارد. این یعنی اگر جایگاه اولین عنصر Y را در X پیدا کنیم و ۷ عنصر در جایگاه خودشان باشند، این تکنیک موثر خواهد بود (همین برای پیدا کردن آخرین عنصر X در Y هم صدق میکند.)
در ادغام کردن اگر حالت اول (مکان اولین عنصر Y در X) یا حالت دوم (مکان آخرین عنصر X در Y) بزرگتر مساوی min_gallop بود، از این تکنیک استفاده میشود.
بعد از هر بار موفق بودن galloping عدد min_gallop به عنوان یک تشویق برای الگوریتم، یکی کم میشود تا در ادامه هم بتوان از galloping استفاده کرد.
حالت عکس هم چنین است که مکان پیدا شده کمتر از min_gallop باشد که یعنی این تکنیک موثر نبوده و از حالت مقایسهی ادغامی ساده، یعنی عنصر به عنصر استفاده میشود و عدد min_gallop به عنوان جریمه یکی زیاد میشود.
روش galloping در دادههای واقعی که partially sorted هستند بسیار کارآمد است و تعداد مقایسهها را کمتر میکند اما در دادههای رندوم این روش موثر نیست و از مقایسهی عنصر به عنصر استفاده میشود که
در الگوریتم gallop کردن از چپ توسط تابع gallop_left و gallop کردن از سمت راست توسط تابع gallop_right انجام میشود. این دو تابع انعکاس آئینهای همدیگر هستند.
برای ادغام ساختاری در کدهایی که مربوط به الگوریتم تیمسورت پیدا میشود به اسم MergeState؛ این ساختار اطلاعات مورد نیاز برای توابع ادغام را داراست. اطلاعات دیگری که در این ساختار نگهداری میشوند:
کد این ساختار اینجا قرار دارد.
توابع بازگشتی وقتی که صدا زده میشوند، اطلاعات هر بار صدا زده شدن آنها، در در یک stack ذخیره میشوند، اما آقای تیم پیترز این الگوریتم را که ماهیت بازگشتی مانندی دارد را غیر بازگشتی نوشته است و برای اینکار از یک stack مخصوص خودش در سطح کد استفاده میکند. هر بار که یک Run پیدا میشود و اندازهی آن به minrun و یا بیشتر میرسد، اطلاعات آن روی stack گذاشته میشود.
اگر بخواهیم ساده نگاه کنیم فرض میکنیم، خود Run روی استک گذاشته میشود اما در واقع فقط آدرس شروع و اندازهی آن روی استک ذخیره میشود تا از تکه تکه کردن آرایه و اشغال فضای بیشتر جلوگیری شود.
این استک، نقاط شروع و طول هر Run را در خودش ذخیره میکند.
این یعنی: Run عه iام از آدرس base[i] شروع شده وlen[i] تا ادامه دارد و این عبارت همیشه درست است:
برای اینکه اندازهی Runها دقیقا و یا تقریبا با هم برابر بمانند تا الگوریتمهای ادغام آنها را سریعتر ادغام کنند، هر بار که یک Run جدیدی ساخته میشود اطلاعات آن روی استک گذاشته میشود، دو شرط برای Runهای روی استک چک میشوند.
اگر روی استک ما Runهای Xو Y و Z وجود داشته باشند، این شروط باید حفظ شوند:
شرط دوم بر این دلالت دارد که اندازهی Runها از بالا به پایین به صورت صعودی باشد و شرط اول بر این دلالت دارد که وقتی از بالا به پایین میرویم طول Runها حداقل با سرعت دنبالهی فیبوناچی رشد کنند.
اگر هر کدام از این شروط نقض بشوند، Run عه Y با Run کوچکتر از بین X یا Z ادغام میشود و شروط دوباره چک میشوند و اگر برقرار بودند، الگوریتم به دنبال Run بعدی در آرایه جستجو میکند.
برای مثال اگر
هر کدام از X یا Z که کوچکتر باشند، با Y ادغام میشوند (که الگوریتم X را ترجیح میدهد؛ بخاطر اینکه در کش تازگی دارد.)
برای مثال اگر این Runها را داشته باشیم:
ران عه Y با X ادغام میشود:
و یا اگر
ران Y با Z ادغام میشود:
که در هر دو مثال شرط دوم نقض میشود و الگوریتم تا زمانی که این دو شرط درست شوند، به ادغام کردن ادامه میدهد و در نهایت، تنها یک Run روی استک باقی میماند که آرایهی مرتب شدهی ماست.
تابع merge_collapse مدیریت این شروط را به عهده دارد و انتخاب میکند که کدام Runها با هم ادغام شوند و این کار را با تابع merge_at انجام میدهد.
عمل ادغام تصمیمات مبنی بر اینکه ادغام باید با galloping شود یا نشود را از نتایج توابع galloping اتخاذ و عملیات ادغام با توجه به شرایط را با کمک توابع merge_lo و merge_hi انجام میدهد.
اگر بخواهیم روی مطالبی که خواندیم مروری داشته باشیم الگوریتم به طول کلی یکبار از سمت چپ شروع به حرکت در آرایه میکند، قسمتهایی که از قبل ترتیبی صعودی یا اکیدا نزولی دارند را شناسایی میکند، اگر اندازهی آنها به اندازهی minrun نبود آنها را به صورت مرتب بزرگتر میکند تا به minrun برسند و سپس با سیاستهای خاصی برای ادغام، مانند استفاده از یک آرایهی موقت کوچک، نزدیک نگه داشتن طول Runها برای ادغام و ادغام با استک برای جلوگیری از بازگشتی شدن الگوریتم آنها را با هم ادغام میکند.
اگه برای ارائهی این الگوریتم به اسلاید نیاز دارید میتونید از اسلایدهای من در اینجا
فایل slides.pdf استفاده کنید.
منابع: