مرحباً بالخوارزميات › الترتيب 13 / 19 السابق التالي

الترتيب بالعد

الترتيب بالعد (counting sort) يرتّب عبر عدّ مرات ظهور العناصر، ويُطبَّق عادةً على مصفوفات الأعداد الصحيحة.

تنفيذ بسيط

لنبدأ بمثال بسيط. بمعطى مصفوفة nums طولها nn، وجميع عناصرها «أعداد صحيحة غير سالبة»، يوضح الشكل أدناه التدفق الإجمالي للترتيب بالعد.

  1. اجتز المصفوفة لإيجاد أكبر عدد، ورمزه mm، ثم أنشئ مصفوفة مساعدة counter طولها m+1m + 1.
  2. استخدم counter لعدّ عدد مرات ظهور كل عدد في nums، حيث يخزّن counter[num] عدد مرات ظهور num. وهذا بسيط: اجتز nums (ورمز العدد الحالي بـnum) وزد counter[num] بمقدار 11 في كل مرة.
  3. وبما أن فهارس counter مرتّبة ترتيباً طبيعياً، فإن الأعداد تكون فعلياً مرتّبة بالفعل. بعد ذلك، اجتز counter واكتب الأعداد مرة أخرى في nums بترتيب تصاعدي وفقاً لأعداد ظهورها.

تدفق الترتيب بالعد

والشيفرة كما يلي:

Go

/* الترتيب بالعد */
// تنفيذ بسيط، لا يصلح لترتيب الكائنات
func countingSortNaive(nums []int) {
	// 1. عدّ أكبر عنصر m في المصفوفة
	m := 0
	for _, num := range nums {
		if num > m {
			m = num
		}
	}
	// 2. عدّ مرات ظهور كل عدد
	// يمثّل counter[num] عدد مرات ظهور num
	counter := make([]int, m+1)
	for _, num := range nums {
		counter[num]++
	}
	// 3. اجتز counter، واكتب كل عنصر مرة أخرى في المصفوفة الأصلية nums
	for i, num := 0, 0; num < m+1; num++ {
		for j := 0; j < counter[num]; j++ {
			nums[i] = num
			i++
		}
	}
}

TypeScript

/* الترتيب بالعد */
// تنفيذ بسيط، لا يصلح لترتيب الكائنات
function countingSortNaive(nums: number[]): void {
    // 1. عدّ أكبر عنصر m في المصفوفة
    let m: number = Math.max(...nums);
    // 2. عدّ مرات ظهور كل عدد
    // يمثّل counter[num] عدد مرات ظهور num
    const counter: number[] = new Array<number>(m + 1).fill(0);
    for (const num of nums) {
        counter[num]++;
    }
    // 3. اجتز counter، واكتب كل عنصر مرة أخرى في المصفوفة الأصلية nums
    let i = 0;
    for (let num = 0; num < m + 1; num++) {
        for (let j = 0; j < counter[num]; j++, i++) {
            nums[i] = num;
        }
    }
}

الصلة بين الترتيب بالعد وترتيب الدلاء

من منظور ترتيب الدلاء، يمكن النظر إلى كل فهرس في مصفوفة العدّ counter باعتباره دلو، ويمكن اعتبار عملية العدّ توزيعاً للعناصر على الدلاء المقابلة لها. وفي جوهر الأمر، الترتيب بالعد حالة خاصة من ترتيب الدلاء لبيانات الأعداد الصحيحة.

تنفيذ كامل

ربما لاحظ القراء المتنبّهون أن عملية الإدخال إذا كانت تتكوّن من كائنات، فإن الخطوة 3. أعلاه لم تعد صالحة. لنفترض أن الإدخال يتكوّن من كائنات منتجات ونريد ترتيبها حسب السعر (وهو متغير عضو في الصنف)؛ فإن الخوارزمية أعلاه لن تنتج إلا ترتيب الأسعار نفسها.

فكيف يمكننا الحصول على ترتيب البيانات الأصلية؟ نحسب أولاً المجاميع البادئة (prefix sums) لـcounter. وكما يوحي الاسم، فإن المجموع البادئ عند الفهرس ii، أي prefix[i]، يساوي مجموع العناصر من الفهرس 0 حتى i:

prefix[i]=∑j=0icounter[j] \text{prefix}[i] = \sum_{j=0}^i \text{counter[j]}

وللمجموع البادئ تفسير واضح: prefix[num] - 1 يعطي فهرس آخر ظهور للعنصر num في المصفوفة الناتجة res. وهذه المعلومة بالغة الأهمية لأنها تخبرنا بالمكان الذي ينبغي أن يوضع فيه كل عنصر في المصفوفة الناتجة. بعد ذلك، نجتاز المصفوفة الأصلية nums بترتيب عكسي، ولكل عنصر num ننفّذ الخطوتين التاليتين.

  1. ضع num عند الفهرس prefix[num] - 1 في المصفوفة res.
  2. قلّل المجموع البادئ prefix[num] بمقدار 11 للحصول على فهرس الموضع التالي لـnum.

وبعد اكتمال الاجتياز، تحتوي المصفوفة res على النتيجة المرتّبة، وأخيراً يُستخدم res لاستبدال المصفوفة الأصلية nums. ويوضح الشكل أدناه تدفق الترتيب بالعد الكامل.

ويُعرض تنفيذ الترتيب بالعد أدناه:

Go

