Top-K 问题

    技术2025-03-13  36

    面试中,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个,即为所得。

    伪代码:

     

    sort(arr, 1, n);

    return arr[1, k];

    时间复杂度:O(n*lg(n))

    分析:明明只需要TopK,却将全局都排序了,这也是这个方法复杂度非常高的原因。那能不能不全局排序,而只局部排序呢?这就引出了第二个优化方法。

    二、局部排序

    不再全局排序,只对最大的k个排序。

    冒泡是一个很常见的排序方法,每冒一个泡,找出最大值,冒k个泡,就得到TopK。

    伪代码:

     

    for(i=1 to k){

    bubble_find_max(arr,i);

    }

    return arr[1, k];

     

    时间复杂度:O(n*k)

    分析:冒泡,将全局排序优化为了局部排序,非TopK的元素是不需要排序的,节省了计算资源。不少朋友会想到,需求是TopK,是不是这最大的k个元素也不需要排序呢?这就引出了第三个优化方法。

    三、堆

    思路:只找到TopK,不排序TopK。

    先用前k个元素生成一个小顶堆,这个小顶堆用于存储,当前最大的k个元素。

    接着,从第k+1个元素开始扫描,和堆顶(堆中最小的元素)比较,如果被扫描的元素大于堆顶,则替换堆顶的元素,并调整堆,以保证堆内的k个元素,总是当前最大的k个元素。

    直到,扫描完所有n-k个元素,最终堆中的k个元素,就是猥琐求的TopK。

    伪代码:

     

    heap[k] = make_heap(arr[1, k]);

    for(i=k+1 to n){

    adjust_heap(heep[k],arr[i]);

    }

    return heap[k];

     

    时间复杂度:O(n*lg(k))

    画外音:n个元素扫一遍,假设运气很差,每次都入堆调整,调整时间复杂度为堆的高度,即lg(k),故整体时间复杂度是n*lg(k)。

    分析:堆,将冒泡的TopK排序优化为了TopK不排序,节省了计算资源。堆,是求TopK的经典算法,那还有没有更快的方案呢?

    四、随机选择

    随机选择算在是《算法导论》中一个经典的算法,其时间复杂度为O(n),是一个线性复杂度的方法。

    这个方法并不是所有同学都知道,为了将算法讲透,先聊一些前序知识,一个所有程序员都应该烂熟于胸的经典算法:快速排序。

    画外音:

    (1)如果有朋友说,“不知道快速排序,也不妨碍我写业务代码呀”…额...

    (2)除非校招,我在面试过程中从不问快速排序,默认所有工程师都知道;

    其伪代码是:

     

    void quick_sort(int[]arr, int low, inthigh){

    if(low== high) return;

    int i = partition(arr, low, high);

    quick_sort(arr, low, i-1);

    quick_sort(arr, i+1, high);

    }

    其核心算法思想是,分治法。

    分治法(Divide&Conquer),把一个大的问题,转化为若干个子问题(Divide),每个子问题“都”解决,大的问题便随之解决(Conquer)。这里的关键词是“都”。从伪代码里可以看到,快速排序递归时,先通过partition把数组分隔为两个部分,两个部分“都”要再次递归。

    分治法有一个特例,叫减治法。

    减治法(Reduce&Conquer),把一个大的问题,转化为若干个子问题(Reduce),这些子问题中“只”解决一个,大的问题便随之解决(Conquer)。这里的关键词是“只”。

    二分查找binary_search,BS,是一个典型的运用减治法思想的算法,其伪代码是:

     

    int BS(int[]arr, int low, inthigh, int target){

    if(low> high) return -1;

    mid= (low+high)/2;

    if(arr[mid]== target) return mid;

    if(arr[mid]> target)

    return BS(arr, low, mid-1, target);

    else

    return BS(arr, mid+1, high, target);

    }

    从伪代码可以看到,二分查找,一个大的问题,可以用一个mid元素,分成左半区,右半区两个子问题。而左右两个子问题,只需要解决其中一个,递归一次,就能够解决二分查找全局的问题。

    通过分治法与减治法的描述,可以发现,分治法的复杂度一般来说是大于减治法的:

    快速排序:O(n*lg(n))

    二分查找:O(lg(n))

    话题收回来,快速排序的核心是:

    i = partition(arr, low, high);

    这个partition是干嘛的呢?

    顾名思义,partition会把整体分为两个部分。

    更具体的,会用数组arr中的一个元素(默认是第一个元素t=arr[low])为划分依据,将数据arr[low, high]划分成左右两个子数组:

    左半部分,都比t大

    右半部分,都比t小

    中间位置i是划分元素

    以上述TopK的数组为例,先用第一个元素t=arr[low]为划分依据,扫描一遍数组,把数组分成了两个半区:

    左半区比t大

    右半区比t小

    中间是t

    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);

    如果i大于k,则说明arr[i]左边的元素都大于k,于是只递归arr[1, i-1]里第k大的元素即可;

    如果i小于k,则说明说明第k大的元素在arr[i]的右边,于是只递归arr[i+1, n]里第k-i大的元素即可;

    画外音:这一段非常重要,多读几遍。

    这就是随机选择算法randomized_select,RS,其伪代码如下:

     

    int RS(arr, low, high, k){

      if(low== high) return arr[low];

      i= partition(arr, low, high);

      temp= i-low; //数组前半部分元素个数

      if(temp>=k)

          return RS(arr, low, i-1, k); //求前半部分第k大

      else

          return RS(arr, i+1, high, k-i); //求后半部分第k-i大

    }

     

    这是一个典型的减治算法,递归内的两个分支,最终只会执行一个,它的时间复杂度是O(n)。

    再次强调一下:

    分治法,大问题分解为小问题,小问题都要递归各个分支,例如:快速排序

    减治法,大问题分解为小问题,小问题只要递归一个分支,例如:二分查找,随机选择

    通过随机选择(randomized_select),找到arr[1, n]中第k大的数,再进行一次partition,就能得到TopK的结果。

    五、bitmap计数

    空间换时间,是算法优化中最常见的手段,如果有相对充裕的内存,可以有更快的算法。

    画外音:即使内存不够,也可以水平切分,使用分段的方法来操作,减少每次内存使用量。

    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。

    画外音:我面试过程中从不问这个问题。

    最常见的解法是:

     

    uint32_t count_one(uint32_t n){

        uint32_t count=0;

        while(n){

            count ++;

            n &= (n-1);

        }

        return count;

    }

    六、总结

    TopK,不难;其思路优化过程,不简单:

    全局排序,O(n*lg(n))

    局部排序,只排序TopK个数,O(n*k)

    堆,TopK个数也不排序了,O(n*lg(k))

    分治法,每个分支“都要”递归,例如:快速排序,O(n*lg(n))

    减治法,“只要”递归一个分支,例如:二分查找O(lg(n)),随机选择O(n)

    TopK的另一个解法:随机选择+partition

    bitmap计数

    Processed: 0.013, SQL: 9