面试算法理解篇——堆排序的应用

11 篇文章 0 订阅
2 篇文章 0 订阅

前言

这篇是关于堆排的小节

堆排序算法

首先说说什么叫堆,以及和BST(二叉查找树,二叉排序树),AVL(平衡二叉树)的区别。
堆在内存中很常见,一般内存中分配都是以堆栈分配的(这里原谅渣渣我对jvm虚拟机还没有足够深的认识,这里不敢瞎说)。堆考的最多的有两种,最大堆和最小堆。简单来说:

  • 最大堆:树的父亲节点比它的子结点大,即Tree[i]>=Tree[i2]&&Tree[i]>=Tree[i2+1]
  • 最小堆:树的父节点比它的子结点小,即Tree[i]>=Tree[i2]&&Tree[i]>=Tree[i2+1]

二叉查找树:二叉查找树定义及算法戳这里
平衡二叉树: 戳这里

算法讲解依旧引用前辈们的博客,贴出几个把堆排序讲解的很好很透彻很容易理解的博客
博客1
博客2
博客3(这个博客之前看过一个版本,代码是错误的,应该是转载的这位大神的,后来删除了,特别恨转载不标明出处的,更可恨的是,你还转载了一个错误的代码给别人,错误还在不太容易发现的地方)
博客4
博客5
好的博客是在太多了,这里记录下来是为了自己以后方便,这篇博文也是加深理解篇,以后忘了堆排序的时候,可以回过头来看看,看着这些性质,可以快速的回忆和总结出来。
首先,拿出堆排序的算法,堆有两种写法,一种是递归的,一中是非递归的。我个人认为递归的好理解一些,所以先拿出递归的算法(这里直接应用博客5中的算法)。

声明:注意根节点是0还是1,如果根节点是1,那么i结点的左结点是2*i,右结点是2*i+1,父节点是i/2。如果根结点是0,那么i结点的左结点是2*i+1,右结点是2*i+2,父结点是(i-1)/2;

//递归解法(最大堆)
void adjust_max_heap_recursive(int *datas,int length,int i)
{
    int left,right,largest;
    int temp;
    left = LEFT(i);   //left child
    right = RIGHT(i); //right child
    //find the largest value among left and rihgt and i.
    if(left<=length && datas[left] > datas[i])
        largest = left;
    else
        largest = i;
    if(right <= length && datas[right] > datas[largest])
        largest = right;
    //exchange i and largest
    if(largest != i)
    {
        temp = datas[i];
        datas[i] = datas[largest];
        datas[largest] = temp;
        //recursive call the function,adjust from largest
        adjust_max_heap(datas,length,largest);
    }
}

用语言描绘下思路:用root表示父结点,left表示左结点,right表示右结点。
建立堆时,要从下往上调整,从右往左遍历所有的非叶子结点。先把left和right调整好,确保left和right已经是最大或者最小堆了,再来调整root。
每次调整某棵树时,从上往下调整,选取父节点,左右子结点中相对较大的与父节点交换。如果父结点就是最大的结点,那么不用交换,该树已经满足最大堆的性质了,如果左结点最大,那么将父结点和子结点交换,交换后,父结点作为left结点的左子树不一定满足最大堆的性质,所以需要同样的思想递归到下一层,而右子树肯定依然是满足最大堆性质的。

//非递归调整最大堆代码
void adjust_max_heap(int *datas,int length,int i)
{
    int left,right,largest;
    int temp;
    while(1)
    {
        left = LEFT(i);   //left child
        right = RIGHT(i); //right child
        //find the largest value among left and rihgt and i.
        if(left <= length && datas[left] > datas[i])
            largest = left;
        else
            largest = i;
        if(right <= length && datas[right] > datas[largest])
            largest = right;
        //exchange i and largest
        if(largest != i)
        {
            temp = datas[i];
            datas[i] = datas[largest];
            datas[largest] = temp;
            i = largest;
            continue;
        }
        else
            break;
    }
}
//建立堆的代码
void build_max_heap(int *datas,int length)
{
    int i;
    //build max heap from the last parent node
    for(i=length/2;i>0;i--)
        adjust_max_heap(datas,length,i);
}

堆排序的思想就是在已经建立好最大堆的基础上,把root结点a[0]和最后一个结点a[n-1]交换,把a[0]到a[n-2]重新调整为最大堆,再把a[0]和a[n-2]交换,再调整堆,再把a[0]和a[n-3]交换…以此类推,最终的出一个有序的数组。
然后是堆的插入,删除,以及稳定性。
这里引用MoreWindows 白话经典算法系列之七 堆与堆排序

/*
每次插入都是将新数据放在数组最后。可以发现从这个新数据的父结点到根结点必然为一个有序的数列,现在的任务是将这个新数据插入到这个有序数据中——这就类似于直接插入排序中将一个数据并入到有序区间中,对照《白话经典算法系列之二 直接插入排序的三种实现》不难写出插入一个新数据时堆的调整代码:
*/
//  新加入i结点  其父结点为(i - 1) / 2
void MinHeapFixup(int a[], int i)
{
    int j, temp;
      
       temp = a[i];
       j = (i - 1) / 2;      //父结点
       while (j >= 0)
       {
              if (a[j] <= temp)
                     break;
             
              a[i] = a[j];     //把较大的子结点往下移动,替换它的子结点
              i = j;
              j = (i - 1) / 2;
       }
       a[i] = temp;
}

更简短的表达为:


void MinHeapFixup(int a[], int i)
{
       for (int j = (i - 1) / 2; j >= 0 && a[i] > a[j]; i = j, j = (i - 1) / 2)
              Swap(a[i], a[j]);
}
插入时:
//在最小堆中加入新的数据nNum
void MinHeapAddNumber(int a[], int n, int nNum)
{
       a[n] = nNum;
       MinHeapFixup(a, n);
}

然后是删除,同样应用这篇博文,因为这篇博文实在是太经典了。

按定义,堆中每次都只能删除第0个数据。为了便于重建堆,实际的操作是将最后一个数据的值赋给根结点,然后再从根结点开始进行一次从上向下的调整。调整时先在左右儿子结点中找最小的,如果父结点比这个最小的子结点还小说明不需要调整了,反之将父结点和它交换后再考虑后面的结点。相当于从根结点将一个数据的“下沉”过程。下面给出代码:
//  从i节点开始调整,n为节点总数 从0开始计算 i节点的子节点为 2*i+1, 2*i+2
void MinHeapFixdown(int a[], int i, int n)
{
    int j, temp;
 
       temp = a[i];
       j = 2 * i + 1;
       while (j < n)
       {
              if (j + 1 < n && a[j + 1] < a[j]) //在左右孩子中找最小的
                     j++;
 
              if (a[j] >= temp)
                     break;
 
              a[i] = a[j];     //把较小的子结点往上移动,替换它的父结点
              i = j;
              j = 2 * i + 1;
       }
       a[i] = temp;
}
//在最小堆中删除数
void MinHeapDeleteNumber(int a[], int n)
{
       Swap(a[0], a[n - 1]);
       MinHeapFixdown(a, 0, n - 1);
}

提一点,这里的swap,不能是自定义的函数来交换,因为这样只是在函数里面交换,函数调用完回来,依然没用交换,具体原因可以参考c语言指针。
下面说一下堆排序的运用。堆的应用实在太多了,排序只是一种,最重要的,是堆调整只有log(n)的这种思想。这个后面有时间再好好总结一下。

应用1:

2个有序数组中,前k个小的数。
这里想法有好几个

思路一

用归并排序的思想,把两个数组合并,然后找出前k个,这里不需要合并两个数组,只需要在比较时计数,找出前k个值就行,复杂度为O(k)。

思路二

二分,先找到第一个数组的a[n/2],然后在第二个数组中找到第一个比a[n/2]小的位置,如果两个和加起来大于k,缩小n/2,小于k,放大n/2。这里复杂度是log(n)*log(n);很明显,当K的值很大的时候,适合这种方法。

思路三

用堆。这个题目用堆其实意义不大,因为只有两个数组,很明显看出选取哪个数组中的最小值,但是下面这个扩展的题目,就不得不使用堆了。

应用二:扩展:m个有序数组,前k个小的数。

2个好做,m个呢?

  • 用思路一,把m个数组合并?这里就不好合并了,2个数组直接就可以知道选取哪个值。所以,需要先两两合并,最后再回归到两个来解决,这里的复杂度就不好计算了,因为每次合并,数组的个数都增加了,下一次再次合并就不是这个复杂度了,但是一共需要合并(log m)次是确认的。
  • 思路二?第一次二分,二分复杂度是logn,复杂度是 (m-1)次二分(log n)(m-1),需要二分log(n)次,所以总复杂度(log(n))(m-1)次方,m过大显然受不了。
    最快的方法就是思路三,维护一个k堆,先把每个数组的第一个值放在堆里面,每次取出堆顶,放进与堆顶相邻的元素,下面是例题。

相关题目

这里给出leetcode的题目连接地址K-th Smallest Prime Fraction
AC代码如下


    Comparator<Node> comparator = new Comparator<Node>() {
        @Override
        public int compare(Node o1, Node o2) {
            if (o1.node > o2.node)
                return 1;
            else {
                return -1;
            }
        }
    };
    class Node {
        int x;
        int y;
        double node;

        public Node(int x, int y, double node) {
            this.x = x;
            this.y = y;
            this.node = node;
        }
    }

    public int[] kthSmallestPrimeFraction(int[] A, int K) {
        Queue<Node> queue = new PriorityQueue<>(comparator);
        int len = A.length;
        int[] b = new int[A.length];

        for (int i = 0; i < A.length; i++) {
            b[i] = A[A.length - i - 1];
        }
        for (int i = 0; i < A.length; i++) {
            queue.add(new Node(i, 0, 1.0 * A[0] / b[i]));
        }
        Node ans = null;
        while (K-- != 0) {
            Node node = queue.poll();
            ans = node;
            if (node.y + 1 < len) {
                Node tN = new Node(node.x, node.y + 1, 1.0 * A[node.y+1] / b[node.x]);
                queue.add(tN);
            }
        }
        return new int[]{A[ans.y],b[ans.x]};
    }
额外思考

这个题目和2个有序数组a[m],b[n],求前k个a[i]+b[j]的最小值。这个题目也是运用堆的思想,维持大小为n的堆不变,把a[0]+b[j](j从0到n-1)的n个值放入堆中,每次取出堆顶,放入堆顶元素右边的位置进去,比如,堆顶元素是a[1]+b[j],那么放进堆的值就是a[2]+b[j],再调整堆。

总结:

堆的特点:

运用的最多的特点,就是可以直接从堆顶取到最大值或最小值,再次维护只需要log2(n)的复杂度。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值