การจำ (คอมพิวเตอร์)
การจำ หรือ การจำค่า หรือ เมโมอิเซชัน (อังกฤษ: memoization) เป็นเทคนิคเพิ่มประสิทธิภาพในการเขียนโปรแกรม ซึ่งเก็บผลลัพธ์ที่คำนวณแล้วของฟังก์ชันไว้โดยมีอาร์กิวเมนต์หรือสถานะที่เกี่ยวข้องเป็นกุญแจ เมื่อมีการเรียกฟังก์ชันด้วยค่าป้อนเข้าเดิมอีกครั้ง โปรแกรมจึงส่งผลลัพธ์ที่เก็บไว้กลับได้แทนการคำนวณซ้ำ การจำค่าเป็นการใช้แคชรูปแบบหนึ่ง และมักอาศัยตารางแฮชเพื่อค้นหาผลลัพธ์อย่างรวดเร็ว จึงเป็นตัวอย่างของการแลกหน่วยความจำกับเวลาในการคำนวณ[1]
เทคนิคนี้มีประโยชน์เป็นพิเศษเมื่อการคำนวณเดิมเกิดซ้ำ เช่น ฟังก์ชันเวียนเกิดที่มีปัญหาย่อยทับซ้อนกัน (overlapping problem) แต่การจำค่าไม่จำกัดอยู่ที่ฟังก์ชันเวียนเกิด และไม่ทำให้ทุกโปรแกรมเร็วขึ้นเสมอไป
ประวัติ
[แก้]ดอนัลด์ มิกกีใช้คำว่า memo functions ในบทความปี ค.ศ. 1968 เรื่อง “Memo” Functions and Machine Learning ซึ่งเสนอการเรียนรู้แบบจดจำผลลัพธ์เพื่อให้โปรแกรมปรับปรุงประสิทธิภาพระหว่างทำงาน[2] คำว่า memoization ในเวลาต่อมาจึงสื่อถึงการทำให้ผลลัพธ์ของฟังก์ชันเป็นสิ่งที่โปรแกรมจำไว้ใช้ได้
หลักการ
[แก้]ให้ เป็นฟังก์ชัน และ เป็นตารางเก็บผลลัพธ์ หากโปรแกรมต้องการ จะทำงานดังนี้
- ถ้ามีค่า อยู่แล้ว ให้คืนค่านั้นทันที
- ถ้ายังไม่มี ให้คำนวณ เก็บ แล้วคืน
ดังนั้นผลลัพธ์ของการเรียกครั้งแรกเรียกว่า แคชมิส cache miss ส่วนการนำผลที่มีอยู่แล้วมาใช้เรียกว่า แคชฮิต cache hit ตารางอาจเป็นตารางแฮช แถวลำดับ หรือโครงสร้างข้อมูลอื่นตามลักษณะของคีย์และจำนวนสถานะ
ความถูกต้องของผลลัพธ์
[แก้]กรณีที่เหมาะกับการใช้เมโมอิเซชันมากที่สุดคือ กรณีฟังก์ชันบริสุทธิ์ (pure function) ซึ่งหมายถึงฟังก์ชันที่ให้ผลลัพธ์เหมือนเดิมเสมอเมื่อได้รับข้อมูลนำเข้าเดียวกัน และไม่มีผลข้างเคียง (side effect) ที่ส่งผลต่อส่วนอื่นของโปรแกรม หลักการนี้สอดคล้องกับแนวคิด ความโปร่งใสเชิงอ้างอิง (referential transparency) กล่าวคือ สามารถแทนที่การเรียกฟังก์ชันด้วยค่าผลลัพธ์ของมันได้ โดยพฤติกรรมของโปรแกรมจะไม่เปลี่ยนแปลง
ตัวอย่างของฟังก์ชันบริสุทธิ์ (pure function) ได้แก่ ฟังก์ชันคำนวณทางคณิตศาสตร์ เช่น การหาค่าแฟกทอเรียล (factorial(n)), จำนวนฟีโบนัชชี (fibonacci(n)), หาค่าสัมบูรณ์ (abs(x)) หรือหาค่าโคไซน์ sin(x) ซึ่งเมื่อรับข้อมูลนำเข้าเดียวกันก็จะให้ผลลัพธ์เดิมเสมอ จึงเหมาะกับการใช้เมโมอิเซชัน
ในทางกลับกัน ฟังก์ชันที่ผลลัพธ์อาจเปลี่ยนแปลงตามเวลา ตัวเลขสุ่ม สถานะของแฟ้มข้อมูล หรือข้อมูลจากเครือข่าย มักไม่เหมาะกับการจำค่าจากอาร์กิวเมนต์เพียงอย่างเดียว เพราะค่าที่บันทึกไว้สามารถล้าสมัยได้ หากจำเป็นต้องใช้เมโมอิเซชัน ควรนำปัจจัยที่มีผลต่อผลลัพธ์มาเป็นส่วนหนึ่งของกุญแจแคช หรือกำหนดวิธีล้างและหมดอายุของแคชให้เหมาะสม ไม่ควรใช้เมโมอิเซชันกับฟังก์ชันที่มีผลข้างเคียงหรือให้ผลลัพธ์ไม่คงที่ ซึ่งผลลัพธ์อาจเปลี่ยนแปลงได้ เช่น การอ่านเวลาปัจจุบัน (time()), การสุ่มตัวเลข (random()), การอ่านข้อมูลจากแฟ้มข้อมูลหรือฐานข้อมูล (readFile(), queryDatabase()), การดึงข้อมูลจากอินเทอร์เน็ต (fetch()) หรือการตรวจสอบอุณหภูมิจากเซนเซอร์ แม้จะเรียกด้วยอาร์กิวเมนต์เดียวกันก็อาจให้ผลลัพธ์ต่างกัน จึงไม่เหมาะกับการใช้เมโมอิเซชัน เว้นแต่จะกำหนดวิธีจัดการแคชเพิ่มเติม[3]
ตัวอย่างเมโมอิเซชัน
[แก้]Pseudo-code ต่อไปนี้แสดงการห่อฟังก์ชัน f ให้กลายเป็นฟังก์ชันที่จำค่าได้ คีย์ต้องแยกค่าป้อนเข้าคนละชุดออกจากกันอย่างถูกต้อง
ฟังก์ชัน จำค่า(f)
แคช ← ว่าง
ฟังก์ชัน g(ข้อมูลนำเข้า...)
ถ้าเคยคำนวณข้อมูลนำเข้านี้แล้ว
คืนค่าผลลัพธ์ที่จำไว้
ผลลัพธ์ ← f(ข้อมูลนำเข้า...)
จำผลลัพธ์ไว้
คืนค่าผลลัพธ์
คืนค่า g
สำหรับฟังก์ชันเวียนเกิด การเรียกฟังก์ชันย่อยภายในต้องเรียกผ่านฟังก์ชันฉบับที่มีการจำค่าด้วย มิฉะนั้นฟังก์ชันย่อยจะยังคำนวณค่าเดิมซ้ำ ๆ และไม่ได้รับประโยชน์จากเมโมอิเซชัน การเพิ่มความสามารถนี้ให้ฟังก์ชันอาจทำได้ด้วย ฟังก์ชันอันดับสูง (higher-order function) หรือ ส่วนตกแต่ง (decorator) ในภาษาที่รองรับการส่งฟังก์ชันเป็นค่า ตัวอย่างเช่น ปีเตอร์ นอร์วิกได้สาธิตวิธีสร้างเมโมอิเซชันแบบอัตโนมัติ และนำไปใช้เพิ่มประสิทธิภาพการพาร์สไวยากรณ์แบบไม่พึ่งบริบท (context-free parsing)[4]
ตัวอย่าง: จำนวนฟีโบนัชชี
[แก้]จำนวนฟีโบนัชชีนิยามได้ว่า:
การคำนวณตามนิยามด้วยฟังก์ชันเวียนเกิดแบบตรงไปตรงมาจะเรียก เดิมซ้ำหลายครั้ง ภาพแรกแสดงต้นไม้การเรียกที่แตกกิ่งซ้ำ ส่วนภาพที่สองยุบปัญหาย่อยเดียวกันให้เหลือสถานะเดียว ซึ่งเป็นแนวคิดที่การจำค่าทำให้เกิดขึ้นในการคำนวณจริง
- ต้นไม้การเรียกแบบเวียนเกิดของฟังก์ชันฟีโบนัชชีสำหรับ ; ปัญหาย่อยเดียวกันปรากฏหลายครั้ง
- กราฟแสดงปัญหาย่อยที่ยุบซ้ำแล้ว; แต่ละ ต้องคำนวณเพียงครั้งเดียว
Pseudo-code แบบจำค่ามีดังนี้:
จำค่าที่เคยคำนวณไว้
ฟังก์ชัน fib(n)
ถ้าเคยคำนวณ n แล้ว
คืนค่าที่จำไว้
คำนวณค่า fib(n)
จำผลลัพธ์ไว้
คืนค่าผลลัพธ์
ในภาษาจาวามักใช้ HashMap เก็บค่าที่คำนวณแล้ว:
import java.util.HashMap;
import java.util.Map;
public class Fibonacci {
private static final Map<Integer, Long> cache = new HashMap<>();
public static long fib(int n) {
if (n < 2) {
return n;
}
if (cache.containsKey(n)) {
return cache.get(n);
}
long result = fib(n - 1) + fib(n - 2);
cache.put(n, result);
return result;
}
public static void main(String[] args) {
System.out.println(fib(50));
}
}
ในภาษาไพทอน สามารถเขียนแนวคิดเดียวกันได้ด้วย functools.cache ดังนี้[5]:
from functools import cache
@cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
ในภาษาซี ไม่มีตัวตกแต่งอย่าง @cache และไม่มีแผนที่ (map) ในไลบรารีมาตรฐาน จึงมักใช้อาร์เรย์เป็นแคช:
#include
#include
long cache[100];
bool computed[100];
long fib(int n) {
if (n < 2)
return n;
if (computed[n])
return cache[n];
cache[n] = fib(n - 1) + fib(n - 2);
computed[n] = true;
return cache[n];
}
int main(void) {
printf("%ld\n", fib(50));
return 0;
}
ในภาษาแฮสเคิล สามารถเขียนเมโมอิเซชันของฟีโบนักชีแบบง่ายได้ด้วยลิสต์แบบประเมินค่าแบบขึ้เกียจ (lazy evaluation)
fibs :: [Integer]
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
fib :: Int -> Integer
fib n = fibs !! n
n ในตัวอย่างเป็นจำนวนเต็มจึงใช้เป็นกุญแจได้โดยตรง สำหรับ functools.cache อาร์กิวเมนต์ตามตำแหน่งและตามชื่อจะต้องเป็นค่าที่แฮชได้ (hashable); เช่น รายการและพจนานุกรมของไพทอนไม่อาจใช้เป็นอาร์กิวเมนต์โดยตรงในแคชนี้[5]
ภายใต้แบบจำลองที่นับการบวกและการค้นหาตารางเป็นเวลาคงที่ ฟังก์ชันเวียนเกิดแบบไม่จำค่ามีจำนวนการเรียกเป็น โดย คืออัตราส่วนทองคำ ขณะที่ฉบับข้างต้นคำนวณสถานะ ถึง อย่างละไม่เกินหนึ่งครั้ง จึงใช้เวลา และเก็บ สถานะ[1] ในการคำนวณจำนวนเต็มขนาดใหญ่มาก ต้นทุนของการบวกเองเพิ่มขึ้นตามจำนวนหลักของผลลัพธ์ ดังนั้นขอบเขตดังกล่าวเป็นการวิเคราะห์ตามแบบจำลองหน่วยต้นทุนเพื่ออธิบายผลของการจำค่า[6]
ตัวอย่างปัญหาอื่น
[แก้]การจำค่าไม่ได้ใช้เฉพาะกับจำนวนฟีโบนัชชี แต่ใช้ได้ดีกับปัญหาที่การคำนวณแบบเวียนเกิดพบปัญหาย่อยเดิมซ้ำหลายครั้ง โดยกำหนดคีย์ของแคชให้แทนสถานะของปัญหาย่อยนั้น ตัวอย่างสำคัญมีดังนี้[1]
| ปัญหา | สถานะที่บันทึก | ค่าที่ต้องการหา |
|---|---|---|
| ปัญหากระเป๋าเป้ | จำนวนสิ่งของที่พิจารณาและความจุที่เหลือ | มูลค่ารวมสูงสุดของสิ่งของที่เลือกได้ |
| ระยะทางเลเวนชเตย์น | ตำแหน่งในสายอักขระสองสาย | จำนวนการแก้ไขน้อยที่สุดเพื่อเปลี่ยนสายหนึ่งเป็นอีกสายหนึ่ง |
| ลำดับย่อยร่วมยาวที่สุด | ตำแหน่งในลำดับสองลำดับ | ความยาวของลำดับย่อยที่ปรากฏร่วมกัน |
| ปัญหาการทอนเหรียญ | จำนวนเงินที่เหลือและชนิดเหรียญที่พิจารณา | จำนวนเหรียญน้อยที่สุด หรือจำนวนวิธีทอนเงิน |
| การคูณลูกโซ่ของเมทริกซ์ | ดัชนีเมทริกซ์ต้นและปลายของช่วง | ลำดับการคูณที่ใช้การคูณสเกลาร์น้อยที่สุด |
| การหาทางในตาราง | ตำแหน่งปัจจุบันในตาราง | จำนวนเส้นทาง หรือต้นทุนต่ำที่สุดจากตำแหน่งนั้น |
ลำดับย่อยร่วมยาวที่สุด
[แก้]ลำดับย่อยร่วมยาวที่สุด หรือแอลซีเอส (longest common subsequence; LCS) เป็นตัวอย่างมาตรฐานของปัญหาที่มีปัญหาย่อยทับซ้อนกัน ให้ และ เป็นลำดับที่มีความยาว และ ตามลำดับ และให้ หมายถึงความยาวของลำดับย่อยร่วมยาวที่สุดของสมาชิก ตัวแรกของ กับสมาชิก ตัวแรกของ จะได้ว่า
เมื่อสมาชิกท้ายสุดของลำดับทั้งสองไม่ตรงกัน ขั้นตอนวิธีต้องพิจารณาทั้งสถานะ และ จึงอาจกลับมาพบสถานะเดียวกันจากเส้นทางต่างกัน การจำค่าของแต่ละคู่ ช่วยให้สถานะหนึ่งคำนวณไม่เกินหนึ่งครั้ง
แคช ← แผนที่ว่าง
ฟังก์ชัน lcs(i, j)
ถ้า (i, j) มีอยู่ใน แคช
คืนค่า แคช[(i, j)]
ถ้า i = 0 หรือ j = 0
ผลลัพธ์ ← 0
มิฉะนั้น ถ้า X[i] = Y[j]
ผลลัพธ์ ← 1 + lcs(i − 1, j − 1)
มิฉะนั้น
ผลลัพธ์ ← ค่าสูงสุดของ
lcs(i − 1, j)
และ lcs(i, j − 1)
แคช[(i, j)] ← ผลลัพธ์
คืนค่า ผลลัพธ์
มีสถานะ ได้ไม่เกิน สถานะ ดังนั้นการหาความยาวของ LCS ด้วยการจำค่าจึงใช้เวลาและพื้นที่ในกรณีเลวร้ายที่สุดเป็น [1]
ปัญหากระเป๋าเป้แบบศูนย์–หนึ่ง
[แก้]ในปัญหากระเป๋าเป้แบบศูนย์–หนึ่ง สิ่งของแต่ละชิ้นเลือกได้ไม่เกินหนึ่งครั้ง ให้ แทนมูลค่าสูงสุดที่หาได้จากสิ่งของ ชิ้นแรก เมื่อกระเป๋ามีความจุเหลือ สถานะจึงระบุได้ด้วยคู่
หากสิ่งของชิ้นที่ มีน้ำหนักเกินความจุที่เหลือ จะเลือกชิ้นนั้นไม่ได้ แต่หากเลือกได้ ขั้นตอนวิธีจะเปรียบเทียบมูลค่าระหว่างการเลือกและไม่เลือกสิ่งของชิ้นนั้น
แคช ← แผนที่ว่าง
ฟังก์ชัน knapsack(i, ความจุ)
ถ้า (i, ความจุ) มีอยู่ใน แคช
คืนค่า แคช[(i, ความจุ)]
ถ้า i = 0 หรือ ความจุ = 0
ผลลัพธ์ ← 0
มิฉะนั้น ถ้า น้ำหนัก[i] > ความจุ
ผลลัพธ์ ← knapsack(i − 1, ความจุ)
มิฉะนั้น
เลือก ← มูลค่า[i]
+ knapsack(i − 1, ความจุ − น้ำหนัก[i])
ไม่เลือก ← knapsack(i − 1, ความจุ)
ผลลัพธ์ ← ค่าสูงสุดของ เลือก และ ไม่เลือก
แคช[(i, ความจุ)] ← ผลลัพธ์
คืนค่า ผลลัพธ์
ถ้ามีสิ่งของ ชิ้นและความจุกระเป๋าเป็นจำนวนเต็ม จะมีสถานะได้ไม่เกิน สถานะ จึงใช้เวลาและพื้นที่ ในกรณีเลวร้ายที่สุด ขอบเขตนี้เป็นเวลาพหุนามเทียม (pseudopolynomial time) เพราะขึ้นกับค่าตัวเลขของ ไม่ใช่เพียงจำนวนบิตที่ใช้แทนค่า[1]
ความสัมพันธ์กับกำหนดการพลวัต
[แก้]การจำค่าเป็นวิธีหนึ่งที่ใช้ในกำหนดการพลวัต โดยเหมาะกับปัญหาที่มีปัญหาย่อยทับซ้อนกัน (overlapping subproblems) เมื่อเริ่มจากปัญหาใหญ่แล้วเรียกปัญหาย่อยตามต้องการ วิธีนี้มักเรียกว่า บนลงล่าง (top-down) ตารางแคชจึงถูกเติมเมื่อมีการเข้าถึงสถานะนั้นจริง และอาจมีหลายสถานะที่ไม่ต้องคำนวณเลย
อีกวิธีหนึ่งคือ การจัดทำตาราง (tabulation) ซึ่งกำหนดลำดับของสถานะจากฐานไปยังคำตอบ แล้วคำนวณด้วยการวนซ้ำหรือวิธีล่างขึ้นบน (bottom-up) ทั้งสองวิธีใช้ผลลัพธ์ของปัญหาย่อยซ้ำได้เหมือนกัน แต่ต่างกันที่ลำดับการคำนวณและจำนวนสถานะที่อาจต้องเก็บ ไม่ควรถือว่าการจำค่าและกำหนดการพลวัตเป็นคำพ้องความหมายกัน[1]
| ลักษณะ | การจำค่า | การทำตาราง |
|---|---|---|
| ทิศทางการคำนวณ | เริ่มจากปัญหาที่ถาม แล้วแตกเป็นปัญหาย่อย | เริ่มจากกรณีฐาน แล้วสร้างคำตอบขึ้นทีละขั้น |
| เวลาที่สร้างค่า | เมื่อมีการเรียกสถานะนั้น | ตามลำดับที่กำหนดไว้ล่วงหน้า |
| รูปแบบที่พบได้บ่อย | ฟังก์ชันเวียนเกิดร่วมกับแคช | การวนซ้ำเติมแถวลำดับหรือตาราง |
| จุดที่ต้องระวัง | ค่าใช้จ่ายของการเรียกเวียนเกิดและขนาดแคช | ต้องหาอันดับของปัญหาย่อยที่ถูกต้อง และอาจคำนวณสถานะที่ไม่จำเป็น |
ข้อแลกเปลี่ยนและข้อควรระวัง
[แก้]การจำค่าจะคุ้มค่าเมื่อการคำนวณเดิมมีต้นทุนสูง มีค่าป้อนเข้าซ้ำ และค่าในแคชมีขนาดที่จัดการได้ ในทางกลับกัน การจำค่าอาจไม่ช่วยหรือทำให้แย่ลงได้ในกรณีต่อไปนี้
- ค่าป้อนเข้าซ้ำกันน้อย จนต้นทุนการสร้างคีย์และค้นหาแคชสูงกว่าการคำนวณใหม่
- จำนวนคีย์ไม่จำกัดหรือผลลัพธ์มีขนาดใหญ่ ทำให้หน่วยความจำเติบโตต่อเนื่อง
- คีย์ไม่ครอบคลุมสถานะภายนอก หรือผลลัพธ์เปลี่ยนไปตามเวลา ทำให้คืนค่าที่ล้าสมัย
- ผลลัพธ์เป็นวัตถุที่แก้ไขได้และผู้เรียกเปลี่ยนวัตถุนั้นภายหลัง ซึ่งอาจเปลี่ยนค่าที่เก็บในแคชโดยไม่ตั้งใจ
- มีการเรียกพร้อมกันหลายเธรด: แม้โครงสร้างแคชจะป้องกันความเสียหายของข้อมูลได้ การคำนวณเดียวกันอาจยังเกิดซ้ำได้หากหลายเธรดพบ cache miss ก่อนที่เธรดแรกจะเก็บผลลัพธ์[5]
แคชที่ใช้เป็นเวลานานจึงมักกำหนดขนาดสูงสุด กลไกแทนที่ข้อมูล หรือวิธีล้างแคช เอกสาร Python ระบุว่าการจำค่าด้วยพจนานุกรมเก็บอ้างอิงของอาร์กิวเมนต์และผลลัพธ์ไว้จนกว่าจะล้างหรือถูกแทนที่ และมี lru_cache สำหรับจำกัดขนาดด้วยนโยบายการใช้ล่าสุดก่อน[5]
ดูเพิ่ม
[แก้]อ้างอิง
[แก้]- 1 2 3 4 5 6 Cormen, Thomas H.; Leiserson, Charles E.; Rivest, Ronald L.; Stein, Clifford (2022). "Dynamic Programming". Introduction to Algorithms (พิมพ์ครั้งที่ 4). Cambridge, Massachusetts: MIT Press. ISBN 978-0-262-04630-5.
- ↑ Michie, Donald (1968). ""Memo" Functions and Machine Learning". Nature. 218: 19–22. doi:10.1038/218019a0.
- ↑ "functools — Higher-order functions and operations on callable objects". Python documentation. Python Software Foundation. สืบค้นเมื่อ 2026-07-23.
- ↑ Norvig, Peter (1991). "Techniques for Automatic Memoization with Applications to Context-Free Parsing". Computational Linguistics. 17 (1): 91–98.
- 1 2 3 4 "functools — Higher-order functions and operations on callable objects". Python documentation. Python Software Foundation. สืบค้นเมื่อ 2026-07-23.
- ↑ Lim, Jason (2018-07-23). "Fibonacci and Memoization". Medium (ภาษาอังกฤษ). สืบค้นเมื่อ 2026-07-23.