มาแก้ปัญหา “AFBF+CGHB+DAFG+AEAB=BCBC” กันเถอะ

“In programming, the hard part isn’t solving problems, but deciding what problems to solve.” – Paul Graham

“ในการเขียนโปรแกรมนั้น การแก้ปัญหาไม่ใช่สิ่งที่ยากที่สุด แต่สิ่งที่ยากที่สุดคือการตัดสินว่าปัญหาใดบ้างที่ควรได้รับการแก้ไข” – พอล เกรแฮม

Paul Graham ‘s quotes

หากข้อความข้างต้นนั้นถูกต้อง ผมก็คิดว่าปัญหาของนักเรียนประถมอันโด่งดังอย่าง “AFBF+CGHB+DAFG+AEAB=BCBC” ก็ควรได้รับการแก้ไขอย่างเป็นรูปธรรมเสียที การจะส่งต่อปัญหานี้ไปสู่คนรุ่นถัดไปนั้น ดูจะเป็นการไร้ความรับผิดชอบต่อคนรุ่นหลังอย่างรุนแรง เกินกว่าที่จะยอมรับได้ ดังนั้นเราจึงเริ่มพัฒนาโค้ดขึ้นมา เพื่อให้มันไม่ใช่ปัญหาอีกต่อไป แม้ว่ามันจะสร้างปัญหาอื่นตามมาก็ตาม

จุดเริ่มต้น

ทันทีที่เห็นคำถาม “AFBF+CGHB+DAFG+AEAB=BCBC” ในเว็บบอร์ดพันทิป ก็ทราบได้ทันทีว่านี่คงเป็นปัญหาใหญ่มากทีเดียว ผมจึงเริ่มโดยการวิเคราะห์ว่าจะมีหนทางใดบ้างที่แก้ปัญหานี้ได้ สุดท้ายเพื่อให้ปัญหานี้ได้รับการแก้ไขอย่างแท้จริง ผมจึงเลือกที่จะเขียนโค้ดเพื่อหาคำตอบของสมการแบบนี้ โดยกำหนดคุณลักษณะไว้ดังนี้

  1. ตัวอักษรทุกตัวจะมีค่าเป็นเลขโดด และไม่ซ้ำกับตัวอักษรตัวอื่น ดังนั้นแล้วจะมีตัวอักษรได้เพียง 10 ตัวเท่านั้นในโจทย์
  2. รองรับโจทย์ที่มีได้ทั้ง + และ – อยู่ปะปนกัน
  3. ไม่จำเป็นว่าจำนวนหลักจะต้องเท่ากัน
  4. รองรับโจทย์ที่มีตัวเลขแทรกอยู่ในโจทย์ได้
  5. บันทึกผลเป็นไฟล์ได้

วิเคราะห์โจทย์

ต่อมาก็ต้อง วิเคราะห์ว่า ผมควรจะทำอะไรกับโจทย์ที่เป็นข้อความยาวๆนี้บ้าง
สิ่งที่ต้องหาแน่ๆเลยคือ “สัญลักษณ์ – เครื่องหมาย” ต่างๆในโจทย์ เพราะมันจะบอกเราว่าในโจทย์นี้มีกี่กลุ่มข้อความ และแต่ละข้อความต้องทำอะไรบ้าง ส่วนไหนคือผลลัพธ์ ส่วนไหนคือส่วนของโจทย์

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

  1. ชุดข้อความ
  2. รายการตัวอักษร
  3. รายการตัวเลข
  4. ลำดับเครื่องหมาย
  5. ชุดผลลัพธ์

วิธีการที่จะใช้คือ

  1. แทนค่าตัวอักษรด้วยค่าตัวเลข 0-9 โดยที่ค่าตัวเลขนั้นจะไม่ซ้ำกันกับค่าในตัวอักษรอื่น และไม่ซ้ำกับตัวเลขที่มีอยู่แล้ว
  2. แทนค่าตัวอักษร และตัวเลข ในชุดข้อมูลทุกชุด รวมถึงผลลัพธ์
  3. คำนวนตามลำดับชุดข้อมูล และลำดับเครื่องหมาย
  4. ตรวจสอบว่าผลลัพธ์จากการคำนวน ตรงกับค่าที่ได้จากการแทนค่าในชุดข้อมูลผลลัพธ์หรือไม่
  5. ถ้าได้ผลลัพธ์ตรงกัน เก็บค่านั้นใว้ และนำมาแสดงในรายงาน
  6. โปรแกรมจะวนจนครบทุกค่าที่เป็นไปตามเงื่อนไขข้อที่ 1.

