Cấu trúc dữ liệu -Tổng quan về Big-O


 


Ký hiệu Big-O là ký hiệu toán học được sử dụng để mô tả hiệu suất hoặc độ phức tạp của thuật toán, cụ thể là thời gian chạy của thuật toán khi kích thước đầu vào tăng lên. Hiểu ký hiệu Big-O là điều cần thiết đối với các kỹ sư phần mềm vì nó cho phép họ phân tích và so sánh hiệu quả của các thuật toán khác nhau và đưa ra quyết định sáng suốt về việc nên sử dụng thuật toán nào trong một tình huống nhất định. Trong hướng dẫn này, chúng tôi sẽ đề cập đến những kiến ​​thức cơ bản về ký hiệu Big-O và cách sử dụng nó để phân tích hiệu suất của các thuật toán.


Big-O là gì?

Ký hiệu Big-O là cách thể hiện độ phức tạp về thời gian (hoặc không gian) của thuật toán. Nó cung cấp ước tính sơ bộ về thời gian chạy của một thuật toán (hoặc lượng bộ nhớ mà nó sử dụng), dựa trên kích thước của đầu vào. Ví dụ: một thuật toán có độ phức tạp về thời gian có nghĩa là thời gian chạy tăng tuyến tính theo kích thước của đầu vào.


Độ phức tạp theo thời gian là gì?

Độ phức tạp về thời gian là thước đo thời gian chạy của một thuật toán, dựa trên kích thước của đầu vào. Nó được thể hiện bằng ký hiệu Big-O, cung cấp ước tính sơ bộ về thời gian chạy. Thuật toán có độ phức tạp thời gian thấp hơn thường sẽ nhanh hơn thuật toán có độ phức tạp thời gian cao hơn.


Độ phức tạp theo không gian là gì?

Độ phức tạp của không gian là thước đo lượng bộ nhớ mà thuật toán yêu cầu, dựa trên kích thước của đầu vào. Giống như độ phức tạp về thời gian, nó được thể hiện bằng ký hiệu Big-O. Thuật toán có độ phức tạp không gian thấp hơn thường sẽ yêu cầu ít bộ nhớ hơn thuật toán có độ phức tạp không gian cao hơn.


Ví dụ về độ phức tạp theo thời gian?

Dưới đây là một số ví dụ về cách thể hiện độ phức tạp thời gian khác nhau bằng cách sử dụng ký hiệu Big-O:

  • O(1): Thời gian không đổi. Thời gian chạy không phụ thuộc vào kích thước của đầu vào.
  • O(n): Thời gian tuyến tính. Thời gian chạy tăng tuyến tính với kích thước của đầu vào, nói một cách dễ hiểu thì nếu dữ liệu tăng gấp 10 lần thì thời gian tốn gấp 10 lần
  • O(n^2): Thời gian bậc hai. Thời gian chạy tỷ lệ thuận với bình phương kích thước của đầu vào.
  • O(logn): Thời gian logarit. Thời gian chạy tăng logarit theo kích thước của đầu vào.
  • O(2^n): Thời gian theo cấp số nhân. Thời gian chạy tăng theo cấp số nhân theo kích thước của đầu vào.


Điều quan trọng cần lưu ý là ký hiệu Big-O chỉ cung cấp giới hạn trên về thời gian chạy của thuật toán. Điều này có nghĩa là một thuật toán có độ phức tạp về thời gian O(n) có khả năng chạy nhanh hơn thuật toán có độ phức tạp về thời gian O(logn) trong một số trường hợp, tùy thuộc vào cách triển khai cụ thể và phần cứng đang được sử dụng.

Ngoài ra, ký hiệu Big-O chỉ xem xét thuật ngữ chiếm ưu thế trong phương trình thời gian chạy. Ví dụ: một thuật toán có thời gian chạy O(n^2 +n) sẽ được đơn giản hóa thành O(n^2).


Các thuật toán mẫu và độ phức tạp về thời gian của chúng

Dưới đây là một số ví dụ về thuật toán cùng với độ phức tạp về thời gian của chúng được biểu thị bằng ký hiệu Big-O:

1. Linear search - O(n)

 function linearSearch(arr, x) {  
   for (let i = 0; i < arr.length; i++) {  
     if (arr[i] === x) {  
       return i;  
     }  
   }  
   return -1;  
 }  
 // Usage  
 const arr = [3, 5, 7, 9, 11];  
 const x = 7;  
 console.log(linearSearch(arr, x));  // => 2

Thuật toán tìm kiếm tuyến tính này có độ phức tạp về thời gian là O(n) , vì thời gian chạy tỷ lệ thuận với kích thước của đầu vào. Nếu kích thước đầu vào là n , tìm kiếm tuyến tính sẽ mất n bước để hoàn thành.

