1. 程式人生 > >LeetCode 41,一題解讀in-place思想

LeetCode 41,一題解讀in-place思想

本文始發於個人公眾號:**TechFlow**,原創不易,求個關注

今天是LeetCode題解系列第21篇,今天來看一道人狠話不多的題目。

題面

題目非常簡單,只有一句話,給定一個整數陣列,要求返回最小的不在陣列當中的正整數。

看起來有些拗口,簡單解釋一下。我們都知道正整數就是從1開始的整數,所以這道題就是從1開始找到第一個不在陣列當中的元素。我們來看下樣例:

樣例 1:

Input: [1,2,0]
Output: 3

樣例 2:

Input: [3,4,-1,1]
Output: 2

樣例 3:

Input: [7,8,9,11,12]
Output: 1

注意:

演算法的時間複雜度必須是 ,並且只能使用 的儲存空間。

分析

在注意出來之前,我們可能覺得這道題也不是那麼難,很容易就想到解法,但是有了這兩條限制之後就沒那麼簡單了。我們遍歷陣列就需要

的複雜度了,怎麼還能找出最小未出現的元素呢?而且還不能申請額外的陣列,只能用常數級的儲存,顯然各種輔助陣列和容器是不能用了。

我們直接這麼苦苦思索是很難想出解法的,不如來循序漸進。

我們先來假設沒有這些限制條件的話應該用什麼方法,最容易想到的應該是排序。我們將陣列排序,一旦陣列有序了之後就方便了。我們從小到大遍歷,很容易就確定哪些元素出現過哪些元素沒有。那麼想要找出來不在陣列當中的最小自然數自然也是輕而易舉。分析一下排序我們可以發現,在此過程當中我們並沒有用到額外的空間,唯一不滿足條件的只有我們的時間複雜度是 而不是

我們寫下程式碼:

class Solution:
def firstMissingPositive(self, nums: List[int]) -> int:
nums = sorted(nums)
if len(nums) == 0 or nums[0] > 1:
return 1

mark = 1
for i in nums:
if i == mark:
mark += 1

return mark

那我們反過來,如果保證空間可以隨意使用,但是對時間複雜度進行限制,我們能想到什麼辦法呢?

應該也很容易想出來,就是引入額外的容器。比如hashset。hashset的增刪改查的複雜度都可以近似看成是常數級。我們只需要遍歷一次陣列,將所有元素插入hashset當中,同時記錄下元素的最大最小值,最後遍歷一下最小值和最大值這個區間,找出不在hashset當中最小的元素即可。n個元素的陣列我們可以很容易證明,我們一定可以在n次查詢以內找到不在陣列當中的自然數。

這段程式碼也不難寫:

class Solution:
def firstMissingPositive(self, nums: List[int]) -> int:
st = set()
if len(nums) == 0:
return 1

mini, maxi = 3e9, -3e9

# 插入set當中維護
for i in nums:
st.add(i)
mini = min(mini, i)
maxi = max(maxi, i)

# 從1開始找到第一個不在set當中的元素
# 由於nums只有n個元素,我們可以可以在n次遍歷當中找到
for i in range(1, maxi):
if i not in st:
return i

# 如果從1到maxi都存在,那麼就放回maxi+1和1的最大值
# 因為如果maxi小於1,那麼上面的迴圈不會執行,所以要和1取最大值
return max(maxi+1, 1)

in-place

上面的兩種做法一種進行了高複雜度的排序,另一種則用到了額外的儲存。看起來這是一個兩難問題,我們不想排序就需要用到儲存,如果不想用儲存呢,那麼則需要元素有序。我們仔細分析一下這兩種情況,就可以找到問題的癥結了,我們有沒有什麼辦法可以兩全其美,既不用額外的儲存又可以保證元素的有序呢?如果我們可以找到一種方法,那麼這個問題就解決了。

這也是我們解題的時候的一個常規的套路,就是對於一些題目而言有一些演算法是比較明顯的,但是可能因為這樣或那樣的限制使得並不能應用在當前的問題當中。但是沒關係,我們一樣可以往這方面去想,先找到一個不那麼合適的解法,在此基礎上謀求突破,很多時候要比憑空想出一個完美的方法來容易許多。

那麼我們怎麼突破呢?

還要從題目的要求入手,題目當中規定只能使用常數的儲存空間,意味著我們不能額外開闢陣列或者其他容器來儲存資料。有經驗的同學可能已經反映過來了,這是in-place的套路。

in-place並不是一個演算法,而是一種思想。它出現的原因也非常簡單,因為我們申請陣列等容器的時候需要通過作業系統向記憶體申請連續的記憶體,這會涉及到一系列記憶體管理演算法的執行,所以是需要消耗大量時間的。所以在一些高效能的場景下,我們會希望儘量避免空間申請操作。