ลงมือเขียนสักที

ฟังค์ชั่น main

สองบันทัดนี้เขียนใว้สำหรับรับค่าผ่าน Terminal ไปเก็บเป็นข้อความใว้ในตัวตัวแปร input_equation เพื่อนำไปตรวจสอบในขั้นตอนต่อไปว่ามีข้อมูลครบหรือไม่

โค้ดในส่วนนี้ ใช้การตรวจสอบข้อความที่รับมาว่ามีครบตามสิ่งที่ต้องการหรือไม่
และตรงตามเงื่อนไขหรือไม่ ซึ่งข้อความนั้นจะสามารถมีได้เพียงตัวอักษร A-Z ตัวเลข 0-9 และเครื่องหมาย + , – , = เท่านั้น และไม่รองรับการเว้นวรรค (spacebar) ด้วย
ถ้าหากเป็นตัวอักษรหรือตัวเลข จะถูกนำไปเก็บในรูปแบบตัวแปรแบบ Dictionary
ที่ชื่อ dict_char โดยตัวอักษร หรือตัวเลขนั้น จะถูกใช้เป็น คีย์ (key) ของตัวแปร Dictionary โดยมีค่า (value) เริ่มต้นของแต่ละคีย์ เป็น 0

ตัวแปร eqaulSym_count ใช้เพื่อนับจำนวนเครื่องหมาย ‘=’ ที่มีในโจทย์ ซึ่งแต่ละโจทย์จะมีได้เพียง 1 ตัวเท่านั้น
ตัวแปร operandSym_count ใช้เพื่อนับจำนวนเครื่องหมาย ‘+’ และ ‘-‘ ในโจทย์ ซึ่งแต่ละโจทย์ จะต้องมีเครื่องหมายอย่างน้อย 1 ชนิด
ตัวแปร notAllow_count ใช้เพื่อนับจำนวนอักษรที่ไม่รองรับในข้อความ

จากในส่วนนี้ จะทำให้เราได้ชุดตัวแปร Dictionary ที่มีชนิดของตัวอักษร และตัวเลขทุกตัว ของโจทย์ อยู่ในตัวแปรที่ชื่อ dict_char

ในโค้ดส่วนนี้คือการตรวจสอบว่า ค่าที่ได้จากโค้ดในส่วนก่อนหน้านั้น มีค่าใดบ้างที่แสดงให้เห็นถึงความผิดของข้อความที่รับมา เช่นจำนวนเครื่องหมาย ‘=’ ที่อาจจะมีมากกว่าหนึ่งแห่ง หรือมีสัญลักษณ์อื่นๆที่ระบบไม่รองรับ อยู่ในข้อความ
โค้ดส่วนนี้ก็จะไปทำให้ exception ในส่วน IOError ที่อยู่ท้ายฟังค์ชั่นทำงาน และหลุดจากโปรแกรม

แต่ถ้าหากทุกๆอย่างปกติ ระบบก็จะแสดงข้อความ Status : Check passed
และแสดง โจทย์ที่เราใส่เข้าไปให้ดูอีกครั้ง
ในโค้ดถัดมา ระบบจะแยกข้อความออกเป็นสองส่วน โดยเป็นส่วนของคำถาม ที่อยู่ก่อนเครื่องหมาย ‘=’ และส่วนที่อยู่หลังเครื่องหมาย ‘=’ จะเป็นส่วนของคำตอบ
ซึ่งระบบจะแสดงทั้งสองส่วนให้เราเห็นด้วย

โค้ดในส่วนนี้จะเป็นส่วนที่ใช้ในการแบ่งส่วนของคำถาม ออกเป็นข้อความ โดยแบ่งจากเครื่องหมาย ‘+’ และ ‘-‘ ที่มีอยู่ในส่วนของคำถามนั้น และเก็บข้อความนั้นลงในตัวแปลชนิด List ที่ชื่อ lseq รวมถึงเก็บเครื่องหมายในคำถามลงในตัวแปรชนิด List อีกตัวที่ชื่อ lsoperand ซึ่งตัวแปรทั้งสองตัวจะถูกเก็บตามลำดับตำแหน่งที่อยู่ในชุดคำถาม
เช่น A+B-C ในตัวแปร lseq จะมีค่าดังนี้ [‘A’ , ‘B’ , ‘C’] และในตัวแปร lsoperand จะมีค่าดังนี้ [‘+’ , ‘-‘]

