Tại sao bắt đầu một ArrayList với công suất ban đầu?


149

Các constructor thông thường của ArrayListlà:

ArrayList<?> list = new ArrayList<>();

Nhưng cũng có một hàm tạo quá tải với một tham số cho dung lượng ban đầu của nó:

ArrayList<?> list = new ArrayList<>(20);

Tại sao nó hữu ích để tạo ra một ArrayListcông suất ban đầu khi chúng ta có thể nối nó với nó như chúng ta muốn?


17
Bạn đã thử xem mã nguồn ArrayList chưa?
AmitG

@Joachim Sauer: Đôi khi chúng tôi nhận được một nhận thức khi chúng tôi đọc nguồn cẩn thận. Tôi đã thử nếu anh ấy đã đọc nguồn. Tôi hiểu khía cạnh của bạn. Cảm ơn.
AmitG

ArrayList là thời gian hoạt động kém, tại sao bạn muốn sử dụng cấu trúc như vậy
positiveGuy

Câu trả lời:


196

Nếu bạn biết trước kích thước của ArrayListnó sẽ là gì, sẽ hiệu quả hơn nếu chỉ định công suất ban đầu. Nếu bạn không làm điều này, mảng bên trong sẽ phải được phân bổ lại nhiều lần khi danh sách phát triển.

Danh sách cuối cùng càng lớn, bạn càng tiết kiệm được nhiều thời gian bằng cách tránh việc tái phân bổ.

Điều đó nói rằng, ngay cả khi không phân bổ trước, việc chèn ncác phần tử ở phía sau của một ArrayListđược đảm bảo sẽ mất tổng O(n)thời gian. Nói cách khác, nối thêm một phần tử là một hoạt động liên tục được khấu hao. Điều này đạt được bằng cách mỗi phân bổ lại tăng kích thước của mảng theo cấp số nhân, thường là theo hệ số 1.5. Với phương pháp này, tổng số thao tác có thể được hiển thịO(n) .


5
Mặc dù phân bổ trước các kích thước đã biết là một ý tưởng tốt, nhưng không thực hiện nó thường không khủng khiếp: bạn sẽ cần phân bổ lại log (n) cho một danh sách với kích thước cuối cùng là n , không nhiều.
Joachim Sauer

2
@PeterOlson O(n log n)sẽ làm log nviệc nthời gian. Đó là một sự đánh giá quá cao (mặc dù đúng về mặt kỹ thuật với chữ O lớn do nó là giới hạn trên). Nó sao chép các phần tử s + s * 1.5 + s * 1.5 ^ 2 + ... + s * 1.5 ^ m (sao cho s * 1.5 ^ m <n <s * 1.5 ^ (m + 1)). Tôi không giỏi về khoản tiền nên tôi không thể đưa ra toán học chính xác trên đỉnh đầu (để thay đổi kích thước 2, là 2n, vì vậy có thể là 1,5n cho hoặc lấy một hằng số nhỏ), nhưng nó không ' T nheo mắt quá nhiều để thấy rằng tổng này nhiều nhất là một hệ số không đổi lớn hơn n. Vì vậy, phải mất các bản sao O (k * n), tất nhiên là O (n).

1
@delnan: Không thể tranh luận với điều đó! ;) BTW, tôi thực sự thích lập luận nheo mắt của bạn; sẽ thêm nó vào tiết mục thủ thuật của tôi.
NPE

6
Việc lập luận với việc nhân đôi sẽ dễ dàng hơn. Giả sử bạn nhân đôi khi đầy, bắt đầu bằng một yếu tố. Giả sử bạn muốn chèn 8 phần tử. Chèn một (chi phí: 1). Chèn hai - đôi, sao chép một phần tử và chèn hai (chi phí: 2). Chèn ba - đôi, sao chép hai phần tử, chèn ba (chi phí: 3). Chèn bốn (chi phí: 1). Chèn năm - gấp đôi, sao chép bốn yếu tố, chèn năm (chi phí: 5). Chèn sáu, bảy và tám (chi phí: 3). Tổng chi phí: 1 + 2 + 3 + 1 + 5 + 3 = 16, gấp đôi số phần tử được chèn. Từ bản phác thảo này, bạn có thể chứng minh rằng chi phí trung bìnhhai cho mỗi lần chèn nói chung.
Eric Lippert