/* الترتيب بالعد */
// تنفيذ كامل، يمكنه ترتيب الكائنات وهو ترتيب مستقر
func countingSort(nums []int) {
	// 1. عدّ أكبر عنصر m في المصفوفة
	m := 0
	for _, num := range nums {
		if num > m {
			m = num
		}
	}
	// 2. عدّ مرات ظهور كل عدد
	// يمثّل counter[num] عدد مرات ظهور num
	counter := make([]int, m+1)
	for _, num := range nums {
		counter[num]++
	}
	// 3. احسب المجموع البادئ لـcounter، وحوّل «عدد مرات الظهور» إلى «فهرس الذيل»
	// counter[num]-1 هو آخر فهرس يظهر فيه num في res
	for i := 0; i < m; i++ {
		counter[i+1] += counter[i]
	}
	// 4. اجتز nums بترتيب عكسي، وضع كل عنصر في المصفوفة الناتجة res
	// هيّئ المصفوفة res لتسجيل النتائج
	n := len(nums)
	res := make([]int, n)
	for i := n - 1; i >= 0; i-- {
		num := nums[i]
		// ضع num عند الفهرس المقابل
		res[counter[num]-1] = num
		// قلّل المجموع البادئ بمقدار 1 للحصول على الفهرس التالي لوضع num
		counter[num]--
	}
	// استخدم المصفوفة الناتجة res لاستبدال المصفوفة الأصلية nums
	copy(nums, res)
}

TypeScript

/* الترتيب بالعد */
// تنفيذ كامل، يمكنه ترتيب الكائنات وهو ترتيب مستقر
function countingSort(nums: number[]): void {
    // 1. عدّ أكبر عنصر m في المصفوفة
    let m: number = Math.max(...nums);
    // 2. عدّ مرات ظهور كل عدد
    // يمثّل counter[num] عدد مرات ظهور num
    const counter: number[] = new Array<number>(m + 1).fill(0);
    for (const num of nums) {
        counter[num]++;
    }
    // 3. احسب المجموع البادئ لـcounter، وحوّل «عدد مرات الظهور» إلى «فهرس الذيل»
    // counter[num]-1 هو آخر فهرس يظهر فيه num في res
    for (let i = 0; i < m; i++) {
        counter[i + 1] += counter[i];
    }
    // 4. اجتز nums بترتيب عكسي، وضع كل عنصر في المصفوفة الناتجة res
    // هيّئ المصفوفة res لتسجيل النتائج
    const n = nums.length;
    const res: number[] = new Array<number>(n);
    for (let i = n - 1; i >= 0; i--) {
        const num = nums[i];
        res[counter[num] - 1] = num; // ضع num عند الفهرس المقابل
        counter[num]--; // قلّل المجموع البادئ بمقدار 1 للحصول على الفهرس التالي لوضع num
    }
    // استخدم المصفوفة الناتجة res لاستبدال المصفوفة الأصلية nums
    for (let i = 0; i < n; i++) {
        nums[i] = res[i];
    }
}

خصائص الخوارزمية

  • التعقيد الزمني O(n+m)O(n + m)، والترتيب بالعد غير تكيّفي (non-adaptive): يستغرق اجتياز nums وcounter زمناً خطياً. وبشكل عام، عندما n≫mn \gg m، يقترب التعقيد الزمني من O(n)O(n).
  • التعقيد المكاني O(n+m)O(n + m)، وترتيب ليس في المكان (non-in-place): يستخدم المصفوفتين res وcounter بطولين nn وmm على التوالي.
  • ترتيب مستقر: بما أن العناصر تُملأ في res بترتيب «من اليمين إلى اليسار»، فإن اجتياز nums بترتيب عكسي يمكن أن يمنع تغيّر المواضع النسبية للعناصر المتساوية، وبذلك يتحقق ترتيب مستقر. وفي الواقع، يمكن أن يؤدي اجتياز nums بالترتيب الأمامي أيضاً إلى نتائج ترتيب صحيحة، لكن النتيجة ستكون غير مستقرة.

القيود

في هذه المرحلة، قد تظن أن الترتيب بالعد بالغ البراعة، لأنه يحقق ترتيباً فعّالاً بمجرد عدّ مرات الظهور. غير أن المتطلبات المسبقة لاستخدام الترتيب بالعد مقيّدة إلى حد كبير.

لا ينطبق الترتيب بالعد إلا على الأعداد الصحيحة غير السالبة. ولتطبيقه على أنواع أخرى من البيانات، عليك التأكد من إمكانية تحويلها إلى أعداد صحيحة غير سالبة دون تغيير الترتيب النسبي للعناصر. فمثلاً، بالنسبة إلى مصفوفة أعداد صحيحة تحتوي على أعداد سالبة، يمكنك أولاً إضافة ثابت إلى كل عدد لإزاحتها إلى النطاق غير السالب، ثم إزاحتها مرة أخرى بعد الترتيب.

يناسب الترتيب بالعد الحالات التي تحتوي على عناصر كثيرة لكن نطاق قيمها صغير. فمثلاً، في السيناريو أعلاه، لا يمكن أن يكون mm كبيراً جداً؛ وإلا فسيستهلك مساحة كبيرة جداً. وعندما n≪mn \ll m، يستغرق الترتيب بالعد زمن O(m)O(m)، وقد يكون أبطأ من خوارزميات ترتيب بتعقيد زمني O(nlog⁡n)O(n \log n).