jsrosetta

Cấu trúc dữ liệu

Tự cài đặt dynamic array

Tự viết dynamic array từ vùng nhớ cố định bằng Node.js, Go và Rust: len, capacity, nhân đôi khi đầy, push/pop/insert/remove và độ phức tạp.

Tác giả:
Phiên bản tối thiểu
Node.js ≥ 14.6Go ≥ 1.23Rust ≥ 1.58
Đã chạy thử trên
Node.js 24.12.0Go 1.27.1Rust 1.98.1

Code Node.js là ES module: lưu file .mjs hoặc đặt "type": "module" trong package.json.

Array của Node.js, slice của Go và Vec của Rust đều là dynamic array: một vùng nhớ liền mạch (với Array là ở dạng fast elements thông thường của V8) có sẵn chỗ trống phía sau, khi hết chỗ thì cấp vùng lớn hơn rồi chép dữ liệu sang. Bài này tự dựng lại cấu trúc đó trên một vùng nhớ coi như có kích thước cố định để thấy rõ length (số phần tử đang dùng) và capacity (số ô đã cấp) khác nhau thế nào. Node.js không có mảng kích thước cố định cho giá trị bất kỳ, nên ta dùng new Array(capacity) và tự cam kết không gọi push hay đổi length của nó. Go dùng slice tạo bằng make([]T, capacity) nhưng không bao giờ append. Rust dùng Box<[Option<T>]>, với None đánh dấu ô chưa dùng, để không phải viết unsafe.

Cấu trúc và tăng capacity

Mỗi bản giữ hai thứ: vùng nhớ data và số phần tử length. capacity chính là độ dài của data. Khi push vào mảng đã đầy, grow cấp vùng mới gấp đôi, chép các phần tử cũ sang rồi mới ghi phần tử mới.

class DynamicArray {
  #data; // vùng nhớ cố định: chỉ đọc/ghi theo chỉ số, không push hay đổi length
  #length = 0;
 
  constructor(capacity = 4) {
    this.#data = new Array(capacity);
  }
 
  get length() {
    return this.#length;
  }
 
  get capacity() {
    return this.#data.length;
  }
 
  push(value) {
    const isFull = this.#length === this.capacity;
    if (isFull) {
      this.#grow();
    }
    this.#data[this.#length] = value;
    this.#length += 1;
  }
 
  #grow() {
    // capacity 0 nhân đôi vẫn là 0, nên tối thiểu là 1
    const next = new Array(Math.max(1, this.capacity * 2));
    for (let i = 0; i < this.#length; i++) {
      next[i] = this.#data[i];
    }
    this.#data = next;
  }
}
 
const arr = new DynamicArray(2);
for (let i = 1; i <= 5; i++) {
  arr.push(i * 10);
  console.log(`len=${arr.length} cap=${arr.capacity}`);
}
// → len=1 cap=2
// → len=2 cap=2
// → len=3 cap=4
// → len=4 cap=4
// → len=5 cap=8

Một lần grow tốn O(n) vì phải chép toàn bộ phần tử, nhưng nhờ nhân đôi, tổng số lần chép sau n lần push không vượt quá khoảng 2n. Chia đều ra, mỗi push tốn O(1) — gọi là amortized O(1). Nếu mỗi lần chỉ tăng thêm một số ô cố định (ví dụ +10), tổng số lần chép sẽ là O(n²).

Đầy đủ: get, set, pop, insert, remove và duyệt

Bản đầy đủ thêm đọc/ghi theo chỉ số, chèn/xoá ở giữa và duyệt phần tử. push giờ chỉ là insert vào cuối, pop là remove phần tử cuối.

  • get ngoài phạm vi trả "không có giá trị" theo cách quen thuộc của mỗi ngôn ngữ: JS trả undefined, Go trả thêm bool ("comma ok"), Rust trả Option<&T>.
  • set, insert, remove với chỉ số sai là lỗi lập trình: JS ném RangeError, Go và Rust panic — giống Vec::insert/Vec::remove của Rust hay truy cập slice ngoài phạm vi của Go.
  • Sau khi remove, ô cuối được dọn (JS gán undefined, Go gán zero value) để vùng nhớ không giữ tham chiếu tới phần tử đã bỏ, giúp GC thu hồi được. Rust không có GC: take() là bắt buộc để move giá trị ra khỏi ô, và ô tự thành None.
class DynamicArray {
  #data;
  #length = 0;
 
  constructor(capacity = 4) {
    this.#data = new Array(capacity);
  }
 
  get length() {
    return this.#length;
  }
 
  get capacity() {
    return this.#data.length;
  }
 