2. Binary search - O(logn)

 class Solution {  
   static binarySearch(arr, x) {  
     let low = 0;  
     let high = arr.length - 1;  
     while (low <= high) {  
       let mid = Math.floor((low + high) / 2);  
       if (arr[mid] < x) {  
         low = mid + 1;  
       } else if (arr[mid] > x) {  
         high = mid - 1;  
       } else {  
         return mid;  
       }  
     }  
     return -1;  
   }  
 }  
 // Usage  
 const arr = [2, 3, 4, 10, 40];  
 const x = 10;  
 console.log(Solution.binarySearch(arr, x));  

Thuật toán tìm kiếm nhị phân này có độ phức tạp về thời gian là O(logn) , vì thời gian chạy tăng logarit theo kích thước của đầu vào. Nếu kích thước đầu vào là n, sẽ mất khoảng logn các bước để hoàn thành tìm kiếm nhị phân.

Tại sao thời gian chạy là O(log n)?

Trong tìm kiếm nhị phân, tại mỗi bước, phạm vi tìm kiếm giảm đi một nửa. Giả sử mảng có kích thước n:

  • Bước đầu tiên: n phần tử.
  • Bước thứ hai: n/2 phần tử.
  • Bước thứ ba: n/4 phần tử.
  • ...

Quá trình này tiếp tục cho đến khi phạm vi tìm kiếm chỉ còn 1 phần tử. Số bước để đạt được điều này là số lần chia n cho 2 cho đến khi còn lại 1, tức là log cơ số 2 của n (log₂(n)). Do đó, số bước là O(log n).

Nếu mảng arr có 16 phần tử (n = 16):

  1. Lần 1: Phạm vi tìm kiếm là 16 phần tử.
  2. Lần 2: Phạm vi tìm kiếm là 8 phần tử.
  3. Lần 3: Phạm vi tìm kiếm là 4 phần tử.
  4. Lần 4: Phạm vi tìm kiếm là 2 phần tử.
  5. Lần 5: Phạm vi tìm kiếm là 1 phần tử.

Số bước cần thiết là log2(16)=4\log_2(16) = 4

Với mảng có 5 phần tử, thuật toán tìm kiếm nhị phân sẽ cần khoảng log2(5)\log_2(5) bước.

log2(5)2.32

Làm tròn lên, chúng ta sẽ cần 3 bước để hoàn thành tìm kiếm.


3. Bubble sort -O(n^2)

 class Solution {  
   static bubbleSort(arr) {  
     let n = arr.length;  
     for (let i = 0; i < n; i++) {  
       for (let j = 0; j < n - i - 1; j++) {  
         if (arr[j] > arr[j + 1]) {  
           [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];  
         }  
       }  
     }  
   }  
 }  
 // Usage  
 const arr = [64, 34, 25, 12, 22, 11, 90];  
 Solution.bubbleSort(arr);  
 console.log("Sorted array is:", arr);  

Độ phức tạp thời gian

  1. Vòng lặp ngoài (for loop với i):

    • Chạy từ 0 đến n-1.
    • Tổng số lần lặp là n.
  2. Vòng lặp trong (for loop với j):

    • Trong mỗi lần lặp của vòng lặp ngoài, vòng lặp này chạy từ 0 đến n-i-2.
    • Tổng số lần lặp của vòng lặp trong phụ thuộc vào giá trị của i và là n-i-1.

Tổng số lần lặp

Để tính tổng số lần lặp của vòng lặp trong qua tất cả các lần lặp của vòng lặp ngoài:

i=0n1(ni1)\sum_{i=0}^{n-1} (n - i - 1)

Đây là một chuỗi số học và có thể được tính như sau:

i=0n1(ni1)=(n1)+(n2)++1+0\sum_{i=0}^{n-1} (n - i - 1) = (n - 1) + (n - 2) + \ldots + 1 + 0


Áp dụng công thức tính tổng của n số nguyên liên tiếp chúng ta sẽ được:

n(n1)2\frac{n(n - 1)}{2}

Độ phức tạp bậc lớn nhất

Khi tính toán độ phức tạp, chúng ta chỉ quan tâm đến bậc lớn nhất, vì nó chiếm ưu thế khi n lớn. Trong trường hợp này, bậc lớn nhất là n2n^2.