9
Đó là chi phí trong thời gian . Bạn cũng có thể thấy mặc dù lượng không gian bị lãng phí thay đổi theo thời gian, là 0% một số thời gian và gần 100% một số thời gian. Thay đổi hệ số từ 2 thành 1,5 hoặc 4 hoặc 100 hoặc bất cứ điều gì thay đổi lượng không gian lãng phí trung bình và thời gian trung bình dành cho việc sao chép, nhưng độ phức tạp thời gian vẫn trung bình tuyến tính bất kể yếu tố là gì.
Eric Lippert

41

Bởi vì ArrayListlà một cấu trúc dữ liệu mảng thay đổi kích thước động , có nghĩa là nó được triển khai như một mảng với kích thước cố định (mặc định) ban đầu. Khi điều này được lấp đầy, mảng sẽ được mở rộng thành một kích thước gấp đôi. Hoạt động này là tốn kém, vì vậy bạn muốn càng ít càng tốt.

Vì vậy, nếu bạn biết giới hạn trên của mình là 20 mục, thì việc tạo mảng có độ dài ban đầu là 20 sẽ tốt hơn so với sử dụng mặc định là 15, sau đó thay đổi kích thước của nó thành 15*2 = 30 và chỉ sử dụng 20 trong khi lãng phí các chu kỳ cho việc mở rộng.

PS - Như AmitG nói, yếu tố mở rộng là triển khai cụ thể (trong trường hợp này (oldCapacity * 3)/2 + 1)


9
nó thực sự làint newCapacity = (oldCapacity * 3)/2 + 1;
AmitG 15/03/13

25

Kích thước mặc định của Arraylist là 10 .

    /**
     * Constructs an empty list with an initial capacity of ten.
     */
    public ArrayList() {
    this(10);
    } 

Vì vậy, nếu bạn định thêm 100 bản ghi trở lên, bạn có thể thấy chi phí phân bổ bộ nhớ.

ArrayList<?> list = new ArrayList<>();    
// same as  new ArrayList<>(10);      

Vì vậy, nếu bạn có bất kỳ ý tưởng nào về số lượng phần tử sẽ được lưu trữ trong Arraylist thì tốt hơn là tạo Arraylist với kích thước đó thay vì bắt đầu bằng 10 và sau đó tiếp tục tăng nó.


Không có gì đảm bảo rằng dung lượng mặc định sẽ luôn là 10 cho các phiên bản JDK trong tương lai -private static final int DEFAULT_CAPACITY = 10
vikingsteve

17

Tôi thực sự đã viết một bài blog về chủ đề này 2 tháng trước. Bài viết dành cho C # List<T>nhưng Java ArrayListcó cách triển khai rất giống nhau. TừArrayList được thực hiện bằng cách sử dụng một mảng động, nó tăng kích thước theo yêu cầu. Vì vậy, lý do cho các nhà xây dựng năng lực là cho mục đích tối ưu hóa.

Khi một trong các hoạt động nối lại này xảy ra, ArrayList sẽ sao chép nội dung của mảng thành một mảng mới có dung lượng gấp đôi dung lượng cũ. Hoạt động này chạy trong O (n) thời gian .

Thí dụ

Dưới đây là một ví dụ về cách ArrayListtăng kích thước:

10
16
25
38
58
... 17 resizes ...
198578
297868
446803
670205
1005308

Vì vậy, trong danh sách bắt đầu với một công suất 10, khi mục 11 được thêm vào đó là tăng 50% + 1tới 16. Trên mục thứ 17, ArrayListnó được tăng trở lại 25và cứ thế. Bây giờ hãy xem xét ví dụ nơi chúng tôi đang tạo một danh sách nơi khả năng mong muốn đã được biết đến là 1000000. Tạo ArrayListmà không có hàm tạo kích thước sẽ gọi ArrayList.add 1000000thời gian sẽ mất O (1) bình thường hoặc O (n) khi thay đổi kích thước.

1000000 + 16 + 25 + ... + 670205 + 1005308 = 4015851 hoạt động

So sánh điều này bằng cách sử dụng hàm tạo và sau đó gọi hàm ArrayList.addđược đảm bảo để chạy trong O (1) .

1000000 + 1000000 = 2000000 hoạt động

Java vs C #

