演算法基礎之陣列的增刪改查和雙指標思想的妙用

雙子孤狼發表於2021-12-24

前言

陣列是一種非常基礎的資料結構,很多人都會覺得陣列非常簡單,在我們使用的程式語言當中幾乎都有陣列這種資料結構,我們平常使用的也非常廣泛。雖然如此,但是我們真的完全瞭解陣列嗎?比如陣列為什麼可以支援隨機訪問,陣列具體有哪些特性,我們如何高效的實現在陣列中插入或者刪除一個元素,這些問題大家是否都能不假思索的回答呢?

什麼是陣列

陣列是指的是用一組連續的記憶體空間,來儲存一組具有相同型別的資料的線性表資料結構。

這個定義裡面有陣列的兩個非常重要的特性:

  • 連續的記憶體空間和相同的資料型別

連續的記憶體空間就是說陣列中所有的元素都必須連續排列在一起,中間不允許有空位置。正是陣列的這個特性決定了陣列支援隨機訪問,因為我們可以通過陣列佔用記憶體的起始記憶體地址,再根據每個元素佔用空間就可以計算出任意位置元素的記憶體地址,進而直接訪問它。然而陣列的這個特性也帶來了不利的一面,那就是刪除一個元素時,為了確保陣列空間的連續性,這個元素之後的所有元素都必須往前移,這也就導致了刪除元素的最壞時間複雜度為 O(n)。插入元素也是同理,所以陣列具有高效的訪問操作的同時卻具有低效的插入和刪除操作。

資料連續的記憶體空間也決定了陣列無法直接進行擴容,當我們需要對陣列進行擴容時,必須重新再申請一塊連續的記憶體空間,然後將舊陣列的所有元素賦值到新陣列當中,可以想象,這種方式肯定是消耗效能的。

Java 中的 ArrayList 集合類,底層就是使用陣列實現的,雖然我們不需要手動進行擴容,但是一旦超過了定義的儲存範圍,觸發擴容操作時,ArrayList 底層就是通過 Arrays.copyOf 的方式進行的擴容:

Arrays.copyOf 方法就會申請一個新陣列來儲存舊陣列的元素。正是因為陣列的這種擴容方式會帶來效能上的消耗,所以當我們使用 ArrayList 時,如果可以預判長度,最好指定一個長度(HashMap 也是同理),這樣可以避免後期觸發擴容。

  • 線性表結構

線性表結構的意思就是資料和線一樣排列成一排,也就是說只有一條線可以往後(往前)走,沒有其他分叉路,其他的比如連結串列,棧,佇列等都屬於線性結構,而二叉樹,圖,堆等資料結構就屬於非線性表結構。

陣列的初始化

幾乎所有的語言中都提供了陣列的資料型別,在這裡以 Java 語言為例。

Java 中可以通過以下方式定義一個陣列:

方式一([] 放前放後都可以):直接定義長度,然後賦值時如果值都是有規律的話可以通過迴圈賦值。

int arr[] = new int[5];
int[] arr1 = new int[5];

方式二:直接定義陣列元素。

int[] arr3 = new int[]{1,2,3};

方式三:直接定義陣列元素,在演算法中一般都是這種寫法,這種方式也可以看做是方式二的簡寫形式。

 int[] arr4 = {1,2,3,4};

單調陣列

單調陣列指的就是一個陣列是有序的,也就是說陣列中的元素要麼是遞增的,要麼就是遞減的。

  • 單調遞增陣列:對於所有 i <= j,有 A[i] <= A[j]

  • 單調遞減陣列:對於所有 i <= j,有 A[i] >= A[j]

在演算法當中,如果給的陣列是一個單調陣列,那麼我們首先應該想到的是二分查詢法,關於二分查詢在演算法系列的後續文章會專門講解,本文不會展開。

區分 length 和 size

在陣列當中,我們需要注意區分陣列的長度(length)和陣列的有效位數(size)。