4. Quick Sort - O(n*logn)

 class Solution {  
   static quickSort(arr) {  
     if (arr.length <= 1) return arr;  
     let pivot = arr[Math.floor(arr.length / 2)];  
     let left = arr.filter(x => x < pivot);  
     let middle = arr.filter(x => x === pivot);  
     let right = arr.filter(x => x > pivot);  
     return [...Solution.quickSort(left), ...middle, ...Solution.quickSort(right)];  
   }  
 }  
 // Usage  
 const arr = [3, 6, 8, 10, 1, 2, 1];  
 const sortedArr = Solution.quickSort(arr);  
 console.log("Sorted array:", sortedArr);  


Phân tích độ phức tạp

  • Trong mỗi bước phân chia, thuật toán Quicksort chọn một pivot và phân chia mảng thành hai phần: phần bên trái và phần bên phải của pivot.
  • Độ dài của các phần này trong trường hợp lý tưởng sẽ là gần như bằng nhau.

Chi Phí của Một Phân Chia

  • Chi phí của việc phân chia một mảng có n phần tử là O(n), vì mỗi phần tử trong mảng phải được so sánh với pivot.

Số bước đệ quy

  • Tổng số cấp độ phân chia là log n vì bạn cần log n lần phân chia để giảm kích thước của mảng từ n phần tử xuống còn 1.
  • Ở mỗi cấp độ phân chia, mảng được chia thành hai phần nhỏ hơn. Số cấp độ phân chia là log n (trong trường hợp lý tưởng), vì mỗi phân chia làm giảm kích thước của mảng còn khoảng một nửa.

Tổng độ phức tạp

  • Số cấp độ phân chia là O(log n)
  • Chi phí phân chia mỗi cấp là O(n)

5. Fibonacci sequence - O(2^n)

 class Solution {  
   static fibonacci(n) {  
     if (n === 0) return 0;  
     if (n === 1) return 1;  
     return Solution.fibonacci(n - 1) + Solution.fibonacci(n - 2);  
   }  
 }  
 // Usage  
 const n = 10; // Example input  
 console.log(`Fibonacci number at position ${n} is ${Solution.fibonacci(n)}`);  


Phân tích độ phức tạp

Khi n=5, cây đệ quy của các lời gọi hàm trông như sau

fibonacci(5)
├── fibonacci(4)
│   ├── fibonacci(3)
│   │   ├── fibonacci(2)
│   │   │   ├── fibonacci(1) -> 1
│   │   │   └── fibonacci(0) -> 0
│   │   └── fibonacci(1) -> 1
│   └── fibonacci(2)
│       ├── fibonacci(1) -> 1
│       └── fibonacci(0) -> 0
└── fibonacci(3)
    ├── fibonacci(2)
    │   ├── fibonacci(1) -> 1
    │   └── fibonacci(0) -> 0
    └── fibonacci(1) -> 1

  • Mỗi lời gọi hàm: Mỗi lời gọi hàm sẽ tạo ra hai lời gọi con, do đó số lượng lời gọi tăng theo cấp số nhân
  • Cấp độ n: Ở cấp độ n, số lượng lời gọi là 2^n

Các thuật toán mẫu và độ phức tạp về không gian của chúng

1.Linear search-O(1)
 class Solution {  
   static linearSearch(arr, x) {  
     for (let i = 0; i < arr.length; i++) {  
       if (arr[i] === x) {  
         return i;  
       }  
     }  
     return -1;  
   }  
 }  
 // Usage  
 const arr = [3, 4, 2, 7, 9];  
 const x = 7;  
 const result = Solution.linearSearch(arr, x);  
 console.log(result !== -1 ? `Element found at index ${result}` : "Element not found");  
  • arr và x là các tham số đầu vào của hàm. Bộ nhớ để lưu trữ các tham số này không tính vào độ phức tạp không gian vì chúng là bộ nhớ đầu vào.
  • Biến i trong vòng lặp for là một biến đơn. Bộ nhớ cho một biến đơn là cố định, không phụ thuộc vào kích thước của arr.
  • Trong toàn bộ quá trình thực hiện của hàm, không có cấu trúc dữ liệu bổ sung nào khác được tạo ra. Chỉ có một biến đơn i được sử dụng để duyệt qua mảng.
2. Fibonacci sequence -O(n)
 class Solution {  
   static fibonacci(n) {  
     if (n === 0) return 0;  
     if (n === 1) return 1;  
     return Solution.fibonacci(n - 1) + Solution.fibonacci(n - 2);  
   }  
 }  
 // Usage  
 const n = 10; // Example input  
 console.log(`Fibonacci number at position ${n} is ${Solution.fibonacci(n)}`);  
  • Ngăn xếp đệ quy (Recursive Call Stack): Mỗi lần gọi hàm đệ quy, trạng thái của hàm hiện tại được lưu trữ trên ngăn xếp để sau khi hoàn thành các lời gọi hàm con, chương trình có thể tiếp tục thực hiện hàm hiện tại. Do đó, bộ nhớ cần thiết cho ngăn xếp đệ quy phụ thuộc vào chiều sâu của cây đệ quy.
  • Chiều sâu của cây đệ quy: Trong trường hợp xấu nhất, độ sâu tối đa của cây đệ quy là n, vì chúng ta phải thực hiện các lời gọi hàm fibonacci(n-1), fibonacci(n-2),... cho đến fibonacci(0).
