طراحی الگوریتم · فصل دوم · صفحه‌های ۳۷ تا ۵۷

روش تقسیم و غلبه (Divide and Conquer)

کل فصل در یک جمله: مسئله را به چند نمونه‌ی کوچک‌تر بشکن، هر کدام را جدا حل کن، بعد جواب‌ها را بچسبان. پنج مثال این فصل فقط پنج شکل مختلفِ همین جمله‌اند.

۰ مفهوم تقسیم و غلبه

نام این روش از یک تکنیک جنگی گرفته شده: در سال ۱۸۰۵ ارتش روسی و اتریشی با بیش از ۱۵ هزار نفر به جنگ ناپلئون آمدند. ناپلئون با تقسیم نیروهای دشمن به دو بخش، بر آن‌ها پیروز شد؛ در واقع با تقسیم سپاه بزرگ به دو سپاه کوچک‌تر و پیروز شدن بر تک‌تک آن‌ها، موفق شد آن سپاه بزرگ را شکست دهد.

در تکنیک تقسیم و غلبه، یک مسئله به چند نمونه‌ی کوچک‌تر تقسیم می‌شود، سپس این نمونه‌های کوچک‌تر حل شده و با ترکیب جواب آن‌ها، جواب مسئله‌ی اصلی حاصل می‌شود. اگر این نمونه‌های کوچک‌تر قابل حل نبودند، آن‌قدر آن‌ها را به نمونه‌های کوچک‌تر تقسیم می‌کنیم تا بالاخره حل شوند. این روش در واقع یک روش حل از بالا به پایین (Top-down) است.

سه مرحله‌ی همیشگی
  1. تقسیم (Divide): مسئله را به یک یا چند نمونه‌ی کوچک‌تر بشکن.
  2. حل (Conquer): هر نمونه را حل کن. اگر به قدر کافی کوچک نبود، دوباره بازگشتی تقسیمش کن.
  3. ترکیب (Combine): جواب نمونه‌های کوچک را ترکیب کن تا جواب مسئله‌ی اصلی به دست آید.
تذکر مهم کتاب هر مسئله‌ای با تقسیم و غلبه حل نمی‌شود. مثلاً برای حل ۱۰ معادله‌ی ۱۰ مجهولی نمی‌توان آن‌ها را به دو مسئله‌ی ۵ معادله‌ی ۵ مجهولی تقسیم کرد؛ چون معادله‌ها به هم وابسته‌اند. در مقابل، در یک آرایه‌ی ۱۰ خانه‌ای می‌توان آرایه را به دو آرایه‌ی ۵ عنصری تقسیم کرد و عنصر مورد نظر را در یکی از آن دو پیدا کرد — چون خانه‌ها مستقل‌اند.

۱ مثال اول — جستجوی دودویی (Binary Search)

جستجوی دودویی فقط در آرایه‌های مرتب کار می‌کند. ایده: عنصر مورد نظر را با خانه‌ی وسط مقایسه کن.

مثال کامل: پیدا کردن ۴۴ در آرایه‌ی ۱۲ عنصری

خانه۱۲۳۴۵۶۷۸۹۱۰۱۱۱۲
مقدار122025272930334445677880
گاممحدودهmidمقایسهتصمیم
۱Low=1 , High=12(1+12)/2 = 6x[6]=30۳۰ < ۴۴ ⟹ نیمه‌ی بالایی، Low = 7
۲Low=7 , High=12(7+12)/2 = 9x[9]=45۴۵ > ۴۴ ⟹ نیمه‌ی پایینی، High = 8
۳Low=7 , High=8(7+8)/2 = 7x[7]=33۳۳ < ۴۴ ⟹ Low = 8
۴Low=8 , High=88x[8]=44برابر شد ⟹ پیدا شد
مقایسه با ۴ مقایسه پیدا شد، در حالی که جستجوی خطی به ۸ مقایسه نیاز داشت.

