《算法新解》读记(一)
这是一本什么书?
最早是在图灵社区看到今年年初这本书的问世,作者刘新宇获得清华大学自动化系学士和硕士学位,长期从事软件研发,关注基本算法和数据结构,尤其是函数式算法,目前就职于亚马逊中国的仓储和物流技术团队。
直到过年期间,和朋友一起逛上海书城,看到了实体书,便随手买了一本。至今也有十来天,稍稍有选择性地读了一点篇章。整体感觉,这本书还是可以用「惊艳」一次来形容的。不同于学生时代我看过的一些面向算法竞赛选手的书籍或者是面向高校学生的数据结构与算法读物,这本书可谓是将两者的特性柔和在了一起,既涵盖了那些经典的数据结构与算法,如红黑树、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 }
一个更好的分治解法
实际上,上面的基于递归的分治是可以改造为迭代来处理的,用迭代取代递归的优势在于节省递归调用的时间和空间开销,但往往会导致代码的可读性下降。关于使用迭代的解法,由于与递归解法大体相似,不再冗述。