السابقالفهرسالتالي

الفصل 14

كائنات المصفوفات

 

تنْـويه: في هذا الفصل، سنخطو خطوة أخرى نحو البرمجة كائنية التوجه، لكننا لن نصل إليها بعد. لذا فإن العديد من الأمثلة هنا ليست رسمية؛ أي أنها ليست برامج جيدة بلغة Java. هذه المرحلة الانتقالية ستساعدك على التعلم (آمل ذلك)، لكنني لا أكتب شفرة كهذه في الحالة العادية.

يمكنك تنزيل شفرة هذا الفصل من http://thinklikecs.webs.com/resources/code/Card2.java.

14.1 الصنف Deck

في الفصل السابق، اشتغلنا مع مصفوفة كائنات، لكنني ذكرت أيضاً أنه من الممكن وجود كائن يحتوي مصفوفة كمتغير حالة. في هذا الفصل سننشئ كائن Deck الذي يحتوي على مصفوفة Cards.

سيبدو تعريف الصنف كما يلي:

class Deck {
   Card[] cards;
   public Deck(int n) {
      this.cards = new Card[n];
   }
}

يهيئ الباني متغير الحالة بمصفوفة من الأوراق، لكنه لا ينشئ أية أوراق. هنا نجد مخطط حالة يبين مظهر Deck بدون أوراق:

هنا يوجد باني بدون متحولات يأخذ مجموعة ورق (deck) من 52 ورقة ويملأها بالأوراق (Cards):

public Deck() {
   this.cards = new Card[52];
   int index = 0;
   for (int suit = 0; suit <= 3; suit++) {
      for (int rank = 1; rank <= 13; rank++) {
         cards[index] = new Card(suit, rank);
         index++;
      }
   }
}

هذه العملية مشابهة للعملية makeDeck؛ لقد غيرنا البنية قليلاً لنجعلها عملية بناء. لاستدعائها، نستخدم new:

Deck deck = new Deck();

الآن سيكون من المعقول أن نضع العمليات المتعلقة بمجموعات الورق في تعريف الصنف Deck. بالنظر إلى العمليات التي كتبناها حتى الآن، أحد المرشحين الواضحين هي printDeck (القسم 13.7). ها هي هنا، بعد التعديل لتعمل مع Deck:

public static void printDeck(Deck deck) {
   for (int i=0; i < deck.cards.length; i++) {
      Card.printCard(deck.cards[i]);
   }
}

أحد التغييرات هو نوع المعامل، من []Card إلى Deck.

التغيير الثاني هو عدم قدرتنا على استخدام deck.length بعد ذلك للحصول على طول المصفوفة، لأن deck الآن هو كائن من نوع Deck، وليس مصفوفة. هو يحتوي على مصفوفة، لكنه ليس مصفوفة. لذا علينا كتابة deck.cards.length لاستخراج المصفوفة من كائن Deck والحصول على طولها.

وللسبب نفسه، علينا استخدام deck.cards[i] للولوج إلى عنصر من المصفوفة، بدلاً من deck[i] فقط.

التغيير الأخير هو أن استدعاء printCard يجب أن يبين صراحة أن printCard معرفة في الصنف Card.

14.2 خلط الأوراق

في معظم ألعاب الورق ستحتاج إلى القدرة على خلط الأوراق؛ وجعلها في ترتيب عشوائي. في القسم 12.6 رأينا كيفية توليد أرقام عشوائية، لكن استخدامها لخلط أوراق الشدة ليس واضحاً.

أحد الاحتمالات هو محاكاة الطريقة التي يخلط البشر بها الأوراق، وهي تكون عادة بتقسيم المجموعة إلى قسمين ثم اختيار الورق بالتبادل منهما. بما أن البشر لا يخلطون جيداً، سيكون الورق عشوائياً بشكل جيد بعد تكرار تلك العملية حوالي 7 مرات. لكن برنامج الحاسوب سيخلط الورق بشكل مثالي في كل مرة، وبعد 8 مرات من خلط الورق مثالياً، ستجد مجموعة الورق قد عادت إلى الترتيب الذي بدأت منه. لمزيد من المعلومات، انظر http://en.wikipedia.org/wiki/Faro_shuffle.

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