کد تکراری (Iterative)

int bsearch(int x[ ], int n, int m)
{
    int low, high, mid;
    low=0;   high=n-1;
    while(low<=high) {
        mid=(low + high)/2;
        if(m<x[mid])   high=mid-1;
        else if(m>x[mid])  low=mid+1;
        else return mid;
    }
    return -1;
}

کد بازگشتی (Recursive)

int bsearch(int low, int high, int m, int x[ ])
{
    int mid;
    if (low > high)  return -1;
    else {
        mid=(low + high)/2;
        if(m==x[mid])      return mid;
        else if(m<x[mid])
            return bsearch(low, mid-1, m, x);
        else return bsearch(mid+1, high, m, x);
    }
}
نکته ۱ — بازگشتی دنباله‌ای (Tail Recursion) این نوع بازگشتی، بازگشتی انتهایی است: بعد از فراخوانی بازگشتی هیچ عملی انجام نمی‌شود. هر تابع بازگشتی دنباله‌ای را می‌توان به نسخه‌ی تکراری (حلقه) تبدیل کرد؛ نسخه‌ی تکراری از نظر سرعت بهتر و از نظر مصرف حافظه پشته‌ای (Stack) هم بهینه‌تر است.
نکته ۲ — چرا این روش بهینه است؟ جستجوی دودویی سریع‌ترین الگوریتم جستجو در آرایه‌ی مرتب است. در بدترین حالت، هر بار اندازه‌ی مسئله نصف می‌شود.

تحلیل زمانی

در بدترین حالت، عدد مورد جستجو (بزرگ‌ترین عنصر آرایه) در نهایت در خانه‌ای است که تنها یک عمل مقایسه لازم دارد:

W(n) = W(⌊n/2⌋) + 1   ,   W(1) = 1

با باز کردن ساده (بدون هیچ تغییر متغیری — چون اندازه هر بار نصف می‌شود، نه جذر):

W(n) = W(n/2) + 1 = W(n/4) + 2 = … = W(n/2k) + k

توقف وقتی n/2k = 1 یعنی k = log₂ n، و چون W(1) = 1:

جواب
W(n) = ⌊log₂ n⌋ + 1 = Θ(log n)

و با قضیه‌ی کمکی T(n) = aT(n/b) + c:

a ≠ 1 ⟹ T(n) = Θ(nlogba)
a = 1 ⟹ T(n) = Θ(logb n)

اینجا a=1, b=2, c=1 ⟹ W(n) = Θ(log n).

در مثال قبلی با ۱۲ خانه، حداکثر ⌊log₂ 12⌋ + 1 = 4 مقایسه داشتیم ✔ — پس ⌊log₂ n⌋ + 1 برابر ارتفاع درخت دودویی متعادل است.

۲ میانگین مقایسه‌ها: موفق S(n) و ناموفق U(n)

فرض کنید آرایه‌ی ۵ خانه‌ای 11 22 33 44 55 مرتب است. درخت جستجوی دودویی (BST) آن:

سطح ۱: 33→ سطح ۲: 11 , 44→ سطح ۳: 22 , 55

در درخت BST، مقدار هر گره = تعداد مقایسه‌ها برای یافتن آن عنصر (ریشه ۱ مقایسه، فرزندان ۲ مقایسه، ...).

میانگین جستجوی موفق S(n)

S(n) = (1 + 2 + 2 + 3 + 3) / 5 = 11/5 = 2.2

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

S(n) = (1×1 + 2×2 + 4×3 + 2×4) / 9 = (1+4+12+8)/9 = 25/9

(این نمونه برای آرایه‌ی ۹ عنصری است که درختش سطح ۴ هم دارد.)

میانگین جستجوی ناموفق U(n)

برای جستجوی ناموفق، گره‌های فرضی (مربعی) را به شکل درخت رسم می‌کنیم و همان روش را تکرار می‌کنیم:

