ทำไมการสุ่มตัวเลขแบบไม่ซ้ำถึงเป็นปัญหา? วิธีเขียนโค้ดให้มีประสิทธิภาพและทดสอบได้ง่าย

10 นาที 10 views บันทึกเป็น PDF
ทำไมการสุ่มตัวเลขแบบไม่ซ้ำถึงเป็นปัญหา? วิธีเขียนโค้ดให้มีประสิทธิภาพและทดสอบได้ง่าย

เคยสงสัยไหมว่าทำไมโค้ดสุ่มเลขถึงทำงานช้าลงเมื่อใกล้หมดกอง? มาเรียนรู้วิธีเขียนอัลกอริทึมสุ่มเลขแบบไม่ซ้ำให้มีประสิทธิภาพ พร้อมเทคนิคการทำ Unit Test

ทำไมการสุ่มตัวเลขแบบไม่ซ้ำถึงเป็นเรื่องท้าทาย

หลายคนมักคิดว่าการเขียนโปรแกรมเพื่อ สุ่มตัวเลข (การเลือกค่าแบบไม่มีแบบแผน) โดยไม่ให้ซ้ำกันเป็นเรื่องง่ายสำหรับมือใหม่ เรามักจะเริ่มด้วยการเขียนลูปเพื่อสุ่มเลขขึ้นมา แล้วเช็กว่าเลขนั้นเคยถูกสุ่มไปหรือยัง ถ้าซ้ำก็ให้สุ่มใหม่ไปเรื่อยๆ จนกว่าจะได้เลขที่ไม่ซ้ำ ซึ่งวิธีนี้ดูเหมือนจะทำงานได้ดีในตอนเริ่มต้น

ลองจินตนาการถึงเกมบิงโกที่มีตัวเลข 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

แชร์บทความ

Facebook X LINE

บทความที่เกี่ยวข้อง

จัดการเซิร์ฟเวอร์ผ่าน VS Code ให้ง่ายขึ้นด้วย Easy SSH พร้อมฟีเจอร์โหลดไฟล์ผ่านคลิกเดียว

จัดการเซิร์ฟเวอร์ผ่าน VS Code ให้ง่ายขึ้นด้วย Easy SSH พร้อมฟีเจอร์โหลดไฟล์ผ่านคลิกเดียว

เบื่อไหมที่ต้องสลับหน้าจอไปมาเพื่อจัดการเซิร์ฟเวอร์? มาลองใช้ Easy SSH ปลั๊กอิน VS Code ที่ช่วยให้คุณรีโมทผ่าน Terminal ได้สะดวก แถมโหลดไฟล์ได้ง่ายแค่กด Ctrl+click

ที่มา: DEV Community

2 hours ago 11 นาที
2 views
วิธีดึงข้อมูลราคาจาก Google Hotels ด้วย API สำหรับนักพัฒนา

วิธีดึงข้อมูลราคาจาก Google Hotels ด้วย API สำหรับนักพัฒนา

อยากทำแอปท่องเที่ยวแต่ดึงข้อมูลราคาจาก Google Hotels ไม่ได้? มาดูวิธีใช้ Apify Actor ช่วยดึงข้อมูลแบบอัตโนมัติด้วย Python ง่ายๆ ไม่ต้องกลัวเว็บพัง

ที่มา: DEV Community

5 hours ago 8 นาที
3 views
วิธีเช็กความพร้อมโปรเจกต์ก่อนปล่อยงานจริงด้วย ReleaseReady

วิธีเช็กความพร้อมโปรเจกต์ก่อนปล่อยงานจริงด้วย ReleaseReady

เคยไหม? โค้ดรันได้ในเครื่องแต่พอปล่อยจริงกลับพัง! มาดูวิธีตรวจสอบความพร้อมของโปรเจกต์ก่อนอัปขึ้น GitHub ด้วยเครื่องมือ ReleaseReady กัน

ที่มา: DEV Community

9 hours ago 9 นาที
4 views