要想在算法面试中取得好的表现,是需要一定的智慧的。智慧其实分两层含义,一是“智", "智" 指的是看到事物的不同,意即你能理解事物的内部机理,放到算法面试里说,就是你知道算法的实现原理,因此,解出算法,那就是你的“智”。 而”慧“就是看到事物的相同,也即所谓的大道至简,殊途同归。吴军博士在他的《数学之美》里反复强调,好的解决方案或是数学模型,在形式上必定是简单明了的。在面试中,如果你能将代码以一种清晰,紧凑,易于理解的方式展现出来,那就是你”慧“的展现。
面向对象的分析和设计模式是我们在开发中通往”慧“ 的好方法,因为他们将开发中反复出现的模式总结,并以一种简单明了的方式展现出来。在这一节,我跟诸位同仁一起探讨一下,如何利用面向对象的分析,和设计模式这两种技术,在算法面试中寻求更优秀的表现,我依旧从leetcode 里面取出算法题作为讨论的材料。
Given an array of words and a length L, format the text such that each line has exactly L characters and is fully (left and right) justified.
You should pack your words in a greedy approach; that is, pack as many words as you can in each line. Pad extra spaces ' '
when necessary so that each line has exactly L characters.
Extra spaces between words should be distributed as evenly as possible. If the number of spaces on a line do not divide evenly between words, the empty slots on the left will be assigned more spaces than the slots on the right.
For the last line of text, it should be left justified and no extra space is inserted between words.
For example,
words: ["This", "is", "an", "example", "of", "text", "justification."]
L: 16
.
Return the formatted lines as:
[ "This is an", "example of text", "justification. " ]
Note: Each word is guaranteed not to exceed L in length.
Corner Cases:
"This is an"4. 如果剩余的空格不能平均分配,那么空格优先分配给处于左边的间隔,例如
“example of text”5. 如果剩下的单词能放入一行,那么多余的空格全部分配到末尾,例如
"justification. "
看上去比较棘手吧,而且还得注意各种边角料的特殊情况。
我们先看看,没有使用面向对象的分析技术时,代码一般怎么写,以下是java 给出的算法:
public static List fullJustify(String[] words, int L) {
List res = new ArrayList();
if (words == null || words.length == 0 || L < 0) return res;
List line = new ArrayList();
String str = "";
int len = 0, div, mod;
for (int i = 0; i < words.length; i++) {
if (len + line.size() + words[i].length() <= L) {
line.add(words[i]);
len += words[i].length();
} else {
if (line.size() == 1) { // only 1 word in this line
str = line.get(0);
for (int j = L - str.length(); j > 0; j--) str += " ";
} else if (line.size() > 1) {
div = (L - len) / (line.size() - 1);
mod = (L - len) % (line.size() - 1);
str = line.get(0); // append first word
for (int j = 1; j < line.size(); j++) { // append remaining words
for (int k = 0; k < div; k++) str += " ";
if (j <= mod) str += " "; // append 1 more space
str += line.get(j);
}
}
res.add(str);
line.clear();
line.add(words[i]); // next line
len = words[i].length();
}
}
// last line
str = line.get(0);
for (int i = 1; i < line.size(); i++) str += " " + line.get(i);
for (int i = L - str.length(); i > 0; i--) str += " ";
res.add(str);
return res;
}
所有这种面向过程的软件方法都具备以下几个特征:1.难理解 2. 难修改, 想想,如果要修改里面的bug,由于逻辑柔和在一起,更改一处,很可能引起新的问题。3. 难扩展,需求更改是软件开发的家常便饭,试想如果需求的第四点改成空格优先分配给右边的间隔,那么这段代码要满足这种需求变更,改动可就大了 。我们看看利用面向对象的分析结合java,给出易理解, 易维护,易扩展的解决方案。
根据前面几点的题意描述,我们发现”单词“ 是整个场景描述中的一个个体,因此我们可以用一个类来表示它,这个类可称为Word. Word 包含的内容是字符串,因此可以用String类型的变量来作为类的数据,由于单词间需要以空格分离,因此单词末尾可以添加空格,所以可以给Word类添加一个 increaseSpace 方法,综上所述,我们可以给出Word类的实现
class Word
{
private String word;
public Word(String s)
{
word = s;
}
public void increaseSpace(int count)
{
for (int i = 0; i < count; i++)
{
word += " ";
}
}
public int getLength()
{
return word.length();
}
public String getWord()
{
return word;
}
}
类代码不多,看一眼就知道它的功能,也不需要任何注释。
我们再看看题意描述中还有一个逻辑单元是”行“, 容易看出”行“是用来放置Word 的,而且它应该负有在单词间分配空格的责任,因此”行“也给成为一个类,可以将它对应的类称为Sentence, 下面给出它的类代码
class Sentence
{
private int length;
public Sentence(int length)
{
this.length = length;
}
}
class Sentence
{
private int length;
private int availableLength ;
private Word lastWord = null;
private ArrayList wordList = new ArrayList();
public Sentence(int length)
{
this.length = length;
availableLength = length;
}
public boolean acceptWord(String word)
{
if (canAcceptWord(word) == false)
{
return false;
}
Word w = addWordToList(word);
putGapInWordsAndComputeAvailableLength(w);
return true;
}
}
由于一行的容量有限,因此加入单词时需要考虑剩余空间是否放得下加入的单词 (canAcceptWord), 如果放得下,那么将单词加入队列,并计算加入单词后,一行还剩多少可用空间 (putGapInWordsAndComputeAvailableLength), 下面我们看看辅助函数的实现代码
class Sentence
{
private int length;
private int availableLength ;
private Word lastWord = null;
private ArrayList wordList = new ArrayList();
private boolean last = false;
public Sentence(int length)
{
this.length = length;
availableLength = length;
}
public boolean acceptWord(String word)
{
if (canAcceptWord(word) == false)
{
return false;
}
Word w = addWordToList(word);
putGapInWordsAndComputeAvailableLength(w);
return true;
}
private boolean canAcceptWord(String word)
{
if (lastWord != null && availableLength - 1< word.length())
{
return false;
}
if (lastWord == null && availableLength < word.length())
{
return false;
}
return true;
}
private Word addWordToList(String word)
{
Word w = new Word(word);
wordList.add(w);
return w;
}
private void putGapInWordsAndComputeAvailableLength(Word word)
{
if (lastWord != null)
{
lastWord.increaseSpace(1);
availableLength -= 1;
}
availableLength -= word.getLength();
lastWord = word;
}
}
Encapsulated what varies.
我们需要将容易变化的逻辑封装起来,因而算法的实现必须单独封装到一个类,对应这种情况,设计模式专门有对应的设计方案,称之为策略模式(Strategy pattern) ,我们先看看策略模式的具体定义:
设计一组算法,将每个算法都封装起来,使得算法间可以相互替代。使用策略模式,用户可以根据需求动态的替换不同的算法。
使用场景:
1. 如果一系列相关的类,他们实现的功能相同,只不过实现的方式有所区别,那么策略模式提供了一个一种方法,使得类的实现方式能动态配置
2. 当你需要算法的不同变种时,例如,你需要排序算法,希望根据不同情形选择冒泡,堆排序,计数排序,快速排序时,使用策略模式可以实现各种排序算法的
动态切换。
3. 当算法所需要输入的数据不需要暴露给算法的调用者时,使用策略模式可以避免暴露复杂的,与算法相关的数据结构。
接下来,我们具体看看如何使用策略模式将实现空格分配算法
第一步,为算法的调用提供一个封装接口
interface SentenceAssembler
{
String assembleSentence();
}
assembleSentence 返回一个空格已经分配好的行字符串,接着我们着手实现相关算法:
class SentenceAssemblerImp implements SentenceAssembler
{
protected ArrayList wordList = new ArrayList();
protected int spaceCount = 0;
private int totalLength = 0;
private int length = 0;
SentenceAssemblerImp(ArrayList wordList, int length)
{
this.wordList = wordList;
this.length = length;
computeTotalLength();
computeRemainingSpace();
}
private void computeTotalLength()
{
for (Word w : wordList)
{
totalLength += w.getLength();
}
}
private void computeRemainingSpace()
{
spaceCount = length - totalLength;
}
public String assembleSentence()
{
return null;
}
protected String makeSentence()
{
String sentence = "";
for (Word w : wordList)
{
sentence += w.getWord();
}
return sentence;
}
}
我们看到 SentenceAssemblerImp 还没有实现 assembleSentence 接口,这个类只是做了一些算法执行前的准备工作,例如计算可分配的多余空格,计算所有单词的总长度等等。由于每个实现函数都很短,所以无需注释,想必大家都可以读的懂,而且易于维护。下面的就是重头戏,也就是空格分配算法的实现
class NormalSentenceAssembler extends SentenceAssemblerImp
{
public NormalSentenceAssembler(ArrayList wordList, int length)
{
super(wordList, length);
}
@Override
public String assembleSentence()
{
int pos = 0;
while (spaceCount > 0)
{
if (pos >= wordList.size() - 1)
{
pos = 0;
}
Word w = wordList.get(pos);
w.increaseSpace(1);
spaceCount--;
pos++;
}
return makeSentence();
}
}
NormalSentenceAssembler 实现的空格分配算法是在单词的间隔平均分配,如果剩余空格不能在单词间平均分配,则左边的空格优先分配。
class LastSentenceAssembler extends SentenceAssemblerImp
{
public LastSentenceAssembler(ArrayList wordList, int length)
{
super(wordList, length);
}
@Override
public String assembleSentence()
{
Word lastWord = wordList.get(wordList.size() - 1);
lastWord.increaseSpace(spaceCount);
return makeSentence();
}
}
LastSentenceAssembler 实现的空格分配算法是,单词间以一个空格分开,多余的空格全部放在末尾。有了算法实现后我们就可以根据情况动态的替换空格分配算法了:
class Sentence
{
.......
public String getSentence()
{
SentenceAssembler sa = createSentenceAssembler();
return sa.assembleSentence();
}
private SentenceAssembler createSentenceAssembler()
{
SentenceAssembler sa = null;
if (last)
{
sa = new LastSentenceAssembler(wordList, length);
}
else
{
sa = new NormalSentenceAssembler(wordList, length);
}
return sa;
}
public void setLast()
{
last = true;
}
}
我们判断一下,如果当前行是最后一行,或者该行只能容纳一个单词,那么使用LastSentenceAssembler 实现的算法,如果当前行不是最后一行,那么使用
NormalSentenceAssembler 实现的空格分配算法, 策略模式可以实现算法的灵活替换,具备很好的可扩展性,试想如果需求的第四点改成空格优先分配给右边的间隔
那么我们只需要增加一个新的 SentenceAssembler 实现,然后将 NormalSentenceAssembler 替换掉就可以了,这种优势是原有面向过程的方法无法达到的。
最后,我们用一个类,将所有类成员组合起来实现算法需求:
class FullyJustify {
private String[] words;
private int length;
ArrayList sentenceList = new ArrayList();
public FullyJustify(String[] words, int length)
{
if (words == null || length <= 0)
{
return;
}
this.words = words;
this.length = length;
doJustify();
}
private void doJustify()
{
int index = 0;
Sentence se = null;
while (index < words.length)
{
if (se == null)
{
se = createSentence();
}
if (se.acceptWord(words[index]))
{
index++;
}
else
{
se = createSentence();
}
}
se.setLast();
}
private Sentence createSentence()
{
Sentence se = null;
se = new Sentence(length);
sentenceList.add(se);
return se;
}
public List justify() {
List l = new ArrayList();
if (length == 0)
{
l.add("");
}
else
{
for (Sentence s : sentenceList)
{
l.add(s.getSentence());
}
}
return l;
}
}
最后,算法的调用方式如下:
public class Solution {
public List fullJustify(String[] words, int L) {
FullyJustify fj = new FullyJustify(words, L);
return fj.justify();
}
}
大家看看,相比原来的方法,面向对象的分析技术和设计模式的使用是不是使得问题的解决过程更清晰,更易于表述和理解。
当然,面向对象的分析和设计模式也有相应的缺点,例如:
1. 类 和 对象 过多, 对象与对象间的逻辑联系复杂,增加了程序的复杂性
2. 相比于面向过程的方法,面向对象比较冗余,上面的代码量比面向过程的方法要多不少(也有我实现的不够精简的原因)
3. 设计模式往往给程序带来逻辑分层,导致程序不容易调试或理解。
但综合而言,面向对象的技术与面向过程的方法相比较,前者就如同白话文,后者如同文言文,使用面向对象的分析方法设计的算法或软件更容易维护和扩展。