zoukankan      html  css  js  c++  java
  • PHP的HashTable实现

    转载自: http://it.taocms.org/07/1145.htm

     

    数据结构:

    HashTable数据结构的描述在Zend/zend_hash.h文件中。首先,HashTable中的每一个元素都保存在下面这样的一个struct中:

    typedef struct bucket {
        ulong h;            /* hash值,下标为数字索引时,h就是索引值 */
        uint nKeyLength;    /* key字符串的长度,当nKeyLength为0时表示是数字索引 */
        void *pData;        /* 实际存放的数据 */
        void *pDataPtr;     /* 数据指针 */
        struct bucket *pListNext;      /* 链表中的下一个元素 */
        struct bucket *pListLast;      /* 链表中的上一个元素 */
        struct bucket *pNext;          /* Hash拉链的下一个元素 */
        struct bucket *pLast;          /* Hash拉链的上一个元素 */
        const char *arKey;             /* key字符串指针 */
    } Bucket;

    PHP使用了双向链表和Hash表结合的方式实现HashTable,这使得HashTable能够在O(1)的时间复杂度上实现任意key的查询,同时又可以支持HashTable的遍历。

    接下来看看HashTable的定义,大致做了下注释,和鸟哥的注释是一样的:

    typedef struct _hashtable {
        uint nTableSize;            /* hash表的大小 */
        uint nTableMask;            /* 掩码,用于根据hash值计算存储位置,永远等于nTableSize-1 */ 
        uint nNumOfElements;        /* hash表中元素的个数 */ 
        ulong nNextFreeElement;     /* 下一个空闲可用位置的数字索引 */ 
        Bucket *pInternalPointer;   /* 内部指针,用于HashTable遍历 */ 
        Bucket *pListHead;          /* 双向链表的头指针 */ 
        Bucket *pListTail;          /* 双向链表的尾指针 */ 
        Bucket **arBuckets;         /* 指向bucket指针的容器 */ 
        dtor_func_t pDestructor;    /* 元素的析构函数指针 */ 
        zend_bool persistent; 
        unsigned char nApplyCount;  /* 循环遍历保护 */ 
        zend_bool bApplyProtection; 
    #if ZEND_DEBUG 
        int inconsistent; 
    #endif 
    } HashTable;

    为了更好的理解上面的数据结构,我参照鸟哥的手绘图,画了一个稍美观点的图示来说明上面那些个指针的作用:

    HashTable上图是一个有6个元素的HashTable,并且当前正在访问第2个元素,左边arBuckets指针列表中的数字和右边bucket中的数字没有对应关系。

    在PHP代码中,任何一个变量都是以zval的结构存储,而zval是使用一个zvalue_value指针来指向真正的数据的。zvalue_value是一个Union结构,这个结构包含一个HashTable指针,因此PHP中的变量就可以是一个HashTable,而且实际上我们在PHP中用到的HashTable是在HashTable上包了一层的zval结构:

    struct _zval_struct {
        zvalue_value value; /* 变量的值 */
        zend_uint refcount__gc;
        zend_uchar type;    /* 变量当前的数据类型 */
        zend_uchar is_ref__gc;
    };
    typedef struct _zval_struct zval;
    
    typedef union _zvalue_value {
        long lval;  /* long value */
        double dval;    /* double value */
        struct {
            char *val;
            int len;
        } str;
        HashTable *ht;  /* hash table value */
        zend_object_value obj;
    } zvalue_value;

    创建HashTable:

    创建(初始化)一个HashTable可以有很多种途径,但基本步骤是相通的,我以创建一个HashTable类型的zval为例来说明。

    首先需要创建一个标准的zval,也就是为这个zval申请内存空间:

    zval *array;              
    MAKE_STD_ZVAL(array);    /* 为zval初始化内存空间,并将地址付给zval指针array */

    MAKE_STD_ZVAL是用来创建标准zval的宏,上面的代码相当于:

    array = (zval *) emalloc(sizeof(zval));
    array->refcount__gc = 1;
    array->is_ref__gc = 0;

    然后可以调用array_init来初始化这个HashTable:

    array_init(array);

    以上代码相当于:

    (*array).value.ht = (HashTable *) emalloc_rel(sizeof(HashTable));
    _zend_hash_init((*array).value.ht, 0, NULL, ZVAL_PTR_DTOR, 0 ZEND_FILE_LINE_RELAY_CC);
    (*array).type = IS_ARRAY;

    初始化过程可以总结为:

    1. 为zval的value.ht申请sizeof(HashTable)大小的内存
    2. 调用_zend_hash_init函数初始化
    3. 设置zval的type为IS_ARRAY

    其中重点就是_zend_hash_init这个函数了。如果我这里不需要创建一个HashTable类型的zval,只是创建一个HashTable而已,那么需要调用zend_hash_init这个宏来进行初始化:

    HashTable *ht;
    ALLOC_HASHTABLE(ht);
    zend_hash_init(ht, 0, NULL, NULL, 0);

    这里我简化了一下zend_hash_init的调用参数,ALLOC_HASHTABLE是为这个HashTable申请内存空间,然后zend_hash_init是个宏,实际上调用的是上面的_zend_hash_init函数,那么接下来就看一下这个函数做了什么。

    ZEND_API int _zend_hash_init(HashTable *ht, uint nSize, hash_func_t pHashFunction, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_D
    {
        uint i = 3;       /* 初始化HashTable大小时,是按照2的整数次幂来初始化,最小为2的3次幂 */
    
        SET_INCONSISTENT(HT_OK); /* 调试用,初始化当前HashTable的状态 */
    
        if (nSize >= 0x80000000) {
            /* HashTable的最大值,防止溢出 */
            ht->nTableSize = 0x80000000;
        } else {
            while ((1U << i) < nSize) {   /* 根据传入的size按照2的整数次幂决定HashTable的大小 */
                i++;
            }
            ht->nTableSize = 1 << i;
        }
    
        ht->nTableMask = 0; /* 0 表示 ht->arBuckets 还没有初始化 */
        ht->pDestructor = pDestructor;  /* 设置析构函数为传入的函数指针 */
        ht->arBuckets = (Bucket**)&uninitialized_bucket;
        ht->pListHead = NULL;
        ht->pListTail = NULL;
        ht->nNumOfElements = 0;
        ht->nNextFreeElement = 0;
        ht->pInternalPointer = NULL;
        ht->persistent = persistent;
        ht->nApplyCount = 0;
        ht->bApplyProtection = 1;
        return SUCCESS;
    }

    代码中pHashFunction这个参数已经不用了,仅仅是为了保持向下兼容留在这里而已。pDestructor是HashTable元素的析构指针,在更新/销毁HashTable中元素的时候会用到。

    zend_hash_init并没有为存放buckets申请内存空间,只是设置了初始化的size,并且设置了nTableMask为0,表示ht->arBuckets还未初始化。整个HashTable的size是按照2的整数次幂申请的,最小为2的3次幂,若空间不够则尝试2的4次幂、2的5次幂……直到大于传入的size。

    那么buckets的空间是什么时候申请的呢?答案就是在操作HashTable的时候,不过不属于创建HashTable的范畴了,接下来就来看看如何操作HashTable。

    操作HashTable:

    添加/更新元素:

    在初始化了HashTable之后,可以用zend_hash_add来向HashTable添加元素 ,zend_hash_add是一个宏:

    #define zend_hash_add(ht, arKey, nKeyLength, pData, nDataSize, pDest) 
            _zend_hash_add_or_update(ht, arKey, nKeyLength, pData, nDataSize, pDest, HASH_ADD ZEND_FILE_LINE_CC)

    那么实际上是调用_zend_hash_add_or_update这个函数:

    ZEND_API int _zend_hash_add_or_update(HashTable *ht, const char *arKey, uint nKeyLength, void *pData, uint nDataSize, void **pDest, int flag ZEND_FILE_LINE_DC)
    {
        ulong h;
        uint nIndex;
        Bucket *p;
    #ifdef ZEND_SIGNALS
        TSRMLS_FETCH();
    #endif
    
        IS_CONSISTENT(ht);
    
        if (nKeyLength <= 0) {
    #if ZEND_DEBUG
            ZEND_PUTS("zend_hash_update: Can't put in empty keyn");
    #endif
            return FAILURE;
        }
    
        CHECK_INIT(ht);       /* 检查是否初始化buckets空间,若没有初始化则初始化buckets的内存空间 */
    
        h = zend_inline_hash_func(arKey, nKeyLength);   /* 计算key的hash值 */
        nIndex = h & ht->nTableMask;    /* 利用掩码得到key的实际存储位置 */
    
        p = ht->arBuckets[nIndex];      /* 取到指定位置的bucket指针 */
        while (p != NULL) {             /* 若指针不为空,则表示当前位置已有bucket了 */
            if (p->arKey == arKey ||
                ((p->h == h) && (p->nKeyLength == nKeyLength) && !memcmp(p->arKey, arKey, nKeyLength))) {
    		/* 若当前bucket的key和要存入的key相同,那么需要更新 */
                    if (flag & HASH_ADD) {  /* 如果当前指定是add操作,此时就返回失败了 */
                        return FAILURE;
                    }
                    HANDLE_BLOCK_INTERRUPTIONS();
    #if ZEND_DEBUG
                    if (p->pData == pData) {
                        ZEND_PUTS("Fatal error in zend_hash_update: p->pData == pDatan");
                        HANDLE_UNBLOCK_INTERRUPTIONS();
                        return FAILURE;
                    }
    #endif
                    if (ht->pDestructor) {    /* 调用析构函数析构掉原先的值 */
                        ht->pDestructor(p->pData);
                    }
                    UPDATE_DATA(ht, p, pData, nDataSize);    /* 替换为新的值 */
                    if (pDest) {
                        *pDest = p->pData;
                    }
                    HANDLE_UNBLOCK_INTERRUPTIONS();
                    return SUCCESS;
            }
            p = p->pNext;   /* 若当前key和要存入的key不同,那么查找Hash拉链的下一个bucket
        }
        /* 运行到这里,表示没有找到任何已存在的key和要存入的key相同的,那么申请一个sizeof(bucket)+nKeyLength大小的新空间给key */
        if (IS_INTERNED(arKey)) {
            p = (Bucket *) pemalloc(sizeof(Bucket), ht->persistent);
            if (!p) {
                return FAILURE;
            }
            p->arKey = arKey;
        } else {
           p = (Bucket *) pemalloc(sizeof(Bucket) + nKeyLength, ht->persistent);
            if (!p) {
                return FAILURE;
            }
            p->arKey = (const char*)(p + 1);
            memcpy((char*)p->arKey, arKey, nKeyLength);
        }
        p->nKeyLength = nKeyLength;
        INIT_DATA(ht, p, pData, nDataSize);                    /* 执行赋值 */
        p->h = h;
        CONNECT_TO_BUCKET_DLLIST(p, ht->arBuckets[nIndex]);    /* 设置乱七八槽的指针 */
        if (pDest) {
            *pDest = p->pData;
        }
    
        HANDLE_BLOCK_INTERRUPTIONS();
        CONNECT_TO_GLOBAL_DLLIST(p, ht);
        ht->arBuckets[nIndex] = p;
        HANDLE_UNBLOCK_INTERRUPTIONS();
    
        ht->nNumOfElements++;
        ZEND_HASH_IF_FULL_DO_RESIZE(ht);        /* If the Hash table is full, resize it */
        return SUCCESS;
    }

    我在上面加了注释,应该能看懂了,其中重点是执行赋值的地方,INIT_DATA这个宏是这么定义的:

    #define INIT_DATA(ht, p, pData, nDataSize);                             
        if (nDataSize == sizeof(void*)) {                                   
            memcpy(&(p)->pDataPtr, pData, sizeof(void *));                  
            (p)->pData = &(p)->pDataPtr;                                    
        } else {                                                            
            (p)->pData = (void *) pemalloc_rel(nDataSize, (ht)->persistent);
            if (!(p)->pData) {                                              
                pefree_rel(p, (ht)->persistent);                            
                return FAILURE;                                             
            }                                                               
            memcpy((p)->pData, pData, nDataSize);                           
            (p)->pDataPtr=NULL;                                             
        }

    这里有一个tricks,PHP判断数据的大小和一个void指针相同时,就不为其申请额外的空间,而是将数据copy到pDataPtr字段中,也就是说,如果你add到HashTable的是一个指针,那么他直接被保存在pDataPtr字段中,同时pData字段也会保存一份。如果你add到HashTable的是一个更大的结构,那么PHP会为这个结构单独申请内存空间,将数据copy到这片新申请的内存空间中,然后将pDataPtr设置为NULL。这在鸟哥的那篇博客中有提到:

    在Bucket中,实际的数据是保存在pData指针指向的内存块中,通常这个内存块是系统另外分配的。但有一种情况例外,就是当Bucket保存 的数据是一个指针时,HashTable将不会另外请求系统分配空间来保存这个指针,而是直接将该指针保存到pDataPtr中,然后再将pData指向本结构成员的地址。这样可以提高效率,减少内存碎片。由此我们可以看到PHP HashTable设计的精妙之处。如果Bucket中的数据不是一个指针,pDataPtr为NULL(本段来自Altair<eniac2008@hotmail.com>的”Zend HashTable详解”)

    当我们要操作的是一个HashTable类型的zval时,可以用add_assoc_*系列函数:

    add_assoc_null(zval *aval, char *key);
    add_assoc_bool(zval *aval, char *key, zend_bool bval);
    add_assoc_long(zval *aval, char *key, long lval);
    add_assoc_double(zval *aval, char *key, double dval);
    add_assoc_string(zval *aval, char *key, char *strval, int dup);
    add_assoc_stringl(zval *aval, char *key,
                        char *strval, uint strlen, int dup);
    add_assoc_zval(zval *aval, char *key, zval *value);

    我们看其中的一个函数的实现:

    #define add_assoc_string(__arg, __key, __str, __duplicate) add_assoc_string_ex(__arg, __key, strlen(__key)+1, __str, __duplicate)
    
    ZEND_API int add_assoc_string_ex(zval *arg, const char *key, uint key_len, char *str, int duplicate) /* {{{ */
    {
        zval *tmp;
    
        MAKE_STD_ZVAL(tmp);
        ZVAL_STRING(tmp, str, duplicate);
    
        return zend_symtable_update(Z_ARRVAL_P(arg), key, key_len, (void *) &tmp, sizeof(zval *), NULL);
    }

    过程就是用传入的char *创建了一个字符串类型的zval,然后插入arg的下标key的位置,重点在于zend_symtable_update,这和刚才用的不太一样,那么这个函数做了什么呢?它实际上也调用了zend_hash_update,只不过在之前调用了一下ZEND_HANDLE_NUMERIC这个宏:

    static inline int zend_symtable_update(HashTable *ht, const char *arKey, uint nKeyLength, void *pData, uint nDataSize, void **pDest)
    {
        ZEND_HANDLE_NUMERIC(arKey, nKeyLength, zend_hash_index_update(ht, idx, pData, nDataSize, pDest));
        return zend_hash_update(ht, arKey, nKeyLength, pData, nDataSize, pDest);
    }

    ZEND_HANDLE_NUMERIC的作用就是当传入的key为字符串类型的数字时,将其转成ulong类型。

    最后再系统看一下添加和更新相关的函数或者宏:

    /* additions/updates/changes */
    ZEND_API int _zend_hash_add_or_update(HashTable *ht, const char *arKey, uint nKeyLength, void *pData, uint nDataSize, void **pDest, int flag ZEND_FILE_LINE_DC);
    #define zend_hash_update(ht, arKey, nKeyLength, pData, nDataSize, pDest) 
            _zend_hash_add_or_update(ht, arKey, nKeyLength, pData, nDataSize, pDest, HASH_UPDATE ZEND_FILE_LINE_CC)
    #define zend_hash_add(ht, arKey, nKeyLength, pData, nDataSize, pDest) 
            _zend_hash_add_or_update(ht, arKey, nKeyLength, pData, nDataSize, pDest, HASH_ADD ZEND_FILE_LINE_CC)
    
    ZEND_API int _zend_hash_quick_add_or_update(HashTable *ht, const char *arKey, uint nKeyLength, ulong h, void *pData, uint nDataSize, void **pDest, int flag ZEND_FILE_LINE_DC);
    #define zend_hash_quick_update(ht, arKey, nKeyLength, h, pData, nDataSize, pDest) 
            _zend_hash_quick_add_or_update(ht, arKey, nKeyLength, h, pData, nDataSize, pDest, HASH_UPDATE ZEND_FILE_LINE_CC)
    #define zend_hash_quick_add(ht, arKey, nKeyLength, h, pData, nDataSize, pDest) 
            _zend_hash_quick_add_or_update(ht, arKey, nKeyLength, h, pData, nDataSize, pDest, HASH_ADD ZEND_FILE_LINE_CC)
    
    ZEND_API int _zend_hash_index_update_or_next_insert(HashTable *ht, ulong h, void *pData, uint nDataSize, void **pDest, int flag ZEND_FILE_LINE_DC);
    #define zend_hash_index_update(ht, h, pData, nDataSize, pDest) 
            _zend_hash_index_update_or_next_insert(ht, h, pData, nDataSize, pDest, HASH_UPDATE ZEND_FILE_LINE_CC)
    #define zend_hash_next_index_insert(ht, pData, nDataSize, pDest) 
            _zend_hash_index_update_or_next_insert(ht, 0, pData, nDataSize, pDest, HASH_NEXT_INSERT ZEND_FILE_LINE_CC)
    
    ZEND_API int zend_hash_add_empty_element(HashTable *ht, const char *arKey, uint nKeyLength);

    _zend_hash_add_or_update刚才已经介绍过了,_zend_hash_index_update_or_next_insert则是用于添加或更新数字下标的元素,或者在HashTable末尾追加元素。 _zend_hash_quick_add_or_update则是为了性能考虑,当需要多次对同一个key进行操作时,可以先利用zend_get_hash_value()计算出hash值,然后在参数中传入,这样就无需每次都计算key的hash值了。

    删除元素:

    删除元素使用zend_hash_del系列的宏和函数,它们的定义如下:

    ZEND_API int zend_hash_del_key_or_index(HashTable *ht, const char *arKey, uint nKeyLength, ulong h, int flag);
    #define zend_hash_del(ht, arKey, nKeyLength) 
            zend_hash_del_key_or_index(ht, arKey, nKeyLength, 0, HASH_DEL_KEY)
    #define zend_hash_quick_del(ht, arKey, nKeyLength, h) 
            zend_hash_del_key_or_index(ht, arKey, nKeyLength, h, HASH_DEL_KEY_QUICK)
    #define zend_hash_index_del(ht, h) 
            zend_hash_del_key_or_index(ht, NULL, 0, h, HASH_DEL_INDEX)

    接下来zend_hash_del_key_or_index函数的代码如下:

    ZEND_API int zend_hash_del_key_or_index(HashTable *ht, const char *arKey, uint nKeyLength, ulong h, int flag)
    {
        uint nIndex;
        Bucket *p;
    #ifdef ZEND_SIGNALS
        TSRMLS_FETCH();
    #endif
    
        IS_CONSISTENT(ht);
    
        if (flag == HASH_DEL_KEY) {                  /* 如果是按照key来删除,则计算相应的hash值 */
            h = zend_inline_hash_func(arKey, nKeyLength);
        }
        nIndex = h & ht->nTableMask;
    
        p = ht->arBuckets[nIndex];
        while (p != NULL) {
            if ((p->h == h)
                 && (p->nKeyLength == nKeyLength)
                 && ((p->nKeyLength == 0)                        /* 性能优化,当按照索引删除时,不去比较key的内容 */
                     || !memcmp(p->arKey, arKey, nKeyLength))) { /* 按照key删除 */
                HANDLE_BLOCK_INTERRUPTIONS();
                if (p == ht->arBuckets[nIndex]) {                /* 若命中的是hash通的首元素,则修改hash桶首元素的指针指向第二个元素 */
                    ht->arBuckets[nIndex] = p->pNext;
                } else {
                    p->pLast->pNext = p->pNext;                /* 否则将前一个元素的pNext指向下一个元素(也就是从Hash拉链中移除当前元素) */
                }
                if (p->pNext) {
                    p->pNext->pLast = p->pLast;		  /* 修改向前的指针 */
                }
                if (p->pListLast != NULL) {                         /* 开始修改双向连表的指针,和修改Hash拉链的指针类似 */
                    p->pListLast->pListNext = p->pListNext;
                } else {
                    /* Deleting the head of the list */
                    ht->pListHead = p->pListNext;
                }
                if (p->pListNext != NULL) {
                    p->pListNext->pListLast = p->pListLast;
                } else {
                    ht->pListTail = p->pListLast;
                }
                if (ht->pInternalPointer == p) {                   /* 若需要,调整内部指针到下一个元素 */
                    ht->pInternalPointer = p->pListNext;
                }
                if (ht->pDestructor) {                                  /* 调用析构函数对元素进行析构 */
                    ht->pDestructor(p->pData);
                }
                if (p->pData != &p->pDataPtr) {                /*  当HashTable中的元素不是指针时,释放添加时为数据申请的内存 */
                    pefree(p->pData, ht->persistent);
                }
                pefree(p, ht->persistent);                          /* 释放为bucket申请的内存 */
                HANDLE_UNBLOCK_INTERRUPTIONS();
                ht->nNumOfElements--;
                return SUCCESS;
            }
            p = p->pNext;
        }
        return FAILURE;
    }

    当我们将数据添加到HashTable中时,需要注意PHP是如何释放内存的,我之前遇到的问题就主要集中在此,这要分几种情况来讨论。

    情况一:我自己申请了一块内存,用指针p指向这块内存,调用zend_hash_add的时候,将p添加到HashTable,类似于如下代码:

    my_type *p;
    p = malloc(sizeof(my_type));
    zend_hash_add(ht, "key", sizeof("key"), (void **)&p, sizeof(p), NULL);

    PHP首先会申请sizeof(Bucket) +4的空间来存放一个Bucket,+4的空间是Bucket用来保存key的,然后PHP会判断因为当期传入pData的是个指针,所以PHP不会为pData申请额外的空间,直接将其放入pDataPtr和pData字段中。

    当删除元素的时候,PHP会释放掉为Bucket申请的内存,那么在此之前的调用析构函数的步骤,就是我们唯一能够清理我们自己申请的内存的机会了。我们需要定义一个函数,并且在zend_hash_init的时候就传进去,因此代码看起来应该是这样的:

    zend_hash_init(ht, 0, NULL, my_free, 0);
    
    my_type *p;
    p = malloc(sizeof(my_type));
    zend_hash_add(ht, "key", sizeof("key"), (void **)&p, sizeof(p), my_free);
    
    /* definition */
    static void my_free(void *p){
      free(*(my_type **)p);
    }

    情况二:我自己申请了一块内存,想将这块内存的内容add到HashTable中,那么代码看起来是这样的:

    my_type *p;
    p = malloc(sizeof(my_type));
    zend_hash_add(ht, "key", sizeof("key"), (void *)p, sizeof(my_type), NULL);

    这个时候,PHP同样会先为Bucket申请sizeof(Bucket)+4的空间,然后判断由于add进来的数据不是个指针,PHP会自己申请sizeof(my_type)大小的空间,然后将p指向的地址的内存copy进去。

    当删除元素的时候,PHP同样会先调用析构函数,然后释放pData的空间,最后释放Bucket的空间。可以看出对于这种情况,PHP会自己申请/释放数据的空间,那么我们在将数据add到HashTable之后就可以释放掉它了,不过更好的办法是使用栈内存:

    my_type p;
    /* do some assignment here */
    ……
    
    zend_hash_add(ht, "key", sizeof("key"), (void *)&p, sizeof(p), NULL);

    这样不需要自己定义析构函数,挺方便。

    有同学可能会问,既然第二种情况看上去这么方便,为什么还需要第一种?我们可以想象这样一个情况,我们需要插入的数据来自另外一个结构体中的指针,当我们通过HashTable对数据进行修改之后,希望通过另外一个结构体访问到这个数据时,数据也是实时更新的。这样就不能对数据进行copy,只能传递指针。同时当HashTable这个结构析构的时候,还要保证数据通过另一个结构体依然能够访问,就需要维护一个引用计数,在析构函数中判断这个引用计数的大小来决定要不要释放数据的内存。

    还有两个函数涉及到内存的释放:

    ZEND_API void zend_hash_destroy(HashTable *ht);
    ZEND_API void zend_hash_clean(HashTable *ht);

    这两个函数都会依次调用每一个元素的析构函数,清理每一个bucket的空间。他们的区别是,zend_hash_destroy会清理掉zend_hash_init申请的bucket指针列表的空间,而zend_hash_clean只是将其指针都置0。

  • 相关阅读:
    cf C. Vasya and Robot
    zoj 3805 Machine
    cf B. Vasya and Public Transport
    cf D. Queue
    cf C. Find Maximum
    cf B. Two Heaps
    cf C. Jeff and Rounding
    cf B. Jeff and Periods
    cf A. Jeff and Digits
    I Think I Need a Houseboat
  • 原文地址:https://www.cnblogs.com/mingaixin/p/4323036.html
Copyright © 2011-2022 走看看