2023 年 1 月 24 日

陣列

物件允許你儲存以鍵值對應的集合。這很好。

但我們常常發現我們需要一個有序集合,其中有第 1 個、第 2 個、第 3 個元素,以此類推。例如,我們需要儲存某個清單:使用者、商品、HTML 元素等。

使用物件在此並不方便,因為它沒有提供管理元素順序的方法。我們無法在現有屬性「之間」插入新的屬性。物件根本不是用於這種用途的。

存在一個特殊資料結構名為 陣列,用於儲存已排序的集合。

宣告

建立一個空陣列有兩種語法

let arr = new Array();
let arr = [];

幾乎所有時間,都會使用第二種語法。我們可以在括號中提供初始元素

let fruits = ["Apple", "Orange", "Plum"];

陣列元素從零開始編號。

我們可以使用方括號中的數字取得元素

let fruits = ["Apple", "Orange", "Plum"];

alert( fruits[0] ); // Apple
alert( fruits[1] ); // Orange
alert( fruits[2] ); // Plum

我們可以替換元素

fruits[2] = 'Pear'; // now ["Apple", "Orange", "Pear"]

…或新增一個到陣列中

fruits[3] = 'Lemon'; // now ["Apple", "Orange", "Pear", "Lemon"]

陣列中元素的總數是它的 長度

let fruits = ["Apple", "Orange", "Plum"];

alert( fruits.length ); // 3

我們也可以使用 alert 來顯示整個陣列。

let fruits = ["Apple", "Orange", "Plum"];

alert( fruits ); // Apple,Orange,Plum

陣列可以儲存任何類型的元素。

例如

// mix of values
let arr = [ 'Apple', { name: 'John' }, true, function() { alert('hello'); } ];

// get the object at index 1 and then show its name
alert( arr[1].name ); // John

// get the function at index 3 and run it
arr[3](); // hello
尾隨逗號

陣列就像物件一樣,可能會以逗號結尾

let fruits = [
  "Apple",
  "Orange",
  "Plum",
];

「尾隨逗號」樣式讓插入/移除項目變得更容易,因為所有行都變得相同。

使用「at」取得最後一個元素

最近新增
這是最近新增到語言中的功能。舊瀏覽器可能需要 polyfills

假設我們想要陣列中的最後一個元素。

有些程式設計語言允許使用負索引來達到相同目的,例如 fruits[-1]

不過,在 JavaScript 中這不起作用。結果將會是 undefined,因為方括號中的索引是逐字處理的。

我們可以明確計算最後一個元素索引,然後存取它:fruits[fruits.length - 1]

let fruits = ["Apple", "Orange", "Plum"];

alert( fruits[fruits.length-1] ); // Plum

有點麻煩,不是嗎?我們需要寫兩次變數名稱。

幸運的是,有一個較短的語法:fruits.at(-1)

let fruits = ["Apple", "Orange", "Plum"];

// same as fruits[fruits.length-1]
alert( fruits.at(-1) ); // Plum

換句話說,arr.at(i)

  • arr[i] 完全相同,如果 i >= 0
  • 對於 i 的負值,它會從陣列的末尾往回計算。

pop/push、shift/unshift 方法

一個 佇列 是陣列最常見的用途之一。在電腦科學中,這表示一個支援兩個操作的已排序元素集合

  • push 將元素附加到末尾。
  • shift 從開頭取得元素,推進佇列,使第二個元素變成第一個。

陣列支援這兩個操作。

在實務上,我們非常常需要它。例如,一個需要在螢幕上顯示的訊息佇列。

陣列還有另一個用例,一個名為 堆疊 的資料結構。

它支援兩個操作

  • push 將元素新增到末尾。
  • pop 從末尾取得元素。

因此,新元素總是從「末尾」新增或取得。

堆疊通常被描繪成一疊卡片:新卡片會新增到頂部或從頂部取得

對於堆疊,最新推入的項目會最先收到,這也稱為 LIFO(後進先出)原則。對於佇列,我們有 FIFO(先進先出)。

