LeetCode 解題思路:26. Remove Duplicates From Sorted Array(刪除有序陣列中的重複項)

掌握雙指針技巧與原地演算法的精髓

題目描述

給定一個按照非遞減順序排列的整數陣列 nums,請你原地刪除重複出現的元素,使每個元素只出現一次。元素的相對順序應該保持一致。然後返回 nums 中唯一元素的個數。

假設 nums 的唯一元素個數為 k,為了通過測試,你需要做以下事情:

  • 更改陣列 nums,使 nums 的前 k 個元素包含原來的唯一元素,且順序與原陣列相同
  • nums 的其餘元素以及 nums 的大小都不重要

什麼是非遞減順序?

在深入解題之前,先理解一個重要概念:

非遞減順序是指陣列中的每個元素都不小於前一個元素。簡單來說,就是「排序好的陣列,但允許有重複值」。

# ✅ 非遞減順序的例子
[1, 2, 3, 4, 5]      # 嚴格遞增
[1, 1, 2, 2, 3]      # 有重複值(這題的重點!)
[5, 5, 5, 5, 5]      # 全部相同
[1, 2, 2, 3, 3, 3]   # 部分重複

# ❌ 不是非遞減順序
[5, 4, 3, 2, 1]      # 遞減
[1, 3, 2, 4]         # 有下降

視覺化理解

非遞減順序(像爬樓梯,可以有平地):
     ┌─────┐
   ┌─┤     │
 ┌─┤ │     │  
─┤ │ │     │
 └─┴─┴─────┘
 1 2 2  3  3

關鍵:因為是非遞減順序,相同的元素必定相鄰!

範例

範例 1:

輸入:nums = [1,1,2]
輸出:2, nums = [1,2,_]
解釋:函數應該返回 k = 2,並且 nums 的前兩個元素為 1, 2
不需要考慮陣列中超出新長度後面的元素(用 _ 表示)

範例 2:

輸入:nums = [0,0,1,1,1,2,2,3,3,4]
輸出:5, nums = [0,1,2,3,4,_,_,_,_,_]
解釋:函數應該返回 k = 5,並且 nums 的前五個元素為 0, 1, 2, 3, 4

限制條件

  • 1 <= nums.length <= 3 * 10⁴
  • -100 <= nums[i] <= 100
  • nums非遞減順序排列

核心概念

1. 什麼是原地演算法(In-place)?

原地演算法是指:

  • 不能創建新陣列來存儲結果
  • 只能使用 O(1) 額外空間(幾個變數)
  • 直接修改原陣列
# ❌ 錯誤示範:不是原地演算法
def removeDuplicates_wrong(nums):
    unique_nums = []  # 創建新陣列!
    for num in nums:
        if num not in unique_nums:
            unique_nums.append(num)
    return len(unique_nums)

# ✅ 正確示範:原地修改
def removeDuplicates_correct(nums):
    # 只用幾個變數,直接修改 nums
    i = 0
    for j in range(1, len(nums)):
        if nums[j] != nums[i]:
            i += 1
            nums[i] = nums[j]
    return i + 1

2. 為什麼這題適合雙指針?

關鍵洞察:因為陣列是非遞減順序,相同的元素必定相鄰

這讓我們可以使用雙指針:

  • 慢指針:維護不重複元素的部分
  • 快指針:探索新元素

解法一:雙指針法(最優解)

思路

使用快慢指針:

  • 慢指針 i:指向下一個不重複元素要放置的位置
  • 快指針 j:遍歷陣列,尋找不重複的元素

程式碼實現

class Solution:
    def removeDuplicates(self, nums: List[int]) -> int:
        if not nums:
            return 0
        
        # 慢指針,指向最後一個不重複元素的位置
        i = 0
        
        # 快指針,從第二個元素開始遍歷
        for j in range(1, len(nums)):
            # 發現不同的元素
            if nums[j] != nums[i]:
                i += 1
                nums[i] = nums[j]
        
        # i 是索引,長度要加 1
        return i + 1

執行過程視覺化

讓我們詳細追蹤 nums = [0,0,1,1,1,2,2,3,3,4]

初始狀態:
nums = [0,0,1,1,1,2,2,3,3,4]
        ↑ ↑
        i j
說明:i 指向最後一個唯一元素,j 探索新元素

步驟 1:nums[j]=0 == nums[i]=0,重複,j 前進
nums = [0,0,1,1,1,2,2,3,3,4]
        ↑   ↑
        i   j

步驟 2:nums[j]=1 != nums[i]=0,發現新元素!
       i 前進到位置 1,複製 nums[j] 到 nums[i]
nums = [0,1,1,1,1,2,2,3,3,4]
          ↑ ↑
          i j

步驟 3-4:nums[j]=1 == nums[i]=1,重複,j 繼續前進
nums = [0,1,1,1,1,2,2,3,3,4]
          ↑     ↑
          i     j

步驟 5:nums[j]=2 != nums[i]=1,發現新元素!
nums = [0,1,2,1,1,2,2,3,3,4]
            ↑     ↑
            i     j

...繼續執行...

最終結果:
nums = [0,1,2,3,4,2,2,3,3,4]
                ↑
                i
前 5 個元素是唯一的:[0,1,2,3,4]
返回 i + 1 = 5

為什麼這樣有效?

  1. 利用非遞減特性:相同元素必相鄰,不會錯過
  2. 慢指針維護結果:前 i+1 個位置始終保存著不重複元素
  3. 快指針探索:找到不同的元素就加入結果區

解法二:更直觀的理解方式

思路

把慢指針理解為「唯一元素的計數器」。

程式碼實現

