【问题标题】:Why is Arrays.binarySearch not improving the performance compared to walking the array?为什么 Arrays.binarySearch 与遍历数组相比没有提高性能?
【发布时间】:2017-09-23 01:08:12
【问题描述】:

我尝试解决Hackerland Radio Transmitters programming challange

总而言之,挑战如下:

Hackerland 是一个有 n 栋房屋的一维城市,其中每栋房屋 i 位于某个 xi em> 在 x 轴上。市长想在城市房屋的屋顶上安装无线电发射器。每个发射器都有一个范围,k,这意味着它可以将信号传输到距离≤k 个单位的所有房屋。

给定 Hackerland 的地图和 k 的值,你能找到覆盖每个房子所需的最少发射器数量吗?

我的实现如下:

package biz.tugay;

import java.util.*;

public class HackerlandRadioTransmitters {

    public static int minNumOfTransmitters(int[] houseLocations, int transmitterRange) {
        // Sort and remove duplicates..
        houseLocations = uniqueHouseLocationsSorted(houseLocations);
        int towerCount = 0;
        for (int nextHouseNotCovered = 0; nextHouseNotCovered < houseLocations.length; ) {
            final int towerLocation = HackerlandRadioTransmitters.findNextTowerIndex(houseLocations, nextHouseNotCovered, transmitterRange);
            towerCount++;
            nextHouseNotCovered = HackerlandRadioTransmitters.nextHouseNotCoveredIndex(houseLocations, towerLocation, transmitterRange);
            if (nextHouseNotCovered == -1) {
                break;
            }
        }
        return towerCount;
    }

    public static int findNextTowerIndex(final int[] houseLocations, final int houseNotCoveredIndex, final int transmitterRange) {
        final int houseLocationWeWantToCover = houseLocations[houseNotCoveredIndex];
        final int farthestHouseLocationAllowed = houseLocationWeWantToCover + transmitterRange;
        int towerIndex = houseNotCoveredIndex;
        int loop = 0;
        while (true) {
            loop++;
            if (towerIndex == houseLocations.length - 1) {
                break;
            }
            if (farthestHouseLocationAllowed >= houseLocations[towerIndex + 1]) {
                towerIndex++;
                continue;
            }
            break;
        }
        System.out.println("findNextTowerIndex looped : " + loop);
        return towerIndex;
    }

    public static int nextHouseNotCoveredIndex(final int[] houseLocations, final int towerIndex, final int transmitterRange) {
        final int towerCoversUntil = houseLocations[towerIndex] + transmitterRange;
        int notCoveredHouseIndex = towerIndex + 1;
        int loop = 0;
        while (notCoveredHouseIndex < houseLocations.length) {
            loop++;
            final int locationOfHouseBeingChecked = houseLocations[notCoveredHouseIndex];
            if (locationOfHouseBeingChecked > towerCoversUntil) {
                break; // Tower does not cover the house anymore, break the loop..
            }
            notCoveredHouseIndex++;
        }
        if (notCoveredHouseIndex == houseLocations.length) {
            notCoveredHouseIndex = -1;
        }
        System.out.println("nextHouseNotCoveredIndex looped : " + loop);
        return notCoveredHouseIndex;
    }

    public static int[] uniqueHouseLocationsSorted(final int[] houseLocations) {
        Arrays.sort(houseLocations);
        final HashSet<Integer> integers = new HashSet<>();
        final int[] houseLocationsUnique = new int[houseLocations.length];

        int innerCounter = 0;
        for (int houseLocation : houseLocations) {
            if (integers.contains(houseLocation)) {
                continue;
            }
            houseLocationsUnique[innerCounter] = houseLocation;
            integers.add(houseLocationsUnique[innerCounter]);
            innerCounter++;
        }
        return Arrays.copyOf(houseLocationsUnique, innerCounter);
    }
}

我很确定这个实现是正确的。但是请看函数中的细节:findNextTowerIndexnextHouseNotCoveredIndex:他们一个一个地遍历数组!

我的一个测试如下:

static void test_01() throws FileNotFoundException {
    final long start = System.currentTimeMillis();
    final File file = new File("input.txt");
    final Scanner scanner = new Scanner(file);
    int[] houseLocations = new int[73382];
    for (int counter = 0; counter < 73382; counter++) {
        houseLocations[counter] = scanner.nextInt();
    }
    final int[] uniqueHouseLocationsSorted = HackerlandRadioTransmitters.uniqueHouseLocationsSorted(houseLocations);
    final int minNumOfTransmitters = HackerlandRadioTransmitters.minNumOfTransmitters(uniqueHouseLocationsSorted, 73381);
    assert minNumOfTransmitters == 1;
    final long end = System.currentTimeMillis();
    System.out.println("Took: " + (end - start) + " milliseconds..");
}