JavaScript 中的陣列可以同時作為佇列和堆疊。它們允許您新增/移除元素,從開頭或末尾新增/移除。

在電腦科學中,允許這樣做的資料結構稱為 雙端佇列

使用陣列末端的方法

pop

擷取陣列的最後一個元素並傳回

let fruits = ["Apple", "Orange", "Pear"];

alert( fruits.pop() ); // remove "Pear" and alert it

alert( fruits ); // Apple, Orange

fruits.pop()fruits.at(-1) 都會傳回陣列的最後一個元素,但 fruits.pop() 也會修改陣列並將其移除。

push

將元素附加到陣列的末端

let fruits = ["Apple", "Orange"];

fruits.push("Pear");

alert( fruits ); // Apple, Orange, Pear

呼叫 fruits.push(...) 等於 fruits[fruits.length] = ...

使用陣列開頭的方法

shift

擷取陣列的第一個元素並傳回

let fruits = ["Apple", "Orange", "Pear"];

alert( fruits.shift() ); // remove Apple and alert it

alert( fruits ); // Orange, Pear
unshift

將元素新增到陣列的開頭

let fruits = ["Orange", "Pear"];

fruits.unshift('Apple');

alert( fruits ); // Apple, Orange, Pear

方法 pushunshift 可以一次新增多個元素

let fruits = ["Apple"];

fruits.push("Orange", "Peach");
fruits.unshift("Pineapple", "Lemon");

// ["Pineapple", "Lemon", "Apple", "Orange", "Peach"]
alert( fruits );

內部結構

陣列是一種特殊的物件。用於存取屬性的方括號 arr[0] 實際上來自物件語法。這基本上與 obj[key] 相同,其中 arr 是物件,而數字用作鍵。

它們擴充物件,提供特殊的方法來處理資料的有序集合,以及 length 屬性。但其核心仍然是一個物件。

請記住,JavaScript 中只有八種基本資料類型(有關更多資訊,請參閱 資料類型 章節)。陣列是一個物件,因此會像物件一樣運作。

例如,它是透過參考複製的

let fruits = ["Banana"]

let arr = fruits; // copy by reference (two variables reference the same array)

alert( arr === fruits ); // true

arr.push("Pear"); // modify the array by reference

alert( fruits ); // Banana, Pear - 2 items now

…但讓陣列真正特別的是它們的內部表示。引擎會嘗試將其元素儲存在連續的記憶體區域中,一個接一個,就像本章節插圖中所描繪的那樣,還有其他最佳化,讓陣列可以非常快速地運作。

但如果我們不再將陣列視為「有序集合」來處理,而是開始將它視為常規物件來處理,它們就會全部中斷。

例如,技術上我們可以這樣做

let fruits = []; // make an array

fruits[99999] = 5; // assign a property with the index far greater than its length

fruits.age = 25; // create a property with an arbitrary name

這是可能的,因為陣列在它們的基礎上是物件。我們可以向它們新增任何屬性。

但引擎會看到我們將陣列視為常規物件來處理。陣列特定的最佳化不適合此類情況,並且會被關閉,它們的優點也會消失。

誤用陣列的方式

  • 新增非數字屬性,例如 arr.test = 5
  • 建立間隙,例如:新增 arr[0],然後新增 arr[1000](它們之間沒有任何東西)。
  • 以相反的順序填入陣列,例如 arr[1000]arr[999] 等。

請將陣列視為處理有序資料的特殊結構。它們提供特殊的方法來處理這些資料。JavaScript 引擎中仔細調整了陣列,以處理連續的有序資料,請使用這種方式。如果您需要任意鍵,很有可能您實際上需要一個常規物件 {}

效能

方法 push/pop 執行速度快,而 shift/unshift 則很慢。

為什麼從陣列的尾端處理比從頭部處理來得快?讓我們看看執行期間發生了什麼事

fruits.shift(); // take 1 element from the start