ตัวแปร len_val คือตัวแปรที่ใช้เก็บจำนวนตัวอักษรทุกตัวรวมถึงตัวเลขที่มีอยู่ในโจทย์ของเราด้วย จากนั้นในส่วนถัดมาจะเป็นการแยกระหว่างตัวอักษรและตัวเลข ออกจากกัน
และมีการเก็บค่าตัวเลขไว้ใน s_number โดยเรียงตามลำดับ และถูกเก็บแยกแต่ละตัวในตัวแปรชนิด List ในตัวแปร lsNumberKey โดยเรียงตามลำดับและเก็บตัวอักษรในตัวแปรชนิด List ไว้ใน lsAlphaKey และเรียงตามลำดับเช่นกัน

maxofnum เป็นตัวแปรที่เก็บค่าที่มากที่สุดที่จะนับไปถึง เช่นถ้ามีตัวอักษรเพียงตัวเดียว ระบบก็จะนับสูงสุดคือ 10 หรือถ้าหากเป็น 2 ตัวอักษรก็จะนับไปถึง 100
minofnum เป็นตัวแปรที่ใช้เก็บค่าเริ่มต้นในการนับ เช่นถ้าหากมีอักษรเพียงตัวเดียวก็จะเริ่มที่ 0 แต่ถ้าหากมี 2 ตัวก็จะเริ่มที่ 01 และถ้าหากมี 3 ตัวอักษรก็จะเริ่มที่ 012
offset_val ใช้เก็บค่า Offset ที่เกิดจากการมีตัวเลขในชุดข้อมูล เพื่อให้ระบบไม่นับซ้ำในส่วนของตัวเลข ซึ่งจะทำให้ลดจำนวนตัวเลขที่จะต้องนับได้มากทีเดียว

เช่น ถ้าหากโจทย์คือ “A1+A3=BB”
maxofnum จากการมีตัวอีกษร 2 ตัว คือ 100
minofnum จากการที่มี 2 ตัวอักษร คือ 01
offset_val จากตัวเลข 2 ตัวคือ 1 และ 3 จะเท่ากับ 1300
ดังนั้นระบบจะเริ่มนับจาก minofnum+offset_val เท่ากับ 1301 ไปจนถึง maxofnum+offset_val เท่ากับ 1399 นั่นคือนับแค่ 99 ค่า สำหรับอักษร 2 ตัว

ตัวอย่างจำนวนเริ่ม และจำนวนสุดท้าย ของโจทย์ 8 ตัวอักษร

เมื่อได้ค่าเริ่มต้นและค่าสุดท้ายที่จะวนทดสอบเพื่อหาคำตอบกันแล้ว แต่ค่าที่อยู่ในระหว่างค่าเริ่มต้นกับค่าสุดท้ายนั้น มีค่าที่มีตัวเลขซ้ำกันอยู่มากมาย ดังนั้นเราต้องมากรองเอาค่าพวกนั้นออกไปก่อน วิธีคือเราจะวนในทุกๆค่าที่อยู่ระหว่างค่าเริ่มต้น ไปจนถึงค่าสุดท้าย
แล้วตรวจดูทีละค่าว่ามีตัวเลขซ้ำกันหรือไม่ โดยการแปลงค่าที่กำลังนับอยู่นั้นให้อยู่ในรูปของข้อความ แล้ววนเทียบตัวอักษรทีละตัว ถ้าหากมีเลขที่ซ้ำกันก็จะข้ามจำนวนนั้นไป
แต่ถ้าหากจำนวนนั้นไม่มีเลขที่ซ้ำกันเลย จำนวนนั้นก็จะถูกแยกกลับเข้าไปเก็บในตัวแปร dict_char โดยเรียงตามลำดับ ก่อนที่จะแพ็ค dict_char, ชุดข้อความ lseq, ชุดเครื่องหมาย lsoperand และผลลัพธ์ res รวมกันในตัวแปรชนิด Dictionary ที่ชื่อว่า dict_pack ที่เลือกใช้ตัวแปรชนิด Dictionary ก็เพราะว่ามันชัดเจนเวลาอ้างอิงตอนใช้งาน แล้วก็เพิ่มค่า dict_pack ลงในตัวแปรชนิด List ที่ชื่อ lsNumber
ทำให้ใน lsNumber มีค่าทุกค่าที่ผ่านเงื่อนไขมาครบแล้ว และพร้อมจะนำไปทดสอบว่ามีค่าใดบ้างที่เมื่อแทนค่าในโจทย์แล้ว ได้คำตอบที่ถูกต้อง

