數據結構與算法(一)數組詳解及實戰

一、什麼是數組?

每一種編程語言中,基本都有數組這種數據類型。每個有編程經驗的人都不會陌生,它不僅僅是一種編程語言中的數據類型,還是一種最基礎的數據結構。雖然簡單,但是裏面涉及到的知識點還是值得深入研究的。

在大部分編程語言中,數組都是從 0 開始編號的,但你是否下意識地想過,爲什麼數組要從 0 開始編號,而不是從 1 開始呢? 從 1 開始不是更符合人類的思維習慣嗎?

教科書中對於數組的定義如下。

數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據。

那如何理解中間的線性表、連續的0內存空間和相同類型的數據。

1.1 線性表

顧名思義,線性表就是數據排成像一條線一樣的結構。每個線性表上的數據最多隻有前和後兩個方向。其實除了數組,鏈表、隊列、棧等也是線性表結構。
在這裏插入圖片描述

1.2 非線性表

比如二叉樹、堆、圖等。之所以叫非線性,是因爲,在非線性表中,數據之間並不是簡單的前後關係,往往具有多前或者多後。
在這裏插入圖片描述

1.3 連續的內存空間和相同類型的數據

正是因爲這兩個限制,它纔有了一個堪稱“殺手鐗”的特性:“隨機訪問”。但有利就有弊,這兩個限制也讓數組的很多操作變得非常低效,比如要想在數組中刪除、插入一個數據,爲了保證連續性,就需要做大量的數據搬移工作。

說到數據的訪問,那你知道數組是如何實現根據下標隨機訪問數組元素的嗎?

我們拿一個長度爲 10 的 int 類型的數組 int[] a = new int[10]來舉例。在我畫的這個圖中,計算機給數組 a[10],分配了一塊連續內存空間 1000~1039,其中,內存塊的首地址爲 base_address = 1000。
在這裏插入圖片描述
我們知道,計算機會給每個內存單元分配一個地址,計算機通過地址來訪問內存中的數據。當計算機需要隨機訪問數組中的某個元素時,它會首先通過下面的尋址公式,計算出該元素存儲的內存地址:

a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示數組中每個元素的大小。我們舉的這個例子裏,數組中存儲的是 int 類型數據,所以 data_type_size 就爲 4 個字節。這個公式非常簡單。

這裏我要特別糾正一個“錯誤”。我在面試的時候,常常會問數組和鏈表的區別,很多人都回答說,“鏈表適合插入、刪除,時間複雜度 O(1);數組適合查找,查找時間複雜度爲 O(1)”。

實際上,這種表述是不準確的。數組是適合查找操作,但是查找的時間複雜度並不爲 O(1)。即便是排好序的數組,你用二分查找,時間複雜度也是 O(logn)。所以,正確的表述應該是,數組支持隨機訪問,根據下標隨機訪問的時間複雜度爲 O(1)

二、插入和刪除爲什麼低效?

2.1 插入操作

假設數組的長度爲 n,現在,如果我們需要將一個數據插入到數組中的第 k 個位置。爲了把第 k 個位置騰出來,給新來的數據,我們需要將第 k~n 這部分的元素都順序地往後挪一位。那插入操作的時間複雜度是多少呢?你可以自己先試着分析一下。

如果在數組的末尾插入元素,那就不需要移動數據了,這時的時間複雜度爲 O(1)。但如果在數組的開頭插入元素,那所有的數據都需要依次往後移動一位,所以最壞時間複雜度是 O(n)。 因爲我們在每個位置插入元素的概率是一樣的,所以平均情況時間複雜度爲 (1+2+…n)/n=O(n)。

如果數組中的數據是有序的,我們在某個位置插入一個新的元素時,就必須按照剛纔的方法搬移 k 之後的數據。但是,如果數組中存儲的數據並沒有任何規律,數組只是被當作一個存儲數據的集合。在這種情況下,如果要將某個數據插入到第 k 個位置,爲了避免大規模的數據搬移,我們還有一個簡單的辦法就是,直接將第 k 位的數據搬移到數組元素的最後,把新的元素直接放入第 k 個位置。