光取用和移除索引為 0 的元素還不夠。其他元素也需要重新編號。

shift 執行序必須執行 3 件事

  1. 移除索引為 0 的元素。
  2. 將所有元素向左移動,將其索引從 1 重新編號為 0,從 2 重新編號為 1,以此類推。
  3. 更新 length 屬性。

陣列中的元素越多,移動它們所需的時間就越多,記憶體內運算也越多。

unshift 發生類似的情況:若要將元素新增到陣列的開頭,我們必須先將現有的元素向右移動,增加其索引。

push/pop 呢?它們不需要移動任何東西。若要從尾端擷取元素,pop 方法會清除索引並縮短 length

pop 執行的動作

fruits.pop(); // take 1 element from the end

pop 方法不需要移動任何東西,因為其他元素會保留其索引。這就是為什麼它執行速度非常快。

push 方法也有類似的狀況。

迴圈

迴圈陣列項目最古老的方法之一是在索引上使用 for 迴圈

let arr = ["Apple", "Orange", "Pear"];

for (let i = 0; i < arr.length; i++) {
  alert( arr[i] );
}

但是對於陣列,還有另一種迴圈形式,for..of

let fruits = ["Apple", "Orange", "Plum"];

// iterates over array elements
for (let fruit of fruits) {
  alert( fruit );
}

for..of 無法存取目前元素的數字,只能存取其值,但在大多數情況下,這就已足夠。而且它比較簡短。

技術上來說,由於陣列是物件,因此也可以使用 for..in

let arr = ["Apple", "Orange", "Pear"];

for (let key in arr) {
  alert( arr[key] ); // Apple, Orange, Pear
}

但這其實是個壞主意。它可能會產生問題

  1. 迴圈 for..in 會反覆處理所有屬性,不只數字屬性。

    瀏覽器和其他環境中存在所謂的「類陣列」物件,它們看起來像陣列。也就是說,它們有 length 和索引屬性,但它們也可能具有其他非數字屬性和方法,而這些通常不是我們需要的。不過,for..in 迴圈會列出它們。因此,如果我們需要處理類陣列物件,那麼這些「額外」屬性可能會成為一個問題。

  2. for..in 迴圈是針對一般物件最佳化,而非陣列,因此速度會慢 10 到 100 倍。當然,它仍然非常快。速度提升可能只會在瓶頸時顯著。但我們仍應了解兩者之間的差異。

一般來說,我們不應對陣列使用 for..in

關於「長度」的一句話

當我們修改陣列時,length 屬性會自動更新。精確來說,它實際上並非陣列中值的計數,而是最大的數字索引值加一。

例如,具有大索引值的單一元素會產生較大的長度

let fruits = [];
fruits[123] = "Apple";

alert( fruits.length ); // 124

請注意,我們通常不會這樣使用陣列。

關於 length 屬性的另一個有趣之處是它可寫入。

如果我們手動增加它,不會發生任何有趣的事情。但如果我們減少它,陣列會被截斷。此程序不可逆,以下為範例

let arr = [1, 2, 3, 4, 5];

arr.length = 2; // truncate to 2 elements
alert( arr ); // [1, 2]

arr.length = 5; // return length back
alert( arr[3] ); // undefined: the values do not return

因此,清除陣列最簡單的方法是:arr.length = 0;

new Array()

還有一個語法可建立陣列

let arr = new Array("Apple", "Pear", "etc");

它很少使用,因為方括號 [] 較短。此外,它還有一個棘手的功能。

如果 new Array 呼叫時只有一個數字引數,則它會建立一個陣列,不包含項目,但具有指定的長度

讓我們看看如何自找麻煩

let arr = new Array(2); // will it create an array of [2] ?

alert( arr[0] ); // undefined! no elements.

alert( arr.length ); // length 2

為避免此類意外,我們通常使用方括號,除非我們真的知道自己在做什麼。

多維陣列

陣列可以包含也是陣列的項目。我們可以將其用於多維陣列,例如儲存矩陣

