這是一本什么書?
最早是在圖靈社區看到今年年初這本書的問世,作者劉新宇獲得清華大學自動化系學士和碩士學位,長期從事軟件研發,關注基本算法和數據結構,尤其是函數式算法,目前就職於亞馬遜中國的倉儲和物流技術團隊。
直到過年期間,和朋友一起逛上海書城,看到了實體書,便隨手買了一本。至今也有十來天,稍稍有選擇性地讀了一點篇章。整體感覺,這本書還是可以用「驚艷」一次來形容的。不同於學生時代我看過的一些面向算法競賽選手的書籍或者是面向高校學生的數據結構與算法讀物,這本書可謂是將兩者的特性柔和在了一起,既涵蓋了那些經典的數據結構與算法,如紅黑樹、AVL樹、Trie、B樹、二叉堆、快速排序、歸並排序等,也有一些高級主題如Patricia、后綴樹、左偏堆、手指樹、斐波那契堆(可怕)等。不過像是90年代發明的跳躍表此書倒是沒提及。
相比較傳統的使用C/C++, Java這樣的命令式編程語言來講解算法與數據結構,這本書中的主要語言是Haskell。Haskell是一種非常純的函數式編程語言,在大學的時候選修過,可惜當時這門課是混過去的,完全不敢說會Haskell,只能說能看懂點。
話休煩絮,此書前言直接甩了兩道算法題過來讓讀者見識下算法的威力。
下面就談談書中前言介紹的其中一題:
最小可用ID
這題的背景是系統中需要使用非負整數作為ID,用戶的ID具有唯一性,系統中有若干ID,需要尋找出一個最小的可以使用的ID。
可能熟悉博弈論的同學都比較容易想到,這個就是SG函數(Sprague-Grundy Function)中的mex運算(minimal excluded)。
此書針對這一問題也給出了多種算法進行比較,由於我工作語言目前是Java而不同於書中的Python或者Haskell,所以下面我會貼出本問題我的Java代碼。
朴素解法
朴素解法很容易實現,直接O(n^2),遍歷每個自然數,掃描數組,判斷是否在數組中,不在則返回答案。此解不需要貼出代碼了。
一個線性解法
基於兩個事實:1.數組中的元素都是非負整數。 2.答案必然落在[0, n]的區間,其中n為數組長度
所以事實上可以用一個bool數組記錄數組中每個數字的出現情況,bool數組的長度可以取n+1,這樣的話在原數組剛好包含了某個0到n-1的排列的情況下,也可以歸一化處理,而無需特判。
1 public class MinFreeProblemSolver { 2 public static int solve(int[] numbers) { 3 boolean[] occurrence = new boolean[numbers.length]; 4 for (int number : numbers) { 5 if (number < numbers.length) { 6 occurrence[number] = true; 7 } 8 } 9 for (int i = 0; i < numbers.length; i++) { 10 if (!occurrence[i]) { 11 return i; 12 } 13 } 14 return numbers.length; 15 } 16 }
一個更好的線性解法
考慮到在例如Java語言中,通常boolean類型在作為數組時,數組中每個boolean值占用1個字節的空間。實際上對於這樣用於標記某個不是太大的非負整數的存在性,可以采用壓位存儲的方法來節省空間。這樣的話一個字節可以存儲8個數字的存在性,這節約了相當多的空間。對於C++或者Java,bitset/BitSet已經封裝好了標記/清除/翻轉/獲取某一位的api。
這個東西也被稱為位圖BitMap,意思差不多。
代碼如下。
1 public class MinFreeProblemSolver { 2 public static int solve(int[] numbers) { 3 BitSet occurrence = new BitSet(numbers.length); 4 for (int number : numbers) { 5 if (number < numbers.length) { 6 occurrence.set(number); 7 } 8 } 9 return occurrence.nextClearBit(0); 10 } 11 }
一個基於分治的解法
實際上這個問題也可以通過分治法來解,假設將數組劈成兩個子數組A和B,使得數組A的元素都小於等於分割值,假設A中的元素個數是原來數組的長度的一半,則說明需要在B數組中尋找最小可用整數,處理B數組,否則處理A數組。
1 public class MinFreeProblemSolver { 2 public static int solve(int[] numbers) { 3 return solve(numbers, 0, numbers.length - 1); 4 } 5 6 private static int solve(int[] numbers, int fromIndex, int endIndex) { 7 if (fromIndex == endIndex) { 8 return numbers[fromIndex] == fromIndex ? fromIndex + 1 : fromIndex; 9 } 10 int pivot = (fromIndex + endIndex) >>> 1; 11 int left = doPartition(numbers, fromIndex, endIndex, pivot); 12 if (left == pivot + 1) { 13 return solve(numbers, pivot + 1, endIndex); 14 } else { 15 return solve(numbers, fromIndex, pivot); 16 } 17 } 18 19 private static int doPartition(int[] numbers, int fromIndex, int endIndex, int pivot) { 20 int left = fromIndex; 21 for (int right = fromIndex; right <= endIndex; right++) { 22 if (numbers[right] <= pivot) { 23 int temp = numbers[right]; 24 numbers[right] = numbers[left]; 25 numbers[left++] = temp; 26 } 27 } 28 return left; 29 } 30 }
一個更好的分治解法
實際上,上面的基於遞歸的分治是可以改造為迭代來處理的,用迭代取代遞歸的優勢在於節省遞歸調用的時間和空間開銷,但往往會導致代碼的可讀性下降。關於使用迭代的解法,由於與遞歸解法大體相似,不再冗述。
