心中有堆

本文深入讲解堆数据结构的特性,包括堆序性、结构性及其实现细节,如构建过程、排序算法、添加与删除元素的方法。同时介绍了堆在Java中的应用,以及与之相关的内存管理知识。

数据结构 系列博文

  1. 心中有堆 https://blog.youkuaiyun.com/luo_boke/article/details/106928990
  2. 心中有树——基础 https://blog.youkuaiyun.com/luo_boke/article/details/106980011
  3. 心中有栈 https://blog.youkuaiyun.com/luo_boke/article/details/106982563
  4. 常见排序算法解析

前言

堆是一颗完全二叉树,是一种经过排序的树形数据结构,它满足如下性质:

  1. 堆序性:任一结点值均小于(或大于)它的所有后代结点值,最小值结点(或最大值结点)在堆的根上。
  2. 结构性:堆总是一棵完全二叉树,即除了最底层,其他层的结点都被元素填满,且最底层尽可能地从左到右填入。

二叉树又是啥?请查看我的另一篇博文《心中有树——基础》

小根堆与大根堆
小根堆:结点值小于后代结点值的堆,也叫最小堆,图左
大根堆:结点值大于后代结点值的堆,也叫最大堆,图右
在这里插入图片描述

二叉堆
二叉堆是一种特殊的堆,即每个结点的子结点不超过2个。堆排序就是使用的二叉堆。

堆的存储
一般使用线性数据结构(如数组)存储堆:
在这里插入图片描述

  • 根结点存储在第0个位置
  • 结点i的左孩子存储在2*i+1的位置
  • 结点i的右孩子存储在2*i+2的位置

堆构建过程

1)根据子结点推父结点:(n-1)/2
2)根据父结点推子结点:左子结点(2n+1),右子结点(2n+2)
索引由0开始计数

我们以9、12、5、24、0、1、99、3、10、7 这10个数来构建大根堆如下,

  1. 首先我们将现在的无序序列看成一个堆结构,一个没有规则的二叉树,将序列里的值按照从上往下,从左到右依次填充到二叉树中。
    在这里插入图片描述
  2. 从最后一个叶子7遍历父结点,如果父<左右子结点最大值,则父结点值与最大子结点交换值。发现7和10都小于35,切换至99和3的父结点,发现99>24,交换24与99的位置。
    在这里插入图片描述
  3. 继续比较0和1的父结点,因0<5,1<5,往下走比较99和35的父结点,最大子结点99>12,交换位置。交换后,此时需要对12这个父结点进行排序,发现24>12,此时需要交换12和24的位置。
    在这里插入图片描述
    在这里插入图片描述
  4. 继续对父结点9进行遍历操作,需要和99换位置,同理9需要最大的子结点35换位置。
    在这里插入图片描述
  5. 最终获得了大堆根
    在这里插入图片描述
    构建代码

    /***
     * 构建大根堆
     * @param array  数据源
     */
    private void buildHeap(int[] array) {
        //从右向左,从下到上依次遍历父结点,建立大根堆,时间复杂度:O(n*log2n)
        for (int i = (array.length - 1 - 1) / 2; i >= 0; i--) {
            adjust(array, i, array.length - 1);
        }
    }
    
    /**
     * 将指定堆构建成大堆根函数
     * 逻辑
     * 1. 如果起始索引无子结点,则跳出该方法
     * 2. 如果只有一个左子结点,进行大小比较并置换值
     * 3. 如果有两个子结点,选择最大值与父结点比较,然后置换其位置。
     * 如果子结点大于父结点,置换完成后,递归进行同样操作,其子结点索引即是函数的start值
     *
     * @param array 源数组
     * @param start 起始索引
     * @param end   结尾索引
     */
    public void adjust(int[] array, int start, int end) {
        // 左子结点的位置
        int leftIndex = 2 * start + 1;
        if (leftIndex == end) {
            //只有一个左结点,进行比较并置换值
            if (array[leftIndex] > array[start]) {
                int temp = array[leftIndex];
                array[leftIndex] = array[start];
                array[start] = temp;
            }
        } else if (leftIndex < end) {
            //有两个子结点
            int temp = array[leftIndex];
            int tempIndex = leftIndex;
            if (array[leftIndex + 1] > array[leftIndex]) {
                temp = array[leftIndex + 1];
                tempIndex = leftIndex + 1;
            }
            if (temp > array[start]) {
                array[tempIndex] = array[start];
                array[start] = temp;
            }
            adjust(array, tempIndex, end);
        }
    }

堆排序过程