U(n) = (6×3 + 4×4) / 10 = (18+16)/10 = 34/10

برای مثال ۵ عنصری قبلی هم: U(n) = (2+2+3+3+3+3)/6 = 16/6.

رابطه‌ی مهم بین S و U
S(n) = (1 + 1/n) · U(n) − 1  ⟹  S(n) = Θ(U(n))

تست با اعداد مثال: S = (1 + 1/5)(16/6) − 1 = 16/5 − 1 = 11/5 ✔

نتیجه‌ی نهایی جستجوی دودویی تعداد مقایسه‌ها برای آرایه‌ی n عنصری در بدترین حالت ⌊log₂ n⌋ + 1 است؛ پس جستجوی دودویی در حالت ناموفق هم‌مرتبه با Θ(log₂ n) است. برای یافتن عنصر در حالت موفق: بهترین حالت ۱ مقایسه، بدترین ⌊log₂ n⌋ + 1، و میانگین از مرتبه‌ی Θ(log₂ n).

۳ مثال دوم — مرتب‌سازی ادغامی (Merge Sort)

قبل از توضیح مرتب‌سازی ادغامی، باید بدانیم ادغام دو لیست مرتب در یک لیست مرتب جدید چگونه است.

ادغام (Merge): دو دسته‌ی مرتب داریم. در هر مرحله کارت جلویی هر دسته را با کارت جلویی دسته‌ی مقابل مقایسه می‌کنیم و کارت با شماره‌ی کوچک‌تر را در دسته‌ی جدید قرار می‌دهیم. هرگاه یکی از دسته‌ها خالی شد، تمام کارت‌های باقی‌مانده در دسته‌ی دیگر را در انتهای دسته‌ی جدید قرار می‌دهیم.

کد ادغام

void merge(int p, int m, int A[ ], int B[ ], int S[ ])
{
    int i,j,k;
    i=j=k=0;
    while(i<p && j<m) {
        if(A[i]<B[j]) { S[k]=A[i]; i++; }
        else          { S[k]=B[j]; j++; }
        k++;
    }
    if(i==p)
        while(j<m)     // تمام مابقی عناصر B را در انتهای S قرار بده
        { S[k]=B[j]; k++; j++; }
    else if(j==m)
        while(i<p)     // تمام مابقی عناصر A را در انتهای S قرار بده
        { S[k]=A[i]; k++; i++; }
}

تعداد مقایسه‌های ادغام

بهترین حالت
B(p,m) = Min(p,m)

وقتی همه‌ی عناصر یک آرایه از اولین عنصر آرایه‌ی دیگر کوچک‌تر باشند؛ مثلاً A = 3 5 6 8 و B = 12 18 25 29 36 ⟹ فقط ۴ مقایسه.

بدترین حالت
W(p,m) = p + m − 1

وقتی همه‌ی عناصر آرایه‌ی اول (به جز آخرین عضو) از اولین عنصر آرایه‌ی دوم کوچک‌تر باشند و آن آخرین عضو از تمام عناصر آرایه‌ی دوم بزرگ‌تر باشد ⟹ ۸ مقایسه.

اگر مجموع عناصر A و B را p + m = n بگیریم، آنگاه:

W(n) = n − 1 = Θ(n)

الگوریتم مرتب‌سازی ادغامی

  1. آرایه را به دو قسمت، هر یک با n/2 عنصر، تقسیم کن.
  2. با روش ادغام، هر زیرآرایه را مرتب کن. اگر آرایه به قدر کافی کوچک نبود، بازگشتی آن را کوچک‌تر و حل کن.
  3. از طریق الگوریتم ادغام، زیرآرایه‌های مرتب‌شده را در یک آرایه‌ی مرتب ترکیب کن.
28 11 13 21 26 14 16 23← 28 11 13 2126 14 16 23
28 1113 2126 1416 23
11 2813 2114 2616 23
11 13 21 2814 16 23 26
11 13 14 16 21 23 26 28

