جداول هش توضیح داده شده: از آدرس‌دهی مستقیم تا آدرس‌دهی باز

جداول هش جستجو، درج، و حذف زمان-ثابت مورد انتظار فراهم می‌کنند، که آن‌ها را به یکی از پراستفاده‌ترین ساختارهای داده در عمل تبدیل می‌کند. این راهنمای جامع ایده آدرس‌دهی مستقیم که هشینگ را انگیزه می‌دهد، نحوه مدیریت برخوردها از طریق زنجیره‌بندی، ویژگی‌های توابع هش خوب، آدرس‌دهی باز به‌عنوان یک جایگزین کارآمد از نظر حافظه، و ملاحظات عملی برای پیاده‌سازی‌های جدول هش دنیای واقعی را پوشش می‌دهد.

جدول هشتابع هشآدرس‌دهی باز

~7 دقیقه مطالعه · آخرین به‌روزرسانی ۱۶ شهریور ۱۴۰۵

ایده انگیزاننده: جداول آدرس‌دهی مستقیم

اگر جهان کلیدهای ممکن کوچک باشد، یک Direct-Address Table (جدول آدرس‌دهی مستقیم) یک راه‌حل ساده و بسیار سریع ارائه می‌دهد: یک آرایه که مستقیماً توسط خود کلید ایندکس می‌شود، جایی که هر اسلات عنصر با آن کلید را نگه می‌دارد (یا خالی است).

DIRECT-ADDRESS-SEARCH(T, k):
  T[k] را برگردان

DIRECT-ADDRESS-INSERT(T, x):
  T[x.key] = x

DIRECT-ADDRESS-DELETE(T, x):
  T[x.key] = NIL

هر عملیات در زمان O(1) اجرا می‌شود — به‌سرعتی که ممکن است. مشکل حافظه است: این نیازمند تخصیص یک آرایه به‌بزرگی کل جهان کلیدهای ممکن است، که هروقت جهان بزرگ باشد (مانند همه رشته‌های ممکن) یا وقتی فقط بخش کوچکی از کلیدهای ممکن واقعاً استفاده می‌شوند، غیرعملی است.

جداول هش: مبادله برخی تضمین‌ها برای صرفه‌جویی عظیم فضا

یک Hash Table (جدول هش) این مسئله فضا را با استفاده از یک آرایه بسیار کوچک‌تر به اندازه m، و یک Hash Function (تابع هش) h(k) که هر کلید از جهان بزرگ را به یک ایندکس در [0, m-1] نگاشت می‌کند، حل می‌کند. این به‌طور چشمگیری مصرف حافظه را کاهش می‌دهد، به قیمت یک مسئله جدید: از آنجا که بسیاری کلیدهای ممکن به همان مجموعه کوچک از ایندکس‌ها نگاشت می‌شوند، دو کلید متفاوت می‌توانند به همان اسلات هش شوند، رویدادی به نام Collision (برخورد).

مدیریت برخوردها با زنجیره‌بندی

Chaining (زنجیره‌بندی) برخوردها را با ذخیره همه عناصری که به همان اسلات هش می‌شوند در یک لیست پیوندی، که پیش‌تر در این مجموعه بحث شد، ریشه‌دار در آن اسلات، حل می‌کند.

CHAINED-HASH-INSERT(T, x):
  x را در ابتدای لیست T[h(x.key)] درج کن

CHAINED-HASH-SEARCH(T, k):
  یک عنصر با کلید k را در لیست T[h(k)] جستجو کن

CHAINED-HASH-DELETE(T, x):
  x را از لیست T[h(x.key)] حذف کن

درج همیشه O(1) است، چون صرفاً به ابتدای یک لیست اضافه می‌کند بدون نیاز به جستجو. جستجو و حذف به طول لیست بستگی دارند، که به این بستگی دارد چند کلید در آن اسلات خاص برخورد کرده‌اند.

تحلیل کارایی مورد انتظار

Load Factor (فاکتور بار) α = n/m را تعریف کن، میانگین تعداد عناصر به‌ازای هر اسلات، جایی که n تعداد عناصر ذخیره‌شده و m تعداد اسلات‌هاست. تحت فرض Simple Uniform Hashing (هشینگ یکنواخت ساده) — که هر کلید مشخص به‌طور برابر احتمال دارد به هر یک از m اسلات هش شود، مستقل از کلیدهای دیگر — طول مورد انتظار هر زنجیره دقیقاً α است.