利用這種處理技巧,在特定場景下,在第 k 個位置插入一個元素的時間複雜度就會降爲 O(1)。這個處理思想在快排中也會用到。

2.2 刪除操作

跟插入數據類似,如果我們要刪除第 k 個位置的數據,爲了內存的連續性,也需要搬移數據,不然中間就會出現空洞,內存就不連續了。

和插入類似,如果刪除數組末尾的數據,則最好情況時間複雜度爲 O(1);如果刪除開頭的數據,則最壞情況時間複雜度爲 O(n);平均情況時間複雜度也爲 O(n)。

實際上,在某些特殊場景下,我們並不一定非得追求數組中數據的連續性。如果我們將多次刪除操作集中在一起執行,刪除的效率是不是會提高很多呢?

我們繼續來看例子。數組 a[10]中存儲了 8 個元素:a,b,c,d,e,f,g,h。現在,我們要依次刪除 a,b,c 三個元素。
在這裏插入圖片描述
爲了避免 d,e,f,g,h 這幾個數據會被搬移三次,我們可以先記錄下已經刪除的數據。每次的刪除操作並不是真正地搬移數據,只是記錄數據已經被刪除。當數組沒有更多空間存儲數據時,我們再觸發執行一次真正的刪除操作,這樣就大大減少了刪除操作導致的數據搬移。

如果你瞭解 JVM,你會發現,這就是 JVM 標記清除垃圾回收算法的核心思想.

三、容器是否可以取代數組?

針對數組類型,很多語言都提供了容器類,比如 Java 中的 ArrayList。在項目開發中,什麼時候適合用數組,什麼時候適合用容器呢?

拿 Java 舉例,幾乎天天都在用 ArrayList,對它應該非常熟悉。那它與數組相比,到底有哪些優勢呢?

我個人覺得,ArrayList 最大的優勢就是可以將很多數組操作的細節封裝起來。比如前面提到的數組插入、刪除數據時需要搬移其他數據等。另外,它還有一個優勢,就是支持動態擴容。

數組本身在定義的時候需要預先指定大小,因爲需要分配連續的內存空間。如果我們申請了大小爲 10 的數組,當第 11 個數據需要存儲到數組中時,我們就需要重新分配一塊更大的空間,將原來的數據複製過去,然後再將新的數據插入。

如果使用 ArrayList,我們就完全不需要關心底層的擴容邏輯,ArrayList 已經幫我們實現好了。每次存儲空間不夠的時候,它都會將空間自動擴容爲 1.5 倍大小。

不過,這裏需要注意一點,因爲擴容操作涉及內存申請和數據搬移,是比較耗時的。所以,如果事先能確定需要存儲的數據大小,最好在創建 ArrayList 的時候事先指定數據大小。

比如我們要從數據庫中取出 10000 條數據放入 ArrayList。我們看下面這幾行代碼,你會發現,相比之下,事先指定數據大小可以省掉很多次內存申請和數據搬移操作。

ArrayList<User> users = new ArrayList(10000);
for (int i = 0; i < 10000; ++i) {
  users.add(xxx);
}

作爲高級語言編程者,是不是數組就無用武之地了呢?當然不是,有些時候,用數組會更合適些,我總結了幾點自己的經驗。

  1. Java ArrayList 無法存儲基本類型,比如 int、long,需要封裝爲 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的性能消耗,所以如果特別關注性能,或者希望使用基本類型,就可以選用數組。

  2. 如果數據大小事先已知,並且對數據的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用數組。

  3. 還有一個是我個人的喜好,當要表示多維數組時,用數組往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList <ArrayList< Object >> array。

我總結一下,對於業務開發,直接使用容器就足夠了,省時省力。畢竟損耗一丟丟性能,完全不會影響到系統整體的性能。但如果你是做一些非常底層的開發,比如開發網絡框架,性能的優化需要做到極致,這個時候數組就會優於容器,成爲首選。