上面我们将大根堆构建好了,现在我们对堆进行排序。其构建思想是:

  1. 根据堆的特点进行编写,先将一组拥有n个元素的数据构建成大根堆或者小根堆(我按照大根堆进行介绍,小根堆是一样的思想)。
  2. 再将根结点上的数和堆最后一位数据进行互换,此时,第n位的数就是整个序列中最大的数。
  3. 然后再将前n-1为元素进行构建形成大根堆,再将根结点与第n-1位数据进行互换,得到第二大数据,此时倒数两个数据无疑是有序的。
  4. 然后将前n-2个数据构建成大根堆,依次循环直到剩下一位元素,则说明第一位后面的数字都是有序的,并且比第一位数大,此时排序完成。
  • 1)将99和7替换,对排除99的堆进行重新构建大根堆
    在这里插入图片描述
  • 2)替换35和9的位置,对剩下的8个数进行重新构建大堆根
    在这里插入图片描述
  • 3)24与3交换位置,对剩余的7个数重新构建大根堆
    在这里插入图片描述
  • 4)后续过程同理,最终得到经过排序完成的堆 0、1、3、5、7、9、10、12、24、35、99
    在这里插入图片描述

排序代码

    /**
     * 堆排序
     *
     * @param array 源数组
     */
    public void heapSort(int[] array) {
        buildHeap(array);
        int tmp;
        //要与root结点置换位置元素的索引
        int end = array.length - 1;
        //n个结点只用构建排序n-1次,最后只有1个元素不用在排序
        for (int i = array.length - 1; i > 0; i--) {
            tmp = array[0];
            array[0] = array[end];
            array[end] = tmp;

            end--;
            //头尾置换后,将堆重新构建为大堆根,置换尾部大元素不参加构建
            //因为除了root结点,其他都是由大到小有序的,所以再次构建大根堆时,不用在进行adjust()前的那个循环
            adjust(array, 0, end);
        }
    }

堆添加元素

添加元素时,新元素被添加到数组末尾,但是添加元素后,堆的性质可能会被破坏,需要向上调整堆结果。如给大堆根堆添加元素100。
在这里插入图片描述
此时此时堆的结构被破坏,需要从下往上进行调整。因100大于0,5,99,则新的大堆根为
在这里插入图片描述
元素添加代码

    /**
     * 在 array 是大堆根的前提下添加元素然后重构大堆根
     *
     * @param array 大堆根数组
     * @param value 添加的元素值
     */
    private void addHeap(int[] array, int value) {
        int[] arr = new int[array.length + 1];
        System.arraycopy(array, 0, arr, 0, array.length);
        arr[arr.length - 1] = value;
        int currentIndex = arr.length - 1;
        int parentIndex = (arr.length - 1) / 2;
        while (parentIndex >= 0) {
            if (value > arr[parentIndex]) {
                int temp = arr[parentIndex];
                arr[parentIndex] = value;
                arr[currentIndex] = temp;

                //如果最后一个元素的父结点还有父结点需要继续进行对比
                currentIndex = parentIndex;
                parentIndex = (currentIndex - 1) / 2;
            } else {
                break;
            }
        }
    }

堆删除元素

堆删除元素都是从结点删除,然后以这个结点为root结点的数组的最后一个元素移动到根结点的位置,并向下调整堆结构,直至重新符合堆序性。如我们删除结点35,将7移到35的位置
在这里插入图片描述
将7与其子结点逐个比较,直至符合大根堆规则
在这里插入图片描述

删除元素代码

/**
     * 在 array 是大堆根的前提下删除元素然后重构大堆根
     *
     * @param array 大堆根数组
     * @param deleteIndex 删除元素的索引
     */
    private int[] deleteHeap(int[] array, int deleteIndex) {
        array[deleteIndex] = array[array.length - 1];
        int[] arr = new int[array.length - 1];
        System.arraycopy(array, 0, arr, 0, array.length - 1);
        int lefeIndex = 2 * deleteIndex + 1;
        while (lefeIndex >= arr.length - 1) {
            int maxIndex = lefeIndex;
            if (arr.length - 1 > lefeIndex) {
                if (arr[lefeIndex + 1] > arr[lefeIndex]) {
                    maxIndex = lefeIndex + 1;
                }
            }

            if (arr[maxIndex] > arr[deleteIndex]) {
                int temp = arr[maxIndex];
                arr[maxIndex] = arr[deleteIndex];
                arr[deleteIndex] = temp;
                lefeIndex = 2 * maxIndex + 1;
            } else {
                break;
            }
        }
        return arr;
    }

Java中的堆

堆这块区域是JVM中最大的,应用的对象和数据都是存在这个区域,这块区域也是线程共享的,也是 gc 主要的回收区。一个 JVM 实例只存在一个堆类存,堆内存的大小是可以调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,以方便执行器执行,堆内存分为三部分:
在这里插入图片描述
新生区:是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分:伊甸区(Eden space)和幸存者区(Survivor pace),所有的类都是在伊甸区被new出来的。

