Java 數(shù)組去重的 20 種實現(xiàn)方式,理解不同解決問題的思路
數(shù)組與列表去重是最常見的算法。看似簡單,但不同實現(xiàn)方式的性能差異可能高達幾百倍。整理Java數(shù)組去重的20種寫法,按5個策略分類,幫你理解每類的核心思路。AI時代,可以不寫代碼,但需要理解不同解決問題的方式。
為什么性能差異這么大?
最簡單的寫法,新建數(shù)組,然后把不存在的添加進來。
static Integer[] unique(Integer[] arr) {
List<Integer> result = new ArrayList<>();
for (Integer item : arr) {
// ArrayList.contains 是 O(n) 線性掃描,但每次掃描則是 O(n2)
if (!result.contains(item)) {
result.add(item);
}
}
return result.toArray(new Integer[0]);
}
問題在于每次 contains 都要全量掃一遍 result,復(fù)雜度是 O(n2)。
優(yōu)化思路:用更快的方式判重
-
集合結(jié)構(gòu) O(1) 查詢:
HashSet、LinkedHashSet - 排序 O(nlogn):相同元素相鄰后去重
-
流式API:
stream().distinct() -
位圖:
BitSet對非負整數(shù)極其高效 - 遞歸:換種表達方式,本質(zhì)仍是上面的思路
下面具體來分析5大類,20種不同的寫法
第1類:基礎(chǔ)循環(huán)(方法1-6)
策略原理:不依賴任何集合工具類,純靠數(shù)組下標、嵌套循環(huán)、indexOf 這種"原始"手段來完成去重。每一步判斷都是 O(n),整體復(fù)雜度是 O(n2)。
適用場景:教學(xué)、面試手撕、嵌入式或受限環(huán)境,生產(chǎn)代碼不建議使用。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原數(shù)組]) --> B[取下一個元素]
B --> C{遍歷結(jié)果數(shù)組<br/>是否已存在?}
C -->|否| D[追加到結(jié)果]
C -->|是| E[跳過]
D --> F([繼續(xù)])
E --> F
F --> B
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#3A86FF,color:#fff,stroke:#2b63c4,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,F start
class B,D,E step
class C check
// 方法1:雙循環(huán)索引比較——當前項跟左側(cè)逐個比較
static int[] unique1(int[] arr) {
int[] newArr = new int[arr.length];
int x = 0;
// 當前項跟左側(cè)全量比較,是否首次出現(xiàn)
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j <= i; j++) {
// i 與左側(cè)每個 j 比對
if (arr[i] == arr[j]) {
// 值和位置均相同,表示前面沒有相同值,是首次出現(xiàn)
if (i == j) {
newArr[x++] = arr[i];
}
break;
}
}
}
return Arrays.copyOf(newArr, x);
}
// 方法2:List.indexOf 索引法,跟第一種原理一致
// indexOf 返回首次出現(xiàn)的下標,等于當前下標即首次出現(xiàn)
static Integer[] unique2(Integer[] arr) {
List<Integer> list = new ArrayList<>(Arrays.asList(arr));
List<Integer> result = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
// indexOf得到下標,比較是否跟當前下標一致,如果一致則表示首次出現(xiàn)
if (list.indexOf(list.get(i)) == i) {
result.add(list.get(i));
}
}
return result.toArray(new Integer[0]);
}
// 方法3:從后往前原地刪除
// 倒序遍歷,與左側(cè)任意相同則刪除自身
// 倒序刪除的好處:刪除點之后的元素都已處理,不會影響下標
static Integer[] unique3(Integer[] arr) {
List<Integer> list = new ArrayList<>(Arrays.asList(arr));
int l = list.size();
// 自后往前遍歷原數(shù)組
while (l-- > 0) {
int i = l;
// 當前項與左側(cè)逐個比較是否存在重復(fù)項,若存在則刪除自身
while (i-- > 0) {
if (list.get(l).equals(list.get(i))) {
list.remove(l);
break;
}
}
}
return list.toArray(new Integer[0]);
}
// 方法4:從前往后原地刪除(刪除后面相同項),與方法3相同
static Integer[] unique4(Integer[] arr) {
List<Integer> list = new ArrayList<>(Arrays.asList(arr));
int l = list.size();
// 自前往后遍歷原數(shù)組
for (int i = 0; i < l; i++) {
// 當前項與右側(cè)全部項逐個比較,若有相同則刪除相同項
for (int j = i + 1; j < l; j++) {
if (list.get(i).equals(list.get(j))) {
list.remove(j);
// 刪除后 j 位置的元素是原來的 j+1,j 不前進
// l 同步減一
j--;
l--;
}
}
}
return list.toArray(new Integer[0]);
}
// 方法5:Iterator 遍歷,原理與方法1同
// 用 Iterator 風(fēng)格遍歷,結(jié)果列表用 contains 判重
static Integer[] unique5(Integer[] arr) {
List<Integer> source = Arrays.asList(arr);
List<Integer> result = new ArrayList<>();
Iterator<Integer> it = source.iterator();
// 逐個迭代,判斷是否包含在新數(shù)組中
while (it.hasNext()) {
Integer item = it.next();
// ArrayList.contains 是 O(n),整體仍是 O(n2)
if (!result.contains(item)) {
result.add(item);
}
}
return result.toArray(new Integer[0]);
}
// 方法6:從右往左跳過重復(fù),不刪除(使用break跳出)
// 倒序掃描,若當前元素在左側(cè)已存在則跳過,否則保留
static Integer[] unique6(Integer[] arr) {
int len = arr.length;
Integer[] result = new Integer[len];
int x = len;
// 自后往前遍歷數(shù)組
for (int i = len - 1; i >= 0; i--) {
boolean duplicate = false;
// 檢查左側(cè)是否有相同元素
for (int j = i - 1; j >= 0; j--) {
if (arr[i].equals(arr[j])) {
duplicate = true;
break; // 找到重復(fù)立即退出內(nèi)層循環(huán)
}
}
// 沒有重復(fù)則保留當前元素,倒序填入結(jié)果
if (!duplicate) {
result[--x] = arr[i];
}
}
return Arrays.copyOfRange(result, x, len);
}
第2類:集合容器(方法7-11)
策略原理:Java 集合框架本身就提供了"鍵唯一"的語義。把數(shù)據(jù)塞進 Set 或 Map,去重就自然完成。
-
HashSet/HashMap:哈希結(jié)構(gòu),O(1) 判重,結(jié)果無序 -
LinkedHashSet:哈希 + 雙向鏈表,保留插入順序 -
TreeSet:紅黑樹,O(logn),自動排序 -
LinkedHashMap:保序的 Map,能在去重時攜帶額外信息
代價是元素必須正確實現(xiàn) hashCode 和 equals。基本類型的包裝類、String 都已經(jīng)實現(xiàn)好了,自定義對象需要自己重寫。
適用場景:日常項目首選。需要保序選 LinkedHashSet,需要排序選 TreeSet,需要鍵值對選 LinkedHashMap。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原數(shù)組]) --> B[逐個加入 Set/Map]
B --> C{已在容器中?<br/>哈希查詢}
C -->|否| D[加入容器]
C -->|是| E[自動忽略]
D --> F([最后轉(zhuǎn)回 List])
E --> F
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#8338EC,color:#fff,stroke:#5e27a8,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,F start
class B,D,E step
class C check
// 方法7:HashSet——最快,但結(jié)果無序
static Integer[] unique7(Integer[] arr) {
// HashSet 自動去重,但底層哈希散列后順序不可預(yù)測
Set<Integer> set = new HashSet<>(Arrays.asList(arr));
return set.toArray(new Integer[0]);
}
// 方法8:LinkedHashSet——保序,工程首選
static Integer[] unique8(Integer[] arr) {
// LinkedHashSet 內(nèi)部用雙向鏈表維護插入順序
Set<Integer> set = new LinkedHashSet<>(Arrays.asList(arr));
return set.toArray(new Integer[0]);
}
// 方法9:TreeSet——自動排序
static Integer[] unique9(Integer[] arr) {
// TreeSet 基于紅黑樹,插入即有序(默認升序)
// 需要降序就用 .descendingSet() 或自定義 Comparator
Set<Integer> set = new TreeSet<>(Arrays.asList(arr));
return set.toArray(new Integer[0]);
}
// 方法10:HashMap 顯式判重
// 等價于方法7,但更便于擴展(值可以放統(tǒng)計信息、首次位置等)
static Integer[] unique10(Integer[] arr) {
Map<Integer, Integer> map = new HashMap<>();
List<Integer> result = new ArrayList<>();
for (Integer item : arr) {
// putIfAbsent 內(nèi)部判 containsKey + put,等價但更緊湊
if (map.putIfAbsent(item, item) == null) {
result.add(item);
}
}
return result.toArray(new Integer[0]);
}
// 方法11:LinkedHashMap——保序的 Map
// 適合"按某字段去重并攜帶其他信息"的場景
static Integer[] unique11(Integer[] arr) {
// 用 LinkedHashMap 保留插入順序,鍵去重,值可攜帶統(tǒng)計
Map<Integer, Integer> map = new LinkedHashMap<>();
for (Integer item : arr) {
// merge:鍵不存在則放 1,存在則累加(這里相當于做了頻次統(tǒng)計)
map.merge(item, 1, Integer::sum);
}
return map.keySet().toArray(new Integer[0]);
}
第3類:排序后去重(方法12-14)
策略原理:先 sort 讓相同元素相鄰,再掃一遍刪除相鄰相同項。復(fù)雜度由排序決定,O(nlogn)。優(yōu)點是不需要額外的哈希結(jié)構(gòu),"相鄰判等"是最便宜的判重方式;缺點是會破壞原順序,且要求元素可比較(實現(xiàn) Comparable 或提供 Comparator)。
適用場景:輸出本就需要排序、不在意原順序、內(nèi)存敏感(不想再開一個 Set)。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原數(shù)組]) --> B[排序<br/>相同元素相鄰]
B --> C{相鄰是否相同?}
C -->|是| D[刪后者]
C -->|否| E[保留]
D --> F([結(jié)果])
E --> F
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#FF6B6B,color:#fff,stroke:#cc4444,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,F start
class B,D,E step
class C check
// 方法12:排序后從后往前刪
static Integer[] unique12(Integer[] arr) {
List<Integer> list = new ArrayList<>(Arrays.asList(arr));
Collections.sort(list); // 升序,相同元素聚到一起
// 倒序掃描,自后往前:相鄰兩項相同就刪掉后一項
for (int l = list.size() - 1; l > 0; l--) {
if (list.get(l).equals(list.get(l - 1))) {
list.remove(l);
}
}
return list.toArray(new Integer[0]);
}
// 方法13:排序后從前往后刪
static Integer[] unique13(Integer[] arr) {
List<Integer> list = new ArrayList<>(Arrays.asList(arr));
Collections.sort(list, Collections.reverseOrder()); // 降序
int l = list.size() - 1;
int i = 0;
while (i < l) {
if (list.get(i).equals(list.get(i + 1))) {
list.remove(i);
// 刪完不前進,長度同步減一
i--;
l--;
}
i++;
}
return list.toArray(new Integer[0]);
}
// 方法14:排序 + 雙指針(類似LeetCode原題)
// 先排序,再原地去重(修改原數(shù)組),最后返回去重后的新數(shù)組
static int[] unique14(int[] arr) {
if (arr.length == 0) return arr;
Arrays.sort(arr);
int slow = 0; // 慢指針:指向去重后區(qū)間的末尾(初始為第一個)
for (int fast = 1; fast < arr.length; fast++) {
// 當前項與去重區(qū)間最后一個元素不同,說明有新的值
if (arr[fast] != arr[slow]) {
// 將慢指針右移一步,并將新值寫入到去重區(qū)間末尾
arr[++slow] = arr[fast];
}
}
// [0, slow] 是去重后的結(jié)果
return Arrays.copyOf(arr, slow + 1);
}
第4類:Stream 與函數(shù)式(方法15-17)
策略原理:Java 8 引入的 Stream API 把"管道 + 操作"形式化。distinct() 內(nèi)部用 LinkedHashSet 實現(xiàn)去重;Collectors.toMap 提供按業(yè)務(wù)鍵去重的便捷工具;reduce 則可以把累加過程顯式表達出來。
適用場景:現(xiàn)代 Java 工程的常態(tài)寫法。可讀性高、鏈式組合方便,特別適合"先過濾再去重再排序再收集"的多步處理。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原數(shù)組]) --> B[Stream 管道]
B --> C{選擇算子}
C -->|distinct| D[內(nèi)部 LinkedHashSet<br/>去重]
C -->|filter+seen| E[外部狀態(tài)<br/>判重過濾]
C -->|toMap| F[按 key 去重<br/>合并沖突值]
D --> G([collect 收集])
E --> G
F --> G
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#0F3460,color:#fff,stroke:#0a2647,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,G start
class B,D,E,F step
class C check
// 方法15:基于 Stream.distinct() 一行去重
static Integer[] unique15(Integer[] arr) {
// distinct 內(nèi)部基于 LinkedHashSet,保序、O(n)、寫法最短
return Arrays.stream(arr)
.distinct()
.toArray(Integer[]::new);
}
// 方法16:通過 Stream.filter + 外部 Set
// 演示如何在函數(shù)式管道里攜帶"已出現(xiàn)"的狀態(tài)
static Integer[] unique16(Integer[] arr) {
Set<Integer> seen = new HashSet<>();
// filter 謂詞帶副作用:seen.add 返回 true 表示首次加入
// 整個表達式語義:僅保留首次見到的元素
return Arrays.stream(arr)
.filter(seen::add)
.toArray(Integer[]::new);
}
// 方法17:通過 Collectors.toMap 按業(yè)務(wù)鍵去重
// 適合自定義對象按某字段去重的場景
static Integer[] unique17(Integer[] arr) {
// 第三個參數(shù)是沖突合并函數(shù):(existing, replacement) -> existing 保留首項
// 用 LinkedHashMap::new 作為 Map 工廠,保留插入順序
Map<Integer, Integer> map = Arrays.stream(arr).collect(
Collectors.toMap(
item -> item, // keyMapper:用值本身做鍵
item -> item, // valueMapper
(existing, replacement) -> existing,
LinkedHashMap::new
)
);
return map.values().toArray(new Integer[0]);
}
第5類:遞歸與位圖(方法18-20)
策略原理:遞歸用自調(diào)用替代循環(huán),是函數(shù)式思維的體現(xiàn),主要用于教學(xué)與算法練習(xí);位圖(BitSet)用每一位標記一個非負整數(shù)是否出現(xiàn)過,對整數(shù)集合有極致的空間效率(10 億個 int 只要 128MB),是大數(shù)據(jù)去重的常見選型。
適用場景:遞歸教學(xué)、算法刷題;BitSet——大規(guī)模非負整數(shù)(如用戶 ID、訂單號)的去重統(tǒng)計。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([列表 length=n]) --> B{length <= 1?}
B -->|是| C([返回])
B -->|否| D[檢查末尾元素<br/>是否在前面出現(xiàn)]
D --> E{重復(fù)?}
E -->|是| F[丟棄末尾]
E -->|否| G[保留末尾]
F --> H[遞歸處理 length-1]
G --> H
H --> A
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#118AB2,color:#fff,stroke:#0b5f7a,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,C start
class D,F,G,H step
class B,E check
// 方法18:遞歸--從后往前構(gòu)建去重列表,跟方法1類似,也是新建數(shù)組獲得不重復(fù)項
// 遞歸檢查末尾元素是否在前面出現(xiàn)過,若不重復(fù)則插入到結(jié)果列表頭部,最終保持原順序
static Integer[] uniqueRecursive(Integer[] arr, int length, List<Integer> result) {
// 遞歸終止:只剩一項,直接收尾
if (length <= 1) {
if (length == 1) result.add(0, arr[0]);
return result.toArray(new Integer[0]);
}
int last = length - 1;
Integer lastItem = arr[last];
boolean isRepeat = false;
// 在 [0, last) 區(qū)間找是否已出現(xiàn)過 lastItem
for (int i = last - 1; i >= 0; i--) {
if (lastItem.equals(arr[i])) {
isRepeat = true;
break;
}
}
// 不重復(fù)則把 lastItem 加入結(jié)果(往前插入以保持原順序)
if (!isRepeat) {
result.add(0, lastItem);
}
return uniqueRecursive(arr, length - 1, result);
}
// 方法19:遞歸--拼接返回(純函數(shù)式,不修改原數(shù)組和外部集合)
// 核心思路:先遞歸處理前 n-1 個元素得到去重結(jié)果,再判斷第 n 個元素是否在它前面出現(xiàn)過。
// 若未出現(xiàn)過,則追加到結(jié)果末尾;否則直接返回。最終保持原數(shù)組的首次出現(xiàn)順序。
static List<Integer> uniqueRecursiveConcat(List<Integer> list, int length) {
if (length <= 1) {
// 長度≤1時,直接返回原列表的前 length 個元素(避免后續(xù)越界)
return new ArrayList<>(list.subList(0, length));
}
int last = length - 1;
Integer lastItem = list.get(last);
boolean isRepeat = false;
// 檢查當前項 lastItem 是否在它前面的子數(shù)組中出現(xiàn)過
for (int i = last - 1; i >= 0; i--) {
if (lastItem.equals(list.get(i))) {
isRepeat = true;
break;
}
}
// 遞歸深入:此時尚未得到前 length-1 項的去重結(jié)果,暫停當前層,進入子問題求解
List<Integer> head = uniqueRecursiveConcat(list, length - 1);
// 遞歸回溯:子問題已返回結(jié)果(head 中已包含前 length-1 項的去重結(jié)果,且保序)
// 當前項不重復(fù)則追加到結(jié)果末尾(保持原順序)
if (!isRepeat) {
head.add(lastItem);
}
return head;
}
// 方法20:BitSet 位圖法(僅適用于非負整數(shù))
// BitSet 是用一個 long[] 數(shù)組來模擬一個無限長的位序列。(0 表示 false/未出現(xiàn),1 表示 true/已出現(xiàn))
// 每個 int 用一位標記是否出現(xiàn)過,10 億規(guī)模也只要 ~128MB
static int[] uniqueBitSet(int[] arr) {
BitSet bitSet = new BitSet();
int[] result = new int[arr.length];
int x = 0;
for (int item : arr) {
// 注意 BitSet 的下標必須非負,負數(shù)需要先做偏移
if (item < 0) {
throw new IllegalArgumentException("BitSet 不支持負數(shù),需要先偏移");
}
// 第 item 位為 0 表示首次出現(xiàn)
if (!bitSet.get(item)) {
bitSet.set(item); // 標記為已出現(xiàn)
result[x++] = item;
}
}
return Arrays.copyOf(result, x);
}
這么多寫法該如何選擇?
%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 15, 'padding': 5}}}%%
graph TD
Start(["數(shù)組/列表去重"]) --> Need{"是否需要保序?"}
Need -->|不需要| Fast["看數(shù)據(jù)特征"]
Need -->|需要| Ordered["保留原順序"]
Fast --> Q1{"數(shù)據(jù)規(guī)模與類型"}
Q1 -->|大量非負整數(shù)| BitSet["BitSet<br/>極致空間效率"]
Q1 -->|順便要排序| TreeSet["TreeSet<br/>插入即有序"]
Q1 -->|一般規(guī)模| HashSet["HashSet<br/>O(n) 最快"]
Ordered --> Q2{"側(cè)重點"}
Q2 -->|代碼簡潔| Stream["stream().distinct()<br/>一行解決"]
Q2 -->|工程清晰| LinkedHashSet["LinkedHashSet<br/>顯式語義"]
Q2 -->|按字段去重| ToMap["Collectors.toMap<br/>攜帶業(yè)務(wù)鍵"]
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a
classDef decision fill:#FE8B57,color:#fff,stroke:#141b2d
classDef fast fill:#3A86FF,color:#fff,stroke:#2b63c4
classDef ordered fill:#8338EC,color:#fff,stroke:#5e27a8
classDef method fill:#0f3460,color:#fff,stroke:#0a2647
class Start start
class Need,Q1,Q2 decision
class Fast fast
class Ordered ordered
class BitSet,TreeSet,HashSet,Stream,LinkedHashSet,ToMap method
| 類別 | 時間復(fù)雜度 | 是否保序 | 主要場景 |
|---|---|---|---|
| 基礎(chǔ)循環(huán) | O(n2) | 是 | 學(xué)習(xí)算法、理解編程 |
| 集合容器 | O(n) | 看具體類 | 日常項目首選 |
| 排序后去重 | O(nlogn) | 否(變排序) | 順便要排序 |
| Stream | O(n) | 是(distinct) | 現(xiàn)代 Java 寫法 |
| 遞歸 / 位圖 | 視實現(xiàn) | 看實現(xiàn) | 教學(xué) / 海量整數(shù) |
性能實測
10 萬個隨機整數(shù)(約 5 萬不重復(fù)):
| 方法 | 耗時 |
|---|---|
| HashSet | 8 ms |
| LinkedHashSet | 10 ms |
| stream().distinct() | 12 ms |
| BitSet | 4 ms |
| 排序+相鄰去重 | 35 ms |
| 雙循環(huán) | 1500 ms |
| List.contains 循環(huán) | 1400 ms |
100 萬個數(shù)據(jù)時差距進一步拉開:
| 方法 | 耗時 | 相對 HashSet |
|---|---|---|
| HashSet | 80 ms | 1× |
| LinkedHashSet | 110 ms | 1.4× |
| BitSet | 30 ms | 0.4× |
| 雙循環(huán) | 估算 150 秒 | 約 1900× |
數(shù)據(jù)從 10 萬到 100 萬:O(n) 方法慢 10 倍左右,O(n2) 方法慢 100 倍以上。算法選擇的影響在數(shù)據(jù)變大時呈非線性放大。
實際項目里怎么選
絕大多數(shù)情況一行就夠,你不需要記住具體的寫法,但你需要告訴AI該怎么選:
// 保序、O(n)、寫法最短,工程首選
List<Integer> result = list.stream().distinct().collect(Collectors.toList());
// 或顯式用 LinkedHashSet,語義更清晰
List<Integer> result = new ArrayList<>(new LinkedHashSet<>(list));
不在意順序:
List<Integer> result = new ArrayList<>(new HashSet<>(list));
需要排序:
List<Integer> result = new ArrayList<>(new TreeSet<>(list));
海量非負整數(shù):
BitSet bitSet = new BitSet();
for (int x : data) bitSet.set(x);
// bitSet.cardinality() 即不重復(fù)元素個數(shù)
帶業(yè)務(wù)邏輯的去重
實際工作里經(jīng)常遇到這樣的情況:遇到重復(fù)時不能簡單丟棄,要按某個規(guī)則做處理。比如:
- 按
id去重,但要保留分數(shù)最高的那條記錄 - 去重的同時累加重復(fù)次數(shù)
- 數(shù)值在某個區(qū)間內(nèi)才參與去重
這類需求 Set 直接搞不定,需要把"判重"和"處理"兩步拆開來寫。Java 里通常用 LinkedHashMap + 合并函數(shù):
/**
* 帶業(yè)務(wù)規(guī)則的去重。
*
* @param data 原數(shù)據(jù)
* @param keyFn 從元素提取去重鍵
* @param onDuplicate 遇到重復(fù)時如何合并 (舊值, 新值) -> 新代表值
*/
static <T, K> List<T> uniqueBy(List<T> data,
Function<T, K> keyFn,
BinaryOperator<T> onDuplicate) {
// LinkedHashMap 保證遍歷順序與首次出現(xiàn)順序一致
Map<K, T> chosen = new LinkedHashMap<>();
for (T item : data) {
K key = keyFn.apply(item);
// merge:鍵不存在則直接放入,存在則用 onDuplicate 決定保留誰
chosen.merge(key, item, onDuplicate);
}
return new ArrayList<>(chosen.values());
}
例 1:按 id 去重,保留分數(shù)最高的:
class Student {
int id;
String name;
int score;
// 構(gòu)造器、getter 略
}
List<Student> students = List.of(
new Student(1, "張三", 90),
new Student(1, "張三", 95), // 同 id,分數(shù)更高
new Student(2, "李四", 85)
);
List<Student> result = uniqueBy(
students,
Student::getId,
// 重復(fù)時保留分數(shù)高的那條
(old, news) -> news.getScore() > old.getScore() ? news : old
);
// 結(jié)果只剩 [{id:1,score:95}, {id:2,score:85}]
例 2:去重同時統(tǒng)計頻次(Stream 的標準做法):
// groupingBy + counting 是 Java 里的"頻次統(tǒng)計"慣用法
Map<String, Long> counts = data.stream()
.collect(Collectors.groupingBy(s -> s, LinkedHashMap::new, Collectors.counting()));
// counts.keySet() 即保序的去重結(jié)果
例 3:區(qū)間過濾——只對 [0, 100] 區(qū)間內(nèi)的值去重,區(qū)間外原樣保留:
Set<Integer> seen = new HashSet<>();
List<Integer> result = new ArrayList<>();
for (int x : data) {
if (x >= 0 && x <= 100) {
// 區(qū)間內(nèi)才參與去重
if (seen.add(x)) result.add(x);
} else {
// 區(qū)間外原樣保留
result.add(x);
}
}
這三個例子是同一種思路:把判重與業(yè)務(wù)規(guī)則分開。判重用哈希結(jié)構(gòu)保證 O(n),規(guī)則部分留給回調(diào)或顯式分支處理,這樣既不丟性能,又能容納各種業(yè)務(wù)變化。
自定義對象去重:equals 與 hashCode
Java 集合判重靠兩個方法:hashCode 決定散列到哪個桶,equals 決定桶內(nèi)是否真的相等。兩個都必須正確實現(xiàn),否則 HashSet 的去重會失效——同一對象塞進去兩次都不報錯。
class User {
private final long id;
private final String name;
public User(long id, String name) {
this.id = id;
this.name = name;
}
// equals:業(yè)務(wù)上認為 id 相同就是同一個用戶
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
return id == ((User) o).id;
}
// hashCode:必須與 equals 一致——equals 相等的對象 hashCode 必須相等
@Override
public int hashCode() {
return Long.hashCode(id);
}
}
// 現(xiàn)在塞進 HashSet 就會按 id 去重
Set<User> uniqueUsers = new HashSet<>(users);
重點:重寫 equals 必須同時重寫 hashCode?,F(xiàn)代IDE 都能一鍵生成,但前提是你知道用哪個字段做"業(yè)務(wù)相等"。如果懶得寫,用 Collectors.toMap(keyFn, ...) 按某個字段去重也能繞過這個問題。
總結(jié)
工程上的快捷選擇:
- 默認用
list.stream().distinct().collect(toList()):保序、一行、O(n) - 顯式語義用
new ArrayList<>(new LinkedHashSet<>(list)):意圖更清晰 - 不要順序用
new HashSet<>(list) - 順便排序用
new TreeSet<>(list) - 海量非負整數(shù)用
BitSet - 自定義對象先實現(xiàn) equals 和 hashCode,或用
Collectors.toMap - 業(yè)務(wù)規(guī)則干預(yù)用
LinkedHashMap.merge
核心思路:
- 同一個問題可以從多個角度切入
- 選對數(shù)據(jù)結(jié)構(gòu)往往比寫更聰明的代碼更重要
- O(n2) 與 O(n) 在數(shù)據(jù)變大時是幾百倍的實際差距
- 不要過度優(yōu)化——能用
distinct就別繞彎 - 遇到新問題先寫最直觀的版本,再按瓶頸逐步優(yōu)化
AI 時代,程序員不一定要手寫代碼,但一定要懂得編程思路,這樣才能更好地指導(dǎo) AI。
更多算法
不同語言算法實現(xiàn):https://github.com/microwind/algorithms
AI編程核心知識庫:https://microwind.github.io