5 种方法求解 TopK!面试不要再问我 Topk 了~
|
前言:本文将介绍随机选择,分治法,减治法的思想,以及 TopK 问题优化的来龙去脉,原理与细节,保证有收获。
面试中,TopK,是问得比较多的几个问题之一,到底有几种方法,这些方案里蕴含的优化思路究竟是怎么样的,今天和大家聊一聊。 _ 画外音:_除非校招,我在面试过程中从不问 TopK 这个问题,默认大家都知道。
问题描述: 从 arr[1, n] 这 n 个数中,找出最大的 k 个数,这就是经典的 TopK 问题。
栗子: 从 arr[1, 12]={5,3,7,1,8,2,9,4,7,2,6,6} 这 n=12 个数中,找出最大的 k=5 个。
一、排序
排序是最容易想到的方法,将 n 个数排序之后,取出最大的 k 个,即为所得。
** 分析:明明只需要 TopK,却将全局都排序了,这也是这个方法复杂度非常高的原因。那能不能不全局排序,而只局部排序呢?这就引出了第二个优化方法。
二、局部排序 不再全局排序,只对最大的 k 个排序。
冒泡是一个很常见的排序方法,每冒一个泡,找出最大值,冒 k 个泡,就得到 TopK。
伪代码:
时间复杂度:O(n*k)
分析:冒泡,将全局排序优化为了局部排序,非 TopK 的元素是不需要排序的,节省了计算资源。不少朋友会想到,需求是 TopK,是不是这最大的 k 个元素也不需要排序呢?这就引出了第三个优化方法。
三、堆 思路:只找到 TopK,不排序 TopK。
先用前 k 个元素生成一个小顶堆,这个小顶堆用于存储,当前最大的 k 个元素。
接着,从第 k+1 个元素开始扫描,和堆顶(堆中最小的元素)比较,如果被扫描的元素大于堆顶,则替换堆顶的元素,并调整堆,以保证堆内的 k 个元素,总是当前最大的 k 个元素。
直到,扫描完所有 n-k 个元素,最终堆中的 k 个元素,就是猥琐求的 TopK。
伪代码:
时间复杂度:O(n*lg(k)) 画外音:n 个元素扫一遍,假设运气很差,每次都入堆调整,调整时间复杂度为堆的高度,即 lg(k),故整体时间复杂度是 n*lg(k)。
分析:堆,将冒泡的 TopK 排序优化为了 TopK 不排序,节省了计算资源。堆,是求 TopK 的经典算法,那还有没有更快的方案呢?
四、随机选择 随机选择算在是《算法导论》中一个经典的算法,其时间复杂度为 O(n),是一个线性复杂度的方法。
这个方法并不是所有同学都知道,为了将算法讲透,先聊一些前序知识,一个所有程序员都应该烂熟于胸的经典算法:快速排序。 画外音: (1)如果有朋友说,“不知道快速排序,也不妨碍我写业务代码呀”…额… (2)除非校招,我在面试过程中从不问快速排序,默认所有工程师都知道;
其伪代码是:
其核心算法思想是,分治法。
分治法(Divide&Conquer),把一个大的问题,转化为若干个子问题(Divide),每个子问题“都”解决,大的问题便随之解决(Conquer)。这里的关键词是 **“都”**。从伪代码里可以看到,快速排序递归时,先通过 partition 把数组分隔为两个部分,两个部分“都”要再次递归。
分治法有一个特例,叫减治法。
减治法(Reduce&Conquer),把一个大的问题,转化为若干个子问题(Reduce),这些子问题中“只”解决一个,大的问题便随之解决(Conquer)。这里的关键词是 **“只”**。
二分查找 binary_search,BS,是一个典型的运用减治法思想的算法,其伪代码是:
从伪代码可以看到,二分查找,一个大的问题,可以用一个 mid 元素,分成左半区,右半区两个子问题。而左右两个子问题,只需要解决其中一个,递归一次,就能够解决二分查找全局的问题。
通过分治法与减治法的描述,可以发现,分治法的复杂度一般来说是大于减治法的: 快速排序:O(n*lg(n)) 二分查找:O(lg(n))
话题收回来,快速排序的核心是: i = partition(arr, low, high);
这个 partition 是干嘛的呢? 顾名思义,partition 会把整体分为两个部分。 更具体的,会用数组 arr 中的一个元素(默认是第一个元素 t=arr[low])为划分依据,将数据 arr[low, high] 划分成左右两个子数组:
以上述 TopK 的数组为例,先用第一个元素 t=arr[low] 为划分依据,扫描一遍数组,把数组分成了两个半区:
partition 返回的是 t 最终的位置 i。
很容易知道,partition 的时间复杂度是 O(n)。 画外音:把整个数组扫一遍,比 t 大的放左边,比 t 小的放右边,最后 t 放在中间 N[i]。
partition 和 TopK 问题有什么关系呢? TopK 是希望求出 arr[1,n] 中最大的 k 个数,那如果找到了第 k 大的数,做一次 partition,不就一次性找到最大的 k 个数了么? 画外音:即 partition 后左半区的 k 个数。
问题变成了 arr[1, n] 中找到第 k 大的数。
再回过头来看看第一次partition,划分之后: i = partition(arr, 1, n);
画外音:这一段非常重要,多读几遍。
这是一个典型的减治算法,递归内的两个分支,最终只会执行一个,它的时间复杂度是 O(n)。
再次强调一下:
通过随机选择(randomized_select),找到 arr[1, n] 中第 k 大的数,再进行一次 partition,就能得到 TopK 的结果。
五、总结 TopK,不难;其思路优化过程,不简单:
知其然,知其所以然。 思路比结论重要。 希望大家对 TopK 有新的认识,谢转。 挖坑:TopK,你以为这就是最快的解法?太小看架构师之路了,更快方案,且听下一期分解。 其中随机选择 (randomized select) 最为经典,用减治法 (Reduce & Conquer) 的思想,将数据规模急速降低,总体复杂度为 O(n)。 结尾挖了一个坑:求 TopK,有没有比随机选择更快的方法呢? 空间换时间,是算法优化中最常见的手段,如果有相对充裕的内存,可以有更快的算法。 画外音:即使内存不够,也可以水平切分,使用分段的方法来操作,减少每次内存使用量。 TopK 问题描述 从 arr[1, 12]={5,3,7,1,8,2,9,4,7,2,6,6} 这 n=12 个数中,找出最大的 k=5 个。 比特位图(bitmap)法 bitmap,是空间换时间的典型代表。它是一种,用若干个 bit 来表示集合的数据结构。 例如,集合 S={1,3,5,7,9},容易发现,S 中所有元素都在 1-16 之间,于是,可以用 16 个 bit 来表示这个集合:存在于集合中的元素,对应 bit 置 1,否则置 0。 画外音:究竟需要多少存存储空间,取决于集合中元素的值域,在什么范围之内。 上述集合 S,可以用 1010101010000000 这样一个 16bit 的 bitmap 来表示,其中,第 1, 3, 5, 7, 9 个 bit 位置是 1。
假设 TopK 的 n 个元素都是 int,且元素之间没有重复,只需要申请 2^32 个 bit,即 4G 的内存,就能够用 bitmap 表示这 n 元素。 扫描一次所有 n 个元素,以生成 bitmap,其时间复杂度是 O(n)。生成后,取 TopK 只需要找到最高位的 k 个 bit 即可。算法总时间复杂度也是 O(n)。 伪代码为: bitmap[4G] = make_bitmap(arr[1, n]); return bitmap[top k bits];
bitmap 算法有个缺点,如果集合元素有重复,相同的元素会被去重,假设集合 S 中有 5 个 1,最终 S 制作成 bitmap 后,这 5 个 1 只对应 1 个 bit 位,相当于 4 个元素被丢掉了,这样会导致,找到的 TopK 不准。该怎么优化呢? 比特位图计数 优化方法是,每个元素的 1 个 bit 变成 1 个计数。
如上图所示,TopK 的集合经过比特位图计数处理后,会记录每个 bit 对应在集合 S 中出现过多少次。 接下来,找 TopK 的过程,就是 bitmap 从高位的计数开始,往低位的计数扫描,得到 count 之和等于 k,对应的 bit 就是 TopK 所求。
如上图所示,k=5: (1)第一个非 0 的 count 是 1,对应的 bit 是 9; (2)第二个非 0 的 count 也是 1,对应的 bit 是 8; (3)第三个非 0 的 count 是 2,对应的 bit 是 7; (4)第四个非 0 的 count 是 2,对应的 bit 是 6,但 TopK 只缺 1 个数字了,故只有 1 个 6 入选; 故,最终的 TopK={9, 8, 7, 7, 6}。 结论:通过比特位图精准计数的方式,求解 TopK,算法整体只需要不到 2 次扫描,时间复杂度为 O(n),比减治法的随机选择会更快。 为了巩固今天的内容,例行挖个坑。 面试中,还有个问题问得比较多:求一个正整数的二进制表示包含多少个 1? 例如:7 的二进制表示是 111,即 7 的二进制表示包含 3 个 1。 画外音:我面试过程中从不问这个问题。 最常见的解法是:
|
时间:2018-09-27 23:32 来源: 转发量:次
声明:本站部分作品是由网友自主投稿和发布、编辑整理上传,对此类作品本站仅提供交流平台,转载的目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,不为其版权负责。如果您发现网站上有侵犯您的知识产权的作品,请与我们取得联系,我们会及时修改或删除。