四、爲什麼數組要從0開始編號,而不是從1開始呢?

從數組存儲的內存模型上來看,“下標”最確切的定義應該是“偏移(offset)”。前面也講到,如果用 a 來表示數組的首地址,a[0]就是偏移爲 0 的位置,也就是首地址,a[k]就表示偏移 k 個 type_size 的位置,所以計算 a[k]的內存地址只需要用這個公式:

a[k]_address = base_address + k * type_size

但是,如果數組從 1 開始計數,那我們計算數組元素 a[k]的內存地址就會變爲:

a[k]_address = base_address + (k-1)*type_size

對比兩個公式,我們不難發現,從 1 開始編號,每次隨機訪問數組元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。

數組作爲非常基礎的數據結構,通過下標隨機訪問數組元素又是其非常基礎的編程操作,效率的優化就要儘可能做到極致。所以爲了減少一次減法操作,數組選擇了從 0 開始編號,而不是從 1 開始。

不過我認爲,上面解釋得再多其實都算不上壓倒性的證明,說數組起始編號非 0 開始不可。所以我覺得最主要的原因可能是歷史原因,C語言如此,所以後輩們都如此

五、經典leetcode題(數組)

如果你想看更多的解答,更好的解答,可以直接訪問Leetcode,具體的鏈接如下,這裏顯示的是一種我個人能想到的解法,和我能接受的更好的解法,有些很好但是腦洞太大的解法不會列出,大部分時候看到這種這種解法。

5.1 求三數之和(Three Sum)

5.1.1 題目

給你一個包含 n 個整數的數組 nums,判斷 nums 中是否存在三個元素 a,b,c ,使得 a + b + c = 0 ?請你找出所有滿足條件且不重複的三元組。
注意:
答案中不可以包含重複的三元組。
示例:

給定數組 nums = [-1, 0, 1, 2, -1, -4],
滿足要求的三元組集合爲:
[
  [-1, 0, 1],
  [-1, -1, 2]
]

5.1.2 自己的解法-暴力解法

/**
* 循環三次,結果集使用了set集合,避免了返回值重複
*/
private List<List<Integer>> directlySolution(int[] nums) {
    if (nums == null || nums.length <= 2) {
        return Collections.emptyList();
    }
    Arrays.sort(nums);
    //set集合去重
    Set<List<Integer>> result = new LinkedHashSet<>();
    for (int i = 0; i < nums.length; i++) {
        for (int j = i+1; j < nums.length; j++) {
            for (int k = j+1; k < nums.length; k++) {
                if (nums[i] + nums[j] + nums[k] == 0) {
                    List<Integer> value = Arrays.asList(nums[i], nums[j], nums[k]);
                    result.add(value);
                }
            }
        }
    }

    return new ArrayList<>(result);
}

但是在leetcode裏超時了,哎

5.1.3 優秀解法

思路
在這裏插入圖片描述

class Solution {
    public static List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> ans = new ArrayList();
        int len = nums.length;
        if(nums == null || len < 3) return ans;
        Arrays.sort(nums); // 排序
        for (int i = 0; i < len ; i++) {
            if(nums[i] > 0) break; // 如果當前數字大於0,則三數之和一定大於0,所以結束循環
            if(i > 0 && nums[i] == nums[i-1]) continue; // 去重
            int L = i+1;
            int R = len-1;
            while(L < R){
                int sum = nums[i] + nums[L] + nums[R];
                if(sum == 0){
                    ans.add(Arrays.asList(nums[i],nums[L],nums[R]));
                    while (L<R && nums[L] == nums[L+1]) L++; // 去重
                    while (L<R && nums[R] == nums[R-1]) R--; // 去重
                    L++;
                    R--;
                }
                else if (sum < 0) L++;
                else if (sum > 0) R--;
            }
        }        
        return ans;
    }
}

嗯,看了別人的解法,感覺自己像個傻比

5.2 求衆數(Majority Element)

