Lập trình động là gì?
Làm thế nào nó khác với đệ quy, ghi nhớ, vv?
Tôi đã đọc bài viết trên wikipedia về nó, nhưng tôi vẫn không thực sự hiểu nó.
Lập trình động là gì?
Làm thế nào nó khác với đệ quy, ghi nhớ, vv?
Tôi đã đọc bài viết trên wikipedia về nó, nhưng tôi vẫn không thực sự hiểu nó.
Câu trả lời:
Lập trình động là khi bạn sử dụng kiến thức trong quá khứ để giúp giải quyết vấn đề trong tương lai dễ dàng hơn.
Một ví dụ điển hình là giải quyết chuỗi Fibonacci cho n = 1.000.002.
Đây sẽ là một quá trình rất dài, nhưng nếu tôi cho bạn kết quả với n = 1.000.000 và n = 1.000.001 thì sao? Đột nhiên vấn đề trở nên dễ quản lý hơn.
Lập trình động được sử dụng rất nhiều trong các vấn đề về chuỗi, chẳng hạn như vấn đề chỉnh sửa chuỗi. Bạn giải quyết một tập hợp con của vấn đề và sau đó sử dụng thông tin đó để giải quyết vấn đề ban đầu khó khăn hơn.
Với lập trình động, bạn thường lưu trữ kết quả của mình trong một số loại bảng. Khi bạn cần câu trả lời cho một vấn đề, bạn tham khảo bảng và xem bạn đã biết nó là gì chưa. Nếu không, bạn sử dụng dữ liệu trong bảng của mình để tạo cho mình một bước đệm hướng tới câu trả lời.
Cuốn sách Thuật toán Cormen có một chương tuyệt vời về lập trình động. VÀ nó miễn phí trên Google Sách! Kiểm tra nó ở đây.
Lập trình động là một kỹ thuật được sử dụng để tránh tính toán nhiều lần cùng một bài toán con trong thuật toán đệ quy.
Chúng ta hãy lấy ví dụ đơn giản về các số Fibonacci: tìm số Fibonacci thứ n được xác định bởi
F n = F n-1 + F n-2 và F 0 = 0, F 1 = 1
Cách rõ ràng để làm điều này là đệ quy:
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
Đệ quy thực hiện rất nhiều phép tính không cần thiết bởi vì một số Fibonacci đã cho sẽ được tính nhiều lần. Một cách dễ dàng để cải thiện điều này là lưu trữ kết quả:
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]
Một cách tốt hơn để làm điều này là loại bỏ đệ quy tất cả cùng nhau bằng cách đánh giá kết quả theo đúng thứ tự:
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]
Chúng tôi thậm chí có thể sử dụng không gian không đổi và chỉ lưu trữ các kết quả một phần cần thiết trên đường đi:
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
Áp dụng lập trình động như thế nào?
Lập trình động thường hoạt động cho các vấn đề có thứ tự từ trái sang phải vốn có như chuỗi, cây hoặc chuỗi số nguyên. Nếu thuật toán đệ quy ngây thơ không tính toán cùng một bài toán con nhiều lần, lập trình động sẽ không có ích.
Tôi đã thực hiện một tập hợp các vấn đề để giúp hiểu logic: https://github.com/tristanguigue/dynamic-programing
if n in cache
như với ví dụ từ trên xuống hoặc tôi đang thiếu một cái gì đó.
Ghi nhớ là khi bạn lưu trữ các kết quả trước đó của một lệnh gọi hàm (một hàm thực luôn trả về cùng một thứ, với cùng các đầu vào). Nó không tạo ra sự khác biệt cho độ phức tạp thuật toán trước khi kết quả được lưu trữ.
Đệ quy là phương thức của một hàm gọi chính nó, thường là với một tập dữ liệu nhỏ hơn. Vì hầu hết các hàm đệ quy có thể được chuyển đổi thành các hàm lặp tương tự, điều này cũng không tạo ra sự khác biệt cho độ phức tạp thuật toán.
Lập trình động là quá trình giải quyết các vấn đề phụ dễ giải quyết hơn và xây dựng câu trả lời từ đó. Hầu hết các thuật toán DP sẽ có trong thời gian chạy giữa thuật toán Greedy (nếu tồn tại) và thuật toán hàm mũ (liệt kê tất cả các khả năng và tìm ra thuật toán tốt nhất).
Đó là một tối ưu hóa thuật toán của bạn để giảm thời gian chạy.
Mặc dù Thuật toán Tham lam thường được gọi là ngây thơ , bởi vì nó có thể chạy nhiều lần trên cùng một bộ dữ liệu, Lập trình động tránh được cạm bẫy này thông qua sự hiểu biết sâu sắc hơn về kết quả một phần phải được lưu trữ để giúp xây dựng giải pháp cuối cùng.
Một ví dụ đơn giản là duyệt qua cây hoặc đồ thị chỉ qua các nút sẽ đóng góp cho giải pháp hoặc đưa vào bảng các giải pháp mà bạn đã tìm thấy cho đến nay để bạn có thể tránh đi qua các nút giống nhau.
Đây là một ví dụ về một vấn đề phù hợp với lập trình động, từ thẩm phán trực tuyến của UVA: Chỉnh sửa bậc thang.
Tôi sẽ tóm tắt nhanh phần quan trọng của phân tích vấn đề này, lấy từ cuốn sách Những thách thức lập trình, tôi khuyên bạn nên kiểm tra nó.
Hãy xem xét kỹ vấn đề đó, nếu chúng ta xác định hàm chi phí cho chúng ta biết hai chuỗi khởi động được bao xa, chúng ta có hai xem xét ba loại thay đổi tự nhiên:
Thay thế - thay đổi một ký tự từ mẫu "s" sang một ký tự khác trong văn bản "t", chẳng hạn như thay đổi "bắn" thành "phát hiện".
Chèn - chèn một ký tự đơn vào mẫu "s" để giúp nó khớp văn bản "t", chẳng hạn như thay đổi "trước" thành "agog".
Xóa - xóa một ký tự đơn từ mẫu "s" để giúp nó khớp văn bản "t", chẳng hạn như thay đổi "giờ" thành "của chúng tôi".
Khi chúng tôi đặt mỗi thao tác này thành một bước, chúng tôi sẽ xác định khoảng cách chỉnh sửa giữa hai chuỗi. Vậy làm thế nào để chúng ta tính toán nó?
Chúng ta có thể định nghĩa một thuật toán đệ quy bằng cách sử dụng quan sát rằng ký tự cuối cùng trong chuỗi phải được khớp, thay thế, chèn hoặc xóa. Cắt bỏ các ký tự trong thao tác chỉnh sửa cuối cùng để lại một thao tác cặp để lại một cặp chuỗi nhỏ hơn. Đặt i và j là ký tự cuối cùng của tiền tố liên quan và t, tương ứng. có ba cặp chuỗi ngắn hơn sau thao tác cuối cùng, tương ứng với chuỗi sau khi khớp / thay thế, chèn hoặc xóa. Nếu chúng ta biết chi phí chỉnh sửa ba cặp chuỗi nhỏ hơn, chúng ta có thể quyết định tùy chọn nào dẫn đến giải pháp tốt nhất và chọn tùy chọn đó cho phù hợp. Chúng ta có thể tìm hiểu chi phí này, thông qua điều tuyệt vời đó là đệ quy:
#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 ); }
Thuật toán này là chính xác, nhưng cũng rất chậm.
Chạy trên máy tính của chúng tôi, phải mất vài giây để so sánh hai chuỗi 11 ký tự và tính toán biến mất thành không bao giờ không bao giờ rơi vào bất cứ điều gì lâu hơn.
Tại sao thuật toán quá chậm? Phải mất thời gian theo cấp số nhân vì nó tính toán lại các giá trị hết lần này đến lần khác. Tại mọi vị trí trong chuỗi, đệ quy phân nhánh theo ba cách, có nghĩa là nó tăng trưởng với tốc độ ít nhất là 3 ^ n - thực sự, thậm chí còn nhanh hơn vì hầu hết các cuộc gọi chỉ giảm một trong hai chỉ số, không phải cả hai.
Vậy làm thế nào chúng ta có thể làm cho thuật toán thực tế? Quan sát quan trọng là hầu hết các cuộc gọi đệ quy này là tính toán những thứ đã được tính toán trước đó. Làm sao mà chúng ta biết được? Chà, chỉ có thể có | s | · | T | các cuộc gọi đệ quy duy nhất có thể có, vì chỉ có nhiều cặp (i, j) riêng biệt được dùng làm tham số của các cuộc gọi đệ quy.
Bằng cách lưu trữ các giá trị cho từng cặp (i, j) này trong một bảng, chúng ta có thể tránh tính toán lại chúng và chỉ cần tra cứu chúng khi cần thiết.
Bảng là một ma trận hai chiều m trong đó mỗi | s | · | t | các ô chứa chi phí cho giải pháp tối ưu của bài toán con này, cũng như một con trỏ cha giải thích cách chúng tôi đến vị trí này:
typedef struct { int cost; /* cost of reaching this cell */ int parent; /* parent cell */ } cell; cell m[MAXLEN+1][MAXLEN+1]; /* dynamic programming table */
Phiên bản lập trình động có ba điểm khác biệt so với phiên bản đệ quy.
Đầu tiên, nó nhận các giá trị trung gian bằng cách sử dụng tra cứu bảng thay vì các cuộc gọi đệ quy.
** Thứ hai, ** nó cập nhật trường cha của mỗi ô, điều này sẽ cho phép chúng ta xây dựng lại trình tự chỉnh sửa sau này.
** Thứ ba, ** Thứ ba, nó được sử dụng một
cell()
hàm mục tiêu tổng quát hơn thay vì chỉ trả về m [| s |] [| t |] .cost. Điều này sẽ cho phép chúng tôi áp dụng thói quen này cho một lớp vấn đề rộng hơn.
Ở đây, một phân tích rất đặc biệt về những gì cần thiết để thu thập kết quả một phần tối ưu nhất, là điều làm cho giải pháp trở nên "năng động".
Đây là một giải pháp thay thế, đầy đủ cho cùng một vấn đề. Nó cũng là một "động" mặc dù cách thực hiện của nó là khác nhau. Tôi khuyên bạn nên kiểm tra hiệu quả của giải pháp bằng cách gửi cho thẩm phán trực tuyến của UVA. Tôi thấy đáng kinh ngạc khi một vấn đề nặng nề như vậy đã được giải quyết một cách hiệu quả như vậy.
Các bit chính của lập trình động là "các vấn đề con chồng chéo" và "cấu trúc con tối ưu". Các tính chất của một vấn đề có nghĩa là một giải pháp tối ưu bao gồm các giải pháp tối ưu cho các vấn đề phụ của nó. Ví dụ, các vấn đề đường dẫn ngắn nhất thể hiện cấu trúc tối ưu. Con đường ngắn nhất từ A đến C là con đường ngắn nhất từ A đến một số nút B theo sau là con đường ngắn nhất từ nút B đến C.
Chi tiết hơn, để giải quyết vấn đề con đường ngắn nhất, bạn sẽ:
Bởi vì chúng tôi đang làm việc từ dưới lên, chúng tôi đã có giải pháp cho các vấn đề phụ khi đến lúc sử dụng chúng, bằng cách ghi nhớ chúng.
Hãy nhớ rằng, các vấn đề lập trình động phải có cả các vấn đề phụ chồng chéo và cấu trúc tối ưu. Tạo chuỗi Fibonacci không phải là một vấn đề lập trình động; nó sử dụng ghi nhớ vì nó có các vấn đề phụ chồng chéo, nhưng nó không có cấu trúc tối ưu (vì không có vấn đề tối ưu hóa nào liên quan).
Lập trình năng động
Định nghĩa
Lập trình động (DP) là một kỹ thuật thiết kế thuật toán chung để giải quyết các vấn đề với các vấn đề phụ chồng chéo. Kỹ thuật này được phát minh bởi nhà toán học người Mỹ Richard Richardmanman vào những năm 1950.
Ý chính
Ý tưởng chính là lưu các câu trả lời chồng chéo các vấn đề nhỏ hơn để tránh tính toán lại.
Thuộc tính lập trình động
Tôi cũng rất mới đối với Lập trình động (một thuật toán mạnh mẽ cho các loại vấn đề cụ thể)
Nói cách đơn giản nhất, chỉ cần nghĩ lập trình động như một cách tiếp cận đệ quy với việc sử dụng kiến thức trước đó
Kiến thức trước đây là điều quan trọng nhất ở đây, Theo dõi giải pháp cho các vấn đề phụ bạn đã có.
Hãy xem xét điều này, ví dụ cơ bản nhất cho dp từ Wikipedia
Tìm chuỗi
function fib(n) // naive implementation
if n <=1 return n
return fib(n − 1) + fib(n − 2)
Cho phép ngắt chức năng gọi với hàm 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))
Cụ thể, sợi (2) được tính ba lần từ đầu. Trong các ví dụ lớn hơn, nhiều giá trị khác của sợi, hoặc các vấn đề phụ, được tính toán lại, dẫn đến thuật toán thời gian theo cấp số nhân.
Bây giờ, hãy thử bằng cách lưu trữ giá trị mà chúng tôi đã tìm thấy trong cấu trúc dữ liệu nói là Bản đồ
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]
Ở đây chúng tôi đang lưu giải pháp cho các vấn đề phụ trong bản đồ, nếu chúng tôi chưa có nó. Kỹ thuật lưu các giá trị mà chúng tôi đã tính toán này được gọi là Ghi nhớ.
Cuối cùng, đối với một vấn đề, trước tiên hãy cố gắng tìm các trạng thái (các vấn đề phụ có thể xảy ra và cố gắng nghĩ ra cách tiếp cận đệ quy tốt hơn để bạn có thể sử dụng giải pháp của vấn đề phụ trước đó cho các vấn đề khác).
Lập trình động là một kỹ thuật để giải quyết các vấn đề với các vấn đề phụ chồng chéo. Một thuật toán lập trình động giải quyết mọi vấn đề phụ chỉ một lần và sau đó Lưu câu trả lời của nó trong một bảng (mảng). Tránh công việc tính toán lại câu trả lời mỗi khi gặp phải vấn đề phụ. Ý tưởng cơ bản của lập trình động là: Tránh tính toán cùng một thứ hai lần, thường bằng cách giữ một bảng kết quả đã biết của các vấn đề phụ.
Bảy bước trong quá trình phát triển thuật toán lập trình động như sau:
6. Convert the memoized recursive algorithm into iterative algorithm
một bước bắt buộc? Điều này có nghĩa là hình thức cuối cùng của nó là không đệ quy?
trong ngắn hạn, sự khác biệt giữa ghi nhớ đệ quy và lập trình động
Lập trình động như tên đề xuất đang sử dụng giá trị được tính toán trước đó để tự động xây dựng giải pháp mới tiếp theo
Áp dụng lập trình động ở đâu: Nếu giải pháp của bạn dựa trên cấu trúc tối ưu và vấn đề phụ chồng chéo thì trong trường hợp đó, sử dụng giá trị được tính toán trước đó sẽ hữu ích để bạn không phải tính toán lại. Đó là cách tiếp cận từ dưới lên. Giả sử bạn cần tính toán sợi (n) trong trường hợp đó, tất cả những gì bạn cần làm là thêm giá trị tính toán trước đó của sợi (n-1) và sợi (n-2)
Đệ quy: Về cơ bản chia nhỏ vấn đề của bạn thành phần nhỏ hơn để giải quyết vấn đề một cách dễ dàng nhưng hãy nhớ rằng nó không tránh tính toán lại nếu chúng ta có cùng giá trị được tính toán trong cuộc gọi đệ quy khác.
Ghi nhớ: Về cơ bản lưu trữ giá trị đệ quy được tính toán cũ trong bảng được gọi là ghi nhớ sẽ tránh tính toán lại nếu nó đã được tính toán bởi một số cuộc gọi trước đó vì vậy mọi giá trị sẽ được tính một lần. Vì vậy, trước khi tính toán, chúng tôi kiểm tra xem giá trị này đã được tính hay chưa nếu đã được tính rồi, chúng tôi trả lại tương tự từ bảng thay vì tính toán lại. Nó cũng là cách tiếp cận từ trên xuống
Dưới đây là một ví dụ đơn giản mã python của Recursive
, Top-down
, Bottom-up
cách tiếp cận để Fibonacci loạt:
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))