زمان مورد انتظار برای یک جستجوی ناموفق: Θ(1 + α)
زمان مورد انتظار برای یک جستجوی موفق:     Θ(1 + α)

اگر m متناسب با n انتخاب شود (پس α = O(1))،
همه عملیات‌ها در زمان مورد انتظار Θ(1) اجرا می‌شوند

این نتیجه مستقیماً به تکنیک‌های تحلیل احتمالاتی که پیش‌تر در این مجموعه بحث شد متصل می‌شود: زمان جستجوی مورد انتظار هزینه O(1) محاسبه تابع هش را با طول زنجیره مورد انتظار ترکیب می‌کند، که تا زمانی که اندازه جدول متناسب با تعداد عناصر ذخیره‌شده رشد کند ثابت می‌ماند.

طراحی توابع هش خوب

کل تحلیل زمان-مورد-انتظار بالا به توزیع تقریباً یکنواخت کلیدها در سراسر اسلات‌ها توسط تابع هش بستگی دارد. یک تابع هش ضعیف‌انتخاب‌شده می‌تواند کارایی را به همان یک لیست پیوندی واحد تنزل دهد، Θ(n)، صرف‌نظر از تضمین‌های نظری، اگر بسیاری کلید به همان اسلات‌ها برخورد کنند.

روش تقسیم

ساده‌ترین رویکرد یک کلید را با استفاده از باقی‌مانده پس از تقسیم به یک اسلات نگاشت می‌کند:

h(k) = k mod m

این محاسبه‌اش سریع است اما به انتخاب m حساس است. انتخاب m به‌عنوان یک توان دو یک اشتباه رایج است، چون باعث می‌شود h(k) فقط به بیت‌های کم‌ارزش k بستگی داشته باشد، و بیت‌های با‌ارزش‌تر را کاملاً نادیده می‌گیرد، که می‌تواند توزیع ضعیفی برای الگوهای کلید خاص ایجاد کند. انتخاب m به‌عنوان یک عدد اول که خیلی نزدیک به یک توان دو نیست معمولاً توزیع‌های بهتر و یکنواخت‌تری در عمل تولید می‌کند.

روش ضرب

یک رویکرد جایگزین کلید را در یک ثابت A بین ۰ و ۱ ضرب می‌کند، بخش اعشاری را استخراج می‌کند، و آن را به اندازه جدول مقیاس می‌کند:

h(k) = ⌊m · (k · A mod 1)⌋

این روش این مزیت را دارد که مقدار خاص m برای کارایی‌اش حیاتی نیست، برخلاف روش تقسیم، که انعطاف‌پذیری بیشتری در انتخاب اندازه جدول (اغلب یک توان دو، برای راحتی پیاده‌سازی) می‌دهد.

آدرس‌دهی باز: یک جایگزین برای زنجیره‌بندی

Open Addressing (آدرس‌دهی باز) کاملاً از لیست‌های پیوندی اجتناب می‌کند، و همه عناصر را مستقیماً درون خود آرایه جدول هش ذخیره می‌کند. وقتی برخوردی رخ می‌دهد، الگوریتم به‌طور سیستماتیک اسلات‌های جایگزین را کاوش می‌کند تا یکی خالی پیدا شود، با دنبال کردن یک Probe Sequence (توالی کاوش) قطعی تعیین‌شده توسط کلید.

HASH-INSERT(T, k):
  i = 0
  تکرار کن:
      j = h(k, i)
      اگر T[j] == NIL:
          T[j] = k
          j را برگردان
      در غیر این صورت:
          i = i + 1
  تا i == m
  خطا "hash table overflow"

کاوش خطی

ساده‌ترین توالی کاوش، Linear Probing (کاوش خطی)، اسلات‌های پیاپی را پس از یک برخورد بررسی می‌کند:

