Haskell #1.3 List

 

List

image info

Rất giống với List ngoài đời, List trong Haskell là rất hữu ích, là cấu trúc dữ liệu thường dùng nhât và nó có thể sử dụng theo nhiều cách để mô phỏng và giải quyết một loạt bài toán khác nhau. List THẬT tuyệt vời. Trong mục này ta sẽ tìm hiểu kiến thức cơ bản về List, string (là một List character) và List comperhension. Trong Haskell, List là một cấu trúc dữ liệu đồng nhất. Nó lưu trữ các phần tử có cùng kiểu, nghĩa là ta có thể có một List integer hay List character nhưng không thể có List vừa chứa integer vừa chứa character.

Lưu ý: Ta có thể dùng từ khóa let để định nghĩa một biến ngay ở trong GHCI. Viết let a = 1 trong GHCI cũng tương đương với a = 1 trong một editor.

ghci> let lostNumbers = [4,8,15,16,23,42]
ghci> lostNumbers
[4,8,15,16,23,42]

Như bạn có thể thấy, List được kí hiệu bởi cặp ngoặc vuông và các giá trị trong List được ngăn cách bởi các dấu phẩy. Nếu ta khai báo một List kiểu như [1,2,'a',3,'b','c',4], Haskell sẽ gào lên rằng các kí tự (được biểu diễn trong cặp dấu nháy đơn) không phải là số.

Lại nói về kí tự, string cũng chính là List các kí tự. "hello" chỉ là một dạng cú pháp thay cho ['h','e','l','l','o']. Bởi vì string là List nên ta cũng có thể dùng một số hàm thao tác lên List với chúng; điều này quả là tiện. Ví dụ như cộng hai List bằng toán tử ++.

ghci> [1,2,3,4] ++ [9,10,11,12]
[1,2,3,4,9,10,11,12]
ghci> "hello" ++ " " ++ "world"
"hello world"
ghci> ['w','o'] ++ ['o','t']
"woot"

Hãy cẩn thận khi liên tiếp dùng toán tử ++ đối với List dài. Khi xếp hai List cạnh nhau (ngay cả khi thêm một List đơn phần tử vào một List, chẳng hạn: [1,2,3] ++ [4]), thì ở bên trong, Haskell phải dò dọc theo toàn bộ List vế trái của ++. Điều này không đáng ngại nếu ta chỉ xử lý những List không quá lớn. Nhưng nếu đặt một thứ vào cuối một List gồm 50 triệu phần tử thì sẽ mất nhiều chút thời gian. Tuy nhiên, đặt một thứ gì đó vào đầu List bằng toán tử : (còn được gọi là toán tử cons) thì hiệu quả tức thì.

ghci> 'A':" SMALL CAT"
"A SMALL CAT"
ghci> 5:[1,2,3,4,5]
[5,1,2,3,4,5]

Lưu ý rằng toán tử : nhận một số và một List các số, hoặc một kí tự và một List kí tự, trong khi ++ nhận vào hai List. Ngay cả khi nếu bạn thêm một phần tử vào cuối List với ++, bạn phải kẹp phần tử đó giữa cặp ngoặc vuông để biến nó thành List. [1,2,3] thực ra là dạng cú pháp thay cho 1:2:3:[][] là một List rỗng. Nếu ta đặt 3 vào trước, nó sẽ trở thành [3]. Nếu ta đặt 2 vào trước, List mới sẽ là [2,3], và cứ như vậy. Lưu ý: [][[]] và [[],[],[]] khác nhau. Cái đầu là một List rỗng, cái thứ hai là List bao gồm một List rỗng, còn cái thứ ba là một List bao gồm ba List rỗng. Nếu bạn muốn lấy một phần tử với chỉ số nhất định khỏi một List, hãy dùng toán tử !!.

ghci> "Steve Buscemi" !! 6
'B'
ghci> [9.4,33.2,96.2,11.2,23.25] !! 1
33.2

Nhưng nếu bạn cố gắng lấy phần tử thứ 6 khỏi một List chỉ có bốn phần tử, bạn sẽ ăn lỗi, vì vậy phải rất cẩn thận! List cũng có thể chứa List. Chúng có thể chứa các List mà bản thân List này lại chứa List bên trong …

ghci> let b = [[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]]
ghci> b
[[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]]
ghci> b ++ [[1,1,1,1]]
[[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3],[1,1,1,1]]
ghci> [6,6,6]:b
[[6,6,6],[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]]
ghci> b !! 2
[1,2,2,3,4]

