分割統治のアイデア:行くし、その時の複雑さを並べ替えます

呉Weiminの先生は最近、このレコードにフェルトを学び、「データ構造」を読んで

 

私たちは、多くの場合、何をすべきか途方に暮れて、問題の巨大な規模に直面しているとき

しかし、私たちは、小さな問題に小さな問題を、この大きな問題を取り、その後、小さな問題に分解することができる場合

原子に分解され、最終的な問題は分解できない(プリミティブ問題) 

我々は、すべての原子問題の一般的なアプローチを見つけることができれば、その後、私たちの大きな問題が解決されます。

小さな問題に大きな問題のこの分解は、「分割統治」と呼ばれる方法[分割統治征服は私がより良い解決に翻訳すべきだと思う](ガバナンス)解決するために

イデオロギー分割統治は困難な問題を解決するために私たちを助け

 

例えば、我々は問題を解決したい:テーブルの上に8個の異なるサイズのボールがありますが、我々はそれを注文する(左から右へ)小から大まで、すべてのボールを作るために行う必要がありますどのように迅速に?

 

従来の考え方は、ボールの最小を見つけることです、そして、ボールの交換位置のほとんどを残した第2の小球を見つけ、そして第二の位置の変更を残した......

しかし、パーティションのアイデアならば、我々は4の二つのグループとして、8個のボールを持って、一次の各グループは、私たちは並んで、その後、各行の良い組み合わせと、この小さな問題は、それはそうです我々はそれが非常に簡単になりません。

ソートへの4球の二つのグループは、ソートマージの結果二つのグループの一種に8個のボールは、我々はすべてのボールが結果を命じている取得したいです。

 我々はそれの同じ考えを使用できるかどうか、4つのボールのグループのためにそう?

マージソート2個のボール2の結果の結果としてボール4のソート各ソート、2個のボールを与えるために並べ替え4

同様に、2つのボール、1個のボール各ソートの結果として、2つのボールの順序は、マージソート結果は、二つのグループボールソートを得ました

最後に、唯一の1つのボール、ボールの種類は、実際には、ソートされていない、とソート2つのボールのある前の質問に戻り、その後、新たな問題が出て中断されません

我々は2個のボール(グループごとに1つ)の結果がソートされたマージされます入れ、結果は2個のボール秩序あります

     

 

 

 これらは、私たちは4個のボールソート結果を残し組み合わせたこの結果を、置けばボールの二組(各群2)の結果は、ソートされています

 だから、どのようにそれをマージ?

今、私たちが想像してみましょう、4つのスロットがあり、我々はボールのこれらの二つのセットが4つのスロットに振り分け、効果はそれを配置する方法には、並べ替えに私たちの4個のボールを達成することであるしたいですか?

也就是要怎么合并这两组球排好序的效果呢?(两颗球排好序 + 两颗球排好序 = 四颗球排好序)

 

 容易想到,我们把两组球的最左边分别比较,为了让描述方便,给这4颗球添加字母识别

 

因为两组球,组内是有序的,所以只用比较两组的左边界就好了,就能找出两组加起来的所有球中最小的球

并且把这颗球填到最左边还空着的槽里

        

 

 同理 A 和 D 比较, A 比较小,放入1槽里

 

 

B和D比较也是一样

 

 这时,已经有一个组是空的了(左边那一组),没有了最左边界的球可以比较(如果是组里有一个球的话,这个球就是最左边界)

 那就把另一组非空的组按左到右顺序加入槽中,当然,因为这里非空组里只剩下D,D理所应当地放入3下标位置

 对排序后的两组球(每组两颗)的合并完成,也就是我们得到了4颗球的排序结果

 

同理地,用上述方法合并两组球(每组4颗)的排序结果,可以得到8颗球的排序结果

基于这个思想,正式引出我们今天要讲的排序算法 , deng deng deng deng ! 归并排序 !

如果我们把刚刚的球换成数字呢?而且是数组中的数字,我们要对数组的排序结果合并。如果刚刚的球和数字等同

那么我们刚刚能放球的空槽等价于什么呢?

 

显然我们需要一个目标数组来和他等价,用来把排好序的两组数放进去,得到我们想要的结果

 

这么一看,好像我们只用把上面那个数组合并到下面那个就够了。合并一次就能达到目的。但如果数据的数量更多,我们会发现不只移动一次

