【问题标题】:Is there a better way to do Insertion Sort?有没有更好的方法来进行插入排序?
【发布时间】:2020-12-11 22:18:14
【问题描述】:

YouTube 视频插入排序 - https://www.youtube.com/watch?v=JU767SDMDvA

这是我在 C 中的实现

void insertionSort(void *data, uint32_t length, int8_t compareTo(const void * const, const 
  void * const), const size_t bytes){
  uint32_t i;
  for(i = 0; i < length; i++){
    uint8_t isSorted;
    int32_t j;
    isSorted = 0;
    for(j = i - 1; j > -1 && !isSorted; j--){
        isSorted = 1;
        if(compareTo((int8_t *)data + j * bytes, (int8_t *)data + (j + 1) * bytes) > 0){
            uint32_t byteIndex;
            void *valCopy;
            valCopy = malloc(bytes);
            memcpy(valCopy, (int8_t *)data + j * bytes, bytes);

            for(byteIndex = 0; byteIndex < bytes; byteIndex++){
                *((int8_t *)data + (j * bytes + byteIndex)) = *((int8_t *)data + ((j + 1) * bytes + byteIndex));
                *((int8_t *)data + ((j + 1) * bytes + byteIndex)) = *((int8_t *)valCopy + byteIndex);
            }

            /**
            instead of the for loop you can replace it with this to make it look more clean
            memcpy((int8_t *)data + j * bytes, (int8_t *)data + (j + 1) * bytes, bytes);
            memcpy((int8_t *)data + (j + 1) * bytes, valCopy, bytes);
            **/

            free(valCopy);
            isSorted = 0;
      }
    }
  }
}


int8_t compareTo(const void * const val1, const void * const val2){
   if(*(const int32_t * const)val1 > *(const int32_t * const)val2)return 1;
   else if(*(const int32_t * const)val1 < *(const int32_t * const)val2)return -1;
   return 0;
}

int main(void){
   int32_t i;
   int32_t data[] = {2, 6, 5, 3, 8, 7, 1, 0};
   int32_t dataLength = sizeof(data) / sizeof(*data);

   insertionSort(data, dataLength, &compareTo, sizeof(int32_t));

   for(i = 0; i < dataLength; i++){
       printf("%d ", data[i]);
   }

   return 0;
}

我想知道是否有比每次使用 memcpy 或 for 循环复制值更有效的方法?