کد بازگشتی

void mergesort(int n, int S[ ])
{
    if(n>1) {
        Copy S[0] through S[{n/2}-1] to A[0] through A[{n/2}-1];
        Copy S[n/2] through S[n-1] to B[0] through B[{n/2}-1];
        mergesort(n/2, A);
        mergesort(n/2, B);
        merge(n/2, n/2, A, B, S);
    }
}

تابع mergesort به صورت بازگشتی ۲ بار خودش را صدا می‌زند و پس از آن تابع ادغام merge فراخوانی می‌شود.

تحلیل زمانی در بدترین حالت

W(n) = W(n/2) + W(n/2) + (n−1)
W(n) = 2W(n/2) + n − 1   ,   W(1) = 0

با قضیه‌ی اصلی: a=2, b=2, k=1 ⟹ nlog₂2 = n1 = n ⟹ حالت تساوی:

W(n) = Θ(n log n)
قضیه‌ی اصلی (مرجع)
T(n) = aT(n/b) + Cnk
nlogba > nk ⟹ T(n) = Θ(nlogba)
nlogba = nk ⟹ T(n) = Θ(nk log₂ n)
nlogba < nk ⟹ T(n) = Θ(nk)

در حالت‌های خوب و متوسط هم مرتبه‌ی Θ(n log n) است — یعنی در هر حال مرتب‌سازی ادغامی همیشه n log n است.

پیچیدگی مصرف حافظه

در الگوریتم مرتب‌سازی ادغامی، در هر مرحله نیاز به حافظه‌ی کمکی به اندازه‌ی n خانه داریم؛ مثلاً در مرحله‌ی اول که آرایه‌ی S به دو آرایه‌ی A و B تقسیم می‌شود، جمع خانه‌های A و B برابر n است.

نتیجه

با بهینه‌سازی می‌توان merge و mergesort را طوری نوشت که مرتبه‌ی فضای مصرفی Θ(n) باشد (به جای Θ(n log n)).

تذکر برای مرتب‌سازی داده‌های موجود روی حافظه‌های جانبی (مثل مرتب کردن محتویات فایلی بزرگ روی هارددیسک)، بهترین راه‌حل الگوریتم ادغامی است. به مرتب‌سازی داده‌های حافظه‌ی جانبی، مرتب‌سازی خارجی گفته می‌شود.

۴ مثال سوم — مرتب‌سازی سریع (Quick Sort)

قبل از توضیح این مرتب‌سازی، لازم است الگوریتم مهم پارتیشن (Partition) را بیان کنیم.

در الگوریتم پارتیشن، یک عنصر محوری یا لولا (Pivot) انتخاب شده و سایر عناصر بندابه‌به گونه‌ای جابه‌جا می‌شوند که کلیه‌ی عناصر بزرگ‌تر از آن در یک طرف و کلیه‌ی عناصر کوچک‌تر از آن در طرف دیگر عنصر لولا قرار گیرند. عنصر لولا اغلب اولین عنصر سمت چپ آرایه فرض می‌شود. در واقع با الگوریتم پارتیشن، جای درست قرارگیری عنصر pivot در آرایه‌ی مرتب‌شده‌ی نهایی را به دست می‌آوریم.

مثال پارتیشن

index0123456789
x265371611159154819

در ابتدا i به اولین خانه و j به آخرین خانه اشاره می‌کند و pivot برابر x[0] یعنی ۲۶ است.

26   5   19   1   61   11   59   15   48   37

دوباره حلقه تکرار می‌شود: i به ۶۱ و j به ۱۵ می‌رسند ⟹ جابه‌جا می‌شوند:

26   5   19   1   15   11   59   61   48   37

عملیات تا آنجا تکرار می‌شود که i و j از یکدیگر عبور کنند (i >= j). در مرحله‌ی بعد j به ۱۱ و i به ۵۹ اشاره می‌کنند ⟹ i>=j ⟹ تمام. در نهایت ۲۶ با x[j] یعنی ۱۱ جابه‌جا می‌شود:

