使用1MB RAM对100万个8位小数进行排序

761
我有一台只有1MB RAM和没有其他本地存储的电脑。我必须使用它通过TCP连接接受100万个8位十进制数,对它们进行排序,然后通过另一个TCP连接发送已排序的列表。
这个数字列表可能包含重复项,我不能丢弃它们。代码将被放在ROM中,因此我不需要将我的代码大小从1MB中减去。我已经有了驱动以太网端口和处理TCP/IP连接的代码,并且它需要2KB的状态数据,包括1KB的缓冲区,通过该缓冲区代码将读取和写入数据。有没有解决这个问题的方法?
问题和答案来源:

slashdot.org

cleaton.net


49
一百万个八位小数的数字(最小27位整数二进制)> 1MB内存。 - Mr47
16
1M的RAM意味着2^20个字节?在这种架构中一个字节包含多少位?“1百万8位十进制数字”中的“百万”是指国际单位制中的一百万(10^6)吗?什么是8位十进制数?是小于10^8的自然数,其小数表示法有8位数字但不包括小数点,还是其他什么? - user395760
15
需要翻译的内容:1 million 8 decimal digit numbers or 1 million 8 bit numbers?一百万个8位十进制数字或者一百万个8位二进制数字? - Patrick White
15
这让我想起了《Dr Dobb's Journal》杂志上的一篇文章(大约在1998-2001年间),作者在读取电话号码时使用了插入排序来进行排序:那是我第一次意识到,有时较慢的算法可能更快... - Adrien Plisson
111
还有一种解决方案还没有被提到:购买具有2MB RAM的硬件。这不应该更加昂贵,而且会让问题变得容易得多。 - Daniel Wagner
显示剩余13条评论
36个回答

787
到目前为止,还有一种相当狡猾的技巧没有被提到。我们假设您没有额外的存储数据的方法,但这并不完全正确。
解决问题的一种方法是采用以下可怕的方法,任何情况下都不应尝试:使用网络流量来存储数据。不,我不是指NAS。
您可以按以下方式使用只有几个字节的RAM对数字进行排序:
1. 首先取2个变量:COUNTERVALUE。 2. 首先将所有寄存器设置为0; 3. 每次接收到整数I时,增加COUNTER并将VALUE设置为max(VALUE, I); 4. 然后发送一个数据设置为I的ICMP回显请求包到路由器。删除I并重复此过程。 5. 每次接收到返回的ICMP数据包时,您只需要提取整数并再次在另一个回显请求中发送它。这会产生大量来回穿梭的ICMP请求,其中包含整数。
一旦COUNTER达到1000000,您就可以通过不断的ICMP请求获得所有值,并且VALUE现在包含最大的整数。选择一些threshold T >> 1000000,将COUNTER设置为零。每次接收到ICMP数据包时,增加COUNTER并将包含的整数I发送回另一个回显请求中,除非I=VALUE,在这种情况下将其传输到排序后的整数目标。一旦COUNTER=T,将VALUE减少1,将COUNTER重置为零并重复此过程。一旦VALUE达到零,您应该已经按从大到小的顺序传输了所有整数到目标,并且只使用了约47位RAM来存储两个持久变量(以及任何需要的少量临时值)。
我知道这很可怕,我知道可能会有各种实际问题,但我认为这可能会让你们中的一些人笑或者至少感到恐惧。

41
所以基本上您正在利用网络延迟,将您的路由器变成一种排队的工具? - Eric R.
20
这并不切实际。任何有一半脑子的系统管理员都会在设备停止恶意行为前,直接禁止与该设备之间的所有流量。是的,短时间内进行1万亿次ping操作是恶意的。 - MDMarra
38
@MDMarra: 你会注意到我在最顶部写道:“解决你的问题的其中一种方法是采取以下可怕的做法,任何情况下都不应该尝试。” 我有我的理由这样说。 - Joe Fitzsimons
10
这个解决方案存在一个根本性的缺陷:必须要存储这一百万个数字。无论是在你的设备上还是路由器上。如果你只是说“发送一个ICMP”请求,数据要么存储在你本地的堆栈上,要么存储在路由器的堆栈上。如果你的设备和路由器的内存总容量不足以容纳所有数据(我不确定路由器的典型内存大小是多少...),那么这种方法就行不通。 - MartinStettner
14
这种方法实际上是一些历史计算系统中存储器的实现方式:延迟线存储器。 - JPvdMerwe
显示剩余19条评论

429

这里有一些可行的 C++ 代码,可以解决这个问题。

证明内存限制得到满足:

编辑:作者在此帖子或博客中均未提供最大内存要求的证明。由于编码值所需的位数取决于先前编码的值,因此这样的证明可能是不容易的。作者指出,他在经验上遇到的最大编码大小是1011732,并随意选择了缓冲区大小1013000

typedef unsigned int u32;

namespace WorkArea
{
    static const u32 circularSize = 253250;
    u32 circular[circularSize] = { 0 };         // consumes 1013000 bytes

    static const u32 stageSize = 8000;
    u32 stage[stageSize];                       // consumes 32000 bytes

