การเขียนโปรแกรมแบบไดนามิกคืออะไร?
มันแตกต่างจากการเรียกซ้ำบันทึก ฯลฯ อย่างไร
ฉันได้อ่านบทความวิกิพีเดียแล้ว แต่ฉันยังไม่เข้าใจจริงๆ
การเขียนโปรแกรมแบบไดนามิกคืออะไร?
มันแตกต่างจากการเรียกซ้ำบันทึก ฯลฯ อย่างไร
ฉันได้อ่านบทความวิกิพีเดียแล้ว แต่ฉันยังไม่เข้าใจจริงๆ
คำตอบ:
การเขียนโปรแกรมแบบไดนามิกคือเมื่อคุณใช้ความรู้ในอดีตเพื่อทำให้การแก้ปัญหาในอนาคตง่ายขึ้น
ตัวอย่างที่ดีคือการแก้ไขลำดับฟีโบนักชีสำหรับ n = 1,000,002
นี่จะเป็นกระบวนการที่ยาวมาก แต่ถ้าฉันให้ผลลัพธ์ให้คุณสำหรับ n = 1,000,000 และ n = 1,000,001 ทันใดนั้นปัญหาก็เริ่มจัดการได้ง่ายขึ้น
การเขียนโปรแกรมแบบไดนามิกมีการใช้จำนวนมากในปัญหาสตริงเช่นปัญหาการแก้ไขสตริง คุณแก้เซตย่อยของปัญหาแล้วใช้ข้อมูลนั้นเพื่อแก้ไขปัญหาต้นฉบับที่ยากขึ้น
ด้วยการเขียนโปรแกรมแบบไดนามิกคุณเก็บผลลัพธ์ของคุณในตารางบางประเภทโดยทั่วไป เมื่อคุณต้องการคำตอบของปัญหาคุณอ้างอิงตารางและดูว่าคุณรู้อยู่แล้วว่ามันคืออะไร ถ้าไม่คุณใช้ข้อมูลในตารางของคุณเพื่อให้ตัวเองก้าวไปสู่คำตอบ
หนังสือ Cormen Algorithms มีบทที่ยอดเยี่ยมเกี่ยวกับการเขียนโปรแกรมแบบไดนามิก และฟรีบน Google หนังสือ! ตรวจสอบที่นี่
การเขียนโปรแกรมแบบไดนามิกเป็นเทคนิคที่ใช้ในการหลีกเลี่ยงการคำนวณหลายครั้งในโปรแกรมย่อยเดียวกันในอัลกอริทึมแบบเรียกซ้ำ
ลองมาตัวอย่างง่ายๆของตัวเลข Fibonacci: หาที่ n THตัวเลข Fibonacci ที่กำหนดโดย
F n = F n-1 + F n-2และ F 0 = 0, F 1 = 1
วิธีที่ชัดเจนในการทำสิ่งนี้คือการเรียกซ้ำ:
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
การเรียกซ้ำใช้การคำนวณที่ไม่จำเป็นจำนวนมากเนื่องจากหมายเลขฟีโบนัชชีที่กำหนดจะถูกคำนวณหลายครั้ง วิธีง่าย ๆ ในการปรับปรุงสิ่งนี้คือการแคชผลลัพธ์:
cache = {}
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
if n in cache:
return cache[n]
cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
return cache[n]
วิธีที่ดีกว่าในการทำเช่นนี้คือกำจัดการเรียกซ้ำทั้งหมดโดยการประเมินผลลัพธ์ในลำดับที่ถูกต้อง:
cache = {}
def fibonacci(n):
cache[0] = 0
cache[1] = 1
for i in range(2, n + 1):
cache[i] = cache[i - 1] + cache[i - 2]
return cache[n]
เราสามารถใช้พื้นที่คงที่และเก็บเฉพาะผลลัพธ์บางส่วนที่จำเป็นระหว่างทาง:
def fibonacci(n):
fi_minus_2 = 0
fi_minus_1 = 1
for i in range(2, n + 1):
fi = fi_minus_1 + fi_minus_2
fi_minus_1, fi_minus_2 = fi, fi_minus_1
return fi
ใช้การเขียนโปรแกรมแบบไดนามิกได้อย่างไร
การเขียนโปรแกรมแบบไดนามิกโดยทั่วไปจะทำงานสำหรับปัญหาที่มีลำดับจากซ้ายไปขวาเช่นสตริงต้นไม้หรือลำดับเลขจำนวนเต็ม หากอัลกอริทึมเรียกซ้ำไม่ได้คำนวณปัญหาย่อยเดียวกันหลาย ๆ ครั้งการเขียนโปรแกรมแบบไดนามิกจะไม่ช่วย
ฉันได้รวบรวมปัญหาเพื่อช่วยให้เข้าใจตรรกะ: https://github.com/tristanguigue/dynamic-programing
if n in cache
เช่นเดียวกับตัวอย่างจากบนลงล่างหรือว่าฉันขาดอะไรไป
การบันทึกคือเมื่อคุณเก็บผลลัพธ์ก่อนหน้าของการเรียกใช้ฟังก์ชัน (ฟังก์ชันจริงจะส่งคืนสิ่งเดียวกันเสมอโดยรับอินพุตเดียวกัน) มันไม่ได้สร้างความแตกต่างสำหรับความซับซ้อนของอัลกอริทึมก่อนที่จะเก็บผลลัพธ์
การเรียกซ้ำเป็นวิธีการของฟังก์ชั่นที่เรียกตัวเองซึ่งมักจะมีชุดข้อมูลที่เล็กกว่า เนื่องจากฟังก์ชั่นวนซ้ำส่วนใหญ่สามารถแปลงเป็นฟังก์ชั่นวนซ้ำที่คล้ายกันได้จึงไม่ทำให้ความซับซ้อนของอัลกอริทึมแตกต่างกัน
การเขียนโปรแกรมแบบไดนามิกเป็นกระบวนการของการแก้ปัญหาย่อยง่ายขึ้นและสร้างคำตอบจากที่ อัลกอริทึม DP ส่วนใหญ่จะอยู่ในช่วงเวลาทำงานระหว่างอัลกอริทึมโลภ (ถ้ามี) และอัลกอริธึมชี้แจง (แจกแจงความเป็นไปได้ทั้งหมดและค้นหาอัลกอริธึมที่ดีที่สุด)
เป็นการเพิ่มประสิทธิภาพของอัลกอริทึมของคุณที่ลดเวลาทำงาน
ในขณะที่อัลกอริทึมโลภมักเรียกว่าไร้เดียงสาเพราะมันอาจทำงานหลายครั้งในชุดข้อมูลเดียวกัน Dynamic Programming หลีกเลี่ยงข้อผิดพลาดนี้ผ่านความเข้าใจที่ลึกซึ้งยิ่งขึ้นเกี่ยวกับผลลัพธ์บางส่วนที่ต้องเก็บไว้เพื่อช่วยสร้างโซลูชันขั้นสุดท้าย
ตัวอย่างง่ายๆคือการสำรวจต้นไม้หรือกราฟผ่านทางโหนดที่จะมีส่วนร่วมในการแก้ปัญหาหรือวางวิธีการแก้ปัญหาที่คุณพบในตารางเพื่อให้คุณสามารถหลีกเลี่ยงการข้ามโหนดเดียวกันซ้ำแล้วซ้ำอีก
นี่คือตัวอย่างของปัญหาที่เหมาะสำหรับการเขียนโปรแกรมแบบไดนามิกจากผู้ตัดสินออนไลน์ของ UVA: Edit Steps Ladder
ฉันจะอธิบายสั้น ๆ ถึงส่วนสำคัญของการวิเคราะห์ปัญหานี้ซึ่งนำมาจากความท้าทายในการเขียนโปรแกรมหนังสือฉันขอแนะนำให้คุณลองดู
ลองดูปัญหานั้นกันดีกว่าถ้าเรากำหนดฟังก์ชั่นต้นทุนเพื่อบอกเราว่าสตริงสองอันนั้นไกลแค่ไหนเรามีสองตัวพิจารณาการเปลี่ยนแปลงตามธรรมชาติสามประเภท:
การแทนที่ - เปลี่ยนอักขระเดียวจากรูปแบบ "s" เป็นอักขระอื่นในข้อความ "t" เช่นการเปลี่ยน "shot" เป็น "spot"
การแทรก - แทรกอักขระเดี่ยวในรูปแบบ "s" เพื่อช่วยจับคู่ข้อความ "t" เช่นการเปลี่ยน "ที่ผ่านมา" เป็น "agog"
การลบ - ลบอักขระเดียวจากรูปแบบ "s" เพื่อช่วยให้ตรงกับข้อความ "t" เช่นการเปลี่ยน "ชั่วโมง" เป็น "ของเรา"
เมื่อเราตั้งค่าการดำเนินการแต่ละรายการให้มีค่าใช้จ่ายเพียงขั้นตอนเดียวเราจะกำหนดระยะทางแก้ไขระหว่างสองสตริง แล้วเราจะคำนวณมันอย่างไร
เราสามารถกำหนดอัลกอริทึมแบบเรียกซ้ำได้โดยใช้การสังเกตว่าอักขระตัวสุดท้ายในสตริงต้องตรงกันจับคู่แทรกหรือลบ การตัดอักขระในการดำเนินการแก้ไขครั้งล่าสุดออกจากการดำเนินการคู่ปล่อยให้สตริงมีขนาดเล็กลง ให้ i และ j เป็นอักขระตัวสุดท้ายของส่วนนำหน้าที่เกี่ยวข้องของและ t ตามลำดับ มีสตริงที่สั้นกว่าสามคู่หลังจากการดำเนินการครั้งล่าสุดสอดคล้องกับสตริงหลังจากการจับคู่ / การแทนที่การแทรกหรือการลบ หากเรารู้ค่าใช้จ่ายในการแก้ไขสตริงขนาดเล็กสามคู่เราสามารถตัดสินใจได้ว่าตัวเลือกใดที่นำไปสู่ทางออกที่ดีที่สุดและเลือกตัวเลือกนั้นตามลำดับ เราสามารถเรียนรู้ค่าใช้จ่ายนี้ผ่านสิ่งที่ยอดเยี่ยมที่เรียกซ้ำ:
#define MATCH 0 /* enumerated type symbol for match */ #define INSERT 1 /* enumerated type symbol for insert */ #define DELETE 2 /* enumerated type symbol for delete */ int string_compare(char *s, char *t, int i, int j) { int k; /* counter */ int opt[3]; /* cost of the three options */ int lowest_cost; /* lowest cost */ if (i == 0) return(j * indel(’ ’)); if (j == 0) return(i * indel(’ ’)); opt[MATCH] = string_compare(s,t,i-1,j-1) + match(s[i],t[j]); opt[INSERT] = string_compare(s,t,i,j-1) + indel(t[j]); opt[DELETE] = string_compare(s,t,i-1,j) + indel(s[i]); lowest_cost = opt[MATCH]; for (k=INSERT; k<=DELETE; k++) if (opt[k] < lowest_cost) lowest_cost = opt[k]; return( lowest_cost ); }
อัลกอริทึมนี้ถูกต้อง แต่ก็ช้าเช่นกัน
ทำงานบนคอมพิวเตอร์ของเราใช้เวลาหลายวินาทีในการเปรียบเทียบสองสายอักขระ 11 ตัวและการคำนวณจะหายไปอย่างไม่มีวันหมด
ทำไมอัลกอริทึมจึงช้า มันต้องใช้เวลาชี้แจงเพราะมันจะคำนวณค่าอีกครั้งและอีกครั้งและอีกครั้ง ในทุกตำแหน่งในสตริงการเรียกซ้ำจะแยกสามวิธีซึ่งหมายความว่ามันจะเติบโตในอัตราอย่างน้อย 3 ^ n - แน่นอนยิ่งเร็วขึ้นเนื่องจากการโทรส่วนใหญ่ลดเพียงหนึ่งในสองดัชนีเท่านั้นไม่ใช่ทั้งสองอย่าง
แล้วเราจะทำให้อัลกอริทึมเป็นจริงได้อย่างไร? การสังเกตที่สำคัญคือการเรียกแบบเรียกซ้ำเหล่านี้ส่วนใหญ่เป็นการคำนวณสิ่งที่เคยคำนวณมาก่อนแล้ว เราจะรู้ได้อย่างไร มีเพียง s | เท่านั้น | · | t | การโทรซ้ำแบบซ้ำที่เป็นไปได้เนื่องจากมีเพียงจำนวนคู่ที่แตกต่างกัน (i, j) เพื่อใช้เป็นพารามิเตอร์ของการโทรซ้ำ
โดยการเก็บค่าสำหรับแต่ละคู่ (i, j) เหล่านี้ในตารางเราสามารถหลีกเลี่ยงการคำนวณใหม่และค้นหาค่าได้ตามต้องการ
ตารางเป็นเมทริกซ์สองมิติ m โดยที่แต่ละ | s | · | t | เซลล์มีค่าใช้จ่ายของโซลูชันที่ดีที่สุดของปัญหาย่อยนี้รวมถึงตัวชี้หลักที่อธิบายวิธีที่เราไปถึงสถานที่นี้:
typedef struct { int cost; /* cost of reaching this cell */ int parent; /* parent cell */ } cell; cell m[MAXLEN+1][MAXLEN+1]; /* dynamic programming table */
เวอร์ชันการเขียนโปรแกรมแบบไดนามิกมีความแตกต่างสามประการจากเวอร์ชันแบบเรียกซ้ำ
ก่อนอื่นจะได้รับค่ากลางโดยใช้การค้นหาตารางแทนการเรียกซ้ำ
** ที่สอง ** มันอัปเดตฟิลด์ผู้ปกครองของแต่ละเซลล์ซึ่งจะช่วยให้เราสามารถสร้างลำดับการแก้ไขในภายหลัง
** สาม ** ประการที่สามมันเป็นเครื่องมือที่ใช้
cell()
ฟังก์ชั่นเป้าหมายทั่วไปมากขึ้นแทนที่จะส่งกลับ m [| s |] [| t |] .cost สิ่งนี้จะทำให้เราสามารถใช้รูทีนนี้กับปัญหาที่กว้างขึ้น
ที่นี่การวิเคราะห์โดยเฉพาะอย่างยิ่งของสิ่งที่ใช้ในการรวบรวมผลลัพธ์บางส่วนที่เหมาะสมที่สุดคือสิ่งที่ทำให้การแก้ปัญหาเป็น "แบบไดนามิก"
นี่คือทางเลือกวิธีแก้ปัญหาแบบเต็มสำหรับปัญหาเดียวกัน นอกจากนี้ยังเป็นแบบไดนามิก "แม้ว่าการดำเนินการจะแตกต่างกัน ฉันขอแนะนำให้คุณตรวจสอบว่าโซลูชั่นมีประสิทธิภาพเพียงใดโดยส่งต่อผู้ตัดสินออนไลน์ของ UVA ฉันพบว่าวิธีการจัดการกับปัญหาดังกล่าวได้อย่างมีประสิทธิภาพ
บิตหลักของการเขียนโปรแกรมแบบไดนามิกคือ "ปัญหาย่อยทับกัน" และ "โครงสร้างย่อยที่เหมาะสม" คุณสมบัติของปัญหาหมายถึงว่าทางออกที่ดีที่สุดนั้นประกอบด้วยทางออกที่ดีที่สุดสำหรับปัญหาย่อย ตัวอย่างเช่นปัญหาเส้นทางที่สั้นที่สุดแสดงโครงสร้างพื้นฐานที่ดีที่สุด เส้นทางที่สั้นที่สุดจาก A ถึง C เป็นเส้นทางที่สั้นที่สุดจาก A ไปยังโหนด B บางแห่งตามด้วยเส้นทางที่สั้นที่สุดจากโหนด B ถึง C
โดยละเอียดยิ่งขึ้นเพื่อแก้ปัญหาเส้นทางที่สั้นที่สุดคุณจะ:
เนื่องจากเราทำงานจากล่างขึ้นบนเราจึงมีวิธีแก้ปัญหาย่อยเมื่อถึงเวลาที่จะใช้งานโดยการบันทึกพวกเขา
โปรดจำไว้ว่าปัญหาการเขียนโปรแกรมแบบไดนามิกต้องมีทั้งปัญหาย่อยซ้อนทับและโครงสร้างพื้นฐานที่ดีที่สุด การสร้างลำดับฟีโบนักชีไม่ใช่ปัญหาการเขียนโปรแกรมแบบไดนามิก มันใช้การบันทึกความจำเพราะมันมีปัญหาย่อยทับซ้อนกัน แต่มันไม่ได้มีโครงสร้างย่อยที่ดีที่สุด (เพราะไม่มีปัญหาการเพิ่มประสิทธิภาพที่เกี่ยวข้อง)
การเขียนโปรแกรมแบบไดนามิก
คำนิยาม
Dynamic programming (DP) เป็นเทคนิคการออกแบบอัลกอริธึมทั่วไปสำหรับการแก้ปัญหาที่มีปัญหาย่อยทับซ้อนกัน เทคนิคนี้ถูกคิดค้นโดยนักคณิตศาสตร์ชาวอเมริกัน“ Richard Bellman” ในปี 1950
ความคิดหลัก
แนวคิดหลักคือการบันทึกคำตอบของการซ้อนทับปัญหาย่อยที่เล็กลงเพื่อหลีกเลี่ยงการคำนวณซ้ำ
คุณสมบัติการเขียนโปรแกรมแบบไดนามิก
ฉันยังใหม่กับ Dynamic Programming มาก (อัลกอริทึมที่มีประสิทธิภาพสำหรับปัญหาบางประเภท)
ในคำที่ง่ายที่สุดแค่คิดว่าการเขียนโปรแกรมแบบไดนามิกเป็นวิธีแบบเรียกซ้ำโดยใช้ความรู้ก่อนหน้า
ความรู้ก่อนหน้าคือสิ่งที่สำคัญที่สุดที่นี่ติดตามการแก้ปัญหาย่อยที่คุณมีอยู่แล้ว
พิจารณาสิ่งนี้เป็นตัวอย่างพื้นฐานที่สุดสำหรับ dp จาก Wikipedia
การหาลำดับฟีโบนักชี
function fib(n) // naive implementation
if n <=1 return n
return fib(n − 1) + fib(n − 2)
ให้แบ่งการเรียกใช้ฟังก์ชันด้วย say n = 5
fib(5)
fib(4) + fib(3)
(fib(3) + fib(2)) + (fib(2) + fib(1))
((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
(((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
โดยเฉพาะอย่างยิ่ง Fib (2) ถูกคำนวณสามครั้งจากรอยขีดข่วน ในตัวอย่างที่มีขนาดใหญ่กว่าการคำนวณค่าตอแหลจำนวนมากหรือปัญหาย่อยถูกคำนวณใหม่ซึ่งนำไปสู่อัลกอริทึมเวลาแบบเอ็กซ์โพเนนเชียล
ตอนนี้ให้ลองโดยการเก็บค่าที่เราพบแล้วในโครงสร้างข้อมูลบอกว่าแผนที่
var m := map(0 → 0, 1 → 1)
function fib(n)
if key n is not in map m
m[n] := fib(n − 1) + fib(n − 2)
return m[n]
ที่นี่เรากำลังบันทึกวิธีแก้ปัญหาย่อยในแผนที่หากเรายังไม่มี เทคนิคการบันทึกค่าที่เราได้คำนวณไปแล้วนี้เรียกว่าการบันทึก
ในที่สุดสำหรับปัญหาอันดับแรกพยายามค้นหาสถานะ (ปัญหาย่อยที่เป็นไปได้และลองคิดถึงวิธีการเรียกซ้ำที่ดีกว่าเพื่อที่คุณจะสามารถใช้วิธีแก้ปัญหาย่อยก่อนหน้านี้ได้
การเขียนโปรแกรมแบบไดนามิกเป็นเทคนิคสำหรับการแก้ปัญหาย่อยปัญหาที่ทับซ้อนกัน อัลกอริทึมการเขียนโปรแกรมแบบไดนามิกแก้ปัญหาย่อยทุกครั้งเพียงครั้งเดียวแล้วบันทึกคำตอบลงในตาราง (อาร์เรย์) หลีกเลี่ยงการคำนวณคำตอบใหม่ทุกครั้งที่พบปัญหาย่อย แนวคิดพื้นฐานของการเขียนโปรแกรมแบบไดนามิกคือ: หลีกเลี่ยงการคำนวณสิ่งเดียวกันสองครั้งโดยปกติจะเก็บตารางผลลัพธ์ที่ทราบของปัญหาย่อย
เจ็ดขั้นตอนในการพัฒนาอัลกอริทึมการเขียนโปรแกรมแบบไดนามิกมีดังนี้:
6. Convert the memoized recursive algorithm into iterative algorithm
ขั้นตอนบังคับ? นี่หมายความว่าแบบฟอร์มสุดท้ายไม่ใช่แบบเรียกซ้ำ?
ในระยะสั้นความแตกต่างระหว่างการเรียกซ้ำการเรียกซ้ำและการเขียนโปรแกรมแบบไดนามิก
การเขียนโปรแกรมแบบไดนามิกเป็นชื่อที่แนะนำคือการใช้ค่าที่คำนวณได้ก่อนหน้านี้เพื่อสร้างโซลูชั่นใหม่ต่อไปแบบไดนามิก
ตำแหน่งที่จะใช้การเขียนโปรแกรมแบบไดนามิก: หากวิธีการแก้ปัญหาของคุณขึ้นอยู่กับโครงสร้างย่อยที่ดีที่สุดและปัญหาย่อยที่ทับซ้อนกันในกรณีนั้นการใช้ค่าที่คำนวณก่อนหน้านี้จะมีประโยชน์ดังนั้นคุณจึงไม่ต้องคำนวณใหม่ มันเป็นวิธีการจากล่างขึ้นบน สมมติว่าคุณต้องคำนวณค่า fib (n) ในกรณีนั้นสิ่งที่คุณต้องทำคือเพิ่มค่าที่คำนวณได้ก่อนหน้านี้ของ fib (n-1) และ fib (n-2)
การเรียกซ้ำ: โดยทั่วไปการแบ่งย่อยคุณมีปัญหาเป็นส่วนเล็ก ๆ เพื่อแก้ปัญหาได้อย่างง่ายดาย แต่โปรดจำไว้ว่ามันจะไม่หลีกเลี่ยงการคำนวณอีกครั้งหากเรามีค่าเดียวกันที่คำนวณไว้ก่อนหน้านี้ในการเรียกการสอบถามซ้ำอื่น ๆ
การบันทึก: โดยทั่วไปแล้วการจัดเก็บค่าการเรียกซ้ำที่คำนวณได้ในตารางนั้นเรียกว่าการบันทึกความจำซึ่งจะหลีกเลี่ยงการคำนวณใหม่หากการคำนวณนั้นถูกคำนวณโดยการโทรก่อนหน้านี้บางครั้งดังนั้นค่าใด ๆ จะถูกคำนวณหนึ่งครั้ง ดังนั้นก่อนการคำนวณเราตรวจสอบว่าค่านี้ได้ถูกคำนวณไปแล้วหรือยังถ้าคำนวณไปแล้วเราจะคืนค่าเดียวกันจากตารางแทนที่จะคำนวณใหม่ นอกจากนี้ยังเป็นวิธีการจากบนลงล่าง
นี่เป็นตัวอย่างรหัสหลามที่เรียบง่ายของRecursive
, Top-down
, Bottom-up
วิธีการสำหรับ Fibonacci ชุด:
def fib_recursive(n):
if n == 1 or n == 2:
return 1
else:
return fib_recursive(n-1) + fib_recursive(n-2)
print(fib_recursive(40))
def fib_memoize_or_top_down(n, mem):
if mem[n] is not 0:
return mem[n]
else:
mem[n] = fib_memoize_or_top_down(n-1, mem) + fib_memoize_or_top_down(n-2, mem)
return mem[n]
n = 40
mem = [0] * (n+1)
mem[1] = 1
mem[2] = 1
print(fib_memoize_or_top_down(n, mem))
def fib_bottom_up(n):
mem = [0] * (n+1)
mem[1] = 1
mem[2] = 1
if n == 1 or n == 2:
return 1
for i in range(3, n+1):
mem[i] = mem[i-1] + mem[i-2]
return mem[n]
print(fib_bottom_up(40))