该文章对应的GitHub仓库:cnlinxi/algorithm_practise
数组中重复的数字
数组中所有数字都在0~n-1的范围内,数组中某些数字是重复的,找出重复的数字。如长度为7的数组{2, 3, 1, 0, 2 5, 3},对应的输出应为2或3.
输入:
2 3 1 0 2 5 3
输出:
2或者3
-
解法1:排序,然后从前往后扫描数组,就可以找到重复数字。
时间复杂度:(O(nlogn))
空间复杂度:(O(1))
-
解法2:哈希表统计每一个数字的出现频次,当一个数字出现频次大于1返回,C++可以使用
std::map
时间复杂度:(O(1))
空间复杂度:(O(n))
-
解法3:设数组名为numbers,让0~n-1每一个数字都放在其下标的位置上面,如果numbers[k]的位置上面没有放置k,就一直交换numbers[numbers[k]]和numbers[k],直到numbers[k]上面放置的是k。假设交换过程中发现numbers[k]与numbers[numbers[k]]相等,则重复数字就是numbers[k]。
上例中,numbers[0]不是0,所以交换numbers[0]和numbers[numbers[0]],则变为:
1 3 2 0 2 5 3
;numbers[0]仍然不是0,交换numbers[0]和numbers[numbers[0]],则变为3 1 2 0 2 5 3
;numbers[0]仍然不是0,变为0 1 2 3 2 5 3
。此时numbers[0]变为0,进行下一个下标的检查,以保证每一个下标对应的值等于下标,直到循环到numbers[4]时,发现numbers[4] == numbers[numbers[4]],则重复数字就是numbers[4],返回即可。实际上这是一种求环的过程。
时间复杂度:(O(n)),尽管有两重循环,但是每个数字最多只要交换两次就可以找到属于自己的位置。
空间复杂度:(O(1))
该方法修改了原始数组。
-
解法4:所有数字都在[0,n-1]范围内,二分查找思想,不断缩小可能的重复数字范围,直到定位到重复数字。
上例中,首先查找整个数组[0,3]范围内数字的个数,如果超过了4,则在[0,3]范围内一定有重复数字;下一步统计在[0,1]范围内数字个数为2,该范围一定没有重复数字,重复数字一定在[2,3]之间;下一步统计[2,2]范围内数字个数,超过了1,返回重复数字2。注意:二分查找的思想是每次撞大运numbers[mid]等于目标数字,相等就结束;但是该题结束条件是:直到搜索范围缩减到一个数字才可结束。
时间复杂度:(O(nlogn)),二分查找(O(logn)),每次定一个范围之后,都要遍历这个数组一次。
空间复杂度:(O(1))
《剑指offer》面试题3
二维数组中的查找
从左往右,从上到下递增的二维数组,输入一个整数,判断二维数组是否存在该整数。
输入:
第一行:二维数组的行数,列数,要查找的数字,
之后是这个二维数组。
4 4 7
1 2 8 9
2 4 9 12
4 7 10 13
6 8 11 15
输出:
1
解法:从这个数组右上角开始找,如果待查找的数字比矩阵中数字小,减小列;如果待查找的数字比矩阵中数字大,增大行。
《剑指offer》面试题4
替换空格
将字符串中的空格替换为%20
输入:
We are happy.
输出:
We%20are%20happy.
解法1:字符串替换,从后往前替换,否则替换一次都需要让后面的字符移动一次,时间复杂度变高。
解法2:C++风格,扫描这个字符串,见到空格往std::vector
放入%20,否则放入原来的字符,最后转化为std::string
即可。
从尾到头打印链表
从链表的尾部向前打印链表。
链表定义:
struct ListNode {
int m_nValue;
ListNode *m_pNext;
explicit ListNode(int x) : m_nValue(x), m_pNext(nullptr) {}
};
输入:
[5,0,1,8,4,5]
输出:
5,4,8,1,0,5
解法1:递归。如果节点为空,直接返回。否则先递归调用打印,然后打印该节点的值。
解法2:利用栈,遍历链表时先存到栈里面,然后从栈里面拿出来打印。
重建二叉树
给定二叉树的前序和中序遍历结果,重建该二叉树。
二叉树定义:
struct TreeNode {
int m_nValue;
TreeNode *m_pLeft;
TreeNode *m_pRight;
explicit TreeNode(int x) : m_nValue(x), m_pLeft(nullptr), m_pRight(nullptr) {}
};
输入:
[1,2,4,7,3,5,6,8]
[4,7,2,1,5,3,8,6]
输出(层次遍历,空节点输出null):
[1,2,3,4,null,5,6,null,7,null,null,8,null,null,null,null,null]
解法:
前序遍历:N L R
中序遍历:L N R
在二叉树前序遍历的数组中,第一个数字就是当前根节点的值。在中序遍历的数组中,该根节点值的左侧是左子树,右侧是右子树,递归构建二叉树。
二叉树的下一个节点
给定二叉树和其中一个节点,返回中序遍历的下一个节点。
输入(层次遍历,空节点输出null):
[1,2,3,4,5,6,7,null,null,8,9,null,null,null,null,null,null,null,null]
2
输出:
4
解法:
pNext2
/
/
|pNode|
s
- 如果该节点有右子树,则下一个节点为右子树的最左节点;
- 否则向上找,直到找到有一个节点是其父节点的左子节点,则下一个节点为该节点的父节点。
用两个栈实现队列
用两个栈实现队列。
即实现以下声明:
template<typename T>
class CQueue {
public:
CQueue(void);
~CQueue(void);
void appendTail(const T &node);
T deleteHead();
private:
std::stack<T> stack1;
std::stack<T> stack2;
};
解法:
- 入队:插入stack1;
- 出队:弹出stack2,如果stack2为空,则将stack1中元素转入stack2,然后弹出stack2。
如入队1,2,3,出队1,2,入队4,5的情形:
-
入队1,2,3
stack1: | | |3| |2| |1| --- stack2: | | ---
-
出队1,2
-
Step1:
stack1: | | --- stack2: | | |1| |2| |3| ---
-
Step2:
stack1: | | --- stack2: | | |3| ---
-
-
入队4,5,6
stack1: | | |6| |5| |4| --- stack2: | | |3| ---
类似的,用两个队列实现栈:
- 入栈:将元素插入当前存储值的队列中,初始时随机插入一个队列中;
- 出栈:将队列中除了最后一个元素,全部出队移动到另一个队列中,最后元素直接丢弃。
斐波那契数列
输入n,求斐波那契数列第n项的值。
输入:
5
输出:
5
解法:
解法1:递归。斐波那契数列的表达式明显是一个递归方程。
解法2:从小n往大算。递归可以认为是从大n往小算,但是这种会带来计算重复。如计算(f(5))时,按照递归需要计算(f(4)+f(3)),而计算(f(4))又要计算(f(3)+f(2)),此时(f(3))就已经重复计算了。要减少这种重复计算,可以从小开始算,先计算(f(3)),再计算(f(4)),再计算(f(5)).
解法3:此外,还有一种矩阵解法。
类似的,跳台阶:
有n级台阶,每次可以跳1级,也可以跳2级。求有多少种跳法。
解法:实际也是斐波那契数列,设n级台阶有(f(n))种跳法,第一次跳1级则有(f(n-1))种跳法,第一次跳2级则有(f(n-2))种跳法,n级台阶的总跳法是这两种跳法之和。
旋转数组的最小数字
把一个数组最开始的若干元素移动到数组的末尾,称作数组的旋转。输入一个递增排序数组的一个旋转,输出旋转数组的最小元素。
输入:
3 4 5 1 2
输出:
1
解法1:从头到尾遍历数组,比较获得最小值。这种解法也不需要旋转递增数组,所有数组都可以这样做。
解法2:二分查找的思想。一个指针初始化为0,始终指向前部的递增数组;另一个指针初始化为数组长度减1,始终指向后部的递增数组。中间位置的元素如果比前部指针指向的元素大,则中间位置在前部递增数组,前部指针向后移动;否则中间位置一定在后部递增数组中,后部指针向前移动,以逐步逼近最小元素位置,如果两个指针间隔相差1,则后一个指针指向的即是最小值。但该解法在数组有大量相同值时会失效,如:
1 1 0 1 1
初始化时,前后指针均指向相同值的元素,因此无法知道到底如何移动,这种情形只用从头到尾搜索最小值。
矩阵中的路径
判断在一个矩阵中是否存在一条包含字符串所有字符的路径。路径可以从矩阵中任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格,不可重复进入格子。
输入:
3 4
a b t g
c f c s
j d e h
bfce
abfb
第一行为矩阵的行列数,然后是该矩阵,之后是两个字符串,检查这两个字符串是否存在这个矩阵中。
输出:
1
0
解法:直接利用“回溯法”暴力搜索该路径。这种每一步可能有多种选择的,都可以使用回溯法,暴力搜索问题的解。
素数环
给定1到n数字,将数字依次填入环中,使得环中任意两个相邻的数字间的和为素数。对于给定的n,按字典序由小到大输出所有符合条件的解(第一个数字恒定为1)。
输入:
6
8
输出:
Case 1:
1 4 3 2 5 6
1 6 5 2 3 4
Case 2:
1 2 3 8 5 6 7 4
1 2 5 8 3 4 7 6
1 4 7 6 5 8 3 2
1 6 7 4 3 8 5 2
解法:每一步有多种选择,使用回溯法暴力搜索问题的解,对于这种排列组合问题,回溯法很擅长。
剪绳子
给定长度n的绳子,剪成m段(m、n均为整数且n>1,m>1),记每段绳子的长度为(k[0],k[1],...,k[m]),求(k[0] imes k[1] imes k[2]... imes k[m])最大乘积。
输入:
8
输出:
18
说明:当绳子长度为8时,剪成2、3、3三段时,乘积最长,最大乘积为(2 imes 3 imes 3=18).
题解1:乘积因子不确定,因子个数也不确定。但实际上每个乘积“基团”最大时,整体的乘积也就最大,因而从整体上看,乘积因子就两个,保证这两个因子最大即可:
这是一个从上至下的递归公式,但是递归会有很多的重复子问题,进而带来大量的重复计算。因此更好的办法是按照从下到上的顺序计算,也就是先计算(f(2),f(3)),再得到(f(4),f(5)),进而得到(f(n))。
题解2:可证,当(ngeq 5)时,尽可能多剪长度为3的绳子;当(n=4)时,应把绳子剪成两段长度为2的绳子。
证明:当(ngeq 5)时,(2 imes (n-2)>n)且(3 imes(n-3)>n),也即当绳子长度大于等于5时,应分成2或3的绳子段,又因为当(ngeq 5)时,(2 imes (n-2)leq 3 imes(n-3)),也就是当绳子长度大于5时,应尽可能分成3的绳子段;(nleq 4)时,(1 imes 3<2 imes 2),因此当(n=4)时,应该分为两段2的绳子段(实际长度等于4时,等于不用分了),其余的就不分了。
二进制中1的个数
输入一个整数,输出该数二进制中1的个数。
输入:
9
输出:
2
说明:9的二进制为1001,其中有2位是1,因此输出2.
解法1:用一个值为1的flag依次与该整数的每一位进行位与,每一次检查,左移flag一位。注意:必须对flag位移而不要对整数位移。这是因为移位之前如果整数是一个负数,仍然要保证移位后是一个负数,因此移位后的最高位会设为1,如果一直做右移运算,那么该整数会最终变为0xFFFFFFFF
而陷入死循环。
解法2: 将一个整数减去1,再和原整数做位与运算,会把该整数最右边的1变成0。因此可以检查一个整数可以做多少次这样的运算,就可以知道该整数到底有多少个1.
数值的整数次方
实现函数求base的exponent次方。不可使用库函数,也不可考虑大数问题。
输入:
2 2
输出:
4
解法:如果一个一个乘起来会带来大量的重复计算,因此:
该公式是典型的递归形式。
打印从1到最大的n位数
输入数字n,按顺序打印从1到最大的n位十进制数。
输入:
3
输出:
1
2
...
999
说明:3位数,应输出1到999.
解法:大数,最常用的做法就是用字符串或者数组表达大数。全排列用递归很容易表达,数字的每一位都可能是0~9中的一个数,然后依次设置下一位,递归的结束条件是设置了数字的最后一位。其实就是平时用的回溯法,暴力搜索。