ทำไมการสุ่มตัวเลขแบบไม่ซ้ำถึงเป็นเรื่องท้าทาย
หลายคนมักคิดว่าการเขียนโปรแกรมเพื่อ สุ่มตัวเลข (การเลือกค่าแบบไม่มีแบบแผน) โดยไม่ให้ซ้ำกันเป็นเรื่องง่ายสำหรับมือใหม่ เรามักจะเริ่มด้วยการเขียนลูปเพื่อสุ่มเลขขึ้นมา แล้วเช็กว่าเลขนั้นเคยถูกสุ่มไปหรือยัง ถ้าซ้ำก็ให้สุ่มใหม่ไปเรื่อยๆ จนกว่าจะได้เลขที่ไม่ซ้ำ ซึ่งวิธีนี้ดูเหมือนจะทำงานได้ดีในตอนเริ่มต้น
ลองจินตนาการถึงเกมบิงโกที่มีตัวเลข 75 ตัว ช่วงแรกของการสุ่มเราจะพบตัวเลขใหม่ได้ง่ายมาก เพราะมีตัวเลขว่างเหลืออยู่เยอะ แต่เมื่อเราเล่นไปจนถึงเลขที่ 70 ความน่าจะเป็นที่จะสุ่มเจอเลขที่ยังไม่เคยออกจะเหลือน้อยมาก ทำให้โปรแกรมต้องทำงานหนักขึ้นเรื่อยๆ เพื่อหาเลขที่เหลืออยู่
ในทางเทคนิค เราเรียกพฤติกรรมนี้ว่า Rejection Sampling (การสุ่มแบบปฏิเสธค่าที่ซ้ำ) ซึ่งไม่ใช่แค่เรื่องของความเร็ว แต่มันคือบั๊กที่ซ่อนอยู่ในการเขียนโค้ดจริง มันทำให้โปรแกรมทำงานช้าลงอย่างเห็นได้ชัดเมื่อเข้าสู่ช่วงท้ายเกม และอาจทำให้โปรแกรมค้างได้หากลูปการทำงานไม่มีจุดสิ้นสุดที่ชัดเจน
function drawNaive(called: number[]): number {
let n: number;
// ทำซ้ำไปเรื่อยๆ จนกว่าจะได้เลขที่ไม่ซ้ำ
do {
n = Math.floor(Math.random() * 75) + 1;
} while (called.includes(n));
return n;
}
โค้ดชุดนี้ใช้ฟังก์ชัน Math.random เพื่อสุ่มเลข 1 ถึง 75 แล้วใช้ called.includes(n) เพื่อตรวจสอบว่าเลขนั้นอยู่ในรายการที่เคยสุ่มไปแล้วหรือไม่ ถ้าซ้ำก็จะวนลูปใหม่
ผลลัพธ์ที่ได้คือเลขจำนวนเต็มตั้งแต่ 1 ถึง 75 ที่ไม่ซ้ำกัน แต่เมื่อจำนวนตัวเลขที่เหลือในกองน้อยลง โอกาสที่ลูปจะทำงานผิดพลาดหรือใช้เวลานานเกินไปก็จะสูงขึ้นตามลำดับ
ปัญหาเรื่องประสิทธิภาพที่ซ่อนอยู่ใต้พรม
ความผิดพลาดที่ร้ายแรงที่สุดของวิธี drawNaive คือการใช้ Array.includes (การค้นหาข้อมูลในรายการ) ภายในลูปที่ไม่มีขอบเขตจำกัด ยิ่งรายการเลขที่เคยสุ่มไปแล้วยาวขึ้นเท่าไหร่ โปรแกรมก็ยิ่งต้องใช้เวลาตรวจสอบนานขึ้นเท่านั้น ซึ่งในสายงานพัฒนาซอฟต์แวร์เราเรียกสิ่งนี้ว่า Time Complexity (ความซับซ้อนของเวลาที่ใช้ในการประมวลผล)
เมื่อเรามีเลขที่ถูกสุ่มไปแล้ว 70 ตัว การจะหาเลขที่เหลืออีก 5 ตัวให้เจออาจต้องสุ่มซ้ำไปซ้ำมาหลายสิบครั้ง นี่คือตัวอย่างของโค้ดที่ดูเหมือนจะใช้งานได้ แต่จริงๆ แล้วมีประสิทธิภาพต่ำมาก หากโปรแกรมนี้ถูกนำไปใช้บนหน้าจอโปรเจกเตอร์ในงานอีเวนต์ ความหน่วงเพียงเล็กน้อยอาจกลายเป็นปัญหาใหญ่ได้
วิธีแก้ปัญหาที่ดีกว่าคือการเปลี่ยนแนวคิดจากการ "สุ่มแล้วเช็ก" เป็นการ "เลือกจากสิ่งที่เหลืออยู่" โดยตรง เราควรเก็บเลขทั้งหมดที่ยังไม่ถูกสุ่มไว้ในรายการหนึ่ง และเลือกสุ่มจากรายการนั้นแทน ซึ่งจะช่วยให้เราได้เลขใหม่เสมอโดยไม่ต้องเสียเวลารอการสุ่มซ้ำ
export const drawNextNumber = (called: number[]): number | null => {
const used = new Set(called); // ใช้ Set เพื่อหาข้อมูลได้เร็วขึ้น
const remaining = Array.from({ length: 75 }, (_, i) => i + 1)
.filter((n) => !used.has(n)); // คัดกรองตัวเลขที่ยังไม่ได้ใช้
if (remaining.length === 0) return null; // ถ้าหมดแล้วให้คืนค่าว่าง
const index = Math.floor(Math.random() * remaining.length);
return remaining[index];
};
โค้ดนี้ใช้ Set (โครงสร้างข้อมูลที่เก็บค่าไม่ซ้ำ) เพื่อตรวจสอบว่าเลขไหนถูกใช้ไปแล้ว ซึ่งเร็วกว่าการค้นหาใน Array มาก และใช้ filter เพื่อสร้างรายการตัวเลขที่เหลืออยู่จริงๆ ก่อนจะสุ่มเลือกตำแหน่งจากรายการนั้น
ผลลัพธ์ที่ได้คือฟังก์ชันที่ทำงานด้วยความเร็วคงที่เสมอ ไม่ว่าจะเป็นการสุ่มตัวเลขตัวแรกหรือตัวสุดท้าย และจะส่งค่า null ออกมาทันทีเมื่อตัวเลขหมดกอง
การออกแบบโค้ดให้ทดสอบได้ง่าย
ในการทำงานจริง โปรแกรมเมอร์มืออาชีพจะไม่พึ่งพาค่าสุ่มจากระบบโดยตรงเสมอไป เพราะการทดสอบโค้ดที่สุ่มค่าได้ตลอดเวลาเป็นเรื่องยากมาก เราจึงต้องใช้วิธี Dependency Injection (การฉีดค่าที่จำเป็นเข้าไปในฟังก์ชัน) เพื่อให้เราสามารถควบคุมผลลัพธ์ในการทดสอบได้
การส่งฟังก์ชันสุ่มเข้าไปเป็นตัวแปรหนึ่งในฟังก์ชันหลัก จะช่วยให้เราสามารถสร้าง "ฟังก์ชันสุ่มปลอม" ขึ้นมาเพื่อทดสอบว่าโค้ดของเราทำงานได้ถูกต้องตามเงื่อนไขหรือไม่ วิธีนี้ช่วยให้เราไม่ต้องนั่งเดาสุ่มค่าในขณะที่เขียน Unit Test (การทดสอบโค้ดทีละส่วนเล็กๆ)
นี่คือหัวใจสำคัญของการเขียนโค้ดที่ยั่งยืน การแยกตรรกะการทำงาน (Logic) ออกจากแหล่งกำเนิดค่าสุ่ม (Random Source) จะทำให้โค้ดของเราสะอาดและดูแลรักษาง่ายขึ้นในระยะยาว ซึ่งเป็นทักษะที่จำเป็นมากสำหรับคนที่อยากเป็นโปรแกรมเมอร์มืออาชีพ
// ฟังก์ชันที่ยอมให้เราใส่ตัวสุ่มเองได้
const drawNext = (called: number[], random = Math.random) => {
// ตรรกะการสุ่มเดิม แต่ใช้ตัวแปร random ที่ส่งเข้ามา
// ...
};
// ทดสอบด้วยตัวสุ่มที่กำหนดค่าเอง
const fakeRandom = () => 0.5;
console.log(drawNext([], fakeRandom));
ในโค้ดนี้เราเพิ่มพารามิเตอร์ random เข้าไปเพื่อให้สามารถเลือกได้ว่าจะใช้ Math.random ของระบบหรือฟังก์ชันสุ่มที่เรากำหนดเองเพื่อใช้ในการทดสอบ
ผลลัพธ์ที่ได้คือฟังก์ชันที่สามารถทดสอบได้แน่นอน 100% เพราะเราควบคุมค่าสุ่มได้ ทำให้เรามั่นใจว่าฟังก์ชันจะทำงานถูกต้องในทุกสถานการณ์
ความปลอดภัยและการจัดการข้อมูลที่เก็บไว้
เมื่อเราทำแอปพลิเคชันบนเว็บ เรามักเก็บสถานะเกมไว้ใน localStorage (พื้นที่เก็บข้อมูลในเบราว์เซอร์) แต่ต้องจำไว้เสมอว่าข้อมูลในนี้คือ Untrusted Input (ข้อมูลที่ไม่น่าเชื่อถือ) เพราะผู้ใช้สามารถแก้ไขค่าต่างๆ ได้ด้วยตัวเองผ่านเครื่องมือพัฒนาของเบราว์เซอร์
ดังนั้น ทุกครั้งที่เราอ่านข้อมูลจาก localStorage เราต้องตรวจสอบความถูกต้องของข้อมูลเสมอ ห้ามนำข้อมูลมาใช้โดยไม่ผ่านการกรองเด็ดขาด เช่น ตรวจสอบว่าค่าที่เก็บมาเป็นตัวเลขจริงหรือไม่ และอยู่ในช่วงที่ถูกต้องหรือเปล่า ก่อนจะนำไปใช้งานต่อในโปรแกรม
นอกจากนี้ หากเกมมีสถานะที่สามารถคำนวณได้จากข้อมูลอื่น เราควรคำนวณใหม่ทุกครั้งแทนที่จะเชื่อค่าที่เก็บไว้เพียงอย่างเดียว เช่น สถานะ "จบเกม" ควรคำนวณจากจำนวนตัวเลขที่ถูกสุ่มไปแล้วว่าครบ 75 ตัวหรือไม่ มากกว่าจะเก็บเป็นสถานะ isGameOver ไว้ในฐานข้อมูล
const savedData = localStorage.getItem('game_state');
const record = JSON.parse(savedData || '{}');
// ตรวจสอบว่า record.called เป็น Array และมีค่าตัวเลขที่ถูกต้อง
const called = Array.isArray(record.called) ? record.called.filter(n => typeof n === 'number') : [];
// คำนวณสถานะใหม่จากจำนวนเลขที่เรียก
const isComplete = called.length >= 75;
โค้ดนี้แสดงการอ่านค่าจาก localStorage แล้วนำมาตรวจสอบความถูกต้องด้วย Array.isArray และ filter ก่อนนำไปใช้จริง เพื่อป้องกันบั๊กจากการที่ข้อมูลถูกแก้ไข
ผลลัพธ์ที่ได้คือโปรแกรมที่ทนทานต่อการป้อนข้อมูลผิดพลาด และไม่พังแม้ผู้ใช้จะพยายามแก้ไขสถานะของเกมด้วยตัวเอง
การสื่อสารระหว่างหน้าจอด้วยเครื่องมือสมัยใหม่
หากคุณทำแอปพลิเคชันที่มีหน้าจอหลักสำหรับผู้ควบคุมและหน้าจอสำหรับแสดงผลบนโปรเจกเตอร์ คุณจะต้องหาวิธีให้ข้อมูลทั้งสองหน้าจอตรงกันเสมอ การใช้ BroadcastChannel (ช่องทางสื่อสารระหว่างหน้าต่างเบราว์เซอร์) เป็นวิธีที่ง่ายและมีประสิทธิภาพที่สุดในปัจจุบัน
การตั้งชื่อช่องทางสื่อสารให้เป็นเอกลักษณ์ (Namespace) เป็นเรื่องสำคัญมาก เพื่อป้องกันไม่ให้ข้อมูลจากแอปอื่นหรือจากเวอร์ชันอื่นหลุดเข้ามาปนกัน นอกจากนี้การรักษาโครงสร้างข้อมูลให้คงที่ (Stable Keys) จะช่วยให้ผู้ใช้งานไม่เจอปัญหาข้อมูลค้างเมื่อมีการอัปเดตแอปพลิเคชันใหม่
จงเลือกใช้เครื่องมือที่ออกแบบมาเพื่อการสื่อสารโดยเฉพาะแทนการพยายามเขียนระบบซิงค์ข้อมูลขึ้นมาเอง เพราะเครื่องมือเหล่านี้ถูกทดสอบมาแล้วว่าทำงานได้ดีและครอบคลุมกรณีการใช้งานที่หลากหลาย ทำให้เรามีเวลาไปโฟกัสกับตรรกะหลักของโปรแกรมมากขึ้น
// สร้างช่องทางสื่อสารสำหรับเกมบิงโก
const channel = new BroadcastChannel("bingo_game_v1");
// ส่งข้อมูลอัปเดตไปให้หน้าจอโปรเจกเตอร์
channel.postMessage({ type: 'UPDATE', called: [1, 5, 12] });
โค้ดนี้แสดงการใช้ BroadcastChannel เพื่อส่งข้อมูลสถานะเกมไปยังหน้าต่างอื่นที่เปิดรอรับข้อมูลอยู่ภายใต้ชื่อช่องทางเดียวกัน
ผลลัพธ์ที่ได้คือหน้าจอโปรเจกเตอร์จะอัปเดตข้อมูลตามหน้าจอผู้ควบคุมแบบเรียลไทม์โดยอัตโนมัติ ช่วยให้การทำงานร่วมกันระหว่างหน้าจอเป็นเรื่องง่ายและเสถียร
สรุปบทเรียนสำหรับมือใหม่
การสุ่มตัวเลขแบบไม่ซ้ำอาจดูเป็นเรื่องเล็กน้อย แต่บทเรียนที่ได้จากการแก้ปัญหานี้คือรากฐานสำคัญของโปรแกรมเมอร์ทุกคน ไม่ว่าจะเป็นการเลือกโครงสร้างข้อมูลที่เหมาะสม การคำนึงถึงประสิทธิภาพ หรือการออกแบบโค้ดให้ทดสอบได้ง่าย สิ่งเหล่านี้คือสิ่งที่แยกมือสมัครเล่นออกจากมืออาชีพ
หลักการที่ควรจำไปใช้คือ: เลือกสุ่มจากรายการที่เหลืออยู่แทนการสุ่มซ้ำ, แยกตรรกะการสุ่มออกจากโค้ดหลักเพื่อให้ทดสอบได้, และอย่าเชื่อใจข้อมูลที่มาจากภายนอกเด็ดขาด การฝึกฝนเหล่านี้จะช่วยให้คุณเขียนซอฟต์แวร์ที่แข็งแกร่งและทำงานได้จริงในสถานการณ์ที่ซับซ้อน
ไม่ว่าคุณจะทำโปรเจกต์อะไร จงให้ความสำคัญกับรายละเอียดเล็กๆ น้อยๆ เสมอ เพราะบั๊กที่ร้ายแรงที่สุดมักซ่อนอยู่ในส่วนที่ดูเหมือนจะ "ไม่มีอะไร" เหมือนกับรอบที่ 70 ของเกมบิงโกที่ทดสอบให้เราเห็นว่าโค้ดที่เขียนมาดีแค่ไหน ขอให้สนุกกับการเขียนโค้ดและพัฒนาฝีมือต่อไปครับ
ที่มา: A No-Repeat Random Draw Looks Trivial Until Round 70 — DEV Community