幸存区有两个:0区(Survivor 0 space)和1区(Survivor 1 space)。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园进行垃圾回收(Minor GC),将伊甸园中的剩余对象移动到幸存0区。

若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。那如果1去也满了呢?再移动到养老区。若养老区也满了,那么这个时候将产生Major GC(Full GC),进行养老区的内存清理。若养老区执行Full GC 之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。

出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:
1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

养老区
用于保存从新生区筛选出来的 JAVA 对象,一般pool池对象都在这个区域活跃。

永久存储区
是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。

如果出现java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。 原因有二:
1)程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。
2)大量动态反射生成的类不断被加载,最终导致Perm区被占满。

博客书写不易,您的点赞收藏是我前进的动力,千万别忘记点赞、 收**藏 ^ _ ^ !

相关链接

<think> 首先,用户的问题是关于一个纸牌游戏:有n纸牌,排成一行,编号从1到n。每纸牌有一定张数,总和是n的倍数。移动规则是: - 从第1取的牌只能移到第2。 - 从第n取的牌只能移到第n-1。 - 其他(2到n-1)取的牌可以移到左边或右边的。 目标是找到最少的移动次数,使得所有的牌数相等。 用户提供了引用,但引用似乎不直接相关,包括快速排序、TSP问题等。我应该专注于用户的核心问题。 用户还提到了一些代码片段,其中一个C++程序似乎尝试解决类似问题,但移动规则不同。在那个代码中,移动规则是第i可以影响第i+1,但用户指定的规则更严格:1号只能移到2号,n号只能移到n-1号,其他可以左右移。 用户说:"移动规则:在编号为1上取的纸牌,只能移到编号为2的上;在编号为n的上取的纸牌,只能移到编号为n - 1的上;其他上取的纸牌,可以移到相邻左边或右边的上。" 此外,纸牌总数是n的倍数,所以平均值是整数。 我需要找到一个方法计算最小移动次数。 回想一下经典问题:如果有n牌,每可以移到相邻,找最小移动次数使所有相等。这通常通过贪心算法解决:从左到右,依次调整每到平均值,移动多余或不足的牌到右边。 但在经典问题中,移动是双向的:可以从左到右或右到左移动牌。但在这里,移动规则限制了方向:对于端点,只能向特定方向移动。 用户指定的规则: - 从1号取牌,只能移到2号(所以只能向右移)。 - 从n号取牌,只能移到n-1号(所以只能向左移)。 - 从中间取牌,可以移到左边或右边。 移动是"取牌",然后"移到"相邻。所以,每次移动是:从一取一些牌,放到相邻。 在最少移动次数中,我们关心移动操作的次数,而不是牌的总数。 在经典贪心算法中,我们通常从左到右处理:对于每i,计算与平均值的偏差,然后将多余牌移到右边(或从左边借,但通常我们处理累计偏差)。 但在经典设置中,没有方向限制,所以贪心工作。 这里,由于方向限制,贪心可能不直接适用。 考虑一个例子。 用户给出了一个例子:n=4, 为3,5,4,8。平均是(3+5+4+8)/4=20/4=5。 移动方案: 1. 第2向第1移动2张?但规则是:从第2取牌,可以移到第1或第3。用户说"第2向第1移动2张",意思是从第2取2张移到第1,结果1:3+2=5, 2:5-2=3, 3:4, 4:8 → [5,3,4,8] 然后"第4向第3移动3张":从第4取3张移到第3,结果3:4+3=7, 4:8-3=5 → [5,3,7,5] 然后"第3向第2移动2张":从第3取2张移到第2,结果2:3+2=5, 3:7-2=5 → [5,5,5,5] 移动次数:3次。 但在这个方案中,移动是从第2到第1(从索引2移到1),第4到第3(从4移到3),第3到第2(从3移到2)。 规则允许:从2可以移到1(因为不是端点),从4可以移到3(n=4,第4只能移到第3,是的),从3可以移到2(中间可以左右移)。 所以是合法的。 现在,最小移动次数是3。 但如何一般化? 另一个例子在引用中:移动33次,但可能是个错误。在引用[2]中,有另一个例子:9,8,13,10,平均10。 移动:从第三取4张到第四?等等,用户说"从第三取44张牌放到第四",但44可能打字错误,应该是4张,因为13-4=9,10+4=14? 等等,用户说"此时每纸牌数分别为9,8,13,109" — 可能109是10,9? 等等,应该是9,8,13,10。 "从第三取44张牌放到第四,此时每纸牌数分别为9,8,13,109" — 可能"109"是"10,9"但写错了。或许是9,8,13,10 变成 9,8,9,14? 但用户说9,8,13,109 — 不合理。可能引用错误。 在引用[2]:"移动33次可达到目的: 从第三取44张牌放到第四,此时每纸牌数分别为9,8,13,109" — 这很混乱。可能应该是"取4张",但13-4=9,10+4=14, 所以9,8,9,14? 但用户说9,8,13,109 — 或许是笔误。然后"从第三取33张" — 等等。我认为引用不可靠,我应该忽略或小心处理。 回到问题。 我需要一个方法来计算最小移动次数。 由于移动方向受限制,这可能会影响我们如何转移牌。 在经典无限制问题中,最小移动次数等于从左开始累计偏差绝对值的和,但调整了符号。 回忆标准贪心算法。 在标准问题中,让a[i]为每的数量,avg为平均值。 定义偏差d[i] = a[i] - avg。 然后从左到右,对于每个i,从1到n-1,计算累计和s[i] = sum_{k=1}^i d[k]。 那么最小移动次数是 sum_{i=1}^{n-1} |s[i]|。 为什么?因为s[i]表示到i为止累计多余或不足的牌,这些牌需要被移到右边或从左边借来,但每次移动调整一个的累计偏差。 在这个求和中,sum |s[i]| 表示需要跨越每个间隙的牌的数量,每个移动对应于改变一个的状态。 在标准设置中,移动是双向的,但这个公式工作。 但在本例中,由于方向限制,可能不同。 在用户提供的C++代码中: ```cpp #include<iostream> using namespace std; int main() { int a[210]; int n; cin>>n; int sum = 0; for(int i = 0;i<n;i++) { cin>>a[i]; sum = sum + a[i]; } int avg = sum/n; int cnt = 0; for(int i = 0;i<n;i++) { a[i] = avg; cnt++; a[i+1] = a[i+1] + (a[i]-avg); } cout<<cnt; return 0; } ``` 这段代码似乎有错误。在循环中,它直接将a[i]设置为avg,然后cnt++,然后修改a[i+1] += (a[i] - avg)。但a[i]刚刚被设置为avg,所以a[i] - avg = 0,因此它总是向a[i+1]加0?这没有意义。 可能是个错误。或许原本想写别的东西。在标准贪心中,我们不会将a[i]设为avg;而是计算差值。 例如,标准代码应该是: ```cpp int cnt = 0; for (int i = 0; i < n-1; i++) { if (a[i] != avg) { int diff = a[i] - avg; a[i+1] += diff; // move diff to right; if diff negative, it's borrowing cnt++; // one move operation } } ``` 但在这种情况下,只移动到右边,并且对于每个非零的diff,我们进行一次移动,将diff张牌移动到右边。 但在这个移动中,我们假设可以从任何移动到右边的,但用户指定的规则中,对于1,只能移动到2(向右),对于n,只能移动到n-1(向左),所以对于端点,方向是固定的。 在标准贪心从左到右中,我们只考虑向右移动,但1只能向右移动,这没问题,但对于n,如果不足,我们需要从左边移动牌给它,但在只向右移动的贪心中n可能没有被正确处理。 在只向右移动的版本中,我们只调整1到n-1,n没有被显式调整;它应该通过前面的移动达到平均值。 在标准只向右移动贪心中,我们只处理i从1到n-1,对于每个i,如果累计的牌数不等于(i*avg),但我们移动牌使得在i之后,左边部分平衡。 在代码中: 设 d[i] = a[i] - avg 然后对于 i 从 0 到 n-2(索引从0开始),如果 s[i] != 0,其中 s[i] 是到 i 为止的 d[k] 之和,但我们通常执行 a[i+1] += a[i] - avg,其中 a[i] 是当前值。 在每次迭代开始时,a[i] 可能尚未是 avg。 在循环中:对于 i 从 0 到 n-2: 如果 a[i] > avg,则移动 (a[i] - avg) 张牌到 a[i+1],所以 a[i+1] += (a[i] - avg),a[i] = avg,移动次数增加。 如果 a[i] < avg,则移动 (avg - a[i]) 张牌到 a[i],从 a[i+1] 移动?但在这个设置中,当我们移动时,是从左向右移动,所以如果 a[i] < avg,我们需要从右边拿牌,但在这个贪心算法中,我们只将牌从左向右移动,因此对于 a[i] < avg,我们无法从左边拿牌,因为左边已经平衡了。 在只允许向右移动的标准贪心算法中,它只适用于所有移动都向右的情况,但如果有牌数不足,我们需要从右边拿牌,但右边可能牌不够。 实际上,在经典问题中,由于牌可以双向移动,但贪心算法只向右移动也能奏效,因为累计偏差会被处理。 但在只向右移动的版本中,对于 a[i] < avg,我们可以认为是从 a[i+1] 移动牌到 a[i],但这在移动意义上是从右向左移动。 在用户提供的代码中,它写的是 a[i] = avg,然后 a[i+1] += (a[i] - avg),但 a[i] 被设为 avg,所以 (a[i] - avg) = 0,因此 a[i+1] 不变,cnt 每次循环都增加,但未进行任何操作。所以是错误代码。 可能应该是: int diff = a[i] - avg; a[i] = avg; a[i+1] += diff; if (diff != 0) cnt++; 然后对于每个 i 从 0 到 n-2。 最后,a[n-1] 应该是 avg,因为总和正确。 在这个设置中,每次当 diff != 0 时,我们进行一次移动操作:如果 diff > 0,我们将 diff 张牌从 i 移动到 i+1;如果 diff < 0,我们将 -diff 张牌从 i+1 移动到 i(因为 a[i+1] 增加了 -diff,但逻辑上是从 i+1 移动到 i)。 在移动操作上,当 diff < 0 时,是从 i+1 移动到 i,即向左移动。 但在用户规则中,对于 i+1,如果 i+1 是端点,移动方向可能被限制。 例如,如果 i=0,0(第一),如果 a[0] < avg,我们需要将牌移动到 a[0],但规则是对于1,只能向2移动,因此不能接收来自左边的牌?1只能向2移动牌,但可以接收来自哪里的牌?规则是关于从哪里取牌,然后移动到哪里。 用户说:“在编号为1上取的纸牌,只能移到编号为2的上” — 所以从1取牌,只能移到2。 但它可以从其他接收牌吗?规则没有明确说明,但隐含地,当牌被移到1时,是从其他移动过来。 类似地,“在编号为n的上取的纸牌,只能移到编号为n-1的上”。 对于中间,可以从左边或右边取牌,或移到左边或右边。 在移动中,当我们将牌移到一或从一移出时,方向取决于动作。 在贪心步骤中,当对于i,如果 a[i] > avg,我们将多余的牌移动到右边(i+1)。 如果 a[i] < avg,我们需要牌,所以从右边(i+1)移动牌到 i,也就是从 i+1 移动到 i,向左移动。 现在,对于索引: 假设编号从1到n。 对于1(i=1): - 如果 a[1] > avg,我们可以将牌从1移动到2,这是允许的(因为从1只能移到2)。 - 如果 a[1] < avg,我们需要将牌移动到1。这可以从2移动牌到1。但根据规则,从2取牌,可以移到1或3,所以是的,允许从2移动到1。 类似地,对于n: - 如果 a[n] > avg,我们可以将牌从n移动到n-1,这是允许的(从n只能移到n-1)。 - 如果 a[n] < avg,我们需要将牌移动到n,这可以从n-1移动牌到n。从n-1取牌,可以移到n-2或n,所以是的,允许移到n。 对于中间,类似。 因此,在贪心算法中,从左到右,对于每个 i 从 1 到 n-1,我们调整i到avg,通过移动牌到或从 i+1,并且移动方向始终是 i 和 i+1 之间,这应该被规则允许,因为对于 i 和 i+1,只要不是端点特殊情况,但如上所述,对于 i=1 和 i+1=2,从2移动到1是允许的,因为2可以移到1。 类似地,对于 i=n-1 和 i=n,从 n 移动到 n-1 或反之。 在贪心循环中,当处理 i 时,我们设置i为avg,通过改变i+1。 但i+1可能尚未处理。 在标准方法中,对于每个 i 从 1 到 n-1: 设 diff = a[i] - avg 然后 a[i] = avg a[i+1] += diff 如果 diff != 0,则移动次数增加一次,因为一次移动可以转移任意数量的牌,但在这个上下文中,每次我们执行这样的操作,它对应于一次移动操作:如果 diff > 0,表示从 i 移动到 i+1;如果 diff < 0,表示从 i+1 移动到 i。 并且由于移动是在相邻之间,且如上所述方向是允许的,因此对于所有 i,这应该是有效的。 例如,在 n=4,为 3,5,4,8,avg=5。 i=1:a[1]=3,diff = 3-5 = -2,所以设置 a[1]=5,a[2] += -2,所以 a[2]=5-2=3。移动:由于 diff<0,从2移动2张牌到1。规则:从2可以移动到1,允许。现在为:5,3,4,8 然后 i=2:a[2]=3,diff=3-5=-2,设置 a[2]=5,a[3] += -2,所以 a[3]=4-2=2?等等,a[3]=4 + (-2) = 2?但应该是4,错误。 在代码中:a[i+1] += diff,其中 diff 是 a[i] - avg。 第一次迭代后:a[1]=5,a[2]=5 + (-2)?等等,a[2] 原本是5,a[2] += diff = a[2] + (a[1]-avg) = 5 + (3-5) = 5 + (-2) = 3。所以:5,3,4,8 现在 i=2:a[2]=3,diff = a[2] - avg = 3-5 = -2,设置 a[2]=5,然后 a[3] += diff = -2,所以 a[3]=4 + (-2) = 2。:5,5,2,8 然后 i=3:a[3]=2,diff = 2-5 = -3,设置 a[3]=5,a[4] += -3,所以 a[4]=8-3=5。:5,5,5,5 移动次数:第一次移动(i=1,diff=-2≠0),第二次移动(i=2,diff=-2≠0),第三次移动(i=3,diff=-3≠0),所以 cnt=3,符合用户例子。 在移动中:第一次从2移动到1,第二次从3移动到2,第三次从4移动到3。 方向:从2到1(允许),从3到2(允许,3可以向左移动),从4到3(允许,4只能移动到3)。 完美。 另一个例子:假设为 1,2,3,2,avg=(1+2+3+2)/4=8/4=2。 i=1:a[1]=1,diff=1-2=-1,设置 a[1]=2,a[2] += -1,所以 a[2]=2-1=1。移动:从2移动1张牌到1(因为 diff<0)。:2,1,3,2 i=2:a[2]=1,diff=1-2=-1,设置 a[2]=2,a[3] += -1,所以 a[3]=3-1=2。移动:从3移动到2。:2,2,2,2 i=3:a[3]=2,diff=0,没有移动(或者 diff=0,不增加计数)。 总共移动次数:2。 手动:一种方式:从3移动到2:但3有3,avg是2,可以移动1到2,但2有2,移动后2=3,3=2?不行。 从3移动1到2:2原来是2,变成3;3=3-1=2。然后:1,3,2,2。然后需要调整2:2=3>2,所以移动1到1或3。如果移动到1:从2移动1到1,1=1+1=2,2=3-1=2。:2,2,2,2。移动次数:2。 如果从2移动到3:但3已经是2,移动后2=2,3=3,然后需要从3移动1到4?但4是2,等等。更复杂。 所以最少是2次移动。 在贪心中,我们得到2次。 现在,检查方向:第一次移动从3到2(i=2,但索引从1开始?在循环中i=1和i=2)。 在代码中,对于i=1:从2移动到1(在索引中,2是第二)。 在规则中:从2取牌移动到1,允许。 然后对于i=2:a[2]原本是,在第一次移动后,a[2]=1?在序列中:初始a[2]=2,第一次移动后,a[2]=1(因为a[2] += diff = 2 + (-1) =1?等等。 在代码中:初始a[1]=1,a[2]=2,a[3]=3,a[4]=2。 i=1(处理1):diff = a[1] - avg =1-2= -1,设置a[1]=2,a[2] += -1,所以a[2]=2-1=1。移动:从2移动1张牌到1。 现在:2,1,3,2 i=2(处理2):a[2]=1,diff=1-2= -1,设置a[2]=2,a[3] += -1,所以a[3]=3-1=2。移动:从3移动1张牌到2。 现在:2,2,2,2 i=3(处理3):a[3]=2,diff=0,无移动。 所以移动:从2到1一次,从3到2一次。 方向:从2到1:2不是端点,可以向左移动,允许。 从3到2:3是中间(n=4),可以向左移动,允许。 没有涉及4的移动,但4在最后是正确的。 现在,假设另一种情况:假设是 4,1,1,2,avg=2。 i=1:a[1]=4,diff=4-2=2>0,设置a[1]=2,a[2] +=2,所以a[2]=1+2=3。移动:从1移动2张牌到2(因为diff>0)。规则:从1只能移到2,允许。:2,3,1,2 i=2:a[2]=3,diff=3-2=1,设置a[2]=2,a[3] +=1,所以a[3]=1+1=2。移动:从2移动1张牌到3?等等,diff>0,所以是从2移动到3。:2,2,2,2 i=3:diff=0,无移动。 总移动次数:2。 手动:从1移动2到2::2,3,1,2。然后从2移动1到3::2,2,2,2。是的。 方向:第一次移动从1到2,允许。第二次从2到3,2是中间,可以向右移动,允许。 另一个例子:假设n=2: 3,1,avg=2。 i=1:a[1]=3,diff=3-2=1,设置a[1]=2,a[2] +=1,所以a[2]=1+1=2。移动:从1移动1张牌到2。规则:从1只能移到2,允许。移动次数:1。 如果是 1,3,avg=2。 i=1:a[1]=1,diff=1-2=-1,设置a[1]=2,a[2] += -1,所以a[2]=3-1=2。移动:从2移动1张牌到1。规则:从2可以移到1(因为n=2>1?2是最后一,但n=2,2只能移到1?用户规则:“第n只可以移向第n-1”,这里n=2,所以2只能移到1。而从2移动到1是允许的。所以可以。 在代码中,对于i从1到n-1,当n=2时,i=1(处理1)。 一般情况:在循环中,i 从 1 到 n-1(如果索引从1开始)。 在代码中,索引通常从0开始,但原理相同。 在用户提供的错误代码中,他们有 for(int i=0;i<n;i++),但那样会包括 i=n-1,而 a[i+1] 可能越界。 在标准中,应该是 i 从 0 到 n-2(0索引)或 1 到 n-1(1索引)。 在正确的实现中,循环从第一个到倒数第二个。 现在,在方向限制下,由于贪心算法中的每次移动都是相邻之间,并且方向在规则下是允许的,正如例子所示,因此贪心算法应该是有效的。 在移动中,当 diff<0 时,是从 i+1 移动到 i,对于 i=0(第一),移动是从2到1,规则允许,因为2可以移动到1。 对于 i=n-2(0索引),处理 n-1,如果 diff<0,从 n 移动到 n-1,规则允许( n 只能移动到 n-1)。 如果 diff>0,对于 i,移动是从 i 到 i+1,对于 i=0(1),可以移动到2;对于 i=n-2,从 n-1 移动到 n, n-1 可以移动到 n(如果 n-1 不是端点,但 n-1 是倒数第二,对于 n>2, n-1 是中间,可以向右移动;对于 n=2,1 移动到2 是允许的)。 所以没有问题。 因此,最小移动次数可以通过这个贪心算法给出:对于每个 i 从 1 到 n-1,如果当前 a[i] != avg,我们执行移动来将其设置为 avg,通过调整 a[i+1],并且每次 |diff| >0 时移动计数增加一次。 在代码中,由于我们设置 a[i]=avg 并修改 a[i+1],当 diff !=0 时计数增加。 现在,在累计求和中,最小移动次数是 sum_{i=1}^{n-1} |s_i|,其中 s_i 是前 i 的累计和减去 i*avg,但在贪心过程中,是 |diff| 的隐式求和,但每个 |diff| 对应一次移动,但 |diff| 可能很大,而移动次数是 diff !=0 的次数,每次移动可以转移任意数量。 在贪心算法中,移动次数是 diff !=0 的次数,而不是 |diff| 的总和。 在例子中:n=4,为 3,5,4,8,avg=5。 第一次:i=1,diff=3-5=-2 !=0,移动,计数=1。 第二次:i=2,a[2] 现在是 3(第一次后),diff=3-5=-2 !=0,移动,计数=2。 第三次:i=3,a[3]=4(之前?第一次后:5,3,4,8;第二次:处理 i=2,a[2]=3,设 a[2]=5,a[3] += -2,所以 a[3]=4-2=2;然后 i=3,a[3]=2,diff=2-5=-3 !=0,移动,计数=3。 每次 diff !=0,计数增加,但 |diff| 是 2,2,3,但移动次数是 3,不是总和。 另一个例子: 2,2,2,2,所有 diff=0,移动次数=0。 4,0,0,4,avg=2。 i=1:a[1]=4,diff=4-2=2>0,设置 a[1]=2,a[2] +=2,a[2]=0+2=2。移动,计数=1。:2,2,0,4 i=2:a[2]=2,diff=0?等等,a[2]=2,diff=2-2=0,不移动?但:2,2,0,4,未平衡。 错误。 在循环 i=1 之后::2,2,0,4 i=2:处理2,a[2]=2,diff=0,不操作,不移动。 i=3:处理3,a[3]=0,diff=0-2= -2,设置 a[3]=2,a[4] += -2,所以 a[4]=4-2=2。移动(因为 diff=-2≠0),计数=2。:2,2,2,2 是的,移动次数=2。 手动:从1移动2到2::2,2,0,4?初始:4,0,0,4;从1移动2到2:1=2,2=0+2=2,:2,2,0,4。然后需要从4移动2到3?但3是0,从4移动2到3:4=2,3=2。:2,2,2,2。移动次数:2。 在贪心中,有两次移动。 在累计偏差中:定义 d_i = a_i - avg。 累计 s_i = sum_{k=1}^i d_k,表示前 i 后多余或不足的数量。 那么最小移动次数是 sum_{i=1}^{n-1} |s_i|。 在例子中: 4,0,0,4,avg=2,d: 2,-2,-2,2 s1 = d1 = 2 s2 = d1+d2 = 2-2=0 s3 = d1+d2+d3 = 2-2-2 = -2 然后 sum |s_i| for i=1,2,3:|2| + |0| + |-2| = 2+0+2=4,但移动次数是2,不匹配。 但移动次数是 diff !=0 的次数,而不是 |s_i| 的总和。 在贪心算法中,移动次数是 diff !=0 的次数,对于 i 从 1 到 n-1。 在累计中,s_i 是累计的,但移动次数与 s_i 过零的次数有关。 在标准分析中,对于最小移动次数,当移动可以在两个方向进行时,它等于 sum |s_i|,但在这个例子中,sum |s_i| =4,但实际最少移动次数是2,矛盾。 我可能混淆了。 在经典问题中,当每次移动可以移动任意数量的牌,但每次移动算作一次操作时,最小移动次数是 sum_{i=1}^{n-1} |s_i|,其中 s_i 是前 i 的累计和减去 i*avg。 但在这个例子中, 4,0,0,4,avg=2。 累计:设1,2,3,4。 前1:和=4,预期 1*2=2,多余 2,所以 |s1|=|2|=2 前2:和=4+0=4,预期 4,多余 0,|s2|=0 前3:和=4+0+0=4,预期 6,不足 2,|s3|=2 sum |s_i| = 2+0+2=4 但实际中,通过两次移动完成:第一次移动2张牌从1到2,第二次移动2张牌从4到3?但4到3不是直接的;在序列中,我们移动了两次。 每次移动改变牌的位置,但移动次数是操作的次数。 在贪心中,我们用了两次移动。 但 sum |s_i| 是4,所以不相等。 我认为我混淆了定义。 在标准文献中,对于相邻移动的最小移动次数,当每次移动可以移动任意数量的牌时,每次移动算作一次操作,最小移动次数是 sum_{i=1}^{n-1} |s_i|,其中 s_i 是累计偏差。 但在这个例子中,sum |s_i| 是4,但我们可以用两次移动完成:移动2张从1到2,然后移动2张从4到3。但4到3,对于 n=4,4可以移动到3。 移动后:第一次移动后:1=2,2=2,3=0,4=4 然后第二次移动:从4移动2张到3:3=2,4=2。 完成。 但移动次数是2,而 sum |s_i| 是4。 为什么?因为 |s_i| 对应于必须跨越 i 和 i+1 之间“间隙”的牌的数量。 对于间隙1(在1和2之间):|s1|=2,表示有2张牌需要从左边移动到右边或反之,但在这个例子中,在贪心中,我们从1移动到2,相当于2张牌向右移动。 对于间隙2(2和3之间):s2=0,所以不需要移动。 对于间隙3(3和4之间):s3=-2,表示有2张牌需要从右边移动到左边,但最后我们是从4移动到3,也就是向左移动。 所以每个 |s_i| 表示在间隙 i 上净移动的牌数,但每次这样的移动可能是一次操作,但当我们移动多张牌时,一次操作可以移动多张牌。 在最小移动次数中,由于每次操作可以移动任意数量的牌,最小操作次数等于需要非零移动的间隙数量,但每个间隙可能需要多个牌的移动,但由于我们可以一次移动所有牌,每个 |s_i| >0 的间隙需要一次移动,但 |s_i| 的大小不直接影响操作次数,因为一次操作可以移动任意数量。 在累计偏差方法中,最小移动次数是累计偏差 s_i 过零的次数之类的。 在贪心算法中,移动次数是 diff !=0 的次数,对于每个 i。 在累计偏差中,s_i 是累计和。 实际上,在贪心过程中,当我们处理 i 时,diff = a[i] - avg,但 a[i] 是当前值,不一定是初始值。 在累计中,在初始状态下,s_i = sum_{k=1}^i (a_k - avg) 那么,在贪心算法中,移动次数等于 s_i !=0 的 i 的数量,对于 i=1 到 n-1?在例子中,n=4,为 4,0,0,4,s1=2,s2=0,s3=-2。s_i !=0 的 i 有:i=1 和 i=3,所以有两个,与移动次数匹配。 在第一个例子中:为 3,5,4,8,avg=5,d: -2,0,-1,3?a1=3-5=-2,a2=5-5=0,a3=4-5=-1,a4=8-5=3 s1 = d1 = -2 s2 = d1+d2 = -2+0 = -2 s3 = d1+d2+d3 = -2+0-1 = -3 s_i 对于 i=1,2,3:s1=-2,s2=-2,s3=-3 s_i !=0 对于所有 i,所以有三个,移动次数=3。 另一个例子:为 2,2,2,2,所有 s_i=0,移动次数=0。 为 1,2,3,2,avg=2,d: -1,0,1,0 s1 = -1 s2 = -1+0 = -1 s3 = -1+0+1 = 0 s_i !=0:s1=-1≠0,s2=-1≠0,s3=0,所以有两个,移动次数=2。 完美。 所以一般来说,最小移动次数是满足累计和 s_i ≠ 0 的索引 i 的数量,其中 i 从 1 到 n-1,s_i = sum_{k=1}^i (a_k - avg)。 由于总和是 n 的倍数,s_{n-1} = - (a_n - avg),但 s_i 对于 i=1 到 n-1。 在代码中,我们可以在读取时计算累计和。 但需要注意的是,s_i 是前 i 个的累计偏差。 对于输出,我们需要满足 s_i ≠ 0 的 i 的数量(i 从 1 到 n-1)。 在累计和中,s_i 依赖于顺序。 现在,在方向约束下,由于贪心法有效,并且该计数给出了移动次数,且每次移动在规则下是可行的,因此应该没问题。 在累计偏差中,我们不需要执行移动
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值