但实际上我们需要在两个数组间进行多次移动

 

比如我们有一个容量为8的数组,我们想用归并排序对他排序 :

 

 

 

整个过程如下图 

 

 

其中合并的过程 :

 

 

 

 

 

我们发现数据是在上下两个数组间来回复制的,最终合并的结果落在目标数组上,因为我们本来就是想把原数组分成两半,对两半进行排序后

合并到目标数组里。

但有特殊的情况,也就是我们的数组元素个数是奇数的情况

比如说我们对以下数组进行拆分

 

 

我们会发现在半分的过程中,有的组拆分次数不一样。

如果我们把整个过程逆过来看,一步一步分析,因为我们希望最后排序完的结果是在目标数组上的的,也就是第一行的数组是在目标数组上的

所以第二行一定是在原数组上,这才符合“把原数组分半,两半的排序结果合并到目标数组”的思想

 

 

而第二行与第三行则反之,第二行应该是在原数组上,第三行应该是在目标数组上

 

 

同理,第四行是原数组,第三行是目标数组,比较特别的是因为第四行只有2和7合并,其他元素还没进行操作,所以我们不画他们

 

 

直观一点,我们用手稿画一下,左边被正方形括起来的是‘组’

而没有括起来的是原子(如最后一行的10和-1)

 

 

 我们发现原子操作 : 对一个数字的排序和合并 就是直接将他复制到另一边,成为一个组

而对原子的复制有两种情况,一种是从原数组到目标数组,也就是倒数第二行

还有一种是从目标数组到原数组,也就是倒数第一行

但是我们发现,从目标数组到原数组的操作实际是空操作,因为原子本来就在我们的原数组里(这时候还没有进行任何操作,原数组还是原来的样子,目标数组处于原始状态),而从原数组到目标数组的原字复制才是实际有操作的,也就是把原子从原数组复制到目标数组对应位置上(倒数第二行)

整个操作只有从上到下合并(原数组到目标数组)和从下到上合并两种操作,如果我们把从上到制标记为0,从下到上标记为1

那么我们一开始的合并(第一行的合并)要标记为0

当我们对原子合并(也就是将原子复制到另一边对应位置),此时的操作标记是0的话,说明确实要复制

而为1的时候,不用复制(对应倒数最后一行)

 

废话了一大堆,终于要讲代码部分了

如果按照我们刚刚的说法,我们可以先写一下伪代码,伪代码只是一种语义的表示

我们刚刚提到的重要操作有 : 1.合并  2.排序

对这两个操作赋予语义 : 1.Merge  2.Sort

刚刚我们提到,一个大组的排序结果,就是把他分成两个小组,两个小组排序结果的合并

语义 : Sort(Big) = Merge( Sort(Big/2), Sort(Big/2) )

我们想把一个大组从中间分开,而且对分出来的两个组先进行排序,然后合并

语义 :

伪代码 :   

  Sort(int[] arr, int left, int right){
      int mid = (left + right) / 2;
      Sort(arr, left, mid);
      Sort(arr, mid + 1, right);
          Merge(arr, left, mid, right);
   }

引入目标数组tar

  Sort(int[] arr, int[] tar, int left, int right){
      int mid = (left + right) / 2;
      Sort(arr, left, mid);
      Sort(arr, mid + 1, right);
          Merge(arr, tar, left, mid, right);
   }

引入操作标志tag

  Sort(int[] arr, int[] tar, int tag, int left, int right){
      int mid = (left + right) / 2;
      Sort(arr, left, mid);
      Sort(arr, mid + 1, right);
          Merge(arr, tar, left, mid, right);
   }

引入对原子的判断

 Sort(int[] arr, int[] tar, int tag, int left, int right){
          if(left == right){} // 左边界 == 右边界 , 表示当前组只有一个元素,也就是原子
      int mid = (left + right) / 2;
      Sort(arr, left, mid);
      Sort(arr, mid + 1, right);
          Merge(arr, tar, left, mid, right);
   }

引入操作的判断,如果tag是0表示要从原数组复制到目标数组

 Sort(int[] arr, int[] tar, int tag, int left, int right){
          if(left == right){
                  if(tag == 0){
                        tar[left] = arr[left];
                  }
           return; }       
int mid = (left + right) / 2;       Sort(arr, left, mid);       Sort(arr, mid + 1, right); Merge(arr, tar, tag, left, mid, right); }

