上述三个问题具有一些共性,本文给出这种共性的描述与总结,然后给出一套固定的分析模式,指导读者如何***正确清晰***的去分析这类问题。
注:正确清晰指的是,按照本文给的这种模式去分析,写代码的时候会很少出错,思考的思路有非常有迹可循。
注2:理解本文的前提,需要读者对上述三个问题有一个比较具体的了解,对三个问题的O(n)时间复杂度的解法知道。
三个问题的共性与区别
注:此处特指O(n)复杂度的解法。
共性: 通过索引将数组划分成已处理部分与未处理部分。其中已处理部分指的是,已经符合某种条件了。
然后在一遍遍历的过程中,通过数组元素的交换操作,与索引的更新操作,不断的扩充已处理部分,缩小待处理部分,直至待处理部分大小为0,算法结束。此时整个数组处于一种符合我们要求的状态。
区别:
- 已处理部分与未处理部分的划分方式不同
- 已处理部分符合的条件不同
- 由于划分条件的不同,所以导致索引的初始化与遍历的终止条件不同
- 由于已处理部分符合的条件不同,所以在遍历过程中的交换操作与索引的更新操作是不同的。
分析的固定模式
在分析的过程中先思考下述问题:
-
已处理部分与未处理部分如何划分?由此导致的索引的初始值应该是什么?
初始的时候,已处理部分长度肯定是0;未处理部分长度肯定是整个数组长度;
在整个过程中已处理部分与未处理部分是数组的一个划分(即不交叉,相加后是整个数组)。 -
已处理部分应该符合什么条件?
这个当然需要具体问题具体分析了。但是万变不离其宗:当已处理部分为整个数组的时候,应该使得整个数组处于一种我们需要的状态。(不一定就是最终的解) -
遍历的终止条件是什么?
显然,这也是具体问题具体分析了。但是万变不离其宗:终止条件就是未处理部分的长度为0。 -
在遍历的过程中,如何根据遍历的元素值不同,怎么通过元素的交换操作,与索引的更新操作,确保已处理部分的扩充而且性质没有变化呢?
这就完全是具体问题具体分析了。但是牢记这个过程的模式,都是根据遍历的元素不同,然后进行两部分操作:元素的交换,索引的更新。达到一个目标:每次迭代的时候将已处理部分大小扩充1,且保持扩充后的已处理部分仍然符合2中的条件。
注:由于遍历过程中的每次迭代处理一个元素,已处理部分的扩充也应该是每次迭代扩充1。
小结:后续针对具体问题,我们就按照上述模式思考;然后根据具体问题分析,如何使用代码针对具体问题表达上述分析结果。
示例:PARTITION过程
注:此处描述的PARTITION过程,相较于快排中略有不同。
问题描述:给定一个数组A[l…r],一个pivot;将数组A原址重新排列,使得A的左半部分全部小于等于pivot,右半部分全部大于pivot。
问题分析:
-
已处理部分与未处理部分如何划分?索引的初始值是什么?
根据题目描述,数组显然划成三部分:最左边的小于等于pivot的部分;中间的大于pivot的部分;右边的未处理部分。索引引入两个索引i,j就可以进行划分了:
A[l,i] 是小于等于pivot的部分;A[i+1,j-1]是大于pivot的部分;A[j,r]是未处理部分;A[j]是当前迭代正在处理的元素。
所以各索引的初始值为:
i = l-1; j = l; -
已处理部分符合什么条件?
第一部分已经描述了,已处理部分应该有:
A[l,i]的元素全部小于等于pivot;A[i+1,j-1]全部大于pivot。接下来我们检查就会发现,当未处理部分长度为0的时候,根据上述性质,显然整个数组被划分成左右两部分了:左边是小于等于pivot的;右边是大于pivot的。即符合我们的要求。 -
遍历的终止条件是什么?
遍历的终止条件即未处理部分长度为0。在本题中即A[j,r]长度为0,所以即j=r+1。 -
遍历过程中的每次迭代,元素交换操作,索引更新操作?
if (A[j] <= pivot) i++; swap(A[i], A[j]); j++; else (A[j] > pivot) j++; /* 分情况分析:两种情况下都能够将已处理部分扩充1,且保持扩充后的已处理部分符合2中描述条件。 详见:https://blog.youkuaiyun.com/LemintC/article/details/94549317 */
代码:
9 void quickSortPartition(vector<int> &A, int pivot)
10 {
11 /*
12 quicksort patition过程略有变化——————将数组A按照pivot划分成左右两部分
13 其中左边<=pivot,右边>pivot
14
15 数组A[l...r]
16
17 A[l,i] 已处理部分中<=pivot的
18 A[i+1, j-1] 已处理部分中>pivot的
19 A[j,r] 未处理部分
20 A[j] 下一待处理元素
21 */
22
23 int l = 0;
24 int r = A.size()-1;
25 int i = l-1;
26 int j = l;
27
28 for(j = l; j <= r; j++) //遍历的终止条件
29 {
30 if(A[j] <= pivot)
31 {
32 i++;
33 int temp = A[i];
34 A[i] = A[j];
35 A[j] = temp;
36 //j++
37 }
38 //else
39 //{
40 //j++
41 //}
42 }
43 }
示例:荷兰国旗问题
问题描述: 给定一个由0,1,2构成的数组,将数组原址重新排列,使得其左侧全部是0;中间全部是1,右侧全部是2。
问题分析:
-
已处理部分与未处理部分如何划分?索引的初始值是什么?
将数组划分成四部分:左边全部为0;其次全部为1;右边全部为2;中间全部是未处理的,所以需要三个索引:i j k。
A[l,i] 是全部为0的部分;A[i+1, j-1] 是全部为1的部分;A[k,r]是全部为2的部分;A[j,k-1] 是未处理部分;A[j]是当前迭代正在处理部分。
所以各索引初始值为:
i = l-1; j = l; k = r+1; -
已处理部分符合什么条件?
1中已经描述,接下来我们检查上述条件。当未处理部分长度为0的时候,显然数组被划分成了三部分:左边的全为0;中间的全为1;右边的全为2。符合我们的最后要求。 -
遍历的终止条件是什么?
即未处理部分长度为0,即A[j,k] 长度为0,所以当k-1 < j的时候,停止迭代。 -
遍历过程中的每次迭代,元素交换操作,索引更新操作?
if( A[j] == 0)
i++;
swap(A[i], A[j]);
else if(A[j] == 1)
j++;
else if(A[j] == 2)
k--;
swap(A[j],A[k]);
//注意此处,没有j++;而是k--。然后将一个未处理的元素交换到了j的位置,所以j不需要j++;
因为j还是指向的未处理元素。
/*
前两种情况与PARTITON过程一样。值得注意的是,由于交换操作,将一个未处理的元素交换到了j的位置,
所以j还是指向的未处理元素,所以不需要通过j++更新。
*/
代码:
22 void hollandFlag(vector<int> &A)
23 {
24 int l = 0;
25 int r = A.size()-1;
26 int i = l-1;
27 int j = l;
28 int k = r+1;
29
30 while(j <= k-1)
31 {
32 if(A[j] == 0)
33 {
34 i++;
35 int temp = A[i];
36 A[i] = A[j];
37 A[j] = temp;
38 j++;
39 }
40 else if(A[j] == 1)
41 {
42 j++;
43 }
44 else //A[j] == 2
45 {
46 k--;
47 int temp = A[k];
48 A[k] = A[j];
49 A[j]=temp;
50 //注意,此处不需要j++的,因为k--后将一个未处理部分,交换到j的位置了。
51 }
52
53 }
54
55 }
示例:leetcode324摆动排序
问题描述:
问题分析:
5 1、如何划分已处理部分与未处理部分(以及划分的初始情况)? 6 A[0...n] 是完整数组 7 8 A[0...odd]的奇数部分 9 A[even, n]的偶数部分 为已处理部分 10 A[0,j-1]范围内的所有 也是已处理部分 11 12 剩下部分为未处理部分 13 14 A[j] 是下一待处理元素 15 16 初始化: odd = -1 17 even = n+1(r为奇数) 或者 n+2(r为偶数) 18 j = 0 19 20 注:显然,从上面描述可以知道,如何高效的判断一个元素是否被处理比较麻烦的。 21 A[i] 若有 (i <= odd && i%2 == 1) || (i >= even && i%2 == 0) || (i < j) 22 表示A[i]已经得到处理 23 24 25 2、已处理部分符合何种要求(核心是,已处理部分为整个数组的时候,满足题目要求)? 26 A[l,odd]的奇数部分都是大于中位数的 27 A[even, r]的偶数部分都是小于中位数的 28 29 最后将中位数填入剩下部分——————能够确保满足条件么?请证明 30 31 3、遍历的终止条件?(核心是,终止的时候,所有元素都得到处理) 32 j > n 33 34 4、如何在遍历的过程中通过元素交换,索引更新,扩充已处理部分且保持性质不变。(两大操作:元素交换,索引更新)? 35 -访问到的元素小于中位数 36 if(A[even-2] 已处理) flag=1; 37 else flag = 0; 38 even-=2 39 swap(even, j) 40 41 flag==1 ? j++ : j=j; 42 43 -访问的元素等于中位数 44 j++ 45 46 -访问的元素大于中位数 47 if(A[odd+2]) 已处理 flag = 1; 48 else flag = 0 49 odd+=2 50 swap(odd, j) 51 flag == 1 ? j++ : j=j; 52 53 54 注:上述索引的下标都是从0开始的。 55 注2:根据朴素算法2中的正确性分析,我们可以不需要排序,只需要使用quickSort中的PATITION将数组划分成大于中位数与小于中位数的部分,然后按照上述规律 56 填入一个新的数组,这样时间复杂度也是O(n),但是不是原址处理,所以空间复杂度也是O(n)。
代码:
61 wiggleSort(A[0...n]) 62 pivot = getPivot(A); //可以在O(n)时间内实现 63 int odd = -1; 64 int even = (n%2 == 0) ? n+2 : n+1; 65 int j = 0; 66 67 while(j <= n) 68 { 69 if(A[j] < pivot) 70 flag = getPrcessed(A[even-2]); 71 even -= 2; 72 swap(A[even], A[j]); 73 j = (flag == 1) ? j++ : j; 74 else if(A[j] == pivot) 75 j++; 76 else //A[j] > pivot 77 flag = getProcessed(A[odd+2]); 78 odd += 2; 79 swap(A[odd], A[j]); 80 j = (flag == 1) ? j++ : j; 81 }
86 注:该问题最大的不同就是划分是分离且离散的。 87 在扩充已处理部分的其中一个子部分的时候,不会影响其他子部分,索引odd与even之间的索引更新不会互相影响。(前面两题则不同) 88 89 另外用于遍历的索引,j的更新则完全依赖于交换给j位置的元素有没有得到处理,交换过来的是已经处理的则j++;若没有处理,则j不变。 90 在前面两个问题中,由于划分是连续的,所以交换过来的元素可以很容易知道是否已经被处理了。而此处因为离散的关系,所以 91 增加一个getProcessed过程,判断要被交换的元素是否得到处理了。
注意:一些非常细微的注意的地方
1、索引的初始值一定不能够相同。具体原因见https://blog.youkuaiyun.com/LemintC/article/details/94549317开头描述。
参考:
https://blog.youkuaiyun.com/LemintC/article/details/94549317
https://blog.youkuaiyun.com/LemintC/article/details/98748471