Từ hồi tớ học Ruby, lại bị tiêm nhiễm thêm một thói quen vừa tốt vừa xấu. Đó là khi thực hành giải mấy bài toán vớ vửn đơn giản, tớ toàn bị ám ảnh phải làm sao để code của mình ít dòng, ít chữ cái nhất có thể. Dần dần CodeGolf trở thành chỗ chơi của mình lúc nào không biết.
Thói quen đó xấu, là bởi vì nhiều khi … mất thời gian. Có nhiều bài chỉ mang tính ôn tập lại mấy cái chức năng của Ruby, thì mình lại bỏ ra 1 tiếng đồng hồ vò đầu bứt tai xem có cách nào ngon và ngắn hơn không.
Nhưng từ khi có thói quen đó, thấy đầu óc mình được optimized hẳn. Trước khi làm gì cũng bỏ ra vài trăm mili giây để đánh giá xem có cách nào nhanh hơn không, và nhờ đó mà nhiều khi lại tiết kiệm được thời gian. Quan trọng nhất là nó thay đổi tư duy của mình khi lập trình: đó là có nhiều cách để giải quyết 1 vấn đề, và không có cách nào là ngắn nhất cả, chỉ có cách ngắn nhất tạm thời, hoặc nói chính xác là “cách ngắn hơn”. Kể cả khi tớ tìm được cách để giải 1 bài mà chỉ dùng có 1 dòng code, tớ cũng chả thấy hài lòng: “chắc chắn còn cách cần ít chữ cái hơn”.
Tất nhiên khi lập trình thì có nhiều tiêu chí để nói code của mình đã được “tối ưu hoá” (không kể đến việc phải giải quyết được vấn đề):
- Chạy nhanh nhất.
- Ít dòng code nhất.
- Thuật giải rõ ràng và đẹp.
Gần như không có ai đạt được cả 3 tiêu chí đó 1 lúc cả. Và thường mọi người khi giải quyết vấn đề thường dựa vào tiêu chí thứ nhất và tiêu chí thứ ba nhiều hơn.
Giờ thì tớ muốn nhét thêm cái tiêu chí thứ 2 đó và phương trình của tớ:
code-tối-ưu = nhanh + đẹp + ngắn
Hồi gì học Ruby trên mạng, tớ gặp 1 bài khá đơn giản như sau:
Cho 1 dãy số từ 1 đến 100. In ra lần lượt cho từng số:
- Fizz nếu số đó chia hết cho 3
- Buzz nếu số đó chia hết cho 5
- FizzBuzz nếu số đó chia hết cho 15
- nếu không phải các trường hợp trên thì in ra chính số đang xét.
Bài này có thể gọi là “siêu dễ”. Thế nhưng mà phần lớn dân tốt nghiệp lập trình không giải được bài này (hô hô).
Cách đơn giản nhất là mà phần lớn học viên ở rubylearning.org đưa ra là như sau:
1.upto(100) do |number|
if number % 15 == 0 then
puts 'FizzBuzz'
elsif number % 5 == 0 then
puts 'Fizz'
elsif number % 15 == 0 then
puts 'Buzz'
else
puts number
end
end
Như vậy là 11 dòng code. Có thể gọi đây là bản thô, chưa tinh xảo.
Một học viên khác (Marcos Ricardo) có vẻ đã quen với việc lập trình trong python từ trước, đưa ra lời giải khá là “pythonish” như thế này:
1.upto(100) do |number|
message = ''
message << "#{'Fizz' if(number % 3 == 0)}#{'Buzz' if(number % 5 == 0)}"
puts message.empty? ? number.to_s : message
end
Như vậy là đỡ đi 1 lệnh điều kiện number % 15, vỏn vẹn 5 dòng.
Tớ thích cách này, vì trông nó rất là … nói chung là mã miếc các thứ lằng nhằng trông prồ. Với cả cách sử dụng lồng biểu thức khá là hay. Nhưng liệu mình có làm được cách nào ngắn gọn hơn và hay hơn không nhỉ?
Cùng lúc đó tớ đang học Drupal. Trong Drupal có 1 cái hệ thống xét loại menu item dựa trên thuộc tính khá là hay: sử dụng cờ (flag).
Các thuộc tính của menu item được biểu diễn dưới dạng các hằng, chứa giá trị thập phân tương ứng với các cờ nhị phân. Và mỗi loại menu đều là một tập hợp nhất định của 1 số thuộc tính nhất định. Chỉ cần cộng các cờ nhị phân của các thuộc tính này sẽ xác định được giá trị định nghĩa của mỗi loại menu item.
Áp dụng với bài toán fizzbuzz này, nếu tớ sử dụng 2 cờ là “chia hết cho 3″ và “chia hết cho 5″, thì tớ chỉ cần cộng giá trị của 2 cờ này với nhau sẽ biết được liệu số này có chia hết cho 3, cho 5, cho cả 2 hay không cho số nào cả. Lời giải của tớ cho bài này trong Ruby chỉ có 2 dòng như sau:
a = nil, 'fizz', 'buzz', 'fizzbuzz'
1.upto(100) { |a[0]| puts a[(a[0]%3 == 0 ? 1 : 0) + (a[0]%5 == 0 ? 2 : 0)] }
Dòng đầu tiên tớ định nghĩa “loại” cho các số.
Ở dòng thứ 2, trong quá trình xét từ 1 đến 100, tớ gán luôn số đang xét cho a[0]. Sau đó tớ cộng tổng của 2 cờ a[0]%3 và a[0]%5, và lôi giá trị “loại” của nó ra từ chính mảng a.
Đến lúc này bạn Marcos Ricardo lại có 1 cách nữa, chỉ mất 1 dòng
1.upto(100) {|number| puts "#{'Fizz' if(number % 3 == 0)}#{'Buzz' if(number % 5 == 0)}".empty? ? number.to_s : "#{'Fizz' if(number % 3 == 0)}#{'Buzz' if(number % 5 == 0)}" }
Ngắn thì ngắn thật. Nhưng hơi bị rối mắt. Và mặc dù cho độ ngắn của lời giải này vượt lời giải của tớ, độ “đẹp” và “nhanh” của nó thì không bằng của tớ được.
Đó là bởi vì cách này phải dùng tới 4 lệnh điều kiện, trong khi của tớ chỉ mất có 2. Và nó phạm phải nguyên tắc DRY (Don’t Repeat Yourself - đừng tự lặp lại mình). Lời giải của tớ thì không mắc phải vấn đề như vậy. Nó hoàn toàn không bị lặp lại, và hoàn toàn có thể được mở rộng để đáp ứng được những yêu cầu mới của bài toán nếu có.
Ví dụ như mở rộng thêm điều kiện cho bài toán như sau:
Cho 1 dãy số từ 1 đến 100. In ra lần lượt cho từng số:
- Fizz nếu số đó chia hết cho 3
- Blah nếu số đó chia hết cho 4
- Buzz nếu số đó chia hết cho 5
- FizzBuzz nếu số đó chia hết cho 15
- FizzBlah nếu số đó chia hết cho 12
- BlahBuzz nếu số đó chia hết cho 20
- FizzBuzzBlah nếu số đó chia hết cho 60
- nếu không phải các trường hợp trên thì in ra chính số đang xét.
Giả sử tớ thích dùng cách “thô” (cái cách mà mất những 11 dòng lúc nãy), tớ sẽ phải thêm đến 4 lệnh điều kiện nữa, và độ dài của chương trình khéo phải đến 20 dòng. Và nếu dùng cách “1 dòng” như trên thì chắc phải mất đến 14 câu điều kiện nhồi nhét vào trong 1 dòng đó. Còn nếu dùng cách của tớ? Chỉ mất 2 dòng và 3 câu điều kiện:
a = nil, 'Fizz', 'Buzz', 'FizzBuzz', 'Blah', 'FizzBlah', 'BlahBuzz', 'FizzBuzzBlah'
1.upto(100) { |a[0]| puts a[(a[0]%3 == 0 ? 1 : 0) + (a[0]%5 == 0 ? 2 : 0) + (a[0]%4 == 0 ? 4 : 0)] }
Như vậy có thể coi lời giải của tớ có độ tối ưu hơn so với các lời giải khác cho đến … 1 tuần sau đó, khi tớ khám phá ra bài viết này:
Oh, Go Ahead. Overthink FizzBuzz
Trong đó có lời giải chỉ mất 1 dòng, mà trông vẫn ngắn gọn, chỉ cần 1 lệnh điều kiện:
(1..100).map {|i| srand(1781773465) if (i%15)==1; [i, "Fizz", "Buzz", "FizzBuzz"][rand(4)]}
Hãy nhìn kĩ nhé: srand(1781773465), và tác giả nói tìm ra con số 1781773465 mất 12 tiếng. Để nói thêm về cách làm này, có lẽ cho tớ khất đến một bài viết sau, vì có lẽ nó … quá sáng tạo (đối với tớ) (tất nhiên nếu mở rộng đề bài như tớ nói ở trên thì có lẽ nó hơi bị mất công, nhưng thực sự cách làm này quá sáng tạo và không ai … dám nghĩ đến). Ta sẽ nói đến lời giải này khi tìm hiểu về Pseudorandom number generator (thuật toán mô phỏng tạo số ngẫu nhiên) trong một bài viết sắp tới của tớ nhé
OK, tớ cuốn xuống phần comment của bài viết, và bắt gặp thêm 1 lời giải 1 dòng nữa, và số kí tự còn ngắn hơn lời giải vừa rồi:
puts (1..100).map{|i|(s=(i%3==0?’Fizz’:”)+(i%5==0?’Buzz’:”))==”?i:s}
I’m speechless!! Sao cách giải đơn giản như vậy mà mình không nghĩ ra!! Và lại còn có bác ngồi mất 12 tiếng để tìm ra con số seed cho hàm random nữa chứ. =))
Và còn đây nữa:
1.upto(100){|i|puts"FizzBuzz#{i}"[i%3<1?0:i%5<1?4:8,i%15<1?8:4]}
Nhìn cách này chỉ muốn đập đầu vào tường! Những cách sáng tạo nhất và ngắn gọn nhất lại là những cách dựa trên những kiến thức cơ bản nhất.
Giờ nhìn lại miếng code 2 dòng mình viết trông thật tội. Áp dụng những cái quá phức tạp cho một bài đơn giản, trong khi có những cách cơ bản khác mà vẫn đạt được độ ngắn như thế. Tuy thế nhưng mà cũng rút ra được khá là nhiều bài học quan trọng:
1. Phải nắm vững lí thuyết và thực hành thành thạo trước khi bắt tay vào làm cái gì.
2. Phức tạp hơn chưa chắc đã là tốt hơn.
3. Nên nhìn nhận vấn đề một cách đơn giản, dựa trên những gì mình đã biết và đã học.
Nghĩ lại thì trò “code 1 dòng” này khá là bổ ích, vì nó vắt kiệt bộ não của lập trình viên và thúc đẩy sự sáng tạo, đồng thời cũng khiến cho việc lập trình trở nên thú vị hơn. Tớ tin chắc là còn có nhiều cách ngắn hơn cho bài này, nhưng có lẽ cũng nên biết điểm dừng thôi nhỉ :P. Tổng thời gian tớ dành ra cho bài toán này:
lời giải đơn giản nhất: 3 phút
tham khảo lời giải của người khác: 30 phút
lời giải 2 dòng: 5 phút
tìm kiếm bài viết liên quan: 30 phút
đọc bài viết và comments của bài “Overthink FizzBuzz”: 30 phút
viết bài blog này: 30 phút
Tổng cộng là 2 tiếng 8 phút
omfg!