    ...

这两个数组总共需要1045000字节的存储空间。这意味着剩余变量和堆栈空间的大小为1048576 - 1045000 - 2×1024 = 1528字节。

在我的Xeon W3520上大约需要23秒才能运行。你可以使用以下Python脚本验证程序是否有效,假设程序名为sort1mb.exe

from subprocess import *
import random

sequence = [random.randint(0, 99999999) for i in xrange(1000000)]

sorter = Popen('sort1mb.exe', stdin=PIPE, stdout=PIPE)
for value in sequence:
    sorter.stdin.write('%08d\n' % value)
sorter.stdin.close()

result = [int(line) for line in sorter.stdout]
print('OK!' if result == sorted(sequence) else 'Error!')

这个算法的详细解释可以在以下一系列文章中找到:


libstdc++ 的内存大小怎么样? :D - Gui13
32
关键观察结果是,一个8位数字包含大约26.6比特的信息,而一百万则是19.9比特。如果对列表进行差分压缩(存储相邻值的差异),则差异范围从0(0比特)到99999999(26.6比特),但您不能在每对之间都具有最大的差分。最坏情况实际上应该是一百万个均匀分布的值,需要每个差分大约为(26.6-19.9)或约6.7比特。存储一百万个6.7比特的值可以轻松地放入1M。差分压缩需要连续合并排序,所以您几乎可以免费获取它。 - Ben Jackson
4
甜蜜的解决方案。你们应该访问他的博客以获取解释。http://preshing.com/20121025/heres-some-working-code-to-sort-one-million-8-digit-numbers-in-1mb-of-ram - davec
10
@BenJackson:你的数学中有一个错误。有2.265 x 10^2436455个唯一可能的输出(由10^6个8位数字组成的有序集合),需要8.094 x 10^6位来存储(略小于1MB)。没有任何巧妙的方案可以在不丢失信息的情况下压缩超过这个信息论极限。你的解释暗示着你需要比这更少的空间,因此是错误的。事实上,“循环”在上面的解决方案中只是足够大以容纳所需的信息,因此 preshing 似乎已经考虑到了这一点,但你却忽略了它。 - Joe Fitzsimons
5
@JoeFitzsimons说:“我还没有计算出递归公式(从0到m中n个数字的唯一排序集合是(n+m)!/(n!m!)),所以你一定是正确的。也许我的估计是一个差值需要b位来存储,实际上显然差值为0时并不需要0位来存储。” - Ben Jackson
显示剩余4条评论

374
请查看第一个正确答案或者后来的带算术编码的答案。下面是一些有趣但不是100%可靠的解决方案。
这是一个非常有趣的任务,这里有另一个解决方案。我希望有人能找到结果有用(或至少有趣)。
第一阶段:初始数据结构、粗略压缩方法、基本结果
让我们进行一些简单的数学计算:我们最初有1M(1048576字节)的RAM可用于存储10^6个8位十进制数字。[0;99999999]。因此,需要27位来存储一个数字(假设使用无符号数字)。因此,要存储原始流,需要约3.5M的RAM。有人已经说过这似乎不可行,但我认为如果输入足够好,这个任务是可以解决的。基本上,思路是用0.29或更高的压缩因子压缩输入数据,并以适当的方式进行排序。

让我们先解决压缩问题。已经有一些相关测试可用:

http://www.theeggeadventure.com/wikimedia/index.php/Java_Data_Compression

“我进行了一项测试,使用各种压缩方式对一百万个连续整数进行压缩。结果如下:”
None     4000027
Deflate  2006803
Filtered 1391833
BZip2    427067
Lzma     255040

看起来LZMA(Lempel-Ziv-Markov链算法)是一个不错的选择。我已经准备了一个简单的PoC,但仍有一些细节需要强调:
  1. 内存受限,因此想法是预先排序数字并使用压缩桶(动态大小)作为临时存储
  2. 使用预排序数据可以更轻松地实现更好的压缩因子,因此每个桶都有一个静态缓冲区(在LZMA之前要对缓冲区中的数字进行排序)
  3. 每个桶保存特定范围,因此最终排序可以针对每个桶分别进行
  4. 可以正确设置桶的大小,因此将有足够的内存来解压缩存储的数据并针对每个桶进行最终排序

In-memory sorting

请注意,附加的代码是一个POC,不能用作最终解决方案,它只是演示了使用几个较小的缓冲区以某种最优方式(可能是压缩)存储预排序的数字的想法。LZMA不是最终解决方案,它只是用作引入压缩到此POC中的最快方式。
请参见下面的POC代码(请注意,这只是一个演示,需要编译LZMA-Java):
public class MemorySortDemo {

static final int NUM_COUNT = 1000000;
static final int NUM_MAX   = 100000000;

static final int BUCKETS      = 5;
static final int DICT_SIZE    = 16 * 1024; // LZMA dictionary size
static final int BUCKET_SIZE  = 1024;
static final int BUFFER_SIZE  = 10 * 1024;
static final int BUCKET_RANGE = NUM_MAX / BUCKETS;

static class Producer {
    private Random random = new Random();
    public int produce() { return random.nextInt(NUM_MAX); }
}

static class Bucket {
    public int size, pointer;
    public int[] buffer = new int[BUFFER_SIZE];

    public ByteArrayOutputStream tempOut = new ByteArrayOutputStream();
    public DataOutputStream tempDataOut = new DataOutputStream(tempOut);
    public ByteArrayOutputStream compressedOut = new ByteArrayOutputStream();

    public void submitBuffer() throws IOException {
        Arrays.sort(buffer, 0, pointer);

        for (int j = 0; j < pointer; j++) {
            tempDataOut.writeInt(buffer[j]);
            size++;
        }            
        pointer = 0;
    }

    public void write(int value) throws IOException {
        if (isBufferFull()) {
            submitBuffer();
        }
        buffer[pointer++] = value;
    }

    public boolean isBufferFull() {
        return pointer == BUFFER_SIZE;
    }

    public byte[] compressData() throws IOException {
        tempDataOut.close();
        return compress(tempOut.toByteArray());
    }        