class Solution:
    def removeDuplicates(self, nums: List[int]) -> int:
        if not nums:
            return 0
        
        # k 表示目前有多少個唯一元素
        k = 1  # 第一個元素肯定是唯一的
        
        for i in range(1, len(nums)):
            # 如果當前元素與前一個不同,就是新的唯一元素
            if nums[i] != nums[i - 1]:
                nums[k] = nums[i]  # 放到第 k 個位置
                k += 1             # 唯一元素數量加 1
        
        return k

這個寫法的好處

  • 語義清晰k 直接表示唯一元素個數
  • 容易理解:比較相鄰元素更直觀

常見錯誤與陷阱

錯誤 1:違反原地要求

# ❌ 錯誤!創建了新陣列
def removeDuplicates(self, nums: List[int]) -> int:
    unique = []
    for num in nums:
        if not unique or num != unique[-1]:
            unique.append(num)
    
    # 即使複製回去也不對,因為過程中用了 O(n) 空間
    nums[:] = unique
    return len(unique)

錯誤 2:忘記非遞減順序的特性

# ❌ 效率低下!沒有利用排序特性
def removeDuplicates(self, nums: List[int]) -> int:
    i = 0
    for j in range(len(nums)):
        # 檢查 nums[j] 是否在前面出現過
        if nums[j] not in nums[:i]:  # O(n) 操作!
            nums[i] = nums[j]
            i += 1
    return i

錯誤 3:返回錯誤的值

# ❌ 常見錯誤
def removeDuplicates(self, nums: List[int]) -> int:
    # ... 處理邏輯 ...
    return i  # 錯誤!i 是最後一個元素的索引,應該返回 i + 1

進階思考

1. 如果陣列未排序怎麼辦?

未排序的情況下,相同元素不一定相鄰,問題變得複雜:

# 方法 1:先排序(改變了相對順序)
def removeDuplicates_unsorted_v1(nums):
    nums.sort()  # O(n log n)
    # 然後用原本的方法
    
# 方法 2:使用額外空間(不是原地)
def removeDuplicates_unsorted_v2(nums):
    seen = set()
    k = 0
    for num in nums:
        if num not in seen:
            seen.add(num)
            nums[k] = num
            k += 1
    return k

2. 如果允許每個元素最多出現 k 次?

這是 LeetCode 80 題的泛化版本:

def removeDuplicates(self, nums: List[int], k: int) -> int:
    if len(nums) <= k:
        return len(nums)
    
    # i 指向下一個要填入的位置
    i = k
    
    for j in range(k, len(nums)):
        # 與第 i-k 個位置比較
        if nums[j] != nums[i - k]:
            nums[i] = nums[j]
            i += 1
    
    return i

3. 統計刪除了多少元素?

def removeDuplicatesWithCount(self, nums: List[int]) -> tuple:
    original_length = len(nums)
    unique_length = self.removeDuplicates(nums)
    removed_count = original_length - unique_length
    
    print(f"原始長度:{original_length}")
    print(f"唯一元素:{unique_length}")
    print(f"刪除數量:{removed_count}")
    
    return unique_length, removed_count

複雜度分析

時間複雜度:O(n)

  • 快指針遍歷整個陣列一次
  • 每個元素最多被移動一次

空間複雜度:O(1)

  • 只使用了兩個指針變數
  • 真正的原地演算法

相關題目推薦

這些題目都運用了類似的雙指針技巧:

  1. LeetCode 27: Remove Element - 移除特定元素
  2. LeetCode 80: Remove Duplicates II - 允許重複兩次
  3. LeetCode 283: Move Zeroes - 移動零到末尾
  4. LeetCode 88: Merge Sorted Array - 合併有序陣列

實戰技巧總結

1. 雙指針模板

def twoPointers(nums):
    # 慢指針:維護結果
    slow = 0
    
    # 快指針:探索元素
    for fast in range(len(nums)):
        if 滿足某條件:
            nums[slow] = nums[fast]
            slow += 1
    
    return slow

2. 調試技巧

def removeDuplicates(self, nums: List[int]) -> int:
    if not nums:
        return 0
    
    i = 0
    print(f"初始: nums = {nums}")
    
    for j in range(1, len(nums)):
        if nums[j] != nums[i]:
            i += 1
            nums[i] = nums[j]
            print(f"Step {j}: 發現新元素 {nums[j]}")
            print(f"        nums = {nums[:i+1]} | {nums[i+1:]}")
    
    return i + 1

3. 完整測試案例

def test_removeDuplicates():
    test_cases = [
        # [輸入, 預期輸出, 預期陣列前綴]
        ([], 0, []),
        ([1], 1, [1]),
        ([1,1,1,1,1], 1, [1]),
        ([1,2,3,4,5], 5, [1,2,3,4,5]),
        ([1,1,2,2,3,3], 3, [1,2,3]),
        ([-1,0,0,0,3,3], 3, [-1,0,3]),
        ([1,1,2], 2, [1,2])
    ]
    
    solution = Solution()
    for nums, expected_k, expected_prefix in test_cases:
        nums_copy = nums.copy()
        k = solution.removeDuplicates(nums_copy)
        assert k == expected_k
        assert nums_copy[:k] == expected_prefix
        print(f"✅ 通過:{nums} -> {k}")

總結

這道題完美展示了:

  1. 非遞減順序的重要性:讓問題簡化,相同元素必相鄰
  2. 雙指針的優雅:一個維護結果,一個探索新元素
  3. 原地演算法的精髓:空間效率極高
  4. 通用的解題模式:可應用於多種陣列操作問題

記住核心思想:利用排序特性,用雙指針原地去重

練習建議

  1. 先理解「非遞減順序」的含義
  2. 在紙上畫出指針移動過程
  3. 寫程式時加入列印語句觀察執行
  4. 嘗試各種邊界測試案例
  5. 挑戰相關題目,鞏固雙指針技巧
0%