ด้วยความที่เราจะต้องทดสอบในทุกๆ ค่าที่มีใน lsNumber ซึ่งจะมีจำนวนสมาชิกขึ้นกับจำนวนตัวอักษรในโจทย์ที่เราใส่เข้าไป ซึ่งเป็นไปได้ว่าอาจจะมีจำนวนมากถึงหลักล้านค่า หรือหลายล้านค่า ซึ่งถ้าหากปล่อยให้โปรแกรมทำงานในรูปแบบ Single Processing ตามปกติ คงจะใช้เวลานานมากๆกว่าจะทำงานได้ครบทั้งหมด ผมจึงเลือกใช้วิธี Multiprocessing โดยใช้ pool of workers ในการรันโปรแกรมนี้

และสร้างฟังค์ชั่น findValue ไว้สำหรับทดสอบค่าที่รับจากตัวแปรประเภท Dictionary ที่กำหนดเข้ามา และส่งค่ากลับเป็นตัวแปรชนิด Dictionary เช่นกัน
ซึ่งภายในตัวแปร Dictionary นั้นประกอบไปด้วย คีย์ ‘Result’ ที่บ่งบอกผลลัพธ์ว่าค่าที่ใส่เข้ามานั้นถูกต้องหรือไม่ เป็นแบบ Boolean มีแค่ True หรือ False เท่านั้น
และคีย์ ‘Value’ ที่เป็นค่าเดียวกับที่รับค่าเข้ามาในฟังค์ชั่นนี้

ค่าที่ได้จากฟังค์ชั่น findValue นี้จะถูกเก็บไว้ในตัวแปร List ที่ชื่อ proc

cpu_count คือตัวแปรที่ใช้เก็บจำนวน Processor Core ที่มีในเครื่อง
pl คือ pool ของ Worker ที่มีจำนวนเท่ากับที่กำหนดผ่าน processes = int(cpu_count) ซึ่งสามารถกำหนดเป็นค่าอื่นๆก็ได้เช่นกัน โดยไม่ต้องอ้างอิงกับจำนวน Processor Core ในเครื่องที่ใช้ เช่นอาจจะกำหนดให้ Processes = 8 หรือ Processes = int(cpu_count)*2 ก้ได้เช่นกัน แต่ที่ผมกำหนดให้เท่ากันนั้นเพราะว่าไม่ต้องการให้โปรแกรมนี้ดึงทรัพยากรของเครื่องมากเกินไป ซึ่งทรัพยากรเครื่องก็มีเหลืออยู่ไม่มาก หลังจากที่หมดไปกับ Chrome , Spotify และตัว VSCode แล้วนี่แหละ

รูปแบบการทำงานของ Pool of Worker ในรูปนี้มี 3 Worker หรือ 3 Processes
ซึ่ง #1 , #2 และ #3 จะทำงานพร้อมกัน

หลังจากที่ได้ค่าจากฟังค์ชั่น findValue ที่ถูกเก็บใว้ในตัวแปร proc ครบแล้ว
ขั้นต่อมาคือการคัดเอาเฉพาะค่าที่ถูกต้องเก็บไว้ในไฟล์ที่ชื่อ Resultlists.txt
ซึ่งโปรแกรมจะแสดงค่าที่ถูกต้อง พร้อมทั้งแทนค่าในโจทย์ เพื่อให้ตรวจสอบได้ง่ายขึ้นว่าสิ่งที่ได้ ถูกต้องจริงหรือมีข้อผิดพลาดใดหรือไม่ พร้อมทั้งบันทึกค่า ทั้งค่าที่ถูก และโจทย์ที่ถูกแทนค่าแล้วลงในไฟล์ Resultlists.txt ด้วย

นอกจากนั้นแล้วยังแสดง เวลาที่เริ่มต้นทำงาน ไปจนถึงเวลาที่โปรแกรมทำงานแล้วเสร็จ
และจำนวนที่ระบบ พร้อมทั้งระยะเวลา ที่ได้รันไปเพื่อหาคำตอบของโจทย์ข้อนี้อีกด้วย

ฟังค์ชั่น findValue

ฟังค์ชั่นนี้เริ่มต้นด้วยเก็บค่าตัวแปรประเภท Dictionary ที่รับเข้ามาในชื่อ Dict_Pack
และแยกค่าในแต่ละคีย์ใว้ในตัวแปรแต่ละตัว โดย
dict_val ใช้เก็บค่าของตัวอักษรแต่ละตัวซึ่งอยู่ในคีย์ 'dict_char'
lsequat ใช้เก็บค่าชุดข้อความเรียงตามลำดับ ซึ่งถูกเก็บใว้ในคีย์ 'ls_Equat'
lsoperands ใช้เก็บค่าเครื่องหมายเรียงตามลำดับ ซึ่งถูกเก็บใว้ในคีย์ 'ls_Operand'
res ใช้เก็บผลลัพธ์ของโจทย์ ซึ่งเก็บไว้ในคีย์ 'ls_Result'