image info

List bên trong List có thể dài ngắn khác nhau nhưng không thể khác kiểu nhau. Cũng như không thể có List chứa đồng thời kí tự và số, bạn không thể có một List chứa một vài List kí tự và một vài List số. List cũng có thể được so sánh với nhau nếu các thứ chứa trong đó so sánh được. Khi dùng <<=> và >= để so sánh các List, việc so sánh được thực hiện theo thứ tự từ vựng. Trước hết, các phần tử đầu được so sánh với nhau. Nếu chúng bằng nhau thì các phần tử thứ hai được so sánh, và cứ như vậy.

ghci> [3,2,1] > [2,1,0]
True
ghci> [3,2,1] > [2,10,100]
True
ghci> [3,4,2] > [3,4]
True
ghci> [3,4,2] > [2,4]
True
ghci> [3,4,2] == [3,4,2]
True

Ngoài ra chúng ta còn có một số hàm cơ bản thao tác lên List. head nhận vào một List rồi trả lại phần tử đầu của nó.

ghci> head [5,4,3,2,1]
5

tail nhận vào một List rồi trả lại đuôi của nó. Nói cách khác, nó chặt đầu của List đi.

ghci> tail [5,4,3,2,1]
[4,3,2,1]

last nhận vào một List rồi trả lại phần tử cuối cùng của nó.

ghci> last [5,4,3,2,1]
1

init nhận vào một List rồi trả lại tất cả mọi thứ trừ phần tử cuối cùng.

ghci> init [5,4,3,2,1]
[5,4,3,2]

Nếu ta hình dung List như một con quái vật, thì các khái niệm trên sẽ như sau.

image info

Nhưng điều gì sẽ xảy ra nếu ta thử lấy đầu của một List rỗng?

ghci> head []
*** Exception: Prelude.head: empty List

Ồi giời ơi, kết quả tệ không ngờ được! Không có quái vật, sẽ không có cái đầu nào. Khi dùng headtaillast và init, hãy cẩn thận tránh dùng nó với các List rỗng. Lỗi kiểu này không catch được lúc biên dịch được, cho nên điều hay là luôn phải phòng ngừa cho việc tình cờ bảo Haskell đưa cho bạn một phần tử nào đó từ một List rỗng.

length nhận vào một List rồi trả lại độ dài của nó.

ghci> length [5,4,3,2,1]
5

null là hàm kiểm tra xem List có rỗng không. Nếu có, nó sẽ trả lại True, hãy dùng hàm này thay cho việc viết xs == [] (nếu bạn có một List tên là xs).

ghci> null [1,2,3]
False
ghci> null []
True

reverse đảo ngược một List.

ghci> reverse [5,4,3,2,1]
[1,2,3,4,5]

take nhận vào một integer n và một List. Nó lấy ra n phần tử kể từ đầu List. Xem này.

ghci> take 3 [5,4,3,2,1]
[5,4,3]
ghci> take 1 [3,9,3]
[3]
ghci> take 5 [1,2]
[1,2]
ghci> take 0 [6,6,6]
[]

Lưu ý rằng nếu ta thử lấy nhiều phần tử hơn số lượng vốn có trong List, nó sẽ trả lại toàn bộ List. Nếu ta lấy 0 phần tử, ta sẽ nhận được một List rỗng.

drop làm việc theo cách ngược lại, bỏ đi từng ấy số phần tử kể từ đầu List.

ghci> drop 3 [8,4,2,1,5,6]
[1,5,6]
ghci> drop 0 [1,2,3,4]
[1,2,3,4]
ghci> drop 100 [1,2,3,4]
[]

maximum nhận vào một List các thứ có thể sắp xếp được theo cách nào đó, rồi trả lại phần tử lớn nhất. minimum trả lại phần tử nhỏ nhất.

ghci> minimum [8,4,2,1,5,6]
1
ghci> maximum [1,9,2,3,4]
9

sum nhận vào một List số rồi trả lại tổng của chúng. product nhận vào một List rồi trả lại tích của chúng.

ghci> sum [5,2,1,6,3,2,5,7]
31
ghci> product [6,2,1,2]
24
ghci> product [1,2,5,6,7,9,2,0]
0

elem nhận vào một thứ và một List rồi báo thứ đó có phải là phần tử thuộc List không. Nó thường được gọi là hàm trung tố vì nếu đọc theo kiểu đó sẽ dễ hơn.

ghci> 4 `elem` [3,4,5,6]
True
ghci> 10 `elem` [3,4,5,6]
False