let matrix = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9]
];

alert( matrix[1][1] ); // 5, the central element

toString

陣列有自己的 toString 方法實作,會傳回以逗號分隔的元素清單。

例如

let arr = [1, 2, 3];

alert( arr ); // 1,2,3
alert( String(arr) === '1,2,3' ); // true

另外,讓我們試試這個

alert( [] + 1 ); // "1"
alert( [1] + 1 ); // "11"
alert( [1,2] + 1 ); // "1,21"

陣列沒有 Symbol.toPrimitive,也沒有可行的 valueOf,它們只實作 toString 轉換,因此這裡的 [] 會變成空字串,[1] 會變成 "1",而 [1,2] 會變成 "1,2"

當二元加號 "+" 算子將某個項目加入字串時,它也會將其轉換為字串,因此下一個步驟如下所示

alert( "" + 1 ); // "1"
alert( "1" + 1 ); // "11"
alert( "1,2" + 1 ); // "1,21"

不要使用 == 比較陣列

與其他一些程式語言不同,JavaScript 中的陣列不應使用 == 算子進行比較。

此算子沒有針對陣列的特殊處理,它會將陣列視為一般物件處理。

讓我們回顧一下規則

  • 只有當兩個物件是同一個物件的參考時,它們才相等 ==
  • 如果 == 的其中一個引數是物件,而另一個是原始值,則會將物件轉換為原始值,如 物件轉換為原始值 章節中所述。
  • …除了 nullundefined,它們彼此相等 ==,且不與其他任何項目相等。

嚴格比較 === 甚至更簡單,因為它不會轉換類型。

所以,如果我們使用 == 比較陣列,它們永遠不會相同,除非我們比較兩個參照完全相同陣列的變數。

例如

alert( [] == [] ); // false
alert( [0] == [0] ); // false

這些陣列在技術上是不同的物件。所以它們不相等。== 算子不會進行逐項比較。

與基本類型比較也可能產生看似奇怪的結果

alert( 0 == [] ); // true

alert('0' == [] ); // false

這裡,在兩種情況下,我們將一個基本類型與一個陣列物件進行比較。因此,陣列 [] 會在比較時轉換為基本類型,並成為一個空字串 ''

然後比較過程繼續使用基本類型,如章節 類型轉換 中所述

// after [] was converted to ''
alert( 0 == '' ); // true, as '' becomes converted to number 0

alert('0' == '' ); // false, no type conversion, different strings

那麼,如何比較陣列?

這很簡單:不要使用 == 算子。相反,在迴圈中逐項比較它們或使用下一章節中說明的迭代方法。

摘要

陣列是一種特殊類型的物件,適用於儲存和管理已排序的資料項目。

宣告

// square brackets (usual)
let arr = [item1, item2...];

// new Array (exceptionally rare)
let arr = new Array(item1, item2...);

呼叫 new Array(number) 會建立一個具有給定長度的陣列,但沒有元素。

  • length 屬性是陣列長度,或者更精確地說,是其最後一個數字索引加一。它會由陣列方法自動調整。
  • 如果我們手動縮短 length,陣列會被截斷。

取得元素

  • 我們可以使用其索引取得元素,例如 arr[0]
  • 我們也可以使用 at(i) 方法,它允許負索引。對於 i 的負值,它會從陣列的尾端往回計算。如果 i >= 0,它的作用與 arr[i] 相同。

我們可以使用以下操作將陣列作為雙端佇列

  • push(...items)items 新增到尾端。
  • pop() 從尾端移除元素並傳回它。
  • shift() 從開頭移除元素並傳回它。
  • unshift(...items)items 新增到開頭。

要迴圈陣列的元素

  • for (let i=0; i<arr.length; i++) – 運作最快,與舊瀏覽器相容。
  • for (let item of arr) – 僅適用於項目的現代語法,
  • for (let i in arr) – 永遠不要使用。

