ข้ามไปเนื้อหา

การจำ (คอมพิวเตอร์)

จากวิกิพีเดีย สารานุกรมเสรี

การจำ หรือ การจำค่า หรือ เมโมอิเซชัน (อังกฤษ: 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]

การเปรียบเทียบภายในบริบทของกำหนดการพลวัต
ลักษณะ การจำค่า การทำตาราง
ทิศทางการคำนวณ เริ่มจากปัญหาที่ถาม แล้วแตกเป็นปัญหาย่อย เริ่มจากกรณีฐาน แล้วสร้างคำตอบขึ้นทีละขั้น
เวลาที่สร้างค่า เมื่อมีการเรียกสถานะนั้น ตามลำดับที่กำหนดไว้ล่วงหน้า
รูปแบบที่พบได้บ่อย ฟังก์ชันเวียนเกิดร่วมกับแคช การวนซ้ำเติมแถวลำดับหรือตาราง
จุดที่ต้องระวัง ค่าใช้จ่ายของการเรียกเวียนเกิดและขนาดแคช ต้องหาอันดับของปัญหาย่อยที่ถูกต้อง และอาจคำนวณสถานะที่ไม่จำเป็น

ข้อแลกเปลี่ยนและข้อควรระวัง

[แก้]

การจำค่าจะคุ้มค่าเมื่อการคำนวณเดิมมีต้นทุนสูง มีค่าป้อนเข้าซ้ำ และค่าในแคชมีขนาดที่จัดการได้ ในทางกลับกัน การจำค่าอาจไม่ช่วยหรือทำให้แย่ลงได้ในกรณีต่อไปนี้

  1. ค่าป้อนเข้าซ้ำกันน้อย จนต้นทุนการสร้างคีย์และค้นหาแคชสูงกว่าการคำนวณใหม่
  2. จำนวนคีย์ไม่จำกัดหรือผลลัพธ์มีขนาดใหญ่ ทำให้หน่วยความจำเติบโตต่อเนื่อง
  3. คีย์ไม่ครอบคลุมสถานะภายนอก หรือผลลัพธ์เปลี่ยนไปตามเวลา ทำให้คืนค่าที่ล้าสมัย
  4. ผลลัพธ์เป็นวัตถุที่แก้ไขได้และผู้เรียกเปลี่ยนวัตถุนั้นภายหลัง ซึ่งอาจเปลี่ยนค่าที่เก็บในแคชโดยไม่ตั้งใจ
  5. มีการเรียกพร้อมกันหลายเธรด: แม้โครงสร้างแคชจะป้องกันความเสียหายของข้อมูลได้ การคำนวณเดียวกันอาจยังเกิดซ้ำได้หากหลายเธรดพบ cache miss ก่อนที่เธรดแรกจะเก็บผลลัพธ์[5]

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

ดูเพิ่ม

[แก้]

อ้างอิง

[แก้]
  1. 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.
  2. ↑ Michie, Donald (1968). ""Memo" Functions and Machine Learning". Nature. 218: 19–22. doi:10.1038/218019a0.
  3. ↑ "functools — Higher-order functions and operations on callable objects". Python documentation. Python Software Foundation. สืบค้นเมื่อ 2026-07-23.
  4. ↑ Norvig, Peter (1991). "Techniques for Automatic Memoization with Applications to Context-Free Parsing". Computational Linguistics. 17 (1): 91–98.
  5. 1 2 3 4 "functools — Higher-order functions and operations on callable objects". Python documentation. Python Software Foundation. สืบค้นเมื่อ 2026-07-23.
  6. ↑ Lim, Jason (2018-07-23). "Fibonacci and Memoization". Medium (ภาษาอังกฤษ). สืบค้นเมื่อ 2026-07-23.

แหล่งข้อมูลอื่น

[แก้]