input.txt 可以从here 下载。 (这不是这个问题中最重要的细节,但仍然......)所以我们有一个 73382 房屋的数组,我特意设置了发射器范围,所以我的方法循环了很多:

这是在我的机器上测试的示例输出:

findNextTowerIndex looped : 38213
nextHouseNotCoveredIndex looped : 13785
Took: 359 milliseconds..

我也有这个测试,它不断言任何东西,只是保持时间:

static void test_02() throws FileNotFoundException {
    final long start = System.currentTimeMillis();
    for (int i = 0; i < 400; i ++) {
        final File file = new File("input.txt");
        final Scanner scanner = new Scanner(file);
        int[] houseLocations = new int[73382];
        for (int counter = 0; counter < 73382; counter++) {
            houseLocations[counter] = scanner.nextInt();
        }
        final int[] uniqueHouseLocationsSorted = HackerlandRadioTransmitters.uniqueHouseLocationsSorted(houseLocations);

        final int transmitterRange = ThreadLocalRandom.current().nextInt(1, 70000);
        final int minNumOfTransmitters = HackerlandRadioTransmitters.minNumOfTransmitters(uniqueHouseLocationsSorted, transmitterRange);
    }
    final long end = System.currentTimeMillis();
    System.out.println("Took: " + (end - start) + " milliseconds..");
}

我随机创建 400 个发射器范围,并运行程序 400 次。我将在我的机器中获得如下运行时间。

Took: 20149 milliseconds..

所以现在,我说,我为什么不使用二进制搜索而不是遍历数组,并将我的实现更改如下:

public static int findNextTowerIndex(final int[] houseLocations, final int houseNotCoveredIndex, final int transmitterRange) {
    final int houseLocationWeWantToCover = houseLocations[houseNotCoveredIndex];
    final int farthestHouseLocationAllowed = houseLocationWeWantToCover + transmitterRange;
    int nextTowerIndex = Arrays.binarySearch(houseLocations, 0, houseLocations.length, farthestHouseLocationAllowed);

    if (nextTowerIndex < 0) {
        nextTowerIndex = -nextTowerIndex;
        nextTowerIndex = nextTowerIndex -2;
    }

    return nextTowerIndex;
}

public static int nextHouseNotCoveredIndex(final int[] houseLocations, final int towerIndex, final int transmitterRange) {
    final int towerCoversUntil = houseLocations[towerIndex] + transmitterRange;
    int nextHouseNotCoveredIndex = Arrays.binarySearch(houseLocations, 0, houseLocations.length, towerCoversUntil);

    if (-nextHouseNotCoveredIndex > houseLocations.length) {
        return -1;
    }

    if (nextHouseNotCoveredIndex < 0) {
        nextHouseNotCoveredIndex = - (nextHouseNotCoveredIndex + 1);
        return nextHouseNotCoveredIndex;
    }

    return nextHouseNotCoveredIndex + 1;
}

我期待性能大幅提升,因为现在我最多会循环 log(N) 次,而不是 O(N).. 所以 test_01 输出:

Took: 297 milliseconds..

请记住,之前是 359 毫秒。对于 test_02:

Took: 18047 milliseconds..

所以我总是在 20 秒左右获得数组遍历实现的值,而对于二分搜索实现,我总是在 18 - 19 秒左右。

我期待使用 Arrays.binarySearch 获得更好的性能提升,但显然不是这样,这是为什么呢?我错过了什么?我是否需要一个超过 73382 的数组才能看到好处,还是无关紧要?

编辑#01

在@huck_cussler 发表评论后,我尝试将我拥有的数据集(使用随机数)翻倍和翻三倍,并尝试运行 test02(当然,将测试本身中的数组大小翻三倍......)。对于线性实现,时间是这样的:

Took: 18789 milliseconds..
Took: 34396 milliseconds..
Took: 53504 milliseconds..

对于二分搜索实现,我得到的值如下:

Took: 18644 milliseconds..
Took: 33831 milliseconds..
Took: 52886 milliseconds..