11   5   19   1   15   26   59   61   48   37
نتیجه همه‌ی اعداد سمت راست ۲۶ از آن بزرگ‌تر و همه‌ی اعداد سمت چپ از آن کوچک‌تر هستند. یعنی ۲۶ در جای درست خود در آرایه‌ی مرتب‌شده قرار گرفته است.

کد پارتیشن

int partition(int x[ ], int left, int right)
{
    int t;
    int i=left;  int j=right+1;  int pivot=x[left];
    do {
        do i++;
        while(x[i]<pivot);
        do j--;
        while(x[j]>pivot);
        if(i<j) {
            t=x[i];  x[i]=x[j];  x[j]=t;
        }
    } while(i<j);
    t=x[left];  x[left]=x[j];  x[j]=t;
    return j;
}

left اندیس خانه سمت چپ آرایه و right اندیس خانه راست آرایه است.

هزینه‌ی پارتیشن

اگر عمل اصلی در تابع فوق را مقایسه‌ی خانه‌های آرایه‌ی x با pivot در نظر بگیریم، این تعداد مقایسه همواره برابر n−1 است؛ چرا که تمام عناصر به جز عنصر محور با pivot مقایسه می‌شوند:

T(n) = n − 1 = Θ(n)

الگوریتم مرتب‌سازی سریع

  1. یک عنصر لولا انتخاب شده و سایر عناصر با تابع partition به گونه‌ای جابه‌جا می‌شوند که کلیه‌ی عناصر بزرگ‌تر از آن در یک سمت و کلیه‌ی عناصر کوچک‌تر از آن در طرف دیگر لولا قرار گیرند.
  2. به روش بازگشتی، زیرآرایه‌ی سمت چپ لولا مرتب می‌شود.
  3. به روش بازگشتی، زیرآرایه‌ی سمت راست لولا مرتب می‌شود.
void quicksort(int x[ ], int left, int right)
{
    int pivotpoint;
    if(left<right) {
        pivotpoint=partition(x, left, right);
        quicksort(x, left, pivotpoint-1);   // مرتب‌سازی زیر لیست چپ
        quicksort(x, pivotpoint+1, right);  // مرتب‌سازی زیر لیست راست
    }
}

ردیابی کامل اجرا

#وضعیت آرایهتوضیح
۰[26 5 37 1 61 11 59 15 48 19]pivot = 26
۱[11 5 19 1 15] 26 [59 61 48 37]۲۶ در جای نهایی
۲[1 5] 11 [19 15] 26 [59 61 48 37]pivot = 11
۳[1] 5 11 [19 15] 26 [59 61 48 37]pivot = 5
۴1 5 11 [15 19] 26 [59 61 48 37]pivot = 19
۵1 5 11 15 19 26 [48 37] [59 61]pivot = 59
۶1 5 11 15 19 26 37 [48] [59 61]pivot = 48
۷1 5 11 15 19 26 37 48 59 61مرتب شد
تذکر — انتخاب لولا

انتخاب عنصر لولا تأثیر مهمی در سرعت اجرا دارد. اگر این عنصر، عنصر میانه‌ی آرایه باشد، آرایه را به دو زیرآرایه تقریباً مساوی تقسیم خواهیم نمود و می‌توان ثابت نمود در این شرایط بهترین حالت روی داده است.

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

عمل عنصر لولا به صورت تصادفی انتخاب می‌شود. یک روش مناسب دیگر برای انتخاب لولا روش میانه‌ی سه (Median of three) است که در آن میانه‌ی مقادیر کلیدهای اول، وسط و آخر به عنوان عنصر لولا می‌شود. مثلاً برای کلیدهای 26, 61, 19 داریم: Median(26, 61, 19) = 26.

تحلیل پیچیدگی زمانی

بدترین حالت