    private byte[] compress(byte[] input) throws IOException {
        final BufferedInputStream in = new BufferedInputStream(new ByteArrayInputStream(input));
        final DataOutputStream out = new DataOutputStream(new BufferedOutputStream(compressedOut));

        final Encoder encoder = new Encoder();
        encoder.setEndMarkerMode(true);
        encoder.setNumFastBytes(0x20);
        encoder.setDictionarySize(DICT_SIZE);
        encoder.setMatchFinder(Encoder.EMatchFinderTypeBT4);

        ByteArrayOutputStream encoderPrperties = new ByteArrayOutputStream();
        encoder.writeCoderProperties(encoderPrperties);
        encoderPrperties.flush();
        encoderPrperties.close();

        encoder.code(in, out, -1, -1, null);
        out.flush();
        out.close();
        in.close();

        return encoderPrperties.toByteArray();
    }

    public int[] decompress(byte[] properties) throws IOException {
        InputStream in = new ByteArrayInputStream(compressedOut.toByteArray());
        ByteArrayOutputStream data = new ByteArrayOutputStream(10 * 1024);
        BufferedOutputStream out = new BufferedOutputStream(data);

        Decoder decoder = new Decoder();
        decoder.setDecoderProperties(properties);
        decoder.code(in, out, 4 * size);

        out.flush();
        out.close();
        in.close();

        DataInputStream input = new DataInputStream(new ByteArrayInputStream(data.toByteArray()));
        int[] array = new int[size];
        for (int k = 0; k < size; k++) {
            array[k] = input.readInt();
        }

        return array;
    }
}

static class Sorter {
    private Bucket[] bucket = new Bucket[BUCKETS];

    public void doSort(Producer p, Consumer c) throws IOException {

        for (int i = 0; i < bucket.length; i++) {  // allocate buckets
            bucket[i] = new Bucket();
        }

        for(int i=0; i< NUM_COUNT; i++) {         // produce some data
            int value = p.produce();
            int bucketId = value/BUCKET_RANGE;
            bucket[bucketId].write(value);
            c.register(value);
        }

        for (int i = 0; i < bucket.length; i++) { // submit non-empty buffers
            bucket[i].submitBuffer();
        }

        byte[] compressProperties = null;
        for (int i = 0; i < bucket.length; i++) { // compress the data
            compressProperties = bucket[i].compressData();
        }

        printStatistics();

        for (int i = 0; i < bucket.length; i++) { // decode & sort buckets one by one
            int[] array = bucket[i].decompress(compressProperties);
            Arrays.sort(array);

            for(int v : array) {
                c.consume(v);
            }
        }
        c.finalCheck();
    }

    public void printStatistics() {
        int size = 0;
        int sizeCompressed = 0;

        for (int i = 0; i < BUCKETS; i++) {
            int bucketSize = 4*bucket[i].size;
            size += bucketSize;
            sizeCompressed += bucket[i].compressedOut.size();

            System.out.println("  bucket[" + i
                    + "] contains: " + bucket[i].size
                    + " numbers, compressed size: " + bucket[i].compressedOut.size()
                    + String.format(" compression factor: %.2f", ((double)bucket[i].compressedOut.size())/bucketSize));
        }

        System.out.println(String.format("Data size: %.2fM",(double)size/(1014*1024))
                + String.format(" compressed %.2fM",(double)sizeCompressed/(1014*1024))
                + String.format(" compression factor %.2f",(double)sizeCompressed/size));
    }
}

static class Consumer {
    private Set<Integer> values = new HashSet<>();

    int v = -1;
    public void consume(int value) {
        if(v < 0) v = value;

        if(v > value) {
            throw new IllegalArgumentException("Current value is greater than previous: " + v + " > " + value);
        }else{
            v = value;
            values.remove(value);
        }
    }

    public void register(int value) {
        values.add(value);
    }

    public void finalCheck() {
        System.out.println(values.size() > 0 ? "NOT OK: " + values.size() : "OK!");
    }
}

public static void main(String[] args) throws IOException {
    Producer p = new Producer();
    Consumer c = new Consumer();
    Sorter sorter = new Sorter();

    sorter.doSort(p, c);
}
}

使用随机数,它会产生以下结果:
bucket[0] contains: 200357 numbers, compressed size: 353679 compression factor: 0.44
bucket[1] contains: 199465 numbers, compressed size: 352127 compression factor: 0.44
bucket[2] contains: 199682 numbers, compressed size: 352464 compression factor: 0.44
bucket[3] contains: 199949 numbers, compressed size: 352947 compression factor: 0.44
bucket[4] contains: 200547 numbers, compressed size: 353914 compression factor: 0.44
Data size: 3.85M compressed 1.70M compression factor 0.44

对于简单的升序序列(使用一个桶),它会产生:

bucket[0] contains: 1000000 numbers, compressed size: 256700 compression factor: 0.06
Data size: 3.85M compressed 0.25M compression factor 0.06

编辑

结论:

  1. 不要试图欺骗Nature
  2. 使用更简单、占用更少内存的压缩方法
  3. 确实需要一些额外的线索。常见的防弹解决方案似乎行不通。

第二阶段:增强型压缩,最终结论

如前一节所述,可以使用任何适合的压缩技术。因此,我们可以放弃LZMA,采用更简单、更好(如果可能的话)的方法。有很多好的解决方案,包括算术编码基数树等。

无论如何,简单但有用的编码方案将比另一个提供一些奇妙算法的外部库更具说明性。实际的解决方案非常简单:由于存在部分排序数据的存储桶,可以使用增量而不是数字。

encoding scheme

随机输入测试显示略微更好的结果:

bucket[0] contains: 10103 numbers, compressed size: 13683 compression factor: 0.34
bucket[1] contains: 9885 numbers, compressed size: 13479 compression factor: 0.34
...
bucket[98] contains: 10026 numbers, compressed size: 13612 compression factor: 0.34
bucket[99] contains: 10058 numbers, compressed size: 13701 compression factor: 0.34
Data size: 3.85M compressed 1.31M compression factor 0.34

示例代码

