如果要針對一個 物件陣列 取最大值該怎麼做?
一天,一位同事這麼問到。
首先需要先知道的是一般在JavaScript物件是無法比較大小的,所以這句話的意思是將物件特定屬性作為比較參考值,或是將物件數個屬性計算成一個可以比較的值後作為參考值,再進行比較尋找最大值。
起初,針對這個問題我第一想到的就是三種方式:
Array.prototype.reduce()
Array.prototype.sort()
Math.max()
最後一個你可能會很好奇:物件無法比較,並且Math.max()
又不像Python的max()
可以輸入key
函式,這樣可以比較嗎?
沒錯,這個方式是存在問題的,但原因並不是因為「物件無法比較」,而是結果型別的問題,這個問題之後會提到。並且這也引發了今天的議題--「JS自動型別轉換」。
這並不是今天主題的重點,也就不賣關子了,直接給出做法。
同樣拿Person類別為例子:
class Person {
name = "";
#birthday = new Date(); // 私有屬性
get birthday() { return this.#birthday }; // 調整處
addr = ""
get age() {
return new Date().getYear() - this.birthday.getYear();
}
constructor(name, birthday, addr = "") {
this.name = name;
this.#birthday = birthday; // 調整處
this.addr = addr;
}
hello() {
console.log(`Hello, ${this.name}.`);
}
}
現在有10個年幼不依的人:
var people = [
"Alice",
"Bob",
"Candy",
"Danel",
"Frank",
"Grant",
"Harry",
"Iris",
"Joe",
"Kevin"];
people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))
現在如果希望找到年紀最大的,這裡提供幾種方式:
Array.prototype.reduce()
不囉唆,直接給例子:
var MaxAgePerson = people.reduce((pre, curr) => (curr.age > pre.age) ? curr : pre);
console.log(MaxAgePerson);
Array.prototype.sort()
不囉唆,直接給例子:
var MaxAgePerson = people.sort((a, b) => b.age - a.age)[0];
console.log(MaxAgePerson);
注意: Array.prototype.sort()
是in-place的。這表示原本的物件陣列的順序也會被改變。如果不希望原物件被變更到,需要先淺複製再排序:
var MaxAgePerson = [...people].sort((a, b) => b.age - a.age)[0];
console.log(MaxAgePerson);
或是透過Array.prototype.filter()
建立副本:
var MaxAgePerson = people.filter(_ => true).sort((a, b) => b.age - a.age)[0];
console.log(MaxAgePerson);
for
迴圈不囉唆,直接給例子:
var MaxAgePerson = people[0];
for(let person of people) {
if(person.age > MaxAgePerson.age)
MaxAgePerson = person;
}
console.log(MaxAgePerson);
這大概是最簡單基礎的做法了,很大機會上你也可以使用.forEach()
處理。
使用.reduce()
或.sort()
可能同樣有與.forEach()
相似的問題。雖然可能寫起來不好看,但有時候有所幫助。
使用上述方式,除了.sort()
外,都只能夠取得最大的一個。如果要最大的數個呢?這就無法處理。從概念上來說,最好自己實現一個Heap結構,不過在簡單情況,可以透過先排序後,在取前幾個或後幾個得到。就像是上面示例的一樣。
不過...就必須傳入一個compareFn
。能不能像字串陣列排序一樣,不帶任何參數呼叫呢?
var arr = "porttitor lacus luctus accumsan tortor posuere ac ut consequat semper"
arr = arr.split(" ")
console.log(arr.sort());
可以的,不過在說明方式之前,必須在聊聊另外一個話題--自動型別轉換。
作為一個弱型別語言,JavaScript在特定時候會進行自動型別轉換,而且這個引發的bug複雜程度讓不少人頭痛。
強型別與弱型別的界線很糢糊。 而我個人更喜歡以資料有沒有型別來看,且從這個角度來看JavaScript也是強型別。
另外一個也常被提到的是靜態型別和動態型別,在我看來就是變數有沒有型別。毫無疑問的JavaScript是動態型別。
比如null
和0
的比較:
console.log(null == 0); // false, 兩者不相等
console.log(null >= 0); // true, 但是 null 大於等於 0
console.log(null <= 0); // true, 而且 null 小於等於 0 !?
console.log(null > 0); // false, 不過 null 既不大於 0
console.log(null < 0); // false, 而且 null 也不小於 0 ???
關於這個的說明,我放到下面講解。大家可以先自己想想看。
所以通常我會建議新手不要太依賴自動型別轉換。不過如果知道一些轉換規則,使用起來其實還蠻方便的。一元運算子+
或-
將轉換成數字:
var a = 1;
var b = "2";
console.log(a + b); // "12"
console.log(a + +b); // 3
注意!
對於第二個你不能寫成:console.log(a ++ b); // Error
但是下面這一下寫法也是可以的:
var a = 1; var b = "2"; console.log(a++ + +b); // 3 var a = 1; var b = "2"; console.log(a +++ +b); // 3 var a = 1; var b = "2"; console.log(b + ++a); // "22" var a = 1; var b = "2"; console.log(+b + ++a); // 4
Symbol.toPrimitive
所以...以下bob
印出來的結果就一定是[object Object]
...嗎?
var bob = new Person(/* name = */ "Bob",
/* birthday = */ new Date(2004/* year */,
1 /* mouth */,
1 /* day */),
/* addr = */ "臺灣")
console.log(`${bob}`); // 印出什麼? => [object Object]
那可不一定,現在來講Person
類別多添加一個方法-- [Symbol.toPrimitive]
:
class Person {
name = "";
#birthday = new Date(); // 私有屬性
get birthday() { return this.#birthday }; // 調整處
addr = ""
get age() {
return new Date().getYear() - this.birthday.getYear();
}
constructor(name, birthday, addr = "") {
this.name = name;
this.#birthday = birthday; // 調整處
this.addr = addr;
}
hello() {
console.log(`Hello, ${this.name}.`);
}
[Symbol.toPrimitive](hint) {
switch(hint) {
case "number":
return this.age;
case "string":
return `Person(name=${this.name}, age=${this.age}, addr=${this.addr})`;
case "default":
default:
return this;
}
}
}
這一次會得到什麼結果?
var bob = new Person(/* name = */ "Bob",
/* birthday = */ new Date(2004/* year */,
1 /* mouth */,
1 /* day */),
/* addr = */ "臺灣")
console.log(`${bob}`); // 印出什麼?
Person(name=Bob, age=18, addr=臺灣)
上面的[Symbol.toPrimitive]
改成toString
同樣可以做得到:
class Person {
name = "";
#birthday = new Date(); // 私有屬性
get birthday() { return this.#birthday }; // 調整處
addr = ""
get age() {
return new Date().getYear() - this.birthday.getYear();
}
constructor(name, birthday, addr = "") {
this.name = name;
this.#birthday = birthday; // 調整處
this.addr = addr;
}
hello() {
console.log(`Hello, ${this.name}.`);
}
toString() {
return `Person(name=${this.name}, age=${this.age}, addr=${this.addr})`;
}
}
不一樣的是,如果使用[Symbol.toPrimitive]
,現在可以自動轉型成數字了:
// 18歲的Bob
var bob = new Person(/* name = */ "Bob",
/* birthday = */ new Date(2004/* year */,
1 /* mouth */,
1 /* day */),
/* addr = */ "臺灣")
// 19歲的Alice
var alice = new Person(/* name = */ "alice",
/* birthday = */ new Date(2003/* year */,
1 /* mouth */,
1 /* day */),
/* addr = */ "臺灣")
console.log(+bob); // 18
console.log(+alice); // 19
這意味這我們可以針對兩個物件進行大小比較:
console.log(alice > bob); // true, Alice年齡比Bob大。
Math.max()
和 Array.prototype.sort()
Math.max()
現在雖然可以比較了,但是仍然不能使用Math.max()
找到最大年紀人,因為Math.max()
會嘗試將結果轉換成數字,但是可以得到最大的歲數:
var people = [
"Alice",
"Bob",
"Candy",
"Danel",
"Frank",
"Grant",
"Harry",
"Iris",
"Joe",
"Kevin"];
people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))
var max_age = Math.max(...people);
console.log(max_age);
Array.prototype.sort()
此外看看Array.prototype.sort()
對compareFn
缺省的說明:
Specifies a function that defines the sort order. If omitted, the array elements are converted to strings, then sorted according to each character's Unicode code point value.
如果沒有給compareFn
,會嘗試轉換成字串做排序,而Person
會被轉換成Person(name=${this.name}, age=${this.age}, addr=${this.addr})
,這意味著相當於使用名字進行排序。
var people = [
"Alice",
"Bob",
"Candy",
"Danel",
"Frank",
"Grant",
"Harry",
"Iris",
"Joe",
"Kevin"];
people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))
people.reverse(); // 故意先顛倒
people.sort(); // 依名字排序
console.log(people) // 變回原本順序
這部分參考MDN。
我們知道compareFn
接收兩個參數,並返回一個數字。兩者排序完後,再進行新一輪比較。所以過程可能像是:
這並不是一個完整的排序演算法,但重要的是b
的轉換經過了兩次。如果說物件比較的對象並不是直接屬性,像是Person.age
其實是計算出來的結果,要是計算在複雜一些,量在大一些,就可能需要等待一段時間才能得到結果。
一種調整方式是先將計算結果保存下來不再計算,以結果先進行排序後,在寫回原本的變數:
var people = [
"Alice",
"Bob",
"Candy",
"Danel",
"Frank",
"Grant",
"Harry",
"Iris",
"Joe",
"Kevin"];
people = people.map(name => new Person(name, new Date(Math.floor(Math.random() * 100) + 1990, 1), ""))
ages = people.map((v, i) => ({ i, age: v.age}));
ages.sort((a, b) => {
if(a.age > b.age)
return 1;
if(a.age < b.age)
return -1;
return 0;
})
people = ages.map(v => people[v.i]);
console.log(null == 0); // false, 兩者不相等
console.log(null >= 0); // true, 但是 null 大於等於 0
console.log(null <= 0); // true, 而且 null 小於等於 0 !?
console.log(null > 0); // false, 不過 null 既不大於 0
console.log(null < 0); // false, 而且 null 也不小於 0 ???
這裡可以分成兩種判斷:相等判斷和比較判斷。除了第一個是相等判斷外,其餘的都是屬於比較判斷。
對於比較判斷,JavaScript會先嘗試將比較對象自動轉型成數字或字串。也就是說null
會自動轉型成0
和0
比較,這也就說明為什麼轉型後的結果既不大於0也不小於0。
這樣做雖然很像是運算子多載。以Python程式碼來說很像是:
import random
class Person:
def __init__(self, age):
self.age = age
def __eq__(self, other):
return self.age == other.age
def __gt__(self, other):
return self.age > other.age
def __str__(self):
age = self.age
return f"Person(age={age})"
def __repr__(self):
age = self.age
return f"Person(age={age})"
上面Person
實現了部分比較的方法,因此現在可以針對兩個物件實例直接使用比較運算子:
bob = Person(18)
alice = Person(19)
print(alice > bob) # True, alice 比 bob大
因此可以直接用於排序:
people = []
for i in range(10):
people.append(Person(random.randint(1, 101)))
people.sort()
Comparable
java並沒有多載運算子的方式,不過你可以透過實現Comparable
界面或使用Comparator
來進行排序。下面以Comparable
為例:
import java.util.Calendar;
import java.util.ArrayList;
import java.lang.Math;
import java.util.Collections;
class Person implements Comparable<Person> {
public String name;
private Calendar birthday;
public String addr;
/* constructor */
public Person(String name, Calendar birthday, String addr) {
this.name = name;
this.birthday = birthday;
this.addr = addr;
}
int getAge() {
Calendar today = Calendar.getInstance();
int todayYear = today.get(Calendar.YEAR);
int birthdayYear = birthday.get(Calendar.YEAR);
return todayYear - birthdayYear;
}
public int compareTo(Person other) {
int age = this.getAge();
int otherAge = other.getAge();
if (age == otherAge)
return 0;
return (this.getAge() > other.getAge()) ? 1 : -1;
}
public String toString() {
return "Person(name="+name+", age="+getAge()+", addr="+addr+")";
}
}
當類別實現了Comparable
界面後,就可以使用的Collections.sort()
進行排序
public class Main {
public static void main(String[] args) {
String[] names = {
"Alice",
"Bob",
"Candy",
"Danel",
"Frank",
"Grant",
"Harry",
"Iris",
"Joe",
"Kevin" };
ArrayList<Person> people = new ArrayList<Person>();
for(String name:names){
Calendar cal = Calendar.getInstance();
int birthYear = (int)Math.floor(Math.random() * 100) + 1;
cal.set(birthYear, 1 /* mouth */, 1 /* date */);
Person p = new Person(name, /* birthday = */cal, /* addr = */"");
people.add(p);
}
System.out.println(people);
Collections.sort(people);
System.out.println(people);
return;
}
}
本文同時發表於我的隨筆