اگر عنصر لولا، عنصر اول به عنوان محور انتخاب شود، بدترین حالت زمانی رخ می‌دهد که آرایه از قبل مرتب باشد (یا به صورت نزولی مرتب شده باشد). چرا؟ چون تابع partition در هر بار مقدار left را به عنوان pivotpoint برمی‌گرداند و لذا آرایه به صورت یک زیرآرایه خالی در سمت چپ و یک زیرآرایه با n−1 عنصر در سمت راست تقسیم می‌شود:

W(n) = W(0) + W(n−1) + (n−1)
W(n) = W(n−1) + (n−1)  ,  W(0)=0
W(n) = n(n−1)/2 = Θ(n²)
بهترین حالت و میانگین
B(n) = 2B(n/2) + Θ(n)  ⟹  B(n) = Θ(n log n)
A(n) = Θ(n log n)

در حالت متوسط، آرایه تقریباً نصف می‌شود و به همان رابطه‌ی T(n) = 2T(n/2) + Θ(n) می‌رسیم.

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

مقایسه‌ی مرتب‌سازی‌ها

الگوریتمبهترین حالتحالت متوسطبدترین حالت
مرتب‌سازی سریعO(n log n)O(n log n)O(n²)
مرتب‌سازی ادغامO(n log n)O(n log n)O(n log n)

۵ مثال چهارم — ضرب ماتریس‌ها (Strassen)

الگوریتم ساده‌ی ضرب ماتریس‌های n×n:

for(i=1; i<=n; i++)
  for(j=1; j<=n; j++) {
      C[i][j]=0;
      for(k=1; k<=n; k++)
          C[i][j]=C[i][j]+A[i][k]*B[k][j];
  }

در این برنامه فوق دو ماتریس A و B ضرب شده و حاصل را در ماتریس C می‌ریزد. سه ماتریس n×n هستند.

تعداد ضرب‌ها = n³ = Θ(n³)   ،   تعداد جمع‌ها = n³ − n² = Θ(n³)

با تغییر ساده زیر می‌توان تعداد جمع‌ها را کاهش داد:

for(i=1; i<=n; i++)
  for(j=1; j<=n; j++) {
      C[i][j]=A[i][1]*B[1][j];
      for(k=2; k<=n; k++)
          C[i][j]=C[i][j]+A[i][k]*B[k][j];
  }

در هر حال مشاهده می‌شود که مرتبه‌ی ضرب‌ها و جمع‌ها تغییر نکرده و همان Θ(n³) باقی مانده است.

ایده‌ی ساده‌سازی

می‌توانیم جمع‌های تکراری را حذف کنیم. مثلاً:

S = 0; S = S+A; S = S+B; S = S+C;

⟹ تبدیل به ⟹

S = A; S = S+B; S = S+C;

تقسیم ماتریس‌ها

اگر n توانی از ۲ باشد، می‌توان ماتریس‌های A و B را به چهار ماتریس کوچک‌تر n/2 × n/2 تقسیم نمود:

C11 = A11B11 + A12B21  ,  C12 = A11B12 + A12B22
C21 = A21B11 + A22B21  ,  C22 = A21B12 + A22B22

اگر در الگوریتم بالا عمل ضرب را در نظر بگیریم، آنگاه تعداد ضرب‌ها برای ماتریس‌های n×n به ۸ عمل ضرب ماتریس‌های n/2 × n/2 نیاز خواهد داشت. از طرف دیگر، ضرب دو ماتریس ۱×۱ فقط به یک عمل ضرب اسکالر نیاز دارد؛ لذا:

T(n) = 8T(n/2) , T(1)=1  ⟹  T(n) = Θ(n³)

ایده‌ی اشتراسن (۱۹۶۹)