引入操作转换,比如说现在我们的tag是0,那么下一次tag就应该为1

 Sort(int[] arr, int[] tar, int tag, int left, int right){
          if(left == right){
                  if(tag == 0){
                        tar[left] = arr[left];
                  }
           return; }       
int mid = (left + right) / 2;       Sort(arr, tar, tag ^ 1,left, mid);//这里的^ : 0^1 = 1, 1^1 = 0, 起到取反作用,从而使得操作上下交替       Sort(arr, tar, tag ^ 1,mid + 1, right); Merge(arr, tar, tag, left, mid, right); }

这里我们特别注意 : mid = (left + right) / 2;

为什么我们用的是 left, mid  mid + 1, right 这种边界分组,而不是 left, mid - 1  mid, right 这种边界分组呢?

假设一下我们的left = a,  right = a + 1

也就是 left 和 right 左右边界是相邻的

那么,(left + right) / 2 = (2*a + 1) / 2 = a + 1/2

按照整型省略原则 a + 1/2 = a

那么 如果我们使用 left, mid - 1的话,实际用的是 a, a - 1

mid,right 实际是 a, a + 1  相对我们刚刚的 left, right 边界根本没有变,而且a , a - 1 会让右边界小于左边界!

如果我们使用 left, mid  实际上用的是 a, a

mid + 1, right  实际上是 a + 1, a+ 1  正好满足我们期望中的 left == right 的条件!

这是我们常见的,对线性结构分区时的边界细节。

上述就是我们的并归排序的大体伪代码,可以看出来是一个递归实现

但是还有一点,我们的Merge函数还没实现呢!

其实很简单,也就是把一个数组两个连续区域的元素按顺序加入到另一边的数组里

也就是我们上面讲过的一个图 :

回顾一下 : 

Merge函数的arr参数就是上面的那个数组,原数组,而tar则是下面的数组,目标数组,而left指的是左边小组的左边界(2所在位置),mid指的是中间

因为我们刚刚指明了

 

 

 左边小组是从 left 到 mid,右边小组是从 mid + 1 到right

 所以mid 指的是上述的 3元素所在位置下标,而 mid + 1 则是1元素所在位置下标

 mid 是左边小组的右边界,而mid + 1是左边小组的左边界

 tag 用来判断并轨操作是要从上到下还是从下到上

 

 

 

 那么我们来实现一下图中的过程:

首先要用两个变量来遍历左右两个小组,比较左右两个小组当前元素的大小,小的放到另一边的数组里

定义左边小组的边界变量为 j,右边小组的边界变量为 k,变量 i 用来记录现在我们要放东西进去的数组放到第几个位置了

因为我们上面讨论了,元素是在两个数组间复制来复制去的,所以要放东西过去的数组不一定是我们的原数组,也不一定是目标数组

 

 

 

Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){
        int i, j, k;
}

接着,既然我们知道数组之间复制来复制去,那么直接按照 tag 来判断到底谁是要被放元素进去(也就是把并归结果放进去)

Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){
        int i, j, k;
            if(tag == 1){
               int[] temp = arr;
               arr = tar;
               tar = temp;
            }
}

函数头中

Merge(int[] arr, int[] tar, int tag, int left, int mid, int right)

arr 是MergeSort放进来的,只能是原数组,而 tar 只能是目标数组

但是我们为了方便直接认定 arr 是被并归的数组,而 tar 是要被放并归结果的数组,反正函数的引用形参交换不会影响外部引用实参(如果是JAVA )

并且直接用 tag 来认定谁是 arr , tar,也就是被并归数组和接受并归结果的数组

如果 tag 是 1,说明是从原本的目标数组 tar 并归到 arr,那么 tar就是被并归的数组,让 arr 指向他

原本的 arr 是接受并归结果的数组,所以把他设置成 tar,这样只是图方便简洁,其实交换还是会降低一定效率的。

 

再来是,引入两组待合并数组的边界比较部分,两组的边界元素比较后,小的那个会放入 tar 里

Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){
        int i, j, k;
            if(tag == 1){
               int[] temp = arr;
               arr = tar;
               tar = temp;
            }
        for(i = left,j = left, k = mid + 1; j <= mid && k <= right; i ++) {
            tar[i] = arr[j] < arr[k] ? arr[j ++] : arr[k ++];
        }
    }

