סימולציה · הסתברות · עיצוב אלגוריתמים
ההגדרה. ל-100 אסירים יש מספרים ייחודיים מ-0 עד 99. בחדר נפרד יש 100 קופסאות, גם הן ממוספרות 0–99. בכל קופסה יש פתק עם מספר — הסדר הוא אקראי לחלוטין, מספר אחד לכל קופסה.
כל אסיר נכנס לחדר לבד ורשאי לפתוח עד 50 קופסאות. אם מצא את הפתק עם המספר שלו — הצליח. לאחר מכן יוצא והחדר חוזר למצבו המקורי. האסירים אינם יכולים לתקשר ביניהם לאחר תחילת המשחק.
אסטרטגיית השרשרת. כל אסיר מתחיל בקופסה שמספרה הוא המספר שלו, קורא את הערך שבפנים, ועובר לקופסה עם אותו מספר — וכך הלאה בשרשרת, עד שמוצא את המספר שלו או שמגיע למגבלת 50 הפתיחות.
נסו לצעוד בעצמכם בדמו הבא כדי לראות איך השרשרת עובדת:
שאלה: מה ההסתברות שבפרמוטציה אקראית של n איברים, אף מחזור לא יהיה ארוך מ-n/2?
שלב 1 — חישוב ההסתברות הכישלון:
נשאל: מה ההסתברות שקיים מחזור באורך k > n/2?
בפרמוטציה של n איברים, יכול להיות לכל היותר מחזור אחד כזה (כי שניים כבר יחד יעלו על n).
ניתן להוכיח שההסתברות לקיום מחזור באורך בדיוק k שווה:
שלב 2 — חיבור כל הכישלונות:
מאחר שאלו אירועים זרים (לא יכולים להתרחש בו-זמנית), ההסתברות לכישלון היא:
שלב 3 — קירוב לוגריתמי:
הסכום הזה מזכיר את הגדרת האינטגרל. עבור n גדול:
שלב 4 — ההסתברות להצלחה:
מדוע 0.3118 ולא 0.3069?
הקירוב האינטגרלי מניח n→∞. עבור n = 100 בדיוק, חישוב מדויק של הסכום נותן:
הסימולציה מתכנסת בדיוק לערך זה ככל שמספר הניסויים גדל — מה שמאמת הן את האסטרטגיה והן את המתמטיקה.
לפני כתיבת קוד, הקדישו כמה דקות לצפייה בסימולציה. שימו לב כיצד צבעי הקופסאות חושפים את מבנה המחזורים — גוונים קרים (כחול/ציאן) למחזורים בטוחים ≤ 50, גוונים חמים (אדום/כתום) למחזורים מסוכנים. ניתן לדעת אם הסיבוב יצליח עוד לפני שאסיר אחד נכנס.
טיפ: בחרו N = 10 (שמאל למעלה) כדי לראות את השרשראות בבירור. אחר כך עברו ל-N = 100 ולחצו ⚡ INSTANT לעשרות סיבובים — צפו כיצד שיעור ההצלחה מתכנס לכ-31.18%.
PrisonersProblem וקובץ App.java נפרד עם main.
PrisonersProblem.javaהמחלקה מכילה שתי תכונות (fields) פרטיות, פעולה בונה אחת, ושלוש פעולות שתממשו:
public class PrisonersProblem { // ── תכונות המחלקה ────────────────────────────────────── private int prisonersNumber; // מספר האסירים / גודל המערך private int[] boxes; // מערך הקופסאות (הפרמוטציה) // ── פעולה בונה ───────────────────────────────────────── public PrisonersProblem(int prisonersNumber) { /* TASK 0 */ } // ── פעולות ───────────────────────────────────────────── public void generateUniqueRandomArray() { /* PROVIDED */ } public int serialSearchByValueChain(int startIndex) { /* TASK 1 */ } public boolean testSerialSearchByValueChain() { /* TASK 2 */ } }
שימו לב: הפעולות אינן מקבלות את המערך כפרמטר — הן עובדות ישירות על this.boxes ועל this.prisonersNumber.
הפעולה הבונה (constructor) נקראת אוטומטית כשיוצרים עצם חדש. תפקידה לאתחל את התכונות של העצם.
prisonersNumber כפרמטר. שמרו אותו ב-this.prisonersNumber, ואתחלו את this.boxes כמערך ריק בגודל המתאים.public PrisonersProblem(int prisonersNumber) { // TODO: שמרו את prisonersNumber בתכונה המתאימה // TODO: צרו מערך int[] חדש בגודל prisonersNumber ושמרו ב-this.boxes }
this.boxes = new int[prisonersNumber] יוצר מערך ומאתחל את כל תאיו ל-0.boxes עדיין ריק — הוא ימולא על-ידי generateUniqueRandomArray().פעולה זו ניתנת לכם מוכנה. היא מעדכנת את this.boxes בפרמוטציה אקראית של המספרים 0 עד prisonersNumber−1 בשיטת Fisher-Yates — O(n), התפלגות אחידה.
public void generateUniqueRandomArray() { for (int i = 0; i < this.prisonersNumber; i++) this.boxes[i] = i; Random rand = new Random(); for (int i = this.prisonersNumber - 1; i > 0; i--) { int j = rand.nextInt(i + 1); int tmp = this.boxes[i]; this.boxes[i] = this.boxes[j]; this.boxes[j] = tmp; } }
זה לב האסטרטגיה. האסיר מתחיל בקופסה startIndex, קורא את הערך מתוך this.boxes, קופץ לאותו אינדקס, וממשיך בשרשרת. ספרו כל קופסה שנפתחה.
החזירו את מספר הקופסאות שנפתחו עד מציאת המספר. החזירו -1 אם מתגלה לולאה אינסופית.
this.boxes[currentIndex] (לא arr[...]). תנאי המציאה: this.boxes[currentIndex] == startIndex. השתמשו ב-boolean[] בגודל this.boxes.length כמגן מפני לולאות.public int serialSearchByValueChain(int startIndex) { if (startIndex < 0 || startIndex >= this.boxes.length) throw new IllegalArgumentException("startIndex out of bounds"); // TODO: הכריזו על boolean[] בגודל this.boxes.length int currentIndex = startIndex; int searchCount = 0; while (true) { // TODO: הגדילו את searchCount ב-1 // TODO: אם currentIndex כבר בוקר — החזירו -1 // TODO: סמנו את currentIndex כמבוקר // TODO: אם this.boxes[currentIndex] == startIndex — החזירו searchCount // TODO: אחרת — currentIndex = this.boxes[currentIndex] } }
arr[i] כותבים this.boxes[i].boolean[] מחליף HashSet — בדיקה ב-O(1) ללא ייבוא ספריות.פעולה זו מדמה סיבוב אחד שלם: קוראת ל-generateUniqueRandomArray() כדי לערבב מחדש את this.boxes, ואז בודקת שכל prisonersNumber האסירים הצליחו בתוך prisonersNumber/2 צעדים.
this.generateUniqueRandomArray(). לאחר מכן עברו בלולאה על כל startIndex מ-0 עד this.prisonersNumber - 1 וקראו ל-this.serialSearchByValueChain(startIndex).public boolean testSerialSearchByValueChain() { // TODO: קראו ל-this.generateUniqueRandomArray() לערבוב הקופסאות // TODO: עברו בלולאה על כל האסירים (startIndex = 0 עד prisonersNumber-1) for ( /* TODO */ ) { // TODO: קראו ל-this.serialSearchByValueChain(startIndex) // TODO: אם התוצאה == -1 או גדולה מ-prisonersNumber/2 — החזירו false } return true; // כולם הצליחו }
this.prisonersNumber / 2.0 כדי להימנע מבעיית חלוקה שלמה.return false — נכונה ויעילה.בקובץ App.java נפרד: צרו עצם אחד מהמחלקה, והריצו עליו tests סיבובים. הפלט צריך להתכנס לכ-31.18%.
public class App { public static void main(String[] args) { int free = 0; int tests = 10000; // TODO: צרו עצם PrisonersProblem עם 100 אסירים PrisonersProblem prisonersObj = /* TODO: new PrisonersProblem(100) */; // TODO: לולאה של tests פעמים for ( /* TODO */ ) { // TODO: קראו ל-prisonersObj.testSerialSearchByValueChain() // TODO: אם מחזירה true — הגדילו את free ב-1 } System.out.println(100.0 * free / tests + "% of trials result in free prisoners."); } }
boxes.ההמרה מכנית לחלוטין — כמעט שורה-אחר-שורה:
| Java | JavaScript |
|---|---|
this.boxes[i] | arr[i] |
this.boxes.length | arr.length |
boolean[] visited = new boolean[n] | let visitedFlags = new Array(arr.length).fill(false) |
int x = 0; | let x = 0; |
return searchCount; (הצלחה) | return { visited: chain, success: true }; |
return -1; (כישלון) | return { visited: chain, success: false }; |
שימו לב: chain הוא מערך שמכיל את כל האינדקסים שנפתחו בסדר, הוסיפו לו chain.push(currentIndex) בכל צעד.
הבעיה עם אסטרטגיה אקראית: אם כל אסיר בוחר 50 קופסאות באקראי, הסיכוי שלו להצליח הוא בדיוק 50%. אבל הגורלות של האסירים השונים בלתי תלויים זה בזה — כל אחד מטיל מטבע משלו. לכן ההסתברות שכולם יצליחו היא (½)¹⁰⁰ ≈ 0.
למה השרשרת שונה: באסטרטגיית השרשרת, כל האסירים עוקבים אחרי אותה פרמוטציה. הגורל שלהם כבר נקבע ברגע ש-100 הפתקים הונחו — לפני שאסיר אחד נכנס לחדר. כולם אם מנצחים יחד (אף מחזור לא ארוך מ-50) או כולם מפסידים יחד (יש מחזור ארוך מ-50). הגורלות תלויים זה בזה באופן מלא, וזה מה שמאפשר לקבוצה להגיע ל-31%.
מה זה מחזור: כל פרמוטציה מתפרקת למחזורים (cycles) נפרדים. מחזור הוא קבוצת קופסאות שמובילות זו לזו ואז חוזרות לנקודת ההתחלה. לדוגמה: קופסה 3 מכילה 7, קופסה 7 מכילה 1, קופסה 1 מכילה 3 — מחזור באורך 3.
הקשר לאסטרטגיה: אסיר מספר k עוקב תמיד בדיוק על המחזור שמכיל את k. אורך המחזור הזה קובע כמה צעדים ייקחו לו — הוא יצליח אם ורק אם אורך המחזור ≤ 50.
תנאי הניצחון: הקבוצה כולה מנצחת אם ורק אם אף מחזור בפרמוטציה אינו ארוך מ-50. אם יש אפילו מחזור אחד באורך 51 ומעלה, כל האסירים שנמצאים בו ייכשלו.
חוק המספרים הגדולים: ככל שמריצים יותר ניסויים בלתי תלויים, הממוצע המדגמי מתקרב לציפייה המתמטית האמיתית. זהו חוק המספרים הגדולים — עיקרון יסוד בתורת ההסתברות.
מהיכן מגיע 31.18%: ניתן לחשב מתמטית שההסתברות שפרמוטציה אקראית של n איברים לא תכיל מחזור ארוך מ-n/2 שווה בקירוב ל-1 − ln(2) ≈ 0.3068. ל-n = 100 הערך המדויק הוא ≈ 0.3118. הסימולציה מתקרבת למספר זה ככל שמספר הסיבובים גדל.
כן, שיעור ההצלחה ירד בחדות. הסיבה היא שהאסטרטגיה מסתמכת על כך שכל אסיר מתחיל בדיוק בקופסה שמספרה שלו. כך מובטח שהשרשרת שהוא עוקב אחריה היא בדיוק המחזור שמכיל את מספרו — ולכן אם המחזור קצר מספיק, הוא בהכרח יגיע אליו.
אם מתחילים בקופסה אקראית, אסיר עשוי לעקוב אחרי מחזור שכלל לא מכיל את המספר שלו — ולא יגיע אליו לעולם, גם אם המחזור שמכיל אותו קצר מ-50. ההסתברות שאסיר יצליח תחזור להיות 50% בלי תלות בין האסירים השונים — ושיעור ההצלחה הכולל יצנח בחזרה לכמעט אפס.