اشتراسن ثابت کرد که می‌توان با تغییر فرمول‌ها تعداد ضرب‌ها را کاهش داد. مثلاً برای محاسبه‌ی A = x·y و B = x·y·z می‌توان نوشت B = A·z (از دو ضرب به یک ضرب). با همین ترفند، حاصل ضرب دو ماتریس A×B یعنی ماتریس C از روابط زیر به دست می‌آید:

C = [ M1+M4−M5+M7    M3+M5 ]
[ M2+M4    M1+M3−M2+M6 ]
M1 = (A11 + A22)(B11 + B22)
M2 = (A21 + A22)B11
M3 = A11(B12 − B22)
M4 = A22(B21 − B11)
M5 = (A11 + A12)B22
M6 = (A21 − A11)(B11 + B12)
M7 = (A12 − A22)(B21 + B22)

مثال عددی برای M₁

A = [[1 2 3 4],[5 6 7 8],[9 1 2 3],[4 5 6 7]]  ،  B = [[8 9 1 2],[3 4 5 6],[7 8 9 1],[2 3 4 5]]
M1 = (A11+A22)(B11+B22)
= ([1 2 ; 5 6] + [2 3 ; 6 7]) × ([8 9 ; 3 4] + [9 1 ; 4 5])
= [3 5 ; 11 13] × [17 10 ; 7 9] = [86 75 ; 278 227]
هزینه‌ی اشتراسن
T(n) = 7T(n/2) , T(1)=1  ⟹  T(n) = nlog₂7 = n2.81
T(n) = 7T(n/2) + 18(n/2)²  ⟹  T(n) = Θ(nlog₂7) = Θ(n2.81)

با این تکنیک یک عمل ضرب کم شده ولی در عوض ۱۴ عمل جمع و تفریق اضافه شده است. بنابراین در مقادیر بزرگ n، در کل زمان مصرفی کاهش می‌یابد، چرا که ضرب به مراتب زمان بیشتری نسبت به جمع و تفریق می‌برد.

بدیهی است که برای حالت n = 1، یعنی ضرب ماتریس‌های ۱×۱، تنها به یک عمل ضرب و صفر عمل جمع و تفریق نیاز داریم.

دو نکته
  • می‌توان اثبات کرد ضرب ماتریس‌ها حداقل به زمان Θ(n²) نیاز دارد، ولی هنوز کسی نتوانسته است الگوریتمی از مرتبه ۲ برای ضرب ماتریس‌ها ابداع کند و از طرف دیگر نیز کسی نتوانسته اثبات کند نوشتن چنین الگوریتمی غیرممکن است.
  • در روش اشتراسن فرض شده که n توانی از ۲ باشد. اگر چنین نبود با اضافه کردن سطر و ستون‌های صفر به ماتریس، می‌توان ابعاد آن را توانی از ۲ کرد.

۶ مثال پنجم — ضرب اعداد صحیح بزرگ

همان‌طور که می‌دانید اعداد صحیح بزرگ را می‌توان توسط آرایه ذخیره کرد. مثلاً عدد بزرگ 8,173,963,563 را به صورت زیر ذخیره می‌کنیم:

8173963563

اسکن فصل دقیقاً تا همین‌جا ادامه دارد و بقیه‌ی این مثال در صفحه‌ی بعدِ کتاب است که در فایل شما نیست.

ایده‌ی کلی (برای مطالعه‌ی بعدی) برای ضرب دو عدد n رقمی، عدد را به دو نیمه‌ی پرمرتبه و کم‌مرتبه تقسیم می‌کنند: x = a·10n/2 + b و y = c·10n/2 + d. ضرب ساده ۴ ضرب کوچک‌تر می‌خواهد (ac, ad, bc, bd)، ولی با ترفند کاراتسوبا (Karatsuba) می‌شود با ۳ ضرب انجامش داد: xy = ac·10n + ((a+b)(c+d) − ac − bd)·10n/2 + bd ⟹ T(n) = 3T(n/2) + Θ(n) = Θ(nlog₂3) ≈ Θ(n1.58).