  get(index) {
    const inRange = Number.isInteger(index) && index >= 0 && index < this.#length;
    if (!inRange) {
      return undefined;
    }
    return this.#data[index];
  }
 
  set(index, value) {
    this.#checkIndex(index, this.#length);
    this.#data[index] = value;
  }
 
  push(value) {
    this.insert(this.#length, value);
  }
 
  pop() {
    const isEmpty = this.#length === 0;
    if (isEmpty) {
      return undefined;
    }
    return this.remove(this.#length - 1);
  }
 
  insert(index, value) {
    this.#checkIndex(index, this.#length + 1); // cho phép index === length: chèn vào cuối
    const isFull = this.#length === this.capacity;
    if (isFull) {
      this.#grow();
    }
    // dời các phần tử từ index sang phải một ô, đi từ cuối về để không ghi đè
    for (let i = this.#length; i > index; i--) {
      this.#data[i] = this.#data[i - 1];
    }
    this.#data[index] = value;
    this.#length += 1;
  }
 
  remove(index) {
    this.#checkIndex(index, this.#length);
    const value = this.#data[index];
    for (let i = index; i < this.#length - 1; i++) {
      this.#data[i] = this.#data[i + 1];
    }
    this.#length -= 1;
    this.#data[this.#length] = undefined; // bỏ tham chiếu để GC thu hồi được
    return value;
  }
 
  *[Symbol.iterator]() {
    for (let i = 0; i < this.#length; i++) {
      yield this.#data[i];
    }
  }
 
  #checkIndex(index, limit) {
    const inRange = Number.isInteger(index) && index >= 0 && index < limit;
    if (!inRange) {
      throw new RangeError(`index ${index} out of range (length ${this.#length})`);
    }
  }
 
  #grow() {
    const next = new Array(Math.max(1, this.capacity * 2));
    for (let i = 0; i < this.#length; i++) {
      next[i] = this.#data[i];
    }
    this.#data = next;
  }
}
 
const arr = new DynamicArray();
for (const value of ["a", "b", "c"]) {
  arr.push(value);
}
arr.insert(1, "x");
console.log([...arr]); // → [ 'a', 'x', 'b', 'c' ]
 
arr.set(0, "A");
console.log(arr.get(0)); // → A
console.log(arr.get(10)); // → undefined
 
console.log(arr.remove(2)); // → b
console.log(arr.pop()); // → c
console.log([...arr], arr.length, arr.capacity); // → [ 'A', 'x' ] 2 4

insert và remove phải dời mọi phần tử phía sau sang một ô nên tốn O(n); chèn hay xoá ở cuối (push/pop) thì không phải dời gì. Duyệt phần tử dùng cơ chế iterator riêng của mỗi ngôn ngữ: generator [Symbol.iterator] để [...arr] và for...of dùng được, iter.Seq[T] để for v := range arr.All() và slices.Collect dùng được, còn Rust trả impl Iterator<Item = &T> từ data[..len].

Độ phức tạp

Thao tác Thời gian Ghi chú
get / set O(1) truy cập trực tiếp theo chỉ số
push O(1) amortized O(n) ở lần phải grow
pop O(1) không thu nhỏ vùng nhớ
insert / remove ở giữa O(n) dời các phần tử phía sau
Duyệt toàn bộ O(n)

So với kiểu có sẵn

Kiểu có sẵn của cả ba ngôn ngữ đều theo cùng ý tưởng, chỉ khác hệ số tăng capacity. Số liệu dưới đây đo trên Node.js 24.12, Go 1.27 và Rust 1.98 (capacity của V8 xem bằng %DebugPrint khi chạy node --allow-natives-syntax); đây là chi tiết cài đặt, không phải cam kết của ngôn ngữ, nên có thể đổi ở bản sau.

Node.js (V8) Go Rust
Kiểu Array slice + append Vec<T>
Xem capacity không có API cap(s) v.capacity()
Cấp trước capacity new Array(n) cấp sẵn nhưng đặt luôn length = n (mảng holey), không tương đương make([]T, 0, n) Vec::with_capacity(n)
Cách tăng n + n/2 + 16 (khoảng 1.5×) 2× tới 256 phần tử, sau đó giảm dần về khoảng 1.25× 2×, lần cấp đầu tối thiểu 4 ô (8 nếu phần tử 1 byte, 1 nếu phần tử lớn hơn 1 KiB)
Capacity khi push liên tục 17 → 43 → … 256 → 512 → 848 → 1280 (int64) 256 → 512 → 1024 → 2048 (i64)
Thu nhỏ tự động có, khi pop/giảm length làm hơn nửa vùng nhớ bị bỏ trống không không, gọi shrink_to_fit()