【问题讨论】:

  • 以一种大小测试两个实现不会暴露不同的大哦行为。当你增加问题规模时会发生什么?这就是您应该看到线性解决方案所需的时间会增长得更快的地方。问题规模每增加一倍,线性算法所需的时间大约会增加一倍。
  • @huck_cussler 我尝试将我的输入加倍,但线性解决方案似乎并没有像你建议的那样增长得更快。当我将输入数据加倍时,我得到线性解决方案的 18789 - 34396 和 test2 的二进制搜索解决方案的 18644 - 33831 之类的值。它们似乎都像 O(N) 一样增加..
  • @huck_cussler 请看我的编辑..
  • 我的第一个想法是将代码行移到启动计时器的位置。如果我正确理解了这个问题,n 是在您的测试代码中显示为73382 的房屋数量。你同意吗?假设是这种情况,通过将计时器启动放在方法的开头,您将捕获运行 n 次的 for 循环。即使该操作相对较快,它仍然是线性的,并且会覆盖您的 log(n) 行为。如果将计时器开始移动到运行 73382 次的 for 循环之后会发生什么? ...
  • ... 这意味着您将不得不在外循环内部停止计时器,这使得计时更加复杂。您必须汇总 400 次迭代的运行时间,或者(我认为更好的选择)单独进行所有设置,然后只对您感兴趣的操作计时。所以,做所有你做的事情在 test_02 的第 4-9 行其他地方,然后循环 400 次,只需执行解决问题的代码。

标签: java arrays binary-search


【解决方案1】:

您的时间包括从硬盘驱动器中检索数据。这可能会占用您的大部分运行时间。从您的时间中省略数据加载,以便更准确地比较您的两种方法。想象一下,如果它需要 18 秒,并且您将 18.644 与 18.789(0.77% 改进)而不是 0.644 与 0.789(18.38% 改进)进行比较。

如果你有一个线性运算 O(n),例如加载一个二进制结构,并且你将它与二进制搜索 O(log n) 结合起来,你最终会得到 O(n)。如果您相信大 O 表示法,那么您应该期望 O(n + log n) 与 O(2 * n) 没有显着差异,因为它们都减少到 O(n)。

此外,二分搜索可能比线性搜索执行得更好或更差,具体取决于塔之间的房屋密度。假设有 1024 座房屋,每 4 座房屋均匀分布有一座塔。线性搜索每塔需要 4 步,而二分搜索每塔需要 log2(1024)=10 步。

还有一件事……您的minNumOfTransmitters 方法正在对从test_01test_02 传入的已排序数组进行排序。该求助步骤比您的搜索本身花费的时间更长,这进一步掩盖了您的两种搜索算法之间的时间差异。

======

我创建了一个小型计时课程,以便更好地了解正在发生的事情。我已经从 minNumOfTransmitters 中删除了这行代码以防止它重新运行排序,并添加了一个布尔参数来选择是否使用您的二进制版本。它总计 400 次迭代的总和,分离出每个步骤。我系统上的结果表明,加载时间使排序时间相形见绌,而排序时间又使求解时间相形见绌。

  Load:  22.565s
  Sort:   4.518s
Linear:   0.012s
Binary:   0.003s

很容易看出优化最后一步对整体运行时间没有太大影响。

private static class Timing {
    public long load=0;
    public long sort=0;
    public long solve1=0;
    public long solve2=0;
    private String secs(long millis) {
        return String.format("%3d.%03ds", millis/1000, millis%1000);
    }
    public String toString() {
        return "  Load: " + secs(load) + "\n  Sort: " + secs(sort) + "\nLinear: " + secs(solve1) + "\nBinary: " + secs(solve2);
    }
    public void add(Timing timing) {
        load+=timing.load;
        sort+=timing.sort;
        solve1+=timing.solve1;
        solve2+=timing.solve2;
    }
}

static Timing test_01() throws FileNotFoundException {
    Timing timing=new Timing();
    long start = System.currentTimeMillis();
    final File file = new File("c:\\path\\to\\xnpwdiG3.txt");
    final Scanner scanner = new Scanner(file);
    int[] houseLocations = new int[73382];
    for (int counter = 0; counter < 73382; counter++) {
        houseLocations[counter] = scanner.nextInt();
    }
    timing.load+=System.currentTimeMillis()-start;
    start=System.currentTimeMillis();
    final int[] uniqueHouseLocationsSorted = HackerlandRadioTransmitters.uniqueHouseLocationsSorted(houseLocations);
    timing.sort=System.currentTimeMillis()-start;
    start=System.currentTimeMillis();
    final int minNumOfTransmitters = HackerlandRadioTransmitters.minNumOfTransmitters(uniqueHouseLocationsSorted, 73381, false);
    timing.solve1=System.currentTimeMillis()-start;
    start=System.currentTimeMillis();
    final int minNumOfTransmittersBin = HackerlandRadioTransmitters.minNumOfTransmitters(uniqueHouseLocationsSorted, 73381, true);
    timing.solve2=System.currentTimeMillis()-start;
    final long end = System.currentTimeMillis();
    return timing;
}

