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

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

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

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

مسئله طولانی‌ترین زیردنباله مشترک

یک Subsequence (زیردنباله) از یک رشته با حذف صفر یا چند کاراکتر بدون تغییر ترتیب کاراکترهای باقی‌مانده استخراج می‌شود. مسئله Longest Common Subsequence (LCS) می‌پرسد: با داشتن دو دنباله، طولانی‌ترین زیردنباله مشترک بین هر دو را پیدا کن.

مثال:
X = "ABCBDAB"
Y = "BDCABA"

یک زیردنباله مشترک: "BCBA" (طول 4)
دیگری: "BDAB" (طول 4)
LCS در این حالت طول 4 دارد

این مسئله زیربنای ابزار diff مورد استفاده برای مقایسه نسخه‌های فایل، مقایسه توالی DNA در بیوانفورماتیک، و ابزارهای تشخیص سرقت ادبی است، که آن را به یکی از عملی‌ترین کاربردهای برنامه‌نویسی پویا تبدیل می‌کند.

ایجاد زیرساختار بهینه

فرض کن Xᵢ نشان‌دهنده اولین i کاراکتر رشته X باشد، و به‌طور مشابه برای Yⱼ. بینش ساختاری کلیدی، آخرین کاراکترهای هر دو دنباله را در نظر می‌گیرد.

اگر X[i] == Y[j]:
    LCS از Xᵢ و Yⱼ، LCS از Xᵢ₋₁ و Yⱼ₋₁ را
    دقیقاً با این کاراکتر متناظر گسترش می‌دهد

اگر X[i] ≠ Y[j]:
    LCS از Xᵢ و Yⱼ طولانی‌تر از این‌هاست:
    - LCS از Xᵢ₋₁ و Yⱼ (آخرین کاراکتر X را حذف کن)
    - LCS از Xᵢ و Yⱼ₋₁ (آخرین کاراکتر Y را حذف کن)

این رابطه بازگشتی برای c[i][j]، طول LCS از Xᵢ و Yⱼ، را می‌دهد:

c[i][j] = 0                              اگر i == 0 یا j == 0
c[i][j] = c[i-1][j-1] + 1                اگر i,j > 0 و X[i] == Y[j]
c[i][j] = max(c[i-1][j], c[i][j-1])      اگر i,j > 0 و X[i] ≠ Y[j]

ساخت راه‌حل پایین-به-بالا

LCS-LENGTH(X, Y, m, n):
  فرض کن c[0..m][0..n] و b[1..m][1..n] جدول‌های جدید باشند
  برای i = 1 تا m:
      c[i][0] = 0
  برای j = 0 تا n:
      c[0][j] = 0
  برای i = 1 تا m:
      برای j = 1 تا n:
          اگر X[i] == Y[j]:
              c[i][j] = c[i-1][j-1] + 1
              b[i][j] = "قطری"
          در غیر این صورت اگر c[i-1][j] ≥ c[i][j-1]:
              c[i][j] = c[i-1][j]
              b[i][j] = "بالا"
          در غیر این صورت:
              c[i][j] = c[i][j-1]
              b[i][j] = "چپ"
  c, b را برگردان

ردیابی مثال قبلی با X = "ABCBDAB" و Y = "BDCABA" جدولی را پر می‌کند که در نهایت c[7][6] مقدار ۴ را نگه می‌دارد، که با طول LCS پیدا‌شده با بازرسی بالا مطابقت دارد.

از آنجا که جدول Θ(mn) ورودی دارد و هرکدام O(1) زمان برای پر شدن با توجه به ورودی‌هایی که به آن‌ها بستگی دارد نیاز دارد، الگوریتم در زمان Θ(mn) اجرا می‌شود — بهبود چشمگیری نسبت به تعداد نمایی زیردنباله‌های ممکن که یک رویکرد brute-force ساده نیاز به بررسی آن‌ها دارد.

بازسازی زیردنباله واقعی

جدول کمکی b ثبت می‌کند کدام حالت در هر سلول اعمال شده، که اجازه می‌دهد طولانی‌ترین زیردنباله مشترک واقعی، نه فقط طولش، با ردیابی به‌عقب از b[m][n] تا مبدأ، با دنبال کردن حرکت‌های "قطری" برای جمع‌آوری کاراکترهای متناظر، و حرکت‌های "بالا" یا "چپ" برای پرش از موقعیت‌های غیرمتناظر، بازسازی شود.

درخت‌های جستجوی دودویی بهینه: نوع متفاوتی از بهینه‌سازی

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

با داشتن n کلید متمایز با احتمال‌های جستجوی شناخته‌شده p₁, ..., pₙ، مسئله Optimal Binary Search Tree (درخت جستجوی دودویی بهینه) ساختار درخت جستجوی دودویی‌ای می‌خواهد که هزینه کل جستجوی مورد انتظار را کمینه کند.

هزینه جستجوی مورد انتظار یک درخت T:
E[هزینه جستجو] = Σ (i=1 تا n) (depth_T(kᵢ) + 1) · pᵢ

جایی که depth_T(kᵢ) عمق کلید kᵢ در درخت T است
(ریشه عمق 0 دارد، پس دسترسی به آن 1 مقایسه هزینه دارد)

ایجاد ساختار بازگشتی

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

فرض کن e[i][j] = هزینه مورد انتظار یک BST بهینه
              شامل کلیدهای kᵢ تا kⱼ

فرض کن w[i][j] = مجموع احتمال‌های pᵢ تا pⱼ
              (این افزایش هزینه اضافه‌کردن یک سطح دیگر
               به هر گره در زیردرخت را در نظر می‌گیرد،
               چون انتخاب یک ریشه عمق هر نواده
               را دقیقاً یک واحد افزایش می‌دهد)

e[i][j] = حداقل روی r (i ≤ r ≤ j) از:
            e[i][r-1] + e[r+1][j] + w[i][j]

حالت پایه: e[i][i-1] = 0  (زیردرخت خالی)

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

پر کردن جدول پایین-به-بالا

OPTIMAL-BST(p, n):
  فرض کن e[1..n+1][0..n], w[1..n+1][0..n], root[1..n][1..n] جدول‌های جدید باشند
  برای i = 1 تا n + 1:
      e[i][i-1] = 0
      w[i][i-1] = 0
  برای length = 1 تا n:
      برای i = 1 تا n - length + 1:
          j = i + length - 1
          e[i][j] = infinity
          w[i][j] = w[i][j-1] + p[j]
          برای r = i تا j:
              t = e[i][r-1] + e[r+1][j] + w[i][j]
              اگر t < e[i][j]:
                  e[i][j] = t
                  root[i][j] = r
  e, root را برگردان

الگوریتم با افزایش طول زیردرخت پیش می‌رود، دقیقاً مانند ضرب زنجیره‌ای ماتریس، چون e[i][j] فقط به ورودی‌های شامل بازه‌های دقیقاً کوتاه‌تر بستگی دارد. با Θ(n²) ورودی جدول، هرکدام نیازمند O(n) کار برای امتحان هر ریشه ممکن، زمان اجرای کل Θ(n³) است، که دقیقاً با پیچیدگی ضرب زنجیره‌ای ماتریس مطابقت دارد، انعکاسی از شباهت ساختاری بین دو مسئله.

چرا این دو مسئله دامنه برنامه‌نویسی پویا را نشان می‌دهند

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

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

مقالات مرتبط

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

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

ادامه

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

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

ادامه

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

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

ادامه

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

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

ادامه

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

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

ادامه

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

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

ادامه