比如我們想要對陣列進行排序,我們直接呼叫sorted方法的時候,其實在函式內部對陣列進行了拷貝,最後返回的其實是拷貝陣列排序之後的結果。也就是說我們獲得的是一個新的陣列,只是其中的元素和原來一模一樣。而如果是in-place的方法,我們則不會另外建立陣列,而在原陣列上進行修改。

非in-place的介面不會修改原值,這方便我們追蹤資料的變化,以及撤銷操作。比如Python機器學習領域的大量numpy和pandas的介面預設都不是in-place的,就是這個原因。而in-place的則相反,由於它會直接修改原值,所以如果我們一旦執行錯了,無法撤銷,原資料就找不回了。比如我們排序錯了,明明要降序,不小心排成了升序,一旦執行就無法還原了。但是和非in-place相比,它的耗時更少,也更節約記憶體。

這題其實已經暗示得很明顯了,我們需要儲存資料,但是又不讓我們申請空間,於是我們只有in-place一條路可以走了。

我們需要設計一個in-place的演算法,讓我們可以判斷元素的存在性。再加上題目中的限制是正整數,而且我們要找的是第一個沒有出現的正整數。如果陣列的長度是n,那麼其實我們可以鎖定,答案一定在[1, n+1]之間。原因也很簡單,因為最理想的情況是這個陣列當中的n個元素剛好是1到n,這樣我們從1開始遍歷,一直找到n就能得到答案是n+1。否則的話,我們一定可以在遍歷到n+1之前就找到答案,所以綜合一下,答案一定在[1, n+1]之間。如果我們能把這個區間寫出來,其實解法已經就在我們眼前了。

既然答案在區間[1, n+1]中間,我們又需要設計一個in-place的方法,那麼我們可以很正常地想到,我們可以將數字放到對應的下標當中去。1放到下標1當中,0放到0當中。

比如[3, 1, 0, 5],我們拿到第一個元素是3,我們把它放到它應該在的位置,也就是5的位置下去,這個時候我們再來放5,由於5超過了陣列的長度,所以進行丟棄。我們往下重複如上的過程,到最後的時候,我們得到的資料情況如下:[0, 1, 5, 3],我們遍歷一下陣列,發現和下標不匹配的位置就是5,它應該對應的資料是2,所以2就是答案。

我一開始是先想到的演算法,幾乎是憑空想出來的,沒有前後推導的過程,覺得非常驚豔,有種天馬行空的感覺。後來關聯上的in-place思想之後,才發現隱藏的思路其實非常合情合理。思路有了,程式碼真的很簡單:

class Solution:
def firstMissingPositive(self, nums: List[int]) -> int:
n = len(nums)
# 因為是正整數,所以陣列長度需要擴大1
nums.append(0)

for i in range(n):
if i == nums[i]:
continue

while True:
# 不停地交換元素,直到範圍超界或者是已經放好了為止
# 需要考慮nums[i] 和 nums[nums[i]]相等的情況,這時候也不應該交換
val = nums[i]
if val > n or val < 0 or val == i or val == nums[val]:
break
nums[i], nums[val] = nums[val], val


for i in range(1, n+1):
if i != nums[i]:
return i

return n+1

最後,我們來分析一下這個演算法的複雜度,為什麼我們在一重迴圈當中還套了一個while迴圈,但是它仍然是 的演算法呢?

這個問題我們之前在介紹two pointers和尺取法的時候就曾經介紹過,我們在分析複雜度的時候不能只簡單地看有幾重迴圈,我們需要細緻地分析。我們要忽略迴圈,回到問題的本質。我們用迴圈的本質是為了能夠讓每個元素放到對應的位置,一共需要安排的元素數量是固定的是n個,位置也是固定的是n個,一個元素只有一個位置。那麼我們一次交換至少可以讓一個元素放到正確的位置,那麼問題來了,我們想要把所有元素放置好,需要迴圈多少次?

我這樣問,大家應該很清楚,一次最少放一個,一共n個,顯然最多放n次。那我們再看while迴圈當中,每執行一次,不就是放好了一個元素嗎?外圍的迴圈只是用來列舉元素的,並不會引入額外的計算,所以這當然是一個 的演算法。

最後,今天的題目官方標的難度是Hard,題目本身不難,由於加上了很多限制才提升了難度。今天的題目沒有用到新的演算法,純粹是對思維和邏輯的考驗。也因此,我覺得它是一道非常純粹的題,純粹在於它並用不到新的演算法,也用不到新的資料結構,就是考察我們分析問題和思考問題的能力。而許多問題則針對性很強,如果之前沒有學過對應的演算法則無法做得出來,所以從這點上來說這題更加公平,非常適合面試。我已經進行了預約,以後如果有面試機會,我可能會問候選人這個問題。

今天的文章就是這些,如果覺得有所收穫,請順手點個關注或者轉發吧,你們的舉手之勞對我來說很重要。

![](https://img2020.cnblogs.com/blog/1906483/202003/1906483-20200316085914343-841407199.png)