Vì vậy độ phức tạp không gian (Space Complexity): Mỗi lời gọi đệ quy chiếm một lượng bộ nhớ nhất định trên ngăn xếp. Chiều sâu của cây đệ quy tối đa là n, do đó độ phức tạp không gian là O(n).

3. Merge sort -O(n)
 class Solution {  
   static mergeSort(arr) {  
     if (arr.length > 1) {  
       let mid = Math.floor(arr.length / 2);  
       let left = arr.slice(0, mid);  
       let right = arr.slice(mid);  
       Solution.mergeSort(left);  
       Solution.mergeSort(right);  
       let i = 0, j = 0, k = 0;  
       while (i < left.length && j < right.length) {  
         if (left[i] < right[j]) {  
           arr[k++] = left[i++];  
         } else {  
           arr[k++] = right[j++];  
         }  
       }  
       while (i < left.length) {  
         arr[k++] = left[i++];  
       }  
       while (j < right.length) {  
         arr[k++] = right[j++];  
       }  
     }  
   }  
 }  
 // Usage  
 const arr = [12, 11, 13, 5, 6, 7];  
 Solution.mergeSort(arr);  
 console.log("Sorted array is:", arr);  

Phân tích độ phức tạp không gian

Bộ nhớ bổ sung cho các mảng con
  • Chia mảng thành hai nửa: Tại mỗi bước của thuật toán, mảng đầu vào được chia thành hai mảng con left và right với kích thước tổng cộng bằng kích thước của mảng gốc.
  • Hợp nhất mảng: Quá trình hợp nhất sử dụng các biến i, j, k để theo dõi các vị trí trong các mảng con và mảng gốc. Các biến này là cố định và không phụ thuộc vào kích thước của mảng đầu vào, do đó không đóng góp vào độ phức tạp không gian.
Ngăn xếp đệ quy
  • Đệ quy: Merge Sort là một thuật toán đệ quy, do đó mỗi lời gọi hàm đệ quy yêu cầu một lượng bộ nhớ bổ sung trên ngăn xếp gọi hàm.
  • Chiều sâu của ngăn xếp đệ quy: Merge Sort chia mảng thành hai nửa tại mỗi bước, dẫn đến chiều sâu của ngăn xếp đệ quy tối đa là log(n) (vì mảng được chia đôi cho đến khi mỗi mảng con chỉ có một phần tử).
Kết hợp các yếu tố
  • Bộ nhớ cho các mảng con: Tại mỗi mức độ sâu của đệ quy, tổng kích thước của các mảng con là bằng kích thước của mảng gốc. Vì vậy, nếu không tính bộ nhớ dùng để lưu trữ mảng đầu vào ban đầu, mỗi lần chia mảng sẽ tạo ra một lượng bộ nhớ bổ sung tương đương với mảng gốc.
  • Ngăn xếp đệ quy: Chiều sâu tối đa của ngăn xếp đệ quy là log(n), mỗi mức độ sâu yêu cầu bộ nhớ bổ sung cho việc lưu trữ trạng thái của các biến và mảng con.
Độ phức tạp không gian tổng thể
  • Bộ nhớ bổ sung cho các mảng con: Mỗi bước chia mảng tạo ra các mảng con có tổng kích thước bằng kích thước của mảng gốc, dẫn đến độ phức tạp không gian là O(n).
  • Ngăn xếp đệ quy: Chiều sâu của ngăn xếp đệ quy là O(log(n)), nhưng không yêu cầu bộ nhớ bổ sung đáng kể so với các mảng con.
Vì vậy, tổng thể, độ phức tạp không gian của thuật toán Merge Sort là O(n).

4. Quick Sort -O(n)  or O(log n)
 class Solution {  
   static quickSort(arr) {  
     if (arr.length <= 1) return arr;  
     let pivot = arr[Math.floor(arr.length / 2)];  
     let left = arr.filter(x => x < pivot);  
     let middle = arr.filter(x => x === pivot);  
     let right = arr.filter(x => x > pivot);  
     return [...Solution.quickSort(left), ...middle, ...Solution.quickSort(right)];  
   }  
 }  
 // Usage  
 const arr = [3, 6, 8, 10, 1, 2, 1];  
 const sortedArr = Solution.quickSort(arr);  
 console.log("Sorted array:", sortedArr);  