要比較陣列,請不要使用 == 算子(以及 >< 等),因為它們沒有針對陣列進行特殊處理。它們將陣列視為任何物件,而這通常不是我們想要的。

相反,你可以使用 for..of 迴圈逐項比較陣列。

我們將繼續探討陣列,並在下一章節 陣列方法 中研究更多新增、移除、擷取元素和排序陣列的方法。

任務

重要性:3

這段程式碼將會顯示什麼?

let fruits = ["Apples", "Pear", "Orange"];

// push a new value into the "copy"
let shoppingCart = fruits;
shoppingCart.push("Banana");

// what's in fruits?
alert( fruits.length ); // ?

結果為 4

let fruits = ["Apples", "Pear", "Orange"];

let shoppingCart = fruits;

shoppingCart.push("Banana");

alert( fruits.length ); // 4

這是因為陣列是物件。所以 shoppingCartfruits 都是指向同一個陣列的參照。

重要性:5

我們來嘗試 5 個陣列操作。

  1. 建立一個陣列 styles,內容為「爵士」和「藍調」。
  2. 在尾端新增「搖滾」。
  3. 將中間的值替換為「古典」。你用於尋找中間值的程式碼應該適用於任何長度為奇數的陣列。
  4. 移除陣列的第一個值並顯示出來。
  5. 在陣列前面新增「饒舌」和「雷鬼」。

處理中的陣列

Jazz, Blues
Jazz, Blues, Rock-n-Roll
Jazz, Classics, Rock-n-Roll
Classics, Rock-n-Roll
Rap, Reggae, Classics, Rock-n-Roll
let styles = ["Jazz", "Blues"];
styles.push("Rock-n-Roll");
styles[Math.floor((styles.length - 1) / 2)] = "Classics";
alert( styles.shift() );
styles.unshift("Rap", "Reggae");
重要性:5

結果是什麼?為什麼?

let arr = ["a", "b"];

arr.push(function() {
  alert( this );
});

arr[2](); // ?

呼叫 arr[2]() 在語法上等同於傳統的 obj[method](),其中 obj 的角色由 arr 擔任,而 method 的角色由 2 擔任。

因此,我們呼叫函式 arr[2] 作為一個物件方法。自然地,它會接收參照物件 arrthis,並輸出陣列

let arr = ["a", "b"];

arr.push(function() {
  alert( this );
})

arr[2](); // a,b,function(){...}

陣列有 3 個值:最初有兩個,加上函式。

重要性:4

撰寫函式 sumInput(),它會

  • 使用 prompt 向使用者索取值,並將值儲存在陣列中。
  • 當使用者輸入非數字值、空字串或按下「取消」時,停止索取。
  • 計算並傳回陣列項目總和。

附註:零 0 是有效的數字,請不要在零時停止輸入。

執行示範

請注意解答中一個細微但重要的細節。我們並未在 prompt 之後立即將 value 轉換為數字,因為在 value = +value 之後,我們將無法區分空字串(停止符號)和零(有效數字)。我們稍後再執行此動作。

function sumInput() {

  let numbers = [];

  while (true) {

    let value = prompt("A number please?", 0);

    // should we cancel?
    if (value === "" || value === null || !isFinite(value)) break;

    numbers.push(+value);
  }

  let sum = 0;
  for (let number of numbers) {
    sum += number;
  }
  return sum;
}

alert( sumInput() );
重要性:2

輸入是一個數字陣列,例如 arr = [1, -2, 3, 4, -9, 6]

任務是:找出 arr 中連續子陣列,其項目總和最大。

撰寫函式 getMaxSubSum(arr),它將傳回該總和。

例如

getMaxSubSum([-1, 2, 3, -9]) == 5 (the sum of highlighted items)
getMaxSubSum([2, -1, 2, 3, -9]) == 6
getMaxSubSum([-1, 2, 3, -9, 11]) == 11
getMaxSubSum([-2, -1, 1, 2]) == 3
getMaxSubSum([100, -9, 2, -3, 5]) == 100
getMaxSubSum([1, 2, 3]) == 6 (take all)