۷ جدول جمع‌بندی فصل

مسئلهرابطه‌ی بازگشتیمرتبهنکته
جستجوی دودوییW(n)=W(n/2)+1Θ(log n)a=1 در قضیه‌ی کمکی
ادغام دو لیستW(n)=n−1Θ(n)بهترین: Min(p,m)
مرتب‌سازی ادغامیW(n)=2W(n/2)+n−1Θ(n log n)همیشه n log n
پارتیشنT(n)=n−1Θ(n)یک عنصر در جای نهایی
مرتب‌سازی سریع (بدترین)W(n)=W(n−1)+(n−1)Θ(n²)آرایه‌ی از قبل مرتب
مرتب‌سازی سریع (بهترین/میانگین)T(n)=2T(n/2)+Θ(n)Θ(n log n)لولا میانه
ضرب ماتریس استانداردT(n)=8T(n/2)Θ(n³)۸ ضرب
ضرب ماتریس اشتراسنT(n)=7T(n/2)Θ(n2.81)۷ ضرب، ۱۸ جمع
قضیه‌ی اصلی — تنها چیزی که باید حفظ باشد
T(n) = aT(n/b) + Cnk
nlogba > nk ⟹ Θ(nlogba)
nlogba = nk ⟹ Θ(nk log n)
nlogba < nk ⟹ Θ(nk)

۸ تمرین‌های کتاب

کتاب در پایان هر مثال، سؤالاتی به عنوان تمرین مطرح کرده است. این‌ها را خودت حل کن؛ راهنما بازشو است، جواب نیست.

تمرین ۱ — سؤالات جستجوی دودویی
راهنمای مسیر
۱) برای آرایه‌ی ۱۲ عنصری مثال، عدد ۲۰ و عدد ۸۰ را جستجو کن و تعداد مقایسه‌ها را بشمار. ۲) ثابت کن S(n) = (1+1/n)U(n) − 1 با روش استقرا. ۳) برای آرایه‌ی ۱۰۰ عنصری، بدترین حالت چند مقایسه است؟
تمرین ۲ — مرتب‌سازی ادغامی
راهنمای مسیر
۱) درخت بازگشت را برای آرایه‌ی ۸ عنصری مثال بکش و تعداد کل مقایسه‌های ادغام را در هر سطح جمع بزن. ۲) نشان بده مجموع کار هر سطح n−1 است و تعداد سطح‌ها log n. ۳) چرا مرتب‌سازی ادغامی برای داده‌های روی هارد مناسب است؟
تمرین ۳ — مرتب‌سازی سریع
راهنمای مسیر
۱) پارتیشن را روی [26 5 37 1 61 11 59 15 48 19] خودت قدم‌به‌قدم اجرا کن و هر جابه‌جایی را بنویس. ۲) اگر آرایه از قبل صعودی مرتب باشد، چرا pivot اول بدترین حالت است؟ ۳) روی همان آرایه، لولا را با روش میانه‌ی سه انتخاب کن و تعداد سطح‌های بازگشت را با حالت قبل مقایسه کن.
تمرین ۴ — اشتراسن
راهنمای مسیر
۱) با همان A و B مثال، M7 را حساب کن. ۲) بررسی کن چرا log₂7 = 2.81. ۳) اگر n توانی از ۲ نباشد چه می‌کنیم و این کار چه هزینه‌ای دارد؟
راهنمای کلی حل هر مسئله‌ی تقسیم و غلبه
  1. رابطه‌ی بازگشتی را بنویس: T(n) = aT(n/b) + f(n). سه عدد را پیدا کن: a، b، و مرتبه‌ی f(n).
  2. logb a را حساب کن و با توان k در f(n) مقایسه کن.
  3. سه حالت قضیه‌ی اصلی را اعمال کن.
  4. اگر رابطه شکل استاندارد نداشت (مثل √n یا n−1)، از تغییر متغیر یا باز کردن استفاده کن.