【讨论】:

    【解决方案2】:

    在您的时间测量中,您包含比数组搜索慢得多的操作。即文件系统 I/O 和数组排序。 一般来说,I/O(从文件系统读取/写入、网络通信)比仅涉及 CPU 和 RAM 访问的操作慢几个数量级。

    让我们以一种不会在每次循环迭代时读取文件的方式重写您的测试:

    static void test_02() throws FileNotFoundException {
            final File file = new File("input.txt");
            final Scanner scanner = new Scanner(file);
            int[] houseLocations = new int[73382];
            for (int counter = 0; counter < 73382; counter++) {
                houseLocations[counter] = scanner.nextInt();
            }
            scanner.close();
            final int rounds = 400;
            final int[] uniqueHouseLocationsSorted = uniqueHouseLocationsSorted(houseLocations);
            final int transmitterRange = 73381;
            final long start = System.currentTimeMillis();
            for (int i = 0; i < rounds; i++) {
                final int minNumOfTransmitters = minNumOfTransmitters(uniqueHouseLocationsSorted, transmitterRange);
            }
            final long end = System.currentTimeMillis();
            System.out.println("Took: " + (end - start) + " milliseconds..");
    }
    

    请注意,在此版本的测试中,文件只读一次,之后开始时间测量。 有了上面的内容,我得到了迭代版本和二分搜索的Took: 1700 milliseconds..(或多或少几毫秒)。所以我们仍然看不到二分查找更快。那是因为几乎所有时间都用于对数组进行 400 次排序。

    现在让我们从minNumOfTransmitters 方法中删除对输入数组进行排序的行。无论如何,我们在测试开始时对数组进行排序(一次)。

    现在我们可以看到事情要快得多。从minNumOfTransmitters 中删除houseLocations = uniqueHouseLocationsSorted(houseLocations) 行后,我得到:Took: 68 milliseconds.. 迭代版本。显然,由于这个持续时间已经非常小,我们不会看到与二分搜索版本有显着差异。

    所以让我们将循环次数增加到:100000
    现在我得到了迭代版本的Took: 2121 milliseconds.. 和二进制搜索版本的Took: 36 milliseconds..

    因为我们现在隔离了我们测量的内容并专注于数组搜索,而不是包括慢得多的操作,所以我们可以注意到二分搜索的性能(更好)的巨大差异。

    如果你想查看二分查找多少次进入它的while循环,你可以自己实现并添加一个计数器:

    private static int binarySearch0(int[] a, int fromIndex, int toIndex, int key) {
            int low = fromIndex;
            int high = toIndex - 1;
            int loop = 0;
            while (low <= high) {
                loop++;
                int mid = (low + high) >>> 1;
                int midVal = a[mid];
    
                if (midVal < key) {
                    low = mid + 1;
                } else if (midVal > key) {
                    high = mid - 1;
                } else {
                    return mid; // key found
                }
            }
            System.out.println("binary search looped " + loop + " times");
            return -(low + 1);  // key not found.
    }
    

    该方法是从 JDK 中的 Arrays 类复制的 - 我只是添加了循环计数器和 println。
    当要搜索的数组长度为 73382 时,循环只进入 16 次。 这正是我们所期望的:log(73382) =~ 16

    【讨论】:

    • 我不认为将transmitterRange 的值固定为 73381 是对性能的代表性测试。
    • 我将 transmitterRange 固定为 73381,因为这是最坏的情况,这意味着这最清楚地展示了迭代数组搜索和二分搜索之间的性能差异。话虽如此,使用随机的transmitterRange 仍然表明二分搜索的性能要好得多,尽管版本之间的差异只是稍微减轻了一点。
    • 我认为这实际上是一种最好的情况,而不是最坏的情况。
    【解决方案3】:

    我同意其他答案,即您的测试的主要问题是它们测量错误的东西:IO 和排序。但我不认为建议的测试是好的。我的建议如下:

    static void test_02() throws FileNotFoundException {
    
        final File file = new File("43620487.txt");
        final Scanner scanner = new Scanner(file);
        int[] houseLocations = new int[73382];
        for (int counter = 0; counter < 73382; counter++) {
            houseLocations[counter] = scanner.nextInt();
        }
        final int[] uniqueHouseLocationsSorted = uniqueHouseLocationsSorted(houseLocations);
    
    
        final Random random = new Random(0); // fixed seed to have the same sequences in all tests
        long sum = 0;
        // warm up
        for (int i = 0; i < 100; i++) {
            final int transmitterRange = random.nextInt(70000) + 1;
            final int minNumOfTransmitters = minNumOfTransmitters(uniqueHouseLocationsSorted, transmitterRange);
            sum += minNumOfTransmitters;
        }
    
        // actual measure
        final long start = System.currentTimeMillis();
        for (int i = 0; i < 4000; i++) {
            final int transmitterRange = random.nextInt(70000) + 1;
            final int minNumOfTransmitters = minNumOfTransmitters(uniqueHouseLocationsSorted, transmitterRange);
            sum += minNumOfTransmitters;
        }
        final long end = System.currentTimeMillis();
        System.out.println("Took: " + (end - start) + " milliseconds. Sum = " + sum);
    }
    

    还要注意,我删除了来自findNextTowerIndexnextHouseNotCoveredIndex 的所有System.out.println 调用以及来自minNumOfTransmittersuniqueHouseLocationsSorted 调用,因为它们也会影响性能测试。

    所以我认为这里很重要:

    1. 将所有 I/O 移出测量循环
    2. 在测量之外进行一些热身
    3. 对所有测量使用相同的随机序列
    4. 不要处理计算结果,因此 JIT 无法完全优化该调用

    通过这样的测试,我在我的机器上看到了大约 10 倍的差异:大约 80 毫秒 vs 大约 8 毫秒。

    如果你真的想在 Java 中进行性能测试,你应该考虑使用 JMH aka Java Microbenchmark Harness

    【讨论】:

      【解决方案4】:

      同意其他答案,IO时间最有问题,排序次之,搜索是最后一次消费者。

      同意 phatfingers 的例子,在你的问题中,二分搜索有时比线性搜索更糟糕,因为完全线性搜索对每个元素进行一个循环(n 次比较),但二分搜索运行塔时间(O(logn)*#tower)),一种建议是二分查找不是从 0 开始,而是从当前位置开始

       int nextTowerIndex = Arrays.binarySearch(houseLocations, houseNotCoveredIndex+1, houseLocations.length, arthestHouseLocationAllowed)
      

      那么它应该O(logn)*#tower/2) 更重要的是,也许你可以计算出每座塔盖多少房子avg,然后先比较avg的房子,然后从houseNotCoveredIndex + avg + 1开始使用二分搜索,但不确定性能好得多。

      ps:排序和唯一性,您可以将 TreeSet 用作

      public static int[] uniqueHouseLocationsSorted(final int[] houseLocations) {
          final Set<Integer> integers = new TreeSet<>();
      
          for (int houseLocation : houseLocations) {
              integers.add(houseLocation);
          }
          int[] unique = new int[integers.size()];
          int i = 0;
          for(Integer loc : integers){
              unique[i] = loc;
              i++;
          }
          return unique;
      }
      

      【讨论】:

        【解决方案5】:

        uniqueHouseLocationsSorted 效率不高,andy 解决方案似乎更好,但我认为这可以改善花费的时间(注意我没有测试代码):

        public static int[] uniqueHouseLocationsSorted(final int[] houseLocations) {
            int size = houseLocations.length;
        
            if (size == 0)  return null; // you have to check for null later or maybe throw an exception here
        
            Arrays.sort(houseLocations);
        
            final int[] houseLocationsUnique = new int[size];
        
            int previous = houseLocationsUnique[0] = houseLocations[0];
            int innerCounter = 1;
        
            for (int i = 1; i < size; i++) {
                int houseLocation = houseLocations[i];
        
                if (houseLocation == previous) continue; // since elements are sorted this is faster
        
                previous = houseLocationsUnique[innerCounter++] = houseLocation;
            }
        
            return Arrays.copyOf(houseLocationsUnique, innerCounter);
        }
        

        也可以考虑使用数组列表,因为复制数组需要时间。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2012-06-25
          • 2013-07-23
          • 1970-01-01
          • 2021-07-24
          • 1970-01-01
          • 2020-04-12
          • 1970-01-01
          相关资源
          最近更新 更多