比如下面這個陣列:

int[] arr = new int[5];
arr[0] = 1;

在這個陣列當中,我們定義了陣列的長度是 5,但是裡面只有一個元素,這時候我們就需要再定義一個 size,用來標記當前陣列有效位數。

我們看下面兩行程式碼:

public static void main(String[] args) {
    List list = new ArrayList<>(16);
    list.add(1);
    System.out.println(list.size());//輸出 1
}

我們都知道,ArrayList 的底層是陣列,當我們初始化一個 ArrayList 的時候,底層會建立一個 16 位長度的陣列。

但是我們這裡只新增了一個元素,所以 size 就是 1ArrayList 中維護了一個 size 屬性來記錄陣列中的有效元素,當我們每次新增元素的時候,size 才會增加。

所以本文後面的例子中也一樣,我們需要時刻記得維護 size,否則就會輸出無效資料位或者有效資料無法被輸出。

陣列的增刪改查

陣列本身並不難,但是很多高階演算法都會依託陣列,所以陣列是基礎的基礎,對於陣列的增刪改查操作我們需要非常熟練,在運算元組時,我們尤其要注意的就是陣列越界的問題,在 Java 這種本身就提供了安全機制的情況下,越界會直接丟擲異常,但是對於 C 語言這種,陣列越界就會訪問到其他記憶體地址,造成一些未知的錯誤產生。

訪問陣列元素

在陣列中,如果給定一個下標,我們可以直接通過下標訪問元素,如果給定的是一個元素的值,那麼我們就需要找到下標,而尋找指定元素的下標我們就可以通過遍歷的方式查詢(當然如果是單調陣列我們可以通過二分查詢法來提高效率)。

如下就是一個根據給定元素的值來查詢元素下標的示例:

/**
  * 尋找指定元素在陣列中的下標
  * @param arr - 指定陣列
  * @param element 要查詢的元素
  * @return
*/
public static int findIndexByElement(int[] arr, int size, int element) {
    for (int i=0;i<size;i++){
        if (arr[i] == element){
            return i;
        }
    }
    return -1;
}

刪除陣列元素

在陣列中刪除一個元素時,為了保證陣列記憶體的連續性,後面的所有元素都必須往前移動。當我們刪除的元素正好是陣列尾部,那麼時間複雜度就是 O(1),如果刪除的元素位於頭部,那麼時間複雜度就是 O(n)

下面就是一個刪除陣列中元素的例子:

/**
   * 移除陣列中指定下標的元素。將 index 之後的元素往前移動一位,同時更新size即可
   * @param arr - 指定陣列
   * @param size - 當前陣列元素數量
   * @param index 刪除位置
   * @return
*/
public static void removeByIndex(int[] arr, int size, int index) {
    if (null == arr || arr.length == 0){//陣列是否為空
        return;
    }
    if (index < 0 || index > size -1){//判斷是否越界
        return;
    }
    for (int i = index;i < size;i++){//從 index 開始,所有的元素都往前移動一位
        arr[i-1] = arr[i];
    }
    size--;//注意要維護 size
}

這道題目本身很簡單,但是這個示例當中也有兩個關鍵點:

  • 陣列的邊界控制
  • size 的維護

上面示例中元素是直接給出了下標,如果給的是元素的值呢?那麼這時候我們就需要先找到當前元素在陣列中的下標,然後再根據下標進行刪除。

插入陣列元素

假如給定的陣列中還有空位,那麼要在陣列中新增一個元素很簡單,直接放到 arr[size] 的位置就行,比如下面的例子,因為陣列只是初始化了一個空間,沒有任何元素,所以新增的時候只需要使用簡單的 arr[index] 進行賦值就可以:

public static void main(String[] args) {
    int[] arr = new int[5];
    arr[0] = 1;
}