如果所有項目都是負數,表示我們不取任何項目(子陣列為空),因此總和為零

getMaxSubSum([-1, -2, -3]) = 0

請嘗試構思一個快速的解答:O(n2),甚至如果你能做到,O(n)。

開啟一個包含測試的沙盒。

緩慢的解答

我們可以計算出所有可能的子和。

最簡單的方法是取每個元素,並計算從該元素開始的所有子陣列的和。

例如,對於 [-1, 2, 3, -9, 11]

// Starting from -1:
-1
-1 + 2
-1 + 2 + 3
-1 + 2 + 3 + (-9)
-1 + 2 + 3 + (-9) + 11

// Starting from 2:
2
2 + 3
2 + 3 + (-9)
2 + 3 + (-9) + 11

// Starting from 3:
3
3 + (-9)
3 + (-9) + 11

// Starting from -9
-9
-9 + 11

// Starting from 11
11

該程式碼實際上是一個巢狀迴圈:外部迴圈遍歷陣列元素,而內部迴圈計算從當前元素開始的子和。

function getMaxSubSum(arr) {
  let maxSum = 0; // if we take no elements, zero will be returned

  for (let i = 0; i < arr.length; i++) {
    let sumFixedStart = 0;
    for (let j = i; j < arr.length; j++) {
      sumFixedStart += arr[j];
      maxSum = Math.max(maxSum, sumFixedStart);
    }
  }

  return maxSum;
}

alert( getMaxSubSum([-1, 2, 3, -9]) ); // 5
alert( getMaxSubSum([-1, 2, 3, -9, 11]) ); // 11
alert( getMaxSubSum([-2, -1, 1, 2]) ); // 3
alert( getMaxSubSum([1, 2, 3]) ); // 6
alert( getMaxSubSum([100, -9, 2, -3, 5]) ); // 100

該解法的時間複雜度為 O(n2)。換句話說,如果我們將陣列大小增加 2 倍,則演算法將執行 4 倍的時間。

對於大型陣列(1000、10000 或更多項),此類演算法可能會導致嚴重的遲緩。

快速解法

讓我們遍歷陣列,並在變數 s 中保留元素的當前部分和。如果 s 在某個時刻變為負值,則指定 s=0。所有此類 s 的最大值將是答案。

如果說明太過模糊,請參閱程式碼,它足夠簡短

function getMaxSubSum(arr) {
  let maxSum = 0;
  let partialSum = 0;

  for (let item of arr) { // for each item of arr
    partialSum += item; // add it to partialSum
    maxSum = Math.max(maxSum, partialSum); // remember the maximum
    if (partialSum < 0) partialSum = 0; // zero if negative
  }

  return maxSum;
}

alert( getMaxSubSum([-1, 2, 3, -9]) ); // 5
alert( getMaxSubSum([-1, 2, 3, -9, 11]) ); // 11
alert( getMaxSubSum([-2, -1, 1, 2]) ); // 3
alert( getMaxSubSum([100, -9, 2, -3, 5]) ); // 100
alert( getMaxSubSum([1, 2, 3]) ); // 6
alert( getMaxSubSum([-1, -2, -3]) ); // 0

該演算法只需要 1 次陣列傳遞,因此時間複雜度為 O(n)。

您可以在這裡找到有關該演算法的更詳細資訊:最大子陣列問題。如果仍然不明白為什麼這樣做有效,請根據上述範例追蹤演算法,看看它是如何運作的,這比任何文字都更好。

在沙盒中開啟具有測試的解法。

教學課程地圖

留言

在留言之前先閱讀這段文字…
  • 如果您有改善建議,請 提交 GitHub 問題 或發起拉取請求,而不是留言。
  • 如果您無法理解文章中的某些內容,請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將它們包裝在 <pre> 標籤中,若要插入超過 10 行程式碼,請使用沙盒 (plnkrjsbincodepen…)