給定一個大小爲 n 的數組,找到其中的多數元素。多數元素是指在數組中出現次數大於 ⌊ n/2 ⌋ 的元素。
你可以假設數組是非空的,並且給定的數組總是存在多數元素。

示例 1:
輸入: [3,2,3]
輸出: 3

5.2.1 自己的解法-排序法

對數組進行排序之後,n/2位置的數據就是多數元素

class Solution {
    public int majorityElement(int[] nums) {
        Arrays.sort(nums);
        return nums[nums.length >> 1];
    }
}

5.2.2 優秀解法-摩爾投票法

在這裏插入圖片描述

class Solution {
    public int majorityElement(int[] nums) {
        int cand_num = nums[0], count = 1;
        for (int i = 1; i < nums.length; ++i) {
            if (cand_num == nums[i])
                ++count;
            else if (--count == 0) {
                cand_num = nums[i];
                count = 1;
            }
        }
        return cand_num;
    }
}

5.3 求缺失的第一個正數(Missing Positive)

給你一個未排序的整數數組,請你找出其中沒有出現的最小的正整數。
提示:
你的算法的時間複雜度應爲O(n),並且只能使用常數級別的額外空間。

示例 1:
輸入: [1,2,0]
輸出: 3

示例 2:
輸入: [3,4,-1,1]
輸出: 2

示例 3:
輸入: [7,8,9,11,12]
輸出: 1

5.3.1 自己的解法-遞歸實現

這種解法是自己想出來的,看leetcode好像沒有這種解法,雖然效率不高,時間複雜度也沒有滿足要求
(1)遍歷數組,如果數據中存在小於0,或者大於nums.length的數,則剔除
(2)遍歷方法,直到數組中的個數達到穩定(剩餘數組個數不會發生變化 或者 爲0)

public int firstMissingPositive(int[] nums) {
   Set<Integer> set = new HashSet<>();
   return recursive(IntStream.of(nums).boxed().collect(Collectors.toList()).toArray(new Integer[0]),set) + 1;
}

/**
 1. 遞歸實現
 2. @param nums 數組
*/
public Integer recursive(Integer[] nums,Set<Integer> set) {
	set.clear();
	int length = nums.length;
	//遍歷數組,如果數據中存在小於0,或者大於nums.length的數,則剔除
	for (int i = 0; i < length; i++) {
	if (!(nums[i] <= 0 || nums[i] > length)) {
	    set.add(nums[i]);
	}
	}
	//直到數組中的個數達到穩定(剩餘數組個數不會發生變化 或者 爲0)
	if (!(set.size() == nums.length || set.size() == 0)) {
		recursive(set.toArray(new Integer[set.size()]), set);
	}
	return  set.size();
}

5.3.2 優秀解法-存放對應的位置

我們還可以把每個元素存放到對應的位置,比如1存放到數組的第一個位置,3存放到數組的第3個位置,
如果是非正數或者大於數組的長度的值,我們不做處理,最後在遍歷一遍數組,如果位置不正確,說明這個位置沒有這個數,我們就直接返回,我們畫個圖看一下。
在這裏插入圖片描述

    public int firstMissingPositive(int[] nums) {
        for (int i = 0; i < nums.length; i++) {
            //如果在指定的位置就不需要修改
            if (i + 1 == nums[i])
                continue;
            int x = nums[i];
            if (x >= 1 && x <= nums.length && x != nums[x - 1]) {
                swap(nums, i, x - 1);
                i--;//抵消上面的i++,如果交換之後就不++;
            }
        }
        //最後在執行一遍循環,查看對應位置的元素是否正確,如果不正確直接返回
        for (int i = 0; i < nums.length; i++) {
            if (i + 1 != nums[i])
                return i + 1;
        }
        return nums.length + 1;
    }

    //交換兩個數的值
    public void swap(int[] A, int i, int j) {
        if (i != j) {
            A[i] ^= A[j];
            A[j] ^= A[i];
            A[i] ^= A[j];
        }
    }

六、主要參考鏈接

  1. 極客時間 王爭《數據結構與算法之美》.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章