meet in the middle——折半查找算法

提出问题

回溯法是一个很经典的算法了,也可以视为暴力搜索算法,一般时间复杂度会达到指数级甚至阶乘级,只能用于求解规模很小的问题。比如N皇后问题、迷宫问题、数独、0-1背包……当问题规模变大一些时,一般会考虑是否有重复搜索,尝试通过记忆化来剪枝,使其变为记忆化搜索,来减少子问题的重复搜索次数,比如爬台阶,这样的做法使时间复杂度达到与动态规划相同的量级,不过常数更大。

考虑这样一个问题:给定一个长度为n的数组,里面有正有负。现在想从表中选出任意多个数,使它们的和为0,问有多少种选法?

考虑退化问题,如果把要选出来的个数确定下来,比如为2、3、4……就变成经典问题——K数之和,暴力方法复杂度为O(N^k),通过排序+双指针可以达到O(N^(k-1))。然而一旦不确定,就变成了0-1背包,对每个数都要考虑选与不选,时间复杂度为O(2^N),才能找到所有组合。这一般能处理到N=20的情况,再大就无能为力了。

假如N=30,这种方法就完全不可行了吗?能否对上面的算法做一些优化呢?这就要用到本文说的meet in the middle算法了。

meet in the middle 简介

这部分内容参照了Meet in the Middle 算法。我们可以将dfs过程视为对状态树的搜索,如图所示。

img

假设从上面的红点开始进行搜索,找一条能通向下面那个红点的路径,每个点都有两条岔路可供选择。显然如果我们简单的从上面那个点开始BFS,代价是较大的,在最差的情况下,可能需要把整张图的45个节点给走一遍。当然把搜索的重任全部交给一个节点是不合理的。于是Meet in the Middle算法就诞生了。顾名思义,就是两个节点各自向中间搜索,直至碰头。

img

在上图中红点的部分为起点开始向外搜索到的点,而蓝点表示从终点开始搜索到的点,假如此时搜索进行到点B,直接就能发现A点是从起点过来的点,那么也就自然找到了一条从 起点->A->B->终点 的路径。考虑最坏的情况,红点的搜索范围我们人为规定为层数的一半,而蓝点的搜索范围实际上是从它本身开始向上分叉,类似于红点搜索范围的轴对称图形,这样,整个图里面黑色部分的节点与边其实我们都可以不用访问,这就起到了剪枝的作用。

例题

开始的问题

考虑最开始的问题,我们可以将数组一分为二,先搜索前一半,并用哈希表记录所有能得到的值,这相当于先让上图中的点A对搜索范围进行整个搜索,这部分的时间复杂度是O(2^(n/2))的。接着,我们对后一半进行搜索,并对得到的每一个结果,尝试在上面的哈希表中找到它的相反数,如果找到,就说明搜索成功。这类似于B从底往上搜索,并检查是否与A相遇,也就是meet in the middle的过程。搜索B的时间复杂度是O(2^(n/2))的,而查哈希表的时间是O(1)的,所以这部分也是O(2^(n/2))的,总的时间复杂度就是O(2*2^(n/2)),不过,最好时间复杂度也是O(2^(n/2)),因为我们总是会搜索A的全部范围。

下面来看力扣上的两个应用。

494. 目标和 - 力扣(LeetCode)

题目描述

给你一个整数数组 nums 和一个整数 target

向数组中的每个整数前添加 '+''-' ,然后串联起所有整数,可以构造一个 表达式

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1"

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

 

示例 1:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

输入:nums = [1], target = 1
输出:1

 

提示:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

解答

其实,这道题可以直接用dfs来解决,并不会超时(毕竟数据范围只有20),用时1200ms,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//dfs,2^20大概为1e6
class Solution {
public:
int ans=0,t;
void dfs(vector<int>&nums,int i,int sum)
{
if(i==nums.size()&&sum==t)//递归终点
++ans;
else if(i<nums.size())
{
dfs(nums,i+1,sum+nums[i]);
dfs(nums,i+1,sum-nums[i]);
}
}
int findTargetSumWays(vector<int>& nums, int target) {
t=target;
dfs(nums,0,0);
return ans;
}
};

不过,我们也可以用meet in the middle来进行优化,实际上码量差不多,然而用时仅为16ms,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//用meet in the middle进行优化,复杂度O(2*2^(n/2)),约为5000,但常数较大
class Solution {
public:
int ans=0,t;
unordered_map<int,int> um;
void dfs(vector<int>&nums,int i,int sum)
{
if(i==nums.size()/2)//前一半终点
++um[sum];
else if(i==nums.size())//后一半终点
ans+=um[t-sum];
else if(i<nums.size())
{
dfs(nums,i+1,sum+nums[i]);
dfs(nums,i+1,sum-nums[i]);
}
}
int findTargetSumWays(vector<int>& nums, int target) {
t=target;
dfs(nums,0,0);//搜索前一半
//搜索后一半
dfs(nums,nums.size()/2+1,nums[nums.size()/2]);
dfs(nums,nums.size()/2+1,-nums[nums.size()/2]);
return ans;
}
};

805. 数组的均值分割 - 力扣(LeetCode)

题目描述

给定你一个整数数组 nums

我们要将 nums 数组中的每个元素移动到 A 数组 或者 B 数组中,使得 A 数组和 B 数组不为空,并且 average(A) == average(B) 。

如果可以完成则返回true , 否则返回 false  。

注意:对于数组 arr ,  average(arr) 是 arr 的所有元素的和除以 arr 长度。

 

示例 1:

输入: nums = [1,2,3,4,5,6,7,8]
输出: true
解释: 我们可以将数组分割为 [1,4,5,8] 和 [2,3,6,7], 他们的平均值都是4.5。

示例 2:

输入: nums = [3,1]
输出: false

 

提示:

  • 1 <= nums.length <= 30
  • 0 <= nums[i] <= 104

解答

这道题的数据范围,就非用meet in the middle不可了,不过还不是一道裸题,还得加以推理,实际上不愧为一道hard。

两个数组平均值相同,那么它们相加后的平均值不变,所以分成的两个数组平均值都与原数组相等
将所有数-原来的平均值,需要找到一个数组,它的和为0
为了避免浮点数,将所有数乘以nums.size()
然而,这样的做法复杂度为O(2^N),超时了,59/111
改进:我们将数组一分为二,分别计算两个部分能形成的和,看看有没有相反数,优化到O(2^(N/2)),用时52ms

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
bool ans=false;
unordered_map<int,int> um;
void dfs(vector<int>&nums,int i,int lim,int sum,int cnt)//dfs寻找一半数组形成的所有和
{
if(i<=nums.size()/2&&(um.find(cnt)==um.end()||um[sum]==0||um[sum]==nums.size()/2))//前一半存表
um[sum]=cnt;
if(i>nums.size()/2&&um.find(-sum)!=um.end()&&um[-sum]+cnt!=0&&um[-sum]+cnt!=nums.size())//后一半查表
ans=true;
if(ans)
return ;
else if(i<lim)//背包问题,选与不选
{
dfs(nums,i+1,lim,sum+nums[i],cnt+1);
dfs(nums,i+1,lim,sum,cnt);
}
}
bool splitArraySameAverage(vector<int>& nums) {
int sum=accumulate(nums.begin(),nums.end(),0);
for(int &i:nums)//预处理,计算和
i=i*nums.size()-sum;
dfs(nums,0,nums.size()/2,0,0);//dfs前一半
dfs(nums,nums.size()/2,nums.size(),0,0);//dfs后一半
return ans;
}
};