第五章 递归综合习题(leetcode)

来源:互联网 发布:人工智能伏羲觉醒2 编辑:程序博客网 时间:2024/06/05 19:30

下面例举了一些leetcode上面的一些习题,作为前一篇内容的补充。没有涉及到动态规划、分治法的题目,因为那些题目难度都比较大^^。
这些题目主要讲解如何根据书上的三种类型,设计递归算法。对于递归定义,这里选择了一道快速幂的题目;对于递归数据结构,这里超前使用了第7章的二叉树而不是使用之前的链表(线性数据结构使用递归并没有太大的意义,直接使用迭代基本上是很方便了),虽然超前但是应该也很容易明白;对于递归的问题求解方法,这里选择了计算排列、计算组合、计算子集三个经典的问题,以及两道富有实际意义的问题:恢复IP地址、最大的岛屿。

递归的数学表达式定义

计算幂(Pow(x, n))

实现函数double myPow(double x, int n),计算xn
将幂写成递归定义,即f(n)是关于f(n1)的递推式,对于f(n)=xn,可以有

f(n)={1xf(n1)n=0n>0

利用这个等式,可以很容易设计出最原始的递归算法,其时间复杂度和迭代计算一样,为O(n)
实际上,
xn={x(xn2)2(xn2)2n=2k+1,kZn=2k,kZ

因此,
f(n)=1x[f(n2)]2[f(n2)]2n=0n=2k+1,kZ+n=2k,kZ+

因此,根据这样的递推式,不难得出下面的程序。不得不说这道题的测试数据非常极端,包含了n<0的情况,同时n能取到32位有符号整数的最小值,在编程实现的时候需要特别注意。

class Solution {public:    double myPow(double x, int n) {        //递归出口条件,注意极端情况        if(n == 1){            return x;        }        if(n == 2){            return x*x;        }        if(n == 0){            return 1;        }        if(x == 0){            return 0;        }        double p = myPow(x, n / 2);        if(n % 2 != 0){            if(n > 0)                return p * p * x;            else                return p * p * (1 / x);        }else{            return p * p;        }    }};

下面分析算法的时间复杂度:函数的递归调用出现在double p = myPow(x, n / 2)语句,该语句每次将问题的规模压缩为原来的一半。其他语句执行的时间为常数时间O(1)
可以设函数整个执行T(n)次,则T(n)=T(n2)+c,因此

T(n)=T(n2)+cT(n2)=T(n22)+c...T(n2k1)=T(n2k)+c

T(n)=kc,又令n2k=11n2k<2
可得2kn<2k+1,即klgn<k+1
代回T(n)=kc可得clgnT(n)<clgn+c

因此该算法的时间复杂度为O(logn),比朴素算法的O(n)明显更优,因此又被称为快速幂算法。更多情况,这种快速幂算法又被写成非递归形式。


递归的数据结构

下面是两道二叉树的题目,用来说明根据递归的数据结构设计递归算法。首先介绍一下二叉树的递归定义。

  • 一个空结点是一颗二叉树
  • 任意一个结点所表示的树是一颗二叉树,必须满足它的左子树和右子树都是二叉树

对于下面的两道题目来说,其树的结点定义如下:

struct TreeNode {    int val;    TreeNode *left;    TreeNode *right;    TreeNode(int x) : val(x), left(NULL), right(NULL) {}};

二叉树的最大深度(Maximum Depth of Binary Tree)

给定一个二叉树t,求这棵树的最大深度。某个结点的深度是指从根节点到当前结点的一条路径所经过的结点数。
因为二叉树的结点是一种递归的定义,很明显可以得出,当前结点的深度,是左右子树的最大深度再加一。而左右子树的最大深度,相当于再次执行这个函数,只不过将原结点的左右孩子结点当作了新的树的根节点。用数学表达式如下所示:

d={0max{dl,dr}+1

因此不难得出递归程序

class Solution {public:    int maxDepth(TreeNode* root) {        if(!root){            return 0;        }        return max(maxDepth(root->left), maxDepth(root->right)) + 1;    }};

因为要遍历所有的结点,因此算法的时间复杂度为O(n),其中n表示结点的个数。

相同的树(Same Tree)

给定两个二叉树,判断这两棵二叉树是否相等。树相等的条件是指两棵树的结构,和对应位置结点的值都完全相同。

根据树的递归定义,一颗树相等,当且仅当当前结点的值相等,且左右子树都相等。空结点是相等的。
因此可以得出如下算法:

class Solution {public:    bool isSameTree(TreeNode* p, TreeNode* q) {        //当前的结点是否存在(递归出口)        if(!p || !q){            if(p == q){                return true;            }else{                return false;            }        }        //当前结点是否相等        if(p->val != q->val){            return false;        }        //左子树是否相等        if(!isSameTree(p->left, q->left)){            return false;        }        //右子树是否相等        if(!isSameTree(p->right, q->right)){            return false;        }        return true;    }};

递归的问题求解方法

对于递归的问题的求解方法,有时候很难得出较为简洁的数学表达式。更多情况下,我们只需要理清什么时候问题的形式依然一致,但是规模缩小了。这样就可以利用递归来进行求解了。或者,画出递归树,也是一个不错的方法。

求排列(Permutations)

给定一串数字A,求出所有可能的排列。例如
A = [1, 2, 3],应当返回
[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

对于排列问题,也是很显然可以利用递归来进行求解的。
对于A[1..n],按照一定次序构成排列,可以先取一个aiA,作为排列的第一个元素,然后将选择的aiA中删去,这样就得到了一个比A少一个元素的集合A。对于A依然有其自身的排列,其问题规模比原来的较小一些。对于其每一个排列,ai加上A的排列就构成了A的排列。为了求出所有的排列,需要对所有的i都需要进行求解,即将A[i]加到A的每一个排列上。
在下面的解法中,使用了类的公有成员变量result作为“全局变量”,返回所有的排列。current_permutation作为全局变量,用来记录当前产生的排列。当达到递归出口时,就应当将当前产生的排列进入result。同时,使用了used数组代替了直接将元素从数组中删除的操作,这样避免了在线性存储结构中频繁删除插入元素造成时间上的不良影响。

class Solution {public:    vector<vector<int>> result;    vector<int> current_permutation;    vector<bool> used;    void solve(vector<int> & nums){        if(current_permutation.size() == nums.size()){  //递归出口            result.push_back(current_permutation);            return;        }        for(int i = 0; i < nums.size(); i++){            if(!used[i]){                //设置已经取了A[i]                used[i] = true;                current_permutation.push_back(nums[i]); //将当前的数加入排列                solve(nums);                //恢复全局变量状态                current_permutation.pop_back();                used[i] = false;            }        }    }    vector<vector<int>> permute(vector<int>& nums) {        if(!nums.empty()){            //初始化used数组            for(int i = 0; i < nums.size(); i++){                used.push_back(false);            }            solve(nums);        }        return result;    }};

时间复杂度是O(n!),因为n个元素的全排列有Pnn=n!个。

求子集(Subsets)

给定一组不同的整数,要求求出他们的所有子集(包括空集和其本身)。
如果给出 [1,2,3] 应该得到

[  [3],  [1],  [2],  [1,2,3],  [1,3],  [2,3],  [1,2],  []]

解决子集问题,也可以转化为递归的枚举。其方法与上面求排列较为类似:先取一个元素aiA,表示当前的子集包含ai,此时应该输出当前子集,将aiA中删除得到A,然后从A中取下一个可能的取值进行相同的操作,如此进行直到所有元素不能取为止。回退时,应该恢复递归状态,将后加入的元素从当前的子集中删除(因为使用的是全局变量)。不过集合相等,与元素的顺序无关,因此,必须要排除重复的。这里先将集合A中的元素进行排序,因此,每次选取时,只选比当前大的构成新的子集,以避免重复。

class Solution {public:    vector<vector<int>> result;    vector<int> current_subset;    void solve(vector<int> & nums, vector<int> & current_subset){        result.push_back(current_subset);        int i;        for(i = 0; i < nums.size(); i++){            if(current_subset.empty() || nums[i] > current_subset.back()){                current_subset.push_back(nums[i]);                solve(nums, current_subset);                current_subset.pop_back();            }        }    }    vector<vector<int>> subsets(vector<int>& nums) {        int i;        sort(nums.begin(), nums.end()); //定序法要求数组有序        solve(nums, current_subset);        return result;    }};

除此之外,求子集还有一种更简洁的办法,是使用二进制遍历法,利用unsigned int无符号整数的表示范围,二进制1表示该元素存在,0表示不存在。这里主要讨论递归,此方法就不做详细说明了。

求指定组合(Combinations)

给定两个整数nk,求1..n中取k个数构成的所有组合。
例如n=4k=2,则输出
[[2,4],[3,4],[2,3],[1,2],[1,3],[1,4]]

其实组合和子集是密切相关的,大体思路与上题没有太大差别。无非就是k的取值决定了问题的规模,也决定了递归出口,因此此处k作为了函数参数,每次选择一个元素后,逐次让k减小1,直到k=0时停止。同时因为数字满足1..n,因此实际取数的操作比上一题要简单一些。去重的问题与上一题的“定序法”完全一致,只是在实现过程中增加了n0参数,用来判断上一层选择的数,这样当前层只需要从n0+1开始取就可以了。

class Solution {public:    vector<vector<int>> result;    vector<int> current_combine;    void solve(int n, int n0, int k){        if(k == 0){            result.push_back(current_combine);            return;        }        for(int i = n0 + 1; i <= n; i++){            current_combine.push_back(i);            solve(n, i, k - 1);            current_combine.pop_back(); //恢复全局变量        }    }    vector<vector<int>> combine(int n, int k) {        if(n > 0){            solve(n, 0, k);        }        return result;    }};

恢复IP地址(Restore IP Address)

给定一串只含有数字的字符串,要求求出所有可能的IPv4地址。
IPv4地址满足x1.x2.x3.x4,其中0xn255
例如"25525511135",应该返回["255.255.11.135", "255.255.111.35"]

这道题也是一个用递归求解的经典题目,也是教材上的一道实验题。IPv4地址一共分为4小节,每一节满足0~255。因此,可以从左依次向右分离,指针每移动一位数字,检测当前小节是否满足地址的要求。如果满足,则继续将剩下的字符看作为新的字符串,继续执行这样的过程。这样就把一个4小节的问题转化为了3小节的问题,问题规模缩小了。这样就满足了一个递归的条件。至于递归出口则略显繁琐,满足“达到4小节”和“字符串扫描完毕”任一条件就行了。
用相对数学的表述,设F(s)表示由字符串s得到的IPv4地址,P(s,k)表示s的长度为k前缀函数,S(s,k)表示s的长度为k的后缀函数,R为所有合法的IPv4小节的集合("0"~"255"的字符串集合,不包含前导0),s1+s2表示字符串s1s2的连接,于是:

F(s)=P(s,i)+.+F(S(s,ni))P(s,i)R

下面给出的实现中,使用了vector来存放当前所有扫描到的小节,在递归出口处进行判断IP是否合法。合法的充要条件:4小节,字符串扫描完毕,且每一小节的取值范围合法。注意前导零对于每一小节来说也是不合法的。
这种先穷举再判断合法性的做法,相比较回溯法的剪枝来说效率较低,但是可读性和清晰性是明显较好的。

class Solution {public:    vector<string> st;  //栈,用来处理输出    vector<string> result;  //返回值    /**    * @param string & str 原始字符串    * @param int i 表示当前读取到第几个字符    * @param int count 表示当前统计到第几个段,达到IPv4的4段就应该返回    */    void solve(string & str, int i, int count){        if(i == str.length() || count == 4){            if(i == str.length() && count == 4){                bool flag = true;                string ans;                for(int k = 0; k < st.size(); k++){                    //检查是否在0~255范围内                    int val = 0;                    for(int m = 0; m < st[k].length(); m++){                        val = val * 10 + st[k][m] - '0';                    }                    if(val > 255){                        flag = false;                        break;                    }                    //检查是否包含前导零                    if(st[k].length() > 1 && st[k][0] == '0'){                        flag = false;                        break;                    }                }                if(flag){                    for(int k = 0; k < st.size(); k++){                        ans += st[k];                        ans.push_back('.');                    }                            ans.pop_back();                    result.push_back(ans);                }            }            return;        }        int val = 0;        for(int j = i; j < i + 3 && j < str.length(); j++){            st.push_back(str.substr(i,j - i + 1));            solve(str, j + 1, count + 1);            st.pop_back();        }    }    vector<string> restoreIpAddresses(string s) {        if(!s.empty()){            solve(s, 0, 0);        }        return result;    }};

岛屿的最大面积(Max Area of Island )

给定非空二维数组,只包含01。其中1表示岛屿。求互相连接的1(通过上下左右相连)构成的块中,最大的一个块有多少个1。如果没有,则返回0。
例如
矩阵
应该返回6。注意不是11,因为必须是上下左右直接相连。

这道题求联通块,也是深搜的一个应用。对于给定的一个点,可以向四周拓展,有1的话就走过去,然后将当前的面积增加1。
s(A)表示包含了位置T(i,j)的块的面积,f(A)表示A(i,j)位置的值,a(A)表示A(i,j)周围的四个点的集合,于是

s(A)=Tia(A)f(Ti)=1s(Ti)+1,

下面给出的解法中,首先判断走的位置是否合法。然后依据上面的公式进行求解。可能有点疑惑的地方在于不同于上面的一些问题,这里没有出现返回的时候将grid的值修改回去。因为在主函数的实现中,我们对grid的所有点都进行了探测。不修改我们可以保证以后的探查过程中,同一个块不会被探查第二次(如果修改回去,一个块有多少个1,就会被探查多少次),因而这样能够显著提高效率,使算法的时间复杂度稳定在O(mn)

class Solution {public:    int getMaxArea(vector<vector<int>> & grid, int i, int j){        if(i < 0 || i >= grid.size() || j < 0 || j >= grid[0].size()){            return 0;        }        if(grid[i][j] == 0){            return 0;        }        grid[i][j] = 0;        return 1 + getMaxArea(grid, i + 1, j) + getMaxArea(grid, i - 1, j)             + getMaxArea(grid, i, j + 1) + getMaxArea(grid, i, j - 1);    }    int maxAreaOfIsland(vector<vector<int>>& grid) {        if(grid.empty()){            return 0;        }        int maxsz = 0;        for(int i = 0; i < grid.size(); i++){            for(int j = 0; j < grid[0].size(); j++){                maxsz = max(getMaxArea(grid, i, j), maxsz);            }        }        return maxsz;    }};