Thuật toán là một khái niệm then chốt trong Tin học. Việc làm quen với các thuật toán cơ bản và khám phá ra những thuật toán là công việc quan trọng của người lập trình. Người lập trình không chỉ cần biết các thuật toán thông dụng mà còn phải biết tự tìm ra các thuật toán giải quyết các vấn đề cụ thể nảy sinh khi viết chương trình.
HỒ ANH MINH GIÁO TRÌNH NHẬP MÔN THUẬT TOÁN ĐẠI HỌC SƯ PHẠM QUY NHƠN – 2003 LỜI NÓI ĐẦU Thuật toán là một khái niệm then chốt trong Tin học. Việc làm quen với các thuật toán cơ bản và khám phá ra những thuật toán là công việc quan trọng của người lập trình. Người lập trình không chỉ cần biết các thuật toán thông dụng mà còn phải biết tự tìm ra các thuật toán giải quyết các vấn đề cụ thể nảy sinh khi viết chương trình. Hiện nay có hai loại tài liệu liên quan tới thuật toán là các tài liệu về ngôn ngữ lập trình và các tài liệu chuyên về thuật toán. Các tài liệu về ngôn ngữ lập trình thường đưa ra các chương trình dựa trên các thuật toán có sẵn. Việc sử dụng các thuật toán có sẵn trong việc viết chương trình dễ tạo thành một thói quen sử dụng thuật toán một cách cảm tính, ít quan tâm tới tính đúng đắn cũng như hiệu quả của thuật toán đang sử dụng. Trong khi đó các tài liệu chuyên môn về thuật toán thì đòi hỏi một mức độ nhất định về kiến thức toán học và tư duy, không thích hợp cho những người mới làm quen với thuật toán. Giáo trình Nhập môn thuật toán được biên soạn dựa trên bài giảng cho sinh viên ngành Tin học. Giáo trình tập trung vào khái niệm thuật toán mà không nhắc nhiều tới cấu trúc dữ liệu. Nội dung giáo trình gồm hai phần: Phần 1 trình bày các khái niệm cơ bản và giới thiệu một số thuật toán sơ cấp thường dùng. Phần 2 giới thiệu một số kỹ thuật thiết kế thuật toán và các bài toán điển hình được giải quyết nhờ áp dụng các kỹ thuật này. Mục đích của giáo trình nhằm giúp cho sinh viên làm quen với việc diễn đạt (mô tả) thuật toán, đánh giá thuật toán và một số kỹ thuật thiết kế thuật toán. Sinh viên có thể vận dụng các yếu tố này vào việc viết chương trình bằng một ngôn ngữ lập trình đã biết. Vì là giáo trình nhập môn nên giáo trình không đi quá chi tiết vào việc sử dụng công cụ Toán học để đánh giá độ phức tạp của thuật toán. Các kỹ thuật thiết kế thuật toán được lựa chọn phù hợp với trình độ của sinh viên năm thứ Hai và cũng là những kỹ 2 thuật được sử dụng phổ biến. Việc áp dụng các kỹ thuật thiết kế thuật toán tổng quát được minh họa thông qua nhiều bài toán cụ thể Cuối mỗi phần đều có khá nhiều bài tập. Những bài tập có dấu * có thể sử dụng như tài liệu tham khảo bồi dưỡng học sinh giỏi. Việc biên soạn không thể tránh khỏi những thiếu sót, chúng tôi rất mong được sự góp ý, bổ sung của bạn đọc để chỉnh lý nhằm phục vụ ngày càng tốt hơn bạn đọc. 3 PHẦN 1 CÁC KHÁI NIỆM CƠ BẢN Phần này nhắc lại các khái niệm cơ bản liên quan tới thuật toán và một số thuật toán sơ cấp quen thuộc thường dùng. 1. THUẬT TOÁN VÀ ĐỘ PHỨC TẠP TÍNH TOÁN. 1.1. Thuật toán, đặc trưng, mô tả thuật toán Khái niệm thuật toán Tùy theo từng góc độ mà khái niệm thuật toán có thể được hiểu theo nhiều cách khác nhau, chẳng hạn: Thuật toán là phương pháp giải quyết vấn đề nào đó theo từng bước Thuật toán là các qui tắc để tính toán Thuật toán là phương pháp giải quyết vấn đề thích hợp cho cài đặt trên máy tính Trong tài liệu này khái niệm thuật toán được định nghĩa như sau: Thuật toán là một dãy hữu hạn các bước hành động xác định để giải quyết một vấn đề, quá trình thực hiện các bước hành động này phải dừng và cho kết quả như mong muốn. Các đặc trưng của thuật toán • Đầu vào và đầu ra: Một thuật toán phải nhận dữ liệu đầu vào để xử lý và cho kết quả ở đầu ra. • Tính hữu hạn (hay còn gọi là tính dừng): Một áp dụng của thuật toán sẽ phải cho ra kết quả sau một số hữu hạn hành động. • Tính đơn trị: Kết quả của mỗi hành động chỉ phụ thuộc vào kết quả của các hành 4 động được thực hiện trước đó và dữ liệu đầu vào. Nói một cách khác, với đầu vào như nhau thuật toán sẽ cho đầu ra như nhau. • Tính xác định: Mỗi bước hành động phải rõ ràng, không nhập nhằng và (người hoặc máy) có thể thực hiện được. • Tính tổng quát: Thuật toán phải áp dụng được cho một lớp các bài toán cùng loại hoặc một bài toán với các đầu vào cụ thể khác nhau. Ngoài ra đối với một thuật toán còn có các yêu cầu về tính đúng đắn và tính hiệu quả. Mô tả thuật toán Mô tả thuật toán là việc nêu ra các bước hành động cũng như trình tự thực hiện các bước. Mô tả thuật toán là công việc cực kỳ quan trọng. Một mô tả tốt sẽ giúp người lập trình hình dung rõ ràng và đúng đắn các việc phải làm. Để mô tả một thuật toán ta có thể áp dụng một trong các cách sau: • Dùng lưu đồ: Sử dụng hình vẽ mô tả tiến trình hoạt động của thuật toán. • Dùng ngôn ngữ tự nhiên: Liệt kê bằng lời trình tự các bước cần thực hiện. Thông thường cách mô tả này giúp hình dung thuật toán ở mức bao quát. • Dùng giả mã (hay tựa Ngôn ngữ lập trình): Sử dụng các từ khóa cũng như các cấu trúc điều khiển của ngôn ngữ lập trình (Pascal chẳng hạn) để mô tả. Trong cách mô tả này ta chỉ sử dụng các từ khóa và cấu trúc với ý nghĩa đã biết của ngôn ngữ lập trình tương ứng chứ không hoàn toàn tuân thủ cú pháp của ngôn ngữ. Cách mô tả này cho phép diễn đạt các hành động một cách chi tiết, cụ thể hơn. Khi dùng giả mã, để mô tả gọn ta sẽ bỏ qua một số từ khóa không cần thiết và sẽ viết các hành động cùng mức với lề thụt vào như nhau, các hành động ở mức sâu hơn sẽ được viết với lề lớn hơn. Ví dụ để mô tả thuật toán tìm số lớn nhất trong một dãy số ta có thể có các mô tả như sau: - Mô tả 1: 5 Đầu vào: dãy a1, a2, , an Đầu ra: x là giá trị lớn nhất trong dãy Thuật toán: Xét lần lượt từng phần tử trong dãy kể từ đầu đến cuối dãy, với mỗi phần tử trong dãy xác định x là giá trị lớn nhất (tạm thời) cho tới thời điểm đó - Mô tả 2: Đầu vào: dãy a[1], a[2], , a[n] Đầu ra: x là giá trị lớn nhất trong dãy Procedure Max(a[1 n]); x:=a[1]; For i:= 2 to n do if x < a[i] then x := a[i]. Return x; • Dùng ngôn ngữ lập trình: Một chương trình chính là một bản mô tả chi tiết một thuật toán bằng một ngôn ngữ lập trình cụ thể. • Kết hợp giả mã với ngôn ngữ tự nhiên. Ví dụ: Phần lớn các chương trình có dạng tổng quát sau: Nhập một số dữ liệu đầu vào. Thực hiện những tính toán nào đó. Tạo ra một số đầu ra thích hợp. Khi mô tả thuật toán ta thường coi dữ liệu đầu vào là có sẵn. Trong tài liệu này chúng ta sử dụng cách mô tả các thuật toán bằng giả mã kết hợp với ngôn ngữ tự nhiên. Một điểm cần lưu ý là với một thuật toán ta có thể mô tả nó ở nhiều mức độ khác nhau tùy theo tình huống. Chẳng hạn ta có thể mô tả một thuật toán bằng một vài hành động mà mỗi một hành động lại tương ứng với một thuật toán khác nào đó. Thông 6 thường ta sẽ mô tả một thuật toán qua nhiều mức, ngày càng chi tiết hơn hoặc mô tả chi tiết hơn một số hành động nào đó nếu cần. Với một bản mô tả thuật toán đủ chi tiết thì việc viết chương trình chỉ là việc chuyển đổi cách diễn đạt thuật toán hiện có sang một ngôn ngữ lập trình thích hợp. Cũng cần phân biệt thuật toán và chương trình: Một chương trình sẽ được viết bằng một ngôn ngữ lập trình cụ thể nào đó, do vậy chương trình phụ thuộc vào ngôn ngữ lập trình (chẳng hạn giới hạn phạm vi của các kiểu dữ liệu của ngôn ngữ) và phụ thuộc vào các yêu cầu phần cứng cụ thể (chẳng hạn giới hạn của bộ nhớ có thể sử dụng được, v.v.). Trong khi đó một thuật toán không quan tâm tới ngôn ngữ lập trình cụ thể nào và chỉ quan tâm tới việc diễn đạt các hành động cần thực hiện. Chính vì vậy khi cài đặt một thuật toán ta phải lưu ý tới những hạn chế của ngôn ngữ lập trình được sử dụng. 1.2. Độ phức tạp tính toán và phân tích thuật toán. Có nhiều cách tiếp cận khác nhau để đánh giá hiệu quả của một thuật toán nhằm chọn lựa thuật toán thích hợp áp dụng trong thực tế. Chẳng hạn có thể xem xét thuật toán đòi hỏi những gì về tài nguyên hệ thống (bộ nhớ) hoặc thuật toán có thể thực hiện trong một khoảng thời gian chấp nhận được hay không. Ở đây chúng ta chỉ quan tâm tới độ phức tạp tính toán tức là đánh giá thời gian thực hiện của thuật toán. Việc đánh giá thời gian thực hiện của thuật toán được gọi là phân tích thuật toán. Phân tích thuật toán là cần thiết vì những lý do sau: • Việc phân tích thuật toán đáng tin cậy hơn là thực nghiệm. Nếu ta thực nghiệm (tức là chạy thử chương trình), ta chỉ biết hành vi của một chương trình đối với những trường hợp riêng lẻ, trong khi đó phân tích thuật toán cho ta biết về hiệu quả cho mọi đầu vào. • Phân tích thuật toán giúp lựa chọn cách giải quyết trong số nhiều cách giải quyết đối với bài toán. Một bài toán có thể có nhiều cách giải quyết khác nhau. Phân tích và so sánh cẩn thận các thuật toán giúp quyết định thuật toán nào là thích hợp nhất với mục đích của chúng ta mà không cần phải cài đặt và kiểm thử tất cả. • Phân tích thuật toán giúp tiên đoán hiệu quả của một chương trình trước khi viết. 7 Điều này là rất quan trọng đối với những chương trình lớn và giúp chúng ta có thể phát hiện và tập trung khắc phục những vấn đề làm chương trình kém hiệu quả. Khi phân tích thuật toán ta sẽ quan tâm tới mối liên hệ giữa dữ liệu đầu vào (mà ta gọi là kích thước đầu vào) với số lượng các thao tác cần thực hiệân của thuật toán. Kích thước đầu vào thường là một số nguyên dương n. Tuỳ theo tình huống cụ thể mà ta coi cái gì là kích thước đầu vào. Chẳng hạn n có thể là số lượng các đối tượng của dữ liệu đầu vào hoặc n có thể là kích thước của miền xác định của một đối tượng, v.v. Chẳng hạn nếu dữ liệu đầu vào là một dãy n số nguyên thì ta có thể coi n là kích thước đầu vào, nếu đầu vào là một ma trận 2 chiều m×n thì kích thước đầu vào có thể coi là hai số nguyên dương m, n. Một điều hiển nhiên là thuật toán phải thực hiện càng nhiều thao tác thì thời gian thực hiện thuật toán càng lớn. Do vậy, ta sẽ coi số lượng các thao tác cơ bản cần thực hiện là một hàm của kích thước đầu vào, ký hiệu là f(n), gọi là hàm thời gian chạy của thuật toán (chương trình). Các thao tác cơ bản thường dùng là các phép toán số học và so sánh, phép gán, thao tác đọc file và ghi file. Tuy nhiên với từng thuật toán, tùy theo tình huống ta sẽ quan tâm tới một số thao tác cơ bản nhất định. Việc tính chính xác hàm f(n) trong phần lớn trường hợp là rất khó và thực ra là không cần thiết. Ta sẽ quan tâm tới tốc độ tăng của hàm f khi n tăng, hay nói khác đi ta muốn biết mức độ tăng thời gian thực hiện thuật toán khi kích thước đầu vào tăng. Đặc biệt ta quan tâm tới tình huống trường hợp tồi nhất (khi số lượng các thao tác cơ bản cần thực hiện là nhiều nhất). Ký pháp O: Giả sử f, g là hai hàm N N, ta nói f có bậc cao nhất là g, ký hiệu f(n) = O(g(n)) nếu tồn tại hai hằng số C và k sao cho f(n) < Cg(n) với mọi n > k. Khi đó ta nói hàm f có bậc (hay tốc độ tăng) là g. Ví dụ 1: Hàm T(n)=3n 3 +2n 2 là O(n 3 ) với k=0 và C=5. Ta cũng có thể nói rằng T(n) là O(n 4 ) nhưng phát biểu này yếu hơn Ví dụ 2: Ta chứng minh rằng hàm 3 n không là O(2 n ). Giả sử tồn tại C và k sao cho 3 n < C.2 n với mọi n > k. Khi đó C>(3/2) n với mọi n>k. 8 Nhưng ta biết rằng (3/2) n tiến ra vô cùng khi n ra vô cùng. Mâu thuẫn. Khi sử dụng ký pháp O ta đã bỏ qua các hằng số của bậc, điều này ám chỉ rằng ta quan tâm tới những trường hợp mà kích thước đầu vào đủ lớn. Giả sử ta có 2 thuật toán với 2 hàm thời gian chạy là f1 và f2 tương ứng, và f1(n)=100n, f2(n)=2 n . Cả hai thuật toán của chúng ta giải quyết một trường hợp cụ thể mất 10 4 giây. Giả sử nhờ cải thiện phần cứng ta có thể tăng tốc độ máy lên 10 lần. Khi đó với thuật toán f2 ta có thể giải quyết bài toán với kích thước đầu vào tăng 30% với thời gian như cũ, trong khi với thuật toán f1 ta có thể giải quyết bài toán với kích thước đầu vào tăng 1000% với thời gian như cũ. Thực tế là máy tính ngày càng rẻ hơn và nhanh hơn, nhưng nhu cầu thực tế giải quyết các bài toán với kích thước ngày càng lớn và càng phức tạp cũng tăng lên. Vì vậy việc tìm ra và sử dụng các thuật toán có độ phức tạp tăng chậm ngày càng trở nên quan trọng hơn. Một số tính chất của ký pháp O: Qui tắc cộng: Giả sử T1(n) và T2(n) là thời gian chạy của 2 thuật toán P1 và P2, T1(n)=O(f(n)) và T2(n)=O(g(n)). Khi đó thời gian chạy tuần tự 2 thuật toán P1 và P2 là T1(n)+T2(n) = O(max(f(n),g(n))). Thật vậy. giả sử C1, k1, C2, k2 là các hằng số sao cho với mọi n>k1 ta có T1(n)<C1.f(n) và với mọi n>k2 có T2(n)<C2.g(n). Gọi k0=max(k1,k2), khi đó với mọi n>k0 ta có T1(n)<C1.f(n) và T2(n)<C2.g(n). Suy ra T1(n) + T2(n) < (C1+C2) max(f(n),g(n)). Vậy T1(n) + T2(n) = O(max(f(n),g(n))). Hay O(f(n))+O(g(n)) = O(max(f(n),g(n))). Áp dụng qui tắc này khi đánh giá thời gian chạy của một thuật toán gồm các đoạn thực hiện tuần tự ta có thể coi thời gian chạy (hay độ phức tạp tính toán) của thuật toán bằng thời gian chạy của đoạn chương trình có thời gian chạy lớn nhất. Một nhận xét khác là nếu g(n) < f(n) với n đủ lớn thì O(f(n)+g(n))=O(f(n)), ví dụ O(n 3 +n 2 )=O(n 3 ). Vì vậy khi xem xét các hàm đánh giá thời gian chạy của thuật toán ta 9 chỉ quan tâm tới hạng tử bậc cao nhất. Qui tắc nhân: Nếu T1(n)=O(f(n)) và T2(n)=O(g(n)) thì T1(n)T2(n)=O(f(n)g(n)). Từ qui tắc này ta có O(c.f(n))=O(f(n)) với c là một hằng số. Ví dụ: O(3n 2 )=O(n 2 ). Qui tắc này được sử dụng để đánh giá độ phức tạp của 2 đoạn chương trình lồng nhau. Ví dụ: Giả sử có đoạn chương trình sau: While btL1 do While btL2 do Các lệnh2; Các lệnh1; và giả sử độ phức tạp của vòng lặp với btL2 là O(f(n)), độ phức tạp của vòng lặp với btL2 là O(g(n)) với vòng lặp trong được coi như một câu lệnh. Khi đó độ phức tạp của đoạn chương trình trên sẽ là O(f(n).g(n)) Khi xác định hàm thời gian chạy của thuật toán ta thường ước tính số các thao tác cơ bản như phép gán, thao tác đọc ghi hoặc các tính toán số học, các phép so sánh. Mỗi thao tác cơ bản này thường được coi là thực hiện mất một đơn vị thời gian (mặc dù trong thực tế việc thực các thao tác này đòi hỏi thời gian khác nhau, chẳng hạn các thao tác đọc ghi mất nhiều thời gian hơn cả, thực hiện phép nhân mất nhiều thời gian hơn thực hiện phép cộng, ). Ví dụ: Đánh giá thời gian chạy của thuật toán sau Procedure Vidu(n:integer); For i:=1 to n-1 do For j:=i+1 to n do For k:=1 to j do Writeln(i+j+k); Ta tính số lần thực hiện Writeln. Với mỗi j chạy từ i+1 đến n, Writeln được thực 10 [...]... đề lựa chọn thuật toán để cài đặt Với một bài toán có thể áp dụng nhiều thuật toán khác nhau để giải quyết Khi đó nảy sinh vấn đề nên lựa chọn sử dụng thuật toán nào Có 2 tiêu chuẩn quan trọng để lựa chọn một thuật toán: tính đơn giản và tính hiệu quả Thuật toán đơn giản là thuật toán dễ hiểu, dễ cài đặt và dễ bảo trì, tuy vậy các thuật toán đơn giản thường kém hiệu quả Ngược lại một thuật toán hiệu... và đánh giá độ phức tạp của thuật toán 24 2.5 Đệ qui 2.5.1 Thuật toán đệ qui Các thuật toán đệ qui đóng vai trò quan trọng trong việc giải quyết nhiều bài toán Một thuật toán đệ qui là thuật toán có yêu cầu thực hiện lại chính thuật toán đó với mức độ dữ liệu thấp hơn Các thuật toán đệ qui có liên quan chặt chẽ với các định nghĩa đệ qui hoặc các quan hệ truy hồi Một thuật toán đệ qui gồm 2 phần: Phần... tạp trong trường hợp tồi nhất của các thuật toán sắp xếp sơ cấp đã nêu 2 Trong các thuật toán sắp xếp sơ cấp trên, thuật toán nào có tính ổn định? Giải thích 3 Chạy từng bước các thuật toán sắp xếp đã nêu trên các mảng cụ thể có kích thước 10 4 Mô tả một thuật toán thích hợp sắp xếp một mảng các bit Cho biết độ phức tạp của thuật toán được sử dụng 20 2.3 Các thuật toán tìm kiếm sơ cấp trên mảng Tìm kiếm... kế thuật toán như Chia để trị, Qui hoạch động, Thuật toán Tham lam, v.v đồng thời giới thiệu các bài toán điển hình có áp dụng các kỹ thuật này 1 CHIA ĐỂ TRỊ 1.1 Mở đầu Chia để trị là một kỹ thuật thiết kế thuật toán bằng cách chia bài toán đã cho thành một số bài toán con hoàn toàn tương tự nhưng với kích thước đầu vào nhỏ hơn, giải quyết lần lượt và độc lập các bài toán con này (có thể áp dụng kỹ thuật. .. ra một thuật toán đệ qui tìm ƯCLN(a,b): Nếu a>b thì ƯCLN(a,b)=ƯCLN(a mod b,b) và ƯCLN(a,0)=a với a>0 Khử đệ qui của thuật toán này 2 Mô tả thuật toán sắp xếp chèn dạng đệ qui 3 Thuật toán sau có đạt được ý định tìm và in ra số lớn nhất trong n số hay không? Giải thích Procedure Max(n); If n=1 then write(a1) Else If an>Max(m-1) then write(an) Else Max(n-1); 4 Thuật toán sau nhằm sửa lại thuật toán của... giờ Thuật toán hiệu quả, do phức tạp (khó thể hiện thuật toán, mất nhiều thời gian sửa các lỗi phát sinh) nên thời gian 11 cài đặt là 2 ngày (có thể lâu hơn), tuy nhiên mỗi lần chạy chương trình tương ứng mất 1 giây Xét về thời gian dành cho viết và sử dụng chương trình, đối với thuật toán đơn giản là 12 giờ còn đối với thuật toán hiệu quả là 2 ngày Nhìn ở góc độ này thì nên ưu tiên chọn thuật toán. .. Phần cơ sở: là các trường hợp không cần thực hiện lại thuật toán Phần đệ qui: là các trường hợp yêu cầu thực hiện lại thuật toán 2.5.2 Một số bài toán Bài toán 1: Tìm ƯCLN của hai số tự nhiên a và b cho trước Có nhiều thuật toán khác nhau để giải bài toán này, ở đây ta sử dụng một tính chất của ƯCLN là: nếu a > b thì ƯCLN(a,b)=ƯCLN(a-b,b) Thuật toán được mô tả như sau: Function Ucln(a,b); {gỉả thiết... phần cơ sở trong thủ tục đệ qui Ví dụ 1: Khử đệ qui của thuật toán tìm ƯCLN của hai số tự nhiên a và b Thuật toán được mô tả như sau: Function Ucln2(a,b); While (ab) and (b1) do If a