هنا تجد مخططاً لكيفية عمل هذه الخوارزمية. لتخطيط البرنامج، سأستعمل مزيجاً من تعليمات Java والكلمات الإنكليزية وهو ما يدعى أحياناً بالشفرة الزائفة (pseudocode):

for (int i=0; i < deck.length; i++) {
   // choose a random number between i and deck.cards.length
   // swap the ith card and the randomly-chosen card
}

الشيء الجميل في الشفرة الزائفة هو أنها غالباً ما تجعل العمليات التي ستحتاج إليها واضحة. في هذه الحالة، سنحتاج شيئاً ما مثل randomInt، تختار عدداً صحيحاً عشوائياً بين low وhigh، وswapCards التي تأخذ دليلين وتبدل بين الأوراق الموجودة في الأماكن المشار إليها.

هذه العملية – كتابة الشفرة الزائفة ثم كتابة العمليات التي تعمل – تدعى التطوير من الأعلى للأسفل (top-down development) (انظر http://en.wikipedia.org/wiki/Top-down_and_bottom-up_design).

14.3 الترتيب

الآن وبعد أن أفسدنا مجموعة الورق، نحتاج طريقة إعادتها مرتبة كما كانت. توجد خوارزمية ترتيب تشابه خوارزمية الخلط بشكل معاكس. تدعى باسم الترتيب الانتقائي (selection sort) لأنها تعمل بالمرور على المصفوفة بشكل متكرر واختيار الورقة الأدنى المتبقية كل مرة.

خلال الدورة الأولى نبحث عن الرقة الأدنى ونستبدلها بالورقة الموجودة في الموقع 0. خلال الدورة رقم i، نبحث عن الورقة الأدنى على يمين i ونستبدلها مع الورقة الموجودة في الموقع i.

ها هي الشفرة الزائفة لخوارزمية الترتيب الانتقائي:

for (int i=0; i < deck.length; i++) {
   // find the lowest card at or to the right of i
   // swap the ith card and the lowest card
}

ثانية، تساعدنا الشفرة الزائفة على تصميم العمليات المساعدة (helper methods). في هذه الحالة يمكننا استخدام swapCards مرة أخرى، لذا سنحتاج عملية جديدة واحدة فقط، تدعى indexLowestCard، تأخذ مصفوفة أوراق ودليل يجب أن تبدأ بالبحث عنده.

14.4 مجموعات الورق الفرعية

كيف يفترض بنا تمثيل يد أو مجموعة فرعية أخرى من مجموعة الورق الكاملة؟ أحد الاحتمالات هو إنشاء صنف جديد باسم Hand، والذي قد يوسع Deck (في Java يقال عن الصنف أنه يوسع –extends- صنفاً آخر إذا كان يرثه). يوجد احتمال آخر، وهو الذي سأشرحه، يكون باستخدام كائن Deck عدد أوراقه أقل من 52.

قد نرغب بعملية، subdeck، تأخذ Deck مع مجال من الأدلة، وتعيد مجموعة ورق جديدة تحتوي على المجموعة الفرعية المحددة من الأوراق:

public static Deck subdeck(Deck deck, int low, int high) {
   Deck sub = new Deck(high-low+1);

   for (int i = 0; i < sub.cards.length; i++) {
      sub.cards[i] = deck.cards[low+i];
   }
   return sub;
}

إن طول المجموعة الفرعية يساوي high-low+1 وذلك بسبب تضمين الورقتين الموجودتين عند الدليلين high وlow. هذا النوع من الحسابات قد يكون مضللاً، ويؤدي إلى خطأ "بفارق واحد". رسم شكل هو أفضل وسيلة في العادة لتفاديهم.

لأننا نعطي new متحولاً، فسيتم استدعاء الباني الأول، الذي يحجز المصفوفة فقط ولا يحجز أية أوراق. بداخل حلقة for، يتم تعبئة المجموعة الفرعية بنسخ عن مرجعيات مجموعة الورق.

فيما يلي مخطط الحالة لمجموعة فرعية تم إنشاؤها باستخدام المعاملات low=3 وhigh=7. النتيجة هي يد فيها 5 أوراق يتم مشاركتها مع المجموعة الأصلية؛ أي تم استخدام أسماء مستعارة.

التسمية المستعارة ليست فكرة جيدة عادة، لأن التغييرات في إحدى المجموعات الفرعية تؤثر على البقية، وهو ما يخالف السلوك المتوقع من أوراق الشدة ومجموعات ورق اللعب الحقيقة. لكن إذا كانت الأوراق غير قابلة للتعديل، ستكون التسمية المستعارة أقل خطورة. في هذه الحالة، لن يوجد أي سبب على الأغلب يدعونا لتغيير رتبة أو منظومة ورقة ما. بدلاً من ذلك يمكننا إنشاء كل ورقة مرة واحدة ثم معاملتها على أنها كائن غير قابل للتحوير. حتى الآن تبدو التسمية المستعارة مع كائنات Card خياراً معقولاً.

14.5 خلط الأوراق والتوزيع

في القسم 14.2 كتبت شفرة زائفة لخوارزمية خلط الأوراق. بفرض أننا نملك عملية باسم shuffleDeck تأخذ مجموعة ورق لعب كمعامل وتخلطها، يمكننا استعمالها لتوزيع الأوراق على اللاعبين:

Deck deck = new Deck();
shuffleDeck(deck);
Deck hand1 = subdeck(deck, 0, 4);
Deck hand2 = subdeck(deck, 5, 9);
Deck pack = subdeck(deck, 10, 51);

هذه الشفرة تضع الأوراق الخمسة الأولى في يد، والأوراق الخمسة التالية في يد أخرى، والباقي يعود إلى العلبة.

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

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

14.6 الترتيب بالدمج

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

في هذا القسم سأشرح خوارزمية أكثر فعالية تدعى الترتيب بالدمج (mergesort). لترتيب n عنصر، تستهلك خوارزمية الترتيب بالدمج زمناً يتناسب مع n.log(n). قد لا يبدو هذا مبهراً، لكن عندما يكبر n، فإن الفرق بين n2 وn.log(n) قد يكون شاسعاً. جرب بضعة قيم لn وانظر بنفسك.

إن الفكرة الأساسية للترتيب بالدمج هي: إذا كنت تملك مجموعتين فرعيتين، وكل منهما قد تم ترتيبها، فسيكون سهلاً (وسريعاً) أن تدمجهما في مجموعة واحدة مرتبة. جرب هذه مع مجموعة ورق شدة:

  1. شكل مجموعتين فرعيتين من 10 أوراق تقريباً لكل منهما ورتبها بحيث تكون الورقة الأدنى في البداية عندما يكون وجه الأوراق إلى الأعلى. ضع المجموعتين أمامك ووجههما إلى الأعلى.
  2. قارن بين الأوراق الأولى من كل مجموعة واختر الأدنى بينهما. اقلبها وأضفها إلى المجموعة المدمجة.
  3. أعد الخطوة 2 حتى تنتهي إحدى المجموعتين. ثم خذ الأوراق الباقية وأضفها إلى المجموعة المدمجة.

يجب أن تكون النتيجة مجموعة واحدة مرتبة. هذا هو ما ستبدو عليه الخوارزمية باستخدام الشفرة الزائفة:

public static Deck merge(Deck d1, Deck d2) {
   // create a new deck big enough for all the cards
   Deck result = new Deck(d1.cards.length + d2.cards.length);

   // use the index i to keep track of where we are in
   // the first deck, and the index j for the second deck
   int i = 0;
   int j = 0;

   // the index k traverses the result deck
   for (int k = 0; k < result.cards.length; k++) {

      // if d1 is empty, d2 wins; if d2 is empty, d1 wins;
      // otherwise, compare the two cards

      // add the winner to the new deck
   }
   return result;
}

أفضل طريقة لاختبار merge هي إنشاء مجموعة ورق وخلطها، واستعمال subdeck لتشكيل مجموعتين فرعيتين (صغيرتين)، ثم استعمال عملية الترتيب من الفصل السابق لترتيب القسمين. ثم يمكنك تمرير هذين القسمين إلى merge لترى إذا كانت ستعمل.

إذا استطعت جعلها تعمل، جرب كتابة شكل بسيط للعملية mergeSort:

public static Deck mergeSort(Deck deck) {
   // find the midpoint of the deck
   // divide the deck into two subdecks
   // sort the subdecks using sortDeck
   // merge the two halves and return the result
}

بعد ذلك، إذا تمكنت من جعل تلك النسخة تعمل، يبدأ المرح الحقيقي! الشيء السحري في الترتيب بالدمج هو أنه تعاودي. عند ترتيب المجموعتين الجزئيتين، لم علينا استدعاء النسخة القديمة والبطيئة من sort؟ لم لا نستدعي العملية الجديدة الأنيقة mergeSort التي تكتبها الآن؟

هذه ليست مجرد فكرة جيدة، بل هي ضرورية للحصول على التحسن في الأداء الذي وعدتك به. لكن حتى تعمل عليك أن تملك حالة قاعدية؛ وإلا فسوف تستمر العملية بالتعاود للأبد. يمكن أن نعتبر مجموعة فرعية من ورقة واحدة أو مجموعة لا تحوي أوراق كحالة قاعدية بسيطة. إذا استقبلت mergeSort مجموعة صغيرة كهذه، يمكن لها أن تعيدها مباشرة، لأنها مرتبة أصلاً.

ستبدو النسخة التعاودية من mergeSort مثل هذه:

public static Deck mergeSort(Deck deck) {
   // if the deck is 0 or 1 cards, return it

   // find the midpoint of the deck
   // divide the deck into two subdecks
   // sort the subdecks using mergesort
   // merge the two halves and return the result
}

كالعادة، توجد طريقتين للتفكير بالبرامج التعاودية: يمكنك تتبع مجرى التنفيذ كله، أو يمكنك القيام "بوثبة الثقة". لقد بنيت هذا المثال لأشجعك على "القفز بثقة".

عند استعمال sortDeck لترتيب المجموعات الفرعية، لن تشعر أنك مضطر لتتبع مسار التنفيذ، صحيح؟ ستفترض أنها تعمل بشكل صحيح لأنك نقحتها من الأخطاء مسبقاً. حسناً، كل ما فعلته لجعل mergeSort تتعاود هو استبدال خوارزمية الترتيب بواحدة أخرى. لا يوجد أي سبب يدعوك لقراءة البرنامج بشكل مختلف.

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

14.7 متغيرات الصنف

حتى الآن، شاهدنا المتغيرات المحلية التي يتم تصريح عنها داخل عملية، ومتغيرات الحالة، التي يتم التصريح عنها في تعريف الصنف، غالباً قبل تعاريف العمليات.

يتم إنشاء المتغيرات المحلية عند استدعاء العملية ويتم تدميرها عند انتهائها. يتم إنشاء متغيرات الحالة عندما تنشئ كائناً جديداً ويتم تدميرها عندما يتم جمع الكائن مع القمامة.

الآن حان وقت معرفة متغيرات الصنف (class variables). مثل متغيرات الحالة، يتم تعريف متغيرات الصنف في تعريف الصنف قبل تعاريف العمليات، لكننا نعرفها باستخدام الكلمة المفتاحية static. يتم إنشاء هذه المتغيرات عند تشغيل البرنامج، وتظل على قيد الحياة حتى إنهاء البرنامج.

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

كمثال، هذه نسخة من Card حيث يكون suits وranks متغيري صنف:

class Card {
   int suit, rank;

   static String[] suits = { "Clubs", "Diamonds", "Hearts", "Spades" };
   static String[] ranks = { "narf", "Ace", "2", "3", "4", "5", "6",
   "7", "8", "9", "10", "Jack", "Queen", "King" };

   public static void printCard(Card c) {
   System.out.println(ranks[c.rank] + " of " + suits[c.suit]);
   }
}

يمكننا الوصول إلى suits وranks من داخل printCard كما لو كانا متغيرين محليين.

14.8 المصطلحات

الشفرة الزائفة: طريقة لتصميم البرامج بكتابة مسودة سريعة باستخدام مزيج من اللغة الإنكليزية ولغة Java.

العملية المساعدة: غالباً ما تكون عملية صغيرة لا تفعل أي عمل مفيد فعلاً لوحدها، لكنها تساعد عملية أخرى، أكثر فائدة.

pseudocode: A way of designing programs by writing rough drafts in a combination of English and Java.

helper method: Often a small method that does not do anything enormously useful by itself, but which helps another, more useful, method.

14.9 تمرينات

تمرين 14.1

إن الغرض من هذا التمرين هو تطبيق خوارزميات الخلط والترتيب الموجودة في هذا الفصل.

  1. نزل شفرة هذا الفصل من http://thinkapjava.com/code/Card2.java. واستوردها إلى بيئة البرمجة عندك. لقد كتبت مخططات أولية للعمليات، لذلك يفترض أن تقدر على تجميع البرنامج. لكن عند تشغيله سيطبع رسائل تبين أن العمليات الفارغة لا تعمل بكل صحيح. عندما تملأ الفراغات في تلك العمليات بشكل صحيح، يجب أن تختفي تلك الرسائل.
  2. إذا قمت بحل التمرين 12.4، فلا بد أنك كتبت العملية randomInt. إذا لم يكن ذلك ما حصل، اكتبها الآن وأضف بعض الشفرة لاختبارها.
  3. اكتب عملية باسم swapCards تأخذ مجموعة ورق (مصفوفة أوراق) ودليلين، وتبدل بين الورقتين الموجودتين في هذين الموقعين.

    مساعدة: يجب أن تبدل المرجعيات وليس محتويات الكائنين. هذا أسرع؛ كما أنها تعالج القضية بشكل صحيح عندما تملك الأوراق أسماء مستعارة.

  4. اكتب عملية باسم shuffleDeck تستخدم الخوارزمية من القسم 14.2. قد ترغب باستخدام عملية randomInt من التمرين 12.4.
  5. اكتب عملية باسم indexLowestCard تستخدم العملية compareCard لإيجاد الورقة الأدنى في مجال معطى من مجموعة الورق (من lowIndex حتى highIndex، حيث ينتمي كل منها إلى المجال).
  6. اكتب عملية باسم sortDeck ترتب مجموعة أوراق من الأدنى إلى الأعلى.
  7. بالاستفادة من الشفرة الزائفة في القسم 14.6، اكتب العملية merge. تأكد من اختبارها قبل محاولة استخدامها كجزء من mergeSort.
  8. اكتب النسخة المبسطة من mergeSort، تلك التي تقسم مجموعة الورق إلى نصفين، وتستخدم sortDeck لترتيب النصفين، وتستخدم merge لإنشاء مجموعة جديدة، مرتبة بالكامل.
  9. اكتب نسخة تعاودية بالكامل من العملية mergeSort. تذكر أن sortDeck عملية تعديل وأن mergeSort تابع، ما يعني أن استدعاءهما يتم بشكل مختلف:
sortDeck(deck); 		// modifies existing deck
deck = mergeSort(deck); 	// replaces old deck with new
السابقالفهرسالتالي