【解法4】优化解:多有序数组折半查找

原始讲义 · 非增强校订版 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照

## 【解法4】优化解:多有序数组折半查找

(1)对A、B数组进行折半查找,设L1、R1为数组A的左右查找边界,L2、R2为B的左右查找边界。数组A[L1至R1]和B[L2\~R2]的中位数,设为a和b,求序列A、B的中位数过程如下:

①若a<b则舍弃序列A中较小的一半A[L1...mid-1],同时舍弃序列B中较大的一半B[mid+1...R1]。

②若a>=b则舍弃序列A中较大的一半A[mid+1...R1],同时舍弃序列B中较小的一半B[L1...mid-1]。

重复过程①、②,且需要保证数组A、B中的元素个数相同,如果不同则从长的数组中舍弃一个(因为我们取mid时都是向下取整,所以这里舍弃最小的那个值),直到两个序列中均只含一个元素时为止,较小者即为所求的中位数。本质上不断缩小搜索区间,每次抛弃较小半区,直至剩下的最后一个候选区间。

(2) 伪代码如下:

void ans(int A[], int B[], int n)
{
    int mid1, mid2, L1=0, L2=0, R1=n-1, R2=n-1;
}
<!-- page 59 --> <!-- verified-by-hand --> > [!success] 已结合原文第 59 页及 Markdown 上下文人工校对 > if/else 层级被 OCR 错嵌且缺失外层循环块;按本页‘保持两区间等长’说明还原标准等长序列中位数算法。
int ans(int A[], int B[], int n)
{
    int L1 = 0, R1 = n - 1, L2 = 0, R2 = n - 1;
    while (L1 &lt; R1)
    {
        int m1 = (L1 + R1) / 2;
        int m2 = (L2 + R2) / 2;
        if (A[m1] == B[m2]) return A[m1];
        if (A[m1] &lt; B[m2])
        {
            if ((R1 - L1 + 1) % 2 == 0) L1 = m1 + 1;
            else L1 = m1;
            R2 = m2;
        }
        else
        {
            R1 = m1;
            if ((R2 - L2 + 1) % 2 == 0) L2 = m2 + 1;
            else L2 = m2;
        }
    }
    return min(A[L1], B[L2]);
}
(3) // 时间复杂度:O(logn) 空间复杂度:O(1)
注意:本题的O(logn)解法思想深刻,但代码实现细节非常多,在考场上一次写对的难度很高。对于大部分同学,熟练掌握O(n)的解法(如解法3:多指针法)是更稳妥的策略,可以确保拿到大部分分数。此O(logn)解法建议学有余力的同学深入理解。 ## 2013年41题 (13 分) 已知—个整数序列 A =(a0, a1……, an-1),其中 0≤a<n(0≤i<n)。若存在 ap1 = ap2 =..= apm = x 且 m > n/2(0≤pk<n,1≤k≤m),则称x为 A的主元素。例如A =(0,5,5,3,5,7,5,5),则5为主元素;又如A =(0,5,5,3,5,1,5,7),则 A 中没有主元素。假设 A 中的n 个元素保存在一个一维数组中,请设计一个尽可能高效的算法,找出 A 的主元素。若存在主元素,则输出该元素;否则输出-1。要求: (1)给出算法的基本设计思想。 (2)根据设计思想,采用C、C++或Java语言描述算法,关键之处给出注释。 (3)说明你所设计算法的时间复杂度和空间复杂度。 <table><tr><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1>做题思路</td><td rowspan=1 colspan=1>时间复杂度</td><td rowspan=1 colspan=1>空间复杂度</td><td rowspan=1 colspan=1>预计得分</td></tr><tr><td rowspan=1 colspan=1>暴力解</td><td rowspan=1 colspan=1>枚举</td><td rowspan=1 colspan=1>O(n²)</td><td rowspan=1 colspan=1>0(1)</td><td rowspan=1 colspan=1>11</td></tr><tr><td rowspan=1 colspan=1>暴力解</td><td rowspan=1 colspan=1>快速排序</td><td rowspan=1 colspan=1>O(nlogn)</td><td rowspan=1 colspan=1>O(logn)</td><td rowspan=1 colspan=1>13</td></tr><tr><td rowspan=1 colspan=1>优化解</td><td rowspan=1 colspan=1>散列表</td><td rowspan=1 colspan=1>O(n)</td><td rowspan=1 colspan=1>O(n)</td><td rowspan=1 colspan=1>14</td></tr><tr><td rowspan=1 colspan=1>特殊解</td><td rowspan=1 colspan=1>技巧</td><td rowspan=1 colspan=1>O(n)</td><td rowspan=1 colspan=1>0(1)</td><td rowspan=1 colspan=1>15</td></tr></table> 【解法1】暴力解:枚举,分别判断每个数是不是主元素 (1)两层循环:外层循环枚举数组中每个候选元素,内层循环统计该元素在数组中出现的次数,若次数大于n/2则为主 元素。 (2) 伪代码如下: <!-- page 60 --> <!-- verified-by-hand --> > [!success] 已结合原文第 60 页及 Markdown 上下文人工校对 > 外层 for 缺少块,return -1 越出函数,并混入裸字‘首’。
int ans(int A[], int n)
{
    for (int i = 0; i &lt; n; i++)
    {
        int m = 0;
        for (int j = 0; j &lt; n; j++)
            if (A[i] == A[j]) m++;
        if (m &gt; n / 2) return A[i];
    }
    return -1;
}
(3) // 时间复杂度:O(n²) 空间复杂度:O(1)
## 【解法2】暴力解:快速排序,然后查找主元素 (1)先快速排序得到升序序列,权值相同的元素都会相邻,扫描一趟数组,L是之前相邻的相同元素最左边的元素下标,i是此时正在扫描的元素下标,如果A[i]不等于A[L]则说明碰到了新的元素。 (2) 伪代码如下:
void Qsort(int A[], int L, int R)
{
    if (L&gt;=R)
    return; // 当前区间元素个数&lt;=1则退出
    int pivot, i=L, j=R; // i和j是左右两个数组下标移动
    pivot=A[L]; // pivot作为基准值参与比较
    while (i&lt;j){
        while (i&lt;j &amp;&amp; A[j]&gt;=pivot)
        j--;
        while (i&lt;j &amp;&amp; A[i]&lt;=pivot)
        i++;
        if (i&lt;j)
        swap(A[i], A[j]); // A[j]&lt;pivot, A[i]&gt;pivot
    }
    swap(A[L], A[i]); // 将基准值放入他的最终位置
    Qsort(A, L, i-1); //递归处理左区间
    Qsort(A, i+1, R); //递归处理右区间
}
int ans(int A[], int n)
{
    s o m
    Qsort(A, 0, n-1);
    int t=A[(n-1)/2]; // 主元素只可能是A[(n-1)/2]
    int m=0; // m统计当前枚举元素出现次数
    for(int i=0; i&lt;n; i++)
    if (A[i]==t)
    m++;
    if (m&gt;n/2)
    return t; //找到了主元素
}
<!-- page 61 -->
int ans(int A[], int n)
{
    int j=A[0], t=1; // 取A[0]作为可能主元素
    for (int i=1; i&lt;n; i++)
    if (A[i]==j) t++;
    else
    t--;
    if (t&lt;0)
    {
        j=A[i];
        t=1;
        int m=0; // m统计j出现的次数
        for (int i=0; i&lt;n; i++)
        if (A[i]==j) m++;
        if (m&gt;n/2) return j; // j是主元素,输出j
        else return -1; // j不是主元素,输出-1
    }
}
return -1; // 未找到主元素
(3) // 时间复杂度:O(nlogn) 空间复杂度:O(logn)
## 【解法3】优化解:散列表 (1)设置一个数组count[0...n-1]统计每个数字出现的次数,比如countil=1表示数字i出现了1次,遍历一次A数组统计所有数字出现的次数,处理完后扫描一次count数组找到是否有主元素,如果有则输出主元素,否则输出-1。 (2) 伪代码如下:
int ans(int A[], int n)
{
    int count[n]={0}; // count统计数字出现频率
    for (int i=0; i&lt;n; i++)
    count[A[i]]++; //0&lt;=A[i]&lt;=n,不会越界
    for (int j=0; j&lt;n; j++)
    if (count[j]&gt;n/2)
    return j; //找到了主元素,输出主元素
    return -1; // 未找到主元素,输出-1
}
(3) // 时间复杂度:O(n) 空间复杂度:O(n)
## 【解法4】特殊解:技巧 (1)利用抵消法(摩尔投票法): 扫描数组,用变量i记录当前候选主元素,t记录其票数。遇到与i相同的元素时票数加1,不同则减1,票数为0时更新候选元素。第一轮结束后,i为可能主元素,再次遍历确认其出现次数是否超过n/2,若超过则为主元素,否则不存在主元素。 (2) 伪代码如下: <!-- page 62 -->
(3) // 时间复杂度:O(n) 空间复杂度:O(1)
摩尔投票法最早由 Boyer和 Moore在1981 年提出,原本用于处理在线序列中寻找主元素问题。主元素定义为:在长度为 n的序列中,出现次数严格超过 n/2 的元素。 其核心思想是“抵消法”—一把不同的元素互相抵消掉,主元素由于数量超过一半,最终必然“存活”下来。 • 主元素出现次数> n/2,非主元素出现次数 < n/2。 • 每次遇到不同元素,抵消一对“主元素-非主元素”。 • 最多能抵消掉 n-m 次(m是主元素出现次数)。 • 剩余主元素个数为 m - (n-m)= 2m - n > 0,说明主元素一定存活到最后。 • 最后留下的候选人若存在主元素,必然是主元素。 • 再次扫描确认其出现次数是否> n/2 即可。 ## 2016年43题 (13分)已知由n(n≥2)个正整数构成的一个集合A ={a|0≤k<n},将其划分为两个不相交的子集A1和A2,元素个数分别是n1和n2,A1和A2中元素之和分别为S1和S2。设计一个尽可能高效的划分算法,满足In1-n2|最小且IS1-S2|最大。要求: (1)给出算法的基本设计思想。 (2)根据设计思想,采用C或C++语言描述算法,关键之处给出注释。 (3)说明你所设计算法的平均时间复杂度和空间复杂度。 <table><tr><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1>做题思路</td><td rowspan=1 colspan=1>时间复杂度</td><td rowspan=1 colspan=1>空间复杂度</td><td rowspan=1 colspan=1>预计得分</td></tr><tr><td rowspan=1 colspan=1>暴力解</td><td rowspan=1 colspan=1>快速排序</td><td rowspan=1 colspan=1>O(nlogn)</td><td rowspan=1 colspan=1>0(1)</td><td rowspan=1 colspan=1>9</td></tr><tr><td rowspan=1 colspan=1>优化解</td><td rowspan=1 colspan=1>多指针法</td><td rowspan=1 colspan=1>O(n)</td><td rowspan=1 colspan=1>O(logn)</td><td rowspan=1 colspan=1>12</td></tr><tr><td rowspan=1 colspan=1>优化解</td><td rowspan=1 colspan=1>多指针法</td><td rowspan=1 colspan=1>O(n)</td><td rowspan=1 colspan=1>0(1)</td><td rowspan=1 colspan=1>13</td></tr></table> ## 【解法1】暴力解:快速排序 1)对数组 A[0...n-1] 进行快速排序得到升序,排序后集合 S1 是 A[0...n/2-11(向下取整),集合S2是 A[n/2...n-11(向下取整)。 2)伪代码如下: <!-- verified-by-hand --> > [!success] 已结合原文第 62 页及 Markdown 上下文人工校对 > 快排段有裸字‘终止’和水印 0m,输出说明应为注释而非 C 语句。
void ans(int A[], int n)
{
    Qsort(A, 0, n - 1);
    // 输出 A1: A[0..n/2-1]
    // 输出 A2: A[n/2..n-1],奇数时中间数分给 A2
}
<!-- page 63 -->
(3) // 时间复杂度:O(nlogn) 空间复杂度:O(logn)
## 【解法2】特殊解:多指针法(递归) (1)对数组A[0\~n-1]进行类似快速排序的做法,在处理左右区间时只处理可能包含中位数的区间,即如果区间的范围是[L,R],则只有L<=n/2-1<=R 才会处理该区间。对快排的递归调用稍作修改即可。 (2) 伪代码如下: <!-- verified-by-hand --> > [!success] 已结合原文第 63 页及 Markdown 上下文人工校对 > 根据快速选择的上下文校正两个内层 while 的 j--/i++ 顺序,把 break 改为递归函数中合法的 return,补齐 ans 函数和函数名大小写
void Quick_Select(int A[], int L, int R, int n)
{
    if (L &gt;= R) return;
    int i = L, j = R;
    int pivot = A[L];
    while (i &lt; j)
    {
        while (i &lt; j &amp;&amp; A[j] &gt;= pivot) j--;
        while (i &lt; j &amp;&amp; A[i] &lt;= pivot) i++;
        if (i &lt; j) swap(A[i], A[j]);
    }
    swap(A[L], A[i]);
    if (i == n / 2 - 1) return;
    if (n / 2 - 1 &lt; i)
        Quick_Select(A, L, i - 1, n);
    else
        Quick_Select(A, i + 1, R, n);
}