  public static void encode(int[] buffer, int length, BinaryOut output) {
    short size = (short)(length & 0x7FFF);

    output.write(size);
    output.write(buffer[0]);

    for(int i=1; i< size; i++) {
        int next = buffer[i] - buffer[i-1];
        int bits = getBinarySize(next);

        int len = bits;

        if(bits > 24) {
          output.write(3, 2);
          len = bits - 24;
        }else if(bits > 16) {
          output.write(2, 2);
          len = bits-16;
        }else if(bits > 8) {
          output.write(1, 2);
          len = bits - 8;
        }else{
          output.write(0, 2);
        }

        if (len > 0) {
            if ((len % 2) > 0) {
                len = len / 2;
                output.write(len, 2);
                output.write(false);
            } else {
                len = len / 2 - 1;
                output.write(len, 2);
            }

            output.write(next, bits);
        }
    }
}

public static short decode(BinaryIn input, int[] buffer, int offset) {
    short length = input.readShort();
    int value = input.readInt();
    buffer[offset] = value;

    for (int i = 1; i < length; i++) {
        int flag = input.readInt(2);

        int bits;
        int next = 0;
        switch (flag) {
            case 0:
                bits = 2 * input.readInt(2) + 2;
                next = input.readInt(bits);
                break;
            case 1:
                bits = 8 + 2 * input.readInt(2) +2;
                next = input.readInt(bits);
                break;
            case 2:
                bits = 16 + 2 * input.readInt(2) +2;
                next = input.readInt(bits);
                break;
            case 3:
                bits = 24 + 2 * input.readInt(2) +2;
                next = input.readInt(bits);
                break;
        }

        buffer[offset + i] = buffer[offset + i - 1] + next;
    }

   return length;
}

请注意,这种方法:
  1. 不会占用大量内存
  2. 适用于流处理
  3. 提供的结果还算不错

完整代码可以在这里找到,BinaryInput和BinaryOutput实现可以在这里找到。

最终结论

没有最终结论 :) 有时候从元级别的角度审视任务是一个非常好的想法。

花费一些时间来完成这个任务很有趣。顺便说一下,下面有很多有趣的答案。感谢您的关注,祝您编程愉快。


17
我使用的是Inkscape。顺便说一句,这是个非常棒的工具。你可以使用这个图表的源文件作为例子。 - Renat Gilmanov
22
LZMA在这种情况下需要太多的内存,难道不实用吗?作为一种算法,它的目的是最小化需要存储或传输的数据量,而不是在内存方面高效。 - Mjiig
68
这是胡说八道...获取100万个27位随机整数,排序后使用7zip、xz或任何你想用的LZMA压缩。结果超过1MB。前提是对连续数字进行压缩。用0位的Delta编码就是这个数字本身,比如1000000(占用4个字节)。对于有序且没有重复数字的情况下,数字1000000和1000000位=128KB,其中重复数字用0表示,下一个数字用1表示。但当存在随机间隔时,即使很小,使用LZMA也是荒谬的。它不是为此而设计的。 - alecco
31
这实际上行不通。我运行了一个仿真,虽然压缩后的数据超过1MB(大约1.5MB),但它仍然使用了超过100MB的RAM来压缩数据。因此,即使是压缩的整数也不能解决问题,更不用说运行时内存使用情况。授予您赏金是我在stackoverflow上犯下的最大错误。 - Favourite Onwuemene
10
这个答案得到许多赞是因为很多程序员喜欢华丽的想法,而不是经过验证的代码。如果这个想法可行,你会看到实际选择和验证的压缩算法,而不仅仅是一种肯定存在可以做到的断言……当可能根本没有可以做到的算法时。 - Olathe
显示剩余14条评论

188

只因1兆字节和100万字节之间的差异,才有此解决方案。有约2的8093729.5次方种选择1百万个8位数字(允许重复且顺序不重要)的不同方式,因此只拥有1百万字节RAM的机器无法表示所有可能性。但1M(减去TCP/IP的2k)等于1022*1024*8 = 8372224位,因此可以找到解决方案。

第一部分,初始解决方案

这种方法需要略多于1M,我将对其进行优化以适应1M的限制。

我将按照子列表的顺序存储0到99999999范围内的数字的紧凑排序列表,作为7位数字的子列表序列。第一个子列表包含从0到127的数字,第二个子列表包含从128到255的数字,以此类推。100000000/128正好是781250,因此需要781250个这样的子列表。

每个子列表由一个2位的子列表头和一个子列表主体组成。子列表主体占用每个子列表条目的7位。所有子列表都连接在一起,并且格式使得可以确定一个子列表的结束位置和下一个子列表的开始位置。完全填充的列表所需的总存储空间为2*781250 + 7*1000000 = 8562500位,约为1.021兆字节。

4个可能的子列表头值如下:

00 空的子列表,没有后续内容。

01 单元素列表,子列表中只有一个条目,下一个7位存储该条目。

10 子列表至少包含2个不同的数字。这些条目以非递减的顺序存储,但最后一个条目小于或等于第一个条目。这样可以识别子列表的结尾。例如,数字2、4、6将存储为(4,6,2)。数字2、2、3、4、4将存储为(2,3,4,4,2)。

11 子列表包含两个或更多个单个数字的重复。下一个7位给出了该数字。然后是零个或多个带有值1的7位条目,后跟一个具有值0的7位条目。子列表主体的长度规定了重复次数。例如,数字12、12将存储为(12,0),数字12、12、12将存储为(12,1,0),数字12、12、12、12将存储为(12,1,1,0)等等。