【问题讨论】:

    标签: c algorithm sorting memcpy insertion-sort


    【解决方案1】:

    正如另一个答案已经观察到的那样,不需要为每次交换调用 malloc()free()。所有这些额外的电话似乎确实是效率低下的最大来源。您最多需要一个malloc 和一个free 呼叫,但是对于不大于您选择的限制的项目大小,您可以在没有any 的情况下逃脱。例如,

    #define SMALL_ITEM_LIMIT 1024 /* for example */
    
    // ...
    
    void insertionSort(void *data, uint32_t length,
        int8_t compareTo(const void * const, const void * const), const size_t bytes) {
        char auto_buffer[SMALL_ITEM_LIMIT];
        char *temp;
    
        if (bytes > SMALL_ITEM_LIMIT) {
            temp = malloc(bytes);
            if (temp == NULL) {
                // ... handle allocation failure ...
            }
        } else {
            temp = auto_buffer;
        }
    
        // ... main part of the sort ...
    
        if (temp != auto_buffer) {
            free(temp);
        }
    }
    

    作为一个小问题,变量isSorted 的使用是不必要的,而且有点笨拙。当当前元素到达其插入位置时,您可以通过从 j 循环中简单地 breaking 来避免它并可能减少几个周期。

    你问:

    我想知道是否有比复制值更有效的方法 每次使用 memcpy 还是 for 循环?

    对于这样的通用排序,您不知道要排序的项目的类型,对于移动元素,除了批量内存操作之外别无选择。我倾向于从memcpy() 和/或memmove() 开始,因为它更清楚。在没有对各种情况进行测试以确定它是否真正提供任何改进的情况下,请勿使用内部循环。

    但是,您不一定需要一次将元素移动一个位置。相反,在每次外循环迭代中,您可以在不移动任何内容的情况下定位插入位置,然后通过单个 n 元素旋转执行插入。对于随机数据,这往往会执行更少的读取和写入。这种变化可能看起来像这样(一些名称已更改以使其更清晰):

    void insertionSort(void *data, uint32_t item_count,
            int compare(const void *, const void *), size_t item_size) {
        char auto_buffer[SMALL_ITEM_LIMIT];
        char *temp = (item_size > SMALL_ITEM_LIMIT) ? malloc(item_size) : auto_buffer;
    
        if (temp) {
            char *char_data = data;  // for clarity; avoids having to cast all the time
    
            for (uint32_t i = 1; i < count; i++) { // no point in starting at 0
                // Find the insertion position
                for (uint32_t j = i; j > 0; j--) {
                    if (compare(char_data +  j      * item_size,
                                char_data + (j - 1) * item_size) >= 0) {
                        break;
                    }
                }
                // Rotate the current item into position
                if (j != i) {
                    memcpy(temp, char_data + i * item_size, item_size);
                    memmove(char_data +  j      * item_size,
                            char_data + (j + 1) * item_size,
                            (i - j) * item_size);
                    memcpy(char_data + j * item_size, temp, item_size);
                }
            }
    
            if (temp != auto_buffer) {
                free(temp);
            }
        } // else memory allocation failed
    }
    

    或者,在实践中,与比较更并行地实现旋转以更好地利用缓存和数据局部性可能会更有效。这就像每次交换只执行一半(或三分之一)的交换。排序循环是这样的:

            for (uint32_t i = 1; i < count; i++) { // no point in starting at 0
                // store element i
                memcpy(temp, char_data + i * item_size, item_size);
    
                // Find the insertion position
                for (uint32_t j = i; j > 0; j--) {
                    if (compare(char_data +  j      * item_size,
                                char_data + (j - 1) * item_size) < 0) {
                        // shift element j - 1 up one position
                        memcpy(char_data + (j - 1) * item_size,
                               char_data +  j      * item_size,
                               item_size);
                    } else {
                        break;
                    }
                }
    
                if (j != i) {
                    // Put the erstwhile value of element i into its position
                    memcpy(char_data + j * item_size, temp, item_size);
                }
            }
    

    在任何特定情况下,其中哪些实际上会在实践中表现更好,这是一个需要通过测试来回答的问题。

    【讨论】:

    • 使用static char auto_buffer[SMALL_ITEM_LIMIT];不是更好吗?不过答案很好。
    • 不,它不会@PatrickRoberts,因为那样它就不是线程安全的。在典型的实现中,分配局部变量实际上是免费的。
    • 还有另一个鲜为人知的优化。将该元素与0处的元素进行比较。如果较小,则旋转数组;无需致电compare。否则,只比较值;无需测试j &gt; 0:0 处的元素是自然哨兵。条件分支的数量减半。
    • 很好的观察,@user58697。我发现这对随机数据没有明显的影响,但它对反向排序数据的最坏情况产生了巨大的影响。
    • 这不是我的观察。信用到信用到期的地方。我是从 Alex Stepanov 那里学来的,他说他是从 Donald Knuth 那里学来的。
    【解决方案2】:

    实现中效率最低的部分是对malloc()free() 的不必要调用,它们应该只需要调用一次,然后在算法中的每次交换中重复使用。这是一个可能的实现:

    void isort(void* ptr, size_t count, size_t size, int (*comp)(const void*, const void*))
    {
        size_t i;
        size_t j;
        bool sorted;
        void* a;
        void* b;
        void* t;
    
        // don't allocate temporary memory when unneeded
        if (count == 0) return;
        t = malloc(size);
    
        for (i = 0; i < count; ++i)
        {
            sorted = false;
    
            for (j = i - 1; j >= 0 && !sorted; --j)
            {
                sorted = true;
                a = (char*)ptr + size * j;
                b = (char*)ptr + size * (j + 1);
    
                if (comp(a, b) > 0)
                {
                    memcpy(t, a, size);
                    memcpy(a, b, size);
                    memcpy(b, t, size);
                    sorted = false;
                }
            }
        }
    
        free(t);
    }
    

    Try it on godbolt

    【讨论】:

      【解决方案3】:

      插入排序不适用于数组,因为必须执行昂贵的数组重新排列。应在linkedLists 上使用插入排序。在数组上,一个类似但更好的算法是选择排序。当然,快速排序或归并排序等分而治之的算法也不错。

      【讨论】:

      • 对链表使用合并排序。对于链表,归并排序很简单,不需要额外的内存(或者最多 log(n) 堆栈内存,取决于你是怎么做的),并且是 O(n*log(n))。
      • 虽然建议不要对数组使用插入排序一般来说是个好建议,但它忽略了这个问题的要点,该问题引起了对插入排序实现的批评,而不是针对练习选择的算法。
      • 几乎每个排序算法都会重新排列被排序的元素(计数排序是一个例外,但它不适用于许多类型的数据)。在某些(但不是全部)情况下,链表上的重排可能比数组上的更便宜,但如果它是一个需要排序的数组,那就无关紧要了。而且它本身并没有强烈地区分不同的算法。插入排序是数组的一个很好的选择——平均而言,它是数组和链表等比较排序中最好的。并不是说这实际上解决了所提出的问题。
      • @JohnBollinger 插入排序和选择排序具有相同的比较性能 (n^2),但选择排序不需要打乱数据,因此需要更少的交换(n 交换而不是 n^2 交换)。对于数组,插入排序不如选择排序以及分治策略(n log n)。这对链表来说很好,因为交换不需要重新洗牌,但没有理由在数组上使用它 - 句号。
      • @John,两者具有相同渐近缩放的比较计数,但插入排序在平均情况下执行的比较次数是选择排序的一半。此外,每个比较至少有一个读取关联,在插入情况下,每个比较都有一个写入关联。假设读取和写入的成本相同,则与比较相关的读取减少为插入情况下的额外写入付出了代价,但选择排序仍然需要为顶部的交换和额外的比较付出代价。
      猜你喜欢
      • 2011-10-17
      • 1970-01-01
      • 1970-01-01
      • 2013-12-09
      • 1970-01-01
      • 1970-01-01
      • 2011-01-01
      • 2020-03-30
      • 2019-12-08
      相关资源
      最近更新 更多