Phân tích độ phức tạp không gian

Bộ nhớ bổ sung cho các mảng con
  • Phân chia mảng: Tại mỗi bước của thuật toán, mảng đầu vào được chia thành ba mảng con: left, middle, và right. Mỗi mảng con này được tạo ra bằng cách lọc mảng ban đầu, do đó cần bộ nhớ bổ sung để lưu trữ chúng.
  • Hợp nhất mảng: Quá trình hợp nhất các mảng con left, middle, và right sử dụng toán tử trải (spread operator) ..., tạo ra một mảng mới từ các mảng con. Do đó, cần bộ nhớ bổ sung để lưu trữ mảng kết quả.
Ngăn xếp đệ quy
  • Đệ quy: QuickSort là một thuật toán đệ quy, do đó mỗi lời gọi hàm đệ quy yêu cầu một lượng bộ nhớ bổ sung trên ngăn xếp gọi hàm.
  • Chiều sâu của ngăn xếp đệ quy: Chiều sâu của ngăn xếp đệ quy phụ thuộc vào cách chọn chốt và phân chia mảng. Trong trường hợp trung bình, chiều sâu của ngăn xếp đệ quy là log(n), nhưng trong trường hợp xấu nhất (khi chốt luôn là phần tử lớn nhất hoặc nhỏ nhất), chiều sâu có thể lên tới n.
Kết hợp các yếu tố
  • Bộ nhớ cho các mảng con: Tại mỗi bước chia mảng, cần bộ nhớ bổ sung để lưu trữ các mảng con left, middle, và right. Tổng kích thước của các mảng con này bằng kích thước của mảng gốc, dẫn đến bộ nhớ bổ sung cần thiết là O(n).
  • Ngăn xếp đệ quy: Chiều sâu của ngăn xếp đệ quy trong trường hợp trung bình là O(log(n)), nhưng trong trường hợp xấu nhất có thể là O(n). Tuy nhiên, yếu tố này thường không đóng góp nhiều vào độ phức tạp không gian tổng thể so với bộ nhớ cho các mảng con.

5. Create Matrix -O(n^2)
 class Solution {  
   static createMatrix(n) {  
     return Array.from({ length: n }, () => Array(n).fill(0));  
   }  
 }  
 // Usage  
 const n = 3; // Example size  
 const matrix = Solution.createMatrix(n);  
 matrix.forEach(row => console.log(row));  

Phân tích độ phức tạp không gian

Bộ nhớ cần thiết để tạo ma trận

1. Mảng chính:

  • Array.from({ length: n }, ...) tạo ra một mảng gồm n phần tử.
  • Mỗi phần tử của mảng này là một mảng con có n phần tử.
2. Mảng con:

  • Array(n).fill(0) tạo ra một mảng con gồm n phần tử, tất cả đều được khởi tạo là 0.
  • Có n mảng con như vậy, mỗi mảng con có kích thước n.
Tổng bộ nhớ cần thiết
  • Tổng số phần tử trong ma trận là n x n.
  • Mỗi phần tử là một số nguyên 0, yêu cầu một lượng bộ nhớ cố định.
Kết luận về độ phức tạp không gian
  • Độ phức tạp không gian (Space Complexity): Bộ nhớ bổ sung cần thiết để tạo ra ma trận là tỷ lệ thuận với tổng số phần tử trong ma trận. Vì ma trận có kích thước n x n, nên độ phức tạp không gian là O(n²).
Kết luận

Độ phức tạp không gian của đoạn mã createMatrix là O(n²), do cần tạo và lưu trữ một ma trận có n x n phần tử, mỗi phần tử đều là một số nguyên 0.

Qua bài viết tôi đã giới thiệu về Big-O của cấu trúc dữ liệu, chúc các bạn có một ngày làm việc vui vẻ.Thanks all!


Nhận xét

Bài đăng phổ biến từ blog này

Cài đặt SSL cho website sử dụng certbot

Xây dựng một hệ thống comment real-time hoặc chat đơn giản sử dụng Pusher

CÁC BÀI TẬP SQL CƠ BẢN - PART 1

Xây dựng một hệ thống tracking hành vi người dùng (phần 1)

Xây dựng một hệ thống tracking hành vi người dùng (phần 2)

Enterprise architecture trên 1 tờ A4

Web caching (P2)

Bàn về async/await trong vòng lặp javascript

Web caching (P1)

Cài đặt môi trường để code website Rails