h(k, i) = (h'(k) + i) mod m

این ساده است و کارایی کش خوبی دارد، که پیش‌تر در این مجموعه درباره سلسله‌مراتب حافظه بحث شد، چون مکان‌های حافظه پیاپی بررسی می‌شوند. با این حال، از Primary Clustering (خوشه‌بندی اولیه) رنج می‌برد: دنباله‌های طولانی از اسلات‌های اشغال‌شده پیاپی تمایل به شکل‌گیری دارند، چون هر کلیدی که به هرجایی درون یک خوشه هش شود آن را بیشتر گسترش می‌دهد، که برخوردهای آینده را به‌طور فزاینده محتمل‌تر می‌کند.

کاوش درجه‌دوم و هشینگ دوگانه

Quadratic Probing (کاوش درجه‌دوم) از یک تابع درجه‌دوم از شماره کاوش برای پخش بیشتر کاوش‌ها استفاده می‌کند:

h(k, i) = (h'(k) + c₁i + c₂i²) mod m

Double Hashing (هشینگ دوگانه) از یک تابع هش دوم و مستقل برای تعیین اندازه گام بین کاوش‌ها استفاده می‌کند، که معمولاً یکنواخت‌ترین توزیع را از میان سه رویکرد تولید می‌کند و در عمل نزدیک‌ترین به ایده‌آل نظری هشینگ یکنواخت است.

h(k, i) = (h₁(k) + i · h₂(k)) mod m

مسئله حذف در آدرس‌دهی باز

حذف در آدرس‌دهی باز ظریف‌تر از زنجیره‌بندی است. صرفاً علامت‌گذاری یک اسلات به‌عنوان خالی پس از حذف می‌تواند جستجوهای آینده را بشکند، چون توالی کاوش برای برخی کلید دیگر ممکن است به عبور از آن اسلات اکنون-خالی برای یافتن موقعیت واقعی‌اش جلوتر متکی باشد. راه‌حل استاندارد از یک نشانگر خاص DELETED متمایز از NIL استفاده می‌کند: جستجوها فراتر از یک نشانگر DELETED ادامه می‌یابند، اما درج‌ها می‌توانند آن اسلات را دوباره استفاده کنند.

ملاحظات عملی

پیاده‌سازی‌های جدول هش دنیای واقعی باید چند مسئله عملی فراتر از الگوریتم اصلی را مدیریت کنند. با رشد فاکتور بار به‌اندازه‌ای زیاد، کارایی تنزل می‌یابد، پس پیاده‌سازی‌ها معمولاً Rehash (دوباره‌هش) می‌کنند — یک جدول بزرگ‌تر تخصیص می‌دهند و هر عنصر را دوباره درج می‌کنند — وقتی فاکتور بار از یک آستانه، مانند ۰.۷۵، فراتر رود. این عملیات تغییر اندازه وقتی رخ می‌دهد گران است، اما به‌اندازه کافی به‌ندرت رخ می‌دهد (تقریباً دو برابر کردن اندازه جدول هر بار) که هزینه‌اش، وقتی میانگین‌گیری شود (یا Amortized (مستهلک)، مفهومی که بعداً در این مجموعه به‌طور عمیق بررسی می‌شود) در سراسر بسیاری درج، O(1) به‌ازای هر درج باقی می‌ماند.

چرا جداول هش این‌قدر گسترده استفاده می‌شوند

جداول هش زیربنای بسیاری از پیاده‌سازی‌های آرایه‌های انجمنی، دیکشنری، و مجموعه‌ای هستند که در تقریباً هر کتابخانه استاندارد زبان برنامه‌نویسی امروزی یافت می‌شوند. کارایی مورد انتظار O(1)شان برای جستجو، درج، و حذف، آن‌ها را به انتخاب پیش‌فرض هروقت جستجوی سریع مبتنی-بر-کلید مورد نیاز باشد تبدیل می‌کند، بهبود چشمگیری نسبت به O(log n) درخت‌های جستجوی متوازن، که بعداً در این مجموعه بحث می‌شوند، هروقت ترتیب کلیدها مورد نیاز نباشد و فقط جستجوی سریع اهمیت داشته باشد.

نوشته و پژوهش‌شده توسط دکتر شاهین صیامی

مقالات مرتبط

توضیح طولانی‌ترین زیردنباله مشترک و درخت‌های جستجوی دودویی بهینه

دو مسئله کلاسیک برنامه‌نویسی پویای دیگر، تطبیق‌پذیری این تکنیک را فراتر از بهینه‌سازی عددی نشان می‌دهند: یافتن طولانی‌ترین زیردنباله مشترک بین دو رشته، سنگ‌بنای ابزارهای diff و بیوانفورماتیک، و ساخت یک درخت جستجوی دودویی که هزینه جستجوی مورد انتظار را با فرکانس‌های دسترسی شناخته‌شده کمینه می‌کند. این راهنمای جامع هر دو الگوریتم را با جزئیات کامل، شامل استخراج رابطه بازگشتی، ساخت جدول، و بازسازی راه‌حل، مرور می‌کند.

ادامه

پایه‌های برنامه‌نویسی پویا: برش میله، زنجیره ماتریس، و اصول اصلی

برنامه‌نویسی پویا مسائل پیچیده را با شکستن آن‌ها به زیرمسئله‌های هم‌پوشان و ذخیره راه‌حل‌ها برای اجتناب از محاسبه زائد حل می‌کند. این راهنمای جامع تکنیک را از طریق مسئله کلاسیک برش میله معرفی می‌کند، آن را به مسئله جزئی‌تر ضرب زنجیره‌ای ماتریس گسترش می‌دهد، و دو ویژگی ضروری — زیرساختار بهینه و زیرمسئله‌های هم‌پوشان — که تعیین می‌کنند چه زمانی برنامه‌نویسی پویا اعمال می‌شود را استخراج می‌کند.

ادامه

درخت‌های قرمز-سیاه: چگونه درخت‌های جستجوی خودمتوازن‌کننده ارتفاع لگاریتمی را تضمین می‌کنند

درخت جستجوی دودویی ساده که پیش‌تر در این مجموعه پوشش داده شد، می‌تواند تحت ترتیب‌های درج بدشانس به ارتفاع خطی تنزل یابد. درخت‌های قرمز-سیاه این را با حفظ پنج ناوردای ساده که از نظر ریاضی ارتفاع لگاریتمی را صرف‌نظر از ترتیب درج تضمین می‌کنند حل می‌کنند. این راهنمای جامع ویژگی‌های قرمز-سیاه، عملیات چرخش که ساختار درخت-جستجو را حین بازساختاردهی درخت حفظ می‌کند، و اینکه درج و حذف چگونه با منطق تعادل‌مجدد گسترش می‌یابند تا این تضمین‌ها را حفظ کنند را پوشش می‌دهد.

ادامه

درخت‌های جستجوی دودویی: پرس‌وجو، درج، و حذف کارآمد

یک درخت جستجوی دودویی عناصر را به ترتیب مرتب‌شده نگه می‌دارد در حالی که جستجو، درج، و حذف کارآمد را پشتیبانی می‌کند، همگی در زمانی متناسب با ارتفاع درخت. این راهنمای جامع ویژگی تعریف‌کننده درخت-جستجوی-دودویی، عملیات‌های اصلی پرس‌وجو شامل جستجو، حداقل، حداکثر، و جانشین، و رویه‌های جزئی‌تر درج و حذف که باید ساختار درخت را با دقت حفظ کنند را پوشش می‌دهد.

ادامه

ساختارهای داده ابتدایی: پشته‌ها، صف‌ها، لیست‌های پیوندی، و درخت‌ها

پیش از پرداختن به ساختارهای داده پیشرفته، تسلط بر بلوک‌های سازنده ابتدایی ضروری است، چون تقریباً هر ساختار پیچیده‌ای از این پایه‌ها ساخته می‌شود. این راهنمای جامع پشته‌ها و صف‌های مبتنی بر آرایه، لیست‌های پیوندی تکی و دوگانه، و تکنیک‌های استاندارد نمایش درخت‌های ریشه‌دار را پوشش می‌دهد، شامل نمایش ظریف فرزند-چپ راهبر-راست برای درخت‌هایی با شاخه‌بندی نامحدود.

ادامه

یافتن میانه بدون مرتب‌سازی کامل: الگوریتم‌های انتخاب زمان-خطی

یافتن k-امین کوچک‌ترین عنصر در یک آرایه مرتب‌نشده نیازمند هزینه کامل Θ(n log n) مرتب‌سازی نیست؛ می‌تواند در زمان خطی انجام شود. این راهنمای جامع مورد بدیهی یافتن حداقل یا حداکثر، یک الگوریتم انتخاب تصادفی ظریف با زمان مورد انتظار خطی، و یک الگوریتم قطعی پیچیده‌تر که زمان خطی را حتی در بدترین‌حالت تضمین می‌کند را پوشش می‌دهد.

ادامه