void ans(int A[], int n)
{
    Quick_Select(A, 0, n - 1, n);
    // 输出 A1: A[0..n/2-1]
    // 输出 A2: A[n/2..n-1],奇数时中间数分给 A2
}
(3)时间复杂度:O(n) // 空间复杂度:O(logn)
<!-- page 64 --> (注:关于此方法的时间复杂度记住即可,不需要掌握,《算法导论》上有完整证明) ## 时间复杂度证明 • 这是一个「快速选择」(Quick Select)的递归算法,类似快排。 • 每次划分时,仍然执行一次 partition(类似快排的分区),其时间是O(区间长度),第一次是 O(n),下一次是O(n/2),再下一次是O(n/4),以此类推。 • 但与快排不同的是: 。快排每次递归处理两个子区间; 。 而这里的 Quick Select 只递归处理包含第 n/2 个元素的那一侧子区间—一 只递归一个子区间。 • 因此,总时间复杂度满足如下递归关系: • 这是典型的「二分递归+线性工作量」,根据递推公式展开: 注意这样求的其实是平均时间复杂度是O(n),但对于和快速排序有关的方法,我们都可以写平均时间复杂度来。 ## 【解法3】特殊解:多指针法(非递归) (1)对数组A[0\~n-1]进行类似快速排序的做法,在处理左右区间时只处理可能包含中位数的区间,即如果区间的范围是[1,r],则只有|<=n/2-1<=r才会处理该区间,可以不用递归来写,每次划分后修改I和r的范围即可。 (2) 伪代码如下: <!-- verified-by-hand --> > [!success] 已结合原文第 64 页及 Markdown 上下文人工校对 > 输出语句被错放到 while 内且是裸中文;正确过程与 p63 已人工校对的递归版同构。
void ans(int A[], int n)
{
    int L = 0, R = n - 1, target = n / 2 - 1;
    while (L &lt; R)
    {
        int i = L, j = R, pivot = A[L];
        while (i &lt; j)
        {
            while (i &lt; j &amp;&amp; A[j] &gt;= pivot) j--;
            while (i &lt; j &amp;&amp; A[i] &lt;= pivot) i++;
            if (i &lt; j) swap(A[i], A[j]);
        }
        swap(A[L], A[i]);
        if (i == target) break;
        if (target &lt; i) R = i - 1;
        else L = i + 1;
    }
    // 输出 A1: A[0..n/2-1]
    // 输出 A2: A[n/2..n-1]
}
<!-- page 65 --> ## 2018年41题 (13分)给定一个含n(n≥1)个整数的数组,请设计一个在时间上尽可能高效的算法,找出数组中未出现的最小正整数。例如,数组{-5,3.2,3}中未出现的最小正整数是1;数组{1,2,3}中未出现的最小正整数是4。要求: (1)给出算法的基本设计思想。 (2)根据设计思想,采用C或C++语言描述算法,关键之处给出注释。 (3)说明你所设计算法的时间复杂度和空间复杂度。 <table><tr><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1>做题思路</td><td rowspan=1 colspan=1>时间复杂度</td><td rowspan=1 colspan=1>空间复杂度</td><td rowspan=1 colspan=1>预计得分</td></tr><tr><td rowspan=1 colspan=1>暴力解</td><td rowspan=1 colspan=1>枚举</td><td rowspan=1 colspan=1>O(n²)</td><td rowspan=1 colspan=1>0(1)</td><td rowspan=1 colspan=1>10</td></tr><tr><td rowspan=1 colspan=1>暴力解</td><td rowspan=1 colspan=1>快速排序</td><td rowspan=1 colspan=1>O(nlogn)</td><td rowspan=1 colspan=1>O(logn)</td><td rowspan=1 colspan=1>12</td></tr><tr><td rowspan=1 colspan=1>优化解</td><td rowspan=1 colspan=1>散列表</td><td rowspan=1 colspan=1>O(n)</td><td rowspan=1 colspan=1>O(n)</td><td rowspan=1 colspan=1>13</td></tr></table>