Java là như trên, bắt đầu từ 10và tăng từng thay đổi kích thước tại 50% + 1. C # bắt đầu 4và tăng mạnh hơn nhiều, tăng gấp đôi ở mỗi lần thay đổi kích thước. Các 1000000bổ sung thêm ví dụ từ trên cho C # sử dụng 3097084hoạt động.

Người giới thiệu


9

Đặt kích thước ban đầu của một ArrayList, ví dụ, để ArrayList<>(100)giảm số lần phân bổ lại bộ nhớ trong phải xảy ra.

Thí dụ:

ArrayList example = new ArrayList<Integer>(3);
example.add(1); // size() == 1
example.add(2); // size() == 2, 
example.add(2); // size() == 3, example has been 'filled'
example.add(3); // size() == 4, example has been 'expanded' so that the fourth element can be added. 

Như bạn thấy trong ví dụ trên - ArrayListcó thể mở rộng nếu cần. Điều này không cho bạn thấy là kích thước của Arraylist thường tăng gấp đôi (mặc dù lưu ý rằng kích thước mới phụ thuộc vào việc triển khai của bạn). Sau đây được trích dẫn từ Oracle :

"Mỗi phiên bản ArrayList có một dung lượng. Dung lượng là kích thước của mảng được sử dụng để lưu trữ các phần tử trong danh sách. Nó luôn lớn nhất bằng kích thước danh sách. Khi các phần tử được thêm vào ArrayList, dung lượng của nó sẽ tự động tăng lên. Các chi tiết của chính sách tăng trưởng không được chỉ định ngoài thực tế là việc thêm một yếu tố có chi phí thời gian khấu hao không đổi. "

Rõ ràng, nếu bạn không biết mình sẽ giữ loại phạm vi nào, đặt kích thước có thể sẽ không phải là ý tưởng hay - tuy nhiên, nếu bạn có một phạm vi cụ thể, việc đặt dung lượng ban đầu sẽ tăng hiệu quả bộ nhớ .


3

ArrayList có thể chứa nhiều giá trị và khi thực hiện các lần chèn lớn ban đầu, bạn có thể yêu cầu ArrayList phân bổ dung lượng lưu trữ lớn hơn để không lãng phí chu kỳ CPU khi nó cố gắng phân bổ nhiều không gian hơn cho mục tiếp theo. Do đó, để phân bổ một số không gian lúc đầu là hiệu quả hơn.


3

Điều này là để tránh những nỗ lực có thể cho việc tái phân bổ cho mọi đối tượng.

int newCapacity = (oldCapacity * 3)/2 + 1;

nội bộ new Object[]được tạo ra.
JVM cần nỗ lực để tạo new Object[]khi bạn thêm phần tử trong danh sách mảng. Nếu bạn không có mã ở trên (bất kỳ thuật toán nào bạn nghĩ) để tái phân bổ thì mỗi khi bạn gọi arraylist.add()thì new Object[]phải tạo ra điều đó là vô nghĩa và chúng tôi đang mất thời gian để tăng kích thước lên 1 cho mỗi và mọi đối tượng được thêm vào. Vì vậy, tốt hơn là tăng kích thước Object[]với công thức sau đây.
(JSL đã sử dụng công thức dự báo được đưa ra dưới đây cho danh sách mảng tăng trưởng thay vì tăng 1 lần mỗi lần. Bởi vì để phát triển, JVM phải nỗ lực)

int newCapacity = (oldCapacity * 3)/2 + 1;

ArrayList sẽ không thực hiện phân bổ lại cho từng đơn add- nó đã sử dụng một số công thức tăng trưởng trong nội bộ. Do đó câu hỏi không được trả lời.
AH

@AH Câu trả lời của tôi là để thử nghiệm âm tính . Vui lòng đọc giữa các dòng. Tôi đã nói "Nếu bạn không có mã ở trên (bất kỳ thuật toán nào bạn nghĩ) để phân bổ lại thì mỗi khi bạn gọi Arraylist.add () thì đối tượng mới [] phải được tạo ra là vô nghĩa và chúng tôi đang mất thời gian." đangint newCapacity = (oldCapacity * 3)/2 + 1;đó là hiện diện trong lớp ArrayList. Bạn vẫn nghĩ rằng nó chưa được trả lời?
AmitG