現在假如給定的是一個單調遞增陣列,我們要往裡面插入一個元素,這時候就沒那麼簡單了,因為我們需要保證陣列的順序,所以不能直接插入到最後,必須得先確定插入的位置,這時候我們有兩種辦法:

  • 方法一:根據元素找到需要插入的位置,然後執行插入操作,插入的同時,其他元素都往後移動。
  • 方法二:直接從後往前開始比較,如果比較的結果比插入元素大,那麼往後移動一位,直到找到自己的插入位置。

下面的示例就是利用方法二,從後往前開始遍歷查詢並插入元素:

/**
     * 將給定的元素插⼊到單調遞增陣列中
     * @param arr - 指定陣列
     * @param size - 陣列已經儲存的元素數量
     * @param element - 待插入的元素
     * @return
*/
public static void addByElementSequence2(int[] arr, int size, int element) {
    if (null == arr || arr.length == 0){//陣列是否為空
        return;
    }
    if (size >= arr.length){ //確認陣列至少有一個空位
        return;
    }
    boolean succ = false;
    for (int i= size -1;i>=0;i--){
        if (arr[i] > element){//如果當前元素大於插入元素,那麼將元素後移
            arr[i+1] = arr[i];//這裡不會越界是因為方法中的第二個判斷確保了陣列至少有一個空位
        }else{//如果當前元素小於等於插入元素,那麼可以插入
            arr[i+1] = element;//i 後面一個位置已經空出來了
            succ = true;
            break;
        }
    }
    if (!succ){//如果上面沒有插入成功,那就說明當前插入的元素最小,直接插入頭部即可
        arr[0] = element;
    }
}

所以其實可以看到,插入一個元素的最壞時間複雜度也是 O(n),那麼有沒有辦法使得插入元素的時間複雜度達到 O(1) 呢?

在特定場景下往陣列中插入一個元素時間複雜度是可以達到 O(1) 的。

比如我們的陣列是無序的,插入一個元素也不在乎順序,也沒有指定插入元素的位置,那麼這時候就可以選擇直接插入尾部;如果插入元素時指定了一個插入位置,如果不關心順序的話也可以採用一種巧妙的辦法來實現:

 /**
     * 使用 O(1) 時間複雜度在給定陣列的指定位置插入元素,可以忽略順序
     * @param arr - 指定陣列
     * @param size - 陣列中有效元素
     * @param index - 指定插入下標
     * @param element - 指定插入元素
*/
public static void addByElement(int[] arr, int size, int index,int element) {
    if (null == arr || arr.length == 0){//陣列是否為空
        return;
    }
    if (size >= arr.length){//確認陣列至少有一個空位
        return;
    }
    arr[size] = arr[index];//將 index 和有效陣列位數的最後一位交換
    arr[index] = element;
}

這裡其實就是直接將需要插入元素的位置上的原有元素放到最後,然後再直接插入,避免了陣列的移動,實現了 O(1) 時間複雜度的插入。

修改陣列元素

修改元素如果不關心順序那麼直接覆蓋即可,如果關心順序,那麼就需要結合上面的方法,先找到需要插入的位置,然後將原有資料刪除,再插入新資料。

雙指標思想

在陣列相關的演算法題中,雙指標思想是最核心最重要的一個思想。我們知道,運算元組可能會帶來大量陣列元素的移動,避免元素的移動直接就可以提升一個演算法的效率,而雙指標的用法恰恰就可以減少陣列元素的移動。

指標是什麼?所謂的指標其實就是一個指向了具體記憶體地址的引用,或者說我們根據這個指標可以直接訪問到記憶體地址。我們平常遍歷陣列的時候,會有一個下標,那麼這個下標就可以算是一個指標,因為通過這個下標,我們可以直接訪問素組元素。所以在陣列中,所謂的雙指標,其實就是指的兩個下標

下面我們就以 leetcode 上的第 26 題為例子來具體看看雙指標的妙用。