dict_val ใช้เก็บค่าของตัวอักษรแต่ละตัวซึ่งอยู่ในคีย์ 'dict_char'
lsequat ใช้เก็บค่าชุดข้อความเรียงตามลำดับ ซึ่งถูกเก็บใว้ในคีย์ 'ls_Equat'
lsoperands ใช้เก็บค่าเครื่องหมายเรียงตามลำดับ ซึ่งถูกเก็บใว้ในคีย์ 'ls_Operand'
res ใช้เก็บผลลัพธ์ของโจทย์ ซึ่งเก็บไว้ในคีย์ 'ls_Result'

ส่วนนี้คือส่วนที่แทนค่าของแต่ละตัวอักษรจาก dict_val ลงในชุดข้อความ lsequat และ res และเก็บค่าที่ได้จากการแทนค่าแล้วในรูปแบบเลขจำนวนเต็มลงในตัวแปรประเภท List ที่ชื่อ lsValInt

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

ส่วนสุดท้ายของฟังก์ชันนี้คือการตรวจสอบว่า ค่าที่ได้จากการแทนค่าในชุดข้อความ และคำนวนตามชุดเครื่องหมายนั้น ได้ค่าเท่ากับค่าที่ได้จากการแทนค่าในส่วนของผลลัพธ์หรือไม่
ซึ่งในส่วนนี้จะมีการแสดงค่าที่รับเข้ามา และผลลัพธ์ว่าใช้ได้หรือไม่ด้วย
ซึ่งค่าเหล่านั้นถูกเก็บใว้ในตัวแปรประเภท Dictionary ที่ชื่อ dict_res โดยผลของการตรวจสอบจะเก็บเป็น Boolean ในคีย์ 'Result' และค่าที่รับเข้ามาจะเก็บอยู่ในคีย์ 'Value'
dict_res นี่คือค่าที่จะได้จากการทำงานของฟังค์ชั่นนี้

มาลองใช้งานกัน

เริ่มต้นด้วยการรันไฟล์ main.py ใน Terminal

ตัวอย่างเมื่อรันโปรแกรมสำเร็จ

บน Console ก็จะมีข้อความ ขึ้นมาว่า “Welcome to Find for What?”
และ “What’s your Equation : ” และมี cursor ให้เราใส่โจทย์ลงไป
โดยโจทย์จะประกอบด้วยตัวอักษร A-Z , 0-9 เครื่องหมาย ‘+’ , ‘-‘ และ ‘=’
เครื่องหมาย ‘=’ จะมีได้เพียง 1 อันใน 1 โจทย์เท่านั้น

ทีนี้เราจะลองใส่โจทย์เจ้าปัญหาของเรานั่นคือ AFBF+CGHB+DAFG+AEAB=BCBC ลงไปเพื่อหาคำตอบกันนะครับ

ใส่โจทย์ตัวอย่างเพื่อลองให้โปรแกรมหาคำตอบ

และกดปุ่ม “Enter” เพิ่มเริ่มการคำนวน

โปรแกรมเริ่มทำงานหลังกดปุ่ม Enter

โปรแกรมจะแสดงข้อมูลต่างๆที่เราได้ใส่เข้าไป ทั้งจำนวนตัวอักษร จำนวนตัวเลขที่จะวนทำงานจากนั้นโปรแกรมก็จะเริ่มทำงาน

สภาวะเมื่อโปรแกรมเริ่มทำงาน

และเราก็เพียงรอ…. และภาวนา เท่านั้นเอง จนกว่าโปรแกรมจะทำงานจบ

สภาวะเมื่อโปรแกรมทำงานจนจบ

เมื่อโปรแกรมทำงานจนจบแล้ว
โปรแกรมจะแสดงค่าที่ถูกต้อง ซึ่งมีเพียง 4 ค่าเท่านั้น ที่แทนแล้วจะทำให้โจทย์นั้นเป็นจริงได้ หลังจากผ่านคำนวนจำนวนด้วยค่าที่ไม่ซ้ำกัน 1,814,400 ค่า และใช้เวลาไป 3 ชั่วโมง 12 นาที กับอีก 15 วินาที นับว่านานทีเดียว
และเราสามารถดูข้อมูลคำตอบได้ในไฟล์ Resultlists.txt ได้เช่นกัน เพื่อความสะดวกในกรณีที่คำตอบมีปริมาณเยอะๆ

โค้ดทั้งหมด