我从空列表开始,读入一堆数字并将它们存储为32位整数,原地排序新数字(可能使用堆排序),然后将它们合并到一个新的紧凑排序列表中。重复此操作,直到没有更多的数字可读取,然后再次遍历紧凑列表以生成输出。

下面的行表示在列表合并操作开始之前的内存状态。 "O"是保存已排序的32位整数的区域。 "X"是保存旧紧凑列表的区域。 "="表示用于紧凑列表的扩展空间,每个“O”中有7位整数。 "Z"是其他随机开销。

ZZZOOOOOOOOOOOOOOOOOOOOOOOOOO==========XXXXXXXXXXXXXXXXXXXXXXXXXX

合并例程从最左边的“O”和最左边的“X”开始读取,并从最左边的“=”开始写入。直到合并所有新整数,写指针才会捕捉到紧凑列表读取指针,因为两个指针对于每个子列表前进2位并且对于旧紧凑列表中的每个条目前进7位,并且有足够的额外空间用于新数字的7位条目。

第二部分,把它压缩成1M

为了将上述解决方案压缩到1M,我需要使紧凑列表格式更加紧凑。我会摆脱其中一种子列表类型,这样就只有3种不同的可能的子列表头值了。然后我可以使用“00”,“01”和“1”作为子列表头值并节省一些位。子列表类型是:

A 空的子列表,没有任何内容跟随。

B 单例,子列表中只有一个条目,下一个7位保存它。

C 子列表至少包含2个不同的数字。 条目按非递减顺序存储,除了最后一个条目小于或等于第一个条目。这允许确定子列表的末尾。例如,数字2,4,6将存储为(4,6,2)。数字2,2,3,4,4将存储为(2,3,4,4,2)。

D 子列表由单个数字的2个或更多重复组成。

我的3个子列表头值将是“A”,“B”和“C”,所以我需要一种方法来表示D类型子列表。

假设我有C类型子列表标题,后跟3个条目,例如“C [17] [101] [58]”。根据上述描述,这不能是有效的C类型子列表的一部分,因为第三个条目小于第二个但大于第一个。我可以使用这种类型的构造来表示D类型子列表。在位方面,无论何处我都有“C {00????}{1????}{01????”}都是不可能的C类型子列表。我将使用此来表示由单个数字的3个或更多重复组成的子列表。前两个7位字编码数字(下面的“N”位),后跟零个或多个{0100001}字,后跟{0100000}字。

For example, 3 repetitions: "C{00NNNNN}{1NN0000}{0100000}", 4 repetitions: "C{00NNNNN}{1NN0000}{0100001}{0100000}", and so on.

这里只剩下只包含单个数字2次重复的列表。我将使用另一个不可能的C类型子列表模式表示它们:"C{0??????}{11?????}{10?????}"。前两个字中有足够空间存储7位数字,但是该模式比所表示的子列表更长,这使得事情变得有些复杂。末尾的五个问号可以被认为不是模式的一部分,因此我的模式为:"C{0NNNNNN}{11N????}10",其中要重复的数字存储在“N”中。这多出了2位。

我需要借用2位,并从该模式中未使用的4位中归还它们。在读取时,当遇到“C{0NNNNNN}{11N00AB}10”时,输出“N”中的2个实例,用A和B位覆盖末尾的“10”,并将读指针向后退回2位。对于这个算法来说,破坏性读取是可以的,因为每个紧凑列表只被处理一次。

在写入单个数字2次重复的子列表时,写入“C{0NNNNNN}11N00”,并将借用的位计数器设置为2。在借用位计数器非零的每次写入时,每写入1位就会减少1,当计数器达到零时,写入“10”。因此,接下来写入的2位将进入A和B位,然后“10”将被放置在末尾。

对于3个子列表头值,我使用“00”、“01”和“1”,并将“1”分配给最常见的子列表类型。我需要一个小表将子列表头值映射到子列表类型,并且我需要每个子列表类型的出现计数器,以便我知道最佳的子列表头映射是什么。

当所有子列表类型等受欢迎时,完全填充的紧凑列表的最坏情况下的最小表示法是发生。在这种情况下,对于每3个子列表头,我可以节省1位,因此列表大小为2*781250 + 7*1000000 - 781250/3 = 8302083.3位。向32位字边界舍入,即8302112位,或1037764字节。

从1M中减去TCP/IP状态和缓冲区的2k是1022 * 1024 = 1046528字节,留给我的可用空间是8764字节。

但是更改子列表头映射的过程怎么办?在下面的内存映射中,“Z”是随机开销,“=”是空闲空间,“X”是紧凑列表。

ZZZ=====XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

从最左边的 "X" 开始阅读,从最左边的 "=" 开始写作,并向右工作。完成后,紧凑列表将会稍微缩短,并位于内存的错误端:

ZZZXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=======

那么我需要把它移到右边:

ZZZ=======XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
在头映射更改过程中,多达1/3的子列表头将从1位变为2位。在最坏的情况下,这些所有更改都将在列表的开头,因此我至少需要 781250/3 位的可用存储空间才能开始,这使我回到了先前版本的压缩列表的内存需求:(为了解决这个问题,我将781250个子列表分成10个子列表组,每个组有自己独立的子列表头映射。使用字母A到J来表示这些组:
ZZZ=====AAAAAABBCCCCDDDDDEEEFFFGGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ

每个子列表组在子列表头映射更改期间会收缩或保持不变:

ZZZ=====AAAAAABBCCCCDDDDDEEEFFFGGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAA=====BBCCCCDDDDDEEEFFFGGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABB=====CCCCDDDDDEEEFFFGGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCC======DDDDDEEEFFFGGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCCDDDDD======EEEFFFGGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCCDDDDDEEE======FFFGGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCCDDDDDEEEFFF======GGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCCDDDDDEEEFFFGGGGGGGGGG=======HHIJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCCDDDDDEEEFFFGGGGGGGGGGHH=======IJJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCCDDDDDEEEFFFGGGGGGGGGGHHI=======JJJJJJJJJJJJJJJJJJJJ
ZZZAAAAAABBCCCDDDDDEEEFFFGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ=======
ZZZ=======AAAAAABBCCCDDDDDEEEFFFGGGGGGGGGGHHIJJJJJJJJJJJJJJJJJJJJ

在映射更改期间,子列表组的最坏情况临时扩展是78125/3 = 26042位,在4k以下。如果我允许使用4k加上1037764字节来完全填充紧凑列表,那么剩下的空间是8764-4096 = 4668字节来存储内存映射中的“Z”。

对于10个子列表头映射表、30个子列表头出现计数和其他少量计数器、指针和小缓冲区,应该足够了。还有一些被用作函数调用返回地址和本地变量堆栈空间等我没有注意到的空间。

第三部分,需要多长时间才能运行?

使用空紧凑列表时,1位列表头将用于一个空子列表,并且列表的起始大小将为781250位。在最坏的情况下,列表每增加一个数字就会增长8位,因此每个32位数字需要40位的自由空间放置在列表缓冲区顶部,然后进行排序和合并。在最坏的情况下,更改子列表头映射会导致2 * 781250 + 7 * entries - 781250 / 3位的空间使用率。

采用在列表中有至少800000个数字之后每五次合并更改子列表头映射的策略,最坏情况下运行将涉及大约30M的紧凑列表读写活动。

来源:

http://nick.cleaton.net/ramsortsol.html


15
如果我们需要处理不可压缩的值,我认为没有更好的解决方案了。但这个方法还可以稍加改进。无需在1位和2位表示之间更改子列表标题,而是可以使用算术编码,它简化了算法,并将每个标题的最坏位数从1.67降低到1.58。你也无需将紧凑列表移动到内存中;而是使用循环缓冲区,并仅更改指针。 - Evgeny Kluev
5
那么,最后,那是一道面试题吗? - mlvljr
2
另一个可能的改进是使用100元素的子列表,而不是128元素的子列表(因为当子列表的数量等于数据集中的元素数量时,可以获得最紧凑的表示)。每个子列表的值都将使用算术编码进行编码(每个值的频率相等,为1/100)。这可以节省大约10000位,远远少于子列表头部的压缩。 - Evgeny Kluev
1
他有一个特殊情况,当列表只是一个或多个重复单个数字的列表时。 - aronchick
1
除非我漏掉了什么,否则这种方法使用的内存比处理D类型子列表(也称为之前的11个案例)所需的内存要多得多。为什么您要通过重复整个字节来存储重复次数,而不是使用每个重复的1位(为每个重复存储1,0表示结束),或者其他更有效的编码(例如,将重复次数的二进制数字存储在254个字节中,使用255作为标志继续进入下一个字节以获取更大的数字。 - Patronics
显示剩余4条评论

57

Gilmanov的答案在假设上非常错误。它基于一百万个连续整数的一个无意义的度量进行推测。这意味着没有间隙。然而,那些随机间隙,即使很小,也会使它成为一个糟糕的想法。

自己试试。获取1百万个随机的27位整数、排序、用7-Zip、xz或任何LZMA压缩,结果超过1.5 MB。前提是对连续数字的压缩。即使delta编码也需要超过1.1 MB。更不用说,这需要使用超过100 MB的RAM进行压缩。所以,即使是压缩后的整数也不适合这个问题,更不用考虑运行时的RAM使用情况

令人悲哀的是人们只投票支持漂亮的图像和合理化。

#include <stdint.h>
#include <stdlib.h>
#include <time.h>

int32_t ints[1000000]; // Random 27-bit integers

int cmpi32(const void *a, const void *b) {
    return ( *(int32_t *)a - *(int32_t *)b );
}

int main() {
    int32_t *pi = ints; // Pointer to input ints (REPLACE W/ read from net)

    // Fill pseudo-random integers of 27 bits
    srand(time(NULL));
    for (int i = 0; i < 1000000; i++)
        ints[i] = rand() & ((1<<27) - 1); // Random 32 bits masked to 27 bits

    qsort(ints, 1000000, sizeof (ints[0]), cmpi32); // Sort 1000000 int32s

    // Now delta encode, optional, store differences to previous int
    for (int i = 1, prev = ints[0]; i < 1000000; i++) {
        ints[i] -= prev;
        prev    += ints[i];
    }

    FILE *f = fopen("ints.bin", "w");
    fwrite(ints, 4, 1000000, f);
    fclose(f);
    exit(0);

}

现在使用LZMA对ints.bin进行压缩...

    $ xz -f --keep ints.bin       # 100 MB RAM
    $ 7z a ints.bin.7z ints.bin   # 130 MB RAM
    $ ls -lh ints.bin*
        3.8M ints.bin
        1.1M ints.bin.7z
        1.2M ints.bin.xz

7
任何涉及基于字典压缩的算法都太过荒谬了,我编写了一些自定义的算法,它们需要相当大的内存来放置它们自己的哈希表(Java中没有HashMap,因为它对资源需求过于高)。最接近的解决方案是使用可变比特长度的 Delta 编码,并反弹回你不喜欢的TCP数据包。同行会重新传输,但在最好的情况下也很奇怪。 - bestsss
@bestsss 是的!看看我的最新答案,我认为这可能是可行的。 - alecco
6
抱歉,但是实际上这似乎也没有回答问题。 - n611x007
@naxa 是的,它回答了:在原始问题的参数内无法完成。只有当数字的分布熵非常低时才能完成。 - alecco
1
这个答案只是表明标准压缩程序在压缩低于1MB的数据时存在困难。可能存在一种编码方案可以将数据压缩到少于1MB,但这个答案并没有证明不存在这样的编码方案可以将数据压缩到这个程度。 - Itsme2003

42

我认为从组合学的角度来看,可以考虑有多少可能的排序数列组合?如果我们给定了组合0,0,0,...,0对应的码为0,组合0,0,0,...,1对应的码为1,以及组合99999999, 99999999, ... 99999999对应的码为N,则N是多少?换句话说,结果空间有多大?

那么,一种思考这个问题的方式是注意到这是一个双射函数问题,即在 N × M 的网格中找到单调路径的数量,其中 N = 1,000,000,M = 100,000,000。换句话说,如果你有一个宽为1,000,000和高为100,000,000的网格,从左下角到右上角有多少最短路径?当然,最短路径要求您只能向右或向上移动(如果向下或向左移动,您将会撤销之前完成的进展)。为了看到这是我们数字排序问题的双射函数,观察以下内容:

你可以将路径中任何水平段想象为排序中的数字,其中该段的Y位置表示值。

enter image description here

因此,如果路径简单地向右移动到末尾,然后一路跳到顶部,那相当于排序0,0,0,...,0。如果它开始跳到顶部,然后向右移动1,000,000次,那就相当于99999999,99999999,..., 99999999。一条路径先向右移动一次,然后向上移动一次,再向右移动一次,再向上移动一次,以此类推直到末尾(然后必须跳到顶部),相当于排序0,1,2,3,...,999999。

幸运的是,这个问题已经解决了,这样的网格有(N + M) Choose (M) 条路径:

(1,000,000 + 100,000,000)选择(100,000,000)~=2.27*10^2436455

因此,N等于2.27*10^2436455,所以代码0表示0,0,0,...,0,代码2.27*10^2436455和一些变化表示99999999,99999999,...,99999999。

为了存储从0到2.27*10^2436455的所有数字,您需要lg2(2.27*10^2436455)=8.0937*10^6位。

1兆字节=8388608位>8093700位

因此,看起来我们至少实际上有足够的空间来存储结果!当然,有趣的部分是在流入数字时进行排序。不确定最佳方法是什么,因为我们还剩下294908位。我想一个有趣的技术是在每个点上假设那是整个排序,找到该排序的代码,然后在接收到新数字时返回并更新先前的代码。挥手挥手。


1
这真的是很多的手挥动。从理论上讲,这是解决方案,因为我们可以编写一个大的——但仍然有限的——状态机;另一方面,对于那个大状态机的指令指针的大小可能超过1兆字节,使其无法启动。实际上,这确实需要比这更多的思考来解决给定的问题。我们不仅需要表示所有状态,还需要表示计算下一个输入数字时需要执行的所有过渡状态。 - Daniel Wagner
5
我认为其他答案只是在掩盖它们的概括性。既然我们现在知道了结果空间的大小,那么我们就知道我们绝对需要多少空间。没有其他答案能够在8093700位以下存储每一个可能的答案,因为这就是最终状态的数量。对最终状态进行压缩可能会在某些情况下减小空间,但总会有一些答案需要完整的空间(没有压缩算法可以压缩所有输入)。 - Francisco Ryan Tolmasky I
其他几个答案已经提到了硬下限(例如,原问题提问者答案的第二句话),所以我不确定这个答案对整体有什么贡献。 - Daniel Wagner
1
有大约2的8093729.5次方种不同的方式可以选择100万个允许重复且顺序不重要的8位数。 - Daniel Wagner
1
抱歉,这就是我为什么要问的原因。无论如何,仅仅知道下限仍然缺乏额外的洞察力,即该大小本身可能以某种方式被视为答案。我的回答的目标是展示我们如何得出下限以及该边界实际告诉我们有关问题的信息。我想我发帖的重点更多地是“对于任何解决方案而言,在剩余的位中必须能够完成其余部分”。我认为这有助于更深入地了解正在发生的事情,而不是直接跳入列表压缩实现。如果您觉得这没有用处,那我很抱歉。 - Francisco Ryan Tolmasky I
显示剩余2条评论

20

在这里,我的建议借鉴了Dan的解决方案

首先,我假设该解决方案必须处理所有可能的输入列表。我认为流行的答案并没有做出这个假设(在我看来这是一个巨大的错误)。

众所周知,无损压缩的形式将不能减小所有输入的大小。

所有流行的答案都假定他们能够应用足够有效的压缩来为他们提供额外的空间。实际上,需要足够大的额外空间以未压缩形式保存部分完成的列表,并允许它们执行排序操作。这只是一个糟糕的假设。

对于这样的解决方案,任何了解它们如何进行压缩的人都能够设计一些不适合此方案的输入数据,因此“解决方案”最有可能因为用尽空间而失败。

相反,我采用数学方法。我们的可能输出是长度为LEN,由范围为0..MAX中的元素组成的所有列表。这里LEN为1,000,000,MAX为100,000,000。

对于任意LEN和MAX,编码此状态所需的位数为:

Log2(MAX Multichoose LEN)

因此,对于我们的数字,一旦我们完成接收和排序,我们将需要至少Log2(100,000,000 MC 1,000,000)位来以可以唯一地区分所有可能的输出的方式存储我们的结果。

这是约等于988kb。因此,我们实际上有足够的空间来保存我们的结果。从这个角度来看,它是可能的。

[删除了毫无意义的胡言乱语,现在有更好的示例...]

最佳答案在这里

另一个很好的答案在这里,基本上使用插入排序作为将列表扩展一个元素的函数(缓冲一些元素并进行预排序,以允许一次性插入多个元素,节省了一些时间)。还使用了一个漂亮而紧凑的状态编码,七位增量的桶。


总是很有趣的第二天重新阅读自己的答案...因此,虽然顶部答案是错误的,但接受的答案https://dev59.com/mWcs5IYBdhLWcg3wjkwY#12978097相当不错。基本上使用插入排序作为函数来获取列表LEN-1并返回LEN。利用了这样一个事实,即如果您预先对小集合进行排序,则可以在一次传递中将它们全部插入,以提高效率。状态表示非常紧凑(7位数字的桶),比我手摇的建议更直观。我的计算机几何思想是胡说八道,对此感到抱歉 - davec
1
我认为你的算术有点出错。我得到 lg2(100999999!/(99999999! * 1000000!)) = 1011718.55。 - NovaDenizen
是的,谢谢。它是988kb而不是965kb。我在1024和1000之间搞糊涂了。我们还有大约35kb左右可以玩耍。我在答案中添加了一个数学计算的链接。 - davec

19
假设这个任务是可能的。在输出之前,将有一个百万个已排序数字的内存表示。有多少不同的这样的表示方法?由于可能会有重复的数字,我们不能使用nCr(选择),但有一种称为multichoose的操作可用于multisets
  • 2.2e2436455种方法可以选择0到99,999,999范围内的一百万个数字。
  • 这需要8,093,730位来表示每个可能的组合,或1,011,717字节。
因此,从理论上讲,如果您可以想出一个理智(足够)的排序数字列表表示方式,那么这可能是可能的。例如,疯狂的表示可能需要一个10MB的查找表或数千行代码。
然而,如果“1M RAM”表示一百万个字节,那么显然空间不足。理论上5%的内存增加能够使其成为可能,这表明表示方式必须非常高效且可能不合理。

选择一百万个数字的方式数量(2.2e2436455)恰好接近于(256 ^(1024 * 988)),即(2.0e2436445)。因此,如果从1M中减去约32 KB的内存,则无法解决该问题。还要记住至少保留了3 KB的内存。 - johnwbyrd
当然,这假定数据是完全随机的。据我们所知,它确实是随机的,但我只是说一下 :) - Thorarin
传统表示可能状态的方法是取以2为底的对数,并报告表示它们所需的位数。 - NovaDenizen
@Thorarin,是的,我认为对于只适用于某些输入的“解决方案”毫无意义。 - Dan

12

这样怎么样?

前27位存储你见过的最小数字,然后是下一个数字与它的差,编码如下:使用5位存储差值所需的位数,然后是差值。使用00000表示再次看到该数字。

这种方法可行的原因是随着插入更多数字,数字之间的平均差值会降低,因此添加更多数字时,用于存储差异的位数也会减少。我相信这被称为增量列表。

我能想到的最坏情况是所有数字等间距(间隔100),例如假设0是第一个数字:

000000000000000000000000000 00111 1100100
                            ^^^^^^^^^^^^^
                            a million times

27 + 1,000,000 * (5+7) bits = ~ 427k

Reddit来拯救你!
如果你只需要排序,这个问题就很简单了。需要122k(1百万位)来存储你已经看过的数字(如果看过0,则第0位为1;如果看过2300,则第2300位为1等等)。
读取数字,将其存储在位域中,然后在保持计数的同时移出位。
但是,你必须记住你看过多少个数字。我受到上面子列表答案的启发,想出了这个方案:
不要使用一个位,而是使用2或27位:
00表示你没有看到这个数字。 01表示你看到了一次 1表示你看到了它,并且接下来的26位是它出现的次数。
我认为这个方法可行:如果没有重复,你会得到一个244k的列表。在最坏的情况下,你会看到每个数字两次(如果你看到一个数字三次,那么它会缩短你剩下的列表),这意味着你看到50,000个数字超过一次,而950,000个数字0或1次。
50,000 * 27 + 950,000 * 2 = 396.7k。
如果你使用以下编码,你可以进一步改进:
0表示你没有看到这个数字 10表示你看到了一次 11是如何计数的
这将平均导致280.7k的存储。
编辑:我的周日早晨数学是错的。
最坏的情况是我们看到500,000个数字两次,因此数学变为:
500,000 *27 + 500,000 *2 = 1.77M
备用编码的平均存储容量为
500,000 * 27 + 500,000 = 1.70M
:(

1
不会,因为第二个数字将是500000。 - jfernand
也许可以添加一些中间值,例如11表示您已经看到该数字多达64次(使用接下来的6位),而11000000表示使用另外32位来存储您已经看到它的次数。 - τεκ
10
你从哪里得到了“100万比特”的数字?你说第2300个比特代表是否看到了2300(我认为你实际上是指第2301个)。哪个比特代表是否看到了99999999(最大的8位数)?很可能是第1亿个比特。 - user94559
如果你在谈论线性序列中的256间隔,那么我进入的是1018.821289k < 1024k。该序列环绕10,000,000次,相当于2.56次。 - jfernand
1,000,000个比特 /(8个比特/字节)= 125000个字节 - jfernand
显示剩余5条评论

10

对于所有可能的输入,有一个解决方案。作弊。

  1. 通过TCP读取m个值,其中m接近可以在内存中排序的最大值,也许是n/4。
  2. 对大约250,000个数字进行排序并输出。
  3. 重复上述过程,处理剩下的3/4。
  4. 让接收器在处理数据时合并其已接收到的4个数字列表。(与使用单个列表相比,速度并不慢很多。)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接