最后,是将剩下的元素压入 tar ( 如果一个组已经被移入 tar 移空了,那么另外一组剩下的就可以直接放入 tar 了,反正已经有序了)

Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){
        int i, j, k;
            if(tag == 1){
               int[] temp = arr;
               arr = tar;
               tar = temp;
            }
        for(i = left,j = left, k = mid + 1; j <= mid && k <= right; i ++) {
            tar[i] = arr[j] < arr[k] ? arr[j ++] : arr[k ++];
        }
        while(j <= mid){ tar[i ++] = arr[j ++]; }
        while(k <= right){ tar[i ++] = arr[k ++]; }
    }

上面这些不像伪代码的伪代码,写成Java的形式:

   
   //
调用 mergeSort 对 arr 数组排序后,arr 并不是有序的, 而 tar 才是有序
  public void mergeSort(int[] arr, int[] tar, int tag, int left, int right){
        if(left == right){
            if(tag == 0){
                tar[left] = arr[left];
            }
                return;
        }
            int mid = (left + right) / 2;
            mergeSort(arr, tar, tag ^ 1,left, mid);//这里的^ : 0^1 = 1, 1^1 = 0, 起到取反作用,从而使得操作上下交替
            mergeSort(arr, tar, tag ^ 1,mid + 1, right);
            merge(arr, tar, tag, left, mid, right);
    }

    public void merge(int[] arr, int[] tar, int tag, int left, int mid, int right){
        int i, j, k;
        if(tag == 1){
            int[] temp = arr;
            arr = tar;
            tar = temp;
        }
        for(i = left,j = left, k = mid + 1; j <= mid && k <= right; i ++) {
            tar[i] = arr[j] < arr[k] ? arr[j ++] : arr[k ++];
        }
        while(j <= mid){ tar[i ++] = arr[j ++]; }
        while(k <= right){ tar[i ++] = arr[k ++]; }
    }

 

 现在我们可以计算一下并归排序的时间复杂度

 归于递归实现的算法,时间复杂度一般可以用消去法得出

 首先,对于一个规模为 n 的问题,我们知道我们主要做了三件事

 1.左半边小组归并排序

 2.右半边小组归并排序

 3.并归

 那么对于规模 n 的问题 并轨排序耗时的表达式为 :  

 T ( n ) = 2 * T ( n / 2 ) + Tm( n )

 其中 T ( n ) 是归并排序对规模为 n 的问题的耗时 ,  Tm ( n )是归并操作对一个规模为 n 的问题的耗时

 其实容易得出 Tm ( n ) = n   因为整个归纳操作只是线性的扫描两个数组,让后把他们线性地放入到接受并归结果的数组,真正耗时可能为 k * n , 但是

 算时间复杂度一般把常数 k 省略, 因为当 n 极大时,k << n , 可以忽略

 则  T ( n ) = 2 * T ( n / 2 ) + n

 我们把 两边同时除以 n,会发现等式两边出奇对称

 T ( n ) / n = [ 2 * T ( n / 2) / n ] + 1 =[ T ( n / 2) / ( n / 2 ) ] + 1

 设 An = T(n) / n 

 则 An = A(n/2) + 1

     A(n/2) =  A(n/4) + 1

  A(n/4) =  A(n/8) + 1

     A(n/8) =  A(n/16) + 1

 ......

   A(2) =  A(1) + 1

把上述所有公式的左边全部加和等于右边全部加和

我们发现 第一行右边的 A(n/2) 可以和第二行左边的 A(n/2) 消去,第二行右边的 A(n/4) 可以和第三行左边的 A(n/4) 消去 ......

最后可以得出 An = A(1) + (log2)(n) = T(1)/1 + (log2)(n) = T(n)/n

最终把 T(n)/n 的 n 乘到左边,得到 T(n) = n*T(1) + n * (log2)(n) = n * 1 + n * (log2)(n)

从极限的角度看,可以把 n 约去

也即 T(n) = n * (log2)(n) ,

可以看出归并排序的时间复杂度是 n * logn 级别

おすすめ

転載: www.cnblogs.com/lqlqlq/p/12057240.html