題目的描述是這樣的:給你一個有序陣列 nums,請你原地刪除重複出現的元素,使每個元素只出現一次 ,返回刪除後陣列的新長度。注意不要使用額外的陣列空間,你必須在原地修改輸入陣列並在使用 O(1) 額外空間的條件下完成。

這道題最直接的第一反應應該有兩個思路:

  • 方法一:定義新陣列來儲存,空間複雜度為 O(n)

新建一個新陣列,然後從頭開始遍歷 nums,因為陣列是有序的,所以相同的元素一定是連續的,我們先把第一個元素放到新陣列,然後開始遍歷,發現 nums 中元素和新陣列第一個元素相同就跳過,發現不同就存入新陣列,然後再繼續遍歷 nums 並和新陣列的第二個元素比較,依次類推。

  • 方法二:雙重迴圈遍歷法

雙重迴圈遍歷陣列,發現相同的資料則刪除,刪除的同時把後面的元素都往前移動。

這兩種寫法在這裡就不寫示例,實現起來相對比較簡單,正常都能想到。但是方法一空間複雜度不符合題目要求,方法二雖然實現了,但是會帶來大量的陣列移動,而且還比較容易出錯。

這道題如果利用雙指標來實現,就會非常簡潔,高效。

這道題我們可以定義兩個指標: ⼀個指向有效陣列的最後⼀個位置(validIndex),⼀個指標負責陣列遍歷(index),當兩個指標指向的值不⼀樣時,將 validIndex 向後移動,並將 index 對應的值賦給 validIndex,如此當 index 遍歷完陣列之後,validIndex 就是有效陣列的最後一個下標。

下面我們通過一個具體的例子來分析一下雙指標的執行過程。

假設我們有一個陣列 int[] arr = {1,1,2,3},這時候我們定義兩個指標 validIndexindex,初始都指向元素 1,也就是下標 0

這時候開始遍歷,兩個值比較,發現相等,validIndex 不移動,index 往後移動到第二個元素 1 所在的位置:

這時候繼續比較,發現還是相等,validIndex 繼續保持原來位置不動,index 則繼續往後移動到 2 的位置:

這時候比較,發現 12 已經不相等了,這時候需要把 validIndex 往後移動到第二個位置,同時把第三個位置的元素 2 賦值給新 validIndex,並且 index 自己也繼續往後移動到 3 的位置。

繼續比較,發現 23 也不想等,繼續把 validIndex 往後移動,同時把 3 賦值給新 validIndex,此時因為 index 已經到達陣列末尾,迴圈結束。

這時候得到的 validIndex 就是不重複陣列元素的下標,用這種方式避免了陣列的大量移動,僅僅通過覆蓋的方式就達到了目的,下面就是雙指標的程式碼實現(示例中程式碼 validLength 初始為 1,所以比較的需要時候是 arr[validLength - 1])需要減 1,而賦值的時候是 arr[validLength++],不需要加 1

/**
   * 刪除陣列中的重複元素
   * @param arr - 陣列
   * @return 返回剩餘陣列新長度
*/
public static int removeRepeatElement(int[] arr) {
    if (null == arr || arr.length == 0){
        return -1;
    }

    int validLength = 1;//有效長度
    for (int i=0;i<arr.length;i++){
        if (arr[i] != arr[validLength - 1]){
            arr[validLength++] = arr[i];
        }
    }
    return validLength;
}

總結

本文主要講述了資料結構中最基礎的一種資料結構陣列的特性,並且分析了陣列的特性決定了陣列的訪問是高效的,但是插入和刪除是低效的原因。陣列本身比較簡單,但是陣列又是許多高階演算法的載體,所以我們如果想要學習高階演算法,那麼陣列的增刪改查是必須要掌握的,同時最後我們通過一個例子介紹了雙指標思想的利用,在陣列中的相關操作中,利用雙指標可以巧妙的避免運算元組時帶來的大量元素移動。

相關文章