Đó là một số hàm cơ bản hoạt động với List. Ta sẽ xem thêm một số hàm với List sau này.

Range

Nếu một bài toán nào đó yêu cầu chúng ta một List gồm tất cả con số từ 1 đến 20? Chắc chắn là ta có thể gõ tất cả vào nhưng hiển nhiên đó chỉ là giải pháp cục súc. Thay vào đó, chúng ta có thể dùng rangerange, giống như trong Python là cách tạo List chứa loạt các phần tử đếm được. Số có thể đếm được: Một, hai, ba, bốn, v.v. Kí tự cũng có thể đếm được. Bảng chữ cái chính là một cách đếm kí tự từ A đến Z. Tên thì không thể đếm được.

Để tạo một dãy chứa các số tự nhiên từ 1 đến 20, bạn phải viết [1..20] (tương đương với [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]). Không có sự khác biệt nào giữa hai cách này ngoại trừ viết chay là việc làm hơi ngu.

ghci> [1..20]
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
ghci> ['a'..'z']
"abcdefghijklmnopqrstuvwxyz"
ghci> ['K'..'Z']
"KLMNOPQRSTUVWXYZ"

range còn có thể nhận vào số bước nhảy. Ví dụ như lấy số chẵn giữa 1 và 20. Hoặc cứ cách ba số thì lấy một số trong khoảng từ 1 đến 20?

ghci> [2,4..20]
[2,4,6,8,10,12,14,16,18,20]
ghci> [3,6..20]
[3,6,9,12,15,18]

Chỉ cần tách hai tham số đầu tiên bằng một dấu phẩy rồi chỉ định xem giới hạn trên bằng bao nhiêu. Tuy rằng cách này khá tiện nhưng dãy có bước nhảy không thông minh như nhiều người mong đợi. Bạn không thể viết [1,2,4,8,16..100] để có được tất cả số lũy thừa 2. Trước hết là vì bạn chỉ có thể chỉ định một bước nhảy duy nhất. Thứ hai là vì dãy không có tính chất số học sẽ rất mơ hồ nếu ta chỉ cung cấp một số ít phần tử đầu tiên. Để tạo một List với tất cả những số từ 20 về 1, bạn không thể chỉ viết [20..1], mà phải viết [20,19..1]. Ngoài ra, hãy cẩn thận khi dùng số có dấu phẩy động trong dãy! Vì chúng không chính xác hoàn toàn (theo định nghĩa) nên việc dùng chúng trong dãy có thể cho kết quả khá kì quái.

ghci> [0.1, 0.3 .. 1]
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]

Lời khuyên của tôi là không nên dùng chúng trong dãy. Bạn cũng có thể dùng dãy để tạo ra List vô hạn bằng việc không xác định giới hạn trên. Sau này ta sẽ tìm hiểu sâu hơn về dãy vô hạn. Hiện tại, hãy kiểm tra xem bạn làm thế nào để lấy được 24 tích số đầu tiên của 13. Tất nhiên là bạn có thể viết [13,26..24*13]. Như còn một cách khác hay hơn: take 24 [13,26..]. Vì Haskell có tính lazy, nên nó sẽ không thử tính toán ngay lập tức cả dãy vô hạn này vì việc đó sẽ không bao giờ kết thúc. Haskell sẽ đợi xem bạn muốn lấy thứ gì từ dãy vô hạn đó. Và khi biết rằng bạn chỉ muốn 24 phần tử đầu tiên, nó ngoan ngoãn nghe lời.

Một số ít các hàm tạo ra List vô hạn, ví dụ như cycle nhận vào một List rồi quay vòng nó để tạo ra một List vô hạn. Nếu bạn chỉ hiển thị kết quả, nó sẽ chạy mãi mãi nên bạn phải cắt nó ở một vị trí nào đó.

ghci> take 10 (cycle [1,2,3])
[1,2,3,1,2,3,1,2,3,1]
ghci> take 12 (cycle "LOL ")
"LOL LOL LOL "

repeat nhận vào một phần tử rồi tạo ra một List vô hạn chính phần tử đó. Đây là kiểu quay vòng chỉ dùng một phần tử.

ghci> take 10 (repeat 5)
[5,5,5,5,5,5,5,5,5,5]

Tuy nhiên sẽ đơn giản hơn nếu ta dùng hàm replicate, nếu muốn một số lần nào đó lặp lại một phần tử trong List. replicate 3 10 trả về [10,10,10].