1
Tôi vẫn nghĩ rằng nó không được trả lời: Trong ArrayListphân bổ lại được khấu hao diễn ra trong mọi trường hợp với bất kỳ giá trị nào cho công suất ban đầu. Và câu hỏi là về: Tại sao lại sử dụng một giá trị không chuẩn cho công suất ban đầu? Bên cạnh đó: "đọc giữa các dòng" không phải là điều mong muốn trong câu trả lời kỹ thuật. ;-)
AH

@AH Tôi đang trả lời như thế, chuyện gì đã xảy ra nếu chúng ta không có quy trình phân bổ lại trong ArrayList. Vậy là câu trả lời. Cố gắng đọc tinh thần của câu trả lời :-). Tôi biết rõ hơn trong ArrayList, việc phân bổ lại được khấu hao diễn ra trong mọi trường hợp với bất kỳ giá trị nào cho công suất ban đầu.
AmitG

2

Tôi nghĩ rằng mỗi ArrayList được tạo với giá trị dung lượng init là "10". Vì vậy, dù sao đi nữa, nếu bạn tạo một ArrayList mà không thiết lập dung lượng trong hàm tạo, nó sẽ được tạo với giá trị mặc định.


2

Tôi muốn nói đó là một sự tối ưu hóa. ArrayList không có dung lượng ban đầu sẽ có ~ 10 hàng trống và sẽ mở rộng khi bạn thực hiện thêm.

Để có một danh sách với chính xác số lượng mục bạn cần gọi trimToSize ()


0

Theo kinh nghiệm của tôi ArrayList, đưa ra một công suất ban đầu là một cách hay để tránh chi phí phân bổ lại. Nhưng nó chịu một cảnh báo. Tất cả các đề xuất được đề cập ở trên nói rằng người ta chỉ nên cung cấp công suất ban đầu khi biết ước tính sơ bộ về số lượng phần tử. Nhưng khi chúng tôi cố gắng cung cấp dung lượng ban đầu mà không có ý tưởng nào, lượng bộ nhớ được bảo lưu và không sử dụng sẽ là một sự lãng phí vì nó có thể không bao giờ được yêu cầu một khi danh sách được điền vào số lượng phần tử cần thiết. Điều tôi đang nói là, chúng ta có thể thực dụng ngay từ đầu trong khi phân bổ công suất, và sau đó tìm ra một cách thông minh để biết công suất tối thiểu cần thiết trong thời gian chạy. ArrayList cung cấp một phương thức gọi là ensureCapacity(int minCapacity). Nhưng sau đó, người ta đã tìm ra một cách thông minh ...


0

Tôi đã kiểm tra ArrayList có và không có initCapacity và tôi nhận được kết quả đáng kinh ngạc
Khi tôi đặt LOOP_NUMBER thành 100.000 hoặc ít hơn kết quả là việc thiết lập initCapacity có hiệu quả.

list1Sttop-list1Start = 14
list2Sttop-list2Start = 10


Nhưng khi tôi đặt LOOP_NUMBER thành 1.000.000 thì kết quả sẽ thay đổi thành:

list1Stop-list1Start = 40
list2Stop-list2Start = 66


Cuối cùng, tôi không thể hiểu nó hoạt động như thế nào?!
Mã mẫu:

 public static final int LOOP_NUMBER = 100000;

public static void main(String[] args) {

    long list1Start = System.currentTimeMillis();
    List<Integer> list1 = new ArrayList();
    for (int i = 0; i < LOOP_NUMBER; i++) {
        list1.add(i);
    }
    long list1Stop = System.currentTimeMillis();
    System.out.println("list1Stop-list1Start = " + String.valueOf(list1Stop - list1Start));

    long list2Start = System.currentTimeMillis();
    List<Integer> list2 = new ArrayList(LOOP_NUMBER);
    for (int i = 0; i < LOOP_NUMBER; i++) {
        list2.add(i);
    }
    long list2Stop = System.currentTimeMillis();
    System.out.println("list2Stop-list2Start = " + String.valueOf(list2Stop - list2Start));
}

Tôi đã thử nghiệm trên windows8.1 và jdk1.7.0_80


1
xin chào, thật không may, dung sai của currentTimeMillis lên tới hàng trăm mili giây (tùy theo), có nghĩa là kết quả này khó tin cậy. Tôi đề nghị sử dụng một số thư viện tùy chỉnh để làm điều đó đúng.
Bogdan
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.